diff --git "a/web/src/customer-grid/apiBridge.ts" "b/web/src/customer-grid/apiBridge.ts" --- "a/web/src/customer-grid/apiBridge.ts" +++ "b/web/src/customer-grid/apiBridge.ts" @@ -1,619 +1,619 @@ -// --------------------------------------------------------------------------- -// customer-grid / apiBridge.ts — the STANDALONE half of the data path (X2). -// -// The branch beside hostBridge.ts. Where `hostBridge` speaks the Streamlit -// Components v1 protocol to a surrounding Python app, this speaks HTTP to the -// API — the SAME event objects, the same ids, the same optimism semantics on -// the client. Which one is live is decided once, by mode, and never mixed: -// `installStandaloneBridge()` is called from main.tsx ONLY in standalone, so -// the embed bundle never registers a sink and its behaviour is byte-unchanged -// (pinned by verify_bridge.py, which was written BEFORE this file existed). -// -// ⚠ WHAT THIS WAVE'S WRITE PATH CAN AND CANNOT DO — read before extending. -// The client's whole write model is "emit → the host reruns → a fresh payload -// arrives → the UI reflects it" (CustomerGrid's own words: *"the emit itself -// triggers the host rerun, so the fresh name/list arrives with the next payload -// — no local mutation to drift from the store"*). Standalone has no rerun and -// X2 fixes `GET /api/v1/customers` at the EXACT current shape — `{fields, rows, -// today, pulled_at}`, with no `workspace`, no `cohorts`, no `docs`. So: -// -// VISIBLE + DURABLE view_upsert · view_delete · field_* · overlay_patch -// (the client already holds these in state + localStorage; -// the POST is what makes them durable server-side) -// DURABLE, INVISIBLE cohort_* · add_to_list · folder_* · item_move -// (they reach the store, but nothing re-reads them until -// the payload carries that state — EXIT-5). The server's -// `toast` is the only feedback the user gets, which is -// why it is wired through and not dropped. -// NOT REACHABLE doc_add / doc_fetch / doc_delete — the Documents panel -// is driven by `payload.docs`, which standalone never -// receives, so no doc event can be emitted at all. X2's -// `doc` field is therefore deliberately NOT handled here: -// wiring a response nobody can trigger is untestable dead -// code. When `/customers` grows `docs`, feed `doc` into -// the same `payload.docPayload` slot the embed uses. -// -// NOTHING HERE FALLS BACK TO `sample_customers.json`. That affordance ("the -// grid still renders with no backend") is unreachable now — the frame requires -// a session before CustomerGrid mounts at all — and behind a login, sample -// revenue rendered after a server hiccup is fabricated data on a screen the -// user has every reason to trust. An honest failure, always. -// --------------------------------------------------------------------------- - -import { - API_V1, - CREDENTIALS, - DATA_ERROR_EVENT, - DERIVED_CELLS_EVENT, - ROWS_STALE_EVENT, - TOAST_EVENT, - WORKSPACE_STALE_EVENT, - UNAUTHORIZED_EVENT, - checkTenant, - signal, -} from "../apiContract"; +// --------------------------------------------------------------------------- +// customer-grid / apiBridge.ts — the STANDALONE half of the data path (X2). +// +// The branch beside hostBridge.ts. Where `hostBridge` speaks the Streamlit +// Components v1 protocol to a surrounding Python app, this speaks HTTP to the +// API — the SAME event objects, the same ids, the same optimism semantics on +// the client. Which one is live is decided once, by mode, and never mixed: +// `installStandaloneBridge()` is called from main.tsx ONLY in standalone, so +// the embed bundle never registers a sink and its behaviour is byte-unchanged +// (pinned by verify_bridge.py, which was written BEFORE this file existed). +// +// ⚠ WHAT THIS WAVE'S WRITE PATH CAN AND CANNOT DO — read before extending. +// The client's whole write model is "emit → the host reruns → a fresh payload +// arrives → the UI reflects it" (CustomerGrid's own words: *"the emit itself +// triggers the host rerun, so the fresh name/list arrives with the next payload +// — no local mutation to drift from the store"*). Standalone has no rerun and +// X2 fixes `GET /api/v1/customers` at the EXACT current shape — `{fields, rows, +// today, pulled_at}`, with no `workspace`, no `cohorts`, no `docs`. So: +// +// VISIBLE + DURABLE view_upsert · view_delete · field_* · overlay_patch +// (the client already holds these in state + localStorage; +// the POST is what makes them durable server-side) +// DURABLE, INVISIBLE cohort_* · add_to_list · folder_* · item_move +// (they reach the store, but nothing re-reads them until +// the payload carries that state — EXIT-5). The server's +// `toast` is the only feedback the user gets, which is +// why it is wired through and not dropped. +// NOT REACHABLE doc_add / doc_fetch / doc_delete — the Documents panel +// is driven by `payload.docs`, which standalone never +// receives, so no doc event can be emitted at all. X2's +// `doc` field is therefore deliberately NOT handled here: +// wiring a response nobody can trigger is untestable dead +// code. When `/customers` grows `docs`, feed `doc` into +// the same `payload.docPayload` slot the embed uses. +// +// NOTHING HERE FALLS BACK TO `sample_customers.json`. That affordance ("the +// grid still renders with no backend") is unreachable now — the frame requires +// a session before CustomerGrid mounts at all — and behind a login, sample +// revenue rendered after a server hiccup is fabricated data on a screen the +// user has every reason to trust. An honest failure, always. +// --------------------------------------------------------------------------- + +import { + API_V1, + CREDENTIALS, + DATA_ERROR_EVENT, + DERIVED_CELLS_EVENT, + ROWS_STALE_EVENT, + TOAST_EVENT, + WORKSPACE_STALE_EVENT, + UNAUTHORIZED_EVENT, + checkTenant, + signal, +} from "../apiContract"; import { setStandaloneSink } from "./hostBridge"; import { changedBuckets } from "./liveWorkspace"; import type { ChangeTokens } from "./liveWorkspace"; -import { CUSTOMER_TOPIC } from "./types"; -import type { CustomersPayload, Field, FilterNode, GridLimit, GridWorkspace, HostEvent, Row, - SortSpec, TopicConfig } from "./types"; -import type { TsPayload, TsRequest } from "./timeSeriesData"; - -const JSON_HEADERS = { "Content-Type": "application/json" }; - -async function readJson(res: Response): Promise { - try { - return await res.json(); - } catch { - return null; - } -} - -/** 401 is the one status that is never a data problem. Raised once, centrally, - * so every call site cannot forget to. Returns true when it handled it. - * The rows caches die with the session — the next signed-in user may not - * be the same person, and a cached book must never cross that boundary. */ -function handledUnauthorized(status: number): boolean { - if (status !== 401) return false; - rowsCache.clear(); - signal(UNAUTHORIZED_EVENT); - return true; -} - -/** - * The session check every authenticated call makes: the 401 above, PLUS the TENANT swap the - * 401 path structurally cannot see. - * - * ⛔ WHY `handledUnauthorized` WAS NEVER ENOUGH. Its own note says the rows cache "dies with the - * session — the next signed-in user may not be the same person". That is the USER boundary, and - * it is enforced on 401. A TENANT swap produces no 401 at all: `aios_session` is one cookie per - * ORIGIN, so signing into another tenant in a second tab repoints this one, and every request - * here keeps returning a cheerful 200 full of somebody else's data. `checkTenant` reads the - * server's own stamp and reloads the frame; see `apiContract.ts::TENANT_HEADER`. - */ -function handledSession(res: Response): boolean { - if (checkTenant(res)) return true; - return handledUnauthorized(res.status); -} - -// --- the READ (CP-B) ------------------------------------------------------- - -/** - * The last good `/customers` payload, reused across surface switches. The rows - * are a 15-minute-cached Odoo pull SERVER-side, but every Customer⇄Cohort - * route change remounts the grid and re-downloaded the whole ~1 MB payload — - * three full transfers in one short session, measured live. Within this window - * a remount reuses the copy; the workspace (the cheap per-scope call) is always - * re-fetched, so views/cohorts stay live. - * - * ⛔ THE CACHE IS SESSION-SCOPED OR IT IS A LEAK. It is cleared on ANY 401 and - * on sign-out/sign-in (Shell calls `clearCustomersCache`) — one browser, two - * accounts, and a cached book served across the boundary would be the exact - * cross-user leak EXIT-3b exists to prevent, client-side this time. - * - * Own edits stay visible ([[the NO-BLIP law]]): `patchCustomer` writes accepted - * values through into the cached rows, so a scope switch after an edit shows - * the edit, not the pre-edit pull. - */ -const CUSTOMERS_FRESH_MS = 5 * 60_000; -// Wave 16 C-TOPIC: ONE cache map keyed by the topic's rows path, so the product pull and the -// customer pull each get the same 5-minute remount window without ever serving each other. -const rowsCache = new Map(); - -/** - * ⭐⭐ WAVE 31 · T21 (owner item 7: *"It still takes a very long time to load from one Database - * into another"*) — THE WORKSPACE ENVELOPE, MEMOISED PER SCOPE. - * - * ⛔ WHAT WAS MISSING, stated exactly: `rowsCache` above has memoised ROWS since EXIT wave 1, and - * `fetchWorkspace` had NO cache of any kind. So switching back to a database whose rows were - * still warm STILL paid a full `/workspace` round trip — and that call is not cheap: it is - * `ut_assembly`, which on tenant #0 reads a **28.6 MB** document (703 ms of deep copy, warm) plus - * the whole `grid_events` workspace build. Measured live on `283b815`: **2,430 ms** for - * `?scope=customer`. Every database→database hop paid it, both directions, for an envelope that - * had not changed. - * - * ⚠ THE FRESHNESS WINDOW IS THE ROWS CACHE'S, ON PURPOSE. Two windows over one surface would - * drift into a grid whose columns are newer than its rows, or the reverse — a shape this repo has - * already paid for. One constant, both memos. - * - * ⛔ AND IT IS EVICTED BY THE WRITE PATH, NOT ONLY BY TIME. Every accepted grid event fires - * `WORKSPACE_STALE_EVENT` → `useCustomerData.reread()`, which calls this with no `allowCached`, - * so it re-fetches AND overwrites this entry. Without that overwrite a person could hide a - * field, switch away, switch back, and be served the pre-write envelope — the write silently - * undone on screen, which is worse than the wait it replaced. - */ -const wsCache = new Map(); - -export function clearWorkspaceCache(): void { - wsCache.clear(); -} - -export function clearCustomersCache(): void { - rowsCache.clear(); - // ⛔ THE ENVELOPE IS SESSION-SCOPED FOR THE SAME REASON THE ROWS ARE, and it carries MORE that - // is per-account than they do: `viewer`, `userOptions`, the user's own views and folders. One - // browser, two accounts, and a cached workspace served across the boundary would show the - // previous person's saved views under the new person's name. - clearWorkspaceCache(); - // ⛔ AND THE READ-THROUGH ROSTER, which is a per-SESSION fact, not a per-topic one. It names - // which tables this tenant serves through the mirror; a second account signing into the same - // browser is a different tenant's answer. Same boundary the rows cache is cleared on, and for - // the same reason. - readThroughMemo = null; -} - -/** - * Drop ONE topic's rows memo. The change poller's companion (wave 29, item 20). - * - * ⛔ NOT `clearCustomersCache()`, and the difference is a megabyte. That clears every topic's - * window, so a poll that noticed a change on the Product grid would also force the Customer pool - * to be re-downloaded on the next surface switch. One bucket changed; one memo is dropped. - * - * ⚠ WAVE 30 — it now sweeps a PREFIX, because one table can have many cache entries. A windowed - * grid's key carries its offset and its predicate (see `windowRowsPath`), so `ut_odoo_orders` - * may hold a dozen. Deleting the bare path would leave every one of them live. - */ -export function clearTopicRowsCache(rowsPath: string): void { - rowsCache.delete(rowsPath); - for (const key of [...rowsCache.keys()]) - if (key.startsWith(`${rowsPath}?`)) rowsCache.delete(key); -} - -/** - * ⭐⭐ WAVE 30 · W30-T42 — DROP EVERY CACHED ROWS RESPONSE FOR ONE TABLE, WHICHEVER DOOR SERVED IT. - * - * ⛔ THE DEFECT THIS PREVENTS, AND IT IS THIS REPO'S NAMED CLASS. Before C2 a table had exactly - * one rows key (`tables//rows`) and eight call sites deleted that literal. A read-through - * grid is served by a DIFFERENT route (`odoo-tables//rows`) under MANY keys (one per - * offset × predicate), so every one of those literals silently stopped invalidating anything — - * a write that returned 200 and a grid that kept painting the pre-write page, with no error - * anywhere. Two invalidation laws for one question is [[one-question-two-normalizers]]; there - * is one law and it lives here. - * - * ⚠ IT MATTERS DESPITE `recordsMutable: false`. A read-through Odoo grid refuses record edits, - * but W30-T28 shipped tenant-wide SHARED COLUMNS on exactly these grids — `PATCH - * /tables/{key}/shared/{pid}` writes a cell that rides the window payload's own `fields`/rows. - */ -export function dropTableRowsCache(tableKey: string): void { - const prefixes = [`tables/${tableKey}/rows`, `odoo-tables/${tableKey}/rows`]; - for (const key of [...rowsCache.keys()]) - if (prefixes.some((p) => key === p || key.startsWith(`${p}?`))) rowsCache.delete(key); -} - -/** - * X2 `GET /api/v1/` → `{fields, rows, today, pulled_at}`, rows already - * scoped to the session's BU pool (EXIT-3b). The cookie replaces HTTP Basic. - * - * Returns null on ANY failure, having raised the matching signal. A null is not - * an empty table: the frame renders a failure, not "this tenant has no - * records", because those two look identical and only one of them is true. - */ -/** - * ⭐ WAVE 30 (D-79's second half) — THE ONE READER OF A SERVER REFUSAL, and the reason it exists. - * - * ⛔ MEASURED: five call sites in this file read `body.detail.message`, and **the server has never - * sent that shape.** `main.py`'s `_error_shape` handler unwraps `deps.err`'s `detail` before the - * response leaves, so every non-2xx body on the wire is `{"error": {"code", "message"}}` — the - * X2 contract, stated in that handler's own docstring. `body.detail` is `undefined` at all five, - * so every carefully-worded refusal in the API ("this database already has a profile column — - * 'Handle'. A database has at most one…") was replaced by "The server answered 400." - * - * That is worse than a missing feature: the product LOOKED like it was explaining itself. Sixteen - * other call sites across the client already read `error.message` correctly, which is why nobody - * noticed — the wrong shape survived only where nobody had recently read a refusal out loud. - * - * ⚠ THE `detail` LEG STAYS, AS A STRING. FastAPI's own `RequestValidationError` does NOT pass - * through `_error_shape`, so a 422 from a malformed body still arrives shaped `{"detail": …}`. - * Reading it as a string is honest; reading `.message` off it never was. - */ -export function refusalMessage(body: unknown, status: number, fallback?: string): string { - const shaped = body as { error?: { message?: unknown }; detail?: unknown } | null | undefined; - const named = shaped?.error?.message; - if (typeof named === "string" && named.trim()) return named; - if (typeof shaped?.detail === "string" && shaped.detail.trim()) return shaped.detail; - return fallback ?? `The server answered ${status}.`; -} - -export async function fetchTopicRows(topic: TopicConfig): Promise { - const cached = rowsCache.get(topic.rowsPath); - if (cached && Date.now() - cached.at < CUSTOMERS_FRESH_MS) { - return cached.payload; - } - let res: Response; - try { - res = await fetch(`${API_V1}/${topic.rowsPath}`, { credentials: CREDENTIALS }); - } catch { - signal(DATA_ERROR_EVENT, "Cannot reach the server."); - return null; - } - if (handledSession(res)) return null; - if (!res.ok) { - // ⭐ WAVE 30 — R's ask, and it is the SIXTH site of the shape T44 closed five of. This path - // answered every non-2xx with a bare status while `refusalMessage` sat 50 lines above it, - // and TWO cause-carrying refusals now arrive here: `store_not_ready` (503, live on a - // connected grid before the mirror's first sync) and the window route's own 409. Both name - // their cause and their fix; both used to render as "The server answered 503." - signal(DATA_ERROR_EVENT, refusalMessage(await readJson(res), res.status)); - return null; - } - const body = (await readJson(res)) as CustomersPayload | null; - if (!body || !Array.isArray(body.rows) || !Array.isArray(body.fields)) { - signal(DATA_ERROR_EVENT, "The server sent an unreadable payload."); - return null; - } - rowsCache.set(topic.rowsPath, { at: Date.now(), payload: body }); - return body; -} - -/** The customer topic's fetch — kept under its own name because half the write-path notes in - * this repo cite it; it IS `fetchTopicRows(CUSTOMER_TOPIC)`. */ -export function fetchCustomers(): Promise { - return fetchTopicRows(CUSTOMER_TOPIC); -} - -// ═══════════════════════════════════════════════════════════════════════════════════════════ -// ⭐⭐ WAVE 30 · W30-T42 — THE READ-THROUGH WINDOW (owner ruling R6 via R7, contract C2). -// ═══════════════════════════════════════════════════════════════════════════════════════════ -// -// THE SHAPE OF THE PROBLEM, in the owner's numbers. `tables//rows` serves a table by -// copying it out of the tenant's `user_tables` document, which is why `MAX_ROWS = 60_000` -// exists and why order lines (255,286) and GL lines (963,783) could never be grids at all. -// W30-T26 built the other door: `odoo-tables//rows` reads the DuckDB mirror and answers -// `{rows, total, totalUnfiltered, offset, limit, limits}` — the requested slice plus a -// `SELECT count(*)` that tells the truth about the whole. -// -// ⛔ THE CLIENT MACHINERY FOR THIS ALREADY EXISTED AND WAS DORMANT — `TableMode`, -// `ScopeCounts`, `countLabel`'s "showing 200 of 201,558", `useVisibleRows`' pass-through and -// every `serverWindowed` guard in `CustomerGrid`. What did not exist was anything that ever -// SET `counts.windowed`: a mode reachable only by a payload no fetch path had ever built. So -// what is added below is a fetch and a mapping, not an engine. -// -// ⛔ `counts.matched` IS THE SERVER'S `total`, NEVER `rows.length`. That is the fabricated -// aggregate this repo has paid for twice ([[no-unverifiable-aggregates]]) and it is the ONE -// number the whole contract exists to protect: a 200-row response describing 32,826 orders. - -/** One window's worth of rows. 200 is what D measured the route at (79 ms warm on 32,700 - * orders, against 474 ms for the whole read); the server clamps anything over - * `datastore.WINDOW_MAX` (5,000) and REPORTS the clamp rather than trimming quietly. */ -export const WINDOW_ROWS = 200; - -export interface RowWindowRequest { - offset: number; - limit: number; - /** The saved view's OWN objects. ⛔ Not translated — see `windowRowsPath`. */ - filters?: FilterNode[] | null; - filterConj?: string; - sorts?: SortSpec | null; - search?: string; -} - -/** - * The request path for one window — AND, deliberately, its cache key. - * - * ⛔ THE TICKET'S NAMED TRAP, SOLVED BY CONSTRUCTION. `rowsCache` is keyed by path. A windowed - * path that omitted its offset would make page 2 a cache HIT on page 1 — a scroll that fetches, - * succeeds, and paints the same rows forever. Rather than remember to key the cache on - * "path + offset + predicate" at each call site, ONE function builds the string and the caller - * uses it for both. They cannot disagree because they are the same value. - * - * ⛔ THE PREDICATE RIDES AS THE CLIENT'S OWN VOCABULARY. `filters` is `ViewConfig.filters` - * (a `FilterNode[]`), `filterConj` is `ViewConfig.filterConj`, `sorts` is `ViewConfig.sorts` — - * JSON, verbatim, no translation layer. D built `compile_filter_tree` to take exactly these - * objects, and an unparseable `filters` is answered with a 400 rather than "no filter", - * because a condition that is quietly dropped WIDENS and a wider answer looks plausible. - * - * ⚠ Empty parts are OMITTED, not sent blank: a view with no filter must produce the identical - * path the first (predicate-free) window used, or the very first scroll misses the cache. - */ -export function windowRowsPath(tableKey: string, req: RowWindowRequest): string { - const q = new URLSearchParams(); - q.set("offset", String(Math.max(0, Math.floor(req.offset || 0)))); - q.set("limit", String(Math.max(1, Math.floor(req.limit || WINDOW_ROWS)))); - if (req.filters && req.filters.length) { - q.set("filters", JSON.stringify(req.filters)); - if (req.filterConj === "or") q.set("filterConj", "or"); - } - if (req.sorts && req.sorts.length) q.set("sorts", JSON.stringify(req.sorts)); - const search = (req.search ?? "").trim(); - if (search) q.set("search", search); - return `odoo-tables/${encodeURIComponent(tableKey)}/rows?${q.toString()}`; -} - -/** - * D's window envelope → the payload this client already knows how to render. - * - * ⛔ `windowed` IS TRUE FOR EVERY READ-THROUGH RESPONSE, INCLUDING ONE THAT HAPPENS TO HOLD THE - * WHOLE TABLE, and that is not sloppiness. The mode is about WHO EVALUATES THE PREDICATE, not - * about whether this particular response was truncated. A 192-row table answered in full still - * had its filter compiled to SQL — flipping to `whole-book` because nothing was cut would - * switch the TypeScript engine back on and filter the server's already-filtered rows a second - * time. `countLabel` handles the no-truncation case honestly on its own (`shown >= matched` - * renders a plain count with no "showing … of"), so nothing is lost by keeping the mode stable. - * - * Returns null on a shape it cannot read — the caller raises the signal, because a payload the - * client cannot parse is a failure, never an empty table. - */ -export function windowPayload(body: unknown): CustomersPayload | null { - const b = body as { - fields?: unknown; rows?: unknown; total?: unknown; totalUnfiltered?: unknown; - limits?: unknown; today?: unknown; recordsMutable?: unknown; - } | null; - if (!b || !Array.isArray(b.rows) || !Array.isArray(b.fields)) return null; - if (typeof b.total !== "number" || !Number.isFinite(b.total)) return null; - const rows = b.rows as Row[]; - const unfiltered = - typeof b.totalUnfiltered === "number" && Number.isFinite(b.totalUnfiltered) - ? b.totalUnfiltered - : b.total; - const out: CustomersPayload = { - fields: b.fields as Field[], - rows, - counts: { - shown: rows.length, - // ⛔ FROM THE SERVER'S COUNT STATEMENT. Never `rows.length`. - matched: b.total, - total: unfiltered, - windowed: true, - }, - }; - if (Array.isArray(b.limits)) out.limits = b.limits as GridLimit[]; - if (typeof b.today === "string") out.today = b.today; - if (typeof b.recordsMutable === "boolean") out.recordsMutable = b.recordsMutable; - return out; -} - -/** - * Fold a freshly-fetched window into what this browser already holds. - * - * ⛔ `offset === 0` REPLACES BUT MUST NOT BLANK THE WORKSPACE. A predicate change re-requests - * from the top, and the window route serves rows — it knows nothing about saved views, folders, - * cohorts, measures, documents or the viewer, all of which `withWorkspace` merged into the live - * payload from a different call. Returning the bare response would empty the Views sidebar on - * every keystroke in the search box. So the window's keys land OVER the existing payload. - * - * ⛔ AN APPEND WHOSE OFFSET DOES NOT MEET THE LOADED COUNT IS DROPPED, NOT GUESSED. Windows are - * contiguous under a total order; a page that starts anywhere else is a response to a question - * this browser no longer holds the answer to (a predicate changed mid-flight, two scrolls - * raced). Appending it anyway would duplicate or skip rows — data the user sees, with nothing - * erroring. Dropping it costs one scroll's latency and the next event re-asks. - */ -export function mergeWindow( - prev: CustomersPayload | null, - next: CustomersPayload, - offset: number -): CustomersPayload | null { - if (offset <= 0) return { ...(prev ?? {}), ...next }; - if (!prev || !prev.counts?.windowed) return prev; - if (offset !== prev.rows.length) return prev; - const rows = [...prev.rows, ...next.rows]; - return { - ...prev, - rows, - limits: next.limits, - // The NEWEST scope-wide numbers win (the table can move under a long scroll); only `shown` - // is ours, because only this browser knows how much of it is actually here. - counts: { ...(next.counts ?? prev.counts), shown: rows.length }, - }; -} - -/** - * Which of this tenant's tables are served THROUGH the mirror — asked once per session. - * - * ⛔ READ FROM THE SERVER, NEVER A KEY LIST HERE. The bindings convert one bucket at a time - * (`GRID_SOURCES`), and `odoo-tables/status` reports `readThrough` per table for exactly this - * reason. A hard-coded list in the client would go stale on D's next spec row and fail in the - * dangerous direction — asking the window route for a table it cannot serve, which answers 409. - * - * ⚠ `bound_not_declared` keys are DELIBERATELY not here: they ride a separate map on that - * response because a binding whose field declaration has not landed answers 404 on the rows - * route. Reading only `tables` is what keeps a half-shipped grid out of windowed mode. - * - * ⛔ FAIL-SOFT, AND IN THE SAFE DIRECTION. Any failure yields the empty set, i.e. today's - * whole-book behaviour, and the memo is NOT poisoned — a transient 500 must not pin a session - * into the slow path for as long as the tab is open. - */ -let readThroughMemo: Promise> | null = null; - -export function readThroughTables(): Promise> { - if (!readThroughMemo) { - readThroughMemo = (async () => { - try { +import { CUSTOMER_TOPIC } from "./types"; +import type { CustomersPayload, Field, FilterNode, GridLimit, GridWorkspace, HostEvent, Row, + SortSpec, TopicConfig } from "./types"; +import type { TsPayload, TsRequest } from "./timeSeriesData"; + +const JSON_HEADERS = { "Content-Type": "application/json" }; + +async function readJson(res: Response): Promise { + try { + return await res.json(); + } catch { + return null; + } +} + +/** 401 is the one status that is never a data problem. Raised once, centrally, + * so every call site cannot forget to. Returns true when it handled it. + * The rows caches die with the session — the next signed-in user may not + * be the same person, and a cached book must never cross that boundary. */ +function handledUnauthorized(status: number): boolean { + if (status !== 401) return false; + rowsCache.clear(); + signal(UNAUTHORIZED_EVENT); + return true; +} + +/** + * The session check every authenticated call makes: the 401 above, PLUS the TENANT swap the + * 401 path structurally cannot see. + * + * ⛔ WHY `handledUnauthorized` WAS NEVER ENOUGH. Its own note says the rows cache "dies with the + * session — the next signed-in user may not be the same person". That is the USER boundary, and + * it is enforced on 401. A TENANT swap produces no 401 at all: `aios_session` is one cookie per + * ORIGIN, so signing into another tenant in a second tab repoints this one, and every request + * here keeps returning a cheerful 200 full of somebody else's data. `checkTenant` reads the + * server's own stamp and reloads the frame; see `apiContract.ts::TENANT_HEADER`. + */ +function handledSession(res: Response): boolean { + if (checkTenant(res)) return true; + return handledUnauthorized(res.status); +} + +// --- the READ (CP-B) ------------------------------------------------------- + +/** + * The last good `/customers` payload, reused across surface switches. The rows + * are a 15-minute-cached Odoo pull SERVER-side, but every Customer⇄Cohort + * route change remounts the grid and re-downloaded the whole ~1 MB payload — + * three full transfers in one short session, measured live. Within this window + * a remount reuses the copy; the workspace (the cheap per-scope call) is always + * re-fetched, so views/cohorts stay live. + * + * ⛔ THE CACHE IS SESSION-SCOPED OR IT IS A LEAK. It is cleared on ANY 401 and + * on sign-out/sign-in (Shell calls `clearCustomersCache`) — one browser, two + * accounts, and a cached book served across the boundary would be the exact + * cross-user leak EXIT-3b exists to prevent, client-side this time. + * + * Own edits stay visible ([[the NO-BLIP law]]): `patchCustomer` writes accepted + * values through into the cached rows, so a scope switch after an edit shows + * the edit, not the pre-edit pull. + */ +const CUSTOMERS_FRESH_MS = 5 * 60_000; +// Wave 16 C-TOPIC: ONE cache map keyed by the topic's rows path, so the product pull and the +// customer pull each get the same 5-minute remount window without ever serving each other. +const rowsCache = new Map(); + +/** + * ⭐⭐ WAVE 31 · T21 (owner item 7: *"It still takes a very long time to load from one Database + * into another"*) — THE WORKSPACE ENVELOPE, MEMOISED PER SCOPE. + * + * ⛔ WHAT WAS MISSING, stated exactly: `rowsCache` above has memoised ROWS since EXIT wave 1, and + * `fetchWorkspace` had NO cache of any kind. So switching back to a database whose rows were + * still warm STILL paid a full `/workspace` round trip — and that call is not cheap: it is + * `ut_assembly`, which on tenant #0 reads a **28.6 MB** document (703 ms of deep copy, warm) plus + * the whole `grid_events` workspace build. Measured live on `283b815`: **2,430 ms** for + * `?scope=customer`. Every database→database hop paid it, both directions, for an envelope that + * had not changed. + * + * ⚠ THE FRESHNESS WINDOW IS THE ROWS CACHE'S, ON PURPOSE. Two windows over one surface would + * drift into a grid whose columns are newer than its rows, or the reverse — a shape this repo has + * already paid for. One constant, both memos. + * + * ⛔ AND IT IS EVICTED BY THE WRITE PATH, NOT ONLY BY TIME. Every accepted grid event fires + * `WORKSPACE_STALE_EVENT` → `useCustomerData.reread()`, which calls this with no `allowCached`, + * so it re-fetches AND overwrites this entry. Without that overwrite a person could hide a + * field, switch away, switch back, and be served the pre-write envelope — the write silently + * undone on screen, which is worse than the wait it replaced. + */ +const wsCache = new Map(); + +export function clearWorkspaceCache(): void { + wsCache.clear(); +} + +export function clearCustomersCache(): void { + rowsCache.clear(); + // ⛔ THE ENVELOPE IS SESSION-SCOPED FOR THE SAME REASON THE ROWS ARE, and it carries MORE that + // is per-account than they do: `viewer`, `userOptions`, the user's own views and folders. One + // browser, two accounts, and a cached workspace served across the boundary would show the + // previous person's saved views under the new person's name. + clearWorkspaceCache(); + // ⛔ AND THE READ-THROUGH ROSTER, which is a per-SESSION fact, not a per-topic one. It names + // which tables this tenant serves through the mirror; a second account signing into the same + // browser is a different tenant's answer. Same boundary the rows cache is cleared on, and for + // the same reason. + readThroughMemo = null; +} + +/** + * Drop ONE topic's rows memo. The change poller's companion (wave 29, item 20). + * + * ⛔ NOT `clearCustomersCache()`, and the difference is a megabyte. That clears every topic's + * window, so a poll that noticed a change on the Product grid would also force the Customer pool + * to be re-downloaded on the next surface switch. One bucket changed; one memo is dropped. + * + * ⚠ WAVE 30 — it now sweeps a PREFIX, because one table can have many cache entries. A windowed + * grid's key carries its offset and its predicate (see `windowRowsPath`), so `ut_odoo_orders` + * may hold a dozen. Deleting the bare path would leave every one of them live. + */ +export function clearTopicRowsCache(rowsPath: string): void { + rowsCache.delete(rowsPath); + for (const key of [...rowsCache.keys()]) + if (key.startsWith(`${rowsPath}?`)) rowsCache.delete(key); +} + +/** + * ⭐⭐ WAVE 30 · W30-T42 — DROP EVERY CACHED ROWS RESPONSE FOR ONE TABLE, WHICHEVER DOOR SERVED IT. + * + * ⛔ THE DEFECT THIS PREVENTS, AND IT IS THIS REPO'S NAMED CLASS. Before C2 a table had exactly + * one rows key (`tables//rows`) and eight call sites deleted that literal. A read-through + * grid is served by a DIFFERENT route (`odoo-tables//rows`) under MANY keys (one per + * offset × predicate), so every one of those literals silently stopped invalidating anything — + * a write that returned 200 and a grid that kept painting the pre-write page, with no error + * anywhere. Two invalidation laws for one question is [[one-question-two-normalizers]]; there + * is one law and it lives here. + * + * ⚠ IT MATTERS DESPITE `recordsMutable: false`. A read-through Odoo grid refuses record edits, + * but W30-T28 shipped tenant-wide SHARED COLUMNS on exactly these grids — `PATCH + * /tables/{key}/shared/{pid}` writes a cell that rides the window payload's own `fields`/rows. + */ +export function dropTableRowsCache(tableKey: string): void { + const prefixes = [`tables/${tableKey}/rows`, `odoo-tables/${tableKey}/rows`]; + for (const key of [...rowsCache.keys()]) + if (prefixes.some((p) => key === p || key.startsWith(`${p}?`))) rowsCache.delete(key); +} + +/** + * X2 `GET /api/v1/` → `{fields, rows, today, pulled_at}`, rows already + * scoped to the session's BU pool (EXIT-3b). The cookie replaces HTTP Basic. + * + * Returns null on ANY failure, having raised the matching signal. A null is not + * an empty table: the frame renders a failure, not "this tenant has no + * records", because those two look identical and only one of them is true. + */ +/** + * ⭐ WAVE 30 (D-79's second half) — THE ONE READER OF A SERVER REFUSAL, and the reason it exists. + * + * ⛔ MEASURED: five call sites in this file read `body.detail.message`, and **the server has never + * sent that shape.** `main.py`'s `_error_shape` handler unwraps `deps.err`'s `detail` before the + * response leaves, so every non-2xx body on the wire is `{"error": {"code", "message"}}` — the + * X2 contract, stated in that handler's own docstring. `body.detail` is `undefined` at all five, + * so every carefully-worded refusal in the API ("this database already has a profile column — + * 'Handle'. A database has at most one…") was replaced by "The server answered 400." + * + * That is worse than a missing feature: the product LOOKED like it was explaining itself. Sixteen + * other call sites across the client already read `error.message` correctly, which is why nobody + * noticed — the wrong shape survived only where nobody had recently read a refusal out loud. + * + * ⚠ THE `detail` LEG STAYS, AS A STRING. FastAPI's own `RequestValidationError` does NOT pass + * through `_error_shape`, so a 422 from a malformed body still arrives shaped `{"detail": …}`. + * Reading it as a string is honest; reading `.message` off it never was. + */ +export function refusalMessage(body: unknown, status: number, fallback?: string): string { + const shaped = body as { error?: { message?: unknown }; detail?: unknown } | null | undefined; + const named = shaped?.error?.message; + if (typeof named === "string" && named.trim()) return named; + if (typeof shaped?.detail === "string" && shaped.detail.trim()) return shaped.detail; + return fallback ?? `The server answered ${status}.`; +} + +export async function fetchTopicRows(topic: TopicConfig): Promise { + const cached = rowsCache.get(topic.rowsPath); + if (cached && Date.now() - cached.at < CUSTOMERS_FRESH_MS) { + return cached.payload; + } + let res: Response; + try { + res = await fetch(`${API_V1}/${topic.rowsPath}`, { credentials: CREDENTIALS }); + } catch { + signal(DATA_ERROR_EVENT, "Cannot reach the server."); + return null; + } + if (handledSession(res)) return null; + if (!res.ok) { + // ⭐ WAVE 30 — R's ask, and it is the SIXTH site of the shape T44 closed five of. This path + // answered every non-2xx with a bare status while `refusalMessage` sat 50 lines above it, + // and TWO cause-carrying refusals now arrive here: `store_not_ready` (503, live on a + // connected grid before the mirror's first sync) and the window route's own 409. Both name + // their cause and their fix; both used to render as "The server answered 503." + signal(DATA_ERROR_EVENT, refusalMessage(await readJson(res), res.status)); + return null; + } + const body = (await readJson(res)) as CustomersPayload | null; + if (!body || !Array.isArray(body.rows) || !Array.isArray(body.fields)) { + signal(DATA_ERROR_EVENT, "The server sent an unreadable payload."); + return null; + } + rowsCache.set(topic.rowsPath, { at: Date.now(), payload: body }); + return body; +} + +/** The customer topic's fetch — kept under its own name because half the write-path notes in + * this repo cite it; it IS `fetchTopicRows(CUSTOMER_TOPIC)`. */ +export function fetchCustomers(): Promise { + return fetchTopicRows(CUSTOMER_TOPIC); +} + +// ═══════════════════════════════════════════════════════════════════════════════════════════ +// ⭐⭐ WAVE 30 · W30-T42 — THE READ-THROUGH WINDOW (owner ruling R6 via R7, contract C2). +// ═══════════════════════════════════════════════════════════════════════════════════════════ +// +// THE SHAPE OF THE PROBLEM, in the owner's numbers. `tables//rows` serves a table by +// copying it out of the tenant's `user_tables` document, which is why `MAX_ROWS = 60_000` +// exists and why order lines (255,286) and GL lines (963,783) could never be grids at all. +// W30-T26 built the other door: `odoo-tables//rows` reads the DuckDB mirror and answers +// `{rows, total, totalUnfiltered, offset, limit, limits}` — the requested slice plus a +// `SELECT count(*)` that tells the truth about the whole. +// +// ⛔ THE CLIENT MACHINERY FOR THIS ALREADY EXISTED AND WAS DORMANT — `TableMode`, +// `ScopeCounts`, `countLabel`'s "showing 200 of 201,558", `useVisibleRows`' pass-through and +// every `serverWindowed` guard in `CustomerGrid`. What did not exist was anything that ever +// SET `counts.windowed`: a mode reachable only by a payload no fetch path had ever built. So +// what is added below is a fetch and a mapping, not an engine. +// +// ⛔ `counts.matched` IS THE SERVER'S `total`, NEVER `rows.length`. That is the fabricated +// aggregate this repo has paid for twice ([[no-unverifiable-aggregates]]) and it is the ONE +// number the whole contract exists to protect: a 200-row response describing 32,826 orders. + +/** One window's worth of rows. 200 is what D measured the route at (79 ms warm on 32,700 + * orders, against 474 ms for the whole read); the server clamps anything over + * `datastore.WINDOW_MAX` (5,000) and REPORTS the clamp rather than trimming quietly. */ +export const WINDOW_ROWS = 200; + +export interface RowWindowRequest { + offset: number; + limit: number; + /** The saved view's OWN objects. ⛔ Not translated — see `windowRowsPath`. */ + filters?: FilterNode[] | null; + filterConj?: string; + sorts?: SortSpec | null; + search?: string; +} + +/** + * The request path for one window — AND, deliberately, its cache key. + * + * ⛔ THE TICKET'S NAMED TRAP, SOLVED BY CONSTRUCTION. `rowsCache` is keyed by path. A windowed + * path that omitted its offset would make page 2 a cache HIT on page 1 — a scroll that fetches, + * succeeds, and paints the same rows forever. Rather than remember to key the cache on + * "path + offset + predicate" at each call site, ONE function builds the string and the caller + * uses it for both. They cannot disagree because they are the same value. + * + * ⛔ THE PREDICATE RIDES AS THE CLIENT'S OWN VOCABULARY. `filters` is `ViewConfig.filters` + * (a `FilterNode[]`), `filterConj` is `ViewConfig.filterConj`, `sorts` is `ViewConfig.sorts` — + * JSON, verbatim, no translation layer. D built `compile_filter_tree` to take exactly these + * objects, and an unparseable `filters` is answered with a 400 rather than "no filter", + * because a condition that is quietly dropped WIDENS and a wider answer looks plausible. + * + * ⚠ Empty parts are OMITTED, not sent blank: a view with no filter must produce the identical + * path the first (predicate-free) window used, or the very first scroll misses the cache. + */ +export function windowRowsPath(tableKey: string, req: RowWindowRequest): string { + const q = new URLSearchParams(); + q.set("offset", String(Math.max(0, Math.floor(req.offset || 0)))); + q.set("limit", String(Math.max(1, Math.floor(req.limit || WINDOW_ROWS)))); + if (req.filters && req.filters.length) { + q.set("filters", JSON.stringify(req.filters)); + if (req.filterConj === "or") q.set("filterConj", "or"); + } + if (req.sorts && req.sorts.length) q.set("sorts", JSON.stringify(req.sorts)); + const search = (req.search ?? "").trim(); + if (search) q.set("search", search); + return `odoo-tables/${encodeURIComponent(tableKey)}/rows?${q.toString()}`; +} + +/** + * D's window envelope → the payload this client already knows how to render. + * + * ⛔ `windowed` IS TRUE FOR EVERY READ-THROUGH RESPONSE, INCLUDING ONE THAT HAPPENS TO HOLD THE + * WHOLE TABLE, and that is not sloppiness. The mode is about WHO EVALUATES THE PREDICATE, not + * about whether this particular response was truncated. A 192-row table answered in full still + * had its filter compiled to SQL — flipping to `whole-book` because nothing was cut would + * switch the TypeScript engine back on and filter the server's already-filtered rows a second + * time. `countLabel` handles the no-truncation case honestly on its own (`shown >= matched` + * renders a plain count with no "showing … of"), so nothing is lost by keeping the mode stable. + * + * Returns null on a shape it cannot read — the caller raises the signal, because a payload the + * client cannot parse is a failure, never an empty table. + */ +export function windowPayload(body: unknown): CustomersPayload | null { + const b = body as { + fields?: unknown; rows?: unknown; total?: unknown; totalUnfiltered?: unknown; + limits?: unknown; today?: unknown; recordsMutable?: unknown; + } | null; + if (!b || !Array.isArray(b.rows) || !Array.isArray(b.fields)) return null; + if (typeof b.total !== "number" || !Number.isFinite(b.total)) return null; + const rows = b.rows as Row[]; + const unfiltered = + typeof b.totalUnfiltered === "number" && Number.isFinite(b.totalUnfiltered) + ? b.totalUnfiltered + : b.total; + const out: CustomersPayload = { + fields: b.fields as Field[], + rows, + counts: { + shown: rows.length, + // ⛔ FROM THE SERVER'S COUNT STATEMENT. Never `rows.length`. + matched: b.total, + total: unfiltered, + windowed: true, + }, + }; + if (Array.isArray(b.limits)) out.limits = b.limits as GridLimit[]; + if (typeof b.today === "string") out.today = b.today; + if (typeof b.recordsMutable === "boolean") out.recordsMutable = b.recordsMutable; + return out; +} + +/** + * Fold a freshly-fetched window into what this browser already holds. + * + * ⛔ `offset === 0` REPLACES BUT MUST NOT BLANK THE WORKSPACE. A predicate change re-requests + * from the top, and the window route serves rows — it knows nothing about saved views, folders, + * cohorts, measures, documents or the viewer, all of which `withWorkspace` merged into the live + * payload from a different call. Returning the bare response would empty the Views sidebar on + * every keystroke in the search box. So the window's keys land OVER the existing payload. + * + * ⛔ AN APPEND WHOSE OFFSET DOES NOT MEET THE LOADED COUNT IS DROPPED, NOT GUESSED. Windows are + * contiguous under a total order; a page that starts anywhere else is a response to a question + * this browser no longer holds the answer to (a predicate changed mid-flight, two scrolls + * raced). Appending it anyway would duplicate or skip rows — data the user sees, with nothing + * erroring. Dropping it costs one scroll's latency and the next event re-asks. + */ +export function mergeWindow( + prev: CustomersPayload | null, + next: CustomersPayload, + offset: number +): CustomersPayload | null { + if (offset <= 0) return { ...(prev ?? {}), ...next }; + if (!prev || !prev.counts?.windowed) return prev; + if (offset !== prev.rows.length) return prev; + const rows = [...prev.rows, ...next.rows]; + return { + ...prev, + rows, + limits: next.limits, + // The NEWEST scope-wide numbers win (the table can move under a long scroll); only `shown` + // is ours, because only this browser knows how much of it is actually here. + counts: { ...(next.counts ?? prev.counts), shown: rows.length }, + }; +} + +/** + * Which of this tenant's tables are served THROUGH the mirror — asked once per session. + * + * ⛔ READ FROM THE SERVER, NEVER A KEY LIST HERE. The bindings convert one bucket at a time + * (`GRID_SOURCES`), and `odoo-tables/status` reports `readThrough` per table for exactly this + * reason. A hard-coded list in the client would go stale on D's next spec row and fail in the + * dangerous direction — asking the window route for a table it cannot serve, which answers 409. + * + * ⚠ `bound_not_declared` keys are DELIBERATELY not here: they ride a separate map on that + * response because a binding whose field declaration has not landed answers 404 on the rows + * route. Reading only `tables` is what keeps a half-shipped grid out of windowed mode. + * + * ⛔ FAIL-SOFT, AND IN THE SAFE DIRECTION. Any failure yields the empty set, i.e. today's + * whole-book behaviour, and the memo is NOT poisoned — a transient 500 must not pin a session + * into the slow path for as long as the tab is open. + */ +let readThroughMemo: Promise> | null = null; + +export function readThroughTables(): Promise> { + if (!readThroughMemo) { + readThroughMemo = (async () => { + try { // The grid needs only the read-through flag, never the operator status's counts/stamps. // `brief=1` is server-derived from the connected-grid registry and reads no table rows. const res = await fetch(`${API_V1}/odoo-tables/status?brief=1`, { credentials: CREDENTIALS }); - if (handledSession(res) || !res.ok) { - readThroughMemo = null; - return new Set(); - } - const body = (await readJson(res)) as - { tables?: Record } | null; - const out = new Set(); - for (const [key, t] of Object.entries(body?.tables ?? {})) - if (t?.readThrough === true) out.add(key); - return out; - } catch { - readThroughMemo = null; - return new Set(); - } - })(); - } - return readThroughMemo; -} - -/** `GET /api/v1/odoo-tables//rows?…` — one window, mapped. Null on any failure, having - * raised the matching signal (a null is a failure, never an empty table). */ -export async function fetchTableWindow( - tableKey: string, - req: RowWindowRequest -): Promise { - const path = windowRowsPath(tableKey, req); - const cached = rowsCache.get(path); - if (cached && Date.now() - cached.at < CUSTOMERS_FRESH_MS) return cached.payload; - let res: Response; - try { - res = await fetch(`${API_V1}/${path}`, { credentials: CREDENTIALS }); - } catch { - signal(DATA_ERROR_EVENT, "Cannot reach the server."); - return null; - } - if (handledSession(res)) return null; - if (!res.ok) { - // ⚠ THE SERVER'S OWN SENTENCE, NOT A STATUS CODE. `filter_unsupported` explains which - // condition has no SQL form and what to use instead; "The server answered 400." explains - // nothing, which is the defect `refusalMessage` was extracted for one wave ago. - const body = await readJson(res); - signal(DATA_ERROR_EVENT, refusalMessage(body, res.status)); - return null; - } - const payload = windowPayload(await readJson(res)); - if (!payload) { - signal(DATA_ERROR_EVENT, "The server sent an unreadable payload."); - return null; - } - rowsCache.set(path, { at: Date.now(), payload }); - return payload; -} - -/** - * `GET /api/v1/workspace` — the saved views, custom fields, folders and cohorts - * for the session user (S1's amendment, 2026-07-30). It exists because X2 pins - * `/customers` at the EXACT current shape, which carries no `workspace`, and - * without one the standalone write path is WRITE-ONLY: a saved view reaches the - * store and is gone from the screen on reload. - * - * The body is `{workspace: GridWorkspace}` — the client's own built type - * (types.ts `GridWorkspace`), which is what the embed's host already projects. - * - * ⚠ ABSENT IS FINE AND MEANS TODAY'S BEHAVIOUR. A 404 (route not shipped) or a - * body carrying no workspace return null and the grid runs exactly as it does - * now — views in memory, no `storageKey`, nothing durable. It degrades to the - * honest gap rather than failing the whole read, because the workspace is an - * enhancement of the table, not a precondition for it. A 401 still ends the - * session: that is the one status that is never about this route. - * - * ⛔ WAVE 21 item 3 (3c) — BUT A REFUSAL IS NOT AN ABSENCE, and conflating the - * two is half of the "RI fields on a new database" report. - * - * This used to answer `null` to every unhappy path alike: route-not-shipped, - * 403, 500, connection refused, garbage body. The caller cannot tell those - * apart, so it did the only thing it could — carried on without a workspace — - * and `CustomerGrid` then had no `storageKey` and fell back to a bucket shared - * with every other surface (see the note at its `storageKey`). A tenant whose - * `/workspace` 403s for `ut_*` therefore got ANOTHER TABLE'S views and fields, - * silently, with the grid looking entirely healthy. - * - * `announceFailure` is the caller saying "I am the INITIAL load; if this fails - * for a reason that is not 'no workspace here', say so". It raises the same - * `DATA_ERROR_EVENT` a failed rows read raises, so the frame renders its honest - * failure card instead of a grid furnished with somebody else's schema. - * - * ⚠ IT IS OFF BY DEFAULT, and that is load-bearing rather than cautious. - * `reread()` calls this on every workspace-stale signal, and its own rule is - * "absent stays absent — never blank a live panel"; a signal from there would - * let one dropped packet replace a working table with an error card. The flag - * is passed explicitly at the one call site that owns the first paint. - */ -export async function fetchWorkspace( - scope: SurfaceScope = "customer", - opts: { announceFailure?: boolean; allowCached?: boolean } = {}, -): Promise { - const loud = opts.announceFailure === true; - // ⭐ W31-T21 — OPT IN, NEVER BY DEFAULT. Only the first-paint read of a surface may be served - // from the memo; the post-write `reread()` calls this with no options and therefore always goes - // to the server, which is what keeps the memo from becoming a second source of truth. - if (opts.allowCached === true) { - const hit = wsCache.get(scope); - if (hit && Date.now() - hit.at < CUSTOMERS_FRESH_MS) return hit.ws; - } - let res: Response; - try { - res = await fetch(`${API_V1}/workspace?scope=${encodeURIComponent(scope)}`, - { credentials: CREDENTIALS }); - } catch { - if (loud) signal(DATA_ERROR_EVENT, "Cannot reach the server."); - return null; - } - if (handledSession(res)) return null; - if (!res.ok) { - // 404 is the ONE status that means "this host does not serve a workspace", - // which is the enhancement posture above and stays quiet. Everything else - // is a server that had an answer and would not give it. - if (loud && res.status !== 404) - // ⭐ WAVE 30 — the server's own sentence when it wrote one, this route's specific fallback - // when it did not. Same repair as the rows path above; `refusalMessage`'s fallback argument - // exists precisely so a call site with a better default keeps it. - signal(DATA_ERROR_EVENT, refusalMessage( - await readJson(res), res.status, - `The server answered ${res.status} for this table's saved views and columns.`)); - return null; - } - const ws = (await readJson(res)) as { workspace?: GridWorkspace } | null; - const w = ws?.workspace; - // A 200 carrying no workspace is still an ABSENCE, not a refusal — an older - // host, or a scope this one has nothing stored for. Unchanged, and quiet. - const good = w && typeof w.storageKey === "string" && Array.isArray(w.views) ? w : null; - // ⭐ W31-T21 — EVERY SUCCESSFUL READ REFRESHES THE MEMO, including the post-write `reread()` - // that is not allowed to CONSUME it. That asymmetry is the whole safety argument: the write - // path can never leave a stale envelope behind for the next switch to serve. - // ⚠ A FAILURE LEAVES THE PREVIOUS ENTRY ALONE rather than poisoning it with `null` — the same - // "absent stays absent, never blank a live panel" posture `reread()` already takes. - if (good) wsCache.set(scope, { at: Date.now(), ws: good }); - return good; -} - -// --- the CHANGE POLLER (wave 29, item 20 / R11 / contract C6) --------------- - -/** - * `GET /api/v1/changes?scope=…` → `{bucket: opaque token}`, or null. - * - * ⚠ A FAILED POLL IS SILENT. It raises no `DATA_ERROR_EVENT`, because the frame REPLACES the whole - * surface with an error card on that signal — so one dropped packet, six times a minute, would turn - * a working grid into a failure screen. `changedBuckets` reads a null as "no baseline, no change" - * and the tab simply carries on with what it has. 401 still ends the session: that is the one - * status which is never about this route. - */ -export async function fetchChangeTokens(scope: string): Promise { - let res: Response; - try { + if (handledSession(res) || !res.ok) { + readThroughMemo = null; + return new Set(); + } + const body = (await readJson(res)) as + { tables?: Record } | null; + const out = new Set(); + for (const [key, t] of Object.entries(body?.tables ?? {})) + if (t?.readThrough === true) out.add(key); + return out; + } catch { + readThroughMemo = null; + return new Set(); + } + })(); + } + return readThroughMemo; +} + +/** `GET /api/v1/odoo-tables//rows?…` — one window, mapped. Null on any failure, having + * raised the matching signal (a null is a failure, never an empty table). */ +export async function fetchTableWindow( + tableKey: string, + req: RowWindowRequest +): Promise { + const path = windowRowsPath(tableKey, req); + const cached = rowsCache.get(path); + if (cached && Date.now() - cached.at < CUSTOMERS_FRESH_MS) return cached.payload; + let res: Response; + try { + res = await fetch(`${API_V1}/${path}`, { credentials: CREDENTIALS }); + } catch { + signal(DATA_ERROR_EVENT, "Cannot reach the server."); + return null; + } + if (handledSession(res)) return null; + if (!res.ok) { + // ⚠ THE SERVER'S OWN SENTENCE, NOT A STATUS CODE. `filter_unsupported` explains which + // condition has no SQL form and what to use instead; "The server answered 400." explains + // nothing, which is the defect `refusalMessage` was extracted for one wave ago. + const body = await readJson(res); + signal(DATA_ERROR_EVENT, refusalMessage(body, res.status)); + return null; + } + const payload = windowPayload(await readJson(res)); + if (!payload) { + signal(DATA_ERROR_EVENT, "The server sent an unreadable payload."); + return null; + } + rowsCache.set(path, { at: Date.now(), payload }); + return payload; +} + +/** + * `GET /api/v1/workspace` — the saved views, custom fields, folders and cohorts + * for the session user (S1's amendment, 2026-07-30). It exists because X2 pins + * `/customers` at the EXACT current shape, which carries no `workspace`, and + * without one the standalone write path is WRITE-ONLY: a saved view reaches the + * store and is gone from the screen on reload. + * + * The body is `{workspace: GridWorkspace}` — the client's own built type + * (types.ts `GridWorkspace`), which is what the embed's host already projects. + * + * ⚠ ABSENT IS FINE AND MEANS TODAY'S BEHAVIOUR. A 404 (route not shipped) or a + * body carrying no workspace return null and the grid runs exactly as it does + * now — views in memory, no `storageKey`, nothing durable. It degrades to the + * honest gap rather than failing the whole read, because the workspace is an + * enhancement of the table, not a precondition for it. A 401 still ends the + * session: that is the one status that is never about this route. + * + * ⛔ WAVE 21 item 3 (3c) — BUT A REFUSAL IS NOT AN ABSENCE, and conflating the + * two is half of the "RI fields on a new database" report. + * + * This used to answer `null` to every unhappy path alike: route-not-shipped, + * 403, 500, connection refused, garbage body. The caller cannot tell those + * apart, so it did the only thing it could — carried on without a workspace — + * and `CustomerGrid` then had no `storageKey` and fell back to a bucket shared + * with every other surface (see the note at its `storageKey`). A tenant whose + * `/workspace` 403s for `ut_*` therefore got ANOTHER TABLE'S views and fields, + * silently, with the grid looking entirely healthy. + * + * `announceFailure` is the caller saying "I am the INITIAL load; if this fails + * for a reason that is not 'no workspace here', say so". It raises the same + * `DATA_ERROR_EVENT` a failed rows read raises, so the frame renders its honest + * failure card instead of a grid furnished with somebody else's schema. + * + * ⚠ IT IS OFF BY DEFAULT, and that is load-bearing rather than cautious. + * `reread()` calls this on every workspace-stale signal, and its own rule is + * "absent stays absent — never blank a live panel"; a signal from there would + * let one dropped packet replace a working table with an error card. The flag + * is passed explicitly at the one call site that owns the first paint. + */ +export async function fetchWorkspace( + scope: SurfaceScope = "customer", + opts: { announceFailure?: boolean; allowCached?: boolean } = {}, +): Promise { + const loud = opts.announceFailure === true; + // ⭐ W31-T21 — OPT IN, NEVER BY DEFAULT. Only the first-paint read of a surface may be served + // from the memo; the post-write `reread()` calls this with no options and therefore always goes + // to the server, which is what keeps the memo from becoming a second source of truth. + if (opts.allowCached === true) { + const hit = wsCache.get(scope); + if (hit && Date.now() - hit.at < CUSTOMERS_FRESH_MS) return hit.ws; + } + let res: Response; + try { + res = await fetch(`${API_V1}/workspace?scope=${encodeURIComponent(scope)}`, + { credentials: CREDENTIALS }); + } catch { + if (loud) signal(DATA_ERROR_EVENT, "Cannot reach the server."); + return null; + } + if (handledSession(res)) return null; + if (!res.ok) { + // 404 is the ONE status that means "this host does not serve a workspace", + // which is the enhancement posture above and stays quiet. Everything else + // is a server that had an answer and would not give it. + if (loud && res.status !== 404) + // ⭐ WAVE 30 — the server's own sentence when it wrote one, this route's specific fallback + // when it did not. Same repair as the rows path above; `refusalMessage`'s fallback argument + // exists precisely so a call site with a better default keeps it. + signal(DATA_ERROR_EVENT, refusalMessage( + await readJson(res), res.status, + `The server answered ${res.status} for this table's saved views and columns.`)); + return null; + } + const ws = (await readJson(res)) as { workspace?: GridWorkspace } | null; + const w = ws?.workspace; + // A 200 carrying no workspace is still an ABSENCE, not a refusal — an older + // host, or a scope this one has nothing stored for. Unchanged, and quiet. + const good = w && typeof w.storageKey === "string" && Array.isArray(w.views) ? w : null; + // ⭐ W31-T21 — EVERY SUCCESSFUL READ REFRESHES THE MEMO, including the post-write `reread()` + // that is not allowed to CONSUME it. That asymmetry is the whole safety argument: the write + // path can never leave a stale envelope behind for the next switch to serve. + // ⚠ A FAILURE LEAVES THE PREVIOUS ENTRY ALONE rather than poisoning it with `null` — the same + // "absent stays absent, never blank a live panel" posture `reread()` already takes. + if (good) wsCache.set(scope, { at: Date.now(), ws: good }); + return good; +} + +// --- the CHANGE POLLER (wave 29, item 20 / R11 / contract C6) --------------- + +/** + * `GET /api/v1/changes?scope=…` → `{bucket: opaque token}`, or null. + * + * ⚠ A FAILED POLL IS SILENT. It raises no `DATA_ERROR_EVENT`, because the frame REPLACES the whole + * surface with an error card on that signal — so one dropped packet, six times a minute, would turn + * a working grid into a failure screen. `changedBuckets` reads a null as "no baseline, no change" + * and the tab simply carries on with what it has. 401 still ends the session: that is the one + * status which is never about this route. + */ +export async function fetchChangeTokens(scope: string): Promise { + let res: Response; + try { res = await fetch(`${API_V1}/changes?scope=${encodeURIComponent(scope)}`, // This header marks a current, user-return check. Legacy bundles lack it, // so their former 10-second requests remain database-free on Pg. { credentials: CREDENTIALS, headers: { "X-AIOS-Change-Check": "focus-v1" } }); - } catch { - return null; - } - if (handledSession(res)) return null; - if (!res.ok) return null; - const body = (await readJson(res)) as { tokens?: unknown } | null; - const tokens = body?.tokens; - if (!tokens || typeof tokens !== "object" || Array.isArray(tokens)) return null; - const out: ChangeTokens = {}; - for (const [bucket, token] of Object.entries(tokens as Record)) - out[bucket] = typeof token === "string" ? token : null; - return out; -} - + } catch { + return null; + } + if (handledSession(res)) return null; + if (!res.ok) return null; + const body = (await readJson(res)) as { tokens?: unknown } | null; + const tokens = body?.tokens; + if (!tokens || typeof tokens !== "object" || Array.isArray(tokens)) return null; + const out: ChangeTokens = {}; + for (const [bucket, token] of Object.entries(tokens as Record)) + out[bucket] = typeof token === "string" ? token : null; + return out; +} + /** * A grid reloads only when the person returns to the tab or window. There is no timer and no * background token request. @@ -624,12 +624,12 @@ interface ChangeWatch { tokens: ChangeTokens | null; inFlight: boolean; refresh: () => Promise; - /** ⭐ ONE poll implementation per watch, held here so the interval and the visibility handler - * call the SAME function. The visibility handler used to re-inline the body, which quietly - * skipped `inFlight` — so returning to a tab whose poll was still outstanding stacked a second - * request on top of it, exactly what that guard exists to prevent. */ -} - + /** ⭐ ONE poll implementation per watch, held here so the interval and the visibility handler + * call the SAME function. The visibility handler used to re-inline the body, which quietly + * skipped `inFlight` — so returning to a tab whose poll was still outstanding stacked a second + * request on top of it, exactly what that guard exists to prevent. */ +} + const changeWatches = new Map(); let visibilityBound = false; function refreshVisibleChanges(): void { @@ -637,22 +637,22 @@ function refreshVisibleChanges(): void { for (const watched of changeWatches.values()) void watched.refresh(); } - -/** - * Watch one scope for server-side changes. Returns its unsubscribe. - * + +/** + * Watch one scope for server-side changes. Returns its unsubscribe. + * * ⭐ ONE WATCH PER SCOPE, REFCOUNTED. A linked-record grid can mount a second `useCustomerData` on * the same surface, and both listeners share one mount baseline and one return-to-tab refresh. * * ⛔ IT NEVER POLLS. Own writes update the current tab from their response; a change made elsewhere * is checked when the person returns to this tab or focuses its window. A forgotten tab left open * overnight creates no database traffic. - */ -export function subscribeChanges(scope: string, - onChange: (changed: string[]) => void): () => void { - if (typeof window === "undefined") return () => {}; - let watch = changeWatches.get(scope); - if (!watch) { + */ +export function subscribeChanges(scope: string, + onChange: (changed: string[]) => void): () => void { + if (typeof window === "undefined") return () => {}; + let watch = changeWatches.get(scope); + if (!watch) { const w0: ChangeWatch = { listeners: new Set(), tokens: null, inFlight: false, refresh: async () => { @@ -669,738 +669,769 @@ export function subscribeChanges(scope: string, } }, }; - watch = w0; - changeWatches.set(scope, w0); - } + watch = w0; + changeWatches.set(scope, w0); + } const w = watch; w.listeners.add(onChange); // One tiny revision baseline after the necessary initial data load lets later user returns // preserve the browser cache when nothing changed. It is never an interval. if (w.tokens === null && !w.inFlight) void w.refresh(); - + if (!visibilityBound) { visibilityBound = true; window.addEventListener("visibilitychange", refreshVisibleChanges); window.addEventListener("focus", refreshVisibleChanges); } - - return () => { - w.listeners.delete(onChange); + + return () => { + w.listeners.delete(onChange); if (w.listeners.size === 0) { - // ⚠ The tokens are DROPPED with the last listener. A remount must re-baseline rather than + // ⚠ The tokens are DROPPED with the last listener. A remount must re-baseline rather than // compare against a token from before it was unmounted. changeWatches.delete(scope); - } - }; -} - -/** - * X2 `PATCH /api/v1//{pid}` — the overlay stratum only (Odoo is never - * written). Kept as its own route rather than folded into the events log - * because X2 keeps it: cell edits are the one write with a per-row identity and - * a rollback the caller already implements. - */ -export async function patchTopicRow( - topic: TopicConfig, - pid: number, - updates: Partial, - /** - * ⭐ Wave-25 (C3/R6) — what the SERVER says this row now holds, for callers that keep an - * optimistic copy. Two things a cell PATCH can now report that the request cannot predict: - * · a CANONICALISED value — a profile column stores `nurilab` for `@Nurilab` or a pasted - * profile URL, so the optimistic copy is a value the store never took; - * · CLEARED cells — blanking a profile handle also clears that row's enriched columns, and - * the client never typed those, so nothing else would ever repaint them. - * An optional callback rather than a richer return type, deliberately: the boolean IS the - * rollback contract every existing caller is written against, and widening it would make the - * two call sites that ignore this the ones most likely to get it wrong. - */ - onAccepted?: (accepted: Partial, cleared: string[]) => void, -): Promise { - try { - const res = await fetch(`${API_V1}/${topic.rowsPath}/${pid}`, { - method: "PATCH", - credentials: CREDENTIALS, - headers: JSON_HEADERS, - body: JSON.stringify(updates), - }); - if (handledSession(res)) return false; - const cached = rowsCache.get(topic.rowsPath); - if (res.ok) { - // Write the ACCEPTED values (the server's own report, not the request) - // through into the cached rows, so a remount within the cache window - // shows this edit instead of the pre-edit pull. - const body = (await readJson(res)) as - { updates?: Record; cleared?: unknown } | null; - const accepted = body?.updates; - const cleared = Array.isArray(body?.cleared) - ? (body.cleared as unknown[]).filter((k): k is string => typeof k === "string") - : []; - const blanks: Partial = {}; - for (const k of cleared) blanks[k] = ""; - if (accepted && typeof accepted === "object") { - const row = cached?.payload.rows.find((r) => r.pid === pid); - if (row) Object.assign(row, accepted, blanks); - onAccepted?.({ ...(accepted as Partial), ...blanks }, cleared); - } - // A user-table edit may change a reciprocal Link or a Rollup in another database. Their - // row payloads share no cache key with this table, so invalidate the user-table family and - // let the active grid re-read the server's materialised relationship cells. - if (topic.rowsPath.startsWith("tables/")) { - // ⚠ WAVE 30 — `odoo-tables/` too. A Link or a Rollup can fold a CONNECTED table's rows - // into this one, so a user-table edit can move a cell on a read-through grid served by - // the other route; sweeping only `tables/` would leave that grid painting the pre-write - // aggregate. The family is the whole rows layer, not one door into it. - for (const key of [...rowsCache.keys()]) - if (key.startsWith("tables/") || key.startsWith("odoo-tables/")) rowsCache.delete(key); - signal(ROWS_STALE_EVENT); - } - } - return res.ok; - } catch { - return false; - } -} - -/** The customer topic's patch, by its historical name. */ -export function patchCustomer(pid: number, updates: Partial): Promise { - return patchTopicRow(CUSTOMER_TOPIC, pid, updates); -} - -/** - * ⭐ Wave-20 owner item 4 / contract C-ADDROW — **APPEND A ROW TO A USER DATABASE**, and - * (C-UNDO) restore a deleted one under its old id. - * - * USER TABLES ONLY, and the refusal is structural rather than checked here: no other scope has - * a `/tables/{key}/rows` endpoint at all. A connector's rows are read-synced from its source — - * R8 is explicit that a "+" which must refuse is a fake affordance, so the caller never renders - * one there. - * - * ⚠ THE ANSWER IS THE ID THAT WAS STORED, never the one that was asked for. An undo that - * requests `rid` may find that id re-used, and the server's own note says it answers with what - * it actually wrote; the caller re-anchors on the returned value rather than assuming. - * - * ⚠ The rows cache is CLEARED on success. `fetchTopicRows` holds a 5-minute window per topic, - * so a re-read straight after an append would serve the payload from before it — the new row - * would appear minutes later, which reads as "the button did nothing". Same reason the shell's - * retired Add-record bar cleared it (wave 20 item 4 moved that door here). - */ -export async function addTableRow( - tableKey: string, - values: Record = {}, - rid?: string | number -): Promise<{ rid: string | number; pid: number } | null> { - try { - const res = await fetch(`${API_V1}/tables/${encodeURIComponent(tableKey)}/rows`, { - method: "POST", - credentials: CREDENTIALS, - headers: JSON_HEADERS, - body: JSON.stringify(rid === undefined ? { values } : { rid, values }), - }); - if (handledSession(res)) return null; - const body = (await readJson(res)) as { rid?: string | number; pid?: number; - error?: { message?: string } } | null; - if (!res.ok) { - // The server states WHY (a row cap, a store refusal). Surfacing its sentence beats a - // generic failure toast, and staying silent would be the worst of the three. - const why = refusalMessage(body, res.status); - signal(TOAST_EVENT, why); - return null; - } - if (body?.rid === undefined || typeof body?.pid !== "number") { - signal(DATA_ERROR_EVENT, "The server did not say which row it created."); - return null; - } - dropTableRowsCache(tableKey); - return { rid: body.rid, pid: body.pid }; - } catch { - signal(DATA_ERROR_EVENT, "Cannot reach the server."); - return null; - } -} - -/** - * ⭐ WAVE 21 item 7 (contract C2) — ADD A COLUMN TO A USER DATABASE'S **DEFINITION**, - * not to this user's overlay. - * - * ⛔ THE BUG THIS EXISTS FOR. Every column the grid creates has always gone out as a - * `field_upsert` EVENT, which lands in the per-user workspace stratum - * (`_table_workspace`). That is right for the connector surfaces, where a custom - * column IS one person's annotation of somebody else's data. It is wrong for a user - * database, where the columns ARE the table — and it is why the automation editor's - * "Automation column" picker was empty by construction: that picker reads the DEFINITION - * (`user_tables`), which the grid had never written to. Two stores, one word, and the two - * surfaces disagreed with no error anywhere. The route has existed since wave 18 with no - * client caller; this is that caller, and `routes_tables.py`'s own comment above it names - * this exact defect. - * - * ⚠ SCOPED TO THE AUTOMATION KIND THIS WAVE (C2, deliberately narrow). Moving EVERY kind - * onto the definition is the right end state and is booked as debt: it changes who can see - * a column (everyone with the table, not just its author), which is a visible change to - * shipped behaviour on three surfaces and does not belong in a wave that is fixing this - * one's blindness. - * - * ⚠ THE SERVER'S FIELD IS THE ANSWER, never the request. `add_field` re-slugs and - * re-types on the way in, so the caller inserts what came BACK — a column ordered under a - * key the store does not hold is a column that renders nowhere. - */ -/** - * ⭐⭐ 2026-08-07 (D-79's last half) — PATCH one column's DEFINITION on a user table. - * - * ⛔ WHY IT EXISTS: wave 25 shipped the profile flag's READER everywhere — the column-menu line, - * the cell validator, R6's clear-on-write, the one-per-table refusal — and never its WRITER. The - * route (`PATCH /tables/{key}/fields/{fkey}`) has accepted `profile` since that wave with no - * client caller, so the enrich action could not be bound to any database a person made - * themselves. This is that caller. - * - * ⚠ THE SERVER'S FIELD IS THE ANSWER, never the request — the same rule `addTableField` carries. - * `_clean_field` enforces `type: 'text'` for a profile flag and refuses a SECOND one with a - * sentence naming the column that already has it, so the refusal is worth surfacing verbatim. - */ -/** - * ⭐ 2026-08-07 — the sibling databases a `link` column may point at, WITH their fields. - * - * `GET /tables` already returns exactly this (key, label, fields) filtered by `may_open`, so - * there is no new route and no new wall: a database you cannot open is a database you cannot - * link to, decided by the same predicate that decides whether you can see it at all. - * - * ⚠ Returns `[]` on any failure rather than throwing. The link editor renders "no other - * databases yet" for an empty list, which is the truthful reading of both an empty workspace - * and an unreachable server — and a create pane that explodes because a picker could not fetch - * is worse than one that says it has nothing to offer. - */ + } + }; +} + +/** + * X2 `PATCH /api/v1//{pid}` — the overlay stratum only (Odoo is never + * written). Kept as its own route rather than folded into the events log + * because X2 keeps it: cell edits are the one write with a per-row identity and + * a rollback the caller already implements. + */ +export async function patchTopicRow( + topic: TopicConfig, + pid: number, + updates: Partial, + /** + * ⭐ Wave-25 (C3/R6) — what the SERVER says this row now holds, for callers that keep an + * optimistic copy. Two things a cell PATCH can now report that the request cannot predict: + * · a CANONICALISED value — a profile column stores `nurilab` for `@Nurilab` or a pasted + * profile URL, so the optimistic copy is a value the store never took; + * · CLEARED cells — blanking a profile handle also clears that row's enriched columns, and + * the client never typed those, so nothing else would ever repaint them. + * An optional callback rather than a richer return type, deliberately: the boolean IS the + * rollback contract every existing caller is written against, and widening it would make the + * two call sites that ignore this the ones most likely to get it wrong. + */ + onAccepted?: (accepted: Partial, cleared: string[]) => void, +): Promise { + try { + const res = await fetch(`${API_V1}/${topic.rowsPath}/${pid}`, { + method: "PATCH", + credentials: CREDENTIALS, + headers: JSON_HEADERS, + body: JSON.stringify(updates), + }); + if (handledSession(res)) return false; + const cached = rowsCache.get(topic.rowsPath); + if (res.ok) { + // Write the ACCEPTED values (the server's own report, not the request) + // through into the cached rows, so a remount within the cache window + // shows this edit instead of the pre-edit pull. + const body = (await readJson(res)) as + { updates?: Record; cleared?: unknown } | null; + const accepted = body?.updates; + const cleared = Array.isArray(body?.cleared) + ? (body.cleared as unknown[]).filter((k): k is string => typeof k === "string") + : []; + const blanks: Partial = {}; + for (const k of cleared) blanks[k] = ""; + if (accepted && typeof accepted === "object") { + const row = cached?.payload.rows.find((r) => r.pid === pid); + if (row) Object.assign(row, accepted, blanks); + onAccepted?.({ ...(accepted as Partial), ...blanks }, cleared); + } + // A user-table edit may change a reciprocal Link or a Rollup in another database. Their + // row payloads share no cache key with this table, so invalidate the user-table family and + // let the active grid re-read the server's materialised relationship cells. + if (topic.rowsPath.startsWith("tables/")) { + // ⚠ WAVE 30 — `odoo-tables/` too. A Link or a Rollup can fold a CONNECTED table's rows + // into this one, so a user-table edit can move a cell on a read-through grid served by + // the other route; sweeping only `tables/` would leave that grid painting the pre-write + // aggregate. The family is the whole rows layer, not one door into it. + for (const key of [...rowsCache.keys()]) + if (key.startsWith("tables/") || key.startsWith("odoo-tables/")) rowsCache.delete(key); + signal(ROWS_STALE_EVENT); + } + } + return res.ok; + } catch { + return false; + } +} + +/** The customer topic's patch, by its historical name. */ +export function patchCustomer(pid: number, updates: Partial): Promise { + return patchTopicRow(CUSTOMER_TOPIC, pid, updates); +} + +/** + * ⭐ Wave-20 owner item 4 / contract C-ADDROW — **APPEND A ROW TO A USER DATABASE**, and + * (C-UNDO) restore a deleted one under its old id. + * + * USER TABLES ONLY, and the refusal is structural rather than checked here: no other scope has + * a `/tables/{key}/rows` endpoint at all. A connector's rows are read-synced from its source — + * R8 is explicit that a "+" which must refuse is a fake affordance, so the caller never renders + * one there. + * + * ⚠ THE ANSWER IS THE ID THAT WAS STORED, never the one that was asked for. An undo that + * requests `rid` may find that id re-used, and the server's own note says it answers with what + * it actually wrote; the caller re-anchors on the returned value rather than assuming. + * + * ⚠ The rows cache is CLEARED on success. `fetchTopicRows` holds a 5-minute window per topic, + * so a re-read straight after an append would serve the payload from before it — the new row + * would appear minutes later, which reads as "the button did nothing". Same reason the shell's + * retired Add-record bar cleared it (wave 20 item 4 moved that door here). + */ +export async function addTableRow( + tableKey: string, + values: Record = {}, + rid?: string | number +): Promise<{ rid: string | number; pid: number } | null> { + try { + const res = await fetch(`${API_V1}/tables/${encodeURIComponent(tableKey)}/rows`, { + method: "POST", + credentials: CREDENTIALS, + headers: JSON_HEADERS, + body: JSON.stringify(rid === undefined ? { values } : { rid, values }), + }); + if (handledSession(res)) return null; + const body = (await readJson(res)) as { rid?: string | number; pid?: number; + error?: { message?: string } } | null; + if (!res.ok) { + // The server states WHY (a row cap, a store refusal). Surfacing its sentence beats a + // generic failure toast, and staying silent would be the worst of the three. + const why = refusalMessage(body, res.status); + signal(TOAST_EVENT, why); + return null; + } + if (body?.rid === undefined || typeof body?.pid !== "number") { + signal(DATA_ERROR_EVENT, "The server did not say which row it created."); + return null; + } + dropTableRowsCache(tableKey); + return { rid: body.rid, pid: body.pid }; + } catch { + signal(DATA_ERROR_EVENT, "Cannot reach the server."); + return null; + } +} + +/** + * ⭐ WAVE 21 item 7 (contract C2) — ADD A COLUMN TO A USER DATABASE'S **DEFINITION**, + * not to this user's overlay. + * + * ⛔ THE BUG THIS EXISTS FOR. Every column the grid creates has always gone out as a + * `field_upsert` EVENT, which lands in the per-user workspace stratum + * (`_table_workspace`). That is right for the connector surfaces, where a custom + * column IS one person's annotation of somebody else's data. It is wrong for a user + * database, where the columns ARE the table — and it is why the automation editor's + * "Automation column" picker was empty by construction: that picker reads the DEFINITION + * (`user_tables`), which the grid had never written to. Two stores, one word, and the two + * surfaces disagreed with no error anywhere. The route has existed since wave 18 with no + * client caller; this is that caller, and `routes_tables.py`'s own comment above it names + * this exact defect. + * + * ⚠ SCOPED TO THE AUTOMATION KIND THIS WAVE (C2, deliberately narrow). Moving EVERY kind + * onto the definition is the right end state and is booked as debt: it changes who can see + * a column (everyone with the table, not just its author), which is a visible change to + * shipped behaviour on three surfaces and does not belong in a wave that is fixing this + * one's blindness. + * + * ⚠ THE SERVER'S FIELD IS THE ANSWER, never the request. `add_field` re-slugs and + * re-types on the way in, so the caller inserts what came BACK — a column ordered under a + * key the store does not hold is a column that renders nowhere. + */ +/** + * ⭐⭐ 2026-08-07 (D-79's last half) — PATCH one column's DEFINITION on a user table. + * + * ⛔ WHY IT EXISTS: wave 25 shipped the profile flag's READER everywhere — the column-menu line, + * the cell validator, R6's clear-on-write, the one-per-table refusal — and never its WRITER. The + * route (`PATCH /tables/{key}/fields/{fkey}`) has accepted `profile` since that wave with no + * client caller, so the enrich action could not be bound to any database a person made + * themselves. This is that caller. + * + * ⚠ THE SERVER'S FIELD IS THE ANSWER, never the request — the same rule `addTableField` carries. + * `_clean_field` enforces `type: 'text'` for a profile flag and refuses a SECOND one with a + * sentence naming the column that already has it, so the refusal is worth surfacing verbatim. + */ +/** + * ⭐ 2026-08-07 — the sibling databases a `link` column may point at, WITH their fields. + * + * `GET /tables` already returns exactly this (key, label, fields) filtered by `may_open`, so + * there is no new route and no new wall: a database you cannot open is a database you cannot + * link to, decided by the same predicate that decides whether you can see it at all. + * + * ⚠ Returns `[]` on any failure rather than throwing. The link editor renders "no other + * databases yet" for an empty list, which is the truthful reading of both an empty workspace + * and an unreachable server — and a create pane that explodes because a picker could not fetch + * is worse than one that says it has nothing to offer. + */ export async function fetchLinkTargets(): Promise { try { // A link picker uses only key/label/schema. `brief=1` keeps the server's table wall but does // not transfer row counts or the row-bearing `user_tables` document for this one field menu. const res = await fetch(`${API_V1}/tables?brief=1`, { credentials: CREDENTIALS }); - if (handledSession(res) || !res.ok) return []; - const body = (await readJson(res)) as { tables?: LinkTarget[] } | null; - return Array.isArray(body?.tables) ? body.tables : []; - } catch { - return []; - } -} - -export interface LinkTarget { - key: string; - label: string; - fields: Field[]; -} - -/** - * ⭐⭐ 2026-08-09 — the READ-THROUGH rollup's offer: which governed Odoo topic, which metric of - * it, grouped by which dimension, over which date window. - * - * ⛔ WHY THIS IS A DIFFERENT LIST FROM `fetchLinkTargets`. A LINK rollup folds rows that live in - * the workspace, so its offer is "the other databases". A SOURCE rollup folds rows that were - * never copied here at all — 256,810 order lines answered by one grouped SQL query — so its - * offer is the semantic model, and the server derives it from `model/topics/*.yml` + - * `model/metrics/*.yml` rather than from anything the client knows. - * - * ⚠ Returns an EMPTY offer on any failure rather than throwing, for the same reason the link - * picker does: the editor then renders "no live sources are available", which is the truthful - * reading of both a tenant with no Odoo mirror and an unreachable server. - */ -export async function fetchRollupSources(): Promise { - try { - const res = await fetch(`${API_V1}/tables/rollup-sources`, { credentials: CREDENTIALS }); - if (handledSession(res) || !res.ok) return { topics: [], windows: [] }; - const body = (await readJson(res)) as Partial | null; - return { - topics: Array.isArray(body?.topics) ? body!.topics! : [], - windows: Array.isArray(body?.windows) ? body!.windows! : [], - }; - } catch { - return { topics: [], windows: [] }; - } -} - -export interface RollupSourceOffer { - topics: RollupSourceTopic[]; - windows: { key: string; label: string }[]; -} - -export interface RollupSourceTopic { - key: string; - label: string; - grain: string; - /** `keyedBy` says whether the group key is an Odoo ID or the dimension's own value — which is - * what decides which column on THIS database it should be matched against. */ - dims: { key: string; label: string; keyedBy: "id" | "value" }[]; - measures: { key: string; label: string; format: string; description: string }[]; -} - -export async function patchTableField( - tableKey: string, - fieldKey: string, - patch: Record -): Promise { - try { - const res = await fetch( - `${API_V1}/tables/${encodeURIComponent(tableKey)}/fields/${encodeURIComponent(fieldKey)}`, - { method: "PATCH", credentials: CREDENTIALS, headers: JSON_HEADERS, - body: JSON.stringify(patch) } - ); - if (handledSession(res)) return null; - const body = (await readJson(res)) as - | { field?: Field; error?: { message?: string } } - | null; - if (!res.ok) { - signal(TOAST_EVENT, refusalMessage(body, res.status)); - return null; - } - // ⭐ 2026-08-09 — the rows cache is a SCHEMA behind after this too, and for a rollup it is a - // VALUES behind: `PATCH /fields` refreshes the relations host-side, so the aggregate in every - // row changed the moment this returned. `addTableField` has dropped it since the day it was - // written; this door needed the same line and did not have it. - dropTableRowsCache(tableKey); - return body?.field ?? null; - } catch { - signal(TOAST_EVENT, "That change did not reach the server."); - return null; - } -} - -/** - * ⭐⭐ 2026-08-10 — REMOVE a column from a user database's SHARED definition. - * - * ⛔ THE ROUTE HAS EXISTED SINCE THE DEFINITION DOORS WERE BUILT AND NOTHING EVER CALLED IT — - * `DELETE /api/v1/tables/{key}/fields/{fkey}`, mounted, guarded by `_field_or_refuse`, and - * unreachable from the product ([[artifact-with-no-importer]] from the consumer end). The - * consequence was invisible while only `link`/`rollup` used that stratum, because both arrive as - * pre-set columns on the databases people actually have. It stops being invisible the moment a - * FORMULA column lands there: `field_delete` (the overlay event) scrubs a per-user bucket the - * definition does not read, so the column would come back on the next render and the Delete - * control would be a button that lies. - * - * ⚠ NOT optimistic, unlike the overlay delete. The server refuses the last remaining column and - * refuses a pre-set one (`may_edit_field`), and removing a column from the screen that the store - * still holds is the same lie in the other direction. - */ -export async function deleteTableField( - tableKey: string, - fieldKey: string -): Promise { - try { - const res = await fetch( - `${API_V1}/tables/${encodeURIComponent(tableKey)}/fields/${encodeURIComponent(fieldKey)}`, - { method: "DELETE", credentials: CREDENTIALS } - ); - if (handledSession(res)) return false; - if (!res.ok) { - const body = (await readJson(res)) as { error?: { message?: string } } | null; - signal(TOAST_EVENT, refusalMessage(body, res.status)); - return false; - } - // The schema moved, and a deleted LINK takes its reciprocal and every rollup that folded it - // with it (`_refresh_relations` runs on this route) — so the cached rows are values behind, - // not just a column behind. Same line `patchTableField` needed. - dropTableRowsCache(tableKey); - return true; - } catch { - signal(TOAST_EVENT, "That delete did not reach the server."); - return false; - } -} - -/** - * ⭐⭐ 2026-08-09 — THE DEFINITION SHAPE, NOT A THREE-KEY SUMMARY. - * - * This used to be typed `{key, label, type}`, which was not merely narrow — it was the second - * half of the defect that made a user-built Rollup permanently blank. `ColumnMenu` builds a - * complete `rollup` bag and hands it over; a parameter type naming three keys meant the only - * caller sent three keys, so the bag never left the browser. `_clean_field` REFUSES a rollup - * with no bag, so the shape below is what makes the create legal at all. - */ -export interface TableFieldDefinition { - key: string; - label: string; - type: string; - link?: Record; - rollup?: Record; - options?: string[]; - [extra: string]: unknown; -} - -export async function addTableField( - tableKey: string, - field: TableFieldDefinition -): Promise { - try { - const res = await fetch(`${API_V1}/tables/${encodeURIComponent(tableKey)}/fields`, { - method: "POST", - credentials: CREDENTIALS, - headers: JSON_HEADERS, - body: JSON.stringify(field), - }); - if (handledSession(res)) return null; - const body = (await readJson(res)) as - | { field?: Field; error?: { message?: string } } - | null; - if (!res.ok) { - // The server states WHY — the column cap, an unknown type (this is the 400 to expect - // until `UT_FIELD_TYPES` learns `automation`, C2's other half), a wall refusal. Its - // sentence beats anything invented here. - const why = refusalMessage(body, res.status); - signal(TOAST_EVENT, why); - return null; - } - const made = body?.field; - if (!made || typeof made.key !== "string") { - signal(DATA_ERROR_EVENT, "The server did not say which column it created."); - return null; - } - // The definition feeds BOTH payloads for a user table (`ut_assembly` builds the rows - // envelope and `/workspace` from the same `fields_base`), so the cached rows are now a - // schema behind. Cheap to drop, and a stale schema is not a stale number — it is a - // column that exists on the server and nowhere on screen. - dropTableRowsCache(tableKey); - return made; - } catch { - signal(DATA_ERROR_EVENT, "Cannot reach the server."); - return null; - } -} - -/** - * ⭐ WAVE 21 item 11 (ruling R10, contract C5) — parse an uploaded `.xlsx`/`.csv` and - * hand back its COLUMNS AND VALUES as strings. - * - * ⛔ THE FILE NEVER BECOMES DATA. This reads a spreadsheet to find out which records the - * user means; nothing here writes a row, creates a field, or keeps the file. The server - * parses (openpyxl is not a thing a browser has) and answers with strings — the matching - * itself is client-side, over rows this browser already holds (`fileSelect.ts`). - * - * ⚠ `truncated` IS THE CONTRACT'S POINT. C5 caps a column at 20k values, and a cap the - * caller cannot see is a silent wrong answer: a 30k-row file would report ten thousand - * records "not found" that are simply past the cap. The dialog surfaces this flag as its - * own sentence, never folded into the miss count ([[no-unverifiable-aggregates]]). - */ -export interface TabularUpload { - columns: string[]; - rows: number; - values: Record; - truncated: boolean; -} - -export async function uploadTabular(file: File): Promise { - const form = new FormData(); - form.append("file", file); - try { - // ⚠ NO Content-Type HEADER. The browser must set it, because only the browser knows - // the multipart boundary it generated — writing `multipart/form-data` by hand omits - // the boundary and the server cannot parse the body at all. - const res = await fetch(`${API_V1}/uploads/tabular`, { - method: "POST", - credentials: CREDENTIALS, - body: form, - }); - if (handledSession(res)) return null; - const body = (await readJson(res)) as - | { columns?: unknown; rows?: unknown; values?: unknown; truncated?: unknown; - error?: { message?: string } } - | null; - if (!res.ok) { - const why = refusalMessage( - body, - res.status, - `The file could not be read (the server answered ${res.status}).` - ); - signal(TOAST_EVENT, why); - return null; - } - const columns = Array.isArray(body?.columns) - ? body.columns.filter((c): c is string => typeof c === "string") - : []; - const rawValues = (body?.values && typeof body.values === "object" ? body.values : {}) as - Record; - const values: Record = {}; - for (const col of columns) - values[col] = Array.isArray(rawValues[col]) - ? (rawValues[col] as unknown[]).map((v) => (v === null || v === undefined ? "" : String(v))) - : []; - if (!columns.length) { - signal(TOAST_EVENT, "That file has no readable columns."); - return null; - } - return { - columns, - rows: typeof body?.rows === "number" ? body.rows : 0, - values, - truncated: body?.truncated === true, - }; - } catch { - signal(DATA_ERROR_EVENT, "Cannot reach the server."); - return null; - } -} - -/** C-UNDO's other half: drop a row this session just added. Same cache rule as the append. */ -export async function deleteTableRow( - tableKey: string, - rid: string | number -): Promise { - try { - const res = await fetch( - `${API_V1}/tables/${encodeURIComponent(tableKey)}/rows/${encodeURIComponent(String(rid))}`, - { method: "DELETE", credentials: CREDENTIALS } - ); - if (handledSession(res)) return false; - if (res.ok) dropTableRowsCache(tableKey); - return res.ok; - } catch { - return false; - } -} - -/** - * Item 7 (contract C-TS) — `POST /api/v1/grid/timeseries`. - * - * A READ that is a POST, because the request body carries a pid list that can run to - * thousands: a URL cannot hold the caller's book, and putting customer ids in a query string - * would also put them in every access log. The server intersects those pids with the caller's - * `allowed_pids` regardless, so the body narrows the answer and can never widen it. - * - * ⚠ RETURNS `null` ON EVERY FAILURE, AND THE PANEL MUST SAY SO RATHER THAN DRAW ZEROES. - * That is the difference between this and a payload of empty buckets: "we could not ask" and - * "the answer is nothing" look identical on a chart and are not the same claim. A 400 is the - * honest "narrow the span"; a 403 is `out_of_scope` (every requested pid was outside the - * caller's book); a 401 ends the session like everywhere else. - */ -export async function fetchTimeseries( - body: TsRequest, - scope: SurfaceScope = surfaceScope -): Promise<{ payload: TsPayload | null; status: number }> { - try { - const res = await fetch( - `${API_V1}/grid/timeseries?scope=${encodeURIComponent(scope)}`, - { - method: "POST", - credentials: CREDENTIALS, - headers: JSON_HEADERS, - body: JSON.stringify(body), - } - ); - if (handledSession(res)) return { payload: null, status: res.status }; - if (!res.ok) return { payload: null, status: res.status }; - const json = (await readJson(res)) as TsPayload | null; - // Shape-check before trusting it: `columns` and `rows` are indexed POSITIONALLY against - // each other, so a payload missing either would index into undefined all the way down. - if (!json || !Array.isArray(json.columns) || !Array.isArray(json.rows)) - return { payload: null, status: res.status }; - return { payload: json, status: res.status }; - } catch { - return { payload: null, status: 0 }; - } -} - -/** - * wave17 owner item 9 / ruling R4 (contract C-CAL) — per-DAY measure values. - * - * ⛔ WHY A SECOND CHANNEL rather than a parameter on the time-series one: **every group carries - * its OWN pid set.** A calendar day holds whatever records the date field placed there, so the - * subject changes from cell to cell; one `allowed_pids` for the whole request would compute - * every day over everybody, which is a different question with the same shape. - * - * The caps are the SERVER's and it answers a 400 with its reason rather than trimming: 31 days, - * 6 fields, 186 cells. The client's job is to send no more than that, never to guess what was - * dropped. `values[field][day] === null` is UNANSWERED — a day past the tenant's today, a day - * whose pids are all outside the reader's book, or a measure that could not resolve — and the - * renderer draws "—" for all three. Never 0. - */ -export interface CalMetricsResponse { - values: Record>; - today?: string; - /** C-TSWIN at day grain: which window each metric was resolved under. */ - windows?: Record; - dropped?: { field: string; reason: string }[]; - problems?: string[]; -} - -export async function fetchCalendarMetrics( - body: { groups: { key: string; pids: number[] }[]; fields: string[] }, - scope: SurfaceScope = surfaceScope -): Promise<{ payload: CalMetricsResponse | null; status: number }> { - try { - const res = await fetch( - `${API_V1}/grid/calendar_metrics?scope=${encodeURIComponent(scope)}`, - { - method: "POST", - credentials: CREDENTIALS, - headers: JSON_HEADERS, - body: JSON.stringify(body), - } - ); - if (handledSession(res)) return { payload: null, status: res.status }; - if (!res.ok) return { payload: null, status: res.status }; - const json = (await readJson(res)) as CalMetricsResponse | null; - // Shape-checked before it is trusted, the `fetchTimeseries` rule: every lookup below is - // `values[field][day]`, so a payload without `values` would read `undefined` as a value and - // paint blanks that look like real "—" refusals. - if (!json || !json.values || typeof json.values !== "object") - return { payload: null, status: res.status }; - return { payload: json, status: res.status }; - } catch { - return { payload: null, status: 0 }; - } -} - -// --- the WRITE (CP-C) ------------------------------------------------------ - -/** - * The replay bound. The SAME 24 the Streamlit value slot uses, for a different - * reason: there, the window exists because one slot gets clobbered by a second - * write; here, it bounds how much a failed POST may carry forward. Both rest on - * the same server guarantee — X2 dedups by event id — so re-sending is free and - * losing an event is not. - */ -export const MAX_REPLAY = 24; - -let pending: HostEvent[] = []; -let inFlight = false; - -/** - * Which SURFACE this grid is drawing — `customer` (the whole scoped pool), - * `cohort` (hand-curated sets) or `product` (the SKU table — wave 16 C-TOPIC). - * It lives at module scope beside the queue on purpose: the queue is already - * module-level, and an event must carry the scope it was CREATED under even if - * it drains after a route change. - * - * ⚠ The server refuses an unknown value rather than defaulting it, so this is - * never a free-text field. Read and write must agree on what a scope is: the - * grid asks `/workspace?scope=X` and every event it then emits says `X`. - */ -export type SurfaceScope = "customer" | "cohort" | "product" | `ut_${string}`; -let surfaceScope: SurfaceScope = "customer"; - -export function setSurfaceScope(scope: SurfaceScope): void { - surfaceScope = scope; -} - -/** - * ⭐ 2026-08-14 (owner item 2) — WHAT THE MOUNTED GRID IS ACTUALLY DRAWING. - * - * ⛔ ADDED BECAUSE THE FRAME USED TO DERIVE THIS FROM THE HASH, AND THE HASH STOPPED BEING - * ABLE TO ANSWER. `Shell.tsx`'s alert-create handler read `window.location.hash` and mapped - * it (`product_data` → `product`, `ut_*` → itself, else `customer`) — correct while every - * grid lived at a route named after its database. The Query module is a grid at `#/query`, - * so that derivation would have filed every alert raised there against the CUSTOMER topic: - * a silently wrong write, on a door that answers 200. - * - * This is the same value the grid stamps on every event it emits, read back. It is module - * state rather than a prop for the reason stated above it — an event must carry the scope it - * was CREATED under even if it drains after a route change — and that is precisely why it, - * and not the URL, is the honest answer to "which database is on screen". - */ -export function currentSurfaceScope(): SurfaceScope { - return surfaceScope; -} - -/** For the gate: the queue is module state and a test needs a known start. */ -export function _resetQueue(): void { - pending = []; - inFlight = false; -} - -export function _pendingIds(): string[] { - return pending.map((e) => e.id); -} - -/** - * ONE request in flight at a time, and the queue drains in order. - * - * ORDER IS NOT COSMETIC: `field_upsert` then `view_upsert` is a field that - * exists and a view that shows it; the other way round is a view referencing a - * column the store has never heard of. Parallel POSTs would race exactly there. - * - * ⚠ A FAILED BATCH IS REQUEUED BUT NOT RETRIED ON A TIMER. It goes back at the - * FRONT (it happened first) and rides the next emit. A self-scheduling retry - * against a server that is down is a tight loop, and a timer is a background - * behaviour nobody asked for; the next user action is a perfectly good clock. - * The cost is bounded and known: with no further action, the last batch stays - * unsent — which is strictly better than today, where standalone drops every - * event on the floor. - */ -async function drain(): Promise { - if (inFlight || pending.length === 0) return; - inFlight = true; - const batch = pending; - pending = []; - let requeue = false; - try { - const res = await fetch(`${API_V1}/grid/events`, { - method: "POST", - credentials: CREDENTIALS, - headers: JSON_HEADERS, - body: JSON.stringify({ events: batch, scopeKey: surfaceScope }), - }); - // A dead session is NOT retryable — replaying into a 401 forever would - // turn one expired cookie into an unbounded request loop. - if (!handledSession(res)) { - if (res.ok) applyEventResult(await readJson(res)); - else requeue = true; - } - } catch { - requeue = true; - } finally { - inFlight = false; - if (requeue) { - // Front, and bounded: events that arrived during the flight are NEWER. - pending = [...batch, ...pending].slice(-MAX_REPLAY); - } else if (pending.length) { - void drain(); - } - } -} - -/** - * X2's `{results:[{id, rerender}], doc?, toast?}`. - * - * `results` needs nothing: the client is already optimistic and the server's - * per-event ack carries no correction. `toast` is surfaced — for the - * echo-dependent events it is the only feedback that exists in standalone. - * `doc` is deliberately unhandled; see this file's header. - */ -function applyEventResult(body: unknown): void { - const b = body as { - toast?: unknown; rerender?: unknown; results?: unknown; derived?: unknown; - } | null; - const toast = b?.toast; - if (typeof toast === "string" && toast.trim() !== "") signal(TOAST_EVENT, toast.trim()); - - // owner item 2 — a measure column's values, computed server-side right after the write and - // returned here rather than waiting for the `/workspace` re-read below. Raised BEFORE the - // stale signal so the numbers paint on the earlier of the two, whichever the server sent. - // Shape-checked, because a shortcut that corrupts rows is worse than no shortcut. - if (b?.derived && typeof b.derived === "object" && !Array.isArray(b.derived)) - signal(DERIVED_CELLS_EVENT, b.derived); - - // The server already tells us when a write changed durable state — `rerender` - // is exactly the flag the Streamlit adapter uses to trigger its own rerun. We - // were dropping it, which is why cohort membership, list adds and folder moves - // never appeared in standalone until a manual reload. - const results = Array.isArray(b?.results) ? (b.results as { rerender?: unknown }[]) : []; - if (b?.rerender === true || results.some((r) => r?.rerender === true)) { - signal(WORKSPACE_STALE_EVENT); - } -} - -/** The sink hostBridge falls through to in standalone. Returns true: the event - * is now OURS (queued and owned), which is all the caller ever needed. */ -function standaloneSink(event: HostEvent): boolean { - pending.push(event); - if (pending.length > MAX_REPLAY) pending.shift(); - void drain(); - return true; -} - -/** Called from main.tsx in standalone ONLY. Never in the embed — that is the - * whole of "standalone-only branches", enforced at the one wiring point. */ -export function installStandaloneBridge(): void { - setStandaloneSink(standaloneSink); -} + if (handledSession(res) || !res.ok) return []; + const body = (await readJson(res)) as { tables?: LinkTarget[] } | null; + return Array.isArray(body?.tables) ? body.tables : []; + } catch { + return []; + } +} + +export interface LinkTarget { + key: string; + label: string; + fields: Field[]; +} + +/** + * ⭐⭐ 2026-08-09 — the READ-THROUGH rollup's offer: which governed Odoo topic, which metric of + * it, grouped by which dimension, over which date window. + * + * ⛔ WHY THIS IS A DIFFERENT LIST FROM `fetchLinkTargets`. A LINK rollup folds rows that live in + * the workspace, so its offer is "the other databases". A SOURCE rollup folds rows that were + * never copied here at all — 256,810 order lines answered by one grouped SQL query — so its + * offer is the semantic model, and the server derives it from `model/topics/*.yml` + + * `model/metrics/*.yml` rather than from anything the client knows. + * + * ⚠ Returns an EMPTY offer on any failure rather than throwing, for the same reason the link + * picker does: the editor then renders "no live sources are available", which is the truthful + * reading of both a tenant with no Odoo mirror and an unreachable server. + */ +export async function fetchRollupSources(): Promise { + try { + const res = await fetch(`${API_V1}/tables/rollup-sources`, { credentials: CREDENTIALS }); + if (handledSession(res) || !res.ok) return { topics: [], windows: [] }; + const body = (await readJson(res)) as Partial | null; + return { + topics: Array.isArray(body?.topics) ? body!.topics! : [], + windows: Array.isArray(body?.windows) ? body!.windows! : [], + }; + } catch { + return { topics: [], windows: [] }; + } +} + +export interface RollupSourceOffer { + topics: RollupSourceTopic[]; + windows: { key: string; label: string }[]; +} + +export interface RollupSourceTopic { + key: string; + label: string; + grain: string; + /** `keyedBy` says whether the group key is an Odoo ID or the dimension's own value — which is + * what decides which column on THIS database it should be matched against. */ + dims: { key: string; label: string; keyedBy: "id" | "value" }[]; + measures: { key: string; label: string; format: string; description: string }[]; +} + +export async function patchTableField( + tableKey: string, + fieldKey: string, + patch: Record +): Promise { + try { + const res = await fetch( + `${API_V1}/tables/${encodeURIComponent(tableKey)}/fields/${encodeURIComponent(fieldKey)}`, + { method: "PATCH", credentials: CREDENTIALS, headers: JSON_HEADERS, + body: JSON.stringify(patch) } + ); + if (handledSession(res)) return null; + const body = (await readJson(res)) as + | { field?: Field; error?: { message?: string } } + | null; + if (!res.ok) { + signal(TOAST_EVENT, refusalMessage(body, res.status)); + return null; + } + // ⭐ 2026-08-09 — the rows cache is a SCHEMA behind after this too, and for a rollup it is a + // VALUES behind: `PATCH /fields` refreshes the relations host-side, so the aggregate in every + // row changed the moment this returned. `addTableField` has dropped it since the day it was + // written; this door needed the same line and did not have it. + dropTableRowsCache(tableKey); + return body?.field ?? null; + } catch { + signal(TOAST_EVENT, "That change did not reach the server."); + return null; + } +} + +/** + * ⭐⭐ 2026-08-10 — REMOVE a column from a user database's SHARED definition. + * + * ⛔ THE ROUTE HAS EXISTED SINCE THE DEFINITION DOORS WERE BUILT AND NOTHING EVER CALLED IT — + * `DELETE /api/v1/tables/{key}/fields/{fkey}`, mounted, guarded by `_field_or_refuse`, and + * unreachable from the product ([[artifact-with-no-importer]] from the consumer end). The + * consequence was invisible while only `link`/`rollup` used that stratum, because both arrive as + * pre-set columns on the databases people actually have. It stops being invisible the moment a + * FORMULA column lands there: `field_delete` (the overlay event) scrubs a per-user bucket the + * definition does not read, so the column would come back on the next render and the Delete + * control would be a button that lies. + * + * ⚠ NOT optimistic, unlike the overlay delete. The server refuses the last remaining column and + * refuses a pre-set one (`may_edit_field`), and removing a column from the screen that the store + * still holds is the same lie in the other direction. + */ +/** + * ⭐⭐ OWNER ITEM 3 (2026-08-23) — REMOVE A DESTINATION FIELD. + * + * ⛔ ITS OWN DOOR, NOT `deleteTableField`. That one is `/tables/{key}/fields/{key}`, which serves + * `ut_*` USER TABLES; a Destination column is a SHARED column on the customers REGISTRY topic and + * lives in a different stratum with a different wall. Pointing the menu at the `ut_` route would + * 404 on a table key that does not exist, which is exactly the shape of "delete looks wired and + * silently does nothing". + */ +export async function deleteRouteOrderField(fieldKey: string): Promise { + try { + const res = await fetch( + `${API_V1}/customers/route-order/${encodeURIComponent(fieldKey)}`, + { method: "DELETE", credentials: CREDENTIALS } + ); + if (handledSession(res)) return false; + if (!res.ok) { + // ⭐ THE SERVER'S OWN SENTENCE. It distinguishes "somebody else planned this route" from + // "that is not a route column" from "no such column", and those need three different + // things from the person reading them. + const body = (await readJson(res)) as { error?: { message?: string } } | null; + signal(TOAST_EVENT, refusalMessage(body, res.status)); + return false; + } + return true; + } catch { + signal(TOAST_EVENT, "That field could not be removed. Check your connection and try again."); + return false; + } +} + +export async function deleteTableField( + tableKey: string, + fieldKey: string +): Promise { + try { + const res = await fetch( + `${API_V1}/tables/${encodeURIComponent(tableKey)}/fields/${encodeURIComponent(fieldKey)}`, + { method: "DELETE", credentials: CREDENTIALS } + ); + if (handledSession(res)) return false; + if (!res.ok) { + const body = (await readJson(res)) as { error?: { message?: string } } | null; + signal(TOAST_EVENT, refusalMessage(body, res.status)); + return false; + } + // The schema moved, and a deleted LINK takes its reciprocal and every rollup that folded it + // with it (`_refresh_relations` runs on this route) — so the cached rows are values behind, + // not just a column behind. Same line `patchTableField` needed. + dropTableRowsCache(tableKey); + return true; + } catch { + signal(TOAST_EVENT, "That delete did not reach the server."); + return false; + } +} + +/** + * ⭐⭐ 2026-08-09 — THE DEFINITION SHAPE, NOT A THREE-KEY SUMMARY. + * + * This used to be typed `{key, label, type}`, which was not merely narrow — it was the second + * half of the defect that made a user-built Rollup permanently blank. `ColumnMenu` builds a + * complete `rollup` bag and hands it over; a parameter type naming three keys meant the only + * caller sent three keys, so the bag never left the browser. `_clean_field` REFUSES a rollup + * with no bag, so the shape below is what makes the create legal at all. + */ +export interface TableFieldDefinition { + key: string; + label: string; + type: string; + link?: Record; + rollup?: Record; + options?: string[]; + [extra: string]: unknown; +} + +export async function addTableField( + tableKey: string, + field: TableFieldDefinition +): Promise { + try { + const res = await fetch(`${API_V1}/tables/${encodeURIComponent(tableKey)}/fields`, { + method: "POST", + credentials: CREDENTIALS, + headers: JSON_HEADERS, + body: JSON.stringify(field), + }); + if (handledSession(res)) return null; + const body = (await readJson(res)) as + | { field?: Field; error?: { message?: string } } + | null; + if (!res.ok) { + // The server states WHY — the column cap, an unknown type (this is the 400 to expect + // until `UT_FIELD_TYPES` learns `automation`, C2's other half), a wall refusal. Its + // sentence beats anything invented here. + const why = refusalMessage(body, res.status); + signal(TOAST_EVENT, why); + return null; + } + const made = body?.field; + if (!made || typeof made.key !== "string") { + signal(DATA_ERROR_EVENT, "The server did not say which column it created."); + return null; + } + // The definition feeds BOTH payloads for a user table (`ut_assembly` builds the rows + // envelope and `/workspace` from the same `fields_base`), so the cached rows are now a + // schema behind. Cheap to drop, and a stale schema is not a stale number — it is a + // column that exists on the server and nowhere on screen. + dropTableRowsCache(tableKey); + return made; + } catch { + signal(DATA_ERROR_EVENT, "Cannot reach the server."); + return null; + } +} + +/** + * ⭐ WAVE 21 item 11 (ruling R10, contract C5) — parse an uploaded `.xlsx`/`.csv` and + * hand back its COLUMNS AND VALUES as strings. + * + * ⛔ THE FILE NEVER BECOMES DATA. This reads a spreadsheet to find out which records the + * user means; nothing here writes a row, creates a field, or keeps the file. The server + * parses (openpyxl is not a thing a browser has) and answers with strings — the matching + * itself is client-side, over rows this browser already holds (`fileSelect.ts`). + * + * ⚠ `truncated` IS THE CONTRACT'S POINT. C5 caps a column at 20k values, and a cap the + * caller cannot see is a silent wrong answer: a 30k-row file would report ten thousand + * records "not found" that are simply past the cap. The dialog surfaces this flag as its + * own sentence, never folded into the miss count ([[no-unverifiable-aggregates]]). + */ +export interface TabularUpload { + columns: string[]; + rows: number; + values: Record; + truncated: boolean; +} + +export async function uploadTabular(file: File): Promise { + const form = new FormData(); + form.append("file", file); + try { + // ⚠ NO Content-Type HEADER. The browser must set it, because only the browser knows + // the multipart boundary it generated — writing `multipart/form-data` by hand omits + // the boundary and the server cannot parse the body at all. + const res = await fetch(`${API_V1}/uploads/tabular`, { + method: "POST", + credentials: CREDENTIALS, + body: form, + }); + if (handledSession(res)) return null; + const body = (await readJson(res)) as + | { columns?: unknown; rows?: unknown; values?: unknown; truncated?: unknown; + error?: { message?: string } } + | null; + if (!res.ok) { + const why = refusalMessage( + body, + res.status, + `The file could not be read (the server answered ${res.status}).` + ); + signal(TOAST_EVENT, why); + return null; + } + const columns = Array.isArray(body?.columns) + ? body.columns.filter((c): c is string => typeof c === "string") + : []; + const rawValues = (body?.values && typeof body.values === "object" ? body.values : {}) as + Record; + const values: Record = {}; + for (const col of columns) + values[col] = Array.isArray(rawValues[col]) + ? (rawValues[col] as unknown[]).map((v) => (v === null || v === undefined ? "" : String(v))) + : []; + if (!columns.length) { + signal(TOAST_EVENT, "That file has no readable columns."); + return null; + } + return { + columns, + rows: typeof body?.rows === "number" ? body.rows : 0, + values, + truncated: body?.truncated === true, + }; + } catch { + signal(DATA_ERROR_EVENT, "Cannot reach the server."); + return null; + } +} + +/** C-UNDO's other half: drop a row this session just added. Same cache rule as the append. */ +export async function deleteTableRow( + tableKey: string, + rid: string | number +): Promise { + try { + const res = await fetch( + `${API_V1}/tables/${encodeURIComponent(tableKey)}/rows/${encodeURIComponent(String(rid))}`, + { method: "DELETE", credentials: CREDENTIALS } + ); + if (handledSession(res)) return false; + if (res.ok) dropTableRowsCache(tableKey); + return res.ok; + } catch { + return false; + } +} + +/** + * Item 7 (contract C-TS) — `POST /api/v1/grid/timeseries`. + * + * A READ that is a POST, because the request body carries a pid list that can run to + * thousands: a URL cannot hold the caller's book, and putting customer ids in a query string + * would also put them in every access log. The server intersects those pids with the caller's + * `allowed_pids` regardless, so the body narrows the answer and can never widen it. + * + * ⚠ RETURNS `null` ON EVERY FAILURE, AND THE PANEL MUST SAY SO RATHER THAN DRAW ZEROES. + * That is the difference between this and a payload of empty buckets: "we could not ask" and + * "the answer is nothing" look identical on a chart and are not the same claim. A 400 is the + * honest "narrow the span"; a 403 is `out_of_scope` (every requested pid was outside the + * caller's book); a 401 ends the session like everywhere else. + */ +export async function fetchTimeseries( + body: TsRequest, + scope: SurfaceScope = surfaceScope +): Promise<{ payload: TsPayload | null; status: number }> { + try { + const res = await fetch( + `${API_V1}/grid/timeseries?scope=${encodeURIComponent(scope)}`, + { + method: "POST", + credentials: CREDENTIALS, + headers: JSON_HEADERS, + body: JSON.stringify(body), + } + ); + if (handledSession(res)) return { payload: null, status: res.status }; + if (!res.ok) return { payload: null, status: res.status }; + const json = (await readJson(res)) as TsPayload | null; + // Shape-check before trusting it: `columns` and `rows` are indexed POSITIONALLY against + // each other, so a payload missing either would index into undefined all the way down. + if (!json || !Array.isArray(json.columns) || !Array.isArray(json.rows)) + return { payload: null, status: res.status }; + return { payload: json, status: res.status }; + } catch { + return { payload: null, status: 0 }; + } +} + +/** + * wave17 owner item 9 / ruling R4 (contract C-CAL) — per-DAY measure values. + * + * ⛔ WHY A SECOND CHANNEL rather than a parameter on the time-series one: **every group carries + * its OWN pid set.** A calendar day holds whatever records the date field placed there, so the + * subject changes from cell to cell; one `allowed_pids` for the whole request would compute + * every day over everybody, which is a different question with the same shape. + * + * The caps are the SERVER's and it answers a 400 with its reason rather than trimming: 31 days, + * 6 fields, 186 cells. The client's job is to send no more than that, never to guess what was + * dropped. `values[field][day] === null` is UNANSWERED — a day past the tenant's today, a day + * whose pids are all outside the reader's book, or a measure that could not resolve — and the + * renderer draws "—" for all three. Never 0. + */ +export interface CalMetricsResponse { + values: Record>; + today?: string; + /** C-TSWIN at day grain: which window each metric was resolved under. */ + windows?: Record; + dropped?: { field: string; reason: string }[]; + problems?: string[]; +} + +export async function fetchCalendarMetrics( + body: { groups: { key: string; pids: number[] }[]; fields: string[] }, + scope: SurfaceScope = surfaceScope +): Promise<{ payload: CalMetricsResponse | null; status: number }> { + try { + const res = await fetch( + `${API_V1}/grid/calendar_metrics?scope=${encodeURIComponent(scope)}`, + { + method: "POST", + credentials: CREDENTIALS, + headers: JSON_HEADERS, + body: JSON.stringify(body), + } + ); + if (handledSession(res)) return { payload: null, status: res.status }; + if (!res.ok) return { payload: null, status: res.status }; + const json = (await readJson(res)) as CalMetricsResponse | null; + // Shape-checked before it is trusted, the `fetchTimeseries` rule: every lookup below is + // `values[field][day]`, so a payload without `values` would read `undefined` as a value and + // paint blanks that look like real "—" refusals. + if (!json || !json.values || typeof json.values !== "object") + return { payload: null, status: res.status }; + return { payload: json, status: res.status }; + } catch { + return { payload: null, status: 0 }; + } +} + +// --- the WRITE (CP-C) ------------------------------------------------------ + +/** + * The replay bound. The SAME 24 the Streamlit value slot uses, for a different + * reason: there, the window exists because one slot gets clobbered by a second + * write; here, it bounds how much a failed POST may carry forward. Both rest on + * the same server guarantee — X2 dedups by event id — so re-sending is free and + * losing an event is not. + */ +export const MAX_REPLAY = 24; + +let pending: HostEvent[] = []; +let inFlight = false; + +/** + * Which SURFACE this grid is drawing — `customer` (the whole scoped pool), + * `cohort` (hand-curated sets) or `product` (the SKU table — wave 16 C-TOPIC). + * It lives at module scope beside the queue on purpose: the queue is already + * module-level, and an event must carry the scope it was CREATED under even if + * it drains after a route change. + * + * ⚠ The server refuses an unknown value rather than defaulting it, so this is + * never a free-text field. Read and write must agree on what a scope is: the + * grid asks `/workspace?scope=X` and every event it then emits says `X`. + */ +export type SurfaceScope = "customer" | "cohort" | "product" | `ut_${string}`; +let surfaceScope: SurfaceScope = "customer"; + +export function setSurfaceScope(scope: SurfaceScope): void { + surfaceScope = scope; +} + +/** + * ⭐ 2026-08-14 (owner item 2) — WHAT THE MOUNTED GRID IS ACTUALLY DRAWING. + * + * ⛔ ADDED BECAUSE THE FRAME USED TO DERIVE THIS FROM THE HASH, AND THE HASH STOPPED BEING + * ABLE TO ANSWER. `Shell.tsx`'s alert-create handler read `window.location.hash` and mapped + * it (`product_data` → `product`, `ut_*` → itself, else `customer`) — correct while every + * grid lived at a route named after its database. The Query module is a grid at `#/query`, + * so that derivation would have filed every alert raised there against the CUSTOMER topic: + * a silently wrong write, on a door that answers 200. + * + * This is the same value the grid stamps on every event it emits, read back. It is module + * state rather than a prop for the reason stated above it — an event must carry the scope it + * was CREATED under even if it drains after a route change — and that is precisely why it, + * and not the URL, is the honest answer to "which database is on screen". + */ +export function currentSurfaceScope(): SurfaceScope { + return surfaceScope; +} + +/** For the gate: the queue is module state and a test needs a known start. */ +export function _resetQueue(): void { + pending = []; + inFlight = false; +} + +export function _pendingIds(): string[] { + return pending.map((e) => e.id); +} + +/** + * ONE request in flight at a time, and the queue drains in order. + * + * ORDER IS NOT COSMETIC: `field_upsert` then `view_upsert` is a field that + * exists and a view that shows it; the other way round is a view referencing a + * column the store has never heard of. Parallel POSTs would race exactly there. + * + * ⚠ A FAILED BATCH IS REQUEUED BUT NOT RETRIED ON A TIMER. It goes back at the + * FRONT (it happened first) and rides the next emit. A self-scheduling retry + * against a server that is down is a tight loop, and a timer is a background + * behaviour nobody asked for; the next user action is a perfectly good clock. + * The cost is bounded and known: with no further action, the last batch stays + * unsent — which is strictly better than today, where standalone drops every + * event on the floor. + */ +async function drain(): Promise { + if (inFlight || pending.length === 0) return; + inFlight = true; + const batch = pending; + pending = []; + let requeue = false; + try { + const res = await fetch(`${API_V1}/grid/events`, { + method: "POST", + credentials: CREDENTIALS, + headers: JSON_HEADERS, + body: JSON.stringify({ events: batch, scopeKey: surfaceScope }), + }); + // A dead session is NOT retryable — replaying into a 401 forever would + // turn one expired cookie into an unbounded request loop. + if (!handledSession(res)) { + if (res.ok) applyEventResult(await readJson(res)); + else requeue = true; + } + } catch { + requeue = true; + } finally { + inFlight = false; + if (requeue) { + // Front, and bounded: events that arrived during the flight are NEWER. + pending = [...batch, ...pending].slice(-MAX_REPLAY); + } else if (pending.length) { + void drain(); + } + } +} + +/** + * X2's `{results:[{id, rerender}], doc?, toast?}`. + * + * `results` needs nothing: the client is already optimistic and the server's + * per-event ack carries no correction. `toast` is surfaced — for the + * echo-dependent events it is the only feedback that exists in standalone. + * `doc` is deliberately unhandled; see this file's header. + */ +function applyEventResult(body: unknown): void { + const b = body as { + toast?: unknown; rerender?: unknown; results?: unknown; derived?: unknown; + } | null; + const toast = b?.toast; + if (typeof toast === "string" && toast.trim() !== "") signal(TOAST_EVENT, toast.trim()); + + // owner item 2 — a measure column's values, computed server-side right after the write and + // returned here rather than waiting for the `/workspace` re-read below. Raised BEFORE the + // stale signal so the numbers paint on the earlier of the two, whichever the server sent. + // Shape-checked, because a shortcut that corrupts rows is worse than no shortcut. + if (b?.derived && typeof b.derived === "object" && !Array.isArray(b.derived)) + signal(DERIVED_CELLS_EVENT, b.derived); + + // The server already tells us when a write changed durable state — `rerender` + // is exactly the flag the Streamlit adapter uses to trigger its own rerun. We + // were dropping it, which is why cohort membership, list adds and folder moves + // never appeared in standalone until a manual reload. + const results = Array.isArray(b?.results) ? (b.results as { rerender?: unknown }[]) : []; + if (b?.rerender === true || results.some((r) => r?.rerender === true)) { + signal(WORKSPACE_STALE_EVENT); + } +} + +/** The sink hostBridge falls through to in standalone. Returns true: the event + * is now OURS (queued and owned), which is all the caller ever needed. */ +function standaloneSink(event: HostEvent): boolean { + pending.push(event); + if (pending.length > MAX_REPLAY) pending.shift(); + void drain(); + return true; +} + +/** Called from main.tsx in standalone ONLY. Never in the embed — that is the + * whole of "standalone-only branches", enforced at the one wiring point. */ +export function installStandaloneBridge(): void { + setStandaloneSink(standaloneSink); +} /** * ⭐⭐ WAVE 34 (owner ruling R13, `W34-T54`) — RUN AN `ai_enrich` COLUMN OVER A SCOPE.