Files
biggus-dickus/docs/superpowers/plans/2026-06-08-mutation-error-feedback.md

26 KiB

Consistent, Status-Aware Mutation Error Feedback — 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: Replace the generic, inconsistent mutation-error feedback with one status-aware mapping rendered consistently inline across every create/edit/delete surface.

Architecture: A shared errorMessageKey(error) maps InUseError/HttpError(status) → i18n keys (single source of truth). A shared <MutationError error> renders the inline alert. Mutations throw HttpError(status) so the status reaches the helper. All inline sites adopt the helper/component; the two non-suppressed update mutations suppress the toast and show inline like their siblings.

Tech Stack: React 19 + TS + pnpm, TanStack Query v5, openapi-fetch, react-i18next, Vitest 4 (jsdom) + RTL + MSW. Test runner: pnpm test (single pass).

Conventions: pnpm; no any/eslint-disable/@ts-ignore; no codename; en/sv parity; app source double-quote+semicolon; ui/ files untouched; token classes only. Run a single test pass.

Spec: docs/superpowers/specs/2026-06-08-mutation-error-feedback-design.md

Key facts:

  • HttpError/InUseError/FieldRejection are exported from web/src/api/queries.ts (lines 6, 13, 20). HttpError(status) carries .status; InUseError(count) carries .count.
  • web/src/api/query-client.ts:11-23 mutationErrorMessage(error, meta) currently special-cases InUseError, HttpError 503 → search.unavailable, meta.errorMessage, else toast.error. The MutationCache.onError skips when meta.suppressErrorToast.
  • 16 of 18 mutations already suppressErrorToast. Only useUpdateTerm (queries.ts:444) and useUpdateAuthority (:521) do not — they have meta: { successMessage: "toast.saved" }.
  • The 16 mutation throw new Error("…") sites: queries.ts:184,203,230,248,279,306,331,382,441,458,475,492,518,535,562,579. useCreateObject (:184) and useCreateVocabulary (:279) destructure { data, error } (no response) — add response. Lines 38,73,90,105,152,169,264 are query fetch errors and :121 is the login map — do not change these. useSearch (:360) already throws HttpError.
  • Inline generic-error sites (all render {X.isError && <p role="alert" className="text-xs text-destructive">{t("form.rejected")}</p>}): authorities-page.tsx:144-148 (create), vocabulary-terms.tsx:119-123 (addTerm), vocabulary-list.tsx:57-61 (create) + :109-113 (renameVocabulary), field-form.tsx:201-205 (failed = isEdit ? update.isError : create.isError, line 98). delete-confirm-dialog.tsx:40 sets err instanceof InUseError ? actions.inUse : form.rejected. object-new-page.tsx:36-39 and object-edit-form.tsx:86-88 set t("form.rejected") in a catch else.
  • objects.loadError ("Could not load objects") already exists in both locales. term-row.tsx/authority-row.tsx Edit-button onClick currently does setLabels(...); setUri(...); setEditing(true);.
  • Test env: jsdom project, MSW onUnhandledRequest:"error". renderApp (src/test/render.tsx) mounts ui at path:"*" in a memory router. src/api/mutation-feedback.test.tsx exists (tests toast wiring via makeQueryClient + ToastRegion + renderHook).

Task 1: Shared helper + <MutationError> + i18n + query-client rewire

Files: Create web/src/api/error-message.ts, web/src/api/error-message.test.ts, web/src/components/mutation-error.tsx, web/src/components/mutation-error.test.tsx; Modify web/src/api/query-client.ts, web/src/api/mutation-feedback.test.tsx, web/src/i18n/en.json, web/src/i18n/sv.json.

  • Step 1: Add the 5 i18n keys (both locales, parity). Add a new top-level "errors" object to web/src/i18n/en.json:
  "errors": {
    "forbidden": "You don't have permission to do that.",
    "notFound": "That item no longer exists.",
    "conflict": "That conflicts with existing data.",
    "validation": "Some values weren't accepted.",
    "server": "The server had a problem. Please try again."
  },

