Skip to content

Commit 67e6788

Browse files
committed
fix(webapp): keep forward cursor on a partial backward page
In listRunIds' backward !hasMore branch, nextCursor was indexed at reversedRows[page.size - 1]. On a partial page (fewer than page.size rows — reachable via runs.list by passing a forward page's cursor as a backward cursor) that index overshoots and yields undefined, so nextCursor became null and forward navigation was stranded. Take the oldest row on the page (rows[0]) instead — equivalent for full pages, correct for partial ones. Adds a regression test: backward onto a partial first page must still expose a working forward cursor.
1 parent f00d5e7 commit 67e6788

2 files changed

Lines changed: 106 additions & 1 deletion

File tree

apps/webapp/app/services/runsRepository/clickhouseRunsRepository.server.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,12 @@ export class ClickHouseRunsRepository implements IRunsRepository {
117117
previousCursor = cursorFor(reversedRows.at(1));
118118
nextCursor = cursorFor(reversedRows.at(options.page.size));
119119
} else {
120-
nextCursor = cursorFor(reversedRows.at(options.page.size - 1));
120+
// No newer rows, so there's no previous (newer) page. The next
121+
// (older) cursor is the oldest row on this page = rows[0] (rows are
122+
// ASC here). Index by the actual row count, not page.size — on a
123+
// partial page (fewer than page.size rows) page.size-1 overshoots
124+
// and would null the cursor, stranding forward navigation.
125+
nextCursor = cursorFor(rows.at(0));
121126
}
122127
break;
123128
}

apps/webapp/test/runsRepositoryCursor.test.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,4 +418,104 @@ describe("RunsRepository cursor pagination", () => {
418418
expect(new Set(seen).size).toBe(ids.length);
419419
}
420420
);
421+
422+
replicationContainerTest(
423+
"a partial backward page still exposes a forward cursor (no stranding)",
424+
async ({ clickhouseContainer, redisOptions, postgresContainer, prisma }) => {
425+
const { clickhouse } = await setupClickhouseReplication({
426+
prisma,
427+
databaseUrl: postgresContainer.getConnectionUri(),
428+
clickhouseUrl: clickhouseContainer.getConnectionUrl(),
429+
redisOptions,
430+
});
431+
432+
const organization = await prisma.organization.create({
433+
data: { title: "test", slug: "test" },
434+
});
435+
const project = await prisma.project.create({
436+
data: {
437+
name: "test",
438+
slug: "test",
439+
organizationId: organization.id,
440+
externalRef: "test",
441+
},
442+
});
443+
const runtimeEnvironment = await prisma.runtimeEnvironment.create({
444+
data: {
445+
slug: "test",
446+
type: "DEVELOPMENT",
447+
projectId: project.id,
448+
organizationId: organization.id,
449+
apiKey: "test",
450+
pkApiKey: "test",
451+
shortcode: "test",
452+
},
453+
});
454+
455+
// Three runs; created_at descending order is [a, b, c] (a newest).
456+
const ids = [
457+
"aaaaaaaaaaaaaaaaaaaaaaaa",
458+
"bbbbbbbbbbbbbbbbbbbbbbbb",
459+
"cccccccccccccccccccccccc",
460+
];
461+
const base = new Date("2026-06-04T16:55:07.000Z").getTime();
462+
for (let i = 0; i < ids.length; i++) {
463+
await prisma.taskRun.create({
464+
data: {
465+
id: ids[i],
466+
createdAt: new Date(base + (ids.length - 1 - i) * 1000),
467+
friendlyId: `run_${ids[i]}`,
468+
taskIdentifier: "my-task",
469+
payload: JSON.stringify({ foo: "bar" }),
470+
traceId: `trace_${i}`,
471+
spanId: `span_${i}`,
472+
queue: "test",
473+
runtimeEnvironmentId: runtimeEnvironment.id,
474+
projectId: project.id,
475+
organizationId: organization.id,
476+
environmentType: "DEVELOPMENT",
477+
engine: "V2",
478+
},
479+
});
480+
}
481+
482+
await setTimeout(1000);
483+
484+
const runsRepository = new RunsRepository({ prisma, clickhouse });
485+
const baseOptions = {
486+
projectId: project.id,
487+
environmentId: runtimeEnvironment.id,
488+
organizationId: organization.id,
489+
};
490+
491+
// First page (size 2) = {a, b}; its nextCursor sits at b's boundary.
492+
const first = await runsRepository.listRuns({ ...baseOptions, page: { size: 2 } });
493+
expect(first.runs.map((r) => r.id).sort()).toEqual([
494+
"aaaaaaaaaaaaaaaaaaaaaaaa",
495+
"bbbbbbbbbbbbbbbbbbbbbbbb",
496+
]);
497+
498+
// Paging backward from that cursor lands on a *partial* page — just the
499+
// newest run {a}, with no rows before it (hasMore === false).
500+
const back = await runsRepository.listRuns({
501+
...baseOptions,
502+
page: { size: 2, cursor: first.pagination.nextCursor!, direction: "backward" },
503+
});
504+
expect(back.runs.map((r) => r.id)).toEqual(["aaaaaaaaaaaaaaaaaaaaaaaa"]);
505+
506+
// A partial backward page must still expose a forward cursor, or the user
507+
// is stranded with no way to page back down.
508+
expect(back.pagination.nextCursor).toBeTruthy();
509+
510+
// And paging forward from it reaches the remaining runs.
511+
const forward = await runsRepository.listRuns({
512+
...baseOptions,
513+
page: { size: 2, cursor: back.pagination.nextCursor!, direction: "forward" },
514+
});
515+
expect(forward.runs.map((r) => r.id).sort()).toEqual([
516+
"bbbbbbbbbbbbbbbbbbbbbbbb",
517+
"cccccccccccccccccccccccc",
518+
]);
519+
}
520+
);
421521
});

0 commit comments

Comments
 (0)