Adopt TanStack Query/Form/Table + zod as the data layer (#43) #64

Merged
james merged 1 commit from 43-tanstack into main 2026-06-15 13:26:02 +00:00
Owner

Closes #43.

Summary

  • TanStack Query v5 for server state, TanStack Form v1 for forms, TanStack Table v8 for tabular display. All headless — pairs with whatever component library lands later.
  • zod adopted for new DTOs (lib/dto/note.ts). Same schema validates the request body server-side (safeParse in the route handler) and powers TanStack Form's validators.onChange client-side. Existing hand-rolled parsers in lib/dto/{user,settings}.ts are slated for migration in a follow-up ticket — out of scope here.
  • Reference entity: new notes table (per-user, FK + cascade, index on (user_id, updated_at)). The existing example entity stays untouched — disturbing it would have been destructive (no ADD CONSTRAINT FK in SQLite for additive ALTER) and unnecessary.
  • Reference route at /notes: server component prefetches via the repo, dehydrates into <HydrationBoundary>; client component drives the create form + table + per-row inline edit + delete via Query / Form / Table together. Exercises the optimistic-update pattern (setQueryData) on update and delete, invalidation on create.
  • app/providers.tsx mounts <QueryClientProvider> via useState(createQueryClient) and renders <ReactQueryDevtools /> only when NODE_ENV !== "production". Wrapped around {children} in the root layout.
  • lib/auth/session-server.ts with getServerSession() — uses next/headers cookies(), marked import "server-only". Refactored lib/auth/session.ts to extract a shared validateSessionId(id) core so route handlers (getSession(req)) and server components (getServerSession()) share the validation path.
  • ADR-0010 records the picks per axis (Query vs SWR / RTK Query / RSC-only; Form vs RHF / Formik / Conform / hand-rolled; Table vs AG Grid / MUI X DataGrid / hand-rolled), the bundle-vs-cohesion call, the Next App Router + PWA compatibility analysis, and the zod adoption.
  • CLAUDE.md "Stack defaults" gains bullets for TanStack and zod.

What didn't change

  • Root layout stays a non-async server component. /offline still ○ (Static) in build output — ADR-0008's force-static precache is preserved.
  • No new public-route allowlist entries. /notes, /api/notes, /api/notes/[id] are all gated by the proxy default-deny.
  • The existing example entity, its DTOs, repos, and tests are untouched.

Sharp edges worth flagging

  • Form-shaped schema wrappers (zNoteCreateForm, zNoteUpdateForm) duplicate zNoteCreate/zNoteUpdate minus the "" → null transform. TanStack Form's Standard Schema integration wants schema input/output to match the form defaultValues exactly; transforms drift. The body || null conversion happens in onSubmit. ADR-0010 calls this out as intentional.
  • One ESLint disable in notes-client.tsx on useReactTable for react-hooks/incompatible-library. TanStack Table's return value relies on per-render identity changes; memoizing breaks it. The library's recommended call site triggers the React Compiler heuristic; the disable is targeted and commented.
  • One QueryClient per server requestcreateQueryClient() returns a fresh instance per call. Reusing on the server would leak one user's cache into another's render.
  • No use(queryPromise) from RSCs — that's the TanStack Query anti-pattern with React 19. Noted in the ADR.

Test plan

  • npm run typecheck — clean.
  • npm run lint — clean (after the one targeted disable above).
  • npm test — 119 passed / 32 skipped (151 total). 41 new tests: tests/db/notes.test.ts (16, dual-engine), tests/api/notes.test.ts (15), tests/dto/note.test.ts (10).
  • npm run build — succeeds. /notes shows as ƒ (dynamic, expected); /api/notes and /api/notes/[id] as ƒ; /offline still ○ (Static).
  • npm run dev — manual probes:
    • GET /api/notes unauth → 401; authed empty → []; authed after POST → contains created row.
    • POST /api/notes {title:""} → 400 invalid_body "Title is required." (zod validating).
    • PATCH /api/notes/[id] with another user's id → 404 (don't-leak-existence).
    • Server-rendered /notes HTML contains the dehydrated ["notes"] query state with prefetched data + the table row already rendered + React Query devtools container.
  • CI: build, dual-engine tests, security scans.
  • Manual browser: full Query+Form+Table flow with optimistic updates (verified via curl + HTML inspection; full UX needs a real browser).

