refactor: separate Feed domain state from persistence DTO

Move four DDD tensions on the Feed aggregate to ground:

- #1 The aggregate now holds a domain FeedState (camelCase) instead of the
  snake_case FeedConfig DTO; infrastructure/feed-mapper.ts owns the
  FeedState<->FeedConfig/FeedListItem translation as the sole snake_case site
  outside the HTTP edge.
- #3 Replace the edit() recomputeExpiry control flag with a Lifetime VO:
  passing a lifetime recomputes expiry, omitting it preserves the current one
  (the dashboard quick-edit path).
- #4 Domain events carry their own feedId; dispatchFeedEvents centralizes the
  drain+dispatch in the application layer (no more manual pullEvents at call
  sites), keeping infra->application dependency direction intact.
- #6 Rename FeedId.fromTrusted to FeedId.unchecked to make the absence of
  revalidation explicit.

Adds Lifetime + feed-mapper round-trip tests. 353 tests green, tsc clean,
wrangler dry-run OK. Docs (CLAUDE.md) synced.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Julien Herr
2026-05-24 14:10:04 +02:00
parent d68a24867d
commit 06c436c36a
30 changed files with 413 additions and 249 deletions
+11 -8
View File
@@ -57,14 +57,15 @@ src/
config/constants.ts # Shared constants (TTLs, limits) config/constants.ts # Shared constants (TTLs, limits)
types/index.ts # Env, FeedConfig, EmailData, WebSubSubscription, etc. types/index.ts # Env, FeedConfig, EmailData, WebSubSubscription, etc.
domain/ # Framework-agnostic core (no Hono/infra imports leak out) domain/ # Framework-agnostic core (no Hono/infra imports leak out)
feed.aggregate.ts # Feed aggregate: consistency boundary; exposes intention-revealing reads + snapshots, never raw config/metadata feed.aggregate.ts # Feed aggregate: consistency boundary; holds domain FeedState (camelCase), exposes intention-revealing reads, never raw state/metadata
feed-state.ts # FeedState: the aggregate's config in domain (camelCase) vocabulary — NOT the snake_case persistence DTO
feed.ts # The expiry predicate (`isExpired`) — the one invariant shared with the read-model routes feed.ts # The expiry predicate (`isExpired`) — the one invariant shared with the read-model routes
feed-keys.ts # The KV key schema (pure string builders), shared by every repository feed-keys.ts # The KV key schema (pure string builders), shared by every repository
clock.ts # Clock port (systemClock) — injected into the aggregate; no ambient Date.now() clock.ts # Clock port (systemClock) — injected into the aggregate; no ambient Date.now()
events.ts # FeedEvent union (FeedCreated, EmailIngested) recorded by the aggregate events.ts # FeedEvent union (FeedCreated, EmailIngested) — each carries its feedId
email-parser.ts # Email parsing (addresses, headers, encoded words) email-parser.ts # Email parsing (addresses, headers, encoded words)
format.ts # Pure formatting helpers (formatBytes) format.ts # Pure formatting helpers (formatBytes)
value-objects/ # FeedId, EmailAddress, Domain, SenderPolicy (immutable, self-validating) value-objects/ # FeedId, EmailAddress, Domain, SenderPolicy, Lifetime (immutable, self-validating)
application/ # Use-cases / orchestration (wires domain + infrastructure) application/ # Use-cases / orchestration (wires domain + infrastructure)
feed-service.ts # createFeedRecord / editFeedDetails / 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 feed-cleanup.ts # Feed/email storage cleanup: purgeFeedKeysStep, collectUnsubscribeUrls, attachment+key deletion
@@ -75,6 +76,7 @@ src/
infrastructure/ # Adapters: KV/R2, outbound HTTP, logging, framework glue infrastructure/ # Adapters: KV/R2, outbound HTTP, logging, framework glue
logger.ts # JSON structured logger logger.ts # JSON structured logger
feed-repository.ts # KV adapter for the Feed aggregate + global feed list + email bodies (load/save) feed-repository.ts # KV adapter for the Feed aggregate + global feed list + email bodies (load/save)
feed-mapper.ts # Translation seam: domain FeedState ↔ persistence DTOs (FeedConfig/FeedListItem); sole owner of snake_case outside the edge
icon-repository.ts # KV adapter for cached favicons (icon:*) icon-repository.ts # KV adapter for cached favicons (icon:*)
websub-subscription-repository.ts # KV adapter for WebSub subscriber lists (websub:subs:*) websub-subscription-repository.ts # KV adapter for WebSub subscriber lists (websub:subs:*)
counters-repository.ts # KV adapter for the monitoring counters singleton (stats:counters) counters-repository.ts # KV adapter for the monitoring counters singleton (stats:counters)
@@ -140,13 +142,14 @@ The KV key schema lives in `src/domain/feed-keys.ts` (pure, framework-agnostic)
- **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. - **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`, `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`, `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 aggregate never exposes its raw state.** It has no `config`/`metadata` getters (a shallow `Readonly<…>` would still leak mutable arrays). Read named accessors (`title`, `expiresAt`, `emails`, `allowedSenders()`, …) which return copies; the repository serialises via `toConfigSnapshot()`/`toMetadataSnapshot()`; the `feeds:list` registry is derived from `summary()`. - **The domain never speaks the storage dialect.** The aggregate holds its config as domain `FeedState` (camelCase), never the snake_case `FeedConfig` DTO. The translation `FeedState ↔ FeedConfig/FeedListItem` lives in `infrastructure/feed-mapper.ts` — the only place outside the HTTP edge that knows the persisted field names. `FeedRepository.load` maps DTO→state on the way in; `save`/`saveConfig` map state→DTO on the way out.
- **One edit path.** `edit(patch, deps)` is the single mutation for config — the dashboard's title/description quick-edit calls it with `recomputeExpiry: false`. It rejects an already-expired feed, so a quick-edit can no more touch an expired feed than a full edit can. - **The aggregate never exposes its raw state.** It has no `state`/`metadata` getters (a shallow `Readonly<…>` would still leak mutable arrays). Read named accessors (`title`, `expiresAt`, `emails`, `allowedSenders()`, …) which return copies; the repository reads `state()`/`toMetadataSnapshot()` (copies) and runs them through the mapper.
- **`feeds:list` stays in sync automatically.** `FeedRepository.save`/`saveConfig` upsert the registry entry from `feed.summary()` — services never mirror title/description/expiry into the list by hand. - **One edit path.** `edit(patch, { lifetime? })` is the single mutation for config. A `Lifetime` VO is resolved by the application (env `FEED_TTL_HOURS` override + client request); its **presence recomputes expiry, its absence preserves it** — which is exactly the dashboard's title/description quick-edit (no lifetime passed). It rejects an already-expired feed, so a quick-edit can no more touch an expired feed than a full edit can.
- **`feeds:list` stays in sync automatically.** `FeedRepository.save`/`saveConfig` upsert the registry entry via `toListItemDTO(feed.id, feed.state())` — services never mirror title/description/expiry into the list by hand.
- Read-only RSS/Atom rendering uses the `feed-fetcher` read model, not the aggregate (no invariant to enforce on the hot path). - 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`). - 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`).
- **Side effects via domain events.** Mutations with consequences record a `FeedEvent` (`FeedCreated`, `EmailIngested`). After persisting, the caller `pullEvents()` and passes them to `application/feed-events.applyFeedEvents`, which runs the counters/WebSub/favicon. Don't inline those side effects at call sites. Side effects with no aggregate mutation (a rejected email, feed deletion that bypasses the aggregate, bulk admin ops, the cron) stay imperative — they have no event to ride on. - **Side effects via domain events.** Mutations with consequences record a `FeedEvent` (`FeedCreated`, `EmailIngested`), each carrying its own `feedId`. After persisting, the caller hands the aggregate to `application/feed-events.dispatchFeedEvents(feed, env, schedule)` — the single dispatch entry point that drains `pullEvents()` and runs the counters/WebSub/favicon. Don't pull events or thread the feed id by hand at call sites. Side effects with no aggregate mutation (a rejected email, feed deletion that bypasses the aggregate, bulk admin ops, the cron) stay imperative — they have no event to ride on.
- **`FeedId` flows through the layers.** It is the identity type taken by the domain (`Feed.id`), the application use-cases (`editFeed`, `editFeedDetails`, `deleteFeedRecord`, `fetchFeedData`, the cleanup steps) and the infrastructure repositories/services (`FeedRepository`, `WebSubSubscriptionRepository`, `notifySubscribers`, …). Mint it **once** at the edge — `FeedId.parse(address)` for inbound email, `FeedId.fromTrusted(param)` at the HTTP edge, `FeedId.generate()` for a new feed — then pass the VO inward. Unwrap to `.value` (string) only at the true serialisation edges: URL builders (`urls.ts`), XML generation (`feed-generator.ts`), the KV key schema (`feed-keys.ts`), logs and JSON responses. - **`FeedId` flows through the layers.** It is the identity type taken by the domain (`Feed.id`), the application use-cases (`editFeed`, `editFeedDetails`, `deleteFeedRecord`, `fetchFeedData`, the cleanup steps) and the infrastructure repositories/services (`FeedRepository`, `WebSubSubscriptionRepository`, `notifySubscribers`, …). Mint it **once** at the edge — `FeedId.parse(address)` for inbound email (validates), `FeedId.unchecked(param)` at the HTTP edge (no revalidation: a bad id just misses in KV and 404s), `FeedId.generate()` for a new feed — then pass the VO inward. Unwrap to `.value` (string) only at the true serialisation edges: URL builders (`urls.ts`), XML generation (`feed-generator.ts`), the KV key schema (`feed-keys.ts`), logs and JSON responses.
### Worker bindings (`Env`) ### Worker bindings (`Env`)
+2 -2
View File
@@ -1,7 +1,7 @@
import { EmailParser } from "../domain/email-parser"; import { EmailParser } from "../domain/email-parser";
import { AttachmentData, EmailMetadata, Env } from "../types"; import { AttachmentData, EmailMetadata, Env } from "../types";
import { bumpCounters } from "../application/stats"; import { bumpCounters } from "../application/stats";
import { applyFeedEvents } from "../application/feed-events"; import { dispatchFeedEvents } from "../application/feed-events";
import { extractEmailDomain } from "../infrastructure/favicon-fetcher"; import { extractEmailDomain } from "../infrastructure/favicon-fetcher";
import { parseOneClickUnsubscribe } from "../infrastructure/unsubscribe"; import { parseOneClickUnsubscribe } from "../infrastructure/unsubscribe";
import { getAttachmentBucket } from "../infrastructure/attachments"; import { getAttachmentBucket } from "../infrastructure/attachments";
@@ -188,7 +188,7 @@ async function storeEmail(
const schedule: BackgroundScheduler = ctx const schedule: BackgroundScheduler = ctx
? (p) => ctx.waitUntil(p) ? (p) => ctx.waitUntil(p)
: () => {}; : () => {};
await applyFeedEvents(feed.id, feed.pullEvents(), env, schedule); await dispatchFeedEvents(feed, env, schedule);
} }
export async function processEmail( export async function processEmail(
+20 -7
View File
@@ -1,6 +1,6 @@
import { Env } from "../types"; import { Env } from "../types";
import { FeedEvent } from "../domain/events"; import { FeedEvent } from "../domain/events";
import { FeedId } from "../domain/value-objects/feed-id"; import { Feed } from "../domain/feed.aggregate";
import { BackgroundScheduler } from "../infrastructure/worker"; import { BackgroundScheduler } from "../infrastructure/worker";
import { bumpCounters } from "./stats"; import { bumpCounters } from "./stats";
import { notifySubscribers } from "../infrastructure/websub"; import { notifySubscribers } from "../infrastructure/websub";
@@ -8,13 +8,13 @@ import { cacheFaviconForDomain } from "../infrastructure/favicon-fetcher";
/** /**
* Apply the side effects of a feed's domain events — the single place that maps * Apply the side effects of a feed's domain events — the single place that maps
* "what happened" (FeedCreated, EmailIngested) to its consequences. Counter * "what happened" (FeedCreated, EmailIngested) to its consequences. Each event
* writes are awaited (they must land); WebSub pings and favicon fetches are * carries its own `feedId`, so nothing has to be threaded in. Counter writes are
* handed to the caller's background scheduler (`ctx.waitUntil` at the edge, a * awaited (they must land); WebSub pings and favicon fetches are handed to the
* no-op when none is available). * caller's background scheduler (`ctx.waitUntil` at the edge, a no-op when none
* is available).
*/ */
export async function applyFeedEvents( export async function applyFeedEvents(
feedId: FeedId,
events: FeedEvent[], events: FeedEvent[],
env: Env, env: Env,
schedule: BackgroundScheduler, schedule: BackgroundScheduler,
@@ -32,7 +32,7 @@ export async function applyFeedEvents(
emails_received: 1, emails_received: 1,
last_email_at: new Date().toISOString(), last_email_at: new Date().toISOString(),
}); });
schedule(notifySubscribers(feedId, env)); schedule(notifySubscribers(event.feedId, env));
if (event.iconDomain) { if (event.iconDomain) {
schedule(cacheFaviconForDomain(event.iconDomain, env)); schedule(cacheFaviconForDomain(event.iconDomain, env));
} }
@@ -40,3 +40,16 @@ export async function applyFeedEvents(
} }
} }
} }
/**
* Drain a freshly-persisted aggregate's events and apply their side effects. The
* single dispatch entry point: callers persist the `Feed`, then call this — no
* caller pulls events or passes the feed id by hand.
*/
export async function dispatchFeedEvents(
feed: Feed,
env: Env,
schedule: BackgroundScheduler,
): Promise<void> {
await applyFeedEvents(feed.pullEvents(), env, schedule);
}
+2 -2
View File
@@ -60,7 +60,7 @@ describe("editFeed — TTL policy", () => {
const { feedId } = await createFeedRecord(env, { ...baseInput }); const { feedId } = await createFeedRecord(env, { ...baseInput });
const before = Date.now(); const before = Date.now();
const result = await editFeed(env, FeedId.fromTrusted(feedId), { const result = await editFeed(env, FeedId.unchecked(feedId), {
title: "renamed", title: "renamed",
}); });
@@ -78,7 +78,7 @@ describe("editFeed — TTL policy", () => {
lifetimeHours: 5, lifetimeHours: 5,
}); });
const result = await editFeed(env, FeedId.fromTrusted(feedId), { const result = await editFeed(env, FeedId.unchecked(feedId), {
title: "x", title: "x",
}); });
+23 -22
View File
@@ -1,11 +1,13 @@
import { Env, FeedConfig } from "../types"; import { Env, FeedConfig } from "../types";
import { bumpCounters } from "../application/stats"; import { bumpCounters } from "../application/stats";
import { applyFeedEvents } from "./feed-events"; import { dispatchFeedEvents } from "./feed-events";
import { sendUnsubscribes } from "../infrastructure/unsubscribe"; import { sendUnsubscribes } from "../infrastructure/unsubscribe";
import { getAttachmentBucket } from "../infrastructure/attachments"; import { getAttachmentBucket } from "../infrastructure/attachments";
import { FeedRepository } from "../infrastructure/feed-repository"; import { FeedRepository } from "../infrastructure/feed-repository";
import { toConfigDTO } from "../infrastructure/feed-mapper";
import { BackgroundScheduler } from "../infrastructure/worker"; import { BackgroundScheduler } from "../infrastructure/worker";
import { FeedId } from "../domain/value-objects/feed-id"; import { FeedId } from "../domain/value-objects/feed-id";
import { Lifetime } from "../domain/value-objects/lifetime";
import { import {
Feed, Feed,
CreateFeedInput, CreateFeedInput,
@@ -16,19 +18,18 @@ import { purgeFeedKeysStep, collectUnsubscribeUrls } from "./feed-cleanup";
export type { CreateFeedInput, UpdateFeedInput }; export type { CreateFeedInput, UpdateFeedInput };
/** /**
* Resolve the effective feed lifetime (hours) from a client request and the * Resolve the effective feed `Lifetime` from a client request and the
* server-side `FEED_TTL_HOURS` override. Parsing the env string and applying the * server-side `FEED_TTL_HOURS` override. Parsing the env string and applying the
* override is application/config policy — the domain only receives the resolved * override is application/config policy — the domain only receives the resolved
* number. Returns undefined when the feed should never expire. * VO. Returns `Lifetime.never` when the feed should never expire.
*/ */
function resolveTtlHours( function resolveLifetime(env: Env, requestedHours?: number): Lifetime {
env: Env,
requestedHours?: number,
): number | undefined {
const hours = env.FEED_TTL_HOURS const hours = env.FEED_TTL_HOURS
? parseInt(env.FEED_TTL_HOURS, 10) ? parseInt(env.FEED_TTL_HOURS, 10)
: (requestedHours ?? NaN); : (requestedHours ?? NaN);
return Number.isFinite(hours) && hours > 0 ? hours : undefined; return Number.isFinite(hours) && hours > 0
? Lifetime.ofHours(hours)
: Lifetime.never;
} }
/** /**
@@ -41,15 +42,15 @@ export async function createFeedRecord(
): Promise<{ feedId: string; config: FeedConfig }> { ): Promise<{ feedId: string; config: FeedConfig }> {
const repo = FeedRepository.from(env); const repo = FeedRepository.from(env);
const feed = Feed.create(FeedId.generate(), input, { const feed = Feed.create(FeedId.generate(), input, {
ttlHours: resolveTtlHours(env, input.lifetimeHours), lifetime: resolveLifetime(env, input.lifetimeHours),
}); });
await repo.save(feed); await repo.save(feed);
// FeedCreated → bumps the feeds_created counter (no background work to schedule). // FeedCreated → bumps the feeds_created counter (no background work to schedule).
await applyFeedEvents(feed.id, feed.pullEvents(), env, () => {}); await dispatchFeedEvents(feed, env, () => {});
return { feedId: feed.id.value, config: feed.toConfigSnapshot() }; return { feedId: feed.id.value, config: toConfigDTO(feed.state()) };
} }
export type UpdateFeedResult = export type UpdateFeedResult =
@@ -72,12 +73,13 @@ export async function editFeedDetails(
const feed = await repo.load(feedId); const feed = await repo.load(feedId);
if (!feed) return { status: "not_found" }; if (!feed) return { status: "not_found" };
if (feed.edit(patch, { recomputeExpiry: false }).status === "expired") { // No lifetime passed ⇒ expiry preserved (quick-edit never recomputes it).
if (feed.edit(patch).status === "expired") {
return { status: "expired" }; return { status: "expired" };
} }
await repo.saveConfig(feed); await repo.saveConfig(feed);
return { status: "ok", config: feed.toConfigSnapshot() }; return { status: "ok", config: toConfigDTO(feed.state()) };
} }
/** /**
@@ -93,20 +95,19 @@ export async function editFeed(
const feed = await repo.load(feedId); const feed = await repo.load(feedId);
if (!feed) return { status: "not_found" }; if (!feed) return { status: "not_found" };
const recomputeExpiry = // Recompute expiry only when a server TTL or a client lifetime applies;
Boolean(env.FEED_TTL_HOURS) || input.lifetimeHours !== undefined; // otherwise pass no lifetime so the aggregate preserves the current expiry.
if ( const lifetime =
feed.edit(input, { Boolean(env.FEED_TTL_HOURS) || input.lifetimeHours !== undefined
recomputeExpiry, ? resolveLifetime(env, input.lifetimeHours)
ttlHours: resolveTtlHours(env, input.lifetimeHours), : undefined;
}).status === "expired" if (feed.edit(input, { lifetime }).status === "expired") {
) {
return { status: "expired" }; return { status: "expired" };
} }
await repo.saveConfig(feed); await repo.saveConfig(feed);
return { status: "ok", config: feed.toConfigSnapshot() }; return { status: "ok", config: toConfigDTO(feed.state()) };
} }
type DeleteFeedFastResult = { type DeleteFeedFastResult = {
+1 -1
View File
@@ -110,7 +110,7 @@ export async function scanKvUsage(kv: KVNamespace): Promise<{ bytes: number }> {
const repo = new FeedRepository(kv); const repo = new FeedRepository(kv);
const feeds = await repo.listFeeds(); const feeds = await repo.listFeeds();
for (const feed of feeds) { for (const feed of feeds) {
const metadata = await repo.getMetadata(FeedId.fromTrusted(feed.id)); const metadata = await repo.getMetadata(FeedId.unchecked(feed.id));
if (!metadata) continue; if (!metadata) continue;
for (const email of metadata.emails) { for (const email of metadata.emails) {
bytes += email.size ?? 0; bytes += email.size ?? 0;
+1 -1
View File
@@ -6,7 +6,7 @@ export class EmailParser {
* Extract the feed id from an inbound recipient address. Returns a validated * Extract the feed id from an inbound recipient address. Returns a validated
* `FeedId` value object (not a raw string) so the most untrusted input in the * `FeedId` value object (not a raw string) so the most untrusted input in the
* system — an address typed by a sender — is guarded at the parse boundary and * system — an address typed by a sender — is guarded at the parse boundary and
* never needs `FeedId.fromTrusted` downstream. * never needs `FeedId.unchecked` downstream.
*/ */
static extractFeedId(emailAddress: string): FeedId | null { static extractFeedId(emailAddress: string): FeedId | null {
return FeedId.parse(emailAddress); return FeedId.parse(emailAddress);
+8 -6
View File
@@ -1,9 +1,11 @@
import { FeedId } from "./value-objects/feed-id";
/** /**
* Domain events the Feed aggregate records when it mutates. They describe *what * Domain events the Feed aggregate records when it mutates. They describe *what
* happened* in business terms; the application layer decides which side effects * happened* in business terms and carry their own `feedId`, so the application
* to run (counters, WebSub pings, favicon caching) via a dispatcher. This keeps * dispatcher can route side effects (counters, WebSub pings, favicon caching)
* the aggregate ignorant of infrastructure and the orchestration code free of * without the caller threading the id back in. This keeps the aggregate ignorant
* scattered, inline side effects. * of infrastructure and the orchestration code free of scattered, inline effects.
* *
* Only mutations that currently have side effects emit events — feed creation * Only mutations that currently have side effects emit events — feed creation
* and email ingestion. Edits and removals carry no side effect, so they emit * and email ingestion. Edits and removals carry no side effect, so they emit
@@ -12,5 +14,5 @@
* outside this mechanism by design — they have no aggregate event to ride on. * outside this mechanism by design — they have no aggregate event to ride on.
*/ */
export type FeedEvent = export type FeedEvent =
| { type: "FeedCreated" } | { type: "FeedCreated"; feedId: FeedId }
| { type: "EmailIngested"; iconDomain?: string }; | { type: "EmailIngested"; feedId: FeedId; iconDomain?: string };
+20
View File
@@ -0,0 +1,20 @@
/**
* The Feed aggregate's internal config state, in domain (camelCase) vocabulary.
* This is deliberately NOT the persistence shape: the snake_case `FeedConfig`
* DTO is an infrastructure concern, and the translation between the two lives in
* `infrastructure/feed-mapper.ts`. The domain never speaks the storage dialect.
*
* `expiresAt` is an absolute instant (epoch ms) already resolved from a
* `Lifetime`; the aggregate stores the resolved value, not the policy.
*/
export interface FeedState {
title: string;
description?: string;
language: string;
author?: string;
allowedSenders: string[];
blockedSenders: string[];
createdAt: number;
updatedAt?: number;
expiresAt?: number;
}
+45 -57
View File
@@ -3,10 +3,12 @@ import { createMockEnv } from "../test/setup";
import { Feed, CreateFeedInput } from "./feed.aggregate"; import { Feed, CreateFeedInput } from "./feed.aggregate";
import { FeedRepository } from "../infrastructure/feed-repository"; import { FeedRepository } from "../infrastructure/feed-repository";
import { FeedId } from "./value-objects/feed-id"; import { FeedId } from "./value-objects/feed-id";
import { Lifetime } from "./value-objects/lifetime";
import { FeedState } from "./feed-state";
import { Clock } from "./clock"; import { Clock } from "./clock";
import type { Env, EmailMetadata } from "../types"; import type { Env, EmailMetadata } from "../types";
const FID = FeedId.fromTrusted("a.b.42"); const FID = FeedId.unchecked("a.b.42");
const mockEnv = () => createMockEnv() as unknown as Env; const mockEnv = () => createMockEnv() as unknown as Env;
@@ -22,6 +24,15 @@ const createInput = (
...overrides, ...overrides,
}); });
const state = (overrides: Partial<FeedState> = {}): FeedState => ({
title: "T",
language: "en",
allowedSenders: [],
blockedSenders: [],
createdAt: 0,
...overrides,
});
const entry = (overrides: Partial<EmailMetadata> = {}): EmailMetadata => ({ const entry = (overrides: Partial<EmailMetadata> = {}): EmailMetadata => ({
key: "feed:a.b.42:1", key: "feed:a.b.42:1",
subject: "Hello", subject: "Hello",
@@ -39,43 +50,41 @@ describe("Feed.create", () => {
expect(feed.emails).toEqual([]); expect(feed.emails).toEqual([]);
}); });
it("resolves expiry from the supplied ttlHours using the injected clock", () => { it("resolves expiry from the supplied lifetime using the injected clock", () => {
const NOW = 1_000_000; const NOW = 1_000_000;
const feed = Feed.create(FID, createInput(), { const feed = Feed.create(FID, createInput(), {
clock: fixedClock(NOW), clock: fixedClock(NOW),
ttlHours: 2, lifetime: Lifetime.ofHours(2),
}); });
expect(feed.createdAt).toBe(NOW); expect(feed.createdAt).toBe(NOW);
expect(feed.updatedAt).toBe(NOW); expect(feed.updatedAt).toBe(NOW);
expect(feed.expiresAt).toBe(NOW + 2 * 3_600_000); expect(feed.expiresAt).toBe(NOW + 2 * 3_600_000);
}); });
it("trusts only deps.ttlHours, not the client lifetimeHours field", () => { it("trusts only deps.lifetime, not the client lifetimeHours field", () => {
// The aggregate no longer parses lifetime policy: the application resolves // The aggregate no longer parses lifetime policy: the application resolves
// the effective ttlHours (env override etc.) and hands it in. // the effective Lifetime (env override etc.) and hands it in.
const feed = Feed.create(FID, createInput({ lifetimeHours: 9999 }), { const feed = Feed.create(FID, createInput({ lifetimeHours: 9999 }));
ttlHours: undefined,
});
expect(feed.expiresAt).toBeUndefined(); expect(feed.expiresAt).toBeUndefined();
}); });
it("treats a non-positive ttlHours as no expiry", () => { it("treats a non-positive lifetime as no expiry", () => {
expect( expect(
Feed.create(FID, createInput(), { ttlHours: 0 }).expiresAt, Feed.create(FID, createInput(), { lifetime: Lifetime.ofHours(0) })
.expiresAt,
).toBeUndefined(); ).toBeUndefined();
expect( expect(
Feed.create(FID, createInput(), { ttlHours: -5 }).expiresAt, Feed.create(FID, createInput(), { lifetime: Lifetime.ofHours(-5) })
.expiresAt,
).toBeUndefined(); ).toBeUndefined();
}); });
}); });
describe("Feed.isExpired / accepts", () => { describe("Feed.isExpired / accepts", () => {
it("reports expiry against the configured instant", () => { it("reports expiry against the configured instant", () => {
const feed = Feed.reconstitute( const feed = Feed.reconstitute(FID, state({ expiresAt: 100 }), {
FID, emails: [],
{ title: "T", language: "en", created_at: 0, expires_at: 100 }, });
{ emails: [] },
);
expect(feed.isExpired(50)).toBe(false); expect(feed.isExpired(50)).toBe(false);
expect(feed.isExpired(150)).toBe(true); expect(feed.isExpired(150)).toBe(true);
}); });
@@ -83,7 +92,7 @@ describe("Feed.isExpired / accepts", () => {
it("uses the injected clock when no instant is supplied", () => { it("uses the injected clock when no instant is supplied", () => {
const feed = Feed.reconstitute( const feed = Feed.reconstitute(
FID, FID,
{ title: "T", language: "en", created_at: 0, expires_at: 100 }, state({ expiresAt: 100 }),
{ emails: [] }, { emails: [] },
fixedClock(150), fixedClock(150),
); );
@@ -93,12 +102,7 @@ describe("Feed.isExpired / accepts", () => {
it("applies the sender policy", () => { it("applies the sender policy", () => {
const feed = Feed.reconstitute( const feed = Feed.reconstitute(
FID, FID,
{ state({ allowedSenders: ["good@example.com"] }),
title: "T",
language: "en",
created_at: 0,
allowed_senders: ["good@example.com"],
},
{ emails: [] }, { emails: [] },
); );
expect(feed.accepts(["good@example.com"])).toBe("accepted"); expect(feed.accepts(["good@example.com"])).toBe("accepted");
@@ -107,28 +111,28 @@ describe("Feed.isExpired / accepts", () => {
}); });
describe("Feed.edit", () => { describe("Feed.edit", () => {
it("recomputes expiry only when asked", () => { it("recomputes expiry only when a lifetime is supplied", () => {
const NOW = 5_000_000; const NOW = 5_000_000;
const FUTURE = NOW + 10 * 3_600_000; const FUTURE = NOW + 10 * 3_600_000;
const feed = Feed.reconstitute( const feed = Feed.reconstitute(
FID, FID,
{ title: "T", language: "en", created_at: 0, expires_at: FUTURE }, state({ expiresAt: FUTURE }),
{ emails: [] }, { emails: [] },
fixedClock(NOW), fixedClock(NOW),
); );
feed.edit({ title: "T2" }, { recomputeExpiry: false }); feed.edit({ title: "T2" }); // no lifetime ⇒ expiry preserved
expect(feed.expiresAt).toBe(FUTURE); // preserved expect(feed.expiresAt).toBe(FUTURE);
expect(feed.updatedAt).toBe(NOW); expect(feed.updatedAt).toBe(NOW);
feed.edit({ title: "T3" }, { recomputeExpiry: true, ttlHours: 1 }); feed.edit({ title: "T3" }, { lifetime: Lifetime.ofHours(1) });
expect(feed.expiresAt).toBe(NOW + 3_600_000); expect(feed.expiresAt).toBe(NOW + 3_600_000);
}); });
it("refuses to edit an already-expired feed", () => { it("refuses to edit an already-expired feed", () => {
const feed = Feed.reconstitute( const feed = Feed.reconstitute(
FID, FID,
{ title: "T", language: "en", created_at: 0, expires_at: 100 }, state({ expiresAt: 100 }),
{ emails: [] }, { emails: [] },
fixedClock(200), fixedClock(200),
); );
@@ -138,13 +142,9 @@ describe("Feed.edit", () => {
describe("Feed.ingest", () => { describe("Feed.ingest", () => {
it("prepends the entry, tracks icon/unsub and trims to the byte budget", () => { it("prepends the entry, tracks icon/unsub and trims to the byte budget", () => {
const feed = Feed.reconstitute( const feed = Feed.reconstitute(FID, state(), {
FID,
{ title: "T", language: "en", created_at: 0 },
{
emails: [entry({ key: "old", size: 400 })], emails: [entry({ key: "old", size: 400 })],
}, });
);
const { dropped } = feed.ingest(entry({ key: "new", size: 400 }), { const { dropped } = feed.ingest(entry({ key: "new", size: 400 }), {
maxBytes: 500, maxBytes: 500,
@@ -162,11 +162,7 @@ describe("Feed.ingest", () => {
}); });
it("always keeps the just-ingested entry, even when it alone is oversized", () => { it("always keeps the just-ingested entry, even when it alone is oversized", () => {
const feed = Feed.reconstitute( const feed = Feed.reconstitute(FID, state(), { emails: [] });
FID,
{ title: "T", language: "en", created_at: 0 },
{ emails: [] },
);
const { dropped } = feed.ingest(entry({ key: "huge", size: 999 }), { const { dropped } = feed.ingest(entry({ key: "huge", size: 999 }), {
maxBytes: 1, maxBytes: 1,
@@ -179,17 +175,13 @@ describe("Feed.ingest", () => {
describe("Feed.removeEmails", () => { describe("Feed.removeEmails", () => {
it("drops matching keys and returns the removed entries", () => { it("drops matching keys and returns the removed entries", () => {
const feed = Feed.reconstitute( const feed = Feed.reconstitute(FID, state(), {
FID,
{ title: "T", language: "en", created_at: 0 },
{
emails: [ emails: [
entry({ key: "k1" }), entry({ key: "k1" }),
entry({ key: "k2" }), entry({ key: "k2" }),
entry({ key: "k3" }), entry({ key: "k3" }),
], ],
}, });
);
const { removed } = feed.removeEmails(["k1", "k3", "missing"]); const { removed } = feed.removeEmails(["k1", "k3", "missing"]);
expect(removed.map((e) => e.key).sort()).toEqual(["k1", "k3"]); expect(removed.map((e) => e.key).sort()).toEqual(["k1", "k3"]);
@@ -200,35 +192,31 @@ describe("Feed.removeEmails", () => {
describe("Feed events", () => { describe("Feed events", () => {
it("records FeedCreated on create and drains it once", () => { it("records FeedCreated on create and drains it once", () => {
const feed = Feed.create(FID, createInput()); const feed = Feed.create(FID, createInput());
expect(feed.pullEvents()).toEqual([{ type: "FeedCreated" }]); expect(feed.pullEvents()).toEqual([{ type: "FeedCreated", feedId: FID }]);
// Draining clears: a second pull is empty. // Draining clears: a second pull is empty.
expect(feed.pullEvents()).toEqual([]); expect(feed.pullEvents()).toEqual([]);
}); });
it("records EmailIngested (with icon domain) on ingest", () => { it("records EmailIngested (with icon domain) on ingest", () => {
const feed = Feed.reconstitute( const feed = Feed.reconstitute(FID, state(), { emails: [] });
FID,
{ title: "T", language: "en", created_at: 0 },
{ emails: [] },
);
feed.ingest(entry({ key: "k" }), { feed.ingest(entry({ key: "k" }), {
maxBytes: 1_000_000, maxBytes: 1_000_000,
iconDomain: "example.com", iconDomain: "example.com",
}); });
expect(feed.pullEvents()).toEqual([ expect(feed.pullEvents()).toEqual([
{ type: "EmailIngested", iconDomain: "example.com" }, { type: "EmailIngested", feedId: FID, iconDomain: "example.com" },
]); ]);
}); });
it("emits no events for edit / removeEmails", () => { it("emits no events for edit / removeEmails", () => {
const feed = Feed.reconstitute( const feed = Feed.reconstitute(
FID, FID,
{ title: "T", language: "en", created_at: 0, expires_at: 9_999_999_999 }, state({ expiresAt: 9_999_999_999 }),
{ emails: [entry({ key: "k1" })] }, { emails: [entry({ key: "k1" })] },
fixedClock(1000), fixedClock(1000),
); );
feed.edit({ title: "X" }, { recomputeExpiry: false }); feed.edit({ title: "X" });
feed.edit({ description: "Y" }, { recomputeExpiry: false }); feed.edit({ description: "Y" });
feed.removeEmails(["k1"]); feed.removeEmails(["k1"]);
expect(feed.pullEvents()).toEqual([]); expect(feed.pullEvents()).toEqual([]);
}); });
@@ -253,6 +241,6 @@ describe("FeedRepository.load / save round-trip", () => {
it("returns null when the feed has no config", async () => { it("returns null when the feed has no config", async () => {
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE); const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
expect(await repo.load(FeedId.fromTrusted("missing"))).toBeNull(); expect(await repo.load(FeedId.unchecked("missing"))).toBeNull();
}); });
}); });
+75 -95
View File
@@ -1,32 +1,10 @@
import { import { FeedMetadata, EmailMetadata } from "../types";
FeedConfig, import { FeedState } from "./feed-state";
FeedMetadata,
EmailMetadata,
FeedListItem,
} from "../types";
import { FeedId } from "./value-objects/feed-id"; import { FeedId } from "./value-objects/feed-id";
import { Lifetime } from "./value-objects/lifetime";
import { SenderPolicy, SenderDecision } from "./value-objects/sender-policy"; import { SenderPolicy, SenderDecision } from "./value-objects/sender-policy";
import { Clock, systemClock } from "./clock"; import { Clock, systemClock } from "./clock";
import { FeedEvent } from "./events"; import { FeedEvent } from "./events";
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 { export interface CreateFeedInput {
title: string; title: string;
@@ -34,6 +12,7 @@ export interface CreateFeedInput {
language: string; language: string;
allowedSenders: string[]; allowedSenders: string[];
blockedSenders: string[]; blockedSenders: string[];
/** Raw client-requested lifetime; the application resolves it into a `Lifetime`. */
lifetimeHours?: number; lifetimeHours?: number;
} }
@@ -48,24 +27,23 @@ export interface UpdateFeedInput {
/** /**
* Dependencies the aggregate needs from the outside but must not reach for * Dependencies the aggregate needs from the outside but must not reach for
* itself: a clock (never ambient `Date.now()`) and an already-resolved feed * itself: a clock (never ambient `Date.now()`) and an already-resolved
* lifetime. The application layer decides the lifetime — parsing env config and * `Lifetime`. The application layer decides the lifetime — parsing env config and
* applying any server-side `FEED_TTL_HOURS` override — and hands the result in. * applying any server-side `FEED_TTL_HOURS` override — and hands the VO in.
*/ */
export interface CreateFeedDeps { export interface CreateFeedDeps {
clock?: Clock; clock?: Clock;
/** Effective lifetime in hours, already resolved by the application. */ /** Effective lifetime, already resolved by the application. */
ttlHours?: number; lifetime?: Lifetime;
} }
export interface EditFeedDeps { export interface EditFeedDeps {
/** Effective lifetime in hours, already resolved by the application. */
ttlHours?: number;
/** /**
* Whether to recompute expiry at all. False preserves the current expiry * Effective lifetime, already resolved by the application. Its *presence* means
* (mirrors the old "no server TTL and no client lifetime ⇒ leave as-is"). * "recompute expiry"; its absence preserves the current expiry — which covers
* the dashboard's title/description quick-edit.
*/ */
recomputeExpiry?: boolean; lifetime?: Lifetime;
} }
export interface IngestOptions { export interface IngestOptions {
@@ -83,17 +61,19 @@ export interface IngestOptions {
* deliberately sit *outside* the aggregate — the caller flushes them alongside * deliberately sit *outside* the aggregate — the caller flushes them alongside
* `FeedRepository.save`/`saveMetadata`. * `FeedRepository.save`/`saveMetadata`.
* *
* I/O-free and time-free: load and persist state through `FeedRepository`; time * Its config is held as domain `FeedState` (camelCase), never the snake_case
* comes from an injected `Clock`. KV has no multi-key transaction, so a future * persistence DTO — `FeedRepository` translates via `feed-mapper.ts`. I/O-free
* Durable Object keyed by feed id would wrap load→mutate→save to serialise * and time-free: load and persist through the repository; time comes from an
* concurrent writers (see email-processor.ts). * injected `Clock`. 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 { export class Feed {
private readonly _events: FeedEvent[] = []; private readonly _events: FeedEvent[] = [];
private constructor( private constructor(
readonly id: FeedId, readonly id: FeedId,
private _config: FeedConfig, private _state: FeedState,
private _metadata: FeedMetadata, private _metadata: FeedMetadata,
private readonly clock: Clock, private readonly clock: Clock,
) {} ) {}
@@ -106,60 +86,60 @@ export class Feed {
): Feed { ): Feed {
const clock = deps.clock ?? systemClock; const clock = deps.clock ?? systemClock;
const now = clock.now(); const now = clock.now();
const expiresAt = resolveExpiresAt(deps.ttlHours, now); const expiresAt = (deps.lifetime ?? Lifetime.never).resolveExpiry(now);
const config: FeedConfig = { const state: FeedState = {
title: input.title, title: input.title,
description: input.description, description: input.description,
language: input.language, language: input.language,
allowed_senders: input.allowedSenders, allowedSenders: input.allowedSenders,
blocked_senders: input.blockedSenders, blockedSenders: input.blockedSenders,
created_at: now, createdAt: now,
updated_at: now, updatedAt: now,
...(expiresAt !== undefined ? { expires_at: expiresAt } : {}), expiresAt,
}; };
const feed = new Feed(id, config, { emails: [] }, clock); const feed = new Feed(id, state, { emails: [] }, clock);
feed._events.push({ type: "FeedCreated" }); feed._events.push({ type: "FeedCreated", feedId: id });
return feed; return feed;
} }
/** Rebuild an aggregate from persisted state. */ /** Rebuild an aggregate from persisted (already-mapped) domain state. */
static reconstitute( static reconstitute(
id: FeedId, id: FeedId,
config: FeedConfig, state: FeedState,
metadata: FeedMetadata, metadata: FeedMetadata,
clock: Clock = systemClock, clock: Clock = systemClock,
): Feed { ): Feed {
return new Feed(id, config, metadata, clock); return new Feed(id, state, metadata, clock);
} }
// ── Intention-revealing reads ───────────────────────────────────────────── // ── Intention-revealing reads ─────────────────────────────────────────────
// The aggregate exposes named fields and copies of its collections, never the // The aggregate exposes named fields and copies of its collections, never the
// raw `config`/`metadata` objects — a shallow `Readonly<…>` would still let a // raw `state`/`metadata` objects — a shallow `Readonly<…>` would still let a
// caller mutate the arrays inside. Persistence reads `toConfigSnapshot()` / // caller mutate the arrays inside. Persistence reads `state()` /
// `toMetadataSnapshot()`; the registry reads `summary()`. // `toMetadataSnapshot()`; the mapper derives the DTOs.
get title(): string { get title(): string {
return this._config.title; return this._state.title;
} }
get description(): string | undefined { get description(): string | undefined {
return this._config.description; return this._state.description;
} }
get language(): string { get language(): string {
return this._config.language; return this._state.language;
} }
get createdAt(): number { get createdAt(): number {
return this._config.created_at; return this._state.createdAt;
} }
get updatedAt(): number | undefined { get updatedAt(): number | undefined {
return this._config.updated_at; return this._state.updatedAt;
} }
get expiresAt(): number | undefined { get expiresAt(): number | undefined {
return this._config.expires_at; return this._state.expiresAt;
} }
get iconDomain(): string | undefined { get iconDomain(): string | undefined {
@@ -167,11 +147,11 @@ export class Feed {
} }
allowedSenders(): string[] { allowedSenders(): string[] {
return [...(this._config.allowed_senders ?? [])]; return [...this._state.allowedSenders];
} }
blockedSenders(): string[] { blockedSenders(): string[] {
return [...(this._config.blocked_senders ?? [])]; return [...this._state.blockedSenders];
} }
/** A copy of the email index — mutating it never touches aggregate state. */ /** A copy of the email index — mutating it never touches aggregate state. */
@@ -184,21 +164,15 @@ export class Feed {
return { ...(this._metadata.unsubscribe ?? {}) }; 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) ─────────────────────────────── // ── Persistence snapshots (repository-only) ───────────────────────────────
/** A serialisable copy of the config for the repository to persist. */ /** A copy of the domain config state for the repository to map + persist. */
toConfigSnapshot(): FeedConfig { state(): FeedState {
return { ...this._config }; return {
...this._state,
allowedSenders: [...this._state.allowedSenders],
blockedSenders: [...this._state.blockedSenders],
};
} }
/** A serialisable copy of the email index for the repository to persist. */ /** A serialisable copy of the email index for the repository to persist. */
@@ -217,13 +191,15 @@ export class Feed {
} }
isExpired(now: number = this.clock.now()): boolean { isExpired(now: number = this.clock.now()): boolean {
return isExpired(this._config, now); // The shared `isExpired` predicate (domain/feed.ts) lives on the read path
// and speaks the persistence DTO; the aggregate checks its own domain state.
return this._state.expiresAt !== undefined && this._state.expiresAt <= now;
} }
accepts(senders: string[]): SenderDecision { accepts(senders: string[]): SenderDecision {
return SenderPolicy.fromLists( return SenderPolicy.fromLists(
this._config.allowed_senders, this._state.allowedSenders,
this._config.blocked_senders, this._state.blockedSenders,
).decide(senders); ).decide(senders);
} }
@@ -249,7 +225,11 @@ export class Feed {
}; };
} }
this._events.push({ type: "EmailIngested", iconDomain: opts.iconDomain }); this._events.push({
type: "EmailIngested",
feedId: this.id,
iconDomain: opts.iconDomain,
});
return this.trimToByteBudget(opts.maxBytes); return this.trimToByteBudget(opts.maxBytes);
} }
@@ -287,11 +267,11 @@ export class Feed {
/** /**
* The single edit path. Apply the patch (only the fields it carries) and * The single edit path. Apply the patch (only the fields it carries) and
* recompute expiry from the application-supplied lifetime when asked — an * recompute expiry when the application supplies a `Lifetime` — an absent
* absent recompute preserves the current expiry, which covers the dashboard's * lifetime preserves the current expiry, which covers the dashboard's
* title/description quick-edit (`recomputeExpiry: false`). Rejects an * title/description quick-edit. Rejects an already-expired feed without
* already-expired feed without mutating it, so a quick-edit can no more touch * mutating it, so a quick-edit can no more touch an expired feed than a full
* an expired feed than a full edit can. * edit can.
*/ */
edit( edit(
patch: UpdateFeedInput, patch: UpdateFeedInput,
@@ -300,23 +280,23 @@ export class Feed {
if (this.isExpired()) return { status: "expired" }; if (this.isExpired()) return { status: "expired" };
const now = this.clock.now(); const now = this.clock.now();
const expiresAt = deps.recomputeExpiry const expiresAt = deps.lifetime
? resolveExpiresAt(deps.ttlHours, now) ? deps.lifetime.resolveExpiry(now)
: this._config.expires_at; : this._state.expiresAt;
if (patch.title !== undefined) this._config.title = patch.title; if (patch.title !== undefined) this._state.title = patch.title;
if (patch.description !== undefined) { if (patch.description !== undefined) {
this._config.description = patch.description; this._state.description = patch.description;
} }
if (patch.language !== undefined) this._config.language = patch.language; if (patch.language !== undefined) this._state.language = patch.language;
if (patch.allowedSenders !== undefined) { if (patch.allowedSenders !== undefined) {
this._config.allowed_senders = patch.allowedSenders; this._state.allowedSenders = patch.allowedSenders;
} }
if (patch.blockedSenders !== undefined) { if (patch.blockedSenders !== undefined) {
this._config.blocked_senders = patch.blockedSenders; this._state.blockedSenders = patch.blockedSenders;
} }
this._config.updated_at = now; this._state.updatedAt = now;
this._config.expires_at = expiresAt; this._state.expiresAt = expiresAt;
return { status: "ok" }; return { status: "ok" };
} }
+6 -4
View File
@@ -17,11 +17,13 @@ export class FeedId {
} }
/** /**
* Wrap an id we already trust — a value we minted ourselves and round-tripped * Wrap a string as a FeedId WITHOUT revalidating it. The caller asserts the id
* through our own links or KV keys (route params, the feed list, email keys). * originated from our own minting — a route param echoing a stored id, a
* No validation: a wrong id simply misses in KV and 404s, exactly as before. * `feeds:list` entry, or an email/KV key. The name is deliberately blunt: a
* wrong id is not rejected here, it simply misses in KV and 404s downstream.
* Untrusted external input (an inbound address) must go through `parse` instead.
*/ */
static fromTrusted(value: string): FeedId { static unchecked(value: string): FeedId {
return new FeedId(value); return new FeedId(value);
} }
+22
View File
@@ -0,0 +1,22 @@
import { describe, it, expect } from "vitest";
import { Lifetime } from "./lifetime";
const NOW = 1_000_000;
const HOUR = 3_600_000;
describe("Lifetime", () => {
it("resolves a positive lifetime to an absolute expiry", () => {
expect(Lifetime.ofHours(2).resolveExpiry(NOW)).toBe(NOW + 2 * HOUR);
});
it("never expires for Lifetime.never", () => {
expect(Lifetime.never.resolveExpiry(NOW)).toBeUndefined();
});
it("treats non-positive or non-finite hours as no expiry", () => {
expect(Lifetime.ofHours(0).resolveExpiry(NOW)).toBeUndefined();
expect(Lifetime.ofHours(-5).resolveExpiry(NOW)).toBeUndefined();
expect(Lifetime.ofHours(NaN).resolveExpiry(NOW)).toBeUndefined();
expect(Lifetime.ofHours(Infinity).resolveExpiry(NOW)).toBeUndefined();
});
});
+32
View File
@@ -0,0 +1,32 @@
const HOUR_MS = 3_600_000;
/**
* A feed's lifetime as a value object: either a positive number of hours or
* "never". `resolveExpiry(now)` turns it into an absolute `expires_at` instant
* (or undefined for a feed that never expires).
*
* Which lifetime applies — client request vs. server-side `FEED_TTL_HOURS`
* override — is the application layer's policy; it builds the VO and hands it to
* the aggregate. The aggregate never parses env config or reaches for a clock to
* compute expiry itself.
*/
export class Lifetime {
private constructor(private readonly hours: number | undefined) {}
/** A finite, positive lifetime. Non-positive/non-finite inputs collapse to never. */
static ofHours(hours: number): Lifetime {
return new Lifetime(hours);
}
/** A feed that never expires. */
static readonly never = new Lifetime(undefined);
/** The absolute expiry instant for this lifetime, or undefined if it never expires. */
resolveExpiry(now: number): number | undefined {
return this.hours !== undefined &&
Number.isFinite(this.hours) &&
this.hours > 0
? now + this.hours * HOUR_MS
: undefined;
}
}
+1 -1
View File
@@ -209,7 +209,7 @@ export default {
for (const feedId of expiredIds) { for (const feedId of expiredIds) {
await purgeExpiredFeeds( await purgeExpiredFeeds(
env.EMAIL_STORAGE, env.EMAIL_STORAGE,
FeedId.fromTrusted(feedId), FeedId.unchecked(feedId),
attachmentBucket, attachmentBucket,
); );
} }
+45
View File
@@ -0,0 +1,45 @@
import { describe, it, expect } from "vitest";
import { fromConfigDTO, toConfigDTO, toListItemDTO } from "./feed-mapper";
import { FeedId } from "../domain/value-objects/feed-id";
import type { FeedConfig } from "../types";
const fullConfig: FeedConfig = {
title: "News",
description: "desc",
language: "en",
author: "Jane",
allowed_senders: ["a@x.com"],
blocked_senders: ["b@y.com"],
created_at: 1000,
updated_at: 2000,
expires_at: 3000,
};
describe("feed-mapper", () => {
it("round-trips a full config DTO through domain state unchanged", () => {
expect(toConfigDTO(fromConfigDTO(fullConfig))).toEqual(fullConfig);
});
it("defaults absent sender lists to empty arrays on the domain side", () => {
const state = fromConfigDTO({
title: "T",
language: "en",
created_at: 1,
});
expect(state.allowedSenders).toEqual([]);
expect(state.blockedSenders).toEqual([]);
});
it("projects the feeds:list item from domain state", () => {
const item = toListItemDTO(
FeedId.unchecked("a.b.42"),
fromConfigDTO(fullConfig),
);
expect(item).toEqual({
id: "a.b.42",
title: "News",
description: "desc",
expires_at: 3000,
});
});
});
+51
View File
@@ -0,0 +1,51 @@
import { FeedConfig, FeedListItem } from "../types";
import { FeedState } from "../domain/feed-state";
import { FeedId } from "../domain/value-objects/feed-id";
/**
* The translation seam between the Feed aggregate's domain state (camelCase) and
* the persistence/edge DTOs (`FeedConfig`/`FeedListItem`, snake_case). This is
* the ONLY place outside the HTTP edge that knows the stored field names — the
* domain stays free of the storage dialect, and the repository round-trips
* through here on every load/save.
*/
/** Persisted config DTO → domain state (used by `FeedRepository.load`). */
export function fromConfigDTO(dto: FeedConfig): FeedState {
return {
title: dto.title,
description: dto.description,
language: dto.language,
author: dto.author,
allowedSenders: dto.allowed_senders ?? [],
blockedSenders: dto.blocked_senders ?? [],
createdAt: dto.created_at,
updatedAt: dto.updated_at,
expiresAt: dto.expires_at,
};
}
/** Domain state → persisted config DTO (used by `FeedRepository.save`). */
export function toConfigDTO(state: FeedState): FeedConfig {
return {
title: state.title,
description: state.description,
language: state.language,
author: state.author,
allowed_senders: state.allowedSenders,
blocked_senders: state.blockedSenders,
created_at: state.createdAt,
updated_at: state.updatedAt,
expires_at: state.expiresAt,
};
}
/** Domain state → the projection cached in the global `feeds:list` registry. */
export function toListItemDTO(id: FeedId, state: FeedState): FeedListItem {
return {
id: id.value,
title: state.title,
description: state.description,
expires_at: state.expiresAt,
};
}
+5 -3
View File
@@ -6,7 +6,7 @@ import { FeedId } from "../domain/value-objects/feed-id";
import type { Env, FeedConfig, FeedMetadata, EmailData } from "../types"; import type { Env, FeedConfig, FeedMetadata, EmailData } from "../types";
const mockEnv = () => createMockEnv() as unknown as Env; const mockEnv = () => createMockEnv() as unknown as Env;
const fid = (value: string) => FeedId.fromTrusted(value); const fid = (value: string) => FeedId.unchecked(value);
const sampleConfig = (overrides: Partial<FeedConfig> = {}): FeedConfig => ({ const sampleConfig = (overrides: Partial<FeedConfig> = {}): FeedConfig => ({
title: "Test Feed", title: "Test Feed",
@@ -106,9 +106,11 @@ describe("FeedRepository feed list", () => {
{ {
title, title,
language: "en", language: "en",
created_at: 1000, allowedSenders: [],
blockedSenders: [],
createdAt: 1000,
description: opts.description, description: opts.description,
expires_at: opts.expires_at, expiresAt: opts.expires_at,
}, },
{ emails: [] }, { emails: [] },
); );
+12 -7
View File
@@ -10,6 +10,7 @@ import { FEEDS_LIST_KEY } from "../config/constants";
import { feedKeys } from "../domain/feed-keys"; import { feedKeys } from "../domain/feed-keys";
import { Feed } from "../domain/feed.aggregate"; import { Feed } from "../domain/feed.aggregate";
import { FeedId } from "../domain/value-objects/feed-id"; import { FeedId } from "../domain/value-objects/feed-id";
import { fromConfigDTO, toConfigDTO, toListItemDTO } from "./feed-mapper";
import { logger } from "./logger"; import { logger } from "./logger";
/** /**
@@ -69,19 +70,23 @@ export class FeedRepository {
this.getMetadata(feedId), this.getMetadata(feedId),
]); ]);
if (!config) return null; if (!config) return null;
return Feed.reconstitute(feedId, config, metadata ?? { emails: [] }); return Feed.reconstitute(
feedId,
fromConfigDTO(config),
metadata ?? { emails: [] },
);
} }
/** /**
* Persist both keys the aggregate owns (config + metadata) and keep the global * Persist both keys the aggregate owns (config + metadata) and keep the global
* `feeds:list` entry in sync. The registry projection is derived from * `feeds:list` entry in sync. Config/list DTOs are derived from the aggregate's
* `feed.summary()` here, so no caller has to remember to mirror it. * domain `state()` via `feed-mapper`, so no caller has to mirror snake_case.
*/ */
async save(feed: Feed): Promise<void> { async save(feed: Feed): Promise<void> {
await Promise.all([ await Promise.all([
this.putConfig(feed.id, feed.toConfigSnapshot()), this.putConfig(feed.id, toConfigDTO(feed.state())),
this.putMetadata(feed.id, feed.toMetadataSnapshot()), this.putMetadata(feed.id, feed.toMetadataSnapshot()),
this.upsertListEntry(feed.summary()), this.upsertListEntry(toListItemDTO(feed.id, feed.state())),
]); ]);
} }
@@ -101,8 +106,8 @@ export class FeedRepository {
*/ */
async saveConfig(feed: Feed): Promise<void> { async saveConfig(feed: Feed): Promise<void> {
await Promise.all([ await Promise.all([
this.putConfig(feed.id, feed.toConfigSnapshot()), this.putConfig(feed.id, toConfigDTO(feed.state())),
this.upsertListEntry(feed.summary()), this.upsertListEntry(toListItemDTO(feed.id, feed.state())),
]); ]);
} }
@@ -5,7 +5,7 @@ import { FeedId } from "../domain/value-objects/feed-id";
import type { Env, WebSubSubscription } from "../types"; import type { Env, WebSubSubscription } from "../types";
const mockEnv = () => createMockEnv() as unknown as Env; const mockEnv = () => createMockEnv() as unknown as Env;
const fid = FeedId.fromTrusted("a.b.42"); const fid = FeedId.unchecked("a.b.42");
describe("WebSubSubscriptionRepository", () => { describe("WebSubSubscriptionRepository", () => {
it("round-trips subscriptions and counts feeds with subscribers", async () => { it("round-trips subscriptions and counts feeds with subscribers", async () => {
+1 -1
View File
@@ -13,7 +13,7 @@ import { FeedId } from "../domain/value-objects/feed-id";
import type { Env, WebSubSubscription } from "../types"; import type { Env, WebSubSubscription } from "../types";
const mockEnv = () => createMockEnv() as unknown as Env; const mockEnv = () => createMockEnv() as unknown as Env;
const fid = (value: string) => FeedId.fromTrusted(value); const fid = (value: string) => FeedId.unchecked(value);
describe("buildHmacSignature", () => { describe("buildHmacSignature", () => {
it("returns sha256= prefixed hex", async () => { it("returns sha256= prefixed hex", async () => {
+1 -1
View File
@@ -998,7 +998,7 @@ app.post(
const { title, description } = c.req.valid("json"); const { title, description } = c.req.valid("json");
// Quick-edit: only title/description, expiry untouched. // Quick-edit: only title/description, expiry untouched.
const result = await editFeedDetails(env, FeedId.fromTrusted(feedId), { const result = await editFeedDetails(env, FeedId.unchecked(feedId), {
title, title,
description, description,
}); });
+3 -3
View File
@@ -158,7 +158,7 @@ emailsRouter.get("/feeds/:feedId/emails", async (c) => {
const message = c.req.query("message"); const message = c.req.query("message");
const count = Number(c.req.query("count") || "0"); const count = Number(c.req.query("count") || "0");
const id = FeedId.fromTrusted(feedId); const id = FeedId.unchecked(feedId);
const feedConfig = await repo.getConfig(id); const feedConfig = await repo.getConfig(id);
const feedMetadata = await repo.getMetadata(id); const feedMetadata = await repo.getMetadata(id);
@@ -656,7 +656,7 @@ emailsRouter.post("/emails/:emailKey/delete", async (c) => {
return c.text("Feed ID is required", 400); return c.text("Feed ID is required", 400);
} }
const feed = await repo.load(FeedId.fromTrusted(feedId)); const feed = await repo.load(FeedId.unchecked(feedId));
await repo.deleteEmail(emailKey); await repo.deleteEmail(emailKey);
if (feed) { if (feed) {
@@ -691,7 +691,7 @@ emailsRouter.post("/feeds/:feedId/emails/bulk-delete", async (c) => {
(c.req.header("Accept") || "").includes("application/json"); (c.req.header("Accept") || "").includes("application/json");
try { try {
const feed = await repo.load(FeedId.fromTrusted(feedId)); const feed = await repo.load(FeedId.unchecked(feedId));
if (!feed) { if (!feed) {
return wantsJson return wantsJson
+7 -7
View File
@@ -153,7 +153,7 @@ feedsRouter.get("/:feedId/edit", async (c) => {
const feedId = c.req.param("feedId"); const feedId = c.req.param("feedId");
const feedConfig = await FeedRepository.from(env).getConfig( const feedConfig = await FeedRepository.from(env).getConfig(
FeedId.fromTrusted(feedId), FeedId.unchecked(feedId),
); );
if (!feedConfig) { if (!feedConfig) {
@@ -335,7 +335,7 @@ feedsRouter.post("/:feedId/edit", async (c) => {
blockedSenders, blockedSenders,
}); });
const result = await editFeed(env, FeedId.fromTrusted(feedId), { const result = await editFeed(env, FeedId.unchecked(feedId), {
title: parsedData.title, title: parsedData.title,
description: parsedData.description, description: parsedData.description,
language: parsedData.language, language: parsedData.language,
@@ -365,7 +365,7 @@ feedsRouter.post("/:feedId/edit", async (c) => {
feedsRouter.post("/:feedId/sender-filter", async (c) => { feedsRouter.post("/:feedId/sender-filter", async (c) => {
const env = c.env; const env = c.env;
const feedId = c.req.param("feedId"); const feedId = c.req.param("feedId");
const id = FeedId.fromTrusted(feedId); const id = FeedId.unchecked(feedId);
const repo = FeedRepository.from(env); const repo = FeedRepository.from(env);
const body = await c.req.json().catch(() => null); const body = await c.req.json().catch(() => null);
@@ -422,7 +422,7 @@ feedsRouter.post("/:feedId/delete", async (c) => {
const wantsJson = (c.req.header("Accept") || "").includes("application/json"); const wantsJson = (c.req.header("Accept") || "").includes("application/json");
try { try {
await deleteFeedRecord(env, FeedId.fromTrusted(feedId), (p) => await deleteFeedRecord(env, FeedId.unchecked(feedId), (p) =>
waitUntilSafe(c, p), waitUntilSafe(c, p),
); );
@@ -460,7 +460,7 @@ feedsRouter.post("/:feedId/purge", async (c) => {
const step = await purgeFeedKeysStep( const step = await purgeFeedKeysStep(
emailStorage, emailStorage,
FeedId.fromTrusted(feedId), FeedId.unchecked(feedId),
{ {
cursor, cursor,
limit, limit,
@@ -522,7 +522,7 @@ feedsRouter.post("/bulk-delete", async (c) => {
for (const feedId of parsedFeedIds) { for (const feedId of parsedFeedIds) {
try { try {
const id = FeedId.fromTrusted(feedId); const id = FeedId.unchecked(feedId);
// Read unsubscribe URLs before the feed metadata is deleted. // Read unsubscribe URLs before the feed metadata is deleted.
const urls = await collectUnsubscribeUrls(emailStorage, id); const urls = await collectUnsubscribeUrls(emailStorage, id);
const result = await deleteFeedFastDetailed(emailStorage, id); const result = await deleteFeedFastDetailed(emailStorage, id);
@@ -606,7 +606,7 @@ feedsRouter.post("/bulk-delete", async (c) => {
for (const feedId of parsedFeedIds) { for (const feedId of parsedFeedIds) {
try { try {
const id = FeedId.fromTrusted(feedId); const id = FeedId.unchecked(feedId);
// Read unsubscribe URLs before the feed metadata is deleted. // Read unsubscribe URLs before the feed metadata is deleted.
const urls = await collectUnsubscribeUrls(emailStorage, id); const urls = await collectUnsubscribeUrls(emailStorage, id);
const result = await deleteFeedFastDetailed(emailStorage, id); const result = await deleteFeedFastDetailed(emailStorage, id);
+7 -9
View File
@@ -176,7 +176,7 @@ apiApp.openapi(
const env = c.env; const env = c.env;
const { feedId } = c.req.valid("param"); const { feedId } = c.req.valid("param");
const repo = FeedRepository.from(env); const repo = FeedRepository.from(env);
const id = FeedId.fromTrusted(feedId); const id = FeedId.unchecked(feedId);
const config = await repo.getConfig(id); const config = await repo.getConfig(id);
if (!config) return c.json({ error: "Feed not found" }, 404); if (!config) return c.json({ error: "Feed not found" }, 404);
const metadata = await repo.getMetadata(id); const metadata = await repo.getMetadata(id);
@@ -209,7 +209,7 @@ apiApp.openapi(
async (c) => { async (c) => {
const env = c.env; const env = c.env;
const { feedId } = c.req.valid("param"); const { feedId } = c.req.valid("param");
const id = FeedId.fromTrusted(feedId); const id = FeedId.unchecked(feedId);
const body = c.req.valid("json"); const body = c.req.valid("json");
const result = await editFeed(env, id, { const result = await editFeed(env, id, {
title: body.title, title: body.title,
@@ -248,10 +248,8 @@ apiApp.openapi(
async (c) => { async (c) => {
const env = c.env; const env = c.env;
const { feedId } = c.req.valid("param"); const { feedId } = c.req.valid("param");
const removed = await deleteFeedRecord( const removed = await deleteFeedRecord(env, FeedId.unchecked(feedId), (p) =>
env, waitUntilSafe(c, p),
FeedId.fromTrusted(feedId),
(p) => waitUntilSafe(c, p),
); );
if (!removed) return c.json({ error: "Feed not found" }, 404); if (!removed) return c.json({ error: "Feed not found" }, 404);
return c.json({ ok: true }, 200); return c.json({ ok: true }, 200);
@@ -278,7 +276,7 @@ apiApp.openapi(
const env = c.env; const env = c.env;
const { feedId } = c.req.valid("param"); const { feedId } = c.req.valid("param");
const metadata = await FeedRepository.from(env).getMetadata( const metadata = await FeedRepository.from(env).getMetadata(
FeedId.fromTrusted(feedId), FeedId.unchecked(feedId),
); );
if (!metadata) return c.json({ error: "Feed not found" }, 404); if (!metadata) return c.json({ error: "Feed not found" }, 404);
return c.json( return c.json(
@@ -315,7 +313,7 @@ apiApp.openapi(
const { feedId, entryId } = c.req.valid("param"); const { feedId, entryId } = c.req.valid("param");
const receivedAt = parseInt(entryId, 10); const receivedAt = parseInt(entryId, 10);
const repo = FeedRepository.from(env); const repo = FeedRepository.from(env);
const metadata = await repo.getMetadata(FeedId.fromTrusted(feedId)); const metadata = await repo.getMetadata(FeedId.unchecked(feedId));
const metaEntry = metadata?.emails.find((e) => e.receivedAt === receivedAt); const metaEntry = metadata?.emails.find((e) => e.receivedAt === receivedAt);
if (!metaEntry) return c.json({ error: "Email not found" }, 404); if (!metaEntry) return c.json({ error: "Email not found" }, 404);
const data = await repo.getEmail(metaEntry.key); const data = await repo.getEmail(metaEntry.key);
@@ -359,7 +357,7 @@ apiApp.openapi(
const repo = FeedRepository.from(env); const repo = FeedRepository.from(env);
const { feedId, entryId } = c.req.valid("param"); const { feedId, entryId } = c.req.valid("param");
const receivedAt = parseInt(entryId, 10); const receivedAt = parseInt(entryId, 10);
const feed = await repo.load(FeedId.fromTrusted(feedId)); const feed = await repo.load(FeedId.unchecked(feedId));
const metaEntry = feed?.emails.find((e) => e.receivedAt === receivedAt); const metaEntry = feed?.emails.find((e) => e.receivedAt === receivedAt);
if (!feed || !metaEntry) return c.json({ error: "Email not found" }, 404); if (!feed || !metaEntry) return c.json({ error: "Email not found" }, 404);
+1 -1
View File
@@ -13,7 +13,7 @@ export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
return new Response("Feed ID is required", { status: 400 }); return new Response("Feed ID is required", { status: 400 });
} }
const feedData = await fetchFeedData(FeedId.fromTrusted(feedId), c.env); const feedData = await fetchFeedData(FeedId.unchecked(feedId), c.env);
if (!feedData) { if (!feedData) {
return new Response("Feed not found", { status: 404 }); return new Response("Feed not found", { status: 404 });
} }
+1 -1
View File
@@ -16,7 +16,7 @@ export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
} }
const repo = FeedRepository.from(c.env); const repo = FeedRepository.from(c.env);
const id = FeedId.fromTrusted(feedId); const id = FeedId.unchecked(feedId);
const [feedMetadata, feedConfig] = await Promise.all([ const [feedMetadata, feedConfig] = await Promise.all([
repo.getMetadata(id), repo.getMetadata(id),
+1 -1
View File
@@ -45,7 +45,7 @@ export async function handleFeedFavicon(
if (!feedId) return projectFavicon(); if (!feedId) return projectFavicon();
const metadata = await FeedRepository.from(env).getMetadata( const metadata = await FeedRepository.from(env).getMetadata(
FeedId.fromTrusted(feedId), FeedId.unchecked(feedId),
); );
const domain = metadata?.iconDomain; const domain = metadata?.iconDomain;
if (!domain) return projectFavicon(); if (!domain) return projectFavicon();
+1 -1
View File
@@ -72,7 +72,7 @@ hubRouter.post("/", async (c) => {
); );
} }
const format = match[1] as "rss" | "atom"; const format = match[1] as "rss" | "atom";
const feedId = FeedId.fromTrusted(match[2]); const feedId = FeedId.unchecked(match[2]);
// Verify the feed exists before accepting any subscription // Verify the feed exists before accepting any subscription
const feedConfig = await FeedRepository.from(env).getConfig(feedId); const feedConfig = await FeedRepository.from(env).getConfig(feedId);
+1 -1
View File
@@ -13,7 +13,7 @@ export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
return new Response("Feed ID is required", { status: 400 }); return new Response("Feed ID is required", { status: 400 });
} }
const feedData = await fetchFeedData(FeedId.fromTrusted(feedId), c.env); const feedData = await fetchFeedData(FeedId.unchecked(feedId), c.env);
if (!feedData) { if (!feedData) {
return new Response("Feed not found", { status: 404 }); return new Response("Feed not found", { status: 404 });
} }