File size: 10,325 Bytes
092334a 5351cc8 092334a 5351cc8 092334a 5351cc8 092334a 5351cc8 092334a | 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 | // ---------------------------------------------------------------------------
// 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 };
}
|