| // --------------------------------------------------------------------------- | |
| // 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<unknown> { | |
| 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<string, { at: number; payload: CustomersPayload }>(); | |
| /** | |
| * ββ 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<string, { at: number; ws: GridWorkspace }>(); | |
| 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/<key>/rows`) and eight call sites deleted that literal. A read-through | |
| * grid is served by a DIFFERENT route (`odoo-tables/<key>/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/<topic.rowsPath>` β `{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<CustomersPayload | null> { | |
| 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<CustomersPayload | null> { | |
| 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/<key>/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/<key>/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<Set<string>> | null = null; | |
| export function readThroughTables(): Promise<Set<string>> { | |
| 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<string>(); | |
| } | |
| const body = (await readJson(res)) as | |
| { tables?: Record<string, { readThrough?: boolean }> } | null; | |
| const out = new Set<string>(); | |
| for (const [key, t] of Object.entries(body?.tables ?? {})) | |
| if (t?.readThrough === true) out.add(key); | |
| return out; | |
| } catch { | |
| readThroughMemo = null; | |
| return new Set<string>(); | |
| } | |
| })(); | |
| } | |
| return readThroughMemo; | |
| } | |
| /** `GET /api/v1/odoo-tables/<key>/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<CustomersPayload | null> { | |
| 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<GridWorkspace | null> { | |
| 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<ChangeTokens | null> { | |
| 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<string, unknown>)) | |
| 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<typeof setInterval> | 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<void>; | |
| } | |
| const changeWatches = new Map<string, ChangeWatch>(); | |
| 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/<topic.rowsPath>/{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<Row>, | |
| /** | |
| * β 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<Row>, cleared: string[]) => void, | |
| ): Promise<boolean> { | |
| 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<string, unknown>; 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<Row> = {}; | |
| 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<Row>), ...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<Row>): Promise<boolean> { | |
| 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<string, unknown> = {}, | |
| 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 | |
| * (`<key>_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<LinkTarget[]> { | |
| 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<RollupSourceOffer> { | |
| 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<RollupSourceOffer> | 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<string, unknown> | |
| ): Promise<Field | null> { | |
| 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<boolean> { | |
| 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<string, unknown>; | |
| rollup?: Record<string, unknown>; | |
| options?: string[]; | |
| [extra: string]: unknown; | |
| } | |
| export async function addTableField( | |
| tableKey: string, | |
| field: TableFieldDefinition | |
| ): Promise<Field | null> { | |
| 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<string, string[]>; | |
| truncated: boolean; | |
| } | |
| export async function uploadTabular(file: File): Promise<TabularUpload | null> { | |
| 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<string, unknown>; | |
| const values: Record<string, string[]> = {}; | |
| 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<boolean> { | |
| 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<string, Record<string, number | null>>; | |
| today?: string; | |
| /** C-TSWIN at day grain: which window each metric was resolved under. */ | |
| windows?: Record<string, { kind: string; label: string }>; | |
| 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<void> { | |
| 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); | |
| } | |