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 <noreply@anthropic.com>
This commit is contained in:
Julien Herr
2026-05-24 00:27:33 +02:00
parent b347f2f625
commit a31ff42f59
13 changed files with 204 additions and 119 deletions
+30
View File
@@ -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
}
]
}
+21
View File
@@ -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 });
});
});
+23
View File
@@ -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<Counters | null> {
return (await this.kv.get(STATS_KEY, { type: "json" })) as Counters | null;
}
async put(counters: Counters): Promise<void> {
await this.kv.put(STATS_KEY, JSON.stringify(counters));
}
}
+1 -44
View File
@@ -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);
});
});
+1 -63
View File
@@ -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<number> {
return this.countKeysByPrefix("websub:");
}
// ── Monitoring counters ───────────────────────────────────────────────────
async getCountersRaw(): Promise<Counters | null> {
return (await this.kv.get(STATS_KEY, { type: "json" })) as Counters | null;
}
async putCounters(counters: Counters): Promise<void> {
await this.kv.put(STATS_KEY, JSON.stringify(counters));
}
// ── Favicons ──────────────────────────────────────────────────────────────
async getIconText(domain: string): Promise<string | null> {
return this.kv.get(this.iconKey(domain), "text");
}
async getIconJson<T>(domain: string): Promise<T | null> {
return (await this.kv.get(this.iconKey(domain), {
type: "json",
})) as T | null;
}
async putIcon(
domain: string,
value: string,
ttlSeconds: number,
): Promise<void> {
await this.kv.put(this.iconKey(domain), value, {
expirationTtl: ttlSeconds,
});
}
// ── WebSub subscriptions ──────────────────────────────────────────────────
async getSubscriptions(feedId: string): Promise<WebSubSubscription[]> {
const raw = await this.kv.get(this.websubKey(feedId), "json");
return (raw as WebSubSubscription[] | null) ?? [];
}
async saveSubscriptions(
feedId: string,
subscriptions: WebSubSubscription[],
): Promise<void> {
await this.kv.put(this.websubKey(feedId), JSON.stringify(subscriptions));
}
}
+18
View File
@@ -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,
});
});
});
+31
View File
@@ -0,0 +1,31 @@
import { Env } from "../types";
import { feedKeys } from "./feed-keys";
/**
* KV access for cached per-domain favicons (`icon:<domain>`). 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<string | null> {
return this.kv.get(feedKeys.icon(domain), "text");
}
async getJson<T>(domain: string): Promise<T | null> {
return (await this.kv.get(feedKeys.icon(domain), {
type: "json",
})) as T | null;
}
async put(domain: string, value: string, ttlSeconds: number): Promise<void> {
await this.kv.put(feedKeys.icon(domain), value, {
expirationTtl: ttlSeconds,
});
}
}
@@ -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);
});
});
@@ -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:<feedId>`).
*/
export class WebSubSubscriptionRepository {
constructor(private readonly kv: KVNamespace) {}
static from(env: Env): WebSubSubscriptionRepository {
return new WebSubSubscriptionRepository(env.EMAIL_STORAGE);
}
async get(feedId: string): Promise<WebSubSubscription[]> {
const raw = await this.kv.get(feedKeys.websub(feedId), "json");
return (raw as WebSubSubscription[] | null) ?? [];
}
async save(
feedId: string,
subscriptions: WebSubSubscription[],
): Promise<void> {
await this.kv.put(feedKeys.websub(feedId), JSON.stringify(subscriptions));
}
/** Number of feeds that currently hold at least one WebSub subscription. */
async countKeys(): Promise<number> {
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;
}
}
+5 -5
View File
@@ -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<void> {
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<IconRecord>(domain);
const record = await IconRepository.from(env).getJson<IconRecord>(domain);
if (!record || record.data === null) return null;
return {
bytes: base64ToArrayBuffer(record.data),
+1 -1
View File
@@ -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);
+6 -4
View File
@@ -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<Counters> {
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<StatsResponse> {
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) });
}
+3 -2
View File
@@ -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<WebSubSubscription[]> {
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<void> {
await FeedRepository.from(env).saveSubscriptions(feedId, subscriptions);
await WebSubSubscriptionRepository.from(env).save(feedId, subscriptions);
}
export async function buildHmacSignature(