diff --git a/src/db/retention.ts b/src/db/retention.ts index 1b69018f8b..e1fda585b9 100644 --- a/src/db/retention.ts +++ b/src/db/retention.ts @@ -27,6 +27,8 @@ export const RETENTION_POLICY: readonly RetentionRule[] = [ // One row per inbound webhook delivery (#8381 / unfinished #3896); short-lived idempotency lookups, // not durable history — same 90d window as audit/ai_usage logs. { table: "webhook_events", column: "received_at", days: 90 }, + // One row per outbound notification delivery (#8899); same append-only log shape as webhook_events. + { table: "notification_deliveries", column: "created_at", days: 90 }, ]; export type PruneResult = { table: string; column: string; cutoff: string; deleted: number }; @@ -39,7 +41,8 @@ const MAX_DELETED_PER_TABLE = 50_000; const MS_PER_DAY = 86_400_000; function retentionWhere(rule: RetentionRule): string { - const base = `${rule.column} < ?1`; + // Anonymous `?` — node:sqlite DatabaseSync rejects numbered `?1` binds with "column index out of range". + const base = `${rule.column} < ?`; if (rule.table === "audit_events") { const durableTypes = DURABLE_AUDIT_EVENT_TYPES.map((type) => `'${type}'`).join(", "); return `${base} AND event_type NOT IN (${durableTypes})`; diff --git a/test/unit/retention.test.ts b/test/unit/retention.test.ts index 2d40c0f300..e3716a7dbf 100644 --- a/test/unit/retention.test.ts +++ b/test/unit/retention.test.ts @@ -140,6 +140,32 @@ describe("pruneExpiredRecords", () => { const rows = await env.DB.prepare("SELECT delivery_id FROM webhook_events").all<{ delivery_id: string }>(); expect(rows.results.map((row) => row.delivery_id)).toEqual(["wh-recent"]); }); + + it("prunes notification_deliveries older than 90d and keeps recent rows (#8899)", async () => { + const env = createTestEnv(); + await env.DB.prepare( + `INSERT INTO notification_deliveries + (id, dedup_key, channel, recipient_login, event_type, repo_full_name, title, body, deeplink, status, created_at) + VALUES + ('nd-old-1', 'd1', 'email', 'alice', 'issue_watch_match', 'acme/widgets', 't', 'b', 'https://x', 'delivered', ?), + ('nd-old-2', 'd2', 'email', 'alice', 'issue_watch_match', 'acme/widgets', 't', 'b', 'https://x', 'delivered', ?), + ('nd-recent', 'd3', 'email', 'alice', 'issue_watch_match', 'acme/widgets', 't', 'b', 'https://x', 'delivered', ?)`, + ) + .bind(daysAgo(100), daysAgo(95), daysAgo(1)) + .run(); + + expect(RETENTION_POLICY.some((rule) => rule.table === "notification_deliveries" && rule.column === "created_at" && rule.days === 90)).toBe( + true, + ); + + const results = await pruneExpiredRecords(env, { + nowMs: NOW, + policy: [{ table: "notification_deliveries", column: "created_at", days: 90 }], + }); + expect(results[0]?.deleted).toBe(2); + const rows = await env.DB.prepare("SELECT id FROM notification_deliveries").all<{ id: string }>(); + expect(rows.results.map((row) => row.id)).toEqual(["nd-recent"]); + }); }); describe("dedupeSignalSnapshots", () => {