// --------------------------------------------------------------------------- // query/queryApi.ts — WAVE 32 item 7 (ruling R1, contract C5): the four calls, // and nothing else. // // Shaped like `alerts/alertsApi.ts` — the same `Result`, the same 4xx/5xx // message split — because the failure that matters is identical: a wrong // `credentials` word produces no client-side symptom at all, just a 401 from a // server that never saw a session. // // ⛔ A REFUSAL IS A 200 HERE, AND THAT IS THE CONTRACT, NOT A LOOSE END. C5's // promise is that a question the backend cannot express gets "a plain sentence, // not a broken view", so `build` answers `{spec: null, refused: ""}` // with an OK status. Mapping it onto an error would paint the error page the // ruling exists to prevent. The 4xx cases are the ones that really are the // caller's fault: no database named, no question, a database this session may // not open. // // ⚠ NO CLIENT UNION OVER `kind`. It is the server's vocabulary and arrives as a // string (the wave-9 law): a client `type Kind = "grid" | …` turns "the server // added a kind" into "the client drops the view". // --------------------------------------------------------------------------- import { API_V1, CREDENTIALS } from "../apiContract"; export type Result = | { ok: true; value: T } | { ok: false; status: number; message: string }; /** One saved AI-built view, as `GET /query` sends it. */ export interface SavedQuery { id: string; scope: string; viewId: string; name: string; /** A STRING, never a union — see the header. */ kind: string; question: string; explain: string; createdAt?: string; } /** What `POST /query/build` answers. Exactly one of `spec` / `refused` is set. */ export interface BuildResult { spec: unknown | null; explain: string | null; refused: string | null; /** The machine-readable cause behind `refused`, when the server names one. */ reason?: string; id?: string; } /** 4xx text is POLICY the reader needs ("this database does not have …"); a 5xx's * text is the server's internals and is never shown. Same split as alertsApi. */ export function errorMessage(status: number, message?: string): string { if (status >= 500 || !message) { return status >= 500 ? "Something went wrong on our side. Try again in a moment." : `The server answered ${status}.`; } return message; } async function call( path: string, init: RequestInit, read: (body: unknown) => T ): Promise> { let res: Response; try { res = await fetch(`${API_V1}${path}`, { credentials: CREDENTIALS, ...init }); } catch { return { ok: false, status: 0, message: "Cannot reach the server." }; } const body = (await res.json().catch(() => null)) as unknown; if (!res.ok) { const detail = (body as { error?: { message?: string } } | null)?.error?.message; return { ok: false, status: res.status, message: errorMessage(res.status, detail) }; } return { ok: true, value: read(body) }; } const json = (data: unknown): RequestInit => ({ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data), }); const str = (v: unknown): string => (typeof v === "string" ? v : ""); /** Fails CLOSED: an unreadable answer is an empty list, never a half-parsed one. */ export function parseSaved(body: unknown): SavedQuery[] { const rows = (body as { views?: unknown[] } | null)?.views; if (!Array.isArray(rows)) return []; return rows .map((r) => r as Record) .filter((r) => r && str(r.id) && str(r.scope)) .map((r) => ({ id: str(r.id), scope: str(r.scope), viewId: str(r.viewId) || str(r.id), name: str(r.name) || "Query", kind: str(r.kind) || "grid", question: str(r.question), explain: str(r.explain), createdAt: str(r.createdAt) || undefined, })); } /** `GET /query`: the saved views AND the built-in scopes the build door accepts. */ export interface QueryIndex { views: SavedQuery[]; /** * The non-`ut_` scopes `POST /query/build` will accept, published by the server. * * ⛔ THE PICKER MIRRORS THIS INSTEAD OF GUESSING. The nav hands this page every granted entry, * which is a WIDER set than the build door accepts — a picker filtered on "not a surface" alone * offers databases that 404. `view_templates`' rule: a picker that offers what the door would * refuse is a control that lies. */ builtins: string[]; } export function fetchQueries(): Promise> { return call("/query", {}, (b) => ({ views: parseSaved(b), builtins: (((b as { builtins?: unknown } | null)?.builtins as unknown[]) ?? []) .filter((k): k is string => typeof k === "string"), })); } /** * Ask for a view. **This writes nothing** — the spec comes back for a person to * read before `saveQuery` stores it, which is the only check on the one failure * class no validator can see (a well-formed spec that answers a different * question). See `routes_query.py`'s header. */ export function buildQuery(question: string, scope: string): Promise> { return call("/query/build", json({ question, scope }), (b) => { const r = (b || {}) as Record; return { spec: r.spec ?? null, explain: str(r.explain) || null, refused: str(r.refused) || null, reason: str(r.reason) || undefined, id: str(r.id) || undefined, }; }); } export function saveQuery( question: string, scope: string, spec: unknown ): Promise> { return call("/query/save", json({ question, scope, spec }), (b) => parseSaved({ views: [b] })[0]); } export function deleteQuery(id: string): Promise> { return call(`/query/${encodeURIComponent(id)}`, { method: "DELETE" }, () => true as const); }