@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.d.ts CHANGED
@@ -1,4 +1,6 @@
1
- import type { Ast, Backend, BackendDevObserver, ChangeEvent, ColsMap, IsoTx, KeyedRow, Mutation, MutationGen, MutatorCtx, OptimisticSource, QueryArg, QueryId, QueryResultRow, RemoteQuery, ResultType, Schema, WireValue } from "@rindle/client";
1
+ import type { Ast, Backend, BackendDevObserver, ChangeEvent, ColsMap, IsoTx, KeyedRow, Mutation, MutationEnvelope, MutationGen, MutatorCtx, OptimisticSource, QueryArg, QueryId, QueryResultRow, RemoteQuery, ResultType, Schema, WireValue } from "@rindle/client";
2
+ import { type SystemStreamSpec } from "./system-streams.ts";
3
+ export type { SystemStreamSpec, SystemStreamTable } from "./system-streams.ts";
2
4
  /** A keyed row: column name → cell. The ergonomic shape — column names are validated against the
3
5
  * schema at runtime, so a typo throws immediately with the valid names. Re-exported from
4
6
  * `@rindle/client` (the leaf both tiers share). */
@@ -52,6 +54,75 @@ export type ClientMutator = ((tx: MutationTx, args: never) => void) | ((tx: IsoT
52
54
  * twin shares names (and possibly code), never the wire. */
53
55
  export type ClientRegistry = Record<string, ClientMutator>;
54
56
  export type { ResultType };
57
+ /** One captured write, pk-granular (RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md §3.2 #1: "the
58
+ * writers already receive `(table, row)`... this is pure capture, no semantic change"). `row` is
59
+ * the post-write image (positional wire cells, schema column order); `undefined` for a `remove` —
60
+ * no row survives it, and `row === undefined` stays the remove marker.
61
+ *
62
+ * `oldRow` is the PRE-IMAGE, captured for a `remove` AND an `edit` (H-ii): the full-width row
63
+ * read via `tx.get` immediately BEFORE the write staged — read-your-writes, so a write to the
64
+ * same pk earlier in the SAME invocation shows through — falling back to the caller's asserted
65
+ * old row when the pk is not txn-visible (a raw remove/edit of an absent row; incl. the
66
+ * pk-MOVING raw edit, whose record is keyed by the NEW pk yet whose pre-image is the caller's
67
+ * OLD row). A captured remove/edit thus always carries a full-width pre-image — with ONE
68
+ * exception: a record that collapses to a (re-)insert has none, see the matrix.
69
+ *
70
+ * Coalescing within one invocation is last-write-wins per pk on `row` (the final image matches
71
+ * the engine head's own semantics for that pk) with `oldRow` pinned to the TXN-ENTRY BASE — the
72
+ * H-ii matrix:
73
+ * - edit-after-add / edit-after-remove: the record collapses to a (re-)insert — post-image
74
+ * only, NO `oldRow` (the pk did not pre-exist this invocation's base; presence hold-back
75
+ * uses `row`).
76
+ * - edit-after-edit: keeps the FIRST pre-image (the txn-entry base — the chain nets to ONE
77
+ * edit from the base to the final image).
78
+ * - remove-after-edit / remove-after-remove: keeps the ORIGINAL pre-image (the first write's
79
+ * captured base), NOT the edited transient — the net effect is a remove of the row the
80
+ * external world last knew.
81
+ * - remove-after-add: keeps the txn-visible pre-image (the transient added row — the pk had
82
+ * no base, and this is the only truthful full-width row there is; G-iii pinned it).
83
+ * - add-after-remove (a re-insert): drops `oldRow` (presence hold-back uses `row`).
84
+ * Across rebase re-invocations the write-set is union-never-shrink ({@link mergeWriteSet}): a
85
+ * re-run that no-ops keeps the prior record — and its pre-image — intact. */
86
+ export interface WriteRecord {
87
+ table: string;
88
+ pk: WireValue[];
89
+ row: WireValue[] | undefined;
90
+ oldRow?: WireValue[];
91
+ }
92
+ /** The pk-granular write-set captured over ONE mutator invocation: table → pk-key (a stable-JSON
93
+ * encoding of the pk cells, {@link stableJson}) → that pk's write, LAST-WRITE-WINS within the
94
+ * invocation — an add-then-edit or edit-then-edit of the SAME pk collapses to its final image,
95
+ * matching the engine head's own semantics for that pk. Chosen (over a flat array) because the
96
+ * later routing proof needs "is pk P in the writable scope" / "did we already see this pk in this
97
+ * invocation" as cheap lookups, and rebase re-invocation needs to MERGE a fresh write-set into an
98
+ * accumulated one ({@link mergeWriteSet}) — both are Map operations, not scans.
99
+ *
100
+ * `touched` (the pre-existing table-granular `Set<string>` the pending axis reads, §7.2) is
101
+ * exactly `new Set(writeSet.keys())` — derived from this, never separately populated, so the two
102
+ * can never drift. */
103
+ export type WriteSet = Map<string, Map<string, WriteRecord>>;
104
+ /** Whether a recorded point read (`tx.get`/`tx.row`) found a row. */
105
+ export type ReadOutcome = "present" | "absent";
106
+ /** One recorded point read, pk-granular (§3.2 #2). Recording-mode only — see {@link ReadLog}.
107
+ * Since H-ii this covers BOTH the public reads (`tx.get`/`tx.row`) and the keyed writers'
108
+ * internal pre-existence probes (§3.2 #3 — see the `rawGet` note in {@link trackingTx}). */
109
+ export interface ReadRecord {
110
+ table: string;
111
+ pk: WireValue[];
112
+ outcome: ReadOutcome;
113
+ }
114
+ /** The read-log captured over ONE mutator invocation when recording is armed (RINDLE-REALTIME-
115
+ * QUERY-ENABLEMENT-DESIGN.md §3.2 #2): every point read (`reads` — the public `tx.get`/`tx.row`
116
+ * and, since H-ii, the keyed writers' internal pre-existence probes) plus every resolved query AST
117
+ * (`queries`, from `tx.query`). A SIBLING of the folded read TRAP (`FoldReadError` below) — the
118
+ * trap arms on the folded path and throws before any read completes (recording never runs there);
119
+ * recording arms on the ordinary (non-folded) prediction run and never throws. Pure capture for
120
+ * devtools/inspection (the §3 routing derivation it once fed was removed by
121
+ * 302-ROOM-STORE-SEPARATION-DESIGN.md §5 — mutators DECLARE their domain now). */
122
+ export interface ReadLog {
123
+ reads: ReadRecord[];
124
+ queries: Ast[];
125
+ }
55
126
  /** A virtual-clock seam for the fold debounce/maxWait timers (FOLDED-MUTATIONS-DESIGN §9): the
56
127
  * oracle injects a deterministic scheduler; production defaults to real timers + `Date.now`. */
57
128
  export interface FoldClock {
@@ -69,6 +140,15 @@ export interface FoldOptions {
69
140
  /** Hard cap so a never-idle drag still persists periodically (trailing throttle). Unbounded if
70
141
  * omitted — an idle gap of `debounceMs` is then the only thing that flushes. */
71
142
  maxWaitMs?: number;
143
+ /** §9.3 room-aware cadence. When this fold's write ROUTES INTO A ROOM (a collaborator is live on
144
+ * the shared head), flush at this (short) interval instead of `debounceMs`/`maxWaitMs`, so the
145
+ * intermediate frames STREAM to the room rather than collapsing to last-value-wins — the pen is
146
+ * watched, so its growth matters. OFF the room (solo / daemon-served) this is ignored and the
147
+ * caller's `debounceMs` collapse governs. `0` ⇒ per-frame (never coalesce while in a room); a
148
+ * small value (e.g. 40ms) animates while still capping the write rate. Absent ⇒ same cadence
149
+ * room or not (today's behavior). The room decision is probed from the write-set at the fold
150
+ * window's first invoke; it stays fixed for that window. */
151
+ roomDebounceMs?: number;
72
152
  /** Keep deferring across overlapping non-fold writes for maximum economy, accepting the §4.2
73
153
  * read-dependent reorder snap. Default `false` (flush-on-enqueue — correct-and-boring). */
74
154
  deferAcrossWrites?: boolean;
@@ -80,6 +160,39 @@ export interface FoldHandle {
80
160
  flush(): void;
81
161
  readonly mid: Promise<number>;
82
162
  }
163
+ /** One I-iv doorbell event ({@link OptimisticBackend.onScopeSessions}, §4.1): a release folded
164
+ * scope-session rows for `scope`, and `others` is the count of OTHER clients' unexpired sessions
165
+ * there — {@link OptimisticBackend.otherScopeSessions} evaluated at fold time (the same one rule,
166
+ * on the injectable {@link FoldClock}, so a virtual-clock harness gets deterministic verdicts).
167
+ * The consumer (client.ts) triggers its one debounced re-lease on the 0→≥1 transition; expired
168
+ * and own-clientID rows never count, so a solo client's own row can never ring its own bell. */
169
+ export interface ScopeSessionsEvent {
170
+ scope: string;
171
+ others: number;
172
+ }
173
+ /** The I-v stuck-downgrade event ({@link OptimisticBackend.onDowngradeStuck}): the ghost's fence
174
+ * is satisfied but these SENT room-domain mids never resolved (an entry that never reached the
175
+ * room — sent-but-undelivered when the socket died — is undecidable in general, §7.5). The ghost
176
+ * HOLDS (fail LOUD, never silent; no timeout-retire is invented) and the mids are surfaced once,
177
+ * actionably. */
178
+ export interface DowngradeStuckEvent {
179
+ sourceKey: string;
180
+ doc: string;
181
+ mids: number[];
182
+ }
183
+ /** The 302 §6.1 context-coverage event ({@link OptimisticBackend.onRoomContextJoin}): a view
184
+ * swapping onto room `sourceKey`'s namespaced tables still references `tables` the room does NOT
185
+ * own — those refs keep reading the PLAIN daemon tables (the client-side join across kinds), and
186
+ * the room's relayed copies of them are dropped by design (§6). Whether a daemon subscription
187
+ * covers the joined rows is unknowable here, so the condition is surfaced ONCE per view: without
188
+ * coverage the join renders silently empty for the whole room session. Fired at swap-in — a view
189
+ * whose every referenced table is room-owned (every in-repo app today) never fires it. */
190
+ export interface RoomContextJoinEvent {
191
+ sourceKey: string;
192
+ name: string;
193
+ args: unknown;
194
+ tables: string[];
195
+ }
83
196
  export interface OptimisticBackendOptions {
84
197
  /** Stable per-client identity for the upstream envelopes (§8.1). */
85
198
  clientID: string;
@@ -93,6 +206,22 @@ export interface OptimisticBackendOptions {
93
206
  /** Virtual-clock seam for the fold debounce timers (FOLDED-MUTATIONS-DESIGN §9). Defaults to
94
207
  * real `setTimeout`/`clearTimeout`/`Date.now`; the fold oracle injects a deterministic clock. */
95
208
  clock?: FoldClock;
209
+ /** The DECLARED confirming stream per mutation (302 §5: declared, not derived — there is no
210
+ * routing proof). A policy returning a string pins that domain verbatim: the mutation stages
211
+ * onto that room's namespaced tables and ships on its channel. Returning `undefined` (or
212
+ * configuring no policy) means `"daemon"`. The client layer builds this from the app's declared
213
+ * realtime mutators + the currently attached rooms; a misdeclaration fails SOFT (302 §5.1) —
214
+ * the write lands on the other authority's tables and the view simply stops feeling instant
215
+ * until the echo relays it. */
216
+ domainPolicy?: (name: string, args: unknown) => string | undefined;
217
+ /** A FINAL (authz/validation) mutation rejection's reason surface — the room plane's twin of the
218
+ * HTTP mutate route's `onRejected` (H-v; the H-iv-b `mutationOutcome {kind:"rejected"}` frame).
219
+ * The prediction's snap-back is NOT this callback's job: the room burns the mid and its lmid
220
+ * release drops the entry exactly as a daemon-path rejection does (processed-as-no-op) — this
221
+ * is where the REASON reaches the app, same contract as the queue's callback. Also invoked when
222
+ * a DEOPT's fresh re-invocation (the already-retired arm) throws — that mutation is dead on the
223
+ * current base with no stream left to confirm it, the closest thing to a rejection there is. */
224
+ onRejected?: (envelope: MutationEnvelope, reason: string) => void;
96
225
  }
97
226
  /** One folded entry's debounce window, for the timeline's fold drill-down (§4.1). */
98
227
  export interface FoldInspect {
@@ -115,6 +244,13 @@ export interface PendingInspect {
115
244
  args: unknown;
116
245
  /** Tables this mutator touched at its last (re)invocation — the pending-axis basis (§7.2). */
117
246
  tables: string[];
247
+ /** The pk-granular write-set captured at this entry's LAST invocation (RINDLE-REALTIME-QUERY-
248
+ * ENABLEMENT-DESIGN.md §3.2 #1), flattened from the {@link WriteSet} map for inspection — one
249
+ * entry per `(table, pk)` currently held. Pure capture; no routing consumer yet. */
250
+ writes: WriteRecord[];
251
+ /** The read-log captured at this entry's LAST *recorded* invocation (§3.2 #2). Empty for a
252
+ * folded entry — the read TRAP arms there, not recording (see {@link PendingMutation.reads}). */
253
+ reads: ReadLog;
118
254
  /** Present iff this entry is a folded (debounced) write. */
119
255
  fold?: FoldInspect;
120
256
  }
@@ -156,6 +292,9 @@ export declare class OptimisticBackend<S extends ColsMap> implements Backend {
156
292
  * its width check. */
157
293
  private readonly colCounts;
158
294
  private readonly colIndex;
295
+ /** Per-table pk column indices — held so `connectSource` can build a fresh per-source
296
+ * `NormalizedSync` with the same layout the daemon's uses. */
297
+ private readonly pkCols;
159
298
  /** The client's OWN typed per-table schemas + the reserved lmid table — the fixed base
160
299
  * of the expected-schema set (CRIT#4 validation). Synthetic agg tables are appended as
161
300
  * queries arrive (`ensureSyntheticTables`). */
@@ -175,6 +314,12 @@ export declare class OptimisticBackend<S extends ColsMap> implements Backend {
175
314
  private readonly overlay;
176
315
  private handler;
177
316
  private catchUpQids;
317
+ /** Newly-hydrated qids whose reconcile ACTUALLY emitted a (catch-up-stamped) batch — recorded by the
318
+ * local-event forwarder alongside {@link catchUpQids}. After the reconcile, any newly-hydrated qid
319
+ * NOT in here folded nothing (0 rows, or its result already present via a sibling → 0 net muts, or
320
+ * the reconcile was skipped), so `onProgress` sends it an explicit empty catch-up — else its SSR
321
+ * seed would never retire (the view freezes). Non-null only for the reconcile's duration. */
322
+ private catchUpEmitted;
178
323
  /** The Store's commit-boundary handler ({@link Backend.onCommitBoundary}), forwarded from the
179
324
  * local engine's `dispatch` brackets so the Store folds every affected view before notifying any
180
325
  * subscriber (cross-view-atomic notification). All this backend's data deltas originate from the
@@ -182,18 +327,114 @@ export declare class OptimisticBackend<S extends ColsMap> implements Backend {
182
327
  private boundaryHandler;
183
328
  private readonly devObservers;
184
329
  private pendingMutations;
330
+ /** The next mid to deal, PER DOMAIN (RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md §7.1): a client
331
+ * writing through room + daemon concurrently must not alias one lmid counter. Seeded with the
332
+ * `"daemon"` stream at 1; a domain absent from the map starts at 1. In the single-domain
333
+ * configuration only `"daemon"` is ever touched, so the sequence is byte-for-byte as before. */
185
334
  private nextMid;
335
+ /** The client-global deal counter behind {@link PendingMutation.seq}: one sequence across ALL
336
+ * domains, bumped whenever any domain's mid is dealt. The replay order (mids are per-domain and
337
+ * incomparable across domains — see the `seq` field doc). */
338
+ private dealSeq;
339
+ /** The explicit confirming-stream override (§7.1/§3) — see
340
+ * {@link OptimisticBackendOptions.domainPolicy}. `undefined` from it ⇒ H-iii derivation. */
341
+ private readonly domainPolicy;
342
+ /** The final-rejection reason surface ({@link OptimisticBackendOptions.onRejected}). */
343
+ private readonly rejectedHandler;
344
+ /** Processed `(domain, mid)` outcome frames (H-v) — the deopt handshake's idempotence guard: a
345
+ * duplicate frame (the original plus a reconnect re-send's re-answer, or two re-answers across
346
+ * two reconnects) must not double-invoke. Needed precisely because a deopt frame can arrive for
347
+ * an ALREADY-RETIRED mid (the replay gotcha) — "no matching entry" alone cannot distinguish
348
+ * "handle it fresh" from "already handled". Per-domain FIFO, capped like the shell's
349
+ * recorded-outcome map ({@link MAX_PROCESSED_OUTCOMES_PER_DOMAIN}); past the cap a duplicate of
350
+ * an evicted mid would be re-processed — the same bounded-window trade the shell makes, and it
351
+ * takes 512 interleaving non-applied outcomes on one domain to open it. */
352
+ private readonly outcomesProcessed;
353
+ /** THE room-table registry (302 §2 — one source per table): per connected room `sourceKey`, the
354
+ * wire-table → engine-table map for the tables that room OWNS (its writable scope). Written by
355
+ * {@link registerRoomTables} (same breath as the engine registration); read by the gate's
356
+ * release rename/filter, the mutator staging map, the view swap ({@link processSwapIns}), and
357
+ * the client's `__realtimeInspect` bookkeeping. The record outlives a downgrade's disconnect —
358
+ * the ghost's views still read the engine tables — and drops at {@link dropGhost} (or the last
359
+ * clean release via {@link unregisterRoomTables}). */
360
+ private readonly roomTables;
361
+ /** Local view qids currently REGISTERED on a room's namespaced tables (302 §4 swap-in), →
362
+ * their sourceKey. Set by {@link processSwapIns}; cleared by the swap-back ({@link dropGhost})
363
+ * and view teardown. The original AST stays in {@link asts} throughout — the swap re-registers
364
+ * only the ENGINE query. */
365
+ private readonly roomSwappedViews;
366
+ /** Room subs whose FIRST snapshot released in the current release — their views swap onto the
367
+ * room tables at the release tail ({@link processSwapIns}), strictly AFTER the reconcile folded
368
+ * the snapshot into those tables (swapping earlier would hydrate the view EMPTY, a flash). */
369
+ private readonly pendingSwapIns;
186
370
  /** The live fold entries, by fold key `${name}\0${identityJSON}` — at most one per key
187
371
  * (FOLDED-MUTATIONS-DESIGN §8). Insertion order is creation order (the drain/flush tiebreak). */
188
372
  private readonly folds;
189
373
  /** The fold debounce clock (real timers by default; the oracle injects a virtual one). */
190
374
  private readonly clock;
191
- /** The high-water confirmed mutation id, folded from the lmid system query's
192
- * RELEASED ops (lmid-as-data) never from a frame. */
193
- private confirmedLmid;
194
- private buffer;
195
- private nextSeq;
196
- private appliedCv;
375
+ /** The high-water confirmed mutation id, PER DOMAIN (RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md
376
+ * §7.2 per-domain confirm-drop): an entry with `mid <= watermark[entry.domain]` has been
377
+ * confirmed. The `"daemon"` domain is folded from the lmid system query's RELEASED ops
378
+ * (lmid-as-data) — never from a frame; a room domain will fold from its own lmid stream (later
379
+ * slice). Seeded with `"daemon"` at 0; the daemon scalar `confirmedLmid` (devtools) is
380
+ * `watermark.get("daemon")`. */
381
+ private watermark;
382
+ /** The per-source coherence gates (§5.1), by source key. Seeded with the daemon gate at
383
+ * construction; a room channel attaches later (`connectSource`). Single-domain: one entry,
384
+ * and every gate-generalized path degenerates to the old single-buffer code. NOT the same
385
+ * space as {@link watermark}/{@link nextMid}: a DOMAIN can confirm with no gate connected
386
+ * (the `__testRelease` seam); a gate's `key` names the domain its lmid stream folds into. */
387
+ private readonly gates;
388
+ /** The daemon's gate — the always-present channel (constructor-attached). The devtools
389
+ * scalars (`__inspect`) read it directly; its `sync` IS {@link sync} (the agg overlay and
390
+ * synthetic tables are daemon-tracked by design). */
391
+ private readonly daemonGate;
392
+ /** System retains by source qid ({@link retainSystemQuery}): a subscription with NO store view
393
+ * and NO user-visible table — its frames buffer on its gate exactly like {@link LMID_QID}'s and
394
+ * fold at RELEASE time ({@link foldSystemFrames}), never entering the sync layer or the local
395
+ * engine. The spec names which system table the qid serves and the scope/doc it was minted for
396
+ * (the fold's row filter). Empty on every non-lifecycle client — every partition below is then
397
+ * a structural no-op and the release path is byte-identical to before. */
398
+ private readonly systemQids;
399
+ /** The §4.2 fence state: room doc → highest `flush_seq` delivered through the daemon plane
400
+ * (monotone max-fold; a remove never regresses it). Slice I-v's ghost-drop consumer — I-iii
401
+ * only maintains + exposes it (`__inspectDomains().lifecycle`). */
402
+ private readonly roomWatermarks;
403
+ /** The §4.1 occupancy state: scope → (client_id → expires_at) from the doorbell stream. Slice
404
+ * I-iv's doorbell consumer (the 1→2 re-lease reaction) — I-iii only maintains + exposes it.
405
+ * A snapshot REPLACES the scope's map (authoritative re-hydrate); a batch folds add/edit/remove
406
+ * incrementally (the age-out sweep's deletes arrive as removes). */
407
+ private readonly scopeSessions;
408
+ /** The I-iv doorbell event sink ({@link onScopeSessions}) — fired once per scope a release's
409
+ * scope-session fold touched, AFTER the whole release applied. Default no-op: a client that
410
+ * never registers (no lifecycle plane) pays nothing. */
411
+ private scopeSessionsHandler;
412
+ /** Deferred old-channel row GC for in-flight upgrade retargets ({@link retargetRemoteQuery}):
413
+ * sub sourceQid → the channel it left. The rows the OLD gate's sync holds for the qid stay
414
+ * visible (merge: daemon tier) until the sub's first snapshot RELEASES on its new room channel
415
+ * ({@link flushRetargetGc}) — dropping them at retarget time would emit net removes ahead of
416
+ * the room's re-adds, the flicker the two-phase cutover exists to avoid. Doubles as the
417
+ * wrong-channel GRACE window in {@link onFrame}: a frame already in flight from the old
418
+ * channel when the sub moved is stale, not a wiring bug. Empty on every non-upgrade client —
419
+ * every consultation below is then a structural no-op. */
420
+ private readonly pendingRetargetGc;
421
+ /** The §4.2 GHOSTS (Slice I-v): demoted room sources awaiting their watermark fence, by
422
+ * sourceKey. Written only by {@link demoteRoomSource}; evaluated after every release
423
+ * ({@link evaluateGhosts}) and dropped by {@link dropGhost} once the fence clears with no
424
+ * sent room-domain pending left. Empty on every non-downgrade client — the per-release
425
+ * evaluation is then a structural no-op. */
426
+ private readonly ghosts;
427
+ /** The I-v stuck-downgrade surface ({@link onDowngradeStuck}) — fired AT MOST ONCE per ghost
428
+ * when its fence is satisfied but sent room-domain mids remain unresolved (§7.5: they retire
429
+ * only through outcome resolution; the ghost holds rather than inventing a timeout-retire).
430
+ * Default no-op. */
431
+ private downgradeStuckHandler;
432
+ /** The 302 §6.1 context-coverage surface ({@link onRoomContextJoin}) — fired at most once per
433
+ * view ({@link contextJoinWarned}), at swap-in, when its AST references tables the room does
434
+ * not own. Default no-op. */
435
+ private roomContextJoinHandler;
436
+ /** Views the context-coverage event already fired for (once per view; cleared on teardown). */
437
+ private readonly contextJoinWarned;
197
438
  private readonly asts;
198
439
  /** Per query: the base tables its result can draw from (from the AST tree). */
199
440
  private readonly queryTables;
@@ -215,7 +456,40 @@ export declare class OptimisticBackend<S extends ColsMap> implements Backend {
215
456
  * {@link writeLocal} invokes it post-commit; {@link applyLocalReplica} deliberately does not. */
216
457
  private localWriteObserver;
217
458
  constructor(schema: Schema<S>, source: OptimisticSource, registry: ClientRegistry, opts: OptimisticBackendOptions);
218
- registerQuery(qid: QueryId, ast: Ast, remote?: RemoteQuery): void;
459
+ /** Wire one authority channel into its own coherence gate (§5.1): every frame the channel
460
+ * delivers buffers on THIS gate's cv timeline, its progress frames release THIS buffer, its
461
+ * restart resets THIS gate alone, and its reserved lmid stream folds into `watermark[key]`.
462
+ * Validates each server hello against our OWN typed schema → reject a schema skew (CRIT#4);
463
+ * the reserved lmid table is part of the expected set so the system query's hello passes, and
464
+ * synthetic agg tables join the set as queries register them. */
465
+ private attachGate;
466
+ /** Attach a SECOND authority channel (§5.1) — the seam Slice G's room upgrade calls with the
467
+ * ws-backed room feed. Rooms speak the daemon protocol verbatim (§2.4: the client cannot tell
468
+ * a room from the daemon), so the argument is a full {@link OptimisticSource} — exactly what
469
+ * `@rindle/remote` builds from `{roomUrl, leaseToken}`. The channel buffers/releases on its
470
+ * own cv timeline (an independent §5.1 gate: coherent within, eventual across) and its
471
+ * reserved lmid stream folds into `watermark[sourceKey]` — so `sourceKey` must equal the
472
+ * `domainPolicy` name for the mutations this authority confirms. The converse is NOT required:
473
+ * a domain may exist with no connected gate (`__testRelease` drives confirms gate-less); the
474
+ * live production path stays daemon-only until G calls this. */
475
+ connectSource(sourceKey: string, source: OptimisticSource): void;
476
+ /** Register the tables room `sourceKey` OWNS (its writable scope — 302 §2): each wire table
477
+ * gets its own namespaced ENGINE table (`{@link roomEngineTable}`), an ordinary tracked table
478
+ * whose sole authority is the room channel. From here on the channel's released deltas rename
479
+ * into these tables (wire tables outside the map are DROPPED — context stays daemon-owned,
480
+ * 302 §6), room-domain mutators stage onto them, and a room-homed view swaps onto them once
481
+ * the room sub hydrates ({@link processSwapIns}). Idempotent per (sourceKey, table); a wire
482
+ * table unknown to the schema is skipped (nothing to hold rows for). */
483
+ registerRoomTables(sourceKey: string, tables: readonly string[]): void;
484
+ /** The wire-table → engine-table map for room `sourceKey`'s owned tables (empty when none) —
485
+ * the client's idempotence check and `__realtimeInspect` read THIS record (one source of
486
+ * truth; the client keeps no shadow copy). */
487
+ roomTablesFor(sourceKey: string): ReadonlyMap<string, string>;
488
+ /** `channel` (G-iii registration-time routing) names the authority channel the remote sub
489
+ * registers on — a `connectSource`d gate key; default `"daemon"` (every existing caller is
490
+ * byte-identical). Slice G-v threads the lease's `realtime.sourceKey` here. Validated FIRST
491
+ * (like the E3 check below): a bad channel must throw before any per-query state is recorded. */
492
+ registerQuery(qid: QueryId, ast: Ast, remote?: RemoteQuery, channel?: string): void;
219
493
  /** Register every synthetic aggregate table `ast` needs that we haven't seen yet: on the
220
494
  * local engine (which auto-tracks it for the optimistic rebase loop), on `NormalizedSync`
221
495
  * (so its rows refcount/GC by group key), and into the source's expected-schema set (so
@@ -229,8 +503,140 @@ export declare class OptimisticBackend<S extends ColsMap> implements Backend {
229
503
  * `unregisterTable` frees it (the engine refuses otherwise). */
230
504
  private releaseSyntheticTables;
231
505
  unregisterQuery(qid: QueryId): void;
232
- retainRemoteQuery(qid: QueryId, remote: RemoteQuery, localQueryId?: QueryId, ast?: Ast): void;
506
+ /** `channel` as in {@link registerQuery} (G-iii): the gate the remote sub registers on; default
507
+ * `"daemon"`. This is the split-retain seam G-v's resolve-then-register drives — resolve the
508
+ * lease, learn `realtime.sourceKey`, `connectSource` it, then retain the query on that channel.
509
+ * Validated FIRST so a bad channel throws before any synthetic-table refcount moves. */
510
+ retainRemoteQuery(qid: QueryId, remote: RemoteQuery, localQueryId?: QueryId, ast?: Ast, channel?: string): void;
233
511
  releaseRemoteQuery(qid: QueryId): void;
512
+ /** The Slice I-iv upgrade retarget (§4.1 "Retarget" / the doorbell reaction): move a LIVE
513
+ * (name, args) sub — every retain of it and every local view it feeds, wholesale — from the
514
+ * channel it lives on onto `sourceKey`'s (already-`connectSource`d, already-promoted) room
515
+ * channel, WITHOUT the view ever dropping its rows. Returns the sub's wire `sourceQid` (the
516
+ * identity the client's renewal loop re-subscribes with).
517
+ *
518
+ * Why a dedicated primitive: the one-channel-per-(name,args) invariant ({@link retainRemote}'s
519
+ * loud throw) is correct — a sub's frames must never split across two cv timelines — so the
520
+ * upgrade cannot simply retain a second sub on the room and release the daemon one; and the
521
+ * naive release-then-retain order GCs the daemon sync's rows synchronously (net removes emit,
522
+ * the view flashes empty) a full ws round trip before the room's seq-0 snapshot refills it.
523
+ * The cutover is therefore TWO-PHASE around the room's first release:
524
+ *
525
+ * 1. NOW (here): unsubscribe the old channel's wire sub, sweep its still-buffered frames for
526
+ * this qid (their cv timeline continues without the sub — the hello-supersession
527
+ * precedent), flip `sub.channel`, re-arm `sub.hydrated` (the room's own snapshot is the
528
+ * cutover point), and register on the room source (its resolver presents the handed
529
+ * roomToken). The old gate's SYNC rows are deliberately NOT dropped: they keep the view's
530
+ * plain tables populated through the window — the view still reads them until the swap.
531
+ * 2. AT THE ROOM'S FIRST RELEASED SNAPSHOT: the reconcile folds the snapshot into the room's
532
+ * namespaced tables, the release tail SWAPS every local view onto them (302 §4.1,
533
+ * {@link processSwapIns} — the accepted-flash boundary), and {@link flushRetargetGc}'s
534
+ * deferred `dropQuery`+reconcile on the OLD gate then GCs the plain-table rows the sub
535
+ * alone referenced — invisible to the swapped views.
536
+ *
537
+ * Idempotent per target channel: a sub already on `sourceKey` returns immediately (the
538
+ * double-doorbell / re-entrancy guard — one retarget per (query, sourceKey)). Validates before
539
+ * mutating: a throw here leaves the sub fully daemon-attached (the client's fail-open). */
540
+ retargetRemoteQuery(remote: RemoteQuery, sourceKey: string): QueryId;
541
+ /** Phase 2 of {@link retargetRemoteQuery}, run at the end of every gate release: once a
542
+ * retargeted sub's first snapshot has RELEASED on its new channel (`sub.hydrated` re-armed at
543
+ * retarget, re-set by {@link markSubHydrated} inside this very release), drop the qid's rows
544
+ * from the OLD gate's sync and reconcile them out — after the room's rows are already applied,
545
+ * so the winner flip is value-equal (net-zero; see the phase table above). A sub torn down
546
+ * mid-window was already swept by `releaseRemoteQuery`/`unregisterQuery` (which delete the
547
+ * record); a vanished record here is pruned defensively. */
548
+ private flushRetargetGc;
549
+ /** The I-v downgrade orchestration primitive (§4.2/§7.4, re-expressed by 302 §4.2 as the
550
+ * SWAP-BACK GATE): retire room `sourceKey` behind the watermark fence. The caller has ALREADY
551
+ * retargeted every live sub off the channel ({@link retargetRemoteQuery} room→daemon —
552
+ * validated loudly below) and holds the fence from the api-server's downgrade response
553
+ * (`finalFlushSeq` = the room's last COMMITTED flush seq; `doc` keys the §4.2 watermark fold,
554
+ * {@link roomWatermarks}). Steps, in order:
555
+ *
556
+ * 1. **Disconnect** the channel ({@link disconnectSource}): handlers detached, gate + buffer
557
+ * dropped. `nextMid`/`watermark`/processed-outcomes for the domain are KEPT FOREVER (§7.1:
558
+ * an assigned mid pins its domain; a later re-upgrade of the same doc continues the
559
+ * sequence — {@link connectSource} attaches a fresh gate and the lmid snapshot max-folds
560
+ * into the surviving watermark). Disconnecting BEFORE the daemon sub's first release is
561
+ * load-bearing: it makes {@link flushRetargetGc}'s deferred old-channel GC a no-op (gate
562
+ * gone ⇒ record deleted, nothing dropped). The room's namespaced tables — and the views
563
+ * swapped onto them — deliberately stay: frozen at the room's last state, they keep the
564
+ * document visible while the falling-back follower may still lack the final flush.
565
+ * Swapping back earlier would show its pre-flush images — the regression §4.2 prevents.
566
+ * 2. **Ghost + first evaluation**: the record joins {@link ghosts} and is evaluated once
567
+ * immediately — `finalFlushSeq === 0` (a never-flushed room) with no room-domain pending
568
+ * drops on the spot, the single-daemon first-frame case.
569
+ *
570
+ * In-flight discipline (§7.5): entries with `mid !== null` on `sourceKey` stay PINNED (rule
571
+ * 2 — never re-route a sent mutation); their resolution arrives via the daemon-carried
572
+ * ledger+outcome folds (I-iii) and blocks the drop until then. Idempotent per sourceKey (a
573
+ * second labeled query sharing the room demotes into the existing ghost). */
574
+ demoteRoomSource(sourceKey: string, doc: string, finalFlushSeq: number): void;
575
+ /** Detach one connected room channel (Slice I-v step 3): the source's handlers are replaced
576
+ * with no-ops (the {@link OptimisticSource} handler seam is single-registration, so this IS
577
+ * the detach — a late frame from a dying socket can no longer touch any bookkeeping), its
578
+ * reserved lmid sub is unregistered, and the gate — buffer, per-source sync, cv watermark —
579
+ * is dropped from {@link gates}. The DOMAIN state deliberately survives forever:
580
+ * `nextMid[sourceKey]`, `watermark[sourceKey]`, and the processed-outcome set are untouched
581
+ * (§7.1 — an assigned mid pins its domain; a re-upgrade must continue, never restart, the mid
582
+ * sequence; {@link connectSource} then attaches a fresh gate whose lmid snapshot max-folds
583
+ * into the surviving watermark via {@link foldConfirm}). Closing the underlying transport is
584
+ * the caller's job. Idempotent (a missing gate is a no-op). */
585
+ disconnectSource(sourceKey: string): void;
586
+ /** Register the I-v stuck-downgrade sink — see {@link DowngradeStuckEvent}. One handler (a
587
+ * later registration replaces it, the {@link onScopeSessions} convention); client.ts maps it
588
+ * onto the loud anomaly surface. */
589
+ onDowngradeStuck(handler: (event: DowngradeStuckEvent) => void): void;
590
+ /** Register the 302 §6.1 context-coverage sink — see {@link RoomContextJoinEvent}. One handler
591
+ * (a later registration replaces it, the {@link onScopeSessions} convention); client.ts maps
592
+ * it onto the loud anomaly surface. */
593
+ onRoomContextJoin(handler: (event: RoomContextJoinEvent) => void): void;
594
+ /** The I-v ghost-drop watcher (§4.2), run after every applied release ({@link applyRelease} —
595
+ * the seam where {@link roomWatermarks} has just folded and the confirm-drop has just run) and
596
+ * once at demote time. For each ghost: the fence must be satisfied
597
+ * (`roomWatermarks[doc] ≥ finalFlushSeq`; 0 is trivially satisfied) AND no SENT room-domain
598
+ * pending may remain (§7.5 — such entries resolve only through the daemon-carried
599
+ * outcome/ledger folds; an entry that never reached the room is undecidable, so the ghost
600
+ * HOLDS and the stuck event fires exactly once, naming the mids). Both satisfied ⇒
601
+ * {@link dropGhost}. */
602
+ private evaluateGhosts;
603
+ /** Drop one cleared ghost — the 302 §4.2 SWAP-BACK: under the fence the daemon tables are
604
+ * value-equal-or-ahead of the room's final state, so (1) every view swapped onto the room's
605
+ * namespaced tables re-registers on its ORIGINAL (daemon-table) AST — visually a no-op, the
606
+ * Store folds the re-hello as an in-place reset; (2) the namespaced tables unregister (no
607
+ * reader is left after the swap); (3) ONE daemon reconcile re-invokes the pending set so any
608
+ * entry whose writes had staged onto the now-gone room tables re-stages onto the daemon tables
609
+ * (its domain policy stopped naming the dead room when the client dropped it). The whole drop
610
+ * runs under one commit boundary so the swap and the re-staged predictions notify as ONE step.
611
+ * After this, a FUTURE upgrade of the same doc registers again from scratch. */
612
+ private dropGhost;
613
+ /** Unregister room `sourceKey`'s namespaced engine tables and drop the {@link roomTables}
614
+ * record. Callers must have no view registered on them (the engine refuses otherwise —
615
+ * loud by design). No-op for an unknown sourceKey. */
616
+ unregisterRoomTables(sourceKey: string): void;
617
+ /** Retain one minted SYSTEM subscription (RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md §4, Slice
618
+ * I-iii): a wire sub with NO store view and NO user-visible table. Registered through the same
619
+ * {@link RemoteSub} bookkeeping as any remote retain — so qid→channel ownership, the overflow
620
+ * re-subscribe, and refcounted release all work unchanged — but with an EMPTY `localQids` set
621
+ * (no hydration/resultType coupling) and a {@link systemQids} record telling the release path
622
+ * which system table this qid's frames carry (`spec.table`) and which scope/doc it was minted
623
+ * for (the fold's row filter). Its frames then buffer on the channel's gate exactly like
624
+ * {@link LMID_QID}'s and fold at RELEASE time in {@link foldSystemFrames} — riding the SAME
625
+ * buffered cv path as the data they co-committed with (fence coherence: an out-of-band
626
+ * shortcut would break I-ii's co-commit ordering guarantee).
627
+ *
628
+ * `channel` defaults to `"daemon"` — the system tables live in the DAEMON store (that is the
629
+ * point: outcome/ledger/watermark rows must be readable with no room socket alive, §7.1
630
+ * "load-bearing for §7.5"). Idempotence per (table, scope/doc) is the CALLER's job (client.ts
631
+ * keys its retains on exactly that); a duplicate retain of the SAME remote identity refcounts
632
+ * like any sub. */
633
+ retainSystemQuery(retainQid: QueryId, remote: RemoteQuery, spec: SystemStreamSpec, channel?: string): void;
634
+ /** Release a {@link retainSystemQuery} retain. Refcounted like any sub; the LAST release
635
+ * unregisters from the owning channel, sweeps its buffered frames, and drops the
636
+ * {@link systemQids} record. The folded lifecycle STATE (`roomWatermarks`/`scopeSessions`/
637
+ * processed outcomes) deliberately survives — the fence is monotone truth about the store, not
638
+ * about the subscription (a re-retained fence must not forget a cleared watermark). */
639
+ releaseSystemQuery(retainQid: QueryId): void;
234
640
  /** Raw CRUD has no optimistic story (§9 replaces it with named mutators). Register a
235
641
  * mutator — even a trivial one — and `invoke` it. */
236
642
  mutate(_mutations: Mutation[]): Promise<void>;
@@ -276,10 +682,51 @@ export declare class OptimisticBackend<S extends ColsMap> implements Backend {
276
682
  * state (read-your-writes), the SAME body the API server drives asynchronously. `ctx.user` is
277
683
  * the acting principal (re-read per invoke, stable across a rebase re-invoke). */
278
684
  private runMutator;
685
+ /** Deal the next wire mid from `domain`'s ledger (RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md
686
+ * §7.1) and advance that counter. A domain absent from the map starts at 1. Per-domain, so a
687
+ * client writing through room + daemon concurrently keeps two gapless, non-aliasing sequences.
688
+ * The client-global `seq` is stamped in the same breath — the ONE cross-domain total order
689
+ * (confirmation order is per-domain; replay order is client-global). Bundled here so no call
690
+ * site can deal a mid without its seq. ONE caller discards the seq deliberately: the H-v deopt
691
+ * flip ({@link handleMutationOutcome}) keeps the entry's ORIGINAL seq — its replay position —
692
+ * and takes only the fresh mid (the dealSeq bump is harmless: seq consumers order, never
693
+ * count). */
694
+ private dealMid;
695
+ /** The declared confirming stream for one invocation: the `domainPolicy`'s verdict, `"daemon"`
696
+ * when it abstains. Resolved BEFORE the prediction runs — the domain picks the staging map
697
+ * (a room domain stages its owned tables onto the room's namespaced twins). */
698
+ private resolveDomain;
699
+ /** The staging table map for a `domain`-routed prediction ({@link trackingTx}'s `stage`):
700
+ * wire table → the room's namespaced engine table for the tables the room owns; identity for
701
+ * everything else (including the whole map for the daemon domain). */
702
+ private stagingMap;
703
+ /** The PLAIN (daemon-homed) engine AST for `ast` — aggregate relationships rewritten to their
704
+ * synthetic `__agg_*` reads, no room renames. The ONE form every non-swapped engine
705
+ * registration uses ({@link registerQuery}, {@link dropGhost}'s swap-back) and the base the
706
+ * swap-in renames ({@link processSwapIns}). */
707
+ private plainEngineAst;
708
+ /** Mutator names the cross-authority warn below already fired for (once per name). */
709
+ private readonly warnedCrossAuthority;
710
+ /** 302 §5.1 dev-time guard: a room-DECLARED mutator wrote tables the room does not own. Those
711
+ * writes staged onto the PLAIN daemon tables (the staging map covers only owned tables), but
712
+ * the entry confirms on the ROOM stream — and only the room's OWNED tables flush back to the
713
+ * daemon, so nothing upstream ever echoes them: once the room confirm retires the entry, the
714
+ * next release's whole-store rewind reverts them for good. The first-party room shell refuses
715
+ * such a mutation (the §3.3 deopt/reject backstop re-routes it to the daemon), so this warns
716
+ * for the shapes where that backstop may be absent (a BYO relay) — loud, once, soft (§5.1:
717
+ * misdeclarations never throw). */
718
+ private warnCrossAuthorityWrites;
279
719
  /** Run the named client mutator optimistically: the prediction applies to the live
280
720
  * engine now (affected views update synchronously), `(mid, name, args)` joins the
281
721
  * pending stack, and the envelope ships upstream. Returns the assigned `mid`. */
282
722
  invoke(name: string, args: unknown): number;
723
+ /** {@link invoke} with an optional PINNED confirming domain (H-v): the deopt handshake's
724
+ * already-retired arm re-invokes the frame's echoed `(name, args)` as a FRESH invocation pinned
725
+ * to `"daemon"` — an honest re-prediction on the current base, never derived (`pin` bypasses
726
+ * {@link resolveDomain} entirely, so the router never runs and no Q6 counter moves). Every
727
+ * other step is `invoke` verbatim: prediction now, capture, drainOverlapping, mid dealt from
728
+ * the pinned domain's ledger, envelope on its channel. */
729
+ private invokeWith;
283
730
  /** Run a FOLDED invoke (FOLDED-MUTATIONS-DESIGN §8): apply the prediction to the live engine now
284
731
  * (like `invoke`), but collapse a run of same-key invokes into ONE pending entry whose `args`
285
732
  * are overwritten in place, debounce the server write, and ship only the last value. The `mid`
@@ -294,6 +741,66 @@ export declare class OptimisticBackend<S extends ColsMap> implements Backend {
294
741
  * by construction), stamp the entry, ship the envelope with the LATEST args, resolve the handle.
295
742
  * The entry stays on `pendingMutations` (now with a real mid) until the lmid release confirms it. */
296
743
  private flushFold;
744
+ /** The transport a `domain`-confirmed mutation ships on (§7.5 sent-pins-domain: only the
745
+ * domain's own authority can confirm it, so its channel is the only correct transport). A
746
+ * domain with NO connected gate ships on the daemon channel — the gate-less configurations
747
+ * (`__testRelease`-driven tests) and today's entire live path resolve `"daemon"` anyway. */
748
+ private channelFor;
749
+ /** The gate a channel-keyed retain registers through (G-iii registration-time routing). The
750
+ * channel MUST already be connected (`connectSource`; the daemon is constructor-attached) —
751
+ * loud by design: a typo'd or not-yet-connected sourceKey must throw at retain time, never
752
+ * silently register on the daemon and split the query's frames across channels. */
753
+ private requireGate;
754
+ /** The channel that owns `sourceQid` — {@link RemoteSub.channel}, the ONE source of truth for
755
+ * qid routing (G-iii). `undefined` when no sub owns the qid (a harness-delivered raw feed, or
756
+ * a just-released sub): such frames buffer on whatever gate they arrive at. */
757
+ private channelOf;
758
+ /** Record `(domain, mid)` as processed; `false` if it already was (a duplicate frame —
759
+ * ignore it). FIFO-capped per domain ({@link MAX_PROCESSED_OUTCOMES_PER_DOMAIN}). */
760
+ private markOutcomeProcessed;
761
+ /** One `mutationOutcome` frame from `domain`'s channel (H-v — the §3.3 handshake's client
762
+ * half). The frame arrives OUT-OF-BAND (see {@link attachGate}); the state machine:
763
+ *
764
+ * 1. `mid` never issued on `domain` ⇒ ignore (a confused/foreign frame must not invent work).
765
+ * 2. `(domain, mid)` already processed ⇒ ignore — idempotence under duplicate frames (the
766
+ * original + a re-send's re-answer; a deopt for a mid whose entry ALREADY FLIPPED also
767
+ * lands here harmlessly on its second frame).
768
+ * 3. `kind:"rejected"` ⇒ FINAL. Surface the reason through {@link rejectedHandler} (room-plane
769
+ * parity with the HTTP queue's callback) and STOP — the drop + snap-back is the EXISTING
770
+ * failed-mutation machinery: the room burnt the mid, its lmid release retires the entry
771
+ * per-domain and the reconcile rewinds the prediction, exactly the daemon path's
772
+ * processed-as-no-op rejection. No new drop path.
773
+ * 4. `kind:"deopt"`, entry found (pending `(domain, mid)`) ⇒ FLIP IN PLACE: `domain` becomes
774
+ * `"daemon"`, a fresh daemon mid is dealt and the envelope ships NOW on the daemon channel
775
+ * ("deal-and-send-now" — the conforming §3.3 re-enqueue: there is no flush machinery for
776
+ * non-fold entries, so the design's "mid: null until the daemon flush" is satisfied
777
+ * momentarily inside this call). THE ENTRY'S `seq` IS KEPT — settled (§5.3, commit
778
+ * 68141096): `seq` is the client-global REPLAY order; re-sequencing would move the entry's
779
+ * overlay position and change read-dependent SIBLINGS' replay base. Everything else stays
780
+ * (writes/reads/touched/touchedSources/writeSources — union-never-shrink), the prediction
781
+ * stays applied (the entry never leaves `pendingMutations`, so no rewind fires), and the
782
+ * router does NOT re-run nor does `drainOverlapping` (§3.3 re-enqueues, never re-derives;
783
+ * any open overlapping fold was invoked later and flushes later with a larger mid).
784
+ * 5. `kind:"deopt"`, entry NOT found ⇒ the burnt-mid confirm won the race, or the frame is a
785
+ * replay re-answer for an entry a previous session retired (the replay gotcha): re-invoke
786
+ * the frame's echoed `name`/`args` as a FRESH invocation PINNED to `"daemon"` — an honest
787
+ * re-prediction on the current base, never a derived route ({@link invokeWith}). A frame
788
+ * without `name` (not self-contained) has nothing to re-invoke and is dropped; a re-invoke
789
+ * that THROWS (the base moved from under it) is surfaced through {@link rejectedHandler} —
790
+ * the mutation is dead with no stream left to confirm it.
791
+ *
792
+ * A `"deopt"` bump joins the Q6 routing counters either way (`routing.reasons.deopt`) —
793
+ * derived-and-deopted routes are visible beside derived successes. */
794
+ private handleMutationOutcome;
795
+ /** §7.5 rule 3 (H-v): re-send `domain`'s unconfirmed pending envelopes with their ORIGINAL
796
+ * mids, in mid order, on the domain's own channel. Folds with `mid === null` are excluded —
797
+ * nothing was ever sent for them (the flush deals their mid). Envelopes are reconstructed from
798
+ * the pending entries exactly as `invoke` shipped them (`clientID`/`mid`/`name`/`args` —
799
+ * entries carry everything the wire needs). Idempotent under the domain's ledger: an APPLIED
800
+ * mid dedups silently and its lmid coverage retires the entry; a NON-APPLIED mid is re-answered
801
+ * from the shell's recorded-outcome map into {@link handleMutationOutcome}. Confirmed entries
802
+ * are already gone from `pendingMutations`, so no filter against the watermark is needed. */
803
+ private resendPending;
297
804
  /** Drain every outstanding fold immediately (FOLDED-MUTATIONS-DESIGN §3): the explicit
298
805
  * `app.flushFolds()` and the `beforeunload`/`close` hook. Creation (insertion) order. */
299
806
  flushFolds(): void;
@@ -326,17 +833,159 @@ export declare class OptimisticBackend<S extends ColsMap> implements Backend {
326
833
  * §4.1). Built fresh per call from state the backend already holds — no new instrumentation, no
327
834
  * mutation. Only ever called by `@rindle/devtools` (imported in dev). */
328
835
  __inspect(): OptimisticInspect;
836
+ /** Test-only per-domain ledger snapshot (RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md §7.1/§8.5).
837
+ * Kept separate from {@link __inspect} so the devtools `OptimisticInspect` mirror stays byte-for-
838
+ * byte identical (daemon-scalar-only). Exposes the per-domain `nextMid`/`watermark` maps plus each
839
+ * pending entry's confirming domain — the axes the §8.5 ledger-isolation assertion checks. */
840
+ __inspectDomains(): {
841
+ nextMid: Record<string, number>;
842
+ watermark: Record<string, number>;
843
+ /** Per connected CHANNEL (§5.1): its release watermark + buffered-frame depth — the axis the
844
+ * gate-isolation assertions read (one source's laggy cvMin must never move the other's). */
845
+ gates: Record<string, {
846
+ appliedCv: number;
847
+ bufferedFrames: number;
848
+ }>;
849
+ /** Per connected/registered room: its wire-table → engine-table map (302 §2) and which local
850
+ * view qids are currently swapped onto it (302 §4). */
851
+ roomTables: Record<string, Record<string, string>>;
852
+ swappedViews: Record<number, string>;
853
+ /** The §4 lifecycle plane's folded state (Slice I-iii introspection): the per-doc §4.2 fence
854
+ * value (`roomWatermarks`, I-v's ghost-drop input), the per-scope §4.1 occupancy map
855
+ * (`scopeSessions`: scope → client_id → expires_at, I-iv's doorbell input), and the live
856
+ * I-v ghosts (demoted room sources still awaiting their swap-back fence). */
857
+ lifecycle: {
858
+ roomWatermarks: Record<string, number>;
859
+ scopeSessions: Record<string, Record<string, number>>;
860
+ ghosts: Record<string, {
861
+ doc: string;
862
+ finalFlushSeq: number;
863
+ }>;
864
+ };
865
+ pending: {
866
+ mid: number | null;
867
+ seq: number | null;
868
+ name: string;
869
+ domain: string;
870
+ }[];
871
+ };
329
872
  /** Recompute the pending axis for every query and fire `onPending` on transitions only. Called
330
873
  * from the two points that move the pending set: invoke/invokeFolded (add) and the confirm-drop
331
874
  * (remove) — exactly where `:359`/`:468` used to flip ResultType (§7.3). */
332
875
  private refreshPending;
333
- private onNormalized;
876
+ private onFrame;
334
877
  private emitServerDelta;
335
878
  private localQidsForSource;
336
- private onProgress;
337
- /** Fold the lmid system query's released ops (lmid-as-data): the one row's
338
- * `last_mutation_id` cell is this client's confirmed high-water mid. */
879
+ /** One gate's release (§5.1 release gate): compute the coherent delta from THIS gate's cv-buffer,
880
+ * then apply it against the gate's source/domain. Split into {@link computeRelease} (buffer →
881
+ * delta, lmid watermark) and {@link applyRelease} (per-source confirm-drop + reconcile) —
882
+ * N independent gates all feed the ONE apply half; {@link __testRelease} drives it directly. */
883
+ private onGateProgress;
884
+ /** Compute one coherent release from ONE gate's cv-buffer (§5.1) — gate-scoped: its buffer, its
885
+ * cvMin timeline. Take every buffered frame at `cv ≤ cvMin`, in (cv, arrival) order, and fold
886
+ * it: the lmid system-query frame advances `watermark[gate.key]` (via {@link foldLmidOps} — the
887
+ * daemon stream folds "daemon", a room stream folds its own domain); data frames fold through
888
+ * this SOURCE's cross-query refcount into ONE net base delta — the §1.3 `D`. Returns that delta
889
+ * plus the set of local views this release JUST hydrated (so their reconcile batch phases as a
890
+ * `snapshot`). Mutates the gate's buffer/`appliedCv`, hydration, and the gate's domain
891
+ * watermark; the pending set and the reconcile are {@link applyRelease}'s job. */
892
+ private computeRelease;
893
+ /** Apply one released delta against `sourceKey`'s domain (§7.2 per-domain confirm-drop + the §1.3
894
+ * reconcile cycle). `watermarkUpdate`, when given, advances `watermark[sourceKey]` first — the
895
+ * hook a per-source lmid confirm rides on (the daemon path folds its watermark in
896
+ * {@link computeRelease} and passes `undefined`). Then: drop every pending entry its OWN domain's
897
+ * watermark now covers (a room confirm can never retire a daemon entry, and vice-versa — the §7.1
898
+ * ledger-collision fix), and run the reconcile cycle against `sourceKey` when the base delta or the
899
+ * pending set changed. `newlyHydrated` stamps the initial-hydration batch as a catch-up. */
900
+ private applyRelease;
901
+ /** Test-only per-source release seam (RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md §7.2/§8.5): drive
902
+ * {@link applyRelease} for `sourceKey` directly — an explicit `watermarkUpdate` (a simulated lmid
903
+ * confirm for that domain) and `deltas` (a coherent base delta), with no real gate. Lets a harness
904
+ * exercise a room-domain confirm before the real second lmid stream / per-source gate is wired
905
+ * (E-iii-b/c). The `__`-prefix marks it a test hook, alongside {@link __inspect}. */
906
+ __testRelease(sourceKey: string, deltas: Mutation[], watermarkUpdate?: number): void;
907
+ /** Swap every view of each just-hydrated ROOM sub onto the room's namespaced tables (302 §4.1):
908
+ * re-register the local engine query with the AST's room-owned table references renamed
909
+ * ({@link remapAstTables}); the Store folds the re-hello as an in-place reset, so the caller's
910
+ * view reference survives and subscribers see ONE transition. Runs at the applyRelease tail —
911
+ * the reconcile has already folded the sub's snapshot into the room tables, so the swapped
912
+ * view hydrates straight to the room state (swapping earlier would flash it empty). The
913
+ * ORIGINAL ast stays in {@link asts}; the swap-back ({@link dropGhost}) re-registers it.
914
+ *
915
+ * This is the accepted-flash boundary (302 §4.1/§7.1): the room's copy may be behind the
916
+ * daemon rows the view showed a moment ago — accepted by decision, revisit on a real
917
+ * two-region deploy. */
918
+ private processSwapIns;
919
+ /** Fold `domain`'s lmid system query's released ops (lmid-as-data): the one row's
920
+ * `last_mutation_id` cell is this client's confirmed high-water mid in that domain — it advances
921
+ * `watermark[domain]` and, on a fresh session ahead of our issued mids, `nextMid[domain]`. The
922
+ * daemon stream folds `"daemon"`; a room stream folds its own `"room:doc:X"`; the daemon-carried
923
+ * §7.1 ledger rows fold through the same {@link foldConfirm} core (Slice I-iii). */
339
924
  private foldLmidOps;
925
+ /** THE one confirm fold (§7.1/§7.2): advance `watermark[domain]` to `lmid` (monotone max) and,
926
+ * on a fresh session ahead of our issued mids, adopt `nextMid[domain]`. Shared verbatim by the
927
+ * per-channel lmid system query ({@link foldLmidOps}) and the daemon-carried room-ledger rows
928
+ * ({@link foldSystemFrames} — one core so the two paths cannot drift). */
929
+ private foldConfirm;
930
+ /** Fold one release's SYSTEM frames in a FIXED category order — the order is STRUCTURAL (one
931
+ * function, categories in sequence), because it is the client half of THE NAMED INVARIANT
932
+ * (§3.3's shipped note; documented above {@link handleMutationOutcome}): **never retire a
933
+ * room-domain entry off a daemon-carried lmid without outcome resolution.**
934
+ *
935
+ * 1. **outcome rows** (`_rindle_room_mutation_outcomes`) — each row for OUR clientID is
936
+ * synthesized into a {@link MutationOutcomeFrame} and routed through
937
+ * {@link handleMutationOutcome}, the SAME H-v state machine the room socket's frames use
938
+ * (one verdict path: frames and rows cannot drift). A deopt flips its pending entry to
939
+ * the daemon IN PLACE (keep-seq, deal-and-send-now); a rejection surfaces + stays for the
940
+ * ordinary burnt-mid retire; a duplicate (frame already seen, or the row re-delivered) is
941
+ * absorbed by the processed set — which doubles as the resolved-verdict memory across
942
+ * releases (per-domain FIFO, {@link MAX_PROCESSED_OUTCOMES_PER_DOMAIN}, mirroring the
943
+ * shell's recorded-outcome cap).
944
+ * 2. **room-ledger rows** (`_rindle_room_client_mutations`) — the FIRST daemon-carried
945
+ * room-lmid path: OUR row's `last_mutation_id` folds into `watermark[room:<doc>]` via
946
+ * {@link foldConfirm}. Because step 1 ALREADY resolved every non-applied verdict this
947
+ * release carries (and earlier releases' verdicts were resolved at their own release),
948
+ * the confirm-drop that follows in {@link applyRelease} retires only entries whose
949
+ * outcome is resolution-by-absence — which I-ii's co-commit atomicity defines as APPLIED
950
+ * (a room flush co-commits the ledger row and every non-applied mid's outcome row in ONE
951
+ * daemon transaction, so a covering lmid without a row IS the applied verdict).
952
+ * Processing this category before step 1 is the violation, in two proven directions
953
+ * (each run break→fail→revert against `test/system_streams.test.ts`): (a) the ledger's
954
+ * fresh-session `nextMid` ADOPTION must not run before historical outcome rows are
955
+ * judged — adopted-first, a previous session's retained deopt row passes the
956
+ * "never-issued" guard and spuriously re-invokes a mutation that session already handled
957
+ * (a double-apply); (b) the RETIRE must not precede resolution — it does not BECAUSE the
958
+ * confirm-drop runs in {@link applyRelease}, strictly after this whole function. That
959
+ * deferral is load-bearing: an "optimization" retiring inline with the watermark fold
960
+ * retires a deopted entry as a silent success (the exact lost-write H-v exists to
961
+ * prevent) and mis-attributes a rejected row's reason.
962
+ * 3. **watermark rows** (`_rindle_room_watermark`) — the §4.2 fence value, max-folded per
963
+ * doc ({@link roomWatermarks}); I-v's ghost-drop consumer, no reaction here.
964
+ * 4. **scope-session rows** (`_rindle_scope_sessions`) — the §4.1 occupancy map
965
+ * ({@link scopeSessions}); I-iv's doorbell consumer, no reaction here.
966
+ *
967
+ * Ordinary data ops fold AFTER all of these (the caller's main loop) — outcome/ledger state
968
+ * must be in place before {@link applyRelease}'s confirm-drop + reconcile consume the release.
969
+ * Every row is filtered against the retain's {@link SystemStreamSpec} scope/doc AND (for the
970
+ * client-keyed tables) our own `clientID` — defense in depth: the server predicate may have
971
+ * been minted doc-only (no `clientId` at lease time), so other clients' rows are expected and
972
+ * must be ignored, and a row for a doc this retain was not minted for is never folded.
973
+ *
974
+ * Returns the scopes category 4 touched (snapshot or ops) — the I-iv doorbell events' input;
975
+ * `null` when none (every non-lifecycle release). The events themselves fire from
976
+ * `onGateProgress` AFTER the release applies, never from inside the fold. */
977
+ private foldSystemFrames;
978
+ /** The I-iv occupancy count — THE one rule (§4.1/D7): unexpired (`expires_at >` the fold
979
+ * clock's now) sessions under `scope` from OTHER clientIDs. Shared by the doorbell events
980
+ * ({@link onGateProgress}) and the client's registration-time check (a doorbell that folded
981
+ * BEFORE a candidate registered must still be able to trigger it) so the two can never
982
+ * disagree. Own-clientID rows never count — a solo client cannot ring its own bell — and
983
+ * expiry is judged on the injectable {@link FoldClock} (deterministic in a virtual-clock
984
+ * harness, the folded-oracle discipline). */
985
+ otherScopeSessions(scope: string): number;
986
+ /** Register the I-iv doorbell event sink — see {@link ScopeSessionsEvent}. One handler (a later
987
+ * registration replaces it, the {@link onLocalWrite} convention); client.ts is the consumer. */
988
+ onScopeSessions(handler: (event: ScopeSessionsEvent) => void): void;
340
989
  /** One §1.3 reconcile cycle: rewind the optimistic layer and fold the coherent SERVER
341
990
  * delta into BOTH head AND the `sync` baseline (`serverBatchBegin`), re-invoke every
342
991
  * still-pending mutator to re-stage the optimistic layer (the rewind un-applied it), then
@@ -344,29 +993,49 @@ export declare class OptimisticBackend<S extends ColsMap> implements Backend {
344
993
  * boundary — `onProgress` releases and `unregisterQuery`'s GC both go through here so head
345
994
  * and sync never diverge (the §1.2 invariant; CRIT#2). */
346
995
  private runReconcileCycle;
347
- /** The daemon restarted (a new boot id): it lost all materialization + `cv` state and its `cv`
348
- * sequence reset, so previously-released `cv`s no longer bound the new stream. The source has
349
- * already re-subscribed every query (reconnect → resync); drop the buffer and the `cv`
350
- * watermark so the fresh, low-`cv` snapshots are RELEASED instead of dropped as stale
351
- * (`onNormalized`/`onProgress` gate on `appliedCv`). Pending optimistic mutations stay put
352
- * they re-apply on the next reconcile, and the lmid system query's fresh snapshot restores the
353
- * confirmation watermark. */
354
- private resetForRestart;
355
- /** The §8.5 escape: the buffer outgrew its cap (a pinned `cvMin` under churn). Drop
356
- * everything buffered and re-register every query on the source the fresh
357
- * snapshots arrive as ordinary frames and the next release re-hydrates via the
358
- * footprint diff (the §5.3 path); still-pending optimism re-applies in that cycle. */
996
+ /** ONE channel's authority restarted (a new boot id): it lost all materialization + `cv` state
997
+ * and its `cv` sequence reset, so previously-released `cv`s no longer bound the new stream. The
998
+ * source has already re-subscribed every query (reconnect → resync); drop THIS gate's buffer
999
+ * and `cv` watermark so the fresh, low-`cv` snapshots are RELEASED instead of dropped as stale
1000
+ * (`onFrame`/`computeRelease` gate on `appliedCv`). The OTHER gates are untouched an
1001
+ * authority restart is per-channel (§5.1). Pending optimistic mutations stay put they
1002
+ * re-apply on the next reconcile, and the channel's lmid system query's fresh snapshot restores
1003
+ * its domain's confirmation watermark. */
1004
+ private resetGate;
1005
+ /** The §8.5 escape: ONE gate's buffer outgrew its cap (a pinned `cvMin` under churn on that
1006
+ * channel). Drop everything it buffered and re-register every query on that source — the fresh
1007
+ * snapshots arrive as ordinary frames and the next release re-hydrates via the footprint diff
1008
+ * (the §5.3 path); still-pending optimism re-applies in that cycle. The other gates' buffers
1009
+ * and subscriptions are untouched. */
359
1010
  private overflow;
360
1011
  private setResultType;
361
1012
  /** Recompute a query's server-channel state from hydration alone (§7): a pending mutation no
362
1013
  * longer affects it. Used when a remote sub attaches to or hydrates a local view. */
363
1014
  private recomputeResultType;
364
1015
  /** A remote sub's first snapshot landed: mark it (and every local view it feeds) hydrated, then
365
- * lift those views out of `unknown` (loading). Idempotent a re-hydrate snapshot re-marks
366
- * harmlessly; a source qid with no sub (the lmid system query) is a no-op. */
1016
+ * lift those views out of `unknown` (loading). A ROOM sub's hydration additionally queues the
1017
+ * 302 §4.1 swap-in performed at the applyRelease TAIL ({@link processSwapIns}), once the
1018
+ * reconcile has folded this snapshot into the room tables. Idempotent — a re-hydrate snapshot
1019
+ * re-marks harmlessly; a source qid with no sub (the lmid system query) is a no-op. */
367
1020
  private markSubHydrated;
1021
+ /** `channel` (G-iii registration-time routing): the gate the sub registers on — the qid's
1022
+ * ownership is fixed HERE, at retain time (no lazy claim; `onFrame` only asserts it). Default
1023
+ * `"daemon"`, so every channel-less caller is byte-identical to before. */
368
1024
  private retainRemote;
369
1025
  private releaseRemote;
370
1026
  private addServerDependencyTables;
371
1027
  }
1028
+ /** The namespaced ENGINE table backing wire `table` for room `sourceKey` (302 §2: `room_deck` ≠
1029
+ * `deck` — one authority per table). `@` appears in no schema table name — ENFORCED by
1030
+ * `createSchema`/`extendSchema`'s addTableMeta ban (packages/client/src/schema.ts), so the name
1031
+ * cannot collide with a real table. */
1032
+ export declare function roomEngineTable(table: string, sourceKey: string): string;
1033
+ /** Rename every TABLE reference in a query AST through `map` (302 §2 point 3 — the room-homed
1034
+ * view's rewrite): the root `table`, every `related` subquery, every `correlatedSubquery`
1035
+ * (EXISTS) condition — walking the KNOWN wire-AST shape, never a blind key scan: `start.row` is
1036
+ * keyed by COLUMN name (a schema column literally named `table` must keep its bound value), and
1037
+ * the same goes for any future column-keyed record. Tables absent from the map keep their name —
1038
+ * that is the client-side join across kinds (a room table joined to daemon-owned context,
1039
+ * 201-style). Structural clone; the input AST is never mutated. */
1040
+ export declare function remapAstTables(ast: Ast, map: ReadonlyMap<string, string>): Ast;
372
1041
  //# sourceMappingURL=backend.d.ts.map