refactor: separate Feed domain state from persistence DTO

Move four DDD tensions on the Feed aggregate to ground:

- #1 The aggregate now holds a domain FeedState (camelCase) instead of the
  snake_case FeedConfig DTO; infrastructure/feed-mapper.ts owns the
  FeedState<->FeedConfig/FeedListItem translation as the sole snake_case site
  outside the HTTP edge.
- #3 Replace the edit() recomputeExpiry control flag with a Lifetime VO:
  passing a lifetime recomputes expiry, omitting it preserves the current one
  (the dashboard quick-edit path).
- #4 Domain events carry their own feedId; dispatchFeedEvents centralizes the
  drain+dispatch in the application layer (no more manual pullEvents at call
  sites), keeping infra->application dependency direction intact.
- #6 Rename FeedId.fromTrusted to FeedId.unchecked to make the absence of
  revalidation explicit.

Adds Lifetime + feed-mapper round-trip tests. 353 tests green, tsc clean,
wrangler dry-run OK. Docs (CLAUDE.md) synced.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
Julien HerrandClaude Opus 4.7 committed 2026-05-24 14:10:04 +02:00
1 parent d68a24867d
commit 06c436c36a
30 files changed
+413 -249

No files matched your search

+2 -2
View File
@@ -1,7 +1,7 @@
import { EmailParser } from "../domain/email-parser";
import { AttachmentData, EmailMetadata, Env } from "../types";
import { bumpCounters } from "../application/stats";
import { applyFeedEvents } from "../application/feed-events";
import { dispatchFeedEvents } from "../application/feed-events";
import { extractEmailDomain } from "../infrastructure/favicon-fetcher";
import { parseOneClickUnsubscribe } from "../infrastructure/unsubscribe";
import { getAttachmentBucket } from "../infrastructure/attachments";
@@ -188,7 +188,7 @@ async function storeEmail(
const schedule: BackgroundScheduler = ctx
? (p) => ctx.waitUntil(p)
: () => {};
await applyFeedEvents(feed.id, feed.pullEvents(), env, schedule);
await dispatchFeedEvents(feed, env, schedule);
}
export async function processEmail(
+20 -7
View File
@@ -1,6 +1,6 @@
import { Env } from "../types";
import { FeedEvent } from "../domain/events";
import { FeedId } from "../domain/value-objects/feed-id";
import { Feed } from "../domain/feed.aggregate";
import { BackgroundScheduler } from "../infrastructure/worker";
import { bumpCounters } from "./stats";
import { notifySubscribers } from "../infrastructure/websub";
@@ -8,13 +8,13 @@ import { cacheFaviconForDomain } from "../infrastructure/favicon-fetcher";
/**
* Apply the side effects of a feed's domain events — the single place that maps
* "what happened" (FeedCreated, EmailIngested) to its consequences. Counter
* writes are awaited (they must land); WebSub pings and favicon fetches are
* handed to the caller's background scheduler (`ctx.waitUntil` at the edge, a
* no-op when none is available).
* "what happened" (FeedCreated, EmailIngested) to its consequences. Each event
* carries its own `feedId`, so nothing has to be threaded in. Counter writes are
* awaited (they must land); WebSub pings and favicon fetches are handed to the
* caller's background scheduler (`ctx.waitUntil` at the edge, a no-op when none
* is available).
*/
export async function applyFeedEvents(
feedId: FeedId,
events: FeedEvent[],
env: Env,
schedule: BackgroundScheduler,
@@ -32,7 +32,7 @@ export async function applyFeedEvents(
emails_received: 1,
last_email_at: new Date().toISOString(),
});
schedule(notifySubscribers(feedId, env));
schedule(notifySubscribers(event.feedId, env));
if (event.iconDomain) {
schedule(cacheFaviconForDomain(event.iconDomain, env));
}
@@ -40,3 +40,16 @@ export async function applyFeedEvents(
}
}
}
/**
* Drain a freshly-persisted aggregate's events and apply their side effects. The
* single dispatch entry point: callers persist the `Feed`, then call this — no
* caller pulls events or passes the feed id by hand.
*/
export async function dispatchFeedEvents(
feed: Feed,
env: Env,
schedule: BackgroundScheduler,
): Promise<void> {
await applyFeedEvents(feed.pullEvents(), env, schedule);
}
+2 -2
View File
@@ -60,7 +60,7 @@ describe("editFeed — TTL policy", () => {
const { feedId } = await createFeedRecord(env, { ...baseInput });
const before = Date.now();
const result = await editFeed(env, FeedId.fromTrusted(feedId), {
const result = await editFeed(env, FeedId.unchecked(feedId), {
title: "renamed",
});
@@ -78,7 +78,7 @@ describe("editFeed — TTL policy", () => {
lifetimeHours: 5,
});
const result = await editFeed(env, FeedId.fromTrusted(feedId), {
const result = await editFeed(env, FeedId.unchecked(feedId), {
title: "x",
});
+23 -22
View File
@@ -1,11 +1,13 @@
import { Env, FeedConfig } from "../types";
import { bumpCounters } from "../application/stats";
import { applyFeedEvents } from "./feed-events";
import { dispatchFeedEvents } from "./feed-events";
import { sendUnsubscribes } from "../infrastructure/unsubscribe";
import { getAttachmentBucket } from "../infrastructure/attachments";
import { FeedRepository } from "../infrastructure/feed-repository";
import { toConfigDTO } from "../infrastructure/feed-mapper";
import { BackgroundScheduler } from "../infrastructure/worker";
import { FeedId } from "../domain/value-objects/feed-id";
import { Lifetime } from "../domain/value-objects/lifetime";
import {
Feed,
CreateFeedInput,
@@ -16,19 +18,18 @@ import { purgeFeedKeysStep, collectUnsubscribeUrls } from "./feed-cleanup";
export type { CreateFeedInput, UpdateFeedInput };
/**
* Resolve the effective feed lifetime (hours) from a client request and the
* Resolve the effective feed `Lifetime` from a client request and the
* server-side `FEED_TTL_HOURS` override. Parsing the env string and applying the
* override is application/config policy — the domain only receives the resolved
* number. Returns undefined when the feed should never expire.
* VO. Returns `Lifetime.never` when the feed should never expire.
*/
function resolveTtlHours(
env: Env,
requestedHours?: number,
): number | undefined {
function resolveLifetime(env: Env, requestedHours?: number): Lifetime {
const hours = env.FEED_TTL_HOURS
? parseInt(env.FEED_TTL_HOURS, 10)
: (requestedHours ?? NaN);
return Number.isFinite(hours) && hours > 0 ? hours : undefined;
return Number.isFinite(hours) && hours > 0
? Lifetime.ofHours(hours)
: Lifetime.never;
}
/**
@@ -41,15 +42,15 @@ export async function createFeedRecord(
): Promise<{ feedId: string; config: FeedConfig }> {
const repo = FeedRepository.from(env);
const feed = Feed.create(FeedId.generate(), input, {
ttlHours: resolveTtlHours(env, input.lifetimeHours),
lifetime: resolveLifetime(env, input.lifetimeHours),
});
await repo.save(feed);
// FeedCreated → bumps the feeds_created counter (no background work to schedule).
await applyFeedEvents(feed.id, feed.pullEvents(), env, () => {});
await dispatchFeedEvents(feed, env, () => {});
return { feedId: feed.id.value, config: feed.toConfigSnapshot() };
return { feedId: feed.id.value, config: toConfigDTO(feed.state()) };
}
export type UpdateFeedResult =
@@ -72,12 +73,13 @@ export async function editFeedDetails(
const feed = await repo.load(feedId);
if (!feed) return { status: "not_found" };
if (feed.edit(patch, { recomputeExpiry: false }).status === "expired") {
// No lifetime passed ⇒ expiry preserved (quick-edit never recomputes it).
if (feed.edit(patch).status === "expired") {
return { status: "expired" };
}
await repo.saveConfig(feed);
return { status: "ok", config: feed.toConfigSnapshot() };
return { status: "ok", config: toConfigDTO(feed.state()) };
}
/**
@@ -93,20 +95,19 @@ export async function editFeed(
const feed = await repo.load(feedId);
if (!feed) return { status: "not_found" };
const recomputeExpiry =
Boolean(env.FEED_TTL_HOURS) || input.lifetimeHours !== undefined;
if (
feed.edit(input, {
recomputeExpiry,
ttlHours: resolveTtlHours(env, input.lifetimeHours),
}).status === "expired"
) {
// Recompute expiry only when a server TTL or a client lifetime applies;
// otherwise pass no lifetime so the aggregate preserves the current expiry.
const lifetime =
Boolean(env.FEED_TTL_HOURS) || input.lifetimeHours !== undefined
? resolveLifetime(env, input.lifetimeHours)
: undefined;
if (feed.edit(input, { lifetime }).status === "expired") {
return { status: "expired" };
}
await repo.saveConfig(feed);
return { status: "ok", config: feed.toConfigSnapshot() };
return { status: "ok", config: toConfigDTO(feed.state()) };
}
type DeleteFeedFastResult = {
+1 -1
View File
@@ -110,7 +110,7 @@ export async function scanKvUsage(kv: KVNamespace): Promise<{ bytes: number }> {
const repo = new FeedRepository(kv);
const feeds = await repo.listFeeds();
for (const feed of feeds) {
const metadata = await repo.getMetadata(FeedId.fromTrusted(feed.id));
const metadata = await repo.getMetadata(FeedId.unchecked(feed.id));
if (!metadata) continue;
for (const email of metadata.emails) {
bytes += email.size ?? 0;