refactor: introduce domain events for feed side effects (Track E — point 5)

Light "collect + dispatch" variant: the Feed aggregate records FeedEvents
(FeedCreated, EmailIngested) on the mutations that have consequences, exposed via
pullEvents(). A new application dispatcher (feed-events.applyFeedEvents) maps
those events to their side effects — counters (awaited) plus WebSub pings and
favicon fetches handed to a BackgroundScheduler. This removes the inline,
scattered side effects from the ingest hot path (email-processor) and from
createFeedRecord; the aggregate is now the source of truth for "what happened".

Side effects with no aggregate mutation (rejected email, feed deletion bypassing
the aggregate, bulk admin ops, the cron, unsubscribes-sent) stay imperative by
design — there is no aggregate event for them to ride on.

BackgroundScheduler type moved to infrastructure/worker.ts (shared). CLAUDE.md
updated. 355 tests pass (+4 event tests); tsc --noEmit clean.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
Julien HerrandClaude Opus 4.7 committed 2026-05-24 13:12:42 +02:00
1 parent 46af982c40
commit b3d42f6c50
9 files changed
+146 -27

No files matched your search

+42
View File
@@ -0,0 +1,42 @@
import { Env } from "../types";
import { FeedEvent } from "../domain/events";
import { FeedId } from "../domain/value-objects/feed-id";
import { BackgroundScheduler } from "../infrastructure/worker";
import { bumpCounters } from "./stats";
import { notifySubscribers } from "../infrastructure/websub";
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).
*/
export async function applyFeedEvents(
feedId: FeedId,
events: FeedEvent[],
env: Env,
schedule: BackgroundScheduler,
): Promise<void> {
for (const event of events) {
switch (event.type) {
case "FeedCreated":
await bumpCounters(env.EMAIL_STORAGE, {
feeds_created: 1,
last_feed_created_at: new Date().toISOString(),
});
break;
case "EmailIngested":
await bumpCounters(env.EMAIL_STORAGE, {
emails_received: 1,
last_email_at: new Date().toISOString(),
});
schedule(notifySubscribers(feedId.value, env));
if (event.iconDomain) {
schedule(cacheFaviconForDomain(event.iconDomain, env));
}
break;
}
}
}