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)
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`)
+2 -2
View File
@@ -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(
+20 -7
View File
@@ -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<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 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",
});
+23 -22
View File
@@ -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 = {
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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);
+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
* 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 };
+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;
}
+51 -63
View File
@@ -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> = {}): FeedState => ({
title: "T",
language: "en",
allowedSenders: [],
blockedSenders: [],
createdAt: 0,
...overrides,
});
const entry = (overrides: Partial<EmailMetadata> = {}): 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();
});
});
+75 -95
View File
@@ -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" };
}
+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
* 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);
}
+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) {
await purgeExpiredFeeds(
env.EMAIL_STORAGE,
FeedId.fromTrusted(feedId),
FeedId.unchecked(feedId),
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";
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 => ({
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: [] },
);
+12 -7
View File
@@ -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<void> {
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<void> {
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())),
]);
}
@@ -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 () => {
+1 -1
View File
@@ -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 () => {
+1 -1
View File
@@ -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,
});
+3 -3
View File
@@ -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
+7 -7
View File
@@ -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);
+7 -9
View File
@@ -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);
+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 });
}
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 });
}
+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 id = FeedId.fromTrusted(feedId);
const id = FeedId.unchecked(feedId);
const [feedMetadata, feedConfig] = await Promise.all([
repo.getMetadata(id),
+1 -1
View File
@@ -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();
+1 -1
View File
@@ -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);
+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 });
}
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 });
}