@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
@@ -98,7 +98,10 @@ async function collectAll(opts) {
98
98
  const specRef = typeof manifest.api?.spec === "string" ? manifest.api.spec.trim() : "";
99
99
  if (!opts.modelsOnly && specRef.length > 0) {
100
100
  const specText = await io.readText(join(root, specRef));
101
- artifacts.push(...deriveApi(parseSpec(specText)));
101
+ const opsDir = typeof manifest.api?.dir === "string" && manifest.api.dir.trim().length > 0
102
+ ? manifest.api.dir.trim()
103
+ : undefined;
104
+ artifacts.push(...deriveApi(parseSpec(specText), opsDir));
102
105
  }
103
106
  return { artifacts: sortArtifacts(artifacts), derived };
104
107
  }
@@ -0,0 +1,27 @@
1
+ import type { GenOptions, GenResult } from "./gen.ts";
2
+ import type { OperationStubOutcome, StubOutcome } from "./scaffold.ts";
3
+ import type { StubManifestEntry } from "./scaffold/workers.ts";
4
+ export interface GenerateResult extends GenResult {
5
+ /** Worker-stub scaffolding outcomes (created / kept / would-create). */
6
+ workerStubs: StubOutcome[];
7
+ /** Operation-delegate scaffolding outcomes (created / kept / would-create). */
8
+ operationStubs: OperationStubOutcome[];
9
+ /** Worker manifest entries wired into `workers[]` (write mode only). */
10
+ wiredWorkers: StubManifestEntry[];
11
+ /** True when `workers[]` was patched (write mode, unwired workers present). */
12
+ manifestPatched: boolean;
13
+ /**
14
+ * App-relative paths of stubs that would be created — the scaffolding equivalent of `drift`.
15
+ * Populated in `check` mode (nothing is written); a caller should fail CI when non-empty.
16
+ */
17
+ missingStubs: string[];
18
+ }
19
+ /**
20
+ * Derive artifacts and scaffold the write-once handler stubs they reference, in one pass.
21
+ * In `check` mode nothing is written and any would-be-created stub is reported in `missingStubs`.
22
+ * Scaffolding is skipped when derivation is `incomplete` (never scaffold from a half-derived model);
23
+ * the caller reports the derivation failure first.
24
+ */
25
+ export declare function generate(opts: GenOptions & {
26
+ check?: boolean;
27
+ }): Promise<GenerateResult>;
@@ -0,0 +1,47 @@
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
+ import { runGen } from "./gen.js";
12
+ import { scaffoldOperations, scaffoldWorkers } from "./scaffold.js";
13
+ /**
14
+ * Derive artifacts and scaffold the write-once handler stubs they reference, in one pass.
15
+ * In `check` mode nothing is written and any would-be-created stub is reported in `missingStubs`.
16
+ * Scaffolding is skipped when derivation is `incomplete` (never scaffold from a half-derived model);
17
+ * the caller reports the derivation failure first.
18
+ */
19
+ export async function generate(opts) {
20
+ const res = await runGen(opts);
21
+ const empty = {
22
+ ...res,
23
+ workerStubs: [],
24
+ operationStubs: [],
25
+ wiredWorkers: [],
26
+ manifestPatched: false,
27
+ missingStubs: [],
28
+ };
29
+ if (res.incomplete)
30
+ return empty;
31
+ const write = !opts.check;
32
+ const scaffoldOpts = { root: opts.root, io: opts.io, manifestFile: opts.manifestFile, write };
33
+ const workers = await scaffoldWorkers(scaffoldOpts);
34
+ const operations = await scaffoldOperations(scaffoldOpts);
35
+ const missingStubs = [
36
+ ...workers.outcomes.filter((o) => o.status === "would-create").map((o) => o.handlerPath),
37
+ ...operations.outcomes.filter((o) => o.status === "would-create").map((o) => o.handlerPath),
38
+ ];
39
+ return {
40
+ ...res,
41
+ workerStubs: workers.outcomes,
42
+ operationStubs: operations.outcomes,
43
+ wiredWorkers: workers.wired,
44
+ manifestPatched: workers.manifestPatched,
45
+ missingStubs,
46
+ };
47
+ }
@@ -13,7 +13,7 @@ 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";
16
+ export { apiDeriver, deriveApi, emitApiBindings, emitApiBindingsRuntime, emitApiController, schemaToTs, API_BINDINGS_DTS, API_BINDINGS_TS, API_CONTROLLER_TS, } from "./derivers/api.ts";
17
17
  export { collectOperations, HTTP_METHODS, isSafeOperationId, operationsWithoutId, operationsWithUnsafeId, parseSpec, refName, resolveSchema, toRouteMatcher, undeclaredPathParams, validateValue, } from "../openapi/spec.ts";
