diff --git a/CLAUDE.md b/CLAUDE.md index 325f9fd..1fbb8a3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,14 +57,15 @@ src/ config/constants.ts # Shared constants (TTLs, limits) types/index.ts # Env, FeedConfig, EmailData, WebSubSubscription, etc. 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-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() - 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) 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) feed-service.ts # createFeedRecord / editFeedDetails / editFeed / deleteFeedRecord (admin UI + REST API) 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 logger.ts # JSON structured logger 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:*) websub-subscription-repository.ts # KV adapter for WebSub subscriber lists (websub:subs:*) 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. - **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()`. - - **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. - - **`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. + - **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. + - **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. + - **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). - 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. -- **`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. + - **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 (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`) diff --git a/src/application/email-processor.ts b/src/application/email-processor.ts index 96e72d9..ca3a045 100644 --- a/src/application/email-processor.ts +++ b/src/application/email-processor.ts @@ -1,7 +1,7 @@ import { EmailParser } from "../domain/email-parser"; import { AttachmentData, EmailMetadata, Env } from "../types"; import { bumpCounters } from "../application/stats"; -import { applyFeedEvents } from "../application/feed-events"; +import { dispatchFeedEvents } from "../application/feed-events"; import { extractEmailDomain } from "../infrastructure/favicon-fetcher"; import { parseOneClickUnsubscribe } from "../infrastructure/unsubscribe"; import { getAttachmentBucket } from "../infrastructure/attachments"; @@ -188,7 +188,7 @@ async function storeEmail( const schedule: BackgroundScheduler = ctx ? (p) => ctx.waitUntil(p) : () => {}; - await applyFeedEvents(feed.id, feed.pullEvents(), env, schedule); + await dispatchFeedEvents(feed, env, schedule); } export async function processEmail( diff --git a/src/application/feed-events.ts b/src/application/feed-events.ts index e257092..d874f7d 100644 --- a/src/application/feed-events.ts +++ b/src/application/feed-events.ts @@ -1,6 +1,6 @@ import { Env } from "../types"; 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 { bumpCounters } from "./stats"; 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 - * "what happened" (FeedCreated, EmailIngested) to its consequences. Counter - * writes are awaited (they must land); WebSub pings and favicon fetches are - * handed to the caller's background scheduler (`ctx.waitUntil` at the edge, a - * no-op when none is available). + * "what happened" (FeedCreated, EmailIngested) to its consequences. Each event + * carries its own `feedId`, so nothing has to be threaded in. Counter writes are + * awaited (they must land); WebSub pings and favicon fetches are handed to the + * caller's background scheduler (`ctx.waitUntil` at the edge, a no-op when none + * is available). */ export async function applyFeedEvents( - feedId: FeedId, events: FeedEvent[], env: Env, schedule: BackgroundScheduler, @@ -32,7 +32,7 @@ export async function applyFeedEvents( emails_received: 1, last_email_at: new Date().toISOString(), }); - schedule(notifySubscribers(feedId, env)); + schedule(notifySubscribers(event.feedId, env)); if (event.iconDomain) { 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 { + await applyFeedEvents(feed.pullEvents(), env, schedule); +} diff --git a/src/application/feed-service.test.ts b/src/application/feed-service.test.ts index 1c1408d..cd154c1 100644 --- a/src/application/feed-service.test.ts +++ b/src/application/feed-service.test.ts @@ -60,7 +60,7 @@ describe("editFeed — TTL policy", () => { const { feedId } = await createFeedRecord(env, { ...baseInput }); const before = Date.now(); - const result = await editFeed(env, FeedId.fromTrusted(feedId), { + const result = await editFeed(env, FeedId.unchecked(feedId), { title: "renamed", }); @@ -78,7 +78,7 @@ describe("editFeed — TTL policy", () => { lifetimeHours: 5, }); - const result = await editFeed(env, FeedId.fromTrusted(feedId), { + const result = await editFeed(env, FeedId.unchecked(feedId), { title: "x", }); diff --git a/src/application/feed-service.ts b/src/application/feed-service.ts index 620f047..c4d62dc 100644 --- a/src/application/feed-service.ts +++ b/src/application/feed-service.ts @@ -1,11 +1,13 @@ import { Env, FeedConfig } from "../types"; import { bumpCounters } from "../application/stats"; -import { applyFeedEvents } from "./feed-events"; +import { dispatchFeedEvents } from "./feed-events"; import { sendUnsubscribes } from "../infrastructure/unsubscribe"; import { getAttachmentBucket } from "../infrastructure/attachments"; import { FeedRepository } from "../infrastructure/feed-repository"; +import { toConfigDTO } from "../infrastructure/feed-mapper"; import { BackgroundScheduler } from "../infrastructure/worker"; import { FeedId } from "../domain/value-objects/feed-id"; +import { Lifetime } from "../domain/value-objects/lifetime"; import { Feed, CreateFeedInput, @@ -16,19 +18,18 @@ import { purgeFeedKeysStep, collectUnsubscribeUrls } from "./feed-cleanup"; 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 * 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( - env: Env, - requestedHours?: number, -): number | undefined { +function resolveLifetime(env: Env, requestedHours?: number): Lifetime { const hours = env.FEED_TTL_HOURS ? parseInt(env.FEED_TTL_HOURS, 10) : (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 }> { const repo = FeedRepository.from(env); const feed = Feed.create(FeedId.generate(), input, { - ttlHours: resolveTtlHours(env, input.lifetimeHours), + lifetime: resolveLifetime(env, input.lifetimeHours), }); await repo.save(feed); // 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 = @@ -72,12 +73,13 @@ export async function editFeedDetails( const feed = await repo.load(feedId); 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" }; } 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); if (!feed) return { status: "not_found" }; - const recomputeExpiry = - Boolean(env.FEED_TTL_HOURS) || input.lifetimeHours !== undefined; - if ( - feed.edit(input, { - recomputeExpiry, - ttlHours: resolveTtlHours(env, input.lifetimeHours), - }).status === "expired" - ) { + // Recompute expiry only when a server TTL or a client lifetime applies; + // otherwise pass no lifetime so the aggregate preserves the current expiry. + const lifetime = + Boolean(env.FEED_TTL_HOURS) || input.lifetimeHours !== undefined + ? resolveLifetime(env, input.lifetimeHours) + : undefined; + if (feed.edit(input, { lifetime }).status === "expired") { return { status: "expired" }; } await repo.saveConfig(feed); - return { status: "ok", config: feed.toConfigSnapshot() }; + return { status: "ok", config: toConfigDTO(feed.state()) }; } type DeleteFeedFastResult = { diff --git a/src/application/stats.ts b/src/application/stats.ts index 2f50c35..979c93d 100644 --- a/src/application/stats.ts +++ b/src/application/stats.ts @@ -110,7 +110,7 @@ export async function scanKvUsage(kv: KVNamespace): Promise<{ bytes: number }> { const repo = new FeedRepository(kv); const feeds = await repo.listFeeds(); 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; for (const email of metadata.emails) { bytes += email.size ?? 0; diff --git a/src/domain/email-parser.ts b/src/domain/email-parser.ts index e8d0234..fc1c75d 100644 --- a/src/domain/email-parser.ts +++ b/src/domain/email-parser.ts @@ -6,7 +6,7 @@ export class EmailParser { * 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 * 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 { return FeedId.parse(emailAddress); diff --git a/src/domain/events.ts b/src/domain/events.ts index 6058ab1..389cef8 100644 --- a/src/domain/events.ts +++ b/src/domain/events.ts @@ -1,9 +1,11 @@ +import { FeedId } from "./value-objects/feed-id"; + /** * Domain events the Feed aggregate records when it mutates. They describe *what - * happened* in business terms; the application layer decides which side effects - * to run (counters, WebSub pings, favicon caching) via a dispatcher. This keeps - * the aggregate ignorant of infrastructure and the orchestration code free of - * scattered, inline side effects. + * happened* in business terms and carry their own `feedId`, so the application + * dispatcher can route side effects (counters, WebSub pings, favicon caching) + * without the caller threading the id back in. This keeps the aggregate ignorant + * of infrastructure and the orchestration code free of scattered, inline effects. * * Only mutations that currently have side effects emit events — feed creation * 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. */ export type FeedEvent = - | { type: "FeedCreated" } - | { type: "EmailIngested"; iconDomain?: string }; + | { type: "FeedCreated"; feedId: FeedId } + | { type: "EmailIngested"; feedId: FeedId; iconDomain?: string }; diff --git a/src/domain/feed-state.ts b/src/domain/feed-state.ts new file mode 100644 index 0000000..b7727c4 --- /dev/null +++ b/src/domain/feed-state.ts @@ -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; +} diff --git a/src/domain/feed.aggregate.test.ts b/src/domain/feed.aggregate.test.ts index a5656ca..05bdae1 100644 --- a/src/domain/feed.aggregate.test.ts +++ b/src/domain/feed.aggregate.test.ts @@ -3,10 +3,12 @@ import { createMockEnv } from "../test/setup"; import { Feed, CreateFeedInput } from "./feed.aggregate"; import { FeedRepository } from "../infrastructure/feed-repository"; import { FeedId } from "./value-objects/feed-id"; +import { Lifetime } from "./value-objects/lifetime"; +import { FeedState } from "./feed-state"; import { Clock } from "./clock"; 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; @@ -22,6 +24,15 @@ const createInput = ( ...overrides, }); +const state = (overrides: Partial = {}): FeedState => ({ + title: "T", + language: "en", + allowedSenders: [], + blockedSenders: [], + createdAt: 0, + ...overrides, +}); + const entry = (overrides: Partial = {}): EmailMetadata => ({ key: "feed:a.b.42:1", subject: "Hello", @@ -39,43 +50,41 @@ describe("Feed.create", () => { 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 feed = Feed.create(FID, createInput(), { clock: fixedClock(NOW), - ttlHours: 2, + lifetime: Lifetime.ofHours(2), }); 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", () => { + it("trusts only deps.lifetime, not the client lifetimeHours field", () => { // The aggregate no longer parses lifetime policy: the application resolves - // the effective ttlHours (env override etc.) and hands it in. - const feed = Feed.create(FID, createInput({ lifetimeHours: 9999 }), { - ttlHours: undefined, - }); + // the effective Lifetime (env override etc.) and hands it in. + const feed = Feed.create(FID, createInput({ lifetimeHours: 9999 })); expect(feed.expiresAt).toBeUndefined(); }); - it("treats a non-positive ttlHours as no expiry", () => { + it("treats a non-positive lifetime as no expiry", () => { expect( - Feed.create(FID, createInput(), { ttlHours: 0 }).expiresAt, + Feed.create(FID, createInput(), { lifetime: Lifetime.ofHours(0) }) + .expiresAt, ).toBeUndefined(); expect( - Feed.create(FID, createInput(), { ttlHours: -5 }).expiresAt, + Feed.create(FID, createInput(), { lifetime: Lifetime.ofHours(-5) }) + .expiresAt, ).toBeUndefined(); }); }); describe("Feed.isExpired / accepts", () => { it("reports expiry against the configured instant", () => { - const feed = Feed.reconstitute( - FID, - { title: "T", language: "en", created_at: 0, expires_at: 100 }, - { emails: [] }, - ); + const feed = Feed.reconstitute(FID, state({ expiresAt: 100 }), { + emails: [], + }); expect(feed.isExpired(50)).toBe(false); expect(feed.isExpired(150)).toBe(true); }); @@ -83,7 +92,7 @@ describe("Feed.isExpired / accepts", () => { it("uses the injected clock when no instant is supplied", () => { const feed = Feed.reconstitute( FID, - { title: "T", language: "en", created_at: 0, expires_at: 100 }, + state({ expiresAt: 100 }), { emails: [] }, fixedClock(150), ); @@ -93,12 +102,7 @@ describe("Feed.isExpired / accepts", () => { it("applies the sender policy", () => { const feed = Feed.reconstitute( FID, - { - title: "T", - language: "en", - created_at: 0, - allowed_senders: ["good@example.com"], - }, + state({ allowedSenders: ["good@example.com"] }), { emails: [] }, ); expect(feed.accepts(["good@example.com"])).toBe("accepted"); @@ -107,28 +111,28 @@ describe("Feed.isExpired / accepts", () => { }); describe("Feed.edit", () => { - it("recomputes expiry only when asked", () => { + it("recomputes expiry only when a lifetime is supplied", () => { const NOW = 5_000_000; const FUTURE = NOW + 10 * 3_600_000; const feed = Feed.reconstitute( FID, - { title: "T", language: "en", created_at: 0, expires_at: FUTURE }, + state({ expiresAt: FUTURE }), { emails: [] }, fixedClock(NOW), ); - feed.edit({ title: "T2" }, { recomputeExpiry: false }); - expect(feed.expiresAt).toBe(FUTURE); // preserved + feed.edit({ title: "T2" }); // no lifetime ⇒ expiry preserved + expect(feed.expiresAt).toBe(FUTURE); 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); }); it("refuses to edit an already-expired feed", () => { const feed = Feed.reconstitute( FID, - { title: "T", language: "en", created_at: 0, expires_at: 100 }, + state({ expiresAt: 100 }), { emails: [] }, fixedClock(200), ); @@ -138,13 +142,9 @@ describe("Feed.edit", () => { describe("Feed.ingest", () => { it("prepends the entry, tracks icon/unsub and trims to the byte budget", () => { - const feed = Feed.reconstitute( - FID, - { title: "T", language: "en", created_at: 0 }, - { - emails: [entry({ key: "old", size: 400 })], - }, - ); + const feed = Feed.reconstitute(FID, state(), { + emails: [entry({ key: "old", size: 400 })], + }); const { dropped } = feed.ingest(entry({ key: "new", size: 400 }), { maxBytes: 500, @@ -162,11 +162,7 @@ describe("Feed.ingest", () => { }); 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 feed = Feed.reconstitute(FID, state(), { emails: [] }); const { dropped } = feed.ingest(entry({ key: "huge", size: 999 }), { maxBytes: 1, @@ -179,17 +175,13 @@ describe("Feed.ingest", () => { describe("Feed.removeEmails", () => { it("drops matching keys and returns the removed entries", () => { - const feed = Feed.reconstitute( - FID, - { title: "T", language: "en", created_at: 0 }, - { - emails: [ - entry({ key: "k1" }), - entry({ key: "k2" }), - entry({ key: "k3" }), - ], - }, - ); + const feed = Feed.reconstitute(FID, state(), { + 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"]); @@ -200,35 +192,31 @@ describe("Feed.removeEmails", () => { describe("Feed events", () => { it("records FeedCreated on create and drains it once", () => { 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. expect(feed.pullEvents()).toEqual([]); }); it("records EmailIngested (with icon domain) on ingest", () => { - const feed = Feed.reconstitute( - FID, - { title: "T", language: "en", created_at: 0 }, - { emails: [] }, - ); + const feed = Feed.reconstitute(FID, state(), { emails: [] }); feed.ingest(entry({ key: "k" }), { maxBytes: 1_000_000, iconDomain: "example.com", }); expect(feed.pullEvents()).toEqual([ - { type: "EmailIngested", iconDomain: "example.com" }, + { type: "EmailIngested", feedId: FID, iconDomain: "example.com" }, ]); }); 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 }, + state({ expiresAt: 9_999_999_999 }), { emails: [entry({ key: "k1" })] }, fixedClock(1000), ); - feed.edit({ title: "X" }, { recomputeExpiry: false }); - feed.edit({ description: "Y" }, { recomputeExpiry: false }); + feed.edit({ title: "X" }); + feed.edit({ description: "Y" }); feed.removeEmails(["k1"]); expect(feed.pullEvents()).toEqual([]); }); @@ -253,6 +241,6 @@ describe("FeedRepository.load / save round-trip", () => { it("returns null when the feed has no config", async () => { const repo = new FeedRepository(mockEnv().EMAIL_STORAGE); - expect(await repo.load(FeedId.fromTrusted("missing"))).toBeNull(); + expect(await repo.load(FeedId.unchecked("missing"))).toBeNull(); }); }); diff --git a/src/domain/feed.aggregate.ts b/src/domain/feed.aggregate.ts index 7d1e7e9..487ee59 100644 --- a/src/domain/feed.aggregate.ts +++ b/src/domain/feed.aggregate.ts @@ -1,32 +1,10 @@ -import { - FeedConfig, - FeedMetadata, - EmailMetadata, - FeedListItem, -} from "../types"; +import { FeedMetadata, EmailMetadata } from "../types"; +import { FeedState } from "./feed-state"; import { FeedId } from "./value-objects/feed-id"; +import { Lifetime } from "./value-objects/lifetime"; import { SenderPolicy, SenderDecision } from "./value-objects/sender-policy"; import { Clock, systemClock } from "./clock"; 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 { title: string; @@ -34,6 +12,7 @@ export interface CreateFeedInput { language: string; allowedSenders: string[]; blockedSenders: string[]; + /** Raw client-requested lifetime; the application resolves it into a `Lifetime`. */ lifetimeHours?: number; } @@ -48,24 +27,23 @@ export interface UpdateFeedInput { /** * Dependencies the aggregate needs from the outside but must not reach for - * itself: a clock (never ambient `Date.now()`) and an already-resolved feed - * lifetime. The application layer decides the lifetime — parsing env config and - * applying any server-side `FEED_TTL_HOURS` override — and hands the result in. + * itself: a clock (never ambient `Date.now()`) and an already-resolved + * `Lifetime`. The application layer decides the lifetime — parsing env config and + * applying any server-side `FEED_TTL_HOURS` override — and hands the VO in. */ export interface CreateFeedDeps { clock?: Clock; - /** Effective lifetime in hours, already resolved by the application. */ - ttlHours?: number; + /** Effective lifetime, already resolved by the application. */ + lifetime?: Lifetime; } 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 - * (mirrors the old "no server TTL and no client lifetime ⇒ leave as-is"). + * Effective lifetime, already resolved by the application. Its *presence* means + * "recompute expiry"; its absence preserves the current expiry — which covers + * the dashboard's title/description quick-edit. */ - recomputeExpiry?: boolean; + lifetime?: Lifetime; } export interface IngestOptions { @@ -83,17 +61,19 @@ export interface IngestOptions { * deliberately sit *outside* the aggregate — the caller flushes them alongside * `FeedRepository.save`/`saveMetadata`. * - * I/O-free and time-free: load and persist state through `FeedRepository`; time - * comes from an 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). + * Its config is held as domain `FeedState` (camelCase), never the snake_case + * persistence DTO — `FeedRepository` translates via `feed-mapper.ts`. I/O-free + * and time-free: load and persist through the repository; time comes from an + * 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 { private readonly _events: FeedEvent[] = []; private constructor( readonly id: FeedId, - private _config: FeedConfig, + private _state: FeedState, private _metadata: FeedMetadata, private readonly clock: Clock, ) {} @@ -106,60 +86,60 @@ export class Feed { ): Feed { const clock = deps.clock ?? systemClock; const now = clock.now(); - const expiresAt = resolveExpiresAt(deps.ttlHours, now); - const config: FeedConfig = { + const expiresAt = (deps.lifetime ?? Lifetime.never).resolveExpiry(now); + const state: FeedState = { 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 } : {}), + allowedSenders: input.allowedSenders, + blockedSenders: input.blockedSenders, + createdAt: now, + updatedAt: now, + expiresAt, }; - const feed = new Feed(id, config, { emails: [] }, clock); - feed._events.push({ type: "FeedCreated" }); + const feed = new Feed(id, state, { emails: [] }, clock); + feed._events.push({ type: "FeedCreated", feedId: id }); return feed; } - /** Rebuild an aggregate from persisted state. */ + /** Rebuild an aggregate from persisted (already-mapped) domain state. */ static reconstitute( id: FeedId, - config: FeedConfig, + state: FeedState, metadata: FeedMetadata, clock: Clock = systemClock, ): Feed { - return new Feed(id, config, metadata, clock); + return new Feed(id, state, metadata, clock); } // ── 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()`. + // raw `state`/`metadata` objects — a shallow `Readonly<…>` would still let a + // caller mutate the arrays inside. Persistence reads `state()` / + // `toMetadataSnapshot()`; the mapper derives the DTOs. get title(): string { - return this._config.title; + return this._state.title; } get description(): string | undefined { - return this._config.description; + return this._state.description; } get language(): string { - return this._config.language; + return this._state.language; } get createdAt(): number { - return this._config.created_at; + return this._state.createdAt; } get updatedAt(): number | undefined { - return this._config.updated_at; + return this._state.updatedAt; } get expiresAt(): number | undefined { - return this._config.expires_at; + return this._state.expiresAt; } get iconDomain(): string | undefined { @@ -167,11 +147,11 @@ export class Feed { } allowedSenders(): string[] { - return [...(this._config.allowed_senders ?? [])]; + return [...this._state.allowedSenders]; } blockedSenders(): string[] { - return [...(this._config.blocked_senders ?? [])]; + return [...this._state.blockedSenders]; } /** A copy of the email index — mutating it never touches aggregate state. */ @@ -184,21 +164,15 @@ export class Feed { 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 copy of the domain config state for the repository to map + persist. */ + state(): FeedState { + return { + ...this._state, + allowedSenders: [...this._state.allowedSenders], + blockedSenders: [...this._state.blockedSenders], + }; } /** 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 { - 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 { return SenderPolicy.fromLists( - this._config.allowed_senders, - this._config.blocked_senders, + this._state.allowedSenders, + this._state.blockedSenders, ).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); } @@ -287,11 +267,11 @@ export class Feed { /** * 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. + * recompute expiry when the application supplies a `Lifetime` — an absent + * lifetime preserves the current expiry, which covers the dashboard's + * title/description quick-edit. 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, @@ -300,23 +280,23 @@ export class Feed { if (this.isExpired()) return { status: "expired" }; const now = this.clock.now(); - const expiresAt = deps.recomputeExpiry - ? resolveExpiresAt(deps.ttlHours, now) - : this._config.expires_at; + const expiresAt = deps.lifetime + ? deps.lifetime.resolveExpiry(now) + : 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) { - 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) { - this._config.allowed_senders = patch.allowedSenders; + this._state.allowedSenders = patch.allowedSenders; } if (patch.blockedSenders !== undefined) { - this._config.blocked_senders = patch.blockedSenders; + this._state.blockedSenders = patch.blockedSenders; } - this._config.updated_at = now; - this._config.expires_at = expiresAt; + this._state.updatedAt = now; + this._state.expiresAt = expiresAt; return { status: "ok" }; } diff --git a/src/domain/value-objects/feed-id.ts b/src/domain/value-objects/feed-id.ts index fcbf14a..6fcb499 100644 --- a/src/domain/value-objects/feed-id.ts +++ b/src/domain/value-objects/feed-id.ts @@ -17,11 +17,13 @@ export class FeedId { } /** - * Wrap an id we already trust — a value we minted ourselves and round-tripped - * through our own links or KV keys (route params, the feed list, email keys). - * No validation: a wrong id simply misses in KV and 404s, exactly as before. + * Wrap a string as a FeedId WITHOUT revalidating it. The caller asserts the id + * originated from our own minting — a route param echoing a stored id, a + * `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); } diff --git a/src/domain/value-objects/lifetime.test.ts b/src/domain/value-objects/lifetime.test.ts new file mode 100644 index 0000000..8166e1e --- /dev/null +++ b/src/domain/value-objects/lifetime.test.ts @@ -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(); + }); +}); diff --git a/src/domain/value-objects/lifetime.ts b/src/domain/value-objects/lifetime.ts new file mode 100644 index 0000000..ff12338 --- /dev/null +++ b/src/domain/value-objects/lifetime.ts @@ -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; + } +} diff --git a/src/index.ts b/src/index.ts index a1ae60b..75b8420 100644 --- a/src/index.ts +++ b/src/index.ts @@ -209,7 +209,7 @@ export default { for (const feedId of expiredIds) { await purgeExpiredFeeds( env.EMAIL_STORAGE, - FeedId.fromTrusted(feedId), + FeedId.unchecked(feedId), attachmentBucket, ); } diff --git a/src/infrastructure/feed-mapper.test.ts b/src/infrastructure/feed-mapper.test.ts new file mode 100644 index 0000000..1a85422 --- /dev/null +++ b/src/infrastructure/feed-mapper.test.ts @@ -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, + }); + }); +}); diff --git a/src/infrastructure/feed-mapper.ts b/src/infrastructure/feed-mapper.ts new file mode 100644 index 0000000..512fe3d --- /dev/null +++ b/src/infrastructure/feed-mapper.ts @@ -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, + }; +} diff --git a/src/infrastructure/feed-repository.test.ts b/src/infrastructure/feed-repository.test.ts index 969c76a..9f28ac2 100644 --- a/src/infrastructure/feed-repository.test.ts +++ b/src/infrastructure/feed-repository.test.ts @@ -6,7 +6,7 @@ import { FeedId } from "../domain/value-objects/feed-id"; import type { Env, FeedConfig, FeedMetadata, EmailData } from "../types"; 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 => ({ title: "Test Feed", @@ -106,9 +106,11 @@ describe("FeedRepository feed list", () => { { title, language: "en", - created_at: 1000, + allowedSenders: [], + blockedSenders: [], + createdAt: 1000, description: opts.description, - expires_at: opts.expires_at, + expiresAt: opts.expires_at, }, { emails: [] }, ); diff --git a/src/infrastructure/feed-repository.ts b/src/infrastructure/feed-repository.ts index 720595f..74ff6e6 100644 --- a/src/infrastructure/feed-repository.ts +++ b/src/infrastructure/feed-repository.ts @@ -10,6 +10,7 @@ import { FEEDS_LIST_KEY } from "../config/constants"; import { feedKeys } from "../domain/feed-keys"; import { Feed } from "../domain/feed.aggregate"; import { FeedId } from "../domain/value-objects/feed-id"; +import { fromConfigDTO, toConfigDTO, toListItemDTO } from "./feed-mapper"; import { logger } from "./logger"; /** @@ -69,19 +70,23 @@ export class FeedRepository { this.getMetadata(feedId), ]); 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 - * `feeds:list` entry in sync. The registry projection is derived from - * `feed.summary()` here, so no caller has to remember to mirror it. + * `feeds:list` entry in sync. Config/list DTOs are derived from the aggregate's + * domain `state()` via `feed-mapper`, so no caller has to mirror snake_case. */ async save(feed: Feed): Promise { await Promise.all([ - this.putConfig(feed.id, feed.toConfigSnapshot()), + this.putConfig(feed.id, toConfigDTO(feed.state())), 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 { await Promise.all([ - this.putConfig(feed.id, feed.toConfigSnapshot()), - this.upsertListEntry(feed.summary()), + this.putConfig(feed.id, toConfigDTO(feed.state())), + this.upsertListEntry(toListItemDTO(feed.id, feed.state())), ]); } diff --git a/src/infrastructure/websub-subscription-repository.test.ts b/src/infrastructure/websub-subscription-repository.test.ts index 4d100c6..1d8fe70 100644 --- a/src/infrastructure/websub-subscription-repository.test.ts +++ b/src/infrastructure/websub-subscription-repository.test.ts @@ -5,7 +5,7 @@ import { FeedId } from "../domain/value-objects/feed-id"; import type { Env, WebSubSubscription } from "../types"; const mockEnv = () => createMockEnv() as unknown as Env; -const fid = FeedId.fromTrusted("a.b.42"); +const fid = FeedId.unchecked("a.b.42"); describe("WebSubSubscriptionRepository", () => { it("round-trips subscriptions and counts feeds with subscribers", async () => { diff --git a/src/infrastructure/websub.test.ts b/src/infrastructure/websub.test.ts index 1336536..09031b0 100644 --- a/src/infrastructure/websub.test.ts +++ b/src/infrastructure/websub.test.ts @@ -13,7 +13,7 @@ import { FeedId } from "../domain/value-objects/feed-id"; import type { Env, WebSubSubscription } from "../types"; const mockEnv = () => createMockEnv() as unknown as Env; -const fid = (value: string) => FeedId.fromTrusted(value); +const fid = (value: string) => FeedId.unchecked(value); describe("buildHmacSignature", () => { it("returns sha256= prefixed hex", async () => { diff --git a/src/routes/admin.tsx b/src/routes/admin.tsx index d3c6f8f..8740b0d 100644 --- a/src/routes/admin.tsx +++ b/src/routes/admin.tsx @@ -998,7 +998,7 @@ app.post( const { title, description } = c.req.valid("json"); // Quick-edit: only title/description, expiry untouched. - const result = await editFeedDetails(env, FeedId.fromTrusted(feedId), { + const result = await editFeedDetails(env, FeedId.unchecked(feedId), { title, description, }); diff --git a/src/routes/admin/emails.tsx b/src/routes/admin/emails.tsx index e532063..9a0b105 100644 --- a/src/routes/admin/emails.tsx +++ b/src/routes/admin/emails.tsx @@ -158,7 +158,7 @@ emailsRouter.get("/feeds/:feedId/emails", async (c) => { const message = c.req.query("message"); 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 feedMetadata = await repo.getMetadata(id); @@ -656,7 +656,7 @@ emailsRouter.post("/emails/:emailKey/delete", async (c) => { 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); if (feed) { @@ -691,7 +691,7 @@ emailsRouter.post("/feeds/:feedId/emails/bulk-delete", async (c) => { (c.req.header("Accept") || "").includes("application/json"); try { - const feed = await repo.load(FeedId.fromTrusted(feedId)); + const feed = await repo.load(FeedId.unchecked(feedId)); if (!feed) { return wantsJson diff --git a/src/routes/admin/feeds.tsx b/src/routes/admin/feeds.tsx index edcaaf3..1452f5d 100644 --- a/src/routes/admin/feeds.tsx +++ b/src/routes/admin/feeds.tsx @@ -153,7 +153,7 @@ feedsRouter.get("/:feedId/edit", async (c) => { const feedId = c.req.param("feedId"); const feedConfig = await FeedRepository.from(env).getConfig( - FeedId.fromTrusted(feedId), + FeedId.unchecked(feedId), ); if (!feedConfig) { @@ -335,7 +335,7 @@ feedsRouter.post("/:feedId/edit", async (c) => { blockedSenders, }); - const result = await editFeed(env, FeedId.fromTrusted(feedId), { + const result = await editFeed(env, FeedId.unchecked(feedId), { title: parsedData.title, description: parsedData.description, language: parsedData.language, @@ -365,7 +365,7 @@ feedsRouter.post("/:feedId/edit", async (c) => { feedsRouter.post("/:feedId/sender-filter", async (c) => { const env = c.env; const feedId = c.req.param("feedId"); - const id = FeedId.fromTrusted(feedId); + const id = FeedId.unchecked(feedId); const repo = FeedRepository.from(env); 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"); try { - await deleteFeedRecord(env, FeedId.fromTrusted(feedId), (p) => + await deleteFeedRecord(env, FeedId.unchecked(feedId), (p) => waitUntilSafe(c, p), ); @@ -460,7 +460,7 @@ feedsRouter.post("/:feedId/purge", async (c) => { const step = await purgeFeedKeysStep( emailStorage, - FeedId.fromTrusted(feedId), + FeedId.unchecked(feedId), { cursor, limit, @@ -522,7 +522,7 @@ feedsRouter.post("/bulk-delete", async (c) => { for (const feedId of parsedFeedIds) { try { - const id = FeedId.fromTrusted(feedId); + const id = FeedId.unchecked(feedId); // Read unsubscribe URLs before the feed metadata is deleted. const urls = await collectUnsubscribeUrls(emailStorage, id); const result = await deleteFeedFastDetailed(emailStorage, id); @@ -606,7 +606,7 @@ feedsRouter.post("/bulk-delete", async (c) => { for (const feedId of parsedFeedIds) { try { - const id = FeedId.fromTrusted(feedId); + const id = FeedId.unchecked(feedId); // Read unsubscribe URLs before the feed metadata is deleted. const urls = await collectUnsubscribeUrls(emailStorage, id); const result = await deleteFeedFastDetailed(emailStorage, id); diff --git a/src/routes/api/index.ts b/src/routes/api/index.ts index 91d53e5..81ead7f 100644 --- a/src/routes/api/index.ts +++ b/src/routes/api/index.ts @@ -176,7 +176,7 @@ apiApp.openapi( const env = c.env; const { feedId } = c.req.valid("param"); const repo = FeedRepository.from(env); - const id = FeedId.fromTrusted(feedId); + const id = FeedId.unchecked(feedId); const config = await repo.getConfig(id); if (!config) return c.json({ error: "Feed not found" }, 404); const metadata = await repo.getMetadata(id); @@ -209,7 +209,7 @@ apiApp.openapi( async (c) => { const env = c.env; const { feedId } = c.req.valid("param"); - const id = FeedId.fromTrusted(feedId); + const id = FeedId.unchecked(feedId); const body = c.req.valid("json"); const result = await editFeed(env, id, { title: body.title, @@ -248,10 +248,8 @@ apiApp.openapi( async (c) => { const env = c.env; const { feedId } = c.req.valid("param"); - const removed = await deleteFeedRecord( - env, - FeedId.fromTrusted(feedId), - (p) => waitUntilSafe(c, p), + const removed = await deleteFeedRecord(env, FeedId.unchecked(feedId), (p) => + waitUntilSafe(c, p), ); if (!removed) return c.json({ error: "Feed not found" }, 404); return c.json({ ok: true }, 200); @@ -278,7 +276,7 @@ apiApp.openapi( const env = c.env; const { feedId } = c.req.valid("param"); const metadata = await FeedRepository.from(env).getMetadata( - FeedId.fromTrusted(feedId), + FeedId.unchecked(feedId), ); if (!metadata) return c.json({ error: "Feed not found" }, 404); return c.json( @@ -315,7 +313,7 @@ apiApp.openapi( const { feedId, entryId } = c.req.valid("param"); const receivedAt = parseInt(entryId, 10); 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); if (!metaEntry) return c.json({ error: "Email not found" }, 404); const data = await repo.getEmail(metaEntry.key); @@ -359,7 +357,7 @@ apiApp.openapi( const repo = FeedRepository.from(env); const { feedId, entryId } = c.req.valid("param"); 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); if (!feed || !metaEntry) return c.json({ error: "Email not found" }, 404); diff --git a/src/routes/atom.ts b/src/routes/atom.ts index f069a59..66da266 100644 --- a/src/routes/atom.ts +++ b/src/routes/atom.ts @@ -13,7 +13,7 @@ export async function handle(c: Context<{ Bindings: Env }>): Promise { 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) { return new Response("Feed not found", { status: 404 }); } diff --git a/src/routes/entries.ts b/src/routes/entries.ts index 090b145..a072ced 100644 --- a/src/routes/entries.ts +++ b/src/routes/entries.ts @@ -16,7 +16,7 @@ export async function handle(c: Context<{ Bindings: Env }>): Promise { } const repo = FeedRepository.from(c.env); - const id = FeedId.fromTrusted(feedId); + const id = FeedId.unchecked(feedId); const [feedMetadata, feedConfig] = await Promise.all([ repo.getMetadata(id), diff --git a/src/routes/favicon.ts b/src/routes/favicon.ts index a7f96b0..2a0873b 100644 --- a/src/routes/favicon.ts +++ b/src/routes/favicon.ts @@ -45,7 +45,7 @@ export async function handleFeedFavicon( if (!feedId) return projectFavicon(); const metadata = await FeedRepository.from(env).getMetadata( - FeedId.fromTrusted(feedId), + FeedId.unchecked(feedId), ); const domain = metadata?.iconDomain; if (!domain) return projectFavicon(); diff --git a/src/routes/hub.ts b/src/routes/hub.ts index b142e3b..5f03e37 100644 --- a/src/routes/hub.ts +++ b/src/routes/hub.ts @@ -72,7 +72,7 @@ hubRouter.post("/", async (c) => { ); } 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 const feedConfig = await FeedRepository.from(env).getConfig(feedId); diff --git a/src/routes/rss.ts b/src/routes/rss.ts index 00e61a6..4d85274 100644 --- a/src/routes/rss.ts +++ b/src/routes/rss.ts @@ -13,7 +13,7 @@ export async function handle(c: Context<{ Bindings: Env }>): Promise { 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) { return new Response("Feed not found", { status: 404 }); }