@nanobpm/urban 0.29.0 → 0.31.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 (44) hide show
  1. package/dist/openapi/spec.d.ts +127 -0
  2. package/dist/openapi/spec.js +444 -0
  3. package/dist/runtime/core/modules/api.d.ts +61 -0
  4. package/dist/runtime/core/modules/api.js +359 -0
  5. package/dist/runtime/core/modules/cancel.d.ts +31 -0
  6. package/dist/runtime/core/modules/cancel.js +90 -0
  7. package/dist/runtime/core/modules/datasource.d.ts +5 -2
  8. package/dist/runtime/core/modules/datasource.js +5 -4
  9. package/dist/runtime/core/modules/instance-tracking.d.ts +8 -0
  10. package/dist/runtime/core/modules/instance-tracking.js +28 -11
  11. package/dist/runtime/core/modules/pages.d.ts +8 -0
  12. package/dist/runtime/core/modules/pages.js +43 -7
  13. package/dist/runtime/core/modules/surfaces.js +9 -0
  14. package/dist/runtime/engine/nanosdk.js +41 -7
  15. package/dist/runtime/index.d.ts +4 -0
  16. package/dist/runtime/index.js +2 -0
  17. package/dist/toolkit/artifact.d.ts +5 -0
  18. package/dist/toolkit/artifact.js +7 -0
  19. package/dist/toolkit/derivers/api.d.ts +19 -0
  20. package/dist/toolkit/derivers/api.js +224 -0
  21. package/dist/toolkit/gen.js +23 -4
  22. package/dist/toolkit/index.d.ts +5 -0
  23. package/dist/toolkit/index.js +12 -1
  24. package/package.json +1 -1
  25. package/src/openapi/spec.test.ts +292 -0
  26. package/src/openapi/spec.ts +540 -0
  27. package/src/runtime/core/modules/api.test.ts +445 -0
  28. package/src/runtime/core/modules/api.ts +453 -0
  29. package/src/runtime/core/modules/cancel.test.ts +310 -0
  30. package/src/runtime/core/modules/cancel.ts +119 -0
  31. package/src/runtime/core/modules/datasource.ts +5 -4
  32. package/src/runtime/core/modules/instance-tracking.ts +33 -11
  33. package/src/runtime/core/modules/pages.test.ts +63 -2
  34. package/src/runtime/core/modules/pages.ts +49 -7
  35. package/src/runtime/core/modules/surfaces.ts +10 -0
  36. package/src/runtime/engine/nanosdk.test.ts +45 -5
  37. package/src/runtime/engine/nanosdk.ts +39 -7
  38. package/src/runtime/index.ts +12 -0
  39. package/src/toolkit/artifact.ts +8 -0
  40. package/src/toolkit/derivers/api.test.ts +219 -0
  41. package/src/toolkit/derivers/api.ts +255 -0
  42. package/src/toolkit/gen.test.ts +39 -0
  43. package/src/toolkit/gen.ts +24 -4
  44. package/src/toolkit/index.ts +41 -1
