@rindle/optimistic 0.4.3 → 0.5.0
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 +30 -61
- package/dist/backend.d.ts +881 -26
- package/dist/backend.d.ts.map +1 -1
- package/dist/backend.js +2033 -205
- package/dist/backend.js.map +1 -1
- package/dist/client.d.ts +156 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +848 -8
- package/dist/client.js.map +1 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/local-persist.d.ts +102 -0
- package/dist/local-persist.d.ts.map +1 -0
- package/dist/local-persist.js +957 -0
- package/dist/local-persist.js.map +1 -0
- package/dist/system-streams.d.ts +62 -0
- package/dist/system-streams.d.ts.map +1 -0
- package/dist/system-streams.js +107 -0
- package/dist/system-streams.js.map +1 -0
- package/package.json +6 -5
- package/src/backend.ts +2476 -198
- package/src/client.ts +1174 -13
- package/src/index.ts +51 -0
- package/src/local-persist.ts +1129 -0
- package/src/system-streams.ts +140 -0
package/dist/backend.d.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import type { Ast, Backend, BackendDevObserver, ChangeEvent, ColsMap, IsoTx, KeyedRow, Mutation, MutationGen, MutatorCtx, OptimisticSource, QueryArg, QueryId, QueryResultRow, RemoteQuery, ResultType, Schema, WireValue } from "@rindle/client";
|
|
1
|
+
import type { Ast, Backend, BackendDevObserver, ChangeEvent, ColsMap, Condition, IsoTx, KeyedRow, Mutation, MutationEnvelope, MutationGen, MutatorCtx, OptimisticSource, QueryArg, QueryId, QueryResultRow, RemoteQuery, ResultType, Schema, WireValue } from "@rindle/client";
|
|
2
|
+
import { type WritableDescriptor } from "@rindle/wasm";
|
|
3
|
+
import { type SystemStreamSpec } from "./system-streams.ts";
|
|
4
|
+
export type { SystemStreamSpec, SystemStreamTable } from "./system-streams.ts";
|
|
2
5
|
/** A keyed row: column name → cell. The ergonomic shape — column names are validated against the
|
|
3
6
|
* schema at runtime, so a typo throws immediately with the valid names. Re-exported from
|
|
4
7
|
* `@rindle/client` (the leaf both tiers share). */
|
|
@@ -52,6 +55,178 @@ export type ClientMutator = ((tx: MutationTx, args: never) => void) | ((tx: IsoT
|
|
|
52
55
|
* twin shares names (and possibly code), never the wire. */
|
|
53
56
|
export type ClientRegistry = Record<string, ClientMutator>;
|
|
54
57
|
export type { ResultType };
|
|
58
|
+
/** One captured write, pk-granular (RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md §3.2 #1: "the
|
|
59
|
+
* writers already receive `(table, row)`... this is pure capture, no semantic change"). `row` is
|
|
60
|
+
* the post-write image (positional wire cells, schema column order); `undefined` for a `remove` —
|
|
61
|
+
* no row survives it, and `row === undefined` stays the remove marker.
|
|
62
|
+
*
|
|
63
|
+
* `oldRow` is the PRE-IMAGE, captured for a `remove` (G-iii, §7.3 tombstone) AND — since H-ii —
|
|
64
|
+
* an `edit` (it feeds H-iii's join-key no-change rule, old vs new join-key cells, and mirrors the
|
|
65
|
+
* engine, which routes an Edit by its OLD row — the H-i handoff): the full-width row read via
|
|
66
|
+
* `tx.get` immediately BEFORE the write staged — read-your-writes, so a write to the same pk
|
|
67
|
+
* earlier in the SAME invocation shows through — falling back to the caller's asserted old row
|
|
68
|
+
* when the pk is not txn-visible (a raw remove/edit of an absent row; incl. the pk-MOVING raw
|
|
69
|
+
* edit, whose record is keyed by the NEW pk yet whose pre-image is the caller's OLD row — the row
|
|
70
|
+
* H-i's `provenanceOf` routes by). A captured remove/edit thus always carries a full-width
|
|
71
|
+
* pre-image (the engine width-checks `holdBackAbsent`; Slice H evaluates writable predicates on
|
|
72
|
+
* it) — with ONE exception: a record that collapses to a (re-)insert has none, see the matrix.
|
|
73
|
+
*
|
|
74
|
+
* Coalescing within one invocation is last-write-wins per pk on `row` (the final image matches
|
|
75
|
+
* the engine head's own semantics for that pk) with `oldRow` pinned to the TXN-ENTRY BASE — the
|
|
76
|
+
* H-ii matrix:
|
|
77
|
+
* - edit-after-add / edit-after-remove: the record collapses to a (re-)insert — post-image
|
|
78
|
+
* only, NO `oldRow` (the pk did not pre-exist this invocation's base; presence hold-back
|
|
79
|
+
* uses `row`).
|
|
80
|
+
* - edit-after-edit: keeps the FIRST pre-image (the txn-entry base — the chain nets to ONE
|
|
81
|
+
* edit from the base to the final image).
|
|
82
|
+
* - remove-after-edit / remove-after-remove: keeps the ORIGINAL pre-image (the first write's
|
|
83
|
+
* captured base), NOT the edited transient — the net effect is a remove of the row the
|
|
84
|
+
* external world last knew.
|
|
85
|
+
* - remove-after-add: keeps the txn-visible pre-image (the transient added row — the pk had
|
|
86
|
+
* no base, and this is the only truthful full-width row there is; G-iii pinned it).
|
|
87
|
+
* - add-after-remove (a re-insert): drops `oldRow` (presence hold-back uses `row`).
|
|
88
|
+
* Across rebase re-invocations the write-set is union-never-shrink ({@link mergeWriteSet}): a
|
|
89
|
+
* re-run that no-ops keeps the prior record — and its pre-image — intact. */
|
|
90
|
+
export interface WriteRecord {
|
|
91
|
+
table: string;
|
|
92
|
+
pk: WireValue[];
|
|
93
|
+
row: WireValue[] | undefined;
|
|
94
|
+
oldRow?: WireValue[];
|
|
95
|
+
}
|
|
96
|
+
/** The pk-granular write-set captured over ONE mutator invocation: table → pk-key (a stable-JSON
|
|
97
|
+
* encoding of the pk cells, {@link stableJson}) → that pk's write, LAST-WRITE-WINS within the
|
|
98
|
+
* invocation — an add-then-edit or edit-then-edit of the SAME pk collapses to its final image,
|
|
99
|
+
* matching the engine head's own semantics for that pk. Chosen (over a flat array) because the
|
|
100
|
+
* later routing proof needs "is pk P in the writable scope" / "did we already see this pk in this
|
|
101
|
+
* invocation" as cheap lookups, and rebase re-invocation needs to MERGE a fresh write-set into an
|
|
102
|
+
* accumulated one ({@link mergeWriteSet}) — both are Map operations, not scans.
|
|
103
|
+
*
|
|
104
|
+
* `touched` (the pre-existing table-granular `Set<string>` the pending axis reads, §7.2) is
|
|
105
|
+
* exactly `new Set(writeSet.keys())` — derived from this, never separately populated, so the two
|
|
106
|
+
* can never drift. */
|
|
107
|
+
export type WriteSet = Map<string, Map<string, WriteRecord>>;
|
|
108
|
+
/** Whether a recorded point read (`tx.get`/`tx.row`) found a row. */
|
|
109
|
+
export type ReadOutcome = "present" | "absent";
|
|
110
|
+
/** One recorded point read, pk-granular (§3.2 #2). Recording-mode only — see {@link ReadLog}.
|
|
111
|
+
* Since H-ii this covers BOTH the public reads (`tx.get`/`tx.row`) and the keyed writers'
|
|
112
|
+
* internal pre-existence probes (§3.2 #3 — see the `rawGet` note in {@link trackingTx}). */
|
|
113
|
+
export interface ReadRecord {
|
|
114
|
+
table: string;
|
|
115
|
+
pk: WireValue[];
|
|
116
|
+
outcome: ReadOutcome;
|
|
117
|
+
/** Per-read provenance (H-ii, §3.2 #3): the engine's `provenanceOf` answer AT THE MOMENT OF THE
|
|
118
|
+
* READ — which source ("daemon" / a room key) served the row the mutator saw. Live pre-commit
|
|
119
|
+
* state: an open txn's staged writes are not consulted (H-i), so a PRESENT read of a pk this
|
|
120
|
+
* txn itself created reports `undefined` — recorded honestly, key omitted (the router treats
|
|
121
|
+
* self-reads via write-set membership, not provenance). ABSENT reads carry no source (the
|
|
122
|
+
* mutator saw no row; there is no full-width row to probe with — a pk this txn staged-removed
|
|
123
|
+
* thus also records `undefined`, again a write-set-membership case for the router). Probed only
|
|
124
|
+
* on a PROMOTED table ({@link promotedTables}): a never-promoted (Collapsed) table is
|
|
125
|
+
* daemon-owned by construction, so a pure single-domain app pays zero wasm calls per read and
|
|
126
|
+
* `undefined` is still correct (everything is daemon-owned). One probe call per recorded
|
|
127
|
+
* present read, at read time. */
|
|
128
|
+
source?: string;
|
|
129
|
+
}
|
|
130
|
+
/** The read-log captured over ONE mutator invocation when recording is armed (RINDLE-REALTIME-
|
|
131
|
+
* QUERY-ENABLEMENT-DESIGN.md §3.2 #2): every point read (`reads` — the public `tx.get`/`tx.row`
|
|
132
|
+
* and, since H-ii, the keyed writers' internal pre-existence probes) plus every resolved query AST
|
|
133
|
+
* (`queries`, from `tx.query`). A SIBLING of the folded read TRAP (`FoldReadError` below) — the
|
|
134
|
+
* trap arms on the folded path and throws before any read completes (recording never runs there);
|
|
135
|
+
* recording arms on the ordinary (non-folded) prediction run and never throws. Pure capture: which
|
|
136
|
+
* row/predicate a read is actually PROVEN covered by (§3.1's table) is a later slice's job. */
|
|
137
|
+
export interface ReadLog {
|
|
138
|
+
reads: ReadRecord[];
|
|
139
|
+
queries: Ast[];
|
|
140
|
+
}
|
|
141
|
+
/** One promoted `(sourceKey, table)`'s client-held routing spec — THE per-table routing record the
|
|
142
|
+
* §3 prove-or-slow-path router reads (H-iii). Recorded by {@link OptimisticBackend.promoteRoomTable}
|
|
143
|
+
* in the same breath as the engine attach, from the lease's `RealtimeLeaseTableSpec` (the
|
|
144
|
+
* api-server's `RoomTableSpec`, one compiler with the boot wire).
|
|
145
|
+
*
|
|
146
|
+
* - `writable`: whether the room may write this table AT ALL — `true` iff the registered engine
|
|
147
|
+
* {@link WritableDescriptor} is not `none` (a context/"followed" table is `none`; the daemon
|
|
148
|
+
* stays write-authoritative for it, §2.2). Rule #1 of the write proof.
|
|
149
|
+
* - `joinKeyCols`: the correlation columns the footprint binds on this table — the "room mutators
|
|
150
|
+
* never change join keys" input (an edit with a moved join-key cell would re-parent the row
|
|
151
|
+
* across the footprint's correlation and is refused room-routing). Deliberately client-side —
|
|
152
|
+
* they never cross the wasm {@link WritableDescriptor} ABI (the room GATE enforces them
|
|
153
|
+
* engine-side, H-iv).
|
|
154
|
+
* - `where`: the lease's row-local writable predicate (advisory here — the router evaluates the
|
|
155
|
+
* ENGINE's compiled copy via `writableMatches`, never this one; kept for introspection parity
|
|
156
|
+
* with the wire).
|
|
157
|
+
* - `footprintWhere`: the EXACT footprint-membership predicate (H-iv-b: root-node-only, lossless
|
|
158
|
+
* extraction; the vacuous-true `{type:"and",conditions:[]}` for an exact unconstrained root;
|
|
159
|
+
* ABSENT for child/correlated tables). The read proof's pk-membership test input — see
|
|
160
|
+
* {@link OptimisticBackend.deriveDomain}. */
|
|
161
|
+
export interface RoomTableRouting {
|
|
162
|
+
writable: boolean;
|
|
163
|
+
joinKeyCols: string[];
|
|
164
|
+
where?: Condition;
|
|
165
|
+
footprintWhere?: Condition;
|
|
166
|
+
}
|
|
167
|
+
/** The per-table spec {@link OptimisticBackend.promoteRoomTable} receives beside the engine
|
|
168
|
+
* descriptor (H-iii spec plumbing) — the wire-facing half of {@link RoomTableRouting}
|
|
169
|
+
* (`writable` is derived from the descriptor's kind, never passed separately). */
|
|
170
|
+
export interface RoomTableRoutingSpec {
|
|
171
|
+
joinKeyCols?: readonly string[];
|
|
172
|
+
where?: Condition;
|
|
173
|
+
footprintWhere?: Condition;
|
|
174
|
+
}
|
|
175
|
+
/** The §3 router's Q6 measurement hook ({@link OptimisticBackend.__inspectDomains}`().routing`):
|
|
176
|
+
* how often derivation succeeded (per derived room) and why it fell back to the daemon (per
|
|
177
|
+
* failure reason). `reasons` counts PER-CANDIDATE proof failures plus the terminal reasons
|
|
178
|
+
* (`no-candidates` / `tx-query` / `ambiguous`) — with multiple candidate rooms one derivation can
|
|
179
|
+
* bump several reason counters (one per failing candidate), and a candidate's failure is counted
|
|
180
|
+
* even when a sibling candidate ultimately proved. Explicit-policy pins bump NOTHING (the router
|
|
181
|
+
* never ran). */
|
|
182
|
+
export interface RoutingInspect {
|
|
183
|
+
/** Successful DERIVED routes, per room key. */
|
|
184
|
+
derived: Record<string, number>;
|
|
185
|
+
/** Derivation failures, per {@link RoutingFailureReason}. */
|
|
186
|
+
reasons: Record<string, number>;
|
|
187
|
+
}
|
|
188
|
+
/** Why a §3 derivation (or one candidate's proof) fell to the daemon — the {@link RoutingInspect}
|
|
189
|
+
* counter keys. */
|
|
190
|
+
export type RoutingFailureReason =
|
|
191
|
+
/** No connected room gate with a promoted table — the single-domain fast path. */
|
|
192
|
+
"no-candidates"
|
|
193
|
+
/** The invocation ran `tx.query` — a declarative read is not room-provable client-side
|
|
194
|
+
* (rindle-cover is native-only BY DESIGN), so the whole derivation fails unconditionally. */
|
|
195
|
+
| "tx-query"
|
|
196
|
+
/** Two or more candidate rooms both proved. Principled disambiguation is a §9.2 (multi-room
|
|
197
|
+
* clients + budgets) NON-goal, deferred past Slice J; routing slow is always sound (the §3.3
|
|
198
|
+
* gate is the contract). Reachable essentially only for zero-write mutations — real writes
|
|
199
|
+
* can't be writable in two rooms (§2.2 exactly-one-writer per doc). */
|
|
200
|
+
| "ambiguous"
|
|
201
|
+
/** A write touched a table not promoted for the candidate, or promoted `writable: none`
|
|
202
|
+
* (context) — the room may not write it at all. */
|
|
203
|
+
| "write-unwritable-table"
|
|
204
|
+
/** `writableMatches` refused the write's post-image (add/edit) or pre-image (remove). */
|
|
205
|
+
| "write-scope-miss"
|
|
206
|
+
/** An edit moved a join-key cell (old vs new differ on a `joinKeyCols` column). */
|
|
207
|
+
| "write-join-key-change"
|
|
208
|
+
/** The engine's provenance for the write's probe row names a DIFFERENT room. */
|
|
209
|
+
| "write-cross-room-provenance"
|
|
210
|
+
/** A recorded read was served by a DIFFERENT room. */
|
|
211
|
+
| "read-cross-room"
|
|
212
|
+
/** A read needed the pk-membership test but the table has no `footprintWhere` (child /
|
|
213
|
+
* correlated / inexact extraction / never-promoted table). */
|
|
214
|
+
| "read-no-footprint-where"
|
|
215
|
+
/** `footprintWhere` references a non-pk column — not decidable from the key alone. */
|
|
216
|
+
| "read-not-key-decidable"
|
|
217
|
+
/** `footprintWhere` is key-decidable but outside the deliberately-minimal client evaluator
|
|
218
|
+
* (an op other than `=`/`!=`, a null/type-mixed comparison, column-vs-column, …). */
|
|
219
|
+
| "read-not-evaluable"
|
|
220
|
+
/** `footprintWhere` evaluated FALSE on the read's pk — the room provably never holds it. */
|
|
221
|
+
| "read-outside-footprint"
|
|
222
|
+
/** NOT a derivation failure (H-v): a routed mutation came back `mutationOutcome
|
|
223
|
+
* {kind:"deopt"}` — the room GATE refused it at commit and the client re-enqueued it onto the
|
|
224
|
+
* daemon. Counted per processed deopt frame so Q6's picture is complete: derived-and-deopted
|
|
225
|
+
* routes are visible beside derived successes (a rising `deopt` count against a rising
|
|
226
|
+
* `derived` count means the client proof and the gate disagree — a compiler/evaluator skew
|
|
227
|
+
* worth investigating; each one costs a room round-trip + a burnt room mid, never
|
|
228
|
+
* correctness). */
|
|
229
|
+
| "deopt";
|
|
55
230
|
/** A virtual-clock seam for the fold debounce/maxWait timers (FOLDED-MUTATIONS-DESIGN §9): the
|
|
56
231
|
* oracle injects a deterministic scheduler; production defaults to real timers + `Date.now`. */
|
|
57
232
|
export interface FoldClock {
|
|
@@ -69,6 +244,15 @@ export interface FoldOptions {
|
|
|
69
244
|
/** Hard cap so a never-idle drag still persists periodically (trailing throttle). Unbounded if
|
|
70
245
|
* omitted — an idle gap of `debounceMs` is then the only thing that flushes. */
|
|
71
246
|
maxWaitMs?: number;
|
|
247
|
+
/** §9.3 room-aware cadence. When this fold's write ROUTES INTO A ROOM (a collaborator is live on
|
|
248
|
+
* the shared head), flush at this (short) interval instead of `debounceMs`/`maxWaitMs`, so the
|
|
249
|
+
* intermediate frames STREAM to the room rather than collapsing to last-value-wins — the pen is
|
|
250
|
+
* watched, so its growth matters. OFF the room (solo / daemon-served) this is ignored and the
|
|
251
|
+
* caller's `debounceMs` collapse governs. `0` ⇒ per-frame (never coalesce while in a room); a
|
|
252
|
+
* small value (e.g. 40ms) animates while still capping the write rate. Absent ⇒ same cadence
|
|
253
|
+
* room or not (today's behavior). The room decision is probed from the write-set at the fold
|
|
254
|
+
* window's first invoke; it stays fixed for that window. */
|
|
255
|
+
roomDebounceMs?: number;
|
|
72
256
|
/** Keep deferring across overlapping non-fold writes for maximum economy, accepting the §4.2
|
|
73
257
|
* read-dependent reorder snap. Default `false` (flush-on-enqueue — correct-and-boring). */
|
|
74
258
|
deferAcrossWrites?: boolean;
|
|
@@ -80,6 +264,26 @@ export interface FoldHandle {
|
|
|
80
264
|
flush(): void;
|
|
81
265
|
readonly mid: Promise<number>;
|
|
82
266
|
}
|
|
267
|
+
/** One I-iv doorbell event ({@link OptimisticBackend.onScopeSessions}, §4.1): a release folded
|
|
268
|
+
* scope-session rows for `scope`, and `others` is the count of OTHER clients' unexpired sessions
|
|
269
|
+
* there — {@link OptimisticBackend.otherScopeSessions} evaluated at fold time (the same one rule,
|
|
270
|
+
* on the injectable {@link FoldClock}, so a virtual-clock harness gets deterministic verdicts).
|
|
271
|
+
* The consumer (client.ts) triggers its one debounced re-lease on the 0→≥1 transition; expired
|
|
272
|
+
* and own-clientID rows never count, so a solo client's own row can never ring its own bell. */
|
|
273
|
+
export interface ScopeSessionsEvent {
|
|
274
|
+
scope: string;
|
|
275
|
+
others: number;
|
|
276
|
+
}
|
|
277
|
+
/** The I-v stuck-downgrade event ({@link OptimisticBackend.onDowngradeStuck}): the ghost's fence
|
|
278
|
+
* is satisfied but these SENT room-domain mids never resolved (an entry that never reached the
|
|
279
|
+
* room — sent-but-undelivered when the socket died — is undecidable in general, §7.5). The ghost
|
|
280
|
+
* HOLDS (fail LOUD, never silent; no timeout-retire is invented) and the mids are surfaced once,
|
|
281
|
+
* actionably. */
|
|
282
|
+
export interface DowngradeStuckEvent {
|
|
283
|
+
sourceKey: string;
|
|
284
|
+
doc: string;
|
|
285
|
+
mids: number[];
|
|
286
|
+
}
|
|
83
287
|
export interface OptimisticBackendOptions {
|
|
84
288
|
/** Stable per-client identity for the upstream envelopes (§8.1). */
|
|
85
289
|
clientID: string;
|
|
@@ -93,6 +297,23 @@ export interface OptimisticBackendOptions {
|
|
|
93
297
|
/** Virtual-clock seam for the fold debounce timers (FOLDED-MUTATIONS-DESIGN §9). Defaults to
|
|
94
298
|
* real `setTimeout`/`clearTimeout`/`Date.now`; the fold oracle injects a deterministic clock. */
|
|
95
299
|
clock?: FoldClock;
|
|
300
|
+
/** The explicit confirming-stream OVERRIDE (RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md §7.1 /
|
|
301
|
+
* §3). Since H-iii the live default is the §3 prove-or-slow-path DERIVATION
|
|
302
|
+
* ({@link OptimisticBackend.deriveDomain}): a configured policy returning a string pins that
|
|
303
|
+
* domain VERBATIM — no proof runs (the test suites and the dual-source e2e pin by name);
|
|
304
|
+
* returning `undefined` (or configuring no policy) derives the domain from the prediction run's
|
|
305
|
+
* write/read capture against the connected room gates' routing table. With no room gate
|
|
306
|
+
* connected the derivation short-circuits to `"daemon"` — every single-domain app is
|
|
307
|
+
* byte-for-byte as before. */
|
|
308
|
+
domainPolicy?: (name: string, args: unknown) => string | undefined;
|
|
309
|
+
/** A FINAL (authz/validation) mutation rejection's reason surface — the room plane's twin of the
|
|
310
|
+
* HTTP mutate route's `onRejected` (H-v; the H-iv-b `mutationOutcome {kind:"rejected"}` frame).
|
|
311
|
+
* The prediction's snap-back is NOT this callback's job: the room burns the mid and its lmid
|
|
312
|
+
* release drops the entry exactly as a daemon-path rejection does (processed-as-no-op) — this
|
|
313
|
+
* is where the REASON reaches the app, same contract as the queue's callback. Also invoked when
|
|
314
|
+
* a DEOPT's fresh re-invocation (the already-retired arm) throws — that mutation is dead on the
|
|
315
|
+
* current base with no stream left to confirm it, the closest thing to a rejection there is. */
|
|
316
|
+
onRejected?: (envelope: MutationEnvelope, reason: string) => void;
|
|
96
317
|
}
|
|
97
318
|
/** One folded entry's debounce window, for the timeline's fold drill-down (§4.1). */
|
|
98
319
|
export interface FoldInspect {
|
|
@@ -115,6 +336,13 @@ export interface PendingInspect {
|
|
|
115
336
|
args: unknown;
|
|
116
337
|
/** Tables this mutator touched at its last (re)invocation — the pending-axis basis (§7.2). */
|
|
117
338
|
tables: string[];
|
|
339
|
+
/** The pk-granular write-set captured at this entry's LAST invocation (RINDLE-REALTIME-QUERY-
|
|
340
|
+
* ENABLEMENT-DESIGN.md §3.2 #1), flattened from the {@link WriteSet} map for inspection — one
|
|
341
|
+
* entry per `(table, pk)` currently held. Pure capture; no routing consumer yet. */
|
|
342
|
+
writes: WriteRecord[];
|
|
343
|
+
/** The read-log captured at this entry's LAST *recorded* invocation (§3.2 #2). Empty for a
|
|
344
|
+
* folded entry — the read TRAP arms there, not recording (see {@link PendingMutation.reads}). */
|
|
345
|
+
reads: ReadLog;
|
|
118
346
|
/** Present iff this entry is a folded (debounced) write. */
|
|
119
347
|
fold?: FoldInspect;
|
|
120
348
|
}
|
|
@@ -156,6 +384,9 @@ export declare class OptimisticBackend<S extends ColsMap> implements Backend {
|
|
|
156
384
|
* its width check. */
|
|
157
385
|
private readonly colCounts;
|
|
158
386
|
private readonly colIndex;
|
|
387
|
+
/** Per-table pk column indices — held so `connectSource` can build a fresh per-source
|
|
388
|
+
* `NormalizedSync` with the same layout the daemon's uses. */
|
|
389
|
+
private readonly pkCols;
|
|
159
390
|
/** The client's OWN typed per-table schemas + the reserved lmid table — the fixed base
|
|
160
391
|
* of the expected-schema set (CRIT#4 validation). Synthetic agg tables are appended as
|
|
161
392
|
* queries arrive (`ensureSyntheticTables`). */
|
|
@@ -174,6 +405,13 @@ export declare class OptimisticBackend<S extends ColsMap> implements Backend {
|
|
|
174
405
|
* pending delta `displayed = server_base ⊕ local_pending_delta` is applied from. */
|
|
175
406
|
private readonly overlay;
|
|
176
407
|
private handler;
|
|
408
|
+
private catchUpQids;
|
|
409
|
+
/** Newly-hydrated qids whose reconcile ACTUALLY emitted a (catch-up-stamped) batch — recorded by the
|
|
410
|
+
* local-event forwarder alongside {@link catchUpQids}. After the reconcile, any newly-hydrated qid
|
|
411
|
+
* NOT in here folded nothing (0 rows, or its result already present via a sibling → 0 net muts, or
|
|
412
|
+
* the reconcile was skipped), so `onProgress` sends it an explicit empty catch-up — else its SSR
|
|
413
|
+
* seed would never retire (the view freezes). Non-null only for the reconcile's duration. */
|
|
414
|
+
private catchUpEmitted;
|
|
177
415
|
/** The Store's commit-boundary handler ({@link Backend.onCommitBoundary}), forwarded from the
|
|
178
416
|
* local engine's `dispatch` brackets so the Store folds every affected view before notifying any
|
|
179
417
|
* subscriber (cross-view-atomic notification). All this backend's data deltas originate from the
|
|
@@ -181,18 +419,139 @@ export declare class OptimisticBackend<S extends ColsMap> implements Backend {
|
|
|
181
419
|
private boundaryHandler;
|
|
182
420
|
private readonly devObservers;
|
|
183
421
|
private pendingMutations;
|
|
422
|
+
/** The next mid to deal, PER DOMAIN (RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md §7.1): a client
|
|
423
|
+
* writing through room + daemon concurrently must not alias one lmid counter. Seeded with the
|
|
424
|
+
* `"daemon"` stream at 1; a domain absent from the map starts at 1. In the single-domain
|
|
425
|
+
* configuration only `"daemon"` is ever touched, so the sequence is byte-for-byte as before. */
|
|
184
426
|
private nextMid;
|
|
427
|
+
/** The client-global deal counter behind {@link PendingMutation.seq}: one sequence across ALL
|
|
428
|
+
* domains, bumped whenever any domain's mid is dealt. The replay order (mids are per-domain and
|
|
429
|
+
* incomparable across domains — see the `seq` field doc). */
|
|
430
|
+
private dealSeq;
|
|
431
|
+
/** The explicit confirming-stream override (§7.1/§3) — see
|
|
432
|
+
* {@link OptimisticBackendOptions.domainPolicy}. `undefined` from it ⇒ H-iii derivation. */
|
|
433
|
+
private readonly domainPolicy;
|
|
434
|
+
/** The final-rejection reason surface ({@link OptimisticBackendOptions.onRejected}). */
|
|
435
|
+
private readonly rejectedHandler;
|
|
436
|
+
/** Processed `(domain, mid)` outcome frames (H-v) — the deopt handshake's idempotence guard: a
|
|
437
|
+
* duplicate frame (the original plus a reconnect re-send's re-answer, or two re-answers across
|
|
438
|
+
* two reconnects) must not double-invoke. Needed precisely because a deopt frame can arrive for
|
|
439
|
+
* an ALREADY-RETIRED mid (the replay gotcha) — "no matching entry" alone cannot distinguish
|
|
440
|
+
* "handle it fresh" from "already handled". Per-domain FIFO, capped like the shell's
|
|
441
|
+
* recorded-outcome map ({@link MAX_PROCESSED_OUTCOMES_PER_DOMAIN}); past the cap a duplicate of
|
|
442
|
+
* an evicted mid would be re-processed — the same bounded-window trade the shell makes, and it
|
|
443
|
+
* takes 512 interleaving non-applied outcomes on one domain to open it. */
|
|
444
|
+
private readonly outcomesProcessed;
|
|
445
|
+
/** THE routing table (H-iii §3): per connected room `sourceKey` → per promoted table → its
|
|
446
|
+
* client-held {@link RoomTableRouting}. Written ONLY by {@link promoteRoomTable} (same breath
|
|
447
|
+
* as the engine attach + {@link promotedTables}); read by {@link deriveDomain} and — via
|
|
448
|
+
* {@link roomTablesFor} — the client's `__realtimeInspect` bookkeeping. */
|
|
449
|
+
private readonly roomRouting;
|
|
450
|
+
/** Q6 counters ({@link RoutingInspect}): successful derivations per room. */
|
|
451
|
+
private readonly routingDerived;
|
|
452
|
+
/** Q6 counters ({@link RoutingInspect}): derivation failures per {@link RoutingFailureReason}. */
|
|
453
|
+
private readonly routingReasons;
|
|
185
454
|
/** The live fold entries, by fold key `${name}\0${identityJSON}` — at most one per key
|
|
186
455
|
* (FOLDED-MUTATIONS-DESIGN §8). Insertion order is creation order (the drain/flush tiebreak). */
|
|
187
456
|
private readonly folds;
|
|
188
457
|
/** The fold debounce clock (real timers by default; the oracle injects a virtual one). */
|
|
189
458
|
private readonly clock;
|
|
190
|
-
/** The high-water confirmed mutation id,
|
|
191
|
-
*
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
459
|
+
/** The high-water confirmed mutation id, PER DOMAIN (RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md
|
|
460
|
+
* §7.2 per-domain confirm-drop): an entry with `mid <= watermark[entry.domain]` has been
|
|
461
|
+
* confirmed. The `"daemon"` domain is folded from the lmid system query's RELEASED ops
|
|
462
|
+
* (lmid-as-data) — never from a frame; a room domain will fold from its own lmid stream (later
|
|
463
|
+
* slice). Seeded with `"daemon"` at 0; the daemon scalar `confirmedLmid` (devtools) is
|
|
464
|
+
* `watermark.get("daemon")`. */
|
|
465
|
+
private watermark;
|
|
466
|
+
/** The per-source coherence gates (§5.1), by source key. Seeded with the daemon gate at
|
|
467
|
+
* construction; a room channel attaches later (`connectSource`). Single-domain: one entry,
|
|
468
|
+
* and every gate-generalized path degenerates to the old single-buffer code. NOT the same
|
|
469
|
+
* space as {@link watermark}/{@link nextMid}: a DOMAIN can confirm with no gate connected
|
|
470
|
+
* (the `__testRelease` seam); a gate's `key` names the domain its lmid stream folds into. */
|
|
471
|
+
private readonly gates;
|
|
472
|
+
/** The daemon's gate — the always-present channel (constructor-attached). The devtools
|
|
473
|
+
* scalars (`__inspect`) read it directly; its `sync` IS {@link sync} (the agg overlay and
|
|
474
|
+
* synthetic tables are daemon-tracked by design). */
|
|
475
|
+
private readonly daemonGate;
|
|
476
|
+
/** Tables promoted to a MERGED multi-source engine (a room source attached). Written by the one
|
|
477
|
+
* promotion seam ({@link promoteRoomTable}; the test-named {@link __addRoomSource} and Slice
|
|
478
|
+
* G-v's lease-driven promotion from `RoomTableSpec[]` both flow through it). Read by the §7.3
|
|
479
|
+
* hold-back trigger in
|
|
480
|
+
* {@link applyRelease}: parking on a never-promoted (Collapsed) table is SKIPPED — inert anyway
|
|
481
|
+
* today (`rewind_collapsed` never consults `held_back`), and a live hazard if the table later
|
|
482
|
+
* promotes (a stale Collapsed-era entry turns overlay-first-visible and pins forever). Slice H's
|
|
483
|
+
* prove-or-slow-path routing keeps the real scenario — a room-confirmed mutation writing a
|
|
484
|
+
* Collapsed table — impossible; this gate covers the window until then. */
|
|
485
|
+
private readonly promotedTables;
|
|
486
|
+
/** The §301 pin registry ({@link EchoFencePin}): every parked §7.3 hold-back's delivery-fence
|
|
487
|
+
* inputs, keyed `${table}\0${sourceKey}\0${pkKey}`. Written in the same breath as the
|
|
488
|
+
* `holdBack`/`holdBackAbsent` park; read by {@link dropEchoFencePins} at the tail of every
|
|
489
|
+
* release. Empty on every single-domain client — the drop pass is then a structural no-op. */
|
|
490
|
+
private readonly pins;
|
|
491
|
+
/** The §301 direction-A fence input: per room domain, the highest lmid the DAEMON-CARRIED
|
|
492
|
+
* ledger rows have folded ({@link foldSystemFrames} step 2 — and ONLY that path: the room
|
|
493
|
+
* socket's own lmid stream confirms long before the flush reaches the daemon, so it folds
|
|
494
|
+
* into the shared {@link watermark} but never here, 301 §1.1). When this covers a pin's mid,
|
|
495
|
+
* the same coherent release (or an earlier one) folded the flush data that carried it into
|
|
496
|
+
* the daemon baseline — the fence. */
|
|
497
|
+
private readonly daemonCarriedLmid;
|
|
498
|
+
/** The §301 §2.4 boot rule's inputs: the daemon boot ids this client has OBSERVED, in order
|
|
499
|
+
* (id → ordinal), plus the current one. Boot ids are opaque — ordering is the client's own
|
|
500
|
+
* observation ({@link OptimisticSource.onBootId} on the daemon gate). Direction-B pins stamp
|
|
501
|
+
* the current boot; a room advertising a LATER-observed boot proves absorption (its
|
|
502
|
+
* re-snapshot came from a daemon state that durably includes the confirmed write). */
|
|
503
|
+
private readonly daemonBootOrdinals;
|
|
504
|
+
private daemonBootId;
|
|
505
|
+
/** The per-read provenance probe `invoke` hands `trackingTx` when recording is armed (H-ii,
|
|
506
|
+
* §3.2 #3): the engine's `provenanceOf` — the VISIBLE overlay-first winner for the row's pk,
|
|
507
|
+
* read from LIVE pre-commit state (an open txn's staged writes are not consulted; H-i). Gated
|
|
508
|
+
* on PER-TABLE {@link promotedTables} membership — the cheapest existing signal, and the
|
|
509
|
+
* correct one: provenance is an ENGINE-merge question, not a channel question ({@link gates}
|
|
510
|
+
* can hold a connected-but-unpromoted room feed, and the oracle harness promotes with no gate),
|
|
511
|
+
* and a never-promoted (Collapsed) table is daemon-owned by construction. So a pure
|
|
512
|
+
* single-domain app pays ZERO wasm calls per recorded read; the recorded `source` is then
|
|
513
|
+
* `undefined`, which is also correct — everything is daemon-owned (see {@link ReadRecord}). */
|
|
514
|
+
private readonly readProvenance;
|
|
515
|
+
/** System retains by source qid ({@link retainSystemQuery}): a subscription with NO store view
|
|
516
|
+
* and NO user-visible table — its frames buffer on its gate exactly like {@link LMID_QID}'s and
|
|
517
|
+
* fold at RELEASE time ({@link foldSystemFrames}), never entering the sync layer or the local
|
|
518
|
+
* engine. The spec names which system table the qid serves and the scope/doc it was minted for
|
|
519
|
+
* (the fold's row filter). Empty on every non-lifecycle client — every partition below is then
|
|
520
|
+
* a structural no-op and the release path is byte-identical to before. */
|
|
521
|
+
private readonly systemQids;
|
|
522
|
+
/** The §4.2 fence state: room doc → highest `flush_seq` delivered through the daemon plane
|
|
523
|
+
* (monotone max-fold; a remove never regresses it). Slice I-v's ghost-drop consumer — I-iii
|
|
524
|
+
* only maintains + exposes it (`__inspectDomains().lifecycle`). */
|
|
525
|
+
private readonly roomWatermarks;
|
|
526
|
+
/** The §4.1 occupancy state: scope → (client_id → expires_at) from the doorbell stream. Slice
|
|
527
|
+
* I-iv's doorbell consumer (the 1→2 re-lease reaction) — I-iii only maintains + exposes it.
|
|
528
|
+
* A snapshot REPLACES the scope's map (authoritative re-hydrate); a batch folds add/edit/remove
|
|
529
|
+
* incrementally (the age-out sweep's deletes arrive as removes). */
|
|
530
|
+
private readonly scopeSessions;
|
|
531
|
+
/** The I-iv doorbell event sink ({@link onScopeSessions}) — fired once per scope a release's
|
|
532
|
+
* scope-session fold touched, AFTER the whole release applied. Default no-op: a client that
|
|
533
|
+
* never registers (no lifecycle plane) pays nothing. */
|
|
534
|
+
private scopeSessionsHandler;
|
|
535
|
+
/** Deferred old-channel row GC for in-flight upgrade retargets ({@link retargetRemoteQuery}):
|
|
536
|
+
* sub sourceQid → the channel it left. The rows the OLD gate's sync holds for the qid stay
|
|
537
|
+
* visible (merge: daemon tier) until the sub's first snapshot RELEASES on its new room channel
|
|
538
|
+
* ({@link flushRetargetGc}) — dropping them at retarget time would emit net removes ahead of
|
|
539
|
+
* the room's re-adds, the flicker the two-phase cutover exists to avoid. Doubles as the
|
|
540
|
+
* wrong-channel GRACE window in {@link onFrame}: a frame already in flight from the old
|
|
541
|
+
* channel when the sub moved is stale, not a wiring bug. Empty on every non-upgrade client —
|
|
542
|
+
* every consultation below is then a structural no-op. */
|
|
543
|
+
private readonly pendingRetargetGc;
|
|
544
|
+
/** The §4.2 GHOSTS (Slice I-v): demoted room sources awaiting their watermark fence, by
|
|
545
|
+
* sourceKey. Written only by {@link demoteRoomSource}; evaluated after every release
|
|
546
|
+
* ({@link evaluateGhosts}) and dropped by {@link dropGhost} once the fence clears with no
|
|
547
|
+
* sent room-domain pending left. Empty on every non-downgrade client — the per-release
|
|
548
|
+
* evaluation is then a structural no-op. */
|
|
549
|
+
private readonly ghosts;
|
|
550
|
+
/** The I-v stuck-downgrade surface ({@link onDowngradeStuck}) — fired AT MOST ONCE per ghost
|
|
551
|
+
* when its fence is satisfied but sent room-domain mids remain unresolved (§7.5: they retire
|
|
552
|
+
* only through outcome resolution; the ghost holds rather than inventing a timeout-retire).
|
|
553
|
+
* Default no-op. */
|
|
554
|
+
private downgradeStuckHandler;
|
|
196
555
|
private readonly asts;
|
|
197
556
|
/** Per query: the base tables its result can draw from (from the AST tree). */
|
|
198
557
|
private readonly queryTables;
|
|
@@ -210,8 +569,32 @@ export declare class OptimisticBackend<S extends ColsMap> implements Backend {
|
|
|
210
569
|
* touches its tables. Cached so `onPending` fires only on transitions (invoke ↔ confirm). */
|
|
211
570
|
private readonly pendingState;
|
|
212
571
|
private pendingHandler;
|
|
572
|
+
/** The local-persistence write-through tap (`207-LOCAL-TABLE-PERSISTENCE-DESIGN.md` §5.1):
|
|
573
|
+
* {@link writeLocal} invokes it post-commit; {@link applyLocalReplica} deliberately does not. */
|
|
574
|
+
private localWriteObserver;
|
|
213
575
|
constructor(schema: Schema<S>, source: OptimisticSource, registry: ClientRegistry, opts: OptimisticBackendOptions);
|
|
214
|
-
|
|
576
|
+
/** Wire one authority channel into its own coherence gate (§5.1): every frame the channel
|
|
577
|
+
* delivers buffers on THIS gate's cv timeline, its progress frames release THIS buffer, its
|
|
578
|
+
* restart resets THIS gate alone, and its reserved lmid stream folds into `watermark[key]`.
|
|
579
|
+
* Validates each server hello against our OWN typed schema → reject a schema skew (CRIT#4);
|
|
580
|
+
* the reserved lmid table is part of the expected set so the system query's hello passes, and
|
|
581
|
+
* synthetic agg tables join the set as queries register them. */
|
|
582
|
+
private attachGate;
|
|
583
|
+
/** Attach a SECOND authority channel (§5.1) — the seam Slice G's room upgrade calls with the
|
|
584
|
+
* ws-backed room feed. Rooms speak the daemon protocol verbatim (§2.4: the client cannot tell
|
|
585
|
+
* a room from the daemon), so the argument is a full {@link OptimisticSource} — exactly what
|
|
586
|
+
* `@rindle/remote` builds from `{roomUrl, leaseToken}`. The channel buffers/releases on its
|
|
587
|
+
* own cv timeline (an independent §5.1 gate: coherent within, eventual across) and its
|
|
588
|
+
* reserved lmid stream folds into `watermark[sourceKey]` — so `sourceKey` must equal the
|
|
589
|
+
* `domainPolicy` name for the mutations this authority confirms. The converse is NOT required:
|
|
590
|
+
* a domain may exist with no connected gate (`__testRelease` drives confirms gate-less); the
|
|
591
|
+
* live production path stays daemon-only until G calls this. */
|
|
592
|
+
connectSource(sourceKey: string, source: OptimisticSource): void;
|
|
593
|
+
/** `channel` (G-iii registration-time routing) names the authority channel the remote sub
|
|
594
|
+
* registers on — a `connectSource`d gate key; default `"daemon"` (every existing caller is
|
|
595
|
+
* byte-identical). Slice G-v threads the lease's `realtime.sourceKey` here. Validated FIRST
|
|
596
|
+
* (like the E3 check below): a bad channel must throw before any per-query state is recorded. */
|
|
597
|
+
registerQuery(qid: QueryId, ast: Ast, remote?: RemoteQuery, channel?: string): void;
|
|
215
598
|
/** Register every synthetic aggregate table `ast` needs that we haven't seen yet: on the
|
|
216
599
|
* local engine (which auto-tracks it for the optimistic rebase loop), on `NormalizedSync`
|
|
217
600
|
* (so its rows refcount/GC by group key), and into the source's expected-schema set (so
|
|
@@ -225,8 +608,145 @@ export declare class OptimisticBackend<S extends ColsMap> implements Backend {
|
|
|
225
608
|
* `unregisterTable` frees it (the engine refuses otherwise). */
|
|
226
609
|
private releaseSyntheticTables;
|
|
227
610
|
unregisterQuery(qid: QueryId): void;
|
|
228
|
-
|
|
611
|
+
/** `channel` as in {@link registerQuery} (G-iii): the gate the remote sub registers on; default
|
|
612
|
+
* `"daemon"`. This is the split-retain seam G-v's resolve-then-register drives — resolve the
|
|
613
|
+
* lease, learn `realtime.sourceKey`, `connectSource` it, then retain the query on that channel.
|
|
614
|
+
* Validated FIRST so a bad channel throws before any synthetic-table refcount moves. */
|
|
615
|
+
retainRemoteQuery(qid: QueryId, remote: RemoteQuery, localQueryId?: QueryId, ast?: Ast, channel?: string): void;
|
|
229
616
|
releaseRemoteQuery(qid: QueryId): void;
|
|
617
|
+
/** The Slice I-iv upgrade retarget (§4.1 "Retarget" / the doorbell reaction): move a LIVE
|
|
618
|
+
* (name, args) sub — every retain of it and every local view it feeds, wholesale — from the
|
|
619
|
+
* channel it lives on onto `sourceKey`'s (already-`connectSource`d, already-promoted) room
|
|
620
|
+
* channel, WITHOUT the view ever dropping its rows. Returns the sub's wire `sourceQid` (the
|
|
621
|
+
* identity the client's renewal loop re-subscribes with).
|
|
622
|
+
*
|
|
623
|
+
* Why a dedicated primitive: the one-channel-per-(name,args) invariant ({@link retainRemote}'s
|
|
624
|
+
* loud throw) is correct — a sub's frames must never split across two cv timelines — so the
|
|
625
|
+
* upgrade cannot simply retain a second sub on the room and release the daemon one; and the
|
|
626
|
+
* naive release-then-retain order GCs the daemon sync's rows synchronously (net removes emit,
|
|
627
|
+
* the view flashes empty) a full ws round trip before the room's seq-0 snapshot refills it.
|
|
628
|
+
* The cutover is therefore TWO-PHASE around the room's first release:
|
|
629
|
+
*
|
|
630
|
+
* 1. NOW (here): unsubscribe the old channel's wire sub, sweep its still-buffered frames for
|
|
631
|
+
* this qid (their cv timeline continues without the sub — the hello-supersession
|
|
632
|
+
* precedent), flip `sub.channel`, re-arm `sub.hydrated` (the room's own snapshot is the
|
|
633
|
+
* cutover point), and register on the room source (its resolver presents the handed
|
|
634
|
+
* roomToken). The old gate's SYNC rows are deliberately NOT dropped: they keep the view's
|
|
635
|
+
* rows visible through the window (merge: the daemon-tier holder). Local views keep their
|
|
636
|
+
* `hydrated` membership — the view stays `complete` on its authoritative daemon rows.
|
|
637
|
+
* 2. AT THE ROOM'S FIRST RELEASED SNAPSHOT ({@link flushRetargetGc}): the room source now
|
|
638
|
+
* holds the footprint rows (folded via `serverBatchBegin(sourceKey)` — value-equal rows
|
|
639
|
+
* re-hydrate as a net-zero diff, RT §3.4 per channel), so the deferred
|
|
640
|
+
* `dropQuery`+reconcile on the OLD gate flips each pk's winner daemon→room value-equal:
|
|
641
|
+
* net-zero again. No emission carries a disappearance at any point.
|
|
642
|
+
*
|
|
643
|
+
* Idempotent per target channel: a sub already on `sourceKey` returns immediately (the
|
|
644
|
+
* double-doorbell / re-entrancy guard — one retarget per (query, sourceKey), mirroring
|
|
645
|
+
* {@link promoteRoomTable}'s caller-side per-(sourceKey, table) idempotence). Validates before
|
|
646
|
+
* mutating: a throw here leaves the sub fully daemon-attached (the client's fail-open). */
|
|
647
|
+
retargetRemoteQuery(remote: RemoteQuery, sourceKey: string): QueryId;
|
|
648
|
+
/** Phase 2 of {@link retargetRemoteQuery}, run at the end of every gate release: once a
|
|
649
|
+
* retargeted sub's first snapshot has RELEASED on its new channel (`sub.hydrated` re-armed at
|
|
650
|
+
* retarget, re-set by {@link markSubHydrated} inside this very release), drop the qid's rows
|
|
651
|
+
* from the OLD gate's sync and reconcile them out — after the room's rows are already applied,
|
|
652
|
+
* so the winner flip is value-equal (net-zero; see the phase table above). A sub torn down
|
|
653
|
+
* mid-window was already swept by `releaseRemoteQuery`/`unregisterQuery` (which delete the
|
|
654
|
+
* record); a vanished record here is pruned defensively. */
|
|
655
|
+
private flushRetargetGc;
|
|
656
|
+
/** The I-v downgrade orchestration primitive (§4.2/§7.4): retire room `sourceKey` behind the
|
|
657
|
+
* watermark fence. The caller has ALREADY retargeted every live sub off the channel
|
|
658
|
+
* ({@link retargetRemoteQuery} room→daemon — validated loudly below) and holds the fence from
|
|
659
|
+
* the api-server's downgrade response (`finalFlushSeq` = the room's last COMMITTED flush seq;
|
|
660
|
+
* `doc` keys the §4.2 watermark fold, {@link roomWatermarks}). Steps, in order:
|
|
661
|
+
*
|
|
662
|
+
* 1. **De-candidacy NOW**: the sourceKey's {@link roomRouting} entries are removed, so
|
|
663
|
+
* {@link deriveDomain} stops proposing the room (zero candidates ⇒ `"daemon"`). Unsent
|
|
664
|
+
* pending (`mid === null`) re-routes for free — a fold's flush re-derives from the live
|
|
665
|
+
* candidate set (§7.5 rule 1). The engine's frozen scope still ANSWERS (`writableMatches`
|
|
666
|
+
* / `provenanceOf` — freezing gates serving, not the scope definition); routing is
|
|
667
|
+
* TS-gated here.
|
|
668
|
+
* 2. **Freeze** the WRITABLE promoted tables (`freezeSource`): the room slice becomes the
|
|
669
|
+
* §4.2 ghost — wins-if-present in the merge (D4), so its rows (at-or-ahead of the daemon
|
|
670
|
+
* until the flush echoes) stay visible while the slice accepts nothing. Context tables
|
|
671
|
+
* (`writable: false`) are deliberately NOT frozen: the daemon is authoritative for them
|
|
672
|
+
* LIVE (§5.2's context tier) and freezing would invert that, pinning a possibly-BEHIND
|
|
673
|
+
* relayed copy over fresher daemon rows; unfrozen they keep exactly the live tiering.
|
|
674
|
+
* 3. **Disconnect** the channel ({@link disconnectSource}): handlers detached, gate + buffer
|
|
675
|
+
* dropped. `nextMid`/`watermark`/processed-outcomes for the domain are KEPT FOREVER (§7.1:
|
|
676
|
+
* an assigned mid pins its domain; a later re-upgrade of the same doc continues the
|
|
677
|
+
* sequence — {@link connectSource} attaches a fresh gate and the lmid snapshot max-folds
|
|
678
|
+
* into the surviving watermark). Disconnecting BEFORE the daemon sub's first release is
|
|
679
|
+
* load-bearing: it makes {@link flushRetargetGc}'s deferred old-channel GC a no-op (gate
|
|
680
|
+
* gone ⇒ record deleted, nothing dropped) — running that GC would rewind the room slice's
|
|
681
|
+
* rows at daemon hydration, i.e. BEFORE the fence, surfacing a lagging follower's stale
|
|
682
|
+
* images (the exact regression §4.2 exists to prevent). The ghost's rows leave only
|
|
683
|
+
* through {@link dropGhost}'s `removeRoomSource`, value-equal under the fence.
|
|
684
|
+
* 4. **Ghost + first evaluation**: the record joins {@link ghosts} and is evaluated once
|
|
685
|
+
* immediately — `finalFlushSeq === 0` (a never-flushed room) with no room-domain pending
|
|
686
|
+
* drops on the spot, the single-daemon first-frame case.
|
|
687
|
+
*
|
|
688
|
+
* In-flight discipline (§7.5): entries with `mid !== null` on `sourceKey` stay PINNED (rule
|
|
689
|
+
* 2 — never re-route a sent mutation); their resolution arrives via the daemon-carried
|
|
690
|
+
* ledger+outcome folds (I-iii) and blocks the drop until then. Idempotent per sourceKey (a
|
|
691
|
+
* second labeled query sharing the room demotes into the existing ghost). */
|
|
692
|
+
demoteRoomSource(sourceKey: string, doc: string, finalFlushSeq: number): void;
|
|
693
|
+
/** Detach one connected room channel (Slice I-v step 3): the source's handlers are replaced
|
|
694
|
+
* with no-ops (the {@link OptimisticSource} handler seam is single-registration, so this IS
|
|
695
|
+
* the detach — a late frame from a dying socket can no longer touch any bookkeeping), its
|
|
696
|
+
* reserved lmid sub is unregistered, and the gate — buffer, per-source sync, cv watermark —
|
|
697
|
+
* is dropped from {@link gates}. The DOMAIN state deliberately survives forever:
|
|
698
|
+
* `nextMid[sourceKey]`, `watermark[sourceKey]`, and the processed-outcome set are untouched
|
|
699
|
+
* (§7.1 — an assigned mid pins its domain; a re-upgrade must continue, never restart, the mid
|
|
700
|
+
* sequence; {@link connectSource} then attaches a fresh gate whose lmid snapshot max-folds
|
|
701
|
+
* into the surviving watermark via {@link foldConfirm}). Closing the underlying transport is
|
|
702
|
+
* the caller's job. Idempotent (a missing gate is a no-op). */
|
|
703
|
+
disconnectSource(sourceKey: string): void;
|
|
704
|
+
/** Register the I-v stuck-downgrade sink — see {@link DowngradeStuckEvent}. One handler (a
|
|
705
|
+
* later registration replaces it, the {@link onScopeSessions} convention); client.ts maps it
|
|
706
|
+
* onto the loud anomaly surface. */
|
|
707
|
+
onDowngradeStuck(handler: (event: DowngradeStuckEvent) => void): void;
|
|
708
|
+
/** The I-v ghost-drop watcher (§4.2), run after every applied release ({@link applyRelease} —
|
|
709
|
+
* the seam where {@link roomWatermarks} has just folded and the confirm-drop has just run) and
|
|
710
|
+
* once at demote time. For each ghost: the fence must be satisfied
|
|
711
|
+
* (`roomWatermarks[doc] ≥ finalFlushSeq`; 0 is trivially satisfied) AND no SENT room-domain
|
|
712
|
+
* pending may remain (§7.5 — such entries resolve only through the daemon-carried
|
|
713
|
+
* outcome/ledger folds; an entry that never reached the room is undecidable, so the ghost
|
|
714
|
+
* HOLDS and the stuck event fires exactly once, naming the mids). Both satisfied ⇒
|
|
715
|
+
* {@link dropGhost}. */
|
|
716
|
+
private evaluateGhosts;
|
|
717
|
+
/** Drop one cleared ghost (§4.2 end state): detach every promoted table's room source (D2
|
|
718
|
+
* stay-merged — `removeRoomSource` reconciles each held pk to daemon-or-vanish, value-equal
|
|
719
|
+
* under the fence, so visually a no-op), release the {@link promotedTables} record for tables
|
|
720
|
+
* no OTHER room/ghost still holds, then run ONE daemon reconcile so entries whose writes had
|
|
721
|
+
* staged onto the ghost slice re-invoke and re-stage by provenance onto the daemon slice (the
|
|
722
|
+
* removal took their staged copies with the tree; provenance now answers daemon). The whole
|
|
723
|
+
* drop runs under one commit boundary so the removal's compensating deltas and the re-staged
|
|
724
|
+
* predictions notify as ONE step. After this, a FUTURE upgrade of the same doc promotes
|
|
725
|
+
* again from scratch — the round trip is pinned by the §8.5 lanes. */
|
|
726
|
+
private dropGhost;
|
|
727
|
+
/** Retain one minted SYSTEM subscription (RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md §4, Slice
|
|
728
|
+
* I-iii): a wire sub with NO store view and NO user-visible table. Registered through the same
|
|
729
|
+
* {@link RemoteSub} bookkeeping as any remote retain — so qid→channel ownership, the overflow
|
|
730
|
+
* re-subscribe, and refcounted release all work unchanged — but with an EMPTY `localQids` set
|
|
731
|
+
* (no hydration/resultType coupling) and a {@link systemQids} record telling the release path
|
|
732
|
+
* which system table this qid's frames carry (`spec.table`) and which scope/doc it was minted
|
|
733
|
+
* for (the fold's row filter). Its frames then buffer on the channel's gate exactly like
|
|
734
|
+
* {@link LMID_QID}'s and fold at RELEASE time in {@link foldSystemFrames} — riding the SAME
|
|
735
|
+
* buffered cv path as the data they co-committed with (fence coherence: an out-of-band
|
|
736
|
+
* shortcut would break I-ii's co-commit ordering guarantee).
|
|
737
|
+
*
|
|
738
|
+
* `channel` defaults to `"daemon"` — the system tables live in the DAEMON store (that is the
|
|
739
|
+
* point: outcome/ledger/watermark rows must be readable with no room socket alive, §7.1
|
|
740
|
+
* "load-bearing for §7.5"). Idempotence per (table, scope/doc) is the CALLER's job (client.ts
|
|
741
|
+
* keys its retains on exactly that); a duplicate retain of the SAME remote identity refcounts
|
|
742
|
+
* like any sub. */
|
|
743
|
+
retainSystemQuery(retainQid: QueryId, remote: RemoteQuery, spec: SystemStreamSpec, channel?: string): void;
|
|
744
|
+
/** Release a {@link retainSystemQuery} retain. Refcounted like any sub; the LAST release
|
|
745
|
+
* unregisters from the owning channel, sweeps its buffered frames, and drops the
|
|
746
|
+
* {@link systemQids} record. The folded lifecycle STATE (`roomWatermarks`/`scopeSessions`/
|
|
747
|
+
* processed outcomes) deliberately survives — the fence is monotone truth about the store, not
|
|
748
|
+
* about the subscription (a re-retained fence must not forget a cleared watermark). */
|
|
749
|
+
releaseSystemQuery(retainQid: QueryId): void;
|
|
230
750
|
/** Raw CRUD has no optimistic story (§9 replaces it with named mutators). Register a
|
|
231
751
|
* mutator — even a trivial one — and `invoke` it. */
|
|
232
752
|
mutate(_mutations: Mutation[]): Promise<void>;
|
|
@@ -234,8 +754,26 @@ export declare class OptimisticBackend<S extends ColsMap> implements Backend {
|
|
|
234
754
|
* straight to the local engine, OUTSIDE the optimistic pending stack — a local table is
|
|
235
755
|
* untracked, so it never rebases, reverts, or waits on a confirmation. Rejects a synced/tracked
|
|
236
756
|
* table (the local engine's `writeLocal` is the chokepoint). Reconcile is synchronous and
|
|
237
|
-
* non-reentrant (A5), so a local write can never interleave with an open server cycle.
|
|
757
|
+
* non-reentrant (A5), so a local write can never interleave with an open server cycle.
|
|
758
|
+
*
|
|
759
|
+
* Fires the {@link onLocalWrite} observer AFTER the engine commit but BEFORE subscriber
|
|
760
|
+
* delivery — i.e. for exactly the batches that passed the M2 guard and committed (the
|
|
761
|
+
* persistence layer's write-through tap, `207-LOCAL-TABLE-PERSISTENCE-DESIGN.md` §5.1). A
|
|
762
|
+
* subscriber throwing during delivery re-raises out of this call, but only after the tap has
|
|
763
|
+
* seen the batch: a committed write can never be invisible to the persistence plane. */
|
|
238
764
|
writeLocal(mutations: Mutation[]): void;
|
|
765
|
+
/** The write-through tap for the local-persistence layer (207 §5.1): `observer` sees every
|
|
766
|
+
* {@link writeLocal} batch post-commit. One observer (the layer); a later registration
|
|
767
|
+
* replaces it. The observer must not throw — a persistence failure degrades durability, never
|
|
768
|
+
* the write path (P9); the layer catches internally. */
|
|
769
|
+
onLocalWrite(observer: (mutations: Mutation[]) => void): void;
|
|
770
|
+
/** Apply a REPLICATED local batch (a restore snapshot / a leader `commit` — 207 §5.1): delegates
|
|
771
|
+
* to the engine's `writeLocal`, so the M2 locality guard still fires (P8 — a corrupt record
|
|
772
|
+
* naming a synced table dies loudly here), but does NOT invoke the {@link onLocalWrite}
|
|
773
|
+
* observer — the echo guard is structural, so the persistence layer can never re-enter itself.
|
|
774
|
+
* `onCommitted` fires post-commit pre-delivery (same anchor as {@link writeLocal}'s tap): the
|
|
775
|
+
* layer updates its mirror there, so a subscriber throw can never desync mirror from engine. */
|
|
776
|
+
applyLocalReplica(mutations: Mutation[], onCommitted?: () => void): void;
|
|
239
777
|
onEvent(handler: (qid: QueryId, ev: ChangeEvent) => void): void;
|
|
240
778
|
onCommitBoundary(handler: (phase: "begin" | "end") => void): void;
|
|
241
779
|
/** Bracket a multi-step optimistic apply as ONE notification commit (cross-view-atomic
|
|
@@ -254,10 +792,51 @@ export declare class OptimisticBackend<S extends ColsMap> implements Backend {
|
|
|
254
792
|
* state (read-your-writes), the SAME body the API server drives asynchronously. `ctx.user` is
|
|
255
793
|
* the acting principal (re-read per invoke, stable across a rebase re-invoke). */
|
|
256
794
|
private runMutator;
|
|
795
|
+
/** Deal the next wire mid from `domain`'s ledger (RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md
|
|
796
|
+
* §7.1) and advance that counter. A domain absent from the map starts at 1. Per-domain, so a
|
|
797
|
+
* client writing through room + daemon concurrently keeps two gapless, non-aliasing sequences.
|
|
798
|
+
* The client-global `seq` is stamped in the same breath — the ONE cross-domain total order
|
|
799
|
+
* (confirmation order is per-domain; replay order is client-global). Bundled here so no call
|
|
800
|
+
* site can deal a mid without its seq. ONE caller discards the seq deliberately: the H-v deopt
|
|
801
|
+
* flip ({@link handleMutationOutcome}) keeps the entry's ORIGINAL seq — its replay position —
|
|
802
|
+
* and takes only the fresh mid (the dealSeq bump is harmless: seq consumers order, never
|
|
803
|
+
* count). */
|
|
804
|
+
private dealMid;
|
|
805
|
+
/** The route decision for one invocation (invoke, and again at fold flush): the explicit
|
|
806
|
+
* `domainPolicy` is the OVERRIDE — a string pins that domain verbatim, no proof runs;
|
|
807
|
+
* `undefined` (or no policy configured) derives per §3. */
|
|
808
|
+
private resolveDomain;
|
|
809
|
+
/** §9.3: does this fold's write-set route into a room (vs the daemon)? Used ONLY to pick the
|
|
810
|
+
* {@link FoldOptions.roomDebounceMs} cadence at a fold window's start. It probes
|
|
811
|
+
* {@link resolveDomain} but RESTORES the Q6 routing diagnostics afterward — the authoritative
|
|
812
|
+
* route and its single counter bump belong to the flush ({@link flushFold}), not to this
|
|
813
|
+
* interval hint. A fold's reads are provably empty (the read-trap arms on that path), so the
|
|
814
|
+
* write-set alone decides. */
|
|
815
|
+
private routesToRoom;
|
|
816
|
+
/** Count one derivation failure ({@link RoutingInspect}). Returns `false` so the per-candidate
|
|
817
|
+
* proof's call sites read `return this.failDerivation(...)`. */
|
|
818
|
+
private failDerivation;
|
|
819
|
+
/** The §3 derivation. Candidates are the connected room gates with at least one promoted table
|
|
820
|
+
* (a promoted table with no gate cannot confirm; a gate with no promoted table holds no data to
|
|
821
|
+
* prove against). Zero candidates ⇒ `"daemon"` (the single-domain fast path). The proof runs
|
|
822
|
+
* per candidate and EXACTLY ONE must survive — two rooms both proving routes slow (principled
|
|
823
|
+
* disambiguation is a §9.2 multi-room NON-goal, deferred past Slice J; slow is always sound). */
|
|
824
|
+
private deriveDomain;
|
|
825
|
+
/** One candidate room's §3 proof over the captured write-set + read-log. Every write must pass
|
|
826
|
+
* ALL the write rules; every read must pass ONE of the read rules. First failure wins (and is
|
|
827
|
+
* counted); order is deterministic (write-set map order, then read-log order). */
|
|
828
|
+
private provesRoom;
|
|
257
829
|
/** Run the named client mutator optimistically: the prediction applies to the live
|
|
258
830
|
* engine now (affected views update synchronously), `(mid, name, args)` joins the
|
|
259
831
|
* pending stack, and the envelope ships upstream. Returns the assigned `mid`. */
|
|
260
832
|
invoke(name: string, args: unknown): number;
|
|
833
|
+
/** {@link invoke} with an optional PINNED confirming domain (H-v): the deopt handshake's
|
|
834
|
+
* already-retired arm re-invokes the frame's echoed `(name, args)` as a FRESH invocation pinned
|
|
835
|
+
* to `"daemon"` — an honest re-prediction on the current base, never derived (`pin` bypasses
|
|
836
|
+
* {@link resolveDomain} entirely, so the router never runs and no Q6 counter moves). Every
|
|
837
|
+
* other step is `invoke` verbatim: prediction now, capture, drainOverlapping, mid dealt from
|
|
838
|
+
* the pinned domain's ledger, envelope on its channel. */
|
|
839
|
+
private invokeWith;
|
|
261
840
|
/** Run a FOLDED invoke (FOLDED-MUTATIONS-DESIGN §8): apply the prediction to the live engine now
|
|
262
841
|
* (like `invoke`), but collapse a run of same-key invokes into ONE pending entry whose `args`
|
|
263
842
|
* are overwritten in place, debounce the server write, and ship only the last value. The `mid`
|
|
@@ -272,6 +851,66 @@ export declare class OptimisticBackend<S extends ColsMap> implements Backend {
|
|
|
272
851
|
* by construction), stamp the entry, ship the envelope with the LATEST args, resolve the handle.
|
|
273
852
|
* The entry stays on `pendingMutations` (now with a real mid) until the lmid release confirms it. */
|
|
274
853
|
private flushFold;
|
|
854
|
+
/** The transport a `domain`-confirmed mutation ships on (§7.5 sent-pins-domain: only the
|
|
855
|
+
* domain's own authority can confirm it, so its channel is the only correct transport). A
|
|
856
|
+
* domain with NO connected gate ships on the daemon channel — the gate-less configurations
|
|
857
|
+
* (`__testRelease`-driven tests) and today's entire live path resolve `"daemon"` anyway. */
|
|
858
|
+
private channelFor;
|
|
859
|
+
/** The gate a channel-keyed retain registers through (G-iii registration-time routing). The
|
|
860
|
+
* channel MUST already be connected (`connectSource`; the daemon is constructor-attached) —
|
|
861
|
+
* loud by design: a typo'd or not-yet-connected sourceKey must throw at retain time, never
|
|
862
|
+
* silently register on the daemon and split the query's frames across channels. */
|
|
863
|
+
private requireGate;
|
|
864
|
+
/** The channel that owns `sourceQid` — {@link RemoteSub.channel}, the ONE source of truth for
|
|
865
|
+
* qid routing (G-iii). `undefined` when no sub owns the qid (a harness-delivered raw feed, or
|
|
866
|
+
* a just-released sub): such frames buffer on whatever gate they arrive at. */
|
|
867
|
+
private channelOf;
|
|
868
|
+
/** Record `(domain, mid)` as processed; `false` if it already was (a duplicate frame —
|
|
869
|
+
* ignore it). FIFO-capped per domain ({@link MAX_PROCESSED_OUTCOMES_PER_DOMAIN}). */
|
|
870
|
+
private markOutcomeProcessed;
|
|
871
|
+
/** One `mutationOutcome` frame from `domain`'s channel (H-v — the §3.3 handshake's client
|
|
872
|
+
* half). The frame arrives OUT-OF-BAND (see {@link attachGate}); the state machine:
|
|
873
|
+
*
|
|
874
|
+
* 1. `mid` never issued on `domain` ⇒ ignore (a confused/foreign frame must not invent work).
|
|
875
|
+
* 2. `(domain, mid)` already processed ⇒ ignore — idempotence under duplicate frames (the
|
|
876
|
+
* original + a re-send's re-answer; a deopt for a mid whose entry ALREADY FLIPPED also
|
|
877
|
+
* lands here harmlessly on its second frame).
|
|
878
|
+
* 3. `kind:"rejected"` ⇒ FINAL. Surface the reason through {@link rejectedHandler} (room-plane
|
|
879
|
+
* parity with the HTTP queue's callback) and STOP — the drop + snap-back is the EXISTING
|
|
880
|
+
* failed-mutation machinery: the room burnt the mid, its lmid release retires the entry
|
|
881
|
+
* per-domain and the reconcile rewinds the prediction, exactly the daemon path's
|
|
882
|
+
* processed-as-no-op rejection. No new drop path.
|
|
883
|
+
* 4. `kind:"deopt"`, entry found (pending `(domain, mid)`) ⇒ FLIP IN PLACE: `domain` becomes
|
|
884
|
+
* `"daemon"`, a fresh daemon mid is dealt and the envelope ships NOW on the daemon channel
|
|
885
|
+
* ("deal-and-send-now" — the conforming §3.3 re-enqueue: there is no flush machinery for
|
|
886
|
+
* non-fold entries, so the design's "mid: null until the daemon flush" is satisfied
|
|
887
|
+
* momentarily inside this call). THE ENTRY'S `seq` IS KEPT — settled (§5.3, commit
|
|
888
|
+
* 68141096): `seq` is the client-global REPLAY order; re-sequencing would move the entry's
|
|
889
|
+
* overlay position and change read-dependent SIBLINGS' replay base. Everything else stays
|
|
890
|
+
* (writes/reads/touched/touchedSources/writeSources — union-never-shrink), the prediction
|
|
891
|
+
* stays applied (the entry never leaves `pendingMutations`, so no rewind fires), and the
|
|
892
|
+
* router does NOT re-run nor does `drainOverlapping` (§3.3 re-enqueues, never re-derives;
|
|
893
|
+
* any open overlapping fold was invoked later and flushes later with a larger mid).
|
|
894
|
+
* 5. `kind:"deopt"`, entry NOT found ⇒ the burnt-mid confirm won the race, or the frame is a
|
|
895
|
+
* replay re-answer for an entry a previous session retired (the replay gotcha): re-invoke
|
|
896
|
+
* the frame's echoed `name`/`args` as a FRESH invocation PINNED to `"daemon"` — an honest
|
|
897
|
+
* re-prediction on the current base, never a derived route ({@link invokeWith}). A frame
|
|
898
|
+
* without `name` (not self-contained) has nothing to re-invoke and is dropped; a re-invoke
|
|
899
|
+
* that THROWS (the base moved from under it) is surfaced through {@link rejectedHandler} —
|
|
900
|
+
* the mutation is dead with no stream left to confirm it.
|
|
901
|
+
*
|
|
902
|
+
* A `"deopt"` bump joins the Q6 routing counters either way (`routing.reasons.deopt`) —
|
|
903
|
+
* derived-and-deopted routes are visible beside derived successes. */
|
|
904
|
+
private handleMutationOutcome;
|
|
905
|
+
/** §7.5 rule 3 (H-v): re-send `domain`'s unconfirmed pending envelopes with their ORIGINAL
|
|
906
|
+
* mids, in mid order, on the domain's own channel. Folds with `mid === null` are excluded —
|
|
907
|
+
* nothing was ever sent for them (the flush deals their mid). Envelopes are reconstructed from
|
|
908
|
+
* the pending entries exactly as `invoke` shipped them (`clientID`/`mid`/`name`/`args` —
|
|
909
|
+
* entries carry everything the wire needs). Idempotent under the domain's ledger: an APPLIED
|
|
910
|
+
* mid dedups silently and its lmid coverage retires the entry; a NON-APPLIED mid is re-answered
|
|
911
|
+
* from the shell's recorded-outcome map into {@link handleMutationOutcome}. Confirmed entries
|
|
912
|
+
* are already gone from `pendingMutations`, so no filter against the watermark is needed. */
|
|
913
|
+
private resendPending;
|
|
275
914
|
/** Drain every outstanding fold immediately (FOLDED-MUTATIONS-DESIGN §3): the explicit
|
|
276
915
|
* `app.flushFolds()` and the `beforeunload`/`close` hook. Creation (insertion) order. */
|
|
277
916
|
flushFolds(): void;
|
|
@@ -304,17 +943,213 @@ export declare class OptimisticBackend<S extends ColsMap> implements Backend {
|
|
|
304
943
|
* §4.1). Built fresh per call from state the backend already holds — no new instrumentation, no
|
|
305
944
|
* mutation. Only ever called by `@rindle/devtools` (imported in dev). */
|
|
306
945
|
__inspect(): OptimisticInspect;
|
|
946
|
+
/** Test-only per-domain ledger snapshot (RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md §7.1/§8.5).
|
|
947
|
+
* Kept separate from {@link __inspect} so the devtools `OptimisticInspect` mirror stays byte-for-
|
|
948
|
+
* byte identical (daemon-scalar-only). Exposes the per-domain `nextMid`/`watermark` maps plus each
|
|
949
|
+
* pending entry's confirming domain — the axes the §8.5 ledger-isolation assertion checks. */
|
|
950
|
+
__inspectDomains(): {
|
|
951
|
+
nextMid: Record<string, number>;
|
|
952
|
+
watermark: Record<string, number>;
|
|
953
|
+
/** Per connected CHANNEL (§5.1): its release watermark + buffered-frame depth — the axis the
|
|
954
|
+
* gate-isolation assertions read (one source's laggy cvMin must never move the other's) —
|
|
955
|
+
* plus the §301 upstream-absorption advert last recorded from its progress frames (a ROOM
|
|
956
|
+
* gate with a new shell; absent otherwise). */
|
|
957
|
+
gates: Record<string, {
|
|
958
|
+
appliedCv: number;
|
|
959
|
+
bufferedFrames: number;
|
|
960
|
+
upstreamCv?: number;
|
|
961
|
+
upstreamBoot?: string;
|
|
962
|
+
}>;
|
|
963
|
+
/** The §3 router's counters (H-iii) — Q6's measurement hook: how often derivation succeeded
|
|
964
|
+
* (per derived room) and why it fell to the daemon (per reason). See {@link RoutingInspect}
|
|
965
|
+
* for the counting discipline (per-candidate failures; pins bump nothing). */
|
|
966
|
+
routing: RoutingInspect;
|
|
967
|
+
/** The §4 lifecycle plane's folded state (Slice I-iii introspection): the per-doc §4.2 fence
|
|
968
|
+
* value (`roomWatermarks`, I-v's ghost-drop input), the per-scope §4.1 occupancy map
|
|
969
|
+
* (`scopeSessions`: scope → client_id → expires_at, I-iv's doorbell input), and the live
|
|
970
|
+
* I-v ghosts (demoted room sources still awaiting their fence). */
|
|
971
|
+
lifecycle: {
|
|
972
|
+
roomWatermarks: Record<string, number>;
|
|
973
|
+
scopeSessions: Record<string, Record<string, number>>;
|
|
974
|
+
ghosts: Record<string, {
|
|
975
|
+
doc: string;
|
|
976
|
+
finalFlushSeq: number;
|
|
977
|
+
tables: string[];
|
|
978
|
+
}>;
|
|
979
|
+
};
|
|
980
|
+
/** The live §301 pin registry (`301-ECHO-FENCE-DESIGN.md` §2.5 — the devtools/Q6 surface):
|
|
981
|
+
* every parked hold-back's fence inputs plus its tripwire state, keyed
|
|
982
|
+
* `${table}\0${sourceKey}\0${pkKey}`. Pruned lazily, so an entry here may briefly outlive
|
|
983
|
+
* its engine pin (never the reverse). */
|
|
984
|
+
pins: Record<string, {
|
|
985
|
+
table: string;
|
|
986
|
+
sourceKey: string;
|
|
987
|
+
domain: string;
|
|
988
|
+
mid: number;
|
|
989
|
+
daemonBoot?: string;
|
|
990
|
+
daemonCv?: number;
|
|
991
|
+
tripwired: boolean;
|
|
992
|
+
}>;
|
|
993
|
+
/** The §301 direction-A fence map: per room domain, the highest DAEMON-CARRIED ledger lmid. */
|
|
994
|
+
daemonCarriedLmid: Record<string, number>;
|
|
995
|
+
pending: {
|
|
996
|
+
mid: number | null;
|
|
997
|
+
seq: number | null;
|
|
998
|
+
name: string;
|
|
999
|
+
domain: string;
|
|
1000
|
+
touchedSources: string[];
|
|
1001
|
+
writeSources: Record<string, string>;
|
|
1002
|
+
}[];
|
|
1003
|
+
};
|
|
307
1004
|
/** Recompute the pending axis for every query and fire `onPending` on transitions only. Called
|
|
308
1005
|
* from the two points that move the pending set: invoke/invokeFolded (add) and the confirm-drop
|
|
309
1006
|
* (remove) — exactly where `:359`/`:468` used to flip ResultType (§7.3). */
|
|
310
1007
|
private refreshPending;
|
|
311
|
-
private
|
|
1008
|
+
private onFrame;
|
|
312
1009
|
private emitServerDelta;
|
|
313
1010
|
private localQidsForSource;
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
*
|
|
1011
|
+
/** One gate's release (§5.1 release gate): compute the coherent delta from THIS gate's cv-buffer,
|
|
1012
|
+
* then apply it against the gate's source/domain. Split into {@link computeRelease} (buffer →
|
|
1013
|
+
* delta, lmid → watermark) and {@link applyRelease} (per-source confirm-drop + reconcile) —
|
|
1014
|
+
* N independent gates all feed the ONE apply half; {@link __testRelease} drives it directly. */
|
|
1015
|
+
private onGateProgress;
|
|
1016
|
+
/** Compute one coherent release from ONE gate's cv-buffer (§5.1) — gate-scoped: its buffer, its
|
|
1017
|
+
* cvMin timeline. Take every buffered frame at `cv ≤ cvMin`, in (cv, arrival) order, and fold
|
|
1018
|
+
* it: the lmid system-query frame advances `watermark[gate.key]` (via {@link foldLmidOps} — the
|
|
1019
|
+
* daemon stream folds "daemon", a room stream folds its own domain); data frames fold through
|
|
1020
|
+
* this SOURCE's cross-query refcount into ONE net base delta — the §1.3 `D`. Returns that delta
|
|
1021
|
+
* plus the set of local views this release JUST hydrated (so their reconcile batch phases as a
|
|
1022
|
+
* `snapshot`). Mutates the gate's buffer/`appliedCv`, hydration, and the gate's domain
|
|
1023
|
+
* watermark; the pending set and the reconcile are {@link applyRelease}'s job. */
|
|
1024
|
+
private computeRelease;
|
|
1025
|
+
/** Apply one released delta against `sourceKey`'s domain (§7.2 per-domain confirm-drop + the §1.3
|
|
1026
|
+
* reconcile cycle). `watermarkUpdate`, when given, advances `watermark[sourceKey]` first — the
|
|
1027
|
+
* hook a per-source lmid confirm rides on (the daemon path folds its watermark in
|
|
1028
|
+
* {@link computeRelease} and passes `undefined`). Then: drop every pending entry its OWN domain's
|
|
1029
|
+
* watermark now covers (a room confirm can never retire a daemon entry, and vice-versa — the §7.1
|
|
1030
|
+
* ledger-collision fix), and run the reconcile cycle against `sourceKey` when the base delta or the
|
|
1031
|
+
* pending set changed. `newlyHydrated` stamps the initial-hydration batch as a catch-up. */
|
|
1032
|
+
private applyRelease;
|
|
1033
|
+
/** Test-only per-source release seam (RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md §7.2/§8.5): drive
|
|
1034
|
+
* {@link applyRelease} for `sourceKey` directly — an explicit `watermarkUpdate` (a simulated lmid
|
|
1035
|
+
* confirm for that domain) and `deltas` (a coherent base delta), with no real gate. Lets a harness
|
|
1036
|
+
* exercise a room-domain confirm before the real second lmid stream / per-source gate is wired
|
|
1037
|
+
* (E-iii-b/c). The `__`-prefix marks it a test hook, alongside {@link __inspect}. */
|
|
1038
|
+
__testRelease(sourceKey: string, deltas: Mutation[], watermarkUpdate?: number): void;
|
|
1039
|
+
/** Register (or overwrite — a later cross-slice confirm re-pinning the same slice-pk matches
|
|
1040
|
+
* the engine's `held_back.insert`) one {@link EchoFencePin}, in the same breath as the
|
|
1041
|
+
* engine park (§2.1). `p` is the confirmed entry whose write staged onto `sourceKey`; its
|
|
1042
|
+
* `mid` is non-null by the caller's confirm filter. */
|
|
1043
|
+
private registerPin;
|
|
1044
|
+
/** The §2.3 drop pass, run at the tail of every applied release: prune registry entries whose
|
|
1045
|
+
* engine pin is already gone (the rewind's state-match fallback or a whole-source removal beat
|
|
1046
|
+
* the fence — benign), drop every pin whose fence cleared (one engine drop each, delivered on
|
|
1047
|
+
* the ordinary event stream, bracketed as ONE notification commit), and tripwire the rest. */
|
|
1048
|
+
private dropEchoFencePins;
|
|
1049
|
+
/** Has `pin`'s delivery fence provably closed its confirm→echo window? (§2.3/§2.4.) */
|
|
1050
|
+
private pinFenceCleared;
|
|
1051
|
+
/** The §2.5 stuck-pin tripwire, in the scopesHash spirit: log ONCE per pin when its slice's
|
|
1052
|
+
* baseline row has CHANGED VALUE since park while the pin still holds — the suspicious state
|
|
1053
|
+
* that precedes every forever-pin (a fence-less pairing, a fence bug). Never drops anything. */
|
|
1054
|
+
private maybeTripwirePin;
|
|
1055
|
+
/** Record one observed daemon boot id (§2.4): first observation of an id assigns the next
|
|
1056
|
+
* ordinal (the client's own total order over opaque boot ids); every call refreshes the
|
|
1057
|
+
* current-boot stamp for direction-B parks. */
|
|
1058
|
+
private observeDaemonBoot;
|
|
1059
|
+
/** Promote `table` to a MERGED multi-source engine with room `sourceKey`'s per-row writable
|
|
1060
|
+
* scope (RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md §5.2) — THE one promotion seam. The engine
|
|
1061
|
+
* attach and the {@link promotedTables} record move in the same breath: the §7.3 hold-back
|
|
1062
|
+
* trigger in {@link applyRelease} parks ONLY on promoted tables, so a promotion that bypassed
|
|
1063
|
+
* the record would silently disable the echo hold-back for that table (and a record without the
|
|
1064
|
+
* engine attach would park onto a slice that doesn't exist). Recorded AFTER the engine accepts —
|
|
1065
|
+
* a rejected descriptor/table must not leave a phantom promotion. Slice G-v's client drives this
|
|
1066
|
+
* from the lease's `realtime.tables` (`RoomTableSpec` → {@link WritableDescriptor}); idempotence
|
|
1067
|
+
* per `(sourceKey, table)` is the CALLER's job (the engine refuses a duplicate room).
|
|
1068
|
+
*
|
|
1069
|
+
* `spec` (H-iii) is the per-table ROUTING spec riding the same lease table block — recorded
|
|
1070
|
+
* into {@link roomRouting} (THE routing table the §3 router reads) in the same breath, with
|
|
1071
|
+
* `writable` derived from the descriptor's kind (`none` = a context table the room may not
|
|
1072
|
+
* write). Omitted (the E-iii-b harness alias below) ⇒ an empty spec: no join keys to guard, no
|
|
1073
|
+
* `footprintWhere` (that room's reads then fail closed to the daemon unless self/room-served). */
|
|
1074
|
+
promoteRoomTable(table: string, sourceKey: string, writable: WritableDescriptor, spec?: RoomTableRoutingSpec): void;
|
|
1075
|
+
/** The recorded routing specs for `sourceKey`'s promoted tables (table → its
|
|
1076
|
+
* {@link RoomTableRouting}) — read-only: the client's `__realtimeInspect`/idempotence
|
|
1077
|
+
* bookkeeping reads THIS record instead of keeping its own shadow copy (one source of truth).
|
|
1078
|
+
* Empty map when the room has promoted nothing. */
|
|
1079
|
+
roomTablesFor(sourceKey: string): ReadonlyMap<string, RoomTableRouting>;
|
|
1080
|
+
/** Test-named alias of {@link promoteRoomTable} (E-iii-b scaffolding — the multi-domain oracle
|
|
1081
|
+
* and per-source-gate suites drive it). Pure delegation: ONE body, one `promotedTables` record. */
|
|
1082
|
+
__addRoomSource(table: string, roomKey: string, writable: WritableDescriptor, spec?: RoomTableRoutingSpec): void;
|
|
1083
|
+
/** Fold `domain`'s lmid system query's released ops (lmid-as-data): the one row's
|
|
1084
|
+
* `last_mutation_id` cell is this client's confirmed high-water mid in that domain — it advances
|
|
1085
|
+
* `watermark[domain]` and, on a fresh session ahead of our issued mids, `nextMid[domain]`. The
|
|
1086
|
+
* daemon stream folds `"daemon"`; a room stream folds its own `"room:doc:X"`; the daemon-carried
|
|
1087
|
+
* §7.1 ledger rows fold through the same {@link foldConfirm} core (Slice I-iii). */
|
|
317
1088
|
private foldLmidOps;
|
|
1089
|
+
/** THE one confirm fold (§7.1/§7.2): advance `watermark[domain]` to `lmid` (monotone max) and,
|
|
1090
|
+
* on a fresh session ahead of our issued mids, adopt `nextMid[domain]`. Shared verbatim by the
|
|
1091
|
+
* per-channel lmid system query ({@link foldLmidOps}) and the daemon-carried room-ledger rows
|
|
1092
|
+
* ({@link foldSystemFrames} — one core so the two paths cannot drift). */
|
|
1093
|
+
private foldConfirm;
|
|
1094
|
+
/** Fold one release's SYSTEM frames in a FIXED category order — the order is STRUCTURAL (one
|
|
1095
|
+
* function, categories in sequence), because it is the client half of THE NAMED INVARIANT
|
|
1096
|
+
* (§3.3's shipped note; documented above {@link handleMutationOutcome}): **never retire a
|
|
1097
|
+
* room-domain entry off a daemon-carried lmid without outcome resolution.**
|
|
1098
|
+
*
|
|
1099
|
+
* 1. **outcome rows** (`_rindle_room_mutation_outcomes`) — each row for OUR clientID is
|
|
1100
|
+
* synthesized into a {@link MutationOutcomeFrame} and routed through
|
|
1101
|
+
* {@link handleMutationOutcome}, the SAME H-v state machine the room socket's frames use
|
|
1102
|
+
* (one verdict path: frames and rows cannot drift). A deopt flips its pending entry to
|
|
1103
|
+
* the daemon IN PLACE (keep-seq, deal-and-send-now); a rejection surfaces + stays for the
|
|
1104
|
+
* ordinary burnt-mid retire; a duplicate (frame already seen, or the row re-delivered) is
|
|
1105
|
+
* absorbed by the processed set — which doubles as the resolved-verdict memory across
|
|
1106
|
+
* releases (per-domain FIFO, {@link MAX_PROCESSED_OUTCOMES_PER_DOMAIN}, mirroring the
|
|
1107
|
+
* shell's recorded-outcome cap).
|
|
1108
|
+
* 2. **room-ledger rows** (`_rindle_room_client_mutations`) — the FIRST daemon-carried
|
|
1109
|
+
* room-lmid path: OUR row's `last_mutation_id` folds into `watermark[room:<doc>]` via
|
|
1110
|
+
* {@link foldConfirm}. Because step 1 ALREADY resolved every non-applied verdict this
|
|
1111
|
+
* release carries (and earlier releases' verdicts were resolved at their own release),
|
|
1112
|
+
* the confirm-drop that follows in {@link applyRelease} retires only entries whose
|
|
1113
|
+
* outcome is resolution-by-absence — which I-ii's co-commit atomicity defines as APPLIED
|
|
1114
|
+
* (a room flush co-commits the ledger row and every non-applied mid's outcome row in ONE
|
|
1115
|
+
* daemon transaction, so a covering lmid without a row IS the applied verdict).
|
|
1116
|
+
* Processing this category before step 1 is the violation, in two proven directions
|
|
1117
|
+
* (each run break→fail→revert against `test/system_streams.test.ts`): (a) the ledger's
|
|
1118
|
+
* fresh-session `nextMid` ADOPTION must not run before historical outcome rows are
|
|
1119
|
+
* judged — adopted-first, a previous session's retained deopt row passes the
|
|
1120
|
+
* "never-issued" guard and spuriously re-invokes a mutation that session already handled
|
|
1121
|
+
* (a double-apply); (b) the RETIRE must not precede resolution — it does not BECAUSE the
|
|
1122
|
+
* confirm-drop runs in {@link applyRelease}, strictly after this whole function. That
|
|
1123
|
+
* deferral is load-bearing: an "optimization" retiring inline with the watermark fold
|
|
1124
|
+
* retires a deopted entry as a silent success (the exact lost-write H-v exists to
|
|
1125
|
+
* prevent) and mis-attributes a rejected row's reason.
|
|
1126
|
+
* 3. **watermark rows** (`_rindle_room_watermark`) — the §4.2 fence value, max-folded per
|
|
1127
|
+
* doc ({@link roomWatermarks}); I-v's ghost-drop consumer, no reaction here.
|
|
1128
|
+
* 4. **scope-session rows** (`_rindle_scope_sessions`) — the §4.1 occupancy map
|
|
1129
|
+
* ({@link scopeSessions}); I-iv's doorbell consumer, no reaction here.
|
|
1130
|
+
*
|
|
1131
|
+
* Ordinary data ops fold AFTER all of these (the caller's main loop) — outcome/ledger state
|
|
1132
|
+
* must be in place before {@link applyRelease}'s confirm-drop + reconcile consume the release.
|
|
1133
|
+
* Every row is filtered against the retain's {@link SystemStreamSpec} scope/doc AND (for the
|
|
1134
|
+
* client-keyed tables) our own `clientID` — defense in depth: the server predicate may have
|
|
1135
|
+
* been minted doc-only (no `clientId` at lease time), so other clients' rows are expected and
|
|
1136
|
+
* must be ignored, and a row for a doc this retain was not minted for is never folded.
|
|
1137
|
+
*
|
|
1138
|
+
* Returns the scopes category 4 touched (snapshot or ops) — the I-iv doorbell events' input;
|
|
1139
|
+
* `null` when none (every non-lifecycle release). The events themselves fire from
|
|
1140
|
+
* `onGateProgress` AFTER the release applies, never from inside the fold. */
|
|
1141
|
+
private foldSystemFrames;
|
|
1142
|
+
/** The I-iv occupancy count — THE one rule (§4.1/D7): unexpired (`expires_at >` the fold
|
|
1143
|
+
* clock's now) sessions under `scope` from OTHER clientIDs. Shared by the doorbell events
|
|
1144
|
+
* ({@link onGateProgress}) and the client's registration-time check (a doorbell that folded
|
|
1145
|
+
* BEFORE a candidate registered must still be able to trigger it) so the two can never
|
|
1146
|
+
* disagree. Own-clientID rows never count — a solo client cannot ring its own bell — and
|
|
1147
|
+
* expiry is judged on the injectable {@link FoldClock} (deterministic in a virtual-clock
|
|
1148
|
+
* harness, the folded-oracle discipline). */
|
|
1149
|
+
otherScopeSessions(scope: string): number;
|
|
1150
|
+
/** Register the I-iv doorbell event sink — see {@link ScopeSessionsEvent}. One handler (a later
|
|
1151
|
+
* registration replaces it, the {@link onLocalWrite} convention); client.ts is the consumer. */
|
|
1152
|
+
onScopeSessions(handler: (event: ScopeSessionsEvent) => void): void;
|
|
318
1153
|
/** One §1.3 reconcile cycle: rewind the optimistic layer and fold the coherent SERVER
|
|
319
1154
|
* delta into BOTH head AND the `sync` baseline (`serverBatchBegin`), re-invoke every
|
|
320
1155
|
* still-pending mutator to re-stage the optimistic layer (the rewind un-applied it), then
|
|
@@ -322,18 +1157,20 @@ export declare class OptimisticBackend<S extends ColsMap> implements Backend {
|
|
|
322
1157
|
* boundary — `onProgress` releases and `unregisterQuery`'s GC both go through here so head
|
|
323
1158
|
* and sync never diverge (the §1.2 invariant; CRIT#2). */
|
|
324
1159
|
private runReconcileCycle;
|
|
325
|
-
/**
|
|
326
|
-
* sequence reset, so previously-released `cv`s no longer bound the new stream. The
|
|
327
|
-
* already re-subscribed every query (reconnect → resync); drop
|
|
328
|
-
* watermark so the fresh, low-`cv` snapshots are RELEASED instead of dropped as stale
|
|
329
|
-
* (`
|
|
330
|
-
*
|
|
331
|
-
*
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
*
|
|
336
|
-
*
|
|
1160
|
+
/** ONE channel's authority restarted (a new boot id): it lost all materialization + `cv` state
|
|
1161
|
+
* and its `cv` sequence reset, so previously-released `cv`s no longer bound the new stream. The
|
|
1162
|
+
* source has already re-subscribed every query (reconnect → resync); drop THIS gate's buffer
|
|
1163
|
+
* and `cv` watermark so the fresh, low-`cv` snapshots are RELEASED instead of dropped as stale
|
|
1164
|
+
* (`onFrame`/`computeRelease` gate on `appliedCv`). The OTHER gates are untouched — an
|
|
1165
|
+
* authority restart is per-channel (§5.1). Pending optimistic mutations stay put — they
|
|
1166
|
+
* re-apply on the next reconcile, and the channel's lmid system query's fresh snapshot restores
|
|
1167
|
+
* its domain's confirmation watermark. */
|
|
1168
|
+
private resetGate;
|
|
1169
|
+
/** The §8.5 escape: ONE gate's buffer outgrew its cap (a pinned `cvMin` under churn on that
|
|
1170
|
+
* channel). Drop everything it buffered and re-register every query on that source — the fresh
|
|
1171
|
+
* snapshots arrive as ordinary frames and the next release re-hydrates via the footprint diff
|
|
1172
|
+
* (the §5.3 path); still-pending optimism re-applies in that cycle. The other gates' buffers
|
|
1173
|
+
* and subscriptions are untouched. */
|
|
337
1174
|
private overflow;
|
|
338
1175
|
private setResultType;
|
|
339
1176
|
/** Recompute a query's server-channel state from hydration alone (§7): a pending mutation no
|
|
@@ -343,8 +1180,26 @@ export declare class OptimisticBackend<S extends ColsMap> implements Backend {
|
|
|
343
1180
|
* lift those views out of `unknown` (loading). Idempotent — a re-hydrate snapshot re-marks
|
|
344
1181
|
* harmlessly; a source qid with no sub (the lmid system query) is a no-op. */
|
|
345
1182
|
private markSubHydrated;
|
|
1183
|
+
/** `channel` (G-iii registration-time routing): the gate the sub registers on — the qid's
|
|
1184
|
+
* ownership is fixed HERE, at retain time (no lazy claim; `onFrame` only asserts it). Default
|
|
1185
|
+
* `"daemon"`, so every channel-less caller is byte-identical to before. */
|
|
346
1186
|
private retainRemote;
|
|
347
1187
|
private releaseRemote;
|
|
348
1188
|
private addServerDependencyTables;
|
|
349
1189
|
}
|
|
1190
|
+
/** Whether `cond` is decidable from the KEY ALONE: every column it references — collected from
|
|
1191
|
+
* BOTH operand positions (under-counting could over-claim decidability; over-counting only fails
|
|
1192
|
+
* closed — the H-iv-a discipline) — is a pk column. A `correlatedSubquery` is never key-local. */
|
|
1193
|
+
export declare function keyDecidable(cond: Condition, pkCols: ReadonlySet<string>): boolean;
|
|
1194
|
+
/** Evaluate a key-decidable `footprintWhere` on a read's pk cells — H-iii's ONE client-side
|
|
1195
|
+
* non-engine evaluator, DELIBERATELY MINIMAL (the TS evaluation caveat on the router block
|
|
1196
|
+
* comment): `simple` `=`/`!=` between a pk COLUMN and a same-primitive-type NON-NULL literal,
|
|
1197
|
+
* composed under and/or. The empty AND — the compiler's vacuous-true emission for an exact
|
|
1198
|
+
* unconstrained footprint root — evaluates `true` (load-bearing: whole-table footprints keep
|
|
1199
|
+
* provable reads); the empty OR is vacuous-false. Returns `undefined` = NOT EVALUABLE for
|
|
1200
|
+
* anything else (other ops, null on either side, cross-type comparisons, column-vs-column,
|
|
1201
|
+
* literal-vs-literal, non-primitive cells, correlated subqueries) — the caller fails to the
|
|
1202
|
+
* daemon. A non-evaluable node anywhere poisons the whole tree (no short-circuit past it):
|
|
1203
|
+
* partial evaluation could otherwise claim a verdict the engine's semantics might contradict. */
|
|
1204
|
+
export declare function evalFootprintOnPk(cond: Condition, pkCells: ReadonlyMap<string, WireValue>): boolean | undefined;
|
|
350
1205
|
//# sourceMappingURL=backend.d.ts.map
|