Files
biggus-dickus/docs/superpowers/specs/2026-06-08-split-queries-design.md
T

8.7 KiB

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/queriesobject-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

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