@nanobpm/urban 0.28.0 → 0.30.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 (84) hide show
  1. package/dist/openapi/spec.d.ts +127 -0
  2. package/dist/openapi/spec.js +444 -0
  3. package/dist/runtime/adapters/deno.js +5 -0
  4. package/dist/runtime/adapters/node.js +5 -0
  5. package/dist/runtime/core/execContext.d.ts +28 -0
  6. package/dist/runtime/core/execContext.js +39 -0
  7. package/dist/runtime/core/host.d.ts +41 -0
  8. package/dist/runtime/core/manifest.d.ts +1 -1
  9. package/dist/runtime/core/modules/api.d.ts +61 -0
  10. package/dist/runtime/core/modules/api.js +359 -0
  11. package/dist/runtime/core/modules/cancel.d.ts +31 -0
  12. package/dist/runtime/core/modules/cancel.js +90 -0
  13. package/dist/runtime/core/modules/connectors.js +3 -1
  14. package/dist/runtime/core/modules/datasource.d.ts +23 -3
  15. package/dist/runtime/core/modules/datasource.js +61 -6
  16. package/dist/runtime/core/modules/gateway.d.ts +10 -3
  17. package/dist/runtime/core/modules/gateway.js +23 -6
  18. package/dist/runtime/core/modules/instance-tracking.d.ts +22 -0
  19. package/dist/runtime/core/modules/instance-tracking.js +158 -0
  20. package/dist/runtime/core/modules/pages.d.ts +8 -0
  21. package/dist/runtime/core/modules/pages.js +17 -1
  22. package/dist/runtime/core/modules/scheduler.d.ts +11 -0
  23. package/dist/runtime/core/modules/scheduler.js +21 -0
  24. package/dist/runtime/core/modules/surfaces.js +9 -0
  25. package/dist/runtime/core/modules/triggers.d.ts +2 -8
  26. package/dist/runtime/core/modules/triggers.js +1 -16
  27. package/dist/runtime/core/modules/workers.js +3 -2
  28. package/dist/runtime/core/runtime.d.ts +3 -0
  29. package/dist/runtime/core/runtime.js +12 -0
  30. package/dist/runtime/core/validate.js +38 -0
  31. package/dist/runtime/engine/nanosdk.d.ts +14 -1
  32. package/dist/runtime/engine/nanosdk.js +90 -7
  33. package/dist/runtime/index.d.ts +4 -0
  34. package/dist/runtime/index.js +2 -0
  35. package/dist/toolkit/artifact.d.ts +5 -0
  36. package/dist/toolkit/artifact.js +7 -0
  37. package/dist/toolkit/derivers/api.d.ts +19 -0
  38. package/dist/toolkit/derivers/api.js +224 -0
  39. package/dist/toolkit/gen.js +23 -4
  40. package/dist/toolkit/index.d.ts +5 -0
  41. package/dist/toolkit/index.js +12 -1
  42. package/package.json +2 -2
  43. package/src/openapi/spec.test.ts +292 -0
  44. package/src/openapi/spec.ts +540 -0
  45. package/src/runtime/adapters/deno.ts +6 -0
  46. package/src/runtime/adapters/node.ts +6 -0
  47. package/src/runtime/core/execContext.ts +56 -0
  48. package/src/runtime/core/host.ts +44 -0
  49. package/src/runtime/core/manifest.ts +1 -0
  50. package/src/runtime/core/modules/actions.test.ts +1 -0
  51. package/src/runtime/core/modules/api.test.ts +445 -0
  52. package/src/runtime/core/modules/api.ts +453 -0
  53. package/src/runtime/core/modules/cancel.test.ts +310 -0
  54. package/src/runtime/core/modules/cancel.ts +119 -0
  55. package/src/runtime/core/modules/connectors.test.ts +1 -0
  56. package/src/runtime/core/modules/connectors.ts +7 -1
  57. package/src/runtime/core/modules/datasource.migrations.test.ts +1 -0
  58. package/src/runtime/core/modules/datasource.ts +77 -6
  59. package/src/runtime/core/modules/gateway.ts +28 -6
  60. package/src/runtime/core/modules/instance-tracking.test.ts +345 -0
  61. package/src/runtime/core/modules/instance-tracking.ts +180 -0
  62. package/src/runtime/core/modules/pages.test.ts +47 -2
  63. package/src/runtime/core/modules/pages.ts +23 -1
  64. package/src/runtime/core/modules/provenance.test.ts +181 -0
  65. package/src/runtime/core/modules/scheduler.ts +31 -0
  66. package/src/runtime/core/modules/surfaces.test.ts +2 -0
  67. package/src/runtime/core/modules/surfaces.ts +10 -0
  68. package/src/runtime/core/modules/triggers.test.ts +1 -0
  69. package/src/runtime/core/modules/triggers.ts +3 -25
  70. package/src/runtime/core/modules/workers.test.ts +1 -0
  71. package/src/runtime/core/modules/workers.ts +11 -2
  72. package/src/runtime/core/runtime.test.ts +3 -0
  73. package/src/runtime/core/runtime.ts +16 -0
  74. package/src/runtime/core/validate.test.ts +96 -0
  75. package/src/runtime/core/validate.ts +39 -0
  76. package/src/runtime/engine/nanosdk.test.ts +118 -5
  77. package/src/runtime/engine/nanosdk.ts +102 -7
  78. package/src/runtime/index.ts +12 -0
  79. package/src/toolkit/artifact.ts +8 -0
  80. package/src/toolkit/derivers/api.test.ts +219 -0
  81. package/src/toolkit/derivers/api.ts +255 -0
  82. package/src/toolkit/gen.test.ts +39 -0
  83. package/src/toolkit/gen.ts +24 -4
  84. package/src/toolkit/index.ts +41 -1
