refactor: tighten DDD boundaries on the Feed aggregate

Address five modeling tensions in one pass:

- Encapsulation: the Feed aggregate no longer exposes raw config/metadata
  (a shallow Readonly still leaked mutable arrays). It now offers
  intention-revealing accessors that return copies, plus
  toConfigSnapshot/toMetadataSnapshot for the repository and summary() for
  the global registry.
- feeds:list consistency: FeedRepository.save/saveConfig upsert the registry
  entry from feed.summary(), so services no longer mirror title/description/
  expiry by hand (the old add/updateInList footgun is gone).
- domain/feed.ts: drop the dead applySenderPolicy, internalise resolveExpiresAt
  and trimToByteBudget into the aggregate; feed.ts keeps only the shared
  isExpired predicate used by the read-model routes.
- Single edit path: remove editDetails; edit(patch, deps) is the sole config
  mutation, with a systematic expired guard. Renaming an expired feed now 403s.
- FeedId flows through the application and infrastructure signatures;
  fromTrusted/parse happen once at the edge, .value only at the serialisation
  boundaries (urls, feed-generator, feed-keys, logs, JSON).

347 tests green, tsc clean, Worker bundle builds.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
Julien HerrandClaude Opus 4.7 committed 2026-05-24 13:45:13 +02:00
1 parent b3d42f6c50
commit ad196f1761
24 files changed
+406 -398

No files matched your search

