Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/db/retention.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand All @@ -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})`;
Expand Down
26 changes: 26 additions & 0 deletions test/unit/retention.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down