@@ -0,0 +1,359 @@
1
+ // api — the OpenAPI endpoint surface (ADR 0058). The contract-first counterpart to `actions[]`:
2
+ // instead of hand-rolled imperative handlers, an author declares `api: { spec }` in the manifest and
3
+ // writes one delegate module per `operationId`. This runtime reads the SAME OpenAPI document the
4
+ // toolkit deriver turns into types (via the shared `openapi/spec.ts`), mounts one dispatcher over the
5
+ // `api.base` namespace, and for each request: parses → validates params/query/body against the spec →
6
+ // 400 with a structured error → resolves + calls the operationId delegate (validated, typed input +
7
+ // the injected AppApi, exactly like a worker/action handler) → serializes the result.
8
+ //
9
+ // Coexistence + eject (ADR 0058): this mounts AFTER `mountActions` (so an exact `actions[]` route
10
+ // still shadows) and BEFORE the generic pages routes. Ejecting skips generated validation and hands
11
+ // the delegate the raw request — whole-surface via `api.eject`, or per-operation via the
12
+ // `x-urban-eject: true` OpenAPI vendor extension. The delegate is always additively imperative: it
13
+ // gets the raw `req` and full `AppApi` on top of the validated input.
14
+ import { errorMessage } from "../guards.js";
15
+ import { json, normalizeRoutePath } from "../router.js";
16
+ import { resolveAppPath } from "./datasource.js";
17
+ import { collectOperations, isSafeOperationId, operationsWithoutId, operationsWithUnsafeId, parseSpec, resolveSchema, toRouteMatcher, undeclaredPathParams, validateValue, } from "../../../openapi/spec.js";
18
+ /**
19
+ * Typed-identity helper the generated `operations.ts` wrapper re-types per operationId. Authoring:
20
+ * `export default defineOperation("createInvoice", async ({ params, query, body }, app) => { ... })`
21
+ * — the handler's first argument is the validated `OperationInput` (params/query/body/req), not the
22
+ * raw request. The runtime resolves a delegate by its module path (`<dir>/<operationId>`), so the id
23
+ * here is a documentation + typing aid; it is returned unchanged.
24
+ */
25
+ export function defineOperation(_id, handler) {
26
+ return handler;
27
+ }
28
+ function isFunction(v) {
29
+ return typeof v === "function";
30
+ }
31
+ /** Resolve an operation delegate from a loaded module: `default` (when a function) else `handler`. */
32
+ export function resolveOperationHandler(mod) {
33
+ if (isFunction(mod.default))
34
+ return mod.default;
35
+ if (isFunction(mod.handler))
36
+ return mod.handler;
37
+ return undefined;
38
+ }
39
+ function readApiBinding(manifest) {
40
+ const raw = manifest && typeof manifest === "object" ? Reflect.get(manifest, "api") : undefined;
41
+ if (!raw || typeof raw !== "object")
42
+ return undefined;
43
+ const spec = Reflect.get(raw, "spec");
44
+ if (typeof spec !== "string" || spec.trim().length === 0)
45
+ return undefined;
46
+ const dir = Reflect.get(raw, "dir");
47
+ const base = Reflect.get(raw, "base");
48
+ const validateResponses = Reflect.get(raw, "validateResponses");
49
+ const eject = Reflect.get(raw, "eject");
50
+ // Trim benign manifest whitespace so it can't produce hard-to-diagnose path/import failures.
51
+ return {
52
+ spec: spec.trim(),
53
+ dir: typeof dir === "string" && dir.trim().length > 0 ? dir.trim() : undefined,
54
+ base: typeof base === "string" && base.trim().length > 0 ? base.trim() : undefined,
55
+ validateResponses: validateResponses === "dev" || validateResponses === "always" || validateResponses === "never"
56
+ ? validateResponses
57
+ : undefined,
58
+ eject: eject === true,
59
+ };
60
+ }
61
+ /** Coerce a wire string to the type its parameter schema declares, so numeric/boolean bounds
62
+ * validate. A value that will not coerce is left as-is and fails the type check with a clear
63
+ * message. The delegate still receives the original string(s). */
64
+ function coerceParam(doc, schema, raw) {
65
+ const s = resolveSchema(doc, schema);
66
+ if (!s)
67
+ return raw;
68
+ if (s.type === "integer" || s.type === "number") {
69
+ const n = Number(raw);
70
+ return raw.trim() !== "" && !Number.isNaN(n) ? n : raw;
71
+ }
72
+ if (s.type === "boolean") {
73
+ if (raw === "true")
74
+ return true;
75
+ if (raw === "false")
76
+ return false;
77
+ }
78
+ return raw;
79
+ }
80
+ /**
81
+ * Mount the OpenAPI endpoint surface. Returns no routes when the app declares no `api` binding, so
82
+ * it is a no-op for apps that use only `actions[]`. A malformed spec (bad JSON / not an object)
83
+ * mounts a single route under the base that returns a clear 500, rather than failing app boot.
84
+ */
85
+ export function mountApi(ctx, app) {
86
+ const binding = readApiBinding(ctx.manifest);
87
+ if (!binding)
88
+ return { name: "api", routes: [], describe: () => ({ api: "disabled" }) };
89
+ // Normalize the manifest base the same way actions[] routes do (shared normalizeRoutePath):
90
+ // ensure a leading "/", strip trailing slashes, and fall back when empty — otherwise a base
91
+ // like "app/api" would never match request paths, which always start with "/".
92
+ const base = normalizeRoutePath(binding.base, "/app/api");
93
+ const dir = binding.dir ?? "operations";
94
+ const surfaceEject = binding.eject === true;
95
+ // Resolve + parse the spec lazily on first request and cache it (mirroring the lazy module load
96
+ // pattern). A malformed/unreadable spec surfaces as a 500 on request — with the reason — rather
97
+ // than failing app boot.
98
+ let docPromise;
99
+ const loadDoc = () => {
100
+ if (!docPromise) {
101
+ docPromise = ctx.host
102
+ .readTextFile(resolveAppPath(ctx.root, binding.spec))
103
+ .then((text) => parseSpec(text))
104
+ .catch((e) => {
105
+ docPromise = undefined; // let a later request retry (e.g. after the file is fixed)
106
+ throw new Error(`OpenAPI spec ${binding.spec} failed to load: ${errorMessage(e)}`);
107
+ });
108
+ }
109
+ return docPromise;
110
+ };
111
+ // Lazily import + cache each delegate module, mirroring mountActions.
112
+ const moduleCache = new Map();
113
+ const loadModule = (operationId) => {
114
+ // Defense-in-depth at the import sink: collectOperations already skips unsafe ids, but never
115
+ // build an import path from an operationId that isn't a single safe segment.
116
+ if (!isSafeOperationId(operationId)) {
117
+ return Promise.reject(new Error(`unsafe operationId (not a single path segment): ${operationId}`));
118
+ }
119
+ const key = resolveAppPath(ctx.root, `${dir.replace(/[/\\]+$/, "")}/${operationId}`);
120
+ let pending = moduleCache.get(key);
121
+ if (!pending) {
122
+ pending = ctx.host.importModule(key).catch((err) => {
123
+ moduleCache.delete(key);
124
+ throw err;
125
+ });
126
+ moduleCache.set(key, pending);
127
+ }
128
+ return pending;
129
+ };
130
+ // Build the operation table lazily from the loaded doc (cached).
131
+ let opsPromise;
132
+ const loadOps = () => {
133
+ if (!opsPromise) {
134
+ opsPromise = loadDoc()
135
+ .then((d) => {
136
+ const missing = operationsWithoutId(d);
137
+ if (missing.length > 0) {
138
+ ctx.host.log("warn", "OpenAPI operations without operationId are skipped (ADR 0058)", {
139
+ missing,
140
+ });
141
+ }
142
+ const undeclared = undeclaredPathParams(d);
143
+ if (undeclared.length > 0) {
144
+ ctx.host.log("warn", "OpenAPI path-template params are not declared as parameters — captured at runtime but neither typed nor validated (ADR 0058)", { undeclared });
145
+ }
146
+ const unsafe = operationsWithUnsafeId(d);
147
+ if (unsafe.length > 0) {
148
+ ctx.host.log("warn", "OpenAPI operationIds that are not a safe path segment are skipped (ADR 0058)", { unsafe });
149
+ }
150
+ const mounted = collectOperations(d).map((op) => {
151
+ const { pattern, paramNames } = toRouteMatcher(base, op.path);
152
+ return { op, pattern, paramNames };
153
+ });
154
+ // Dispatch matches the first route in order, so sort most-specific first: fewer path-template
155
+ // params (a static segment beats a `{param}` capture), then longer path. This keeps a static
156
+ // route (e.g. /x/active) from being shadowed by a templated one (e.g. /x/{id}).
157
+ mounted.sort((a, b) => {
158
+ if (a.paramNames.length !== b.paramNames.length) {
159
+ return a.paramNames.length - b.paramNames.length;
160
+ }
161
+ return b.op.path.length - a.op.path.length;
162
+ });
163
+ return mounted;
164
+ })
165
+ .catch((e) => {
166
+ opsPromise = undefined; // don't cache the rejection — let a later request retry (mirrors loadDoc)
167
+ throw e;
168
+ });
169
+ }
170
+ return opsPromise;
171
+ };
172
+ const dispatchInner = async (req) => {
173
+ let d;
174
+ let ops;
175
+ try {
176
+ d = await loadDoc();
177
+ ops = await loadOps();
178
+ }
179
+ catch (e) {
180
+ return json({ error: errorMessage(e) }, 500);
181
+ }
182
+ const method = req.method.toUpperCase();
183
+ const match = ops.find((m) => m.op.method.toUpperCase() === method && m.pattern.test(req.path));
184
+ if (!match) {
185
+ // Path is in our namespace but no operation matches: is it a known path on another method?
186
+ const pathKnown = ops.some((m) => m.pattern.test(req.path));
187
+ return json({ error: pathKnown ? "method not allowed" : "no such operation" }, pathKnown ? 405 : 404);
188
+ }
189
+ const { op } = match;
190
+ const ejected = surfaceEject || op.eject;
191
+ // Path params from the capture groups; query as a single-or-array map.
192
+ const captures = match.pattern.exec(req.path) ?? [];
193
+ const params = {};
194
+ try {
195
+ match.paramNames.forEach((name, i) => {
196
+ const v = captures[i + 1];
197
+ if (v !== undefined)
198
+ params[name] = decodeURIComponent(v);
199
+ });
200
+ }
201
+ catch {
202
+ // Malformed percent-encoding is client input, not a server fault — a clear 400 beats a 500.
203
+ return json({ error: "malformed path parameter encoding" }, 400);
204
+ }
205
+ const query = {};
206
+ for (const key of new Set(req.query.keys())) {
207
+ const all = req.query.getAll(key);
208
+ query[key] = all.length > 1 ? all : all[0];
209
+ }
210
+ // Parse the JSON body (empty body → undefined). Invalid JSON is a 400 unless the op is ejected
211
+ // (then the delegate reads the raw request itself).
212
+ const raw = await req.text();
213
+ let body = {};
214
+ let bodyParseFailed = false;
215
+ if (raw) {
216
+ try {
217
+ body = JSON.parse(raw);
218
+ }
219
+ catch {
220
+ bodyParseFailed = true;
221
+ body = undefined;
222
+ }
223
+ }
224
+ else {
225
+ body = undefined;
226
+ }
227
+ if (bodyParseFailed && !ejected) {
228
+ return json({ error: "request body must be JSON" }, 400);
229
+ }
230
+ if (!ejected) {
231
+ const issues = [];
232
+ for (const p of op.parameters) {
233
+ if (p.in === "path") {
234
+ const val = params[p.name];
235
+ if (val === undefined) {
236
+ if (p.required)
237
+ issues.push({ path: `params/${p.name}`, message: "is required" });
238
+ continue;
239
+ }
240
+ issues.push(...validateValue(d, p.schema, coerceParam(d, p.schema, val), `params/${p.name}`));
241
+ }
242
+ else if (p.in === "query") {
243
+ const val = query[p.name];
244
+ if (val === undefined) {
245
+ if (p.required)
246
+ issues.push({ path: `query/${p.name}`, message: "is required" });
247
+ continue;
248
+ }
249
+ const values = Array.isArray(val) ? val : [val];
250
+ const ps = resolveSchema(d, p.schema);
251
+ if (ps?.type === "array") {
252
+ // Repeated keys (?tag=a&tag=b) form the array; coerce + validate each item, then the
253
+ // whole array against the schema's array constraints (minItems, items, ...).
254
+ const items = ps.items ? values.map((v) => coerceParam(d, ps.items, v)) : values;
255
+ issues.push(...validateValue(d, ps, items, `query/${p.name}`));
256
+ }
257
+ else {
258
+ // Scalar param: validate EVERY provided value, not just the first, so extra repeated
259
+ // values can't bypass validation.
260
+ values.forEach((v, i) => {
261
+ const at = values.length > 1 ? `query/${p.name}[${i}]` : `query/${p.name}`;
262
+ issues.push(...validateValue(d, p.schema, coerceParam(d, p.schema, v), at));
263
+ });
264
+ }
265
+ }
266
+ }
267
+ if (op.requestBodyRequired && (raw === "" || body === undefined)) {
268
+ issues.push({ path: "body", message: "request body is required" });
269
+ }
270
+ else if (op.requestBodySchema && body !== undefined) {
271
+ issues.push(...validateValue(d, op.requestBodySchema, body, "body"));
272
+ }
273
+ if (issues.length > 0) {
274
+ return json({ error: "request validation failed", issues }, 400);
275
+ }
276
+ }
277
+ let handler;
278
+ try {
279
+ handler = resolveOperationHandler(await loadModule(op.operationId));
280
+ }
281
+ catch (e) {
282
+ ctx.host.log("error", "operation delegate failed to load", {
283
+ operationId: op.operationId,
284
+ dir,
285
+ error: errorMessage(e),
286
+ });
287
+ return json({ error: `operation ${op.operationId} delegate failed to load` }, 500);
288
+ }
289
+ if (!handler) {
290
+ return json({
291
+ error: `operation ${op.operationId} delegate exports no default function or named \`handler\``,
292
+ }, 500);
293
+ }
294
+ // Keep the runtime body in lockstep with the derived type: the deriver types `body` as
295
+ // `undefined` exactly when the op declares no usable request body (no JSON schema and not
296
+ // required), so pass `undefined` to those handlers rather than leaking a parsed value through
297
+ // a body-less type.
298
+ const handlerBody = op.requestBodySchema !== undefined || op.requestBodyRequired ? body : undefined;
299
+ try {
300
+ const result = await handler({ req, params, query, body: handlerBody }, app);
301
+ if (!result)
302
+ return { status: 204 };
303
+ // Optional response validation (dev by default): warn-only, never blocks the response.
304
+ const mode = binding.validateResponses ?? "dev";
305
+ const doValidate = mode === "always" || (mode === "dev" && ctx.host.env("NODE_ENV") !== "production");
306
+ if (doValidate && op.responseSchema && result.body !== undefined) {
307
+ const rIssues = validateValue(d, op.responseSchema, result.body, "response");
308
+ if (rIssues.length > 0) {
309
+ ctx.host.log("warn", "operation response failed schema validation (ADR 0058)", {
310
+ operationId: op.operationId,
311
+ issues: rIssues,
312
+ });
313
+ }
314
+ }
315
+ return {
316
+ status: result.status ?? 200,
317
+ headers: { "content-type": "application/json", ...(result.headers ?? {}) },
318
+ body: result.body === undefined ? undefined : JSON.stringify(result.body),
319
+ };
320
+ }
321
+ catch (e) {
322
+ return json({ error: errorMessage(e) }, 500);
323
+ }
324
+ };
325
+ // Categorical safety net: any unexpected throw during dispatch — e.g. a malformed spec
326
+ // `pattern` surfacing from validation — becomes a controlled 500 rather than escaping into
327
+ // the host request handler, which has no top-level catch of its own.
328
+ const dispatch = async (req) => {
329
+ try {
330
+ return await dispatchInner(req);
331
+ }
332
+ catch (e) {
333
+ return json({ error: errorMessage(e) }, 500);
334
+ }
335
+ };
336
+ // One prefix route over the base namespace does regex dispatch across all operations — the shared
337
+ // router only does exact/prefix matches, so path-param templates are handled here. A trailing
338
+ // slash keeps the prefix boundary-safe.
339
+ const route = {
340
+ method: "*",
341
+ path: `${base}/`,
342
+ prefix: true,
343
+ source: `api:${binding.spec}`,
344
+ handler: dispatch,
345
+ };
346
+ // Also serve the base path exactly (an operation could live at the namespace root).
347
+ const rootRoute = {
348
+ method: "*",
349
+ path: base,
350
+ prefix: false,
351
+ source: `api:${binding.spec}`,
352
+ handler: dispatch,
353
+ };
354
+ return {
355
+ name: "api",
356
+ routes: [rootRoute, route],
357
+ describe: () => ({ api: { spec: binding.spec, base, dir, eject: surfaceEject } }),
358
+ };
359
+ }
@@ -0,0 +1,31 @@
1
+ import type { AppApi } from "../context.ts";
2
+ import type { InstanceTracking } from "../manifest.ts";
3
+ export type CancelInstanceState = "ACTIVE" | "COMPLETED" | "TERMINATED" | "gone";
4
+ export interface CancelInstanceResult {
5
+ ok: boolean;
6
+ processInstanceKey: string;
7
+ /** The instance state as read back from the engine after the cancel attempt.
8
+ * `"gone"` means the engine has no record of the key (already cleaned up / never existed). */
9
+ state: CancelInstanceState;
10
+ /** Rows flipped to their terminal status by the immediate instanceTracking reconcile. */
11
+ reconciled: number;
12
+ error?: string;
13
+ }
14
+ type CancelApi = Pick<AppApi, "engine" | "data" | "log">;
15
+ /**
16
+ * Cancel a process instance honestly: terminate it, verify it is actually gone, and immediately
17
+ * reconcile any instanceTracking row bound to its key.
18
+ *
19
+ * Outcome rules:
20
+ * - `cancelInstance` returns without throwing → the engine accepted the termination (a committed
21
+ * 204); trust it even if the read model still lags at ACTIVE.
22
+ * - `cancelInstance` throws but the instance reads back non-ACTIVE (already TERMINATED/COMPLETED
23
+ * /gone) → the cancel was effectively idempotent; treat as success.
24
+ * - `cancelInstance` throws and the instance is still ACTIVE → an honest failure: report it and
25
+ * do NOT touch any row (the instance is still running).
26
+ *
27
+ * Reconcile only on `TERMINATED`: a COMPLETED instance's terminal row-write is owned by the app's
28
+ * own finalize logic, and a gone/ACTIVE instance has nothing to flip here.
29
+ */
30
+ export declare function cancelInstanceReconciling(api: CancelApi, bindings: InstanceTracking[], processInstanceKey: string): Promise<CancelInstanceResult>;
31
+ export {};
@@ -0,0 +1,90 @@
1
+ // The Urban-native "cancel a process instance" primitive.
2
+ //
3
+ // Cancelling a running process from a page action must do two honest things that a bare
4
+ // `engine.cancelInstance` call does not:
5
+ //
6
+ // 1. Report the *real* outcome. A cancel can fail (permissions, the instance already gone,
7
+ // a transport error). Swallowing that and reporting success leaves an instance running in
8
+ // the engine while the UI claims it stopped — the worst possible lie for a "cancel" button.
9
+ // 2. Reconcile the tracked row immediately. Apps bind process instances to rows via
10
+ // `instanceTracking`; the poll reconciler flips a row to its terminal status within a few
11
+ // seconds of the instance terminating. When *we* are the one terminating it, we can apply
12
+ // that same declared patch right away instead of waiting for the next poll tick.
13
+ //
14
+ // This primitive keys success on the engine's response, then reconciles off the read model: a
15
+ // non-throwing `cancelInstance` is a committed termination (trusted even if the read model still
16
+ // lags at ACTIVE), while a throw whose instance reads back still-ACTIVE is an honest failure. It
17
+ // reuses the instanceTracking bindings' `onTerminated.set` patch, so the immediate reconcile can
18
+ // never drift from the poll reconciler's.
19
+ import { errorMessage } from "../guards.js";
20
+ import { reconcileTerminatedKey } from "./instance-tracking.js";
21
+ /**
22
+ * Cancel a process instance honestly: terminate it, verify it is actually gone, and immediately
23
+ * reconcile any instanceTracking row bound to its key.
24
+ *
25
+ * Outcome rules:
26
+ * - `cancelInstance` returns without throwing → the engine accepted the termination (a committed
27
+ * 204); trust it even if the read model still lags at ACTIVE.
28
+ * - `cancelInstance` throws but the instance reads back non-ACTIVE (already TERMINATED/COMPLETED
29
+ * /gone) → the cancel was effectively idempotent; treat as success.
30
+ * - `cancelInstance` throws and the instance is still ACTIVE → an honest failure: report it and
31
+ * do NOT touch any row (the instance is still running).
32
+ *
33
+ * Reconcile only on `TERMINATED`: a COMPLETED instance's terminal row-write is owned by the app's
34
+ * own finalize logic, and a gone/ACTIVE instance has nothing to flip here.
35
+ */
36
+ export async function cancelInstanceReconciling(api, bindings, processInstanceKey) {
37
+ const key = String(processInstanceKey);
38
+ let threw;
39
+ try {
40
+ await api.engine.cancelInstance({ processInstanceKey: key });
41
+ }
42
+ catch (error) {
43
+ threw = error;
44
+ }
45
+ const state = await readState(api, key, threw);
46
+ if (threw && state === "ACTIVE") {
47
+ // The cancel genuinely failed and the instance is still running — never flip a row to a
48
+ // terminal status while the engine keeps executing it.
49
+ api.log("error", "cancel: engine termination failed, instance still active", {
50
+ processInstanceKey: key,
51
+ error: errorMessage(threw),
52
+ });
53
+ return { ok: false, processInstanceKey: key, state, reconciled: 0, error: errorMessage(threw) };
54
+ }
55
+ let reconciled = 0;
56
+ if (state === "TERMINATED") {
57
+ try {
58
+ reconciled = await reconcileTerminatedKey(api, bindings, key);
59
+ }
60
+ catch (error) {
61
+ // The engine termination itself succeeded — a reconcile *write* failure (bad binding,
62
+ // transient DB error) must not turn that into an unhandled 500. Log it and let the
63
+ // instanceTracking poll reconciler catch the row up on its next tick.
64
+ api.log("error", "cancel: instance terminated but row reconcile failed", {
65
+ processInstanceKey: key,
66
+ error: errorMessage(error),
67
+ });
68
+ return { ok: true, processInstanceKey: key, state, reconciled: 0, error: errorMessage(error) };
69
+ }
70
+ }
71
+ return { ok: true, processInstanceKey: key, state, reconciled };
72
+ }
73
+ /** Read the instance state back from the engine to verify what actually happened. When the read
74
+ * itself fails we fall back to the cancel call's own result: a thrown cancel with an unverifiable
75
+ * state is reported as still-ACTIVE (honest worst case); an accepted cancel is trusted as gone. */
76
+ async function readState(api, key, threw) {
77
+ try {
78
+ const [snapshot] = await api.engine.searchProcessInstances({ processInstanceKeys: [key] });
79
+ if (snapshot)
80
+ return snapshot.state;
81
+ return "gone";
82
+ }
83
+ catch (error) {
84
+ api.log("warn", "cancel: could not verify post-cancel state", {
85
+ processInstanceKey: key,
86
+ error: errorMessage(error),
87
+ });
88
+ return threw ? "ACTIVE" : "gone";
89
+ }
90
+ }
@@ -2,6 +2,7 @@ import type { RuntimeContext } from "../context.ts";
2
2
  import type { HostContext, SqliteDb } from "../host.ts";
