mirror of
https://github.com/juherr/kill-the-news.git
synced 2026-06-21 06:13:48 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
@@ -34,9 +34,9 @@ describe("Feed.create", () => {
|
||||
it("builds a config with an empty email index and no expiry by default", () => {
|
||||
const feed = Feed.create(FID, createInput());
|
||||
expect(feed.id.value).toBe("a.b.42");
|
||||
expect(feed.config.title).toBe("News");
|
||||
expect(feed.config.expires_at).toBeUndefined();
|
||||
expect(feed.metadata.emails).toEqual([]);
|
||||
expect(feed.title).toBe("News");
|
||||
expect(feed.expiresAt).toBeUndefined();
|
||||
expect(feed.emails).toEqual([]);
|
||||
});
|
||||
|
||||
it("resolves expiry from the supplied ttlHours using the injected clock", () => {
|
||||
@@ -45,9 +45,9 @@ describe("Feed.create", () => {
|
||||
clock: fixedClock(NOW),
|
||||
ttlHours: 2,
|
||||
});
|
||||
expect(feed.config.created_at).toBe(NOW);
|
||||
expect(feed.config.updated_at).toBe(NOW);
|
||||
expect(feed.config.expires_at).toBe(NOW + 2 * 3_600_000);
|
||||
expect(feed.createdAt).toBe(NOW);
|
||||
expect(feed.updatedAt).toBe(NOW);
|
||||
expect(feed.expiresAt).toBe(NOW + 2 * 3_600_000);
|
||||
});
|
||||
|
||||
it("trusts only deps.ttlHours, not the client lifetimeHours field", () => {
|
||||
@@ -56,7 +56,16 @@ describe("Feed.create", () => {
|
||||
const feed = Feed.create(FID, createInput({ lifetimeHours: 9999 }), {
|
||||
ttlHours: undefined,
|
||||
});
|
||||
expect(feed.config.expires_at).toBeUndefined();
|
||||
expect(feed.expiresAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it("treats a non-positive ttlHours as no expiry", () => {
|
||||
expect(
|
||||
Feed.create(FID, createInput(), { ttlHours: 0 }).expiresAt,
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
Feed.create(FID, createInput(), { ttlHours: -5 }).expiresAt,
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -109,11 +118,11 @@ describe("Feed.edit", () => {
|
||||
);
|
||||
|
||||
feed.edit({ title: "T2" }, { recomputeExpiry: false });
|
||||
expect(feed.config.expires_at).toBe(FUTURE); // preserved
|
||||
expect(feed.config.updated_at).toBe(NOW);
|
||||
expect(feed.expiresAt).toBe(FUTURE); // preserved
|
||||
expect(feed.updatedAt).toBe(NOW);
|
||||
|
||||
feed.edit({ title: "T3" }, { recomputeExpiry: true, ttlHours: 1 });
|
||||
expect(feed.config.expires_at).toBe(NOW + 3_600_000);
|
||||
expect(feed.expiresAt).toBe(NOW + 3_600_000);
|
||||
});
|
||||
|
||||
it("refuses to edit an already-expired feed", () => {
|
||||
@@ -143,13 +152,28 @@ describe("Feed.ingest", () => {
|
||||
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({
|
||||
expect(feed.emails[0].key).toBe("new");
|
||||
expect(feed.iconDomain).toBe("example.com");
|
||||
expect(feed.unsubscribeUrls()).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"]);
|
||||
expect(feed.emails.map((e) => e.key)).toEqual(["new"]);
|
||||
});
|
||||
|
||||
it("always keeps the just-ingested entry, even when it alone is oversized", () => {
|
||||
const feed = Feed.reconstitute(
|
||||
FID,
|
||||
{ title: "T", language: "en", created_at: 0 },
|
||||
{ emails: [] },
|
||||
);
|
||||
|
||||
const { dropped } = feed.ingest(entry({ key: "huge", size: 999 }), {
|
||||
maxBytes: 1,
|
||||
});
|
||||
|
||||
expect(dropped).toEqual([]);
|
||||
expect(feed.emails.map((e) => e.key)).toEqual(["huge"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -169,7 +193,7 @@ describe("Feed.removeEmails", () => {
|
||||
|
||||
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"]);
|
||||
expect(feed.emails.map((e) => e.key)).toEqual(["k2"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -196,14 +220,14 @@ describe("Feed events", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits no events for editDetails / edit / removeEmails", () => {
|
||||
it("emits no events for edit / removeEmails", () => {
|
||||
const feed = Feed.reconstitute(
|
||||
FID,
|
||||
{ title: "T", language: "en", created_at: 0, expires_at: 9_999_999_999 },
|
||||
{ emails: [entry({ key: "k1" })] },
|
||||
fixedClock(1000),
|
||||
);
|
||||
feed.editDetails({ title: "X" });
|
||||
feed.edit({ title: "X" }, { recomputeExpiry: false });
|
||||
feed.edit({ description: "Y" }, { recomputeExpiry: false });
|
||||
feed.removeEmails(["k1"]);
|
||||
expect(feed.pullEvents()).toEqual([]);
|
||||
@@ -218,15 +242,13 @@ describe("FeedRepository.load / save round-trip", () => {
|
||||
|
||||
const loaded = await repo.load(FID);
|
||||
expect(loaded).not.toBeNull();
|
||||
expect(loaded!.config.title).toBe("Round");
|
||||
expect(loaded!.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(FID);
|
||||
expect(reloaded!.metadata.emails.map((e) => e.key)).toEqual([
|
||||
"feed:a.b.42:1",
|
||||
]);
|
||||
expect(reloaded!.emails.map((e) => e.key)).toEqual(["feed:a.b.42:1"]);
|
||||
});
|
||||
|
||||
it("returns null when the feed has no config", async () => {
|
||||
|
||||
+119
-22
@@ -1,9 +1,32 @@
|
||||
import { FeedConfig, FeedMetadata, EmailMetadata } from "../types";
|
||||
import {
|
||||
FeedConfig,
|
||||
FeedMetadata,
|
||||
EmailMetadata,
|
||||
FeedListItem,
|
||||
} from "../types";
|
||||
import { FeedId } from "./value-objects/feed-id";
|
||||
import { SenderPolicy, SenderDecision } from "./value-objects/sender-policy";
|
||||
import { Clock, systemClock } from "./clock";
|
||||
import { FeedEvent } from "./events";
|
||||
import { resolveExpiresAt, isExpired, trimToByteBudget } from "./feed";
|
||||
import { isExpired } from "./feed";
|
||||
|
||||
const HOUR_MS = 3_600_000;
|
||||
|
||||
/**
|
||||
* Resolve a feed's `expires_at` from an already-resolved lifetime (hours) and a
|
||||
* current instant. Returns undefined when no positive lifetime applies (the feed
|
||||
* never expires). Which lifetime applies (client request vs. server-side
|
||||
* override, env parsing) is the application layer's call — the aggregate only
|
||||
* receives the resolved number. File-private: the aggregate is its sole user.
|
||||
*/
|
||||
function resolveExpiresAt(
|
||||
ttlHours: number | undefined,
|
||||
now: number,
|
||||
): number | undefined {
|
||||
return ttlHours !== undefined && Number.isFinite(ttlHours) && ttlHours > 0
|
||||
? now + ttlHours * HOUR_MS
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export interface CreateFeedInput {
|
||||
title: string;
|
||||
@@ -109,12 +132,78 @@ export class Feed {
|
||||
return new Feed(id, config, metadata, clock);
|
||||
}
|
||||
|
||||
get config(): Readonly<FeedConfig> {
|
||||
return this._config;
|
||||
// ── Intention-revealing reads ─────────────────────────────────────────────
|
||||
// The aggregate exposes named fields and copies of its collections, never the
|
||||
// raw `config`/`metadata` objects — a shallow `Readonly<…>` would still let a
|
||||
// caller mutate the arrays inside. Persistence reads `toConfigSnapshot()` /
|
||||
// `toMetadataSnapshot()`; the registry reads `summary()`.
|
||||
|
||||
get title(): string {
|
||||
return this._config.title;
|
||||
}
|
||||
|
||||
get metadata(): Readonly<FeedMetadata> {
|
||||
return this._metadata;
|
||||
get description(): string | undefined {
|
||||
return this._config.description;
|
||||
}
|
||||
|
||||
get language(): string {
|
||||
return this._config.language;
|
||||
}
|
||||
|
||||
get createdAt(): number {
|
||||
return this._config.created_at;
|
||||
}
|
||||
|
||||
get updatedAt(): number | undefined {
|
||||
return this._config.updated_at;
|
||||
}
|
||||
|
||||
get expiresAt(): number | undefined {
|
||||
return this._config.expires_at;
|
||||
}
|
||||
|
||||
get iconDomain(): string | undefined {
|
||||
return this._metadata.iconDomain;
|
||||
}
|
||||
|
||||
allowedSenders(): string[] {
|
||||
return [...(this._config.allowed_senders ?? [])];
|
||||
}
|
||||
|
||||
blockedSenders(): string[] {
|
||||
return [...(this._config.blocked_senders ?? [])];
|
||||
}
|
||||
|
||||
/** A copy of the email index — mutating it never touches aggregate state. */
|
||||
get emails(): readonly EmailMetadata[] {
|
||||
return [...this._metadata.emails];
|
||||
}
|
||||
|
||||
/** Per-sender one-click unsubscribe links (copy). */
|
||||
unsubscribeUrls(): Record<string, string> {
|
||||
return { ...(this._metadata.unsubscribe ?? {}) };
|
||||
}
|
||||
|
||||
/** The projection stored in the global `feeds:list` registry. */
|
||||
summary(): FeedListItem {
|
||||
return {
|
||||
id: this.id.value,
|
||||
title: this._config.title,
|
||||
description: this._config.description,
|
||||
expires_at: this._config.expires_at,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Persistence snapshots (repository-only) ───────────────────────────────
|
||||
|
||||
/** A serialisable copy of the config for the repository to persist. */
|
||||
toConfigSnapshot(): FeedConfig {
|
||||
return { ...this._config };
|
||||
}
|
||||
|
||||
/** A serialisable copy of the email index for the repository to persist. */
|
||||
toMetadataSnapshot(): FeedMetadata {
|
||||
return { ...this._metadata, emails: [...this._metadata.emails] };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -161,7 +250,24 @@ export class Feed {
|
||||
}
|
||||
|
||||
this._events.push({ type: "EmailIngested", iconDomain: opts.iconDomain });
|
||||
return trimToByteBudget(this._metadata, opts.maxBytes);
|
||||
return this.trimToByteBudget(opts.maxBytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce the per-feed byte budget by dropping the oldest emails (from the
|
||||
* tail of the index) until the total fits, always keeping at least one entry.
|
||||
* Returns the dropped entries so the caller can purge their KV/R2 storage.
|
||||
*/
|
||||
private trimToByteBudget(maxBytes: number): { dropped: EmailMetadata[] } {
|
||||
const emails = this._metadata.emails;
|
||||
let totalSize = emails.reduce((sum, e) => sum + (e.size ?? 0), 0);
|
||||
const dropped: EmailMetadata[] = [];
|
||||
while (totalSize > maxBytes && emails.length > 1) {
|
||||
const entry = emails.pop()!;
|
||||
totalSize -= entry.size ?? 0;
|
||||
dropped.push(entry);
|
||||
}
|
||||
return { dropped };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -180,21 +286,12 @@ export class Feed {
|
||||
}
|
||||
|
||||
/**
|
||||
* In-place edit of the presentational fields only (title + description). Never
|
||||
* touches expiry or the sender policy — used by the dashboard's minimal edit.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
this._config.updated_at = this.clock.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Full edit: apply the patch and recompute expiry from the application-supplied
|
||||
* lifetime when asked (an absent recompute preserves the current expiry).
|
||||
* Rejects an already-expired feed without mutating it.
|
||||
* The single edit path. Apply the patch (only the fields it carries) and
|
||||
* recompute expiry from the application-supplied lifetime when asked — an
|
||||
* absent recompute preserves the current expiry, which covers the dashboard's
|
||||
* title/description quick-edit (`recomputeExpiry: false`). Rejects an
|
||||
* already-expired feed without mutating it, so a quick-edit can no more touch
|
||||
* an expired feed than a full edit can.
|
||||
*/
|
||||
edit(
|
||||
patch: UpdateFeedInput,
|
||||
|
||||
+1
-86
@@ -1,26 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
resolveExpiresAt,
|
||||
isExpired,
|
||||
applySenderPolicy,
|
||||
trimToByteBudget,
|
||||
} from "./feed";
|
||||
import type { FeedMetadata, EmailMetadata } from "../types";
|
||||
|
||||
describe("resolveExpiresAt", () => {
|
||||
const NOW = 1_000_000;
|
||||
|
||||
it("returns undefined when no positive lifetime applies", () => {
|
||||
expect(resolveExpiresAt(undefined, NOW)).toBeUndefined();
|
||||
expect(resolveExpiresAt(0, NOW)).toBeUndefined();
|
||||
expect(resolveExpiresAt(-5, NOW)).toBeUndefined();
|
||||
expect(resolveExpiresAt(NaN, NOW)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("computes expiry from a supplied lifetime relative to now", () => {
|
||||
expect(resolveExpiresAt(2, NOW)).toBe(NOW + 2 * 3_600_000);
|
||||
});
|
||||
});
|
||||
import { isExpired } from "./feed";
|
||||
|
||||
describe("isExpired", () => {
|
||||
it("is false when no expiry is set", () => {
|
||||
@@ -33,67 +12,3 @@ describe("isExpired", () => {
|
||||
expect(isExpired({ expires_at: 1000 }, 999)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applySenderPolicy", () => {
|
||||
it("accepts everything when no lists are configured", () => {
|
||||
expect(applySenderPolicy({}, ["anyone@example.com"])).toBe("accepted");
|
||||
});
|
||||
|
||||
it("requires an allowlist match when an allowlist is set", () => {
|
||||
const config = { allowed_senders: ["news@example.com"] };
|
||||
expect(applySenderPolicy(config, ["news@example.com"])).toBe("accepted");
|
||||
expect(applySenderPolicy(config, ["other@example.com"])).toBe("blocked");
|
||||
});
|
||||
|
||||
it("matches an allowlist by domain", () => {
|
||||
const config = { allowed_senders: ["example.com"] };
|
||||
expect(applySenderPolicy(config, ["anyone@example.com"])).toBe("accepted");
|
||||
});
|
||||
|
||||
it("blocks a blocklisted sender even when allowlisted", () => {
|
||||
const config = {
|
||||
allowed_senders: ["example.com"],
|
||||
blocked_senders: ["spam@example.com"],
|
||||
};
|
||||
expect(applySenderPolicy(config, ["spam@example.com"])).toBe("blocked");
|
||||
expect(applySenderPolicy(config, ["ok@example.com"])).toBe("accepted");
|
||||
});
|
||||
|
||||
it("with only a blocklist, accepts everything else", () => {
|
||||
const config = { blocked_senders: ["bad.com"] };
|
||||
expect(applySenderPolicy(config, ["x@bad.com"])).toBe("blocked");
|
||||
expect(applySenderPolicy(config, ["x@good.com"])).toBe("accepted");
|
||||
});
|
||||
});
|
||||
|
||||
describe("trimToByteBudget", () => {
|
||||
const entry = (key: string, size: number): EmailMetadata => ({
|
||||
key,
|
||||
subject: key,
|
||||
receivedAt: 1,
|
||||
size,
|
||||
});
|
||||
|
||||
it("keeps everything within budget", () => {
|
||||
const meta: FeedMetadata = { emails: [entry("a", 10), entry("b", 10)] };
|
||||
const { dropped } = trimToByteBudget(meta, 100);
|
||||
expect(dropped).toEqual([]);
|
||||
expect(meta.emails).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("drops the oldest entries (from the tail) until within budget", () => {
|
||||
const meta: FeedMetadata = {
|
||||
emails: [entry("new", 30), entry("mid", 30), entry("old", 30)],
|
||||
};
|
||||
const { dropped } = trimToByteBudget(meta, 50);
|
||||
expect(dropped.map((e) => e.key)).toEqual(["old", "mid"]);
|
||||
expect(meta.emails.map((e) => e.key)).toEqual(["new"]);
|
||||
});
|
||||
|
||||
it("always keeps at least one entry, even when oversized", () => {
|
||||
const meta: FeedMetadata = { emails: [entry("only", 999)] };
|
||||
const { dropped } = trimToByteBudget(meta, 1);
|
||||
expect(dropped).toEqual([]);
|
||||
expect(meta.emails).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
+9
-66
@@ -1,37 +1,14 @@
|
||||
import { FeedConfig, FeedMetadata, EmailMetadata } from "../types";
|
||||
import { SenderPolicy, SenderDecision } from "./value-objects/sender-policy";
|
||||
|
||||
const HOUR_MS = 3_600_000;
|
||||
|
||||
export type { SenderDecision };
|
||||
import { FeedConfig } from "../types";
|
||||
|
||||
/**
|
||||
* The Feed aggregate's invariants, in one framework-agnostic place: expiry,
|
||||
* sender allow/block policy, and the email-size budget. No I/O and no ambient
|
||||
* time or environment — callers pass `now` (from a Clock) and a resolved
|
||||
* lifetime; persistence goes through the FeedRepository.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Resolve a feed's `expires_at` from an already-resolved lifetime (hours) and a
|
||||
* current instant. Returns undefined when no positive lifetime applies (i.e. the
|
||||
* feed never expires). The policy decision of *which* lifetime applies (a client
|
||||
* request vs. a server-side `FEED_TTL_HOURS` override, and parsing the env
|
||||
* string) belongs to the application layer, not here.
|
||||
*/
|
||||
export function resolveExpiresAt(
|
||||
ttlHours: number | undefined,
|
||||
now: number,
|
||||
): number | undefined {
|
||||
return ttlHours !== undefined && Number.isFinite(ttlHours) && ttlHours > 0
|
||||
? now + ttlHours * HOUR_MS
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a feed has reached its expiry instant. `now` defaults to the wall
|
||||
* clock for convenience at the HTTP edge (routes); the aggregate always passes
|
||||
* its injected clock so its own behaviour stays deterministic.
|
||||
* The expiry predicate, shared between the Feed aggregate and the read-model
|
||||
* routes (rss/atom/entries) that render from a config snapshot without loading
|
||||
* the aggregate. This is the *only* feed invariant that lives outside the
|
||||
* aggregate, precisely because the hot read path bypasses it.
|
||||
*
|
||||
* `now` defaults to the wall clock for convenience at the HTTP edge; the
|
||||
* aggregate always passes its injected clock so its own behaviour stays
|
||||
* deterministic.
|
||||
*/
|
||||
export function isExpired(
|
||||
config: Pick<FeedConfig, "expires_at">,
|
||||
@@ -39,37 +16,3 @@ export function isExpired(
|
||||
): boolean {
|
||||
return config.expires_at !== undefined && config.expires_at <= now;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether an inbound email is accepted, given the feed's sender lists and
|
||||
* the message's candidate sender addresses. Thin wrapper over the `SenderPolicy`
|
||||
* value object (which holds the matching semantics).
|
||||
*/
|
||||
export function applySenderPolicy(
|
||||
config: Pick<FeedConfig, "allowed_senders" | "blocked_senders">,
|
||||
senders: string[],
|
||||
): SenderDecision {
|
||||
return SenderPolicy.fromLists(
|
||||
config.allowed_senders,
|
||||
config.blocked_senders,
|
||||
).decide(senders);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce the per-feed byte budget by dropping the oldest emails (mutating
|
||||
* `metadata.emails`) until the total fits, always keeping at least one entry.
|
||||
* Returns the dropped entries so the caller can purge their KV/R2 storage.
|
||||
*/
|
||||
export function trimToByteBudget(
|
||||
metadata: FeedMetadata,
|
||||
maxBytes: number,
|
||||
): { dropped: EmailMetadata[] } {
|
||||
let totalSize = metadata.emails.reduce((sum, e) => sum + (e.size ?? 0), 0);
|
||||
const dropped: EmailMetadata[] = [];
|
||||
while (totalSize > maxBytes && metadata.emails.length > 1) {
|
||||
const entry = metadata.emails.pop()!;
|
||||
totalSize -= entry.size ?? 0;
|
||||
dropped.push(entry);
|
||||
}
|
||||
return { dropped };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user