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.
This commit is contained in:
Simon Klee
2026-10-26 12:20:08 +01:00
Unverified
parent dbe0510d88
commit 1252deedd9
2 changed files with 54 additions and 15 deletions
+20 -13
View File
@@ -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,
},
}
}),
+34 -2
View File
@@ -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<Session.Info> }) => 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],
])
}),
)