@nanobpm/urban 0.27.0 → 0.29.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 (54) hide show
  1. package/dist/runtime/adapters/deno.js +5 -0
  2. package/dist/runtime/adapters/node.js +5 -0
  3. package/dist/runtime/core/execContext.d.ts +28 -0
  4. package/dist/runtime/core/execContext.js +39 -0
  5. package/dist/runtime/core/host.d.ts +41 -0
  6. package/dist/runtime/core/manifest.d.ts +1 -1
  7. package/dist/runtime/core/modules/connectors.js +3 -1
  8. package/dist/runtime/core/modules/datasource.d.ts +18 -1
  9. package/dist/runtime/core/modules/datasource.js +56 -2
  10. package/dist/runtime/core/modules/gateway.d.ts +10 -3
  11. package/dist/runtime/core/modules/gateway.js +23 -6
  12. package/dist/runtime/core/modules/instance-tracking.d.ts +14 -0
  13. package/dist/runtime/core/modules/instance-tracking.js +141 -0
  14. package/dist/runtime/core/modules/pages.d.ts +6 -0
  15. package/dist/runtime/core/modules/pages.js +167 -13
  16. package/dist/runtime/core/modules/scheduler.d.ts +11 -0
  17. package/dist/runtime/core/modules/scheduler.js +21 -0
  18. package/dist/runtime/core/modules/triggers.d.ts +2 -8
  19. package/dist/runtime/core/modules/triggers.js +1 -16
  20. package/dist/runtime/core/modules/workers.js +3 -2
  21. package/dist/runtime/core/runtime.d.ts +3 -0
  22. package/dist/runtime/core/runtime.js +12 -0
  23. package/dist/runtime/core/validate.js +38 -0
  24. package/dist/runtime/engine/nanosdk.d.ts +14 -1
  25. package/dist/runtime/engine/nanosdk.js +49 -0
  26. package/package.json +2 -2
  27. package/src/runtime/adapters/deno.ts +6 -0
  28. package/src/runtime/adapters/node.ts +6 -0
  29. package/src/runtime/core/execContext.ts +56 -0
  30. package/src/runtime/core/host.ts +44 -0
  31. package/src/runtime/core/manifest.ts +1 -0
  32. package/src/runtime/core/modules/actions.test.ts +1 -0
  33. package/src/runtime/core/modules/connectors.test.ts +1 -0
  34. package/src/runtime/core/modules/connectors.ts +7 -1
  35. package/src/runtime/core/modules/datasource.migrations.test.ts +1 -0
  36. package/src/runtime/core/modules/datasource.ts +72 -2
  37. package/src/runtime/core/modules/gateway.ts +28 -6
  38. package/src/runtime/core/modules/instance-tracking.test.ts +345 -0
  39. package/src/runtime/core/modules/instance-tracking.ts +158 -0
  40. package/src/runtime/core/modules/pages.test.ts +77 -2
  41. package/src/runtime/core/modules/pages.ts +171 -13
  42. package/src/runtime/core/modules/provenance.test.ts +181 -0
  43. package/src/runtime/core/modules/scheduler.ts +31 -0
  44. package/src/runtime/core/modules/surfaces.test.ts +2 -0
  45. package/src/runtime/core/modules/triggers.test.ts +1 -0
  46. package/src/runtime/core/modules/triggers.ts +3 -25
  47. package/src/runtime/core/modules/workers.test.ts +1 -0
  48. package/src/runtime/core/modules/workers.ts +11 -2
  49. package/src/runtime/core/runtime.test.ts +3 -0
  50. package/src/runtime/core/runtime.ts +16 -0
  51. package/src/runtime/core/validate.test.ts +96 -0
  52. package/src/runtime/core/validate.ts +39 -0
  53. package/src/runtime/engine/nanosdk.test.ts +73 -0
  54. package/src/runtime/engine/nanosdk.ts +63 -0
