refactor: invert application↔routes boundary (Track B — points 3, 6a)

- Point 3: move the feed/email storage-cleanup helpers (purgeFeedKeysStep,
  collectUnsubscribeUrls, purgeExpiredFeeds, deleteKeysWithConcurrency,
  deleteAttachmentsForEmails) out of routes/admin/helpers.ts into
  src/application/feed-cleanup.ts, so the application layer no longer imports
  from routes/. deleteFeedRecord no longer takes a Hono Context: it accepts a
  BackgroundScheduler ((task) => void) and the HTTP edge passes
  (p) => waitUntilSafe(c, p). Application/domain are now Hono-Context-free.
- Point 6a: rename the misleadingly-named Feed.rename → Feed.editDetails (it
  edits title + description), and feed-service.renameFeed → editFeedDetails.

CLAUDE.md source layout updated. 351 tests pass; tsc --noEmit clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Julien Herr
2026-05-24 10:05:21 +02:00
parent f823a5f222
commit 46af982c40
9 changed files with 41 additions and 33 deletions
+3 -2
View File
@@ -65,7 +65,8 @@ src/
format.ts # Pure formatting helpers (formatBytes) format.ts # Pure formatting helpers (formatBytes)
value-objects/ # FeedId, EmailAddress, Domain, SenderPolicy (immutable, self-validating) value-objects/ # FeedId, EmailAddress, Domain, SenderPolicy (immutable, self-validating)
application/ # Use-cases / orchestration (wires domain + infrastructure) 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 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) feed-fetcher.ts # Read model for RSS/Atom rendering (config + email bodies; bypasses the aggregate)
stats.ts # Monitoring counters increment policy + storage scans 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 ### 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. - **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). - 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`). - 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()`. - **`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()`.
@@ -1,8 +1,8 @@
import { EmailData, EmailMetadata, Env } from "../../types"; import { EmailData, EmailMetadata, Env } from "../types";
import { logger } from "../../infrastructure/logger"; import { logger } from "../infrastructure/logger";
import { getAttachmentBucket } from "../../infrastructure/attachments"; import { getAttachmentBucket } from "../infrastructure/attachments";
import { FeedRepository } from "../../infrastructure/feed-repository"; import { FeedRepository } from "../infrastructure/feed-repository";
import { FeedId } from "../../domain/value-objects/feed-id"; import { FeedId } from "../domain/value-objects/feed-id";
// Delete the R2 attachments belonging to the given email keys. Call before the // Delete the R2 attachments belonging to the given email keys. Call before the
// emails are removed from feed metadata, while `emails` still carries their // emails are removed from feed metadata, while `emails` still carries their
+16 -15
View File
@@ -1,7 +1,5 @@
import { Context } from "hono";
import { Env, FeedConfig } from "../types"; import { Env, FeedConfig } from "../types";
import { bumpCounters } from "../application/stats"; import { bumpCounters } from "../application/stats";
import { waitUntilSafe } from "../infrastructure/worker";
import { sendUnsubscribes } from "../infrastructure/unsubscribe"; import { sendUnsubscribes } from "../infrastructure/unsubscribe";
import { getAttachmentBucket } from "../infrastructure/attachments"; import { getAttachmentBucket } from "../infrastructure/attachments";
import { FeedRepository } from "../infrastructure/feed-repository"; import { FeedRepository } from "../infrastructure/feed-repository";
@@ -11,10 +9,7 @@ import {
CreateFeedInput, CreateFeedInput,
UpdateFeedInput, UpdateFeedInput,
} from "../domain/feed.aggregate"; } from "../domain/feed.aggregate";
import { import { purgeFeedKeysStep, collectUnsubscribeUrls } from "./feed-cleanup";
purgeFeedKeysStep,
collectUnsubscribeUrls,
} from "../routes/admin/helpers";
export type { CreateFeedInput, UpdateFeedInput }; export type { CreateFeedInput, UpdateFeedInput };
@@ -72,7 +67,7 @@ export type UpdateFeedResult =
* In-place edit of title/description only — never touches expiry. Used by the * 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. * dashboard's minimal edit. Mirrors the new title/description into the list.
*/ */
export async function renameFeed( export async function editFeedDetails(
env: Env, env: Env,
feedId: string, feedId: string,
patch: { title?: string; description?: string }, patch: { title?: string; description?: string },
@@ -81,7 +76,7 @@ export async function renameFeed(
const feed = await repo.load(FeedId.fromTrusted(feedId)); const feed = await repo.load(FeedId.fromTrusted(feedId));
if (!feed) return { status: "not_found" }; if (!feed) return { status: "not_found" };
feed.rename(patch); feed.editDetails(patch);
await repo.saveConfig(feed); await repo.saveConfig(feed);
await repo.updateInList( await repo.updateInList(
feed.id, feed.id,
@@ -167,16 +162,23 @@ export async function deleteFeedFastDetailed(
return { ok: configDeleted, configDeleted, metadataDeleted, errors }; 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 + * 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 * metadata, remove it from the list, bump the counter, and hand the background
* unsubscribe requests + key purge via ctx.waitUntil. Returns whether the feed * unsubscribe requests + key purge to the supplied scheduler. Returns whether
* was present in the global list. * the feed was present in the global list.
*/ */
export async function deleteFeedRecord( export async function deleteFeedRecord(
c: Context<{ Bindings: Env }>,
env: Env, env: Env,
feedId: string, feedId: string,
schedule: BackgroundScheduler,
): Promise<boolean> { ): Promise<boolean> {
const emailStorage = env.EMAIL_STORAGE; const emailStorage = env.EMAIL_STORAGE;
const repo = new FeedRepository(emailStorage); const repo = new FeedRepository(emailStorage);
@@ -191,11 +193,10 @@ export async function deleteFeedRecord(
} }
if (unsubscribeUrls.length > 0) { if (unsubscribeUrls.length > 0) {
waitUntilSafe(c, sendUnsubscribes(unsubscribeUrls, env)); schedule(sendUnsubscribes(unsubscribeUrls, env));
} }
waitUntilSafe( schedule(
c,
purgeFeedKeysStep(emailStorage, feedId, { purgeFeedKeysStep(emailStorage, feedId, {
bucket: getAttachmentBucket(env), bucket: getAttachmentBucket(env),
}), }),
+3 -3
View File
@@ -164,10 +164,10 @@ export class Feed {
} }
/** /**
* In-place edit of the presentational fields only. Never touches expiry or the * In-place edit of the presentational fields only (title + description). Never
* sender policy — used by the dashboard's minimal title/description edit. * 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.title !== undefined) this._config.title = patch.title;
if (patch.description !== undefined) { if (patch.description !== undefined) {
this._config.description = patch.description; this._config.description = patch.description;
+1 -1
View File
@@ -14,7 +14,7 @@ import { handleCloudflareEmail } from "./infrastructure/cloudflare-email";
import { Env } from "./types"; import { Env } from "./types";
import { logger } from "./infrastructure/logger"; import { logger } from "./infrastructure/logger";
import { FeedRepository } from "./infrastructure/feed-repository"; import { FeedRepository } from "./infrastructure/feed-repository";
import { purgeExpiredFeeds } from "./routes/admin/helpers"; import { purgeExpiredFeeds } from "./application/feed-cleanup";
import { import {
bumpCounters, bumpCounters,
scanR2Usage, scanR2Usage,
+2 -2
View File
@@ -9,7 +9,7 @@ import { logger } from "../infrastructure/logger";
import { timingSafeEqual, checkProxyAuth } from "../infrastructure/auth"; import { timingSafeEqual, checkProxyAuth } from "../infrastructure/auth";
import { Layout, clampText } from "./admin/ui"; import { Layout, clampText } from "./admin/ui";
import { FeedRepository } from "../infrastructure/feed-repository"; import { FeedRepository } from "../infrastructure/feed-repository";
import { renameFeed } from "../application/feed-service"; import { editFeedDetails } from "../application/feed-service";
import { import {
feedRssUrl, feedRssUrl,
feedAtomUrl, feedAtomUrl,
@@ -997,7 +997,7 @@ app.post(
const { title, description } = c.req.valid("json"); const { title, description } = c.req.valid("json");
// In-place edit: only title/description, expiry untouched. // 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") { if (result.status === "not_found") {
return c.json({ error: "Feed not found" }, 404); return c.json({ error: "Feed not found" }, 404);
+1 -1
View File
@@ -5,7 +5,7 @@ import { Layout, clampText } from "./ui";
import { import {
deleteAttachmentsForEmails, deleteAttachmentsForEmails,
deleteKeysWithConcurrency, deleteKeysWithConcurrency,
} from "./helpers"; } from "../../application/feed-cleanup";
import { FeedRepository } from "../../infrastructure/feed-repository"; import { FeedRepository } from "../../infrastructure/feed-repository";
import { FeedId } from "../../domain/value-objects/feed-id"; import { FeedId } from "../../domain/value-objects/feed-id";
import { import {
+5 -2
View File
@@ -8,7 +8,10 @@ import { logger } from "../../infrastructure/logger";
import { sendUnsubscribes } from "../../infrastructure/unsubscribe"; import { sendUnsubscribes } from "../../infrastructure/unsubscribe";
import { getAttachmentBucket } from "../../infrastructure/attachments"; import { getAttachmentBucket } from "../../infrastructure/attachments";
import { Layout } from "./ui"; import { Layout } from "./ui";
import { purgeFeedKeysStep, collectUnsubscribeUrls } from "./helpers"; import {
purgeFeedKeysStep,
collectUnsubscribeUrls,
} from "../../application/feed-cleanup";
import { FeedRepository } from "../../infrastructure/feed-repository"; import { FeedRepository } from "../../infrastructure/feed-repository";
import { FeedId } from "../../domain/value-objects/feed-id"; import { FeedId } from "../../domain/value-objects/feed-id";
import { import {
@@ -419,7 +422,7 @@ feedsRouter.post("/:feedId/delete", async (c) => {
const wantsJson = (c.req.header("Accept") || "").includes("application/json"); const wantsJson = (c.req.header("Accept") || "").includes("application/json");
try { try {
await deleteFeedRecord(c, env, feedId); await deleteFeedRecord(env, feedId, (p) => waitUntilSafe(c, p));
if (wantsJson) { if (wantsJson) {
return c.json({ ok: true, feedId }); return c.json({ ok: true, feedId });
+5 -2
View File
@@ -8,7 +8,8 @@ import {
editFeed, editFeed,
deleteFeedRecord, deleteFeedRecord,
} from "../../application/feed-service"; } 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 { FeedRepository } from "../../infrastructure/feed-repository";
import { FeedId } from "../../domain/value-objects/feed-id"; import { FeedId } from "../../domain/value-objects/feed-id";
import { getStats } from "../../application/stats"; import { getStats } from "../../application/stats";
@@ -248,7 +249,9 @@ apiApp.openapi(
async (c) => { async (c) => {
const env = c.env; const env = c.env;
const { feedId } = c.req.valid("param"); 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); if (!removed) return c.json({ error: "Feed not found" }, 404);
return c.json({ ok: true }, 200); return c.json({ ok: true }, 200);
}, },