@rindle/optimistic 0.4.4 → 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
@@ -41,6 +41,7 @@ import { CLIENT_MUTATIONS_SCHEMA, driveMutationSync, insertCell, insertPlan, isG
41
41
  import { aggTableSchemas, NormalizedSync, rewriteAggregates } from "@rindle/normalized";
42
42
  import { WasmBackend } from "@rindle/wasm";
43
43
  import { AggOverlay, collectAggDefs } from "./agg-overlay.js";
44
+ import { decodeOutcomeRow, LIFECYCLE_TABLE_SCHEMAS, ROOM_CLIENT_MUTATIONS_TABLE, ROOM_MUTATION_OUTCOMES_TABLE, ROOM_WATERMARK_TABLE, roomDomainKey, SCOPE_SESSIONS_TABLE, } from "./system-streams.js";
44
45
  /** The reserved source-qid of the backend's own lmid system query. User/local query ids
45
46
  * are assigned by the `Store` starting at 1, so 0 never collides. */
46
47
  const LMID_QID = 0;
@@ -56,6 +57,13 @@ const REAL_CLOCK = {
56
57
  clearTimeout: (h) => clearTimeout(h),
57
58
  now: () => Date.now(),
58
59
  };
60
+ /** The (shared, frozen-by-convention) empty map {@link OptimisticBackend.roomTablesFor} answers
61
+ * for a room with no registered tables. */
62
+ const EMPTY_ROOM_TABLES = new Map();
63
+ /** Per-domain retention cap for the processed-outcome set (H-v) — mirrors the shell's
64
+ * `MAX_RECORDED_OUTCOMES_PER_CLIENT`: the sender caps what it can re-answer at 512 per client,
65
+ * so remembering more than 512 processed mids per domain buys nothing. */
66
+ const MAX_PROCESSED_OUTCOMES_PER_DOMAIN = 512;
59
67
  export class OptimisticBackend {
60
68
  local;
61
69
  sync;
@@ -78,6 +86,9 @@ export class OptimisticBackend {
78
86
  * its width check. */
79
87
  colCounts;
80
88
  colIndex;
89
+ /** Per-table pk column indices — held so `connectSource` can build a fresh per-source
90
+ * `NormalizedSync` with the same layout the daemon's uses. */
91
+ pkCols;
81
92
  /** The client's OWN typed per-table schemas + the reserved lmid table — the fixed base
82
93
  * of the expected-schema set (CRIT#4 validation). Synthetic agg tables are appended as
83
94
  * queries arrive (`ensureSyntheticTables`). */
@@ -102,6 +113,12 @@ export class OptimisticBackend {
102
113
  // only for the duration of the reconcile cycle in `onProgress`; a re-hydrate after a drop is a real
103
114
  // footprint diff (genuine change) and is NOT remapped. See {@link ChangeEvent} `catchUp`.
104
115
  catchUpQids = null;
116
+ /** Newly-hydrated qids whose reconcile ACTUALLY emitted a (catch-up-stamped) batch — recorded by the
117
+ * local-event forwarder alongside {@link catchUpQids}. After the reconcile, any newly-hydrated qid
118
+ * NOT in here folded nothing (0 rows, or its result already present via a sibling → 0 net muts, or
119
+ * the reconcile was skipped), so `onProgress` sends it an explicit empty catch-up — else its SSR
120
+ * seed would never retire (the view freezes). Non-null only for the reconcile's duration. */
121
+ catchUpEmitted = null;
105
122
  /** The Store's commit-boundary handler ({@link Backend.onCommitBoundary}), forwarded from the
106
123
  * local engine's `dispatch` brackets so the Store folds every affected view before notifying any
107
124
  * subscriber (cross-view-atomic notification). All this backend's data deltas originate from the
@@ -109,18 +126,115 @@ export class OptimisticBackend {
109
126
  boundaryHandler = () => { };
110
127
  devObservers = new Set();
111
128
  pendingMutations = [];
112
- nextMid = 1;
129
+ /** The next mid to deal, PER DOMAIN (RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md §7.1): a client
130
+ * writing through room + daemon concurrently must not alias one lmid counter. Seeded with the
131
+ * `"daemon"` stream at 1; a domain absent from the map starts at 1. In the single-domain
132
+ * configuration only `"daemon"` is ever touched, so the sequence is byte-for-byte as before. */
133
+ nextMid = new Map([["daemon", 1]]);
134
+ /** The client-global deal counter behind {@link PendingMutation.seq}: one sequence across ALL
135
+ * domains, bumped whenever any domain's mid is dealt. The replay order (mids are per-domain and
136
+ * incomparable across domains — see the `seq` field doc). */
137
+ dealSeq = 0;
138
+ /** The explicit confirming-stream override (§7.1/§3) — see
139
+ * {@link OptimisticBackendOptions.domainPolicy}. `undefined` from it ⇒ H-iii derivation. */
140
+ domainPolicy;
141
+ /** The final-rejection reason surface ({@link OptimisticBackendOptions.onRejected}). */
142
+ rejectedHandler;
143
+ /** Processed `(domain, mid)` outcome frames (H-v) — the deopt handshake's idempotence guard: a
144
+ * duplicate frame (the original plus a reconnect re-send's re-answer, or two re-answers across
145
+ * two reconnects) must not double-invoke. Needed precisely because a deopt frame can arrive for
146
+ * an ALREADY-RETIRED mid (the replay gotcha) — "no matching entry" alone cannot distinguish
147
+ * "handle it fresh" from "already handled". Per-domain FIFO, capped like the shell's
148
+ * recorded-outcome map ({@link MAX_PROCESSED_OUTCOMES_PER_DOMAIN}); past the cap a duplicate of
149
+ * an evicted mid would be re-processed — the same bounded-window trade the shell makes, and it
150
+ * takes 512 interleaving non-applied outcomes on one domain to open it. */
151
+ outcomesProcessed = 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();
113
169
  /** The live fold entries, by fold key `${name}\0${identityJSON}` — at most one per key
114
170
  * (FOLDED-MUTATIONS-DESIGN §8). Insertion order is creation order (the drain/flush tiebreak). */
115
171
  folds = new Map();
116
172
  /** The fold debounce clock (real timers by default; the oracle injects a virtual one). */
117
173
  clock;
118
- /** The high-water confirmed mutation id, folded from the lmid system query's
119
- * RELEASED ops (lmid-as-data) never from a frame. */
120
- confirmedLmid = 0;
121
- buffer = [];
122
- nextSeq = 0;
123
- appliedCv = 0;
174
+ /** The high-water confirmed mutation id, PER DOMAIN (RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md
175
+ * §7.2 per-domain confirm-drop): an entry with `mid <= watermark[entry.domain]` has been
176
+ * confirmed. The `"daemon"` domain is folded from the lmid system query's RELEASED ops
177
+ * (lmid-as-data) never from a frame; a room domain will fold from its own lmid stream (later
178
+ * slice). Seeded with `"daemon"` at 0; the daemon scalar `confirmedLmid` (devtools) is
179
+ * `watermark.get("daemon")`. */
180
+ watermark = new Map([["daemon", 0]]);
181
+ /** The per-source coherence gates (§5.1), by source key. Seeded with the daemon gate at
182
+ * construction; a room channel attaches later (`connectSource`). Single-domain: one entry,
183
+ * and every gate-generalized path degenerates to the old single-buffer code. NOT the same
184
+ * space as {@link watermark}/{@link nextMid}: a DOMAIN can confirm with no gate connected
185
+ * (the `__testRelease` seam); a gate's `key` names the domain its lmid stream folds into. */
186
+ gates = new Map();
187
+ /** The daemon's gate — the always-present channel (constructor-attached). The devtools
188
+ * scalars (`__inspect`) read it directly; its `sync` IS {@link sync} (the agg overlay and
189
+ * synthetic tables are daemon-tracked by design). */
190
+ daemonGate;
191
+ // --- the §4 lifecycle SYSTEM-STREAM plane (Slice I-iii) --------------------------------
192
+ /** System retains by source qid ({@link retainSystemQuery}): a subscription with NO store view
193
+ * and NO user-visible table — its frames buffer on its gate exactly like {@link LMID_QID}'s and
194
+ * fold at RELEASE time ({@link foldSystemFrames}), never entering the sync layer or the local
195
+ * engine. The spec names which system table the qid serves and the scope/doc it was minted for
196
+ * (the fold's row filter). Empty on every non-lifecycle client — every partition below is then
197
+ * a structural no-op and the release path is byte-identical to before. */
198
+ systemQids = new Map();
199
+ /** The §4.2 fence state: room doc → highest `flush_seq` delivered through the daemon plane
200
+ * (monotone max-fold; a remove never regresses it). Slice I-v's ghost-drop consumer — I-iii
201
+ * only maintains + exposes it (`__inspectDomains().lifecycle`). */
202
+ roomWatermarks = new Map();
203
+ /** The §4.1 occupancy state: scope → (client_id → expires_at) from the doorbell stream. Slice
204
+ * I-iv's doorbell consumer (the 1→2 re-lease reaction) — I-iii only maintains + exposes it.
205
+ * A snapshot REPLACES the scope's map (authoritative re-hydrate); a batch folds add/edit/remove
206
+ * incrementally (the age-out sweep's deletes arrive as removes). */
207
+ scopeSessions = new Map();
208
+ /** The I-iv doorbell event sink ({@link onScopeSessions}) — fired once per scope a release's
209
+ * scope-session fold touched, AFTER the whole release applied. Default no-op: a client that
210
+ * never registers (no lifecycle plane) pays nothing. */
211
+ scopeSessionsHandler = () => { };
212
+ /** Deferred old-channel row GC for in-flight upgrade retargets ({@link retargetRemoteQuery}):
213
+ * sub sourceQid → the channel it left. The rows the OLD gate's sync holds for the qid stay
214
+ * visible (merge: daemon tier) until the sub's first snapshot RELEASES on its new room channel
215
+ * ({@link flushRetargetGc}) — dropping them at retarget time would emit net removes ahead of
216
+ * the room's re-adds, the flicker the two-phase cutover exists to avoid. Doubles as the
217
+ * wrong-channel GRACE window in {@link onFrame}: a frame already in flight from the old
218
+ * channel when the sub moved is stale, not a wiring bug. Empty on every non-upgrade client —
219
+ * every consultation below is then a structural no-op. */
220
+ pendingRetargetGc = new Map();
221
+ /** The §4.2 GHOSTS (Slice I-v): demoted room sources awaiting their watermark fence, by
222
+ * sourceKey. Written only by {@link demoteRoomSource}; evaluated after every release
223
+ * ({@link evaluateGhosts}) and dropped by {@link dropGhost} once the fence clears with no
224
+ * sent room-domain pending left. Empty on every non-downgrade client — the per-release
225
+ * evaluation is then a structural no-op. */
226
+ ghosts = new Map();
227
+ /** The I-v stuck-downgrade surface ({@link onDowngradeStuck}) — fired AT MOST ONCE per ghost
228
+ * when its fence is satisfied but sent room-domain mids remain unresolved (§7.5: they retire
229
+ * only through outcome resolution; the ghost holds rather than inventing a timeout-retire).
230
+ * Default no-op. */
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();
124
238
  asts = new Map();
125
239
  /** Per query: the base tables its result can draw from (from the AST tree). */
126
240
  queryTables = new Map();
@@ -145,16 +259,21 @@ export class OptimisticBackend {
145
259
  this.local = new WasmBackend(schema);
146
260
  this.local.onEvent((qid, ev) => {
147
261
  // Stamp a newly-hydrating query's reconcile batch as a catch-up (initial-hydration) delivery,
148
- // so the Store phases it as a `snapshot` rather than narrating the whole first result set.
149
- const stamped = ev.type === "batch" && this.catchUpQids?.has(qid) ? { ...ev, catchUp: true } : ev;
150
- this.handler(qid, stamped);
262
+ // so the Store phases it as a `snapshot` rather than narrating the whole first result set. Record
263
+ // that we emitted a hydration batch for this qid, so `onProgress` knows which newly-hydrated qids
264
+ // still need an explicit empty catch-up (they folded nothing — see {@link catchUpEmitted}).
265
+ const stamp = ev.type === "batch" && this.catchUpQids?.has(qid) === true;
266
+ if (stamp)
267
+ this.catchUpEmitted?.add(qid);
268
+ this.handler(qid, stamp ? { ...ev, catchUp: true } : ev);
151
269
  });
152
270
  // Forward the local engine's commit brackets up to the Store (cross-view-atomic notification):
153
271
  // every data delta this backend emits comes from `this.local`, so its commit boundaries are ours.
154
272
  this.local.onCommitBoundary((phase) => this.boundaryHandler(phase));
155
273
  this.colCounts = colCountsFromSchema(schema);
156
274
  this.colIndex = colIndexFromSchema(schema);
157
- this.sync = new NormalizedSync(pkColsFromSchema(schema), this.colCounts);
275
+ this.pkCols = pkColsFromSchema(schema);
276
+ this.sync = new NormalizedSync(this.pkCols, this.colCounts);
158
277
  this.specs = tableSpecsFromSchema(schema);
159
278
  this.localTables = localTableNames(schema);
160
279
  this.source = source;
@@ -163,21 +282,119 @@ export class OptimisticBackend {
163
282
  this.user = opts.user ?? (() => "");
164
283
  this.bufferCap = opts.bufferCap ?? 1024;
165
284
  this.clock = opts.clock ?? REAL_CLOCK;
166
- // Validate each server hello against our OWN typed schema reject a schema skew
167
- // (CRIT#4). The reserved lmid table is part of the expected set so the system
168
- // query's hello passes. Synthetic agg tables join the set as queries register them.
169
- this.clientTablesBase = [...normalizedTableSchemas(schema), CLIENT_MUTATIONS_SCHEMA];
170
- this.source.expectClientSchema?.(this.clientTablesBase);
171
- this.source.onNormalized((qid, ev) => this.onNormalized(qid, ev));
172
- this.source.onProgress((frame) => this.onProgress(frame));
173
- this.source.onRestart?.(() => this.resetForRestart());
174
- // The lmid system query (lmid-as-data): our confirmations arrive on this stream,
175
- // cv-tagged, released by the same cvMin as the data they belong to. The server
176
- // derives the identity from the connection; args are advisory.
177
- this.source.registerQuery(LMID_QID, { name: LMID_QUERY_NAME, args: {} });
285
+ // No policy configured every route DERIVES (H-iii §3). With no room gate connected the
286
+ // derivation short-circuits to "daemon", so a single-domain app is byte-for-byte as before.
287
+ this.domainPolicy = opts.domainPolicy ?? (() => undefined);
288
+ this.rejectedHandler = opts.onRejected ?? (() => { });
289
+ // The reserved lmid table + (I-iii) the four lifecycle system tables join the expected set so
290
+ // a system subscription's hello passes CRIT#4 validation. Extra CLIENT-side entries are inert
291
+ // for every other server hello (validation only checks tables a server advertises), so a
292
+ // client that never receives a lifecycle block is byte-identical.
293
+ this.clientTablesBase = [...normalizedTableSchemas(schema), CLIENT_MUTATIONS_SCHEMA, ...LIFECYCLE_TABLE_SCHEMAS];
294
+ // The daemon is the always-present channel: its gate is attached at construction, and its
295
+ // per-source refcount space IS `this.sync` (the agg overlay reads it directly). A room
296
+ // channel attaches through the same seam later (§5.1; Slice G).
297
+ this.daemonGate = this.attachGate("daemon", source, this.sync);
298
+ }
299
+ /** Wire one authority channel into its own coherence gate (§5.1): every frame the channel
300
+ * delivers buffers on THIS gate's cv timeline, its progress frames release THIS buffer, its
301
+ * restart resets THIS gate alone, and its reserved lmid stream folds into `watermark[key]`.
302
+ * Validates each server hello against our OWN typed schema → reject a schema skew (CRIT#4);
303
+ * the reserved lmid table is part of the expected set so the system query's hello passes, and
304
+ * synthetic agg tables join the set as queries register them. */
305
+ attachGate(key, source, sync) {
306
+ const gate = { key, source, sync, buffer: [], nextSeq: 0, appliedCv: 0 };
307
+ this.gates.set(key, gate);
308
+ source.expectClientSchema?.([...this.clientTablesBase, ...this.synthetic.values()]);
309
+ source.onNormalized((qid, ev) => this.onFrame(gate, qid, ev));
310
+ source.onProgress((frame) => this.onGateProgress(gate, frame));
311
+ source.onRestart?.(() => this.resetGate(gate));
312
+ // The deopt handshake's client half (H-v §3.3): the channel's `mutationOutcome` frames arrive
313
+ // as `(domain = gate.key, frame)`. OUT-OF-BAND — the source dispatches on arrival and this
314
+ // handler runs immediately, NEVER behind the gate's cv buffer: a deopt must migrate its entry
315
+ // BEFORE the buffered lmid release that would otherwise retire it as a success (and the §7.3
316
+ // hold-back trigger, keyed on `p.domain`, would park its staged writes the wrong way).
317
+ source.onMutationOutcome?.((frame) => this.handleMutationOutcome(gate.key, frame));
318
+ // §7.5 rule 3 (H-v): a re-established session re-sends this DOMAIN's unconfirmed pending
319
+ // envelopes with their ORIGINAL mids — the authority's own ledger dedups (an applied mid is
320
+ // silent; a non-applied one is re-answered from the recorded-outcome map into the handler
321
+ // above). This is the deopt crash-window closer: a frame lost with its socket is re-earned.
322
+ source.onResync?.(() => this.resendPending(gate.key));
323
+ // The lmid system query (lmid-as-data): confirmations arrive on this channel's stream,
324
+ // cv-tagged, released by the same cvMin as the data they belong to. The server derives
325
+ // the identity from the connection; args are advisory. Qid 0 is reserved PER CHANNEL —
326
+ // it never collides with Store-dealt qids and never enters the sync layer.
327
+ source.registerQuery(LMID_QID, { name: LMID_QUERY_NAME, args: {} });
328
+ return gate;
329
+ }
330
+ /** Attach a SECOND authority channel (§5.1) — the seam Slice G's room upgrade calls with the
331
+ * ws-backed room feed. Rooms speak the daemon protocol verbatim (§2.4: the client cannot tell
332
+ * a room from the daemon), so the argument is a full {@link OptimisticSource} — exactly what
333
+ * `@rindle/remote` builds from `{roomUrl, leaseToken}`. The channel buffers/releases on its
334
+ * own cv timeline (an independent §5.1 gate: coherent within, eventual across) and its
335
+ * reserved lmid stream folds into `watermark[sourceKey]` — so `sourceKey` must equal the
336
+ * `domainPolicy` name for the mutations this authority confirms. The converse is NOT required:
337
+ * a domain may exist with no connected gate (`__testRelease` drives confirms gate-less); the
338
+ * live production path stays daemon-only until G calls this. */
339
+ connectSource(sourceKey, source) {
340
+ if (this.gates.has(sourceKey)) {
341
+ throw new Error(`optimistic backend: source ${sourceKey} is already connected`);
342
+ }
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;
178
389
  }
179
390
  // --- the Backend seam ---------------------------------------------------------
180
- registerQuery(qid, ast, remote) {
391
+ /** `channel` (G-iii registration-time routing) names the authority channel the remote sub
392
+ * registers on — a `connectSource`d gate key; default `"daemon"` (every existing caller is
393
+ * byte-identical). Slice G-v threads the lease's `realtime.sourceKey` here. Validated FIRST
394
+ * (like the E3 check below): a bad channel must throw before any per-query state is recorded. */
395
+ registerQuery(qid, ast, remote, channel) {
396
+ if (remote)
397
+ this.requireGate(channel ?? "daemon");
181
398
  // queryTables is derived from the ORIGINAL ast — its `count(comments)` subquery names
182
399
  // `comment`, so an optimistic comment mutation flips this query to `unknown` (§6). The
183
400
  // local engine, by contrast, runs the REWRITTEN ast (reads the synthetic `__agg_*`).
@@ -206,11 +423,11 @@ export class OptimisticBackend {
206
423
  // child is left a native reduce (L1) — `rewriteAggregates`/`ensureSyntheticTables` skip it.
207
424
  this.ensureSyntheticTables(qid, ast);
208
425
  // Local first (synchronous empty view), then the server stream hydrates it.
209
- this.local.registerQuery(qid, rewriteAggregates(ast, (t) => this.localTables.has(t)));
426
+ this.local.registerQuery(qid, this.plainEngineAst(ast));
210
427
  if (remote) {
211
428
  // A remote query is `unknown` until its first server snapshot lands (hydration); retainRemote
212
429
  // attaches it to the sub and sets the lifecycle against the sub's hydration state.
213
- this.retainRemote(qid, remote);
430
+ this.retainRemote(qid, remote, qid, channel);
214
431
  }
215
432
  else {
216
433
  // No server stream (a purely local AST view — or the local half of a split retain whose
@@ -280,11 +497,22 @@ export class OptimisticBackend {
280
497
  this.source.expectClientSchema?.([...this.clientTablesBase, ...this.synthetic.values()]);
281
498
  }
282
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
283
502
  const remoteQid = this.releaseRemote(qid);
284
- if (remoteQid !== undefined)
285
- this.buffer = this.buffer.filter((f) => f.qid !== remoteQid);
286
- // GC: rows this remote footprint SOLELY referenced fall to refcount 0 net removes.
287
- const gc = remoteQid === undefined ? [] : this.sync.dropQuery(remoteQid);
503
+ // GC: rows this remote footprint SOLELY referenced fall to refcount 0 → net removes. A qid
504
+ // lives on ONE channel, so at most one gate's dropQuery is non-empty (dropQuery of an
505
+ // unknown qid returns []) but sweep every gate so this needs no ownership lookup.
506
+ const gcs = [];
507
+ if (remoteQid !== undefined) {
508
+ this.pendingRetargetGc.delete(remoteQid); // the sweep below covers a mid-retarget teardown
509
+ for (const gate of this.gates.values()) {
510
+ gate.buffer = gate.buffer.filter((f) => f.qid !== remoteQid);
511
+ const gc = mapGateDeltas(gate, gate.sync.dropQuery(remoteQid));
512
+ if (gc.length)
513
+ gcs.push([gate.key, gc]);
514
+ }
515
+ }
288
516
  // Tear down the local pipeline+view first so the reconcile cycle below skips it.
289
517
  this.local.unregisterQuery(qid);
290
518
  // The GC removals must leave BOTH head AND the engine's `sync` baseline. A plain
@@ -292,9 +520,10 @@ export class OptimisticBackend {
292
520
  // optimistic REMOVE: the next release's rewind diffs head against sync+D and RESURRECTS
293
521
  // them, GC never frees anything, and a later query is served the stale/deleted row
294
522
  // forever (CRIT#2). Deliver them as a coherent SERVER delta instead — the same
295
- // sync-moving boundary `onProgress` uses — so head and sync both drop the rows.
296
- if (gc.length)
297
- this.runReconcileCycle(gc);
523
+ // sync-moving boundary the release gate uses — so head and sync both drop the rows,
524
+ // against the SOURCE whose baseline held them.
525
+ for (const [key, gc] of gcs)
526
+ this.runReconcileCycle(key, gc);
298
527
  // The local pipeline is gone (no live conn) and the remote footprint's `__agg` rows were
299
528
  // GC'd above, so any synthetic table this was the last reader of can now be freed (§4).
300
529
  this.releaseSyntheticTables(qid);
@@ -305,10 +534,15 @@ export class OptimisticBackend {
305
534
  this.pendingState.delete(qid); // §7.2 cache, keyed by the local materialized qid (a monotonic
306
535
  // Store counter — re-materialize gets a fresh id, never this one again), so drop it on teardown.
307
536
  }
308
- retainRemoteQuery(qid, remote, localQueryId, ast) {
537
+ /** `channel` as in {@link registerQuery} (G-iii): the gate the remote sub registers on; default
538
+ * `"daemon"`. This is the split-retain seam G-v's resolve-then-register drives — resolve the
539
+ * lease, learn `realtime.sourceKey`, `connectSource` it, then retain the query on that channel.
540
+ * Validated FIRST so a bad channel throws before any synthetic-table refcount moves. */
541
+ retainRemoteQuery(qid, remote, localQueryId, ast, channel) {
542
+ this.requireGate(channel ?? "daemon");
309
543
  if (ast)
310
544
  this.ensureSyntheticTables(qid, ast);
311
- this.retainRemote(qid, remote, localQueryId);
545
+ this.retainRemote(qid, remote, localQueryId, channel);
312
546
  }
313
547
  releaseRemoteQuery(qid) {
314
548
  const remoteQid = this.releaseRemote(qid);
@@ -319,10 +553,309 @@ export class OptimisticBackend {
319
553
  }
320
554
  if (remoteQid === undefined)
321
555
  return;
322
- this.buffer = this.buffer.filter((f) => f.qid !== remoteQid);
323
- const gc = this.sync.dropQuery(remoteQid);
324
- if (gc.length)
325
- this.runReconcileCycle(gc);
556
+ // A mid-retarget release: the every-gate sweep below IS the deferred old-channel GC
557
+ // (dropQuery hits the old gate's sync too), so retire the pending record — and its
558
+ // wrong-channel grace — with it.
559
+ this.pendingRetargetGc.delete(remoteQid);
560
+ // Per-gate sweep, like `unregisterQuery`: at most one gate owned this qid's frames/rows.
561
+ for (const gate of this.gates.values()) {
562
+ gate.buffer = gate.buffer.filter((f) => f.qid !== remoteQid);
563
+ const gc = mapGateDeltas(gate, gate.sync.dropQuery(remoteQid));
564
+ if (gc.length)
565
+ this.runReconcileCycle(gate.key, gc);
566
+ }
567
+ }
568
+ /** The Slice I-iv upgrade retarget (§4.1 "Retarget" / the doorbell reaction): move a LIVE
569
+ * (name, args) sub — every retain of it and every local view it feeds, wholesale — from the
570
+ * channel it lives on onto `sourceKey`'s (already-`connectSource`d, already-promoted) room
571
+ * channel, WITHOUT the view ever dropping its rows. Returns the sub's wire `sourceQid` (the
572
+ * identity the client's renewal loop re-subscribes with).
573
+ *
574
+ * Why a dedicated primitive: the one-channel-per-(name,args) invariant ({@link retainRemote}'s
575
+ * loud throw) is correct — a sub's frames must never split across two cv timelines — so the
576
+ * upgrade cannot simply retain a second sub on the room and release the daemon one; and the
577
+ * naive release-then-retain order GCs the daemon sync's rows synchronously (net removes emit,
578
+ * the view flashes empty) a full ws round trip before the room's seq-0 snapshot refills it.
579
+ * The cutover is therefore TWO-PHASE around the room's first release:
580
+ *
581
+ * 1. NOW (here): unsubscribe the old channel's wire sub, sweep its still-buffered frames for
582
+ * this qid (their cv timeline continues without the sub — the hello-supersession
583
+ * precedent), flip `sub.channel`, re-arm `sub.hydrated` (the room's own snapshot is the
584
+ * cutover point), and register on the room source (its resolver presents the handed
585
+ * roomToken). The old gate's SYNC rows are deliberately NOT dropped: they keep the view's
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.
592
+ *
593
+ * Idempotent per target channel: a sub already on `sourceKey` returns immediately (the
594
+ * double-doorbell / re-entrancy guard — one retarget per (query, sourceKey)). Validates before
595
+ * mutating: a throw here leaves the sub fully daemon-attached (the client's fail-open). */
596
+ retargetRemoteQuery(remote, sourceKey) {
597
+ const newGate = this.requireGate(sourceKey); // throw loudly BEFORE any sub state moves
598
+ const key = remoteKey(remote);
599
+ const sub = this.remoteSubs.get(key);
600
+ if (!sub) {
601
+ throw new Error(`optimistic backend: no live sub for query "${remote.name}" — nothing to retarget`);
602
+ }
603
+ if (sub.channel === sourceKey)
604
+ return sub.sourceQid; // already there — idempotent
605
+ const oldGate = this.gates.get(sub.channel) ?? this.daemonGate;
606
+ oldGate.source.unregisterQuery(sub.sourceQid);
607
+ oldGate.buffer = oldGate.buffer.filter((f) => f.qid !== sub.sourceQid);
608
+ this.pendingRetargetGc.set(sub.sourceQid, sub.channel);
609
+ sub.channel = sourceKey;
610
+ sub.hydrated = false;
611
+ newGate.source.registerQuery(sub.sourceQid, remote);
612
+ return sub.sourceQid;
613
+ }
614
+ /** Phase 2 of {@link retargetRemoteQuery}, run at the end of every gate release: once a
615
+ * retargeted sub's first snapshot has RELEASED on its new channel (`sub.hydrated` re-armed at
616
+ * retarget, re-set by {@link markSubHydrated} inside this very release), drop the qid's rows
617
+ * from the OLD gate's sync and reconcile them out — after the room's rows are already applied,
618
+ * so the winner flip is value-equal (net-zero; see the phase table above). A sub torn down
619
+ * mid-window was already swept by `releaseRemoteQuery`/`unregisterQuery` (which delete the
620
+ * record); a vanished record here is pruned defensively. */
621
+ flushRetargetGc(gate) {
622
+ if (this.pendingRetargetGc.size === 0)
623
+ return; // every non-upgrade release: structural no-op
624
+ for (const [sourceQid, oldGateKey] of this.pendingRetargetGc) {
625
+ const key = this.sourceToRemote.get(sourceQid);
626
+ const sub = key !== undefined ? this.remoteSubs.get(key) : undefined;
627
+ if (!sub) {
628
+ this.pendingRetargetGc.delete(sourceQid);
629
+ continue;
630
+ }
631
+ if (sub.channel !== gate.key || !sub.hydrated)
632
+ continue; // not this gate / not yet cut over
633
+ this.pendingRetargetGc.delete(sourceQid);
634
+ const oldGate = this.gates.get(oldGateKey);
635
+ if (!oldGate)
636
+ continue;
637
+ const gc = mapGateDeltas(oldGate, oldGate.sync.dropQuery(sourceQid));
638
+ if (gc.length)
639
+ this.runReconcileCycle(oldGateKey, gc);
640
+ }
641
+ }
642
+ // --- the §4.2 downgrade: demote → ghost → fence → drop (Slice I-v) ----------------------
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:
649
+ *
650
+ * 1. **Disconnect** the channel ({@link disconnectSource}): handlers detached, gate + buffer
651
+ * dropped. `nextMid`/`watermark`/processed-outcomes for the domain are KEPT FOREVER (§7.1:
652
+ * an assigned mid pins its domain; a later re-upgrade of the same doc continues the
653
+ * sequence — {@link connectSource} attaches a fresh gate and the lmid snapshot max-folds
654
+ * into the surviving watermark). Disconnecting BEFORE the daemon sub's first release is
655
+ * load-bearing: it makes {@link flushRetargetGc}'s deferred old-channel GC a no-op (gate
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
661
+ * immediately — `finalFlushSeq === 0` (a never-flushed room) with no room-domain pending
662
+ * drops on the spot, the single-daemon first-frame case.
663
+ *
664
+ * In-flight discipline (§7.5): entries with `mid !== null` on `sourceKey` stay PINNED (rule
665
+ * 2 — never re-route a sent mutation); their resolution arrives via the daemon-carried
666
+ * ledger+outcome folds (I-iii) and blocks the drop until then. Idempotent per sourceKey (a
667
+ * second labeled query sharing the room demotes into the existing ghost). */
668
+ demoteRoomSource(sourceKey, doc, finalFlushSeq) {
669
+ if (sourceKey === "daemon") {
670
+ throw new Error("optimistic backend: the daemon source cannot be demoted");
671
+ }
672
+ // Validate FIRST (nothing mutated yet): a live sub still on the channel would silently
673
+ // starve once the gate detaches — the caller must retarget every sub off the room first.
674
+ for (const sub of this.remoteSubs.values()) {
675
+ if (sub.channel === sourceKey) {
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
+ }
678
+ }
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)
695
+ this.evaluateGhosts();
696
+ }
697
+ /** Detach one connected room channel (Slice I-v step 3): the source's handlers are replaced
698
+ * with no-ops (the {@link OptimisticSource} handler seam is single-registration, so this IS
699
+ * the detach — a late frame from a dying socket can no longer touch any bookkeeping), its
700
+ * reserved lmid sub is unregistered, and the gate — buffer, per-source sync, cv watermark —
701
+ * is dropped from {@link gates}. The DOMAIN state deliberately survives forever:
702
+ * `nextMid[sourceKey]`, `watermark[sourceKey]`, and the processed-outcome set are untouched
703
+ * (§7.1 — an assigned mid pins its domain; a re-upgrade must continue, never restart, the mid
704
+ * sequence; {@link connectSource} then attaches a fresh gate whose lmid snapshot max-folds
705
+ * into the surviving watermark via {@link foldConfirm}). Closing the underlying transport is
706
+ * the caller's job. Idempotent (a missing gate is a no-op). */
707
+ disconnectSource(sourceKey) {
708
+ if (sourceKey === "daemon") {
709
+ throw new Error("optimistic backend: the daemon source cannot be disconnected");
710
+ }
711
+ const gate = this.gates.get(sourceKey);
712
+ if (!gate)
713
+ return;
714
+ this.gates.delete(sourceKey);
715
+ gate.source.onNormalized(() => { });
716
+ gate.source.onProgress(() => { });
717
+ gate.source.onRestart?.(() => { });
718
+ gate.source.onMutationOutcome?.(() => { });
719
+ gate.source.onResync?.(() => { });
720
+ gate.source.unregisterQuery(LMID_QID);
721
+ }
722
+ /** Register the I-v stuck-downgrade sink — see {@link DowngradeStuckEvent}. One handler (a
723
+ * later registration replaces it, the {@link onScopeSessions} convention); client.ts maps it
724
+ * onto the loud anomaly surface. */
725
+ onDowngradeStuck(handler) {
726
+ this.downgradeStuckHandler = handler;
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
+ }
734
+ /** The I-v ghost-drop watcher (§4.2), run after every applied release ({@link applyRelease} —
735
+ * the seam where {@link roomWatermarks} has just folded and the confirm-drop has just run) and
736
+ * once at demote time. For each ghost: the fence must be satisfied
737
+ * (`roomWatermarks[doc] ≥ finalFlushSeq`; 0 is trivially satisfied) AND no SENT room-domain
738
+ * pending may remain (§7.5 — such entries resolve only through the daemon-carried
739
+ * outcome/ledger folds; an entry that never reached the room is undecidable, so the ghost
740
+ * HOLDS and the stuck event fires exactly once, naming the mids). Both satisfied ⇒
741
+ * {@link dropGhost}. */
742
+ evaluateGhosts() {
743
+ if (this.ghosts.size === 0)
744
+ return; // every non-downgrade release: structural no-op
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;
751
+ if ((this.roomWatermarks.get(ghost.doc) ?? 0) < ghost.finalFlushSeq)
752
+ continue; // fence holds
753
+ const stuck = this.pendingMutations.filter((p) => p.domain === sourceKey && p.mid !== null);
754
+ if (stuck.length > 0) {
755
+ if (!ghost.stuckReported) {
756
+ ghost.stuckReported = true;
757
+ this.downgradeStuckHandler({ sourceKey, doc: ghost.doc, mids: stuck.map((p) => p.mid) });
758
+ }
759
+ continue; // hold — never a timeout-retire (§7.5 rule 2)
760
+ }
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) {
774
+ this.ghosts.delete(sourceKey);
775
+ this.inOneCommit(() => {
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));
785
+ }
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)
790
+ this.runReconcileCycle("daemon", []);
791
+ });
792
+ this.refreshPending(); // the reconcile may have dropped a throwing re-invocation
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
+ }
805
+ // --- the §4 lifecycle SYSTEM-STREAM retains (Slice I-iii) ------------------------------
806
+ /** Retain one minted SYSTEM subscription (RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md §4, Slice
807
+ * I-iii): a wire sub with NO store view and NO user-visible table. Registered through the same
808
+ * {@link RemoteSub} bookkeeping as any remote retain — so qid→channel ownership, the overflow
809
+ * re-subscribe, and refcounted release all work unchanged — but with an EMPTY `localQids` set
810
+ * (no hydration/resultType coupling) and a {@link systemQids} record telling the release path
811
+ * which system table this qid's frames carry (`spec.table`) and which scope/doc it was minted
812
+ * for (the fold's row filter). Its frames then buffer on the channel's gate exactly like
813
+ * {@link LMID_QID}'s and fold at RELEASE time in {@link foldSystemFrames} — riding the SAME
814
+ * buffered cv path as the data they co-committed with (fence coherence: an out-of-band
815
+ * shortcut would break I-ii's co-commit ordering guarantee).
816
+ *
817
+ * `channel` defaults to `"daemon"` — the system tables live in the DAEMON store (that is the
818
+ * point: outcome/ledger/watermark rows must be readable with no room socket alive, §7.1
819
+ * "load-bearing for §7.5"). Idempotence per (table, scope/doc) is the CALLER's job (client.ts
820
+ * keys its retains on exactly that); a duplicate retain of the SAME remote identity refcounts
821
+ * like any sub. */
822
+ retainSystemQuery(retainQid, remote, spec, channel = "daemon") {
823
+ const gate = this.requireGate(channel); // throw loudly BEFORE any sub state moves
824
+ const key = remoteKey(remote);
825
+ let sub = this.remoteSubs.get(key);
826
+ if (sub) {
827
+ if (sub.channel !== channel) {
828
+ throw new Error(`optimistic backend: system query "${remote.name}" is already retained on channel ${JSON.stringify(sub.channel)} — cannot retain it on ${JSON.stringify(channel)}`);
829
+ }
830
+ sub.refCount++;
831
+ this.localToRemote.set(retainQid, key);
832
+ this.remoteRetainToLocal.set(retainQid, undefined);
833
+ return;
834
+ }
835
+ // A fresh sub: deliberately NOT `retainRemote` — its `localQueryId` default would couple this
836
+ // retain's qid to the view-hydration machinery (`hydrated`/`resultType`), and a system stream
837
+ // has no view to hydrate.
838
+ sub = { sourceQid: retainQid, remote, refCount: 1, localQids: new Map(), hydrated: false, channel };
839
+ this.remoteSubs.set(key, sub);
840
+ this.sourceToRemote.set(retainQid, key);
841
+ this.localToRemote.set(retainQid, key);
842
+ this.remoteRetainToLocal.set(retainQid, undefined);
843
+ this.systemQids.set(retainQid, { ...spec });
844
+ gate.source.registerQuery(retainQid, remote);
845
+ }
846
+ /** Release a {@link retainSystemQuery} retain. Refcounted like any sub; the LAST release
847
+ * unregisters from the owning channel, sweeps its buffered frames, and drops the
848
+ * {@link systemQids} record. The folded lifecycle STATE (`roomWatermarks`/`scopeSessions`/
849
+ * processed outcomes) deliberately survives — the fence is monotone truth about the store, not
850
+ * about the subscription (a re-retained fence must not forget a cleared watermark). */
851
+ releaseSystemQuery(retainQid) {
852
+ const remoteQid = this.releaseRemote(retainQid);
853
+ if (remoteQid === undefined)
854
+ return; // still refcounted (or unknown)
855
+ for (const gate of this.gates.values()) {
856
+ gate.buffer = gate.buffer.filter((f) => f.qid !== remoteQid);
857
+ }
858
+ this.systemQids.delete(remoteQid);
326
859
  }
327
860
  /** Raw CRUD has no optimistic story (§9 replaces it with named mutators). Register a
328
861
  * mutator — even a trivial one — and `invoke` it. */
@@ -402,32 +935,119 @@ export class OptimisticBackend {
402
935
  mutator(tx, args);
403
936
  }
404
937
  }
938
+ /** Deal the next wire mid from `domain`'s ledger (RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md
939
+ * §7.1) and advance that counter. A domain absent from the map starts at 1. Per-domain, so a
940
+ * client writing through room + daemon concurrently keeps two gapless, non-aliasing sequences.
941
+ * The client-global `seq` is stamped in the same breath — the ONE cross-domain total order
942
+ * (confirmation order is per-domain; replay order is client-global). Bundled here so no call
943
+ * site can deal a mid without its seq. ONE caller discards the seq deliberately: the H-v deopt
944
+ * flip ({@link handleMutationOutcome}) keeps the entry's ORIGINAL seq — its replay position —
945
+ * and takes only the fresh mid (the dealSeq bump is harmless: seq consumers order, never
946
+ * count). */
947
+ dealMid(domain) {
948
+ const mid = this.nextMid.get(domain) ?? 1;
949
+ this.nextMid.set(domain, mid + 1);
950
+ return { mid, seq: ++this.dealSeq };
951
+ }
952
+ // --- the DECLARED router (302 §5: declared, not derived) --------------------------------
953
+ //
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).`);
1004
+ }
405
1005
  /** Run the named client mutator optimistically: the prediction applies to the live
406
1006
  * engine now (affected views update synchronously), `(mid, name, args)` joins the
407
1007
  * pending stack, and the envelope ships upstream. Returns the assigned `mid`. */
408
1008
  invoke(name, args) {
1009
+ return this.invokeWith(name, args);
1010
+ }
1011
+ /** {@link invoke} with an optional PINNED confirming domain (H-v): the deopt handshake's
1012
+ * already-retired arm re-invokes the frame's echoed `(name, args)` as a FRESH invocation pinned
1013
+ * to `"daemon"` — an honest re-prediction on the current base, never derived (`pin` bypasses
1014
+ * {@link resolveDomain} entirely, so the router never runs and no Q6 counter moves). Every
1015
+ * other step is `invoke` verbatim: prediction now, capture, drainOverlapping, mid dealt from
1016
+ * the pinned domain's ledger, envelope on its channel. */
1017
+ invokeWith(name, args, pin) {
409
1018
  const mutator = this.registry[name];
410
1019
  if (!mutator)
411
1020
  throw new Error(`unknown client mutator: ${name}`);
412
1021
  // One commit boundary spans the prediction AND the `__agg`-head reconcile below, so their views
413
1022
  // (data + count) flush together rather than tearing across two engine commits.
414
1023
  return this.inOneCommit(() => {
415
- // 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),
416
1030
  // the staged write is discarded (the wasm txn is a clean no-op until commit) and the throw
417
1031
  // propagates with NO mid consumed — a burnt mid is a permanent server-side gap that
418
1032
  // silently refuses every later mutation from this client (#10).
419
- const touched = new Set();
1033
+ const writes = new Map();
1034
+ const reads = { reads: [], queries: [] };
420
1035
  const ops = [];
421
1036
  this.local.writeWith((tx) => {
422
- this.runMutator(mutator, trackingTx(tx, touched, this.specs, this.localTables, this.opCollector(ops)), args);
1037
+ this.runMutator(mutator, trackingTx(tx, writes, this.specs, this.localTables, this.opCollector(ops), false, reads, this.stagingMap(domain)), args);
423
1038
  });
1039
+ // `touched` is DERIVED, never separately populated (§3.2 #1) — see {@link WriteSet}.
1040
+ const touched = new Set(writes.keys());
1041
+ this.warnCrossAuthorityWrites(name, domain, touched);
424
1042
  // Flush-on-enqueue (§4.2): a fold whose tables overlap this write must take its mid NOW, BEFORE
425
1043
  // this write does, so wire order == local-apply order for any pair that can observe each other
426
1044
  // (a read-dependent write reading a folded cell sees the same value optimistically and on the
427
1045
  // wire — no snap). Drained folds ship with smaller mids; this write's mid is dealt after.
428
1046
  this.drainOverlapping(touched);
429
- const mid = this.nextMid++;
430
- this.pendingMutations.push({ mid, name, args, touched });
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.
1049
+ const { mid, seq } = this.dealMid(domain);
1050
+ this.pendingMutations.push({ mid, seq, name, args, domain, touched, writes, reads });
431
1051
  // The prediction stuck — fold its child ops into the optimistic agg delta and push it onto
432
1052
  // the `__agg` head rows (§4). No reset here (this is the §1.3 trivial case, no rewind): the
433
1053
  // delta accumulates on top of the prior pending set, and `reconcileAggHead` recomputes each
@@ -436,7 +1056,7 @@ export class OptimisticBackend {
436
1056
  this.overlay.observe(op);
437
1057
  this.reconcileAggHead();
438
1058
  this.refreshPending(); // §7.2: this write now touches its queries' pending axis (NOT ResultType).
439
- void this.source.pushMutation({ clientID: this.clientID, mid, name, args });
1059
+ void this.channelFor(domain).pushMutation({ clientID: this.clientID, mid, name, args });
440
1060
  return mid;
441
1061
  });
442
1062
  }
@@ -451,15 +1071,19 @@ export class OptimisticBackend {
451
1071
  const foldKey = `${name}\0${stableJson(opts.key)}`;
452
1072
  // One commit boundary spans the prediction AND the `__agg`-head reconcile (see {@link inOneCommit}),
453
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);
454
1077
  return this.inOneCommit(() => {
455
1078
  // Apply the prediction with the read trap armed (§5): a folded mutator that reads state to
456
1079
  // compute its write is non-absorbing and refused. A throw discards the staged write (clean
457
- // no-op) and consumes no mid — exactly `invoke`'s guarantee.
458
- const touched = new Set();
1080
+ // no-op) and consumes no mid — exactly `invoke`'s guarantee. NO `readLog` here — the trap
1081
+ // path stays byte-for-byte as it was; recording (§3.2 #2) never arms alongside the trap.
1082
+ const writes = new Map();
459
1083
  const ops = [];
460
1084
  try {
461
1085
  this.local.writeWith((tx) => {
462
- this.runMutator(mutator, trackingTx(tx, touched, this.specs, this.localTables, this.opCollector(ops), true), args);
1086
+ this.runMutator(mutator, trackingTx(tx, writes, this.specs, this.localTables, this.opCollector(ops), true, undefined, this.stagingMap(domain)), args);
463
1087
  });
464
1088
  }
465
1089
  catch (e) {
@@ -471,19 +1095,33 @@ export class OptimisticBackend {
471
1095
  for (const op of ops)
472
1096
  this.overlay.observe(op);
473
1097
  this.reconcileAggHead();
1098
+ // `touched` is DERIVED, never separately populated (§3.2 #1) — see {@link WriteSet}.
1099
+ const touched = new Set(writes.keys());
1100
+ this.warnCrossAuthorityWrites(name, domain, touched);
474
1101
  const now = this.clock.now();
475
1102
  let f = this.folds.get(foldKey);
476
1103
  if (f) {
477
1104
  // Overwrite the single entry in place — the pending stack does NOT grow (§1 #2). The head
478
1105
  // already carries this new prediction (absorbing, last-wins on the cell); the entry holds
479
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).
480
1110
  f.entry.args = args;
481
1111
  f.entry.touched = touched;
1112
+ f.entry.writes = writes;
1113
+ f.entry.domain = domain;
482
1114
  f.args = args;
483
1115
  this.clock.clearTimeout(f.timer);
484
1116
  }
485
1117
  else {
486
- const entry = { mid: null, name, args, touched };
1118
+ // §9.3: pick the window's cadence. Routing into a room ⇒ flush at roomDebounceMs so
1119
+ // intermediates stream to the shared head; off the room, the caller's collapse debounce
1120
+ // governs.
1121
+ const inRoom = opts.roomDebounceMs !== undefined && domain !== "daemon";
1122
+ const debounceMs = inRoom ? opts.roomDebounceMs : opts.debounceMs ?? DEFAULT_FOLD_DEBOUNCE_MS;
1123
+ const maxWaitMs = inRoom ? opts.roomDebounceMs : opts.maxWaitMs;
1124
+ const entry = { mid: null, seq: null, name, args, domain, touched, writes, reads: { reads: [], queries: [] } };
487
1125
  this.pendingMutations.push(entry);
488
1126
  let resolveMid;
489
1127
  const midPromise = new Promise((res) => (resolveMid = res));
@@ -492,8 +1130,8 @@ export class OptimisticBackend {
492
1130
  args,
493
1131
  timer: undefined,
494
1132
  firstAt: now,
495
- debounceMs: opts.debounceMs ?? DEFAULT_FOLD_DEBOUNCE_MS,
496
- maxWaitMs: opts.maxWaitMs,
1133
+ debounceMs,
1134
+ maxWaitMs,
497
1135
  deferAcrossWrites: opts.deferAcrossWrites ?? false,
498
1136
  midPromise,
499
1137
  resolveMid,
@@ -535,11 +1173,163 @@ export class OptimisticBackend {
535
1173
  return;
536
1174
  this.clock.clearTimeout(f.timer);
537
1175
  this.folds.delete(foldKey);
538
- const mid = this.nextMid++;
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);
1181
+ f.entry.domain = domain;
1182
+ const { mid, seq } = this.dealMid(domain);
539
1183
  f.entry.mid = mid;
540
- void this.source.pushMutation({ clientID: this.clientID, mid, name: f.entry.name, args: f.args });
1184
+ f.entry.seq = seq;
1185
+ void this.channelFor(domain).pushMutation({ clientID: this.clientID, mid, name: f.entry.name, args: f.args });
541
1186
  f.resolveMid(mid);
542
1187
  }
1188
+ /** The transport a `domain`-confirmed mutation ships on (§7.5 sent-pins-domain: only the
1189
+ * domain's own authority can confirm it, so its channel is the only correct transport). A
1190
+ * domain with NO connected gate ships on the daemon channel — the gate-less configurations
1191
+ * (`__testRelease`-driven tests) and today's entire live path resolve `"daemon"` anyway. */
1192
+ channelFor(domain) {
1193
+ return (this.gates.get(domain) ?? this.daemonGate).source;
1194
+ }
1195
+ /** The gate a channel-keyed retain registers through (G-iii registration-time routing). The
1196
+ * channel MUST already be connected (`connectSource`; the daemon is constructor-attached) —
1197
+ * loud by design: a typo'd or not-yet-connected sourceKey must throw at retain time, never
1198
+ * silently register on the daemon and split the query's frames across channels. */
1199
+ requireGate(channel) {
1200
+ const gate = this.gates.get(channel);
1201
+ if (!gate) {
1202
+ throw new Error(`optimistic backend: no source connected for channel ${JSON.stringify(channel)} — call connectSource(${JSON.stringify(channel)}, source) before retaining a query on it`);
1203
+ }
1204
+ return gate;
1205
+ }
1206
+ /** The channel that owns `sourceQid` — {@link RemoteSub.channel}, the ONE source of truth for
1207
+ * qid routing (G-iii). `undefined` when no sub owns the qid (a harness-delivered raw feed, or
1208
+ * a just-released sub): such frames buffer on whatever gate they arrive at. */
1209
+ channelOf(sourceQid) {
1210
+ const key = this.sourceToRemote.get(sourceQid);
1211
+ return key ? this.remoteSubs.get(key)?.channel : undefined;
1212
+ }
1213
+ // --- the §3.3 deopt handshake, client half (H-v) ---------------------------------
1214
+ //
1215
+ // THE NAMED INVARIANT (Slice I inherits it): **never retire a room-domain entry off a
1216
+ // daemon-carried lmid without outcome resolution.** On the room socket it holds by
1217
+ // construction: every room lmid folds through the room's OWN gate, whose socket also carries
1218
+ // the outcome frames — same-socket ordering puts the frame before the ack, and the reconnect
1219
+ // re-send re-earns a lost frame, so a room-domain entry is only ever retired as a success when
1220
+ // the room really applied it. Slice I's downgrade path breaks that coupling: the doc-scoped
1221
+ // ledger row becomes readable THROUGH THE DAEMON with no room socket alive (§7.1 "load-bearing
1222
+ // for §7.5"), and an lmid adopted that way covers burnt non-applied mids with no frame to say
1223
+ // so — retiring a deopted entry there as a silent success is exactly the lost-write this
1224
+ // handshake exists to prevent. ENFORCED since I-iii by {@link foldSystemFrames}: the I-ii
1225
+ // outcome ROWS (co-committed, in ONE daemon transaction, with the ledger row that covers them)
1226
+ // are synthesized into frames and routed through THIS machine BEFORE the ledger fold advances
1227
+ // the domain watermark — one verdict path for frames and rows, with the processed set as the
1228
+ // cross-release resolved-verdict memory, and absence-under-a-covering-lmid = applied (I-ii's
1229
+ // atomicity makes that the sound default).
1230
+ /** Record `(domain, mid)` as processed; `false` if it already was (a duplicate frame —
1231
+ * ignore it). FIFO-capped per domain ({@link MAX_PROCESSED_OUTCOMES_PER_DOMAIN}). */
1232
+ markOutcomeProcessed(domain, mid) {
1233
+ let mids = this.outcomesProcessed.get(domain);
1234
+ if (!mids)
1235
+ this.outcomesProcessed.set(domain, (mids = new Set()));
1236
+ if (mids.has(mid))
1237
+ return false;
1238
+ mids.add(mid);
1239
+ while (mids.size > MAX_PROCESSED_OUTCOMES_PER_DOMAIN) {
1240
+ mids.delete(mids.values().next().value);
1241
+ }
1242
+ return true;
1243
+ }
1244
+ /** One `mutationOutcome` frame from `domain`'s channel (H-v — the §3.3 handshake's client
1245
+ * half). The frame arrives OUT-OF-BAND (see {@link attachGate}); the state machine:
1246
+ *
1247
+ * 1. `mid` never issued on `domain` ⇒ ignore (a confused/foreign frame must not invent work).
1248
+ * 2. `(domain, mid)` already processed ⇒ ignore — idempotence under duplicate frames (the
1249
+ * original + a re-send's re-answer; a deopt for a mid whose entry ALREADY FLIPPED also
1250
+ * lands here harmlessly on its second frame).
1251
+ * 3. `kind:"rejected"` ⇒ FINAL. Surface the reason through {@link rejectedHandler} (room-plane
1252
+ * parity with the HTTP queue's callback) and STOP — the drop + snap-back is the EXISTING
1253
+ * failed-mutation machinery: the room burnt the mid, its lmid release retires the entry
1254
+ * per-domain and the reconcile rewinds the prediction, exactly the daemon path's
1255
+ * processed-as-no-op rejection. No new drop path.
1256
+ * 4. `kind:"deopt"`, entry found (pending `(domain, mid)`) ⇒ FLIP IN PLACE: `domain` becomes
1257
+ * `"daemon"`, a fresh daemon mid is dealt and the envelope ships NOW on the daemon channel
1258
+ * ("deal-and-send-now" — the conforming §3.3 re-enqueue: there is no flush machinery for
1259
+ * non-fold entries, so the design's "mid: null until the daemon flush" is satisfied
1260
+ * momentarily inside this call). THE ENTRY'S `seq` IS KEPT — settled (§5.3, commit
1261
+ * 68141096): `seq` is the client-global REPLAY order; re-sequencing would move the entry's
1262
+ * overlay position and change read-dependent SIBLINGS' replay base. Everything else stays
1263
+ * (writes/reads/touched/touchedSources/writeSources — union-never-shrink), the prediction
1264
+ * stays applied (the entry never leaves `pendingMutations`, so no rewind fires), and the
1265
+ * router does NOT re-run nor does `drainOverlapping` (§3.3 re-enqueues, never re-derives;
1266
+ * any open overlapping fold was invoked later and flushes later with a larger mid).
1267
+ * 5. `kind:"deopt"`, entry NOT found ⇒ the burnt-mid confirm won the race, or the frame is a
1268
+ * replay re-answer for an entry a previous session retired (the replay gotcha): re-invoke
1269
+ * the frame's echoed `name`/`args` as a FRESH invocation PINNED to `"daemon"` — an honest
1270
+ * re-prediction on the current base, never a derived route ({@link invokeWith}). A frame
1271
+ * without `name` (not self-contained) has nothing to re-invoke and is dropped; a re-invoke
1272
+ * that THROWS (the base moved from under it) is surfaced through {@link rejectedHandler} —
1273
+ * the mutation is dead with no stream left to confirm it.
1274
+ *
1275
+ * A `"deopt"` bump joins the Q6 routing counters either way (`routing.reasons.deopt`) —
1276
+ * derived-and-deopted routes are visible beside derived successes. */
1277
+ handleMutationOutcome(domain, frame) {
1278
+ if (frame.mid >= (this.nextMid.get(domain) ?? 1))
1279
+ return; // never issued here — not ours
1280
+ if (!this.markOutcomeProcessed(domain, frame.mid))
1281
+ return; // duplicate frame
1282
+ if (frame.kind === "rejected") {
1283
+ const entry = this.pendingMutations.find((p) => p.domain === domain && p.mid === frame.mid);
1284
+ this.rejectedHandler({
1285
+ clientID: this.clientID,
1286
+ mid: frame.mid,
1287
+ name: entry?.name ?? frame.name ?? "",
1288
+ args: entry !== undefined ? entry.args : frame.args,
1289
+ }, frame.reason ?? "mutation rejected");
1290
+ return;
1291
+ }
1292
+ // kind === "deopt": the room gate refused a declared-room mutation — re-enqueue onto the daemon.
1293
+ const entry = this.pendingMutations.find((p) => p.domain === domain && p.mid === frame.mid);
1294
+ if (entry) {
1295
+ entry.domain = "daemon";
1296
+ // Deal the fresh daemon mid but DISCARD its seq — the entry keeps its own (state-machine
1297
+ // step 4 above; the harmless dealSeq bump is accepted). This is the ONE place a dealt seq
1298
+ // is dropped, so "within one domain seq order == mid order" weakens to "except deopt
1299
+ // re-enqueues" — see the {@link PendingMutation.seq} doc.
1300
+ const { mid } = this.dealMid("daemon");
1301
+ entry.mid = mid;
1302
+ void this.channelFor("daemon").pushMutation({ clientID: this.clientID, mid, name: entry.name, args: entry.args });
1303
+ return;
1304
+ }
1305
+ if (frame.name === undefined)
1306
+ return; // not self-contained — nothing to re-invoke
1307
+ try {
1308
+ this.invokeWith(frame.name, frame.args, "daemon");
1309
+ }
1310
+ catch (err) {
1311
+ this.rejectedHandler({ clientID: this.clientID, mid: frame.mid, name: frame.name, args: frame.args }, `deopt re-invocation failed: ${String(err?.message ?? err)}`);
1312
+ }
1313
+ }
1314
+ /** §7.5 rule 3 (H-v): re-send `domain`'s unconfirmed pending envelopes with their ORIGINAL
1315
+ * mids, in mid order, on the domain's own channel. Folds with `mid === null` are excluded —
1316
+ * nothing was ever sent for them (the flush deals their mid). Envelopes are reconstructed from
1317
+ * the pending entries exactly as `invoke` shipped them (`clientID`/`mid`/`name`/`args` —
1318
+ * entries carry everything the wire needs). Idempotent under the domain's ledger: an APPLIED
1319
+ * mid dedups silently and its lmid coverage retires the entry; a NON-APPLIED mid is re-answered
1320
+ * from the shell's recorded-outcome map into {@link handleMutationOutcome}. Confirmed entries
1321
+ * are already gone from `pendingMutations`, so no filter against the watermark is needed. */
1322
+ resendPending(domain) {
1323
+ const unconfirmed = this.pendingMutations
1324
+ .filter((p) => p.domain === domain && p.mid !== null)
1325
+ .sort((a, b) => a.mid - b.mid);
1326
+ if (unconfirmed.length === 0)
1327
+ return;
1328
+ const channel = this.channelFor(domain);
1329
+ for (const p of unconfirmed) {
1330
+ void channel.pushMutation({ clientID: this.clientID, mid: p.mid, name: p.name, args: p.args });
1331
+ }
1332
+ }
543
1333
  /** Drain every outstanding fold immediately (FOLDED-MUTATIONS-DESIGN §3): the explicit
544
1334
  * `app.flushFolds()` and the `beforeunload`/`close` hook. Creation (insertion) order. */
545
1335
  flushFolds() {
@@ -638,7 +1428,19 @@ export class OptimisticBackend {
638
1428
  const pending = this.pendingMutations.map((p) => {
639
1429
  const folded = foldByEntry.get(p);
640
1430
  const key = p.mid != null ? `m:${p.mid}` : folded ? `f:${folded[0]}` : `?:${p.name}`;
641
- const out = { key, mid: p.mid, name: p.name, args: p.args, tables: [...p.touched] };
1431
+ const writes = [];
1432
+ for (const byPk of p.writes.values())
1433
+ for (const rec of byPk.values())
1434
+ writes.push(rec);
1435
+ const out = {
1436
+ key,
1437
+ mid: p.mid,
1438
+ name: p.name,
1439
+ args: p.args,
1440
+ tables: [...p.touched],
1441
+ writes,
1442
+ reads: { reads: [...p.reads.reads], queries: [...p.reads.queries] },
1443
+ };
642
1444
  if (folded) {
643
1445
  const f = folded[1];
644
1446
  out.fold = {
@@ -653,13 +1455,41 @@ export class OptimisticBackend {
653
1455
  });
654
1456
  return {
655
1457
  pending,
656
- confirmedLmid: this.confirmedLmid,
657
- nextMid: this.nextMid,
658
- appliedCv: this.appliedCv,
659
- bufferedFrames: this.buffer.length,
1458
+ // Back-compat scalars for the devtools `OptimisticInspect` mirror (unchanged shape): the DAEMON
1459
+ // domain's ledger — the only one in single-domain. Per-domain state is `__inspectDomains()`.
1460
+ confirmedLmid: this.watermark.get("daemon") ?? 0,
1461
+ nextMid: this.nextMid.get("daemon") ?? 1,
1462
+ appliedCv: this.daemonGate.appliedCv,
1463
+ bufferedFrames: this.daemonGate.buffer.length,
660
1464
  pendingTables: [...this.pendingTables()],
661
1465
  };
662
1466
  }
1467
+ /** Test-only per-domain ledger snapshot (RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md §7.1/§8.5).
1468
+ * Kept separate from {@link __inspect} so the devtools `OptimisticInspect` mirror stays byte-for-
1469
+ * byte identical (daemon-scalar-only). Exposes the per-domain `nextMid`/`watermark` maps plus each
1470
+ * pending entry's confirming domain — the axes the §8.5 ledger-isolation assertion checks. */
1471
+ __inspectDomains() {
1472
+ return {
1473
+ nextMid: Object.fromEntries(this.nextMid),
1474
+ watermark: Object.fromEntries(this.watermark),
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),
1478
+ lifecycle: {
1479
+ roomWatermarks: Object.fromEntries(this.roomWatermarks),
1480
+ scopeSessions: Object.fromEntries([...this.scopeSessions].map(([scope, sessions]) => [scope, Object.fromEntries(sessions)])),
1481
+ ghosts: Object.fromEntries([...this.ghosts].map(([k, g]) => [k, { doc: g.doc, finalFlushSeq: g.finalFlushSeq }])),
1482
+ },
1483
+ pending: this.pendingMutations.map((p) => ({
1484
+ mid: p.mid,
1485
+ // The client-global deal sequence — the REPLAY order (mids are per-domain, incomparable
1486
+ // across domains; see PendingMutation.seq). The harness asserts send order with this.
1487
+ seq: p.seq,
1488
+ name: p.name,
1489
+ domain: p.domain,
1490
+ })),
1491
+ };
1492
+ }
663
1493
  /** Recompute the pending axis for every query and fire `onPending` on transitions only. Called
664
1494
  * from the two points that move the pending set: invoke/invokeFolded (add) and the confirm-drop
665
1495
  * (remove) — exactly where `:359`/`:468` used to flip ResultType (§7.3). */
@@ -672,18 +1502,39 @@ export class OptimisticBackend {
672
1502
  }
673
1503
  }
674
1504
  }
675
- // --- the downstream stream (§8.5: buffer, then release coherently) ---------------
676
- onNormalized(qid, ev) {
677
- if (qid !== LMID_QID)
1505
+ // --- the downstream stream (§8.5: buffer, then release coherently — PER GATE, §5.1) ------
1506
+ onFrame(gate, qid, ev) {
1507
+ // Ownership is fixed at RETAIN time (G-iii registration-time routing: {@link RemoteSub.channel},
1508
+ // the one source of truth) — a qid lives on the ONE channel its sub registered on. So a frame
1509
+ // arriving on any OTHER gate means the server routed a qid to the wrong channel: a wiring bug —
1510
+ // fail loudly rather than silently splitting one query's frames across two cv timelines. (This
1511
+ // used to be a lazy first-arrival CLAIM; it is now a pure assertion.) A qid with NO sub (a
1512
+ // harness-delivered raw feed) has no owner and buffers on the arriving gate; the per-channel
1513
+ // reserved LMID_QID is exempt — each gate owns its own.
1514
+ if (qid !== LMID_QID) {
1515
+ const owner = this.channelOf(qid);
1516
+ if (owner !== undefined && owner !== gate.key) {
1517
+ // I-iv retarget grace: a frame already in flight from the sub's PREVIOUS channel when
1518
+ // {@link retargetRemoteQuery} moved it (the unsubscribe races the server's last frames)
1519
+ // is stale, not a wiring bug — drop it. The grace window is exactly the deferred-GC
1520
+ // window: {@link flushRetargetGc} deletes the record, and the loud throw is restored.
1521
+ if (this.pendingRetargetGc.get(qid) === gate.key)
1522
+ return;
1523
+ throw new Error(`optimistic backend: qid ${qid} arrived on ${gate.key} but is owned by ${owner}`);
1524
+ }
1525
+ }
1526
+ // System-plane frames (I-iii) are bookkeeping, not view data: like the lmid stream they skip
1527
+ // the devtools server-delta tap (there is no local view to attribute them to).
1528
+ if (qid !== LMID_QID && !this.systemQids.has(qid))
678
1529
  this.emitServerDelta(qid, ev);
679
1530
  if (ev.type === "hello") {
680
1531
  // A hello is a (re)subscribe = a NEW epoch. The column map below is mutated eagerly, but
681
- // data frames are cv-buffered and drained later (onProgress). So any frame still buffered
682
- // for this qid is from a SUPERSEDED epoch and must NOT be scattered through this epoch's
683
- // (possibly changed) map — drop it. This epoch's snapshot, which always follows the hello,
684
- // re-hydrates the qid from scratch, so the dropped frames are redundant. Scoped to this
685
- // qid: other queries' frames (and the lmid system query's) keep their coherent release.
686
- this.buffer = this.buffer.filter((f) => f.qid !== qid);
1532
+ // data frames are cv-buffered and drained later (the gate's progress). So any frame still
1533
+ // buffered for this qid is from a SUPERSEDED epoch and must NOT be scattered through this
1534
+ // epoch's (possibly changed) map — drop it. This epoch's snapshot, which always follows the
1535
+ // hello, re-hydrates the qid from scratch, so the dropped frames are redundant. Scoped to
1536
+ // this qid: other queries' frames (and the lmid system query's) keep their coherent release.
1537
+ gate.buffer = gate.buffer.filter((f) => f.qid !== qid);
687
1538
  this.addServerDependencyTables(qid, ev.tables.map((t) => t.name));
688
1539
  // Learn this query's per-table column map (PROJECTION-SUPPORT-DESIGN.md §5.2): map each
689
1540
  // advertised column to its base ColId BY NAME. The hello may carry FEWER columns than the
@@ -703,18 +1554,18 @@ export class OptimisticBackend {
703
1554
  // epoch left (a server that expanded then contracted back), so the now-exact rows don't
704
1555
  // scatter through a `-1`-bearing layout (silent cell corruption).
705
1556
  if (cols.length !== full || cols.some((c, i) => c !== i))
706
- this.sync.registerProjection(qid, t.name, cols);
1557
+ gate.sync.registerProjection(qid, t.name, cols);
707
1558
  else
708
- this.sync.unregisterProjection(qid, t.name);
1559
+ gate.sync.unregisterProjection(qid, t.name);
709
1560
  }
710
1561
  return; // envelope validation is the source's job
711
1562
  }
712
1563
  const cv = ev.cv ?? 0;
713
- if (cv <= this.appliedCv && ev.type === "batch")
714
- return; // stale redelivery
715
- this.buffer.push({ cv, qid, kind: ev.type, ops: ev.ops, seq: this.nextSeq++ });
716
- if (this.buffer.length > this.bufferCap)
717
- this.overflow();
1564
+ if (cv <= gate.appliedCv && ev.type === "batch")
1565
+ return; // stale redelivery ON THIS TIMELINE
1566
+ gate.buffer.push({ cv, qid, kind: ev.type, ops: ev.ops, seq: gate.nextSeq++ });
1567
+ if (gate.buffer.length > this.bufferCap)
1568
+ this.overflow(gate);
718
1569
  }
719
1570
  emitServerDelta(sourceQid, ev) {
720
1571
  if (!this.devObservers.size)
@@ -732,86 +1583,427 @@ export class OptimisticBackend {
732
1583
  const localQids = [...sub.localQids.keys()];
733
1584
  return localQids.length ? localQids : [sourceQid];
734
1585
  }
735
- onProgress(frame) {
1586
+ /** One gate's release (§5.1 release gate): compute the coherent delta from THIS gate's cv-buffer,
1587
+ * then apply it against the gate's source/domain. Split into {@link computeRelease} (buffer →
1588
+ * delta, lmid → watermark) and {@link applyRelease} (per-source confirm-drop + reconcile) —
1589
+ * N independent gates all feed the ONE apply half; {@link __testRelease} drives it directly. */
1590
+ onGateProgress(gate, frame) {
1591
+ const { deltas, newlyHydrated, touchedScopes } = this.computeRelease(gate, frame);
1592
+ this.applyRelease(gate.key, deltas, undefined, newlyHydrated);
1593
+ // I-iv phase 2: a retargeted sub whose first ROOM snapshot released just now gets its old
1594
+ // channel's rows GC'd — AFTER the release fully applied, so the winner flip is value-equal
1595
+ // against the freshly-folded room rows (never a remove-before-the-refill).
1596
+ this.flushRetargetGc(gate);
1597
+ // I-iv doorbell events LAST — everything this release carried (data, confirms, the occupancy
1598
+ // fold itself, the retarget cutover) is already applied when the consumer's reaction (an
1599
+ // async re-lease) is kicked off. One event per touched scope, count evaluated at the fold
1600
+ // clock's now (deterministic under an injected clock).
1601
+ if (touchedScopes !== null) {
1602
+ for (const scope of touchedScopes) {
1603
+ this.scopeSessionsHandler({ scope, others: this.otherScopeSessions(scope) });
1604
+ }
1605
+ }
1606
+ }
1607
+ /** Compute one coherent release from ONE gate's cv-buffer (§5.1) — gate-scoped: its buffer, its
1608
+ * cvMin timeline. Take every buffered frame at `cv ≤ cvMin`, in (cv, arrival) order, and fold
1609
+ * it: the lmid system-query frame advances `watermark[gate.key]` (via {@link foldLmidOps} — the
1610
+ * daemon stream folds "daemon", a room stream folds its own domain); data frames fold through
1611
+ * this SOURCE's cross-query refcount into ONE net base delta — the §1.3 `D`. Returns that delta
1612
+ * plus the set of local views this release JUST hydrated (so their reconcile batch phases as a
1613
+ * `snapshot`). Mutates the gate's buffer/`appliedCv`, hydration, and the gate's domain
1614
+ * watermark; the pending set and the reconcile are {@link applyRelease}'s job. */
1615
+ computeRelease(gate, frame) {
736
1616
  // Snapshot which local views are already hydrated BEFORE this release folds: any that cross into
737
1617
  // hydrated below get their first result set as this cycle's batch, which must phase as a snapshot.
738
1618
  const wasHydrated = new Set(this.hydrated);
739
- // (1) Take every buffered frame at cv ≤ cvMin, in (cv, arrival) order, and fold it:
740
- // lmid system-query frames advance `confirmedLmid`; data frames fold through the
741
- // cross-query refcount into ONE net base delta — the §1.3 `D`. Confirmation and
742
- // data of the same commit share a cv, so they release together by construction.
743
- const ready = this.buffer
1619
+ const ready = gate.buffer
744
1620
  .filter((f) => f.cv <= frame.cvMin)
745
1621
  .sort((a, b) => a.cv - b.cv || a.seq - b.seq);
746
- this.buffer = this.buffer.filter((f) => f.cv > frame.cvMin);
1622
+ gate.buffer = gate.buffer.filter((f) => f.cv > frame.cvMin);
1623
+ // The §4 lifecycle SYSTEM frames fold FIRST, in a FIXED structural category order (Slice
1624
+ // I-iii; see {@link foldSystemFrames} for why the order is load-bearing), then the ordinary
1625
+ // lmid + data frames fold exactly as before. With no system retain the partition is empty and
1626
+ // this release is byte-identical to pre-I-iii. The returned scope set feeds the I-iv doorbell
1627
+ // events `onGateProgress` fires once the WHOLE release has applied.
1628
+ const touchedScopes = this.foldSystemFrames(ready.filter((f) => this.systemQids.has(f.qid)));
747
1629
  const muts = [];
748
1630
  for (const f of ready) {
1631
+ if (this.systemQids.has(f.qid)) {
1632
+ // Folded above; a system stream has no store view and MUST NOT enter the sync layer (its
1633
+ // tables are not in the schema) — but its first snapshot still marks the sub hydrated so
1634
+ // the overflow/introspection bookkeeping stays uniform.
1635
+ if (f.kind === "snapshot")
1636
+ this.markSubHydrated(f.qid);
1637
+ continue;
1638
+ }
749
1639
  if (f.qid === LMID_QID) {
750
- this.foldLmidOps(f.ops);
1640
+ // Confirmation and data of the same commit share a cv, so they release together — each
1641
+ // channel's lmid stream folds into ITS OWN domain's watermark (§7.1).
1642
+ this.foldLmidOps(f.ops, gate.key);
751
1643
  continue;
752
1644
  }
753
- muts.push(...(f.kind === "snapshot" ? this.sync.rehydrate(f.qid, f.ops) : this.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)));
754
1649
  // A query's first released snapshot is its hydration point — even an empty one (0 rows is an
755
1650
  // authoritative answer): lift every local view this sub feeds out of `unknown` (loading).
756
1651
  if (f.kind === "snapshot")
757
1652
  this.markSubHydrated(f.qid);
758
1653
  }
759
- this.appliedCv = Math.max(this.appliedCv, frame.cvMin);
760
- // (2) Drop confirmed pending (§1.3 step 5's bookkeeping half): mid ≤ the lmid this
761
- // release itself delivered. A failed mutation drops the same way — the release just
762
- // carries no effects for it, so the rewind in (3) snaps the prediction back. An UNFLUSHED
763
- // folded entry (`mid == null`) is never confirmable — it has not crossed the wire — so it is
764
- // always retained until its own flush stamps a real mid (FOLDED-MUTATIONS-DESIGN §4.1).
1654
+ gate.appliedCv = Math.max(gate.appliedCv, frame.cvMin);
1655
+ let newlyHydrated = null;
1656
+ for (const qid of this.hydrated) {
1657
+ if (!wasHydrated.has(qid))
1658
+ (newlyHydrated ??= new Set()).add(qid);
1659
+ }
1660
+ return { deltas: muts, newlyHydrated, touchedScopes };
1661
+ }
1662
+ /** Apply one released delta against `sourceKey`'s domain (§7.2 per-domain confirm-drop + the §1.3
1663
+ * reconcile cycle). `watermarkUpdate`, when given, advances `watermark[sourceKey]` first — the
1664
+ * hook a per-source lmid confirm rides on (the daemon path folds its watermark in
1665
+ * {@link computeRelease} and passes `undefined`). Then: drop every pending entry its OWN domain's
1666
+ * watermark now covers (a room confirm can never retire a daemon entry, and vice-versa — the §7.1
1667
+ * ledger-collision fix), and run the reconcile cycle against `sourceKey` when the base delta or the
1668
+ * pending set changed. `newlyHydrated` stamps the initial-hydration batch as a catch-up. */
1669
+ applyRelease(sourceKey, deltas, watermarkUpdate, newlyHydrated = null) {
1670
+ if (watermarkUpdate !== undefined) {
1671
+ this.watermark.set(sourceKey, Math.max(this.watermark.get(sourceKey) ?? 0, watermarkUpdate));
1672
+ }
1673
+ // Drop confirmed pending (§1.3 step 5's bookkeeping half), PER DOMAIN: an entry is retired only
1674
+ // when ITS domain's watermark reaches its mid — so two concurrent streams never alias one counter
1675
+ // (§7.1). A failed mutation drops the same way (the release carries no effects, so the rewind snaps
1676
+ // the prediction back). An UNFLUSHED fold (`mid == null`) is never confirmable — retained until its
1677
+ // flush stamps a real mid (FOLDED-MUTATIONS-DESIGN §4.1), regardless of any domain's watermark.
1678
+ // H-v NOTE — retiring here treats coverage as SUCCESS, which for a room domain is sound only
1679
+ // because outcome resolution ALWAYS precedes the coverage that retires: on the room socket
1680
+ // the outcome frames outrun the lmid acks (same-socket ordering + the resync re-send), and on
1681
+ // the daemon-carried path (I-iii) `foldSystemFrames` routes the co-committed outcome ROWS
1682
+ // through handleMutationOutcome BEFORE the ledger fold advances the watermark this filter
1683
+ // reads — a deopted entry has already flipped off the domain by the time its burnt mid is
1684
+ // covered, either way (the named invariant above handleMutationOutcome).
765
1685
  const before = this.pendingMutations.length;
766
- this.pendingMutations = this.pendingMutations.filter((p) => p.mid === null || p.mid > this.confirmedLmid);
1686
+ this.pendingMutations = this.pendingMutations.filter((p) => p.mid === null || p.mid > (this.watermark.get(p.domain) ?? 0));
767
1687
  const pendingChanged = this.pendingMutations.length !== before;
768
- // (3) The reconcile cycle — only when something can have changed: a base delta to
769
- // fold in, or a pending set that shrank (its optimistic layer must rewind out). The batch it
770
- // emits for any view that JUST became hydrated is that view's initial result set, so mark those
771
- // qids so the local-event forwarder stamps their batch `catchUp` (→ Store phases it `snapshot`).
772
- if (muts.length || pendingChanged) {
773
- let newlyHydrated = null;
774
- for (const qid of this.hydrated) {
775
- if (!wasHydrated.has(qid))
776
- (newlyHydrated ??= new Set()).add(qid);
777
- }
1688
+ // The reconcile cycle — only when something can have changed: a base delta to fold in, or a
1689
+ // pending set that shrank (its optimistic layer must rewind out). The batch it emits for any view
1690
+ // that JUST became hydrated is that view's initial result set, so mark those qids so the
1691
+ // local-event forwarder stamps their batch `catchUp` (→ Store phases it `snapshot`).
1692
+ if (deltas.length || pendingChanged) {
1693
+ const emitted = (this.catchUpEmitted = new Set());
778
1694
  this.catchUpQids = newlyHydrated;
779
1695
  try {
780
- this.runReconcileCycle(muts);
1696
+ this.runReconcileCycle(sourceKey, deltas);
781
1697
  }
782
1698
  finally {
783
1699
  this.catchUpQids = null;
1700
+ this.catchUpEmitted = null;
784
1701
  }
1702
+ // Drop the qids the reconcile actually delivered a batch for; the rest folded nothing.
1703
+ if (newlyHydrated)
1704
+ for (const qid of emitted)
1705
+ newlyHydrated.delete(qid);
785
1706
  }
786
- // (4) ResultType is the SERVER CHANNEL's state only now (§7): `unknown` while not hydrated,
787
- // else `complete` — a pending mutation no longer moves it. The pending axis moves separately.
1707
+ // ResultType is the SERVER CHANNEL's state only now (§7): `unknown` while not hydrated, else
1708
+ // `complete` — a pending mutation no longer moves it. The pending axis moves separately.
788
1709
  for (const qid of this.queryTables.keys()) {
789
1710
  this.setResultType(qid, this.hydrated.has(qid) ? "complete" : "unknown");
790
1711
  }
1712
+ // A newly-hydrated query whose reconcile emitted NO batch (0 rows, its whole result already present
1713
+ // via a sibling → 0 net muts, or the reconcile was skipped) still needs a hydration signal, or its
1714
+ // SSR seed never retires and the view freezes. Send an explicit empty catch-up (now that it reads
1715
+ // `complete`, the Store retires the seed and reveals whatever is already in its tree).
1716
+ if (newlyHydrated) {
1717
+ for (const qid of newlyHydrated)
1718
+ this.handler(qid, { type: "batch", events: [], catchUp: true });
1719
+ }
791
1720
  this.refreshPending();
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();
1726
+ // The I-v ghost-drop watcher (§4.2), LAST: this release's watermark rows have folded
1727
+ // (computeRelease) and its confirm-drop has retired what it covers — exactly the two inputs
1728
+ // the drop condition reads. Structural no-op with no ghost.
1729
+ this.evaluateGhosts();
1730
+ }
1731
+ /** Test-only per-source release seam (RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md §7.2/§8.5): drive
1732
+ * {@link applyRelease} for `sourceKey` directly — an explicit `watermarkUpdate` (a simulated lmid
1733
+ * confirm for that domain) and `deltas` (a coherent base delta), with no real gate. Lets a harness
1734
+ * exercise a room-domain confirm before the real second lmid stream / per-source gate is wired
1735
+ * (E-iii-b/c). The `__`-prefix marks it a test hook, alongside {@link __inspect}. */
1736
+ __testRelease(sourceKey, deltas, watermarkUpdate) {
1737
+ this.applyRelease(sourceKey, deltas, watermarkUpdate);
792
1738
  }
793
- /** Fold the lmid system query's released ops (lmid-as-data): the one row's
794
- * `last_mutation_id` cell is this client's confirmed high-water mid. */
795
- foldLmidOps(ops) {
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();
1756
+ this.inOneCommit(() => {
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
+ }
1792
+ }
1793
+ });
1794
+ }
1795
+ /** Fold `domain`'s lmid system query's released ops (lmid-as-data): the one row's
1796
+ * `last_mutation_id` cell is this client's confirmed high-water mid in that domain — it advances
1797
+ * `watermark[domain]` and, on a fresh session ahead of our issued mids, `nextMid[domain]`. The
1798
+ * daemon stream folds `"daemon"`; a room stream folds its own `"room:doc:X"`; the daemon-carried
1799
+ * §7.1 ledger rows fold through the same {@link foldConfirm} core (Slice I-iii). */
1800
+ foldLmidOps(ops, domain) {
796
1801
  for (const op of ops) {
797
1802
  const row = op.op === "add" ? op.row : op.op === "edit" ? op.new : undefined;
798
1803
  if (!row)
799
1804
  continue; // a remove (client GC) confirms nothing
800
- const lmid = Number(row[1]);
801
- if (!Number.isFinite(lmid))
802
- continue;
803
- if (lmid > this.nextMid - 1) {
804
- if (this.pendingMutations.length > 0) {
805
- // The server confirmed a mid we never issued while we have mutations in
806
- // flighttwo writers on one clientID or corrupted state. Unrecoverable.
807
- throw new Error(`optimistic backend: confirmed lmid ${lmid} is ahead of issued mids (${this.nextMid - 1})`);
1805
+ this.foldConfirm(domain, Number(row[1]));
1806
+ }
1807
+ }
1808
+ /** THE one confirm fold (§7.1/§7.2): advance `watermark[domain]` to `lmid` (monotone max) and,
1809
+ * on a fresh session ahead of our issued mids, adopt `nextMid[domain]`. Shared verbatim by the
1810
+ * per-channel lmid system query ({@link foldLmidOps}) and the daemon-carried room-ledger rows
1811
+ * ({@link foldSystemFrames}one core so the two paths cannot drift). */
1812
+ foldConfirm(domain, lmid) {
1813
+ if (!Number.isFinite(lmid))
1814
+ return;
1815
+ const highestIssued = (this.nextMid.get(domain) ?? 1) - 1;
1816
+ if (lmid > highestIssued) {
1817
+ // Only in-flight mutations of THIS domain can contradict its watermark (§7.1: the counters
1818
+ // are independent — a room-domain mutation pending while the daemon's historical lmid
1819
+ // snapshot arrives is a normal fresh-session interleaving, not a second writer). An
1820
+ // unflushed fold (`mid == null`) has issued nothing yet either way: it cannot explain a
1821
+ // confirmed-ahead lmid, and its eventual flush deals from the adopted counter below.
1822
+ if (this.pendingMutations.some((p) => p.domain === domain && p.mid !== null)) {
1823
+ // The server confirmed a mid we never issued while we have mutations in
1824
+ // flight on this domain — two writers on one clientID or corrupted state. Unrecoverable.
1825
+ throw new Error(`optimistic backend: confirmed lmid ${lmid} is ahead of issued mids (${highestIssued})`);
1826
+ }
1827
+ // A fresh session over a clientID with history: adopt the server's high-water
1828
+ // mark so our next mid continues the sequence instead of colliding below it.
1829
+ this.nextMid.set(domain, lmid + 1);
1830
+ }
1831
+ this.watermark.set(domain, Math.max(this.watermark.get(domain) ?? 0, lmid));
1832
+ }
1833
+ // --- the §4 lifecycle system-stream folds (Slice I-iii) --------------------------------
1834
+ /** Fold one release's SYSTEM frames in a FIXED category order — the order is STRUCTURAL (one
1835
+ * function, categories in sequence), because it is the client half of THE NAMED INVARIANT
1836
+ * (§3.3's shipped note; documented above {@link handleMutationOutcome}): **never retire a
1837
+ * room-domain entry off a daemon-carried lmid without outcome resolution.**
1838
+ *
1839
+ * 1. **outcome rows** (`_rindle_room_mutation_outcomes`) — each row for OUR clientID is
1840
+ * synthesized into a {@link MutationOutcomeFrame} and routed through
1841
+ * {@link handleMutationOutcome}, the SAME H-v state machine the room socket's frames use
1842
+ * (one verdict path: frames and rows cannot drift). A deopt flips its pending entry to
1843
+ * the daemon IN PLACE (keep-seq, deal-and-send-now); a rejection surfaces + stays for the
1844
+ * ordinary burnt-mid retire; a duplicate (frame already seen, or the row re-delivered) is
1845
+ * absorbed by the processed set — which doubles as the resolved-verdict memory across
1846
+ * releases (per-domain FIFO, {@link MAX_PROCESSED_OUTCOMES_PER_DOMAIN}, mirroring the
1847
+ * shell's recorded-outcome cap).
1848
+ * 2. **room-ledger rows** (`_rindle_room_client_mutations`) — the FIRST daemon-carried
1849
+ * room-lmid path: OUR row's `last_mutation_id` folds into `watermark[room:<doc>]` via
1850
+ * {@link foldConfirm}. Because step 1 ALREADY resolved every non-applied verdict this
1851
+ * release carries (and earlier releases' verdicts were resolved at their own release),
1852
+ * the confirm-drop that follows in {@link applyRelease} retires only entries whose
1853
+ * outcome is resolution-by-absence — which I-ii's co-commit atomicity defines as APPLIED
1854
+ * (a room flush co-commits the ledger row and every non-applied mid's outcome row in ONE
1855
+ * daemon transaction, so a covering lmid without a row IS the applied verdict).
1856
+ * Processing this category before step 1 is the violation, in two proven directions
1857
+ * (each run break→fail→revert against `test/system_streams.test.ts`): (a) the ledger's
1858
+ * fresh-session `nextMid` ADOPTION must not run before historical outcome rows are
1859
+ * judged — adopted-first, a previous session's retained deopt row passes the
1860
+ * "never-issued" guard and spuriously re-invokes a mutation that session already handled
1861
+ * (a double-apply); (b) the RETIRE must not precede resolution — it does not BECAUSE the
1862
+ * confirm-drop runs in {@link applyRelease}, strictly after this whole function. That
1863
+ * deferral is load-bearing: an "optimization" retiring inline with the watermark fold
1864
+ * retires a deopted entry as a silent success (the exact lost-write H-v exists to
1865
+ * prevent) and mis-attributes a rejected row's reason.
1866
+ * 3. **watermark rows** (`_rindle_room_watermark`) — the §4.2 fence value, max-folded per
1867
+ * doc ({@link roomWatermarks}); I-v's ghost-drop consumer, no reaction here.
1868
+ * 4. **scope-session rows** (`_rindle_scope_sessions`) — the §4.1 occupancy map
1869
+ * ({@link scopeSessions}); I-iv's doorbell consumer, no reaction here.
1870
+ *
1871
+ * Ordinary data ops fold AFTER all of these (the caller's main loop) — outcome/ledger state
1872
+ * must be in place before {@link applyRelease}'s confirm-drop + reconcile consume the release.
1873
+ * Every row is filtered against the retain's {@link SystemStreamSpec} scope/doc AND (for the
1874
+ * client-keyed tables) our own `clientID` — defense in depth: the server predicate may have
1875
+ * been minted doc-only (no `clientId` at lease time), so other clients' rows are expected and
1876
+ * must be ignored, and a row for a doc this retain was not minted for is never folded.
1877
+ *
1878
+ * Returns the scopes category 4 touched (snapshot or ops) — the I-iv doorbell events' input;
1879
+ * `null` when none (every non-lifecycle release). The events themselves fire from
1880
+ * `onGateProgress` AFTER the release applies, never from inside the fold. */
1881
+ foldSystemFrames(frames) {
1882
+ if (frames.length === 0)
1883
+ return null;
1884
+ const byTable = (table) => frames.flatMap((frame) => {
1885
+ const spec = this.systemQids.get(frame.qid);
1886
+ return spec !== undefined && spec.table === table ? [{ spec, frame }] : [];
1887
+ });
1888
+ // (1) outcome rows → the H-v machine, BEFORE any ledger fold (the named invariant).
1889
+ for (const { spec, frame } of byTable(ROOM_MUTATION_OUTCOMES_TABLE)) {
1890
+ for (const op of frame.ops) {
1891
+ const cells = op.op === "add" ? op.row : op.op === "edit" ? op.new : undefined;
1892
+ if (!cells)
1893
+ continue; // a remove is retention pruning (mid ≤ lmid − 512), never a verdict
1894
+ const row = decodeOutcomeRow(cells);
1895
+ if (!row || row.clientId !== this.clientID)
1896
+ continue;
1897
+ if (spec.doc !== undefined && row.doc !== spec.doc)
1898
+ continue;
1899
+ const frameShape = {
1900
+ mid: row.mid,
1901
+ kind: row.kind,
1902
+ ...(row.reason !== undefined ? { reason: row.reason } : {}),
1903
+ ...(row.name !== undefined ? { name: row.name } : {}),
1904
+ ...(row.args !== undefined ? { args: row.args } : {}),
1905
+ };
1906
+ // Release-time invocation is sound here where out-of-band was REQUIRED for the socket
1907
+ // frames (`attachGate`): the socket frame races a buffered lmid ack it must beat, so it
1908
+ // may not wait behind the gate — a ROW cannot race its own release (it and the covering
1909
+ // ledger row co-committed at one cv and fold in THIS function's fixed order). The
1910
+ // machine's steps need nothing from an open release: the flip/reject only move pending
1911
+ // bookkeeping + ship an envelope, and the not-found re-invoke arm runs a fresh prediction
1912
+ // — legal before `applyRelease` opens the reconcile cycle, identical to an app invoke
1913
+ // racing the release.
1914
+ this.handleMutationOutcome(roomDomainKey(row.doc), frameShape);
1915
+ }
1916
+ }
1917
+ // (2) room-ledger rows → the daemon-carried per-domain confirm (outcomes above resolved first).
1918
+ for (const { spec, frame } of byTable(ROOM_CLIENT_MUTATIONS_TABLE)) {
1919
+ for (const op of frame.ops) {
1920
+ const cells = op.op === "add" ? op.row : op.op === "edit" ? op.new : undefined;
1921
+ if (!cells)
1922
+ continue; // a ledger remove confirms nothing (mirrors foldLmidOps)
1923
+ const [doc, clientId, lmid] = cells;
1924
+ if (typeof doc !== "string" || clientId !== this.clientID)
1925
+ continue;
1926
+ if (spec.doc !== undefined && doc !== spec.doc)
1927
+ continue;
1928
+ this.foldConfirm(roomDomainKey(doc), Number(lmid));
1929
+ }
1930
+ }
1931
+ // (3) watermark rows → the monotone §4.2 fence value per doc.
1932
+ for (const { spec, frame } of byTable(ROOM_WATERMARK_TABLE)) {
1933
+ for (const op of frame.ops) {
1934
+ const cells = op.op === "add" ? op.row : op.op === "edit" ? op.new : undefined;
1935
+ if (!cells)
1936
+ continue; // the fence is monotone — a remove never regresses it
1937
+ const [doc, flushSeq] = cells;
1938
+ const seq = Number(flushSeq);
1939
+ if (typeof doc !== "string" || !Number.isFinite(seq))
1940
+ continue;
1941
+ if (spec.doc !== undefined && doc !== spec.doc)
1942
+ continue;
1943
+ this.roomWatermarks.set(doc, Math.max(this.roomWatermarks.get(doc) ?? 0, seq));
1944
+ }
1945
+ }
1946
+ // (4) scope-session rows → the §4.1 occupancy map (a snapshot REPLACES the scope's map — an
1947
+ // authoritative re-hydrate must drop sessions that aged out while the stream was down; a
1948
+ // batch folds add/edit/remove incrementally). Touched scopes are collected for the I-iv
1949
+ // doorbell events (a snapshot touches its minted scope even with zero ops — an emptied-out
1950
+ // scope is a legitimate 1→0 observation for the transition tracker).
1951
+ let touchedScopes = null;
1952
+ const touch = (scope) => {
1953
+ (touchedScopes ??= new Set()).add(scope);
1954
+ };
1955
+ for (const { spec, frame } of byTable(SCOPE_SESSIONS_TABLE)) {
1956
+ if (frame.kind === "snapshot" && spec.scope !== undefined) {
1957
+ this.scopeSessions.set(spec.scope, new Map());
1958
+ touch(spec.scope);
1959
+ }
1960
+ for (const op of frame.ops) {
1961
+ // A remove's identity rides its (full) removed row; add/edit carry the post-image.
1962
+ const cells = op.op === "edit" ? op.new : op.row;
1963
+ const [scope, clientId, expiresAt] = cells;
1964
+ if (typeof scope !== "string" || typeof clientId !== "string")
1965
+ continue;
1966
+ if (spec.scope !== undefined && scope !== spec.scope)
1967
+ continue;
1968
+ let sessions = this.scopeSessions.get(scope);
1969
+ if (!sessions)
1970
+ this.scopeSessions.set(scope, (sessions = new Map()));
1971
+ if (op.op === "remove") {
1972
+ sessions.delete(clientId);
808
1973
  }
809
- // A fresh session over a clientID with history: adopt the server's high-water
810
- // mark so our next mid continues the sequence instead of colliding below it.
811
- this.nextMid = lmid + 1;
1974
+ else {
1975
+ const exp = Number(expiresAt);
1976
+ if (Number.isFinite(exp))
1977
+ sessions.set(clientId, exp);
1978
+ }
1979
+ touch(scope);
812
1980
  }
813
- this.confirmedLmid = Math.max(this.confirmedLmid, lmid);
814
1981
  }
1982
+ return touchedScopes;
1983
+ }
1984
+ /** The I-iv occupancy count — THE one rule (§4.1/D7): unexpired (`expires_at >` the fold
1985
+ * clock's now) sessions under `scope` from OTHER clientIDs. Shared by the doorbell events
1986
+ * ({@link onGateProgress}) and the client's registration-time check (a doorbell that folded
1987
+ * BEFORE a candidate registered must still be able to trigger it) so the two can never
1988
+ * disagree. Own-clientID rows never count — a solo client cannot ring its own bell — and
1989
+ * expiry is judged on the injectable {@link FoldClock} (deterministic in a virtual-clock
1990
+ * harness, the folded-oracle discipline). */
1991
+ otherScopeSessions(scope) {
1992
+ const sessions = this.scopeSessions.get(scope);
1993
+ if (!sessions)
1994
+ return 0;
1995
+ const now = this.clock.now();
1996
+ let n = 0;
1997
+ for (const [clientId, expiresAt] of sessions) {
1998
+ if (clientId !== this.clientID && expiresAt > now)
1999
+ n++;
2000
+ }
2001
+ return n;
2002
+ }
2003
+ /** Register the I-iv doorbell event sink — see {@link ScopeSessionsEvent}. One handler (a later
2004
+ * registration replaces it, the {@link onLocalWrite} convention); client.ts is the consumer. */
2005
+ onScopeSessions(handler) {
2006
+ this.scopeSessionsHandler = handler;
815
2007
  }
816
2008
  /** One §1.3 reconcile cycle: rewind the optimistic layer and fold the coherent SERVER
817
2009
  * delta into BOTH head AND the `sync` baseline (`serverBatchBegin`), re-invoke every
@@ -819,37 +2011,56 @@ export class OptimisticBackend {
819
2011
  * deliver the coalesced result (`serverBatchEnd`). This is the engine's only sync-moving
820
2012
  * boundary — `onProgress` releases and `unregisterQuery`'s GC both go through here so head
821
2013
  * and sync never diverge (the §1.2 invariant; CRIT#2). */
822
- runReconcileCycle(serverDeltas) {
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.
823
2021
  this.local.serverBatchBegin(serverDeltas.map(toServerOp));
824
- // The rewind cleared every optimistic write incl. prior `__agg` editsso head is now
825
- // the server baseline. Rebuild the optimistic agg delta from scratch off the re-invoked
826
- // (confirm-filtered) pending set, so a just-confirmed mutation's delta vanishes exactly as
827
- // its server count is absorbed (§5 watermark no double count).
2022
+ // The rewind covers EVERY tracked table (302: the engine is source-agnosticthere 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.
828
2031
  this.overlay.reset();
829
- // Re-invoke in WIRE order: assigned mids ascending, then unflushed folds (`mid == null`) last
830
- // by creation order — the deterministic slot of FOLDED-MUTATIONS-DESIGN §4.1. A read-dependent
831
- // mutator must replay against the same base the server computed from, which is mid order; with
832
- // deferred fold mids, mid order creation order, so we sort rather than trust array order. The
833
- // comparator is explicit (NOT `(mid ?? ∞) - (mid ?? ∞)`, which is `∞ - = NaN` for two unflushed
834
- // folds a NaN comparator silently corrupts V8's sort): assigned-before-unflushed, mids
835
- // ascending, and STABLE for two unflushed folds so they keep creation order across cycles.
2032
+ // Sort ALL pending into SEND order (the client-global `seq` ascending, then unflushed folds
2033
+ // last by creation order — the deterministic §4.1 slot; the comparator is explicit, NOT
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).
836
2043
  const order = [...this.pendingMutations].sort((a, b) => {
837
- if (a.mid === null && b.mid === null)
2044
+ if (a.seq === null && b.seq === null)
838
2045
  return 0; // both unflushed → stable creation order
839
- if (a.mid === null)
840
- return 1; // an unflushed fold sorts after every assigned mid
841
- if (b.mid === null)
2046
+ if (a.seq === null)
2047
+ return 1; // an unflushed fold sorts after every dealt seq
2048
+ if (b.seq === null)
842
2049
  return -1;
843
- return a.mid - b.mid;
2050
+ return a.seq - b.seq;
844
2051
  });
845
2052
  const dropped = new Set();
846
2053
  try {
847
2054
  for (const p of order) {
848
- const touched = new Set();
2055
+ // NO `readLog` here — recording is armed only on the initial `invoke` (§3.2 #2 note on
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.
2059
+ const writes = new Map();
849
2060
  const ops = [];
850
2061
  try {
851
2062
  this.local.writeWith((tx) => {
852
- this.runMutator(this.registry[p.name], trackingTx(tx, touched, this.specs, this.localTables, this.opCollector(ops)), p.args);
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);
853
2064
  });
854
2065
  }
855
2066
  catch {
@@ -867,47 +2078,54 @@ export class OptimisticBackend {
867
2078
  this.overlay.observe(op);
868
2079
  // The pending footprint is the UNION across invocations: a re-run that no-ops (touched =
869
2080
  // {}) must NOT shrink it, else a still-pending mutation reports not-pending and its
870
- // pending-axis clear fires early (§7.2).
871
- for (const t of touched)
2081
+ // pending-axis clear fires early (§7.2). `writes` mirrors this: merge, never replace.
2082
+ for (const t of writes.keys())
872
2083
  p.touched.add(t);
2084
+ mergeWriteSet(p.writes, writes);
873
2085
  }
874
2086
  // Preserve creation order in the live array (the unflushed-fold sort tiebreak depends on it).
875
2087
  if (dropped.size)
876
2088
  this.pendingMutations = this.pendingMutations.filter((p) => !dropped.has(p));
877
2089
  // Re-apply the optimistic agg delta onto the (rewound) `__agg` head rows — INSIDE the open
878
2090
  // cycle, so the writes buffer and coalesce into the one per-query delivery `serverBatchEnd`
879
- // makes (and never escape as a separate batch).
2091
+ // makes (and never escape as a separate batch). Every cycle (see the reset above).
880
2092
  this.reconcileAggHead();
881
2093
  }
882
2094
  finally {
883
2095
  this.local.serverBatchEnd(); // ALWAYS close the cycle — ONE delivery per affected query.
884
2096
  }
885
2097
  }
886
- /** The daemon restarted (a new boot id): it lost all materialization + `cv` state and its `cv`
887
- * sequence reset, so previously-released `cv`s no longer bound the new stream. The source has
888
- * already re-subscribed every query (reconnect → resync); drop the buffer and the `cv`
889
- * watermark so the fresh, low-`cv` snapshots are RELEASED instead of dropped as stale
890
- * (`onNormalized`/`onProgress` gate on `appliedCv`). Pending optimistic mutations stay put
891
- * they re-apply on the next reconcile, and the lmid system query's fresh snapshot restores the
892
- * confirmation watermark. */
893
- resetForRestart() {
894
- this.buffer = [];
895
- this.appliedCv = 0;
896
- }
897
- /** The §8.5 escape: the buffer outgrew its cap (a pinned `cvMin` under churn). Drop
898
- * everything buffered and re-register every query on the source the fresh
899
- * snapshots arrive as ordinary frames and the next release re-hydrates via the
900
- * footprint diff (the §5.3 path); still-pending optimism re-applies in that cycle. */
901
- overflow() {
902
- this.buffer = [];
2098
+ /** ONE channel's authority restarted (a new boot id): it lost all materialization + `cv` state
2099
+ * and its `cv` sequence reset, so previously-released `cv`s no longer bound the new stream. The
2100
+ * source has already re-subscribed every query (reconnect → resync); drop THIS gate's buffer
2101
+ * and `cv` watermark so the fresh, low-`cv` snapshots are RELEASED instead of dropped as stale
2102
+ * (`onFrame`/`computeRelease` gate on `appliedCv`). The OTHER gates are untouched an
2103
+ * authority restart is per-channel (§5.1). Pending optimistic mutations stay put they
2104
+ * re-apply on the next reconcile, and the channel's lmid system query's fresh snapshot restores
2105
+ * its domain's confirmation watermark. */
2106
+ resetGate(gate) {
2107
+ gate.buffer = [];
2108
+ gate.appliedCv = 0;
2109
+ }
2110
+ /** The §8.5 escape: ONE gate's buffer outgrew its cap (a pinned `cvMin` under churn on that
2111
+ * channel). Drop everything it buffered and re-register every query on that source — the fresh
2112
+ * snapshots arrive as ordinary frames and the next release re-hydrates via the footprint diff
2113
+ * (the §5.3 path); still-pending optimism re-applies in that cycle. The other gates' buffers
2114
+ * and subscriptions are untouched. */
2115
+ overflow(gate) {
2116
+ gate.buffer = [];
903
2117
  for (const sub of this.remoteSubs.values()) {
904
- this.source.unregisterQuery(sub.sourceQid);
905
- this.source.registerQuery(sub.sourceQid, sub.remote);
2118
+ // Re-register only the subs THIS channel owns ({@link RemoteSub.channel} — the one source
2119
+ // of truth, G-iii): resubscribing another channel's sub here would fork its stream.
2120
+ if (sub.channel !== gate.key)
2121
+ continue;
2122
+ gate.source.unregisterQuery(sub.sourceQid);
2123
+ gate.source.registerQuery(sub.sourceQid, sub.remote);
906
2124
  }
907
2125
  // The lmid system query's buffered frames were dropped too — re-subscribe it so a
908
2126
  // fresh snapshot restores the confirmation watermark.
909
- this.source.unregisterQuery(LMID_QID);
910
- this.source.registerQuery(LMID_QID, { name: LMID_QUERY_NAME, args: {} });
2127
+ gate.source.unregisterQuery(LMID_QID);
2128
+ gate.source.registerQuery(LMID_QID, { name: LMID_QUERY_NAME, args: {} });
911
2129
  }
912
2130
  setResultType(qid, rt) {
913
2131
  if (this.resultTypes.get(qid) === rt)
@@ -921,8 +2139,10 @@ export class OptimisticBackend {
921
2139
  this.setResultType(qid, this.hydrated.has(qid) ? "complete" : "unknown");
922
2140
  }
923
2141
  /** A remote sub's first snapshot landed: mark it (and every local view it feeds) hydrated, then
924
- * lift those views out of `unknown` (loading). Idempotent a re-hydrate snapshot re-marks
925
- * 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. */
926
2146
  markSubHydrated(sourceQid) {
927
2147
  const key = this.sourceToRemote.get(sourceQid);
928
2148
  if (!key)
@@ -931,37 +2151,71 @@ export class OptimisticBackend {
931
2151
  if (!sub || sub.hydrated)
932
2152
  return;
933
2153
  sub.hydrated = true;
2154
+ if (sub.channel !== "daemon" && !this.systemQids.has(sub.sourceQid))
2155
+ this.pendingSwapIns.add(sub);
934
2156
  for (const localQid of sub.localQids.keys()) {
935
2157
  this.hydrated.add(localQid);
936
2158
  this.recomputeResultType(localQid);
937
2159
  }
938
2160
  }
939
- retainRemote(retainQid, remote, localQueryId = retainQid) {
2161
+ /** `channel` (G-iii registration-time routing): the gate the sub registers on — the qid's
2162
+ * ownership is fixed HERE, at retain time (no lazy claim; `onFrame` only asserts it). Default
2163
+ * `"daemon"`, so every channel-less caller is byte-identical to before. */
2164
+ retainRemote(retainQid, remote, localQueryId = retainQid, channel = "daemon") {
2165
+ const gate = this.requireGate(channel); // throw loudly BEFORE any sub state moves
940
2166
  const key = remoteKey(remote);
941
2167
  let sub = this.remoteSubs.get(key);
942
2168
  let isNew = false;
943
2169
  if (!sub) {
944
- sub = { sourceQid: retainQid, remote, refCount: 0, localQids: new Map(), hydrated: false };
2170
+ sub = { sourceQid: retainQid, remote, refCount: 0, localQids: new Map(), hydrated: false, channel };
945
2171
  this.remoteSubs.set(key, sub);
946
2172
  this.sourceToRemote.set(sub.sourceQid, key);
947
2173
  isNew = true;
948
2174
  }
2175
+ else if (sub.channel !== channel) {
2176
+ // A (name,args) sub lives on ONE channel — a second retain naming another is a wiring bug
2177
+ // (it would split the query's frames across two cv timelines). Fail loudly.
2178
+ throw new Error(`optimistic backend: query "${remote.name}" is already retained on channel ${JSON.stringify(sub.channel)} — cannot retain it on ${JSON.stringify(channel)}`);
2179
+ }
949
2180
  sub.refCount++;
950
2181
  if (localQueryId !== undefined) {
951
2182
  sub.localQids.set(localQueryId, (sub.localQids.get(localQueryId) ?? 0) + 1);
952
2183
  // A late-joiner to an already-hydrated sub is immediately hydrated; otherwise this view now
953
2184
  // awaits the sub's first snapshot (so a split-path local view registered `complete` flips to
954
2185
  // `unknown` here). Then recompute its lifecycle.
955
- if (sub.hydrated)
2186
+ if (sub.hydrated) {
956
2187
  this.hydrated.add(localQueryId);
957
- 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 {
958
2209
  this.hydrated.delete(localQueryId);
959
- this.recomputeResultType(localQueryId);
2210
+ this.recomputeResultType(localQueryId);
2211
+ }
960
2212
  }
961
2213
  this.localToRemote.set(retainQid, key);
962
2214
  this.remoteRetainToLocal.set(retainQid, localQueryId);
2215
+ // Register on the CHANNEL's source (G-iii): the qid's frames will arrive — and buffer, release,
2216
+ // and overflow — on that channel's own §5.1 gate.
963
2217
  if (isNew)
964
- this.source.registerQuery(sub.sourceQid, remote);
2218
+ gate.source.registerQuery(sub.sourceQid, remote);
965
2219
  }
966
2220
  releaseRemote(retainQid) {
967
2221
  const key = this.localToRemote.get(retainQid);
@@ -983,7 +2237,10 @@ export class OptimisticBackend {
983
2237
  }
984
2238
  if (sub.refCount > 0)
985
2239
  return undefined;
986
- this.source.unregisterQuery(sub.sourceQid);
2240
+ // Unregister from the SAME gate's source the retain registered on. Since I-v a gate CAN be
2241
+ // removed ({@link disconnectSource}) — but never with a live sub on it ({@link
2242
+ // demoteRoomSource} validates loudly), so the daemon fallback is purely defensive.
2243
+ (this.gates.get(sub.channel) ?? this.daemonGate).source.unregisterQuery(sub.sourceQid);
987
2244
  this.sourceToRemote.delete(sub.sourceQid);
988
2245
  this.remoteSubs.delete(key);
989
2246
  return sub.sourceQid;
@@ -1061,18 +2318,91 @@ function colIndexFromSchema(schema) {
1061
2318
  }
1062
2319
  return out;
1063
2320
  }
1064
- /** Wrap the raw wasm txn as the client `MutationTx`, recording the touched tables (the client
1065
- * knows its own footprint what the pending axis derives from, §7.2). The keyed methods validate
1066
- * column names eagerly: a typo'd table or column throws with the valid names listed, at the moment
1067
- * the mutator runs.
2321
+ /** Merge a fresh invocation's write-set into a `PendingMutation`'s accumulated one (§3.2 #1, rebase
2322
+ * re-invocation): each pk's value is OVERWRITTEN with the newest image (a later invocation ran
2323
+ * against the base the rebase just replaced, so its view supersedes the earlier one), but a key
2324
+ * present only in `dest` is left alone — the same union-never-shrink rule `touched` already
2325
+ * follows (§7.2: "a re-run that no-ops must NOT shrink it"). */
2326
+ function mergeWriteSet(dest, src) {
2327
+ for (const [table, byPk] of src) {
2328
+ let d = dest.get(table);
2329
+ if (!d)
2330
+ dest.set(table, (d = new Map()));
2331
+ for (const [pkKey, rec] of byPk)
2332
+ d.set(pkKey, rec);
2333
+ }
2334
+ }
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}`;
2342
+ }
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 });
2356
+ }
2357
+ return out;
2358
+ }
2359
+ /** Rename every TABLE reference in a query AST through `map` (302 §2 point 3 — the 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);
2383
+ }
2384
+ /** Wrap the raw wasm txn as the client `MutationTx`, capturing a pk-granular write-set as it
2385
+ * applies (`writes`, a {@link WriteSet} — table → pk-key → last-write-wins image, §3.2 #1);
2386
+ * `touched` (the pending axis's table-granular Set, §7.2) is derived by the CALLER as
2387
+ * `new Set(writes.keys())`, never populated here. The keyed methods validate column names eagerly:
2388
+ * a typo'd table or column throws with the valid names listed, at the moment the mutator runs.
2389
+ *
2390
+ * With `trapReads` (the FOLDED path, §5), the PUBLIC reads `tx.get`/`tx.row`/`tx.query` throw
2391
+ * `FoldReadError` — a folded mutator that reads state to compute its write is non-absorbing and
2392
+ * refused. The keyed writers (`update`/`upsert`/`insertIgnore`/`delete`) still read internally to
2393
+ * preserve unspecified columns / check pre-existence; that is fold-legal (the trap wraps only the
2394
+ * returned object's `get`/`row`/`query` surface, never the writers' internal probe) — unchanged
2395
+ * by H-ii, which records those probes but arms recording only where the trap never is.
1068
2396
  *
1069
- * With `trapReads` (the FOLDED path, §5), the PUBLIC reads `tx.get`/`tx.row` throw `FoldReadError`
1070
- * a folded mutator that reads state to compute its write is non-absorbing and refused. The keyed
1071
- * writers (`update`/`upsert`/`delete`) still read internally to preserve unspecified columns; that
1072
- * is column-preservation, not value-derivation, so it stays allowed. */
2397
+ * With `readLog` (recording mode, RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md §3.2 #2) a SIBLING
2398
+ * of `trapReads`, the two never armed together by any call site the PUBLIC `tx.get`/`tx.row`
2399
+ * push a `(table, pk, outcome, source?)` {@link ReadRecord} (per-read provenance via the
2400
+ * `provenance` probe, H-ii §3.2 #3), `tx.query` pushes its resolved AST, and (H-ii) the keyed
2401
+ * writers' pre-existence probes record through the same path. Pure capture: it changes no return
2402
+ * value, throws nothing, and is a no-op when `readLog` is omitted. */
1073
2403
  /** Apply one logical {@link MutationOp} (yielded by a shared generator mutator) onto the client's
1074
2404
  * keyed {@link MutationTx} — the same methods a plain client mutator calls directly. Column
1075
- * validation, touched-table tracking, and op collection all happen inside those methods. */
2405
+ * validation, write-set capture, and op collection all happen inside those methods. */
1076
2406
  function applyOpToTx(tx, op) {
1077
2407
  switch (op.kind) {
1078
2408
  case "insert":
@@ -1087,13 +2417,22 @@ function applyOpToTx(tx, op) {
1087
2417
  return tx.delete(op.table, op.pk);
1088
2418
  }
1089
2419
  }
1090
- function trackingTx(tx, touched, specs, localTables, onOp, trapReads = false) {
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 writes — goes 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) {
1091
2427
  const spec = (table) => {
1092
2428
  const s = specs[table];
1093
2429
  if (!s)
1094
2430
  throw new Error(`unknown table ${JSON.stringify(table)} — tables: ${Object.keys(specs).join(", ")}`);
1095
2431
  return s;
1096
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;
1097
2436
  // M1 (`201-LOCAL-ONLY-TABLES-DESIGN.md` §6): a replayable mutator is a pure function of
1098
2437
  // (synced base + args) — it neither READS nor WRITES a local-only table. The server runs the
1099
2438
  // same mutator from `args` alone and cannot see local tables, so any dependence diverges the
@@ -1121,6 +2460,24 @@ function trackingTx(tx, touched, specs, localTables, onOp, trapReads = false) {
1121
2460
  }
1122
2461
  };
1123
2462
  const pkCells = (table, obj) => spec(table).primaryKey.map((i) => obj[spec(table).columns[i]]);
2463
+ // pk from the POSITIONAL wire shape (raw cells in schema column order) — the counterpart of
2464
+ // `pkCells` (which reads a KeyedRow) for the raw `add`/`remove`/`edit` writers below (§3.2 #1).
2465
+ const pkFromCells = (table, cells) => spec(table).primaryKey.map((i) => cells[i]);
2466
+ // Record (or overwrite) this pk's write for the invocation (§3.2 #1): last-write-wins WITHIN
2467
+ // this invocation — an add-then-edit (or edit-then-edit) of the same pk collapses to its final
2468
+ // image, matching the engine head's own semantics for that pk. The record is replaced with
2469
+ // exactly the arguments given: the CALLERS (`edit`/`remove` below, consulting `prior`) decide
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);
2474
+ if (!byPk)
2475
+ writes.set(engineTable, (byPk = new Map()));
2476
+ const pkKey = stableJson(pk);
2477
+ // Defensive copies: the wasm binding's returned arrays are not contractually immutable/unique,
2478
+ // so a captured record must not alias a cell array the engine could later reuse or mutate.
2479
+ byPk.set(pkKey, { table: engineTable, pk: [...pk], row: row ? [...row] : undefined, ...(oldRow ? { oldRow: [...oldRow] } : {}) });
2480
+ };
1124
2481
  // A full insert row: each cell is `obj[c]`, or `null` for an omitted nullable column (design 206
1125
2482
  // §6.2); a `json` object is stringified for the engine (`toCell`). Non-nullable columns are
1126
2483
  // guaranteed present by `checkColumns(full)`.
@@ -1133,29 +2490,99 @@ function trackingTx(tx, touched, specs, localTables, onOp, trapReads = false) {
1133
2490
  spec(table).columns.forEach((c, i) => (out[c] = cells[i]));
1134
2491
  return out;
1135
2492
  };
1136
- // Internal read used by the keyed writers below and the keyed `row` reader; never trapped (for
1137
- // the FOLD trap) but ALWAYS guarded against local reads (M1).
2493
+ // The raw, UN-recorded read primitive behind `getImpl`/`rowImpl` the M1 local guard + the txn
2494
+ // read, nothing else. Slice B deliberately kept the keyed writers (`update`/`upsert`/
2495
+ // `insertIgnore`/`delete`) on this, un-recorded ("recording is about the PUBLIC read entry
2496
+ // points"). H-ii deliberately REVERSES that: the pre-existence probe each keyed writer BRANCHES
2497
+ // on is genuine value-dependence the §3 routing proof must see — the concrete silent-drop shape
2498
+ // is `update("cards", {id:5,…})` where the client's daemon slice has the row but the room's
2499
+ // footprint lacks it: the room-side update no-ops, the commit "succeeds" with zero effects, the
2500
+ // confirm retires the entry, and the user's edit silently vanishes. The proof can only refuse
2501
+ // that route if the probe is on the record. So the keyed writers now probe through `getImpl`
2502
+ // (recorded like any public read, §3.2 #3); the FOLD trap is unaffected — it wraps only the
2503
+ // returned object's `get`/`row`/`query` surface, so keyed writers stay fold-legal and the
2504
+ // trapped path (where `readLog` is never armed) records nothing, exactly as before.
1138
2505
  const rawGet = (table, pk) => {
1139
2506
  assertNotLocal(table, "read");
1140
- return tx.get(table, pk);
2507
+ return tx.get(staged(table), pk);
2508
+ };
2509
+ // Push one {@link ReadRecord} when recording is armed (§3.2 #2/#3): outcome from `row`'s
2510
+ // presence. Pure capture for inspection.
2511
+ const recordRead = (table, pk, row) => {
2512
+ if (!readLog)
2513
+ return;
2514
+ readLog.reads.push({
2515
+ table,
2516
+ pk: [...pk],
2517
+ outcome: row === undefined ? "absent" : "present",
2518
+ });
2519
+ };
2520
+ // The PUBLIC positional read (§3.2 #2) — and, since H-ii, the keyed writers' pre-existence
2521
+ // probe (§3.2 #3, see the `rawGet` note): `rawGet` plus a `readLog` record when recording is
2522
+ // armed. A no-op record when `readLog` is omitted — exactly `rawGet`'s behavior then.
2523
+ const getImpl = (table, pk) => {
2524
+ const result = rawGet(table, pk);
2525
+ recordRead(table, pk, result);
2526
+ return result;
1141
2527
  };
2528
+ // The PUBLIC keyed read (§3.2 #2), the `row` counterpart of `getImpl`.
2529
+ const rowImpl = (table, pk) => {
2530
+ checkColumns(table, pk, false);
2531
+ const pkc = pkCells(table, pk);
2532
+ const cells = rawGet(table, pkc);
2533
+ recordRead(table, pkc, cells);
2534
+ return cells ? toKeyed(table, cells) : undefined;
2535
+ };
2536
+ // The pk's existing record from THIS invocation, if any — the coalescing-matrix input for
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));
1142
2539
  const add = (table, row) => {
1143
2540
  assertNotLocal(table, "write");
1144
- touched.add(table);
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.
1145
2547
  onOp?.({ table, kind: "add", row });
1146
- tx.add(table, row);
2548
+ tx.add(t, row);
1147
2549
  };
