@@ -309,9 +309,9 @@ describe("BatchListPresenter run-ops read routing (PG14 control-plane/legacy + P
309309 }
310310 ) ;
311311
312- // Split scan merge serves new + legacy in one keyset -ordered page.
312+ // Split scan merge serves new + legacy in one createdAt -ordered page; legacy is always read .
313313 heteroPostgresTest (
314- "split scan merges new (PG17) + legacy (PG14) rows under the keyset order; legacy read only when new does not fill the page " ,
314+ "split scan merges new (PG17) + legacy (PG14) rows under the createdAt keyset order; legacy always read " ,
315315 async ( { prisma14, prisma17 } ) => {
316316 const ctx14 = await seedParents ( prisma14 , "merge" ) ;
317317 await mirrorEnvParents ( prisma17 , ctx14 , "merge" ) ;
@@ -323,7 +323,8 @@ describe("BatchListPresenter run-ops read routing (PG14 control-plane/legacy + P
323323 await createBatch ( prisma14 , ctx14 , { id : "batch_d" , friendlyId : "fr_d" , runCount : 4 } ) ;
324324 await createBatch ( prisma17 , ctx14 , { id : "batch_e" , friendlyId : "fr_e" , runCount : 5 } ) ;
325325
326- // Case A: small page fully served by new alone => legacy NOT read.
326+ // Case A: always-merge — legacy is read even when new could fill the page (the old skip was
327+ // unsound across the residency split). Page is the createdAt-ordered union of both DBs.
327328 const legacySpyA = spyClient ( prisma14 ) ;
328329 const presenterA = new BatchListPresenter ( prisma17 , prisma17 , {
329330 runOpsNew : prisma17 ,
@@ -332,9 +333,9 @@ describe("BatchListPresenter run-ops read routing (PG14 control-plane/legacy + P
332333 splitEnabled : true ,
333334 } ) ;
334335 const pageA = await presenterA . call ( baseCall ( ctx14 , { pageSize : 2 } ) ) ;
335- // new ids are e, c, a -> DESC: e, c (pageSize 2). pageSize+1 = 3 rows from new fills the page .
336- expect ( pageA . batches . map ( ( b ) => b . id ) ) . toEqual ( [ "batch_e" , "batch_c " ] ) ;
337- expect ( legacySpyA . counts . findMany ) . toBe ( 0 ) ;
336+ // union newest-first (createdAt, insertion order a<b<c<d<e): e, d, c, b, a -> page of 2 = e, d .
337+ expect ( pageA . batches . map ( ( b ) => b . id ) ) . toEqual ( [ "batch_e" , "batch_d " ] ) ;
338+ expect ( legacySpyA . counts . findMany ) . toBeGreaterThan ( 0 ) ;
338339
339340 // Case B: page needs legacy rows => legacy IS read and the merge is keyset-ordered union.
340341 const legacySpyB = spyClient ( prisma14 ) ;
@@ -348,8 +349,8 @@ describe("BatchListPresenter run-ops read routing (PG14 control-plane/legacy + P
348349 // union DESC of all 5: e, d, c, b, a -> first 4.
349350 expect ( pageB . batches . map ( ( b ) => b . id ) ) . toEqual ( [ "batch_e" , "batch_d" , "batch_c" , "batch_b" ] ) ;
350351 expect ( legacySpyB . counts . findMany ) . toBeGreaterThan ( 0 ) ;
351- // cursor parity: next is the 4th id (pageSize-th), previous undefined (no input cursor) .
352- expect ( pageB . pagination . next ) . toBe ( "batch_b" ) ;
352+ // cursor parity: next is the composite (createdAt, id) cursor of the 4th row, previous undefined .
353+ expect ( pageB . pagination . next ?. endsWith ( "_batch_b" ) ) . toBe ( true ) ;
353354 expect ( pageB . pagination . previous ) . toBeUndefined ( ) ;
354355 expect ( pageB . hasAnyBatches ) . toBe ( true ) ;
355356 }
@@ -436,7 +437,9 @@ describe("BatchListPresenter run-ops read routing (PG14 control-plane/legacy + P
436437 const hasMore = direct . length > 2 ;
437438 const expectedPage = direct . slice ( 0 , 2 ) ;
438439 expect ( page . batches . map ( ( b ) => b . id ) ) . toEqual ( expectedPage . map ( ( r ) => r . id ) ) ;
439- expect ( page . pagination . next ) . toBe ( hasMore ? expectedPage [ 1 ] . id : undefined ) ;
440+ expect (
441+ hasMore ? page . pagination . next ?. endsWith ( `_${ expectedPage [ 1 ] . id } ` ) : ! page . pagination . next
442+ ) . toBe ( true ) ;
440443 expect ( page . pagination . previous ) . toBeUndefined ( ) ;
441444 expect ( page . hasAnyBatches ) . toBe ( true ) ;
442445
@@ -453,6 +456,87 @@ describe("BatchListPresenter run-ops read routing (PG14 control-plane/legacy + P
453456 }
454457 ) ;
455458
459+ // REGRESSION: a flipped org's real id mix. A cuid ("c"=0x63) sorts ABOVE a run-ops id ("0"=0x30)
460+ // under `id DESC`, so pre-flip legacy batches belong at the top — but #scanBatchTaskRun reads new
461+ // first, skips legacy once the page is full, and `id < cursor` can never reach a "c…" from a "0…"
462+ // cursor. Net: pre-flip legacy batches become unreachable.
463+ heteroPostgresTest (
464+ "flipped org: pre-flip legacy (cuid) batches remain reachable alongside post-flip run-ops batches" ,
465+ async ( { prisma14, prisma17 } ) => {
466+ const ctx = await seedParents ( prisma14 , "flip" ) ;
467+ await mirrorEnvParents ( prisma17 , ctx , "flip" ) ;
468+
469+ // Pre-flip cuid batch on legacy (sorts highest); post-flip run-ops batches on new (sort below).
470+ const LEGACY_CUID = "cm0preflipbatch0000000001" ;
471+ await createBatch ( prisma14 , ctx , { id : LEGACY_CUID , friendlyId : "fr_preflip" , runCount : 9 } ) ;
472+
473+ const NEW_RUNOPS = [
474+ "06fnewbatch00000000000000a" ,
475+ "06fnewbatch00000000000000b" ,
476+ "06fnewbatch00000000000000c" ,
477+ ] ;
478+ for ( const id of NEW_RUNOPS ) {
479+ await createBatch ( prisma17 , ctx , { id, friendlyId : `fr_${ id . slice ( - 1 ) } ` , runCount : 1 } ) ;
480+ }
481+
482+ const presenter = new BatchListPresenter ( prisma17 , prisma17 , {
483+ runOpsNew : prisma17 ,
484+ runOpsLegacyReplica : prisma14 ,
485+ controlPlaneReplica : prisma14 ,
486+ splitEnabled : true ,
487+ } ) ;
488+
489+ // The pre-flip cuid batch is the oldest, so under newest-first it lands on a later page — but it
490+ // must be REACHABLE by paging forward, not stranded behind the run-ops ids (the skip + id-order
491+ // bug dropped it entirely: `id < <run-ops cursor>` never matches a "c…" id).
492+ const seen = new Set < string > ( ) ;
493+ let cursor : string | undefined = undefined ;
494+ for ( let i = 0 ; i < 10 ; i ++ ) {
495+ const page = await presenter . call (
496+ baseCall ( ctx , { pageSize : 2 , cursor, direction : "forward" } )
497+ ) ;
498+ page . batches . forEach ( ( b ) => seen . add ( b . id ) ) ;
499+ if ( ! page . pagination . next ) break ;
500+ cursor = page . pagination . next ;
501+ }
502+ expect ( [ ...seen ] ) . toContain ( LEGACY_CUID ) ;
503+ }
504+ ) ;
505+
506+ // REGRESSION (ordering): even with an always-merge fix, keyset-by-id is chronologically wrong across
507+ // the flip — a cuid ("c") sorts above a run-ops id ("0"), so an OLDER pre-flip batch outranks a NEWER
508+ // post-flip batch. The list is "newest first", so the later-created run-ops batch must come first.
509+ heteroPostgresTest (
510+ "flipped org: batches list is newest-first across the flip boundary (by createdAt, not id)" ,
511+ async ( { prisma14, prisma17 } ) => {
512+ const ctx = await seedParents ( prisma14 , "order" ) ;
513+ await mirrorEnvParents ( prisma17 , ctx , "order" ) ;
514+
515+ const OLD_LEGACY = "cm0oldbatch00000000000001" ; // cuid, created EARLIER
516+ const NEW_RUNOPS = "06fnewbatch000000000000001" ; // run-ops, created LATER
517+ await createBatch ( prisma14 , ctx , {
518+ id : OLD_LEGACY ,
519+ friendlyId : "fr_old" ,
520+ createdAt : new Date ( Date . now ( ) - 3_600_000 ) ,
521+ } ) ;
522+ await createBatch ( prisma17 , ctx , {
523+ id : NEW_RUNOPS ,
524+ friendlyId : "fr_new" ,
525+ createdAt : new Date ( ) ,
526+ } ) ;
527+
528+ const presenter = new BatchListPresenter ( prisma17 , prisma17 , {
529+ runOpsNew : prisma17 ,
530+ runOpsLegacyReplica : prisma14 ,
531+ controlPlaneReplica : prisma14 ,
532+ splitEnabled : true ,
533+ } ) ;
534+ const page = await presenter . call ( baseCall ( ctx , { pageSize : 10 } ) ) ;
535+ // Newest-first: the later-created run-ops batch outranks the older legacy one.
536+ expect ( page . batches . map ( ( b ) => b . id ) ) . toEqual ( [ NEW_RUNOPS , OLD_LEGACY ] ) ;
537+ }
538+ ) ;
539+
456540 heteroRunOpsPostgresTest (
457541 "scan against dedicated RunOpsPrismaClient (splitEnabled): returns batches from new DB" ,
458542 async ( { prisma14, prisma17 } ) => {
0 commit comments