@nanobpm/urban 0.81.0 → 0.82.0

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.
@@ -66,10 +66,12 @@ export type Expr =
66
66
  | LitExpr
67
67
  | ColExpr
68
68
  | ProjColExpr
69
+ | RollupColExpr
69
70
  | CompareExpr
70
71
  | AndExpr
71
72
  | OrExpr
72
73
  | NotExpr
74
+ | IsNullExpr
73
75
  | CaseExpr
74
76
  | ExistsExpr;
75
77
 
@@ -85,6 +87,13 @@ export interface ProjColExpr {
85
87
  readonly kind: "pcol";
86
88
  readonly name: string;
87
89
  }
90
+ export interface RollupColExpr {
91
+ readonly kind: "rcol";
92
+ /** The read-model rollup-lookup alias this column is read from (see {@link RollupLookupDecl}). */
93
+ readonly lookup: string;
94
+ /** The rollup output column referenced (a column of the looked-up `*_counts` rollup row). */
95
+ readonly name: string;
96
+ }
88
97
  export interface CompareExpr {
89
98
  readonly kind: "compare";
90
99
  readonly op: CompareOp;
@@ -103,6 +112,10 @@ export interface NotExpr {
103
112
  readonly kind: "not";
104
113
  readonly expr: Expr;
105
114
  }
115
+ export interface IsNullExpr {
116
+ readonly kind: "isNull";
117
+ readonly expr: Expr;
118
+ }
106
119
  export interface WhenClause {
107
120
  readonly when: Expr;
108
121
  readonly then: Expr;
@@ -128,6 +141,13 @@ export const lit = (value: Literal): LitExpr => ({ kind: "lit", value });
128
141
  export const col = (name: string): ColExpr => ({ kind: "col", name });
129
142
  /** A reference to a correlated PROJECTION-row column (only valid inside {@link exists}). */
130
143
  export const pcol = (name: string): ProjColExpr => ({ kind: "pcol", name });
144
+ /**
145
+ * A reference to a column of a key-correlated ROLLUP LOOKUP row (see {@link ReadModelDecl.lookups}).
146
+ * `rcol("c", "prs_opened")` reads the `prs_opened` column of the rollup joined under alias `"c"` for
147
+ * the base row (compiling to `LEFT JOIN <rollup> ON <key>` on the SQL side / the matching group row on
148
+ * the TS side). Only valid in a {@link defineReadModel} whose `lookups` declares that alias.
149
+ */
150
+ export const rcol = (lookup: string, name: string): RollupColExpr => ({ kind: "rcol", lookup, name });
131
151
 
132
152
  const cmp = (op: CompareOp) => (left: Expr, right: Expr): CompareExpr => ({ kind: "compare", op, left, right });
133
153
  export const eq = cmp("eq");
@@ -140,6 +160,10 @@ export const gte = cmp("gte");
140
160
  export const and = (...clauses: Expr[]): AndExpr => ({ kind: "and", clauses });
141
161
  export const or = (...clauses: Expr[]): OrExpr => ({ kind: "or", clauses });
142
162
  export const not = (expr: Expr): NotExpr => ({ kind: "not", expr });
163
+ /** `<expr> IS NULL` — a null test (1/0 in SQL; `value == null` in TS). `not(isNull(x))` is `IS NOT NULL`. */
164
+ export const isNull = (expr: Expr): IsNullExpr => ({ kind: "isNull", expr });
165
+ /** Sugar for `not(isNull(expr))` — `<expr> IS NOT NULL`. */
166
+ export const isNotNull = (expr: Expr): NotExpr => ({ kind: "not", expr: { kind: "isNull", expr } });
143
167
 
144
168
  /** Build a `CASE WHEN … THEN … [WHEN …] ELSE … END` expression. */
145
169
  export const caseWhen = (whens: WhenClause[], otherwise: Expr): CaseExpr => ({
@@ -249,6 +273,19 @@ export interface SqlCompileOptions {
249
273
  readonly baseAlias?: string;
250
274
  /** Resolve a projection NAME to its physical SQL table/view. Defaults to {@link projectionRegistry}. */
251
275
  readonly resolveProjectionTable?: (name: string) => string;
276
+ /**
277
+ * Resolve a {@link col} reference to its qualified SQL expression, overriding the default
278
+ * `<baseAlias>."name"`. A rollup over a key-JOIN uses this to map a flat output-column name onto the
279
+ * correct joined relation (`col("pr_status")` → `"r"."status"`) so the closed {@link Expr} predicate
280
+ * machinery is reused unchanged across single-relation and join sources.
281
+ */
282
+ readonly resolveColumn?: (name: string) => string;
283
+ /**
284
+ * Resolve an {@link rcol} reference (a key-correlated rollup-lookup column) to its qualified SQL
285
+ * expression. {@link defineReadModel} supplies this from its `lookups` so the lookup's NULL default
286
+ * (a LEFT-JOIN miss) compiles to `COALESCE(<alias>."name", <default>)`.
287
+ */
288
+ readonly resolveRollupColumn?: (lookup: string, name: string) => string;
252
289
  }
253
290
 
254
291
  const DEFAULT_BASE_ALIAS = "base";
@@ -351,12 +388,17 @@ export function compileToSqlSelect(expr: Expr, options: SqlCompileOptions = {}):
351
388
  case "lit":
352
389
  return sqlLiteral(node.value);
353
390
  case "col":
354
- return `${quoteIdent(baseAlias)}.${quoteIdent(node.name)}`;
391
+ return options.resolveColumn ? options.resolveColumn(node.name) : `${quoteIdent(baseAlias)}.${quoteIdent(node.name)}`;
355
392
  case "pcol":
356
393
  if (!projTable) {
357
394
  throw new Error(`pcol("${node.name}") used outside an EXISTS projection context`);
358
395
  }
359
396
  return `${quoteIdent(projTable)}.${quoteIdent(node.name)}`;
397
+ case "rcol":
398
+ if (!options.resolveRollupColumn) {
399
+ throw new Error(`rcol("${node.lookup}", "${node.name}") used outside a rollup-lookup context`);
400
+ }
401
+ return options.resolveRollupColumn(node.lookup, node.name);
360
402
  case "compare":
361
403
  // SQLite comparisons against NULL yield NULL, but the TS backend (compareValues) collapses any
362
404
  // nullish operand to `false`/0. COALESCE the SQL result to 0 so both backends agree on NULL inputs.
@@ -375,6 +417,9 @@ export function compileToSqlSelect(expr: Expr, options: SqlCompileOptions = {}):
375
417
  // `NOT NULL` is NULL in SQLite, while the TS backend returns `!truthy(NULL)` → true. Compile as
376
418
  // `NOT COALESCE(x, 0)` so `not(NULL)` is 1 in both backends under the shared "NULL → false" rule.
377
419
  return `(NOT COALESCE(${walk(node.expr, projTable, existsDepth)}, 0))`;
420
+ case "isNull":
421
+ // `x IS NULL` yields 1/0 in SQLite (never NULL), matching the TS backend's `value == null` test.
422
+ return `(${walk(node.expr, projTable, existsDepth)} IS NULL)`;
378
423
  case "case": {
379
424
  const whens = node.whens
380
425
  .map((w) => `WHEN ${walk(w.when, projTable, existsDepth)} THEN ${walk(w.then, projTable, existsDepth)}`)
@@ -396,8 +441,18 @@ export function compileToSqlSelect(expr: Expr, options: SqlCompileOptions = {}):
396
441
 
397
442
  // ─────────────────────────────────────────── TS backend ──────────────────────────────────────────
398
443
 
444
+ /** The resolved single rollup-lookup rows for one evaluation, keyed by the FOLDED (case-insensitive,
445
+ * SQLite-folded) lookup alias — see {@link rcol}, whose `node.lookup` is folded the same way on read.
446
+ * Every declared lookup alias maps to exactly one resolved row: the matched rollup group with its
447
+ * missing/NULL columns already filled from the lookup's declared defaults (the TS twin of the VIEW's
448
+ * `COALESCE(alias.col, default)`). A LEFT-JOIN miss is therefore represented as a defaults/NULL-filled
449
+ * row — NOT an absent alias — so `rcol` always reads a stable object. Framework resolvers
450
+ * ({@link ReadModel.evaluate}, the parity guard) populate this; a raw `fnFor` caller on a model with
451
+ * lookups must pass the resolved map. */
452
+ export type LookupRows = Record<string, Record<string, unknown>>;
453
+
399
454
  /** The compiled runtime derivation: same value as the SQL VIEW, computed in-process. */
400
- export type DerivationFn = (baseRow: BaseRow, projections?: ProjectionRows) => unknown;
455
+ export type DerivationFn = (baseRow: BaseRow, projections?: ProjectionRows, lookups?: LookupRows) => unknown;
401
456
 
402
457
  function compareValues(op: CompareOp, left: unknown, right: unknown): boolean {
403
458
  // SQL comparisons against NULL yield NULL, which is falsy in WHERE/CASE-WHEN contexts. Mirror that:
@@ -509,7 +564,13 @@ function truthy(value: unknown): boolean {
509
564
  * named projection's rows (the TS side of the projection-name seam). Both backends walk the SAME AST.
510
565
  */
511
566
  export function compileToFn(expr: Expr): DerivationFn {
512
- const evalNode = (node: Expr, baseRow: BaseRow, projections: ProjectionRows, projRow?: Record<string, unknown>): unknown => {
567
+ const evalNode = (
568
+ node: Expr,
569
+ baseRow: BaseRow,
570
+ projections: ProjectionRows,
571
+ lookups: LookupRows,
572
+ projRow?: Record<string, unknown>,
573
+ ): unknown => {
513
574
  switch (node.kind) {
514
575
  case "lit":
515
576
  return node.value;
@@ -518,33 +579,58 @@ export function compileToFn(expr: Expr): DerivationFn {
518
579
  case "pcol":
519
580
  if (!projRow) throw new Error(`pcol("${node.name}") evaluated outside an EXISTS projection context`);
520
581
  return lookupColumn(projRow, node.name);
582
+ case "rcol": {
583
+ // Read the resolved row by the FOLDED alias: lookup aliases are identifier-validated and deduped
584
+ // case-insensitively (SQLite folding), and the SQL `resolveRollupColumn` resolves `node.lookup`
585
+ // through `foldSqlIdentifier` too, so a raw case-sensitive `lookups[node.lookup]` would let
586
+ // `rcol("C", …)` pass validation against a lookup declared `as: "c"` yet fail here at runtime.
587
+ // Read by OWN property (via {@link readOwn}) so an identifier-legal `__proto__` alias over a bare
588
+ // `fnFor` caller's plain `{}` can't resolve `Object.prototype` (truthy) and bypass the guard below.
589
+ const row = readOwn(lookups, foldSqlIdentifier(node.lookup));
590
+ // Every declared lookup alias is resolved to a full row up front (a LEFT-JOIN miss becomes a
591
+ // defaults/NULL-filled row, never an absent alias — see LookupRows). A missing alias here means a
592
+ // raw `fnFor` caller skipped lookup resolution; fail loudly rather than silently reading NULL.
593
+ if (!row) {
594
+ throw new Error(
595
+ `rcol("${node.lookup}", "${node.name}") evaluated without a resolved lookup row — ` +
596
+ `use ReadModel.evaluate() (or pass the model's resolved lookups) instead of a bare fnFor call`,
597
+ );
598
+ }
599
+ return lookupColumn(row, node.name);
600
+ }
521
601
  case "compare":
522
602
  return compareValues(
523
603
  node.op,
524
- evalNode(node.left, baseRow, projections, projRow),
525
- evalNode(node.right, baseRow, projections, projRow),
604
+ evalNode(node.left, baseRow, projections, lookups, projRow),
605
+ evalNode(node.right, baseRow, projections, lookups, projRow),
526
606
  );
527
607
  case "and":
528
- return node.clauses.every((c) => truthy(evalNode(c, baseRow, projections, projRow)));
608
+ return node.clauses.every((c) => truthy(evalNode(c, baseRow, projections, lookups, projRow)));
529
609
  case "or":
530
- return node.clauses.some((c) => truthy(evalNode(c, baseRow, projections, projRow)));
610
+ return node.clauses.some((c) => truthy(evalNode(c, baseRow, projections, lookups, projRow)));
531
611
  case "not":
532
- return !truthy(evalNode(node.expr, baseRow, projections, projRow));
612
+ return !truthy(evalNode(node.expr, baseRow, projections, lookups, projRow));
613
+ case "isNull": {
614
+ const v = evalNode(node.expr, baseRow, projections, lookups, projRow);
615
+ return v === null || v === undefined;
616
+ }
533
617
  case "case": {
534
618
  for (const w of node.whens) {
535
- if (truthy(evalNode(w.when, baseRow, projections, projRow))) {
536
- return evalNode(w.then, baseRow, projections, projRow);
619
+ if (truthy(evalNode(w.when, baseRow, projections, lookups, projRow))) {
620
+ return evalNode(w.then, baseRow, projections, lookups, projRow);
537
621
  }
538
622
  }
539
- return evalNode(node.else, baseRow, projections, projRow);
623
+ return evalNode(node.else, baseRow, projections, lookups, projRow);
540
624
  }
541
625
  case "exists": {
542
- const rows = projections[node.projection] ?? [];
543
- return rows.some((r) => truthy(evalNode(node.where, baseRow, projections, r)));
626
+ // Read by OWN property so a projection literally named `__proto__` over a bare `fnFor` caller's
627
+ // plain `{}` resolves to no rows (empty EXISTS) instead of the inherited `Object.prototype`.
628
+ const rows = readOwn(projections, node.projection) ?? [];
629
+ return rows.some((r) => truthy(evalNode(node.where, baseRow, projections, lookups, r)));
544
630
  }
545
631
  }
546
632
  };
547
- return (baseRow, projections = {}) => evalNode(expr, baseRow, projections);
633
+ return (baseRow, projections = {}, lookups = {}) => evalNode(expr, baseRow, projections, lookups);
548
634
  }
549
635
 
550
636
  /** Resolve a column value the way SQLite resolves an identifier: an exact-key match first, then a
@@ -563,6 +649,37 @@ export function lookupColumn(row: Record<string, unknown>, name: string): unknow
563
649
  return null;
564
650
  }
565
651
 
652
+ /** Read a dictionary entry by OWN property only. A model-controlled identifier (a lookup alias or
653
+ * projection name) reaching a bare `fnFor` caller's plain-`{}` dictionary must not resolve an inherited
654
+ * prototype member: `dict["__proto__"]` / `dict["toString"]` on a `{}` return truthy `Object.prototype` /
655
+ * function values, which would slip past a `?? default` / missing-key guard and read from the prototype
656
+ * chain. The canonical resolved dictionaries are `Object.create(null)`, but this keeps the bare-caller
657
+ * path safe too (the sibling of the OWN-key checks in {@link lookupColumn}/{@link resolveCandidateRows}). */
658
+ function readOwn<T>(dict: Record<string, T>, key: string): T | undefined {
659
+ return Object.hasOwn(dict, key) ? dict[key] : undefined;
660
+ }
661
+
662
+ /** Read a rollup lookup's candidate rows from the caller-supplied `lookupRows` dictionary by its alias,
663
+ * resolved the way SQLite folds identifiers: an exact OWN-key match first, then a case-insensitive
664
+ * OWN-key fallback (the sibling of {@link lookupColumn}). Lookup aliases are case-insensitive SQL
665
+ * identifiers — declared/deduped via `foldSqlIdentifier`, and `rcol` reads them folded — so candidates
666
+ * supplied under a different casing (e.g. `{ C: [...] }` for an alias declared `c`) must still be found;
667
+ * a raw case-sensitive `lookupRows[alias]` would silently treat them as "no candidates" and NULL/default-
668
+ * fill, drifting from the folded treatment everywhere else. OWN-key checks (not the prototype-walking
669
+ * `in`) also keep an identifier-legal `__proto__` alias from resolving to `Object.prototype` (inherited,
670
+ * non-array) and throwing in the caller's `.filter(...)`. */
671
+ function resolveCandidateRows(
672
+ lookupRows: Record<string, ReadonlyArray<Record<string, unknown>>>,
673
+ alias: string,
674
+ ): ReadonlyArray<Record<string, unknown>> {
675
+ if (Object.hasOwn(lookupRows, alias)) return lookupRows[alias];
676
+ const folded = foldSqlIdentifier(alias);
677
+ for (const key of Object.keys(lookupRows)) {
678
+ if (foldSqlIdentifier(key) === folded) return lookupRows[key];
679
+ }
680
+ return [];
681
+ }
682
+
566
683
  // ───────────────────────────────────────── defineReadModel ───────────────────────────────────────
567
684
 
568
685
  /** The declarative read-model input passed to {@link defineReadModel}. */
@@ -581,6 +698,35 @@ export interface ReadModelDecl {
581
698
  * ones. Defaults to true (a read model is usually the base row PLUS its derived columns).
582
699
  */
583
700
  readonly selectBaseColumns?: boolean;
701
+ /**
702
+ * Optional key-correlated ROLLUP LOOKUPS (ADR 0065, #468). Each declares a `LEFT JOIN <rollup> ON
703
+ * <key>` so a derived column can read the rollup's `*_counts` columns for the base row via
704
+ * {@link rcol}. This is the per-row half of the layering: the aggregate is single-sourced in
705
+ * {@link defineRollup}; the read model consumes its columns for a `CASE`-style per-row signal (the
706
+ * `plan_delivery`/`plan_wave_progress` shape). The join `on` must map the rollup's FULL group key so
707
+ * the lookup is single-valued (no fan-out of base rows).
708
+ */
709
+ readonly lookups?: readonly RollupLookupDecl[];
710
+ }
711
+
712
+ /** One key-correlated rollup lookup on a {@link defineReadModel} (see {@link ReadModelDecl.lookups}). */
713
+ export interface RollupLookupDecl {
714
+ /** The alias `rcol(alias, …)` reads this lookup's columns under (a SQL identifier). */
715
+ readonly as: string;
716
+ /** The rollup whose `*_counts` VIEW is joined (its `name` is the physical relation, its columns the
717
+ * readable set). Pass the {@link Rollup} object so the read model can validate referenced columns. */
718
+ readonly rollup: Rollup;
719
+ /**
720
+ * The equi-join key: each `{ base, rollup }` pair equates a base column to a rollup group-key column.
721
+ * Must cover the rollup's ENTIRE `groupBy` so the joined row is unique (≤1 match per base row).
722
+ */
723
+ readonly on: ReadonlyArray<{ readonly base: string; readonly rollup: string }>;
724
+ /**
725
+ * Optional per-column defaults substituted when the join misses (no matching rollup group) or the
726
+ * rollup value is NULL — the `COALESCE(<alias>."col", <default>)` in the hand-authored VIEWs. Applied
727
+ * identically in both backends so an epic with no PRs reads `0` rather than NULL.
728
+ */
729
+ readonly defaults?: Record<string, Literal>;
584
730
  }
585
731
 
586
732
  /** A compiled read model: the declaration plus its derived SQL, derived functions, and managed VIEW. */
@@ -592,8 +738,25 @@ export interface ReadModel {
592
738
  sqlSelectFor(column: string, options?: SqlCompileOptions): string;
593
739
  /** The TS derivation function for one derived column. */
594
740
  fnFor(column: string): DerivationFn;
595
- /** Evaluate ALL derived columns for a base row in-process (the TS backend of the whole model). */
596
- evaluate(baseRow: BaseRow, projections?: ProjectionRows): Record<string, unknown>;
741
+ /** Evaluate ALL derived columns for a base row in-process (the TS backend of the whole model). The
742
+ * optional `lookupRows` supplies each rollup-lookup's candidate rows (keyed by lookup alias); the
743
+ * model resolves the single matching group row per lookup (applying declared defaults) before
744
+ * evaluating {@link rcol} references. */
745
+ evaluate(
746
+ baseRow: BaseRow,
747
+ projections?: ProjectionRows,
748
+ lookupRows?: Record<string, ReadonlyArray<Record<string, unknown>>>,
749
+ ): Record<string, unknown>;
750
+ /** Resolve each declared rollup lookup's single matching group row for `baseRow` (match on the join
751
+ * keys, fill declared defaults), producing the {@link LookupRows} a {@link DerivationFn} reads via
752
+ * {@link rcol}. The single source of the lookup-resolution rule: {@link evaluate} and the parity guard
753
+ * both call this so they can never drift. Throws if a lookup's candidate set yields MORE than one
754
+ * match for the join key — the lookup is required to be single-valued (its join covers the rollup's
755
+ * full group key), so multiple matches would fan out in SQL while TS silently picked one. */
756
+ resolveLookups(
757
+ baseRow: BaseRow,
758
+ lookupRows: Record<string, ReadonlyArray<Record<string, unknown>>>,
759
+ ): LookupRows;
597
760
  /** The managed `CREATE VIEW` DDL (seam (a) applies this at boot). Pass `{ temp: true }` to emit a
598
761
  * `CREATE TEMP VIEW` in SQLite's TEMP schema (used by the parity guard so it never touches the
599
762
  * application's real objects); the SELECT body is byte-identical either way. */
@@ -623,6 +786,9 @@ function collectProjectionNames(expr: Expr, into: Set<string>): void {
623
786
  case "not":
624
787
  collectProjectionNames(expr.expr, into);
625
788
  return;
789
+ case "isNull":
790
+ collectProjectionNames(expr.expr, into);
791
+ return;
626
792
  case "case":
627
793
  for (const w of expr.whens) {
628
794
  collectProjectionNames(w.when, into);
@@ -635,13 +801,15 @@ function collectProjectionNames(expr: Expr, into: Set<string>): void {
635
801
  }
636
802
  }
637
803
 
638
- /** Collect the base-row columns (`col`) and per-projection columns (`pcol`, inside `exists`) an
639
- * expression references. Used by the parity guard to build fixture tables carrying every referenced
640
- * column even when a sample supplies no rows for a projection. */
804
+ /** Collect the base-row columns (`col`), per-projection columns (`pcol`, inside `exists`), and
805
+ * per-rollup-lookup columns (`rcol`) an expression references. Used by the parity guard to build
806
+ * fixture tables carrying every referenced column even when a sample supplies no rows for a
807
+ * projection/lookup. */
641
808
  function collectColumns(
642
809
  expr: Expr,
643
810
  baseCols: Set<string>,
644
811
  projCols: Map<string, Set<string>>,
812
+ lookupCols: Map<string, Set<string>>,
645
813
  currentProjection?: string,
646
814
  ): void {
647
815
  switch (expr.kind) {
@@ -655,27 +823,49 @@ function collectColumns(
655
823
  projCols.set(currentProjection, set);
656
824
  }
657
825
  return;
826
+ case "rcol": {
827
+ // Canonicalize the bucket key case-insensitively (SQLite folds lookup aliases): a derivation using
828
+ // rcol("C", …) must land in the SAME bucket as a lookup declared/seeded `as: "c"`, else the referenced
829
+ // column is recorded under a divergent key and both consumers (the parity guard and the SQL TEMP
830
+ // fixture builder, which fold on read) drop it — producing missing fixture columns / false parity
831
+ // failures. Reuse an existing key whose folded form matches (e.g. the pre-seeded declared alias).
832
+ const folded = foldSqlIdentifier(expr.lookup);
833
+ let key = expr.lookup;
834
+ for (const k of lookupCols.keys()) {
835
+ if (foldSqlIdentifier(k) === folded) {
836
+ key = k;
837
+ break;
838
+ }
839
+ }
840
+ const set = lookupCols.get(key) ?? new Set<string>();
841
+ set.add(expr.name);
842
+ lookupCols.set(key, set);
843
+ return;
844
+ }
658
845
  case "compare":
659
- collectColumns(expr.left, baseCols, projCols, currentProjection);
660
- collectColumns(expr.right, baseCols, projCols, currentProjection);
846
+ collectColumns(expr.left, baseCols, projCols, lookupCols, currentProjection);
847
+ collectColumns(expr.right, baseCols, projCols, lookupCols, currentProjection);
661
848
  return;
662
849
  case "and":
663
850
  case "or":
664
- for (const c of expr.clauses) collectColumns(c, baseCols, projCols, currentProjection);
851
+ for (const c of expr.clauses) collectColumns(c, baseCols, projCols, lookupCols, currentProjection);
665
852
  return;
666
853
  case "not":
667
- collectColumns(expr.expr, baseCols, projCols, currentProjection);
854
+ collectColumns(expr.expr, baseCols, projCols, lookupCols, currentProjection);
855
+ return;
856
+ case "isNull":
857
+ collectColumns(expr.expr, baseCols, projCols, lookupCols, currentProjection);
668
858
  return;
669
859
  case "case":
670
860
  for (const w of expr.whens) {
671
- collectColumns(w.when, baseCols, projCols, currentProjection);
672
- collectColumns(w.then, baseCols, projCols, currentProjection);
861
+ collectColumns(w.when, baseCols, projCols, lookupCols, currentProjection);
862
+ collectColumns(w.then, baseCols, projCols, lookupCols, currentProjection);
673
863
  }
674
- collectColumns(expr.else, baseCols, projCols, currentProjection);
864
+ collectColumns(expr.else, baseCols, projCols, lookupCols, currentProjection);
675
865
  return;
676
866
  case "exists": {
677
867
  if (!projCols.has(expr.projection)) projCols.set(expr.projection, new Set<string>());
678
- collectColumns(expr.where, baseCols, projCols, expr.projection);
868
+ collectColumns(expr.where, baseCols, projCols, lookupCols, expr.projection);
679
869
  return;
680
870
  }
681
871
  default:
@@ -718,10 +908,97 @@ export function defineReadModel(decl: ReadModelDecl): ReadModel {
718
908
  for (const c of columns) collectProjectionNames(decl.derive[c], projSet);
719
909
  for (const name of projSet) assertSqlIdentifier("projection name", name);
720
910
 
911
+ // ── Rollup lookups (#468): validate the LEFT-JOIN key-lookups and build the alias → metadata map the
912
+ // SQL `rcol` resolver and the TS row resolver share, so both backends read the same rollup columns. ──
913
+ const lookups = decl.lookups ?? [];
914
+ // Single source of truth for the base-alias/lookup-alias collision guard. A `baseAlias` that folds
915
+ // (SQLite compares identifiers case-insensitively) to a rollup lookup alias would make the managed
916
+ // VIEW reuse one identifier for both the base table and a joined rollup — ambiguous/invalid SQL. This
917
+ // runs both at definition time against the DEFAULT base alias AND inside `viewDdl` against a
918
+ // caller-supplied `baseAlias` override, which the definition-time check can't see (#468).
919
+ const assertBaseAliasDistinctFromLookups = (baseAlias: string): void => {
920
+ const folded = foldSqlIdentifier(baseAlias);
921
+ for (const lk of lookups) {
922
+ if (foldSqlIdentifier(lk.as) === folded) {
923
+ throw new Error(
924
+ `read model "${decl.name}" rollup lookup alias "${lk.as}" collides with the base alias "${baseAlias}"; choose another`,
925
+ );
926
+ }
927
+ }
928
+ };
929
+ const lookupByAlias = new Map<string, RollupLookupDecl>();
930
+ for (const lk of lookups) {
931
+ assertSqlIdentifier("rollup lookup alias", lk.as);
932
+ const key = foldSqlIdentifier(lk.as);
933
+ if (lookupByAlias.has(key)) {
934
+ throw new Error(`read model "${decl.name}" declares two rollup lookups under alias "${lk.as}"`);
935
+ }
936
+ if (lk.on.length === 0) throw new Error(`read model "${decl.name}" rollup lookup "${lk.as}" has an empty join key`);
937
+ for (const pair of lk.on) {
938
+ assertSqlIdentifier("lookup base column", pair.base);
939
+ assertSqlIdentifier("lookup rollup column", pair.rollup);
940
+ }
941
+ // The join must cover the rollup's FULL group key, so the joined row is single-valued (no fan-out
942
+ // of base rows). Compare the on-keys' rollup side against the rollup's declared groupBy set.
943
+ const onRollupKeys = new Set(lk.on.map((p) => foldSqlIdentifier(p.rollup)));
944
+ const groupKeys = new Set(lk.rollup.groupBy.map((g) => foldSqlIdentifier(g)));
945
+ if (onRollupKeys.size !== groupKeys.size || [...groupKeys].some((g) => !onRollupKeys.has(g))) {
946
+ throw new Error(
947
+ `read model "${decl.name}" rollup lookup "${lk.as}" must join on the rollup's full group key ` +
948
+ `[${lk.rollup.groupBy.join(", ")}] to stay single-valued; got [${lk.on.map((p) => p.rollup).join(", ")}]`,
949
+ );
950
+ }
951
+ for (const dname of Object.keys(lk.defaults ?? {})) {
952
+ if (!lk.rollup.outputColumns.includes(dname)) {
953
+ throw new Error(
954
+ `read model "${decl.name}" rollup lookup "${lk.as}" declares a default for "${dname}", ` +
955
+ `not a column of rollup "${lk.rollup.projectionName}"`,
956
+ );
957
+ }
958
+ }
959
+ lookupByAlias.set(key, lk);
960
+ }
961
+ // Definition-time guard: no lookup alias may collide with the DEFAULT base alias (a caller-supplied
962
+ // `baseAlias` override is guarded separately inside `viewDdl`, which is the only place it's known).
963
+ assertBaseAliasDistinctFromLookups(DEFAULT_BASE_ALIAS);
964
+ // Validate every `rcol(alias, name)` reference resolves to a declared lookup and a real rollup column.
965
+ {
966
+ const baseColsSeen = new Set<string>();
967
+ const projColsSeen = new Map<string, Set<string>>();
968
+ const lookupColsSeen = new Map<string, Set<string>>();
969
+ for (const c of columns) collectColumns(decl.derive[c], baseColsSeen, projColsSeen, lookupColsSeen);
970
+ for (const [alias, names] of lookupColsSeen) {
971
+ const lk = lookupByAlias.get(foldSqlIdentifier(alias));
972
+ if (!lk) {
973
+ throw new Error(`read model "${decl.name}" references rollup-lookup alias "${alias}" with no matching lookup`);
974
+ }
975
+ for (const n of names) {
976
+ if (!lk.rollup.outputColumns.includes(n)) {
977
+ throw new Error(
978
+ `read model "${decl.name}" reads rcol("${alias}", "${n}") but "${n}" is not a column of ` +
979
+ `rollup "${lk.rollup.projectionName}" [${lk.rollup.outputColumns.join(", ")}]`,
980
+ );
981
+ }
982
+ }
983
+ }
984
+ }
985
+
986
+ const resolveRollupColumn = (alias: string, name: string): string => {
987
+ const lk = lookupByAlias.get(foldSqlIdentifier(alias));
988
+ if (!lk) throw new Error(`read model "${decl.name}" has no rollup lookup "${alias}"`);
989
+ const qualified = `${quoteIdent(alias)}.${quoteIdent(name)}`;
990
+ const dflt = lk.defaults?.[name];
991
+ return dflt === undefined ? qualified : `COALESCE(${qualified}, ${sqlLiteral(dflt)})`;
992
+ };
993
+ const withLookupResolver = (options?: SqlCompileOptions): SqlCompileOptions =>
994
+ lookups.length === 0
995
+ ? (options ?? {})
996
+ : { ...options, resolveRollupColumn: options?.resolveRollupColumn ?? resolveRollupColumn };
997
+
721
998
  const sqlSelectFor = (column: string, options?: SqlCompileOptions): string => {
722
999
  const expr = decl.derive[column];
723
1000
  if (!expr) throw new Error(`read model "${decl.name}" has no derived column "${column}"`);
724
- return compileToSqlSelect(expr, options);
1001
+ return compileToSqlSelect(expr, withLookupResolver(options));
725
1002
  };
726
1003
  const fnCache = new Map<string, DerivationFn>();
727
1004
  const fnFor = (column: string): DerivationFn => {
@@ -737,15 +1014,66 @@ export function defineReadModel(decl: ReadModelDecl): ReadModel {
737
1014
 
738
1015
  const viewDdl = (options?: SqlCompileOptions, viewOptions?: ViewDdlOptions): string => {
739
1016
  const baseAlias = resolveBaseAlias(options?.baseAlias);
1017
+ // A caller-supplied `baseAlias` override is only known here — guard it against the lookup aliases so
1018
+ // the LEFT JOINs below can't reuse the base table's identifier for a joined rollup (invalid SQL).
1019
+ assertBaseAliasDistinctFromLookups(baseAlias);
740
1020
  const selectBase = decl.selectBaseColumns !== false;
741
1021
  const derived = columns.map((c) => `${sqlSelectFor(c, options)} AS ${quoteIdent(c)}`);
742
1022
  const selectList = [...(selectBase ? [`${quoteIdent(baseAlias)}.*`] : []), ...derived].join(",\n ");
743
1023
  const createView = viewOptions?.temp ? "CREATE TEMP VIEW IF NOT EXISTS" : "CREATE VIEW IF NOT EXISTS";
744
- return (
745
- `${createView} ${quoteIdent(decl.name)} AS\n` +
746
- `SELECT\n ${selectList}\n` +
747
- `FROM ${quoteIdent(decl.baseTable)} ${quoteIdent(baseAlias)};`
748
- );
1024
+ // One `LEFT JOIN <rollup> ON <baseKey> = <rollupKey> …` per declared lookup, in declaration order so
1025
+ // the DDL is deterministic. The rollup's `*_counts` VIEW is joined by name (its physical relation).
1026
+ const joins = lookups.map((lk) => {
1027
+ const on = lk.on
1028
+ .map((p) => `${quoteIdent(baseAlias)}.${quoteIdent(p.base)} = ${quoteIdent(lk.as)}.${quoteIdent(p.rollup)}`)
1029
+ .join(" AND ");
1030
+ return `LEFT JOIN ${quoteIdent(lk.rollup.projectionName)} ${quoteIdent(lk.as)} ON ${on}`;
1031
+ });
1032
+ const fromClause = [`FROM ${quoteIdent(decl.baseTable)} ${quoteIdent(baseAlias)}`, ...joins].join("\n");
1033
+ return `${createView} ${quoteIdent(decl.name)} AS\n` + `SELECT\n ${selectList}\n` + `${fromClause};`;
1034
+ };
1035
+
1036
+ const resolveLookupRows = (
1037
+ baseRow: BaseRow,
1038
+ lookupRows: Record<string, ReadonlyArray<Record<string, unknown>>>,
1039
+ ): LookupRows => {
1040
+ // Null-prototype dictionary: lookup aliases are only identifier-validated, so an alias like
1041
+ // `__proto__` must set a plain own property rather than trip the magic prototype setter a normal
1042
+ // `{}` inherits from `Object.prototype` (same treatment as `evaluate()`'s derived-output map below).
1043
+ const resolved: LookupRows = Object.create(null);
1044
+ for (const lk of lookups) {
1045
+ // Read candidates by the lookup's alias via {@link resolveCandidateRows}: an OWN-property check
1046
+ // (so an identifier-legal `__proto__` alias over a plain `{}` can't resolve `lookupRows["__proto__"]`
1047
+ // to `Object.prototype` — inherited, truthy, non-array — and throw in the `.filter(...)` below), plus
1048
+ // a case-insensitive fallback so a caller supplying candidates under a different casing (e.g.
1049
+ // `{ C: [...] }` for an alias declared `c`) is still matched, mirroring the folded alias treatment
1050
+ // used by `rcol`, the resolved-row keying below, and the SQL LEFT JOIN.
1051
+ const candidates = resolveCandidateRows(lookupRows, lk.as);
1052
+ const matches = candidates.filter((r) =>
1053
+ lk.on.every((p) => compareValues("eq", lookupColumn(baseRow, p.base), lookupColumn(r, p.rollup))),
1054
+ );
1055
+ // The join covers the rollup's full group key, so a well-formed rollup yields at most one row per
1056
+ // key. More than one match means the candidate set is not single-valued: SQL's LEFT JOIN would fan
1057
+ // out to multiple rows while this TS resolver would silently keep one — the exact drift the parity
1058
+ // guard exists to prevent. Fail loudly instead.
1059
+ if (matches.length > 1) {
1060
+ throw new Error(
1061
+ `read model "${decl.name}" rollup lookup "${lk.as}" matched ${matches.length} rows for the ` +
1062
+ `join key — a lookup must be single-valued (its join covers the rollup's full group key)`,
1063
+ );
1064
+ }
1065
+ const match = matches[0];
1066
+ // Build one resolved row over EVERY rollup output column so `rcol` reads a stable object; a
1067
+ // missing/NULL value falls back to the declared default (the `COALESCE(alias.col, default)` twin).
1068
+ const row: Record<string, unknown> = Object.create(null);
1069
+ for (const name of lk.rollup.outputColumns) {
1070
+ const raw = match ? lookupColumn(match, name) : null;
1071
+ const dflt = lk.defaults?.[name];
1072
+ row[name] = raw === null || raw === undefined ? (dflt === undefined ? null : dflt) : raw;
1073
+ }
1074
+ resolved[foldSqlIdentifier(lk.as)] = row;
1075
+ }
1076
+ return resolved;
749
1077
  };
750
1078
 
751
1079
  return {
@@ -753,13 +1081,15 @@ export function defineReadModel(decl: ReadModelDecl): ReadModel {
753
1081
  projectionNames: [...projSet],
754
1082
  sqlSelectFor,
755
1083
  fnFor,
756
- evaluate: (baseRow, projections) => {
1084
+ resolveLookups: resolveLookupRows,
1085
+ evaluate: (baseRow, projections, lookupRows) => {
757
1086
  // Derived column names are user-controlled and only identifier-validated, so a name like
758
1087
  // `__proto__` must set a plain own property rather than trip the magic prototype setter a
759
1088
  // normal `{}` inherits from `Object.prototype` (same null-prototype treatment as the
760
1089
  // untrusted-key dictionaries elsewhere in this repo, e.g. `core/logger.ts`).
761
1090
  const out: Record<string, unknown> = Object.create(null);
762
- for (const c of columns) out[c] = fnFor(c)(baseRow, projections);
1091
+ const resolvedLookups = resolveLookupRows(baseRow, lookupRows ?? {});
1092
+ for (const c of columns) out[c] = fnFor(c)(baseRow, projections, resolvedLookups);
763
1093
  return out;
764
1094
  },
765
1095
  viewDdl,
@@ -842,10 +1172,15 @@ export const readModelRegistry = new ReadModelRegistry();
842
1172
 
843
1173
  // ─────────────────────────────────────────── Parity guard ────────────────────────────────────────
844
1174
 
845
- /** A single parity check case: a base row plus the projection rows in scope for it. */
1175
+ /** A single parity check case: a base row, the projection rows in scope for it, and (for a read model
1176
+ * with rollup lookups) the candidate rollup rows per lookup alias. */
846
1177
  export interface ParitySample {
847
1178
  readonly baseRow: BaseRow;
848
1179
  readonly projections?: ProjectionRows;
1180
+ /** Candidate rows for each declared rollup lookup, keyed by lookup alias (see {@link rcol}). The
1181
+ * guard inserts them into the lookup rollup's fixture table so the VIEW's `LEFT JOIN` materialises,
1182
+ * and hands the same rows to the TS backend to resolve — closing the lookup's parity surface. */
1183
+ readonly lookups?: Record<string, ReadonlyArray<Record<string, unknown>>>;
849
1184
  }
850
1185
 
851
1186
  /** The seam a parity guard needs to run the SQL backend: a SQLite handle it can create/query/drop. */
@@ -929,13 +1264,24 @@ export function assertReadModelParity(
929
1264
  }
930
1265
  }
931
1266
 
932
- // Collect the union of base columns and per-projection columns across all samples, so the fixture
933
- // tables carry every referenced column. Seed from the AST first so a projection referenced by an
934
- // `exists` predicate always has its correlated columns even when a sample supplies no rows for it.
1267
+ // Collect the union of base columns, per-projection columns, and per-rollup-lookup columns across all
1268
+ // samples, so the fixture tables carry every referenced column. Seed from the AST first so a
1269
+ // projection/lookup referenced by the derivation always has its columns even when a sample supplies
1270
+ // no rows for it.
1271
+ const lookups = model.decl.lookups ?? [];
935
1272
  const baseCols = new Set<string>();
936
1273
  const projCols = new Map<string, Set<string>>();
1274
+ const lookupColsByAlias = new Map<string, Set<string>>();
937
1275
  for (const name of model.projectionNames) projCols.set(name, new Set());
938
- for (const c of columns) collectColumns(model.decl.derive[c], baseCols, projCols);
1276
+ for (const lk of lookups) {
1277
+ // Seed each lookup fixture with its join keys (so the LEFT JOIN's ON columns exist) even if no
1278
+ // `rcol` reads them, plus every column that carries a declared default.
1279
+ const set = new Set<string>();
1280
+ for (const p of lk.on) set.add(p.rollup);
1281
+ for (const d of Object.keys(lk.defaults ?? {})) set.add(d);
1282
+ lookupColsByAlias.set(lk.as, set);
1283
+ }
1284
+ for (const c of columns) collectColumns(model.decl.derive[c], baseCols, projCols, lookupColsByAlias);
939
1285
  for (const s of samples) {
940
1286
  for (const k of Object.keys(s.baseRow)) baseCols.add(k);
941
1287
  for (const [pname, rows] of Object.entries(s.projections ?? {})) {
@@ -945,6 +1291,17 @@ export function assertReadModelParity(
945
1291
  if (!set) continue;
946
1292
  for (const r of rows) for (const k of Object.keys(r)) set.add(k);
947
1293
  }
1294
+ // Widen each DECLARED lookup's fixture from the sample's candidate rows read via
1295
+ // {@link resolveCandidateRows} — the same case-insensitive OWN-key resolution `resolveLookups` uses
1296
+ // (line ~1009) — so a sample supplying candidates under a different casing (e.g. `{ C: [...] }` for a
1297
+ // lookup declared `as: "c"`) still widens the right fixture instead of being silently ignored (which
1298
+ // would later insert nothing and fail parity for a non-parity reason). Keyed off `lookups`, not the
1299
+ // sample's keys, so it mirrors the resolver exactly.
1300
+ for (const lk of lookups) {
1301
+ const set = lookupColsByAlias.get(lk.as);
1302
+ if (!set) continue;
1303
+ for (const r of resolveCandidateRows(s.lookups ?? {}, lk.as)) for (const k of Object.keys(r)) set.add(k);
1304
+ }
948
1305
  }
949
1306
 
950
1307
  const baseTable = model.decl.baseTable;
@@ -993,6 +1350,29 @@ export function assertReadModelParity(
993
1350
  projectionTables.set(name, table);
994
1351
  }
995
1352
 
1353
+ // Resolve each rollup lookup's fixture table (its rollup's physical relation name). Reject a lookup
1354
+ // relation colliding with the base table or another lookup/projection, so the guard fabricates one
1355
+ // isolated fixture per relation (mirrors the projection checks above).
1356
+ const lookupTables = new Map<string, string>(); // alias -> relation
1357
+ for (const lk of lookups) {
1358
+ const table = assertSqlIdentifier("rollup lookup relation", lk.rollup.projectionName);
1359
+ const tableKey = foldSqlIdentifier(table);
1360
+ if (tableKey === foldSqlIdentifier(baseTable)) {
1361
+ throw new Error(
1362
+ `read model "${model.decl.name}" rollup lookup "${lk.as}" resolves to its base table "${baseTable}"; ` +
1363
+ `a lookup needs a distinct relation so the parity guard can build an isolated fixture for it`,
1364
+ );
1365
+ }
1366
+ const owner = tableOwner.get(tableKey);
1367
+ if (owner !== undefined) {
1368
+ throw new Error(
1369
+ `read model "${model.decl.name}" rollup lookup "${lk.as}" shares the physical relation "${table}" ` +
1370
+ `with "${owner}"; each lookup needs a distinct relation for an isolated parity fixture`,
1371
+ );
1372
+ }
1373
+ tableOwner.set(tableKey, lk.as);
1374
+ lookupTables.set(lk.as, table);
1375
+ }
996
1376
  // Drop/create every fixture through this single helper so the pre-check reset (leaked objects from
997
1377
  // an earlier call) and the post-check teardown (below) can never drift apart. Schema-qualified with
998
1378
  // `temp.` so an unqualified name can never resolve to — and delete — a real `main` table/view.
@@ -1000,6 +1380,7 @@ export function assertReadModelParity(
1000
1380
  db.exec(`DROP VIEW IF EXISTS temp.${quoteIdent(model.decl.name)};`);
1001
1381
  db.exec(`DROP TABLE IF EXISTS temp.${quoteIdent(baseTable)};`);
1002
1382
  for (const table of projectionTables.values()) db.exec(`DROP TABLE IF EXISTS temp.${quoteIdent(table)};`);
1383
+ for (const table of lookupTables.values()) db.exec(`DROP TABLE IF EXISTS temp.${quoteIdent(table)};`);
1003
1384
  };
1004
1385
 
1005
1386
  const insertRow = (table: string, row: Record<string, unknown>): void => {
@@ -1025,12 +1406,14 @@ export function assertReadModelParity(
1025
1406
  try {
1026
1407
  createFixture(baseTable, baseCols);
1027
1408
  for (const [name, table] of projectionTables) createFixture(table, projCols.get(name) ?? new Set());
1409
+ for (const [alias, table] of lookupTables) createFixture(table, lookupColsByAlias.get(alias) ?? new Set());
1028
1410
  db.exec(model.viewDdl(options.sql, { temp: true }));
1029
1411
 
1030
1412
  samples.forEach((sample, idx) => {
1031
1413
  // Reset fixture data for this isolated sample.
1032
1414
  db.run(`DELETE FROM ${quoteIdent(baseTable)};`);
1033
1415
  for (const table of projectionTables.values()) db.run(`DELETE FROM ${quoteIdent(table)};`);
1416
+ for (const table of lookupTables.values()) db.run(`DELETE FROM ${quoteIdent(table)};`);
1034
1417
  insertRow(baseTable, sample.baseRow);
1035
1418
  for (const [pname, rows] of Object.entries(sample.projections ?? {})) {
1036
1419
  // Ignore projections the model never references — no fixture table exists for them, and they
@@ -1039,6 +1422,16 @@ export function assertReadModelParity(
1039
1422
  if (!table) continue;
1040
1423
  for (const r of rows) insertRow(table, r);
1041
1424
  }
1425
+ // Insert each DECLARED lookup's candidate rows read via {@link resolveCandidateRows} — the same
1426
+ // case-insensitive OWN-key resolution `resolveLookups` uses below — so candidates supplied under a
1427
+ // different casing (e.g. `{ C: [...] }` for a lookup declared `as: "c"`) reach the SQL fixture the
1428
+ // VIEW's LEFT JOIN reads, instead of being dropped by an exact-key `sample.lookups[alias]` lookup and
1429
+ // failing parity for a non-parity reason. Keyed off `lookups`, mirroring the resolver.
1430
+ for (const lk of lookups) {
1431
+ const table = lookupTables.get(lk.as);
1432
+ if (!table) continue;
1433
+ for (const r of resolveCandidateRows(sample.lookups ?? {}, lk.as)) insertRow(table, r);
1434
+ }
1042
1435
 
1043
1436
  // Read the SQL-derived values straight from the managed VIEW — the VIEW body already computes
1044
1437
  // each derived column, so this exercises the exact DDL the framework emits (not a recompiled copy).
@@ -1047,9 +1440,14 @@ export function assertReadModelParity(
1047
1440
  `SELECT ${derivedList} FROM ${quoteIdent(model.decl.name)};`,
1048
1441
  );
1049
1442
  const sqlRow = sqlRows[0] ?? {};
1443
+ // Resolve each rollup lookup's single matching row (match on the join keys, fill declared
1444
+ // defaults) via the model's OWN resolver — the single source of the lookup-resolution rule (incl.
1445
+ // the single-valued/no-fan-out guard). Done here (rather than via `model.evaluate`) so a test that
1446
+ // overrides `model.fnFor` to inject drift still drives the compared TS value.
1447
+ const resolvedLookups = model.resolveLookups(sample.baseRow, sample.lookups ?? {});
1050
1448
  for (const c of columns) {
1051
1449
  const sqlValue = normaliseSqlValue(sqlRow[c]);
1052
- const fnValue = normaliseSqlValue(model.fnFor(c)(sample.baseRow, sample.projections));
1450
+ const fnValue = normaliseSqlValue(model.fnFor(c)(sample.baseRow, sample.projections, resolvedLookups));
1053
1451
  if (!Object.is(sqlValue, fnValue)) {
1054
1452
  onMismatch(
1055
1453
  `read-model parity mismatch in "${model.decl.name}".${c} (sample #${idx}): ` +
@@ -1062,3 +1460,877 @@ export function assertReadModelParity(
1062
1460
  dropFixtures();
1063
1461
  }
1064
1462
  }
1463
+
1464
+ // ═══════════════════════════════════════════ Rollups (#468) ══════════════════════════════════════
1465
+ //
1466
+ // A ROLLUP is the declare-once GROUP-BY sibling of `defineReadModel`: the same "one closed AST, two
1467
+ // backends that cannot drift" discipline, but for an AGGREGATE projection. `defineRollup` compiles a
1468
+ // group spec to BOTH a managed `*_counts` SQLite VIEW and an in-process TS group-reduce, registers the
1469
+ // result as a canonical projection (so a read model can `LEFT JOIN` it via a rollup lookup — see
1470
+ // `ReadModelDecl.lookups`), and is parity-guarded by `assertRollupParity` exactly as per-row read
1471
+ // models are by `assertReadModelParity`.
1472
+ //
1473
+ // The aggregate op set is CLOSED and FLAT (D2): count / countWhere / max / minWhere / coalesce / add —
1474
+ // exactly the ops the hand-authored plan-family VIEWs use, no general expression engine. Predicates
1475
+ // inside countWhere/minWhere reuse the same closed {@link Expr} DSL (compare/and/or/not/isNull/case
1476
+ // over `col`), so they compile to both backends through the shared `compileToSqlSelect`/`compileToFn`.
1477
+
1478
+ // ──────────────────────────────────────── Aggregate AST (D2) ─────────────────────────────────────
1479
+
1480
+ /** A closed, flat GROUP-BY aggregate expression (D2). `coalesce`/`add` wrap other aggregates so the
1481
+ * exact `COALESCE(MIN(…), MAX(…))` and `MAX(…)+1` shapes the plan VIEWs use are expressible; every
1482
+ * variant is handled exhaustively by both backends (SQL + TS reduce). */
1483
+ export type AggExpr = CountAgg | CountWhereAgg | MaxAgg | MinWhereAgg | CoalesceAgg | AddAgg;
1484
+
1485
+ export interface CountAgg {
1486
+ readonly kind: "count";
1487
+ /** When set, `COUNT(col)` (non-NULL rows); omitted → `COUNT(*)` (all group rows). */
1488
+ readonly column?: string;
1489
+ }
1490
+ export interface CountWhereAgg {
1491
+ readonly kind: "countWhere";
1492
+ /** `SUM(CASE WHEN pred THEN 1 ELSE 0 END)` — count of group rows satisfying the closed predicate. */
1493
+ readonly pred: Expr;
1494
+ }
1495
+ export interface MaxAgg {
1496
+ readonly kind: "max";
1497
+ readonly column: string;
1498
+ }
1499
+ export interface MinWhereAgg {
1500
+ readonly kind: "minWhere";
1501
+ /** `MIN(CASE WHEN pred THEN col END)` — min of `col` over rows satisfying `pred` (NULL if none). */
1502
+ readonly column: string;
1503
+ readonly pred: Expr;
1504
+ }
1505
+ export interface CoalesceAgg {
1506
+ readonly kind: "coalesce";
1507
+ readonly a: AggExpr;
1508
+ readonly b: AggExpr;
1509
+ }
1510
+ export interface AddAgg {
1511
+ readonly kind: "add";
1512
+ readonly expr: AggExpr;
1513
+ /** An INTEGER literal added to `expr` (e.g. `MAX(wave) + 1`). NULL + literal is NULL, both backends. */
1514
+ readonly literal: number;
1515
+ }
1516
+
1517
+ /** `COUNT(col)` (non-NULL) when `column` is given, else `COUNT(*)`. */
1518
+ export const count = (column?: string): CountAgg => (column === undefined ? { kind: "count" } : { kind: "count", column });
1519
+ /** `SUM(CASE WHEN pred THEN 1 ELSE 0 END)` — the count of group rows satisfying `pred`. */
1520
+ export const countWhere = (pred: Expr): CountWhereAgg => ({ kind: "countWhere", pred });
1521
+ /** `MAX(col)` over the group (ignores NULLs; NULL for an all-NULL/empty group). */
1522
+ export const max = (column: string): MaxAgg => ({ kind: "max", column });
1523
+ /** `MIN(CASE WHEN pred THEN col END)` — the min of `col` over group rows satisfying `pred`. */
1524
+ export const minWhere = (column: string, pred: Expr): MinWhereAgg => ({ kind: "minWhere", column, pred });
1525
+ /** `COALESCE(a, b)` over two aggregates — `a` unless it is NULL, then `b`. */
1526
+ export const coalesce = (a: AggExpr, b: AggExpr): CoalesceAgg => ({ kind: "coalesce", a, b });
1527
+ /** `expr + <integer literal>` (e.g. `MAX(wave) + 1`). Rejects a non-integer literal at build time. */
1528
+ export const add = (expr: AggExpr, literal: number): AddAgg => {
1529
+ if (!Number.isInteger(literal)) throw new Error(`add() literal must be an integer, got ${literal}`);
1530
+ return { kind: "add", expr, literal };
1531
+ };
1532
+
1533
+ /** Reject an {@link Expr} that has no meaning in a rollup predicate context (no correlated projection
1534
+ * or lookup exists there): `exists`/`pcol`/`rcol`. Keeps the rollup's predicate surface closed. */
1535
+ function assertRollupPredicate(where: string, expr: Expr): void {
1536
+ switch (expr.kind) {
1537
+ case "exists":
1538
+ throw new Error(`${where}: exists(...) is not valid in a rollup predicate`);
1539
+ case "pcol":
1540
+ throw new Error(`${where}: pcol(...) is not valid in a rollup predicate`);
1541
+ case "rcol":
1542
+ throw new Error(`${where}: rcol(...) is not valid in a rollup predicate`);
1543
+ case "compare":
1544
+ assertRollupPredicate(where, expr.left);
1545
+ assertRollupPredicate(where, expr.right);
1546
+ return;
1547
+ case "and":
1548
+ case "or":
1549
+ for (const c of expr.clauses) assertRollupPredicate(where, c);
1550
+ return;
1551
+ case "not":
1552
+ case "isNull":
1553
+ assertRollupPredicate(where, expr.expr);
1554
+ return;
1555
+ case "case":
1556
+ for (const w of expr.whens) {
1557
+ assertRollupPredicate(where, w.when);
1558
+ assertRollupPredicate(where, w.then);
1559
+ }
1560
+ assertRollupPredicate(where, expr.else);
1561
+ return;
1562
+ default:
1563
+ return;
1564
+ }
1565
+ }
1566
+
1567
+ /** Collect the source-row column names an aggregate references (its `column` plus any predicate cols),
1568
+ * used to seed fixture tables and to validate a rollup's non-join source references. */
1569
+ function collectAggColumns(agg: AggExpr, into: Set<string>): void {
1570
+ switch (agg.kind) {
1571
+ case "count":
1572
+ if (agg.column !== undefined) into.add(agg.column);
1573
+ return;
1574
+ case "max":
1575
+ into.add(agg.column);
1576
+ return;
1577
+ case "countWhere": {
1578
+ const proj = new Map<string, Set<string>>();
1579
+ collectColumns(agg.pred, into, proj, new Map());
1580
+ return;
1581
+ }
1582
+ case "minWhere": {
1583
+ into.add(agg.column);
1584
+ const proj = new Map<string, Set<string>>();
1585
+ collectColumns(agg.pred, into, proj, new Map());
1586
+ return;
1587
+ }
1588
+ case "coalesce":
1589
+ collectAggColumns(agg.a, into);
1590
+ collectAggColumns(agg.b, into);
1591
+ return;
1592
+ case "add":
1593
+ collectAggColumns(agg.expr, into);
1594
+ return;
1595
+ }
1596
+ }
1597
+
1598
+ // ─────────────────────────────────────────── Rollup sources ──────────────────────────────────────
1599
+
1600
+ /** The relation a rollup groups over. `table`/`projection`/`rollup` expose their columns by name; a
1601
+ * key-`join` exposes a FLAT namespace mapped by {@link RollupJoinSource.columns} (the two-hop reach —
1602
+ * D4). All three are single-mechanism composable: a `rollup` source is another {@link Rollup}. */
1603
+ export type RollupSource = RollupTableSource | RollupProjectionSource | RollupJoinSource | RollupRollupSource;
1604
+
1605
+ export interface RollupTableSource {
1606
+ readonly kind: "table";
1607
+ readonly table: string;
1608
+ }
1609
+ export interface RollupProjectionSource {
1610
+ readonly kind: "projection";
1611
+ readonly projection: string;
1612
+ }
1613
+ /** One relation in a {@link RollupJoinSource} — a base table/view/projection under a join alias. */
1614
+ export interface RollupRelation {
1615
+ readonly relation: string;
1616
+ readonly alias: string;
1617
+ }
1618
+ export interface RollupJoinSource {
1619
+ readonly kind: "join";
1620
+ /** `"left"` (default) or `"inner"`. LEFT keeps unmatched left rows with NULL right columns. */
1621
+ readonly type?: "left" | "inner";
1622
+ readonly left: RollupRelation;
1623
+ readonly right: RollupRelation;
1624
+ /** Equi-join key(s): `left.<left> = right.<right>`. */
1625
+ readonly on: ReadonlyArray<{ readonly left: string; readonly right: string }>;
1626
+ /**
1627
+ * The FLAT output namespace: output column name → `[side, physicalColumn]`. This resolves collisions
1628
+ * (both sides may have a `status`) and is what `groupBy`/aggregate `col(...)` references resolve
1629
+ * against, so the closed predicate/aggregate machinery is reused unchanged over a join.
1630
+ */
1631
+ readonly columns: Record<string, readonly [side: "left" | "right", column: string]>;
1632
+ }
1633
+ export interface RollupRollupSource {
1634
+ readonly kind: "rollup";
1635
+ readonly rollup: Rollup;
1636
+ }
1637
+
1638
+ /** A rollup over a base table. */
1639
+ export const fromTable = (table: string): RollupTableSource => ({ kind: "table", table });
1640
+ /** A rollup over a canonical projection (resolved to its physical relation via {@link projectionRegistry}). */
1641
+ export const fromProjection = (projection: string): RollupProjectionSource => ({ kind: "projection", projection });
1642
+ /** A rollup over another rollup's `*_counts` VIEW (composable — e.g. wave's two levels). */
1643
+ export const fromRollup = (rollup: Rollup): RollupRollupSource => ({ kind: "rollup", rollup });
1644
+ /** A rollup over a declared key-join of two relations (the two-hop reach — D4). */
1645
+ export const joinSource = (spec: Omit<RollupJoinSource, "kind">): RollupJoinSource => ({ kind: "join", ...spec });
1646
+
1647
+ /** The in-memory rows the TS group-reduce sees per LEAF relation (base table/view/projection name). */
1648
+ export type RollupInputs = Record<string, ReadonlyArray<Record<string, unknown>>>;
1649
+
1650
+ // ───────────────────────────────────────────── defineRollup ──────────────────────────────────────
1651
+
1652
+ /** The declarative rollup input passed to {@link defineRollup}. */
1653
+ export interface RollupDecl {
1654
+ /** The managed VIEW name (also the rollup / canonical-projection identifier). */
1655
+ readonly name: string;
1656
+ /** The relation to group over (table / projection / key-join / another rollup). */
1657
+ readonly source: RollupSource;
1658
+ /** One or more group-key columns (referenced by name in the source's output namespace). */
1659
+ readonly groupBy: readonly string[];
1660
+ /** Optional pre-aggregation row filter (the `WHERE` — e.g. `isNotNull(col("wave"))`). */
1661
+ readonly where?: Expr;
1662
+ /** The aggregate columns: name → closed {@link AggExpr}. Authored ONCE, compiled to both backends. */
1663
+ readonly aggregates: Record<string, AggExpr>;
1664
+ }
1665
+
1666
+ /** A compiled rollup: the declaration plus both backends and the managed VIEW DDL. */
1667
+ export interface Rollup {
1668
+ readonly decl: RollupDecl;
1669
+ /** The canonical-projection name (= `decl.name`). */
1670
+ readonly projectionName: string;
1671
+ /** The group-key columns (order preserved from the declaration). */
1672
+ readonly groupBy: readonly string[];
1673
+ /** All output columns: the group keys followed by the aggregate names (aggregates sorted by name). */
1674
+ readonly outputColumns: readonly string[];
1675
+ /** Leaf relation names this rollup reads, transitively (for fixtures / feeding the TS reduce). */
1676
+ readonly sourceRelations: readonly string[];
1677
+ /** This rollup and every rollup it composes, in dependency order (dependencies first) — the order the
1678
+ * managed VIEWs must be created in. */
1679
+ readonly viewChain: readonly Rollup[];
1680
+ /** The SQLite select-list expression for one aggregate column. */
1681
+ sqlAggFor(column: string, options?: SqlCompileOptions): string;
1682
+ /** The in-process group-reduce (the TS backend): leaf rows → one output row per group. */
1683
+ reduce(inputs: RollupInputs): Record<string, unknown>[];
1684
+ /** The managed `CREATE VIEW` DDL. `{ temp: true }` emits a `CREATE TEMP VIEW` (parity-guard use). */
1685
+ viewDdl(options?: SqlCompileOptions, viewOptions?: ViewDdlOptions): string;
1686
+ /** Fixture column requirements per leaf relation (referenced physical columns), for the parity guard. */
1687
+ fixtureColumns(): Map<string, Set<string>>;
1688
+ }
1689
+
1690
+ const ROLLUP_SRC_ALIAS = "__urban_rollup_src";
1691
+
1692
+ /** Resolve a rollup source to its SQL `FROM` clause, a `col`-name → qualified-SQL resolver, its leaf
1693
+ * relation names, and the rollup dependencies it composes. */
1694
+ function compileRollupSourceSql(
1695
+ source: RollupSource,
1696
+ resolveProjectionTable: (name: string) => string,
1697
+ ): { from: string; resolveColumn: (name: string) => string } {
1698
+ switch (source.kind) {
1699
+ case "table": {
1700
+ const table = assertSqlIdentifier("rollup source table", source.table);
1701
+ return {
1702
+ from: `${quoteIdent(table)} ${quoteIdent(ROLLUP_SRC_ALIAS)}`,
1703
+ resolveColumn: (name) => `${quoteIdent(ROLLUP_SRC_ALIAS)}.${quoteIdent(name)}`,
1704
+ };
1705
+ }
1706
+ case "projection": {
1707
+ const table = assertSqlIdentifier("rollup source projection", resolveProjectionTable(source.projection));
1708
+ return {
1709
+ from: `${quoteIdent(table)} ${quoteIdent(ROLLUP_SRC_ALIAS)}`,
1710
+ resolveColumn: (name) => `${quoteIdent(ROLLUP_SRC_ALIAS)}.${quoteIdent(name)}`,
1711
+ };
1712
+ }
1713
+ case "rollup": {
1714
+ const table = assertSqlIdentifier("rollup source rollup", source.rollup.projectionName);
1715
+ return {
1716
+ from: `${quoteIdent(table)} ${quoteIdent(ROLLUP_SRC_ALIAS)}`,
1717
+ resolveColumn: (name) => `${quoteIdent(ROLLUP_SRC_ALIAS)}.${quoteIdent(name)}`,
1718
+ };
1719
+ }
1720
+ case "join": {
1721
+ const leftRel = assertSqlIdentifier("rollup join relation", resolveProjectionTable(source.left.relation));
1722
+ const rightRel = assertSqlIdentifier("rollup join relation", resolveProjectionTable(source.right.relation));
1723
+ const leftAlias = assertSqlIdentifier("rollup join alias", source.left.alias);
1724
+ const rightAlias = assertSqlIdentifier("rollup join alias", source.right.alias);
1725
+ const joinKw = source.type === "inner" ? "JOIN" : "LEFT JOIN";
1726
+ const on = source.on
1727
+ .map(
1728
+ (p) =>
1729
+ `${quoteIdent(leftAlias)}.${quoteIdent(assertSqlIdentifier("join key", p.left))} = ` +
1730
+ `${quoteIdent(rightAlias)}.${quoteIdent(assertSqlIdentifier("join key", p.right))}`,
1731
+ )
1732
+ .join(" AND ");
1733
+ const from =
1734
+ `${quoteIdent(leftRel)} ${quoteIdent(leftAlias)} ${joinKw} ` +
1735
+ `${quoteIdent(rightRel)} ${quoteIdent(rightAlias)} ON ${on}`;
1736
+ // Index the join-mapped output columns by their FOLDED (case-insensitive) name: SQLite folds
1737
+ // identifiers case-insensitively and the TS twin (`materializeRollupSource`) resolves these columns
1738
+ // via the case-insensitive `lookupColumn`, so a rollup predicate/groupBy that references a
1739
+ // join-mapped column with different casing must resolve here too — a case-sensitive `source.columns[name]`
1740
+ // would succeed in TS but throw in SQL. Fail fast on a case-only collision (two output columns SQLite
1741
+ // cannot tell apart), and use a `Map` so a `__proto__`-named column can't resolve off the prototype chain.
1742
+ const foldedColumns = new Map<string, readonly [side: "left" | "right", column: string]>();
1743
+ for (const [outName, mapped] of Object.entries(source.columns)) {
1744
+ const key = foldSqlIdentifier(outName);
1745
+ if (foldedColumns.has(key)) {
1746
+ throw new Error(
1747
+ `rollup join source declares output columns differing only in case ("${outName}"); ` +
1748
+ `SQLite folds identifiers case-insensitively so they would be ambiguous`,
1749
+ );
1750
+ }
1751
+ foldedColumns.set(key, mapped);
1752
+ }
1753
+ const resolveColumn = (name: string): string => {
1754
+ const mapped = foldedColumns.get(foldSqlIdentifier(name));
1755
+ if (!mapped) throw new Error(`rollup join source has no mapped column "${name}"`);
1756
+ const [side, physical] = mapped;
1757
+ const alias = side === "left" ? leftAlias : rightAlias;
1758
+ return `${quoteIdent(alias)}.${quoteIdent(assertSqlIdentifier("join column", physical))}`;
1759
+ };
1760
+ return { from, resolveColumn };
1761
+ }
1762
+ }
1763
+ }
1764
+
1765
+ /** The TS twin of {@link compileRollupSourceSql}: materialise a source's flat rows from leaf inputs. */
1766
+ function materializeRollupSource(source: RollupSource, inputs: RollupInputs): Record<string, unknown>[] {
1767
+ switch (source.kind) {
1768
+ case "table":
1769
+ return [...(inputs[source.table] ?? [])];
1770
+ case "projection":
1771
+ return [...(inputs[source.projection] ?? [])];
1772
+ case "rollup":
1773
+ return source.rollup.reduce(inputs);
1774
+ case "join": {
1775
+ const leftRows = inputs[source.left.relation] ?? [];
1776
+ const rightRows = inputs[source.right.relation] ?? [];
1777
+ const map = (l: Record<string, unknown>, r: Record<string, unknown> | undefined): Record<string, unknown> => {
1778
+ const row: Record<string, unknown> = Object.create(null);
1779
+ for (const [outName, [side, physical]] of Object.entries(source.columns)) {
1780
+ const from = side === "left" ? l : r;
1781
+ row[outName] = from ? lookupColumn(from, physical) : null;
1782
+ }
1783
+ return row;
1784
+ };
1785
+ // Build the composite join key with the SAME storage-class-tagged encoding the GROUP BY reduce
1786
+ // uses (groupKeyPart): integer number/bigint of equal value collide, numeric never equals text —
1787
+ // matching `compareValues("eq", …)`. A nullish part yields `null` (no key), because SQLite's
1788
+ // `ON l.k = r.k` never matches a NULL key, so those rows are excluded from the join entirely.
1789
+ const keyOf = (row: Record<string, unknown>, cols: readonly string[]): string | null => {
1790
+ const parts: string[] = [];
1791
+ for (const c of cols) {
1792
+ const v = lookupColumn(row, c);
1793
+ if (v === null || v === undefined) return null;
1794
+ parts.push(groupKeyPart(v));
1795
+ }
1796
+ return parts.join("\u0001");
1797
+ };
1798
+ // Index the right side once by its join key (O(|right|)) so each left row probes in O(1) rather
1799
+ // than scanning every right row (was O(|left|·|right|)).
1800
+ const rightCols = source.on.map((p) => p.right);
1801
+ const leftCols = source.on.map((p) => p.left);
1802
+ const rightByKey = new Map<string, Record<string, unknown>[]>();
1803
+ for (const r of rightRows) {
1804
+ const k = keyOf(r, rightCols);
1805
+ if (k === null) continue;
1806
+ let bucket = rightByKey.get(k);
1807
+ if (!bucket) {
1808
+ bucket = [];
1809
+ rightByKey.set(k, bucket);
1810
+ }
1811
+ bucket.push(r);
1812
+ }
1813
+ const out: Record<string, unknown>[] = [];
1814
+ for (const l of leftRows) {
1815
+ const k = keyOf(l, leftCols);
1816
+ const matches = k === null ? undefined : rightByKey.get(k);
1817
+ if (!matches || matches.length === 0) {
1818
+ if (source.type !== "inner") out.push(map(l, undefined));
1819
+ } else {
1820
+ for (const r of matches) out.push(map(l, r));
1821
+ }
1822
+ }
1823
+ return out;
1824
+ }
1825
+ }
1826
+ }
1827
+
1828
+ /** Leaf relation names a source reads, transitively (a `rollup` source recurses into its own leaves). */
1829
+ function rollupSourceRelations(source: RollupSource): string[] {
1830
+ switch (source.kind) {
1831
+ case "table":
1832
+ return [source.table];
1833
+ case "projection":
1834
+ return [source.projection];
1835
+ case "join":
1836
+ return [source.left.relation, source.right.relation];
1837
+ case "rollup":
1838
+ return [...source.rollup.sourceRelations];
1839
+ }
1840
+ }
1841
+
1842
+ /** The physical twin of {@link rollupSourceRelations}: each leaf relation name mapped to the physical
1843
+ * SQL table the compiled VIEW reads, applying the SAME projection→table resolution
1844
+ * {@link compileRollupSourceSql} uses — identity for a raw `table` source, `resolveProjectionTable` for
1845
+ * `projection`/`join` relations, recursing for a composed `rollup`. The parity guard builds its TEMP
1846
+ * fixtures under these names so they match the VIEW's `FROM`; otherwise a projection whose name maps to a
1847
+ * different physical relation (e.g. `urban_instance_state` → `_urban_instance_state`) would leave the
1848
+ * VIEW pointing at a table the logical-named fixture never created. */
1849
+ function rollupSourcePhysicalTables(
1850
+ source: RollupSource,
1851
+ resolveProjectionTable: (name: string) => string,
1852
+ ): Map<string, string> {
1853
+ switch (source.kind) {
1854
+ case "table":
1855
+ return new Map([[source.table, source.table]]);
1856
+ case "projection":
1857
+ return new Map([[source.projection, resolveProjectionTable(source.projection)]]);
1858
+ case "join":
1859
+ return new Map([
1860
+ [source.left.relation, resolveProjectionTable(source.left.relation)],
1861
+ [source.right.relation, resolveProjectionTable(source.right.relation)],
1862
+ ]);
1863
+ case "rollup":
1864
+ return rollupSourcePhysicalTables(source.rollup.decl.source, resolveProjectionTable);
1865
+ }
1866
+ }
1867
+
1868
+ /** Evaluate one aggregate over a group's source rows (the TS backend). */
1869
+ function evalAgg(agg: AggExpr, rows: Record<string, unknown>[]): unknown {
1870
+ switch (agg.kind) {
1871
+ case "count": {
1872
+ const { column } = agg;
1873
+ return column === undefined
1874
+ ? rows.length
1875
+ : rows.filter((r) => lookupColumn(r, column) !== null).length;
1876
+ }
1877
+ case "countWhere": {
1878
+ const fn = compileToFn(agg.pred);
1879
+ return rows.filter((r) => truthy(fn(r))).length;
1880
+ }
1881
+ case "max": {
1882
+ const vals = rows.map((r) => lookupColumn(r, agg.column)).filter((v) => v !== null && v !== undefined);
1883
+ if (vals.length === 0) return null;
1884
+ return vals.reduce((m, v) => (compareOrderable(orderable(v), orderable(m)) > 0 ? v : m));
1885
+ }
1886
+ case "minWhere": {
1887
+ const fn = compileToFn(agg.pred);
1888
+ const vals = rows
1889
+ .filter((r) => truthy(fn(r)))
1890
+ .map((r) => lookupColumn(r, agg.column))
1891
+ .filter((v) => v !== null && v !== undefined);
1892
+ if (vals.length === 0) return null;
1893
+ return vals.reduce((m, v) => (compareOrderable(orderable(v), orderable(m)) < 0 ? v : m));
1894
+ }
1895
+ case "coalesce": {
1896
+ const a = evalAgg(agg.a, rows);
1897
+ return a === null || a === undefined ? evalAgg(agg.b, rows) : a;
1898
+ }
1899
+ case "add": {
1900
+ const base = evalAgg(agg.expr, rows);
1901
+ if (base === null || base === undefined) return null; // SQLite: NULL + literal = NULL
1902
+ if (typeof base === "bigint") return base + BigInt(agg.literal);
1903
+ const n = typeof base === "number" ? base : Number(base);
1904
+ return n + agg.literal;
1905
+ }
1906
+ }
1907
+ }
1908
+
1909
+ /** Compile one aggregate to a SQLite select-list expression (the SQL backend). */
1910
+ function compileAggToSql(agg: AggExpr, options: SqlCompileOptions): string {
1911
+ const resolveColumn = options.resolveColumn ?? ((n: string) => quoteIdent(n));
1912
+ switch (agg.kind) {
1913
+ case "count":
1914
+ return agg.column === undefined ? "COUNT(*)" : `COUNT(${resolveColumn(agg.column)})`;
1915
+ case "countWhere":
1916
+ return `SUM(CASE WHEN ${compileToSqlSelect(agg.pred, options)} THEN 1 ELSE 0 END)`;
1917
+ case "max":
1918
+ return `MAX(${resolveColumn(agg.column)})`;
1919
+ case "minWhere":
1920
+ return `MIN(CASE WHEN ${compileToSqlSelect(agg.pred, options)} THEN ${resolveColumn(agg.column)} END)`;
1921
+ case "coalesce":
1922
+ return `COALESCE(${compileAggToSql(agg.a, options)}, ${compileAggToSql(agg.b, options)})`;
1923
+ case "add":
1924
+ return `(${compileAggToSql(agg.expr, options)} + ${sqlLiteral(agg.literal)})`;
1925
+ }
1926
+ }
1927
+
1928
+ /** A composite group key for the TS reduce, tagged by storage class so `1`/`'1'`/NULL group distinctly
1929
+ * (matching SQLite's affinity-free grouping the parity fixtures pin), while integer `number`/`bigint`
1930
+ * of equal value group together (a driver may surface either). */
1931
+ function groupKeyPart(value: unknown): string {
1932
+ if (value === null || value === undefined) return "\u0000null";
1933
+ if (typeof value === "bigint") return `\u0000int:${value}`;
1934
+ if (typeof value === "number" && Number.isInteger(value)) return `\u0000int:${value}`;
1935
+ if (typeof value === "number") return `\u0000num:${value}`;
1936
+ if (typeof value === "boolean") return `\u0000int:${value ? 1 : 0}`;
1937
+ return `\u0000str:${String(value)}`;
1938
+ }
1939
+
1940
+ /**
1941
+ * Declare a GROUP-BY rollup ONCE (ADR 0065, #468). Returns a {@link Rollup} exposing both backends (the
1942
+ * managed `*_counts` VIEW DDL and the TS group-reduce) driven from one closed spec, plus the metadata
1943
+ * the parity guard and registry need. Register it in {@link rollupRegistry} to have the runtime apply
1944
+ * its VIEW at boot and expose its name as a canonical projection.
1945
+ */
1946
+ export function defineRollup(decl: RollupDecl): Rollup {
1947
+ assertSqlIdentifier("rollup name", decl.name);
1948
+ if (decl.groupBy.length === 0) throw new Error(`rollup "${decl.name}" declares no group-by columns`);
1949
+ for (const g of decl.groupBy) assertSqlIdentifier("group-by column", g);
1950
+ const aggNames = Object.keys(decl.aggregates).sort();
1951
+ if (aggNames.length === 0) throw new Error(`rollup "${decl.name}" declares no aggregates`);
1952
+ for (const a of aggNames) assertSqlIdentifier("aggregate column", a);
1953
+ // An aggregate column name colliding with a group-key name would emit two SELECT aliases of one name.
1954
+ const groupSet = new Set(decl.groupBy.map((g) => foldSqlIdentifier(g)));
1955
+ for (const a of aggNames) {
1956
+ if (groupSet.has(foldSqlIdentifier(a))) {
1957
+ throw new Error(`rollup "${decl.name}" aggregate "${a}" collides with a group-by column of the same name`);
1958
+ }
1959
+ }
1960
+ if (decl.where) assertRollupPredicate(`rollup "${decl.name}" where`, decl.where);
1961
+ for (const a of aggNames) {
1962
+ const agg = decl.aggregates[a];
1963
+ forEachAggPredicate(agg, (pred) => assertRollupPredicate(`rollup "${decl.name}" aggregate "${a}"`, pred));
1964
+ }
1965
+
1966
+ // Fail-fast: when the source's output namespace is statically known — a key-`join` exposes its flat
1967
+ // `columns` map, a composed `rollup` exposes its `outputColumns` — reject a rollup that references a
1968
+ // `groupBy`/`col(...)` name absent from it. Otherwise the two backends drift silently: the SQL side
1969
+ // throws (`no mapped column` for a join; `no such column` for a rollup source) at view compilation /
1970
+ // query time, while the TS reduce reads NULL via `lookupColumn` and groups/counts against a phantom
1971
+ // column. Fold names case-insensitively to mirror the resolvers (SQLite identifier folding +
1972
+ // `lookupColumn`). A `table`/`projection` source has no statically-known namespace here, so it is
1973
+ // left to the existing per-relation resolution.
1974
+ const knownNamespaceCols =
1975
+ decl.source.kind === "join"
1976
+ ? Object.keys(decl.source.columns)
1977
+ : decl.source.kind === "rollup"
1978
+ ? decl.source.rollup.outputColumns
1979
+ : undefined;
1980
+ if (knownNamespaceCols) {
1981
+ const knownFolded = new Set(knownNamespaceCols.map(foldSqlIdentifier));
1982
+ const referencedCols = new Set<string>();
1983
+ for (const g of decl.groupBy) referencedCols.add(g);
1984
+ if (decl.where) collectColumns(decl.where, referencedCols, new Map(), new Map());
1985
+ for (const a of aggNames) collectAggColumns(decl.aggregates[a], referencedCols);
1986
+ for (const name of referencedCols) {
1987
+ if (!knownFolded.has(foldSqlIdentifier(name))) {
1988
+ const kind = decl.source.kind === "join" ? "join source" : "rollup source";
1989
+ throw new Error(
1990
+ `rollup "${decl.name}" references column "${name}" absent from its ${kind} output namespace ` +
1991
+ `[${knownNamespaceCols.join(", ")}]`,
1992
+ );
1993
+ }
1994
+ }
1995
+ }
1996
+
1997
+ const outputColumns = [...decl.groupBy, ...aggNames];
1998
+ const sourceRelations = [...new Set(rollupSourceRelations(decl.source))];
1999
+ const dependencies = decl.source.kind === "rollup" ? decl.source.rollup.viewChain : [];
2000
+ // Dependency rollups first (dedup by name, preserving order); this rollup itself is appended last
2001
+ // once `self` exists (see below).
2002
+ const viewChain: Rollup[] = [];
2003
+ const seenChain = new Set<string>();
2004
+ for (const dep of dependencies) {
2005
+ const key = foldSqlIdentifier(dep.projectionName);
2006
+ if (!seenChain.has(key)) {
2007
+ seenChain.add(key);
2008
+ viewChain.push(dep);
2009
+ }
2010
+ }
2011
+
2012
+ const sqlAggFor = (column: string, options?: SqlCompileOptions): string => {
2013
+ const agg = decl.aggregates[column];
2014
+ if (!agg) throw new Error(`rollup "${decl.name}" has no aggregate "${column}"`);
2015
+ const { resolveColumn } = compileRollupSourceSql(decl.source, resolveProjectionTableFor(options));
2016
+ return compileAggToSql(agg, { ...options, resolveColumn });
2017
+ };
2018
+
2019
+ const viewDdl = (options?: SqlCompileOptions, viewOptions?: ViewDdlOptions): string => {
2020
+ const { from, resolveColumn } = compileRollupSourceSql(decl.source, resolveProjectionTableFor(options));
2021
+ const colOptions: SqlCompileOptions = { ...options, resolveColumn };
2022
+ const groupSelect = decl.groupBy.map((g) => `${resolveColumn(g)} AS ${quoteIdent(g)}`);
2023
+ const aggSelect = aggNames.map((a) => `${compileAggToSql(decl.aggregates[a], colOptions)} AS ${quoteIdent(a)}`);
2024
+ const selectList = [...groupSelect, ...aggSelect].join(",\n ");
2025
+ const whereClause = decl.where ? `\nWHERE ${compileToSqlSelect(decl.where, colOptions)}` : "";
2026
+ const groupByClause = decl.groupBy.map((g) => resolveColumn(g)).join(", ");
2027
+ const createView = viewOptions?.temp ? "CREATE TEMP VIEW IF NOT EXISTS" : "CREATE VIEW IF NOT EXISTS";
2028
+ return (
2029
+ `${createView} ${quoteIdent(decl.name)} AS\n` +
2030
+ `SELECT\n ${selectList}\n` +
2031
+ `FROM ${from}${whereClause}\n` +
2032
+ `GROUP BY ${groupByClause};`
2033
+ );
2034
+ };
2035
+
2036
+ const reduce = (inputs: RollupInputs): Record<string, unknown>[] => {
2037
+ const src = materializeRollupSource(decl.source, inputs);
2038
+ const whereFn = decl.where ? compileToFn(decl.where) : undefined;
2039
+ const filtered = whereFn ? src.filter((r) => truthy(whereFn(r))) : src;
2040
+ const groups = new Map<string, Record<string, unknown>[]>();
2041
+ const order: { first: Record<string, unknown>; rows: Record<string, unknown>[] }[] = [];
2042
+ for (const r of filtered) {
2043
+ const key = decl.groupBy.map((g) => groupKeyPart(lookupColumn(r, g))).join("\u0001");
2044
+ let bucket = groups.get(key);
2045
+ if (!bucket) {
2046
+ bucket = [];
2047
+ groups.set(key, bucket);
2048
+ order.push({ first: r, rows: bucket });
2049
+ }
2050
+ bucket.push(r);
2051
+ }
2052
+ return order.map(({ first, rows }) => {
2053
+ const out: Record<string, unknown> = Object.create(null);
2054
+ for (const g of decl.groupBy) out[g] = lookupColumn(first, g);
2055
+ for (const a of aggNames) out[a] = evalAgg(decl.aggregates[a], rows);
2056
+ return out;
2057
+ });
2058
+ };
2059
+
2060
+ const fixtureColumns = (): Map<string, Set<string>> => {
2061
+ const cols = new Map<string, Set<string>>();
2062
+ const ensure = (rel: string): Set<string> => {
2063
+ let s = cols.get(rel);
2064
+ if (!s) {
2065
+ s = new Set<string>();
2066
+ cols.set(rel, s);
2067
+ }
2068
+ return s;
2069
+ };
2070
+ // Columns referenced by this rollup's group keys, where predicate, and aggregates (as source
2071
+ // output-namespace names), then mapped onto the physical relation(s) of its source.
2072
+ const referenced = new Set<string>();
2073
+ for (const g of decl.groupBy) referenced.add(g);
2074
+ if (decl.where) collectColumns(decl.where, referenced, new Map(), new Map());
2075
+ for (const a of aggNames) collectAggColumns(decl.aggregates[a], referenced);
2076
+ switch (decl.source.kind) {
2077
+ case "table":
2078
+ case "projection": {
2079
+ const rel = decl.source.kind === "table" ? decl.source.table : decl.source.projection;
2080
+ const s = ensure(rel);
2081
+ for (const c of referenced) s.add(c);
2082
+ break;
2083
+ }
2084
+ case "join": {
2085
+ const leftSet = ensure(decl.source.left.relation);
2086
+ const rightSet = ensure(decl.source.right.relation);
2087
+ for (const [, [side, physical]] of Object.entries(decl.source.columns)) {
2088
+ (side === "left" ? leftSet : rightSet).add(physical);
2089
+ }
2090
+ for (const p of decl.source.on) {
2091
+ leftSet.add(p.left);
2092
+ rightSet.add(p.right);
2093
+ }
2094
+ break;
2095
+ }
2096
+ case "rollup": {
2097
+ // The inner rollup provides its own output columns via its VIEW; recurse for its leaf fixtures.
2098
+ for (const [rel, s] of decl.source.rollup.fixtureColumns()) {
2099
+ const dst = ensure(rel);
2100
+ for (const c of s) dst.add(c);
2101
+ }
2102
+ break;
2103
+ }
2104
+ }
2105
+ return cols;
2106
+ };
2107
+
2108
+ const self: Rollup = {
2109
+ decl,
2110
+ projectionName: decl.name,
2111
+ groupBy: [...decl.groupBy],
2112
+ outputColumns,
2113
+ sourceRelations,
2114
+ viewChain,
2115
+ sqlAggFor,
2116
+ reduce,
2117
+ viewDdl,
2118
+ fixtureColumns,
2119
+ };
2120
+ viewChain.push(self);
2121
+ return self;
2122
+ }
2123
+
2124
+ /** Walk every predicate `Expr` embedded in an aggregate (countWhere/minWhere), recursing coalesce/add. */
2125
+ function forEachAggPredicate(agg: AggExpr, visit: (pred: Expr) => void): void {
2126
+ switch (agg.kind) {
2127
+ case "countWhere":
2128
+ visit(agg.pred);
2129
+ return;
2130
+ case "minWhere":
2131
+ visit(agg.pred);
2132
+ return;
2133
+ case "coalesce":
2134
+ forEachAggPredicate(agg.a, visit);
2135
+ forEachAggPredicate(agg.b, visit);
2136
+ return;
2137
+ case "add":
2138
+ forEachAggPredicate(agg.expr, visit);
2139
+ return;
2140
+ default:
2141
+ return;
2142
+ }
2143
+ }
2144
+
2145
+ function resolveProjectionTableFor(options?: SqlCompileOptions): (name: string) => string {
2146
+ return options?.resolveProjectionTable ?? ((n: string) => projectionRegistry.sqlTableFor(n));
2147
+ }
2148
+
2149
+ // ────────────────────────────────────────── Rollup registry ──────────────────────────────────────
2150
+
2151
+ /**
2152
+ * The rollup registry (ADR 0065, #468) — the GROUP-BY sibling of {@link ReadModelRegistry}. Registering
2153
+ * a {@link Rollup} both (a) queues its managed VIEW for boot provisioning (in dependency order) and (b)
2154
+ * registers its name as a canonical projection so read-model rollup lookups resolve. Idempotent: a
2155
+ * re-register whose VIEW DDL matches is a no-op; a conflicting redefinition throws.
2156
+ */
2157
+ export class RollupRegistry {
2158
+ readonly #byName = new Map<string, Rollup>();
2159
+
2160
+ register(rollup: Rollup, projections: ProjectionRegistry = projectionRegistry): void {
2161
+ const key = foldSqlIdentifier(rollup.projectionName);
2162
+ const existing = this.#byName.get(key);
2163
+ if (existing) {
2164
+ if (existing.projectionName !== rollup.projectionName) {
2165
+ throw new Error(
2166
+ `rollup "${rollup.projectionName}" already registered as "${existing.projectionName}"; SQLite ` +
2167
+ `folds identifiers case-insensitively, so names differing only in case denote one VIEW`,
2168
+ );
2169
+ }
2170
+ if (existing.viewDdl() !== rollup.viewDdl()) {
2171
+ throw new Error(`rollup "${rollup.projectionName}" already registered with a different definition`);
2172
+ }
2173
+ return;
2174
+ }
2175
+ this.#byName.set(key, rollup);
2176
+ // Expose the rollup's VIEW as a canonical projection so `exists(...)`/rollup lookups resolve to it.
2177
+ projections.register({ name: rollup.projectionName });
2178
+ }
2179
+
2180
+ all(): Rollup[] {
2181
+ return [...this.#byName.values()];
2182
+ }
2183
+
2184
+ get(name: string): Rollup | undefined {
2185
+ return this.#byName.get(foldSqlIdentifier(name));
2186
+ }
2187
+
2188
+ /** Apply every registered rollup's managed VIEW to a database, dependency-ordered (a composed rollup
2189
+ * after the rollup it reads) and MANAGED (drop+recreate `main.`-qualified, mirroring
2190
+ * {@link ReadModelRegistry.ensureViews}). Safe to call repeatedly. */
2191
+ ensureViews(db: { exec(sql: string): void }, options?: SqlCompileOptions): void {
2192
+ const applied = new Set<string>();
2193
+ const applyChain = (rollup: Rollup): void => {
2194
+ for (const r of rollup.viewChain) {
2195
+ const key = foldSqlIdentifier(r.projectionName);
2196
+ if (applied.has(key)) continue;
2197
+ applied.add(key);
2198
+ db.exec(`DROP VIEW IF EXISTS main.${quoteIdent(r.projectionName)};`);
2199
+ db.exec(r.viewDdl(options));
2200
+ }
2201
+ };
2202
+ for (const rollup of this.#byName.values()) applyChain(rollup);
2203
+ }
2204
+
2205
+ clear(): void {
2206
+ this.#byName.clear();
2207
+ }
2208
+ }
2209
+
2210
+ /** Process-wide rollup registry — the concrete registration point apps/sidecars import and call. */
2211
+ export const rollupRegistry = new RollupRegistry();
2212
+
2213
+ // ─────────────────────────────────────── Rollup parity guard ─────────────────────────────────────
2214
+
2215
+ /** Options for {@link assertRollupParity}. */
2216
+ export interface RollupParityOptions {
2217
+ readonly onMismatch?: (message: string) => never;
2218
+ readonly sql?: SqlCompileOptions;
2219
+ }
2220
+
2221
+ /**
2222
+ * The framework PARITY GUARD for grouped rollups (#468) — the GROUP-BY sibling of
2223
+ * {@link assertReadModelParity}. For each input-set it materialises the rollup's managed VIEW (and any
2224
+ * composed dependency VIEWs) over throwaway TEMP fixtures built from the leaf rows, reads the SQL-derived
2225
+ * group rows, computes the TS `reduce` for the same inputs, and asserts they agree as a SET keyed by the
2226
+ * group columns (GROUP BY output order is unspecified, so rows are matched by key, not position).
2227
+ */
2228
+ export function assertRollupParity(
2229
+ rollup: Rollup,
2230
+ db: ParityDb,
2231
+ sampleSets: RollupInputs[],
2232
+ options: RollupParityOptions = {},
2233
+ ): void {
2234
+ const onMismatch = options.onMismatch ?? defaultOnMismatch;
2235
+ const fixtureCols = rollup.fixtureColumns();
2236
+ const relations = [...fixtureCols.keys()];
2237
+ // Fixtures are keyed by LOGICAL relation name (what `RollupInputs`/`reduce` read), but the managed VIEW
2238
+ // reads the PHYSICAL table `compileRollupSourceSql` resolves each projection/join relation to. Build the
2239
+ // TEMP tables under those physical names (via the same resolver the VIEW is compiled with, line below)
2240
+ // so the VIEW's `FROM` finds them; the column/row maps stay logical-keyed.
2241
+ const resolveTable = options.sql?.resolveProjectionTable ?? ((n: string) => projectionRegistry.sqlTableFor(n));
2242
+ const physicalTables = rollupSourcePhysicalTables(rollup.decl.source, resolveTable);
2243
+ const physical = (rel: string): string => physicalTables.get(rel) ?? rel;
2244
+
2245
+ const createFixture = (table: string, cols: Set<string>): void => {
2246
+ const colList = [...cols];
2247
+ const ddl = colList.length
2248
+ ? `CREATE TEMP TABLE ${quoteIdent(table)} (${colList.map((c) => quoteIdent(c)).join(", ")});`
2249
+ : `CREATE TEMP TABLE ${quoteIdent(table)} (_placeholder);`;
2250
+ db.exec(ddl);
2251
+ };
2252
+ const insertRow = (table: string, row: Record<string, unknown>): void => {
2253
+ const keys = Object.keys(row);
2254
+ if (keys.length === 0) {
2255
+ db.run(`INSERT INTO ${quoteIdent(table)} DEFAULT VALUES;`);
2256
+ return;
2257
+ }
2258
+ const placeholders = keys.map(() => "?").join(", ");
2259
+ db.run(
2260
+ `INSERT INTO ${quoteIdent(table)} (${keys.map((k) => quoteIdent(k)).join(", ")}) VALUES (${placeholders});`,
2261
+ keys.map((k) => normaliseSqlValue(row[k])),
2262
+ );
2263
+ };
2264
+ const dropFixtures = (): void => {
2265
+ // Drop dependent VIEWs before their base relations; drop the whole chain plus leaf fixtures.
2266
+ for (let i = rollup.viewChain.length - 1; i >= 0; i--) {
2267
+ db.exec(`DROP VIEW IF EXISTS temp.${quoteIdent(rollup.viewChain[i].projectionName)};`);
2268
+ }
2269
+ for (const rel of relations) db.exec(`DROP TABLE IF EXISTS temp.${quoteIdent(physical(rel))};`);
2270
+ };
2271
+
2272
+ // Widen each leaf fixture with any extra column keys present in the sample rows.
2273
+ const cols = new Map<string, Set<string>>();
2274
+ for (const [rel, set] of fixtureCols) cols.set(rel, new Set(set));
2275
+ for (const inputs of sampleSets) {
2276
+ for (const [rel, rows] of Object.entries(inputs)) {
2277
+ const set = cols.get(rel);
2278
+ if (!set) continue;
2279
+ for (const r of rows) for (const k of Object.keys(r)) set.add(k);
2280
+ }
2281
+ }
2282
+
2283
+ dropFixtures();
2284
+ try {
2285
+ for (const rel of relations) createFixture(physical(rel), cols.get(rel) ?? new Set());
2286
+ // Materialise the VIEW chain (dependencies first) as TEMP views over the leaf fixtures.
2287
+ for (const r of rollup.viewChain) db.exec(r.viewDdl(options.sql, { temp: true }));
2288
+
2289
+ const outputList = rollup.outputColumns.map((c) => quoteIdent(c)).join(", ");
2290
+ sampleSets.forEach((inputs, idx) => {
2291
+ for (const rel of relations) db.run(`DELETE FROM ${quoteIdent(physical(rel))};`);
2292
+ for (const [rel, rows] of Object.entries(inputs)) {
2293
+ if (!cols.has(rel)) continue;
2294
+ for (const r of rows) insertRow(physical(rel), r);
2295
+ }
2296
+
2297
+ const sqlRows = db.all<Record<string, unknown>>(
2298
+ `SELECT ${outputList} FROM ${quoteIdent(rollup.projectionName)};`,
2299
+ );
2300
+ const tsRows = rollup.reduce(inputs);
2301
+
2302
+ const keyOf = (row: Record<string, unknown>): string =>
2303
+ rollup.groupBy.map((g) => groupKeyPart(normaliseSqlValue(lookupColumn(row, g)))).join("\u0001");
2304
+ const sqlByKey = new Map<string, Record<string, unknown>>();
2305
+ for (const r of sqlRows) sqlByKey.set(keyOf(r), r);
2306
+ const tsByKey = new Map<string, Record<string, unknown>>();
2307
+ for (const r of tsRows) tsByKey.set(keyOf(r), r);
2308
+
2309
+ if (sqlByKey.size !== tsByKey.size) {
2310
+ onMismatch(
2311
+ `rollup parity mismatch in "${rollup.projectionName}" (sample #${idx}): ` +
2312
+ `SQL produced ${sqlByKey.size} group(s) vs TS ${tsByKey.size}`,
2313
+ );
2314
+ }
2315
+ for (const [key, tsRow] of tsByKey) {
2316
+ const sqlRow = sqlByKey.get(key);
2317
+ if (!sqlRow) {
2318
+ onMismatch(`rollup parity mismatch in "${rollup.projectionName}" (sample #${idx}): TS group ${key} missing from SQL`);
2319
+ continue;
2320
+ }
2321
+ for (const c of rollup.outputColumns) {
2322
+ const sqlValue = normaliseSqlValue(sqlRow[c]);
2323
+ const tsValue = normaliseSqlValue(tsRow[c]);
2324
+ if (!Object.is(sqlValue, tsValue)) {
2325
+ onMismatch(
2326
+ `rollup parity mismatch in "${rollup.projectionName}".${c} (sample #${idx}, group ${key}): ` +
2327
+ `SQL=${formatParityValue(sqlValue)} vs TS=${formatParityValue(tsValue)}`,
2328
+ );
2329
+ }
2330
+ }
2331
+ }
2332
+ });
2333
+ } finally {
2334
+ dropFixtures();
2335
+ }
2336
+ }