@nanobpm/urban 0.80.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.
Files changed (37) hide show
  1. package/dist/runtime/core/manifest.d.ts +44 -11
  2. package/dist/runtime/core/modules/cancel.d.ts +13 -8
  3. package/dist/runtime/core/modules/cancel.js +40 -21
  4. package/dist/runtime/core/modules/datasource.js +7 -4
  5. package/dist/runtime/core/modules/gateway.js +5 -4
  6. package/dist/runtime/core/modules/instance-status-read-model.d.ts +56 -0
  7. package/dist/runtime/core/modules/instance-status-read-model.js +127 -0
  8. package/dist/runtime/core/modules/instance-tracking.d.ts +47 -19
  9. package/dist/runtime/core/modules/instance-tracking.js +451 -123
  10. package/dist/runtime/core/modules/open-user-tasks-store.d.ts +5 -0
  11. package/dist/runtime/core/modules/open-user-tasks-store.js +8 -0
  12. package/dist/runtime/core/modules/pages.js +6 -4
  13. package/dist/runtime/core/modules/workers.js +25 -1
  14. package/dist/runtime/core/read-model.d.ts +296 -5
  15. package/dist/runtime/core/read-model.js +1077 -39
  16. package/dist/runtime/core/validate.js +192 -0
  17. package/dist/runtime/index.d.ts +4 -3
  18. package/dist/runtime/index.js +11 -1
  19. package/package.json +1 -1
  20. package/src/runtime/core/manifest.ts +44 -11
  21. package/src/runtime/core/modules/cancel.test.ts +112 -38
  22. package/src/runtime/core/modules/cancel.ts +42 -22
  23. package/src/runtime/core/modules/datasource.ts +7 -4
  24. package/src/runtime/core/modules/gateway.ts +7 -4
  25. package/src/runtime/core/modules/instance-status-read-model.ts +156 -0
  26. package/src/runtime/core/modules/instance-tracking.test.ts +589 -60
  27. package/src/runtime/core/modules/instance-tracking.ts +469 -126
  28. package/src/runtime/core/modules/open-user-tasks-store.test.ts +11 -0
  29. package/src/runtime/core/modules/open-user-tasks-store.ts +11 -0
  30. package/src/runtime/core/modules/pages.ts +6 -5
  31. package/src/runtime/core/modules/workers.ts +24 -1
  32. package/src/runtime/core/read-model.test.ts +94 -0
  33. package/src/runtime/core/read-model.ts +1356 -45
  34. package/src/runtime/core/rollup.test.ts +818 -0
  35. package/src/runtime/core/validate.test.ts +341 -0
  36. package/src/runtime/core/validate.ts +188 -0
  37. package/src/runtime/index.ts +58 -0
