# 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.