🤖 Generated with Claude Code

Closes #43. ## Summary - **TanStack Query v5** for server state, **TanStack Form v1** for forms, **TanStack Table v8** for tabular display. All headless — pairs with whatever component library lands later. - **zod** adopted for new DTOs (`lib/dto/note.ts`). Same schema validates the request body server-side (`safeParse` in the route handler) and powers TanStack Form's `validators.onChange` client-side. Existing hand-rolled parsers in `lib/dto/{user,settings}.ts` are slated for migration in a follow-up ticket — out of scope here. - **Reference entity: new `notes` table** (per-user, FK + cascade, index on `(user_id, updated_at)`). The existing `example` entity stays untouched — disturbing it would have been destructive (no `ADD CONSTRAINT FK` in SQLite for additive ALTER) and unnecessary. - **Reference route at `/notes`**: server component prefetches via the repo, dehydrates into `<HydrationBoundary>`; client component drives the create form + table + per-row inline edit + delete via Query / Form / Table together. Exercises the optimistic-update pattern (`setQueryData`) on update and delete, invalidation on create. - **`app/providers.tsx`** mounts `<QueryClientProvider>` via `useState(createQueryClient)` and renders `<ReactQueryDevtools />` only when `NODE_ENV !== "production"`. Wrapped around `{children}` in the root layout. - **`lib/auth/session-server.ts`** with `getServerSession()` — uses `next/headers` `cookies()`, marked `import "server-only"`. Refactored `lib/auth/session.ts` to extract a shared `validateSessionId(id)` core so route handlers (`getSession(req)`) and server components (`getServerSession()`) share the validation path. - **ADR-0010** records the picks per axis (Query vs SWR / RTK Query / RSC-only; Form vs RHF / Formik / Conform / hand-rolled; Table vs AG Grid / MUI X DataGrid / hand-rolled), the bundle-vs-cohesion call, the Next App Router + PWA compatibility analysis, and the zod adoption. - **CLAUDE.md "Stack defaults"** gains bullets for TanStack and zod. ## What didn't change - Root layout stays a non-async server component. `/offline` still `○ (Static)` in build output — ADR-0008's `force-static` precache is preserved. - No new public-route allowlist entries. `/notes`, `/api/notes`, `/api/notes/[id]` are all gated by the proxy default-deny. - The existing `example` entity, its DTOs, repos, and tests are untouched. ## Sharp edges worth flagging - **Form-shaped schema wrappers** (`zNoteCreateForm`, `zNoteUpdateForm`) duplicate `zNoteCreate`/`zNoteUpdate` minus the `"" → null` transform. TanStack Form's Standard Schema integration wants schema input/output to match the form `defaultValues` exactly; transforms drift. The `body || null` conversion happens in `onSubmit`. ADR-0010 calls this out as intentional. - **One ESLint disable** in `notes-client.tsx` on `useReactTable` for `react-hooks/incompatible-library`. TanStack Table's return value relies on per-render identity changes; memoizing breaks it. The library's recommended call site triggers the React Compiler heuristic; the disable is targeted and commented. - **One QueryClient per server request** — `createQueryClient()` returns a fresh instance per call. Reusing on the server would leak one user's cache into another's render. - **No `use(queryPromise)`** from RSCs — that's the TanStack Query anti-pattern with React 19. Noted in the ADR. ## Test plan - [x] `npm run typecheck` — clean. - [x] `npm run lint` — clean (after the one targeted disable above). - [x] `npm test` — 119 passed / 32 skipped (151 total). 41 new tests: `tests/db/notes.test.ts` (16, dual-engine), `tests/api/notes.test.ts` (15), `tests/dto/note.test.ts` (10). - [x] `npm run build` — succeeds. `/notes` shows as `ƒ` (dynamic, expected); `/api/notes` and `/api/notes/[id]` as `ƒ`; `/offline` still `○ (Static)`. - [x] `npm run dev` — manual probes: - `GET /api/notes` unauth → 401; authed empty → `[]`; authed after POST → contains created row. - `POST /api/notes {title:""}` → 400 `invalid_body` "Title is required." (zod validating). - `PATCH /api/notes/[id]` with another user's id → 404 (don't-leak-existence). - Server-rendered `/notes` HTML contains the dehydrated `["notes"]` query state with prefetched data + the table row already rendered + React Query devtools container. - [ ] CI: build, dual-engine tests, security scans. - [ ] **Manual browser:** full Query+Form+Table flow with optimistic updates (verified via curl + HTML inspection; full UX needs a real browser). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Adopt TanStack Query/Form/Table + zod as the data layer (#43)
All checks were successful
PR / npm audit (pull_request) Successful in 42s
PR / OSV-Scanner (pull_request) Successful in 42s
PR / Lint (pull_request) Successful in 46s
PR / Static analysis (Semgrep) (pull_request) Successful in 46s
PR / Typecheck (pull_request) Successful in 47s
PR / Test (sqlite) (pull_request) Successful in 1m1s
PR / Test (postgres) (pull_request) Successful in 1m4s
PR / Build (pull_request) Successful in 1m17s
PR / Trivy (image) (pull_request) Successful in 1m31s
aed4b9fc14
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
james force-pushed 43-tanstack from aed4b9fc14
All checks were successful
PR / npm audit (pull_request) Successful in 42s
PR / OSV-Scanner (pull_request) Successful in 42s
PR / Lint (pull_request) Successful in 46s
PR / Static analysis (Semgrep) (pull_request) Successful in 46s
PR / Typecheck (pull_request) Successful in 47s
PR / Test (sqlite) (pull_request) Successful in 1m1s
PR / Test (postgres) (pull_request) Successful in 1m4s
PR / Build (pull_request) Successful in 1m17s
PR / Trivy (image) (pull_request) Successful in 1m31s
to aedfddebf1
All checks were successful
PR / OSV-Scanner (pull_request) Successful in 28s
PR / Typecheck (pull_request) Successful in 40s
PR / Static analysis (Semgrep) (pull_request) Successful in 45s
PR / Lint (pull_request) Successful in 46s
PR / npm audit (pull_request) Successful in 49s
PR / Test (sqlite) (pull_request) Successful in 1m2s
PR / Test (postgres) (pull_request) Successful in 1m4s
PR / Build (pull_request) Successful in 1m19s
PR / Trivy (image) (pull_request) Successful in 1m32s
2026-06-15 13:18:45 +00:00
Compare
james force-pushed 43-tanstack from aedfddebf1
All checks were successful
PR / OSV-Scanner (pull_request) Successful in 28s
PR / Typecheck (pull_request) Successful in 40s
PR / Static analysis (Semgrep) (pull_request) Successful in 45s
PR / Lint (pull_request) Successful in 46s
PR / npm audit (pull_request) Successful in 49s
PR / Test (sqlite) (pull_request) Successful in 1m2s
PR / Test (postgres) (pull_request) Successful in 1m4s
PR / Build (pull_request) Successful in 1m19s
PR / Trivy (image) (pull_request) Successful in 1m32s
to 2fccdfe068
All checks were successful
Secrets / gitleaks (pull_request) Successful in 17s
PR / OSV-Scanner (pull_request) Successful in 22s
PR / Static analysis (Semgrep) (pull_request) Successful in 29s
PR / Trivy (image) (pull_request) Successful in 1m2s
PR / npm audit (pull_request) Successful in 1m55s
PR / Typecheck (pull_request) Successful in 1m58s
PR / Test (sqlite) (pull_request) Successful in 2m9s
PR / Test (postgres) (pull_request) Successful in 2m11s
PR / Build (pull_request) Successful in 2m19s
PR / Lint (pull_request) Successful in 2m1s
2026-06-15 13:25:13 +00:00
Compare
james merged commit c3ac57588d into main 2026-06-15 13:26:02 +00:00
Sign in to join this conversation.
No description provided.