@nanobpm/urban 0.77.4 → 0.78.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.
@@ -0,0 +1,792 @@
1
+ // read-model — the declare-once, compile-to-both derived read-model primitive (ADR 0065).
2
+ //
3
+ // An Urban app presents derived operator state that is a *pure function of canonical inputs* — a
4
+ // task's display stage, an instance's status edge, whether it is parked on a human. nano-workforce
5
+ // #412 made those display projections SQLite VIEWs so the stored/derived value could not tear from
6
+ // its inputs, but two drift surfaces survived that this module closes:
7
+ //
8
+ // • Surface #2 — a derived column authored TWICE: once as SQL (CASE/EXISTS) inside the VIEW, and
9
+ // once as a TS oracle (a `deriveStage`-style function) the app calls in-process, kept in
10
+ // lockstep only by a hand-written parity test (itself a drift surface).
11
+ // • Surface #3 — every read model hand-wired end to end (migration + VIEW + page binding +
12
+ // pages↔schema contract entry + parity test), re-authored per projection.
13
+ //
14
+ // The fix is to declare a derived read model ONCE, as a pure derivation over a base row plus named
15
+ // canonical projections, expressed in a small CLOSED expression DSL (a discriminated-union AST — NOT
16
+ // arbitrary SQL strings). A single compiler then emits BOTH backends from that one AST, so they
17
+ // cannot drift by construction:
18
+ //
19
+ // • {@link compileToSqlSelect} — the SQLite VIEW select-list expression for the derived column.
20
+ // • {@link compileToFn} — a runtime TS function `(baseRow, projections) => value` computing
21
+ // the same value in-process.
22
+ //
23
+ // From a {@link defineReadModel} declaration the framework then DERIVES the managed VIEW DDL
24
+ // ({@link deriveReadModelViewDdl}) and a PARITY GUARD ({@link assertReadModelParity}) an app calls
25
+ // with sample rows instead of hand-writing a bespoke parity test per projection.
26
+ //
27
+ // ─────────────────────────────────────────────────────────────────────────────────────────────────
28
+ // SHARED SEAMS FOR SIBLING TASKS (ADR 0065 rollout) — read this before extending:
29
+ //
30
+ // (a) Read-model registry — {@link ReadModelRegistry} + the process-wide {@link readModelRegistry}
31
+ // singleton. A sidecar/app registers a `defineReadModel(...)` result here; the runtime boot
32
+ // path (see `core/modules/workers.ts`) applies every registered model's managed VIEW to the
33
+ // app's data source. The `writer-source-inversion` task registers the derived status-edge read
34
+ // model here. NB: this is intentionally NAMED `readModelRegistry`, distinct from the toolkit's
35
+ // unrelated `readModels()` model-file scanner — they are different concepts.
36
+ //
37
+ // (b) Projection-name seam — {@link ProjectionRegistry} + the process-wide {@link projectionRegistry}
38
+ // singleton. A canonical engine-truth projection sidecar (the `canonical-projections` task,
39
+ // landing `urban_open_user_tasks` and `urban_instance_state`) registers a projection NAME so an
40
+ // `EXISTS(name, ...)` in the DSL resolves: the SQL backend learns which physical table/view to
41
+ // read, and the TS backend is handed the projection's rows at evaluate time via the
42
+ // `projections` argument. A read model can reference a projection by name even though the
43
+ // concrete sidecar lands in a later wave.
44
+ //
45
+ // (c) Barrel — everything here is re-exported from `packages/urban/src/runtime/index.ts` under the
46
+ // clearly-commented "Read models" section.
47
+ // ─────────────────────────────────────────────────────────────────────────────────────────────────
48
+ // ─────────────────────────────────────── Expression builders ─────────────────────────────────────
49
+ /** A literal scalar. */
50
+ export const lit = (value) => ({ kind: "lit", value });
51
+ /** A reference to a BASE-row column. */
52
+ export const col = (name) => ({ kind: "col", name });
53
+ /** A reference to a correlated PROJECTION-row column (only valid inside {@link exists}). */
54
+ export const pcol = (name) => ({ kind: "pcol", name });
55
+ const cmp = (op) => (left, right) => ({ kind: "compare", op, left, right });
56
+ export const eq = cmp("eq");
57
+ export const neq = cmp("neq");
58
+ export const lt = cmp("lt");
59
+ export const lte = cmp("lte");
60
+ export const gt = cmp("gt");
61
+ export const gte = cmp("gte");
62
+ export const and = (...clauses) => ({ kind: "and", clauses });
63
+ export const or = (...clauses) => ({ kind: "or", clauses });
64
+ export const not = (expr) => ({ kind: "not", expr });
65
+ /** Build a `CASE WHEN … THEN … [WHEN …] ELSE … END` expression. */
66
+ export const caseWhen = (whens, otherwise) => ({
67
+ kind: "case",
68
+ whens,
69
+ else: otherwise,
70
+ });
71
+ /** Sugar for a single `{ when, then }` clause used by {@link caseWhen}. */
72
+ export const when = (whenExpr, thenExpr) => ({ when: whenExpr, then: thenExpr });
73
+ /** `EXISTS (SELECT 1 FROM <projection> WHERE <where>)` — a correlated existence test. */
74
+ export const exists = (projection, where) => ({ kind: "exists", projection, where });
75
+ /**
76
+ * The projection-name registry (seam (b)). A canonical engine-truth projection sidecar registers its
77
+ * name here so a `defineReadModel` authored anywhere can `exists(name, …)` over it. Advisory/idempotent:
78
+ * re-registering the same name with a matching `sqlTable` is a no-op; a conflicting `sqlTable` throws
79
+ * so two sidecars cannot silently claim one name.
80
+ */
81
+ export class ProjectionRegistry {
82
+ #byName = new Map();
83
+ register(source) {
84
+ const key = foldSqlIdentifier(source.name);
85
+ const existing = this.#byName.get(key);
86
+ const sqlTable = source.sqlTable ?? source.name;
87
+ if (existing) {
88
+ if (existing.name !== source.name) {
89
+ // Case-only variant of an already-registered name (e.g. "Foo" vs "foo"). SQLite folds
90
+ // identifiers, so both denote ONE physical relation; allowing both would silently alias.
91
+ throw new Error(`projection "${source.name}" already registered as "${existing.name}"; SQLite folds ` +
92
+ `identifiers case-insensitively, so names differing only in case denote one relation`);
93
+ }
94
+ const existingTable = existing.sqlTable ?? existing.name;
95
+ if (existingTable !== sqlTable) {
96
+ throw new Error(`projection "${source.name}" already registered to table "${existingTable}"; ` +
97
+ `refusing to re-register to "${sqlTable}"`);
98
+ }
99
+ return;
100
+ }
101
+ assertSqlIdentifier("projection name", source.name);
102
+ assertSqlIdentifier("projection table", sqlTable);
103
+ this.#byName.set(key, { name: source.name, sqlTable });
104
+ }
105
+ /** The physical SQL table/view for a projection name; falls back to the name itself if unregistered
106
+ * (so a read model compiles against a not-yet-landed sidecar, which supplies the same-named table). */
107
+ sqlTableFor(name) {
108
+ return this.#byName.get(foldSqlIdentifier(name))?.sqlTable ?? name;
109
+ }
110
+ has(name) {
111
+ return this.#byName.has(foldSqlIdentifier(name));
112
+ }
113
+ names() {
114
+ return [...this.#byName.values()].map((s) => s.name);
115
+ }
116
+ /** Reset all registrations. Mirrors {@link ReadModelRegistry.clear} so tests and dev harnesses can
117
+ * restore this process-wide singleton to a deterministic empty state (isolation across test runs). */
118
+ clear() {
119
+ this.#byName.clear();
120
+ }
121
+ }
122
+ /**
123
+ * Process-wide projection registry — the concrete registration point sidecars import and call. The
124
+ * `canonical-projections` task registers `urban_open_user_tasks` and `urban_instance_state` here.
125
+ */
126
+ export const projectionRegistry = new ProjectionRegistry();
127
+ const DEFAULT_BASE_ALIAS = "base";
128
+ /**
129
+ * Resolve and validate the base-table alias. A caller-provided `baseAlias` is interpolated straight
130
+ * into DDL/SQL, so it must be a real identifier — validating here is the single source of truth for
131
+ * every call site (VIEW DDL + derivation compile).
132
+ */
133
+ function resolveBaseAlias(alias) {
134
+ return assertSqlIdentifier("base alias", alias ?? DEFAULT_BASE_ALIAS);
135
+ }
136
+ /** A conservative SQL identifier guard — we interpolate names directly into DDL/SQL. */
137
+ const SQL_IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
138
+ export function assertSqlIdentifier(kind, name) {
139
+ if (!SQL_IDENT.test(name)) {
140
+ throw new Error(`invalid ${kind} "${name}": must match ${SQL_IDENT.source}`);
141
+ }
142
+ return name;
143
+ }
144
+ function quoteIdent(name) {
145
+ return `"${name.replace(/"/g, '""')}"`;
146
+ }
147
+ /**
148
+ * Fold a (validated, ASCII) SQL identifier to its canonical case. SQLite compares identifiers
149
+ * case-insensitively, so names differing only in case (`"TASKS"` vs `"tasks"`) denote the SAME
150
+ * object; every identifier equality check and dedup key in this module MUST fold through here or it
151
+ * will miss such collisions and fail opaquely later at provisioning. `SQL_IDENT` restricts
152
+ * identifiers to ASCII `[A-Za-z0-9_]`, so `toLowerCase()` matches SQLite's ASCII case folding exactly.
153
+ */
154
+ function foldSqlIdentifier(name) {
155
+ return name.toLowerCase();
156
+ }
157
+ const SQL_COMPARE = {
158
+ eq: "=",
159
+ neq: "<>",
160
+ lt: "<",
161
+ lte: "<=",
162
+ gt: ">",
163
+ gte: ">=",
164
+ };
165
+ function sqlLiteral(value) {
166
+ if (value === null)
167
+ return "NULL";
168
+ if (typeof value === "boolean")
169
+ return value ? "1" : "0";
170
+ if (typeof value === "number") {
171
+ if (!Number.isFinite(value))
172
+ throw new Error(`non-finite numeric literal cannot compile to SQL: ${value}`);
173
+ return String(value);
174
+ }
175
+ return `'${value.replace(/'/g, "''")}'`;
176
+ }
177
+ /**
178
+ * Compile ONE derivation expression to a SQLite select-list expression string — the derived column's
179
+ * body inside the managed VIEW. Both backends walk the SAME AST, so the SQL here and the function
180
+ * {@link compileToFn} produces cannot diverge.
181
+ */
182
+ export function compileToSqlSelect(expr, options = {}) {
183
+ const baseAlias = resolveBaseAlias(options.baseAlias);
184
+ const resolveTable = options.resolveProjectionTable ?? ((n) => projectionRegistry.sqlTableFor(n));
185
+ // Reserved alias for the EXISTS projection relation, derived so it can never equal `baseAlias`: a
186
+ // projection whose physical table name happens to match the base alias would otherwise shadow the
187
+ // outer base row inside the sub-select, silently breaking `col(...)` correlation. Depth-indexed so
188
+ // nested EXISTS predicates each bind `pcol(...)` to their own projection, not the innermost one.
189
+ // SQLite compares identifiers case-insensitively, so a `baseAlias` that differs only by case (e.g.
190
+ // "__URBAN_PROJ_0") would still collide with the reserved alias — guard case-insensitively.
191
+ const projAliasAt = (depth) => {
192
+ let alias = `__urban_proj_${depth}`;
193
+ while (foldSqlIdentifier(alias) === foldSqlIdentifier(baseAlias))
194
+ alias = `_${alias}`;
195
+ return alias;
196
+ };
197
+ const walk = (node, projTable, existsDepth) => {
198
+ switch (node.kind) {
199
+ case "lit":
200
+ return sqlLiteral(node.value);
201
+ case "col":
202
+ return `${quoteIdent(baseAlias)}.${quoteIdent(node.name)}`;
203
+ case "pcol":
204
+ if (!projTable) {
205
+ throw new Error(`pcol("${node.name}") used outside an EXISTS projection context`);
206
+ }
207
+ return `${quoteIdent(projTable)}.${quoteIdent(node.name)}`;
208
+ case "compare":
209
+ // SQLite comparisons against NULL yield NULL, but the TS backend (compareValues) collapses any
210
+ // nullish operand to `false`/0. COALESCE the SQL result to 0 so both backends agree on NULL inputs.
211
+ return `COALESCE((${walk(node.left, projTable, existsDepth)} ${SQL_COMPARE[node.op]} ${walk(node.right, projTable, existsDepth)}), 0)`;
212
+ case "and":
213
+ // `NULL AND 0`/`NULL OR 0` can yield NULL in SQLite, but the TS backend coerces each clause through
214
+ // `truthy(...)` (NULL → false), so COALESCE the boolean expression to 0 to keep the 0/1 domain aligned.
215
+ return node.clauses.length
216
+ ? `COALESCE((${node.clauses.map((c) => walk(c, projTable, existsDepth)).join(" AND ")}), 0)`
217
+ : "1";
218
+ case "or":
219
+ return node.clauses.length
220
+ ? `COALESCE((${node.clauses.map((c) => walk(c, projTable, existsDepth)).join(" OR ")}), 0)`
221
+ : "0";
222
+ case "not":
223
+ // `NOT NULL` is NULL in SQLite, while the TS backend returns `!truthy(NULL)` → true. Compile as
224
+ // `NOT COALESCE(x, 0)` so `not(NULL)` is 1 in both backends under the shared "NULL → false" rule.
225
+ return `(NOT COALESCE(${walk(node.expr, projTable, existsDepth)}, 0))`;
226
+ case "case": {
227
+ const whens = node.whens
228
+ .map((w) => `WHEN ${walk(w.when, projTable, existsDepth)} THEN ${walk(w.then, projTable, existsDepth)}`)
229
+ .join(" ");
230
+ return `CASE ${whens} ELSE ${walk(node.else, projTable, existsDepth)} END`;
231
+ }
232
+ case "exists": {
233
+ const table = assertSqlIdentifier("projection table", resolveTable(node.projection));
234
+ // Alias the projection relation to a reserved name distinct from `baseAlias` so `col(...)` in the
235
+ // predicate always correlates to the OUTER base row (via `pcol` binds to this alias). The closed
236
+ // AST keeps the correlation explicit and injection-free.
237
+ const projAlias = projAliasAt(existsDepth);
238
+ return `EXISTS (SELECT 1 FROM ${quoteIdent(table)} AS ${quoteIdent(projAlias)} WHERE ${walk(node.where, projAlias, existsDepth + 1)})`;
239
+ }
240
+ }
241
+ };
242
+ return walk(expr, undefined, 0);
243
+ }
244
+ function compareValues(op, left, right) {
245
+ // SQL comparisons against NULL yield NULL, which is falsy in WHERE/CASE-WHEN contexts. Mirror that:
246
+ // any NULL/undefined operand makes eq/lt/… false and neq false too (SQL `x <> NULL` is also NULL).
247
+ const nullish = left === null || left === undefined || right === null || right === undefined;
248
+ if (nullish)
249
+ return false;
250
+ // Compare through the SAME scalar normalisation as ordering so the TS backend matches SQLite:
251
+ // booleans compile to 1/0 in SQL (see sqlLiteral), so `eq(col("x"), lit(true))` must be true when
252
+ // `x` is 1 (strict `===` would make `1 === true` false and drift); and 64-bit INTEGER keys arriving
253
+ // as `bigint` compare EXACTLY (never truncated through `Number()` — see compareOrderable).
254
+ const cmp = compareOrderable(orderable(left), orderable(right));
255
+ switch (op) {
256
+ case "eq":
257
+ return cmp === 0;
258
+ case "neq":
259
+ // A NaN (incomparable, e.g. NaN against a real number) is "not equal" → true.
260
+ return cmp !== 0;
261
+ case "lt":
262
+ return cmp < 0;
263
+ case "lte":
264
+ return cmp <= 0;
265
+ case "gt":
266
+ return cmp > 0;
267
+ case "gte":
268
+ return cmp >= 0;
269
+ }
270
+ }
271
+ /** Normalise a scalar to an orderable primitive for comparison, matching SQLite affinity: numbers
272
+ * compare numerically, booleans as 0/1, `bigint` INTEGERs are kept EXACT (so 64-bit keys past 2^53
273
+ * don't collapse together), everything else as its string form. Avoids unsafe casts. */
274
+ function orderable(value) {
275
+ if (typeof value === "number")
276
+ return value;
277
+ // SQLite INTEGERs can reach the TS backend as `bigint` (e.g. a 64-bit key in a base row). Keep the
278
+ // exact `bigint` — truncating via `Number()` would collapse two distinct keys beyond Number.MAX_-
279
+ // SAFE_INTEGER to one value and silently drift from the SQL VIEW (compareOrderable compares exactly).
280
+ if (typeof value === "bigint")
281
+ return value;
282
+ if (typeof value === "string")
283
+ return value;
284
+ if (typeof value === "boolean")
285
+ return value ? 1 : 0;
286
+ return String(value);
287
+ }
288
+ /** Three-way compare two {@link orderable} scalars the way SQLite's WHERE/ORDER BY would, WITHOUT
289
+ * truncating 64-bit `bigint` keys through `Number()`. Returns a negative/zero/positive number, or
290
+ * `NaN` only when two real numbers are genuinely incomparable (a `NaN` operand) — callers treat that
291
+ * as "not equal, not ordered". */
292
+ function compareOrderable(left, right) {
293
+ const leftText = typeof left === "string";
294
+ const rightText = typeof right === "string";
295
+ // Mixed numeric-vs-text. This TS backend mirrors the VIEW as exercised by the parity guard, whose
296
+ // TEMP fixtures declare NO column affinity: with affinity-free operands SQLite orders purely by
297
+ // storage class (NULL < INTEGER/REAL < TEXT) and applies no numeric-affinity coercion, so a numeric
298
+ // value always sorts BEFORE a text value and is never equal to one — even a numeric-looking "42".
299
+ // (A real base table that declared an INTEGER/TEXT affinity WOULD coerce across classes; the guard's
300
+ // affinity-free fixtures are what pins the two backends to this deterministic, coercion-free ordering.)
301
+ // Return that definite ordering directly: coercing the numeric side through `Number()` (as a naive
302
+ // fallback would) truncates a 64-bit `bigint` and could collapse distinct keys, reintroducing SQL/TS
303
+ // drift for both equality and ordering.
304
+ if (leftText !== rightText)
305
+ return leftText ? 1 : -1;
306
+ // Both TEXT → SQLite BINARY collation (byte / code-unit comparison).
307
+ if (leftText && rightText) {
308
+ if (left < right)
309
+ return -1;
310
+ if (left > right)
311
+ return 1;
312
+ return 0;
313
+ }
314
+ // Both numeric → stay in the integer domain when both are integral, so keys past
315
+ // Number.MAX_SAFE_INTEGER don't round together.
316
+ const lb = asExactBigInt(left);
317
+ const rb = asExactBigInt(right);
318
+ if (lb !== undefined && rb !== undefined)
319
+ return lb < rb ? -1 : lb > rb ? 1 : 0;
320
+ // A `bigint` reaching here pairs with a non-integer real, where exact integer comparison is moot;
321
+ // coerce it to a number so the operands share a type.
322
+ const l = typeof left === "bigint" ? Number(left) : left;
323
+ const r = typeof right === "bigint" ? Number(right) : right;
324
+ if (l < r)
325
+ return -1;
326
+ if (l > r)
327
+ return 1;
328
+ return l === r ? 0 : Number.NaN;
329
+ }
330
+ /** The exact `bigint` for an integer-valued numeric scalar, or `undefined` when it isn't an integer
331
+ * (a real/`NaN` number, or a string) — the caller then compares via `Number` instead. */
332
+ function asExactBigInt(value) {
333
+ if (typeof value === "bigint")
334
+ return value;
335
+ if (typeof value === "number" && Number.isInteger(value))
336
+ return BigInt(value);
337
+ return undefined;
338
+ }
339
+ /** True in SQL's CASE-WHEN / boolean sense, mirroring SQLite's numeric coercion: non-null, non-zero. */
340
+ function truthy(value) {
341
+ if (value === null || value === undefined)
342
+ return false;
343
+ if (typeof value === "boolean")
344
+ return value;
345
+ if (typeof value === "number")
346
+ return value !== 0;
347
+ if (typeof value === "bigint")
348
+ return value !== 0n;
349
+ if (typeof value === "string") {
350
+ // SQLite coerces a string to a number in a boolean context (`CASE WHEN '0'`/`'abc'` are false,
351
+ // `'2abc'` is true via its leading numeric prefix). Mirror that so the TS backend cannot drift.
352
+ const numeric = Number.parseFloat(value);
353
+ return Number.isFinite(numeric) && numeric !== 0;
354
+ }
355
+ return true;
356
+ }
357
+ /**
358
+ * Compile ONE derivation expression to a TS function computing the SAME value as {@link compileToSqlSelect}.
359
+ * The function reads the base row for `col`, and — for `exists` — the `projections` argument for the
360
+ * named projection's rows (the TS side of the projection-name seam). Both backends walk the SAME AST.
361
+ */
362
+ export function compileToFn(expr) {
363
+ const evalNode = (node, baseRow, projections, projRow) => {
364
+ switch (node.kind) {
365
+ case "lit":
366
+ return node.value;
367
+ case "col":
368
+ return baseRow[node.name] ?? null;
369
+ case "pcol":
370
+ if (!projRow)
371
+ throw new Error(`pcol("${node.name}") evaluated outside an EXISTS projection context`);
372
+ return projRow[node.name] ?? null;
373
+ case "compare":
374
+ return compareValues(node.op, evalNode(node.left, baseRow, projections, projRow), evalNode(node.right, baseRow, projections, projRow));
375
+ case "and":
376
+ return node.clauses.every((c) => truthy(evalNode(c, baseRow, projections, projRow)));
377
+ case "or":
378
+ return node.clauses.some((c) => truthy(evalNode(c, baseRow, projections, projRow)));
379
+ case "not":
380
+ return !truthy(evalNode(node.expr, baseRow, projections, projRow));
381
+ case "case": {
382
+ for (const w of node.whens) {
383
+ if (truthy(evalNode(w.when, baseRow, projections, projRow))) {
384
+ return evalNode(w.then, baseRow, projections, projRow);
385
+ }
386
+ }
387
+ return evalNode(node.else, baseRow, projections, projRow);
388
+ }
389
+ case "exists": {
390
+ const rows = projections[node.projection] ?? [];
391
+ return rows.some((r) => truthy(evalNode(node.where, baseRow, projections, r)));
392
+ }
393
+ }
394
+ };
395
+ return (baseRow, projections = {}) => evalNode(expr, baseRow, projections);
396
+ }
397
+ function collectProjectionNames(expr, into) {
398
+ switch (expr.kind) {
399
+ case "exists":
400
+ into.add(expr.projection);
401
+ collectProjectionNames(expr.where, into);
402
+ return;
403
+ case "compare":
404
+ collectProjectionNames(expr.left, into);
405
+ collectProjectionNames(expr.right, into);
406
+ return;
407
+ case "and":
408
+ case "or":
409
+ for (const c of expr.clauses)
410
+ collectProjectionNames(c, into);
411
+ return;
412
+ case "not":
413
+ collectProjectionNames(expr.expr, into);
414
+ return;
415
+ case "case":
416
+ for (const w of expr.whens) {
417
+ collectProjectionNames(w.when, into);
418
+ collectProjectionNames(w.then, into);
419
+ }
420
+ collectProjectionNames(expr.else, into);
421
+ return;
422
+ default:
423
+ return;
424
+ }
425
+ }
426
+ /** Collect the base-row columns (`col`) and per-projection columns (`pcol`, inside `exists`) an
427
+ * expression references. Used by the parity guard to build fixture tables carrying every referenced
428
+ * column even when a sample supplies no rows for a projection. */
429
+ function collectColumns(expr, baseCols, projCols, currentProjection) {
430
+ switch (expr.kind) {
431
+ case "col":
432
+ baseCols.add(expr.name);
433
+ return;
434
+ case "pcol":
435
+ if (currentProjection) {
436
+ const set = projCols.get(currentProjection) ?? new Set();
437
+ set.add(expr.name);
438
+ projCols.set(currentProjection, set);
439
+ }
440
+ return;
441
+ case "compare":
442
+ collectColumns(expr.left, baseCols, projCols, currentProjection);
443
+ collectColumns(expr.right, baseCols, projCols, currentProjection);
444
+ return;
445
+ case "and":
446
+ case "or":
447
+ for (const c of expr.clauses)
448
+ collectColumns(c, baseCols, projCols, currentProjection);
449
+ return;
450
+ case "not":
451
+ collectColumns(expr.expr, baseCols, projCols, currentProjection);
452
+ return;
453
+ case "case":
454
+ for (const w of expr.whens) {
455
+ collectColumns(w.when, baseCols, projCols, currentProjection);
456
+ collectColumns(w.then, baseCols, projCols, currentProjection);
457
+ }
458
+ collectColumns(expr.else, baseCols, projCols, currentProjection);
459
+ return;
460
+ case "exists": {
461
+ if (!projCols.has(expr.projection))
462
+ projCols.set(expr.projection, new Set());
463
+ collectColumns(expr.where, baseCols, projCols, expr.projection);
464
+ return;
465
+ }
466
+ default:
467
+ return;
468
+ }
469
+ }
470
+ /**
471
+ * Declare a derived read model ONCE, as a pure derivation over a base row plus named canonical
472
+ * projections. The returned {@link ReadModel} exposes both backends (SQL select-list + TS function)
473
+ * and the managed VIEW DDL, all driven from the same AST so surface #2 (double-authoring) is closed.
474
+ * Register it in the {@link readModelRegistry} to have the runtime apply its VIEW at boot (surface #3).
475
+ */
476
+ export function defineReadModel(decl) {
477
+ assertSqlIdentifier("read model name", decl.name);
478
+ assertSqlIdentifier("base table", decl.baseTable);
479
+ // SQLite forbids a VIEW and a table sharing one name, so a read model whose managed VIEW name equals
480
+ // its base table would fail opaquely at provisioning (`ReadModelRegistry.ensureViews`) with a raw SQL
481
+ // error. Reject it here, at declaration time, with an actionable message instead. Fold case first:
482
+ // SQLite matches identifiers case-insensitively, so `name: "TASKS"` still collides with `baseTable: "tasks"`.
483
+ if (foldSqlIdentifier(decl.name) === foldSqlIdentifier(decl.baseTable)) {
484
+ throw new Error(`read model "${decl.name}" cannot share its name with its base table "${decl.baseTable}"; ` +
485
+ `SQLite forbids a VIEW and a table having the same name, so the managed VIEW would fail to ` +
486
+ `provision — give the read model a distinct name`);
487
+ }
488
+ // Canonicalise the derived-column order (sort by name) so the managed VIEW DDL is independent of the
489
+ // declaration object's key insertion order. `ReadModelRegistry.register` compares `viewDdl()` strings
490
+ // for idempotency/conflict detection, so two declarations that differ only in `derive` key order must
491
+ // produce byte-identical DDL — otherwise they would be flagged as conflicting definitions of the same
492
+ // read model. Columns are accessed by name, so their SELECT order carries no semantic meaning.
493
+ const columns = Object.keys(decl.derive).sort();
494
+ if (columns.length === 0) {
495
+ throw new Error(`read model "${decl.name}" declares no derived columns`);
496
+ }
497
+ for (const c of columns)
498
+ assertSqlIdentifier("derived column", c);
499
+ const projSet = new Set();
500
+ for (const c of columns)
501
+ collectProjectionNames(decl.derive[c], projSet);
502
+ for (const name of projSet)
503
+ assertSqlIdentifier("projection name", name);
504
+ const sqlSelectFor = (column, options) => {
505
+ const expr = decl.derive[column];
506
+ if (!expr)
507
+ throw new Error(`read model "${decl.name}" has no derived column "${column}"`);
508
+ return compileToSqlSelect(expr, options);
509
+ };
510
+ const fnCache = new Map();
511
+ const fnFor = (column) => {
512
+ const expr = decl.derive[column];
513
+ if (!expr)
514
+ throw new Error(`read model "${decl.name}" has no derived column "${column}"`);
515
+ let fn = fnCache.get(column);
516
+ if (!fn) {
517
+ fn = compileToFn(expr);
518
+ fnCache.set(column, fn);
519
+ }
520
+ return fn;
521
+ };
522
+ const viewDdl = (options, viewOptions) => {
523
+ const baseAlias = resolveBaseAlias(options?.baseAlias);
524
+ const selectBase = decl.selectBaseColumns !== false;
525
+ const derived = columns.map((c) => `${sqlSelectFor(c, options)} AS ${quoteIdent(c)}`);
526
+ const selectList = [...(selectBase ? [`${quoteIdent(baseAlias)}.*`] : []), ...derived].join(",\n ");
527
+ const createView = viewOptions?.temp ? "CREATE TEMP VIEW IF NOT EXISTS" : "CREATE VIEW IF NOT EXISTS";
528
+ return (`${createView} ${quoteIdent(decl.name)} AS\n` +
529
+ `SELECT\n ${selectList}\n` +
530
+ `FROM ${quoteIdent(decl.baseTable)} ${quoteIdent(baseAlias)};`);
531
+ };
532
+ return {
533
+ decl,
534
+ projectionNames: [...projSet],
535
+ sqlSelectFor,
536
+ fnFor,
537
+ evaluate: (baseRow, projections) => {
538
+ // Derived column names are user-controlled and only identifier-validated, so a name like
539
+ // `__proto__` must set a plain own property rather than trip the magic prototype setter a
540
+ // normal `{}` inherits from `Object.prototype` (same null-prototype treatment as the
541
+ // untrusted-key dictionaries elsewhere in this repo, e.g. `core/logger.ts`).
542
+ const out = Object.create(null);
543
+ for (const c of columns)
544
+ out[c] = fnFor(c)(baseRow, projections);
545
+ return out;
546
+ },
547
+ viewDdl,
548
+ };
549
+ }
550
+ /** Convenience alias for {@link ReadModel.viewDdl} — the framework-derived managed VIEW DDL. */
551
+ export function deriveReadModelViewDdl(model, options) {
552
+ return model.viewDdl(options);
553
+ }
554
+ // ────────────────────────────────────────── Read-model registry ──────────────────────────────────
555
+ /**
556
+ * The read-model registry (seam (a)). Sidecars/apps register a {@link ReadModel}; the runtime boot
557
+ * path applies every registered model's managed VIEW to the app's data source (see
558
+ * `core/modules/workers.ts`). Idempotent: re-registering the same name whose VIEW DDL matches is a
559
+ * no-op; a conflicting redefinition throws so two definitions cannot silently claim one name.
560
+ */
561
+ export class ReadModelRegistry {
562
+ #byName = new Map();
563
+ register(model) {
564
+ const key = foldSqlIdentifier(model.decl.name);
565
+ const existing = this.#byName.get(key);
566
+ if (existing) {
567
+ if (existing.decl.name !== model.decl.name) {
568
+ // Case-only variant (e.g. "TASKS" vs "tasks"). SQLite folds VIEW names, so both would collide
569
+ // at `ensureViews`; reject the collision here with an actionable error instead.
570
+ throw new Error(`read model "${model.decl.name}" already registered as "${existing.decl.name}"; SQLite folds ` +
571
+ `identifiers case-insensitively, so names differing only in case denote one VIEW`);
572
+ }
573
+ if (existing.viewDdl() !== model.viewDdl()) {
574
+ throw new Error(`read model "${model.decl.name}" already registered with a different definition`);
575
+ }
576
+ return;
577
+ }
578
+ this.#byName.set(key, model);
579
+ }
580
+ all() {
581
+ return [...this.#byName.values()];
582
+ }
583
+ get(name) {
584
+ return this.#byName.get(foldSqlIdentifier(name));
585
+ }
586
+ /** Apply every registered model's managed VIEW to a database (the boot-path entry point). Safe to
587
+ * call repeatedly and it is truly MANAGED: each VIEW is dropped and recreated so a changed
588
+ * read-model definition in code always replaces a stale VIEW body (a plain `CREATE VIEW IF NOT
589
+ * EXISTS` would leave the SQL backend running an old definition, reintroducing backend drift).
590
+ * Referenced base/projection tables need not exist yet — SQLite only resolves a VIEW's body when
591
+ * it is queried.
592
+ *
593
+ * The DROP is `main.`-qualified on purpose: an unqualified `DROP VIEW IF EXISTS "name"` resolves
594
+ * TEMP first, so a stray TEMP view of the same name (e.g. leaked from a parity-guard run on a
595
+ * long-lived handle) would be dropped INSTEAD of the managed `main` view — and the following
596
+ * `CREATE VIEW IF NOT EXISTS` would then no-op against the surviving `main` view, leaving a stale
597
+ * managed body in production. Qualifying to `main` drops exactly the managed view we recreate. */
598
+ ensureViews(db, options) {
599
+ for (const model of this.#byName.values()) {
600
+ db.exec(`DROP VIEW IF EXISTS main.${quoteIdent(model.decl.name)};`);
601
+ db.exec(model.viewDdl(options));
602
+ }
603
+ }
604
+ clear() {
605
+ this.#byName.clear();
606
+ }
607
+ }
608
+ /**
609
+ * Process-wide read-model registry — the concrete registration point. The `writer-source-inversion`
610
+ * task registers its derived status-edge read model here so the runtime provisions its VIEW at boot.
611
+ */
612
+ export const readModelRegistry = new ReadModelRegistry();
613
+ function defaultOnMismatch(message) {
614
+ throw new Error(message);
615
+ }
616
+ function normaliseSqlValue(value) {
617
+ // SQLite has no boolean type — booleans round-trip as 0/1. Normalise the TS side to match so a
618
+ // `true`/`1` pair is parity, not a spurious mismatch.
619
+ if (typeof value === "boolean")
620
+ return value ? 1 : 0;
621
+ if (value === undefined)
622
+ return null;
623
+ // A SQLite INTEGER can surface as `number` on one side (when it fits) and `bigint` on the other,
624
+ // depending on the driver — the comparison below is `Object.is`, so `1n` vs `1` would be a spurious
625
+ // mismatch. Collapse a LOSSLESS `bigint` (one that round-trips exactly through `Number`, i.e. within
626
+ // safe-integer range) to `number`. A `bigint` past 2^53 is kept EXACT — narrowing it would collapse
627
+ // two distinct 64-bit keys and mask a real divergence (mirrors the exactness `orderable` preserves).
628
+ if (typeof value === "bigint" && BigInt(Number(value)) === value)
629
+ return Number(value);
630
+ return value;
631
+ }
632
+ function formatParityValue(value) {
633
+ // `JSON.stringify` throws a TypeError on `bigint`, which a derived INTEGER > 2^53 can reach this
634
+ // path as. Format it explicitly so the guard always reports the parity mismatch instead of masking
635
+ // it with a serialisation error.
636
+ if (typeof value === "bigint")
637
+ return `${value}n`;
638
+ return JSON.stringify(value);
639
+ }
640
+ /**
641
+ * The framework PARITY GUARD (surface #3). Given a compiled read model and sample base+projection
642
+ * rows, it materialises the managed VIEW over throwaway in-memory tables, reads the SQL-derived
643
+ * value for each column, computes the TS-function value for the same inputs, and asserts they agree —
644
+ * so an app no longer hand-writes a bespoke parity test per projection. Throws (or calls
645
+ * `onMismatch`) on the first divergence, naming the column, sample index, and both values.
646
+ *
647
+ * The caller supplies a SQLite handle (e.g. an in-memory DB from the node/deno host). The guard
648
+ * builds minimal fixture tables for the base table and each referenced projection from the sample
649
+ * rows' own keys — entirely in SQLite's TEMP schema (`CREATE TEMP TABLE`/`CREATE TEMP VIEW`, dropped
650
+ * `temp.`-qualified) — so it needs no pre-existing schema AND can never clobber real application
651
+ * tables/views the caller's DB happens to hold under the same names.
652
+ */
653
+ export function assertReadModelParity(model, db, samples, options = {}) {
654
+ const onMismatch = options.onMismatch ?? defaultOnMismatch;
655
+ const columns = options.columns ?? Object.keys(model.decl.derive);
656
+ // Resolve projection NAMES to physical tables with the SAME resolver (and identifier validation) the
657
+ // SQL compiler uses, so the guard's fixtures line up with the VIEW it materialises below.
658
+ const resolveTable = options.sql?.resolveProjectionTable ?? ((n) => projectionRegistry.sqlTableFor(n));
659
+ // Validate the requested columns up front so an unknown name yields an actionable error rather than
660
+ // a downstream `undefined` blowing up inside `collectColumns`/`fnFor`.
661
+ for (const c of columns) {
662
+ if (!Object.prototype.hasOwnProperty.call(model.decl.derive, c)) {
663
+ throw new Error(`read model "${model.decl.name}" has no derived column "${c}" to check parity for`);
664
+ }
665
+ }
666
+ // Collect the union of base columns and per-projection columns across all samples, so the fixture
667
+ // tables carry every referenced column. Seed from the AST first so a projection referenced by an
668
+ // `exists` predicate always has its correlated columns even when a sample supplies no rows for it.
669
+ const baseCols = new Set();
670
+ const projCols = new Map();
671
+ for (const name of model.projectionNames)
672
+ projCols.set(name, new Set());
673
+ for (const c of columns)
674
+ collectColumns(model.decl.derive[c], baseCols, projCols);
675
+ for (const s of samples) {
676
+ for (const k of Object.keys(s.baseRow))
677
+ baseCols.add(k);
678
+ for (const [pname, rows] of Object.entries(s.projections ?? {})) {
679
+ // Only widen fixtures for projections the model actually references; a sample may carry extra
680
+ // projections the read model never reads, and those get no fixture table (see insert loop below).
681
+ const set = projCols.get(pname);
682
+ if (!set)
683
+ continue;
684
+ for (const r of rows)
685
+ for (const k of Object.keys(r))
686
+ set.add(k);
687
+ }
688
+ }
689
+ const baseTable = model.decl.baseTable;
690
+ const createFixture = (table, cols) => {
691
+ const colList = [...cols];
692
+ // A degenerate fixture with no columns still needs a placeholder so the table is creatable.
693
+ // TEMP tables live in SQLite's `temp` schema and shadow same-named objects in `main`, so the
694
+ // guard never touches (nor can it clobber) the application's real base/projection tables.
695
+ const ddl = colList.length
696
+ ? `CREATE TEMP TABLE ${quoteIdent(table)} (${colList.map((c) => quoteIdent(c)).join(", ")});`
697
+ : `CREATE TEMP TABLE ${quoteIdent(table)} (_placeholder);`;
698
+ db.exec(ddl);
699
+ };
700
+ // Resolve each projection name to its physical fixture table (validated), rejecting a many-to-one
701
+ // mapping up front so the guard never creates/drops one table twice for a non-parity reason.
702
+ const projectionTables = new Map();
703
+ const tableOwner = new Map();
704
+ for (const name of model.projectionNames) {
705
+ const table = assertSqlIdentifier("projection table", resolveTable(name));
706
+ // A projection resolving to the base table's physical name is a mapping bug: the guard fabricates an
707
+ // isolated fixture per relation, so it would `CREATE TEMP TABLE` (and later drop/insert) the base
708
+ // table twice and fail for a non-parity reason. Reject it up front (mirrors the many-to-one check).
709
+ // Case-fold: SQLite treats `"Tasks"` and `"tasks"` as the same physical table.
710
+ if (foldSqlIdentifier(table) === foldSqlIdentifier(baseTable)) {
711
+ throw new Error(`read model "${model.decl.name}" maps projection "${name}" to its base table "${baseTable}"; ` +
712
+ `each projection needs a distinct sqlTable so the parity guard can build an isolated fixture ` +
713
+ `for it`);
714
+ }
715
+ // Two distinct projection names resolving to ONE physical table is a mapping bug: the guard would
716
+ // otherwise `CREATE TABLE` (and later drop/insert) that table twice and fail for a non-parity
717
+ // reason. Reject it up front with an actionable error instead (mirrors the column check above).
718
+ // Key the ownership map by the case-folded table so `"Foo"` and `"foo"` are recognised as one.
719
+ const tableKey = foldSqlIdentifier(table);
720
+ const owner = tableOwner.get(tableKey);
721
+ if (owner !== undefined && owner !== name) {
722
+ throw new Error(`read model "${model.decl.name}" maps projections "${owner}" and "${name}" to the same ` +
723
+ `physical table "${table}"; each projection needs a distinct sqlTable so the parity guard ` +
724
+ `can build an isolated fixture for it`);
725
+ }
726
+ tableOwner.set(tableKey, name);
727
+ projectionTables.set(name, table);
728
+ }
729
+ // Drop/create every fixture through this single helper so the pre-check reset (leaked objects from
730
+ // an earlier call) and the post-check teardown (below) can never drift apart. Schema-qualified with
731
+ // `temp.` so an unqualified name can never resolve to — and delete — a real `main` table/view.
732
+ const dropFixtures = () => {
733
+ db.exec(`DROP VIEW IF EXISTS temp.${quoteIdent(model.decl.name)};`);
734
+ db.exec(`DROP TABLE IF EXISTS temp.${quoteIdent(baseTable)};`);
735
+ for (const table of projectionTables.values())
736
+ db.exec(`DROP TABLE IF EXISTS temp.${quoteIdent(table)};`);
737
+ };
738
+ const insertRow = (table, row) => {
739
+ const keys = Object.keys(row);
740
+ if (keys.length === 0) {
741
+ // A keyless row (e.g. `{ baseRow: {} }`) still represents one input row: the fixture always
742
+ // carries at least a `_placeholder` column, so `DEFAULT VALUES` materialises it and the VIEW
743
+ // sees a row. Skipping it would leave the VIEW empty and fail parity for a non-parity reason.
744
+ db.run(`INSERT INTO ${quoteIdent(table)} DEFAULT VALUES;`);
745
+ return;
746
+ }
747
+ const placeholders = keys.map(() => "?").join(", ");
748
+ db.run(`INSERT INTO ${quoteIdent(table)} (${keys.map((k) => quoteIdent(k)).join(", ")}) VALUES (${placeholders});`, keys.map((k) => normaliseSqlValue(row[k])));
749
+ };
750
+ // Fresh fixtures in the TEMP schema. Drop first so the guard can be called repeatedly on one DB
751
+ // without clobbering the caller's application data, and ALWAYS drop again in `finally` so leftover
752
+ // TEMP objects can never shadow the caller's real `main` tables/view after the guard returns/throws.
753
+ dropFixtures();
754
+ try {
755
+ createFixture(baseTable, baseCols);
756
+ for (const [name, table] of projectionTables)
757
+ createFixture(table, projCols.get(name) ?? new Set());
758
+ db.exec(model.viewDdl(options.sql, { temp: true }));
759
+ samples.forEach((sample, idx) => {
760
+ // Reset fixture data for this isolated sample.
761
+ db.run(`DELETE FROM ${quoteIdent(baseTable)};`);
762
+ for (const table of projectionTables.values())
763
+ db.run(`DELETE FROM ${quoteIdent(table)};`);
764
+ insertRow(baseTable, sample.baseRow);
765
+ for (const [pname, rows] of Object.entries(sample.projections ?? {})) {
766
+ // Ignore projections the model never references — no fixture table exists for them, and they
767
+ // cannot affect any derived column, so inserting would fail the guard for a non-parity reason.
768
+ const table = projectionTables.get(pname);
769
+ if (!table)
770
+ continue;
771
+ for (const r of rows)
772
+ insertRow(table, r);
773
+ }
774
+ // Read the SQL-derived values straight from the managed VIEW — the VIEW body already computes
775
+ // each derived column, so this exercises the exact DDL the framework emits (not a recompiled copy).
776
+ const derivedList = columns.map((c) => quoteIdent(c)).join(", ");
777
+ const sqlRows = db.all(`SELECT ${derivedList} FROM ${quoteIdent(model.decl.name)};`);
778
+ const sqlRow = sqlRows[0] ?? {};
779
+ for (const c of columns) {
780
+ const sqlValue = normaliseSqlValue(sqlRow[c]);
781
+ const fnValue = normaliseSqlValue(model.fnFor(c)(sample.baseRow, sample.projections));
782
+ if (!Object.is(sqlValue, fnValue)) {
783
+ onMismatch(`read-model parity mismatch in "${model.decl.name}".${c} (sample #${idx}): ` +
784
+ `SQL=${formatParityValue(sqlValue)} vs TS=${formatParityValue(fnValue)}`);
785
+ }
786
+ }
787
+ });
788
+ }
789
+ finally {
790
+ dropFixtures();
791
+ }
792
+ }