@rindle/optimistic 0.1.0-rc.5
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/LICENSE +201 -0
- package/README.md +66 -0
- package/dist/agg-overlay.d.ts +86 -0
- package/dist/agg-overlay.d.ts.map +1 -0
- package/dist/agg-overlay.js +224 -0
- package/dist/agg-overlay.js.map +1 -0
- package/dist/backend.d.ts +243 -0
- package/dist/backend.d.ts.map +1 -0
- package/dist/backend.js +998 -0
- package/dist/backend.js.map +1 -0
- package/dist/client-id.d.ts +12 -0
- package/dist/client-id.d.ts.map +1 -0
- package/dist/client-id.js +60 -0
- package/dist/client-id.js.map +1 -0
- package/dist/client.d.ts +64 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +81 -0
- package/dist/client.js.map +1 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +25 -0
- package/dist/index.js.map +1 -0
- package/package.json +43 -0
- package/src/agg-overlay.ts +257 -0
- package/src/backend.ts +1155 -0
- package/src/client-id.ts +61 -0
- package/src/client.ts +156 -0
- package/src/index.ts +68 -0
package/dist/backend.js
ADDED
|
@@ -0,0 +1,998 @@
|
|
|
1
|
+
// OptimisticBackend — the composition that adds OPTIMISTIC WRITES to the normalized
|
|
2
|
+
// local-first path (OPTIMISTIC-WRITES-DESIGN.md §8.5, §9). The richer cousin of
|
|
3
|
+
// `NormalizedBackend`: the same substrate (a local `@rindle/wasm` engine + a server
|
|
4
|
+
// stream + `NormalizedSync`), plus
|
|
5
|
+
//
|
|
6
|
+
// - a **named client-mutator registry**: `invoke("createIssue", args)` runs the
|
|
7
|
+
// mutator against the live engine NOW (the prediction shows instantly), pushes
|
|
8
|
+
// `(mid, name, args)` — never effects — onto the pending stack, and ships the
|
|
9
|
+
// envelope upstream (§4);
|
|
10
|
+
// - **lmid-as-data**: at construction the backend subscribes its own one-row
|
|
11
|
+
// SYSTEM QUERY (`LMID_QUERY_NAME` → `_rindle_client_mutations WHERE client_id =
|
|
12
|
+
// me`). The server writes `lmid` in the SAME transaction as a mutation's
|
|
13
|
+
// effects, so the confirmation arrives as an ordinary cv-tagged data frame and
|
|
14
|
+
// is released by the same `cvMin` — confirmation can never skew from the data
|
|
15
|
+
// of its own commit (the drain race the old frame-carried lmid suffered);
|
|
16
|
+
// - **cv-buffering**: every `cv`-tagged data frame buffers; a progress frame
|
|
17
|
+
// releases all `cv ≤ cvMin` as ONE coherent step — data ops into the engine's
|
|
18
|
+
// `sync` side, lmid-table ops into `confirmedLmid` (§1.3.1/§8.5 — no torn reads
|
|
19
|
+
// across queries, no apply ahead of the release point);
|
|
20
|
+
// - the **§1.3 reconcile cycle** per release: the wasm engine rewinds
|
|
21
|
+
// (`serverBatchBegin`), every still-pending mutator is **re-invoked** against the
|
|
22
|
+
// rebased base (read-dependent mutators read current values — §4.1), and the
|
|
23
|
+
// whole cycle coalesces to one minimal delivery (`serverBatchEnd`) — a
|
|
24
|
+
// confirmed-correct prediction produces zero churn;
|
|
25
|
+
// - per-query **ResultType** is the SERVER CHANNEL's state only (FOLDED-MUTATIONS-DESIGN
|
|
26
|
+
// §7): `unknown` while not hydrated, else `complete`. A pending local mutation no longer
|
|
27
|
+
// moves it — "is a prediction pending here?" is its own reactive axis (`pending(qid)` /
|
|
28
|
+
// `onPending`), so a fold held through its debounce window doesn't pin a query loading.
|
|
29
|
+
// There is NO server rejection signal: a failed mutation is processed-as-no-op (lmid
|
|
30
|
+
// advances, effects rolled back) and the prediction snaps back on the ordinary release.
|
|
31
|
+
// - **folded mutations** (FOLDED-MUTATIONS-DESIGN): `invokeFolded` collapses a run of
|
|
32
|
+
// same-key absorbing writes to ONE pending entry whose `args` are overwritten in place,
|
|
33
|
+
// debounces the server write (a virtual `clock` seam keeps it test-deterministic), and
|
|
34
|
+
// ships only the last value. The `mid` is assigned at FLUSH (not invoke), so the gapless
|
|
35
|
+
// wire sequence is preserved under debounce (§4.1); an overlapping non-fold write drains
|
|
36
|
+
// the interacting fold first (`drainOverlapping`, §4.2 flush-on-enqueue).
|
|
37
|
+
//
|
|
38
|
+
// `Store`/`ArrayView` are untouched: this implements `Backend`, so
|
|
39
|
+
// `new Store(schema, new OptimisticBackend(...))` reuses the whole machinery.
|
|
40
|
+
import { CLIENT_MUTATIONS_SCHEMA, LMID_QUERY_NAME, normalizedTableSchemas, tableSpec } from "@rindle/client";
|
|
41
|
+
import { aggTableSchemas, NormalizedSync, rewriteAggregates } from "@rindle/normalized";
|
|
42
|
+
import { WasmBackend } from "@rindle/wasm";
|
|
43
|
+
import { AggOverlay, collectAggDefs } from "./agg-overlay.js";
|
|
44
|
+
/** The reserved source-qid of the backend's own lmid system query. User/local query ids
|
|
45
|
+
* are assigned by the `Store` starting at 1, so 0 never collides. */
|
|
46
|
+
const LMID_QID = 0;
|
|
47
|
+
/** Thrown by the read-trap tx when a FOLDED mutator reads state (`tx.get`/`tx.row`) — the classic
|
|
48
|
+
* non-absorbing shape, refused at the folded path (FOLDED-MUTATIONS-DESIGN §5). */
|
|
49
|
+
class FoldReadError extends Error {
|
|
50
|
+
}
|
|
51
|
+
/** Default trailing-debounce window for a folded call site (FOLDED-MUTATIONS-DESIGN §3 example). */
|
|
52
|
+
const DEFAULT_FOLD_DEBOUNCE_MS = 120;
|
|
53
|
+
/** The real-timer clock used when none is injected. */
|
|
54
|
+
const REAL_CLOCK = {
|
|
55
|
+
setTimeout: (cb, ms) => setTimeout(cb, ms),
|
|
56
|
+
clearTimeout: (h) => clearTimeout(h),
|
|
57
|
+
now: () => Date.now(),
|
|
58
|
+
};
|
|
59
|
+
export class OptimisticBackend {
|
|
60
|
+
local;
|
|
61
|
+
sync;
|
|
62
|
+
source;
|
|
63
|
+
registry;
|
|
64
|
+
clientID;
|
|
65
|
+
bufferCap;
|
|
66
|
+
/** Column order + pk indices per table, for the keyed `MutationTx` methods. */
|
|
67
|
+
specs;
|
|
68
|
+
/** Each table's full column count (union-row width) + column-name → base ColId — to learn a
|
|
69
|
+
* projected query's per-table projection off its `hello` and register it with the sync layer,
|
|
70
|
+
* so it scatters that query's narrower rows into the shared union (PROJECTION-SUPPORT-DESIGN
|
|
71
|
+
* §5.2). Without this a projected query's short rows reach the wasm `Db` un-scattered and fail
|
|
72
|
+
* its width check. */
|
|
73
|
+
colCounts;
|
|
74
|
+
colIndex;
|
|
75
|
+
/** The client's OWN typed per-table schemas + the reserved lmid table — the fixed base
|
|
76
|
+
* of the expected-schema set (CRIT#4 validation). Synthetic agg tables are appended as
|
|
77
|
+
* queries arrive (`ensureSyntheticTables`). */
|
|
78
|
+
clientTablesBase;
|
|
79
|
+
/** Synthetic aggregate tables (`__agg_*`) registered so far, by name (AGGREGATE-SYNC-DESIGN
|
|
80
|
+
* §3.3). Per aggregate DEFINITION (not per query), so two queries over the same count
|
|
81
|
+
* share one table. */
|
|
82
|
+
synthetic = new Map();
|
|
83
|
+
/** Synthetic table name → how many registered LOCAL queries reference it. A table is
|
|
84
|
+
* materialized on the `0→1` transition and reclaimed (engine source + baseline + refcount
|
|
85
|
+
* layer + overlay def) on `1→0` — so aggregate state is not permanent (§4). */
|
|
86
|
+
syntheticRefs = new Map();
|
|
87
|
+
/** Local qid → the synthetic tables it referenced at registration, to decrement on teardown. */
|
|
88
|
+
queryAggTables = new Map();
|
|
89
|
+
/** The optimistic aggregate overlay (§4–§6): the per-aggregate definitions + the per-group
|
|
90
|
+
* pending delta `displayed = server_base ⊕ local_pending_delta` is applied from. */
|
|
91
|
+
overlay = new AggOverlay();
|
|
92
|
+
handler = () => { };
|
|
93
|
+
pendingMutations = [];
|
|
94
|
+
nextMid = 1;
|
|
95
|
+
/** The live fold entries, by fold key `${name}\0${identityJSON}` — at most one per key
|
|
96
|
+
* (FOLDED-MUTATIONS-DESIGN §8). Insertion order is creation order (the drain/flush tiebreak). */
|
|
97
|
+
folds = new Map();
|
|
98
|
+
/** The fold debounce clock (real timers by default; the oracle injects a virtual one). */
|
|
99
|
+
clock;
|
|
100
|
+
/** The high-water confirmed mutation id, folded from the lmid system query's
|
|
101
|
+
* RELEASED ops (lmid-as-data) — never from a frame. */
|
|
102
|
+
confirmedLmid = 0;
|
|
103
|
+
buffer = [];
|
|
104
|
+
nextSeq = 0;
|
|
105
|
+
appliedCv = 0;
|
|
106
|
+
asts = new Map();
|
|
107
|
+
/** Per query: the base tables its result can draw from (from the AST tree). */
|
|
108
|
+
queryTables = new Map();
|
|
109
|
+
remoteSubs = new Map();
|
|
110
|
+
sourceToRemote = new Map();
|
|
111
|
+
localToRemote = new Map();
|
|
112
|
+
remoteRetainToLocal = new Map();
|
|
113
|
+
resultTypes = new Map();
|
|
114
|
+
/** Local view qids that are server-authoritative: a query with no remote sub (purely local) is
|
|
115
|
+
* hydrated on registration; a remote query is hydrated when its sub's first snapshot releases.
|
|
116
|
+
* An un-hydrated query reports `unknown` (still loading) — the basis of `resultType`. */
|
|
117
|
+
hydrated = new Set();
|
|
118
|
+
resultTypeHandler = () => { };
|
|
119
|
+
/** The pending AXIS (§7.2), split off `ResultType`: per query, whether any pending mutation
|
|
120
|
+
* touches its tables. Cached so `onPending` fires only on transitions (invoke ↔ confirm). */
|
|
121
|
+
pendingState = new Map();
|
|
122
|
+
pendingHandler = () => { };
|
|
123
|
+
constructor(schema, source, registry, opts) {
|
|
124
|
+
this.local = new WasmBackend(schema);
|
|
125
|
+
this.local.onEvent((qid, ev) => this.handler(qid, ev));
|
|
126
|
+
this.colCounts = colCountsFromSchema(schema);
|
|
127
|
+
this.colIndex = colIndexFromSchema(schema);
|
|
128
|
+
this.sync = new NormalizedSync(pkColsFromSchema(schema), this.colCounts);
|
|
129
|
+
this.specs = tableSpecsFromSchema(schema);
|
|
130
|
+
this.source = source;
|
|
131
|
+
this.registry = registry;
|
|
132
|
+
this.clientID = opts.clientID;
|
|
133
|
+
this.bufferCap = opts.bufferCap ?? 1024;
|
|
134
|
+
this.clock = opts.clock ?? REAL_CLOCK;
|
|
135
|
+
// Validate each server hello against our OWN typed schema → reject a schema skew
|
|
136
|
+
// (CRIT#4). The reserved lmid table is part of the expected set so the system
|
|
137
|
+
// query's hello passes. Synthetic agg tables join the set as queries register them.
|
|
138
|
+
this.clientTablesBase = [...normalizedTableSchemas(schema), CLIENT_MUTATIONS_SCHEMA];
|
|
139
|
+
this.source.expectClientSchema?.(this.clientTablesBase);
|
|
140
|
+
this.source.onNormalized((qid, ev) => this.onNormalized(qid, ev));
|
|
141
|
+
this.source.onProgress((frame) => this.onProgress(frame));
|
|
142
|
+
this.source.onRestart?.(() => this.resetForRestart());
|
|
143
|
+
// The lmid system query (lmid-as-data): our confirmations arrive on this stream,
|
|
144
|
+
// cv-tagged, released by the same cvMin as the data they belong to. The server
|
|
145
|
+
// derives the identity from the connection; args are advisory.
|
|
146
|
+
this.source.registerQuery(LMID_QID, { name: LMID_QUERY_NAME, args: {} });
|
|
147
|
+
}
|
|
148
|
+
// --- the Backend seam ---------------------------------------------------------
|
|
149
|
+
registerQuery(qid, ast, remote) {
|
|
150
|
+
this.asts.set(qid, ast);
|
|
151
|
+
// queryTables is derived from the ORIGINAL ast — its `count(comments)` subquery names
|
|
152
|
+
// `comment`, so an optimistic comment mutation flips this query to `unknown` (§6). The
|
|
153
|
+
// local engine, by contrast, runs the REWRITTEN ast (reads the synthetic `__agg_*`).
|
|
154
|
+
this.queryTables.set(qid, collectTables(ast));
|
|
155
|
+
// A relationship `count` is DISPLAYED from a server-authoritative synthetic base table,
|
|
156
|
+
// not recomputed locally (AGGREGATE-SYNC-DESIGN.md §3.3): register that table (engine +
|
|
157
|
+
// refcount layer + hello validation), then drive the local engine off a rewritten AST
|
|
158
|
+
// whose `count` relationships read it with a plain projected join. The remote query stays
|
|
159
|
+
// un-rewritten — the server always emits the synthetic `__agg_*` rows.
|
|
160
|
+
this.ensureSyntheticTables(qid, ast);
|
|
161
|
+
// Local first (synchronous empty view), then the server stream hydrates it.
|
|
162
|
+
this.local.registerQuery(qid, rewriteAggregates(ast));
|
|
163
|
+
if (remote) {
|
|
164
|
+
// A remote query is `unknown` until its first server snapshot lands (hydration); retainRemote
|
|
165
|
+
// attaches it to the sub and sets the lifecycle against the sub's hydration state.
|
|
166
|
+
this.retainRemote(qid, remote);
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
// No server stream (a purely local AST view — or the local half of a split retain whose
|
|
170
|
+
// remote attaches separately via `retainRemoteQuery`): local data is synchronous, so it is
|
|
171
|
+
// `complete` with nothing to await. A later remote retain flips it back to `unknown` if it
|
|
172
|
+
// attaches an un-hydrated sub.
|
|
173
|
+
this.hydrated.add(qid);
|
|
174
|
+
this.setResultType(qid, "complete");
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/** Register every synthetic aggregate table `ast` needs that we haven't seen yet: on the
|
|
178
|
+
* local engine (which auto-tracks it for the optimistic rebase loop), on `NormalizedSync`
|
|
179
|
+
* (so its rows refcount/GC by group key), and into the source's expected-schema set (so
|
|
180
|
+
* the server's `hello` — which advertises the same table — passes CRIT#4 validation).
|
|
181
|
+
* Idempotent across queries that share an aggregate definition. */
|
|
182
|
+
ensureSyntheticTables(qid, ast) {
|
|
183
|
+
// Idempotent per qid: a re-register of the same query keeps the refcounts balanced.
|
|
184
|
+
if (this.queryAggTables.has(qid))
|
|
185
|
+
return;
|
|
186
|
+
let added = false;
|
|
187
|
+
const names = [];
|
|
188
|
+
for (const t of aggTableSchemas(ast)) {
|
|
189
|
+
names.push(t.name);
|
|
190
|
+
const prev = this.syntheticRefs.get(t.name) ?? 0;
|
|
191
|
+
this.syntheticRefs.set(t.name, prev + 1);
|
|
192
|
+
if (prev > 0)
|
|
193
|
+
continue; // another query already materialized this table — just refcount
|
|
194
|
+
this.synthetic.set(t.name, t);
|
|
195
|
+
this.local.registerTable(t.name, { columns: t.columns, primaryKey: t.primaryKey });
|
|
196
|
+
this.sync.registerTable(t.name, t.primaryKey);
|
|
197
|
+
added = true;
|
|
198
|
+
}
|
|
199
|
+
if (names.length)
|
|
200
|
+
this.queryAggTables.set(qid, names);
|
|
201
|
+
// The optimistic delta (§4) needs each aggregate's child table + group key + filter, which
|
|
202
|
+
// the synthetic schema alone doesn't carry — derive the definitions from the original AST.
|
|
203
|
+
this.overlay.register(collectAggDefs(ast, (t) => this.specs[t]?.columns));
|
|
204
|
+
if (added)
|
|
205
|
+
this.source.expectClientSchema?.([...this.clientTablesBase, ...this.synthetic.values()]);
|
|
206
|
+
}
|
|
207
|
+
/** Decrement the refcount of every synthetic table query `qid` referenced; for each one that
|
|
208
|
+
* reaches 0 (no live reader left), remove it from the engine, the refcount layer, and the
|
|
209
|
+
* overlay — so aggregate state is reclaimed, not permanent (§4). Must run AFTER
|
|
210
|
+
* `local.unregisterQuery(qid)` so the engine source has no live connection when
|
|
211
|
+
* `unregisterTable` frees it (the engine refuses otherwise). */
|
|
212
|
+
releaseSyntheticTables(qid) {
|
|
213
|
+
const names = this.queryAggTables.get(qid);
|
|
214
|
+
if (!names)
|
|
215
|
+
return;
|
|
216
|
+
this.queryAggTables.delete(qid);
|
|
217
|
+
let removed = false;
|
|
218
|
+
for (const name of names) {
|
|
219
|
+
const next = (this.syntheticRefs.get(name) ?? 1) - 1;
|
|
220
|
+
if (next > 0) {
|
|
221
|
+
this.syntheticRefs.set(name, next);
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
this.syntheticRefs.delete(name);
|
|
225
|
+
this.local.unregisterTable(name);
|
|
226
|
+
this.sync.unregisterTable(name);
|
|
227
|
+
this.overlay.unregister(name);
|
|
228
|
+
this.synthetic.delete(name);
|
|
229
|
+
removed = true;
|
|
230
|
+
}
|
|
231
|
+
if (removed)
|
|
232
|
+
this.source.expectClientSchema?.([...this.clientTablesBase, ...this.synthetic.values()]);
|
|
233
|
+
}
|
|
234
|
+
unregisterQuery(qid) {
|
|
235
|
+
const remoteQid = this.releaseRemote(qid);
|
|
236
|
+
if (remoteQid !== undefined)
|
|
237
|
+
this.buffer = this.buffer.filter((f) => f.qid !== remoteQid);
|
|
238
|
+
// GC: rows this remote footprint SOLELY referenced fall to refcount 0 → net removes.
|
|
239
|
+
const gc = remoteQid === undefined ? [] : this.sync.dropQuery(remoteQid);
|
|
240
|
+
// Tear down the local pipeline+view first so the reconcile cycle below skips it.
|
|
241
|
+
this.local.unregisterQuery(qid);
|
|
242
|
+
// The GC removals must leave BOTH head AND the engine's `sync` baseline. A plain
|
|
243
|
+
// `local.mutate` (a HEAD-only write) leaves them in `sync`, so they look like a pending
|
|
244
|
+
// optimistic REMOVE: the next release's rewind diffs head against sync+D and RESURRECTS
|
|
245
|
+
// them, GC never frees anything, and a later query is served the stale/deleted row
|
|
246
|
+
// forever (CRIT#2). Deliver them as a coherent SERVER delta instead — the same
|
|
247
|
+
// sync-moving boundary `onProgress` uses — so head and sync both drop the rows.
|
|
248
|
+
if (gc.length)
|
|
249
|
+
this.runReconcileCycle(gc);
|
|
250
|
+
// The local pipeline is gone (no live conn) and the remote footprint's `__agg` rows were
|
|
251
|
+
// GC'd above, so any synthetic table this was the last reader of can now be freed (§4).
|
|
252
|
+
this.releaseSyntheticTables(qid);
|
|
253
|
+
this.asts.delete(qid);
|
|
254
|
+
this.queryTables.delete(qid);
|
|
255
|
+
this.resultTypes.delete(qid);
|
|
256
|
+
this.hydrated.delete(qid);
|
|
257
|
+
this.pendingState.delete(qid); // §7.2 cache, keyed by the local materialized qid (a monotonic
|
|
258
|
+
// Store counter — re-materialize gets a fresh id, never this one again), so drop it on teardown.
|
|
259
|
+
}
|
|
260
|
+
retainRemoteQuery(qid, remote, localQueryId) {
|
|
261
|
+
this.retainRemote(qid, remote, localQueryId);
|
|
262
|
+
}
|
|
263
|
+
releaseRemoteQuery(qid) {
|
|
264
|
+
const remoteQid = this.releaseRemote(qid);
|
|
265
|
+
if (remoteQid === undefined)
|
|
266
|
+
return;
|
|
267
|
+
this.buffer = this.buffer.filter((f) => f.qid !== remoteQid);
|
|
268
|
+
const gc = this.sync.dropQuery(remoteQid);
|
|
269
|
+
if (gc.length)
|
|
270
|
+
this.runReconcileCycle(gc);
|
|
271
|
+
}
|
|
272
|
+
/** Raw CRUD has no optimistic story (§9 replaces it with named mutators). Register a
|
|
273
|
+
* mutator — even a trivial one — and `invoke` it. */
|
|
274
|
+
mutate(_mutations) {
|
|
275
|
+
return Promise.reject(new Error("optimistic backend: writes go through named mutators — use invoke(name, args)"));
|
|
276
|
+
}
|
|
277
|
+
onEvent(handler) {
|
|
278
|
+
this.handler = handler;
|
|
279
|
+
}
|
|
280
|
+
// --- the named-mutator entry (§9) ----------------------------------------------
|
|
281
|
+
/** Run the named client mutator optimistically: the prediction applies to the live
|
|
282
|
+
* engine now (affected views update synchronously), `(mid, name, args)` joins the
|
|
283
|
+
* pending stack, and the envelope ships upstream. Returns the assigned `mid`. */
|
|
284
|
+
invoke(name, args) {
|
|
285
|
+
const mutator = this.registry[name];
|
|
286
|
+
if (!mutator)
|
|
287
|
+
throw new Error(`unknown client mutator: ${name}`);
|
|
288
|
+
// Apply the prediction FIRST. If the mutator throws (client-side validation, a bad read),
|
|
289
|
+
// the staged write is discarded (the wasm txn is a clean no-op until commit) and the throw
|
|
290
|
+
// propagates with NO mid consumed — a burnt mid is a permanent server-side gap that
|
|
291
|
+
// silently refuses every later mutation from this client (#10).
|
|
292
|
+
const touched = new Set();
|
|
293
|
+
const ops = [];
|
|
294
|
+
this.local.writeWith((tx) => {
|
|
295
|
+
mutator(trackingTx(tx, touched, this.specs, this.opCollector(ops)), args);
|
|
296
|
+
});
|
|
297
|
+
// Flush-on-enqueue (§4.2): a fold whose tables overlap this write must take its mid NOW, BEFORE
|
|
298
|
+
// this write does, so wire order == local-apply order for any pair that can observe each other
|
|
299
|
+
// (a read-dependent write reading a folded cell sees the same value optimistically and on the
|
|
300
|
+
// wire — no snap). Drained folds ship with smaller mids; this write's mid is dealt after.
|
|
301
|
+
this.drainOverlapping(touched);
|
|
302
|
+
const mid = this.nextMid++;
|
|
303
|
+
this.pendingMutations.push({ mid, name, args, touched });
|
|
304
|
+
// The prediction stuck — fold its child ops into the optimistic agg delta and push it onto
|
|
305
|
+
// the `__agg` head rows (§4). No reset here (this is the §1.3 trivial case, no rewind): the
|
|
306
|
+
// delta accumulates on top of the prior pending set, and `reconcileAggHead` recomputes each
|
|
307
|
+
// touched group's head as the absolute `server_base ⊕ delta`.
|
|
308
|
+
for (const op of ops)
|
|
309
|
+
this.overlay.observe(op);
|
|
310
|
+
this.reconcileAggHead();
|
|
311
|
+
this.refreshPending(); // §7.2: this write now touches its queries' pending axis (NOT ResultType).
|
|
312
|
+
void this.source.pushMutation({ clientID: this.clientID, mid, name, args });
|
|
313
|
+
return mid;
|
|
314
|
+
}
|
|
315
|
+
/** Run a FOLDED invoke (FOLDED-MUTATIONS-DESIGN §8): apply the prediction to the live engine now
|
|
316
|
+
* (like `invoke`), but collapse a run of same-key invokes into ONE pending entry whose `args`
|
|
317
|
+
* are overwritten in place, debounce the server write, and ship only the last value. The `mid`
|
|
318
|
+
* is assigned at flush, not here (§4.1) — so the return is a {@link FoldHandle}, not a mid. */
|
|
319
|
+
invokeFolded(name, opts, args) {
|
|
320
|
+
const mutator = this.registry[name];
|
|
321
|
+
if (!mutator)
|
|
322
|
+
throw new Error(`unknown client mutator: ${name}`);
|
|
323
|
+
const foldKey = `${name}\0${stableJson(opts.key)}`;
|
|
324
|
+
// Apply the prediction with the read trap armed (§5): a folded mutator that reads state to
|
|
325
|
+
// compute its write is non-absorbing and refused. A throw discards the staged write (clean
|
|
326
|
+
// no-op) and consumes no mid — exactly `invoke`'s guarantee.
|
|
327
|
+
const touched = new Set();
|
|
328
|
+
const ops = [];
|
|
329
|
+
try {
|
|
330
|
+
this.local.writeWith((tx) => {
|
|
331
|
+
mutator(trackingTx(tx, touched, this.specs, this.opCollector(ops), true), args);
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
catch (e) {
|
|
335
|
+
if (e instanceof FoldReadError) {
|
|
336
|
+
throw new Error(`cannot fold "${name}": it reads state via tx.get/tx.row, so it is not absorbing — folded mutators must be last-writer-wins (FOLDED-MUTATIONS-DESIGN §5)`);
|
|
337
|
+
}
|
|
338
|
+
throw e;
|
|
339
|
+
}
|
|
340
|
+
for (const op of ops)
|
|
341
|
+
this.overlay.observe(op);
|
|
342
|
+
this.reconcileAggHead();
|
|
343
|
+
const now = this.clock.now();
|
|
344
|
+
let f = this.folds.get(foldKey);
|
|
345
|
+
if (f) {
|
|
346
|
+
// Overwrite the single entry in place — the pending stack does NOT grow (§1 #2). The head
|
|
347
|
+
// already carries this new prediction (absorbing, last-wins on the cell); the entry holds
|
|
348
|
+
// only the LATEST args, which is what a rebase re-derives from and what the flush ships.
|
|
349
|
+
f.entry.args = args;
|
|
350
|
+
f.entry.touched = touched;
|
|
351
|
+
f.args = args;
|
|
352
|
+
this.clock.clearTimeout(f.timer);
|
|
353
|
+
}
|
|
354
|
+
else {
|
|
355
|
+
const entry = { mid: null, name, args, touched };
|
|
356
|
+
this.pendingMutations.push(entry);
|
|
357
|
+
let resolveMid;
|
|
358
|
+
const midPromise = new Promise((res) => (resolveMid = res));
|
|
359
|
+
f = {
|
|
360
|
+
entry,
|
|
361
|
+
args,
|
|
362
|
+
timer: undefined,
|
|
363
|
+
firstAt: now,
|
|
364
|
+
debounceMs: opts.debounceMs ?? DEFAULT_FOLD_DEBOUNCE_MS,
|
|
365
|
+
maxWaitMs: opts.maxWaitMs,
|
|
366
|
+
deferAcrossWrites: opts.deferAcrossWrites ?? false,
|
|
367
|
+
midPromise,
|
|
368
|
+
resolveMid,
|
|
369
|
+
};
|
|
370
|
+
this.folds.set(foldKey, f);
|
|
371
|
+
}
|
|
372
|
+
// (Re)arm the trailing debounce — unless the maxWaitMs cap is already due (a never-idle drag
|
|
373
|
+
// still persists periodically, §3/#4), in which case flush now instead of re-arming.
|
|
374
|
+
if (f.maxWaitMs !== undefined && now - f.firstAt >= f.maxWaitMs) {
|
|
375
|
+
this.flushFold(foldKey);
|
|
376
|
+
}
|
|
377
|
+
else {
|
|
378
|
+
f.timer = this.clock.setTimeout(() => this.flushFold(foldKey), f.debounceMs);
|
|
379
|
+
}
|
|
380
|
+
this.refreshPending();
|
|
381
|
+
const handle = { flush: () => this.flushFold(foldKey), mid: f.midPromise };
|
|
382
|
+
return handle;
|
|
383
|
+
}
|
|
384
|
+
/** Flush-on-enqueue (§4.2): for each outstanding fold whose touched tables overlap `tables`,
|
|
385
|
+
* assign its mid NOW and ship it — in creation (insertion) order, so the wire stays gapless. A
|
|
386
|
+
* `deferAcrossWrites` fold opts out (it keeps deferring, accepting the read-dependent snap). The
|
|
387
|
+
* incoming write's own fold key (if any) is skipped — it is being folded into, not flushed. */
|
|
388
|
+
drainOverlapping(tables, exceptKey) {
|
|
389
|
+
// Snapshot the entries first: `flushFold` mutates `this.folds` mid-iteration.
|
|
390
|
+
for (const [key, f] of [...this.folds]) {
|
|
391
|
+
if (key === exceptKey || f.deferAcrossWrites)
|
|
392
|
+
continue;
|
|
393
|
+
if (intersects(f.entry.touched, tables))
|
|
394
|
+
this.flushFold(key);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
/** Flush one fold (§8): deal its `mid` from `nextMid` (SEND order — never reserved, so gapless
|
|
398
|
+
* by construction), stamp the entry, ship the envelope with the LATEST args, resolve the handle.
|
|
399
|
+
* The entry stays on `pendingMutations` (now with a real mid) until the lmid release confirms it. */
|
|
400
|
+
flushFold(foldKey) {
|
|
401
|
+
const f = this.folds.get(foldKey);
|
|
402
|
+
if (!f)
|
|
403
|
+
return;
|
|
404
|
+
this.clock.clearTimeout(f.timer);
|
|
405
|
+
this.folds.delete(foldKey);
|
|
406
|
+
const mid = this.nextMid++;
|
|
407
|
+
f.entry.mid = mid;
|
|
408
|
+
void this.source.pushMutation({ clientID: this.clientID, mid, name: f.entry.name, args: f.args });
|
|
409
|
+
f.resolveMid(mid);
|
|
410
|
+
}
|
|
411
|
+
/** Drain every outstanding fold immediately (FOLDED-MUTATIONS-DESIGN §3): the explicit
|
|
412
|
+
* `app.flushFolds()` and the `beforeunload`/`close` hook. Creation (insertion) order. */
|
|
413
|
+
flushFolds() {
|
|
414
|
+
for (const key of [...this.folds.keys()])
|
|
415
|
+
this.flushFold(key);
|
|
416
|
+
}
|
|
417
|
+
/** A `trackingTx` op collector that records only the ops over a tracked aggregate's child
|
|
418
|
+
* table (the others can't move any count). Applied to the overlay by the caller AFTER the
|
|
419
|
+
* mutator succeeds, so a throwing mutator (whose staged write is discarded) leaves no delta. */
|
|
420
|
+
opCollector(ops) {
|
|
421
|
+
return (op) => {
|
|
422
|
+
if (this.overlay.hasChild(op.table))
|
|
423
|
+
ops.push(op);
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
/** Push the optimistic per-group delta onto the `__agg` head rows (§4):
|
|
427
|
+
* `target = server_base ⊕ delta`. A head-only write to the (tracked) synthetic table, so it
|
|
428
|
+
* joins the optimistic layer and is rewound/rebuilt by the reconcile cycle like any
|
|
429
|
+
* prediction. `server_base` is read from `NormalizedSync` (the authoritative base) — NOT
|
|
430
|
+
* from head, which already carries the optimistic layer (a torn read). Works standalone (an
|
|
431
|
+
* ordinary delivery) and inside an open cycle (the write buffers into it). */
|
|
432
|
+
reconcileAggHead() {
|
|
433
|
+
const entries = this.overlay.entries();
|
|
434
|
+
if (entries.length === 0)
|
|
435
|
+
return;
|
|
436
|
+
this.local.writeWith((tx) => {
|
|
437
|
+
for (const e of entries) {
|
|
438
|
+
const countCol = e.def.groupKeyCols.length; // row is [group…, count]; count is at index k
|
|
439
|
+
const serverRow = this.sync.baseRow(e.aggTable, e.cells);
|
|
440
|
+
const serverBase = serverRow ? Number(serverRow[countCol]) : 0;
|
|
441
|
+
const target = Math.max(0, serverBase + e.n); // a displayed count never goes below 0
|
|
442
|
+
const headRow = tx.get(e.aggTable, e.cells);
|
|
443
|
+
const wantRow = serverRow !== undefined || target > 0;
|
|
444
|
+
if (wantRow) {
|
|
445
|
+
const desired = [...e.cells, target];
|
|
446
|
+
if (!headRow)
|
|
447
|
+
tx.add(e.aggTable, desired); // §6.1 optimistic birth (no server group yet)
|
|
448
|
+
else if (!rowsEqual(headRow, desired))
|
|
449
|
+
tx.edit(e.aggTable, headRow, desired);
|
|
450
|
+
}
|
|
451
|
+
else if (headRow) {
|
|
452
|
+
tx.remove(e.aggTable, headRow); // delta took a not-yet-on-server group back to identity
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
});
|
|
456
|
+
this.overlay.pruneZeros();
|
|
457
|
+
}
|
|
458
|
+
/** The SERVER CHANNEL's state for a query (§7): `unknown` while not hydrated, else `complete`.
|
|
459
|
+
* A pending local mutation no longer moves it — see {@link pending}. */
|
|
460
|
+
resultType(qid) {
|
|
461
|
+
return this.resultTypes.get(qid) ?? "complete";
|
|
462
|
+
}
|
|
463
|
+
onResultType(handler) {
|
|
464
|
+
this.resultTypeHandler = handler;
|
|
465
|
+
}
|
|
466
|
+
// --- the pending AXIS (§7.2): orthogonal to ResultType -------------------------------
|
|
467
|
+
/** Whether any pending mutation (folded or not) touches this query's tables — "is a prediction
|
|
468
|
+
* pending here?" (FOLDED-MUTATIONS-DESIGN §7.2). Orthogonal to {@link resultType}; this is the
|
|
469
|
+
* same `queryTables ∩ pending.touched` computation that used to be smuggled into `unknown`. */
|
|
470
|
+
pending(qid) {
|
|
471
|
+
const tables = this.queryTables.get(qid);
|
|
472
|
+
if (!tables)
|
|
473
|
+
return false;
|
|
474
|
+
return this.pendingMutations.some((p) => intersects(tables, p.touched));
|
|
475
|
+
}
|
|
476
|
+
/** Reactive pending axis (§7.2): fires when a query's pending-ness flips (invoke ↔ confirm), so a
|
|
477
|
+
* "saving…" affordance clears on its own when `lmid` catches up. */
|
|
478
|
+
onPending(handler) {
|
|
479
|
+
this.pendingHandler = handler;
|
|
480
|
+
}
|
|
481
|
+
/** The coarse, table-level pending indicator set (§7.2): every table some pending mutation touched. */
|
|
482
|
+
pendingTables() {
|
|
483
|
+
const out = new Set();
|
|
484
|
+
for (const p of this.pendingMutations)
|
|
485
|
+
for (const t of p.touched)
|
|
486
|
+
out.add(t);
|
|
487
|
+
return out;
|
|
488
|
+
}
|
|
489
|
+
/** Recompute the pending axis for every query and fire `onPending` on transitions only. Called
|
|
490
|
+
* from the two points that move the pending set: invoke/invokeFolded (add) and the confirm-drop
|
|
491
|
+
* (remove) — exactly where `:359`/`:468` used to flip ResultType (§7.3). */
|
|
492
|
+
refreshPending() {
|
|
493
|
+
for (const qid of this.queryTables.keys()) {
|
|
494
|
+
const now = this.pending(qid);
|
|
495
|
+
if (this.pendingState.get(qid) !== now) {
|
|
496
|
+
this.pendingState.set(qid, now);
|
|
497
|
+
this.pendingHandler(qid, now);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
// --- the downstream stream (§8.5: buffer, then release coherently) ---------------
|
|
502
|
+
onNormalized(qid, ev) {
|
|
503
|
+
if (ev.type === "hello") {
|
|
504
|
+
// A hello is a (re)subscribe = a NEW epoch. The column map below is mutated eagerly, but
|
|
505
|
+
// data frames are cv-buffered and drained later (onProgress). So any frame still buffered
|
|
506
|
+
// for this qid is from a SUPERSEDED epoch and must NOT be scattered through this epoch's
|
|
507
|
+
// (possibly changed) map — drop it. This epoch's snapshot, which always follows the hello,
|
|
508
|
+
// re-hydrates the qid from scratch, so the dropped frames are redundant. Scoped to this
|
|
509
|
+
// qid: other queries' frames (and the lmid system query's) keep their coherent release.
|
|
510
|
+
this.buffer = this.buffer.filter((f) => f.qid !== qid);
|
|
511
|
+
this.addServerDependencyTables(qid, ev.tables.map((t) => t.name));
|
|
512
|
+
// Learn this query's per-table column map (PROJECTION-SUPPORT-DESIGN.md §5.2): map each
|
|
513
|
+
// advertised column to its base ColId BY NAME. The hello may carry FEWER columns than the
|
|
514
|
+
// client's schema (a projection) or MORE (an EXPANDED server table mid an
|
|
515
|
+
// `expand-then-contract` migration) — a column the client lacks maps to `-1`, a DROP
|
|
516
|
+
// sentinel the sync layer discards while keeping the rest Absent. Register the map so the
|
|
517
|
+
// sync layer scatters the rows into the shared union; a table whose map is an in-order,
|
|
518
|
+
// drop-free full width IS '*' (a verbatim full row) and stays unregistered (a synthetic agg
|
|
519
|
+
// table is unknown here, also '*'). Idempotent across re-hydrate epochs.
|
|
520
|
+
for (const t of ev.tables) {
|
|
521
|
+
const full = this.colCounts[t.name];
|
|
522
|
+
if (full === undefined)
|
|
523
|
+
continue; // unknown/synthetic table → '*' (full presence)
|
|
524
|
+
const index = this.colIndex[t.name];
|
|
525
|
+
const cols = t.columns.map((name) => index?.get(name) ?? -1);
|
|
526
|
+
// Register a non-trivial map; otherwise revert to '*' — and CLEAR any stale map a prior
|
|
527
|
+
// epoch left (a server that expanded then contracted back), so the now-exact rows don't
|
|
528
|
+
// scatter through a `-1`-bearing layout (silent cell corruption).
|
|
529
|
+
if (cols.length !== full || cols.some((c, i) => c !== i))
|
|
530
|
+
this.sync.registerProjection(qid, t.name, cols);
|
|
531
|
+
else
|
|
532
|
+
this.sync.unregisterProjection(qid, t.name);
|
|
533
|
+
}
|
|
534
|
+
return; // envelope validation is the source's job
|
|
535
|
+
}
|
|
536
|
+
const cv = ev.cv ?? 0;
|
|
537
|
+
if (cv <= this.appliedCv && ev.type === "batch")
|
|
538
|
+
return; // stale redelivery
|
|
539
|
+
this.buffer.push({ cv, qid, kind: ev.type, ops: ev.ops, seq: this.nextSeq++ });
|
|
540
|
+
if (this.buffer.length > this.bufferCap)
|
|
541
|
+
this.overflow();
|
|
542
|
+
}
|
|
543
|
+
onProgress(frame) {
|
|
544
|
+
// (1) Take every buffered frame at cv ≤ cvMin, in (cv, arrival) order, and fold it:
|
|
545
|
+
// lmid system-query frames advance `confirmedLmid`; data frames fold through the
|
|
546
|
+
// cross-query refcount into ONE net base delta — the §1.3 `D`. Confirmation and
|
|
547
|
+
// data of the same commit share a cv, so they release together by construction.
|
|
548
|
+
const ready = this.buffer
|
|
549
|
+
.filter((f) => f.cv <= frame.cvMin)
|
|
550
|
+
.sort((a, b) => a.cv - b.cv || a.seq - b.seq);
|
|
551
|
+
this.buffer = this.buffer.filter((f) => f.cv > frame.cvMin);
|
|
552
|
+
const muts = [];
|
|
553
|
+
for (const f of ready) {
|
|
554
|
+
if (f.qid === LMID_QID) {
|
|
555
|
+
this.foldLmidOps(f.ops);
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
muts.push(...(f.kind === "snapshot" ? this.sync.rehydrate(f.qid, f.ops) : this.sync.applyBatch(f.qid, f.ops)));
|
|
559
|
+
// A query's first released snapshot is its hydration point — even an empty one (0 rows is an
|
|
560
|
+
// authoritative answer): lift every local view this sub feeds out of `unknown` (loading).
|
|
561
|
+
if (f.kind === "snapshot")
|
|
562
|
+
this.markSubHydrated(f.qid);
|
|
563
|
+
}
|
|
564
|
+
this.appliedCv = Math.max(this.appliedCv, frame.cvMin);
|
|
565
|
+
// (2) Drop confirmed pending (§1.3 step 5's bookkeeping half): mid ≤ the lmid this
|
|
566
|
+
// release itself delivered. A failed mutation drops the same way — the release just
|
|
567
|
+
// carries no effects for it, so the rewind in (3) snaps the prediction back. An UNFLUSHED
|
|
568
|
+
// folded entry (`mid == null`) is never confirmable — it has not crossed the wire — so it is
|
|
569
|
+
// always retained until its own flush stamps a real mid (FOLDED-MUTATIONS-DESIGN §4.1).
|
|
570
|
+
const before = this.pendingMutations.length;
|
|
571
|
+
this.pendingMutations = this.pendingMutations.filter((p) => p.mid === null || p.mid > this.confirmedLmid);
|
|
572
|
+
const pendingChanged = this.pendingMutations.length !== before;
|
|
573
|
+
// (3) The reconcile cycle — only when something can have changed: a base delta to
|
|
574
|
+
// fold in, or a pending set that shrank (its optimistic layer must rewind out).
|
|
575
|
+
if (muts.length || pendingChanged) {
|
|
576
|
+
this.runReconcileCycle(muts);
|
|
577
|
+
}
|
|
578
|
+
// (4) ResultType is the SERVER CHANNEL's state only now (§7): `unknown` while not hydrated,
|
|
579
|
+
// else `complete` — a pending mutation no longer moves it. The pending axis moves separately.
|
|
580
|
+
for (const qid of this.queryTables.keys()) {
|
|
581
|
+
this.setResultType(qid, this.hydrated.has(qid) ? "complete" : "unknown");
|
|
582
|
+
}
|
|
583
|
+
this.refreshPending();
|
|
584
|
+
}
|
|
585
|
+
/** Fold the lmid system query's released ops (lmid-as-data): the one row's
|
|
586
|
+
* `last_mutation_id` cell is this client's confirmed high-water mid. */
|
|
587
|
+
foldLmidOps(ops) {
|
|
588
|
+
for (const op of ops) {
|
|
589
|
+
const row = op.op === "add" ? op.row : op.op === "edit" ? op.new : undefined;
|
|
590
|
+
if (!row)
|
|
591
|
+
continue; // a remove (client GC) confirms nothing
|
|
592
|
+
const lmid = Number(row[1]);
|
|
593
|
+
if (!Number.isFinite(lmid))
|
|
594
|
+
continue;
|
|
595
|
+
if (lmid > this.nextMid - 1) {
|
|
596
|
+
if (this.pendingMutations.length > 0) {
|
|
597
|
+
// The server confirmed a mid we never issued while we have mutations in
|
|
598
|
+
// flight — two writers on one clientID or corrupted state. Unrecoverable.
|
|
599
|
+
throw new Error(`optimistic backend: confirmed lmid ${lmid} is ahead of issued mids (${this.nextMid - 1})`);
|
|
600
|
+
}
|
|
601
|
+
// A fresh session over a clientID with history: adopt the server's high-water
|
|
602
|
+
// mark so our next mid continues the sequence instead of colliding below it.
|
|
603
|
+
this.nextMid = lmid + 1;
|
|
604
|
+
}
|
|
605
|
+
this.confirmedLmid = Math.max(this.confirmedLmid, lmid);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
/** One §1.3 reconcile cycle: rewind the optimistic layer and fold the coherent SERVER
|
|
609
|
+
* delta into BOTH head AND the `sync` baseline (`serverBatchBegin`), re-invoke every
|
|
610
|
+
* still-pending mutator to re-stage the optimistic layer (the rewind un-applied it), then
|
|
611
|
+
* deliver the coalesced result (`serverBatchEnd`). This is the engine's only sync-moving
|
|
612
|
+
* boundary — `onProgress` releases and `unregisterQuery`'s GC both go through here so head
|
|
613
|
+
* and sync never diverge (the §1.2 invariant; CRIT#2). */
|
|
614
|
+
runReconcileCycle(serverDeltas) {
|
|
615
|
+
this.local.serverBatchBegin(serverDeltas.map(toServerOp));
|
|
616
|
+
// The rewind cleared every optimistic write — incl. prior `__agg` edits — so head is now
|
|
617
|
+
// the server baseline. Rebuild the optimistic agg delta from scratch off the re-invoked
|
|
618
|
+
// (confirm-filtered) pending set, so a just-confirmed mutation's delta vanishes exactly as
|
|
619
|
+
// its server count is absorbed (§5 watermark — no double count).
|
|
620
|
+
this.overlay.reset();
|
|
621
|
+
// Re-invoke in WIRE order: assigned mids ascending, then unflushed folds (`mid == null`) last
|
|
622
|
+
// by creation order — the deterministic slot of FOLDED-MUTATIONS-DESIGN §4.1. A read-dependent
|
|
623
|
+
// mutator must replay against the same base the server computed from, which is mid order; with
|
|
624
|
+
// deferred fold mids, mid order ≠ creation order, so we sort rather than trust array order. The
|
|
625
|
+
// comparator is explicit (NOT `(mid ?? ∞) - (mid ?? ∞)`, which is `∞ - ∞ = NaN` for two unflushed
|
|
626
|
+
// folds — a NaN comparator silently corrupts V8's sort): assigned-before-unflushed, mids
|
|
627
|
+
// ascending, and STABLE for two unflushed folds so they keep creation order across cycles.
|
|
628
|
+
const order = [...this.pendingMutations].sort((a, b) => {
|
|
629
|
+
if (a.mid === null && b.mid === null)
|
|
630
|
+
return 0; // both unflushed → stable creation order
|
|
631
|
+
if (a.mid === null)
|
|
632
|
+
return 1; // an unflushed fold sorts after every assigned mid
|
|
633
|
+
if (b.mid === null)
|
|
634
|
+
return -1;
|
|
635
|
+
return a.mid - b.mid;
|
|
636
|
+
});
|
|
637
|
+
const dropped = new Set();
|
|
638
|
+
try {
|
|
639
|
+
for (const p of order) {
|
|
640
|
+
const touched = new Set();
|
|
641
|
+
const ops = [];
|
|
642
|
+
try {
|
|
643
|
+
this.local.writeWith((tx) => {
|
|
644
|
+
this.registry[p.name](trackingTx(tx, touched, this.specs, this.opCollector(ops)), p.args);
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
catch {
|
|
648
|
+
// A re-invocation threw — e.g. a read-dependent mutator whose base row the server
|
|
649
|
+
// deleted, or one reading a row a now-rejected sibling created. Drop it from pending
|
|
650
|
+
// (its staged write was discarded); its prediction simply snaps back on this release —
|
|
651
|
+
// a throwing mutation surfaces on the mutation axis (`onRejected`), not as a query-level
|
|
652
|
+
// `error` (§7.4). The `finally` below ALWAYS closes the cycle, so one bad mutator can't
|
|
653
|
+
// wedge the engine forever (#7).
|
|
654
|
+
dropped.add(p);
|
|
655
|
+
continue;
|
|
656
|
+
}
|
|
657
|
+
// The re-invocation stuck — fold its child ops into the rebuilt optimistic agg delta.
|
|
658
|
+
for (const op of ops)
|
|
659
|
+
this.overlay.observe(op);
|
|
660
|
+
// The pending footprint is the UNION across invocations: a re-run that no-ops (touched =
|
|
661
|
+
// {}) must NOT shrink it, else a still-pending mutation reports not-pending and its
|
|
662
|
+
// pending-axis clear fires early (§7.2).
|
|
663
|
+
for (const t of touched)
|
|
664
|
+
p.touched.add(t);
|
|
665
|
+
}
|
|
666
|
+
// Preserve creation order in the live array (the unflushed-fold sort tiebreak depends on it).
|
|
667
|
+
if (dropped.size)
|
|
668
|
+
this.pendingMutations = this.pendingMutations.filter((p) => !dropped.has(p));
|
|
669
|
+
// Re-apply the optimistic agg delta onto the (rewound) `__agg` head rows — INSIDE the open
|
|
670
|
+
// cycle, so the writes buffer and coalesce into the one per-query delivery `serverBatchEnd`
|
|
671
|
+
// makes (and never escape as a separate batch).
|
|
672
|
+
this.reconcileAggHead();
|
|
673
|
+
}
|
|
674
|
+
finally {
|
|
675
|
+
this.local.serverBatchEnd(); // ALWAYS close the cycle — ONE delivery per affected query.
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
/** The daemon restarted (a new boot id): it lost all materialization + `cv` state and its `cv`
|
|
679
|
+
* sequence reset, so previously-released `cv`s no longer bound the new stream. The source has
|
|
680
|
+
* already re-subscribed every query (reconnect → resync); drop the buffer and the `cv`
|
|
681
|
+
* watermark so the fresh, low-`cv` snapshots are RELEASED instead of dropped as stale
|
|
682
|
+
* (`onNormalized`/`onProgress` gate on `appliedCv`). Pending optimistic mutations stay put —
|
|
683
|
+
* they re-apply on the next reconcile, and the lmid system query's fresh snapshot restores the
|
|
684
|
+
* confirmation watermark. */
|
|
685
|
+
resetForRestart() {
|
|
686
|
+
this.buffer = [];
|
|
687
|
+
this.appliedCv = 0;
|
|
688
|
+
}
|
|
689
|
+
/** The §8.5 escape: the buffer outgrew its cap (a pinned `cvMin` under churn). Drop
|
|
690
|
+
* everything buffered and re-register every query on the source — the fresh
|
|
691
|
+
* snapshots arrive as ordinary frames and the next release re-hydrates via the
|
|
692
|
+
* footprint diff (the §5.3 path); still-pending optimism re-applies in that cycle. */
|
|
693
|
+
overflow() {
|
|
694
|
+
this.buffer = [];
|
|
695
|
+
for (const sub of this.remoteSubs.values()) {
|
|
696
|
+
this.source.unregisterQuery(sub.sourceQid);
|
|
697
|
+
this.source.registerQuery(sub.sourceQid, sub.remote);
|
|
698
|
+
}
|
|
699
|
+
// The lmid system query's buffered frames were dropped too — re-subscribe it so a
|
|
700
|
+
// fresh snapshot restores the confirmation watermark.
|
|
701
|
+
this.source.unregisterQuery(LMID_QID);
|
|
702
|
+
this.source.registerQuery(LMID_QID, { name: LMID_QUERY_NAME, args: {} });
|
|
703
|
+
}
|
|
704
|
+
setResultType(qid, rt) {
|
|
705
|
+
if (this.resultTypes.get(qid) === rt)
|
|
706
|
+
return;
|
|
707
|
+
this.resultTypes.set(qid, rt);
|
|
708
|
+
this.resultTypeHandler(qid, rt);
|
|
709
|
+
}
|
|
710
|
+
/** Recompute a query's server-channel state from hydration alone (§7): a pending mutation no
|
|
711
|
+
* longer affects it. Used when a remote sub attaches to or hydrates a local view. */
|
|
712
|
+
recomputeResultType(qid) {
|
|
713
|
+
if (!this.queryTables.has(qid))
|
|
714
|
+
return;
|
|
715
|
+
this.setResultType(qid, this.hydrated.has(qid) ? "complete" : "unknown");
|
|
716
|
+
}
|
|
717
|
+
/** A remote sub's first snapshot landed: mark it (and every local view it feeds) hydrated, then
|
|
718
|
+
* lift those views out of `unknown` (loading). Idempotent — a re-hydrate snapshot re-marks
|
|
719
|
+
* harmlessly; a source qid with no sub (the lmid system query) is a no-op. */
|
|
720
|
+
markSubHydrated(sourceQid) {
|
|
721
|
+
const key = this.sourceToRemote.get(sourceQid);
|
|
722
|
+
if (!key)
|
|
723
|
+
return;
|
|
724
|
+
const sub = this.remoteSubs.get(key);
|
|
725
|
+
if (!sub || sub.hydrated)
|
|
726
|
+
return;
|
|
727
|
+
sub.hydrated = true;
|
|
728
|
+
for (const localQid of sub.localQids.keys()) {
|
|
729
|
+
this.hydrated.add(localQid);
|
|
730
|
+
this.recomputeResultType(localQid);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
retainRemote(retainQid, remote, localQueryId = retainQid) {
|
|
734
|
+
const key = remoteKey(remote);
|
|
735
|
+
let sub = this.remoteSubs.get(key);
|
|
736
|
+
let isNew = false;
|
|
737
|
+
if (!sub) {
|
|
738
|
+
sub = { sourceQid: retainQid, remote, refCount: 0, localQids: new Map(), hydrated: false };
|
|
739
|
+
this.remoteSubs.set(key, sub);
|
|
740
|
+
this.sourceToRemote.set(sub.sourceQid, key);
|
|
741
|
+
isNew = true;
|
|
742
|
+
}
|
|
743
|
+
sub.refCount++;
|
|
744
|
+
if (localQueryId !== undefined) {
|
|
745
|
+
sub.localQids.set(localQueryId, (sub.localQids.get(localQueryId) ?? 0) + 1);
|
|
746
|
+
// A late-joiner to an already-hydrated sub is immediately hydrated; otherwise this view now
|
|
747
|
+
// awaits the sub's first snapshot (so a split-path local view registered `complete` flips to
|
|
748
|
+
// `unknown` here). Then recompute its lifecycle.
|
|
749
|
+
if (sub.hydrated)
|
|
750
|
+
this.hydrated.add(localQueryId);
|
|
751
|
+
else
|
|
752
|
+
this.hydrated.delete(localQueryId);
|
|
753
|
+
this.recomputeResultType(localQueryId);
|
|
754
|
+
}
|
|
755
|
+
this.localToRemote.set(retainQid, key);
|
|
756
|
+
this.remoteRetainToLocal.set(retainQid, localQueryId);
|
|
757
|
+
if (isNew)
|
|
758
|
+
this.source.registerQuery(sub.sourceQid, remote);
|
|
759
|
+
}
|
|
760
|
+
releaseRemote(retainQid) {
|
|
761
|
+
const key = this.localToRemote.get(retainQid);
|
|
762
|
+
if (!key)
|
|
763
|
+
return undefined;
|
|
764
|
+
this.localToRemote.delete(retainQid);
|
|
765
|
+
const localQueryId = this.remoteRetainToLocal.get(retainQid);
|
|
766
|
+
this.remoteRetainToLocal.delete(retainQid);
|
|
767
|
+
const sub = this.remoteSubs.get(key);
|
|
768
|
+
if (!sub)
|
|
769
|
+
return undefined;
|
|
770
|
+
sub.refCount--;
|
|
771
|
+
if (localQueryId !== undefined) {
|
|
772
|
+
const refs = (sub.localQids.get(localQueryId) ?? 0) - 1;
|
|
773
|
+
if (refs > 0)
|
|
774
|
+
sub.localQids.set(localQueryId, refs);
|
|
775
|
+
else
|
|
776
|
+
sub.localQids.delete(localQueryId);
|
|
777
|
+
}
|
|
778
|
+
if (sub.refCount > 0)
|
|
779
|
+
return undefined;
|
|
780
|
+
this.source.unregisterQuery(sub.sourceQid);
|
|
781
|
+
this.sourceToRemote.delete(sub.sourceQid);
|
|
782
|
+
this.remoteSubs.delete(key);
|
|
783
|
+
return sub.sourceQid;
|
|
784
|
+
}
|
|
785
|
+
addServerDependencyTables(sourceQid, names) {
|
|
786
|
+
const key = this.sourceToRemote.get(sourceQid);
|
|
787
|
+
if (!key)
|
|
788
|
+
return;
|
|
789
|
+
const sub = this.remoteSubs.get(key);
|
|
790
|
+
if (!sub)
|
|
791
|
+
return;
|
|
792
|
+
let grew = false;
|
|
793
|
+
for (const localQid of sub.localQids.keys()) {
|
|
794
|
+
const tables = this.queryTables.get(localQid);
|
|
795
|
+
if (!tables)
|
|
796
|
+
continue;
|
|
797
|
+
for (const name of names) {
|
|
798
|
+
if (!tables.has(name))
|
|
799
|
+
grew = true;
|
|
800
|
+
tables.add(name);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
// A newly-learned server dependency may bring a query into a pending mutation's footprint (§7.2);
|
|
804
|
+
// ResultType is unaffected (server-channel-only, §7).
|
|
805
|
+
if (grew)
|
|
806
|
+
this.refreshPending();
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
function remoteKey(remote) {
|
|
810
|
+
return stableJson([remote.name, remote.args]);
|
|
811
|
+
}
|
|
812
|
+
function stableJson(value) {
|
|
813
|
+
if (value === null || typeof value !== "object")
|
|
814
|
+
return JSON.stringify(value);
|
|
815
|
+
if (Array.isArray(value))
|
|
816
|
+
return `[${value.map(stableJson).join(",")}]`;
|
|
817
|
+
const obj = value;
|
|
818
|
+
return `{${Object.keys(obj)
|
|
819
|
+
.sort()
|
|
820
|
+
.map((k) => `${JSON.stringify(k)}:${stableJson(obj[k])}`)
|
|
821
|
+
.join(",")}}`;
|
|
822
|
+
}
|
|
823
|
+
function pkColsFromSchema(schema) {
|
|
824
|
+
const out = {};
|
|
825
|
+
for (const name of Object.keys(schema.tables))
|
|
826
|
+
out[name] = tableSpec(schema.tables[name]).primaryKey;
|
|
827
|
+
return out;
|
|
828
|
+
}
|
|
829
|
+
function tableSpecsFromSchema(schema) {
|
|
830
|
+
const out = {};
|
|
831
|
+
for (const name of Object.keys(schema.tables)) {
|
|
832
|
+
const { columns, primaryKey } = tableSpec(schema.tables[name]);
|
|
833
|
+
out[name] = { columns, primaryKey };
|
|
834
|
+
}
|
|
835
|
+
return out;
|
|
836
|
+
}
|
|
837
|
+
/** Each table's FULL column count (the union-row width), from the typed schema. */
|
|
838
|
+
function colCountsFromSchema(schema) {
|
|
839
|
+
const out = {};
|
|
840
|
+
for (const name of Object.keys(schema.tables))
|
|
841
|
+
out[name] = tableSpec(schema.tables[name]).columns.length;
|
|
842
|
+
return out;
|
|
843
|
+
}
|
|
844
|
+
/** Each table's column name → base ColId, from the typed schema (for mapping a projected hello's
|
|
845
|
+
* columns back to base positions, PROJECTION-SUPPORT-DESIGN.md §5.2). */
|
|
846
|
+
function colIndexFromSchema(schema) {
|
|
847
|
+
const out = {};
|
|
848
|
+
for (const name of Object.keys(schema.tables)) {
|
|
849
|
+
const cols = tableSpec(schema.tables[name]).columns;
|
|
850
|
+
out[name] = new Map(cols.map((c, i) => [c, i]));
|
|
851
|
+
}
|
|
852
|
+
return out;
|
|
853
|
+
}
|
|
854
|
+
/** Wrap the raw wasm txn as the client `MutationTx`, recording the touched tables (the client
|
|
855
|
+
* knows its own footprint — what the pending axis derives from, §7.2). The keyed methods validate
|
|
856
|
+
* column names eagerly: a typo'd table or column throws with the valid names listed, at the moment
|
|
857
|
+
* the mutator runs.
|
|
858
|
+
*
|
|
859
|
+
* With `trapReads` (the FOLDED path, §5), the PUBLIC reads `tx.get`/`tx.row` throw `FoldReadError`
|
|
860
|
+
* — a folded mutator that reads state to compute its write is non-absorbing and refused. The keyed
|
|
861
|
+
* writers (`update`/`upsert`/`delete`) still read internally to preserve unspecified columns; that
|
|
862
|
+
* is column-preservation, not value-derivation, so it stays allowed. */
|
|
863
|
+
function trackingTx(tx, touched, specs, onOp, trapReads = false) {
|
|
864
|
+
const spec = (table) => {
|
|
865
|
+
const s = specs[table];
|
|
866
|
+
if (!s)
|
|
867
|
+
throw new Error(`unknown table ${JSON.stringify(table)} — tables: ${Object.keys(specs).join(", ")}`);
|
|
868
|
+
return s;
|
|
869
|
+
};
|
|
870
|
+
/** Validate `obj`'s keys against the table's columns; require the pk columns; with
|
|
871
|
+
* `full`, require EVERY column (inserts carry whole rows — there are no defaults). */
|
|
872
|
+
const checkColumns = (table, obj, full) => {
|
|
873
|
+
const s = spec(table);
|
|
874
|
+
const unknown = Object.keys(obj).filter((k) => !s.columns.includes(k));
|
|
875
|
+
if (unknown.length) {
|
|
876
|
+
throw new Error(`unknown column${unknown.length > 1 ? "s" : ""} ${unknown.join(", ")} on ${table} — columns: ${s.columns.join(", ")}`);
|
|
877
|
+
}
|
|
878
|
+
const required = full ? s.columns : s.primaryKey.map((i) => s.columns[i]);
|
|
879
|
+
const missing = required.filter((c) => !(c in obj));
|
|
880
|
+
if (missing.length) {
|
|
881
|
+
throw new Error(`missing ${full ? "column" : "primary-key column"}${missing.length > 1 ? "s" : ""} ${missing.join(", ")} on ${table}`);
|
|
882
|
+
}
|
|
883
|
+
};
|
|
884
|
+
const pkCells = (table, obj) => spec(table).primaryKey.map((i) => obj[spec(table).columns[i]]);
|
|
885
|
+
const toCells = (table, obj) => spec(table).columns.map((c) => obj[c]);
|
|
886
|
+
const toKeyed = (table, cells) => {
|
|
887
|
+
const out = {};
|
|
888
|
+
spec(table).columns.forEach((c, i) => (out[c] = cells[i]));
|
|
889
|
+
return out;
|
|
890
|
+
};
|
|
891
|
+
// Internal read — used by the keyed writers below and the keyed `row` reader; never trapped.
|
|
892
|
+
const rawGet = (table, pk) => tx.get(table, pk);
|
|
893
|
+
const add = (table, row) => {
|
|
894
|
+
touched.add(table);
|
|
895
|
+
onOp?.({ table, kind: "add", row });
|
|
896
|
+
tx.add(table, row);
|
|
897
|
+
};
|
|
898
|
+
const remove = (table, row) => {
|
|
899
|
+
touched.add(table);
|
|
900
|
+
onOp?.({ table, kind: "remove", row });
|
|
901
|
+
tx.remove(table, row);
|
|
902
|
+
};
|
|
903
|
+
const edit = (table, oldRow, newRow) => {
|
|
904
|
+
touched.add(table);
|
|
905
|
+
onOp?.({ table, kind: "edit", row: newRow, old: oldRow });
|
|
906
|
+
tx.edit(table, oldRow, newRow);
|
|
907
|
+
};
|
|
908
|
+
// The folded read trap (§5): a mutator that reads to compute its write is refused. `() => never`
|
|
909
|
+
// is assignable to the wider read signatures (extra args ignored, `never` widens to the result).
|
|
910
|
+
const trapped = () => {
|
|
911
|
+
throw new FoldReadError();
|
|
912
|
+
};
|
|
913
|
+
return {
|
|
914
|
+
get: trapReads ? trapped : rawGet,
|
|
915
|
+
add,
|
|
916
|
+
remove,
|
|
917
|
+
edit,
|
|
918
|
+
row: trapReads
|
|
919
|
+
? trapped
|
|
920
|
+
: (table, pk) => {
|
|
921
|
+
checkColumns(table, pk, false);
|
|
922
|
+
const cells = rawGet(table, pkCells(table, pk));
|
|
923
|
+
return cells ? toKeyed(table, cells) : undefined;
|
|
924
|
+
},
|
|
925
|
+
insert: (table, row) => {
|
|
926
|
+
checkColumns(table, row, true);
|
|
927
|
+
add(table, toCells(table, row));
|
|
928
|
+
},
|
|
929
|
+
update: (table, row) => {
|
|
930
|
+
checkColumns(table, row, false);
|
|
931
|
+
const current = rawGet(table, pkCells(table, row));
|
|
932
|
+
if (!current)
|
|
933
|
+
return; // rebase-friendly: the row may have vanished upstream
|
|
934
|
+
const s = spec(table);
|
|
935
|
+
const next = s.columns.map((c, i) => (c in row ? row[c] : current[i]));
|
|
936
|
+
edit(table, current, next);
|
|
937
|
+
},
|
|
938
|
+
upsert: (table, row) => {
|
|
939
|
+
checkColumns(table, row, true);
|
|
940
|
+
const current = rawGet(table, pkCells(table, row));
|
|
941
|
+
if (current)
|
|
942
|
+
edit(table, current, toCells(table, row));
|
|
943
|
+
else
|
|
944
|
+
add(table, toCells(table, row));
|
|
945
|
+
},
|
|
946
|
+
delete: (table, pk) => {
|
|
947
|
+
checkColumns(table, pk, false);
|
|
948
|
+
const current = rawGet(table, pkCells(table, pk));
|
|
949
|
+
if (!current)
|
|
950
|
+
return; // rebase-friendly no-op
|
|
951
|
+
remove(table, current);
|
|
952
|
+
},
|
|
953
|
+
};
|
|
954
|
+
}
|
|
955
|
+
function toServerOp(m) {
|
|
956
|
+
if (m.op === "add")
|
|
957
|
+
return { table: m.table, type: "add", row: m.row };
|
|
958
|
+
if (m.op === "remove")
|
|
959
|
+
return { table: m.table, type: "remove", row: m.row };
|
|
960
|
+
return { table: m.table, type: "edit", row: m.new, old: m.old };
|
|
961
|
+
}
|
|
962
|
+
/** Every base table a query's AST can draw rows from: the root + every related
|
|
963
|
+
* subquery + EXISTS subqueries (a conservative deep scan for `table` fields). */
|
|
964
|
+
function collectTables(ast) {
|
|
965
|
+
const out = new Set();
|
|
966
|
+
const walk = (v) => {
|
|
967
|
+
if (Array.isArray(v)) {
|
|
968
|
+
for (const x of v)
|
|
969
|
+
walk(x);
|
|
970
|
+
}
|
|
971
|
+
else if (v && typeof v === "object") {
|
|
972
|
+
const o = v;
|
|
973
|
+
if (typeof o.table === "string")
|
|
974
|
+
out.add(o.table);
|
|
975
|
+
for (const k of Object.keys(o))
|
|
976
|
+
walk(o[k]);
|
|
977
|
+
}
|
|
978
|
+
};
|
|
979
|
+
walk(ast);
|
|
980
|
+
return out;
|
|
981
|
+
}
|
|
982
|
+
function intersects(a, b) {
|
|
983
|
+
for (const x of b)
|
|
984
|
+
if (a.has(x))
|
|
985
|
+
return true;
|
|
986
|
+
return false;
|
|
987
|
+
}
|
|
988
|
+
/** Elementwise equality over positional bare-cell rows (the synthetic `__agg` rows are all
|
|
989
|
+
* scalar cells — group key + count — so identity comparison suffices). */
|
|
990
|
+
function rowsEqual(a, b) {
|
|
991
|
+
if (a.length !== b.length)
|
|
992
|
+
return false;
|
|
993
|
+
for (let i = 0; i < a.length; i++)
|
|
994
|
+
if (a[i] !== b[i])
|
|
995
|
+
return false;
|
|
996
|
+
return true;
|
|
997
|
+
}
|
|
998
|
+
//# sourceMappingURL=backend.js.map
|