diff --git a/docs/superpowers/plans/2026-06-08-split-queries.md b/docs/superpowers/plans/2026-06-08-split-queries.md
new file mode 100644
index 0000000..7e644b7
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-08-split-queries.md
@@ -0,0 +1,344 @@
+# 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.
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).
diff --git a/web/src/api/error-message.ts b/web/src/api/error-message.ts
index b4538a3..1ad45e2 100644
--- a/web/src/api/error-message.ts
+++ b/web/src/api/error-message.ts
@@ -1,4 +1,4 @@
-import { HttpError, InUseError } from "./queries";
+import { HttpError, InUseError } from "./errors";
/** Maps a caught mutation error to an i18n key (+ interpolation opts). The single
* source of truth shared by the global toast fallback and every inline display. */
diff --git a/web/src/api/errors.ts b/web/src/api/errors.ts
new file mode 100644
index 0000000..32b2a8f
--- /dev/null
+++ b/web/src/api/errors.ts
@@ -0,0 +1,28 @@
+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";
+ }
+}
diff --git a/web/src/api/queries.ts b/web/src/api/queries.ts
deleted file mode 100644
index b004e4f..0000000
--- a/web/src/api/queries.ts
+++ /dev/null
@@ -1,584 +0,0 @@
-import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-
-import { api } from "./client";
-import type { components } from "./schema";
-
-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";
- }
-}
-
-type UserView = components["schemas"]["UserView"];
-type LoginRequest = components["schemas"]["LoginRequest"];
-
-export function useMe() {
- return useQuery({
- queryKey: ["me"],
- queryFn: async (): Promise => {
- const { data, response } = await api.GET("/api/admin/me");
-
- if (response.status === 401) return null;
-
- if (!data) throw new Error("failed to load session");
-
- return data;
- },
- retry: false,
- });
-}
-
-export type ObjectListParams = {
- limit: number;
- offset: number;
- sort?: string;
- order?: "asc" | "desc";
- visibility?: string;
- q?: string;
-};
-
-export function useObjectsPage(params: ObjectListParams) {
- return useQuery({
- queryKey: ["objects", params],
- placeholderData: keepPreviousData,
- queryFn: async () => {
- const { data, error } = await api.GET("/api/admin/objects", {
- params: {
- query: {
- limit: params.limit,
- offset: params.offset,
- sort: params.sort,
- order: params.order,
- visibility: params.visibility,
- q: params.q,
- },
- },
- });
-
- if (error || !data) throw new Error("failed to load objects");
-
- return data;
- },
- });
-}
-
-export function useObject(id: string) {
- return useQuery({
- queryKey: ["object", id],
- queryFn: async () => {
- const { data, response } = await api.GET("/api/admin/objects/{id}", {
- params: { path: { id } },
- });
-
- if (response.status === 404) return null;
-
- if (!data) throw new Error("failed to load object");
-
- return data;
- },
- // A 404 resolves to null rather than erroring, so don't retry it.
- retry: false,
- });
-}
-
-export function useFieldDefinitions() {
- return useQuery({
- queryKey: ["field-definitions"],
- queryFn: async () => {
- const { data, error } = await api.GET("/api/admin/field-definitions");
-
- if (error || !data) throw new Error("failed to load field definitions");
-
- return data;
- },
- staleTime: 5 * 60 * 1000,
- });
-}
-
-export function useLogin() {
- const qc = useQueryClient();
-
- return useMutation({
- mutationFn: async (body: LoginRequest) => {
- const { response } = await api.POST("/api/admin/login", { body });
-
- if (response.status !== 204) {
- throw new Error(response.status === 401 ? "invalid" : "network");
- }
- },
- onSuccess: () => qc.invalidateQueries({ queryKey: ["me"] }),
- meta: { suppressErrorToast: true },
- });
-}
-
-export function useLogout() {
- const qc = useQueryClient();
-
- return useMutation({
- mutationFn: async () => {
- await api.POST("/api/admin/logout");
- },
- onSuccess: () => qc.setQueryData(["me"], null),
- });
-}
-
-type ObjectCreateRequest = components["schemas"]["ObjectCreateRequest"];
-type ObjectUpdateRequest = components["schemas"]["ObjectUpdateRequest"];
-
-export function useTerms(vocabularyId: string | null | undefined) {
- return useQuery({
- queryKey: ["terms", vocabularyId],
- enabled: !!vocabularyId,
- queryFn: async () => {
- const { data, error } = await api.GET("/api/admin/vocabularies/{id}/terms", {
- params: { path: { id: vocabularyId! } },
- });
-
- if (error || !data) throw new Error("failed to load terms");
-
- return data;
- },
- staleTime: 5 * 60 * 1000,
- });
-}
-
-export function useAuthorities(kind: string | null | undefined) {
- return useQuery({
- queryKey: ["authorities", kind],
- enabled: !!kind,
- queryFn: async () => {
- const { data, error } = await api.GET("/api/admin/authorities", {
- params: { query: { kind: kind! } },
- });
-
- if (error || !data) throw new Error("failed to load authorities");
-
- return data;
- },
- staleTime: 5 * 60 * 1000,
- });
-}
-
-export function useCreateObject() {
- const qc = useQueryClient();
-
- return useMutation({
- mutationFn: async (body: ObjectCreateRequest) => {
- const { data, error, response } = await api.POST("/api/admin/objects", { body });
-
- if (error || !data) throw new HttpError(response.status);
-
- return data;
- },
- onSuccess: () => qc.invalidateQueries({ queryKey: ["objects"] }),
- meta: { successMessage: "toast.created", suppressErrorToast: true },
- });
-}
-
-export function useUpdateObject() {
- const qc = useQueryClient();
-
- return useMutation({
- mutationFn: async ({ id, body }: { id: string; body: ObjectUpdateRequest }) => {
- const { response } = await api.PUT("/api/admin/objects/{id}", {
- params: { path: { id } },
- body,
- });
-
- if (response.status !== 204) throw new HttpError(response.status);
- },
- onSuccess: (_d, { id }) => {
- void qc.invalidateQueries({ queryKey: ["objects"] });
- void qc.invalidateQueries({ queryKey: ["object", id] });
- },
- meta: { successMessage: "toast.saved", suppressErrorToast: true },
- });
-}
-
-export function useSetFields() {
- const qc = useQueryClient();
-
- return useMutation({
- mutationFn: async ({ id, fields }: { id: string; fields: Record }) => {
- const { response, error } = await api.PUT("/api/admin/objects/{id}/fields", {
- params: { path: { id } },
- body: fields as Record,
- });
-
- if (response.status === 204) return;
-
- if (response.status === 422 && error && typeof error === "object" && "field" in error) {
- const detail = error as { field: string; code: string };
- throw new FieldRejection(detail.field, detail.code);
- }
-
- throw new HttpError(response.status);
- },
- onSuccess: (_d, { id }) => {
- void qc.invalidateQueries({ queryKey: ["object", id] });
- },
- meta: { suppressErrorToast: true },
- });
-}
-
-export function useDeleteObject() {
- const qc = useQueryClient();
-
- return useMutation({
- mutationFn: async (id: string) => {
- const { response } = await api.DELETE("/api/admin/objects/{id}", {
- params: { path: { id } },
- });
-
- if (response.status !== 204) throw new HttpError(response.status);
- },
- onSuccess: () => qc.invalidateQueries({ queryKey: ["objects"] }),
- meta: { successMessage: "toast.deleted", suppressErrorToast: true },
- });
-}
-
-type NewVocabularyRequest = components["schemas"]["NewVocabularyRequest"];
-type LabelInput = components["schemas"]["LabelInput"];
-
-export function useVocabularies() {
- return useQuery({
- queryKey: ["vocabularies"],
- queryFn: async () => {
- const { data, error } = await api.GET("/api/admin/vocabularies");
-
- if (error || !data) throw new Error("failed to load vocabularies");
-
- return data;
- },
- staleTime: 5 * 60 * 1000,
- });
-}
-
-export function useCreateVocabulary() {
- const qc = useQueryClient();
-
- return useMutation({
- mutationFn: async (body: NewVocabularyRequest) => {
- const { data, error, response } = await api.POST("/api/admin/vocabularies", { body });
-
- if (error || !data) throw new HttpError(response.status);
-
- return data;
- },
- onSuccess: () => qc.invalidateQueries({ queryKey: ["vocabularies"] }),
- meta: { successMessage: "toast.created", suppressErrorToast: true },
- });
-}
-
-export function useAddTerm() {
- const qc = useQueryClient();
-
- return useMutation({
- mutationFn: async ({
- vocabularyId,
- external_uri,
- labels,
- }: {
- vocabularyId: string;
- external_uri: string | null;
- labels: LabelInput[];
- }) => {
- const { response } = await api.POST("/api/admin/vocabularies/{id}/terms", {
- params: { path: { id: vocabularyId } },
- body: { external_uri, labels },
- });
-
- if (response.status !== 201) throw new HttpError(response.status);
- },
- onSuccess: (_result, { vocabularyId }) =>
- qc.invalidateQueries({ queryKey: ["terms", vocabularyId] }),
- meta: { successMessage: "toast.created", suppressErrorToast: true },
- });
-}
-
-export function useCreateAuthority() {
- const qc = useQueryClient();
-
- return useMutation({
- mutationFn: async ({
- kind,
- external_uri,
- labels,
- }: {
- kind: string;
- external_uri: string | null;
- labels: LabelInput[];
- }) => {
- const { response } = await api.POST("/api/admin/authorities", {
- body: { kind, external_uri, labels },
- });
-
- if (response.status !== 201) throw new HttpError(response.status);
- },
- onSuccess: (_result, { kind }) =>
- qc.invalidateQueries({ queryKey: ["authorities", kind] }),
- meta: { successMessage: "toast.created", suppressErrorToast: true },
- });
-}
-
-const SEARCH_PAGE = 20;
-
-export function useSearch(q: string, visibility: string | null) {
- const term = q.trim();
-
- return useInfiniteQuery({
- queryKey: ["search", term, visibility],
- enabled: term.length > 0,
- initialPageParam: 0,
- queryFn: async ({ pageParam }) => {
- const { data, error, response } = await api.GET("/api/admin/search", {
- params: {
- query: {
- q: term,
- ...(visibility ? { visibility } : {}),
- offset: pageParam,
- limit: SEARCH_PAGE,
- },
- },
- });
-
- if (error || !data) throw new HttpError(response.status);
-
- return data;
- },
- placeholderData: keepPreviousData,
- getNextPageParam: (lastPage, allPages) => {
- const loaded = allPages.reduce((n, page) => n + page.hits.length, 0);
-
- return loaded < lastPage.estimated_total ? loaded : undefined;
- },
- });
-}
-
-type NewFieldDefinitionRequest = components["schemas"]["NewFieldDefinitionRequest"];
-
-export function useCreateFieldDefinition() {
- const qc = useQueryClient();
-
- return useMutation({
- mutationFn: async (body: NewFieldDefinitionRequest) => {
- const { data, response } = await api.POST("/api/admin/field-definitions", { body });
-
- if (response.status !== 201 || !data) throw new HttpError(response.status);
-
- return data;
- },
- onSuccess: () => qc.invalidateQueries({ queryKey: ["field-definitions"] }),
- meta: { successMessage: "toast.created", suppressErrorToast: true },
- });
-}
-
-type Visibility = "draft" | "internal" | "public";
-
-/** 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";
- }
-}
-
-export function useSetVisibility() {
- const qc = useQueryClient();
-
- return useMutation({
- mutationFn: async ({ id, visibility }: { id: string; visibility: Visibility }) => {
- const { response } = await api.POST("/api/admin/objects/{id}/visibility", {
- params: { path: { id } },
- body: { visibility },
- });
-
- if (response.status !== 204) throw new VisibilityError(response.status);
- },
- onSuccess: (_result, { id }) => {
- void qc.invalidateQueries({ queryKey: ["object", id] });
- void qc.invalidateQueries({ queryKey: ["objects"] });
- },
- meta: { successMessage: "toast.published", suppressErrorToast: true },
- });
-}
-
-export function useUpdateTerm() {
- const qc = useQueryClient();
-
- return useMutation({
- mutationFn: async ({
- vocabularyId,
- termId,
- external_uri,
- labels,
- }: {
- vocabularyId: string;
- termId: string;
- external_uri: string | null;
- labels: LabelInput[];
- }) => {
- const { response } = await api.PATCH("/api/admin/vocabularies/{id}/terms/{term_id}", {
- params: { path: { id: vocabularyId, term_id: termId } },
- body: { external_uri, labels },
- });
-
- if (response.status !== 204) throw new HttpError(response.status);
- },
- onSuccess: (_d, { vocabularyId }) => qc.invalidateQueries({ queryKey: ["terms", vocabularyId] }),
- meta: { successMessage: "toast.saved", suppressErrorToast: true },
- });
-}
-
-export function useDeleteTerm() {
- const qc = useQueryClient();
-
- return useMutation({
- mutationFn: async ({ vocabularyId, termId }: { vocabularyId: string; termId: string }) => {
- const { error, response } = await api.DELETE("/api/admin/vocabularies/{id}/terms/{term_id}", {
- params: { path: { id: vocabularyId, term_id: termId } },
- });
-
- if (response.status === 409) throw new InUseError((error as { count?: number })?.count ?? 0);
- if (response.status !== 204) throw new HttpError(response.status);
- },
- onSuccess: (_d, { vocabularyId }) => qc.invalidateQueries({ queryKey: ["terms", vocabularyId] }),
- meta: { successMessage: "toast.deleted", suppressErrorToast: true },
- });
-}
-
-export function useRenameVocabulary() {
- const qc = useQueryClient();
-
- return useMutation({
- mutationFn: async ({ id, key }: { id: string; key: string }) => {
- const { response } = await api.PATCH("/api/admin/vocabularies/{id}", {
- params: { path: { id } },
- body: { key },
- });
-
- if (response.status !== 204) throw new HttpError(response.status);
- },
- onSuccess: () => qc.invalidateQueries({ queryKey: ["vocabularies"] }),
- meta: { successMessage: "toast.renamed", suppressErrorToast: true },
- });
-}
-
-export function useDeleteVocabulary() {
- const qc = useQueryClient();
-
- return useMutation({
- mutationFn: async (id: string) => {
- const { error, response } = await api.DELETE("/api/admin/vocabularies/{id}", {
- params: { path: { id } },
- });
-
- if (response.status === 409) throw new InUseError((error as { count?: number })?.count ?? 0);
- if (response.status !== 204) throw new HttpError(response.status);
- },
- onSuccess: () => qc.invalidateQueries({ queryKey: ["vocabularies"] }),
- meta: { successMessage: "toast.deleted", suppressErrorToast: true },
- });
-}
-
-export function useUpdateAuthority() {
- const qc = useQueryClient();
-
- return useMutation({
- mutationFn: async ({
- id,
- external_uri,
- labels,
- }: {
- id: string;
- kind: string;
- external_uri: string | null;
- labels: LabelInput[];
- }) => {
- const { response } = await api.PATCH("/api/admin/authorities/{id}", {
- params: { path: { id } },
- body: { external_uri, labels },
- });
-
- if (response.status !== 204) throw new HttpError(response.status);
- },
- onSuccess: (_d, { kind }) => qc.invalidateQueries({ queryKey: ["authorities", kind] }),
- meta: { successMessage: "toast.saved", suppressErrorToast: true },
- });
-}
-
-export function useDeleteAuthority() {
- const qc = useQueryClient();
-
- return useMutation({
- mutationFn: async ({ id }: { id: string; kind: string }) => {
- const { error, response } = await api.DELETE("/api/admin/authorities/{id}", {
- params: { path: { id } },
- });
-
- if (response.status === 409) throw new InUseError((error as { count?: number })?.count ?? 0);
- if (response.status !== 204) throw new HttpError(response.status);
- },
- onSuccess: (_d, { kind }) => qc.invalidateQueries({ queryKey: ["authorities", kind] }),
- meta: { successMessage: "toast.deleted", suppressErrorToast: true },
- });
-}
-
-export function useUpdateFieldDefinition() {
- const qc = useQueryClient();
-
- return useMutation({
- mutationFn: async ({
- key,
- required,
- group,
- labels,
- }: {
- key: string;
- required: boolean;
- group: string | null;
- labels: LabelInput[];
- }) => {
- const { response } = await api.PATCH("/api/admin/field-definitions/{key}", {
- params: { path: { key } },
- body: { required, group, labels },
- });
-
- if (response.status !== 204) throw new HttpError(response.status);
- },
- onSuccess: () => qc.invalidateQueries({ queryKey: ["field-definitions"] }),
- meta: { successMessage: "toast.saved", suppressErrorToast: true },
- });
-}
-
-export function useDeleteFieldDefinition() {
- const qc = useQueryClient();
-
- return useMutation({
- mutationFn: async (key: string) => {
- const { error, response } = await api.DELETE("/api/admin/field-definitions/{key}", {
- params: { path: { key } },
- });
-
- if (response.status === 409) throw new InUseError((error as { count?: number })?.count ?? 0);
- if (response.status !== 204) throw new HttpError(response.status);
- },
- onSuccess: () => qc.invalidateQueries({ queryKey: ["field-definitions"] }),
- meta: { successMessage: "toast.deleted", suppressErrorToast: true },
- });
-}
diff --git a/web/src/api/queries/auth.ts b/web/src/api/queries/auth.ts
new file mode 100644
index 0000000..45c612a
--- /dev/null
+++ b/web/src/api/queries/auth.ts
@@ -0,0 +1,51 @@
+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"];
+
+export function useMe() {
+ return useQuery({
+ queryKey: keys.me(),
+ queryFn: async (): Promise => {
+ const { data, response } = await api.GET("/api/admin/me");
+
+ if (response.status === 401) return null;
+
+ if (!data) throw new Error("failed to load session");
+
+ return data;
+ },
+ retry: false,
+ });
+}
+
+export function useLogin() {
+ const qc = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (body: LoginRequest) => {
+ const { response } = await api.POST("/api/admin/login", { body });
+
+ if (response.status !== 204) {
+ throw new Error(response.status === 401 ? "invalid" : "network");
+ }
+ },
+ onSuccess: () => qc.invalidateQueries({ queryKey: keys.me() }),
+ meta: { suppressErrorToast: true },
+ });
+}
+
+export function useLogout() {
+ const qc = useQueryClient();
+
+ return useMutation({
+ mutationFn: async () => {
+ await api.POST("/api/admin/logout");
+ },
+ onSuccess: () => qc.setQueryData(keys.me(), null),
+ });
+}
diff --git a/web/src/api/queries/authorities.ts b/web/src/api/queries/authorities.ts
new file mode 100644
index 0000000..cce2bff
--- /dev/null
+++ b/web/src/api/queries/authorities.ts
@@ -0,0 +1,93 @@
+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"];
+
+export function useAuthorities(kind: string | null | undefined) {
+ return useQuery({
+ queryKey: keys.authorities(kind),
+ enabled: !!kind,
+ queryFn: async () => {
+ const { data, error } = await api.GET("/api/admin/authorities", {
+ params: { query: { kind: kind! } },
+ });
+
+ if (error || !data) throw new Error("failed to load authorities");
+
+ return data;
+ },
+ staleTime: 5 * 60 * 1000,
+ });
+}
+
+export function useCreateAuthority() {
+ const qc = useQueryClient();
+
+ return useMutation({
+ mutationFn: async ({
+ kind,
+ external_uri,
+ labels,
+ }: {
+ kind: string;
+ external_uri: string | null;
+ labels: LabelInput[];
+ }) => {
+ const { response } = await api.POST("/api/admin/authorities", {
+ body: { kind, external_uri, labels },
+ });
+
+ if (response.status !== 201) throw new HttpError(response.status);
+ },
+ onSuccess: (_result, { kind }) =>
+ qc.invalidateQueries({ queryKey: keys.authorities(kind) }),
+ meta: { successMessage: "toast.created", suppressErrorToast: true },
+ });
+}
+
+export function useUpdateAuthority() {
+ const qc = useQueryClient();
+
+ return useMutation({
+ mutationFn: async ({
+ id,
+ external_uri,
+ labels,
+ }: {
+ id: string;
+ kind: string;
+ external_uri: string | null;
+ labels: LabelInput[];
+ }) => {
+ const { response } = await api.PATCH("/api/admin/authorities/{id}", {
+ params: { path: { id } },
+ body: { external_uri, labels },
+ });
+
+ if (response.status !== 204) throw new HttpError(response.status);
+ },
+ onSuccess: (_d, { kind }) => qc.invalidateQueries({ queryKey: keys.authorities(kind) }),
+ meta: { successMessage: "toast.saved", suppressErrorToast: true },
+ });
+}
+
+export function useDeleteAuthority() {
+ const qc = useQueryClient();
+
+ return useMutation({
+ mutationFn: async ({ id }: { id: string; kind: string }) => {
+ const { error, response } = await api.DELETE("/api/admin/authorities/{id}", {
+ params: { path: { id } },
+ });
+
+ if (response.status === 409) throw new InUseError((error as { count?: number })?.count ?? 0);
+ if (response.status !== 204) throw new HttpError(response.status);
+ },
+ onSuccess: (_d, { kind }) => qc.invalidateQueries({ queryKey: keys.authorities(kind) }),
+ meta: { successMessage: "toast.deleted", suppressErrorToast: true },
+ });
+}
diff --git a/web/src/api/queries/field-defs.ts b/web/src/api/queries/field-defs.ts
new file mode 100644
index 0000000..8bd7b5f
--- /dev/null
+++ b/web/src/api/queries/field-defs.ts
@@ -0,0 +1,83 @@
+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"];
+
+export function useFieldDefinitions() {
+ return useQuery({
+ queryKey: keys.fieldDefinitions(),
+ queryFn: async () => {
+ const { data, error } = await api.GET("/api/admin/field-definitions");
+
+ if (error || !data) throw new Error("failed to load field definitions");
+
+ return data;
+ },
+ staleTime: 5 * 60 * 1000,
+ });
+}
+
+export function useCreateFieldDefinition() {
+ const qc = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (body: NewFieldDefinitionRequest) => {
+ const { data, response } = await api.POST("/api/admin/field-definitions", { body });
+
+ if (response.status !== 201 || !data) throw new HttpError(response.status);
+
+ return data;
+ },
+ onSuccess: () => qc.invalidateQueries({ queryKey: keys.fieldDefinitions() }),
+ meta: { successMessage: "toast.created", suppressErrorToast: true },
+ });
+}
+
+export function useUpdateFieldDefinition() {
+ const qc = useQueryClient();
+
+ return useMutation({
+ mutationFn: async ({
+ key,
+ required,
+ group,
+ labels,
+ }: {
+ key: string;
+ required: boolean;
+ group: string | null;
+ labels: LabelInput[];
+ }) => {
+ const { response } = await api.PATCH("/api/admin/field-definitions/{key}", {
+ params: { path: { key } },
+ body: { required, group, labels },
+ });
+
+ if (response.status !== 204) throw new HttpError(response.status);
+ },
+ onSuccess: () => qc.invalidateQueries({ queryKey: keys.fieldDefinitions() }),
+ meta: { successMessage: "toast.saved", suppressErrorToast: true },
+ });
+}
+
+export function useDeleteFieldDefinition() {
+ const qc = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (key: string) => {
+ const { error, response } = await api.DELETE("/api/admin/field-definitions/{key}", {
+ params: { path: { key } },
+ });
+
+ if (response.status === 409) throw new InUseError((error as { count?: number })?.count ?? 0);
+ if (response.status !== 204) throw new HttpError(response.status);
+ },
+ onSuccess: () => qc.invalidateQueries({ queryKey: keys.fieldDefinitions() }),
+ meta: { successMessage: "toast.deleted", suppressErrorToast: true },
+ });
+}
diff --git a/web/src/api/queries/index.ts b/web/src/api/queries/index.ts
new file mode 100644
index 0000000..4f71f3d
--- /dev/null
+++ b/web/src/api/queries/index.ts
@@ -0,0 +1,8 @@
+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";
diff --git a/web/src/api/queries/objects.ts b/web/src/api/queries/objects.ts
new file mode 100644
index 0000000..024e11d
--- /dev/null
+++ b/web/src/api/queries/objects.ts
@@ -0,0 +1,157 @@
+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";
+
+export function useObjectsPage(params: ObjectListParams) {
+ return useQuery({
+ queryKey: keys.objectsPage(params),
+ placeholderData: keepPreviousData,
+ queryFn: async () => {
+ const { data, error } = await api.GET("/api/admin/objects", {
+ params: {
+ query: {
+ limit: params.limit,
+ offset: params.offset,
+ sort: params.sort,
+ order: params.order,
+ visibility: params.visibility,
+ q: params.q,
+ },
+ },
+ });
+
+ if (error || !data) throw new Error("failed to load objects");
+
+ return data;
+ },
+ });
+}
+
+export function useObject(id: string) {
+ return useQuery({
+ queryKey: keys.object(id),
+ queryFn: async () => {
+ const { data, response } = await api.GET("/api/admin/objects/{id}", {
+ params: { path: { id } },
+ });
+
+ if (response.status === 404) return null;
+
+ if (!data) throw new Error("failed to load object");
+
+ return data;
+ },
+ // A 404 resolves to null rather than erroring, so don't retry it.
+ retry: false,
+ });
+}
+
+export function useCreateObject() {
+ const qc = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (body: ObjectCreateRequest) => {
+ const { data, error, response } = await api.POST("/api/admin/objects", { body });
+
+ if (error || !data) throw new HttpError(response.status);
+
+ return data;
+ },
+ onSuccess: () => qc.invalidateQueries({ queryKey: keys.objects() }),
+ meta: { successMessage: "toast.created", suppressErrorToast: true },
+ });
+}
+
+export function useUpdateObject() {
+ const qc = useQueryClient();
+
+ return useMutation({
+ mutationFn: async ({ id, body }: { id: string; body: ObjectUpdateRequest }) => {
+ const { response } = await api.PUT("/api/admin/objects/{id}", {
+ params: { path: { id } },
+ body,
+ });
+
+ if (response.status !== 204) throw new HttpError(response.status);
+ },
+ onSuccess: (_d, { id }) => {
+ void qc.invalidateQueries({ queryKey: keys.objects() });
+ void qc.invalidateQueries({ queryKey: keys.object(id) });
+ void qc.invalidateQueries({ queryKey: keys.search() });
+ },
+ meta: { successMessage: "toast.saved", suppressErrorToast: true },
+ });
+}
+
+export function useSetFields() {
+ const qc = useQueryClient();
+
+ return useMutation({
+ mutationFn: async ({ id, fields }: { id: string; fields: Record }) => {
+ const { response, error } = await api.PUT("/api/admin/objects/{id}/fields", {
+ params: { path: { id } },
+ body: fields as Record,
+ });
+
+ if (response.status === 204) return;
+
+ if (response.status === 422 && error && typeof error === "object" && "field" in error) {
+ const detail = error as { field: string; code: string };
+ throw new FieldRejection(detail.field, detail.code);
+ }
+
+ throw new HttpError(response.status);
+ },
+ onSuccess: (_d, { id }) => {
+ void qc.invalidateQueries({ queryKey: keys.object(id) });
+ },
+ meta: { suppressErrorToast: true },
+ });
+}
+
+export function useDeleteObject() {
+ const qc = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (id: string) => {
+ const { response } = await api.DELETE("/api/admin/objects/{id}", {
+ params: { path: { id } },
+ });
+
+ if (response.status !== 204) throw new HttpError(response.status);
+ },
+ onSuccess: () => {
+ void qc.invalidateQueries({ queryKey: keys.objects() });
+ void qc.invalidateQueries({ queryKey: keys.search() });
+ },
+ meta: { successMessage: "toast.deleted", suppressErrorToast: true },
+ });
+}
+
+export function useSetVisibility() {
+ const qc = useQueryClient();
+
+ return useMutation({
+ mutationFn: async ({ id, visibility }: { id: string; visibility: Visibility }) => {
+ const { response } = await api.POST("/api/admin/objects/{id}/visibility", {
+ params: { path: { id } },
+ body: { visibility },
+ });
+
+ if (response.status !== 204) throw new VisibilityError(response.status);
+ },
+ onSuccess: (_result, { id }) => {
+ void qc.invalidateQueries({ queryKey: keys.object(id) });
+ void qc.invalidateQueries({ queryKey: keys.objects() });
+ void qc.invalidateQueries({ queryKey: keys.search() });
+ },
+ meta: { successMessage: "toast.published", suppressErrorToast: true },
+ });
+}
diff --git a/web/src/api/queries/search.ts b/web/src/api/queries/search.ts
new file mode 100644
index 0000000..76e9ecc
--- /dev/null
+++ b/web/src/api/queries/search.ts
@@ -0,0 +1,39 @@
+import { keepPreviousData, useInfiniteQuery } from "@tanstack/react-query";
+
+import { api } from "../client";
+import { HttpError } from "../errors";
+import { keys } from "../query-keys";
+
+const SEARCH_PAGE = 20;
+
+export function useSearch(q: string, visibility: string | null) {
+ const term = q.trim();
+
+ return useInfiniteQuery({
+ queryKey: keys.searchResults(term, visibility),
+ enabled: term.length > 0,
+ initialPageParam: 0,
+ queryFn: async ({ pageParam }) => {
+ const { data, error, response } = await api.GET("/api/admin/search", {
+ params: {
+ query: {
+ q: term,
+ ...(visibility ? { visibility } : {}),
+ offset: pageParam,
+ limit: SEARCH_PAGE,
+ },
+ },
+ });
+
+ if (error || !data) throw new HttpError(response.status);
+
+ return data;
+ },
+ placeholderData: keepPreviousData,
+ getNextPageParam: (lastPage, allPages) => {
+ const loaded = allPages.reduce((n, page) => n + page.hits.length, 0);
+
+ return loaded < lastPage.estimated_total ? loaded : undefined;
+ },
+ });
+}
diff --git a/web/src/api/queries/vocab.ts b/web/src/api/queries/vocab.ts
new file mode 100644
index 0000000..72db7f7
--- /dev/null
+++ b/web/src/api/queries/vocab.ts
@@ -0,0 +1,160 @@
+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"];
+
+export function useVocabularies() {
+ return useQuery({
+ queryKey: keys.vocabularies(),
+ queryFn: async () => {
+ const { data, error } = await api.GET("/api/admin/vocabularies");
+
+ if (error || !data) throw new Error("failed to load vocabularies");
+
+ return data;
+ },
+ staleTime: 5 * 60 * 1000,
+ });
+}
+
+export function useCreateVocabulary() {
+ const qc = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (body: NewVocabularyRequest) => {
+ const { data, error, response } = await api.POST("/api/admin/vocabularies", { body });
+
+ if (error || !data) throw new HttpError(response.status);
+
+ return data;
+ },
+ onSuccess: () => qc.invalidateQueries({ queryKey: keys.vocabularies() }),
+ meta: { successMessage: "toast.created", suppressErrorToast: true },
+ });
+}
+
+export function useRenameVocabulary() {
+ const qc = useQueryClient();
+
+ return useMutation({
+ mutationFn: async ({ id, key }: { id: string; key: string }) => {
+ const { response } = await api.PATCH("/api/admin/vocabularies/{id}", {
+ params: { path: { id } },
+ body: { key },
+ });
+
+ if (response.status !== 204) throw new HttpError(response.status);
+ },
+ onSuccess: () => qc.invalidateQueries({ queryKey: keys.vocabularies() }),
+ meta: { successMessage: "toast.renamed", suppressErrorToast: true },
+ });
+}
+
+export function useDeleteVocabulary() {
+ const qc = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (id: string) => {
+ const { error, response } = await api.DELETE("/api/admin/vocabularies/{id}", {
+ params: { path: { id } },
+ });
+
+ if (response.status === 409) throw new InUseError((error as { count?: number })?.count ?? 0);
+ if (response.status !== 204) throw new HttpError(response.status);
+ },
+ onSuccess: () => qc.invalidateQueries({ queryKey: keys.vocabularies() }),
+ meta: { successMessage: "toast.deleted", suppressErrorToast: true },
+ });
+}
+
+export function useTerms(vocabularyId: string | null | undefined) {
+ return useQuery({
+ queryKey: keys.terms(vocabularyId),
+ enabled: !!vocabularyId,
+ queryFn: async () => {
+ const { data, error } = await api.GET("/api/admin/vocabularies/{id}/terms", {
+ params: { path: { id: vocabularyId! } },
+ });
+
+ if (error || !data) throw new Error("failed to load terms");
+
+ return data;
+ },
+ staleTime: 5 * 60 * 1000,
+ });
+}
+
+export function useAddTerm() {
+ const qc = useQueryClient();
+
+ return useMutation({
+ mutationFn: async ({
+ vocabularyId,
+ external_uri,
+ labels,
+ }: {
+ vocabularyId: string;
+ external_uri: string | null;
+ labels: LabelInput[];
+ }) => {
+ const { response } = await api.POST("/api/admin/vocabularies/{id}/terms", {
+ params: { path: { id: vocabularyId } },
+ body: { external_uri, labels },
+ });
+
+ if (response.status !== 201) throw new HttpError(response.status);
+ },
+ onSuccess: (_result, { vocabularyId }) =>
+ qc.invalidateQueries({ queryKey: keys.terms(vocabularyId) }),
+ meta: { successMessage: "toast.created", suppressErrorToast: true },
+ });
+}
+
+export function useUpdateTerm() {
+ const qc = useQueryClient();
+
+ return useMutation({
+ mutationFn: async ({
+ vocabularyId,
+ termId,
+ external_uri,
+ labels,
+ }: {
+ vocabularyId: string;
+ termId: string;
+ external_uri: string | null;
+ labels: LabelInput[];
+ }) => {
+ const { response } = await api.PATCH("/api/admin/vocabularies/{id}/terms/{term_id}", {
+ params: { path: { id: vocabularyId, term_id: termId } },
+ body: { external_uri, labels },
+ });
+
+ if (response.status !== 204) throw new HttpError(response.status);
+ },
+ onSuccess: (_d, { vocabularyId }) => qc.invalidateQueries({ queryKey: keys.terms(vocabularyId) }),
+ meta: { successMessage: "toast.saved", suppressErrorToast: true },
+ });
+}
+
+export function useDeleteTerm() {
+ const qc = useQueryClient();
+
+ return useMutation({
+ mutationFn: async ({ vocabularyId, termId }: { vocabularyId: string; termId: string }) => {
+ const { error, response } = await api.DELETE("/api/admin/vocabularies/{id}/terms/{term_id}", {
+ params: { path: { id: vocabularyId, term_id: termId } },
+ });
+
+ if (response.status === 409) throw new InUseError((error as { count?: number })?.count ?? 0);
+ if (response.status !== 204) throw new HttpError(response.status);
+ },
+ onSuccess: (_d, { vocabularyId }) => qc.invalidateQueries({ queryKey: keys.terms(vocabularyId) }),
+ meta: { successMessage: "toast.deleted", suppressErrorToast: true },
+ });
+}
diff --git a/web/src/api/query-keys.test.ts b/web/src/api/query-keys.test.ts
new file mode 100644
index 0000000..ddc6c09
--- /dev/null
+++ b/web/src/api/query-keys.test.ts
@@ -0,0 +1,24 @@
+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);
+});
diff --git a/web/src/api/query-keys.ts b/web/src/api/query-keys.ts
new file mode 100644
index 0000000..b1fec78
--- /dev/null
+++ b/web/src/api/query-keys.ts
@@ -0,0 +1,24 @@
+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,
+};
diff --git a/web/src/api/search-invalidation.test.tsx b/web/src/api/search-invalidation.test.tsx
new file mode 100644
index 0000000..7c7dbc9
--- /dev/null
+++ b/web/src/api/search-invalidation.test.tsx
@@ -0,0 +1,28 @@
+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),
+ );
+});
diff --git a/web/src/config/config-provider.tsx b/web/src/config/config-provider.tsx
index 8573294..dc6a668 100644
--- a/web/src/config/config-provider.tsx
+++ b/web/src/config/config-provider.tsx
@@ -2,12 +2,13 @@ import { useEffect, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { api } from "../api/client";
+import { keys } from "../api/query-keys";
import i18n, { LOCALE_KEY } from "../i18n";
import { ConfigContext, DEFAULTS, type ConfigView } from "./config-context";
export function ConfigProvider({ children }: { children: ReactNode }) {
const { data } = useQuery({
- queryKey: ["config"],
+ queryKey: keys.config(),
queryFn: async (): Promise => {
const { data, error } = await api.GET("/api/config");