@@ -0,0 +1,56 @@
1
+ // Ambient job execution context — threads the active job's process instance / element through
2
+ // to DataLayer writes so write-provenance capture (ProcessOS domain-signal plane, D0) can join
3
+ // written rows back to the instance/element that wrote them, WITHOUT plumbing the job through
4
+ // every call. Entirely domain-free: it knows nothing about any app's tables or their meaning.
5
+ //
6
+ // The async primitive (`AsyncLocalStorage`) is a host capability (`node:async_hooks`), so it is
7
+ // injected through the `HostContext` seam; core stays `node:*`-free per the host contract. When
8
+ // no store is installed — a host that doesn't provide one, or a plain unit test — `run()` just
9
+ // calls `fn` and `current()` returns undefined, so capture degrades to a no-op. Absent-safe and
10
+ // zero-cost when off, by construction.
11
+
12
+ import type { AsyncStore } from "./host.ts";
13
+
14
+ /** The ambient context of the job currently executing on a worker. All fields are optional so a
15
+ * job dispatched without an instance/element still runs; provenance is only recorded when an
16
+ * `instanceKey` is present. */
17
+ export interface JobExecContext {
18
+ /** The process instance that dispatched the running job. */
19
+ instanceKey?: string;
20
+ /** The BPMN element (e.g. service task) the job is for. */
21
+ elementId?: string;
22
+ /** The job type (worker) currently executing. */
23
+ jobType?: string;
24
+ }
25
+
26
+ let store: AsyncStore<JobExecContext> | undefined;
27
+
28
+ /**
29
+ * Install the host-backed ambient store once per process. Idempotent — the first store created
30
+ * wins: every Node/Deno store is an equivalent `AsyncLocalStorage` instance, and a process that
31
+ * hosts several apps shares one async store safely (values are keyed by async execution context,
32
+ * not by app). A `factory` that returns undefined (a host without the capability) leaves capture
33
+ * disabled, but a later capable host may still install the process store.
34
+ */
35
+ export function installExecStore(factory: () => AsyncStore<JobExecContext> | undefined): void {
36
+ if (store) return;
37
+ const next = factory();
38
+ if (next) store = next;
39
+ }
40
+
41
+ /** Run `fn` with `ctx` as the ambient job context. A transparent pass-through when no store is
42
+ * installed (returns `fn()` directly). */
43
+ export function runInJobContext<R>(ctx: JobExecContext, fn: () => R): R {
44
+ return store ? store.run(ctx, fn) : fn();
45
+ }
46
+
47
+ /** The ambient job context of the current async execution, or undefined outside a job (or when
48
+ * no store is installed). */
49
+ export function currentJobContext(): JobExecContext | undefined {
50
+ return store?.current();
51
+ }
52
+
53
+ /** Test seam: drop the installed store so a subsequent {@link installExecStore} re-installs. */
54
+ export function __resetExecStoreForTests(): void {
55
+ store = undefined;
56
+ }
@@ -35,6 +35,17 @@ export interface WatchHandle {
35
35
  close(): void;
36
36
  }
37
37
 
38
+ /** A minimal ambient async-scoped store — the subset of `AsyncLocalStorage` the runtime needs
39
+ * to thread a job's execution context to DataLayer writes for write-provenance capture. Backed
40
+ * by `node:async_hooks` in both the Node and Deno adapters (both provide it); core depends only
41
+ * on this interface, never on `node:*`. */
42
+ export interface AsyncStore<T> {
43
+ /** Run `fn` with `value` as the current store value, restoring the prior value afterwards. */
44
+ run<R>(value: T, fn: () => R): R;
45
+ /** The current store value, or undefined outside any `run`. */
46
+ current(): T | undefined;
47
+ }
48
+
38
49
  /** A tiny synchronous SQLite handle — the subset the runtime needs. */
