@rindle/normalized 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 +23 -0
- package/dist/agg-table.d.ts +19 -0
- package/dist/agg-table.d.ts.map +1 -0
- package/dist/agg-table.js +211 -0
- package/dist/agg-table.js.map +1 -0
- package/dist/backend.d.ts +53 -0
- package/dist/backend.d.ts.map +1 -0
- package/dist/backend.js +259 -0
- package/dist/backend.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/sync.d.ts +127 -0
- package/dist/sync.d.ts.map +1 -0
- package/dist/sync.js +366 -0
- package/dist/sync.js.map +1 -0
- package/package.json +41 -0
- package/src/agg-table.ts +216 -0
- package/src/backend.ts +285 -0
- package/src/index.ts +16 -0
- package/src/sync.ts +396 -0
package/src/sync.ts
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
// NormalizedSync — the client-side cross-query refcount + GC layer of the normalized
|
|
2
|
+
// local-first path (NORMALIZED-CHANGES-DESIGN.md §5), extended with the client column
|
|
3
|
+
// UNION for projection support (PROJECTION-SUPPORT-DESIGN.md §4). It sits between the
|
|
4
|
+
// per-query normalized streams (each an independent, validated `NormalizedOp` batch from
|
|
5
|
+
// the server, via `@rindle/remote`) and the client's one shared normalized store (the wasm
|
|
6
|
+
// `Db`'s base tables). The server already de-duplicates *intra-query* multi-path (§4.2);
|
|
7
|
+
// this layer does the *cross-query* refcount the server deliberately does NOT — so a base
|
|
8
|
+
// row shared by N queries lands in the local `Db` exactly once and is GC'd only when the
|
|
9
|
+
// last query stops referencing it.
|
|
10
|
+
//
|
|
11
|
+
// It is the half the design calls out as "NO CVR" ([[no-cvr-client-refcounts]]): the
|
|
12
|
+
// refcount is per-row on the *client*, keyed by `(table, pk)`, with each query's footprint
|
|
13
|
+
// tracked as a membership set. The query-id rides at the subscription envelope, never on a
|
|
14
|
+
// row (§5.1) — so this layer is fed per-query (`applyBatch(queryId, ops)`).
|
|
15
|
+
//
|
|
16
|
+
// **Projection (the client column union, PROJECTION-SUPPORT-DESIGN.md §4).** A *projected*
|
|
17
|
+
// query streams rows positional over its OWN (narrower) schema; the client scatters those
|
|
18
|
+
// cells into the shared base-table positions via the query's registered column map
|
|
19
|
+
// (`registerProjection`) and leaves the rest ABSENT (the `undefined` marshal token, §5.3).
|
|
20
|
+
// The same map also absorbs the reverse skew — a server WIDER than the client (an expanded
|
|
21
|
+
// table mid `expand-then-contract` migration): a wire column the client doesn't have maps to a
|
|
22
|
+
// DROP sentinel (`-1`) and its cell is discarded, so an old client keeps working against a
|
|
23
|
+
// newer server. (Mapping is BY NAME, so neither a narrower nor a wider server mis-aligns.)
|
|
24
|
+
// The base row a query group holds is the **union over all referencing queries'
|
|
25
|
+
// projections** (§2): widening (`add`/`edit` brings new columns) column-merges them in;
|
|
26
|
+
// narrowing (`dropQuery`, or a query's `remove` of a still-referenced row) recomputes the
|
|
27
|
+
// union from the *remaining* queries and resets dropped columns to ABSENT — the client-side
|
|
28
|
+
// equivalent of Zero's `remove-keys` (§4.2). A `'*'` query (no registered projection)
|
|
29
|
+
// contributes all columns, so any row it references is full and present everywhere: the
|
|
30
|
+
// union never introduces ABSENT and behavior is byte-identical to before projection (§7).
|
|
31
|
+
//
|
|
32
|
+
// Output: a NET `Mutation[]` to apply to the wasm `Db` in ONE `write()` transaction (the
|
|
33
|
+
// design's "stage into one txn, commit once"). This module is pure logic — it never
|
|
34
|
+
// touches the `Db`; the `NormalizedStore` (Slice 5) feeds the mutations to it.
|
|
35
|
+
|
|
36
|
+
import type { Mutation, NormalizedOp, QueryId, WireValue } from "@rindle/client";
|
|
37
|
+
|
|
38
|
+
// `NormalizedOp` (the table-tagged, path-free wire op, §3) lives in `@rindle/client` so both the
|
|
39
|
+
// protocol (`@rindle/remote`) and this sync layer share one type. Re-exported here for callers.
|
|
40
|
+
export type { NormalizedOp } from "@rindle/client";
|
|
41
|
+
|
|
42
|
+
/** Per-table primary-key column indices (into a positional row) — how `(table, pk)` is keyed. */
|
|
43
|
+
export type PkCols = Record<string, number[]>;
|
|
44
|
+
|
|
45
|
+
/** Per-table FULL column count — the width of a base/union row. Needed to scatter a projected
|
|
46
|
+
* (narrower) wire row into the shared positional layout (PROJECTION-SUPPORT-DESIGN.md §5.3). */
|
|
47
|
+
export type ColCounts = Record<string, number>;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The Absent token on the JS side (PROJECTION-SUPPORT-DESIGN.md §5.3): an absent cell in a
|
|
51
|
+
* positional UNION row. The wasm marshal maps `undefined` → `OwnedValue::Absent` (and `null`
|
|
52
|
+
* → present-and-null), so forwarding a union row with `undefined` cells is how the local
|
|
53
|
+
* engine learns a column is not present on a shared row.
|
|
54
|
+
*/
|
|
55
|
+
const ABSENT = undefined;
|
|
56
|
+
|
|
57
|
+
/** A union-row cell: a present wire value, or `ABSENT` (`undefined`) when the column is not
|
|
58
|
+
* present on the shared row. */
|
|
59
|
+
type UnionCell = WireValue | undefined;
|
|
60
|
+
|
|
61
|
+
interface BaseEntry {
|
|
62
|
+
table: string;
|
|
63
|
+
/** The shared UNION row (present cells filled; absent cells carry {@link ABSENT}). */
|
|
64
|
+
row: UnionCell[];
|
|
65
|
+
/** The queries currently referencing this row. Its size is the cross-query refcount; its
|
|
66
|
+
* members drive the per-row column union (§4.1). */
|
|
67
|
+
queries: Set<QueryId>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Elementwise equality over positional rows (json columns compare as strings; `ABSENT`
|
|
71
|
+
* compares equal only to `ABSENT`). */
|
|
72
|
+
function rowEq(a: UnionCell[], b: UnionCell[]): boolean {
|
|
73
|
+
if (a.length !== b.length) return false;
|
|
74
|
+
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export class NormalizedSync {
|
|
79
|
+
private readonly pkCols: PkCols;
|
|
80
|
+
/** Per-table full width, for scattering projected rows. Empty ⇒ projection unsupported
|
|
81
|
+
* (every query must be `'*'`), which is the pre-projection behavior. */
|
|
82
|
+
private readonly colCounts: ColCounts;
|
|
83
|
+
/** `(table, pk)` key → the one shared base entry. */
|
|
84
|
+
private readonly base = new Map<string, BaseEntry>();
|
|
85
|
+
/** queryId → the set of `(table, pk)` keys that query currently footprints. */
|
|
86
|
+
private readonly qfoot = new Map<QueryId, Set<string>>();
|
|
87
|
+
/** queryId → (table → the base ColIds it contributes for that table, in the order its wire
|
|
88
|
+
* rows are positional against — its `required_cols` for the table, §4.1). A query spans
|
|
89
|
+
* multiple base tables (root + related/EXISTS children), each projected independently; a
|
|
90
|
+
* table absent from the inner map ⇒ that query syncs it `'*'` (all columns, full width). */
|
|
91
|
+
private readonly qcols = new Map<QueryId, Map<string, number[]>>();
|
|
92
|
+
|
|
93
|
+
constructor(pkCols: PkCols, colCounts: ColCounts = {}) {
|
|
94
|
+
this.pkCols = pkCols;
|
|
95
|
+
this.colCounts = colCounts;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Register a query's projection for one base table (PROJECTION-SUPPORT-DESIGN.md §4.1): the
|
|
100
|
+
* base ColIds it contributes for `table`, in the order that table's wire rows are positional
|
|
101
|
+
* against. Omit a table (or never call) for a `'*'` table — it then contributes every column
|
|
102
|
+
* and its rows are full width. Must be called before the query's first batch.
|
|
103
|
+
*
|
|
104
|
+
* A `cols[i] < 0` entry is a DROP sentinel: wire column `i` is an EXPANDED server column the
|
|
105
|
+
* client doesn't have (the server side of an `expand-then-contract` migration), so its cell is
|
|
106
|
+
* discarded rather than scattered. The query still contributes only its real (`>= 0`) ColIds.
|
|
107
|
+
*/
|
|
108
|
+
registerProjection(queryId: QueryId, table: string, cols: number[]): void {
|
|
109
|
+
let byTable = this.qcols.get(queryId);
|
|
110
|
+
if (!byTable) {
|
|
111
|
+
byTable = new Map<string, number[]>();
|
|
112
|
+
this.qcols.set(queryId, byTable);
|
|
113
|
+
}
|
|
114
|
+
byTable.set(table, cols.slice());
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Drop a query's projection for one table, reverting it to `'*'` (full presence, rows scattered
|
|
119
|
+
* verbatim). The inverse of {@link registerProjection}; a no-op if none was registered. Needed
|
|
120
|
+
* because `qcols` persists across re-hydrate epochs: if a live subscription's hello narrows from
|
|
121
|
+
* an EXPANDED layout (a `-1`-bearing map) back to an exact full-width one, the stale map must be
|
|
122
|
+
* cleared or it would mis-scatter the now-exact rows. A no-op for an unprojected (`'*'`) table.
|
|
123
|
+
*/
|
|
124
|
+
unregisterProjection(queryId: QueryId, table: string): void {
|
|
125
|
+
this.qcols.get(queryId)?.delete(table);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Register a table's primary-key columns after construction — for a **synthetic aggregate
|
|
130
|
+
* table** (`AGGREGATE-SYNC-DESIGN.md` §3.3): a relationship `count` is synced as a
|
|
131
|
+
* server-authoritative `__agg_*` base table that is not in the client's typed schema, so
|
|
132
|
+
* the backend registers it here (and on the local engine) as queries that use it arrive.
|
|
133
|
+
* Idempotent — re-registering the same table (a second query over the same aggregate)
|
|
134
|
+
* is a no-op. Once registered, its rows refcount/GC exactly like any base table.
|
|
135
|
+
*/
|
|
136
|
+
registerTable(table: string, primaryKey: number[]): void {
|
|
137
|
+
this.pkCols[table] = primaryKey;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The inverse of {@link registerTable} for a synthetic aggregate table whose last
|
|
142
|
+
* referencing query is gone (`AGGREGATE-SYNC-DESIGN.md` §4): drop its primary-key
|
|
143
|
+
* registration so the table is unknown again. A balanced stream has already GC'd its rows
|
|
144
|
+
* at the `1→0` transition (via {@link dropQuery}); defensively this also sweeps any residual
|
|
145
|
+
* base rows + per-query footprint entries for the table, so a later re-registration of the
|
|
146
|
+
* same name starts clean. A no-op for an unregistered table.
|
|
147
|
+
*/
|
|
148
|
+
unregisterTable(table: string): void {
|
|
149
|
+
delete this.pkCols[table];
|
|
150
|
+
// A balanced stream GC's the rows at 1→0 (dropQuery); defensively sweep any residual base
|
|
151
|
+
// rows + footprint references for this table (matched on the entry's stored table —
|
|
152
|
+
// separator-agnostic) so a re-register of the same name starts clean.
|
|
153
|
+
const stale = new Set<string>();
|
|
154
|
+
for (const [k, e] of this.base) if (e.table === table) stale.add(k);
|
|
155
|
+
for (const k of stale) this.base.delete(k);
|
|
156
|
+
if (stale.size) for (const foot of this.qfoot.values()) for (const k of stale) foot.delete(k);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Apply one query's normalized batch (its hydrate snapshot or one transaction's ops) and
|
|
161
|
+
* return the NET base-table mutations to commit to the wasm `Db` in a single transaction.
|
|
162
|
+
* Cross-query refcount + per-query dedup + column union (§4, §5):
|
|
163
|
+
* - `add`: counted into this query's footprint once; on the base `0→1` transition the row
|
|
164
|
+
* enters at this query's projection; on a `1→N` transition the query's columns are merged
|
|
165
|
+
* into the shared union (widen) and an `edit` is forwarded if the union changed.
|
|
166
|
+
* - `remove`: on the base `N→0` transition the row leaves; on `N→M>0` the union is recomputed
|
|
167
|
+
* from the remaining queries and an `edit` narrows the shared row.
|
|
168
|
+
* - `edit`: column-merge this query's cells into the shared union; forward once (idempotent
|
|
169
|
+
* across queries — same source row, same values).
|
|
170
|
+
*/
|
|
171
|
+
applyBatch(queryId: QueryId, ops: NormalizedOp[]): Mutation[] {
|
|
172
|
+
const muts: Mutation[] = [];
|
|
173
|
+
const foot = this.footOf(queryId);
|
|
174
|
+
for (const op of ops) {
|
|
175
|
+
if (op.op === "add")
|
|
176
|
+
this.add(queryId, foot, op.table, this.scatter(queryId, op.table, op.row), muts);
|
|
177
|
+
else if (op.op === "remove")
|
|
178
|
+
this.remove(queryId, foot, op.table, this.scatter(queryId, op.table, op.row), muts);
|
|
179
|
+
else this.edit(op.table, this.scatter(queryId, op.table, op.new), muts);
|
|
180
|
+
}
|
|
181
|
+
return muts;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Re-hydrate one query under a new epoch (§5.3): the server re-sent `queryId`'s whole
|
|
186
|
+
* footprint as seq-0 `add`s. Diff the new footprint against the query's current one —
|
|
187
|
+
* rows only in the old set leave (refcount out, GC/narrow), rows only in the new set enter
|
|
188
|
+
* (refcount in / widen), and an intersecting row whose value changed during the gap is an
|
|
189
|
+
* edit. Only `queryId`'s references move; other queries' counts are untouched. Returns the
|
|
190
|
+
* net mutations to commit.
|
|
191
|
+
*/
|
|
192
|
+
rehydrate(queryId: QueryId, snapshot: NormalizedOp[]): Mutation[] {
|
|
193
|
+
const muts: Mutation[] = [];
|
|
194
|
+
const foot = this.footOf(queryId);
|
|
195
|
+
// The new footprint, keyed; a re-hydrate snapshot is all `add`s.
|
|
196
|
+
const next = new Map<string, { table: string; row: UnionCell[] }>();
|
|
197
|
+
for (const op of snapshot) {
|
|
198
|
+
if (op.op !== "add") continue;
|
|
199
|
+
const row = this.scatter(queryId, op.table, op.row);
|
|
200
|
+
next.set(this.key(op.table, row), { table: op.table, row });
|
|
201
|
+
}
|
|
202
|
+
// Rows that left: in the old footprint but not the new one.
|
|
203
|
+
for (const key of [...foot]) {
|
|
204
|
+
if (!next.has(key)) this.removeKey(queryId, foot, key, muts);
|
|
205
|
+
}
|
|
206
|
+
// Rows that entered or changed.
|
|
207
|
+
for (const [key, { table, row }] of next) {
|
|
208
|
+
if (foot.has(key)) {
|
|
209
|
+
// Still referenced — only forward an edit if the union changed during the gap.
|
|
210
|
+
const e = this.base.get(key);
|
|
211
|
+
if (e) this.mergeInto(e, row, table, muts);
|
|
212
|
+
} else {
|
|
213
|
+
this.add(queryId, foot, table, row, muts);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return muts;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Drop a query (§5.1): decrement its footprint's refcounts, GC each row at the last
|
|
221
|
+
* reference (or narrow the union if others remain), and forget the query. `O(footprint)`,
|
|
222
|
+
* no per-row scan. Returns the net mutations. (The caller also tells the server to
|
|
223
|
+
* deregister the stream.)
|
|
224
|
+
*/
|
|
225
|
+
dropQuery(queryId: QueryId): Mutation[] {
|
|
226
|
+
const muts: Mutation[] = [];
|
|
227
|
+
const foot = this.qfoot.get(queryId);
|
|
228
|
+
if (!foot) return muts;
|
|
229
|
+
for (const key of [...foot]) this.removeKey(queryId, foot, key, muts);
|
|
230
|
+
this.qfoot.delete(queryId);
|
|
231
|
+
this.qcols.delete(queryId);
|
|
232
|
+
return muts;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// --- introspection (for tests / the Store) ---------------------------------
|
|
236
|
+
|
|
237
|
+
/** The number of distinct base rows currently synced into the local store. */
|
|
238
|
+
baseSize(): number {
|
|
239
|
+
return this.base.size;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** How many queries currently reference `(table, row)` (0 if absent). The lookup keys by PK,
|
|
243
|
+
* so a projected `row` need only carry its PK columns at the base positions. */
|
|
244
|
+
refCount(table: string, row: UnionCell[]): number {
|
|
245
|
+
return this.base.get(this.key(table, row))?.queries.size ?? 0;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* The synced (server-authoritative) row currently held for `(table, pkCells)`, or
|
|
250
|
+
* `undefined` if no query references it. `pkCells` are the primary-key cells in
|
|
251
|
+
* `primaryKey` order (NOT a full row) — the same key the cross-query refcount uses.
|
|
252
|
+
*
|
|
253
|
+
* The optimistic aggregate overlay (`AGGREGATE-SYNC-DESIGN.md` §4) reads the server's
|
|
254
|
+
* `__agg` count cell through this so it can compute `displayed = server_base ⊕ delta`
|
|
255
|
+
* from the authoritative base rather than re-deriving it from the local engine's head
|
|
256
|
+
* (which already carries the optimistic layer — a torn read).
|
|
257
|
+
*/
|
|
258
|
+
baseRow(table: string, pkCells: WireValue[]): (WireValue | undefined)[] | undefined {
|
|
259
|
+
const cols = this.pkCols[table];
|
|
260
|
+
if (!cols) throw new Error(`NormalizedSync: no primary key registered for table ${table}`);
|
|
261
|
+
// Place the pk cells at their schema positions so `key()` reduces this probe to the
|
|
262
|
+
// exact same string it builds for any full row with these key cells.
|
|
263
|
+
const probe: WireValue[] = [];
|
|
264
|
+
cols.forEach((c, j) => (probe[c] = pkCells[j]));
|
|
265
|
+
return this.base.get(this.key(table, probe))?.row;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// --- internals -------------------------------------------------------------
|
|
269
|
+
|
|
270
|
+
private footOf(queryId: QueryId): Set<string> {
|
|
271
|
+
let foot = this.qfoot.get(queryId);
|
|
272
|
+
if (!foot) {
|
|
273
|
+
foot = new Set<string>();
|
|
274
|
+
this.qfoot.set(queryId, foot);
|
|
275
|
+
}
|
|
276
|
+
return foot;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Scatter a query's (possibly narrower) wire row for `table` into the shared full-width
|
|
280
|
+
* positional layout. No registered projection for `(queryId, table)` ⇒ a `'*'` table whose
|
|
281
|
+
* row is already full width (returned as-is). Otherwise allocate a full-width row of
|
|
282
|
+
* `ABSENT` and place `row[i]` at `cols[i]`. */
|
|
283
|
+
private scatter(queryId: QueryId, table: string, row: WireValue[]): UnionCell[] {
|
|
284
|
+
const cols = this.qcols.get(queryId)?.get(table);
|
|
285
|
+
if (!cols) return row; // '*' — already full width, present everywhere
|
|
286
|
+
const width = this.colCounts[table];
|
|
287
|
+
if (width === undefined) {
|
|
288
|
+
throw new Error(`NormalizedSync: no column count registered for projected table ${table}`);
|
|
289
|
+
}
|
|
290
|
+
const out: UnionCell[] = new Array<UnionCell>(width).fill(ABSENT);
|
|
291
|
+
// `cols[i] < 0` ⇒ wire column `i` is an expanded server column the client lacks: drop its
|
|
292
|
+
// cell, leaving the client's narrower row intact. Known columns map by name to their base
|
|
293
|
+
// position, so a server wider than (or reordered vs.) the client still scatters correctly.
|
|
294
|
+
for (let i = 0; i < cols.length; i++) if (cols[i] >= 0) out[cols[i]] = row[i];
|
|
295
|
+
return out;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Column-merge `incoming`'s present cells over `e.row` (a widen / pure-value change) and
|
|
299
|
+
* forward a single `edit` if the union changed. Both rows are full width. */
|
|
300
|
+
private mergeInto(e: BaseEntry, incoming: UnionCell[], table: string, muts: Mutation[]): void {
|
|
301
|
+
const merged = e.row.map((cur, i) => (incoming[i] !== ABSENT ? incoming[i] : cur));
|
|
302
|
+
if (!rowEq(merged, e.row)) {
|
|
303
|
+
muts.push(mut("edit", table, e.row, merged));
|
|
304
|
+
e.row = merged;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
private add(queryId: QueryId, foot: Set<string>, table: string, row: UnionCell[], muts: Mutation[]): void {
|
|
309
|
+
const key = this.key(table, row);
|
|
310
|
+
if (foot.has(key)) return; // this query already references it
|
|
311
|
+
foot.add(key);
|
|
312
|
+
const e = this.base.get(key);
|
|
313
|
+
if (e) {
|
|
314
|
+
e.queries.add(queryId); // another query already synced the row → no Db add (would dup-key)
|
|
315
|
+
// …but this query may carry NEW columns (widen) or a NEWER value for a shared column
|
|
316
|
+
// (the gainer half of a server commit that edits the same row on a holder query's
|
|
317
|
+
// stream, CRIT#3). Column-merge and forward an edit at the new union if it changed.
|
|
318
|
+
this.mergeInto(e, row, table, muts);
|
|
319
|
+
} else {
|
|
320
|
+
this.base.set(key, { table, row, queries: new Set([queryId]) });
|
|
321
|
+
muts.push(mut("add", table, undefined, row)); // 0→1
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
private remove(
|
|
326
|
+
queryId: QueryId,
|
|
327
|
+
foot: Set<string>,
|
|
328
|
+
table: string,
|
|
329
|
+
row: UnionCell[],
|
|
330
|
+
muts: Mutation[],
|
|
331
|
+
): void {
|
|
332
|
+
this.removeKey(queryId, foot, this.key(table, row), muts);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
private removeKey(queryId: QueryId, foot: Set<string>, key: string, muts: Mutation[]): void {
|
|
336
|
+
if (!foot.has(key)) return; // not referenced by this query
|
|
337
|
+
foot.delete(key);
|
|
338
|
+
const e = this.base.get(key);
|
|
339
|
+
if (!e) return; // defensive: inconsistent stream
|
|
340
|
+
e.queries.delete(queryId);
|
|
341
|
+
if (e.queries.size === 0) {
|
|
342
|
+
this.base.delete(key);
|
|
343
|
+
muts.push(mut("remove", e.table, undefined, e.row)); // N→0
|
|
344
|
+
} else {
|
|
345
|
+
// Others still reference it → recompute the union from the remaining queries and narrow
|
|
346
|
+
// the shared row (reset any column no longer contributed to ABSENT). §4.2.
|
|
347
|
+
const present = this.presentCols(e.queries, e.table);
|
|
348
|
+
if (present === null) return; // a '*' query still holds it → full presence, no narrowing
|
|
349
|
+
const narrowed = e.row.map((cell, i) => (present.has(i) ? cell : ABSENT));
|
|
350
|
+
if (!rowEq(narrowed, e.row)) {
|
|
351
|
+
muts.push(mut("edit", e.table, e.row, narrowed));
|
|
352
|
+
e.row = narrowed;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
private edit(table: string, next: UnionCell[], muts: Mutation[]): void {
|
|
358
|
+
// PK is stable across an edit, so `next` keys the existing entry.
|
|
359
|
+
const key = this.key(table, next);
|
|
360
|
+
const e = this.base.get(key);
|
|
361
|
+
if (!e) return; // defensive: edit of an untracked row
|
|
362
|
+
this.mergeInto(e, next, table, muts); // column-merge; dedups (no-op) if already applied
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** The columns present on a `table` row footprinted by `queries`: the union of their
|
|
366
|
+
* projections for that table. `null` ⇒ all columns present (some referencing query syncs
|
|
367
|
+
* the table `'*'`), so the row is full. */
|
|
368
|
+
private presentCols(queries: Set<QueryId>, table: string): Set<number> | null {
|
|
369
|
+
const out = new Set<number>();
|
|
370
|
+
for (const q of queries) {
|
|
371
|
+
const cols = this.qcols.get(q)?.get(table);
|
|
372
|
+
if (!cols) return null; // a '*' query contributes every column for this table
|
|
373
|
+
for (const c of cols) if (c >= 0) out.add(c); // skip DROP sentinels (expanded server cols)
|
|
374
|
+
}
|
|
375
|
+
return out;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
private key(table: string, row: UnionCell[]): string {
|
|
379
|
+
const cols = this.pkCols[table];
|
|
380
|
+
if (!cols) throw new Error(`NormalizedSync: no primary key registered for table ${table}`);
|
|
381
|
+
return `${table} ${JSON.stringify(cols.map((i) => row[i]))}`;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** Build a `Mutation`. A union row may carry `ABSENT` (`undefined`) cells — the wasm `mutate`
|
|
386
|
+
* ABI accepts them as the Absent token (§5.3) — so the row crosses as `WireValue[]` with that
|
|
387
|
+
* understood sentinel. */
|
|
388
|
+
function mut(
|
|
389
|
+
op: "add" | "remove" | "edit",
|
|
390
|
+
table: string,
|
|
391
|
+
old: UnionCell[] | undefined,
|
|
392
|
+
row: UnionCell[],
|
|
393
|
+
): Mutation {
|
|
394
|
+
if (op === "edit") return { op, table, old: (old as WireValue[]) ?? [], new: row as WireValue[] };
|
|
395
|
+
return { op, table, row: row as WireValue[] } as Mutation;
|
|
396
|
+
}
|