@nanobpm/urban 0.78.0 → 0.80.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/core/modules/canonical-projections.d.ts +13 -0
- package/dist/runtime/core/modules/canonical-projections.js +30 -0
- package/dist/runtime/core/modules/formjs.gen.d.ts +1 -0
- package/dist/runtime/core/modules/formjs.gen.js +5 -0
- package/dist/runtime/core/modules/forms.d.ts +22 -0
- package/dist/runtime/core/modules/forms.js +59 -0
- package/dist/runtime/core/modules/instance-state-store.d.ts +50 -0
- package/dist/runtime/core/modules/instance-state-store.js +101 -0
- package/dist/runtime/core/modules/open-user-tasks-store.d.ts +63 -0
- package/dist/runtime/core/modules/open-user-tasks-store.js +135 -0
- package/dist/runtime/core/modules/pages.js +36 -0
- package/dist/runtime/core/modules/runtime.gen.js +1 -1
- package/dist/runtime/core/modules/surfaces.js +18 -124
- package/dist/runtime/core/modules/workers.js +33 -0
- package/dist/runtime/index.d.ts +5 -0
- package/dist/runtime/index.js +8 -1
- package/package.json +1 -1
- package/src/runtime/browser/fake-dom.test-utils.ts +114 -0
- package/src/runtime/browser/formjs.browser.js +192 -0
- package/src/runtime/browser/formjs.browser.test.ts +136 -0
- package/src/runtime/browser/runtime.browser.js +101 -1
- package/src/runtime/browser/runtime.engineform.test.ts +158 -0
- package/src/runtime/core/modules/canonical-projections.test.ts +190 -0
- package/src/runtime/core/modules/canonical-projections.ts +38 -0
- package/src/runtime/core/modules/formjs.gen.ts +6 -0
- package/src/runtime/core/modules/forms.test.ts +122 -0
- package/src/runtime/core/modules/forms.ts +67 -0
- package/src/runtime/core/modules/gen-artifacts.test.ts +41 -0
- package/src/runtime/core/modules/instance-state-store.test.ts +143 -0
- package/src/runtime/core/modules/instance-state-store.ts +135 -0
- package/src/runtime/core/modules/open-user-tasks-store.test.ts +156 -0
- package/src/runtime/core/modules/open-user-tasks-store.ts +172 -0
- package/src/runtime/core/modules/pages.test.ts +99 -0
- package/src/runtime/core/modules/pages.ts +38 -0
- package/src/runtime/core/modules/runtime.gen.ts +1 -1
- package/src/runtime/core/modules/surfaces.ts +19 -121
- package/src/runtime/core/modules/workers.ts +33 -0
- package/src/runtime/index.ts +29 -1
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// The shared engine-form server seams (ADR 0026, #457): the /api/form resolution
|
|
2
|
+
// gate and the user-task completion, factored out of surfaces.ts so the taskInbox
|
|
3
|
+
// surface and the pages grid detail share ONE implementation. These tests pin the
|
|
4
|
+
// gate's contract directly (both callers depend on it identically).
|
|
5
|
+
|
|
6
|
+
import { test } from "node:test";
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import type { EngineClient, FormSchema } from "../host.ts";
|
|
9
|
+
import { completeUserTaskResponse, resolveFormResponse } from "./forms.ts";
|
|
10
|
+
|
|
11
|
+
function formEngine(getForm: EngineClient["getForm"]): Pick<EngineClient, "getForm"> {
|
|
12
|
+
return { getForm };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
test("resolveFormResponse 400s when neither identifier is present (empty/whitespace = absent)", async () => {
|
|
16
|
+
const engine = formEngine(async () => {
|
|
17
|
+
throw new Error("getForm must not be called when no identifier is present");
|
|
18
|
+
});
|
|
19
|
+
for (const [formKey, formId] of [
|
|
20
|
+
[undefined, undefined],
|
|
21
|
+
["", ""],
|
|
22
|
+
[" ", undefined],
|
|
23
|
+
[undefined, "\t"],
|
|
24
|
+
] as const) {
|
|
25
|
+
const res = await resolveFormResponse(engine, formKey, formId);
|
|
26
|
+
assert.equal(res.status, 400);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("resolveFormResponse passes raw identifiers through to getForm and returns its schema", async () => {
|
|
31
|
+
const seen: { formKey?: string; formId?: string }[] = [];
|
|
32
|
+
const schema: FormSchema = { formKey: "form-1", schema: { components: [] } };
|
|
33
|
+
const engine = formEngine(async (input) => {
|
|
34
|
+
seen.push(input);
|
|
35
|
+
return schema;
|
|
36
|
+
});
|
|
37
|
+
const res = await resolveFormResponse(engine, "form-1", undefined);
|
|
38
|
+
assert.equal(res.status, 200);
|
|
39
|
+
assert.ok(res.body);
|
|
40
|
+
assert.deepEqual(JSON.parse(res.body), schema);
|
|
41
|
+
// A blank formKey is passed through unchanged so getForm can fall back to formId
|
|
42
|
+
// (that fallback is getForm's single responsibility, not the gate's).
|
|
43
|
+
await resolveFormResponse(engine, "", "the-form-id");
|
|
44
|
+
assert.deepEqual(seen, [
|
|
45
|
+
{ formKey: "form-1", formId: undefined },
|
|
46
|
+
{ formKey: "", formId: "the-form-id" },
|
|
47
|
+
]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("resolveFormResponse 204s (no body) when getForm can't resolve a form", async () => {
|
|
51
|
+
const res = await resolveFormResponse(formEngine(async () => null), "missing", undefined);
|
|
52
|
+
assert.equal(res.status, 204);
|
|
53
|
+
assert.equal(res.body, "");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("completeUserTaskResponse completes the task with the submitted variables", async () => {
|
|
57
|
+
const calls: { key: string; variables?: Record<string, unknown> }[] = [];
|
|
58
|
+
const engine: Pick<EngineClient, "completeUserTask"> = {
|
|
59
|
+
async completeUserTask(key, variables) {
|
|
60
|
+
calls.push({ key, variables });
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
const res = await completeUserTaskResponse(engine, JSON.stringify({ userTaskKey: "ut-7", variables: { a: 1 } }));
|
|
64
|
+
assert.equal(res.status, 200);
|
|
65
|
+
assert.ok(res.body);
|
|
66
|
+
assert.deepEqual(JSON.parse(res.body), { ok: true });
|
|
67
|
+
assert.deepEqual(calls, [{ key: "ut-7", variables: { a: 1 } }]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("completeUserTaskResponse 400s on a missing userTaskKey or malformed body — no completion attempted", async () => {
|
|
71
|
+
let called = false;
|
|
72
|
+
const engine: Pick<EngineClient, "completeUserTask"> = {
|
|
73
|
+
async completeUserTask() {
|
|
74
|
+
called = true;
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
assert.equal((await completeUserTaskResponse(engine, JSON.stringify({ variables: {} }))).status, 400);
|
|
78
|
+
assert.equal((await completeUserTaskResponse(engine, "{ not json")).status, 400);
|
|
79
|
+
assert.equal(called, false);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("completeUserTaskResponse 400s on a non-object JSON body — no crash, no completion attempted", async () => {
|
|
83
|
+
let called = false;
|
|
84
|
+
const engine: Pick<EngineClient, "completeUserTask"> = {
|
|
85
|
+
async completeUserTask() {
|
|
86
|
+
called = true;
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
// `JSON.parse("null")` returns `null`, and a bare scalar/array parses to a
|
|
90
|
+
// non-record — reading `.userTaskKey` off those must 400, never throw a 500.
|
|
91
|
+
for (const bodyText of ["null", "123", '"a string"', "true", "[]"]) {
|
|
92
|
+
const res = await completeUserTaskResponse(engine, bodyText);
|
|
93
|
+
assert.equal(res.status, 400, `body=${bodyText} should 400`);
|
|
94
|
+
}
|
|
95
|
+
assert.equal(called, false);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("completeUserTaskResponse 400s on non-object variables — no completion attempted", async () => {
|
|
99
|
+
let called = false;
|
|
100
|
+
const engine: Pick<EngineClient, "completeUserTask"> = {
|
|
101
|
+
async completeUserTask() {
|
|
102
|
+
called = true;
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
for (const variables of [1, "x", true, [], null]) {
|
|
106
|
+
const res = await completeUserTaskResponse(engine, JSON.stringify({ userTaskKey: "ut-1", variables }));
|
|
107
|
+
assert.equal(res.status, 400, `variables=${JSON.stringify(variables)} should 400`);
|
|
108
|
+
}
|
|
109
|
+
assert.equal(called, false);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("completeUserTaskResponse allows an omitted variables (undefined)", async () => {
|
|
113
|
+
const calls: { key: string; variables?: Record<string, unknown> }[] = [];
|
|
114
|
+
const engine: Pick<EngineClient, "completeUserTask"> = {
|
|
115
|
+
async completeUserTask(key, variables) {
|
|
116
|
+
calls.push({ key, variables });
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
const res = await completeUserTaskResponse(engine, JSON.stringify({ userTaskKey: "ut-2" }));
|
|
120
|
+
assert.equal(res.status, 200);
|
|
121
|
+
assert.deepEqual(calls, [{ key: "ut-2", variables: undefined }]);
|
|
122
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Shared engine-form plumbing (ADR 0026, extended to the pages surface).
|
|
2
|
+
//
|
|
3
|
+
// The `taskInbox` surface pioneered rendering a user task's *engine-declared*
|
|
4
|
+
// form: resolve the task's `formKey`/`formId` via `EngineClient.getForm(...)`,
|
|
5
|
+
// render the returned form-js schema client-side, and post the entered values
|
|
6
|
+
// as the completion `variables`. The pages `dataGrid` engine-form detail (#457)
|
|
7
|
+
// needs the SAME two server seams — the `/api/form` resolution gate and the
|
|
8
|
+
// task-completion endpoint — so they live here ONCE and are wired by both
|
|
9
|
+
// `surfaces.ts` (taskInbox) and `pages.ts` (the grid detail). No fork: a single
|
|
10
|
+
// resolution gate, and the `host.ts` `presentFormIdentifier` presence rule stays
|
|
11
|
+
// the single source of truth for "was an identifier provided".
|
|
12
|
+
|
|
13
|
+
import type { EngineClient, HttpResponse } from "../host.ts";
|
|
14
|
+
import { presentFormIdentifier } from "../host.ts";
|
|
15
|
+
import { isRecord } from "../guards.ts";
|
|
16
|
+
import { json, noContent } from "../router.ts";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The shared `/api/form` resolution gate. Rejects only when *neither* identifier
|
|
20
|
+
* is present — presence follows getForm's canonical rule (`presentFormIdentifier`:
|
|
21
|
+
* empty/whitespace = absent) so a whitespace-only `?formKey= ` 400s here instead
|
|
22
|
+
* of slipping past a raw truthiness check and returning a spurious 204. The raw
|
|
23
|
+
* values are passed through unchanged — resolving a blank key to its `formId`
|
|
24
|
+
* fallback is getForm's single responsibility. A form that can't be resolved
|
|
25
|
+
* returns 204 so the client renders the no-form fallback rather than erroring.
|
|
26
|
+
*/
|
|
27
|
+
export async function resolveFormResponse(
|
|
28
|
+
engine: Pick<EngineClient, "getForm">,
|
|
29
|
+
formKey: string | undefined,
|
|
30
|
+
formId: string | undefined,
|
|
31
|
+
): Promise<HttpResponse> {
|
|
32
|
+
if (!presentFormIdentifier(formKey) && !presentFormIdentifier(formId))
|
|
33
|
+
return json({ error: "formKey or formId required" }, 400);
|
|
34
|
+
const form = await engine.getForm({ formKey, formId });
|
|
35
|
+
if (!form) return noContent();
|
|
36
|
+
return json(form);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The shared user-task completion endpoint. Parses `{ userTaskKey, variables }`
|
|
41
|
+
* from the request body and completes the task via `EngineClient.completeUserTask`.
|
|
42
|
+
* A body that isn't a JSON object (`null`, a scalar, an array) 400s instead of
|
|
43
|
+
* throwing a 500; a missing/blank `userTaskKey` 400s; a malformed body 400s; a
|
|
44
|
+
* present but non-object `variables` (number, string, array, `null`) 400s so the
|
|
45
|
+
* completion contract stays well-defined at this shared seam. Used by both the
|
|
46
|
+
* taskInbox surface and the pages grid's engine-form detail so the completion
|
|
47
|
+
* contract can't drift between them.
|
|
48
|
+
*/
|
|
49
|
+
export async function completeUserTaskResponse(
|
|
50
|
+
engine: Pick<EngineClient, "completeUserTask">,
|
|
51
|
+
bodyText: string,
|
|
52
|
+
): Promise<HttpResponse> {
|
|
53
|
+
let parsed: unknown;
|
|
54
|
+
try {
|
|
55
|
+
parsed = JSON.parse(bodyText || "{}");
|
|
56
|
+
} catch {
|
|
57
|
+
return json({ error: "invalid JSON body" }, 400);
|
|
58
|
+
}
|
|
59
|
+
if (!isRecord(parsed)) return json({ error: "invalid JSON body" }, 400);
|
|
60
|
+
const { userTaskKey, variables } = parsed;
|
|
61
|
+
if (typeof userTaskKey !== "string" || !userTaskKey)
|
|
62
|
+
return json({ error: "userTaskKey required" }, 400);
|
|
63
|
+
if (variables !== undefined && !isRecord(variables))
|
|
64
|
+
return json({ error: "variables must be a JSON object" }, 400);
|
|
65
|
+
await engine.completeUserTask(userTaskKey, variables);
|
|
66
|
+
return json({ ok: true });
|
|
67
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Drift gate for the generated browser-runtime artifacts, enforced INSIDE the test
|
|
2
|
+
// suite (which CI already runs) rather than in a workflow step. The browser runtime
|
|
3
|
+
// and the shared form-js renderer are authored as real, type-checked/linted/tested
|
|
4
|
+
// source (runtime.browser.js, formjs.browser.js) and emitted to committed string
|
|
5
|
+
// artifacts (runtime.gen.ts, formjs.gen.ts) by scripts/gen-runtime.mjs. If either
|
|
6
|
+
// checked-in artifact drifts from a fresh generation, the served bundle no longer
|
|
7
|
+
// matches its source and a stale renderer could ship — so we regenerate-and-compare
|
|
8
|
+
// here and fail on any drift, the same guarantee `npm run check:runtime` gives.
|
|
9
|
+
|
|
10
|
+
import { test } from "node:test";
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { execFileSync } from "node:child_process";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
|
|
15
|
+
// Resolve the generator relative to THIS file (cwd varies: `npm test` runs from the
|
|
16
|
+
// package root, but a runner may invoke us from elsewhere).
|
|
17
|
+
const GEN_SCRIPT = fileURLToPath(new URL("../../../../scripts/gen-runtime.mjs", import.meta.url));
|
|
18
|
+
|
|
19
|
+
function hasStderr(e: unknown): e is { stderr?: unknown; stdout?: unknown } {
|
|
20
|
+
return typeof e === "object" && e !== null && ("stderr" in e || "stdout" in e);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
test("the committed runtime + formjs artifacts are byte-identical to a fresh generation (no drift)", () => {
|
|
24
|
+
// `--check` regenerates both artifacts in-memory and diffs them against the
|
|
25
|
+
// committed files, exiting non-zero (with a diagnostic) on any drift. Spawn the real
|
|
26
|
+
// `node` binary explicitly: under `deno test`, `process.execPath` is the `deno`
|
|
27
|
+
// binary, which cannot run this Node script — mirrors the pages.test.ts node --check
|
|
28
|
+
// guard. Regenerate + commit locally with `npm run gen:runtime -w packages/urban`.
|
|
29
|
+
let ok = true;
|
|
30
|
+
let diagnostic = "";
|
|
31
|
+
try {
|
|
32
|
+
execFileSync("node", [GEN_SCRIPT, "--check"], { stdio: "pipe" });
|
|
33
|
+
} catch (err) {
|
|
34
|
+
ok = false;
|
|
35
|
+
diagnostic = hasStderr(err) ? `${String(err.stdout ?? "")}${String(err.stderr ?? "")}` : String(err);
|
|
36
|
+
}
|
|
37
|
+
assert.ok(
|
|
38
|
+
ok,
|
|
39
|
+
`a committed generated artifact is stale — run \`npm run gen:runtime -w packages/urban\` and commit the result.\n${diagnostic}`,
|
|
40
|
+
);
|
|
41
|
+
});
|
|
@@ -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
|
+
});
|