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

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

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

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 + move useMe, 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 + move useObjectsPage, 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 + move useFieldDefinitions, 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 + move useVocabularies, 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 + move useAuthorities, 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 the SEARCH_PAGE const and useSearch:
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: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.