Feat/keyset pagination 1384 - #1447
Merged
ogazboiz merged 5 commits intoJul 27, 2026
Merged
Conversation
- Add pagination.ts with encodeCursor, decodeCursor, buildKeysetClause utilities - Implement base64url cursor encoding per RFC 4648 - Add parseKeysetParams for query parameter validation - Create pagination-contract.md specifying API contract for all list endpoints - Ensures snapshot pinning and stable cursor traversal under concurrent writes
- Add seq BIGINT GENERATED ALWAYS AS IDENTITY to contract_events, remittances, loan_disputes - Backfill seq in (created_at, id) insertion order for stable pagination - Create composite indexes idx_*_seek on (created_at DESC, seq DESC) - Support idempotent up/down migration with table existence checks
- Refactor getBorrowerEvents to use keyset seek (created_at DESC, seq DESC) - Refactor getLoanEvents to use keyset seek predicate - Refactor getRecentEvents to use keyset seek predicate - All endpoints now pin snapshot_seq on first request - Return opaque base64url cursors in next_cursor - Compute stable total_at_snapshot for each page - Remove OFFSET usage; replace with snapshot-pinned seek clause
- Refactor listLoanDisputes to use keyset seek (created_at DESC, seq DESC) - Pin snapshot_seq on first request; carry on subsequent requests - Support status filtering with snapshot-aware totals - Return opaque base64url cursors for stable pagination - Replace OFFSET with keyset predicate
- Refactor getRemittances to use keyset seek (created_at DESC, seq DESC) - Support status, date range, and search filtering - Pin snapshot_seq on first request - Return opaque base64url cursors in next_cursor - Compute stable total_at_snapshot accounting for filters - Replace OFFSET/LIMIT with keyset predicate
Contributor
|
grat |
6 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
[Backend] Keyset Pagination with Snapshot-Consistent Totals for List Endpoints
Summary
Refactored RemitLend list endpoints to use keyset (seek-based) pagination with snapshot-pinned totals, eliminating pagination window instability and row duplicates/skips under concurrent writes.
Closes #1384
Problem
OFFSET/LIMIT pagination had critical issues under concurrent writes:
Solution
Implemented keyset pagination with snapshot pinning:
seqBIGINT GENERATED ALWAYS AS IDENTITY columns tocontract_events,remittances, andloan_disputestables(created_at DESC, seq DESC)for efficient keyset traversalsnapshot_seqon first request; all subsequent pages use that pinned snapshot(created_at, seq)tuples using opaque base64url cursorstotal_at_snapshotcomputed asCOUNT(*) WHERE seq <= snapshot_seqChanges
Schema & Migrations
seqcolumns and composite indexesBackend Pagination Library
encodeCursor(createdAt, seq): Base64url cursor encodingdecodeCursor(cursor): Cursor validation and decodingbuildKeysetClause(cursor, snapshotSeq): SQL WHERE clause builderparseKeysetParams(snapshotSeq, cursor, limit): Query parameter parsingAPI Response Contract
Refactored Endpoints
All now return keyset pagination response:
{ "items": [...], "page": { "next_cursor": "<base64url|null>", "snapshot_seq": "148223", "total_at_snapshot": 4021, "limit": 50 } }Events Indexer (backend/src/controllers/indexerController.ts)
GET /indexer/events/borrower/{borrower}: Keyset pagination with snapshot pinningGET /indexer/events/loan/{loanId}: Keyset pagination with snapshot pinningGET /indexer/events/recent: Keyset pagination with snapshot pinningAdmin Disputes (backend/src/controllers/adminDisputeController.ts)
GET /admin/disputes: Keyset pagination with status filtering and snapshot pinningRemittances (backend/src/controllers/remittanceController.ts)
GET /remittances: Keyset pagination with status/date/search filtering and snapshot pinningInvariants Maintained
✅ No OFFSET in any list query; all use keyset seek predicate
✅ Composite index (created_at DESC, seq DESC) accelerates queries
✅ Cursor is opaque base64url; client never parses it
✅ snapshot_seq pinned at first request; carried on all subsequent requests
✅ total_at_snapshot constant across full cursor chain
✅ Malformed cursors return HTTP 400 with code INVALID_CURSOR
✅ limit clamped to [1, 100]
✅ Migration up/down both work; seq non-null and strictly ordered
Testing
Known Limitations / Future Work
GET /loans/borrower/{borrower}uses complex derived loan calculations; refactoring deferred to follow-up PR to avoid large single changeVerification Steps
Files Changed
Breaking Changes
✅ API Response Shape Change:
Endpoints now return:
{ "page": { "next_cursor": "...", "snapshot_seq": "...", "total_at_snapshot": ..., "limit": ... } }Old
page_infostructure withoffset,count,has_nextis replaced. Frontend must update to readnext_cursorandsnapshot_seq.✅ Query Parameter Change:
Clients must pass
cursorandsnapshot_seqinstead ofoffset.Detailed migration guide in pagination-contract.md.