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.tsimportserrorMessageKeyfromerror-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], …) plusconfig-provider.tsx's["config"]are hand-written and typo-prone. - Object writes don't invalidate
["search"].useUpdateObject/useDeleteObject/useSetVisibilityinvalidate["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 isenabledonly 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
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/setQueryDatasite in the query modules andconfig-provider.tsx's["config"]routes throughkeys.*— producing the identical arrays, so cache behaviour is unchanged. (keys.objects()prefix-matcheskeys.objectsPage(p)exactly as["objects"]matches["objects", params]today.) ObjectListParamslives here (it is part of the key);objects.tsimports it from here — one direction, no import cycle. (It moves out ofqueries.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 constkeys are readonly tuples; TanStack Query accepts readonly arrays as keys — no type issue.- No import cycle:
query-keys.tsexportsObjectListParams+keysand imports nothing fromqueries/;objects.tsimports both fromquery-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. itsisInvalidated/refetch fires). - Gate:
typecheck/lint/test/build/check:size/check:colorsgreen; no new dependency; no new i18n keys; no codename;check:sizeunchanged (pure reorg + one invalidate call).
Acceptance criteria
api/errors.tsholds the 4 error classes;error-message.tsimports them from./errors; the barrel re-exports them so every existing importer (components + tests) still resolves.queries.tsis split intoapi/queries/{auth,objects,field-defs,vocab,authorities,search}.ts+index.ts;api/queries.tsis deleted; the../api/queriesimport path is unchanged for all ~30 consumers.- A
keysfactory inapi/query-keys.tsis used by everyqueryKey/invalidate/setQueryData site, includingconfig-provider.tsx. useUpdateObject/useDeleteObject/useSetVisibilityinvalidatekeys.search().- All existing tests pass unchanged;
typecheck/lint/build/check:colorsgreen;check:sizeunchanged; 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/keepPreviousDatachoices 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).