@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,943 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtemp, rm } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { createNodeHost } from "../adapters/node.ts";
7
+ import type { SqliteDb } from "./host.ts";
8
+ import {
9
+ and,
10
+ assertReadModelParity,
11
+ caseWhen,
12
+ col,
13
+ compileToFn,
14
+ compileToSqlSelect,
15
+ defineReadModel,
16
+ eq,
17
+ exists,
18
+ gt,
19
+ lit,
20
+ lt,
21
+ neq,
22
+ not,
23
+ or,
24
+ pcol,
25
+ ProjectionRegistry,
26
+ projectionRegistry,
27
+ ReadModelRegistry,
28
+ when,
29
+ } from "./read-model.ts";
30
+ import type { ReadModel } from "./read-model.ts";
31
+
32
+ async function withDb(fn: (db: SqliteDb) => void | Promise<void>): Promise<void> {
33
+ const dir = await mkdtemp(join(tmpdir(), "urban-read-model-"));
34
+ const host = createNodeHost({ cwd: dir, log: () => {} });
35
+ const db = host.openSqlite(join(dir, "test.db"));
36
+ try {
37
+ await fn(db);
38
+ } finally {
39
+ db.close();
40
+ await rm(dir, { recursive: true, force: true });
41
+ }
42
+ }
43
+
44
+ // A representative read model exercising CASE + EXISTS + a comparison, declared ONCE. Its base rows
45
+ // are `tasks`; it references the (fixture) canonical projection `urban_open_user_tasks` by name.
46
+ const taskReadModel: ReadModel = defineReadModel({
47
+ name: "tasks_display",
48
+ baseTable: "tasks",
49
+ derive: {
50
+ // done → completed; else if an open user task exists for the instance → awaiting_operator; else active.
51
+ display_status: caseWhen(
52
+ [
53
+ when(eq(col("state"), lit("done")), lit("completed")),
54
+ when(
55
+ exists("urban_open_user_tasks", eq(pcol("process_instance_key"), col("process_instance_key"))),
56
+ lit("awaiting_operator"),
57
+ ),
58
+ ],
59
+ lit("active"),
60
+ ),
61
+ // A plain comparison → a boolean derived column (round-trips as 0/1 in SQLite).
62
+ is_high_priority: gt(col("priority"), lit(5)),
63
+ },
64
+ });
65
+
66
+ function seedFixtures(db: SqliteDb): void {
67
+ db.exec(`CREATE TABLE tasks (id TEXT, process_instance_key TEXT, state TEXT, priority INTEGER);`);
68
+ db.exec(`CREATE TABLE urban_open_user_tasks (process_instance_key TEXT);`);
69
+ db.exec(taskReadModel.viewDdl());
70
+ // t1: done, high priority → completed / high; no open user task.
71
+ db.run(`INSERT INTO tasks VALUES (?, ?, ?, ?)`, ["t1", "pi-1", "done", 9]);
72
+ // t2: running, has an open user task → awaiting_operator / not high.
73
+ db.run(`INSERT INTO tasks VALUES (?, ?, ?, ?)`, ["t2", "pi-2", "running", 3]);
74
+ db.run(`INSERT INTO urban_open_user_tasks VALUES (?)`, ["pi-2"]);
75
+ // t3: running, no open user task → active / high.
76
+ db.run(`INSERT INTO tasks VALUES (?, ?, ?, ?)`, ["t3", "pi-3", "running", 8]);
77
+ }
78
+
79
+ const EXPECTED: Record<string, { display_status: string; is_high_priority: number }> = {
80
+ t1: { display_status: "completed", is_high_priority: 1 },
81
+ t2: { display_status: "awaiting_operator", is_high_priority: 0 },
82
+ t3: { display_status: "active", is_high_priority: 1 },
83
+ };
84
+
85
+ test("(i) the read model with CASE + EXISTS compiles to a SQLite VIEW returning the expected values", async () => {
86
+ await withDb((db) => {
87
+ seedFixtures(db);
88
+ const rows = db
89
+ .all<{ id: string; display_status: string; is_high_priority: number }>(
90
+ `SELECT id, display_status, is_high_priority FROM tasks_display ORDER BY id`,
91
+ )
92
+ .map((r) => ({ ...r }));
93
+ assert.deepEqual(
94
+ rows,
95
+ [
96
+ { id: "t1", ...EXPECTED.t1 },
97
+ { id: "t2", ...EXPECTED.t2 },
98
+ { id: "t3", ...EXPECTED.t3 },
99
+ ],
100
+ "SQLite VIEW derived columns must match the expected derivation",
101
+ );
102
+ // The managed VIEW re-exports the base columns alongside the derived ones.
103
+ const full = db.all<Record<string, unknown>>(`SELECT * FROM tasks_display WHERE id = 't1'`)[0];
104
+ assert.equal(full.state, "done");
105
+ assert.equal(full.priority, 9);
106
+ });
107
+ });
108
+
109
+ test("(ii) the TS function returns the SAME values as the SQL VIEW for the same inputs", () => {
110
+ const projections = { urban_open_user_tasks: [{ process_instance_key: "pi-2" }] };
111
+ const inputs: Record<string, { process_instance_key: string; state: string; priority: number }> = {
112
+ t1: { process_instance_key: "pi-1", state: "done", priority: 9 },
113
+ t2: { process_instance_key: "pi-2", state: "running", priority: 3 },
114
+ t3: { process_instance_key: "pi-3", state: "running", priority: 8 },
115
+ };
116
+ for (const [id, base] of Object.entries(inputs)) {
117
+ const out = taskReadModel.evaluate(base, projections);
118
+ assert.equal(out.display_status, EXPECTED[id].display_status, `display_status for ${id}`);
119
+ // The TS backend yields a boolean; SQLite yields 0/1 — same truth value.
120
+ assert.equal(out.is_high_priority ? 1 : 0, EXPECTED[id].is_high_priority, `is_high_priority for ${id}`);
121
+ }
122
+ });
123
+
124
+ test("(iii) the parity guard PASSES when the two backends agree", async () => {
125
+ await withDb((db) => {
126
+ assert.doesNotThrow(() =>
127
+ assertReadModelParity(taskReadModel, db, [
128
+ { baseRow: { process_instance_key: "pi-1", state: "done", priority: 9 }, projections: {} },
129
+ {
130
+ baseRow: { process_instance_key: "pi-2", state: "running", priority: 3 },
131
+ projections: { urban_open_user_tasks: [{ process_instance_key: "pi-2" }] },
132
+ },
133
+ {
134
+ baseRow: { process_instance_key: "pi-3", state: "running", priority: 8 },
135
+ projections: { urban_open_user_tasks: [{ process_instance_key: "pi-9" }] },
136
+ },
137
+ ]),
138
+ );
139
+ });
140
+ });
141
+
142
+ test("(iii) the parity guard FAILS when a deliberately-mutated AST makes the backends diverge", async () => {
143
+ // A drifted model: the SQL backend stays the real declaration, but the TS backend is compiled from
144
+ // a MUTATED AST (the display_status CASE with its comparison flipped `eq` → `neq`). This is exactly
145
+ // the surface-#2 drift the guard exists to catch — the two would silently diverge without it.
146
+ const mutatedDisplayStatus = caseWhen(
147
+ [
148
+ when(neq(col("state"), lit("done")), lit("completed")), // ← flipped from eq
149
+ when(
150
+ exists("urban_open_user_tasks", eq(pcol("process_instance_key"), col("process_instance_key"))),
151
+ lit("awaiting_operator"),
152
+ ),
153
+ ],
154
+ lit("active"),
155
+ );
156
+ const drifted: ReadModel = {
157
+ ...taskReadModel,
158
+ fnFor: (column) =>
159
+ column === "display_status" ? compileToFn(mutatedDisplayStatus) : taskReadModel.fnFor(column),
160
+ };
161
+
162
+ await withDb((db) => {
163
+ assert.throws(
164
+ () =>
165
+ assertReadModelParity(drifted, db, [
166
+ { baseRow: { process_instance_key: "pi-1", state: "done", priority: 9 }, projections: {} },
167
+ ]),
168
+ /parity mismatch in "tasks_display"\.display_status/,
169
+ );
170
+ });
171
+ });
172
+
173
+ test("compileToSqlSelect emits a closed, injection-free SQL expression for CASE + EXISTS", () => {
174
+ const sql = taskReadModel.sqlSelectFor("display_status");
175
+ assert.match(sql, /^CASE WHEN/);
176
+ assert.match(sql, /EXISTS \(SELECT 1 FROM "urban_open_user_tasks" AS "__urban_proj_0" WHERE/);
177
+ assert.match(sql, /"base"\."state" = 'done'/);
178
+ });
179
+
180
+ test("compileToFn / compileToSqlSelect are driven from the same AST (single-declaration guarantee)", () => {
181
+ const expr = and(eq(col("a"), lit(1)), gt(col("b"), lit(2)));
182
+ const fn = compileToFn(expr);
183
+ assert.equal(fn({ a: 1, b: 5 }), true);
184
+ assert.equal(fn({ a: 1, b: 2 }), false);
185
+ assert.equal(fn({ a: 0, b: 5 }), false);
186
+ // Comparisons and the AND are COALESCE'd to 0 so a NULL operand can't make SQL yield NULL where the
187
+ // TS backend yields false (see the NULL-parity test below); the AST is still the single source.
188
+ assert.equal(
189
+ compileToSqlSelect(expr),
190
+ `COALESCE((COALESCE(("base"."a" = 1), 0) AND COALESCE(("base"."b" > 2), 0)), 0)`,
191
+ );
192
+ });
193
+
194
+ test("both backends agree on NULL inputs: SQL NULL propagation is COALESCE'd to the TS 'NULL → false' rule", async () => {
195
+ // SQLite propagates NULL through comparisons and AND/OR/NOT (`a = NULL` → NULL, `NOT NULL` → NULL),
196
+ // but the TS backend collapses any nullish operand to false (compareValues/truthy). Without the
197
+ // COALESCE guards in compileToSqlSelect the two backends would silently diverge on a NULL base value.
198
+ const model = defineReadModel({
199
+ name: "null_parity_read_model",
200
+ baseTable: "null_rows",
201
+ derive: {
202
+ eq_is_true: eq(col("a"), lit(1)),
203
+ neq_is_true: neq(col("a"), lit(1)),
204
+ and_is_true: and(eq(col("a"), lit(1)), gt(col("b"), lit(2))),
205
+ or_is_true: or(eq(col("a"), lit(1)), gt(col("b"), lit(2))),
206
+ not_is_true: not(eq(col("a"), lit(1))),
207
+ },
208
+ });
209
+ await withDb((db) => {
210
+ assert.doesNotThrow(() =>
211
+ assertReadModelParity(model, db, [
212
+ { baseRow: { a: null, b: null } },
213
+ { baseRow: { a: null, b: 5 } },
214
+ { baseRow: { a: 1, b: null } },
215
+ { baseRow: { a: 1, b: 5 } },
216
+ { baseRow: { a: 2, b: 1 } },
217
+ ]),
218
+ );
219
+ });
220
+ });
221
+
222
+ test("EXISTS references a projection by name; the projection registry resolves the physical table", () => {
223
+ const reg = new ProjectionRegistry();
224
+ reg.register({ name: "urban_instance_state", sqlTable: "_urban_instance_state" });
225
+ const sql = compileToSqlSelect(exists("urban_instance_state", eq(pcol("k"), col("k"))), {
226
+ resolveProjectionTable: (n) => reg.sqlTableFor(n),
227
+ });
228
+ assert.match(sql, /FROM "_urban_instance_state" AS "__urban_proj_0" WHERE/);
229
+ // An unregistered name falls back to itself, so a read model compiles before its sidecar lands.
230
+ assert.equal(reg.sqlTableFor("not_yet_landed"), "not_yet_landed");
231
+ });
232
+
233
+ test("EXISTS aliases the projection relation distinctly from the base alias so col(...) correlates to the OUTER base row", () => {
234
+ // A projection whose PHYSICAL table equals the base alias must not shadow the outer base row inside
235
+ // the sub-select: the projection relation gets a reserved alias, pcol binds to it, and col still binds
236
+ // to the outer base alias — otherwise `col(...)` would silently bind to the inner projection row.
237
+ const sql = compileToSqlSelect(exists("p", eq(pcol("k"), col("k"))), {
238
+ baseAlias: "base",
239
+ resolveProjectionTable: () => "base", // physical projection table collides with the base alias
240
+ });
241
+ assert.match(sql, /FROM "base" AS "__urban_proj_0" WHERE COALESCE\(\("__urban_proj_0"\."k" = "base"\."k"\), 0\)/);
242
+
243
+ // If the base alias IS the reserved alias itself, the projection alias is prefixed further so the two
244
+ // stay distinct — the derivation is collision-free for any base alias.
245
+ const collide = compileToSqlSelect(exists("p", eq(pcol("k"), col("k"))), {
246
+ baseAlias: "__urban_proj_0",
247
+ resolveProjectionTable: () => "__urban_proj_0",
248
+ });
249
+ assert.match(collide, /FROM "__urban_proj_0" AS "___urban_proj_0" WHERE COALESCE\(\("___urban_proj_0"\."k" = "__urban_proj_0"\."k"\), 0\)/);
250
+ });
251
+
252
+ test("projection alias avoids a base alias that collides only by case (SQLite idents are case-insensitive)", () => {
253
+ // SQLite compares identifiers case-insensitively, so "__URBAN_PROJ_0" and "__urban_proj_0" are the
254
+ // SAME relation. The reserved projection alias must still be prefixed away from such a base alias,
255
+ // or `col(...)` inside EXISTS would correlate to the projection row instead of the outer base row.
256
+ const collide = compileToSqlSelect(exists("p", eq(pcol("k"), col("k"))), {
257
+ baseAlias: "__URBAN_PROJ_0",
258
+ resolveProjectionTable: () => "p",
259
+ });
260
+ assert.match(collide, /FROM "p" AS "___urban_proj_0" WHERE COALESCE\(\("___urban_proj_0"\."k" = "__URBAN_PROJ_0"\."k"\), 0\)/);
261
+ });
262
+
263
+ test("a base alias that is not a SQL identifier is rejected at compile time", () => {
264
+ assert.throws(
265
+ () => compileToSqlSelect(col("k"), { baseAlias: 'evil"; DROP TABLE t; --' }),
266
+ /invalid base alias/,
267
+ );
268
+ });
269
+
270
+ test("nested EXISTS bind each pcol(...) to its own projection via depth-indexed aliases", () => {
271
+ const sql = compileToSqlSelect(
272
+ exists("outer", and(eq(pcol("ok"), col("bk")), exists("inner", eq(pcol("ik"), col("bk"))))),
273
+ );
274
+ // The outer projection is __urban_proj_0, the nested one __urban_proj_1 — so the inner pcol cannot
275
+ // shadow the outer projection's row.
276
+ assert.match(sql, /FROM "outer" AS "__urban_proj_0" WHERE/);
277
+ assert.match(sql, /FROM "inner" AS "__urban_proj_1" WHERE COALESCE\(\("__urban_proj_1"\."ik" = "base"\."bk"\), 0\)/);
278
+ assert.match(sql, /"__urban_proj_0"\."ok" = "base"\."bk"/);
279
+ });
280
+
281
+ test("the parity guard threads custom SQL options (resolver + VIEW) and keeps EXISTS correlation when the projection table equals the base alias", async () => {
282
+ // End-to-end: the guard must (a) resolve the projection NAME through options.sql.resolveProjectionTable,
283
+ // (b) materialise the VIEW with those same options, and (c) alias the EXISTS relation so a projection
284
+ // physically named like the base alias still correlates — all three together keep the backends in parity.
285
+ const model = defineReadModel({
286
+ name: "collide_alias_read_model",
287
+ baseTable: "rows",
288
+ derive: {
289
+ has_match: caseWhen([when(exists("p", eq(pcol("bk"), col("bk"))), lit(1))], lit(0)),
290
+ },
291
+ });
292
+ const sql = { resolveProjectionTable: (n: string) => (n === "p" ? "base" : n) };
293
+ await withDb((db) => {
294
+ assert.doesNotThrow(() =>
295
+ assertReadModelParity(
296
+ model,
297
+ db,
298
+ [
299
+ { baseRow: { bk: "x" }, projections: { p: [{ bk: "x" }] } },
300
+ { baseRow: { bk: "y" }, projections: { p: [{ bk: "x" }] } },
301
+ ],
302
+ { sql },
303
+ ),
304
+ );
305
+ });
306
+ });
307
+
308
+ test("the parity guard resolves projection tables through options.sql and validates them like the SQL compiler", async () => {
309
+ // The guard must consult options.sql.resolveProjectionTable and apply the SAME identifier validation the
310
+ // SQL compiler uses — so a resolver returning an invalid table name is rejected up front, not silently
311
+ // ignored (which would build fixtures against a different physical table than the runtime VIEW).
312
+ const model = defineReadModel({
313
+ name: "bad_resolver_read_model",
314
+ baseTable: "rows",
315
+ derive: {
316
+ has_match: caseWhen([when(exists("p", eq(pcol("k"), col("k"))), lit(1))], lit(0)),
317
+ },
318
+ });
319
+ await withDb((db) => {
320
+ assert.throws(
321
+ () =>
322
+ assertReadModelParity(model, db, [{ baseRow: { k: "x" }, projections: { p: [{ k: "x" }] } }], {
323
+ sql: { resolveProjectionTable: () => "bad table" },
324
+ }),
325
+ /invalid projection table "bad table"/,
326
+ );
327
+ });
328
+ });
329
+
330
+ test("the parity guard rejects a projection resolving to the base table's physical name (would collide with the base fixture)", async () => {
331
+ // A projection that resolves to the SAME physical table as the base table would make the guard build
332
+ // (and later drop/insert) one fixture table twice — once as the base, once as the projection — failing
333
+ // with a confusing SQLite "table already exists" error. The guard must reject it up front with an
334
+ // actionable message, mirroring the many-to-one projection-table check.
335
+ const model = defineReadModel({
336
+ name: "base_collision_read_model",
337
+ baseTable: "rows",
338
+ derive: {
339
+ has_match: caseWhen([when(exists("p", eq(pcol("k"), col("k"))), lit(1))], lit(0)),
340
+ },
341
+ });
342
+ await withDb((db) => {
343
+ assert.throws(
344
+ () =>
345
+ assertReadModelParity(model, db, [{ baseRow: { k: "x" }, projections: { p: [{ k: "x" }] } }], {
346
+ sql: { resolveProjectionTable: () => "rows" },
347
+ }),
348
+ /maps projection "p" to its base table "rows"/,
349
+ );
350
+ });
351
+ });
352
+
353
+ test("the parity guard rejects a projection whose physical name matches the base table only by case", async () => {
354
+ // SQLite folds identifiers, so a projection resolving to "ROWS" is the SAME physical table as the base
355
+ // "rows" and would make the guard build one fixture twice. The case-fold in the guard must catch it.
356
+ const model = defineReadModel({
357
+ name: "base_case_collision_read_model",
358
+ baseTable: "rows",
359
+ derive: {
360
+ has_match: caseWhen([when(exists("p", eq(pcol("k"), col("k"))), lit(1))], lit(0)),
361
+ },
362
+ });
363
+ await withDb((db) => {
364
+ assert.throws(
365
+ () =>
366
+ assertReadModelParity(model, db, [{ baseRow: { k: "x" }, projections: { p: [{ k: "x" }] } }], {
367
+ sql: { resolveProjectionTable: () => "ROWS" },
368
+ }),
369
+ /maps projection "p" to its base table "rows"/,
370
+ );
371
+ });
372
+ });
373
+
374
+ test("the process-wide projectionRegistry is idempotent and rejects a conflicting redefinition", () => {
375
+ try {
376
+ projectionRegistry.register({ name: "urban_open_user_tasks" });
377
+ // Same registration is a no-op.
378
+ assert.doesNotThrow(() => projectionRegistry.register({ name: "urban_open_user_tasks" }));
379
+ assert.ok(projectionRegistry.has("urban_open_user_tasks"));
380
+ assert.throws(
381
+ () => projectionRegistry.register({ name: "urban_open_user_tasks", sqlTable: "something_else" }),
382
+ /already registered/,
383
+ );
384
+ } finally {
385
+ // Reset the process-wide singleton so this test cannot leak registrations into others.
386
+ projectionRegistry.clear();
387
+ }
388
+ });
389
+
390
+ test("projectionRegistry.clear() resets all registrations for deterministic test isolation", () => {
391
+ try {
392
+ projectionRegistry.register({ name: "proj_clear_a", sqlTable: "tbl_a" });
393
+ projectionRegistry.register({ name: "proj_clear_b", sqlTable: "tbl_b" });
394
+ assert.ok(projectionRegistry.has("proj_clear_a"));
395
+ assert.ok(projectionRegistry.has("proj_clear_b"));
396
+
397
+ projectionRegistry.clear();
398
+
399
+ assert.equal(projectionRegistry.has("proj_clear_a"), false);
400
+ assert.equal(projectionRegistry.has("proj_clear_b"), false);
401
+ assert.deepEqual(projectionRegistry.names(), []);
402
+ // Falls back to the name itself once cleared (same as an unregistered name).
403
+ assert.equal(projectionRegistry.sqlTableFor("proj_clear_a"), "proj_clear_a");
404
+ // Cleared registry accepts a fresh, previously-conflicting registration.
405
+ assert.doesNotThrow(() => projectionRegistry.register({ name: "proj_clear_a", sqlTable: "tbl_different" }));
406
+ } finally {
407
+ projectionRegistry.clear();
408
+ }
409
+ });
410
+
411
+ test("defineReadModel rejects a read model whose name equals its base table (SQLite forbids a VIEW and table sharing a name)", () => {
412
+ // SQLite cannot hold a VIEW and a table under one name, so provisioning such a read model would fail
413
+ // opaquely with a raw SQL error at `ensureViews`. Reject it at declaration time with an actionable one.
414
+ assert.throws(
415
+ () =>
416
+ defineReadModel({
417
+ name: "shared_name",
418
+ baseTable: "shared_name",
419
+ derive: { c: col("x") },
420
+ }),
421
+ /cannot share its name with its base table "shared_name"/,
422
+ );
423
+ });
424
+
425
+ test("defineReadModel rejects a name/base-table collision that differs only in case (SQLite folds identifiers)", () => {
426
+ // SQLite matches identifiers case-insensitively, so a VIEW named "TASKS" still collides with a table
427
+ // "tasks". The declaration-time guard must case-fold or the collision would slip through to opaque
428
+ // failure at `ensureViews`.
429
+ assert.throws(
430
+ () =>
431
+ defineReadModel({
432
+ name: "TASKS",
433
+ baseTable: "tasks",
434
+ derive: { c: col("x") },
435
+ }),
436
+ /cannot share its name with its base table "tasks"/,
437
+ );
438
+ });
439
+
440
+ test("ProjectionRegistry rejects two projection names that collide only under SQLite case-folding", () => {
441
+ // SQLite matches identifiers case-insensitively, so "Foo" and "foo" denote ONE physical relation.
442
+ // Keying the registry by exact name would let both register as distinct entries and silently alias;
443
+ // the dedup key must fold, mirroring every other identity check in this module.
444
+ const reg = new ProjectionRegistry();
445
+ reg.register({ name: "Foo", sqlTable: "foo_table" });
446
+ assert.throws(
447
+ () => reg.register({ name: "foo", sqlTable: "foo_table" }),
448
+ /already registered as "Foo"/,
449
+ );
450
+ // Lookups fold too: a case-variant name resolves to the registered physical table.
451
+ assert.equal(reg.sqlTableFor("FOO"), "foo_table");
452
+ assert.ok(reg.has("fOo"));
453
+ // Exact re-registration stays an idempotent no-op.
454
+ assert.doesNotThrow(() => reg.register({ name: "Foo", sqlTable: "foo_table" }));
455
+ });
456
+
457
+ test("ReadModelRegistry rejects two read-model names that collide only under SQLite case-folding", () => {
458
+ // SQLite folds VIEW names, so "TASKS" and "tasks" would collide at `ensureViews`. The registry must
459
+ // reject the case-only collision at registration time rather than letting drop/create race opaquely.
460
+ const upper = defineReadModel({ name: "MODELCASE", baseTable: "rows", derive: { c: col("x") } });
461
+ const lower = defineReadModel({ name: "modelcase", baseTable: "rows", derive: { c: col("x") } });
462
+ const reg = new ReadModelRegistry();
463
+ reg.register(upper);
464
+ assert.throws(() => reg.register(lower), /already registered as "MODELCASE"/);
465
+ // A case-variant lookup resolves to the registered model.
466
+ assert.equal(reg.get("ModelCase"), upper);
467
+ });
468
+
469
+ test("evaluate() writes derived columns as own properties on a null-prototype bag (no prototype pollution via a `__proto__` column)", () => {
470
+ // Derived column names are user-controlled and only identifier-validated, so `__proto__` is a legal
471
+ // name. On a plain `{}` bag, `out["__proto__"] = <value>` trips `Object.prototype`'s magic accessor
472
+ // instead of creating an own property; a null-prototype bag makes every name a safe own key. The
473
+ // computed key here defines a real own `__proto__` property on `derive` (a literal key would set the
474
+ // prototype instead).
475
+ const model = defineReadModel({
476
+ name: "proto_pollution_read_model",
477
+ baseTable: "tasks",
478
+ derive: { ["__proto__"]: col("x") },
479
+ });
480
+ const out = model.evaluate({ x: 42 }, {});
481
+ assert.equal(Object.getPrototypeOf(out), null, "output bag must have a null prototype");
482
+ assert.equal(out["__proto__"], 42, "the `__proto__` column must be stored as a real own property");
483
+ const probe: Record<string, unknown> = {};
484
+ assert.equal(probe["polluted"], undefined, "evaluate() must not pollute Object.prototype");
485
+ });
486
+
487
+ test("the read-model registry provisions every managed VIEW and rejects a conflicting redefinition", async () => {
488
+ await withDb((db) => {
489
+ const reg = new ReadModelRegistry();
490
+ reg.register(taskReadModel);
491
+ reg.register(taskReadModel); // idempotent
492
+ // A conflicting redefinition under the same name throws.
493
+ const conflicting = defineReadModel({
494
+ name: "tasks_display",
495
+ baseTable: "tasks",
496
+ derive: { display_status: lit("x") },
497
+ });
498
+ assert.throws(() => reg.register(conflicting), /different definition/);
499
+
500
+ db.exec(`CREATE TABLE tasks (id TEXT, process_instance_key TEXT, state TEXT, priority INTEGER);`);
501
+ db.exec(`CREATE TABLE urban_open_user_tasks (process_instance_key TEXT);`);
502
+ reg.ensureViews(db);
503
+ reg.ensureViews(db); // drop-and-recreate → safe to re-run
504
+ db.run(`INSERT INTO tasks VALUES (?, ?, ?, ?)`, ["t1", "pi-1", "done", 9]);
505
+ const row = db.all<{ display_status: string }>(`SELECT display_status FROM tasks_display`)[0];
506
+ assert.equal(row.display_status, "completed");
507
+ });
508
+ });
509
+
510
+ test("the read-model registry treats declarations that differ only in derive key order as identical", () => {
511
+ // The registry compares viewDdl() strings for idempotency; column order must be canonicalised (sorted)
512
+ // so two equivalent declarations don't get flagged as a conflicting redefinition (advisory :599).
513
+ const forward = defineReadModel({
514
+ name: "order_read_model",
515
+ baseTable: "order_rows",
516
+ derive: { alpha: gt(col("a"), lit(1)), beta: gt(col("b"), lit(2)) },
517
+ });
518
+ const reversed = defineReadModel({
519
+ name: "order_read_model",
520
+ baseTable: "order_rows",
521
+ derive: { beta: gt(col("b"), lit(2)), alpha: gt(col("a"), lit(1)) },
522
+ });
523
+ assert.equal(forward.viewDdl(), reversed.viewDdl(), "key insertion order must not change the VIEW DDL");
524
+ const reg = new ReadModelRegistry();
525
+ reg.register(forward);
526
+ assert.doesNotThrow(() => reg.register(reversed), "reordered-but-equivalent redefinition is a no-op");
527
+ });
528
+
529
+ test("compareOrderable orders a 64-bit numeric key BEFORE a numeric-looking TEXT value without truncation", () => {
530
+ // A 64-bit key reaching the TS backend as `bigint`, compared to a TEXT literal, must follow SQLite's
531
+ // storage-class ordering (INTEGER/REAL < TEXT) — the numeric value is "less", never equal — WITHOUT
532
+ // coercing the bigint through Number() (which would collapse distinct keys). See advisory on line 409.
533
+ const big = 9007199254740993n; // 2^53 + 1 — not representable as a JS number
534
+ const twoP53 = 9007199254740992n; // 2^53 — Number() maps `big` onto this
535
+ const text = "9007199254740992";
536
+ // A numeric key is never equal to a text value, even one that "looks like" the same integer.
537
+ assert.equal(compileToFn(eq(col("k"), lit(text)))({ k: big }), false);
538
+ assert.equal(compileToFn(eq(col("k"), lit(text)))({ k: twoP53 }), false);
539
+ assert.equal(compileToFn(neq(col("k"), lit(text)))({ k: big }), true);
540
+ // The numeric key sorts BEFORE the text value (lt true, gt false) — a definite ordering, not NaN.
541
+ assert.equal(compileToFn(lt(col("k"), lit(text)))({ k: big }), true);
542
+ assert.equal(compileToFn(gt(col("k"), lit(text)))({ k: big }), false);
543
+ // Symmetric: the TEXT value sorts AFTER the numeric key (text > numeric).
544
+ assert.equal(compileToFn(gt(lit(text), col("k")))({ k: big }), true);
545
+ // Distinct 64-bit keys must not collapse when both are compared to the same text value.
546
+ assert.equal(compileToFn(lt(col("k"), lit(text)))({ k: twoP53 }), true);
547
+ });
548
+
549
+ test("the parity guard agrees with the SQL VIEW for a numeric key compared to a TEXT literal", async () => {
550
+ // End-to-end: the managed VIEW compares an (untyped) numeric column to a numeric-looking text literal
551
+ // by storage class, and the TS backend must match — including for 64-bit keys past 2^53.
552
+ const model = defineReadModel({
553
+ name: "keytext_read_model",
554
+ baseTable: "keytext_rows",
555
+ derive: {
556
+ eq_text: eq(col("k"), lit("9007199254740992")),
557
+ lt_text: lt(col("k"), lit("9007199254740992")),
558
+ gt_text: gt(col("k"), lit("9007199254740992")),
559
+ },
560
+ });
561
+ await withDb((db) => {
562
+ assert.doesNotThrow(() =>
563
+ assertReadModelParity(model, db, [
564
+ { baseRow: { k: 9007199254740993n } },
565
+ { baseRow: { k: 9007199254740992n } },
566
+ { baseRow: { k: 5n } },
567
+ ]),
568
+ );
569
+ });
570
+ });
571
+
572
+
573
+ test("the parity guard reports a mismatch (not a bigint serialisation TypeError) when a divergent value is a bigint", async () => {
574
+ // A derived INTEGER > 2^53 can reach the mismatch formatter as a `bigint`. `JSON.stringify` throws
575
+ // a TypeError on bigint, which would mask the real parity failure — the guard must still name the
576
+ // mismatch. Drift the TS backend so it returns a bigint the SQL VIEW does not, then assert the
577
+ // thrown error is the parity mismatch (mentioning the bigint value), not a TypeError.
578
+ const model = defineReadModel({
579
+ name: "bigint_mismatch_read_model",
580
+ baseTable: "bigint_mismatch_rows",
581
+ derive: {
582
+ key: col("k"),
583
+ },
584
+ });
585
+ const drifted: ReadModel = {
586
+ ...model,
587
+ fnFor: (column) => (column === "key" ? () => 9007199254740993n : model.fnFor(column)),
588
+ };
589
+ await withDb((db) => {
590
+ assert.throws(
591
+ () => assertReadModelParity(drifted, db, [{ baseRow: { k: 1 } }]),
592
+ /parity mismatch in "bigint_mismatch_read_model"\.key.*TS=9007199254740993n/,
593
+ );
594
+ });
595
+ });
596
+
597
+ test("the parity guard treats a lossless bigint and its number equal (parity) but keeps a bigint past 2^53 exact (mismatch)", async () => {
598
+ // A SQLite INTEGER can surface as `number` on the SQL side and `bigint` on the TS side (or vice
599
+ // versa) depending on the driver — the guard compares with `Object.is`, so `1` vs `1n` would be a
600
+ // SPURIOUS mismatch. normaliseSqlValue collapses a LOSSLESS bigint to number so identical integers
601
+ // are parity; a bigint past 2^53 stays EXACT so a genuine divergence is never masked.
602
+ const model = defineReadModel({
603
+ name: "bigint_norm_read_model",
604
+ baseTable: "bigint_norm_rows",
605
+ derive: { key: col("k") },
606
+ });
607
+ // baseRow { k: 1 } → the SQL VIEW yields the number 1.
608
+ const losslessDrift: ReadModel = {
609
+ ...model,
610
+ fnFor: (column) => (column === "key" ? () => 1n : model.fnFor(column)),
611
+ };
612
+ const exactDrift: ReadModel = {
613
+ ...model,
614
+ fnFor: (column) => (column === "key" ? () => 9007199254740993n : model.fnFor(column)),
615
+ };
616
+ await withDb((db) => {
617
+ // 1n (TS) vs 1 (SQL) is parity — the lossless bigint normalises to number.
618
+ assert.doesNotThrow(() => assertReadModelParity(losslessDrift, db, [{ baseRow: { k: 1 } }]));
619
+ // 2^53+1 (TS) vs 1 (SQL) stays a mismatch — a bigint past 2^53 is not collapsed onto a number.
620
+ assert.throws(
621
+ () => assertReadModelParity(exactDrift, db, [{ baseRow: { k: 1 } }]),
622
+ /parity mismatch in "bigint_norm_read_model"\.key/,
623
+ );
624
+ });
625
+ });
626
+
627
+
628
+ test("the parity guard runs entirely in the TEMP schema and never clobbers real main-schema tables/views the DB already holds under the model's names", async () => {
629
+ // The guard drops/creates the base table, projection tables and managed VIEW by the model's REAL
630
+ // names. If it did so in the `main` schema, calling it against a DB that already holds application
631
+ // tables/views with those names would silently DELETE the caller's data. It must instead operate in
632
+ // SQLite's TEMP schema (CREATE TEMP TABLE/VIEW, temp.-qualified drops), leaving `main` untouched.
633
+ await withDb((db) => {
634
+ // Real application objects in `main`, under the SAME names the guard uses, holding real rows.
635
+ db.exec(`CREATE TABLE tasks (id TEXT, process_instance_key TEXT, state TEXT, priority INTEGER);`);
636
+ db.exec(`CREATE TABLE urban_open_user_tasks (process_instance_key TEXT);`);
637
+ db.exec(taskReadModel.viewDdl());
638
+ db.run(`INSERT INTO tasks (id, process_instance_key, state, priority) VALUES (?, ?, ?, ?)`, [
639
+ "real-1",
640
+ "pi-real",
641
+ "done",
642
+ 7,
643
+ ]);
644
+ db.run(`INSERT INTO urban_open_user_tasks (process_instance_key) VALUES (?)`, ["pi-real"]);
645
+
646
+ assert.doesNotThrow(() =>
647
+ assertReadModelParity(taskReadModel, db, [
648
+ { baseRow: { process_instance_key: "pi-1", state: "done", priority: 9 }, projections: {} },
649
+ {
650
+ baseRow: { process_instance_key: "pi-2", state: "running", priority: 3 },
651
+ projections: { urban_open_user_tasks: [{ process_instance_key: "pi-2" }] },
652
+ },
653
+ ]),
654
+ );
655
+
656
+ // The real main-schema base table, projection table AND managed VIEW survive with their data.
657
+ const tasks = db.all<{ id: string }>(`SELECT id FROM main.tasks`);
658
+ assert.equal(tasks.length, 1);
659
+ assert.equal(tasks[0]?.id, "real-1");
660
+ const proj = db.all<{ process_instance_key: string }>(
661
+ `SELECT process_instance_key FROM main.urban_open_user_tasks`,
662
+ );
663
+ assert.equal(proj.length, 1);
664
+ assert.equal(proj[0]?.process_instance_key, "pi-real");
665
+ const view = db.all<{ display_status: string }>(
666
+ `SELECT display_status FROM main.tasks_display WHERE id = 'real-1'`,
667
+ );
668
+ assert.equal(view.length, 1);
669
+ assert.equal(view[0]?.display_status, "completed");
670
+ });
671
+ });
672
+
673
+
674
+ test("the parity guard drops its TEMP fixtures/VIEW after running so a long-lived connection is left clean (even on mismatch)", async () => {
675
+ // The guard builds TEMP tables + a TEMP VIEW under the model's real names. If it left them behind,
676
+ // subsequent UNqualified reads/writes on that same long-lived handle would silently resolve to the
677
+ // leftover TEMP fixtures (TEMP shadows same-named `main` objects for the connection's lifetime), and
678
+ // the TEMP VIEW would shadow the managed `main` VIEW. It must drop everything it created in a
679
+ // `finally`, on both the passing path AND the throwing (mismatch) path.
680
+ const leftoverTempObjects = (db: SqliteDb): string[] =>
681
+ db
682
+ .all<{ name: string }>(`SELECT name FROM temp.sqlite_master WHERE type IN ('table', 'view')`)
683
+ .map((r) => r.name);
684
+
685
+ // Passing run: TEMP schema is empty afterwards.
686
+ await withDb((db) => {
687
+ assertReadModelParity(taskReadModel, db, [
688
+ { baseRow: { process_instance_key: "pi-1", state: "done", priority: 9 }, projections: {} },
689
+ ]);
690
+ assert.deepEqual(leftoverTempObjects(db), []);
691
+ });
692
+
693
+ // Throwing run (deliberate drift): the `finally` still drops everything before the throw propagates.
694
+ const drifted: ReadModel = {
695
+ ...taskReadModel,
696
+ fnFor: (column) =>
697
+ column === "display_status"
698
+ ? compileToFn(caseWhen([when(neq(col("state"), lit("done")), lit("completed"))], lit("active")))
699
+ : taskReadModel.fnFor(column),
700
+ };
701
+ await withDb((db) => {
702
+ assert.throws(() =>
703
+ assertReadModelParity(drifted, db, [
704
+ { baseRow: { process_instance_key: "pi-1", state: "done", priority: 9 }, projections: {} },
705
+ ]),
706
+ );
707
+ assert.deepEqual(leftoverTempObjects(db), []);
708
+ });
709
+ });
710
+
711
+
712
+ test("eq/neq coerce booleans like SQLite (1/0) so the TS backend cannot drift from the VIEW", () => {
713
+ // `lit(true)` compiles to SQL `1`; strict `===` would make `1 === true` false and diverge.
714
+ const on = compileToFn(eq(col("flag"), lit(true)));
715
+ assert.equal(on({ flag: 1 }), true);
716
+ assert.equal(on({ flag: 0 }), false);
717
+ const off = compileToFn(neq(col("flag"), lit(true)));
718
+ assert.equal(off({ flag: 1 }), false);
719
+ assert.equal(off({ flag: 0 }), true);
720
+ });
721
+
722
+ test("eq/neq compare bigint INTEGERs numerically so the TS backend cannot drift from the VIEW", () => {
723
+ // A 64-bit key can reach the TS backend as a `bigint`; it must equal the SQL literal `1`, not the
724
+ // string "1" — stringifying via `orderable` would make `1n === 1` false and drift from the VIEW.
725
+ const on = compileToFn(eq(col("k"), lit(1)));
726
+ assert.equal(on({ k: 1n }), true);
727
+ assert.equal(on({ k: 2n }), false);
728
+ const off = compileToFn(neq(col("k"), lit(1)));
729
+ assert.equal(off({ k: 1n }), false);
730
+ assert.equal(off({ k: 2n }), true);
731
+ });
732
+
733
+ test("eq/neq/gt compare 64-bit bigint keys EXACTLY so distinct keys past 2^53 don't collapse", () => {
734
+ // Number(9007199254740993n) === Number(9007199254740992n) → both round to 9007199254740992, so
735
+ // truncating a 64-bit key through Number() would make two DISTINCT keys compare equal and drift from
736
+ // the SQL VIEW. The TS backend must compare the bigints exactly.
737
+ const big = 9007199254740993n; // 2^53 + 1 — NOT representable as a JS number
738
+ const twoP53 = 9007199254740992n; // 2^53 — Number() maps `big` onto this
739
+ const same = compileToFn(eq(col("a"), col("b")));
740
+ assert.equal(same({ a: big, b: big }), true, "a 64-bit key equals itself exactly");
741
+ assert.equal(same({ a: big, b: twoP53 }), false, "keys differing only past 2^53 must NOT be equal");
742
+ const different = compileToFn(neq(col("a"), col("b")));
743
+ assert.equal(different({ a: big, b: twoP53 }), true, "distinct 64-bit keys are unequal");
744
+ const greater = compileToFn(gt(col("a"), col("b")));
745
+ assert.equal(greater({ a: big, b: twoP53 }), true, "2^53+1 > 2^53 holds without truncation");
746
+ });
747
+
748
+ test("CASE-WHEN coerces string conditions numerically like SQLite ('0'/'abc' false, '2abc' true)", async () => {
749
+ // SQLite evaluates a string in a boolean context by its leading numeric prefix, so a non-boolean
750
+ // column flowing into a CASE condition must coerce identically in TS or the two backends diverge.
751
+ const model = defineReadModel({
752
+ name: "flagword_read_model",
753
+ baseTable: "flagword_rows",
754
+ derive: { label: caseWhen([when(col("word"), lit("truthy"))], lit("falsy")) },
755
+ });
756
+ await withDb((db) => {
757
+ assert.doesNotThrow(() =>
758
+ assertReadModelParity(model, db, [
759
+ { baseRow: { word: "0" } },
760
+ { baseRow: { word: "abc" } },
761
+ { baseRow: { word: "2abc" } },
762
+ { baseRow: { word: "1" } },
763
+ { baseRow: { word: "" } },
764
+ ]),
765
+ );
766
+ });
767
+ });
768
+
769
+ test("the parity guard PASSES for a boolean-equality derived column across both backends", async () => {
770
+ const model = defineReadModel({
771
+ name: "flags_read_model",
772
+ baseTable: "flag_rows",
773
+ derive: { is_on: eq(col("flag"), lit(true)) },
774
+ });
775
+ await withDb((db) => {
776
+ assert.doesNotThrow(() =>
777
+ assertReadModelParity(model, db, [{ baseRow: { flag: 1 } }, { baseRow: { flag: 0 } }]),
778
+ );
779
+ });
780
+ });
781
+
782
+ test("ensureViews replaces a stale VIEW body (managed provisioning, not IF NOT EXISTS)", async () => {
783
+ await withDb((db) => {
784
+ db.exec(`CREATE TABLE tasks (id TEXT, process_instance_key TEXT, state TEXT, priority INTEGER);`);
785
+ db.exec(`CREATE TABLE urban_open_user_tasks (process_instance_key TEXT);`);
786
+ // A stale VIEW from an older definition already exists under the managed name.
787
+ db.exec(`CREATE VIEW tasks_display AS SELECT 'stale' AS display_status;`);
788
+ const reg = new ReadModelRegistry();
789
+ reg.register(taskReadModel);
790
+ reg.ensureViews(db);
791
+ db.run(`INSERT INTO tasks VALUES (?, ?, ?, ?)`, ["t1", "pi-1", "done", 9]);
792
+ const row = db.all<{ display_status: string }>(`SELECT display_status FROM tasks_display`)[0];
793
+ assert.equal(row.display_status, "completed"); // stale body was dropped and replaced
794
+ });
795
+ });
796
+
797
+ test("ensureViews drops the MAIN managed view even when a TEMP view shadows the name (no stale main body)", async () => {
798
+ // An unqualified `DROP VIEW IF EXISTS "n"` resolves TEMP first, so a stray TEMP view of the same
799
+ // name (e.g. leaked from a parity-guard run on a long-lived handle) would be dropped INSTEAD of the
800
+ // managed `main` view; the following `CREATE VIEW IF NOT EXISTS "n"` would then no-op against the
801
+ // surviving main view, leaving a changed definition's STALE body live in production. ensureViews
802
+ // qualifies the DROP to `main`, so a redefinition always refreshes the managed main body.
803
+ await withDb((db) => {
804
+ db.exec(`CREATE TABLE shadow_base (id TEXT);`);
805
+ db.run(`INSERT INTO shadow_base VALUES (?)`, ["x"]);
806
+
807
+ const v1 = defineReadModel({ name: "shadow_rm", baseTable: "shadow_base", derive: { label: lit("A") } });
808
+ const regA = new ReadModelRegistry();
809
+ regA.register(v1);
810
+ regA.ensureViews(db); // main.shadow_rm = body A
811
+
812
+ // A stray TEMP view of the same name that would swallow an unqualified DROP.
813
+ db.exec(`CREATE TEMP VIEW shadow_rm AS SELECT 'temp' AS label;`);
814
+
815
+ const v2 = defineReadModel({ name: "shadow_rm", baseTable: "shadow_base", derive: { label: lit("B") } });
816
+ const regB = new ReadModelRegistry();
817
+ regB.register(v2);
818
+ regB.ensureViews(db); // must replace the MAIN body with B, not leave A behind the TEMP shadow
819
+
820
+ const mainRow = db.all<{ label: string }>(`SELECT label FROM main.shadow_rm`)[0];
821
+ assert.equal(mainRow.label, "B", "the managed main view must refresh to the new body, not stay stale behind a TEMP shadow");
822
+ });
823
+ });
824
+
825
+ test("the parity guard rejects an unknown column with an actionable error", async () => {
826
+ await withDb((db) => {
827
+ assert.throws(
828
+ () =>
829
+ assertReadModelParity(
830
+ taskReadModel,
831
+ db,
832
+ [{ baseRow: { process_instance_key: "pi-1", state: "done", priority: 9 } }],
833
+ { columns: ["not_a_column"] },
834
+ ),
835
+ /no derived column "not_a_column"/,
836
+ );
837
+ });
838
+ });
839
+
840
+ test("the parity guard rejects two projections mapped to one physical table with an actionable error", async () => {
841
+ // A mapping bug: two DSL projection names resolving to ONE physical table would make the guard
842
+ // CREATE (then drop/insert) that table twice and fail for a non-parity reason. It must reject up
843
+ // front with an actionable error (idempotent registrations, so safe to re-run).
844
+ try {
845
+ projectionRegistry.register({ name: "dup_proj_a", sqlTable: "dup_shared_table" });
846
+ projectionRegistry.register({ name: "dup_proj_b", sqlTable: "dup_shared_table" });
847
+ const model = defineReadModel({
848
+ name: "dup_table_read_model",
849
+ baseTable: "dup_rows",
850
+ derive: {
851
+ label: caseWhen(
852
+ [
853
+ when(exists("dup_proj_a", eq(pcol("k"), col("k"))), lit("a")),
854
+ when(exists("dup_proj_b", eq(pcol("k"), col("k"))), lit("b")),
855
+ ],
856
+ lit("none"),
857
+ ),
858
+ },
859
+ });
860
+ await withDb((db) => {
861
+ assert.throws(
862
+ () => assertReadModelParity(model, db, [{ baseRow: { k: "x" } }]),
863
+ /maps projections "dup_proj_a" and "dup_proj_b" to the same physical table "dup_shared_table"/,
864
+ );
865
+ });
866
+ } finally {
867
+ // Reset the process-wide singleton so this test cannot leak registrations into others.
868
+ projectionRegistry.clear();
869
+ }
870
+ });
871
+
872
+ test("the parity guard rejects two projections whose physical tables match only by case", async () => {
873
+ // SQLite folds identifiers, so "Dup_Shared" and "dup_shared" are ONE physical table. The many-to-one
874
+ // dedup keys by the case-folded name, so this collision must be rejected up front just like an exact one.
875
+ try {
876
+ projectionRegistry.register({ name: "dupcase_proj_a", sqlTable: "Dup_Shared" });
877
+ projectionRegistry.register({ name: "dupcase_proj_b", sqlTable: "dup_shared" });
878
+ const model = defineReadModel({
879
+ name: "dupcase_table_read_model",
880
+ baseTable: "dupcase_rows",
881
+ derive: {
882
+ label: caseWhen(
883
+ [
884
+ when(exists("dupcase_proj_a", eq(pcol("k"), col("k"))), lit("a")),
885
+ when(exists("dupcase_proj_b", eq(pcol("k"), col("k"))), lit("b")),
886
+ ],
887
+ lit("none"),
888
+ ),
889
+ },
890
+ });
891
+ await withDb((db) => {
892
+ assert.throws(
893
+ () => assertReadModelParity(model, db, [{ baseRow: { k: "x" } }]),
894
+ /maps projections "dupcase_proj_a" and "dupcase_proj_b" to the same physical table/,
895
+ );
896
+ });
897
+ } finally {
898
+ // Reset the process-wide singleton so this test cannot leak registrations into others.
899
+ projectionRegistry.clear();
900
+ }
901
+ });
902
+
903
+ test("the parity guard ignores projections the model does not reference", async () => {
904
+ await withDb((db) => {
905
+ assert.doesNotThrow(() =>
906
+ assertReadModelParity(taskReadModel, db, [
907
+ {
908
+ baseRow: { process_instance_key: "pi-1", state: "done", priority: 9 },
909
+ projections: { urban_open_user_tasks: [], unrelated_projection: [{ foo: "bar" }] },
910
+ },
911
+ ]),
912
+ );
913
+ });
914
+ });
915
+
916
+ test("the parity guard materialises a keyless sample row (DEFAULT VALUES, not skipped)", async () => {
917
+ // A constant-derived model referencing no base columns: a `{ baseRow: {} }` sample must still
918
+ // produce one VIEW row, else SQL returns 0 rows (undefined) while the TS fn returns the constant.
919
+ const model = defineReadModel({
920
+ name: "constant_read_model",
921
+ baseTable: "constant_rows",
922
+ derive: { label: lit("active") },
923
+ });
924
+ await withDb((db) => {
925
+ assert.doesNotThrow(() => assertReadModelParity(model, db, [{ baseRow: {} }]));
926
+ });
927
+ });
928
+
929
+ test("an invalid SQL identifier is rejected at declaration time (injection guard)", () => {
930
+ assert.throws(() => defineReadModel({ name: "bad name", baseTable: "t", derive: { c: lit(1) } }), /invalid/);
931
+ assert.throws(() => defineReadModel({ name: "ok", baseTable: "t; DROP TABLE x", derive: { c: lit(1) } }), /invalid/);
932
+ // A projection name referenced via exists(...) must be validated at declaration time too, so a bad
933
+ // name fails immediately here rather than later at registry.register / view-apply time.
934
+ assert.throws(
935
+ () =>
936
+ defineReadModel({
937
+ name: "ok",
938
+ baseTable: "t",
939
+ derive: { c: exists("bad projection", eq(pcol("x"), lit(1))) },
940
+ }),
941
+ /invalid/,
942
+ );
943
+ });