import type { Bus } from './bus'; const DAY_MS = 24 * 60 * 60 * 1000; export interface SweepResult { eventsDeleted: number; abandonedDeletedDeliveries: number; } /** * Retention sweep: * * - Delete events older than `eventsRetentionDays` whose deliveries are * ALL terminal (succeeded/abandoned). Foreign-key cascade removes * their deliveries. * - Delete `abandoned` deliveries older than `failedRetentionDays`. Their * parent event may stick around if it still has non-terminal deliveries * for other subscribers; but if that was its last delivery, the event * itself becomes a candidate for the next sweep cycle. * * Idempotent and cheap. Called from the dispatcher's idle loop, no * separate cron needed. */ export function retentionSweep(bus: Bus): SweepResult { const now = Date.now(); const eventsCutoff = now - bus.retention.eventsRetentionDays * DAY_MS; const failedCutoff = now - bus.retention.failedRetentionDays * DAY_MS; // Drop ancient abandoned deliveries first so the events they belong // to can become eligible for full deletion in the same pass. // Use RETURNING + .all() so the count reflects rows we actually // touched, not cascade artifacts. const droppedAbandoned = bus.db .query<{ event_id: number }, [number]>( `DELETE FROM deliveries WHERE status = 'abandoned' AND finished_at IS NOT NULL AND finished_at < ? RETURNING event_id`, ) .all(failedCutoff); // Delete events whose every delivery is terminal AND the event is old // enough. A subquery counts non-terminal deliveries per event; events // with zero are eligible. RETURNING gives us the exact count without // counting CASCADE child rows. const droppedEvents = bus.db .query<{ id: number }, [number]>( `DELETE FROM events WHERE emitted_at < ? AND id NOT IN ( SELECT DISTINCT event_id FROM deliveries WHERE status NOT IN ('succeeded', 'abandoned') ) RETURNING id`, ) .all(eventsCutoff); return { eventsDeleted: droppedEvents.length, abandonedDeletedDeliveries: droppedAbandoned.length, }; }