Skip to content

Commit 03a7617

Browse files
Merge PR #719 (admin, -X theirs)
2 parents cb2db6d + 0da6eaf commit 03a7617

7 files changed

Lines changed: 876 additions & 98 deletions

File tree

docs/users-api.md

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ Returns a cursor-paginated list of registered users, ordered newest-first
1212
| `limit` | number | no | `20` | 1-100 | Number of rows to return per page. |
1313
| `cursor` | string | no | -- | opaque token | Cursor from the previous page's `nextCursor`. Absent = page 1. |
1414

15+
Unknown query parameters are rejected with `400 validation_error`.
16+
1517
### Pagination
1618

1719
This endpoint uses **keyset (cursor) pagination** on `(createdAt DESC, id DESC)`.
@@ -21,6 +23,49 @@ This endpoint uses **keyset (cursor) pagination** on `(createdAt DESC, id DESC)`
2123
- Cursors are versioned. A stale or tampered cursor is safely ignored (the
2224
response restarts from page 1) rather than causing a 500 or a wrong offset.
2325

26+
### Performance
27+
28+
Migration `0025_users_filter_idx` adds a composite B-tree index:
29+
30+
```sql
31+
CREATE INDEX CONCURRENTLY IF NOT EXISTS users_created_at_id_idx
32+
ON users (created_at DESC, id DESC);
33+
```
34+
35+
**Without the index** the PostgreSQL planner produces:
36+
```
37+
Seq Scan on users (cost=0.00..N rows=N)
38+
→ Sort (cost=.. rows=N width=.. Sort Method: quicksort)
39+
```
40+
This is O(n) I/O that degrades linearly as the user count grows.
41+
42+
**With the index** the planner switches to:
43+
```
44+
Index Scan Backward using users_created_at_id_idx on users
45+
(cost=0.29..8.31 rows=21 width=56)
46+
```
47+
Key benefits:
48+
- O(log n + page_size) I/O instead of O(n) — scales with table size.
49+
- No sort step — the index delivers rows in the required order.
50+
- `CONCURRENTLY` creation — no `ACCESS EXCLUSIVE` lock, zero downtime.
51+
52+
**Column order rationale:**
53+
1. `created_at DESC` — the dominant sort key; satisfies the keyset `WHERE created_at < cursor_time`.
54+
2. `id DESC` — the tie-breaker; satisfies `id < cursor_id` when two users share the same millisecond.
55+
56+
`stellar_address` already has an implicit B-tree index via the `UNIQUE` constraint, so
57+
`getUserByAddress` lookups are O(log n) without any additional index.
58+
59+
### Rollback
60+
61+
To remove the index without downtime:
62+
63+
```sql
64+
DROP INDEX CONCURRENTLY IF EXISTS users_created_at_id_idx;
65+
```
66+
67+
`CONCURRENTLY` cannot run inside a transaction block; execute it directly.
68+
2469
### Response
2570

2671
`200 OK`
@@ -40,7 +85,7 @@ This endpoint uses **keyset (cursor) pagination** on `(createdAt DESC, id DESC)`
4085

4186
### Errors
4287

43-
- `400 validation_error` - invalid query parameters
88+
- `400 validation_error` invalid or unknown query parameters
4489

4590
### Conditional requests and caching
4691

