@nanobpm/urban 0.35.0 → 0.37.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 (47) 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.d.ts +7 -5
  23. package/dist/toolkit/scaffold/workers.js +12 -5
  24. package/dist/toolkit/scaffold.d.ts +16 -0
  25. package/dist/toolkit/scaffold.js +41 -1
  26. package/package.json +74 -74
  27. package/src/cli.test.ts +0 -1
  28. package/src/cli.ts +30 -45
  29. package/src/openapi/spec.ts +24 -0
  30. package/src/runtime/core/modules/api.test.ts +104 -2
  31. package/src/runtime/core/modules/api.ts +95 -8
  32. package/src/runtime/core/runtime.test.ts +53 -1
  33. package/src/runtime/core/runtime.ts +12 -2
  34. package/src/runtime/index.ts +3 -1
  35. package/src/toolkit/api-dir.test.ts +32 -0
  36. package/src/toolkit/api-dir.ts +41 -0
  37. package/src/toolkit/derivers/api.test.ts +41 -3
  38. package/src/toolkit/derivers/api.ts +75 -5
  39. package/src/toolkit/gen.ts +5 -2
  40. package/src/toolkit/generate.test.ts +135 -0
  41. package/src/toolkit/generate.ts +71 -0
  42. package/src/toolkit/index.ts +10 -0
  43. package/src/toolkit/scaffold/operations.test.ts +136 -0
  44. package/src/toolkit/scaffold/operations.ts +75 -0
  45. package/src/toolkit/scaffold/workers.test.ts +35 -0
  46. package/src/toolkit/scaffold/workers.ts +13 -5
  47. package/src/toolkit/scaffold.ts +63 -1
@@ -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
+ }
@@ -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;
@@ -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 +
@@ -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, and
114
- * not a duplicate of one already planned. `declaredTypeIds` are the manifest `types` ids — an
115
- * envelope `in`/`out` is typed only when it names one (matching the worker-io deriver).
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;
@@ -11,12 +11,16 @@ 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
+ externalTaskTypes?: string[];
23
+ api?: { spec?: string; dir?: string };
20
24
  }
21
25
 
22
26
  export interface ScaffoldOptions {
@@ -61,7 +65,12 @@ export async function scaffoldWorkers(opts: ScaffoldOptions): Promise<ScaffoldRu
61
65
  const models = await readModels(root, io, manifest);
62
66
  const declaredTypeIds = Object.keys(manifest.types ?? {});
63
67
 
64
- const { plans, skipped } = planWorkerScaffold(models, manifest.workers ?? [], declaredTypeIds);
68
+ const { plans, skipped } = planWorkerScaffold(
69
+ models,
70
+ manifest.workers ?? [],
71
+ declaredTypeIds,
72
+ manifest.externalTaskTypes ?? [],
73
+ );
65
74
 
66
75
  const outcomes: StubOutcome[] = [];
67
76
  const wired: StubManifestEntry[] = [];
@@ -102,3 +111,56 @@ export async function scaffoldWorkers(opts: ScaffoldOptions): Promise<ScaffoldRu
102
111
 
103
112
  return { outcomes, skipped, wired, manifestPatched, write };
104
113
  }
114
+
115
+ /** One planned operation-delegate outcome (write-once). */
116
+ export interface OperationStubOutcome {
117
+ operationId: string;
118
+ handlerPath: string;
119
+ status: StubStatus;
120
+ }
121
+
122
+ export interface OperationScaffoldRun {
123
+ outcomes: OperationStubOutcome[];
124
+ write: boolean;
125
+ }
126
+
127
+ /**
128
+ * Scaffold write-once operation-delegate stubs from the app's OpenAPI spec (ADR 0059). One typed
129
+ * `defineOperation` stub per declared `operationId`, created only if absent (human-owned files are
130
+ * kept verbatim). Dry-run by default. A no-op when the app declares no `api.spec`.
131
+ */
132
+ export async function scaffoldOperations(opts: ScaffoldOptions): Promise<OperationScaffoldRun> {
133
+ const { root, io } = opts;
134
+ const write = opts.write ?? false;
135
+ const manifestFile = opts.manifestFile ?? "nano.app.json";
136
+ const manifestPath = joinPath(root, manifestFile);
137
+
138
+ const manifest: ScaffoldManifest = JSON.parse(await io.readText(manifestPath));
139
+ const specRef = typeof manifest.api?.spec === "string" ? manifest.api.spec.trim() : "";
140
+ if (specRef.length === 0) return { outcomes: [], write };
141
+
142
+ const specText = await io.readText(joinPath(root, specRef));
143
+ const dir =
144
+ typeof manifest.api?.dir === "string" && manifest.api.dir.trim().length > 0
145
+ ? manifest.api.dir.trim()
146
+ : undefined;
147
+ const plans = planOperationScaffold(parseSpec(specText), dir);
148
+
149
+ const outcomes: OperationStubOutcome[] = [];
150
+ for (const plan of plans) {
151
+ const abs = joinPath(root, plan.handlerPath);
152
+ const exists = await io.exists(abs);
153
+ let status: StubStatus;
154
+ if (exists) {
155
+ status = "kept"; // human-owned — never clobber
156
+ } else if (write) {
157
+ await io.writeText(abs, plan.stub);
158
+ status = "created";
159
+ } else {
160
+ status = "would-create";
161
+ }
162
+ outcomes.push({ operationId: plan.operationId, handlerPath: plan.handlerPath, status });
163
+ }
164
+
165
+ return { outcomes, write };
166
+ }