@rindle/optimistic 0.5.0 → 0.6.3

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.
package/dist/backend.js CHANGED
@@ -58,8 +58,8 @@ const REAL_CLOCK = {
58
58
  now: () => Date.now(),
59
59
  };
60
60
  /** The (shared, frozen-by-convention) empty map {@link OptimisticBackend.roomTablesFor} answers
61
- * for a room with no promoted tables. */
62
- const EMPTY_ROUTING = new Map();
61
+ * for a room with no registered tables. */
62
+ const EMPTY_ROOM_TABLES = new Map();
63
63
  /** Per-domain retention cap for the processed-outcome set (H-v) — mirrors the shell's
64
64
  * `MAX_RECORDED_OUTCOMES_PER_CLIENT`: the sender caps what it can re-answer at 512 per client,
65
65
  * so remembering more than 512 processed mids per domain buys nothing. */
@@ -149,15 +149,23 @@ export class OptimisticBackend {
149
149
  * an evicted mid would be re-processed — the same bounded-window trade the shell makes, and it
150
150
  * takes 512 interleaving non-applied outcomes on one domain to open it. */
151
151
  outcomesProcessed = new Map();
152
- /** THE routing table (H-iii §3): per connected room `sourceKey` → per promoted table → its
153
- * client-held {@link RoomTableRouting}. Written ONLY by {@link promoteRoomTable} (same breath
154
- * as the engine attach + {@link promotedTables}); read by {@link deriveDomain} and — via
155
- * {@link roomTablesFor} the client's `__realtimeInspect` bookkeeping. */
156
- roomRouting = new Map();
157
- /** Q6 counters ({@link RoutingInspect}): successful derivations per room. */
158
- routingDerived = new Map();
159
- /** Q6 counters ({@link RoutingInspect}): derivation failures per {@link RoutingFailureReason}. */
160
- routingReasons = new Map();
152
+ /** THE room-table registry (302 §2 — one source per table): per connected room `sourceKey`, the
153
+ * wire-table engine-table map for the tables that room OWNS (its writable scope). Written by
154
+ * {@link registerRoomTables} (same breath as the engine registration); read by the gate's
155
+ * release rename/filter, the mutator staging map, the view swap ({@link processSwapIns}), and
156
+ * the client's `__realtimeInspect` bookkeeping. The record outlives a downgrade's disconnect —
157
+ * the ghost's views still read the engine tables — and drops at {@link dropGhost} (or the last
158
+ * clean release via {@link unregisterRoomTables}). */
159
+ roomTables = new Map();
160
+ /** Local view qids currently REGISTERED on a room's namespaced tables (302 §4 swap-in), →
161
+ * their sourceKey. Set by {@link processSwapIns}; cleared by the swap-back ({@link dropGhost})
162
+ * and view teardown. The original AST stays in {@link asts} throughout — the swap re-registers
163
+ * only the ENGINE query. */
164
+ roomSwappedViews = new Map();
165
+ /** Room subs whose FIRST snapshot released in the current release — their views swap onto the
166
+ * room tables at the release tail ({@link processSwapIns}), strictly AFTER the reconcile folded
167
+ * the snapshot into those tables (swapping earlier would hydrate the view EMPTY, a flash). */
168
+ pendingSwapIns = new Set();
161
169
  /** The live fold entries, by fold key `${name}\0${identityJSON}` — at most one per key
162
170
  * (FOLDED-MUTATIONS-DESIGN §8). Insertion order is creation order (the drain/flush tiebreak). */
163
171
  folds = new Map();
@@ -180,45 +188,6 @@ export class OptimisticBackend {
180
188
  * scalars (`__inspect`) read it directly; its `sync` IS {@link sync} (the agg overlay and
181
189
  * synthetic tables are daemon-tracked by design). */
182
190
  daemonGate;
183
- /** Tables promoted to a MERGED multi-source engine (a room source attached). Written by the one
184
- * promotion seam ({@link promoteRoomTable}; the test-named {@link __addRoomSource} and Slice
185
- * G-v's lease-driven promotion from `RoomTableSpec[]` both flow through it). Read by the §7.3
186
- * hold-back trigger in
187
- * {@link applyRelease}: parking on a never-promoted (Collapsed) table is SKIPPED — inert anyway
188
- * today (`rewind_collapsed` never consults `held_back`), and a live hazard if the table later
189
- * promotes (a stale Collapsed-era entry turns overlay-first-visible and pins forever). Slice H's
190
- * prove-or-slow-path routing keeps the real scenario — a room-confirmed mutation writing a
191
- * Collapsed table — impossible; this gate covers the window until then. */
192
- promotedTables = new Set();
193
- /** The §301 pin registry ({@link EchoFencePin}): every parked §7.3 hold-back's delivery-fence
194
- * inputs, keyed `${table}\0${sourceKey}\0${pkKey}`. Written in the same breath as the
195
- * `holdBack`/`holdBackAbsent` park; read by {@link dropEchoFencePins} at the tail of every
196
- * release. Empty on every single-domain client — the drop pass is then a structural no-op. */
197
- pins = new Map();
198
- /** The §301 direction-A fence input: per room domain, the highest lmid the DAEMON-CARRIED
199
- * ledger rows have folded ({@link foldSystemFrames} step 2 — and ONLY that path: the room
200
- * socket's own lmid stream confirms long before the flush reaches the daemon, so it folds
201
- * into the shared {@link watermark} but never here, 301 §1.1). When this covers a pin's mid,
202
- * the same coherent release (or an earlier one) folded the flush data that carried it into
203
- * the daemon baseline — the fence. */
204
- daemonCarriedLmid = new Map();
205
- /** The §301 §2.4 boot rule's inputs: the daemon boot ids this client has OBSERVED, in order
206
- * (id → ordinal), plus the current one. Boot ids are opaque — ordering is the client's own
207
- * observation ({@link OptimisticSource.onBootId} on the daemon gate). Direction-B pins stamp
208
- * the current boot; a room advertising a LATER-observed boot proves absorption (its
209
- * re-snapshot came from a daemon state that durably includes the confirmed write). */
210
- daemonBootOrdinals = new Map();
211
- daemonBootId;
212
- /** The per-read provenance probe `invoke` hands `trackingTx` when recording is armed (H-ii,
213
- * §3.2 #3): the engine's `provenanceOf` — the VISIBLE overlay-first winner for the row's pk,
214
- * read from LIVE pre-commit state (an open txn's staged writes are not consulted; H-i). Gated
215
- * on PER-TABLE {@link promotedTables} membership — the cheapest existing signal, and the
216
- * correct one: provenance is an ENGINE-merge question, not a channel question ({@link gates}
217
- * can hold a connected-but-unpromoted room feed, and the oracle harness promotes with no gate),
218
- * and a never-promoted (Collapsed) table is daemon-owned by construction. So a pure
219
- * single-domain app pays ZERO wasm calls per recorded read; the recorded `source` is then
220
- * `undefined`, which is also correct — everything is daemon-owned (see {@link ReadRecord}). */
221
- readProvenance = (table, row) => this.promotedTables.has(table) ? this.local.provenanceOf(table, row) : undefined;
222
191
  // --- the §4 lifecycle SYSTEM-STREAM plane (Slice I-iii) --------------------------------
223
192
  /** System retains by source qid ({@link retainSystemQuery}): a subscription with NO store view
224
193
  * and NO user-visible table — its frames buffer on its gate exactly like {@link LMID_QID}'s and
@@ -260,6 +229,12 @@ export class OptimisticBackend {
260
229
  * only through outcome resolution; the ghost holds rather than inventing a timeout-retire).
261
230
  * Default no-op. */
262
231
  downgradeStuckHandler = () => { };
232
+ /** The 302 §6.1 context-coverage surface ({@link onRoomContextJoin}) — fired at most once per
233
+ * view ({@link contextJoinWarned}), at swap-in, when its AST references tables the room does
234
+ * not own. Default no-op. */
235
+ roomContextJoinHandler = () => { };
236
+ /** Views the context-coverage event already fired for (once per view; cleared on teardown). */
237
+ contextJoinWarned = new Set();
263
238
  asts = new Map();
264
239
  /** Per query: the base tables its result can draw from (from the AST tree). */
265
240
  queryTables = new Map();
@@ -334,13 +309,6 @@ export class OptimisticBackend {
334
309
  source.onNormalized((qid, ev) => this.onFrame(gate, qid, ev));
335
310
  source.onProgress((frame) => this.onGateProgress(gate, frame));
336
311
  source.onRestart?.(() => this.resetGate(gate));
337
- // §301 §2.4: the DAEMON channel's observed boot-id stream seeds the client-local boot
338
- // ordinals the direction-B fence's boot rule compares against (boot ids are opaque; order is
339
- // the client's own observation). Only the daemon gate's boots matter — a room's advertised
340
- // `upstreamBoot` names a DAEMON boot, and room-channel incarnations are handled by
341
- // `resetGate`. Optional: an in-process source never reports one.
342
- if (key === "daemon")
343
- source.onBootId?.((bootId) => this.observeDaemonBoot(bootId));
344
312
  // The deopt handshake's client half (H-v §3.3): the channel's `mutationOutcome` frames arrive
345
313
  // as `(domain = gate.key, frame)`. OUT-OF-BAND — the source dispatches on arrival and this
346
314
  // handler runs immediately, NEVER behind the gate's cv buffer: a deopt must migrate its entry
@@ -372,7 +340,52 @@ export class OptimisticBackend {
372
340
  if (this.gates.has(sourceKey)) {
373
341
  throw new Error(`optimistic backend: source ${sourceKey} is already connected`);
374
342
  }
375
- this.attachGate(sourceKey, source, new NormalizedSync(this.pkCols, this.colCounts));
343
+ const gate = this.attachGate(sourceKey, source, new NormalizedSync(this.pkCols, this.colCounts));
344
+ // A re-upgrade of a doc whose tables are still registered (a ghost that never dropped, or a
345
+ // quick down/up bounce) adopts the surviving record as this incarnation's rename map.
346
+ const tables = this.roomTables.get(sourceKey);
347
+ if (tables !== undefined)
348
+ gate.tableMap = tables;
349
+ // …and CANCELS the pending swap-back: the room is the authority again, its views stay swapped,
350
+ // and a ghost left armed would fire against this LIVE gate when the old fence clears —
351
+ // un-swapping the views and unregistering the namespaced tables the gate's tableMap still
352
+ // renames deltas into (the next release would then throw from serverBatchBegin and poison the
353
+ // rebase state). A future downgrade arms a fresh ghost with its own fence.
354
+ this.ghosts.delete(sourceKey);
355
+ }
356
+ /** Register the tables room `sourceKey` OWNS (its writable scope — 302 §2): each wire table
357
+ * gets its own namespaced ENGINE table (`{@link roomEngineTable}`), an ordinary tracked table
358
+ * whose sole authority is the room channel. From here on the channel's released deltas rename
359
+ * into these tables (wire tables outside the map are DROPPED — context stays daemon-owned,
360
+ * 302 §6), room-domain mutators stage onto them, and a room-homed view swaps onto them once
361
+ * the room sub hydrates ({@link processSwapIns}). Idempotent per (sourceKey, table); a wire
362
+ * table unknown to the schema is skipped (nothing to hold rows for). */
363
+ registerRoomTables(sourceKey, tables) {
364
+ if (sourceKey === "daemon") {
365
+ throw new Error("optimistic backend: the daemon is not a room — no namespaced tables");
366
+ }
367
+ let map = this.roomTables.get(sourceKey);
368
+ if (!map)
369
+ this.roomTables.set(sourceKey, (map = new Map()));
370
+ for (const table of tables) {
371
+ if (map.has(table))
372
+ continue;
373
+ const spec = this.specs[table];
374
+ if (spec === undefined || this.localTables.has(table))
375
+ continue;
376
+ const engineTable = roomEngineTable(table, sourceKey);
377
+ this.local.registerTable(engineTable, { columns: spec.columns, primaryKey: spec.primaryKey });
378
+ map.set(table, engineTable);
379
+ }
380
+ const gate = this.gates.get(sourceKey);
381
+ if (gate !== undefined)
382
+ gate.tableMap = map;
383
+ }
384
+ /** The wire-table → engine-table map for room `sourceKey`'s owned tables (empty when none) —
385
+ * the client's idempotence check and `__realtimeInspect` read THIS record (one source of
386
+ * truth; the client keeps no shadow copy). */
387
+ roomTablesFor(sourceKey) {
388
+ return this.roomTables.get(sourceKey) ?? EMPTY_ROOM_TABLES;
376
389
  }
377
390
  // --- the Backend seam ---------------------------------------------------------
378
391
  /** `channel` (G-iii registration-time routing) names the authority channel the remote sub
@@ -410,7 +423,7 @@ export class OptimisticBackend {
410
423
  // child is left a native reduce (L1) — `rewriteAggregates`/`ensureSyntheticTables` skip it.
411
424
  this.ensureSyntheticTables(qid, ast);
412
425
  // Local first (synchronous empty view), then the server stream hydrates it.
413
- this.local.registerQuery(qid, rewriteAggregates(ast, (t) => this.localTables.has(t)));
426
+ this.local.registerQuery(qid, this.plainEngineAst(ast));
414
427
  if (remote) {
415
428
  // A remote query is `unknown` until its first server snapshot lands (hydration); retainRemote
416
429
  // attaches it to the sub and sets the lifecycle against the sub's hydration state.
@@ -484,6 +497,8 @@ export class OptimisticBackend {
484
497
  this.source.expectClientSchema?.([...this.clientTablesBase, ...this.synthetic.values()]);
485
498
  }
486
499
  unregisterQuery(qid) {
500
+ this.roomSwappedViews.delete(qid); // a swapped view's teardown forgets its room backing
501
+ this.contextJoinWarned.delete(qid); // …and its once-per-view coverage-warn latch
487
502
  const remoteQid = this.releaseRemote(qid);
488
503
  // GC: rows this remote footprint SOLELY referenced fall to refcount 0 → net removes. A qid
489
504
  // lives on ONE channel, so at most one gate's dropQuery is non-empty (dropQuery of an
@@ -493,7 +508,7 @@ export class OptimisticBackend {
493
508
  this.pendingRetargetGc.delete(remoteQid); // the sweep below covers a mid-retarget teardown
494
509
  for (const gate of this.gates.values()) {
495
510
  gate.buffer = gate.buffer.filter((f) => f.qid !== remoteQid);
496
- const gc = gate.sync.dropQuery(remoteQid);
511
+ const gc = mapGateDeltas(gate, gate.sync.dropQuery(remoteQid));
497
512
  if (gc.length)
498
513
  gcs.push([gate.key, gc]);
499
514
  }
@@ -545,7 +560,7 @@ export class OptimisticBackend {
545
560
  // Per-gate sweep, like `unregisterQuery`: at most one gate owned this qid's frames/rows.
546
561
  for (const gate of this.gates.values()) {
547
562
  gate.buffer = gate.buffer.filter((f) => f.qid !== remoteQid);
548
- const gc = gate.sync.dropQuery(remoteQid);
563
+ const gc = mapGateDeltas(gate, gate.sync.dropQuery(remoteQid));
549
564
  if (gc.length)
550
565
  this.runReconcileCycle(gate.key, gc);
551
566
  }
@@ -568,17 +583,15 @@ export class OptimisticBackend {
568
583
  * precedent), flip `sub.channel`, re-arm `sub.hydrated` (the room's own snapshot is the
569
584
  * cutover point), and register on the room source (its resolver presents the handed
570
585
  * roomToken). The old gate's SYNC rows are deliberately NOT dropped: they keep the view's
571
- * rows visible through the window (merge: the daemon-tier holder). Local views keep their
572
- * `hydrated` membership the view stays `complete` on its authoritative daemon rows.
573
- * 2. AT THE ROOM'S FIRST RELEASED SNAPSHOT ({@link flushRetargetGc}): the room source now
574
- * holds the footprint rows (folded via `serverBatchBegin(sourceKey)` value-equal rows
575
- * re-hydrate as a net-zero diff, RT §3.4 per channel), so the deferred
576
- * `dropQuery`+reconcile on the OLD gate flips each pk's winner daemon→room value-equal:
577
- * net-zero again. No emission carries a disappearance at any point.
586
+ * plain tables populated through the window the view still reads them until the swap.
587
+ * 2. AT THE ROOM'S FIRST RELEASED SNAPSHOT: the reconcile folds the snapshot into the room's
588
+ * namespaced tables, the release tail SWAPS every local view onto them (302 §4.1,
589
+ * {@link processSwapIns} the accepted-flash boundary), and {@link flushRetargetGc}'s
590
+ * deferred `dropQuery`+reconcile on the OLD gate then GCs the plain-table rows the sub
591
+ * alone referenced invisible to the swapped views.
578
592
  *
579
593
  * Idempotent per target channel: a sub already on `sourceKey` returns immediately (the
580
- * double-doorbell / re-entrancy guard — one retarget per (query, sourceKey), mirroring
581
- * {@link promoteRoomTable}'s caller-side per-(sourceKey, table) idempotence). Validates before
594
+ * double-doorbell / re-entrancy guard — one retarget per (query, sourceKey)). Validates before
582
595
  * mutating: a throw here leaves the sub fully daemon-attached (the client's fail-open). */
583
596
  retargetRemoteQuery(remote, sourceKey) {
584
597
  const newGate = this.requireGate(sourceKey); // throw loudly BEFORE any sub state moves
@@ -621,41 +634,30 @@ export class OptimisticBackend {
621
634
  const oldGate = this.gates.get(oldGateKey);
622
635
  if (!oldGate)
623
636
  continue;
624
- const gc = oldGate.sync.dropQuery(sourceQid);
637
+ const gc = mapGateDeltas(oldGate, oldGate.sync.dropQuery(sourceQid));
625
638
  if (gc.length)
626
639
  this.runReconcileCycle(oldGateKey, gc);
627
640
  }
628
641
  }
629
642
  // --- the §4.2 downgrade: demote → ghost → fence → drop (Slice I-v) ----------------------
630
- /** The I-v downgrade orchestration primitive (§4.2/§7.4): retire room `sourceKey` behind the
631
- * watermark fence. The caller has ALREADY retargeted every live sub off the channel
632
- * ({@link retargetRemoteQuery} room→daemon — validated loudly below) and holds the fence from
633
- * the api-server's downgrade response (`finalFlushSeq` = the room's last COMMITTED flush seq;
634
- * `doc` keys the §4.2 watermark fold, {@link roomWatermarks}). Steps, in order:
643
+ /** The I-v downgrade orchestration primitive (§4.2/§7.4, re-expressed by 302 §4.2 as the
644
+ * SWAP-BACK GATE): retire room `sourceKey` behind the watermark fence. The caller has ALREADY
645
+ * retargeted every live sub off the channel ({@link retargetRemoteQuery} room→daemon —
646
+ * validated loudly below) and holds the fence from the api-server's downgrade response
647
+ * (`finalFlushSeq` = the room's last COMMITTED flush seq; `doc` keys the §4.2 watermark fold,
648
+ * {@link roomWatermarks}). Steps, in order:
635
649
  *
636
- * 1. **De-candidacy NOW**: the sourceKey's {@link roomRouting} entries are removed, so
637
- * {@link deriveDomain} stops proposing the room (zero candidates ⇒ `"daemon"`). Unsent
638
- * pending (`mid === null`) re-routes for free — a fold's flush re-derives from the live
639
- * candidate set (§7.5 rule 1). The engine's frozen scope still ANSWERS (`writableMatches`
640
- * / `provenanceOf` — freezing gates serving, not the scope definition); routing is
641
- * TS-gated here.
642
- * 2. **Freeze** the WRITABLE promoted tables (`freezeSource`): the room slice becomes the
643
- * §4.2 ghost — wins-if-present in the merge (D4), so its rows (at-or-ahead of the daemon
644
- * until the flush echoes) stay visible while the slice accepts nothing. Context tables
645
- * (`writable: false`) are deliberately NOT frozen: the daemon is authoritative for them
646
- * LIVE (§5.2's context tier) and freezing would invert that, pinning a possibly-BEHIND
647
- * relayed copy over fresher daemon rows; unfrozen they keep exactly the live tiering.
648
- * 3. **Disconnect** the channel ({@link disconnectSource}): handlers detached, gate + buffer
650
+ * 1. **Disconnect** the channel ({@link disconnectSource}): handlers detached, gate + buffer
649
651
  * dropped. `nextMid`/`watermark`/processed-outcomes for the domain are KEPT FOREVER (§7.1:
650
652
  * an assigned mid pins its domain; a later re-upgrade of the same doc continues the
651
653
  * sequence — {@link connectSource} attaches a fresh gate and the lmid snapshot max-folds
652
654
  * into the surviving watermark). Disconnecting BEFORE the daemon sub's first release is
653
655
  * load-bearing: it makes {@link flushRetargetGc}'s deferred old-channel GC a no-op (gate
654
- * gone ⇒ record deleted, nothing dropped) running that GC would rewind the room slice's
655
- * rows at daemon hydration, i.e. BEFORE the fence, surfacing a lagging follower's stale
656
- * images (the exact regression §4.2 exists to prevent). The ghost's rows leave only
657
- * through {@link dropGhost}'s `removeRoomSource`, value-equal under the fence.
658
- * 4. **Ghost + first evaluation**: the record joins {@link ghosts} and is evaluated once
656
+ * gone ⇒ record deleted, nothing dropped). The room's namespaced tables and the views
657
+ * swapped onto them deliberately stay: frozen at the room's last state, they keep the
658
+ * document visible while the falling-back follower may still lack the final flush.
659
+ * Swapping back earlier would show its pre-flush images the regression §4.2 prevents.
660
+ * 2. **Ghost + first evaluation**: the record joins {@link ghosts} and is evaluated once
659
661
  * immediately — `finalFlushSeq === 0` (a never-flushed room) with no room-domain pending
660
662
  * drops on the spot, the single-daemon first-frame case.
661
663
  *
@@ -667,8 +669,6 @@ export class OptimisticBackend {
667
669
  if (sourceKey === "daemon") {
668
670
  throw new Error("optimistic backend: the daemon source cannot be demoted");
669
671
  }
670
- if (this.ghosts.has(sourceKey))
671
- return; // idempotent — one ghost per source
672
672
  // Validate FIRST (nothing mutated yet): a live sub still on the channel would silently
673
673
  // starve once the gate detaches — the caller must retarget every sub off the room first.
674
674
  for (const sub of this.remoteSubs.values()) {
@@ -676,14 +676,22 @@ export class OptimisticBackend {
676
676
  throw new Error(`optimistic backend: cannot demote ${JSON.stringify(sourceKey)} — query "${sub.remote.name}" is still retained on it (retarget it to the daemon first)`);
677
677
  }
678
678
  }
679
- const routing = this.roomRouting.get(sourceKey);
680
- const tables = routing ? [...routing.keys()] : [];
681
- const writable = routing ? tables.filter((t) => routing.get(t).writable) : [];
682
- this.roomRouting.delete(sourceKey); // (1) de-candidacy
683
- for (const table of writable)
684
- this.local.freezeSource(table, sourceKey); // (2) the ghost
685
- this.disconnectSource(sourceKey); // (3) the channel
686
- this.ghosts.set(sourceKey, { doc, finalFlushSeq, tables, stuckReported: false }); // (4)
679
+ // Idempotent per sourceKey (co-tenant queries sharing the room demote into the existing
680
+ // ghost) but NEVER a bare early-return: each demote carries its own fence, so keep the
681
+ // NEWEST flush (monotone max swapping back on an older fence would show pre-flush images),
682
+ // and disconnect defensively in case a gate re-attached since the ghost was armed (a
683
+ // down→up→down bounce; {@link connectSource} cancels the ghost on re-upgrade, so this arm
684
+ // normally finds no gate — but a stale gate left connected would let the next daemon release
685
+ // GC the room slice out from under the still-swapped views, the §4.2 regression).
686
+ const existing = this.ghosts.get(sourceKey);
687
+ if (existing) {
688
+ this.disconnectSource(sourceKey);
689
+ existing.finalFlushSeq = Math.max(existing.finalFlushSeq, finalFlushSeq);
690
+ this.evaluateGhosts();
691
+ return;
692
+ }
693
+ this.disconnectSource(sourceKey); // (1) the channel
694
+ this.ghosts.set(sourceKey, { doc, finalFlushSeq, stuckReported: false }); // (2)
687
695
  this.evaluateGhosts();
688
696
  }
689
697
  /** Detach one connected room channel (Slice I-v step 3): the source's handlers are replaced
@@ -717,6 +725,12 @@ export class OptimisticBackend {
717
725
  onDowngradeStuck(handler) {
718
726
  this.downgradeStuckHandler = handler;
719
727
  }
728
+ /** Register the 302 §6.1 context-coverage sink — see {@link RoomContextJoinEvent}. One handler
729
+ * (a later registration replaces it, the {@link onScopeSessions} convention); client.ts maps
730
+ * it onto the loud anomaly surface. */
731
+ onRoomContextJoin(handler) {
732
+ this.roomContextJoinHandler = handler;
733
+ }
720
734
  /** The I-v ghost-drop watcher (§4.2), run after every applied release ({@link applyRelease} —
721
735
  * the seam where {@link roomWatermarks} has just folded and the confirm-drop has just run) and
722
736
  * once at demote time. For each ghost: the fence must be satisfied
@@ -729,6 +743,11 @@ export class OptimisticBackend {
729
743
  if (this.ghosts.size === 0)
730
744
  return; // every non-downgrade release: structural no-op
731
745
  for (const [sourceKey, ghost] of [...this.ghosts]) {
746
+ // A LIVE gate means the doc re-upgraded — dropping now would dismantle the live room
747
+ // (un-swap its views, unregister the tables its tableMap renames into). connectSource
748
+ // cancels the ghost on re-upgrade, so this guard is purely defensive; hold, never drop.
749
+ if (this.gates.has(sourceKey))
750
+ continue;
732
751
  if ((this.roomWatermarks.get(ghost.doc) ?? 0) < ghost.finalFlushSeq)
733
752
  continue; // fence holds
734
753
  const stuck = this.pendingMutations.filter((p) => p.domain === sourceKey && p.mid !== null);
@@ -739,52 +758,50 @@ export class OptimisticBackend {
739
758
  }
740
759
  continue; // hold — never a timeout-retire (§7.5 rule 2)
741
760
  }
742
- this.dropGhost(sourceKey, ghost);
743
- }
744
- }
745
- /** Drop one cleared ghost (§4.2 end state): detach every promoted table's room source (D2
746
- * stay-merged `removeRoomSource` reconciles each held pk to daemon-or-vanish, value-equal
747
- * under the fence, so visually a no-op), release the {@link promotedTables} record for tables
748
- * no OTHER room/ghost still holds, then run ONE daemon reconcile so entries whose writes had
749
- * staged onto the ghost slice re-invoke and re-stage by provenance onto the daemon slice (the
750
- * removal took their staged copies with the tree; provenance now answers daemon). The whole
751
- * drop runs under one commit boundary so the removal's compensating deltas and the re-staged
752
- * predictions notify as ONE step. After this, a FUTURE upgrade of the same doc promotes
753
- * again from scratch the round trip is pinned by the §8.5 lanes. */
754
- dropGhost(sourceKey, ghost) {
761
+ this.dropGhost(sourceKey);
762
+ }
763
+ }
764
+ /** Drop one cleared ghost — the 302 §4.2 SWAP-BACK: under the fence the daemon tables are
765
+ * value-equal-or-ahead of the room's final state, so (1) every view swapped onto the room's
766
+ * namespaced tables re-registers on its ORIGINAL (daemon-table) AST — visually a no-op, the
767
+ * Store folds the re-hello as an in-place reset; (2) the namespaced tables unregister (no
768
+ * reader is left after the swap); (3) ONE daemon reconcile re-invokes the pending set so any
769
+ * entry whose writes had staged onto the now-gone room tables re-stages onto the daemon tables
770
+ * (its domain policy stopped naming the dead room when the client dropped it). The whole drop
771
+ * runs under one commit boundary so the swap and the re-staged predictions notify as ONE step.
772
+ * After this, a FUTURE upgrade of the same doc registers again from scratch. */
773
+ dropGhost(sourceKey) {
755
774
  this.ghosts.delete(sourceKey);
756
- // Re-stage eligibility FIRST: a pending entry staged (only) on the ghost slice must join the
757
- // daemon reconcile below (the cycle filter is `touchedSources.has(sourceKey)`) — union, never
758
- // shrink, the §5.3 rule.
759
- let restage = false;
760
- for (const p of this.pendingMutations) {
761
- if (p.touchedSources.has(sourceKey)) {
762
- p.touchedSources.add("daemon");
763
- restage = true;
764
- }
765
- }
766
775
  this.inOneCommit(() => {
767
- for (const table of ghost.tables)
768
- this.local.removeRoomSource(table, sourceKey);
769
- for (const table of ghost.tables) {
770
- let held = false;
771
- for (const byTable of this.roomRouting.values())
772
- if (byTable.has(table))
773
- held = true;
774
- for (const g of this.ghosts.values())
775
- if (g.tables.includes(table))
776
- held = true;
777
- if (!held)
778
- this.promotedTables.delete(table);
776
+ for (const [qid, key] of [...this.roomSwappedViews]) {
777
+ if (key !== sourceKey)
778
+ continue;
779
+ this.roomSwappedViews.delete(qid);
780
+ const ast = this.asts.get(qid);
781
+ if (ast === undefined)
782
+ continue;
783
+ this.local.unregisterQuery(qid);
784
+ this.local.registerQuery(qid, this.plainEngineAst(ast));
779
785
  }
780
- // One daemon reconcile replays the re-staged entries onto the daemon slice. Run whenever
781
- // any pending exists: the removal above may have taken a staged write with the tree even
782
- // when `touchedSources` never recorded the ghost (defensive; an empty-pending drop skips).
783
- if (restage || this.pendingMutations.length > 0)
786
+ this.unregisterRoomTables(sourceKey);
787
+ // One daemon reconcile re-stages the pending set onto the surviving tables. Run whenever
788
+ // any pending exists: unregistering the room tables took their staged copies with the tree.
789
+ if (this.pendingMutations.length > 0)
784
790
  this.runReconcileCycle("daemon", []);
785
791
  });
786
792
  this.refreshPending(); // the reconcile may have dropped a throwing re-invocation
787
793
  }
794
+ /** Unregister room `sourceKey`'s namespaced engine tables and drop the {@link roomTables}
795
+ * record. Callers must have no view registered on them (the engine refuses otherwise —
796
+ * loud by design). No-op for an unknown sourceKey. */
797
+ unregisterRoomTables(sourceKey) {
798
+ const map = this.roomTables.get(sourceKey);
799
+ if (!map)
800
+ return;
801
+ this.roomTables.delete(sourceKey);
802
+ for (const engineTable of map.values())
803
+ this.local.unregisterTable(engineTable);
804
+ }
788
805
  // --- the §4 lifecycle SYSTEM-STREAM retains (Slice I-iii) ------------------------------
789
806
  /** Retain one minted SYSTEM subscription (RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md §4, Slice
790
807
  * I-iii): a wire sub with NO store view and NO user-visible table. Registered through the same
@@ -932,201 +949,58 @@ export class OptimisticBackend {
932
949
  this.nextMid.set(domain, mid + 1);
933
950
  return { mid, seq: ++this.dealSeq };
934
951
  }
935
- // --- the §3 prove-or-slow-path ROUTER (H-iii) -----------------------------------
936
- //
937
- // RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md §3: a mutation routes to room R iff every row its
938
- // prediction run WROTE is provably inside R's writable scope and every read it RECORDED is
939
- // provably covered by R's footprint — ANY unproven condition routes slow (the §0 asymmetry:
940
- // mis-guessing "slow" costs one round-trip; mis-guessing "room" would commit divergent data).
941
- // Failure is therefore SILENT "daemon" — never a throw, never a warning per call — plus the Q6
942
- // per-reason counters ({@link RoutingInspect}).
943
- //
944
- // The proof evaluates against the ENGINE wherever the engine has an answer: `writableMatches`
945
- // (the registered compiled writable scope — the same predicate the merge's winner tiering uses)
946
- // and `provenanceOf` (the visible overlay-first winner). It runs AFTER the prediction committed,
947
- // so the probes see the prediction's own staged effects — which only ever strengthens the
948
- // conservative direction (a fresh pk reports "daemon"/undefined and neither disqualifies).
949
- //
950
- // ** THE ONE CLIENT-SIDE NON-ENGINE EVALUATOR — deliberately minimal (the TS evaluation
951
- // caveat). ** Evaluating `footprintWhere` on a read's pk cells ({@link evalFootprintOnPk}) is
952
- // the single place Slice H allows a non-engine evaluator, and it is restricted to STRICT cell
953
- // equality only: `simple` `=` / `!=` between a pk column and a same-primitive-type non-null
954
- // literal, composed under and/or (the empty AND is the vacuous-true exact-unconstrained-root
955
- // emission and evaluates true). ANY other op or shape — LIKE, <, >=, IS, IN, null operands,
956
- // cross-type comparisons, column-vs-column — is NOT EVALUABLE and fails to the daemon.
957
- // Justification: a mis-evaluation here cannot commit divergence — the room GATE (H-iv) re-proves
958
- // every write and absent read engine-side at commit and deopts, so a wrong client verdict only
959
- // costs a wasted hop (route room → gate deopt) or a skipped optimization (route daemon); and
960
- // restricting to strict equality on non-null, same-typed pk cells removes the entire
961
- // null/collation/coercion divergence space between this evaluator and the engine's comparators.
962
- // The gate remains the contract; this router is the optimization.
952
+ // --- the DECLARED router (302 §5: declared, not derived) --------------------------------
963
953
  //
964
- // ** H-v (SHIPPED the router's missing recovery half). ** A derived (or pinned) room route
965
- // whose gate DEOPTS (the H-iv-b `mutationOutcome {kind:"deopt"}` frame the mid is burnt in
966
- // the room ledger, no effects committed) is re-enqueued onto the daemon stream by
967
- // {@link handleMutationOutcome}: the entry flips in place (fresh daemon mid, ORIGINAL seq,
968
- // prediction applied throughout), or already retired re-invokes from the frame's echoed
969
- // name/args pinned to the daemon. A reconnect re-sends unconfirmed mids so a frame lost with
970
- // its socket is re-answered ({@link resendPending}). A wrong client verdict therefore costs
971
- // exactly one room round-trip plus one burnt room-mid (§3.3), never a stranded prediction.
972
- /** The route decision for one invocation (invoke, and again at fold flush): the explicit
973
- * `domainPolicy` is the OVERRIDE a string pins that domain verbatim, no proof runs;
974
- * `undefined` (or no policy configured) derives per §3. */
975
- resolveDomain(name, args, writes, reads) {
976
- const pinned = this.domainPolicy(name, args);
977
- if (pinned !== undefined)
978
- return pinned;
979
- return this.deriveDomain(writes, reads);
980
- }
981
- /** §9.3: does this fold's write-set route into a room (vs the daemon)? Used ONLY to pick the
982
- * {@link FoldOptions.roomDebounceMs} cadence at a fold window's start. It probes
983
- * {@link resolveDomain} but RESTORES the Q6 routing diagnostics afterward — the authoritative
984
- * route and its single counter bump belong to the flush ({@link flushFold}), not to this
985
- * interval hint. A fold's reads are provably empty (the read-trap arms on that path), so the
986
- * write-set alone decides. */
987
- routesToRoom(name, args, writes) {
988
- const reasons = [...this.routingReasons];
989
- const derived = [...this.routingDerived];
990
- const domain = this.resolveDomain(name, args, writes, { reads: [], queries: [] });
991
- this.routingReasons.clear();
992
- for (const [k, v] of reasons)
993
- this.routingReasons.set(k, v);
994
- this.routingDerived.clear();
995
- for (const [k, v] of derived)
996
- this.routingDerived.set(k, v);
997
- return domain !== "daemon";
998
- }
999
- /** Count one derivation failure ({@link RoutingInspect}). Returns `false` so the per-candidate
1000
- * proof's call sites read `return this.failDerivation(...)`. */
1001
- failDerivation(reason) {
1002
- this.routingReasons.set(reason, (this.routingReasons.get(reason) ?? 0) + 1);
1003
- return false;
1004
- }
1005
- /** The §3 derivation. Candidates are the connected room gates with at least one promoted table
1006
- * (a promoted table with no gate cannot confirm; a gate with no promoted table holds no data to
1007
- * prove against). Zero candidates `"daemon"` (the single-domain fast path). The proof runs
1008
- * per candidate and EXACTLY ONE must survive — two rooms both proving routes slow (principled
1009
- * disambiguation is a §9.2 multi-room NON-goal, deferred past Slice J; slow is always sound). */
1010
- deriveDomain(writes, reads) {
1011
- const candidates = [];
1012
- for (const key of this.gates.keys()) {
1013
- if (key === "daemon")
1014
- continue;
1015
- if ((this.roomRouting.get(key)?.size ?? 0) > 0)
1016
- candidates.push(key);
1017
- }
1018
- if (candidates.length === 0) {
1019
- this.failDerivation("no-candidates");
1020
- return "daemon";
1021
- }
1022
- // tx.query is not room-executable client-side: predicate containment (rindle-cover) is
1023
- // native-only BY DESIGN, so a declarative read fails the WHOLE derivation unconditionally.
1024
- // This is required, not conservative.
1025
- if (reads.queries.length > 0) {
1026
- this.failDerivation("tx-query");
1027
- return "daemon";
1028
- }
1029
- const proven = candidates.filter((room) => this.provesRoom(room, writes, reads));
1030
- if (proven.length === 1) {
1031
- const room = proven[0];
1032
- this.routingDerived.set(room, (this.routingDerived.get(room) ?? 0) + 1);
1033
- return room;
1034
- }
1035
- if (proven.length > 1)
1036
- this.failDerivation("ambiguous");
1037
- return "daemon"; // zero survivors: each candidate already counted its own failure reason
1038
- }
1039
- /** One candidate room's §3 proof over the captured write-set + read-log. Every write must pass
1040
- * ALL the write rules; every read must pass ONE of the read rules. First failure wins (and is
1041
- * counted); order is deterministic (write-set map order, then read-log order). */
1042
- provesRoom(room, writes, reads) {
1043
- const specs = this.roomRouting.get(room);
1044
- // --- write rules: every WriteRecord passes ALL of #1–#4 -------------------------------
1045
- for (const [table, byPk] of writes) {
1046
- // #1: the table is promoted for R with a writable (predicate-kind) spec. A context table
1047
- // (`writable: none`), an un-promoted table, and a local table (unreachable — the mutator
1048
- // guard refuses local writes at stage time) all fail here.
1049
- const spec = specs?.get(table);
1050
- if (spec === undefined || !spec.writable)
1051
- return this.failDerivation("write-unwritable-table");
1052
- const colIx = this.colIndex[table];
1053
- for (const rec of byPk.values()) {
1054
- const removeShape = rec.row === undefined;
1055
- // #2: the registered writable scope, evaluated BY THE ENGINE on the post-image for
1056
- // add/edit shapes and on the pre-image for removes (the row the room would delete). A
1057
- // remove record always carries its full-width pre-image (the H-ii capture contract);
1058
- // a violated contract fails closed rather than probing nothing.
1059
- const scopeRow = removeShape ? rec.oldRow : rec.row;
1060
- if (scopeRow === undefined || !this.local.writableMatches(table, room, scopeRow)) {
1061
- return this.failDerivation("write-scope-miss");
1062
- }
1063
- // #3: join-key no-change. An edit-shape (row + oldRow — the oldRow is the txn-entry base)
1064
- // must keep every joinKeyCols cell STRICTLY identical old-vs-new; an add-shape may SET
1065
- // them (it creates the correlation); removes are exempt. A join-key column missing from
1066
- // the schema is a spec bug — fail closed.
1067
- if (!removeShape && rec.oldRow !== undefined) {
1068
- for (const col of spec.joinKeyCols) {
1069
- const i = colIx?.get(col);
1070
- if (i === undefined || !identicalCell(rec.oldRow[i], rec.row[i])) {
1071
- return this.failDerivation("write-join-key-change");
1072
- }
1073
- }
1074
- }
1075
- // #4: provenance corroboration — the engine routes an Edit by its OLD row (H-i), so probe
1076
- // the pre-image for edit/remove shapes and the post-image for add-shapes. A winner naming
1077
- // a DIFFERENT room disqualifies; "daemon"/undefined does NOT (a thin slice, or a fresh pk
1078
- // — fresh pks stage to the daemon slice by the E2 decision, kept permanently: the
1079
- // room-side authoritative run is the committing execution either way).
1080
- const probeRow = rec.oldRow ?? rec.row;
1081
- if (probeRow !== undefined) {
1082
- const src = this.local.provenanceOf(table, probeRow);
1083
- if (src !== undefined && src !== "daemon" && src !== room) {
1084
- return this.failDerivation("write-cross-room-provenance");
1085
- }
1086
- }
1087
- }
1088
- }
1089
- // --- read rules: every ReadRecord passes ONE ------------------------------------------
1090
- // (tx.query already failed the whole derivation in deriveDomain — never reaches here.)
1091
- for (const r of reads.reads) {
1092
- // Self-read: the pk is in THIS invocation's write-set for that table — the write rules
1093
- // above already judged it (covers the keyed writers' probe-then-write shape). NOTE the
1094
- // read-log reflects the ORIGINAL invoke only (the pre-existing capture caveat).
1095
- if (writes.get(r.table)?.has(stableJson(r.pk)))
1096
- continue;
1097
- if (r.source !== undefined) {
1098
- if (r.source === room)
1099
- continue; // the room served the row — in-footprint by construction
1100
- if (r.source !== "daemon")
1101
- return this.failDerivation("read-cross-room");
1102
- }
1103
- // Present-with-daemon/undefined source, or ABSENT (source is never recorded for those —
1104
- // including the un-promoted-table meaning of an absent key): the footprint-membership test
1105
- // on the PK ALONE. R's footprintWhere must exist, be key-decidable (every column it reads is
1106
- // a pk column), and evaluate TRUE on the read's pk cells:
1107
- // present-daemon + TRUE ⇒ the row is in the room's COMPLETE footprint ⇒ covered;
1108
- // absent + TRUE ⇒ absent-in-room = absent-in-truth ⇒ covered;
1109
- // FALSE (either outcome) ⇒ fail — present-daemon-FALSE means the room lacks the row;
1110
- // absent-FALSE is arguably provable (decidably-outside ⇒ the room never sees the pk)
1111
- // but the room-side run would then read absent for a DIFFERENT reason than truth's —
1112
- // stay CONSERVATIVE and fail;
1113
- // no footprintWhere / not key-decidable / not evaluable ⇒ fail (see the evaluator caveat
1114
- // on the router block comment).
1115
- const fw = specs?.get(r.table)?.footprintWhere;
1116
- if (fw === undefined)
1117
- return this.failDerivation("read-no-footprint-where");
1118
- const tspec = this.specs[r.table];
1119
- const pkCols = tspec.primaryKey.map((i) => tspec.columns[i]);
1120
- if (!keyDecidable(fw, new Set(pkCols)))
1121
- return this.failDerivation("read-not-key-decidable");
1122
- const pkCells = new Map(pkCols.map((c, j) => [c, r.pk[j]]));
1123
- const verdict = evalFootprintOnPk(fw, pkCells);
1124
- if (verdict === undefined)
1125
- return this.failDerivation("read-not-evaluable");
1126
- if (!verdict)
1127
- return this.failDerivation("read-outside-footprint");
1128
- }
1129
- return true;
954
+ // The user declares which mutators are room mutators; the client neither proves, derives,
955
+ // widens, nor falls back. The declaration reaches this backend as `domainPolicy` the client
956
+ // layer resolves (mutator name, args) against its declared realtime mutators and the currently
957
+ // attached rooms. A misdeclaration fails SOFT (302 §5.1): a daemon-declared mutator touching
958
+ // room-visible data stages onto the daemon tables while the room-homed view reads the room
959
+ // tables no optimistic feedback until the echo relays it a hop later, never a divergence.
960
+ // The room GATE stays the authoritative backstop: a room-routed mutation the room refuses comes
961
+ // back as a `mutationOutcome` deopt/reject frame and the H-v machinery below re-enqueues or
962
+ // surfaces it.
963
+ /** The declared confirming stream for one invocation: the `domainPolicy`'s verdict, `"daemon"`
964
+ * when it abstains. Resolved BEFORE the prediction runs — the domain picks the staging map
965
+ * (a room domain stages its owned tables onto the room's namespaced twins). */
966
+ resolveDomain(name, args) {
967
+ return this.domainPolicy(name, args) ?? "daemon";
968
+ }
969
+ /** The staging table map for a `domain`-routed prediction ({@link trackingTx}'s `stage`):
970
+ * wire table → the room's namespaced engine table for the tables the room owns; identity for
971
+ * everything else (including the whole map for the daemon domain). */
972
+ stagingMap(domain) {
973
+ return domain === "daemon" ? undefined : this.roomTables.get(domain);
974
+ }
975
+ /** The PLAIN (daemon-homed) engine AST for `ast` aggregate relationships rewritten to their
976
+ * synthetic `__agg_*` reads, no room renames. The ONE form every non-swapped engine
977
+ * registration uses ({@link registerQuery}, {@link dropGhost}'s swap-back) and the base the
978
+ * swap-in renames ({@link processSwapIns}). */
979
+ plainEngineAst(ast) {
980
+ return rewriteAggregates(ast, (t) => this.localTables.has(t));
981
+ }
982
+ /** Mutator names the cross-authority warn below already fired for (once per name). */
983
+ warnedCrossAuthority = new Set();
984
+ /** 302 §5.1 dev-time guard: a room-DECLARED mutator wrote tables the room does not own. Those
985
+ * writes staged onto the PLAIN daemon tables (the staging map covers only owned tables), but
986
+ * the entry confirms on the ROOM stream — and only the room's OWNED tables flush back to the
987
+ * daemon, so nothing upstream ever echoes them: once the room confirm retires the entry, the
988
+ * next release's whole-store rewind reverts them for good. The first-party room shell refuses
989
+ * such a mutation (the §3.3 deopt/reject backstop re-routes it to the daemon), so this warns
990
+ * for the shapes where that backstop may be absent (a BYO relay) — loud, once, soft (§5.1:
991
+ * misdeclarations never throw). */
992
+ warnCrossAuthorityWrites(name, domain, touched) {
993
+ if (domain === "daemon" || this.warnedCrossAuthority.has(name))
994
+ return;
995
+ const map = this.roomTables.get(domain);
996
+ const staged = new Set(map?.values() ?? []);
997
+ const outside = [...touched].filter((t) => !staged.has(t));
998
+ if (outside.length === 0)
999
+ return;
1000
+ this.warnedCrossAuthority.add(name);
1001
+ console.warn(`[rindle] room mutator "${name}" wrote table(s) ${outside.join(", ")} that room ${JSON.stringify(domain)} does not own` +
1002
+ ` (owned: ${map !== undefined && map.size > 0 ? [...map.keys()].join(", ") : "none"}) — these writes rely on the room` +
1003
+ ` shell's deopt backstop and revert after the room confirm if the shell applies the mutation anyway (302 §5.1).`);
1130
1004
  }
1131
1005
  /** Run the named client mutator optimistically: the prediction applies to the live
1132
1006
  * engine now (affected views update synchronously), `(mid, name, args)` joins the
@@ -1147,43 +1021,33 @@ export class OptimisticBackend {
1147
1021
  // One commit boundary spans the prediction AND the `__agg`-head reconcile below, so their views
1148
1022
  // (data + count) flush together rather than tearing across two engine commits.
1149
1023
  return this.inOneCommit(() => {
1150
- // Apply the prediction FIRST. If the mutator throws (client-side validation, a bad read),
1024
+ // The confirming stream is DECLARED (302 §5), so it resolves BEFORE the prediction: the
1025
+ // domain picks the staging map — a room-domain mutator's writes to the room's owned tables
1026
+ // land on the namespaced engine twins the room-homed views read. An H-v deopt re-invocation
1027
+ // pins via `pin` and the policy never runs.
1028
+ const domain = pin ?? this.resolveDomain(name, args);
1029
+ // Apply the prediction. If the mutator throws (client-side validation, a bad read),
1151
1030
  // the staged write is discarded (the wasm txn is a clean no-op until commit) and the throw
1152
1031
  // propagates with NO mid consumed — a burnt mid is a permanent server-side gap that
1153
1032
  // silently refuses every later mutation from this client (#10).
1154
1033
  const writes = new Map();
1155
1034
  const reads = { reads: [], queries: [] };
1156
1035
  const ops = [];
1157
- // Per-staged-write source keys (staged order), zipped with the `commitTracked` `sources[]`
1158
- // below to learn which physical source each pk routed onto (§5.3 filter + §7.3 hold-back).
1159
- const stagedKeys = [];
1160
- const sources = this.local.writeWith((tx) => {
1161
- this.runMutator(mutator,
1162
- // `readProvenance` rides only with recording (H-ii §3.2 #3): the folded path (trap, no
1163
- // readLog) and the reconcile replay (no readLog) never probe — see trackingTx.
1164
- trackingTx(tx, writes, this.specs, this.localTables, this.opCollector(ops), false, reads, stagedKeys, this.readProvenance), args);
1036
+ this.local.writeWith((tx) => {
1037
+ this.runMutator(mutator, trackingTx(tx, writes, this.specs, this.localTables, this.opCollector(ops), false, reads, this.stagingMap(domain)), args);
1165
1038
  });
1166
1039
  // `touched` is DERIVED, never separately populated (§3.2 #1) — see {@link WriteSet}.
1167
1040
  const touched = new Set(writes.keys());
1041
+ this.warnCrossAuthorityWrites(name, domain, touched);
1168
1042
  // Flush-on-enqueue (§4.2): a fold whose tables overlap this write must take its mid NOW, BEFORE
1169
1043
  // this write does, so wire order == local-apply order for any pair that can observe each other
1170
1044
  // (a read-dependent write reading a folded cell sees the same value optimistically and on the
1171
1045
  // wire — no snap). Drained folds ship with smaller mids; this write's mid is dealt after.
1172
1046
  this.drainOverlapping(touched);
1173
- // The confirming stream (§7.1): its ledger deals the mid and its watermark alone retires the
1174
- // entry. THE §3 ROUTER RUNS HERE (H-iii)after the prediction (write/read capture is
1175
- // complete, and the engine holds the committed prediction the probes read) and BEFORE the
1176
- // mid is dealt: an assigned mid pins its domain forever (§7.1) — a re-invocation never
1177
- // re-routes. An explicit `domainPolicy` string pins verbatim (no proof); an H-v deopt
1178
- // re-invocation pins via `pin` (see {@link invokeWith}) and the router never runs.
1179
- const domain = pin ?? this.resolveDomain(name, args, writes, reads);
1047
+ // The confirming stream's ledger deals the mid and its watermark alone retires the entry
1048
+ // (§7.1). An assigned mid pins its domain forever a re-invocation never re-routes.
1180
1049
  const { mid, seq } = this.dealMid(domain);
1181
- // The write-source axes (§5.3): which physical source each write staged onto. All `"daemon"`
1182
- // in single-domain (Collapsed) — so the filter and hold-back below are inert on the live path.
1183
- const touchedSources = new Set(sources);
1184
- const writeSources = new Map();
1185
- mergeWriteSources(writeSources, stagedKeys, sources);
1186
- this.pendingMutations.push({ mid, seq, name, args, domain, touched, writes, reads, touchedSources, writeSources });
1050
+ this.pendingMutations.push({ mid, seq, name, args, domain, touched, writes, reads });
1187
1051
  // The prediction stuck — fold its child ops into the optimistic agg delta and push it onto
1188
1052
  // the `__agg` head rows (§4). No reset here (this is the §1.3 trivial case, no rewind): the
1189
1053
  // delta accumulates on top of the prior pending set, and `reconcileAggHead` recomputes each
@@ -1207,6 +1071,9 @@ export class OptimisticBackend {
1207
1071
  const foldKey = `${name}\0${stableJson(opts.key)}`;
1208
1072
  // One commit boundary spans the prediction AND the `__agg`-head reconcile (see {@link inOneCommit}),
1209
1073
  // so a folded mutation's list view and count view flush together, never torn across two commits.
1074
+ // The declared domain (302 §5) — resolved up front, like `invoke`'s: it picks the staging
1075
+ // map, the §9.3 cadence, and the provisional confirming stream (the flush re-resolves).
1076
+ const domain = this.resolveDomain(name, args);
1210
1077
  return this.inOneCommit(() => {
1211
1078
  // Apply the prediction with the read trap armed (§5): a folded mutator that reads state to
1212
1079
  // compute its write is non-absorbing and refused. A throw discards the staged write (clean
@@ -1214,11 +1081,9 @@ export class OptimisticBackend {
1214
1081
  // path stays byte-for-byte as it was; recording (§3.2 #2) never arms alongside the trap.
1215
1082
  const writes = new Map();
1216
1083
  const ops = [];
1217
- const stagedKeys = [];
1218
- let sources = [];
1219
1084
  try {
1220
- sources = this.local.writeWith((tx) => {
1221
- this.runMutator(mutator, trackingTx(tx, writes, this.specs, this.localTables, this.opCollector(ops), true, undefined, stagedKeys), args);
1085
+ this.local.writeWith((tx) => {
1086
+ this.runMutator(mutator, trackingTx(tx, writes, this.specs, this.localTables, this.opCollector(ops), true, undefined, this.stagingMap(domain)), args);
1222
1087
  });
1223
1088
  }
1224
1089
  catch (e) {
@@ -1232,38 +1097,31 @@ export class OptimisticBackend {
1232
1097
  this.reconcileAggHead();
1233
1098
  // `touched` is DERIVED, never separately populated (§3.2 #1) — see {@link WriteSet}.
1234
1099
  const touched = new Set(writes.keys());
1235
- // The write-source axes (§5.3), like `touched`/`writes` — REPLACED wholesale on an in-place
1236
- // fold overwrite (the latest invocation supersedes, absorbing), initialized on a new entry.
1237
- const touchedSources = new Set(sources);
1238
- const writeSources = new Map();
1239
- mergeWriteSources(writeSources, stagedKeys, sources);
1100
+ this.warnCrossAuthorityWrites(name, domain, touched);
1240
1101
  const now = this.clock.now();
1241
1102
  let f = this.folds.get(foldKey);
1242
1103
  if (f) {
1243
1104
  // Overwrite the single entry in place — the pending stack does NOT grow (§1 #2). The head
1244
1105
  // already carries this new prediction (absorbing, last-wins on the cell); the entry holds
1245
1106
  // only the LATEST args, which is what a rebase re-derives from and what the flush ships.
1107
+ // `domain` too: THIS invocation staged through the freshly-resolved domain's map above, so
1108
+ // a mid-window rebase must re-stage through the same one (the flush re-resolves anyway;
1109
+ // no mid is pinned yet — `entry.mid` is null until flush).
1246
1110
  f.entry.args = args;
1247
1111
  f.entry.touched = touched;
1248
1112
  f.entry.writes = writes;
1249
- f.entry.touchedSources = touchedSources;
1250
- f.entry.writeSources = writeSources;
1113
+ f.entry.domain = domain;
1251
1114
  f.args = args;
1252
1115
  this.clock.clearTimeout(f.timer);
1253
1116
  }
1254
1117
  else {
1255
- // Provisional domain (§7.1): re-resolved from the final args/write-set at flush, when the
1256
- // mid is dealt. Never read before then — an un-flushed fold (`mid == null`) is
1257
- // unconditionally retained — so the full (counting) derivation deliberately does NOT run
1258
- // here (it would double-bump the Q6 counters for one logical route); the flush derives.
1259
- const domain = this.domainPolicy(name, args) ?? "daemon";
1260
1118
  // §9.3: pick the window's cadence. Routing into a room ⇒ flush at roomDebounceMs so
1261
1119
  // intermediates stream to the shared head; off the room, the caller's collapse debounce
1262
- // governs. The probe restores the Q6 diagnostics (the flush owns the authoritative bump).
1263
- const inRoom = opts.roomDebounceMs !== undefined && this.routesToRoom(name, args, writes);
1120
+ // governs.
1121
+ const inRoom = opts.roomDebounceMs !== undefined && domain !== "daemon";
1264
1122
  const debounceMs = inRoom ? opts.roomDebounceMs : opts.debounceMs ?? DEFAULT_FOLD_DEBOUNCE_MS;
1265
1123
  const maxWaitMs = inRoom ? opts.roomDebounceMs : opts.maxWaitMs;
1266
- const entry = { mid: null, seq: null, name, args, domain, touched, writes, reads: { reads: [], queries: [] }, touchedSources, writeSources };
1124
+ const entry = { mid: null, seq: null, name, args, domain, touched, writes, reads: { reads: [], queries: [] } };
1267
1125
  this.pendingMutations.push(entry);
1268
1126
  let resolveMid;
1269
1127
  const midPromise = new Promise((res) => (resolveMid = res));
@@ -1315,13 +1173,11 @@ export class OptimisticBackend {
1315
1173
  return;
1316
1174
  this.clock.clearTimeout(f.timer);
1317
1175
  this.folds.delete(foldKey);
1318
- // Resolve the confirming stream from the FINAL args (§7.1) and deal the mid from that domain's
1319
- // ledger — SEND order, never reserved, so gapless within the domain. Under H-iii derivation
1320
- // this RE-DERIVES from the entry's LATEST write-set (each in-place fold overwrite replaced it,
1321
- // so it reflects the final absorbed value): same §3 rules minus reads — a fold's read-set is
1322
- // provably empty by construction (the FoldReadError trap, not recording, arms on that path),
1323
- // which is the STRONGEST read proof there is. The mid dealt below then pins this domain.
1324
- const domain = this.resolveDomain(f.entry.name, f.args, f.entry.writes, f.entry.reads);
1176
+ // Re-resolve the DECLARED confirming stream from the FINAL args (§7.1) and deal the mid from
1177
+ // that domain's ledger — SEND order, never reserved, so gapless within the domain. The mid
1178
+ // dealt below then pins this domain. (A domain that changed since the window opened a room
1179
+ // attached or dropped mid-window re-stages on the next reconcile's re-invocation.)
1180
+ const domain = this.resolveDomain(f.entry.name, f.args);
1325
1181
  f.entry.domain = domain;
1326
1182
  const { mid, seq } = this.dealMid(domain);
1327
1183
  f.entry.mid = mid;
@@ -1433,8 +1289,7 @@ export class OptimisticBackend {
1433
1289
  }, frame.reason ?? "mutation rejected");
1434
1290
  return;
1435
1291
  }
1436
- // kind === "deopt": Q6's completion how often a routed mutation came back refused.
1437
- this.routingReasons.set("deopt", (this.routingReasons.get("deopt") ?? 0) + 1);
1292
+ // kind === "deopt": the room gate refused a declared-room mutation re-enqueue onto the daemon.
1438
1293
  const entry = this.pendingMutations.find((p) => p.domain === domain && p.mid === frame.mid);
1439
1294
  if (entry) {
1440
1295
  entry.domain = "daemon";
@@ -1617,37 +1472,14 @@ export class OptimisticBackend {
1617
1472
  return {
1618
1473
  nextMid: Object.fromEntries(this.nextMid),
1619
1474
  watermark: Object.fromEntries(this.watermark),
1620
- gates: Object.fromEntries([...this.gates].map(([k, g]) => [
1621
- k,
1622
- {
1623
- appliedCv: g.appliedCv,
1624
- bufferedFrames: g.buffer.length,
1625
- ...(g.upstreamCv !== undefined ? { upstreamCv: g.upstreamCv } : {}),
1626
- ...(g.upstreamBoot !== undefined ? { upstreamBoot: g.upstreamBoot } : {}),
1627
- },
1628
- ])),
1629
- routing: {
1630
- derived: Object.fromEntries(this.routingDerived),
1631
- reasons: Object.fromEntries(this.routingReasons),
1632
- },
1475
+ gates: Object.fromEntries([...this.gates].map(([k, g]) => [k, { appliedCv: g.appliedCv, bufferedFrames: g.buffer.length }])),
1476
+ roomTables: Object.fromEntries([...this.roomTables].map(([k, m]) => [k, Object.fromEntries(m)])),
1477
+ swappedViews: Object.fromEntries(this.roomSwappedViews),
1633
1478
  lifecycle: {
1634
1479
  roomWatermarks: Object.fromEntries(this.roomWatermarks),
1635
1480
  scopeSessions: Object.fromEntries([...this.scopeSessions].map(([scope, sessions]) => [scope, Object.fromEntries(sessions)])),
1636
- ghosts: Object.fromEntries([...this.ghosts].map(([k, g]) => [k, { doc: g.doc, finalFlushSeq: g.finalFlushSeq, tables: [...g.tables] }])),
1481
+ ghosts: Object.fromEntries([...this.ghosts].map(([k, g]) => [k, { doc: g.doc, finalFlushSeq: g.finalFlushSeq }])),
1637
1482
  },
1638
- pins: Object.fromEntries([...this.pins].map(([k, p]) => [
1639
- k,
1640
- {
1641
- table: p.table,
1642
- sourceKey: p.sourceKey,
1643
- domain: p.domain,
1644
- mid: p.mid,
1645
- ...(p.daemonBoot !== undefined ? { daemonBoot: p.daemonBoot } : {}),
1646
- ...(p.daemonCv !== undefined ? { daemonCv: p.daemonCv } : {}),
1647
- tripwired: p.tripwired === true,
1648
- },
1649
- ])),
1650
- daemonCarriedLmid: Object.fromEntries(this.daemonCarriedLmid),
1651
1483
  pending: this.pendingMutations.map((p) => ({
1652
1484
  mid: p.mid,
1653
1485
  // The client-global deal sequence — the REPLAY order (mids are per-domain, incomparable
@@ -1655,11 +1487,6 @@ export class OptimisticBackend {
1655
1487
  seq: p.seq,
1656
1488
  name: p.name,
1657
1489
  domain: p.domain,
1658
- // The write-source axes (§5.3): which physical source(s) this entry's writes routed onto —
1659
- // the filter basis, and (per pk) the hold-back's cross-slice test. The harness asserts the
1660
- // filter/routing against its independent model with these.
1661
- touchedSources: [...p.touchedSources],
1662
- writeSources: Object.fromEntries(p.writeSources),
1663
1490
  })),
1664
1491
  };
1665
1492
  }
@@ -1761,16 +1588,6 @@ export class OptimisticBackend {
1761
1588
  * delta, lmid → watermark) and {@link applyRelease} (per-source confirm-drop + reconcile) —
1762
1589
  * N independent gates all feed the ONE apply half; {@link __testRelease} drives it directly. */
1763
1590
  onGateProgress(gate, frame) {
1764
- // §301 direction B: record the frame's upstream-absorption advert BEFORE the release applies,
1765
- // so the drop pass at applyRelease's tail evaluates exactly this release's advert — the room
1766
- // emits data-then-progress on one socket, so by the time the advert says "absorbed through
1767
- // cv U" the re-published echo data is in this release's fold (never ahead of it). Verbatim,
1768
- // not max-folded: on a daemon restart the advertised cv space legitimately resets and the
1769
- // §2.4 boot rule (not monotonicity) carries the ordering.
1770
- if (frame.upstreamCv !== undefined) {
1771
- gate.upstreamCv = frame.upstreamCv;
1772
- gate.upstreamBoot = frame.upstreamBoot;
1773
- }
1774
1591
  const { deltas, newlyHydrated, touchedScopes } = this.computeRelease(gate, frame);
1775
1592
  this.applyRelease(gate.key, deltas, undefined, newlyHydrated);
1776
1593
  // I-iv phase 2: a retargeted sub whose first ROOM snapshot released just now gets its old
@@ -1825,7 +1642,10 @@ export class OptimisticBackend {
1825
1642
  this.foldLmidOps(f.ops, gate.key);
1826
1643
  continue;
1827
1644
  }
1828
- muts.push(...(f.kind === "snapshot" ? gate.sync.rehydrate(f.qid, f.ops) : gate.sync.applyBatch(f.qid, f.ops)));
1645
+ // A ROOM gate's deltas rename into the room's namespaced tables — and a wire table outside
1646
+ // the registered map is DROPPED (302 §6: context comes from the daemon, one authority per
1647
+ // table; a room's relayed context copy must never enter the store).
1648
+ muts.push(...mapGateDeltas(gate, f.kind === "snapshot" ? gate.sync.rehydrate(f.qid, f.ops) : gate.sync.applyBatch(f.qid, f.ops)));
1829
1649
  // A query's first released snapshot is its hydration point — even an empty one (0 rows is an
1830
1650
  // authoritative answer): lift every local view this sub feeds out of `unknown` (loading).
1831
1651
  if (f.kind === "snapshot")
@@ -1850,44 +1670,6 @@ export class OptimisticBackend {
1850
1670
  if (watermarkUpdate !== undefined) {
1851
1671
  this.watermark.set(sourceKey, Math.max(this.watermark.get(sourceKey) ?? 0, watermarkUpdate));
1852
1672
  }
1853
- // Echo hold-back (§7.3), BEFORE the confirm-drop: an entry `p` this release CONFIRMS
1854
- // (`p.mid <= watermark[p.domain]`) but which staged a write onto a DIFFERENT source `S` (a
1855
- // cross-slice write — e.g. a room-domain mutation whose write routed to the daemon slice) is
1856
- // about to leave `pendingMutations`, so it will NOT be re-invoked on `S`'s next rewind. Park each
1857
- // such write inertly on `S` so that rewind (which un-applies the not-re-invoked write) does not
1858
- // flash-revert it. Two variants, one per write shape (G-i unified them engine-side):
1859
- // - ADD/EDIT (`rec.row` present): pinned PRESENCE — drops when `S`'s confirmed baseline
1860
- // value-matches (its echo lands — the E-i-tested drop).
1861
- // - REMOVE (`rec.row === undefined`, G-iii): pinned ABSENCE — the §7.3 tombstone, parked with
1862
- // the captured pre-image (`rec.oldRow`) so `S`'s rewind cannot resurrect the deleted row;
1863
- // drops when `S`'s baseline lacks the pk (the delete echoed).
1864
- // Inert in single-domain: every write's source equals its domain.
1865
- for (const p of this.pendingMutations) {
1866
- if (p.mid === null || p.mid > (this.watermark.get(p.domain) ?? 0))
1867
- continue; // not confirmed now
1868
- for (const [table, byPk] of p.writes) {
1869
- // The Collapsed-park gate (G-iii): NEVER park on an un-promoted table. Inert-anyway today
1870
- // (`rewind_collapsed` never consults `held_back`), so skipping is behaviorally identical —
1871
- // but a stale Collapsed-era entry would become overlay-first-visible (a permanent pin) if
1872
- // the table later promotes. Slice H's prove-or-slow-path routing makes the real scenario
1873
- // (a room-confirmed mutation writing a Collapsed table) impossible; this covers until then.
1874
- if (!this.promotedTables.has(table))
1875
- continue;
1876
- for (const [pkKey, rec] of byPk) {
1877
- const s = p.writeSources.get(writeSourceKey(table, pkKey));
1878
- if (s === undefined || s === p.domain)
1879
- continue; // same-slice: the rewind replays it
1880
- if (rec.row !== undefined)
1881
- this.local.holdBack(table, s, rec.row);
1882
- else if (rec.oldRow !== undefined)
1883
- this.local.holdBackAbsent(table, s, rec.oldRow);
1884
- else
1885
- continue;
1886
- // §301 §2.1: register the pin's delivery-fence inputs in the same breath as the park.
1887
- this.registerPin(table, s, pkKey, rec.row ?? rec.oldRow, p);
1888
- }
1889
- }
1890
- }
1891
1673
  // Drop confirmed pending (§1.3 step 5's bookkeeping half), PER DOMAIN: an entry is retired only
1892
1674
  // when ITS domain's watermark reaches its mid — so two concurrent streams never alias one counter
1893
1675
  // (§7.1). A failed mutation drops the same way (the release carries no effects, so the rewind snaps
@@ -1936,11 +1718,11 @@ export class OptimisticBackend {
1936
1718
  this.handler(qid, { type: "batch", events: [], catchUp: true });
1937
1719
  }
1938
1720
  this.refreshPending();
1939
- // The §301 echo-fence drop pass — strictly AFTER the reconcile above folded this release's
1940
- // data, so a fence that cleared in this release drops its pin only once the co-committed
1941
- // echo data is actually in the baseline (dropping earlier would itself reopen the flash
1942
- // window §7.3 closes). Structural no-op with no pins (every single-domain client).
1943
- this.dropEchoFencePins();
1721
+ // The 302 §4.1 swap-in — strictly AFTER the reconcile above folded this release's data, so a
1722
+ // room sub whose first snapshot just released swaps its views onto room tables that already
1723
+ // hold the snapshot (swapping earlier would hydrate them empty). Structural no-op with no
1724
+ // pending swap (every single-domain client).
1725
+ this.processSwapIns();
1944
1726
  // The I-v ghost-drop watcher (§4.2), LAST: this release's watermark rows have folded
1945
1727
  // (computeRelease) and its confirm-drop has retired what it covers — exactly the two inputs
1946
1728
  // the drop condition reads. Structural no-op with no ghost.
@@ -1954,151 +1736,62 @@ export class OptimisticBackend {
1954
1736
  __testRelease(sourceKey, deltas, watermarkUpdate) {
1955
1737
  this.applyRelease(sourceKey, deltas, watermarkUpdate);
1956
1738
  }
1957
- // --- the §301 echo fences (301-ECHO-FENCE-DESIGN.md) -----------------------------------
1958
- /** Register (or overwrite a later cross-slice confirm re-pinning the same slice-pk matches
1959
- * the engine's `held_back.insert`) one {@link EchoFencePin}, in the same breath as the
1960
- * engine park (§2.1). `p` is the confirmed entry whose write staged onto `sourceKey`; its
1961
- * `mid` is non-null by the caller's confirm filter. */
1962
- registerPin(table, sourceKey, pkKey, probeRow, p) {
1963
- const pin = {
1964
- table,
1965
- sourceKey,
1966
- probeRow,
1967
- domain: p.domain,
1968
- mid: p.mid,
1969
- };
1970
- if (sourceKey !== "daemon" && p.domain === "daemon") {
1971
- // Direction B stamps (§1.2): the parking daemon release's coherence position — the confirm
1972
- // folded at-or-before it, so a room advertising absorption ≥ this cv has absorbed the
1973
- // commit that carried the write — plus the client-observed boot it belongs to (§2.4).
1974
- pin.daemonCv = this.daemonGate.appliedCv;
1975
- if (this.daemonBootId !== undefined)
1976
- pin.daemonBoot = this.daemonBootId;
1977
- }
1978
- // The tripwire reference (§2.5): the slice's confirmed-baseline row at park.
1979
- pin.baselineAtPark = this.local.heldBackState(table, sourceKey, probeRow)?.baseline;
1980
- this.pins.set(`${table}\0${sourceKey}\0${pkKey}`, pin);
1981
- }
1982
- /** The §2.3 drop pass, run at the tail of every applied release: prune registry entries whose
1983
- * engine pin is already gone (the rewind's state-match fallback or a whole-source removal beat
1984
- * the fence — benign), drop every pin whose fence cleared (one engine drop each, delivered on
1985
- * the ordinary event stream, bracketed as ONE notification commit), and tripwire the rest. */
1986
- dropEchoFencePins() {
1987
- if (this.pins.size === 0)
1988
- return; // every single-domain client: structural no-op
1989
- let cleared = null;
1990
- for (const [key, pin] of this.pins) {
1991
- const state = this.local.heldBackState(pin.table, pin.sourceKey, pin.probeRow);
1992
- if (state === undefined) {
1993
- this.pins.delete(key); // engine pin gone (state-match / source removal): lazy prune
1994
- continue;
1995
- }
1996
- if (this.pinFenceCleared(pin))
1997
- (cleared ??= []).push([key, pin]);
1998
- else
1999
- this.maybeTripwirePin(key, pin, state.baseline);
2000
- }
2001
- if (!cleared)
2002
- return;
1739
+ // --- the 302 §4 swap-in ------------------------------------------------------------------
1740
+ /** Swap every view of each just-hydrated ROOM sub onto the room's namespaced tables (302 §4.1):
1741
+ * re-register the local engine query with the AST's room-owned table references renamed
1742
+ * ({@link remapAstTables}); the Store folds the re-hello as an in-place reset, so the caller's
1743
+ * view reference survives and subscribers see ONE transition. Runs at the applyRelease tail —
1744
+ * the reconcile has already folded the sub's snapshot into the room tables, so the swapped
1745
+ * view hydrates straight to the room state (swapping earlier would flash it empty). The
1746
+ * ORIGINAL ast stays in {@link asts}; the swap-back ({@link dropGhost}) re-registers it.
1747
+ *
1748
+ * This is the accepted-flash boundary (302 §4.1/§7.1): the room's copy may be behind the
1749
+ * daemon rows the view showed a moment ago — accepted by decision, revisit on a real
1750
+ * two-region deploy. */
1751
+ processSwapIns() {
1752
+ if (this.pendingSwapIns.size === 0)
1753
+ return; // every single-domain release: structural no-op
1754
+ const subs = [...this.pendingSwapIns];
1755
+ this.pendingSwapIns.clear();
2003
1756
  this.inOneCommit(() => {
2004
- for (const [key, pin] of cleared) {
2005
- this.pins.delete(key);
2006
- this.local.dropHeldBack(pin.table, pin.sourceKey, pin.probeRow);
1757
+ for (const sub of subs) {
1758
+ const map = this.roomTables.get(sub.channel);
1759
+ for (const qid of sub.localQids.keys()) {
1760
+ const ast = this.asts.get(qid);
1761
+ if (ast === undefined)
1762
+ continue;
1763
+ // 302 §6.1 coverage check, BEFORE the owned-table gate (an all-context room swaps
1764
+ // nothing yet still starves every ref): any referenced table the room does not own
1765
+ // keeps reading the PLAIN daemon tables after the swap — legal (the client-side join
1766
+ // across kinds), but the rows render only if a daemon subscription covers them, which
1767
+ // is unknowable here. Surface once per view, loudly, so a silently-empty join is a
1768
+ // named condition. Local-only tables are daemon-free by definition — skip them.
1769
+ if (!this.contextJoinWarned.has(qid)) {
1770
+ const uncovered = [...collectTables(ast)].filter((t) => !(map?.has(t) ?? false) && !this.localTables.has(t));
1771
+ if (uncovered.length > 0) {
1772
+ this.contextJoinWarned.add(qid);
1773
+ this.roomContextJoinHandler({ sourceKey: sub.channel, name: sub.remote.name, args: sub.remote.args, tables: uncovered });
1774
+ }
1775
+ }
1776
+ if (map === undefined || map.size === 0)
1777
+ continue; // no owned tables — nothing to swap
1778
+ if (this.roomSwappedViews.get(qid) === sub.channel)
1779
+ continue; // already swapped
1780
+ const rewritten = remapAstTables(this.plainEngineAst(ast), map);
1781
+ this.local.unregisterQuery(qid);
1782
+ this.local.registerQuery(qid, rewritten);
1783
+ this.roomSwappedViews.set(qid, sub.channel);
1784
+ // The pending axis follows the engine tables the view now reads (union — the wire
1785
+ // names stay too, conservatively: a daemon-declared write to a room-visible table is
1786
+ // still an honest "pending elsewhere" signal).
1787
+ const tables = this.queryTables.get(qid);
1788
+ if (tables)
1789
+ for (const t of map.values())
1790
+ tables.add(t);
1791
+ }
2007
1792
  }
2008
1793
  });
2009
1794
  }
2010
- /** Has `pin`'s delivery fence provably closed its confirm→echo window? (§2.3/§2.4.) */
2011
- pinFenceCleared(pin) {
2012
- if (pin.sourceKey === "daemon") {
2013
- // Direction A: the daemon-carried ledger for the confirming room domain covers the mid —
2014
- // the I-ii co-commit means this release (or an earlier one) folded the flush data that
2015
- // carried it into the daemon baseline (§1.1).
2016
- return (this.daemonCarriedLmid.get(pin.domain) ?? 0) >= pin.mid;
2017
- }
2018
- // Direction B: only a daemon-confirmed write has the daemon-cv stamp; a room-staged pin
2019
- // confirmed by ANOTHER room (outside today's two-tier topology) has no fence.
2020
- if (pin.daemonCv === undefined)
2021
- return false;
2022
- const gate = this.gates.get(pin.sourceKey);
2023
- if (!gate || gate.upstreamCv === undefined)
2024
- return false; // no advert (old shell): §2.5 fallback
2025
- if (gate.upstreamBoot === pin.daemonBoot)
2026
- return gate.upstreamCv >= pin.daemonCv;
2027
- // Boot mismatch (§2.4): a boot the client has OBSERVED as later proves absorption (the
2028
- // room's post-restart re-snapshot came from daemon state that durably includes the
2029
- // confirmed write); an unknown/older/unstamped boot holds — conservative, its next
2030
- // re-snapshot advances it.
2031
- if (pin.daemonBoot === undefined || gate.upstreamBoot === undefined)
2032
- return false;
2033
- const pinOrd = this.daemonBootOrdinals.get(pin.daemonBoot);
2034
- const advOrd = this.daemonBootOrdinals.get(gate.upstreamBoot);
2035
- return pinOrd !== undefined && advOrd !== undefined && advOrd > pinOrd;
2036
- }
2037
- /** The §2.5 stuck-pin tripwire, in the scopesHash spirit: log ONCE per pin when its slice's
2038
- * baseline row has CHANGED VALUE since park while the pin still holds — the suspicious state
2039
- * that precedes every forever-pin (a fence-less pairing, a fence bug). Never drops anything. */
2040
- maybeTripwirePin(key, pin, baseline) {
2041
- if (pin.tripwired)
2042
- return;
2043
- const same = pin.baselineAtPark === undefined || baseline === undefined
2044
- ? pin.baselineAtPark === baseline
2045
- : pin.baselineAtPark.length === baseline.length &&
2046
- pin.baselineAtPark.every((c, i) => identicalCell(c, baseline[i]));
2047
- if (same)
2048
- return;
2049
- pin.tripwired = true;
2050
- const [table, sourceKey, pkKey] = key.split("\0");
2051
- console.warn(`rindle: a §7.3 hold-back pin on table "${table}" slice "${sourceKey}" pk ${pkKey} (confirming domain ${pin.domain}, mid ${pin.mid}) is parked while its slice's baseline moved past it — if this client never converges on that row, this pin is why (301-ECHO-FENCE-DESIGN.md §2.5).`);
2052
- }
2053
- /** Record one observed daemon boot id (§2.4): first observation of an id assigns the next
2054
- * ordinal (the client's own total order over opaque boot ids); every call refreshes the
2055
- * current-boot stamp for direction-B parks. */
2056
- observeDaemonBoot(bootId) {
2057
- if (!this.daemonBootOrdinals.has(bootId)) {
2058
- this.daemonBootOrdinals.set(bootId, this.daemonBootOrdinals.size);
2059
- }
2060
- this.daemonBootId = bootId;
2061
- }
2062
- /** Promote `table` to a MERGED multi-source engine with room `sourceKey`'s per-row writable
2063
- * scope (RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md §5.2) — THE one promotion seam. The engine
2064
- * attach and the {@link promotedTables} record move in the same breath: the §7.3 hold-back
2065
- * trigger in {@link applyRelease} parks ONLY on promoted tables, so a promotion that bypassed
2066
- * the record would silently disable the echo hold-back for that table (and a record without the
2067
- * engine attach would park onto a slice that doesn't exist). Recorded AFTER the engine accepts —
2068
- * a rejected descriptor/table must not leave a phantom promotion. Slice G-v's client drives this
2069
- * from the lease's `realtime.tables` (`RoomTableSpec` → {@link WritableDescriptor}); idempotence
2070
- * per `(sourceKey, table)` is the CALLER's job (the engine refuses a duplicate room).
2071
- *
2072
- * `spec` (H-iii) is the per-table ROUTING spec riding the same lease table block — recorded
2073
- * into {@link roomRouting} (THE routing table the §3 router reads) in the same breath, with
2074
- * `writable` derived from the descriptor's kind (`none` = a context table the room may not
2075
- * write). Omitted (the E-iii-b harness alias below) ⇒ an empty spec: no join keys to guard, no
2076
- * `footprintWhere` (that room's reads then fail closed to the daemon unless self/room-served). */
2077
- promoteRoomTable(table, sourceKey, writable, spec) {
2078
- this.local.addRoomSource(table, sourceKey, writable);
2079
- this.promotedTables.add(table);
2080
- let byTable = this.roomRouting.get(sourceKey);
2081
- if (!byTable)
2082
- this.roomRouting.set(sourceKey, (byTable = new Map()));
2083
- byTable.set(table, {
2084
- writable: writable.kind !== "none",
2085
- joinKeyCols: [...(spec?.joinKeyCols ?? [])],
2086
- ...(spec?.where !== undefined ? { where: spec.where } : {}),
2087
- ...(spec?.footprintWhere !== undefined ? { footprintWhere: spec.footprintWhere } : {}),
2088
- });
2089
- }
2090
- /** The recorded routing specs for `sourceKey`'s promoted tables (table → its
2091
- * {@link RoomTableRouting}) — read-only: the client's `__realtimeInspect`/idempotence
2092
- * bookkeeping reads THIS record instead of keeping its own shadow copy (one source of truth).
2093
- * Empty map when the room has promoted nothing. */
2094
- roomTablesFor(sourceKey) {
2095
- return this.roomRouting.get(sourceKey) ?? EMPTY_ROUTING;
2096
- }
2097
- /** Test-named alias of {@link promoteRoomTable} (E-iii-b scaffolding — the multi-domain oracle
2098
- * and per-source-gate suites drive it). Pure delegation: ONE body, one `promotedTables` record. */
2099
- __addRoomSource(table, roomKey, writable, spec) {
2100
- this.promoteRoomTable(table, roomKey, writable, spec);
2101
- }
2102
1795
  /** Fold `domain`'s lmid system query's released ops (lmid-as-data): the one row's
2103
1796
  * `last_mutation_id` cell is this client's confirmed high-water mid in that domain — it advances
2104
1797
  * `watermark[domain]` and, on a fresh session ahead of our issued mids, `nextMid[domain]`. The
@@ -2232,16 +1925,7 @@ export class OptimisticBackend {
2232
1925
  continue;
2233
1926
  if (spec.doc !== undefined && doc !== spec.doc)
2234
1927
  continue;
2235
- const covered = Number(lmid);
2236
- this.foldConfirm(roomDomainKey(doc), covered);
2237
- // The §301 direction-A fence input: the DAEMON-CARRIED ledger fold ALONE (the room
2238
- // socket's own lmid stream confirms long before the flush reaches the daemon, so it
2239
- // folds the shared watermark above but never this map — 301 §1.1). Max-fold: ledger
2240
- // rows re-deliver across re-hydrates.
2241
- if (Number.isFinite(covered)) {
2242
- const dom = roomDomainKey(doc);
2243
- this.daemonCarriedLmid.set(dom, Math.max(this.daemonCarriedLmid.get(dom) ?? 0, covered));
2244
- }
1928
+ this.foldConfirm(roomDomainKey(doc), Number(lmid));
2245
1929
  }
2246
1930
  }
2247
1931
  // (3) watermark rows → the monotone §4.2 fence value per doc.
@@ -2327,40 +2011,36 @@ export class OptimisticBackend {
2327
2011
  * deliver the coalesced result (`serverBatchEnd`). This is the engine's only sync-moving
2328
2012
  * boundary — `onProgress` releases and `unregisterQuery`'s GC both go through here so head
2329
2013
  * and sync never diverge (the §1.2 invariant; CRIT#2). */
2330
- runReconcileCycle(sourceKey, serverDeltas) {
2331
- // `sourceKey` names the authority these `deltas` confirm — `"daemon"` on the live daemon path (and
2332
- // the GC path), a `room:doc:X` string on a per-source release (E-iii). It selects which physical
2333
- // source the wasm engine's rewind folds the delta into (`Db::serverBatchBegin`). The E-iii-b/c
2334
- // per-source rewind + intersecting-pending re-invoke filter ride the same seam later.
2335
- this.local.serverBatchBegin(sourceKey, serverDeltas.map(toServerOp));
2336
- // The aggregate overlay (§4) is DAEMON-tracked: its `__agg_*` tables are server-authoritative
2337
- // synthetic tables that only the daemon feed drives. A room cycle must not wipe or rebuild it
2338
- // (that would double-count / lose daemon agg state), so gate every overlay interaction reset,
2339
- // observe, and reconcileAggHead on the daemon cycle. Single-domain: always daemon unchanged.
2340
- const daemonCycle = sourceKey === "daemon";
2341
- if (daemonCycle) {
2342
- // The rewind cleared every optimistic write incl. prior `__agg` edits so head is now the
2343
- // server baseline. Rebuild the optimistic agg delta from scratch off the re-invoked
2344
- // (confirm-filtered) pending set, so a just-confirmed mutation's delta vanishes exactly as its
2345
- // server count is absorbed (§5 watermark no double count).
2346
- this.overlay.reset();
2347
- }
2014
+ runReconcileCycle(_sourceKey, serverDeltas) {
2015
+ // `_sourceKey` names the authority these `deltas` confirm — `"daemon"` on the live daemon
2016
+ // path (and the GC path), a `room:doc:X` string on a room release, whose deltas already carry
2017
+ // the room's ENGINE table names (the gate's rename/filter). Kept for call-site readability
2018
+ // and tracing only: the engine itself is source-agnostic (302: one authority per table) — its
2019
+ // rewind covers EVERY tracked table and every pending mutation re-invokes below regardless of
2020
+ // which channel released, so NOTHING in this cycle may branch on it.
2021
+ this.local.serverBatchBegin(serverDeltas.map(toServerOp));
2022
+ // The rewind covers EVERY tracked table (302: the engine is source-agnostic there is no
2023
+ // per-source rewind)including the `__agg_*` head rows whichever channel released. So the
2024
+ // optimistic agg delta rebuilds on EVERY cycle, room or daemon: reset here, re-observe from
2025
+ // the re-invoked pending set below, re-apply onto the rewound heads at the end. Gating any of
2026
+ // the three on a daemon-only cycle (the pre-302 per-source-rewind contract) would let a room
2027
+ // release wipe the optimistic `__agg` edits and skip the rebuild — every count() view snaps
2028
+ // back to the server base until the next daemon release. The delta stays sound across
2029
+ // domains: `reconcileAggHead` recomputes each head as the absolute `server_base ⊕ delta`,
2030
+ // and the server base (`this.sync`) only moves on daemon releases.
2031
+ this.overlay.reset();
2348
2032
  // Sort ALL pending into SEND order (the client-global `seq` ascending, then unflushed folds
2349
2033
  // last by creation order — the deterministic §4.1 slot; the comparator is explicit, NOT
2350
- // `(seq ?? ∞) - (seq ?? ∞)` which is `∞ - ∞ = NaN` and corrupts V8's sort), THEN FILTER to the
2351
- // writers of THIS source5.3): a per-source rewind un-applied ONLY that source's staged
2352
- // writes, so ONLY those must re-invoke the others' predictions are intact on their un-rewound
2353
- // trees. The key MUST be `seq`, never `mid`: mids are per-domain (§7.1) so mids from different
2354
- // domains are incomparable — a mid-sort would replay a room mid 1 before a daemon mid 5 that
2355
- // was sent FIRST, letting a read-dependent mutator re-predict from a base it never saw
2356
- // (confirmation order is per-domain; replay order is client-global). FILTERING the
2357
- // globally-ordered array preserves the relative send order among the subset (the 200 §4.1
2358
- // invariant); NEVER re-sort the subset. Single-domain: every entry has
2359
- // `touchedSources = {"daemon"}` and seq order == mid order (except H-v deopt re-enqueues,
2360
- // which keep their ORIGINAL seq under a later daemon mid — deliberately, so this very sort
2361
- // replays them at their original overlay position) ⇒ byte-identical to before.
2362
- const order = [...this.pendingMutations]
2363
- .sort((a, b) => {
2034
+ // `(seq ?? ∞) - (seq ?? ∞)` which is `∞ - ∞ = NaN` and corrupts V8's sort). The key MUST be
2035
+ // `seq`, never `mid`: mids are per-domain 7.1) so mids from different domains are
2036
+ // incomparable a mid-sort would replay a room mid 1 before a daemon mid 5 that was sent
2037
+ // FIRST, letting a read-dependent mutator re-predict from a base it never saw (confirmation
2038
+ // order is per-domain; replay order is client-global). EVERY entry re-invokes the engine's
2039
+ // rewind covers every tracked table (302: there is no per-source rewind), so every entry's
2040
+ // staged writes were just un-applied, whichever channel released. Single-domain: seq order ==
2041
+ // mid order (except H-v deopt re-enqueues, which keep their ORIGINAL seq under a later daemon
2042
+ // mid deliberately, so this very sort replays them at their original overlay position).
2043
+ const order = [...this.pendingMutations].sort((a, b) => {
2364
2044
  if (a.seq === null && b.seq === null)
2365
2045
  return 0; // both unflushed → stable creation order
2366
2046
  if (a.seq === null)
@@ -2368,20 +2048,19 @@ export class OptimisticBackend {
2368
2048
  if (b.seq === null)
2369
2049
  return -1;
2370
2050
  return a.seq - b.seq;
2371
- })
2372
- .filter((p) => p.touchedSources.has(sourceKey));
2051
+ });
2373
2052
  const dropped = new Set();
2374
2053
  try {
2375
2054
  for (const p of order) {
2376
2055
  // NO `readLog` here — recording is armed only on the initial `invoke` (§3.2 #2 note on
2377
2056
  // `PendingMutation.reads`); a re-invocation's write-set still needs fresh capture (below).
2057
+ // The staging map follows the entry's CURRENT domain — a deopt-flipped or re-routed entry
2058
+ // re-stages onto its new domain's tables here.
2378
2059
  const writes = new Map();
2379
2060
  const ops = [];
2380
- const stagedKeys = [];
2381
- let sources = [];
2382
2061
  try {
2383
- sources = this.local.writeWith((tx) => {
2384
- this.runMutator(this.registry[p.name], trackingTx(tx, writes, this.specs, this.localTables, this.opCollector(ops), false, undefined, stagedKeys), p.args);
2062
+ this.local.writeWith((tx) => {
2063
+ this.runMutator(this.registry[p.name], trackingTx(tx, writes, this.specs, this.localTables, this.opCollector(ops), false, undefined, this.stagingMap(p.domain)), p.args);
2385
2064
  });
2386
2065
  }
2387
2066
  catch {
@@ -2394,31 +2073,23 @@ export class OptimisticBackend {
2394
2073
  dropped.add(p);
2395
2074
  continue;
2396
2075
  }
2397
- // The re-invocation stuck — fold its child ops into the rebuilt optimistic agg delta (daemon
2398
- // cycle only a room cycle leaves the daemon-tracked overlay untouched).
2399
- if (daemonCycle)
2400
- for (const op of ops)
2401
- this.overlay.observe(op);
2076
+ // The re-invocation stuck — fold its child ops into the rebuilt optimistic agg delta.
2077
+ for (const op of ops)
2078
+ this.overlay.observe(op);
2402
2079
  // The pending footprint is the UNION across invocations: a re-run that no-ops (touched =
2403
2080
  // {}) must NOT shrink it, else a still-pending mutation reports not-pending and its
2404
- // pending-axis clear fires early (§7.2). `writes`/the source axes mirror this: merge, never
2405
- // replace. (A re-invocation may route differently, e.g. onto a source the room feed now
2406
- // holds — union so the filter still catches it on either source's next cycle.)
2081
+ // pending-axis clear fires early (§7.2). `writes` mirrors this: merge, never replace.
2407
2082
  for (const t of writes.keys())
2408
2083
  p.touched.add(t);
2409
2084
  mergeWriteSet(p.writes, writes);
2410
- for (const s of sources)
2411
- p.touchedSources.add(s);
2412
- mergeWriteSources(p.writeSources, stagedKeys, sources);
2413
2085
  }
2414
2086
  // Preserve creation order in the live array (the unflushed-fold sort tiebreak depends on it).
2415
2087
  if (dropped.size)
2416
2088
  this.pendingMutations = this.pendingMutations.filter((p) => !dropped.has(p));
2417
2089
  // Re-apply the optimistic agg delta onto the (rewound) `__agg` head rows — INSIDE the open
2418
2090
  // cycle, so the writes buffer and coalesce into the one per-query delivery `serverBatchEnd`
2419
- // makes (and never escape as a separate batch). Daemon cycle only (see above).
2420
- if (daemonCycle)
2421
- this.reconcileAggHead();
2091
+ // makes (and never escape as a separate batch). Every cycle (see the reset above).
2092
+ this.reconcileAggHead();
2422
2093
  }
2423
2094
  finally {
2424
2095
  this.local.serverBatchEnd(); // ALWAYS close the cycle — ONE delivery per affected query.
@@ -2435,10 +2106,6 @@ export class OptimisticBackend {
2435
2106
  resetGate(gate) {
2436
2107
  gate.buffer = [];
2437
2108
  gate.appliedCv = 0;
2438
- // The §301 upstream-absorption advert dies with the incarnation that made it — the fresh
2439
- // one re-advertises (direction-B pins conservatively hold until it does).
2440
- delete gate.upstreamBoot;
2441
- delete gate.upstreamCv;
2442
2109
  }
2443
2110
  /** The §8.5 escape: ONE gate's buffer outgrew its cap (a pinned `cvMin` under churn on that
2444
2111
  * channel). Drop everything it buffered and re-register every query on that source — the fresh
@@ -2472,8 +2139,10 @@ export class OptimisticBackend {
2472
2139
  this.setResultType(qid, this.hydrated.has(qid) ? "complete" : "unknown");
2473
2140
  }
2474
2141
  /** A remote sub's first snapshot landed: mark it (and every local view it feeds) hydrated, then
2475
- * lift those views out of `unknown` (loading). Idempotent a re-hydrate snapshot re-marks
2476
- * harmlessly; a source qid with no sub (the lmid system query) is a no-op. */
2142
+ * lift those views out of `unknown` (loading). A ROOM sub's hydration additionally queues the
2143
+ * 302 §4.1 swap-in performed at the applyRelease TAIL ({@link processSwapIns}), once the
2144
+ * reconcile has folded this snapshot into the room tables. Idempotent — a re-hydrate snapshot
2145
+ * re-marks harmlessly; a source qid with no sub (the lmid system query) is a no-op. */
2477
2146
  markSubHydrated(sourceQid) {
2478
2147
  const key = this.sourceToRemote.get(sourceQid);
2479
2148
  if (!key)
@@ -2482,6 +2151,8 @@ export class OptimisticBackend {
2482
2151
  if (!sub || sub.hydrated)
2483
2152
  return;
2484
2153
  sub.hydrated = true;
2154
+ if (sub.channel !== "daemon" && !this.systemQids.has(sub.sourceQid))
2155
+ this.pendingSwapIns.add(sub);
2485
2156
  for (const localQid of sub.localQids.keys()) {
2486
2157
  this.hydrated.add(localQid);
2487
2158
  this.recomputeResultType(localQid);
@@ -2512,11 +2183,32 @@ export class OptimisticBackend {
2512
2183
  // A late-joiner to an already-hydrated sub is immediately hydrated; otherwise this view now
2513
2184
  // awaits the sub's first snapshot (so a split-path local view registered `complete` flips to
2514
2185
  // `unknown` here). Then recompute its lifecycle.
2515
- if (sub.hydrated)
2186
+ if (sub.hydrated) {
2516
2187
  this.hydrated.add(localQueryId);
2517
- else
2188
+ // 302 §4.1 LATE JOIN: a ROOM sub's one-shot swap queue ({@link markSubHydrated}) fired at
2189
+ // its first released snapshot — long gone by now — so a view attaching afterwards must
2190
+ // swap onto the room's namespaced tables HERE, or its engine query stays registered on
2191
+ // the plain daemon tables the room channel never feeds (empty/stale, reported complete,
2192
+ // diverging from its already-swapped siblings forever). The room tables already hold the
2193
+ // released state (hydrated ⇒ folded), so swapping immediately is the ordinary
2194
+ // after-the-data order; processSwapIns skips already-swapped siblings, and
2195
+ // pendingSwapIns is empty outside a release, so exactly this sub's un-swapped views move.
2196
+ if (sub.channel !== "daemon" && !this.systemQids.has(sub.sourceQid)) {
2197
+ this.pendingSwapIns.add(sub);
2198
+ this.processSwapIns();
2199
+ }
2200
+ // FORCE the notify past setResultType's dedup: the labeled split registers the local
2201
+ // half `complete`, then flips the STORE view to `unknown` for the lease window WITHOUT
2202
+ // touching our record — so a complete→complete recompute here would swallow the event
2203
+ // and strand the late-joining view `unknown` forever. Redundant notifies are idempotent
2204
+ // Store-side; a swallowed transition is not recoverable.
2205
+ this.resultTypes.set(localQueryId, "complete");
2206
+ this.resultTypeHandler(localQueryId, "complete");
2207
+ }
2208
+ else {
2518
2209
  this.hydrated.delete(localQueryId);
2519
- this.recomputeResultType(localQueryId);
2210
+ this.recomputeResultType(localQueryId);
2211
+ }
2520
2212
  }
2521
2213
  this.localToRemote.set(retainQid, key);
2522
2214
  this.remoteRetainToLocal.set(retainQid, localQueryId);
@@ -2640,109 +2332,54 @@ function mergeWriteSet(dest, src) {
2640
2332
  d.set(pkKey, rec);
2641
2333
  }
2642
2334
  }
2643
- /** The key a per-pk write source is stored under in {@link PendingMutation.writeSources}: `table`
2644
- * + NUL + the pk-key (`stableJson(pk)`). NUL appears in neither a table name nor `stableJson`
2645
- * output, so it is an unambiguous separator the same key the confirm-drop hold-back reconstructs
2646
- * from `(table, pkKey)` while iterating `writes`. */
2647
- function writeSourceKey(table, pkKey) {
2648
- return `${table}\u0000${pkKey}`;
2649
- }
2650
- /** Zip the staged-order per-write source keys (`stagedKeys`, from `trackingTx`) with the staged-order
2651
- * `sources[]` (`commitTracked`) into `dest`, last-write-per-key winning (§5.3). Never shrinks `dest`
2652
- * — a rebase re-invocation MERGES its fresh routing in, mirroring {@link mergeWriteSet}. The two
2653
- * arrays align 1:1: `trackingTx` pushes exactly one key per staged change, and `commitTracked`
2654
- * returns one source per staged change, both in staged order. */
2655
- function mergeWriteSources(dest, stagedKeys, sources) {
2656
- const n = Math.min(stagedKeys.length, sources.length);
2657
- for (let i = 0; i < n; i++)
2658
- dest.set(stagedKeys[i], sources[i]);
2659
- }
2660
- // --- the §3 router's pure helpers (H-iii) --------------------------------------------
2661
- /** STRICT cell identity for the join-key no-change rule (§3 write rule #3): `===`, nothing more —
2662
- * no coercion, no deep equality (a non-primitive join-key cell compares by reference and thus
2663
- * conservatively fails), `NaN !== NaN` conservatively fails. Failing closed here only costs a
2664
- * daemon route. */
2665
- function identicalCell(a, b) {
2666
- return a === b;
2335
+ // --- the 302 room-table helpers -------------------------------------------------------
2336
+ /** The namespaced ENGINE table backing wire `table` for room `sourceKey` (302 §2: `room_deck`
2337
+ * `deck` one authority per table). `@` appears in no schema table name ENFORCED by
2338
+ * `createSchema`/`extendSchema`'s addTableMeta ban (packages/client/src/schema.ts), so the name
2339
+ * cannot collide with a real table. */
2340
+ export function roomEngineTable(table, sourceKey) {
2341
+ return `${table}@${sourceKey}`;
2667
2342
  }
2668
- /** Whether `cond` is decidable from the KEY ALONE: every column it references collected from
2669
- * BOTH operand positions (under-counting could over-claim decidability; over-counting only fails
2670
- * closed the H-iv-a discipline)is a pk column. A `correlatedSubquery` is never key-local. */
2671
- // Exported for test/predicate_agreement.test.ts — the TS corner of the cross-evaluator
2672
- // agreement corpus (rust/rindle-room-core/tests/fixtures/predicate-agreement.json). NOT part of
2673
- // the package surface (index.ts curates exports); the corpus is the drift tripwire that keeps
2674
- // this evaluator honest against the engine + room-gate pair it shares no code with.
2675
- export function keyDecidable(cond, pkCols) {
2676
- switch (cond.type) {
2677
- case "simple": {
2678
- if (cond.left.type === "column" && !pkCols.has(cond.left.name))
2679
- return false;
2680
- if (cond.right.type === "column" && !pkCols.has(cond.right.name))
2681
- return false;
2682
- return true;
2683
- }
2684
- case "and":
2685
- case "or":
2686
- return cond.conditions.every((c) => keyDecidable(c, pkCols));
2687
- case "correlatedSubquery":
2688
- return false;
2343
+ /** Rename a room gate's released deltas into the room's namespaced tables, DROPPING deltas for
2344
+ * wire tables outside the map (context / unknown the daemon is their sole authority, 302 §6).
2345
+ * Identity (no copy) for a map-less gate the daemon path is untouched. */
2346
+ function mapGateDeltas(gate, muts) {
2347
+ const map = gate.tableMap;
2348
+ if (map === undefined)
2349
+ return muts;
2350
+ const out = [];
2351
+ for (const m of muts) {
2352
+ const engineTable = map.get(m.table);
2353
+ if (engineTable === undefined)
2354
+ continue;
2355
+ out.push({ ...m, table: engineTable });
2689
2356
  }
2357
+ return out;
2690
2358
  }
2691
- /** Evaluate a key-decidable `footprintWhere` on a read's pk cells H-iii's ONE client-side
2692
- * non-engine evaluator, DELIBERATELY MINIMAL (the TS evaluation caveat on the router block
2693
- * comment): `simple` `=`/`!=` between a pk COLUMN and a same-primitive-type NON-NULL literal,
2694
- * composed under and/or. The empty AND the compiler's vacuous-true emission for an exact
2695
- * unconstrained footprint root evaluates `true` (load-bearing: whole-table footprints keep
2696
- * provable reads); the empty OR is vacuous-false. Returns `undefined` = NOT EVALUABLE for
2697
- * anything else (other ops, null on either side, cross-type comparisons, column-vs-column,
2698
- * literal-vs-literal, non-primitive cells, correlated subqueries) — the caller fails to the
2699
- * daemon. A non-evaluable node anywhere poisons the whole tree (no short-circuit past it):
2700
- * partial evaluation could otherwise claim a verdict the engine's semantics might contradict. */
2701
- // Exported for test/predicate_agreement.test.ts (see keyDecidable's note above).
2702
- export function evalFootprintOnPk(cond, pkCells) {
2703
- switch (cond.type) {
2704
- case "simple": {
2705
- if (cond.op !== "=" && cond.op !== "!=")
2706
- return undefined;
2707
- const col = cond.left.type === "column" ? cond.left : cond.right.type === "column" ? cond.right : undefined;
2708
- const lit = cond.left.type === "literal" ? cond.left : cond.right.type === "literal" ? cond.right : undefined;
2709
- if (col === undefined || lit === undefined)
2710
- return undefined; // column-vs-column / literal-vs-literal
2711
- if (!pkCells.has(col.name))
2712
- return undefined; // not a pk column (keyDecidable pre-screens)
2713
- const cell = pkCells.get(col.name);
2714
- const value = lit.value;
2715
- if (cell === null || value === null)
2716
- return undefined; // no null semantics client-side
2717
- if (typeof cell !== typeof value)
2718
- return undefined; // no cross-type comparison semantics
2719
- if (typeof cell !== "string" && typeof cell !== "number" && typeof cell !== "boolean")
2720
- return undefined;
2721
- return cond.op === "=" ? cell === value : cell !== value;
2722
- }
2723
- case "and": {
2724
- let out = true;
2725
- for (const c of cond.conditions) {
2726
- const v = evalFootprintOnPk(c, pkCells);
2727
- if (v === undefined)
2728
- return undefined;
2729
- out = out && v;
2730
- }
2731
- return out;
2732
- }
2733
- case "or": {
2734
- let out = false;
2735
- for (const c of cond.conditions) {
2736
- const v = evalFootprintOnPk(c, pkCells);
2737
- if (v === undefined)
2738
- return undefined;
2739
- out = out || v;
2740
- }
2741
- return out;
2742
- }
2743
- case "correlatedSubquery":
2744
- return undefined;
2745
- }
2359
+ /** Rename every TABLE reference in a query AST through `map` (302 §2 point 3the room-homed
2360
+ * view's rewrite): the root `table`, every `related` subquery, every `correlatedSubquery`
2361
+ * (EXISTS) condition walking the KNOWN wire-AST shape, never a blind key scan: `start.row` is
2362
+ * keyed by COLUMN name (a schema column literally named `table` must keep its bound value), and
2363
+ * the same goes for any future column-keyed record. Tables absent from the map keep their name —
2364
+ * that is the client-side join across kinds (a room table joined to daemon-owned context,
2365
+ * 201-style). Structural clone; the input AST is never mutated. */
2366
+ export function remapAstTables(ast, map) {
2367
+ const walkCond = (c) => {
2368
+ if (c.type === "and" || c.type === "or")
2369
+ return { ...c, conditions: c.conditions.map(walkCond) };
2370
+ if (c.type === "correlatedSubquery")
2371
+ return { ...c, related: walkSub(c.related) };
2372
+ return c; // "simple" — column refs and literals carry no table reference
2373
+ };
2374
+ const walkSub = (s) => ({ ...s, subquery: walk(s.subquery) });
2375
+ const walk = (a) => ({
2376
+ ...a,
2377
+ table: map.get(a.table) ?? a.table,
2378
+ ...(a.where !== undefined ? { where: walkCond(a.where) } : {}),
2379
+ ...(a.having !== undefined ? { having: walkCond(a.having) } : {}),
2380
+ ...(a.related !== undefined ? { related: a.related.map(walkSub) } : {}),
2381
+ });
2382
+ return walk(ast);
2746
2383
  }
2747
2384
  /** Wrap the raw wasm txn as the client `MutationTx`, capturing a pk-granular write-set as it
2748
2385
  * applies (`writes`, a {@link WriteSet} — table → pk-key → last-write-wins image, §3.2 #1);
@@ -2780,17 +2417,22 @@ function applyOpToTx(tx, op) {
2780
2417
  return tx.delete(op.table, op.pk);
2781
2418
  }
2782
2419
  }
2783
- function trackingTx(tx, writes, specs, localTables, onOp, trapReads = false, readLog, stagedKeys,
2784
- /** Per-read provenance probe (H-ii §3.2 #3): called once per RECORDED present read with the
2785
- * full-width row the read observed; meaningful only alongside `readLog` (never consulted when
2786
- * recording is offthe fold and replay paths pass neither). */
2787
- provenance) {
2420
+ function trackingTx(tx, writes, specs, localTables, onOp, trapReads = false, readLog,
2421
+ /** The 302 staging map for a room-DECLARED mutation: wire table the room's namespaced engine
2422
+ * table for the tables the room owns; identity for everything else. Every raw engine access —
2423
+ * reads and writesgoes through it, so a room mutator reads/writes the room's own state
2424
+ * (its optimistic effects land where the room-homed views look) while its envelope still
2425
+ * ships the wire names. Absent (or a non-owned table) ⇒ the plain table, verbatim. */
2426
+ stage) {
2788
2427
  const spec = (table) => {
2789
2428
  const s = specs[table];
2790
2429
  if (!s)
2791
2430
  throw new Error(`unknown table ${JSON.stringify(table)} — tables: ${Object.keys(specs).join(", ")}`);
2792
2431
  return s;
2793
2432
  };
2433
+ /** The ENGINE table a wire-named access lands on (302 §2). Schema/column validation always
2434
+ * runs on the WIRE name (the namespaced twin shares the spec). */
2435
+ const staged = (table) => stage?.get(table) ?? table;
2794
2436
  // M1 (`201-LOCAL-ONLY-TABLES-DESIGN.md` §6): a replayable mutator is a pure function of
2795
2437
  // (synced base + args) — it neither READS nor WRITES a local-only table. The server runs the
2796
2438
  // same mutator from `args` alone and cannot see local tables, so any dependence diverges the
@@ -2825,18 +2467,16 @@ provenance) {
2825
2467
  // this invocation — an add-then-edit (or edit-then-edit) of the same pk collapses to its final
2826
2468
  // image, matching the engine head's own semantics for that pk. The record is replaced with
2827
2469
  // exactly the arguments given: the CALLERS (`edit`/`remove` below, consulting `prior`) decide
2828
- // the pre-image per the H-ii coalescing matrix on {@link WriteRecord}.
2829
- const recordWrite = (table, pk, row, oldRow) => {
2830
- let byPk = writes.get(table);
2470
+ // the pre-image per the H-ii coalescing matrix on {@link WriteRecord}. Keyed by the STAGED
2471
+ // (engine) table name, so the pending axis and the write-set match what the engine holds.
2472
+ const recordWrite = (engineTable, pk, row, oldRow) => {
2473
+ let byPk = writes.get(engineTable);
2831
2474
  if (!byPk)
2832
- writes.set(table, (byPk = new Map()));
2475
+ writes.set(engineTable, (byPk = new Map()));
2833
2476
  const pkKey = stableJson(pk);
2834
2477
  // Defensive copies: the wasm binding's returned arrays are not contractually immutable/unique,
2835
2478
  // so a captured record must not alias a cell array the engine could later reuse or mutate.
2836
- byPk.set(pkKey, { table, pk: [...pk], row: row ? [...row] : undefined, ...(oldRow ? { oldRow: [...oldRow] } : {}) });
2837
- // Staged-order key trace (§5.3): pushed once per staged change, so it zips 1:1 with the staged
2838
- // order of `commitTracked`'s `sources[]` — letting the caller learn each write's routed source.
2839
- stagedKeys?.push(writeSourceKey(table, pkKey));
2479
+ byPk.set(pkKey, { table: engineTable, pk: [...pk], row: row ? [...row] : undefined, ...(oldRow ? { oldRow: [...oldRow] } : {}) });
2840
2480
  };
2841
2481
  // A full insert row: each cell is `obj[c]`, or `null` for an omitted nullable column (design 206
2842
2482
  // §6.2); a `json` object is stringified for the engine (`toCell`). Non-nullable columns are
@@ -2864,22 +2504,17 @@ provenance) {
2864
2504
  // trapped path (where `readLog` is never armed) records nothing, exactly as before.
2865
2505
  const rawGet = (table, pk) => {
2866
2506
  assertNotLocal(table, "read");
2867
- return tx.get(table, pk);
2507
+ return tx.get(staged(table), pk);
2868
2508
  };
2869
2509
  // Push one {@link ReadRecord} when recording is armed (§3.2 #2/#3): outcome from `row`'s
2870
- // presence, per-read provenance probed with the observed FULL-WIDTH row — present reads only
2871
- // (an absent read has no row to probe with, and records no source; see {@link ReadRecord}).
2872
- // The `source` key is OMITTED (not set to undefined) when there is no answer, so a
2873
- // single-domain record is byte-identical to Slice B's.
2510
+ // presence. Pure capture for inspection.
2874
2511
  const recordRead = (table, pk, row) => {
2875
2512
  if (!readLog)
2876
2513
  return;
2877
- const source = row === undefined ? undefined : provenance?.(table, row);
2878
2514
  readLog.reads.push({
2879
2515
  table,
2880
2516
  pk: [...pk],
2881
2517
  outcome: row === undefined ? "absent" : "present",
2882
- ...(source !== undefined ? { source } : {}),
2883
2518
  });
2884
2519
  };
2885
2520
  // The PUBLIC positional read (§3.2 #2) — and, since H-ii, the keyed writers' pre-existence
@@ -2899,50 +2534,55 @@ provenance) {
2899
2534
  return cells ? toKeyed(table, cells) : undefined;
2900
2535
  };
2901
2536
  // The pk's existing record from THIS invocation, if any — the coalescing-matrix input for
2902
- // `edit`/`remove` below (see {@link WriteRecord}).
2903
- const prior = (table, pk) => writes.get(table)?.get(stableJson(pk));
2537
+ // `edit`/`remove` below (see {@link WriteRecord}). Keyed by the STAGED name like the records.
2538
+ const prior = (table, pk) => writes.get(staged(table))?.get(stableJson(pk));
2904
2539
  const add = (table, row) => {
2905
2540
  assertNotLocal(table, "write");
2906
- recordWrite(table, pkFromCells(table, row), row);
2541
+ const t = staged(table);
2542
+ recordWrite(t, pkFromCells(table, row), row);
2543
+ // ChildOps carry the WIRE name (unlike the write-set): the agg overlay's defs are keyed by
2544
+ // the ORIGINAL AST's child tables (`collectAggDefs`), and the `__agg_*` heads it feeds are
2545
+ // shared by plain and swapped views alike — a staged name would silently miss the dispatch
2546
+ // and the optimistic count would lag every room-declared write until its echo.
2907
2547
  onOp?.({ table, kind: "add", row });
2908
- tx.add(table, row);
2548
+ tx.add(t, row);
2909
2549
  };
2910
2550
  const remove = (table, row) => {
2911
2551
  assertNotLocal(table, "write");
2552
+ const t = staged(table);
2912
2553
  const pk = pkFromCells(table, row);
2913
- // The remove PRE-IMAGE (G-iii, §7.3 tombstone; the H-ii matrix on {@link WriteRecord}):
2914
- // remove-after-edit/-remove keeps the ORIGINAL captured pre-image (the txn-entry base — the
2915
- // net effect is a remove of the row the external world last knew, never the edited transient).
2916
- // Otherwise (first touch, or remove-after-add) the truthful full-width row is the txn-visible
2917
- // one — `tx.get` read BEFORE the remove stages (read-your-writes: an add of this pk earlier in
2918
- // the SAME invocation shows through). Captured NOW so Slice H's writable-predicate evaluation
2919
- // over it needs no migration. Falls back to the caller's asserted `row` when the pk is not
2554
+ // The remove PRE-IMAGE (the H-ii matrix on {@link WriteRecord}): remove-after-edit/-remove
2555
+ // keeps the ORIGINAL captured pre-image (the txn-entry base — the net effect is a remove of
2556
+ // the row the external world last knew, never the edited transient). Otherwise (first touch,
2557
+ // or remove-after-add) the truthful full-width row is the txn-visible one — `tx.get` read
2558
+ // BEFORE the remove stages (read-your-writes: an add of this pk earlier in the SAME
2559
+ // invocation shows through). Falls back to the caller's asserted `row` when the pk is not
2920
2560
  // resident (a raw remove of an absent row) — a captured remove thus always carries a
2921
- // full-width pre-image (the engine width-checks `holdBackAbsent`).
2922
- const oldRow = prior(table, pk)?.oldRow ?? tx.get(table, pk) ?? row;
2923
- recordWrite(table, pk, undefined, oldRow);
2924
- onOp?.({ table, kind: "remove", row });
2925
- tx.remove(table, row);
2561
+ // full-width pre-image.
2562
+ const oldRow = prior(table, pk)?.oldRow ?? tx.get(t, pk) ?? row;
2563
+ recordWrite(t, pk, undefined, oldRow);
2564
+ onOp?.({ table, kind: "remove", row }); // wire name — see `add`
2565
+ tx.remove(t, row);
2926
2566
  };
2927
2567
  const edit = (table, oldRow, newRow) => {
2928
2568
  assertNotLocal(table, "write");
2569
+ const t = staged(table);
2929
2570
  const pk = pkFromCells(table, newRow);
2930
- // The edit PRE-IMAGE (H-ii; the matrix on {@link WriteRecord}) H-iii's join-key no-change
2931
- // input, and the row the engine routes the Edit by (H-i). First touch: the txn-visible row
2932
- // read BEFORE staging, falling back to the caller's asserted `oldRow` when the pk is not
2571
+ // The edit PRE-IMAGE (the H-ii matrix on {@link WriteRecord}). First touch: the txn-visible
2572
+ // row read BEFORE staging, falling back to the caller's asserted `oldRow` when the pk is not
2933
2573
  // resident (covers the pk-MOVING raw edit — the record is keyed by the NEW pk; the pre-image
2934
2574
  // carries the OLD row). Edit-after-edit: keep the FIRST pre-image (the txn-entry base).
2935
2575
  // Edit-after-add / edit-after-remove: the record collapses to a (re-)insert — NO pre-image
2936
2576
  // (the pk did not pre-exist this invocation's base).
2937
2577
  const p = prior(table, pk);
2938
2578
  const pre = p === undefined
2939
- ? (tx.get(table, pk) ?? oldRow)
2579
+ ? (tx.get(t, pk) ?? oldRow)
2940
2580
  : p.row !== undefined && p.oldRow !== undefined
2941
2581
  ? p.oldRow
2942
2582
  : undefined;
2943
- recordWrite(table, pk, newRow, pre);
2944
- onOp?.({ table, kind: "edit", row: newRow, old: oldRow });
2945
- tx.edit(table, oldRow, newRow);
2583
+ recordWrite(t, pk, newRow, pre);
2584
+ onOp?.({ table, kind: "edit", row: newRow, old: oldRow }); // wire name — see `add`
2585
+ tx.edit(t, oldRow, newRow);
2946
2586
  };
2947
2587
  // The folded read trap (§5): a mutator that reads to compute its write is refused. `() => never`
2948
2588
  // is assignable to the wider read signatures (extra args ignored, `never` widens to the result).
@@ -2959,7 +2599,9 @@ provenance) {
2959
2599
  for (const t of collectTables(ast))
2960
2600
  assertNotLocal(t, "read");
2961
2601
  readLog?.queries.push(ast);
2962
- return tx.query(ast);
2602
+ // A room-declared mutator's one-shot query reads the room's own staged state for the tables
2603
+ // the room owns (the same staging rule as the point reads above).
2604
+ return tx.query(stage !== undefined && stage.size > 0 ? remapAstTables(ast, stage) : ast);
2963
2605
  };
2964
2606
  return {
2965
2607
  get: trapReads ? trapped : getImpl,