mirror of
https://github.com/juherr/kill-the-news.git
synced 2026-06-20 22:03: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 { describe, it, expect } from "vitest";
|
||||||
import { createMockEnv } from "../test/setup";
|
import { createMockEnv } from "../test/setup";
|
||||||
import { FeedRepository } from "./feed-repository";
|
import { FeedRepository } from "./feed-repository";
|
||||||
|
import { FeedId } from "./value-objects/feed-id";
|
||||||
import type { Env, FeedConfig, FeedMetadata, EmailData } from "../types";
|
import type { Env, FeedConfig, FeedMetadata, EmailData } from "../types";
|
||||||
|
|
||||||
const mockEnv = () => createMockEnv() as unknown as Env;
|
const mockEnv = () => createMockEnv() as unknown as Env;
|
||||||
|
const fid = (value: string) => FeedId.fromTrusted(value);
|
||||||
|
|
||||||
const sampleConfig = (overrides: Partial<FeedConfig> = {}): FeedConfig => ({
|
const sampleConfig = (overrides: Partial<FeedConfig> = {}): FeedConfig => ({
|
||||||
title: "Test Feed",
|
title: "Test Feed",
|
||||||
@@ -24,15 +26,17 @@ const sampleEmail = (overrides: Partial<EmailData> = {}): EmailData => ({
|
|||||||
describe("FeedRepository key schema", () => {
|
describe("FeedRepository key schema", () => {
|
||||||
it("builds the canonical KV keys via the public API", () => {
|
it("builds the canonical KV keys via the public API", () => {
|
||||||
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
|
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
|
||||||
expect(repo.feedKeyPrefix("a.b.42")).toBe("feed:a.b.42:");
|
expect(repo.feedKeyPrefix(fid("a.b.42"))).toBe("feed:a.b.42:");
|
||||||
expect(repo.newEmailKey("a.b.42")).toMatch(/^feed:a\.b\.42:\d+$/);
|
expect(repo.newEmailKey(fid("a.b.42"))).toMatch(/^feed:a\.b\.42:\d+$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("recognises email keys vs config/metadata keys", () => {
|
it("recognises email keys vs config/metadata keys", () => {
|
||||||
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
|
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
|
||||||
expect(repo.isEmailKey("a.b.42", "feed:a.b.42:config")).toBe(false);
|
expect(repo.isEmailKey(fid("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(fid("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:1700000000000")).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("recovers the feed id from an email key", () => {
|
it("recovers the feed id from an email key", () => {
|
||||||
@@ -44,29 +48,29 @@ describe("FeedRepository key schema", () => {
|
|||||||
describe("FeedRepository config & metadata", () => {
|
describe("FeedRepository config & metadata", () => {
|
||||||
it("round-trips and deletes a feed config", async () => {
|
it("round-trips and deletes a feed config", async () => {
|
||||||
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
|
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
|
||||||
expect(await repo.getConfig("a.b.42")).toBeNull();
|
expect(await repo.getConfig(fid("a.b.42"))).toBeNull();
|
||||||
await repo.putConfig("a.b.42", sampleConfig());
|
await repo.putConfig(fid("a.b.42"), sampleConfig());
|
||||||
expect(await repo.getConfig("a.b.42")).toMatchObject({
|
expect(await repo.getConfig(fid("a.b.42"))).toMatchObject({
|
||||||
title: "Test Feed",
|
title: "Test Feed",
|
||||||
});
|
});
|
||||||
await repo.deleteConfig("a.b.42");
|
await repo.deleteConfig(fid("a.b.42"));
|
||||||
expect(await repo.getConfig("a.b.42")).toBeNull();
|
expect(await repo.getConfig(fid("a.b.42"))).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("round-trips and deletes feed metadata", async () => {
|
it("round-trips and deletes feed metadata", async () => {
|
||||||
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
|
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
|
||||||
const meta: FeedMetadata = { emails: [] };
|
const meta: FeedMetadata = { emails: [] };
|
||||||
await repo.putMetadata("a.b.42", meta);
|
await repo.putMetadata(fid("a.b.42"), meta);
|
||||||
expect(await repo.getMetadata("a.b.42")).toEqual(meta);
|
expect(await repo.getMetadata(fid("a.b.42"))).toEqual(meta);
|
||||||
await repo.deleteMetadata("a.b.42");
|
await repo.deleteMetadata(fid("a.b.42"));
|
||||||
expect(await repo.getMetadata("a.b.42")).toBeNull();
|
expect(await repo.getMetadata(fid("a.b.42"))).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("FeedRepository emails", () => {
|
describe("FeedRepository emails", () => {
|
||||||
it("stores and reads an email under a minted key", async () => {
|
it("stores and reads an email under a minted key", async () => {
|
||||||
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
|
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());
|
await repo.putEmail(key, sampleEmail());
|
||||||
expect(await repo.getEmail(key)).toMatchObject({ subject: "Hello" });
|
expect(await repo.getEmail(key)).toMatchObject({ subject: "Hello" });
|
||||||
await repo.deleteEmail(key);
|
await repo.deleteEmail(key);
|
||||||
@@ -75,26 +79,26 @@ describe("FeedRepository emails", () => {
|
|||||||
|
|
||||||
it("lists every key under a feed prefix", async () => {
|
it("lists every key under a feed prefix", async () => {
|
||||||
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
|
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
|
||||||
await repo.putConfig("a.b.42", sampleConfig());
|
await repo.putConfig(fid("a.b.42"), sampleConfig());
|
||||||
await repo.putMetadata("a.b.42", { emails: [] });
|
await repo.putMetadata(fid("a.b.42"), { emails: [] });
|
||||||
const emailKey = repo.newEmailKey("a.b.42");
|
const emailKey = repo.newEmailKey(fid("a.b.42"));
|
||||||
await repo.putEmail(emailKey, sampleEmail());
|
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:config");
|
||||||
expect(listed.names).toContain("feed:a.b.42:metadata");
|
expect(listed.names).toContain("feed:a.b.42:metadata");
|
||||||
expect(listed.names).toContain(emailKey);
|
expect(listed.names).toContain(emailKey);
|
||||||
expect(listed.names.filter((k) => repo.isEmailKey("a.b.42", k))).toEqual([
|
expect(
|
||||||
emailKey,
|
listed.names.filter((k) => repo.isEmailKey(fid("a.b.42"), k)),
|
||||||
]);
|
).toEqual([emailKey]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("FeedRepository feed list", () => {
|
describe("FeedRepository feed list", () => {
|
||||||
it("adds, updates, lists and removes feeds with expiry", async () => {
|
it("adds, updates, lists and removes feeds with expiry", async () => {
|
||||||
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
|
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
|
||||||
await repo.addToList("a.b.42", "One", "desc", 5000);
|
await repo.addToList(fid("a.b.42"), "One", "desc", 5000);
|
||||||
await repo.addToList("c.d.99", "Two");
|
await repo.addToList(fid("c.d.99"), "Two");
|
||||||
|
|
||||||
let feeds = await repo.listFeeds();
|
let feeds = await repo.listFeeds();
|
||||||
expect(feeds).toHaveLength(2);
|
expect(feeds).toHaveLength(2);
|
||||||
@@ -103,23 +107,23 @@ describe("FeedRepository feed list", () => {
|
|||||||
expires_at: 5000,
|
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();
|
feeds = await repo.listFeeds();
|
||||||
const updated = feeds.find((f) => f.id === "a.b.42");
|
const updated = feeds.find((f) => f.id === "a.b.42");
|
||||||
expect(updated).toMatchObject({ title: "One-updated" });
|
expect(updated).toMatchObject({ title: "One-updated" });
|
||||||
expect(updated?.expires_at).toBeUndefined();
|
expect(updated?.expires_at).toBeUndefined();
|
||||||
|
|
||||||
expect(await repo.removeFromList("a.b.42")).toBe(true);
|
expect(await repo.removeFromList(fid("a.b.42"))).toBe(true);
|
||||||
expect(await repo.removeFromList("missing")).toBe(false);
|
expect(await repo.removeFromList(fid("missing"))).toBe(false);
|
||||||
feeds = await repo.listFeeds();
|
feeds = await repo.listFeeds();
|
||||||
expect(feeds.map((f) => f.id)).toEqual(["c.d.99"]);
|
expect(feeds.map((f) => f.id)).toEqual(["c.d.99"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("bulk-removes only the matching ids", async () => {
|
it("bulk-removes only the matching ids", async () => {
|
||||||
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
|
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
|
||||||
await repo.addToList("a.b.42", "One");
|
await repo.addToList(fid("a.b.42"), "One");
|
||||||
await repo.addToList("c.d.99", "Two");
|
await repo.addToList(fid("c.d.99"), "Two");
|
||||||
await repo.addToList("e.f.10", "Three");
|
await repo.addToList(fid("e.f.10"), "Three");
|
||||||
|
|
||||||
const removed = await repo.removeFromListBulk(["a.b.42", "e.f.10", "nope"]);
|
const removed = await repo.removeFromListBulk(["a.b.42", "e.f.10", "nope"]);
|
||||||
expect(removed.sort()).toEqual(["a.b.42", "e.f.10"]);
|
expect(removed.sort()).toEqual(["a.b.42", "e.f.10"]);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
import { FEEDS_LIST_KEY } from "../config/constants";
|
import { FEEDS_LIST_KEY } from "../config/constants";
|
||||||
import { feedKeys } from "./feed-keys";
|
import { feedKeys } from "./feed-keys";
|
||||||
import { Feed } from "./feed.aggregate";
|
import { Feed } from "./feed.aggregate";
|
||||||
|
import { FeedId } from "./value-objects/feed-id";
|
||||||
import { logger } from "../lib/logger";
|
import { logger } from "../lib/logger";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -28,27 +29,27 @@ export class FeedRepository {
|
|||||||
|
|
||||||
// ── Key schema (delegates to feed-keys) ───────────────────────────────────
|
// ── Key schema (delegates to feed-keys) ───────────────────────────────────
|
||||||
|
|
||||||
private configKey(feedId: string): string {
|
private configKey(feedId: FeedId): string {
|
||||||
return feedKeys.config(feedId);
|
return feedKeys.config(feedId.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
private metadataKey(feedId: string): string {
|
private metadataKey(feedId: FeedId): string {
|
||||||
return feedKeys.metadata(feedId);
|
return feedKeys.metadata(feedId.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Prefix covering every key owned by a feed (config, metadata, emails). */
|
/** Prefix covering every key owned by a feed (config, metadata, emails). */
|
||||||
feedKeyPrefix(feedId: string): string {
|
feedKeyPrefix(feedId: FeedId): string {
|
||||||
return feedKeys.feedPrefix(feedId);
|
return feedKeys.feedPrefix(feedId.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Mint a fresh, time-ordered email key. Call once and reuse the result. */
|
/** Mint a fresh, time-ordered email key. Call once and reuse the result. */
|
||||||
newEmailKey(feedId: string): string {
|
newEmailKey(feedId: FeedId): string {
|
||||||
return feedKeys.newEmail(feedId);
|
return feedKeys.newEmail(feedId.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** True when `key` is an email entry (not the feed's config/metadata key). */
|
/** True when `key` is an email entry (not the feed's config/metadata key). */
|
||||||
isEmailKey(feedId: string, key: string): boolean {
|
isEmailKey(feedId: FeedId, key: string): boolean {
|
||||||
return feedKeys.isEmail(feedId, key);
|
return feedKeys.isEmail(feedId.value, key);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Recover the feed id embedded in an email key (`feed:<id>:<ts>`). */
|
/** 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
|
* 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.
|
* 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([
|
const [config, metadata] = await Promise.all([
|
||||||
this.getConfig(feedId),
|
this.getConfig(feedId),
|
||||||
this.getMetadata(feedId),
|
this.getMetadata(feedId),
|
||||||
@@ -97,33 +98,33 @@ export class FeedRepository {
|
|||||||
|
|
||||||
// ── Feed config ───────────────────────────────────────────────────────────
|
// ── Feed config ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async getConfig(feedId: string): Promise<FeedConfig | null> {
|
async getConfig(feedId: FeedId): Promise<FeedConfig | null> {
|
||||||
return (await this.kv.get(this.configKey(feedId), {
|
return (await this.kv.get(this.configKey(feedId), {
|
||||||
type: "json",
|
type: "json",
|
||||||
})) as FeedConfig | null;
|
})) 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));
|
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));
|
await this.kv.delete(this.configKey(feedId));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Feed metadata ─────────────────────────────────────────────────────────
|
// ── Feed metadata ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async getMetadata(feedId: string): Promise<FeedMetadata | null> {
|
async getMetadata(feedId: FeedId): Promise<FeedMetadata | null> {
|
||||||
return (await this.kv.get(this.metadataKey(feedId), {
|
return (await this.kv.get(this.metadataKey(feedId), {
|
||||||
type: "json",
|
type: "json",
|
||||||
})) as FeedMetadata | null;
|
})) 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));
|
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));
|
await this.kv.delete(this.metadataKey(feedId));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,7 +157,7 @@ export class FeedRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async addToList(
|
async addToList(
|
||||||
feedId: string,
|
feedId: FeedId,
|
||||||
title: string,
|
title: string,
|
||||||
description?: string,
|
description?: string,
|
||||||
expires_at?: number,
|
expires_at?: number,
|
||||||
@@ -166,18 +167,18 @@ export class FeedRepository {
|
|||||||
type: "json",
|
type: "json",
|
||||||
})) as FeedList | null) || { feeds: [] };
|
})) 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));
|
await this.kv.put(FEEDS_LIST_KEY, JSON.stringify(feedList));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error adding feed to list", {
|
logger.error("Error adding feed to list", {
|
||||||
feedId,
|
feedId: feedId.value,
|
||||||
error: String(error),
|
error: String(error),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateInList(
|
async updateInList(
|
||||||
feedId: string,
|
feedId: FeedId,
|
||||||
title: string,
|
title: string,
|
||||||
description?: string,
|
description?: string,
|
||||||
expires_at?: number,
|
expires_at?: number,
|
||||||
@@ -187,7 +188,9 @@ export class FeedRepository {
|
|||||||
type: "json",
|
type: "json",
|
||||||
})) as FeedList | null) || { feeds: [] };
|
})) 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) {
|
if (feedIndex !== -1) {
|
||||||
feedList.feeds[feedIndex].title = title;
|
feedList.feeds[feedIndex].title = title;
|
||||||
feedList.feeds[feedIndex].description = description;
|
feedList.feeds[feedIndex].description = description;
|
||||||
@@ -196,7 +199,7 @@ export class FeedRepository {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error updating feed in list", {
|
logger.error("Error updating feed in list", {
|
||||||
feedId,
|
feedId: feedId.value,
|
||||||
error: String(error),
|
error: String(error),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -233,15 +236,15 @@ export class FeedRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async removeFromList(feedId: string): Promise<boolean> {
|
async removeFromList(feedId: FeedId): Promise<boolean> {
|
||||||
const removed = await this.removeFromListBulk([feedId]);
|
const removed = await this.removeFromListBulk([feedId.value]);
|
||||||
return removed.includes(feedId);
|
return removed.includes(feedId.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Key listing / counting ────────────────────────────────────────────────
|
// ── Key listing / counting ────────────────────────────────────────────────
|
||||||
|
|
||||||
async listFeedKeys(
|
async listFeedKeys(
|
||||||
feedId: string,
|
feedId: FeedId,
|
||||||
options: { cursor?: string; limit?: number } = {},
|
options: { cursor?: string; limit?: number } = {},
|
||||||
): Promise<{ names: string[]; cursor: string; listComplete: boolean }> {
|
): Promise<{ names: string[]; cursor: string; listComplete: boolean }> {
|
||||||
const prefix = this.feedKeyPrefix(feedId);
|
const prefix = this.feedKeyPrefix(feedId);
|
||||||
|
|||||||
@@ -2,8 +2,11 @@ import { describe, it, expect } from "vitest";
|
|||||||
import { createMockEnv } from "../test/setup";
|
import { createMockEnv } from "../test/setup";
|
||||||
import { Feed, CreateFeedInput } from "./feed.aggregate";
|
import { Feed, CreateFeedInput } from "./feed.aggregate";
|
||||||
import { FeedRepository } from "./feed-repository";
|
import { FeedRepository } from "./feed-repository";
|
||||||
|
import { FeedId } from "./value-objects/feed-id";
|
||||||
import type { Env, EmailMetadata } from "../types";
|
import type { Env, EmailMetadata } from "../types";
|
||||||
|
|
||||||
|
const FID = FeedId.fromTrusted("a.b.42");
|
||||||
|
|
||||||
const mockEnv = (overrides: Partial<Env> = {}) =>
|
const mockEnv = (overrides: Partial<Env> = {}) =>
|
||||||
({ ...createMockEnv(), ...overrides }) as unknown as Env;
|
({ ...createMockEnv(), ...overrides }) as unknown as Env;
|
||||||
|
|
||||||
@@ -27,25 +30,21 @@ const entry = (overrides: Partial<EmailMetadata> = {}): EmailMetadata => ({
|
|||||||
|
|
||||||
describe("Feed.create", () => {
|
describe("Feed.create", () => {
|
||||||
it("builds a config with an empty email index and no expiry by default", () => {
|
it("builds a config with an empty email index and no expiry by default", () => {
|
||||||
const feed = Feed.create("a.b.42", createInput(), mockEnv());
|
const feed = Feed.create(FID, createInput(), mockEnv());
|
||||||
expect(feed.id).toBe("a.b.42");
|
expect(feed.id.value).toBe("a.b.42");
|
||||||
expect(feed.config.title).toBe("News");
|
expect(feed.config.title).toBe("News");
|
||||||
expect(feed.config.expires_at).toBeUndefined();
|
expect(feed.config.expires_at).toBeUndefined();
|
||||||
expect(feed.metadata.emails).toEqual([]);
|
expect(feed.metadata.emails).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resolves expiry from lifetimeHours", () => {
|
it("resolves expiry from lifetimeHours", () => {
|
||||||
const feed = Feed.create(
|
const feed = Feed.create(FID, createInput({ lifetimeHours: 1 }), mockEnv());
|
||||||
"a.b.42",
|
|
||||||
createInput({ lifetimeHours: 1 }),
|
|
||||||
mockEnv(),
|
|
||||||
);
|
|
||||||
expect(feed.config.expires_at).toBeGreaterThan(Date.now());
|
expect(feed.config.expires_at).toBeGreaterThan(Date.now());
|
||||||
});
|
});
|
||||||
|
|
||||||
it("lets FEED_TTL_HOURS override a client lifetime", () => {
|
it("lets FEED_TTL_HOURS override a client lifetime", () => {
|
||||||
const feed = Feed.create(
|
const feed = Feed.create(
|
||||||
"a.b.42",
|
FID,
|
||||||
createInput({ lifetimeHours: 1000000 }),
|
createInput({ lifetimeHours: 1000000 }),
|
||||||
mockEnv({ FEED_TTL_HOURS: "1" }),
|
mockEnv({ FEED_TTL_HOURS: "1" }),
|
||||||
);
|
);
|
||||||
@@ -57,7 +56,7 @@ describe("Feed.create", () => {
|
|||||||
describe("Feed.isExpired / accepts", () => {
|
describe("Feed.isExpired / accepts", () => {
|
||||||
it("reports expiry against the configured instant", () => {
|
it("reports expiry against the configured instant", () => {
|
||||||
const feed = Feed.reconstitute(
|
const feed = Feed.reconstitute(
|
||||||
"a.b.42",
|
FID,
|
||||||
{ title: "T", language: "en", created_at: 0, expires_at: 100 },
|
{ title: "T", language: "en", created_at: 0, expires_at: 100 },
|
||||||
{ emails: [] },
|
{ emails: [] },
|
||||||
);
|
);
|
||||||
@@ -67,7 +66,7 @@ describe("Feed.isExpired / accepts", () => {
|
|||||||
|
|
||||||
it("applies the sender policy", () => {
|
it("applies the sender policy", () => {
|
||||||
const feed = Feed.reconstitute(
|
const feed = Feed.reconstitute(
|
||||||
"a.b.42",
|
FID,
|
||||||
{
|
{
|
||||||
title: "T",
|
title: "T",
|
||||||
language: "en",
|
language: "en",
|
||||||
@@ -84,7 +83,7 @@ describe("Feed.isExpired / accepts", () => {
|
|||||||
describe("Feed.ingest", () => {
|
describe("Feed.ingest", () => {
|
||||||
it("prepends the entry, tracks icon/unsub and trims to the byte budget", () => {
|
it("prepends the entry, tracks icon/unsub and trims to the byte budget", () => {
|
||||||
const feed = Feed.reconstitute(
|
const feed = Feed.reconstitute(
|
||||||
"a.b.42",
|
FID,
|
||||||
{ title: "T", language: "en", created_at: 0 },
|
{ title: "T", language: "en", created_at: 0 },
|
||||||
{
|
{
|
||||||
emails: [entry({ key: "old", size: 400 })],
|
emails: [entry({ key: "old", size: 400 })],
|
||||||
@@ -110,7 +109,7 @@ describe("Feed.ingest", () => {
|
|||||||
describe("Feed.removeEmails", () => {
|
describe("Feed.removeEmails", () => {
|
||||||
it("drops matching keys and returns the removed entries", () => {
|
it("drops matching keys and returns the removed entries", () => {
|
||||||
const feed = Feed.reconstitute(
|
const feed = Feed.reconstitute(
|
||||||
"a.b.42",
|
FID,
|
||||||
{ title: "T", language: "en", created_at: 0 },
|
{ title: "T", language: "en", created_at: 0 },
|
||||||
{
|
{
|
||||||
emails: [
|
emails: [
|
||||||
@@ -131,20 +130,20 @@ describe("FeedRepository.load / save round-trip", () => {
|
|||||||
it("persists a created feed and reflects later mutations", async () => {
|
it("persists a created feed and reflects later mutations", async () => {
|
||||||
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
|
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
|
||||||
const created = Feed.create(
|
const created = Feed.create(
|
||||||
"a.b.42",
|
FID,
|
||||||
createInput({ title: "Round" }),
|
createInput({ title: "Round" }),
|
||||||
mockEnv(),
|
mockEnv(),
|
||||||
);
|
);
|
||||||
await repo.save(created);
|
await repo.save(created);
|
||||||
|
|
||||||
const loaded = await repo.load("a.b.42");
|
const loaded = await repo.load(FID);
|
||||||
expect(loaded).not.toBeNull();
|
expect(loaded).not.toBeNull();
|
||||||
expect(loaded!.config.title).toBe("Round");
|
expect(loaded!.config.title).toBe("Round");
|
||||||
|
|
||||||
loaded!.ingest(entry({ key: "feed:a.b.42:1" }), { maxBytes: 1_000_000 });
|
loaded!.ingest(entry({ key: "feed:a.b.42:1" }), { maxBytes: 1_000_000 });
|
||||||
await repo.saveMetadata(loaded!);
|
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([
|
expect(reloaded!.metadata.emails.map((e) => e.key)).toEqual([
|
||||||
"feed:a.b.42:1",
|
"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 () => {
|
it("returns null when the feed has no config", async () => {
|
||||||
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
|
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 { Env, FeedConfig, FeedMetadata, EmailMetadata } from "../types";
|
||||||
|
import { FeedId } from "./value-objects/feed-id";
|
||||||
import {
|
import {
|
||||||
resolveExpiresAt,
|
resolveExpiresAt,
|
||||||
isExpired,
|
isExpired,
|
||||||
@@ -46,13 +47,13 @@ export interface IngestOptions {
|
|||||||
*/
|
*/
|
||||||
export class Feed {
|
export class Feed {
|
||||||
private constructor(
|
private constructor(
|
||||||
readonly id: string,
|
readonly id: FeedId,
|
||||||
private _config: FeedConfig,
|
private _config: FeedConfig,
|
||||||
private _metadata: FeedMetadata,
|
private _metadata: FeedMetadata,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** Mint a brand-new feed with an empty email index. */
|
/** 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 now = Date.now();
|
||||||
const expiresAt = resolveExpiresAt(env, input.lifetimeHours);
|
const expiresAt = resolveExpiresAt(env, input.lifetimeHours);
|
||||||
const config: FeedConfig = {
|
const config: FeedConfig = {
|
||||||
@@ -70,7 +71,7 @@ export class Feed {
|
|||||||
|
|
||||||
/** Rebuild an aggregate from persisted state. */
|
/** Rebuild an aggregate from persisted state. */
|
||||||
static reconstitute(
|
static reconstitute(
|
||||||
id: string,
|
id: FeedId,
|
||||||
config: FeedConfig,
|
config: FeedConfig,
|
||||||
metadata: FeedMetadata,
|
metadata: FeedMetadata,
|
||||||
): Feed {
|
): Feed {
|
||||||
|
|||||||
@@ -16,6 +16,15 @@ export class FeedId {
|
|||||||
return match ? new FeedId(match[1]) : null;
|
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 {
|
static generate(): FeedId {
|
||||||
const noun1 = nouns[Math.floor(Math.random() * nouns.length)];
|
const noun1 = nouns[Math.floor(Math.random() * nouns.length)];
|
||||||
const noun2 = nouns[Math.floor(Math.random() * nouns.length)];
|
const noun2 = nouns[Math.floor(Math.random() * nouns.length)];
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { parseOneClickUnsubscribe } from "../utils/unsubscribe";
|
|||||||
import { getAttachmentBucket } from "../utils/attachments";
|
import { getAttachmentBucket } from "../utils/attachments";
|
||||||
import { FeedRepository } from "../domain/feed-repository";
|
import { FeedRepository } from "../domain/feed-repository";
|
||||||
import { Feed } from "../domain/feed.aggregate";
|
import { Feed } from "../domain/feed.aggregate";
|
||||||
|
import { FeedId } from "../domain/value-objects/feed-id";
|
||||||
import { logger } from "./logger";
|
import { logger } from "./logger";
|
||||||
import { FEED_MAX_BYTES } from "../config/constants";
|
import { FEED_MAX_BYTES } from "../config/constants";
|
||||||
|
|
||||||
@@ -84,7 +85,7 @@ async function loadAcceptingFeed(
|
|||||||
return { ok: false, reason: "invalid_address" };
|
return { ok: false, reason: "invalid_address" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const feed = await FeedRepository.from(env).load(feedId);
|
const feed = await FeedRepository.from(env).load(FeedId.fromTrusted(feedId));
|
||||||
if (!feed) {
|
if (!feed) {
|
||||||
logger.error("Feed not found", { feedId });
|
logger.error("Feed not found", { feedId });
|
||||||
return { ok: false, reason: "feed_not_found" };
|
return { ok: false, reason: "feed_not_found" };
|
||||||
@@ -182,9 +183,9 @@ async function storeEmail(
|
|||||||
...r2Deletions,
|
...r2Deletions,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
logger.info("Email processed", { feedId: feed.id });
|
logger.info("Email processed", { feedId: feed.id.value });
|
||||||
if (ctx) {
|
if (ctx) {
|
||||||
ctx.waitUntil(notifySubscribers(feed.id, env));
|
ctx.waitUntil(notifySubscribers(feed.id.value, env));
|
||||||
if (iconDomain) {
|
if (iconDomain) {
|
||||||
ctx.waitUntil(cacheFaviconForDomain(iconDomain, env));
|
ctx.waitUntil(cacheFaviconForDomain(iconDomain, env));
|
||||||
}
|
}
|
||||||
@@ -207,5 +208,5 @@ export async function processEmail(
|
|||||||
emails_received: 1,
|
emails_received: 1,
|
||||||
last_email_at: new Date().toISOString(),
|
last_email_at: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
return { ok: true, feedId: validation.feed.id };
|
return { ok: true, feedId: validation.feed.id.value };
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-10
@@ -1,11 +1,11 @@
|
|||||||
import { Context } from "hono";
|
import { Context } from "hono";
|
||||||
import { Env, FeedConfig } from "../types";
|
import { Env, FeedConfig } from "../types";
|
||||||
import { generateFeedId } from "../utils/id-generator";
|
|
||||||
import { bumpCounters } from "../utils/stats";
|
import { bumpCounters } from "../utils/stats";
|
||||||
import { waitUntilSafe } from "../utils/worker";
|
import { waitUntilSafe } from "../utils/worker";
|
||||||
import { sendUnsubscribes } from "../utils/unsubscribe";
|
import { sendUnsubscribes } from "../utils/unsubscribe";
|
||||||
import { getAttachmentBucket } from "../utils/attachments";
|
import { getAttachmentBucket } from "../utils/attachments";
|
||||||
import { FeedRepository } from "../domain/feed-repository";
|
import { FeedRepository } from "../domain/feed-repository";
|
||||||
|
import { FeedId } from "../domain/value-objects/feed-id";
|
||||||
import {
|
import {
|
||||||
Feed,
|
Feed,
|
||||||
CreateFeedInput,
|
CreateFeedInput,
|
||||||
@@ -27,7 +27,7 @@ export async function createFeedRecord(
|
|||||||
input: CreateFeedInput,
|
input: CreateFeedInput,
|
||||||
): Promise<{ feedId: string; config: FeedConfig }> {
|
): Promise<{ feedId: string; config: FeedConfig }> {
|
||||||
const repo = FeedRepository.from(env);
|
const repo = FeedRepository.from(env);
|
||||||
const feed = Feed.create(generateFeedId(), input, env);
|
const feed = Feed.create(FeedId.generate(), input, env);
|
||||||
|
|
||||||
await repo.save(feed);
|
await repo.save(feed);
|
||||||
await repo.addToList(
|
await repo.addToList(
|
||||||
@@ -42,7 +42,7 @@ export async function createFeedRecord(
|
|||||||
last_feed_created_at: new Date().toISOString(),
|
last_feed_created_at: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
return { feedId: feed.id, config: feed.config };
|
return { feedId: feed.id.value, config: feed.config };
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UpdateFeedResult =
|
export type UpdateFeedResult =
|
||||||
@@ -60,13 +60,13 @@ export async function renameFeed(
|
|||||||
patch: { title?: string; description?: string },
|
patch: { title?: string; description?: string },
|
||||||
): Promise<UpdateFeedResult> {
|
): Promise<UpdateFeedResult> {
|
||||||
const repo = FeedRepository.from(env);
|
const repo = FeedRepository.from(env);
|
||||||
const feed = await repo.load(feedId);
|
const feed = await repo.load(FeedId.fromTrusted(feedId));
|
||||||
if (!feed) return { status: "not_found" };
|
if (!feed) return { status: "not_found" };
|
||||||
|
|
||||||
feed.rename(patch);
|
feed.rename(patch);
|
||||||
await repo.saveConfig(feed);
|
await repo.saveConfig(feed);
|
||||||
await repo.updateInList(
|
await repo.updateInList(
|
||||||
feedId,
|
feed.id,
|
||||||
feed.config.title,
|
feed.config.title,
|
||||||
feed.config.description,
|
feed.config.description,
|
||||||
feed.config.expires_at,
|
feed.config.expires_at,
|
||||||
@@ -85,7 +85,7 @@ export async function editFeed(
|
|||||||
input: UpdateFeedInput,
|
input: UpdateFeedInput,
|
||||||
): Promise<UpdateFeedResult> {
|
): Promise<UpdateFeedResult> {
|
||||||
const repo = FeedRepository.from(env);
|
const repo = FeedRepository.from(env);
|
||||||
const feed = await repo.load(feedId);
|
const feed = await repo.load(FeedId.fromTrusted(feedId));
|
||||||
if (!feed) return { status: "not_found" };
|
if (!feed) return { status: "not_found" };
|
||||||
|
|
||||||
if (feed.edit(input, env).status === "expired") {
|
if (feed.edit(input, env).status === "expired") {
|
||||||
@@ -94,7 +94,7 @@ export async function editFeed(
|
|||||||
|
|
||||||
await repo.saveConfig(feed);
|
await repo.saveConfig(feed);
|
||||||
await repo.updateInList(
|
await repo.updateInList(
|
||||||
feedId,
|
feed.id,
|
||||||
feed.config.title,
|
feed.config.title,
|
||||||
feed.config.description,
|
feed.config.description,
|
||||||
feed.config.expires_at,
|
feed.config.expires_at,
|
||||||
@@ -119,20 +119,21 @@ export async function deleteFeedFastDetailed(
|
|||||||
feedId: string,
|
feedId: string,
|
||||||
): Promise<DeleteFeedFastResult> {
|
): Promise<DeleteFeedFastResult> {
|
||||||
const repo = new FeedRepository(emailStorage);
|
const repo = new FeedRepository(emailStorage);
|
||||||
|
const id = FeedId.fromTrusted(feedId);
|
||||||
|
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
let configDeleted = false;
|
let configDeleted = false;
|
||||||
let metadataDeleted = false;
|
let metadataDeleted = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await repo.deleteConfig(feedId);
|
await repo.deleteConfig(id);
|
||||||
configDeleted = true;
|
configDeleted = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errors.push(`config delete failed: ${String(error)}`);
|
errors.push(`config delete failed: ${String(error)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await repo.deleteMetadata(feedId);
|
await repo.deleteMetadata(id);
|
||||||
metadataDeleted = true;
|
metadataDeleted = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errors.push(`metadata delete failed: ${String(error)}`);
|
errors.push(`metadata delete failed: ${String(error)}`);
|
||||||
@@ -159,7 +160,7 @@ export async function deleteFeedRecord(
|
|||||||
const unsubscribeUrls = await collectUnsubscribeUrls(emailStorage, feedId);
|
const unsubscribeUrls = await collectUnsubscribeUrls(emailStorage, feedId);
|
||||||
|
|
||||||
await deleteFeedFastDetailed(emailStorage, feedId);
|
await deleteFeedFastDetailed(emailStorage, feedId);
|
||||||
const removed = await repo.removeFromList(feedId);
|
const removed = await repo.removeFromList(FeedId.fromTrusted(feedId));
|
||||||
if (removed) {
|
if (removed) {
|
||||||
await bumpCounters(emailStorage, { feeds_deleted: 1 });
|
await bumpCounters(emailStorage, { feeds_deleted: 1 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
deleteKeysWithConcurrency,
|
deleteKeysWithConcurrency,
|
||||||
} from "./helpers";
|
} from "./helpers";
|
||||||
import { FeedRepository } from "../../domain/feed-repository";
|
import { FeedRepository } from "../../domain/feed-repository";
|
||||||
|
import { FeedId } from "../../domain/value-objects/feed-id";
|
||||||
import { feedRssUrl, feedAtomUrl, feedEmailAddress } from "../../utils/urls";
|
import { feedRssUrl, feedAtomUrl, feedEmailAddress } from "../../utils/urls";
|
||||||
import { formatBytes } from "../../utils/format";
|
import { formatBytes } from "../../utils/format";
|
||||||
import { EmailAddress } from "../../domain/value-objects/email-address";
|
import { EmailAddress } from "../../domain/value-objects/email-address";
|
||||||
@@ -153,8 +154,9 @@ emailsRouter.get("/feeds/:feedId/emails", async (c) => {
|
|||||||
const message = c.req.query("message");
|
const message = c.req.query("message");
|
||||||
const count = Number(c.req.query("count") || "0");
|
const count = Number(c.req.query("count") || "0");
|
||||||
|
|
||||||
const feedConfig = await repo.getConfig(feedId);
|
const id = FeedId.fromTrusted(feedId);
|
||||||
const feedMetadata = await repo.getMetadata(feedId);
|
const feedConfig = await repo.getConfig(id);
|
||||||
|
const feedMetadata = await repo.getMetadata(id);
|
||||||
|
|
||||||
if (!feedConfig || !feedMetadata) {
|
if (!feedConfig || !feedMetadata) {
|
||||||
return c.text("Feed not found", 404);
|
return c.text("Feed not found", 404);
|
||||||
@@ -650,7 +652,7 @@ emailsRouter.post("/emails/:emailKey/delete", async (c) => {
|
|||||||
return c.text("Feed ID is required", 400);
|
return c.text("Feed ID is required", 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const feed = await repo.load(feedId);
|
const feed = await repo.load(FeedId.fromTrusted(feedId));
|
||||||
|
|
||||||
await repo.deleteEmail(emailKey);
|
await repo.deleteEmail(emailKey);
|
||||||
if (feed) {
|
if (feed) {
|
||||||
@@ -685,7 +687,7 @@ emailsRouter.post("/feeds/:feedId/emails/bulk-delete", async (c) => {
|
|||||||
(c.req.header("Accept") || "").includes("application/json");
|
(c.req.header("Accept") || "").includes("application/json");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const feed = await repo.load(feedId);
|
const feed = await repo.load(FeedId.fromTrusted(feedId));
|
||||||
|
|
||||||
if (!feed) {
|
if (!feed) {
|
||||||
return wantsJson
|
return wantsJson
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { getAttachmentBucket } from "../../utils/attachments";
|
|||||||
import { Layout } from "./ui";
|
import { Layout } from "./ui";
|
||||||
import { purgeFeedKeysStep, collectUnsubscribeUrls } from "./helpers";
|
import { purgeFeedKeysStep, collectUnsubscribeUrls } from "./helpers";
|
||||||
import { FeedRepository } from "../../domain/feed-repository";
|
import { FeedRepository } from "../../domain/feed-repository";
|
||||||
|
import { FeedId } from "../../domain/value-objects/feed-id";
|
||||||
import {
|
import {
|
||||||
createFeedRecord,
|
createFeedRecord,
|
||||||
editFeed,
|
editFeed,
|
||||||
@@ -148,7 +149,9 @@ feedsRouter.get("/:feedId/edit", async (c) => {
|
|||||||
const env = c.env;
|
const env = c.env;
|
||||||
const feedId = c.req.param("feedId");
|
const feedId = c.req.param("feedId");
|
||||||
|
|
||||||
const feedConfig = await FeedRepository.from(env).getConfig(feedId);
|
const feedConfig = await FeedRepository.from(env).getConfig(
|
||||||
|
FeedId.fromTrusted(feedId),
|
||||||
|
);
|
||||||
|
|
||||||
if (!feedConfig) {
|
if (!feedConfig) {
|
||||||
return c.text("Feed not found", 404);
|
return c.text("Feed not found", 404);
|
||||||
@@ -359,6 +362,7 @@ feedsRouter.post("/:feedId/edit", async (c) => {
|
|||||||
feedsRouter.post("/:feedId/sender-filter", async (c) => {
|
feedsRouter.post("/:feedId/sender-filter", async (c) => {
|
||||||
const env = c.env;
|
const env = c.env;
|
||||||
const feedId = c.req.param("feedId");
|
const feedId = c.req.param("feedId");
|
||||||
|
const id = FeedId.fromTrusted(feedId);
|
||||||
const repo = FeedRepository.from(env);
|
const repo = FeedRepository.from(env);
|
||||||
|
|
||||||
const body = await c.req.json().catch(() => null);
|
const body = await c.req.json().catch(() => null);
|
||||||
@@ -370,7 +374,7 @@ feedsRouter.post("/:feedId/sender-filter", async (c) => {
|
|||||||
const { action, value } = parsed.data;
|
const { action, value } = parsed.data;
|
||||||
const normalized = value.trim().toLowerCase();
|
const normalized = value.trim().toLowerCase();
|
||||||
|
|
||||||
const feedConfig = await repo.getConfig(feedId);
|
const feedConfig = await repo.getConfig(id);
|
||||||
if (!feedConfig) return c.json({ ok: false, error: "Feed not found" }, 404);
|
if (!feedConfig) return c.json({ ok: false, error: "Feed not found" }, 404);
|
||||||
|
|
||||||
const allowedSenders = (feedConfig.allowed_senders || []).map((s) =>
|
const allowedSenders = (feedConfig.allowed_senders || []).map((s) =>
|
||||||
@@ -397,7 +401,7 @@ feedsRouter.post("/:feedId/sender-filter", async (c) => {
|
|||||||
|
|
||||||
if (!targetList.includes(normalized)) {
|
if (!targetList.includes(normalized)) {
|
||||||
targetList.push(normalized);
|
targetList.push(normalized);
|
||||||
await repo.putConfig(feedId, {
|
await repo.putConfig(id, {
|
||||||
...feedConfig,
|
...feedConfig,
|
||||||
allowed_senders: allowedSenders,
|
allowed_senders: allowedSenders,
|
||||||
blocked_senders: blockedSenders,
|
blocked_senders: blockedSenders,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { EmailData, EmailMetadata, Env } from "../../types";
|
|||||||
import { logger } from "../../lib/logger";
|
import { logger } from "../../lib/logger";
|
||||||
import { getAttachmentBucket } from "../../utils/attachments";
|
import { getAttachmentBucket } from "../../utils/attachments";
|
||||||
import { FeedRepository } from "../../domain/feed-repository";
|
import { FeedRepository } from "../../domain/feed-repository";
|
||||||
|
import { FeedId } from "../../domain/value-objects/feed-id";
|
||||||
|
|
||||||
// Delete the R2 attachments belonging to the given email keys. Call before the
|
// Delete the R2 attachments belonging to the given email keys. Call before the
|
||||||
// emails are removed from feed metadata, while `emails` still carries their
|
// emails are removed from feed metadata, while `emails` still carries their
|
||||||
@@ -60,7 +61,9 @@ export async function collectUnsubscribeUrls(
|
|||||||
feedId: string,
|
feedId: string,
|
||||||
): Promise<string[]> {
|
): Promise<string[]> {
|
||||||
try {
|
try {
|
||||||
const metadata = await new FeedRepository(emailStorage).getMetadata(feedId);
|
const metadata = await new FeedRepository(emailStorage).getMetadata(
|
||||||
|
FeedId.fromTrusted(feedId),
|
||||||
|
);
|
||||||
return Object.values(metadata?.unsubscribe ?? {});
|
return Object.values(metadata?.unsubscribe ?? {});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error reading unsubscribe URLs", {
|
logger.error("Error reading unsubscribe URLs", {
|
||||||
@@ -82,14 +85,15 @@ export async function purgeFeedKeysStep(
|
|||||||
listComplete: boolean;
|
listComplete: boolean;
|
||||||
}> {
|
}> {
|
||||||
const repo = new FeedRepository(emailStorage);
|
const repo = new FeedRepository(emailStorage);
|
||||||
const listed = await repo.listFeedKeys(feedId, {
|
const id = FeedId.fromTrusted(feedId);
|
||||||
|
const listed = await repo.listFeedKeys(id, {
|
||||||
cursor: options.cursor,
|
cursor: options.cursor,
|
||||||
limit: options.limit,
|
limit: options.limit,
|
||||||
});
|
});
|
||||||
const keys = listed.names;
|
const keys = listed.names;
|
||||||
|
|
||||||
if (options.bucket && keys.length > 0) {
|
if (options.bucket && keys.length > 0) {
|
||||||
const emailKeys = keys.filter((k) => repo.isEmailKey(feedId, k));
|
const emailKeys = keys.filter((k) => repo.isEmailKey(id, k));
|
||||||
if (emailKeys.length > 0) {
|
if (emailKeys.length > 0) {
|
||||||
const emailDataResults = await Promise.allSettled(
|
const emailDataResults = await Promise.allSettled(
|
||||||
emailKeys.map((k) => repo.getEmail(k)),
|
emailKeys.map((k) => repo.getEmail(k)),
|
||||||
|
|||||||
+12
-6
@@ -10,6 +10,7 @@ import {
|
|||||||
} from "../../lib/feed-service";
|
} from "../../lib/feed-service";
|
||||||
import { deleteAttachmentsForEmails } from "../admin/helpers";
|
import { deleteAttachmentsForEmails } from "../admin/helpers";
|
||||||
import { FeedRepository } from "../../domain/feed-repository";
|
import { FeedRepository } from "../../domain/feed-repository";
|
||||||
|
import { FeedId } from "../../domain/value-objects/feed-id";
|
||||||
import { getStats } from "../../utils/stats";
|
import { getStats } from "../../utils/stats";
|
||||||
import { feedEmailAddress, feedRssUrl, feedAtomUrl } from "../../utils/urls";
|
import { feedEmailAddress, feedRssUrl, feedAtomUrl } from "../../utils/urls";
|
||||||
import {
|
import {
|
||||||
@@ -170,9 +171,10 @@ apiApp.openapi(
|
|||||||
const env = c.env;
|
const env = c.env;
|
||||||
const { feedId } = c.req.valid("param");
|
const { feedId } = c.req.valid("param");
|
||||||
const repo = FeedRepository.from(env);
|
const repo = FeedRepository.from(env);
|
||||||
const config = await repo.getConfig(feedId);
|
const id = FeedId.fromTrusted(feedId);
|
||||||
|
const config = await repo.getConfig(id);
|
||||||
if (!config) return c.json({ error: "Feed not found" }, 404);
|
if (!config) return c.json({ error: "Feed not found" }, 404);
|
||||||
const metadata = await repo.getMetadata(feedId);
|
const metadata = await repo.getMetadata(id);
|
||||||
return c.json(
|
return c.json(
|
||||||
toFeed(feedId, config, metadata?.emails.length ?? 0, env),
|
toFeed(feedId, config, metadata?.emails.length ?? 0, env),
|
||||||
200,
|
200,
|
||||||
@@ -215,7 +217,9 @@ apiApp.openapi(
|
|||||||
return c.json({ error: "Feed not found" }, 404);
|
return c.json({ error: "Feed not found" }, 404);
|
||||||
if (result.status === "expired")
|
if (result.status === "expired")
|
||||||
return c.json({ error: "Feed has expired and cannot be modified" }, 409);
|
return c.json({ error: "Feed has expired and cannot be modified" }, 409);
|
||||||
const metadata = await FeedRepository.from(env).getMetadata(feedId);
|
const metadata = await FeedRepository.from(env).getMetadata(
|
||||||
|
FeedId.fromTrusted(feedId),
|
||||||
|
);
|
||||||
return c.json(
|
return c.json(
|
||||||
toFeed(feedId, result.config, metadata?.emails.length ?? 0, env),
|
toFeed(feedId, result.config, metadata?.emails.length ?? 0, env),
|
||||||
200,
|
200,
|
||||||
@@ -265,7 +269,9 @@ apiApp.openapi(
|
|||||||
async (c) => {
|
async (c) => {
|
||||||
const env = c.env;
|
const env = c.env;
|
||||||
const { feedId } = c.req.valid("param");
|
const { feedId } = c.req.valid("param");
|
||||||
const metadata = await FeedRepository.from(env).getMetadata(feedId);
|
const metadata = await FeedRepository.from(env).getMetadata(
|
||||||
|
FeedId.fromTrusted(feedId),
|
||||||
|
);
|
||||||
if (!metadata) return c.json({ error: "Feed not found" }, 404);
|
if (!metadata) return c.json({ error: "Feed not found" }, 404);
|
||||||
return c.json(
|
return c.json(
|
||||||
{
|
{
|
||||||
@@ -301,7 +307,7 @@ apiApp.openapi(
|
|||||||
const { feedId, entryId } = c.req.valid("param");
|
const { feedId, entryId } = c.req.valid("param");
|
||||||
const receivedAt = parseInt(entryId, 10);
|
const receivedAt = parseInt(entryId, 10);
|
||||||
const repo = FeedRepository.from(env);
|
const repo = FeedRepository.from(env);
|
||||||
const metadata = await repo.getMetadata(feedId);
|
const metadata = await repo.getMetadata(FeedId.fromTrusted(feedId));
|
||||||
const metaEntry = metadata?.emails.find((e) => e.receivedAt === receivedAt);
|
const metaEntry = metadata?.emails.find((e) => e.receivedAt === receivedAt);
|
||||||
if (!metaEntry) return c.json({ error: "Email not found" }, 404);
|
if (!metaEntry) return c.json({ error: "Email not found" }, 404);
|
||||||
const data = await repo.getEmail(metaEntry.key);
|
const data = await repo.getEmail(metaEntry.key);
|
||||||
@@ -345,7 +351,7 @@ apiApp.openapi(
|
|||||||
const repo = FeedRepository.from(env);
|
const repo = FeedRepository.from(env);
|
||||||
const { feedId, entryId } = c.req.valid("param");
|
const { feedId, entryId } = c.req.valid("param");
|
||||||
const receivedAt = parseInt(entryId, 10);
|
const receivedAt = parseInt(entryId, 10);
|
||||||
const feed = await repo.load(feedId);
|
const feed = await repo.load(FeedId.fromTrusted(feedId));
|
||||||
const metaEntry = feed?.metadata.emails.find(
|
const metaEntry = feed?.metadata.emails.find(
|
||||||
(e) => e.receivedAt === receivedAt,
|
(e) => e.receivedAt === receivedAt,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Env } from "../types";
|
|||||||
import { processEmailContent } from "../utils/html-processor";
|
import { processEmailContent } from "../utils/html-processor";
|
||||||
import { formatBytes } from "../utils/format";
|
import { formatBytes } from "../utils/format";
|
||||||
import { FeedRepository } from "../domain/feed-repository";
|
import { FeedRepository } from "../domain/feed-repository";
|
||||||
|
import { FeedId } from "../domain/value-objects/feed-id";
|
||||||
import { isExpired } from "../domain/feed";
|
import { isExpired } from "../domain/feed";
|
||||||
|
|
||||||
export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
|
export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
|
||||||
@@ -15,10 +16,11 @@ export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const repo = FeedRepository.from(c.env);
|
const repo = FeedRepository.from(c.env);
|
||||||
|
const id = FeedId.fromTrusted(feedId);
|
||||||
|
|
||||||
const [feedMetadata, feedConfig] = await Promise.all([
|
const [feedMetadata, feedConfig] = await Promise.all([
|
||||||
repo.getMetadata(feedId),
|
repo.getMetadata(id),
|
||||||
repo.getConfig(feedId),
|
repo.getConfig(id),
|
||||||
]);
|
]);
|
||||||
if (!feedMetadata) {
|
if (!feedMetadata) {
|
||||||
return new Response("Feed not found", { status: 404 });
|
return new Response("Feed not found", { status: 404 });
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Context } from "hono";
|
import { Context } from "hono";
|
||||||
import { Env } from "../types";
|
import { Env } from "../types";
|
||||||
import { FeedRepository } from "../domain/feed-repository";
|
import { FeedRepository } from "../domain/feed-repository";
|
||||||
|
import { FeedId } from "../domain/value-objects/feed-id";
|
||||||
import { cacheFaviconForDomain, getCachedIcon } from "../utils/favicon-fetcher";
|
import { cacheFaviconForDomain, getCachedIcon } from "../utils/favicon-fetcher";
|
||||||
|
|
||||||
export const FAVICON_PATH = "/favicon.svg";
|
export const FAVICON_PATH = "/favicon.svg";
|
||||||
@@ -40,7 +41,9 @@ export async function handleFeedFavicon(
|
|||||||
const feedId = c.req.param("feedId");
|
const feedId = c.req.param("feedId");
|
||||||
if (!feedId) return projectFavicon();
|
if (!feedId) return projectFavicon();
|
||||||
|
|
||||||
const metadata = await FeedRepository.from(env).getMetadata(feedId);
|
const metadata = await FeedRepository.from(env).getMetadata(
|
||||||
|
FeedId.fromTrusted(feedId),
|
||||||
|
);
|
||||||
const domain = metadata?.iconDomain;
|
const domain = metadata?.iconDomain;
|
||||||
if (!domain) return projectFavicon();
|
if (!domain) return projectFavicon();
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -8,6 +8,7 @@ import { waitUntilSafe } from "../utils/worker";
|
|||||||
import { DEFAULT_LEASE_SECONDS, MAX_LEASE_SECONDS } from "../config/constants";
|
import { DEFAULT_LEASE_SECONDS, MAX_LEASE_SECONDS } from "../config/constants";
|
||||||
import { feedTopicPattern } from "../utils/urls";
|
import { feedTopicPattern } from "../utils/urls";
|
||||||
import { FeedRepository } from "../domain/feed-repository";
|
import { FeedRepository } from "../domain/feed-repository";
|
||||||
|
import { FeedId } from "../domain/value-objects/feed-id";
|
||||||
|
|
||||||
type AppEnv = { Bindings: Env };
|
type AppEnv = { Bindings: Env };
|
||||||
|
|
||||||
@@ -74,7 +75,9 @@ hubRouter.post("/", async (c) => {
|
|||||||
const feedId = match[2];
|
const feedId = match[2];
|
||||||
|
|
||||||
// Verify the feed exists before accepting any subscription
|
// Verify the feed exists before accepting any subscription
|
||||||
const feedConfig = await FeedRepository.from(env).getConfig(feedId);
|
const feedConfig = await FeedRepository.from(env).getConfig(
|
||||||
|
FeedId.fromTrusted(feedId),
|
||||||
|
);
|
||||||
if (!feedConfig) {
|
if (!feedConfig) {
|
||||||
return c.text("Not Found: feed does not exist", 404);
|
return c.text("Not Found: feed does not exist", 404);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Env, FeedConfig, EmailData } from "../types";
|
import { Env, FeedConfig, EmailData } from "../types";
|
||||||
import { MAX_FEED_ITEMS } from "../config/constants";
|
import { MAX_FEED_ITEMS } from "../config/constants";
|
||||||
import { FeedRepository } from "../domain/feed-repository";
|
import { FeedRepository } from "../domain/feed-repository";
|
||||||
|
import { FeedId } from "../domain/value-objects/feed-id";
|
||||||
|
|
||||||
export interface FeedData {
|
export interface FeedData {
|
||||||
feedConfig: FeedConfig;
|
feedConfig: FeedConfig;
|
||||||
@@ -12,11 +13,12 @@ export async function fetchFeedData(
|
|||||||
env: Env,
|
env: Env,
|
||||||
): Promise<FeedData | null> {
|
): Promise<FeedData | null> {
|
||||||
const repo = FeedRepository.from(env);
|
const repo = FeedRepository.from(env);
|
||||||
|
const id = FeedId.fromTrusted(feedId);
|
||||||
|
|
||||||
const feedMetadata = await repo.getMetadata(feedId);
|
const feedMetadata = await repo.getMetadata(id);
|
||||||
if (!feedMetadata) return null;
|
if (!feedMetadata) return null;
|
||||||
|
|
||||||
const feedConfig = (await repo.getConfig(feedId)) ?? {
|
const feedConfig = (await repo.getConfig(id)) ?? {
|
||||||
title: `Newsletter Feed ${feedId}`,
|
title: `Newsletter Feed ${feedId}`,
|
||||||
description: "Converted email newsletter",
|
description: "Converted email newsletter",
|
||||||
language: "en",
|
language: "en",
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
import { FeedId } from "../domain/value-objects/feed-id";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates a random feed ID in the format noun1.noun2.XY
|
|
||||||
* @returns A random feed ID string
|
|
||||||
*/
|
|
||||||
export function generateFeedId(): string {
|
|
||||||
return FeedId.generate().value;
|
|
||||||
}
|
|
||||||
+2
-1
@@ -3,6 +3,7 @@ import { logger } from "../lib/logger";
|
|||||||
import { FeedRepository } from "../domain/feed-repository";
|
import { FeedRepository } from "../domain/feed-repository";
|
||||||
import { CountersRepository } from "../domain/counters-repository";
|
import { CountersRepository } from "../domain/counters-repository";
|
||||||
import { WebSubSubscriptionRepository } from "../domain/websub-subscription-repository";
|
import { WebSubSubscriptionRepository } from "../domain/websub-subscription-repository";
|
||||||
|
import { FeedId } from "../domain/value-objects/feed-id";
|
||||||
import { getAttachmentBucket } from "./attachments";
|
import { getAttachmentBucket } from "./attachments";
|
||||||
|
|
||||||
const EMPTY_COUNTERS: Counters = {
|
const EMPTY_COUNTERS: Counters = {
|
||||||
@@ -109,7 +110,7 @@ export async function scanKvUsage(kv: KVNamespace): Promise<{ bytes: number }> {
|
|||||||
const repo = new FeedRepository(kv);
|
const repo = new FeedRepository(kv);
|
||||||
const feeds = await repo.listFeeds();
|
const feeds = await repo.listFeeds();
|
||||||
for (const feed of feeds) {
|
for (const feed of feeds) {
|
||||||
const metadata = await repo.getMetadata(feed.id);
|
const metadata = await repo.getMetadata(FeedId.fromTrusted(feed.id));
|
||||||
if (!metadata) continue;
|
if (!metadata) continue;
|
||||||
for (const email of metadata.emails) {
|
for (const email of metadata.emails) {
|
||||||
bytes += email.size ?? 0;
|
bytes += email.size ?? 0;
|
||||||
|
|||||||
+4
-2
@@ -3,6 +3,7 @@ import { generateRssFeed, generateAtomFeed } from "./feed-generator";
|
|||||||
import { baseUrl, feedRssUrl, feedAtomUrl, feedUrl } from "./urls";
|
import { baseUrl, feedRssUrl, feedAtomUrl, feedUrl } from "./urls";
|
||||||
import { FeedRepository } from "../domain/feed-repository";
|
import { FeedRepository } from "../domain/feed-repository";
|
||||||
import { WebSubSubscriptionRepository } from "../domain/websub-subscription-repository";
|
import { WebSubSubscriptionRepository } from "../domain/websub-subscription-repository";
|
||||||
|
import { FeedId } from "../domain/value-objects/feed-id";
|
||||||
|
|
||||||
export async function getSubscriptions(
|
export async function getSubscriptions(
|
||||||
feedId: string,
|
feedId: string,
|
||||||
@@ -47,9 +48,10 @@ async function buildFeedXml(
|
|||||||
format: "rss" | "atom" = "rss",
|
format: "rss" | "atom" = "rss",
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const repo = FeedRepository.from(env);
|
const repo = FeedRepository.from(env);
|
||||||
|
const id = FeedId.fromTrusted(feedId);
|
||||||
const [feedMetadata, rawConfig] = await Promise.all([
|
const [feedMetadata, rawConfig] = await Promise.all([
|
||||||
repo.getMetadata(feedId),
|
repo.getMetadata(id),
|
||||||
repo.getConfig(feedId),
|
repo.getConfig(id),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!feedMetadata) return null;
|
if (!feedMetadata) return null;
|
||||||
|
|||||||
Reference in New Issue
Block a user