Keyset pagination in both SDKs builds its cursor from a single column with a strict comparison, so rows that tie on the boundary value are silently dropped.
Problem
Page.Next filters on the first .OrderBy() column only, using gt (asc) or lt (desc) against the last row's value:
- Go:
fetchNextTyped, clients/go/query_builder.go — reads q.state.orderBy[0], emits a strict gt/lt filter.
- TS:
clients/ts/src/query-builder.ts — same shape.
If the last row of a page shares its cursor value with the first rows of the next page, those tied rows are skipped: the filter excludes everything <= (or >=) the boundary. Paginating on a low-cardinality column (page, status, a truncated timestamp) can drop an arbitrary number of rows with no error and no signal — HasMore and Next behave exactly as they do on a clean page.
There is also no Decode-path counterpart: when the cursor column is absent from the projection, pagination ends quietly by design (documented, TS parity). Ties are different — the caller asked for a valid cursor and silently lost rows.
Proposed fix
A composite cursor: filter on (col, tie_breaker) rather than col alone, using a lexicographic comparison. Requires:
- A tie-breaker column — the second
.OrderBy() if present, otherwise a server-provided unique column.
- Wire-format support for a tuple comparison, or an
(a > x) OR (a = x AND b > y) expansion in the structured-query filter grammar (internal/query/builder.go).
- Matching changes in both SDKs plus a shared
wire_cases.json conformance case, so they can't drift.
This is a wire-format change shared by both clients, which is why it isn't being folded into the Go SDK PR.
Interim
Documented as a caveat in docs/src/content/docs/sdk/go/queries.md (and the TS twin): paginate on a column that is unique per row, or accept that ties at a page edge can be dropped.
Related
Surfaced during PR #434 review-thread triage. Same family as the untyped-path float64 precision ceiling already documented on both pages.
Keyset pagination in both SDKs builds its cursor from a single column with a strict comparison, so rows that tie on the boundary value are silently dropped.
Problem
Page.Nextfilters on the first.OrderBy()column only, usinggt(asc) orlt(desc) against the last row's value:fetchNextTyped,clients/go/query_builder.go— readsq.state.orderBy[0], emits a strictgt/ltfilter.clients/ts/src/query-builder.ts— same shape.If the last row of a page shares its cursor value with the first rows of the next page, those tied rows are skipped: the filter excludes everything
<=(or>=) the boundary. Paginating on a low-cardinality column (page,status, a truncated timestamp) can drop an arbitrary number of rows with no error and no signal —HasMoreandNextbehave exactly as they do on a clean page.There is also no
Decode-path counterpart: when the cursor column is absent from the projection, pagination ends quietly by design (documented, TS parity). Ties are different — the caller asked for a valid cursor and silently lost rows.Proposed fix
A composite cursor: filter on
(col, tie_breaker)rather thancolalone, using a lexicographic comparison. Requires:.OrderBy()if present, otherwise a server-provided unique column.(a > x) OR (a = x AND b > y)expansion in the structured-query filter grammar (internal/query/builder.go).wire_cases.jsonconformance case, so they can't drift.This is a wire-format change shared by both clients, which is why it isn't being folded into the Go SDK PR.
Interim
Documented as a caveat in
docs/src/content/docs/sdk/go/queries.md(and the TS twin): paginate on a column that is unique per row, or accept that ties at a page edge can be dropped.Related
Surfaced during PR #434 review-thread triage. Same family as the untyped-path
float64precision ceiling already documented on both pages.