3
3
  import type { DomainType } from "../manifest.ts";
4
4
  import { Table, type DataSource as GatewayDataSource, type InsertObserver } from "./gateway.ts";
5
+ import { isAbsolutePath } from "../../../toolkit/artifact.ts";
5
6
  export declare function sqlitePathFromUrl(url: string): string;
6
7
  /** True when `p` is already an absolute path on any host `urban data` targets. `urban data` runs
7
8
  * on a Node host that may be Windows, so absolute-path detection can't assume POSIX: this matches
@@ -9,8 +10,10 @@ export declare function sqlitePathFromUrl(url: string): string;
9
10
  * that starts with a single backslash ("\data\app.db") and a Windows UNC path
10
11
  * ("\\\\server\\share"). A single leading backslash covers both the drive-root and UNC cases,
11
12
  * matching Node's `path.win32.isAbsolute`. Used by `resolveAppPath` so a caller-supplied absolute
12
- * path is never incorrectly prefixed with the app root. */
13
- export declare function isAbsolutePath(p: string): boolean;
13
+ * path is never incorrectly prefixed with the app root. Defined once in `toolkit/artifact.ts` (the
14
+ * shared, dependency-free layer) and re-exported here so the toolkit's gen path-join and this
15
+ * runtime resolver apply the identical rule (no gen/runtime drift). */
16
+ export { isAbsolutePath };
14
17
  /** Resolve `p` against the app `root`: an absolute `p` (see `isAbsolutePath`) is returned as-is;
15
18
  * a relative `p` is joined onto `root`. Trims a trailing separator of either kind off `root` so
16
19
  * we never emit a doubled separator. The join separator matches the root's own style so we never
@@ -4,6 +4,7 @@
4
4
  // runtime side of ADR 0024 (datasource) + ADR 0040 (domain model), scoped to what an app needs.
5
5
  import { makeGateway, } from "./gateway.js";
6
6
  import { currentJobContext } from "../execContext.js";
7
+ import { isAbsolutePath } from "../../../toolkit/artifact.js";
7
8
  export function sqlitePathFromUrl(url) {
8
9
  // Accept "file:./x.db", "file:x.db", "sqlite:./x.db" or a bare path.
9
10
  return url.replace(/^(file|sqlite):(\/\/)?/, "");
@@ -14,10 +15,10 @@ export function sqlitePathFromUrl(url) {
14
15
  * that starts with a single backslash ("\data\app.db") and a Windows UNC path
15
16
  * ("\\\\server\\share"). A single leading backslash covers both the drive-root and UNC cases,
16
17
  * matching Node's `path.win32.isAbsolute`. Used by `resolveAppPath` so a caller-supplied absolute
17
- * path is never incorrectly prefixed with the app root. */
18
- export function isAbsolutePath(p) {
19
- return /^(\/|\\|[A-Za-z]:[/\\])/.test(p);
20
- }
18
+ * path is never incorrectly prefixed with the app root. Defined once in `toolkit/artifact.ts` (the
19
+ * shared, dependency-free layer) and re-exported here so the toolkit's gen path-join and this
20
+ * runtime resolver apply the identical rule (no gen/runtime drift). */
21
+ export { isAbsolutePath };
21
22
  /** Resolve `p` against the app `root`: an absolute `p` (see `isAbsolutePath`) is returned as-is;
22
23
  * a relative `p` is joined onto `root`. Trims a trailing separator of either kind off `root` so
23
24
  * we never emit a doubled separator. The join separator matches the root's own style so we never
@@ -1,4 +1,5 @@
1
1
  import type { AppApi, Mounted, RuntimeContext } from "../context.ts";
2
+ import type { InstanceTracking } from "../manifest.ts";
2
3
  import { type SchedulerDeps } from "./scheduler.ts";
3
4
  /** Default poll interval when a binding does not set `pollMs`. */
