mirror of
https://github.com/juherr/kill-the-news.git
synced 2026-06-20 14:03:47 +00:00
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:
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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,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,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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user