fix: migrate TransactionService::list_for_user to cursor/keyset pagination - #62
Merged
abayomicornelius merged 6 commits intoAug 17, 2026
Conversation
Needed to encode/decode the (created_at, id) keyset-pagination cursor for TransactionService::list_for_user (StellarSend#54) as an opaque string. 0.22 is already present in Cargo.lock transitively, so this adds no new dependency version to the tree — cargo-deny's tracked base64 0.13/0.21/0.22 duplicate-versions note is unaffected.
TransactionListParams gains cursor (opaque keyset cursor) and include_total (opt-in COUNT(*)). PaginatedTransactions' total/page/ total_pages become Option<_> since cursor mode doesn't compute them by default, plus a new next_cursor field for keyset continuation.
Splits list_for_user into two paths: list_for_user_cursor (new default) seeks from an opaque (created_at, id) cursor instead of counting/skipping past N rows, so cost no longer grows with how deep into the history a page is and no COUNT(*) runs unless the caller opts in via include_total. list_for_user_offset preserves the original page/per_page/COUNT(*) behavior unchanged, used only when a caller explicitly passes page. Ordering by (created_at, id) rather than created_at alone means rows sharing an identical timestamp (e.g. batch-payment legs inserted in one transaction) are still totally ordered, so keyset pagination can't skip or duplicate them across pages. Closes StellarSend#54
Updates the route's doc comment with the new cursor/include_total params and notes the page/per_page legacy mode's cost. Adds a short note to docs/API.md's Payments section pointing at the same tradeoff.
Unit-level coverage for the two pure helpers backing keyset pagination: a cursor round-trips through encode/decode at microsecond precision, and malformed input (bad base64, wrong shape, invalid UUID half) is rejected as AppError::BadRequest rather than panicking.
Integration tests (DATABASE_URL-gated, #[ignore]d, following escrow::db_tests/reconciliation::db_tests) proving StellarSend#54's acceptance criteria directly against real SQL rather than mocks: - 125 seeded rows paged via cursor come back exactly once, no gaps or duplicates. - A 12-row block sharing an identical created_at (simulating batch-payment legs inserted in one transaction) is still paginated correctly across a page boundary that falls inside the tied group. - The legacy page/per_page path still returns the same total/ total_pages/items shape as before. - Cursor mode omits total/total_pages by default and only computes them when include_total=true is passed. Run with: DATABASE_URL=... cargo test -- --ignored
Contributor
|
all checks passed |
3 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.
Summary
TransactionService::list_for_userran a fullCOUNT(*)on every request and usedOFFSET, which is O(offset) in Postgres — both well-known limitations that don't scale for large transaction histories.list_for_user_cursorseeks from an opaque(created_at, id)cursor (WHERE (created_at, id) < (:cursor_created_at, :cursor_id) ORDER BY created_at DESC, id DESC LIMIT :per_page) instead of counting/skipping past N rows, so cost no longer grows with how deep into the history a page is, and noCOUNT(*)runs unless the caller opts in.(created_at, id)rather thancreated_atalone means rows sharing an identical timestamp (e.g. batch-payment legs inserted within the same transaction) are still totally ordered — no gaps or duplicates across a page boundary that falls inside a tied group.page/per_pagestill works as a legacy mode (list_for_user_offset, unchanged behavior) for existing consumers — passingpageopts into the originalCOUNT(*)+OFFSETpath.include_total=trueopts either mode into computingtotal/total_pages(always computed in legacy mode);PaginatedTransactions.total/.page/.total_pagesbecomeOption<_>since cursor mode doesn't compute them by default, plus a newnext_cursorfield for continuation.Closes #54
Test plan
cargo test(unit tests, no DB) — cursor encode/decode round-trip + malformed-input rejection, all passcargo test -- --ignoredagainst a real Postgres — 4 integration tests pass:created_at(simulating batch-payment legs) is paginated correctly across a page boundary that falls inside the tied grouppage/per_pagemode still returns the sametotal/total_pages/items shape as beforetotal/total_pagesby default, computes them only withinclude_total=truecargo deny check advisories/cargo deny check bans licenses sources— pass (the newbase64dependency reuses the exact version, 0.22.1, already present transitively — no new dependency version added to the tree)cargo fmt --checkon changed files — clean