@nanobpm/urban 0.81.0 → 0.82.1

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.
@@ -52,6 +52,13 @@ export const lit = (value) => ({ kind: "lit", value });
52
52
  export const col = (name) => ({ kind: "col", name });
53
53
  /** A reference to a correlated PROJECTION-row column (only valid inside {@link exists}). */
54
54
  export const pcol = (name) => ({ kind: "pcol", name });
55
+ /**
56
+ * A reference to a column of a key-correlated ROLLUP LOOKUP row (see {@link ReadModelDecl.lookups}).
57
+ * `rcol("c", "prs_opened")` reads the `prs_opened` column of the rollup joined under alias `"c"` for
58
+ * the base row (compiling to `LEFT JOIN <rollup> ON <key>` on the SQL side / the matching group row on
59
+ * the TS side). Only valid in a {@link defineReadModel} whose `lookups` declares that alias.
60
+ */
61
+ export const rcol = (lookup, name) => ({ kind: "rcol", lookup, name });
55
62
  const cmp = (op) => (left, right) => ({ kind: "compare", op, left, right });
56
63
  export const eq = cmp("eq");
57
64
  export const neq = cmp("neq");
@@ -62,6 +69,10 @@ export const gte = cmp("gte");
62
69
  export const and = (...clauses) => ({ kind: "and", clauses });
63
70
  export const or = (...clauses) => ({ kind: "or", clauses });
64
71
  export const not = (expr) => ({ kind: "not", expr });
72
+ /** `<expr> IS NULL` — a null test (1/0 in SQL; `value == null` in TS). `not(isNull(x))` is `IS NOT NULL`. */
73
+ export const isNull = (expr) => ({ kind: "isNull", expr });
74
+ /** Sugar for `not(isNull(expr))` — `<expr> IS NOT NULL`. */
75
+ export const isNotNull = (expr) => ({ kind: "not", expr: { kind: "isNull", expr } });
65
76
  /** Build a `CASE WHEN … THEN … [WHEN …] ELSE … END` expression. */