18
18
  export type { HttpMethodLower, OpenApiDoc, OpenApiParameter, OpenApiSchema, OperationInfo, } from "../openapi/spec.ts";
19
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<{
@@ -26,9 +26,12 @@ export declare const DERIVERS: readonly [import("./artifact.ts").Deriver<import(
26
26
  declaredTypeIds: Iterable<string>;
27
27
  }>, import("./artifact.ts").Deriver<{
28
28
  doc: import("../openapi/spec.ts").OpenApiDoc;
29
+ operationsDir?: string;
29
30
  }>];
30
31
  export { collectArtifacts, runGen, joinPath, readModels, expandPattern, previewModels } from "./gen.ts";
31
32
  export type { GenIO, GenOptions, GenResult } from "./gen.ts";
33
+ export { generate } from "./generate.ts";
34
+ export type { GenerateResult } from "./generate.ts";
32
35
  export { createNodeGenIO } from "./fsio.ts";
33
36
  export { deriveModels, isWorkflow, bpmnFilename, processesOutDir, DEFAULT_WORKFLOW_PATTERNS, PROCESSES_DIR, MODEL_PROVENANCE, } from "./models.ts";
34
37
  export type { DerivedModels, DerivedModelInfo, ModelError, ModelsManifest } from "./models.ts";
@@ -36,5 +39,9 @@ export { planWorkerScaffold, renderWorkerStub, slugifyTaskType, } from "./scaffo
36
39
  export type { ScaffoldWorker, SkippedWorker, SkipReason, StubManifestEntry, WorkerScaffoldPlan, WorkerStubPlan, } from "./scaffold/workers.ts";
37
40
  export { scaffoldWorkers } from "./scaffold.ts";
38
41
  export type { ScaffoldOptions, ScaffoldRun, StubOutcome, StubStatus } from "./scaffold.ts";
42
+ export { planOperationScaffold, renderOperationStub } from "./scaffold/operations.ts";
43
+ export type { OperationStubPlan } from "./scaffold/operations.ts";
44
+ export { scaffoldOperations } from "./scaffold.ts";
45
+ export type { OperationScaffoldRun, OperationStubOutcome } from "./scaffold.ts";
39
46
  export { addConnector } from "./addConnector.ts";
40
47
  export type { AddConnectorOptions, AddConnectorResult } from "./addConnector.ts";
@@ -11,7 +11,7 @@ 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";
14
+ export { apiDeriver, deriveApi, emitApiBindings, emitApiBindingsRuntime, emitApiController, schemaToTs, API_BINDINGS_DTS, API_BINDINGS_TS, API_CONTROLLER_TS, } from "./derivers/api.js";
15
15
  // The OpenAPI machinery the deriver + runtime share (ADR 0058).
16
16
  export { collectOperations, HTTP_METHODS, isSafeOperationId, operationsWithoutId, operationsWithUnsafeId, parseSpec, refName, resolveSchema, toRouteMatcher, undeclaredPathParams, validateValue, } from "../openapi/spec.js";
17
17
  // The registry of all derivers (for discovery / IDE migration).
@@ -31,10 +31,14 @@ export const DERIVERS = [
31
31
  ];
32
32
  // Gen orchestrator + IO
33
33
  export { collectArtifacts, runGen, joinPath, readModels, expandPattern, previewModels } from "./gen.js";
34
+ export { generate } from "./generate.js";
34
35
  export { createNodeGenIO } from "./fsio.js";
35
36
  // Code-first model derivation (workflows/*.ts → BPMN)
36
37
  export { deriveModels, isWorkflow, bpmnFilename, processesOutDir, DEFAULT_WORKFLOW_PATTERNS, PROCESSES_DIR, MODEL_PROVENANCE, } from "./models.js";
37
38
  // Worker-stub scaffolder (ADR 0056): write-once handler stubs from the model.
38
39
  export { planWorkerScaffold, renderWorkerStub, slugifyTaskType, } from "./scaffold/workers.js";
39
40
  export { scaffoldWorkers } from "./scaffold.js";
41
+ // Operation-delegate scaffolder (ADR 0059): write-once typed delegate stubs from the OpenAPI spec.
42
+ export { planOperationScaffold, renderOperationStub } from "./scaffold/operations.js";
43
+ export { scaffoldOperations } from "./scaffold.js";
40
44
  export { addConnector } from "./addConnector.js";
@@ -0,0 +1,21 @@
1
+ import { DEFAULT_OPERATIONS_DIR } from "../api-dir.ts";
2
+ import { type OpenApiDoc } from "../../openapi/spec.ts";
3
+ export { DEFAULT_OPERATIONS_DIR };
4
+ /** One planned operation-delegate stub. */
5
+ export interface OperationStubPlan {
6
+ operationId: string;
7
+ /** App-relative delegate path: `<dir>/<operationId>.ts`. */
8
+ handlerPath: string;
9
+ /** The stub file contents (a typed `defineOperation` that throws `NotImplemented` → HTTP 501). */
10
+ stub: string;
11
+ }
12
+ /** Render a stub matching a real hand-authored operation delegate (default-exported, typed via the
13
+ * generated `defineOperation`). It throws `NotImplemented` so the endpoint returns 501 until the
14
+ * body is implemented — never a silent 500, never a drift from the contract. */
15
+ export declare function renderOperationStub(operationId: string, dir?: string): string;
16
+ /**
17
+ * Plan write-once delegate stubs from the spec: one per declared `operationId` (collectOperations
18
+ * already skips ops without a safe id). The planner emits a plan for every operation; the impure
19
+ * edge (`scaffoldOperations`) keeps an existing file verbatim and creates only the absent ones.
20
+ */
21
+ export declare function planOperationScaffold(doc: OpenApiDoc, dir?: string): OperationStubPlan[];
@@ -0,0 +1,55 @@
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
+ import { DEFAULT_OPERATIONS_DIR, normalizeApiDir } from "../api-dir.js";
10
+ import { assertUniqueOperationIds, collectOperations } from "../../openapi/spec.js";
11
+ export { DEFAULT_OPERATIONS_DIR };
12
+ /** Normalize an `api.dir` to a clean relative segment (see `normalizeApiDir`); rejects absolute
13
+ * paths and `..` segments so stubs are scaffolded where the runtime resolves delegates from. */
14
+ function normalizeDir(dir) {
15
+ return normalizeApiDir(dir);
16
+ }
17
+ /** Number of `../` hops from `<dir>/<file>.ts` back up to the app root (dir may be nested). */
18
+ function upToRoot(dir) {
19
+ const depth = dir.split("/").filter((s) => s.length > 0).length;
20
+ return "../".repeat(depth);
21
+ }
22
+ /** Render a stub matching a real hand-authored operation delegate (default-exported, typed via the
23
+ * generated `defineOperation`). It throws `NotImplemented` so the endpoint returns 501 until the
24
+ * body is implemented — never a silent 500, never a drift from the contract. */
25
+ export function renderOperationStub(operationId, dir = DEFAULT_OPERATIONS_DIR) {
26
+ const up = upToRoot(normalizeDir(dir));
27
+ const key = JSON.stringify(operationId);
28
+ return (`// Operation delegate stub for the \`${operationId}\` endpoint, scaffolded from the OpenAPI spec\n` +
29
+ `// (ADR 0059). This file is yours to edit — \`urban gen\` will never overwrite it. The\n` +
30
+ `// generated controller (nano-generated/controller.ts) imports this default export and\n` +
31
+ `// type-checks it against the spec, so the signature cannot drift. Implement the body below\n` +
32
+ `// (the validated \`{ req, params, query, body }\` input + the injected \`app\`) and delete the throw.\n` +
33
+ `import { NotImplemented } from "@nanobpm/urban";\n` +
34
+ `import { defineOperation } from ${JSON.stringify(`${up}nano-generated/operations.ts`)};\n` +
35
+ `\n` +
36
+ `export default defineOperation(${key}, async (input, app) => {\n` +
37
+ ` // \`input\` is the validated request (params/query/body/req); reach app state + services\n` +
38
+ ` // through the injected \`app\` API. Return \`{ status?, headers?, body? }\` (or nothing → 204).\n` +
39
+ ` throw new NotImplemented(${key});\n` +
40
+ `});\n`);
41
+ }
42
+ /**
43
+ * Plan write-once delegate stubs from the spec: one per declared `operationId` (collectOperations
44
+ * already skips ops without a safe id). The planner emits a plan for every operation; the impure
45
+ * edge (`scaffoldOperations`) keeps an existing file verbatim and creates only the absent ones.
46
+ */
47
+ export function planOperationScaffold(doc, dir = DEFAULT_OPERATIONS_DIR) {
48
+ assertUniqueOperationIds(doc);
49
+ const cleanDir = normalizeDir(dir);
50
+ return collectOperations(doc).map((op) => ({
51
+ operationId: op.operationId,
52
+ handlerPath: `${cleanDir}/${op.operationId}.ts`,
53
+ stub: renderOperationStub(op.operationId, cleanDir),
54
+ }));
55
+ }
@@ -41,7 +41,7 @@ export function renderWorkerStub(taskType, typedIn, typedOut) {
41
41
  ? ` // \`job.variables\` is typed as WorkerInputs[${key}].`
42
42
  : ` // \`job.variables\` is an open Record<string, unknown> (no declared input type).`;
43
43
  return (`// Handler stub for the \`${taskType}\` service task, scaffolded from the model (ADR 0056).\n` +
44
- `// This file is yours to edit — \`urban stubs\` will never overwrite it. Implement the body\n` +
44
+ `// This file is yours to edit — \`urban gen\` will never overwrite it. Implement the body\n` +
45
45
  `// below (use \`app.data.table(...)\` for state) and delete the throw.\n` +
46
46
  `import type { AppJobHandler } from "@nanobpm/urban/worker";\n` +
47
47
  generatedImport +
@@ -29,3 +29,19 @@ export interface ScaffoldRun {
29
29
  }
30
30
  /** Scaffold write-once worker stubs from the model, wiring them into `manifest.workers[]`. */
31
31
  export declare function scaffoldWorkers(opts: ScaffoldOptions): Promise<ScaffoldRun>;
32
+ /** One planned operation-delegate outcome (write-once). */
33
+ export interface OperationStubOutcome {
34
+ operationId: string;
35
+ handlerPath: string;
36
+ status: StubStatus;
37
+ }
38
+ export interface OperationScaffoldRun {
39
+ outcomes: OperationStubOutcome[];
40
+ write: boolean;
41
+ }
42
+ /**
43
+ * Scaffold write-once operation-delegate stubs from the app's OpenAPI spec (ADR 0059). One typed
44
+ * `defineOperation` stub per declared `operationId`, created only if absent (human-owned files are
45
+ * kept verbatim). Dry-run by default. A no-op when the app declares no `api.spec`.
46
+ */
47
+ export declare function scaffoldOperations(opts: ScaffoldOptions): Promise<OperationScaffoldRun>;
@@ -4,6 +4,8 @@
4
4
  // human-owned, so an existing file is KEPT verbatim, never clobbered (the opposite of `runGen`).
5
5
  import { joinPath, readModels } from "./gen.js";
6
6
  import { planWorkerScaffold, } from "./scaffold/workers.js";
7
+ import { planOperationScaffold } from "./scaffold/operations.js";
8
+ import { parseSpec } from "../openapi/spec.js";
7
9
  /** Scaffold write-once worker stubs from the model, wiring them into `manifest.workers[]`. */
8
10
  export async function scaffoldWorkers(opts) {
9
11
  const { root, io } = opts;
@@ -52,3 +54,41 @@ export async function scaffoldWorkers(opts) {
52
54
  }
53
55
  return { outcomes, skipped, wired, manifestPatched, write };
54
56
  }
57
+ /**
58
+ * Scaffold write-once operation-delegate stubs from the app's OpenAPI spec (ADR 0059). One typed
59
+ * `defineOperation` stub per declared `operationId`, created only if absent (human-owned files are
60
+ * kept verbatim). Dry-run by default. A no-op when the app declares no `api.spec`.
61
+ */
62
+ export async function scaffoldOperations(opts) {
63
+ const { root, io } = opts;
64
+ const write = opts.write ?? false;
65
+ const manifestFile = opts.manifestFile ?? "nano.app.json";
66
+ const manifestPath = joinPath(root, manifestFile);
67
+ const manifest = JSON.parse(await io.readText(manifestPath));
68
+ const specRef = typeof manifest.api?.spec === "string" ? manifest.api.spec.trim() : "";
69
+ if (specRef.length === 0)
70
+ return { outcomes: [], write };
71
+ const specText = await io.readText(joinPath(root, specRef));
72
+ const dir = typeof manifest.api?.dir === "string" && manifest.api.dir.trim().length > 0
73
+ ? manifest.api.dir.trim()
74
+ : undefined;
75
+ const plans = planOperationScaffold(parseSpec(specText), dir);
76
+ const outcomes = [];
77
+ for (const plan of plans) {
78
+ const abs = joinPath(root, plan.handlerPath);
79
+ const exists = await io.exists(abs);
80
+ let status;
81
+ if (exists) {
82
+ status = "kept"; // human-owned — never clobber
83
+ }
84
+ else if (write) {
85
+ await io.writeText(abs, plan.stub);
86
+ status = "created";
87
+ }
88
+ else {
89
+ status = "would-create";
90
+ }
91
+ outcomes.push({ operationId: plan.operationId, handlerPath: plan.handlerPath, status });
92
+ }
93
+ return { outcomes, write };
94
+ }
package/package.json CHANGED
@@ -1,76 +1,76 @@
1
1
  {
2
- "name": "@nanobpm/urban",
3
- "version": "0.35.0",
4
- "description": "Urban: build and run code-first apps on Nano \u2014 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
- "license": "Apache-2.0",
6
- "type": "module",
7
- "keywords": [
8
- "nanobpm",
9
- "urban",
10
- "cli",
11
- "runtime",
12
- "codegen",
13
- "derivation",
14
- "nano-app",
15
- "rad"
16
- ],
17
- "publishConfig": {
18
- "access": "public"
19
- },
20
- "repository": {
21
- "type": "git",
22
- "url": "git+https://github.com/nanobpm/nano-ide.git",
23
- "directory": "packages/urban"
24
- },
25
- "engines": {
26
- "node": ">=22.6"
27
- },
28
- "bin": {
29
- "urban": "src/bin.mjs"
30
- },
31
- "main": "./dist/index.js",
32
- "types": "./dist/index.d.ts",
33
- "exports": {
34
- ".": {
35
- "types": "./dist/index.d.ts",
36
- "default": "./dist/index.js"
37
- },
38
- "./runtime": {
39
- "types": "./dist/runtime/index.d.ts",
40
- "default": "./dist/runtime/index.js"
41
- },
42
- "./toolkit": {
43
- "types": "./dist/toolkit/index.d.ts",
44
- "default": "./dist/toolkit/index.js"
45
- },
46
- "./worker": {
47
- "types": "./dist/worker.d.ts",
48
- "default": "./dist/worker.js"
49
- },
50
- "./source": "./src/index.ts"
51
- },
52
- "files": [
53
- "dist",
54
- "src",
55
- "README.md"
56
- ],
57
- "scripts": {
58
- "build": "tsc -p tsconfig.build.json",
59
- "prepack": "npm run build",
60
- "typecheck": "tsc --noEmit -p tsconfig.json",
61
- "test": "node --test --experimental-strip-types \"src/**/*.test.ts\"",
62
- "test:deno": "deno test -A --no-check src/"
63
- },
64
- "dependencies": {
65
- "@nanobpm/nano-app-schema": "^0.4.0",
66
- "@nanobpm/nano-sdk": "^1.2.3",
67
- "@nanobpm/workflow": "^0.8.0",
68
- "bpmn-auto-layout": "^2.0.0-alpha.2",
69
- "create-urban-app": "^0.9.0",
70
- "yaml": "^2.9.0"
71
- },
72
- "devDependencies": {
73
- "@types/node": "^22.20.1",
74
- "typescript": "^5.9.3"
75
- }
2
+ "name": "@nanobpm/urban",
3
+ "version": "0.36.0",
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
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "keywords": [
8
+ "nanobpm",
9
+ "urban",
10
+ "cli",
11
+ "runtime",
12
+ "codegen",
13
+ "derivation",
14
+ "nano-app",
15
+ "rad"
16
+ ],
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/nanobpm/nano-ide.git",
23
+ "directory": "packages/urban"
24
+ },
25
+ "engines": {
26
+ "node": ">=22.6"
27
+ },
28
+ "bin": {
29
+ "urban": "src/bin.mjs"
30
+ },
31
+ "main": "./dist/index.js",
32
+ "types": "./dist/index.d.ts",
33
+ "exports": {
34
+ ".": {
35
+ "types": "./dist/index.d.ts",
36
+ "default": "./dist/index.js"
37
+ },
38
+ "./runtime": {
39
+ "types": "./dist/runtime/index.d.ts",
40
+ "default": "./dist/runtime/index.js"
41
+ },
42
+ "./toolkit": {
43
+ "types": "./dist/toolkit/index.d.ts",
44
+ "default": "./dist/toolkit/index.js"
45
+ },
46
+ "./worker": {
47
+ "types": "./dist/worker.d.ts",
48
+ "default": "./dist/worker.js"
49
+ },
50
+ "./source": "./src/index.ts"
51
+ },
52
+ "files": [
53
+ "dist",
54
+ "src",
55
+ "README.md"
56
+ ],
57
+ "scripts": {
58
+ "build": "tsc -p tsconfig.build.json",
59
+ "prepack": "npm run build",
60
+ "typecheck": "tsc --noEmit -p tsconfig.json",
61
+ "test": "node --test --experimental-strip-types \"src/**/*.test.ts\"",
62
+ "test:deno": "deno test -A --no-check src/"
63
+ },
64
+ "dependencies": {
65
+ "@nanobpm/nano-app-schema": "^0.4.0",
66
+ "@nanobpm/nano-sdk": "^1.2.3",
67
+ "@nanobpm/workflow": "^0.8.0",
68
+ "bpmn-auto-layout": "^2.0.0-alpha.2",
69
+ "create-urban-app": "^0.9.0",
70
+ "yaml": "^2.9.0"
71
+ },
72
+ "devDependencies": {
73
+ "@types/node": "^22.20.1",
74
+ "typescript": "^5.9.3"
75
+ }
76
76
  }
package/src/cli.test.ts CHANGED
@@ -14,7 +14,6 @@ test("urban data exits 0 with a parseable envelope on an unreadable request", as
14
14
  root: ".",
15
15
  manifest: "nano.app.json",
16
16
  check: false,
17
- write: false,
18
17
  deno: false,
19
18
  models: true,
20
19
  stdout: false,
package/src/cli.ts CHANGED
@@ -25,7 +25,7 @@ import { denoGlobal, processGlobal } from "./runtime/adapters/globals.ts";
25
25
  import { errorMessage, isRecord } from "./runtime/core/guards.ts";
26
26
  import { scaffold, slugify } from "create-urban-app";
27
27
  import type { DataRequest } from "./runtime/index.ts";
28
- import { addConnector, createNodeGenIO, previewModels, runGen, scaffoldWorkers } from "./toolkit/index.ts";
28
+ import { addConnector, createNodeGenIO, previewModels, generate, runGen } from "./toolkit/index.ts";
29
29
  import { pathToFileURL } from "node:url";
30
30
  import { readFileSync } from "node:fs";
31
31
  import { spawnSync } from "node:child_process";
@@ -50,7 +50,6 @@ interface Flags {
50
50
  manifest: string;
51
51
  port?: number;
52
52
  check: boolean;
53
- write: boolean;
54
53
  deno: boolean;
55
54
  models: boolean;
56
55
  stdout: boolean;
@@ -62,7 +61,7 @@ interface Flags {
62
61
  }
63
62
 
64
63
  function parse(argv: string[]): Flags {
65
- const f: Flags = { root: ".", manifest: "nano.app.json", check: false, write: false, deno: false, models: true, stdout: false, install: true, help: false, version: false, _: [] };
64
+ const f: Flags = { root: ".", manifest: "nano.app.json", check: false, deno: false, models: true, stdout: false, install: true, help: false, version: false, _: [] };
66
65
  const need = (i: number, flag: string): string => {
67
66
  const v = argv[i];
68
67
  if (v === undefined || v.startsWith("-")) throw new Error(`flag ${flag} requires a value`);
@@ -73,7 +72,6 @@ function parse(argv: string[]): Flags {
73
72
  if (a === "-h" || a === "--help") f.help = true;
74
73
  else if (a === "-v" || a === "--version") f.version = true;
75
74
  else if (a === "--check") f.check = true;
76
- else if (a === "--write") f.write = true;
77
75
  else if (a === "--deno") f.deno = true;
78
76
  else if (a === "--no-models") f.models = false;
79
77
  else if (a === "--models") f.models = true;
@@ -108,11 +106,12 @@ Usage:
108
106
  urban new <name> [--root <path>] [--deno] [--style model|code] scaffold a new Urban app
109
107
  (--code-first is shorthand for --style code)
110
108
  urban check validate the manifest
111
- urban gen [--check] [--no-models] derive artifacts (migrations, worker-io, + code-first models)
109
+ urban gen [--check] [--no-models] derive artifacts + scaffold write-once handler stubs
110
+ (--check: verify up-to-date without writing; a missing stub or
111
+ drifted artifact fails — run \`urban gen\`)
112
112
  (--no-models: skip writing derived .bpmn; type-contracts only)
113
113
  urban derive [--check|--stdout] derive code-first models only (workflows/*.ts → processes/*.bpmn)
114
114
  (--stdout: print {models,incomplete} JSON without writing)
115
- urban stubs [--write] scaffold write-once handler stubs from the model
116
115
  urban add <pkg> [--no-install] install a connector pack + wire it into nano.app.json
117
116
  urban run materialize + serve the app
118
117
  urban dev run + watch sources, hot-reload on change
@@ -148,23 +147,43 @@ async function cmdCheck(f: Flags): Promise<number> {
148
147
 
149
148
  async function cmdGen(f: Flags): Promise<number> {
150
149
  const io = createNodeGenIO();
151
- const res = await runGen({ root: f.root, io, manifestFile: f.manifest, check: f.check, emitModels: f.models });
150
+ const res = await generate({ root: f.root, io, manifestFile: f.manifest, check: f.check, emitModels: f.models });
152
151
  if (res.incomplete) {
153
152
  console.error(`✖ ${res.modelErrors.length} workflow(s) failed to derive:`);
154
153
  for (const e of res.modelErrors) console.error(` • ${e.path}: ${e.message}`);
155
154
  return 1;
156
155
  }
157
156
  if (f.check) {
158
- if (res.drift.length === 0) {
159
- console.log(`✔ generated artifacts are up to date (${res.artifacts.length} checked)`);
157
+ if (res.drift.length === 0 && res.missingStubs.length === 0) {
158
+ console.log(`✔ generated artifacts + handler stubs are up to date (${res.artifacts.length} checked)`);
160
159
  return 0;
161
160
  }
162
- console.error(`✖ ${res.drift.length} generated artifact(s) are out of date — run \`urban gen\`:`);
163
- for (const p of res.drift) console.error(` • ${p}`);
161
+ if (res.drift.length > 0) {
162
+ console.error(`✖ ${res.drift.length} generated artifact(s) are out of date — run \`urban gen\`:`);
163
+ for (const p of res.drift) console.error(` • ${p}`);
164
+ }
165
+ if (res.missingStubs.length > 0) {
166
+ console.error(`✖ ${res.missingStubs.length} handler stub(s) missing — run \`urban gen\`:`);
167
+ for (const p of res.missingStubs) console.error(` • ${p}`);
168
+ }
164
169
  return 1;
165
170
  }
166
171
  console.log(`✔ generated ${res.artifacts.length} artifact(s):`);
167
172
  for (const a of res.artifacts) console.log(` • ${a.path}`);
173
+
174
+ // Write-once handler stubs scaffolded as part of generation (workers from the model, operation
175
+ // delegates from the OpenAPI spec). Existing, human-owned stubs are kept verbatim.
176
+ const created = [
177
+ ...res.workerStubs.filter((o) => o.status === "created").map((o) => o.handlerPath),
178
+ ...res.operationStubs.filter((o) => o.status === "created").map((o) => o.handlerPath),
179
+ ];
180
+ if (created.length > 0) {
181
+ console.log(`✔ scaffolded ${created.length} handler stub(s):`);
182
+ for (const p of created) console.log(` + ${p}`);
183
+ }
184
+ if (res.manifestPatched) {
185
+ console.log(`✔ wired ${res.wiredWorkers.length} worker(s) into ${f.manifest}`);
186
+ }
168
187
  return 0;
169
188
  }
170
189
 
@@ -217,38 +236,6 @@ async function cmdDerive(f: Flags): Promise<number> {
217
236
  return 0;
218
237
  }
219
238
 
220
- async function cmdStubs(f: Flags): Promise<number> {
221
- const io = createNodeGenIO();
222
- const run = await scaffoldWorkers({ root: f.root, io, manifestFile: f.manifest, write: f.write });
223
-
224
- const created = run.outcomes.filter((o) => o.status === "created");
225
- const would = run.outcomes.filter((o) => o.status === "would-create");
226
- const kept = run.outcomes.filter((o) => o.status === "kept");
227
- const typing = (o: { typedIn: boolean; typedOut: boolean }) =>
228
- o.typedIn || o.typedOut ? ` (typed${o.typedIn ? " in" : ""}${o.typedOut ? " out" : ""})` : "";
229
-
230
- if (run.write) {
231
- console.log(`✔ scaffolded ${created.length} worker stub(s):`);
232
- for (const o of created) console.log(` + ${o.handlerPath}${typing(o)}`);
233
- if (run.manifestPatched) {
234
- console.log(`✔ wired ${run.wired.length} worker(s) into ${f.manifest}`);
235
- }
236
- } else {
237
- if (would.length === 0) {
238
- console.log(`✔ no new worker stubs to scaffold`);
239
- } else {
240
- console.log(`Would scaffold ${would.length} worker stub(s) — run with --write to apply:`);
241
- for (const o of would) console.log(` + ${o.handlerPath}${typing(o)}`);
242
- }
243
- }
244
- for (const o of kept) console.log(` = ${o.handlerPath} (kept — already exists)`);
245
- if (run.skipped.length > 0) {
246
- console.log(` skipped ${run.skipped.length}: ` +
247
- run.skipped.map((s) => `${s.taskType} (${s.reason})`).join(", "));
248
- }
249
- return 0;
250
- }
251
-
252
239
  async function cmdRun(f: Flags, mount?: Record<string, boolean>): Promise<number> {
253
240
  const app = await runFromEnv({
254
241
  root: f.root,
@@ -430,8 +417,6 @@ export async function main(argv: string[]): Promise<number> {
430
417
  return cmdGen(f);
431
418
  case "derive":
432
419
  return cmdDerive(f);
433
- case "stubs":
434
- return cmdStubs(f);
435
420
  case "add":
436
421
  return cmdAdd(f);
437
422
  case "data":
@@ -265,6 +265,30 @@ export function collectOperations(doc: OpenApiDoc): OperationInfo[] {
265
265
  return out;
266
266
  }
267
267
 
268
+ /**
269
+ * Throw if the document declares the same `operationId` on more than one operation. An operationId
270
+ * must be unique across the whole OpenAPI document (it names the delegate module `<dir>/<operationId>`
271
+ * AND the generated controller registry key), so a duplicate would silently overwrite an earlier
272
+ * entry in the emitted object literal — dispatching the wrong delegate while still type-checking — and
273
+ * make the scaffolder plan the same stub twice. Called by the gen/scaffold path so `urban gen`
274
+ * fails loudly on an incoherent spec instead of shipping a mis-dispatching registry.
275
+ */
276
+ export function assertUniqueOperationIds(doc: OpenApiDoc): void {
277
+ const seen = new Set<string>();
278
+ const duplicates = new Set<string>();
279
+ for (const op of collectOperations(doc)) {
280
+ if (seen.has(op.operationId)) duplicates.add(op.operationId);
281
+ seen.add(op.operationId);
282
+ }
283
+ if (duplicates.size > 0) {
284
+ const list = [...duplicates].sort().join(", ");
285
+ throw new Error(
286
+ `OpenAPI spec declares duplicate operationId(s): ${list}. Each operationId must be unique — ` +
287
+ "it names the delegate module and the generated controller registry key.",
288
+ );
289
+ }
290
+ }
291
+
268
292
  /** "method path" pointers for every operation that declares no `operationId`. Such operations are
269
293
  * unroutable (no delegate to bind), so the runtime mount logs them at `warn` and skips them. */
270
294
  export function operationsWithoutId(doc: OpenApiDoc): string[] {