loopable / web /src /customer-grid /apiBridge.ts
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
051f280 verified
Raw
History Blame Contribute Delete
67.5 kB
// ---------------------------------------------------------------------------
// 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);
}