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(