@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
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
// POST /app/actions/message → engine.publishMessage
|
|
17
17
|
import { errorMessage, isRecord } from "../guards.js";
|
|
18
18
|
import { html, json } from "../router.js";
|
|
19
|
+
import { cancelInstanceReconciling } from "./cancel.js";
|
|
19
20
|
import { quoteIdent } from "./gateway.js";
|
|
20
21
|
/** A JavaScript-body response (the renderer module served at /app/runtime.js). */
|
|
21
22
|
function javascript(body, status = 200) {
|
|
@@ -34,7 +35,7 @@ export function createPagesRoutes(opts, deps) {
|
|
|
34
35
|
const rowLimitRaw = opts.rowLimit ?? 200;
|
|
35
36
|
const rowLimit = Number.isFinite(rowLimitRaw) ? Math.max(0, Math.floor(rowLimitRaw)) : 200;
|
|
36
37
|
const sourceName = opts.sourceName ?? "app";
|
|
37
|
-
const { db, engine, readPage, listPages } = deps;
|
|
38
|
+
const { db, engine, readPage, listPages, cancel } = deps;
|
|
38
39
|
// The table-name whitelist is memoised: an Urban app runs its migrations at boot
|
|
39
40
|
// (before serving), so the schema is stable for the process lifetime, and the renderer
|
|
40
41
|
// refreshes grids repeatedly — re-introspecting on every `/app/data` hit would be a hot
|
|
@@ -257,6 +258,19 @@ export function createPagesRoutes(opts, deps) {
|
|
|
257
258
|
}
|
|
258
259
|
if (key === undefined || key === "")
|
|
259
260
|
return json({ error: "processInstanceKey is required" }, 400);
|
|
261
|
+
// The reconcile-aware primitive verifies the real post-cancel state and flips the tracked
|
|
262
|
+
// row immediately; a !ok result means the engine did NOT stop the instance, so surface 502.
|
|
263
|
+
if (cancel) {
|
|
264
|
+
try {
|
|
265
|
+
const result = await cancel(String(key));
|
|
266
|
+
return json(result, result.ok ? 200 : 502);
|
|
267
|
+
}
|
|
268
|
+
catch (e) {
|
|
269
|
+
// Defensive: the primitive is designed not to throw, but if it ever does, mirror the
|
|
270
|
+
// fallback path's honest 502 rather than letting the request reject as a 500.
|
|
271
|
+
return json({ ok: false, processInstanceKey: String(key), error: errorMessage(e) }, 502);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
260
274
|
try {
|
|
261
275
|
await engine.cancelInstance({ processInstanceKey: String(key) });
|
|
262
276
|
return json({ ok: true });
|
|
@@ -323,9 +337,11 @@ export function mountPages(ctx, app) {
|
|
|
323
337
|
sourceName: typeof decl.sourceName === "string" ? decl.sourceName : undefined,
|
|
324
338
|
};
|
|
325
339
|
const sourceName = opts.sourceName ?? "app";
|
|
340
|
+
const bindings = ctx.manifest.instanceTracking ?? [];
|
|
326
341
|
const routes = createPagesRoutes(opts, {
|
|
327
342
|
db: app.data.open(sourceName),
|
|
328
343
|
engine: app.engine,
|
|
344
|
+
cancel: (key) => cancelInstanceReconciling(app, bindings, key),
|
|
329
345
|
readPage: (p) => ctx.host.readTextFile(p),
|
|
330
346
|
listPages: async () => {
|
|
331
347
|
const dir = opts.pagesDir ?? "pages";
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// (LLM wiring is a separate concern). Surfaces contribute routes to the shared server.
|
|
5
5
|
import { html, json, normalizeRoutePath } from "../router.js";
|
|
6
6
|
import { mountActions } from "./actions.js";
|
|
7
|
+
import { mountApi } from "./api.js";
|
|
7
8
|
import { mountPages } from "./pages.js";
|
|
8
9
|
/** Escape HTML-significant characters before embedding a value in markup. */
|
|
9
10
|
function escapeHtml(s) {
|
|
@@ -93,6 +94,14 @@ export function mountSurfaces(ctx, app) {
|
|
|
93
94
|
routes.push(...actions.routes);
|
|
94
95
|
enabled.push(`actions(${actions.routes.length})`);
|
|
95
96
|
}
|
|
97
|
+
// The OpenAPI endpoint surface (ADR 0058) mounts AFTER actions (so an exact `actions[]` route
|
|
98
|
+
// still shadows an operation) and BEFORE the generic pages routes. It owns the `api.base`
|
|
99
|
+
// namespace via a regex dispatcher, so it only contributes routes when the app declares `api`.
|
|
100
|
+
const api = mountApi(ctx, app);
|
|
101
|
+
if (api.routes.length > 0) {
|
|
102
|
+
routes.push(...api.routes);
|
|
103
|
+
enabled.push("api");
|
|
104
|
+
}
|
|
96
105
|
// The pages surface (the schema-driven page runtime) mounts its own routes.
|
|
97
106
|
const pages = mountPages(ctx, app);
|
|
98
107
|
if (pages.routes.length > 0) {
|
|
@@ -169,9 +169,9 @@ export class SdkEngineClient {
|
|
|
169
169
|
elementId: job.elementId,
|
|
170
170
|
variables: job.variables ?? {},
|
|
171
171
|
};
|
|
172
|
+
let out;
|
|
172
173
|
try {
|
|
173
|
-
|
|
174
|
-
return await job.complete(out ?? {});
|
|
174
|
+
out = await handler(engineJob);
|
|
175
175
|
}
|
|
176
176
|
catch (err) {
|
|
177
177
|
// A handler-raised BPMN error is a modelled, non-retryable outcome:
|
|
@@ -201,17 +201,51 @@ export class SdkEngineClient {
|
|
|
201
201
|
}
|
|
202
202
|
}
|
|
203
203
|
const message = err instanceof Error ? err.message : String(err);
|
|
204
|
-
this.log("error", `handler ${jobType} threw`, {
|
|
204
|
+
this.log("error", `handler ${jobType} threw`, {
|
|
205
|
+
err: message,
|
|
206
|
+
jobKey: engineJob.jobKey,
|
|
207
|
+
processInstanceKey: engineJob.processInstanceKey,
|
|
208
|
+
elementId: engineJob.elementId,
|
|
209
|
+
});
|
|
205
210
|
try {
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
|
|
211
|
+
// A generic handler failure omits the retry count so the SDK decrements the job's
|
|
212
|
+
// remaining retries (`job.retries - 1`): a transient failure (e.g. a fleeting store
|
|
213
|
+
// error) self-heals on redelivery and only parks as an incident once the budget is
|
|
214
|
+
// exhausted. A `BpmnError` reaching here (no `job.error()` transport, or the error
|
|
215
|
+
// report itself threw) is the exception: it is a modelled, DETERMINISTIC outcome, so
|
|
216
|
+
// retrying would re-throw the same error and burn the budget for nothing — pin
|
|
217
|
+
// `retries: 0` to keep it non-retryable. Await + swallow so a rejected `fail` cannot
|
|
218
|
+
// escape as an unhandled rejection.
|
|
219
|
+
const body = {
|
|
220
|
+
errorMessage: message.slice(0, 500),
|
|
221
|
+
};
|
|
222
|
+
if (isBpmnError(err))
|
|
223
|
+
body.retries = 0;
|
|
224
|
+
return await job.fail(body);
|
|
210
225
|
}
|
|
211
226
|
catch {
|
|
212
227
|
return undefined;
|
|
213
228
|
}
|
|
214
229
|
}
|
|
230
|
+
// The handler resolved: its work is done. Reporting that result to the engine
|
|
231
|
+
// (`complete`) is a SEPARATE concern — a failure here is a transport/engine problem
|
|
232
|
+
// (e.g. a transient store error), NOT a handler bug. Failing it (even with a decrementing
|
|
233
|
+
// retry) would burn the job's retry budget for a completion we can't confirm didn't land.
|
|
234
|
+
// Instead, log and leave the job locked so the engine redelivers it on lock timeout;
|
|
235
|
+
// handlers are idempotent (at-least-once), so a redelivery re-completes cleanly.
|
|
236
|
+
try {
|
|
237
|
+
return await job.complete(out ?? {});
|
|
238
|
+
}
|
|
239
|
+
catch (completeErr) {
|
|
240
|
+
const message = completeErr instanceof Error ? completeErr.message : String(completeErr);
|
|
241
|
+
this.log("error", `complete ${jobType} failed; leaving job for engine redelivery`, {
|
|
242
|
+
err: message,
|
|
243
|
+
jobKey: engineJob.jobKey,
|
|
244
|
+
processInstanceKey: engineJob.processInstanceKey,
|
|
245
|
+
elementId: engineJob.elementId,
|
|
246
|
+
});
|
|
247
|
+
return undefined;
|
|
248
|
+
}
|
|
215
249
|
},
|
|
216
250
|
});
|
|
217
251
|
// The nano-sdk job worker owns its own start lifecycle: `createJobWorker` runs
|
package/dist/runtime/index.d.ts
CHANGED
|
@@ -17,8 +17,12 @@ export { makeGateway, Table } from "./core/modules/gateway.ts";
|
|
|
17
17
|
export type { ColumnMeta, DataSource as GatewayDataSource, ExecResult, ForeignKeyMeta, Row, TableMeta, } from "./core/modules/gateway.ts";
|
|
18
18
|
export { createPagesRoutes, mountPages } from "./core/modules/pages.ts";
|
|
19
19
|
export type { PagesDataSource, PagesDeps, PagesHandle, PagesOptions } from "./core/modules/pages.ts";
|
|
20
|
+
export { cancelInstanceReconciling } from "./core/modules/cancel.ts";
|
|
21
|
+
export type { CancelInstanceResult, CancelInstanceState } from "./core/modules/cancel.ts";
|
|
20
22
|
export { mountActions, resolveActionHandler } from "./core/modules/actions.ts";
|
|
21
23
|
export type { ActionHandler, ActionRequest, ActionResult, ActionsHandle } from "./core/modules/actions.ts";
|
|
24
|
+
export { defineOperation, mountApi, resolveOperationHandler } from "./core/modules/api.ts";
|
|
25
|
+
export type { ApiBinding, ApiHandle, DefaultContract, OperationContract, OperationHandler, OperationInput, OperationResult, } from "./core/modules/api.ts";
|
|
22
26
|
export type { AppJobHandler } from "./core/modules/workers.ts";
|
|
23
27
|
export { resolveHandler, sdkDecisionEvaluator } from "./core/modules/workers.ts";
|
|
24
28
|
export { buildMessages, callLlm, resolveProvider, runLlmJob, } from "./core/modules/llm.ts";
|
package/dist/runtime/index.js
CHANGED
|
@@ -15,7 +15,9 @@ export { DataLayer, TypeRepo } from "./core/modules/datasource.js";
|
|
|
15
15
|
export { runDataOp } from "./core/modules/dataops.js";
|
|
16
16
|
export { makeGateway, Table } from "./core/modules/gateway.js";
|
|
17
17
|
export { createPagesRoutes, mountPages } from "./core/modules/pages.js";
|
|
18
|
+
export { cancelInstanceReconciling } from "./core/modules/cancel.js";
|
|
18
19
|
export { mountActions, resolveActionHandler } from "./core/modules/actions.js";
|
|
20
|
+
export { defineOperation, mountApi, resolveOperationHandler } from "./core/modules/api.js";
|
|
19
21
|
export { resolveHandler, sdkDecisionEvaluator } from "./core/modules/workers.js";
|
|
20
22
|
export { buildMessages, callLlm, resolveProvider, runLlmJob, } from "./core/modules/llm.js";
|
|
21
23
|
export { evalCorrelation } from "./core/modules/triggers.js";
|
|
@@ -17,3 +17,8 @@ export interface Deriver<I> {
|
|
|
17
17
|
export declare const GENERATED_DIR = "nano-generated";
|
|
18
18
|
/** Stable ordering so artifact lists compare deterministically. */
|
|
19
19
|
export declare function sortArtifacts(a: DerivedArtifact[]): DerivedArtifact[];
|
|
20
|
+
/** True when `p` is an absolute path — POSIX root (`/`), a drive-letter root (`C:\` or `C:/`), or a
|
|
21
|
+
* Windows UNC/drive-root backslash (`\`). The single source of truth for the absolute-path rule,
|
|
22
|
+
* shared by the toolkit's path join (gen.ts) and the runtime's resolveAppPath so both agree on
|
|
23
|
+
* whether a manifest path is app-root-relative or absolute (no gen/runtime drift). */
|
|
24
|
+
export declare function isAbsolutePath(p: string): boolean;
|
package/dist/toolkit/artifact.js
CHANGED
|
@@ -9,3 +9,10 @@ export const GENERATED_DIR = "nano-generated";
|
|
|
9
9
|
export function sortArtifacts(a) {
|
|
10
10
|
return [...a].sort((x, y) => (x.path < y.path ? -1 : x.path > y.path ? 1 : 0));
|
|
11
11
|
}
|
|
12
|
+
/** True when `p` is an absolute path — POSIX root (`/`), a drive-letter root (`C:\` or `C:/`), or a
|
|
13
|
+
* Windows UNC/drive-root backslash (`\`). The single source of truth for the absolute-path rule,
|
|
14
|
+
* shared by the toolkit's path join (gen.ts) and the runtime's resolveAppPath so both agree on
|
|
15
|
+
* whether a manifest path is app-root-relative or absolute (no gen/runtime drift). */
|
|
16
|
+
export function isAbsolutePath(p) {
|
|
17
|
+
return /^(\/|\\|[A-Za-z]:[/\\])/.test(p);
|
|
18
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { DerivedArtifact, Deriver } from "../artifact.ts";
|
|
2
|
+
import { type OpenApiDoc, type OpenApiSchema } from "../../openapi/spec.ts";
|
|
3
|
+
/** Filenames of the emitted artifacts (siblings of worker-io.d.ts in nano-generated/). */
|
|
4
|
+
export declare const API_BINDINGS_DTS = "api-io.d.ts";
|
|
5
|
+
export declare const API_BINDINGS_TS = "operations.ts";
|
|
6
|
+
/** Render a JSON-Schema (OpenAPI subset) as a TS type expression. Local component `$ref`s render as
|
|
7
|
+
* their component name (emitted as named interfaces); unknown shapes fall back to `unknown`. */
|
|
8
|
+
export declare function schemaToTs(schema: OpenApiSchema | undefined, depth?: number): string;
|
|
9
|
+
/** Emit `api-io.d.ts`: named component types + per-operation Request/Response + the id union. */
|
|
10
|
+
export declare function emitApiBindings(doc: OpenApiDoc): string;
|
|
11
|
+
/** Emit `operations.ts`: the typed `defineOperation` wrapper, keyed by operationId (mirrors the
|
|
12
|
+
* generated typed `defineWorker`). Written verbatim (it never changes with the spec) — it only
|
|
13
|
+
* re-types the runtime's `defineOperation`. */
|
|
14
|
+
export declare function emitApiBindingsRuntime(): string;
|
|
15
|
+
/** Derive the api artifacts from a parsed OpenAPI document. */
|
|
16
|
+
export declare function deriveApi(doc: OpenApiDoc): DerivedArtifact[];
|
|
17
|
+
export declare const apiDeriver: Deriver<{
|
|
18
|
+
doc: OpenApiDoc;
|
|
19
|
+
}>;
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// Deriver: an OpenAPI document → `api-io.d.ts` (the endpoint type map) + `operations.ts` (the typed
|
|
2
|
+
// `defineOperation` wrapper). ADR 0058: the contract-first endpoint surface. This is the authoring
|
|
3
|
+
// half — per `operationId` it emits a `Request`/`Response` type from the operation's parameters,
|
|
4
|
+
// requestBody, and first success response, plus an `ApiOperationId` union — mirroring how
|
|
5
|
+
// `worker-io.d.ts` types `defineWorker` from the model (ADR 0033 §3). The runtime
|
|
6
|
+
// (`runtime/core/modules/api.ts`) is the matching half: it validates + routes from the SAME spec via
|
|
7
|
+
// the shared `openapi/spec.ts`, so types and runtime never drift.
|
|
8
|
+
import { GENERATED_DIR } from "../artifact.js";
|
|
9
|
+
import { collectOperations, isObjectSchema, refName, } from "../../openapi/spec.js";
|
|
10
|
+
/** Filenames of the emitted artifacts (siblings of worker-io.d.ts in nano-generated/). */
|
|
11
|
+
export const API_BINDINGS_DTS = "api-io.d.ts";
|
|
12
|
+
export const API_BINDINGS_TS = "operations.ts";
|
|
13
|
+
/** A TS identifier form of an operationId (used for the per-op type names). */
|
|
14
|
+
function typeStem(operationId) {
|
|
15
|
+
const cleaned = operationId.replace(/[^A-Za-z0-9]+/g, " ").trim();
|
|
16
|
+
const pascal = cleaned
|
|
17
|
+
.split(/\s+/)
|
|
18
|
+
.map((w) => (w.length > 0 ? w[0].toUpperCase() + w.slice(1) : ""))
|
|
19
|
+
.join("");
|
|
20
|
+
return /^[A-Za-z_]/.test(pascal) ? pascal : `Op${pascal}`;
|
|
21
|
+
}
|
|
22
|
+
/** Render a JSON-Schema (OpenAPI subset) as a TS type expression. Local component `$ref`s render as
|
|
23
|
+
* their component name (emitted as named interfaces); unknown shapes fall back to `unknown`. */
|
|
24
|
+
export function schemaToTs(schema, depth = 0) {
|
|
25
|
+
if (!schema || depth > 20)
|
|
26
|
+
return "unknown";
|
|
27
|
+
if (schema.$ref) {
|
|
28
|
+
const name = refName(schema.$ref);
|
|
29
|
+
return name ? typeStem(name) : "unknown";
|
|
30
|
+
}
|
|
31
|
+
const nullable = schema.nullable === true;
|
|
32
|
+
const wrap = (t) => (nullable ? `${t} | null` : t);
|
|
33
|
+
if (Array.isArray(schema.enum) && schema.enum.length > 0) {
|
|
34
|
+
return wrap(schema.enum.map((v) => JSON.stringify(v)).join(" | "));
|
|
35
|
+
}
|
|
36
|
+
if (schema.const !== undefined)
|
|
37
|
+
return wrap(JSON.stringify(schema.const));
|
|
38
|
+
if (Array.isArray(schema.oneOf) && schema.oneOf.length > 0) {
|
|
39
|
+
return wrap(schema.oneOf.map((s) => schemaToTs(s, depth + 1)).join(" | "));
|
|
40
|
+
}
|
|
41
|
+
if (Array.isArray(schema.anyOf) && schema.anyOf.length > 0) {
|
|
42
|
+
return wrap(schema.anyOf.map((s) => schemaToTs(s, depth + 1)).join(" | "));
|
|
43
|
+
}
|
|
44
|
+
if (Array.isArray(schema.allOf) && schema.allOf.length > 0) {
|
|
45
|
+
return wrap(schema.allOf.map((s) => schemaToTs(s, depth + 1)).join(" & "));
|
|
46
|
+
}
|
|
47
|
+
switch (schema.type) {
|
|
48
|
+
case "string":
|
|
49
|
+
return wrap("string");
|
|
50
|
+
case "integer":
|
|
51
|
+
case "number":
|
|
52
|
+
return wrap("number");
|
|
53
|
+
case "boolean":
|
|
54
|
+
return wrap("boolean");
|
|
55
|
+
case "null":
|
|
56
|
+
return "null";
|
|
57
|
+
case "array":
|
|
58
|
+
return wrap(`Array<${schemaToTs(schema.items, depth + 1)}>`);
|
|
59
|
+
case "object":
|
|
60
|
+
return wrap(objectToTs(schema, depth));
|
|
61
|
+
default:
|
|
62
|
+
// No `type`: infer object-ness from object-only keywords (shared with the runtime
|
|
63
|
+
// validator via isObjectSchema so types and validation agree); otherwise unknown.
|
|
64
|
+
if (isObjectSchema(schema))
|
|
65
|
+
return wrap(objectToTs(schema, depth));
|
|
66
|
+
return "unknown";
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function objectToTs(schema, depth) {
|
|
70
|
+
const props = schema.properties ?? {};
|
|
71
|
+
const required = new Set(schema.required ?? []);
|
|
72
|
+
const entries = Object.entries(props).map(([key, ps]) => {
|
|
73
|
+
const opt = required.has(key) ? "" : "?";
|
|
74
|
+
const doc = typeof ps.description === "string" ? ps.description.replace(/\*\//g, "*\\/") : undefined;
|
|
75
|
+
const jsdoc = doc ? `/** ${doc} */ ` : "";
|
|
76
|
+
return `${jsdoc}${JSON.stringify(key)}${opt}: ${schemaToTs(ps, depth + 1)}`;
|
|
77
|
+
});
|
|
78
|
+
const extra = schema.additionalProperties;
|
|
79
|
+
if (extra && typeof extra === "object") {
|
|
80
|
+
// TS requires every named property type to be assignable to the index signature. When the
|
|
81
|
+
// schema also declares properties, a narrow index type (e.g. `number`) would make the emitted
|
|
82
|
+
// type invalid (TS2411), so fall back to `unknown`; with no declared properties we can use the
|
|
83
|
+
// precise additionalProperties type (the common "map" case).
|
|
84
|
+
const hasProps = Object.keys(props).length > 0;
|
|
85
|
+
entries.push(`[key: string]: ${hasProps ? "unknown" : schemaToTs(extra, depth + 1)}`);
|
|
86
|
+
}
|
|
87
|
+
else if (extra !== false) {
|
|
88
|
+
// OpenAPI/JSON-Schema default: additional properties are allowed. Emit an index
|
|
89
|
+
// signature so the emitted type isn't stricter than the runtime validator, which only
|
|
90
|
+
// rejects extras when additionalProperties === false (otherwise excess-property errors
|
|
91
|
+
// on object literals would reject bodies the runtime accepts).
|
|
92
|
+
entries.push("[key: string]: unknown");
|
|
93
|
+
}
|
|
94
|
+
if (entries.length > 0)
|
|
95
|
+
return `{ ${entries.join("; ")} }`;
|
|
96
|
+
// No declared properties and additionalProperties: false → a closed, empty object.
|
|
97
|
+
return "Record<string, never>";
|
|
98
|
+
}
|
|
99
|
+
/** The `params`/`query` object type for an operation, from its parameters (strings on the wire). */
|
|
100
|
+
function paramsTs(op, where) {
|
|
101
|
+
const ps = op.parameters.filter((p) => p.in === where);
|
|
102
|
+
if (ps.length === 0)
|
|
103
|
+
return "{}";
|
|
104
|
+
const fields = ps.map((p) => {
|
|
105
|
+
const opt = p.required ? "" : "?";
|
|
106
|
+
// Query values arrive as strings (or string[] for repeated keys); path params are single
|
|
107
|
+
// strings. Optionality is carried by the `?` modifier — don't fold `undefined` into the
|
|
108
|
+
// value type, or required query params would still accept undefined.
|
|
109
|
+
const t = where === "query" ? "string | string[]" : "string";
|
|
110
|
+
return `${JSON.stringify(p.name)}${opt}: ${t}`;
|
|
111
|
+
});
|
|
112
|
+
return `{ ${fields.join("; ")} }`;
|
|
113
|
+
}
|
|
114
|
+
/** Emit `api-io.d.ts`: named component types + per-operation Request/Response + the id union. */
|
|
115
|
+
export function emitApiBindings(doc) {
|
|
116
|
+
const ops = collectOperations(doc);
|
|
117
|
+
const components = doc.components?.schemas ?? {};
|
|
118
|
+
// Fail-closed on TypeScript name collisions: typeStem is lossy (non-alphanumerics collapse), so
|
|
119
|
+
// distinct component/operation names ("get-invoice" vs "get_invoice") can map to the same TS
|
|
120
|
+
// identifier and emit duplicate declarations that break typechecking. Surface it here (where
|
|
121
|
+
// `urban gen`/`urban check` reports it) rather than shipping broken generated types.
|
|
122
|
+
const emittedTypeNames = new Map();
|
|
123
|
+
const claimTypeName = (typeName, source) => {
|
|
124
|
+
const prior = emittedTypeNames.get(typeName);
|
|
125
|
+
if (prior !== undefined) {
|
|
126
|
+
throw new Error(`OpenAPI spec produces a duplicate TypeScript type "${typeName}" from ${prior} and ${source} ` +
|
|
127
|
+
`— their names collapse to the same identifier; rename one to disambiguate (ADR 0058).`);
|
|
128
|
+
}
|
|
129
|
+
emittedTypeNames.set(typeName, source);
|
|
130
|
+
};
|
|
131
|
+
for (const name of Object.keys(components)) {
|
|
132
|
+
claimTypeName(typeStem(name), `component schema "${name}"`);
|
|
133
|
+
}
|
|
134
|
+
for (const op of ops) {
|
|
135
|
+
claimTypeName(`${typeStem(op.operationId)}Request`, `operationId "${op.operationId}"`);
|
|
136
|
+
claimTypeName(`${typeStem(op.operationId)}Response`, `operationId "${op.operationId}"`);
|
|
137
|
+
}
|
|
138
|
+
const header = "// AUTO-GENERATED by @nanobpm/urban from the app's OpenAPI spec (ADR 0058).\n" +
|
|
139
|
+
"// The endpoint type map: each operationId maps to its typed request (path/query params +\n" +
|
|
140
|
+
"// JSON body) and success response, derived from the spec. Do not edit — regenerated from\n" +
|
|
141
|
+
"// the spec; erased to plain JS at compile.\n" +
|
|
142
|
+
"// eslint-disable\n\n";
|
|
143
|
+
const componentDecls = Object.entries(components)
|
|
144
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
145
|
+
.map(([name, schema]) => `export type ${typeStem(name)} = ${schemaToTs(schema)};`)
|
|
146
|
+
.join("\n");
|
|
147
|
+
const opDecls = ops
|
|
148
|
+
.map((op) => {
|
|
149
|
+
const stem = typeStem(op.operationId);
|
|
150
|
+
// With a schema → the typed body. Required but no JSON schema → `unknown` (a body IS
|
|
151
|
+
// required at runtime, just of an unknown shape). Otherwise (optional, absent) → `undefined`.
|
|
152
|
+
const bodyType = op.requestBodySchema
|
|
153
|
+
? schemaToTs(op.requestBodySchema)
|
|
154
|
+
: op.requestBodyRequired
|
|
155
|
+
? "unknown"
|
|
156
|
+
: "undefined";
|
|
157
|
+
// An optional (or absent) request body is passed to the delegate as `undefined` at runtime,
|
|
158
|
+
// so mark it optional in the type rather than forcing handlers to treat it as always-present.
|
|
159
|
+
const bodyOpt = op.requestBodyRequired ? "" : "?";
|
|
160
|
+
const resp = op.responseSchema ? schemaToTs(op.responseSchema) : "unknown";
|
|
161
|
+
const summary = op.summary ? `/** ${op.summary.replace(/\*\//g, "*\\/")} */\n` : "";
|
|
162
|
+
return (`${summary}export interface ${stem}Request {\n` +
|
|
163
|
+
` params: ${paramsTs(op, "path")};\n` +
|
|
164
|
+
` query: ${paramsTs(op, "query")};\n` +
|
|
165
|
+
` body${bodyOpt}: ${bodyType};\n` +
|
|
166
|
+
`}\n` +
|
|
167
|
+
`export type ${stem}Response = ${resp};`);
|
|
168
|
+
})
|
|
169
|
+
.join("\n\n");
|
|
170
|
+
const idUnion = ops.length > 0
|
|
171
|
+
? ops.map((o) => JSON.stringify(o.operationId)).join(" | ")
|
|
172
|
+
: "never";
|
|
173
|
+
const mapEntries = ops
|
|
174
|
+
.map((op) => {
|
|
175
|
+
const stem = typeStem(op.operationId);
|
|
176
|
+
return ` ${JSON.stringify(op.operationId)}: { request: ${stem}Request; response: ${stem}Response };`;
|
|
177
|
+
})
|
|
178
|
+
.join("\n");
|
|
179
|
+
return (header +
|
|
180
|
+
(componentDecls ? `${componentDecls}\n\n` : "") +
|
|
181
|
+
(opDecls ? `${opDecls}\n\n` : "") +
|
|
182
|
+
`/** Every declared operationId (ADR 0058): the delegate key set. */\n` +
|
|
183
|
+
`export type ApiOperationId = ${idUnion};\n\n` +
|
|
184
|
+
`/** Request/response contract per operationId. */\n` +
|
|
185
|
+
`export interface ApiOperations {\n${mapEntries}\n}\n`);
|
|
186
|
+
}
|
|
187
|
+
/** Emit `operations.ts`: the typed `defineOperation` wrapper, keyed by operationId (mirrors the
|
|
188
|
+
* generated typed `defineWorker`). Written verbatim (it never changes with the spec) — it only
|
|
189
|
+
* re-types the runtime's `defineOperation`. */
|
|
190
|
+
export function emitApiBindingsRuntime() {
|
|
191
|
+
return ("// AUTO-GENERATED by @nanobpm/urban (ADR 0058): the typed `defineOperation`.\n" +
|
|
192
|
+
"// Re-exports the operation SDK and overrides `defineOperation` with an operationId-keyed\n" +
|
|
193
|
+
"// signature (the delegate's validated request + result typed from the spec). Erased to a\n" +
|
|
194
|
+
"// pass-through at runtime. Do not edit.\n" +
|
|
195
|
+
"// eslint-disable\n\n" +
|
|
196
|
+
`import { defineOperation as defineOperationRaw } from "@nanobpm/urban";\n` +
|
|
197
|
+
`import type { OperationHandler } from "@nanobpm/urban";\n` +
|
|
198
|
+
`import type { ApiOperationId, ApiOperations } from "./${API_BINDINGS_DTS}";\n\n` +
|
|
199
|
+
`type ReqFor<K extends ApiOperationId> = ApiOperations[K]["request"];\n` +
|
|
200
|
+
`type ResFor<K extends ApiOperationId> = ApiOperations[K]["response"];\n\n` +
|
|
201
|
+
`/**\n` +
|
|
202
|
+
` * Typed \`defineOperation\`: \`id\` is constrained to the spec's declared operationIds\n` +
|
|
203
|
+
` * (ADR 0058) so it autocompletes and rejects unknown ops, and the handler's request\n` +
|
|
204
|
+
` * (validated params/query/body) + result are typed from the operation's schemas.\n` +
|
|
205
|
+
` */\n` +
|
|
206
|
+
`export function defineOperation<K extends ApiOperationId>(\n` +
|
|
207
|
+
` id: K,\n` +
|
|
208
|
+
` handler: OperationHandler<ReqFor<K>, ResFor<K>>,\n` +
|
|
209
|
+
`): OperationHandler<ReqFor<K>, ResFor<K>> {\n` +
|
|
210
|
+
` return defineOperationRaw(id, handler);\n` +
|
|
211
|
+
`}\n`);
|
|
212
|
+
}
|
|
213
|
+
/** Derive the api artifacts from a parsed OpenAPI document. */
|
|
214
|
+
export function deriveApi(doc) {
|
|
215
|
+
return [
|
|
216
|
+
{ path: `${GENERATED_DIR}/${API_BINDINGS_DTS}`, content: emitApiBindings(doc) },
|
|
217
|
+
{ path: `${GENERATED_DIR}/${API_BINDINGS_TS}`, content: emitApiBindingsRuntime() },
|
|
218
|
+
];
|
|
219
|
+
}
|
|
220
|
+
export const apiDeriver = {
|
|
221
|
+
id: "openapi->api-io",
|
|
222
|
+
describe: "Derive api-io.d.ts (typed request/response per operationId) + the typed defineOperation.",
|
|
223
|
+
derive: ({ doc }) => deriveApi(doc),
|
|
224
|
+
};
|
package/dist/toolkit/gen.js
CHANGED
|
@@ -2,17 +2,28 @@
|
|
|
2
2
|
// models, runs the pure derivers, and either writes the artifacts to disk (`urban gen`) or
|
|
3
3
|
// compares them against what's on disk and reports drift (`urban gen --check`). The derivers stay
|
|
4
4
|
// pure; all IO is confined here behind a tiny FS port so the same code runs on Node and Deno.
|
|
5
|
-
import { sortArtifacts } from "./artifact.js";
|
|
5
|
+
import { isAbsolutePath, sortArtifacts } from "./artifact.js";
|
|
6
6
|
import { deriveMigrations } from "./derivers/migrations.js";
|
|
7
7
|
import { deriveDomain } from "./derivers/domain.js";
|
|
8
8
|
import { deriveWorkerBindings } from "./derivers/worker-io.js";
|
|
9
9
|
import { deriveMeta } from "./derivers/meta.js";
|
|
10
10
|
import { deriveMessageBindings } from "./derivers/messages.js";
|
|
11
|
+
import { deriveApi } from "./derivers/api.js";
|
|
12
|
+
import { parseSpec } from "../openapi/spec.js";
|
|
11
13
|
import { deriveModels, MODEL_PROVENANCE } from "./models.js";
|
|
12
14
|
function join(root, rel) {
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
|
|
15
|
+
// Normalize Windows-style separators to forward slashes (GenIO uses forward slashes on all
|
|
16
|
+
// platforms — matching the runtime's resolveAppPath) and trim edge separators, so callers may
|
|
17
|
+
// pass either style without gen/runtime drift over where a file resolves.
|
|
18
|
+
const norm = (s) => s.replace(/\\/g, "/");
|
|
19
|
+
// An absolute `rel` resolves to itself — never prefixed with `root` — mirroring the runtime's
|
|
20
|
+
// resolveAppPath (isAbsolutePath is the shared SoT in artifact.ts). Without this an absolute
|
|
21
|
+
// manifest path (e.g. "/abs/openapi.json") would be stripped to root-relative and gen would
|
|
22
|
+
// read/derive a different file than the runtime resolves. Trailing edge separators are still
|
|
23
|
+
// trimmed for stable, comparable artifact keys.
|
|
24
|
+
if (isAbsolutePath(rel))
|
|
25
|
+
return norm(rel).replace(/\/+$/, "");
|
|
26
|
+
return `${norm(root).replace(/\/+$/, "")}/${norm(rel).replace(/^\/+/, "")}`;
|
|
16
27
|
}
|
|
17
28
|
function dirOf(p) {
|
|
18
29
|
const i = p.lastIndexOf("/");
|
|
@@ -81,6 +92,14 @@ async function collectAll(opts) {
|
|
|
81
92
|
artifacts.push(...deriveMessageBindings(models, declaredTypeIds));
|
|
82
93
|
}
|
|
83
94
|
}
|
|
95
|
+
// 4. OpenAPI `api` binding → typed endpoint contracts (ADR 0058). Fail-closed: a declared spec
|
|
96
|
+
// that is missing or malformed throws here so `urban gen`/`urban check` surfaces it. Trim the
|
|
97
|
+
// spec path so benign whitespace matches the runtime's readApiBinding (no gen/runtime drift).
|
|
98
|
+
const specRef = typeof manifest.api?.spec === "string" ? manifest.api.spec.trim() : "";
|
|
99
|
+
if (!opts.modelsOnly && specRef.length > 0) {
|
|
100
|
+
const specText = await io.readText(join(root, specRef));
|
|
101
|
+
artifacts.push(...deriveApi(parseSpec(specText)));
|
|
102
|
+
}
|
|
84
103
|
return { artifacts: sortArtifacts(artifacts), derived };
|
|
85
104
|
}
|
|
86
105
|
/** Run the derivers and write artifacts (or, with `check`, report drift without writing). */
|
package/dist/toolkit/index.d.ts
CHANGED
|
@@ -13,6 +13,9 @@ export { deriveMeta, emitMeta, foldMeta, metaDeriver, scanModelMeta, META_TS, }
|
|
|
13
13
|
export type { MetaDecl } from "./derivers/meta.ts";
|
|
14
14
|
export { deriveMessageBindings, emitMessageBindings, emitMessageBindingsRuntime, messagesDeriver, scanModelMessages, MESSAGE_BINDINGS_DTS, MESSAGE_BINDINGS_TS, } from "./derivers/messages.ts";
|
|
15
15
|
export type { MessageIo, MessageBindingDecl } from "./derivers/messages.ts";
|
|
16
|
+
export { apiDeriver, deriveApi, emitApiBindings, emitApiBindingsRuntime, schemaToTs, API_BINDINGS_DTS, API_BINDINGS_TS, } from "./derivers/api.ts";
|
|
17
|
+
export { collectOperations, HTTP_METHODS, isSafeOperationId, operationsWithoutId, operationsWithUnsafeId, parseSpec, refName, resolveSchema, toRouteMatcher, undeclaredPathParams, validateValue, } from "../openapi/spec.ts";
|
|
18
|
+
export type { HttpMethodLower, OpenApiDoc, OpenApiParameter, OpenApiSchema, OperationInfo, } from "../openapi/spec.ts";
|
|
16
19
|
export declare const DERIVERS: readonly [import("./artifact.ts").Deriver<import("./derivers/migrations.ts").ToolkitManifest>, import("./artifact.ts").Deriver<import("./derivers/migrations.ts").ToolkitManifest>, import("./artifact.ts").Deriver<{
|
|
17
20
|
models: import("./derivers/worker-io.ts").ModelSource[];
|
|
18
21
|
declaredTypeIds: Iterable<string>;
|
|
@@ -21,6 +24,8 @@ export declare const DERIVERS: readonly [import("./artifact.ts").Deriver<import(
|
|
|
21
24
|
}>, import("./artifact.ts").Deriver<{
|
|
22
25
|
models: import("./derivers/worker-io.ts").ModelSource[];
|
|
23
26
|
declaredTypeIds: Iterable<string>;
|
|
27
|
+
}>, import("./artifact.ts").Deriver<{
|
|
28
|
+
doc: import("../openapi/spec.ts").OpenApiDoc;
|
|
24
29
|
}>];
|
|
25
30
|
export { collectArtifacts, runGen, joinPath, readModels, expandPattern, previewModels } from "./gen.ts";
|
|
26
31
|
export type { GenIO, GenOptions, GenResult } from "./gen.ts";
|
package/dist/toolkit/index.js
CHANGED
|
@@ -11,13 +11,24 @@ export { deriveWorkerBindings, emitWorkerBindings, emitWorkerBindingsRuntime, ov
|
|
|
11
11
|
export { byModelPath, typeRefFor } from "./derivers/worker-io.js";
|
|
12
12
|
export { deriveMeta, emitMeta, foldMeta, metaDeriver, scanModelMeta, META_TS, } from "./derivers/meta.js";
|
|
13
13
|
export { deriveMessageBindings, emitMessageBindings, emitMessageBindingsRuntime, messagesDeriver, scanModelMessages, MESSAGE_BINDINGS_DTS, MESSAGE_BINDINGS_TS, } from "./derivers/messages.js";
|
|
14
|
+
export { apiDeriver, deriveApi, emitApiBindings, emitApiBindingsRuntime, schemaToTs, API_BINDINGS_DTS, API_BINDINGS_TS, } from "./derivers/api.js";
|
|
15
|
+
// The OpenAPI machinery the deriver + runtime share (ADR 0058).
|
|
16
|
+
export { collectOperations, HTTP_METHODS, isSafeOperationId, operationsWithoutId, operationsWithUnsafeId, parseSpec, refName, resolveSchema, toRouteMatcher, undeclaredPathParams, validateValue, } from "../openapi/spec.js";
|
|
14
17
|
// The registry of all derivers (for discovery / IDE migration).
|
|
15
18
|
import { migrationsDeriver } from "./derivers/migrations.js";
|
|
16
19
|
import { domainDeriver } from "./derivers/domain.js";
|
|
17
20
|
import { workerIoDeriver } from "./derivers/worker-io.js";
|
|
18
21
|
import { metaDeriver } from "./derivers/meta.js";
|
|
19
22
|
import { messagesDeriver } from "./derivers/messages.js";
|
|
20
|
-
|
|
23
|
+
import { apiDeriver } from "./derivers/api.js";
|
|
24
|
+
export const DERIVERS = [
|
|
25
|
+
migrationsDeriver,
|
|
26
|
+
domainDeriver,
|
|
27
|
+
workerIoDeriver,
|
|
28
|
+
metaDeriver,
|
|
29
|
+
messagesDeriver,
|
|
30
|
+
apiDeriver,
|
|
31
|
+
];
|
|
21
32
|
// Gen orchestrator + IO
|
|
22
33
|
export { collectArtifacts, runGen, joinPath, readModels, expandPattern, previewModels } from "./gen.js";
|
|
23
34
|
export { createNodeGenIO } from "./fsio.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/urban",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.0",
|
|
4
4
|
"description": "Urban: build and run code-first apps on Nano — runtime, derivation toolkit, and CLI in one. Author durable processes with @nanobpm/workflow (defineFlow), a typed datasource, forms, triggers and surfaces from a nano.app.json manifest, on Node or Deno.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|