From a31ff42f59faa527efea2c1cdf6e22e120ba7939 Mon Sep 17 00:00:00 2001 From: Julien Herr Date: Sun, 24 May 2026 00:27:33 +0200 Subject: [PATCH] refactor(domain): split Icon/WebSub/Counters out of FeedRepository FeedRepository no longer owns favicons, WebSub subscriber lists or the monitoring counters singleton. Each concern gets its own repository (IconRepository, WebSubSubscriptionRepository, CountersRepository), sharing the key schema via feed-keys. KV key strings are unchanged; counters increment policy stays in utils/stats.ts. Co-Authored-By: Claude Opus 4.7 --- .claude/launch.json | 30 +++++++++ src/domain/counters-repository.test.ts | 21 ++++++ src/domain/counters-repository.ts | 23 +++++++ src/domain/feed-repository.test.ts | 45 +------------ src/domain/feed-repository.ts | 64 +------------------ src/domain/icon-repository.test.ts | 18 ++++++ src/domain/icon-repository.ts | 31 +++++++++ .../websub-subscription-repository.test.ts | 19 ++++++ src/domain/websub-subscription-repository.ts | 45 +++++++++++++ src/utils/favicon-fetcher.ts | 10 +-- src/utils/stats.test.ts | 2 +- src/utils/stats.ts | 10 +-- src/utils/websub.ts | 5 +- 13 files changed, 204 insertions(+), 119 deletions(-) create mode 100644 .claude/launch.json create mode 100644 src/domain/counters-repository.test.ts create mode 100644 src/domain/counters-repository.ts create mode 100644 src/domain/icon-repository.test.ts create mode 100644 src/domain/icon-repository.ts create mode 100644 src/domain/websub-subscription-repository.test.ts create mode 100644 src/domain/websub-subscription-repository.ts diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..7ba45f7 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,30 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "docs", + "runtimeExecutable": "npx", + "runtimeArgs": ["serve", "docs", "-p", "4321", "--no-clipboard"], + "port": 4321 + }, + { + "name": "dev", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev"], + "port": 8787 + }, + { + "name": "dev-build", + "runtimeExecutable": "npx", + "runtimeArgs": [ + "wrangler", + "dev", + "--config", + "wrangler.build.toml", + "--port", + "8788" + ], + "port": 8788 + } + ] +} diff --git a/src/domain/counters-repository.test.ts b/src/domain/counters-repository.test.ts new file mode 100644 index 0000000..b06ba34 --- /dev/null +++ b/src/domain/counters-repository.test.ts @@ -0,0 +1,21 @@ +import { describe, it, expect } from "vitest"; +import { createMockEnv } from "../test/setup"; +import { CountersRepository } from "./counters-repository"; +import type { Env } from "../types"; + +const mockEnv = () => createMockEnv() as unknown as Env; + +describe("CountersRepository", () => { + it("round-trips the counters singleton", async () => { + const repo = new CountersRepository(mockEnv().EMAIL_STORAGE); + expect(await repo.getRaw()).toBeNull(); + await repo.put({ + feeds_created: 1, + feeds_deleted: 0, + emails_received: 2, + emails_rejected: 0, + unsubscribes_sent: 0, + }); + expect(await repo.getRaw()).toMatchObject({ emails_received: 2 }); + }); +}); diff --git a/src/domain/counters-repository.ts b/src/domain/counters-repository.ts new file mode 100644 index 0000000..7a3c57e --- /dev/null +++ b/src/domain/counters-repository.ts @@ -0,0 +1,23 @@ +import { Counters, Env } from "../types"; +import { STATS_KEY } from "./feed-keys"; + +/** + * KV access for the monitoring counters singleton (`stats:counters`). The + * increment policy lives in the application layer (utils/stats.ts); this + * repository owns only the raw read/write of the blob. + */ +export class CountersRepository { + constructor(private readonly kv: KVNamespace) {} + + static from(env: Env): CountersRepository { + return new CountersRepository(env.EMAIL_STORAGE); + } + + async getRaw(): Promise { + return (await this.kv.get(STATS_KEY, { type: "json" })) as Counters | null; + } + + async put(counters: Counters): Promise { + await this.kv.put(STATS_KEY, JSON.stringify(counters)); + } +} diff --git a/src/domain/feed-repository.test.ts b/src/domain/feed-repository.test.ts index aa4ba46..12b7142 100644 --- a/src/domain/feed-repository.test.ts +++ b/src/domain/feed-repository.test.ts @@ -1,13 +1,7 @@ import { describe, it, expect } from "vitest"; import { createMockEnv } from "../test/setup"; import { FeedRepository } from "./feed-repository"; -import type { - Env, - FeedConfig, - FeedMetadata, - EmailData, - WebSubSubscription, -} from "../types"; +import type { Env, FeedConfig, FeedMetadata, EmailData } from "../types"; const mockEnv = () => createMockEnv() as unknown as Env; @@ -31,7 +25,6 @@ describe("FeedRepository key schema", () => { it("builds the canonical KV keys via the public API", () => { const repo = new FeedRepository(mockEnv().EMAIL_STORAGE); expect(repo.feedKeyPrefix("a.b.42")).toBe("feed:a.b.42:"); - expect(repo.iconKey("example.com")).toBe("icon:example.com"); expect(repo.newEmailKey("a.b.42")).toMatch(/^feed:a\.b\.42:\d+$/); }); @@ -133,39 +126,3 @@ describe("FeedRepository feed list", () => { expect((await repo.listFeeds()).map((f) => f.id)).toEqual(["c.d.99"]); }); }); - -describe("FeedRepository counters, icons, websub", () => { - it("round-trips raw counters", async () => { - const repo = new FeedRepository(mockEnv().EMAIL_STORAGE); - expect(await repo.getCountersRaw()).toBeNull(); - await repo.putCounters({ - feeds_created: 1, - feeds_deleted: 0, - emails_received: 2, - emails_rejected: 0, - unsubscribes_sent: 0, - }); - expect(await repo.getCountersRaw()).toMatchObject({ emails_received: 2 }); - }); - - it("stores and reads favicons as text or json", async () => { - const repo = new FeedRepository(mockEnv().EMAIL_STORAGE); - expect(await repo.getIconText("example.com")).toBeNull(); - await repo.putIcon("example.com", JSON.stringify({ data: null }), 60); - expect(await repo.getIconText("example.com")).toBe('{"data":null}'); - expect(await repo.getIconJson<{ data: null }>("example.com")).toEqual({ - data: null, - }); - }); - - it("round-trips websub subscriptions and counts them", async () => { - const repo = new FeedRepository(mockEnv().EMAIL_STORAGE); - expect(await repo.getSubscriptions("a.b.42")).toEqual([]); - const subs: WebSubSubscription[] = [ - { callbackUrl: "https://r.example/cb", expiresAt: 9999 }, - ]; - await repo.saveSubscriptions("a.b.42", subs); - expect(await repo.getSubscriptions("a.b.42")).toEqual(subs); - expect(await repo.countSubscriptionKeys()).toBe(1); - }); -}); diff --git a/src/domain/feed-repository.ts b/src/domain/feed-repository.ts index 7c74baa..d5dcc92 100644 --- a/src/domain/feed-repository.ts +++ b/src/domain/feed-repository.ts @@ -1,14 +1,12 @@ import { - Counters, EmailData, Env, FeedConfig, FeedList, FeedListItem, FeedMetadata, - WebSubSubscription, } from "../types"; -import { FEEDS_LIST_KEY, STATS_KEY } from "../config/constants"; +import { FEEDS_LIST_KEY } from "../config/constants"; import { feedKeys } from "./feed-keys"; import { logger } from "../lib/logger"; @@ -37,15 +35,6 @@ export class FeedRepository { return feedKeys.metadata(feedId); } - /** KV key for a domain's cached favicon (shared across feeds). */ - iconKey(domain: string): string { - return feedKeys.icon(domain); - } - - private websubKey(feedId: string): string { - return feedKeys.websub(feedId); - } - /** Prefix covering every key owned by a feed (config, metadata, emails). */ feedKeyPrefix(feedId: string): string { return feedKeys.feedPrefix(feedId); @@ -241,55 +230,4 @@ export class FeedRepository { } return total; } - - /** Number of feeds that currently hold at least one WebSub subscription. */ - countSubscriptionKeys(): Promise { - return this.countKeysByPrefix("websub:"); - } - - // ── Monitoring counters ─────────────────────────────────────────────────── - - async getCountersRaw(): Promise { - return (await this.kv.get(STATS_KEY, { type: "json" })) as Counters | null; - } - - async putCounters(counters: Counters): Promise { - await this.kv.put(STATS_KEY, JSON.stringify(counters)); - } - - // ── Favicons ────────────────────────────────────────────────────────────── - - async getIconText(domain: string): Promise { - return this.kv.get(this.iconKey(domain), "text"); - } - - async getIconJson(domain: string): Promise { - return (await this.kv.get(this.iconKey(domain), { - type: "json", - })) as T | null; - } - - async putIcon( - domain: string, - value: string, - ttlSeconds: number, - ): Promise { - await this.kv.put(this.iconKey(domain), value, { - expirationTtl: ttlSeconds, - }); - } - - // ── WebSub subscriptions ────────────────────────────────────────────────── - - async getSubscriptions(feedId: string): Promise { - const raw = await this.kv.get(this.websubKey(feedId), "json"); - return (raw as WebSubSubscription[] | null) ?? []; - } - - async saveSubscriptions( - feedId: string, - subscriptions: WebSubSubscription[], - ): Promise { - await this.kv.put(this.websubKey(feedId), JSON.stringify(subscriptions)); - } } diff --git a/src/domain/icon-repository.test.ts b/src/domain/icon-repository.test.ts new file mode 100644 index 0000000..b5d4c07 --- /dev/null +++ b/src/domain/icon-repository.test.ts @@ -0,0 +1,18 @@ +import { describe, it, expect } from "vitest"; +import { createMockEnv } from "../test/setup"; +import { IconRepository } from "./icon-repository"; +import type { Env } from "../types"; + +const mockEnv = () => createMockEnv() as unknown as Env; + +describe("IconRepository", () => { + it("stores and reads favicons as text or json under the icon: key", async () => { + const repo = new IconRepository(mockEnv().EMAIL_STORAGE); + expect(await repo.getText("example.com")).toBeNull(); + await repo.put("example.com", JSON.stringify({ data: null }), 60); + expect(await repo.getText("example.com")).toBe('{"data":null}'); + expect(await repo.getJson<{ data: null }>("example.com")).toEqual({ + data: null, + }); + }); +}); diff --git a/src/domain/icon-repository.ts b/src/domain/icon-repository.ts new file mode 100644 index 0000000..e791f57 --- /dev/null +++ b/src/domain/icon-repository.ts @@ -0,0 +1,31 @@ +import { Env } from "../types"; +import { feedKeys } from "./feed-keys"; + +/** + * KV access for cached per-domain favicons (`icon:`). Entries may be + * positive (base64 bytes) or negative (a sentinel marking a failed fetch), and + * always carry a TTL — the cache's sole expiry mechanism. + */ +export class IconRepository { + constructor(private readonly kv: KVNamespace) {} + + static from(env: Env): IconRepository { + return new IconRepository(env.EMAIL_STORAGE); + } + + getText(domain: string): Promise { + return this.kv.get(feedKeys.icon(domain), "text"); + } + + async getJson(domain: string): Promise { + return (await this.kv.get(feedKeys.icon(domain), { + type: "json", + })) as T | null; + } + + async put(domain: string, value: string, ttlSeconds: number): Promise { + await this.kv.put(feedKeys.icon(domain), value, { + expirationTtl: ttlSeconds, + }); + } +} diff --git a/src/domain/websub-subscription-repository.test.ts b/src/domain/websub-subscription-repository.test.ts new file mode 100644 index 0000000..2691c5d --- /dev/null +++ b/src/domain/websub-subscription-repository.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from "vitest"; +import { createMockEnv } from "../test/setup"; +import { WebSubSubscriptionRepository } from "./websub-subscription-repository"; +import type { Env, WebSubSubscription } from "../types"; + +const mockEnv = () => createMockEnv() as unknown as Env; + +describe("WebSubSubscriptionRepository", () => { + it("round-trips subscriptions and counts feeds with subscribers", async () => { + const repo = new WebSubSubscriptionRepository(mockEnv().EMAIL_STORAGE); + expect(await repo.get("a.b.42")).toEqual([]); + const subs: WebSubSubscription[] = [ + { callbackUrl: "https://r.example/cb", expiresAt: 9999 }, + ]; + await repo.save("a.b.42", subs); + expect(await repo.get("a.b.42")).toEqual(subs); + expect(await repo.countKeys()).toBe(1); + }); +}); diff --git a/src/domain/websub-subscription-repository.ts b/src/domain/websub-subscription-repository.ts new file mode 100644 index 0000000..622af06 --- /dev/null +++ b/src/domain/websub-subscription-repository.ts @@ -0,0 +1,45 @@ +import { Env, WebSubSubscription } from "../types"; +import { feedKeys } from "./feed-keys"; +import { logger } from "../lib/logger"; + +/** + * KV access for per-feed WebSub subscriber lists (`websub:subs:`). + */ +export class WebSubSubscriptionRepository { + constructor(private readonly kv: KVNamespace) {} + + static from(env: Env): WebSubSubscriptionRepository { + return new WebSubSubscriptionRepository(env.EMAIL_STORAGE); + } + + async get(feedId: string): Promise { + const raw = await this.kv.get(feedKeys.websub(feedId), "json"); + return (raw as WebSubSubscription[] | null) ?? []; + } + + async save( + feedId: string, + subscriptions: WebSubSubscription[], + ): Promise { + await this.kv.put(feedKeys.websub(feedId), JSON.stringify(subscriptions)); + } + + /** Number of feeds that currently hold at least one WebSub subscription. */ + async countKeys(): Promise { + const prefix = feedKeys.websubPrefix(); + let total = 0; + let cursor: string | undefined; + try { + do { + const listed = await this.kv.list({ prefix, cursor, limit: 1000 }); + total += listed.keys.length; + cursor = listed.list_complete ? undefined : listed.cursor; + } while (cursor); + } catch (error) { + logger.error("Error counting subscription keys", { + error: String(error), + }); + } + return total; + } +} diff --git a/src/utils/favicon-fetcher.ts b/src/utils/favicon-fetcher.ts index 3fd891f..19e7ebd 100644 --- a/src/utils/favicon-fetcher.ts +++ b/src/utils/favicon-fetcher.ts @@ -4,7 +4,7 @@ import { ICON_TTL_SECONDS, MAX_ICON_BYTES, } from "../config/constants"; -import { FeedRepository } from "../domain/feed-repository"; +import { IconRepository } from "../domain/icon-repository"; import { EmailAddress } from "../domain/value-objects/email-address"; import { logger } from "../lib/logger"; @@ -90,8 +90,8 @@ export async function cacheFaviconForDomain( env: Env, ): Promise { try { - const repo = FeedRepository.from(env); - const existing = await repo.getIconText(domain); + const repo = IconRepository.from(env); + const existing = await repo.getText(domain); if (existing !== null) return; // present (incl. negative) → nothing to do const icon = await resolveIcon(domain); @@ -102,7 +102,7 @@ export async function cacheFaviconForDomain( } : { data: null, contentType: "" }; - await repo.putIcon(domain, JSON.stringify(record), ICON_TTL_SECONDS); + await repo.put(domain, JSON.stringify(record), ICON_TTL_SECONDS); } catch (error) { logger.warn("Favicon cache failed", { domain, error: String(error) }); } @@ -115,7 +115,7 @@ export async function getCachedIcon( domain: string, env: Env, ): Promise<{ bytes: ArrayBuffer; contentType: string } | null> { - const record = await FeedRepository.from(env).getIconJson(domain); + const record = await IconRepository.from(env).getJson(domain); if (!record || record.data === null) return null; return { bytes: base64ToArrayBuffer(record.data), diff --git a/src/utils/stats.test.ts b/src/utils/stats.test.ts index 6fbde1a..eacc5aa 100644 --- a/src/utils/stats.test.ts +++ b/src/utils/stats.test.ts @@ -87,7 +87,7 @@ describe("stats helper", () => { ], }), ); - await kv.put("websub:a:1", "{}"); + await kv.put("websub:subs:a", "{}"); await bumpCounters(kv, { emails_received: 5, feeds_created: 2 }); const stats = await getStats(env); diff --git a/src/utils/stats.ts b/src/utils/stats.ts index b084869..cb25f5d 100644 --- a/src/utils/stats.ts +++ b/src/utils/stats.ts @@ -1,6 +1,8 @@ import { Counters, Env, StatsResponse } from "../types"; import { logger } from "../lib/logger"; import { FeedRepository } from "../domain/feed-repository"; +import { CountersRepository } from "../domain/counters-repository"; +import { WebSubSubscriptionRepository } from "../domain/websub-subscription-repository"; import { getAttachmentBucket } from "./attachments"; const EMPTY_COUNTERS: Counters = { @@ -13,7 +15,7 @@ const EMPTY_COUNTERS: Counters = { export async function getCounters(kv: KVNamespace): Promise { try { - const stored = await new FeedRepository(kv).getCountersRaw(); + const stored = await new CountersRepository(kv).getRaw(); return { ...EMPTY_COUNTERS, ...(stored || {}) }; } catch (error) { logger.error("Error reading counters", { error: String(error) }); @@ -44,7 +46,7 @@ export async function bumpCounters( current.last_feed_created_at = changes.last_feed_created_at; if (!current.first_seen) current.first_seen = new Date().toISOString(); - await new FeedRepository(kv).putCounters(current); + await new CountersRepository(kv).put(current); } catch (error) { logger.error("Error updating counters", { error: String(error) }); } @@ -62,7 +64,7 @@ export async function getStats(env: Env): Promise { const [counters, feeds, websubCount] = await Promise.all([ getCounters(env.EMAIL_STORAGE), repo.listFeeds(), - repo.countSubscriptionKeys(), + WebSubSubscriptionRepository.from(env).countKeys(), ]); return { @@ -138,7 +140,7 @@ export async function setStorageSnapshot( current.kv_bytes_estimated = snapshot.kv_bytes_estimated; current.storage_scanned_at = new Date().toISOString(); if (!current.first_seen) current.first_seen = new Date().toISOString(); - await new FeedRepository(kv).putCounters(current); + await new CountersRepository(kv).put(current); } catch (error) { logger.error("Error writing storage snapshot", { error: String(error) }); } diff --git a/src/utils/websub.ts b/src/utils/websub.ts index 8f76189..4419fb1 100644 --- a/src/utils/websub.ts +++ b/src/utils/websub.ts @@ -2,12 +2,13 @@ import { Env, FeedConfig, EmailData, WebSubSubscription } from "../types"; import { generateRssFeed, generateAtomFeed } from "./feed-generator"; import { baseUrl, feedRssUrl, feedAtomUrl, feedUrl } from "./urls"; import { FeedRepository } from "../domain/feed-repository"; +import { WebSubSubscriptionRepository } from "../domain/websub-subscription-repository"; export async function getSubscriptions( feedId: string, env: Env, ): Promise { - return FeedRepository.from(env).getSubscriptions(feedId); + return WebSubSubscriptionRepository.from(env).get(feedId); } export async function saveSubscriptions( @@ -15,7 +16,7 @@ export async function saveSubscriptions( subscriptions: WebSubSubscription[], env: Env, ): Promise { - await FeedRepository.from(env).saveSubscriptions(feedId, subscriptions); + await WebSubSubscriptionRepository.from(env).save(feedId, subscriptions); } export async function buildHmacSignature(