Skip to main content

Pagination Contracts

Guarantees​

Cursor Integrity​

  • Structural validation (always): malformed cursors, a version mismatch, or a cursor replayed under a different orderBy are rejected with a clear 400 (CURSOR_INVALID). Optional cursorMaxAgeMs rejects stale cursors.
  • Signature verification (when a secret is configured): with a cursorSigningSecret (per-resource or global via setGlobalCursorSigningSecret), each cursor is suffixed with .<hmac-sha256(payload, secret)> and the signature is verified on decode (constant-time). A forged or altered payload — or one signed with a different secret, or unsigned — is rejected as reason: "tampered". A resource secret overrides the global; an explicit null opts a resource out; unset inherits the global.
  • Not a security boundary either way: a cursor encodes only a keyset position and its values are bound as parameterized SQL; every query still applies the resource's auth scope + filter, so even an unsigned/forged cursor cannot widen access or inject SQL. Signing adds integrity/anti-forgery on top.
  • Encoding stability: Cursor format is stable within major version
  • Opaque to client: Cursors are treated as opaque strings; clients should not parse them

Result Set Properties​

  • No duplicates: Paginating through a static dataset returns each item exactly once
  • No gaps: Paginating through a static dataset returns all items
  • Stable ordering: Items are returned in consistent order based on orderBy fields

Ordering​

  • Multi-field support: Can order by multiple fields (e.g., orderBy=score:desc,id)
  • Tie-breaking: Always includes unique field (usually id) for deterministic ordering
  • Null handling: Null values sort last (configurable)

Limits​

  • Server-enforced max: Requests cannot exceed server's max page size
  • Default applied: If no limit specified, server applies default

Non-Guarantees​

Consistency (What We Don't Promise)​

  • ❌ Snapshot isolation: Dataset may change between page fetches
  • ❌ Repeatable reads: Same cursor may return different results after mutations
  • ❌ Insert visibility: New items may appear in "already fetched" pages

Behavior Under Mutation (What We Don't Promise)​

  • ❌ Insert ordering: Items inserted between fetches may appear in unexpected positions
  • ❌ Delete handling: Deleted items may cause apparent "skips"
  • ❌ Update consistency: Updated items may move between pages

Performance (What We Don't Promise)​

  • ❌ Constant time: Pagination performance may vary with dataset size
  • ❌ Cursor validity duration: Cursors may become invalid after extended periods

Cursor Behavior Under Mutations​

Item Inserted​

  • If inserted BEFORE cursor position: May cause duplicate in next page
  • If inserted AFTER cursor position: Will appear normally
  • If inserted AT cursor position: Behavior depends on tie-breaking

Item Deleted​

  • If deleted item was cursor: Next fetch continues from next item
  • If deleted BEFORE cursor: May cause item to be skipped
  • If deleted AFTER cursor: No effect on pagination

Item Updated (Affecting Sort Order)​

  • Item may move to different page
  • May cause apparent duplicate or skip
  • This is expected behavior, not a bug

Mitigation Strategies​

For applications requiring consistency:

  1. Timestamp filtering: Add createdAt < snapshot_time filter
  2. Optimistic locking: Use ETags to detect concurrent modifications (see etag.md)
  3. Sequence numbers: Track changelog sequence for consistent views

Test Coverage​

  • tests/invariants/pagination-invariants.test.ts - Core invariants
  • tests/pagination.test.ts - Basic functionality
  • tests/smoke/pagination-cursor-hardening.test.ts - Edge cases