refactor(domain): introduce FeedRepository as the single KV access layer

Centralise the KV key schema and all get/put access behind a FeedRepository
class under src/domain/. Every feed/email/list/icon/websub/counter key was
previously inlined across ~12 modules with two divergent storeEmail and
addFeedToList implementations; the dead src/utils/storage.ts write path is
removed and the email key convention unified on feed:<id>:<ts>.

Behaviour-preserving: existing tests pass unchanged in logic, plus a new
feed-repository.test.ts covering CRUD, key builders, list ops and counters.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Julien Herr
2026-05-23 23:56:44 +02:00
parent a0eaebe749
commit 2b3f00f7e3
22 changed files with 616 additions and 539 deletions
+171
View File
@@ -0,0 +1,171 @@
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";
const mockEnv = () => createMockEnv() as unknown as Env;
const sampleConfig = (overrides: Partial<FeedConfig> = {}): FeedConfig => ({
title: "Test Feed",
language: "en",
created_at: 1000,
...overrides,
});
const sampleEmail = (overrides: Partial<EmailData> = {}): EmailData => ({
subject: "Hello",
from: "news@example.com",
content: "<p>hi</p>",
receivedAt: 1234,
headers: {},
...overrides,
});
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+$/);
});
it("recognises email keys vs config/metadata keys", () => {
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
expect(repo.isEmailKey("a.b.42", "feed:a.b.42:config")).toBe(false);
expect(repo.isEmailKey("a.b.42", "feed:a.b.42:metadata")).toBe(false);
expect(repo.isEmailKey("a.b.42", "feed:a.b.42:1700000000000")).toBe(true);
});
it("recovers the feed id from an email key", () => {
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
expect(repo.feedIdFromEmailKey("feed:a.b.42:1700000000000")).toBe("a.b.42");
});
});
describe("FeedRepository config & metadata", () => {
it("round-trips and deletes a feed config", async () => {
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
expect(await repo.getConfig("a.b.42")).toBeNull();
await repo.putConfig("a.b.42", sampleConfig());
expect(await repo.getConfig("a.b.42")).toMatchObject({
title: "Test Feed",
});
await repo.deleteConfig("a.b.42");
expect(await repo.getConfig("a.b.42")).toBeNull();
});
it("round-trips and deletes feed metadata", async () => {
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
const meta: FeedMetadata = { emails: [] };
await repo.putMetadata("a.b.42", meta);
expect(await repo.getMetadata("a.b.42")).toEqual(meta);
await repo.deleteMetadata("a.b.42");
expect(await repo.getMetadata("a.b.42")).toBeNull();
});
});
describe("FeedRepository emails", () => {
it("stores and reads an email under a minted key", async () => {
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
const key = repo.newEmailKey("a.b.42");
await repo.putEmail(key, sampleEmail());
expect(await repo.getEmail(key)).toMatchObject({ subject: "Hello" });
await repo.deleteEmail(key);
expect(await repo.getEmail(key)).toBeNull();
});
it("lists every key under a feed prefix", async () => {
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
await repo.putConfig("a.b.42", sampleConfig());
await repo.putMetadata("a.b.42", { emails: [] });
const emailKey = repo.newEmailKey("a.b.42");
await repo.putEmail(emailKey, sampleEmail());
const listed = await repo.listFeedKeys("a.b.42");
expect(listed.names).toContain("feed:a.b.42:config");
expect(listed.names).toContain("feed:a.b.42:metadata");
expect(listed.names).toContain(emailKey);
expect(listed.names.filter((k) => repo.isEmailKey("a.b.42", k))).toEqual([
emailKey,
]);
});
});
describe("FeedRepository feed list", () => {
it("adds, updates, lists and removes feeds with expiry", async () => {
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
await repo.addToList("a.b.42", "One", "desc", 5000);
await repo.addToList("c.d.99", "Two");
let feeds = await repo.listFeeds();
expect(feeds).toHaveLength(2);
expect(feeds.find((f) => f.id === "a.b.42")).toMatchObject({
title: "One",
expires_at: 5000,
});
await repo.updateInList("a.b.42", "One-updated", undefined, undefined);
feeds = await repo.listFeeds();
const updated = feeds.find((f) => f.id === "a.b.42");
expect(updated).toMatchObject({ title: "One-updated" });
expect(updated?.expires_at).toBeUndefined();
expect(await repo.removeFromList("a.b.42")).toBe(true);
expect(await repo.removeFromList("missing")).toBe(false);
feeds = await repo.listFeeds();
expect(feeds.map((f) => f.id)).toEqual(["c.d.99"]);
});
it("bulk-removes only the matching ids", async () => {
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
await repo.addToList("a.b.42", "One");
await repo.addToList("c.d.99", "Two");
await repo.addToList("e.f.10", "Three");
const removed = await repo.removeFromListBulk(["a.b.42", "e.f.10", "nope"]);
expect(removed.sort()).toEqual(["a.b.42", "e.f.10"]);
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);
});
});