@nanobpm/urban 0.78.0 → 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,190 @@
1
+ import { afterEach, test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtemp, rm } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { createNodeHost } from "../../adapters/node.ts";
7
+ import type { SqliteDb } from "../host.ts";
8
+ import {
9
+ and,
10
+ caseWhen,
11
+ col,
12
+ defineReadModel,
13
+ eq,
14
+ exists,
15
+ lit,
16
+ pcol,
17
+ projectionRegistry,
18
+ type ParitySample,
19
+ type ReadModel,
20
+ when,
21
+ assertReadModelParity,
22
+ } from "../read-model.ts";
23
+ import {
24
+ CANONICAL_PROJECTIONS,
25
+ registerCanonicalProjections,
26
+ } from "./canonical-projections.ts";
27
+ import { INSTANCE_STATE_TABLE, InstanceStateStore } from "./instance-state-store.ts";
28
+ import { OPEN_USER_TASKS_TABLE, OpenUserTasksStore } from "./open-user-tasks-store.ts";
29
+
30
+ // This suite registers into the process-wide `projectionRegistry` singleton; clear it after every
31
+ // test so registrations never leak into later tests and make the suite order-dependent.
32
+ afterEach(() => {
33
+ projectionRegistry.clear();
34
+ });
35
+
36
+ // A read model that derives an instance's status edge PURELY from the two canonical projections:
37
+ // terminated → "abandoned" (terminated wins), else open user task → "awaiting_operator", else the
38
+ // instance's own stored status. This is the shape the downstream writer-source-inversion task derives.
39
+ function statusEdgeModel(): ReadModel {
40
+ return defineReadModel({
41
+ name: "urban_instance_status",
42
+ baseTable: "instances",
43
+ derive: {
44
+ status_edge: caseWhen(
45
+ [
46
+ when(
47
+ exists(
48
+ "urban_instance_state",
49
+ and(eq(pcol("process_instance_key"), col("process_instance_key")), eq(pcol("state"), lit("TERMINATED"))),
50
+ ),
51
+ lit("abandoned"),
52
+ ),
53
+ when(
54
+ exists("urban_open_user_tasks", eq(pcol("process_instance_key"), col("process_instance_key"))),
55
+ lit("awaiting_operator"),
56
+ ),
57
+ ],
58
+ col("status"),
59
+ ),
60
+ },
61
+ });
62
+ }
63
+
64
+ async function withDb(fn: (db: SqliteDb) => void | Promise<void>): Promise<void> {
65
+ const dir = await mkdtemp(join(tmpdir(), "urban-canonical-"));
66
+ const host = createNodeHost({ cwd: dir, log: () => {} });
67
+ const db = host.openSqlite(join(dir, "test.db"));
68
+ try {
69
+ await fn(db);
70
+ } finally {
71
+ db.close();
72
+ await rm(dir, { recursive: true, force: true });
73
+ }
74
+ }
75
+
76
+ test("registerCanonicalProjections maps each DSL name onto its `_urban_` physical table", () => {
77
+ registerCanonicalProjections();
78
+ assert.equal(projectionRegistry.sqlTableFor("urban_open_user_tasks"), OPEN_USER_TASKS_TABLE);
79
+ assert.equal(projectionRegistry.sqlTableFor("urban_instance_state"), INSTANCE_STATE_TABLE);
80
+ // Idempotent: a second registration is a no-op, not a conflict throw.
81
+ assert.doesNotThrow(() => registerCanonicalProjections());
82
+ assert.deepEqual(
83
+ CANONICAL_PROJECTIONS.map((p) => p.name).sort(),
84
+ ["urban_instance_state", "urban_open_user_tasks"],
85
+ );
86
+ });
87
+
88
+ test("a defineReadModel EXISTS over both projections materialises a VIEW that reads the sidecar tables", async () => {
89
+ registerCanonicalProjections();
90
+ await withDb((db) => {
91
+ // Provision the two engine-truth sidecars exactly as the runtime boot path does.
92
+ const openTasks = new OpenUserTasksStore(db, { clock: { now: () => 0 } });
93
+ const instanceState = new InstanceStateStore(db, { clock: { now: () => 0 } });
94
+ openTasks.ensureSchema();
95
+ instanceState.ensureSchema();
96
+
97
+ // The base domain table the read model derives over.
98
+ db.exec("CREATE TABLE instances (process_instance_key TEXT, status TEXT);");
99
+ for (const key of ["pi-active", "pi-human", "pi-terminated", "pi-both"]) {
100
+ db.run("INSERT INTO instances (process_instance_key, status) VALUES (?, ?);", [key, "running"]);
101
+ }
102
+
103
+ // Record engine truth into the projections (the sources the VIEW derives from).
104
+ instanceState.recordState("pi-active", "ACTIVE");
105
+ instanceState.recordState("pi-human", "ACTIVE", true);
106
+ openTasks.syncInstance("pi-human", [{ userTaskKey: "ut-1", elementId: "Approve" }]);
107
+ instanceState.recordState("pi-terminated", "TERMINATED");
108
+ // pi-both: terminated AND has an open task — terminated must win.
109
+ instanceState.recordState("pi-both", "TERMINATED");
110
+ openTasks.syncInstance("pi-both", [{ userTaskKey: "ut-2" }]);
111
+
112
+ // Materialise the framework-derived managed VIEW and read the derived edge.
113
+ const model = statusEdgeModel();
114
+ db.exec(model.viewDdl());
115
+ const rows = db.all<{ process_instance_key: string; status_edge: string }>(
116
+ "SELECT process_instance_key, status_edge FROM urban_instance_status ORDER BY process_instance_key;",
117
+ );
118
+ const byKey = Object.fromEntries(rows.map((r) => [r.process_instance_key, r.status_edge]));
119
+ assert.equal(byKey["pi-active"], "running", "no terminal/human edge ⇒ the base status survives");
120
+ assert.equal(byKey["pi-human"], "awaiting_operator", "an open user task ⇒ awaiting_operator");
121
+ assert.equal(byKey["pi-terminated"], "abandoned", "TERMINATED ⇒ abandoned");
122
+ assert.equal(byKey["pi-both"], "abandoned", "terminated wins over waiting-on-human");
123
+ });
124
+ });
125
+
126
+ test("the SQLite VIEW and the TS function agree over the canonical projections (parity guard)", async () => {
127
+ registerCanonicalProjections();
128
+ const model = statusEdgeModel();
129
+ const samples: ParitySample[] = [
130
+ // Terminal edge.
131
+ {
132
+ baseRow: { process_instance_key: "pi-1", status: "running" },
133
+ projections: { urban_instance_state: [{ process_instance_key: "pi-1", state: "TERMINATED" }] },
134
+ },
135
+ // Wait-on-human edge.
136
+ {
137
+ baseRow: { process_instance_key: "pi-2", status: "running" },
138
+ projections: { urban_open_user_tasks: [{ process_instance_key: "pi-2", user_task_key: "ut-1" }] },
139
+ },
140
+ // Neither edge ⇒ the base status survives.
141
+ {
142
+ baseRow: { process_instance_key: "pi-3", status: "running" },
143
+ projections: {},
144
+ },
145
+ // Correlation: an open task for a DIFFERENT instance must not match.
146
+ {
147
+ baseRow: { process_instance_key: "pi-4", status: "running" },
148
+ projections: { urban_open_user_tasks: [{ process_instance_key: "pi-x", user_task_key: "ut-2" }] },
149
+ },
150
+ // Precedence: terminated AND open task ⇒ abandoned (terminated wins).
151
+ {
152
+ baseRow: { process_instance_key: "pi-5", status: "running" },
153
+ projections: {
154
+ urban_instance_state: [{ process_instance_key: "pi-5", state: "TERMINATED" }],
155
+ urban_open_user_tasks: [{ process_instance_key: "pi-5", user_task_key: "ut-3" }],
156
+ },
157
+ },
158
+ // A non-terminal recorded state ⇒ no terminal edge.
159
+ {
160
+ baseRow: { process_instance_key: "pi-6", status: "converging" },
161
+ projections: { urban_instance_state: [{ process_instance_key: "pi-6", state: "ACTIVE" }] },
162
+ },
163
+ ];
164
+
165
+ await withDb((db) => {
166
+ // The parity guard builds its own isolated TEMP fixtures for the base + projection tables and
167
+ // asserts the SQL VIEW and the compiled TS function agree for every sample — no bespoke oracle.
168
+ assert.doesNotThrow(() => assertReadModelParity(model, db, samples));
169
+ });
170
+ });
171
+
172
+ test("the parity guard catches a divergence between the two backends", async () => {
173
+ registerCanonicalProjections();
174
+ const model = statusEdgeModel();
175
+ // Feed the guard's SQL side a sample, but hand a mutated TS function that disagrees, to prove the
176
+ // guard is actually comparing the two backends (not trivially passing).
177
+ const mutated: ReadModel = { ...model, fnFor: () => () => "WRONG" };
178
+ await withDb((db) => {
179
+ assert.throws(
180
+ () =>
181
+ assertReadModelParity(mutated, db, [
182
+ {
183
+ baseRow: { process_instance_key: "pi-1", status: "running" },
184
+ projections: { urban_instance_state: [{ process_instance_key: "pi-1", state: "TERMINATED" }] },
185
+ },
186
+ ]),
187
+ /parity mismatch/,
188
+ );
189
+ });
190
+ });
@@ -0,0 +1,38 @@
1
+ // canonical-projections — register the framework's canonical engine-truth projections under their
2
+ // stable DSL names (ADR 0065, proposal point #1). This is the concrete use of the projection-name
3
+ // seam the read-model primitive established (`projectionRegistry.register({ name, sqlTable })`): it
4
+ // maps each unprefixed DSL name a `defineReadModel(...)` `exists(...)` reads
5
+ // (`urban_open_user_tasks`, `urban_instance_state`) onto its `_urban_`-prefixed physical sidecar
6
+ // table, so the SQL backend's `EXISTS` compiles against the real table while the domain model / DB
7
+ // Manager never sees it.
8
+ //
9
+ // Registration is idempotent (the registry no-ops a matching re-register), so calling this on every
10
+ // worker mount — the boot path in `workers.ts` — is safe. It is decoupled from schema provisioning:
11
+ // a read model must be able to reference these projections by name even in a process/app that has no
12
+ // datasource to provision the physical tables into.
13
+
14
+ import {
15
+ INSTANCE_STATE_PROJECTION,
16
+ INSTANCE_STATE_TABLE,
17
+ } from "./instance-state-store.ts";
18
+ import {
19
+ OPEN_USER_TASKS_PROJECTION,
20
+ OPEN_USER_TASKS_TABLE,
21
+ } from "./open-user-tasks-store.ts";
22
+ import { type ProjectionRegistry, projectionRegistry } from "../read-model.ts";
23
+
24
+ /** The canonical projection name → physical `_urban_` table mappings this task owns. */
25
+ export const CANONICAL_PROJECTIONS: ReadonlyArray<{ readonly name: string; readonly sqlTable: string }> = [
26
+ { name: OPEN_USER_TASKS_PROJECTION, sqlTable: OPEN_USER_TASKS_TABLE },
27
+ { name: INSTANCE_STATE_PROJECTION, sqlTable: INSTANCE_STATE_TABLE },
28
+ ];
29
+
30
+ /**
31
+ * Register the canonical engine-truth projections in a {@link ProjectionRegistry} (defaults to the
32
+ * process-wide `projectionRegistry`), so a `defineReadModel(...)` authored anywhere can
33
+ * `exists("urban_open_user_tasks", …)` / `exists("urban_instance_state", …)` and have the SQL backend
34
+ * resolve them to the `_urban_`-prefixed physical tables. Idempotent.
35
+ */
36
+ export function registerCanonicalProjections(registry: ProjectionRegistry = projectionRegistry): void {
37
+ for (const projection of CANONICAL_PROJECTIONS) registry.register(projection);
38
+ }
@@ -0,0 +1,143 @@
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 { makeGateway } from "./gateway.ts";
11
+ import {
12
+ INSTANCE_STATE_PROJECTION,
13
+ INSTANCE_STATE_SCHEMA_SQL,
14
+ INSTANCE_STATE_TABLE,
15
+ InstanceStateStore,
16
+ } from "./instance-state-store.ts";
17
+
18
+ async function withStore(fn: (store: InstanceStateStore, db: SqliteDb) => void | Promise<void>): Promise<void> {
19
+ const dir = await mkdtemp(join(tmpdir(), "urban-instance-state-"));
20
+ const host = createNodeHost({ cwd: dir, log: () => {} });
21
+ const db = host.openSqlite(join(dir, "test.db"));
22
+ const store = new InstanceStateStore(db, { clock: { now: () => 0 } });
23
+ store.ensureSchema();
24
+ try {
25
+ await fn(store, db);
26
+ } finally {
27
+ db.close();
28
+ await rm(dir, { recursive: true, force: true });
29
+ }
30
+ }
31
+
32
+ function normaliseSql(sql: string): string {
33
+ return sql
34
+ .split("\n")
35
+ .map((line) => line.replace(/--.*$/, ""))
36
+ .join("\n")
37
+ .replace(/\s+/g, " ")
38
+ .trim();
39
+ }
40
+
41
+ const migrationPath = fileURLToPath(
42
+ new URL("../../../../../../db/migrations/007_urban_instance_state.sql", import.meta.url),
43
+ );
44
+
45
+ test("the instance-state projection table is framework bookkeeping — `_urban_`-prefixed and hidden from the domain model", async () => {
46
+ assert.match(INSTANCE_STATE_TABLE, /^_urban_/, "projection table must be `_urban_`-prefixed to stay hidden");
47
+ await withStore(async (_store, db) => {
48
+ const surfaced = (await makeGateway(db).schema()).map((t) => t.name);
49
+ assert.ok(!surfaced.includes(INSTANCE_STATE_TABLE), "projection table must not surface in the domain model");
50
+ });
51
+ });
52
+
53
+ test("the boot migration and InstanceStateStore's DDL do not drift", () => {
54
+ const migrationSql = readFileSync(migrationPath, "utf8");
55
+ assert.equal(
56
+ normaliseSql(migrationSql),
57
+ normaliseSql(INSTANCE_STATE_SCHEMA_SQL),
58
+ "db/migrations/007_urban_instance_state.sql must match INSTANCE_STATE_SCHEMA_SQL — update both together",
59
+ );
60
+ });
61
+
62
+ test("the boot migration is forward-only and additive (IF NOT EXISTS, no drops/alters)", () => {
63
+ const migrationSql = readFileSync(migrationPath, "utf8");
64
+ assert.match(migrationSql, /CREATE TABLE IF NOT EXISTS _urban_instance_state/);
65
+ assert.doesNotMatch(migrationSql, /\bDROP\b/i);
66
+ assert.doesNotMatch(migrationSql, /\bALTER\b/i);
67
+ assert.match(migrationPath, /[/\\]007_urban_instance_state\.sql$/);
68
+ });
69
+
70
+ test("ensureSchema creates the projection table idempotently", async () => {
71
+ await withStore((store, db) => {
72
+ store.ensureSchema();
73
+ store.ensureSchema();
74
+ const tables = db
75
+ .all<{ name: string }>("SELECT name FROM sqlite_master WHERE type='table' AND name = ?", [INSTANCE_STATE_TABLE])
76
+ .map((r) => r.name);
77
+ assert.deepEqual(tables, [INSTANCE_STATE_TABLE]);
78
+ });
79
+ });
80
+
81
+ test("recordState upserts one canonical state per instance and is idempotent", async () => {
82
+ await withStore((store) => {
83
+ assert.equal(store.recordState("pi-1", "ACTIVE"), true);
84
+ assert.equal(store.recordState("pi-1", "ACTIVE"), false, "re-record of the same state is a no-op");
85
+ assert.deepEqual(store.getState("pi-1"), {
86
+ processInstanceKey: "pi-1",
87
+ state: "ACTIVE",
88
+ waitingOnHuman: false,
89
+ });
90
+
91
+ // A lifecycle transition upserts in place (still one row).
92
+ assert.equal(store.recordState("pi-1", "TERMINATED"), true);
93
+ assert.deepEqual(store.getState("pi-1"), {
94
+ processInstanceKey: "pi-1",
95
+ state: "TERMINATED",
96
+ waitingOnHuman: false,
97
+ });
98
+ assert.equal(store.recordState("", "ACTIVE"), false, "blank instance key is ignored");
99
+ });
100
+ });
101
+
102
+ test("recordState tracks the waiting-on-human flag", async () => {
103
+ await withStore((store) => {
104
+ store.recordState("pi-1", "ACTIVE", true);
105
+ assert.equal(store.getState("pi-1")?.waitingOnHuman, true);
106
+ // Flipping only the flag (state unchanged) is a real change.
107
+ assert.equal(store.recordState("pi-1", "ACTIVE", false), true);
108
+ assert.equal(store.getState("pi-1")?.waitingOnHuman, false);
109
+ });
110
+ });
111
+
112
+ test("recordFromSnapshot records from an engine ProcessInstanceSnapshot", async () => {
113
+ await withStore((store) => {
114
+ store.recordFromSnapshot({ processInstanceKey: "pi-9", state: "COMPLETED" });
115
+ assert.deepEqual(store.getState("pi-9"), {
116
+ processInstanceKey: "pi-9",
117
+ state: "COMPLETED",
118
+ waitingOnHuman: false,
119
+ });
120
+ });
121
+ });
122
+
123
+ test("getState surfaces (throws on) an unexpected persisted state rather than masquerading it as ACTIVE", async () => {
124
+ await withStore((store, db) => {
125
+ // Simulate a corrupted row / an unhandled future engine state landing in the table.
126
+ db.run(
127
+ `INSERT INTO ${INSTANCE_STATE_TABLE} (process_instance_key, state, waiting_on_human, updated_at)
128
+ VALUES (?, ?, 0, ?)`,
129
+ ["pi-bad", "SOMETHING_ELSE", new Date(0).toISOString()],
130
+ );
131
+ assert.throws(() => store.getState("pi-bad"), /unexpected state/);
132
+ });
133
+ });
134
+
135
+ test("getState returns undefined for an unrecorded instance", async () => {
136
+ await withStore((store) => {
137
+ assert.equal(store.getState("unknown"), undefined);
138
+ });
139
+ });
140
+
141
+ test("the DSL projection name is stable and unprefixed (usable in `exists(...)`)", () => {
142
+ assert.equal(INSTANCE_STATE_PROJECTION, "urban_instance_state");
143
+ });
@@ -0,0 +1,135 @@
1
+ // instance-state-store — the canonical per-instance engine lifecycle-state projection (ADR 0065,
2
+ // proposal point #1). A framework-level, per-source sidecar (exactly like the lineage store) that
3
+ // records each tracked process instance's engine-truth lifecycle state (ACTIVE / COMPLETED /
4
+ // TERMINATED) plus whether it is waiting on a human, from the same `searchProcessInstances` /
5
+ // `openUserTasks` truth the `instanceTracking` reconciler already reads.
6
+ //
7
+ // A read model can then DERIVE an instance's status edge purely from this projection — e.g. the
8
+ // terminal edge (`onTerminated`, → `abandoned`) becomes an `EXISTS(urban_instance_state WHERE
9
+ // process_instance_key = … AND state = 'TERMINATED')` derivation, instead of a stored `status` write
10
+ // that tears from engine truth. This projection PROVIDES that engine-truth source; retiring the
11
+ // reconciler's writes is the downstream `writer-source-inversion` task.
12
+ //
13
+ // It is framework bookkeeping, so the physical table is `_urban_`-prefixed to stay hidden from the
14
+ // domain model / DB Manager (see `SqliteGateway.schema()`), like the lineage tables — and NEVER
15
+ // app-written. Recording is idempotent: re-recording an unchanged state is a no-op.
16
+
17
+ import type { ProcessInstanceSnapshot, ProcessInstanceState, SqliteDb } from "../host.ts";
18
+ import { type Clock, systemClock } from "./lineage-store.ts";
19
+
20
+ /** The instance-state projection table. Framework bookkeeping, so `_urban_`-prefixed to stay hidden
21
+ * from the domain model / DB Manager (see `SqliteGateway.schema()`), like the lineage tables. */
22
+ export const INSTANCE_STATE_TABLE = "_urban_instance_state";
23
+
24
+ /** The stable DSL projection NAME a `defineReadModel` `exists(...)` references (registered in the
25
+ * `projectionRegistry`, mapped to {@link INSTANCE_STATE_TABLE}). The DSL name is unprefixed so a read
26
+ * model reads `EXISTS(urban_instance_state WHERE process_instance_key = base.process_instance_key)`. */
27
+ export const INSTANCE_STATE_PROJECTION = "urban_instance_state";
28
+
29
+ /**
30
+ * The canonical instance-state DDL — the single source of truth applied by
31
+ * {@link InstanceStateStore.ensureSchema}, mirrored verbatim by the boot migration
32
+ * `db/migrations/007_urban_instance_state.sql` (a drift-guard test asserts they match).
33
+ *
34
+ * Forward-only and additive. Keyed by `process_instance_key` (one canonical state per instance) so a
35
+ * re-record upserts in place and is idempotent. `waiting_on_human` is 0/1 (SQLite has no boolean).
36
+ */
37
+ export const INSTANCE_STATE_SCHEMA_SQL = `CREATE TABLE IF NOT EXISTS ${INSTANCE_STATE_TABLE} (
38
+ process_instance_key TEXT NOT NULL,
39
+ state TEXT NOT NULL,
40
+ waiting_on_human INTEGER NOT NULL DEFAULT 0,
41
+ updated_at TEXT NOT NULL,
42
+ PRIMARY KEY (process_instance_key)
43
+ );`;
44
+
45
+ export interface InstanceStateStoreOptions {
46
+ readonly clock?: Clock;
47
+ }
48
+
49
+ /** One projected instance-state row, as {@link InstanceStateStore.getState} returns it. */
50
+ export interface InstanceStateRow {
51
+ readonly processInstanceKey: string;
52
+ readonly state: ProcessInstanceState;
53
+ readonly waitingOnHuman: boolean;
54
+ }
55
+
56
+ interface InstanceStateDbRow {
57
+ process_instance_key: string;
58
+ state: string;
59
+ waiting_on_human: number;
60
+ }
61
+
62
+ function normalizeState(raw: string): ProcessInstanceState {
63
+ // Validate rather than coerce: silently mapping an unknown value to "ACTIVE" would let corrupted rows
64
+ // or an unhandled new engine state masquerade as ACTIVE and produce incorrect derived edges. Surface
65
+ // the bad data loudly instead.
66
+ if (raw === "ACTIVE" || raw === "COMPLETED" || raw === "TERMINATED") return raw;
67
+ throw new Error(
68
+ `${INSTANCE_STATE_TABLE} contains an unexpected state ${JSON.stringify(raw)} — refusing to coerce to ACTIVE (data corruption or an unhandled engine state)`,
69
+ );
70
+ }
71
+
72
+ /**
73
+ * The per-source sidecar behind the `urban_instance_state` projection. A thin, idempotent
74
+ * persistence + query layer over the engine's per-instance lifecycle truth.
75
+ */
76
+ export class InstanceStateStore {
77
+ readonly #db: SqliteDb;
78
+ readonly #clock: Clock;
79
+
80
+ constructor(db: SqliteDb, options: InstanceStateStoreOptions = {}) {
81
+ this.#db = db;
82
+ this.#clock = options.clock ?? systemClock;
83
+ }
84
+
85
+ /** Apply the canonical DDL (idempotent). Callers whose app DataLayer applies the boot migration
86
+ * `db/migrations/007_urban_instance_state.sql` do not need this; the runtime calls it so the store
87
+ * is usable against a bare source too. The DDL is identical to the migration (drift-guarded). */
88
+ ensureSchema(): void {
89
+ this.#db.exec(INSTANCE_STATE_SCHEMA_SQL);
90
+ }
91
+
92
+ /**
93
+ * Record an instance's canonical engine lifecycle state (and whether it is waiting on a human),
94
+ * upserting the single row keyed by `processInstanceKey`. Idempotent: re-recording the same
95
+ * `(state, waitingOnHuman)` is a no-op (returns `false`). A blank `processInstanceKey` is ignored.
96
+ */
97
+ recordState(processInstanceKey: string, state: ProcessInstanceState, waitingOnHuman = false): boolean {
98
+ if (!processInstanceKey) return false;
99
+ const { changes } = this.#db.run(
100
+ `INSERT INTO ${INSTANCE_STATE_TABLE}
101
+ (process_instance_key, state, waiting_on_human, updated_at)
102
+ VALUES (?, ?, ?, ?)
103
+ ON CONFLICT (process_instance_key) DO UPDATE SET
104
+ state = excluded.state,
105
+ waiting_on_human = excluded.waiting_on_human,
106
+ updated_at = excluded.updated_at
107
+ WHERE ${INSTANCE_STATE_TABLE}.state <> excluded.state
108
+ OR ${INSTANCE_STATE_TABLE}.waiting_on_human <> excluded.waiting_on_human`,
109
+ [processInstanceKey, state, waitingOnHuman ? 1 : 0, new Date(this.#clock.now()).toISOString()],
110
+ );
111
+ return changes > 0;
112
+ }
113
+
114
+ /** Record from a {@link ProcessInstanceSnapshot} (the engine's `searchProcessInstances` row),
115
+ * carrying the optional waiting-on-human flag the reconciler probes separately. */
116
+ recordFromSnapshot(snapshot: ProcessInstanceSnapshot, waitingOnHuman = false): boolean {
117
+ return this.recordState(snapshot.processInstanceKey, snapshot.state, waitingOnHuman);
118
+ }
119
+
120
+ /** The projected canonical state for an instance, or `undefined` if none recorded. */
121
+ getState(processInstanceKey: string): InstanceStateRow | undefined {
122
+ const rows = this.#db.all<InstanceStateDbRow>(
123
+ `SELECT process_instance_key, state, waiting_on_human
124
+ FROM ${INSTANCE_STATE_TABLE} WHERE process_instance_key = ?`,
125
+ [processInstanceKey],
126
+ );
127
+ const row = rows[0];
128
+ if (!row) return undefined;
129
+ return {
130
+ processInstanceKey: row.process_instance_key,
131
+ state: normalizeState(row.state),
132
+ waitingOnHuman: row.waiting_on_human !== 0,
133
+ };
134
+ }
135
+ }
@@ -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
+ });