@@ -478,6 +478,210 @@ describe('Storage', () => {
478478 expect ( page2 . data ) . toHaveLength ( 2 ) ;
479479 expect ( page2 . data [ 0 ] . stepId ) . not . toBe ( page1 . data [ 0 ] . stepId ) ;
480480 } ) ;
481+
482+ it ( 'should handle pagination when new items are created after getting a cursor' , async ( ) => {
483+ // Create initial set of items (4 items)
484+ for ( let i = 0 ; i < 4 ; i ++ ) {
485+ await storage . steps . create ( testRunId , {
486+ stepId : `step_${ i } ` ,
487+ stepName : `step-${ i } ` ,
488+ input : [ ] ,
489+ } ) ;
490+ }
491+
492+ // Get first page with limit=4 (should return all 4 items)
493+ const page1 = await storage . steps . list ( {
494+ runId : testRunId ,
495+ pagination : { limit : 4 } ,
496+ } ) ;
497+
498+ expect ( page1 . data ) . toHaveLength ( 4 ) ;
499+ expect ( page1 . hasMore ) . toBe ( false ) ;
500+ // With the fix, cursor should be set to the last item even when hasMore is false
501+ expect ( page1 . cursor ) . not . toBeNull ( ) ;
502+
503+ // Now create 4 more items (total: 8 items)
504+ for ( let i = 4 ; i < 8 ; i ++ ) {
505+ await storage . steps . create ( testRunId , {
506+ stepId : `step_${ i } ` ,
507+ stepName : `step-${ i } ` ,
508+ input : [ ] ,
509+ } ) ;
510+ }
511+
512+ // Try to get the "next page" using the old cursor (which was null)
513+ // This should show that we can't continue from where we left off
514+ const page2 = await storage . steps . list ( {
515+ runId : testRunId ,
516+ pagination : { limit : 4 } ,
517+ } ) ;
518+
519+ // Should now return 4 items (the newest ones: step_7, step_6, step_5, step_4)
520+ expect ( page2 . data ) . toHaveLength ( 4 ) ;
521+ expect ( page2 . hasMore ) . toBe ( true ) ;
522+
523+ // Get the next page using the cursor from page2
524+ const page3 = await storage . steps . list ( {
525+ runId : testRunId ,
526+ pagination : { limit : 4 , cursor : page2 . cursor || undefined } ,
527+ } ) ;
528+
529+ // Should return the older 4 items (step_3, step_2, step_1, step_0)
530+ expect ( page3 . data ) . toHaveLength ( 4 ) ;
531+ expect ( page3 . hasMore ) . toBe ( false ) ;
532+
533+ // Verify no overlap
534+ const page2Ids = new Set ( page2 . data . map ( ( s ) => s . stepId ) ) ;
535+ const page3Ids = new Set ( page3 . data . map ( ( s ) => s . stepId ) ) ;
536+
537+ for ( const id of page3Ids ) {
538+ expect ( page2Ids . has ( id ) ) . toBe ( false ) ;
539+ }
540+ } ) ;
541+
542+ it ( 'should handle pagination with cursor after items are added mid-pagination' , async ( ) => {
543+ // Create initial 4 items
544+ for ( let i = 0 ; i < 4 ; i ++ ) {
545+ await storage . steps . create ( testRunId , {
546+ stepId : `step_${ i } ` ,
547+ stepName : `step-${ i } ` ,
548+ input : [ ] ,
549+ } ) ;
550+ }
551+
552+ // Get first page with limit=2
553+ const page1 = await storage . steps . list ( {
554+ runId : testRunId ,
555+ pagination : { limit : 2 } ,
556+ } ) ;
557+
558+ expect ( page1 . data ) . toHaveLength ( 2 ) ;
559+ expect ( page1 . hasMore ) . toBe ( true ) ;
560+ const cursor1 = page1 . cursor ;
561+
562+ // Get second page
563+ const page2 = await storage . steps . list ( {
564+ runId : testRunId ,
565+ pagination : { limit : 2 , cursor : cursor1 || undefined } ,
566+ } ) ;
567+
568+ expect ( page2 . data ) . toHaveLength ( 2 ) ;
569+ expect ( page2 . hasMore ) . toBe ( false ) ;
570+ const cursor2 = page2 . cursor ;
571+
572+ // With the fix, cursor2 should NOT be null even when hasMore is false
573+ expect ( cursor2 ) . not . toBeNull ( ) ;
574+
575+ // Now add 4 more items (total: 8)
576+ for ( let i = 4 ; i < 8 ; i ++ ) {
577+ await storage . steps . create ( testRunId , {
578+ stepId : `step_${ i } ` ,
579+ stepName : `step-${ i } ` ,
580+ input : [ ] ,
581+ } ) ;
582+ }
583+
584+ // Try to continue with cursor2 (should return no items since we're at the end)
585+ // The cursor marks where we left off, so continuing from there should not return
586+ // the newly created items (which are newer than the cursor position)
587+ const page3 = await storage . steps . list ( {
588+ runId : testRunId ,
589+ pagination : { limit : 2 , cursor : cursor2 || undefined } ,
590+ } ) ;
591+
592+ expect ( page3 . data ) . toHaveLength ( 0 ) ;
593+ expect ( page3 . hasMore ) . toBe ( false ) ;
594+
595+ // But if we use cursor1 again (from the first page), we should still get the next 2 items
596+ // This verifies that the cursor is stable and repeatable
597+ const page2Retry = await storage . steps . list ( {
598+ runId : testRunId ,
599+ pagination : { limit : 2 , cursor : cursor1 || undefined } ,
600+ } ) ;
601+
602+ // Should return 2 items that come after cursor1 position
603+ // In descending order, these would be the next 2 oldest items
604+ expect ( page2Retry . data ) . toHaveLength ( 2 ) ;
605+
606+ // The items should be the same as page2 originally returned
607+ // (the cursor position is stable regardless of new items added)
608+ expect ( page2Retry . data [ 0 ] . stepId ) . toBe ( page2 . data [ 0 ] . stepId ) ;
609+ expect ( page2Retry . data [ 1 ] . stepId ) . toBe ( page2 . data [ 1 ] . stepId ) ;
610+ } ) ;
611+
612+ it ( 'should reproduce GitHub issue #298: pagination after reaching the end and creating new items' , async ( ) => {
613+ // This test reproduces the exact scenario from issue #298
614+ // https://github.com/vercel/workflow/issues/298
615+
616+ // Start with X items (4 items)
617+ for ( let i = 0 ; i < 4 ; i ++ ) {
618+ await storage . steps . create ( testRunId , {
619+ stepId : `step_${ i } ` ,
620+ stepName : `step-${ i } ` ,
621+ input : [ ] ,
622+ } ) ;
623+ }
624+
625+ // First page contains X items if limit=X
626+ const firstPage = await storage . steps . list ( {
627+ runId : testRunId ,
628+ pagination : { limit : 4 } ,
629+ } ) ;
630+
631+ expect ( firstPage . data ) . toHaveLength ( 4 ) ;
632+ expect ( firstPage . hasMore ) . toBe ( false ) ;
633+ const firstCursor = firstPage . cursor ;
634+
635+ // Cursor should be set even when we reached the end
636+ expect ( firstCursor ) . not . toBeNull ( ) ;
637+
638+ // Create new items (total becomes 2X = 8 items)
639+ for ( let i = 4 ; i < 8 ; i ++ ) {
640+ await storage . steps . create ( testRunId , {
641+ stepId : `step_${ i } ` ,
642+ stepName : `step-${ i } ` ,
643+ input : [ ] ,
644+ } ) ;
645+ }
646+
647+ // Next page with cursor=<previous-request-cursor> should return 0 items
648+ // because the cursor marks where we left off, and there are no items
649+ // OLDER than the cursor position (in descending order)
650+ const nextPage = await storage . steps . list ( {
651+ runId : testRunId ,
652+ pagination : { limit : 4 , cursor : firstCursor || undefined } ,
653+ } ) ;
654+
655+ expect ( nextPage . data ) . toHaveLength ( 0 ) ;
656+ expect ( nextPage . hasMore ) . toBe ( false ) ;
657+
658+ // If we start from the beginning (no cursor), we should get the newest 4 items
659+ const freshPage = await storage . steps . list ( {
660+ runId : testRunId ,
661+ pagination : { limit : 4 } ,
662+ } ) ;
663+
664+ expect ( freshPage . data ) . toHaveLength ( 4 ) ;
665+ expect ( freshPage . hasMore ) . toBe ( true ) ;
666+
667+ // The fresh page should contain the new items (step_7, step_6, step_5, step_4)
668+ expect ( freshPage . data [ 0 ] . stepId ) . toBe ( 'step_7' ) ;
669+ expect ( freshPage . data [ 1 ] . stepId ) . toBe ( 'step_6' ) ;
670+ expect ( freshPage . data [ 2 ] . stepId ) . toBe ( 'step_5' ) ;
671+ expect ( freshPage . data [ 3 ] . stepId ) . toBe ( 'step_4' ) ;
672+
673+ // And the second page should contain the original items
674+ const secondPage = await storage . steps . list ( {
675+ runId : testRunId ,
676+ pagination : { limit : 4 , cursor : freshPage . cursor || undefined } ,
677+ } ) ;
678+
679+ expect ( secondPage . data ) . toHaveLength ( 4 ) ;
680+ expect ( secondPage . data [ 0 ] . stepId ) . toBe ( 'step_3' ) ;
681+ expect ( secondPage . data [ 1 ] . stepId ) . toBe ( 'step_2' ) ;
682+ expect ( secondPage . data [ 2 ] . stepId ) . toBe ( 'step_1' ) ;
683+ expect ( secondPage . data [ 3 ] . stepId ) . toBe ( 'step_0' ) ;
684+ } ) ;
481685 } ) ;
482686 } ) ;
483687
0 commit comments