File size: 17,172 Bytes
bf8519f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dcdb685
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
// ---------------------------------------------------------------------------
// customer-grid / liveWorkspace.ts
// The workspace that arrives AFTER mount β€” pure and React-free, so
// verify_live_workspace.py can run it under node.
//
// β›” THE DEFECT THIS EXISTS FOR (owner, 2026-08-04). "Creating a new Cohort or
// Locked list under Product/Customer only shows up when I click a different
// module first and come back."
//
// It was exact. `CustomerGrid`'s init effect is gated on
// `initializedKey.current === storageKey`, so it reads `payload.workspace`
// EXACTLY ONCE per mount. Under Streamlit that was invisible β€” a rerun replaces
// the iframe, so every host round trip WAS a remount and init ran again. The
// standalone shell has no rerun: `CustomerGrid` stays mounted, and the only
// thing that remounts it is `key={active.key}` in Shell.tsx β€” i.e. switching
// modules, which is precisely the workaround the owner found.
//
// So the write path was complete and the READ path stopped at the door:
// `add_to_list` β†’ the host creates the set β†’ `rerender: true` β†’
// WORKSPACE_STALE_EVENT β†’ `reread()` β†’ `payload.workspace` genuinely carries the
// new locked view (wave 17 R1 projects every cohort as a view) β€” and `views`
// state, seeded once at init, never heard about it.
//
// TWO STRATA GO STALE TOGETHER, and fixing only the first would have looked
// fixed while staying broken:
//   views   the projected locked view = the rail row the owner is looking for.
//   fields  `fields_from_workspace(ws, cohorts=bool(cohort_lists))` β€” the
//           derived "Locked views" column EXISTS ONLY ONCE A COHORT DOES. The
//           user's FIRST cohort therefore changes the field contract, and an
//           init-once `fields` would have left that column out of the Fields
//           menu until the very remount we are removing the need for.
//
// ⭐ ADD-ONLY, AND THAT IS A DESIGN DECISION, NOT AN OMISSION. Everything the
// user already holds is left BY IDENTITY: an in-flight filter tree, a config
// mid-autosave (420 ms debounce), a rename waiting on its echo. This module is
// the answer to "what has APPEARED since we mounted", and nothing else. Taking
// host copies of things we already hold would re-introduce every blip
// optimism.ts / viewEcho.ts / folders.ts were written to remove β€” the whole
// no-blip layer rests on "this browser's copy is the newest truth".
//
// ⚠ TOMBSTONES ARE NOT OPTIONAL HERE, and an add-only merge without them is
// WORSE than the bug it fixes. The event queue sends ONE batch at a time
// (apiBridge `drain`), so this interleaving is ordinary:
//     batch 1 [add_to_list]  in flight
//     user deletes a view    β†’ removed optimistically, queued behind batch 1
//     batch 1 answers        β†’ rerender β†’ reread β†’ the workspace STILL lists
//                              the deleted view (its delete has not been sent)
// Without a tombstone the row comes back β€” and since this merge never removes,
// it would stay back until a remount. That is a delete that visibly failed.
// Same window and same rule as folders.ts and optimism.ts: ECHO_RECENT_MS, and
// anything past it yields to the host, because divergence is not an echo.
// ---------------------------------------------------------------------------

import { ECHO_RECENT_MS } from "./viewEcho";
import type { Field, SavedView, ViewConfig } from "./types";

/** id -> when THIS browser deleted it. Browser-clock arithmetic on purpose:
 *  both sides of the comparison come from this machine, so this is not the
 *  tenant-day contract ([[date-window-vocabulary]]) β€” that one is about two
 *  ENGINES agreeing on a date. */
export type Tombstones = Record<string, number>;

/** Upper bound on a tombstone map, matching FOLDER_STAMP_MAX. A long-lived tab
 *  must not accumulate an archive of everything it ever deleted. */
export const TOMBSTONE_MAX = 64;

const isRecent = (t: number | undefined, now: number): boolean =>
  typeof t === "number" && now - t <= ECHO_RECENT_MS;

/** Drop entries past the echo window. Called at every stamp AND before every
 *  persist, so the blob stays a recent window rather than a growing log. */
