@nanobpm/urban 0.80.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.
Files changed (37) hide show
  1. package/dist/runtime/core/manifest.d.ts +44 -11
  2. package/dist/runtime/core/modules/cancel.d.ts +13 -8
  3. package/dist/runtime/core/modules/cancel.js +40 -21
  4. package/dist/runtime/core/modules/datasource.js +7 -4
  5. package/dist/runtime/core/modules/gateway.js +5 -4
  6. package/dist/runtime/core/modules/instance-status-read-model.d.ts +56 -0
  7. package/dist/runtime/core/modules/instance-status-read-model.js +127 -0
  8. package/dist/runtime/core/modules/instance-tracking.d.ts +47 -19
  9. package/dist/runtime/core/modules/instance-tracking.js +451 -123
  10. package/dist/runtime/core/modules/open-user-tasks-store.d.ts +5 -0
  11. package/dist/runtime/core/modules/open-user-tasks-store.js +8 -0
  12. package/dist/runtime/core/modules/pages.js +6 -4
  13. package/dist/runtime/core/modules/workers.js +25 -1
  14. package/dist/runtime/core/read-model.d.ts +296 -5
  15. package/dist/runtime/core/read-model.js +1077 -39
  16. package/dist/runtime/core/validate.js +192 -0
  17. package/dist/runtime/index.d.ts +4 -3
  18. package/dist/runtime/index.js +11 -1
  19. package/package.json +1 -1
  20. package/src/runtime/core/manifest.ts +44 -11
  21. package/src/runtime/core/modules/cancel.test.ts +112 -38
  22. package/src/runtime/core/modules/cancel.ts +42 -22
  23. package/src/runtime/core/modules/datasource.ts +7 -4
  24. package/src/runtime/core/modules/gateway.ts +7 -4
  25. package/src/runtime/core/modules/instance-status-read-model.ts +156 -0
  26. package/src/runtime/core/modules/instance-tracking.test.ts +589 -60
  27. package/src/runtime/core/modules/instance-tracking.ts +469 -126
  28. package/src/runtime/core/modules/open-user-tasks-store.test.ts +11 -0
  29. package/src/runtime/core/modules/open-user-tasks-store.ts +11 -0
  30. package/src/runtime/core/modules/pages.ts +6 -5
  31. package/src/runtime/core/modules/workers.ts +24 -1
  32. package/src/runtime/core/read-model.test.ts +94 -0
  33. package/src/runtime/core/read-model.ts +1356 -45
  34. package/src/runtime/core/rollup.test.ts +818 -0
  35. package/src/runtime/core/validate.test.ts +341 -0
  36. package/src/runtime/core/validate.ts +188 -0
  37. package/src/runtime/index.ts +58 -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 SQL identifier guard — a table name must be a bare identifier *and* a known table
187
- * (checked against `schema()`), so `/app/data/:table` can never inject SQL. */
188
- const IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
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 (!IDENT.test(table)) return json({ error: "invalid table name" }, 400);
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();
@@ -11,7 +11,7 @@ import { runInJobContext } from "../execContext.ts";
11
11
  import type { JobExecContext } from "../execContext.ts";
12
12
  import { readLineage } from "../lineage.ts";
13
13
  import { LineageStore } from "./lineage-store.ts";
14
- import { readModelRegistry } from "../read-model.ts";
14
+ import { readModelRegistry, rollupRegistry } from "../read-model.ts";
15
15
  import { registerCanonicalProjections } from "./canonical-projections.ts";
16
16
  import { InstanceStateStore } from "./instance-state-store.ts";
17
17
  import { OpenUserTasksStore } from "./open-user-tasks-store.ts";
@@ -139,6 +139,25 @@ function tryLineageStore(app: AppApi, log: RuntimeContext["host"]["log"]): Linea
139
139
  }
140
140
  }
141
141
 
