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