@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
@@ -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;
@@ -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";
@@ -40,13 +40,14 @@ export { applyAmbientLineage, buildLineageTree, deriveLineage, LINEAGE_KEY, LINE
40
40
  export type { ApplyLineageOptions, LineageAttachment, LineageEdge, LineageEdgeType, LineageEnvelope, LineageNode, LineageTree, } from "./core/lineage.ts";
41
41
  export { LINEAGE_ATTACHMENTS_TABLE, LINEAGE_EDGES_TABLE, LINEAGE_SCHEMA_SQL, LineageStore, systemClock as lineageSystemClock, } from "./core/modules/lineage-store.ts";
42
42
  export type { Clock as LineageClock, LineageJobLike, LineageStoreOptions, } from "./core/modules/lineage-store.ts";
43
- export { and, assertReadModelParity, assertSqlIdentifier, caseWhen, col, compileToFn, compileToSqlSelect, defineReadModel, deriveReadModelViewDdl, eq, exists, gt, gte, lit, lt, lte, neq, not, or, pcol, ProjectionRegistry, projectionRegistry, ReadModelRegistry, readModelRegistry, when, } from "./core/read-model.ts";
44
- export type { AndExpr, BaseRow, CaseExpr, ColExpr, CompareExpr, CompareOp, DerivationFn, ExistsExpr, Expr, Literal, LitExpr, NotExpr, OrExpr, ParityDb, ParityOptions, ParitySample, ProjColExpr, ProjectionRows, ProjectionSource, ReadModel, ReadModelDecl, SqlCompileOptions, WhenClause, } from "./core/read-model.ts";
43
+ export { add, and, assertReadModelParity, assertRollupParity, assertSqlIdentifier, caseWhen, coalesce, col, compileToFn, compileToSqlSelect, count, countWhere, defineReadModel, defineRollup, deriveReadModelViewDdl, eq, exists, fromProjection, fromRollup, fromTable, gt, gte, isNotNull, isNull, joinSource, lit, lt, lte, max, minWhere, neq, not, or, pcol, ProjectionRegistry, projectionRegistry, rcol, ReadModelRegistry, readModelRegistry, RollupRegistry, rollupRegistry, when, } from "./core/read-model.ts";
44
+ export type { AddAgg, AggExpr, AndExpr, BaseRow, CaseExpr, ColExpr, CoalesceAgg, CompareExpr, CompareOp, CountAgg, CountWhereAgg, DerivationFn, ExistsExpr, Expr, IsNullExpr, Literal, LitExpr, LookupRows, MaxAgg, MinWhereAgg, NotExpr, OrExpr, ParityDb, ParityOptions, ParitySample, ProjColExpr, ProjectionRows, ProjectionSource, ReadModel, ReadModelDecl, RollupColExpr, Rollup, RollupDecl, RollupInputs, RollupJoinSource, RollupLookupDecl, RollupParityOptions, RollupProjectionSource, RollupRelation, RollupRollupSource, RollupSource, RollupTableSource, SqlCompileOptions, WhenClause, } from "./core/read-model.ts";
45
45
  export { CANONICAL_PROJECTIONS, registerCanonicalProjections, } from "./core/modules/canonical-projections.ts";
46
46
  export { OPEN_USER_TASKS_PROJECTION, OPEN_USER_TASKS_SCHEMA_SQL, OPEN_USER_TASKS_TABLE, OpenUserTasksStore, } from "./core/modules/open-user-tasks-store.ts";
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";
@@ -52,7 +52,12 @@ export { LINEAGE_ATTACHMENTS_TABLE, LINEAGE_EDGES_TABLE, LINEAGE_SCHEMA_SQL, Lin
52
52
  // workers.ts) applies its managed VIEW. The `writer-source-inversion` task registers here.
53
53
  // • `projectionRegistry` — register a canonical engine-truth projection NAME usable in `exists(...)`.
54
54
  // The `canonical-projections` task registers `urban_open_user_tasks` / `urban_instance_state`.
55
- export { and, assertReadModelParity, assertSqlIdentifier, caseWhen, col, compileToFn, compileToSqlSelect, defineReadModel, deriveReadModelViewDdl, eq, exists, gt, gte, lit, lt, lte, neq, not, or, pcol, ProjectionRegistry, projectionRegistry, ReadModelRegistry, readModelRegistry, when, } from "./core/read-model.js";
55
+ // The GROUP-BY sibling `defineRollup` (ADR 0065, #468): a declare-once aggregate rollup compiled to
56
+ // BOTH a managed `*_counts` VIEW and a TS group-reduce (closed aggregate op set: `count`/`countWhere`/
57
+ // `max`/`minWhere`/`coalesce`/`add`), registered as a canonical projection via `rollupRegistry` and
58
+ // parity-guarded by `assertRollupParity`. `defineReadModel` gains a key-correlated rollup lookup
59
+ // (`ReadModelDecl.lookups` + `rcol`) so per-row derivations read a rollup's columns for the base row.
60
+ export { add, and, assertReadModelParity, assertRollupParity, assertSqlIdentifier, caseWhen, coalesce, col, compileToFn, compileToSqlSelect, count, countWhere, defineReadModel, defineRollup, deriveReadModelViewDdl, eq, exists, fromProjection, fromRollup, fromTable, gt, gte, isNotNull, isNull, joinSource, lit, lt, lte, max, minWhere, neq, not, or, pcol, ProjectionRegistry, projectionRegistry, rcol, ReadModelRegistry, readModelRegistry, RollupRegistry, rollupRegistry, when, } from "./core/read-model.js";
56
61
  // Canonical engine-truth projection sidecars (ADR 0065, proposal point #1): two framework-owned,
57
62
  // per-source projections the DSL `exists(...)` derives over — `urban_open_user_tasks` (which instances
58
63
  // are parked on a human) and `urban_instance_state` (per-instance engine lifecycle state). Provisioned
@@ -61,6 +66,11 @@ export { and, assertReadModelParity, assertSqlIdentifier, caseWhen, col, compile
61
66
  export { CANONICAL_PROJECTIONS, registerCanonicalProjections, } from "./core/modules/canonical-projections.js";
62
67
  export { OPEN_USER_TASKS_PROJECTION, OPEN_USER_TASKS_SCHEMA_SQL, OPEN_USER_TASKS_TABLE, OpenUserTasksStore, } from "./core/modules/open-user-tasks-store.js";
63
68
  export { INSTANCE_STATE_PROJECTION, INSTANCE_STATE_SCHEMA_SQL, INSTANCE_STATE_TABLE, InstanceStateStore, } from "./core/modules/instance-state-store.js";
69
+ // The DERIVED half of the instanceTracking writer→source inversion (ADR 0065, #439-L1 / #318): the
70
+ // builder that declares a binding's effective-status read model — the terminal + wait-on-human edges —
71
+ // as ONE `defineReadModel` derivation over the canonical projections, compiled to both the managed
72
+ // SQLite VIEW and the TS function. The reconciler feeds the projections; this derives the status edge.
73
+ export { DEFAULT_DERIVED_STATUS_COLUMN, defaultInstanceTrackingViewName, defineInstanceTrackingReadModel, deriveInstanceStatusExpr, instanceTrackingReadModelTarget, TERMINATED_STATE, terminatedEdgeExpr, waitingHumanEdgeExpr, } from "./core/modules/instance-status-read-model.js";
64
74
  // instance in?" from the BPMN scope hierarchy + write-provenance (Tier 0, zero declaration), an
65
75
  // optional `nano:phase` override (Tier 1), and a lineage rollup for the epic frontier (Tier 2).
66
76
  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.80.0",
3
+ "version": "0.82.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 applied to a row whose instance is **parked waiting on a human** — the
59
- * wait-state twin of {@link SchemaInstanceTracking.onTerminated} (issue #355). An instance is
60
- * waiting on a human *iff* it has an open user task, so the runtime derives this edge from
61
- * engine truth (`openUserTasks`) rather than from a written column: on each poll, an active,
62
- * non-terminated row whose instance has any open user task has this patch applied (typically
63
- * `status = "awaiting_operator"`). Terminated wins over waiting-human (the {@link
64
- * SchemaInstanceTracking.onTerminated} edge is applied first and excludes the key from this
65
- * pass); a row with neither a terminated instance nor an open user task is left untouched, so
66
- * the worker-owned transient status (running / converging / merging) survives.
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 value patch written to a row whose instance has an open user task
74
- * (e.g. set the status to a "waiting on human" value). */
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
  /**