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 };
}