4
5
  export declare const DEFAULT_INSTANCE_TRACKING_POLL_MS = 15000;
@@ -6,6 +7,13 @@ export interface InstanceTrackingHandle extends Mounted {
6
7
  /** `table.keyField` labels of the armed bindings, for `inspect()`. */
7
8
  readonly bindings: string[];
8
9
  }
10
+ /** Apply a binding's `onTerminated.set` patch to the single row keyed by `processInstanceKey`.
11
+ * Only the binding whose table owns that key has a matching row, so callers can fan this over
12
+ * every binding and let the no-match ones be no-ops. Returns rows changed. The single place that
13
+ * writes the terminal patch (and logs the reconcile): both the poll reconciler below (`reconcileOnce`,
14
+ * once per TERMINATED key it discovers) and the cancel primitive (immediately when it terminates an
15
+ * instance) route through here, so the two can never drift on the patch or the log. */
16
+ export declare function reconcileTerminatedKey(api: Pick<AppApi, "data" | "log">, bindings: InstanceTracking[], processInstanceKey: string): Promise<number>;
9
17
  /**
10
18
  * Mount the `instanceTracking` reconciler: arm one poll loop per binding. Each loop selects
11
19
  * the active rows, asks the engine which of their instances are terminated, and applies the
@@ -15,6 +15,30 @@
15
15
  import { defaultScheduler, MAX_TIMER_DELAY_MS } from "./scheduler.js";
16
16
  /** Default poll interval when a binding does not set `pollMs`. */
