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:
1 parent
d68a24867d
commit
06c436c36a
30 files changed
+413
-249
No files matched your search
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user