@nanobpm/urban 0.29.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/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/datasource.d.ts +5 -2
- package/dist/runtime/core/modules/datasource.js +5 -4
- package/dist/runtime/core/modules/instance-tracking.d.ts +8 -0
- package/dist/runtime/core/modules/instance-tracking.js +28 -11
- package/dist/runtime/core/modules/pages.d.ts +8 -0
- package/dist/runtime/core/modules/pages.js +17 -1
- package/dist/runtime/core/modules/surfaces.js +9 -0
- package/dist/runtime/engine/nanosdk.js +41 -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 +1 -1
- package/src/openapi/spec.test.ts +292 -0
- package/src/openapi/spec.ts +540 -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/datasource.ts +5 -4
- package/src/runtime/core/modules/instance-tracking.ts +33 -11
- package/src/runtime/core/modules/pages.test.ts +46 -2
- package/src/runtime/core/modules/pages.ts +23 -1
- package/src/runtime/core/modules/surfaces.ts +10 -0
- package/src/runtime/engine/nanosdk.test.ts +45 -5
- package/src/runtime/engine/nanosdk.ts +39 -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,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
|
-
|
|
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
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|