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