@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.
- package/dist/runtime/adapters/deno.js +5 -0
- package/dist/runtime/adapters/node.js +5 -0
- package/dist/runtime/core/execContext.d.ts +28 -0
- package/dist/runtime/core/execContext.js +39 -0
- package/dist/runtime/core/host.d.ts +41 -0
- package/dist/runtime/core/manifest.d.ts +1 -1
- package/dist/runtime/core/modules/connectors.js +3 -1
- package/dist/runtime/core/modules/datasource.d.ts +18 -1
- package/dist/runtime/core/modules/datasource.js +56 -2
- package/dist/runtime/core/modules/gateway.d.ts +10 -3
- package/dist/runtime/core/modules/gateway.js +23 -6
- package/dist/runtime/core/modules/instance-tracking.d.ts +14 -0
- package/dist/runtime/core/modules/instance-tracking.js +141 -0
- package/dist/runtime/core/modules/pages.d.ts +6 -0
- package/dist/runtime/core/modules/pages.js +167 -13
- package/dist/runtime/core/modules/scheduler.d.ts +11 -0
- package/dist/runtime/core/modules/scheduler.js +21 -0
- package/dist/runtime/core/modules/triggers.d.ts +2 -8
- package/dist/runtime/core/modules/triggers.js +1 -16
- package/dist/runtime/core/modules/workers.js +3 -2
- package/dist/runtime/core/runtime.d.ts +3 -0
- package/dist/runtime/core/runtime.js +12 -0
- package/dist/runtime/core/validate.js +38 -0
- package/dist/runtime/engine/nanosdk.d.ts +14 -1
- package/dist/runtime/engine/nanosdk.js +49 -0
- package/package.json +2 -2
- package/src/runtime/adapters/deno.ts +6 -0
- package/src/runtime/adapters/node.ts +6 -0
- package/src/runtime/core/execContext.ts +56 -0
- package/src/runtime/core/host.ts +44 -0
- package/src/runtime/core/manifest.ts +1 -0
- package/src/runtime/core/modules/actions.test.ts +1 -0
- package/src/runtime/core/modules/connectors.test.ts +1 -0
- package/src/runtime/core/modules/connectors.ts +7 -1
- package/src/runtime/core/modules/datasource.migrations.test.ts +1 -0
- package/src/runtime/core/modules/datasource.ts +72 -2
- package/src/runtime/core/modules/gateway.ts +28 -6
- package/src/runtime/core/modules/instance-tracking.test.ts +345 -0
- package/src/runtime/core/modules/instance-tracking.ts +158 -0
- package/src/runtime/core/modules/pages.test.ts +77 -2
- package/src/runtime/core/modules/pages.ts +171 -13
- package/src/runtime/core/modules/provenance.test.ts +181 -0
- package/src/runtime/core/modules/scheduler.ts +31 -0
- package/src/runtime/core/modules/surfaces.test.ts +2 -0
- package/src/runtime/core/modules/triggers.test.ts +1 -0
- package/src/runtime/core/modules/triggers.ts +3 -25
- package/src/runtime/core/modules/workers.test.ts +1 -0
- package/src/runtime/core/modules/workers.ts +11 -2
- package/src/runtime/core/runtime.test.ts +3 -0
- package/src/runtime/core/runtime.ts +16 -0
- package/src/runtime/core/validate.test.ts +96 -0
- package/src/runtime/core/validate.ts +39 -0
- package/src/runtime/engine/nanosdk.test.ts +73 -0
- package/src/runtime/engine/nanosdk.ts +63 -0
|
@@ -13,6 +13,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
13
13
|
import { isAbsolute, resolve } from "node:path";
|
|
14
14
|
import { pathToFileURL } from "node:url";
|
|
15
15
|
import { DatabaseSync } from "node:sqlite";
|
|
16
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
16
17
|
function sqliteParams(params) {
|
|
17
18
|
return params.map((param) => {
|
|
18
19
|
if (param === null ||
|
|
@@ -103,6 +104,10 @@ export function createDenoHost(opts = {}) {
|
|
|
103
104
|
};
|
|
104
105
|
},
|
|
105
106
|
now: () => Date.now(),
|
|
107
|
+
createAsyncStore() {
|
|
108
|
+
const als = new AsyncLocalStorage();
|
|
109
|
+
return { run: (value, fn) => als.run(value, fn), current: () => als.getStore() };
|
|
110
|
+
},
|
|
106
111
|
log,
|
|
107
112
|
};
|
|
108
113
|
}
|
|
@@ -16,6 +16,7 @@ import { register } from "node:module";
|
|
|
16
16
|
import { isAbsolute, resolve } from "node:path";
|
|
17
17
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
18
18
|
import { DatabaseSync } from "node:sqlite";
|
|
19
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
19
20
|
function sqliteParams(params) {
|
|
20
21
|
return params.map((param) => {
|
|
21
22
|
if (param === null ||
|
|
@@ -154,6 +155,10 @@ export function createNodeHost(opts = {}) {
|
|
|
154
155
|
return { close: () => w.close() };
|
|
155
156
|
},
|
|
156
157
|
now: () => Date.now(),
|
|
158
|
+
createAsyncStore() {
|
|
159
|
+
const als = new AsyncLocalStorage();
|
|
160
|
+
return { run: (value, fn) => als.run(value, fn), current: () => als.getStore() };
|
|
161
|
+
},
|
|
157
162
|
log,
|
|
158
163
|
};
|
|
159
164
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { AsyncStore } from "./host.ts";
|
|
2
|
+
/** The ambient context of the job currently executing on a worker. All fields are optional so a
|
|
3
|
+
* job dispatched without an instance/element still runs; provenance is only recorded when an
|
|
4
|
+
* `instanceKey` is present. */
|
|
5
|
+
export interface JobExecContext {
|
|
6
|
+
/** The process instance that dispatched the running job. */
|
|
7
|
+
instanceKey?: string;
|
|
8
|
+
/** The BPMN element (e.g. service task) the job is for. */
|
|
9
|
+
elementId?: string;
|
|
10
|
+
/** The job type (worker) currently executing. */
|
|
11
|
+
jobType?: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Install the host-backed ambient store once per process. Idempotent — the first store created
|
|
15
|
+
* wins: every Node/Deno store is an equivalent `AsyncLocalStorage` instance, and a process that
|
|
16
|
+
* hosts several apps shares one async store safely (values are keyed by async execution context,
|
|
17
|
+
* not by app). A `factory` that returns undefined (a host without the capability) leaves capture
|
|
18
|
+
* disabled, but a later capable host may still install the process store.
|
|
19
|
+
*/
|
|
20
|
+
export declare function installExecStore(factory: () => AsyncStore<JobExecContext> | undefined): void;
|
|
21
|
+
/** Run `fn` with `ctx` as the ambient job context. A transparent pass-through when no store is
|
|
22
|
+
* installed (returns `fn()` directly). */
|
|
23
|
+
export declare function runInJobContext<R>(ctx: JobExecContext, fn: () => R): R;
|
|
24
|
+
/** The ambient job context of the current async execution, or undefined outside a job (or when
|
|
25
|
+
* no store is installed). */
|
|
26
|
+
export declare function currentJobContext(): JobExecContext | undefined;
|
|
27
|
+
/** Test seam: drop the installed store so a subsequent {@link installExecStore} re-installs. */
|
|
28
|
+
export declare function __resetExecStoreForTests(): void;
|
|
@@ -0,0 +1,39 @@
|
|
|
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
|
+
let store;
|
|
12
|
+
/**
|
|
13
|
+
* Install the host-backed ambient store once per process. Idempotent — the first store created
|
|
14
|
+
* wins: every Node/Deno store is an equivalent `AsyncLocalStorage` instance, and a process that
|
|
15
|
+
* hosts several apps shares one async store safely (values are keyed by async execution context,
|
|
16
|
+
* not by app). A `factory` that returns undefined (a host without the capability) leaves capture
|
|
17
|
+
* disabled, but a later capable host may still install the process store.
|
|
18
|
+
*/
|
|
19
|
+
export function installExecStore(factory) {
|
|
20
|
+
if (store)
|
|
21
|
+
return;
|
|
22
|
+
const next = factory();
|
|
23
|
+
if (next)
|
|
24
|
+
store = next;
|
|
25
|
+
}
|
|
26
|
+
/** Run `fn` with `ctx` as the ambient job context. A transparent pass-through when no store is
|
|
27
|
+
* installed (returns `fn()` directly). */
|
|
28
|
+
export function runInJobContext(ctx, fn) {
|
|
29
|
+
return store ? store.run(ctx, fn) : fn();
|
|
30
|
+
}
|
|
31
|
+
/** The ambient job context of the current async execution, or undefined outside a job (or when
|
|
32
|
+
* no store is installed). */
|
|
33
|
+
export function currentJobContext() {
|
|
34
|
+
return store?.current();
|
|
35
|
+
}
|
|
36
|
+
/** Test seam: drop the installed store so a subsequent {@link installExecStore} re-installs. */
|
|
37
|
+
export function __resetExecStoreForTests() {
|
|
38
|
+
store = undefined;
|
|
39
|
+
}
|
|
@@ -25,6 +25,16 @@ export interface HttpServer {
|
|
|
25
25
|
export interface WatchHandle {
|
|
26
26
|
close(): void;
|
|
27
27
|
}
|
|
28
|
+
/** A minimal ambient async-scoped store — the subset of `AsyncLocalStorage` the runtime needs
|
|
29
|
+
* to thread a job's execution context to DataLayer writes for write-provenance capture. Backed
|
|
30
|
+
* by `node:async_hooks` in both the Node and Deno adapters (both provide it); core depends only
|
|
31
|
+
* on this interface, never on `node:*`. */
|
|
32
|
+
export interface AsyncStore<T> {
|
|
33
|
+
/** Run `fn` with `value` as the current store value, restoring the prior value afterwards. */
|
|
34
|
+
run<R>(value: T, fn: () => R): R;
|
|
35
|
+
/** The current store value, or undefined outside any `run`. */
|
|
36
|
+
current(): T | undefined;
|
|
37
|
+
}
|
|
28
38
|
/** A tiny synchronous SQLite handle — the subset the runtime needs. */
|
|
29
39
|
export interface SqliteDb {
|
|
30
40
|
/** Execute one or more statements with no result (DDL, PRAGMA, migrations). */
|
|
@@ -81,6 +91,12 @@ export interface HostContext {
|
|
|
81
91
|
watch?(onChange: (path: string) => void): WatchHandle;
|
|
82
92
|
/** Current wall-clock time in ms since epoch (seam for tests). */
|
|
83
93
|
now(): number;
|
|
94
|
+
/**
|
|
95
|
+
* Create an ambient async-scoped store (backed by `AsyncLocalStorage`). Used to thread the
|
|
96
|
+
* active job's execution context to DataLayer writes for write-provenance capture. Optional:
|
|
97
|
+
* a host that cannot provide one leaves provenance capture disabled (absent-safe).
|
|
98
|
+
*/
|
|
99
|
+
createAsyncStore?<T>(): AsyncStore<T>;
|
|
84
100
|
/** Structured log sink. */
|
|
85
101
|
log(level: "info" | "warn" | "error", msg: string, fields?: Record<string, unknown>): void;
|
|
86
102
|
}
|
|
@@ -122,6 +138,19 @@ export interface WorkerSubscription {
|
|
|
122
138
|
readonly jobType: string;
|
|
123
139
|
unsubscribe(): Promise<void>;
|
|
124
140
|
}
|
|
141
|
+
/**
|
|
142
|
+
* A process instance's terminal-relevant lifecycle state, as the engine reports it.
|
|
143
|
+
* `ACTIVE` (still running), `COMPLETED` (ended normally at an end event), or
|
|
144
|
+
* `TERMINATED` (cancelled/terminated — via a row-cancel, an operator, or a crash).
|
|
145
|
+
* Deliberately the small set the instance-tracking reconciler keys on, not the richer
|
|
146
|
+
* element-instance state set (which adds SKIPPED/CANCELED/FAILED for tokens).
|
|
147
|
+
*/
|
|
148
|
+
export type ProcessInstanceState = "ACTIVE" | "COMPLETED" | "TERMINATED";
|
|
149
|
+
/** A single process instance's lifecycle snapshot returned by {@link EngineClient.searchProcessInstances}. */
|
|
150
|
+
export interface ProcessInstanceSnapshot {
|
|
151
|
+
readonly processInstanceKey: string;
|
|
152
|
+
readonly state: ProcessInstanceState;
|
|
153
|
+
}
|
|
125
154
|
/**
|
|
126
155
|
* EngineClient is the seam onto a Nano engine. The SDK/REST-backed adapter implements
|
|
127
156
|
* it against a live engine; tests implement it in-memory. Core modules depend only on this.
|
|
@@ -166,6 +195,18 @@ export interface EngineClient {
|
|
|
166
195
|
}[]>;
|
|
167
196
|
/** Complete a user task. */
|
|
168
197
|
completeUserTask(userTaskKey: string, variables?: Record<string, unknown>): Promise<void>;
|
|
198
|
+
/**
|
|
199
|
+
* Search process instances by key and/or lifecycle state. The instance-tracking
|
|
200
|
+
* reconciler uses this to detect a tracked instance reaching a terminal state
|
|
201
|
+
* (`TERMINATED`/`COMPLETED`) even though no completion worker ran for it — the
|
|
202
|
+
* read-model row it backs would otherwise stay "active" forever. An eventually
|
|
203
|
+
* consistent read (zero-wait); pass the keys currently marked active in the
|
|
204
|
+
* read model and, optionally, a `state` to narrow the result.
|
|
205
|
+
*/
|
|
206
|
+
searchProcessInstances(filter?: {
|
|
207
|
+
processInstanceKeys?: string[];
|
|
208
|
+
state?: ProcessInstanceState;
|
|
209
|
+
}): Promise<ProcessInstanceSnapshot[]>;
|
|
169
210
|
/** Register a push worker for a job type. Draining is handled by the adapter. */
|
|
170
211
|
registerWorker(jobType: string, handler: JobHandler, options?: {
|
|
171
212
|
workerName?: string;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { HostContext } from "./host.ts";
|
|
2
2
|
import type { AppManifest, Worker } from "@nanobpm/nano-app-schema";
|
|
3
|
-
export type { ActionDecl, AppManifest, AppUi, ChatSurface, Connection, Data, DataSource, DomainField, DomainType, LlmBinding, Models, PagesSurface, Runtime, Security, Surfaces, TaskInboxSurface, Trigger, TriggerAction, Worker, } from "@nanobpm/nano-app-schema";
|
|
3
|
+
export type { ActionDecl, AppManifest, AppUi, ChatSurface, Connection, Data, DataSource, DomainField, DomainType, InstanceTracking, LlmBinding, Models, PagesSurface, Runtime, Security, Surfaces, TaskInboxSurface, Trigger, TriggerAction, Worker, } from "@nanobpm/nano-app-schema";
|
|
4
4
|
/** The job type a worker subscribes to (schema key: `taskType`). */
|
|
5
5
|
export declare function workerJobType(w: Worker): string | undefined;
|
|
6
6
|
/**
|
|
@@ -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
|
|
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,7 @@
|
|
|
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
5
|
export declare function sqlitePathFromUrl(url: string): string;
|
|
6
6
|
/** True when `p` is already an absolute path on any host `urban data` targets. `urban data` runs
|
|
7
7
|
* on a Node host that may be Windows, so absolute-path detection can't assume POSIX: this matches
|
|
@@ -29,6 +29,23 @@ export declare function resolveAppPath(root: string, p: string): string;
|
|
|
29
29
|
* for this identifier: `applyMigrations` writes it and `dataops`' `migrations` op reads it, so
|
|
30
30
|
* both import this constant and the ledger name can never drift between application and listing. */
|
|
31
31
|
export declare const MIGRATIONS_TABLE = "_urban_migrations";
|
|
32
|
+
/** Name of the urban-owned write-provenance sidecar table (ProcessOS domain-signal plane, D0).
|
|
33
|
+
* One append per app `Table.insert` performed inside a job, linking the written row —
|
|
34
|
+
* `(source, table_name, pk_value)` — to the process instance/element that wrote it. Follows the
|
|
35
|
+
* `_urban_` convention so it is automatically excluded from domain-model introspection,
|
|
36
|
+
* DB Manager, and forms (see `gateway.ts` `schema()`), and never collides with an app table. */
|
|
37
|
+
export declare const WRITE_PROVENANCE_TABLE = "_urban_write_provenance";
|
|
38
|
+
/** Provision the write-provenance sidecar on a source (idempotent). Domain-free: it records only
|
|
39
|
+
* the join key + timestamps, never any row content. */
|
|
40
|
+
export declare function ensureProvenanceTable(db: SqliteDb): void;
|
|
41
|
+
/** Build the {@link InsertObserver} that captures write-provenance for one source. Best-effort:
|
|
42
|
+
* it records a row only when a job execution context with an instance key is active (a worker
|
|
43
|
+
* handler is running) — writes outside a job (e.g. HTTP actions) record nothing, degrading
|
|
44
|
+
* gracefully. It skips urban/engine-internal tables so the provenance table never records
|
|
45
|
+
* itself. It runs on the same connection as the insert, so inside a `tx` the provenance row is
|
|
46
|
+
* committed/rolled back atomically with the app write. `Table.insert` wraps this defensively, so
|
|
47
|
+
* a throw here can never break the app insert. */
|
|
48
|
+
export declare function makeProvenanceRecorder(host: HostContext, db: SqliteDb, source: string): InsertObserver;
|
|
32
49
|
/** Resolve a datasource `url` to its on-disk SQLite path against `root`. The result is absolute
|
|
33
50
|
* when the resolved path is absolute (either because `url` names an absolute path or `root` is
|
|
34
51
|
* absolute); if `root` is relative (e.g. "." as used by the CLI/tests) a relative `url` stays
|
|
@@ -2,7 +2,8 @@
|
|
|
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";
|
|
6
7
|
export function sqlitePathFromUrl(url) {
|
|
7
8
|
// Accept "file:./x.db", "file:x.db", "sqlite:./x.db" or a bare path.
|
|
8
9
|
return url.replace(/^(file|sqlite):(\/\/)?/, "");
|
|
@@ -43,6 +44,54 @@ export function resolveAppPath(root, p) {
|
|
|
43
44
|
* for this identifier: `applyMigrations` writes it and `dataops`' `migrations` op reads it, so
|
|
44
45
|
* both import this constant and the ledger name can never drift between application and listing. */
|
|
45
46
|
export const MIGRATIONS_TABLE = "_urban_migrations";
|
|
47
|
+
/** Name of the urban-owned write-provenance sidecar table (ProcessOS domain-signal plane, D0).
|
|
48
|
+
* One append per app `Table.insert` performed inside a job, linking the written row —
|
|
49
|
+
* `(source, table_name, pk_value)` — to the process instance/element that wrote it. Follows the
|
|
50
|
+
* `_urban_` convention so it is automatically excluded from domain-model introspection,
|
|
51
|
+
* DB Manager, and forms (see `gateway.ts` `schema()`), and never collides with an app table. */
|
|
52
|
+
export const WRITE_PROVENANCE_TABLE = "_urban_write_provenance";
|
|
53
|
+
/** Provision the write-provenance sidecar on a source (idempotent). Domain-free: it records only
|
|
54
|
+
* the join key + timestamps, never any row content. */
|
|
55
|
+
export function ensureProvenanceTable(db) {
|
|
56
|
+
db.exec(`CREATE TABLE IF NOT EXISTS ${WRITE_PROVENANCE_TABLE} (` +
|
|
57
|
+
"seq INTEGER PRIMARY KEY AUTOINCREMENT, " +
|
|
58
|
+
"source TEXT NOT NULL, " +
|
|
59
|
+
"table_name TEXT NOT NULL, " +
|
|
60
|
+
"pk_value TEXT NOT NULL, " +
|
|
61
|
+
"instance_key TEXT, " +
|
|
62
|
+
"element_id TEXT, " +
|
|
63
|
+
"job_type TEXT, " +
|
|
64
|
+
"op TEXT NOT NULL DEFAULT 'insert', " +
|
|
65
|
+
"at TEXT NOT NULL)");
|
|
66
|
+
}
|
|
67
|
+
/** Build the {@link InsertObserver} that captures write-provenance for one source. Best-effort:
|
|
68
|
+
* it records a row only when a job execution context with an instance key is active (a worker
|
|
69
|
+
* handler is running) — writes outside a job (e.g. HTTP actions) record nothing, degrading
|
|
70
|
+
* gracefully. It skips urban/engine-internal tables so the provenance table never records
|
|
71
|
+
* itself. It runs on the same connection as the insert, so inside a `tx` the provenance row is
|
|
72
|
+
* committed/rolled back atomically with the app write. `Table.insert` wraps this defensively, so
|
|
73
|
+
* a throw here can never break the app insert. */
|
|
74
|
+
export function makeProvenanceRecorder(host, db, source) {
|
|
75
|
+
return (table, pk) => {
|
|
76
|
+
if (table.startsWith("_urban_") || table.startsWith("_nano_") || table.startsWith("sqlite_")) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const ctx = currentJobContext();
|
|
80
|
+
if (!ctx || ctx.instanceKey == null)
|
|
81
|
+
return;
|
|
82
|
+
db.run(`INSERT INTO ${WRITE_PROVENANCE_TABLE} ` +
|
|
83
|
+
"(source, table_name, pk_value, instance_key, element_id, job_type, op, at) " +
|
|
84
|
+
"VALUES (?, ?, ?, ?, ?, ?, 'insert', ?)", [
|
|
85
|
+
source,
|
|
86
|
+
table,
|
|
87
|
+
String(pk),
|
|
88
|
+
ctx.instanceKey,
|
|
89
|
+
ctx.elementId ?? null,
|
|
90
|
+
ctx.jobType ?? null,
|
|
91
|
+
new Date(host.now()).toISOString(),
|
|
92
|
+
]);
|
|
93
|
+
};
|
|
94
|
+
}
|
|
46
95
|
/** Resolve a datasource `url` to its on-disk SQLite path against `root`. The result is absolute
|
|
47
96
|
* when the resolved path is absolute (either because `url` names an absolute path or `root` is
|
|
48
97
|
* absolute); if `root` is relative (e.g. "." as used by the CLI/tests) a relative `url` stays
|
|
@@ -256,6 +305,11 @@ async function provisionSqlite(ctx, name, src) {
|
|
|
256
305
|
const migrationsApplied = src.migrations
|
|
257
306
|
? await applyMigrations(ctx.host, db, ctx.root, src.migrations)
|
|
258
307
|
: [];
|
|
308
|
+
// Provision the write-provenance sidecar and wire the gateway's insert observer to it, so
|
|
309
|
+
// every app `Table.insert` performed inside a job is linked to its process instance/element
|
|
310
|
+
// (ProcessOS domain-signal capture, D0). Domain-free and non-invasive — the sidecar lives
|
|
311
|
+
// outside the domain model.
|
|
312
|
+
ensureProvenanceTable(db);
|
|
259
313
|
ctx.host.log("info", `datasource: provisioned "${name}"`, {
|
|
260
314
|
driver: "sqlite",
|
|
261
315
|
path: resolveSqlitePath(ctx.root, src.url),
|
|
@@ -265,7 +319,7 @@ async function provisionSqlite(ctx, name, src) {
|
|
|
265
319
|
name,
|
|
266
320
|
driver: "sqlite",
|
|
267
321
|
db,
|
|
268
|
-
source: makeGateway(db),
|
|
322
|
+
source: makeGateway(db, makeProvenanceRecorder(ctx.host, db, name)),
|
|
269
323
|
migrationsApplied,
|
|
270
324
|
close: () => db.close(),
|
|
271
325
|
};
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
175
|
-
|
|
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,14 @@
|
|
|
1
|
+
import type { AppApi, Mounted, RuntimeContext } from "../context.ts";
|
|
2
|
+
import { type SchedulerDeps } from "./scheduler.ts";
|
|
3
|
+
/** Default poll interval when a binding does not set `pollMs`. */
|
|
4
|
+
export declare const DEFAULT_INSTANCE_TRACKING_POLL_MS = 15000;
|
|
5
|
+
export interface InstanceTrackingHandle extends Mounted {
|
|
6
|
+
/** `table.keyField` labels of the armed bindings, for `inspect()`. */
|
|
7
|
+
readonly bindings: string[];
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Mount the `instanceTracking` reconciler: arm one poll loop per binding. Each loop selects
|
|
11
|
+
* the active rows, asks the engine which of their instances are terminated, and applies the
|
|
12
|
+
* declared patch. `stop()` cancels every armed timer.
|
|
13
|
+
*/
|
|
14
|
+
export declare function mountInstanceTracking(ctx: RuntimeContext, api: AppApi, sched?: SchedulerDeps): InstanceTrackingHandle;
|
|
@@ -0,0 +1,141 @@
|
|
|
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
|
+
/** Poll one binding's instances once. Selects the active rows, asks the engine which of their
|
|
19
|
+
* instances are TERMINATED, and applies `onTerminated.set` to each. Returns how many keys were
|
|
20
|
+
* scanned and how many rows were reconciled this tick. */
|
|
21
|
+
async function reconcileOnce(api, binding) {
|
|
22
|
+
const table = api.data.table(binding.table, binding.keyField);
|
|
23
|
+
// Select the rows worth polling. With a statusField + activeStatuses, only rows in an
|
|
24
|
+
// active status are candidates, so a row already in a terminal status is skipped; without
|
|
25
|
+
// them every row is polled and a terminal instance's row is idempotently re-patched (the
|
|
26
|
+
// engine query returns only TERMINATED instances and the patch is a no-op once applied).
|
|
27
|
+
let candidates;
|
|
28
|
+
const statusField = binding.statusField;
|
|
29
|
+
if (statusField && binding.activeStatuses && binding.activeStatuses.length > 0) {
|
|
30
|
+
const perStatus = await Promise.all(binding.activeStatuses.map((s) => table.find({ [statusField]: s })));
|
|
31
|
+
candidates = perStatus.flat();
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
candidates = await table.all();
|
|
35
|
+
}
|
|
36
|
+
// Map each active row to its tracked instance key. A row with no/empty key can't be
|
|
37
|
+
// reconciled (it never started an instance, or the column is unset) — skip it.
|
|
38
|
+
const keyToRows = new Map();
|
|
39
|
+
for (const row of candidates) {
|
|
40
|
+
const raw = row[binding.keyField];
|
|
41
|
+
if (raw == null || raw === "")
|
|
42
|
+
continue;
|
|
43
|
+
const key = String(raw);
|
|
44
|
+
const list = keyToRows.get(key);
|
|
45
|
+
if (list)
|
|
46
|
+
list.push(row);
|
|
47
|
+
else
|
|
48
|
+
keyToRows.set(key, [row]);
|
|
49
|
+
}
|
|
50
|
+
const keys = [...keyToRows.keys()];
|
|
51
|
+
if (keys.length === 0)
|
|
52
|
+
return { scanned: 0, reconciled: 0 };
|
|
53
|
+
const snapshots = await api.engine.searchProcessInstances({
|
|
54
|
+
processInstanceKeys: keys,
|
|
55
|
+
state: "TERMINATED",
|
|
56
|
+
});
|
|
57
|
+
const patch = binding.onTerminated.set;
|
|
58
|
+
let reconciled = 0;
|
|
59
|
+
for (const snap of snapshots) {
|
|
60
|
+
if (snap.state !== "TERMINATED")
|
|
61
|
+
continue; // defensive; we asked for TERMINATED only
|
|
62
|
+
if (!keyToRows.has(snap.processInstanceKey))
|
|
63
|
+
continue; // not one we were tracking
|
|
64
|
+
const changed = await table.update(snap.processInstanceKey, patch);
|
|
65
|
+
if (changed > 0) {
|
|
66
|
+
reconciled += changed;
|
|
67
|
+
api.log("info", "instanceTracking: reconciled terminated instance", {
|
|
68
|
+
table: binding.table,
|
|
69
|
+
keyField: binding.keyField,
|
|
70
|
+
processInstanceKey: snap.processInstanceKey,
|
|
71
|
+
rowsChanged: changed,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return { scanned: keys.length, reconciled };
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Mount the `instanceTracking` reconciler: arm one poll loop per binding. Each loop selects
|
|
79
|
+
* the active rows, asks the engine which of their instances are terminated, and applies the
|
|
80
|
+
* declared patch. `stop()` cancels every armed timer.
|
|
81
|
+
*/
|
|
82
|
+
export function mountInstanceTracking(ctx, api, sched = defaultScheduler()) {
|
|
83
|
+
const bindings = ctx.manifest.instanceTracking ?? [];
|
|
84
|
+
const timers = new Set();
|
|
85
|
+
const armed = [];
|
|
86
|
+
let stopped = false;
|
|
87
|
+
for (const binding of bindings) {
|
|
88
|
+
// `pollMs` feeds a self-rescheduling timer, so a non-number/NaN/zero/negative value would
|
|
89
|
+
// clamp to a 0-delay hot loop. `??` only guards null/undefined; sanitize to a finite positive
|
|
90
|
+
// number and otherwise fall back to the default. (Manifest validation rejects bad values up
|
|
91
|
+
// front; this is belt-and-suspenders for a binding mounted without validation.)
|
|
92
|
+
const rawPoll = binding.pollMs;
|
|
93
|
+
const pollMs = typeof rawPoll === "number" && Number.isFinite(rawPoll) && rawPoll > 0
|
|
94
|
+
? rawPoll
|
|
95
|
+
: DEFAULT_INSTANCE_TRACKING_POLL_MS;
|
|
96
|
+
// A single setTimeout delay overflows its 32-bit signed range (~24.8 days) and fires
|
|
97
|
+
// immediately — which for a self-rescheduling poll would become a hot loop. Clamp through
|
|
98
|
+
// the one shared constant the cron trigger loop uses (derive-don't-duplicate).
|
|
99
|
+
const delayMs = Math.min(pollMs, MAX_TIMER_DELAY_MS);
|
|
100
|
+
const label = `${binding.table}.${binding.keyField}`;
|
|
101
|
+
armed.push(label);
|
|
102
|
+
const arm = () => {
|
|
103
|
+
if (stopped)
|
|
104
|
+
return;
|
|
105
|
+
let handle;
|
|
106
|
+
handle = sched.setTimer(() => {
|
|
107
|
+
timers.delete(handle);
|
|
108
|
+
if (stopped)
|
|
109
|
+
return;
|
|
110
|
+
void (async () => {
|
|
111
|
+
try {
|
|
112
|
+
await reconcileOnce(api, binding);
|
|
113
|
+
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
api.log("error", `instanceTracking "${label}": reconcile failed`, {
|
|
116
|
+
error: String(err),
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
arm(); // reschedule regardless of outcome (at-least-once, idempotent)
|
|
121
|
+
}
|
|
122
|
+
})();
|
|
123
|
+
}, delayMs);
|
|
124
|
+
timers.add(handle);
|
|
125
|
+
};
|
|
126
|
+
arm();
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
name: "instanceTracking",
|
|
130
|
+
bindings: armed,
|
|
131
|
+
async stop() {
|
|
132
|
+
stopped = true;
|
|
133
|
+
for (const h of timers)
|
|
134
|
+
sched.clearTimer(h);
|
|
135
|
+
timers.clear();
|
|
136
|
+
},
|
|
137
|
+
describe() {
|
|
138
|
+
return { bindings: armed };
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
|
@@ -23,6 +23,12 @@ export interface PagesDeps {
|
|
|
23
23
|
engine: EngineClient;
|
|
24
24
|
/** Read a page file; injectable for tests. */
|
|
25
25
|
readPage(path: string): Promise<string>;
|
|
26
|
+
/**
|
|
27
|
+
* List the available page ids (the `*.page.json` basenames under `pagesDir`),
|
|
28
|
+
* powering the `/app/pages` index endpoint and a `nav` node's `items: "auto"`.
|
|
29
|
+
* Optional; when absent the index is empty. Injectable for tests.
|
|
30
|
+
*/
|
|
31
|
+
listPages?(): Promise<string[]>;
|
|
26
32
|
}
|
|
27
33
|
/**
|
|
28
34
|
* Build the page-runtime routes over an injected datasource + engine. Pure over its deps
|