@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.
- package/dist/openapi/spec.d.ts +127 -0
- package/dist/openapi/spec.js +444 -0
- package/dist/runtime/adapters/deno.js +5 -0
- package/dist/runtime/adapters/node.js +5 -0
- package/dist/runtime/core/execContext.d.ts +28 -0
- package/dist/runtime/core/execContext.js +39 -0
- package/dist/runtime/core/host.d.ts +41 -0
- package/dist/runtime/core/manifest.d.ts +1 -1
- package/dist/runtime/core/modules/api.d.ts +61 -0
- package/dist/runtime/core/modules/api.js +359 -0
- package/dist/runtime/core/modules/cancel.d.ts +31 -0
- package/dist/runtime/core/modules/cancel.js +90 -0
- package/dist/runtime/core/modules/connectors.js +3 -1
- package/dist/runtime/core/modules/datasource.d.ts +23 -3
- package/dist/runtime/core/modules/datasource.js +61 -6
- package/dist/runtime/core/modules/gateway.d.ts +10 -3
- package/dist/runtime/core/modules/gateway.js +23 -6
- package/dist/runtime/core/modules/instance-tracking.d.ts +22 -0
- package/dist/runtime/core/modules/instance-tracking.js +158 -0
- package/dist/runtime/core/modules/pages.d.ts +8 -0
- package/dist/runtime/core/modules/pages.js +17 -1
- package/dist/runtime/core/modules/scheduler.d.ts +11 -0
- package/dist/runtime/core/modules/scheduler.js +21 -0
- package/dist/runtime/core/modules/surfaces.js +9 -0
- package/dist/runtime/core/modules/triggers.d.ts +2 -8
- package/dist/runtime/core/modules/triggers.js +1 -16
- package/dist/runtime/core/modules/workers.js +3 -2
- package/dist/runtime/core/runtime.d.ts +3 -0
- package/dist/runtime/core/runtime.js +12 -0
- package/dist/runtime/core/validate.js +38 -0
- package/dist/runtime/engine/nanosdk.d.ts +14 -1
- package/dist/runtime/engine/nanosdk.js +90 -7
- package/dist/runtime/index.d.ts +4 -0
- package/dist/runtime/index.js +2 -0
- package/dist/toolkit/artifact.d.ts +5 -0
- package/dist/toolkit/artifact.js +7 -0
- package/dist/toolkit/derivers/api.d.ts +19 -0
- package/dist/toolkit/derivers/api.js +224 -0
- package/dist/toolkit/gen.js +23 -4
- package/dist/toolkit/index.d.ts +5 -0
- package/dist/toolkit/index.js +12 -1
- package/package.json +2 -2
- package/src/openapi/spec.test.ts +292 -0
- package/src/openapi/spec.ts +540 -0
- package/src/runtime/adapters/deno.ts +6 -0
- package/src/runtime/adapters/node.ts +6 -0
- package/src/runtime/core/execContext.ts +56 -0
- package/src/runtime/core/host.ts +44 -0
- package/src/runtime/core/manifest.ts +1 -0
- package/src/runtime/core/modules/actions.test.ts +1 -0
- package/src/runtime/core/modules/api.test.ts +445 -0
- package/src/runtime/core/modules/api.ts +453 -0
- package/src/runtime/core/modules/cancel.test.ts +310 -0
- package/src/runtime/core/modules/cancel.ts +119 -0
- package/src/runtime/core/modules/connectors.test.ts +1 -0
- package/src/runtime/core/modules/connectors.ts +7 -1
- package/src/runtime/core/modules/datasource.migrations.test.ts +1 -0
- package/src/runtime/core/modules/datasource.ts +77 -6
- package/src/runtime/core/modules/gateway.ts +28 -6
- package/src/runtime/core/modules/instance-tracking.test.ts +345 -0
- package/src/runtime/core/modules/instance-tracking.ts +180 -0
- package/src/runtime/core/modules/pages.test.ts +47 -2
- package/src/runtime/core/modules/pages.ts +23 -1
- package/src/runtime/core/modules/provenance.test.ts +181 -0
- package/src/runtime/core/modules/scheduler.ts +31 -0
- package/src/runtime/core/modules/surfaces.test.ts +2 -0
- package/src/runtime/core/modules/surfaces.ts +10 -0
- package/src/runtime/core/modules/triggers.test.ts +1 -0
- package/src/runtime/core/modules/triggers.ts +3 -25
- package/src/runtime/core/modules/workers.test.ts +1 -0
- package/src/runtime/core/modules/workers.ts +11 -2
- package/src/runtime/core/runtime.test.ts +3 -0
- package/src/runtime/core/runtime.ts +16 -0
- package/src/runtime/core/validate.test.ts +96 -0
- package/src/runtime/core/validate.ts +39 -0
- package/src/runtime/engine/nanosdk.test.ts +118 -5
- package/src/runtime/engine/nanosdk.ts +102 -7
- package/src/runtime/index.ts +12 -0
- package/src/toolkit/artifact.ts +8 -0
- package/src/toolkit/derivers/api.test.ts +219 -0
- package/src/toolkit/derivers/api.ts +255 -0
- package/src/toolkit/gen.test.ts +39 -0
- package/src/toolkit/gen.ts +24 -4
- 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
|
+
}
|