@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
|
@@ -56,6 +56,11 @@ export declare class OpenUserTasksStore {
|
|
|
56
56
|
syncInstance(processInstanceKey: string, openTasks: readonly UserTaskSummary[]): void;
|
|
57
57
|
/** Retire every projected open task for an instance (e.g. it terminated). Idempotent. */
|
|
58
58
|
clearInstance(processInstanceKey: string): void;
|
|
59
|
+
/** Every distinct instance key currently projected as having ≥1 open user task. Used by the
|
|
60
|
+
* reconciler to re-probe (and, when answered, RETIRE) keys that have dropped out of a binding's
|
|
61
|
+
* `activeStatuses` allow-list — a projected row must be retired independently of the base-row
|
|
62
|
+
* selector, or an answered escalation's row would linger and the derived edge tear (ADR 0065). */
|
|
63
|
+
instanceKeys(): string[];
|
|
59
64
|
/** True iff the instance currently has any open user task — the "parked on a human" predicate. */
|
|
60
65
|
hasOpenTask(processInstanceKey: string): boolean;
|
|
61
66
|
/** The projected open tasks for an instance, in insertion order. */
|
|
@@ -113,6 +113,14 @@ export class OpenUserTasksStore {
|
|
|
113
113
|
return;
|
|
114
114
|
this.#db.run(`DELETE FROM ${OPEN_USER_TASKS_TABLE} WHERE process_instance_key = ?`, [processInstanceKey]);
|
|
115
115
|
}
|
|
116
|
+
/** Every distinct instance key currently projected as having ≥1 open user task. Used by the
|
|
117
|
+
* reconciler to re-probe (and, when answered, RETIRE) keys that have dropped out of a binding's
|
|
118
|
+
* `activeStatuses` allow-list — a projected row must be retired independently of the base-row
|
|
119
|
+
* selector, or an answered escalation's row would linger and the derived edge tear (ADR 0065). */
|
|
120
|
+
instanceKeys() {
|
|
121
|
+
const rows = this.#db.all(`SELECT DISTINCT process_instance_key FROM ${OPEN_USER_TASKS_TABLE}`);
|
|
122
|
+
return rows.map((r) => r.process_instance_key);
|
|
123
|
+
}
|
|
116
124
|
/** True iff the instance currently has any open user task — the "parked on a human" predicate. */
|
|
117
125
|
hasOpenTask(processInstanceKey) {
|
|
118
126
|
if (!processInstanceKey)
|
|
@@ -32,6 +32,7 @@ import { FORMJS_JS } from "./formjs.gen.js";
|
|
|
32
32
|
import { completeUserTaskResponse, resolveFormResponse } from "./forms.js";
|
|
33
33
|
import { quoteIdent } from "./gateway.js";
|
|
34
34
|
import { RENDERER_JS } from "./runtime.gen.js";
|
|
35
|
+
import { isSqlIdentifier } from "../read-model.js";
|
|
35
36
|
/** A JavaScript-body response (the renderer module served at the fingerprinted
|
|
36
37
|
* `/app/runtime.<hash>.js`, and its `/app/runtime.js` back-compat route). */
|
|
37
38
|
function javascript(body, status = 200, extraHeaders) {
|
|
@@ -118,9 +119,10 @@ async function serveAsset(read, filePath) {
|
|
|
118
119
|
return { status: 404, headers: { "content-type": "text/plain; charset=utf-8" }, body: "not found" };
|
|
119
120
|
}
|
|
120
121
|
}
|
|
121
|
-
/** A
|
|
122
|
-
* (checked against `schema()`), so `/app/data/:table` can never inject
|
|
123
|
-
|
|
122
|
+
/** A table name must be a bare SQL identifier ({@link isSqlIdentifier}, the runtime-wide single source
|
|
123
|
+
* of truth) *and* a known table (checked against `schema()`), so `/app/data/:table` can never inject
|
|
124
|
+
* SQL. Reusing the shared guard (rather than a private regex) keeps this surface from drifting from
|
|
125
|
+
* manifest / read-model / data-source identifier validation. */
|
|
124
126
|
// A query flag (e.g. ?count=1) is "on" when present and not one of the falsy
|
|
125
127
|
// spellings. A bare `?count` (empty string) still counts as on, so a caller can
|
|
126
128
|
// opt in without inventing a value.
|
|
@@ -299,7 +301,7 @@ export function createPagesRoutes(opts, deps) {
|
|
|
299
301
|
// source is rejected rather than silently served off the default.
|
|
300
302
|
if (source !== sourceName)
|
|
301
303
|
return json({ error: `unknown datasource "${source}"` }, 404);
|
|
302
|
-
if (!
|
|
304
|
+
if (!isSqlIdentifier(table))
|
|
303
305
|
return json({ error: "invalid table name" }, 400);
|
|
304
306
|
let tables;
|
|
305
307
|
try {
|
|
@@ -128,7 +128,27 @@ export interface SqlCompileOptions {
|
|
|
128
128
|
/** Resolve a projection NAME to its physical SQL table/view. Defaults to {@link projectionRegistry}. */
|
|
129
129
|
readonly resolveProjectionTable?: (name: string) => string;
|
|
130
130
|
}
|
|
131
|
+
/** A conservative SQL identifier guard — we interpolate names directly into DDL/SQL. This is the
|
|
132
|
+
* single source of truth for "is this a legal identifier"; the manifest validator imports it (rather
|
|
133
|
+
* than re-declaring the regex) so a name accepted at author time is accepted at VIEW provisioning too
|
|
134
|
+
* (No Drift Surfaces — a divergence would let a manifest validate but fail at boot). */
|
|
135
|
+
export declare const SQL_IDENT: RegExp;
|
|
136
|
+
/** True iff `name` is a legal SQL identifier per {@link SQL_IDENT}. The boolean twin of
|
|
137
|
+
* {@link assertSqlIdentifier}, for callers (e.g. the manifest validator) that collect issues rather
|
|
138
|
+
* than throw. */
|
|
139
|
+
export declare function isSqlIdentifier(name: string): boolean;
|
|
131
140
|
export declare function assertSqlIdentifier(kind: string, name: string): string;
|
|
141
|
+
/** Object-name prefixes reserved for engine/runtime internals. Tables and views whose names begin
|
|
142
|
+
* with one of these are Nano bookkeeping (`_urban_*` / `_nano_*`) or SQLite internals (`sqlite_*`):
|
|
143
|
+
* the provenance recorder skips them (`modules/datasource.ts`) and the datasource `schema()` surface
|
|
144
|
+
* filters them out (`modules/gateway.ts`), so they are invisible to `/app/data`, DB Manager, and forms.
|
|
145
|
+
* This is the single source of truth for that prefix set (No Drift Surfaces — a manifest that provisions
|
|
146
|
+
* a `_urban_*` VIEW would otherwise validate yet be unreadable by the documented pages surface). */
|
|
147
|
+
export declare const RESERVED_OBJECT_PREFIXES: readonly ["_urban_", "_nano_", "sqlite_"];
|
|
148
|
+
/** True iff `name` begins with a {@link RESERVED_OBJECT_PREFIXES} prefix. Case-insensitive, matching
|
|
149
|
+
* SQLite's ASCII identifier folding (a `_URBAN_x` table resolves to the same reserved object as
|
|
150
|
+
* `_urban_x`), so a reserved name cannot slip through by casing. */
|
|
151
|
+
export declare function isReservedObjectName(name: string): boolean;
|
|
132
152
|
/**
|
|
133
153
|
* Compile ONE derivation expression to a SQLite select-list expression string — the derived column's
|
|
134
154
|
* body inside the managed VIEW. Both backends walk the SAME AST, so the SQL here and the function
|
|
@@ -143,6 +163,14 @@ export type DerivationFn = (baseRow: BaseRow, projections?: ProjectionRows) => u
|
|
|
143
163
|
* named projection's rows (the TS side of the projection-name seam). Both backends walk the SAME AST.
|
|
144
164
|
*/
|
|
145
165
|
export declare function compileToFn(expr: Expr): DerivationFn;
|
|
166
|
+
/** Resolve a column value the way SQLite resolves an identifier: an exact-key match first, then a
|
|
167
|
+
* case-insensitive fallback. SQLite folds ASCII identifier case even for quoted identifiers, so the
|
|
168
|
+
* VIEW's `col("Status")` binds to a table declared with column `status`; a plain case-sensitive JS
|
|
169
|
+
* `row["Status"]` would miss it and return null, breaking VIEW/TS parity on the no-edge fallback and on
|
|
170
|
+
* `keyField` correlation. Fold here so both backends read the same column. (A table cannot declare two
|
|
171
|
+
* columns differing only in case, so the fallback is unambiguous.) Returns `null` for a missing column,
|
|
172
|
+
* preserving the previous `?? null` contract. */
|
|
173
|
+
export declare function lookupColumn(row: Record<string, unknown>, name: string): unknown;
|
|
146
174
|
/** The declarative read-model input passed to {@link defineReadModel}. */
|
|
147
175
|
export interface ReadModelDecl {
|
|
148
176
|
/** The managed VIEW name (also the read-model identifier). */
|
|
@@ -133,14 +133,37 @@ const DEFAULT_BASE_ALIAS = "base";
|
|
|
133
133
|
function resolveBaseAlias(alias) {
|
|
134
134
|
return assertSqlIdentifier("base alias", alias ?? DEFAULT_BASE_ALIAS);
|
|
135
135
|
}
|
|
136
|
-
/** A conservative SQL identifier guard — we interpolate names directly into DDL/SQL.
|
|
137
|
-
|
|
136
|
+
/** A conservative SQL identifier guard — we interpolate names directly into DDL/SQL. This is the
|
|
137
|
+
* single source of truth for "is this a legal identifier"; the manifest validator imports it (rather
|
|
138
|
+
* than re-declaring the regex) so a name accepted at author time is accepted at VIEW provisioning too
|
|
139
|
+
* (No Drift Surfaces — a divergence would let a manifest validate but fail at boot). */
|
|
140
|
+
export const SQL_IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
141
|
+
/** True iff `name` is a legal SQL identifier per {@link SQL_IDENT}. The boolean twin of
|
|
142
|
+
* {@link assertSqlIdentifier}, for callers (e.g. the manifest validator) that collect issues rather
|
|
143
|
+
* than throw. */
|
|
144
|
+
export function isSqlIdentifier(name) {
|
|
145
|
+
return SQL_IDENT.test(name);
|
|
146
|
+
}
|
|
138
147
|
export function assertSqlIdentifier(kind, name) {
|
|
139
148
|
if (!SQL_IDENT.test(name)) {
|
|
140
149
|
throw new Error(`invalid ${kind} "${name}": must match ${SQL_IDENT.source}`);
|
|
141
150
|
}
|
|
142
151
|
return name;
|
|
143
152
|
}
|
|
153
|
+
/** Object-name prefixes reserved for engine/runtime internals. Tables and views whose names begin
|
|
154
|
+
* with one of these are Nano bookkeeping (`_urban_*` / `_nano_*`) or SQLite internals (`sqlite_*`):
|
|
155
|
+
* the provenance recorder skips them (`modules/datasource.ts`) and the datasource `schema()` surface
|
|
156
|
+
* filters them out (`modules/gateway.ts`), so they are invisible to `/app/data`, DB Manager, and forms.
|
|
157
|
+
* This is the single source of truth for that prefix set (No Drift Surfaces — a manifest that provisions
|
|
158
|
+
* a `_urban_*` VIEW would otherwise validate yet be unreadable by the documented pages surface). */
|
|
159
|
+
export const RESERVED_OBJECT_PREFIXES = ["_urban_", "_nano_", "sqlite_"];
|
|
160
|
+
/** True iff `name` begins with a {@link RESERVED_OBJECT_PREFIXES} prefix. Case-insensitive, matching
|
|
161
|
+
* SQLite's ASCII identifier folding (a `_URBAN_x` table resolves to the same reserved object as
|
|
162
|
+
* `_urban_x`), so a reserved name cannot slip through by casing. */
|
|
163
|
+
export function isReservedObjectName(name) {
|
|
164
|
+
const folded = name.toLowerCase();
|
|
165
|
+
return RESERVED_OBJECT_PREFIXES.some((p) => folded.startsWith(p));
|
|
166
|
+
}
|
|
144
167
|
function quoteIdent(name) {
|
|
145
168
|
return `"${name.replace(/"/g, '""')}"`;
|
|
146
169
|
}
|
|
@@ -365,11 +388,11 @@ export function compileToFn(expr) {
|
|
|
365
388
|
case "lit":
|
|
366
389
|
return node.value;
|
|
367
390
|
case "col":
|
|
368
|
-
return baseRow
|
|
391
|
+
return lookupColumn(baseRow, node.name);
|
|
369
392
|
case "pcol":
|
|
370
393
|
if (!projRow)
|
|
371
394
|
throw new Error(`pcol("${node.name}") evaluated outside an EXISTS projection context`);
|
|
372
|
-
return projRow
|
|
395
|
+
return lookupColumn(projRow, node.name);
|
|
373
396
|
case "compare":
|
|
374
397
|
return compareValues(node.op, evalNode(node.left, baseRow, projections, projRow), evalNode(node.right, baseRow, projections, projRow));
|
|
375
398
|
case "and":
|
|
@@ -394,6 +417,23 @@ export function compileToFn(expr) {
|
|
|
394
417
|
};
|
|
395
418
|
return (baseRow, projections = {}) => evalNode(expr, baseRow, projections);
|
|
396
419
|
}
|
|
420
|
+
/** Resolve a column value the way SQLite resolves an identifier: an exact-key match first, then a
|
|
421
|
+
* case-insensitive fallback. SQLite folds ASCII identifier case even for quoted identifiers, so the
|
|
422
|
+
* VIEW's `col("Status")` binds to a table declared with column `status`; a plain case-sensitive JS
|
|
423
|
+
* `row["Status"]` would miss it and return null, breaking VIEW/TS parity on the no-edge fallback and on
|
|
424
|
+
* `keyField` correlation. Fold here so both backends read the same column. (A table cannot declare two
|
|
425
|
+
* columns differing only in case, so the fallback is unambiguous.) Returns `null` for a missing column,
|
|
426
|
+
* preserving the previous `?? null` contract. */
|
|
427
|
+
export function lookupColumn(row, name) {
|
|
428
|
+
if (Object.hasOwn(row, name))
|
|
429
|
+
return row[name] ?? null;
|
|
430
|
+
const folded = name.toLowerCase();
|
|
431
|
+
for (const key of Object.keys(row)) {
|
|
432
|
+
if (key.toLowerCase() === folded)
|
|
433
|
+
return row[key] ?? null;
|
|
434
|
+
}
|
|
435
|
+
return null;
|
|
436
|
+
}
|
|
397
437
|
function collectProjectionNames(expr, into) {
|
|
398
438
|
switch (expr.kind) {
|
|
399
439
|
case "exists":
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
import schema from "@nanobpm/nano-app-schema/schema" with { type: "json" };
|
|
11
11
|
import { isRecord } from "./guards.js";
|
|
12
12
|
import { BIND_MODES, NETWORK_KEYS, isBindMode, isConfiguredStatusSelector } from "./manifest.js";
|
|
13
|
+
import { DEFAULT_DERIVED_STATUS_COLUMN, defaultInstanceTrackingViewName, } from "./modules/instance-status-read-model.js";
|
|
14
|
+
import { SQL_IDENT, isReservedObjectName } from "./read-model.js";
|
|
13
15
|
export class ManifestValidationError extends Error {
|
|
14
16
|
issues;
|
|
15
17
|
constructor(issues) {
|
|
@@ -169,12 +171,47 @@ export function collectManifestIssues(m) {
|
|
|
169
171
|
if (tracking) {
|
|
170
172
|
tracking.forEach((t, i) => {
|
|
171
173
|
const b = isRecord(t) ? t : undefined;
|
|
174
|
+
// Only a status-bearing binding builds a derived VIEW; a status-less binding just feeds projections
|
|
175
|
+
// and is polled through `api.data.table()`, whose `Table<T>` gateway safely `quoteIdent`s the base
|
|
176
|
+
// table and key column (so a name like `external-orders` works). The SQL_IDENT restriction below is
|
|
177
|
+
// therefore required ONLY when a VIEW is actually built (`table`/`keyField` are interpolated raw into
|
|
178
|
+
// its DDL/edge predicates then) — enforcing it on a status-less binding wrongly rejects a valid name
|
|
179
|
+
// that provisioning never touches. Gate on `buildsView` so validation and provisioning agree exactly
|
|
180
|
+
// (No Drift — a name is rejected here iff it would break at VIEW build).
|
|
181
|
+
const buildsView = typeof b?.statusField === "string" && b.statusField.length > 0;
|
|
172
182
|
if (typeof b?.table !== "string" || b.table.length === 0) {
|
|
173
183
|
issues.push({ path: `instanceTracking[${i}].table`, message: "missing table" });
|
|
174
184
|
}
|
|
185
|
+
else if (buildsView && !SQL_IDENT.test(b.table)) {
|
|
186
|
+
// The base `table` is compiled directly into the derived VIEW's DDL (`defineReadModel` asserts
|
|
187
|
+
// `baseTable` matches SQL_IDENT). Manifest validation historically only checked non-empty, so a
|
|
188
|
+
// name like `external-orders` validated and was polled while the VIEW was silently skipped at
|
|
189
|
+
// mount (read-model construction throws). Enforce the identifier rule at author time so validation
|
|
190
|
+
// and provisioning agree (No Drift — a name accepted here is accepted at VIEW build).
|
|
191
|
+
issues.push({
|
|
192
|
+
path: `instanceTracking[${i}].table`,
|
|
193
|
+
message: `table must be a SQL identifier (${SQL_IDENT.source})`,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
175
196
|
if (typeof b?.keyField !== "string" || b.keyField.length === 0) {
|
|
176
197
|
issues.push({ path: `instanceTracking[${i}].keyField`, message: "missing keyField" });
|
|
177
198
|
}
|
|
199
|
+
else if (buildsView && !SQL_IDENT.test(b.keyField)) {
|
|
200
|
+
// `keyField` is interpolated into the derived VIEW's edge predicates (`terminatedEdgeExpr`), so it
|
|
201
|
+
// must be a bare identifier for the same reason `table` must — but only when a VIEW is built.
|
|
202
|
+
issues.push({
|
|
203
|
+
path: `instanceTracking[${i}].keyField`,
|
|
204
|
+
message: `keyField must be a SQL identifier (${SQL_IDENT.source})`,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
// `statusField`, when present, becomes a VIEW output column (`col(statusField)`), so it must also be
|
|
208
|
+
// a bare identifier — a non-identifier would fail opaquely at VIEW construction.
|
|
209
|
+
if (typeof b?.statusField === "string" && b.statusField.length > 0 && !SQL_IDENT.test(b.statusField)) {
|
|
210
|
+
issues.push({
|
|
211
|
+
path: `instanceTracking[${i}].statusField`,
|
|
212
|
+
message: `statusField must be a SQL identifier (${SQL_IDENT.source})`,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
178
215
|
const onTerminated = isRecord(b?.onTerminated) ? b.onTerminated : undefined;
|
|
179
216
|
const set = isRecord(onTerminated?.set) ? onTerminated.set : undefined;
|
|
180
217
|
if (!set || Object.keys(set).length === 0) {
|
|
@@ -183,6 +220,9 @@ export function collectManifestIssues(m) {
|
|
|
183
220
|
message: "missing onTerminated.set patch (a non-empty column → value map)",
|
|
184
221
|
});
|
|
185
222
|
}
|
|
223
|
+
// The single derived-status target for this binding (used by the readModel.statusColumn collision
|
|
224
|
+
// guard below). Undefined when absent/blank.
|
|
225
|
+
const statusFieldName = typeof b?.statusField === "string" && b.statusField.length > 0 ? b.statusField : undefined;
|
|
186
226
|
// `onWaitingHuman` (issue #355) is the optional wait-on-human edge — the twin of
|
|
187
227
|
// `onTerminated`. It is optional (a binding may reconcile only the terminal edge), but when
|
|
188
228
|
// present its `set` must be a non-empty column → value patch for the same reason
|
|
@@ -246,6 +286,158 @@ export function collectManifestIssues(m) {
|
|
|
246
286
|
message: "pollMs must be a finite positive number of milliseconds",
|
|
247
287
|
});
|
|
248
288
|
}
|
|
289
|
+
// `readModel` (ADR 0065, the writer→source inversion) overrides the derived status VIEW name /
|
|
290
|
+
// column the runtime provisions. Both are compiled into SQL identifiers, so a non-identifier
|
|
291
|
+
// value would fail opaquely at VIEW provisioning; validate them here. The VIEW must differ from
|
|
292
|
+
// the base `table` (SQLite forbids a view and a table sharing a name).
|
|
293
|
+
if (b?.readModel !== undefined) {
|
|
294
|
+
const rm = isRecord(b.readModel) ? b.readModel : undefined;
|
|
295
|
+
if (!rm) {
|
|
296
|
+
issues.push({
|
|
297
|
+
path: `instanceTracking[${i}].readModel`,
|
|
298
|
+
message: "readModel must be an object with optional `view` / `statusColumn`",
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
else {
|
|
302
|
+
// Mirror the schema's `additionalProperties: false` intent (as the `network` block does): reject
|
|
303
|
+
// unknown keys so a typo like `statusColum` fails loudly instead of silently falling back to the
|
|
304
|
+
// default derived column while the page reads the wrong field.
|
|
305
|
+
for (const k of Object.keys(rm)) {
|
|
306
|
+
if (k !== "view" && k !== "statusColumn") {
|
|
307
|
+
issues.push({
|
|
308
|
+
path: `instanceTracking[${i}].readModel.${k}`,
|
|
309
|
+
message: "unknown key (readModel additionalProperties: false)",
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
for (const field of ["view", "statusColumn"]) {
|
|
314
|
+
const v = rm[field];
|
|
315
|
+
if (v !== undefined && (typeof v !== "string" || !SQL_IDENT.test(v))) {
|
|
316
|
+
issues.push({
|
|
317
|
+
path: `instanceTracking[${i}].readModel.${field}`,
|
|
318
|
+
message: `readModel.${field} must be a SQL identifier (${SQL_IDENT.source})`,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
// Fold case before comparing: `defineReadModel`/SQLite treat identifiers case-insensitively,
|
|
323
|
+
// so `table:"plans"` + `readModel.view:"Plans"` would pass a case-sensitive check here yet be
|
|
324
|
+
// rejected as a base-table collision at VIEW construction. Compare folded so author-time
|
|
325
|
+
// validation matches provisioning behavior.
|
|
326
|
+
if (typeof rm.view === "string" && typeof b?.table === "string" && rm.view.toLowerCase() === b.table.toLowerCase()) {
|
|
327
|
+
issues.push({
|
|
328
|
+
path: `instanceTracking[${i}].readModel.view`,
|
|
329
|
+
message: "readModel.view must differ from the base table (a view cannot shadow its table)",
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
// The VIEW emits `${statusColumn} AS ...` after `base.*`; if the derived status column folds to
|
|
333
|
+
// the same identifier as the base `statusField`, SQLite exposes two output columns of that name
|
|
334
|
+
// and keeps the base (stored) one — so consumers read the STALE value, silently defeating the
|
|
335
|
+
// derivation. Reject the collision at author time (case-insensitive, matching SQLite's folding).
|
|
336
|
+
if (typeof rm.statusColumn === "string" && statusFieldName !== undefined &&
|
|
337
|
+
rm.statusColumn.toLowerCase() === statusFieldName.toLowerCase()) {
|
|
338
|
+
issues.push({
|
|
339
|
+
path: `instanceTracking[${i}].readModel.statusColumn`,
|
|
340
|
+
message: `readModel.statusColumn must differ from statusField "${statusFieldName}" (a same-named derived column is shadowed by the stored base column and reads stale)`,
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
// Validate the EFFECTIVE default target too, not just an explicit override. When
|
|
346
|
+
// `readModel.statusColumn` is omitted (or `readModel` entirely), the VIEW derives the status
|
|
347
|
+
// under the default column `derived_status`. A binding with `statusField: "derived_status"` and
|
|
348
|
+
// no override therefore collides with the default derived column exactly as an explicit override
|
|
349
|
+
// would — SQLite keeps the stored base column and the derived read is stale. The explicit-override
|
|
350
|
+
// collision is caught above; this closes the omitted-override path (which the runtime would
|
|
351
|
+
// otherwise only discover at boot, then skip the VIEW).
|
|
352
|
+
const rmRec = isRecord(b?.readModel) ? b.readModel : undefined;
|
|
353
|
+
const hasExplicitStatusColumn = typeof rmRec?.statusColumn === "string" && rmRec.statusColumn.length > 0;
|
|
354
|
+
if (statusFieldName !== undefined && !hasExplicitStatusColumn &&
|
|
355
|
+
DEFAULT_DERIVED_STATUS_COLUMN.toLowerCase() === statusFieldName.toLowerCase()) {
|
|
356
|
+
issues.push({
|
|
357
|
+
path: `instanceTracking[${i}].statusField`,
|
|
358
|
+
message: `statusField "${statusFieldName}" collides with the default derived status column "${DEFAULT_DERIVED_STATUS_COLUMN}" (set readModel.statusColumn to a distinct name, or rename statusField)`,
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
// Reserved-base-table guard (ADR 0065): the base `table` must not itself be a reserved runtime
|
|
362
|
+
// object (`_urban_` / `_nano_` / `sqlite_`). The reserved-VIEW guard below only rejects a reserved
|
|
363
|
+
// *effective view name*, so a binding like `table: "_urban_instance_state", readModel: { view:
|
|
364
|
+
// "tracking" }` slips through — it would publish a NON-reserved `tracking` VIEW that SELECTs the
|
|
365
|
+
// runtime's hidden instance-state sidecar, and `gateway.schema()` filters only the object NAME, so
|
|
366
|
+
// `/app/data/.../tracking` would expose those internal rows. A tracking binding over a reserved
|
|
367
|
+
// runtime table is never legitimate; reject it at author time regardless of statusField.
|
|
368
|
+
if (typeof b?.table === "string" && b.table.length > 0 && isReservedObjectName(b.table)) {
|
|
369
|
+
issues.push({
|
|
370
|
+
path: `instanceTracking[${i}].table`,
|
|
371
|
+
message: `base table "${b.table}" uses a reserved prefix (_urban_ / _nano_ / sqlite_); tracking a reserved runtime table would publish a derived VIEW over hidden runtime state (choose an application base table)`,
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
// Reserved-prefix guard (ADR 0065): the effective managed VIEW name (`readModel.view` or the
|
|
375
|
+
// default `<table>__tracking`) must not begin with a reserved object prefix (`_urban_` / `_nano_`
|
|
376
|
+
// / `sqlite_`). Such a VIEW provisions fine but is filtered out of the datasource `schema()`
|
|
377
|
+
// surface (`gateway.ts`), so the operator page configured for it reads `unknown table`. Reject at
|
|
378
|
+
// author time rather than let it fail opaquely at read. Only bindings with a `statusField`
|
|
379
|
+
// provision a VIEW, so only those can hit this.
|
|
380
|
+
if (statusFieldName !== undefined && typeof b?.table === "string" && b.table.length > 0) {
|
|
381
|
+
const rmView = typeof rmRec?.view === "string" && rmRec.view.length > 0 ? rmRec.view : undefined;
|
|
382
|
+
const effectiveView = rmView ?? defaultInstanceTrackingViewName(b.table);
|
|
383
|
+
if (isReservedObjectName(effectiveView)) {
|
|
384
|
+
issues.push({
|
|
385
|
+
path: rmView ? `instanceTracking[${i}].readModel.view` : `instanceTracking[${i}].table`,
|
|
386
|
+
message: `managed VIEW name "${effectiveView}" uses a reserved prefix (_urban_ / _nano_ / sqlite_); such a view is hidden from the datasource surface and cannot be read by a page (choose a non-reserved readModel.view or base table name)`,
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
// Duplicate managed-VIEW names across bindings (ADR 0065): each binding's read model DROP+CREATEs a
|
|
392
|
+
// VIEW named `readModel.view` or, by default, `<table>__tracking`. Two bindings that fold to the same
|
|
393
|
+
// VIEW name (two bindings on one base table with no distinct override, or names differing only by
|
|
394
|
+
// case) would silently clobber each other at provisioning — the later CREATE wins and the earlier
|
|
395
|
+
// binding loses its derivation. Reject the collision here, folded (SQLite folds identifiers), naming
|
|
396
|
+
// both bindings. Only bindings with a `statusField` provision a VIEW, so only those can collide.
|
|
397
|
+
const viewOwners = new Map();
|
|
398
|
+
// Also reserve every binding's BASE TABLE name: SQLite shares one namespace for tables and views,
|
|
399
|
+
// so a managed VIEW `orders__tracking` for one binding collides with another binding whose base
|
|
400
|
+
// `table` is `orders__tracking`. The manifest would pass validation, then `CREATE VIEW` fails at boot
|
|
401
|
+
// and the first binding loses its derived surface. Collect base tables first (folded), then flag any
|
|
402
|
+
// managed-view name that lands on one.
|
|
403
|
+
const baseTableOwners = new Map();
|
|
404
|
+
tracking.forEach((t, i) => {
|
|
405
|
+
const b = isRecord(t) ? t : undefined;
|
|
406
|
+
if (typeof b?.table !== "string" || b.table.length === 0)
|
|
407
|
+
return;
|
|
408
|
+
const folded = b.table.toLowerCase();
|
|
409
|
+
if (!baseTableOwners.has(folded))
|
|
410
|
+
baseTableOwners.set(folded, i);
|
|
411
|
+
});
|
|
412
|
+
tracking.forEach((t, i) => {
|
|
413
|
+
const b = isRecord(t) ? t : undefined;
|
|
414
|
+
if (typeof b?.table !== "string" || b.table.length === 0)
|
|
415
|
+
return;
|
|
416
|
+
if (typeof b?.statusField !== "string" || b.statusField.length === 0)
|
|
417
|
+
return; // no VIEW ⇒ no collision
|
|
418
|
+
const rm = isRecord(b.readModel) ? b.readModel : undefined;
|
|
419
|
+
const view = typeof rm?.view === "string" && rm.view.length > 0 ? rm.view : defaultInstanceTrackingViewName(b.table);
|
|
420
|
+
const folded = view.toLowerCase();
|
|
421
|
+
const prev = viewOwners.get(folded);
|
|
422
|
+
if (prev !== undefined) {
|
|
423
|
+
issues.push({
|
|
424
|
+
path: `instanceTracking[${i}].readModel.view`,
|
|
425
|
+
message: `managed VIEW name "${view}" collides with instanceTracking[${prev}] (identifiers fold case-insensitively); give each binding a distinct readModel.view`,
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
else {
|
|
429
|
+
viewOwners.set(folded, i);
|
|
430
|
+
}
|
|
431
|
+
// A managed VIEW name that folds to another binding's base table shares SQLite's table/view
|
|
432
|
+
// namespace and would fail `CREATE VIEW` at boot. (Its own base table is already rejected above as
|
|
433
|
+
// "view cannot shadow its table"; here we catch collisions with a DIFFERENT binding's base table.)
|
|
434
|
+
const tableOwner = baseTableOwners.get(folded);
|
|
435
|
+
if (tableOwner !== undefined && tableOwner !== i) {
|
|
436
|
+
issues.push({
|
|
437
|
+
path: `instanceTracking[${i}].readModel.view`,
|
|
438
|
+
message: `managed VIEW name "${view}" collides with the base table of instanceTracking[${tableOwner}] (SQLite shares one table/view namespace); give this binding a distinct readModel.view`,
|
|
439
|
+
});
|
|
440
|
+
}
|
|
249
441
|
});
|
|
250
442
|
}
|
|
251
443
|
return issues;
|
package/dist/runtime/index.d.ts
CHANGED
|
@@ -5,7 +5,7 @@ export type { DispatchMode, Disposer, EmitChannel, ErrorSink, EventBusOptions, L
|
|
|
5
5
|
export { URBAN_EVENT_MODES, createUrbanEvents, mountExtensions, urbanEventMode } from "./core/extensions.ts";
|
|
6
6
|
export type { DispatchRequest, DispatchResponse, ExtensionHost, ExtensionRegistered, ExtensionSetupContext, GateDecision, GateRequest, LifecycleEvent, MountExtensionsOptions, ReconcileEvent, UrbanEventName, UrbanEvents, UrbanExtension, } from "./core/extensions.ts";
|
|
7
7
|
export { bindModeToHost, expandEnv, expandEnvString, isBindMode, loadManifest, parseManifest, resolveBindHost, resolveBindMode, workerJobType, ALL_INTERFACES_HOST, BIND_ENV_VAR, LOOPBACK_HOST, } from "./core/manifest.ts";
|
|
8
|
-
export type { ActionDecl, AppManifest, BindMode, ChatSurface, DataSource, DomainField, DomainType, LlmBinding, NetworkConfig, PagesSurface, Surfaces, TaskInboxSurface, Trigger, Worker, } from "./core/manifest.ts";
|
|
8
|
+
export type { ActionDecl, AppManifest, BindMode, ChatSurface, DataSource, DomainField, DomainType, InstanceTracking, LlmBinding, NetworkConfig, PagesSurface, Surfaces, TaskInboxSurface, Trigger, Worker, } from "./core/manifest.ts";
|
|
9
9
|
export { collectManifestIssues, ManifestValidationError, validateManifest, } from "./core/validate.ts";
|
|
10
10
|
export type { ValidationIssue } from "./core/validate.ts";
|
|
11
11
|
export type { EngineClient, EngineJob, FormSchema, HostContext, HttpHandler, HttpRequest, HttpResponse, HttpServer, JobHandler, ProcessInstanceSnapshot, ProcessInstanceState, SqliteDb, UserTaskState, UserTaskSummary, UserTaskFilter, WatchHandle, WorkerSubscription, } from "./core/host.ts";
|
|
@@ -47,6 +47,7 @@ export { OPEN_USER_TASKS_PROJECTION, OPEN_USER_TASKS_SCHEMA_SQL, OPEN_USER_TASKS
|
|
|
47
47
|
export type { OpenUserTaskRow, OpenUserTasksStoreOptions, } from "./core/modules/open-user-tasks-store.ts";
|
|
48
48
|
export { INSTANCE_STATE_PROJECTION, INSTANCE_STATE_SCHEMA_SQL, INSTANCE_STATE_TABLE, InstanceStateStore, } from "./core/modules/instance-state-store.ts";
|
|
49
49
|
export type { InstanceStateRow, InstanceStateStoreOptions, } from "./core/modules/instance-state-store.ts";
|
|
50
|
+
export { DEFAULT_DERIVED_STATUS_COLUMN, defaultInstanceTrackingViewName, defineInstanceTrackingReadModel, deriveInstanceStatusExpr, instanceTrackingReadModelTarget, TERMINATED_STATE, terminatedEdgeExpr, waitingHumanEdgeExpr, } from "./core/modules/instance-status-read-model.ts";
|
|
50
51
|
export { buildScopeIndex, derivePhase, deriveInstancePhases, furthestReached, rollupLineagePhase, SCOPE_CONTAINERS, } from "./core/phase.ts";
|
|
51
52
|
export type { CrumbKind, CrumbSource, DerivePhaseOptions, FurthestReached, InstancePhase, LineagePhaseRollup, Phase, PhaseCrumb, PhaseGranularity, ProvenanceProgressRow, ScopeElement, ScopeIndex, } from "./core/phase.ts";
|
|
52
53
|
export { deployModels, RESOURCES_DIR } from "./core/modules/deploy.ts";
|
package/dist/runtime/index.js
CHANGED
|
@@ -61,6 +61,11 @@ export { and, assertReadModelParity, assertSqlIdentifier, caseWhen, col, compile
|
|
|
61
61
|
export { CANONICAL_PROJECTIONS, registerCanonicalProjections, } from "./core/modules/canonical-projections.js";
|
|
62
62
|
export { OPEN_USER_TASKS_PROJECTION, OPEN_USER_TASKS_SCHEMA_SQL, OPEN_USER_TASKS_TABLE, OpenUserTasksStore, } from "./core/modules/open-user-tasks-store.js";
|
|
63
63
|
export { INSTANCE_STATE_PROJECTION, INSTANCE_STATE_SCHEMA_SQL, INSTANCE_STATE_TABLE, InstanceStateStore, } from "./core/modules/instance-state-store.js";
|
|
64
|
+
// The DERIVED half of the instanceTracking writer→source inversion (ADR 0065, #439-L1 / #318): the
|
|
65
|
+
// builder that declares a binding's effective-status read model — the terminal + wait-on-human edges —
|
|
66
|
+
// as ONE `defineReadModel` derivation over the canonical projections, compiled to both the managed
|
|
67
|
+
// SQLite VIEW and the TS function. The reconciler feeds the projections; this derives the status edge.
|
|
68
|
+
export { DEFAULT_DERIVED_STATUS_COLUMN, defaultInstanceTrackingViewName, defineInstanceTrackingReadModel, deriveInstanceStatusExpr, instanceTrackingReadModelTarget, TERMINATED_STATE, terminatedEdgeExpr, waitingHumanEdgeExpr, } from "./core/modules/instance-status-read-model.js";
|
|
64
69
|
// instance in?" from the BPMN scope hierarchy + write-provenance (Tier 0, zero declaration), an
|
|
65
70
|
// optional `nano:phase` override (Tier 1), and a lineage rollup for the epic frontier (Tier 2).
|
|
66
71
|
export { buildScopeIndex, derivePhase, deriveInstancePhases, furthestReached, rollupLineagePhase, SCOPE_CONTAINERS, } from "./core/phase.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/urban",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.81.0",
|
|
4
4
|
"description": "Urban: build and run code-first apps on Nano — runtime, derivation toolkit, and CLI in one. Author durable processes with @nanobpm/workflow (defineFlow), a typed datasource, forms, triggers and surfaces from a nano.app.json manifest, on Node or Deno.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -55,25 +55,58 @@ export type InstanceTracking = SchemaInstanceTracking & {
|
|
|
55
55
|
* is polled (fail-open). Requires `statusField`; mutually exclusive with `activeStatuses`. */
|
|
56
56
|
readonly terminalStatuses?: readonly string[];
|
|
57
57
|
/**
|
|
58
|
-
* The reconciliation
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
* the
|
|
58
|
+
* The reconciliation for a row whose instance is **parked waiting on a human** — the wait-state
|
|
59
|
+
* twin of {@link SchemaInstanceTracking.onTerminated} (issue #355). An instance is waiting on a
|
|
60
|
+
* human *iff* it has an open user task, so the runtime derives this edge from engine truth
|
|
61
|
+
* (`openUserTasks`); terminated wins over waiting-human, and a row with neither is left carrying
|
|
62
|
+
* its worker-owned transient status (running / converging / merging).
|
|
63
|
+
*
|
|
64
|
+
* Since ADR 0065 (the writer→source inversion, #439-L1 / #318) this is a DERIVED, not a stored,
|
|
65
|
+
* edge: the reconciler no longer writes `set` into the base row with `table.update`. Instead it
|
|
66
|
+
* feeds the canonical `urban_open_user_tasks` projection, and the read model derives the effective
|
|
67
|
+
* status from it via `defineReadModel` — so an answered escalation re-flips by construction and the
|
|
68
|
+
* stored-derived tearing of nano-workforce#422 cannot recur. `set[statusField]` now supplies the
|
|
69
|
+
* VALUE the derived wait-on-human edge resolves to (rather than a patch); any secondary columns in
|
|
70
|
+
* `set` are no longer written (a VIEW cannot reset a stored column). See {@link readModel}.
|
|
67
71
|
*
|
|
68
72
|
* Optional and, like `terminalStatuses`, bridged locally until the canonical schema
|
|
69
73
|
* (`@nanobpm/nano-app-schema`) folds it in; drop this augmentation and re-export once the dep
|
|
70
74
|
* bump lands (No Drift Surfaces).
|
|
71
75
|
*/
|
|
72
76
|
readonly onWaitingHuman?: {
|
|
73
|
-
/** Column → literal
|
|
74
|
-
* (
|
|
77
|
+
/** Column → literal patch. Its `statusField` entry supplies the VALUE the derived wait-on-human
|
|
78
|
+
* edge resolves to (an instance with an open user task ⇒ this status); no longer a stored write.
|
|
79
|
+
* Since the inversion the reconciler makes NO base-row write at all, so only the `statusField` entry
|
|
80
|
+
* is consumed — any other key is intentionally IGNORED (an app-owned column is the app worker's job,
|
|
81
|
+
* not the reconciler's; ADR 0065). */
|
|
75
82
|
readonly set: { readonly [k: string]: string | number | boolean | null };
|
|
76
83
|
};
|
|
84
|
+
/**
|
|
85
|
+
* Optional overrides for the DERIVED read-model VIEW the runtime provisions per binding (ADR 0065,
|
|
86
|
+
* the writer→source inversion). The reconciler feeds engine truth into the canonical projections
|
|
87
|
+
* (`urban_instance_state`, `urban_open_user_tasks`) and the runtime provisions a managed SQLite VIEW
|
|
88
|
+
* over the binding's base table that re-exports `base.*` plus a derived status column — the terminal
|
|
89
|
+
* (`onTerminated`) and wait-on-human (`onWaitingHuman`) edges computed by `defineReadModel` over the
|
|
90
|
+
* projections, precedence-correct (terminated wins). The operator page reads this derived column; the
|
|
91
|
+
* base `statusField` column now holds only the worker-owned / business-outcome status. Both fields
|
|
92
|
+
* default sensibly (`view` → `<table>__tracking`, `statusColumn` → `derived_status`); override them to
|
|
93
|
+
* avoid a collision (e.g. more than one binding on one base table, or a base column already named
|
|
94
|
+
* `derived_status`). A binding with no `statusField` provisions no VIEW (nothing to derive), but the
|
|
95
|
+
* reconciler still feeds the projections.
|
|
96
|
+
*
|
|
97
|
+
* The VIEW is a first-class readable pages surface, not a separate wiring step: `SqliteGateway.schema()`
|
|
98
|
+
* introspects views (`type IN ('table','view')`) and `/app/data/<source>/<view>` serves `SELECT *`
|
|
99
|
+
* verbatim, so an operator page reads the derived status simply by pointing its page `data.table` at
|
|
100
|
+
* this managed VIEW (which re-exports `base.*`, so it is a drop-in for the base table) and its column at
|
|
101
|
+
* `statusColumn`. This is opt-in by design: the base table stays readable carrying the worker-owned
|
|
102
|
+
* status; the runtime does NOT silently rebind every page off the base table.
|
|
103
|
+
*/
|
|
104
|
+
readonly readModel?: {
|
|
105
|
+
/** Managed VIEW name (must be a SQL identifier and differ from the base table). */
|
|
106
|
+
readonly view?: string;
|
|
107
|
+
/** Derived effective-status column name in that VIEW (must be a SQL identifier). */
|
|
108
|
+
readonly statusColumn?: string;
|
|
109
|
+
};
|
|
77
110
|
};
|
|
78
111
|
|
|
79
112
|
/**
|