and to web/src/i18n/sv.json:

  "errors": {
    "forbidden": "Du har inte behörighet att göra det.",
    "notFound": "Objektet finns inte längre.",
    "conflict": "Det står i konflikt med befintliga data.",
    "validation": "Vissa värden godtogs inte.",
    "server": "Servern hade ett problem. Försök igen."
  },

(Valid JSON — mind commas. Place it consistently in both files, e.g. before toast.)

  • Step 2: Create web/src/api/error-message.ts:
import { HttpError, InUseError } from "./queries";

/** 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. */
export function errorMessageKey(error: unknown): { key: string; opts?: Record<string, unknown> } {
  if (error instanceof InUseError) return { key: "actions.inUse", opts: { count: error.count } };
  if (error instanceof HttpError) return { key: statusKey(error.status) };
  return { key: "toast.error" };
}

function statusKey(status: number): string {
  if (status === 403) return "errors.forbidden";
  if (status === 404) return "errors.notFound";
  if (status === 409) return "errors.conflict";
  if (status === 422) return "errors.validation";
  if (status >= 500) return "errors.server";
  return "toast.error";
}
  • Step 3: Create web/src/api/error-message.test.ts (write + run):
import { expect, test } from "vitest";

import { errorMessageKey } from "./error-message";
import { HttpError, InUseError } from "./queries";

test("maps HTTP statuses to specific keys", () => {
  expect(errorMessageKey(new HttpError(403))).toEqual({ key: "errors.forbidden" });
  expect(errorMessageKey(new HttpError(404))).toEqual({ key: "errors.notFound" });
  expect(errorMessageKey(new HttpError(409))).toEqual({ key: "errors.conflict" });
  expect(errorMessageKey(new HttpError(422))).toEqual({ key: "errors.validation" });
  expect(errorMessageKey(new HttpError(500))).toEqual({ key: "errors.server" });
  expect(errorMessageKey(new HttpError(502))).toEqual({ key: "errors.server" });
});

test("an unmapped status falls back to the generic key", () => {
  expect(errorMessageKey(new HttpError(418))).toEqual({ key: "toast.error" });
});

test("InUseError carries the count", () => {
  expect(errorMessageKey(new InUseError(3))).toEqual({ key: "actions.inUse", opts: { count: 3 } });
});

test("a bare Error or unknown maps to the generic key", () => {
  expect(errorMessageKey(new Error("boom"))).toEqual({ key: "toast.error" });
  expect(errorMessageKey(null)).toEqual({ key: "toast.error" });
});

Run: cd web && pnpm vitest run src/api/error-message.test.ts → 4 passing.

  • Step 4: Create web/src/components/mutation-error.tsx:
import { useTranslation } from "react-i18next";

import { errorMessageKey } from "../api/error-message";

/** Renders a caught mutation error as an inline alert, or nothing when error is falsy.
 *  Replaces the duplicated `<p role="alert" className="text-xs text-destructive">` markup. */
export function MutationError({ error }: { error: unknown }) {
  const { t } = useTranslation();
  if (!error) return null;
  const { key, opts } = errorMessageKey(error);
  return (
    <p role="alert" className="text-xs text-destructive">
      {t(key, opts)}
    </p>
  );
}
  • Step 5: Create web/src/components/mutation-error.test.tsx (write + run):
import { expect, test } from "vitest";
import { render, screen } from "@testing-library/react";

import "../i18n";
import { MutationError } from "./mutation-error";
import { HttpError, InUseError } from "../api/queries";

test("renders the status-specific message for an HttpError", () => {
  render(<MutationError error={new HttpError(403)} />);
  expect(screen.getByRole("alert")).toHaveTextContent(/permission/i);
});

test("renders the in-use count for an InUseError", () => {
  render(<MutationError error={new InUseError(2)} />);
  expect(screen.getByRole("alert")).toHaveTextContent(/2/);
});

test("renders nothing when there is no error", () => {
  const { container } = render(<MutationError error={null} />);
  expect(container).toBeEmptyDOMElement();
});

Run: cd web && pnpm vitest run src/components/mutation-error.test.tsx → 3 passing.

  • Step 6: Rewire web/src/api/query-client.ts. Change the import line import { HttpError, InUseError } from "./queries"; to import { errorMessageKey } from "./error-message";, and replace the whole mutationErrorMessage function body with:
