@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.
Files changed (84) hide show
  1. package/dist/openapi/spec.d.ts +127 -0
  2. package/dist/openapi/spec.js +444 -0
  3. package/dist/runtime/adapters/deno.js +5 -0
  4. package/dist/runtime/adapters/node.js +5 -0
  5. package/dist/runtime/core/execContext.d.ts +28 -0
  6. package/dist/runtime/core/execContext.js +39 -0
  7. package/dist/runtime/core/host.d.ts +41 -0
  8. package/dist/runtime/core/manifest.d.ts +1 -1
  9. package/dist/runtime/core/modules/api.d.ts +61 -0
  10. package/dist/runtime/core/modules/api.js +359 -0
  11. package/dist/runtime/core/modules/cancel.d.ts +31 -0
  12. package/dist/runtime/core/modules/cancel.js +90 -0
  13. package/dist/runtime/core/modules/connectors.js +3 -1
  14. package/dist/runtime/core/modules/datasource.d.ts +23 -3
  15. package/dist/runtime/core/modules/datasource.js +61 -6
  16. package/dist/runtime/core/modules/gateway.d.ts +10 -3
  17. package/dist/runtime/core/modules/gateway.js +23 -6
  18. package/dist/runtime/core/modules/instance-tracking.d.ts +22 -0
  19. package/dist/runtime/core/modules/instance-tracking.js +158 -0
  20. package/dist/runtime/core/modules/pages.d.ts +8 -0
  21. package/dist/runtime/core/modules/pages.js +17 -1
  22. package/dist/runtime/core/modules/scheduler.d.ts +11 -0
  23. package/dist/runtime/core/modules/scheduler.js +21 -0
  24. package/dist/runtime/core/modules/surfaces.js +9 -0
  25. package/dist/runtime/core/modules/triggers.d.ts +2 -8
  26. package/dist/runtime/core/modules/triggers.js +1 -16
  27. package/dist/runtime/core/modules/workers.js +3 -2
  28. package/dist/runtime/core/runtime.d.ts +3 -0
  29. package/dist/runtime/core/runtime.js +12 -0
  30. package/dist/runtime/core/validate.js +38 -0
  31. package/dist/runtime/engine/nanosdk.d.ts +14 -1
  32. package/dist/runtime/engine/nanosdk.js +90 -7
  33. package/dist/runtime/index.d.ts +4 -0
  34. package/dist/runtime/index.js +2 -0
  35. package/dist/toolkit/artifact.d.ts +5 -0
  36. package/dist/toolkit/artifact.js +7 -0
  37. package/dist/toolkit/derivers/api.d.ts +19 -0
  38. package/dist/toolkit/derivers/api.js +224 -0
  39. package/dist/toolkit/gen.js +23 -4
  40. package/dist/toolkit/index.d.ts +5 -0
  41. package/dist/toolkit/index.js +12 -1
  42. package/package.json +2 -2
  43. package/src/openapi/spec.test.ts +292 -0
  44. package/src/openapi/spec.ts +540 -0
  45. package/src/runtime/adapters/deno.ts +6 -0
  46. package/src/runtime/adapters/node.ts +6 -0
  47. package/src/runtime/core/execContext.ts +56 -0
  48. package/src/runtime/core/host.ts +44 -0
  49. package/src/runtime/core/manifest.ts +1 -0
  50. package/src/runtime/core/modules/actions.test.ts +1 -0
  51. package/src/runtime/core/modules/api.test.ts +445 -0
  52. package/src/runtime/core/modules/api.ts +453 -0
  53. package/src/runtime/core/modules/cancel.test.ts +310 -0
  54. package/src/runtime/core/modules/cancel.ts +119 -0
  55. package/src/runtime/core/modules/connectors.test.ts +1 -0
  56. package/src/runtime/core/modules/connectors.ts +7 -1
  57. package/src/runtime/core/modules/datasource.migrations.test.ts +1 -0
  58. package/src/runtime/core/modules/datasource.ts +77 -6
  59. package/src/runtime/core/modules/gateway.ts +28 -6
  60. package/src/runtime/core/modules/instance-tracking.test.ts +345 -0
  61. package/src/runtime/core/modules/instance-tracking.ts +180 -0
  62. package/src/runtime/core/modules/pages.test.ts +47 -2
  63. package/src/runtime/core/modules/pages.ts +23 -1
  64. package/src/runtime/core/modules/provenance.test.ts +181 -0
  65. package/src/runtime/core/modules/scheduler.ts +31 -0
  66. package/src/runtime/core/modules/surfaces.test.ts +2 -0
  67. package/src/runtime/core/modules/surfaces.ts +10 -0
  68. package/src/runtime/core/modules/triggers.test.ts +1 -0
  69. package/src/runtime/core/modules/triggers.ts +3 -25
  70. package/src/runtime/core/modules/workers.test.ts +1 -0
  71. package/src/runtime/core/modules/workers.ts +11 -2
  72. package/src/runtime/core/runtime.test.ts +3 -0
  73. package/src/runtime/core/runtime.ts +16 -0
  74. package/src/runtime/core/validate.test.ts +96 -0
  75. package/src/runtime/core/validate.ts +39 -0
  76. package/src/runtime/engine/nanosdk.test.ts +118 -5
  77. package/src/runtime/engine/nanosdk.ts +102 -7
  78. package/src/runtime/index.ts +12 -0
  79. package/src/toolkit/artifact.ts +8 -0
  80. package/src/toolkit/derivers/api.test.ts +219 -0
  81. package/src/toolkit/derivers/api.ts +255 -0
  82. package/src/toolkit/gen.test.ts +39 -0
  83. package/src/toolkit/gen.ts +24 -4
  84. package/src/toolkit/index.ts +41 -1
