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