@@ -18,6 +18,7 @@ import { errorMessage, isRecord } from "../guards.js";
18
18
  import { BpmnError } from "../host.js";
19
19
  import { workerJobType } from "../manifest.js";
20
20
  import { drainDefinedWorkers, } from "../../connector-worker-sdk.js";
21
+ import { runInJobContext } from "../execContext.js";
21
22
  function joinRoot(root, p) {
22
23
  return p.startsWith("/") ? p : `${root.replace(/\/+$/, "")}/${p.replace(/^\/+/, "")}`;
23
24
  }
@@ -181,7 +182,8 @@ export async function mountConnectors(ctx, app) {
181
182
  throw new Error(`connector pack "${packId}" entry "${spec.entry}" did not register a worker for ` +
182
183
  `"${jobType}" (its defineWorker call declares a different type).`);
183
184
  }
184
- const wrapped = adaptConnectorHandler(worker);
185
+ const inner = adaptConnectorHandler(worker);
186
+ const wrapped = (job) => runInJobContext({ instanceKey: job.processInstanceKey, elementId: job.elementId, jobType }, () => inner(job));
185
187
  const sub = await ctx.engine.registerWorker(jobType, wrapped, {
186
188
  workerName: `${ctx.manifest.id}:${jobType}`,
187
189
  // The pack's `defineWorker({ maxParallelJobs })` is the authoritative runtime
@@ -1,7 +1,8 @@
1
1
  import type { RuntimeContext } from "../context.ts";
2
2
  import type { HostContext, SqliteDb } from "../host.ts";
3
3
  import type { DomainType } from "../manifest.ts";
4
- import { Table, type DataSource as GatewayDataSource } from "./gateway.ts";
4
+ import { Table, type DataSource as GatewayDataSource, type InsertObserver } from "./gateway.ts";
5
+ import { isAbsolutePath } from "../../../toolkit/artifact.ts";
5
6
  export declare function sqlitePathFromUrl(url: string): string;
6
7
  /** True when `p` is already an absolute path on any host `urban data` targets. `urban data` runs
7
8
  * on a Node host that may be Windows, so absolute-path detection can't assume POSIX: this matches
@@ -9,8 +10,10 @@ export declare function sqlitePathFromUrl(url: string): string;
9
10
  * that starts with a single backslash ("\data\app.db") and a Windows UNC path
10
11
  * ("\\\\server\\share"). A single leading backslash covers both the drive-root and UNC cases,
11
12
  * matching Node's `path.win32.isAbsolute`. Used by `resolveAppPath` so a caller-supplied absolute
12
- * path is never incorrectly prefixed with the app root. */
13
- export declare function isAbsolutePath(p: string): boolean;
13
+ * path is never incorrectly prefixed with the app root. Defined once in `toolkit/artifact.ts` (the
14
+ * shared, dependency-free layer) and re-exported here so the toolkit's gen path-join and this
15
+ * runtime resolver apply the identical rule (no gen/runtime drift). */
16
+ export { isAbsolutePath };
14
17
  /** Resolve `p` against the app `root`: an absolute `p` (see `isAbsolutePath`) is returned as-is;
15
18
  * a relative `p` is joined onto `root`. Trims a trailing separator of either kind off `root` so
16
19
  * we never emit a doubled separator. The join separator matches the root's own style so we never
@@ -29,6 +32,23 @@ export declare function resolveAppPath(root: string, p: string): string;
29
32
  * for this identifier: `applyMigrations` writes it and `dataops`' `migrations` op reads it, so
30
33
  * both import this constant and the ledger name can never drift between application and listing. */
31
34
  export declare const MIGRATIONS_TABLE = "_urban_migrations";
35
+ /** Name of the urban-owned write-provenance sidecar table (ProcessOS domain-signal plane, D0).
36
+ * One append per app `Table.insert` performed inside a job, linking the written row —
37
+ * `(source, table_name, pk_value)` — to the process instance/element that wrote it. Follows the
38
+ * `_urban_` convention so it is automatically excluded from domain-model introspection,
39
+ * DB Manager, and forms (see `gateway.ts` `schema()`), and never collides with an app table. */
40
+ export declare const WRITE_PROVENANCE_TABLE = "_urban_write_provenance";
41
+ /** Provision the write-provenance sidecar on a source (idempotent). Domain-free: it records only
42
+ * the join key + timestamps, never any row content. */
43
+ export declare function ensureProvenanceTable(db: SqliteDb): void;
44
+ /** Build the {@link InsertObserver} that captures write-provenance for one source. Best-effort:
45
+ * it records a row only when a job execution context with an instance key is active (a worker
46
+ * handler is running) — writes outside a job (e.g. HTTP actions) record nothing, degrading
47
+ * gracefully. It skips urban/engine-internal tables so the provenance table never records
48
+ * itself. It runs on the same connection as the insert, so inside a `tx` the provenance row is
49
+ * committed/rolled back atomically with the app write. `Table.insert` wraps this defensively, so
50
+ * a throw here can never break the app insert. */
51
+ export declare function makeProvenanceRecorder(host: HostContext, db: SqliteDb, source: string): InsertObserver;
32
52
  /** Resolve a datasource `url` to its on-disk SQLite path against `root`. The result is absolute
33
53
  * when the resolved path is absolute (either because `url` names an absolute path or `root` is
34
54
  * absolute); if `root` is relative (e.g. "." as used by the CLI/tests) a relative `url` stays
@@ -2,7 +2,9 @@
2
2
  // `types` domain model. Only the `sqlite` driver is implemented; other drivers hit an
3
3
  // explicit seam (they are declared in the manifest but not yet provisioned). This is the
4
4
  // runtime side of ADR 0024 (datasource) + ADR 0040 (domain model), scoped to what an app needs.
5
- import { makeGateway } from "./gateway.js";
5
+ import { makeGateway, } from "./gateway.js";
6
+ import { currentJobContext } from "../execContext.js";
7
+ import { isAbsolutePath } from "../../../toolkit/artifact.js";
6
8
  export function sqlitePathFromUrl(url) {
7
9
  // Accept "file:./x.db", "file:x.db", "sqlite:./x.db" or a bare path.
8
10
  return url.replace(/^(file|sqlite):(\/\/)?/, "");
@@ -13,10 +15,10 @@ export function sqlitePathFromUrl(url) {
13
15
  * that starts with a single backslash ("\data\app.db") and a Windows UNC path
14
16
  * ("\\\\server\\share"). A single leading backslash covers both the drive-root and UNC cases,
15
17
  * matching Node's `path.win32.isAbsolute`. Used by `resolveAppPath` so a caller-supplied absolute
16
- * path is never incorrectly prefixed with the app root. */
17
- export function isAbsolutePath(p) {
18
- return /^(\/|\\|[A-Za-z]:[/\\])/.test(p);
19
- }
18
+ * path is never incorrectly prefixed with the app root. Defined once in `toolkit/artifact.ts` (the
19
+ * shared, dependency-free layer) and re-exported here so the toolkit's gen path-join and this
20
+ * runtime resolver apply the identical rule (no gen/runtime drift). */
21
+ export { isAbsolutePath };
20
22
  /** Resolve `p` against the app `root`: an absolute `p` (see `isAbsolutePath`) is returned as-is;
21
23
  * a relative `p` is joined onto `root`. Trims a trailing separator of either kind off `root` so
22
24
  * we never emit a doubled separator. The join separator matches the root's own style so we never
@@ -43,6 +45,54 @@ export function resolveAppPath(root, p) {
43
45
  * for this identifier: `applyMigrations` writes it and `dataops`' `migrations` op reads it, so
44
46
  * both import this constant and the ledger name can never drift between application and listing. */
45
47
  export const MIGRATIONS_TABLE = "_urban_migrations";
48
+ /** Name of the urban-owned write-provenance sidecar table (ProcessOS domain-signal plane, D0).
49
+ * One append per app `Table.insert` performed inside a job, linking the written row —
50
+ * `(source, table_name, pk_value)` — to the process instance/element that wrote it. Follows the
51
+ * `_urban_` convention so it is automatically excluded from domain-model introspection,
52
+ * DB Manager, and forms (see `gateway.ts` `schema()`), and never collides with an app table. */
53
+ export const WRITE_PROVENANCE_TABLE = "_urban_write_provenance";
54
+ /** Provision the write-provenance sidecar on a source (idempotent). Domain-free: it records only
55
+ * the join key + timestamps, never any row content. */
56
+ export function ensureProvenanceTable(db) {
57
+ db.exec(`CREATE TABLE IF NOT EXISTS ${WRITE_PROVENANCE_TABLE} (` +
58
+ "seq INTEGER PRIMARY KEY AUTOINCREMENT, " +
59
+ "source TEXT NOT NULL, " +
60
+ "table_name TEXT NOT NULL, " +
61
+ "pk_value TEXT NOT NULL, " +
62
+ "instance_key TEXT, " +
63
+ "element_id TEXT, " +
64
+ "job_type TEXT, " +
65
+ "op TEXT NOT NULL DEFAULT 'insert', " +
66
+ "at TEXT NOT NULL)");
67
+ }
68
+ /** Build the {@link InsertObserver} that captures write-provenance for one source. Best-effort:
69
+ * it records a row only when a job execution context with an instance key is active (a worker
70
+ * handler is running) — writes outside a job (e.g. HTTP actions) record nothing, degrading
71
+ * gracefully. It skips urban/engine-internal tables so the provenance table never records
72
+ * itself. It runs on the same connection as the insert, so inside a `tx` the provenance row is
73
+ * committed/rolled back atomically with the app write. `Table.insert` wraps this defensively, so
74
+ * a throw here can never break the app insert. */
75
+ export function makeProvenanceRecorder(host, db, source) {
76
+ return (table, pk) => {
77
+ if (table.startsWith("_urban_") || table.startsWith("_nano_") || table.startsWith("sqlite_")) {
78
+ return;
79
+ }
80
+ const ctx = currentJobContext();
81
+ if (!ctx || ctx.instanceKey == null)
82
+ return;
83
+ db.run(`INSERT INTO ${WRITE_PROVENANCE_TABLE} ` +
84
+ "(source, table_name, pk_value, instance_key, element_id, job_type, op, at) " +
85
+ "VALUES (?, ?, ?, ?, ?, ?, 'insert', ?)", [
86
+ source,
87
+ table,
88
+ String(pk),
89
+ ctx.instanceKey,
90
+ ctx.elementId ?? null,
91
+ ctx.jobType ?? null,
92
+ new Date(host.now()).toISOString(),
93
+ ]);
94
+ };
95
+ }
46
96
  /** Resolve a datasource `url` to its on-disk SQLite path against `root`. The result is absolute
47
97
  * when the resolved path is absolute (either because `url` names an absolute path or `root` is
48
98
  * absolute); if `root` is relative (e.g. "." as used by the CLI/tests) a relative `url` stays
@@ -256,6 +306,11 @@ async function provisionSqlite(ctx, name, src) {
256
306
  const migrationsApplied = src.migrations
257
307
  ? await applyMigrations(ctx.host, db, ctx.root, src.migrations)
258
308
  : [];
309
+ // Provision the write-provenance sidecar and wire the gateway's insert observer to it, so
310
+ // every app `Table.insert` performed inside a job is linked to its process instance/element
311
+ // (ProcessOS domain-signal capture, D0). Domain-free and non-invasive — the sidecar lives
312
+ // outside the domain model.
313
+ ensureProvenanceTable(db);
259
314
  ctx.host.log("info", `datasource: provisioned "${name}"`, {
260
315
  driver: "sqlite",
261
316
  path: resolveSqlitePath(ctx.root, src.url),
@@ -265,7 +320,7 @@ async function provisionSqlite(ctx, name, src) {
265
320
  name,
266
321
  driver: "sqlite",
267
322
  db,
268
- source: makeGateway(db),
323
+ source: makeGateway(db, makeProvenanceRecorder(ctx.host, db, name)),
269
324
  migrationsApplied,
270
325
  close: () => db.close(),
271
326
  };
@@ -1,5 +1,10 @@
1
1
  import type { SqliteDb } from "../host.ts";
2
2
  export type Row = Record<string, unknown>;
3
+ /** Best-effort observer invoked after a successful {@link Table.insert}, with the table name and
4
+ * the new primary-key value. The DataLayer uses it to capture write-provenance. It MUST NOT
5
+ * throw — an app insert must never fail because provenance capture did — but {@link Table.insert}
6
+ * also wraps it defensively. */
7
+ export type InsertObserver = (table: string, pk: number | bigint) => void;
3
8
  export interface ExecResult {
4
9
  /** Rows changed by an INSERT/UPDATE/DELETE. */
5
10
  changed: number;
@@ -58,7 +63,7 @@ export declare class Table<T extends object = Row> {
58
63
  #private;
59
64
  readonly name: string;
60
65
  readonly pk: string;
61
- constructor(src: DataSource, name: string, pk?: string);
66
+ constructor(src: DataSource, name: string, pk?: string, onInsert?: InsertObserver);
62
67
  /** Insert one row; returns the new primary-key value (the inserted rowid for an INTEGER
63
68
  * PRIMARY KEY). Keys whose value is `undefined` are omitted, so the table's own column
64
69
  * `DEFAULT`/`NULL` governs — `undefined` means "not provided, let the schema decide", never
@@ -81,5 +86,7 @@ export declare class Table<T extends object = Row> {
81
86
  /** Count rows matching an equality filter (all rows when omitted). */
82
87
  count(where?: Partial<T>): Promise<number>;
83
88
  }
84
- /** Wrap a provisioned `SqliteDb` as the record-oriented `DataSource` gateway. */
85
- export declare function makeGateway(db: SqliteDb): DataSource;
89
+ /** Wrap a provisioned `SqliteDb` as the record-oriented `DataSource` gateway. An optional
90
+ * `onInsert` observer is invoked after every successful `Table.insert` (used for
91
+ * write-provenance capture); it is threaded to each `Table` this gateway creates. */
92
+ export declare function makeGateway(db: SqliteDb, onInsert?: InsertObserver): DataSource;
@@ -34,10 +34,12 @@ export class Table {
34
34
  name;
35
35
  pk;
36
36
  #src;
37
- constructor(src, name, pk = "id") {
37
+ #onInsert;
38
+ constructor(src, name, pk = "id", onInsert) {
38
39
  this.#src = src;
39
40
  this.name = name;
40
41
  this.pk = pk;
42
+ this.#onInsert = onInsert;
41
43
  }
42
44
  /** Insert one row; returns the new primary-key value (the inserted rowid for an INTEGER
43
45
  * PRIMARY KEY). Keys whose value is `undefined` are omitted, so the table's own column
@@ -59,6 +61,17 @@ export class Table {
59
61
  // silently returning 0, which a caller could mistake for a real primary key.
60
62
  throw new Error(`Table(${this.name}).insert: driver reported no lastInsertId`);
61
63
  }
64
+ if (this.#onInsert) {
65
+ // Provenance capture is strictly observational: never let it break the app insert, which
66
+ // executed successfully above. If this insert runs inside a transaction, commit/rollback
67
+ // semantics still belong to that surrounding transaction.
68
+ try {
69
+ this.#onInsert(this.name, r.lastInsertId);
70
+ }
71
+ catch {
72
+ // swallow — an insert must never fail because provenance capture did
73
+ }
74
+ }
62
75
  return r.lastInsertId;
63
76
  }
64
77
  /** Fetch the row with the given primary key, or `undefined`. */
@@ -111,8 +124,10 @@ export class Table {
111
124
  * are async to hold the app-facing contract; the work underneath is synchronous. */
112
125
  class SqliteGateway {
113
126
  #db;
114
- constructor(db) {
127
+ #onInsert;
128
+ constructor(db, onInsert) {
115
129
  this.#db = db;
130
+ this.#onInsert = onInsert;
116
131
  }
117
132
  async query(sql, params = []) {
118
133
  return this.#db.all(sql, params);
@@ -167,10 +182,12 @@ class SqliteGateway {
167
182
  return out;
168
183
  }
169
184
  table(name, pk = "id") {
170
- return new Table(this, name, pk);
185
+ return new Table(this, name, pk, this.#onInsert);
171
186
  }
172
187
  }
173
- /** Wrap a provisioned `SqliteDb` as the record-oriented `DataSource` gateway. */
174
- export function makeGateway(db) {
175
- return new SqliteGateway(db);
188
+ /** Wrap a provisioned `SqliteDb` as the record-oriented `DataSource` gateway. An optional
189
+ * `onInsert` observer is invoked after every successful `Table.insert` (used for
190
+ * write-provenance capture); it is threaded to each `Table` this gateway creates. */
191
+ export function makeGateway(db, onInsert) {
192
+ return new SqliteGateway(db, onInsert);
176
193
  }
@@ -0,0 +1,22 @@
1
+ import type { AppApi, Mounted, RuntimeContext } from "../context.ts";
2
+ import type { InstanceTracking } from "../manifest.ts";
3
+ import { type SchedulerDeps } from "./scheduler.ts";
4
+ /** Default poll interval when a binding does not set `pollMs`. */
5
+ export declare const DEFAULT_INSTANCE_TRACKING_POLL_MS = 15000;
6
+ export interface InstanceTrackingHandle extends Mounted {
7
+ /** `table.keyField` labels of the armed bindings, for `inspect()`. */
8
+ readonly bindings: string[];
9
+ }
10
+ /** Apply a binding's `onTerminated.set` patch to the single row keyed by `processInstanceKey`.
11
+ * Only the binding whose table owns that key has a matching row, so callers can fan this over
12
+ * every binding and let the no-match ones be no-ops. Returns rows changed. The single place that
13
+ * writes the terminal patch (and logs the reconcile): both the poll reconciler below (`reconcileOnce`,
14
+ * once per TERMINATED key it discovers) and the cancel primitive (immediately when it terminates an
15
+ * instance) route through here, so the two can never drift on the patch or the log. */
16
+ export declare function reconcileTerminatedKey(api: Pick<AppApi, "data" | "log">, bindings: InstanceTracking[], processInstanceKey: string): Promise<number>;
17
+ /**
18
+ * Mount the `instanceTracking` reconciler: arm one poll loop per binding. Each loop selects
19
+ * the active rows, asks the engine which of their instances are terminated, and applies the
20
+ * declared patch. `stop()` cancels every armed timer.
21
+ */
22
+ export declare function mountInstanceTracking(ctx: RuntimeContext, api: AppApi, sched?: SchedulerDeps): InstanceTrackingHandle;
@@ -0,0 +1,158 @@
1
+ // instance-tracking — the process-instance lifecycle reconciler (manifest `instanceTracking`).
2
+ //
3
+ // The problem it closes: an Urban app derives a row's status from the workers that run
4
+ // along a process's *happy path*. When an instance instead reaches a terminal state with
5
+ // no completion job — an operator/`cancelInstance` termination, or a crash — no worker
6
+ // runs, so the read-model row stays "active" forever and any UI derived from it lies.
7
+ //
8
+ // Each `instanceTracking` binding names a datasource table whose rows track an instance by
9
+ // a key column. This module polls the engine for those rows' instances and, when one is
10
+ // `TERMINATED`, applies the binding's `onTerminated.set` patch to the row. It deliberately
11
+ // reconciles only on `TERMINATED` — the state that by definition runs no completion worker
12
+ // — and never on `COMPLETED`, whose terminal row-write the app's own finalize worker owns
13
+ // (reconciling it here would race that worker and could clobber a legitimately-completed
14
+ // row). The poll is driven through the shared `SchedulerDeps` seam so tests are deterministic.
15
+ import { defaultScheduler, MAX_TIMER_DELAY_MS } from "./scheduler.js";
16
+ /** Default poll interval when a binding does not set `pollMs`. */
17
+ export const DEFAULT_INSTANCE_TRACKING_POLL_MS = 15_000;
18
+ /** Apply a binding's `onTerminated.set` patch to the single row keyed by `processInstanceKey`.
19
+ * Only the binding whose table owns that key has a matching row, so callers can fan this over
20
+ * every binding and let the no-match ones be no-ops. Returns rows changed. The single place that
21
+ * writes the terminal patch (and logs the reconcile): both the poll reconciler below (`reconcileOnce`,
22
+ * once per TERMINATED key it discovers) and the cancel primitive (immediately when it terminates an
23
+ * instance) route through here, so the two can never drift on the patch or the log. */
24
+ export async function reconcileTerminatedKey(api, bindings, processInstanceKey) {
25
+ const key = String(processInstanceKey);
26
+ let reconciled = 0;
27
+ for (const binding of bindings) {
28
+ const table = api.data.table(binding.table, binding.keyField);
29
+ const changed = await table.update(key, binding.onTerminated.set);
30
+ if (changed > 0) {
31
+ reconciled += changed;
32
+ api.log("info", "instanceTracking: reconciled terminated instance", {
33
+ table: binding.table,
34
+ keyField: binding.keyField,
35
+ processInstanceKey: key,
36
+ rowsChanged: changed,
37
+ });
38
+ }
39
+ }
40
+ return reconciled;
41
+ }
42
+ /** Poll one binding's instances once. Selects the active rows, asks the engine which of their
43
+ * instances are TERMINATED, and applies `onTerminated.set` to each. Returns how many keys were
44
+ * scanned and how many rows were reconciled this tick. */
45
+ async function reconcileOnce(api, binding) {
46
+ const table = api.data.table(binding.table, binding.keyField);
47
+ // Select the rows worth polling. With a statusField + activeStatuses, only rows in an
48
+ // active status are candidates, so a row already in a terminal status is skipped; without
49
+ // them every row is polled and a terminal instance's row is idempotently re-patched (the
50
+ // engine query returns only TERMINATED instances and the patch is a no-op once applied).
51
+ let candidates;
52
+ const statusField = binding.statusField;
53
+ if (statusField && binding.activeStatuses && binding.activeStatuses.length > 0) {
54
+ const perStatus = await Promise.all(binding.activeStatuses.map((s) => table.find({ [statusField]: s })));
55
+ candidates = perStatus.flat();
56
+ }
57
+ else {
58
+ candidates = await table.all();
59
+ }
60
+ // Map each active row to its tracked instance key. A row with no/empty key can't be
61
+ // reconciled (it never started an instance, or the column is unset) — skip it.
62
+ const keyToRows = new Map();
63
+ for (const row of candidates) {
64
+ const raw = row[binding.keyField];
65
+ if (raw == null || raw === "")
66
+ continue;
67
+ const key = String(raw);
68
+ const list = keyToRows.get(key);
69
+ if (list)
70
+ list.push(row);
71
+ else
72
+ keyToRows.set(key, [row]);
73
+ }
74
+ const keys = [...keyToRows.keys()];
75
+ if (keys.length === 0)
76
+ return { scanned: 0, reconciled: 0 };
77
+ const snapshots = await api.engine.searchProcessInstances({
78
+ processInstanceKeys: keys,
79
+ state: "TERMINATED",
80
+ });
81
+ // Apply the terminal patch through the shared `reconcileTerminatedKey` — the one place that
82
+ // writes `onTerminated.set` and logs the reconcile — so the poll path and the cancel primitive
83
+ // can never drift on the patch or its logging (No Drift Surfaces).
84
+ let reconciled = 0;
85
+ for (const snap of snapshots) {
86
+ if (snap.state !== "TERMINATED")
87
+ continue; // defensive; we asked for TERMINATED only
88
+ if (!keyToRows.has(snap.processInstanceKey))
89
+ continue; // not one we were tracking
90
+ reconciled += await reconcileTerminatedKey(api, [binding], snap.processInstanceKey);
91
+ }
92
+ return { scanned: keys.length, reconciled };
93
+ }
94
+ /**
95
+ * Mount the `instanceTracking` reconciler: arm one poll loop per binding. Each loop selects
96
+ * the active rows, asks the engine which of their instances are terminated, and applies the
97
+ * declared patch. `stop()` cancels every armed timer.
98
+ */
99
+ export function mountInstanceTracking(ctx, api, sched = defaultScheduler()) {
100
+ const bindings = ctx.manifest.instanceTracking ?? [];
101
+ const timers = new Set();
102
+ const armed = [];
103
+ let stopped = false;
104
+ for (const binding of bindings) {
105
+ // `pollMs` feeds a self-rescheduling timer, so a non-number/NaN/zero/negative value would
106
+ // clamp to a 0-delay hot loop. `??` only guards null/undefined; sanitize to a finite positive
107
+ // number and otherwise fall back to the default. (Manifest validation rejects bad values up
108
+ // front; this is belt-and-suspenders for a binding mounted without validation.)
109
+ const rawPoll = binding.pollMs;
110
+ const pollMs = typeof rawPoll === "number" && Number.isFinite(rawPoll) && rawPoll > 0
111
+ ? rawPoll
112
+ : DEFAULT_INSTANCE_TRACKING_POLL_MS;
113
+ // A single setTimeout delay overflows its 32-bit signed range (~24.8 days) and fires
114
+ // immediately — which for a self-rescheduling poll would become a hot loop. Clamp through
115
+ // the one shared constant the cron trigger loop uses (derive-don't-duplicate).
116
+ const delayMs = Math.min(pollMs, MAX_TIMER_DELAY_MS);
117
+ const label = `${binding.table}.${binding.keyField}`;
118
+ armed.push(label);
119
+ const arm = () => {
120
+ if (stopped)
121
+ return;
122
+ let handle;
123
+ handle = sched.setTimer(() => {
124
+ timers.delete(handle);
125
+ if (stopped)
126
+ return;
127
+ void (async () => {
128
+ try {
129
+ await reconcileOnce(api, binding);
130
+ }
131
+ catch (err) {
132
+ api.log("error", `instanceTracking "${label}": reconcile failed`, {
133
+ error: String(err),
134
+ });
135
+ }
136
+ finally {
137
+ arm(); // reschedule regardless of outcome (at-least-once, idempotent)
138
+ }
139
+ })();
140
+ }, delayMs);
141
+ timers.add(handle);
142
+ };
143
+ arm();
144
+ }
145
+ return {
146
+ name: "instanceTracking",
147
+ bindings: armed,
148
+ async stop() {
149
+ stopped = true;
150
+ for (const h of timers)
151
+ sched.clearTimer(h);
152
+ timers.clear();
153
+ },
154
+ describe() {
155
+ return { bindings: armed };
156
+ },
157
+ };
158
+ }
@@ -1,6 +1,7 @@
1
1
  import type { AppApi, RuntimeContext } from "../context.ts";
2
2
  import type { EngineClient } from "../host.ts";
3
3
  import { type Route } from "../router.ts";
4
+ import { type CancelInstanceResult } from "./cancel.ts";
4
5
  /** The subset of the datasource gateway the page runtime needs. */
5
6
  export interface PagesDataSource {
6
7
  query(sql: string, params?: unknown[]): Promise<Record<string, unknown>[]>;
@@ -29,6 +30,13 @@ export interface PagesDeps {
29
30
  * Optional; when absent the index is empty. Injectable for tests.
30
31
  */
31
32
  listPages?(): Promise<string[]>;
33
+ /**
34
+ * Cancel a process instance honestly: terminate it, verify it is actually gone, and reconcile
35
+ * any `instanceTracking` row bound to its key. Injected by `mountPages` (bound to the app's
36
+ * engine, datasource, and instanceTracking bindings). When absent, the `/app/actions/cancel`
37
+ * route falls back to a bare `engine.cancelInstance` (used by tests that inject no cancel dep).
38
+ */
39
+ cancel?(processInstanceKey: string): Promise<CancelInstanceResult>;
32
40
  }
33
41
  /**
34
42
  * Build the page-runtime routes over an injected datasource + engine. Pure over its deps
@@ -16,6 +16,7 @@
16
16
  // POST /app/actions/message → engine.publishMessage
17
17
  import { errorMessage, isRecord } from "../guards.js";
18
18
  import { html, json } from "../router.js";
19
+ import { cancelInstanceReconciling } from "./cancel.js";
19
20
  import { quoteIdent } from "./gateway.js";
20
21
  /** A JavaScript-body response (the renderer module served at /app/runtime.js). */
21
22
  function javascript(body, status = 200) {
@@ -34,7 +35,7 @@ export function createPagesRoutes(opts, deps) {
34
35
  const rowLimitRaw = opts.rowLimit ?? 200;
35
36
  const rowLimit = Number.isFinite(rowLimitRaw) ? Math.max(0, Math.floor(rowLimitRaw)) : 200;
36
37
  const sourceName = opts.sourceName ?? "app";
37
- const { db, engine, readPage, listPages } = deps;
38
+ const { db, engine, readPage, listPages, cancel } = deps;
38
39
  // The table-name whitelist is memoised: an Urban app runs its migrations at boot
39
40
  // (before serving), so the schema is stable for the process lifetime, and the renderer
40
41
  // refreshes grids repeatedly — re-introspecting on every `/app/data` hit would be a hot
@@ -257,6 +258,19 @@ export function createPagesRoutes(opts, deps) {
257
258
  }
258
259
  if (key === undefined || key === "")
259
260
  return json({ error: "processInstanceKey is required" }, 400);
261
+ // The reconcile-aware primitive verifies the real post-cancel state and flips the tracked
262
+ // row immediately; a !ok result means the engine did NOT stop the instance, so surface 502.
263
+ if (cancel) {
264
+ try {
265
+ const result = await cancel(String(key));
266
+ return json(result, result.ok ? 200 : 502);
267
+ }
268
+ catch (e) {
269
+ // Defensive: the primitive is designed not to throw, but if it ever does, mirror the
270
+ // fallback path's honest 502 rather than letting the request reject as a 500.
271
+ return json({ ok: false, processInstanceKey: String(key), error: errorMessage(e) }, 502);
272
+ }
273
+ }
260
274
  try {
261
275
  await engine.cancelInstance({ processInstanceKey: String(key) });
262
276
  return json({ ok: true });
@@ -323,9 +337,11 @@ export function mountPages(ctx, app) {
323
337
  sourceName: typeof decl.sourceName === "string" ? decl.sourceName : undefined,
324
338
  };
325
339
  const sourceName = opts.sourceName ?? "app";
340
+ const bindings = ctx.manifest.instanceTracking ?? [];
326
341
  const routes = createPagesRoutes(opts, {
327
342
  db: app.data.open(sourceName),
328
343
  engine: app.engine,
344
+ cancel: (key) => cancelInstanceReconciling(app, bindings, key),
329
345
  readPage: (p) => ctx.host.readTextFile(p),
330
346
  listPages: async () => {
331
347
  const dir = opts.pagesDir ?? "pages";
@@ -0,0 +1,11 @@
1
+ /** Injectable timer + clock seam so background scheduling is deterministic under test. */
2
+ export interface SchedulerDeps {
3
+ setTimer: (fn: () => void, delayMs: number) => unknown;
4
+ clearTimer: (handle: unknown) => void;
5
+ /** Current wall-clock time in ms since epoch. */
6
+ now: () => number;
7
+ }
8
+ /** Max delay a single `setTimeout` honours before its 32-bit signed overflow (~24.8 days). */
9
+ export declare const MAX_TIMER_DELAY_MS = 2147483647;
10
+ /** Default (live) scheduler seam, backed by the global timer functions. */
11
+ export declare function defaultScheduler(): SchedulerDeps;
@@ -0,0 +1,21 @@
1
+ // The injectable timer + clock seam shared by every background loop in urban (cron
2
+ // triggers, the instance-tracking reconciler). Keeping one definition here — rather
3
+ // than a copy per module — means a test's fake scheduler drives them all the same
4
+ // way, and the "clamp a far-future delay to setTimeout's 32-bit range" rule has a
5
+ // single home.
6
+ /** Max delay a single `setTimeout` honours before its 32-bit signed overflow (~24.8 days). */
7
+ export const MAX_TIMER_DELAY_MS = 2_147_483_647;
8
+ function isTimerHandle(handle) {
9
+ return typeof handle === "number" || (typeof handle === "object" && handle !== null);
10
+ }
11
+ /** Default (live) scheduler seam, backed by the global timer functions. */
12
+ export function defaultScheduler() {
13
+ return {
14
+ setTimer: (fn, ms) => globalThis.setTimeout(fn, ms),
15
+ clearTimer: (h) => {
16
+ if (isTimerHandle(h))
17
+ globalThis.clearTimeout(h);
18
+ },
19
+ now: () => Date.now(),
20
+ };
21
+ }
@@ -4,6 +4,7 @@
4
4
  // (LLM wiring is a separate concern). Surfaces contribute routes to the shared server.
5
5
  import { html, json, normalizeRoutePath } from "../router.js";
6
6
  import { mountActions } from "./actions.js";
7
+ import { mountApi } from "./api.js";
7
8
  import { mountPages } from "./pages.js";
8
9
  /** Escape HTML-significant characters before embedding a value in markup. */
9
10
  function escapeHtml(s) {
@@ -93,6 +94,14 @@ export function mountSurfaces(ctx, app) {
93
94
  routes.push(...actions.routes);
94
95
  enabled.push(`actions(${actions.routes.length})`);
95
96
  }
97
+ // The OpenAPI endpoint surface (ADR 0058) mounts AFTER actions (so an exact `actions[]` route
98
+ // still shadows an operation) and BEFORE the generic pages routes. It owns the `api.base`
99
+ // namespace via a regex dispatcher, so it only contributes routes when the app declares `api`.
100
+ const api = mountApi(ctx, app);
101
+ if (api.routes.length > 0) {
102
+ routes.push(...api.routes);
103
+ enabled.push("api");
104
+ }
96
105
  // The pages surface (the schema-driven page runtime) mounts its own routes.
97
106
  const pages = mountPages(ctx, app);
98
107
  if (pages.routes.length > 0) {
@@ -1,15 +1,10 @@
1
1
  import type { AppApi, Mounted, RuntimeContext } from "../context.ts";
2
2
  import { type Route } from "../router.ts";
3
+ import { type SchedulerDeps } from "./scheduler.ts";
4
+ export type { SchedulerDeps };
3
5
  export interface TriggersHandle extends Mounted {
4
6
  routes: Route[];
5
7
  }
6
- /** Injectable timer + clock seam so cron scheduling is deterministic under test. */
7
- export interface SchedulerDeps {
8
- setTimer: (fn: () => void, delayMs: number) => unknown;
9
- clearTimer: (handle: unknown) => void;
10
- /** Current wall-clock time in ms since epoch. */
11
- now: () => number;
12
- }
13
8
  type Scope = {
14
9
  body: unknown;
15
10
  headers: Record<string, string>;
@@ -58,4 +53,3 @@ export declare function runTriggerAction(app: AppApi, action: RuntimeTriggerActi
58
53
  /** Mount webhook + cron triggers. Webhooks contribute HTTP routes; cron triggers arm
59
54
  * background timers that fire their action on schedule. `stop()` clears every timer. */
60
55
  export declare function mountTriggers(ctx: RuntimeContext, app: AppApi, sched?: SchedulerDeps): TriggersHandle;
61
- export {};
@@ -5,6 +5,7 @@
5
5
  import { isRecord } from "../guards.js";
6
6
  import { json, normalizeRoutePath } from "../router.js";
7
7
  import { nextCronFire, parseCron } from "./cron.js";
8
+ import { defaultScheduler, MAX_TIMER_DELAY_MS } from "./scheduler.js";
8
9
  /**
9
10
  * Resolve a tiny FEEL-ish expression against the event scope: a `= body.a.b` path walk
10
11
  * returns the raw value at that path (or `undefined` if any segment is missing); a literal
@@ -92,22 +93,6 @@ export async function runTriggerAction(app, action, scope) {
92
93
  return { kind: "none" };
93
94
  }
94
95
  const emptyScope = { body: {}, headers: {}, query: {} };
95
- /** Max delay a single `setTimeout` honours before its 32-bit signed overflow (~24.8 days). */
96
- const MAX_TIMER_DELAY_MS = 2_147_483_647;
97
- /** Default (live) scheduler seam, backed by the global timer functions. */
98
- function defaultScheduler() {
99
- return {
100
- setTimer: (fn, ms) => globalThis.setTimeout(fn, ms),
101
- clearTimer: (h) => {
102
- if (isTimerHandle(h))
103
- globalThis.clearTimeout(h);
104
- },
105
- now: () => Date.now(),
106
- };
107
- }
108
- function isTimerHandle(handle) {
109
- return typeof handle === "number" || (typeof handle === "object" && handle !== null);
110
- }
111
96
  /** Mount webhook + cron triggers. Webhooks contribute HTTP routes; cron triggers arm
112
97
  * background timers that fire their action on schedule. `stop()` clears every timer. */
113
98
  export function mountTriggers(ctx, app, sched = defaultScheduler()) {
@@ -4,6 +4,7 @@
4
4
  import { isRecord } from "../guards.js";
5
5
  import { workerJobType } from "../manifest.js";
6
6
  import { runLlmJob } from "./llm.js";
7
+ import { runInJobContext } from "../execContext.js";
7
8
  /** Adapt an engine SDK client into the LLM module's `DecisionEvaluator`, parsing the
8
9
  * decision's JSON-string `output` into the value fed back to the LLM job. Validates the
9
10
  * SDK shape and adds decision context to a parse failure rather than surfacing a raw
@@ -98,7 +99,7 @@ export async function mountWorkers(ctx, app) {
98
99
  env: (n) => app.env(n),
99
100
  evaluateDecision: app.sdk ? sdkDecisionEvaluator(app.sdk) : undefined,
100
101
  };
101
- const wrapped = (job) => runLlmJob(job.variables, binding, rt);
102
+ const wrapped = (job) => runInJobContext({ instanceKey: job.processInstanceKey, elementId: job.elementId, jobType }, () => runLlmJob(job.variables, binding, rt));
102
103
  const sub = await ctx.engine.registerWorker(jobType, wrapped, {
103
104
  workerName: `${ctx.manifest.id}:${jobType}`,
104
105
  });
@@ -113,7 +114,7 @@ export async function mountWorkers(ctx, app) {
113
114
  throw new Error(`worker "${jobType}": ${decl.handler} exports no handler for it ` +
114
115
  `(expected handlers["${jobType}"], a named export, or a default function)`);
115
116
  }
116
- const wrapped = (job) => handler(job, app);
117
+ const wrapped = (job) => runInJobContext({ instanceKey: job.processInstanceKey, elementId: job.elementId, jobType }, () => handler(job, app));
117
118
  const sub = await ctx.engine.registerWorker(jobType, wrapped, {
118
119
  workerName: `${ctx.manifest.id}:${jobType}`,
119
120
  });