19 KiB
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.tsimport 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"](inconfig/config-provider.tsx:10),["objects", params],["objects"],["object", id],["field-definitions"],["terms", vocabularyId],["authorities", kind],["vocabularies"],["search", term, visibility]. useTerms/useAuthoritieskey on astring | null | undefinedarg (enabled-gated), sokeys.terms/keys.authoritiesmust 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):
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-25HttpError/FieldRejection/InUseError, and ~393-399VisibilityError). At the top of the file (after the existingimportlines), add an import for use + a re-export for compatibility:
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. Changeimport { HttpError, InUseError } from "./queries";toimport { 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:
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
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:
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):
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.tswithkeys.*. Addimport { keys, type ObjectListParams } from "./query-keys";and DELETE the localexport 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 importingObjectListParamsfrom../api/querieskeep working: addexport type { ObjectListParams } from "./query-keys";near the top.
-
Step 4: Add search invalidation (
web/src/api/queries.ts). In each of theseonSuccesshandlers addvoid qc.invalidateQueries({ queryKey: keys.search() });:useUpdateObjectonSuccess (after theobjects/objectinvalidations)useDeleteObjectonSuccess (alongside theobjectsinvalidation — convert it to a block:onSuccess: () => { void qc.invalidateQueries({ queryKey: keys.objects() }); void qc.invalidateQueries({ queryKey: keys.search() }); })useSetVisibilityonSuccess (after theobject/objectsinvalidations)
-
Step 5: Update
web/src/config/config-provider.tsx. Addimport { keys } from "../api/query-keys";and changequeryKey: ["config"]→queryKey: keys.config(). -
Step 6: Create
web/src/api/search-invalidation.test.tsx(write + run) — proves the new behavior:
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 }) => (
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
);
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:
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
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 + moveuseMe,useLogin,useLogout:
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 + moveuseObjectsPage,useObject,useCreateObject,useUpdateObject,useSetFields,useDeleteObject,useSetVisibility:
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 + moveuseFieldDefinitions,useCreateFieldDefinition,useUpdateFieldDefinition,useDeleteFieldDefinition:
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 + moveuseVocabularies,useCreateVocabulary,useRenameVocabulary,useDeleteVocabulary,useTerms,useAddTerm,useUpdateTerm,useDeleteTerm:
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 + moveuseAuthorities,useCreateAuthority,useUpdateAuthority,useDeleteAuthority:
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 theSEARCH_PAGEconst anduseSearch:
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):
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):
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:
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
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:sizeshould be unchanged (pure reorg + one invalidate call). Barrel keeps../api/queriesstable → 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/errorsis a deferred cosmetic follow-up. auth.tsneeds noerrors.tsimport (its throws are plainError); every other module imports the error classes it throws.