@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.
Files changed (37) hide show
  1. package/dist/runtime/core/manifest.d.ts +44 -11
  2. package/dist/runtime/core/modules/cancel.d.ts +13 -8
  3. package/dist/runtime/core/modules/cancel.js +40 -21
  4. package/dist/runtime/core/modules/datasource.js +7 -4
  5. package/dist/runtime/core/modules/gateway.js +5 -4
  6. package/dist/runtime/core/modules/instance-status-read-model.d.ts +56 -0
  7. package/dist/runtime/core/modules/instance-status-read-model.js +127 -0
  8. package/dist/runtime/core/modules/instance-tracking.d.ts +47 -19
  9. package/dist/runtime/core/modules/instance-tracking.js +451 -123
  10. package/dist/runtime/core/modules/open-user-tasks-store.d.ts +5 -0
  11. package/dist/runtime/core/modules/open-user-tasks-store.js +8 -0
  12. package/dist/runtime/core/modules/pages.js +6 -4
  13. package/dist/runtime/core/modules/workers.js +25 -1
  14. package/dist/runtime/core/read-model.d.ts +296 -5
  15. package/dist/runtime/core/read-model.js +1077 -39
  16. package/dist/runtime/core/validate.js +192 -0
  17. package/dist/runtime/index.d.ts +4 -3
  18. package/dist/runtime/index.js +11 -1
  19. package/package.json +1 -1
  20. package/src/runtime/core/manifest.ts +44 -11
  21. package/src/runtime/core/modules/cancel.test.ts +112 -38
  22. package/src/runtime/core/modules/cancel.ts +42 -22
  23. package/src/runtime/core/modules/datasource.ts +7 -4
  24. package/src/runtime/core/modules/gateway.ts +7 -4
  25. package/src/runtime/core/modules/instance-status-read-model.ts +156 -0
  26. package/src/runtime/core/modules/instance-tracking.test.ts +589 -60
  27. package/src/runtime/core/modules/instance-tracking.ts +469 -126
  28. package/src/runtime/core/modules/open-user-tasks-store.test.ts +11 -0
  29. package/src/runtime/core/modules/open-user-tasks-store.ts +11 -0
  30. package/src/runtime/core/modules/pages.ts +6 -5
  31. package/src/runtime/core/modules/workers.ts +24 -1
  32. package/src/runtime/core/read-model.test.ts +94 -0
  33. package/src/runtime/core/read-model.ts +1356 -45
  34. package/src/runtime/core/rollup.test.ts +818 -0
  35. package/src/runtime/core/validate.test.ts +341 -0
  36. package/src/runtime/core/validate.ts +188 -0
  37. 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 flips to `abandoned` (and drops the process pointer)
31
- // when its instance terminates — the exact patch the poll reconciler applies.
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
- test("terminating a tracked instance reconciles its row immediately", async () => {
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, undefined); // process_key was cleared to null, so no row keyed by "100"
148
- const byPr = (await h.table.all()).find((x) => x.pr_key === "pr-1");
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 + reconciles", async () => {
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 reconcile the (stale-active) row.
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
- const byPr = (await h.table.all()).find((x) => x.pr_key === "pr-1");
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 touches no row", async () => {
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 — instance still running
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("a COMPLETED instance is not reconciled (the app's finalize logic owns that row)", async () => {
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, not clobbered
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("a gone/unknown instance is idempotent success with no reconcile", async () => {
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 has no record of this key
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
- assert.equal(r.reconciled, 0);
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("a TERMINATED instance with no matching binding row reconciles nothing", async () => {
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, 0);
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("a reconcile write failure does not fail the cancel (the engine already terminated it)", async () => {
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 h = await withHarness(engine);
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
- // A binding pointing at a table that does not exist makes the reconcile UPDATE throw.
263
- const badBinding: InstanceTracking = {
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.ok(r.error); // the reconcile failure is surfaced, not swallowed
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 h.close();
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
- assert.equal(r.reconciled, 0);
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"); // never flip a possibly-live run to a terminal status
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 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.
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 reuses the instanceTracking bindings'
18
- // `onTerminated.set` patch, so the immediate reconcile can never drift from the poll reconciler's.
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
- /** Rows flipped to their terminal status by the immediate instanceTracking reconcile. */
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
- * reconcile any instanceTracking row bound to its key.
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. Only a TERMINATED read reconciles a
49
- * row here (see below); a COMPLETED read intentionally reconciles nothing.
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 touch any row (the instance may still be
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
- * Reconcile only on `TERMINATED`: a COMPLETED instance's terminal row-write is owned by the app's
57
- * own finalize logic, and a gone/ACTIVE instance has nothing to flip here.
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 flip a tracked row to terminal while the instance may live.
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
- if (state === "TERMINATED") {
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
- reconciled = await reconcileTerminatedKey(api, bindings, key);
118
+ const projections = tryInstanceProjections(api);
119
+ reconciled = reconcileTerminatedKey(api, projections, key);
97
120
  } catch (error) {
98
- // The engine termination itself succeeded — a reconcile *write* failure (bad binding,
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 (table.startsWith("_urban_") || table.startsWith("_nano_") || table.startsWith("sqlite_")) {
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
- * so reject anything that isn't a plain identifier to prevent invalid SQL / injection. */
158
- const SQL_IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
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
- "SELECT name, type FROM sqlite_master WHERE type IN ('table','view') " +
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
+ }