Files
kill-the-news/src/domain/feed-repository.ts
T
Julien HerrandClaude Opus 4.7 a31ff42f59 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 <[email protected]>
2026-05-24 00:27:33 +02:00

234 lines
7.6 KiB
TypeScript

import {
EmailData,
Env,
FeedConfig,
FeedList,
FeedListItem,
FeedMetadata,
} from "../types";
import { FEEDS_LIST_KEY } from "../config/constants";
import { feedKeys } from "./feed-keys";
import { logger } from "../lib/logger";
/**
* Single source of truth for KV access to the Feed aggregate. The key schema
* itself lives in `feed-keys.ts`; this repository owns the get/put operations.
* No other module should build a `feed:`/`feeds:list`/`websub:`/`icon:`/
* `stats:counters` key string — go through `feed-keys` or a repository method.
*
* Wraps one `KVNamespace`; construct per request via `FeedRepository.from(env)`.
*/
export class FeedRepository {
constructor(private readonly kv: KVNamespace) {}
static from(env: Env): FeedRepository {
return new FeedRepository(env.EMAIL_STORAGE);
}
// ── Key schema (delegates to feed-keys) ───────────────────────────────────
private configKey(feedId: string): string {
return feedKeys.config(feedId);
}
private metadataKey(feedId: string): string {
return feedKeys.metadata(feedId);
}
/** Prefix covering every key owned by a feed (config, metadata, emails). */
feedKeyPrefix(feedId: string): string {
return feedKeys.feedPrefix(feedId);
}
/** Mint a fresh, time-ordered email key. Call once and reuse the result. */
newEmailKey(feedId: string): string {
return feedKeys.newEmail(feedId);
}
/** True when `key` is an email entry (not the feed's config/metadata key). */
isEmailKey(feedId: string, key: string): boolean {
return feedKeys.isEmail(feedId, key);
}
/** Recover the feed id embedded in an email key (`feed:<id>:<ts>`). */
feedIdFromEmailKey(key: string): string {
return feedKeys.feedIdFromEmail(key);
}
// ── Feed config ───────────────────────────────────────────────────────────
async getConfig(feedId: string): Promise<FeedConfig | null> {
return (await this.kv.get(this.configKey(feedId), {
type: "json",
})) as FeedConfig | null;
}
async putConfig(feedId: string, config: FeedConfig): Promise<void> {
await this.kv.put(this.configKey(feedId), JSON.stringify(config));
}
async deleteConfig(feedId: string): Promise<void> {
await this.kv.delete(this.configKey(feedId));
}
// ── Feed metadata ─────────────────────────────────────────────────────────
async getMetadata(feedId: string): Promise<FeedMetadata | null> {
return (await this.kv.get(this.metadataKey(feedId), {
type: "json",
})) as FeedMetadata | null;
}
async putMetadata(feedId: string, metadata: FeedMetadata): Promise<void> {
await this.kv.put(this.metadataKey(feedId), JSON.stringify(metadata));
}
async deleteMetadata(feedId: string): Promise<void> {
await this.kv.delete(this.metadataKey(feedId));
}
// ── Emails ────────────────────────────────────────────────────────────────
async putEmail(key: string, data: EmailData): Promise<void> {
await this.kv.put(key, JSON.stringify(data));
}
async getEmail(key: string): Promise<EmailData | null> {
return (await this.kv.get(key, { type: "json" })) as EmailData | null;
}
async deleteEmail(key: string): Promise<void> {
await this.kv.delete(key);
}
// ── Global feed list ──────────────────────────────────────────────────────
async listFeeds(): Promise<FeedListItem[]> {
try {
const feedList = (await this.kv.get(FEEDS_LIST_KEY, {
type: "json",
})) as FeedList | null;
return feedList?.feeds || [];
} catch (error) {
logger.error("Error listing feeds", { error: String(error) });
return [];
}
}
async addToList(
feedId: string,
title: string,
description?: string,
expires_at?: number,
): Promise<void> {
try {
const feedList = ((await this.kv.get(FEEDS_LIST_KEY, {
type: "json",
})) as FeedList | null) || { feeds: [] };
feedList.feeds.push({ id: feedId, title, description, expires_at });
await this.kv.put(FEEDS_LIST_KEY, JSON.stringify(feedList));
} catch (error) {
logger.error("Error adding feed to list", {
feedId,
error: String(error),
});
}
}
async updateInList(
feedId: string,
title: string,
description?: string,
expires_at?: number,
): Promise<void> {
try {
const feedList = ((await this.kv.get(FEEDS_LIST_KEY, {
type: "json",
})) as FeedList | null) || { feeds: [] };
const feedIndex = feedList.feeds.findIndex((feed) => feed.id === feedId);
if (feedIndex !== -1) {
feedList.feeds[feedIndex].title = title;
feedList.feeds[feedIndex].description = description;
feedList.feeds[feedIndex].expires_at = expires_at;
await this.kv.put(FEEDS_LIST_KEY, JSON.stringify(feedList));
}
} catch (error) {
logger.error("Error updating feed in list", {
feedId,
error: String(error),
});
}
}
async removeFromListBulk(feedIds: string[]): Promise<string[]> {
try {
const feedList = ((await this.kv.get(FEEDS_LIST_KEY, {
type: "json",
})) as FeedList | null) || { feeds: [] };
const toRemove = new Set(feedIds.filter(Boolean));
if (toRemove.size === 0) return [];
const removed: string[] = [];
const nextFeeds: FeedListItem[] = [];
for (const feed of feedList.feeds) {
if (toRemove.has(feed.id)) {
removed.push(feed.id);
continue;
}
nextFeeds.push(feed);
}
if (removed.length === 0) return [];
feedList.feeds = nextFeeds;
await this.kv.put(FEEDS_LIST_KEY, JSON.stringify(feedList));
return removed;
} catch (error) {
logger.error("Error removing feeds from list", { error: String(error) });
return [];
}
}
async removeFromList(feedId: string): Promise<boolean> {
const removed = await this.removeFromListBulk([feedId]);
return removed.includes(feedId);
}
// ── Key listing / counting ────────────────────────────────────────────────
async listFeedKeys(
feedId: string,
options: { cursor?: string; limit?: number } = {},
): Promise<{ names: string[]; cursor: string; listComplete: boolean }> {
const prefix = this.feedKeyPrefix(feedId);
const limit = Math.min(1000, Math.max(1, Math.floor(options.limit || 100)));
const cursor = options.cursor || undefined;
const listed = await this.kv.list({ prefix, cursor, limit });
return {
names: (listed.keys || []).map((k) => k.name),
cursor: listed.cursor || "",
listComplete: !!listed.list_complete,
};
}
async countKeysByPrefix(prefix: string): Promise<number> {
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 keys", { prefix, error: String(error) });
}
return total;
}
}