@nanobpm/urban 0.28.0 → 0.30.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/openapi/spec.d.ts +127 -0
- package/dist/openapi/spec.js +444 -0
- 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/api.d.ts +61 -0
- package/dist/runtime/core/modules/api.js +359 -0
- package/dist/runtime/core/modules/cancel.d.ts +31 -0
- package/dist/runtime/core/modules/cancel.js +90 -0
- package/dist/runtime/core/modules/connectors.js +3 -1
- package/dist/runtime/core/modules/datasource.d.ts +23 -3
- package/dist/runtime/core/modules/datasource.js +61 -6
- 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 +22 -0
- package/dist/runtime/core/modules/instance-tracking.js +158 -0
- package/dist/runtime/core/modules/pages.d.ts +8 -0
- package/dist/runtime/core/modules/pages.js +17 -1
- package/dist/runtime/core/modules/scheduler.d.ts +11 -0
- package/dist/runtime/core/modules/scheduler.js +21 -0
- package/dist/runtime/core/modules/surfaces.js +9 -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 +90 -7
- package/dist/runtime/index.d.ts +4 -0
- package/dist/runtime/index.js +2 -0
- package/dist/toolkit/artifact.d.ts +5 -0
- package/dist/toolkit/artifact.js +7 -0
- package/dist/toolkit/derivers/api.d.ts +19 -0
- package/dist/toolkit/derivers/api.js +224 -0
- package/dist/toolkit/gen.js +23 -4
- package/dist/toolkit/index.d.ts +5 -0
- package/dist/toolkit/index.js +12 -1
- package/package.json +2 -2
- package/src/openapi/spec.test.ts +292 -0
- package/src/openapi/spec.ts +540 -0
- 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/api.test.ts +445 -0
- package/src/runtime/core/modules/api.ts +453 -0
- package/src/runtime/core/modules/cancel.test.ts +310 -0
- package/src/runtime/core/modules/cancel.ts +119 -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 +77 -6
- 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 +180 -0
- package/src/runtime/core/modules/pages.test.ts +47 -2
- package/src/runtime/core/modules/pages.ts +23 -1
- 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/surfaces.ts +10 -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 +118 -5
- package/src/runtime/engine/nanosdk.ts +102 -7
- package/src/runtime/index.ts +12 -0
- package/src/toolkit/artifact.ts +8 -0
- package/src/toolkit/derivers/api.test.ts +219 -0
- package/src/toolkit/derivers/api.ts +255 -0
- package/src/toolkit/gen.test.ts +39 -0
- package/src/toolkit/gen.ts +24 -4
- package/src/toolkit/index.ts +41 -1
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import { 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 { makeGateway } from "./gateway.ts";
|
|
8
|
+
import type { Table } from "./gateway.ts";
|
|
9
|
+
import { DataLayer, type ProvisionedSource } from "./datasource.ts";
|
|
10
|
+
import type { AppApi } from "../context.ts";
|
|
11
|
+
import type { EngineClient, ProcessInstanceSnapshot } from "../host.ts";
|
|
12
|
+
import type { InstanceTracking } from "../manifest.ts";
|
|
13
|
+
import { cancelInstanceReconciling } from "./cancel.ts";
|
|
14
|
+
|
|
15
|
+
type State = ProcessInstanceSnapshot["state"];
|
|
16
|
+
|
|
17
|
+
const PR_DDL = `CREATE TABLE pull_requests (
|
|
18
|
+
pr_key TEXT PRIMARY KEY,
|
|
19
|
+
process_key TEXT,
|
|
20
|
+
status TEXT NOT NULL
|
|
21
|
+
);`;
|
|
22
|
+
|
|
23
|
+
interface PrRow {
|
|
24
|
+
pr_key: string;
|
|
25
|
+
process_key: string | null;
|
|
26
|
+
status: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// The pull_requests binding: an ACTIVE row flips to `abandoned` (and drops the process pointer)
|
|
30
|
+
// when its instance terminates — the exact patch the poll reconciler applies.
|
|
31
|
+
const PR_BINDING: InstanceTracking = {
|
|
32
|
+
table: "pull_requests",
|
|
33
|
+
keyField: "process_key",
|
|
34
|
+
statusField: "status",
|
|
35
|
+
activeStatuses: ["converging"],
|
|
36
|
+
onTerminated: { set: { status: "abandoned", process_key: null } },
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** An EngineClient double for the cancel primitive. `cancelInstance` runs `onCancel` (which may
|
|
40
|
+
* throw and/or mutate `states`); `searchProcessInstances` reflects the current `states` map, or
|
|
41
|
+
* throws when `verifyThrows` is set (to exercise the unverifiable-read path). */
|
|
42
|
+
function cancelEngine(opts: {
|
|
43
|
+
states: Record<string, State>;
|
|
44
|
+
onCancel: (key: string, states: Record<string, State>) => void;
|
|
45
|
+
verifyThrows?: boolean;
|
|
46
|
+
}): { engine: EngineClient; cancels: string[] } {
|
|
47
|
+
const cancels: string[] = [];
|
|
48
|
+
const notUsed = (m: string) => (): never => {
|
|
49
|
+
throw new Error(`cancelEngine.${m} is not exercised by this test`);
|
|
50
|
+
};
|
|
51
|
+
const engine: EngineClient = {
|
|
52
|
+
async cancelInstance(args: { processInstanceKey: string }): Promise<void> {
|
|
53
|
+
cancels.push(args.processInstanceKey);
|
|
54
|
+
opts.onCancel(args.processInstanceKey, opts.states);
|
|
55
|
+
},
|
|
56
|
+
async searchProcessInstances(filter?: {
|
|
57
|
+
processInstanceKeys?: string[];
|
|
58
|
+
state?: State;
|
|
59
|
+
}): Promise<ProcessInstanceSnapshot[]> {
|
|
60
|
+
if (opts.verifyThrows) throw new Error("read model unavailable");
|
|
61
|
+
const keys = filter?.processInstanceKeys ?? [];
|
|
62
|
+
const out: ProcessInstanceSnapshot[] = [];
|
|
63
|
+
for (const key of keys) {
|
|
64
|
+
const state = opts.states[key];
|
|
65
|
+
if (state && (!filter?.state || filter.state === state)) {
|
|
66
|
+
out.push({ processInstanceKey: key, state });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
},
|
|
71
|
+
deployResources: notUsed("deployResources"),
|
|
72
|
+
createInstance: notUsed("createInstance"),
|
|
73
|
+
publishMessage: notUsed("publishMessage"),
|
|
74
|
+
searchUserTasks: notUsed("searchUserTasks"),
|
|
75
|
+
completeUserTask: notUsed("completeUserTask"),
|
|
76
|
+
registerWorker: notUsed("registerWorker"),
|
|
77
|
+
close: notUsed("close"),
|
|
78
|
+
};
|
|
79
|
+
return { engine, cancels };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface Harness {
|
|
83
|
+
api: AppApi;
|
|
84
|
+
table: Table<PrRow>;
|
|
85
|
+
logs: { level: string; msg: string }[];
|
|
86
|
+
close: () => Promise<void>;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function withHarness(engine: EngineClient, seedStatus = "converging"): Promise<Harness> {
|
|
90
|
+
const dir = await mkdtemp(join(tmpdir(), "urban-cancel-"));
|
|
91
|
+
const host = createNodeHost({ cwd: dir, log: () => {} });
|
|
92
|
+
const db = host.openSqlite(join(dir, "test.db"));
|
|
93
|
+
db.exec(PR_DDL);
|
|
94
|
+
const source: ProvisionedSource = {
|
|
95
|
+
name: "app",
|
|
96
|
+
driver: "sqlite",
|
|
97
|
+
db,
|
|
98
|
+
source: makeGateway(db),
|
|
99
|
+
migrationsApplied: [],
|
|
100
|
+
close: () => db.close(),
|
|
101
|
+
};
|
|
102
|
+
const data = new DataLayer(new Map([["app", source]]), "app", {});
|
|
103
|
+
const table = data.table<PrRow>("pull_requests", "process_key");
|
|
104
|
+
await table.insert({ pr_key: "pr-1", process_key: "100", status: seedStatus });
|
|
105
|
+
const logs: { level: string; msg: string }[] = [];
|
|
106
|
+
const api: AppApi = {
|
|
107
|
+
manifest: { schemaVersion: 1, id: "app", name: "App" },
|
|
108
|
+
data,
|
|
109
|
+
engine,
|
|
110
|
+
env: () => undefined,
|
|
111
|
+
log: (level, msg) => logs.push({ level, msg }),
|
|
112
|
+
};
|
|
113
|
+
return {
|
|
114
|
+
api,
|
|
115
|
+
table,
|
|
116
|
+
logs,
|
|
117
|
+
close: async () => {
|
|
118
|
+
db.close();
|
|
119
|
+
await rm(dir, { recursive: true, force: true });
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
test("terminating a tracked instance reconciles its row immediately", async () => {
|
|
125
|
+
// cancelInstance succeeds (no throw) and the engine commits the termination.
|
|
126
|
+
const { engine, cancels } = cancelEngine({
|
|
127
|
+
states: { "100": "ACTIVE" },
|
|
128
|
+
onCancel: (key, states) => {
|
|
129
|
+
states[key] = "TERMINATED";
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
const h = await withHarness(engine);
|
|
133
|
+
try {
|
|
134
|
+
const r = await cancelInstanceReconciling(h.api, [PR_BINDING], "100");
|
|
135
|
+
assert.deepEqual(cancels, ["100"]);
|
|
136
|
+
assert.equal(r.ok, true);
|
|
137
|
+
assert.equal(r.state, "TERMINATED");
|
|
138
|
+
assert.equal(r.reconciled, 1);
|
|
139
|
+
const row = await h.table.get("100");
|
|
140
|
+
assert.equal(row, undefined); // process_key was cleared to null, so no row keyed by "100"
|
|
141
|
+
const byPr = (await h.table.all()).find((x) => x.pr_key === "pr-1");
|
|
142
|
+
assert.equal(byPr?.status, "abandoned");
|
|
143
|
+
assert.equal(byPr?.process_key, null);
|
|
144
|
+
} finally {
|
|
145
|
+
await h.close();
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("a cancel that throws but reads back TERMINATED is idempotent success + reconciles", async () => {
|
|
150
|
+
// The instance was already terminated; the engine rejects a second cancel, but the verify read
|
|
151
|
+
// confirms it is gone — treat as success and still reconcile the (stale-active) row.
|
|
152
|
+
const { engine } = cancelEngine({
|
|
153
|
+
states: { "100": "TERMINATED" },
|
|
154
|
+
onCancel: () => {
|
|
155
|
+
throw new Error("instance is not active");
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
const h = await withHarness(engine);
|
|
159
|
+
try {
|
|
160
|
+
const r = await cancelInstanceReconciling(h.api, [PR_BINDING], "100");
|
|
161
|
+
assert.equal(r.ok, true);
|
|
162
|
+
assert.equal(r.state, "TERMINATED");
|
|
163
|
+
assert.equal(r.reconciled, 1);
|
|
164
|
+
const byPr = (await h.table.all()).find((x) => x.pr_key === "pr-1");
|
|
165
|
+
assert.equal(byPr?.status, "abandoned");
|
|
166
|
+
} finally {
|
|
167
|
+
await h.close();
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("a failed cancel with a still-ACTIVE instance is an honest failure and touches no row", async () => {
|
|
172
|
+
const { engine } = cancelEngine({
|
|
173
|
+
states: { "100": "ACTIVE" },
|
|
174
|
+
onCancel: () => {
|
|
175
|
+
throw new Error("permission denied");
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
const h = await withHarness(engine);
|
|
179
|
+
try {
|
|
180
|
+
const r = await cancelInstanceReconciling(h.api, [PR_BINDING], "100");
|
|
181
|
+
assert.equal(r.ok, false);
|
|
182
|
+
assert.equal(r.state, "ACTIVE");
|
|
183
|
+
assert.equal(r.reconciled, 0);
|
|
184
|
+
assert.match(r.error ?? "", /permission denied/);
|
|
185
|
+
const row = await h.table.get("100");
|
|
186
|
+
assert.equal(row?.status, "converging"); // untouched — instance still running
|
|
187
|
+
assert.ok(h.logs.some((l) => l.level === "error"));
|
|
188
|
+
} finally {
|
|
189
|
+
await h.close();
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("a COMPLETED instance is not reconciled (the app's finalize logic owns that row)", async () => {
|
|
194
|
+
const { engine } = cancelEngine({
|
|
195
|
+
states: { "100": "COMPLETED" },
|
|
196
|
+
onCancel: () => {}, // engine accepts the request; instance had already completed
|
|
197
|
+
});
|
|
198
|
+
const h = await withHarness(engine);
|
|
199
|
+
try {
|
|
200
|
+
const r = await cancelInstanceReconciling(h.api, [PR_BINDING], "100");
|
|
201
|
+
assert.equal(r.ok, true);
|
|
202
|
+
assert.equal(r.state, "COMPLETED");
|
|
203
|
+
assert.equal(r.reconciled, 0);
|
|
204
|
+
const row = await h.table.get("100");
|
|
205
|
+
assert.equal(row?.status, "converging"); // left for the finalize worker, not clobbered
|
|
206
|
+
} finally {
|
|
207
|
+
await h.close();
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("a gone/unknown instance is idempotent success with no reconcile", async () => {
|
|
212
|
+
const { engine } = cancelEngine({
|
|
213
|
+
states: {}, // the engine has no record of this key
|
|
214
|
+
onCancel: () => {},
|
|
215
|
+
});
|
|
216
|
+
const h = await withHarness(engine);
|
|
217
|
+
try {
|
|
218
|
+
const r = await cancelInstanceReconciling(h.api, [PR_BINDING], "999999");
|
|
219
|
+
assert.equal(r.ok, true);
|
|
220
|
+
assert.equal(r.state, "gone");
|
|
221
|
+
assert.equal(r.reconciled, 0);
|
|
222
|
+
} finally {
|
|
223
|
+
await h.close();
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("a TERMINATED instance with no matching binding row reconciles nothing", async () => {
|
|
228
|
+
const { engine } = cancelEngine({
|
|
229
|
+
states: { "555": "TERMINATED" },
|
|
230
|
+
onCancel: () => {},
|
|
231
|
+
});
|
|
232
|
+
const h = await withHarness(engine);
|
|
233
|
+
try {
|
|
234
|
+
// "555" matches no row (the seeded row tracks "100").
|
|
235
|
+
const r = await cancelInstanceReconciling(h.api, [PR_BINDING], "555");
|
|
236
|
+
assert.equal(r.ok, true);
|
|
237
|
+
assert.equal(r.state, "TERMINATED");
|
|
238
|
+
assert.equal(r.reconciled, 0);
|
|
239
|
+
const row = await h.table.get("100");
|
|
240
|
+
assert.equal(row?.status, "converging");
|
|
241
|
+
} finally {
|
|
242
|
+
await h.close();
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("a reconcile write failure does not fail the cancel (the engine already terminated it)", async () => {
|
|
247
|
+
const { engine } = cancelEngine({
|
|
248
|
+
states: { "100": "ACTIVE" },
|
|
249
|
+
onCancel: (key, states) => {
|
|
250
|
+
states[key] = "TERMINATED";
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
const h = await withHarness(engine);
|
|
254
|
+
try {
|
|
255
|
+
// A binding pointing at a table that does not exist makes the reconcile UPDATE throw.
|
|
256
|
+
const badBinding: InstanceTracking = {
|
|
257
|
+
table: "does_not_exist",
|
|
258
|
+
keyField: "process_key",
|
|
259
|
+
onTerminated: { set: { status: "abandoned" } },
|
|
260
|
+
};
|
|
261
|
+
const r = await cancelInstanceReconciling(h.api, [badBinding], "100");
|
|
262
|
+
assert.equal(r.ok, true); // engine termination succeeded — the cancel is a success
|
|
263
|
+
assert.equal(r.state, "TERMINATED");
|
|
264
|
+
assert.equal(r.reconciled, 0);
|
|
265
|
+
assert.ok(r.error); // the reconcile failure is surfaced, not swallowed
|
|
266
|
+
assert.ok(h.logs.some((l) => l.level === "error"));
|
|
267
|
+
} finally {
|
|
268
|
+
await h.close();
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("an accepted cancel whose verify read fails is trusted (gone) — the 204 committed", async () => {
|
|
273
|
+
const { engine } = cancelEngine({
|
|
274
|
+
states: { "100": "ACTIVE" },
|
|
275
|
+
onCancel: () => {}, // accepted
|
|
276
|
+
verifyThrows: true,
|
|
277
|
+
});
|
|
278
|
+
const h = await withHarness(engine);
|
|
279
|
+
try {
|
|
280
|
+
const r = await cancelInstanceReconciling(h.api, [PR_BINDING], "100");
|
|
281
|
+
assert.equal(r.ok, true);
|
|
282
|
+
assert.equal(r.state, "gone");
|
|
283
|
+
assert.equal(r.reconciled, 0);
|
|
284
|
+
assert.ok(h.logs.some((l) => l.level === "warn"));
|
|
285
|
+
} finally {
|
|
286
|
+
await h.close();
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("a thrown cancel whose verify read also fails is reported as an honest failure", async () => {
|
|
291
|
+
const { engine } = cancelEngine({
|
|
292
|
+
states: { "100": "ACTIVE" },
|
|
293
|
+
onCancel: () => {
|
|
294
|
+
throw new Error("transport error");
|
|
295
|
+
},
|
|
296
|
+
verifyThrows: true,
|
|
297
|
+
});
|
|
298
|
+
const h = await withHarness(engine);
|
|
299
|
+
try {
|
|
300
|
+
const r = await cancelInstanceReconciling(h.api, [PR_BINDING], "100");
|
|
301
|
+
assert.equal(r.ok, false);
|
|
302
|
+
assert.equal(r.state, "ACTIVE");
|
|
303
|
+
assert.equal(r.reconciled, 0);
|
|
304
|
+
assert.match(r.error ?? "", /transport error/);
|
|
305
|
+
const row = await h.table.get("100");
|
|
306
|
+
assert.equal(row?.status, "converging");
|
|
307
|
+
} finally {
|
|
308
|
+
await h.close();
|
|
309
|
+
}
|
|
310
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// The Urban-native "cancel a process instance" primitive.
|
|
2
|
+
//
|
|
3
|
+
// Cancelling a running process from a page action must do two honest things that a bare
|
|
4
|
+
// `engine.cancelInstance` call does not:
|
|
5
|
+
//
|
|
6
|
+
// 1. Report the *real* outcome. A cancel can fail (permissions, the instance already gone,
|
|
7
|
+
// a transport error). Swallowing that and reporting success leaves an instance running in
|
|
8
|
+
// the engine while the UI claims it stopped — the worst possible lie for a "cancel" button.
|
|
9
|
+
// 2. Reconcile the tracked row immediately. Apps bind process instances to rows via
|
|
10
|
+
// `instanceTracking`; the poll reconciler flips a row to its terminal status within a few
|
|
11
|
+
// seconds of the instance terminating. When *we* are the one terminating it, we can apply
|
|
12
|
+
// that same declared patch right away instead of waiting for the next poll tick.
|
|
13
|
+
//
|
|
14
|
+
// This primitive keys success on the engine's response, then reconciles off the read model: a
|
|
15
|
+
// non-throwing `cancelInstance` is a committed termination (trusted even if the read model still
|
|
16
|
+
// lags at ACTIVE), while a throw whose instance reads back still-ACTIVE is an honest failure. It
|
|
17
|
+
// reuses the instanceTracking bindings' `onTerminated.set` patch, so the immediate reconcile can
|
|
18
|
+
// never drift from the poll reconciler's.
|
|
19
|
+
|
|
20
|
+
import type { AppApi } from "../context.ts";
|
|
21
|
+
import type { InstanceTracking } from "../manifest.ts";
|
|
22
|
+
import { errorMessage } from "../guards.ts";
|
|
23
|
+
import { reconcileTerminatedKey } from "./instance-tracking.ts";
|
|
24
|
+
|
|
25
|
+
export type CancelInstanceState = "ACTIVE" | "COMPLETED" | "TERMINATED" | "gone";
|
|
26
|
+
|
|
27
|
+
export interface CancelInstanceResult {
|
|
28
|
+
ok: boolean;
|
|
29
|
+
processInstanceKey: string;
|
|
30
|
+
/** The instance state as read back from the engine after the cancel attempt.
|
|
31
|
+
* `"gone"` means the engine has no record of the key (already cleaned up / never existed). */
|
|
32
|
+
state: CancelInstanceState;
|
|
33
|
+
/** Rows flipped to their terminal status by the immediate instanceTracking reconcile. */
|
|
34
|
+
reconciled: number;
|
|
35
|
+
error?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type CancelApi = Pick<AppApi, "engine" | "data" | "log">;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Cancel a process instance honestly: terminate it, verify it is actually gone, and immediately
|
|
42
|
+
* reconcile any instanceTracking row bound to its key.
|
|
43
|
+
*
|
|
44
|
+
* Outcome rules:
|
|
45
|
+
* - `cancelInstance` returns without throwing → the engine accepted the termination (a committed
|
|
46
|
+
* 204); trust it even if the read model still lags at ACTIVE.
|
|
47
|
+
* - `cancelInstance` throws but the instance reads back non-ACTIVE (already TERMINATED/COMPLETED
|
|
48
|
+
* /gone) → the cancel was effectively idempotent; treat as success.
|
|
49
|
+
* - `cancelInstance` throws and the instance is still ACTIVE → an honest failure: report it and
|
|
50
|
+
* do NOT touch any row (the instance is still running).
|
|
51
|
+
*
|
|
52
|
+
* Reconcile only on `TERMINATED`: a COMPLETED instance's terminal row-write is owned by the app's
|
|
53
|
+
* own finalize logic, and a gone/ACTIVE instance has nothing to flip here.
|
|
54
|
+
*/
|
|
55
|
+
export async function cancelInstanceReconciling(
|
|
56
|
+
api: CancelApi,
|
|
57
|
+
bindings: InstanceTracking[],
|
|
58
|
+
processInstanceKey: string,
|
|
59
|
+
): Promise<CancelInstanceResult> {
|
|
60
|
+
const key = String(processInstanceKey);
|
|
61
|
+
|
|
62
|
+
let threw: unknown;
|
|
63
|
+
try {
|
|
64
|
+
await api.engine.cancelInstance({ processInstanceKey: key });
|
|
65
|
+
} catch (error) {
|
|
66
|
+
threw = error;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const state = await readState(api, key, threw);
|
|
70
|
+
|
|
71
|
+
if (threw && state === "ACTIVE") {
|
|
72
|
+
// The cancel genuinely failed and the instance is still running — never flip a row to a
|
|
73
|
+
// terminal status while the engine keeps executing it.
|
|
74
|
+
api.log("error", "cancel: engine termination failed, instance still active", {
|
|
75
|
+
processInstanceKey: key,
|
|
76
|
+
error: errorMessage(threw),
|
|
77
|
+
});
|
|
78
|
+
return { ok: false, processInstanceKey: key, state, reconciled: 0, error: errorMessage(threw) };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let reconciled = 0;
|
|
82
|
+
if (state === "TERMINATED") {
|
|
83
|
+
try {
|
|
84
|
+
reconciled = await reconcileTerminatedKey(api, bindings, key);
|
|
85
|
+
} catch (error) {
|
|
86
|
+
// The engine termination itself succeeded — a reconcile *write* failure (bad binding,
|
|
87
|
+
// transient DB error) must not turn that into an unhandled 500. Log it and let the
|
|
88
|
+
// instanceTracking poll reconciler catch the row up on its next tick.
|
|
89
|
+
api.log("error", "cancel: instance terminated but row reconcile failed", {
|
|
90
|
+
processInstanceKey: key,
|
|
91
|
+
error: errorMessage(error),
|
|
92
|
+
});
|
|
93
|
+
return { ok: true, processInstanceKey: key, state, reconciled: 0, error: errorMessage(error) };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return { ok: true, processInstanceKey: key, state, reconciled };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Read the instance state back from the engine to verify what actually happened. When the read
|
|
101
|
+
* itself fails we fall back to the cancel call's own result: a thrown cancel with an unverifiable
|
|
102
|
+
* state is reported as still-ACTIVE (honest worst case); an accepted cancel is trusted as gone. */
|
|
103
|
+
async function readState(
|
|
104
|
+
api: CancelApi,
|
|
105
|
+
key: string,
|
|
106
|
+
threw: unknown,
|
|
107
|
+
): Promise<CancelInstanceState> {
|
|
108
|
+
try {
|
|
109
|
+
const [snapshot] = await api.engine.searchProcessInstances({ processInstanceKeys: [key] });
|
|
110
|
+
if (snapshot) return snapshot.state;
|
|
111
|
+
return "gone";
|
|
112
|
+
} catch (error) {
|
|
113
|
+
api.log("warn", "cancel: could not verify post-cancel state", {
|
|
114
|
+
processInstanceKey: key,
|
|
115
|
+
error: errorMessage(error),
|
|
116
|
+
});
|
|
117
|
+
return threw ? "ACTIVE" : "gone";
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -17,6 +17,7 @@ class MiniEngine implements EngineClient {
|
|
|
17
17
|
async publishMessage(): Promise<void> {}
|
|
18
18
|
async searchUserTasks(): Promise<[]> { return []; }
|
|
19
19
|
async completeUserTask(): Promise<void> {}
|
|
20
|
+
async searchProcessInstances(): Promise<[]> { return []; }
|
|
20
21
|
async registerWorker(jobType: string, handler: JobHandler): Promise<WorkerSubscription> {
|
|
21
22
|
this.workers.set(jobType, handler);
|
|
22
23
|
return { jobType, unsubscribe: async () => void this.workers.delete(jobType) };
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
type ConnectorWorkerJob,
|
|
25
25
|
type DefinedConnectorWorker,
|
|
26
26
|
} from "../../connector-worker-sdk.ts";
|
|
27
|
+
import { runInJobContext } from "../execContext.ts";
|
|
27
28
|
|
|
28
29
|
/** A config field a pack declares (subset of ext-types `ConfigField`): an env
|
|
29
30
|
* pointer with an optional default. */
|
|
@@ -244,7 +245,12 @@ export async function mountConnectors(ctx: RuntimeContext, app: AppApi): Promise
|
|
|
244
245
|
);
|
|
245
246
|
}
|
|
246
247
|
|
|
247
|
-
const
|
|
248
|
+
const inner = adaptConnectorHandler(worker);
|
|
249
|
+
const wrapped: JobHandler = (job) =>
|
|
250
|
+
runInJobContext(
|
|
251
|
+
{ instanceKey: job.processInstanceKey, elementId: job.elementId, jobType },
|
|
252
|
+
() => inner(job),
|
|
253
|
+
);
|
|
248
254
|
const sub = await ctx.engine.registerWorker(jobType, wrapped, {
|
|
249
255
|
workerName: `${ctx.manifest.id}:${jobType}`,
|
|
250
256
|
// The pack's `defineWorker({ maxParallelJobs })` is the authoritative runtime
|
|
@@ -17,6 +17,7 @@ const noEngine: EngineClient = {
|
|
|
17
17
|
publishMessage: async () => {},
|
|
18
18
|
searchUserTasks: async () => [],
|
|
19
19
|
completeUserTask: async () => {},
|
|
20
|
+
searchProcessInstances: async () => [],
|
|
20
21
|
registerWorker: async (jobType) => ({ jobType, unsubscribe: async () => {} }),
|
|
21
22
|
close: async () => {},
|
|
22
23
|
};
|
|
@@ -6,7 +6,14 @@
|
|
|
6
6
|
import type { RuntimeContext } from "../context.ts";
|
|
7
7
|
import type { HostContext, SqliteDb } from "../host.ts";
|
|
8
8
|
import type { AppManifest, DataSource, DomainType } from "../manifest.ts";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
makeGateway,
|
|
11
|
+
Table,
|
|
12
|
+
type DataSource as GatewayDataSource,
|
|
13
|
+
type InsertObserver,
|
|
14
|
+
} from "./gateway.ts";
|
|
15
|
+
import { currentJobContext } from "../execContext.ts";
|
|
16
|
+
import { isAbsolutePath } from "../../../toolkit/artifact.ts";
|
|
10
17
|
|
|
11
18
|
export function sqlitePathFromUrl(url: string): string {
|
|
12
19
|
// Accept "file:./x.db", "file:x.db", "sqlite:./x.db" or a bare path.
|
|
@@ -19,10 +26,10 @@ export function sqlitePathFromUrl(url: string): string {
|
|
|
19
26
|
* that starts with a single backslash ("\data\app.db") and a Windows UNC path
|
|
20
27
|
* ("\\\\server\\share"). A single leading backslash covers both the drive-root and UNC cases,
|
|
21
28
|
* matching Node's `path.win32.isAbsolute`. Used by `resolveAppPath` so a caller-supplied absolute
|
|
22
|
-
* path is never incorrectly prefixed with the app root.
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
}
|
|
29
|
+
* path is never incorrectly prefixed with the app root. Defined once in `toolkit/artifact.ts` (the
|
|
30
|
+
* shared, dependency-free layer) and re-exported here so the toolkit's gen path-join and this
|
|
31
|
+
* runtime resolver apply the identical rule (no gen/runtime drift). */
|
|
32
|
+
export { isAbsolutePath };
|
|
26
33
|
|
|
27
34
|
/** Resolve `p` against the app `root`: an absolute `p` (see `isAbsolutePath`) is returned as-is;
|
|
28
35
|
* a relative `p` is joined onto `root`. Trims a trailing separator of either kind off `root` so
|
|
@@ -51,6 +58,65 @@ export function resolveAppPath(root: string, p: string): string {
|
|
|
51
58
|
* both import this constant and the ledger name can never drift between application and listing. */
|
|
52
59
|
export const MIGRATIONS_TABLE = "_urban_migrations";
|
|
53
60
|
|
|
61
|
+
/** Name of the urban-owned write-provenance sidecar table (ProcessOS domain-signal plane, D0).
|
|
62
|
+
* One append per app `Table.insert` performed inside a job, linking the written row —
|
|
63
|
+
* `(source, table_name, pk_value)` — to the process instance/element that wrote it. Follows the
|
|
64
|
+
* `_urban_` convention so it is automatically excluded from domain-model introspection,
|
|
65
|
+
* DB Manager, and forms (see `gateway.ts` `schema()`), and never collides with an app table. */
|
|
66
|
+
export const WRITE_PROVENANCE_TABLE = "_urban_write_provenance";
|
|
67
|
+
|
|
68
|
+
/** Provision the write-provenance sidecar on a source (idempotent). Domain-free: it records only
|
|
69
|
+
* the join key + timestamps, never any row content. */
|
|
70
|
+
export function ensureProvenanceTable(db: SqliteDb): void {
|
|
71
|
+
db.exec(
|
|
72
|
+
`CREATE TABLE IF NOT EXISTS ${WRITE_PROVENANCE_TABLE} (` +
|
|
73
|
+
"seq INTEGER PRIMARY KEY AUTOINCREMENT, " +
|
|
74
|
+
"source TEXT NOT NULL, " +
|
|
75
|
+
"table_name TEXT NOT NULL, " +
|
|
76
|
+
"pk_value TEXT NOT NULL, " +
|
|
77
|
+
"instance_key TEXT, " +
|
|
78
|
+
"element_id TEXT, " +
|
|
79
|
+
"job_type TEXT, " +
|
|
80
|
+
"op TEXT NOT NULL DEFAULT 'insert', " +
|
|
81
|
+
"at TEXT NOT NULL)",
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Build the {@link InsertObserver} that captures write-provenance for one source. Best-effort:
|
|
86
|
+
* it records a row only when a job execution context with an instance key is active (a worker
|
|
87
|
+
* handler is running) — writes outside a job (e.g. HTTP actions) record nothing, degrading
|
|
88
|
+
* gracefully. It skips urban/engine-internal tables so the provenance table never records
|
|
89
|
+
* itself. It runs on the same connection as the insert, so inside a `tx` the provenance row is
|
|
90
|
+
* committed/rolled back atomically with the app write. `Table.insert` wraps this defensively, so
|
|
91
|
+
* a throw here can never break the app insert. */
|
|
92
|
+
export function makeProvenanceRecorder(
|
|
93
|
+
host: HostContext,
|
|
94
|
+
db: SqliteDb,
|
|
95
|
+
source: string,
|
|
96
|
+
): InsertObserver {
|
|
97
|
+
return (table, pk) => {
|
|
98
|
+
if (table.startsWith("_urban_") || table.startsWith("_nano_") || table.startsWith("sqlite_")) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const ctx = currentJobContext();
|
|
102
|
+
if (!ctx || ctx.instanceKey == null) return;
|
|
103
|
+
db.run(
|
|
104
|
+
`INSERT INTO ${WRITE_PROVENANCE_TABLE} ` +
|
|
105
|
+
"(source, table_name, pk_value, instance_key, element_id, job_type, op, at) " +
|
|
106
|
+
"VALUES (?, ?, ?, ?, ?, ?, 'insert', ?)",
|
|
107
|
+
[
|
|
108
|
+
source,
|
|
109
|
+
table,
|
|
110
|
+
String(pk),
|
|
111
|
+
ctx.instanceKey,
|
|
112
|
+
ctx.elementId ?? null,
|
|
113
|
+
ctx.jobType ?? null,
|
|
114
|
+
new Date(host.now()).toISOString(),
|
|
115
|
+
],
|
|
116
|
+
);
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
54
120
|
/** Resolve a datasource `url` to its on-disk SQLite path against `root`. The result is absolute
|
|
55
121
|
* when the resolved path is absolute (either because `url` names an absolute path or `root` is
|
|
56
122
|
* absolute); if `root` is relative (e.g. "." as used by the CLI/tests) a relative `url` stays
|
|
@@ -320,6 +386,11 @@ async function provisionSqlite(
|
|
|
320
386
|
const migrationsApplied = src.migrations
|
|
321
387
|
? await applyMigrations(ctx.host, db, ctx.root, src.migrations)
|
|
322
388
|
: [];
|
|
389
|
+
// Provision the write-provenance sidecar and wire the gateway's insert observer to it, so
|
|
390
|
+
// every app `Table.insert` performed inside a job is linked to its process instance/element
|
|
391
|
+
// (ProcessOS domain-signal capture, D0). Domain-free and non-invasive — the sidecar lives
|
|
392
|
+
// outside the domain model.
|
|
393
|
+
ensureProvenanceTable(db);
|
|
323
394
|
ctx.host.log("info", `datasource: provisioned "${name}"`, {
|
|
324
395
|
driver: "sqlite",
|
|
325
396
|
path: resolveSqlitePath(ctx.root, src.url),
|
|
@@ -329,7 +400,7 @@ async function provisionSqlite(
|
|
|
329
400
|
name,
|
|
330
401
|
driver: "sqlite",
|
|
331
402
|
db,
|
|
332
|
-
source: makeGateway(db),
|
|
403
|
+
source: makeGateway(db, makeProvenanceRecorder(ctx.host, db, name)),
|
|
333
404
|
migrationsApplied,
|
|
334
405
|
close: () => db.close(),
|
|
335
406
|
};
|
|
@@ -16,6 +16,12 @@ import type { SqliteDb } from "../host.ts";
|
|
|
16
16
|
|
|
17
17
|
export type Row = Record<string, unknown>;
|
|
18
18
|
|
|
19
|
+
/** Best-effort observer invoked after a successful {@link Table.insert}, with the table name and
|
|
20
|
+
* the new primary-key value. The DataLayer uses it to capture write-provenance. It MUST NOT
|
|
21
|
+
* throw — an app insert must never fail because provenance capture did — but {@link Table.insert}
|
|
22
|
+
* also wraps it defensively. */
|
|
23
|
+
export type InsertObserver = (table: string, pk: number | bigint) => void;
|
|
24
|
+
|
|
19
25
|
export interface ExecResult {
|
|
20
26
|
/** Rows changed by an INSERT/UPDATE/DELETE. */
|
|
21
27
|
changed: number;
|
|
@@ -92,11 +98,13 @@ export class Table<T extends object = Row> {
|
|
|
92
98
|
readonly name: string;
|
|
93
99
|
readonly pk: string;
|
|
94
100
|
readonly #src: DataSource;
|
|
101
|
+
readonly #onInsert?: InsertObserver;
|
|
95
102
|
|
|
96
|
-
constructor(src: DataSource, name: string, pk = "id") {
|
|
103
|
+
constructor(src: DataSource, name: string, pk = "id", onInsert?: InsertObserver) {
|
|
97
104
|
this.#src = src;
|
|
98
105
|
this.name = name;
|
|
99
106
|
this.pk = pk;
|
|
107
|
+
this.#onInsert = onInsert;
|
|
100
108
|
}
|
|
101
109
|
|
|
102
110
|
/** Insert one row; returns the new primary-key value (the inserted rowid for an INTEGER
|
|
@@ -124,6 +132,16 @@ export class Table<T extends object = Row> {
|
|
|
124
132
|
// silently returning 0, which a caller could mistake for a real primary key.
|
|
125
133
|
throw new Error(`Table(${this.name}).insert: driver reported no lastInsertId`);
|
|
126
134
|
}
|
|
135
|
+
if (this.#onInsert) {
|
|
136
|
+
// Provenance capture is strictly observational: never let it break the app insert, which
|
|
137
|
+
// executed successfully above. If this insert runs inside a transaction, commit/rollback
|
|
138
|
+
// semantics still belong to that surrounding transaction.
|
|
139
|
+
try {
|
|
140
|
+
this.#onInsert(this.name, r.lastInsertId);
|
|
141
|
+
} catch {
|
|
142
|
+
// swallow — an insert must never fail because provenance capture did
|
|
143
|
+
}
|
|
144
|
+
}
|
|
127
145
|
return r.lastInsertId;
|
|
128
146
|
}
|
|
129
147
|
|
|
@@ -202,8 +220,10 @@ export class Table<T extends object = Row> {
|
|
|
202
220
|
* are async to hold the app-facing contract; the work underneath is synchronous. */
|
|
203
221
|
class SqliteGateway implements DataSource {
|
|
204
222
|
readonly #db: SqliteDb;
|
|
205
|
-
|
|
223
|
+
readonly #onInsert?: InsertObserver;
|
|
224
|
+
constructor(db: SqliteDb, onInsert?: InsertObserver) {
|
|
206
225
|
this.#db = db;
|
|
226
|
+
this.#onInsert = onInsert;
|
|
207
227
|
}
|
|
208
228
|
|
|
209
229
|
async query<T extends object = Row>(sql: string, params: unknown[] = []): Promise<T[]> {
|
|
@@ -272,11 +292,13 @@ class SqliteGateway implements DataSource {
|
|
|
272
292
|
}
|
|
273
293
|
|
|
274
294
|
table<T extends object = Row>(name: string, pk = "id"): Table<T> {
|
|
275
|
-
return new Table<T>(this, name, pk);
|
|
295
|
+
return new Table<T>(this, name, pk, this.#onInsert);
|
|
276
296
|
}
|
|
277
297
|
}
|
|
278
298
|
|
|
279
|
-
/** Wrap a provisioned `SqliteDb` as the record-oriented `DataSource` gateway.
|
|
280
|
-
|
|
281
|
-
|
|
299
|
+
/** Wrap a provisioned `SqliteDb` as the record-oriented `DataSource` gateway. An optional
|
|
300
|
+
* `onInsert` observer is invoked after every successful `Table.insert` (used for
|
|
301
|
+
* write-provenance capture); it is threaded to each `Table` this gateway creates. */
|
|
302
|
+
export function makeGateway(db: SqliteDb, onInsert?: InsertObserver): DataSource {
|
|
303
|
+
return new SqliteGateway(db, onInsert);
|
|
282
304
|
}
|