···104104 .where(sql`${job.id} = ${id}`)
105105}
106106107107+/** Terminal jobs older than this are pruned. `failed` rows are kept longer so
108108+ * there's a window to inspect them before they age out. */
109109+const DONE_RETENTION_MS = 24 * 60 * 60_000 // 1 day
110110+const FAILED_RETENTION_MS = 7 * 24 * 60 * 60_000 // 7 days
111111+112112+/**
113113+ * Delete old terminal jobs so the table stays small. Unbounded growth slows
114114+ * both the `claim()` scan and the dashboard's per-repo job aggregation (which
115115+ * has no index on the payload's repo id). Returns the number of rows removed.
116116+ * Safe to call opportunistically from the worker; it only touches rows past
117117+ * their retention window.
118118+ */
119119+export async function cleanupOldJobs(): Promise<number> {
120120+ const db = useDb()
121121+ const doneCutoff = new Date(Date.now() - DONE_RETENTION_MS)
122122+ const failedCutoff = new Date(Date.now() - FAILED_RETENTION_MS)
123123+ const result = await db.execute(sql`
124124+ DELETE FROM ${job}
125125+ WHERE (${job.status} = 'done' AND ${job.updatedAt} < ${doneCutoff.toISOString()})
126126+ OR (${job.status} = 'failed' AND ${job.updatedAt} < ${failedCutoff.toISOString()})
127127+ `)
128128+ const affected = (result as { rowCount?: number, affectedRows?: number })
129129+ return affected.rowCount ?? affected.affectedRows ?? 0
130130+}
131131+107132/**
108133 * Record a failure. Re-queues with exponential backoff until `maxAttempts`,
109134 * after which the job is marked `failed` and stays put for inspection.