@@ -426,6 +426,12 @@ async function cleanupExpiredKnowledgeBases(
426426 * workspace files → S3 storage) are handled explicitly so the SELECT that drives
427427 * the external cleanup and the SELECT that drives the DB delete see the same rows.
428428 */
429+ /** Per-run values an `onBatch` hook needs but `CLEANUP_TARGETS` cannot know at module scope. */
430+ interface CleanupBatchContext {
431+ retentionDate : Date
432+ label : string
433+ }
434+
429435/**
430436 * Re-roots any still-active workflow or workspace file filed under a folder that is about to be
431437 * hard-deleted, giving it a collision-free name first.
@@ -440,13 +446,43 @@ async function cleanupExpiredKnowledgeBases(
440446 * An active child inside a soft-deleted folder is already an anomaly — the delete cascade
441447 * archives children — so this normally selects nothing, which is why the per-row loop is fine.
442448 */
443- async function reRootActiveFolderChildren ( folderIds : string [ ] , label : string ) : Promise < void > {
449+ async function reRootActiveFolderChildren (
450+ folderIds : string [ ] ,
451+ retentionDate : Date ,
452+ label : string
453+ ) : Promise < void > {
444454 if ( folderIds . length === 0 ) return
445455
456+ /**
457+ * Re-asserted here, not just on the DELETE. `deleteFilter` already skips a folder restored
458+ * between the SELECT and this hook — but without the same check here, this hook would still
459+ * strip and rename the children of a folder that then survives, leaving a live folder
460+ * emptied out. Every other side effect in this sweep only touches rows that are on their way
461+ * out; this one mutates rows that stay, so it is the one place where losing that race is
462+ * visible to the user.
463+ *
464+ * This narrows the window to match the DELETE's own re-assertion rather than closing it.
465+ * Closing it properly means holding a row lock across select → onBatch → delete, which
466+ * nothing in this sweep does today.
467+ */
468+ const stillExpired = await cleanupDb
469+ . select ( { id : folderTable . id } )
470+ . from ( folderTable )
471+ . where (
472+ and (
473+ inArray ( folderTable . id , folderIds ) ,
474+ isNotNull ( folderTable . deletedAt ) ,
475+ lt ( folderTable . deletedAt , retentionDate )
476+ )
477+ )
478+
479+ const expiredIds = stillExpired . map ( ( { id } ) => id )
480+ if ( expiredIds . length === 0 ) return
481+
446482 const workflows = await cleanupDb
447483 . select ( { id : workflow . id , name : workflow . name , workspaceId : workflow . workspaceId } )
448484 . from ( workflow )
449- . where ( and ( inArray ( workflow . folderId , folderIds ) , isNull ( workflow . archivedAt ) ) )
485+ . where ( and ( inArray ( workflow . folderId , expiredIds ) , isNull ( workflow . archivedAt ) ) )
450486
451487 for ( const row of workflows ) {
452488 if ( ! row . workspaceId ) continue
@@ -463,7 +499,7 @@ async function reRootActiveFolderChildren(folderIds: string[], label: string): P
463499 . from ( workspaceFiles )
464500 . where (
465501 and (
466- inArray ( workspaceFiles . folderId , folderIds ) ,
502+ inArray ( workspaceFiles . folderId , expiredIds ) ,
467503 isNull ( workspaceFiles . deletedAt ) ,
468504 eq ( workspaceFiles . context , 'workspace' )
469505 )
@@ -513,10 +549,11 @@ const CLEANUP_TARGETS = [
513549 'knowledge_base' ,
514550 'table' ,
515551 ] ) ,
516- onBatch : ( rows : { id : string } [ ] ) =>
552+ onBatch : ( rows : { id : string } [ ] , ctx : CleanupBatchContext ) =>
517553 reRootActiveFolderChildren (
518554 rows . map ( ( { id } ) => id ) ,
519- 'cleanup'
555+ ctx . retentionDate ,
556+ ctx . label
520557 ) ,
521558 name : 'folder' ,
522559 } ,
@@ -778,7 +815,10 @@ export async function runCleanupSoftDeletes(payload: CleanupJobPayload): Promise
778815 tableName : `${ label } /${ target . name } ` ,
779816 requireTimestampNotNull : true ,
780817 additionalPredicate : 'additionalPredicate' in target ? target . additionalPredicate : undefined ,
781- onBatch : 'onBatch' in target ? target . onBatch : undefined ,
818+ onBatch :
819+ 'onBatch' in target
820+ ? ( rows : { id : string } [ ] ) => target . onBatch ( rows , { retentionDate, label } )
821+ : undefined ,
782822 dbClient : cleanupDb ,
783823 } )
784824 totalDeleted += result . deleted
0 commit comments