mirror of
https://github.com/juherr/kill-the-news.git
synced 2026-06-21 06:13:48 +00:00
refactor(domain): introduce the Feed aggregate as the write-path API
Add a Feed aggregate class owning config + the email index, with create, ingest, removeEmails, isExpired and accepts delegating to the existing pure invariant functions. FeedRepository gains load/save/saveMetadata that reconstitute and persist the aggregate. All write paths now go through it: createFeedRecord (Feed.create), email ingestion (feed.ingest), and every email deletion in the admin UI and REST API (feed.removeEmails) — no route mutates metadata.emails directly anymore. KV key strings unchanged. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
||||
} from "../types";
|
||||
import { FEEDS_LIST_KEY } from "../config/constants";
|
||||
import { feedKeys } from "./feed-keys";
|
||||
import { Feed } from "./feed.aggregate";
|
||||
import { logger } from "../lib/logger";
|
||||
|
||||
/**
|
||||
@@ -55,6 +56,37 @@ export class FeedRepository {
|
||||
return feedKeys.feedIdFromEmail(key);
|
||||
}
|
||||
|
||||
// ── Feed aggregate ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Load the aggregate (config + email index). A feed exists iff it has a
|
||||
* config; metadata defaults to empty so a freshly-created feed still loads.
|
||||
*/
|
||||
async load(feedId: string): Promise<Feed | null> {
|
||||
const [config, metadata] = await Promise.all([
|
||||
this.getConfig(feedId),
|
||||
this.getMetadata(feedId),
|
||||
]);
|
||||
if (!config) return null;
|
||||
return Feed.reconstitute(feedId, config, metadata ?? { emails: [] });
|
||||
}
|
||||
|
||||
/** Persist both keys the aggregate owns (config + metadata). */
|
||||
async save(feed: Feed): Promise<void> {
|
||||
await Promise.all([
|
||||
this.putConfig(feed.id, feed.config),
|
||||
this.putMetadata(feed.id, feed.metadata),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist only the email index. Used by the ingest/delete paths where config
|
||||
* is unchanged — avoids a redundant config write on the hot path.
|
||||
*/
|
||||
async saveMetadata(feed: Feed): Promise<void> {
|
||||
await this.putMetadata(feed.id, feed.metadata);
|
||||
}
|
||||
|
||||
// ── Feed config ───────────────────────────────────────────────────────────
|
||||
|
||||
async getConfig(feedId: string): Promise<FeedConfig | null> {
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { createMockEnv } from "../test/setup";
|
||||
import { Feed, CreateFeedInput } from "./feed.aggregate";
|
||||
import { FeedRepository } from "./feed-repository";
|
||||
import type { Env, EmailMetadata } from "../types";
|
||||
|
||||
const mockEnv = (overrides: Partial<Env> = {}) =>
|
||||
({ ...createMockEnv(), ...overrides }) as unknown as Env;
|
||||
|
||||
const createInput = (
|
||||
overrides: Partial<CreateFeedInput> = {},
|
||||
): CreateFeedInput => ({
|
||||
title: "News",
|
||||
language: "en",
|
||||
allowedSenders: [],
|
||||
blockedSenders: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const entry = (overrides: Partial<EmailMetadata> = {}): EmailMetadata => ({
|
||||
key: "feed:a.b.42:1",
|
||||
subject: "Hello",
|
||||
receivedAt: 1,
|
||||
size: 10,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("Feed.create", () => {
|
||||
it("builds a config with an empty email index and no expiry by default", () => {
|
||||
const feed = Feed.create("a.b.42", createInput(), mockEnv());
|
||||
expect(feed.id).toBe("a.b.42");
|
||||
expect(feed.config.title).toBe("News");
|
||||
expect(feed.config.expires_at).toBeUndefined();
|
||||
expect(feed.metadata.emails).toEqual([]);
|
||||
});
|
||||
|
||||
it("resolves expiry from lifetimeHours", () => {
|
||||
const feed = Feed.create(
|
||||
"a.b.42",
|
||||
createInput({ lifetimeHours: 1 }),
|
||||
mockEnv(),
|
||||
);
|
||||
expect(feed.config.expires_at).toBeGreaterThan(Date.now());
|
||||
});
|
||||
|
||||
it("lets FEED_TTL_HOURS override a client lifetime", () => {
|
||||
const feed = Feed.create(
|
||||
"a.b.42",
|
||||
createInput({ lifetimeHours: 1000000 }),
|
||||
mockEnv({ FEED_TTL_HOURS: "1" }),
|
||||
);
|
||||
const oneClientHour = Date.now() + 1000000 * 3_600_000;
|
||||
expect(feed.config.expires_at).toBeLessThan(oneClientHour);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Feed.isExpired / accepts", () => {
|
||||
it("reports expiry against the configured instant", () => {
|
||||
const feed = Feed.reconstitute(
|
||||
"a.b.42",
|
||||
{ title: "T", language: "en", created_at: 0, expires_at: 100 },
|
||||
{ emails: [] },
|
||||
);
|
||||
expect(feed.isExpired(50)).toBe(false);
|
||||
expect(feed.isExpired(150)).toBe(true);
|
||||
});
|
||||
|
||||
it("applies the sender policy", () => {
|
||||
const feed = Feed.reconstitute(
|
||||
"a.b.42",
|
||||
{
|
||||
title: "T",
|
||||
language: "en",
|
||||
created_at: 0,
|
||||
allowed_senders: ["good@example.com"],
|
||||
},
|
||||
{ emails: [] },
|
||||
);
|
||||
expect(feed.accepts(["good@example.com"])).toBe("accepted");
|
||||
expect(feed.accepts(["bad@example.com"])).toBe("blocked");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Feed.ingest", () => {
|
||||
it("prepends the entry, tracks icon/unsub and trims to the byte budget", () => {
|
||||
const feed = Feed.reconstitute(
|
||||
"a.b.42",
|
||||
{ title: "T", language: "en", created_at: 0 },
|
||||
{
|
||||
emails: [entry({ key: "old", size: 400 })],
|
||||
},
|
||||
);
|
||||
|
||||
const { dropped } = feed.ingest(entry({ key: "new", size: 400 }), {
|
||||
maxBytes: 500,
|
||||
iconDomain: "example.com",
|
||||
unsub: { senderKey: "news@example.com", url: "https://u/1" },
|
||||
});
|
||||
|
||||
expect(feed.metadata.emails[0].key).toBe("new");
|
||||
expect(feed.metadata.iconDomain).toBe("example.com");
|
||||
expect(feed.metadata.unsubscribe).toEqual({
|
||||
"news@example.com": "https://u/1",
|
||||
});
|
||||
expect(dropped.map((e) => e.key)).toEqual(["old"]);
|
||||
expect(feed.metadata.emails.map((e) => e.key)).toEqual(["new"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Feed.removeEmails", () => {
|
||||
it("drops matching keys and returns the removed entries", () => {
|
||||
const feed = Feed.reconstitute(
|
||||
"a.b.42",
|
||||
{ title: "T", language: "en", created_at: 0 },
|
||||
{
|
||||
emails: [
|
||||
entry({ key: "k1" }),
|
||||
entry({ key: "k2" }),
|
||||
entry({ key: "k3" }),
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
const { removed } = feed.removeEmails(["k1", "k3", "missing"]);
|
||||
expect(removed.map((e) => e.key).sort()).toEqual(["k1", "k3"]);
|
||||
expect(feed.metadata.emails.map((e) => e.key)).toEqual(["k2"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FeedRepository.load / save round-trip", () => {
|
||||
it("persists a created feed and reflects later mutations", async () => {
|
||||
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
|
||||
const created = Feed.create(
|
||||
"a.b.42",
|
||||
createInput({ title: "Round" }),
|
||||
mockEnv(),
|
||||
);
|
||||
await repo.save(created);
|
||||
|
||||
const loaded = await repo.load("a.b.42");
|
||||
expect(loaded).not.toBeNull();
|
||||
expect(loaded!.config.title).toBe("Round");
|
||||
|
||||
loaded!.ingest(entry({ key: "feed:a.b.42:1" }), { maxBytes: 1_000_000 });
|
||||
await repo.saveMetadata(loaded!);
|
||||
|
||||
const reloaded = await repo.load("a.b.42");
|
||||
expect(reloaded!.metadata.emails.map((e) => e.key)).toEqual([
|
||||
"feed:a.b.42:1",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns null when the feed has no config", async () => {
|
||||
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
|
||||
expect(await repo.load("missing")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { Env, FeedConfig, FeedMetadata, EmailMetadata } from "../types";
|
||||
import {
|
||||
resolveExpiresAt,
|
||||
isExpired,
|
||||
applySenderPolicy,
|
||||
trimToByteBudget,
|
||||
SenderDecision,
|
||||
} from "./feed";
|
||||
|
||||
export interface CreateFeedInput {
|
||||
title: string;
|
||||
description?: string;
|
||||
language: string;
|
||||
allowedSenders: string[];
|
||||
blockedSenders: string[];
|
||||
lifetimeHours?: number;
|
||||
}
|
||||
|
||||
export interface UpdateFeedInput {
|
||||
title?: string;
|
||||
description?: string;
|
||||
language?: string;
|
||||
allowedSenders?: string[];
|
||||
blockedSenders?: string[];
|
||||
lifetimeHours?: number;
|
||||
}
|
||||
|
||||
export interface IngestOptions {
|
||||
maxBytes: number;
|
||||
iconDomain?: string;
|
||||
/** RFC 8058 one-click unsubscribe link, keyed by the sending newsletter. */
|
||||
unsub?: { senderKey: string; url: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* The Feed aggregate: the consistency boundary around a feed's config and the
|
||||
* metadata index of its emails. All mutations to either go through a method
|
||||
* here so the invariants (expiry policy, sender policy, byte budget) live in one
|
||||
* place. Email bodies are large blobs referenced by `metadata.emails[].key` and
|
||||
* deliberately sit *outside* the aggregate — the caller flushes them alongside
|
||||
* `FeedRepository.save`/`saveMetadata`.
|
||||
*
|
||||
* I/O-free: load and persist state through `FeedRepository`. KV has no multi-key
|
||||
* transaction, so a future Durable Object keyed by feed id would wrap
|
||||
* load→mutate→save to serialise concurrent writers (see email-processor.ts).
|
||||
*/
|
||||
export class Feed {
|
||||
private constructor(
|
||||
readonly id: string,
|
||||
private _config: FeedConfig,
|
||||
private _metadata: FeedMetadata,
|
||||
) {}
|
||||
|
||||
/** Mint a brand-new feed with an empty email index. */
|
||||
static create(id: string, input: CreateFeedInput, env: Env): Feed {
|
||||
const now = Date.now();
|
||||
const expiresAt = resolveExpiresAt(env, input.lifetimeHours);
|
||||
const config: FeedConfig = {
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
language: input.language,
|
||||
allowed_senders: input.allowedSenders,
|
||||
blocked_senders: input.blockedSenders,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
...(expiresAt !== undefined ? { expires_at: expiresAt } : {}),
|
||||
};
|
||||
return new Feed(id, config, { emails: [] });
|
||||
}
|
||||
|
||||
/** Rebuild an aggregate from persisted state. */
|
||||
static reconstitute(
|
||||
id: string,
|
||||
config: FeedConfig,
|
||||
metadata: FeedMetadata,
|
||||
): Feed {
|
||||
return new Feed(id, config, metadata);
|
||||
}
|
||||
|
||||
get config(): Readonly<FeedConfig> {
|
||||
return this._config;
|
||||
}
|
||||
|
||||
get metadata(): Readonly<FeedMetadata> {
|
||||
return this._metadata;
|
||||
}
|
||||
|
||||
isExpired(now: number = Date.now()): boolean {
|
||||
return isExpired(this._config, now);
|
||||
}
|
||||
|
||||
accepts(senders: string[]): SenderDecision {
|
||||
return applySenderPolicy(this._config, senders);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an email to the front of the index, refresh the icon domain and the
|
||||
* per-sender unsubscribe link, then trim the oldest entries back under the
|
||||
* byte budget. Returns the dropped entries so the caller can purge their
|
||||
* bodies/attachments.
|
||||
*/
|
||||
ingest(
|
||||
entry: EmailMetadata,
|
||||
opts: IngestOptions,
|
||||
): { dropped: EmailMetadata[] } {
|
||||
this._metadata.emails.unshift(entry);
|
||||
|
||||
if (opts.iconDomain) {
|
||||
this._metadata.iconDomain = opts.iconDomain;
|
||||
}
|
||||
if (opts.unsub) {
|
||||
this._metadata.unsubscribe = {
|
||||
...(this._metadata.unsubscribe ?? {}),
|
||||
[opts.unsub.senderKey]: opts.unsub.url,
|
||||
};
|
||||
}
|
||||
|
||||
return trimToByteBudget(this._metadata, opts.maxBytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the given email keys from the index. Returns the removed entries so the
|
||||
* caller can purge their bodies/attachments.
|
||||
*/
|
||||
removeEmails(keys: string[]): { removed: EmailMetadata[] } {
|
||||
const target = new Set(keys);
|
||||
const removed: EmailMetadata[] = [];
|
||||
const kept: EmailMetadata[] = [];
|
||||
for (const entry of this._metadata.emails) {
|
||||
(target.has(entry.key) ? removed : kept).push(entry);
|
||||
}
|
||||
this._metadata.emails = kept;
|
||||
return { removed };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user