From a21ab85576e5200ce09a3092db977223c67ae149 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Mon, 8 Jun 2026 20:46:29 +0200 Subject: [PATCH] =?UTF-8?q?docs(plans):=20split=20queries.ts=20=E2=80=94?= =?UTF-8?q?=203-task=20plan=20(#65)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-06-08-split-queries.md | 344 ++++++++++++++++++ 1 file changed, 344 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-08-split-queries.md diff --git a/docs/superpowers/plans/2026-06-08-split-queries.md b/docs/superpowers/plans/2026-06-08-split-queries.md new file mode 100644 index 0000000..7e644b7 --- /dev/null +++ b/docs/superpowers/plans/2026-06-08-split-queries.md @@ -0,0 +1,344 @@ +# Split queries.ts — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Extract the 4 error classes to `api/errors.ts`, add a `keys` query-key factory in `api/query-keys.ts` (and invalidate `["search"]` on object writes), then split `queries.ts` into `api/queries/{auth,objects,field-defs,vocab,authorities,search}.ts` behind a stable `api/queries/index.ts` barrel — behavior-preserving except the search invalidation. + +**Architecture:** Three ordered, individually-green tasks. Task 1 extracts errors (queries.ts re-exports them). Task 2 adds the key factory + search invalidation (still monolithic). Task 3 moves the now-final hook bodies into domain modules behind a barrel that keeps `../api/queries` stable for all ~30 consumers. + +**Tech Stack:** React 19 + TS + pnpm, TanStack Query v5, openapi-fetch, Vitest 4 (jsdom) + RTL + MSW. + +**Conventions:** pnpm; **no `any`/`eslint-disable`/`@ts-ignore`**; no codename; double-quote+semicolon. Run a single test pass per task. Behavior-preserving except the one search-invalidation change. + +**Spec:** `docs/superpowers/specs/2026-06-08-split-queries-design.md` + +**Key facts:** +- `web/src/api/queries.ts` (584 lines) currently defines 4 error classes (`HttpError` :6, `FieldRejection` :13, `InUseError` :20, `VisibilityError` :394) + `type ObjectListParams` (:46) + all hooks. +- Error-class importers (non-test): `api/error-message.ts` (`HttpError, InUseError`), `objects/object-edit-form.tsx` + `objects/object-new-page.tsx` (`FieldRejection`), `objects/publish-control.tsx` (`VisibilityError`), `search/search-panel.tsx` (`HttpError`) — all via `../api/queries`. Tests: `mutation-error.test.tsx`, `labelled-record-row.test.tsx`, `error-message.test.ts` import error classes from `../api/queries`. +- ~30 files import hooks from `../api/queries`. Query-layer test suites: `queries.test.ts`, `queries.authoring.test.tsx`, `queries.fields.test.tsx`, `queries.search.test.tsx`, `queries.visibility.test.tsx`, `queries.vocab.test.tsx`, `mutation-feedback.test.tsx`. +- Key literals: `["me"]`, `["config"]` (in `config/config-provider.tsx:10`), `["objects", params]`, `["objects"]`, `["object", id]`, `["field-definitions"]`, `["terms", vocabularyId]`, `["authorities", kind]`, `["vocabularies"]`, `["search", term, visibility]`. +- `useTerms`/`useAuthorities` key on a `string | null | undefined` arg (enabled-gated), so `keys.terms`/`keys.authorities` must accept that union. + +--- + +# Task 1: Extract error classes → `api/errors.ts` + +**Files:** Create `web/src/api/errors.ts`; Modify `web/src/api/queries.ts`, `web/src/api/error-message.ts`. + +- [ ] **Step 1: Create `web/src/api/errors.ts`** (move the 4 classes verbatim): +```ts +export class HttpError extends Error { + constructor(public readonly status: number) { + super(`HTTP ${status}`); + this.name = "HttpError"; + } +} + +export class FieldRejection extends Error { + constructor(public readonly field: string, public readonly code: string) { + super(`field rejected: ${field}`); + this.name = "FieldRejection"; + } +} + +export class InUseError extends Error { + constructor(public readonly count: number) { + super(`in use: ${count}`); + this.name = "InUseError"; + } +} + +/** Error carrying the HTTP status so callers can branch 422-gate vs 409-illegal. */ +export class VisibilityError extends Error { + constructor(public status: number) { + super(`visibility change failed (${status})`); + this.name = "VisibilityError"; + } +} +``` + +- [ ] **Step 2: Update `web/src/api/queries.ts`.** DELETE the 4 class definitions (lines ~6-25 `HttpError`/`FieldRejection`/`InUseError`, and ~393-399 `VisibilityError`). At the top of the file (after the existing `import` lines), add an import for use + a re-export for compatibility: +```ts +import { HttpError, FieldRejection, InUseError, VisibilityError } from "./errors"; + +export { HttpError, FieldRejection, InUseError, VisibilityError } from "./errors"; +``` +(The `import` binds them for the throw sites in this file; the `export … from` re-exports them so every consumer importing from `../api/queries` keeps working. Everything else in `queries.ts` is unchanged.) + +- [ ] **Step 3: Repoint `web/src/api/error-message.ts`.** Change `import { HttpError, InUseError } from "./queries";` to `import { HttpError, InUseError } from "./errors";`. (This is the decoupling: the toast path no longer transitively loads the hook module.) + +- [ ] **Step 4: Verify (vitest ONCE for the affected suites), typecheck, lint:** +```bash +cd web && pnpm vitest run src/api/error-message.test.ts src/api/mutation-feedback.test.tsx src/api/queries.test.ts src/components/mutation-error.test.tsx src/components/labelled-record-row.test.tsx && pnpm typecheck && pnpm lint +``` +Expected: green. The error classes are now sourced from `errors.ts` but re-exported, so all importers resolve. If typecheck flags an unused import in `queries.ts`, ensure each of the 4 classes is actually thrown somewhere in the file (they are: `HttpError` many sites, `FieldRejection` in `useSetFields`, `InUseError` in the delete mutations, `VisibilityError` in `useSetVisibility`) — keep all four in the import. + +- [ ] **Step 5: Commit** +```bash +cd /Users/olsson/Laboratory/biggus-dickus +git add web/src/api/errors.ts web/src/api/queries.ts web/src/api/error-message.ts +git commit -m "refactor(web): extract API error classes to api/errors.ts (#65)" +``` + +--- + +# Task 2: Query-key factory + search invalidation + +**Files:** Create `web/src/api/query-keys.ts`, `web/src/api/query-keys.test.ts`, `web/src/api/search-invalidation.test.tsx`; Modify `web/src/api/queries.ts`, `web/src/config/config-provider.tsx`. + +- [ ] **Step 1: Create `web/src/api/query-keys.ts`:** +```ts +export type ObjectListParams = { + limit: number; + offset: number; + sort?: string; + order?: "asc" | "desc"; + visibility?: string; + q?: string; +}; + +/** Central query-key factory — the single source of truth for cache keys, so + * query/invalidate/setQueryData sites can't drift. */ +export const keys = { + me: () => ["me"] as const, + config: () => ["config"] as const, + objects: () => ["objects"] as const, + objectsPage: (params: ObjectListParams) => ["objects", params] as const, + object: (id: string) => ["object", id] as const, + fieldDefinitions: () => ["field-definitions"] as const, + vocabularies: () => ["vocabularies"] as const, + terms: (vocabularyId: string | null | undefined) => ["terms", vocabularyId] as const, + authorities: (kind: string | null | undefined) => ["authorities", kind] as const, + search: () => ["search"] as const, + searchResults: (term: string, visibility: string | null) => ["search", term, visibility] as const, +}; +``` + +- [ ] **Step 2: Create `web/src/api/query-keys.test.ts`** (write + run): +```ts +import { expect, test } from "vitest"; + +import { keys } from "./query-keys"; + +test("the key factory produces the expected arrays", () => { + expect(keys.me()).toEqual(["me"]); + expect(keys.config()).toEqual(["config"]); + expect(keys.objects()).toEqual(["objects"]); + const p = { limit: 50, offset: 0 }; + expect(keys.objectsPage(p)).toEqual(["objects", p]); + expect(keys.object("x")).toEqual(["object", "x"]); + expect(keys.fieldDefinitions()).toEqual(["field-definitions"]); + expect(keys.vocabularies()).toEqual(["vocabularies"]); + expect(keys.terms("v1")).toEqual(["terms", "v1"]); + expect(keys.authorities("person")).toEqual(["authorities", "person"]); + expect(keys.search()).toEqual(["search"]); + expect(keys.searchResults("q", null)).toEqual(["search", "q", null]); +}); + +test("objects() is a prefix of objectsPage() so invalidation matches", () => { + const prefix = keys.objects(); + const full = keys.objectsPage({ limit: 50, offset: 0 }); + expect(full.slice(0, prefix.length)).toEqual(prefix); +}); +``` +Run: `cd web && pnpm vitest run src/api/query-keys.test.ts`. + +- [ ] **Step 3: Replace every key literal in `web/src/api/queries.ts` with `keys.*`.** Add `import { keys, type ObjectListParams } from "./query-keys";` and DELETE the local `export type ObjectListParams = {…};` block (now imported). Substitutions (every occurrence): + - `queryKey: ["me"]` → `queryKey: keys.me()`; `qc.invalidateQueries({ queryKey: ["me"] })` → `keys.me()`; `qc.setQueryData(["me"], null)` → `qc.setQueryData(keys.me(), null)` + - `queryKey: ["objects", params]` → `keys.objectsPage(params)` + - `["objects"]` (invalidations) → `keys.objects()` + - `["object", id]` → `keys.object(id)` + - `["field-definitions"]` → `keys.fieldDefinitions()` + - `["terms", vocabularyId]` → `keys.terms(vocabularyId)` + - `["authorities", kind]` → `keys.authorities(kind)` + - `["vocabularies"]` → `keys.vocabularies()` + - `queryKey: ["search", term, visibility]` → `keys.searchResults(term, visibility)` + Re-export the type so consumers importing `ObjectListParams` from `../api/queries` keep working: add `export type { ObjectListParams } from "./query-keys";` near the top. + +- [ ] **Step 4: Add search invalidation (`web/src/api/queries.ts`).** In each of these `onSuccess` handlers add `void qc.invalidateQueries({ queryKey: keys.search() });`: + - `useUpdateObject` onSuccess (after the `objects`/`object` invalidations) + - `useDeleteObject` onSuccess (alongside the `objects` invalidation — convert it to a block: `onSuccess: () => { void qc.invalidateQueries({ queryKey: keys.objects() }); void qc.invalidateQueries({ queryKey: keys.search() }); }`) + - `useSetVisibility` onSuccess (after the `object`/`objects` invalidations) + +- [ ] **Step 5: Update `web/src/config/config-provider.tsx`.** Add `import { keys } from "../api/query-keys";` and change `queryKey: ["config"]` → `queryKey: keys.config()`. + +- [ ] **Step 6: Create `web/src/api/search-invalidation.test.tsx`** (write + run) — proves the new behavior: +```tsx +import { expect, test } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { http, HttpResponse } from "msw"; +import type { ReactNode } from "react"; + +import { server } from "../test/server"; +import { useSetVisibility } from "./queries"; +import { keys } from "./query-keys"; + +test("changing an object's visibility invalidates the active search query", async () => { + server.use( + http.post("/api/admin/objects/:id/visibility", () => new HttpResponse(null, { status: 204 })), + ); + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + qc.setQueryData(keys.searchResults("amphora", null), { pages: [], pageParams: [] }); + + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + const { result } = renderHook(() => useSetVisibility(), { wrapper }); + + await result.current.mutateAsync({ id: "o1", visibility: "public" }); + + await waitFor(() => + expect(qc.getQueryState(keys.searchResults("amphora", null))?.isInvalidated).toBe(true), + ); +}); +``` +Run: `cd web && pnpm vitest run src/api/search-invalidation.test.tsx`. (If `isInvalidated` is flaky, assert `qc.getQueryState(keys.searchResults("amphora", null))` exists and was marked stale via `isInvalidated`; the mutation's `onSuccess` runs the invalidation synchronously after the 204.) + +- [ ] **Step 7: Verify (vitest ONCE for the query suites), typecheck, lint:** +```bash +cd web && pnpm vitest run src/api/query-keys.test.ts src/api/search-invalidation.test.tsx src/api/queries.test.ts src/api/queries.authoring.test.tsx src/api/queries.fields.test.tsx src/api/queries.search.test.tsx src/api/queries.visibility.test.tsx src/api/queries.vocab.test.tsx src/config && pnpm typecheck && pnpm lint +``` +Expected: green. The key arrays are identical to before, so all existing query tests pass unchanged. + +- [ ] **Step 8: Commit** +```bash +cd /Users/olsson/Laboratory/biggus-dickus +git add web/src/api/query-keys.ts web/src/api/query-keys.test.ts web/src/api/search-invalidation.test.tsx web/src/api/queries.ts web/src/config/config-provider.tsx +git commit -m "refactor(web): central query-key factory + invalidate search on object writes (#65)" +``` + +--- + +# Task 3: Split queries.ts into `api/queries/` domain modules + +**Files:** Create `web/src/api/queries/{index,auth,objects,field-defs,vocab,authorities,search}.ts`; Delete `web/src/api/queries.ts`. + +**Approach:** Move each hook (and its local `type X = components[...]` aliases) VERBATIM from the current `queries.ts` into its domain module — the bodies already use `keys.*` and the `errors.ts` classes after Tasks 1-2. Only the relative import paths change (`./client`→`../client`, `./schema`→`../schema`, `./errors`, `./query-keys`). Then add the barrel and delete `queries.ts`. + +- [ ] **Step 1: `web/src/api/queries/auth.ts`** — header + move `useMe`, `useLogin`, `useLogout`: +```ts +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { api } from "../client"; +import type { components } from "../schema"; +import { keys } from "../query-keys"; + +type UserView = components["schemas"]["UserView"]; +type LoginRequest = components["schemas"]["LoginRequest"]; +``` +(These three throw only plain `Error` — no `errors.ts` import needed here.) + +- [ ] **Step 2: `web/src/api/queries/objects.ts`** — header + move `useObjectsPage`, `useObject`, `useCreateObject`, `useUpdateObject`, `useSetFields`, `useDeleteObject`, `useSetVisibility`: +```ts +import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { api } from "../client"; +import type { components } from "../schema"; +import { HttpError, FieldRejection, VisibilityError } from "../errors"; +import { keys, type ObjectListParams } from "../query-keys"; + +type ObjectCreateRequest = components["schemas"]["ObjectCreateRequest"]; +type ObjectUpdateRequest = components["schemas"]["ObjectUpdateRequest"]; +type Visibility = "draft" | "internal" | "public"; +``` +(`ObjectListParams` now comes from `query-keys`. `useObjectsPage`/`useObject` query fns throw plain `Error`; the mutations use the imported error classes.) + +- [ ] **Step 3: `web/src/api/queries/field-defs.ts`** — header + move `useFieldDefinitions`, `useCreateFieldDefinition`, `useUpdateFieldDefinition`, `useDeleteFieldDefinition`: +```ts +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { api } from "../client"; +import type { components } from "../schema"; +import { HttpError, InUseError } from "../errors"; +import { keys } from "../query-keys"; + +type NewFieldDefinitionRequest = components["schemas"]["NewFieldDefinitionRequest"]; +type LabelInput = components["schemas"]["LabelInput"]; +``` + +- [ ] **Step 4: `web/src/api/queries/vocab.ts`** — header + move `useVocabularies`, `useCreateVocabulary`, `useRenameVocabulary`, `useDeleteVocabulary`, `useTerms`, `useAddTerm`, `useUpdateTerm`, `useDeleteTerm`: +```ts +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { api } from "../client"; +import type { components } from "../schema"; +import { HttpError, InUseError } from "../errors"; +import { keys } from "../query-keys"; + +type NewVocabularyRequest = components["schemas"]["NewVocabularyRequest"]; +type LabelInput = components["schemas"]["LabelInput"]; +``` + +- [ ] **Step 5: `web/src/api/queries/authorities.ts`** — header + move `useAuthorities`, `useCreateAuthority`, `useUpdateAuthority`, `useDeleteAuthority`: +```ts +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { api } from "../client"; +import type { components } from "../schema"; +import { HttpError, InUseError } from "../errors"; +import { keys } from "../query-keys"; + +type LabelInput = components["schemas"]["LabelInput"]; +``` + +- [ ] **Step 6: `web/src/api/queries/search.ts`** — header + move the `SEARCH_PAGE` const and `useSearch`: +```ts +import { keepPreviousData, useInfiniteQuery } from "@tanstack/react-query"; + +import { api } from "../client"; +import { HttpError } from "../errors"; +import { keys } from "../query-keys"; + +const SEARCH_PAGE = 20; +``` + +- [ ] **Step 7: `web/src/api/queries/index.ts`** (barrel): +```ts +export * from "./auth"; +export * from "./objects"; +export * from "./field-defs"; +export * from "./vocab"; +export * from "./authorities"; +export * from "./search"; +export * from "../errors"; +export type { ObjectListParams } from "../query-keys"; +``` + +- [ ] **Step 8: Delete the old monolith:** `git rm web/src/api/queries.ts` (every hook has been moved; the barrel + modules now provide the same exports). Confirm no hook/type was dropped: each of the 24 hooks + `ObjectListParams` + the 4 error classes is exported via the barrel. + +- [ ] **Step 9: FULL FRONTEND GATE (run tests EXACTLY ONCE):** +```bash +cd web && pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size && pnpm check:colors +``` +All green. The `../api/queries` import path now resolves to `api/queries/index.ts`, so all ~30 consumers + the query-layer test suites resolve unchanged. If typecheck reports a missing export, a hook landed in the wrong module or an import path is off — fix the module, do NOT edit consumers/tests. Report test totals, largest chunk (gz), and the `check:colors` line. + +- [ ] **Step 10: Codename + status:** +```bash +cd /Users/olsson/Laboratory/biggus-dickus +git grep -in 'biggus\|dickus' -- web/src; echo "codename-exit=$?" +git status --short +``` +Expected: no matches (`codename-exit=1`); `web/src/api/queries.ts` shows as deleted, the 7 new files added. + +- [ ] **Step 11: Commit** +```bash +cd /Users/olsson/Laboratory/biggus-dickus +git add web/src/api/queries/ && git rm -q web/src/api/queries.ts 2>/dev/null; git add -A web/src/api +git commit -m "refactor(web): split queries.ts into api/queries/ domain modules behind a barrel (#65)" +``` + +--- + +## Self-Review (completed) + +**Spec coverage:** AC1 errors extracted + error-message repointed + barrel re-export (T1, T3 S7); AC2 directory split + queries.ts deleted + stable path (T3); AC3 key factory used everywhere incl. config-provider (T2 S3/S5); AC4 search invalidation on the 3 object mutations (T2 S4); AC5 existing tests unchanged + gate (T1 S4, T2 S7, T3 S9). ✓ + +**Placeholder scan:** every new file shown in full or as a precise header + verbatim-move instruction; the move tasks name the exact hook list per module; tests have concrete assertions. No TBD. ✓ + +**Type/consistency:** `keys` (T2) is the same object consumed in T3's modules; `ObjectListParams` defined in `query-keys.ts` (T2), imported by `objects.ts` (T3 S2) and re-exported by the barrel (T3 S7); error classes from `errors.ts` (T1) imported by `objects/field-defs/vocab/authorities/search` modules (T3) and re-exported by the barrel; `keys.terms`/`keys.authorities` accept `string | null | undefined` to match the enabled-gated query usage. ✓ + +## Notes +- No new dependency, no new i18n keys, `components/ui/*` untouched. `check:size` should be unchanged (pure reorg + one invalidate call). Barrel keeps `../api/queries` stable → zero consumer churn. +- The error classes are intentionally importable from both `../api/errors` (canonical) and `../api/queries` (compat re-export). Repointing the 4 component importers to `../api/errors` is a deferred cosmetic follow-up. +- `auth.ts` needs no `errors.ts` import (its throws are plain `Error`); every other module imports the error classes it throws.