1148
2550
  const remove = (table, row) => {
1149
2551
  assertNotLocal(table, "write");
1150
- touched.add(table);
1151
- onOp?.({ table, kind: "remove", row });
1152
- tx.remove(table, row);
2552
+ const t = staged(table);
2553
+ const pk = pkFromCells(table, row);
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
2560
+ // resident (a raw remove of an absent row) — a captured remove thus always carries a
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);
1153
2566
  };
1154
2567
  const edit = (table, oldRow, newRow) => {
1155
2568
  assertNotLocal(table, "write");
1156
- touched.add(table);
1157
- onOp?.({ table, kind: "edit", row: newRow, old: oldRow });
1158
- tx.edit(table, oldRow, newRow);
2569
+ const t = staged(table);
2570
+ const pk = pkFromCells(table, newRow);
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
2573
+ // resident (covers the pk-MOVING raw edit — the record is keyed by the NEW pk; the pre-image
2574
+ // carries the OLD row). Edit-after-edit: keep the FIRST pre-image (the txn-entry base).
2575
+ // Edit-after-add / edit-after-remove: the record collapses to a (re-)insert — NO pre-image
2576
+ // (the pk did not pre-exist this invocation's base).
2577
+ const p = prior(table, pk);
2578
+ const pre = p === undefined
2579
+ ? (tx.get(t, pk) ?? oldRow)
2580
+ : p.row !== undefined && p.oldRow !== undefined
2581
+ ? p.oldRow
2582
+ : undefined;
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);
1159
2586
  };
