@nanobpm/urban 0.81.0 → 0.82.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/runtime/core/modules/workers.js +25 -1
- package/dist/runtime/core/read-model.d.ts +268 -5
- package/dist/runtime/core/read-model.js +1033 -35
- package/dist/runtime/index.d.ts +2 -2
- package/dist/runtime/index.js +6 -1
- package/package.json +1 -1
- package/src/runtime/core/modules/workers.ts +24 -1
- package/src/runtime/core/read-model.test.ts +27 -2
- package/src/runtime/core/read-model.ts +1313 -41
- package/src/runtime/core/rollup.test.ts +818 -0
- package/src/runtime/index.ts +43 -0
|
@@ -0,0 +1,818 @@
|
|
|
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
|
+
add,
|
|
10
|
+
and,
|
|
11
|
+
assertReadModelParity,
|
|
12
|
+
assertRollupParity,
|
|
13
|
+
caseWhen,
|
|
14
|
+
coalesce,
|
|
15
|
+
col,
|
|
16
|
+
count,
|
|
17
|
+
countWhere,
|
|
18
|
+
defineReadModel,
|
|
19
|
+
defineRollup,
|
|
20
|
+
eq,
|
|
21
|
+
fromProjection,
|
|
22
|
+
fromRollup,
|
|
23
|
+
gt,
|
|
24
|
+
isNotNull,
|
|
25
|
+
isNull,
|
|
26
|
+
joinSource,
|
|
27
|
+
lit,
|
|
28
|
+
max,
|
|
29
|
+
minWhere,
|
|
30
|
+
neq,
|
|
31
|
+
not,
|
|
32
|
+
or,
|
|
33
|
+
pcol,
|
|
34
|
+
exists,
|
|
35
|
+
ProjectionRegistry,
|
|
36
|
+
projectionRegistry,
|
|
37
|
+
rcol,
|
|
38
|
+
RollupRegistry,
|
|
39
|
+
when,
|
|
40
|
+
} from "./read-model.ts";
|
|
41
|
+
import type { Rollup } from "./read-model.ts";
|
|
42
|
+
|
|
43
|
+
async function withDb(fn: (db: SqliteDb) => void | Promise<void>): Promise<void> {
|
|
44
|
+
const dir = await mkdtemp(join(tmpdir(), "urban-rollup-"));
|
|
45
|
+
const host = createNodeHost({ cwd: dir, log: () => {} });
|
|
46
|
+
const db = host.openSqlite(join(dir, "test.db"));
|
|
47
|
+
try {
|
|
48
|
+
await fn(db);
|
|
49
|
+
} finally {
|
|
50
|
+
db.close();
|
|
51
|
+
await rm(dir, { recursive: true, force: true });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ─── The plan-family rollups, single-sourced from the hand-authored VIEWs (nano-workforce 059/060/061).
|
|
56
|
+
|
|
57
|
+
// plan_delivery_counts — GROUP BY plan_key over plan_tasks LEFT JOIN pull_requests ON pr_key.
|
|
58
|
+
const planDeliveryCounts = defineRollup({
|
|
59
|
+
name: "plan_delivery_counts",
|
|
60
|
+
source: joinSource({
|
|
61
|
+
left: { relation: "plan_tasks", alias: "t" },
|
|
62
|
+
right: { relation: "pull_requests", alias: "p" },
|
|
63
|
+
on: [{ left: "pr_key", right: "pr_key" }],
|
|
64
|
+
columns: {
|
|
65
|
+
plan_key: ["left", "plan_key"],
|
|
66
|
+
pr_key: ["left", "pr_key"],
|
|
67
|
+
pr_status: ["right", "status"],
|
|
68
|
+
},
|
|
69
|
+
}),
|
|
70
|
+
groupBy: ["plan_key"],
|
|
71
|
+
aggregates: {
|
|
72
|
+
prs_opened: count("pr_key"),
|
|
73
|
+
prs_merged: countWhere(and(isNotNull(col("pr_key")), eq(col("pr_status"), lit("merged")))),
|
|
74
|
+
prs_in_flight: countWhere(
|
|
75
|
+
and(
|
|
76
|
+
isNotNull(col("pr_key")),
|
|
77
|
+
or(
|
|
78
|
+
isNull(col("pr_status")),
|
|
79
|
+
not(
|
|
80
|
+
or(
|
|
81
|
+
eq(col("pr_status"), lit("converged")),
|
|
82
|
+
eq(col("pr_status"), lit("merged")),
|
|
83
|
+
eq(col("pr_status"), lit("abandoned")),
|
|
84
|
+
),
|
|
85
|
+
),
|
|
86
|
+
),
|
|
87
|
+
),
|
|
88
|
+
),
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// plan_wave_counts — GROUP BY (plan_key, wave), WHERE wave IS NOT NULL. The disjoint six-way partition.
|
|
93
|
+
const notMerged = not(eq(col("pr_status"), lit("merged")));
|
|
94
|
+
const planWaveCounts = defineRollup({
|
|
95
|
+
name: "plan_wave_counts",
|
|
96
|
+
source: joinSource({
|
|
97
|
+
left: { relation: "plan_tasks", alias: "t" },
|
|
98
|
+
right: { relation: "pull_requests", alias: "p" },
|
|
99
|
+
on: [{ left: "pr_key", right: "pr_key" }],
|
|
100
|
+
columns: {
|
|
101
|
+
plan_key: ["left", "plan_key"],
|
|
102
|
+
wave: ["left", "wave"],
|
|
103
|
+
task_status: ["left", "status"],
|
|
104
|
+
pr_status: ["right", "status"],
|
|
105
|
+
},
|
|
106
|
+
}),
|
|
107
|
+
where: isNotNull(col("wave")),
|
|
108
|
+
groupBy: ["plan_key", "wave"],
|
|
109
|
+
aggregates: {
|
|
110
|
+
total: count(),
|
|
111
|
+
merged: countWhere(eq(col("pr_status"), lit("merged"))),
|
|
112
|
+
skipped: countWhere(and(notMerged, eq(col("task_status"), lit("skipped")))),
|
|
113
|
+
blocked: countWhere(and(notMerged, eq(col("task_status"), lit("blocked")))),
|
|
114
|
+
escalated: countWhere(and(notMerged, eq(col("task_status"), lit("escalated")))),
|
|
115
|
+
in_flight: countWhere(
|
|
116
|
+
and(
|
|
117
|
+
notMerged,
|
|
118
|
+
not(
|
|
119
|
+
or(
|
|
120
|
+
eq(col("task_status"), lit("skipped")),
|
|
121
|
+
eq(col("task_status"), lit("blocked")),
|
|
122
|
+
eq(col("task_status"), lit("escalated")),
|
|
123
|
+
),
|
|
124
|
+
),
|
|
125
|
+
),
|
|
126
|
+
),
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// plan_wave_progress — COMPOSABLE: GROUP BY plan_key over plan_wave_counts (the wave frontier).
|
|
131
|
+
const planWaveProgress = defineRollup({
|
|
132
|
+
name: "plan_wave_progress",
|
|
133
|
+
source: fromRollup(planWaveCounts),
|
|
134
|
+
groupBy: ["plan_key"],
|
|
135
|
+
aggregates: {
|
|
136
|
+
wave_count: add(max("wave"), 1),
|
|
137
|
+
current_wave: coalesce(minWhere("wave", gt(col("in_flight"), lit(0))), max("wave")),
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("defineRollup (join source, D2 count/countWhere) parity-matches its VIEW — plan_delivery_counts", async () => {
|
|
142
|
+
await withDb((db) => {
|
|
143
|
+
assert.doesNotThrow(() =>
|
|
144
|
+
assertRollupParity(planDeliveryCounts, db, [
|
|
145
|
+
{
|
|
146
|
+
// p1: all merged → landed shape; p2: one in flight; p3: no PRs.
|
|
147
|
+
plan_tasks: [
|
|
148
|
+
{ plan_key: "p1", pr_key: "pr1" },
|
|
149
|
+
{ plan_key: "p1", pr_key: "pr2" },
|
|
150
|
+
{ plan_key: "p2", pr_key: "pr3" },
|
|
151
|
+
{ plan_key: "p2", pr_key: "pr4" },
|
|
152
|
+
{ plan_key: "p3", pr_key: null },
|
|
153
|
+
],
|
|
154
|
+
pull_requests: [
|
|
155
|
+
{ pr_key: "pr1", status: "merged" },
|
|
156
|
+
{ pr_key: "pr2", status: "merged" },
|
|
157
|
+
{ pr_key: "pr3", status: "merged" },
|
|
158
|
+
// pr4 has no pull_requests row → in flight (MISSING_PR_STATUS sentinel).
|
|
159
|
+
],
|
|
160
|
+
},
|
|
161
|
+
]),
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("defineRollup join source resolves group/predicate columns case-insensitively (SQLite folding) — parity-matches", async () => {
|
|
167
|
+
// SQLite folds identifiers case-insensitively and the TS twin resolves join-mapped columns via the
|
|
168
|
+
// case-insensitive `lookupColumn`, so a rollup referencing a join-mapped output column with DIFFERENT
|
|
169
|
+
// casing than declared must resolve in SQL too. Before the fix `source.columns["PLAN_KEY"]` was a
|
|
170
|
+
// case-sensitive miss: the SQL compilation threw `no mapped column` while the TS reduce resolved fine.
|
|
171
|
+
const casedRollup = defineRollup({
|
|
172
|
+
name: "plan_delivery_counts_cased",
|
|
173
|
+
source: joinSource({
|
|
174
|
+
left: { relation: "plan_tasks", alias: "t" },
|
|
175
|
+
right: { relation: "pull_requests", alias: "p" },
|
|
176
|
+
on: [{ left: "pr_key", right: "pr_key" }],
|
|
177
|
+
columns: {
|
|
178
|
+
plan_key: ["left", "plan_key"],
|
|
179
|
+
pr_key: ["left", "pr_key"],
|
|
180
|
+
pr_status: ["right", "status"],
|
|
181
|
+
},
|
|
182
|
+
}),
|
|
183
|
+
groupBy: ["PLAN_KEY"],
|
|
184
|
+
aggregates: {
|
|
185
|
+
prs_opened: count("PR_KEY"),
|
|
186
|
+
prs_merged: countWhere(and(isNotNull(col("Pr_Key")), eq(col("PR_STATUS"), lit("merged")))),
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
await withDb((db) => {
|
|
190
|
+
assert.doesNotThrow(() =>
|
|
191
|
+
assertRollupParity(casedRollup, db, [
|
|
192
|
+
{
|
|
193
|
+
plan_tasks: [
|
|
194
|
+
{ plan_key: "p1", pr_key: "pr1" },
|
|
195
|
+
{ plan_key: "p1", pr_key: "pr2" },
|
|
196
|
+
{ plan_key: "p2", pr_key: "pr3" },
|
|
197
|
+
],
|
|
198
|
+
pull_requests: [
|
|
199
|
+
{ pr_key: "pr1", status: "merged" },
|
|
200
|
+
{ pr_key: "pr3", status: "merged" },
|
|
201
|
+
],
|
|
202
|
+
},
|
|
203
|
+
]),
|
|
204
|
+
);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("defineRollup rejects a groupBy/col reference absent from a known JOIN source namespace (fail-fast, no backend drift)", () => {
|
|
209
|
+
// The SQL side would throw `no mapped column` at view compilation while the TS reduce reads NULL via
|
|
210
|
+
// `lookupColumn` and groups/counts against a phantom column — a silent cross-backend drift. Reject it
|
|
211
|
+
// deterministically at declaration time instead.
|
|
212
|
+
assert.throws(
|
|
213
|
+
() =>
|
|
214
|
+
defineRollup({
|
|
215
|
+
name: "plan_delivery_counts_badcol",
|
|
216
|
+
source: joinSource({
|
|
217
|
+
left: { relation: "plan_tasks", alias: "t" },
|
|
218
|
+
right: { relation: "pull_requests", alias: "p" },
|
|
219
|
+
on: [{ left: "pr_key", right: "pr_key" }],
|
|
220
|
+
columns: {
|
|
221
|
+
plan_key: ["left", "plan_key"],
|
|
222
|
+
pr_key: ["left", "pr_key"],
|
|
223
|
+
},
|
|
224
|
+
}),
|
|
225
|
+
groupBy: ["plan_key"],
|
|
226
|
+
aggregates: { merged: countWhere(eq(col("pr_status"), lit("merged"))) },
|
|
227
|
+
}),
|
|
228
|
+
/references column "pr_status" absent from its join source output namespace/,
|
|
229
|
+
);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("defineRollup rejects a groupBy/col reference absent from a known ROLLUP source namespace (fail-fast, no backend drift)", () => {
|
|
233
|
+
// A rollup-over-rollup that references a column the inner rollup does not output: the SQL side throws
|
|
234
|
+
// `no such column` at query time while the TS reduce reads NULL via `lookupColumn` — reject it
|
|
235
|
+
// deterministically at declaration time instead.
|
|
236
|
+
assert.throws(
|
|
237
|
+
() =>
|
|
238
|
+
defineRollup({
|
|
239
|
+
name: "plan_wave_progress_badcol",
|
|
240
|
+
source: fromRollup(planWaveCounts),
|
|
241
|
+
groupBy: ["plan_key"],
|
|
242
|
+
aggregates: { x: max("nonexistent") },
|
|
243
|
+
}),
|
|
244
|
+
/references column "nonexistent" absent from its rollup source output namespace/,
|
|
245
|
+
);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("defineRollup (two-level group key + WHERE) parity-matches — plan_wave_counts", async () => {
|
|
249
|
+
await withDb((db) => {
|
|
250
|
+
assert.doesNotThrow(() =>
|
|
251
|
+
assertRollupParity(planWaveCounts, db, [
|
|
252
|
+
{
|
|
253
|
+
plan_tasks: [
|
|
254
|
+
{ plan_key: "p1", wave: 0, status: "merged", pr_key: "a" },
|
|
255
|
+
{ plan_key: "p1", wave: 0, status: "opened", pr_key: "b" },
|
|
256
|
+
{ plan_key: "p1", wave: 1, status: "blocked", pr_key: "c" },
|
|
257
|
+
{ plan_key: "p1", wave: 1, status: "skipped", pr_key: null },
|
|
258
|
+
{ plan_key: "p2", wave: 0, status: "escalated", pr_key: "d" },
|
|
259
|
+
// NULL wave rows are excluded by WHERE wave IS NOT NULL.
|
|
260
|
+
{ plan_key: "p1", wave: null, status: "opened", pr_key: null },
|
|
261
|
+
],
|
|
262
|
+
pull_requests: [
|
|
263
|
+
{ pr_key: "a", status: "merged" },
|
|
264
|
+
{ pr_key: "b", status: "opened" },
|
|
265
|
+
{ pr_key: "c", status: "opened" },
|
|
266
|
+
{ pr_key: "d", status: "opened" },
|
|
267
|
+
],
|
|
268
|
+
},
|
|
269
|
+
]),
|
|
270
|
+
);
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("defineRollup COMPOSABLE over a rollup (add/coalesce/minWhere/max) parity-matches — wave frontier", async () => {
|
|
275
|
+
await withDb((db) => {
|
|
276
|
+
assert.doesNotThrow(() =>
|
|
277
|
+
assertRollupParity(planWaveProgress, db, [
|
|
278
|
+
{
|
|
279
|
+
plan_tasks: [
|
|
280
|
+
// p1: wave 0 fully merged, wave 1 in flight → wave_count=2, current_wave=1.
|
|
281
|
+
{ plan_key: "p1", wave: 0, status: "merged", pr_key: "a" },
|
|
282
|
+
{ plan_key: "p1", wave: 1, status: "opened", pr_key: "b" },
|
|
283
|
+
// p2: all waves settled → current_wave pins to MAX(wave).
|
|
284
|
+
{ plan_key: "p2", wave: 0, status: "merged", pr_key: "c" },
|
|
285
|
+
{ plan_key: "p2", wave: 1, status: "merged", pr_key: "d" },
|
|
286
|
+
],
|
|
287
|
+
pull_requests: [
|
|
288
|
+
{ pr_key: "a", status: "merged" },
|
|
289
|
+
{ pr_key: "b", status: "opened" },
|
|
290
|
+
{ pr_key: "c", status: "merged" },
|
|
291
|
+
{ pr_key: "d", status: "merged" },
|
|
292
|
+
],
|
|
293
|
+
},
|
|
294
|
+
]),
|
|
295
|
+
);
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test("the rollup parity guard FAILS when the TS reduce is deliberately drifted from the VIEW", async () => {
|
|
300
|
+
const drifted: Rollup = {
|
|
301
|
+
...planDeliveryCounts,
|
|
302
|
+
// Mutate the TS backend: drop one row so a group count diverges from the SQL VIEW.
|
|
303
|
+
reduce: (inputs) => planDeliveryCounts.reduce(inputs).map((r) => ({ ...r, prs_opened: 999 })),
|
|
304
|
+
};
|
|
305
|
+
await withDb((db) => {
|
|
306
|
+
assert.throws(
|
|
307
|
+
() =>
|
|
308
|
+
assertRollupParity(drifted, db, [
|
|
309
|
+
{ plan_tasks: [{ plan_key: "p1", pr_key: "pr1" }], pull_requests: [{ pr_key: "pr1", status: "merged" }] },
|
|
310
|
+
]),
|
|
311
|
+
/rollup parity mismatch in "plan_delivery_counts"\.prs_opened/,
|
|
312
|
+
);
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
// ─── defineReadModel + key-correlated rollup lookup: the per-row delivery signal (nano-workforce 061).
|
|
317
|
+
|
|
318
|
+
const planDelivery = defineReadModel({
|
|
319
|
+
name: "plan_delivery",
|
|
320
|
+
baseTable: "plans",
|
|
321
|
+
lookups: [
|
|
322
|
+
{
|
|
323
|
+
as: "c",
|
|
324
|
+
rollup: planDeliveryCounts,
|
|
325
|
+
on: [{ base: "plan_key", rollup: "plan_key" }],
|
|
326
|
+
defaults: { prs_opened: 0, prs_in_flight: 0, prs_merged: 0 },
|
|
327
|
+
},
|
|
328
|
+
],
|
|
329
|
+
derive: {
|
|
330
|
+
delivery: caseWhen(
|
|
331
|
+
[
|
|
332
|
+
when(or(neq(col("status"), lit("done")), eq(rcol("c", "prs_opened"), lit(0))), lit(null)),
|
|
333
|
+
when(gt(rcol("c", "prs_in_flight"), lit(0)), lit("converging")),
|
|
334
|
+
when(eq(rcol("c", "prs_merged"), rcol("c", "prs_opened")), lit("landed")),
|
|
335
|
+
],
|
|
336
|
+
lit(null),
|
|
337
|
+
),
|
|
338
|
+
},
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test("defineReadModel rollup lookup (rcol + defaults) parity-matches — plan_delivery signal", async () => {
|
|
342
|
+
await withDb((db) => {
|
|
343
|
+
assert.doesNotThrow(() =>
|
|
344
|
+
assertReadModelParity(planDelivery, db, [
|
|
345
|
+
// done + all merged → landed.
|
|
346
|
+
{
|
|
347
|
+
baseRow: { plan_key: "p1", status: "done" },
|
|
348
|
+
lookups: { c: [{ plan_key: "p1", prs_opened: 2, prs_merged: 2, prs_in_flight: 0 }] },
|
|
349
|
+
},
|
|
350
|
+
// done + some in flight → converging.
|
|
351
|
+
{
|
|
352
|
+
baseRow: { plan_key: "p2", status: "done" },
|
|
353
|
+
lookups: { c: [{ plan_key: "p2", prs_opened: 3, prs_merged: 1, prs_in_flight: 2 }] },
|
|
354
|
+
},
|
|
355
|
+
// done + no PRs (LEFT-JOIN miss → COALESCE defaults) → null.
|
|
356
|
+
{ baseRow: { plan_key: "p3", status: "done" }, lookups: { c: [] } },
|
|
357
|
+
// not done → null (regardless of counts).
|
|
358
|
+
{
|
|
359
|
+
baseRow: { plan_key: "p4", status: "running" },
|
|
360
|
+
lookups: { c: [{ plan_key: "p4", prs_opened: 2, prs_merged: 2, prs_in_flight: 0 }] },
|
|
361
|
+
},
|
|
362
|
+
// done, resolved-not-landed (terminal but not all merged) → null.
|
|
363
|
+
{
|
|
364
|
+
baseRow: { plan_key: "p5", status: "done" },
|
|
365
|
+
lookups: { c: [{ plan_key: "p5", prs_opened: 3, prs_merged: 2, prs_in_flight: 0 }] },
|
|
366
|
+
},
|
|
367
|
+
]),
|
|
368
|
+
);
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test("defineReadModel evaluate resolves the lookup + defaults the same way the VIEW does", () => {
|
|
373
|
+
const landed = planDelivery.evaluate(
|
|
374
|
+
{ plan_key: "p1", status: "done" },
|
|
375
|
+
{},
|
|
376
|
+
{ c: [{ plan_key: "p1", prs_opened: 2, prs_merged: 2, prs_in_flight: 0 }] },
|
|
377
|
+
);
|
|
378
|
+
assert.equal(landed.delivery, "landed");
|
|
379
|
+
const noPrs = planDelivery.evaluate({ plan_key: "p3", status: "done" }, {}, { c: [] });
|
|
380
|
+
assert.equal(noPrs.delivery, null);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
test("a bare fnFor call on an rcol column fails loudly (must resolve lookups via evaluate)", () => {
|
|
384
|
+
// rcol needs a resolved lookup row; a caller that skips resolution (bare fnFor, no lookups arg) must
|
|
385
|
+
// get a clear error rather than a silent NULL that would drift from the VIEW's LEFT JOIN.
|
|
386
|
+
assert.throws(
|
|
387
|
+
() => planDelivery.fnFor("delivery")({ plan_key: "p1", status: "done" }),
|
|
388
|
+
/without a resolved lookup row/,
|
|
389
|
+
);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
test("a rollup lookup with MORE than one candidate match throws (single-valued, no silent fan-out)", () => {
|
|
393
|
+
// The join covers the rollup's full group key, so at most one row matches. Two matching candidate rows
|
|
394
|
+
// would fan out in SQL's LEFT JOIN while the TS resolver silently kept one — detect and fail loudly.
|
|
395
|
+
assert.throws(
|
|
396
|
+
() =>
|
|
397
|
+
planDelivery.resolveLookups({ plan_key: "p1", status: "done" }, {
|
|
398
|
+
c: [
|
|
399
|
+
{ plan_key: "p1", prs_opened: 2, prs_merged: 2, prs_in_flight: 0 },
|
|
400
|
+
{ plan_key: "p1", prs_opened: 9, prs_merged: 0, prs_in_flight: 9 },
|
|
401
|
+
],
|
|
402
|
+
}),
|
|
403
|
+
/matched 2 rows for the join key/,
|
|
404
|
+
);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
// ─── Regression guards: case-insensitive rcol alias resolution + null-prototype lookup map (#469 review).
|
|
408
|
+
|
|
409
|
+
test("rcol resolves case-insensitively (SQLite folding) — a differently-cased alias parity-matches", async () => {
|
|
410
|
+
// Lookup aliases are validated/deduped case-insensitively and the SQL `resolveRollupColumn` folds the
|
|
411
|
+
// alias, so `rcol("C", …)` against a lookup declared `as: "c"` compiles/validates fine. Before the fix
|
|
412
|
+
// the TS backend read `lookups["C"]` verbatim → undefined → threw "without a resolved lookup row",
|
|
413
|
+
// drifting from the working SQL LEFT JOIN. It must now resolve the same row on both backends.
|
|
414
|
+
const foldedModel = defineReadModel({
|
|
415
|
+
name: "plan_delivery_folded",
|
|
416
|
+
baseTable: "plans",
|
|
417
|
+
lookups: [
|
|
418
|
+
{
|
|
419
|
+
as: "c",
|
|
420
|
+
rollup: planDeliveryCounts,
|
|
421
|
+
on: [{ base: "plan_key", rollup: "plan_key" }],
|
|
422
|
+
defaults: { prs_opened: 0 },
|
|
423
|
+
},
|
|
424
|
+
],
|
|
425
|
+
derive: { opened: rcol("C", "prs_opened") },
|
|
426
|
+
});
|
|
427
|
+
const evaluated = foldedModel.evaluate(
|
|
428
|
+
{ plan_key: "p1" },
|
|
429
|
+
{},
|
|
430
|
+
{ c: [{ plan_key: "p1", prs_opened: 7 }] },
|
|
431
|
+
);
|
|
432
|
+
assert.equal(evaluated.opened, 7);
|
|
433
|
+
await withDb((db) => {
|
|
434
|
+
assert.doesNotThrow(() =>
|
|
435
|
+
assertReadModelParity(foldedModel, db, [
|
|
436
|
+
{ baseRow: { plan_key: "p1" }, lookups: { c: [{ plan_key: "p1", prs_opened: 7 }] } },
|
|
437
|
+
{ baseRow: { plan_key: "p2" }, lookups: { c: [] } },
|
|
438
|
+
]),
|
|
439
|
+
);
|
|
440
|
+
});
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
test("resolveLookups uses a null-prototype map — a `__proto__` alias sets an own property, not the prototype", () => {
|
|
444
|
+
// Lookup aliases are only identifier-validated, so `__proto__` is a legal alias. Building `resolved`
|
|
445
|
+
// as a plain `{}` would route `resolved["__proto__"] = row` through the magic prototype setter,
|
|
446
|
+
// corrupting the object's prototype chain instead of storing the row. A null-prototype dict avoids it.
|
|
447
|
+
const evilModel = defineReadModel({
|
|
448
|
+
name: "plan_delivery_proto",
|
|
449
|
+
baseTable: "plans",
|
|
450
|
+
lookups: [
|
|
451
|
+
{ as: "__proto__", rollup: planDeliveryCounts, on: [{ base: "plan_key", rollup: "plan_key" }] },
|
|
452
|
+
],
|
|
453
|
+
derive: { opened: rcol("__proto__", "prs_opened") },
|
|
454
|
+
});
|
|
455
|
+
// Build the candidate input as a null-prototype dict: a plain `{ __proto__: [...] }` literal (or a
|
|
456
|
+
// bracket assignment on a normal object) would trip the inherited `__proto__` setter instead of
|
|
457
|
+
// storing an own "__proto__" property, so the row would never reach the resolver.
|
|
458
|
+
const candidates: Record<string, ReadonlyArray<Record<string, unknown>>> = Object.create(null);
|
|
459
|
+
candidates.__proto__ = [{ plan_key: "p1", prs_opened: 5 }];
|
|
460
|
+
const resolved = evilModel.resolveLookups({ plan_key: "p1" }, candidates);
|
|
461
|
+
assert.equal(Object.getPrototypeOf(resolved), null);
|
|
462
|
+
assert.ok(Object.hasOwn(resolved, "__proto__"));
|
|
463
|
+
assert.equal(Object.getPrototypeOf({}), Object.prototype);
|
|
464
|
+
const evaluated = evilModel.evaluate({ plan_key: "p1" }, {}, candidates);
|
|
465
|
+
assert.equal(evaluated.opened, 5);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
test("resolveLookups reads candidates by OWN property — a `__proto__` alias over a plain `{}` yields no match, not a throw", () => {
|
|
469
|
+
// The candidate input is a caller-supplied object; a normal `{}` inherits `Object.prototype`, so a
|
|
470
|
+
// `__proto__` alias would resolve `lookupRows["__proto__"]` to `Object.prototype` (truthy, non-array)
|
|
471
|
+
// and the internal `.filter(...)` would throw. Reading via `Object.hasOwn` treats it as absent instead.
|
|
472
|
+
const evilModel = defineReadModel({
|
|
473
|
+
name: "plan_delivery_proto_input",
|
|
474
|
+
baseTable: "plans",
|
|
475
|
+
lookups: [
|
|
476
|
+
{ as: "__proto__", rollup: planDeliveryCounts, on: [{ base: "plan_key", rollup: "plan_key" }] },
|
|
477
|
+
],
|
|
478
|
+
derive: { opened: rcol("__proto__", "prs_opened") },
|
|
479
|
+
});
|
|
480
|
+
// A plain object literal with no own `__proto__` property: the lookup must fall back to "no candidates".
|
|
481
|
+
const plainInput: Record<string, ReadonlyArray<Record<string, unknown>>> = {};
|
|
482
|
+
assert.doesNotThrow(() => evilModel.resolveLookups({ plan_key: "p1" }, plainInput));
|
|
483
|
+
const resolved = evilModel.resolveLookups({ plan_key: "p1" }, plainInput);
|
|
484
|
+
assert.equal(resolved.__proto__.prs_opened, null);
|
|
485
|
+
const evaluated = evilModel.evaluate({ plan_key: "p1" }, {}, plainInput);
|
|
486
|
+
assert.equal(evaluated.opened, null);
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
test("resolveLookups reads candidates case-insensitively — a differently-cased alias key still matches", async () => {
|
|
490
|
+
// Lookup aliases are case-insensitive SQL identifiers: `resolveLookups` stores its resolved row under
|
|
491
|
+
// `foldSqlIdentifier(alias)` and `rcol` reads it folded. A caller that supplies candidates under a
|
|
492
|
+
// different casing (e.g. `{ C: [...] }` for an alias declared `c`) must therefore still be matched —
|
|
493
|
+
// a raw case-sensitive `lookupRows["c"]` would treat them as "no candidates" and NULL/default-fill,
|
|
494
|
+
// drifting from the folded treatment (and from the SQL LEFT JOIN, which never sees this dictionary).
|
|
495
|
+
const model = defineReadModel({
|
|
496
|
+
name: "plan_delivery_cased_input",
|
|
497
|
+
baseTable: "plans",
|
|
498
|
+
lookups: [
|
|
499
|
+
{
|
|
500
|
+
as: "c",
|
|
501
|
+
rollup: planDeliveryCounts,
|
|
502
|
+
on: [{ base: "plan_key", rollup: "plan_key" }],
|
|
503
|
+
defaults: { prs_opened: 0 },
|
|
504
|
+
},
|
|
505
|
+
],
|
|
506
|
+
derive: { opened: rcol("c", "prs_opened") },
|
|
507
|
+
});
|
|
508
|
+
// Candidate keyed under the UPPER-CASE alias for a lookup declared `as: "c"`.
|
|
509
|
+
const resolved = model.resolveLookups({ plan_key: "p1" }, { C: [{ plan_key: "p1", prs_opened: 9 }] });
|
|
510
|
+
assert.equal(resolved.c.prs_opened, 9);
|
|
511
|
+
const evaluated = model.evaluate({ plan_key: "p1" }, {}, { C: [{ plan_key: "p1", prs_opened: 9 }] });
|
|
512
|
+
assert.equal(evaluated.opened, 9);
|
|
513
|
+
// Parity: the SQL VIEW reads the real rollup relation, so both backends must land the same value.
|
|
514
|
+
await withDb((db) => {
|
|
515
|
+
assert.doesNotThrow(() =>
|
|
516
|
+
assertReadModelParity(model, db, [
|
|
517
|
+
{ baseRow: { plan_key: "p1" }, lookups: { c: [{ plan_key: "p1", prs_opened: 9 }] } },
|
|
518
|
+
{ baseRow: { plan_key: "p2" }, lookups: { c: [] } },
|
|
519
|
+
]),
|
|
520
|
+
);
|
|
521
|
+
});
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
test("assertReadModelParity widens/inserts lookup fixtures case-insensitively — differently-cased sample key still parity-matches", async () => {
|
|
525
|
+
// The parity guard's fixture WIDENING and INSERTION must resolve each sample's lookup key the same way
|
|
526
|
+
// `resolveLookups` does: case-insensitively (SQLite identifier folding). A sample keyed `{ C: [...] }`
|
|
527
|
+
// for a lookup declared `as: "c"` must reach the SQL fixture — else the guard inserts NOTHING (VIEW
|
|
528
|
+
// LEFT JOIN NULL/default-fills to prs_opened=0) while the TS resolver folds and reads 7, producing a
|
|
529
|
+
// FALSE parity mismatch. This exercises the guard itself (round-3 only covered the TS resolver, and
|
|
530
|
+
// passed the parity sample under the matching lowercase key, so this surface stayed untested).
|
|
531
|
+
const model = defineReadModel({
|
|
532
|
+
name: "plan_delivery_cased_fixture",
|
|
533
|
+
baseTable: "plans",
|
|
534
|
+
lookups: [
|
|
535
|
+
{
|
|
536
|
+
as: "c",
|
|
537
|
+
rollup: planDeliveryCounts,
|
|
538
|
+
on: [{ base: "plan_key", rollup: "plan_key" }],
|
|
539
|
+
defaults: { prs_opened: 0 },
|
|
540
|
+
},
|
|
541
|
+
],
|
|
542
|
+
derive: { opened: rcol("c", "prs_opened") },
|
|
543
|
+
});
|
|
544
|
+
await withDb((db) => {
|
|
545
|
+
assert.doesNotThrow(() =>
|
|
546
|
+
// Sample lookup keyed under the UPPER-CASE alias for a lookup declared `as: "c"`.
|
|
547
|
+
assertReadModelParity(model, db, [
|
|
548
|
+
{ baseRow: { plan_key: "p1" }, lookups: { C: [{ plan_key: "p1", prs_opened: 7 }] } },
|
|
549
|
+
{ baseRow: { plan_key: "p2" }, lookups: { C: [] } },
|
|
550
|
+
]),
|
|
551
|
+
);
|
|
552
|
+
});
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
test("viewDdl rejects a caller-supplied baseAlias that folds to a rollup lookup alias — collision would reuse one identifier for the base table and a joined rollup", () => {
|
|
556
|
+
// Lookup aliases are validated against the DEFAULT base alias at definition time, but `viewDdl` lets a
|
|
557
|
+
// caller override `baseAlias`. An override that folds (SQLite compares identifiers case-insensitively)
|
|
558
|
+
// to a lookup alias would emit `FROM plans "c" ... LEFT JOIN plan_delivery_counts "c" ON ...` — the same
|
|
559
|
+
// identifier bound to both relations, producing ambiguous/invalid SQL. The override must be rejected the
|
|
560
|
+
// same way a definition-time collision is, so the two backends can never diverge on it.
|
|
561
|
+
const model = defineReadModel({
|
|
562
|
+
name: "plan_delivery_basealias_guard",
|
|
563
|
+
baseTable: "plans",
|
|
564
|
+
lookups: [
|
|
565
|
+
{
|
|
566
|
+
as: "c",
|
|
567
|
+
rollup: planDeliveryCounts,
|
|
568
|
+
on: [{ base: "plan_key", rollup: "plan_key" }],
|
|
569
|
+
defaults: { prs_opened: 0 },
|
|
570
|
+
},
|
|
571
|
+
],
|
|
572
|
+
derive: { opened: rcol("c", "prs_opened") },
|
|
573
|
+
});
|
|
574
|
+
// The default alias ("base") does not collide, so the managed DDL still renders.
|
|
575
|
+
assert.doesNotThrow(() => model.viewDdl());
|
|
576
|
+
// An exact-case override colliding with the lookup alias must throw.
|
|
577
|
+
assert.throws(() => model.viewDdl({ baseAlias: "c" }), /collides with the base alias/);
|
|
578
|
+
// A DIFFERENTLY-CASED override must also throw (SQLite folds identifiers) — never silently ambiguous SQL.
|
|
579
|
+
assert.throws(() => model.viewDdl({ baseAlias: "C" }), /collides with the base alias/);
|
|
580
|
+
// A non-colliding override renders fine and binds the base table to the requested alias.
|
|
581
|
+
const ddl = model.viewDdl({ baseAlias: "b" });
|
|
582
|
+
assert.match(ddl, /FROM "plans" "b"/);
|
|
583
|
+
assert.match(ddl, /LEFT JOIN "plan_delivery_counts" "c"/);
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
test("collectColumns buckets rcol columns case-insensitively — a differently-cased alias's non-default column reaches the SQL fixture", async () => {
|
|
587
|
+
// The SQL TEMP fixture builder pre-seeds each lookup bucket under its DECLARED alias (`as`) — its join
|
|
588
|
+
// keys + declared-default columns — and then `collectColumns` records the derivation's `rcol`
|
|
589
|
+
// references into that same map. If a derivation reads `rcol("C", "prs_in_flight")` against a lookup
|
|
590
|
+
// declared `as: "c"` — where `prs_in_flight` is NEITHER a join key NOR a declared default NOR present in
|
|
591
|
+
// the sample candidate rows — recording it under the verbatim key "C" (instead of folding to "c") lands
|
|
592
|
+
// it in a bucket the fixture builder never reads, so the column is dropped from the TEMP fixture table
|
|
593
|
+
// and the compiled VIEW's `c.prs_in_flight` read hits "no such column". Folding the bucket key puts it in
|
|
594
|
+
// the pre-seeded "c" bucket so the fixture carries the referenced column and both backends read NULL.
|
|
595
|
+
const model = defineReadModel({
|
|
596
|
+
name: "plan_delivery_folded_bucket",
|
|
597
|
+
baseTable: "plans",
|
|
598
|
+
lookups: [
|
|
599
|
+
{
|
|
600
|
+
as: "c",
|
|
601
|
+
rollup: planDeliveryCounts,
|
|
602
|
+
on: [{ base: "plan_key", rollup: "plan_key" }],
|
|
603
|
+
defaults: { prs_opened: 0 },
|
|
604
|
+
},
|
|
605
|
+
],
|
|
606
|
+
derive: { flight: rcol("C", "prs_in_flight") },
|
|
607
|
+
});
|
|
608
|
+
await withDb((db) => {
|
|
609
|
+
assert.doesNotThrow(() =>
|
|
610
|
+
// Candidate rows deliberately OMIT `prs_in_flight`, so only `collectColumns` can add it to the
|
|
611
|
+
// fixture — the sample-widening step cannot mask a divergent bucket key here.
|
|
612
|
+
assertReadModelParity(model, db, [
|
|
613
|
+
{ baseRow: { plan_key: "p1" }, lookups: { c: [{ plan_key: "p1", prs_opened: 2 }] } },
|
|
614
|
+
{ baseRow: { plan_key: "p2" }, lookups: { c: [] } },
|
|
615
|
+
]),
|
|
616
|
+
);
|
|
617
|
+
});
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
test("assertRollupParity builds TEMP fixtures under the PHYSICAL table a projection name maps to", async () => { // A `fromProjection` source names a LOGICAL projection; the compiled VIEW resolves it to a physical
|
|
621
|
+
// relation via `resolveProjectionTable` (e.g. `urban_instance_state` → `_urban_instance_state`). The
|
|
622
|
+
// parity guard must create/insert its TEMP fixtures under that PHYSICAL name — otherwise the VIEW's
|
|
623
|
+
// FROM points at a table the (logical-named) fixture never created and SQLite throws "no such table".
|
|
624
|
+
// Sample rows stay keyed by the logical projection name (what `RollupInputs`/`reduce` read).
|
|
625
|
+
const instanceRollup = defineRollup({
|
|
626
|
+
name: "instance_state_counts",
|
|
627
|
+
source: fromProjection("urban_instance_state"),
|
|
628
|
+
groupBy: ["status"],
|
|
629
|
+
aggregates: { total: count() },
|
|
630
|
+
});
|
|
631
|
+
const remap = (n: string): string => (n === "urban_instance_state" ? "_urban_instance_state" : n);
|
|
632
|
+
await withDb((db) => {
|
|
633
|
+
assert.doesNotThrow(() =>
|
|
634
|
+
assertRollupParity(
|
|
635
|
+
instanceRollup,
|
|
636
|
+
db,
|
|
637
|
+
[
|
|
638
|
+
{
|
|
639
|
+
urban_instance_state: [
|
|
640
|
+
{ status: "active", instance_key: "i1" },
|
|
641
|
+
{ status: "active", instance_key: "i2" },
|
|
642
|
+
{ status: "done", instance_key: "i3" },
|
|
643
|
+
],
|
|
644
|
+
},
|
|
645
|
+
{ urban_instance_state: [] },
|
|
646
|
+
],
|
|
647
|
+
{ sql: { resolveProjectionTable: remap } },
|
|
648
|
+
),
|
|
649
|
+
);
|
|
650
|
+
});
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
test("assertRollupParity — count(col) excludes undefined column values, matching SQL COUNT(col) (TS/SQL nullish parity)", async () => {
|
|
654
|
+
// Copilot advisory read-model.ts:1861 worried the TS `count(column)` case — which filters
|
|
655
|
+
// `lookupColumn(r, col) !== null` — would COUNT an in-memory `undefined` (common in JS fixtures) even
|
|
656
|
+
// though SQL has no `undefined` and `COUNT(col)` excludes NULL, drifting the two backends apart. It does
|
|
657
|
+
// not: `lookupColumn` is the single source of truth for column reads and already coalesces
|
|
658
|
+
// `undefined`/missing → null (read-model.ts:644/647), so the filter excludes them exactly as SQL does.
|
|
659
|
+
// This pins that contract via the parity harness: a row carrying `undefined` (or a missing key) must NOT
|
|
660
|
+
// be counted, and the TS reduce must agree with SQL `COUNT(val)`. It fails the moment a future change to
|
|
661
|
+
// `lookupColumn`/the count case lets `undefined` slip through and reintroduces the cross-backend drift.
|
|
662
|
+
const countColNullish = defineRollup({
|
|
663
|
+
name: "count_col_nullish",
|
|
664
|
+
source: fromProjection("count_col_src"),
|
|
665
|
+
groupBy: ["grp"],
|
|
666
|
+
aggregates: { n: count("val") },
|
|
667
|
+
});
|
|
668
|
+
await withDb((db) => {
|
|
669
|
+
assert.doesNotThrow(() =>
|
|
670
|
+
assertRollupParity(countColNullish, db, [
|
|
671
|
+
{
|
|
672
|
+
count_col_src: [
|
|
673
|
+
{ grp: "g", val: 1 },
|
|
674
|
+
{ grp: "g", val: undefined }, // JS-only: SQL stores NULL; TS must coalesce → excluded
|
|
675
|
+
{ grp: "g", val: null }, // explicit NULL → excluded by both backends
|
|
676
|
+
{ grp: "g" }, // missing key entirely → NULL/excluded
|
|
677
|
+
{ grp: "g", val: 2 },
|
|
678
|
+
],
|
|
679
|
+
},
|
|
680
|
+
]),
|
|
681
|
+
);
|
|
682
|
+
});
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
test("a read model can carry TWO distinct rollup lookups (plan_read_model shape)", async () => {
|
|
686
|
+
const planSummary = defineReadModel({
|
|
687
|
+
name: "plan_summary_rm",
|
|
688
|
+
baseTable: "plans",
|
|
689
|
+
selectBaseColumns: false,
|
|
690
|
+
lookups: [
|
|
691
|
+
{ as: "w", rollup: planWaveProgress, on: [{ base: "plan_key", rollup: "plan_key" }] },
|
|
692
|
+
{
|
|
693
|
+
as: "d",
|
|
694
|
+
rollup: planDeliveryCounts,
|
|
695
|
+
on: [{ base: "plan_key", rollup: "plan_key" }],
|
|
696
|
+
defaults: { prs_opened: 0 },
|
|
697
|
+
},
|
|
698
|
+
],
|
|
699
|
+
derive: {
|
|
700
|
+
wave_count: rcol("w", "wave_count"),
|
|
701
|
+
current_wave: rcol("w", "current_wave"),
|
|
702
|
+
has_prs: gt(rcol("d", "prs_opened"), lit(0)),
|
|
703
|
+
},
|
|
704
|
+
});
|
|
705
|
+
await withDb((db) => {
|
|
706
|
+
assert.doesNotThrow(() =>
|
|
707
|
+
assertReadModelParity(planSummary, db, [
|
|
708
|
+
{
|
|
709
|
+
baseRow: { plan_key: "p1" },
|
|
710
|
+
lookups: {
|
|
711
|
+
w: [{ plan_key: "p1", wave_count: 3, current_wave: 1 }],
|
|
712
|
+
d: [{ plan_key: "p1", prs_opened: 4 }],
|
|
713
|
+
},
|
|
714
|
+
},
|
|
715
|
+
{ baseRow: { plan_key: "p2" }, lookups: { w: [], d: [] } },
|
|
716
|
+
]),
|
|
717
|
+
);
|
|
718
|
+
});
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
// ─── Registry + validation.
|
|
722
|
+
|
|
723
|
+
test("RollupRegistry.ensureViews applies composed VIEWs in dependency order and registers the projection", async () => {
|
|
724
|
+
const registry = new RollupRegistry();
|
|
725
|
+
const projections = new ProjectionRegistry();
|
|
726
|
+
registry.register(planWaveCounts, projections);
|
|
727
|
+
registry.register(planWaveProgress, projections);
|
|
728
|
+
assert.ok(projections.has("plan_wave_counts"));
|
|
729
|
+
assert.ok(projections.has("plan_wave_progress"));
|
|
730
|
+
await withDb((db) => {
|
|
731
|
+
// A composed rollup's VIEW references its dependency's VIEW, so applying out of order would fail;
|
|
732
|
+
// ensureViews must topologically order the chain (dependency first).
|
|
733
|
+
assert.doesNotThrow(() => registry.ensureViews(db));
|
|
734
|
+
db.exec(`CREATE TABLE plan_tasks (plan_key TEXT, wave INTEGER, status TEXT, pr_key TEXT);`);
|
|
735
|
+
db.exec(`CREATE TABLE pull_requests (pr_key TEXT, status TEXT);`);
|
|
736
|
+
db.run(`INSERT INTO plan_tasks VALUES (?,?,?,?)`, ["p1", 0, "merged", "a"]);
|
|
737
|
+
db.run(`INSERT INTO pull_requests VALUES (?,?)`, ["a", "merged"]);
|
|
738
|
+
const rows = db.all<Record<string, unknown>>(`SELECT wave_count, current_wave FROM plan_wave_progress;`);
|
|
739
|
+
assert.equal(Number(rows[0].wave_count), 1);
|
|
740
|
+
assert.equal(Number(rows[0].current_wave), 0);
|
|
741
|
+
});
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
test("a rollup lookup must join on the rollup's full group key (single-valued)", () => {
|
|
745
|
+
assert.throws(
|
|
746
|
+
() =>
|
|
747
|
+
defineReadModel({
|
|
748
|
+
name: "bad_lookup_rm",
|
|
749
|
+
baseTable: "plans",
|
|
750
|
+
lookups: [{ as: "c", rollup: planWaveCounts, on: [{ base: "plan_key", rollup: "plan_key" }] }],
|
|
751
|
+
derive: { x: rcol("c", "total") },
|
|
752
|
+
}),
|
|
753
|
+
/must join on the rollup's full group key \[plan_key, wave\]/,
|
|
754
|
+
);
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
test("add() rejects a non-integer literal at build time", () => {
|
|
758
|
+
assert.throws(() => add(count(), 1.5), /must be an integer/);
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
test("a rollup aggregate predicate rejects exists(...) — the rollup surface stays closed", () => {
|
|
762
|
+
assert.throws(
|
|
763
|
+
() =>
|
|
764
|
+
defineRollup({
|
|
765
|
+
name: "bad_pred_rollup",
|
|
766
|
+
source: { kind: "table", table: "t" },
|
|
767
|
+
groupBy: ["k"],
|
|
768
|
+
aggregates: { n: countWhere(exists("proj", eq(pcol("k"), col("k")))) },
|
|
769
|
+
}),
|
|
770
|
+
/is not valid in a rollup predicate/,
|
|
771
|
+
);
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
test("rcol referencing an undeclared lookup alias is rejected at declaration", () => {
|
|
775
|
+
assert.throws(
|
|
776
|
+
() =>
|
|
777
|
+
defineReadModel({
|
|
778
|
+
name: "unknown_alias_rm",
|
|
779
|
+
baseTable: "plans",
|
|
780
|
+
lookups: [{ as: "c", rollup: planDeliveryCounts, on: [{ base: "plan_key", rollup: "plan_key" }] }],
|
|
781
|
+
derive: { x: rcol("nope", "prs_opened") },
|
|
782
|
+
}),
|
|
783
|
+
/rollup-lookup alias "nope"/,
|
|
784
|
+
);
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
test("rcol referencing a non-existent rollup column is rejected at declaration", () => {
|
|
788
|
+
assert.throws(
|
|
789
|
+
() =>
|
|
790
|
+
defineReadModel({
|
|
791
|
+
name: "unknown_col_rm",
|
|
792
|
+
baseTable: "plans",
|
|
793
|
+
lookups: [{ as: "c", rollup: planDeliveryCounts, on: [{ base: "plan_key", rollup: "plan_key" }] }],
|
|
794
|
+
derive: { x: rcol("c", "not_a_column") },
|
|
795
|
+
}),
|
|
796
|
+
/"not_a_column" is not a column of rollup/,
|
|
797
|
+
);
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
test("the rollup VIEW DDL is a closed GROUP BY select (no raw SQL, deterministic)", () => {
|
|
801
|
+
const ddl = planDeliveryCounts.viewDdl();
|
|
802
|
+
assert.match(ddl, /^CREATE VIEW IF NOT EXISTS "plan_delivery_counts" AS/);
|
|
803
|
+
assert.match(ddl, /GROUP BY "t"\."plan_key"/);
|
|
804
|
+
assert.match(ddl, /LEFT JOIN "pull_requests" "p" ON "t"\."pr_key" = "p"\."pr_key"/);
|
|
805
|
+
assert.match(ddl, /COUNT\("t"\."pr_key"\) AS "prs_opened"/);
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
// A projection referenced by exists() still resolves through the process-wide registry after a rollup
|
|
809
|
+
// registers its name there — sanity that the two seams coexist.
|
|
810
|
+
test("registering a rollup exposes its name via the process-wide projectionRegistry", () => {
|
|
811
|
+
const before = projectionRegistry.has("plan_delivery_counts");
|
|
812
|
+
try {
|
|
813
|
+
new RollupRegistry().register(planDeliveryCounts);
|
|
814
|
+
assert.ok(projectionRegistry.has("plan_delivery_counts"));
|
|
815
|
+
} finally {
|
|
816
|
+
if (!before) projectionRegistry.clear();
|
|
817
|
+
}
|
|
818
|
+
});
|