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]>
234 lines
7.6 KiB
TypeScript
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;
|
|
}
|
|
}
|