export function pruneTombstones(stamps: Tombstones | undefined, now: number): Tombstones {
  const kept = Object.entries(stamps ?? {}).filter(
    ([, t]) => typeof t === "number" && isRecent(t, now)
  );
  return Object.fromEntries(kept.slice(-TOMBSTONE_MAX));
}

/** Record one deletion. Pure so the caller's ref update stays a one-liner. */
export function stampTombstone(stamps: Tombstones | undefined, id: string, now: number): Tombstones {
  return pruneTombstones({ ...(stamps ?? {}), [id]: now }, now);
}

/**
 * ⭐ D-19 (wave 20) β€” **THE LOCALSTORAGE GHOST.**
 *
 * At init the grid seeded EVERY view from localStorage and then merged the host's list over the
 * top, so a view the host no longer names simply survived β€” for ever, in that browser. Three
 * ordinary paths produce one: the view was deleted from another tab or another machine, its
 * share was revoked, or the store moved under it. The row keeps working until you click it, and
 * then it is a saved view nobody else can see and no write can reach; the owner reported it as
 * "live and staging disagree". Item 13's pg cutover makes the host list authoritative for real,
 * which turns a rare confusion into a visible one.
 *
 * So: **the host's list decides which views exist.** A local copy the host does not name is
 * dropped β€” with two guards, and neither is optional:
 *
 *   1. `hostAuthoritative === false` keeps everything. Standalone with no `/workspace` (and the
 *      legacy embed) has no host list at all, and "not named" there means "not asked", not
 *      "deleted". Dropping on a payload that never carried views would empty the rail.
 *      β›” **An EMPTY host list counts as not-authoritative for the same reason, and this is the
 *      one branch that could destroy data.** A `/workspace` answering `200 {views: []}` is
 *      indistinguishable from a store that has not answered yet β€” a scope whose bucket is
 *      briefly empty during item 13's `hf β†’ pg` migration, a fresh backend, a bucket that was
 *      never seeded. Dropping there wipes every saved view in that browser, and the persist
 *      effect rewrites localStorage immediately after, so there is no second chance. The ghost
 *      this exists for is a view missing from a NON-EMPTY list; nothing is lost by refusing to
 *      act on no list at all.
 *   2. A view THIS BROWSER wrote inside the echo window survives. A create is optimistic: the
 *      row exists locally the instant it is made, and the host cannot name it until its
 *      `view_upsert` has been sent AND the next `/workspace` read has come back. Without this
 *      guard, creating a view and reloading fast enough would delete it β€” the exact inverse of
 *      the bug, and a worse one.
 *
 * Same window, same reasoning and the same stamp shape as the tombstones above: past
 * ECHO_RECENT_MS, divergence is not an echo.
 */
export function seedLocalViews(
  localViews: readonly SavedView[],
  hostViews: readonly SavedView[],
  writes: Tombstones | undefined,
  now: number,
  hostAuthoritative: boolean
): SavedView[] {
  if (!hostAuthoritative || hostViews.length === 0) return [...localViews];
  const named = new Set(hostViews.map((v) => v.id));
  return localViews.filter((v) => named.has(v.id) || isRecent(writes?.[v.id], now));
}

/**
 * Views that have APPEARED on the host since this browser last looked.
 *
 * Returns `current` BY IDENTITY when there is nothing to adopt β€” which is the
 * load-bearing half of the contract, not an optimisation. `withWorkspace` mints
 * a fresh payload object on every re-read, so `hostViews` changes identity each
 * time whether or not its contents did; a merge that always returned a new
 * array would re-render the grid (and re-write localStorage) on every echo.
 *
 * `normalize` is injected rather than imported because it needs the FIELD LIST
 * the caller is about to commit β€” a locked view's projected `config.order` names
 * the derived cohort column, and normalizing against a stale field array would
 * quietly drop the very key that arrived with it. The caller therefore adopts
 * fields FIRST and hands the result down (see CustomerGrid's live effect).
 */
