| // --------------------------------------------------------------------------- | |
| // 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<string, number>; | |
| /** key -> when this browser DELETED the field. */ | |
| deleted?: Record<string, number>; | |
| } | |
| /** 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<string, number> | undefined): Record<string, number> => { | |
| const out: Record<string, number> = {}; | |
| 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<string, unknown> = {}; | |
| for (const k of Object.keys(v as object).sort()) { | |
| const x = (v as Record<string, unknown>)[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<number, Partial<Row>> } { | |
| const keep: CellJournalEntry[] = []; | |
| const seeds: Record<number, Partial<Row>> = {}; | |
| 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 }; | |
| } | |