feat(api): parseSort + parseFilter query helpers (#192) #202

Merged
james merged 3 commits from 192-sort-filter-helpers into main 2026-06-21 12:25:17 +00:00
Owner

Follow-up from #179. Adds two pure-function helpers for ?sort= and ?filter[...]= query params, and smoke-adopts them on /api/notes end-to-end.

Helpers

apps/api/lib/api/parseSort.ts

parseSort<F extends string>(
  input: string | null | undefined,
  allowed: readonly F[],
): { ok: true; sort: Array<{ field: F; direction: "asc" | "desc" }> }
  | { ok: false; response: NextResponse }
  • ?sort=name (asc), ?sort=-name (desc), ?sort=-createdAt,name (multi-field, left-to-right priority).
  • Unknown field → 400 invalid_sort_field via lib/api/errors.ts.
  • Empty / missing → { ok: true, sort: [] }; caller picks the default.

apps/api/lib/api/parseFilter.ts

parseFilter<S extends FilterSchemas<string>>(
  searchParams: URLSearchParams,
  schemas: S,
): { ok: true; filter: FilterValues<S> }
  | { ok: false; response: NextResponse }
  • Bracketed syntax: ?filter[status]=open&filter[kind]=email.
  • Per-field zod schemas validate the raw string value; coercions and transforms work.
  • Unknown field → 400 invalid_filter_field. Failed per-field validation → 400 invalid_filter_value.

Both helpers return a tagged { ok, response? } result rather than throwing — mirrors parseCursorQuery in lib/api/pagination.ts, so the three plug together cleanly in a route.

URL syntax decision: bracketed filters

I went with ?filter[status]=open over flat ?status=open. The existing docs/api-conventions.md §Sort and filter grammar already documented bracketed syntax (line 161 of main before this PR), so this PR is a pickup, not a fork. Bracketed namespacing also keeps filter params unambiguously separate from cursor, limit, and sort — a flat scheme would force the parser to know about every reserved non-filter key.

Smoke adoption: /api/notes

  • ?sort=updatedAt | -updatedAt | createdAt | -createdAt — single-field for cursor stability; the leading entry drives the cursor, trailing entries are ignored.
  • ?filter[q]=<substring> — case-insensitive title contains. Implemented as lower(title) LIKE lower(?) ESCAPE '\' so it's portable across SQLite and Postgres; user input has % _ \ escaped to prevent wildcard injection.

The cursor's ts field carries whichever sort column is active, so zTimestampIdCursor stays unchanged.

Out of scope

Rolling sort/filter out across the remaining endpoints — that's a follow-up. /api/educations, /api/profile/contacts, /api/account/tokens stay flat for now.

Test plan

  • pnpm install --frozen-lockfile
  • pnpm -F @carol/api typecheck
  • pnpm -F @carol/api lint
  • pnpm -F @carol/api test — 554 passed / 107 skipped (Postgres leg runs in CI)
  • pnpm -F @carol/api openapi:check
  • pnpm -F @carol/api openapi:coverage
  • pnpm -F @carol/api-client check (spec changed → regenerated typed client)
  • pnpm -r typecheck

New unit tests:

  • apps/api/tests/lib/api/parseSort.test.ts — 16 cases (happy, multi-field, unknown, empty, casing, trailing commas, repeated keys, lone -, content-type, type spot-check).
  • apps/api/tests/lib/api/parseFilter.test.ts — 16 cases (happy, multi-field, coercion, transforms, repeated keys, unknown, casing, enum failure, length failure, malformed brackets, nested brackets, content-type, type spot-check).

New integration tests on tests/api/notes.test.ts:

  • Unknown sort + filter fields rejected with the right codes.
  • sort=createdAt and sort=-createdAt order correctly.
  • filter[q] is case-insensitive and escapes LIKE wildcards.
  • Empty filter[q] fails as invalid_filter_value.
  • Ticket: #192
  • Conventions: docs/api-conventions.md §Sort and filter grammar — updated in this PR with the three new error codes and the cursor-pagination interaction note.
Follow-up from #179. Adds two pure-function helpers for `?sort=` and `?filter[...]=` query params, and smoke-adopts them on `/api/notes` end-to-end. ## Helpers ### `apps/api/lib/api/parseSort.ts` ```ts parseSort<F extends string>( input: string | null | undefined, allowed: readonly F[], ): { ok: true; sort: Array<{ field: F; direction: "asc" | "desc" }> } | { ok: false; response: NextResponse } ``` - `?sort=name` (asc), `?sort=-name` (desc), `?sort=-createdAt,name` (multi-field, left-to-right priority). - Unknown field → 400 `invalid_sort_field` via `lib/api/errors.ts`. - Empty / missing → `{ ok: true, sort: [] }`; caller picks the default. ### `apps/api/lib/api/parseFilter.ts` ```ts parseFilter<S extends FilterSchemas<string>>( searchParams: URLSearchParams, schemas: S, ): { ok: true; filter: FilterValues<S> } | { ok: false; response: NextResponse } ``` - Bracketed syntax: `?filter[status]=open&filter[kind]=email`. - Per-field zod schemas validate the raw string value; coercions and transforms work. - Unknown field → 400 `invalid_filter_field`. Failed per-field validation → 400 `invalid_filter_value`. Both helpers return a tagged `{ ok, response? }` result rather than throwing — mirrors `parseCursorQuery` in `lib/api/pagination.ts`, so the three plug together cleanly in a route. ## URL syntax decision: bracketed filters I went with `?filter[status]=open` over flat `?status=open`. The existing `docs/api-conventions.md` §Sort and filter grammar already documented bracketed syntax (line 161 of main before this PR), so this PR is a pickup, not a fork. Bracketed namespacing also keeps filter params unambiguously separate from `cursor`, `limit`, and `sort` — a flat scheme would force the parser to know about every reserved non-filter key. ## Smoke adoption: `/api/notes` - `?sort=updatedAt | -updatedAt | createdAt | -createdAt` — single-field for cursor stability; the leading entry drives the cursor, trailing entries are ignored. - `?filter[q]=<substring>` — case-insensitive title contains. Implemented as `lower(title) LIKE lower(?) ESCAPE '\'` so it's portable across SQLite and Postgres; user input has `% _ \` escaped to prevent wildcard injection. The cursor's `ts` field carries whichever sort column is active, so `zTimestampIdCursor` stays unchanged. ## Out of scope Rolling sort/filter out across the remaining endpoints — that's a follow-up. `/api/educations`, `/api/profile/contacts`, `/api/account/tokens` stay flat for now. ## 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` — 554 passed / 107 skipped (Postgres leg runs in CI) - [x] `pnpm -F @carol/api openapi:check` - [x] `pnpm -F @carol/api openapi:coverage` - [x] `pnpm -F @carol/api-client check` (spec changed → regenerated typed client) - [x] `pnpm -r typecheck` New unit tests: - `apps/api/tests/lib/api/parseSort.test.ts` — 16 cases (happy, multi-field, unknown, empty, casing, trailing commas, repeated keys, lone `-`, content-type, type spot-check). - `apps/api/tests/lib/api/parseFilter.test.ts` — 16 cases (happy, multi-field, coercion, transforms, repeated keys, unknown, casing, enum failure, length failure, malformed brackets, nested brackets, content-type, type spot-check). New integration tests on `tests/api/notes.test.ts`: - Unknown sort + filter fields rejected with the right codes. - `sort=createdAt` and `sort=-createdAt` order correctly. - `filter[q]` is case-insensitive and escapes LIKE wildcards. - Empty `filter[q]` fails as `invalid_filter_value`. ## Links - Ticket: #192 - Conventions: [`docs/api-conventions.md`](https://forge.wynning.tech/james/carol/src/branch/main/docs/api-conventions.md) §Sort and filter grammar — updated in this PR with the three new error codes and the cursor-pagination interaction note.
Adds a pure-function helper for the `?sort=` query grammar in
docs/api-conventions.md §Sort and filter grammar. Accepts `name` (asc),
`-name` (desc), and comma-separated multi-field input; rejects unknown
fields with 400 invalid_sort_field via lib/api/errors.ts. The caller
supplies the allowlist of sortable fields, which is the safety
boundary — raw user input never makes it through to the query layer.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a pure-function helper for the `?filter[<field>]=<value>` query
grammar in docs/api-conventions.md §Sort and filter grammar. The caller
supplies a per-field zod schema map; unknown fields fail with 400
invalid_filter_field, and per-field validation failures with 400
invalid_filter_value, both as RFC 7807 Problem Details via
lib/api/errors.ts.

Bracketed syntax (`filter[status]=open`) chosen over flat
(`status=open`) so a list endpoint can mix filter params with cursor,
limit, and sort without ambiguity.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
feat(api): adopt sort/filter on /api/notes (#192)
Some checks failed
Commits / Conventional Commits (pull_request) Successful in 22s
PR / OpenAPI (pull_request) Successful in 2m20s
PR / Static analysis (pull_request) Successful in 2m7s
PR / Typecheck (pull_request) Successful in 2m56s
PR / Client (web export smoke) (pull_request) Successful in 3m30s
PR / Lint (pull_request) Successful in 4m3s
PR / OSV-Scanner (pull_request) Successful in 1m38s
PR / Build (pull_request) Successful in 3m53s
PR / Package age policy (soft) (pull_request) Successful in 1m20s
PR / Test (postgres) (pull_request) Failing after 3m11s
PR / pnpm audit (pull_request) Successful in 2m22s
PR / Test (sqlite) (pull_request) Successful in 2m43s
Secrets / gitleaks (pull_request) Successful in 43s
PR / Coverage (soft) (pull_request) Successful in 3m28s
PR / Trivy (image) (pull_request) Failing after 3m33s
ea37116d3f
Smoke adoption of parseSort + parseFilter on the reference paginated
endpoint. /api/notes now accepts:

  - ?sort=updatedAt | -updatedAt | createdAt | -createdAt
    (single-field for cursor stability; the leading entry drives the
    cursor, trailing entries are ignored)
  - ?filter[q]=<substring> — case-insensitive title contains

The cursor's `ts` field carries whichever sort column is active so the
existing zTimestampIdCursor stays unchanged. The substring filter uses
`lower(title) LIKE lower(?) ESCAPE '\'` for SQLite + Postgres parity;
user input has `% _ \` escaped so it can't inject wildcards.

OpenAPI spec + the generated typed client are regenerated; conventions
doc + error-code table updated for the new `invalid_sort_field`,
`invalid_filter_field`, `invalid_filter_value` codes.

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.6% ≥ 50%
Branches 60.6% ⚠️ ≥ 75%
Functions 67.2% 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.6% ✅ | ≥ 50% | | Branches | 60.6% ⚠️ | ≥ 75% | | Functions | 67.2% | 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 merged commit f3ecfd2eb7 into main 2026-06-21 12:25:17 +00:00
james deleted branch 192-sort-filter-helpers 2026-06-21 12:25:17 +00:00
Sign in to join this conversation.
No description provided.