Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion backend/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -671,13 +671,16 @@
{
"name": "cursor",
"in": "query",
"description": "Preferred pagination. Opaque cursor in the form `<timestamp>_<id>` 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"
}
},
{
"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"
}
Expand All @@ -700,7 +703,9 @@
"meta": {
"count": 0,
"total": 0,
"hasNextPage": false
"hasNextPage": false,
"nextCursor": null,
"prevCursor": null
}
}
}
Expand Down
3 changes: 2 additions & 1 deletion backend/schema-snapshots/get-_api_v1_transactions.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,8 @@
"type": "number"
},
"nextCursor": {
"type": "string"
"type": "string",
"nullable": true
},
"prevCursor": {
"type": "string"
Expand Down
53 changes: 53 additions & 0 deletions backend/src/__tests__/pagination.test.ts
Original file line number Diff line number Diff line change
@@ -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 () => {
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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');

Expand Down Expand Up @@ -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');
Expand Down
47 changes: 47 additions & 0 deletions backend/src/__tests__/transactions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down
111 changes: 111 additions & 0 deletions backend/src/pagination.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@

/**
* @file pagination.ts
* Pagination utilities and types for consistent list endpoint behavior.
Expand Down Expand Up @@ -93,6 +94,8 @@ export const DEFAULT_PAGINATION_CONFIG: PaginationConfig = {
defaultSortOrder: 'desc',
};

export const CURSOR_SEPARATOR = '_';

// ─── Query Parsing ──────────────────────────────────────────────────────────

/**
Expand Down Expand Up @@ -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>_<id>` (timestamp is ISO-8601; id is the row id).
* This matches the `?cursor=<timestamp>_<id>` 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 `<timestamp>_<id>`.
*
* 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<string, unknown> {
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<Record<string, 'desc'>> {
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);
}