mirror of
https://github.com/juherr/kill-the-news.git
synced 2026-06-21 06:13:48 +00:00
refactor(domain): make FeedId circulate through the domain and repository
FeedId is now the type of Feed.id and of every single-feed method on FeedRepository; callers wrap raw strings via FeedId.fromTrusted at the repository boundary. String-medium operations (URLs, logs, JSON, list registry, email keys) stay string. Drop the redundant generateFeedId wrapper in favour of FeedId.generate. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { createMockEnv } from "../test/setup";
|
||||
import { FeedRepository } from "./feed-repository";
|
||||
import { FeedId } from "./value-objects/feed-id";
|
||||
import type { Env, FeedConfig, FeedMetadata, EmailData } from "../types";
|
||||
|
||||
const mockEnv = () => createMockEnv() as unknown as Env;
|
||||
const fid = (value: string) => FeedId.fromTrusted(value);
|
||||
|
||||
const sampleConfig = (overrides: Partial<FeedConfig> = {}): FeedConfig => ({
|
||||
title: "Test Feed",
|
||||
@@ -24,15 +26,17 @@ const sampleEmail = (overrides: Partial<EmailData> = {}): EmailData => ({
|
||||
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.newEmailKey("a.b.42")).toMatch(/^feed:a\.b\.42:\d+$/);
|
||||
expect(repo.feedKeyPrefix(fid("a.b.42"))).toBe("feed:a.b.42:");
|
||||
expect(repo.newEmailKey(fid("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);
|
||||
expect(repo.isEmailKey(fid("a.b.42"), "feed:a.b.42:config")).toBe(false);
|
||||
expect(repo.isEmailKey(fid("a.b.42"), "feed:a.b.42:metadata")).toBe(false);
|
||||
expect(repo.isEmailKey(fid("a.b.42"), "feed:a.b.42:1700000000000")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("recovers the feed id from an email key", () => {
|
||||
@@ -44,29 +48,29 @@ describe("FeedRepository key schema", () => {
|
||||
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({
|
||||
expect(await repo.getConfig(fid("a.b.42"))).toBeNull();
|
||||
await repo.putConfig(fid("a.b.42"), sampleConfig());
|
||||
expect(await repo.getConfig(fid("a.b.42"))).toMatchObject({
|
||||
title: "Test Feed",
|
||||
});
|
||||
await repo.deleteConfig("a.b.42");
|
||||
expect(await repo.getConfig("a.b.42")).toBeNull();
|
||||
await repo.deleteConfig(fid("a.b.42"));
|
||||
expect(await repo.getConfig(fid("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();
|
||||
await repo.putMetadata(fid("a.b.42"), meta);
|
||||
expect(await repo.getMetadata(fid("a.b.42"))).toEqual(meta);
|
||||
await repo.deleteMetadata(fid("a.b.42"));
|
||||
expect(await repo.getMetadata(fid("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");
|
||||
const key = repo.newEmailKey(fid("a.b.42"));
|
||||
await repo.putEmail(key, sampleEmail());
|
||||
expect(await repo.getEmail(key)).toMatchObject({ subject: "Hello" });
|
||||
await repo.deleteEmail(key);
|
||||
@@ -75,26 +79,26 @@ describe("FeedRepository emails", () => {
|
||||
|
||||
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.putConfig(fid("a.b.42"), sampleConfig());
|
||||
await repo.putMetadata(fid("a.b.42"), { emails: [] });
|
||||
const emailKey = repo.newEmailKey(fid("a.b.42"));
|
||||
await repo.putEmail(emailKey, sampleEmail());
|
||||
|
||||
const listed = await repo.listFeedKeys("a.b.42");
|
||||
const listed = await repo.listFeedKeys(fid("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,
|
||||
]);
|
||||
expect(
|
||||
listed.names.filter((k) => repo.isEmailKey(fid("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");
|
||||
await repo.addToList(fid("a.b.42"), "One", "desc", 5000);
|
||||
await repo.addToList(fid("c.d.99"), "Two");
|
||||
|
||||
let feeds = await repo.listFeeds();
|
||||
expect(feeds).toHaveLength(2);
|
||||
@@ -103,23 +107,23 @@ describe("FeedRepository feed list", () => {
|
||||
expires_at: 5000,
|
||||
});
|
||||
|
||||
await repo.updateInList("a.b.42", "One-updated", undefined, undefined);
|
||||
await repo.updateInList(fid("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);
|
||||
expect(await repo.removeFromList(fid("a.b.42"))).toBe(true);
|
||||
expect(await repo.removeFromList(fid("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");
|
||||
await repo.addToList(fid("a.b.42"), "One");
|
||||
await repo.addToList(fid("c.d.99"), "Two");
|
||||
await repo.addToList(fid("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"]);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { FEEDS_LIST_KEY } from "../config/constants";
|
||||
import { feedKeys } from "./feed-keys";
|
||||
import { Feed } from "./feed.aggregate";
|
||||
import { FeedId } from "./value-objects/feed-id";
|
||||
import { logger } from "../lib/logger";
|
||||
|
||||
/**
|
||||
@@ -28,27 +29,27 @@ export class FeedRepository {
|
||||
|
||||
// ── Key schema (delegates to feed-keys) ───────────────────────────────────
|
||||
|
||||
private configKey(feedId: string): string {
|
||||
return feedKeys.config(feedId);
|
||||
private configKey(feedId: FeedId): string {
|
||||
return feedKeys.config(feedId.value);
|
||||
}
|
||||
|
||||
private metadataKey(feedId: string): string {
|
||||
return feedKeys.metadata(feedId);
|
||||
private metadataKey(feedId: FeedId): string {
|
||||
return feedKeys.metadata(feedId.value);
|
||||
}
|
||||
|
||||
/** Prefix covering every key owned by a feed (config, metadata, emails). */
|
||||
feedKeyPrefix(feedId: string): string {
|
||||
return feedKeys.feedPrefix(feedId);
|
||||
feedKeyPrefix(feedId: FeedId): string {
|
||||
return feedKeys.feedPrefix(feedId.value);
|
||||
}
|
||||
|
||||
/** Mint a fresh, time-ordered email key. Call once and reuse the result. */
|
||||
newEmailKey(feedId: string): string {
|
||||
return feedKeys.newEmail(feedId);
|
||||
newEmailKey(feedId: FeedId): string {
|
||||
return feedKeys.newEmail(feedId.value);
|
||||
}
|
||||
|
||||
/** 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);
|
||||
isEmailKey(feedId: FeedId, key: string): boolean {
|
||||
return feedKeys.isEmail(feedId.value, key);
|
||||
}
|
||||
|
||||
/** Recover the feed id embedded in an email key (`feed:<id>:<ts>`). */
|
||||
@@ -62,7 +63,7 @@ export class FeedRepository {
|
||||
* Load the aggregate (config + email index). A feed exists iff it has a
|
||||
* config; metadata defaults to empty so a freshly-created feed still loads.
|
||||
*/
|
||||
async load(feedId: string): Promise<Feed | null> {
|
||||
async load(feedId: FeedId): Promise<Feed | null> {
|
||||
const [config, metadata] = await Promise.all([
|
||||
this.getConfig(feedId),
|
||||
this.getMetadata(feedId),
|
||||
@@ -97,33 +98,33 @@ export class FeedRepository {
|
||||
|
||||
// ── Feed config ───────────────────────────────────────────────────────────
|
||||
|
||||
async getConfig(feedId: string): Promise<FeedConfig | null> {
|
||||
async getConfig(feedId: FeedId): Promise<FeedConfig | null> {
|
||||
return (await this.kv.get(this.configKey(feedId), {
|
||||
type: "json",
|
||||
})) as FeedConfig | null;
|
||||
}
|
||||
|
||||
async putConfig(feedId: string, config: FeedConfig): Promise<void> {
|
||||
async putConfig(feedId: FeedId, config: FeedConfig): Promise<void> {
|
||||
await this.kv.put(this.configKey(feedId), JSON.stringify(config));
|
||||
}
|
||||
|
||||
async deleteConfig(feedId: string): Promise<void> {
|
||||
async deleteConfig(feedId: FeedId): Promise<void> {
|
||||
await this.kv.delete(this.configKey(feedId));
|
||||
}
|
||||
|
||||
// ── Feed metadata ─────────────────────────────────────────────────────────
|
||||
|
||||
async getMetadata(feedId: string): Promise<FeedMetadata | null> {
|
||||
async getMetadata(feedId: FeedId): Promise<FeedMetadata | null> {
|
||||
return (await this.kv.get(this.metadataKey(feedId), {
|
||||
type: "json",
|
||||
})) as FeedMetadata | null;
|
||||
}
|
||||
|
||||
async putMetadata(feedId: string, metadata: FeedMetadata): Promise<void> {
|
||||
async putMetadata(feedId: FeedId, metadata: FeedMetadata): Promise<void> {
|
||||
await this.kv.put(this.metadataKey(feedId), JSON.stringify(metadata));
|
||||
}
|
||||
|
||||
async deleteMetadata(feedId: string): Promise<void> {
|
||||
async deleteMetadata(feedId: FeedId): Promise<void> {
|
||||
await this.kv.delete(this.metadataKey(feedId));
|
||||
}
|
||||
|
||||
@@ -156,7 +157,7 @@ export class FeedRepository {
|
||||
}
|
||||
|
||||
async addToList(
|
||||
feedId: string,
|
||||
feedId: FeedId,
|
||||
title: string,
|
||||
description?: string,
|
||||
expires_at?: number,
|
||||
@@ -166,18 +167,18 @@ export class FeedRepository {
|
||||
type: "json",
|
||||
})) as FeedList | null) || { feeds: [] };
|
||||
|
||||
feedList.feeds.push({ id: feedId, title, description, expires_at });
|
||||
feedList.feeds.push({ id: feedId.value, title, description, expires_at });
|
||||
await this.kv.put(FEEDS_LIST_KEY, JSON.stringify(feedList));
|
||||
} catch (error) {
|
||||
logger.error("Error adding feed to list", {
|
||||
feedId,
|
||||
feedId: feedId.value,
|
||||
error: String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async updateInList(
|
||||
feedId: string,
|
||||
feedId: FeedId,
|
||||
title: string,
|
||||
description?: string,
|
||||
expires_at?: number,
|
||||
@@ -187,7 +188,9 @@ export class FeedRepository {
|
||||
type: "json",
|
||||
})) as FeedList | null) || { feeds: [] };
|
||||
|
||||
const feedIndex = feedList.feeds.findIndex((feed) => feed.id === feedId);
|
||||
const feedIndex = feedList.feeds.findIndex(
|
||||
(feed) => feed.id === feedId.value,
|
||||
);
|
||||
if (feedIndex !== -1) {
|
||||
feedList.feeds[feedIndex].title = title;
|
||||
feedList.feeds[feedIndex].description = description;
|
||||
@@ -196,7 +199,7 @@ export class FeedRepository {
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Error updating feed in list", {
|
||||
feedId,
|
||||
feedId: feedId.value,
|
||||
error: String(error),
|
||||
});
|
||||
}
|
||||
@@ -233,15 +236,15 @@ export class FeedRepository {
|
||||
}
|
||||
}
|
||||
|
||||
async removeFromList(feedId: string): Promise<boolean> {
|
||||
const removed = await this.removeFromListBulk([feedId]);
|
||||
return removed.includes(feedId);
|
||||
async removeFromList(feedId: FeedId): Promise<boolean> {
|
||||
const removed = await this.removeFromListBulk([feedId.value]);
|
||||
return removed.includes(feedId.value);
|
||||
}
|
||||
|
||||
// ── Key listing / counting ────────────────────────────────────────────────
|
||||
|
||||
async listFeedKeys(
|
||||
feedId: string,
|
||||
feedId: FeedId,
|
||||
options: { cursor?: string; limit?: number } = {},
|
||||
): Promise<{ names: string[]; cursor: string; listComplete: boolean }> {
|
||||
const prefix = this.feedKeyPrefix(feedId);
|
||||
|
||||
@@ -2,8 +2,11 @@ import { describe, it, expect } from "vitest";
|
||||
import { createMockEnv } from "../test/setup";
|
||||
import { Feed, CreateFeedInput } from "./feed.aggregate";
|
||||
import { FeedRepository } from "./feed-repository";
|
||||
import { FeedId } from "./value-objects/feed-id";
|
||||
import type { Env, EmailMetadata } from "../types";
|
||||
|
||||
const FID = FeedId.fromTrusted("a.b.42");
|
||||
|
||||
const mockEnv = (overrides: Partial<Env> = {}) =>
|
||||
({ ...createMockEnv(), ...overrides }) as unknown as Env;
|
||||
|
||||
@@ -27,25 +30,21 @@ const entry = (overrides: Partial<EmailMetadata> = {}): EmailMetadata => ({
|
||||
|
||||
describe("Feed.create", () => {
|
||||
it("builds a config with an empty email index and no expiry by default", () => {
|
||||
const feed = Feed.create("a.b.42", createInput(), mockEnv());
|
||||
expect(feed.id).toBe("a.b.42");
|
||||
const feed = Feed.create(FID, createInput(), mockEnv());
|
||||
expect(feed.id.value).toBe("a.b.42");
|
||||
expect(feed.config.title).toBe("News");
|
||||
expect(feed.config.expires_at).toBeUndefined();
|
||||
expect(feed.metadata.emails).toEqual([]);
|
||||
});
|
||||
|
||||
it("resolves expiry from lifetimeHours", () => {
|
||||
const feed = Feed.create(
|
||||
"a.b.42",
|
||||
createInput({ lifetimeHours: 1 }),
|
||||
mockEnv(),
|
||||
);
|
||||
const feed = Feed.create(FID, createInput({ lifetimeHours: 1 }), mockEnv());
|
||||
expect(feed.config.expires_at).toBeGreaterThan(Date.now());
|
||||
});
|
||||
|
||||
it("lets FEED_TTL_HOURS override a client lifetime", () => {
|
||||
const feed = Feed.create(
|
||||
"a.b.42",
|
||||
FID,
|
||||
createInput({ lifetimeHours: 1000000 }),
|
||||
mockEnv({ FEED_TTL_HOURS: "1" }),
|
||||
);
|
||||
@@ -57,7 +56,7 @@ describe("Feed.create", () => {
|
||||
describe("Feed.isExpired / accepts", () => {
|
||||
it("reports expiry against the configured instant", () => {
|
||||
const feed = Feed.reconstitute(
|
||||
"a.b.42",
|
||||
FID,
|
||||
{ title: "T", language: "en", created_at: 0, expires_at: 100 },
|
||||
{ emails: [] },
|
||||
);
|
||||
@@ -67,7 +66,7 @@ describe("Feed.isExpired / accepts", () => {
|
||||
|
||||
it("applies the sender policy", () => {
|
||||
const feed = Feed.reconstitute(
|
||||
"a.b.42",
|
||||
FID,
|
||||
{
|
||||
title: "T",
|
||||
language: "en",
|
||||
@@ -84,7 +83,7 @@ describe("Feed.isExpired / accepts", () => {
|
||||
describe("Feed.ingest", () => {
|
||||
it("prepends the entry, tracks icon/unsub and trims to the byte budget", () => {
|
||||
const feed = Feed.reconstitute(
|
||||
"a.b.42",
|
||||
FID,
|
||||
{ title: "T", language: "en", created_at: 0 },
|
||||
{
|
||||
emails: [entry({ key: "old", size: 400 })],
|
||||
@@ -110,7 +109,7 @@ describe("Feed.ingest", () => {
|
||||
describe("Feed.removeEmails", () => {
|
||||
it("drops matching keys and returns the removed entries", () => {
|
||||
const feed = Feed.reconstitute(
|
||||
"a.b.42",
|
||||
FID,
|
||||
{ title: "T", language: "en", created_at: 0 },
|
||||
{
|
||||
emails: [
|
||||
@@ -131,20 +130,20 @@ describe("FeedRepository.load / save round-trip", () => {
|
||||
it("persists a created feed and reflects later mutations", async () => {
|
||||
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
|
||||
const created = Feed.create(
|
||||
"a.b.42",
|
||||
FID,
|
||||
createInput({ title: "Round" }),
|
||||
mockEnv(),
|
||||
);
|
||||
await repo.save(created);
|
||||
|
||||
const loaded = await repo.load("a.b.42");
|
||||
const loaded = await repo.load(FID);
|
||||
expect(loaded).not.toBeNull();
|
||||
expect(loaded!.config.title).toBe("Round");
|
||||
|
||||
loaded!.ingest(entry({ key: "feed:a.b.42:1" }), { maxBytes: 1_000_000 });
|
||||
await repo.saveMetadata(loaded!);
|
||||
|
||||
const reloaded = await repo.load("a.b.42");
|
||||
const reloaded = await repo.load(FID);
|
||||
expect(reloaded!.metadata.emails.map((e) => e.key)).toEqual([
|
||||
"feed:a.b.42:1",
|
||||
]);
|
||||
@@ -152,6 +151,6 @@ describe("FeedRepository.load / save round-trip", () => {
|
||||
|
||||
it("returns null when the feed has no config", async () => {
|
||||
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
|
||||
expect(await repo.load("missing")).toBeNull();
|
||||
expect(await repo.load(FeedId.fromTrusted("missing"))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Env, FeedConfig, FeedMetadata, EmailMetadata } from "../types";
|
||||
import { FeedId } from "./value-objects/feed-id";
|
||||
import {
|
||||
resolveExpiresAt,
|
||||
isExpired,
|
||||
@@ -46,13 +47,13 @@ export interface IngestOptions {
|
||||
*/
|
||||
export class Feed {
|
||||
private constructor(
|
||||
readonly id: string,
|
||||
readonly id: FeedId,
|
||||
private _config: FeedConfig,
|
||||
private _metadata: FeedMetadata,
|
||||
) {}
|
||||
|
||||
/** Mint a brand-new feed with an empty email index. */
|
||||
static create(id: string, input: CreateFeedInput, env: Env): Feed {
|
||||
static create(id: FeedId, input: CreateFeedInput, env: Env): Feed {
|
||||
const now = Date.now();
|
||||
const expiresAt = resolveExpiresAt(env, input.lifetimeHours);
|
||||
const config: FeedConfig = {
|
||||
@@ -70,7 +71,7 @@ export class Feed {
|
||||
|
||||
/** Rebuild an aggregate from persisted state. */
|
||||
static reconstitute(
|
||||
id: string,
|
||||
id: FeedId,
|
||||
config: FeedConfig,
|
||||
metadata: FeedMetadata,
|
||||
): Feed {
|
||||
|
||||
@@ -16,6 +16,15 @@ export class FeedId {
|
||||
return match ? new FeedId(match[1]) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap an id we already trust — a value we minted ourselves and round-tripped
|
||||
* through our own links or KV keys (route params, the feed list, email keys).
|
||||
* No validation: a wrong id simply misses in KV and 404s, exactly as before.
|
||||
*/
|
||||
static fromTrusted(value: string): FeedId {
|
||||
return new FeedId(value);
|
||||
}
|
||||
|
||||
static generate(): FeedId {
|
||||
const noun1 = nouns[Math.floor(Math.random() * nouns.length)];
|
||||
const noun2 = nouns[Math.floor(Math.random() * nouns.length)];
|
||||
|
||||
Reference in New Issue
Block a user