@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.
Files changed (34) 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/read-model.d.ts +28 -0
  14. package/dist/runtime/core/read-model.js +44 -4
  15. package/dist/runtime/core/validate.js +192 -0
  16. package/dist/runtime/index.d.ts +2 -1
  17. package/dist/runtime/index.js +5 -0
  18. package/package.json +1 -1
  19. package/src/runtime/core/manifest.ts +44 -11
  20. package/src/runtime/core/modules/cancel.test.ts +112 -38
  21. package/src/runtime/core/modules/cancel.ts +42 -22
  22. package/src/runtime/core/modules/datasource.ts +7 -4
  23. package/src/runtime/core/modules/gateway.ts +7 -4
  24. package/src/runtime/core/modules/instance-status-read-model.ts +156 -0
  25. package/src/runtime/core/modules/instance-tracking.test.ts +589 -60
  26. package/src/runtime/core/modules/instance-tracking.ts +469 -126
  27. package/src/runtime/core/modules/open-user-tasks-store.test.ts +11 -0
  28. package/src/runtime/core/modules/open-user-tasks-store.ts +11 -0
  29. package/src/runtime/core/modules/pages.ts +6 -5
  30. package/src/runtime/core/read-model.test.ts +69 -0
  31. package/src/runtime/core/read-model.ts +43 -4
  32. package/src/runtime/core/validate.test.ts +341 -0
  33. package/src/runtime/core/validate.ts +188 -0
  34. package/src/runtime/index.ts +15 -0
@@ -12,6 +12,11 @@ import schema from "@nanobpm/nano-app-schema/schema" with { type: "json" };
12
12
  import { isRecord } from "./guards.ts";
13
13
  import { BIND_MODES, NETWORK_KEYS, isBindMode, isConfiguredStatusSelector } from "./manifest.ts";
14
14
  import type { AppManifest } from "./manifest.ts";
15
+ import {
16
+ DEFAULT_DERIVED_STATUS_COLUMN,
17
+ defaultInstanceTrackingViewName,
18
+ } from "./modules/instance-status-read-model.ts";
19
+ import { SQL_IDENT, isReservedObjectName } from "./read-model.ts";
15
20
 
