@nanobpm/urban 0.36.0 → 0.38.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 +18 -2
- package/dist/openapi/spec.js +35 -12
- package/dist/runtime/core/modules/api.d.ts +10 -6
- package/dist/runtime/core/modules/api.js +62 -17
- package/dist/toolkit/derivers/api.d.ts +9 -4
- package/dist/toolkit/derivers/api.js +60 -24
- package/dist/toolkit/gen.js +1 -1
- package/dist/toolkit/index.d.ts +1 -0
- package/dist/toolkit/scaffold/workers.d.ts +7 -5
- package/dist/toolkit/scaffold/workers.js +11 -4
- package/dist/toolkit/scaffold.js +1 -1
- package/package.json +2 -2
- package/src/openapi/spec.test.ts +44 -0
- package/src/openapi/spec.ts +49 -12
- package/src/runtime/core/modules/api.test.ts +151 -1
- package/src/runtime/core/modules/api.ts +66 -22
- package/src/toolkit/derivers/api.test.ts +160 -3
- package/src/toolkit/derivers/api.ts +65 -25
- package/src/toolkit/gen.ts +2 -2
- package/src/toolkit/scaffold/workers.test.ts +35 -0
- package/src/toolkit/scaffold/workers.ts +12 -4
- package/src/toolkit/scaffold.ts +7 -1
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Deriver: an OpenAPI document → `api-io.d.ts` (the endpoint type map) + `operations.ts` (the typed
|
|
2
2
|
// `defineOperation` wrapper). ADR 0058: the contract-first endpoint surface. This is the authoring
|
|
3
3
|
// half — per `operationId` it emits a `Request`/`Response` type from the operation's parameters,
|
|
4
|
-
// requestBody, and
|
|
4
|
+
// requestBody, and response (a union of every documented JSON response body), plus an
|
|
5
|
+
// `ApiOperationId` union — mirroring how
|
|
5
6
|
// `worker-io.d.ts` types `defineWorker` from the model (ADR 0033 §3). The runtime
|
|
6
7
|
// (`runtime/core/modules/api.ts`) is the matching half: it validates + routes from the SAME spec via
|
|
7
8
|
// the shared `openapi/spec.ts`, so types and runtime never drift.
|
|
@@ -117,23 +118,34 @@ function objectToTs(schema: OpenApiSchema, depth: number): string {
|
|
|
117
118
|
return "Record<string, never>";
|
|
118
119
|
}
|
|
119
120
|
|
|
120
|
-
/** The `params`/`query` object type for an operation, from its parameters
|
|
121
|
-
|
|
121
|
+
/** The `params`/`query` object type for an operation, from its parameters. Values are coerced to
|
|
122
|
+
* their declared schema type before the delegate receives them (the runtime coerces + validates,
|
|
123
|
+
* then forwards the coerced value), so a param's type is its schema type — not the raw wire string.
|
|
124
|
+
* A parameter with no schema, or ANY parameter of an ejected operation (whose runtime skips
|
|
125
|
+
* coercion/validation and hands the delegate the raw request), keeps the raw wire type (`string`,
|
|
126
|
+
* or `string | string[]` for query). Ejected query params are also made optional: the runtime does
|
|
127
|
+
* not enforce presence for ejected ops, so a "required" query param may in fact be absent. */
|
|
128
|
+
function paramsTs(op: OperationInfo, where: "path" | "query", ejected: boolean): string {
|
|
122
129
|
const ps = op.parameters.filter((p) => p.in === where);
|
|
123
130
|
if (ps.length === 0) return "{}";
|
|
131
|
+
const rawWire = where === "query" ? "string | string[]" : "string";
|
|
124
132
|
const fields = ps.map((p) => {
|
|
125
|
-
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
|
|
133
|
+
// Coerced to the schema type (number/boolean/array/enum/…); optionality is carried by the `?`
|
|
134
|
+
// modifier — don't fold `undefined` into the value type, or required params would still accept
|
|
135
|
+
// undefined. Schemaless params, and every param of an ejected op, fall back to the raw wire type.
|
|
136
|
+
// A required query param of an ejected op is nonetheless optional here (runtime skips presence);
|
|
137
|
+
// path params stay as declared (the route only matches when every path segment is present).
|
|
138
|
+
const opt = ejected && where === "query" ? "?" : p.required ? "" : "?";
|
|
139
|
+
const t = ejected || !p.schema ? rawWire : schemaToTs(p.schema);
|
|
130
140
|
return `${JSON.stringify(p.name)}${opt}: ${t}`;
|
|
131
141
|
});
|
|
132
142
|
return `{ ${fields.join("; ")} }`;
|
|
133
143
|
}
|
|
134
144
|
|
|
135
|
-
/** Emit `api-io.d.ts`: named component types + per-operation Request/Response + the id union.
|
|
136
|
-
|
|
145
|
+
/** Emit `api-io.d.ts`: named component types + per-operation Request/Response + the id union.
|
|
146
|
+
* `surfaceEject` reflects the manifest's whole-surface `api.eject` (which the deriver otherwise
|
|
147
|
+
* can't see): when set, EVERY operation is treated as ejected (raw params/query, `unknown` body). */
|
|
148
|
+
export function emitApiBindings(doc: OpenApiDoc, surfaceEject = false): string {
|
|
137
149
|
const ops = collectOperations(doc);
|
|
138
150
|
const components = doc.components?.schemas ?? {};
|
|
139
151
|
|
|
@@ -152,6 +164,11 @@ export function emitApiBindings(doc: OpenApiDoc): string {
|
|
|
152
164
|
}
|
|
153
165
|
emittedTypeNames.set(typeName, source);
|
|
154
166
|
};
|
|
167
|
+
// Reserve the fixed generated declarations (the operation-id union + operations map) up front, so
|
|
168
|
+
// a component or operationId whose stem collapses onto one of them fails the drift gate with a
|
|
169
|
+
// clear rename message instead of silently emitting a duplicate, uncompilable declaration.
|
|
170
|
+
claimTypeName("ApiOperationId", "the generated operation-id union");
|
|
171
|
+
claimTypeName("ApiOperations", "the generated operations map");
|
|
155
172
|
for (const name of Object.keys(components)) {
|
|
156
173
|
claimTypeName(typeStem(name), `component schema "${name}"`);
|
|
157
174
|
}
|
|
@@ -162,8 +179,9 @@ export function emitApiBindings(doc: OpenApiDoc): string {
|
|
|
162
179
|
|
|
163
180
|
const header =
|
|
164
181
|
"// AUTO-GENERATED by @nanobpm/urban from the app's OpenAPI spec (ADR 0058).\n" +
|
|
165
|
-
"// The endpoint type map: each operationId maps to its typed request (path/query params
|
|
166
|
-
"// JSON body) and
|
|
182
|
+
"// The endpoint type map: each operationId maps to its typed request (coerced path/query params\n" +
|
|
183
|
+
"// + JSON body) and response (a union of every documented JSON response body), derived from the\n" +
|
|
184
|
+
"// spec. Do not edit — regenerated from\n" +
|
|
167
185
|
"// the spec; erased to plain JS at compile.\n" +
|
|
168
186
|
"// eslint-disable\n\n";
|
|
169
187
|
|
|
@@ -175,22 +193,38 @@ export function emitApiBindings(doc: OpenApiDoc): string {
|
|
|
175
193
|
const opDecls = ops
|
|
176
194
|
.map((op) => {
|
|
177
195
|
const stem = typeStem(op.operationId);
|
|
196
|
+
const ejected = surfaceEject || op.eject;
|
|
197
|
+
// An ejected operation (`x-urban-eject`, or the whole surface via manifest `api.eject`)
|
|
198
|
+
// bypasses the generated coercion/validation — its delegate reads the raw request — so its
|
|
199
|
+
// input is NOT schema-shaped at runtime: params/query stay raw wire types and the body is
|
|
200
|
+
// unvalidated (`unknown`). Typing it by the schema would claim guarantees the runtime doesn't
|
|
201
|
+
// make. (ADR 0059 documents the ejection exception.)
|
|
178
202
|
// With a schema → the typed body. Required but no JSON schema → `unknown` (a body IS
|
|
179
203
|
// required at runtime, just of an unknown shape). Otherwise (optional, absent) → `undefined`.
|
|
180
|
-
const bodyType =
|
|
181
|
-
?
|
|
182
|
-
: op.
|
|
183
|
-
?
|
|
184
|
-
:
|
|
204
|
+
const bodyType = ejected
|
|
205
|
+
? "unknown"
|
|
206
|
+
: op.requestBodySchema
|
|
207
|
+
? schemaToTs(op.requestBodySchema)
|
|
208
|
+
: op.requestBodyRequired
|
|
209
|
+
? "unknown"
|
|
210
|
+
: "undefined";
|
|
185
211
|
// An optional (or absent) request body is passed to the delegate as `undefined` at runtime,
|
|
186
212
|
// so mark it optional in the type rather than forcing handlers to treat it as always-present.
|
|
187
213
|
const bodyOpt = op.requestBodyRequired ? "" : "?";
|
|
188
|
-
|
|
214
|
+
// Response type: a union of every documented JSON response body (success + errors), so a
|
|
215
|
+
// handler that returns a documented error body typechecks against its operation's response
|
|
216
|
+
// type. Bodyless documented statuses carry no schema and are skipped here. Deduped (identical
|
|
217
|
+
// schemas collapse), stable order. No documented JSON body → unknown.
|
|
218
|
+
const respSeen = new Set<string>();
|
|
219
|
+
const respParts = op.responseSchemas
|
|
220
|
+
.flatMap((e) => (e.schema ? [schemaToTs(e.schema)] : []))
|
|
221
|
+
.filter((t) => (respSeen.has(t) ? false : (respSeen.add(t), true)));
|
|
222
|
+
const resp = respParts.length > 0 ? respParts.join(" | ") : "unknown";
|
|
189
223
|
const summary = op.summary ? `/** ${op.summary.replace(/\*\//g, "*\\/")} */\n` : "";
|
|
190
224
|
return (
|
|
191
225
|
`${summary}export interface ${stem}Request {\n` +
|
|
192
|
-
` params: ${paramsTs(op, "path")};\n` +
|
|
193
|
-
` query: ${paramsTs(op, "query")};\n` +
|
|
226
|
+
` params: ${paramsTs(op, "path", ejected)};\n` +
|
|
227
|
+
` query: ${paramsTs(op, "query", ejected)};\n` +
|
|
194
228
|
` body${bodyOpt}: ${bodyType};\n` +
|
|
195
229
|
`}\n` +
|
|
196
230
|
`export type ${stem}Response = ${resp};`
|
|
@@ -308,18 +342,24 @@ export function emitApiController(doc: OpenApiDoc, operationsDir: string = DEFAU
|
|
|
308
342
|
}
|
|
309
343
|
|
|
310
344
|
/** Derive the api artifacts from a parsed OpenAPI document. `operationsDir` is the manifest's
|
|
311
|
-
* `api.dir` (default `operations/`) — the controller's static delegate imports resolve into it.
|
|
312
|
-
|
|
345
|
+
* `api.dir` (default `operations/`) — the controller's static delegate imports resolve into it.
|
|
346
|
+
* `surfaceEject` is the manifest's whole-surface `api.eject`: when set, every operation is typed as
|
|
347
|
+
* ejected (raw params/query, `unknown` body) so the generated types match the runtime. */
|
|
348
|
+
export function deriveApi(
|
|
349
|
+
doc: OpenApiDoc,
|
|
350
|
+
operationsDir: string = DEFAULT_OPERATIONS_DIR,
|
|
351
|
+
surfaceEject = false,
|
|
352
|
+
): DerivedArtifact[] {
|
|
313
353
|
return [
|
|
314
|
-
{ path: `${GENERATED_DIR}/${API_BINDINGS_DTS}`, content: emitApiBindings(doc) },
|
|
354
|
+
{ path: `${GENERATED_DIR}/${API_BINDINGS_DTS}`, content: emitApiBindings(doc, surfaceEject) },
|
|
315
355
|
{ path: `${GENERATED_DIR}/${API_BINDINGS_TS}`, content: emitApiBindingsRuntime() },
|
|
316
356
|
{ path: `${GENERATED_DIR}/${API_CONTROLLER_TS}`, content: emitApiController(doc, operationsDir) },
|
|
317
357
|
];
|
|
318
358
|
}
|
|
319
359
|
|
|
320
|
-
export const apiDeriver: Deriver<{ doc: OpenApiDoc; operationsDir?: string }> = {
|
|
360
|
+
export const apiDeriver: Deriver<{ doc: OpenApiDoc; operationsDir?: string; surfaceEject?: boolean }> = {
|
|
321
361
|
id: "openapi->api-io",
|
|
322
362
|
describe:
|
|
323
363
|
"Derive api-io.d.ts (typed request/response per operationId) + the typed defineOperation + the delegate registry.",
|
|
324
|
-
derive: ({ doc, operationsDir }) => deriveApi(doc, operationsDir),
|
|
364
|
+
derive: ({ doc, operationsDir, surfaceEject }) => deriveApi(doc, operationsDir, surfaceEject),
|
|
325
365
|
};
|
package/src/toolkit/gen.ts
CHANGED
|
@@ -122,7 +122,7 @@ async function collectAll(opts: GenOptions): Promise<{ artifacts: DerivedArtifac
|
|
|
122
122
|
const manifestPath = join(root, opts.manifestFile ?? "nano.app.json");
|
|
123
123
|
const manifest: ToolkitManifest & {
|
|
124
124
|
models?: { processes?: string[]; workflows?: string[] };
|
|
125
|
-
api?: { spec?: string; dir?: string };
|
|
125
|
+
api?: { spec?: string; dir?: string; eject?: boolean };
|
|
126
126
|
} = JSON.parse(await io.readText(manifestPath));
|
|
127
127
|
|
|
128
128
|
const artifacts: DerivedArtifact[] = [];
|
|
@@ -162,7 +162,7 @@ async function collectAll(opts: GenOptions): Promise<{ artifacts: DerivedArtifac
|
|
|
162
162
|
const opsDir = typeof manifest.api?.dir === "string" && manifest.api.dir.trim().length > 0
|
|
163
163
|
? manifest.api.dir.trim()
|
|
164
164
|
: undefined;
|
|
165
|
-
artifacts.push(...deriveApi(parseSpec(specText), opsDir));
|
|
165
|
+
artifacts.push(...deriveApi(parseSpec(specText), opsDir, manifest.api?.eject === true));
|
|
166
166
|
}
|
|
167
167
|
|
|
168
168
|
return { artifacts: sortArtifacts(artifacts), derived };
|
|
@@ -99,6 +99,16 @@ test("planWorkerScaffold: skips already-wired, orchestrator, and duplicates", ()
|
|
|
99
99
|
assert.equal(reasons["t.new"], "duplicate");
|
|
100
100
|
});
|
|
101
101
|
|
|
102
|
+
test("planWorkerScaffold: skips a task type declared external (out-of-process worker)", () => {
|
|
103
|
+
const models = [
|
|
104
|
+
model("a.bpmn", "p1", serviceTask("A", "senior:review"), serviceTask("B", "t.new")),
|
|
105
|
+
];
|
|
106
|
+
const { plans, skipped } = planWorkerScaffold(models, [], [], ["senior:review"]);
|
|
107
|
+
assert.deepEqual(plans.map((p) => p.taskType), ["t.new"]);
|
|
108
|
+
const reasons = Object.fromEntries(skipped.map((s) => [s.taskType, s.reason]));
|
|
109
|
+
assert.equal(reasons["senior:review"], "external");
|
|
110
|
+
});
|
|
111
|
+
|
|
102
112
|
test("planWorkerScaffold: colliding slugs get a numeric suffix", () => {
|
|
103
113
|
const m = model("a.bpmn", "p", serviceTask("A", "orders.charge"), serviceTask("B", "orders-charge"));
|
|
104
114
|
const { plans } = planWorkerScaffold([m], [], []);
|
|
@@ -195,3 +205,28 @@ test("scaffoldWorkers is idempotent: a second --write run is a no-op", async ()
|
|
|
195
205
|
assert.equal(run2.manifestPatched, false, "no new wiring on the second run");
|
|
196
206
|
assert.equal(JSON.stringify(io.files), snapshot, "files unchanged on re-run");
|
|
197
207
|
});
|
|
208
|
+
|
|
209
|
+
test("scaffoldWorkers reads manifest.externalTaskTypes and skips those (never scaffolds/wires)", async () => {
|
|
210
|
+
const files = {
|
|
211
|
+
"/app/nano.app.json": JSON.stringify({
|
|
212
|
+
id: "demo",
|
|
213
|
+
models: { processes: ["processes/*.bpmn"] },
|
|
214
|
+
externalTaskTypes: ["senior:review"],
|
|
215
|
+
}),
|
|
216
|
+
"/app/processes/p.bpmn": model("p.bpmn", "p",
|
|
217
|
+
serviceTask("A", "senior:review"),
|
|
218
|
+
serviceTask("B", "t.new"),
|
|
219
|
+
).xml,
|
|
220
|
+
};
|
|
221
|
+
const io = memIO(files);
|
|
222
|
+
const run = await scaffoldWorkers({ root: "/app", io, write: true });
|
|
223
|
+
// Only the app-hosted task is scaffolded; the external one is skipped.
|
|
224
|
+
assert.deepEqual(run.outcomes.map((o) => o.taskType), ["t.new"]);
|
|
225
|
+
assert.ok(run.skipped.some((s) => s.taskType === "senior:review" && s.reason === "external"));
|
|
226
|
+
assert.equal(io.files["/app/workers/senior-review/worker.ts"], undefined, "external task gets no stub");
|
|
227
|
+
const manifest = JSON.parse(io.files["/app/nano.app.json"]);
|
|
228
|
+
assert.ok(
|
|
229
|
+
!manifest.workers?.some((w: { taskType: string }) => w.taskType === "senior:review"),
|
|
230
|
+
"external task is never wired into workers[]",
|
|
231
|
+
);
|
|
232
|
+
});
|
|
@@ -39,7 +39,7 @@ export interface WorkerStubPlan {
|
|
|
39
39
|
manifestEntry: StubManifestEntry;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
export type SkipReason = "already-wired" | "orchestrator" | "duplicate";
|
|
42
|
+
export type SkipReason = "already-wired" | "orchestrator" | "duplicate" | "external";
|
|
43
43
|
|
|
44
44
|
export interface SkippedWorker {
|
|
45
45
|
taskType: string;
|
|
@@ -110,14 +110,17 @@ export function renderWorkerStub(
|
|
|
110
110
|
|
|
111
111
|
/**
|
|
112
112
|
* Plan write-once handler stubs from the models. A stub is planned for each service-task
|
|
113
|
-
* `taskType` that is NOT already wired in the manifest, NOT the imperative orchestrator,
|
|
114
|
-
*
|
|
115
|
-
*
|
|
113
|
+
* `taskType` that is NOT already wired in the manifest, NOT the imperative orchestrator, NOT
|
|
114
|
+
* declared external (serviced by an out-of-process worker), and not a duplicate of one already
|
|
115
|
+
* planned. `declaredTypeIds` are the manifest `types` ids — an envelope `in`/`out` is typed only
|
|
116
|
+
* when it names one (matching the worker-io deriver). `externalTaskTypes` are the manifest's
|
|
117
|
+
* declared external task types, which the app owns the model for but deliberately does not host.
|
|
116
118
|
*/
|
|
117
119
|
export function planWorkerScaffold(
|
|
118
120
|
models: ModelSource[],
|
|
119
121
|
workers: ScaffoldWorker[] = [],
|
|
120
122
|
declaredTypeIds: Iterable<string> = [],
|
|
123
|
+
externalTaskTypes: Iterable<string> = [],
|
|
121
124
|
): WorkerScaffoldPlan {
|
|
122
125
|
const wired = new Set(
|
|
123
126
|
workers
|
|
@@ -125,6 +128,7 @@ export function planWorkerScaffold(
|
|
|
125
128
|
.filter((t): t is string => typeof t === "string" && t.length > 0),
|
|
126
129
|
);
|
|
127
130
|
const declared = new Set(declaredTypeIds);
|
|
131
|
+
const external = new Set(externalTaskTypes);
|
|
128
132
|
|
|
129
133
|
const plans: WorkerStubPlan[] = [];
|
|
130
134
|
const skipped: SkippedWorker[] = [];
|
|
@@ -138,6 +142,10 @@ export function planWorkerScaffold(
|
|
|
138
142
|
skipped.push({ taskType, reason: "orchestrator" });
|
|
139
143
|
continue;
|
|
140
144
|
}
|
|
145
|
+
if (external.has(taskType)) {
|
|
146
|
+
skipped.push({ taskType, reason: "external" });
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
141
149
|
if (wired.has(taskType)) {
|
|
142
150
|
skipped.push({ taskType, reason: "already-wired" });
|
|
143
151
|
continue;
|
package/src/toolkit/scaffold.ts
CHANGED
|
@@ -19,6 +19,7 @@ interface ScaffoldManifest {
|
|
|
19
19
|
types?: Record<string, unknown>;
|
|
20
20
|
models?: { processes?: string[] };
|
|
21
21
|
workers?: ScaffoldWorker[];
|
|
22
|
+
externalTaskTypes?: string[];
|
|
22
23
|
api?: { spec?: string; dir?: string };
|
|
23
24
|
}
|
|
24
25
|
|
|
@@ -64,7 +65,12 @@ export async function scaffoldWorkers(opts: ScaffoldOptions): Promise<ScaffoldRu
|
|
|
64
65
|
const models = await readModels(root, io, manifest);
|
|
65
66
|
const declaredTypeIds = Object.keys(manifest.types ?? {});
|
|
66
67
|
|
|
67
|
-
const { plans, skipped } = planWorkerScaffold(
|
|
68
|
+
const { plans, skipped } = planWorkerScaffold(
|
|
69
|
+
models,
|
|
70
|
+
manifest.workers ?? [],
|
|
71
|
+
declaredTypeIds,
|
|
72
|
+
manifest.externalTaskTypes ?? [],
|
|
73
|
+
);
|
|
68
74
|
|
|
69
75
|
const outcomes: StubOutcome[] = [];
|
|
70
76
|
const wired: StubManifestEntry[] = [];
|