Skip to content

Commit 68363b2

Browse files
Fix pagination resumption in local world (#299)
1 parent 2b880f9 commit 68363b2

File tree

4 files changed

+212
-2
lines changed

4 files changed

+212
-2
lines changed

.changeset/lucky-breads-fry.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@workflow/world-local": patch
3+
---
4+
5+
When paginating, return a cursor even at the end of the list, to allow for stable resumption

packages/world-local/src/fs.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,8 @@ describe('fs utilities', () => {
131131

132132
expect(result.data).toHaveLength(5);
133133
expect(result.hasMore).toBe(false);
134-
expect(result.cursor).toBeNull();
134+
// Cursor should be set even when hasMore is false (for stable pagination)
135+
expect(result.cursor).not.toBeNull();
135136

136137
// Should be sorted in descending order (newest first)
137138
assert(result.data[0], 'expected first item to be defined');

packages/world-local/src/fs.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,7 @@ export async function paginatedFileSystemQuery<T extends { createdAt: Date }>(
260260
const hasMore = validItems.length > limit;
261261
const items = hasMore ? validItems.slice(0, limit) : validItems;
262262
const nextCursor =
263-
hasMore && items.length > 0
263+
items.length > 0
264264
? createCursor(
265265
items[items.length - 1].createdAt,
266266
getId?.(items[items.length - 1])

packages/world-local/src/storage.test.ts

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)