142
+ /**
143
+ * Apply every managed rollup VIEW registered in the process-wide `rollupRegistry` (ADR 0065, #468) to
144
+ * the app's DEFAULT data source, in dependency order (a composed rollup after the rollup it reads).
145
+ * Mirrors {@link tryProvisionReadModelViews}: empty until a sibling task registers a rollup, and
146
+ * absent-/error-safe so a missing datasource or a VIEW-provisioning failure never breaks a worker mount.
147
+ */
148
+ function tryProvisionRollupViews(app: AppApi, log: RuntimeContext["host"]["log"]): void {
149
+ if (rollupRegistry.all().length === 0) return;
150
+ if (!app.data.hasDefaultSource()) {
151
+ log("debug", "rollups: no default data source; managed VIEW provisioning skipped");
152
+ return;
153
+ }
154
+ try {
155
+ rollupRegistry.ensureViews(app.data.source().db);
156
+ } catch (err) {
157
+ log("warn", "rollups: failed to provision managed VIEWs", { error: String(err) });
158
+ }
159
+ }
160
+
142
161
  /**
143
162
  * Apply every managed read-model VIEW registered in the process-wide `readModelRegistry` to the app's
144
163
  * DEFAULT data source (ADR 0065, surface #3). A `defineReadModel(...)` declaration compiles to a
@@ -251,6 +270,10 @@ export async function mountWorkers(
251
270
  // provision the projection tables per source (absent-safe), and finally the read-model VIEWs.
252
271
  registerCanonicalProjections();
253
272
  tryProvisionCanonicalProjections(app, ctx.host.log);
273
+ // Rollup VIEWs (ADR 0065, #468) BEFORE the read-model VIEWs: a read model's key-correlated rollup
274
+ // lookup `LEFT JOIN`s a rollup's `*_counts` VIEW, and a composed rollup reads another rollup's VIEW,
275
+ // so every rollup VIEW (in dependency order) must exist before any dependent VIEW compiles.
276
+ tryProvisionRollupViews(app, ctx.host.log);
254
277
  tryProvisionReadModelViews(app, ctx.host.log);
255
278
 
256
279
  // Cache module loads so a multi-type handler module is imported once.
@@ -24,7 +24,10 @@ import {
24
24
  pcol,
25
25
  ProjectionRegistry,
26
26
  projectionRegistry,
27
+ rcol,
27
28
  ReadModelRegistry,
29
+ isReservedObjectName,
30
+ RESERVED_OBJECT_PREFIXES,
28
31
  when,
29
32
  } from "./read-model.ts";
30
33
  import type { ReadModel } from "./read-model.ts";
@@ -82,6 +85,19 @@ const EXPECTED: Record<string, { display_status: string; is_high_priority: numbe
82
85
  t3: { display_status: "active", is_high_priority: 1 },
83
86
  };
84
87
 
88
+ test("isReservedObjectName flags every reserved prefix, case-insensitively, and clears domain names", () => {
89
+ // Single source of truth for the prefixes the datasource surface hides (gateway.schema()) and the
90
+ // provenance recorder skips (datasource.ts). SQLite folds ASCII object names, so a cased reserved
91
+ // name must not slip through.
92
+ assert.deepEqual([...RESERVED_OBJECT_PREFIXES], ["_urban_", "_nano_", "sqlite_"]);
93
+ for (const name of ["_urban_x", "_URBAN_x", "_nano_y", "sqlite_master", "SQLITE_stat1"]) {
94
+ assert.ok(isReservedObjectName(name), `expected "${name}" reserved`);
95
+ }
96
+ for (const name of ["orders", "plans__tracking", "urban_things", "nano", "sqlitely"]) {
97
+ assert.ok(!isReservedObjectName(name), `expected "${name}" NOT reserved`);
98
+ }
99
+ });
100
+
85
101
  test("(i) the read model with CASE + EXISTS compiles to a SQLite VIEW returning the expected values", async () => {
86
102
  await withDb((db) => {
87
103
  seedFixtures(db);
@@ -219,6 +235,84 @@ test("both backends agree on NULL inputs: SQL NULL propagation is COALESCE'd to
219
235
  });
220
236
  });
221
237
 
238
+ test("compileToFn resolves col/pcol identifiers case-insensitively (SQLite identifier folding)", () => {
239
+ // SQLite folds identifier case even for quoted identifiers, so `col("Status")` in the VIEW binds to a
240
+ // column DECLARED `status`. The TS backend must fold too — a case-sensitive `row["Status"]` returns
241
+ // null and silently diverges from the VIEW on the no-edge fallback and on keyField correlation.
242
+ assert.equal(compileToFn(col("Status"))({ status: "abandoned" }), "abandoned"); // folds to declared column
243
+ assert.equal(compileToFn(col("status"))({ status: "abandoned" }), "abandoned"); // exact match still works
244
+ assert.equal(compileToFn(col("Missing"))({ status: "x" }), null); // a truly-absent column → null (not a fold)
245
+ // pcol inside EXISTS folds too, so keyField correlation matches the VIEW's join.
246
+ const corr = compileToFn(exists("p", eq(pcol("Process_Instance_Key"), col("Key"))));
247
+ assert.equal(corr({ key: "pi-1" }, { p: [{ process_instance_key: "pi-1" }] }), true);
248
+ assert.equal(corr({ key: "pi-1" }, { p: [{ process_instance_key: "pi-2" }] }), false);
249
+ });
250
+
251
+ test("compileToFn / rcol reads the resolved lookup row by OWN property (a `__proto__` alias can't resolve Object.prototype)", () => {
252
+ // Lookup aliases are only identifier-validated, so `__proto__` is a legal alias. A bare `fnFor` caller
253
+ // supplies a plain `{}` for `lookups`; a raw `lookups["__proto__"]` resolves the inherited
254
+ // `Object.prototype` (truthy), bypassing the "missing lookup row → throw" guard and reading NULLs off the
255
+ // prototype chain. Reading by OWN property keeps the guard fail-loud on the bare-caller path.
256
+ const fn = compileToFn(rcol("__proto__", "amount"));
257
+ assert.throws(() => fn({}), /without a resolved lookup row/);
258
+ // A genuinely-supplied own `__proto__` row still resolves (null-prototype dict, an own property).
259
+ const lookups: Record<string, Record<string, unknown>> = Object.create(null);
260
+ lookups.__proto__ = { amount: 7 };
261
+ assert.equal(fn({}, {}, lookups), 7);
262
+ });
263
+
264
+ test("compileToFn / exists reads projections by OWN property (a `__proto__` projection can't resolve Object.prototype)", () => {
265
+ // Projection names are user-controlled, so `__proto__` is legal. A bare `fnFor` caller passes a plain
266
+ // `{}` for projections; a raw `projections["__proto__"]` resolves the inherited `Object.prototype`
267
+ // (truthy, non-array), so `.some(...)` throws a TypeError. Reading by OWN property yields an empty EXISTS.
268
+ const fn = compileToFn(exists("__proto__", eq(col("k"), lit(1))));
269
+ assert.equal(fn({ k: 1 }), false); // no such projection → empty EXISTS, not a prototype read
270
+ // A genuinely-supplied own `__proto__` projection still matches.
271
+ const projections: Record<string, ReadonlyArray<Record<string, unknown>>> = Object.create(null);
272
+ projections.__proto__ = [{ k: 1 }];
273
+ assert.equal(fn({ k: 1 }, projections), true);
274
+ });
275
+
276
+ test("mixed-case col/pcol identifiers: the VIEW and TS backends agree (VIEW/TS parity on the no-edge fallback)", async () => { // End-to-end guard for the case-fold fix: author a model with mixed-case identifiers over lowercase
277
+ // columns and confirm the SQLite VIEW and the TS backend derive the SAME value — including the
278
+ // parity-critical no-edge fallback that passes the stored status through.
279
+ const mixed: ReadModel = defineReadModel({
280
+ name: "mixed_case_display",
281
+ baseTable: "widgets",
282
+ derive: {
283
+ effective: caseWhen(
284
+ [
285
+ when(
286
+ exists("urban_open_user_tasks", eq(pcol("Process_Instance_Key"), col("Process_Key"))),
287
+ lit("awaiting_operator"),
288
+ ),
289
+ ],
290
+ col("Status"), // no-edge fallback → the stored status, read via a mixed-case identifier
291
+ ),
292
+ },
293
+ });
294
+ await withDb((db) => {
295
+ db.exec(`CREATE TABLE widgets (id TEXT, process_key TEXT, status TEXT);`);
296
+ db.exec(`CREATE TABLE urban_open_user_tasks (process_instance_key TEXT);`);
297
+ db.exec(mixed.viewDdl());
298
+ db.run(`INSERT INTO widgets VALUES (?, ?, ?)`, ["w1", "pi-1", "dispatched"]); // no open task → fallback
299
+ db.run(`INSERT INTO widgets VALUES (?, ?, ?)`, ["w2", "pi-2", "dispatched"]);
300
+ db.run(`INSERT INTO urban_open_user_tasks VALUES (?)`, ["pi-2"]); // w2 waiting on a human
301
+ // SQL backend: the VIEW resolves the mixed-case identifiers against the lowercase columns.
302
+ const sqlRows = db
303
+ .all<{ id: string; effective: string }>(`SELECT id, effective FROM mixed_case_display ORDER BY id`)
304
+ .map((r) => ({ ...r }));
305
+ assert.deepEqual(sqlRows, [
306
+ { id: "w1", effective: "dispatched" }, // no-edge fallback → stored status (the parity-critical path)
307
+ { id: "w2", effective: "awaiting_operator" },
308
+ ]);
309
+ // TS backend: base/projection rows arrive keyed as SQLite stored them (lowercase).
310
+ const projections = { urban_open_user_tasks: [{ process_instance_key: "pi-2" }] };
311
+ assert.equal(mixed.evaluate({ id: "w1", process_key: "pi-1", status: "dispatched" }, projections).effective, "dispatched");
312
+ assert.equal(mixed.evaluate({ id: "w2", process_key: "pi-2", status: "dispatched" }, projections).effective, "awaiting_operator");
313
+ });
314
+ });
315
+
222
316
  test("EXISTS references a projection by name; the projection registry resolves the physical table", () => {
223
317
  const reg = new ProjectionRegistry();
224
318
  reg.register({ name: "urban_instance_state", sqlTable: "_urban_instance_state" });