diff --git a/apps/web/src/agents-api.ts b/apps/web/src/agents-api.ts index 538df56d4..d5c2136aa 100644 --- a/apps/web/src/agents-api.ts +++ b/apps/web/src/agents-api.ts @@ -2,12 +2,11 @@ // this file carries no roster/detail data model of its own. import { WorkflowDefinitionResponse, WorkflowRunResponse, paginatedSchema } from "@intx/types"; -import { type } from "arktype"; -import type { ArkErrors } from "arktype"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { ApiQueryError } from "@/lib/api-query"; import { deployAgentSource, type DeployedAgent, type NewAgentInput } from "./agent-deploy"; +import { apiRequest, type Validator } from "./api"; import { workbenchKeys } from "./chat-path"; import { tenantKeys } from "./query-client"; @@ -21,30 +20,8 @@ const InstancesPage = paginatedSchema(WorkflowRunResponse); // not a raised limit here. const PAGE_LIMIT = 100; -type Validator = (data: unknown) => T | ArkErrors; - -async function getJSON(path: string, schema: Validator): Promise { - let response: Response; - try { - response = await fetch(path, { headers: { accept: "application/json" } }); - } catch (cause) { - throw new ApiQueryError( - cause instanceof Error ? cause.message : String(cause), - undefined, - path, - ); - } - if (response.status === 401) { - throw new ApiQueryError("Not signed in.", 401, path); - } - if (!response.ok) { - throw new ApiQueryError(`The server answered ${response.status}.`, response.status, path); - } - const parsed = schema(await response.json().catch(() => undefined)); - if (parsed instanceof type.errors) { - throw new ApiQueryError(`Unexpected response shape: ${parsed.summary}`, undefined, path); - } - return parsed; +function getJSON(path: string, schema: Validator): Promise { + return apiRequest(path, schema, undefined, ApiQueryError); } export function listAgentDefinitions(tenantId: string): Promise { diff --git a/apps/web/src/api.test.ts b/apps/web/src/api.test.ts new file mode 100644 index 000000000..e75817d55 --- /dev/null +++ b/apps/web/src/api.test.ts @@ -0,0 +1,219 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { type } from "arktype"; + +import { ApiQueryError, UnauthenticatedError } from "@/lib/api-query"; +import { + apiQueryOptions, + apiRequest, + fetchArtifactDetail, + fetchTenantDetail, + readErrorEnvelope, + requestVoid, +} from "./api"; +import { ChatApiError } from "./chat/threads-api"; +import { shouldRetryQuery, isAuthInvalidError } from "./query-client"; +import { SettingsApiError, getAccount } from "./settings/api"; +import { CredentialsApiError, deleteCredential } from "./settings/credentials-api"; +import { InferenceSettingsApiError, deleteOwnOffering } from "./settings/inference/api"; +import { TenancyApiError, removePrincipal } from "./settings/tenancy-api"; + +const ItemSchema = type({ id: "string", count: "number" }); + +function stubFetch(status: number, body?: unknown): typeof fetch { + return (async (_input: string | URL | Request, _init?: RequestInit) => { + return new Response(body === undefined ? "" : JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; +} + +const originalFetch = globalThis.fetch; +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +describe("Error hierarchy — all API errors extend ApiQueryError", () => { + test("domain API error classes are instanceof both their own class and ApiQueryError", () => { + const errors = [ + new SettingsApiError("settings fail", 500), + new TenancyApiError("tenancy fail", 404), + new CredentialsApiError("cred fail", 403), + new InferenceSettingsApiError("inference fail", 401), + new ChatApiError("chat fail", 502), + ]; + + for (const error of errors) { + expect(error).toBeInstanceOf(Error); + expect(error).toBeInstanceOf(ApiQueryError); + } + + expect(errors[0]).toBeInstanceOf(SettingsApiError); + expect(errors[1]).toBeInstanceOf(TenancyApiError); + expect(errors[2]).toBeInstanceOf(CredentialsApiError); + expect(errors[3]).toBeInstanceOf(InferenceSettingsApiError); + expect(errors[4]).toBeInstanceOf(ChatApiError); + }); + + test("shouldRetryQuery and isAuthInvalidError recognize domain error subclasses via ApiQueryError", () => { + const notFound = new TenancyApiError("missing", 404); + expect(shouldRetryQuery(0, notFound)).toBe(false); + + const serverErr = new CredentialsApiError("boom", 500); + expect(shouldRetryQuery(0, serverErr)).toBe(true); + expect(shouldRetryQuery(3, serverErr)).toBe(false); + + const unauth = new InferenceSettingsApiError("expired", 401); + expect(isAuthInvalidError(unauth)).toBe(true); + expect(isAuthInvalidError(notFound)).toBe(false); + }); +}); + +describe("readErrorEnvelope", () => { + test("extracts userMessage from cardinal error envelope", () => { + const msg = readErrorEnvelope( + 403, + { error: { code: "forbidden", userMessage: "Access denied.", refId: "ref-1" } }, + "saving settings", + ); + expect(msg).toBe("Access denied."); + }); + + test("extracts message from stock hub error envelope when userMessage is absent", () => { + const msg = readErrorEnvelope( + 400, + { error: { code: "bad_request", message: "Invalid skill name." } }, + "creating skill", + ); + expect(msg).toBe("Invalid skill name."); + }); + + test("falls back to path-free sentence when body is not a recognized envelope", () => { + expect(readErrorEnvelope(502, { unexpected: true }, "loading data")).toBe( + "The server answered 502 while loading data.", + ); + expect(readErrorEnvelope(502, undefined)).toBe("The server answered 502."); + }); +}); + +describe("apiRequest and requestVoid", () => { + test("parses valid response through ArkType schema", async () => { + const value = await apiRequest( + "/api/items/1", + ItemSchema, + "loading item", + ApiQueryError, + undefined, + stubFetch(200, { id: "item-1", count: 42 }), + ); + expect(value).toEqual({ id: "item-1", count: 42 }); + }); + + test("rejects with statused ErrorCtor on non-2xx and surfaces envelope userMessage", async () => { + const error = await apiRequest( + "/api/items/1", + ItemSchema, + "loading item", + CredentialsApiError, + undefined, + stubFetch(403, { + error: { code: "forbidden", userMessage: "No permission", refId: "r-1" }, + }), + ).catch((cause: unknown) => cause); + + expect(error).toBeInstanceOf(CredentialsApiError); + expect(error).toBeInstanceOf(ApiQueryError); + expect((error as CredentialsApiError).status).toBe(403); + expect((error as CredentialsApiError).message).toBe("No permission"); + }); + + test("rejects when response body fails ArkType schema validation", async () => { + const error = await apiRequest( + "/api/items/1", + ItemSchema, + "loading item", + SettingsApiError, + undefined, + stubFetch(200, { id: "item-1", count: "not-a-number" }), + ).catch((cause: unknown) => cause); + + expect(error).toBeInstanceOf(SettingsApiError); + expect(error).toBeInstanceOf(ApiQueryError); + expect((error as SettingsApiError).message).toContain("Unexpected response shape"); + }); + + test("requestVoid resolves only on 204 and rejects 200/201 or network failures", async () => { + await expect( + requestVoid( + "/api/items/1", + "deleting item", + CredentialsApiError, + { method: "DELETE" }, + stubFetch(204), + ), + ).resolves.toBeUndefined(); + + for (const status of [200, 201]) { + const drift = await requestVoid( + "/api/items/1", + "deleting item", + TenancyApiError, + { method: "DELETE" }, + stubFetch(status, { ok: true }), + ).catch((cause: unknown) => cause); + expect(drift).toBeInstanceOf(TenancyApiError); + expect(drift).toBeInstanceOf(ApiQueryError); + expect((drift as TenancyApiError).status).toBe(status); + expect((drift as TenancyApiError).message).toContain("204"); + } + + const networkErr = await requestVoid( + "/api/items/1", + "deleting item", + TenancyApiError, + { method: "DELETE" }, + (async () => { + throw new TypeError("fetch failed"); + }) as unknown as typeof fetch, + ).catch((cause: unknown) => cause); + expect(networkErr).toBeInstanceOf(TenancyApiError); + expect((networkErr as TenancyApiError).status).toBeUndefined(); + }); + + test("apiQueryOptions maps 401 to UnauthenticatedError and non-ok to ApiQueryError", async () => { + globalThis.fetch = stubFetch(401, {}); + const opts = apiQueryOptions("/api/me", ItemSchema); + const unauth = await opts.queryFn().catch((cause: unknown) => cause); + expect(unauth).toBeInstanceOf(UnauthenticatedError); + + globalThis.fetch = stubFetch(503, {}); + const serverErr = await opts.queryFn().catch((cause: unknown) => cause); + expect(serverErr).toBeInstanceOf(ApiQueryError); + expect((serverErr as ApiQueryError).status).toBe(503); + }); + + test("settings, tenancy, credentials, inference, and api helpers preserve error contracts", async () => { + globalThis.fetch = stubFetch(401, {}); + await expect(getAccount()).rejects.toBeInstanceOf(UnauthenticatedError); + await expect(removePrincipal("t1", "p1")).rejects.toBeInstanceOf(UnauthenticatedError); + await expect(fetchTenantDetail("t1")).rejects.toBeInstanceOf(UnauthenticatedError); + await expect(fetchArtifactDetail("t1", "a1")).rejects.toBeInstanceOf(UnauthenticatedError); + + const inf401 = await deleteOwnOffering( + "t1", + "o1", + stubFetch(401, { + error: { code: "unauthorized", userMessage: "Sign in again", refId: "r" }, + }), + ).catch((cause: unknown) => cause); + expect(inf401).toBeInstanceOf(InferenceSettingsApiError); + expect(inf401).toBeInstanceOf(ApiQueryError); + expect((inf401 as InferenceSettingsApiError).status).toBe(401); + expect((inf401 as InferenceSettingsApiError).message).toBe("Sign in again"); + + globalThis.fetch = stubFetch(500, {}); + const credErr = await deleteCredential("t1", "c1").catch((cause: unknown) => cause); + expect(credErr).toBeInstanceOf(CredentialsApiError); + expect(credErr).toBeInstanceOf(ApiQueryError); + }); +}); diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index a67d24f08..b696733b4 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -1,5 +1,6 @@ // Every response is validated at the boundary with the platform's own // schemas, so a shape change surfaces as an error state, never `undefined`. +// Fallback error messages name what was happening, never the raw route. import { ApprovalResponse, @@ -18,6 +19,126 @@ import type { APIQuery } from "@/lib/api-query"; import { ApiQueryError, UnauthenticatedError, toAPIQuery } from "@/lib/api-query"; import { pathToQueryKey } from "./query-client"; +const CardinalErrorEnvelope = type({ + error: { code: "string", userMessage: "string", "refId?": "string" }, +}); + +const StockErrorEnvelope = type({ + error: { code: "string", message: "string" }, +}); + +// The hub's own envelope `userMessage` (or stock `message`) wins when +// present, otherwise a generic, path-free sentence. +export function readErrorEnvelope(status: number, body: unknown, verb?: string): string { + const cardinal = CardinalErrorEnvelope(body); + if (!(cardinal instanceof type.errors)) { + return cardinal.error.userMessage; + } + const stock = StockErrorEnvelope(body); + if (!(stock instanceof type.errors)) { + return stock.error.message; + } + return verb !== undefined && verb.length > 0 + ? `The server answered ${status} while ${verb}.` + : `The server answered ${status}.`; +} + +export type Validator = (data: unknown) => T | ArkErrors; + +export type ApiErrorCtor = new (message: string, status?: number, path?: string) => Error; + +// Exported (not `typeof fetch`) so a bound server-side wrapper — which never +// carries `fetch`'s static `preconnect` member — satisfies the type too. +export type FetchImpl = (input: string | URL | Request, init?: RequestInit) => Promise; + +function defaultHeaders(init?: RequestInit): HeadersInit { + const hasBody = init?.body !== undefined; + return { + accept: "application/json", + ...(hasBody || init?.method !== undefined ? { "content-type": "application/json" } : {}), + ...init?.headers, + }; +} + +export async function apiRequest( + path: string, + schema: Validator, + verb?: string, + ErrorCtor: ApiErrorCtor = ApiQueryError, + init?: RequestInit, + fetchImpl: FetchImpl = fetch, + options?: { readonly throwUnauthenticatedOn401?: boolean }, +): Promise { + let response: Response; + try { + response = await fetchImpl(path, { + ...init, + headers: defaultHeaders(init), + }); + } catch (cause) { + throw new ErrorCtor(cause instanceof Error ? cause.message : String(cause), undefined, path); + } + if (response.status === 401 && options?.throwUnauthenticatedOn401 === true) { + throw new UnauthenticatedError(); + } + if (!response.ok) { + const body: unknown = await response.json().catch(() => undefined); + throw new ErrorCtor(readErrorEnvelope(response.status, body, verb), response.status, path); + } + const body: unknown = await response.json().catch(() => undefined); + const parsed = schema(body); + if (parsed instanceof type.errors) { + const prefix = + verb !== undefined && verb.length > 0 + ? `Unexpected response shape while ${verb}` + : "Unexpected response shape"; + throw new ErrorCtor(`${prefix}: ${parsed.summary}`, undefined, path); + } + return parsed; +} + +/** For a route whose success response is `204 No Content` — nothing to + * validate against a schema, so this is the one place a call is allowed + * to end without a parsed body, rather than every `request` caller + * having to accept an unsound `undefined as T`. */ +export async function requestVoid( + path: string, + verb?: string, + ErrorCtor: ApiErrorCtor = ApiQueryError, + init?: RequestInit, + fetchImpl: FetchImpl = fetch, + options?: { readonly throwUnauthenticatedOn401?: boolean }, +): Promise { + let response: Response; + try { + response = await fetchImpl(path, { + ...init, + headers: { "content-type": "application/json", ...init?.headers }, + }); + } catch (cause) { + throw new ErrorCtor(cause instanceof Error ? cause.message : String(cause), undefined, path); + } + if (response.status === 401 && options?.throwUnauthenticatedOn401 === true) { + throw new UnauthenticatedError(); + } + if (!response.ok) { + const body: unknown = await response.json().catch(() => undefined); + throw new ErrorCtor(readErrorEnvelope(response.status, body, verb), response.status, path); + } + // A void route answers `204 No Content` by contract. Any other 2xx (e.g. + // a 200/201-with-body a route drifted into returning where 204 is expected) + // is a contract mismatch and must fail loudly rather than be silently + // accepted as an empty success. + if (response.status !== 204) { + const label = verb !== undefined && verb.length > 0 ? `while ${verb}` : `for ${path}`; + throw new ErrorCtor( + `Unexpected response ${label}: expected 204 No Content, got ${response.status}`, + response.status, + path, + ); + } +} + export const ProfileSchema = UserProfile; export const PrincipalsSchema = paginatedSchema(PrincipalSummary); export const TenantApprovalsSchema = paginatedSchema(ApprovalResponse); @@ -83,30 +204,15 @@ export type ArtifactCounts = typeof ArtifactCountsSchema.infer; type Paginated = { data: T[]; nextCursor: string | null }; export type PrincipalsPage = Paginated; -/** An arktype schema, seen as the validating call every `Type` provides. */ -type Validator = (data: unknown) => T | ArkErrors; - /** The key and fetcher `useAPIQuery` runs, for callers that batch reads with * `useQueries` and must share the same cache entry. */ export function apiQueryOptions(path: string, schema: Validator) { return { queryKey: pathToQueryKey(path), - queryFn: async (): Promise => { - const response = await fetch(path, { - headers: { accept: "application/json" }, - }); - if (response.status === 401) { - throw new UnauthenticatedError(); - } - if (!response.ok) { - throw new ApiQueryError(`The server answered ${response.status}.`, response.status, path); - } - const parsed = schema(await response.json()); - if (parsed instanceof type.errors) { - throw new ApiQueryError(`Unexpected response shape: ${parsed.summary}`, undefined, path); - } - return parsed; - }, + queryFn: (): Promise => + apiRequest(path, schema, undefined, ApiQueryError, undefined, fetch, { + throwUnauthenticatedOn401: true, + }), }; } @@ -127,29 +233,11 @@ export function useAPIQuery( } // Loud on a non-2xx status or a shape mismatch, never a silent fallback. -async function postJSON(path: string, schema: Validator, body: unknown): Promise { - let response: Response; - try { - response = await fetch(path, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(body), - }); - } catch (cause) { - throw new ApiQueryError( - cause instanceof Error ? cause.message : String(cause), - undefined, - path, - ); - } - if (!response.ok) { - throw new ApiQueryError(`The server answered ${response.status}.`, response.status, path); - } - const parsed = schema(await response.json()); - if (parsed instanceof type.errors) { - throw new ApiQueryError(`Unexpected response shape: ${parsed.summary}`, undefined, path); - } - return parsed; +function postJSON(path: string, schema: Validator, body: unknown): Promise { + return apiRequest(path, schema, undefined, ApiQueryError, { + method: "POST", + body: JSON.stringify(body), + }); } /** Approves a pending approval. Scope "always" is the stock standing @@ -178,22 +266,16 @@ export function rejectApproval( // The only place `parentId` comes from — a bench is a top-level tenant, // so a raw-id/name heuristic can never tell it apart from a workbench. -export async function fetchTenantDetail(tenantId: string): Promise { - const response = await fetch(`/api/tenants/${encodeURIComponent(tenantId)}`, { - headers: { accept: "application/json" }, - }); - if (!response.ok) { - throw new ApiQueryError( - `The server answered ${response.status}.`, - response.status, - `tenant ${tenantId}`, - ); - } - const parsed = TenantDetailSchema(await response.json()); - if (parsed instanceof type.errors) { - throw new ApiQueryError(`Unexpected tenant response shape: ${parsed.summary}`); - } - return parsed; +export function fetchTenantDetail(tenantId: string): Promise { + return apiRequest( + `/api/tenants/${encodeURIComponent(tenantId)}`, + TenantDetailSchema, + "loading tenant detail", + ApiQueryError, + undefined, + fetch, + { throwUnauthenticatedOn401: true }, + ); } // The server answers 415 for a non-HTML artifact. @@ -207,20 +289,14 @@ export async function fetchArtifactDetail( tenantId: string, artifactId: string, ): Promise { - const response = await fetch( + const parsed = await apiRequest( `/api/tenants/${tenantId}/artifacts/${encodeURIComponent(artifactId)}`, - { headers: { accept: "application/json" } }, + ArtifactDetailSchema, + "loading artifact detail", + ApiQueryError, + undefined, + fetch, + { throwUnauthenticatedOn401: true }, ); - if (!response.ok) { - throw new ApiQueryError( - `The server answered ${response.status}.`, - response.status, - `artifact ${artifactId}`, - ); - } - const parsed = ArtifactDetailSchema(await response.json()); - if (parsed instanceof type.errors) { - throw new ApiQueryError(`Unexpected artifact response shape: ${parsed.summary}`); - } return parsed.artifact; } diff --git a/apps/web/src/bench-context.tsx b/apps/web/src/bench-context.tsx index 2a965e637..a10a595e6 100644 --- a/apps/web/src/bench-context.tsx +++ b/apps/web/src/bench-context.tsx @@ -106,12 +106,6 @@ export function BenchProvider({ children }: { readonly children: ReactNode }) { selectedTenantId: resolved?.tenantId ?? null, selectedPrincipalId: resolved?.principalId ?? null, selectTenant: (tenantId: string) => { - const previous = stored; - if (previous !== null && previous !== tenantId) { - // Drop the left-behind bench's cache entirely — do not invalidate - // (which would refetch for a bench the user is no longer on). - queryClient.removeQueries({ queryKey: tenantKeys.all(previous) }); - } writeStoredTenantId(tenantId); setStored(tenantId); }, diff --git a/apps/web/src/bench/tenant-config.ts b/apps/web/src/bench/tenant-config.ts index 46898fbce..9890adca7 100644 --- a/apps/web/src/bench/tenant-config.ts +++ b/apps/web/src/bench/tenant-config.ts @@ -1,7 +1,5 @@ -import { type } from "arktype"; - import { ApiQueryError } from "@/lib/api-query"; -import { TenantDetailSchema, fetchTenantDetail, type TenantDetail } from "../api"; +import { TenantDetailSchema, apiRequest, fetchTenantDetail, type TenantDetail } from "../api"; /** Sets one key of the stock tenant config. Read-modify-write on fresh config * so other keys are never dropped. */ @@ -12,17 +10,16 @@ export async function patchTenantConfigKey( ): Promise { const current = await fetchTenantDetail(tenantId); const path = `/api/tenants/${encodeURIComponent(tenantId)}`; - const response = await fetch(path, { - method: "PATCH", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ config: { ...current.config, [key]: value } }), - }); - if (!response.ok) { - throw new ApiQueryError(`The server answered ${response.status}.`, response.status, path); - } - const parsed = TenantDetailSchema(await response.json()); - if (parsed instanceof type.errors) { - throw new ApiQueryError(`Unexpected tenant response shape: ${parsed.summary}`); - } - return parsed; + return apiRequest( + path, + TenantDetailSchema, + "updating tenant configuration", + ApiQueryError, + { + method: "PATCH", + body: JSON.stringify({ config: { ...current.config, [key]: value } }), + }, + fetch, + { throwUnauthenticatedOn401: true }, + ); } diff --git a/apps/web/src/chat/threads-api.ts b/apps/web/src/chat/threads-api.ts index 20ed5b30b..da77c5bf6 100644 --- a/apps/web/src/chat/threads-api.ts +++ b/apps/web/src/chat/threads-api.ts @@ -6,6 +6,8 @@ import { decodeMail } from "@intx/mime"; import { WorkflowDeploymentResponse } from "@intx/types"; import { reportError } from "@corbits/error-sink"; +import { ApiQueryError } from "@/lib/api-query"; +import { apiRequest, type Validator } from "../api"; import { agentSlugFromSourceAssetName } from "../agent-deploy"; import { listTopLevelRuns } from "../agents-api"; import { WORKER_SOURCE_CONFIG } from "../worker-source"; @@ -21,12 +23,9 @@ import { hasRedeployFailed } from "./redeploy-failures"; import { mentionedAgents } from "./mentions"; import { appendRoster } from "./workbench-roster"; -export class ChatApiError extends Error { - constructor( - message: string, - readonly status?: number, - ) { - super(message); +export class ChatApiError extends ApiQueryError { + constructor(message: string, status?: number, path?: string) { + super(message, status, path); this.name = "ChatApiError"; } } @@ -187,21 +186,8 @@ const InboxPage = type({ messages: type({ uid: "number", envelope: Envelope, raw: "string" }).array(), }); -async function getJson(path: string, schema: (value: unknown) => T | type.errors): Promise { - let response: Response; - try { - response = await fetch(path, { headers: { accept: "application/json" } }); - } catch (cause) { - throw new ChatApiError(cause instanceof Error ? cause.message : String(cause)); - } - if (!response.ok) { - throw new ChatApiError(`The server answered ${String(response.status)}.`, response.status); - } - const parsed = schema(await response.json().catch(() => undefined)); - if (parsed instanceof type.errors) { - throw new ChatApiError(`Unexpected response shape from ${path}: ${parsed.summary}`); - } - return parsed; +function getJson(path: string, schema: Validator): Promise { + return apiRequest(path, schema, undefined, ChatApiError); } /** Base64 to text as UTF-8. `atob` alone yields one char per byte, which diff --git a/apps/web/src/query-cache-persistence.test.ts b/apps/web/src/query-cache-persistence.test.ts new file mode 100644 index 000000000..687822c06 --- /dev/null +++ b/apps/web/src/query-cache-persistence.test.ts @@ -0,0 +1,205 @@ +// Unit tests for IndexedDB-backed TanStack Query cache persistence (`query-cache-persistence.ts`). +// Uses a lightweight fake IDBFactory so persistence, hydration, stale-while-revalidate +// invalidation, expiry, and clear flows run deterministically without a browser DOM. +import { describe, expect, test } from "bun:test"; +import { QueryClient } from "@tanstack/react-query"; + +import { + attachQueryCachePersistence, + clearPersistedQueryCache, + hydrateQueryClientFromIndexedDB, + type PersistedQueryEntry, +} from "./query-cache-persistence"; +import { tenantKeys } from "./query-client"; + +function createFakeIdb(): { + readonly factory: IDBFactory; + readonly rows: Map; +} { + const rows = new Map(); + + function makeRequest(compute: () => T): IDBRequest { + const req = { + result: undefined as unknown as T, + error: null, + onsuccess: null as ((this: IDBRequest, ev: Event) => unknown) | null, + onerror: null as ((this: IDBRequest, ev: Event) => unknown) | null, + } as unknown as IDBRequest; + queueMicrotask(() => { + (req as { result: T }).result = compute(); + req.onsuccess?.call(req, new Event("success")); + }); + return req; + } + + const store = { + put(value: PersistedQueryEntry) { + return makeRequest(() => { + rows.set(value.queryHash, value); + return value.queryHash; + }); + }, + delete(key: string) { + return makeRequest(() => { + rows.delete(key); + }); + }, + clear() { + return makeRequest(() => { + rows.clear(); + }); + }, + getAll() { + return makeRequest(() => [...rows.values()]); + }, + }; + + const db = { + objectStoreNames: { + contains: () => true, + }, + createObjectStore: () => store, + transaction: () => { + const tx = { + objectStore: () => store, + oncomplete: null as ((this: IDBTransaction, ev: Event) => unknown) | null, + onerror: null as ((this: IDBTransaction, ev: Event) => unknown) | null, + error: null, + } as unknown as IDBTransaction; + queueMicrotask(() => { + tx.oncomplete?.call(tx, new Event("complete")); + }); + return tx; + }, + close: () => undefined, + }; + + const factory = { + open() { + const req = { + result: db, + error: null, + onupgradeneeded: null as + | ((this: IDBOpenDBRequest, ev: IDBVersionChangeEvent) => unknown) + | null, + onsuccess: null as ((this: IDBOpenDBRequest, ev: Event) => unknown) | null, + onerror: null as ((this: IDBOpenDBRequest, ev: Event) => unknown) | null, + } as unknown as IDBOpenDBRequest; + queueMicrotask(() => { + req.onsuccess?.call(req, new Event("success")); + }); + return req; + }, + } as unknown as IDBFactory; + + return { factory, rows }; +} + +describe("query-cache-persistence", () => { + test("persists successful query data on microtask and hydrates it marked invalidated for background revalidation", async () => { + const { factory, rows } = createFakeIdb(); + const writerClient = new QueryClient(); + const detach = attachQueryCachePersistence(writerClient, { + idbFactory: factory, + }); + + const benchAKey = tenantKeys.skills("bench-a"); + const benchBKey = tenantKeys.skills("bench-b"); + writerClient.setQueryData(benchAKey, [{ assetId: "s1", name: "alpha" }]); + writerClient.setQueryData(benchBKey, [{ assetId: "s2", name: "beta" }]); + + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(rows.size).toBe(2); + + const readerClient = new QueryClient({ + defaultOptions: { queries: { staleTime: 30_000 } }, + }); + await hydrateQueryClientFromIndexedDB(readerClient, { idbFactory: factory }); + + expect(readerClient.getQueryData(benchAKey)).toEqual([ + { assetId: "s1", name: "alpha" }, + ]); + expect(readerClient.getQueryData(benchBKey)).toEqual([ + { assetId: "s2", name: "beta" }, + ]); + // Hydrated entries are marked invalidated so a page reload still revalidates + // active queries against the hub despite the 30s staleTime. + expect(readerClient.getQueryState(benchAKey)?.isInvalidated).toBe(true); + expect(readerClient.getQueryState(benchBKey)?.isInvalidated).toBe(true); + + detach(); + }); + + test("removes invalidated query entries from IndexedDB until fresh data arrives", async () => { + const { factory, rows } = createFakeIdb(); + const client = new QueryClient(); + const detach = attachQueryCachePersistence(client, { idbFactory: factory }); + + const key = tenantKeys.detail("bench-a"); + client.setQueryData(key, { id: "bench-a", name: "Old Name" }); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(rows.size).toBe(1); + + await client.invalidateQueries({ queryKey: key }); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(rows.size).toBe(0); + + detach(); + }); + + test("skips expired entries during hydration and does not overwrite existing in-memory data", async () => { + const { factory, rows } = createFakeIdb(); + const key = tenantKeys.routines("bench-a"); + const expiredKey = tenantKeys.routines("bench-expired"); + const now = Date.now(); + + rows.set(JSON.stringify(key), { + queryHash: JSON.stringify(key), + queryKey: key, + data: ["older-from-idb"], + dataUpdatedAt: now - 10_000, + }); + rows.set(JSON.stringify(expiredKey), { + queryHash: JSON.stringify(expiredKey), + queryKey: expiredKey, + data: ["stale-expired"], + dataUpdatedAt: now - 48 * 60 * 60 * 1000, + }); + + const client = new QueryClient(); + client.setQueryData(key, ["newer-in-memory"], { updatedAt: now }); + + await hydrateQueryClientFromIndexedDB(client, { + idbFactory: factory, + maxAgeMs: 24 * 60 * 60 * 1000, + }); + + expect(client.getQueryData(key)).toEqual(["newer-in-memory"]); + expect(client.getQueryData(expiredKey)).toBeUndefined(); + }); + + test("clearPersistedQueryCache wipes stored entries", async () => { + const { factory, rows } = createFakeIdb(); + const key = tenantKeys.assets("bench-a"); + rows.set(JSON.stringify(key), { + queryHash: JSON.stringify(key), + queryKey: key, + data: [{ id: "asset-1" }], + dataUpdatedAt: Date.now(), + }); + + await clearPersistedQueryCache(factory); + expect(rows.size).toBe(0); + }); + + test("gracefully no-ops when indexedDB is unavailable", async () => { + const client = new QueryClient(); + const detach = attachQueryCachePersistence(client, { idbFactory: undefined }); + client.setQueryData(tenantKeys.skills("bench-a"), []); + await expect( + hydrateQueryClientFromIndexedDB(client, { idbFactory: undefined }), + ).resolves.toBeUndefined(); + await expect(clearPersistedQueryCache(undefined)).resolves.toBeUndefined(); + detach(); + }); +}); diff --git a/apps/web/src/query-cache-persistence.ts b/apps/web/src/query-cache-persistence.ts new file mode 100644 index 000000000..5e6a04415 --- /dev/null +++ b/apps/web/src/query-cache-persistence.ts @@ -0,0 +1,239 @@ +// Hydrates cached query entries on boot as stale-while-revalidate so active +// queries render cached data immediately while still revalidating against the +// hub on a page reload, and flushes successful snapshots on a microtask. + +import { reportError } from "@corbits/error-sink"; +import type { QueryClient } from "@tanstack/react-query"; +import { type } from "arktype"; + +const DB_NAME = "workbench-query-cache-v1"; +const DB_VERSION = 1; +const STORE_NAME = "queries"; +const DEFAULT_MAX_AGE_MS = 24 * 60 * 60 * 1000; + +const PersistedQueryEntrySchema = type({ + queryHash: "string", + queryKey: "unknown[]", + data: "unknown", + dataUpdatedAt: "number", +}); + +export type PersistedQueryEntry = { + readonly queryHash: string; + readonly queryKey: readonly unknown[]; + readonly data: unknown; + readonly dataUpdatedAt: number; +}; + +function resolveIdbFactory(explicit?: IDBFactory | undefined): IDBFactory | undefined { + if (arguments.length > 0) return explicit; + return typeof indexedDB !== "undefined" ? indexedDB : undefined; +} + +function openCacheDb(factory: IDBFactory): Promise { + return new Promise((resolve, reject) => { + const request = factory.open(DB_NAME, DB_VERSION); + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME, { keyPath: "queryHash" }); + } + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => + reject(request.error ?? new Error("Failed to open IndexedDB query cache")); + }); +} + +function runStoreRequest( + factory: IDBFactory, + mode: IDBTransactionMode, + work: (store: IDBObjectStore) => IDBRequest, +): Promise { + return openCacheDb(factory).then( + (db) => + new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, mode); + const store = tx.objectStore(STORE_NAME); + const req = work(store); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error ?? new Error("IndexedDB query cache request failed")); + tx.oncomplete = () => db.close(); + tx.onerror = () => { + db.close(); + reject(tx.error ?? new Error("IndexedDB query cache transaction failed")); + }; + }), + ); +} + +// Hydrated entries get `updatedAt: 0` + `invalidateQueries` so TanStack Query's +// 30s `staleTime` never suppresses a fresh fetch on reload, and any query that +// already started fetching before IndexedDB opened is left untouched. +export async function hydrateQueryClientFromIndexedDB( + queryClient: QueryClient, + options?: { + readonly idbFactory?: IDBFactory | undefined; + readonly maxAgeMs?: number; + }, +): Promise { + const factory = + options !== undefined && "idbFactory" in options ? options.idbFactory : resolveIdbFactory(); + if (factory === undefined) return; + + const maxAgeMs = options?.maxAgeMs ?? DEFAULT_MAX_AGE_MS; + const minUpdatedAt = Date.now() - maxAgeMs; + + try { + const rawEntries = await runStoreRequest(factory, "readonly", (store) => + store.getAll(), + ); + const hydratedKeys: (readonly unknown[])[] = []; + for (const raw of rawEntries) { + const entry = PersistedQueryEntrySchema(raw); + if (entry instanceof type.errors) continue; + if (entry.dataUpdatedAt < minUpdatedAt) continue; + + const existing = queryClient.getQueryState(entry.queryKey); + if ( + existing !== undefined && + (existing.data !== undefined || existing.fetchStatus === "fetching") + ) { + continue; + } + queryClient.setQueryData(entry.queryKey, entry.data, { + updatedAt: 0, + }); + hydratedKeys.push(entry.queryKey); + } + for (const queryKey of hydratedKeys) { + void queryClient.invalidateQueries({ + queryKey, + exact: true, + refetchType: "active", + }); + } + } catch (cause) { + reportError(cause, { operation: "queryCachePersistence.hydrate" }); + } +} + +export function attachQueryCachePersistence( + queryClient: QueryClient, + options?: { + readonly idbFactory?: IDBFactory | undefined; + readonly debounceMs?: number; + }, +): () => void { + const factory = + options !== undefined && "idbFactory" in options ? options.idbFactory : resolveIdbFactory(); + if (factory === undefined) return () => undefined; + + const debounceMs = options?.debounceMs ?? 0; + const pendingWrites = new Map(); + const pendingDeletes = new Set(); + let timer: ReturnType | null = null; + let microtaskScheduled = false; + + const flush = () => { + timer = null; + microtaskScheduled = false; + const toWrite = [...pendingWrites.values()]; + const toDelete = [...pendingDeletes]; + pendingWrites.clear(); + pendingDeletes.clear(); + if (toWrite.length === 0 && toDelete.length === 0) return; + + void openCacheDb(factory) + .then( + (db) => + new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, "readwrite"); + const store = tx.objectStore(STORE_NAME); + for (const hash of toDelete) { + store.delete(hash); + } + for (const entry of toWrite) { + store.put(entry); + } + tx.oncomplete = () => { + db.close(); + resolve(); + }; + tx.onerror = () => { + db.close(); + reject(tx.error ?? new Error("IndexedDB query cache flush failed")); + }; + }), + ) + .catch((cause: unknown) => { + reportError(cause, { operation: "queryCachePersistence.flush" }); + }); + }; + + const scheduleFlush = () => { + if (debounceMs <= 0) { + if (microtaskScheduled) return; + microtaskScheduled = true; + queueMicrotask(flush); + return; + } + if (timer !== null) return; + timer = setTimeout(flush, debounceMs); + }; + + const unsubscribe = queryClient.getQueryCache().subscribe((event) => { + const query = event.query; + if (event.type === "removed") { + pendingWrites.delete(query.queryHash); + pendingDeletes.add(query.queryHash); + scheduleFlush(); + return; + } + if (event.type === "updated" || event.type === "added") { + if (query.state.isInvalidated) { + pendingWrites.delete(query.queryHash); + // Only evict when live data (`dataUpdatedAt > 0`) was invalidated, not + // when hydration itself seeded `updatedAt: 0`. + if (query.state.dataUpdatedAt > 0) { + pendingDeletes.add(query.queryHash); + scheduleFlush(); + } + return; + } + if ( + query.state.status === "success" && + query.state.data !== undefined && + query.state.dataUpdatedAt > 0 + ) { + pendingDeletes.delete(query.queryHash); + pendingWrites.set(query.queryHash, { + queryHash: query.queryHash, + queryKey: [...query.queryKey], + data: query.state.data, + dataUpdatedAt: query.state.dataUpdatedAt, + }); + scheduleFlush(); + } + } + }); + + return () => { + if (timer !== null) { + clearTimeout(timer); + timer = null; + } + microtaskScheduled = false; + unsubscribe(); + }; +} + +export async function clearPersistedQueryCache(idbFactory?: IDBFactory | undefined): Promise { + const factory = arguments.length > 0 ? idbFactory : resolveIdbFactory(); + if (factory === undefined) return; + try { + await runStoreRequest(factory, "readwrite", (store) => store.clear()); + } catch (cause) { + reportError(cause, { operation: "queryCachePersistence.clear" }); + } +} diff --git a/apps/web/src/query-client.ts b/apps/web/src/query-client.ts index 260a4355f..189db6c53 100644 --- a/apps/web/src/query-client.ts +++ b/apps/web/src/query-client.ts @@ -1,11 +1,16 @@ // One QueryClient for the signed-in shell: shared cache for /api/me and -// tenant-scoped reads, so navigating between pages reuses data and a bench -// switch can drop the previous bench's tenant keys in a single call. +// tenant-scoped reads, persisted to IndexedDB so navigating between pages or +// switching benches reuses cached data immediately while revalidating. import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query"; import { ApiQueryError, UnauthenticatedError } from "@/lib/api-query"; import { workbenchesQueryKey } from "@/chat/workbench-tenants"; +import { + attachQueryCachePersistence, + clearPersistedQueryCache, + hydrateQueryClientFromIndexedDB, +} from "./query-cache-persistence"; // A 404 is a stable answer, not a transient failure, so retrying it three // times only delays an honest quiet no-op. @@ -26,24 +31,32 @@ export function isAuthInvalidError(error: unknown): boolean { // while the rest of the shell keeps rendering. Caller must make // `onAuthInvalid` idempotent. export function createAppQueryClient(onAuthInvalid: () => void = () => undefined): QueryClient { - return new QueryClient({ + const handleAuthInvalid = () => { + void clearPersistedQueryCache(); + onAuthInvalid(); + }; + const client = new QueryClient({ queryCache: new QueryCache({ onError: (error) => { - if (isAuthInvalidError(error)) onAuthInvalid(); + if (isAuthInvalidError(error)) handleAuthInvalid(); }, }), mutationCache: new MutationCache({ onError: (error) => { - if (isAuthInvalidError(error)) onAuthInvalid(); + if (isAuthInvalidError(error)) handleAuthInvalid(); }, }), defaultOptions: { queries: { staleTime: 30_000, + gcTime: 24 * 60 * 60 * 1000, retry: shouldRetryQuery, }, }, }); + void hydrateQueryClientFromIndexedDB(client); + attachQueryCachePersistence(client); + return client; } /** Stable identity-scoped keys — survive a bench switch. */ diff --git a/apps/web/src/root.tsx b/apps/web/src/root.tsx index caf7909c8..40f9781ef 100644 --- a/apps/web/src/root.tsx +++ b/apps/web/src/root.tsx @@ -14,6 +14,7 @@ import { getPath, navigateTo, subscribeToPath } from "./router-store"; import { ONBOARDING_PATH } from "./routes"; import { fetchSession, signOut } from "./session"; import type { SessionState, SessionUser } from "./session"; +import { clearPersistedQueryCache } from "./query-cache-persistence"; import { setThemeStorageKey } from "./theme-store"; const log = getLogger("web.session"); @@ -71,6 +72,7 @@ export function Root() { const handleSignOut = useCallback(() => { setSession({ kind: "signed-out" }); + void clearPersistedQueryCache(); toast("Signed out. If you were on a shared computer, close the browser to be sure."); void signOut().then((ok) => { if (ok) return; diff --git a/apps/web/src/routines-api.ts b/apps/web/src/routines-api.ts index 331808cce..4aa4303f7 100644 --- a/apps/web/src/routines-api.ts +++ b/apps/web/src/routines-api.ts @@ -8,6 +8,7 @@ import { WorkflowDeploymentResponse } from "@intx/types"; import type { APIQuery } from "@/lib/api-query"; import { ApiQueryError, UnauthenticatedError, toAPIQuery } from "@/lib/api-query"; import { listTopLevelRuns } from "@/agents-api"; +import { apiRequest, type Validator } from "@/api"; import { deploymentLiveness } from "@/chat/deployment-liveness"; import { isAgentDeploySourceAssetName } from "@/agent-deploy"; import { WORKER_SOURCE_CONFIG } from "@/worker-source"; @@ -108,15 +109,15 @@ async function setScheduleEnabled( action: "pause" | "resume", ): Promise { const path = `${cronPath(tenantId)}/${encodeURIComponent(id)}/${action}`; - const response = await fetch(path, { method: "POST", headers: { accept: "application/json" } }); - if (response.status === 401) throw new UnauthenticatedError(); - if (!response.ok) { - throw new ApiQueryError(`The server answered ${response.status}.`, response.status, path); - } - const parsed = CreatedCronSchedule(await response.json()); - if (parsed instanceof type.errors) { - throw new ApiQueryError(`Unexpected response shape: ${parsed.summary}`, undefined, path); - } + const parsed = await apiRequest( + path, + CreatedCronSchedule, + undefined, + ApiQueryError, + { method: "POST" }, + fetch, + { throwUnauthenticatedOn401: true }, + ); return parsed.schedule; } @@ -159,17 +160,10 @@ export async function listCronSchedules(tenantId: string): Promise(path: string, schema: (data: unknown) => T | type.errors): Promise { - const response = await fetch(path, { headers: { accept: "application/json" } }); - if (response.status === 401) throw new UnauthenticatedError(); - if (!response.ok) { - throw new ApiQueryError(`The server answered ${response.status}.`, response.status, path); - } - const parsed = schema(await response.json()); - if (parsed instanceof type.errors) { - throw new ApiQueryError(`Unexpected response shape: ${parsed.summary}`, undefined, path); - } - return parsed; +function fetchJSON(path: string, schema: Validator): Promise { + return apiRequest(path, schema, undefined, ApiQueryError, undefined, fetch, { + throwUnauthenticatedOn401: true, + }); } // Worker and every deployed agent are `workflow`-kind assets too, but they're diff --git a/apps/web/src/settings/api-request.ts b/apps/web/src/settings/api-request.ts deleted file mode 100644 index c3e77948a..000000000 --- a/apps/web/src/settings/api-request.ts +++ /dev/null @@ -1,86 +0,0 @@ -// Fallback message names what was happening, never the raw route, which -// nobody reading a settings panel should have to see. Each seam keeps its -// own `Error` subclass so a catch site can tell which API failed. - -import { type } from "arktype"; -import type { ArkErrors } from "arktype"; - -const ErrorEnvelope = type({ - error: { code: "string", userMessage: "string", refId: "string" }, -}); - -// The hub's own envelope `userMessage` wins when present, otherwise a -// generic, path-free sentence. -export function readErrorEnvelope(status: number, body: unknown, verb: string): string { - const envelope = ErrorEnvelope(body); - return envelope instanceof type.errors - ? `The server answered ${status} while ${verb}.` - : envelope.error.userMessage; -} - -export type Validator = (data: unknown) => T | ArkErrors; - -export type ApiErrorCtor = new (message: string, status?: number) => Error; - -export async function apiRequest( - path: string, - schema: Validator, - verb: string, - ErrorCtor: ApiErrorCtor, - init?: RequestInit, -): Promise { - let response: Response; - try { - response = await fetch(path, { - ...init, - headers: { "content-type": "application/json", ...init?.headers }, - }); - } catch (cause) { - throw new ErrorCtor(cause instanceof Error ? cause.message : String(cause)); - } - if (!response.ok) { - const body: unknown = await response.json().catch(() => undefined); - throw new ErrorCtor(readErrorEnvelope(response.status, body, verb), response.status); - } - const body: unknown = await response.json().catch(() => undefined); - const parsed = schema(body); - if (parsed instanceof type.errors) { - throw new ErrorCtor(`Unexpected response shape while ${verb}: ${parsed.summary}`); - } - return parsed; -} - -/** For a route whose success response is `204 No Content` — nothing to - * validate against a schema, so this is the one place a call is allowed - * to end without a parsed body, rather than every `request` caller - * having to accept an unsound `undefined as T`. */ -export async function requestVoid( - path: string, - verb: string, - ErrorCtor: ApiErrorCtor, - init?: RequestInit, -): Promise { - let response: Response; - try { - response = await fetch(path, { - ...init, - headers: { "content-type": "application/json", ...init?.headers }, - }); - } catch (cause) { - throw new ErrorCtor(cause instanceof Error ? cause.message : String(cause)); - } - if (!response.ok) { - const body: unknown = await response.json().catch(() => undefined); - throw new ErrorCtor(readErrorEnvelope(response.status, body, verb), response.status); - } - // A void route answers `204 No Content` by contract. Any other 2xx (e.g. - // a 200/201-with-body a route drifted into returning where 204 is expected) - // is a contract mismatch and must fail loudly rather than be silently - // accepted as an empty success. - if (response.status !== 204) { - throw new ErrorCtor( - `Unexpected response while ${verb}: expected 204 No Content, got ${response.status}`, - response.status, - ); - } -} diff --git a/apps/web/src/settings/api.ts b/apps/web/src/settings/api.ts index 9eacc2952..bbb286ee5 100644 --- a/apps/web/src/settings/api.ts +++ b/apps/web/src/settings/api.ts @@ -1,55 +1,37 @@ // Workbench settings and bench membership listings aren't reimplemented // here — they come straight from `@/chat` and `@/bench`. -import { type } from "arktype"; -import type { ArkErrors } from "arktype"; import { TenantResponse, UserProfile } from "@intx/types"; -import { UnauthenticatedError } from "@/lib/api-query"; +import { ApiQueryError } from "@/lib/api-query"; +import { apiRequest, type Validator } from "../api"; export type Bench = typeof TenantResponse.infer; export type Account = typeof UserProfile.infer; -export class SettingsApiError extends Error { - constructor( - message: string, - readonly status?: number, - ) { - super(message); +export class SettingsApiError extends ApiQueryError { + constructor(message: string, status?: number, path?: string) { + super(message, status, path); + this.name = "SettingsApiError"; } } -type Validator = (data: unknown) => T | ArkErrors; - -async function request(path: string, schema: Validator, init?: RequestInit): Promise { - let response: Response; - try { - response = await fetch(path, { - ...init, - headers: { "content-type": "application/json", ...init?.headers }, - }); - } catch (cause) { - throw new SettingsApiError(cause instanceof Error ? cause.message : String(cause)); - } - if (response.status === 401) { - throw new UnauthenticatedError(); - } - if (!response.ok) { - throw new SettingsApiError(`The hub answered ${response.status} for ${path}.`, response.status); - } - const body: unknown = await response.json().catch(() => undefined); - const parsed = schema(body); - if (parsed instanceof type.errors) { - throw new SettingsApiError(`Unexpected response shape from ${path}: ${parsed.summary}`); - } - return parsed; +function request( + path: string, + schema: Validator, + verb: string, + init?: RequestInit, +): Promise { + return apiRequest(path, schema, verb, SettingsApiError, init, fetch, { + throwUnauthenticatedOn401: true, + }); } export function getAccount(): Promise { - return request("/api/me", UserProfile); + return request("/api/me", UserProfile, "loading your account"); } export function renameBench(tenantId: string, name: string): Promise { - return request(`/api/tenants/${tenantId}`, TenantResponse, { + return request(`/api/tenants/${tenantId}`, TenantResponse, "renaming this bench", { method: "PATCH", body: JSON.stringify({ name }), }); diff --git a/apps/web/src/settings/credentials-api.ts b/apps/web/src/settings/credentials-api.ts index 326d2adde..3c1c56da6 100644 --- a/apps/web/src/settings/credentials-api.ts +++ b/apps/web/src/settings/credentials-api.ts @@ -10,7 +10,8 @@ import { } from "@intx/types"; export { credentialTypes } from "@intx/types"; -import { apiRequest, requestVoid, type Validator } from "./api-request"; +import { ApiQueryError } from "@/lib/api-query"; +import { apiRequest, requestVoid, type Validator } from "../api"; export type Credential = typeof CredentialResponse.infer; export type Provider = typeof ProviderResponse.infer; @@ -18,12 +19,10 @@ export type Provider = typeof ProviderResponse.infer; const CredentialsPage = paginatedSchema(CredentialResponse); const ProvidersPage = paginatedSchema(ProviderResponse); -export class CredentialsApiError extends Error { - constructor( - message: string, - readonly status?: number, - ) { - super(message); +export class CredentialsApiError extends ApiQueryError { + constructor(message: string, status?: number, path?: string) { + super(message, status, path); + this.name = "CredentialsApiError"; } } diff --git a/apps/web/src/settings/inference/api.test.ts b/apps/web/src/settings/inference/api.test.ts deleted file mode 100644 index 917b65065..000000000 --- a/apps/web/src/settings/inference/api.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -// Regression coverage for the inference/api `requestVoid` seam (exercised -// through `deleteOwnOffering`, the one database-less void route the Inference -// section calls, which also proves the `fetchImpl` seam is honored): a literal -// 204 must be the only 2xx success, any other 2xx is a contract drift that -// fails loudly, and the cardinal error envelope is surfaced with its status. -import { beforeEach, describe, expect, test } from "bun:test"; - -import { deleteOwnOffering, InferenceSettingsApiError } from "./api"; - -const TENANT = "tenant-1"; -const OFFERING = "offering-1"; - -type StubCall = { readonly input: string | URL | Request; readonly init: RequestInit | undefined }; -const calls: StubCall[] = []; - -function stubFetch(status: number, body?: unknown): typeof fetch { - return (async (input: string | URL | Request, init?: RequestInit) => { - calls.push({ input, init }); - return new Response(body === undefined ? "" : JSON.stringify(body), { - status, - headers: { "content-type": "application/json" }, - }); - }) as typeof fetch; -} - -beforeEach(() => { - calls.length = 0; -}); - -describe("inference/api deleteOwnOffering (requestVoid)", () => { - test("a literal 204 resolves", async () => { - await expect(deleteOwnOffering(TENANT, OFFERING, stubFetch(204))).resolves.toBeUndefined(); - expect(calls).toHaveLength(1); - expect(calls[0]!.init?.method).toBe("DELETE"); - }); - - test("a 2xx other than 204 is a contract drift and fails loudly", async () => { - const error = await deleteOwnOffering(TENANT, OFFERING, stubFetch(200, { ok: true })).catch( - (cause: unknown) => cause, - ); - expect(error).toBeInstanceOf(InferenceSettingsApiError); - expect((error as InferenceSettingsApiError).status).toBe(200); - expect((error as InferenceSettingsApiError).message).toContain("204"); - }); - - test("a 201 is likewise rejected", async () => { - const error = await deleteOwnOffering( - TENANT, - OFFERING, - stubFetch(201, { created: true }), - ).catch((cause: unknown) => cause); - expect(error).toBeInstanceOf(InferenceSettingsApiError); - expect((error as InferenceSettingsApiError).status).toBe(201); - }); - - test("a 401 surfaces the envelope's userMessage with the status", async () => { - const error = await deleteOwnOffering( - TENANT, - OFFERING, - stubFetch(401, { - error: { code: "unauthorized", userMessage: "Sign in again", refId: "r" }, - }), - ).catch((cause: unknown) => cause); - expect(error).toBeInstanceOf(InferenceSettingsApiError); - expect((error as InferenceSettingsApiError).status).toBe(401); - expect((error as InferenceSettingsApiError).message).toBe("Sign in again"); - }); - - test("a 403 surfaces the envelope's userMessage with the status", async () => { - const error = await deleteOwnOffering( - TENANT, - OFFERING, - stubFetch(403, { - error: { code: "forbidden", userMessage: "Not permitted", refId: "r" }, - }), - ).catch((cause: unknown) => cause); - expect(error).toBeInstanceOf(InferenceSettingsApiError); - expect((error as InferenceSettingsApiError).status).toBe(403); - expect((error as InferenceSettingsApiError).message).toBe("Not permitted"); - }); - - test("a 5xx falls back to a generic path-free sentence", async () => { - const error = await deleteOwnOffering( - TENANT, - OFFERING, - stubFetch(500, { notAnEnvelope: true }), - ).catch((cause: unknown) => cause); - expect(error).toBeInstanceOf(InferenceSettingsApiError); - expect((error as InferenceSettingsApiError).status).toBe(500); - }); - - test("a network failure maps to an InferenceSettingsApiError", async () => { - const throwing = (async () => { - throw new TypeError("fetch failed"); - }) as unknown as typeof fetch; - const error = await deleteOwnOffering(TENANT, OFFERING, throwing).catch( - (cause: unknown) => cause, - ); - expect(error).toBeInstanceOf(InferenceSettingsApiError); - expect((error as InferenceSettingsApiError).status).toBeUndefined(); - }); -}); diff --git a/apps/web/src/settings/inference/api.ts b/apps/web/src/settings/inference/api.ts index 534219098..43c37836e 100644 --- a/apps/web/src/settings/inference/api.ts +++ b/apps/web/src/settings/inference/api.ts @@ -29,7 +29,6 @@ // (bring-your-own-key), then the model/provider/offering copies that // reference it — never the ancestor's credential. -import { type } from "arktype"; import { reportError } from "@corbits/error-sink"; import { CreateCredential, @@ -48,114 +47,41 @@ import { UpdateModelProvider, paginatedSchema, } from "@intx/types"; +import { ApiQueryError } from "@/lib/api-query"; +import { + apiRequest, + requestVoid as sharedRequestVoid, + type FetchImpl, + type Validator, +} from "../../api"; export type { ModelInfo, ModelOfferingInfo } from "@intx/types"; +export type { FetchImpl }; -export class InferenceSettingsApiError extends Error { - constructor( - message: string, - readonly status?: number, - ) { - super(message); +export class InferenceSettingsApiError extends ApiQueryError { + constructor(message: string, status?: number, path?: string) { + super(message, status, path); + this.name = "InferenceSettingsApiError"; } } -type Validator = (data: unknown) => T | type.errors; - -// Every call below takes an optional trailing `fetchImpl`, defaulting to -// the global `fetch` the browser (`InferenceSection`) already relies on. -// It exists so a server-side caller — `@corbits/config-profiles`' apply -// route, composing a profile's writes out of these exact functions — can -// pass a `fetch` bound to the hub's own base URL and the acting -// principal's session cookie, rather than this module growing a second, -// duplicated write path for the server-side case. -// Exported (not `typeof fetch`) so a bound wrapper — which never carries -// `fetch`'s own static `preconnect` member — satisfies the type too. -// `string | URL | Request`, not DOM's `RequestInfo | URL` alias, so this -// type resolves the same whether or not a consuming package's tsconfig -// carries `"lib": ["DOM"]` — see `@corbits/config-profiles`' own -// `index.ts` module doc for why a server-only package deliberately never -// adds that lib. -export type FetchImpl = (input: string | URL | Request, init?: RequestInit) => Promise; - -async function request( +function request( path: string, schema: Validator, verb: string, init?: RequestInit, fetchImpl: FetchImpl = fetch, ): Promise { - let response: Response; - try { - response = await fetchImpl(path, { - ...init, - headers: { "content-type": "application/json", ...init?.headers }, - }); - } catch (cause) { - throw new InferenceSettingsApiError(cause instanceof Error ? cause.message : String(cause)); - } - if (!response.ok) { - const body: unknown = await response.json().catch(() => undefined); - const envelope = type({ - error: { code: "string", userMessage: "string", refId: "string" }, - })(body); - throw new InferenceSettingsApiError( - envelope instanceof type.errors - ? `The server answered ${response.status} while ${verb}.` - : envelope.error.userMessage, - response.status, - ); - } - const body: unknown = await response.json().catch(() => undefined); - const parsed = schema(body); - if (parsed instanceof type.errors) { - throw new InferenceSettingsApiError( - `Unexpected response shape while ${verb}: ${parsed.summary}`, - ); - } - return parsed; + return apiRequest(path, schema, verb, InferenceSettingsApiError, init, fetchImpl); } -/** For a route whose success response is `204 No Content` — nothing to - * validate against a schema, so this is the one place a call is allowed - * to end without a parsed body, rather than every `request` caller - * having to accept an unsound `undefined as T`. */ -async function requestVoid( +function requestVoid( path: string, verb: string, init?: RequestInit, fetchImpl: FetchImpl = fetch, ): Promise { - let response: Response; - try { - response = await fetchImpl(path, { - ...init, - headers: { "content-type": "application/json", ...init?.headers }, - }); - } catch (cause) { - throw new InferenceSettingsApiError(cause instanceof Error ? cause.message : String(cause)); - } - if (!response.ok) { - const body: unknown = await response.json().catch(() => undefined); - const envelope = type({ - error: { code: "string", userMessage: "string", refId: "string" }, - })(body); - throw new InferenceSettingsApiError( - envelope instanceof type.errors - ? `The server answered ${response.status} while ${verb}.` - : envelope.error.userMessage, - response.status, - ); - } - // A void route answers `204 No Content` by contract. Any other 2xx (e.g. a - // 200/201-with-body the route drifted into returning where 204 is expected) - // is a contract mismatch and must fail loudly. - if (response.status !== 204) { - throw new InferenceSettingsApiError( - `The server answered ${response.status} while ${verb}; expected 204 No Content.`, - response.status, - ); - } + return sharedRequestVoid(path, verb, InferenceSettingsApiError, init, fetchImpl); } /** The tenant's resolved catalog — every model visible after inheritance, diff --git a/apps/web/src/settings/inference/oauth-login.ts b/apps/web/src/settings/inference/oauth-login.ts index 9d173b288..a183c8532 100644 --- a/apps/web/src/settings/inference/oauth-login.ts +++ b/apps/web/src/settings/inference/oauth-login.ts @@ -5,6 +5,7 @@ import { type } from "arktype"; +import { apiRequest } from "../../api"; import { InferenceSettingsApiError, type FetchImpl } from "./api"; const StartedLogin = type({ loginId: "string", authorizeUrl: "string" }); @@ -16,30 +17,9 @@ const LoginState = type({ status: "'pending'" }) .or({ status: "'cancelled'" }); export type LoginState = typeof LoginState.infer; -async function parsed( - response: Response, - schema: (data: unknown) => T | type.errors, - verb: string, -): Promise { - if (!response.ok) { - throw new InferenceSettingsApiError( - `The server answered ${String(response.status)} while ${verb}.`, - response.status, - ); - } - const body: unknown = await response.json().catch(() => undefined); - const result = schema(body); - if (result instanceof type.errors) { - throw new InferenceSettingsApiError( - `Unexpected response shape while ${verb}: ${result.summary}`, - ); - } - return result; -} - /** Starts a login for `provider`, filing the credential it will mint under * `providerId`/`credentialName`. Returns the URL to send the person to. */ -export async function startProviderLogin( +export function startProviderLogin( tenantId: string, input: { readonly provider: string; @@ -48,26 +28,31 @@ export async function startProviderLogin( }, fetchImpl: FetchImpl = fetch, ): Promise { - return parsed( - await fetchImpl(`/api/tenants/${tenantId}/oauth-logins`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(input), - }), + return apiRequest( + `/api/tenants/${tenantId}/oauth-logins`, StartedLogin, "starting the sign-in", + InferenceSettingsApiError, + { + method: "POST", + body: JSON.stringify(input), + }, + fetchImpl, ); } -export async function readProviderLogin( +export function readProviderLogin( tenantId: string, loginId: string, fetchImpl: FetchImpl = fetch, ): Promise { - return parsed( - await fetchImpl(`/api/tenants/${tenantId}/oauth-logins/${loginId}`), + return apiRequest( + `/api/tenants/${tenantId}/oauth-logins/${loginId}`, LoginState, "checking the sign-in", + InferenceSettingsApiError, + undefined, + fetchImpl, ); } diff --git a/apps/web/src/settings/tenancy-api.test.ts b/apps/web/src/settings/tenancy-api.test.ts deleted file mode 100644 index acceed8e6..000000000 --- a/apps/web/src/settings/tenancy-api.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -// Regression coverage for the tenancy-api `requestVoid` seam (exercised -// through `removePrincipal`, which is the one delete route the People panel -// always hits): a literal 204 must be the only 2xx success, any other 2xx is -// a contract drift that fails loudly, and 401/403 map to their distinct errors -// (UnauthenticatedError / TenancyApiError-with-status) before the generic -// non-ok fallback runs. -import { afterEach, describe, expect, test } from "bun:test"; - -import { UnauthenticatedError } from "@/lib/api-query"; -import { removePrincipal, TenancyApiError } from "./tenancy-api"; - -const TENANT = "tenant-1"; -const PRINCIPAL = "prn-1"; - -function stubFetch(status: number, body?: unknown): typeof fetch { - return (async (_input: string | URL | Request, _init?: RequestInit) => { - return new Response(body === undefined ? "" : JSON.stringify(body), { - status, - headers: { "content-type": "application/json" }, - }); - }) as typeof fetch; -} - -const originalFetch = globalThis.fetch; -afterEach(() => { - globalThis.fetch = originalFetch; -}); - -describe("tenancy-api removePrincipal (requestVoid)", () => { - test("a literal 204 resolves", async () => { - globalThis.fetch = stubFetch(204); - await expect(removePrincipal(TENANT, PRINCIPAL)).resolves.toBeUndefined(); - }); - - test("a 2xx other than 204 is a contract drift and fails loudly", async () => { - globalThis.fetch = stubFetch(200, { ok: true }); - const error = await removePrincipal(TENANT, PRINCIPAL).catch((cause: unknown) => cause); - expect(error).toBeInstanceOf(TenancyApiError); - expect((error as TenancyApiError).status).toBe(200); - expect((error as TenancyApiError).message).toContain("204"); - }); - - test("a 201 is likewise rejected", async () => { - globalThis.fetch = stubFetch(201, { created: true }); - const error = await removePrincipal(TENANT, PRINCIPAL).catch((cause: unknown) => cause); - expect(error).toBeInstanceOf(TenancyApiError); - expect((error as TenancyApiError).status).toBe(201); - }); - - test("a 401 maps to UnauthenticatedError", async () => { - globalThis.fetch = stubFetch(401, { - error: { code: "unauthorized", userMessage: "Sign in again", refId: "r" }, - }); - const error = await removePrincipal(TENANT, PRINCIPAL).catch((cause: unknown) => cause); - expect(error).toBeInstanceOf(UnauthenticatedError); - }); - - test("a 403 maps to a statused TenancyApiError", async () => { - globalThis.fetch = stubFetch(403, { - error: { code: "forbidden", userMessage: "Not permitted", refId: "r" }, - }); - const error = await removePrincipal(TENANT, PRINCIPAL).catch((cause: unknown) => cause); - expect(error).toBeInstanceOf(TenancyApiError); - expect((error as TenancyApiError).status).toBe(403); - }); - - test("a generic 5xx still lands on the statused TenancyApiError", async () => { - globalThis.fetch = stubFetch(500, { - error: { code: "boom", userMessage: "Something broke", refId: "r" }, - }); - const error = await removePrincipal(TENANT, PRINCIPAL).catch((cause: unknown) => cause); - expect(error).toBeInstanceOf(TenancyApiError); - expect((error as TenancyApiError).status).toBe(500); - }); - - test("a network failure maps to a TenancyApiError with no status", async () => { - globalThis.fetch = (async () => { - throw new TypeError("fetch failed"); - }) as unknown as typeof fetch; - const error = await removePrincipal(TENANT, PRINCIPAL).catch((cause: unknown) => cause); - expect(error).toBeInstanceOf(TenancyApiError); - expect((error as TenancyApiError).status).toBeUndefined(); - }); -}); diff --git a/apps/web/src/settings/tenancy-api.ts b/apps/web/src/settings/tenancy-api.ts index a0920fb3d..2e3b2cbdf 100644 --- a/apps/web/src/settings/tenancy-api.ts +++ b/apps/web/src/settings/tenancy-api.ts @@ -1,5 +1,3 @@ -import { type } from "arktype"; -import type { ArkErrors } from "arktype"; import { EvaluateResult, GrantResponse, @@ -11,7 +9,8 @@ import { type GrantOrigin, type UpdatablePrincipalStatus, } from "@intx/types"; -import { UnauthenticatedError } from "@/lib/api-query"; +import { ApiQueryError } from "@/lib/api-query"; +import { apiRequest, requestVoid as sharedRequestVoid, type Validator } from "../api"; export type Principal = typeof PrincipalResponse.infer; export type Role = typeof RoleResponse.infer; @@ -21,12 +20,10 @@ const PrincipalsPage = paginatedSchema(PrincipalResponse); const RolesPage = paginatedSchema(RoleResponse); const GrantsPage = paginatedSchema(GrantResponse); -export class TenancyApiError extends Error { - constructor( - message: string, - readonly status?: number, - ) { - super(message); +export class TenancyApiError extends ApiQueryError { + constructor(message: string, status?: number, path?: string) { + super(message, status, path); + this.name = "TenancyApiError"; } } @@ -41,67 +38,16 @@ export function isNotFoundError(cause: unknown): boolean { return cause instanceof TenancyApiError && cause.status === 404; } -type Validator = (data: unknown) => T | ArkErrors; +function request(path: string, schema: Validator, init?: RequestInit): Promise { + return apiRequest(path, schema, undefined, TenancyApiError, init, fetch, { + throwUnauthenticatedOn401: true, + }); +} -async function request(path: string, schema: Validator, init?: RequestInit): Promise { - let response: Response; - try { - response = await fetch(path, { - ...init, - headers: { "content-type": "application/json", ...init?.headers }, - }); - } catch (cause) { - throw new TenancyApiError(cause instanceof Error ? cause.message : String(cause)); - } - if (response.status === 401) { - throw new UnauthenticatedError(); - } - if (response.status === 403) { - throw new TenancyApiError(`Not permitted to view ${path}.`, 403); - } - if (!response.ok) { - throw new TenancyApiError(`The hub answered ${response.status} for ${path}.`, response.status); - } - const body: unknown = await response.json().catch(() => undefined); - const parsed = schema(body); - if (parsed instanceof type.errors) { - throw new TenancyApiError(`Unexpected response shape from ${path}: ${parsed.summary}`); - } - return parsed; -} - -/** For a route whose success response is `204 No Content` — nothing to - * validate against a schema, so this is the one place a call is allowed - * to end without a parsed body, rather than every `request` caller - * having to accept an unsound `undefined as T`. */ -async function requestVoid(path: string, init?: RequestInit): Promise { - let response: Response; - try { - response = await fetch(path, { - ...init, - headers: { "content-type": "application/json", ...init?.headers }, - }); - } catch (cause) { - throw new TenancyApiError(cause instanceof Error ? cause.message : String(cause)); - } - if (response.status === 401) { - throw new UnauthenticatedError(); - } - if (response.status === 403) { - throw new TenancyApiError(`Not permitted to view ${path}.`, 403); - } - if (!response.ok) { - throw new TenancyApiError(`The hub answered ${response.status} for ${path}.`, response.status); - } - // A void route answers `204 No Content` by contract. Any other 2xx (e.g. a - // 200/201-with-body the route drifted into returning where 204 is expected) - // is a contract mismatch and must fail loudly. - if (response.status !== 204) { - throw new TenancyApiError( - `The hub answered ${response.status} for ${path}; expected 204 No Content.`, - response.status, - ); - } +function requestVoid(path: string, init?: RequestInit): Promise { + return sharedRequestVoid(path, undefined, TenancyApiError, init, fetch, { + throwUnauthenticatedOn401: true, + }); } // -- Principals -------------------------------------------------------- diff --git a/apps/web/src/skills-api.ts b/apps/web/src/skills-api.ts index d3adb214a..8b847bf25 100644 --- a/apps/web/src/skills-api.ts +++ b/apps/web/src/skills-api.ts @@ -2,9 +2,9 @@ // routes. Those carry only metadata, so `description` always reads back // empty and there is no update/scope/version-history route yet. import { type } from "arktype"; -import type { ArkErrors } from "arktype"; import { ApiQueryError } from "@/lib/api-query"; +import { apiRequest, type Validator } from "./api"; const SkillSummary = type({ assetId: "string", @@ -30,60 +30,27 @@ type Asset = typeof AssetResponse.infer; const AssetListResponse = AssetResponse.array(); -const ErrorEnvelope = type({ - error: { code: "string", message: "string" }, -}); - -type Validator = (data: unknown) => T | ArkErrors; - function base(tenantId: string): string { return `/api/tenants/${tenantId}/assets`; } -async function request( +function request( path: string, schema: Validator, init: { method?: string; body?: unknown } = {}, ): Promise { - let response: Response; - try { - response = - init.body === undefined - ? await fetch(path, { - method: init.method ?? "GET", - headers: { accept: "application/json" }, - }) - : await fetch(path, { - method: init.method ?? "GET", - headers: { - accept: "application/json", - "content-type": "application/json", - }, - body: JSON.stringify(init.body), - }); - } catch (cause) { - throw new ApiQueryError( - cause instanceof Error ? cause.message : String(cause), - undefined, - path, - ); - } - const json: unknown = await response.json().catch(() => undefined); - if (!response.ok) { - const envelope = ErrorEnvelope(json); - throw new ApiQueryError( - envelope instanceof type.errors - ? `The server answered ${String(response.status)}.` - : envelope.error.message, - response.status, - path, - ); - } - const parsed = schema(json); - if (parsed instanceof type.errors) { - throw new ApiQueryError(`Unexpected response shape: ${parsed.summary}`, undefined, path); - } - return parsed; + return apiRequest( + path, + schema, + undefined, + ApiQueryError, + init.body === undefined + ? { method: init.method ?? "GET" } + : { + method: init.method ?? "GET", + body: JSON.stringify(init.body), + }, + ); } function toSkillSummary(asset: Asset): SkillSummary {