11import { asyncJobs , db } from '@sim/db'
2- import { tableJobs , workflowExecutionLogs } from '@sim/db/schema'
2+ import { tableJobs , workflowDeploymentOperation , workflowExecutionLogs } from '@sim/db/schema'
33import { createLogger } from '@sim/logger'
44import { toError } from '@sim/utils/errors'
5- import { and , eq , inArray , lt , sql } from 'drizzle-orm'
5+ import { and , eq , exists , gt , inArray , lt , sql } from 'drizzle-orm'
6+ import { alias } from 'drizzle-orm/pg-core'
67import { type NextRequest , NextResponse } from 'next/server'
78import { verifyCronAuth } from '@/lib/auth/internal'
89import { JOB_RETENTION_HOURS , JOB_STATUS } from '@/lib/core/async-jobs'
@@ -17,6 +18,14 @@ const STALE_THRESHOLD_MINUTES = Math.ceil(STALE_THRESHOLD_MS / 60000)
1718const MAX_INT32 = 2_147_483_647
1819/** Terminal table-jobs older than this are pruned; only the latest job per table is ever read. */
1920const TABLE_JOB_RETENTION_HOURS = 24
21+ /**
22+ * Terminal deployment operations older than this are pruned. Every reader of
23+ * this table is latest-generation-only, and idempotency keys only need to
24+ * survive a client retry window, so 30 days is generous.
25+ */
26+ const DEPLOYMENT_OPERATION_RETENTION_DAYS = 30
27+ const DEPLOYMENT_OPERATION_PRUNE_BATCH_SIZE = 2000
28+ const DEPLOYMENT_OPERATION_PRUNE_MAX_BATCHES = 10
2029
2130export const GET = withRouteHandler ( async ( request : NextRequest ) => {
2231 try {
@@ -221,6 +230,64 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
221230 } )
222231 }
223232
233+ /**
234+ * Prune terminal deployment operations past retention. HARD INVARIANT:
235+ * the newest-generation row per workflow must always survive — the next
236+ * deploy computes `generation = MAX(generation) + 1`, and the webhook
237+ * registration store fences rows with lt/gt comparisons against stored
238+ * generations, so generation reuse after a full wipe would permanently
239+ * wedge that workflow's deployments. The `exists(newer)` predicate
240+ * guarantees the max-generation row is never eligible; the status filter
241+ * keeps in-flight rows (an outbox worker may still hold their fence).
242+ */
243+ let deploymentOperationsPruned = 0
244+ try {
245+ const deploymentOpRetention = new Date (
246+ Date . now ( ) - DEPLOYMENT_OPERATION_RETENTION_DAYS * 24 * 60 * 60 * 1000
247+ )
248+ const newerOperation = alias ( workflowDeploymentOperation , 'newer_operation' )
249+ for ( let batch = 0 ; batch < DEPLOYMENT_OPERATION_PRUNE_MAX_BATCHES ; batch ++ ) {
250+ const prunable = db
251+ . select ( { id : workflowDeploymentOperation . id } )
252+ . from ( workflowDeploymentOperation )
253+ . where (
254+ and (
255+ inArray ( workflowDeploymentOperation . status , [ 'active' , 'failed' , 'superseded' ] ) ,
256+ lt ( workflowDeploymentOperation . completedAt , deploymentOpRetention ) ,
257+ exists (
258+ db
259+ . select ( { id : newerOperation . id } )
260+ . from ( newerOperation )
261+ . where (
262+ and (
263+ eq ( newerOperation . workflowId , workflowDeploymentOperation . workflowId ) ,
264+ gt ( newerOperation . generation , workflowDeploymentOperation . generation )
265+ )
266+ )
267+ )
268+ )
269+ )
270+ . limit ( DEPLOYMENT_OPERATION_PRUNE_BATCH_SIZE )
271+
272+ const deleted = await db
273+ . delete ( workflowDeploymentOperation )
274+ . where ( inArray ( workflowDeploymentOperation . id , prunable ) )
275+ . returning ( { id : workflowDeploymentOperation . id } )
276+
277+ deploymentOperationsPruned += deleted . length
278+ if ( deleted . length < DEPLOYMENT_OPERATION_PRUNE_BATCH_SIZE ) break
279+ }
280+ if ( deploymentOperationsPruned > 0 ) {
281+ logger . info (
282+ `Pruned ${ deploymentOperationsPruned } old deployment operations (retention: ${ DEPLOYMENT_OPERATION_RETENTION_DAYS } d)`
283+ )
284+ }
285+ } catch ( error ) {
286+ logger . error ( 'Failed to prune old deployment operations:' , {
287+ error : toError ( error ) . message ,
288+ } )
289+ }
290+
224291 return NextResponse . json ( {
225292 success : true ,
226293 executions : {
@@ -239,6 +306,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
239306 tableJobs : {
240307 staleMarkedFailed : staleTableJobsMarkedFailed ,
241308 } ,
309+ deploymentOperations : {
310+ pruned : deploymentOperationsPruned ,
311+ retentionDays : DEPLOYMENT_OPERATION_RETENTION_DAYS ,
312+ } ,
242313 } )
243314 } catch ( error ) {
244315 logger . error ( 'Error in stale execution cleanup job:' , error )
0 commit comments