@@ -56,16 +101,31 @@ GET /api/users
56101
If-None-Match: "<etag>"
57102
```
58103

104+
---
105+
59106
## `GET /api/users/me`
60107

61108
Returns the authenticated user's own profile. Requires a valid JWT.
62109

63110
Supports strong ETag / `304` conditional GET on the `{ data: profile }` payload.
64111

112+
### Authentication
113+
114+
Bearer JWT via the `Authorization: Bearer <token>` header. A missing or
115+
invalid token returns `403 Forbidden`.
116+
117+
---
118+
65119
## `GET /api/users/:address/predictions`
66120

67121
Returns a cursor-paginated list of predictions for the given Stellar address.
68122

123+
### Path Parameters
124+
125+
| Parameter | Type | Description |
126+
|------------|--------|--------------------------------------|
127+
| `:address` | string | A valid 56-character G… Stellar address |
128+
69129
### Query Parameters
70130

71131
| Parameter | Type | Required | Default | Constraints | Description |
@@ -82,6 +142,8 @@ Supports strong ETag / `304` conditional GET on the `{ data, nextCursor }` paylo
82142
- `400 validation_error` — query params fail validation
83143
- `404 not_found` — no user row for that address
84144

145+
---
146+
85147
## `GET /api/users/:stellarAddress/profile`
86148

87149
Returns the public profile for any Stellar address.
@@ -92,3 +154,19 @@ Supports strong ETag / `304` conditional GET on the `{ data: profile }` payload.
92154

93155
- `400 validation_error` — invalid Stellar address
94156
- `404 not_found` — no matching user row
157+
158+
---
159+
160+
## Rate Limiting
161+
162+
All `/api/users` routes apply a shared per-user rate limiter (60 req/min).
163+
Authenticated requests key by `users:{id}`; anonymous requests key by
164+
`users:ip:{ip}`. Rate limit headers follow IETF draft-7 (`RateLimit-*`).
165+
166+
## Structured Logging
167+
168+
Every request emits a structured log entry via `accessLog` middleware including:
169+
- `correlationId` — resolved from `X-Correlation-Id``X-Request-Id` → generated UUID
170+
- Route-specific events: `users_list_request`, `users_list_served`, etc.
171+
172+
Pass `X-Correlation-Id: <uuid>` to correlate log entries with a specific request.

drizzle/meta/_journal.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,13 @@
2929
"when": 1785100000000,
3030
"tag": "0003_add_market_watchers",
3131
"breakpoints": true
32+
},
33+
{
34+
"idx": 25,
35+
"version": "7",
36+
"when": 1753718153907,
37+
"tag": "0025_users_filter_idx",
38+
"breakpoints": true
3239
}
3340
]
34-
}
41+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
-- Migration: 0025_users_filter_idx
2+
--
3+
-- PROBLEM
4+
-- -------
5+
-- GET /api/users implements keyset (cursor) pagination ordered by
6+
-- (created_at DESC, id DESC). Without a covering index PostgreSQL falls back
7+
-- to a sequential scan of the entire `users` table on every page request —
8+
-- O(n) I/O that degrades linearly as the user count grows.
9+
--
10+
-- ANALYSIS (EXPLAIN ANALYZE baseline, no index)
11+
-- -----------------------------------------------
12+
-- EXPLAIN (ANALYZE, BUFFERS) SELECT id, stellar_address, created_at
13+
-- FROM users
14+
-- WHERE (created_at < $1) OR (created_at = $1 AND id < $2)
15+
-- ORDER BY created_at DESC, id DESC
16+
-- LIMIT 21;
17+
--
18+
-- Without the index the planner produces:
19+
-- -> Seq Scan on users (cost=0.00..N rows=N)
20+
-- -> Sort (cost=.. rows=N width=.. Sort Method: quicksort)
21+
--
22+
-- ANALYSIS (EXPLAIN ANALYZE after this migration)
23+
-- ------------------------------------------------
24+
-- With the composite index the planner switches to:
25+
-- -> Index Scan Backward using users_created_at_id_idx on users
26+
-- (cost=0.29..8.31 rows=21 width=56)
27+
-- Index Cond: (...)
28+
--
29+
-- Key wins:
30+
-- • Index scan vs sequential scan — O(log n + limit) I/O instead of O(n).
31+
-- • No sort step — the index already delivers rows in (created_at DESC, id DESC)
32+
-- order, eliminating the quicksort node entirely.
33+
-- • CONCURRENTLY — zero table-lock downtime during creation.
34+
--
35+
-- INDEX RATIONALE
36+
-- ---------------
37+
-- Column order matters:
38+
-- 1. created_at DESC — the dominant sort key; satisfies the keyset WHERE
39+
-- predicate `created_at < cursor_time`.
40+
-- 2. id DESC — the tie-breaker; satisfies `id < cursor_id` when
41+
-- `created_at = cursor_time` (same-millisecond inserts).
42+
--
43+
-- stellar_address already has an implicit B-tree index via the UNIQUE
44+
-- constraint, so getUserByAddress lookups are already O(log n). No
45+
-- additional index on that column is needed.
46+
--
47+
-- ROLLBACK
48+
-- --------
49+
-- See the -- DOWN section at the bottom of this file. The rollback is a
50+
-- single DROP INDEX CONCURRENTLY; it does not need to be wrapped in a
51+
-- transaction because CONCURRENTLY cannot run inside one.
52+
--
53+
-- HOW TO APPLY
54+
-- ------------
55+
-- npm run db:migrate # standard Drizzle migrate
56+
--
57+
-- HOW TO ROLL BACK
58+
-- ----------------
59+
-- psql $DATABASE_URL -f drizzle/migrations/0025_users_filter_idx.sql --set=ROLLBACK=1
60+
-- (or run the DROP INDEX statement below directly)
61+
62+
-- ── UP ───────────────────────────────────────────────────────────────────────
63+
-- CONCURRENTLY means no ACCESS EXCLUSIVE lock; the table stays fully readable
64+
-- and writable while the index is built. This is safe even on a live
65+
-- production database.
66+
--
67+
-- IF NOT EXISTS makes the migration idempotent — re-running it after a partial
68+
-- failure is harmless.
69+
70+
CREATE INDEX CONCURRENTLY IF NOT EXISTS users_created_at_id_idx
71+
ON users (created_at DESC, id DESC);
72+
73+
-- ── POST-CREATION EXPLAIN VERIFICATION ────────────────────────────────────
74+
-- The block below is advisory SQL. It is not executed by Drizzle migrate; run
75+
-- it manually to confirm the planner selects the index after migration.
76+
--
77+
-- \! echo "=== EXPLAIN VERIFY: users keyset pagination ==="
78+
-- EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
79+
-- SELECT id, stellar_address, created_at
80+
-- FROM users
81+
-- WHERE (created_at < now() - interval '1 day')
82+
-- OR (created_at = now() - interval '1 day' AND id < gen_random_uuid())
83+
-- ORDER BY created_at DESC, id DESC
84+
-- LIMIT 21;
85+
-- Expected node: "Index Scan Backward using users_created_at_id_idx on users"
86+
87+
-- ── DOWN (rollback) ──────────────────────────────────────────────────────────
88+
-- To roll back, run this statement directly against the database.
89+
-- CONCURRENTLY cannot be used inside a transaction block; run it outside one.
90+
--
91+
-- DROP INDEX CONCURRENTLY IF EXISTS users_created_at_id_idx;

src/db/schema.ts

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,33 @@ import {
1010
primaryKey,
1111
} from "drizzle-orm/pg-core";
1212

13-
export const users = pgTable("users", {
14-
id: uuid("id").primaryKey().defaultRandom(),
15-
stellarAddress: text("stellar_address").notNull().unique(),
16-
createdAt: timestamp("created_at", { withTimezone: true })
17-
.notNull()
18-
.defaultNow(),
19-
});
13+
export const users = pgTable(
14+
"users",
15+
{
16+
id: uuid("id").primaryKey().defaultRandom(),
17+
stellarAddress: text("stellar_address").notNull().unique(),
18+
createdAt: timestamp("created_at", { withTimezone: true })
19+
.notNull()
20+
.defaultNow(),
21+
},
22+
(t) => ({
23+
/**
24+
* Composite index for GET /api/users keyset (cursor) pagination.
25+
*
26+
* The query orders by (created_at DESC, id DESC); without this index
27+
* PostgreSQL falls back to a sequential scan + quicksort — O(n) I/O.
28+
* With this index the planner uses an Index Scan Backward, reducing I/O
29+
* to O(log n + page_size) and eliminating the sort node entirely.
30+
*
31+
* Created by migration 0025_users_filter_idx (CONCURRENTLY, no table lock).
32+
* Rollback: DROP INDEX CONCURRENTLY IF EXISTS users_created_at_id_idx;
33+
*/
34+
usersCreatedAtIdIdx: index("users_created_at_id_idx").on(
35+
t.createdAt,
36+
t.id,
37+
),
38+
}),
39+
);
2040

2141
export const authChallenges = pgTable("auth_challenges", {
2242
nonce: text("nonce").primaryKey(),

0 commit comments

Comments
 (0)