@nanobpm/urban 0.77.4 → 0.79.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.
@@ -0,0 +1,156 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { readFileSync } from "node:fs";
4
+ import { fileURLToPath } from "node:url";
5
+ import { mkdtemp, rm } from "node:fs/promises";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
8
+ import { createNodeHost } from "../../adapters/node.ts";
9
+ import type { SqliteDb } from "../host.ts";
10
+ import type { UserTaskSummary } from "../host.ts";
11
+ import { makeGateway } from "./gateway.ts";
12
+ import {
13
+ OPEN_USER_TASKS_PROJECTION,
14
+ OPEN_USER_TASKS_SCHEMA_SQL,
15
+ OPEN_USER_TASKS_TABLE,
16
+ OpenUserTasksStore,
17
+ } from "./open-user-tasks-store.ts";
18
+
19
+ async function withStore(fn: (store: OpenUserTasksStore, db: SqliteDb) => void | Promise<void>): Promise<void> {
20
+ const dir = await mkdtemp(join(tmpdir(), "urban-open-tasks-"));
21
+ const host = createNodeHost({ cwd: dir, log: () => {} });
22
+ const db = host.openSqlite(join(dir, "test.db"));
23
+ const store = new OpenUserTasksStore(db, { clock: { now: () => 0 } });
24
+ store.ensureSchema();
25
+ try {
26
+ await fn(store, db);
27
+ } finally {
28
+ db.close();
29
+ await rm(dir, { recursive: true, force: true });
30
+ }
31
+ }
32
+
33
+ function normaliseSql(sql: string): string {
34
+ return sql
35
+ .split("\n")
36
+ .map((line) => line.replace(/--.*$/, ""))
37
+ .join("\n")
38
+ .replace(/\s+/g, " ")
39
+ .trim();
40
+ }
41
+
42
+ const task = (userTaskKey: string, elementId?: string): UserTaskSummary => ({ userTaskKey, elementId });
43
+
44
+ const migrationPath = fileURLToPath(
45
+ new URL("../../../../../../db/migrations/006_urban_open_user_tasks.sql", import.meta.url),
46
+ );
47
+
48
+ test("the open-user-tasks projection table is framework bookkeeping — `_urban_`-prefixed and hidden from the domain model", async () => {
49
+ assert.match(OPEN_USER_TASKS_TABLE, /^_urban_/, "projection table must be `_urban_`-prefixed to stay hidden");
50
+ await withStore(async (_store, db) => {
51
+ const surfaced = (await makeGateway(db).schema()).map((t) => t.name);
52
+ assert.ok(!surfaced.includes(OPEN_USER_TASKS_TABLE), "projection table must not surface in the domain model");
53
+ });
54
+ });
55
+
56
+ test("the boot migration and OpenUserTasksStore's DDL do not drift", () => {
57
+ const migrationSql = readFileSync(migrationPath, "utf8");
58
+ assert.equal(
59
+ normaliseSql(migrationSql),
60
+ normaliseSql(OPEN_USER_TASKS_SCHEMA_SQL),
61
+ "db/migrations/006_urban_open_user_tasks.sql must match OPEN_USER_TASKS_SCHEMA_SQL — update both together",
62
+ );
63
+ });
64
+
65
+ test("the boot migration is forward-only and additive (IF NOT EXISTS, no drops/alters)", () => {
66
+ const migrationSql = readFileSync(migrationPath, "utf8");
67
+ assert.match(migrationSql, /CREATE TABLE IF NOT EXISTS _urban_open_user_tasks/);
68
+ assert.doesNotMatch(migrationSql, /\bDROP\b/i);
69
+ assert.doesNotMatch(migrationSql, /\bALTER\b/i);
70
+ assert.match(migrationPath, /[/\\]006_urban_open_user_tasks\.sql$/);
71
+ });
72
+
73
+ test("ensureSchema creates the projection table idempotently", async () => {
74
+ await withStore((store, db) => {
75
+ store.ensureSchema();
76
+ store.ensureSchema();
77
+ const tables = db
78
+ .all<{ name: string }>("SELECT name FROM sqlite_master WHERE type='table' AND name = ?", [OPEN_USER_TASKS_TABLE])
79
+ .map((r) => r.name);
80
+ assert.deepEqual(tables, [OPEN_USER_TASKS_TABLE]);
81
+ });
82
+ });
83
+
84
+ test("recordOpenTask is idempotent (a re-record of the same user task is a no-op)", async () => {
85
+ await withStore((store) => {
86
+ assert.equal(store.recordOpenTask("pi-1", task("ut-1", "Approve")), true);
87
+ assert.equal(store.recordOpenTask("pi-1", task("ut-1", "Approve")), false, "re-record collapses to a no-op");
88
+ assert.equal(store.openTasks("pi-1").length, 1);
89
+ assert.equal(store.recordOpenTask("", task("ut-x")), false, "blank instance key is ignored");
90
+ assert.equal(store.recordOpenTask("pi-1", task("")), false, "blank user task key is ignored");
91
+ });
92
+ });
93
+
94
+ test("syncInstance makes the projection reflect the engine's CURRENT open set (retiring closed tasks)", async () => {
95
+ await withStore((store) => {
96
+ // Two open tasks initially.
97
+ store.syncInstance("pi-1", [task("ut-1", "Approve"), task("ut-2", "Review")]);
98
+ assert.deepEqual(store.openTasks("pi-1").map((t) => t.userTaskKey).sort(), ["ut-1", "ut-2"]);
99
+ assert.equal(store.hasOpenTask("pi-1"), true);
100
+
101
+ // ut-1 answered/closed, a new ut-3 created → engine now reports {ut-2, ut-3}.
102
+ store.syncInstance("pi-1", [task("ut-2", "Review"), task("ut-3", "Sign")]);
103
+ assert.deepEqual(store.openTasks("pi-1").map((t) => t.userTaskKey).sort(), ["ut-2", "ut-3"]);
104
+
105
+ // All answered → no open tasks: the instance is no longer parked on a human (nano-workforce#422).
106
+ store.syncInstance("pi-1", []);
107
+ assert.equal(store.hasOpenTask("pi-1"), false);
108
+ assert.deepEqual(store.openTasks("pi-1"), []);
109
+ });
110
+ });
111
+
112
+ test("syncInstance is idempotent and scoped to one instance", async () => {
113
+ await withStore((store) => {
114
+ store.syncInstance("pi-1", [task("ut-1")]);
115
+ store.syncInstance("pi-2", [task("ut-2")]);
116
+ // Re-syncing pi-1's unchanged open set must not touch pi-2.
117
+ store.syncInstance("pi-1", [task("ut-1")]);
118
+ assert.deepEqual(store.openTasks("pi-1").map((t) => t.userTaskKey), ["ut-1"]);
119
+ assert.deepEqual(store.openTasks("pi-2").map((t) => t.userTaskKey), ["ut-2"]);
120
+ });
121
+ });
122
+
123
+ test("clearInstance retires every open task for an instance", async () => {
124
+ await withStore((store) => {
125
+ store.syncInstance("pi-1", [task("ut-1"), task("ut-2")]);
126
+ store.clearInstance("pi-1");
127
+ assert.equal(store.hasOpenTask("pi-1"), false);
128
+ });
129
+ });
130
+
131
+ test("syncInstance is atomic — it composes inside an existing transaction and rolls back on failure", async () => {
132
+ await withStore((store, db) => {
133
+ // Composes inside an outer transaction (SAVEPOINT, not BEGIN): the whole replace commits as one unit.
134
+ db.exec("BEGIN");
135
+ store.syncInstance("pi-1", [task("ut-1"), task("ut-2")]);
136
+ db.exec("COMMIT");
137
+ assert.deepEqual(store.openTasks("pi-1").map((t) => t.userTaskKey).sort(), ["ut-1", "ut-2"]);
138
+
139
+ // A failure mid-sync must roll the whole operation back, leaving the prior open set intact rather
140
+ // than a torn partial state. A subclass that throws from recordOpenTask forces a mid-loop failure.
141
+ const boom = new Error("boom");
142
+ class ThrowingStore extends OpenUserTasksStore {
143
+ override recordOpenTask(): boolean {
144
+ throw boom;
145
+ }
146
+ }
147
+ const broken = new ThrowingStore(db, { clock: { now: () => 0 } });
148
+ assert.throws(() => broken.syncInstance("pi-1", [task("ut-9")]), boom);
149
+ // The prior open set is unchanged — nothing was half-written or half-deleted.
150
+ assert.deepEqual(store.openTasks("pi-1").map((t) => t.userTaskKey).sort(), ["ut-1", "ut-2"]);
151
+ });
152
+ });
153
+
154
+ test("the DSL projection name is stable and unprefixed (usable in `exists(...)`)", () => {
155
+ assert.equal(OPEN_USER_TASKS_PROJECTION, "urban_open_user_tasks");
156
+ });
@@ -0,0 +1,172 @@
1
+ // open-user-tasks-store — the canonical engine-truth projection of "which process instances are
2
+ // parked on a human" (ADR 0065, proposal point #1). A framework-level, per-source sidecar (exactly
3
+ // like the lineage store) that materialises the set of currently-open (CREATED-state) user tasks per
4
+ // process instance from engine truth — the same `openUserTasks` / `searchUserTasks({state:"CREATED"})`
5
+ // query the `instanceTracking` reconciler already runs.
6
+ //
7
+ // This ABSORBS nano-workforce's hand-rolled `user_tasks` table: it is the ONE authoritative projection
8
+ // a read model can `EXISTS`-over to derive the "waiting-on-human" status edge, instead of an app
9
+ // hand-maintaining a stored `awaiting_operator` column (the tearing surface of nano-workforce#422).
10
+ //
11
+ // It is framework bookkeeping, so the physical table is `_urban_`-prefixed to stay hidden from the
12
+ // domain model / DB Manager (see `SqliteGateway.schema()`), like the lineage tables — and it is NEVER
13
+ // app-written. Recording is idempotent and authoritative-per-instance: {@link OpenUserTasksStore.syncInstance}
14
+ // replaces an instance's rows with exactly the engine's current open set, so an answered/closed task
15
+ // disappears on the next poll and the derived edge re-flips by construction (No Drift Surfaces).
16
+
17
+ import type { SqliteDb } from "../host.ts";
18
+ import type { UserTaskSummary } from "../host.ts";
19
+ import { type Clock, systemClock } from "./lineage-store.ts";
20
+
21
+ /** The open-user-tasks projection table. Framework bookkeeping, so `_urban_`-prefixed to stay hidden
22
+ * from the domain model / DB Manager (see `SqliteGateway.schema()`), like the lineage tables. */
23
+ export const OPEN_USER_TASKS_TABLE = "_urban_open_user_tasks";
24
+
25
+ /** The stable DSL projection NAME a `defineReadModel` `exists(...)` references (registered in the
26
+ * `projectionRegistry`, mapped to {@link OPEN_USER_TASKS_TABLE}). The DSL name is unprefixed so a read
27
+ * model reads `EXISTS(urban_open_user_tasks WHERE process_instance_key = base.process_instance_key)`. */
28
+ export const OPEN_USER_TASKS_PROJECTION = "urban_open_user_tasks";
29
+
30
+ /**
31
+ * The canonical open-user-tasks DDL — the single source of truth applied by
32
+ * {@link OpenUserTasksStore.ensureSchema}, mirrored verbatim by the boot migration
33
+ * `db/migrations/006_urban_open_user_tasks.sql` (a drift-guard test asserts they match).
34
+ *
35
+ * Forward-only and additive. Keyed by `user_task_key` so a re-record of the same open task is a no-op;
36
+ * `process_instance_key` carries an index so a read model's correlated `EXISTS(... WHERE
37
+ * process_instance_key = …)` and the reconciler's per-instance sync read only that instance's rows.
38
+ */
39
+ export const OPEN_USER_TASKS_SCHEMA_SQL = `CREATE TABLE IF NOT EXISTS ${OPEN_USER_TASKS_TABLE} (
40
+ process_instance_key TEXT NOT NULL,
41
+ user_task_key TEXT NOT NULL,
42
+ element_id TEXT,
43
+ created_at TEXT NOT NULL,
44
+ PRIMARY KEY (user_task_key)
45
+ );
46
+ CREATE INDEX IF NOT EXISTS idx_${OPEN_USER_TASKS_TABLE}_instance ON ${OPEN_USER_TASKS_TABLE} (process_instance_key);`;
47
+
48
+ export interface OpenUserTasksStoreOptions {
49
+ readonly clock?: Clock;
50
+ }
51
+
52
+ /** One projected open-user-task row, as {@link OpenUserTasksStore.openTasks} returns it. */
53
+ export interface OpenUserTaskRow {
54
+ readonly processInstanceKey: string;
55
+ readonly userTaskKey: string;
56
+ readonly elementId?: string;
57
+ }
58
+
59
+ interface OpenTaskDbRow {
60
+ process_instance_key: string;
61
+ user_task_key: string;
62
+ element_id: string | null;
63
+ }
64
+
65
+ /**
66
+ * The per-source sidecar behind the `urban_open_user_tasks` projection. A thin, idempotent
67
+ * persistence + query layer over the engine's open-user-task truth; it interprets no app schema.
68
+ */
69
+ export class OpenUserTasksStore {
70
+ readonly #db: SqliteDb;
71
+ readonly #clock: Clock;
72
+
73
+ constructor(db: SqliteDb, options: OpenUserTasksStoreOptions = {}) {
74
+ this.#db = db;
75
+ this.#clock = options.clock ?? systemClock;
76
+ }
77
+
78
+ /** Apply the canonical DDL (idempotent). Callers whose app DataLayer applies the boot migration
79
+ * `db/migrations/006_urban_open_user_tasks.sql` do not need this; the runtime calls it so the store
80
+ * is usable against a bare source too. The DDL is identical to the migration (drift-guarded). */
81
+ ensureSchema(): void {
82
+ this.#db.exec(OPEN_USER_TASKS_SCHEMA_SQL);
83
+ }
84
+
85
+ /**
86
+ * Record one open user task for an instance, idempotently. A re-record of the same `userTaskKey` is
87
+ * a no-op. A blank `processInstanceKey` or `userTaskKey` is ignored. Prefer {@link syncInstance} to
88
+ * record an instance's WHOLE current open set (which also retires closed tasks); this single-row
89
+ * form is the idempotent building block.
90
+ */
91
+ recordOpenTask(processInstanceKey: string, task: UserTaskSummary): boolean {
92
+ if (!processInstanceKey || !task.userTaskKey) return false;
93
+ const { changes } = this.#db.run(
94
+ `INSERT OR IGNORE INTO ${OPEN_USER_TASKS_TABLE}
95
+ (process_instance_key, user_task_key, element_id, created_at)
96
+ VALUES (?, ?, ?, ?)`,
97
+ [processInstanceKey, task.userTaskKey, task.elementId ?? null, new Date(this.#clock.now()).toISOString()],
98
+ );
99
+ return changes > 0;
100
+ }
101
+
102
+ /**
103
+ * Make the projection reflect an instance's CURRENT open set exactly: insert the engine's open tasks
104
+ * and delete any previously-projected task for the instance that is no longer open. This is the
105
+ * authoritative record from engine truth (`openUserTasks({ processInstanceKey })`), the operation the
106
+ * reconciler drives each poll — so an answered/closed task disappears and the derived
107
+ * "waiting-on-human" edge re-flips by construction (nano-workforce#422). Idempotent: syncing the same
108
+ * open set twice leaves the table unchanged. A blank `processInstanceKey` is ignored.
109
+ */
110
+ syncInstance(processInstanceKey: string, openTasks: readonly UserTaskSummary[]): void {
111
+ if (!processInstanceKey) return;
112
+ // Wrap the insert+delete replace in a SAVEPOINT so the whole sync is atomic: no reader observes a
113
+ // transient mixed state mid-sync, and a mid-loop failure rolls back cleanly instead of leaving the
114
+ // instance with a torn open set. SAVEPOINT (not BEGIN) so this also composes when called inside an
115
+ // existing transaction, e.g. a reconciler poll that batches many instances.
116
+ this.#db.exec("SAVEPOINT urban_sync_open_tasks");
117
+ try {
118
+ const keep = new Set<string>();
119
+ for (const task of openTasks) {
120
+ if (!task.userTaskKey) continue;
121
+ keep.add(task.userTaskKey);
122
+ this.recordOpenTask(processInstanceKey, task);
123
+ }
124
+ const existing = this.#db.all<{ user_task_key: string }>(
125
+ `SELECT user_task_key FROM ${OPEN_USER_TASKS_TABLE} WHERE process_instance_key = ?`,
126
+ [processInstanceKey],
127
+ );
128
+ for (const row of existing) {
129
+ if (!keep.has(row.user_task_key)) {
130
+ this.#db.run(`DELETE FROM ${OPEN_USER_TASKS_TABLE} WHERE user_task_key = ?`, [row.user_task_key]);
131
+ }
132
+ }
133
+ this.#db.exec("RELEASE urban_sync_open_tasks");
134
+ } catch (err) {
135
+ this.#db.exec("ROLLBACK TO urban_sync_open_tasks");
136
+ this.#db.exec("RELEASE urban_sync_open_tasks");
137
+ throw err;
138
+ }
139
+ }
140
+
141
+ /** Retire every projected open task for an instance (e.g. it terminated). Idempotent. */
142
+ clearInstance(processInstanceKey: string): void {
143
+ if (!processInstanceKey) return;
144
+ this.#db.run(`DELETE FROM ${OPEN_USER_TASKS_TABLE} WHERE process_instance_key = ?`, [processInstanceKey]);
145
+ }
146
+
147
+ /** True iff the instance currently has any open user task — the "parked on a human" predicate. */
148
+ hasOpenTask(processInstanceKey: string): boolean {
149
+ if (!processInstanceKey) return false;
150
+ // Existence predicate: `SELECT 1 … LIMIT 1` short-circuits on the first matching row rather than
151
+ // counting every open task for the instance.
152
+ const rows = this.#db.all<{ one: number }>(
153
+ `SELECT 1 AS one FROM ${OPEN_USER_TASKS_TABLE} WHERE process_instance_key = ? LIMIT 1`,
154
+ [processInstanceKey],
155
+ );
156
+ return rows.length > 0;
157
+ }
158
+
159
+ /** The projected open tasks for an instance, in insertion order. */
160
+ openTasks(processInstanceKey: string): OpenUserTaskRow[] {
161
+ const rows = this.#db.all<OpenTaskDbRow>(
162
+ `SELECT process_instance_key, user_task_key, element_id
163
+ FROM ${OPEN_USER_TASKS_TABLE} WHERE process_instance_key = ? ORDER BY rowid`,
164
+ [processInstanceKey],
165
+ );
166
+ return rows.map((r) => ({
167
+ processInstanceKey: r.process_instance_key,
168
+ userTaskKey: r.user_task_key,
169
+ ...(r.element_id ? { elementId: r.element_id } : {}),
170
+ }));
171
+ }
172
+ }
@@ -11,6 +11,10 @@ import { runInJobContext } from "../execContext.ts";
11
11
  import type { JobExecContext } from "../execContext.ts";
