@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.
- package/dist/runtime/core/manifest.d.ts +44 -11
- package/dist/runtime/core/modules/cancel.d.ts +13 -8
- package/dist/runtime/core/modules/cancel.js +40 -21
- package/dist/runtime/core/modules/datasource.js +7 -4
- package/dist/runtime/core/modules/gateway.js +5 -4
- package/dist/runtime/core/modules/instance-status-read-model.d.ts +56 -0
- package/dist/runtime/core/modules/instance-status-read-model.js +127 -0
- package/dist/runtime/core/modules/instance-tracking.d.ts +47 -19
- package/dist/runtime/core/modules/instance-tracking.js +451 -123
- package/dist/runtime/core/modules/open-user-tasks-store.d.ts +5 -0
- package/dist/runtime/core/modules/open-user-tasks-store.js +8 -0
- package/dist/runtime/core/modules/pages.js +6 -4
- package/dist/runtime/core/modules/workers.js +25 -1
- package/dist/runtime/core/read-model.d.ts +296 -5
- package/dist/runtime/core/read-model.js +1077 -39
- package/dist/runtime/core/validate.js +192 -0
- package/dist/runtime/index.d.ts +4 -3
- package/dist/runtime/index.js +11 -1
- package/package.json +1 -1
- package/src/runtime/core/manifest.ts +44 -11
- package/src/runtime/core/modules/cancel.test.ts +112 -38
- package/src/runtime/core/modules/cancel.ts +42 -22
- package/src/runtime/core/modules/datasource.ts +7 -4
- package/src/runtime/core/modules/gateway.ts +7 -4
- package/src/runtime/core/modules/instance-status-read-model.ts +156 -0
- package/src/runtime/core/modules/instance-tracking.test.ts +589 -60
- package/src/runtime/core/modules/instance-tracking.ts +469 -126
- package/src/runtime/core/modules/open-user-tasks-store.test.ts +11 -0
- package/src/runtime/core/modules/open-user-tasks-store.ts +11 -0
- package/src/runtime/core/modules/pages.ts +6 -5
- package/src/runtime/core/modules/workers.ts +24 -1
- package/src/runtime/core/read-model.test.ts +94 -0
- package/src/runtime/core/read-model.ts +1356 -45
- package/src/runtime/core/rollup.test.ts +818 -0
- package/src/runtime/core/validate.test.ts +341 -0
- package/src/runtime/core/validate.ts +188 -0
- 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
|
|
122
|
-
* (checked against `schema()`), so `/app/data/:table` can never inject
|
|
123
|
-
|
|
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 (!
|
|
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
|
-
|
|
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
|
|
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;
|