// --------------------------------------------------------------------------- // customer-grid / optimism.ts // Wave-6 item 3c — the NO-BLIP optimism layer's pure half: field DEFINITIONS // and CELL VALUES survive an iframe remount without ever waiting on the host's // echo. The companion of viewEcho.ts (which does the same for the FILTER tree), // built on the same rule and the same clock discipline. // // THE BLIP. Every edit is already rendered optimistically from local state // (setFields / overlayEdits). What un-renders it is the REMOUNT: a Streamlit // rerun replaces the iframe, local React state dies, and the remounted grid // re-initialises from that rerun's payload — which, on a lagged run (the value // slot is read at run start, [[streamlit-component-value-slot]]), predates the // edit. The rename reverts, the deleted column resurrects, the typed cell // flashes its old value: painted, plausible, and one round trip out of date. // // THE RULE, same as viewEcho: localStorage is this browser's newest truth. // - A field def EDITED here recently (per-key stamp) beats a host echo that // still differs on RENDERED props; a host copy that has caught up is // returned BYTE-IDENTICAL, so nothing churns. // - A field DELETED here recently (per-key tombstone) stays deleted even // when a lagged echo still carries the def. // - A cell value PATCHED here recently (journal) is re-seeded over a payload // that does not reflect it yet; an absorbed entry is pruned. // Everything stale (past ECHO_RECENT_MS) is archaeology: host state stays the // durable truth, exactly as before this module existed. // // RENDERED props deliberately exclude host/client acknowledgement metadata (`createdBy`, // `permissions`, edit/correction ids) — the item 3h return-value law compares on the same list, // and createdBy is STAMPED // host-side (a local def never has it first). That is what lets the stamp flow // into an otherwise-identical local def without a corrective repaint. // --------------------------------------------------------------------------- import type { Field, Row } from "./types"; import { ECHO_RECENT_MS } from "./viewEcho"; /** Per-key freshness for this browser's own field-def writes. Browser-clock * arithmetic on purpose — both timestamps come from the same machine (the * viewEcho rule; this is not the tenant-day contract). */ export interface FieldStamps { /** key -> when this browser last EDITED the def (create/rename/retype/format/…). */ edited?: Record; /** key -> when this browser DELETED the field. */ deleted?: Record; } /** Drop stamp entries past the echo window so the persisted blob stays small * and a stale stamp can never be revived by clock skew. */ export function pruneStamps( stamps: FieldStamps | undefined, now: number ): FieldStamps { const keep = (m: Record | undefined): Record => { const out: Record = {}; for (const [k, t] of Object.entries(m ?? {})) if (typeof t === "number" && now - t <= ECHO_RECENT_MS) out[k] = t; return out; }; return { edited: keep(stamps?.edited), deleted: keep(stamps?.deleted) }; } /** Deep key-sorted clone, so two structurally-equal defs built with different * property insertion orders (host json vs client literal) fingerprint alike. * Array ORDER is preserved — a select's option order is user-declared data. */ function stable(v: unknown): unknown { if (Array.isArray(v)) return v.map(stable); if (v && typeof v === "object") { const out: Record = {}; for (const k of Object.keys(v as object).sort()) { const x = (v as Record)[k]; if (x !== undefined) out[k] = stable(x); } return out; } return v; } /** * The def as the user SEES it — every prop except host/client acknowledgement metadata * (`createdBy`, `permissions`, edit/correction ids), mirroring the item-3h comparison list. Two defs * with equal fingerprints render identically, so preferring either is not a * visible choice and the HOST copy wins (it may carry newer stamps). */ export function renderedFingerprint(f: Field): string { const { createdBy: _cb, permissions: _pm, editRequestId: _er, labelCorrectedFrom: _lf, labelCorrectionId: _lc, ...rest } = f; return JSON.stringify(stable(rest)); } /** * Field defs at mount: host echo vs this browser's local copy. Replaces the * old `mergeFields` (host-always-wins), which is exactly the clobber that made * a rename/retype/delete blip across a lagged rerun. * * `hostAuthoritative` is false only in STANDALONE mode (no workspace in the * payload): there localStorage is the only store, so local props survive * broadly — the legacy spread — and locally-created source-'odoo' customs * (formula/created_time/measure_) are kept regardless of stamp age. */ export function reconcileFields( hostFields: Field[], localFields: Field[], stamps: FieldStamps | undefined, now: number, hostAuthoritative: boolean ): Field[] { const recent = (t: number | undefined): boolean => typeof t === "number" && now - t <= ECHO_RECENT_MS; const localByKey = new Map(localFields.map((f) => [f.key, f])); const out: Field[] = []; for (const host of hostFields) { const local = localByKey.get(host.key); localByKey.delete(host.key); // This browser deleted the field seconds ago; the echo has not caught up. // Resurrecting it (even for one round trip) is the delete blip. if (recent(stamps?.deleted?.[host.key])) continue; if (!local) { out.push(host); continue; } if (!hostAuthoritative) { // Standalone: localStorage is the store. Local props survive wherever the // API's base def does not carry them (the legacy merge, verbatim). out.push({ ...local, ...host, note: host.note ?? local.note }); continue; } const acknowledgedCorrection = host.labelCorrectedFrom === local.label && host.label !== local.label && typeof host.labelCorrectionId === "string" && host.labelCorrectionId === local.editRequestId; if (acknowledgedCorrection) { // This is not a lagged echo: the host accepted THIS exact optimistic write but had to // allocate another display name. Its correction must beat the recent-edit grace period. out.push(host); continue; } if ( recent(stamps?.edited?.[host.key]) && renderedFingerprint(local) !== renderedFingerprint(host) ) { // A provably-lagged echo of this browser's own in-flight edit: the LOCAL // def wins wholesale. The host-side stamp still flows in — the client // never asserts authorship, so a host-known createdBy is newer truth. out.push({ ...local, ...(host.createdBy != null ? { createdBy: host.createdBy } : {}), }); continue; } // Host caught up (fingerprints equal), or the local copy is archaeology: // the HOST object, byte-identical — no churn. One legacy nicety kept: a // local note survives a host copy that has none. out.push(host.note == null && local.note != null ? { ...host, note: local.note } : host); } for (const local of localByKey.values()) { if (!local.custom) continue; // W9 hardening: a key this browser DELETED recently must not resurrect from the // local copy either — the host-echo loop already refuses it, and a delete now has // two doors (column menu + Fields panel), so the tombstone guards both sides. if (recent(stamps?.deleted?.[local.key])) continue; if (local.source === "overlay") { // The shipped rule, unchanged: custom overlay fields persist locally. out.push(local); continue; } // Created source-'odoo' strata (formula / created_time / measure_ columns) // are host-persisted, so only an IN-FLIGHT one is kept — this closes the // wave-3 residual (d): an in-flight measure_ column no longer vanishes for // one round trip. With no host store they are local data and always kept. if (!hostAuthoritative || recent(stamps?.edited?.[local.key])) out.push(local); } return out; } // ------------------------------------------------------------- cell journal /** One committed cell edit, as patchOverlay sent it. */ export interface CellJournalEntry { pid: number; key: string; value: string | number | null; at: number; } /** Upper bound on journal size — an id-deduped recent window, same shape as the * host-event log (bounded > any real interaction burst). */ export const CELL_JOURNAL_MAX = 64; /** Record one committed edit, replacing any older entry for the same cell. */ export function journalUpsert( entries: CellJournalEntry[], entry: CellJournalEntry ): CellJournalEntry[] { const out = entries.filter((e) => !(e.pid === entry.pid && e.key === entry.key)); out.push(entry); return out.slice(-CELL_JOURNAL_MAX); } /** * The journal against a fresh payload. Three outcomes per entry, and only one * of them re-seeds: * absorbed the payload already shows the value -> pruned (host caught up) * recent differs, edited seconds ago -> kept + seeded over rows * stale differs, past the echo window -> pruned (host truth wins) * Values compare as strings because the overlay contract stores strings and a * number that round-trips through the store comes back as one. */ export function reconcileCellJournal( entries: CellJournalEntry[], rowByPid: (pid: number) => Row | undefined, now: number ): { keep: CellJournalEntry[]; seeds: Record> } { const keep: CellJournalEntry[] = []; const seeds: Record> = {}; for (const e of entries) { if ( e == null || typeof e.pid !== "number" || typeof e.key !== "string" || typeof e.at !== "number" ) continue; const row = rowByPid(e.pid); const hostValue = row ? row[e.key] : undefined; const same = String(hostValue ?? "") === String(e.value ?? ""); if (same) continue; if (now - e.at > ECHO_RECENT_MS) continue; keep.push(e); seeds[e.pid] = { ...seeds[e.pid], [e.key]: e.value }; } return { keep, seeds }; }