@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/dist/sync.js ADDED
@@ -0,0 +1,366 @@
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
+ * The Absent token on the JS side (PROJECTION-SUPPORT-DESIGN.md §5.3): an absent cell in a
37
+ * positional UNION row. The wasm marshal maps `undefined` → `OwnedValue::Absent` (and `null`
38
+ * → present-and-null), so forwarding a union row with `undefined` cells is how the local
39
+ * engine learns a column is not present on a shared row.
40
+ */
41
+ const ABSENT = undefined;
42
+ /** Elementwise equality over positional rows (json columns compare as strings; `ABSENT`
43
+ * compares equal only to `ABSENT`). */
44
+ function rowEq(a, b) {
45
+ if (a.length !== b.length)
46
+ return false;
47
+ for (let i = 0; i < a.length; i++)
48
+ if (a[i] !== b[i])
49
+ return false;
50
+ return true;
51
+ }
52
+ export class NormalizedSync {
53
+ pkCols;
54
+ /** Per-table full width, for scattering projected rows. Empty ⇒ projection unsupported
55
+ * (every query must be `'*'`), which is the pre-projection behavior. */
56
+ colCounts;
57
+ /** `(table, pk)` key → the one shared base entry. */
58
+ base = new Map();
59
+ /** queryId → the set of `(table, pk)` keys that query currently footprints. */
60
+ qfoot = new Map();
61
+ /** queryId → (table → the base ColIds it contributes for that table, in the order its wire
62
+ * rows are positional against — its `required_cols` for the table, §4.1). A query spans
63
+ * multiple base tables (root + related/EXISTS children), each projected independently; a
64
+ * table absent from the inner map ⇒ that query syncs it `'*'` (all columns, full width). */
65
+ qcols = new Map();
66
+ constructor(pkCols, colCounts = {}) {
67
+ this.pkCols = pkCols;
68
+ this.colCounts = colCounts;
69
+ }
70
+ /**
71
+ * Register a query's projection for one base table (PROJECTION-SUPPORT-DESIGN.md §4.1): the
72
+ * base ColIds it contributes for `table`, in the order that table's wire rows are positional
73
+ * against. Omit a table (or never call) for a `'*'` table — it then contributes every column
74
+ * and its rows are full width. Must be called before the query's first batch.
75
+ *
76
+ * A `cols[i] < 0` entry is a DROP sentinel: wire column `i` is an EXPANDED server column the
77
+ * client doesn't have (the server side of an `expand-then-contract` migration), so its cell is
78
+ * discarded rather than scattered. The query still contributes only its real (`>= 0`) ColIds.
79
+ */
80
+ registerProjection(queryId, table, cols) {
81
+ let byTable = this.qcols.get(queryId);
82
+ if (!byTable) {
83
+ byTable = new Map();
84
+ this.qcols.set(queryId, byTable);
85
+ }
86
+ byTable.set(table, cols.slice());
87
+ }
88
+ /**
89
+ * Drop a query's projection for one table, reverting it to `'*'` (full presence, rows scattered
90
+ * verbatim). The inverse of {@link registerProjection}; a no-op if none was registered. Needed
91
+ * because `qcols` persists across re-hydrate epochs: if a live subscription's hello narrows from
92
+ * an EXPANDED layout (a `-1`-bearing map) back to an exact full-width one, the stale map must be
93
+ * cleared or it would mis-scatter the now-exact rows. A no-op for an unprojected (`'*'`) table.
94
+ */
95
+ unregisterProjection(queryId, table) {
96
+ this.qcols.get(queryId)?.delete(table);
97
+ }
98
+ /**
99
+ * Register a table's primary-key columns after construction — for a **synthetic aggregate
100
+ * table** (`AGGREGATE-SYNC-DESIGN.md` §3.3): a relationship `count` is synced as a
101
+ * server-authoritative `__agg_*` base table that is not in the client's typed schema, so
102
+ * the backend registers it here (and on the local engine) as queries that use it arrive.
103
+ * Idempotent — re-registering the same table (a second query over the same aggregate)
104
+ * is a no-op. Once registered, its rows refcount/GC exactly like any base table.
105
+ */
106
+ registerTable(table, primaryKey) {
107
+ this.pkCols[table] = primaryKey;
108
+ }
109
+ /**
110
+ * The inverse of {@link registerTable} for a synthetic aggregate table whose last
111
+ * referencing query is gone (`AGGREGATE-SYNC-DESIGN.md` §4): drop its primary-key
112
+ * registration so the table is unknown again. A balanced stream has already GC'd its rows
113
+ * at the `1→0` transition (via {@link dropQuery}); defensively this also sweeps any residual
114
+ * base rows + per-query footprint entries for the table, so a later re-registration of the
115
+ * same name starts clean. A no-op for an unregistered table.
116
+ */
117
+ unregisterTable(table) {
118
+ delete this.pkCols[table];
119
+ // A balanced stream GC's the rows at 1→0 (dropQuery); defensively sweep any residual base
120
+ // rows + footprint references for this table (matched on the entry's stored table —
121
+ // separator-agnostic) so a re-register of the same name starts clean.
122
+ const stale = new Set();
123
+ for (const [k, e] of this.base)
124
+ if (e.table === table)
125
+ stale.add(k);
126
+ for (const k of stale)
127
+ this.base.delete(k);
128
+ if (stale.size)
129
+ for (const foot of this.qfoot.values())
130
+ for (const k of stale)
131
+ foot.delete(k);
132
+ }
133
+ /**
134
+ * Apply one query's normalized batch (its hydrate snapshot or one transaction's ops) and
135
+ * return the NET base-table mutations to commit to the wasm `Db` in a single transaction.
136
+ * Cross-query refcount + per-query dedup + column union (§4, §5):
137
+ * - `add`: counted into this query's footprint once; on the base `0→1` transition the row
138
+ * enters at this query's projection; on a `1→N` transition the query's columns are merged
139
+ * into the shared union (widen) and an `edit` is forwarded if the union changed.
140
+ * - `remove`: on the base `N→0` transition the row leaves; on `N→M>0` the union is recomputed
141
+ * from the remaining queries and an `edit` narrows the shared row.
142
+ * - `edit`: column-merge this query's cells into the shared union; forward once (idempotent
143
+ * across queries — same source row, same values).
144
+ */
145
+ applyBatch(queryId, ops) {
146
+ const muts = [];
147
+ const foot = this.footOf(queryId);
148
+ for (const op of ops) {
149
+ if (op.op === "add")
150
+ this.add(queryId, foot, op.table, this.scatter(queryId, op.table, op.row), muts);
151
+ else if (op.op === "remove")
152
+ this.remove(queryId, foot, op.table, this.scatter(queryId, op.table, op.row), muts);
153
+ else
154
+ this.edit(op.table, this.scatter(queryId, op.table, op.new), muts);
155
+ }
156
+ return muts;
157
+ }
158
+ /**
159
+ * Re-hydrate one query under a new epoch (§5.3): the server re-sent `queryId`'s whole
160
+ * footprint as seq-0 `add`s. Diff the new footprint against the query's current one —
161
+ * rows only in the old set leave (refcount out, GC/narrow), rows only in the new set enter
162
+ * (refcount in / widen), and an intersecting row whose value changed during the gap is an
163
+ * edit. Only `queryId`'s references move; other queries' counts are untouched. Returns the
164
+ * net mutations to commit.
165
+ */
166
+ rehydrate(queryId, snapshot) {
167
+ const muts = [];
168
+ const foot = this.footOf(queryId);
169
+ // The new footprint, keyed; a re-hydrate snapshot is all `add`s.
170
+ const next = new Map();
171
+ for (const op of snapshot) {
172
+ if (op.op !== "add")
173
+ continue;
174
+ const row = this.scatter(queryId, op.table, op.row);
175
+ next.set(this.key(op.table, row), { table: op.table, row });
176
+ }
177
+ // Rows that left: in the old footprint but not the new one.
178
+ for (const key of [...foot]) {
179
+ if (!next.has(key))
180
+ this.removeKey(queryId, foot, key, muts);
181
+ }
182
+ // Rows that entered or changed.
183
+ for (const [key, { table, row }] of next) {
184
+ if (foot.has(key)) {
185
+ // Still referenced — only forward an edit if the union changed during the gap.
186
+ const e = this.base.get(key);
187
+ if (e)
188
+ this.mergeInto(e, row, table, muts);
189
+ }
190
+ else {
191
+ this.add(queryId, foot, table, row, muts);
192
+ }
193
+ }
194
+ return muts;
195
+ }
196
+ /**
197
+ * Drop a query (§5.1): decrement its footprint's refcounts, GC each row at the last
198
+ * reference (or narrow the union if others remain), and forget the query. `O(footprint)`,
199
+ * no per-row scan. Returns the net mutations. (The caller also tells the server to
200
+ * deregister the stream.)
201
+ */
202
+ dropQuery(queryId) {
203
+ const muts = [];
204
+ const foot = this.qfoot.get(queryId);
205
+ if (!foot)
206
+ return muts;
207
+ for (const key of [...foot])
208
+ this.removeKey(queryId, foot, key, muts);
209
+ this.qfoot.delete(queryId);
210
+ this.qcols.delete(queryId);
211
+ return muts;
212
+ }
213
+ // --- introspection (for tests / the Store) ---------------------------------
214
+ /** The number of distinct base rows currently synced into the local store. */
215
+ baseSize() {
216
+ return this.base.size;
217
+ }
218
+ /** How many queries currently reference `(table, row)` (0 if absent). The lookup keys by PK,
219
+ * so a projected `row` need only carry its PK columns at the base positions. */
220
+ refCount(table, row) {
221
+ return this.base.get(this.key(table, row))?.queries.size ?? 0;
222
+ }
223
+ /**
224
+ * The synced (server-authoritative) row currently held for `(table, pkCells)`, or
225
+ * `undefined` if no query references it. `pkCells` are the primary-key cells in
226
+ * `primaryKey` order (NOT a full row) — the same key the cross-query refcount uses.
227
+ *
228
+ * The optimistic aggregate overlay (`AGGREGATE-SYNC-DESIGN.md` §4) reads the server's
229
+ * `__agg` count cell through this so it can compute `displayed = server_base ⊕ delta`
230
+ * from the authoritative base rather than re-deriving it from the local engine's head
231
+ * (which already carries the optimistic layer — a torn read).
232
+ */
233
+ baseRow(table, pkCells) {
234
+ const cols = this.pkCols[table];
235
+ if (!cols)
236
+ throw new Error(`NormalizedSync: no primary key registered for table ${table}`);
237
+ // Place the pk cells at their schema positions so `key()` reduces this probe to the
238
+ // exact same string it builds for any full row with these key cells.
239
+ const probe = [];
240
+ cols.forEach((c, j) => (probe[c] = pkCells[j]));
241
+ return this.base.get(this.key(table, probe))?.row;
242
+ }
243
+ // --- internals -------------------------------------------------------------
244
+ footOf(queryId) {
245
+ let foot = this.qfoot.get(queryId);
246
+ if (!foot) {
247
+ foot = new Set();
248
+ this.qfoot.set(queryId, foot);
249
+ }
250
+ return foot;
251
+ }
252
+ /** Scatter a query's (possibly narrower) wire row for `table` into the shared full-width
253
+ * positional layout. No registered projection for `(queryId, table)` ⇒ a `'*'` table whose
254
+ * row is already full width (returned as-is). Otherwise allocate a full-width row of
255
+ * `ABSENT` and place `row[i]` at `cols[i]`. */
256
+ scatter(queryId, table, row) {
257
+ const cols = this.qcols.get(queryId)?.get(table);
258
+ if (!cols)
259
+ return row; // '*' — already full width, present everywhere
260
+ const width = this.colCounts[table];
261
+ if (width === undefined) {
262
+ throw new Error(`NormalizedSync: no column count registered for projected table ${table}`);
263
+ }
264
+ const out = new Array(width).fill(ABSENT);
265
+ // `cols[i] < 0` ⇒ wire column `i` is an expanded server column the client lacks: drop its
266
+ // cell, leaving the client's narrower row intact. Known columns map by name to their base
267
+ // position, so a server wider than (or reordered vs.) the client still scatters correctly.
268
+ for (let i = 0; i < cols.length; i++)
269
+ if (cols[i] >= 0)
270
+ out[cols[i]] = row[i];
271
+ return out;
272
+ }
273
+ /** Column-merge `incoming`'s present cells over `e.row` (a widen / pure-value change) and
274
+ * forward a single `edit` if the union changed. Both rows are full width. */
275
+ mergeInto(e, incoming, table, muts) {
276
+ const merged = e.row.map((cur, i) => (incoming[i] !== ABSENT ? incoming[i] : cur));
277
+ if (!rowEq(merged, e.row)) {
278
+ muts.push(mut("edit", table, e.row, merged));
279
+ e.row = merged;
280
+ }
281
+ }
282
+ add(queryId, foot, table, row, muts) {
283
+ const key = this.key(table, row);
284
+ if (foot.has(key))
285
+ return; // this query already references it
286
+ foot.add(key);
287
+ const e = this.base.get(key);
288
+ if (e) {
289
+ e.queries.add(queryId); // another query already synced the row → no Db add (would dup-key)
290
+ // …but this query may carry NEW columns (widen) or a NEWER value for a shared column
291
+ // (the gainer half of a server commit that edits the same row on a holder query's
292
+ // stream, CRIT#3). Column-merge and forward an edit at the new union if it changed.
293
+ this.mergeInto(e, row, table, muts);
294
+ }
295
+ else {
296
+ this.base.set(key, { table, row, queries: new Set([queryId]) });
297
+ muts.push(mut("add", table, undefined, row)); // 0→1
298
+ }
299
+ }
300
+ remove(queryId, foot, table, row, muts) {
301
+ this.removeKey(queryId, foot, this.key(table, row), muts);
302
+ }
303
+ removeKey(queryId, foot, key, muts) {
304
+ if (!foot.has(key))
305
+ return; // not referenced by this query
306
+ foot.delete(key);
307
+ const e = this.base.get(key);
308
+ if (!e)
309
+ return; // defensive: inconsistent stream
310
+ e.queries.delete(queryId);
311
+ if (e.queries.size === 0) {
312
+ this.base.delete(key);
313
+ muts.push(mut("remove", e.table, undefined, e.row)); // N→0
314
+ }
315
+ else {
316
+ // Others still reference it → recompute the union from the remaining queries and narrow
317
+ // the shared row (reset any column no longer contributed to ABSENT). §4.2.
318
+ const present = this.presentCols(e.queries, e.table);
319
+ if (present === null)
320
+ return; // a '*' query still holds it → full presence, no narrowing
321
+ const narrowed = e.row.map((cell, i) => (present.has(i) ? cell : ABSENT));
322
+ if (!rowEq(narrowed, e.row)) {
323
+ muts.push(mut("edit", e.table, e.row, narrowed));
324
+ e.row = narrowed;
325
+ }
326
+ }
327
+ }
328
+ edit(table, next, muts) {
329
+ // PK is stable across an edit, so `next` keys the existing entry.
330
+ const key = this.key(table, next);
331
+ const e = this.base.get(key);
332
+ if (!e)
333
+ return; // defensive: edit of an untracked row
334
+ this.mergeInto(e, next, table, muts); // column-merge; dedups (no-op) if already applied
335
+ }
336
+ /** The columns present on a `table` row footprinted by `queries`: the union of their
337
+ * projections for that table. `null` ⇒ all columns present (some referencing query syncs
338
+ * the table `'*'`), so the row is full. */
339
+ presentCols(queries, table) {
340
+ const out = new Set();
341
+ for (const q of queries) {
342
+ const cols = this.qcols.get(q)?.get(table);
343
+ if (!cols)
344
+ return null; // a '*' query contributes every column for this table
345
+ for (const c of cols)
346
+ if (c >= 0)
347
+ out.add(c); // skip DROP sentinels (expanded server cols)
348
+ }
349
+ return out;
350
+ }
351
+ key(table, row) {
352
+ const cols = this.pkCols[table];
353
+ if (!cols)
354
+ throw new Error(`NormalizedSync: no primary key registered for table ${table}`);
355
+ return `${table} ${JSON.stringify(cols.map((i) => row[i]))}`;
356
+ }
357
+ }
358
+ /** Build a `Mutation`. A union row may carry `ABSENT` (`undefined`) cells — the wasm `mutate`
359
+ * ABI accepts them as the Absent token (§5.3) — so the row crosses as `WireValue[]` with that
360
+ * understood sentinel. */
361
+ function mut(op, table, old, row) {
362
+ if (op === "edit")
363
+ return { op, table, old: old ?? [], new: row };
364
+ return { op, table, row: row };
365
+ }
366
+ //# sourceMappingURL=sync.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sync.js","sourceRoot":"","sources":["../src/sync.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@rindle/normalized",
3
+ "version": "0.1.0-rc.5",
4
+ "license": "Apache-2.0",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/rindle-sh/rindle.git",
8
+ "directory": "packages/normalized"
9
+ },
10
+ "type": "module",
11
+ "description": "Normalized local-first client glue: NormalizedSync (cross-query refcount + GC + footprint diffing) driving the wasm Db's base tables (NORMALIZED-CHANGES-DESIGN.md §5).",
12
+ "main": "./dist/index.js",
13
+ "types": "./dist/index.d.ts",
14
+ "sideEffects": false,
15
+ "exports": {
16
+ ".": {
17
+ "@rindle/source": "./src/index.ts",
18
+ "types": "./dist/index.d.ts",
19
+ "default": "./dist/index.js"
20
+ }
21
+ },
22
+ "files": [
23
+ "dist",
24
+ "src",
25
+ "README.md"
26
+ ],
27
+ "dependencies": {
28
+ "@rindle/client": "0.1.0-rc.5",
29
+ "@rindle/wasm": "0.1.0-rc.5"
30
+ },
31
+ "devDependencies": {
32
+ "@types/node": "^22.10.0",
33
+ "typescript": "^5.7.0",
34
+ "@rindle/replica": "0.0.0"
35
+ },
36
+ "scripts": {
37
+ "build": "tsc -p tsconfig.build.json",
38
+ "typecheck": "tsc --noEmit",
39
+ "test": "tsc --noEmit && node --conditions=@rindle/source --test test/*.test.ts && node --conditions=@rindle/source test/e2e.mjs && node --conditions=@rindle/source test/projection.e2e.mjs"
40
+ }
41
+ }
@@ -0,0 +1,216 @@
1
+ // Synthetic aggregate tables (AGGREGATE-SYNC-DESIGN.md §3.1/§3.3) — the client twin of
2
+ // `rindle-replica/src/normalize.rs`. A relationship `count` is never recomputed locally
3
+ // (the client lacks the child rows); the server ships the reduce's `(group_key, count)`
4
+ // output as a SYNTHETIC base table, and the client:
5
+ // 1. registers that table on its local engine + the `NormalizedSync` refcount layer,
6
+ // 2. rewrites the local AST so the relationship reads the table with a plain singular
7
+ // join + the same scalar projection (`aggregatePrecomputed`) instead of a reduce.
8
+ //
9
+ // The table NAME is a content hash of the aggregate's definition, so client and server
10
+ // agree without coordinating. `aggTableName` is the **byte-exact** twin of Rust
11
+ // `agg_table_name` — the FNV-1a-64 byte protocol documented there; cross-language vectors
12
+ // pin it (Rust `agg_table_name_cross_language_vector` ↔ this module's test).
13
+
14
+ import type {
15
+ Aggregate,
16
+ Ast,
17
+ Condition,
18
+ CorrelatedSubquery,
19
+ Correlation,
20
+ LitValue,
21
+ NormalizedTableSchema,
22
+ ValuePosition,
23
+ } from "@rindle/client";
24
+
25
+ // FNV-1a-64, the same constants as `protocol.ts`'s `Fnv` (a local copy so `@rindle/normalized`
26
+ // keeps no dependency on `@rindle/remote`). The byte methods mirror Rust `normalize::Fnv`.
27
+ const FNV_OFFSET = 0xcbf29ce484222325n;
28
+ const FNV_PRIME = 0x00000100000001b3n;
29
+ const MASK = 0xffffffffffffffffn;
30
+ const enc = new TextEncoder();
31
+
32
+ class Fnv {
33
+ h = FNV_OFFSET;
34
+ byte(b: number): void {
35
+ this.h = ((this.h ^ BigInt(b & 0xff)) * FNV_PRIME) & MASK;
36
+ }
37
+ /** A `u32` little-endian (Rust `v.to_le_bytes()`). */
38
+ u32(v: number): void {
39
+ this.byte(v & 0xff);
40
+ this.byte((v >>> 8) & 0xff);
41
+ this.byte((v >>> 16) & 0xff);
42
+ this.byte((v >>> 24) & 0xff);
43
+ }
44
+ /** A length-prefixed string: `u32(byteLen)` then UTF-8 bytes. */
45
+ s(str: string): void {
46
+ const bytes = enc.encode(str);
47
+ this.u32(bytes.length);
48
+ for (const b of bytes) this.byte(b);
49
+ }
50
+ /** An `f64` little-endian (Rust `n.to_le_bytes()` — IEEE-754 binary64). */
51
+ f64(v: number): void {
52
+ const buf = new ArrayBuffer(8);
53
+ new DataView(buf).setFloat64(0, v, true);
54
+ for (const b of new Uint8Array(buf)) this.byte(b);
55
+ }
56
+ hex(): string {
57
+ return this.h.toString(16).padStart(16, "0");
58
+ }
59
+ }
60
+
61
+ /** The synthetic base-table name for a relationship `count` aggregate — the byte-exact twin
62
+ * of Rust `agg_table_name` (`normalize.rs` §3.1). A content hash of the aggregate definition
63
+ * (child table, kind, group key = correlation **child** fields, child `where`); the parent
64
+ * correlation field is excluded. Same definition ⇒ same name (cross-query sharing); a
65
+ * different filter ⇒ a different name (no `(table, pk)` collision). */
66
+ export function aggTableName(csq: CorrelatedSubquery): string {
67
+ const f = new Fnv();
68
+ f.u32(csq.correlation.childField.length);
69
+ for (const cf of csq.correlation.childField) f.s(cf);
70
+ hashAggSubquery(f, csq.subquery);
71
+ return `__agg_${f.hex()}`;
72
+ }
73
+
74
+ function hashAggSubquery(f: Fnv, ast: Ast): void {
75
+ f.s(ast.table);
76
+ f.byte(ast.aggregate === "count" ? 1 : 0); // Some(Count)=1, None=0
77
+ if (ast.where === undefined) f.byte(0);
78
+ else {
79
+ f.byte(1);
80
+ hashCondition(f, ast.where);
81
+ }
82
+ const related = ast.related ?? [];
83
+ f.u32(related.length);
84
+ for (const r of related) {
85
+ hashCorrelation(f, r.correlation);
86
+ hashAggSubquery(f, r.subquery);
87
+ }
88
+ }
89
+
90
+ function hashCorrelation(f: Fnv, c: Correlation): void {
91
+ f.u32(c.parentField.length);
92
+ for (const p of c.parentField) f.s(p);
93
+ f.u32(c.childField.length);
94
+ for (const x of c.childField) f.s(x);
95
+ }
96
+
97
+ function hashCondition(f: Fnv, cond: Condition): void {
98
+ switch (cond.type) {
99
+ case "simple":
100
+ f.byte(0);
101
+ f.s(cond.op); // SimpleOp is the wire string ("=", "!=", …) === Rust `op_str`
102
+ hashValuePosition(f, cond.left);
103
+ hashValuePosition(f, cond.right);
104
+ break;
105
+ case "and":
106
+ f.byte(1);
107
+ f.u32(cond.conditions.length);
108
+ for (const c of cond.conditions) hashCondition(f, c);
109
+ break;
110
+ case "or":
111
+ f.byte(2);
112
+ f.u32(cond.conditions.length);
113
+ for (const c of cond.conditions) hashCondition(f, c);
114
+ break;
115
+ case "correlatedSubquery":
116
+ f.byte(3);
117
+ f.byte(cond.op === "EXISTS" ? 1 : 0); // Exists=1, NotExists=0
118
+ f.byte(systemByte(cond.related.system));
119
+ hashCorrelation(f, cond.related.correlation);
120
+ hashAggSubquery(f, cond.related.subquery);
121
+ break;
122
+ }
123
+ }
124
+
125
+ function systemByte(sys: CorrelatedSubquery["system"]): number {
126
+ switch (sys) {
127
+ case "permissions":
128
+ return 1; // System::Permissions
129
+ case "client":
130
+ return 2; // System::Client
131
+ case "test":
132
+ return 3; // System::Test
133
+ default:
134
+ return 0; // None
135
+ }
136
+ }
137
+
138
+ function hashValuePosition(f: Fnv, vp: ValuePosition): void {
139
+ if (vp.type === "column") {
140
+ f.byte(0);
141
+ f.s(vp.name);
142
+ } else {
143
+ f.byte(1);
144
+ hashLit(f, vp.value);
145
+ }
146
+ }
147
+
148
+ function hashLit(f: Fnv, lit: LitValue): void {
149
+ if (lit === null) f.byte(0);
150
+ else if (typeof lit === "boolean") {
151
+ f.byte(1);
152
+ f.byte(lit ? 1 : 0);
153
+ } else if (typeof lit === "number") {
154
+ f.byte(2);
155
+ f.f64(lit);
156
+ } else if (typeof lit === "string") {
157
+ f.byte(3);
158
+ f.s(lit);
159
+ } else {
160
+ f.byte(4);
161
+ f.u32(lit.length);
162
+ for (const x of lit) hashLit(f, x);
163
+ }
164
+ }
165
+
166
+ /** Every synthetic aggregate table `ast` surfaces (recursively, a nested aggregate under a
167
+ * materialized relationship included), as flat table schemas — the twin of Rust
168
+ * `agg_table_schemas`. Columns `[childField…, "count"]`; PK = the leading group columns. */
169
+ export function aggTableSchemas(ast: Ast): NormalizedTableSchema[] {
170
+ const out: NormalizedTableSchema[] = [];
171
+ collectAggTables(ast, out);
172
+ return out;
173
+ }
174
+
175
+ function collectAggTables(ast: Ast, out: NormalizedTableSchema[]): void {
176
+ for (const r of ast.related ?? []) {
177
+ if (r.subquery.aggregate) {
178
+ const k = r.correlation.childField.length;
179
+ out.push({
180
+ name: aggTableName(r),
181
+ columns: [...r.correlation.childField, "count"],
182
+ primaryKey: Array.from({ length: k }, (_, i) => i),
183
+ });
184
+ } else {
185
+ collectAggTables(r.subquery, out);
186
+ }
187
+ }
188
+ }
189
+
190
+ /** Rewrite an AST for the LOCAL engine: each relationship `count` becomes a precomputed,
191
+ * source-backed singular relationship over its synthetic table — read the server's count
192
+ * with a plain join + the same scalar projection (`aggregatePrecomputed`), never a `reduce`
193
+ * (which would recount the already-aggregated rows). Non-aggregate relationships recurse
194
+ * (a nested aggregate is rewritten too); the parent's frame is otherwise untouched, so the
195
+ * view-schema slot order is preserved. Returns a new AST; the input is not mutated. */
196
+ export function rewriteAggregates(ast: Ast): Ast {
197
+ if (!ast.related?.length) return ast;
198
+ return { ...ast, related: ast.related.map(rewriteRelationship) };
199
+ }
200
+
201
+ function rewriteRelationship(csq: CorrelatedSubquery): CorrelatedSubquery {
202
+ if (csq.subquery.aggregate) {
203
+ const subquery: Ast = {
204
+ table: aggTableName(csq),
205
+ aggregate: csq.subquery.aggregate as Aggregate,
206
+ aggregatePrecomputed: true,
207
+ };
208
+ // The correlation is unchanged: the synthetic table's group columns are named after the
209
+ // child correlation fields, so `childField` still resolves against `[childField…, count]`.
210
+ if (csq.subquery.alias !== undefined) subquery.alias = csq.subquery.alias;
211
+ const out: CorrelatedSubquery = { correlation: csq.correlation, subquery };
212
+ if (csq.system !== undefined) out.system = csq.system;
213
+ return out;
214
+ }
215
+ return { ...csq, subquery: rewriteAggregates(csq.subquery) };
216
+ }