feat(page): add typed keyset pagination for Bob

Add versioned typed cursors, composable native query mods, bidirectional page assembly and opt-in authorized counts. Cover SQLite and PostgreSQL outcomes, strict cursor boundaries and a runnable example; require PostgreSQL service tests in CI.

Refs: #6
This commit is contained in:
end committed 2026-10-27 12:55:23 -07:00
1 parent da1ff94fe2
commit 8615d8ca66
11 files changed
+1142 -11

No files matched your search

+184
View File
@@ -0,0 +1,184 @@
// Package page provides transport-neutral keyset pagination for Bob SELECT queries.
// Callers own authorization, non-null stored sort values, and correspondence between
// Sort columns and the typed row key. The final column must itself be unique in the
// selected relation. Mixed directions are supported; null sort values are not.
//
// Sort IDs version the expressions, directions, key types and comparison semantics.
// Cursors expose values and allow edits; they are seek boundaries, not credentials.
// Concurrent writes or changed filters can skip or repeat rows between requests.
// Code generation may eventually align Sort and key; handwritten pairs have no
// compile-time field/type correspondence guarantee.
//
// Keys support exactly int, int8, int16, int32, int64, string, time.Time and the
// standard uuid.UUID. Named types, unsigned values, pointers and null wrappers
// require explicit caller conversion. Timestamps normalize to UTC with nanosecond
// precision. Versioned JSON cursors use unpadded base64url, limited to 16,384 bytes
// after encoding; unsupported values and malformed cursors return typed errors.
package page
import (
"fmt"
"slices"
"unicode/utf8"
"github.com/stephenafamo/bob"
)
// Order is a column's canonical ordering direction.
type Order uint8
const (
// Asc orders smaller values first.
Asc Order = iota
// Desc orders larger values first.
Desc
)
// Column declares a trusted, non-null SQL expression and its canonical direction.
// Unique asserts that this column alone is unique, including across joins.
type Column struct {
Expression bob.Expression
Direction Order
Unique bool
}
// Sort is an immutable, caller-owned ordering contract. Change ID when its
// expressions, order, key types, uniqueness or comparison semantics change.
type Sort struct {
ID string
Columns []Column
}
// Direction selects which side of an optional anchor to fetch.
type Direction uint8
const (
// Forward maps to first/after and is the default direction.
Forward Direction = iota
// Backward maps to last/before; returned items retain canonical sort order.
Backward
)
// Request carries a transport-independent page request. Zero Size uses the
// configured default; excessive sizes clamp to Maximum. Empty Cursor has no anchor.
type Request struct {
Direction Direction
Size int
Cursor string
}
// Limits configures page sizes; require 1 <= Default <= Maximum < max int.
type Limits struct {
Default int
Maximum int
}
// KeyError identifies a programmer error in the row key's arity.
type KeyError struct {
Expected int
Actual int
}
// Error implements error with the expected and actual key lengths.
func (e *KeyError) Error() string {
return fmt.Sprintf("page: key length %d, expected %d", e.Actual, e.Expected)
}
// Page contains canonical-order items and cursors for their first and last rows.
// Empty pages have empty cursors. Overflow determines the fetch-direction flag;
// anchor presence determines the opposite flag, even for empty/stale results.
// The opposite flag does not prove another row exists.
type Page[T any] struct {
Items []T
StartCursor string
EndCursor string
HasNext bool
HasPrevious bool
}
// Prepared holds validated pagination and the typed row key. Construct with Prepare.
// Treat Sort expressions and the key's captured state as immutable while in use.
type Prepared[T any] struct {
sort Sort
key func(T) []any
direction Direction
size int
anchor []any
}
// Prepare validates a request before a Bob mod can be applied. key must return
// exactly one supported concrete value per Sort column, in the same order, using
// actual stored/scanned values (including database timestamp precision).
func Prepare[T any](sort Sort, key func(T) []any, request Request, limits Limits) (*Prepared[T], error) {
if sort.ID == "" || !utf8.ValidString(sort.ID) || len(sort.Columns) == 0 {
return nil, fmt.Errorf("page: sort needs a valid ID and columns")
}
for i, c := range sort.Columns {
if c.Expression == nil || c.Direction > Desc {
return nil, fmt.Errorf("page: invalid sort column %d", i)
}
}
if !sort.Columns[len(sort.Columns)-1].Unique {
return nil, fmt.Errorf("page: final sort column must be unique")
}
if key == nil {
return nil, fmt.Errorf("page: nil key function")
}
if limits.Default < 1 || limits.Maximum < limits.Default || limits.Maximum == int(^uint(0)>>1) {
return nil, fmt.Errorf("page: invalid limits")
}
if request.Direction > Backward || request.Size < 0 {
return nil, fmt.Errorf("page: invalid request direction or size")
}
size := request.Size
if size == 0 {
size = limits.Default
}
size = min(size, limits.Maximum)
sort.Columns = slices.Clone(sort.Columns)
p := &Prepared[T]{sort: sort, key: key, direction: request.Direction, size: size}
if request.Cursor != "" {
var err error
p.anchor, err = decodeCursor(request.Cursor, sort)
if err != nil {
return nil, err
}
}
return p, nil
}
// Page validates all fetched rows, including overflow, and assembles a page.
// Pass rows in query order, with at most size+1 rows. It copies retained items;
// backward pagination never reverses the caller's slice. Errors return no page.
func (p *Prepared[T]) Page(rows []T) (Page[T], error) {
var zero Page[T]
if len(rows) > p.size+1 {
return zero, fmt.Errorf("page: too many fetched rows")
}
cursors := make([]string, len(rows))
for i, row := range rows {
key := p.key(row)
if len(key) != len(p.sort.Columns) {
return zero, &KeyError{Expected: len(p.sort.Columns), Actual: len(key)}
}
var err error
cursors[i], err = encodeCursor(p.sort.ID, key)
if err != nil {
return zero, err
}
}
overflow := len(rows) > p.size
n := min(len(rows), p.size)
result := Page[T]{Items: slices.Clone(rows[:n])}
if n > 0 {
result.StartCursor, result.EndCursor = cursors[0], cursors[n-1]
}
if p.direction == Backward {
slices.Reverse(result.Items)
result.StartCursor, result.EndCursor = result.EndCursor, result.StartCursor
result.HasPrevious, result.HasNext = overflow, len(p.anchor) > 0
} else {
result.HasNext, result.HasPrevious = overflow, len(p.anchor) > 0
}
return result, nil
}