mirror of
https://github.com/juherr/kill-the-news.git
synced 2026-06-20 22:03:48 +00:00
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:
@@ -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()`.
|
||||
|
||||
@@ -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
|
||||
@@ -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<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 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<boolean> {
|
||||
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),
|
||||
}),
|
||||
|
||||
@@ -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;
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user