import db from "../../db.server"; /** * Periodic TTL cleanup for the `ProcessedWebhook` idempotency table. * * The table grows by one row per Shopify webhook delivery and is never read * after the retry window closes, so without pruning it grows unbounded — * eventually a disk/space DoS. We only need rows for as long as Shopify might * retry a delivery (hours), so a generous retention window of a few days is * ample while keeping the table small. */ const RETENTION_MS = 7 * 24 * 60 * 60 * 1000; // 7 days const INTERVAL_MS = 60 * 60 * 1000; // hourly export interface CleanupDeps { db: { processedWebhook: { deleteMany: (args: { where: { receivedAt: { lt: Date } }; }) => Promise<{ count: number }>; }; }; } let scheduled = false; async function runCleanup(deps: CleanupDeps): Promise { try { const cutoff = new Date(Date.now() - RETENTION_MS); const { count } = await deps.db.processedWebhook.deleteMany({ where: { receivedAt: { lt: cutoff } }, }); if (count > 0) { console.log(`webhook-cleanup: removed ${count} ProcessedWebhook row(s) older than 7d`); } } catch (err) { // Best-effort housekeeping — never throw into the caller. console.warn("webhook-cleanup: prune failed:", err); } } /** * Idempotently schedule the hourly cleanup. Safe to call on every webhook — * the first call starts a single unref'd interval and runs an immediate * sweep; subsequent calls are no-ops. * * Because this is only ever invoked while handling a live webhook request, it * never runs during `prisma generate` / `react-router build` or other CLI * contexts. The interval is `unref`'d so it can never keep the process alive. */ export function ensureWebhookCleanupScheduled(deps: CleanupDeps = { db }): void { if (scheduled) return; scheduled = true; const timer = setInterval(() => { void runCleanup(deps); }, INTERVAL_MS); // Don't let the housekeeping interval keep the event loop alive on shutdown. if (typeof timer.unref === "function") timer.unref(); // Kick off an immediate sweep so a long-lived process prunes promptly. void runCleanup(deps); }