@rindle/optimistic 0.1.0-rc.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,998 @@
1
+ // OptimisticBackend — the composition that adds OPTIMISTIC WRITES to the normalized
2
+ // local-first path (OPTIMISTIC-WRITES-DESIGN.md §8.5, §9). The richer cousin of
3
+ // `NormalizedBackend`: the same substrate (a local `@rindle/wasm` engine + a server
4
+ // stream + `NormalizedSync`), plus
5
+ //
6
+ // - a **named client-mutator registry**: `invoke("createIssue", args)` runs the
7
+ // mutator against the live engine NOW (the prediction shows instantly), pushes
8
+ // `(mid, name, args)` — never effects — onto the pending stack, and ships the
9
+ // envelope upstream (§4);
10
+ // - **lmid-as-data**: at construction the backend subscribes its own one-row
11
+ // SYSTEM QUERY (`LMID_QUERY_NAME` → `_rindle_client_mutations WHERE client_id =
12
+ // me`). The server writes `lmid` in the SAME transaction as a mutation's
13
+ // effects, so the confirmation arrives as an ordinary cv-tagged data frame and
14
+ // is released by the same `cvMin` — confirmation can never skew from the data
15
+ // of its own commit (the drain race the old frame-carried lmid suffered);
16
+ // - **cv-buffering**: every `cv`-tagged data frame buffers; a progress frame
17
+ // releases all `cv ≤ cvMin` as ONE coherent step — data ops into the engine's
18
+ // `sync` side, lmid-table ops into `confirmedLmid` (§1.3.1/§8.5 — no torn reads
19
+ // across queries, no apply ahead of the release point);
20
+ // - the **§1.3 reconcile cycle** per release: the wasm engine rewinds
21
+ // (`serverBatchBegin`), every still-pending mutator is **re-invoked** against the
22
+ // rebased base (read-dependent mutators read current values — §4.1), and the
23
+ // whole cycle coalesces to one minimal delivery (`serverBatchEnd`) — a
24
+ // confirmed-correct prediction produces zero churn;
25
+ // - per-query **ResultType** is the SERVER CHANNEL's state only (FOLDED-MUTATIONS-DESIGN
26
+ // §7): `unknown` while not hydrated, else `complete`. A pending local mutation no longer
27
+ // moves it — "is a prediction pending here?" is its own reactive axis (`pending(qid)` /
28
+ // `onPending`), so a fold held through its debounce window doesn't pin a query loading.
29
+ // There is NO server rejection signal: a failed mutation is processed-as-no-op (lmid
30
+ // advances, effects rolled back) and the prediction snaps back on the ordinary release.
31
+ // - **folded mutations** (FOLDED-MUTATIONS-DESIGN): `invokeFolded` collapses a run of
32
+ // same-key absorbing writes to ONE pending entry whose `args` are overwritten in place,
33
+ // debounces the server write (a virtual `clock` seam keeps it test-deterministic), and
34
+ // ships only the last value. The `mid` is assigned at FLUSH (not invoke), so the gapless
35
+ // wire sequence is preserved under debounce (§4.1); an overlapping non-fold write drains
36
+ // the interacting fold first (`drainOverlapping`, §4.2 flush-on-enqueue).
37
+ //
38
+ // `Store`/`ArrayView` are untouched: this implements `Backend`, so
39
+ // `new Store(schema, new OptimisticBackend(...))` reuses the whole machinery.
40
+ import { CLIENT_MUTATIONS_SCHEMA, LMID_QUERY_NAME, normalizedTableSchemas, tableSpec } from "@rindle/client";
41
+ import { aggTableSchemas, NormalizedSync, rewriteAggregates } from "@rindle/normalized";
42
+ import { WasmBackend } from "@rindle/wasm";
43
+ import { AggOverlay, collectAggDefs } from "./agg-overlay.js";
44
+ /** The reserved source-qid of the backend's own lmid system query. User/local query ids
45
+ * are assigned by the `Store` starting at 1, so 0 never collides. */
46
+ const LMID_QID = 0;
47
+ /** Thrown by the read-trap tx when a FOLDED mutator reads state (`tx.get`/`tx.row`) — the classic
48
+ * non-absorbing shape, refused at the folded path (FOLDED-MUTATIONS-DESIGN §5). */
49
+ class FoldReadError extends Error {
50
+ }
51
+ /** Default trailing-debounce window for a folded call site (FOLDED-MUTATIONS-DESIGN §3 example). */
52
+ const DEFAULT_FOLD_DEBOUNCE_MS = 120;
53
+ /** The real-timer clock used when none is injected. */
54
+ const REAL_CLOCK = {
55
+ setTimeout: (cb, ms) => setTimeout(cb, ms),
56
+ clearTimeout: (h) => clearTimeout(h),
57
+ now: () => Date.now(),
58
+ };
59
+ export class OptimisticBackend {
60
+ local;
61
+ sync;
62
+ source;
63
+ registry;
64
+ clientID;
65
+ bufferCap;
66
+ /** Column order + pk indices per table, for the keyed `MutationTx` methods. */
67
+ specs;
68
+ /** Each table's full column count (union-row width) + column-name → base ColId — to learn a
69
+ * projected query's per-table projection off its `hello` and register it with the sync layer,
70
+ * so it scatters that query's narrower rows into the shared union (PROJECTION-SUPPORT-DESIGN
71
+ * §5.2). Without this a projected query's short rows reach the wasm `Db` un-scattered and fail
72
+ * its width check. */
73
+ colCounts;
74
+ colIndex;
75
+ /** The client's OWN typed per-table schemas + the reserved lmid table — the fixed base
76
+ * of the expected-schema set (CRIT#4 validation). Synthetic agg tables are appended as
77
+ * queries arrive (`ensureSyntheticTables`). */
78
+ clientTablesBase;
79
+ /** Synthetic aggregate tables (`__agg_*`) registered so far, by name (AGGREGATE-SYNC-DESIGN
80
+ * §3.3). Per aggregate DEFINITION (not per query), so two queries over the same count
81
+ * share one table. */
82
+ synthetic = new Map();
83
+ /** Synthetic table name → how many registered LOCAL queries reference it. A table is
84
+ * materialized on the `0→1` transition and reclaimed (engine source + baseline + refcount
85
+ * layer + overlay def) on `1→0` — so aggregate state is not permanent (§4). */
86
+ syntheticRefs = new Map();
87
+ /** Local qid → the synthetic tables it referenced at registration, to decrement on teardown. */
88
+ queryAggTables = new Map();
89
+ /** The optimistic aggregate overlay (§4–§6): the per-aggregate definitions + the per-group
90
+ * pending delta `displayed = server_base ⊕ local_pending_delta` is applied from. */
91
+ overlay = new AggOverlay();
92
+ handler = () => { };
93
+ pendingMutations = [];
94
+ nextMid = 1;
95
+ /** The live fold entries, by fold key `${name}\0${identityJSON}` — at most one per key
96
+ * (FOLDED-MUTATIONS-DESIGN §8). Insertion order is creation order (the drain/flush tiebreak). */
97
+ folds = new Map();
98
+ /** The fold debounce clock (real timers by default; the oracle injects a virtual one). */
99
+ clock;
100
+ /** The high-water confirmed mutation id, folded from the lmid system query's
101
+ * RELEASED ops (lmid-as-data) — never from a frame. */
102
+ confirmedLmid = 0;
103
+ buffer = [];
104
+ nextSeq = 0;
105
+ appliedCv = 0;
106
+ asts = new Map();
107
+ /** Per query: the base tables its result can draw from (from the AST tree). */
108
+ queryTables = new Map();
109
+ remoteSubs = new Map();
110
+ sourceToRemote = new Map();
111
+ localToRemote = new Map();
112
+ remoteRetainToLocal = new Map();
113
+ resultTypes = new Map();
114
+ /** Local view qids that are server-authoritative: a query with no remote sub (purely local) is
115
+ * hydrated on registration; a remote query is hydrated when its sub's first snapshot releases.
116
+ * An un-hydrated query reports `unknown` (still loading) — the basis of `resultType`. */
117
+ hydrated = new Set();
118
+ resultTypeHandler = () => { };
119
+ /** The pending AXIS (§7.2), split off `ResultType`: per query, whether any pending mutation
120
+ * touches its tables. Cached so `onPending` fires only on transitions (invoke ↔ confirm). */
121
+ pendingState = new Map();
122
+ pendingHandler = () => { };
123
+ constructor(schema, source, registry, opts) {
124
+ this.local = new WasmBackend(schema);
125
+ this.local.onEvent((qid, ev) => this.handler(qid, ev));
126
+ this.colCounts = colCountsFromSchema(schema);
127
+ this.colIndex = colIndexFromSchema(schema);
128
+ this.sync = new NormalizedSync(pkColsFromSchema(schema), this.colCounts);
129
+ this.specs = tableSpecsFromSchema(schema);
130
+ this.source = source;
131
+ this.registry = registry;
132
+ this.clientID = opts.clientID;
133
+ this.bufferCap = opts.bufferCap ?? 1024;
134
+ this.clock = opts.clock ?? REAL_CLOCK;
135
+ // Validate each server hello against our OWN typed schema → reject a schema skew
136
+ // (CRIT#4). The reserved lmid table is part of the expected set so the system
137
+ // query's hello passes. Synthetic agg tables join the set as queries register them.
138
+ this.clientTablesBase = [...normalizedTableSchemas(schema), CLIENT_MUTATIONS_SCHEMA];
139
+ this.source.expectClientSchema?.(this.clientTablesBase);
140
+ this.source.onNormalized((qid, ev) => this.onNormalized(qid, ev));
141
+ this.source.onProgress((frame) => this.onProgress(frame));
142
+ this.source.onRestart?.(() => this.resetForRestart());
143
+ // The lmid system query (lmid-as-data): our confirmations arrive on this stream,
144
+ // cv-tagged, released by the same cvMin as the data they belong to. The server
145
+ // derives the identity from the connection; args are advisory.
146
+ this.source.registerQuery(LMID_QID, { name: LMID_QUERY_NAME, args: {} });
147
+ }
148
+ // --- the Backend seam ---------------------------------------------------------
149
+ registerQuery(qid, ast, remote) {
150
+ this.asts.set(qid, ast);
151
+ // queryTables is derived from the ORIGINAL ast — its `count(comments)` subquery names
152
+ // `comment`, so an optimistic comment mutation flips this query to `unknown` (§6). The
153
+ // local engine, by contrast, runs the REWRITTEN ast (reads the synthetic `__agg_*`).
154
+ this.queryTables.set(qid, collectTables(ast));
155
+ // A relationship `count` is DISPLAYED from a server-authoritative synthetic base table,
156
+ // not recomputed locally (AGGREGATE-SYNC-DESIGN.md §3.3): register that table (engine +
157
+ // refcount layer + hello validation), then drive the local engine off a rewritten AST
158
+ // whose `count` relationships read it with a plain projected join. The remote query stays
159
+ // un-rewritten — the server always emits the synthetic `__agg_*` rows.
160
+ this.ensureSyntheticTables(qid, ast);
161
+ // Local first (synchronous empty view), then the server stream hydrates it.
162
+ this.local.registerQuery(qid, rewriteAggregates(ast));
163
+ if (remote) {
164
+ // A remote query is `unknown` until its first server snapshot lands (hydration); retainRemote
165
+ // attaches it to the sub and sets the lifecycle against the sub's hydration state.
166
+ this.retainRemote(qid, remote);
167
+ }
168
+ else {
169
+ // No server stream (a purely local AST view — or the local half of a split retain whose
170
+ // remote attaches separately via `retainRemoteQuery`): local data is synchronous, so it is
171
+ // `complete` with nothing to await. A later remote retain flips it back to `unknown` if it
172
+ // attaches an un-hydrated sub.
173
+ this.hydrated.add(qid);
174
+ this.setResultType(qid, "complete");
175
+ }
176
+ }
177
+ /** Register every synthetic aggregate table `ast` needs that we haven't seen yet: on the
178
+ * local engine (which auto-tracks it for the optimistic rebase loop), on `NormalizedSync`
179
+ * (so its rows refcount/GC by group key), and into the source's expected-schema set (so
180
+ * the server's `hello` — which advertises the same table — passes CRIT#4 validation).
181
+ * Idempotent across queries that share an aggregate definition. */
182
+ ensureSyntheticTables(qid, ast) {
183
+ // Idempotent per qid: a re-register of the same query keeps the refcounts balanced.
184
+ if (this.queryAggTables.has(qid))
185
+ return;
186
+ let added = false;
187
+ const names = [];
188
+ for (const t of aggTableSchemas(ast)) {
189
+ names.push(t.name);
190
+ const prev = this.syntheticRefs.get(t.name) ?? 0;
191
+ this.syntheticRefs.set(t.name, prev + 1);
192
+ if (prev > 0)
193
+ continue; // another query already materialized this table — just refcount
194
+ this.synthetic.set(t.name, t);
195
+ this.local.registerTable(t.name, { columns: t.columns, primaryKey: t.primaryKey });
196
+ this.sync.registerTable(t.name, t.primaryKey);
197
+ added = true;
198
+ }
199
+ if (names.length)
200
+ this.queryAggTables.set(qid, names);
201
+ // The optimistic delta (§4) needs each aggregate's child table + group key + filter, which
202
+ // the synthetic schema alone doesn't carry — derive the definitions from the original AST.
203
+ this.overlay.register(collectAggDefs(ast, (t) => this.specs[t]?.columns));
204
+ if (added)
205
+ this.source.expectClientSchema?.([...this.clientTablesBase, ...this.synthetic.values()]);
206
+ }
207
+ /** Decrement the refcount of every synthetic table query `qid` referenced; for each one that
208
+ * reaches 0 (no live reader left), remove it from the engine, the refcount layer, and the
209
+ * overlay — so aggregate state is reclaimed, not permanent (§4). Must run AFTER
210
+ * `local.unregisterQuery(qid)` so the engine source has no live connection when
211
+ * `unregisterTable` frees it (the engine refuses otherwise). */
212
+ releaseSyntheticTables(qid) {
213
+ const names = this.queryAggTables.get(qid);
214
+ if (!names)
215
+ return;
216
+ this.queryAggTables.delete(qid);
217
+ let removed = false;
218
+ for (const name of names) {
219
+ const next = (this.syntheticRefs.get(name) ?? 1) - 1;
220
+ if (next > 0) {
221
+ this.syntheticRefs.set(name, next);
222
+ continue;
223
+ }
224
+ this.syntheticRefs.delete(name);
225
+ this.local.unregisterTable(name);
226
+ this.sync.unregisterTable(name);
227
+ this.overlay.unregister(name);
228
+ this.synthetic.delete(name);
229
+ removed = true;
230
+ }
231
+ if (removed)
232
+ this.source.expectClientSchema?.([...this.clientTablesBase, ...this.synthetic.values()]);
233
+ }
234
+ unregisterQuery(qid) {
235
+ const remoteQid = this.releaseRemote(qid);
236
+ if (remoteQid !== undefined)
237
+ this.buffer = this.buffer.filter((f) => f.qid !== remoteQid);
238
+ // GC: rows this remote footprint SOLELY referenced fall to refcount 0 → net removes.
239
+ const gc = remoteQid === undefined ? [] : this.sync.dropQuery(remoteQid);
240
+ // Tear down the local pipeline+view first so the reconcile cycle below skips it.
241
+ this.local.unregisterQuery(qid);
242
+ // The GC removals must leave BOTH head AND the engine's `sync` baseline. A plain
243
+ // `local.mutate` (a HEAD-only write) leaves them in `sync`, so they look like a pending
244
+ // optimistic REMOVE: the next release's rewind diffs head against sync+D and RESURRECTS
245
+ // them, GC never frees anything, and a later query is served the stale/deleted row
246
+ // forever (CRIT#2). Deliver them as a coherent SERVER delta instead — the same
247
+ // sync-moving boundary `onProgress` uses — so head and sync both drop the rows.
248
+ if (gc.length)
249
+ this.runReconcileCycle(gc);
250
+ // The local pipeline is gone (no live conn) and the remote footprint's `__agg` rows were
251
+ // GC'd above, so any synthetic table this was the last reader of can now be freed (§4).
252
+ this.releaseSyntheticTables(qid);
253
+ this.asts.delete(qid);
254
+ this.queryTables.delete(qid);
255
+ this.resultTypes.delete(qid);
256
+ this.hydrated.delete(qid);
257
+ this.pendingState.delete(qid); // §7.2 cache, keyed by the local materialized qid (a monotonic
258
+ // Store counter — re-materialize gets a fresh id, never this one again), so drop it on teardown.
259
+ }
260
+ retainRemoteQuery(qid, remote, localQueryId) {
261
+ this.retainRemote(qid, remote, localQueryId);
262
+ }
263
+ releaseRemoteQuery(qid) {
264
+ const remoteQid = this.releaseRemote(qid);
265
+ if (remoteQid === undefined)
266
+ return;
267
+ this.buffer = this.buffer.filter((f) => f.qid !== remoteQid);
268
+ const gc = this.sync.dropQuery(remoteQid);
269
+ if (gc.length)
270
+ this.runReconcileCycle(gc);
271
+ }
272
+ /** Raw CRUD has no optimistic story (§9 replaces it with named mutators). Register a
273
+ * mutator — even a trivial one — and `invoke` it. */
274
+ mutate(_mutations) {
275
+ return Promise.reject(new Error("optimistic backend: writes go through named mutators — use invoke(name, args)"));
276
+ }
277
+ onEvent(handler) {
278
+ this.handler = handler;
279
+ }
280
+ // --- the named-mutator entry (§9) ----------------------------------------------
281
+ /** Run the named client mutator optimistically: the prediction applies to the live
282
+ * engine now (affected views update synchronously), `(mid, name, args)` joins the
283
+ * pending stack, and the envelope ships upstream. Returns the assigned `mid`. */
284
+ invoke(name, args) {
285
+ const mutator = this.registry[name];
286
+ if (!mutator)
287
+ throw new Error(`unknown client mutator: ${name}`);
288
+ // Apply the prediction FIRST. If the mutator throws (client-side validation, a bad read),
289
+ // the staged write is discarded (the wasm txn is a clean no-op until commit) and the throw
290
+ // propagates with NO mid consumed — a burnt mid is a permanent server-side gap that
291
+ // silently refuses every later mutation from this client (#10).
292
+ const touched = new Set();
293
+ const ops = [];
294
+ this.local.writeWith((tx) => {
295
+ mutator(trackingTx(tx, touched, this.specs, this.opCollector(ops)), args);
296
+ });
297
+ // Flush-on-enqueue (§4.2): a fold whose tables overlap this write must take its mid NOW, BEFORE
298
+ // this write does, so wire order == local-apply order for any pair that can observe each other
299
+ // (a read-dependent write reading a folded cell sees the same value optimistically and on the
300
+ // wire — no snap). Drained folds ship with smaller mids; this write's mid is dealt after.
301
+ this.drainOverlapping(touched);
302
+ const mid = this.nextMid++;
303
+ this.pendingMutations.push({ mid, name, args, touched });
304
+ // The prediction stuck — fold its child ops into the optimistic agg delta and push it onto
305
+ // the `__agg` head rows (§4). No reset here (this is the §1.3 trivial case, no rewind): the
306
+ // delta accumulates on top of the prior pending set, and `reconcileAggHead` recomputes each
307
+ // touched group's head as the absolute `server_base ⊕ delta`.
308
+ for (const op of ops)
309
+ this.overlay.observe(op);
310
+ this.reconcileAggHead();
311
+ this.refreshPending(); // §7.2: this write now touches its queries' pending axis (NOT ResultType).
312
+ void this.source.pushMutation({ clientID: this.clientID, mid, name, args });
313
+ return mid;
314
+ }
315
+ /** Run a FOLDED invoke (FOLDED-MUTATIONS-DESIGN §8): apply the prediction to the live engine now
316
+ * (like `invoke`), but collapse a run of same-key invokes into ONE pending entry whose `args`
317
+ * are overwritten in place, debounce the server write, and ship only the last value. The `mid`
318
+ * is assigned at flush, not here (§4.1) — so the return is a {@link FoldHandle}, not a mid. */
319
+ invokeFolded(name, opts, args) {
320
+ const mutator = this.registry[name];
321
+ if (!mutator)
322
+ throw new Error(`unknown client mutator: ${name}`);
323
+ const foldKey = `${name}\0${stableJson(opts.key)}`;
324
+ // Apply the prediction with the read trap armed (§5): a folded mutator that reads state to
325
+ // compute its write is non-absorbing and refused. A throw discards the staged write (clean
326
+ // no-op) and consumes no mid — exactly `invoke`'s guarantee.
327
+ const touched = new Set();
328
+ const ops = [];
329
+ try {
330
+ this.local.writeWith((tx) => {
331
+ mutator(trackingTx(tx, touched, this.specs, this.opCollector(ops), true), args);
332
+ });
333
+ }
334
+ catch (e) {
335
+ if (e instanceof FoldReadError) {
336
+ throw new Error(`cannot fold "${name}": it reads state via tx.get/tx.row, so it is not absorbing — folded mutators must be last-writer-wins (FOLDED-MUTATIONS-DESIGN §5)`);
337
+ }
338
+ throw e;
339
+ }
340
+ for (const op of ops)
341
+ this.overlay.observe(op);
342
+ this.reconcileAggHead();
343
+ const now = this.clock.now();
344
+ let f = this.folds.get(foldKey);
345
+ if (f) {
346
+ // Overwrite the single entry in place — the pending stack does NOT grow (§1 #2). The head
347
+ // already carries this new prediction (absorbing, last-wins on the cell); the entry holds
348
+ // only the LATEST args, which is what a rebase re-derives from and what the flush ships.
349
+ f.entry.args = args;
350
+ f.entry.touched = touched;
351
+ f.args = args;
352
+ this.clock.clearTimeout(f.timer);
353
+ }
354
+ else {
355
+ const entry = { mid: null, name, args, touched };
356
+ this.pendingMutations.push(entry);
357
+ let resolveMid;
358
+ const midPromise = new Promise((res) => (resolveMid = res));
359
+ f = {
360
+ entry,
361
+ args,
362
+ timer: undefined,
363
+ firstAt: now,
364
+ debounceMs: opts.debounceMs ?? DEFAULT_FOLD_DEBOUNCE_MS,
365
+ maxWaitMs: opts.maxWaitMs,
366
+ deferAcrossWrites: opts.deferAcrossWrites ?? false,
367
+ midPromise,
368
+ resolveMid,
369
+ };
370
+ this.folds.set(foldKey, f);
371
+ }
372
+ // (Re)arm the trailing debounce — unless the maxWaitMs cap is already due (a never-idle drag
373
+ // still persists periodically, §3/#4), in which case flush now instead of re-arming.
374
+ if (f.maxWaitMs !== undefined && now - f.firstAt >= f.maxWaitMs) {
375
+ this.flushFold(foldKey);
376
+ }
377
+ else {
378
+ f.timer = this.clock.setTimeout(() => this.flushFold(foldKey), f.debounceMs);
379
+ }
380
+ this.refreshPending();
381
+ const handle = { flush: () => this.flushFold(foldKey), mid: f.midPromise };
382
+ return handle;
383
+ }
384
+ /** Flush-on-enqueue (§4.2): for each outstanding fold whose touched tables overlap `tables`,
385
+ * assign its mid NOW and ship it — in creation (insertion) order, so the wire stays gapless. A
386
+ * `deferAcrossWrites` fold opts out (it keeps deferring, accepting the read-dependent snap). The
387
+ * incoming write's own fold key (if any) is skipped — it is being folded into, not flushed. */
388
+ drainOverlapping(tables, exceptKey) {
389
+ // Snapshot the entries first: `flushFold` mutates `this.folds` mid-iteration.
390
+ for (const [key, f] of [...this.folds]) {
391
+ if (key === exceptKey || f.deferAcrossWrites)
392
+ continue;
393
+ if (intersects(f.entry.touched, tables))
394
+ this.flushFold(key);
395
+ }
396
+ }
397
+ /** Flush one fold (§8): deal its `mid` from `nextMid` (SEND order — never reserved, so gapless
398
+ * by construction), stamp the entry, ship the envelope with the LATEST args, resolve the handle.
399
+ * The entry stays on `pendingMutations` (now with a real mid) until the lmid release confirms it. */
400
+ flushFold(foldKey) {
401
+ const f = this.folds.get(foldKey);
402
+ if (!f)
403
+ return;
404
+ this.clock.clearTimeout(f.timer);
405
+ this.folds.delete(foldKey);
406
+ const mid = this.nextMid++;
407
+ f.entry.mid = mid;
408
+ void this.source.pushMutation({ clientID: this.clientID, mid, name: f.entry.name, args: f.args });
409
+ f.resolveMid(mid);
410
+ }
411
+ /** Drain every outstanding fold immediately (FOLDED-MUTATIONS-DESIGN §3): the explicit
412
+ * `app.flushFolds()` and the `beforeunload`/`close` hook. Creation (insertion) order. */
413
+ flushFolds() {
414
+ for (const key of [...this.folds.keys()])
415
+ this.flushFold(key);
416
+ }
417
+ /** A `trackingTx` op collector that records only the ops over a tracked aggregate's child
418
+ * table (the others can't move any count). Applied to the overlay by the caller AFTER the
419
+ * mutator succeeds, so a throwing mutator (whose staged write is discarded) leaves no delta. */
420
+ opCollector(ops) {
421
+ return (op) => {
422
+ if (this.overlay.hasChild(op.table))
423
+ ops.push(op);
424
+ };
425
+ }
426
+ /** Push the optimistic per-group delta onto the `__agg` head rows (§4):
427
+ * `target = server_base ⊕ delta`. A head-only write to the (tracked) synthetic table, so it
428
+ * joins the optimistic layer and is rewound/rebuilt by the reconcile cycle like any
429
+ * prediction. `server_base` is read from `NormalizedSync` (the authoritative base) — NOT
430
+ * from head, which already carries the optimistic layer (a torn read). Works standalone (an
431
+ * ordinary delivery) and inside an open cycle (the write buffers into it). */
432
+ reconcileAggHead() {
433
+ const entries = this.overlay.entries();
434
+ if (entries.length === 0)
435
+ return;
436
+ this.local.writeWith((tx) => {
437
+ for (const e of entries) {
438
+ const countCol = e.def.groupKeyCols.length; // row is [group…, count]; count is at index k
439
+ const serverRow = this.sync.baseRow(e.aggTable, e.cells);
440
+ const serverBase = serverRow ? Number(serverRow[countCol]) : 0;
441
+ const target = Math.max(0, serverBase + e.n); // a displayed count never goes below 0
442
+ const headRow = tx.get(e.aggTable, e.cells);
443
+ const wantRow = serverRow !== undefined || target > 0;
444
+ if (wantRow) {
445
+ const desired = [...e.cells, target];
446
+ if (!headRow)
447
+ tx.add(e.aggTable, desired); // §6.1 optimistic birth (no server group yet)
448
+ else if (!rowsEqual(headRow, desired))
449
+ tx.edit(e.aggTable, headRow, desired);
450
+ }
451
+ else if (headRow) {
452
+ tx.remove(e.aggTable, headRow); // delta took a not-yet-on-server group back to identity
453
+ }
454
+ }
455
+ });
456
+ this.overlay.pruneZeros();
457
+ }
458
+ /** The SERVER CHANNEL's state for a query (§7): `unknown` while not hydrated, else `complete`.
459
+ * A pending local mutation no longer moves it — see {@link pending}. */
460
+ resultType(qid) {
461
+ return this.resultTypes.get(qid) ?? "complete";
462
+ }
463
+ onResultType(handler) {
464
+ this.resultTypeHandler = handler;
465
+ }
466
+ // --- the pending AXIS (§7.2): orthogonal to ResultType -------------------------------
467
+ /** Whether any pending mutation (folded or not) touches this query's tables — "is a prediction
468
+ * pending here?" (FOLDED-MUTATIONS-DESIGN §7.2). Orthogonal to {@link resultType}; this is the
469
+ * same `queryTables ∩ pending.touched` computation that used to be smuggled into `unknown`. */
470
+ pending(qid) {
471
+ const tables = this.queryTables.get(qid);
472
+ if (!tables)
473
+ return false;
474
+ return this.pendingMutations.some((p) => intersects(tables, p.touched));
475
+ }
476
+ /** Reactive pending axis (§7.2): fires when a query's pending-ness flips (invoke ↔ confirm), so a
477
+ * "saving…" affordance clears on its own when `lmid` catches up. */
478
+ onPending(handler) {
479
+ this.pendingHandler = handler;
480
+ }
481
+ /** The coarse, table-level pending indicator set (§7.2): every table some pending mutation touched. */
482
+ pendingTables() {
483
+ const out = new Set();
484
+ for (const p of this.pendingMutations)
485
+ for (const t of p.touched)
486
+ out.add(t);
487
+ return out;
488
+ }
489
+ /** Recompute the pending axis for every query and fire `onPending` on transitions only. Called
490
+ * from the two points that move the pending set: invoke/invokeFolded (add) and the confirm-drop
491
+ * (remove) — exactly where `:359`/`:468` used to flip ResultType (§7.3). */
492
+ refreshPending() {
493
+ for (const qid of this.queryTables.keys()) {
494
+ const now = this.pending(qid);
495
+ if (this.pendingState.get(qid) !== now) {
496
+ this.pendingState.set(qid, now);
497
+ this.pendingHandler(qid, now);
498
+ }
499
+ }
500
+ }
501
+ // --- the downstream stream (§8.5: buffer, then release coherently) ---------------
502
+ onNormalized(qid, ev) {
503
+ if (ev.type === "hello") {
504
+ // A hello is a (re)subscribe = a NEW epoch. The column map below is mutated eagerly, but
505
+ // data frames are cv-buffered and drained later (onProgress). So any frame still buffered
506
+ // for this qid is from a SUPERSEDED epoch and must NOT be scattered through this epoch's
507
+ // (possibly changed) map — drop it. This epoch's snapshot, which always follows the hello,
508
+ // re-hydrates the qid from scratch, so the dropped frames are redundant. Scoped to this
509
+ // qid: other queries' frames (and the lmid system query's) keep their coherent release.
510
+ this.buffer = this.buffer.filter((f) => f.qid !== qid);
511
+ this.addServerDependencyTables(qid, ev.tables.map((t) => t.name));
512
+ // Learn this query's per-table column map (PROJECTION-SUPPORT-DESIGN.md §5.2): map each
513
+ // advertised column to its base ColId BY NAME. The hello may carry FEWER columns than the
514
+ // client's schema (a projection) or MORE (an EXPANDED server table mid an
515
+ // `expand-then-contract` migration) — a column the client lacks maps to `-1`, a DROP
516
+ // sentinel the sync layer discards while keeping the rest Absent. Register the map so the
517
+ // sync layer scatters the rows into the shared union; a table whose map is an in-order,
518
+ // drop-free full width IS '*' (a verbatim full row) and stays unregistered (a synthetic agg
519
+ // table is unknown here, also '*'). Idempotent across re-hydrate epochs.
520
+ for (const t of ev.tables) {
521
+ const full = this.colCounts[t.name];
522
+ if (full === undefined)
523
+ continue; // unknown/synthetic table → '*' (full presence)
524
+ const index = this.colIndex[t.name];
525
+ const cols = t.columns.map((name) => index?.get(name) ?? -1);
526
+ // Register a non-trivial map; otherwise revert to '*' — and CLEAR any stale map a prior
527
+ // epoch left (a server that expanded then contracted back), so the now-exact rows don't
528
+ // scatter through a `-1`-bearing layout (silent cell corruption).
529
+ if (cols.length !== full || cols.some((c, i) => c !== i))
530
+ this.sync.registerProjection(qid, t.name, cols);
531
+ else
532
+ this.sync.unregisterProjection(qid, t.name);
533
+ }
534
+ return; // envelope validation is the source's job
535
+ }
536
+ const cv = ev.cv ?? 0;
537
+ if (cv <= this.appliedCv && ev.type === "batch")
538
+ return; // stale redelivery
539
+ this.buffer.push({ cv, qid, kind: ev.type, ops: ev.ops, seq: this.nextSeq++ });
540
+ if (this.buffer.length > this.bufferCap)
541
+ this.overflow();
542
+ }
543
+ onProgress(frame) {
544
+ // (1) Take every buffered frame at cv ≤ cvMin, in (cv, arrival) order, and fold it:
545
+ // lmid system-query frames advance `confirmedLmid`; data frames fold through the
546
+ // cross-query refcount into ONE net base delta — the §1.3 `D`. Confirmation and
547
+ // data of the same commit share a cv, so they release together by construction.
548
+ const ready = this.buffer
549
+ .filter((f) => f.cv <= frame.cvMin)
550
+ .sort((a, b) => a.cv - b.cv || a.seq - b.seq);
551
+ this.buffer = this.buffer.filter((f) => f.cv > frame.cvMin);
552
+ const muts = [];
553
+ for (const f of ready) {
554
+ if (f.qid === LMID_QID) {
555
+ this.foldLmidOps(f.ops);
556
+ continue;
557
+ }
558
+ muts.push(...(f.kind === "snapshot" ? this.sync.rehydrate(f.qid, f.ops) : this.sync.applyBatch(f.qid, f.ops)));
559
+ // A query's first released snapshot is its hydration point — even an empty one (0 rows is an
560
+ // authoritative answer): lift every local view this sub feeds out of `unknown` (loading).
561
+ if (f.kind === "snapshot")
562
+ this.markSubHydrated(f.qid);
563
+ }
564
+ this.appliedCv = Math.max(this.appliedCv, frame.cvMin);
565
+ // (2) Drop confirmed pending (§1.3 step 5's bookkeeping half): mid ≤ the lmid this
566
+ // release itself delivered. A failed mutation drops the same way — the release just
567
+ // carries no effects for it, so the rewind in (3) snaps the prediction back. An UNFLUSHED
568
+ // folded entry (`mid == null`) is never confirmable — it has not crossed the wire — so it is
569
+ // always retained until its own flush stamps a real mid (FOLDED-MUTATIONS-DESIGN §4.1).
570
+ const before = this.pendingMutations.length;
571
+ this.pendingMutations = this.pendingMutations.filter((p) => p.mid === null || p.mid > this.confirmedLmid);
572
+ const pendingChanged = this.pendingMutations.length !== before;
573
+ // (3) The reconcile cycle — only when something can have changed: a base delta to
574
+ // fold in, or a pending set that shrank (its optimistic layer must rewind out).
575
+ if (muts.length || pendingChanged) {
576
+ this.runReconcileCycle(muts);
577
+ }
578
+ // (4) ResultType is the SERVER CHANNEL's state only now (§7): `unknown` while not hydrated,
579
+ // else `complete` — a pending mutation no longer moves it. The pending axis moves separately.
580
+ for (const qid of this.queryTables.keys()) {
581
+ this.setResultType(qid, this.hydrated.has(qid) ? "complete" : "unknown");
582
+ }
583
+ this.refreshPending();
584
+ }
585
+ /** Fold the lmid system query's released ops (lmid-as-data): the one row's
586
+ * `last_mutation_id` cell is this client's confirmed high-water mid. */
587
+ foldLmidOps(ops) {
588
+ for (const op of ops) {
589
+ const row = op.op === "add" ? op.row : op.op === "edit" ? op.new : undefined;
590
+ if (!row)
591
+ continue; // a remove (client GC) confirms nothing
592
+ const lmid = Number(row[1]);
593
+ if (!Number.isFinite(lmid))
594
+ continue;
595
+ if (lmid > this.nextMid - 1) {
596
+ if (this.pendingMutations.length > 0) {
597
+ // The server confirmed a mid we never issued while we have mutations in
598
+ // flight — two writers on one clientID or corrupted state. Unrecoverable.
599
+ throw new Error(`optimistic backend: confirmed lmid ${lmid} is ahead of issued mids (${this.nextMid - 1})`);
600
+ }
601
+ // A fresh session over a clientID with history: adopt the server's high-water
602
+ // mark so our next mid continues the sequence instead of colliding below it.
603
+ this.nextMid = lmid + 1;
604
+ }
605
+ this.confirmedLmid = Math.max(this.confirmedLmid, lmid);
606
+ }
607
+ }
608
+ /** One §1.3 reconcile cycle: rewind the optimistic layer and fold the coherent SERVER
609
+ * delta into BOTH head AND the `sync` baseline (`serverBatchBegin`), re-invoke every
610
+ * still-pending mutator to re-stage the optimistic layer (the rewind un-applied it), then
611
+ * deliver the coalesced result (`serverBatchEnd`). This is the engine's only sync-moving
612
+ * boundary — `onProgress` releases and `unregisterQuery`'s GC both go through here so head
613
+ * and sync never diverge (the §1.2 invariant; CRIT#2). */
614
+ runReconcileCycle(serverDeltas) {
615
+ this.local.serverBatchBegin(serverDeltas.map(toServerOp));
616
+ // The rewind cleared every optimistic write — incl. prior `__agg` edits — so head is now
617
+ // the server baseline. Rebuild the optimistic agg delta from scratch off the re-invoked
618
+ // (confirm-filtered) pending set, so a just-confirmed mutation's delta vanishes exactly as
619
+ // its server count is absorbed (§5 watermark — no double count).
620
+ this.overlay.reset();
621
+ // Re-invoke in WIRE order: assigned mids ascending, then unflushed folds (`mid == null`) last
622
+ // by creation order — the deterministic slot of FOLDED-MUTATIONS-DESIGN §4.1. A read-dependent
623
+ // mutator must replay against the same base the server computed from, which is mid order; with
624
+ // deferred fold mids, mid order ≠ creation order, so we sort rather than trust array order. The
625
+ // comparator is explicit (NOT `(mid ?? ∞) - (mid ?? ∞)`, which is `∞ - ∞ = NaN` for two unflushed
626
+ // folds — a NaN comparator silently corrupts V8's sort): assigned-before-unflushed, mids
627
+ // ascending, and STABLE for two unflushed folds so they keep creation order across cycles.
628
+ const order = [...this.pendingMutations].sort((a, b) => {
629
+ if (a.mid === null && b.mid === null)
630
+ return 0; // both unflushed → stable creation order
631
+ if (a.mid === null)
632
+ return 1; // an unflushed fold sorts after every assigned mid
633
+ if (b.mid === null)
634
+ return -1;
635
+ return a.mid - b.mid;
636
+ });
637
+ const dropped = new Set();
638
+ try {
639
+ for (const p of order) {
640
+ const touched = new Set();
641
+ const ops = [];
642
+ try {
643
+ this.local.writeWith((tx) => {
644
+ this.registry[p.name](trackingTx(tx, touched, this.specs, this.opCollector(ops)), p.args);
645
+ });
646
+ }
647
+ catch {
648
+ // A re-invocation threw — e.g. a read-dependent mutator whose base row the server
649
+ // deleted, or one reading a row a now-rejected sibling created. Drop it from pending
650
+ // (its staged write was discarded); its prediction simply snaps back on this release —
651
+ // a throwing mutation surfaces on the mutation axis (`onRejected`), not as a query-level
652
+ // `error` (§7.4). The `finally` below ALWAYS closes the cycle, so one bad mutator can't
653
+ // wedge the engine forever (#7).
654
+ dropped.add(p);
655
+ continue;
656
+ }
657
+ // The re-invocation stuck — fold its child ops into the rebuilt optimistic agg delta.
658
+ for (const op of ops)
659
+ this.overlay.observe(op);
660
+ // The pending footprint is the UNION across invocations: a re-run that no-ops (touched =
661
+ // {}) must NOT shrink it, else a still-pending mutation reports not-pending and its
662
+ // pending-axis clear fires early (§7.2).
663
+ for (const t of touched)
664
+ p.touched.add(t);
665
+ }
666
+ // Preserve creation order in the live array (the unflushed-fold sort tiebreak depends on it).
667
+ if (dropped.size)
668
+ this.pendingMutations = this.pendingMutations.filter((p) => !dropped.has(p));
669
+ // Re-apply the optimistic agg delta onto the (rewound) `__agg` head rows — INSIDE the open
670
+ // cycle, so the writes buffer and coalesce into the one per-query delivery `serverBatchEnd`
671
+ // makes (and never escape as a separate batch).
672
+ this.reconcileAggHead();
673
+ }
674
+ finally {
675
+ this.local.serverBatchEnd(); // ALWAYS close the cycle — ONE delivery per affected query.
676
+ }
677
+ }
678
+ /** The daemon restarted (a new boot id): it lost all materialization + `cv` state and its `cv`
679
+ * sequence reset, so previously-released `cv`s no longer bound the new stream. The source has
680
+ * already re-subscribed every query (reconnect → resync); drop the buffer and the `cv`
681
+ * watermark so the fresh, low-`cv` snapshots are RELEASED instead of dropped as stale
682
+ * (`onNormalized`/`onProgress` gate on `appliedCv`). Pending optimistic mutations stay put —
683
+ * they re-apply on the next reconcile, and the lmid system query's fresh snapshot restores the
684
+ * confirmation watermark. */
685
+ resetForRestart() {
686
+ this.buffer = [];
687
+ this.appliedCv = 0;
688
+ }
689
+ /** The §8.5 escape: the buffer outgrew its cap (a pinned `cvMin` under churn). Drop
690
+ * everything buffered and re-register every query on the source — the fresh
691
+ * snapshots arrive as ordinary frames and the next release re-hydrates via the
692
+ * footprint diff (the §5.3 path); still-pending optimism re-applies in that cycle. */
693
+ overflow() {
694
+ this.buffer = [];
695
+ for (const sub of this.remoteSubs.values()) {
696
+ this.source.unregisterQuery(sub.sourceQid);
697
+ this.source.registerQuery(sub.sourceQid, sub.remote);
698
+ }
699
+ // The lmid system query's buffered frames were dropped too — re-subscribe it so a
700
+ // fresh snapshot restores the confirmation watermark.
701
+ this.source.unregisterQuery(LMID_QID);
702
+ this.source.registerQuery(LMID_QID, { name: LMID_QUERY_NAME, args: {} });
703
+ }
704
+ setResultType(qid, rt) {
705
+ if (this.resultTypes.get(qid) === rt)
706
+ return;
707
+ this.resultTypes.set(qid, rt);
708
+ this.resultTypeHandler(qid, rt);
709
+ }
710
+ /** Recompute a query's server-channel state from hydration alone (§7): a pending mutation no
711
+ * longer affects it. Used when a remote sub attaches to or hydrates a local view. */
712
+ recomputeResultType(qid) {
713
+ if (!this.queryTables.has(qid))
714
+ return;
715
+ this.setResultType(qid, this.hydrated.has(qid) ? "complete" : "unknown");
716
+ }
717
+ /** A remote sub's first snapshot landed: mark it (and every local view it feeds) hydrated, then
718
+ * lift those views out of `unknown` (loading). Idempotent — a re-hydrate snapshot re-marks
719
+ * harmlessly; a source qid with no sub (the lmid system query) is a no-op. */
720
+ markSubHydrated(sourceQid) {
721
+ const key = this.sourceToRemote.get(sourceQid);
722
+ if (!key)
723
+ return;
724
+ const sub = this.remoteSubs.get(key);
725
+ if (!sub || sub.hydrated)
726
+ return;
727
+ sub.hydrated = true;
728
+ for (const localQid of sub.localQids.keys()) {
729
+ this.hydrated.add(localQid);
730
+ this.recomputeResultType(localQid);
731
+ }
732
+ }
733
+ retainRemote(retainQid, remote, localQueryId = retainQid) {
734
+ const key = remoteKey(remote);
735
+ let sub = this.remoteSubs.get(key);
736
+ let isNew = false;
737
+ if (!sub) {
738
+ sub = { sourceQid: retainQid, remote, refCount: 0, localQids: new Map(), hydrated: false };
739
+ this.remoteSubs.set(key, sub);
740
+ this.sourceToRemote.set(sub.sourceQid, key);
741
+ isNew = true;
742
+ }
743
+ sub.refCount++;
744
+ if (localQueryId !== undefined) {
745
+ sub.localQids.set(localQueryId, (sub.localQids.get(localQueryId) ?? 0) + 1);
746
+ // A late-joiner to an already-hydrated sub is immediately hydrated; otherwise this view now
747
+ // awaits the sub's first snapshot (so a split-path local view registered `complete` flips to
748
+ // `unknown` here). Then recompute its lifecycle.
749
+ if (sub.hydrated)
750
+ this.hydrated.add(localQueryId);
751
+ else
752
+ this.hydrated.delete(localQueryId);
753
+ this.recomputeResultType(localQueryId);
754
+ }
755
+ this.localToRemote.set(retainQid, key);
756
+ this.remoteRetainToLocal.set(retainQid, localQueryId);
757
+ if (isNew)
758
+ this.source.registerQuery(sub.sourceQid, remote);
759
+ }
760
+ releaseRemote(retainQid) {
761
+ const key = this.localToRemote.get(retainQid);
762
+ if (!key)
763
+ return undefined;
764
+ this.localToRemote.delete(retainQid);
765
+ const localQueryId = this.remoteRetainToLocal.get(retainQid);
766
+ this.remoteRetainToLocal.delete(retainQid);
767
+ const sub = this.remoteSubs.get(key);
768
+ if (!sub)
769
+ return undefined;
770
+ sub.refCount--;
771
+ if (localQueryId !== undefined) {
772
+ const refs = (sub.localQids.get(localQueryId) ?? 0) - 1;
773
+ if (refs > 0)
774
+ sub.localQids.set(localQueryId, refs);
775
+ else
776
+ sub.localQids.delete(localQueryId);
777
+ }
778
+ if (sub.refCount > 0)
779
+ return undefined;
780
+ this.source.unregisterQuery(sub.sourceQid);
781
+ this.sourceToRemote.delete(sub.sourceQid);
782
+ this.remoteSubs.delete(key);
783
+ return sub.sourceQid;
784
+ }
785
+ addServerDependencyTables(sourceQid, names) {
786
+ const key = this.sourceToRemote.get(sourceQid);
787
+ if (!key)
788
+ return;
789
+ const sub = this.remoteSubs.get(key);
790
+ if (!sub)
791
+ return;
792
+ let grew = false;
793
+ for (const localQid of sub.localQids.keys()) {
794
+ const tables = this.queryTables.get(localQid);
795
+ if (!tables)
796
+ continue;
797
+ for (const name of names) {
798
+ if (!tables.has(name))
799
+ grew = true;
800
+ tables.add(name);
801
+ }
802
+ }
803
+ // A newly-learned server dependency may bring a query into a pending mutation's footprint (§7.2);
804
+ // ResultType is unaffected (server-channel-only, §7).
805
+ if (grew)
806
+ this.refreshPending();
807
+ }
808
+ }
809
+ function remoteKey(remote) {
810
+ return stableJson([remote.name, remote.args]);
811
+ }
812
+ function stableJson(value) {
813
+ if (value === null || typeof value !== "object")
814
+ return JSON.stringify(value);
815
+ if (Array.isArray(value))
816
+ return `[${value.map(stableJson).join(",")}]`;
817
+ const obj = value;
818
+ return `{${Object.keys(obj)
819
+ .sort()
820
+ .map((k) => `${JSON.stringify(k)}:${stableJson(obj[k])}`)
821
+ .join(",")}}`;
822
+ }
823
+ function pkColsFromSchema(schema) {
824
+ const out = {};
825
+ for (const name of Object.keys(schema.tables))
826
+ out[name] = tableSpec(schema.tables[name]).primaryKey;
827
+ return out;
828
+ }
829
+ function tableSpecsFromSchema(schema) {
830
+ const out = {};
831
+ for (const name of Object.keys(schema.tables)) {
832
+ const { columns, primaryKey } = tableSpec(schema.tables[name]);
833
+ out[name] = { columns, primaryKey };
834
+ }
835
+ return out;
836
+ }
837
+ /** Each table's FULL column count (the union-row width), from the typed schema. */
838
+ function colCountsFromSchema(schema) {
839
+ const out = {};
840
+ for (const name of Object.keys(schema.tables))
841
+ out[name] = tableSpec(schema.tables[name]).columns.length;
842
+ return out;
843
+ }
844
+ /** Each table's column name → base ColId, from the typed schema (for mapping a projected hello's
845
+ * columns back to base positions, PROJECTION-SUPPORT-DESIGN.md §5.2). */
846
+ function colIndexFromSchema(schema) {
847
+ const out = {};
848
+ for (const name of Object.keys(schema.tables)) {
849
+ const cols = tableSpec(schema.tables[name]).columns;
850
+ out[name] = new Map(cols.map((c, i) => [c, i]));
851
+ }
852
+ return out;
853
+ }
854
+ /** Wrap the raw wasm txn as the client `MutationTx`, recording the touched tables (the client
855
+ * knows its own footprint — what the pending axis derives from, §7.2). The keyed methods validate
856
+ * column names eagerly: a typo'd table or column throws with the valid names listed, at the moment
857
+ * the mutator runs.
858
+ *
859
+ * With `trapReads` (the FOLDED path, §5), the PUBLIC reads `tx.get`/`tx.row` throw `FoldReadError`
860
+ * — a folded mutator that reads state to compute its write is non-absorbing and refused. The keyed
861
+ * writers (`update`/`upsert`/`delete`) still read internally to preserve unspecified columns; that
862
+ * is column-preservation, not value-derivation, so it stays allowed. */
863
+ function trackingTx(tx, touched, specs, onOp, trapReads = false) {
864
+ const spec = (table) => {
865
+ const s = specs[table];
866
+ if (!s)
867
+ throw new Error(`unknown table ${JSON.stringify(table)} — tables: ${Object.keys(specs).join(", ")}`);
868
+ return s;
869
+ };
870
+ /** Validate `obj`'s keys against the table's columns; require the pk columns; with
871
+ * `full`, require EVERY column (inserts carry whole rows — there are no defaults). */
872
+ const checkColumns = (table, obj, full) => {
873
+ const s = spec(table);
874
+ const unknown = Object.keys(obj).filter((k) => !s.columns.includes(k));
875
+ if (unknown.length) {
876
+ throw new Error(`unknown column${unknown.length > 1 ? "s" : ""} ${unknown.join(", ")} on ${table} — columns: ${s.columns.join(", ")}`);
877
+ }
878
+ const required = full ? s.columns : s.primaryKey.map((i) => s.columns[i]);
879
+ const missing = required.filter((c) => !(c in obj));
880
+ if (missing.length) {
881
+ throw new Error(`missing ${full ? "column" : "primary-key column"}${missing.length > 1 ? "s" : ""} ${missing.join(", ")} on ${table}`);
882
+ }
883
+ };
884
+ const pkCells = (table, obj) => spec(table).primaryKey.map((i) => obj[spec(table).columns[i]]);
885
+ const toCells = (table, obj) => spec(table).columns.map((c) => obj[c]);
886
+ const toKeyed = (table, cells) => {
887
+ const out = {};
888
+ spec(table).columns.forEach((c, i) => (out[c] = cells[i]));
889
+ return out;
890
+ };
891
+ // Internal read — used by the keyed writers below and the keyed `row` reader; never trapped.
892
+ const rawGet = (table, pk) => tx.get(table, pk);
893
+ const add = (table, row) => {
894
+ touched.add(table);
895
+ onOp?.({ table, kind: "add", row });
896
+ tx.add(table, row);
897
+ };
898
+ const remove = (table, row) => {
899
+ touched.add(table);
900
+ onOp?.({ table, kind: "remove", row });
901
+ tx.remove(table, row);
902
+ };
903
+ const edit = (table, oldRow, newRow) => {
904
+ touched.add(table);
905
+ onOp?.({ table, kind: "edit", row: newRow, old: oldRow });
906
+ tx.edit(table, oldRow, newRow);
907
+ };
908
+ // The folded read trap (§5): a mutator that reads to compute its write is refused. `() => never`
909
+ // is assignable to the wider read signatures (extra args ignored, `never` widens to the result).
910
+ const trapped = () => {
911
+ throw new FoldReadError();
912
+ };
913
+ return {
914
+ get: trapReads ? trapped : rawGet,
915
+ add,
916
+ remove,
917
+ edit,
918
+ row: trapReads
919
+ ? trapped
920
+ : (table, pk) => {
921
+ checkColumns(table, pk, false);
922
+ const cells = rawGet(table, pkCells(table, pk));
923
+ return cells ? toKeyed(table, cells) : undefined;
924
+ },
925
+ insert: (table, row) => {
926
+ checkColumns(table, row, true);
927
+ add(table, toCells(table, row));
928
+ },
929
+ update: (table, row) => {
930
+ checkColumns(table, row, false);
931
+ const current = rawGet(table, pkCells(table, row));
932
+ if (!current)
933
+ return; // rebase-friendly: the row may have vanished upstream
934
+ const s = spec(table);
935
+ const next = s.columns.map((c, i) => (c in row ? row[c] : current[i]));
936
+ edit(table, current, next);
937
+ },
938
+ upsert: (table, row) => {
939
+ checkColumns(table, row, true);
940
+ const current = rawGet(table, pkCells(table, row));
941
+ if (current)
942
+ edit(table, current, toCells(table, row));
943
+ else
944
+ add(table, toCells(table, row));
945
+ },
946
+ delete: (table, pk) => {
947
+ checkColumns(table, pk, false);
948
+ const current = rawGet(table, pkCells(table, pk));
949
+ if (!current)
950
+ return; // rebase-friendly no-op
951
+ remove(table, current);
952
+ },
953
+ };
954
+ }
955
+ function toServerOp(m) {
956
+ if (m.op === "add")
957
+ return { table: m.table, type: "add", row: m.row };
958
+ if (m.op === "remove")
959
+ return { table: m.table, type: "remove", row: m.row };
960
+ return { table: m.table, type: "edit", row: m.new, old: m.old };
961
+ }
962
+ /** Every base table a query's AST can draw rows from: the root + every related
963
+ * subquery + EXISTS subqueries (a conservative deep scan for `table` fields). */
964
+ function collectTables(ast) {
965
+ const out = new Set();
966
+ const walk = (v) => {
967
+ if (Array.isArray(v)) {
968
+ for (const x of v)
969
+ walk(x);
970
+ }
971
+ else if (v && typeof v === "object") {
972
+ const o = v;
973
+ if (typeof o.table === "string")
974
+ out.add(o.table);
975
+ for (const k of Object.keys(o))
976
+ walk(o[k]);
977
+ }
978
+ };
979
+ walk(ast);
980
+ return out;
981
+ }
982
+ function intersects(a, b) {
983
+ for (const x of b)
984
+ if (a.has(x))
985
+ return true;
986
+ return false;
987
+ }
988
+ /** Elementwise equality over positional bare-cell rows (the synthetic `__agg` rows are all
989
+ * scalar cells — group key + count — so identity comparison suffices). */
990
+ function rowsEqual(a, b) {
991
+ if (a.length !== b.length)
992
+ return false;
993
+ for (let i = 0; i < a.length; i++)
994
+ if (a[i] !== b[i])
995
+ return false;
996
+ return true;
997
+ }
998
+ //# sourceMappingURL=backend.js.map