@nanobpm/urban 0.35.0 → 0.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/cli.d.ts +0 -1
  2. package/dist/cli.js +32 -47
  3. package/dist/openapi/spec.d.ts +9 -0
  4. package/dist/openapi/spec.js +22 -0
  5. package/dist/runtime/core/modules/api.d.ts +19 -4
  6. package/dist/runtime/core/modules/api.js +81 -3
  7. package/dist/runtime/core/runtime.d.ts +10 -0
  8. package/dist/runtime/core/runtime.js +2 -2
  9. package/dist/runtime/index.d.ts +3 -1
  10. package/dist/runtime/index.js +2 -1
  11. package/dist/toolkit/api-dir.d.ts +11 -0
  12. package/dist/toolkit/api-dir.js +36 -0
  13. package/dist/toolkit/derivers/api.d.ts +25 -2
  14. package/dist/toolkit/derivers/api.js +60 -5
  15. package/dist/toolkit/gen.js +4 -1
  16. package/dist/toolkit/generate.d.ts +27 -0
  17. package/dist/toolkit/generate.js +47 -0
  18. package/dist/toolkit/index.d.ts +8 -1
  19. package/dist/toolkit/index.js +5 -1
  20. package/dist/toolkit/scaffold/operations.d.ts +21 -0
  21. package/dist/toolkit/scaffold/operations.js +55 -0
  22. package/dist/toolkit/scaffold/workers.js +1 -1
  23. package/dist/toolkit/scaffold.d.ts +16 -0
  24. package/dist/toolkit/scaffold.js +40 -0
  25. package/package.json +74 -74
  26. package/src/cli.test.ts +0 -1
  27. package/src/cli.ts +30 -45
  28. package/src/openapi/spec.ts +24 -0
  29. package/src/runtime/core/modules/api.test.ts +104 -2
  30. package/src/runtime/core/modules/api.ts +95 -8
  31. package/src/runtime/core/runtime.test.ts +53 -1
  32. package/src/runtime/core/runtime.ts +12 -2
  33. package/src/runtime/index.ts +3 -1
  34. package/src/toolkit/api-dir.test.ts +32 -0
  35. package/src/toolkit/api-dir.ts +41 -0
  36. package/src/toolkit/derivers/api.test.ts +41 -3
  37. package/src/toolkit/derivers/api.ts +75 -5
  38. package/src/toolkit/gen.ts +5 -2
  39. package/src/toolkit/generate.test.ts +135 -0
  40. package/src/toolkit/generate.ts +71 -0
  41. package/src/toolkit/index.ts +10 -0
  42. package/src/toolkit/scaffold/operations.test.ts +136 -0
  43. package/src/toolkit/scaffold/operations.ts +75 -0
  44. package/src/toolkit/scaffold/workers.ts +1 -1
  45. package/src/toolkit/scaffold.ts +56 -0
@@ -8,8 +8,10 @@
8
8
 
9
9
  import type { DerivedArtifact, Deriver } from "../artifact.ts";
10
10
  import { GENERATED_DIR } from "../artifact.ts";
