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 <[email protected]>
This commit is contained in:
Julien HerrandClaude Opus 4.7 committed 2026-05-24 10:05:21 +02:00
1 parent f823a5f222
commit 46af982c40
9 files changed
+41 -33

No files matched your search

+143
View File
@@ -0,0 +1,143 @@
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
// attachmentIds.
export async function deleteAttachmentsForEmails(
env: Env,
emails: EmailMetadata[],
keys: Iterable<string>,
): Promise<void> {
const keySet = new Set(keys);
const attachmentIds = emails
.filter((e) => keySet.has(e.key))
.flatMap((e) => e.attachmentIds ?? []);
if (attachmentIds.length === 0) return;
const bucket = getAttachmentBucket(env);
if (!bucket) return;
await Promise.allSettled(attachmentIds.map((id) => bucket.delete(id)));
}
export async function deleteKeysWithConcurrency(
emailStorage: KVNamespace,
keys: string[],
concurrency: number,
): Promise<{ ok: string[]; failed: string[] }> {
const uniqueKeys = Array.from(new Set(keys.filter(Boolean)));
const ok: string[] = [];
const failed: string[] = [];
const limit = Math.max(1, Math.floor(concurrency) || 1);
for (let i = 0; i < uniqueKeys.length; i += limit) {
const batch = uniqueKeys.slice(i, i + limit);
const results = await Promise.allSettled(
batch.map((key) => emailStorage.delete(key)),
);
results.forEach((result, idx) => {
const key = batch[idx];
if (result.status === "fulfilled") {
ok.push(key);
} else {
failed.push(key);
}
});
}
return { ok, failed };
}
/**
* Read a feed's stored RFC 8058 one-click unsubscribe URLs (one per sender).
* Must be called before the feed metadata is deleted. Never throws.
*/
export async function collectUnsubscribeUrls(
emailStorage: KVNamespace,
feedId: string,
): Promise<string[]> {
try {
const metadata = await new FeedRepository(emailStorage).getMetadata(
FeedId.fromTrusted(feedId),
);
return Object.values(metadata?.unsubscribe ?? {});
} catch (error) {
logger.error("Error reading unsubscribe URLs", {
feedId,
error: String(error),
});
return [];
}
}
export async function purgeFeedKeysStep(
emailStorage: KVNamespace,
feedId: string,
options: { cursor?: string; limit?: number; bucket?: R2Bucket } = {},
): Promise<{
deletedKeys: string[];
failedKeys: string[];
cursor: string;
listComplete: boolean;
}> {
const repo = new FeedRepository(emailStorage);
const id = FeedId.fromTrusted(feedId);
const listed = await repo.listFeedKeys(id, {
cursor: options.cursor,
limit: options.limit,
});
const keys = listed.names;
if (options.bucket && keys.length > 0) {
const emailKeys = keys.filter((k) => repo.isEmailKey(id, k));
if (emailKeys.length > 0) {
const emailDataResults = await Promise.allSettled(
emailKeys.map((k) => repo.getEmail(k)),
);
const attachmentIds = emailDataResults
.filter(
(r): r is PromiseFulfilledResult<EmailData | null> =>
r.status === "fulfilled",
)
.flatMap((r) => r.value?.attachments?.map((a) => a.id) ?? []);
if (attachmentIds.length > 0) {
await Promise.allSettled(
attachmentIds.map((id) => options.bucket!.delete(id)),
);
}
}
}
const { ok, failed } = await deleteKeysWithConcurrency(
emailStorage,
keys,
35,
);
return {
deletedKeys: ok,
failedKeys: failed,
cursor: listed.cursor,
listComplete: listed.listComplete,
};
}
export async function purgeExpiredFeeds(
emailStorage: KVNamespace,
feedId: string,
bucket?: R2Bucket,
): Promise<void> {
let cursor: string | undefined;
do {
const step = await purgeFeedKeysStep(emailStorage, feedId, {
bucket,
limit: 100,
cursor,
});
cursor = step.listComplete ? undefined : step.cursor;
} while (cursor);
}
+16 -15
View File
@@ -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),
}),