@rindle/optimistic 0.5.0 → 0.6.4
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/README.md +1 -1
- package/dist/backend.d.ts +136 -323
- package/dist/backend.d.ts.map +1 -1
- package/dist/backend.js +445 -829
- package/dist/backend.js.map +1 -1
- package/dist/client-id.d.ts +5 -0
- package/dist/client-id.d.ts.map +1 -1
- package/dist/client-id.js +45 -0
- package/dist/client-id.js.map +1 -1
- package/dist/client.d.ts +21 -6
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +91 -40
- package/dist/client.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +6 -6
- package/src/backend.ts +481 -1031
- package/src/client-id.ts +44 -0
- package/src/client.ts +124 -51
- package/src/index.ts +5 -5
package/src/backend.ts
CHANGED
|
@@ -60,6 +60,7 @@ import type {
|
|
|
60
60
|
ColsMap,
|
|
61
61
|
ColType,
|
|
62
62
|
Condition,
|
|
63
|
+
CorrelatedSubquery,
|
|
63
64
|
IsoTx,
|
|
64
65
|
KeyedRow,
|
|
65
66
|
Mutation,
|
|
@@ -82,7 +83,7 @@ import type {
|
|
|
82
83
|
WireValue,
|
|
83
84
|
} from "@rindle/client";
|
|
84
85
|
import { aggTableSchemas, NormalizedSync, rewriteAggregates, type ColCounts, type PkCols } from "@rindle/normalized";
|
|
85
|
-
import { WasmBackend, type ServerDeltaOp, type WasmWriteTxn
|
|
86
|
+
import { WasmBackend, type ServerDeltaOp, type WasmWriteTxn } from "@rindle/wasm";
|
|
86
87
|
|
|
87
88
|
import { AggOverlay, type ChildOp, collectAggDefs } from "./agg-overlay.ts";
|
|
88
89
|
import {
|
|
@@ -170,16 +171,13 @@ const LMID_QID: QueryId = 0;
|
|
|
170
171
|
* the post-write image (positional wire cells, schema column order); `undefined` for a `remove` —
|
|
171
172
|
* no row survives it, and `row === undefined` stays the remove marker.
|
|
172
173
|
*
|
|
173
|
-
* `oldRow` is the PRE-IMAGE, captured for a `remove`
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
* H-i's `provenanceOf` routes by). A captured remove/edit thus always carries a full-width
|
|
181
|
-
* pre-image (the engine width-checks `holdBackAbsent`; Slice H evaluates writable predicates on
|
|
182
|
-
* it) — with ONE exception: a record that collapses to a (re-)insert has none, see the matrix.
|
|
174
|
+
* `oldRow` is the PRE-IMAGE, captured for a `remove` AND an `edit` (H-ii): the full-width row
|
|
175
|
+
* read via `tx.get` immediately BEFORE the write staged — read-your-writes, so a write to the
|
|
176
|
+
* same pk earlier in the SAME invocation shows through — falling back to the caller's asserted
|
|
177
|
+
* old row when the pk is not txn-visible (a raw remove/edit of an absent row; incl. the
|
|
178
|
+
* pk-MOVING raw edit, whose record is keyed by the NEW pk yet whose pre-image is the caller's
|
|
179
|
+
* OLD row). A captured remove/edit thus always carries a full-width pre-image — with ONE
|
|
180
|
+
* exception: a record that collapses to a (re-)insert has none, see the matrix.
|
|
183
181
|
*
|
|
184
182
|
* Coalescing within one invocation is last-write-wins per pk on `row` (the final image matches
|
|
185
183
|
* the engine head's own semantics for that pk) with `oldRow` pinned to the TXN-ENTRY BASE — the
|
|
@@ -227,18 +225,6 @@ export interface ReadRecord {
|
|
|
227
225
|
table: string;
|
|
228
226
|
pk: WireValue[];
|
|
229
227
|
outcome: ReadOutcome;
|
|
230
|
-
/** Per-read provenance (H-ii, §3.2 #3): the engine's `provenanceOf` answer AT THE MOMENT OF THE
|
|
231
|
-
* READ — which source ("daemon" / a room key) served the row the mutator saw. Live pre-commit
|
|
232
|
-
* state: an open txn's staged writes are not consulted (H-i), so a PRESENT read of a pk this
|
|
233
|
-
* txn itself created reports `undefined` — recorded honestly, key omitted (the router treats
|
|
234
|
-
* self-reads via write-set membership, not provenance). ABSENT reads carry no source (the
|
|
235
|
-
* mutator saw no row; there is no full-width row to probe with — a pk this txn staged-removed
|
|
236
|
-
* thus also records `undefined`, again a write-set-membership case for the router). Probed only
|
|
237
|
-
* on a PROMOTED table ({@link promotedTables}): a never-promoted (Collapsed) table is
|
|
238
|
-
* daemon-owned by construction, so a pure single-domain app pays zero wasm calls per read and
|
|
239
|
-
* `undefined` is still correct (everything is daemon-owned). One probe call per recorded
|
|
240
|
-
* present read, at read time. */
|
|
241
|
-
source?: string;
|
|
242
228
|
}
|
|
243
229
|
|
|
244
230
|
/** The read-log captured over ONE mutator invocation when recording is armed (RINDLE-REALTIME-
|
|
@@ -246,106 +232,14 @@ export interface ReadRecord {
|
|
|
246
232
|
* and, since H-ii, the keyed writers' internal pre-existence probes) plus every resolved query AST
|
|
247
233
|
* (`queries`, from `tx.query`). A SIBLING of the folded read TRAP (`FoldReadError` below) — the
|
|
248
234
|
* trap arms on the folded path and throws before any read completes (recording never runs there);
|
|
249
|
-
* recording arms on the ordinary (non-folded) prediction run and never throws. Pure capture
|
|
250
|
-
*
|
|
235
|
+
* recording arms on the ordinary (non-folded) prediction run and never throws. Pure capture for
|
|
236
|
+
* devtools/inspection (the §3 routing derivation it once fed was removed by
|
|
237
|
+
* 302-ROOM-STORE-SEPARATION-DESIGN.md §5 — mutators DECLARE their domain now). */
|
|
251
238
|
export interface ReadLog {
|
|
252
239
|
reads: ReadRecord[];
|
|
253
240
|
queries: Ast[];
|
|
254
241
|
}
|
|
255
242
|
|
|
256
|
-
/** One promoted `(sourceKey, table)`'s client-held routing spec — THE per-table routing record the
|
|
257
|
-
* §3 prove-or-slow-path router reads (H-iii). Recorded by {@link OptimisticBackend.promoteRoomTable}
|
|
258
|
-
* in the same breath as the engine attach, from the lease's `RealtimeLeaseTableSpec` (the
|
|
259
|
-
* api-server's `RoomTableSpec`, one compiler with the boot wire).
|
|
260
|
-
*
|
|
261
|
-
* - `writable`: whether the room may write this table AT ALL — `true` iff the registered engine
|
|
262
|
-
* {@link WritableDescriptor} is not `none` (a context/"followed" table is `none`; the daemon
|
|
263
|
-
* stays write-authoritative for it, §2.2). Rule #1 of the write proof.
|
|
264
|
-
* - `joinKeyCols`: the correlation columns the footprint binds on this table — the "room mutators
|
|
265
|
-
* never change join keys" input (an edit with a moved join-key cell would re-parent the row
|
|
266
|
-
* across the footprint's correlation and is refused room-routing). Deliberately client-side —
|
|
267
|
-
* they never cross the wasm {@link WritableDescriptor} ABI (the room GATE enforces them
|
|
268
|
-
* engine-side, H-iv).
|
|
269
|
-
* - `where`: the lease's row-local writable predicate (advisory here — the router evaluates the
|
|
270
|
-
* ENGINE's compiled copy via `writableMatches`, never this one; kept for introspection parity
|
|
271
|
-
* with the wire).
|
|
272
|
-
* - `footprintWhere`: the EXACT footprint-membership predicate (H-iv-b: root-node-only, lossless
|
|
273
|
-
* extraction; the vacuous-true `{type:"and",conditions:[]}` for an exact unconstrained root;
|
|
274
|
-
* ABSENT for child/correlated tables). The read proof's pk-membership test input — see
|
|
275
|
-
* {@link OptimisticBackend.deriveDomain}. */
|
|
276
|
-
export interface RoomTableRouting {
|
|
277
|
-
writable: boolean;
|
|
278
|
-
joinKeyCols: string[];
|
|
279
|
-
where?: Condition;
|
|
280
|
-
footprintWhere?: Condition;
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
/** The per-table spec {@link OptimisticBackend.promoteRoomTable} receives beside the engine
|
|
284
|
-
* descriptor (H-iii spec plumbing) — the wire-facing half of {@link RoomTableRouting}
|
|
285
|
-
* (`writable` is derived from the descriptor's kind, never passed separately). */
|
|
286
|
-
export interface RoomTableRoutingSpec {
|
|
287
|
-
joinKeyCols?: readonly string[];
|
|
288
|
-
where?: Condition;
|
|
289
|
-
footprintWhere?: Condition;
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
/** The §3 router's Q6 measurement hook ({@link OptimisticBackend.__inspectDomains}`().routing`):
|
|
293
|
-
* how often derivation succeeded (per derived room) and why it fell back to the daemon (per
|
|
294
|
-
* failure reason). `reasons` counts PER-CANDIDATE proof failures plus the terminal reasons
|
|
295
|
-
* (`no-candidates` / `tx-query` / `ambiguous`) — with multiple candidate rooms one derivation can
|
|
296
|
-
* bump several reason counters (one per failing candidate), and a candidate's failure is counted
|
|
297
|
-
* even when a sibling candidate ultimately proved. Explicit-policy pins bump NOTHING (the router
|
|
298
|
-
* never ran). */
|
|
299
|
-
export interface RoutingInspect {
|
|
300
|
-
/** Successful DERIVED routes, per room key. */
|
|
301
|
-
derived: Record<string, number>;
|
|
302
|
-
/** Derivation failures, per {@link RoutingFailureReason}. */
|
|
303
|
-
reasons: Record<string, number>;
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
/** Why a §3 derivation (or one candidate's proof) fell to the daemon — the {@link RoutingInspect}
|
|
307
|
-
* counter keys. */
|
|
308
|
-
export type RoutingFailureReason =
|
|
309
|
-
/** No connected room gate with a promoted table — the single-domain fast path. */
|
|
310
|
-
| "no-candidates"
|
|
311
|
-
/** The invocation ran `tx.query` — a declarative read is not room-provable client-side
|
|
312
|
-
* (rindle-cover is native-only BY DESIGN), so the whole derivation fails unconditionally. */
|
|
313
|
-
| "tx-query"
|
|
314
|
-
/** Two or more candidate rooms both proved. Principled disambiguation is a §9.2 (multi-room
|
|
315
|
-
* clients + budgets) NON-goal, deferred past Slice J; routing slow is always sound (the §3.3
|
|
316
|
-
* gate is the contract). Reachable essentially only for zero-write mutations — real writes
|
|
317
|
-
* can't be writable in two rooms (§2.2 exactly-one-writer per doc). */
|
|
318
|
-
| "ambiguous"
|
|
319
|
-
/** A write touched a table not promoted for the candidate, or promoted `writable: none`
|
|
320
|
-
* (context) — the room may not write it at all. */
|
|
321
|
-
| "write-unwritable-table"
|
|
322
|
-
/** `writableMatches` refused the write's post-image (add/edit) or pre-image (remove). */
|
|
323
|
-
| "write-scope-miss"
|
|
324
|
-
/** An edit moved a join-key cell (old vs new differ on a `joinKeyCols` column). */
|
|
325
|
-
| "write-join-key-change"
|
|
326
|
-
/** The engine's provenance for the write's probe row names a DIFFERENT room. */
|
|
327
|
-
| "write-cross-room-provenance"
|
|
328
|
-
/** A recorded read was served by a DIFFERENT room. */
|
|
329
|
-
| "read-cross-room"
|
|
330
|
-
/** A read needed the pk-membership test but the table has no `footprintWhere` (child /
|
|
331
|
-
* correlated / inexact extraction / never-promoted table). */
|
|
332
|
-
| "read-no-footprint-where"
|
|
333
|
-
/** `footprintWhere` references a non-pk column — not decidable from the key alone. */
|
|
334
|
-
| "read-not-key-decidable"
|
|
335
|
-
/** `footprintWhere` is key-decidable but outside the deliberately-minimal client evaluator
|
|
336
|
-
* (an op other than `=`/`!=`, a null/type-mixed comparison, column-vs-column, …). */
|
|
337
|
-
| "read-not-evaluable"
|
|
338
|
-
/** `footprintWhere` evaluated FALSE on the read's pk — the room provably never holds it. */
|
|
339
|
-
| "read-outside-footprint"
|
|
340
|
-
/** NOT a derivation failure (H-v): a routed mutation came back `mutationOutcome
|
|
341
|
-
* {kind:"deopt"}` — the room GATE refused it at commit and the client re-enqueued it onto the
|
|
342
|
-
* daemon. Counted per processed deopt frame so Q6's picture is complete: derived-and-deopted
|
|
343
|
-
* routes are visible beside derived successes (a rising `deopt` count against a rising
|
|
344
|
-
* `derived` count means the client proof and the gate disagree — a compiler/evaluator skew
|
|
345
|
-
* worth investigating; each one costs a room round-trip + a burnt room mid, never
|
|
346
|
-
* correctness). */
|
|
347
|
-
| "deopt";
|
|
348
|
-
|
|
349
243
|
interface PendingMutation {
|
|
350
244
|
/** The wire mutation id. A FOLDED entry carries `null` until its window flushes — the `mid`
|
|
351
245
|
* is dealt from `nextMid` in SEND order, never reserved at invoke, so the wire sequence stays
|
|
@@ -379,21 +273,9 @@ interface PendingMutation {
|
|
|
379
273
|
/** The pk-granular write-set captured at this entry's LAST invocation (§3.2 #1). A rebase
|
|
380
274
|
* re-invocation MERGES its fresh write-set into this one ({@link mergeWriteSet}), mirroring the
|
|
381
275
|
* `touched` union: the key set only grows across re-invocations (a re-run that no-ops must not
|
|
382
|
-
* shrink it, §7.2), each key's value is always the newest.
|
|
276
|
+
* shrink it, §7.2), each key's value is always the newest. Tables are ENGINE names: a
|
|
277
|
+
* room-domain entry's writes on the room's own tables record the namespaced name (302 §2). */
|
|
383
278
|
writes: WriteSet;
|
|
384
|
-
/** The physical SOURCES this mutator's writes staged onto (RINDLE-REALTIME-QUERY-ENABLEMENT-
|
|
385
|
-
* DESIGN.md §5.3, from `commitTracked`) — `{"daemon"}` in single-domain. Drives the per-source
|
|
386
|
-
* reconcile FILTER: `runReconcileCycle(sourceKey)` re-invokes an entry only if it wrote onto
|
|
387
|
-
* `sourceKey` (its writes there were un-applied by that source's rewind; other-source writes are
|
|
388
|
-
* intact). Union-merged across re-invocations, never shrunk (like `touched`). DISTINCT from
|
|
389
|
-
* {@link domain} (which stream CONFIRMS it): a room-domain mutation whose write routed to the
|
|
390
|
-
* daemon slice (a fresh pk) has `domain === "room:doc:X"` but `touchedSources ∋ "daemon"`. */
|
|
391
|
-
touchedSources: Set<string>;
|
|
392
|
-
/** Per staged (table, pk) → the source it routed onto ({@link writeSourceKey}), aligned from
|
|
393
|
-
* `commitTracked`'s staged-order `sources[]` (last write to a pk wins). Read at the confirm-drop
|
|
394
|
-
* to fire the §7.3 echo hold-back for a write whose source ≠ its confirming {@link domain}.
|
|
395
|
-
* All `"daemon"` in single-domain (inert). Merged never-shrunk across re-invocations. */
|
|
396
|
-
writeSources: Map<string, string>;
|
|
397
279
|
/** The read-log captured at this entry's LAST *recorded* invocation (§3.2 #2). Empty for a
|
|
398
280
|
* FOLDED entry (the trap, not recording, arms on that path — nothing is ever recorded there)
|
|
399
281
|
* and left as the ORIGINAL invoke's log across a rebase re-invocation: recording is armed only
|
|
@@ -492,14 +374,12 @@ interface SourceGate {
|
|
|
492
374
|
/** Arrival counter behind {@link BufferedFrame.seq}, scoped to THIS gate's buffer. */
|
|
493
375
|
nextSeq: number;
|
|
494
376
|
appliedCv: number;
|
|
495
|
-
/**
|
|
496
|
-
*
|
|
497
|
-
*
|
|
498
|
-
*
|
|
499
|
-
*
|
|
500
|
-
|
|
501
|
-
upstreamBoot?: string;
|
|
502
|
-
upstreamCv?: number;
|
|
377
|
+
/** A ROOM gate's wire-table → engine-table map (302 §2 — one source per table): the channel's
|
|
378
|
+
* released deltas rename into the room's own namespaced tables, and a wire table NOT in the
|
|
379
|
+
* map (context the room still publishes, or an unknown table) is DROPPED — the daemon is the
|
|
380
|
+
* sole authority for context, so its copy must never enter the store from a room channel
|
|
381
|
+
* (302 §6). Absent on the daemon gate (its deltas apply verbatim). */
|
|
382
|
+
tableMap?: ReadonlyMap<string, string>;
|
|
503
383
|
}
|
|
504
384
|
|
|
505
385
|
/** One I-iv doorbell event ({@link OptimisticBackend.onScopeSessions}, §4.1): a release folded
|
|
@@ -513,21 +393,23 @@ export interface ScopeSessionsEvent {
|
|
|
513
393
|
others: number;
|
|
514
394
|
}
|
|
515
395
|
|
|
516
|
-
/** One demoted room source's §4.2
|
|
517
|
-
*
|
|
518
|
-
*
|
|
519
|
-
*
|
|
396
|
+
/** One demoted room source's §4.2 SWAP-BACK gate record (Slice I-v, re-expressed by 302 §4.2):
|
|
397
|
+
* after a downgrade the room's namespaced tables keep backing their views — frozen at the room's
|
|
398
|
+
* last state (the channel is disconnected) — until the daemon plane has provably absorbed the
|
|
399
|
+
* room's final flush. Swapping the views back earlier would show the falling-back follower's
|
|
400
|
+
* PRE-flush images (a visibly rolled-back document). The drop condition is evaluated after every
|
|
401
|
+
* release ({@link OptimisticBackend.evaluateGhosts}):
|
|
520
402
|
*
|
|
521
403
|
* `roomWatermarks[doc] ≥ finalFlushSeq` (0 ⇒ trivially true — a never-flushed room)
|
|
522
404
|
* AND no pending mutation with `domain === sourceKey` remains (sent-pins-domain, §7.5 —
|
|
523
405
|
* room-domain entries retire ONLY through the outcome-resolved daemon-carried folds, I-iii).
|
|
524
|
-
|
|
406
|
+
*
|
|
407
|
+
* Both satisfied ⇒ {@link OptimisticBackend.dropGhost}: every room-swapped view re-registers on
|
|
408
|
+
* its ORIGINAL (daemon-table) AST — value-equal under the fence, so visually a no-op — and the
|
|
409
|
+
* room's namespaced tables unregister. */
|
|
525
410
|
interface RoomGhost {
|
|
526
411
|
doc: string;
|
|
527
412
|
finalFlushSeq: number;
|
|
528
|
-
/** Every table promoted for the sourceKey at demote time — the {@link dropGhost} removal set
|
|
529
|
-
* (and, while the ghost lives, its hold on the {@link promotedTables} record). */
|
|
530
|
-
tables: string[];
|
|
531
413
|
/** Whether the ONE stuck-downgrade event already fired for this ghost. */
|
|
532
414
|
stuckReported: boolean;
|
|
533
415
|
}
|
|
@@ -543,43 +425,6 @@ export interface DowngradeStuckEvent {
|
|
|
543
425
|
mids: number[];
|
|
544
426
|
}
|
|
545
427
|
|
|
546
|
-
/** One §301 echo-fence pin (`301-ECHO-FENCE-DESIGN.md` §2.1): the client-side registry entry for
|
|
547
|
-
* a parked engine hold-back, carrying the DELIVERY-fence inputs the engine deliberately does not
|
|
548
|
-
* know. Parked in the same breath as `holdBack`/`holdBackAbsent` at the §7.3 confirm-drop; keyed
|
|
549
|
-
* `${table}\0${sourceKey}\0${pkKey}` so a re-park of the same slice-pk overwrites (matching the
|
|
550
|
-
* engine's `held_back.insert`). The fence per direction (§1):
|
|
551
|
-
*
|
|
552
|
-
* - **A** (`sourceKey === "daemon"`, a room-domain write staged on the daemon slice): drops
|
|
553
|
-
* when the DAEMON-CARRIED room ledger covers `mid` — {@link OptimisticBackend}'s
|
|
554
|
-
* `daemonCarriedLmid[domain] ≥ mid` (the I-ii co-commit puts the flush data in the same
|
|
555
|
-
* coherent release, §1.1).
|
|
556
|
-
* - **B** (a room `sourceKey`, `domain === "daemon"`): drops when the room gate advertises
|
|
557
|
-
* absorption at-or-past the parking daemon release — `(upstreamBoot, upstreamCv)` covers
|
|
558
|
-
* `(daemonBoot, daemonCv)` under the §2.4 boot rule.
|
|
559
|
-
* - a room-staged pin confirmed by ANOTHER room has no fence (out of today's two-tier
|
|
560
|
-
* topology); it drops only via the engine's state-match fallback. */
|
|
561
|
-
interface EchoFencePin {
|
|
562
|
-
table: string;
|
|
563
|
-
/** The staged slice holding the engine pin — what to drop ON. */
|
|
564
|
-
sourceKey: string;
|
|
565
|
-
/** The held row / pre-image (full-width; the pk derives from it) — what to drop AT. */
|
|
566
|
-
probeRow: WireValue[];
|
|
567
|
-
/** The confirming domain (direction A's ledger to watch). */
|
|
568
|
-
domain: string;
|
|
569
|
-
/** The confirmed wire mid (direction A's fence input). */
|
|
570
|
-
mid: number;
|
|
571
|
-
/** Direction B stamps (301 §1.2): the parking daemon release's coherence position + the
|
|
572
|
-
* client-observed daemon boot it belongs to. Absent on direction A / the no-fence shape. */
|
|
573
|
-
daemonBoot?: string;
|
|
574
|
-
daemonCv?: number;
|
|
575
|
-
/** The slice's confirmed-baseline row at park — the §2.5 stuck-pin tripwire's "has the
|
|
576
|
-
* baseline CHANGED VALUE since park" reference (that change is the suspicious state that
|
|
577
|
-
* precedes every forever-pin). */
|
|
578
|
-
baselineAtPark?: WireValue[];
|
|
579
|
-
/** The tripwire's log-once latch. */
|
|
580
|
-
tripwired?: boolean;
|
|
581
|
-
}
|
|
582
|
-
|
|
583
428
|
export interface OptimisticBackendOptions {
|
|
584
429
|
/** Stable per-client identity for the upstream envelopes (§8.1). */
|
|
585
430
|
clientID: string;
|
|
@@ -593,14 +438,13 @@ export interface OptimisticBackendOptions {
|
|
|
593
438
|
/** Virtual-clock seam for the fold debounce timers (FOLDED-MUTATIONS-DESIGN §9). Defaults to
|
|
594
439
|
* real `setTimeout`/`clearTimeout`/`Date.now`; the fold oracle injects a deterministic clock. */
|
|
595
440
|
clock?: FoldClock;
|
|
596
|
-
/** The
|
|
597
|
-
*
|
|
598
|
-
*
|
|
599
|
-
*
|
|
600
|
-
*
|
|
601
|
-
* write
|
|
602
|
-
*
|
|
603
|
-
* byte-for-byte as before. */
|
|
441
|
+
/** The DECLARED confirming stream per mutation (302 §5: declared, not derived — there is no
|
|
442
|
+
* routing proof). A policy returning a string pins that domain verbatim: the mutation stages
|
|
443
|
+
* onto that room's namespaced tables and ships on its channel. Returning `undefined` (or
|
|
444
|
+
* configuring no policy) means `"daemon"`. The client layer builds this from the app's declared
|
|
445
|
+
* realtime mutators + the currently attached rooms; a misdeclaration fails SOFT (302 §5.1) —
|
|
446
|
+
* the write lands on the other authority's tables and the view simply stops feeling instant
|
|
447
|
+
* until the echo relays it. */
|
|
604
448
|
domainPolicy?: (name: string, args: unknown) => string | undefined;
|
|
605
449
|
/** A FINAL (authz/validation) mutation rejection's reason surface — the room plane's twin of the
|
|
606
450
|
* HTTP mutate route's `onRejected` (H-v; the H-iv-b `mutationOutcome {kind:"rejected"}` frame).
|
|
@@ -680,8 +524,8 @@ const REAL_CLOCK: FoldClock = {
|
|
|
680
524
|
};
|
|
681
525
|
|
|
682
526
|
/** The (shared, frozen-by-convention) empty map {@link OptimisticBackend.roomTablesFor} answers
|
|
683
|
-
* for a room with no
|
|
684
|
-
const
|
|
527
|
+
* for a room with no registered tables. */
|
|
528
|
+
const EMPTY_ROOM_TABLES: ReadonlyMap<string, string> = new Map();
|
|
685
529
|
|
|
686
530
|
/** Per-domain retention cap for the processed-outcome set (H-v) — mirrors the shell's
|
|
687
531
|
* `MAX_RECORDED_OUTCOMES_PER_CLIENT`: the sender caps what it can re-answer at 512 per client,
|
|
@@ -774,15 +618,23 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
|
|
|
774
618
|
* an evicted mid would be re-processed — the same bounded-window trade the shell makes, and it
|
|
775
619
|
* takes 512 interleaving non-applied outcomes on one domain to open it. */
|
|
776
620
|
private readonly outcomesProcessed = new Map<string, Set<number>>();
|
|
777
|
-
/** THE
|
|
778
|
-
*
|
|
779
|
-
* as the engine
|
|
780
|
-
*
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
621
|
+
/** THE room-table registry (302 §2 — one source per table): per connected room `sourceKey`, the
|
|
622
|
+
* wire-table → engine-table map for the tables that room OWNS (its writable scope). Written by
|
|
623
|
+
* {@link registerRoomTables} (same breath as the engine registration); read by the gate's
|
|
624
|
+
* release rename/filter, the mutator staging map, the view swap ({@link processSwapIns}), and
|
|
625
|
+
* the client's `__realtimeInspect` bookkeeping. The record outlives a downgrade's disconnect —
|
|
626
|
+
* the ghost's views still read the engine tables — and drops at {@link dropGhost} (or the last
|
|
627
|
+
* clean release via {@link unregisterRoomTables}). */
|
|
628
|
+
private readonly roomTables = new Map<string, Map<string, string>>();
|
|
629
|
+
/** Local view qids currently REGISTERED on a room's namespaced tables (302 §4 swap-in), →
|
|
630
|
+
* their sourceKey. Set by {@link processSwapIns}; cleared by the swap-back ({@link dropGhost})
|
|
631
|
+
* and view teardown. The original AST stays in {@link asts} throughout — the swap re-registers
|
|
632
|
+
* only the ENGINE query. */
|
|
633
|
+
private readonly roomSwappedViews = new Map<QueryId, string>();
|
|
634
|
+
/** Room subs whose FIRST snapshot released in the current release — their views swap onto the
|
|
635
|
+
* room tables at the release tail ({@link processSwapIns}), strictly AFTER the reconcile folded
|
|
636
|
+
* the snapshot into those tables (swapping earlier would hydrate the view EMPTY, a flash). */
|
|
637
|
+
private readonly pendingSwapIns = new Set<RemoteSub>();
|
|
786
638
|
/** The live fold entries, by fold key `${name}\0${identityJSON}` — at most one per key
|
|
787
639
|
* (FOLDED-MUTATIONS-DESIGN §8). Insertion order is creation order (the drain/flush tiebreak). */
|
|
788
640
|
private readonly folds = new Map<string, FoldRecord>();
|
|
@@ -805,47 +657,6 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
|
|
|
805
657
|
* scalars (`__inspect`) read it directly; its `sync` IS {@link sync} (the agg overlay and
|
|
806
658
|
* synthetic tables are daemon-tracked by design). */
|
|
807
659
|
private readonly daemonGate: SourceGate;
|
|
808
|
-
/** Tables promoted to a MERGED multi-source engine (a room source attached). Written by the one
|
|
809
|
-
* promotion seam ({@link promoteRoomTable}; the test-named {@link __addRoomSource} and Slice
|
|
810
|
-
* G-v's lease-driven promotion from `RoomTableSpec[]` both flow through it). Read by the §7.3
|
|
811
|
-
* hold-back trigger in
|
|
812
|
-
* {@link applyRelease}: parking on a never-promoted (Collapsed) table is SKIPPED — inert anyway
|
|
813
|
-
* today (`rewind_collapsed` never consults `held_back`), and a live hazard if the table later
|
|
814
|
-
* promotes (a stale Collapsed-era entry turns overlay-first-visible and pins forever). Slice H's
|
|
815
|
-
* prove-or-slow-path routing keeps the real scenario — a room-confirmed mutation writing a
|
|
816
|
-
* Collapsed table — impossible; this gate covers the window until then. */
|
|
817
|
-
private readonly promotedTables = new Set<string>();
|
|
818
|
-
/** The §301 pin registry ({@link EchoFencePin}): every parked §7.3 hold-back's delivery-fence
|
|
819
|
-
* inputs, keyed `${table}\0${sourceKey}\0${pkKey}`. Written in the same breath as the
|
|
820
|
-
* `holdBack`/`holdBackAbsent` park; read by {@link dropEchoFencePins} at the tail of every
|
|
821
|
-
* release. Empty on every single-domain client — the drop pass is then a structural no-op. */
|
|
822
|
-
private readonly pins = new Map<string, EchoFencePin>();
|
|
823
|
-
/** The §301 direction-A fence input: per room domain, the highest lmid the DAEMON-CARRIED
|
|
824
|
-
* ledger rows have folded ({@link foldSystemFrames} step 2 — and ONLY that path: the room
|
|
825
|
-
* socket's own lmid stream confirms long before the flush reaches the daemon, so it folds
|
|
826
|
-
* into the shared {@link watermark} but never here, 301 §1.1). When this covers a pin's mid,
|
|
827
|
-
* the same coherent release (or an earlier one) folded the flush data that carried it into
|
|
828
|
-
* the daemon baseline — the fence. */
|
|
829
|
-
private readonly daemonCarriedLmid = new Map<string, number>();
|
|
830
|
-
/** The §301 §2.4 boot rule's inputs: the daemon boot ids this client has OBSERVED, in order
|
|
831
|
-
* (id → ordinal), plus the current one. Boot ids are opaque — ordering is the client's own
|
|
832
|
-
* observation ({@link OptimisticSource.onBootId} on the daemon gate). Direction-B pins stamp
|
|
833
|
-
* the current boot; a room advertising a LATER-observed boot proves absorption (its
|
|
834
|
-
* re-snapshot came from a daemon state that durably includes the confirmed write). */
|
|
835
|
-
private readonly daemonBootOrdinals = new Map<string, number>();
|
|
836
|
-
private daemonBootId: string | undefined;
|
|
837
|
-
/** The per-read provenance probe `invoke` hands `trackingTx` when recording is armed (H-ii,
|
|
838
|
-
* §3.2 #3): the engine's `provenanceOf` — the VISIBLE overlay-first winner for the row's pk,
|
|
839
|
-
* read from LIVE pre-commit state (an open txn's staged writes are not consulted; H-i). Gated
|
|
840
|
-
* on PER-TABLE {@link promotedTables} membership — the cheapest existing signal, and the
|
|
841
|
-
* correct one: provenance is an ENGINE-merge question, not a channel question ({@link gates}
|
|
842
|
-
* can hold a connected-but-unpromoted room feed, and the oracle harness promotes with no gate),
|
|
843
|
-
* and a never-promoted (Collapsed) table is daemon-owned by construction. So a pure
|
|
844
|
-
* single-domain app pays ZERO wasm calls per recorded read; the recorded `source` is then
|
|
845
|
-
* `undefined`, which is also correct — everything is daemon-owned (see {@link ReadRecord}). */
|
|
846
|
-
private readonly readProvenance = (table: string, row: WireValue[]): string | undefined =>
|
|
847
|
-
this.promotedTables.has(table) ? this.local.provenanceOf(table, row) : undefined;
|
|
848
|
-
|
|
849
660
|
// --- the §4 lifecycle SYSTEM-STREAM plane (Slice I-iii) --------------------------------
|
|
850
661
|
/** System retains by source qid ({@link retainSystemQuery}): a subscription with NO store view
|
|
851
662
|
* and NO user-visible table — its frames buffer on its gate exactly like {@link LMID_QID}'s and
|
|
@@ -968,12 +779,6 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
|
|
|
968
779
|
source.onNormalized((qid, ev) => this.onFrame(gate, qid, ev));
|
|
969
780
|
source.onProgress((frame) => this.onGateProgress(gate, frame));
|
|
970
781
|
source.onRestart?.(() => this.resetGate(gate));
|
|
971
|
-
// §301 §2.4: the DAEMON channel's observed boot-id stream seeds the client-local boot
|
|
972
|
-
// ordinals the direction-B fence's boot rule compares against (boot ids are opaque; order is
|
|
973
|
-
// the client's own observation). Only the daemon gate's boots matter — a room's advertised
|
|
974
|
-
// `upstreamBoot` names a DAEMON boot, and room-channel incarnations are handled by
|
|
975
|
-
// `resetGate`. Optional: an in-process source never reports one.
|
|
976
|
-
if (key === "daemon") source.onBootId?.((bootId) => this.observeDaemonBoot(bootId));
|
|
977
782
|
// The deopt handshake's client half (H-v §3.3): the channel's `mutationOutcome` frames arrive
|
|
978
783
|
// as `(domain = gate.key, frame)`. OUT-OF-BAND — the source dispatches on arrival and this
|
|
979
784
|
// handler runs immediately, NEVER behind the gate's cv buffer: a deopt must migrate its entry
|
|
@@ -1006,7 +811,49 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
|
|
|
1006
811
|
if (this.gates.has(sourceKey)) {
|
|
1007
812
|
throw new Error(`optimistic backend: source ${sourceKey} is already connected`);
|
|
1008
813
|
}
|
|
1009
|
-
this.attachGate(sourceKey, source, new NormalizedSync(this.pkCols, this.colCounts));
|
|
814
|
+
const gate = this.attachGate(sourceKey, source, new NormalizedSync(this.pkCols, this.colCounts));
|
|
815
|
+
// A re-upgrade of a doc whose tables are still registered (a ghost that never dropped, or a
|
|
816
|
+
// quick down/up bounce) adopts the surviving record as this incarnation's rename map.
|
|
817
|
+
const tables = this.roomTables.get(sourceKey);
|
|
818
|
+
if (tables !== undefined) gate.tableMap = tables;
|
|
819
|
+
// …and CANCELS the pending swap-back: the room is the authority again, its views stay swapped,
|
|
820
|
+
// and a ghost left armed would fire against this LIVE gate when the old fence clears —
|
|
821
|
+
// un-swapping the views and unregistering the namespaced tables the gate's tableMap still
|
|
822
|
+
// renames deltas into (the next release would then throw from serverBatchBegin and poison the
|
|
823
|
+
// rebase state). A future downgrade arms a fresh ghost with its own fence.
|
|
824
|
+
this.ghosts.delete(sourceKey);
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/** Register the tables room `sourceKey` OWNS (its writable scope — 302 §2): each wire table
|
|
828
|
+
* gets its own namespaced ENGINE table (`{@link roomEngineTable}`), an ordinary tracked table
|
|
829
|
+
* whose sole authority is the room channel. From here on the channel's released deltas rename
|
|
830
|
+
* into these tables (wire tables outside the map are DROPPED — context stays daemon-owned,
|
|
831
|
+
* 302 §6), room-domain mutators stage onto them, and a room-homed view swaps onto them once
|
|
832
|
+
* the room sub hydrates ({@link processSwapIns}). Idempotent per (sourceKey, table); a wire
|
|
833
|
+
* table unknown to the schema is skipped (nothing to hold rows for). */
|
|
834
|
+
registerRoomTables(sourceKey: string, tables: readonly string[]): void {
|
|
835
|
+
if (sourceKey === "daemon") {
|
|
836
|
+
throw new Error("optimistic backend: the daemon is not a room — no namespaced tables");
|
|
837
|
+
}
|
|
838
|
+
let map = this.roomTables.get(sourceKey);
|
|
839
|
+
if (!map) this.roomTables.set(sourceKey, (map = new Map()));
|
|
840
|
+
for (const table of tables) {
|
|
841
|
+
if (map.has(table)) continue;
|
|
842
|
+
const spec = this.specs[table];
|
|
843
|
+
if (spec === undefined || this.localTables.has(table)) continue;
|
|
844
|
+
const engineTable = roomEngineTable(table, sourceKey);
|
|
845
|
+
this.local.registerTable(engineTable, { columns: spec.columns, primaryKey: spec.primaryKey });
|
|
846
|
+
map.set(table, engineTable);
|
|
847
|
+
}
|
|
848
|
+
const gate = this.gates.get(sourceKey);
|
|
849
|
+
if (gate !== undefined) gate.tableMap = map;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/** The wire-table → engine-table map for room `sourceKey`'s owned tables (empty when none) —
|
|
853
|
+
* the client's idempotence check and `__realtimeInspect` read THIS record (one source of
|
|
854
|
+
* truth; the client keeps no shadow copy). */
|
|
855
|
+
roomTablesFor(sourceKey: string): ReadonlyMap<string, string> {
|
|
856
|
+
return this.roomTables.get(sourceKey) ?? EMPTY_ROOM_TABLES;
|
|
1010
857
|
}
|
|
1011
858
|
|
|
1012
859
|
// --- the Backend seam ---------------------------------------------------------
|
|
@@ -1047,7 +894,7 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
|
|
|
1047
894
|
// child is left a native reduce (L1) — `rewriteAggregates`/`ensureSyntheticTables` skip it.
|
|
1048
895
|
this.ensureSyntheticTables(qid, ast);
|
|
1049
896
|
// Local first (synchronous empty view), then the server stream hydrates it.
|
|
1050
|
-
this.local.registerQuery(qid,
|
|
897
|
+
this.local.registerQuery(qid, this.plainEngineAst(ast));
|
|
1051
898
|
if (remote) {
|
|
1052
899
|
// A remote query is `unknown` until its first server snapshot lands (hydration); retainRemote
|
|
1053
900
|
// attaches it to the sub and sets the lifecycle against the sub's hydration state.
|
|
@@ -1117,6 +964,7 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
|
|
|
1117
964
|
}
|
|
1118
965
|
|
|
1119
966
|
unregisterQuery(qid: QueryId): void {
|
|
967
|
+
this.roomSwappedViews.delete(qid); // a swapped view's teardown forgets its room backing
|
|
1120
968
|
const remoteQid = this.releaseRemote(qid);
|
|
1121
969
|
// GC: rows this remote footprint SOLELY referenced fall to refcount 0 → net removes. A qid
|
|
1122
970
|
// lives on ONE channel, so at most one gate's dropQuery is non-empty (dropQuery of an
|
|
@@ -1126,7 +974,7 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
|
|
|
1126
974
|
this.pendingRetargetGc.delete(remoteQid); // the sweep below covers a mid-retarget teardown
|
|
1127
975
|
for (const gate of this.gates.values()) {
|
|
1128
976
|
gate.buffer = gate.buffer.filter((f) => f.qid !== remoteQid);
|
|
1129
|
-
const gc = gate.sync.dropQuery(remoteQid);
|
|
977
|
+
const gc = mapGateDeltas(gate, gate.sync.dropQuery(remoteQid));
|
|
1130
978
|
if (gc.length) gcs.push([gate.key, gc]);
|
|
1131
979
|
}
|
|
1132
980
|
}
|
|
@@ -1176,7 +1024,7 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
|
|
|
1176
1024
|
// Per-gate sweep, like `unregisterQuery`: at most one gate owned this qid's frames/rows.
|
|
1177
1025
|
for (const gate of this.gates.values()) {
|
|
1178
1026
|
gate.buffer = gate.buffer.filter((f) => f.qid !== remoteQid);
|
|
1179
|
-
const gc = gate.sync.dropQuery(remoteQid);
|
|
1027
|
+
const gc = mapGateDeltas(gate, gate.sync.dropQuery(remoteQid));
|
|
1180
1028
|
if (gc.length) this.runReconcileCycle(gate.key, gc);
|
|
1181
1029
|
}
|
|
1182
1030
|
}
|
|
@@ -1199,17 +1047,15 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
|
|
|
1199
1047
|
* precedent), flip `sub.channel`, re-arm `sub.hydrated` (the room's own snapshot is the
|
|
1200
1048
|
* cutover point), and register on the room source (its resolver presents the handed
|
|
1201
1049
|
* roomToken). The old gate's SYNC rows are deliberately NOT dropped: they keep the view's
|
|
1202
|
-
*
|
|
1203
|
-
*
|
|
1204
|
-
*
|
|
1205
|
-
*
|
|
1206
|
-
*
|
|
1207
|
-
*
|
|
1208
|
-
* net-zero again. No emission carries a disappearance at any point.
|
|
1050
|
+
* plain tables populated through the window — the view still reads them until the swap.
|
|
1051
|
+
* 2. AT THE ROOM'S FIRST RELEASED SNAPSHOT: the reconcile folds the snapshot into the room's
|
|
1052
|
+
* namespaced tables, the release tail SWAPS every local view onto them (302 §4.1,
|
|
1053
|
+
* {@link processSwapIns} — the accepted-flash boundary), and {@link flushRetargetGc}'s
|
|
1054
|
+
* deferred `dropQuery`+reconcile on the OLD gate then GCs the plain-table rows the sub
|
|
1055
|
+
* alone referenced — invisible to the swapped views.
|
|
1209
1056
|
*
|
|
1210
1057
|
* Idempotent per target channel: a sub already on `sourceKey` returns immediately (the
|
|
1211
|
-
* double-doorbell / re-entrancy guard — one retarget per (query, sourceKey)
|
|
1212
|
-
* {@link promoteRoomTable}'s caller-side per-(sourceKey, table) idempotence). Validates before
|
|
1058
|
+
* double-doorbell / re-entrancy guard — one retarget per (query, sourceKey)). Validates before
|
|
1213
1059
|
* mutating: a throw here leaves the sub fully daemon-attached (the client's fail-open). */
|
|
1214
1060
|
retargetRemoteQuery(remote: RemoteQuery, sourceKey: string): QueryId {
|
|
1215
1061
|
const newGate = this.requireGate(sourceKey); // throw loudly BEFORE any sub state moves
|
|
@@ -1251,42 +1097,31 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
|
|
|
1251
1097
|
this.pendingRetargetGc.delete(sourceQid);
|
|
1252
1098
|
const oldGate = this.gates.get(oldGateKey);
|
|
1253
1099
|
if (!oldGate) continue;
|
|
1254
|
-
const gc = oldGate.sync.dropQuery(sourceQid);
|
|
1100
|
+
const gc = mapGateDeltas(oldGate, oldGate.sync.dropQuery(sourceQid));
|
|
1255
1101
|
if (gc.length) this.runReconcileCycle(oldGateKey, gc);
|
|
1256
1102
|
}
|
|
1257
1103
|
}
|
|
1258
1104
|
|
|
1259
1105
|
// --- the §4.2 downgrade: demote → ghost → fence → drop (Slice I-v) ----------------------
|
|
1260
1106
|
|
|
1261
|
-
/** The I-v downgrade orchestration primitive (§4.2/§7.4
|
|
1262
|
-
* watermark fence. The caller has ALREADY
|
|
1263
|
-
* ({@link retargetRemoteQuery} room→daemon —
|
|
1264
|
-
*
|
|
1265
|
-
* `doc` keys the §4.2 watermark fold,
|
|
1107
|
+
/** The I-v downgrade orchestration primitive (§4.2/§7.4, re-expressed by 302 §4.2 as the
|
|
1108
|
+
* SWAP-BACK GATE): retire room `sourceKey` behind the watermark fence. The caller has ALREADY
|
|
1109
|
+
* retargeted every live sub off the channel ({@link retargetRemoteQuery} room→daemon —
|
|
1110
|
+
* validated loudly below) and holds the fence from the api-server's downgrade response
|
|
1111
|
+
* (`finalFlushSeq` = the room's last COMMITTED flush seq; `doc` keys the §4.2 watermark fold,
|
|
1112
|
+
* {@link roomWatermarks}). Steps, in order:
|
|
1266
1113
|
*
|
|
1267
|
-
* 1. **
|
|
1268
|
-
* {@link deriveDomain} stops proposing the room (zero candidates ⇒ `"daemon"`). Unsent
|
|
1269
|
-
* pending (`mid === null`) re-routes for free — a fold's flush re-derives from the live
|
|
1270
|
-
* candidate set (§7.5 rule 1). The engine's frozen scope still ANSWERS (`writableMatches`
|
|
1271
|
-
* / `provenanceOf` — freezing gates serving, not the scope definition); routing is
|
|
1272
|
-
* TS-gated here.
|
|
1273
|
-
* 2. **Freeze** the WRITABLE promoted tables (`freezeSource`): the room slice becomes the
|
|
1274
|
-
* §4.2 ghost — wins-if-present in the merge (D4), so its rows (at-or-ahead of the daemon
|
|
1275
|
-
* until the flush echoes) stay visible while the slice accepts nothing. Context tables
|
|
1276
|
-
* (`writable: false`) are deliberately NOT frozen: the daemon is authoritative for them
|
|
1277
|
-
* LIVE (§5.2's context tier) and freezing would invert that, pinning a possibly-BEHIND
|
|
1278
|
-
* relayed copy over fresher daemon rows; unfrozen they keep exactly the live tiering.
|
|
1279
|
-
* 3. **Disconnect** the channel ({@link disconnectSource}): handlers detached, gate + buffer
|
|
1114
|
+
* 1. **Disconnect** the channel ({@link disconnectSource}): handlers detached, gate + buffer
|
|
1280
1115
|
* dropped. `nextMid`/`watermark`/processed-outcomes for the domain are KEPT FOREVER (§7.1:
|
|
1281
1116
|
* an assigned mid pins its domain; a later re-upgrade of the same doc continues the
|
|
1282
1117
|
* sequence — {@link connectSource} attaches a fresh gate and the lmid snapshot max-folds
|
|
1283
1118
|
* into the surviving watermark). Disconnecting BEFORE the daemon sub's first release is
|
|
1284
1119
|
* load-bearing: it makes {@link flushRetargetGc}'s deferred old-channel GC a no-op (gate
|
|
1285
|
-
* gone ⇒ record deleted, nothing dropped)
|
|
1286
|
-
*
|
|
1287
|
-
*
|
|
1288
|
-
*
|
|
1289
|
-
*
|
|
1120
|
+
* gone ⇒ record deleted, nothing dropped). The room's namespaced tables — and the views
|
|
1121
|
+
* swapped onto them — deliberately stay: frozen at the room's last state, they keep the
|
|
1122
|
+
* document visible while the falling-back follower may still lack the final flush.
|
|
1123
|
+
* Swapping back earlier would show its pre-flush images — the regression §4.2 prevents.
|
|
1124
|
+
* 2. **Ghost + first evaluation**: the record joins {@link ghosts} and is evaluated once
|
|
1290
1125
|
* immediately — `finalFlushSeq === 0` (a never-flushed room) with no room-domain pending
|
|
1291
1126
|
* drops on the spot, the single-daemon first-frame case.
|
|
1292
1127
|
*
|
|
@@ -1298,7 +1133,6 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
|
|
|
1298
1133
|
if (sourceKey === "daemon") {
|
|
1299
1134
|
throw new Error("optimistic backend: the daemon source cannot be demoted");
|
|
1300
1135
|
}
|
|
1301
|
-
if (this.ghosts.has(sourceKey)) return; // idempotent — one ghost per source
|
|
1302
1136
|
// Validate FIRST (nothing mutated yet): a live sub still on the channel would silently
|
|
1303
1137
|
// starve once the gate detaches — the caller must retarget every sub off the room first.
|
|
1304
1138
|
for (const sub of this.remoteSubs.values()) {
|
|
@@ -1308,13 +1142,22 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
|
|
|
1308
1142
|
);
|
|
1309
1143
|
}
|
|
1310
1144
|
}
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1145
|
+
// Idempotent per sourceKey (co-tenant queries sharing the room demote into the existing
|
|
1146
|
+
// ghost) — but NEVER a bare early-return: each demote carries its own fence, so keep the
|
|
1147
|
+
// NEWEST flush (monotone max — swapping back on an older fence would show pre-flush images),
|
|
1148
|
+
// and disconnect defensively in case a gate re-attached since the ghost was armed (a
|
|
1149
|
+
// down→up→down bounce; {@link connectSource} cancels the ghost on re-upgrade, so this arm
|
|
1150
|
+
// normally finds no gate — but a stale gate left connected would let the next daemon release
|
|
1151
|
+
// GC the room slice out from under the still-swapped views, the §4.2 regression).
|
|
1152
|
+
const existing = this.ghosts.get(sourceKey);
|
|
1153
|
+
if (existing) {
|
|
1154
|
+
this.disconnectSource(sourceKey);
|
|
1155
|
+
existing.finalFlushSeq = Math.max(existing.finalFlushSeq, finalFlushSeq);
|
|
1156
|
+
this.evaluateGhosts();
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
1159
|
+
this.disconnectSource(sourceKey); // (1) the channel
|
|
1160
|
+
this.ghosts.set(sourceKey, { doc, finalFlushSeq, stuckReported: false }); // (2)
|
|
1318
1161
|
this.evaluateGhosts();
|
|
1319
1162
|
}
|
|
1320
1163
|
|
|
@@ -1361,6 +1204,10 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
|
|
|
1361
1204
|
private evaluateGhosts(): void {
|
|
1362
1205
|
if (this.ghosts.size === 0) return; // every non-downgrade release: structural no-op
|
|
1363
1206
|
for (const [sourceKey, ghost] of [...this.ghosts]) {
|
|
1207
|
+
// A LIVE gate means the doc re-upgraded — dropping now would dismantle the live room
|
|
1208
|
+
// (un-swap its views, unregister the tables its tableMap renames into). connectSource
|
|
1209
|
+
// cancels the ghost on re-upgrade, so this guard is purely defensive; hold, never drop.
|
|
1210
|
+
if (this.gates.has(sourceKey)) continue;
|
|
1364
1211
|
if ((this.roomWatermarks.get(ghost.doc) ?? 0) < ghost.finalFlushSeq) continue; // fence holds
|
|
1365
1212
|
const stuck = this.pendingMutations.filter((p) => p.domain === sourceKey && p.mid !== null);
|
|
1366
1213
|
if (stuck.length > 0) {
|
|
@@ -1370,47 +1217,48 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
|
|
|
1370
1217
|
}
|
|
1371
1218
|
continue; // hold — never a timeout-retire (§7.5 rule 2)
|
|
1372
1219
|
}
|
|
1373
|
-
this.dropGhost(sourceKey
|
|
1220
|
+
this.dropGhost(sourceKey);
|
|
1374
1221
|
}
|
|
1375
1222
|
}
|
|
1376
1223
|
|
|
1377
|
-
/** Drop one cleared ghost
|
|
1378
|
-
*
|
|
1379
|
-
*
|
|
1380
|
-
*
|
|
1381
|
-
*
|
|
1382
|
-
*
|
|
1383
|
-
*
|
|
1384
|
-
*
|
|
1385
|
-
*
|
|
1386
|
-
private dropGhost(sourceKey: string
|
|
1224
|
+
/** Drop one cleared ghost — the 302 §4.2 SWAP-BACK: under the fence the daemon tables are
|
|
1225
|
+
* value-equal-or-ahead of the room's final state, so (1) every view swapped onto the room's
|
|
1226
|
+
* namespaced tables re-registers on its ORIGINAL (daemon-table) AST — visually a no-op, the
|
|
1227
|
+
* Store folds the re-hello as an in-place reset; (2) the namespaced tables unregister (no
|
|
1228
|
+
* reader is left after the swap); (3) ONE daemon reconcile re-invokes the pending set so any
|
|
1229
|
+
* entry whose writes had staged onto the now-gone room tables re-stages onto the daemon tables
|
|
1230
|
+
* (its domain policy stopped naming the dead room when the client dropped it). The whole drop
|
|
1231
|
+
* runs under one commit boundary so the swap and the re-staged predictions notify as ONE step.
|
|
1232
|
+
* After this, a FUTURE upgrade of the same doc registers again from scratch. */
|
|
1233
|
+
private dropGhost(sourceKey: string): void {
|
|
1387
1234
|
this.ghosts.delete(sourceKey);
|
|
1388
|
-
// Re-stage eligibility FIRST: a pending entry staged (only) on the ghost slice must join the
|
|
1389
|
-
// daemon reconcile below (the cycle filter is `touchedSources.has(sourceKey)`) — union, never
|
|
1390
|
-
// shrink, the §5.3 rule.
|
|
1391
|
-
let restage = false;
|
|
1392
|
-
for (const p of this.pendingMutations) {
|
|
1393
|
-
if (p.touchedSources.has(sourceKey)) {
|
|
1394
|
-
p.touchedSources.add("daemon");
|
|
1395
|
-
restage = true;
|
|
1396
|
-
}
|
|
1397
|
-
}
|
|
1398
1235
|
this.inOneCommit(() => {
|
|
1399
|
-
for (const
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1236
|
+
for (const [qid, key] of [...this.roomSwappedViews]) {
|
|
1237
|
+
if (key !== sourceKey) continue;
|
|
1238
|
+
this.roomSwappedViews.delete(qid);
|
|
1239
|
+
const ast = this.asts.get(qid);
|
|
1240
|
+
if (ast === undefined) continue;
|
|
1241
|
+
this.local.unregisterQuery(qid);
|
|
1242
|
+
this.local.registerQuery(qid, this.plainEngineAst(ast));
|
|
1405
1243
|
}
|
|
1406
|
-
|
|
1407
|
-
//
|
|
1408
|
-
//
|
|
1409
|
-
if (
|
|
1244
|
+
this.unregisterRoomTables(sourceKey);
|
|
1245
|
+
// One daemon reconcile re-stages the pending set onto the surviving tables. Run whenever
|
|
1246
|
+
// any pending exists: unregistering the room tables took their staged copies with the tree.
|
|
1247
|
+
if (this.pendingMutations.length > 0) this.runReconcileCycle("daemon", []);
|
|
1410
1248
|
});
|
|
1411
1249
|
this.refreshPending(); // the reconcile may have dropped a throwing re-invocation
|
|
1412
1250
|
}
|
|
1413
1251
|
|
|
1252
|
+
/** Unregister room `sourceKey`'s namespaced engine tables and drop the {@link roomTables}
|
|
1253
|
+
* record. Callers must have no view registered on them (the engine refuses otherwise —
|
|
1254
|
+
* loud by design). No-op for an unknown sourceKey. */
|
|
1255
|
+
unregisterRoomTables(sourceKey: string): void {
|
|
1256
|
+
const map = this.roomTables.get(sourceKey);
|
|
1257
|
+
if (!map) return;
|
|
1258
|
+
this.roomTables.delete(sourceKey);
|
|
1259
|
+
for (const engineTable of map.values()) this.local.unregisterTable(engineTable);
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1414
1262
|
// --- the §4 lifecycle SYSTEM-STREAM retains (Slice I-iii) ------------------------------
|
|
1415
1263
|
|
|
1416
1264
|
/** Retain one minted SYSTEM subscription (RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md §4, Slice
|
|
@@ -1576,192 +1424,63 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
|
|
|
1576
1424
|
return { mid, seq: ++this.dealSeq };
|
|
1577
1425
|
}
|
|
1578
1426
|
|
|
1579
|
-
// --- the §
|
|
1580
|
-
//
|
|
1581
|
-
// RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md §3: a mutation routes to room R iff every row its
|
|
1582
|
-
// prediction run WROTE is provably inside R's writable scope and every read it RECORDED is
|
|
1583
|
-
// provably covered by R's footprint — ANY unproven condition routes slow (the §0 asymmetry:
|
|
1584
|
-
// mis-guessing "slow" costs one round-trip; mis-guessing "room" would commit divergent data).
|
|
1585
|
-
// Failure is therefore SILENT "daemon" — never a throw, never a warning per call — plus the Q6
|
|
1586
|
-
// per-reason counters ({@link RoutingInspect}).
|
|
1587
|
-
//
|
|
1588
|
-
// The proof evaluates against the ENGINE wherever the engine has an answer: `writableMatches`
|
|
1589
|
-
// (the registered compiled writable scope — the same predicate the merge's winner tiering uses)
|
|
1590
|
-
// and `provenanceOf` (the visible overlay-first winner). It runs AFTER the prediction committed,
|
|
1591
|
-
// so the probes see the prediction's own staged effects — which only ever strengthens the
|
|
1592
|
-
// conservative direction (a fresh pk reports "daemon"/undefined and neither disqualifies).
|
|
1593
|
-
//
|
|
1594
|
-
// ** THE ONE CLIENT-SIDE NON-ENGINE EVALUATOR — deliberately minimal (the TS evaluation
|
|
1595
|
-
// caveat). ** Evaluating `footprintWhere` on a read's pk cells ({@link evalFootprintOnPk}) is
|
|
1596
|
-
// the single place Slice H allows a non-engine evaluator, and it is restricted to STRICT cell
|
|
1597
|
-
// equality only: `simple` `=` / `!=` between a pk column and a same-primitive-type non-null
|
|
1598
|
-
// literal, composed under and/or (the empty AND is the vacuous-true exact-unconstrained-root
|
|
1599
|
-
// emission and evaluates true). ANY other op or shape — LIKE, <, >=, IS, IN, null operands,
|
|
1600
|
-
// cross-type comparisons, column-vs-column — is NOT EVALUABLE and fails to the daemon.
|
|
1601
|
-
// Justification: a mis-evaluation here cannot commit divergence — the room GATE (H-iv) re-proves
|
|
1602
|
-
// every write and absent read engine-side at commit and deopts, so a wrong client verdict only
|
|
1603
|
-
// costs a wasted hop (route room → gate deopt) or a skipped optimization (route daemon); and
|
|
1604
|
-
// restricting to strict equality on non-null, same-typed pk cells removes the entire
|
|
1605
|
-
// null/collation/coercion divergence space between this evaluator and the engine's comparators.
|
|
1606
|
-
// The gate remains the contract; this router is the optimization.
|
|
1427
|
+
// --- the DECLARED router (302 §5: declared, not derived) --------------------------------
|
|
1607
1428
|
//
|
|
1608
|
-
//
|
|
1609
|
-
//
|
|
1610
|
-
//
|
|
1611
|
-
//
|
|
1612
|
-
//
|
|
1613
|
-
//
|
|
1614
|
-
//
|
|
1615
|
-
//
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
*
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
*
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
/**
|
|
1644
|
-
*
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
return "daemon";
|
|
1664
|
-
}
|
|
1665
|
-
// tx.query is not room-executable client-side: predicate containment (rindle-cover) is
|
|
1666
|
-
// native-only BY DESIGN, so a declarative read fails the WHOLE derivation unconditionally.
|
|
1667
|
-
// This is required, not conservative.
|
|
1668
|
-
if (reads.queries.length > 0) {
|
|
1669
|
-
this.failDerivation("tx-query");
|
|
1670
|
-
return "daemon";
|
|
1671
|
-
}
|
|
1672
|
-
const proven = candidates.filter((room) => this.provesRoom(room, writes, reads));
|
|
1673
|
-
if (proven.length === 1) {
|
|
1674
|
-
const room = proven[0];
|
|
1675
|
-
this.routingDerived.set(room, (this.routingDerived.get(room) ?? 0) + 1);
|
|
1676
|
-
return room;
|
|
1677
|
-
}
|
|
1678
|
-
if (proven.length > 1) this.failDerivation("ambiguous");
|
|
1679
|
-
return "daemon"; // zero survivors: each candidate already counted its own failure reason
|
|
1680
|
-
}
|
|
1681
|
-
|
|
1682
|
-
/** One candidate room's §3 proof over the captured write-set + read-log. Every write must pass
|
|
1683
|
-
* ALL the write rules; every read must pass ONE of the read rules. First failure wins (and is
|
|
1684
|
-
* counted); order is deterministic (write-set map order, then read-log order). */
|
|
1685
|
-
private provesRoom(room: string, writes: WriteSet, reads: ReadLog): boolean {
|
|
1686
|
-
const specs = this.roomRouting.get(room);
|
|
1687
|
-
// --- write rules: every WriteRecord passes ALL of #1–#4 -------------------------------
|
|
1688
|
-
for (const [table, byPk] of writes) {
|
|
1689
|
-
// #1: the table is promoted for R with a writable (predicate-kind) spec. A context table
|
|
1690
|
-
// (`writable: none`), an un-promoted table, and a local table (unreachable — the mutator
|
|
1691
|
-
// guard refuses local writes at stage time) all fail here.
|
|
1692
|
-
const spec = specs?.get(table);
|
|
1693
|
-
if (spec === undefined || !spec.writable) return this.failDerivation("write-unwritable-table");
|
|
1694
|
-
const colIx = this.colIndex[table];
|
|
1695
|
-
for (const rec of byPk.values()) {
|
|
1696
|
-
const removeShape = rec.row === undefined;
|
|
1697
|
-
// #2: the registered writable scope, evaluated BY THE ENGINE on the post-image for
|
|
1698
|
-
// add/edit shapes and on the pre-image for removes (the row the room would delete). A
|
|
1699
|
-
// remove record always carries its full-width pre-image (the H-ii capture contract);
|
|
1700
|
-
// a violated contract fails closed rather than probing nothing.
|
|
1701
|
-
const scopeRow = removeShape ? rec.oldRow : rec.row;
|
|
1702
|
-
if (scopeRow === undefined || !this.local.writableMatches(table, room, scopeRow)) {
|
|
1703
|
-
return this.failDerivation("write-scope-miss");
|
|
1704
|
-
}
|
|
1705
|
-
// #3: join-key no-change. An edit-shape (row + oldRow — the oldRow is the txn-entry base)
|
|
1706
|
-
// must keep every joinKeyCols cell STRICTLY identical old-vs-new; an add-shape may SET
|
|
1707
|
-
// them (it creates the correlation); removes are exempt. A join-key column missing from
|
|
1708
|
-
// the schema is a spec bug — fail closed.
|
|
1709
|
-
if (!removeShape && rec.oldRow !== undefined) {
|
|
1710
|
-
for (const col of spec.joinKeyCols) {
|
|
1711
|
-
const i = colIx?.get(col);
|
|
1712
|
-
if (i === undefined || !identicalCell(rec.oldRow[i], rec.row![i])) {
|
|
1713
|
-
return this.failDerivation("write-join-key-change");
|
|
1714
|
-
}
|
|
1715
|
-
}
|
|
1716
|
-
}
|
|
1717
|
-
// #4: provenance corroboration — the engine routes an Edit by its OLD row (H-i), so probe
|
|
1718
|
-
// the pre-image for edit/remove shapes and the post-image for add-shapes. A winner naming
|
|
1719
|
-
// a DIFFERENT room disqualifies; "daemon"/undefined does NOT (a thin slice, or a fresh pk
|
|
1720
|
-
// — fresh pks stage to the daemon slice by the E2 decision, kept permanently: the
|
|
1721
|
-
// room-side authoritative run is the committing execution either way).
|
|
1722
|
-
const probeRow = rec.oldRow ?? rec.row;
|
|
1723
|
-
if (probeRow !== undefined) {
|
|
1724
|
-
const src = this.local.provenanceOf(table, probeRow);
|
|
1725
|
-
if (src !== undefined && src !== "daemon" && src !== room) {
|
|
1726
|
-
return this.failDerivation("write-cross-room-provenance");
|
|
1727
|
-
}
|
|
1728
|
-
}
|
|
1729
|
-
}
|
|
1730
|
-
}
|
|
1731
|
-
// --- read rules: every ReadRecord passes ONE ------------------------------------------
|
|
1732
|
-
// (tx.query already failed the whole derivation in deriveDomain — never reaches here.)
|
|
1733
|
-
for (const r of reads.reads) {
|
|
1734
|
-
// Self-read: the pk is in THIS invocation's write-set for that table — the write rules
|
|
1735
|
-
// above already judged it (covers the keyed writers' probe-then-write shape). NOTE the
|
|
1736
|
-
// read-log reflects the ORIGINAL invoke only (the pre-existing capture caveat).
|
|
1737
|
-
if (writes.get(r.table)?.has(stableJson(r.pk))) continue;
|
|
1738
|
-
if (r.source !== undefined) {
|
|
1739
|
-
if (r.source === room) continue; // the room served the row — in-footprint by construction
|
|
1740
|
-
if (r.source !== "daemon") return this.failDerivation("read-cross-room");
|
|
1741
|
-
}
|
|
1742
|
-
// Present-with-daemon/undefined source, or ABSENT (source is never recorded for those —
|
|
1743
|
-
// including the un-promoted-table meaning of an absent key): the footprint-membership test
|
|
1744
|
-
// on the PK ALONE. R's footprintWhere must exist, be key-decidable (every column it reads is
|
|
1745
|
-
// a pk column), and evaluate TRUE on the read's pk cells:
|
|
1746
|
-
// present-daemon + TRUE ⇒ the row is in the room's COMPLETE footprint ⇒ covered;
|
|
1747
|
-
// absent + TRUE ⇒ absent-in-room = absent-in-truth ⇒ covered;
|
|
1748
|
-
// FALSE (either outcome) ⇒ fail — present-daemon-FALSE means the room lacks the row;
|
|
1749
|
-
// absent-FALSE is arguably provable (decidably-outside ⇒ the room never sees the pk)
|
|
1750
|
-
// but the room-side run would then read absent for a DIFFERENT reason than truth's —
|
|
1751
|
-
// stay CONSERVATIVE and fail;
|
|
1752
|
-
// no footprintWhere / not key-decidable / not evaluable ⇒ fail (see the evaluator caveat
|
|
1753
|
-
// on the router block comment).
|
|
1754
|
-
const fw = specs?.get(r.table)?.footprintWhere;
|
|
1755
|
-
if (fw === undefined) return this.failDerivation("read-no-footprint-where");
|
|
1756
|
-
const tspec = this.specs[r.table];
|
|
1757
|
-
const pkCols = tspec.primaryKey.map((i) => tspec.columns[i]);
|
|
1758
|
-
if (!keyDecidable(fw, new Set(pkCols))) return this.failDerivation("read-not-key-decidable");
|
|
1759
|
-
const pkCells = new Map<string, WireValue>(pkCols.map((c, j) => [c, r.pk[j]]));
|
|
1760
|
-
const verdict = evalFootprintOnPk(fw, pkCells);
|
|
1761
|
-
if (verdict === undefined) return this.failDerivation("read-not-evaluable");
|
|
1762
|
-
if (!verdict) return this.failDerivation("read-outside-footprint");
|
|
1763
|
-
}
|
|
1764
|
-
return true;
|
|
1429
|
+
// The user declares which mutators are room mutators; the client neither proves, derives,
|
|
1430
|
+
// widens, nor falls back. The declaration reaches this backend as `domainPolicy` — the client
|
|
1431
|
+
// layer resolves (mutator name, args) against its declared realtime mutators and the currently
|
|
1432
|
+
// attached rooms. A misdeclaration fails SOFT (302 §5.1): a daemon-declared mutator touching
|
|
1433
|
+
// room-visible data stages onto the daemon tables while the room-homed view reads the room
|
|
1434
|
+
// tables — no optimistic feedback until the echo relays it a hop later, never a divergence.
|
|
1435
|
+
// The room GATE stays the authoritative backstop: a room-routed mutation the room refuses comes
|
|
1436
|
+
// back as a `mutationOutcome` deopt/reject frame and the H-v machinery below re-enqueues or
|
|
1437
|
+
// surfaces it.
|
|
1438
|
+
|
|
1439
|
+
/** The declared confirming stream for one invocation: the `domainPolicy`'s verdict, `"daemon"`
|
|
1440
|
+
* when it abstains. Resolved BEFORE the prediction runs — the domain picks the staging map
|
|
1441
|
+
* (a room domain stages its owned tables onto the room's namespaced twins). */
|
|
1442
|
+
private resolveDomain(name: string, args: unknown): string {
|
|
1443
|
+
return this.domainPolicy(name, args) ?? "daemon";
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
/** The staging table map for a `domain`-routed prediction ({@link trackingTx}'s `stage`):
|
|
1447
|
+
* wire table → the room's namespaced engine table for the tables the room owns; identity for
|
|
1448
|
+
* everything else (including the whole map for the daemon domain). */
|
|
1449
|
+
private stagingMap(domain: string): ReadonlyMap<string, string> | undefined {
|
|
1450
|
+
return domain === "daemon" ? undefined : this.roomTables.get(domain);
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
/** The PLAIN (daemon-homed) engine AST for `ast` — aggregate relationships rewritten to their
|
|
1454
|
+
* synthetic `__agg_*` reads, no room renames. The ONE form every non-swapped engine
|
|
1455
|
+
* registration uses ({@link registerQuery}, {@link dropGhost}'s swap-back) and the base the
|
|
1456
|
+
* swap-in renames ({@link processSwapIns}). */
|
|
1457
|
+
private plainEngineAst(ast: Ast): Ast {
|
|
1458
|
+
return rewriteAggregates(ast, (t) => this.localTables.has(t));
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
/** Mutator names the cross-authority warn below already fired for (once per name). */
|
|
1462
|
+
private readonly warnedCrossAuthority = new Set<string>();
|
|
1463
|
+
|
|
1464
|
+
/** 302 §5.1 dev-time guard: a room-DECLARED mutator wrote tables the room does not own. Those
|
|
1465
|
+
* writes staged onto the PLAIN daemon tables (the staging map covers only owned tables), but
|
|
1466
|
+
* the entry confirms on the ROOM stream — and only the room's OWNED tables flush back to the
|
|
1467
|
+
* daemon, so nothing upstream ever echoes them: once the room confirm retires the entry, the
|
|
1468
|
+
* next release's whole-store rewind reverts them for good. The first-party room shell refuses
|
|
1469
|
+
* such a mutation (the §3.3 deopt/reject backstop re-routes it to the daemon), so this warns
|
|
1470
|
+
* for the shapes where that backstop may be absent (a BYO relay) — loud, once, soft (§5.1:
|
|
1471
|
+
* misdeclarations never throw). */
|
|
1472
|
+
private warnCrossAuthorityWrites(name: string, domain: string, touched: ReadonlySet<string>): void {
|
|
1473
|
+
if (domain === "daemon" || this.warnedCrossAuthority.has(name)) return;
|
|
1474
|
+
const map = this.roomTables.get(domain);
|
|
1475
|
+
const staged = new Set(map?.values() ?? []);
|
|
1476
|
+
const outside = [...touched].filter((t) => !staged.has(t));
|
|
1477
|
+
if (outside.length === 0) return;
|
|
1478
|
+
this.warnedCrossAuthority.add(name);
|
|
1479
|
+
console.warn(
|
|
1480
|
+
`[rindle] room mutator "${name}" wrote table(s) ${outside.join(", ")} that room ${JSON.stringify(domain)} does not own` +
|
|
1481
|
+
` (owned: ${map !== undefined && map.size > 0 ? [...map.keys()].join(", ") : "none"}) — these writes rely on the room` +
|
|
1482
|
+
` shell's deopt backstop and revert after the room confirm if the shell applies the mutation anyway (302 §5.1).`,
|
|
1483
|
+
);
|
|
1765
1484
|
}
|
|
1766
1485
|
|
|
1767
1486
|
/** Run the named client mutator optimistically: the prediction applies to the live
|
|
@@ -1783,46 +1502,37 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
|
|
|
1783
1502
|
// One commit boundary spans the prediction AND the `__agg`-head reconcile below, so their views
|
|
1784
1503
|
// (data + count) flush together rather than tearing across two engine commits.
|
|
1785
1504
|
return this.inOneCommit(() => {
|
|
1786
|
-
//
|
|
1505
|
+
// The confirming stream is DECLARED (302 §5), so it resolves BEFORE the prediction: the
|
|
1506
|
+
// domain picks the staging map — a room-domain mutator's writes to the room's owned tables
|
|
1507
|
+
// land on the namespaced engine twins the room-homed views read. An H-v deopt re-invocation
|
|
1508
|
+
// pins via `pin` and the policy never runs.
|
|
1509
|
+
const domain = pin ?? this.resolveDomain(name, args);
|
|
1510
|
+
// Apply the prediction. If the mutator throws (client-side validation, a bad read),
|
|
1787
1511
|
// the staged write is discarded (the wasm txn is a clean no-op until commit) and the throw
|
|
1788
1512
|
// propagates with NO mid consumed — a burnt mid is a permanent server-side gap that
|
|
1789
1513
|
// silently refuses every later mutation from this client (#10).
|
|
1790
1514
|
const writes: WriteSet = new Map();
|
|
1791
1515
|
const reads: ReadLog = { reads: [], queries: [] };
|
|
1792
1516
|
const ops: ChildOp[] = [];
|
|
1793
|
-
|
|
1794
|
-
// below to learn which physical source each pk routed onto (§5.3 filter + §7.3 hold-back).
|
|
1795
|
-
const stagedKeys: string[] = [];
|
|
1796
|
-
const sources = this.local.writeWith((tx) => {
|
|
1517
|
+
this.local.writeWith((tx) => {
|
|
1797
1518
|
this.runMutator(
|
|
1798
1519
|
mutator,
|
|
1799
|
-
|
|
1800
|
-
// readLog) and the reconcile replay (no readLog) never probe — see trackingTx.
|
|
1801
|
-
trackingTx(tx, writes, this.specs, this.localTables, this.opCollector(ops), false, reads, stagedKeys, this.readProvenance),
|
|
1520
|
+
trackingTx(tx, writes, this.specs, this.localTables, this.opCollector(ops), false, reads, this.stagingMap(domain)),
|
|
1802
1521
|
args,
|
|
1803
1522
|
);
|
|
1804
1523
|
});
|
|
1805
1524
|
// `touched` is DERIVED, never separately populated (§3.2 #1) — see {@link WriteSet}.
|
|
1806
1525
|
const touched = new Set(writes.keys());
|
|
1526
|
+
this.warnCrossAuthorityWrites(name, domain, touched);
|
|
1807
1527
|
// Flush-on-enqueue (§4.2): a fold whose tables overlap this write must take its mid NOW, BEFORE
|
|
1808
1528
|
// this write does, so wire order == local-apply order for any pair that can observe each other
|
|
1809
1529
|
// (a read-dependent write reading a folded cell sees the same value optimistically and on the
|
|
1810
1530
|
// wire — no snap). Drained folds ship with smaller mids; this write's mid is dealt after.
|
|
1811
1531
|
this.drainOverlapping(touched);
|
|
1812
|
-
// The confirming stream
|
|
1813
|
-
//
|
|
1814
|
-
// complete, and the engine holds the committed prediction the probes read) and BEFORE the
|
|
1815
|
-
// mid is dealt: an assigned mid pins its domain forever (§7.1) — a re-invocation never
|
|
1816
|
-
// re-routes. An explicit `domainPolicy` string pins verbatim (no proof); an H-v deopt
|
|
1817
|
-
// re-invocation pins via `pin` (see {@link invokeWith}) and the router never runs.
|
|
1818
|
-
const domain = pin ?? this.resolveDomain(name, args, writes, reads);
|
|
1532
|
+
// The confirming stream's ledger deals the mid and its watermark alone retires the entry
|
|
1533
|
+
// (§7.1). An assigned mid pins its domain forever — a re-invocation never re-routes.
|
|
1819
1534
|
const { mid, seq } = this.dealMid(domain);
|
|
1820
|
-
|
|
1821
|
-
// in single-domain (Collapsed) — so the filter and hold-back below are inert on the live path.
|
|
1822
|
-
const touchedSources = new Set(sources);
|
|
1823
|
-
const writeSources = new Map<string, string>();
|
|
1824
|
-
mergeWriteSources(writeSources, stagedKeys, sources);
|
|
1825
|
-
this.pendingMutations.push({ mid, seq, name, args, domain, touched, writes, reads, touchedSources, writeSources });
|
|
1535
|
+
this.pendingMutations.push({ mid, seq, name, args, domain, touched, writes, reads });
|
|
1826
1536
|
// The prediction stuck — fold its child ops into the optimistic agg delta and push it onto
|
|
1827
1537
|
// the `__agg` head rows (§4). No reset here (this is the §1.3 trivial case, no rewind): the
|
|
1828
1538
|
// delta accumulates on top of the prior pending set, and `reconcileAggHead` recomputes each
|
|
@@ -1845,6 +1555,9 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
|
|
|
1845
1555
|
const foldKey = `${name}\0${stableJson(opts.key)}`;
|
|
1846
1556
|
// One commit boundary spans the prediction AND the `__agg`-head reconcile (see {@link inOneCommit}),
|
|
1847
1557
|
// so a folded mutation's list view and count view flush together, never torn across two commits.
|
|
1558
|
+
// The declared domain (302 §5) — resolved up front, like `invoke`'s: it picks the staging
|
|
1559
|
+
// map, the §9.3 cadence, and the provisional confirming stream (the flush re-resolves).
|
|
1560
|
+
const domain = this.resolveDomain(name, args);
|
|
1848
1561
|
return this.inOneCommit(() => {
|
|
1849
1562
|
// Apply the prediction with the read trap armed (§5): a folded mutator that reads state to
|
|
1850
1563
|
// compute its write is non-absorbing and refused. A throw discards the staged write (clean
|
|
@@ -1852,11 +1565,9 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
|
|
|
1852
1565
|
// path stays byte-for-byte as it was; recording (§3.2 #2) never arms alongside the trap.
|
|
1853
1566
|
const writes: WriteSet = new Map();
|
|
1854
1567
|
const ops: ChildOp[] = [];
|
|
1855
|
-
const stagedKeys: string[] = [];
|
|
1856
|
-
let sources: string[] = [];
|
|
1857
1568
|
try {
|
|
1858
|
-
|
|
1859
|
-
this.runMutator(mutator, trackingTx(tx, writes, this.specs, this.localTables, this.opCollector(ops), true, undefined,
|
|
1569
|
+
this.local.writeWith((tx) => {
|
|
1570
|
+
this.runMutator(mutator, trackingTx(tx, writes, this.specs, this.localTables, this.opCollector(ops), true, undefined, this.stagingMap(domain)), args);
|
|
1860
1571
|
});
|
|
1861
1572
|
} catch (e) {
|
|
1862
1573
|
if (e instanceof FoldReadError) {
|
|
@@ -1871,37 +1582,30 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
|
|
|
1871
1582
|
|
|
1872
1583
|
// `touched` is DERIVED, never separately populated (§3.2 #1) — see {@link WriteSet}.
|
|
1873
1584
|
const touched = new Set(writes.keys());
|
|
1874
|
-
|
|
1875
|
-
// fold overwrite (the latest invocation supersedes, absorbing), initialized on a new entry.
|
|
1876
|
-
const touchedSources = new Set(sources);
|
|
1877
|
-
const writeSources = new Map<string, string>();
|
|
1878
|
-
mergeWriteSources(writeSources, stagedKeys, sources);
|
|
1585
|
+
this.warnCrossAuthorityWrites(name, domain, touched);
|
|
1879
1586
|
const now = this.clock.now();
|
|
1880
1587
|
let f = this.folds.get(foldKey);
|
|
1881
1588
|
if (f) {
|
|
1882
1589
|
// Overwrite the single entry in place — the pending stack does NOT grow (§1 #2). The head
|
|
1883
1590
|
// already carries this new prediction (absorbing, last-wins on the cell); the entry holds
|
|
1884
1591
|
// only the LATEST args, which is what a rebase re-derives from and what the flush ships.
|
|
1592
|
+
// `domain` too: THIS invocation staged through the freshly-resolved domain's map above, so
|
|
1593
|
+
// a mid-window rebase must re-stage through the same one (the flush re-resolves anyway;
|
|
1594
|
+
// no mid is pinned yet — `entry.mid` is null until flush).
|
|
1885
1595
|
f.entry.args = args;
|
|
1886
1596
|
f.entry.touched = touched;
|
|
1887
1597
|
f.entry.writes = writes;
|
|
1888
|
-
f.entry.
|
|
1889
|
-
f.entry.writeSources = writeSources;
|
|
1598
|
+
f.entry.domain = domain;
|
|
1890
1599
|
f.args = args;
|
|
1891
1600
|
this.clock.clearTimeout(f.timer);
|
|
1892
1601
|
} else {
|
|
1893
|
-
// Provisional domain (§7.1): re-resolved from the final args/write-set at flush, when the
|
|
1894
|
-
// mid is dealt. Never read before then — an un-flushed fold (`mid == null`) is
|
|
1895
|
-
// unconditionally retained — so the full (counting) derivation deliberately does NOT run
|
|
1896
|
-
// here (it would double-bump the Q6 counters for one logical route); the flush derives.
|
|
1897
|
-
const domain = this.domainPolicy(name, args) ?? "daemon";
|
|
1898
1602
|
// §9.3: pick the window's cadence. Routing into a room ⇒ flush at roomDebounceMs so
|
|
1899
1603
|
// intermediates stream to the shared head; off the room, the caller's collapse debounce
|
|
1900
|
-
// governs.
|
|
1901
|
-
const inRoom = opts.roomDebounceMs !== undefined &&
|
|
1604
|
+
// governs.
|
|
1605
|
+
const inRoom = opts.roomDebounceMs !== undefined && domain !== "daemon";
|
|
1902
1606
|
const debounceMs = inRoom ? opts.roomDebounceMs! : opts.debounceMs ?? DEFAULT_FOLD_DEBOUNCE_MS;
|
|
1903
1607
|
const maxWaitMs = inRoom ? opts.roomDebounceMs! : opts.maxWaitMs;
|
|
1904
|
-
const entry: PendingMutation = { mid: null, seq: null, name, args, domain, touched, writes, reads: { reads: [], queries: [] }
|
|
1608
|
+
const entry: PendingMutation = { mid: null, seq: null, name, args, domain, touched, writes, reads: { reads: [], queries: [] } };
|
|
1905
1609
|
this.pendingMutations.push(entry);
|
|
1906
1610
|
let resolveMid!: (mid: number) => void;
|
|
1907
1611
|
const midPromise = new Promise<number>((res) => (resolveMid = res));
|
|
@@ -1951,13 +1655,11 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
|
|
|
1951
1655
|
if (!f) return;
|
|
1952
1656
|
this.clock.clearTimeout(f.timer);
|
|
1953
1657
|
this.folds.delete(foldKey);
|
|
1954
|
-
//
|
|
1955
|
-
// ledger — SEND order, never reserved, so gapless within the domain.
|
|
1956
|
-
// this
|
|
1957
|
-
//
|
|
1958
|
-
|
|
1959
|
-
// which is the STRONGEST read proof there is. The mid dealt below then pins this domain.
|
|
1960
|
-
const domain = this.resolveDomain(f.entry.name, f.args, f.entry.writes, f.entry.reads);
|
|
1658
|
+
// Re-resolve the DECLARED confirming stream from the FINAL args (§7.1) and deal the mid from
|
|
1659
|
+
// that domain's ledger — SEND order, never reserved, so gapless within the domain. The mid
|
|
1660
|
+
// dealt below then pins this domain. (A domain that changed since the window opened — a room
|
|
1661
|
+
// attached or dropped mid-window — re-stages on the next reconcile's re-invocation.)
|
|
1662
|
+
const domain = this.resolveDomain(f.entry.name, f.args);
|
|
1961
1663
|
f.entry.domain = domain;
|
|
1962
1664
|
const { mid, seq } = this.dealMid(domain);
|
|
1963
1665
|
f.entry.mid = mid;
|
|
@@ -2076,8 +1778,7 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
|
|
|
2076
1778
|
);
|
|
2077
1779
|
return;
|
|
2078
1780
|
}
|
|
2079
|
-
// kind === "deopt":
|
|
2080
|
-
this.routingReasons.set("deopt", (this.routingReasons.get("deopt") ?? 0) + 1);
|
|
1781
|
+
// kind === "deopt": the room gate refused a declared-room mutation — re-enqueue onto the daemon.
|
|
2081
1782
|
const entry = this.pendingMutations.find((p) => p.domain === domain && p.mid === frame.mid);
|
|
2082
1783
|
if (entry) {
|
|
2083
1784
|
entry.domain = "daemon";
|
|
@@ -2261,85 +1962,42 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
|
|
|
2261
1962
|
nextMid: Record<string, number>;
|
|
2262
1963
|
watermark: Record<string, number>;
|
|
2263
1964
|
/** Per connected CHANNEL (§5.1): its release watermark + buffered-frame depth — the axis the
|
|
2264
|
-
* gate-isolation assertions read (one source's laggy cvMin must never move the other's)
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
* for the counting discipline (per-candidate failures; pins bump nothing). */
|
|
2271
|
-
routing: RoutingInspect;
|
|
1965
|
+
* gate-isolation assertions read (one source's laggy cvMin must never move the other's). */
|
|
1966
|
+
gates: Record<string, { appliedCv: number; bufferedFrames: number }>;
|
|
1967
|
+
/** Per connected/registered room: its wire-table → engine-table map (302 §2) and which local
|
|
1968
|
+
* view qids are currently swapped onto it (302 §4). */
|
|
1969
|
+
roomTables: Record<string, Record<string, string>>;
|
|
1970
|
+
swappedViews: Record<number, string>;
|
|
2272
1971
|
/** The §4 lifecycle plane's folded state (Slice I-iii introspection): the per-doc §4.2 fence
|
|
2273
1972
|
* value (`roomWatermarks`, I-v's ghost-drop input), the per-scope §4.1 occupancy map
|
|
2274
1973
|
* (`scopeSessions`: scope → client_id → expires_at, I-iv's doorbell input), and the live
|
|
2275
|
-
* I-v ghosts (demoted room sources still awaiting their fence). */
|
|
1974
|
+
* I-v ghosts (demoted room sources still awaiting their swap-back fence). */
|
|
2276
1975
|
lifecycle: {
|
|
2277
1976
|
roomWatermarks: Record<string, number>;
|
|
2278
1977
|
scopeSessions: Record<string, Record<string, number>>;
|
|
2279
|
-
ghosts: Record<string, { doc: string; finalFlushSeq: number
|
|
1978
|
+
ghosts: Record<string, { doc: string; finalFlushSeq: number }>;
|
|
2280
1979
|
};
|
|
2281
|
-
|
|
2282
|
-
* every parked hold-back's fence inputs plus its tripwire state, keyed
|
|
2283
|
-
* `${table}\0${sourceKey}\0${pkKey}`. Pruned lazily, so an entry here may briefly outlive
|
|
2284
|
-
* its engine pin (never the reverse). */
|
|
2285
|
-
pins: Record<
|
|
2286
|
-
string,
|
|
2287
|
-
{
|
|
2288
|
-
table: string;
|
|
2289
|
-
sourceKey: string;
|
|
2290
|
-
domain: string;
|
|
2291
|
-
mid: number;
|
|
2292
|
-
daemonBoot?: string;
|
|
2293
|
-
daemonCv?: number;
|
|
2294
|
-
tripwired: boolean;
|
|
2295
|
-
}
|
|
2296
|
-
>;
|
|
2297
|
-
/** The §301 direction-A fence map: per room domain, the highest DAEMON-CARRIED ledger lmid. */
|
|
2298
|
-
daemonCarriedLmid: Record<string, number>;
|
|
2299
|
-
pending: { mid: number | null; seq: number | null; name: string; domain: string; touchedSources: string[]; writeSources: Record<string, string> }[];
|
|
1980
|
+
pending: { mid: number | null; seq: number | null; name: string; domain: string }[];
|
|
2300
1981
|
} {
|
|
2301
1982
|
return {
|
|
2302
1983
|
nextMid: Object.fromEntries(this.nextMid),
|
|
2303
1984
|
watermark: Object.fromEntries(this.watermark),
|
|
2304
1985
|
gates: Object.fromEntries(
|
|
2305
|
-
[...this.gates].map(([k, g]) => [
|
|
2306
|
-
k,
|
|
2307
|
-
{
|
|
2308
|
-
appliedCv: g.appliedCv,
|
|
2309
|
-
bufferedFrames: g.buffer.length,
|
|
2310
|
-
...(g.upstreamCv !== undefined ? { upstreamCv: g.upstreamCv } : {}),
|
|
2311
|
-
...(g.upstreamBoot !== undefined ? { upstreamBoot: g.upstreamBoot } : {}),
|
|
2312
|
-
},
|
|
2313
|
-
]),
|
|
1986
|
+
[...this.gates].map(([k, g]) => [k, { appliedCv: g.appliedCv, bufferedFrames: g.buffer.length }]),
|
|
2314
1987
|
),
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
1988
|
+
roomTables: Object.fromEntries(
|
|
1989
|
+
[...this.roomTables].map(([k, m]) => [k, Object.fromEntries(m)]),
|
|
1990
|
+
),
|
|
1991
|
+
swappedViews: Object.fromEntries(this.roomSwappedViews),
|
|
2319
1992
|
lifecycle: {
|
|
2320
1993
|
roomWatermarks: Object.fromEntries(this.roomWatermarks),
|
|
2321
1994
|
scopeSessions: Object.fromEntries(
|
|
2322
1995
|
[...this.scopeSessions].map(([scope, sessions]) => [scope, Object.fromEntries(sessions)]),
|
|
2323
1996
|
),
|
|
2324
1997
|
ghosts: Object.fromEntries(
|
|
2325
|
-
[...this.ghosts].map(([k, g]) => [k, { doc: g.doc, finalFlushSeq: g.finalFlushSeq
|
|
1998
|
+
[...this.ghosts].map(([k, g]) => [k, { doc: g.doc, finalFlushSeq: g.finalFlushSeq }]),
|
|
2326
1999
|
),
|
|
2327
2000
|
},
|
|
2328
|
-
pins: Object.fromEntries(
|
|
2329
|
-
[...this.pins].map(([k, p]) => [
|
|
2330
|
-
k,
|
|
2331
|
-
{
|
|
2332
|
-
table: p.table,
|
|
2333
|
-
sourceKey: p.sourceKey,
|
|
2334
|
-
domain: p.domain,
|
|
2335
|
-
mid: p.mid,
|
|
2336
|
-
...(p.daemonBoot !== undefined ? { daemonBoot: p.daemonBoot } : {}),
|
|
2337
|
-
...(p.daemonCv !== undefined ? { daemonCv: p.daemonCv } : {}),
|
|
2338
|
-
tripwired: p.tripwired === true,
|
|
2339
|
-
},
|
|
2340
|
-
]),
|
|
2341
|
-
),
|
|
2342
|
-
daemonCarriedLmid: Object.fromEntries(this.daemonCarriedLmid),
|
|
2343
2001
|
pending: this.pendingMutations.map((p) => ({
|
|
2344
2002
|
mid: p.mid,
|
|
2345
2003
|
// The client-global deal sequence — the REPLAY order (mids are per-domain, incomparable
|
|
@@ -2347,11 +2005,6 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
|
|
|
2347
2005
|
seq: p.seq,
|
|
2348
2006
|
name: p.name,
|
|
2349
2007
|
domain: p.domain,
|
|
2350
|
-
// The write-source axes (§5.3): which physical source(s) this entry's writes routed onto —
|
|
2351
|
-
// the filter basis, and (per pk) the hold-back's cross-slice test. The harness asserts the
|
|
2352
|
-
// filter/routing against its independent model with these.
|
|
2353
|
-
touchedSources: [...p.touchedSources],
|
|
2354
|
-
writeSources: Object.fromEntries(p.writeSources),
|
|
2355
2008
|
})),
|
|
2356
2009
|
};
|
|
2357
2010
|
}
|
|
@@ -2449,16 +2102,6 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
|
|
|
2449
2102
|
* delta, lmid → watermark) and {@link applyRelease} (per-source confirm-drop + reconcile) —
|
|
2450
2103
|
* N independent gates all feed the ONE apply half; {@link __testRelease} drives it directly. */
|
|
2451
2104
|
private onGateProgress(gate: SourceGate, frame: ProgressFrame): void {
|
|
2452
|
-
// §301 direction B: record the frame's upstream-absorption advert BEFORE the release applies,
|
|
2453
|
-
// so the drop pass at applyRelease's tail evaluates exactly this release's advert — the room
|
|
2454
|
-
// emits data-then-progress on one socket, so by the time the advert says "absorbed through
|
|
2455
|
-
// cv U" the re-published echo data is in this release's fold (never ahead of it). Verbatim,
|
|
2456
|
-
// not max-folded: on a daemon restart the advertised cv space legitimately resets and the
|
|
2457
|
-
// §2.4 boot rule (not monotonicity) carries the ordering.
|
|
2458
|
-
if (frame.upstreamCv !== undefined) {
|
|
2459
|
-
gate.upstreamCv = frame.upstreamCv;
|
|
2460
|
-
gate.upstreamBoot = frame.upstreamBoot;
|
|
2461
|
-
}
|
|
2462
2105
|
const { deltas, newlyHydrated, touchedScopes } = this.computeRelease(gate, frame);
|
|
2463
2106
|
this.applyRelease(gate.key, deltas, undefined, newlyHydrated);
|
|
2464
2107
|
// I-iv phase 2: a retargeted sub whose first ROOM snapshot released just now gets its old
|
|
@@ -2516,8 +2159,11 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
|
|
|
2516
2159
|
this.foldLmidOps(f.ops, gate.key);
|
|
2517
2160
|
continue;
|
|
2518
2161
|
}
|
|
2162
|
+
// A ROOM gate's deltas rename into the room's namespaced tables — and a wire table outside
|
|
2163
|
+
// the registered map is DROPPED (302 §6: context comes from the daemon, one authority per
|
|
2164
|
+
// table; a room's relayed context copy must never enter the store).
|
|
2519
2165
|
muts.push(
|
|
2520
|
-
...(f.kind === "snapshot" ? gate.sync.rehydrate(f.qid, f.ops) : gate.sync.applyBatch(f.qid, f.ops)),
|
|
2166
|
+
...mapGateDeltas(gate, f.kind === "snapshot" ? gate.sync.rehydrate(f.qid, f.ops) : gate.sync.applyBatch(f.qid, f.ops)),
|
|
2521
2167
|
);
|
|
2522
2168
|
// A query's first released snapshot is its hydration point — even an empty one (0 rows is an
|
|
2523
2169
|
// authoritative answer): lift every local view this sub feeds out of `unknown` (loading).
|
|
@@ -2547,38 +2193,6 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
|
|
|
2547
2193
|
if (watermarkUpdate !== undefined) {
|
|
2548
2194
|
this.watermark.set(sourceKey, Math.max(this.watermark.get(sourceKey) ?? 0, watermarkUpdate));
|
|
2549
2195
|
}
|
|
2550
|
-
// Echo hold-back (§7.3), BEFORE the confirm-drop: an entry `p` this release CONFIRMS
|
|
2551
|
-
// (`p.mid <= watermark[p.domain]`) but which staged a write onto a DIFFERENT source `S` (a
|
|
2552
|
-
// cross-slice write — e.g. a room-domain mutation whose write routed to the daemon slice) is
|
|
2553
|
-
// about to leave `pendingMutations`, so it will NOT be re-invoked on `S`'s next rewind. Park each
|
|
2554
|
-
// such write inertly on `S` so that rewind (which un-applies the not-re-invoked write) does not
|
|
2555
|
-
// flash-revert it. Two variants, one per write shape (G-i unified them engine-side):
|
|
2556
|
-
// - ADD/EDIT (`rec.row` present): pinned PRESENCE — drops when `S`'s confirmed baseline
|
|
2557
|
-
// value-matches (its echo lands — the E-i-tested drop).
|
|
2558
|
-
// - REMOVE (`rec.row === undefined`, G-iii): pinned ABSENCE — the §7.3 tombstone, parked with
|
|
2559
|
-
// the captured pre-image (`rec.oldRow`) so `S`'s rewind cannot resurrect the deleted row;
|
|
2560
|
-
// drops when `S`'s baseline lacks the pk (the delete echoed).
|
|
2561
|
-
// Inert in single-domain: every write's source equals its domain.
|
|
2562
|
-
for (const p of this.pendingMutations) {
|
|
2563
|
-
if (p.mid === null || p.mid > (this.watermark.get(p.domain) ?? 0)) continue; // not confirmed now
|
|
2564
|
-
for (const [table, byPk] of p.writes) {
|
|
2565
|
-
// The Collapsed-park gate (G-iii): NEVER park on an un-promoted table. Inert-anyway today
|
|
2566
|
-
// (`rewind_collapsed` never consults `held_back`), so skipping is behaviorally identical —
|
|
2567
|
-
// but a stale Collapsed-era entry would become overlay-first-visible (a permanent pin) if
|
|
2568
|
-
// the table later promotes. Slice H's prove-or-slow-path routing makes the real scenario
|
|
2569
|
-
// (a room-confirmed mutation writing a Collapsed table) impossible; this covers until then.
|
|
2570
|
-
if (!this.promotedTables.has(table)) continue;
|
|
2571
|
-
for (const [pkKey, rec] of byPk) {
|
|
2572
|
-
const s = p.writeSources.get(writeSourceKey(table, pkKey));
|
|
2573
|
-
if (s === undefined || s === p.domain) continue; // same-slice: the rewind replays it
|
|
2574
|
-
if (rec.row !== undefined) this.local.holdBack(table, s, rec.row);
|
|
2575
|
-
else if (rec.oldRow !== undefined) this.local.holdBackAbsent(table, s, rec.oldRow);
|
|
2576
|
-
else continue;
|
|
2577
|
-
// §301 §2.1: register the pin's delivery-fence inputs in the same breath as the park.
|
|
2578
|
-
this.registerPin(table, s, pkKey, rec.row ?? (rec.oldRow as WireValue[]), p);
|
|
2579
|
-
}
|
|
2580
|
-
}
|
|
2581
|
-
}
|
|
2582
2196
|
// Drop confirmed pending (§1.3 step 5's bookkeeping half), PER DOMAIN: an entry is retired only
|
|
2583
2197
|
// when ITS domain's watermark reaches its mid — so two concurrent streams never alias one counter
|
|
2584
2198
|
// (§7.1). A failed mutation drops the same way (the release carries no effects, so the rewind snaps
|
|
@@ -2627,11 +2241,11 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
|
|
|
2627
2241
|
for (const qid of newlyHydrated) this.handler(qid, { type: "batch", events: [], catchUp: true });
|
|
2628
2242
|
}
|
|
2629
2243
|
this.refreshPending();
|
|
2630
|
-
// The §
|
|
2631
|
-
//
|
|
2632
|
-
//
|
|
2633
|
-
//
|
|
2634
|
-
this.
|
|
2244
|
+
// The 302 §4.1 swap-in — strictly AFTER the reconcile above folded this release's data, so a
|
|
2245
|
+
// room sub whose first snapshot just released swaps its views onto room tables that already
|
|
2246
|
+
// hold the snapshot (swapping earlier would hydrate them empty). Structural no-op with no
|
|
2247
|
+
// pending swap (every single-domain client).
|
|
2248
|
+
this.processSwapIns();
|
|
2635
2249
|
// The I-v ghost-drop watcher (§4.2), LAST: this release's watermark rows have folded
|
|
2636
2250
|
// (computeRelease) and its confirm-drop has retired what it covers — exactly the two inputs
|
|
2637
2251
|
// the drop condition reads. Structural no-op with no ghost.
|
|
@@ -2647,164 +2261,45 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
|
|
|
2647
2261
|
this.applyRelease(sourceKey, deltas, watermarkUpdate);
|
|
2648
2262
|
}
|
|
2649
2263
|
|
|
2650
|
-
// --- the §
|
|
2264
|
+
// --- the 302 §4 swap-in ------------------------------------------------------------------
|
|
2651
2265
|
|
|
2652
|
-
/**
|
|
2653
|
-
* the engine
|
|
2654
|
-
*
|
|
2655
|
-
*
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
|
|
2665
|
-
|
|
2666
|
-
|
|
2667
|
-
domain: p.domain,
|
|
2668
|
-
mid: p.mid as number,
|
|
2669
|
-
};
|
|
2670
|
-
if (sourceKey !== "daemon" && p.domain === "daemon") {
|
|
2671
|
-
// Direction B stamps (§1.2): the parking daemon release's coherence position — the confirm
|
|
2672
|
-
// folded at-or-before it, so a room advertising absorption ≥ this cv has absorbed the
|
|
2673
|
-
// commit that carried the write — plus the client-observed boot it belongs to (§2.4).
|
|
2674
|
-
pin.daemonCv = this.daemonGate.appliedCv;
|
|
2675
|
-
if (this.daemonBootId !== undefined) pin.daemonBoot = this.daemonBootId;
|
|
2676
|
-
}
|
|
2677
|
-
// The tripwire reference (§2.5): the slice's confirmed-baseline row at park.
|
|
2678
|
-
pin.baselineAtPark = this.local.heldBackState(table, sourceKey, probeRow)?.baseline as
|
|
2679
|
-
| WireValue[]
|
|
2680
|
-
| undefined;
|
|
2681
|
-
this.pins.set(`${table}\0${sourceKey}\0${pkKey}`, pin);
|
|
2682
|
-
}
|
|
2683
|
-
|
|
2684
|
-
/** The §2.3 drop pass, run at the tail of every applied release: prune registry entries whose
|
|
2685
|
-
* engine pin is already gone (the rewind's state-match fallback or a whole-source removal beat
|
|
2686
|
-
* the fence — benign), drop every pin whose fence cleared (one engine drop each, delivered on
|
|
2687
|
-
* the ordinary event stream, bracketed as ONE notification commit), and tripwire the rest. */
|
|
2688
|
-
private dropEchoFencePins(): void {
|
|
2689
|
-
if (this.pins.size === 0) return; // every single-domain client: structural no-op
|
|
2690
|
-
let cleared: [string, EchoFencePin][] | null = null;
|
|
2691
|
-
for (const [key, pin] of this.pins) {
|
|
2692
|
-
const state = this.local.heldBackState(pin.table, pin.sourceKey, pin.probeRow);
|
|
2693
|
-
if (state === undefined) {
|
|
2694
|
-
this.pins.delete(key); // engine pin gone (state-match / source removal): lazy prune
|
|
2695
|
-
continue;
|
|
2696
|
-
}
|
|
2697
|
-
if (this.pinFenceCleared(pin)) (cleared ??= []).push([key, pin]);
|
|
2698
|
-
else this.maybeTripwirePin(key, pin, state.baseline as WireValue[] | undefined);
|
|
2699
|
-
}
|
|
2700
|
-
if (!cleared) return;
|
|
2266
|
+
/** Swap every view of each just-hydrated ROOM sub onto the room's namespaced tables (302 §4.1):
|
|
2267
|
+
* re-register the local engine query with the AST's room-owned table references renamed
|
|
2268
|
+
* ({@link remapAstTables}); the Store folds the re-hello as an in-place reset, so the caller's
|
|
2269
|
+
* view reference survives and subscribers see ONE transition. Runs at the applyRelease tail —
|
|
2270
|
+
* the reconcile has already folded the sub's snapshot into the room tables, so the swapped
|
|
2271
|
+
* view hydrates straight to the room state (swapping earlier would flash it empty). The
|
|
2272
|
+
* ORIGINAL ast stays in {@link asts}; the swap-back ({@link dropGhost}) re-registers it.
|
|
2273
|
+
*
|
|
2274
|
+
* This is the accepted-flash boundary (302 §4.1/§7.1): the room's copy may be behind the
|
|
2275
|
+
* daemon rows the view showed a moment ago — accepted by decision, revisit on a real
|
|
2276
|
+
* two-region deploy. */
|
|
2277
|
+
private processSwapIns(): void {
|
|
2278
|
+
if (this.pendingSwapIns.size === 0) return; // every single-domain release: structural no-op
|
|
2279
|
+
const subs = [...this.pendingSwapIns];
|
|
2280
|
+
this.pendingSwapIns.clear();
|
|
2701
2281
|
this.inOneCommit(() => {
|
|
2702
|
-
for (const
|
|
2703
|
-
this.
|
|
2704
|
-
|
|
2282
|
+
for (const sub of subs) {
|
|
2283
|
+
const map = this.roomTables.get(sub.channel);
|
|
2284
|
+
for (const qid of sub.localQids.keys()) {
|
|
2285
|
+
const ast = this.asts.get(qid);
|
|
2286
|
+
if (ast === undefined) continue;
|
|
2287
|
+
if (map === undefined || map.size === 0) continue; // no owned tables — nothing to swap
|
|
2288
|
+
if (this.roomSwappedViews.get(qid) === sub.channel) continue; // already swapped
|
|
2289
|
+
const rewritten = remapAstTables(this.plainEngineAst(ast), map);
|
|
2290
|
+
this.local.unregisterQuery(qid);
|
|
2291
|
+
this.local.registerQuery(qid, rewritten);
|
|
2292
|
+
this.roomSwappedViews.set(qid, sub.channel);
|
|
2293
|
+
// The pending axis follows the engine tables the view now reads (union — the wire
|
|
2294
|
+
// names stay too, conservatively: a daemon-declared write to a room-visible table is
|
|
2295
|
+
// still an honest "pending elsewhere" signal).
|
|
2296
|
+
const tables = this.queryTables.get(qid);
|
|
2297
|
+
if (tables) for (const t of map.values()) tables.add(t);
|
|
2298
|
+
}
|
|
2705
2299
|
}
|
|
2706
2300
|
});
|
|
2707
2301
|
}
|
|
2708
2302
|
|
|
2709
|
-
/** Has `pin`'s delivery fence provably closed its confirm→echo window? (§2.3/§2.4.) */
|
|
2710
|
-
private pinFenceCleared(pin: EchoFencePin): boolean {
|
|
2711
|
-
if (pin.sourceKey === "daemon") {
|
|
2712
|
-
// Direction A: the daemon-carried ledger for the confirming room domain covers the mid —
|
|
2713
|
-
// the I-ii co-commit means this release (or an earlier one) folded the flush data that
|
|
2714
|
-
// carried it into the daemon baseline (§1.1).
|
|
2715
|
-
return (this.daemonCarriedLmid.get(pin.domain) ?? 0) >= pin.mid;
|
|
2716
|
-
}
|
|
2717
|
-
// Direction B: only a daemon-confirmed write has the daemon-cv stamp; a room-staged pin
|
|
2718
|
-
// confirmed by ANOTHER room (outside today's two-tier topology) has no fence.
|
|
2719
|
-
if (pin.daemonCv === undefined) return false;
|
|
2720
|
-
const gate = this.gates.get(pin.sourceKey);
|
|
2721
|
-
if (!gate || gate.upstreamCv === undefined) return false; // no advert (old shell): §2.5 fallback
|
|
2722
|
-
if (gate.upstreamBoot === pin.daemonBoot) return gate.upstreamCv >= pin.daemonCv;
|
|
2723
|
-
// Boot mismatch (§2.4): a boot the client has OBSERVED as later proves absorption (the
|
|
2724
|
-
// room's post-restart re-snapshot came from daemon state that durably includes the
|
|
2725
|
-
// confirmed write); an unknown/older/unstamped boot holds — conservative, its next
|
|
2726
|
-
// re-snapshot advances it.
|
|
2727
|
-
if (pin.daemonBoot === undefined || gate.upstreamBoot === undefined) return false;
|
|
2728
|
-
const pinOrd = this.daemonBootOrdinals.get(pin.daemonBoot);
|
|
2729
|
-
const advOrd = this.daemonBootOrdinals.get(gate.upstreamBoot);
|
|
2730
|
-
return pinOrd !== undefined && advOrd !== undefined && advOrd > pinOrd;
|
|
2731
|
-
}
|
|
2732
|
-
|
|
2733
|
-
/** The §2.5 stuck-pin tripwire, in the scopesHash spirit: log ONCE per pin when its slice's
|
|
2734
|
-
* baseline row has CHANGED VALUE since park while the pin still holds — the suspicious state
|
|
2735
|
-
* that precedes every forever-pin (a fence-less pairing, a fence bug). Never drops anything. */
|
|
2736
|
-
private maybeTripwirePin(key: string, pin: EchoFencePin, baseline: WireValue[] | undefined): void {
|
|
2737
|
-
if (pin.tripwired) return;
|
|
2738
|
-
const same =
|
|
2739
|
-
pin.baselineAtPark === undefined || baseline === undefined
|
|
2740
|
-
? pin.baselineAtPark === baseline
|
|
2741
|
-
: pin.baselineAtPark.length === baseline.length &&
|
|
2742
|
-
pin.baselineAtPark.every((c, i) => identicalCell(c, baseline[i]));
|
|
2743
|
-
if (same) return;
|
|
2744
|
-
pin.tripwired = true;
|
|
2745
|
-
const [table, sourceKey, pkKey] = key.split("\0");
|
|
2746
|
-
console.warn(
|
|
2747
|
-
`rindle: a §7.3 hold-back pin on table "${table}" slice "${sourceKey}" pk ${pkKey} (confirming domain ${pin.domain}, mid ${pin.mid}) is parked while its slice's baseline moved past it — if this client never converges on that row, this pin is why (301-ECHO-FENCE-DESIGN.md §2.5).`,
|
|
2748
|
-
);
|
|
2749
|
-
}
|
|
2750
|
-
|
|
2751
|
-
/** Record one observed daemon boot id (§2.4): first observation of an id assigns the next
|
|
2752
|
-
* ordinal (the client's own total order over opaque boot ids); every call refreshes the
|
|
2753
|
-
* current-boot stamp for direction-B parks. */
|
|
2754
|
-
private observeDaemonBoot(bootId: string): void {
|
|
2755
|
-
if (!this.daemonBootOrdinals.has(bootId)) {
|
|
2756
|
-
this.daemonBootOrdinals.set(bootId, this.daemonBootOrdinals.size);
|
|
2757
|
-
}
|
|
2758
|
-
this.daemonBootId = bootId;
|
|
2759
|
-
}
|
|
2760
|
-
|
|
2761
|
-
/** Promote `table` to a MERGED multi-source engine with room `sourceKey`'s per-row writable
|
|
2762
|
-
* scope (RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md §5.2) — THE one promotion seam. The engine
|
|
2763
|
-
* attach and the {@link promotedTables} record move in the same breath: the §7.3 hold-back
|
|
2764
|
-
* trigger in {@link applyRelease} parks ONLY on promoted tables, so a promotion that bypassed
|
|
2765
|
-
* the record would silently disable the echo hold-back for that table (and a record without the
|
|
2766
|
-
* engine attach would park onto a slice that doesn't exist). Recorded AFTER the engine accepts —
|
|
2767
|
-
* a rejected descriptor/table must not leave a phantom promotion. Slice G-v's client drives this
|
|
2768
|
-
* from the lease's `realtime.tables` (`RoomTableSpec` → {@link WritableDescriptor}); idempotence
|
|
2769
|
-
* per `(sourceKey, table)` is the CALLER's job (the engine refuses a duplicate room).
|
|
2770
|
-
*
|
|
2771
|
-
* `spec` (H-iii) is the per-table ROUTING spec riding the same lease table block — recorded
|
|
2772
|
-
* into {@link roomRouting} (THE routing table the §3 router reads) in the same breath, with
|
|
2773
|
-
* `writable` derived from the descriptor's kind (`none` = a context table the room may not
|
|
2774
|
-
* write). Omitted (the E-iii-b harness alias below) ⇒ an empty spec: no join keys to guard, no
|
|
2775
|
-
* `footprintWhere` (that room's reads then fail closed to the daemon unless self/room-served). */
|
|
2776
|
-
promoteRoomTable(
|
|
2777
|
-
table: string,
|
|
2778
|
-
sourceKey: string,
|
|
2779
|
-
writable: WritableDescriptor,
|
|
2780
|
-
spec?: RoomTableRoutingSpec,
|
|
2781
|
-
): void {
|
|
2782
|
-
this.local.addRoomSource(table, sourceKey, writable);
|
|
2783
|
-
this.promotedTables.add(table);
|
|
2784
|
-
let byTable = this.roomRouting.get(sourceKey);
|
|
2785
|
-
if (!byTable) this.roomRouting.set(sourceKey, (byTable = new Map()));
|
|
2786
|
-
byTable.set(table, {
|
|
2787
|
-
writable: writable.kind !== "none",
|
|
2788
|
-
joinKeyCols: [...(spec?.joinKeyCols ?? [])],
|
|
2789
|
-
...(spec?.where !== undefined ? { where: spec.where } : {}),
|
|
2790
|
-
...(spec?.footprintWhere !== undefined ? { footprintWhere: spec.footprintWhere } : {}),
|
|
2791
|
-
});
|
|
2792
|
-
}
|
|
2793
|
-
|
|
2794
|
-
/** The recorded routing specs for `sourceKey`'s promoted tables (table → its
|
|
2795
|
-
* {@link RoomTableRouting}) — read-only: the client's `__realtimeInspect`/idempotence
|
|
2796
|
-
* bookkeeping reads THIS record instead of keeping its own shadow copy (one source of truth).
|
|
2797
|
-
* Empty map when the room has promoted nothing. */
|
|
2798
|
-
roomTablesFor(sourceKey: string): ReadonlyMap<string, RoomTableRouting> {
|
|
2799
|
-
return this.roomRouting.get(sourceKey) ?? EMPTY_ROUTING;
|
|
2800
|
-
}
|
|
2801
|
-
|
|
2802
|
-
/** Test-named alias of {@link promoteRoomTable} (E-iii-b scaffolding — the multi-domain oracle
|
|
2803
|
-
* and per-source-gate suites drive it). Pure delegation: ONE body, one `promotedTables` record. */
|
|
2804
|
-
__addRoomSource(table: string, roomKey: string, writable: WritableDescriptor, spec?: RoomTableRoutingSpec): void {
|
|
2805
|
-
this.promoteRoomTable(table, roomKey, writable, spec);
|
|
2806
|
-
}
|
|
2807
|
-
|
|
2808
2303
|
/** Fold `domain`'s lmid system query's released ops (lmid-as-data): the one row's
|
|
2809
2304
|
* `last_mutation_id` cell is this client's confirmed high-water mid in that domain — it advances
|
|
2810
2305
|
* `watermark[domain]` and, on a fresh session ahead of our issued mids, `nextMid[domain]`. The
|
|
@@ -2935,16 +2430,7 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
|
|
|
2935
2430
|
const [doc, clientId, lmid] = cells;
|
|
2936
2431
|
if (typeof doc !== "string" || clientId !== this.clientID) continue;
|
|
2937
2432
|
if (spec.doc !== undefined && doc !== spec.doc) continue;
|
|
2938
|
-
|
|
2939
|
-
this.foldConfirm(roomDomainKey(doc), covered);
|
|
2940
|
-
// The §301 direction-A fence input: the DAEMON-CARRIED ledger fold ALONE (the room
|
|
2941
|
-
// socket's own lmid stream confirms long before the flush reaches the daemon, so it
|
|
2942
|
-
// folds the shared watermark above but never this map — 301 §1.1). Max-fold: ledger
|
|
2943
|
-
// rows re-deliver across re-hydrates.
|
|
2944
|
-
if (Number.isFinite(covered)) {
|
|
2945
|
-
const dom = roomDomainKey(doc);
|
|
2946
|
-
this.daemonCarriedLmid.set(dom, Math.max(this.daemonCarriedLmid.get(dom) ?? 0, covered));
|
|
2947
|
-
}
|
|
2433
|
+
this.foldConfirm(roomDomainKey(doc), Number(lmid));
|
|
2948
2434
|
}
|
|
2949
2435
|
}
|
|
2950
2436
|
// (3) watermark rows → the monotone §4.2 fence value per doc.
|
|
@@ -3023,58 +2509,53 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
|
|
|
3023
2509
|
* deliver the coalesced result (`serverBatchEnd`). This is the engine's only sync-moving
|
|
3024
2510
|
* boundary — `onProgress` releases and `unregisterQuery`'s GC both go through here so head
|
|
3025
2511
|
* and sync never diverge (the §1.2 invariant; CRIT#2). */
|
|
3026
|
-
private runReconcileCycle(
|
|
3027
|
-
// `
|
|
3028
|
-
// the GC path), a `room:doc:X` string on a
|
|
3029
|
-
//
|
|
3030
|
-
//
|
|
3031
|
-
|
|
3032
|
-
//
|
|
3033
|
-
|
|
3034
|
-
//
|
|
3035
|
-
//
|
|
3036
|
-
|
|
3037
|
-
|
|
3038
|
-
|
|
3039
|
-
|
|
3040
|
-
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
|
|
2512
|
+
private runReconcileCycle(_sourceKey: string, serverDeltas: Mutation[]): void {
|
|
2513
|
+
// `_sourceKey` names the authority these `deltas` confirm — `"daemon"` on the live daemon
|
|
2514
|
+
// path (and the GC path), a `room:doc:X` string on a room release, whose deltas already carry
|
|
2515
|
+
// the room's ENGINE table names (the gate's rename/filter). Kept for call-site readability
|
|
2516
|
+
// and tracing only: the engine itself is source-agnostic (302: one authority per table) — its
|
|
2517
|
+
// rewind covers EVERY tracked table and every pending mutation re-invokes below regardless of
|
|
2518
|
+
// which channel released, so NOTHING in this cycle may branch on it.
|
|
2519
|
+
this.local.serverBatchBegin(serverDeltas.map(toServerOp));
|
|
2520
|
+
// The rewind covers EVERY tracked table (302: the engine is source-agnostic — there is no
|
|
2521
|
+
// per-source rewind) — including the `__agg_*` head rows — whichever channel released. So the
|
|
2522
|
+
// optimistic agg delta rebuilds on EVERY cycle, room or daemon: reset here, re-observe from
|
|
2523
|
+
// the re-invoked pending set below, re-apply onto the rewound heads at the end. Gating any of
|
|
2524
|
+
// the three on a daemon-only cycle (the pre-302 per-source-rewind contract) would let a room
|
|
2525
|
+
// release wipe the optimistic `__agg` edits and skip the rebuild — every count() view snaps
|
|
2526
|
+
// back to the server base until the next daemon release. The delta stays sound across
|
|
2527
|
+
// domains: `reconcileAggHead` recomputes each head as the absolute `server_base ⊕ delta`,
|
|
2528
|
+
// and the server base (`this.sync`) only moves on daemon releases.
|
|
2529
|
+
this.overlay.reset();
|
|
3044
2530
|
// Sort ALL pending into SEND order (the client-global `seq` ascending, then unflushed folds
|
|
3045
2531
|
// last by creation order — the deterministic §4.1 slot; the comparator is explicit, NOT
|
|
3046
|
-
// `(seq ?? ∞) - (seq ?? ∞)` which is `∞ - ∞ = NaN` and corrupts V8's sort)
|
|
3047
|
-
//
|
|
3048
|
-
//
|
|
3049
|
-
//
|
|
3050
|
-
//
|
|
3051
|
-
//
|
|
3052
|
-
//
|
|
3053
|
-
//
|
|
3054
|
-
//
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
.
|
|
3060
|
-
|
|
3061
|
-
if (a.seq === null) return 1; // an unflushed fold sorts after every dealt seq
|
|
3062
|
-
if (b.seq === null) return -1;
|
|
3063
|
-
return a.seq - b.seq;
|
|
3064
|
-
})
|
|
3065
|
-
.filter((p) => p.touchedSources.has(sourceKey));
|
|
2532
|
+
// `(seq ?? ∞) - (seq ?? ∞)` which is `∞ - ∞ = NaN` and corrupts V8's sort). The key MUST be
|
|
2533
|
+
// `seq`, never `mid`: mids are per-domain (§7.1) so mids from different domains are
|
|
2534
|
+
// incomparable — a mid-sort would replay a room mid 1 before a daemon mid 5 that was sent
|
|
2535
|
+
// FIRST, letting a read-dependent mutator re-predict from a base it never saw (confirmation
|
|
2536
|
+
// order is per-domain; replay order is client-global). EVERY entry re-invokes — the engine's
|
|
2537
|
+
// rewind covers every tracked table (302: there is no per-source rewind), so every entry's
|
|
2538
|
+
// staged writes were just un-applied, whichever channel released. Single-domain: seq order ==
|
|
2539
|
+
// mid order (except H-v deopt re-enqueues, which keep their ORIGINAL seq under a later daemon
|
|
2540
|
+
// mid — deliberately, so this very sort replays them at their original overlay position).
|
|
2541
|
+
const order = [...this.pendingMutations].sort((a, b) => {
|
|
2542
|
+
if (a.seq === null && b.seq === null) return 0; // both unflushed → stable creation order
|
|
2543
|
+
if (a.seq === null) return 1; // an unflushed fold sorts after every dealt seq
|
|
2544
|
+
if (b.seq === null) return -1;
|
|
2545
|
+
return a.seq - b.seq;
|
|
2546
|
+
});
|
|
3066
2547
|
const dropped = new Set<PendingMutation>();
|
|
3067
2548
|
try {
|
|
3068
2549
|
for (const p of order) {
|
|
3069
2550
|
// NO `readLog` here — recording is armed only on the initial `invoke` (§3.2 #2 note on
|
|
3070
2551
|
// `PendingMutation.reads`); a re-invocation's write-set still needs fresh capture (below).
|
|
2552
|
+
// The staging map follows the entry's CURRENT domain — a deopt-flipped or re-routed entry
|
|
2553
|
+
// re-stages onto its new domain's tables here.
|
|
3071
2554
|
const writes: WriteSet = new Map();
|
|
3072
2555
|
const ops: ChildOp[] = [];
|
|
3073
|
-
const stagedKeys: string[] = [];
|
|
3074
|
-
let sources: string[] = [];
|
|
3075
2556
|
try {
|
|
3076
|
-
|
|
3077
|
-
this.runMutator(this.registry[p.name], trackingTx(tx, writes, this.specs, this.localTables, this.opCollector(ops), false, undefined,
|
|
2557
|
+
this.local.writeWith((tx) => {
|
|
2558
|
+
this.runMutator(this.registry[p.name], trackingTx(tx, writes, this.specs, this.localTables, this.opCollector(ops), false, undefined, this.stagingMap(p.domain)), p.args);
|
|
3078
2559
|
});
|
|
3079
2560
|
} catch {
|
|
3080
2561
|
// A re-invocation threw — e.g. a read-dependent mutator whose base row the server
|
|
@@ -3086,25 +2567,20 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
|
|
|
3086
2567
|
dropped.add(p);
|
|
3087
2568
|
continue;
|
|
3088
2569
|
}
|
|
3089
|
-
// The re-invocation stuck — fold its child ops into the rebuilt optimistic agg delta
|
|
3090
|
-
|
|
3091
|
-
if (daemonCycle) for (const op of ops) this.overlay.observe(op);
|
|
2570
|
+
// The re-invocation stuck — fold its child ops into the rebuilt optimistic agg delta.
|
|
2571
|
+
for (const op of ops) this.overlay.observe(op);
|
|
3092
2572
|
// The pending footprint is the UNION across invocations: a re-run that no-ops (touched =
|
|
3093
2573
|
// {}) must NOT shrink it, else a still-pending mutation reports not-pending and its
|
|
3094
|
-
// pending-axis clear fires early (§7.2). `writes
|
|
3095
|
-
// replace. (A re-invocation may route differently, e.g. onto a source the room feed now
|
|
3096
|
-
// holds — union so the filter still catches it on either source's next cycle.)
|
|
2574
|
+
// pending-axis clear fires early (§7.2). `writes` mirrors this: merge, never replace.
|
|
3097
2575
|
for (const t of writes.keys()) p.touched.add(t);
|
|
3098
2576
|
mergeWriteSet(p.writes, writes);
|
|
3099
|
-
for (const s of sources) p.touchedSources.add(s);
|
|
3100
|
-
mergeWriteSources(p.writeSources, stagedKeys, sources);
|
|
3101
2577
|
}
|
|
3102
2578
|
// Preserve creation order in the live array (the unflushed-fold sort tiebreak depends on it).
|
|
3103
2579
|
if (dropped.size) this.pendingMutations = this.pendingMutations.filter((p) => !dropped.has(p));
|
|
3104
2580
|
// Re-apply the optimistic agg delta onto the (rewound) `__agg` head rows — INSIDE the open
|
|
3105
2581
|
// cycle, so the writes buffer and coalesce into the one per-query delivery `serverBatchEnd`
|
|
3106
|
-
// makes (and never escape as a separate batch).
|
|
3107
|
-
|
|
2582
|
+
// makes (and never escape as a separate batch). Every cycle (see the reset above).
|
|
2583
|
+
this.reconcileAggHead();
|
|
3108
2584
|
} finally {
|
|
3109
2585
|
this.local.serverBatchEnd(); // ALWAYS close the cycle — ONE delivery per affected query.
|
|
3110
2586
|
}
|
|
@@ -3121,10 +2597,6 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
|
|
|
3121
2597
|
private resetGate(gate: SourceGate): void {
|
|
3122
2598
|
gate.buffer = [];
|
|
3123
2599
|
gate.appliedCv = 0;
|
|
3124
|
-
// The §301 upstream-absorption advert dies with the incarnation that made it — the fresh
|
|
3125
|
-
// one re-advertises (direction-B pins conservatively hold until it does).
|
|
3126
|
-
delete gate.upstreamBoot;
|
|
3127
|
-
delete gate.upstreamCv;
|
|
3128
2600
|
}
|
|
3129
2601
|
|
|
3130
2602
|
/** The §8.5 escape: ONE gate's buffer outgrew its cap (a pinned `cvMin` under churn on that
|
|
@@ -3160,14 +2632,17 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
|
|
|
3160
2632
|
}
|
|
3161
2633
|
|
|
3162
2634
|
/** A remote sub's first snapshot landed: mark it (and every local view it feeds) hydrated, then
|
|
3163
|
-
* lift those views out of `unknown` (loading).
|
|
3164
|
-
*
|
|
2635
|
+
* lift those views out of `unknown` (loading). A ROOM sub's hydration additionally queues the
|
|
2636
|
+
* 302 §4.1 swap-in — performed at the applyRelease TAIL ({@link processSwapIns}), once the
|
|
2637
|
+
* reconcile has folded this snapshot into the room tables. Idempotent — a re-hydrate snapshot
|
|
2638
|
+
* re-marks harmlessly; a source qid with no sub (the lmid system query) is a no-op. */
|
|
3165
2639
|
private markSubHydrated(sourceQid: QueryId): void {
|
|
3166
2640
|
const key = this.sourceToRemote.get(sourceQid);
|
|
3167
2641
|
if (!key) return;
|
|
3168
2642
|
const sub = this.remoteSubs.get(key);
|
|
3169
2643
|
if (!sub || sub.hydrated) return;
|
|
3170
2644
|
sub.hydrated = true;
|
|
2645
|
+
if (sub.channel !== "daemon" && !this.systemQids.has(sub.sourceQid)) this.pendingSwapIns.add(sub);
|
|
3171
2646
|
for (const localQid of sub.localQids.keys()) {
|
|
3172
2647
|
this.hydrated.add(localQid);
|
|
3173
2648
|
this.recomputeResultType(localQid);
|
|
@@ -3205,9 +2680,31 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
|
|
|
3205
2680
|
// A late-joiner to an already-hydrated sub is immediately hydrated; otherwise this view now
|
|
3206
2681
|
// awaits the sub's first snapshot (so a split-path local view registered `complete` flips to
|
|
3207
2682
|
// `unknown` here). Then recompute its lifecycle.
|
|
3208
|
-
if (sub.hydrated)
|
|
3209
|
-
|
|
3210
|
-
|
|
2683
|
+
if (sub.hydrated) {
|
|
2684
|
+
this.hydrated.add(localQueryId);
|
|
2685
|
+
// 302 §4.1 LATE JOIN: a ROOM sub's one-shot swap queue ({@link markSubHydrated}) fired at
|
|
2686
|
+
// its first released snapshot — long gone by now — so a view attaching afterwards must
|
|
2687
|
+
// swap onto the room's namespaced tables HERE, or its engine query stays registered on
|
|
2688
|
+
// the plain daemon tables the room channel never feeds (empty/stale, reported complete,
|
|
2689
|
+
// diverging from its already-swapped siblings forever). The room tables already hold the
|
|
2690
|
+
// released state (hydrated ⇒ folded), so swapping immediately is the ordinary
|
|
2691
|
+
// after-the-data order; processSwapIns skips already-swapped siblings, and
|
|
2692
|
+
// pendingSwapIns is empty outside a release, so exactly this sub's un-swapped views move.
|
|
2693
|
+
if (sub.channel !== "daemon" && !this.systemQids.has(sub.sourceQid)) {
|
|
2694
|
+
this.pendingSwapIns.add(sub);
|
|
2695
|
+
this.processSwapIns();
|
|
2696
|
+
}
|
|
2697
|
+
// FORCE the notify past setResultType's dedup: the labeled split registers the local
|
|
2698
|
+
// half `complete`, then flips the STORE view to `unknown` for the lease window WITHOUT
|
|
2699
|
+
// touching our record — so a complete→complete recompute here would swallow the event
|
|
2700
|
+
// and strand the late-joining view `unknown` forever. Redundant notifies are idempotent
|
|
2701
|
+
// Store-side; a swallowed transition is not recoverable.
|
|
2702
|
+
this.resultTypes.set(localQueryId, "complete");
|
|
2703
|
+
this.resultTypeHandler(localQueryId, "complete");
|
|
2704
|
+
} else {
|
|
2705
|
+
this.hydrated.delete(localQueryId);
|
|
2706
|
+
this.recomputeResultType(localQueryId);
|
|
2707
|
+
}
|
|
3211
2708
|
}
|
|
3212
2709
|
this.localToRemote.set(retainQid, key);
|
|
3213
2710
|
this.remoteRetainToLocal.set(retainQid, localQueryId);
|
|
@@ -3346,103 +2843,53 @@ function mergeWriteSet(dest: WriteSet, src: WriteSet): void {
|
|
|
3346
2843
|
}
|
|
3347
2844
|
}
|
|
3348
2845
|
|
|
3349
|
-
|
|
3350
|
-
* + NUL + the pk-key (`stableJson(pk)`). NUL appears in neither a table name nor `stableJson`
|
|
3351
|
-
* output, so it is an unambiguous separator — the same key the confirm-drop hold-back reconstructs
|
|
3352
|
-
* from `(table, pkKey)` while iterating `writes`. */
|
|
3353
|
-
function writeSourceKey(table: string, pkKey: string): string {
|
|
3354
|
-
return `${table}\u0000${pkKey}`;
|
|
3355
|
-
}
|
|
3356
|
-
|
|
3357
|
-
/** Zip the staged-order per-write source keys (`stagedKeys`, from `trackingTx`) with the staged-order
|
|
3358
|
-
* `sources[]` (`commitTracked`) into `dest`, last-write-per-key winning (§5.3). Never shrinks `dest`
|
|
3359
|
-
* — a rebase re-invocation MERGES its fresh routing in, mirroring {@link mergeWriteSet}. The two
|
|
3360
|
-
* arrays align 1:1: `trackingTx` pushes exactly one key per staged change, and `commitTracked`
|
|
3361
|
-
* returns one source per staged change, both in staged order. */
|
|
3362
|
-
function mergeWriteSources(dest: Map<string, string>, stagedKeys: string[], sources: string[]): void {
|
|
3363
|
-
const n = Math.min(stagedKeys.length, sources.length);
|
|
3364
|
-
for (let i = 0; i < n; i++) dest.set(stagedKeys[i], sources[i]);
|
|
3365
|
-
}
|
|
3366
|
-
|
|
3367
|
-
// --- the §3 router's pure helpers (H-iii) --------------------------------------------
|
|
2846
|
+
// --- the 302 room-table helpers -------------------------------------------------------
|
|
3368
2847
|
|
|
3369
|
-
/**
|
|
3370
|
-
*
|
|
3371
|
-
*
|
|
3372
|
-
*
|
|
3373
|
-
function
|
|
3374
|
-
return
|
|
2848
|
+
/** The namespaced ENGINE table backing wire `table` for room `sourceKey` (302 §2: `room_deck` ≠
|
|
2849
|
+
* `deck` — one authority per table). `@` appears in no schema table name — ENFORCED by
|
|
2850
|
+
* `createSchema`/`extendSchema`'s addTableMeta ban (packages/client/src/schema.ts), so the name
|
|
2851
|
+
* cannot collide with a real table. */
|
|
2852
|
+
export function roomEngineTable(table: string, sourceKey: string): string {
|
|
2853
|
+
return `${table}@${sourceKey}`;
|
|
3375
2854
|
}
|
|
3376
2855
|
|
|
3377
|
-
/**
|
|
3378
|
-
*
|
|
3379
|
-
*
|
|
3380
|
-
|
|
3381
|
-
|
|
3382
|
-
|
|
3383
|
-
|
|
3384
|
-
|
|
3385
|
-
|
|
3386
|
-
|
|
3387
|
-
|
|
3388
|
-
if (cond.right.type === "column" && !pkCols.has(cond.right.name)) return false;
|
|
3389
|
-
return true;
|
|
3390
|
-
}
|
|
3391
|
-
case "and":
|
|
3392
|
-
case "or":
|
|
3393
|
-
return cond.conditions.every((c) => keyDecidable(c, pkCols));
|
|
3394
|
-
case "correlatedSubquery":
|
|
3395
|
-
return false;
|
|
2856
|
+
/** Rename a room gate's released deltas into the room's namespaced tables, DROPPING deltas for
|
|
2857
|
+
* wire tables outside the map (context / unknown — the daemon is their sole authority, 302 §6).
|
|
2858
|
+
* Identity (no copy) for a map-less gate — the daemon path is untouched. */
|
|
2859
|
+
function mapGateDeltas(gate: SourceGate, muts: Mutation[]): Mutation[] {
|
|
2860
|
+
const map = gate.tableMap;
|
|
2861
|
+
if (map === undefined) return muts;
|
|
2862
|
+
const out: Mutation[] = [];
|
|
2863
|
+
for (const m of muts) {
|
|
2864
|
+
const engineTable = map.get(m.table);
|
|
2865
|
+
if (engineTable === undefined) continue;
|
|
2866
|
+
out.push({ ...m, table: engineTable });
|
|
3396
2867
|
}
|
|
2868
|
+
return out;
|
|
3397
2869
|
}
|
|
3398
2870
|
|
|
3399
|
-
/**
|
|
3400
|
-
*
|
|
3401
|
-
*
|
|
3402
|
-
*
|
|
3403
|
-
*
|
|
3404
|
-
*
|
|
3405
|
-
*
|
|
3406
|
-
|
|
3407
|
-
|
|
3408
|
-
|
|
3409
|
-
|
|
3410
|
-
|
|
3411
|
-
|
|
3412
|
-
|
|
3413
|
-
|
|
3414
|
-
|
|
3415
|
-
|
|
3416
|
-
|
|
3417
|
-
|
|
3418
|
-
|
|
3419
|
-
|
|
3420
|
-
|
|
3421
|
-
if (typeof cell !== typeof value) return undefined; // no cross-type comparison semantics
|
|
3422
|
-
if (typeof cell !== "string" && typeof cell !== "number" && typeof cell !== "boolean") return undefined;
|
|
3423
|
-
return cond.op === "=" ? cell === value : cell !== value;
|
|
3424
|
-
}
|
|
3425
|
-
case "and": {
|
|
3426
|
-
let out = true;
|
|
3427
|
-
for (const c of cond.conditions) {
|
|
3428
|
-
const v = evalFootprintOnPk(c, pkCells);
|
|
3429
|
-
if (v === undefined) return undefined;
|
|
3430
|
-
out = out && v;
|
|
3431
|
-
}
|
|
3432
|
-
return out;
|
|
3433
|
-
}
|
|
3434
|
-
case "or": {
|
|
3435
|
-
let out = false;
|
|
3436
|
-
for (const c of cond.conditions) {
|
|
3437
|
-
const v = evalFootprintOnPk(c, pkCells);
|
|
3438
|
-
if (v === undefined) return undefined;
|
|
3439
|
-
out = out || v;
|
|
3440
|
-
}
|
|
3441
|
-
return out;
|
|
3442
|
-
}
|
|
3443
|
-
case "correlatedSubquery":
|
|
3444
|
-
return undefined;
|
|
3445
|
-
}
|
|
2871
|
+
/** Rename every TABLE reference in a query AST through `map` (302 §2 point 3 — the room-homed
|
|
2872
|
+
* view's rewrite): the root `table`, every `related` subquery, every `correlatedSubquery`
|
|
2873
|
+
* (EXISTS) condition — walking the KNOWN wire-AST shape, never a blind key scan: `start.row` is
|
|
2874
|
+
* keyed by COLUMN name (a schema column literally named `table` must keep its bound value), and
|
|
2875
|
+
* the same goes for any future column-keyed record. Tables absent from the map keep their name —
|
|
2876
|
+
* that is the client-side join across kinds (a room table joined to daemon-owned context,
|
|
2877
|
+
* 201-style). Structural clone; the input AST is never mutated. */
|
|
2878
|
+
export function remapAstTables(ast: Ast, map: ReadonlyMap<string, string>): Ast {
|
|
2879
|
+
const walkCond = (c: Condition): Condition => {
|
|
2880
|
+
if (c.type === "and" || c.type === "or") return { ...c, conditions: c.conditions.map(walkCond) };
|
|
2881
|
+
if (c.type === "correlatedSubquery") return { ...c, related: walkSub(c.related) };
|
|
2882
|
+
return c; // "simple" — column refs and literals carry no table reference
|
|
2883
|
+
};
|
|
2884
|
+
const walkSub = (s: CorrelatedSubquery): CorrelatedSubquery => ({ ...s, subquery: walk(s.subquery) });
|
|
2885
|
+
const walk = (a: Ast): Ast => ({
|
|
2886
|
+
...a,
|
|
2887
|
+
table: map.get(a.table) ?? a.table,
|
|
2888
|
+
...(a.where !== undefined ? { where: walkCond(a.where) } : {}),
|
|
2889
|
+
...(a.having !== undefined ? { having: walkCond(a.having) } : {}),
|
|
2890
|
+
...(a.related !== undefined ? { related: a.related.map(walkSub) } : {}),
|
|
2891
|
+
});
|
|
2892
|
+
return walk(ast);
|
|
3446
2893
|
}
|
|
3447
2894
|
|
|
3448
2895
|
/** Wrap the raw wasm txn as the client `MutationTx`, capturing a pk-granular write-set as it
|
|
@@ -3490,17 +2937,21 @@ function trackingTx(
|
|
|
3490
2937
|
onOp?: (op: ChildOp) => void,
|
|
3491
2938
|
trapReads = false,
|
|
3492
2939
|
readLog?: ReadLog,
|
|
3493
|
-
|
|
3494
|
-
|
|
3495
|
-
*
|
|
3496
|
-
*
|
|
3497
|
-
|
|
2940
|
+
/** The 302 staging map for a room-DECLARED mutation: wire table → the room's namespaced engine
|
|
2941
|
+
* table for the tables the room owns; identity for everything else. Every raw engine access —
|
|
2942
|
+
* reads and writes — goes through it, so a room mutator reads/writes the room's own state
|
|
2943
|
+
* (its optimistic effects land where the room-homed views look) while its envelope still
|
|
2944
|
+
* ships the wire names. Absent (or a non-owned table) ⇒ the plain table, verbatim. */
|
|
2945
|
+
stage?: ReadonlyMap<string, string>,
|
|
3498
2946
|
): MutationTx {
|
|
3499
2947
|
const spec = (table: string) => {
|
|
3500
2948
|
const s = specs[table];
|
|
3501
2949
|
if (!s) throw new Error(`unknown table ${JSON.stringify(table)} — tables: ${Object.keys(specs).join(", ")}`);
|
|
3502
2950
|
return s;
|
|
3503
2951
|
};
|
|
2952
|
+
/** The ENGINE table a wire-named access lands on (302 §2). Schema/column validation always
|
|
2953
|
+
* runs on the WIRE name (the namespaced twin shares the spec). */
|
|
2954
|
+
const staged = (table: string): string => stage?.get(table) ?? table;
|
|
3504
2955
|
|
|
3505
2956
|
// M1 (`201-LOCAL-ONLY-TABLES-DESIGN.md` §6): a replayable mutator is a pure function of
|
|
3506
2957
|
// (synced base + args) — it neither READS nor WRITES a local-only table. The server runs the
|
|
@@ -3546,17 +2997,15 @@ function trackingTx(
|
|
|
3546
2997
|
// this invocation — an add-then-edit (or edit-then-edit) of the same pk collapses to its final
|
|
3547
2998
|
// image, matching the engine head's own semantics for that pk. The record is replaced with
|
|
3548
2999
|
// exactly the arguments given: the CALLERS (`edit`/`remove` below, consulting `prior`) decide
|
|
3549
|
-
// the pre-image per the H-ii coalescing matrix on {@link WriteRecord}.
|
|
3550
|
-
|
|
3551
|
-
|
|
3552
|
-
|
|
3000
|
+
// the pre-image per the H-ii coalescing matrix on {@link WriteRecord}. Keyed by the STAGED
|
|
3001
|
+
// (engine) table name, so the pending axis and the write-set match what the engine holds.
|
|
3002
|
+
const recordWrite = (engineTable: string, pk: WireValue[], row: WireValue[] | undefined, oldRow?: WireValue[]): void => {
|
|
3003
|
+
let byPk = writes.get(engineTable);
|
|
3004
|
+
if (!byPk) writes.set(engineTable, (byPk = new Map()));
|
|
3553
3005
|
const pkKey = stableJson(pk);
|
|
3554
3006
|
// Defensive copies: the wasm binding's returned arrays are not contractually immutable/unique,
|
|
3555
3007
|
// so a captured record must not alias a cell array the engine could later reuse or mutate.
|
|
3556
|
-
byPk.set(pkKey, { table, pk: [...pk], row: row ? [...row] : undefined, ...(oldRow ? { oldRow: [...oldRow] } : {}) });
|
|
3557
|
-
// Staged-order key trace (§5.3): pushed once per staged change, so it zips 1:1 with the staged
|
|
3558
|
-
// order of `commitTracked`'s `sources[]` — letting the caller learn each write's routed source.
|
|
3559
|
-
stagedKeys?.push(writeSourceKey(table, pkKey));
|
|
3008
|
+
byPk.set(pkKey, { table: engineTable, pk: [...pk], row: row ? [...row] : undefined, ...(oldRow ? { oldRow: [...oldRow] } : {}) });
|
|
3560
3009
|
};
|
|
3561
3010
|
|
|
3562
3011
|
// A full insert row: each cell is `obj[c]`, or `null` for an omitted nullable column (design 206
|
|
@@ -3587,22 +3036,17 @@ function trackingTx(
|
|
|
3587
3036
|
// trapped path (where `readLog` is never armed) records nothing, exactly as before.
|
|
3588
3037
|
const rawGet = (table: string, pk: WireValue[]) => {
|
|
3589
3038
|
assertNotLocal(table, "read");
|
|
3590
|
-
return tx.get(table, pk) as WireValue[] | undefined;
|
|
3039
|
+
return tx.get(staged(table), pk) as WireValue[] | undefined;
|
|
3591
3040
|
};
|
|
3592
3041
|
|
|
3593
3042
|
// Push one {@link ReadRecord} when recording is armed (§3.2 #2/#3): outcome from `row`'s
|
|
3594
|
-
// presence
|
|
3595
|
-
// (an absent read has no row to probe with, and records no source; see {@link ReadRecord}).
|
|
3596
|
-
// The `source` key is OMITTED (not set to undefined) when there is no answer, so a
|
|
3597
|
-
// single-domain record is byte-identical to Slice B's.
|
|
3043
|
+
// presence. Pure capture for inspection.
|
|
3598
3044
|
const recordRead = (table: string, pk: WireValue[], row: WireValue[] | undefined): void => {
|
|
3599
3045
|
if (!readLog) return;
|
|
3600
|
-
const source = row === undefined ? undefined : provenance?.(table, row);
|
|
3601
3046
|
readLog.reads.push({
|
|
3602
3047
|
table,
|
|
3603
3048
|
pk: [...pk],
|
|
3604
3049
|
outcome: row === undefined ? "absent" : "present",
|
|
3605
|
-
...(source !== undefined ? { source } : {}),
|
|
3606
3050
|
});
|
|
3607
3051
|
};
|
|
3608
3052
|
|
|
@@ -3625,38 +3069,43 @@ function trackingTx(
|
|
|
3625
3069
|
};
|
|
3626
3070
|
|
|
3627
3071
|
// The pk's existing record from THIS invocation, if any — the coalescing-matrix input for
|
|
3628
|
-
// `edit`/`remove` below (see {@link WriteRecord}).
|
|
3072
|
+
// `edit`/`remove` below (see {@link WriteRecord}). Keyed by the STAGED name like the records.
|
|
3629
3073
|
const prior = (table: string, pk: WireValue[]): WriteRecord | undefined =>
|
|
3630
|
-
writes.get(table)?.get(stableJson(pk));
|
|
3074
|
+
writes.get(staged(table))?.get(stableJson(pk));
|
|
3631
3075
|
const add = (table: string, row: WireValue[]) => {
|
|
3632
3076
|
assertNotLocal(table, "write");
|
|
3633
|
-
|
|
3077
|
+
const t = staged(table);
|
|
3078
|
+
recordWrite(t, pkFromCells(table, row), row);
|
|
3079
|
+
// ChildOps carry the WIRE name (unlike the write-set): the agg overlay's defs are keyed by
|
|
3080
|
+
// the ORIGINAL AST's child tables (`collectAggDefs`), and the `__agg_*` heads it feeds are
|
|
3081
|
+
// shared by plain and swapped views alike — a staged name would silently miss the dispatch
|
|
3082
|
+
// and the optimistic count would lag every room-declared write until its echo.
|
|
3634
3083
|
onOp?.({ table, kind: "add", row });
|
|
3635
|
-
tx.add(
|
|
3084
|
+
tx.add(t, row);
|
|
3636
3085
|
};
|
|
3637
3086
|
const remove = (table: string, row: WireValue[]) => {
|
|
3638
3087
|
assertNotLocal(table, "write");
|
|
3088
|
+
const t = staged(table);
|
|
3639
3089
|
const pk = pkFromCells(table, row);
|
|
3640
|
-
// The remove PRE-IMAGE (
|
|
3641
|
-
//
|
|
3642
|
-
//
|
|
3643
|
-
//
|
|
3644
|
-
//
|
|
3645
|
-
//
|
|
3646
|
-
// over it needs no migration. Falls back to the caller's asserted `row` when the pk is not
|
|
3090
|
+
// The remove PRE-IMAGE (the H-ii matrix on {@link WriteRecord}): remove-after-edit/-remove
|
|
3091
|
+
// keeps the ORIGINAL captured pre-image (the txn-entry base — the net effect is a remove of
|
|
3092
|
+
// the row the external world last knew, never the edited transient). Otherwise (first touch,
|
|
3093
|
+
// or remove-after-add) the truthful full-width row is the txn-visible one — `tx.get` read
|
|
3094
|
+
// BEFORE the remove stages (read-your-writes: an add of this pk earlier in the SAME
|
|
3095
|
+
// invocation shows through). Falls back to the caller's asserted `row` when the pk is not
|
|
3647
3096
|
// resident (a raw remove of an absent row) — a captured remove thus always carries a
|
|
3648
|
-
// full-width pre-image
|
|
3649
|
-
const oldRow = prior(table, pk)?.oldRow ?? (tx.get(
|
|
3650
|
-
recordWrite(
|
|
3651
|
-
onOp?.({ table, kind: "remove", row });
|
|
3652
|
-
tx.remove(
|
|
3097
|
+
// full-width pre-image.
|
|
3098
|
+
const oldRow = prior(table, pk)?.oldRow ?? (tx.get(t, pk) as WireValue[] | undefined) ?? row;
|
|
3099
|
+
recordWrite(t, pk, undefined, oldRow);
|
|
3100
|
+
onOp?.({ table, kind: "remove", row }); // wire name — see `add`
|
|
3101
|
+
tx.remove(t, row);
|
|
3653
3102
|
};
|
|
3654
3103
|
const edit = (table: string, oldRow: WireValue[], newRow: WireValue[]) => {
|
|
3655
3104
|
assertNotLocal(table, "write");
|
|
3105
|
+
const t = staged(table);
|
|
3656
3106
|
const pk = pkFromCells(table, newRow);
|
|
3657
|
-
// The edit PRE-IMAGE (H-ii
|
|
3658
|
-
//
|
|
3659
|
-
// read BEFORE staging, falling back to the caller's asserted `oldRow` when the pk is not
|
|
3107
|
+
// The edit PRE-IMAGE (the H-ii matrix on {@link WriteRecord}). First touch: the txn-visible
|
|
3108
|
+
// row read BEFORE staging, falling back to the caller's asserted `oldRow` when the pk is not
|
|
3660
3109
|
// resident (covers the pk-MOVING raw edit — the record is keyed by the NEW pk; the pre-image
|
|
3661
3110
|
// carries the OLD row). Edit-after-edit: keep the FIRST pre-image (the txn-entry base).
|
|
3662
3111
|
// Edit-after-add / edit-after-remove: the record collapses to a (re-)insert — NO pre-image
|
|
@@ -3664,13 +3113,13 @@ function trackingTx(
|
|
|
3664
3113
|
const p = prior(table, pk);
|
|
3665
3114
|
const pre =
|
|
3666
3115
|
p === undefined
|
|
3667
|
-
? ((tx.get(
|
|
3116
|
+
? ((tx.get(t, pk) as WireValue[] | undefined) ?? oldRow)
|
|
3668
3117
|
: p.row !== undefined && p.oldRow !== undefined
|
|
3669
3118
|
? p.oldRow
|
|
3670
3119
|
: undefined;
|
|
3671
|
-
recordWrite(
|
|
3672
|
-
onOp?.({ table, kind: "edit", row: newRow, old: oldRow });
|
|
3673
|
-
tx.edit(
|
|
3120
|
+
recordWrite(t, pk, newRow, pre);
|
|
3121
|
+
onOp?.({ table, kind: "edit", row: newRow, old: oldRow }); // wire name — see `add`
|
|
3122
|
+
tx.edit(t, oldRow, newRow);
|
|
3674
3123
|
};
|
|
3675
3124
|
|
|
3676
3125
|
// The folded read trap (§5): a mutator that reads to compute its write is refused. `() => never`
|
|
@@ -3688,7 +3137,9 @@ function trackingTx(
|
|
|
3688
3137
|
const ast = q.ast();
|
|
3689
3138
|
for (const t of collectTables(ast)) assertNotLocal(t, "read");
|
|
3690
3139
|
readLog?.queries.push(ast);
|
|
3691
|
-
|
|
3140
|
+
// A room-declared mutator's one-shot query reads the room's own staged state for the tables
|
|
3141
|
+
// the room owns (the same staging rule as the point reads above).
|
|
3142
|
+
return tx.query(stage !== undefined && stage.size > 0 ? remapAstTables(ast, stage) : ast) as QueryResultRow[];
|
|
3692
3143
|
};
|
|
3693
3144
|
|
|
3694
3145
|
return {
|
|
@@ -3762,4 +3213,3 @@ function intersects(a: Set<string>, b: Set<string>): boolean {
|
|
|
3762
3213
|
for (const x of b) if (a.has(x)) return true;
|
|
3763
3214
|
return false;
|
|
3764
3215
|
}
|
|
3765
|
-
|