function mutationErrorMessage(
  error: unknown,
  meta: MutationMeta | undefined,
): string {
  if (meta?.errorMessage) return i18n.t(meta.errorMessage);
  const { key, opts } = errorMessageKey(error);
  return i18n.t(key, opts);
}

(Leave the MutationCache onError/onSuccess wiring unchanged.)

  • Step 7: Rework the obsolete toast test in web/src/api/mutation-feedback.test.tsx. The "a non-suppressed mutation failing shows the catch-all error toast" test uses useUpdateTerm, which will suppress in Task 3 — decouple it now by driving the MutationCache fallback with a synthetic non-suppressed mutation. Add useMutation to the @tanstack/react-query import and HttpError to the queries import, then replace that single test with:
  test("a non-suppressed mutation failing shows the status-mapped error toast", async () => {
    const { result, unmount } = renderHook(
      () =>
        useMutation({
          mutationFn: async () => {
            throw new HttpError(500);
          },
        }),
      { wrapper: makeWrapper() },
    );

    await expect(result.current.mutateAsync()).rejects.toThrow();

    await waitFor(() => {
      expect(
        within(document.body).getByText(i18n.t("errors.server")),
      ).toBeInTheDocument();
    });

    unmount();
  });

(Keep the other two tests — success toast via useUpdateTerm, and suppressed useDeleteVocabulary adds no toast — unchanged. Imports: add useMutation; add HttpError from ./queries.)

  • Step 8: Verify (vitest ONCE for the touched files), then typecheck + lint:
cd web && pnpm vitest run src/api/error-message.test.ts src/components/mutation-error.test.tsx src/api/mutation-feedback.test.tsx src/i18n && pnpm typecheck && pnpm lint

Expected: all green (helper 4, component 3, feedback 3, i18n parity covering the 5 new keys).

  • Step 9: Commit
cd /Users/olsson/Laboratory/biggus-dickus
git add web/src/api/error-message.ts web/src/api/error-message.test.ts web/src/components/mutation-error.tsx web/src/components/mutation-error.test.tsx web/src/api/query-client.ts web/src/api/mutation-feedback.test.tsx web/src/i18n/en.json web/src/i18n/sv.json
git commit -m "feat(web): shared status-aware error-message helper + MutationError component (#63)"

Task 2: Make mutation errors load-bearing (throw HttpError(status))