17
17
  export const DEFAULT_INSTANCE_TRACKING_POLL_MS = 15_000;
18
+ /** Apply a binding's `onTerminated.set` patch to the single row keyed by `processInstanceKey`.
19
+ * Only the binding whose table owns that key has a matching row, so callers can fan this over
20
+ * every binding and let the no-match ones be no-ops. Returns rows changed. The single place that
21
+ * writes the terminal patch (and logs the reconcile): both the poll reconciler below (`reconcileOnce`,
22
+ * once per TERMINATED key it discovers) and the cancel primitive (immediately when it terminates an
23
+ * instance) route through here, so the two can never drift on the patch or the log. */
24
+ export async function reconcileTerminatedKey(api, bindings, processInstanceKey) {
25
+ const key = String(processInstanceKey);
26
+ let reconciled = 0;
27
+ for (const binding of bindings) {
28
+ const table = api.data.table(binding.table, binding.keyField);
29
+ const changed = await table.update(key, binding.onTerminated.set);
30
+ if (changed > 0) {
31
+ reconciled += changed;
32
+ api.log("info", "instanceTracking: reconciled terminated instance", {
33
+ table: binding.table,
34
+ keyField: binding.keyField,
35
+ processInstanceKey: key,
36
+ rowsChanged: changed,
37
+ });
38
+ }
39
+ }
40
+ return reconciled;
41
+ }
18
42
  /** Poll one binding's instances once. Selects the active rows, asks the engine which of their
19
43
  * instances are TERMINATED, and applies `onTerminated.set` to each. Returns how many keys were
20
44
  * scanned and how many rows were reconciled this tick. */
