// --------------------------------------------------------------------------- // query/QueryPage.tsx — WAVE 32 item 7 (ruling R1, contract C5): the Query module. // // Ask a question about ONE database you already have; the assistant answers with // a VIEW, you read what it will actually do, and you keep it or you do not. // // ⛔ CHROME, NOT A REGISTRY MODULE, and `granted` is why that is allowed. `nav.ts`'s // law for this set is one sentence — "A CHROME ROUTE RENDERS NOTHING THE SERVER DID // NOT ALREADY GRANT" — so the database picker is the nav's OWN entries, handed down // by the Shell, never a directory this page fetches. (A registry row would have been // worse than wrong: every provisioned tenant's `modules` list is `['analyst', // 'automation']`, so a registry key is silently OMITTED from the rail in every // tenant while every gate stays green.) // // ⛔ NO SECOND GRID. Opening a saved query NAVIGATES to that database and asks the // real surface to select the view (`VIEW_OPEN_EVENT`), which is how the view renders // in its declared kind without this file learning what a kanban is. That channel's // own contract says it: "the SHELL routes to the table and the GRID owns view // selection, so neither has to learn the other's state." // // ⭐ AND THIS IS ITS FIRST EMITTER. `VIEW_OPEN_EVENT` was added in wave 20 for an // alert's click-through and has had a LISTENER (`CustomerGrid`) and no sender ever // since — the deep link it was built for was never wired ([[artifact-with-no-importer]] // on the emit side). Two consequences handled here: the listener checks its own view // list first (`views.some(...)`), so an event that arrives before the target grid has // loaded is DROPPED, and there is no acknowledgement channel to wait on. So the emit // is repeated on a short bounded schedule and `selectView` early-returns once the view // is active, which makes a re-emit free rather than a flicker. // // ⚠ THE SENTENCE UNDER THE ANSWER IS THE LOAD-BEARING PART OF THIS PAGE. Measured in // the prototype (W32-T50 section 4d): a free model produced a spec whose every column // was real, every op legal, kind in the vocabulary and target granted — and which // answered a different question ("Revenue by Creator" against a database holding no // revenue). No server-side validation can see that class. So the server DERIVES a // read-back from the spec it accepted, this page shows it, and nothing is saved until // a person says so. Never shorten `explain` away to make the page tidier. // // ⚠ NO CLIENT UNION OVER `kind` — the server's vocabulary arrives as a string (the // wave-9 law), so a kind we do not have a label for renders as itself rather than // dropping the row. // --------------------------------------------------------------------------- import { useCallback, useEffect, useState } from "react"; import { VIEW_OPEN_EVENT, signal } from "../apiContract"; import { databaseEntries } from "../shell/nav"; import type { NavEntry } from "../shell/nav"; import { buildQuery, deleteQuery, fetchQueries, saveQuery } from "./queryApi"; import type { BuildResult, SavedQuery } from "./queryApi"; import "./query.css"; export interface QueryPageProps { /** * ⛔ REQUIRED, and it is the nav's own entries. An optional prop here would * degrade to "the feature does not exist" the first time a mount forgot it, * which is indistinguishable from never having been built (the PRD's words for * this wiring). The Shell already holds these — passing them is also what keeps * the chrome law true by construction rather than by promise. */ granted: NavEntry[]; } /** Human words for a kind. A kind absent from here renders as itself, never dropped. */ const KIND_LABEL: Record = { grid: "Table", list: "List", chart: "Chart", kanban: "Board", calendar: "Calendar", timeseries: "Time series", map: "Map", }; /** * When the view is opened in another surface, the target grid may not have loaded its * views yet — and its listener drops an event naming a view it cannot see. Re-emit on * this schedule (ms); `selectView` is idempotent, so a late arrival costs nothing. */ export const OPEN_RETRIES = [0, 250, 700, 1500, 2600] as const; export function openSavedView(scope: string, viewId: string): void { if (typeof window === "undefined") return; if (window.location.hash.replace(/^#\/?/, "") !== scope) { window.location.hash = `#/${scope}`; } for (const delay of OPEN_RETRIES) { window.setTimeout(() => signal(VIEW_OPEN_EVENT, { topic: scope, viewId }), delay); } } /** * The answer block: a refusal, or the read-back plus the two ways out. * * ⚠ A NAMED EXPORT ON PURPOSE, so `verify_query`'s render leg can paint the three states * directly. `renderToString` does not run effects, so the states this page reaches only * AFTER a fetch would otherwise be unprovable without a browser — and the read-back and * the refusal sentence are precisely the parts that must not quietly stop rendering * ([[ui-invisible-to-assertions]]). */ export function QueryAnswer({ result, saving, onKeep, onDiscard }: { result: BuildResult; saving?: boolean; onKeep: () => void; onDiscard: () => void; }) { if (result.refused) { return (