11
+ import { DEFAULT_OPERATIONS_DIR, normalizeApiDir } from "../api-dir.ts";
11
12
  import {
12
13
  collectOperations,
14
+ assertUniqueOperationIds,
13
15
  isObjectSchema,
14
16
  type OpenApiDoc,
15
17
  type OpenApiSchema,
@@ -20,6 +22,13 @@ import {
20
22
  /** Filenames of the emitted artifacts (siblings of worker-io.d.ts in nano-generated/). */
21
23
  export const API_BINDINGS_DTS = "api-io.d.ts";
22
24
  export const API_BINDINGS_TS = "operations.ts";
25
+ /** The generated delegate registry: statically imports every operation delegate and asserts the
26
+ * complete, correctly-typed set via `satisfies` — so a missing/mismatched/orphan delegate fails
27
+ * `tsc` (ADR 0059). The runtime dispatches through this map instead of per-op path imports. */
28
+ export const API_CONTROLLER_TS = "controller.ts";
29
+
30
+ export { DEFAULT_OPERATIONS_DIR };
31
+
23
32
 
24
33
  /** A TS identifier form of an operationId (used for the per-op type names). */
25
34
  function typeStem(operationId: string): string {
@@ -240,16 +249,77 @@ export function emitApiBindingsRuntime(): string {
240
249
  );
241
250
  }
242
251
 
243
- /** Derive the api artifacts from a parsed OpenAPI document. */
244
- export function deriveApi(doc: OpenApiDoc): DerivedArtifact[] {
252
+ /** Normalize an `api.dir` to a clean relative segment (see `normalizeApiDir`); rejects absolute
253
+ * paths and `..` segments so the generated import paths stay app-relative and can't drift. */
254
+ function normalizeOperationsDir(dir: string): string {
255
+ return normalizeApiDir(dir);
256
+ }
257
+
258
+ /**
259
+ * Emit `controller.ts`: the generated delegate registry (ADR 0059). It statically imports every
260
+ * operation delegate (`<dir>/<operationId>.ts`) and asserts the whole set against the spec-derived
261
+ * contract with a single `satisfies { [K in ApiOperationId]: OperationHandler<ReqFor<K>, ResFor<K>> }`.
262
+ * That one assertion gives four compile-time drift guarantees, all surfaced by `tsc`:
263
+ * • a missing delegate file → the static `import` fails ("Cannot find module");
264
+ * • an op in the spec, missing here → the mapped type requires key `K` ("Property is missing");
265
+ * • a delegate with the wrong signature → not assignable to `OperationHandler<Req, Res>`;
266
+ * • an orphan delegate (no spec op) → an excess property vs the mapped type.
267
+ * The runtime imports this ONE barrel and dispatches via `operations[operationId]`, so existing
268
+ * endpoints cannot drift from the contract. Delegates are keyed by their real operationId; the
269
+ * import binding is index-based (`op0`, `op1`, …) because an operationId is a safe path segment but
270
+ * not necessarily a valid TS identifier. Requires each delegate to be a default export.
271
+ */
272
+ export function emitApiController(doc: OpenApiDoc, operationsDir: string = DEFAULT_OPERATIONS_DIR): string {
273
+ assertUniqueOperationIds(doc);
274
+ const ops = collectOperations(doc);
275
+ const dir = normalizeOperationsDir(operationsDir);
276
+
277
+ const header =
278
+ "// AUTO-GENERATED by @nanobpm/urban from the app's OpenAPI spec (ADR 0059): the delegate\n" +
279
+ "// registry. Statically imports every operation delegate and asserts the complete, correctly-\n" +
280
+ "// typed set via `satisfies` — a missing, mismatched, or orphan delegate fails `tsc`. The\n" +
281
+ "// runtime dispatches through this map, so endpoints cannot drift from the spec. Do not edit —\n" +
282
+ "// regenerated from the spec.\n" +
283
+ "// eslint-disable\n\n";
284
+
285
+ const typeImports =
286
+ `import type { OperationHandler } from "@nanobpm/urban";\n` +
287
+ `import type { ApiOperationId, ApiOperations } from "./${API_BINDINGS_DTS}";\n`;
288
+
289
+ const delegateImports = ops
290
+ .map((op, i) => `import op${i} from ${JSON.stringify(`../${dir}/${op.operationId}.ts`)};`)
291
+ .join("\n");
292
+
293
+ const mapEntries = ops.map((op, i) => ` ${JSON.stringify(op.operationId)}: op${i},`).join("\n");
294
+
295
+ return (
296
+ header +
297
+ typeImports +
298
+ (delegateImports ? `${delegateImports}\n` : "") +
299
+ `\n` +
300
+ `type ReqFor<K extends ApiOperationId> = ApiOperations[K]["request"];\n` +
301
+ `type ResFor<K extends ApiOperationId> = ApiOperations[K]["response"];\n\n` +
302
+ `/** Every operationId → its default-exported delegate. The \`satisfies\` clause makes \`tsc\`\n` +
303
+ ` * reject a missing, wrongly-typed, or orphan delegate (ADR 0059). */\n` +
304
+ `export const operations = {\n${mapEntries}${mapEntries ? "\n" : ""}} satisfies {\n` +
305
+ ` [K in ApiOperationId]: OperationHandler<ReqFor<K>, ResFor<K>>;\n` +
306
+ `};\n`
307
+ );
308
+ }
309
+
310
+ /** 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
+ export function deriveApi(doc: OpenApiDoc, operationsDir: string = DEFAULT_OPERATIONS_DIR): DerivedArtifact[] {
245
313
  return [
246
314
  { path: `${GENERATED_DIR}/${API_BINDINGS_DTS}`, content: emitApiBindings(doc) },
247
315
  { path: `${GENERATED_DIR}/${API_BINDINGS_TS}`, content: emitApiBindingsRuntime() },
316
+ { path: `${GENERATED_DIR}/${API_CONTROLLER_TS}`, content: emitApiController(doc, operationsDir) },
248
317
  ];
249
318
  }
250
319
 
251
- export const apiDeriver: Deriver<{ doc: OpenApiDoc }> = {
320
+ export const apiDeriver: Deriver<{ doc: OpenApiDoc; operationsDir?: string }> = {
252
321
  id: "openapi->api-io",
253
- describe: "Derive api-io.d.ts (typed request/response per operationId) + the typed defineOperation.",
254
- derive: ({ doc }) => deriveApi(doc),
322
+ describe:
323
+ "Derive api-io.d.ts (typed request/response per operationId) + the typed defineOperation + the delegate registry.",
324
+ derive: ({ doc, operationsDir }) => deriveApi(doc, operationsDir),
255
325
  };
@@ -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 };
125
+ api?: { spec?: string; dir?: string };
126
126
  } = JSON.parse(await io.readText(manifestPath));
127
127
 
128
128
  const artifacts: DerivedArtifact[] = [];
@@ -159,7 +159,10 @@ async function collectAll(opts: GenOptions): Promise<{ artifacts: DerivedArtifac
159
159
  const specRef = typeof manifest.api?.spec === "string" ? manifest.api.spec.trim() : "";
160
160
  if (!opts.modelsOnly && specRef.length > 0) {
161
161
  const specText = await io.readText(join(root, specRef));
162
- artifacts.push(...deriveApi(parseSpec(specText)));
162
+ const opsDir = typeof manifest.api?.dir === "string" && manifest.api.dir.trim().length > 0
163
+ ? manifest.api.dir.trim()
164
+ : undefined;
165
+ artifacts.push(...deriveApi(parseSpec(specText), opsDir));
163
166
  }
164
167
 
165
168
  return { artifacts: sortArtifacts(artifacts), derived };
@@ -0,0 +1,135 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { generate } from "./generate.ts";
4
+ import type { GenIO } from "./gen.ts";
5
+
6
+ /** In-memory filesystem mirroring gen.test.ts's memIO (IO-free, deterministic). */
7
+ function memIO(files: Record<string, string>): GenIO & { files: Record<string, string> } {
8
+ return {
9
+ files,
10
+ async readText(p) {
11
+ if (!(p in files)) throw new Error(`ENOENT ${p}`);
12
+ return files[p];
13
+ },
14
+ async writeText(p, c) {
15
+ files[p] = c;
16
+ },
17
+ async listDir(p) {
18
+ const prefix = p.replace(/\/+$/, "") + "/";
19
+ const names = new Set<string>();
20
+ for (const f of Object.keys(files)) {
21
+ if (f.startsWith(prefix)) {
22
+ const rest = f.slice(prefix.length);
23
+ if (!rest.includes("/")) names.add(rest);
24
+ }
25
+ }
26
+ return [...names];
27
+ },
28
+ async exists(p) {
29
+ return p in files;
30
+ },
31
+ async remove(p) {
32
+ delete files[p];
33
+ },
34
+ };
35
+ }
36
+
37
+ const MANIFEST = JSON.stringify({
38
+ id: "demo",
39
+ data: { default: "app" },
40
+ models: { processes: ["processes/*.bpmn"] },
41
+ types: { greeting: { table: "greetings", fields: { who: { type: "string" } } } },
42
+ api: { spec: "openapi.json" },
43
+ });
44
+
45
+ const BPMN = `<bpmn:process id="p" xmlns:bpmn="x" xmlns:zeebe="y">
46
+ <bpmn:serviceTask id="T"><bpmn:extensionElements>
47
+ <zeebe:taskDefinition type="demo.do" />
48
+ </bpmn:extensionElements></bpmn:serviceTask>
49
+ </bpmn:process>`;
50
+
51
+ const OPENAPI = JSON.stringify({
52
+ openapi: "3.0.0",
53
+ paths: {
54
+ "/ping": { get: { operationId: "ping", responses: { "200": {} } } },
55
+ },
56
+ });
57
+
58
+ function fixture(): Record<string, string> {
59
+ return {
60
+ "/app/nano.app.json": MANIFEST,
61
+ "/app/processes/p.bpmn": BPMN,
62
+ "/app/openapi.json": OPENAPI,
63
+ };
64
+ }
65
+
66
+ test("generate write mode scaffolds worker + operation stubs alongside derived artifacts", async () => {
67
+ const io = memIO(fixture());
68
+ const res = await generate({ root: "/app", io });
69
+
70
+ // Derived artifacts landed (the runGen half).
71
+ assert.ok(io.files["/app/nano-generated/worker-io.d.ts"], "expected derived worker-io");
72
+ assert.ok(io.files["/app/nano-generated/app.schema.sql"], "expected derived migrations");
73
+
74
+ // Worker stub created + wired into the manifest.
75
+ assert.deepEqual(
76
+ res.workerStubs.map((s) => s.status),
77
+ ["created"],
78
+ );
79
+ assert.ok(io.files["/app/workers/demo-do/worker.ts"], "expected a worker stub on disk");
80
+ assert.ok(res.manifestPatched, "expected workers[] to be patched");
81
+ assert.equal(res.wiredWorkers.length, 1);
82
+
83
+ // Operation stub created.
84
+ assert.deepEqual(
85
+ res.operationStubs.map((s) => s.status),
86
+ ["created"],
87
+ );
88
+ assert.ok(io.files["/app/operations/ping.ts"], "expected an operation stub on disk");
89
+
90
+ // Write mode never reports missing stubs.
91
+ assert.deepEqual(res.missingStubs, []);
92
+ });
93
+
94
+ test("generate --check writes nothing and reports would-create stubs as missing", async () => {
95
+ const io = memIO(fixture());
96
+ // Pre-derive so the only drift under --check comes from the missing stubs.
97
+ await generate({ root: "/app", io });
98
+ const before = { ...io.files };
99
+
100
+ const res = await generate({ root: "/app", io, check: true });
101
+
102
+ // Nothing changed on disk (both derivation and scaffolding are write-free under --check).
103
+ assert.deepEqual(io.files, before, "--check must not write");
104
+
105
+ // The already-created stubs are kept, so nothing is missing this run.
106
+ assert.deepEqual(res.missingStubs, []);
107
+ assert.deepEqual(res.drift, []);
108
+ });
109
+
110
+ test("generate --check surfaces an uncommitted stub via missingStubs (the CI drift catch)", async () => {
111
+ const io = memIO(fixture());
112
+ const before = { ...io.files };
113
+
114
+ const res = await generate({ root: "/app", io, check: true });
115
+
116
+ assert.deepEqual(io.files, before, "--check must not write");
117
+ assert.deepEqual(
118
+ res.missingStubs.sort(),
119
+ ["operations/ping.ts", "workers/demo-do/worker.ts"],
120
+ );
121
+ });
122
+
123
+ test("generate keeps existing human-owned stubs (write-once, never clobbered)", async () => {
124
+ const io = memIO(fixture());
125
+ io.files["/app/workers/demo-do/worker.ts"] = "// hand-authored body";
126
+ io.files["/app/operations/ping.ts"] = "// hand-authored delegate";
127
+
128
+ const res = await generate({ root: "/app", io });
129
+
130
+ assert.equal(io.files["/app/workers/demo-do/worker.ts"], "// hand-authored body");
131
+ assert.equal(io.files["/app/operations/ping.ts"], "// hand-authored delegate");
132
+ assert.deepEqual(res.workerStubs.map((s) => s.status), ["kept"]);
133
+ assert.deepEqual(res.operationStubs.map((s) => s.status), ["kept"]);
134
+ assert.deepEqual(res.missingStubs, []);
135
+ });
@@ -0,0 +1,71 @@
1
+ // `urban gen`: the whole generation concern in one pass — derive artifacts (migrations, worker-I/O,
2
+ // code-first models, OpenAPI contracts + controller) AND scaffold the write-once handler stubs those
3
+ // artifacts reference (worker stubs from the model, operation-delegate stubs from the OpenAPI spec).
4
+ //
5
+ // This composes the pure derivation (`runGen`, gen.ts) with the write-once scaffolders (scaffold.ts)
6
+ // WITHOUT a cycle: scaffold.ts already depends on gen.ts, so this leaf module depends on both and
7
+ // neither depends back. Scaffolding is write-once (an existing, human-owned stub is never clobbered),
8
+ // so folding it into `gen` is idempotent. `--check` writes nothing: a missing stub surfaces in
9
+ // `missingStubs` exactly like a drifted artifact surfaces in `drift`, so CI fails when a stub the
10
+ // generated controller imports isn't committed.
11
+
12
+ import type { GenOptions, GenResult } from "./gen.ts";
13
+ import { runGen } from "./gen.ts";
14
+ import type { OperationStubOutcome, StubOutcome } from "./scaffold.ts";
15
+ import { scaffoldOperations, scaffoldWorkers } from "./scaffold.ts";
16
+ import type { StubManifestEntry } from "./scaffold/workers.ts";
17
+
18
+ export interface GenerateResult extends GenResult {
19
+ /** Worker-stub scaffolding outcomes (created / kept / would-create). */
20
+ workerStubs: StubOutcome[];
21
+ /** Operation-delegate scaffolding outcomes (created / kept / would-create). */
22
+ operationStubs: OperationStubOutcome[];
23
+ /** Worker manifest entries wired into `workers[]` (write mode only). */
24
+ wiredWorkers: StubManifestEntry[];
25
+ /** True when `workers[]` was patched (write mode, unwired workers present). */
26
+ manifestPatched: boolean;
27
+ /**
28
+ * App-relative paths of stubs that would be created — the scaffolding equivalent of `drift`.
29
+ * Populated in `check` mode (nothing is written); a caller should fail CI when non-empty.
30
+ */
31
+ missingStubs: string[];
32
+ }
33
+
34
+ /**
35
+ * Derive artifacts and scaffold the write-once handler stubs they reference, in one pass.
36
+ * In `check` mode nothing is written and any would-be-created stub is reported in `missingStubs`.
37
+ * Scaffolding is skipped when derivation is `incomplete` (never scaffold from a half-derived model);
38
+ * the caller reports the derivation failure first.
39
+ */
40
+ export async function generate(opts: GenOptions & { check?: boolean }): Promise<GenerateResult> {
41
+ const res = await runGen(opts);
42
+
43
+ const empty: GenerateResult = {
44
+ ...res,
45
+ workerStubs: [],
46
+ operationStubs: [],
47
+ wiredWorkers: [],
48
+ manifestPatched: false,
49
+ missingStubs: [],
50
+ };
51
+ if (res.incomplete) return empty;
52
+
53
+ const write = !opts.check;
54
+ const scaffoldOpts = { root: opts.root, io: opts.io, manifestFile: opts.manifestFile, write };
55
+ const workers = await scaffoldWorkers(scaffoldOpts);
56
+ const operations = await scaffoldOperations(scaffoldOpts);
57
+
58
+ const missingStubs = [
59
+ ...workers.outcomes.filter((o) => o.status === "would-create").map((o) => o.handlerPath),
60
+ ...operations.outcomes.filter((o) => o.status === "would-create").map((o) => o.handlerPath),
61
+ ];
62
+
63
+ return {
64
+ ...res,
65
+ workerStubs: workers.outcomes,
66
+ operationStubs: operations.outcomes,
67
+ wiredWorkers: workers.wired,
68
+ manifestPatched: workers.manifestPatched,
69
+ missingStubs,
70
+ };
71
+ }
@@ -103,9 +103,11 @@ export {
103
103
  deriveApi,
104
104
  emitApiBindings,
105
105
  emitApiBindingsRuntime,
106
+ emitApiController,
106
107
  schemaToTs,
107
108
  API_BINDINGS_DTS,
108
109
  API_BINDINGS_TS,
110
+ API_CONTROLLER_TS,
109
111
  } from "./derivers/api.ts";
110
112
 
111
113
  // The OpenAPI machinery the deriver + runtime share (ADR 0058).
@@ -149,6 +151,8 @@ export const DERIVERS = [
149
151
  // Gen orchestrator + IO
150
152
  export { collectArtifacts, runGen, joinPath, readModels, expandPattern, previewModels } from "./gen.ts";
151
153
  export type { GenIO, GenOptions, GenResult } from "./gen.ts";
154
+ export { generate } from "./generate.ts";
155
+ export type { GenerateResult } from "./generate.ts";
152
156
  export { createNodeGenIO } from "./fsio.ts";
153
157
 
154
158
  // Code-first model derivation (workflows/*.ts → BPMN)
@@ -180,5 +184,11 @@ export type {
180
184
  export { scaffoldWorkers } from "./scaffold.ts";
181
185
  export type { ScaffoldOptions, ScaffoldRun, StubOutcome, StubStatus } from "./scaffold.ts";
182
186
 
187
+ // Operation-delegate scaffolder (ADR 0059): write-once typed delegate stubs from the OpenAPI spec.
188
+ export { planOperationScaffold, renderOperationStub } from "./scaffold/operations.ts";
189
+ export type { OperationStubPlan } from "./scaffold/operations.ts";
190
+ export { scaffoldOperations } from "./scaffold.ts";
191
+ export type { OperationScaffoldRun, OperationStubOutcome } from "./scaffold.ts";
192
+
183
193
  export { addConnector } from "./addConnector.ts";
184
194
  export type { AddConnectorOptions, AddConnectorResult } from "./addConnector.ts";
@@ -0,0 +1,136 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { planOperationScaffold, renderOperationStub } from "./operations.ts";
4
+ import { scaffoldOperations } from "../scaffold.ts";
5
+ import type { GenIO } from "../gen.ts";
6
+ import type { OpenApiDoc } from "../../openapi/spec.ts";
7
+
8
+ const doc: OpenApiDoc = {
9
+ openapi: "3.0.0",
10
+ paths: {
11
+ "/invoices/{id}": {
12
+ get: {
13
+ operationId: "getInvoice",
14
+ parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
15
+ responses: { "200": {} },
16
+ },
17
+ },
18
+ "/invoices": {
19
+ post: { operationId: "createInvoice", responses: { "201": {} } },
20
+ },
21
+ },
22
+ };
23
+
24
+ test("renderOperationStub is a typed default-exported delegate that throws NotImplemented", () => {
25
+ const stub = renderOperationStub("getInvoice");
26
+ assert.match(stub, /import \{ NotImplemented \} from "@nanobpm\/urban";/);
27
+ assert.match(stub, /import \{ defineOperation \} from "\.\.\/nano-generated\/operations\.ts";/);
28
+ assert.match(stub, /export default defineOperation\("getInvoice", async \(input, app\) =>/);
29
+ assert.match(stub, /throw new NotImplemented\("getInvoice"\);/);
30
+ });
31
+
32
+ test("renderOperationStub adjusts the generated-import depth for a nested api.dir", () => {
33
+ const stub = renderOperationStub("getInvoice", "api/handlers");
34
+ assert.match(stub, /import \{ defineOperation \} from "\.\.\/\.\.\/nano-generated\/operations\.ts";/);
35
+ });
36
+
37
+ test("planOperationScaffold plans one stub per declared operationId", () => {
38
+ const plans = planOperationScaffold(doc);
39
+ const byId = Object.fromEntries(plans.map((p) => [p.operationId, p.handlerPath]));
40
+ assert.deepEqual(byId, {
41
+ getInvoice: "operations/getInvoice.ts",
42
+ createInvoice: "operations/createInvoice.ts",
43
+ });
44
+ });
45
+
46
+ test("planOperationScaffold honors a custom api.dir", () => {
47
+ const plans = planOperationScaffold(doc, "handlers");
48
+ assert.ok(plans.every((p) => p.handlerPath.startsWith("handlers/")));
49
+ });
50
+
51
+ test("planOperationScaffold throws on a duplicate operationId (incoherent spec)", () => {
52
+ const dupe: OpenApiDoc = {
53
+ openapi: "3.0.0",
54
+ paths: {
55
+ "/a": { get: { operationId: "same", responses: { "200": {} } } },
56
+ "/b": { get: { operationId: "same", responses: { "200": {} } } },
57
+ },
58
+ };
59
+ assert.throws(() => planOperationScaffold(dupe), /duplicate operationId\(s\): same/);
60
+ });
61
+
62
+ function memIO(files: Record<string, string>): GenIO & { files: Record<string, string> } {
63
+ return {
64
+ files,
65
+ async readText(p) {
66
+ if (!(p in files)) throw new Error(`ENOENT ${p}`);
67
+ return files[p];
68
+ },
69
+ async writeText(p, c) {
70
+ files[p] = c;
71
+ },
72
+ async listDir(p) {
73
+ const prefix = p.replace(/\/+$/, "") + "/";
74
+ const names = new Set<string>();
75
+ for (const f of Object.keys(files)) {
76
+ if (f.startsWith(prefix)) {
77
+ const rest = f.slice(prefix.length);
78
+ if (!rest.includes("/")) names.add(rest);
79
+ }
80
+ }
81
+ return [...names];
82
+ },
83
+ async exists(p) {
84
+ return p in files;
85
+ },
86
+ };
87
+ }
88
+
89
+ function appFixture(): Record<string, string> {
90
+ return {
91
+ "/app/nano.app.json": JSON.stringify({ id: "demo", api: { spec: "openapi.json" } }),
92
+ "/app/openapi.json": JSON.stringify(doc),
93
+ };
94
+ }
95
+
96
+ test("scaffoldOperations is a no-op when the app declares no api binding", async () => {
97
+ const io = memIO({ "/app/nano.app.json": JSON.stringify({ id: "demo" }) });
98
+ const run = await scaffoldOperations({ root: "/app", io, write: true });
99
+ assert.deepEqual(run.outcomes, []);
100
+ });
101
+
102
+ test("scaffoldOperations dry-run writes nothing and reports would-create per op", async () => {
103
+ const io = memIO(appFixture());
104
+ const before = Object.keys(io.files).length;
105
+ const run = await scaffoldOperations({ root: "/app", io });
106
+ assert.equal(run.write, false);
107
+ assert.equal(Object.keys(io.files).length, before, "dry-run must not write");
108
+ assert.deepEqual(run.outcomes.map((o) => o.status).sort(), ["would-create", "would-create"]);
109
+ });
110
+
111
+ test("scaffoldOperations --write creates one typed stub per op", async () => {
112
+ const io = memIO(appFixture());
113
+ const run = await scaffoldOperations({ root: "/app", io, write: true });
114
+ assert.ok(run.outcomes.every((o) => o.status === "created"));
115
+ assert.match(io.files["/app/operations/getInvoice.ts"], /NotImplemented\("getInvoice"\)/);
116
+ assert.match(io.files["/app/operations/createInvoice.ts"], /NotImplemented\("createInvoice"\)/);
117
+ });
118
+
119
+ test("scaffoldOperations never clobbers an existing delegate (keeps it)", async () => {
120
+ const files = appFixture();
121
+ files["/app/operations/getInvoice.ts"] = "// hand-written, do not touch\n";
122
+ const io = memIO(files);
123
+ const run = await scaffoldOperations({ root: "/app", io, write: true });
124
+ assert.equal(io.files["/app/operations/getInvoice.ts"], "// hand-written, do not touch\n");
125
+ assert.equal(run.outcomes.find((o) => o.operationId === "getInvoice")?.status, "kept");
126
+ assert.equal(run.outcomes.find((o) => o.operationId === "createInvoice")?.status, "created");
127
+ });
128
+
129
+ test("scaffoldOperations is idempotent: a second --write run keeps everything", async () => {
130
+ const io = memIO(appFixture());
131
+ await scaffoldOperations({ root: "/app", io, write: true });
132
+ const snapshot = JSON.stringify(io.files);
133
+ const run2 = await scaffoldOperations({ root: "/app", io, write: true });
134
+ assert.ok(run2.outcomes.every((o) => o.status === "kept"));
135
+ assert.equal(JSON.stringify(io.files), snapshot, "files unchanged on re-run");
136
+ });
@@ -0,0 +1,75 @@
1
+ // Pure planner for the write-once operation-delegate scaffolder (ADR 0059). Given a parsed OpenAPI
2
+ // document, it plans one typed delegate stub per declared `operationId` — the ONE part of the HTTP
3
+ // surface that cannot be derived from the spec (the handler *body* is human logic). Everything here
4
+ // is pure and testable like a deriver; the impure write-if-absent edge lives in `../scaffold.ts`.
5
+ //
6
+ // scaffold ≠ derive: stubs are human-owned files under `<dir>/<operationId>.ts`, so they are planned
7
+ // once and never overwritten — unlike the derived `nano-generated/` tree (which includes the
8
+ // controller registry that statically imports these delegates and type-checks the whole set).
9
+
10
+ import { DEFAULT_OPERATIONS_DIR, normalizeApiDir } from "../api-dir.ts";
11
+ import { assertUniqueOperationIds, collectOperations, type OpenApiDoc } from "../../openapi/spec.ts";
12
+
13
+ export { DEFAULT_OPERATIONS_DIR };
14
+
15
+ /** One planned operation-delegate stub. */
16
+ export interface OperationStubPlan {
17
+ operationId: string;
18
+ /** App-relative delegate path: `<dir>/<operationId>.ts`. */
19
+ handlerPath: string;
20
+ /** The stub file contents (a typed `defineOperation` that throws `NotImplemented` → HTTP 501). */
21
+ stub: string;
22
+ }
23
+
24
+ /** Normalize an `api.dir` to a clean relative segment (see `normalizeApiDir`); rejects absolute
25
+ * paths and `..` segments so stubs are scaffolded where the runtime resolves delegates from. */
26
+ function normalizeDir(dir: string): string {
27
+ return normalizeApiDir(dir);
28
+ }
29
+
30
+ /** Number of `../` hops from `<dir>/<file>.ts` back up to the app root (dir may be nested). */
31
+ function upToRoot(dir: string): string {
32
+ const depth = dir.split("/").filter((s) => s.length > 0).length;
33
+ return "../".repeat(depth);
34
+ }
35
+
36
+ /** Render a stub matching a real hand-authored operation delegate (default-exported, typed via the
37
+ * generated `defineOperation`). It throws `NotImplemented` so the endpoint returns 501 until the
38
+ * body is implemented — never a silent 500, never a drift from the contract. */
39
+ export function renderOperationStub(operationId: string, dir: string = DEFAULT_OPERATIONS_DIR): string {
40
+ const up = upToRoot(normalizeDir(dir));
41
+ const key = JSON.stringify(operationId);
42
+ return (
43
+ `// Operation delegate stub for the \`${operationId}\` endpoint, scaffolded from the OpenAPI spec\n` +
44
+ `// (ADR 0059). This file is yours to edit — \`urban gen\` will never overwrite it. The\n` +
45
+ `// generated controller (nano-generated/controller.ts) imports this default export and\n` +
46
+ `// type-checks it against the spec, so the signature cannot drift. Implement the body below\n` +
47
+ `// (the validated \`{ req, params, query, body }\` input + the injected \`app\`) and delete the throw.\n` +
48
+ `import { NotImplemented } from "@nanobpm/urban";\n` +
49
+ `import { defineOperation } from ${JSON.stringify(`${up}nano-generated/operations.ts`)};\n` +
50
+ `\n` +
51
+ `export default defineOperation(${key}, async (input, app) => {\n` +
52
+ ` // \`input\` is the validated request (params/query/body/req); reach app state + services\n` +
53
+ ` // through the injected \`app\` API. Return \`{ status?, headers?, body? }\` (or nothing → 204).\n` +
54
+ ` throw new NotImplemented(${key});\n` +
55
+ `});\n`
56
+ );
57
+ }
58
+
59
+ /**
60
+ * Plan write-once delegate stubs from the spec: one per declared `operationId` (collectOperations
61
+ * already skips ops without a safe id). The planner emits a plan for every operation; the impure
62
+ * edge (`scaffoldOperations`) keeps an existing file verbatim and creates only the absent ones.
63
+ */
64
+ export function planOperationScaffold(
65
+ doc: OpenApiDoc,
66
+ dir: string = DEFAULT_OPERATIONS_DIR,
67
+ ): OperationStubPlan[] {
68
+ assertUniqueOperationIds(doc);
69
+ const cleanDir = normalizeDir(dir);
70
+ return collectOperations(doc).map((op) => ({
71
+ operationId: op.operationId,
72
+ handlerPath: `${cleanDir}/${op.operationId}.ts`,
73
+ stub: renderOperationStub(op.operationId, cleanDir),
74
+ }));
75
+ }
@@ -92,7 +92,7 @@ export function renderWorkerStub(
92
92
 
93
93
  return (
94
94
  `// Handler stub for the \`${taskType}\` service task, scaffolded from the model (ADR 0056).\n` +
95
- `// This file is yours to edit — \`urban stubs\` will never overwrite it. Implement the body\n` +
95
+ `// This file is yours to edit — \`urban gen\` will never overwrite it. Implement the body\n` +
96
96
  `// below (use \`app.data.table(...)\` for state) and delete the throw.\n` +
97
97
  `import type { AppJobHandler } from "@nanobpm/urban/worker";\n` +
98
98
  generatedImport +
@@ -11,12 +11,15 @@ import {
11
11
  type SkippedWorker,
12
12
  type StubManifestEntry,
13
13
  } from "./scaffold/workers.ts";
14
+ import { planOperationScaffold } from "./scaffold/operations.ts";
15
+ import { parseSpec } from "../openapi/spec.ts";
14
16
 
15
17
  /** The manifest fields the scaffolder reads. */
16
18
  interface ScaffoldManifest {
17
19
  types?: Record<string, unknown>;
18
20
  models?: { processes?: string[] };
19
21
  workers?: ScaffoldWorker[];
22
+ api?: { spec?: string; dir?: string };
20
23
  }
21
24
 
22
25
  export interface ScaffoldOptions {
@@ -102,3 +105,56 @@ export async function scaffoldWorkers(opts: ScaffoldOptions): Promise<ScaffoldRu
102
105
 
103
106
  return { outcomes, skipped, wired, manifestPatched, write };
104
107
  }
108
+
109
+ /** One planned operation-delegate outcome (write-once). */
110
+ export interface OperationStubOutcome {
111
+ operationId: string;
112
+ handlerPath: string;
113
+ status: StubStatus;
114
+ }
115
+
116
+ export interface OperationScaffoldRun {
117
+ outcomes: OperationStubOutcome[];
118
+ write: boolean;
119
+ }
120
+
121
+ /**
122
+ * Scaffold write-once operation-delegate stubs from the app's OpenAPI spec (ADR 0059). One typed
123
+ * `defineOperation` stub per declared `operationId`, created only if absent (human-owned files are
124
+ * kept verbatim). Dry-run by default. A no-op when the app declares no `api.spec`.
125
+ */
126
+ export async function scaffoldOperations(opts: ScaffoldOptions): Promise<OperationScaffoldRun> {
127
+ const { root, io } = opts;
128
+ const write = opts.write ?? false;
129
+ const manifestFile = opts.manifestFile ?? "nano.app.json";
130
+ const manifestPath = joinPath(root, manifestFile);
131
+
132
+ const manifest: ScaffoldManifest = JSON.parse(await io.readText(manifestPath));
133
+ const specRef = typeof manifest.api?.spec === "string" ? manifest.api.spec.trim() : "";
134
+ if (specRef.length === 0) return { outcomes: [], write };
135
+
136
+ const specText = await io.readText(joinPath(root, specRef));
137
+ const dir =
138
+ typeof manifest.api?.dir === "string" && manifest.api.dir.trim().length > 0
139
+ ? manifest.api.dir.trim()
140
+ : undefined;
141
+ const plans = planOperationScaffold(parseSpec(specText), dir);
142
+
143
+ const outcomes: OperationStubOutcome[] = [];
144
+ for (const plan of plans) {
145
+ const abs = joinPath(root, plan.handlerPath);
146
+ const exists = await io.exists(abs);
147
+ let status: StubStatus;
148
+ if (exists) {
149
+ status = "kept"; // human-owned — never clobber
150
+ } else if (write) {
151
+ await io.writeText(abs, plan.stub);
152
+ status = "created";
153
+ } else {
154
+ status = "would-create";
155
+ }
156
+ outcomes.push({ operationId: plan.operationId, handlerPath: plan.handlerPath, status });
157
+ }
158
+
159
+ return { outcomes, write };
160
+ }