diff --git a/docs/superpowers/specs/2026-06-08-split-queries-design.md b/docs/superpowers/specs/2026-06-08-split-queries-design.md new file mode 100644 index 0000000..caafdc4 --- /dev/null +++ b/docs/superpowers/specs/2026-06-08-split-queries-design.md @@ -0,0 +1,146 @@ +# Split queries.ts: Errors + Query-Key Factory + Domain Modules — Design + +**Date:** 2026-06-08 +**Status:** Approved (brainstorming) — ready for implementation planning. +**Issue:** #65 (split the 584-line `queries.ts`, extract the error classes, add a query-key factory, decide the search-invalidation gap). + +## Context + +`web/src/api/queries.ts` is 584 lines spanning 8 domains (error classes, auth, objects, fields-on-object, +field-definitions, vocab, terms, authorities, search). No hook crosses a domain boundary, so a split is +low-risk. Three concrete problems: + +- **Toast path drags in the hook module.** After #63, `query-client.ts` imports `errorMessageKey` from + `error-message.ts`, which imports `{ HttpError, InUseError }` from `./queries` — so the toast wiring + transitively loads all the hooks. Extracting the error classes breaks that chain. +- **No query-key factory.** ~20 key literals (`["objects", params]`, `["object", id]`, + `["terms", vocabularyId]`, …) plus `config-provider.tsx`'s `["config"]` are hand-written and + typo-prone. +- **Object writes don't invalidate `["search"]`.** `useUpdateObject`/`useDeleteObject`/`useSetVisibility` + invalidate `["objects"]`/`["object", id]` but never the search index, so the search panel keeps stale + hits until the user re-searches. **Decision (brainstorming): invalidate** — conventional + eventually-consistent behaviour; the query is `enabled` only with a term, so it's a no-op when not + searching. + +**Constraint:** ~30 files import from `../api/queries`. The public import path must stay stable (a +barrel), so the refactor touches the data layer only — feature files don't change. + +## Components + +### `api/errors.ts` (new) — canonical home for the 4 error classes +Move `HttpError`, `FieldRejection`, `InUseError`, `VisibilityError` here verbatim (no behaviour change). +`error-message.ts` changes its import from `./queries` to `./errors` — this is the decoupling: the toast +path (`query-client → error-message → errors`) no longer loads the hook module. The barrel (below) +**re-exports** the error classes (`export * from "../errors"`), so the existing importers that pull them +from `../api/queries` — `object-edit-form.tsx`, `object-new-page.tsx`, `publish-control.tsx`, +`search-panel.tsx`, and the tests `mutation-error.test.tsx` / `labelled-record-row.test.tsx` / +`error-message.test.ts` — keep working unchanged. + +### `api/query-keys.ts` (new) — one key factory +```ts +export type ObjectListParams = { + limit: number; + offset: number; + sort?: string; + order?: "asc" | "desc"; + visibility?: string; + q?: string; +}; + +export const keys = { + me: () => ["me"] as const, + config: () => ["config"] as const, + objects: () => ["objects"] as const, // family prefix (invalidation) + objectsPage: (p: ObjectListParams) => ["objects", p] as const, + object: (id: string) => ["object", id] as const, + fieldDefinitions: () => ["field-definitions"] as const, + vocabularies: () => ["vocabularies"] as const, + terms: (vocabularyId: string) => ["terms", vocabularyId] as const, + authorities: (kind: string) => ["authorities", kind] as const, + search: () => ["search"] as const, // family prefix (invalidation) + searchResults: (term: string, visibility: string | null) => ["search", term, visibility] as const, +}; +``` +- Every `queryKey:` / `invalidateQueries` / `setQueryData` site in the query modules **and** + `config-provider.tsx`'s `["config"]` routes through `keys.*` — producing the identical arrays, so + cache behaviour is unchanged. (`keys.objects()` prefix-matches `keys.objectsPage(p)` exactly as + `["objects"]` matches `["objects", params]` today.) +- `ObjectListParams` lives here (it *is* part of the key); `objects.ts` imports it from here — one + direction, no import cycle. (It moves out of `queries.ts`'s objects section.) + +### `api/queries/` (new directory) — split by domain + barrel +``` +api/ + client.ts (unchanged) + errors.ts (new) + query-keys.ts (new) + query-client.ts (unchanged — already imports only error-message) + error-message.ts (one-line change: import errors from ./errors) + queries/ + index.ts barrel: `export * from "./auth"; … ; export * from "../errors";` + auth.ts useMe, useLogin, useLogout + objects.ts useObjectsPage, useObject, useCreateObject, useUpdateObject, useSetFields, useDeleteObject, useSetVisibility + field-defs.ts useFieldDefinitions, useCreateFieldDefinition, useUpdateFieldDefinition, useDeleteFieldDefinition + vocab.ts useVocabularies, useCreateVocabulary, useRenameVocabulary, useDeleteVocabulary, useTerms, useAddTerm, useUpdateTerm, useDeleteTerm + authorities.ts useAuthorities, useCreateAuthority, useUpdateAuthority, useDeleteAuthority + search.ts useSearch, SEARCH_PAGE +``` +`api/queries.ts` is deleted; `import { … } from "../api/queries"` resolves to `api/queries/index.ts`. +Each module imports `api` from `../client`, types from `../schema`, error classes from `../errors`, and +`keys` from `../query-keys`. The hook bodies are moved verbatim (only their key literals → `keys.*`). + +### `api/query-client.ts` +No change needed — after #63 it imports only `errorMessageKey`. (Once `error-message.ts` points at +`./errors`, the `query-client → error-message → queries` hook dependency is gone.) + +### Search invalidation (`objects.ts`) +`useUpdateObject`, `useDeleteObject`, `useSetVisibility` add +`void qc.invalidateQueries({ queryKey: keys.search() })` alongside their existing object invalidations. + +## Data flow / behaviour +Identical to today except: after an object update/delete/visibility-change, an active search query +refetches (eventually-consistent with the Meilisearch reindex). All keys, hooks, error classes, and +endpoints are otherwise unchanged. + +## Error handling / edges +- The barrel re-exporting errors means the classes are importable from both `../api/errors` (canonical) + and `../api/queries` (compat) — intentional, to avoid churning ~6 importers + tests. +- `as const` keys are readonly tuples; TanStack Query accepts readonly arrays as keys — no type issue. +- No import cycle: `query-keys.ts` exports `ObjectListParams` + `keys` and imports nothing from + `queries/`; `objects.ts` imports both from `query-keys.ts`. +- The search query stays `enabled: term.length > 0`, so invalidating `["search"]` is a no-op when no + search is active. + +## Testing +- **Behavior guard (must pass unchanged):** `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`, `error-message.test.ts`, + `mutation-error.test.tsx`, `labelled-record-row.test.tsx` — all import from `../api/queries` (the + barrel) and from the re-exported error classes; they must not need edits. The full app suite is the + integration guard. +- **New `query-keys.test.ts`:** assert the factory arrays — e.g. `keys.objects()` → `["objects"]`, + `keys.objectsPage(p)` → `["objects", p]`, `keys.object("x")` → `["object", "x"]`, + `keys.searchResults("q", null)` → `["search", "q", null]`. +- **Search-invalidation assertion:** extend `queries.visibility.test.tsx` (or `.authoring`) to seed an + active `["search", …]` query in the cache, run an object update/delete/set-visibility, and assert the + search query is invalidated (e.g. its `isInvalidated`/refetch fires). +- **Gate:** `typecheck`/`lint`/`test`/`build`/`check:size`/`check:colors` green; no new dependency; no new + i18n keys; no codename; `check:size` unchanged (pure reorg + one invalidate call). + +## Acceptance criteria +1. `api/errors.ts` holds the 4 error classes; `error-message.ts` imports them from `./errors`; the barrel + re-exports them so every existing importer (components + tests) still resolves. +2. `queries.ts` is split into `api/queries/{auth,objects,field-defs,vocab,authorities,search}.ts` + + `index.ts`; `api/queries.ts` is deleted; the `../api/queries` import path is unchanged for all ~30 + consumers. +3. A `keys` factory in `api/query-keys.ts` is used by every `queryKey`/invalidate/setQueryData site, + including `config-provider.tsx`. +4. `useUpdateObject`/`useDeleteObject`/`useSetVisibility` invalidate `keys.search()`. +5. All existing tests pass unchanged; `typecheck`/`lint`/`build`/`check:colors` green; `check:size` + unchanged; no new dependency; no new i18n keys; no codename. + +## Out of scope → follow-ups +- No hook signature/behaviour changes beyond the search invalidation; no endpoint changes. +- `staleTime` / `keepPreviousData` choices stay as-is (intentional per #63's note). +- Repointing the 4 component error-importers from the barrel to `../api/errors` (the re-export keeps them + working; a later cosmetic cleanup could do it).