39
50
  export interface SqliteDb {
40
51
  /** Execute one or more statements with no result (DDL, PRAGMA, migrations). */
@@ -89,6 +100,12 @@ export interface HostContext {
89
100
  watch?(onChange: (path: string) => void): WatchHandle;
90
101
  /** Current wall-clock time in ms since epoch (seam for tests). */
91
102
  now(): number;
103
+ /**
104
+ * Create an ambient async-scoped store (backed by `AsyncLocalStorage`). Used to thread the
105
+ * active job's execution context to DataLayer writes for write-provenance capture. Optional:
106
+ * a host that cannot provide one leaves provenance capture disabled (absent-safe).
107
+ */
108
+ createAsyncStore?<T>(): AsyncStore<T>;
92
109
  /** Structured log sink. */
93
110
  log(level: "info" | "warn" | "error", msg: string, fields?: Record<string, unknown>): void;
94
111
  }
@@ -146,6 +163,21 @@ export interface WorkerSubscription {
146
163
  unsubscribe(): Promise<void>;
147
164
  }
148
165
 
166
+ /**
167
+ * A process instance's terminal-relevant lifecycle state, as the engine reports it.
168
+ * `ACTIVE` (still running), `COMPLETED` (ended normally at an end event), or
169
+ * `TERMINATED` (cancelled/terminated — via a row-cancel, an operator, or a crash).
170
+ * Deliberately the small set the instance-tracking reconciler keys on, not the richer
171
+ * element-instance state set (which adds SKIPPED/CANCELED/FAILED for tokens).
172
+ */
173
+ export type ProcessInstanceState = "ACTIVE" | "COMPLETED" | "TERMINATED";
174
+
175
+ /** A single process instance's lifecycle snapshot returned by {@link EngineClient.searchProcessInstances}. */
176
+ export interface ProcessInstanceSnapshot {
177
+ readonly processInstanceKey: string;
178
+ readonly state: ProcessInstanceState;
179
+ }
180
+
149
181
  /**
150
182
  * EngineClient is the seam onto a Nano engine. The SDK/REST-backed adapter implements
151
183
  * it against a live engine; tests implement it in-memory. Core modules depend only on this.
@@ -177,6 +209,18 @@ export interface EngineClient {
177
209
  }): Promise<{ userTaskKey: string; elementId?: string; variables?: Record<string, unknown> }[]>;
178
210
  /** Complete a user task. */
179
211
  completeUserTask(userTaskKey: string, variables?: Record<string, unknown>): Promise<void>;
212
+ /**
213
+ * Search process instances by key and/or lifecycle state. The instance-tracking
214
+ * reconciler uses this to detect a tracked instance reaching a terminal state
215
+ * (`TERMINATED`/`COMPLETED`) even though no completion worker ran for it — the
216
+ * read-model row it backs would otherwise stay "active" forever. An eventually
217
+ * consistent read (zero-wait); pass the keys currently marked active in the
218
+ * read model and, optionally, a `state` to narrow the result.
219
+ */
220
+ searchProcessInstances(filter?: {
221
+ processInstanceKeys?: string[];
222
+ state?: ProcessInstanceState;
223
+ }): Promise<ProcessInstanceSnapshot[]>;
180
224
  /** Register a push worker for a job type. Draining is handled by the adapter. */
181
225
  registerWorker(
182
226
  jobType: string,
@@ -21,6 +21,7 @@ export type {
21
21
  DataSource,
22
22
  DomainField,
23
23
  DomainType,
24
+ InstanceTracking,
24
25
  LlmBinding,
25
26
  Models,
26
27
  PagesSurface,
@@ -29,6 +29,7 @@ const engine: EngineClient = {
29
29
  publishMessage: async () => {},
30
30
  searchUserTasks: async () => [],
31
31
  completeUserTask: async () => {},
32
+ searchProcessInstances: async () => [],
32
33
  registerWorker: async (jobType) => ({ jobType, unsubscribe: async () => {} }),
33
34
  close: async () => {},
34
35
  };
@@ -17,6 +17,7 @@ class MiniEngine implements EngineClient {
17
17
  async publishMessage(): Promise<void> {}
18
18
  async searchUserTasks(): Promise<[]> { return []; }
19
19
  async completeUserTask(): Promise<void> {}
20
+ async searchProcessInstances(): Promise<[]> { return []; }
20
21
  async registerWorker(jobType: string, handler: JobHandler): Promise<WorkerSubscription> {
21
22
  this.workers.set(jobType, handler);
22
23
  return { jobType, unsubscribe: async () => void this.workers.delete(jobType) };
@@ -24,6 +24,7 @@ import {
24
24
  type ConnectorWorkerJob,
25
25
  type DefinedConnectorWorker,
26
26
  } from "../../connector-worker-sdk.ts";
27
+ import { runInJobContext } from "../execContext.ts";
27
28
 
28
29
  /** A config field a pack declares (subset of ext-types `ConfigField`): an env
29
30
  * pointer with an optional default. */
@@ -244,7 +245,12 @@ export async function mountConnectors(ctx: RuntimeContext, app: AppApi): Promise
244
245
  );
245
246
  }
246
247
 
247
- const wrapped = adaptConnectorHandler(worker);
248
+ const inner = adaptConnectorHandler(worker);
249
+ const wrapped: JobHandler = (job) =>
250
+ runInJobContext(
251
+ { instanceKey: job.processInstanceKey, elementId: job.elementId, jobType },
252
+ () => inner(job),
253
+ );
248
254
  const sub = await ctx.engine.registerWorker(jobType, wrapped, {
249
255
  workerName: `${ctx.manifest.id}:${jobType}`,
250
256
  // The pack's `defineWorker({ maxParallelJobs })` is the authoritative runtime
@@ -17,6 +17,7 @@ const noEngine: EngineClient = {
17
17
  publishMessage: async () => {},
18
18
  searchUserTasks: async () => [],
19
19
  completeUserTask: async () => {},
20
+ searchProcessInstances: async () => [],
20
21
  registerWorker: async (jobType) => ({ jobType, unsubscribe: async () => {} }),
21
22
  close: async () => {},
22
23
  };
@@ -6,7 +6,13 @@
6
6
  import type { RuntimeContext } from "../context.ts";
7
7
  import type { HostContext, SqliteDb } from "../host.ts";
8
8
  import type { AppManifest, DataSource, DomainType } from "../manifest.ts";
9
- import { makeGateway, Table, type DataSource as GatewayDataSource } from "./gateway.ts";
9
+ import {
10
+ makeGateway,
11
+ Table,
12
+ type DataSource as GatewayDataSource,
13
+ type InsertObserver,
14
+ } from "./gateway.ts";
15
+ import { currentJobContext } from "../execContext.ts";
10
16
 
11
17
  export function sqlitePathFromUrl(url: string): string {
12
18
  // Accept "file:./x.db", "file:x.db", "sqlite:./x.db" or a bare path.
@@ -51,6 +57,65 @@ export function resolveAppPath(root: string, p: string): string {
51
57
  * both import this constant and the ledger name can never drift between application and listing. */
52
58
  export const MIGRATIONS_TABLE = "_urban_migrations";
53
59
 
60
+ /** Name of the urban-owned write-provenance sidecar table (ProcessOS domain-signal plane, D0).
61
+ * One append per app `Table.insert` performed inside a job, linking the written row —
62
+ * `(source, table_name, pk_value)` — to the process instance/element that wrote it. Follows the
63
+ * `_urban_` convention so it is automatically excluded from domain-model introspection,
64
+ * DB Manager, and forms (see `gateway.ts` `schema()`), and never collides with an app table. */
65
+ export const WRITE_PROVENANCE_TABLE = "_urban_write_provenance";
66
+
67
+ /** Provision the write-provenance sidecar on a source (idempotent). Domain-free: it records only
68
+ * the join key + timestamps, never any row content. */
69
+ export function ensureProvenanceTable(db: SqliteDb): void {
70
+ db.exec(
71
+ `CREATE TABLE IF NOT EXISTS ${WRITE_PROVENANCE_TABLE} (` +
72
+ "seq INTEGER PRIMARY KEY AUTOINCREMENT, " +
73
+ "source TEXT NOT NULL, " +
74
+ "table_name TEXT NOT NULL, " +
75
+ "pk_value TEXT NOT NULL, " +
76
+ "instance_key TEXT, " +
77
+ "element_id TEXT, " +
78
+ "job_type TEXT, " +
79
+ "op TEXT NOT NULL DEFAULT 'insert', " +
80
+ "at TEXT NOT NULL)",
81
+ );
82
+ }
83
+
84
+ /** Build the {@link InsertObserver} that captures write-provenance for one source. Best-effort:
85
+ * it records a row only when a job execution context with an instance key is active (a worker
86
+ * handler is running) — writes outside a job (e.g. HTTP actions) record nothing, degrading
87
+ * gracefully. It skips urban/engine-internal tables so the provenance table never records
88
+ * itself. It runs on the same connection as the insert, so inside a `tx` the provenance row is
89
+ * committed/rolled back atomically with the app write. `Table.insert` wraps this defensively, so
90
+ * a throw here can never break the app insert. */
91
+ export function makeProvenanceRecorder(
92
+ host: HostContext,
93
+ db: SqliteDb,
94
+ source: string,
95
+ ): InsertObserver {
96
+ return (table, pk) => {
97
+ if (table.startsWith("_urban_") || table.startsWith("_nano_") || table.startsWith("sqlite_")) {
98
+ return;
99
+ }
100
+ const ctx = currentJobContext();
101
+ if (!ctx || ctx.instanceKey == null) return;
102
+ db.run(
103
+ `INSERT INTO ${WRITE_PROVENANCE_TABLE} ` +
104
+ "(source, table_name, pk_value, instance_key, element_id, job_type, op, at) " +
105
+ "VALUES (?, ?, ?, ?, ?, ?, 'insert', ?)",
106
+ [
107
+ source,
108
+ table,
109
+ String(pk),
110
+ ctx.instanceKey,
111
+ ctx.elementId ?? null,
112
+ ctx.jobType ?? null,
113
+ new Date(host.now()).toISOString(),
114
+ ],
115
+ );
116
+ };
117
+ }
118
+
54
119
  /** Resolve a datasource `url` to its on-disk SQLite path against `root`. The result is absolute
55
120
  * when the resolved path is absolute (either because `url` names an absolute path or `root` is
56
121
  * absolute); if `root` is relative (e.g. "." as used by the CLI/tests) a relative `url` stays
@@ -320,6 +385,11 @@ async function provisionSqlite(
320
385
  const migrationsApplied = src.migrations
321
386
  ? await applyMigrations(ctx.host, db, ctx.root, src.migrations)
322
387
  : [];
388
+ // Provision the write-provenance sidecar and wire the gateway's insert observer to it, so
389
+ // every app `Table.insert` performed inside a job is linked to its process instance/element
390
+ // (ProcessOS domain-signal capture, D0). Domain-free and non-invasive — the sidecar lives
391
+ // outside the domain model.
392
+ ensureProvenanceTable(db);
323
393
  ctx.host.log("info", `datasource: provisioned "${name}"`, {
324
394
  driver: "sqlite",
325
395
  path: resolveSqlitePath(ctx.root, src.url),
@@ -329,7 +399,7 @@ async function provisionSqlite(
329
399
  name,
330
400
  driver: "sqlite",
331
401
  db,
332
- source: makeGateway(db),
402
+ source: makeGateway(db, makeProvenanceRecorder(ctx.host, db, name)),
333
403
  migrationsApplied,
334
404
  close: () => db.close(),
335
405
  };
@@ -16,6 +16,12 @@ import type { SqliteDb } from "../host.ts";
16
16
 
17
17
  export type Row = Record<string, unknown>;
18
18
 
19
+ /** Best-effort observer invoked after a successful {@link Table.insert}, with the table name and
20
+ * the new primary-key value. The DataLayer uses it to capture write-provenance. It MUST NOT
21
+ * throw — an app insert must never fail because provenance capture did — but {@link Table.insert}
22
+ * also wraps it defensively. */
23
+ export type InsertObserver = (table: string, pk: number | bigint) => void;
24
+
19
25
  export interface ExecResult {
20
26
  /** Rows changed by an INSERT/UPDATE/DELETE. */
21
27
  changed: number;
@@ -92,11 +98,13 @@ export class Table<T extends object = Row> {
92
98
  readonly name: string;
93
99
  readonly pk: string;
94
100
  readonly #src: DataSource;
101
+ readonly #onInsert?: InsertObserver;
95
102
 
96
- constructor(src: DataSource, name: string, pk = "id") {
103
+ constructor(src: DataSource, name: string, pk = "id", onInsert?: InsertObserver) {
97
104
  this.#src = src;
98
105
  this.name = name;
99
106
  this.pk = pk;
107
+ this.#onInsert = onInsert;
100
108
  }
101
109
 
102
110
  /** Insert one row; returns the new primary-key value (the inserted rowid for an INTEGER
@@ -124,6 +132,16 @@ export class Table<T extends object = Row> {
124
132
  // silently returning 0, which a caller could mistake for a real primary key.
125
133
  throw new Error(`Table(${this.name}).insert: driver reported no lastInsertId`);
126
134
  }
135
+ if (this.#onInsert) {
136
+ // Provenance capture is strictly observational: never let it break the app insert, which
137
+ // executed successfully above. If this insert runs inside a transaction, commit/rollback
138
+ // semantics still belong to that surrounding transaction.
139
+ try {
140
+ this.#onInsert(this.name, r.lastInsertId);
141
+ } catch {
142
+ // swallow — an insert must never fail because provenance capture did
143
+ }
144
+ }
127
145
  return r.lastInsertId;
128
146
  }
129
147
 
@@ -202,8 +220,10 @@ export class Table<T extends object = Row> {
202
220
  * are async to hold the app-facing contract; the work underneath is synchronous. */
203
221
  class SqliteGateway implements DataSource {
204
222
  readonly #db: SqliteDb;
205
- constructor(db: SqliteDb) {
223
+ readonly #onInsert?: InsertObserver;
224
+ constructor(db: SqliteDb, onInsert?: InsertObserver) {
206
225
  this.#db = db;
226
+ this.#onInsert = onInsert;
207
227
  }
208
228
 
209
229
  async query<T extends object = Row>(sql: string, params: unknown[] = []): Promise<T[]> {
@@ -272,11 +292,13 @@ class SqliteGateway implements DataSource {
272
292
  }
273
293
 
274
294
  table<T extends object = Row>(name: string, pk = "id"): Table<T> {
275
- return new Table<T>(this, name, pk);
295
+ return new Table<T>(this, name, pk, this.#onInsert);
276
296
  }
277
297
  }
278
298
 
279
- /** Wrap a provisioned `SqliteDb` as the record-oriented `DataSource` gateway. */
280
- export function makeGateway(db: SqliteDb): DataSource {
281
- return new SqliteGateway(db);
299
+ /** Wrap a provisioned `SqliteDb` as the record-oriented `DataSource` gateway. An optional
300
+ * `onInsert` observer is invoked after every successful `Table.insert` (used for
301
+ * write-provenance capture); it is threaded to each `Table` this gateway creates. */
302
+ export function makeGateway(db: SqliteDb, onInsert?: InsertObserver): DataSource {
303
+ return new SqliteGateway(db, onInsert);
282
304
  }