@nanobpm/urban 0.80.0 → 0.81.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/runtime/core/manifest.d.ts +44 -11
- package/dist/runtime/core/modules/cancel.d.ts +13 -8
- package/dist/runtime/core/modules/cancel.js +40 -21
- package/dist/runtime/core/modules/datasource.js +7 -4
- package/dist/runtime/core/modules/gateway.js +5 -4
- package/dist/runtime/core/modules/instance-status-read-model.d.ts +56 -0
- package/dist/runtime/core/modules/instance-status-read-model.js +127 -0
- package/dist/runtime/core/modules/instance-tracking.d.ts +47 -19
- package/dist/runtime/core/modules/instance-tracking.js +451 -123
- package/dist/runtime/core/modules/open-user-tasks-store.d.ts +5 -0
- package/dist/runtime/core/modules/open-user-tasks-store.js +8 -0
- package/dist/runtime/core/modules/pages.js +6 -4
- package/dist/runtime/core/read-model.d.ts +28 -0
- package/dist/runtime/core/read-model.js +44 -4
- package/dist/runtime/core/validate.js +192 -0
- package/dist/runtime/index.d.ts +2 -1
- package/dist/runtime/index.js +5 -0
- package/package.json +1 -1
- package/src/runtime/core/manifest.ts +44 -11
- package/src/runtime/core/modules/cancel.test.ts +112 -38
- package/src/runtime/core/modules/cancel.ts +42 -22
- package/src/runtime/core/modules/datasource.ts +7 -4
- package/src/runtime/core/modules/gateway.ts +7 -4
- package/src/runtime/core/modules/instance-status-read-model.ts +156 -0
- package/src/runtime/core/modules/instance-tracking.test.ts +589 -60
- package/src/runtime/core/modules/instance-tracking.ts +469 -126
- package/src/runtime/core/modules/open-user-tasks-store.test.ts +11 -0
- package/src/runtime/core/modules/open-user-tasks-store.ts +11 -0
- package/src/runtime/core/modules/pages.ts +6 -5
- package/src/runtime/core/read-model.test.ts +69 -0
- package/src/runtime/core/read-model.ts +43 -4
- package/src/runtime/core/validate.test.ts +341 -0
- package/src/runtime/core/validate.ts +188 -0
- package/src/runtime/index.ts +15 -0
|
@@ -154,3 +154,14 @@ test("syncInstance is atomic — it composes inside an existing transaction and
|
|
|
154
154
|
test("the DSL projection name is stable and unprefixed (usable in `exists(...)`)", () => {
|
|
155
155
|
assert.equal(OPEN_USER_TASKS_PROJECTION, "urban_open_user_tasks");
|
|
156
156
|
});
|
|
157
|
+
|
|
158
|
+
test("instanceKeys returns each distinct instance with ≥1 open task (and drops one when cleared)", async () => {
|
|
159
|
+
await withStore((store) => {
|
|
160
|
+
assert.deepEqual(store.instanceKeys(), []); // empty projection
|
|
161
|
+
store.syncInstance("pi-1", [task("ut-1"), task("ut-2")]); // two tasks, one instance
|
|
162
|
+
store.syncInstance("pi-2", [task("ut-3")]);
|
|
163
|
+
assert.deepEqual(store.instanceKeys().sort(), ["pi-1", "pi-2"]); // distinct, not per-task
|
|
164
|
+
store.clearInstance("pi-1"); // answered/terminated
|
|
165
|
+
assert.deepEqual(store.instanceKeys(), ["pi-2"]); // retired key no longer listed
|
|
166
|
+
});
|
|
167
|
+
});
|
|
@@ -144,6 +144,17 @@ export class OpenUserTasksStore {
|
|
|
144
144
|
this.#db.run(`DELETE FROM ${OPEN_USER_TASKS_TABLE} WHERE process_instance_key = ?`, [processInstanceKey]);
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
+
/** Every distinct instance key currently projected as having ≥1 open user task. Used by the
|
|
148
|
+
* reconciler to re-probe (and, when answered, RETIRE) keys that have dropped out of a binding's
|
|
149
|
+
* `activeStatuses` allow-list — a projected row must be retired independently of the base-row
|
|
150
|
+
* selector, or an answered escalation's row would linger and the derived edge tear (ADR 0065). */
|
|
151
|
+
instanceKeys(): string[] {
|
|
152
|
+
const rows = this.#db.all<{ process_instance_key: string }>(
|
|
153
|
+
`SELECT DISTINCT process_instance_key FROM ${OPEN_USER_TASKS_TABLE}`,
|
|
154
|
+
);
|
|
155
|
+
return rows.map((r) => r.process_instance_key);
|
|
156
|
+
}
|
|
157
|
+
|
|
147
158
|
/** True iff the instance currently has any open user task — the "parked on a human" predicate. */
|
|
148
159
|
hasOpenTask(processInstanceKey: string): boolean {
|
|
149
160
|
if (!processInstanceKey) return false;
|
|
@@ -35,6 +35,7 @@ import { FORMJS_JS } from "./formjs.gen.ts";
|
|
|
35
35
|
import { completeUserTaskResponse, resolveFormResponse } from "./forms.ts";
|
|
36
36
|
import { quoteIdent } from "./gateway.ts";
|
|
37
37
|
import { RENDERER_JS } from "./runtime.gen.ts";
|
|
38
|
+
import { isSqlIdentifier } from "../read-model.ts";
|
|
38
39
|
|
|
39
40
|
/** The subset of the datasource gateway the page runtime needs. */
|
|
40
41
|
export interface PagesDataSource {
|
|
@@ -183,10 +184,10 @@ async function serveAsset(
|
|
|
183
184
|
}
|
|
184
185
|
}
|
|
185
186
|
|
|
186
|
-
/** A
|
|
187
|
-
* (checked against `schema()`), so `/app/data/:table` can never inject
|
|
188
|
-
|
|
189
|
-
|
|
187
|
+
/** A table name must be a bare SQL identifier ({@link isSqlIdentifier}, the runtime-wide single source
|
|
188
|
+
* of truth) *and* a known table (checked against `schema()`), so `/app/data/:table` can never inject
|
|
189
|
+
* SQL. Reusing the shared guard (rather than a private regex) keeps this surface from drifting from
|
|
190
|
+
* manifest / read-model / data-source identifier validation. */
|
|
190
191
|
// A query flag (e.g. ?count=1) is "on" when present and not one of the falsy
|
|
191
192
|
// spellings. A bare `?count` (empty string) still counts as on, so a caller can
|
|
192
193
|
// opt in without inventing a value.
|
|
@@ -369,7 +370,7 @@ export function createPagesRoutes(opts: PagesOptions, deps: PagesDeps): Route[]
|
|
|
369
370
|
// v1 exposes only the injected default datasource; a request naming any other
|
|
370
371
|
// source is rejected rather than silently served off the default.
|
|
371
372
|
if (source !== sourceName) return json({ error: `unknown datasource "${source}"` }, 404);
|
|
372
|
-
if (!
|
|
373
|
+
if (!isSqlIdentifier(table)) return json({ error: "invalid table name" }, 400);
|
|
373
374
|
let tables: Set<string>;
|
|
374
375
|
try {
|
|
375
376
|
tables = await knownTables();
|
|
@@ -25,6 +25,8 @@ import {
|
|
|
25
25
|
ProjectionRegistry,
|
|
26
26
|
projectionRegistry,
|
|
27
27
|
ReadModelRegistry,
|
|
28
|
+
isReservedObjectName,
|
|
29
|
+
RESERVED_OBJECT_PREFIXES,
|
|
28
30
|
when,
|
|
29
31
|
} from "./read-model.ts";
|
|
30
32
|
import type { ReadModel } from "./read-model.ts";
|
|
@@ -82,6 +84,19 @@ const EXPECTED: Record<string, { display_status: string; is_high_priority: numbe
|
|
|
82
84
|
t3: { display_status: "active", is_high_priority: 1 },
|
|
83
85
|
};
|
|
84
86
|
|
|
87
|
+
test("isReservedObjectName flags every reserved prefix, case-insensitively, and clears domain names", () => {
|
|
88
|
+
// Single source of truth for the prefixes the datasource surface hides (gateway.schema()) and the
|
|
89
|
+
// provenance recorder skips (datasource.ts). SQLite folds ASCII object names, so a cased reserved
|
|
90
|
+
// name must not slip through.
|
|
91
|
+
assert.deepEqual([...RESERVED_OBJECT_PREFIXES], ["_urban_", "_nano_", "sqlite_"]);
|
|
92
|
+
for (const name of ["_urban_x", "_URBAN_x", "_nano_y", "sqlite_master", "SQLITE_stat1"]) {
|
|
93
|
+
assert.ok(isReservedObjectName(name), `expected "${name}" reserved`);
|
|
94
|
+
}
|
|
95
|
+
for (const name of ["orders", "plans__tracking", "urban_things", "nano", "sqlitely"]) {
|
|
96
|
+
assert.ok(!isReservedObjectName(name), `expected "${name}" NOT reserved`);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
85
100
|
test("(i) the read model with CASE + EXISTS compiles to a SQLite VIEW returning the expected values", async () => {
|
|
86
101
|
await withDb((db) => {
|
|
87
102
|
seedFixtures(db);
|
|
@@ -219,6 +234,60 @@ test("both backends agree on NULL inputs: SQL NULL propagation is COALESCE'd to
|
|
|
219
234
|
});
|
|
220
235
|
});
|
|
221
236
|
|
|
237
|
+
test("compileToFn resolves col/pcol identifiers case-insensitively (SQLite identifier folding)", () => {
|
|
238
|
+
// SQLite folds identifier case even for quoted identifiers, so `col("Status")` in the VIEW binds to a
|
|
239
|
+
// column DECLARED `status`. The TS backend must fold too — a case-sensitive `row["Status"]` returns
|
|
240
|
+
// null and silently diverges from the VIEW on the no-edge fallback and on keyField correlation.
|
|
241
|
+
assert.equal(compileToFn(col("Status"))({ status: "abandoned" }), "abandoned"); // folds to declared column
|
|
242
|
+
assert.equal(compileToFn(col("status"))({ status: "abandoned" }), "abandoned"); // exact match still works
|
|
243
|
+
assert.equal(compileToFn(col("Missing"))({ status: "x" }), null); // a truly-absent column → null (not a fold)
|
|
244
|
+
// pcol inside EXISTS folds too, so keyField correlation matches the VIEW's join.
|
|
245
|
+
const corr = compileToFn(exists("p", eq(pcol("Process_Instance_Key"), col("Key"))));
|
|
246
|
+
assert.equal(corr({ key: "pi-1" }, { p: [{ process_instance_key: "pi-1" }] }), true);
|
|
247
|
+
assert.equal(corr({ key: "pi-1" }, { p: [{ process_instance_key: "pi-2" }] }), false);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("mixed-case col/pcol identifiers: the VIEW and TS backends agree (VIEW/TS parity on the no-edge fallback)", async () => {
|
|
251
|
+
// End-to-end guard for the case-fold fix: author a model with mixed-case identifiers over lowercase
|
|
252
|
+
// columns and confirm the SQLite VIEW and the TS backend derive the SAME value — including the
|
|
253
|
+
// parity-critical no-edge fallback that passes the stored status through.
|
|
254
|
+
const mixed: ReadModel = defineReadModel({
|
|
255
|
+
name: "mixed_case_display",
|
|
256
|
+
baseTable: "widgets",
|
|
257
|
+
derive: {
|
|
258
|
+
effective: caseWhen(
|
|
259
|
+
[
|
|
260
|
+
when(
|
|
261
|
+
exists("urban_open_user_tasks", eq(pcol("Process_Instance_Key"), col("Process_Key"))),
|
|
262
|
+
lit("awaiting_operator"),
|
|
263
|
+
),
|
|
264
|
+
],
|
|
265
|
+
col("Status"), // no-edge fallback → the stored status, read via a mixed-case identifier
|
|
266
|
+
),
|
|
267
|
+
},
|
|
268
|
+
});
|
|
269
|
+
await withDb((db) => {
|
|
270
|
+
db.exec(`CREATE TABLE widgets (id TEXT, process_key TEXT, status TEXT);`);
|
|
271
|
+
db.exec(`CREATE TABLE urban_open_user_tasks (process_instance_key TEXT);`);
|
|
272
|
+
db.exec(mixed.viewDdl());
|
|
273
|
+
db.run(`INSERT INTO widgets VALUES (?, ?, ?)`, ["w1", "pi-1", "dispatched"]); // no open task → fallback
|
|
274
|
+
db.run(`INSERT INTO widgets VALUES (?, ?, ?)`, ["w2", "pi-2", "dispatched"]);
|
|
275
|
+
db.run(`INSERT INTO urban_open_user_tasks VALUES (?)`, ["pi-2"]); // w2 waiting on a human
|
|
276
|
+
// SQL backend: the VIEW resolves the mixed-case identifiers against the lowercase columns.
|
|
277
|
+
const sqlRows = db
|
|
278
|
+
.all<{ id: string; effective: string }>(`SELECT id, effective FROM mixed_case_display ORDER BY id`)
|
|
279
|
+
.map((r) => ({ ...r }));
|
|
280
|
+
assert.deepEqual(sqlRows, [
|
|
281
|
+
{ id: "w1", effective: "dispatched" }, // no-edge fallback → stored status (the parity-critical path)
|
|
282
|
+
{ id: "w2", effective: "awaiting_operator" },
|
|
283
|
+
]);
|
|
284
|
+
// TS backend: base/projection rows arrive keyed as SQLite stored them (lowercase).
|
|
285
|
+
const projections = { urban_open_user_tasks: [{ process_instance_key: "pi-2" }] };
|
|
286
|
+
assert.equal(mixed.evaluate({ id: "w1", process_key: "pi-1", status: "dispatched" }, projections).effective, "dispatched");
|
|
287
|
+
assert.equal(mixed.evaluate({ id: "w2", process_key: "pi-2", status: "dispatched" }, projections).effective, "awaiting_operator");
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
|
|
222
291
|
test("EXISTS references a projection by name; the projection registry resolves the physical table", () => {
|
|
223
292
|
const reg = new ProjectionRegistry();
|
|
224
293
|
reg.register({ name: "urban_instance_state", sqlTable: "_urban_instance_state" });
|
|
@@ -261,14 +261,37 @@ function resolveBaseAlias(alias: string | undefined): string {
|
|
|
261
261
|
return assertSqlIdentifier("base alias", alias ?? DEFAULT_BASE_ALIAS);
|
|
262
262
|
}
|
|
263
263
|
|
|
264
|
-
/** A conservative SQL identifier guard — we interpolate names directly into DDL/SQL.
|
|
265
|
-
|
|
264
|
+
/** A conservative SQL identifier guard — we interpolate names directly into DDL/SQL. This is the
|
|
265
|
+
* single source of truth for "is this a legal identifier"; the manifest validator imports it (rather
|
|
266
|
+
* than re-declaring the regex) so a name accepted at author time is accepted at VIEW provisioning too
|
|
267
|
+
* (No Drift Surfaces — a divergence would let a manifest validate but fail at boot). */
|
|
268
|
+
export const SQL_IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
269
|
+
/** True iff `name` is a legal SQL identifier per {@link SQL_IDENT}. The boolean twin of
|
|
270
|
+
* {@link assertSqlIdentifier}, for callers (e.g. the manifest validator) that collect issues rather
|
|
271
|
+
* than throw. */
|
|
272
|
+
export function isSqlIdentifier(name: string): boolean {
|
|
273
|
+
return SQL_IDENT.test(name);
|
|
274
|
+
}
|
|
266
275
|
export function assertSqlIdentifier(kind: string, name: string): string {
|
|
267
276
|
if (!SQL_IDENT.test(name)) {
|
|
268
277
|
throw new Error(`invalid ${kind} "${name}": must match ${SQL_IDENT.source}`);
|
|
269
278
|
}
|
|
270
279
|
return name;
|
|
271
280
|
}
|
|
281
|
+
/** Object-name prefixes reserved for engine/runtime internals. Tables and views whose names begin
|
|
282
|
+
* with one of these are Nano bookkeeping (`_urban_*` / `_nano_*`) or SQLite internals (`sqlite_*`):
|
|
283
|
+
* the provenance recorder skips them (`modules/datasource.ts`) and the datasource `schema()` surface
|
|
284
|
+
* filters them out (`modules/gateway.ts`), so they are invisible to `/app/data`, DB Manager, and forms.
|
|
285
|
+
* This is the single source of truth for that prefix set (No Drift Surfaces — a manifest that provisions
|
|
286
|
+
* a `_urban_*` VIEW would otherwise validate yet be unreadable by the documented pages surface). */
|
|
287
|
+
export const RESERVED_OBJECT_PREFIXES = ["_urban_", "_nano_", "sqlite_"] as const;
|
|
288
|
+
/** True iff `name` begins with a {@link RESERVED_OBJECT_PREFIXES} prefix. Case-insensitive, matching
|
|
289
|
+
* SQLite's ASCII identifier folding (a `_URBAN_x` table resolves to the same reserved object as
|
|
290
|
+
* `_urban_x`), so a reserved name cannot slip through by casing. */
|
|
291
|
+
export function isReservedObjectName(name: string): boolean {
|
|
292
|
+
const folded = name.toLowerCase();
|
|
293
|
+
return RESERVED_OBJECT_PREFIXES.some((p) => folded.startsWith(p));
|
|
294
|
+
}
|
|
272
295
|
function quoteIdent(name: string): string {
|
|
273
296
|
return `"${name.replace(/"/g, '""')}"`;
|
|
274
297
|
}
|
|
@@ -491,10 +514,10 @@ export function compileToFn(expr: Expr): DerivationFn {
|
|
|
491
514
|
case "lit":
|
|
492
515
|
return node.value;
|
|
493
516
|
case "col":
|
|
494
|
-
return baseRow
|
|
517
|
+
return lookupColumn(baseRow, node.name);
|
|
495
518
|
case "pcol":
|
|
496
519
|
if (!projRow) throw new Error(`pcol("${node.name}") evaluated outside an EXISTS projection context`);
|
|
497
|
-
return projRow
|
|
520
|
+
return lookupColumn(projRow, node.name);
|
|
498
521
|
case "compare":
|
|
499
522
|
return compareValues(
|
|
500
523
|
node.op,
|
|
@@ -524,6 +547,22 @@ export function compileToFn(expr: Expr): DerivationFn {
|
|
|
524
547
|
return (baseRow, projections = {}) => evalNode(expr, baseRow, projections);
|
|
525
548
|
}
|
|
526
549
|
|
|
550
|
+
/** Resolve a column value the way SQLite resolves an identifier: an exact-key match first, then a
|
|
551
|
+
* case-insensitive fallback. SQLite folds ASCII identifier case even for quoted identifiers, so the
|
|
552
|
+
* VIEW's `col("Status")` binds to a table declared with column `status`; a plain case-sensitive JS
|
|
553
|
+
* `row["Status"]` would miss it and return null, breaking VIEW/TS parity on the no-edge fallback and on
|
|
554
|
+
* `keyField` correlation. Fold here so both backends read the same column. (A table cannot declare two
|
|
555
|
+
* columns differing only in case, so the fallback is unambiguous.) Returns `null` for a missing column,
|
|
556
|
+
* preserving the previous `?? null` contract. */
|
|
557
|
+
export function lookupColumn(row: Record<string, unknown>, name: string): unknown {
|
|
558
|
+
if (Object.hasOwn(row, name)) return row[name] ?? null;
|
|
559
|
+
const folded = name.toLowerCase();
|
|
560
|
+
for (const key of Object.keys(row)) {
|
|
561
|
+
if (key.toLowerCase() === folded) return row[key] ?? null;
|
|
562
|
+
}
|
|
563
|
+
return null;
|
|
564
|
+
}
|
|
565
|
+
|
|
527
566
|
// ───────────────────────────────────────── defineReadModel ───────────────────────────────────────
|
|
528
567
|
|
|
529
568
|
/** The declarative read-model input passed to {@link defineReadModel}. */
|
|
@@ -398,3 +398,344 @@ test("instanceTracking with a valid positive pollMs has no pollMs issue", () =>
|
|
|
398
398
|
});
|
|
399
399
|
assert.deepEqual(issues, []);
|
|
400
400
|
});
|
|
401
|
+
|
|
402
|
+
// ── readModel (ADR 0065, the writer→source inversion) ───────────────────────────────────────────
|
|
403
|
+
// The new `instanceTracking.readModel` validation surface: identifier shape, base-table/statusField
|
|
404
|
+
// collisions (folded case-insensitively, matching SQLite), and duplicate managed-VIEW names across
|
|
405
|
+
// bindings. These lock the author-time guard to the VIEW-provisioning behavior it stands in for.
|
|
406
|
+
|
|
407
|
+
test("a valid instanceTracking.readModel override has no issues", () => {
|
|
408
|
+
const issues = collectManifestIssues({
|
|
409
|
+
...valid,
|
|
410
|
+
instanceTracking: [
|
|
411
|
+
{
|
|
412
|
+
table: "plans",
|
|
413
|
+
keyField: "process_key",
|
|
414
|
+
statusField: "status",
|
|
415
|
+
activeStatuses: ["planning"],
|
|
416
|
+
onTerminated: { set: { status: "abandoned" } },
|
|
417
|
+
readModel: { view: "plans_status", statusColumn: "effective_status" },
|
|
418
|
+
},
|
|
419
|
+
],
|
|
420
|
+
});
|
|
421
|
+
assert.deepEqual(issues, []);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
test("a non-object readModel is flagged", () => {
|
|
425
|
+
const issues = collectManifestIssues({
|
|
426
|
+
...valid,
|
|
427
|
+
instanceTracking: [
|
|
428
|
+
{
|
|
429
|
+
table: "plans",
|
|
430
|
+
keyField: "process_key",
|
|
431
|
+
statusField: "status",
|
|
432
|
+
onTerminated: { set: { status: "abandoned" } },
|
|
433
|
+
readModel: "nope",
|
|
434
|
+
},
|
|
435
|
+
],
|
|
436
|
+
});
|
|
437
|
+
assert.ok(issues.some((i) => i.path === "instanceTracking[0].readModel"));
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
test("a non-identifier readModel.view / statusColumn is flagged", () => {
|
|
441
|
+
const issues = collectManifestIssues({
|
|
442
|
+
...valid,
|
|
443
|
+
instanceTracking: [
|
|
444
|
+
{
|
|
445
|
+
table: "plans",
|
|
446
|
+
keyField: "process_key",
|
|
447
|
+
statusField: "status",
|
|
448
|
+
onTerminated: { set: { status: "abandoned" } },
|
|
449
|
+
readModel: { view: "has space", statusColumn: "1bad" },
|
|
450
|
+
},
|
|
451
|
+
],
|
|
452
|
+
});
|
|
453
|
+
assert.ok(issues.some((i) => i.path === "instanceTracking[0].readModel.view"));
|
|
454
|
+
assert.ok(issues.some((i) => i.path === "instanceTracking[0].readModel.statusColumn"));
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
test("readModel.view colliding with the base table is flagged case-insensitively", () => {
|
|
458
|
+
const issues = collectManifestIssues({
|
|
459
|
+
...valid,
|
|
460
|
+
instanceTracking: [
|
|
461
|
+
{
|
|
462
|
+
table: "plans",
|
|
463
|
+
keyField: "process_key",
|
|
464
|
+
statusField: "status",
|
|
465
|
+
onTerminated: { set: { status: "abandoned" } },
|
|
466
|
+
readModel: { view: "Plans" }, // folds to the base table name
|
|
467
|
+
},
|
|
468
|
+
],
|
|
469
|
+
});
|
|
470
|
+
assert.ok(issues.some((i) => i.path === "instanceTracking[0].readModel.view"));
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
test("a reserved-prefix readModel.view is flagged (hidden from the datasource surface)", () => {
|
|
474
|
+
// A `_urban_*` / `_nano_*` / `sqlite_*` view provisions fine but is filtered out of gateway.schema(),
|
|
475
|
+
// so a page reads `unknown table`. Reject it at author time, on the explicit override, case-insensitively.
|
|
476
|
+
for (const view of ["_urban_status", "_NANO_status", "sqlite_meta"]) {
|
|
477
|
+
const issues = collectManifestIssues({
|
|
478
|
+
...valid,
|
|
479
|
+
instanceTracking: [
|
|
480
|
+
{
|
|
481
|
+
table: "plans",
|
|
482
|
+
keyField: "process_key",
|
|
483
|
+
statusField: "status",
|
|
484
|
+
onTerminated: { set: { status: "abandoned" } },
|
|
485
|
+
readModel: { view },
|
|
486
|
+
},
|
|
487
|
+
],
|
|
488
|
+
});
|
|
489
|
+
assert.ok(
|
|
490
|
+
issues.some((i) => i.path === "instanceTracking[0].readModel.view"),
|
|
491
|
+
`expected reserved view "${view}" to be flagged`,
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
test("a reserved-prefix default VIEW (from a reserved base table) is flagged on `table`", () => {
|
|
497
|
+
// With no override the VIEW defaults to `<table>__tracking`; a `_urban_*` base table therefore yields a
|
|
498
|
+
// reserved default view name. The issue is attributed to `table` since there is no explicit view.
|
|
499
|
+
const issues = collectManifestIssues({
|
|
500
|
+
...valid,
|
|
501
|
+
instanceTracking: [
|
|
502
|
+
{
|
|
503
|
+
table: "_urban_plans",
|
|
504
|
+
keyField: "process_key",
|
|
505
|
+
statusField: "status",
|
|
506
|
+
onTerminated: { set: { status: "abandoned" } },
|
|
507
|
+
},
|
|
508
|
+
],
|
|
509
|
+
});
|
|
510
|
+
assert.ok(issues.some((i) => i.path === "instanceTracking[0].table"));
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
test("a reserved base table with a NON-reserved explicit view is flagged on `table` (issue #452 review)", () => {
|
|
514
|
+
// The reserved-VIEW guard only inspects the effective VIEW NAME. A binding
|
|
515
|
+
// `table: "_urban_instance_state", readModel: { view: "tracking" }` slips past it: the published
|
|
516
|
+
// `tracking` VIEW is non-reserved (so gateway.schema() exposes it) yet SELECTs the runtime's hidden
|
|
517
|
+
// sidecar rows. Tracking a reserved runtime table is never legitimate — reject it on `table`.
|
|
518
|
+
const issues = collectManifestIssues({
|
|
519
|
+
...valid,
|
|
520
|
+
instanceTracking: [
|
|
521
|
+
{
|
|
522
|
+
table: "_urban_instance_state",
|
|
523
|
+
keyField: "process_key",
|
|
524
|
+
statusField: "status",
|
|
525
|
+
onTerminated: { set: { status: "abandoned" } },
|
|
526
|
+
readModel: { view: "tracking" },
|
|
527
|
+
},
|
|
528
|
+
],
|
|
529
|
+
});
|
|
530
|
+
assert.ok(
|
|
531
|
+
issues.some((i) => i.path === "instanceTracking[0].table" && i.message.includes("reserved prefix")),
|
|
532
|
+
"expected a reserved base-table issue on `table`",
|
|
533
|
+
);
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
test("non-identifier table / keyField / statusField are flagged (they compile into VIEW SQL)", () => {
|
|
537
|
+
// `table`/`keyField`/`statusField` are interpolated into the derived VIEW's DDL and predicates, so a
|
|
538
|
+
// non-identifier (e.g. `external-orders`) that historically only passed the non-empty check would
|
|
539
|
+
// validate yet throw at VIEW construction. Enforce the identifier rule at author time (No Drift).
|
|
540
|
+
const issues = collectManifestIssues({
|
|
541
|
+
...valid,
|
|
542
|
+
instanceTracking: [
|
|
543
|
+
{
|
|
544
|
+
table: "external-orders",
|
|
545
|
+
keyField: "process key",
|
|
546
|
+
statusField: "the.status",
|
|
547
|
+
onTerminated: { set: { status: "abandoned" } },
|
|
548
|
+
},
|
|
549
|
+
],
|
|
550
|
+
});
|
|
551
|
+
assert.ok(issues.some((i) => i.path === "instanceTracking[0].table"));
|
|
552
|
+
assert.ok(issues.some((i) => i.path === "instanceTracking[0].keyField"));
|
|
553
|
+
assert.ok(issues.some((i) => i.path === "instanceTracking[0].statusField"));
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
test("a STATUS-LESS instanceTracking binding accepts a non-identifier table/keyField (no VIEW is built)", () => {
|
|
557
|
+
// A binding with no `statusField` provisions no derived VIEW; it only feeds projections and is polled
|
|
558
|
+
// through `api.data.table()`, whose `Table<T>` gateway `quoteIdent`s the base table + key column. So a
|
|
559
|
+
// hyphenated/spaced name like `external-orders` / `process key` works and MUST NOT be rejected here —
|
|
560
|
+
// the SQL_IDENT rule applies only when a VIEW is actually built (No Drift with provisioning).
|
|
561
|
+
const issues = collectManifestIssues({
|
|
562
|
+
...valid,
|
|
563
|
+
instanceTracking: [
|
|
564
|
+
{
|
|
565
|
+
table: "external-orders",
|
|
566
|
+
keyField: "process key",
|
|
567
|
+
onTerminated: { set: { status: "abandoned" } },
|
|
568
|
+
},
|
|
569
|
+
],
|
|
570
|
+
});
|
|
571
|
+
assert.ok(!issues.some((i) => i.path === "instanceTracking[0].table"));
|
|
572
|
+
assert.ok(!issues.some((i) => i.path === "instanceTracking[0].keyField"));
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
test("an unknown key inside readModel is flagged (additionalProperties: false)", () => {
|
|
576
|
+
// A typo like `statusColum` would silently fall back to the default derived column while the page reads
|
|
577
|
+
// the wrong field. Mirror the `network` block's unknown-key rejection.
|
|
578
|
+
const issues = collectManifestIssues({
|
|
579
|
+
...valid,
|
|
580
|
+
instanceTracking: [
|
|
581
|
+
{
|
|
582
|
+
table: "plans",
|
|
583
|
+
keyField: "process_key",
|
|
584
|
+
statusField: "status",
|
|
585
|
+
onTerminated: { set: { status: "abandoned" } },
|
|
586
|
+
readModel: { view: "plans_board", statusColum: "eff_status" },
|
|
587
|
+
},
|
|
588
|
+
],
|
|
589
|
+
});
|
|
590
|
+
assert.ok(issues.some((i) => i.path === "instanceTracking[0].readModel.statusColum"));
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
test("readModel.statusColumn colliding with statusField is flagged case-insensitively", () => {
|
|
594
|
+
const issues = collectManifestIssues({
|
|
595
|
+
...valid,
|
|
596
|
+
instanceTracking: [
|
|
597
|
+
{
|
|
598
|
+
table: "plans",
|
|
599
|
+
keyField: "process_key",
|
|
600
|
+
statusField: "status",
|
|
601
|
+
onTerminated: { set: { status: "abandoned" } },
|
|
602
|
+
readModel: { statusColumn: "Status" }, // folds to statusField -> stored column shadows the derived one
|
|
603
|
+
},
|
|
604
|
+
],
|
|
605
|
+
});
|
|
606
|
+
assert.ok(issues.some((i) => i.path === "instanceTracking[0].readModel.statusColumn"));
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
test("two bindings folding to the same managed-VIEW name are flagged", () => {
|
|
610
|
+
const issues = collectManifestIssues({
|
|
611
|
+
...valid,
|
|
612
|
+
instanceTracking: [
|
|
613
|
+
{
|
|
614
|
+
table: "plans",
|
|
615
|
+
keyField: "process_key",
|
|
616
|
+
statusField: "status",
|
|
617
|
+
onTerminated: { set: { status: "abandoned" } },
|
|
618
|
+
},
|
|
619
|
+
{
|
|
620
|
+
// Same base table, no distinct override → both default to `plans__tracking`.
|
|
621
|
+
table: "plans",
|
|
622
|
+
keyField: "other_key",
|
|
623
|
+
statusField: "status",
|
|
624
|
+
onTerminated: { set: { status: "abandoned" } },
|
|
625
|
+
},
|
|
626
|
+
],
|
|
627
|
+
});
|
|
628
|
+
assert.ok(issues.some((i) => i.path === "instanceTracking[1].readModel.view"));
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
test("two bindings whose readModel.view differ only by case are flagged", () => {
|
|
632
|
+
const issues = collectManifestIssues({
|
|
633
|
+
...valid,
|
|
634
|
+
instanceTracking: [
|
|
635
|
+
{
|
|
636
|
+
table: "plans",
|
|
637
|
+
keyField: "process_key",
|
|
638
|
+
statusField: "status",
|
|
639
|
+
onTerminated: { set: { status: "abandoned" } },
|
|
640
|
+
readModel: { view: "shared_status" },
|
|
641
|
+
},
|
|
642
|
+
{
|
|
643
|
+
table: "orders",
|
|
644
|
+
keyField: "process_key",
|
|
645
|
+
statusField: "status",
|
|
646
|
+
onTerminated: { set: { status: "abandoned" } },
|
|
647
|
+
readModel: { view: "Shared_Status" }, // folds to the first binding's view
|
|
648
|
+
},
|
|
649
|
+
],
|
|
650
|
+
});
|
|
651
|
+
assert.ok(issues.some((i) => i.path === "instanceTracking[1].readModel.view"));
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
test("distinct managed-VIEW names across bindings on the same table have no view collision", () => {
|
|
655
|
+
const issues = collectManifestIssues({
|
|
656
|
+
...valid,
|
|
657
|
+
instanceTracking: [
|
|
658
|
+
{
|
|
659
|
+
table: "plans",
|
|
660
|
+
keyField: "process_key",
|
|
661
|
+
statusField: "status",
|
|
662
|
+
onTerminated: { set: { status: "abandoned" } },
|
|
663
|
+
readModel: { view: "plans_a" },
|
|
664
|
+
},
|
|
665
|
+
{
|
|
666
|
+
table: "plans",
|
|
667
|
+
keyField: "other_key",
|
|
668
|
+
statusField: "status",
|
|
669
|
+
onTerminated: { set: { status: "abandoned" } },
|
|
670
|
+
readModel: { view: "plans_b" },
|
|
671
|
+
},
|
|
672
|
+
],
|
|
673
|
+
});
|
|
674
|
+
assert.deepEqual(issues, []);
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
test("statusField folding to the default derived column (no override) is flagged", () => {
|
|
678
|
+
// With no `readModel.statusColumn`, the VIEW derives under the default `derived_status`. A binding
|
|
679
|
+
// whose `statusField` IS `derived_status` therefore collides with the default derived column exactly
|
|
680
|
+
// as an explicit override would — SQLite keeps the stored base column and the derived read is stale.
|
|
681
|
+
// This is the omitted-override path the runtime would otherwise only discover at boot.
|
|
682
|
+
const issues = collectManifestIssues({
|
|
683
|
+
...valid,
|
|
684
|
+
instanceTracking: [
|
|
685
|
+
{
|
|
686
|
+
table: "plans",
|
|
687
|
+
keyField: "process_key",
|
|
688
|
+
statusField: "Derived_Status", // folds to the default derived column name
|
|
689
|
+
onTerminated: { set: { Derived_Status: "abandoned" } },
|
|
690
|
+
},
|
|
691
|
+
],
|
|
692
|
+
});
|
|
693
|
+
assert.ok(issues.some((i) => i.path === "instanceTracking[0].statusField"));
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
test("an explicit readModel.statusColumn that differs from the default derived column is not falsely flagged", () => {
|
|
697
|
+
// statusField == default derived column, but an explicit override moves the derived column elsewhere:
|
|
698
|
+
// no collision, so the omitted-override guard must NOT fire.
|
|
699
|
+
const issues = collectManifestIssues({
|
|
700
|
+
...valid,
|
|
701
|
+
instanceTracking: [
|
|
702
|
+
{
|
|
703
|
+
table: "plans",
|
|
704
|
+
keyField: "process_key",
|
|
705
|
+
statusField: "derived_status",
|
|
706
|
+
onTerminated: { set: { derived_status: "abandoned" } },
|
|
707
|
+
readModel: { statusColumn: "effective_status" },
|
|
708
|
+
},
|
|
709
|
+
],
|
|
710
|
+
});
|
|
711
|
+
assert.deepEqual(issues, []);
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
test("a managed VIEW name colliding with another binding's base table is flagged", () => {
|
|
715
|
+
// SQLite shares one namespace for tables and views: a managed VIEW named `orders` (binding 0) collides
|
|
716
|
+
// with binding 1's base table `orders`, so `CREATE VIEW` would fail at boot and binding 0 loses its
|
|
717
|
+
// derived surface. Reject it at author time.
|
|
718
|
+
const issues = collectManifestIssues({
|
|
719
|
+
...valid,
|
|
720
|
+
instanceTracking: [
|
|
721
|
+
{
|
|
722
|
+
table: "plans",
|
|
723
|
+
keyField: "process_key",
|
|
724
|
+
statusField: "status",
|
|
725
|
+
onTerminated: { set: { status: "abandoned" } },
|
|
726
|
+
readModel: { view: "orders" }, // folds to binding[1]'s base table
|
|
727
|
+
},
|
|
728
|
+
{
|
|
729
|
+
table: "orders",
|
|
730
|
+
keyField: "process_key",
|
|
731
|
+
statusField: "status",
|
|
732
|
+
onTerminated: { set: { status: "abandoned" } },
|
|
733
|
+
},
|
|
734
|
+
],
|
|
735
|
+
});
|
|
736
|
+
assert.ok(
|
|
737
|
+
issues.some(
|
|
738
|
+
(i) => i.path === "instanceTracking[0].readModel.view" && i.message.includes("base table"),
|
|
739
|
+
),
|
|
740
|
+
);
|
|
741
|
+
});
|