docs(specs): split queries.ts — errors + key factory + domain modules (#65)
This commit is contained in:
@@ -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).
|
||||
Reference in New Issue
Block a user