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:
1 parent
46af982c40
commit
b3d42f6c50
9 files changed
+146
-27
No files matched your search
@@ -1,14 +1,12 @@
|
||||
import { EmailParser } from "../domain/email-parser";
|
||||
import { AttachmentData, EmailMetadata, Env } from "../types";
|
||||
import { notifySubscribers } from "../infrastructure/websub";
|
||||
import { bumpCounters } from "../application/stats";
|
||||
import {
|
||||
cacheFaviconForDomain,
|
||||
extractEmailDomain,
|
||||
} from "../infrastructure/favicon-fetcher";
|
||||
import { applyFeedEvents } from "../application/feed-events";
|
||||
import { extractEmailDomain } from "../infrastructure/favicon-fetcher";
|
||||
import { parseOneClickUnsubscribe } from "../infrastructure/unsubscribe";
|
||||
import { getAttachmentBucket } from "../infrastructure/attachments";
|
||||
import { FeedRepository } from "../infrastructure/feed-repository";
|
||||
import { BackgroundScheduler } from "../infrastructure/worker";
|
||||
import { Feed } from "../domain/feed.aggregate";
|
||||
import { logger } from "../infrastructure/logger";
|
||||
import { FEED_MAX_BYTES } from "../config/constants";
|
||||
@@ -183,12 +181,14 @@ async function storeEmail(
|
||||
]);
|
||||
|
||||
logger.info("Email processed", { feedId: feed.id.value });
|
||||
if (ctx) {
|
||||
ctx.waitUntil(notifySubscribers(feed.id.value, env));
|
||||
if (iconDomain) {
|
||||
ctx.waitUntil(cacheFaviconForDomain(iconDomain, env));
|
||||
}
|
||||
}
|
||||
|
||||
// The aggregate recorded an EmailIngested event; the dispatcher applies its
|
||||
// side effects (received counter, WebSub ping, favicon fetch). Background work
|
||||
// rides on ctx.waitUntil when present, and is skipped in its absence (tests).
|
||||
const schedule: BackgroundScheduler = ctx
|
||||
? (p) => ctx.waitUntil(p)
|
||||
: () => {};
|
||||
await applyFeedEvents(feed.id, feed.pullEvents(), env, schedule);
|
||||
}
|
||||
|
||||
export async function processEmail(
|
||||
@@ -203,9 +203,5 @@ export async function processEmail(
|
||||
}
|
||||
|
||||
await storeEmail(validation.feed, input, env, ctx);
|
||||
await bumpCounters(env.EMAIL_STORAGE, {
|
||||
emails_received: 1,
|
||||
last_email_at: new Date().toISOString(),
|
||||
});
|
||||
return { ok: true, feedId: validation.feed.id.value };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { createMockEnv } from "../test/setup";
|
||||
import { createFeedRecord, editFeed } from "./feed-service";
|
||||
import { getCounters } from "./stats";
|
||||
import type { Env } from "../types";
|
||||
|
||||
const mkEnv = (overrides: Partial<Env> = {}) =>
|
||||
@@ -42,6 +43,14 @@ describe("createFeedRecord — TTL policy", () => {
|
||||
// 1h (server) wins over 9999h (client).
|
||||
expect(config.expires_at!).toBeLessThan(before + TWO_HOURS);
|
||||
});
|
||||
|
||||
it("bumps the feeds_created counter via the FeedCreated domain event", async () => {
|
||||
const env = mkEnv();
|
||||
await createFeedRecord(env, { ...baseInput });
|
||||
const counters = await getCounters(env.EMAIL_STORAGE);
|
||||
expect(counters.feeds_created).toBe(1);
|
||||
expect(counters.last_feed_created_at).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("editFeed — TTL policy", () => {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Env, FeedConfig } from "../types";
|
||||
import { bumpCounters } from "../application/stats";
|
||||
import { applyFeedEvents } from "./feed-events";
|
||||
import { sendUnsubscribes } from "../infrastructure/unsubscribe";
|
||||
import { getAttachmentBucket } from "../infrastructure/attachments";
|
||||
import { FeedRepository } from "../infrastructure/feed-repository";
|
||||
import { BackgroundScheduler } from "../infrastructure/worker";
|
||||
import { FeedId } from "../domain/value-objects/feed-id";
|
||||
import {
|
||||
Feed,
|
||||
@@ -50,10 +52,8 @@ export async function createFeedRecord(
|
||||
feed.config.expires_at,
|
||||
);
|
||||
|
||||
await bumpCounters(env.EMAIL_STORAGE, {
|
||||
feeds_created: 1,
|
||||
last_feed_created_at: new Date().toISOString(),
|
||||
});
|
||||
// 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 };
|
||||
}
|
||||
@@ -162,13 +162,6 @@ export async function deleteFeedFastDetailed(
|
||||
return { ok: configDeleted, configDeleted, metadataDeleted, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedules a fire-and-forget background task. The HTTP edge passes an adapter
|
||||
* over `ctx.waitUntil` (e.g. `(p) => waitUntilSafe(c, p)`); keeping it a plain
|
||||
* function means the application layer never imports Hono's `Context`.
|
||||
*/
|
||||
export type BackgroundScheduler = (task: Promise<unknown>) => void;
|
||||
|
||||
/**
|
||||
* Delete a single feed end-to-end: capture unsubscribe URLs, drop its config +
|
||||
* metadata, remove it from the list, bump the counter, and hand the background
|
||||
|
||||
Reference in new issue
Block a user