export function adoptNewViews(
  current: SavedView[],
  hostViews: SavedView[] | undefined,
  tombstones: Tombstones | undefined,
  now: number,
  normalize: (config: Partial<ViewConfig> | undefined) => ViewConfig
): SavedView[] {
  if (!Array.isArray(hostViews) || hostViews.length === 0) return current;
  const held = new Set(current.map((v) => v.id));
  const fresh: SavedView[] = [];
  for (const view of hostViews) {
    if (!view || typeof view.id !== "string" || view.id === "") continue;
    if (held.has(view.id)) continue;
    // Deleted here seconds ago and the echo has not caught up. Resurrecting it β€”
    // even for one round trip β€” is the delete blip, and this merge never removes,
    // so it would be a permanent one.
    if (isRecent(tombstones?.[view.id], now)) continue;
    held.add(view.id);                    // a host list with a duplicate id adds once
    fresh.push({ ...view, config: normalize(view.config) });
  }
  return fresh.length === 0 ? current : [...current, ...fresh];
}

/**
 * Fields that have APPEARED on the host since this browser last looked β€” in
 * practice the derived "Locked views" column, which the server emits only once
 * the user owns at least one cohort.
 *
 * Appended in host order at the END, which is where `fields_from_workspace`
 * puts the derived column anyway, and `reconcileOrder` folds any key missing
 * from a saved `config.order` in for us β€” so nothing has to touch a stored view
 * for the new column to become togglable in the Fields menu.
 *
 * ⚠ The tombstone map here is `FieldStamps.deleted`, the SAME one
 * `reconcileFields` consults at mount, for the same reason: a column this
 * browser dropped must not walk back in through a lagged echo.
 */
export function adoptNewFields(
  current: Field[],
  hostFields: Field[] | undefined,
  tombstones: Tombstones | undefined,
  now: number
): Field[] {
  if (!Array.isArray(hostFields) || hostFields.length === 0) return current;
  const held = new Set(current.map((f) => f.key));
  const fresh: Field[] = [];
  for (const field of hostFields) {
    if (!field || typeof field.key !== "string" || field.key === "") continue;
    if (held.has(field.key)) continue;
    if (isRecent(tombstones?.[field.key], now)) continue;
    held.add(field.key);
    fresh.push(field);
  }
  return fresh.length === 0 ? current : [...current, ...fresh];
}

// ===========================================================================
// THE CHANGE TOKEN (wave 29, item 20 / R11 / contract C6)
//
// Everything above answers "what appeared in the payload we just read". This
// half answers the question nobody was asking at all: **should we read?**
//
// β›” THE DEFECT (owner, 2026-08-10): a record created in another tab, by an
// automation, or by a connector sync does not appear until you reload. The
// rows fetch runs once per mount and re-runs only on ROWS_STALE_EVENT β€” and
// EVERY dispatcher of that event is this browser's own write path. Of the
// owner's three writers, only "another tab" emits anything, and only into the
// tab that did the writing. There is no polling, no revalidation, no
// visibilitychange and no BroadcastChannel anywhere in the client.
//
// The server now publishes a per-bucket revision that costs no deep copy
// (`GET /api/v1/changes`). These are the pure decisions taken on it, here
// rather than inside the effect so `verify_live_workspace.py` can run them
// under node β€” the same reason the merges above are pure.
// ===========================================================================

/** `{bucket: opaque token}`. A null means the backend publishes no revision for
 *  that bucket (Odoo-cached rows; a store backend that has none yet) β€” "do not
 *  poll this", which is a different fact from "unchanged". */
export type ChangeTokens = Record<string, string | null>;

/**
 * Which buckets genuinely changed between two observations.
 *
 * ⭐ A CHANGE IS REPORTED ONLY WHEN BOTH SIDES CARRY A NON-NULL TOKEN AND THEY
 * DIFFER. Every other case β€” the first observation, a poll that failed, a
 * bucket that has no token, a bucket appearing or disappearing from the map β€”
 * reports NOTHING, and the reasoning is asymmetric on purpose:
 *
 *   * a missed change costs one stale view until the next real write, which is
 *     exactly today's behaviour and therefore cannot be a regression;
 *   * a false change costs a full rows re-read, and a false change that repeats
 *     EVERY interval costs one per tab per 10 s β€” the melt this whole design
 *     exists to avoid.
 *
 * So the unknown cases resolve to "no", and each of them is a one-time
 * transition (first poll, a deploy adding a bucket, a backend flip) rather than
 * a standing condition. The alerts engine takes the identical `seeded` posture
 * server-side: its first evaluation raises nothing.
 */
