@nanobpm/urban 0.77.4 → 0.79.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/modules/canonical-projections.d.ts +13 -0
- package/dist/runtime/core/modules/canonical-projections.js +30 -0
- package/dist/runtime/core/modules/instance-state-store.d.ts +50 -0
- package/dist/runtime/core/modules/instance-state-store.js +101 -0
- package/dist/runtime/core/modules/open-user-tasks-store.d.ts +63 -0
- package/dist/runtime/core/modules/open-user-tasks-store.js +135 -0
- package/dist/runtime/core/modules/workers.js +61 -0
- package/dist/runtime/core/read-model.d.ts +267 -0
- package/dist/runtime/core/read-model.js +792 -0
- package/dist/runtime/index.d.ts +7 -0
- package/dist/runtime/index.js +21 -1
- package/package.json +1 -1
- package/src/runtime/core/modules/canonical-projections.test.ts +190 -0
- package/src/runtime/core/modules/canonical-projections.ts +38 -0
- package/src/runtime/core/modules/instance-state-store.test.ts +143 -0
- package/src/runtime/core/modules/instance-state-store.ts +135 -0
- package/src/runtime/core/modules/open-user-tasks-store.test.ts +156 -0
- package/src/runtime/core/modules/open-user-tasks-store.ts +172 -0
- package/src/runtime/core/modules/workers.ts +61 -0
- package/src/runtime/core/read-model.test.ts +943 -0
- package/src/runtime/core/read-model.ts +1025 -0
- package/src/runtime/index.ts +94 -1
|
@@ -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
|
+
}
|