Files: Modify web/src/api/queries.ts.

  • Step 1: Convert the 16 mutation throws to HttpError. In web/src/api/queries.ts, at each of these lines replace throw new Error("…") with throw new HttpError(response.status) — keeping every surrounding InUseError (409) and FieldRejection (422) branch exactly as-is:

    • :203 useUpdateObjectif (response.status !== 204) throw new HttpError(response.status);
    • :230 useSetFields — the final fallback after the 204/422 checks: throw new HttpError(response.status);
    • :248 useDeleteObjectthrow new HttpError(response.status);
    • :306 useAddTermif (response.status !== 201) throw new HttpError(response.status);
    • :331 useCreateAuthoritythrow new HttpError(response.status);
    • :382 useCreateFieldDefinitionif (response.status !== 201 || !data) throw new HttpError(response.status);
    • :441 useUpdateTermthrow new HttpError(response.status);
    • :458 useDeleteTerm — the post-409 fallback: if (response.status !== 204) throw new HttpError(response.status);
    • :475 useRenameVocabularythrow new HttpError(response.status);
    • :492 useDeleteVocabulary — post-409 fallback: throw new HttpError(response.status);
    • :518 useUpdateAuthoritythrow new HttpError(response.status);
    • :535 useDeleteAuthority — post-409 fallback: throw new HttpError(response.status);
    • :562 useUpdateFieldDefinitionthrow new HttpError(response.status);
    • :579 useDeleteFieldDefinition — post-409 fallback: throw new HttpError(response.status);
  • Step 2: The two POSTs that don't destructure response. For useCreateObject (:184) and useCreateVocabulary (:279), change const { data, error } = await api.POST(...) to const { data, error, response } = await api.POST(...) and replace if (error || !data) throw new Error("…") with if (error || !data) throw new HttpError(response.status);.

  • Step 3: Do NOT touch the query fetch errors (:38,73,90,105,152,169,264) or the login map (:121) — they keep throw new Error(...) (consumed by component isError/login mapping, not the toast). HttpError is already imported in this file (it's defined here), so no import change.

  • Step 4: Verify (vitest ONCE), typecheck, lint:

cd web && pnpm vitest run src/api/queries.test.ts src/api/mutation-feedback.test.tsx && pnpm typecheck && pnpm lint

Expected: green. HttpError extends Error, so any .rejects.toThrow() assertions still pass (no test asserts the old message strings). If queries.test.ts asserts a specific thrown type/message that now differs, update it to assert HttpError/status (do not weaken).

  • Step 5: Commit
cd /Users/olsson/Laboratory/biggus-dickus
git add web/src/api/queries.ts
git commit -m "feat(web): mutations throw HttpError(status) so failures are status-aware (#63)"

Task 3: Edit-row consistency + delete-dialog adoption

Files: Modify web/src/api/queries.ts, web/src/vocab/term-row.tsx, web/src/authorities/authority-row.tsx, web/src/components/delete-confirm-dialog.tsx, and their tests / web/src/api/mutation-feedback.test.tsx consumers.

  • Step 1: Suppress the two update toasts. In web/src/api/queries.ts, change useUpdateTerm's meta (:444) from meta: { successMessage: "toast.saved" } to meta: { successMessage: "toast.saved", suppressErrorToast: true }, and the same for useUpdateAuthority (:521).

  • Step 2: Inline error + reset in web/src/vocab/term-row.tsx. Add the import import { MutationError } from "../components/mutation-error";. In the editing view, add <MutationError error={updateTerm.error} /> immediately after the save/cancel <div className="flex gap-2">…</div> (still inside the <li>). In the non-editing view's Edit <Button> onClick, add updateTerm.reset(); as the first statement (before setLabels(...)):

        onClick={() => {
          updateTerm.reset();
          setLabels(term.labels as LabelInput[]);
          setUri(term.external_uri ?? "");
          setEditing(true);
        }}
  • Step 2b: Same for web/src/authorities/authority-row.tsx. Import MutationError; add <MutationError error={updateAuthority.error} /> after the save/cancel <div className="flex gap-2">…</div>; add updateAuthority.reset(); as the first statement in the Edit button's onClick.

  • Step 3: Status-aware delete dialog (web/src/components/delete-confirm-dialog.tsx). Add import { errorMessageKey } from "../api/error-message";, remove the now-unused import { InUseError } from "../api/queries";, and change the catch block (lines ~37-41) from the err instanceof InUseError ? … : t("form.rejected") line to:

    } catch (err) {
      // Keep the dialog open; show the blocking reason. Never let the rejected
      // mutation escape as an unhandled rejection.
      const { key, opts } = errorMessageKey(err);
      setMessage(t(key, opts));
      return;
    }

(errorMessageKey already maps InUseErroractions.inUse with the count, so the in-use message is preserved and a 403/404 delete now shows a specific message.)

  • Step 4: Tests. Add a failing-update test to web/src/vocab/term-row.tsx's area — create web/src/vocab/term-row.test.tsx if absent (check first), else extend. Render a TermRow inside renderApp with the makeQueryClient toast wrapper is overkill — use the existing renderApp and MSW. Concretely:
import { expect, test } from "vitest";
import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { http, HttpResponse } from "msw";

import { server } from "../test/server";
import { renderApp } from "../test/render";
import { TermRow } from "./term-row";

const term = { id: "t1", external_uri: null, labels: [{ lang: "en", label: "Bronze" }] };

test("a failed term update shows an inline error and keeps the row editable", async () => {
  server.use(
    http.patch("/api/admin/vocabularies/:id/terms/:term_id", () => new HttpResponse(null, { status: 403 })),
  );
  renderApp(<ul><TermRow vocabularyId="v1" term={term as never} lang="en" /></ul>);

  await userEvent.click(screen.getByRole("button", { name: /edit/i }));
  await userEvent.click(screen.getByRole("button", { name: /save/i }));

  expect(await screen.findByRole("alert")).toHaveTextContent(/permission/i);
  // still editable: the save button is still present (editor did not close)
  expect(screen.getByRole("button", { name: /save/i })).toBeInTheDocument();
});