1160
2587
  // The folded read trap (§5): a mutator that reads to compute its write is refused. `() => never`
1161
2588
  // is assignable to the wider read signatures (extra args ignored, `never` widens to the result).
@@ -1171,28 +2598,29 @@ function trackingTx(tx, touched, specs, localTables, onOp, trapReads = false) {
1171
2598
  const ast = q.ast();
1172
2599
  for (const t of collectTables(ast))
1173
2600
  assertNotLocal(t, "read");
1174
- return tx.query(ast);
2601
+ readLog?.queries.push(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);
1175
2605
  };
1176
2606
  return {
1177
- get: trapReads ? trapped : rawGet,
2607
+ get: trapReads ? trapped : getImpl,
1178
2608
  query: trapReads ? trapped : runQuery,
1179
2609
  add,
1180
2610
  remove,
1181
2611
  edit,
1182
- row: trapReads
1183
- ? trapped
1184
- : (table, pk) => {
1185
- checkColumns(table, pk, false);
1186
- const cells = rawGet(table, pkCells(table, pk));
1187
- return cells ? toKeyed(table, cells) : undefined;
1188
- },
2612
+ row: trapReads ? trapped : rowImpl,
1189
2613
  insert: (table, row) => {
1190
2614
  checkColumns(table, row, true);
1191
2615
  add(table, toCells(table, row));
1192
2616
  },
2617
+ // The keyed writers' pre-existence probes go through `getImpl` — RECORDED reads since H-ii
2618
+ // (§3.2 #3): each writer BRANCHES on the probe, a value-dependence the routing proof must see
2619
+ // (the silent-drop rationale on `rawGet` above). Fold-legal exactly as before (the trap wraps
2620
+ // the public surface above, never these), and byte-identical when recording is off.
1193
2621
  update: (table, row) => {
1194
2622
  checkColumns(table, row, false);
1195
- const current = rawGet(table, pkCells(table, row));
2623
+ const current = getImpl(table, pkCells(table, row));
1196
2624
  if (!current)
1197
2625
  return; // rebase-friendly: the row may have vanished upstream
1198
2626
  const s = spec(table);
@@ -1203,7 +2631,7 @@ function trackingTx(tx, touched, specs, localTables, onOp, trapReads = false) {
1203
2631
  },
1204
2632
  upsert: (table, row) => {
1205
2633
  checkColumns(table, row, true);
1206
- const current = rawGet(table, pkCells(table, row));
2634
+ const current = getImpl(table, pkCells(table, row));
1207
2635
  if (current)
1208
2636
  edit(table, current, toCells(table, row));
1209
2637
  else
@@ -1211,12 +2639,12 @@ function trackingTx(tx, touched, specs, localTables, onOp, trapReads = false) {
1211
2639
  },
1212
2640
  insertIgnore: (table, row) => {
1213
2641
  checkColumns(table, row, true);
1214
- if (!rawGet(table, pkCells(table, row)))
2642
+ if (!getImpl(table, pkCells(table, row)))
1215
2643
  add(table, toCells(table, row));
1216
2644
  },
1217
2645
  delete: (table, pk) => {
1218
2646
  checkColumns(table, pk, false);
1219
- const current = rawGet(table, pkCells(table, pk));
2647
+ const current = getImpl(table, pkCells(table, pk));
1220
2648
  if (!current)
1221
2649
  return; // rebase-friendly no-op
1222
2650
  remove(table, current);