@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,39 @@
1
+ // Ambient job execution context — threads the active job's process instance / element through
2
+ // to DataLayer writes so write-provenance capture (ProcessOS domain-signal plane, D0) can join
3
+ // written rows back to the instance/element that wrote them, WITHOUT plumbing the job through
4
+ // every call. Entirely domain-free: it knows nothing about any app's tables or their meaning.
5
+ //
6
+ // The async primitive (`AsyncLocalStorage`) is a host capability (`node:async_hooks`), so it is
7
+ // injected through the `HostContext` seam; core stays `node:*`-free per the host contract. When
8
+ // no store is installed — a host that doesn't provide one, or a plain unit test — `run()` just
9
+ // calls `fn` and `current()` returns undefined, so capture degrades to a no-op. Absent-safe and
10
+ // zero-cost when off, by construction.
11
+ let store;
12
+ /**
13
+ * Install the host-backed ambient store once per process. Idempotent — the first store created
14
+ * wins: every Node/Deno store is an equivalent `AsyncLocalStorage` instance, and a process that
15
+ * hosts several apps shares one async store safely (values are keyed by async execution context,
16
+ * not by app). A `factory` that returns undefined (a host without the capability) leaves capture
17
+ * disabled, but a later capable host may still install the process store.
18
+ */
19
+ export function installExecStore(factory) {
20
+ if (store)
21
+ return;
22
+ const next = factory();
23
+ if (next)
24
+ store = next;
25
+ }
26
+ /** Run `fn` with `ctx` as the ambient job context. A transparent pass-through when no store is
27
+ * installed (returns `fn()` directly). */
28
+ export function runInJobContext(ctx, fn) {
29
+ return store ? store.run(ctx, fn) : fn();
30
+ }
31
+ /** The ambient job context of the current async execution, or undefined outside a job (or when
32
+ * no store is installed). */
33
+ export function currentJobContext() {
34
+ return store?.current();
35
+ }
36
+ /** Test seam: drop the installed store so a subsequent {@link installExecStore} re-installs. */
37
+ export function __resetExecStoreForTests() {
38
+ store = undefined;
39
+ }
@@ -25,6 +25,16 @@ export interface HttpServer {
25
25
  export interface WatchHandle {
26
26
  close(): void;
27
27
  }
28
+ /** A minimal ambient async-scoped store — the subset of `AsyncLocalStorage` the runtime needs
29
+ * to thread a job's execution context to DataLayer writes for write-provenance capture. Backed
30
+ * by `node:async_hooks` in both the Node and Deno adapters (both provide it); core depends only
31
+ * on this interface, never on `node:*`. */
32
+ export interface AsyncStore<T> {
33
+ /** Run `fn` with `value` as the current store value, restoring the prior value afterwards. */
34
+ run<R>(value: T, fn: () => R): R;
35
+ /** The current store value, or undefined outside any `run`. */
36
+ current(): T | undefined;
37
+ }
28
38
  /** A tiny synchronous SQLite handle — the subset the runtime needs. */
29
39
  export interface SqliteDb {
30
40
  /** Execute one or more statements with no result (DDL, PRAGMA, migrations). */
@@ -81,6 +91,12 @@ export interface HostContext {
81
91
  watch?(onChange: (path: string) => void): WatchHandle;
82
92
  /** Current wall-clock time in ms since epoch (seam for tests). */
83
93
  now(): number;
94
+ /**
95
+ * Create an ambient async-scoped store (backed by `AsyncLocalStorage`). Used to thread the
96
+ * active job's execution context to DataLayer writes for write-provenance capture. Optional:
97
+ * a host that cannot provide one leaves provenance capture disabled (absent-safe).
98
+ */
99
+ createAsyncStore?<T>(): AsyncStore<T>;
84
100
  /** Structured log sink. */
85
101
  log(level: "info" | "warn" | "error", msg: string, fields?: Record<string, unknown>): void;
86
102
  }
@@ -122,6 +138,19 @@ export interface WorkerSubscription {
122
138
  readonly jobType: string;
123
139
  unsubscribe(): Promise<void>;
124
140
  }
141
+ /**
142
+ * A process instance's terminal-relevant lifecycle state, as the engine reports it.
143
+ * `ACTIVE` (still running), `COMPLETED` (ended normally at an end event), or
144
+ * `TERMINATED` (cancelled/terminated — via a row-cancel, an operator, or a crash).
145
+ * Deliberately the small set the instance-tracking reconciler keys on, not the richer
146
+ * element-instance state set (which adds SKIPPED/CANCELED/FAILED for tokens).
147
+ */
148
+ export type ProcessInstanceState = "ACTIVE" | "COMPLETED" | "TERMINATED";
149
+ /** A single process instance's lifecycle snapshot returned by {@link EngineClient.searchProcessInstances}. */
150
+ export interface ProcessInstanceSnapshot {
151
+ readonly processInstanceKey: string;
152
+ readonly state: ProcessInstanceState;
153
+ }
125
154
  /**
126
155
  * EngineClient is the seam onto a Nano engine. The SDK/REST-backed adapter implements
127
156
  * it against a live engine; tests implement it in-memory. Core modules depend only on this.
@@ -166,6 +195,18 @@ export interface EngineClient {
166
195
  }[]>;
167
196
  /** Complete a user task. */
168
197
  completeUserTask(userTaskKey: string, variables?: Record<string, unknown>): Promise<void>;
198
+ /**
199
+ * Search process instances by key and/or lifecycle state. The instance-tracking
200
+ * reconciler uses this to detect a tracked instance reaching a terminal state
201
+ * (`TERMINATED`/`COMPLETED`) even though no completion worker ran for it — the
202
+ * read-model row it backs would otherwise stay "active" forever. An eventually
203
+ * consistent read (zero-wait); pass the keys currently marked active in the
204
+ * read model and, optionally, a `state` to narrow the result.
205
+ */
206
+ searchProcessInstances(filter?: {
207
+ processInstanceKeys?: string[];
208
+ state?: ProcessInstanceState;
209
+ }): Promise<ProcessInstanceSnapshot[]>;
169
210
  /** Register a push worker for a job type. Draining is handled by the adapter. */
170
211
  registerWorker(jobType: string, handler: JobHandler, options?: {
171
212
  workerName?: string;
@@ -1,6 +1,6 @@
1
1
  import type { HostContext } from "./host.ts";
2
2
  import type { AppManifest, Worker } from "@nanobpm/nano-app-schema";
3
- export type { ActionDecl, AppManifest, AppUi, ChatSurface, Connection, Data, DataSource, DomainField, DomainType, LlmBinding, Models, PagesSurface, Runtime, Security, Surfaces, TaskInboxSurface, Trigger, TriggerAction, Worker, } from "@nanobpm/nano-app-schema";
3
+ export type { ActionDecl, AppManifest, AppUi, ChatSurface, Connection, Data, DataSource, DomainField, DomainType, InstanceTracking, LlmBinding, Models, PagesSurface, Runtime, Security, Surfaces, TaskInboxSurface, Trigger, TriggerAction, Worker, } from "@nanobpm/nano-app-schema";
4
4
  /** The job type a worker subscribes to (schema key: `taskType`). */
5
5
  export declare function workerJobType(w: Worker): string | undefined;
6
6
  /**
@@ -0,0 +1,61 @@
1
+ import type { AppApi, RuntimeContext } from "../context.ts";
2
+ import type { HttpRequest } from "../host.ts";
3
+ import { type Route } from "../router.ts";
4
+ /** The manifest `api` binding (ADR 0058). Mirrored locally until `@nanobpm/nano-app-schema`
5
+ * republishes with the `api` field folded into `AppManifest` (the schema PR lands first). */
6
+ export interface ApiBinding {
7
+ spec: string;
8
+ dir?: string;
9
+ base?: string;
10
+ validateResponses?: "dev" | "always" | "never";
11
+ eject?: boolean;
12
+ }
13
+ /** The `{ params, query, body }` contract a typed operation is generic over. */
14
+ export interface OperationContract {
15
+ params: unknown;
16
+ query: unknown;
17
+ body: unknown;
18
+ }
19
+ /** The loose contract an untyped delegate sees (strings on the wire; unknown JSON body). */
20
+ export interface DefaultContract {
21
+ params: Record<string, string>;
22
+ query: Record<string, string | string[] | undefined>;
23
+ body: unknown;
24
+ }
25
+ /** The input handed to an operation delegate: the raw request plus the parsed (and, unless ejected,
26
+ * validated) path params, query, and JSON body. */
27
+ export interface OperationInput<Req extends OperationContract = DefaultContract> {
28
+ req: HttpRequest;
29
+ params: Req["params"];
30
+ query: Req["query"];
31
+ body: Req["body"];
32
+ }
33
+ /** What a delegate returns; the runtime serializes `body` as JSON. Empty return → `204`. */
34
+ export interface OperationResult<Res = unknown> {
35
+ status?: number;
36
+ headers?: Record<string, string>;
37
+ body?: Res;
38
+ }
39
+ /** An app-authored operation delegate: validated/typed input + the injected AppApi. */
40
+ export type OperationHandler<Req extends OperationContract = DefaultContract, Res = unknown> = (input: OperationInput<Req>, app: AppApi) => Promise<OperationResult<Res> | void> | OperationResult<Res> | void;
41
+ /**
42
+ * Typed-identity helper the generated `operations.ts` wrapper re-types per operationId. Authoring:
43
+ * `export default defineOperation("createInvoice", async ({ params, query, body }, app) => { ... })`
44
+ * — the handler's first argument is the validated `OperationInput` (params/query/body/req), not the
45
+ * raw request. The runtime resolves a delegate by its module path (`<dir>/<operationId>`), so the id
46
+ * here is a documentation + typing aid; it is returned unchanged.
47
+ */
48
+ export declare function defineOperation<Req extends OperationContract = DefaultContract, Res = unknown>(_id: string, handler: OperationHandler<Req, Res>): OperationHandler<Req, Res>;
49
+ /** Resolve an operation delegate from a loaded module: `default` (when a function) else `handler`. */
50
+ export declare function resolveOperationHandler(mod: Record<string, unknown>): OperationHandler | undefined;
51
+ export interface ApiHandle {
52
+ readonly name: string;
53
+ routes: Route[];
54
+ describe(): Record<string, unknown>;
55
+ }
56
+ /**
57
+ * Mount the OpenAPI endpoint surface. Returns no routes when the app declares no `api` binding, so
58
+ * it is a no-op for apps that use only `actions[]`. A malformed spec (bad JSON / not an object)
59
+ * mounts a single route under the base that returns a clear 500, rather than failing app boot.
60
+ */
61
+ export declare function mountApi(ctx: RuntimeContext, app: AppApi): ApiHandle;
@@ -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
+ }