Skip to content

sdk: keyset pagination drops rows tying the page-boundary value (Go + TS) #452

Description

@jfwoods

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:

  1. A tie-breaker column — the second .OrderBy() if present, otherwise a server-provided unique column.
  2. 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).
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Status
    Backlog

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions