|
| 1 | +# Audit Log API — Cursor Pagination |
| 2 | + |
| 3 | +`GET /api/admin/audit` returns audit log entries ordered by `(created_at DESC, id DESC)` with opaque keyset cursor pagination. This document explains the ordering contract, why a composite cursor is necessary, and how to page through results correctly. |
| 4 | + |
| 5 | +## Why `(created_at, id)` and not just `created_at` |
| 6 | + |
| 7 | +A `ORDER BY created_at DESC` alone is **not stable** when multiple rows share the same millisecond timestamp — which is routine under concurrent writes (e.g. a burst of audit events from a batch admin operation or multiple simultaneous requests). Postgres makes no guarantee about the relative order of ties, so a single-column sort can return different orderings on repeated executions, causing a page boundary to **skip or duplicate rows**. |
| 8 | + |
| 9 | +Adding `id DESC` as a tie-breaker makes the sort key `(created_at, id)` globally unique and deterministic. The backing database index `audit_logs_created_at_id_idx` covers exactly this pair: |
| 10 | + |
| 11 | +```sql |
| 12 | +CREATE INDEX audit_logs_created_at_id_idx |
| 13 | + ON audit_logs (created_at DESC, id DESC); |
| 14 | +``` |
| 15 | + |
| 16 | +## How the cursor works |
| 17 | + |
| 18 | +The cursor encodes the `(created_at, id)` of the **last row on the current page**. On the next request it is decoded into a keyset predicate: |
| 19 | + |
| 20 | +```sql |
| 21 | +WHERE (created_at < :cursor_ts) |
| 22 | + OR (created_at = :cursor_ts AND id < :cursor_id) |
| 23 | +ORDER BY created_at DESC, id DESC |
| 24 | +LIMIT :limit + 1 -- one extra row for the "has more" check |
| 25 | +``` |
| 26 | + |
| 27 | +The `OR` branches correspond to two cases: |
| 28 | + |
| 29 | +| Case | Meaning | |
| 30 | +|------|---------| |
| 31 | +| `created_at < cursor_ts` | The next row has an older timestamp — the common case. | |
| 32 | +| `created_at = cursor_ts AND id < cursor_id` | The next row shares the same timestamp; the `id` tie-breaker selects the correct continuation point. | |
| 33 | + |
| 34 | +## Paging example |
| 35 | + |
| 36 | +``` |
| 37 | +GET /api/admin/audit?limit=2 |
| 38 | +→ { data: [{id:"e", ...}, {id:"d", ...}], nextCursor: "eyJ...A" } |
| 39 | +
|
| 40 | +GET /api/admin/audit?limit=2&cursor=eyJ...A |
| 41 | +→ { data: [{id:"c", ...}, {id:"b", ...}], nextCursor: "eyJ...B" } |
| 42 | +
|
| 43 | +GET /api/admin/audit?limit=2&cursor=eyJ...B |
| 44 | +→ { data: [{id:"a", ...}], nextCursor: null } |
| 45 | +``` |
| 46 | + |
| 47 | +All five rows are returned exactly once, even if all five share the same `created_at` timestamp. |
| 48 | + |
| 49 | +## Cursor format |
| 50 | + |
| 51 | +Cursors are **opaque** base64url strings. Their internal encoding is versioned and may change between releases. Never construct a cursor manually — always use the `nextCursor` value returned by the API. A missing or invalid cursor is treated as "start from the beginning" (first page). |
| 52 | + |
| 53 | +## Query parameters |
| 54 | + |
| 55 | +| Parameter | Type | Description | |
| 56 | +|-----------|------|-------------| |
| 57 | +| `cursor` | string | Opaque cursor from the previous page's `nextCursor`. Omit for the first page. | |
| 58 | +| `limit` | integer | Rows per page. Defaults to 20, capped at 100. | |
| 59 | +| `action` | string | Exact-match filter on the `action` field. | |
| 60 | +| `actor` | string | Exact-match filter on `wallet_address`. | |
| 61 | +| `startDate` | ISO 8601 | Include rows with `created_at >= startDate`. | |
| 62 | +| `endDate` | ISO 8601 | Include rows with `created_at <= endDate`. | |
| 63 | + |
| 64 | +## Stream export |
| 65 | + |
| 66 | +`GET /api/admin/audit/export` streams all matching rows as NDJSON, also ordered `(created_at DESC, id DESC)`. This endpoint does not paginate — it streams the full result set up to the configured `maxRecords` limit (default 100 000). Use filters (`startDate`, `endDate`, `action`, `actor`) to narrow the export. |
| 67 | + |
| 68 | +## Migration |
| 69 | + |
| 70 | +Migration `0025_audit_logs_cursor_index.sql` applied the following change: |
| 71 | + |
| 72 | +```sql |
| 73 | +-- Replaced: |
| 74 | +DROP INDEX IF EXISTS audit_logs_created_at_idx; |
| 75 | + |
| 76 | +-- With: |
| 77 | +CREATE INDEX IF NOT EXISTS audit_logs_created_at_id_idx |
| 78 | + ON audit_logs (created_at DESC, id DESC); |
| 79 | +``` |
| 80 | + |
| 81 | +The old single-column index is superseded by the composite index, which covers the same queries and additionally accelerates the cursor tie-breaker predicate. |
| 82 | + |
| 83 | +## See also |
| 84 | + |
| 85 | +- [`src/repositories/auditLogRepo.ts`](../src/repositories/auditLogRepo.ts) — keyset predicate implementation |
| 86 | +- [`src/utils/cursor.ts`](../src/utils/cursor.ts) — cursor encode/decode |
| 87 | +- [`drizzle/migrations/0025_audit_logs_cursor_index.sql`](../drizzle/migrations/0025_audit_logs_cursor_index.sql) — migration |
| 88 | +- [`tests/auditLogCursorStability.test.ts`](../tests/auditLogCursorStability.test.ts) — cursor stability tests |
0 commit comments