// --------------------------------------------------------------------------- // 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 { const res = await fetch(`${API_V1}/odoo-tables/status`, { 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 { res = await fetch(`${API_V1}/changes?scope=${encodeURIComponent(scope)}`, { credentials: CREDENTIALS }); } 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; } /** ~10 s. The owner's ask is "a new record shows up in about ten seconds", and the request it * costs is a dict read server-side — see `routes_changes.py` for why that is the whole design. */ export const CHANGE_POLL_MS = 10_000; interface ChangeWatch { timer: ReturnType | null; tokens: ChangeTokens | null; listeners: Set<(changed: string[]) => void>; inFlight: boolean; /** ⭐ 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. */ poll: () => Promise; } const changeWatches = new Map(); let visibilityBound = false; /** * Watch one scope for server-side changes. Returns its unsubscribe. * * ⭐ ONE TIMER PER SCOPE, REFCOUNTED, and that is a budget rather than tidiness. A linked-record * grid mounts a second `useCustomerData` on the same surface, and a per-hook interval would double * the poll rate for a table the user opened once. The done-when is "roughly 6 tiny calls per minute * per tab" — per TAB, not per component. * * ⛔ IT DOES NOT POLL A HIDDEN TAB, and it polls IMMEDIATELY on becoming visible again. A background * tab left open overnight would otherwise spend the night asking a question nobody can see the * answer to, and the one moment its answer certainly matters is the moment you look at it. * `visibilitychange` had zero listeners in this client before this. */ 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 = { timer: null, tokens: null, listeners: new Set(), inFlight: false, poll: async () => { // A slow answer must not queue a second request behind itself: on a cold Space the first // poll can outlive the interval, and without this the backlog grows while it stays slow. if (w0.inFlight || document.visibilityState !== "visible") return; w0.inFlight = true; try { const next = await fetchChangeTokens(scope); const changed = changedBuckets(w0.tokens, next); if (next) w0.tokens = next; if (changed.length) for (const fn of [...w0.listeners]) fn(changed); } finally { w0.inFlight = false; } }, }; watch = w0; changeWatches.set(scope, w0); } const w = watch; w.listeners.add(onChange); if (!w.timer) { void w.poll(); // take the baseline now, not in ten seconds w.timer = setInterval(() => { void w.poll(); }, CHANGE_POLL_MS); } if (!visibilityBound) { visibilityBound = true; window.addEventListener("visibilitychange", () => { if (document.visibilityState !== "visible") return; for (const watched of changeWatches.values()) if (watched.timer) void watched.poll(); }); } return () => { w.listeners.delete(onChange); if (w.listeners.size === 0 && w.timer) { clearInterval(w.timer); w.timer = null; // ⚠ The tokens are DROPPED with the last listener. A remount must re-baseline rather than // compare against a token from before it was unmounted: the payload it is about to fetch is // fresh by definition, so treating the interval it missed as "a change" would cost a second // full read of what it just read. w.tokens = null; } }; } /** * 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 { const res = await fetch(`${API_V1}/tables`, { 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; } /** 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); }