File size: 17,172 Bytes
bf8519f dcdb685 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 | // ---------------------------------------------------------------------------
// 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;
}
|