@@ -54,23 +78,16 @@ async function reconcileOnce(api, binding) {
54
78
  processInstanceKeys: keys,
55
79
  state: "TERMINATED",
56
80
  });
57
- const patch = binding.onTerminated.set;
81
+ // Apply the terminal patch through the shared `reconcileTerminatedKey` — the one place that
82
+ // writes `onTerminated.set` and logs the reconcile — so the poll path and the cancel primitive
83
+ // can never drift on the patch or its logging (No Drift Surfaces).
58
84
  let reconciled = 0;
59
85
  for (const snap of snapshots) {
60
86
  if (snap.state !== "TERMINATED")
61
87
  continue; // defensive; we asked for TERMINATED only
62
88
  if (!keyToRows.has(snap.processInstanceKey))
63
89
  continue; // not one we were tracking
64
- const changed = await table.update(snap.processInstanceKey, patch);
65
- if (changed > 0) {
66
- reconciled += changed;
67
- api.log("info", "instanceTracking: reconciled terminated instance", {
68
- table: binding.table,
69
- keyField: binding.keyField,
70
- processInstanceKey: snap.processInstanceKey,
71
- rowsChanged: changed,
72
- });
73
- }
90
+ reconciled += await reconcileTerminatedKey(api, [binding], snap.processInstanceKey);
74
91
  }