66
77
  export const caseWhen = (whens, otherwise) => ({
67
78
  kind: "case",
@@ -222,12 +233,17 @@ export function compileToSqlSelect(expr, options = {}) {
222
233
  case "lit":
223
234
  return sqlLiteral(node.value);
224
235
  case "col":
225
- return `${quoteIdent(baseAlias)}.${quoteIdent(node.name)}`;
236
+ return options.resolveColumn ? options.resolveColumn(node.name) : `${quoteIdent(baseAlias)}.${quoteIdent(node.name)}`;
226
237
  case "pcol":
227
238
  if (!projTable) {
228
239
  throw new Error(`pcol("${node.name}") used outside an EXISTS projection context`);
229
240
  }
230
241
  return `${quoteIdent(projTable)}.${quoteIdent(node.name)}`;
242
+ case "rcol":
243
+ if (!options.resolveRollupColumn) {
244
+ throw new Error(`rcol("${node.lookup}", "${node.name}") used outside a rollup-lookup context`);
245
+ }
246
+ return options.resolveRollupColumn(node.lookup, node.name);
231
247
  case "compare":
232
248
  // SQLite comparisons against NULL yield NULL, but the TS backend (compareValues) collapses any
233
249
  // nullish operand to `false`/0. COALESCE the SQL result to 0 so both backends agree on NULL inputs.
@@ -246,6 +262,9 @@ export function compileToSqlSelect(expr, options = {}) {
246
262
  // `NOT NULL` is NULL in SQLite, while the TS backend returns `!truthy(NULL)` → true. Compile as
247
263
  // `NOT COALESCE(x, 0)` so `not(NULL)` is 1 in both backends under the shared "NULL → false" rule.
248
264
  return `(NOT COALESCE(${walk(node.expr, projTable, existsDepth)}, 0))`;
265
+ case "isNull":
266
+ // `x IS NULL` yields 1/0 in SQLite (never NULL), matching the TS backend's `value == null` test.
267
+ return `(${walk(node.expr, projTable, existsDepth)} IS NULL)`;
249
268
  case "case": {
250
269
  const whens = node.whens
251
270
  .map((w) => `WHEN ${walk(w.when, projTable, existsDepth)} THEN ${walk(w.then, projTable, existsDepth)}`)
@@ -383,7 +402,7 @@ function truthy(value) {
383
402
  * named projection's rows (the TS side of the projection-name seam). Both backends walk the SAME AST.
384
403
  */
385
404
  export function compileToFn(expr) {
386
- const evalNode = (node, baseRow, projections, projRow) => {
405
+ const evalNode = (node, baseRow, projections, lookups, projRow) => {
387
406
  switch (node.kind) {
388
407
  case "lit":
389
408
  return node.value;
@@ -393,29 +412,52 @@ export function compileToFn(expr) {
393
412
  if (!projRow)
394
413
  throw new Error(`pcol("${node.name}") evaluated outside an EXISTS projection context`);
395
414
  return lookupColumn(projRow, node.name);
415
+ case "rcol": {
416
+ // Read the resolved row by the FOLDED alias: lookup aliases are identifier-validated and deduped
417
+ // case-insensitively (SQLite folding), and the SQL `resolveRollupColumn` resolves `node.lookup`
418
+ // through `foldSqlIdentifier` too, so a raw case-sensitive `lookups[node.lookup]` would let
419
+ // `rcol("C", …)` pass validation against a lookup declared `as: "c"` yet fail here at runtime.
420
+ // Read by OWN property (via {@link readOwn}) so an identifier-legal `__proto__` alias over a bare
421
+ // `fnFor` caller's plain `{}` can't resolve `Object.prototype` (truthy) and bypass the guard below.
422
+ const row = readOwn(lookups, foldSqlIdentifier(node.lookup));
423
+ // Every declared lookup alias is resolved to a full row up front (a LEFT-JOIN miss becomes a
424
+ // defaults/NULL-filled row, never an absent alias — see LookupRows). A missing alias here means a
425
+ // raw `fnFor` caller skipped lookup resolution; fail loudly rather than silently reading NULL.
426
+ if (!row) {
427
+ throw new Error(`rcol("${node.lookup}", "${node.name}") evaluated without a resolved lookup row — ` +
428
+ `use ReadModel.evaluate() (or pass the model's resolved lookups) instead of a bare fnFor call`);
429
+ }
430
+ return lookupColumn(row, node.name);
431
+ }
396
432
  case "compare":
397
- return compareValues(node.op, evalNode(node.left, baseRow, projections, projRow), evalNode(node.right, baseRow, projections, projRow));
433
+ return compareValues(node.op, evalNode(node.left, baseRow, projections, lookups, projRow), evalNode(node.right, baseRow, projections, lookups, projRow));
398
434
  case "and":
399
- return node.clauses.every((c) => truthy(evalNode(c, baseRow, projections, projRow)));
435
+ return node.clauses.every((c) => truthy(evalNode(c, baseRow, projections, lookups, projRow)));
400
436
  case "or":
401
- return node.clauses.some((c) => truthy(evalNode(c, baseRow, projections, projRow)));
437
+ return node.clauses.some((c) => truthy(evalNode(c, baseRow, projections, lookups, projRow)));
402
438
  case "not":
403
- return !truthy(evalNode(node.expr, baseRow, projections, projRow));
439
+ return !truthy(evalNode(node.expr, baseRow, projections, lookups, projRow));
440
+ case "isNull": {
441
+ const v = evalNode(node.expr, baseRow, projections, lookups, projRow);
442
+ return v === null || v === undefined;
443
+ }
404
444
  case "case": {
405
445
  for (const w of node.whens) {
406
- if (truthy(evalNode(w.when, baseRow, projections, projRow))) {
407
- return evalNode(w.then, baseRow, projections, projRow);
446
+ if (truthy(evalNode(w.when, baseRow, projections, lookups, projRow))) {
447
+ return evalNode(w.then, baseRow, projections, lookups, projRow);
408
448
  }
409
449
  }
410
- return evalNode(node.else, baseRow, projections, projRow);
450
+ return evalNode(node.else, baseRow, projections, lookups, projRow);
411
451
  }
412
452
  case "exists": {
413
- const rows = projections[node.projection] ?? [];
414
- return rows.some((r) => truthy(evalNode(node.where, baseRow, projections, r)));
453
+ // Read by OWN property so a projection literally named `__proto__` over a bare `fnFor` caller's
454
+ // plain `{}` resolves to no rows (empty EXISTS) instead of the inherited `Object.prototype`.
455
+ const rows = readOwn(projections, node.projection) ?? [];
456
+ return rows.some((r) => truthy(evalNode(node.where, baseRow, projections, lookups, r)));
415
457
  }
416
458
  }
417
459
  };
418
- return (baseRow, projections = {}) => evalNode(expr, baseRow, projections);
460
+ return (baseRow, projections = {}, lookups = {}) => evalNode(expr, baseRow, projections, lookups);
419
461
  }
420
462
  /** Resolve a column value the way SQLite resolves an identifier: an exact-key match first, then a
421
463
  * case-insensitive fallback. SQLite folds ASCII identifier case even for quoted identifiers, so the
@@ -434,6 +476,34 @@ export function lookupColumn(row, name) {
434
476
  }
435
477
  return null;
436
478
  }
479
+ /** Read a dictionary entry by OWN property only. A model-controlled identifier (a lookup alias or
480
+ * projection name) reaching a bare `fnFor` caller's plain-`{}` dictionary must not resolve an inherited
481
+ * prototype member: `dict["__proto__"]` / `dict["toString"]` on a `{}` return truthy `Object.prototype` /
482
+ * function values, which would slip past a `?? default` / missing-key guard and read from the prototype
483
+ * chain. The canonical resolved dictionaries are `Object.create(null)`, but this keeps the bare-caller
484
+ * path safe too (the sibling of the OWN-key checks in {@link lookupColumn}/{@link resolveCandidateRows}). */
485
+ function readOwn(dict, key) {
486
+ return Object.hasOwn(dict, key) ? dict[key] : undefined;
487
+ }
488
+ /** Read a rollup lookup's candidate rows from the caller-supplied `lookupRows` dictionary by its alias,
489
+ * resolved the way SQLite folds identifiers: an exact OWN-key match first, then a case-insensitive
490
+ * OWN-key fallback (the sibling of {@link lookupColumn}). Lookup aliases are case-insensitive SQL
491
+ * identifiers — declared/deduped via `foldSqlIdentifier`, and `rcol` reads them folded — so candidates
492
+ * supplied under a different casing (e.g. `{ C: [...] }` for an alias declared `c`) must still be found;
493
+ * a raw case-sensitive `lookupRows[alias]` would silently treat them as "no candidates" and NULL/default-
494
+ * fill, drifting from the folded treatment everywhere else. OWN-key checks (not the prototype-walking
495
+ * `in`) also keep an identifier-legal `__proto__` alias from resolving to `Object.prototype` (inherited,
496
+ * non-array) and throwing in the caller's `.filter(...)`. */
497
+ function resolveCandidateRows(lookupRows, alias) {
498
+ if (Object.hasOwn(lookupRows, alias))
499
+ return lookupRows[alias];
500
+ const folded = foldSqlIdentifier(alias);
501
+ for (const key of Object.keys(lookupRows)) {
502
+ if (foldSqlIdentifier(key) === folded)
503
+ return lookupRows[key];
504
+ }
505
+ return [];
506
+ }
437
507
  function collectProjectionNames(expr, into) {
438
508
  switch (expr.kind) {
439
509
  case "exists":
@@ -452,6 +522,9 @@ function collectProjectionNames(expr, into) {
452
522
  case "not":
453
523
  collectProjectionNames(expr.expr, into);
454
524
  return;
525
+ case "isNull":
526
+ collectProjectionNames(expr.expr, into);
527
+ return;
455
528
  case "case":
456
529
  for (const w of expr.whens) {
457
530
  collectProjectionNames(w.when, into);
@@ -463,10 +536,11 @@ function collectProjectionNames(expr, into) {
463
536
  return;
464
537
  }
465
538
  }
466
- /** Collect the base-row columns (`col`) and per-projection columns (`pcol`, inside `exists`) an
467
- * expression references. Used by the parity guard to build fixture tables carrying every referenced
468
- * column even when a sample supplies no rows for a projection. */
469
- function collectColumns(expr, baseCols, projCols, currentProjection) {
539
+ /** Collect the base-row columns (`col`), per-projection columns (`pcol`, inside `exists`), and
540
+ * per-rollup-lookup columns (`rcol`) an expression references. Used by the parity guard to build
541
+ * fixture tables carrying every referenced column even when a sample supplies no rows for a
542
+ * projection/lookup. */
543
+ function collectColumns(expr, baseCols, projCols, lookupCols, currentProjection) {
470
544
  switch (expr.kind) {
471
545
  case "col":
472
546
  baseCols.add(expr.name);
@@ -478,29 +552,51 @@ function collectColumns(expr, baseCols, projCols, currentProjection) {
478
552
  projCols.set(currentProjection, set);
479
553
  }
480
554
  return;
555
+ case "rcol": {
556
+ // Canonicalize the bucket key case-insensitively (SQLite folds lookup aliases): a derivation using
557
+ // rcol("C", …) must land in the SAME bucket as a lookup declared/seeded `as: "c"`, else the referenced
558
+ // column is recorded under a divergent key and both consumers (the parity guard and the SQL TEMP
559
+ // fixture builder, which fold on read) drop it — producing missing fixture columns / false parity
560
+ // failures. Reuse an existing key whose folded form matches (e.g. the pre-seeded declared alias).
561
+ const folded = foldSqlIdentifier(expr.lookup);
562
+ let key = expr.lookup;
563
+ for (const k of lookupCols.keys()) {
564
+ if (foldSqlIdentifier(k) === folded) {
565
+ key = k;
566
+ break;
567
+ }
568
+ }
569
+ const set = lookupCols.get(key) ?? new Set();
570
+ set.add(expr.name);
571
+ lookupCols.set(key, set);
572
+ return;
573
+ }
481
574
  case "compare":
482
- collectColumns(expr.left, baseCols, projCols, currentProjection);
483
- collectColumns(expr.right, baseCols, projCols, currentProjection);
575
+ collectColumns(expr.left, baseCols, projCols, lookupCols, currentProjection);
576
+ collectColumns(expr.right, baseCols, projCols, lookupCols, currentProjection);
484
577
  return;
485
578
  case "and":
486
579
  case "or":
487
580
  for (const c of expr.clauses)
488
- collectColumns(c, baseCols, projCols, currentProjection);
581
+ collectColumns(c, baseCols, projCols, lookupCols, currentProjection);
489
582
  return;
490
583
  case "not":
491
- collectColumns(expr.expr, baseCols, projCols, currentProjection);
584
+ collectColumns(expr.expr, baseCols, projCols, lookupCols, currentProjection);
585
+ return;
586
+ case "isNull":
587
+ collectColumns(expr.expr, baseCols, projCols, lookupCols, currentProjection);
492
588
  return;
493
589
  case "case":
494
590
  for (const w of expr.whens) {
495
- collectColumns(w.when, baseCols, projCols, currentProjection);
496
- collectColumns(w.then, baseCols, projCols, currentProjection);
591
+ collectColumns(w.when, baseCols, projCols, lookupCols, currentProjection);
592
+ collectColumns(w.then, baseCols, projCols, lookupCols, currentProjection);
497
593
  }
498
- collectColumns(expr.else, baseCols, projCols, currentProjection);
594
+ collectColumns(expr.else, baseCols, projCols, lookupCols, currentProjection);
499
595
  return;
500
596
  case "exists": {
501
597
  if (!projCols.has(expr.projection))
502
598
  projCols.set(expr.projection, new Set());
503
- collectColumns(expr.where, baseCols, projCols, expr.projection);
599
+ collectColumns(expr.where, baseCols, projCols, lookupCols, expr.projection);
504
600
  return;
505
601
  }
506
602
  default:
@@ -541,11 +637,90 @@ export function defineReadModel(decl) {
541
637
  collectProjectionNames(decl.derive[c], projSet);
542
638
  for (const name of projSet)
543
639
  assertSqlIdentifier("projection name", name);
640
+ // ── Rollup lookups (#468): validate the LEFT-JOIN key-lookups and build the alias → metadata map the
641
+ // SQL `rcol` resolver and the TS row resolver share, so both backends read the same rollup columns. ──
642
+ const lookups = decl.lookups ?? [];
643
+ // Single source of truth for the base-alias/lookup-alias collision guard. A `baseAlias` that folds
644
+ // (SQLite compares identifiers case-insensitively) to a rollup lookup alias would make the managed
645
+ // VIEW reuse one identifier for both the base table and a joined rollup — ambiguous/invalid SQL. This
646
+ // runs both at definition time against the DEFAULT base alias AND inside `viewDdl` against a
647
+ // caller-supplied `baseAlias` override, which the definition-time check can't see (#468).
648
+ const assertBaseAliasDistinctFromLookups = (baseAlias) => {
649
+ const folded = foldSqlIdentifier(baseAlias);
650
+ for (const lk of lookups) {
651
+ if (foldSqlIdentifier(lk.as) === folded) {
652
+ throw new Error(`read model "${decl.name}" rollup lookup alias "${lk.as}" collides with the base alias "${baseAlias}"; choose another`);
653
+ }
654
+ }
655
+ };
656
+ const lookupByAlias = new Map();
657
+ for (const lk of lookups) {
658
+ assertSqlIdentifier("rollup lookup alias", lk.as);
659
+ const key = foldSqlIdentifier(lk.as);
660
+ if (lookupByAlias.has(key)) {
661
+ throw new Error(`read model "${decl.name}" declares two rollup lookups under alias "${lk.as}"`);
662
+ }
663
+ if (lk.on.length === 0)
664
+ throw new Error(`read model "${decl.name}" rollup lookup "${lk.as}" has an empty join key`);
665
+ for (const pair of lk.on) {
666
+ assertSqlIdentifier("lookup base column", pair.base);
667
+ assertSqlIdentifier("lookup rollup column", pair.rollup);
668
+ }
669
+ // The join must cover the rollup's FULL group key, so the joined row is single-valued (no fan-out
670
+ // of base rows). Compare the on-keys' rollup side against the rollup's declared groupBy set.
671
+ const onRollupKeys = new Set(lk.on.map((p) => foldSqlIdentifier(p.rollup)));
672
+ const groupKeys = new Set(lk.rollup.groupBy.map((g) => foldSqlIdentifier(g)));
673
+ if (onRollupKeys.size !== groupKeys.size || [...groupKeys].some((g) => !onRollupKeys.has(g))) {
674
+ throw new Error(`read model "${decl.name}" rollup lookup "${lk.as}" must join on the rollup's full group key ` +
675
+ `[${lk.rollup.groupBy.join(", ")}] to stay single-valued; got [${lk.on.map((p) => p.rollup).join(", ")}]`);
676
+ }
677
+ for (const dname of Object.keys(lk.defaults ?? {})) {
678
+ if (!lk.rollup.outputColumns.includes(dname)) {
679
+ throw new Error(`read model "${decl.name}" rollup lookup "${lk.as}" declares a default for "${dname}", ` +
680
+ `not a column of rollup "${lk.rollup.projectionName}"`);
681
+ }
682
+ }
683
+ lookupByAlias.set(key, lk);
684
+ }
685
+ // Definition-time guard: no lookup alias may collide with the DEFAULT base alias (a caller-supplied
686
+ // `baseAlias` override is guarded separately inside `viewDdl`, which is the only place it's known).
687
+ assertBaseAliasDistinctFromLookups(DEFAULT_BASE_ALIAS);
688
+ // Validate every `rcol(alias, name)` reference resolves to a declared lookup and a real rollup column.
689
+ {
690
+ const baseColsSeen = new Set();
691
+ const projColsSeen = new Map();
692
+ const lookupColsSeen = new Map();
693
+ for (const c of columns)
694
+ collectColumns(decl.derive[c], baseColsSeen, projColsSeen, lookupColsSeen);
695
+ for (const [alias, names] of lookupColsSeen) {
696
+ const lk = lookupByAlias.get(foldSqlIdentifier(alias));
697
+ if (!lk) {
698
+ throw new Error(`read model "${decl.name}" references rollup-lookup alias "${alias}" with no matching lookup`);
699
+ }
700
+ for (const n of names) {
701
+ if (!lk.rollup.outputColumns.includes(n)) {
702
+ throw new Error(`read model "${decl.name}" reads rcol("${alias}", "${n}") but "${n}" is not a column of ` +
703
+ `rollup "${lk.rollup.projectionName}" [${lk.rollup.outputColumns.join(", ")}]`);
704
+ }
705
+ }
706
+ }
707
+ }
708
+ const resolveRollupColumn = (alias, name) => {
709
+ const lk = lookupByAlias.get(foldSqlIdentifier(alias));
710
+ if (!lk)
711
+ throw new Error(`read model "${decl.name}" has no rollup lookup "${alias}"`);
712
+ const qualified = `${quoteIdent(alias)}.${quoteIdent(name)}`;
713
+ const dflt = lk.defaults?.[name];
714
+ return dflt === undefined ? qualified : `COALESCE(${qualified}, ${sqlLiteral(dflt)})`;
715
+ };
716
+ const withLookupResolver = (options) => lookups.length === 0
717
+ ? (options ?? {})
718
+ : { ...options, resolveRollupColumn: options?.resolveRollupColumn ?? resolveRollupColumn };
544
719
  const sqlSelectFor = (column, options) => {
545
720
  const expr = decl.derive[column];
546
721
  if (!expr)
547
722
  throw new Error(`read model "${decl.name}" has no derived column "${column}"`);
548
- return compileToSqlSelect(expr, options);
723
+ return compileToSqlSelect(expr, withLookupResolver(options));
549
724
  };
550
725
  const fnCache = new Map();
551
726
  const fnFor = (column) => {
@@ -561,27 +736,74 @@ export function defineReadModel(decl) {
561
736
  };
562
737
  const viewDdl = (options, viewOptions) => {
563
738
  const baseAlias = resolveBaseAlias(options?.baseAlias);
739
+ // A caller-supplied `baseAlias` override is only known here — guard it against the lookup aliases so
740
+ // the LEFT JOINs below can't reuse the base table's identifier for a joined rollup (invalid SQL).
741
+ assertBaseAliasDistinctFromLookups(baseAlias);
564
742
  const selectBase = decl.selectBaseColumns !== false;
565
743
  const derived = columns.map((c) => `${sqlSelectFor(c, options)} AS ${quoteIdent(c)}`);
566
744
  const selectList = [...(selectBase ? [`${quoteIdent(baseAlias)}.*`] : []), ...derived].join(",\n ");
567
745
  const createView = viewOptions?.temp ? "CREATE TEMP VIEW IF NOT EXISTS" : "CREATE VIEW IF NOT EXISTS";
568
- return (`${createView} ${quoteIdent(decl.name)} AS\n` +
569
- `SELECT\n ${selectList}\n` +
570
- `FROM ${quoteIdent(decl.baseTable)} ${quoteIdent(baseAlias)};`);
746
+ // One `LEFT JOIN <rollup> ON <baseKey> = <rollupKey> …` per declared lookup, in declaration order so
747
+ // the DDL is deterministic. The rollup's `*_counts` VIEW is joined by name (its physical relation).
748
+ const joins = lookups.map((lk) => {
749
+ const on = lk.on
750
+ .map((p) => `${quoteIdent(baseAlias)}.${quoteIdent(p.base)} = ${quoteIdent(lk.as)}.${quoteIdent(p.rollup)}`)
751
+ .join(" AND ");
752
+ return `LEFT JOIN ${quoteIdent(lk.rollup.projectionName)} ${quoteIdent(lk.as)} ON ${on}`;
753
+ });
754
+ const fromClause = [`FROM ${quoteIdent(decl.baseTable)} ${quoteIdent(baseAlias)}`, ...joins].join("\n");
755
+ return `${createView} ${quoteIdent(decl.name)} AS\n` + `SELECT\n ${selectList}\n` + `${fromClause};`;
756
+ };
757
+ const resolveLookupRows = (baseRow, lookupRows) => {
758
+ // Null-prototype dictionary: lookup aliases are only identifier-validated, so an alias like
759
+ // `__proto__` must set a plain own property rather than trip the magic prototype setter a normal
760
+ // `{}` inherits from `Object.prototype` (same treatment as `evaluate()`'s derived-output map below).
761
+ const resolved = Object.create(null);
762
+ for (const lk of lookups) {
763
+ // Read candidates by the lookup's alias via {@link resolveCandidateRows}: an OWN-property check
764
+ // (so an identifier-legal `__proto__` alias over a plain `{}` can't resolve `lookupRows["__proto__"]`
765
+ // to `Object.prototype` — inherited, truthy, non-array — and throw in the `.filter(...)` below), plus
766
+ // a case-insensitive fallback so a caller supplying candidates under a different casing (e.g.
767
+ // `{ C: [...] }` for an alias declared `c`) is still matched, mirroring the folded alias treatment
768
+ // used by `rcol`, the resolved-row keying below, and the SQL LEFT JOIN.
769
+ const candidates = resolveCandidateRows(lookupRows, lk.as);
770
+ const matches = candidates.filter((r) => lk.on.every((p) => compareValues("eq", lookupColumn(baseRow, p.base), lookupColumn(r, p.rollup))));
771
+ // The join covers the rollup's full group key, so a well-formed rollup yields at most one row per
772
+ // key. More than one match means the candidate set is not single-valued: SQL's LEFT JOIN would fan
773
+ // out to multiple rows while this TS resolver would silently keep one — the exact drift the parity
774
+ // guard exists to prevent. Fail loudly instead.
775
+ if (matches.length > 1) {
776
+ throw new Error(`read model "${decl.name}" rollup lookup "${lk.as}" matched ${matches.length} rows for the ` +
777
+ `join key — a lookup must be single-valued (its join covers the rollup's full group key)`);
778
+ }
779
+ const match = matches[0];
780
+ // Build one resolved row over EVERY rollup output column so `rcol` reads a stable object; a
781
+ // missing/NULL value falls back to the declared default (the `COALESCE(alias.col, default)` twin).
782
+ const row = Object.create(null);
783
+ for (const name of lk.rollup.outputColumns) {
784
+ const raw = match ? lookupColumn(match, name) : null;
785
+ const dflt = lk.defaults?.[name];
786
+ row[name] = raw === null || raw === undefined ? (dflt === undefined ? null : dflt) : raw;
787
+ }
788
+ resolved[foldSqlIdentifier(lk.as)] = row;
789
+ }
790
+ return resolved;
571
791
  };
572
792
  return {
573
793
  decl,
574
794
  projectionNames: [...projSet],
575
795
  sqlSelectFor,
576
796
  fnFor,
577
- evaluate: (baseRow, projections) => {
797
+ resolveLookups: resolveLookupRows,
798
+ evaluate: (baseRow, projections, lookupRows) => {
578
799
  // Derived column names are user-controlled and only identifier-validated, so a name like
579
800
  // `__proto__` must set a plain own property rather than trip the magic prototype setter a
580
801
  // normal `{}` inherits from `Object.prototype` (same null-prototype treatment as the
581
802
  // untrusted-key dictionaries elsewhere in this repo, e.g. `core/logger.ts`).
582
803
  const out = Object.create(null);
804
+ const resolvedLookups = resolveLookupRows(baseRow, lookupRows ?? {});
583
805
  for (const c of columns)
584
- out[c] = fnFor(c)(baseRow, projections);
806
+ out[c] = fnFor(c)(baseRow, projections, resolvedLookups);
585
807
  return out;
586
808
  },
587
809
  viewDdl,
@@ -703,15 +925,28 @@ export function assertReadModelParity(model, db, samples, options = {}) {
703
925
  throw new Error(`read model "${model.decl.name}" has no derived column "${c}" to check parity for`);
704
926
  }
705
927
  }
706
- // Collect the union of base columns and per-projection columns across all samples, so the fixture
707
- // tables carry every referenced column. Seed from the AST first so a projection referenced by an
708
- // `exists` predicate always has its correlated columns even when a sample supplies no rows for it.
928
+ // Collect the union of base columns, per-projection columns, and per-rollup-lookup columns across all
929
+ // samples, so the fixture tables carry every referenced column. Seed from the AST first so a
930
+ // projection/lookup referenced by the derivation always has its columns even when a sample supplies
931
+ // no rows for it.
932
+ const lookups = model.decl.lookups ?? [];
709
933
  const baseCols = new Set();
710
934
  const projCols = new Map();
935
+ const lookupColsByAlias = new Map();
711
936
  for (const name of model.projectionNames)
712
937
  projCols.set(name, new Set());
938
+ for (const lk of lookups) {
939
+ // Seed each lookup fixture with its join keys (so the LEFT JOIN's ON columns exist) even if no
940
+ // `rcol` reads them, plus every column that carries a declared default.
941
+ const set = new Set();
942
+ for (const p of lk.on)
943
+ set.add(p.rollup);
944
+ for (const d of Object.keys(lk.defaults ?? {}))
945
+ set.add(d);
946
+ lookupColsByAlias.set(lk.as, set);
947
+ }
713
948
  for (const c of columns)
714
- collectColumns(model.decl.derive[c], baseCols, projCols);
949
+ collectColumns(model.decl.derive[c], baseCols, projCols, lookupColsByAlias);
715
950
  for (const s of samples) {
716
951
  for (const k of Object.keys(s.baseRow))
717
952
  baseCols.add(k);
@@ -725,6 +960,20 @@ export function assertReadModelParity(model, db, samples, options = {}) {
725
960
  for (const k of Object.keys(r))
726
961
  set.add(k);
727
962
  }
963
+ // Widen each DECLARED lookup's fixture from the sample's candidate rows read via
964
+ // {@link resolveCandidateRows} — the same case-insensitive OWN-key resolution `resolveLookups` uses
965
+ // (line ~1009) — so a sample supplying candidates under a different casing (e.g. `{ C: [...] }` for a
966
+ // lookup declared `as: "c"`) still widens the right fixture instead of being silently ignored (which
967
+ // would later insert nothing and fail parity for a non-parity reason). Keyed off `lookups`, not the
968
+ // sample's keys, so it mirrors the resolver exactly.
969
+ for (const lk of lookups) {
970
+ const set = lookupColsByAlias.get(lk.as);
971
+ if (!set)
972
+ continue;
973
+ for (const r of resolveCandidateRows(s.lookups ?? {}, lk.as))
974
+ for (const k of Object.keys(r))
975
+ set.add(k);
976
+ }
728
977
  }
729
978
  const baseTable = model.decl.baseTable;
730
979
  const createFixture = (table, cols) => {
@@ -766,6 +1015,25 @@ export function assertReadModelParity(model, db, samples, options = {}) {
766
1015
  tableOwner.set(tableKey, name);
767
1016
  projectionTables.set(name, table);
768
1017
  }
1018
+ // Resolve each rollup lookup's fixture table (its rollup's physical relation name). Reject a lookup
1019
+ // relation colliding with the base table or another lookup/projection, so the guard fabricates one
1020
+ // isolated fixture per relation (mirrors the projection checks above).
1021
+ const lookupTables = new Map(); // alias -> relation
1022
+ for (const lk of lookups) {
1023
+ const table = assertSqlIdentifier("rollup lookup relation", lk.rollup.projectionName);
1024
+ const tableKey = foldSqlIdentifier(table);
1025
+ if (tableKey === foldSqlIdentifier(baseTable)) {
1026
+ throw new Error(`read model "${model.decl.name}" rollup lookup "${lk.as}" resolves to its base table "${baseTable}"; ` +
1027
+ `a lookup needs a distinct relation so the parity guard can build an isolated fixture for it`);
1028
+ }
1029
+ const owner = tableOwner.get(tableKey);
1030
+ if (owner !== undefined) {
1031
+ throw new Error(`read model "${model.decl.name}" rollup lookup "${lk.as}" shares the physical relation "${table}" ` +
1032
+ `with "${owner}"; each lookup needs a distinct relation for an isolated parity fixture`);
1033
+ }
1034
+ tableOwner.set(tableKey, lk.as);
1035
+ lookupTables.set(lk.as, table);
1036
+ }
769
1037
  // Drop/create every fixture through this single helper so the pre-check reset (leaked objects from
770
1038
  // an earlier call) and the post-check teardown (below) can never drift apart. Schema-qualified with
771
1039
  // `temp.` so an unqualified name can never resolve to — and delete — a real `main` table/view.
@@ -774,6 +1042,8 @@ export function assertReadModelParity(model, db, samples, options = {}) {
774
1042
  db.exec(`DROP TABLE IF EXISTS temp.${quoteIdent(baseTable)};`);
775
1043
  for (const table of projectionTables.values())
776
1044
  db.exec(`DROP TABLE IF EXISTS temp.${quoteIdent(table)};`);
1045
+ for (const table of lookupTables.values())
1046
+ db.exec(`DROP TABLE IF EXISTS temp.${quoteIdent(table)};`);
777
1047
  };
778
1048
  const insertRow = (table, row) => {
779
1049
  const keys = Object.keys(row);
@@ -795,12 +1065,16 @@ export function assertReadModelParity(model, db, samples, options = {}) {
795
1065
  createFixture(baseTable, baseCols);
796
1066
  for (const [name, table] of projectionTables)
797
1067
  createFixture(table, projCols.get(name) ?? new Set());
1068
+ for (const [alias, table] of lookupTables)
1069
+ createFixture(table, lookupColsByAlias.get(alias) ?? new Set());
798
1070
  db.exec(model.viewDdl(options.sql, { temp: true }));
799
1071
  samples.forEach((sample, idx) => {
800
1072
  // Reset fixture data for this isolated sample.
801
1073
  db.run(`DELETE FROM ${quoteIdent(baseTable)};`);
802
1074
  for (const table of projectionTables.values())
803
1075
  db.run(`DELETE FROM ${quoteIdent(table)};`);
1076
+ for (const table of lookupTables.values())
1077
+ db.run(`DELETE FROM ${quoteIdent(table)};`);
804
1078
  insertRow(baseTable, sample.baseRow);
805
1079
  for (const [pname, rows] of Object.entries(sample.projections ?? {})) {
806
1080
  // Ignore projections the model never references — no fixture table exists for them, and they
@@ -811,14 +1085,31 @@ export function assertReadModelParity(model, db, samples, options = {}) {
811
1085
  for (const r of rows)
812
1086
  insertRow(table, r);
813
1087
  }
1088
+ // Insert each DECLARED lookup's candidate rows read via {@link resolveCandidateRows} — the same
1089
+ // case-insensitive OWN-key resolution `resolveLookups` uses below — so candidates supplied under a
1090
+ // different casing (e.g. `{ C: [...] }` for a lookup declared `as: "c"`) reach the SQL fixture the
1091
+ // VIEW's LEFT JOIN reads, instead of being dropped by an exact-key `sample.lookups[alias]` lookup and
1092
+ // failing parity for a non-parity reason. Keyed off `lookups`, mirroring the resolver.
1093
+ for (const lk of lookups) {
1094
+ const table = lookupTables.get(lk.as);
1095
+ if (!table)
1096
+ continue;
1097
+ for (const r of resolveCandidateRows(sample.lookups ?? {}, lk.as))
1098
+ insertRow(table, r);
1099
+ }
814
1100
  // Read the SQL-derived values straight from the managed VIEW — the VIEW body already computes
815
1101
  // each derived column, so this exercises the exact DDL the framework emits (not a recompiled copy).
816
1102
  const derivedList = columns.map((c) => quoteIdent(c)).join(", ");
817
1103
  const sqlRows = db.all(`SELECT ${derivedList} FROM ${quoteIdent(model.decl.name)};`);
818
1104
  const sqlRow = sqlRows[0] ?? {};
1105
+ // Resolve each rollup lookup's single matching row (match on the join keys, fill declared
1106
+ // defaults) via the model's OWN resolver — the single source of the lookup-resolution rule (incl.
1107
+ // the single-valued/no-fan-out guard). Done here (rather than via `model.evaluate`) so a test that
1108
+ // overrides `model.fnFor` to inject drift still drives the compared TS value.
1109
+ const resolvedLookups = model.resolveLookups(sample.baseRow, sample.lookups ?? {});
819
1110
  for (const c of columns) {
820
1111
  const sqlValue = normaliseSqlValue(sqlRow[c]);
821
- const fnValue = normaliseSqlValue(model.fnFor(c)(sample.baseRow, sample.projections));
1112
+ const fnValue = normaliseSqlValue(model.fnFor(c)(sample.baseRow, sample.projections, resolvedLookups));
822
1113
  if (!Object.is(sqlValue, fnValue)) {
823
1114
  onMismatch(`read-model parity mismatch in "${model.decl.name}".${c} (sample #${idx}): ` +
824
1115
  `SQL=${formatParityValue(sqlValue)} vs TS=${formatParityValue(fnValue)}`);
@@ -830,3 +1121,710 @@ export function assertReadModelParity(model, db, samples, options = {}) {
830
1121
  dropFixtures();
831
1122
  }
832
1123
  }
1124
+ /** `COUNT(col)` (non-NULL) when `column` is given, else `COUNT(*)`. */
1125
+ export const count = (column) => (column === undefined ? { kind: "count" } : { kind: "count", column });
1126
+ /** `SUM(CASE WHEN pred THEN 1 ELSE 0 END)` — the count of group rows satisfying `pred`. */
1127
+ export const countWhere = (pred) => ({ kind: "countWhere", pred });
1128
+ /** `MAX(col)` over the group (ignores NULLs; NULL for an all-NULL/empty group). */
1129
+ export const max = (column) => ({ kind: "max", column });
1130
+ /** `MIN(CASE WHEN pred THEN col END)` — the min of `col` over group rows satisfying `pred`. */
1131
+ export const minWhere = (column, pred) => ({ kind: "minWhere", column, pred });
1132
+ /** `COALESCE(a, b)` over two aggregates — `a` unless it is NULL, then `b`. */
1133
+ export const coalesce = (a, b) => ({ kind: "coalesce", a, b });
1134
+ /** `expr + <integer literal>` (e.g. `MAX(wave) + 1`). Rejects a non-integer literal at build time. */
1135
+ export const add = (expr, literal) => {
1136
+ if (!Number.isInteger(literal))
1137
+ throw new Error(`add() literal must be an integer, got ${literal}`);
1138
+ return { kind: "add", expr, literal };
1139
+ };
1140
+ /** Reject an {@link Expr} that has no meaning in a rollup predicate context (no correlated projection
1141
+ * or lookup exists there): `exists`/`pcol`/`rcol`. Keeps the rollup's predicate surface closed. */
1142
+ function assertRollupPredicate(where, expr) {
1143
+ switch (expr.kind) {
1144
+ case "exists":
1145
+ throw new Error(`${where}: exists(...) is not valid in a rollup predicate`);
1146
+ case "pcol":
1147
+ throw new Error(`${where}: pcol(...) is not valid in a rollup predicate`);
1148
+ case "rcol":
1149
+ throw new Error(`${where}: rcol(...) is not valid in a rollup predicate`);
1150
+ case "compare":
1151
+ assertRollupPredicate(where, expr.left);
1152
+ assertRollupPredicate(where, expr.right);
1153
+ return;
1154
+ case "and":
1155
+ case "or":
1156
+ for (const c of expr.clauses)
1157
+ assertRollupPredicate(where, c);
1158
+ return;
1159
+ case "not":
1160
+ case "isNull":
1161
+ assertRollupPredicate(where, expr.expr);
1162
+ return;
1163
+ case "case":
1164
+ for (const w of expr.whens) {
1165
+ assertRollupPredicate(where, w.when);
1166
+ assertRollupPredicate(where, w.then);
1167
+ }
1168
+ assertRollupPredicate(where, expr.else);
1169
+ return;
1170
+ default:
1171
+ return;
1172
+ }
1173
+ }
1174
+ /** Collect the source-row column names an aggregate references (its `column` plus any predicate cols),
1175
+ * used to seed fixture tables and to validate a rollup's non-join source references. */
1176
+ function collectAggColumns(agg, into) {
1177
+ switch (agg.kind) {
1178
+ case "count":
1179
+ if (agg.column !== undefined)
1180
+ into.add(agg.column);
1181
+ return;
1182
+ case "max":
1183
+ into.add(agg.column);
1184
+ return;
1185
+ case "countWhere": {
1186
+ const proj = new Map();
1187
+ collectColumns(agg.pred, into, proj, new Map());
1188
+ return;
1189
+ }
1190
+ case "minWhere": {
1191
+ into.add(agg.column);
1192
+ const proj = new Map();
1193
+ collectColumns(agg.pred, into, proj, new Map());
1194
+ return;
1195
+ }
1196
+ case "coalesce":
1197
+ collectAggColumns(agg.a, into);
1198
+ collectAggColumns(agg.b, into);
1199
+ return;
1200
+ case "add":
1201
+ collectAggColumns(agg.expr, into);
1202
+ return;
1203
+ }
1204
+ }
1205
+ /** A rollup over a base table. */
1206
+ export const fromTable = (table) => ({ kind: "table", table });
1207
+ /** A rollup over a canonical projection (resolved to its physical relation via {@link projectionRegistry}). */
1208
+ export const fromProjection = (projection) => ({ kind: "projection", projection });
1209
+ /** A rollup over another rollup's `*_counts` VIEW (composable — e.g. wave's two levels). */
1210
+ export const fromRollup = (rollup) => ({ kind: "rollup", rollup });
1211
+ /** A rollup over a declared key-join of two relations (the two-hop reach — D4). */
1212
+ export const joinSource = (spec) => ({ kind: "join", ...spec });
1213
+ const ROLLUP_SRC_ALIAS = "__urban_rollup_src";
1214
+ /** Resolve a rollup source to its SQL `FROM` clause, a `col`-name → qualified-SQL resolver, its leaf
1215
+ * relation names, and the rollup dependencies it composes. */
1216
+ function compileRollupSourceSql(source, resolveProjectionTable) {
1217
+ switch (source.kind) {
1218
+ case "table": {
1219
+ const table = assertSqlIdentifier("rollup source table", source.table);
1220
+ return {
1221
+ from: `${quoteIdent(table)} ${quoteIdent(ROLLUP_SRC_ALIAS)}`,
1222
+ resolveColumn: (name) => `${quoteIdent(ROLLUP_SRC_ALIAS)}.${quoteIdent(name)}`,
1223
+ };
1224
+ }
1225
+ case "projection": {
1226
+ const table = assertSqlIdentifier("rollup source projection", resolveProjectionTable(source.projection));
1227
+ return {
1228
+ from: `${quoteIdent(table)} ${quoteIdent(ROLLUP_SRC_ALIAS)}`,
1229
+ resolveColumn: (name) => `${quoteIdent(ROLLUP_SRC_ALIAS)}.${quoteIdent(name)}`,
1230
+ };
1231
+ }
1232
+ case "rollup": {
1233
+ const table = assertSqlIdentifier("rollup source rollup", source.rollup.projectionName);
1234
+ return {
1235
+ from: `${quoteIdent(table)} ${quoteIdent(ROLLUP_SRC_ALIAS)}`,
1236
+ resolveColumn: (name) => `${quoteIdent(ROLLUP_SRC_ALIAS)}.${quoteIdent(name)}`,
1237
+ };
1238
+ }
1239
+ case "join": {
1240
+ const leftRel = assertSqlIdentifier("rollup join relation", resolveProjectionTable(source.left.relation));
1241
+ const rightRel = assertSqlIdentifier("rollup join relation", resolveProjectionTable(source.right.relation));
1242
+ const leftAlias = assertSqlIdentifier("rollup join alias", source.left.alias);
1243
+ const rightAlias = assertSqlIdentifier("rollup join alias", source.right.alias);
1244
+ const joinKw = source.type === "inner" ? "JOIN" : "LEFT JOIN";
1245
+ const on = source.on
1246
+ .map((p) => `${quoteIdent(leftAlias)}.${quoteIdent(assertSqlIdentifier("join key", p.left))} = ` +
1247
+ `${quoteIdent(rightAlias)}.${quoteIdent(assertSqlIdentifier("join key", p.right))}`)
1248
+ .join(" AND ");
1249
+ const from = `${quoteIdent(leftRel)} ${quoteIdent(leftAlias)} ${joinKw} ` +
1250
+ `${quoteIdent(rightRel)} ${quoteIdent(rightAlias)} ON ${on}`;
1251
+ // Index the join-mapped output columns by their FOLDED (case-insensitive) name: SQLite folds
1252
+ // identifiers case-insensitively and the TS twin (`materializeRollupSource`) resolves these columns
1253
+ // via the case-insensitive `lookupColumn`, so a rollup predicate/groupBy that references a
1254
+ // join-mapped column with different casing must resolve here too — a case-sensitive `source.columns[name]`
1255
+ // would succeed in TS but throw in SQL. Fail fast on a case-only collision (two output columns SQLite
1256
+ // cannot tell apart), and use a `Map` so a `__proto__`-named column can't resolve off the prototype chain.
1257
+ const foldedColumns = new Map();
1258
+ for (const [outName, mapped] of Object.entries(source.columns)) {
1259
+ const key = foldSqlIdentifier(outName);
1260
+ if (foldedColumns.has(key)) {
1261
+ throw new Error(`rollup join source declares output columns differing only in case ("${outName}"); ` +
1262
+ `SQLite folds identifiers case-insensitively so they would be ambiguous`);
1263
+ }
1264
+ foldedColumns.set(key, mapped);
1265
+ }
1266
+ const resolveColumn = (name) => {
1267
+ const mapped = foldedColumns.get(foldSqlIdentifier(name));
1268
+ if (!mapped)
1269
+ throw new Error(`rollup join source has no mapped column "${name}"`);
1270
+ const [side, physical] = mapped;
1271
+ const alias = side === "left" ? leftAlias : rightAlias;
1272
+ return `${quoteIdent(alias)}.${quoteIdent(assertSqlIdentifier("join column", physical))}`;
1273
+ };
1274
+ return { from, resolveColumn };
1275
+ }
1276
+ }
1277
+ }
1278
+ /** The TS twin of {@link compileRollupSourceSql}: materialise a source's flat rows from leaf inputs. */
1279
+ function materializeRollupSource(source, inputs) {
1280
+ switch (source.kind) {
1281
+ case "table":
1282
+ return [...(inputs[source.table] ?? [])];
1283
+ case "projection":
1284
+ return [...(inputs[source.projection] ?? [])];
1285
+ case "rollup":
1286
+ return source.rollup.reduce(inputs);
1287
+ case "join": {
1288
+ const leftRows = inputs[source.left.relation] ?? [];
1289
+ const rightRows = inputs[source.right.relation] ?? [];
1290
+ const map = (l, r) => {
1291
+ const row = Object.create(null);
1292
+ for (const [outName, [side, physical]] of Object.entries(source.columns)) {
1293
+ const from = side === "left" ? l : r;
1294
+ row[outName] = from ? lookupColumn(from, physical) : null;
1295
+ }
1296
+ return row;
1297
+ };
1298
+ // Build the composite join key with the SAME storage-class-tagged encoding the GROUP BY reduce
1299
+ // uses (groupKeyPart): integer number/bigint of equal value collide, numeric never equals text —
1300
+ // matching `compareValues("eq", …)`. A nullish part yields `null` (no key), because SQLite's
1301
+ // `ON l.k = r.k` never matches a NULL key, so those rows are excluded from the join entirely.
1302
+ const keyOf = (row, cols) => {
1303
+ const parts = [];
1304
+ for (const c of cols) {
1305
+ const v = lookupColumn(row, c);
1306
+ if (v === null || v === undefined)
1307
+ return null;
1308
+ parts.push(groupKeyPart(v));
1309
+ }
1310
+ return parts.join("\u0001");
1311
+ };
1312
+ // Index the right side once by its join key (O(|right|)) so each left row probes in O(1) rather
1313
+ // than scanning every right row (was O(|left|·|right|)).
1314
+ const rightCols = source.on.map((p) => p.right);
1315
+ const leftCols = source.on.map((p) => p.left);
1316
+ const rightByKey = new Map();
1317
+ for (const r of rightRows) {
1318
+ const k = keyOf(r, rightCols);
1319
+ if (k === null)
1320
+ continue;
1321
+ let bucket = rightByKey.get(k);
1322
+ if (!bucket) {
1323
+ bucket = [];
1324
+ rightByKey.set(k, bucket);
1325
+ }
1326
+ bucket.push(r);
1327
+ }
1328
+ const out = [];
1329
+ for (const l of leftRows) {
1330
+ const k = keyOf(l, leftCols);
1331
+ const matches = k === null ? undefined : rightByKey.get(k);
1332
+ if (!matches || matches.length === 0) {
1333
+ if (source.type !== "inner")
1334
+ out.push(map(l, undefined));
1335
+ }
1336
+ else {
1337
+ for (const r of matches)
1338
+ out.push(map(l, r));
1339
+ }
1340
+ }
1341
+ return out;
1342
+ }
1343
+ }
1344
+ }
1345
+ /** Leaf relation names a source reads, transitively (a `rollup` source recurses into its own leaves). */
1346
+ function rollupSourceRelations(source) {
1347
+ switch (source.kind) {
1348
+ case "table":
1349
+ return [source.table];
1350
+ case "projection":
1351
+ return [source.projection];
1352
+ case "join":
1353
+ return [source.left.relation, source.right.relation];
1354
+ case "rollup":
1355
+ return [...source.rollup.sourceRelations];
1356
+ }
1357
+ }
1358
+ /** The physical twin of {@link rollupSourceRelations}: each leaf relation name mapped to the physical
1359
+ * SQL table the compiled VIEW reads, applying the SAME projection→table resolution
1360
+ * {@link compileRollupSourceSql} uses — identity for a raw `table` source, `resolveProjectionTable` for
1361
+ * `projection`/`join` relations, recursing for a composed `rollup`. The parity guard builds its TEMP
1362
+ * fixtures under these names so they match the VIEW's `FROM`; otherwise a projection whose name maps to a
1363
+ * different physical relation (e.g. `urban_instance_state` → `_urban_instance_state`) would leave the
1364
+ * VIEW pointing at a table the logical-named fixture never created. */
1365
+ function rollupSourcePhysicalTables(source, resolveProjectionTable) {
1366
+ switch (source.kind) {
1367
+ case "table":
1368
+ return new Map([[source.table, source.table]]);
1369
+ case "projection":
1370
+ return new Map([[source.projection, resolveProjectionTable(source.projection)]]);
1371
+ case "join":
1372
+ return new Map([
1373
+ [source.left.relation, resolveProjectionTable(source.left.relation)],
1374
+ [source.right.relation, resolveProjectionTable(source.right.relation)],
1375
+ ]);
1376
+ case "rollup":
1377
+ return rollupSourcePhysicalTables(source.rollup.decl.source, resolveProjectionTable);
1378
+ }
1379
+ }
1380
+ /** Evaluate one aggregate over a group's source rows (the TS backend). */
1381
+ function evalAgg(agg, rows) {
1382
+ switch (agg.kind) {
1383
+ case "count": {
1384
+ const { column } = agg;
1385
+ return column === undefined
1386
+ ? rows.length
1387
+ : rows.filter((r) => lookupColumn(r, column) !== null).length;
1388
+ }
1389
+ case "countWhere": {
1390
+ const fn = compileToFn(agg.pred);
1391
+ return rows.filter((r) => truthy(fn(r))).length;
1392
+ }
1393
+ case "max": {
1394
+ const vals = rows.map((r) => lookupColumn(r, agg.column)).filter((v) => v !== null && v !== undefined);
1395
+ if (vals.length === 0)
1396
+ return null;
1397
+ return vals.reduce((m, v) => (compareOrderable(orderable(v), orderable(m)) > 0 ? v : m));
1398
+ }
1399
+ case "minWhere": {
1400
+ const fn = compileToFn(agg.pred);
1401
+ const vals = rows
1402
+ .filter((r) => truthy(fn(r)))
1403
+ .map((r) => lookupColumn(r, agg.column))
1404
+ .filter((v) => v !== null && v !== undefined);
1405
+ if (vals.length === 0)
1406
+ return null;
1407
+ return vals.reduce((m, v) => (compareOrderable(orderable(v), orderable(m)) < 0 ? v : m));
1408
+ }
1409
+ case "coalesce": {
1410
+ const a = evalAgg(agg.a, rows);
1411
+ return a === null || a === undefined ? evalAgg(agg.b, rows) : a;
1412
+ }
1413
+ case "add": {
1414
+ const base = evalAgg(agg.expr, rows);
1415
+ if (base === null || base === undefined)
1416
+ return null; // SQLite: NULL + literal = NULL
1417
+ if (typeof base === "bigint")
1418
+ return base + BigInt(agg.literal);
1419
+ const n = typeof base === "number" ? base : Number(base);
1420
+ return n + agg.literal;
1421
+ }
1422
+ }
1423
+ }
1424
+ /** Compile one aggregate to a SQLite select-list expression (the SQL backend). */
1425
+ function compileAggToSql(agg, options) {
1426
+ const resolveColumn = options.resolveColumn ?? ((n) => quoteIdent(n));
1427
+ switch (agg.kind) {
1428
+ case "count":
1429
+ return agg.column === undefined ? "COUNT(*)" : `COUNT(${resolveColumn(agg.column)})`;
1430
+ case "countWhere":
1431
+ return `SUM(CASE WHEN ${compileToSqlSelect(agg.pred, options)} THEN 1 ELSE 0 END)`;
1432
+ case "max":
1433
+ return `MAX(${resolveColumn(agg.column)})`;
1434
+ case "minWhere":
1435
+ return `MIN(CASE WHEN ${compileToSqlSelect(agg.pred, options)} THEN ${resolveColumn(agg.column)} END)`;
1436
+ case "coalesce":
1437
+ return `COALESCE(${compileAggToSql(agg.a, options)}, ${compileAggToSql(agg.b, options)})`;
1438
+ case "add":
1439
+ return `(${compileAggToSql(agg.expr, options)} + ${sqlLiteral(agg.literal)})`;
1440
+ }
1441
+ }
1442
+ /** A composite group key for the TS reduce, tagged by storage class so `1`/`'1'`/NULL group distinctly
1443
+ * (matching SQLite's affinity-free grouping the parity fixtures pin), while integer `number`/`bigint`
1444
+ * of equal value group together (a driver may surface either). */
1445
+ function groupKeyPart(value) {
1446
+ if (value === null || value === undefined)
1447
+ return "\u0000null";
1448
+ if (typeof value === "bigint")
1449
+ return `\u0000int:${value}`;
1450
+ if (typeof value === "number" && Number.isInteger(value))
1451
+ return `\u0000int:${value}`;
1452
+ if (typeof value === "number")
1453
+ return `\u0000num:${value}`;
1454
+ if (typeof value === "boolean")
1455
+ return `\u0000int:${value ? 1 : 0}`;
1456
+ return `\u0000str:${String(value)}`;
1457
+ }
1458
+ /**
1459
+ * Declare a GROUP-BY rollup ONCE (ADR 0065, #468). Returns a {@link Rollup} exposing both backends (the
1460
+ * managed `*_counts` VIEW DDL and the TS group-reduce) driven from one closed spec, plus the metadata
1461
+ * the parity guard and registry need. Register it in {@link rollupRegistry} to have the runtime apply
1462
+ * its VIEW at boot and expose its name as a canonical projection.
1463
+ */
1464
+ export function defineRollup(decl) {
1465
+ assertSqlIdentifier("rollup name", decl.name);
1466
+ if (decl.groupBy.length === 0)
1467
+ throw new Error(`rollup "${decl.name}" declares no group-by columns`);
1468
+ for (const g of decl.groupBy)
1469
+ assertSqlIdentifier("group-by column", g);
1470
+ const aggNames = Object.keys(decl.aggregates).sort();
1471
+ if (aggNames.length === 0)
1472
+ throw new Error(`rollup "${decl.name}" declares no aggregates`);
1473
+ for (const a of aggNames)
1474
+ assertSqlIdentifier("aggregate column", a);
1475
+ // An aggregate column name colliding with a group-key name would emit two SELECT aliases of one name.
1476
+ const groupSet = new Set(decl.groupBy.map((g) => foldSqlIdentifier(g)));
1477
+ for (const a of aggNames) {
1478
+ if (groupSet.has(foldSqlIdentifier(a))) {
1479
+ throw new Error(`rollup "${decl.name}" aggregate "${a}" collides with a group-by column of the same name`);
1480
+ }
1481
+ }
1482
+ if (decl.where)
1483
+ assertRollupPredicate(`rollup "${decl.name}" where`, decl.where);
1484
+ for (const a of aggNames) {
1485
+ const agg = decl.aggregates[a];
1486
+ forEachAggPredicate(agg, (pred) => assertRollupPredicate(`rollup "${decl.name}" aggregate "${a}"`, pred));
1487
+ }
1488
+ // Fail-fast: when the source's output namespace is statically known — a key-`join` exposes its flat
1489
+ // `columns` map, a composed `rollup` exposes its `outputColumns` — reject a rollup that references a
1490
+ // `groupBy`/`col(...)` name absent from it. Otherwise the two backends drift silently: the SQL side
1491
+ // throws (`no mapped column` for a join; `no such column` for a rollup source) at view compilation /
1492
+ // query time, while the TS reduce reads NULL via `lookupColumn` and groups/counts against a phantom
1493
+ // column. Fold names case-insensitively to mirror the resolvers (SQLite identifier folding +
1494
+ // `lookupColumn`). A `table`/`projection` source has no statically-known namespace here, so it is
1495
+ // left to the existing per-relation resolution.
1496
+ const knownNamespaceCols = decl.source.kind === "join"
1497
+ ? Object.keys(decl.source.columns)
1498
+ : decl.source.kind === "rollup"
1499
+ ? decl.source.rollup.outputColumns
1500
+ : undefined;
1501
+ if (knownNamespaceCols) {
1502
+ const knownFolded = new Set(knownNamespaceCols.map(foldSqlIdentifier));
1503
+ const referencedCols = new Set();
1504
+ for (const g of decl.groupBy)
1505
+ referencedCols.add(g);
1506
+ if (decl.where)
1507
+ collectColumns(decl.where, referencedCols, new Map(), new Map());
1508
+ for (const a of aggNames)
1509
+ collectAggColumns(decl.aggregates[a], referencedCols);
1510
+ for (const name of referencedCols) {
1511
+ if (!knownFolded.has(foldSqlIdentifier(name))) {
1512
+ const kind = decl.source.kind === "join" ? "join source" : "rollup source";
1513
+ throw new Error(`rollup "${decl.name}" references column "${name}" absent from its ${kind} output namespace ` +
1514
+ `[${knownNamespaceCols.join(", ")}]`);
1515
+ }
1516
+ }
1517
+ }
1518
+ const outputColumns = [...decl.groupBy, ...aggNames];
1519
+ const sourceRelations = [...new Set(rollupSourceRelations(decl.source))];
1520
+ const dependencies = decl.source.kind === "rollup" ? decl.source.rollup.viewChain : [];
1521
+ // Dependency rollups first (dedup by name, preserving order); this rollup itself is appended last
1522
+ // once `self` exists (see below).
1523
+ const viewChain = [];
1524
+ const seenChain = new Set();
1525
+ for (const dep of dependencies) {
1526
+ const key = foldSqlIdentifier(dep.projectionName);
1527
+ if (!seenChain.has(key)) {
1528
+ seenChain.add(key);
1529
+ viewChain.push(dep);
1530
+ }
1531
+ }
1532
+ const sqlAggFor = (column, options) => {
1533
+ const agg = decl.aggregates[column];
1534
+ if (!agg)
1535
+ throw new Error(`rollup "${decl.name}" has no aggregate "${column}"`);
1536
+ const { resolveColumn } = compileRollupSourceSql(decl.source, resolveProjectionTableFor(options));
1537
+ return compileAggToSql(agg, { ...options, resolveColumn });
1538
+ };
1539
+ const viewDdl = (options, viewOptions) => {
1540
+ const { from, resolveColumn } = compileRollupSourceSql(decl.source, resolveProjectionTableFor(options));
1541
+ const colOptions = { ...options, resolveColumn };
1542
+ const groupSelect = decl.groupBy.map((g) => `${resolveColumn(g)} AS ${quoteIdent(g)}`);
1543
+ const aggSelect = aggNames.map((a) => `${compileAggToSql(decl.aggregates[a], colOptions)} AS ${quoteIdent(a)}`);
1544
+ const selectList = [...groupSelect, ...aggSelect].join(",\n ");
1545
+ const whereClause = decl.where ? `\nWHERE ${compileToSqlSelect(decl.where, colOptions)}` : "";
1546
+ const groupByClause = decl.groupBy.map((g) => resolveColumn(g)).join(", ");
1547
+ const createView = viewOptions?.temp ? "CREATE TEMP VIEW IF NOT EXISTS" : "CREATE VIEW IF NOT EXISTS";
1548
+ return (`${createView} ${quoteIdent(decl.name)} AS\n` +
1549
+ `SELECT\n ${selectList}\n` +
1550
+ `FROM ${from}${whereClause}\n` +
1551
+ `GROUP BY ${groupByClause};`);
1552
+ };
1553
+ const reduce = (inputs) => {
1554
+ const src = materializeRollupSource(decl.source, inputs);
1555
+ const whereFn = decl.where ? compileToFn(decl.where) : undefined;
1556
+ const filtered = whereFn ? src.filter((r) => truthy(whereFn(r))) : src;
1557
+ const groups = new Map();
1558
+ const order = [];
1559
+ for (const r of filtered) {
1560
+ const key = decl.groupBy.map((g) => groupKeyPart(lookupColumn(r, g))).join("\u0001");
1561
+ let bucket = groups.get(key);
1562
+ if (!bucket) {
1563
+ bucket = [];
1564
+ groups.set(key, bucket);
1565
+ order.push({ first: r, rows: bucket });
1566
+ }
1567
+ bucket.push(r);
1568
+ }
1569
+ return order.map(({ first, rows }) => {
1570
+ const out = Object.create(null);
1571
+ for (const g of decl.groupBy)
1572
+ out[g] = lookupColumn(first, g);
1573
+ for (const a of aggNames)
1574
+ out[a] = evalAgg(decl.aggregates[a], rows);
1575
+ return out;
1576
+ });
1577
+ };
1578
+ const fixtureColumns = () => {
1579
+ const cols = new Map();
1580
+ const ensure = (rel) => {
1581
+ let s = cols.get(rel);
1582
+ if (!s) {
1583
+ s = new Set();
1584
+ cols.set(rel, s);
1585
+ }
1586
+ return s;
1587
+ };
1588
+ // Columns referenced by this rollup's group keys, where predicate, and aggregates (as source
1589
+ // output-namespace names), then mapped onto the physical relation(s) of its source.
1590
+ const referenced = new Set();
1591
+ for (const g of decl.groupBy)
1592
+ referenced.add(g);
1593
+ if (decl.where)
1594
+ collectColumns(decl.where, referenced, new Map(), new Map());
1595
+ for (const a of aggNames)
1596
+ collectAggColumns(decl.aggregates[a], referenced);
1597
+ switch (decl.source.kind) {
1598
+ case "table":
1599
+ case "projection": {
1600
+ const rel = decl.source.kind === "table" ? decl.source.table : decl.source.projection;
1601
+ const s = ensure(rel);
1602
+ for (const c of referenced)
1603
+ s.add(c);
1604
+ break;
1605
+ }
1606
+ case "join": {
1607
+ const leftSet = ensure(decl.source.left.relation);
1608
+ const rightSet = ensure(decl.source.right.relation);
1609
+ for (const [, [side, physical]] of Object.entries(decl.source.columns)) {
1610
+ (side === "left" ? leftSet : rightSet).add(physical);
1611
+ }
1612
+ for (const p of decl.source.on) {
1613
+ leftSet.add(p.left);
1614
+ rightSet.add(p.right);
1615
+ }
1616
+ break;
1617
+ }
1618
+ case "rollup": {
1619
+ // The inner rollup provides its own output columns via its VIEW; recurse for its leaf fixtures.
1620
+ for (const [rel, s] of decl.source.rollup.fixtureColumns()) {
1621
+ const dst = ensure(rel);
1622
+ for (const c of s)
1623
+ dst.add(c);
1624
+ }
1625
+ break;
1626
+ }
1627
+ }
1628
+ return cols;
1629
+ };
1630
+ const self = {
1631
+ decl,
1632
+ projectionName: decl.name,
1633
+ groupBy: [...decl.groupBy],
1634
+ outputColumns,
1635
+ sourceRelations,
1636
+ viewChain,
1637
+ sqlAggFor,
1638
+ reduce,
1639
+ viewDdl,
1640
+ fixtureColumns,
1641
+ };
1642
+ viewChain.push(self);
1643
+ return self;
1644
+ }
1645
+ /** Walk every predicate `Expr` embedded in an aggregate (countWhere/minWhere), recursing coalesce/add. */
1646
+ function forEachAggPredicate(agg, visit) {
1647
+ switch (agg.kind) {
1648
+ case "countWhere":
1649
+ visit(agg.pred);
1650
+ return;
1651
+ case "minWhere":
1652
+ visit(agg.pred);
1653
+ return;
1654
+ case "coalesce":
1655
+ forEachAggPredicate(agg.a, visit);
1656
+ forEachAggPredicate(agg.b, visit);
1657
+ return;
1658
+ case "add":
1659
+ forEachAggPredicate(agg.expr, visit);
1660
+ return;
1661
+ default:
1662
+ return;
1663
+ }
1664
+ }
1665
+ function resolveProjectionTableFor(options) {
1666
+ return options?.resolveProjectionTable ?? ((n) => projectionRegistry.sqlTableFor(n));
1667
+ }
1668
+ // ────────────────────────────────────────── Rollup registry ──────────────────────────────────────
1669
+ /**
1670
+ * The rollup registry (ADR 0065, #468) — the GROUP-BY sibling of {@link ReadModelRegistry}. Registering
1671
+ * a {@link Rollup} both (a) queues its managed VIEW for boot provisioning (in dependency order) and (b)
1672
+ * registers its name as a canonical projection so read-model rollup lookups resolve. Idempotent: a
1673
+ * re-register whose VIEW DDL matches is a no-op; a conflicting redefinition throws.
1674
+ */
1675
+ export class RollupRegistry {
1676
+ #byName = new Map();
1677
+ register(rollup, projections = projectionRegistry) {
1678
+ const key = foldSqlIdentifier(rollup.projectionName);
1679
+ const existing = this.#byName.get(key);
1680
+ if (existing) {
1681
+ if (existing.projectionName !== rollup.projectionName) {
1682
+ throw new Error(`rollup "${rollup.projectionName}" already registered as "${existing.projectionName}"; SQLite ` +
1683
+ `folds identifiers case-insensitively, so names differing only in case denote one VIEW`);
1684
+ }
1685
+ if (existing.viewDdl() !== rollup.viewDdl()) {
1686
+ throw new Error(`rollup "${rollup.projectionName}" already registered with a different definition`);
1687
+ }
1688
+ return;
1689
+ }
1690
+ this.#byName.set(key, rollup);
1691
+ // Expose the rollup's VIEW as a canonical projection so `exists(...)`/rollup lookups resolve to it.
1692
+ projections.register({ name: rollup.projectionName });
1693
+ }
1694
+ all() {
1695
+ return [...this.#byName.values()];
1696
+ }
1697
+ get(name) {
1698
+ return this.#byName.get(foldSqlIdentifier(name));
1699
+ }
1700
+ /** Apply every registered rollup's managed VIEW to a database, dependency-ordered (a composed rollup
1701
+ * after the rollup it reads) and MANAGED (drop+recreate `main.`-qualified, mirroring
1702
+ * {@link ReadModelRegistry.ensureViews}). Safe to call repeatedly. */
1703
+ ensureViews(db, options) {
1704
+ const applied = new Set();
1705
+ const applyChain = (rollup) => {
1706
+ for (const r of rollup.viewChain) {
1707
+ const key = foldSqlIdentifier(r.projectionName);
1708
+ if (applied.has(key))
1709
+ continue;
1710
+ applied.add(key);
1711
+ db.exec(`DROP VIEW IF EXISTS main.${quoteIdent(r.projectionName)};`);
1712
+ db.exec(r.viewDdl(options));
1713
+ }
1714
+ };
1715
+ for (const rollup of this.#byName.values())
1716
+ applyChain(rollup);
1717
+ }
1718
+ clear() {
1719
+ this.#byName.clear();
1720
+ }
1721
+ }
1722
+ /** Process-wide rollup registry — the concrete registration point apps/sidecars import and call. */
1723
+ export const rollupRegistry = new RollupRegistry();
1724
+ /**
1725
+ * The framework PARITY GUARD for grouped rollups (#468) — the GROUP-BY sibling of
1726
+ * {@link assertReadModelParity}. For each input-set it materialises the rollup's managed VIEW (and any
1727
+ * composed dependency VIEWs) over throwaway TEMP fixtures built from the leaf rows, reads the SQL-derived
1728
+ * group rows, computes the TS `reduce` for the same inputs, and asserts they agree as a SET keyed by the
1729
+ * group columns (GROUP BY output order is unspecified, so rows are matched by key, not position).
1730
+ */
1731
+ export function assertRollupParity(rollup, db, sampleSets, options = {}) {
1732
+ const onMismatch = options.onMismatch ?? defaultOnMismatch;
1733
+ const fixtureCols = rollup.fixtureColumns();
1734
+ const relations = [...fixtureCols.keys()];
1735
+ // Fixtures are keyed by LOGICAL relation name (what `RollupInputs`/`reduce` read), but the managed VIEW
1736
+ // reads the PHYSICAL table `compileRollupSourceSql` resolves each projection/join relation to. Build the
1737
+ // TEMP tables under those physical names (via the same resolver the VIEW is compiled with, line below)
1738
+ // so the VIEW's `FROM` finds them; the column/row maps stay logical-keyed.
1739
+ const resolveTable = options.sql?.resolveProjectionTable ?? ((n) => projectionRegistry.sqlTableFor(n));
1740
+ const physicalTables = rollupSourcePhysicalTables(rollup.decl.source, resolveTable);
1741
+ const physical = (rel) => physicalTables.get(rel) ?? rel;
1742
+ const createFixture = (table, cols) => {
1743
+ const colList = [...cols];
1744
+ const ddl = colList.length
1745
+ ? `CREATE TEMP TABLE ${quoteIdent(table)} (${colList.map((c) => quoteIdent(c)).join(", ")});`
1746
+ : `CREATE TEMP TABLE ${quoteIdent(table)} (_placeholder);`;
1747
+ db.exec(ddl);
1748
+ };
1749
+ const insertRow = (table, row) => {
1750
+ const keys = Object.keys(row);
1751
+ if (keys.length === 0) {
1752
+ db.run(`INSERT INTO ${quoteIdent(table)} DEFAULT VALUES;`);
1753
+ return;
1754
+ }
1755
+ const placeholders = keys.map(() => "?").join(", ");
1756
+ db.run(`INSERT INTO ${quoteIdent(table)} (${keys.map((k) => quoteIdent(k)).join(", ")}) VALUES (${placeholders});`, keys.map((k) => normaliseSqlValue(row[k])));
1757
+ };
1758
+ const dropFixtures = () => {
1759
+ // Drop dependent VIEWs before their base relations; drop the whole chain plus leaf fixtures.
1760
+ for (let i = rollup.viewChain.length - 1; i >= 0; i--) {
1761
+ db.exec(`DROP VIEW IF EXISTS temp.${quoteIdent(rollup.viewChain[i].projectionName)};`);
1762
+ }
1763
+ for (const rel of relations)
1764
+ db.exec(`DROP TABLE IF EXISTS temp.${quoteIdent(physical(rel))};`);
1765
+ };
1766
+ // Widen each leaf fixture with any extra column keys present in the sample rows.
1767
+ const cols = new Map();
1768
+ for (const [rel, set] of fixtureCols)
1769
+ cols.set(rel, new Set(set));
1770
+ for (const inputs of sampleSets) {
1771
+ for (const [rel, rows] of Object.entries(inputs)) {
1772
+ const set = cols.get(rel);
1773
+ if (!set)
1774
+ continue;
1775
+ for (const r of rows)
1776
+ for (const k of Object.keys(r))
1777
+ set.add(k);
1778
+ }
1779
+ }
1780
+ dropFixtures();
1781
+ try {
1782
+ for (const rel of relations)
1783
+ createFixture(physical(rel), cols.get(rel) ?? new Set());
1784
+ // Materialise the VIEW chain (dependencies first) as TEMP views over the leaf fixtures.
1785
+ for (const r of rollup.viewChain)
1786
+ db.exec(r.viewDdl(options.sql, { temp: true }));
1787
+ const outputList = rollup.outputColumns.map((c) => quoteIdent(c)).join(", ");
1788
+ sampleSets.forEach((inputs, idx) => {
1789
+ for (const rel of relations)
1790
+ db.run(`DELETE FROM ${quoteIdent(physical(rel))};`);
1791
+ for (const [rel, rows] of Object.entries(inputs)) {
1792
+ if (!cols.has(rel))
1793
+ continue;
1794
+ for (const r of rows)
1795
+ insertRow(physical(rel), r);
1796
+ }
1797
+ const sqlRows = db.all(`SELECT ${outputList} FROM ${quoteIdent(rollup.projectionName)};`);
1798
+ const tsRows = rollup.reduce(inputs);
1799
+ const keyOf = (row) => rollup.groupBy.map((g) => groupKeyPart(normaliseSqlValue(lookupColumn(row, g)))).join("\u0001");
1800
+ const sqlByKey = new Map();
1801
+ for (const r of sqlRows)
1802
+ sqlByKey.set(keyOf(r), r);
1803
+ const tsByKey = new Map();
1804
+ for (const r of tsRows)
1805
+ tsByKey.set(keyOf(r), r);
1806
+ if (sqlByKey.size !== tsByKey.size) {
1807
+ onMismatch(`rollup parity mismatch in "${rollup.projectionName}" (sample #${idx}): ` +
1808
+ `SQL produced ${sqlByKey.size} group(s) vs TS ${tsByKey.size}`);
1809
+ }
1810
+ for (const [key, tsRow] of tsByKey) {
1811
+ const sqlRow = sqlByKey.get(key);
1812
+ if (!sqlRow) {
1813
+ onMismatch(`rollup parity mismatch in "${rollup.projectionName}" (sample #${idx}): TS group ${key} missing from SQL`);
1814
+ continue;
1815
+ }
1816
+ for (const c of rollup.outputColumns) {
1817
+ const sqlValue = normaliseSqlValue(sqlRow[c]);
1818
+ const tsValue = normaliseSqlValue(tsRow[c]);
1819
+ if (!Object.is(sqlValue, tsValue)) {
1820
+ onMismatch(`rollup parity mismatch in "${rollup.projectionName}".${c} (sample #${idx}, group ${key}): ` +
1821
+ `SQL=${formatParityValue(sqlValue)} vs TS=${formatParityValue(tsValue)}`);
1822
+ }
1823
+ }
1824
+ }
1825
+ });
1826
+ }
1827
+ finally {
1828
+ dropFixtures();
1829
+ }
1830
+ }