refactor(domain): introduce the Feed aggregate as the write-path API

Add a Feed aggregate class owning config + the email index, with create,
ingest, removeEmails, isExpired and accepts delegating to the existing
pure invariant functions. FeedRepository gains load/save/saveMetadata
that reconstitute and persist the aggregate.

All write paths now go through it: createFeedRecord (Feed.create),
email ingestion (feed.ingest), and every email deletion in the admin UI
and REST API (feed.removeEmails) — no route mutates metadata.emails
directly anymore. KV key strings unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Julien Herr
2026-05-24 00:33:14 +02:00
parent a31ff42f59
commit c45f6677fe
8 changed files with 415 additions and 131 deletions
+135
View File
@@ -0,0 +1,135 @@
import { Env, FeedConfig, FeedMetadata, EmailMetadata } from "../types";
import {
resolveExpiresAt,
isExpired,
applySenderPolicy,
trimToByteBudget,
SenderDecision,
} from "./feed";
export interface CreateFeedInput {
title: string;
description?: string;
language: string;
allowedSenders: string[];
blockedSenders: string[];
lifetimeHours?: number;
}
export interface UpdateFeedInput {
title?: string;
description?: string;
language?: string;
allowedSenders?: string[];
blockedSenders?: string[];
lifetimeHours?: number;
}
export interface IngestOptions {
maxBytes: number;
iconDomain?: string;
/** RFC 8058 one-click unsubscribe link, keyed by the sending newsletter. */
unsub?: { senderKey: string; url: string };
}
/**
* The Feed aggregate: the consistency boundary around a feed's config and the
* metadata index of its emails. All mutations to either go through a method
* here so the invariants (expiry policy, sender policy, byte budget) live in one
* place. Email bodies are large blobs referenced by `metadata.emails[].key` and
* deliberately sit *outside* the aggregate — the caller flushes them alongside
* `FeedRepository.save`/`saveMetadata`.
*
* I/O-free: load and persist state through `FeedRepository`. KV has no multi-key
* transaction, so a future Durable Object keyed by feed id would wrap
* load→mutate→save to serialise concurrent writers (see email-processor.ts).
*/
export class Feed {
private constructor(
readonly id: string,
private _config: FeedConfig,
private _metadata: FeedMetadata,
) {}
/** Mint a brand-new feed with an empty email index. */
static create(id: string, input: CreateFeedInput, env: Env): Feed {
const now = Date.now();
const expiresAt = resolveExpiresAt(env, input.lifetimeHours);
const config: FeedConfig = {
title: input.title,
description: input.description,
language: input.language,
allowed_senders: input.allowedSenders,
blocked_senders: input.blockedSenders,
created_at: now,
updated_at: now,
...(expiresAt !== undefined ? { expires_at: expiresAt } : {}),
};
return new Feed(id, config, { emails: [] });
}
/** Rebuild an aggregate from persisted state. */
static reconstitute(
id: string,
config: FeedConfig,
metadata: FeedMetadata,
): Feed {
return new Feed(id, config, metadata);
}
get config(): Readonly<FeedConfig> {
return this._config;
}
get metadata(): Readonly<FeedMetadata> {
return this._metadata;
}
isExpired(now: number = Date.now()): boolean {
return isExpired(this._config, now);
}
accepts(senders: string[]): SenderDecision {
return applySenderPolicy(this._config, senders);
}
/**
* Add an email to the front of the index, refresh the icon domain and the
* per-sender unsubscribe link, then trim the oldest entries back under the
* byte budget. Returns the dropped entries so the caller can purge their
* bodies/attachments.
*/
ingest(
entry: EmailMetadata,
opts: IngestOptions,
): { dropped: EmailMetadata[] } {
this._metadata.emails.unshift(entry);
if (opts.iconDomain) {
this._metadata.iconDomain = opts.iconDomain;
}
if (opts.unsub) {
this._metadata.unsubscribe = {
...(this._metadata.unsubscribe ?? {}),
[opts.unsub.senderKey]: opts.unsub.url,
};
}
return trimToByteBudget(this._metadata, opts.maxBytes);
}
/**
* Drop the given email keys from the index. Returns the removed entries so the
* caller can purge their bodies/attachments.
*/
removeEmails(keys: string[]): { removed: EmailMetadata[] } {
const target = new Set(keys);
const removed: EmailMetadata[] = [];
const kept: EmailMetadata[] = [];
for (const entry of this._metadata.emails) {
(target.has(entry.key) ? removed : kept).push(entry);
}
this._metadata.emails = kept;
return { removed };
}
}