75
92
  return { scanned: keys.length, reconciled };
76
93
  }
@@ -1,6 +1,7 @@
1
1
  import type { AppApi, RuntimeContext } from "../context.ts";
2
2
  import type { EngineClient } from "../host.ts";
3
3
  import { type Route } from "../router.ts";
4
+ import { type CancelInstanceResult } from "./cancel.ts";
4
5
  /** The subset of the datasource gateway the page runtime needs. */
5
6
  export interface PagesDataSource {
6
7
  query(sql: string, params?: unknown[]): Promise<Record<string, unknown>[]>;
@@ -29,6 +30,13 @@ export interface PagesDeps {
29
30
  * Optional; when absent the index is empty. Injectable for tests.
30
31
  */
31
32
  listPages?(): Promise<string[]>;
33
+ /**
34
+ * Cancel a process instance honestly: terminate it, verify it is actually gone, and reconcile
35
+ * any `instanceTracking` row bound to its key. Injected by `mountPages` (bound to the app's
36
+ * engine, datasource, and instanceTracking bindings). When absent, the `/app/actions/cancel`
37
+ * route falls back to a bare `engine.cancelInstance` (used by tests that inject no cancel dep).
38
+ */
39
+ cancel?(processInstanceKey: string): Promise<CancelInstanceResult>;
32
40
  }
33
41
  /**
34
42
  * Build the page-runtime routes over an injected datasource + engine. Pure over its deps