refactor: tighten DDD boundaries on the Feed aggregate
Address five modeling tensions in one pass: - Encapsulation: the Feed aggregate no longer exposes raw config/metadata (a shallow Readonly still leaked mutable arrays). It now offers intention-revealing accessors that return copies, plus toConfigSnapshot/toMetadataSnapshot for the repository and summary() for the global registry. - feeds:list consistency: FeedRepository.save/saveConfig upsert the registry entry from feed.summary(), so services no longer mirror title/description/ expiry by hand (the old add/updateInList footgun is gone). - domain/feed.ts: drop the dead applySenderPolicy, internalise resolveExpiresAt and trimToByteBudget into the aggregate; feed.ts keeps only the shared isExpired predicate used by the read-model routes. - Single edit path: remove editDetails; edit(patch, deps) is the sole config mutation, with a systematic expired guard. Renaming an expired feed now 403s. - FeedId flows through the application and infrastructure signatures; fromTrusted/parse happen once at the edge, .value only at the serialisation boundaries (urls, feed-generator, feed-keys, logs, JSON). 347 tests green, tsc clean, Worker bundle builds. Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
1 parent
b3d42f6c50
commit
ad196f1761
24 files changed
+406
-398
No files matched your search
@@ -95,8 +95,8 @@ async function loadAcceptingFeed(
|
||||
logger.warn("Rejected email: sender filter", {
|
||||
feedId: feedId.value,
|
||||
senders: input.senders,
|
||||
allowedSenders: feed.config.allowed_senders,
|
||||
blockedSenders: feed.config.blocked_senders,
|
||||
allowedSenders: feed.allowedSenders(),
|
||||
blockedSenders: feed.blockedSenders(),
|
||||
});
|
||||
return { ok: false, reason: "sender_blocked" };
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { FeedId } from "../domain/value-objects/feed-id";
|
||||
// attachmentIds.
|
||||
export async function deleteAttachmentsForEmails(
|
||||
env: Env,
|
||||
emails: EmailMetadata[],
|
||||
emails: readonly EmailMetadata[],
|
||||
keys: Iterable<string>,
|
||||
): Promise<void> {
|
||||
const keySet = new Set(keys);
|
||||
@@ -58,16 +58,14 @@ export async function deleteKeysWithConcurrency(
|
||||
*/
|
||||
export async function collectUnsubscribeUrls(
|
||||
emailStorage: KVNamespace,
|
||||
feedId: string,
|
||||
feedId: FeedId,
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
const metadata = await new FeedRepository(emailStorage).getMetadata(
|
||||
FeedId.fromTrusted(feedId),
|
||||
);
|
||||
const metadata = await new FeedRepository(emailStorage).getMetadata(feedId);
|
||||
return Object.values(metadata?.unsubscribe ?? {});
|
||||
} catch (error) {
|
||||
logger.error("Error reading unsubscribe URLs", {
|
||||
feedId,
|
||||
feedId: feedId.value,
|
||||
error: String(error),
|
||||
});
|
||||
return [];
|
||||
@@ -76,7 +74,7 @@ export async function collectUnsubscribeUrls(
|
||||
|
||||
export async function purgeFeedKeysStep(
|
||||
emailStorage: KVNamespace,
|
||||
feedId: string,
|
||||
feedId: FeedId,
|
||||
options: { cursor?: string; limit?: number; bucket?: R2Bucket } = {},
|
||||
): Promise<{
|
||||
deletedKeys: string[];
|
||||
@@ -85,15 +83,14 @@ export async function purgeFeedKeysStep(
|
||||
listComplete: boolean;
|
||||
}> {
|
||||
const repo = new FeedRepository(emailStorage);
|
||||
const id = FeedId.fromTrusted(feedId);
|
||||
const listed = await repo.listFeedKeys(id, {
|
||||
const listed = await repo.listFeedKeys(feedId, {
|
||||
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));
|
||||
const emailKeys = keys.filter((k) => repo.isEmailKey(feedId, k));
|
||||
if (emailKeys.length > 0) {
|
||||
const emailDataResults = await Promise.allSettled(
|
||||
emailKeys.map((k) => repo.getEmail(k)),
|
||||
@@ -128,7 +125,7 @@ export async function purgeFeedKeysStep(
|
||||
|
||||
export async function purgeExpiredFeeds(
|
||||
emailStorage: KVNamespace,
|
||||
feedId: string,
|
||||
feedId: FeedId,
|
||||
bucket?: R2Bucket,
|
||||
): Promise<void> {
|
||||
let cursor: string | undefined;
|
||||
|
||||
@@ -32,7 +32,7 @@ export async function applyFeedEvents(
|
||||
emails_received: 1,
|
||||
last_email_at: new Date().toISOString(),
|
||||
});
|
||||
schedule(notifySubscribers(feedId.value, env));
|
||||
schedule(notifySubscribers(feedId, env));
|
||||
if (event.iconDomain) {
|
||||
schedule(cacheFaviconForDomain(event.iconDomain, env));
|
||||
}
|
||||
|
||||
@@ -9,17 +9,16 @@ export interface FeedData {
|
||||
}
|
||||
|
||||
export async function fetchFeedData(
|
||||
feedId: string,
|
||||
feedId: FeedId,
|
||||
env: Env,
|
||||
): Promise<FeedData | null> {
|
||||
const repo = FeedRepository.from(env);
|
||||
const id = FeedId.fromTrusted(feedId);
|
||||
|
||||
const feedMetadata = await repo.getMetadata(id);
|
||||
const feedMetadata = await repo.getMetadata(feedId);
|
||||
if (!feedMetadata) return null;
|
||||
|
||||
const feedConfig = (await repo.getConfig(id)) ?? {
|
||||
title: `Newsletter Feed ${feedId}`,
|
||||
const feedConfig = (await repo.getConfig(feedId)) ?? {
|
||||
title: `Newsletter Feed ${feedId.value}`,
|
||||
description: "Converted email newsletter",
|
||||
language: "en",
|
||||
created_at: Date.now(),
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest";
|
||||
import { createMockEnv } from "../test/setup";
|
||||
import { createFeedRecord, editFeed } from "./feed-service";
|
||||
import { getCounters } from "./stats";
|
||||
import { FeedId } from "../domain/value-objects/feed-id";
|
||||
import type { Env } from "../types";
|
||||
|
||||
const mkEnv = (overrides: Partial<Env> = {}) =>
|
||||
@@ -59,7 +60,9 @@ describe("editFeed — TTL policy", () => {
|
||||
const { feedId } = await createFeedRecord(env, { ...baseInput });
|
||||
|
||||
const before = Date.now();
|
||||
const result = await editFeed(env, feedId, { title: "renamed" });
|
||||
const result = await editFeed(env, FeedId.fromTrusted(feedId), {
|
||||
title: "renamed",
|
||||
});
|
||||
|
||||
expect(result.status).toBe("ok");
|
||||
if (result.status === "ok") {
|
||||
@@ -75,7 +78,9 @@ describe("editFeed — TTL policy", () => {
|
||||
lifetimeHours: 5,
|
||||
});
|
||||
|
||||
const result = await editFeed(env, feedId, { title: "x" });
|
||||
const result = await editFeed(env, FeedId.fromTrusted(feedId), {
|
||||
title: "x",
|
||||
});
|
||||
|
||||
expect(result.status).toBe("ok");
|
||||
if (result.status === "ok") {
|
||||
|
||||
@@ -45,17 +45,11 @@ export async function createFeedRecord(
|
||||
});
|
||||
|
||||
await repo.save(feed);
|
||||
await repo.addToList(
|
||||
feed.id,
|
||||
feed.config.title,
|
||||
feed.config.description,
|
||||
feed.config.expires_at,
|
||||
);
|
||||
|
||||
// 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 };
|
||||
return { feedId: feed.id.value, config: feed.toConfigSnapshot() };
|
||||
}
|
||||
|
||||
export type UpdateFeedResult =
|
||||
@@ -64,28 +58,26 @@ export type UpdateFeedResult =
|
||||
| { status: "expired" };
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Quick-edit of title/description only — never recomputes expiry. Used by the
|
||||
* dashboard's minimal edit. Delegates to the aggregate's single `edit` path, so
|
||||
* an expired feed is rejected here too. The list projection is kept in sync by
|
||||
* the repository on `saveConfig`.
|
||||
*/
|
||||
export async function editFeedDetails(
|
||||
env: Env,
|
||||
feedId: string,
|
||||
feedId: FeedId,
|
||||
patch: { title?: string; description?: string },
|
||||
): Promise<UpdateFeedResult> {
|
||||
const repo = FeedRepository.from(env);
|
||||
const feed = await repo.load(FeedId.fromTrusted(feedId));
|
||||
const feed = await repo.load(feedId);
|
||||
if (!feed) return { status: "not_found" };
|
||||
|
||||
feed.editDetails(patch);
|
||||
if (feed.edit(patch, { recomputeExpiry: false }).status === "expired") {
|
||||
return { status: "expired" };
|
||||
}
|
||||
await repo.saveConfig(feed);
|
||||
await repo.updateInList(
|
||||
feed.id,
|
||||
feed.config.title,
|
||||
feed.config.description,
|
||||
feed.config.expires_at,
|
||||
);
|
||||
|
||||
return { status: "ok", config: feed.config };
|
||||
return { status: "ok", config: feed.toConfigSnapshot() };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -94,11 +86,11 @@ export async function editFeedDetails(
|
||||
*/
|
||||
export async function editFeed(
|
||||
env: Env,
|
||||
feedId: string,
|
||||
feedId: FeedId,
|
||||
input: UpdateFeedInput,
|
||||
): Promise<UpdateFeedResult> {
|
||||
const repo = FeedRepository.from(env);
|
||||
const feed = await repo.load(FeedId.fromTrusted(feedId));
|
||||
const feed = await repo.load(feedId);
|
||||
if (!feed) return { status: "not_found" };
|
||||
|
||||
const recomputeExpiry =
|
||||
@@ -113,14 +105,8 @@ export async function editFeed(
|
||||
}
|
||||
|
||||
await repo.saveConfig(feed);
|
||||
await repo.updateInList(
|
||||
feed.id,
|
||||
feed.config.title,
|
||||
feed.config.description,
|
||||
feed.config.expires_at,
|
||||
);
|
||||
|
||||
return { status: "ok", config: feed.config };
|
||||
return { status: "ok", config: feed.toConfigSnapshot() };
|
||||
}
|
||||
|
||||
type DeleteFeedFastResult = {
|
||||
@@ -136,24 +122,23 @@ type DeleteFeedFastResult = {
|
||||
*/
|
||||
export async function deleteFeedFastDetailed(
|
||||
emailStorage: KVNamespace,
|
||||
feedId: string,
|
||||
feedId: FeedId,
|
||||
): Promise<DeleteFeedFastResult> {
|
||||
const repo = new FeedRepository(emailStorage);
|
||||
const id = FeedId.fromTrusted(feedId);
|
||||
|
||||
const errors: string[] = [];
|
||||
let configDeleted = false;
|
||||
let metadataDeleted = false;
|
||||
|
||||
try {
|
||||
await repo.deleteConfig(id);
|
||||
await repo.deleteConfig(feedId);
|
||||
configDeleted = true;
|
||||
} catch (error) {
|
||||
errors.push(`config delete failed: ${String(error)}`);
|
||||
}
|
||||
|
||||
try {
|
||||
await repo.deleteMetadata(id);
|
||||
await repo.deleteMetadata(feedId);
|
||||
metadataDeleted = true;
|
||||
} catch (error) {
|
||||
errors.push(`metadata delete failed: ${String(error)}`);
|
||||
@@ -170,7 +155,7 @@ export async function deleteFeedFastDetailed(
|
||||
*/
|
||||
export async function deleteFeedRecord(
|
||||
env: Env,
|
||||
feedId: string,
|
||||
feedId: FeedId,
|
||||
schedule: BackgroundScheduler,
|
||||
): Promise<boolean> {
|
||||
const emailStorage = env.EMAIL_STORAGE;
|
||||
@@ -180,7 +165,7 @@ export async function deleteFeedRecord(
|
||||
const unsubscribeUrls = await collectUnsubscribeUrls(emailStorage, feedId);
|
||||
|
||||
await deleteFeedFastDetailed(emailStorage, feedId);
|
||||
const removed = await repo.removeFromList(FeedId.fromTrusted(feedId));
|
||||
const removed = await repo.removeFromList(feedId);
|
||||
if (removed) {
|
||||
await bumpCounters(emailStorage, { feeds_deleted: 1 });
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user