@@ -56,6 +56,11 @@ export declare class OpenUserTasksStore {
56
56
  syncInstance(processInstanceKey: string, openTasks: readonly UserTaskSummary[]): void;
57
57
  /** Retire every projected open task for an instance (e.g. it terminated). Idempotent. */
58
58
  clearInstance(processInstanceKey: string): void;
59
+ /** Every distinct instance key currently projected as having ≥1 open user task. Used by the
60
+ * reconciler to re-probe (and, when answered, RETIRE) keys that have dropped out of a binding's
61
+ * `activeStatuses` allow-list — a projected row must be retired independently of the base-row
62
+ * selector, or an answered escalation's row would linger and the derived edge tear (ADR 0065). */
63
+ instanceKeys(): string[];
59
64
  /** True iff the instance currently has any open user task — the "parked on a human" predicate. */
60
65
  hasOpenTask(processInstanceKey: string): boolean;
61
66
  /** The projected open tasks for an instance, in insertion order. */
@@ -113,6 +113,14 @@ export class OpenUserTasksStore {
113
113
  return;
114
114
  this.#db.run(`DELETE FROM ${OPEN_USER_TASKS_TABLE} WHERE process_instance_key = ?`, [processInstanceKey]);
115
115
  }
116
+ /** Every distinct instance key currently projected as having ≥1 open user task. Used by the
117
+ * reconciler to re-probe (and, when answered, RETIRE) keys that have dropped out of a binding's
118
+ * `activeStatuses` allow-list — a projected row must be retired independently of the base-row
119
+ * selector, or an answered escalation's row would linger and the derived edge tear (ADR 0065). */
120
+ instanceKeys() {
121
+ const rows = this.#db.all(`SELECT DISTINCT process_instance_key FROM ${OPEN_USER_TASKS_TABLE}`);
122
+ return rows.map((r) => r.process_instance_key);
123
+ }
116
124
  /** True iff the instance currently has any open user task — the "parked on a human" predicate. */
117
125
  hasOpenTask(processInstanceKey) {
118
126
  if (!processInstanceKey)
@@ -32,6 +32,7 @@ import { FORMJS_JS } from "./formjs.gen.js";
32
32
  import { completeUserTaskResponse, resolveFormResponse } from "./forms.js";
33
33
  import { quoteIdent } from "./gateway.js";
34
34
  import { RENDERER_JS } from "./runtime.gen.js";
35
+ import { isSqlIdentifier } from "../read-model.js";
35
36
  /** A JavaScript-body response (the renderer module served at the fingerprinted
36
37
  * `/app/runtime.<hash>.js`, and its `/app/runtime.js` back-compat route). */
37
38
  function javascript(body, status = 200, extraHeaders) {
@@ -118,9 +119,10 @@ async function serveAsset(read, filePath) {
118
119
  return { status: 404, headers: { "content-type": "text/plain; charset=utf-8" }, body: "not found" };
119
120
  }
120
121
  }
121
- /** A SQL identifier guard — a table name must be a bare identifier *and* a known table
122
- * (checked against `schema()`), so `/app/data/:table` can never inject SQL. */
123
- const IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
122
+ /** A table name must be a bare SQL identifier ({@link isSqlIdentifier}, the runtime-wide single source
123
+ * of truth) *and* a known table (checked against `schema()`), so `/app/data/:table` can never inject
124
+ * SQL. Reusing the shared guard (rather than a private regex) keeps this surface from drifting from
125
+ * manifest / read-model / data-source identifier validation. */
124
126
  // A query flag (e.g. ?count=1) is "on" when present and not one of the falsy
125
127
  // spellings. A bare `?count` (empty string) still counts as on, so a caller can
126
128
  // opt in without inventing a value.
@@ -299,7 +301,7 @@ export function createPagesRoutes(opts, deps) {
299
301
  // source is rejected rather than silently served off the default.
300
302
  if (source !== sourceName)
301
303
  return json({ error: `unknown datasource "${source}"` }, 404);
302
- if (!IDENT.test(table))
304
+ if (!isSqlIdentifier(table))
303
305
  return json({ error: "invalid table name" }, 400);
304
306
  let tables;
305
307
  try {
@@ -7,7 +7,7 @@ import { runLlmJob } from "./llm.js";
7
7
  import { runInJobContext } from "../execContext.js";
8
8
  import { readLineage } from "../lineage.js";
9
9
  import { LineageStore } from "./lineage-store.js";
10
- import { readModelRegistry } from "../read-model.js";
10
+ import { readModelRegistry, rollupRegistry } from "../read-model.js";
11
11
  import { registerCanonicalProjections } from "./canonical-projections.js";
12
12
  import { InstanceStateStore } from "./instance-state-store.js";
13
13
  import { OpenUserTasksStore } from "./open-user-tasks-store.js";
@@ -99,6 +99,26 @@ function tryLineageStore(app, log) {
99
99
  return undefined;
100
100
  }
101
101
  }
102
+ /**
103
+ * Apply every managed rollup VIEW registered in the process-wide `rollupRegistry` (ADR 0065, #468) to
104
+ * the app's DEFAULT data source, in dependency order (a composed rollup after the rollup it reads).
105
+ * Mirrors {@link tryProvisionReadModelViews}: empty until a sibling task registers a rollup, and
106
+ * absent-/error-safe so a missing datasource or a VIEW-provisioning failure never breaks a worker mount.
107
+ */
108
+ function tryProvisionRollupViews(app, log) {
109
+ if (rollupRegistry.all().length === 0)
110
+ return;
111
+ if (!app.data.hasDefaultSource()) {
112
+ log("debug", "rollups: no default data source; managed VIEW provisioning skipped");
113
+ return;
114
+ }
115
+ try {
116
+ rollupRegistry.ensureViews(app.data.source().db);
117
+ }
118
+ catch (err) {
119
+ log("warn", "rollups: failed to provision managed VIEWs", { error: String(err) });
120
+ }
121
+ }
102
122
  /**
103
123
  * Apply every managed read-model VIEW registered in the process-wide `readModelRegistry` to the app's
104
124
  * DEFAULT data source (ADR 0065, surface #3). A `defineReadModel(...)` declaration compiles to a
@@ -202,6 +222,10 @@ export async function mountWorkers(ctx, app, sched = defaultScheduler()) {
202
222
  // provision the projection tables per source (absent-safe), and finally the read-model VIEWs.
203
223
  registerCanonicalProjections();
204
224
  tryProvisionCanonicalProjections(app, ctx.host.log);
225
+ // Rollup VIEWs (ADR 0065, #468) BEFORE the read-model VIEWs: a read model's key-correlated rollup
226
+ // lookup `LEFT JOIN`s a rollup's `*_counts` VIEW, and a composed rollup reads another rollup's VIEW,
227
+ // so every rollup VIEW (in dependency order) must exist before any dependent VIEW compiles.
228
+ tryProvisionRollupViews(app, ctx.host.log);
205
229
  tryProvisionReadModelViews(app, ctx.host.log);
206
230
  // Cache module loads so a multi-type handler module is imported once.
207
231
  const moduleCache = new Map();
@@ -10,7 +10,7 @@ export type Literal = string | number | boolean | null;
10
10
  * `col` references a column of the BASE row. `pcol` references a column of the CORRELATED projection
11
11
  * row and is only meaningful inside an {@link ExistsExpr} `where` predicate.
12
12
  */
13
- export type Expr = LitExpr | ColExpr | ProjColExpr | CompareExpr | AndExpr | OrExpr | NotExpr | CaseExpr | ExistsExpr;
13
+ export type Expr = LitExpr | ColExpr | ProjColExpr | RollupColExpr | CompareExpr | AndExpr | OrExpr | NotExpr | IsNullExpr | CaseExpr | ExistsExpr;
14
14
  export interface LitExpr {
15
15
  readonly kind: "lit";
16
16
  readonly value: Literal;
@@ -23,6 +23,13 @@ export interface ProjColExpr {
23
23
  readonly kind: "pcol";
24
24
  readonly name: string;
25
25
  }
26
+ export interface RollupColExpr {
27
+ readonly kind: "rcol";
28
+ /** The read-model rollup-lookup alias this column is read from (see {@link RollupLookupDecl}). */
29
+ readonly lookup: string;
30
+ /** The rollup output column referenced (a column of the looked-up `*_counts` rollup row). */
31
+ readonly name: string;
32
+ }
26
33
  export interface CompareExpr {
27
34
  readonly kind: "compare";
28
35
  readonly op: CompareOp;
@@ -41,6 +48,10 @@ export interface NotExpr {
41
48
  readonly kind: "not";
42
49
  readonly expr: Expr;
43
50
  }
51
+ export interface IsNullExpr {
52
+ readonly kind: "isNull";
53
+ readonly expr: Expr;
54
+ }
44
55
  export interface WhenClause {
45
56
  readonly when: Expr;
46
57
  readonly then: Expr;
@@ -63,6 +74,13 @@ export declare const lit: (value: Literal) => LitExpr;
63
74
  export declare const col: (name: string) => ColExpr;
64
75
  /** A reference to a correlated PROJECTION-row column (only valid inside {@link exists}). */
65
76
  export declare const pcol: (name: string) => ProjColExpr;
77
+ /**
78
+ * A reference to a column of a key-correlated ROLLUP LOOKUP row (see {@link ReadModelDecl.lookups}).
79
+ * `rcol("c", "prs_opened")` reads the `prs_opened` column of the rollup joined under alias `"c"` for
80
+ * the base row (compiling to `LEFT JOIN <rollup> ON <key>` on the SQL side / the matching group row on
81
+ * the TS side). Only valid in a {@link defineReadModel} whose `lookups` declares that alias.
82
+ */
83
+ export declare const rcol: (lookup: string, name: string) => RollupColExpr;
66
84
  export declare const eq: (left: Expr, right: Expr) => CompareExpr;
67
85
  export declare const neq: (left: Expr, right: Expr) => CompareExpr;
68
86
  export declare const lt: (left: Expr, right: Expr) => CompareExpr;
@@ -72,6 +90,10 @@ export declare const gte: (left: Expr, right: Expr) => CompareExpr;
72
90
  export declare const and: (...clauses: Expr[]) => AndExpr;
73
91
  export declare const or: (...clauses: Expr[]) => OrExpr;
74
92
  export declare const not: (expr: Expr) => NotExpr;
93
+ /** `<expr> IS NULL` — a null test (1/0 in SQL; `value == null` in TS). `not(isNull(x))` is `IS NOT NULL`. */
94
+ export declare const isNull: (expr: Expr) => IsNullExpr;
95
+ /** Sugar for `not(isNull(expr))` — `<expr> IS NOT NULL`. */
96
+ export declare const isNotNull: (expr: Expr) => NotExpr;
75
97
  /** Build a `CASE WHEN … THEN … [WHEN …] ELSE … END` expression. */
76
98
  export declare const caseWhen: (whens: WhenClause[], otherwise: Expr) => CaseExpr;
77
99
  /** Sugar for a single `{ when, then }` clause used by {@link caseWhen}. */
@@ -127,22 +149,72 @@ export interface SqlCompileOptions {
127
149
  readonly baseAlias?: string;
128
150
  /** Resolve a projection NAME to its physical SQL table/view. Defaults to {@link projectionRegistry}. */
129
151
  readonly resolveProjectionTable?: (name: string) => string;
152
+ /**
153
+ * Resolve a {@link col} reference to its qualified SQL expression, overriding the default
154
+ * `<baseAlias>."name"`. A rollup over a key-JOIN uses this to map a flat output-column name onto the
155
+ * correct joined relation (`col("pr_status")` → `"r"."status"`) so the closed {@link Expr} predicate
156
+ * machinery is reused unchanged across single-relation and join sources.
157
+ */
158
+ readonly resolveColumn?: (name: string) => string;
159
+ /**
160
+ * Resolve an {@link rcol} reference (a key-correlated rollup-lookup column) to its qualified SQL
161
+ * expression. {@link defineReadModel} supplies this from its `lookups` so the lookup's NULL default
162
+ * (a LEFT-JOIN miss) compiles to `COALESCE(<alias>."name", <default>)`.
163
+ */
164
+ readonly resolveRollupColumn?: (lookup: string, name: string) => string;
130
165
  }
166
+ /** A conservative SQL identifier guard — we interpolate names directly into DDL/SQL. This is the
167
+ * single source of truth for "is this a legal identifier"; the manifest validator imports it (rather
168
+ * than re-declaring the regex) so a name accepted at author time is accepted at VIEW provisioning too
169
+ * (No Drift Surfaces — a divergence would let a manifest validate but fail at boot). */
170
+ export declare const SQL_IDENT: RegExp;
171
+ /** True iff `name` is a legal SQL identifier per {@link SQL_IDENT}. The boolean twin of
172
+ * {@link assertSqlIdentifier}, for callers (e.g. the manifest validator) that collect issues rather
173
+ * than throw. */
174
+ export declare function isSqlIdentifier(name: string): boolean;
131
175
  export declare function assertSqlIdentifier(kind: string, name: string): string;
176
+ /** Object-name prefixes reserved for engine/runtime internals. Tables and views whose names begin
177
+ * with one of these are Nano bookkeeping (`_urban_*` / `_nano_*`) or SQLite internals (`sqlite_*`):
178
+ * the provenance recorder skips them (`modules/datasource.ts`) and the datasource `schema()` surface
179
+ * filters them out (`modules/gateway.ts`), so they are invisible to `/app/data`, DB Manager, and forms.
180
+ * This is the single source of truth for that prefix set (No Drift Surfaces — a manifest that provisions
181
+ * a `_urban_*` VIEW would otherwise validate yet be unreadable by the documented pages surface). */
182
+ export declare const RESERVED_OBJECT_PREFIXES: readonly ["_urban_", "_nano_", "sqlite_"];
183
+ /** True iff `name` begins with a {@link RESERVED_OBJECT_PREFIXES} prefix. Case-insensitive, matching
184
+ * SQLite's ASCII identifier folding (a `_URBAN_x` table resolves to the same reserved object as
185
+ * `_urban_x`), so a reserved name cannot slip through by casing. */
186
+ export declare function isReservedObjectName(name: string): boolean;
132
187
  /**
133
188
  * Compile ONE derivation expression to a SQLite select-list expression string — the derived column's
134
189
  * body inside the managed VIEW. Both backends walk the SAME AST, so the SQL here and the function
135
190
  * {@link compileToFn} produces cannot diverge.
136
191
  */
137
192
  export declare function compileToSqlSelect(expr: Expr, options?: SqlCompileOptions): string;
193
+ /** The resolved single rollup-lookup rows for one evaluation, keyed by the FOLDED (case-insensitive,
194
+ * SQLite-folded) lookup alias — see {@link rcol}, whose `node.lookup` is folded the same way on read.
195
+ * Every declared lookup alias maps to exactly one resolved row: the matched rollup group with its
196
+ * missing/NULL columns already filled from the lookup's declared defaults (the TS twin of the VIEW's
197
+ * `COALESCE(alias.col, default)`). A LEFT-JOIN miss is therefore represented as a defaults/NULL-filled
198
+ * row — NOT an absent alias — so `rcol` always reads a stable object. Framework resolvers
199
+ * ({@link ReadModel.evaluate}, the parity guard) populate this; a raw `fnFor` caller on a model with
200
+ * lookups must pass the resolved map. */
201
+ export type LookupRows = Record<string, Record<string, unknown>>;
138
202
  /** The compiled runtime derivation: same value as the SQL VIEW, computed in-process. */
139
- export type DerivationFn = (baseRow: BaseRow, projections?: ProjectionRows) => unknown;
203
+ export type DerivationFn = (baseRow: BaseRow, projections?: ProjectionRows, lookups?: LookupRows) => unknown;
140
204
  /**
141
205
  * Compile ONE derivation expression to a TS function computing the SAME value as {@link compileToSqlSelect}.
142
206
  * The function reads the base row for `col`, and — for `exists` — the `projections` argument for the
143
207
  * named projection's rows (the TS side of the projection-name seam). Both backends walk the SAME AST.
144
208
  */
145
209
  export declare function compileToFn(expr: Expr): DerivationFn;
210
+ /** Resolve a column value the way SQLite resolves an identifier: an exact-key match first, then a
211
+ * case-insensitive fallback. SQLite folds ASCII identifier case even for quoted identifiers, so the
212
+ * VIEW's `col("Status")` binds to a table declared with column `status`; a plain case-sensitive JS
213
+ * `row["Status"]` would miss it and return null, breaking VIEW/TS parity on the no-edge fallback and on
214
+ * `keyField` correlation. Fold here so both backends read the same column. (A table cannot declare two
215
+ * columns differing only in case, so the fallback is unambiguous.) Returns `null` for a missing column,
216
+ * preserving the previous `?? null` contract. */
217
+ export declare function lookupColumn(row: Record<string, unknown>, name: string): unknown;
146
218
  /** The declarative read-model input passed to {@link defineReadModel}. */
147
219
  export interface ReadModelDecl {
148
220
  /** The managed VIEW name (also the read-model identifier). */
@@ -159,6 +231,37 @@ export interface ReadModelDecl {
159
231
  * ones. Defaults to true (a read model is usually the base row PLUS its derived columns).
160
232
  */
161
233
  readonly selectBaseColumns?: boolean;
234
+ /**
235
+ * Optional key-correlated ROLLUP LOOKUPS (ADR 0065, #468). Each declares a `LEFT JOIN <rollup> ON
236
+ * <key>` so a derived column can read the rollup's `*_counts` columns for the base row via
237
+ * {@link rcol}. This is the per-row half of the layering: the aggregate is single-sourced in
238
+ * {@link defineRollup}; the read model consumes its columns for a `CASE`-style per-row signal (the
239
+ * `plan_delivery`/`plan_wave_progress` shape). The join `on` must map the rollup's FULL group key so
240
+ * the lookup is single-valued (no fan-out of base rows).
241
+ */
242
+ readonly lookups?: readonly RollupLookupDecl[];
243
+ }
244
+ /** One key-correlated rollup lookup on a {@link defineReadModel} (see {@link ReadModelDecl.lookups}). */
245
+ export interface RollupLookupDecl {
246
+ /** The alias `rcol(alias, …)` reads this lookup's columns under (a SQL identifier). */
247
+ readonly as: string;
248
+ /** The rollup whose `*_counts` VIEW is joined (its `name` is the physical relation, its columns the
249
+ * readable set). Pass the {@link Rollup} object so the read model can validate referenced columns. */
250
+ readonly rollup: Rollup;
251
+ /**
252
+ * The equi-join key: each `{ base, rollup }` pair equates a base column to a rollup group-key column.
253
+ * Must cover the rollup's ENTIRE `groupBy` so the joined row is unique (≤1 match per base row).
254
+ */
255
+ readonly on: ReadonlyArray<{
256
+ readonly base: string;
257
+ readonly rollup: string;
258
+ }>;
259
+ /**
260
+ * Optional per-column defaults substituted when the join misses (no matching rollup group) or the
261
+ * rollup value is NULL — the `COALESCE(<alias>."col", <default>)` in the hand-authored VIEWs. Applied
262
+ * identically in both backends so an epic with no PRs reads `0` rather than NULL.
263
+ */
264
+ readonly defaults?: Record<string, Literal>;
162
265
  }
163
266
  /** A compiled read model: the declaration plus its derived SQL, derived functions, and managed VIEW. */
164
267
  export interface ReadModel {
@@ -169,8 +272,18 @@ export interface ReadModel {
169
272
  sqlSelectFor(column: string, options?: SqlCompileOptions): string;
170
273
  /** The TS derivation function for one derived column. */
171
274
  fnFor(column: string): DerivationFn;
172
- /** Evaluate ALL derived columns for a base row in-process (the TS backend of the whole model). */
173
- evaluate(baseRow: BaseRow, projections?: ProjectionRows): Record<string, unknown>;
275
+ /** Evaluate ALL derived columns for a base row in-process (the TS backend of the whole model). The
276
+ * optional `lookupRows` supplies each rollup-lookup's candidate rows (keyed by lookup alias); the
277
+ * model resolves the single matching group row per lookup (applying declared defaults) before
278
+ * evaluating {@link rcol} references. */
279
+ evaluate(baseRow: BaseRow, projections?: ProjectionRows, lookupRows?: Record<string, ReadonlyArray<Record<string, unknown>>>): Record<string, unknown>;
280
+ /** Resolve each declared rollup lookup's single matching group row for `baseRow` (match on the join
281
+ * keys, fill declared defaults), producing the {@link LookupRows} a {@link DerivationFn} reads via
282
+ * {@link rcol}. The single source of the lookup-resolution rule: {@link evaluate} and the parity guard
283
+ * both call this so they can never drift. Throws if a lookup's candidate set yields MORE than one
284
+ * match for the join key — the lookup is required to be single-valued (its join covers the rollup's
285
+ * full group key), so multiple matches would fan out in SQL while TS silently picked one. */
286
+ resolveLookups(baseRow: BaseRow, lookupRows: Record<string, ReadonlyArray<Record<string, unknown>>>): LookupRows;
174
287
  /** The managed `CREATE VIEW` DDL (seam (a) applies this at boot). Pass `{ temp: true }` to emit a
175
288
  * `CREATE TEMP VIEW` in SQLite's TEMP schema (used by the parity guard so it never touches the
176
289
  * application's real objects); the SELECT body is byte-identical either way. */
@@ -223,10 +336,15 @@ export declare class ReadModelRegistry {
223
336
  * task registers its derived status-edge read model here so the runtime provisions its VIEW at boot.
224
337
  */
225
338
  export declare const readModelRegistry: ReadModelRegistry;
226
- /** A single parity check case: a base row plus the projection rows in scope for it. */
339
+ /** A single parity check case: a base row, the projection rows in scope for it, and (for a read model
340
+ * with rollup lookups) the candidate rollup rows per lookup alias. */
227
341
  export interface ParitySample {
228
342
  readonly baseRow: BaseRow;
229
343
  readonly projections?: ProjectionRows;
344
+ /** Candidate rows for each declared rollup lookup, keyed by lookup alias (see {@link rcol}). The
345
+ * guard inserts them into the lookup rollup's fixture table so the VIEW's `LEFT JOIN` materialises,
346
+ * and hands the same rows to the TS backend to resolve — closing the lookup's parity surface. */
347
+ readonly lookups?: Record<string, ReadonlyArray<Record<string, unknown>>>;
230
348
  }
231
349
  /** The seam a parity guard needs to run the SQL backend: a SQLite handle it can create/query/drop. */
232
350
  export interface ParityDb {
@@ -265,3 +383,176 @@ export interface ParityOptions {
265
383
  * tables/views the caller's DB happens to hold under the same names.
266
384
  */
267
385
  export declare function assertReadModelParity(model: ReadModel, db: ParityDb, samples: ParitySample[], options?: ParityOptions): void;
386
+ /** A closed, flat GROUP-BY aggregate expression (D2). `coalesce`/`add` wrap other aggregates so the
387
+ * exact `COALESCE(MIN(…), MAX(…))` and `MAX(…)+1` shapes the plan VIEWs use are expressible; every
388
+ * variant is handled exhaustively by both backends (SQL + TS reduce). */
389
+ export type AggExpr = CountAgg | CountWhereAgg | MaxAgg | MinWhereAgg | CoalesceAgg | AddAgg;
390
+ export interface CountAgg {
391
+ readonly kind: "count";
392
+ /** When set, `COUNT(col)` (non-NULL rows); omitted → `COUNT(*)` (all group rows). */
393
+ readonly column?: string;
394
+ }
395
+ export interface CountWhereAgg {
396
+ readonly kind: "countWhere";
397
+ /** `SUM(CASE WHEN pred THEN 1 ELSE 0 END)` — count of group rows satisfying the closed predicate. */
398
+ readonly pred: Expr;
399
+ }
400
+ export interface MaxAgg {
401
+ readonly kind: "max";
402
+ readonly column: string;
403
+ }
404
+ export interface MinWhereAgg {
405
+ readonly kind: "minWhere";
406
+ /** `MIN(CASE WHEN pred THEN col END)` — min of `col` over rows satisfying `pred` (NULL if none). */
407
+ readonly column: string;
408
+ readonly pred: Expr;
409
+ }
410
+ export interface CoalesceAgg {
411
+ readonly kind: "coalesce";
412
+ readonly a: AggExpr;
413
+ readonly b: AggExpr;
414
+ }
415
+ export interface AddAgg {
416
+ readonly kind: "add";
417
+ readonly expr: AggExpr;
418
+ /** An INTEGER literal added to `expr` (e.g. `MAX(wave) + 1`). NULL + literal is NULL, both backends. */
419
+ readonly literal: number;
420
+ }
421
+ /** `COUNT(col)` (non-NULL) when `column` is given, else `COUNT(*)`. */
422
+ export declare const count: (column?: string) => CountAgg;
423
+ /** `SUM(CASE WHEN pred THEN 1 ELSE 0 END)` — the count of group rows satisfying `pred`. */
424
+ export declare const countWhere: (pred: Expr) => CountWhereAgg;
425
+ /** `MAX(col)` over the group (ignores NULLs; NULL for an all-NULL/empty group). */
426
+ export declare const max: (column: string) => MaxAgg;
427
+ /** `MIN(CASE WHEN pred THEN col END)` — the min of `col` over group rows satisfying `pred`. */
428
+ export declare const minWhere: (column: string, pred: Expr) => MinWhereAgg;
429
+ /** `COALESCE(a, b)` over two aggregates — `a` unless it is NULL, then `b`. */
430
+ export declare const coalesce: (a: AggExpr, b: AggExpr) => CoalesceAgg;
431
+ /** `expr + <integer literal>` (e.g. `MAX(wave) + 1`). Rejects a non-integer literal at build time. */
432
+ export declare const add: (expr: AggExpr, literal: number) => AddAgg;
433
+ /** The relation a rollup groups over. `table`/`projection`/`rollup` expose their columns by name; a
434
+ * key-`join` exposes a FLAT namespace mapped by {@link RollupJoinSource.columns} (the two-hop reach —
435
+ * D4). All three are single-mechanism composable: a `rollup` source is another {@link Rollup}. */
436
+ export type RollupSource = RollupTableSource | RollupProjectionSource | RollupJoinSource | RollupRollupSource;
437
+ export interface RollupTableSource {
438
+ readonly kind: "table";
439
+ readonly table: string;
440
+ }
441
+ export interface RollupProjectionSource {
442
+ readonly kind: "projection";
443
+ readonly projection: string;
444
+ }
445
+ /** One relation in a {@link RollupJoinSource} — a base table/view/projection under a join alias. */
446
+ export interface RollupRelation {
447
+ readonly relation: string;
448
+ readonly alias: string;
449
+ }
450
+ export interface RollupJoinSource {
451
+ readonly kind: "join";
452
+ /** `"left"` (default) or `"inner"`. LEFT keeps unmatched left rows with NULL right columns. */
453
+ readonly type?: "left" | "inner";
454
+ readonly left: RollupRelation;
455
+ readonly right: RollupRelation;
456
+ /** Equi-join key(s): `left.<left> = right.<right>`. */
457
+ readonly on: ReadonlyArray<{
458
+ readonly left: string;
459
+ readonly right: string;
460
+ }>;
461
+ /**
462
+ * The FLAT output namespace: output column name → `[side, physicalColumn]`. This resolves collisions
463
+ * (both sides may have a `status`) and is what `groupBy`/aggregate `col(...)` references resolve
464
+ * against, so the closed predicate/aggregate machinery is reused unchanged over a join.
465
+ */
466
+ readonly columns: Record<string, readonly [side: "left" | "right", column: string]>;
467
+ }
468
+ export interface RollupRollupSource {
469
+ readonly kind: "rollup";
470
+ readonly rollup: Rollup;
471
+ }
472
+ /** A rollup over a base table. */
473
+ export declare const fromTable: (table: string) => RollupTableSource;
474
+ /** A rollup over a canonical projection (resolved to its physical relation via {@link projectionRegistry}). */
475
+ export declare const fromProjection: (projection: string) => RollupProjectionSource;
476
+ /** A rollup over another rollup's `*_counts` VIEW (composable — e.g. wave's two levels). */
477
+ export declare const fromRollup: (rollup: Rollup) => RollupRollupSource;
478
+ /** A rollup over a declared key-join of two relations (the two-hop reach — D4). */
479
+ export declare const joinSource: (spec: Omit<RollupJoinSource, "kind">) => RollupJoinSource;
480
+ /** The in-memory rows the TS group-reduce sees per LEAF relation (base table/view/projection name). */
481
+ export type RollupInputs = Record<string, ReadonlyArray<Record<string, unknown>>>;
482
+ /** The declarative rollup input passed to {@link defineRollup}. */
483
+ export interface RollupDecl {
484
+ /** The managed VIEW name (also the rollup / canonical-projection identifier). */
485
+ readonly name: string;
486
+ /** The relation to group over (table / projection / key-join / another rollup). */
487
+ readonly source: RollupSource;
488
+ /** One or more group-key columns (referenced by name in the source's output namespace). */
489
+ readonly groupBy: readonly string[];
490
+ /** Optional pre-aggregation row filter (the `WHERE` — e.g. `isNotNull(col("wave"))`). */
491
+ readonly where?: Expr;
492
+ /** The aggregate columns: name → closed {@link AggExpr}. Authored ONCE, compiled to both backends. */
493
+ readonly aggregates: Record<string, AggExpr>;
494
+ }
495
+ /** A compiled rollup: the declaration plus both backends and the managed VIEW DDL. */
496
+ export interface Rollup {
497
+ readonly decl: RollupDecl;
498
+ /** The canonical-projection name (= `decl.name`). */
499
+ readonly projectionName: string;
500
+ /** The group-key columns (order preserved from the declaration). */
501
+ readonly groupBy: readonly string[];
502
+ /** All output columns: the group keys followed by the aggregate names (aggregates sorted by name). */
503
+ readonly outputColumns: readonly string[];
504
+ /** Leaf relation names this rollup reads, transitively (for fixtures / feeding the TS reduce). */
505
+ readonly sourceRelations: readonly string[];
506
+ /** This rollup and every rollup it composes, in dependency order (dependencies first) — the order the
507
+ * managed VIEWs must be created in. */
508
+ readonly viewChain: readonly Rollup[];
509
+ /** The SQLite select-list expression for one aggregate column. */
510
+ sqlAggFor(column: string, options?: SqlCompileOptions): string;
511
+ /** The in-process group-reduce (the TS backend): leaf rows → one output row per group. */
512
+ reduce(inputs: RollupInputs): Record<string, unknown>[];
513
+ /** The managed `CREATE VIEW` DDL. `{ temp: true }` emits a `CREATE TEMP VIEW` (parity-guard use). */
514
+ viewDdl(options?: SqlCompileOptions, viewOptions?: ViewDdlOptions): string;
515
+ /** Fixture column requirements per leaf relation (referenced physical columns), for the parity guard. */
516
+ fixtureColumns(): Map<string, Set<string>>;
517
+ }
518
+ /**
519
+ * Declare a GROUP-BY rollup ONCE (ADR 0065, #468). Returns a {@link Rollup} exposing both backends (the
520
+ * managed `*_counts` VIEW DDL and the TS group-reduce) driven from one closed spec, plus the metadata
521
+ * the parity guard and registry need. Register it in {@link rollupRegistry} to have the runtime apply
522
+ * its VIEW at boot and expose its name as a canonical projection.
523
+ */
524
+ export declare function defineRollup(decl: RollupDecl): Rollup;
525
+ /**
526
+ * The rollup registry (ADR 0065, #468) — the GROUP-BY sibling of {@link ReadModelRegistry}. Registering
527
+ * a {@link Rollup} both (a) queues its managed VIEW for boot provisioning (in dependency order) and (b)
528
+ * registers its name as a canonical projection so read-model rollup lookups resolve. Idempotent: a
529
+ * re-register whose VIEW DDL matches is a no-op; a conflicting redefinition throws.
530
+ */
531
+ export declare class RollupRegistry {
532
+ #private;
533
+ register(rollup: Rollup, projections?: ProjectionRegistry): void;
534
+ all(): Rollup[];
535
+ get(name: string): Rollup | undefined;
536
+ /** Apply every registered rollup's managed VIEW to a database, dependency-ordered (a composed rollup
537
+ * after the rollup it reads) and MANAGED (drop+recreate `main.`-qualified, mirroring
538
+ * {@link ReadModelRegistry.ensureViews}). Safe to call repeatedly. */
539
+ ensureViews(db: {
540
+ exec(sql: string): void;
541
+ }, options?: SqlCompileOptions): void;
542
+ clear(): void;
543
+ }
544
+ /** Process-wide rollup registry — the concrete registration point apps/sidecars import and call. */
545
+ export declare const rollupRegistry: RollupRegistry;
546
+ /** Options for {@link assertRollupParity}. */
547
+ export interface RollupParityOptions {
548
+ readonly onMismatch?: (message: string) => never;
549
+ readonly sql?: SqlCompileOptions;
550
+ }
551
+ /**
552
+ * The framework PARITY GUARD for grouped rollups (#468) — the GROUP-BY sibling of
553
+ * {@link assertReadModelParity}. For each input-set it materialises the rollup's managed VIEW (and any
554
+ * composed dependency VIEWs) over throwaway TEMP fixtures built from the leaf rows, reads the SQL-derived
555
+ * group rows, computes the TS `reduce` for the same inputs, and asserts they agree as a SET keyed by the
556
+ * group columns (GROUP BY output order is unspecified, so rows are matched by key, not position).
557
+ */
558
+ export declare function assertRollupParity(rollup: Rollup, db: ParityDb, sampleSets: RollupInputs[], options?: RollupParityOptions): void;