That is not a view this database can draw

{result.refused}

); } if (!result.spec) return null; return (

Here is what that view would do

{/* ⛔ THE READ-BACK. Derived by the SERVER from the spec it accepted, so it describes what will be built rather than what the model meant. */} {result.explain ?

{result.explain}

: null}
); } /** One saved view. Named for the same reason as {@link QueryAnswer}. */ export function SavedQueryRow({ view, dbLabel, onOpen, onDelete }: { view: SavedQuery; dbLabel: string; onOpen: () => void; onDelete: () => void; }) { return (
  • {KIND_LABEL[view.kind] ?? view.kind} · {dbLabel}
    {view.question ?

    “{view.question}”

    : null} {view.explain ?

    {view.explain}

    : null}
  • ); } export default function QueryPage({ granted }: QueryPageProps) { const [scope, setScope] = useState(""); const [question, setQuestion] = useState(""); const [busy, setBusy] = useState(false); const [result, setResult] = useState(null); const [problem, setProblem] = useState(""); const [saved, setSaved] = useState([]); const [builtins, setBuiltins] = useState([]); const [saving, setSaving] = useState(false); const [loaded, setLoaded] = useState(false); /** * The databases this page may ASK ABOUT. * * `databaseEntries` drops SURFACE rows (that fact is decided where rows are built, not * re-derived here) and a group head has no destination. ⛔ BUT "granted and not a surface" is * WIDER THAN THE BUILD DOOR ACCEPTS: it takes any `ut_*` key plus the built-in topics it * publishes as `builtins`. Offering anything else would be a control that lies — the picker * would list a database and the build call would 404 on it. So the server's own accepted set is * mirrored, never guessed, and until it arrives only `ut_*` keys are offered (fail NARROW). */ const databases = databaseEntries(granted).filter( (e) => e.kind !== "group" && (e.key.startsWith("ut_") || builtins.includes(e.key)) ); // Keep the picker on a database that still exists: a grant can be withdrawn while // this page is open, and the server prunes on read, so the client must not pin a key // the payload no longer carries. useEffect(() => { if (databases.length && !databases.some((d) => d.key === scope)) { setScope(databases[0].key); } }, [databases, scope]); const reload = useCallback(async () => { const res = await fetchQueries(); setLoaded(true); if (res.ok) { setSaved(res.value.views); setBuiltins(res.value.builtins); } }, []); useEffect(() => { void reload(); }, [reload]); const ask = useCallback(async () => { const q = question.trim(); if (!q || !scope || busy) return; setBusy(true); setProblem(""); setResult(null); const res = await buildQuery(q, scope); setBusy(false); if (!res.ok) { setProblem(res.message); return; } setResult(res.value); }, [busy, question, scope]); const keep = useCallback(async () => { if (!result?.spec || saving) return; setSaving(true); const res = await saveQuery(question.trim(), scope, result.spec); setSaving(false); if (!res.ok) { setProblem(res.message); return; } setResult(null); setQuestion(""); await reload(); }, [question, reload, result, saving, scope]); const forget = useCallback(async (id: string) => { const res = await deleteQuery(id); if (!res.ok) { setProblem(res.message); return; } await reload(); }, [reload]); const labelOf = (key: string) => databases.find((d) => d.key === key)?.label ?? key; return (

    Query

    Ask about one of your databases. You get a view you can read before you keep it — and a plain answer when the question is not one this product can draw.

    {databases.length === 0 ? ( // ⚠ TWO DIFFERENT FACTS, TWO DIFFERENT SENTENCES. Before the index arrives the offerable // set is deliberately narrow (see `databases`), so "you have no database" would be a claim // this page cannot yet make.

    {loaded ? "You do not have a database to ask about yet. Create one from the Database menu, " + "then come back and ask about it." : "Loading your databases…"}

    ) : (
    )} {problem ? (

    {problem}

    ) : null} {/* A REFUSAL IS AN ANSWER, and it gets the same weight as a success — a sentence, never an error page (R1's own words). Both states live in `QueryAnswer`. */} {result ? ( void keep()} onDiscard={() => setResult(null)} /> ) : null}

    Your views

    {!loaded ? (

    ) : saved.length === 0 ? (

    Nothing yet. A view you keep lands here, and in the database it was built from.

    ) : (
      {saved.map((v) => ( openSavedView(v.scope, v.viewId)} onDelete={() => void forget(v.id)} /> ))}
    )}
    ); }