12
12
  import { readLineage } from "../lineage.ts";
13
13
  import { LineageStore } from "./lineage-store.ts";
14
+ import { readModelRegistry } from "../read-model.ts";
15
+ import { registerCanonicalProjections } from "./canonical-projections.ts";
16
+ import { InstanceStateStore } from "./instance-state-store.ts";
17
+ import { OpenUserTasksStore } from "./open-user-tasks-store.ts";
14
18
  import { defaultScheduler, schedulerClock, type SchedulerDeps } from "./scheduler.ts";
15
19
 
16
20
  /** The subset of the engine SDK the LLM decision-rails need: evaluate a DMN decision,
@@ -135,6 +139,50 @@ function tryLineageStore(app: AppApi, log: RuntimeContext["host"]["log"]): Linea
135
139
  }
136
140
  }
137
141
 
142
+ /**
143
+ * Apply every managed read-model VIEW registered in the process-wide `readModelRegistry` to the app's
144
+ * DEFAULT data source (ADR 0065, surface #3). A `defineReadModel(...)` declaration compiles to a
145
+ * managed SQLite VIEW; provisioning it here — the same per-source boot path `tryLineageStore` uses —
146
+ * means an app (or a canonical-projection sidecar) that registers a read model gets its VIEW created
147
+ * for free, with no hand-written migration. The registry is empty until a sibling task registers into
148
+ * it, so this is a silent no-op in the base framework. Absent- and error-safe like lineage: an app
149
+ * with no datasource provisions nothing, and a VIEW-provisioning failure never breaks a worker mount.
150
+ */
151
+ function tryProvisionReadModelViews(app: AppApi, log: RuntimeContext["host"]["log"]): void {
152
+ if (readModelRegistry.all().length === 0) return;
153
+ if (!app.data.hasDefaultSource()) {
154
+ log("debug", "read-models: no default data source; managed VIEW provisioning skipped");
155
+ return;
156
+ }
157
+ try {
158
+ readModelRegistry.ensureViews(app.data.source().db);
159
+ } catch (err) {
160
+ log("warn", "read-models: failed to provision managed VIEWs", { error: String(err) });
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Provision the canonical engine-truth projection sidecars (ADR 0065, proposal point #1) on the app's
166
+ * DEFAULT data source — `_urban_open_user_tasks` and `_urban_instance_state`, the sources a read model
167
+ * `EXISTS`-derives its status edges from. Exactly the per-source boot path `tryLineageStore` uses: an
168
+ * app with no datasource provisions nothing, and a schema failure degrades to no projection tables
169
+ * rather than a failed worker mount. The DSL projection-name registration is done unconditionally by
170
+ * the caller (so read models compile even without a datasource); this only creates the physical tables.
171
+ */
172
+ function tryProvisionCanonicalProjections(app: AppApi, log: RuntimeContext["host"]["log"]): void {
173
+ if (!app.data.hasDefaultSource()) {
174
+ log("debug", "canonical-projections: no default data source; projection tables not provisioned");
175
+ return;
176
+ }
177
+ try {
178
+ const { db } = app.data.source();
179
+ new OpenUserTasksStore(db).ensureSchema();
180
+ new InstanceStateStore(db).ensureSchema();
181
+ } catch (err) {
182
+ log("warn", "canonical-projections: failed to provision projection tables", { error: String(err) });
183
+ }
184
+ }
185
+
138
186
  /** Record an activated job's lineage edge, never letting a projection write break the job. */
139
187
  function recordJobLineage(store: LineageStore, job: EngineJob, log: RuntimeContext["host"]["log"]): void {
140
188
  try {
@@ -192,6 +240,19 @@ export async function mountWorkers(
192
240
  // app configures no default data source.
193
241
  const lineageStore = tryLineageStore(app, ctx.host.log);
194
242
 
243
+ // The framework read-model VIEWs (ADR 0065): apply every registered managed VIEW to the app's
244
+ // default data source, the same per-source boot path lineage uses. A no-op until a sibling task
245
+ // registers a read model into `readModelRegistry`.
246
+ //
247
+ // Register the canonical engine-truth projections (`urban_open_user_tasks`, `urban_instance_state`)
248
+ // FIRST and unconditionally: a read model's managed VIEW resolves each projection name to its
249
+ // physical `_urban_` table at DDL-compile time, so the name→table mapping must exist before
250
+ // `tryProvisionReadModelViews` compiles any VIEW that `EXISTS`-references a projection. Then
251
+ // provision the projection tables per source (absent-safe), and finally the read-model VIEWs.
252
+ registerCanonicalProjections();
253
+ tryProvisionCanonicalProjections(app, ctx.host.log);
254
+ tryProvisionReadModelViews(app, ctx.host.log);
255
+
195
256
  // Cache module loads so a multi-type handler module is imported once.
196
257
  const moduleCache = new Map<string, Record<string, unknown>>();
197
258
  const joinRoot = (p: string): string =>