test("re-entering edit after a failure clears the stale error", async () => {
  server.use(
    http.patch("/api/admin/vocabularies/:id/terms/:term_id", () => new HttpResponse(null, { status: 403 })),
  );
  renderApp(<ul><TermRow vocabularyId="v1" term={term as never} lang="en" /></ul>);

  await userEvent.click(screen.getByRole("button", { name: /edit/i }));
  await userEvent.click(screen.getByRole("button", { name: /save/i }));
  expect(await screen.findByRole("alert")).toBeInTheDocument();
  await userEvent.click(screen.getByRole("button", { name: /cancel/i }));
  await userEvent.click(screen.getByRole("button", { name: /edit/i }));

  expect(screen.queryByRole("alert")).toBeNull();
});

(Adjust the lang/term cast to match TermView. If a term-row.test.tsx already exists, append these and keep existing tests green. The mutation-feedback.test.tsx test #1 — useUpdateTerm success → toast.saved — still passes since suppress only affects errors; confirm it stays green.)

  • Step 5: Verify (vitest ONCE):
cd web && pnpm vitest run src/vocab/term-row.test.tsx src/authorities src/components/delete-confirm-dialog.test.tsx src/api/mutation-feedback.test.tsx && pnpm typecheck && pnpm lint

Expected: green (new row tests pass; delete-dialog story/test still green; authority tests green). If delete-confirm-dialog.test.tsx doesn't exist, drop it from the command.

  • Step 6: Commit
cd /Users/olsson/Laboratory/biggus-dickus
git add web/src/api/queries.ts web/src/vocab/term-row.tsx web/src/authorities/authority-row.tsx web/src/components/delete-confirm-dialog.tsx web/src/vocab/term-row.test.tsx
git commit -m "feat(web): inline status-aware errors on term/authority edit rows + delete dialog (#63)"

Task 4: Create/rename/object form adoption + fetch fix + full gate

Files: Modify web/src/authorities/authorities-page.tsx, web/src/vocab/vocabulary-terms.tsx, web/src/vocab/vocabulary-list.tsx, web/src/fields/field-form.tsx, web/src/objects/object-new-page.tsx, web/src/objects/object-edit-form.tsx (+ a test).

  • Step 1: Adopt <MutationError> at the create/rename inline sites. In each file, add import { MutationError } from "../components/mutation-error"; (path ../components/mutation-error from these dirs) and replace the {X.isError && <p role="alert" className="text-xs text-destructive">{t("form.rejected")}</p>} block with <MutationError error={X.error} />:

    • authorities-page.tsx:144-148<MutationError error={create.error} />
    • vocabulary-terms.tsx:119-123<MutationError error={addTerm.error} />
    • vocabulary-list.tsx:57-61<MutationError error={create.error} />; and :109-113<MutationError error={renameVocabulary.error} />
    • field-form.tsx:201-205<MutationError error={isEdit ? update.error : create.error} />; then delete the now-unused const failed = isEdit ? update.isError : create.isError; (:98). Keep const pending = … and the {error && …form.required} validation block.
  • Step 2: Object create/edit catch-else via the helper. In web/src/objects/object-new-page.tsx, add import { errorMessageKey } from "../api/error-message"; and change the create catch (:36-39) from } catch { setError(t("form.rejected")); return false; } to:

    } catch (e) {
      const { key, opts } = errorMessageKey(e);
      setError(t(key, opts));
      return false;
    }

In web/src/objects/object-edit-form.tsx, add the same import and change the non-FieldRejection else (:86-88) from setError(t("form.rejected")); to:

      } else {
        const { key, opts } = errorMessageKey(e);
        setError(t(key, opts));
      }
  • Step 3: Fetch-error fix in web/src/objects/object-edit-form.tsx. Change const { data: object, isLoading } = useObject(id!); to const { data: object, isLoading, isError } = useObject(id!); and insert, between the isLoading and !object guards:
  if (isError) return <p className="p-4 text-sm text-destructive">{t("objects.loadError")}</p>;
  • Step 4: Test the fetch fix + one create-form adoption. Add to web/src/objects/object-edit-form.test.tsx (create if absent):