+2 -2
View File
@@ -95,8 +95,8 @@ async function loadAcceptingFeed(
logger.warn("Rejected email: sender filter", {
feedId: feedId.value,
senders: input.senders,
allowedSenders: feed.config.allowed_senders,
blockedSenders: feed.config.blocked_senders,
allowedSenders: feed.allowedSenders(),
blockedSenders: feed.blockedSenders(),
});
return { ok: false, reason: "sender_blocked" };
}
+8 -11
View File
@@ -9,7 +9,7 @@ import { FeedId } from "../domain/value-objects/feed-id";
// attachmentIds.
export async function deleteAttachmentsForEmails(
env: Env,
emails: EmailMetadata[],
emails: readonly EmailMetadata[],
keys: Iterable<string>,
): Promise<void> {
const keySet = new Set(keys);
@@ -58,16 +58,14 @@ export async function deleteKeysWithConcurrency(
*/
export async function collectUnsubscribeUrls(
emailStorage: KVNamespace,
feedId: string,
feedId: FeedId,
): Promise<string[]> {
try {
const metadata = await new FeedRepository(emailStorage).getMetadata(
FeedId.fromTrusted(feedId),
);
const metadata = await new FeedRepository(emailStorage).getMetadata(feedId);
return Object.values(metadata?.unsubscribe ?? {});
} catch (error) {
logger.error("Error reading unsubscribe URLs", {
feedId,
feedId: feedId.value,
error: String(error),
});
return [];
@@ -76,7 +74,7 @@ export async function collectUnsubscribeUrls(
export async function purgeFeedKeysStep(
emailStorage: KVNamespace,
feedId: string,
feedId: FeedId,
options: { cursor?: string; limit?: number; bucket?: R2Bucket } = {},
): Promise<{
deletedKeys: string[];
@@ -85,15 +83,14 @@ export async function purgeFeedKeysStep(
listComplete: boolean;
}> {
const repo = new FeedRepository(emailStorage);
const id = FeedId.fromTrusted(feedId);
const listed = await repo.listFeedKeys(id, {
const listed = await repo.listFeedKeys(feedId, {
cursor: options.cursor,
limit: options.limit,
});
const keys = listed.names;
if (options.bucket && keys.length > 0) {
const emailKeys = keys.filter((k) => repo.isEmailKey(id, k));
const emailKeys = keys.filter((k) => repo.isEmailKey(feedId, k));
if (emailKeys.length > 0) {
const emailDataResults = await Promise.allSettled(
emailKeys.map((k) => repo.getEmail(k)),
@@ -128,7 +125,7 @@ export async function purgeFeedKeysStep(
export async function purgeExpiredFeeds(
emailStorage: KVNamespace,
feedId: string,
feedId: FeedId,
bucket?: R2Bucket,
): Promise<void> {
let cursor: string | undefined;
+1 -1
View File
@@ -32,7 +32,7 @@ export async function applyFeedEvents(
emails_received: 1,
last_email_at: new Date().toISOString(),
});
schedule(notifySubscribers(feedId.value, env));
schedule(notifySubscribers(feedId, env));
if (event.iconDomain) {
schedule(cacheFaviconForDomain(event.iconDomain, env));
}
+4 -5
View File
@@ -9,17 +9,16 @@ export interface FeedData {
}
export async function fetchFeedData(
feedId: string,
feedId: FeedId,
env: Env,
): Promise<FeedData | null> {
const repo = FeedRepository.from(env);
const id = FeedId.fromTrusted(feedId);
const feedMetadata = await repo.getMetadata(id);
const feedMetadata = await repo.getMetadata(feedId);
if (!feedMetadata) return null;
const feedConfig = (await repo.getConfig(id)) ?? {
title: `Newsletter Feed ${feedId}`,
const feedConfig = (await repo.getConfig(feedId)) ?? {
title: `Newsletter Feed ${feedId.value}`,
description: "Converted email newsletter",
language: "en",
created_at: Date.now(),
+7 -2
View File
@@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest";
import { createMockEnv } from "../test/setup";
import { createFeedRecord, editFeed } from "./feed-service";
import { getCounters } from "./stats";
import { FeedId } from "../domain/value-objects/feed-id";
import type { Env } from "../types";
const mkEnv = (overrides: Partial<Env> = {}) =>
@@ -59,7 +60,9 @@ describe("editFeed — TTL policy", () => {
const { feedId } = await createFeedRecord(env, { ...baseInput });
const before = Date.now();
const result = await editFeed(env, feedId, { title: "renamed" });
const result = await editFeed(env, FeedId.fromTrusted(feedId), {
title: "renamed",
});
expect(result.status).toBe("ok");
if (result.status === "ok") {
@@ -75,7 +78,9 @@ describe("editFeed — TTL policy", () => {
lifetimeHours: 5,
});
const result = await editFeed(env, feedId, { title: "x" });
const result = await editFeed(env, FeedId.fromTrusted(feedId), {
title: "x",
});
expect(result.status).toBe("ok");
if (result.status === "ok") {
+19 -34
View File
@@ -45,17 +45,11 @@ export async function createFeedRecord(
});
await repo.save(feed);
await repo.addToList(
feed.id,
feed.config.title,
feed.config.description,
feed.config.expires_at,
);
// FeedCreated → bumps the feeds_created counter (no background work to schedule).
await applyFeedEvents(feed.id, feed.pullEvents(), env, () => {});
return { feedId: feed.id.value, config: feed.config };
return { feedId: feed.id.value, config: feed.toConfigSnapshot() };
}
export type UpdateFeedResult =
@@ -64,28 +58,26 @@ export type UpdateFeedResult =
| { status: "expired" };
/**
* In-place edit of title/description only — never touches expiry. Used by the
* dashboard's minimal edit. Mirrors the new title/description into the list.
* Quick-edit of title/description only — never recomputes expiry. Used by the
* dashboard's minimal edit. Delegates to the aggregate's single `edit` path, so
* an expired feed is rejected here too. The list projection is kept in sync by
* the repository on `saveConfig`.
*/
export async function editFeedDetails(
env: Env,
feedId: string,
feedId: FeedId,
patch: { title?: string; description?: string },
): Promise<UpdateFeedResult> {
const repo = FeedRepository.from(env);
const feed = await repo.load(FeedId.fromTrusted(feedId));
const feed = await repo.load(feedId);
if (!feed) return { status: "not_found" };
feed.editDetails(patch);
if (feed.edit(patch, { recomputeExpiry: false }).status === "expired") {
return { status: "expired" };
}
await repo.saveConfig(feed);
await repo.updateInList(
feed.id,
feed.config.title,
feed.config.description,
feed.config.expires_at,
);
return { status: "ok", config: feed.config };
return { status: "ok", config: feed.toConfigSnapshot() };
}
/**
@@ -94,11 +86,11 @@ export async function editFeedDetails(
*/
export async function editFeed(
env: Env,
feedId: string,
feedId: FeedId,
input: UpdateFeedInput,
): Promise<UpdateFeedResult> {
const repo = FeedRepository.from(env);
const feed = await repo.load(FeedId.fromTrusted(feedId));
const feed = await repo.load(feedId);
if (!feed) return { status: "not_found" };
const recomputeExpiry =
@@ -113,14 +105,8 @@ export async function editFeed(
}
await repo.saveConfig(feed);
await repo.updateInList(
feed.id,
feed.config.title,
feed.config.description,
feed.config.expires_at,
);
return { status: "ok", config: feed.config };
return { status: "ok", config: feed.toConfigSnapshot() };
}
type DeleteFeedFastResult = {
@@ -136,24 +122,23 @@ type DeleteFeedFastResult = {
*/
export async function deleteFeedFastDetailed(
emailStorage: KVNamespace,
feedId: string,
feedId: FeedId,
): Promise<DeleteFeedFastResult> {
const repo = new FeedRepository(emailStorage);
const id = FeedId.fromTrusted(feedId);
const errors: string[] = [];
let configDeleted = false;
let metadataDeleted = false;
try {
await repo.deleteConfig(id);
await repo.deleteConfig(feedId);
configDeleted = true;
} catch (error) {
errors.push(`config delete failed: ${String(error)}`);
}
try {
await repo.deleteMetadata(id);
await repo.deleteMetadata(feedId);
metadataDeleted = true;
} catch (error) {
errors.push(`metadata delete failed: ${String(error)}`);
@@ -170,7 +155,7 @@ export async function deleteFeedFastDetailed(
*/
export async function deleteFeedRecord(
env: Env,
feedId: string,
feedId: FeedId,
schedule: BackgroundScheduler,
): Promise<boolean> {
const emailStorage = env.EMAIL_STORAGE;
@@ -180,7 +165,7 @@ export async function deleteFeedRecord(
const unsubscribeUrls = await collectUnsubscribeUrls(emailStorage, feedId);
await deleteFeedFastDetailed(emailStorage, feedId);
const removed = await repo.removeFromList(FeedId.fromTrusted(feedId));
const removed = await repo.removeFromList(feedId);
if (removed) {
await bumpCounters(emailStorage, { feeds_deleted: 1 });
}