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::`). */ feedIdFromEmailKey(key: string): string { return feedKeys.feedIdFromEmail(key); } // ── Feed config ─────────────────────────────────────────────────────────── async getConfig(feedId: string): Promise { return (await this.kv.get(this.configKey(feedId), { type: "json", })) as FeedConfig | null; } async putConfig(feedId: string, config: FeedConfig): Promise { await this.kv.put(this.configKey(feedId), JSON.stringify(config)); } async deleteConfig(feedId: string): Promise { await this.kv.delete(this.configKey(feedId)); } // ── Feed metadata ───────────────────────────────────────────────────────── async getMetadata(feedId: string): Promise { return (await this.kv.get(this.metadataKey(feedId), { type: "json", })) as FeedMetadata | null; } async putMetadata(feedId: string, metadata: FeedMetadata): Promise { await this.kv.put(this.metadataKey(feedId), JSON.stringify(metadata)); } async deleteMetadata(feedId: string): Promise { await this.kv.delete(this.metadataKey(feedId)); } // ── Emails ──────────────────────────────────────────────────────────────── async putEmail(key: string, data: EmailData): Promise { await this.kv.put(key, JSON.stringify(data)); } async getEmail(key: string): Promise { return (await this.kv.get(key, { type: "json" })) as EmailData | null; } async deleteEmail(key: string): Promise { await this.kv.delete(key); } // ── Global feed list ────────────────────────────────────────────────────── async listFeeds(): Promise { 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 { 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 { 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 { 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 { 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 { 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; } }