test("renders a load error (not 'not found') when the object fetch fails", async () => {
  server.use(http.get("/api/admin/objects/:id", () => new HttpResponse(null, { status: 500 })));
  renderApp(<Routes><Route path="/objects/:id/edit" element={<ObjectEditForm />} /></Routes>, {
    route: "/objects/abc/edit",
  });
  expect(await screen.findByText(/could not load/i)).toBeInTheDocument();
  expect(screen.queryByText(/not found/i)).toBeNull();
});

(Use the file's existing imports/harness; add http/HttpResponse from msw, Routes/Route, server, renderApp, ObjectEditForm as needed. If the file exists, append and keep its tests green.) Also add to web/src/authorities/authorities-page.test.tsx (or the nearest existing authorities test) a case asserting a failed create shows the status message:

test("a failed create shows a status-aware inline error", async () => {
  server.use(http.post("/api/admin/authorities", () => new HttpResponse(null, { status: 403 })));
  // …render the authorities page, fill a label, submit…
  expect(await screen.findByText(/permission/i)).toBeInTheDocument();
});

(Wire the render/fill/submit to match the existing authorities test harness; if that harness isn't readily reusable, cover the create-error path through field-form or skip this second assertion — the MutationError component test already proves the rendering, and error-message.test.ts proves the mapping. Do NOT add a brittle test just to hit a number.)

  • Step 5: 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. Report test totals, largest chunk (gz), and the check:colors line.

  • Step 6: 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).

  • Step 7: Manual smoke (recommended). With the stack + server + pnpm dev: cause a failing save (e.g. stop the server, or a 409 on a duplicate) on a term/authority edit row → inline status message appears at the row, row stays editable; a failed create shows the inline message; loading an edit form whose object 500s shows "Could not load objects" not "not found."

  • Step 8: Commit

cd /Users/olsson/Laboratory/biggus-dickus
git add web/src/authorities/authorities-page.tsx web/src/vocab/vocabulary-terms.tsx web/src/vocab/vocabulary-list.tsx web/src/fields/field-form.tsx web/src/objects/object-new-page.tsx web/src/objects/object-edit-form.tsx web/src/objects/object-edit-form.test.tsx web/src/authorities/authorities-page.test.tsx
git commit -m "feat(web): adopt MutationError across create/object forms; distinguish edit-form fetch error (#63)"

Self-Review (completed)

Spec coverage: AC1 errorMessageKey + unit test (T1 S2-S3); AC2 <MutationError> + adoption at delete-dialog (T3 S3), create/rename forms (T4 S1), object-form formError via helper (T4 S2), edit rows (T3 S2); AC3 16 throws → HttpError + query-client rewire (T2, T1 S6); AC4 update suppress + inline + reset (T3 S1-S2); AC5 edit-form fetch error (T4 S3); AC6 gate + parity + codename (T4 S5-S6, T1 S1). ✓

Placeholder scan: every code step shows complete code; tests have concrete assertions and exact statuses (403/500); the one soft spot (T4 S4 second assertion) is explicitly bounded with "don't add a brittle test to hit a number," and the mapping/rendering are already proven by T1's two test files. No TODO/TBD. ✓

Type/consistency: errorMessageKey(error: unknown): { key: string; opts? } defined in T1, consumed identically by query-client.ts, MutationError, delete-confirm-dialog, object-new-page, object-edit-form. HttpError(status) (T2) feeds errorMessageKey's instanceof HttpError branch. suppressErrorToast added (T3 S1) matches the sibling mutations' meta shape. objects.loadError pre-exists. ✓

Notes

  • No new dependency. ui/* untouched. en/sv parity preserved (5 new errors.* keys, guarded by the #60 parity test).
  • After Task 2 (before Task 3), useUpdateTerm/useUpdateAuthority are still non-suppressed and now throw HttpError, so a failed update shows a status-mapped toast — a transient mid-milestone state; Task 3 moves it inline. Each commit is independently green.
  • Error classes stay in queries.ts; relocating them to api/errors.ts is tracked in #65.