@@ -946,6 +946,169 @@ describe('LifecycleService.sweep — Archiver (P3)', () => {
946946 expect ( hot . bulkDeleted ) . toEqual ( [ ] ) ;
947947 expect ( report . skipped ) . toEqual ( [ { object : 'sys_audit_log' , reason : 'archive-pending' } ] ) ;
948948 } ) ;
949+
950+ /* ------------------------------------------------------------------ *
951+ * [#10347] The Archiver honours a declared `ttl`.
952+ *
953+ * The property under test is "the declared ttl cutoff GOVERNED which rows
954+ * moved", and it is invisible to a suite that only asserts rows were
955+ * archived — such a suite passes identically against the `created_at`-only
956+ * behaviour this card changes. Two things make the cases below able to fail:
957+ *
958+ * 1. `filteringHotStore` really evaluates the `where` the Archiver sends.
959+ * `hotStore()` above deliberately ignores it (its subjects are batching
960+ * and teardown, not selection), so a control built on that fake returns
961+ * every row under either policy and can never distinguish them.
962+ * 2. The rows are chosen so the two policies DISAGREE about which are due,
963+ * in both directions — one row `created_at` age would move and the ttl
964+ * would not, and one the reverse.
965+ * ------------------------------------------------------------------ */
966+
967+ /** The card's own example. Both windows are '90d' ON PURPOSE: the cutoff
968+ * INSTANT is then identical under either policy, so the only thing that can
969+ * separate them is which COLUMN is read. */
970+ const TTL_ARCHIVE_OBJ : LifecycleObjectLike = {
971+ name : 'sys_audit_log' ,
972+ lifecycle : {
973+ class : 'audit' ,
974+ ttl : { field : 'expires_at' , expireAfter : '90d' } ,
975+ archive : { after : '90d' , to : 'archive' , keep : '7y' } ,
976+ } as any ,
977+ } ;
978+
979+ const DAY = 86_400_000 ;
980+ const at = ( deltaMs : number ) => new Date ( FIXED_NOW + deltaMs ) . toISOString ( ) ;
981+
982+ /**
983+ * Four rows. `created_at` age and the `expires_at` ttl disagree on the first
984+ * two in opposite directions; the last two carry no expiry stamp at all
985+ * (null, then the key absent) while being old enough for the age policy.
986+ */
987+ const disagreeingRows = ( ) => [
988+ // Age says move it — 400 days old. The ttl says it has not expired yet.
989+ { id : 'old-unexpired' , created_at : at ( - 400 * DAY ) , expires_at : at ( + 30 * DAY ) } ,
990+ // The reverse: one day old, so age keeps it — but its stamp expired 400
991+ // days ago, so the declared ttl says it is due.
992+ { id : 'young-expired' , created_at : at ( - DAY ) , expires_at : at ( - 400 * DAY ) } ,
993+ // No expiry stamp: old by age, undecided by ttl.
994+ { id : 'null-stamp' , created_at : at ( - 400 * DAY ) , expires_at : null } ,
995+ { id : 'absent-stamp' , created_at : at ( - 400 * DAY ) } ,
996+ ] ;
997+
998+ /**
999+ * A hot store that EVALUATES the archiver's `where` and records it. `$lt` is
1000+ * applied with the platform's settled null answer — a value that is not
1001+ * there satisfies no positive comparison (#5298/#5299, every backend's
1002+ * `nullValueSatisfiesOperator` ends `default: return false`) — so the
1003+ * null/absent rows are decided by that contract, not by a JS accident.
1004+ */
1005+ function filteringHotStore ( rows : Array < Record < string , unknown > > ) {
1006+ const wheres : Array < Record < string , any > > = [ ] ;
1007+ const bulkDeleted : Array < Array < string | number > > = [ ] ;
1008+ let remaining = [ ...rows ] ;
1009+ const matches = ( row : Record < string , unknown > , where : Record < string , any > ) =>
1010+ Object . entries ( where ) . every ( ( [ field , cond ] ) => {
1011+ const value = row [ field ] ;
1012+ if ( value === null || value === undefined ) return false ;
1013+ return String ( value ) < String ( cond . $lt ) ;
1014+ } ) ;
1015+ return {
1016+ wheres,
1017+ bulkDeleted,
1018+ remaining : ( ) => remaining . map ( ( r ) => r . id ) ,
1019+ driver : {
1020+ name : 'default' ,
1021+ find : async ( _object : string , query : any ) => {
1022+ wheres . push ( query . where ) ;
1023+ return remaining . filter ( ( r ) => matches ( r , query . where ) ) . slice ( 0 , query . limit ?? remaining . length ) ;
1024+ } ,
1025+ upsert : async ( ) => ( { } ) ,
1026+ bulkDelete : async ( _object : string , ids : Array < string | number > ) => {
1027+ bulkDeleted . push ( ids ) ;
1028+ remaining = remaining . filter ( ( r ) => ! ids . includes ( r . id as string ) ) ;
1029+ } ,
1030+ deleteMany : async ( ) => 0 ,
1031+ } ,
1032+ } ;
1033+ }
1034+
1035+ it ( 'DISCRIMINATING CONTROL: a declared ttl decides which rows move — not created_at age' , async ( ) => {
1036+ const cold = coldStore ( ) ;
1037+ const hot = filteringHotStore ( disagreeingRows ( ) ) ;
1038+ const { engine } = captureEngine ( [ TTL_ARCHIVE_OBJ ] , {
1039+ driver : hot . driver ,
1040+ datasources : { archive : cold . driver } ,
1041+ } ) ;
1042+
1043+ const report = await service ( engine ) . sweep ( ) ;
1044+
1045+ // The candidate read is issued against the DECLARED ttl field.
1046+ expect ( hot . wheres ) . toEqual ( [ { expires_at : { $lt : isoCutoff ( '90d' ) } } ] ) ;
1047+ // Only the expired row moves. `old-unexpired` is what makes this a control:
1048+ // the `created_at`-only Archiver copies it, and the ttl the author declared
1049+ // says it is not due for another 30 days.
1050+ expect ( cold . upserts . map ( ( r ) => r . id ) ) . toEqual ( [ 'young-expired' ] ) ;
1051+ expect ( hot . bulkDeleted ) . toEqual ( [ [ 'young-expired' ] ] ) ;
1052+ expect ( hot . remaining ( ) ) . toEqual ( [ 'old-unexpired' , 'null-stamp' , 'absent-stamp' ] ) ;
1053+
1054+ const entry = report . swept . find ( ( e ) => e . policy === 'archive' ) ;
1055+ expect ( entry ?. archived ) . toBe ( 1 ) ;
1056+ expect ( entry ?. cutoff ) . toBe ( isoCutoff ( '90d' ) ) ;
1057+ expect ( report . skipped ) . toEqual ( [ ] ) ;
1058+ } ) ;
1059+
1060+ it ( 'a row whose ttl.field is null or absent is NOT due at the epoch — it is retained' , async ( ) => {
1061+ // Stated as its own case because it is a DECISION, not a side effect: a row
1062+ // with no expiry stamp has not been given one, and archiving it would move
1063+ // exactly the rows whose expiry the author has not decided yet. The three
1064+ // rows here are all past `archive.after` by age, so a fix that reached for
1065+ // `created_at` — or read a missing stamp as 0 — would copy all three.
1066+ const cold = coldStore ( ) ;
1067+ const hot = filteringHotStore ( [
1068+ { id : 'null-stamp' , created_at : at ( - 400 * DAY ) , expires_at : null } ,
1069+ { id : 'absent-stamp' , created_at : at ( - 400 * DAY ) } ,
1070+ { id : 'expired' , created_at : at ( - 400 * DAY ) , expires_at : at ( - 91 * DAY ) } ,
1071+ ] ) ;
1072+ const { engine } = captureEngine ( [ TTL_ARCHIVE_OBJ ] , {
1073+ driver : hot . driver ,
1074+ datasources : { archive : cold . driver } ,
1075+ } ) ;
1076+
1077+ const report = await service ( engine ) . sweep ( ) ;
1078+
1079+ expect ( cold . upserts . map ( ( r ) => r . id ) ) . toEqual ( [ 'expired' ] ) ;
1080+ expect ( hot . remaining ( ) ) . toEqual ( [ 'null-stamp' , 'absent-stamp' ] ) ;
1081+ expect ( report . swept . find ( ( e ) => e . policy === 'archive' ) ?. archived ) . toBe ( 1 ) ;
1082+ } ) ;
1083+
1084+ it ( 'POSITIVE CONTROL: archive WITHOUT ttl still moves rows by created_at age' , async ( ) => {
1085+ // Every archive-declaring object shipped today is this shape (`sys_audit_log`,
1086+ // `sys_metadata_audit`: retention + archive, no ttl). It is fed the SAME
1087+ // rows as the discriminating control, so the two cases answer differently
1088+ // on the same input: a fix leaking into this path would copy
1089+ // `young-expired` (which has an expired stamp) and skip the two stampless
1090+ // rows, and this expectation would fail.
1091+ const cold = coldStore ( ) ;
1092+ const hot = filteringHotStore ( disagreeingRows ( ) ) ;
1093+ const { engine } = captureEngine ( [ AUDIT_OBJ ] , {
1094+ driver : hot . driver ,
1095+ datasources : { archive : cold . driver } ,
1096+ } ) ;
1097+
1098+ const report = await service ( engine ) . sweep ( ) ;
1099+
1100+ expect ( hot . wheres ) . toEqual ( [ { created_at : { $lt : isoCutoff ( '90d' ) } } ] ) ;
1101+ expect ( cold . upserts . map ( ( r ) => r . id ) ) . toEqual ( [ 'old-unexpired' , 'null-stamp' , 'absent-stamp' ] ) ;
1102+ expect ( hot . bulkDeleted ) . toEqual ( [ [ 'old-unexpired' , 'null-stamp' , 'absent-stamp' ] ] ) ;
1103+ expect ( hot . remaining ( ) ) . toEqual ( [ 'young-expired' ] ) ;
1104+
1105+ const entry = report . swept . find ( ( e ) => e . policy === 'archive' ) ;
1106+ expect ( entry ?. archived ) . toBe ( 3 ) ;
1107+ expect ( entry ?. cutoff ) . toBe ( isoCutoff ( '90d' ) ) ;
1108+ // The cold-side `keep` prune is a bound on the ARCHIVE, not on which hot
1109+ // rows are due: it stays on `created_at` under either policy.
1110+ expect ( cold . coldDeletes ) . toEqual ( [ { where : { created_at : { $lt : isoCutoff ( '7y' ) } } } ] ) ;
1111+ } ) ;
9491112} ) ;
9501113
9511114describe ( 'LifecycleService.sweep — space reclaim' , ( ) => {
0 commit comments