From 1252deedd91e330d6d72cd2180d948b2a96a9fa6 Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Mon, 26 Oct 2026 12:20:08 +0100 Subject: [PATCH] fix(core): return session list cursors only when another page exists Session.list returned cursor.next for every non-empty page, including the last one, so a list whose size is a multiple of the limit, or a short final page, made clients request one more empty page. Read one extra row to learn whether another page exists, and return the cursor in the direction of travel only then. A backward page arrives reversed, so its extra row is dropped from the front. --- packages/core/src/session.ts | 33 ++++++++++++++--------- packages/core/test/session-list.test.ts | 36 +++++++++++++++++++++++-- 2 files changed, 54 insertions(+), 15 deletions(-) diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 8660a673c9b..528cf96ac2d 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -405,24 +405,31 @@ const layer = Layer.effect( input.cursor === undefined ? input : yield* ListCursor.parse(input.cursor).pipe(Effect.mapError(() => new InvalidListCursorError())) - const data = yield* store.list({ ...filter, limit: input.limit ?? DefaultListLimit }) + const limit = input.limit ?? DefaultListLimit + const backward = "anchor" in filter && filter.anchor.direction === "previous" + // One extra row reveals whether another page exists; a backward page arrives reversed, so it sits first. + const rows = yield* store.list({ ...filter, limit: limit + 1 }) + const more = rows.length > limit + const data = more && backward ? rows.slice(1) : rows.slice(0, limit) const first = data[0] const last = data.at(-1) return { data, cursor: { - previous: first - ? ListCursor.make({ - ...filter, - anchor: { id: first.id, time: DateTime.toEpochMillis(first.time.updated), direction: "previous" }, - }) - : undefined, - next: last - ? ListCursor.make({ - ...filter, - anchor: { id: last.id, time: DateTime.toEpochMillis(last.time.updated), direction: "next" }, - }) - : undefined, + previous: + first && (more || !backward) + ? ListCursor.make({ + ...filter, + anchor: { id: first.id, time: DateTime.toEpochMillis(first.time.updated), direction: "previous" }, + }) + : undefined, + next: + last && (more || backward) + ? ListCursor.make({ + ...filter, + anchor: { id: last.id, time: DateTime.toEpochMillis(last.time.updated), direction: "next" }, + }) + : undefined, }, } }), diff --git a/packages/core/test/session-list.test.ts b/packages/core/test/session-list.test.ts index 1138130ab6f..62e2187452b 100644 --- a/packages/core/test/session-list.test.ts +++ b/packages/core/test/session-list.test.ts @@ -43,12 +43,44 @@ describe("Session.list", () => { const first = yield* session.list({ ...filter, limit: 2 }) const second = yield* session.list({ cursor: first.cursor.next, limit: 2 }) const back = yield* session.list({ cursor: second.cursor.previous, limit: 2 }) - const end = yield* session.list({ cursor: second.cursor.next, limit: 2 }) expect(expected).toHaveLength(3) expect([...first.data, ...second.data].map((item) => item.id)).toEqual(expected) + expect(second.cursor.next).toBeUndefined() expect(back.data.map((item) => item.id)).toEqual(expected.slice(0, 2)) - expect(end).toEqual({ data: [], cursor: { previous: undefined, next: undefined } }) + }), + ) + + it.effect("offers a cursor in the direction of travel only while more sessions exist", () => + Effect.gen(function* () { + const session = yield* Session.Service + yield* Effect.forEach(Array.from({ length: 6 }), () => session.create({ location: project }), { discard: true }) + const filter = { directory: project.directory, order: "asc" as const } + const store = yield* SessionStore.Service + const expected = (yield* store.list(filter)).map((item) => item.id) + const ids = (page: { data: ReadonlyArray }) => page.data.map((item) => item.id) + + const first = yield* session.list({ ...filter, limit: 2 }) + const second = yield* session.list({ cursor: first.cursor.next, limit: 2 }) + const last = yield* session.list({ cursor: second.cursor.next, limit: 2 }) + const back = yield* session.list({ cursor: last.cursor.previous, limit: 2 }) + const start = yield* session.list({ cursor: back.cursor.previous, limit: 2 }) + + expect(expected).toHaveLength(6) + expect([ids(first), ids(second), ids(last), ids(back), ids(start)]).toEqual([ + expected.slice(0, 2), + expected.slice(2, 4), + expected.slice(4, 6), + expected.slice(2, 4), + expected.slice(0, 2), + ]) + expect([first, second, last, back, start].map((page) => [!!page.cursor.previous, !!page.cursor.next])).toEqual([ + [true, true], + [true, true], + [true, false], + [true, true], + [false, true], + ]) }), )