@@ -0,0 +1,345 @@
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 {
14
+ DEFAULT_INSTANCE_TRACKING_POLL_MS,
15
+ mountInstanceTracking,
16
+ } from "./instance-tracking.ts";
17
+ import { MAX_TIMER_DELAY_MS, type SchedulerDeps } from "./scheduler.ts";
18
+
19
+ // A real-timer flush drains the entire pending microtask chain (find → engine → update)
20
+ // each time a fake timer fires — deeper than a fixed number of `await Promise.resolve()`s.
21
+ const realSetTimeout = globalThis.setTimeout;
22
+ const flush = () => new Promise<void>((r) => realSetTimeout(r, 0));
23
+
24
+ // A deterministic timer + clock seam (mirrors triggers.test.ts): `advance` fires every due
25
+ // timer in order, draining microtasks between fires so the async reconcile completes.
26
+ function fakeScheduler(): SchedulerDeps & { advance: (ms: number) => Promise<void>; pending: () => number } {
27
+ let clock = 0;
28
+ let seq = 0;
29
+ const timers = new Map<number, { at: number; fn: () => void }>();
30
+ return {
31
+ now: () => clock,
32
+ setTimer: (fn, delayMs) => {
33
+ const id = ++seq;
34
+ timers.set(id, { at: clock + delayMs, fn });
35
+ return id;
36
+ },
37
+ clearTimer: (h) => {
38
+ if (typeof h === "number") timers.delete(h);
39
+ },
40
+ pending: () => timers.size,
41
+ advance: async (ms) => {
42
+ const target = clock + ms;
43
+ // deno-lint-ignore no-constant-condition
44
+ while (true) {
45
+ let nextId = -1;
46
+ let nextAt = Infinity;
47
+ for (const [id, t] of timers) {
48
+ if (t.at <= target && t.at < nextAt) {
49
+ nextAt = t.at;
50
+ nextId = id;
51
+ }
52
+ }
53
+ if (nextId < 0) break;
54
+ const t = timers.get(nextId)!;
55
+ timers.delete(nextId);
56
+ clock = t.at;
57
+ t.fn();
58
+ await flush();
59
+ }
60
+ clock = target;
61
+ },
62
+ };
63
+ }
64
+
65
+ /** An EngineClient whose only live method is searchProcessInstances, driven by a fixed map of
66
+ * processInstanceKey → state. Records the keys asked for on each call. */
67
+ function fakeEngine(states: Record<string, ProcessInstanceSnapshot["state"]>): {
68
+ engine: EngineClient;
69
+ queries: string[][];
70
+ } {
71
+ const queries: string[][] = [];
72
+ const notUsed = (m: string) => (): never => {
73
+ throw new Error(`fakeEngine.${m} is not exercised by this test`);
74
+ };
75
+ // Typed as EngineClient (not `as unknown as`) so the compiler enforces the full contract:
76
+ // if EngineClient gains/changes a method, this fake fails to compile instead of drifting.
77
+ const engine: EngineClient = {
78
+ async searchProcessInstances(filter?: {
79
+ processInstanceKeys?: string[];
80
+ state?: ProcessInstanceSnapshot["state"];
81
+ }): Promise<ProcessInstanceSnapshot[]> {
82
+ const keys = filter?.processInstanceKeys ?? [];
83
+ queries.push([...keys]);
84
+ const out: ProcessInstanceSnapshot[] = [];
85
+ for (const key of keys) {
86
+ const state = states[key];
87
+ if (state && (!filter?.state || filter.state === state)) {
88
+ out.push({ processInstanceKey: key, state });
89
+ }
90
+ }
91
+ return out;
92
+ },
93
+ deployResources: notUsed("deployResources"),
94
+ createInstance: notUsed("createInstance"),
95
+ cancelInstance: notUsed("cancelInstance"),
96
+ publishMessage: notUsed("publishMessage"),
97
+ searchUserTasks: notUsed("searchUserTasks"),
98
+ completeUserTask: notUsed("completeUserTask"),
99
+ registerWorker: notUsed("registerWorker"),
100
+ close: notUsed("close"),
101
+ };
102
+ return { engine, queries };
103
+ }
104
+
105
+ interface Harness {
106
+ api: AppApi;
107
+ table: Table<PlanRow>;
108
+ dir: string;
109
+ close: () => Promise<void>;
110
+ logs: { level: string; msg: string }[];
111
+ }
112
+
113
+ // The row shape every test seeds (matches PLANS_DDL); typing the harness table with it lets
114
+ // `table.get(...)` return a typed row instead of the generic `object` constraint that
115
+ // `ReturnType<DataLayer["table"]>` collapses to.
116
+ interface PlanRow {
117
+ plan_key: string;
118
+ process_key: string | null;
119
+ status: string;
120
+ note: string | null;
121
+ }
122
+
123
+ async function withHarness(
124
+ engine: EngineClient,
125
+ ddl: string,
126
+ seed: (t: Table<PlanRow>) => Promise<void>,
127
+ keyField = "process_key",
128
+ table = "plans",
129
+ ): Promise<Harness> {
130
+ const dir = await mkdtemp(join(tmpdir(), "urban-instance-tracking-"));
131
+ const host = createNodeHost({ cwd: dir, log: () => {} });
132
+ const db = host.openSqlite(join(dir, "test.db"));
133
+ db.exec(ddl);
134
+ const source: ProvisionedSource = {
135
+ name: "app",
136
+ driver: "sqlite",
137
+ db,
138
+ source: makeGateway(db),
139
+ migrationsApplied: [],
140
+ close: () => db.close(),
141
+ };
142
+ const data = new DataLayer(new Map([["app", source]]), "app", {});
143
+ const tbl = data.table<PlanRow>(table, keyField);
144
+ await seed(tbl);
145
+ const logs: { level: string; msg: string }[] = [];
146
+ const api: AppApi = {
147
+ manifest: { schemaVersion: 1, id: "app", name: "App" },
148
+ data,
149
+ engine,
150
+ env: () => undefined,
151
+ log: (level, msg) => logs.push({ level, msg }),
152
+ };
153
+ return {
154
+ api,
155
+ table: tbl,
156
+ dir,
157
+ logs,
158
+ close: async () => {
159
+ db.close();
160
+ await rm(dir, { recursive: true, force: true });
161
+ },
162
+ };
163
+ }
164
+
165
+ const PLANS_DDL =
166
+ "CREATE TABLE plans (plan_key TEXT PRIMARY KEY, process_key TEXT, status TEXT NOT NULL, note TEXT)";
167
+
168
+ const planBinding = (over: Partial<InstanceTracking> = {}): InstanceTracking => ({
169
+ table: "plans",
170
+ keyField: "process_key",
171
+ statusField: "status",
172
+ activeStatuses: ["planning", "dispatched"],
173
+ onTerminated: { set: { status: "abandoned", note: null } },
174
+ pollMs: 1000,
175
+ ...over,
176
+ });
177
+
178
+ test("reconciles an active row whose instance is TERMINATED", async () => {
179
+ const { engine } = fakeEngine({ pi1: "TERMINATED" });
180
+ const h = await withHarness(engine, PLANS_DDL, async (t) => {
181
+ await t.insert({ plan_key: "p1", process_key: "pi1", status: "dispatched", note: "hi" });
182
+ });
183
+ const sched = fakeScheduler();
184
+ const handle = mount(h, [planBinding()], sched);
185
+ await sched.advance(1000);
186
+ const row = await h.table.get("pi1");
187
+ assert.equal(row?.status, "abandoned");
188
+ assert.equal(row?.note, null);
189
+ await handle.stop();
190
+ await h.close();
191
+ });
192
+
193
+ test("leaves an active row whose instance is still ACTIVE untouched", async () => {
194
+ const { engine } = fakeEngine({ pi1: "ACTIVE" });
195
+ const h = await withHarness(engine, PLANS_DDL, async (t) => {
196
+ await t.insert({ plan_key: "p1", process_key: "pi1", status: "dispatched", note: "hi" });
197
+ });
198
+ const sched = fakeScheduler();
199
+ const handle = mount(h, [planBinding()], sched);
200
+ await sched.advance(1000);
201
+ const row = await h.table.get("pi1");
202
+ assert.equal(row?.status, "dispatched");
203
+ await handle.stop();
204
+ await h.close();
205
+ });
206
+
207
+ test("does NOT reconcile a COMPLETED instance (a finalize worker owns that row)", async () => {
208
+ const { engine, queries } = fakeEngine({ pi1: "COMPLETED" });
209
+ const h = await withHarness(engine, PLANS_DDL, async (t) => {
210
+ await t.insert({ plan_key: "p1", process_key: "pi1", status: "dispatched", note: "hi" });
211
+ });
212
+ const sched = fakeScheduler();
213
+ const handle = mount(h, [planBinding()], sched);
214
+ await sched.advance(1000);
215
+ const row = await h.table.get("pi1");
216
+ assert.equal(row?.status, "dispatched"); // untouched
217
+ // and it did ask the engine, filtered to TERMINATED
218
+ assert.deepEqual(queries, [["pi1"]]);
219
+ await handle.stop();
220
+ await h.close();
221
+ });
222
+
223
+ test("only polls rows in an active status (skips already-terminal rows)", async () => {
224
+ const { engine, queries } = fakeEngine({ pi1: "TERMINATED", pi2: "TERMINATED" });
225
+ const h = await withHarness(engine, PLANS_DDL, async (t) => {
226
+ await t.insert({ plan_key: "p1", process_key: "pi1", status: "dispatched", note: null });
227
+ await t.insert({ plan_key: "p2", process_key: "pi2", status: "abandoned", note: null }); // terminal
228
+ });
229
+ const sched = fakeScheduler();
230
+ const handle = mount(h, [planBinding()], sched);
231
+ await sched.advance(1000);
232
+ assert.deepEqual(queries, [["pi1"]]); // pi2 was already terminal, never polled
233
+ await handle.stop();
234
+ await h.close();
235
+ });
236
+
237
+ test("skips rows whose key column is null/empty", async () => {
238
+ const { engine, queries } = fakeEngine({});
239
+ const h = await withHarness(engine, PLANS_DDL, async (t) => {
240
+ await t.insert({ plan_key: "p1", process_key: null, status: "dispatched", note: null });
241
+ await t.insert({ plan_key: "p2", process_key: "", status: "dispatched", note: null });
242
+ });
243
+ const sched = fakeScheduler();
244
+ const handle = mount(h, [planBinding()], sched);
245
+ await sched.advance(1000);
246
+ assert.equal(queries.length, 0); // no keys to poll → no engine call
247
+ await handle.stop();
248
+ await h.close();
249
+ });
250
+
251
+ test("re-arms after each tick and stops cleanly", async () => {
252
+ const { engine, queries } = fakeEngine({ pi1: "ACTIVE" });
253
+ const h = await withHarness(engine, PLANS_DDL, async (t) => {
254
+ await t.insert({ plan_key: "p1", process_key: "pi1", status: "dispatched", note: null });
255
+ });
256
+ const sched = fakeScheduler();
257
+ const handle = mount(h, [planBinding()], sched);
258
+ await sched.advance(1000);
259
+ await sched.advance(1000);
260
+ await sched.advance(1000);
261
+ assert.equal(queries.length, 3); // polled once per interval
262
+ assert.equal(sched.pending(), 1); // one timer armed for the next tick
263
+ await handle.stop();
264
+ assert.equal(sched.pending(), 0); // stop() cleared it
265
+ await h.close();
266
+ });
267
+
268
+ test("without statusField/activeStatuses, polls every row", async () => {
269
+ const { engine, queries } = fakeEngine({ pi1: "TERMINATED", pi2: "TERMINATED" });
270
+ const h = await withHarness(engine, PLANS_DDL, async (t) => {
271
+ await t.insert({ plan_key: "p1", process_key: "pi1", status: "abandoned", note: null });
272
+ await t.insert({ plan_key: "p2", process_key: "pi2", status: "dispatched", note: null });
273
+ });
274
+ const sched = fakeScheduler();
275
+ const binding = planBinding({ statusField: undefined, activeStatuses: undefined });
276
+ const handle = mount(h, [binding], sched);
277
+ await sched.advance(1000);
278
+ assert.deepEqual(queries[0]?.sort(), ["pi1", "pi2"]);
279
+ // both got reconciled (already-abandoned p1 is a no-op change, p2 flips)
280
+ assert.equal((await h.table.get("pi2"))?.status, "abandoned");
281
+ await handle.stop();
282
+ await h.close();
283
+ });
284
+
285
+ test("defaults pollMs when the binding omits it", async () => {
286
+ const { engine, queries } = fakeEngine({ pi1: "ACTIVE" });
287
+ const h = await withHarness(engine, PLANS_DDL, async (t) => {
288
+ await t.insert({ plan_key: "p1", process_key: "pi1", status: "dispatched", note: null });
289
+ });
290
+ const sched = fakeScheduler();
291
+ const binding = planBinding({ pollMs: undefined });
292
+ const handle = mount(h, [binding], sched);
293
+ await sched.advance(DEFAULT_INSTANCE_TRACKING_POLL_MS - 1);
294
+ assert.equal(queries.length, 0); // not yet
295
+ await sched.advance(1);
296
+ assert.equal(queries.length, 1); // fired at the default interval
297
+ await handle.stop();
298
+ await h.close();
299
+ });
300
+
301
+ test("clamps a pollMs beyond setTimeout's 32-bit range instead of firing immediately", async () => {
302
+ const { engine, queries } = fakeEngine({ pi1: "ACTIVE" });
303
+ const h = await withHarness(engine, PLANS_DDL, async (t) => {
304
+ await t.insert({ plan_key: "p1", process_key: "pi1", status: "dispatched", note: null });
305
+ });
306
+ const sched = fakeScheduler();
307
+ // A pollMs past the 32-bit signed range would overflow setTimeout and fire at ~0 (a hot loop).
308
+ const handle = mount(h, [planBinding({ pollMs: MAX_TIMER_DELAY_MS * 3 })], sched);
309
+ await sched.advance(1000);
310
+ assert.equal(queries.length, 0); // did NOT fire immediately …
311
+ await sched.advance(MAX_TIMER_DELAY_MS);
312
+ assert.equal(queries.length, 1); // … it fired at the clamped delay
313
+ await handle.stop();
314
+ await h.close();
315
+ });
316
+
317
+ // A binding mounted without validation (or with a hand-built binding) must not become a
318
+ // 0-delay hot loop when pollMs is non-positive/NaN — it falls back to the default interval.
319
+ for (const bad of [0, -1000, Number.NaN]) {
320
+ test(`falls back to the default interval for a non-positive/NaN pollMs (${bad})`, async () => {
321
+ const { engine, queries } = fakeEngine({ pi1: "ACTIVE" });
322
+ const h = await withHarness(engine, PLANS_DDL, async (t) => {
323
+ await t.insert({ plan_key: "p1", process_key: "pi1", status: "dispatched", note: null });
324
+ });
325
+ const sched = fakeScheduler();
326
+ const handle = mount(h, [planBinding({ pollMs: bad })], sched);
327
+ await sched.advance(DEFAULT_INSTANCE_TRACKING_POLL_MS - 1);
328
+ assert.equal(queries.length, 0); // did NOT hot-loop at ~0 …
329
+ await sched.advance(1);
330
+ assert.equal(queries.length, 1); // … it fired at the default interval instead
331
+ await handle.stop();
332
+ await h.close();
333
+ });
334
+ }
335
+
336
+
337
+ function mount(h: Harness, bindings: InstanceTracking[], sched: SchedulerDeps) {
338
+ h.api.manifest.instanceTracking = bindings;
339
+ return mountInstanceTracking(
340
+ // biome-ignore lint/plugin: RuntimeContext test double — mountInstanceTracking reads only manifest/engine/root; host is unused here.
341
+ { manifest: h.api.manifest, host: {} as never, engine: h.api.engine, root: "." },
342
+ h.api,
343
+ sched,
344
+ );
345
+ }
@@ -0,0 +1,180 @@
1
+ // instance-tracking — the process-instance lifecycle reconciler (manifest `instanceTracking`).
2
+ //
3
+ // The problem it closes: an Urban app derives a row's status from the workers that run
4
+ // along a process's *happy path*. When an instance instead reaches a terminal state with
5
+ // no completion job — an operator/`cancelInstance` termination, or a crash — no worker
6
+ // runs, so the read-model row stays "active" forever and any UI derived from it lies.
7
+ //
8
+ // Each `instanceTracking` binding names a datasource table whose rows track an instance by
9
+ // a key column. This module polls the engine for those rows' instances and, when one is
10
+ // `TERMINATED`, applies the binding's `onTerminated.set` patch to the row. It deliberately
11
+ // reconciles only on `TERMINATED` — the state that by definition runs no completion worker
12
+ // — and never on `COMPLETED`, whose terminal row-write the app's own finalize worker owns
13
+ // (reconciling it here would race that worker and could clobber a legitimately-completed
14
+ // row). The poll is driven through the shared `SchedulerDeps` seam so tests are deterministic.
15
+
16
+ import type { AppApi, Mounted, RuntimeContext } from "../context.ts";
17
+ import type { InstanceTracking } from "../manifest.ts";
18
+ import { defaultScheduler, MAX_TIMER_DELAY_MS, type SchedulerDeps } from "./scheduler.ts";
19
+
20
+ /** Default poll interval when a binding does not set `pollMs`. */
21
+ export const DEFAULT_INSTANCE_TRACKING_POLL_MS = 15_000;
22
+
23
+ export interface InstanceTrackingHandle extends Mounted {
24
+ /** `table.keyField` labels of the armed bindings, for `inspect()`. */
25
+ readonly bindings: string[];
26
+ }
27
+
28
+ type Row = Record<string, unknown>;
29
+
30
+ /** Apply a binding's `onTerminated.set` patch to the single row keyed by `processInstanceKey`.
31
+ * Only the binding whose table owns that key has a matching row, so callers can fan this over
32
+ * every binding and let the no-match ones be no-ops. Returns rows changed. The single place that
33
+ * writes the terminal patch (and logs the reconcile): both the poll reconciler below (`reconcileOnce`,
34
+ * once per TERMINATED key it discovers) and the cancel primitive (immediately when it terminates an
35
+ * instance) route through here, so the two can never drift on the patch or the log. */
36
+ export async function reconcileTerminatedKey(
37
+ api: Pick<AppApi, "data" | "log">,
38
+ bindings: InstanceTracking[],
39
+ processInstanceKey: string,
40
+ ): Promise<number> {
41
+ const key = String(processInstanceKey);
42
+ let reconciled = 0;
43
+ for (const binding of bindings) {
44
+ const table = api.data.table<Row>(binding.table, binding.keyField);
45
+ const changed = await table.update(key, binding.onTerminated.set);
46
+ if (changed > 0) {
47
+ reconciled += changed;
48
+ api.log("info", "instanceTracking: reconciled terminated instance", {
49
+ table: binding.table,
50
+ keyField: binding.keyField,
51
+ processInstanceKey: key,
52
+ rowsChanged: changed,
53
+ });
54
+ }
55
+ }
56
+ return reconciled;
57
+ }
58
+
59
+ /** Poll one binding's instances once. Selects the active rows, asks the engine which of their
60
+ * instances are TERMINATED, and applies `onTerminated.set` to each. Returns how many keys were
61
+ * scanned and how many rows were reconciled this tick. */
62
+ async function reconcileOnce(
63
+ api: AppApi,
64
+ binding: InstanceTracking,
65
+ ): Promise<{ scanned: number; reconciled: number }> {
66
+ const table = api.data.table<Row>(binding.table, binding.keyField);
67
+
68
+ // Select the rows worth polling. With a statusField + activeStatuses, only rows in an
69
+ // active status are candidates, so a row already in a terminal status is skipped; without
70
+ // them every row is polled and a terminal instance's row is idempotently re-patched (the
71
+ // engine query returns only TERMINATED instances and the patch is a no-op once applied).
72
+ let candidates: Row[];
73
+ const statusField = binding.statusField;
74
+ if (statusField && binding.activeStatuses && binding.activeStatuses.length > 0) {
75
+ const perStatus = await Promise.all(
76
+ binding.activeStatuses.map((s): Promise<Row[]> => table.find({ [statusField]: s })),
77
+ );
78
+ candidates = perStatus.flat();
79
+ } else {
80
+ candidates = await table.all();
81
+ }
82
+
83
+ // Map each active row to its tracked instance key. A row with no/empty key can't be
84
+ // reconciled (it never started an instance, or the column is unset) — skip it.
85
+ const keyToRows = new Map<string, Row[]>();
86
+ for (const row of candidates) {
87
+ const raw = row[binding.keyField];
88
+ if (raw == null || raw === "") continue;
89
+ const key = String(raw);
90
+ const list = keyToRows.get(key);
91
+ if (list) list.push(row);
92
+ else keyToRows.set(key, [row]);
93
+ }
94
+ const keys = [...keyToRows.keys()];
95
+ if (keys.length === 0) return { scanned: 0, reconciled: 0 };
96
+
97
+ const snapshots = await api.engine.searchProcessInstances({
98
+ processInstanceKeys: keys,
99
+ state: "TERMINATED",
100
+ });
101
+
102
+ // Apply the terminal patch through the shared `reconcileTerminatedKey` — the one place that
103
+ // writes `onTerminated.set` and logs the reconcile — so the poll path and the cancel primitive
104
+ // can never drift on the patch or its logging (No Drift Surfaces).
105
+ let reconciled = 0;
106
+ for (const snap of snapshots) {
107
+ if (snap.state !== "TERMINATED") continue; // defensive; we asked for TERMINATED only
108
+ if (!keyToRows.has(snap.processInstanceKey)) continue; // not one we were tracking
109
+ reconciled += await reconcileTerminatedKey(api, [binding], snap.processInstanceKey);
110
+ }
111
+ return { scanned: keys.length, reconciled };
112
+ }
113
+
114
+ /**
115
+ * Mount the `instanceTracking` reconciler: arm one poll loop per binding. Each loop selects
116
+ * the active rows, asks the engine which of their instances are terminated, and applies the
117
+ * declared patch. `stop()` cancels every armed timer.
118
+ */
119
+ export function mountInstanceTracking(
120
+ ctx: RuntimeContext,
121
+ api: AppApi,
122
+ sched: SchedulerDeps = defaultScheduler(),
123
+ ): InstanceTrackingHandle {
124
+ const bindings = ctx.manifest.instanceTracking ?? [];
125
+ const timers = new Set<unknown>();
126
+ const armed: string[] = [];
127
+ let stopped = false;
128
+
129
+ for (const binding of bindings) {
130
+ // `pollMs` feeds a self-rescheduling timer, so a non-number/NaN/zero/negative value would
131
+ // clamp to a 0-delay hot loop. `??` only guards null/undefined; sanitize to a finite positive
132
+ // number and otherwise fall back to the default. (Manifest validation rejects bad values up
133
+ // front; this is belt-and-suspenders for a binding mounted without validation.)
134
+ const rawPoll = binding.pollMs;
135
+ const pollMs = typeof rawPoll === "number" && Number.isFinite(rawPoll) && rawPoll > 0
136
+ ? rawPoll
137
+ : DEFAULT_INSTANCE_TRACKING_POLL_MS;
138
+ // A single setTimeout delay overflows its 32-bit signed range (~24.8 days) and fires
139
+ // immediately — which for a self-rescheduling poll would become a hot loop. Clamp through
140
+ // the one shared constant the cron trigger loop uses (derive-don't-duplicate).
141
+ const delayMs = Math.min(pollMs, MAX_TIMER_DELAY_MS);
142
+ const label = `${binding.table}.${binding.keyField}`;
143
+ armed.push(label);
144
+
145
+ const arm = (): void => {
146
+ if (stopped) return;
147
+ let handle: unknown;
148
+ handle = sched.setTimer(() => {
149
+ timers.delete(handle);
150
+ if (stopped) return;
151
+ void (async () => {
152
+ try {
153
+ await reconcileOnce(api, binding);
154
+ } catch (err) {
155
+ api.log("error", `instanceTracking "${label}": reconcile failed`, {
156
+ error: String(err),
157
+ });
158
+ } finally {
159
+ arm(); // reschedule regardless of outcome (at-least-once, idempotent)
160
+ }
161
+ })();
162
+ }, delayMs);
163
+ timers.add(handle);
164
+ };
165
+ arm();
166
+ }
167
+
168
+ return {
169
+ name: "instanceTracking",
170
+ bindings: armed,
171
+ async stop(): Promise<void> {
172
+ stopped = true;
173
+ for (const h of timers) sched.clearTimer(h);
174
+ timers.clear();
175
+ },
176
+ describe(): Record<string, unknown> {
177
+ return { bindings: armed };
178
+ },
179
+ };
180
+ }
@@ -2,7 +2,7 @@ import { test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import type { EngineClient, HttpRequest, HttpResponse } from "../host.ts";
4
4
  import { makeRouter } from "../router.ts";
5
- import { createPagesRoutes, type PagesDataSource } from "./pages.ts";
5
+ import { createPagesRoutes, type PagesDataSource, type PagesDeps } from "./pages.ts";
6
6
 
7
7
  function req(
8
8
  method: string,
@@ -41,6 +41,7 @@ function fakeEngine(): { engine: EngineClient; calls: FakeEngineCalls } {
41
41
  },
42
42
  async searchUserTasks() { return []; },
43
43
  async completeUserTask() {},
44
+ async searchProcessInstances() { return []; },
44
45
  async registerWorker(jobType) { return { jobType, unsubscribe: async () => {} }; },
45
46
  async close() {},
46
47
  };
@@ -60,7 +61,7 @@ function fakeDb(overrides: Partial<PagesDataSource> = {}): PagesDataSource {
60
61
  };
61
62
  }