export function changedBuckets(
  prev: ChangeTokens | null | undefined,
  next: ChangeTokens | null | undefined
): string[] {
  if (!prev || !next) return [];         // no baseline yet, or the poll failed
  const out: string[] = [];
  for (const [bucket, token] of Object.entries(next)) {
    if (typeof token !== "string" || token === "") continue;
    const before = prev[bucket];
    if (typeof before !== "string" || before === "") continue;   // baseline it
    if (before !== token) out.push(bucket);
  }
  return out;
}

/** What a set of changed buckets asks this tab to do. */
export interface ChangePlan {
  /** Drop this topic's rows memo and re-read the pool β€” the expensive one. */
  refetchRows: boolean;
  /** Re-read `/workspace` β€” views, fields, derived cells. The cheap one. */
  rereadWorkspace: boolean;
}

/**
 * β›” THE ROWS MEMO IS WHY THIS IS NOT JUST "REFETCH". `fetchTopicRows` holds a
 * FIVE-MINUTE per-topic memo, cleared only by the writing tab, so a refetch
 * triggered without dropping it is served from the copy that predates the very
 * change we detected: a poll that costs a request, reports success, and shows
 * the user nothing new. The caller must drop the memo for THIS topic β€”
 * `clearTopicRowsCache(topic.rowsPath)`, never `clearCustomersCache()`, which
 * would evict the other topic's window and re-download ~1 MB on the next
 * surface switch.
 *
 * An UNRECOGNISED bucket takes the CHEAP branch. A later server may publish a
 * bucket this build has never heard of (the shared overlay stratum is exactly
 * that case), and "something I do not understand changed" should cost a small
 * workspace read, never a pool download.
 */
export function planChangeReaction(changed: readonly string[]): ChangePlan {
  const plan: ChangePlan = { refetchRows: false, rereadWorkspace: false };
  for (const bucket of changed) {
    if (bucket === "rows") plan.refetchRows = true;
    else plan.rereadWorkspace = true;
  }
  return plan;
}

/**
 * The floor between two POOL re-reads of one topic. Only bites under sustained writing.
 *
 * β›” WHY A FLOOR IS NOT OPTIONAL, and the arithmetic is the same one that killed the naive design.
 * `user_tables.STORE_KEY` is ONE bucket holding EVERY `ut_*` table in a tenant, so the rows token
 * for every user table moves together β€” and while an automation upserts rows, or the relation
 * refresh commits, the token moves on every single interval. Without a floor each open tab would
 * then re-read the whole pool six times a minute, at three full-tenant deep copies per read: the
 * "two tabs saturate the server" case the poller was designed to avoid, reached through the
 * REFETCH instead of through the poll. Bounding cost per token request and leaving refetches
 * unbounded would just move the melt one step downstream.
 */
export const ROWS_REFETCH_FLOOR_MS = 30_000;

/**
 * How long to wait before re-reading this topic's pool: `0` = now.
 *
 * ⭐ IT COALESCES, IT NEVER DROPS. A caller that is told to wait must schedule the read for the
 * boundary, not discard the signal β€” several changes inside the window collapse into ONE read,
 * which is the same shape `routes_tables._refresh_relations` uses server-side and for the same
 * reason. Discarding would leave a tab stale for good if the burst ended right after a suppressed
 * change.
 *
 * ⚠ THE IDLE CASE IS UNAFFECTED, which is what keeps the owner's "about ten seconds" true: one
 * person adding one record hits a `lastAt` far in the past and re-reads immediately on the next
 * poll. The floor is only reachable when changes arrive faster than it.
 */
export function rowsRefetchDelay(lastAt: number, now: number,
                                 floorMs: number = ROWS_REFETCH_FLOOR_MS): number {
  if (!lastAt) return 0;                       // never read on this surface β€” go now
  const since = now - lastAt;
  return since >= floorMs ? 0 : floorMs - since;
}