diff --git a/backend/openapi.json b/backend/openapi.json index e84421cb3..e35b965b3 100644 --- a/backend/openapi.json +++ b/backend/openapi.json @@ -671,6 +671,7 @@ { "name": "cursor", "in": "query", + "description": "Preferred pagination. Opaque cursor in the form `_` returned as `meta.nextCursor`. Rows are ordered by `timestamp` desc then `id` desc and filtered to `(timestamp, id) < cursor`, so newly inserted transactions do not cause skipped or duplicated rows.", "schema": { "type": "string" } @@ -678,6 +679,8 @@ { "name": "page", "in": "query", + "deprecated": true, + "description": "Deprecated offset-based pagination (`skip: (page-1)*limit`). Kept for backward compatibility only; may skip or duplicate rows when new transactions are inserted between fetches. Use `cursor` instead.", "schema": { "type": "integer" } @@ -700,7 +703,9 @@ "meta": { "count": 0, "total": 0, - "hasNextPage": false + "hasNextPage": false, + "nextCursor": null, + "prevCursor": null } } } diff --git a/backend/schema-snapshots/get-_api_v1_transactions.json b/backend/schema-snapshots/get-_api_v1_transactions.json index 21034640b..632fb7801 100644 --- a/backend/schema-snapshots/get-_api_v1_transactions.json +++ b/backend/schema-snapshots/get-_api_v1_transactions.json @@ -66,7 +66,8 @@ "type": "number" }, "nextCursor": { - "type": "string" + "type": "string", + "nullable": true }, "prevCursor": { "type": "string" diff --git a/backend/src/__tests__/pagination.test.ts b/backend/src/__tests__/pagination.test.ts index ee29ea72c..0bf63f082 100644 --- a/backend/src/__tests__/pagination.test.ts +++ b/backend/src/__tests__/pagination.test.ts @@ -1,6 +1,7 @@ import request from 'supertest'; import app from '../index'; import { ensurePaginationFixtureTransactions } from './setup'; +import { prisma } from '../lib/prisma'; describe('Pagination', () => { beforeAll(async () => { @@ -57,6 +58,45 @@ describe('Pagination', () => { expect(intersection.length).toBe(0); }); + it('should not skip or duplicate rows when a new tx is inserted between cursor pages', async () => { + // Fetch page 1 with limit 2 + const firstPage = await request(app).get('/api/transactions?limit=2'); + expect(firstPage.status).toBe(200); + expect(firstPage.body.data.length).toBe(2); + expect(firstPage.body.pagination.nextCursor).toBeDefined(); + + const firstPageIds = firstPage.body.data.map((tx: any) => tx.id); + + // Insert a new transaction that would appear at the top of the ordering + await prisma.transaction.create({ + data: { + id: 'tx-cursor-inserted', + type: 'deposit', + amount: '1.0000000', + asset: 'XLM', + timestamp: new Date(), + transactionHash: 'hash-cursor-inserted', + walletAddress: 'fixture-pagination', + }, + }); + + // Fetch page 2 via cursor + const secondPage = await request(app).get( + `/api/transactions?limit=2&cursor=${firstPage.body.pagination.nextCursor}` + ); + expect(secondPage.status).toBe(200); + expect(secondPage.body.data.length).toBeGreaterThan(0); + + const secondPageIds = secondPage.body.data.map((tx: any) => tx.id); + + // No duplicates between pages + const intersection = firstPageIds.filter((id: string) => secondPageIds.includes(id)); + expect(intersection.length).toBe(0); + + // The newly inserted tx must not appear on page 2 (it sorts before the cursor) + expect(secondPageIds).not.toContain('tx-cursor-inserted'); + }); + it('should support offset-based pagination', async () => { const page1 = await request(app).get('/api/transactions?limit=5&page=1'); const page2 = await request(app).get('/api/transactions?limit=5&page=2'); @@ -144,6 +184,18 @@ describe('Pagination', () => { expect(response.body.pagination.hasNextPage).toBe(false); }); + it('should include nextCursor in pagination snapshot', async () => { + const response = await request(app).get('/api/transactions?limit=5'); + + expect(response.status).toBe(200); + expect(response.body.pagination).toHaveProperty('nextCursor'); + if (response.body.pagination.hasNextPage) { + expect(response.body.pagination.nextCursor).toBeTruthy(); + } else { + expect(response.body.pagination.nextCursor).toBeNull(); + } + }); + it('should handle invalid page number gracefully', async () => { const response = await request(app).get('/api/transactions?page=-1'); @@ -346,6 +398,7 @@ describe('Pagination', () => { expect(pagination).toHaveProperty('count'); expect(pagination).toHaveProperty('hasNextPage'); expect(pagination).toHaveProperty('hasPrevPage'); + expect(pagination).toHaveProperty('nextCursor'); expect(typeof pagination.count).toBe('number'); expect(typeof pagination.hasNextPage).toBe('boolean'); expect(typeof pagination.hasPrevPage).toBe('boolean'); diff --git a/backend/src/__tests__/transactions.test.ts b/backend/src/__tests__/transactions.test.ts index da2900d2f..a20eba232 100644 --- a/backend/src/__tests__/transactions.test.ts +++ b/backend/src/__tests__/transactions.test.ts @@ -38,6 +38,53 @@ describe('GET /api/v1/transactions', () => { expect(duplicateIds).toEqual([]); }); + it('does not skip or duplicate rows when a new transaction is inserted between cursor page fetches', async () => { + const firstPage = await request(app).get('/api/v1/transactions?limit=2&sortBy=timestamp&sortOrder=desc'); + + expect(firstPage.status).toBe(200); + expect(firstPage.body.data).toHaveLength(2); + expect(firstPage.body.pagination.nextCursor).toBeTruthy(); + + const newTx = await request(app) + .post('/api/v1/transactions') + .send({ + type: 'deposit', + status: 'completed', + amount: '123.45', + asset: 'XLM', + timestamp: new Date().toISOString(), + transactionHash: 'tx-cursor-insert-' + Date.now(), + walletAddress: DEFAULT_WALLET, + }); + + expect([newTx.status, 201, 200]).toContain(newTx.status); + + const secondPage = await request(app).get( + `/api/v1/transactions?limit=2&sortBy=timestamp&sortOrder=desc&cursor=${encodeURIComponent(firstPage.body.pagination.nextCursor)}` + ); + + expect(secondPage.status).toBe(200); + + const firstPageIds = firstPage.body.data.map((transaction: { id: string }) => transaction.id); + const secondPageIds = secondPage.body.data.map((transaction: { id: string }) => transaction.id); + + expect(secondPageIds.length).toBe(2); + expect(secondPageIds.some((id: string) => firstPageIds.includes(id))).toBe(false); + expect(secondPageIds.includes(newTx.body.id)).toBe(false); + }); + + it('still supports deprecated page-based pagination for backward compatibility', async () => { + const pageOne = await request(app).get('/api/v1/transactions?limit=10&page=1'); + const pageTwo = await request(app).get('/api/v1/transactions?limit=10&page=2'); + + expect(pageOne.status).toBe(200); + expect(pageTwo.status).toBe(200); + expect(pageOne.body.data).toHaveLength(10); + expect(pageTwo.body.data).toHaveLength(10); + expect(pageOne.body.pagination.page).toBe(1); + expect(pageTwo.body.pagination.page).toBe(2); + }); + it('filters transactions by type accurately', async () => { const response = await request(app).get('/api/v1/transactions?limit=100&type=deposit'); diff --git a/backend/src/pagination.ts b/backend/src/pagination.ts index da4d1b0ee..2827f0094 100644 --- a/backend/src/pagination.ts +++ b/backend/src/pagination.ts @@ -1,3 +1,4 @@ + /** * @file pagination.ts * Pagination utilities and types for consistent list endpoint behavior. @@ -93,6 +94,8 @@ export const DEFAULT_PAGINATION_CONFIG: PaginationConfig = { defaultSortOrder: 'desc', }; +export const CURSOR_SEPARATOR = '_'; + // ─── Query Parsing ────────────────────────────────────────────────────────── /** @@ -426,3 +429,111 @@ export function encodeCursor(value: string): string { export function decodeCursor(cursor: string): string { return Buffer.from(cursor, 'base64url').toString('utf-8'); } + +// ─── Composite Cursor Helpers ─────────────────────────────────────────────── + +/** + * Build a composite cursor from a timestamp and id. + * + * Format: `_` (timestamp is ISO-8601; id is the row id). + * This matches the `?cursor=_` contract used by list endpoints + * that need stable keyset pagination across inserts. + * + * @param timestamp - Sort timestamp (Date or ISO string) + * @param id - Row identifier + * @returns Composite cursor string + */ +export function buildCursor(timestamp: Date | string, id: string | number): string { + const ts = timestamp instanceof Date ? timestamp.toISOString() : timestamp; + return `${ts}${CURSOR_SEPARATOR}${id}`; +} + +/** + * Parse a composite cursor of the form `_`. + * + * Returns `null` when the cursor is malformed so callers can fall back to + * offset pagination or return an empty page. + * + * @param cursor - Composite cursor string + * @returns Parsed `{ timestamp, id }` or null when invalid + */ +export function parseCursor( + cursor: string +): { timestamp: Date; id: string } | null { + if (typeof cursor !== 'string' || cursor.length === 0) { + return null; + } + + const separatorIndex = cursor.lastIndexOf(CURSOR_SEPARATOR); + if (separatorIndex <= 0 || separatorIndex === cursor.length - 1) { + return null; + } + + const rawTimestamp = cursor.slice(0, separatorIndex); + const rawId = cursor.slice(separatorIndex + 1); + const timestamp = new Date(rawTimestamp); + + if (Number.isNaN(timestamp.getTime()) || rawId.length === 0) { + return null; + } + + return { timestamp, id: rawId }; +} + +/** + * Build the Prisma `where` clause for keyset pagination on `(timestamp, id)`. + * + * Rows strictly "after" the cursor in descending `(timestamp, id)` order are + * those with an older timestamp, or the same timestamp and a smaller id. + * + * @param cursor - Composite cursor string + * @returns Prisma where fragment, or `{}` when the cursor is invalid/absent + */ +export function buildCursorWhere( + cursor: string | undefined +): Record { + if (!cursor) { + return {}; + } + + const parsed = parseCursor(cursor); + if (!parsed) { + return {}; + } + + return { + OR: [ + { timestamp: { lt: parsed.timestamp } }, + { timestamp: parsed.timestamp, id: { lt: parsed.id } }, + ], + }; +} + +/** + * Build the Prisma `orderBy` clause for stable keyset pagination. + * + * Uses `(timestamp desc, id desc)` so that the cursor tuple is unique even + * when multiple rows share the same timestamp. + * + * @returns Prisma orderBy array + */ +export function buildCursorOrderBy(): Array> { + return [{ timestamp: 'desc' }, { id: 'desc' }]; +} + +/** + * Extract the `nextCursor` value from the last item of a page. + * + * @param items - Page items (already ordered by `(timestamp desc, id desc)`) + * @param hasMore - Whether additional rows exist beyond this page + * @returns Composite cursor string or null + */ +export function nextCursorFromItems< + T extends { timestamp: Date | string; id: string | number } +>(items: T[], hasMore: boolean): string | null { + if (!hasMore || items.length === 0) { + return null; + } + const last = items[items.length - 1]; + return buildCursor(last.timestamp, last.id); +}