diff --git a/CLAUDE.md b/CLAUDE.md index 5c23af5..abeffd6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,7 +65,8 @@ src/ format.ts # Pure formatting helpers (formatBytes) value-objects/ # FeedId, EmailAddress, Domain, SenderPolicy (immutable, self-validating) application/ # Use-cases / orchestration (wires domain + infrastructure) - feed-service.ts # createFeedRecord / renameFeed / editFeed / deleteFeedRecord (admin UI + REST API) + feed-service.ts # createFeedRecord / editFeedDetails / editFeed / deleteFeedRecord (admin UI + REST API) + feed-cleanup.ts # Feed/email storage cleanup: purgeFeedKeysStep, collectUnsubscribeUrls, attachment+key deletion email-processor.ts # Core ingestion: load aggregate → accepts? → feed.ingest → persist feed-fetcher.ts # Read model for RSS/Atom rendering (config + email bodies; bypasses the aggregate) stats.ts # Monitoring counters increment policy + storage scans @@ -136,7 +137,7 @@ The KV key schema lives in `src/domain/feed-keys.ts` (pure, framework-agnostic) ### Domain & layering rules - **Layers**: `domain/` is framework-agnostic (no Hono). `application/` orchestrates use-cases. `infrastructure/` holds adapters (KV/R2, HTTP, logging). `routes/` is the HTTP edge. Imports point inward: routes → application → domain; infrastructure implements ports the inner layers call. -- **The `Feed` aggregate is the only writer of feed config + the email index.** Load it with `FeedRepository.load(feedId)`, mutate via its methods (`ingest`, `removeEmails`, `rename`, `edit`), then persist with `save`/`saveMetadata`/`saveConfig`. No route or service mutates `metadata.emails` directly. Email **bodies** are large blobs outside the aggregate — flush them (`putEmail`/`deleteEmail`) alongside the metadata save. +- **The `Feed` aggregate is the only writer of feed config + the email index.** Load it with `FeedRepository.load(feedId)`, mutate via its methods (`ingest`, `removeEmails`, `editDetails`, `edit`), then persist with `save`/`saveMetadata`/`saveConfig`. No route or service mutates `metadata.emails` directly. Email **bodies** are large blobs outside the aggregate — flush them (`putEmail`/`deleteEmail`) alongside the metadata save. - Read-only RSS/Atom rendering uses the `feed-fetcher` read model, not the aggregate (no invariant to enforce on the hot path). - KV has no multi-key transaction; the aggregate is the seam a future Durable Object would wrap to serialise concurrent ingests (see `email-processor.ts`). - **`FeedId`** is the type used by the domain (`Feed.id`) and every single-feed `FeedRepository` method. Wrap a raw id string with `FeedId.fromTrusted(value)` at the call site; keep `.value` (string) for URLs, logs, JSON and the feed-list registry. Mint new ids with `FeedId.generate()`. diff --git a/src/routes/admin/helpers.ts b/src/application/feed-cleanup.ts similarity index 92% rename from src/routes/admin/helpers.ts rename to src/application/feed-cleanup.ts index 10b59f1..bd8a755 100644 --- a/src/routes/admin/helpers.ts +++ b/src/application/feed-cleanup.ts @@ -1,8 +1,8 @@ -import { EmailData, EmailMetadata, Env } from "../../types"; -import { logger } from "../../infrastructure/logger"; -import { getAttachmentBucket } from "../../infrastructure/attachments"; -import { FeedRepository } from "../../infrastructure/feed-repository"; -import { FeedId } from "../../domain/value-objects/feed-id"; +import { EmailData, EmailMetadata, Env } from "../types"; +import { logger } from "../infrastructure/logger"; +import { getAttachmentBucket } from "../infrastructure/attachments"; +import { FeedRepository } from "../infrastructure/feed-repository"; +import { FeedId } from "../domain/value-objects/feed-id"; // Delete the R2 attachments belonging to the given email keys. Call before the // emails are removed from feed metadata, while `emails` still carries their diff --git a/src/application/feed-service.ts b/src/application/feed-service.ts index a92f446..e58559b 100644 --- a/src/application/feed-service.ts +++ b/src/application/feed-service.ts @@ -1,7 +1,5 @@ -import { Context } from "hono"; import { Env, FeedConfig } from "../types"; import { bumpCounters } from "../application/stats"; -import { waitUntilSafe } from "../infrastructure/worker"; import { sendUnsubscribes } from "../infrastructure/unsubscribe"; import { getAttachmentBucket } from "../infrastructure/attachments"; import { FeedRepository } from "../infrastructure/feed-repository"; @@ -11,10 +9,7 @@ import { CreateFeedInput, UpdateFeedInput, } from "../domain/feed.aggregate"; -import { - purgeFeedKeysStep, - collectUnsubscribeUrls, -} from "../routes/admin/helpers"; +import { purgeFeedKeysStep, collectUnsubscribeUrls } from "./feed-cleanup"; export type { CreateFeedInput, UpdateFeedInput }; @@ -72,7 +67,7 @@ export type UpdateFeedResult = * 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. */ -export async function renameFeed( +export async function editFeedDetails( env: Env, feedId: string, patch: { title?: string; description?: string }, @@ -81,7 +76,7 @@ export async function renameFeed( const feed = await repo.load(FeedId.fromTrusted(feedId)); if (!feed) return { status: "not_found" }; - feed.rename(patch); + feed.editDetails(patch); await repo.saveConfig(feed); await repo.updateInList( feed.id, @@ -167,16 +162,23 @@ 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) => void; + /** * Delete a single feed end-to-end: capture unsubscribe URLs, drop its config + - * metadata, remove it from the list, bump the counter, and schedule background - * unsubscribe requests + key purge via ctx.waitUntil. Returns whether the feed - * was present in the global list. + * metadata, remove it from the list, bump the counter, and hand the background + * unsubscribe requests + key purge to the supplied scheduler. Returns whether + * the feed was present in the global list. */ export async function deleteFeedRecord( - c: Context<{ Bindings: Env }>, env: Env, feedId: string, + schedule: BackgroundScheduler, ): Promise { const emailStorage = env.EMAIL_STORAGE; const repo = new FeedRepository(emailStorage); @@ -191,11 +193,10 @@ export async function deleteFeedRecord( } if (unsubscribeUrls.length > 0) { - waitUntilSafe(c, sendUnsubscribes(unsubscribeUrls, env)); + schedule(sendUnsubscribes(unsubscribeUrls, env)); } - waitUntilSafe( - c, + schedule( purgeFeedKeysStep(emailStorage, feedId, { bucket: getAttachmentBucket(env), }), diff --git a/src/domain/feed.aggregate.ts b/src/domain/feed.aggregate.ts index ccef079..6c4b0af 100644 --- a/src/domain/feed.aggregate.ts +++ b/src/domain/feed.aggregate.ts @@ -164,10 +164,10 @@ export class Feed { } /** - * In-place edit of the presentational fields only. Never touches expiry or the - * sender policy — used by the dashboard's minimal title/description edit. + * In-place edit of the presentational fields only (title + description). Never + * touches expiry or the sender policy — used by the dashboard's minimal edit. */ - rename(patch: { title?: string; description?: string }): void { + editDetails(patch: { title?: string; description?: string }): void { if (patch.title !== undefined) this._config.title = patch.title; if (patch.description !== undefined) { this._config.description = patch.description; diff --git a/src/index.ts b/src/index.ts index 0e968ba..e5c5bdd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,7 +14,7 @@ import { handleCloudflareEmail } from "./infrastructure/cloudflare-email"; import { Env } from "./types"; import { logger } from "./infrastructure/logger"; import { FeedRepository } from "./infrastructure/feed-repository"; -import { purgeExpiredFeeds } from "./routes/admin/helpers"; +import { purgeExpiredFeeds } from "./application/feed-cleanup"; import { bumpCounters, scanR2Usage, diff --git a/src/routes/admin.tsx b/src/routes/admin.tsx index 3f2269e..8c545fe 100644 --- a/src/routes/admin.tsx +++ b/src/routes/admin.tsx @@ -9,7 +9,7 @@ import { logger } from "../infrastructure/logger"; import { timingSafeEqual, checkProxyAuth } from "../infrastructure/auth"; import { Layout, clampText } from "./admin/ui"; import { FeedRepository } from "../infrastructure/feed-repository"; -import { renameFeed } from "../application/feed-service"; +import { editFeedDetails } from "../application/feed-service"; import { feedRssUrl, feedAtomUrl, @@ -997,7 +997,7 @@ app.post( const { title, description } = c.req.valid("json"); // In-place edit: only title/description, expiry untouched. - const result = await renameFeed(env, feedId, { title, description }); + const result = await editFeedDetails(env, feedId, { title, description }); if (result.status === "not_found") { return c.json({ error: "Feed not found" }, 404); diff --git a/src/routes/admin/emails.tsx b/src/routes/admin/emails.tsx index 9f64aff..c1eedf2 100644 --- a/src/routes/admin/emails.tsx +++ b/src/routes/admin/emails.tsx @@ -5,7 +5,7 @@ import { Layout, clampText } from "./ui"; import { deleteAttachmentsForEmails, deleteKeysWithConcurrency, -} from "./helpers"; +} from "../../application/feed-cleanup"; import { FeedRepository } from "../../infrastructure/feed-repository"; import { FeedId } from "../../domain/value-objects/feed-id"; import { diff --git a/src/routes/admin/feeds.tsx b/src/routes/admin/feeds.tsx index a7ffb4b..f5e67b2 100644 --- a/src/routes/admin/feeds.tsx +++ b/src/routes/admin/feeds.tsx @@ -8,7 +8,10 @@ import { logger } from "../../infrastructure/logger"; import { sendUnsubscribes } from "../../infrastructure/unsubscribe"; import { getAttachmentBucket } from "../../infrastructure/attachments"; import { Layout } from "./ui"; -import { purgeFeedKeysStep, collectUnsubscribeUrls } from "./helpers"; +import { + purgeFeedKeysStep, + collectUnsubscribeUrls, +} from "../../application/feed-cleanup"; import { FeedRepository } from "../../infrastructure/feed-repository"; import { FeedId } from "../../domain/value-objects/feed-id"; import { @@ -419,7 +422,7 @@ feedsRouter.post("/:feedId/delete", async (c) => { const wantsJson = (c.req.header("Accept") || "").includes("application/json"); try { - await deleteFeedRecord(c, env, feedId); + await deleteFeedRecord(env, feedId, (p) => waitUntilSafe(c, p)); if (wantsJson) { return c.json({ ok: true, feedId }); diff --git a/src/routes/api/index.ts b/src/routes/api/index.ts index b369a10..5b38b6a 100644 --- a/src/routes/api/index.ts +++ b/src/routes/api/index.ts @@ -8,7 +8,8 @@ import { editFeed, deleteFeedRecord, } from "../../application/feed-service"; -import { deleteAttachmentsForEmails } from "../admin/helpers"; +import { deleteAttachmentsForEmails } from "../../application/feed-cleanup"; +import { waitUntilSafe } from "../../infrastructure/worker"; import { FeedRepository } from "../../infrastructure/feed-repository"; import { FeedId } from "../../domain/value-objects/feed-id"; import { getStats } from "../../application/stats"; @@ -248,7 +249,9 @@ apiApp.openapi( async (c) => { const env = c.env; const { feedId } = c.req.valid("param"); - const removed = await deleteFeedRecord(c, env, feedId); + const removed = await deleteFeedRecord(env, feedId, (p) => + waitUntilSafe(c, p), + ); if (!removed) return c.json({ error: "Feed not found" }, 404); return c.json({ ok: true }, 200); },