Skip to content

feat(table): cursor keyset continuation in ListPaged#56

Open
klaidliadon wants to merge 2 commits into
masterfrom
feat/table-list-cursor
Open

feat(table): cursor keyset continuation in ListPaged#56
klaidliadon wants to merge 2 commits into
masterfrom
feat/table-list-cursor

Conversation

@klaidliadon

@klaidliadon klaidliadon commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

CursorPaginator (#55) gives keyset pagination but makes every consumer hand-write a Cursor type — Apply/From/OrderBy — even for the overwhelmingly common case of "page by the table's id." This PR folds that case into ListPaged itself, so callers never pick a pagination mode: a Page carrying a Cursor continues a keyset walk over the table's IDColumn, anything else is offset pagination, and id-ordered offset pages hand back a NextCursor to opt in.

(Earlier revision exposed this as a separate ListCursor method — reworked per review so the method choice, and the mixed-Page mistakes it invited, no longer exist.)


refactor(page)Order.IsValid / Order.Sanitize

  • Order.IsValid() — strict check (exactly Asc/Desc).
  • Order.Sanitize() — lenient normalize (case + whitespace, unknown → Asc); the Order type now owns its own normalization rules.
  • Sort.sanitize delegates direction handling to it (was an inline switch); behavior-identical, still case-insensitive and defaulting.
  • Page.SetDefaults zero-checks collapse to cmp.Or.

feat(table) — cursor keyset continuation in ListPaged

Signature is unchanged:

func (t *Table[T, P, I]) ListPaged(ctx context.Context, where sq.Sqlizer, page *Page) ([]P, *Page, error)

Semantics:

  • Offset pagination works exactly as before, plus: when the effective order is exactly IDColumn (the default when no sort is given), pages with more rows populate page.NextCursor.
  • A Page carrying that Cursor continues as a keyset walk over IDColumn: forward-only, no random page access, but pages never skip or duplicate rows under concurrent writes. The first page of a walk is identical in both modes, so callers start with a plain Page{Size: n} and continue with Page{Size: n, Cursor: prev.NextCursor} — or just round-trip the returned *Page (More/NextCursor are reset every call, so reuse is safe). Resetting NextCursor lives in PrepareResult — the method that owns the page's output fields — which also fixes a latent leak in CursorPaginator.PrepareResult from feat: CursorPaginator for keyset pagination #55: it previously left a reused page's stale NextCursor in place on the final page.
  • The cursor encodes its direction ({"id": ..., "order": "DESC"}, opaque base64-JSON via EncodeCursor), so a bare {Cursor: next} page cannot silently flip a Desc walk to Asc. Direction is chosen on the first page through the normal order vocabulary (Sort: [{id DESC}] / Column: "-id").
  • Conflicts error instead of guessing:
    • page order that contradicts the cursor, or isn't the id column → ErrCursorPageOrdered (from feat: CursorPaginator for keyset pagination #55)
    • cursor combined with Page > 1ErrCursorPaged (new sentinel; Page == 1 stays allowed because PrepareResult writes it into round-tripped pages)
    • undecodable cursor, or a decoded token whose direction isn't exactly ASC/DESCErrInvalidCursor. Cursors are minted by ListPaged, not user input — a forged "order":"sideways" is rejected, not coerced to Asc the way Sort input is.

Notes

  • Behavior change to ListPaged: it previously ignored Page.Cursor; now it acts on it, and it populates NextCursor it never set before. The old behavior was itself a hazard — a Page carrying a cursor was silently offset-paginated. Table is marked NOTICE: Experimental, so no compatibility ceremony.
  • Why dispatch on the Page and not on the table's Paginator config: config-based dispatch makes the same Page mean different things on differently-configured tables (Page: 5 against a cursor-configured table must either silently become forward-only or error at runtime), and CursorPaginator's cursor type can't see instance state like t.IDColumn anyway. Keying on the request keeps it self-describing.
  • Uniqueness caveat. Keyset stability requires IDColumn to be unique — true for a primary key. Keyset by non-id columns stays out of scope (needs a forced tiebreaker); CursorPaginator covers that case.

Test plan

TestTableListPagedCursor (tests/cursor_test.go):

$ make test TEST=TestTableListPagedCursor
--- PASS: TestTableListPagedCursor (0.02s)
    --- PASS: TestTableListPagedCursor/Desc_walks_newest_first_without_gaps_or_overlap (0.00s)
    --- PASS: TestTableListPagedCursor/default_order_walks_oldest_first_without_gaps_or_overlap (0.00s)
    --- PASS: TestTableListPagedCursor/round-tripping_the_returned_page_continues_the_walk (0.00s)
    --- PASS: TestTableListPagedCursor/non-id_order_emits_no_cursor (0.00s)
    --- PASS: TestTableListPagedCursor/cursor_with_a_conflicting_page_order_errors (0.00s)
    --- PASS: TestTableListPagedCursor/cursor_with_a_page_number_errors (0.00s)
    --- PASS: TestTableListPagedCursor/rejects_an_undecodable_cursor (0.00s)
    --- PASS: TestTableListPagedCursor/rejects_a_forged_cursor_order (0.00s)
PASS
ok  	github.com/goware/pgkit/v2/tests
  • Desc and default-Asc walks assert strict monotonic ordering and no gaps/overlap across all pages (covers both Lt/Gt branches plus the offset→keyset NextCursor handoff).
  • Round-trip subtest reuses the returned *Page object across the whole walk and asserts the final page leaks no stale cursor; TestCursorPaginatorPaginateReturnsPage got the same final-page assertion for the CursorPaginator fix (verified red without the fix, green with it).
  • Error paths: conflicting page order (ErrCursorPageOrdered, both wrong direction and wrong column), cursor + page number (ErrCursorPaged), undecodable cursor and forged cursor order — "sideways", lowercase, empty — (ErrInvalidCursor, verified red without the validation); non-id ordering emits no cursor.
  • Each commit builds standalone; full suite green (make test-all, 6 packages ok, 0 failures); go build/go vet/gofmt -l clean.

@klaidliadon klaidliadon force-pushed the feat/table-list-cursor branch from 1332d0a to f8f081c Compare June 11, 2026 13:10
Comment thread table.go Outdated
// direction (empty means Asc; any other non-Asc/Desc value is an error).
// Forward-only and stable under concurrent writes, unlike ListPaged; IDColumn
// must be unique for the ordering to be stable.
func (t *Table[T, P, I]) ListCursor(ctx context.Context, where sq.Sqlizer, page *Page, order Order) ([]P, *Page, error) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Why do we need additional method?
  2. Why can't we have ListPaged handle page differently depending on Paginator? (page based / cursor based)
  3. What happens if you pass cursor page to ListPaged? What happens if you pass page based page to ListCursor?

I believe the complexity may be hidden here. We do not need to expose user to this decision making and costly mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reworked: ListCursor is gone and ListPaged now handles both modes (c0cc5f5). Taking the questions in order:

1 + 2. Agreed on one method, with one change to the mechanism: dispatch keys on the request (the Page), not on the table's Paginator config. Config-based dispatch makes the same Page mean different things on differently-configured tables — Page: 5 against a cursor-configured table either silently becomes forward-only or errors at runtime — which is exactly the costly-mistake class this should kill. It also doesn't compose: CursorPaginator's cursor type can't see instance state like t.IDColumn. So: a Page carrying a Cursor continues a keyset walk over IDColumn; anything else is offset pagination, and id-ordered offset pages (the default order) now return NextCursor. Callers start with a plain Page{Size: n} and continue by cursor — no mode to pick, no new method.

3. Both crossings are now well-defined: a cursor page just works; a page-number page works and also hands back NextCursor. The ambiguous combination (Cursor + Page > 1) returns ErrCursorPaged instead of guessing, an order that contradicts the cursor returns ErrCursorPageOrdered, and the cursor encodes its own direction — validated strictly on decode, so a forged or corrupted token ("order":"sideways") returns ErrInvalidCursor rather than silently walking Asc. For reference, master has the silent case today: ListPaged ignores Page.Cursor and offset-paginates it.

PR description is updated to match.

@klaidliadon klaidliadon force-pushed the feat/table-list-cursor branch from f8f081c to f753317 Compare June 12, 2026 09:20
@klaidliadon klaidliadon changed the title feat(table): ListCursor for id-keyset pagination feat(table): cursor keyset continuation in ListPaged Jun 12, 2026
@klaidliadon klaidliadon force-pushed the feat/table-list-cursor branch from f753317 to 574c38c Compare June 12, 2026 09:28
- Order.IsValid() - strict check (exactly Asc/Desc)
- Order.Sanitize() - lenient normalize (case + whitespace, unknown ->
  Asc); the Order type owns its own normalization rules
- Sort.sanitize delegates direction handling to Order.Sanitize (was an
  inline switch); behavior identical
- Page.SetDefaults zero-checks collapse to cmp.Or
- a Page carrying a Cursor continues a keyset walk over IDColumn:
  forward-only, stable under concurrent writes; anything else is
  offset-paginated as before
- offset pages ordered exactly by IDColumn (the default) populate
  NextCursor, so callers start with a plain Page and continue by cursor
- the cursor encodes its direction; a forged or missing direction in the
  token returns ErrInvalidCursor (cursors are minted, not user input),
  a conflicting page order returns ErrCursorPageOrdered, and cursor +
  page number > 1 returns ErrCursorPaged
- PrepareResult owns resetting NextCursor (both paginators), so
  round-tripping the returned Page object never leaks a stale cursor;
  fixes the same leak in CursorPaginator.PrepareResult from #55
@klaidliadon klaidliadon force-pushed the feat/table-list-cursor branch from 574c38c to c0cc5f5 Compare June 12, 2026 09:41
@klaidliadon klaidliadon requested a review from marino39 June 15, 2026 08:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants