Skip to content

Commit f91521e

Browse files
Merge pull request #734 from martoniel/openAPI-examples-for-Appindexer
Open api examples for appindexer
2 parents ec0a426 + 0fd153e commit f91521e

2 files changed

Lines changed: 118 additions & 0 deletions

File tree

docs/audit-log-pagination.md

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
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

openapi.yaml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -918,10 +918,18 @@ components:
918918
- totals
919919
AuditEntry:
920920
type: object
921+
description: >
922+
A single audit log entry. Entries are always returned ordered by
923+
`created_at DESC, id DESC`. The `(created_at, id)` pair forms the
924+
composite keyset cursor key — both fields are present in every entry.
921925
properties:
922926
id:
923927
type: string
924928
format: uuid
929+
description: >
930+
Unique entry identifier (UUID). Acts as the tie-breaker in the
931+
keyset cursor when multiple entries share the same `createdAt`
932+
timestamp (common under concurrent writes).
925933
action:
926934
type: string
927935
actor:
@@ -931,6 +939,10 @@ components:
931939
createdAt:
932940
type: string
933941
format: date-time
942+
description: >
943+
ISO 8601 timestamp of when the event was recorded. Primary sort
944+
key for cursor pagination (DESC). Sub-millisecond precision is
945+
preserved in the cursor encoding.
934946
required:
935947
- id
936948
- action
@@ -3480,6 +3492,13 @@ paths:
34803492
tags:
34813493
- Admin
34823494
summary: List audit log entries (admin only)
3495+
description: >
3496+
Returns a paginated list of audit log entries ordered by
3497+
`created_at DESC, id DESC`. The composite sort key `(created_at, id)`
3498+
is unique and monotone, so pagination is stable even when multiple rows
3499+
share the same millisecond timestamp (a common occurrence under
3500+
concurrent writes). Pass the `nextCursor` value from one response as
3501+
the `cursor` parameter of the next request to advance through pages.
34833502
security:
34843503
- bearerAuth: []
34853504
parameters:
@@ -3488,35 +3507,46 @@ paths:
34883507
required: false
34893508
name: action
34903509
in: query
3510+
description: Filter by audit action string (exact match).
34913511
- schema:
34923512
type: string
34933513
required: false
34943514
name: actor
34953515
in: query
3516+
description: Filter by wallet address of the actor (exact match).
34963517
- schema:
34973518
type: string
34983519
format: date-time
34993520
required: false
35003521
name: startDate
35013522
in: query
3523+
description: 'Include entries at or after this ISO 8601 datetime (e.g. `2026-07-01T00:00:00Z`).'
35023524
- schema:
35033525
type: string
35043526
format: date-time
35053527
required: false
35063528
name: endDate
35073529
in: query
3530+
description: 'Include entries at or before this ISO 8601 datetime (e.g. `2026-07-31T23:59:59Z`).'
35083531
- schema:
35093532
type: string
35103533
required: false
35113534
name: cursor
35123535
in: query
3536+
description: >
3537+
Opaque keyset cursor returned as `nextCursor` in the previous page.
3538+
The cursor encodes the `(created_at, id)` of the last row on the
3539+
preceding page. Rows are ordered `DESC` on both columns, so the
3540+
next page begins strictly after that pair. Do not construct cursors
3541+
manually — always use the value returned by the API.
35133542
- schema:
35143543
type: integer
35153544
minimum: 0
35163545
exclusiveMinimum: true
35173546
required: false
35183547
name: limit
35193548
in: query
3549+
description: Maximum number of entries to return per page (default 20).
35203550
responses:
35213551
'200':
35223552
description: Paginated audit log

0 commit comments

Comments
 (0)