62
63
 
63
- function build(dbOverrides: Partial<PagesDataSource> = {}) {
64
+ function build(dbOverrides: Partial<PagesDataSource> = {}, deps: Partial<PagesDeps> = {}) {
64
65
  const { engine, calls } = fakeEngine();
65
66
  const db = fakeDb(dbOverrides);
66
67
  const readPage = async (path: string) => {
@@ -73,6 +74,7 @@ function build(dbOverrides: Partial<PagesDataSource> = {}) {
73
74
  engine,
74
75
  readPage,
75
76
  listPages: async () => ["home", "epic"],
77
+ ...deps,
76
78
  });
77
79
  const router = makeRouter(routes);
78
80
  return { router, calls };
@@ -355,6 +357,49 @@ test("POST /app/actions/cancel cancels, 400 without a key", async () => {
355
357
  assert.equal(bad.status, 400);
356
358
  });
357
359
 
360
+ test("POST /app/actions/cancel uses the injected cancel primitive and returns its result", async () => {
361
+ const seen: string[] = [];
362
+ const cancel: PagesDeps["cancel"] = async (key) => {
363
+ seen.push(key);
364
+ return { ok: true, processInstanceKey: key, state: "TERMINATED", reconciled: 1 };
365
+ };
366
+ const { router } = build({}, { cancel });
367
+ const res = await router(req("POST", "/app/actions/cancel", { body: { processInstanceKey: "pi-7" } }));
368
+ assert.equal(res.status, 200);
369
+ assert.deepEqual(seen, ["pi-7"]);
370
+ assert.deepEqual(JSON.parse(res.body ?? "{}"), {
371
+ ok: true,
372
+ processInstanceKey: "pi-7",
373
+ state: "TERMINATED",
374
+ reconciled: 1,
375
+ });
376
+ });
377
+
378
+ test("POST /app/actions/cancel surfaces a 502 when the cancel primitive reports failure", async () => {
379
+ const cancel: PagesDeps["cancel"] = async (key) => ({
380
+ ok: false,
381
+ processInstanceKey: key,
382
+ state: "ACTIVE",
383
+ reconciled: 0,
384
+ error: "engine rejected the cancellation",
385
+ });
386
+ const { router } = build({}, { cancel });
387
+ const res = await router(req("POST", "/app/actions/cancel", { body: { processInstanceKey: "pi-8" } }));
388
+ assert.equal(res.status, 502);
389
+ assert.equal(JSON.parse(res.body ?? "{}").ok, false);
390
+ assert.match(JSON.parse(res.body ?? "{}").error, /engine rejected/);
391
+ });
392
+
393
+ test("POST /app/actions/cancel maps a throwing cancel primitive to a 502", async () => {
394
+ const cancel: PagesDeps["cancel"] = async () => {
395
+ throw new Error("boom");
396
+ };
397
+ const { router } = build({}, { cancel });
398
+ const res = await router(req("POST", "/app/actions/cancel", { body: { processInstanceKey: "pi-x" } }));
399
+ assert.equal(res.status, 502);
400
+ assert.equal(JSON.parse(res.body ?? "{}").ok, false);
401
+ });
402
+
358
403
  test("POST /app/actions/message publishes, 400 on missing fields", async () => {
359
404
  const { router, calls } = build();
360
405
  const ok = await router(req("POST", "/app/actions/message", {
@@ -19,6 +19,7 @@ import type { AppApi, RuntimeContext } from "../context.ts";
19
19
  import { errorMessage, isRecord } from "../guards.ts";
20
20
  import type { EngineClient } from "../host.ts";
21
21
  import { html, json, type Route } from "../router.ts";
22
+ import { cancelInstanceReconciling, type CancelInstanceResult } from "./cancel.ts";
22
23
  import { quoteIdent } from "./gateway.ts";
23
24
 
24
25
  /** The subset of the datasource gateway the page runtime needs. */
@@ -49,6 +50,13 @@ export interface PagesDeps {
49
50
  * Optional; when absent the index is empty. Injectable for tests.
50
51
  */
51
52
  listPages?(): Promise<string[]>;
53
+ /**
54
+ * Cancel a process instance honestly: terminate it, verify it is actually gone, and reconcile
55
+ * any `instanceTracking` row bound to its key. Injected by `mountPages` (bound to the app's
56
+ * engine, datasource, and instanceTracking bindings). When absent, the `/app/actions/cancel`
57
+ * route falls back to a bare `engine.cancelInstance` (used by tests that inject no cancel dep).
58
+ */
59
+ cancel?(processInstanceKey: string): Promise<CancelInstanceResult>;
52
60
  }
53
61
 
54
62
  /** A JavaScript-body response (the renderer module served at /app/runtime.js). */
@@ -70,7 +78,7 @@ export function createPagesRoutes(opts: PagesOptions, deps: PagesDeps): Route[]
70
78
  const rowLimitRaw = opts.rowLimit ?? 200;
71
79
  const rowLimit = Number.isFinite(rowLimitRaw) ? Math.max(0, Math.floor(rowLimitRaw)) : 200;
72
80
  const sourceName = opts.sourceName ?? "app";
73
- const { db, engine, readPage, listPages } = deps;
81
+ const { db, engine, readPage, listPages, cancel } = deps;
74
82
 
75
83
  // The table-name whitelist is memoised: an Urban app runs its migrations at boot
76
84
  // (before serving), so the schema is stable for the process lifetime, and the renderer
@@ -285,6 +293,18 @@ export function createPagesRoutes(opts: PagesOptions, deps: PagesDeps): Route[]
285
293
  return json({ error: "body must be JSON" }, 400);
286
294
  }
287
295
  if (key === undefined || key === "") return json({ error: "processInstanceKey is required" }, 400);
296
+ // The reconcile-aware primitive verifies the real post-cancel state and flips the tracked
297
+ // row immediately; a !ok result means the engine did NOT stop the instance, so surface 502.
298
+ if (cancel) {
299
+ try {
300
+ const result = await cancel(String(key));
301
+ return json(result, result.ok ? 200 : 502);
302
+ } catch (e) {
303
+ // Defensive: the primitive is designed not to throw, but if it ever does, mirror the
304
+ // fallback path's honest 502 rather than letting the request reject as a 500.
305
+ return json({ ok: false, processInstanceKey: String(key), error: errorMessage(e) }, 502);
306
+ }
307
+ }
288
308
  try {
289
309
  await engine.cancelInstance({ processInstanceKey: String(key) });
290
310
  return json({ ok: true });
@@ -354,9 +374,11 @@ export function mountPages(ctx: RuntimeContext, app: AppApi): PagesHandle {
354
374
  sourceName: typeof decl.sourceName === "string" ? decl.sourceName : undefined,
355
375
  };
356
376
  const sourceName = opts.sourceName ?? "app";
377
+ const bindings = ctx.manifest.instanceTracking ?? [];
357
378
  const routes = createPagesRoutes(opts, {
358
379
  db: app.data.open(sourceName),
359
380
  engine: app.engine,
381
+ cancel: (key) => cancelInstanceReconciling(app, bindings, key),
360
382
  readPage: (p) => ctx.host.readTextFile(p),
361
383
  listPages: async () => {
362
384
  const dir = opts.pagesDir ?? "pages";