@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.
- 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/modules/workers.js +25 -1
- package/dist/runtime/core/read-model.d.ts +296 -5
- package/dist/runtime/core/read-model.js +1077 -39
- package/dist/runtime/core/validate.js +192 -0
- package/dist/runtime/index.d.ts +4 -3
- package/dist/runtime/index.js +11 -1
- 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/modules/workers.ts +24 -1
- package/src/runtime/core/read-model.test.ts +94 -0
- package/src/runtime/core/read-model.ts +1356 -45
- package/src/runtime/core/rollup.test.ts +818 -0
- package/src/runtime/core/validate.test.ts +341 -0
- package/src/runtime/core/validate.ts +188 -0
- 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
|
|
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();
|
|
@@ -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" });
|