16
21
  export interface ValidationIssue {
17
22
  path: string;
@@ -187,11 +192,44 @@ export function collectManifestIssues(m: unknown): ValidationIssue[] {
187
192
  if (tracking) {
188
193
  tracking.forEach((t, i) => {
189
194
  const b = isRecord(t) ? t : undefined;
195
+ // Only a status-bearing binding builds a derived VIEW; a status-less binding just feeds projections
196
+ // and is polled through `api.data.table()`, whose `Table<T>` gateway safely `quoteIdent`s the base
197
+ // table and key column (so a name like `external-orders` works). The SQL_IDENT restriction below is
198
+ // therefore required ONLY when a VIEW is actually built (`table`/`keyField` are interpolated raw into
199
+ // its DDL/edge predicates then) — enforcing it on a status-less binding wrongly rejects a valid name
200
+ // that provisioning never touches. Gate on `buildsView` so validation and provisioning agree exactly
201
+ // (No Drift — a name is rejected here iff it would break at VIEW build).
202
+ const buildsView = typeof b?.statusField === "string" && b.statusField.length > 0;
190
203
  if (typeof b?.table !== "string" || b.table.length === 0) {
191
204
  issues.push({ path: `instanceTracking[${i}].table`, message: "missing table" });
205
+ } else if (buildsView && !SQL_IDENT.test(b.table)) {
206
+ // The base `table` is compiled directly into the derived VIEW's DDL (`defineReadModel` asserts
207
+ // `baseTable` matches SQL_IDENT). Manifest validation historically only checked non-empty, so a
208
+ // name like `external-orders` validated and was polled while the VIEW was silently skipped at
209
+ // mount (read-model construction throws). Enforce the identifier rule at author time so validation
210
+ // and provisioning agree (No Drift — a name accepted here is accepted at VIEW build).
211
+ issues.push({
212
+ path: `instanceTracking[${i}].table`,
213
+ message: `table must be a SQL identifier (${SQL_IDENT.source})`,
214
+ });
192
215
  }
193
216
  if (typeof b?.keyField !== "string" || b.keyField.length === 0) {
194
217
  issues.push({ path: `instanceTracking[${i}].keyField`, message: "missing keyField" });
218
+ } else if (buildsView && !SQL_IDENT.test(b.keyField)) {
219
+ // `keyField` is interpolated into the derived VIEW's edge predicates (`terminatedEdgeExpr`), so it
220
+ // must be a bare identifier for the same reason `table` must — but only when a VIEW is built.
221
+ issues.push({
222
+ path: `instanceTracking[${i}].keyField`,
223
+ message: `keyField must be a SQL identifier (${SQL_IDENT.source})`,
224
+ });
225
+ }
226
+ // `statusField`, when present, becomes a VIEW output column (`col(statusField)`), so it must also be
227
+ // a bare identifier — a non-identifier would fail opaquely at VIEW construction.
228
+ if (typeof b?.statusField === "string" && b.statusField.length > 0 && !SQL_IDENT.test(b.statusField)) {
229
+ issues.push({
230
+ path: `instanceTracking[${i}].statusField`,
231
+ message: `statusField must be a SQL identifier (${SQL_IDENT.source})`,
232
+ });
195
233
  }
196
234
  const onTerminated = isRecord(b?.onTerminated) ? b.onTerminated : undefined;
197
235
  const set = isRecord(onTerminated?.set) ? onTerminated.set : undefined;
@@ -201,6 +239,9 @@ export function collectManifestIssues(m: unknown): ValidationIssue[] {
201
239
  message: "missing onTerminated.set patch (a non-empty column → value map)",
202
240
  });
203
241
  }
242
+ // The single derived-status target for this binding (used by the readModel.statusColumn collision
243
+ // guard below). Undefined when absent/blank.
244
+ const statusFieldName = typeof b?.statusField === "string" && b.statusField.length > 0 ? b.statusField : undefined;
204
245
  // `onWaitingHuman` (issue #355) is the optional wait-on-human edge — the twin of
205
246
  // `onTerminated`. It is optional (a binding may reconcile only the terminal edge), but when
206
247
  // present its `set` must be a non-empty column → value patch for the same reason
@@ -264,12 +305,159 @@ export function collectManifestIssues(m: unknown): ValidationIssue[] {
264
305
  message: "pollMs must be a finite positive number of milliseconds",
265
306
  });
266
307
  }
308
+ // `readModel` (ADR 0065, the writer→source inversion) overrides the derived status VIEW name /
309
+ // column the runtime provisions. Both are compiled into SQL identifiers, so a non-identifier
310
+ // value would fail opaquely at VIEW provisioning; validate them here. The VIEW must differ from
311
+ // the base `table` (SQLite forbids a view and a table sharing a name).
312
+ if (b?.readModel !== undefined) {
313
+ const rm = isRecord(b.readModel) ? b.readModel : undefined;
314
+ if (!rm) {
315
+ issues.push({
316
+ path: `instanceTracking[${i}].readModel`,
317
+ message: "readModel must be an object with optional `view` / `statusColumn`",
318
+ });
319
+ } else {
320
+ // Mirror the schema's `additionalProperties: false` intent (as the `network` block does): reject
321
+ // unknown keys so a typo like `statusColum` fails loudly instead of silently falling back to the
322
+ // default derived column while the page reads the wrong field.
323
+ for (const k of Object.keys(rm)) {
324
+ if (k !== "view" && k !== "statusColumn") {
325
+ issues.push({
326
+ path: `instanceTracking[${i}].readModel.${k}`,
327
+ message: "unknown key (readModel additionalProperties: false)",
328
+ });
329
+ }
330
+ }
331
+ for (const field of ["view", "statusColumn"] as const) {
332
+ const v = rm[field];
333
+ if (v !== undefined && (typeof v !== "string" || !SQL_IDENT.test(v))) {
334
+ issues.push({
335
+ path: `instanceTracking[${i}].readModel.${field}`,
336
+ message: `readModel.${field} must be a SQL identifier (${SQL_IDENT.source})`,
337
+ });
338
+ }
339
+ }
340
+ // Fold case before comparing: `defineReadModel`/SQLite treat identifiers case-insensitively,
341
+ // so `table:"plans"` + `readModel.view:"Plans"` would pass a case-sensitive check here yet be
342
+ // rejected as a base-table collision at VIEW construction. Compare folded so author-time
343
+ // validation matches provisioning behavior.
344
+ if (typeof rm.view === "string" && typeof b?.table === "string" && rm.view.toLowerCase() === b.table.toLowerCase()) {
345
+ issues.push({
346
+ path: `instanceTracking[${i}].readModel.view`,
347
+ message: "readModel.view must differ from the base table (a view cannot shadow its table)",
348
+ });
349
+ }
350
+ // The VIEW emits `${statusColumn} AS ...` after `base.*`; if the derived status column folds to
351
+ // the same identifier as the base `statusField`, SQLite exposes two output columns of that name
352
+ // and keeps the base (stored) one — so consumers read the STALE value, silently defeating the
353
+ // derivation. Reject the collision at author time (case-insensitive, matching SQLite's folding).
354
+ if (typeof rm.statusColumn === "string" && statusFieldName !== undefined &&
355
+ rm.statusColumn.toLowerCase() === statusFieldName.toLowerCase()) {
356
+ issues.push({
357
+ path: `instanceTracking[${i}].readModel.statusColumn`,
358
+ message: `readModel.statusColumn must differ from statusField "${statusFieldName}" (a same-named derived column is shadowed by the stored base column and reads stale)`,
359
+ });
360
+ }
361
+ }
362
+ }
363
+ // Validate the EFFECTIVE default target too, not just an explicit override. When
364
+ // `readModel.statusColumn` is omitted (or `readModel` entirely), the VIEW derives the status
365
+ // under the default column `derived_status`. A binding with `statusField: "derived_status"` and
366
+ // no override therefore collides with the default derived column exactly as an explicit override
367
+ // would — SQLite keeps the stored base column and the derived read is stale. The explicit-override
368
+ // collision is caught above; this closes the omitted-override path (which the runtime would
369
+ // otherwise only discover at boot, then skip the VIEW).
370
+ const rmRec = isRecord(b?.readModel) ? b.readModel : undefined;
371
+ const hasExplicitStatusColumn = typeof rmRec?.statusColumn === "string" && rmRec.statusColumn.length > 0;
372
+ if (statusFieldName !== undefined && !hasExplicitStatusColumn &&
373
+ DEFAULT_DERIVED_STATUS_COLUMN.toLowerCase() === statusFieldName.toLowerCase()) {
374
+ issues.push({
375
+ path: `instanceTracking[${i}].statusField`,
376
+ message: `statusField "${statusFieldName}" collides with the default derived status column "${DEFAULT_DERIVED_STATUS_COLUMN}" (set readModel.statusColumn to a distinct name, or rename statusField)`,
377
+ });
378
+ }
379
+ // Reserved-base-table guard (ADR 0065): the base `table` must not itself be a reserved runtime
380
+ // object (`_urban_` / `_nano_` / `sqlite_`). The reserved-VIEW guard below only rejects a reserved
381
+ // *effective view name*, so a binding like `table: "_urban_instance_state", readModel: { view:
382
+ // "tracking" }` slips through — it would publish a NON-reserved `tracking` VIEW that SELECTs the
383
+ // runtime's hidden instance-state sidecar, and `gateway.schema()` filters only the object NAME, so
384
+ // `/app/data/.../tracking` would expose those internal rows. A tracking binding over a reserved
385
+ // runtime table is never legitimate; reject it at author time regardless of statusField.
386
+ if (typeof b?.table === "string" && b.table.length > 0 && isReservedObjectName(b.table)) {
387
+ issues.push({
388
+ path: `instanceTracking[${i}].table`,
389
+ 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)`,
390
+ });
391
+ }
392
+ // Reserved-prefix guard (ADR 0065): the effective managed VIEW name (`readModel.view` or the
393
+ // default `<table>__tracking`) must not begin with a reserved object prefix (`_urban_` / `_nano_`
394
+ // / `sqlite_`). Such a VIEW provisions fine but is filtered out of the datasource `schema()`
395
+ // surface (`gateway.ts`), so the operator page configured for it reads `unknown table`. Reject at
396
+ // author time rather than let it fail opaquely at read. Only bindings with a `statusField`
397
+ // provision a VIEW, so only those can hit this.
398
+ if (statusFieldName !== undefined && typeof b?.table === "string" && b.table.length > 0) {
399
+ const rmView = typeof rmRec?.view === "string" && rmRec.view.length > 0 ? rmRec.view : undefined;
400
+ const effectiveView = rmView ?? defaultInstanceTrackingViewName(b.table);
401
+ if (isReservedObjectName(effectiveView)) {
402
+ issues.push({
403
+ path: rmView ? `instanceTracking[${i}].readModel.view` : `instanceTracking[${i}].table`,
404
+ 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)`,
405
+ });
406
+ }
407
+ }
408
+ });
409
+ // Duplicate managed-VIEW names across bindings (ADR 0065): each binding's read model DROP+CREATEs a
410
+ // VIEW named `readModel.view` or, by default, `<table>__tracking`. Two bindings that fold to the same
411
+ // VIEW name (two bindings on one base table with no distinct override, or names differing only by
412
+ // case) would silently clobber each other at provisioning — the later CREATE wins and the earlier
413
+ // binding loses its derivation. Reject the collision here, folded (SQLite folds identifiers), naming
414
+ // both bindings. Only bindings with a `statusField` provision a VIEW, so only those can collide.
415
+ const viewOwners = new Map<string, number>();
416
+ // Also reserve every binding's BASE TABLE name: SQLite shares one namespace for tables and views,
417
+ // so a managed VIEW `orders__tracking` for one binding collides with another binding whose base
418
+ // `table` is `orders__tracking`. The manifest would pass validation, then `CREATE VIEW` fails at boot
419
+ // and the first binding loses its derived surface. Collect base tables first (folded), then flag any
420
+ // managed-view name that lands on one.
421
+ const baseTableOwners = new Map<string, number>();
422
+ tracking.forEach((t, i) => {
423
+ const b = isRecord(t) ? t : undefined;
424
+ if (typeof b?.table !== "string" || b.table.length === 0) return;
425
+ const folded = b.table.toLowerCase();
426
+ if (!baseTableOwners.has(folded)) baseTableOwners.set(folded, i);
427
+ });
428
+ tracking.forEach((t, i) => {
429
+ const b = isRecord(t) ? t : undefined;
430
+ if (typeof b?.table !== "string" || b.table.length === 0) return;
431
+ if (typeof b?.statusField !== "string" || b.statusField.length === 0) return; // no VIEW ⇒ no collision
432
+ const rm = isRecord(b.readModel) ? b.readModel : undefined;
433
+ const view = typeof rm?.view === "string" && rm.view.length > 0 ? rm.view : defaultInstanceTrackingViewName(b.table);
434
+ const folded = view.toLowerCase();
435
+ const prev = viewOwners.get(folded);
436
+ if (prev !== undefined) {
437
+ issues.push({
438
+ path: `instanceTracking[${i}].readModel.view`,
439
+ message: `managed VIEW name "${view}" collides with instanceTracking[${prev}] (identifiers fold case-insensitively); give each binding a distinct readModel.view`,
440
+ });
441
+ } else {
442
+ viewOwners.set(folded, i);
443
+ }
444
+ // A managed VIEW name that folds to another binding's base table shares SQLite's table/view
445
+ // namespace and would fail `CREATE VIEW` at boot. (Its own base table is already rejected above as
446
+ // "view cannot shadow its table"; here we catch collisions with a DIFFERENT binding's base table.)
447
+ const tableOwner = baseTableOwners.get(folded);
448
+ if (tableOwner !== undefined && tableOwner !== i) {
449
+ issues.push({
450
+ path: `instanceTracking[${i}].readModel.view`,
451
+ 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`,
452
+ });
453
+ }
267
454
  });
268
455
  }
269
456
 
270
457
  return issues;
271
458
  }
272
459
 
460
+
273
461
  export function assertValidManifest(m: unknown): asserts m is AppManifest {
274
462
  const issues = collectManifestIssues(m);
275
463
  if (issues.length > 0) throw new ManifestValidationError(issues);
@@ -62,6 +62,7 @@ export type {
62
62
  DataSource,
63
63
  DomainField,
64
64
  DomainType,
65
+ InstanceTracking,
65
66
  LlmBinding,
66
67
  NetworkConfig,
67
68
  PagesSurface,
@@ -308,6 +309,20 @@ export type {
308
309
  InstanceStateRow,
309
310
  InstanceStateStoreOptions,
310
311
  } from "./core/modules/instance-state-store.ts";
312
+ // The DERIVED half of the instanceTracking writer→source inversion (ADR 0065, #439-L1 / #318): the
313
+ // builder that declares a binding's effective-status read model — the terminal + wait-on-human edges —
314
+ // as ONE `defineReadModel` derivation over the canonical projections, compiled to both the managed
315
+ // SQLite VIEW and the TS function. The reconciler feeds the projections; this derives the status edge.
316
+ export {
317
+ DEFAULT_DERIVED_STATUS_COLUMN,
318
+ defaultInstanceTrackingViewName,
319
+ defineInstanceTrackingReadModel,
320
+ deriveInstanceStatusExpr,
321
+ instanceTrackingReadModelTarget,
322
+ TERMINATED_STATE,
323
+ terminatedEdgeExpr,
324
+ waitingHumanEdgeExpr,
325
+ } from "./core/modules/instance-status-read-model.ts";
311
326
  // instance in?" from the BPMN scope hierarchy + write-provenance (Tier 0, zero declaration), an
312
327
  // optional `nano:phase` override (Tier 1), and a lineage rollup for the epic frontier (Tier 2).
313
328
  export {