// --------------------------------------------------------------------------- // customer-grid / liveWorkspace.ts // The workspace that arrives AFTER mount — pure and React-free, so // verify_live_workspace.py can run it under node. // // ⛔ THE DEFECT THIS EXISTS FOR (owner, 2026-08-04). "Creating a new Cohort or // Locked list under Product/Customer only shows up when I click a different // module first and come back." // // It was exact. `CustomerGrid`'s init effect is gated on // `initializedKey.current === storageKey`, so it reads `payload.workspace` // EXACTLY ONCE per mount. Under Streamlit that was invisible — a rerun replaces // the iframe, so every host round trip WAS a remount and init ran again. The // standalone shell has no rerun: `CustomerGrid` stays mounted, and the only // thing that remounts it is `key={active.key}` in Shell.tsx — i.e. switching // modules, which is precisely the workaround the owner found. // // So the write path was complete and the READ path stopped at the door: // `add_to_list` → the host creates the set → `rerender: true` → // WORKSPACE_STALE_EVENT → `reread()` → `payload.workspace` genuinely carries the // new locked view (wave 17 R1 projects every cohort as a view) — and `views` // state, seeded once at init, never heard about it. // // TWO STRATA GO STALE TOGETHER, and fixing only the first would have looked // fixed while staying broken: // views the projected locked view = the rail row the owner is looking for. // fields `fields_from_workspace(ws, cohorts=bool(cohort_lists))` — the // derived "Locked views" column EXISTS ONLY ONCE A COHORT DOES. The // user's FIRST cohort therefore changes the field contract, and an // init-once `fields` would have left that column out of the Fields // menu until the very remount we are removing the need for. // // ⭐ ADD-ONLY, AND THAT IS A DESIGN DECISION, NOT AN OMISSION. Everything the // user already holds is left BY IDENTITY: an in-flight filter tree, a config // mid-autosave (420 ms debounce), a rename waiting on its echo. This module is // the answer to "what has APPEARED since we mounted", and nothing else. Taking // host copies of things we already hold would re-introduce every blip // optimism.ts / viewEcho.ts / folders.ts were written to remove — the whole // no-blip layer rests on "this browser's copy is the newest truth". // // ⚠ TOMBSTONES ARE NOT OPTIONAL HERE, and an add-only merge without them is // WORSE than the bug it fixes. The event queue sends ONE batch at a time // (apiBridge `drain`), so this interleaving is ordinary: // batch 1 [add_to_list] in flight // user deletes a view → removed optimistically, queued behind batch 1 // batch 1 answers → rerender → reread → the workspace STILL lists // the deleted view (its delete has not been sent) // Without a tombstone the row comes back — and since this merge never removes, // it would stay back until a remount. That is a delete that visibly failed. // Same window and same rule as folders.ts and optimism.ts: ECHO_RECENT_MS, and // anything past it yields to the host, because divergence is not an echo. // --------------------------------------------------------------------------- import { ECHO_RECENT_MS } from "./viewEcho"; import type { Field, SavedView, ViewConfig } from "./types"; /** id -> when THIS browser deleted it. Browser-clock arithmetic on purpose: * both sides of the comparison come from this machine, so this is not the * tenant-day contract ([[date-window-vocabulary]]) — that one is about two * ENGINES agreeing on a date. */ export type Tombstones = Record; /** Upper bound on a tombstone map, matching FOLDER_STAMP_MAX. A long-lived tab * must not accumulate an archive of everything it ever deleted. */ export const TOMBSTONE_MAX = 64; const isRecent = (t: number | undefined, now: number): boolean => typeof t === "number" && now - t <= ECHO_RECENT_MS; /** Drop entries past the echo window. Called at every stamp AND before every * persist, so the blob stays a recent window rather than a growing log. */ export function pruneTombstones(stamps: Tombstones | undefined, now: number): Tombstones { const kept = Object.entries(stamps ?? {}).filter( ([, t]) => typeof t === "number" && isRecent(t, now) ); return Object.fromEntries(kept.slice(-TOMBSTONE_MAX)); } /** Record one deletion. Pure so the caller's ref update stays a one-liner. */ export function stampTombstone(stamps: Tombstones | undefined, id: string, now: number): Tombstones { return pruneTombstones({ ...(stamps ?? {}), [id]: now }, now); } /** * ⭐ D-19 (wave 20) — **THE LOCALSTORAGE GHOST.** * * At init the grid seeded EVERY view from localStorage and then merged the host's list over the * top, so a view the host no longer names simply survived — for ever, in that browser. Three * ordinary paths produce one: the view was deleted from another tab or another machine, its * share was revoked, or the store moved under it. The row keeps working until you click it, and * then it is a saved view nobody else can see and no write can reach; the owner reported it as * "live and staging disagree". Item 13's pg cutover makes the host list authoritative for real, * which turns a rare confusion into a visible one. * * So: **the host's list decides which views exist.** A local copy the host does not name is * dropped — with two guards, and neither is optional: * * 1. `hostAuthoritative === false` keeps everything. Standalone with no `/workspace` (and the * legacy embed) has no host list at all, and "not named" there means "not asked", not * "deleted". Dropping on a payload that never carried views would empty the rail. * ⛔ **An EMPTY host list counts as not-authoritative for the same reason, and this is the * one branch that could destroy data.** A `/workspace` answering `200 {views: []}` is * indistinguishable from a store that has not answered yet — a scope whose bucket is * briefly empty during item 13's `hf → pg` migration, a fresh backend, a bucket that was * never seeded. Dropping there wipes every saved view in that browser, and the persist * effect rewrites localStorage immediately after, so there is no second chance. The ghost * this exists for is a view missing from a NON-EMPTY list; nothing is lost by refusing to * act on no list at all. * 2. A view THIS BROWSER wrote inside the echo window survives. A create is optimistic: the * row exists locally the instant it is made, and the host cannot name it until its * `view_upsert` has been sent AND the next `/workspace` read has come back. Without this * guard, creating a view and reloading fast enough would delete it — the exact inverse of * the bug, and a worse one. * * Same window, same reasoning and the same stamp shape as the tombstones above: past * ECHO_RECENT_MS, divergence is not an echo. */ export function seedLocalViews( localViews: readonly SavedView[], hostViews: readonly SavedView[], writes: Tombstones | undefined, now: number, hostAuthoritative: boolean ): SavedView[] { if (!hostAuthoritative || hostViews.length === 0) return [...localViews]; const named = new Set(hostViews.map((v) => v.id)); return localViews.filter((v) => named.has(v.id) || isRecent(writes?.[v.id], now)); } /** * Views that have APPEARED on the host since this browser last looked. * * Returns `current` BY IDENTITY when there is nothing to adopt — which is the * load-bearing half of the contract, not an optimisation. `withWorkspace` mints * a fresh payload object on every re-read, so `hostViews` changes identity each * time whether or not its contents did; a merge that always returned a new * array would re-render the grid (and re-write localStorage) on every echo. * * `normalize` is injected rather than imported because it needs the FIELD LIST * the caller is about to commit — a locked view's projected `config.order` names * the derived cohort column, and normalizing against a stale field array would * quietly drop the very key that arrived with it. The caller therefore adopts * fields FIRST and hands the result down (see CustomerGrid's live effect). */ export function adoptNewViews( current: SavedView[], hostViews: SavedView[] | undefined, tombstones: Tombstones | undefined, now: number, normalize: (config: Partial | undefined) => ViewConfig ): SavedView[] { if (!Array.isArray(hostViews) || hostViews.length === 0) return current; const held = new Set(current.map((v) => v.id)); const fresh: SavedView[] = []; for (const view of hostViews) { if (!view || typeof view.id !== "string" || view.id === "") continue; if (held.has(view.id)) continue; // Deleted here seconds ago and the echo has not caught up. Resurrecting it — // even for one round trip — is the delete blip, and this merge never removes, // so it would be a permanent one. if (isRecent(tombstones?.[view.id], now)) continue; held.add(view.id); // a host list with a duplicate id adds once fresh.push({ ...view, config: normalize(view.config) }); } return fresh.length === 0 ? current : [...current, ...fresh]; } /** * Fields that have APPEARED on the host since this browser last looked — in * practice the derived "Locked views" column, which the server emits only once * the user owns at least one cohort. * * Appended in host order at the END, which is where `fields_from_workspace` * puts the derived column anyway, and `reconcileOrder` folds any key missing * from a saved `config.order` in for us — so nothing has to touch a stored view * for the new column to become togglable in the Fields menu. * * ⚠ The tombstone map here is `FieldStamps.deleted`, the SAME one * `reconcileFields` consults at mount, for the same reason: a column this * browser dropped must not walk back in through a lagged echo. */ export function adoptNewFields( current: Field[], hostFields: Field[] | undefined, tombstones: Tombstones | undefined, now: number ): Field[] { if (!Array.isArray(hostFields) || hostFields.length === 0) return current; const held = new Set(current.map((f) => f.key)); const fresh: Field[] = []; for (const field of hostFields) { if (!field || typeof field.key !== "string" || field.key === "") continue; if (held.has(field.key)) continue; if (isRecent(tombstones?.[field.key], now)) continue; held.add(field.key); fresh.push(field); } return fresh.length === 0 ? current : [...current, ...fresh]; } // =========================================================================== // THE CHANGE TOKEN (wave 29, item 20 / R11 / contract C6) // // Everything above answers "what appeared in the payload we just read". This // half answers the question nobody was asking at all: **should we read?** // // ⛔ THE DEFECT (owner, 2026-08-10): a record created in another tab, by an // automation, or by a connector sync does not appear until you reload. The // rows fetch runs once per mount and re-runs only on ROWS_STALE_EVENT — and // EVERY dispatcher of that event is this browser's own write path. Of the // owner's three writers, only "another tab" emits anything, and only into the // tab that did the writing. There is no polling, no revalidation, no // visibilitychange and no BroadcastChannel anywhere in the client. // // The server now publishes a per-bucket revision that costs no deep copy // (`GET /api/v1/changes`). These are the pure decisions taken on it, here // rather than inside the effect so `verify_live_workspace.py` can run them // under node — the same reason the merges above are pure. // =========================================================================== /** `{bucket: opaque token}`. A null means the backend publishes no revision for * that bucket (Odoo-cached rows; a store backend that has none yet) — "do not * poll this", which is a different fact from "unchanged". */ export type ChangeTokens = Record; /** * Which buckets genuinely changed between two observations. * * ⭐ A CHANGE IS REPORTED ONLY WHEN BOTH SIDES CARRY A NON-NULL TOKEN AND THEY * DIFFER. Every other case — the first observation, a poll that failed, a * bucket that has no token, a bucket appearing or disappearing from the map — * reports NOTHING, and the reasoning is asymmetric on purpose: * * * a missed change costs one stale view until the next real write, which is * exactly today's behaviour and therefore cannot be a regression; * * a false change costs a full rows re-read, and a false change that repeats * EVERY interval costs one per tab per 10 s — the melt this whole design * exists to avoid. * * So the unknown cases resolve to "no", and each of them is a one-time * transition (first poll, a deploy adding a bucket, a backend flip) rather than * a standing condition. The alerts engine takes the identical `seeded` posture * server-side: its first evaluation raises nothing. */ export function changedBuckets( prev: ChangeTokens | null | undefined, next: ChangeTokens | null | undefined ): string[] { if (!prev || !next) return []; // no baseline yet, or the poll failed const out: string[] = []; for (const [bucket, token] of Object.entries(next)) { if (typeof token !== "string" || token === "") continue; const before = prev[bucket]; if (typeof before !== "string" || before === "") continue; // baseline it if (before !== token) out.push(bucket); } return out; } /** What a set of changed buckets asks this tab to do. */ export interface ChangePlan { /** Drop this topic's rows memo and re-read the pool — the expensive one. */ refetchRows: boolean; /** Re-read `/workspace` — views, fields, derived cells. The cheap one. */ rereadWorkspace: boolean; } /** * ⛔ THE ROWS MEMO IS WHY THIS IS NOT JUST "REFETCH". `fetchTopicRows` holds a * FIVE-MINUTE per-topic memo, cleared only by the writing tab, so a refetch * triggered without dropping it is served from the copy that predates the very * change we detected: a poll that costs a request, reports success, and shows * the user nothing new. The caller must drop the memo for THIS topic — * `clearTopicRowsCache(topic.rowsPath)`, never `clearCustomersCache()`, which * would evict the other topic's window and re-download ~1 MB on the next * surface switch. * * An UNRECOGNISED bucket takes the CHEAP branch. A later server may publish a * bucket this build has never heard of (the shared overlay stratum is exactly * that case), and "something I do not understand changed" should cost a small * workspace read, never a pool download. */ export function planChangeReaction(changed: readonly string[]): ChangePlan { const plan: ChangePlan = { refetchRows: false, rereadWorkspace: false }; for (const bucket of changed) { if (bucket === "rows") plan.refetchRows = true; else plan.rereadWorkspace = true; } return plan; } /** * The floor between two POOL re-reads of one topic. Only bites under sustained writing. * * ⛔ WHY A FLOOR IS NOT OPTIONAL, and the arithmetic is the same one that killed the naive design. * `user_tables.STORE_KEY` is ONE bucket holding EVERY `ut_*` table in a tenant, so the rows token * for every user table moves together — and while an automation upserts rows, or the relation * refresh commits, the token moves on every single interval. Without a floor each open tab would * then re-read the whole pool six times a minute, at three full-tenant deep copies per read: the * "two tabs saturate the server" case the poller was designed to avoid, reached through the * REFETCH instead of through the poll. Bounding cost per token request and leaving refetches * unbounded would just move the melt one step downstream. */ export const ROWS_REFETCH_FLOOR_MS = 30_000; /** * How long to wait before re-reading this topic's pool: `0` = now. * * ⭐ IT COALESCES, IT NEVER DROPS. A caller that is told to wait must schedule the read for the * boundary, not discard the signal — several changes inside the window collapse into ONE read, * which is the same shape `routes_tables._refresh_relations` uses server-side and for the same * reason. Discarding would leave a tab stale for good if the burst ended right after a suppressed * change. * * ⚠ THE IDLE CASE IS UNAFFECTED, which is what keeps the owner's "about ten seconds" true: one * person adding one record hits a `lastAt` far in the past and re-reads immediately on the next * poll. The floor is only reachable when changes arrive faster than it. */ export function rowsRefetchDelay(lastAt: number, now: number, floorMs: number = ROWS_REFETCH_FLOOR_MS): number { if (!lastAt) return 0; // never read on this surface — go now const since = now - lastAt; return since >= floorMs ? 0 : floorMs - since; }