feat(api): cursor pagination on remaining flat lists (#191) #203

Merged
james merged 4 commits from 191-pagination-remaining-lists into main 2026-06-21 12:40:39 +00:00
Owner

Closes #191.

Applies the cursor-pagination convention codified in #179 to the three remaining flat-list endpoints. /api/notes already shipped it as the reference; this PR brings the rest in line.

Endpoints changed

Path Ordering Cursor payload
GET /api/educations (start_date DESC, id DESC) { ts: start_date, id }
GET /api/profile/contacts (display_order ASC, id ASC) { order, id }
GET /api/account/tokens (created_at DESC, id DESC) { ts, id }

Each endpoint adds ?cursor=<opaque>&limit=<n> (default 50, max 200, clamped) and returns the standard envelope:

{ "data": [ ... ], "next_cursor": "...", "has_more": true }

Response-shape diff

Before: bare JSON array.
After: { data: [...], next_cursor: string | null, has_more: boolean }.

Notes

  • id replaces the secondary created_at tiebreaker in ProfileContactsRepository.listByUserId. A reorder mutation rewrites display_order but does not touch created_at, so id is the only stable tiebreaker available for the cursor.
  • The bundled GET /api/profile keeps its embedded contacts inline by design (it's a single bundle, not a list endpoint). docs/api-conventions.md calls this out.
  • The PWA screens (apps/api/app/(app)/experience/education-section.tsx, apps/api/app/(app)/account/tokens/tokens-client.tsx) are updated to unwrap data and request ?limit=200. Both surfaces show the entire list today — education history is small per user and active PATs are soft-capped at 25. Folding these into proper cursor pagination via @carol/api-client is left for #184 when the universal client adopts the new shape.
  • @carol/api-client's generated typed surface is regenerated; its drift gate stays green.
  • Out of scope (per #191): parseSort / parseFilter helpers (#192).

Test plan

  • pnpm install --frozen-lockfile
  • pnpm -F @carol/api typecheck
  • pnpm -F @carol/api lint
  • pnpm -F @carol/api test against SQLite + Postgres matrix (633 passing)
  • pnpm -F @carol/api openapi:check
  • pnpm -F @carol/api openapi:coverage (54 routes)
  • pnpm -F @carol/api-client check
  • Test coverage per endpoint: first page (no cursor), forward pagination via cursor, malformed cursor → 400 RFC 7807, ordering stability via deterministic tiebreak (distinct start dates for educations, explicit display_order for contacts, >1ms delay between PAT creates).

Refs:

Closes #191. Applies the cursor-pagination convention codified in #179 to the three remaining flat-list endpoints. /api/notes already shipped it as the reference; this PR brings the rest in line. ## Endpoints changed | Path | Ordering | Cursor payload | | --- | --- | --- | | `GET /api/educations` | `(start_date DESC, id DESC)` | `{ ts: start_date, id }` | | `GET /api/profile/contacts` | `(display_order ASC, id ASC)` | `{ order, id }` | | `GET /api/account/tokens` | `(created_at DESC, id DESC)` | `{ ts, id }` | Each endpoint adds `?cursor=<opaque>&limit=<n>` (default 50, max 200, clamped) and returns the standard envelope: ```json { "data": [ ... ], "next_cursor": "...", "has_more": true } ``` ## Response-shape diff Before: bare JSON array. After: `{ data: [...], next_cursor: string | null, has_more: boolean }`. ## Notes - `id` replaces the secondary `created_at` tiebreaker in `ProfileContactsRepository.listByUserId`. A reorder mutation rewrites `display_order` but does not touch `created_at`, so `id` is the only stable tiebreaker available for the cursor. - The bundled `GET /api/profile` keeps its embedded contacts inline by design (it's a single bundle, not a list endpoint). `docs/api-conventions.md` calls this out. - The PWA screens (`apps/api/app/(app)/experience/education-section.tsx`, `apps/api/app/(app)/account/tokens/tokens-client.tsx`) are updated to unwrap `data` and request `?limit=200`. Both surfaces show the entire list today — education history is small per user and active PATs are soft-capped at 25. Folding these into proper cursor pagination via `@carol/api-client` is left for #184 when the universal client adopts the new shape. - `@carol/api-client`'s generated typed surface is regenerated; its drift gate stays green. - Out of scope (per #191): `parseSort` / `parseFilter` helpers (#192). ## Test plan - [x] `pnpm install --frozen-lockfile` - [x] `pnpm -F @carol/api typecheck` - [x] `pnpm -F @carol/api lint` - [x] `pnpm -F @carol/api test` against SQLite + Postgres matrix (633 passing) - [x] `pnpm -F @carol/api openapi:check` - [x] `pnpm -F @carol/api openapi:coverage` (54 routes) - [x] `pnpm -F @carol/api-client check` - [x] Test coverage per endpoint: first page (no cursor), forward pagination via cursor, malformed cursor → 400 RFC 7807, ordering stability via deterministic tiebreak (distinct start dates for educations, explicit display_order for contacts, >1ms delay between PAT creates). Refs: - Convention: [`docs/api-conventions.md`](docs/api-conventions.md) §Pagination. - Reference impl: `apps/api/app/api/notes/route.ts`.
Switches the education list endpoint from a flat array to the
cursor-pagination envelope ({ data, next_cursor, has_more }) per the
convention from #179. Ordering stays (start_date DESC) with id DESC
as the deterministic tiebreaker; the cursor encodes both. The PWA
education screen is updated to unwrap data and request limit=200 — a
follow-up under #184 will fold this into @carol/api-client.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Switches the profile-contacts list endpoint from a flat array to the
cursor-pagination envelope ({ data, next_cursor, has_more }) per the
convention from #179. Ordering stays (display_order ASC) with id ASC
as the deterministic tiebreaker (replacing created_at, which a
reorder mutation does not touch — id is stable under reorders). The
bundled /api/profile GET still returns its embedded contacts inline
by design — it's a single bundle, not a list endpoint.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Switches the personal-access-tokens list endpoint from a flat array
to the cursor-pagination envelope ({ data, next_cursor, has_more })
per the convention from #179. Ordering stays (created_at DESC) with
id DESC as the deterministic tiebreaker; the cursor encodes both.
The (user_id, revoked_at) index from migration 009 still backs the
scan. The PWA tokens screen unwraps data and requests limit=200 —
well above TOKEN_MAX_ACTIVE_PER_USER's soft cap of 25.

Also regenerates the @carol/api-client typed surface from the
updated spec so its drift gate stays green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
docs(api): refresh pagination section for #191 newly-paginated endpoints
Some checks failed
Commits / Conventional Commits (pull_request) Successful in 15s
PR / OpenAPI (pull_request) Successful in 2m10s
PR / Static analysis (pull_request) Successful in 2m12s
PR / Client (web export smoke) (pull_request) Successful in 2m57s
PR / Lint (pull_request) Successful in 3m29s
PR / OSV-Scanner (pull_request) Failing after 1m33s
PR / Build (pull_request) Successful in 3m21s
PR / Typecheck (pull_request) Successful in 3m28s
PR / pnpm audit (pull_request) Successful in 2m12s
PR / Test (sqlite) (pull_request) Successful in 3m40s
PR / Test (postgres) (pull_request) Successful in 3m45s
PR / Package age policy (soft) (pull_request) Successful in 57s
Secrets / gitleaks (pull_request) Successful in 52s
PR / Coverage (soft) (pull_request) Successful in 2m19s
PR / Trivy (image) (pull_request) Failing after 2m22s
8034950932
#191 moved /api/educations, /api/profile/contacts, and
/api/account/tokens off the flat-array shape and onto the cursor
envelope. Document each endpoint's ordering tuple and the cursor
payload it encodes, and note that the bundled /api/profile GET
intentionally stays inline.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

📊 Test coverage

Patch coverage: no testable lines changed.

Overall (app/, lib/, db/, excluding UI per ADR-0019):

Metric Value Soft target
Lines 69.9% ≥ 50%
Branches 60.9% ⚠️ ≥ 75%
Functions 67.6% informational

Soft thresholds per ADR-0019. Coverage is informational and does not block merge.

<!-- coverage-comment --> ## 📊 Test coverage **Patch coverage:** no testable lines changed. **Overall** (`app/`, `lib/`, `db/`, excluding UI per ADR-0019): | Metric | Value | Soft target | |---|---|---| | Lines | 69.9% ✅ | ≥ 50% | | Branches | 60.9% ⚠️ | ≥ 75% | | Functions | 67.6% | informational | Soft thresholds per [ADR-0019](docs/adr/0019-coverage-soft-targets.md). Coverage is informational and does not block merge.

Trivy (container image)

Threshold: high  ·  Total findings: 121  ·  At/above threshold: 1

critical high medium low
0 1 50 70
severity id package installed / range fix
high CVE-2026-12151 undici 6.25.0 6.27.0, 7.28.0, 8.5.0
<!-- scanner-comment: trivy --> ### Trivy (container image) **Threshold:** `high` &nbsp;·&nbsp; **Total findings:** 121 &nbsp;·&nbsp; **At/above threshold:** 1 | critical | high | medium | low | |---:|---:|---:|---:| | 0 | 1 | 50 | 70 | | severity | id | package | installed / range | fix | |---|---|---|---|---| | high | [CVE-2026-12151](https://avd.aquasec.com/nvd/cve-2026-12151) | undici | 6.25.0 | `6.27.0, 7.28.0, 8.5.0` |
james force-pushed 191-pagination-remaining-lists from 8034950932
Some checks failed
Commits / Conventional Commits (pull_request) Successful in 15s
PR / OpenAPI (pull_request) Successful in 2m10s
PR / Static analysis (pull_request) Successful in 2m12s
PR / Client (web export smoke) (pull_request) Successful in 2m57s
PR / Lint (pull_request) Successful in 3m29s
PR / OSV-Scanner (pull_request) Failing after 1m33s
PR / Build (pull_request) Successful in 3m21s
PR / Typecheck (pull_request) Successful in 3m28s
PR / pnpm audit (pull_request) Successful in 2m12s
PR / Test (sqlite) (pull_request) Successful in 3m40s
PR / Test (postgres) (pull_request) Successful in 3m45s
PR / Package age policy (soft) (pull_request) Successful in 57s
Secrets / gitleaks (pull_request) Successful in 52s
PR / Coverage (soft) (pull_request) Successful in 2m19s
PR / Trivy (image) (pull_request) Failing after 2m22s
to d868427474
Some checks failed
Commits / Conventional Commits (pull_request) Successful in 7s
PR / OSV-Scanner (pull_request) Successful in 2m8s
PR / pnpm audit (pull_request) Successful in 2m48s
PR / Static analysis (pull_request) Successful in 2m54s
PR / OpenAPI (pull_request) Successful in 3m3s
PR / Client (web export smoke) (pull_request) Successful in 3m14s
PR / Typecheck (pull_request) Successful in 3m55s
PR / Lint (pull_request) Successful in 4m0s
PR / Test (postgres) (pull_request) Successful in 4m0s
PR / Test (sqlite) (pull_request) Successful in 4m1s
PR / Build (pull_request) Successful in 4m8s
PR / Package age policy (soft) (pull_request) Successful in 1m12s
Secrets / gitleaks (pull_request) Successful in 1m7s
PR / Trivy (image) (pull_request) Failing after 1m51s
PR / Coverage (soft) (pull_request) Successful in 1m44s
2026-06-21 12:30:47 +00:00
Compare
james merged commit ae02f455b9 into main 2026-06-21 12:40:39 +00:00
james deleted branch 191-pagination-remaining-lists 2026-06-21 12:40:40 +00:00
Sign in to join this conversation.
No description provided.