@@ -17,10 +17,13 @@ function captureEngine(
1717 deleteImpl ?: ( object : string , options : any ) => any ;
1818 driver ?: Record < string , unknown > ;
1919 datasources ?: Record < string , unknown > ;
20+ /** When set, the engine exposes `find` (guarded-reap candidate reads). */
21+ findImpl ?: ( object : string , options : any ) => any ;
2022 } = { } ,
2123) {
2224 const deletes : Array < { object : string ; where : any ; multi : any ; context : any } > = [ ] ;
23- const engine = {
25+ const finds : Array < { object : string ; where : any ; limit : any ; context : any } > = [ ] ;
26+ const engine : any = {
2427 registry : { getAllObjects : ( ) => objects } ,
2528 async delete ( object : string , options : any ) {
2629 deletes . push ( { object, where : options ?. where , multi : options ?. multi , context : options ?. context } ) ;
@@ -33,7 +36,13 @@ function captureEngine(
3336 return ds ;
3437 } ,
3538 } ;
36- return { engine, deletes } ;
39+ if ( opts . findImpl ) {
40+ engine . find = async ( object : string , options : any ) => {
41+ finds . push ( { object, where : options ?. where , limit : options ?. limit , context : options ?. context } ) ;
42+ return opts . findImpl ! ( object , options ) ;
43+ } ;
44+ }
45+ return { engine, deletes, finds } ;
3746}
3847
3948function service ( engine : any , extra : Partial < ConstructorParameters < typeof LifecycleService > [ 0 ] > = { } ) {
@@ -307,6 +316,105 @@ describe('LifecycleService.sweep — Reaper', () => {
307316 } ) ;
308317} ) ;
309318
319+ describe ( 'LifecycleService.sweep — reap guard' , ( ) => {
320+ const guarded : LifecycleObjectLike [ ] = [
321+ { name : 'sys_file' , lifecycle : { class : 'transient' , ttl : { field : 'deleted_at' , expireAfter : '30d' } } } ,
322+ ] ;
323+
324+ it ( 'deletes only guard-confirmed ids, per id, after fetching candidates with the cutoff filter' , async ( ) => {
325+ const rows = [
326+ { id : 'f1' , deleted_at : '2020-01-01T00:00:00Z' } ,
327+ { id : 'f2' , deleted_at : '2020-01-02T00:00:00Z' } ,
328+ { id : 'f3' , deleted_at : '2020-01-03T00:00:00Z' } ,
329+ ] ;
330+ const { engine, deletes, finds } = captureEngine ( guarded , { findImpl : ( ) => rows } ) ;
331+ const svc = service ( engine ) ;
332+ const guard = vi . fn ( async ( ) => [ 'f1' , 'f3' ] ) ; // vetoes f2
333+ svc . registerReapGuard ( 'sys_file' , guard ) ;
334+
335+ const report = await svc . sweep ( ) ;
336+
337+ expect ( finds ) . toHaveLength ( 1 ) ;
338+ expect ( finds [ 0 ] . where ) . toEqual ( { deleted_at : { $lt : isoCutoff ( '30d' ) } } ) ;
339+ expect ( finds [ 0 ] . context ) . toEqual ( { isSystem : true , positions : [ ] , permissions : [ ] } ) ;
340+ expect ( guard ) . toHaveBeenCalledWith ( 'sys_file' , rows ) ;
341+ // Per-id deletes — the engine's delete path reads `where.id` as a scalar
342+ // target; a `{$in}` there would be bound as an object by the driver.
343+ expect ( deletes . map ( ( d ) => d . where ) ) . toEqual ( [ { id : 'f1' } , { id : 'f3' } ] ) ;
344+ expect ( report . swept ) . toEqual ( [
345+ { object : 'sys_file' , class : 'transient' , policy : 'ttl' , cutoff : isoCutoff ( '30d' ) , deleted : 2 } ,
346+ ] ) ;
347+ expect ( report . errors ) . toEqual ( [ ] ) ;
348+ } ) ;
349+
350+ it ( 'an erroring guard fails safe: no rows deleted, error reported' , async ( ) => {
351+ const { engine, deletes } = captureEngine ( guarded , { findImpl : ( ) => [ { id : 'f1' } ] } ) ;
352+ const svc = service ( engine ) ;
353+ svc . registerReapGuard ( 'sys_file' , async ( ) => {
354+ throw new Error ( 'storage unreachable' ) ;
355+ } ) ;
356+
357+ const report = await svc . sweep ( ) ;
358+
359+ expect ( deletes ) . toHaveLength ( 0 ) ;
360+ expect ( report . swept ) . toEqual ( [ ] ) ;
361+ expect ( report . errors ) . toEqual ( [ { object : 'sys_file' , error : 'storage unreachable' } ] ) ;
362+ } ) ;
363+
364+ it ( 'a guard on one object never changes the blind reap of others (regression pin)' , async ( ) => {
365+ const { engine, deletes } = captureEngine (
366+ [
367+ ...guarded ,
368+ { name : 'sys_job_run' , lifecycle : { class : 'telemetry' , retention : { maxAge : '30d' } } } ,
369+ ] ,
370+ { findImpl : ( ) => [ ] } ,
371+ ) ;
372+ const svc = service ( engine ) ;
373+ svc . registerReapGuard ( 'sys_file' , async ( ) => [ ] ) ;
374+
375+ const report = await svc . sweep ( ) ;
376+
377+ // sys_file: no candidates → no delete. sys_job_run: classic blind reap.
378+ expect ( deletes ) . toHaveLength ( 1 ) ;
379+ expect ( deletes [ 0 ] . object ) . toBe ( 'sys_job_run' ) ;
380+ expect ( deletes [ 0 ] . where ) . toEqual ( { created_at : { $lt : isoCutoff ( '30d' ) } } ) ;
381+ expect ( report . errors ) . toEqual ( [ ] ) ;
382+ } ) ;
383+
384+ it ( 'a guarded object on an engine without find is skipped, never blind-deleted' , async ( ) => {
385+ const { engine, deletes } = captureEngine ( guarded ) ; // no findImpl → no engine.find
386+ const svc = service ( engine ) ;
387+ svc . registerReapGuard ( 'sys_file' , async ( ) => [ 'f1' ] ) ;
388+
389+ const report = await svc . sweep ( ) ;
390+
391+ expect ( deletes ) . toHaveLength ( 0 ) ;
392+ expect ( report . swept ) . toEqual ( [ ] ) ;
393+ expect ( report . skipped ) . toEqual ( [ { object : 'sys_file' , reason : 'reap-guard-unsupported' } ] ) ;
394+ } ) ;
395+
396+ it ( 'drains full batches but stops the pass when a batch is not fully confirmed' , async ( ) => {
397+ // Two "pages" of 500, then a short page. All of page 1 confirmed → loop
398+ // continues; page 2 only partially confirmed → pass ends (vetoed rows
399+ // would be re-fetched forever within one sweep).
400+ const page = ( n : number , prefix : string ) =>
401+ Array . from ( { length : n } , ( _ , i ) => ( { id : `${ prefix } ${ i } ` , deleted_at : '2020-01-01T00:00:00Z' } ) ) ;
402+ const pages = [ page ( 500 , 'a' ) , page ( 500 , 'b' ) ] ;
403+ let call = 0 ;
404+ const { engine, deletes, finds } = captureEngine ( guarded , { findImpl : ( ) => pages [ call ++ ] ?? [ ] } ) ;
405+ const svc = service ( engine ) ;
406+ svc . registerReapGuard ( 'sys_file' , async ( _object , rows ) =>
407+ rows [ 0 ] . id === 'a0' ? rows . map ( ( r : any ) => r . id ) : rows . slice ( 0 , 10 ) . map ( ( r : any ) => r . id ) ,
408+ ) ;
409+
410+ const report = await svc . sweep ( ) ;
411+
412+ expect ( finds ) . toHaveLength ( 2 ) ;
413+ expect ( deletes ) . toHaveLength ( 510 ) ; // 500 confirmed from page 1 + 10 from page 2, per id
414+ expect ( report . swept [ 0 ] . deleted ) . toBe ( 510 ) ;
415+ } ) ;
416+ } ) ;
417+
310418describe ( 'LifecycleService.sweep — Archiver (P3)' , ( ) => {
311419 const AUDIT_OBJ : LifecycleObjectLike = {
312420 name : 'sys_audit_log' ,
0 commit comments