@nanobpm/urban 0.80.0 → 0.82.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/manifest.d.ts +44 -11
- package/dist/runtime/core/modules/cancel.d.ts +13 -8
- package/dist/runtime/core/modules/cancel.js +40 -21
- package/dist/runtime/core/modules/datasource.js +7 -4
- package/dist/runtime/core/modules/gateway.js +5 -4
- package/dist/runtime/core/modules/instance-status-read-model.d.ts +56 -0
- package/dist/runtime/core/modules/instance-status-read-model.js +127 -0
- package/dist/runtime/core/modules/instance-tracking.d.ts +47 -19
- package/dist/runtime/core/modules/instance-tracking.js +451 -123
- package/dist/runtime/core/modules/open-user-tasks-store.d.ts +5 -0
- package/dist/runtime/core/modules/open-user-tasks-store.js +8 -0
- package/dist/runtime/core/modules/pages.js +6 -4
- package/dist/runtime/core/modules/workers.js +25 -1
- package/dist/runtime/core/read-model.d.ts +296 -5
- package/dist/runtime/core/read-model.js +1077 -39
- package/dist/runtime/core/validate.js +192 -0
- package/dist/runtime/index.d.ts +4 -3
- package/dist/runtime/index.js +11 -1
- package/package.json +1 -1
- package/src/runtime/core/manifest.ts +44 -11
- package/src/runtime/core/modules/cancel.test.ts +112 -38
- package/src/runtime/core/modules/cancel.ts +42 -22
- package/src/runtime/core/modules/datasource.ts +7 -4
- package/src/runtime/core/modules/gateway.ts +7 -4
- package/src/runtime/core/modules/instance-status-read-model.ts +156 -0
- package/src/runtime/core/modules/instance-tracking.test.ts +589 -60
- package/src/runtime/core/modules/instance-tracking.ts +469 -126
- package/src/runtime/core/modules/open-user-tasks-store.test.ts +11 -0
- package/src/runtime/core/modules/open-user-tasks-store.ts +11 -0
- package/src/runtime/core/modules/pages.ts +6 -5
- package/src/runtime/core/modules/workers.ts +24 -1
- package/src/runtime/core/read-model.test.ts +94 -0
- package/src/runtime/core/read-model.ts +1356 -45
- package/src/runtime/core/rollup.test.ts +818 -0
- package/src/runtime/core/validate.test.ts +341 -0
- package/src/runtime/core/validate.ts +188 -0
- package/src/runtime/index.ts +58 -0
|
@@ -9,9 +9,17 @@ import { makeGateway } from "./gateway.ts";
|
|
|
9
9
|
import type { Table } from "./gateway.ts";
|
|
10
10
|
import { DataLayer, type ProvisionedSource } from "./datasource.ts";
|
|
11
11
|
import type { AppApi } from "../context.ts";
|
|
12
|
-
import type { EngineClient, ProcessInstanceSnapshot } from "../host.ts";
|
|
12
|
+
import type { EngineClient, ProcessInstanceSnapshot, SqliteDb } from "../host.ts";
|
|
13
13
|
import type { InstanceTracking } from "../manifest.ts";
|
|
14
14
|
import { cancelInstanceReconciling } from "./cancel.ts";
|
|
15
|
+
import { INSTANCE_STATE_TABLE } from "./instance-state-store.ts";
|
|
16
|
+
|
|
17
|
+
// ADR 0065 — the instanceTracking writer→source inversion. Cancel no longer WRITES a terminal patch on
|
|
18
|
+
// the base row; it records the terminal fact into the canonical `_urban_instance_state` projection
|
|
19
|
+
// through the SAME `reconcileTerminatedKey` the poll reconciler uses (so cancel and poll cannot drift),
|
|
20
|
+
// and the terminal status is DERIVED from the projection by the read model. These tests therefore assert
|
|
21
|
+
// on the recorded projection state and `reconciled` (1 = terminal source recorded) — never a base-row
|
|
22
|
+
// mutation.
|
|
15
23
|
|
|
16
24
|
type State = ProcessInstanceSnapshot["state"];
|
|
17
25
|
|
|
@@ -27,8 +35,9 @@ interface PrRow {
|
|
|
27
35
|
status: string;
|
|
28
36
|
}
|
|
29
37
|
|
|
30
|
-
// The pull_requests binding: an ACTIVE row
|
|
31
|
-
//
|
|
38
|
+
// The pull_requests binding: an ACTIVE row derives to `abandoned` when its instance terminates — the
|
|
39
|
+
// value the derived terminal edge resolves to (no longer a stored patch). Cancel feeds the projection;
|
|
40
|
+
// the read model derives the status.
|
|
32
41
|
const PR_BINDING: InstanceTracking = {
|
|
33
42
|
table: "pull_requests",
|
|
34
43
|
keyField: "process_key",
|
|
@@ -85,6 +94,7 @@ function cancelEngine(opts: {
|
|
|
85
94
|
interface Harness {
|
|
86
95
|
api: AppApi;
|
|
87
96
|
table: Table<PrRow>;
|
|
97
|
+
db: SqliteDb;
|
|
88
98
|
logs: { level: string; msg: string }[];
|
|
89
99
|
close: () => Promise<void>;
|
|
90
100
|
}
|
|
@@ -120,6 +130,7 @@ async function withHarness(engine: EngineClient, seedStatus = "converging"): Pro
|
|
|
120
130
|
return {
|
|
121
131
|
api,
|
|
122
132
|
table,
|
|
133
|
+
db,
|
|
123
134
|
logs,
|
|
124
135
|
close: async () => {
|
|
125
136
|
db.close();
|
|
@@ -128,7 +139,22 @@ async function withHarness(engine: EngineClient, seedStatus = "converging"): Pro
|
|
|
128
139
|
};
|
|
129
140
|
}
|
|
130
141
|
|
|
131
|
-
|
|
142
|
+
/** The recorded engine lifecycle state for a key in the `_urban_instance_state` projection, or
|
|
143
|
+
* undefined when it was never recorded. The projection table is created lazily by cancel's own
|
|
144
|
+
* `ensureSchema`; return undefined if it does not exist yet (nothing was ever recorded). */
|
|
145
|
+
function projectedState(db: SqliteDb, key: string): string | undefined {
|
|
146
|
+
try {
|
|
147
|
+
const rows = db.all<{ state: string }>(
|
|
148
|
+
`SELECT state FROM ${INSTANCE_STATE_TABLE} WHERE process_instance_key = ?`,
|
|
149
|
+
[key],
|
|
150
|
+
);
|
|
151
|
+
return rows[0]?.state;
|
|
152
|
+
} catch {
|
|
153
|
+
return undefined; // projection table not provisioned ⇒ nothing recorded
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
test("terminating a tracked instance records its terminal source immediately (no base write)", async () => {
|
|
132
158
|
// cancelInstance succeeds (no throw) and the engine commits the termination.
|
|
133
159
|
const { engine, cancels } = cancelEngine({
|
|
134
160
|
states: { "100": "ACTIVE" },
|
|
@@ -143,19 +169,21 @@ test("terminating a tracked instance reconciles its row immediately", async () =
|
|
|
143
169
|
assert.equal(r.ok, true);
|
|
144
170
|
assert.equal(r.state, "TERMINATED");
|
|
145
171
|
assert.equal(r.reconciled, 1);
|
|
172
|
+
// The terminal fact was recorded into the canonical projection …
|
|
173
|
+
assert.equal(projectedState(h.db, "100"), "TERMINATED");
|
|
174
|
+
// … and the base row was left UNTOUCHED (source-not-writer); the derived read model surfaces the
|
|
175
|
+
// terminal status without a stored write.
|
|
146
176
|
const row = await h.table.get("100");
|
|
147
|
-
assert.equal(row,
|
|
148
|
-
|
|
149
|
-
assert.equal(byPr?.status, "abandoned");
|
|
150
|
-
assert.equal(byPr?.process_key, null);
|
|
177
|
+
assert.equal(row?.status, "converging");
|
|
178
|
+
assert.equal(row?.process_key, "100");
|
|
151
179
|
} finally {
|
|
152
180
|
await h.close();
|
|
153
181
|
}
|
|
154
182
|
});
|
|
155
183
|
|
|
156
|
-
test("a cancel that throws but reads back TERMINATED is idempotent success +
|
|
184
|
+
test("a cancel that throws but reads back TERMINATED is idempotent success + records the source", async () => {
|
|
157
185
|
// The instance was already terminated; the engine rejects a second cancel, but the verify read
|
|
158
|
-
// confirms it is gone — treat as success and still
|
|
186
|
+
// confirms it is gone — treat as success and still record the terminal source.
|
|
159
187
|
const { engine } = cancelEngine({
|
|
160
188
|
states: { "100": "TERMINATED" },
|
|
161
189
|
onCancel: () => {
|
|
@@ -168,14 +196,13 @@ test("a cancel that throws but reads back TERMINATED is idempotent success + rec
|
|
|
168
196
|
assert.equal(r.ok, true);
|
|
169
197
|
assert.equal(r.state, "TERMINATED");
|
|
170
198
|
assert.equal(r.reconciled, 1);
|
|
171
|
-
|
|
172
|
-
assert.equal(byPr?.status, "abandoned");
|
|
199
|
+
assert.equal(projectedState(h.db, "100"), "TERMINATED");
|
|
173
200
|
} finally {
|
|
174
201
|
await h.close();
|
|
175
202
|
}
|
|
176
203
|
});
|
|
177
204
|
|
|
178
|
-
test("a failed cancel with a still-ACTIVE instance is an honest failure and
|
|
205
|
+
test("a failed cancel with a still-ACTIVE instance is an honest failure and records nothing", async () => {
|
|
179
206
|
const { engine } = cancelEngine({
|
|
180
207
|
states: { "100": "ACTIVE" },
|
|
181
208
|
onCancel: () => {
|
|
@@ -189,15 +216,38 @@ test("a failed cancel with a still-ACTIVE instance is an honest failure and touc
|
|
|
189
216
|
assert.equal(r.state, "ACTIVE");
|
|
190
217
|
assert.equal(r.reconciled, 0);
|
|
191
218
|
assert.match(r.error ?? "", /permission denied/);
|
|
219
|
+
assert.equal(projectedState(h.db, "100"), undefined); // instance still running — nothing recorded
|
|
192
220
|
const row = await h.table.get("100");
|
|
193
|
-
assert.equal(row?.status, "converging"); // untouched
|
|
221
|
+
assert.equal(row?.status, "converging"); // untouched
|
|
194
222
|
assert.ok(h.logs.some((l) => l.level === "error"));
|
|
195
223
|
} finally {
|
|
196
224
|
await h.close();
|
|
197
225
|
}
|
|
198
226
|
});
|
|
199
227
|
|
|
200
|
-
test("
|
|
228
|
+
test("an accepted cancel that still reads ACTIVE (engine lag) records the terminal source", async () => {
|
|
229
|
+
// The engine accepted the 204 but its query store still lags at ACTIVE. The cancel IS committed, so
|
|
230
|
+
// the terminal source must be recorded now — otherwise the derived view stays on the stored worker
|
|
231
|
+
// status until a poll catches up (and never, if the instance is later cleaned up). accepted-cancel/lag.
|
|
232
|
+
const { engine } = cancelEngine({
|
|
233
|
+
states: { "100": "ACTIVE" },
|
|
234
|
+
onCancel: () => {}, // accepted (204); state deliberately NOT flipped, to model read-model lag
|
|
235
|
+
});
|
|
236
|
+
const h = await withHarness(engine);
|
|
237
|
+
try {
|
|
238
|
+
const r = await cancelInstanceReconciling(h.api, [PR_BINDING], "100");
|
|
239
|
+
assert.equal(r.ok, true);
|
|
240
|
+
assert.equal(r.state, "ACTIVE");
|
|
241
|
+
assert.equal(r.reconciled, 1);
|
|
242
|
+
assert.equal(projectedState(h.db, "100"), "TERMINATED");
|
|
243
|
+
const row = await h.table.get("100");
|
|
244
|
+
assert.equal(row?.status, "converging"); // base row untouched (source-not-writer)
|
|
245
|
+
} finally {
|
|
246
|
+
await h.close();
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("a COMPLETED instance is not recorded (the app's finalize logic owns that outcome)", async () => {
|
|
201
251
|
const { engine } = cancelEngine({
|
|
202
252
|
states: { "100": "COMPLETED" },
|
|
203
253
|
onCancel: () => {}, // engine accepts the request; instance had already completed
|
|
@@ -208,16 +258,17 @@ test("a COMPLETED instance is not reconciled (the app's finalize logic owns that
|
|
|
208
258
|
assert.equal(r.ok, true);
|
|
209
259
|
assert.equal(r.state, "COMPLETED");
|
|
210
260
|
assert.equal(r.reconciled, 0);
|
|
261
|
+
assert.equal(projectedState(h.db, "100"), undefined); // COMPLETED never recorded here
|
|
211
262
|
const row = await h.table.get("100");
|
|
212
|
-
assert.equal(row?.status, "converging"); // left for the finalize worker
|
|
263
|
+
assert.equal(row?.status, "converging"); // left for the finalize worker
|
|
213
264
|
} finally {
|
|
214
265
|
await h.close();
|
|
215
266
|
}
|
|
216
267
|
});
|
|
217
268
|
|
|
218
|
-
test("
|
|
269
|
+
test("an accepted cancel that reads back gone (engine cleanup) records the terminal source", async () => {
|
|
219
270
|
const { engine } = cancelEngine({
|
|
220
|
-
states: {}, // the engine
|
|
271
|
+
states: {}, // the engine no longer exposes the key (already cleaned up)
|
|
221
272
|
onCancel: () => {},
|
|
222
273
|
});
|
|
223
274
|
const h = await withHarness(engine);
|
|
@@ -225,13 +276,21 @@ test("a gone/unknown instance is idempotent success with no reconcile", async ()
|
|
|
225
276
|
const r = await cancelInstanceReconciling(h.api, [PR_BINDING], "999999");
|
|
226
277
|
assert.equal(r.ok, true);
|
|
227
278
|
assert.equal(r.state, "gone");
|
|
228
|
-
|
|
279
|
+
// The accepted 204 is a committed termination, so record the terminal source even though the read
|
|
280
|
+
// model no longer exposes the instance — otherwise the derived view could never leave the stored
|
|
281
|
+
// worker status (the poll cannot observe a gone instance either). accepted-cancel/read-lag.
|
|
282
|
+
assert.equal(r.reconciled, 1);
|
|
283
|
+
assert.equal(projectedState(h.db, "999999"), "TERMINATED");
|
|
229
284
|
} finally {
|
|
230
285
|
await h.close();
|
|
231
286
|
}
|
|
232
287
|
});
|
|
233
288
|
|
|
234
|
-
test("
|
|
289
|
+
test("records the terminal source even when no base row matches (the projection is binding-independent)", async () => {
|
|
290
|
+
// Post-inversion the terminal fact is recorded into the canonical projection for the cancelled key
|
|
291
|
+
// regardless of which base rows reference it — the projection is the engine-truth store, and the
|
|
292
|
+
// derived read model only surfaces the fact for rows that point at the key (none here). This agrees
|
|
293
|
+
// with the poll path (both record TERMINATED for the same key), so they cannot drift.
|
|
235
294
|
const { engine } = cancelEngine({
|
|
236
295
|
states: { "555": "TERMINATED" },
|
|
237
296
|
onCancel: () => {},
|
|
@@ -242,37 +301,47 @@ test("a TERMINATED instance with no matching binding row reconciles nothing", as
|
|
|
242
301
|
const r = await cancelInstanceReconciling(h.api, [PR_BINDING], "555");
|
|
243
302
|
assert.equal(r.ok, true);
|
|
244
303
|
assert.equal(r.state, "TERMINATED");
|
|
245
|
-
assert.equal(r.reconciled,
|
|
304
|
+
assert.equal(r.reconciled, 1);
|
|
305
|
+
assert.equal(projectedState(h.db, "555"), "TERMINATED");
|
|
246
306
|
const row = await h.table.get("100");
|
|
247
|
-
assert.equal(row?.status, "converging");
|
|
307
|
+
assert.equal(row?.status, "converging"); // the unrelated tracked row is untouched
|
|
248
308
|
} finally {
|
|
249
309
|
await h.close();
|
|
250
310
|
}
|
|
251
311
|
});
|
|
252
312
|
|
|
253
|
-
test("
|
|
313
|
+
test("cancel succeeds and records nothing when there is no default data source (absent-safe)", async () => {
|
|
314
|
+
// The projection feed is a sidecar on the app's own data source. With no default source there is
|
|
315
|
+
// nothing to record into — but a successful engine termination must STILL be reported as a success:
|
|
316
|
+
// a projection feed must never break a cancel. `reconcileTerminatedKey` is absent-safe (returns 0).
|
|
254
317
|
const { engine } = cancelEngine({
|
|
255
318
|
states: { "100": "ACTIVE" },
|
|
256
319
|
onCancel: (key, states) => {
|
|
257
320
|
states[key] = "TERMINATED";
|
|
258
321
|
},
|
|
259
322
|
});
|
|
260
|
-
const
|
|
323
|
+
const dir = await mkdtemp(join(tmpdir(), "urban-cancel-nosrc-"));
|
|
324
|
+
const logs: { level: string; msg: string }[] = [];
|
|
325
|
+
const data = new DataLayer(new Map(), undefined, {}); // no default source configured
|
|
326
|
+
const api: AppApi = {
|
|
327
|
+
manifest: { schemaVersion: 1, id: "app", name: "App" },
|
|
328
|
+
data,
|
|
329
|
+
engine,
|
|
330
|
+
env: () => undefined,
|
|
331
|
+
now: () => 0,
|
|
332
|
+
wait: () => Promise.resolve(),
|
|
333
|
+
log: createLogger((level, msg) => {
|
|
334
|
+
logs.push({ level, msg });
|
|
335
|
+
}),
|
|
336
|
+
};
|
|
261
337
|
try {
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
table: "does_not_exist",
|
|
265
|
-
keyField: "process_key",
|
|
266
|
-
onTerminated: { set: { status: "abandoned" } },
|
|
267
|
-
};
|
|
268
|
-
const r = await cancelInstanceReconciling(h.api, [badBinding], "100");
|
|
269
|
-
assert.equal(r.ok, true); // engine termination succeeded — the cancel is a success
|
|
338
|
+
const r = await cancelInstanceReconciling(api, [PR_BINDING], "100");
|
|
339
|
+
assert.equal(r.ok, true); // the engine termination committed — a success
|
|
270
340
|
assert.equal(r.state, "TERMINATED");
|
|
271
|
-
assert.equal(r.reconciled, 0);
|
|
272
|
-
assert.
|
|
273
|
-
assert.ok(h.logs.some((l) => l.level === "error"));
|
|
341
|
+
assert.equal(r.reconciled, 0); // nothing to record into
|
|
342
|
+
assert.equal(r.error, undefined); // absent-safe: not surfaced as an error
|
|
274
343
|
} finally {
|
|
275
|
-
await
|
|
344
|
+
await rm(dir, { recursive: true, force: true });
|
|
276
345
|
}
|
|
277
346
|
});
|
|
278
347
|
|
|
@@ -287,7 +356,10 @@ test("an accepted cancel whose verify read fails is trusted (gone) — the 204 c
|
|
|
287
356
|
const r = await cancelInstanceReconciling(h.api, [PR_BINDING], "100");
|
|
288
357
|
assert.equal(r.ok, true);
|
|
289
358
|
assert.equal(r.state, "gone");
|
|
290
|
-
|
|
359
|
+
// Accepted 204 → committed termination; record the terminal source even though the verify read
|
|
360
|
+
// could not confirm it (read-model lag / cleanup must not strand the derived status).
|
|
361
|
+
assert.equal(r.reconciled, 1);
|
|
362
|
+
assert.equal(projectedState(h.db, "100"), "TERMINATED");
|
|
291
363
|
assert.ok(h.logs.some((l) => l.level === "warn"));
|
|
292
364
|
} finally {
|
|
293
365
|
await h.close();
|
|
@@ -309,6 +381,7 @@ test("a thrown cancel whose verify read also fails is reported as an honest fail
|
|
|
309
381
|
assert.equal(r.state, "ACTIVE");
|
|
310
382
|
assert.equal(r.reconciled, 0);
|
|
311
383
|
assert.match(r.error ?? "", /transport error/);
|
|
384
|
+
assert.equal(projectedState(h.db, "100"), undefined);
|
|
312
385
|
const row = await h.table.get("100");
|
|
313
386
|
assert.equal(row?.status, "converging");
|
|
314
387
|
} finally {
|
|
@@ -336,8 +409,9 @@ test("a thrown cancel whose verify read comes back empty (gone) is an unverified
|
|
|
336
409
|
assert.equal(r.state, "gone");
|
|
337
410
|
assert.equal(r.reconciled, 0);
|
|
338
411
|
assert.match(r.error ?? "", /fetch failed/);
|
|
412
|
+
assert.equal(projectedState(h.db, "100"), undefined); // never record a possibly-live run as terminal
|
|
339
413
|
const row = await h.table.get("100");
|
|
340
|
-
assert.equal(row?.status, "converging");
|
|
414
|
+
assert.equal(row?.status, "converging");
|
|
341
415
|
assert.ok(h.logs.some((l) => l.level === "error"));
|
|
342
416
|
} finally {
|
|
343
417
|
await h.close();
|
|
@@ -6,21 +6,23 @@
|
|
|
6
6
|
// 1. Report the *real* outcome. A cancel can fail (permissions, the instance already gone,
|
|
7
7
|
// a transport error). Swallowing that and reporting success leaves an instance running in
|
|
8
8
|
// the engine while the UI claims it stopped — the worst possible lie for a "cancel" button.
|
|
9
|
-
// 2. Reconcile the tracked
|
|
10
|
-
// `instanceTracking`; the
|
|
11
|
-
//
|
|
12
|
-
//
|
|
9
|
+
// 2. Reconcile the tracked instance immediately. Apps bind process instances to rows via
|
|
10
|
+
// `instanceTracking`; since ADR 0065 the reconciler is a SOURCE, not a writer — its poll feeds
|
|
11
|
+
// engine truth into the canonical `urban_instance_state` projection and the terminal status edge
|
|
12
|
+
// is DERIVED from it (no stored column to tear; nano-workforce#422). When *we* are the one
|
|
13
|
+
// terminating the instance, we can record that same terminal source right away instead of waiting
|
|
14
|
+
// for the next poll tick, and the derived read model reflects it on the next read.
|
|
13
15
|
//
|
|
14
16
|
// This primitive keys success on the engine's response, then reconciles off the read model: a
|
|
15
17
|
// non-throwing `cancelInstance` is a committed termination (trusted even if the read model still
|
|
16
18
|
// lags at ACTIVE), while a throw that does not read back a positively-terminal state (still ACTIVE,
|
|
17
|
-
// or absent from the read model) is an honest failure. It
|
|
18
|
-
// `
|
|
19
|
+
// or absent from the read model) is an honest failure. It records the terminal source through the
|
|
20
|
+
// SAME `reconcileTerminatedKey` the poll reconciler uses, so cancel and poll can never drift.
|
|
19
21
|
|
|
20
22
|
import type { AppApi } from "../context.ts";
|
|
21
23
|
import type { InstanceTracking } from "../manifest.ts";
|
|
22
24
|
import { errorMessage } from "../guards.ts";
|
|
23
|
-
import { reconcileTerminatedKey } from "./instance-tracking.ts";
|
|
25
|
+
import { reconcileTerminatedKey, tryInstanceProjections } from "./instance-tracking.ts";
|
|
24
26
|
|
|
25
27
|
export type CancelInstanceState = "ACTIVE" | "COMPLETED" | "TERMINATED" | "gone";
|
|
26
28
|
|
|
@@ -30,7 +32,8 @@ export interface CancelInstanceResult {
|
|
|
30
32
|
/** The instance state as read back from the engine after the cancel attempt.
|
|
31
33
|
* `"gone"` means the engine has no record of the key (already cleaned up / never existed). */
|
|
32
34
|
state: CancelInstanceState;
|
|
33
|
-
/**
|
|
35
|
+
/** 1 when the immediate reconcile recorded the terminal source into the canonical instance-state
|
|
36
|
+
* projection (so the derived terminal status edge reflects it at once), else 0. */
|
|
34
37
|
reconciled: number;
|
|
35
38
|
error?: string;
|
|
36
39
|
}
|
|
@@ -39,22 +42,26 @@ type CancelApi = Pick<AppApi, "engine" | "data" | "log">;
|
|
|
39
42
|
|
|
40
43
|
/**
|
|
41
44
|
* Cancel a process instance honestly: terminate it, verify it is actually gone, and immediately
|
|
42
|
-
*
|
|
45
|
+
* record the terminal source for its key into the canonical instance-state projection.
|
|
43
46
|
*
|
|
44
47
|
* Outcome rules:
|
|
45
48
|
* - `cancelInstance` returns without throwing → the engine accepted the termination (a committed
|
|
46
|
-
* 204); trust it even if the read model still lags at ACTIVE
|
|
49
|
+
* 204); trust it even if the read model still lags at ACTIVE or has already cleaned the instance
|
|
50
|
+
* up ("gone"), and record the terminal source for the key so the derived view flips at once.
|
|
47
51
|
* - `cancelInstance` throws but the instance reads back positively terminal (TERMINATED/COMPLETED)
|
|
48
|
-
* → the cancel was effectively idempotent; treat as success.
|
|
49
|
-
*
|
|
52
|
+
* → the cancel was effectively idempotent; treat as success. A TERMINATED read records a terminal
|
|
53
|
+
* source here; a COMPLETED read intentionally records nothing.
|
|
50
54
|
* - `cancelInstance` throws and the read is NOT positively terminal — still ACTIVE, or "gone"
|
|
51
55
|
* (absent from the read model, which after an engine restart can transiently lack a live
|
|
52
|
-
* instance) → an honest failure: report it and do NOT
|
|
56
|
+
* instance) → an honest failure: report it and do NOT record anything (the instance may still be
|
|
53
57
|
* running). A throw is not a committed cancel, and absence from the read model is not proof of
|
|
54
58
|
* termination, so success is never inferred from it.
|
|
55
59
|
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
60
|
+
* Record when the cancellation is CONFIRMED — an accepted (non-throwing) cancel of any non-COMPLETED
|
|
61
|
+
* readback (ACTIVE lag / gone / TERMINATED), or a throw that reads back TERMINATED. A COMPLETED
|
|
62
|
+
* instance's terminal outcome is owned by the app's own finalize logic, so it records nothing. When
|
|
63
|
+
* the app declares no `instanceTracking` bindings there is nothing to reconcile, so the feed is
|
|
64
|
+
* skipped.
|
|
58
65
|
*/
|
|
59
66
|
export async function cancelInstanceReconciling(
|
|
60
67
|
api: CancelApi,
|
|
@@ -81,7 +88,7 @@ export async function cancelInstanceReconciling(
|
|
|
81
88
|
// before it rehydrates, so "gone" is absence-of-evidence, NOT proof of termination.
|
|
82
89
|
// Trust neither as success: claiming a cancel that may not have happened (while the run keeps
|
|
83
90
|
// executing) is the worst possible lie for a cancel button. Report ok:false so the caller
|
|
84
|
-
// surfaces it and retries; never
|
|
91
|
+
// surfaces it and retries; never record a terminal source while the instance may live.
|
|
85
92
|
api.log("error", "cancel: engine termination not confirmed, instance may still be active", {
|
|
86
93
|
processInstanceKey: key,
|
|
87
94
|
state,
|
|
@@ -91,14 +98,27 @@ export async function cancelInstanceReconciling(
|
|
|
91
98
|
}
|
|
92
99
|
|
|
93
100
|
let reconciled = 0;
|
|
94
|
-
|
|
101
|
+
// Record the terminal source whenever the cancellation is CONFIRMED — not only on a positively
|
|
102
|
+
// TERMINATED readback. An accepted (non-throwing) `cancelInstance` is a committed 204: the instance
|
|
103
|
+
// WILL terminate, even if the read model still lags at ACTIVE or the engine has already cleaned the
|
|
104
|
+
// instance up ("gone"). A throw that nonetheless reads back TERMINATED is idempotently confirmed too.
|
|
105
|
+
// Excluded: COMPLETED (its terminal outcome is owned by the app's own finalize logic) and the honest-
|
|
106
|
+
// failure case (a throw with a non-terminal read), which already returned ok:false above. Without
|
|
107
|
+
// recording on the accepted-but-lagging read, a committed cancel returned ok:true / reconciled:0 and
|
|
108
|
+
// the derived view stayed on the stored worker status until a poll caught up — and NEVER, if the
|
|
109
|
+
// engine no longer exposes the instance for the poll to observe (issue: accepted-cancel/read-lag).
|
|
110
|
+
const cancelConfirmed = !threw || state === "TERMINATED";
|
|
111
|
+
if (cancelConfirmed && state !== "COMPLETED" && bindings.length > 0) {
|
|
112
|
+
// Record through the SAME `reconcileTerminatedKey` the poll path uses, over the app's own
|
|
113
|
+
// projection stores, so cancel and poll can never drift. It is internally absent-safe and never
|
|
114
|
+
// throws through — a projection-write failure logs a warn and returns 0 rather than turning a
|
|
115
|
+
// successful engine termination into a 500; the poll reconciler catches the projection up on its
|
|
116
|
+
// next tick. The extra try/catch is belt-and-suspenders against a store construction fault.
|
|
95
117
|
try {
|
|
96
|
-
|
|
118
|
+
const projections = tryInstanceProjections(api);
|
|
119
|
+
reconciled = reconcileTerminatedKey(api, projections, key);
|
|
97
120
|
} catch (error) {
|
|
98
|
-
|
|
99
|
-
// transient DB error) must not turn that into an unhandled 500. Log it and let the
|
|
100
|
-
// instanceTracking poll reconciler catch the row up on its next tick.
|
|
101
|
-
api.log("error", "cancel: instance terminated but row reconcile failed", {
|
|
121
|
+
api.log("error", "cancel: instance terminated but projection reconcile failed", {
|
|
102
122
|
processInstanceKey: key,
|
|
103
123
|
error: errorMessage(error),
|
|
104
124
|
});
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
} from "./gateway.ts";
|
|
15
15
|
import { currentJobContext } from "../execContext.ts";
|
|
16
16
|
import { isAbsolutePath } from "../../../toolkit/artifact.ts";
|
|
17
|
+
import { SQL_IDENT, isReservedObjectName } from "../read-model.ts";
|
|
17
18
|
|
|
18
19
|
export function sqlitePathFromUrl(url: string): string {
|
|
19
20
|
// Accept "file:./x.db", "file:x.db", "sqlite:./x.db" or a bare path.
|
|
@@ -95,7 +96,7 @@ export function makeProvenanceRecorder(
|
|
|
95
96
|
source: string,
|
|
96
97
|
): InsertObserver {
|
|
97
98
|
return (table, pk) => {
|
|
98
|
-
if (
|
|
99
|
+
if (isReservedObjectName(table)) {
|
|
99
100
|
return;
|
|
100
101
|
}
|
|
101
102
|
const ctx = currentJobContext();
|
|
@@ -153,9 +154,11 @@ export async function openSqliteSource(
|
|
|
153
154
|
return db;
|
|
154
155
|
}
|
|
155
156
|
|
|
156
|
-
/** A safe unquoted SQL identifier. Table/column names are interpolated directly into SQL,
|
|
157
|
-
*
|
|
158
|
-
|
|
157
|
+
/** A safe unquoted SQL identifier check. Table/column names are interpolated directly into SQL, so
|
|
158
|
+
* reject anything that isn't a plain identifier to prevent invalid SQL / injection. The regex itself
|
|
159
|
+
* is the runtime-wide single source of truth in `read-model.ts` (No Drift Surfaces — a divergent copy
|
|
160
|
+
* could let table validation and read-model/manifest validation disagree); we reuse it here rather than
|
|
161
|
+
* re-declaring it, keeping this module's own error message. */
|
|
159
162
|
function assertSqlIdent(kind: string, name: string): string {
|
|
160
163
|
if (!SQL_IDENT.test(name)) {
|
|
161
164
|
throw new Error(`invalid ${kind} "${name}": must match ${SQL_IDENT.source}`);
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
// const o = await orders.get(id);
|
|
14
14
|
|
|
15
15
|
import type { SqliteDb } from "../host.ts";
|
|
16
|
+
import { RESERVED_OBJECT_PREFIXES } from "../read-model.ts";
|
|
16
17
|
|
|
17
18
|
export type Row = Record<string, unknown>;
|
|
18
19
|
|
|
@@ -264,11 +265,13 @@ class SqliteGateway implements DataSource {
|
|
|
264
265
|
// Exclude SQLite internals (`sqlite_%`) and Nano's own bookkeeping tables (`_urban_%` /
|
|
265
266
|
// `_nano_%`, e.g. the migrations ledger): neither is a user/domain object, so they must
|
|
266
267
|
// never surface in the domain model, DB Manager, or forms. The same exclusions apply to
|
|
267
|
-
// views.
|
|
268
|
+
// views. The prefix set is derived from RESERVED_OBJECT_PREFIXES (No Drift Surfaces) so this
|
|
269
|
+
// read-side filter cannot diverge from the write-side skip / manifest-validation rejection.
|
|
270
|
+
const reservedClause = RESERVED_OBJECT_PREFIXES.map(
|
|
271
|
+
(p) => `AND name NOT LIKE '${p.replace(/_/g, "\\_")}%' ESCAPE '\\'`,
|
|
272
|
+
).join(" ");
|
|
268
273
|
const tables = this.#db.all<{ name: string; type: string }>(
|
|
269
|
-
|
|
270
|
-
"AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '\\_urban\\_%' ESCAPE '\\' " +
|
|
271
|
-
"AND name NOT LIKE '\\_nano\\_%' ESCAPE '\\' ORDER BY name",
|
|
274
|
+
`SELECT name, type FROM sqlite_master WHERE type IN ('table','view') ${reservedClause} ORDER BY name`,
|
|
272
275
|
);
|
|
273
276
|
const out: TableMeta[] = [];
|
|
274
277
|
for (const t of tables) {
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// instance-status-read-model — the DERIVED half of the `instanceTracking` writer→source inversion
|
|
2
|
+
// (ADR 0065, surface #1; #439-L1 / #318). The reconciler used to WRITE the derivable status edges
|
|
3
|
+
// (`onTerminated.set` / `onWaitingHuman.set` applied as `table.update` patches); that stored-derived
|
|
4
|
+
// value tore from its inputs (nano-workforce#422: an answered escalation still showed a stale ⚠).
|
|
5
|
+
//
|
|
6
|
+
// After the inversion, the reconciler is a SOURCE — it feeds engine truth into the canonical
|
|
7
|
+
// projections (`urban_instance_state`, `urban_open_user_tasks`, ADR 0065 #1) — and the two derivable
|
|
8
|
+
// status edges are DERIVED here, once, in the closed `defineReadModel` expression DSL:
|
|
9
|
+
//
|
|
10
|
+
// terminal edge: EXISTS(urban_instance_state WHERE process_instance_key = base.<keyField>
|
|
11
|
+
// AND state = 'TERMINATED') -> onTerminated value
|
|
12
|
+
// wait-on-human edge: EXISTS(urban_open_user_tasks WHERE process_instance_key = base.<keyField>)
|
|
13
|
+
// -> onWaitingHuman value
|
|
14
|
+
//
|
|
15
|
+
// authored ONCE and compiled to BOTH the SQLite VIEW and the in-process TS function (surface #2 is
|
|
16
|
+
// closed by the two-backend compiler). Because a derived edge is recomputed on every read over the
|
|
17
|
+
// LIVE projections, a re-escalation after an answer re-flips by construction and #422 cannot recur:
|
|
18
|
+
// once `OpenUserTasksStore.syncInstance` retires the answered task, the `EXISTS` goes false on the
|
|
19
|
+
// next read — no stored column to leave stale.
|
|
20
|
+
|
|
21
|
+
import type { InstanceTracking } from "../manifest.ts";
|
|
22
|
+
import {
|
|
23
|
+
and,
|
|
24
|
+
caseWhen,
|
|
25
|
+
col,
|
|
26
|
+
defineReadModel,
|
|
27
|
+
eq,
|
|
28
|
+
type Expr,
|
|
29
|
+
exists,
|
|
30
|
+
lit,
|
|
31
|
+
not,
|
|
32
|
+
pcol,
|
|
33
|
+
type ReadModel,
|
|
34
|
+
type WhenClause,
|
|
35
|
+
when,
|
|
36
|
+
} from "../read-model.ts";
|
|
37
|
+
import { INSTANCE_STATE_PROJECTION } from "./instance-state-store.ts";
|
|
38
|
+
import { OPEN_USER_TASKS_PROJECTION } from "./open-user-tasks-store.ts";
|
|
39
|
+
|
|
40
|
+
/** The engine-truth terminal lifecycle state the terminal edge keys on (mirrors
|
|
41
|
+
* `ProcessInstanceState`'s `TERMINATED`). Reconciled only on `TERMINATED` — never `COMPLETED`, whose
|
|
42
|
+
* terminal row-write the app's own finalize worker owns — exactly as the pre-inversion reconciler was. */
|
|
43
|
+
export const TERMINATED_STATE = "TERMINATED";
|
|
44
|
+
|
|
45
|
+
/** The default derived-status column name a binding's read-model VIEW exposes. Distinct from the base
|
|
46
|
+
* `statusField` on purpose: the managed VIEW re-exports `base.*` (so pages keep every base column),
|
|
47
|
+
* and SQLite lets the base column win over a same-named derived alias — so the EFFECTIVE (derived)
|
|
48
|
+
* status is surfaced under its own column, which the operator page reads instead of the stored one.
|
|
49
|
+
* A binding may override it via `readModel.statusColumn`. */
|
|
50
|
+
export const DEFAULT_DERIVED_STATUS_COLUMN = "derived_status";
|
|
51
|
+
|
|
52
|
+
/** The default managed-VIEW name for a binding: `<table>__tracking`. Distinct from the base table
|
|
53
|
+
* (SQLite forbids a VIEW and a table sharing a name). An app with more than one binding on the same
|
|
54
|
+
* base table must give each a distinct `readModel.view`. */
|
|
55
|
+
export function defaultInstanceTrackingViewName(table: string): string {
|
|
56
|
+
return `${table}__tracking`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** `EXISTS(urban_instance_state WHERE process_instance_key = base.<keyField> AND state = 'TERMINATED')`
|
|
60
|
+
* — the terminal edge, derived over the canonical instance-state projection. Authored once; compiles
|
|
61
|
+
* to both the SQLite VIEW `EXISTS` and the TS `projections["urban_instance_state"].some(...)`. */
|
|
62
|
+
export function terminatedEdgeExpr(keyField: string): Expr {
|
|
63
|
+
return exists(
|
|
64
|
+
INSTANCE_STATE_PROJECTION,
|
|
65
|
+
and(eq(pcol("process_instance_key"), col(keyField)), eq(pcol("state"), lit(TERMINATED_STATE))),
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** `EXISTS(urban_open_user_tasks WHERE process_instance_key = base.<keyField>)` — the wait-on-human
|
|
70
|
+
* edge, derived over the canonical open-user-tasks projection (an instance is parked on a human iff it
|
|
71
|
+
* has an open user task). Authored once; compiles to both backends. */
|
|
72
|
+
export function waitingHumanEdgeExpr(keyField: string): Expr {
|
|
73
|
+
return exists(OPEN_USER_TASKS_PROJECTION, eq(pcol("process_instance_key"), col(keyField)));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The effective-status derivation for one binding, in the closed expression DSL:
|
|
78
|
+
*
|
|
79
|
+
* CASE
|
|
80
|
+
* WHEN <terminated> THEN <onTerminated.set[statusField]> -- terminal wins
|
|
81
|
+
* WHEN <waiting-on-human> AND NOT <terminated> THEN <onWaitingHuman.set[statusField]>
|
|
82
|
+
* ELSE base.<statusField> -- the worker-owned / business-outcome transient status
|
|
83
|
+
* END
|
|
84
|
+
*
|
|
85
|
+
* Precedence (terminated over waiting-human) is preserved: the terminal `WHEN` is emitted first, and
|
|
86
|
+
* the waiting `WHEN` is additionally guarded by `NOT <terminated>` so it is correct even when the two
|
|
87
|
+
* patches touch disjoint columns. When a binding's patch does not set the `statusField` (it reconciles
|
|
88
|
+
* only secondary columns), that edge contributes no `WHEN` and the derived status passes the stored
|
|
89
|
+
* value through — a derived read model never re-introduces a stored derivable write.
|
|
90
|
+
*/
|
|
91
|
+
export function deriveInstanceStatusExpr(binding: InstanceTracking, statusField: string): Expr {
|
|
92
|
+
const terminated = terminatedEdgeExpr(binding.keyField);
|
|
93
|
+
const whens: WhenClause[] = [];
|
|
94
|
+
|
|
95
|
+
const terminalStatus = patchValueForColumn(binding.onTerminated?.set, statusField);
|
|
96
|
+
if (terminalStatus !== undefined) {
|
|
97
|
+
whens.push(when(terminated, lit(terminalStatus)));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const waitingStatus = patchValueForColumn(binding.onWaitingHuman?.set, statusField);
|
|
101
|
+
if (waitingStatus !== undefined) {
|
|
102
|
+
whens.push(when(and(waitingHumanEdgeExpr(binding.keyField), not(terminated)), lit(waitingStatus)));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// No edge touches the status column ⇒ the derived status is exactly the stored transient (no CASE).
|
|
106
|
+
return whens.length > 0 ? caseWhen(whens, col(statusField)) : col(statusField);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Read the patch value a `*.set` map assigns to the status COLUMN, matching the key case-insensitively.
|
|
110
|
+
* SQL column identifiers fold case (SQLite), but a JS `set[statusField]` lookup is case-sensitive, so a
|
|
111
|
+
* binding with `statusField: "Status"` and `onTerminated.set: { status: "abandoned" }` would emit no
|
|
112
|
+
* terminal `WHEN` and silently fall through to the base column. Fold the comparison so the derivation
|
|
113
|
+
* matches the column the equivalent SQL UPDATE would have addressed. */
|
|
114
|
+
function patchValueForColumn(set: unknown, statusField: string): string | number | boolean | null | undefined {
|
|
115
|
+
if (set === null || typeof set !== "object") return undefined;
|
|
116
|
+
const folded = statusField.toLowerCase();
|
|
117
|
+
for (const [k, v] of Object.entries(set)) {
|
|
118
|
+
if (k.toLowerCase() !== folded) continue;
|
|
119
|
+
if (v === undefined) return undefined;
|
|
120
|
+
if (v === null || typeof v === "string" || typeof v === "number" || typeof v === "boolean") return v;
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The managed-VIEW name + derived-status column a binding's read model uses (config overrides applied). */
|
|
127
|
+
export function instanceTrackingReadModelTarget(binding: InstanceTracking): {
|
|
128
|
+
readonly view: string;
|
|
129
|
+
readonly statusColumn: string;
|
|
130
|
+
} {
|
|
131
|
+
return {
|
|
132
|
+
view: binding.readModel?.view ?? defaultInstanceTrackingViewName(binding.table),
|
|
133
|
+
statusColumn: binding.readModel?.statusColumn ?? DEFAULT_DERIVED_STATUS_COLUMN,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Build the derived read model for one `instanceTracking` binding: a managed VIEW over the binding's
|
|
139
|
+
* base table that re-exports `base.*` plus one derived `statusColumn` computed by
|
|
140
|
+
* {@link deriveInstanceStatusExpr} over the canonical projections. Returns `undefined` when the binding
|
|
141
|
+
* declares no `statusField` (there is no single status column to derive — the projections still get fed,
|
|
142
|
+
* but nothing well-defined can be projected as "the effective status").
|
|
143
|
+
*
|
|
144
|
+
* The two backends (SQLite VIEW select-list + in-process TS function) fall out of the one AST, so they
|
|
145
|
+
* cannot drift; `assertReadModelParity` verifies it in tests.
|
|
146
|
+
*/
|
|
147
|
+
export function defineInstanceTrackingReadModel(binding: InstanceTracking): ReadModel | undefined {
|
|
148
|
+
const statusField = binding.statusField;
|
|
149
|
+
if (typeof statusField !== "string" || statusField.length === 0) return undefined;
|
|
150
|
+
const { view, statusColumn } = instanceTrackingReadModelTarget(binding);
|
|
151
|
+
return defineReadModel({
|
|
152
|
+
name: view,
|
|
153
|
+
baseTable: binding.table,
|
|
154
|
+
derive: { [statusColumn]: deriveInstanceStatusExpr(binding, statusField) },
|
|
155
|
+
});
|
|
156
|
+
}
|