| // --------------------------------------------------------------------------- | |
| // 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<string, number>; | |
| /** 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<ViewConfig> | 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<string, string | null>; | |
| /** | |
| * 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; | |
| } | |