File size: 6,063 Bytes
609fb78 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 | // ---------------------------------------------------------------------------
// 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);
}
|