loopable / web /src /query /queryApi.ts
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
609fb78 verified
Raw
History Blame Contribute Delete
6.06 kB
// ---------------------------------------------------------------------------
// 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<T>`, 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: "<sentence>"}`
// 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<T> =
| { 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<T>(
path: string,
init: RequestInit,
read: (body: unknown) => T
): Promise<Result<T>> {
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<string, unknown>)
.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<Result<QueryIndex>> {
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<Result<BuildResult>> {
return call("/query/build", json({ question, scope }), (b) => {
const r = (b || {}) as Record<string, unknown>;
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<Result<SavedQuery>> {
return call("/query/save", json({ question, scope, spec }), (b) => parseSaved({ views: [b] })[0]);
}
export function deleteQuery(id: string): Promise<Result<true>> {
return call(`/query/${encodeURIComponent(id)}`, { method: "DELETE" }, () => true as const);
}