diff --git a/src/pages/Transfers.css b/src/pages/Transfers.css
index fd86757..298167c 100644
--- a/src/pages/Transfers.css
+++ b/src/pages/Transfers.css
@@ -101,3 +101,12 @@
margin-top: 0;
}
}
+
+.transfers-snapshot-notice {
+ margin: 0 0 1rem;
+ padding: 0.65rem 0.85rem;
+ border-radius: 8px;
+ background: rgba(37, 99, 235, 0.12);
+ color: var(--color-text);
+ font-size: 0.9rem;
+}
diff --git a/src/pages/Transfers.jsx b/src/pages/Transfers.jsx
index def8104..4b9bdfe 100644
--- a/src/pages/Transfers.jsx
+++ b/src/pages/Transfers.jsx
@@ -19,6 +19,11 @@ import { useTransfers } from '../hooks/useTransfers.js';
import { useOnlineStatus } from '../hooks/useOnlineStatus.js';
import { useApp } from '../context/AppContext.jsx';
import { DATE_RANGE_PRESETS, isWithinDateRange } from '../utils/dateRange.js';
+import {
+ DEFAULT_PAGE_SIZE,
+ encodeCursor,
+ resolveTransferPage,
+} from '../utils/transferSnapshot.js';
import './Transfers.css';
// Derived from the contract so a new lifecycle state cannot be filterable in
@@ -31,11 +36,13 @@ const STATUS_OPTIONS = [
})),
];
-const PAGE_SIZE = 5;
-
/**
* Transfers page: lists all transfers with their status.
* Filter state is synced to the URL query string.
+ *
+ * Pagination uses a frozen snapshot so concurrent inserts cannot duplicate or
+ * omit rows while the user pages. Filter changes reset the snapshot; expired
+ * cursors recover by rebuilding from the live filtered list.
*/
export default function Transfers() {
const { transfers, loading, error, reload } = useTransfers();
@@ -57,23 +64,24 @@ export default function Transfers() {
const [selectedIds, setSelectedIds] = useState(() => new Set());
const [selectAllAcross, setSelectAllAcross] = useState(false);
- // Pagination state
- const [page, setPage] = useState(1);
-
- // Reset page and selection when filters change
+ // Snapshot pagination state
+ const [snapshot, setSnapshot] = useState(null);
+ const [cursor, setCursor] = useState(null);
+ const [snapshotNotice, setSnapshotNotice] = useState(null);
+ // Reset cursor/selection when filters change; snapshot rebuilds below.
useEffect(() => {
- setPage(1);
+ setCursor(null);
+ setSnapshot(null);
setSelectedIds(new Set());
setSelectAllAcross(false);
+ setSnapshotNotice(null);
}, [search, status, range]);
// Normalise the query-string status so a legacy or provider spelling in a
// shared/bookmarked URL (?status=settled) still selects the right rows.
const canonicalStatus = normalizeStatus(status);
+
// Track connectivity so that a reconnect triggers an automatic reload.
- // The reload reconciles the true status of transfers that may have been
- // created or settled while the connection was down — without resubmitting
- // anything.
useEffect(() => {
if (isOnline && wasOffline && !loading) {
setSyncingAfterReconnect(true);
@@ -94,11 +102,78 @@ export default function Transfers() {
});
}, [transfers, search, status, canonicalStatus, range]);
- // Paginated data
- const totalPages = Math.ceil(filteredTransfers.length / PAGE_SIZE) || 1;
- const pageTransfers = useMemo(() => {
- return filteredTransfers.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
- }, [filteredTransfers, page]);
+ const filterState = useMemo(
+ () => ({ search, status: canonicalStatus || status, range }),
+ [search, status, canonicalStatus, range],
+ );
+
+ // Resolve the current page against a frozen snapshot. Membership stays
+ // deterministic even if `transfers` grows while the user is on page 2.
+ const resolved = useMemo(() => {
+ if (loading) {
+ return {
+ snapshot: null,
+ page: {
+ ok: true,
+ items: [],
+ page: 1,
+ totalPages: 1,
+ totalCount: 0,
+ cursor: null,
+ nextCursor: null,
+ prevCursor: null,
+ recovered: false,
+ },
+ recovered: false,
+ };
+ }
+ return resolveTransferPage(filteredTransfers, {
+ filters: filterState,
+ cursor,
+ snapshot,
+ pageSize: DEFAULT_PAGE_SIZE,
+ });
+ }, [filteredTransfers, filterState, cursor, snapshot, loading]);
+
+ const pageTransfers = resolved.page.items;
+ const totalPages = resolved.page.totalPages;
+ const page = resolved.page.page;
+
+ // Persist a newly minted snapshot and surface recovery after an expired cursor.
+ useEffect(() => {
+ if (
+ resolved.snapshot &&
+ resolved.snapshot.id !== snapshot?.id
+ ) {
+ setSnapshot(resolved.snapshot);
+ }
+ if (resolved.recovered && cursor) {
+ setCursor(null);
+ setSnapshotNotice(
+ 'Transfer list was refreshed to keep paging consistent.',
+ );
+ }
+ }, [resolved, snapshot, cursor]);
+
+ const handlePageChange = useCallback(
+ (nextPage) => {
+ const active = resolved.snapshot;
+ if (!active) return;
+ const safePage = Math.min(Math.max(1, nextPage), totalPages);
+ const priorRef =
+ active.items[(safePage - 1) * active.pageSize - 1] ?? null;
+ setCursor(
+ encodeCursor({
+ snapshotId: active.id,
+ scope: active.scope,
+ page: safePage,
+ after: priorRef,
+ }),
+ );
+ setSnapshotNotice(null);
+ },
+ [resolved.snapshot, totalPages],
+ );
// Selection state computations
const allPageSelected =
@@ -265,7 +340,11 @@ export default function Transfers() {
/>
))}
-
+
>
);
};
@@ -283,6 +362,17 @@ export default function Transfers() {
)}
+ {snapshotNotice && (
+
{
+ const aTime = Date.parse(a?.createdAt ?? '') || 0;
+ const bTime = Date.parse(b?.createdAt ?? '') || 0;
+ if (bTime !== aTime) return bTime - aTime;
+ return String(b?.id ?? '').localeCompare(String(a?.id ?? ''));
+ });
+}
+
+/**
+ * Canonical filter scope bound into every cursor.
+ * @param {{search?: string, status?: string, range?: string}} filters
+ */
+export function filterScopeKey(filters = {}) {
+ return JSON.stringify({
+ search: String(filters.search ?? ''),
+ status: String(filters.status ?? ''),
+ range: String(filters.range ?? ''),
+ });
+}
+
+/**
+ * @param {object[]} transfers - already filtered live rows
+ * @param {{filters?: object, now?: number|Date, pageSize?: number, snapshotId?: string}} [options]
+ */
+export function createTransferSnapshot(transfers, options = {}) {
+ const nowMs =
+ options.now instanceof Date
+ ? options.now.getTime()
+ : (options.now ?? Date.now());
+ const pageSize = options.pageSize ?? DEFAULT_PAGE_SIZE;
+ const scope = filterScopeKey(options.filters);
+ const items = stableSortTransfers(transfers).map((t) => ({
+ id: t.id,
+ createdAt: t.createdAt,
+ record: { ...t },
+ }));
+
+ return {
+ id: options.snapshotId ?? mintSnapshotId(nowMs, scope, items.length),
+ createdAt: new Date(nowMs).toISOString(),
+ expiresAt: new Date(nowMs + SNAPSHOT_TTL_MS).toISOString(),
+ scope,
+ pageSize,
+ items,
+ };
+}
+
+function mintSnapshotId(nowMs, scope, count) {
+ const seed = `${nowMs}:${scope}:${count}`;
+ let hash = 0x811c9dc5;
+ for (let i = 0; i < seed.length; i += 1) {
+ hash ^= seed.charCodeAt(i);
+ hash = Math.imul(hash, 0x01000193);
+ }
+ return `snap_${(hash >>> 0).toString(16).padStart(8, '0')}`;
+}
+
+/**
+ * Opaque cursor: snapshot id + scope + last-seen key + page index.
+ * @param {{snapshotId: string, scope: string, after?: {createdAt: string, id: string}|null, page: number}} parts
+ */
+export function encodeCursor(parts) {
+ const payload = {
+ s: parts.snapshotId,
+ scope: parts.scope,
+ page: parts.page,
+ after: parts.after
+ ? { t: parts.after.createdAt, i: parts.after.id }
+ : null,
+ };
+ return `c_${toBase64Url(JSON.stringify(payload))}`;
+}
+
+/**
+ * @param {string} cursor
+ * @returns {{snapshotId: string, scope: string, page: number, after: {createdAt: string, id: string}|null}|null}
+ */
+export function decodeCursor(cursor) {
+ if (!cursor || typeof cursor !== 'string' || !cursor.startsWith('c_')) {
+ return null;
+ }
+ try {
+ const raw = JSON.parse(fromBase64Url(cursor.slice(2)));
+ if (!raw?.s || !Number.isSafeInteger(raw.page) || raw.page < 1) return null;
+ return {
+ snapshotId: raw.s,
+ scope: raw.scope ?? '',
+ page: raw.page,
+ after: raw.after ? { createdAt: raw.after.t, id: raw.after.i } : null,
+ };
+ } catch {
+ return null;
+ }
+}
+
+function toBase64Url(value) {
+ if (typeof btoa === 'function') {
+ return btoa(unescape(encodeURIComponent(value)))
+ .replace(/\+/g, '-')
+ .replace(/\//g, '_')
+ .replace(/=+$/g, '');
+ }
+ return Buffer.from(value, 'utf8')
+ .toString('base64')
+ .replace(/\+/g, '-')
+ .replace(/\//g, '_')
+ .replace(/=+$/g, '');
+}
+
+function fromBase64Url(value) {
+ const padded = value.replace(/-/g, '+').replace(/_/g, '/');
+ const pad = padded.length % 4 === 0 ? '' : '='.repeat(4 - (padded.length % 4));
+ const b64 = padded + pad;
+ if (typeof atob === 'function') {
+ return decodeURIComponent(escape(atob(b64)));
+ }
+ return Buffer.from(b64, 'base64').toString('utf8');
+}
+
+/**
+ * Is the cursor still valid for this snapshot and filter scope?
+ * @param {object|null} decoded
+ * @param {object} snapshot
+ * @param {object} filters
+ * @param {number|Date} [now]
+ */
+export function isCursorInScope(decoded, snapshot, filters, now = Date.now()) {
+ if (!decoded || !snapshot) return false;
+ if (decoded.snapshotId !== snapshot.id) return false;
+ if (decoded.scope !== snapshot.scope) return false;
+ if (decoded.scope !== filterScopeKey(filters)) return false;
+ if (isSnapshotExpired(snapshot, now)) return false;
+ const totalPages = Math.max(1, Math.ceil(snapshot.items.length / snapshot.pageSize));
+ if (decoded.page > totalPages) return false;
+ const prior = snapshot.items[(decoded.page - 1) * snapshot.pageSize - 1] ?? null;
+ if (!prior) return decoded.after === null;
+ return decoded.after?.id === prior.id && decoded.after?.createdAt === prior.createdAt;
+}
+
+export function isSnapshotExpired(snapshot, now = Date.now()) {
+ if (!snapshot?.expiresAt) return true;
+ const expiry = Date.parse(snapshot.expiresAt);
+ if (Number.isNaN(expiry)) return true;
+ const current = now instanceof Date ? now.getTime() : now;
+ return current >= expiry;
+}
+
+/**
+ * Page a snapshot. Returns rows from the live transfer map so callers still
+ * render full records, but membership is frozen to the snapshot.
+ *
+ * @param {object} snapshot
+ * @param {object[]} liveTransfers - full transfer objects (id-keyed lookup)
+ * @param {{cursor?: string|null, filters?: object, now?: number|Date}} [options]
+ */
+export function pageFromSnapshot(snapshot, liveTransfers, options = {}) {
+ const filters = options.filters ?? {};
+ const now = options.now ?? Date.now();
+ const pageSize = snapshot?.pageSize ?? DEFAULT_PAGE_SIZE;
+ const byId = new Map((liveTransfers ?? []).map((t) => [t.id, t]));
+
+ const decoded = options.cursor ? decodeCursor(options.cursor) : null;
+ const scoped = isCursorInScope(decoded, snapshot, filters, now);
+
+ if (!snapshot || isSnapshotExpired(snapshot, now)) {
+ return {
+ ok: false,
+ code: 'snapshot_expired',
+ items: [],
+ page: 1,
+ totalPages: 1,
+ totalCount: 0,
+ cursor: null,
+ nextCursor: null,
+ prevCursor: null,
+ recovered: true,
+ };
+ }
+
+ if (options.cursor && !scoped) {
+ return {
+ ok: false,
+ code: 'cursor_expired',
+ items: [],
+ page: 1,
+ totalPages: Math.max(1, Math.ceil(snapshot.items.length / pageSize)),
+ totalCount: snapshot.items.length,
+ cursor: null,
+ nextCursor: null,
+ prevCursor: null,
+ recovered: true,
+ };
+ }
+
+ const page = scoped ? Math.max(1, decoded.page) : 1;
+ const totalCount = snapshot.items.length;
+ const totalPages = Math.max(1, Math.ceil(totalCount / pageSize) || 1);
+ const start = (page - 1) * pageSize;
+ const slice = snapshot.items.slice(start, start + pageSize);
+ // Keep a complete snapshot even if a row disappears or changes its active
+ // filter after the snapshot was taken. Fresh fields win while it remains live.
+ const items = slice.map((ref) => byId.get(ref.id) ?? ref.record);
+ const priorRef = snapshot.items[start - 1] ?? null;
+ const lastRef = slice[slice.length - 1] ?? null;
+ const currentCursor = encodeCursor({
+ snapshotId: snapshot.id,
+ scope: snapshot.scope,
+ page,
+ after: priorRef,
+ });
+ const nextCursor =
+ page < totalPages
+ ? encodeCursor({
+ snapshotId: snapshot.id,
+ scope: snapshot.scope,
+ page: page + 1,
+ after: lastRef,
+ })
+ : null;
+ const prevCursor =
+ page > 1
+ ? encodeCursor({
+ snapshotId: snapshot.id,
+ scope: snapshot.scope,
+ page: page - 1,
+ after: snapshot.items[(page - 2) * pageSize - 1] ?? null,
+ })
+ : null;
+
+ return {
+ ok: true,
+ code: null,
+ items,
+ page,
+ totalPages,
+ totalCount,
+ cursor: currentCursor,
+ nextCursor,
+ prevCursor,
+ recovered: false,
+ };
+}
+
+/**
+ * Resolve a page, recovering from expired cursors by minting a fresh snapshot.
+ * @param {object[]} filteredTransfers
+ * @param {{filters: object, cursor?: string|null, snapshot?: object|null, pageSize?: number, now?: number|Date}} state
+ */
+export function resolveTransferPage(filteredTransfers, state = {}) {
+ const now = state.now ?? Date.now();
+ const filters = state.filters ?? {};
+ let snapshot = state.snapshot ?? null;
+ let recovered = false;
+
+ // An empty snapshot created while the list was still loading must not freeze
+ // out rows that arrive a moment later. Concurrent inserts into a non-empty
+ // snapshot remain frozen (that is the whole point of snapshot paging).
+ const needsNew =
+ !snapshot ||
+ isSnapshotExpired(snapshot, now) ||
+ snapshot.scope !== filterScopeKey(filters) ||
+ (snapshot.items.length === 0 && (filteredTransfers?.length ?? 0) > 0);
+
+ if (needsNew) {
+ snapshot = createTransferSnapshot(filteredTransfers, {
+ filters,
+ now,
+ pageSize: state.pageSize ?? DEFAULT_PAGE_SIZE,
+ });
+ recovered = Boolean(state.snapshot) || Boolean(state.cursor);
+ }
+
+ const page = pageFromSnapshot(snapshot, filteredTransfers, {
+ cursor: needsNew ? null : state.cursor,
+ filters,
+ now,
+ });
+
+ if (!page.ok) {
+ snapshot = createTransferSnapshot(filteredTransfers, {
+ filters,
+ now,
+ pageSize: state.pageSize ?? DEFAULT_PAGE_SIZE,
+ });
+ const recoveredPage = pageFromSnapshot(snapshot, filteredTransfers, {
+ cursor: null,
+ filters,
+ now,
+ });
+ return { snapshot, page: { ...recoveredPage, recovered: true }, recovered: true };
+ }
+
+ return { snapshot, page: { ...page, recovered }, recovered };
+}
diff --git a/test/integration/transfers-snapshot-pagination.test.jsx b/test/integration/transfers-snapshot-pagination.test.jsx
new file mode 100644
index 0000000..08dc86b
--- /dev/null
+++ b/test/integration/transfers-snapshot-pagination.test.jsx
@@ -0,0 +1,75 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { beforeEach, describe, expect, it } from 'vitest';
+import App from '../../src/App.jsx';
+
+function seedTransfers(count) {
+ const transfers = Array.from({ length: count }, (_, i) => ({
+ id: `tx_${1000 + i}`,
+ recipient: `person${i}@example.com`,
+ from: 'USD',
+ to: 'NGN',
+ sendAmount: 100 + i,
+ receiveAmount: (100 + i) * 1400,
+ status: i % 2 === 0 ? 'completed' : 'pending',
+ createdAt: `2026-06-${String(i + 1).padStart(2, '0')}T10:00:00Z`,
+ }));
+ localStorage.setItem('remitflow.transfers', JSON.stringify(transfers));
+ return transfers;
+}
+
+async function gotoTransfers() {
+ window.history.pushState({}, '', '/transfers');
+ render(
);
+ await screen.findByRole('heading', { name: /your transfers/i });
+ await screen.findByLabelText(/select all transfers on this page/i);
+}
+
+describe('Transfers snapshot pagination', () => {
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ it('pages through a frozen snapshot without gaps', async () => {
+ const user = userEvent.setup();
+ seedTransfers(12);
+ await gotoTransfers();
+
+ expect(screen.getByRole('button', { name: /next/i })).toBeInTheDocument();
+ expect(screen.getByText(/page 1 of 3/i)).toBeInTheDocument();
+
+ // Newest by createdAt: person11 is June 12.
+ expect(
+ screen.getByRole('group', { name: /transfer to person11@e/i }),
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByRole('group', { name: /transfer to person6@e/i }),
+ ).not.toBeInTheDocument();
+
+ await user.click(screen.getByRole('button', { name: /next/i }));
+ expect(await screen.findByText(/page 2 of 3/i)).toBeInTheDocument();
+ expect(
+ screen.getByRole('group', { name: /transfer to person6@e/i }),
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByRole('group', { name: /transfer to person11@e/i }),
+ ).not.toBeInTheDocument();
+ });
+
+ it('resets to page 1 when filters change', async () => {
+ const user = userEvent.setup();
+ seedTransfers(12);
+ await gotoTransfers();
+
+ await user.click(screen.getByRole('button', { name: /next/i }));
+ expect(await screen.findByText(/page 2 of /i)).toBeInTheDocument();
+
+ await user.selectOptions(
+ screen.getByLabelText(/filter by status/i),
+ 'pending',
+ );
+
+ expect(await screen.findByText(/page 1 of /i)).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /previous/i })).toBeDisabled();
+ });
+});
diff --git a/test/unit/transfer-snapshot.test.js b/test/unit/transfer-snapshot.test.js
new file mode 100644
index 0000000..9478477
--- /dev/null
+++ b/test/unit/transfer-snapshot.test.js
@@ -0,0 +1,245 @@
+import { describe, expect, it } from 'vitest';
+import {
+ SNAPSHOT_TTL_MS,
+ createTransferSnapshot,
+ decodeCursor,
+ encodeCursor,
+ filterScopeKey,
+ isCursorInScope,
+ pageFromSnapshot,
+ resolveTransferPage,
+ stableSortTransfers,
+} from '../../src/utils/transferSnapshot.js';
+
+const NOW = Date.parse('2026-09-24T19:00:00Z');
+
+function tx(id, createdAt, extra = {}) {
+ return {
+ id,
+ recipient: `${id}@example.com`,
+ from: 'USD',
+ to: 'NGN',
+ sendAmount: '10.00',
+ receiveAmount: '1000.00',
+ status: 'completed',
+ createdAt,
+ ...extra,
+ };
+}
+
+describe('transferSnapshot', () => {
+ it('sorts by createdAt desc with id as tie-breaker', () => {
+ const rows = [
+ tx('tx_a', '2026-09-01T10:00:00Z'),
+ tx('tx_b', '2026-09-01T10:00:00Z'),
+ tx('tx_c', '2026-09-02T10:00:00Z'),
+ ];
+ expect(stableSortTransfers(rows).map((r) => r.id)).toEqual([
+ 'tx_c',
+ 'tx_b',
+ 'tx_a',
+ ]);
+ });
+
+ it('keeps pages deterministic when new transfers arrive', () => {
+ const initial = Array.from({ length: 12 }, (_, i) =>
+ tx(`tx_${String(i).padStart(2, '0')}`, `2026-09-01T10:${String(i).padStart(2, '0')}:00Z`),
+ );
+ const snapshot = createTransferSnapshot(initial, {
+ filters: { search: '', status: '', range: '' },
+ now: NOW,
+ pageSize: 5,
+ });
+
+ const page1 = pageFromSnapshot(snapshot, initial, {
+ filters: { search: '', status: '', range: '' },
+ now: NOW,
+ });
+ expect(page1.ok).toBe(true);
+ expect(page1.items.map((t) => t.id)).toEqual([
+ 'tx_11',
+ 'tx_10',
+ 'tx_09',
+ 'tx_08',
+ 'tx_07',
+ ]);
+
+ // Concurrent insert of a newer transfer must not push into page 1 of this snapshot.
+ const withInsert = [
+ tx('tx_new', '2026-09-03T10:00:00Z'),
+ ...initial,
+ ];
+ const page1Again = pageFromSnapshot(snapshot, withInsert, {
+ cursor: page1.cursor,
+ filters: { search: '', status: '', range: '' },
+ now: NOW,
+ });
+ expect(page1Again.items.map((t) => t.id)).toEqual(page1.items.map((t) => t.id));
+ expect(page1Again.items.map((t) => t.id)).not.toContain('tx_new');
+
+ const page2 = pageFromSnapshot(snapshot, withInsert, {
+ cursor: page1.nextCursor,
+ filters: { search: '', status: '', range: '' },
+ now: NOW,
+ });
+ expect(page2.items.map((t) => t.id)).toEqual([
+ 'tx_06',
+ 'tx_05',
+ 'tx_04',
+ 'tx_03',
+ 'tx_02',
+ ]);
+ });
+
+ it('keeps snapshot pages complete after a live row disappears and rejects a forged cursor', () => {
+ const rows = Array.from({ length: 7 }, (_, i) =>
+ tx(`tx_${i}`, `2026-09-01T10:0${i}:00Z`),
+ );
+ const filters = { search: '', status: '', range: '' };
+ const snapshot = createTransferSnapshot(rows, { filters, now: NOW, pageSize: 5 });
+ const first = pageFromSnapshot(snapshot, rows, { filters, now: NOW });
+ const filtered = rows.filter((row) => row.id !== 'tx_1');
+ const second = pageFromSnapshot(snapshot, filtered, {
+ cursor: first.nextCursor,
+ filters,
+ now: NOW,
+ });
+ expect(second.ok).toBe(true);
+ expect(second.items.map((row) => row.id)).toEqual(['tx_1', 'tx_0']);
+
+ const forged = encodeCursor({
+ snapshotId: snapshot.id,
+ scope: snapshot.scope,
+ page: 2,
+ after: snapshot.items[0],
+ });
+ expect(pageFromSnapshot(snapshot, filtered, {
+ cursor: forged,
+ filters,
+ now: NOW,
+ }).code).toBe('cursor_expired');
+ });
+
+ it('rejects cursors outside the filter scope', () => {
+ const rows = [tx('tx_1', '2026-09-01T10:00:00Z')];
+ const snapshot = createTransferSnapshot(rows, {
+ filters: { search: '', status: 'completed', range: '' },
+ now: NOW,
+ });
+ const cursor = encodeCursor({
+ snapshotId: snapshot.id,
+ scope: snapshot.scope,
+ page: 1,
+ after: null,
+ });
+ const decoded = decodeCursor(cursor);
+ expect(
+ isCursorInScope(decoded, snapshot, { search: '', status: 'pending', range: '' }, NOW),
+ ).toBe(false);
+ expect(
+ isCursorInScope(decoded, snapshot, { search: '', status: 'completed', range: '' }, NOW),
+ ).toBe(true);
+ });
+
+ it('recovers from an expired snapshot cursor', () => {
+ const rows = Array.from({ length: 6 }, (_, i) =>
+ tx(`tx_${i}`, `2026-09-01T10:0${i}:00Z`),
+ );
+ const snapshot = createTransferSnapshot(rows, {
+ filters: { search: '', status: '', range: '' },
+ now: NOW,
+ pageSize: 5,
+ });
+ const staleCursor = encodeCursor({
+ snapshotId: snapshot.id,
+ scope: snapshot.scope,
+ page: 2,
+ after: snapshot.items[4],
+ });
+
+ const resolved = resolveTransferPage(rows, {
+ filters: { search: '', status: '', range: '' },
+ cursor: staleCursor,
+ snapshot,
+ pageSize: 5,
+ now: NOW + SNAPSHOT_TTL_MS + 1,
+ });
+
+ expect(resolved.recovered).toBe(true);
+ expect(resolved.page.page).toBe(1);
+ expect(resolved.snapshot.id).not.toBe(snapshot.id);
+ expect(resolved.page.items).toHaveLength(5);
+ });
+
+ it('resets to a new snapshot when filters change', () => {
+ const rows = [
+ tx('tx_1', '2026-09-01T10:00:00Z', { status: 'completed' }),
+ tx('tx_2', '2026-09-01T11:00:00Z', { status: 'pending' }),
+ ];
+ const first = resolveTransferPage(rows, {
+ filters: { search: '', status: '', range: '' },
+ now: NOW,
+ pageSize: 5,
+ });
+ const second = resolveTransferPage(
+ rows.filter((t) => t.status === 'pending'),
+ {
+ filters: { search: '', status: 'pending', range: '' },
+ cursor: first.page.cursor,
+ snapshot: first.snapshot,
+ now: NOW,
+ pageSize: 5,
+ },
+ );
+ expect(second.snapshot.scope).toBe(
+ filterScopeKey({ search: '', status: 'pending', range: '' }),
+ );
+ expect(second.snapshot.id).not.toBe(first.snapshot.id);
+ expect(second.page.items.map((t) => t.id)).toEqual(['tx_2']);
+ });
+
+ it('pages a large fixture without gaps inside the snapshot', () => {
+ const rows = Array.from({ length: 50 }, (_, i) =>
+ tx(`tx_${String(i).padStart(3, '0')}`, `2026-08-01T${String(i % 24).padStart(2, '0')}:${String(i % 60).padStart(2, '0')}:00Z`),
+ );
+ let state = resolveTransferPage(rows, {
+ filters: { search: '', status: '', range: '' },
+ now: NOW,
+ pageSize: 5,
+ });
+ const seen = new Set(state.page.items.map((t) => t.id));
+ while (state.page.nextCursor) {
+ state = resolveTransferPage(rows, {
+ filters: { search: '', status: '', range: '' },
+ cursor: state.page.nextCursor,
+ snapshot: state.snapshot,
+ now: NOW,
+ pageSize: 5,
+ });
+ for (const item of state.page.items) {
+ expect(seen.has(item.id)).toBe(false);
+ seen.add(item.id);
+ }
+ }
+ expect(seen.size).toBe(50);
+ });
+
+ it('rebuilds when an empty loading snapshot later receives rows', () => {
+ const empty = resolveTransferPage([], {
+ filters: { search: '', status: '', range: '' },
+ now: NOW,
+ pageSize: 5,
+ });
+ expect(empty.snapshot.items).toHaveLength(0);
+
+ const rows = [tx('tx_1', '2026-09-01T10:00:00Z')];
+ const filled = resolveTransferPage(rows, {
+ filters: { search: '', status: '', range: '' },
+ snapshot: empty.snapshot,
+ now: NOW,
+ pageSize: 5,
+ });
+ expect(filled.snapshot.items).toHaveLength(1);
+ expect(filled.page.items.map((t) => t.id)).toEqual(['tx_1']);
+ });
+});