@nanobpm/urban 0.14.0 → 0.16.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/cli.js CHANGED
@@ -11,7 +11,7 @@
11
11
  // --port <n>, -h/--help, -v/--version.
12
12
  import { collectManifestIssues, installSignalHandlers, loadManifest, runDev, runFromEnv, selectHost, } from "./runtime/index.js";
13
13
  import { scaffold, slugify } from "create-urban-app";
14
- import { createNodeGenIO, runGen } from "./toolkit/index.js";
14
+ import { createNodeGenIO, runGen, scaffoldWorkers } from "./toolkit/index.js";
15
15
  import { pathToFileURL } from "node:url";
16
16
  import { readFileSync } from "node:fs";
17
17
  // Read the package version from package.json (one hop up from both src/cli.ts and the
@@ -27,7 +27,7 @@ function readVersion() {
27
27
  }
28
28
  const VERSION = readVersion();
29
29
  function parse(argv) {
30
- const f = { root: ".", manifest: "nano.app.json", check: false, deno: false, help: false, version: false, _: [] };
30
+ const f = { root: ".", manifest: "nano.app.json", check: false, write: false, deno: false, help: false, version: false, _: [] };
31
31
  const need = (i, flag) => {
32
32
  const v = argv[i];
33
33
  if (v === undefined || v.startsWith("-"))
@@ -42,8 +42,18 @@ function parse(argv) {
42
42
  f.version = true;
43
43
  else if (a === "--check")
44
44
  f.check = true;
45
+ else if (a === "--write")
46
+ f.write = true;
45
47
  else if (a === "--deno")
46
48
  f.deno = true;
49
+ else if (a === "--code-first")
50
+ f.style = "code";
51
+ else if (a === "--style") {
52
+ const v = need(++i, a);
53
+ if (v !== "model" && v !== "code")
54
+ throw new Error(`flag --style requires "model" or "code" (got "${v}")`);
55
+ f.style = v;
56
+ }
47
57
  else if (a === "--")
48
58
  continue; // tolerate a bare "--" some runners inject (not an option terminator here)
49
59
  else if (a === "--root")
@@ -68,9 +78,11 @@ function parse(argv) {
68
78
  const USAGE = `urban — build and run Urban apps (nano.app.json)
69
79
 
70
80
  Usage:
71
- urban new <name> [--root <path>] [--deno] scaffold a new Urban app
81
+ urban new <name> [--root <path>] [--deno] [--style model|code] scaffold a new Urban app
82
+ (--code-first is shorthand for --style code)
72
83
  urban check validate the manifest
73
84
  urban gen [--check] derive artifacts (migrations, worker-io)
85
+ urban stubs [--write] scaffold write-once handler stubs from the model
74
86
  urban run materialize + serve the app
75
87
  urban dev run + watch sources, hot-reload on change
76
88
  urban deploy deploy models only, then exit
@@ -117,6 +129,39 @@ async function cmdGen(f) {
117
129
  console.log(` • ${a.path}`);
118
130
  return 0;
119
131
  }
132
+ async function cmdStubs(f) {
133
+ const io = createNodeGenIO();
134
+ const run = await scaffoldWorkers({ root: f.root, io, manifestFile: f.manifest, write: f.write });
135
+ const created = run.outcomes.filter((o) => o.status === "created");
136
+ const would = run.outcomes.filter((o) => o.status === "would-create");
137
+ const kept = run.outcomes.filter((o) => o.status === "kept");
138
+ const typing = (o) => o.typedIn || o.typedOut ? ` (typed${o.typedIn ? " in" : ""}${o.typedOut ? " out" : ""})` : "";
139
+ if (run.write) {
140
+ console.log(`✔ scaffolded ${created.length} worker stub(s):`);
141
+ for (const o of created)
142
+ console.log(` + ${o.handlerPath}${typing(o)}`);
143
+ if (run.manifestPatched) {
144
+ console.log(`✔ wired ${run.wired.length} worker(s) into ${f.manifest}`);
145
+ }
146
+ }
147
+ else {
148
+ if (would.length === 0) {
149
+ console.log(`✔ no new worker stubs to scaffold`);
150
+ }
151
+ else {
152
+ console.log(`Would scaffold ${would.length} worker stub(s) — run with --write to apply:`);
153
+ for (const o of would)
154
+ console.log(` + ${o.handlerPath}${typing(o)}`);
155
+ }
156
+ }
157
+ for (const o of kept)
158
+ console.log(` = ${o.handlerPath} (kept — already exists)`);
159
+ if (run.skipped.length > 0) {
160
+ console.log(` skipped ${run.skipped.length}: ` +
161
+ run.skipped.map((s) => `${s.taskType} (${s.reason})`).join(", "));
162
+ }
163
+ return 0;
164
+ }
120
165
  async function cmdRun(f, mount) {
121
166
  const app = await runFromEnv({
122
167
  root: f.root,
@@ -147,14 +192,16 @@ async function cmdDeploy(f) {
147
192
  async function cmdNew(f) {
148
193
  const name = f._[1];
149
194
  if (!name) {
150
- console.error("usage: urban new <name> [--root <path>] [--deno]");
195
+ console.error("usage: urban new <name> [--root <path>] [--deno] [--style model|code] (or --code-first)");
151
196
  return 1;
152
197
  }
153
198
  const dir = f.root !== "." ? f.root : `./${slugify(name)}`;
154
- const res = await scaffold({ name, dir, deno: f.deno });
199
+ const res = await scaffold({ name, dir, deno: f.deno, style: f.style });
155
200
  console.log(`✔ scaffolded "${res.id}" in ${res.dir} (${res.files.length} files)`);
156
201
  console.log(` cd ${res.dir}`);
157
202
  console.log(` npm install && npm start`);
203
+ if (f.style === "code")
204
+ console.log(` npm run greet -- Adam # start a code-first instance`);
158
205
  if (f.deno)
159
206
  console.log(` # or on Deno: deno task start`);
160
207
  return 0;
@@ -185,6 +232,8 @@ export async function main(argv) {
185
232
  return cmdCheck(f);
186
233
  case "gen":
187
234
  return cmdGen(f);
235
+ case "stubs":
236
+ return cmdStubs(f);
188
237
  case "run":
189
238
  return cmdRun(f);
190
239
  case "dev":
@@ -1,4 +1,5 @@
1
1
  import type { DerivedArtifact } from "./artifact.ts";
2
+ import { type ModelSource } from "./derivers/worker-io.ts";
2
3
  /** Minimal filesystem port. Node/Deno impls live in `fsio.ts`. */
3
4
  export interface GenIO {
4
5
  readText(path: string): Promise<string>;
@@ -19,6 +20,12 @@ export interface GenResult {
19
20
  }
20
21
  declare function join(root: string, rel: string): string;
21
22
  declare function dirOf(p: string): string;
23
+ /** Read + resolve the app's process models from the manifest's `models.processes` patterns. */
24
+ export declare function readModels(root: string, io: GenIO, manifest: {
25
+ models?: {
26
+ processes?: string[];
27
+ };
28
+ }): Promise<ModelSource[]>;
22
29
  /** Collect all artifacts a run would produce, without touching disk beyond reads. */
23
30
  export declare function collectArtifacts(opts: GenOptions): Promise<DerivedArtifact[]>;
24
31
  /** Run the derivers and write artifacts (or, with `check`, report drift without writing). */
@@ -30,6 +30,17 @@ async function expandPattern(root, io, pattern) {
30
30
  .map((n) => (dir === "." ? n : `${dir}/${n}`))
31
31
  .sort();
32
32
  }
33
+ /** Read + resolve the app's process models from the manifest's `models.processes` patterns. */
34
+ export async function readModels(root, io, manifest) {
35
+ const procPatterns = manifest.models?.processes ?? [];
36
+ const models = [];
37
+ for (const pat of procPatterns) {
38
+ for (const rel of await expandPattern(root, io, pat)) {
39
+ models.push({ path: rel, xml: await io.readText(join(root, rel)) });
40
+ }
41
+ }
42
+ return models;
43
+ }
33
44
  /** Collect all artifacts a run would produce, without touching disk beyond reads. */
34
45
  export async function collectArtifacts(opts) {
35
46
  const { root, io } = opts;
@@ -41,13 +52,7 @@ export async function collectArtifacts(opts) {
41
52
  artifacts.push(...deriveMigrations(manifest));
42
53
  }
43
54
  // 2. models → worker I/O index
44
- const procPatterns = manifest.models?.processes ?? [];
45
- const models = [];
46
- for (const pat of procPatterns) {
47
- for (const rel of await expandPattern(root, io, pat)) {
48
- models.push({ path: rel, xml: await io.readText(join(root, rel)) });
49
- }
50
- }
55
+ const models = await readModels(root, io, manifest);
51
56
  if (models.length > 0) {
52
57
  const declaredTypeIds = Object.keys(manifest.types ?? {});
53
58
  artifacts.push(...deriveWorkerBindings(models, declaredTypeIds));
@@ -8,6 +8,10 @@ export declare const DERIVERS: readonly [import("./artifact.ts").Deriver<import(
8
8
  models: import("./derivers/worker-io.ts").ModelSource[];
9
9
  declaredTypeIds: Iterable<string>;
10
10
  }>];
11
- export { collectArtifacts, runGen, joinPath } from "./gen.ts";
11
+ export { collectArtifacts, runGen, joinPath, readModels } from "./gen.ts";
12
12
  export type { GenIO, GenOptions, GenResult } from "./gen.ts";
13
13
  export { createNodeGenIO } from "./fsio.ts";
14
+ export { planWorkerScaffold, renderWorkerStub, slugifyTaskType, } from "./scaffold/workers.ts";
15
+ export type { ScaffoldWorker, SkippedWorker, SkipReason, StubManifestEntry, WorkerScaffoldPlan, WorkerStubPlan, } from "./scaffold/workers.ts";
16
+ export { scaffoldWorkers } from "./scaffold.ts";
17
+ export type { ScaffoldOptions, ScaffoldRun, StubOutcome, StubStatus } from "./scaffold.ts";
@@ -11,5 +11,8 @@ import { migrationsDeriver } from "./derivers/migrations.js";
11
11
  import { workerIoDeriver } from "./derivers/worker-io.js";
12
12
  export const DERIVERS = [migrationsDeriver, workerIoDeriver];
13
13
  // Gen orchestrator + IO
14
- export { collectArtifacts, runGen, joinPath } from "./gen.js";
14
+ export { collectArtifacts, runGen, joinPath, readModels } from "./gen.js";
15
15
  export { createNodeGenIO } from "./fsio.js";
16
+ // Worker-stub scaffolder (ADR 0056): write-once handler stubs from the model.
17
+ export { planWorkerScaffold, renderWorkerStub, slugifyTaskType, } from "./scaffold/workers.js";
18
+ export { scaffoldWorkers } from "./scaffold.js";
@@ -0,0 +1,48 @@
1
+ import { type ModelSource } from "../derivers/worker-io.ts";
2
+ /** A worker to plan against: the manifest's already-wired workers. */
3
+ export interface ScaffoldWorker {
4
+ taskType: string;
5
+ handler?: string;
6
+ llm?: string;
7
+ }
8
+ /** The `workers[]` manifest entry a stub is wired in as. */
9
+ export interface StubManifestEntry {
10
+ taskType: string;
11
+ handler: string;
12
+ }
13
+ /** One planned handler stub. */
14
+ export interface WorkerStubPlan {
15
+ taskType: string;
16
+ process?: string;
17
+ elementId?: string;
18
+ /** Directory slug under `workers/` (unique across the plan). */
19
+ slug: string;
20
+ /** App-relative handler path: `workers/<slug>/worker.ts`. */
21
+ handlerPath: string;
22
+ /** True when the data-envelope `in`/`out` names a declared domain type (so it is typed). */
23
+ typedIn: boolean;
24
+ typedOut: boolean;
25
+ /** The stub file contents. */
26
+ stub: string;
27
+ manifestEntry: StubManifestEntry;
28
+ }
29
+ export type SkipReason = "already-wired" | "orchestrator" | "duplicate";
30
+ export interface SkippedWorker {
31
+ taskType: string;
32
+ reason: SkipReason;
33
+ }
34
+ export interface WorkerScaffoldPlan {
35
+ plans: WorkerStubPlan[];
36
+ skipped: SkippedWorker[];
37
+ }
38
+ /** Turn a task type into a filesystem-safe directory slug. */
39
+ export declare function slugifyTaskType(taskType: string): string;
40
+ /** Render a stub matching a real hand-authored worker (default-exported AppJobHandler). */
41
+ export declare function renderWorkerStub(taskType: string, typedIn: boolean, typedOut: boolean): string;
42
+ /**
43
+ * Plan write-once handler stubs from the models. A stub is planned for each service-task
44
+ * `taskType` that is NOT already wired in the manifest, NOT the imperative orchestrator, and
45
+ * not a duplicate of one already planned. `declaredTypeIds` are the manifest `types` ids — an
46
+ * envelope `in`/`out` is typed only when it names one (matching the worker-io deriver).
47
+ */
48
+ export declare function planWorkerScaffold(models: ModelSource[], workers?: ScaffoldWorker[], declaredTypeIds?: Iterable<string>): WorkerScaffoldPlan;
@@ -0,0 +1,114 @@
1
+ // Pure planner for the write-once worker-stub scaffolder (ADR 0056). Given the app's models,
2
+ // its manifest workers, and the set of declared domain type ids, it plans one typed handler
3
+ // stub per un-wired service task — the ONE part of an Urban app that cannot be derived from
4
+ // the model (the handler *body* is human logic). Everything here is pure and testable like a
5
+ // deriver; the impure write-if-absent edge lives in `../scaffold.ts`.
6
+ //
7
+ // scaffold ≠ derive: stubs are human-owned files under `workers/<slug>/worker.ts`, so they are
8
+ // planned once and never overwritten — unlike the derived `nano-generated/` tree.
9
+ import { scanModelWorkers } from "../derivers/worker-io.js";
10
+ /** Suffix the imperative orchestrator task type carries (ADR 0054 / defineWorkflow). */
11
+ const ORCHESTRATOR_SUFFIX = ":__orchestrate";
12
+ /** Relative import from `workers/<slug>/worker.ts` to the generated worker-io types. */
13
+ const GENERATED_IMPORT = "../../nano-generated/worker-io.d.ts";
14
+ /** Turn a task type into a filesystem-safe directory slug. */
15
+ export function slugifyTaskType(taskType) {
16
+ const s = taskType
17
+ .toLowerCase()
18
+ .replace(/[^a-z0-9]+/g, "-")
19
+ .replace(/^-+|-+$/g, "");
20
+ return s.length > 0 ? s : "worker";
21
+ }
22
+ /** Render a stub matching a real hand-authored worker (default-exported AppJobHandler). */
23
+ export function renderWorkerStub(taskType, typedIn, typedOut) {
24
+ const key = JSON.stringify(taskType); // e.g. "pr.finalize" — matches the deriver's property keys
25
+ const syms = [];
26
+ if (typedIn)
27
+ syms.push("WorkerInputs");
28
+ if (typedOut)
29
+ syms.push("WorkerOutputs");
30
+ const generatedImport = syms.length > 0
31
+ ? `import type { ${syms.join(", ")} } from ${JSON.stringify(GENERATED_IMPORT)};\n`
32
+ : "";
33
+ let generic = "";
34
+ if (typedIn && typedOut)
35
+ generic = `<WorkerInputs[${key}], WorkerOutputs[${key}]>`;
36
+ else if (typedIn)
37
+ generic = `<WorkerInputs[${key}]>`;
38
+ else if (typedOut)
39
+ generic = `<Record<string, unknown>, WorkerOutputs[${key}]>`;
40
+ const varsNote = typedIn
41
+ ? ` // \`job.variables\` is typed as WorkerInputs[${key}].`
42
+ : ` // \`job.variables\` is an open Record<string, unknown> (no declared input type).`;
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` +
45
+ `// below (use \`app.data.table(...)\` for state) and delete the throw.\n` +
46
+ `import type { AppJobHandler } from "@nanobpm/urban/worker";\n` +
47
+ generatedImport +
48
+ `\n` +
49
+ `const handler: AppJobHandler${generic} = async (job, app) => {\n` +
50
+ varsNote +
51
+ `\n` +
52
+ ` // Reach app state and services through the injected \`app\` API.\n` +
53
+ ` throw new Error(${JSON.stringify(`worker not implemented: ${taskType}`)});\n` +
54
+ `};\n` +
55
+ `\n` +
56
+ `export default handler;\n`);
57
+ }
58
+ /**
59
+ * Plan write-once handler stubs from the models. A stub is planned for each service-task
60
+ * `taskType` that is NOT already wired in the manifest, NOT the imperative orchestrator, and
61
+ * not a duplicate of one already planned. `declaredTypeIds` are the manifest `types` ids — an
62
+ * envelope `in`/`out` is typed only when it names one (matching the worker-io deriver).
63
+ */
64
+ export function planWorkerScaffold(models, workers = [], declaredTypeIds = []) {
65
+ const wired = new Set(workers
66
+ .map((w) => w?.taskType)
67
+ .filter((t) => typeof t === "string" && t.length > 0));
68
+ const declared = new Set(declaredTypeIds);
69
+ const plans = [];
70
+ const skipped = [];
71
+ const seenTaskTypes = new Set();
72
+ const usedSlugs = new Set();
73
+ for (const model of [...models].sort((a, b) => a.path.localeCompare(b.path))) {
74
+ for (const io of scanModelWorkers(model.xml)) {
75
+ const taskType = io.taskType;
76
+ if (taskType.endsWith(ORCHESTRATOR_SUFFIX)) {
77
+ skipped.push({ taskType, reason: "orchestrator" });
78
+ continue;
79
+ }
80
+ if (wired.has(taskType)) {
81
+ skipped.push({ taskType, reason: "already-wired" });
82
+ continue;
83
+ }
84
+ if (seenTaskTypes.has(taskType)) {
85
+ skipped.push({ taskType, reason: "duplicate" });
86
+ continue;
87
+ }
88
+ seenTaskTypes.add(taskType);
89
+ let slug = slugifyTaskType(taskType);
90
+ if (usedSlugs.has(slug)) {
91
+ let n = 2;
92
+ while (usedSlugs.has(`${slug}-${n}`))
93
+ n++;
94
+ slug = `${slug}-${n}`;
95
+ }
96
+ usedSlugs.add(slug);
97
+ const typedIn = io.in != null && declared.has(io.in);
98
+ const typedOut = io.out != null && declared.has(io.out);
99
+ const handlerPath = `workers/${slug}/worker.ts`;
100
+ plans.push({
101
+ taskType,
102
+ process: io.process,
103
+ elementId: io.elementId,
104
+ slug,
105
+ handlerPath,
106
+ typedIn,
107
+ typedOut,
108
+ stub: renderWorkerStub(taskType, typedIn, typedOut),
109
+ manifestEntry: { taskType, handler: handlerPath },
110
+ });
111
+ }
112
+ }
113
+ return { plans, skipped };
114
+ }
@@ -0,0 +1,31 @@
1
+ import type { GenIO } from "./gen.ts";
2
+ import { type SkippedWorker, type StubManifestEntry } from "./scaffold/workers.ts";
3
+ export interface ScaffoldOptions {
4
+ root: string;
5
+ io: GenIO;
6
+ manifestFile?: string;
7
+ /** Apply changes (create files + patch manifest). Default false = dry-run. */
8
+ write?: boolean;
9
+ }
10
+ export type StubStatus = "created" | "would-create" | "kept";
11
+ export interface StubOutcome {
12
+ taskType: string;
13
+ handlerPath: string;
14
+ status: StubStatus;
15
+ typedIn: boolean;
16
+ typedOut: boolean;
17
+ }
18
+ export interface ScaffoldRun {
19
+ outcomes: StubOutcome[];
20
+ skipped: SkippedWorker[];
21
+ /**
22
+ * Manifest entries that would be appended to `workers[]` — one per planned stub, so this is
23
+ * populated on a dry-run too (letting a caller preview the wiring). They are actually written
24
+ * to the manifest only when `write` is set (see `manifestPatched`).
25
+ */
26
+ wired: StubManifestEntry[];
27
+ manifestPatched: boolean;
28
+ write: boolean;
29
+ }
30
+ /** Scaffold write-once worker stubs from the model, wiring them into `manifest.workers[]`. */
31
+ export declare function scaffoldWorkers(opts: ScaffoldOptions): Promise<ScaffoldRun>;
@@ -0,0 +1,54 @@
1
+ // The stub-scaffolder's one impure edge (ADR 0056): read the manifest + models, run the pure
2
+ // planner, and — write-if-absent only — create each handler stub and wire it into the manifest.
3
+ // Dry-run by default (`write: false`); nothing is touched unless `write` is set. Stubs are
4
+ // human-owned, so an existing file is KEPT verbatim, never clobbered (the opposite of `runGen`).
5
+ import { joinPath, readModels } from "./gen.js";
6
+ import { planWorkerScaffold, } from "./scaffold/workers.js";
7
+ /** Scaffold write-once worker stubs from the model, wiring them into `manifest.workers[]`. */
8
+ export async function scaffoldWorkers(opts) {
9
+ const { root, io } = opts;
10
+ const write = opts.write ?? false;
11
+ const manifestFile = opts.manifestFile ?? "nano.app.json";
12
+ const manifestPath = joinPath(root, manifestFile);
13
+ const manifest = JSON.parse(await io.readText(manifestPath));
14
+ const models = await readModels(root, io, manifest);
15
+ const declaredTypeIds = Object.keys(manifest.types ?? {});
16
+ const { plans, skipped } = planWorkerScaffold(models, manifest.workers ?? [], declaredTypeIds);
17
+ const outcomes = [];
18
+ const wired = [];
19
+ for (const plan of plans) {
20
+ const abs = joinPath(root, plan.handlerPath);
21
+ const exists = await io.exists(abs);
22
+ let status;
23
+ if (exists) {
24
+ status = "kept"; // human-owned — never clobber
25
+ }
26
+ else if (write) {
27
+ await io.writeText(abs, plan.stub);
28
+ status = "created";
29
+ }
30
+ else {
31
+ status = "would-create";
32
+ }
33
+ outcomes.push({
34
+ taskType: plan.taskType,
35
+ handlerPath: plan.handlerPath,
36
+ status,
37
+ typedIn: plan.typedIn,
38
+ typedOut: plan.typedOut,
39
+ });
40
+ // The planner already excluded already-wired task types, so every plan needs wiring —
41
+ // including an orphan stub file that exists on disk but isn't in the manifest yet.
42
+ wired.push(plan.manifestEntry);
43
+ }
44
+ let manifestPatched = false;
45
+ if (write && wired.length > 0) {
46
+ const withWorkers = {
47
+ ...manifest,
48
+ workers: [...(manifest.workers ?? []), ...wired],
49
+ };
50
+ await io.writeText(manifestPath, `${JSON.stringify(withWorkers, null, 2)}\n`);
51
+ manifestPatched = true;
52
+ }
53
+ return { outcomes, skipped, wired, manifestPatched, write };
54
+ }
@@ -0,0 +1,3 @@
1
+ export type { AppJobHandler } from "./runtime/core/modules/workers.ts";
2
+ export type { EngineJob, JobHandler } from "./runtime/core/host.ts";
3
+ export type { AppApi } from "./runtime/core/context.ts";
package/dist/worker.js ADDED
@@ -0,0 +1,18 @@
1
+ // `@nanobpm/urban/worker` — the runtime-free worker-authoring surface.
2
+ //
3
+ // A service-task handler only needs a handful of *types*: the handler signature and the
4
+ // shapes it is handed. Those all live under `runtime/core/`, which is guaranteed free of any
5
+ // `node:*` import or `Deno` reference (the ADR 0052 purity invariant, enforced by
6
+ // core/purity.test.ts). Importing them from the package barrel (`@nanobpm/urban`) instead
7
+ // drags in `runtime/index.ts`, which re-exports the Node and Deno host adapters — so the
8
+ // barrel's type graph transitively references `node:*` and a consumer must have `@types/node`
9
+ // resolvable (or `skipLibCheck` on) just to typecheck a handler.
10
+ //
11
+ // This entry re-exports ONLY those core authoring types, so a worker module (e.g. a scaffolded
12
+ // stub, ADR 0056) typechecks on Node or Deno, with or without `skipLibCheck`, and never needs
13
+ // `@types/node`. It is types-only: nothing here emits runtime code.
14
+ //
15
+ // (One caveat: `AppApi.sdk` exposes the full `@nanobpm/nano-sdk` client by design, so the
16
+ // handler surface also reaches the SDK's types. Those are kept node-free by the
17
+ // `@nanobpm/nano-sdk` >= 1.2.2 floor in package.json — see ADR 0056.)
18
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
4
4
  "description": "Urban: build and run code-first apps on Nano — runtime, derivation toolkit, and CLI in one. Author durable processes with @nanobpm/workflow (defineFlow), a typed datasource, forms, triggers and surfaces from a nano.app.json manifest, on Node or Deno.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -43,6 +43,10 @@
43
43
  "types": "./dist/toolkit/index.d.ts",
44
44
  "default": "./dist/toolkit/index.js"
45
45
  },
46
+ "./worker": {
47
+ "types": "./dist/worker.d.ts",
48
+ "default": "./dist/worker.js"
49
+ },
46
50
  "./source": "./src/index.ts"
47
51
  },
48
52
  "files": [
@@ -58,10 +62,10 @@
58
62
  "test:deno": "deno test -A --no-check src/"
59
63
  },
60
64
  "dependencies": {
61
- "@nanobpm/nano-sdk": "^1.2.0",
65
+ "@nanobpm/nano-sdk": "^1.2.2",
62
66
  "@nanobpm/workflow": "^0.7.0",
63
67
  "bpmn-auto-layout": "^2.0.0-alpha.2",
64
- "create-urban-app": "^0.4.0"
68
+ "create-urban-app": "^0.5.0"
65
69
  },
66
70
  "devDependencies": {
67
71
  "@types/node": "^22.20.1",
package/src/cli.ts CHANGED
@@ -19,7 +19,7 @@ import {
19
19
  selectHost,
20
20
  } from "./runtime/index.ts";
21
21
  import { scaffold, slugify } from "create-urban-app";
22
- import { createNodeGenIO, runGen } from "./toolkit/index.ts";
22
+ import { createNodeGenIO, runGen, scaffoldWorkers } from "./toolkit/index.ts";
23
23
  import { pathToFileURL } from "node:url";
24
24
  import { readFileSync } from "node:fs";
25
25
 
@@ -42,14 +42,16 @@ interface Flags {
42
42
  manifest: string;
43
43
  port?: number;
44
44
  check: boolean;
45
+ write: boolean;
45
46
  deno: boolean;
47
+ style?: "model" | "code";
46
48
  help: boolean;
47
49
  version: boolean;
48
50
  _: string[];
49
51
  }
50
52
 
51
53
  function parse(argv: string[]): Flags {
52
- const f: Flags = { root: ".", manifest: "nano.app.json", check: false, deno: false, help: false, version: false, _: [] };
54
+ const f: Flags = { root: ".", manifest: "nano.app.json", check: false, write: false, deno: false, help: false, version: false, _: [] };
53
55
  const need = (i: number, flag: string): string => {
54
56
  const v = argv[i];
55
57
  if (v === undefined || v.startsWith("-")) throw new Error(`flag ${flag} requires a value`);
@@ -60,7 +62,14 @@ function parse(argv: string[]): Flags {
60
62
  if (a === "-h" || a === "--help") f.help = true;
61
63
  else if (a === "-v" || a === "--version") f.version = true;
62
64
  else if (a === "--check") f.check = true;
65
+ else if (a === "--write") f.write = true;
63
66
  else if (a === "--deno") f.deno = true;
67
+ else if (a === "--code-first") f.style = "code";
68
+ else if (a === "--style") {
69
+ const v = need(++i, a);
70
+ if (v !== "model" && v !== "code") throw new Error(`flag --style requires "model" or "code" (got "${v}")`);
71
+ f.style = v;
72
+ }
64
73
  else if (a === "--") continue; // tolerate a bare "--" some runners inject (not an option terminator here)
65
74
  else if (a === "--root") f.root = need(++i, a);
66
75
  else if (a === "--manifest") f.manifest = need(++i, a);
@@ -80,9 +89,11 @@ function parse(argv: string[]): Flags {
80
89
  const USAGE = `urban — build and run Urban apps (nano.app.json)
81
90
 
82
91
  Usage:
83
- urban new <name> [--root <path>] [--deno] scaffold a new Urban app
92
+ urban new <name> [--root <path>] [--deno] [--style model|code] scaffold a new Urban app
93
+ (--code-first is shorthand for --style code)
84
94
  urban check validate the manifest
85
95
  urban gen [--check] derive artifacts (migrations, worker-io)
96
+ urban stubs [--write] scaffold write-once handler stubs from the model
86
97
  urban run materialize + serve the app
87
98
  urban dev run + watch sources, hot-reload on change
88
99
  urban deploy deploy models only, then exit
@@ -129,6 +140,38 @@ async function cmdGen(f: Flags): Promise<number> {
129
140
  return 0;
130
141
  }
131
142
 
143
+ async function cmdStubs(f: Flags): Promise<number> {
144
+ const io = createNodeGenIO();
145
+ const run = await scaffoldWorkers({ root: f.root, io, manifestFile: f.manifest, write: f.write });
146
+
147
+ const created = run.outcomes.filter((o) => o.status === "created");
148
+ const would = run.outcomes.filter((o) => o.status === "would-create");
149
+ const kept = run.outcomes.filter((o) => o.status === "kept");
150
+ const typing = (o: { typedIn: boolean; typedOut: boolean }) =>
151
+ o.typedIn || o.typedOut ? ` (typed${o.typedIn ? " in" : ""}${o.typedOut ? " out" : ""})` : "";
152
+
153
+ if (run.write) {
154
+ console.log(`✔ scaffolded ${created.length} worker stub(s):`);
155
+ for (const o of created) console.log(` + ${o.handlerPath}${typing(o)}`);
156
+ if (run.manifestPatched) {
157
+ console.log(`✔ wired ${run.wired.length} worker(s) into ${f.manifest}`);
158
+ }
159
+ } else {
160
+ if (would.length === 0) {
161
+ console.log(`✔ no new worker stubs to scaffold`);
162
+ } else {
163
+ console.log(`Would scaffold ${would.length} worker stub(s) — run with --write to apply:`);
164
+ for (const o of would) console.log(` + ${o.handlerPath}${typing(o)}`);
165
+ }
166
+ }
167
+ for (const o of kept) console.log(` = ${o.handlerPath} (kept — already exists)`);
168
+ if (run.skipped.length > 0) {
169
+ console.log(` skipped ${run.skipped.length}: ` +
170
+ run.skipped.map((s) => `${s.taskType} (${s.reason})`).join(", "));
171
+ }
172
+ return 0;
173
+ }
174
+
132
175
  async function cmdRun(f: Flags, mount?: Record<string, boolean>): Promise<number> {
133
176
  const app = await runFromEnv({
134
177
  root: f.root,
@@ -162,14 +205,15 @@ async function cmdDeploy(f: Flags): Promise<number> {
162
205
  async function cmdNew(f: Flags): Promise<number> {
163
206
  const name = f._[1];
164
207
  if (!name) {
165
- console.error("usage: urban new <name> [--root <path>] [--deno]");
208
+ console.error("usage: urban new <name> [--root <path>] [--deno] [--style model|code] (or --code-first)");
166
209
  return 1;
167
210
  }
168
211
  const dir = f.root !== "." ? f.root : `./${slugify(name)}`;
169
- const res = await scaffold({ name, dir, deno: f.deno });
212
+ const res = await scaffold({ name, dir, deno: f.deno, style: f.style });
170
213
  console.log(`✔ scaffolded "${res.id}" in ${res.dir} (${res.files.length} files)`);
171
214
  console.log(` cd ${res.dir}`);
172
215
  console.log(` npm install && npm start`);
216
+ if (f.style === "code") console.log(` npm run greet -- Adam # start a code-first instance`);
173
217
  if (f.deno) console.log(` # or on Deno: deno task start`);
174
218
  return 0;
175
219
  }
@@ -199,6 +243,8 @@ export async function main(argv: string[]): Promise<number> {
199
243
  return cmdCheck(f);
200
244
  case "gen":
201
245
  return cmdGen(f);
246
+ case "stubs":
247
+ return cmdStubs(f);
202
248
  case "run":
203
249
  return cmdRun(f);
204
250
  case "dev":
@@ -57,6 +57,22 @@ async function expandPattern(root: string, io: GenIO, pattern: string): Promise<
57
57
  .sort();
58
58
  }
59
59
 
60
+ /** Read + resolve the app's process models from the manifest's `models.processes` patterns. */
61
+ export async function readModels(
62
+ root: string,
63
+ io: GenIO,
64
+ manifest: { models?: { processes?: string[] } },
65
+ ): Promise<ModelSource[]> {
66
+ const procPatterns = manifest.models?.processes ?? [];
67
+ const models: ModelSource[] = [];
68
+ for (const pat of procPatterns) {
69
+ for (const rel of await expandPattern(root, io, pat)) {
70
+ models.push({ path: rel, xml: await io.readText(join(root, rel)) });
71
+ }
72
+ }
73
+ return models;
74
+ }
75
+
60
76
  /** Collect all artifacts a run would produce, without touching disk beyond reads. */
61
77
  export async function collectArtifacts(opts: GenOptions): Promise<DerivedArtifact[]> {
62
78
  const { root, io } = opts;
@@ -73,13 +89,7 @@ export async function collectArtifacts(opts: GenOptions): Promise<DerivedArtifac
73
89
  }
74
90
 
75
91
  // 2. models → worker I/O index
76
- const procPatterns = manifest.models?.processes ?? [];
77
- const models: ModelSource[] = [];
78
- for (const pat of procPatterns) {
79
- for (const rel of await expandPattern(root, io, pat)) {
80
- models.push({ path: rel, xml: await io.readText(join(root, rel)) });
81
- }
82
- }
92
+ const models = await readModels(root, io, manifest);
83
93
  if (models.length > 0) {
84
94
  const declaredTypeIds = Object.keys(manifest.types ?? {});
85
95
  artifacts.push(...deriveWorkerBindings(models, declaredTypeIds));
@@ -32,6 +32,23 @@ import { workerIoDeriver } from "./derivers/worker-io.ts";
32
32
  export const DERIVERS = [migrationsDeriver, workerIoDeriver] as const;
33
33
 
34
34
  // Gen orchestrator + IO
35
- export { collectArtifacts, runGen, joinPath } from "./gen.ts";
35
+ export { collectArtifacts, runGen, joinPath, readModels } from "./gen.ts";
36
36
  export type { GenIO, GenOptions, GenResult } from "./gen.ts";
37
37
  export { createNodeGenIO } from "./fsio.ts";
38
+
39
+ // Worker-stub scaffolder (ADR 0056): write-once handler stubs from the model.
40
+ export {
41
+ planWorkerScaffold,
42
+ renderWorkerStub,
43
+ slugifyTaskType,
44
+ } from "./scaffold/workers.ts";
45
+ export type {
46
+ ScaffoldWorker,
47
+ SkippedWorker,
48
+ SkipReason,
49
+ StubManifestEntry,
50
+ WorkerScaffoldPlan,
51
+ WorkerStubPlan,
52
+ } from "./scaffold/workers.ts";
53
+ export { scaffoldWorkers } from "./scaffold.ts";
54
+ export type { ScaffoldOptions, ScaffoldRun, StubOutcome, StubStatus } from "./scaffold.ts";
@@ -0,0 +1,197 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import {
4
+ planWorkerScaffold,
5
+ renderWorkerStub,
6
+ slugifyTaskType,
7
+ type ScaffoldWorker,
8
+ } from "./workers.ts";
9
+ import type { ModelSource } from "../derivers/worker-io.ts";
10
+ import { scaffoldWorkers } from "../scaffold.ts";
11
+ import type { GenIO } from "../gen.ts";
12
+
13
+ // A service task with a data-envelope in/out contract; `type` is the taskType, and the
14
+ // io.nanobpm.dataEnvelope.in/out zeebe:property carry the envelope type ids.
15
+ function serviceTask(id: string, taskType: string, inType?: string, outType?: string): string {
16
+ const props = [
17
+ inType ? `<zeebe:property name="io.nanobpm.dataEnvelope.in" value="${inType}" />` : "",
18
+ outType ? `<zeebe:property name="io.nanobpm.dataEnvelope.out" value="${outType}" />` : "",
19
+ ].join("");
20
+ return `<bpmn:serviceTask id="${id}"><bpmn:extensionElements>
21
+ <zeebe:taskDefinition type="${taskType}" />
22
+ ${props ? `<zeebe:taskHeaders>${props}</zeebe:taskHeaders>` : ""}
23
+ </bpmn:extensionElements></bpmn:serviceTask>`;
24
+ }
25
+
26
+ function model(path: string, procId: string, ...tasks: string[]): ModelSource {
27
+ return {
28
+ path,
29
+ xml: `<bpmn:process id="${procId}" xmlns:bpmn="x" xmlns:zeebe="y">
30
+ ${tasks.join("\n")}
31
+ </bpmn:process>`,
32
+ };
33
+ }
34
+
35
+ test("slugifyTaskType makes a filesystem-safe slug", () => {
36
+ assert.equal(slugifyTaskType("pr.finalize"), "pr-finalize");
37
+ assert.equal(slugifyTaskType("Orders::Charge Card!"), "orders-charge-card");
38
+ assert.equal(slugifyTaskType("---"), "worker");
39
+ });
40
+
41
+ test("typed in+out stub imports both symbols and parameterises the handler", () => {
42
+ const stub = renderWorkerStub("pr.finalize", true, true);
43
+ assert.match(stub, /import type \{ AppJobHandler \} from "@nanobpm\/urban\/worker";/);
44
+ assert.match(stub, /import type \{ WorkerInputs, WorkerOutputs \} from "\.\.\/\.\.\/nano-generated\/worker-io\.d\.ts";/);
45
+ assert.match(stub, /AppJobHandler<WorkerInputs\["pr\.finalize"\], WorkerOutputs\["pr\.finalize"\]>/);
46
+ assert.match(stub, /export default handler;/);
47
+ assert.match(stub, /throw new Error\("worker not implemented: pr\.finalize"\)/);
48
+ });
49
+
50
+ test("untyped stub imports no generated types and uses bare AppJobHandler", () => {
51
+ const stub = renderWorkerStub("demo.do", false, false);
52
+ assert.doesNotMatch(stub, /worker-io\.d\.ts/);
53
+ assert.match(stub, /const handler: AppJobHandler = async \(job, app\) =>/);
54
+ });
55
+
56
+ test("typed-out-only stub fills In with the open default", () => {
57
+ const stub = renderWorkerStub("demo.out", false, true);
58
+ assert.match(stub, /import type \{ WorkerOutputs \} from/);
59
+ assert.match(stub, /AppJobHandler<Record<string, unknown>, WorkerOutputs\["demo\.out"\]>/);
60
+ });
61
+
62
+ test("planWorkerScaffold: typed vs untyped from declared types", () => {
63
+ const m = model(
64
+ "a.bpmn",
65
+ "orders",
66
+ serviceTask("A", "orders.charge", "ChargeIn", "ChargeOut"),
67
+ serviceTask("B", "orders.ship"),
68
+ );
69
+ const { plans } = planWorkerScaffold([m], [], ["ChargeIn", "ChargeOut"]);
70
+ assert.equal(plans.length, 2);
71
+ const charge = plans.find((p) => p.taskType === "orders.charge")!;
72
+ assert.equal(charge.typedIn, true);
73
+ assert.equal(charge.typedOut, true);
74
+ assert.equal(charge.handlerPath, "workers/orders-charge/worker.ts");
75
+ assert.deepEqual(charge.manifestEntry, { taskType: "orders.charge", handler: "workers/orders-charge/worker.ts" });
76
+ const ship = plans.find((p) => p.taskType === "orders.ship")!;
77
+ assert.equal(ship.typedIn, false);
78
+ assert.equal(ship.typedOut, false);
79
+ });
80
+
81
+ test("planWorkerScaffold: an in/out that is not a declared type stays untyped", () => {
82
+ const m = model("a.bpmn", "p", serviceTask("A", "t.a", "Undeclared", "AlsoUndeclared"));
83
+ const { plans } = planWorkerScaffold([m], [], ["SomethingElse"]);
84
+ assert.equal(plans[0].typedIn, false);
85
+ assert.equal(plans[0].typedOut, false);
86
+ });
87
+
88
+ test("planWorkerScaffold: skips already-wired, orchestrator, and duplicates", () => {
89
+ const wired: ScaffoldWorker[] = [{ taskType: "t.wired", handler: "workers/x/worker.ts" }];
90
+ const models = [
91
+ model("a.bpmn", "p1", serviceTask("A", "t.wired"), serviceTask("B", "t.new"), serviceTask("O", "wf:__orchestrate")),
92
+ model("b.bpmn", "p2", serviceTask("C", "t.new")), // duplicate taskType across models
93
+ ];
94
+ const { plans, skipped } = planWorkerScaffold(models, wired, []);
95
+ assert.deepEqual(plans.map((p) => p.taskType), ["t.new"]);
96
+ const reasons = Object.fromEntries(skipped.map((s) => [s.taskType, s.reason]));
97
+ assert.equal(reasons["t.wired"], "already-wired");
98
+ assert.equal(reasons["wf:__orchestrate"], "orchestrator");
99
+ assert.equal(reasons["t.new"], "duplicate");
100
+ });
101
+
102
+ test("planWorkerScaffold: colliding slugs get a numeric suffix", () => {
103
+ const m = model("a.bpmn", "p", serviceTask("A", "orders.charge"), serviceTask("B", "orders-charge"));
104
+ const { plans } = planWorkerScaffold([m], [], []);
105
+ const slugs = plans.map((p) => p.slug).sort();
106
+ assert.deepEqual(slugs, ["orders-charge", "orders-charge-2"]);
107
+ });
108
+
109
+ // ── runner (impure edge, in-memory IO) ──────────────────────────────────────────────────
110
+
111
+ function memIO(files: Record<string, string>): GenIO & { files: Record<string, string> } {
112
+ return {
113
+ files,
114
+ async readText(p) {
115
+ if (!(p in files)) throw new Error(`ENOENT ${p}`);
116
+ return files[p];
117
+ },
118
+ async writeText(p, c) {
119
+ files[p] = c;
120
+ },
121
+ async listDir(p) {
122
+ const prefix = p.replace(/\/+$/, "") + "/";
123
+ const names = new Set<string>();
124
+ for (const f of Object.keys(files)) {
125
+ if (f.startsWith(prefix)) {
126
+ const rest = f.slice(prefix.length);
127
+ if (!rest.includes("/")) names.add(rest);
128
+ }
129
+ }
130
+ return [...names];
131
+ },
132
+ async exists(p) {
133
+ return p in files;
134
+ },
135
+ };
136
+ }
137
+
138
+ function appFixture(): Record<string, string> {
139
+ return {
140
+ "/app/nano.app.json": JSON.stringify({
141
+ id: "demo",
142
+ models: { processes: ["processes/*.bpmn"] },
143
+ workers: [{ taskType: "t.wired", handler: "workers/wired/worker.ts" }],
144
+ }),
145
+ "/app/processes/p.bpmn": model("p.bpmn", "p",
146
+ serviceTask("A", "t.new"),
147
+ serviceTask("B", "t.wired"),
148
+ ).xml,
149
+ };
150
+ }
151
+
152
+ test("scaffoldWorkers dry-run writes nothing and reports would-create", async () => {
153
+ const io = memIO(appFixture());
154
+ const before = Object.keys(io.files).length;
155
+ const run = await scaffoldWorkers({ root: "/app", io });
156
+ assert.equal(run.write, false);
157
+ assert.equal(Object.keys(io.files).length, before, "dry-run must not write");
158
+ assert.deepEqual(run.outcomes.map((o) => o.status), ["would-create"]);
159
+ assert.equal(run.outcomes[0].taskType, "t.new");
160
+ assert.equal(run.manifestPatched, false);
161
+ assert.ok(run.skipped.some((s) => s.taskType === "t.wired" && s.reason === "already-wired"));
162
+ });
163
+
164
+ test("scaffoldWorkers --write creates the stub and patches the manifest once", async () => {
165
+ const io = memIO(appFixture());
166
+ const run = await scaffoldWorkers({ root: "/app", io, write: true });
167
+ assert.equal(run.outcomes[0].status, "created");
168
+ const stub = io.files["/app/workers/t-new/worker.ts"];
169
+ assert.ok(stub, "stub file written");
170
+ assert.match(stub, /worker not implemented: t\.new/);
171
+ const manifest = JSON.parse(io.files["/app/nano.app.json"]);
172
+ assert.deepEqual(manifest.workers.map((w: { taskType: string }) => w.taskType), ["t.wired", "t.new"]);
173
+ assert.ok(io.files["/app/nano.app.json"].endsWith("\n"), "manifest ends with newline");
174
+ });
175
+
176
+ test("scaffoldWorkers never clobbers an existing stub (keeps it), still wires it", async () => {
177
+ const files = appFixture();
178
+ files["/app/workers/t-new/worker.ts"] = "// hand-edited, do not touch\n";
179
+ const io = memIO(files);
180
+ const run = await scaffoldWorkers({ root: "/app", io, write: true });
181
+ assert.equal(run.outcomes[0].status, "kept");
182
+ assert.equal(io.files["/app/workers/t-new/worker.ts"], "// hand-edited, do not touch\n");
183
+ const manifest = JSON.parse(io.files["/app/nano.app.json"]);
184
+ assert.ok(manifest.workers.some((w: { taskType: string }) => w.taskType === "t.new"), "orphan stub gets wired");
185
+ });
186
+
187
+ test("scaffoldWorkers is idempotent: a second --write run is a no-op", async () => {
188
+ const io = memIO(appFixture());
189
+ await scaffoldWorkers({ root: "/app", io, write: true });
190
+ const snapshot = JSON.stringify(io.files);
191
+ const run2 = await scaffoldWorkers({ root: "/app", io, write: true });
192
+ // t.new is now wired in the manifest, so the planner skips it entirely (nothing to do).
193
+ assert.deepEqual(run2.outcomes, []);
194
+ assert.ok(run2.skipped.some((s) => s.taskType === "t.new" && s.reason === "already-wired"));
195
+ assert.equal(run2.manifestPatched, false, "no new wiring on the second run");
196
+ assert.equal(JSON.stringify(io.files), snapshot, "files unchanged on re-run");
197
+ });
@@ -0,0 +1,178 @@
1
+ // Pure planner for the write-once worker-stub scaffolder (ADR 0056). Given the app's models,
2
+ // its manifest workers, and the set of declared domain type ids, it plans one typed handler
3
+ // stub per un-wired service task — the ONE part of an Urban app that cannot be derived from
4
+ // the model (the handler *body* is human logic). Everything here is pure and testable like a
5
+ // deriver; the impure write-if-absent edge lives in `../scaffold.ts`.
6
+ //
7
+ // scaffold ≠ derive: stubs are human-owned files under `workers/<slug>/worker.ts`, so they are
8
+ // planned once and never overwritten — unlike the derived `nano-generated/` tree.
9
+
10
+ import { scanModelWorkers, type ModelSource } from "../derivers/worker-io.ts";
11
+
12
+ /** A worker to plan against: the manifest's already-wired workers. */
13
+ export interface ScaffoldWorker {
14
+ taskType: string;
15
+ handler?: string;
16
+ llm?: string;
17
+ }
18
+
19
+ /** The `workers[]` manifest entry a stub is wired in as. */
20
+ export interface StubManifestEntry {
21
+ taskType: string;
22
+ handler: string;
23
+ }
24
+
25
+ /** One planned handler stub. */
26
+ export interface WorkerStubPlan {
27
+ taskType: string;
28
+ process?: string;
29
+ elementId?: string;
30
+ /** Directory slug under `workers/` (unique across the plan). */
31
+ slug: string;
32
+ /** App-relative handler path: `workers/<slug>/worker.ts`. */
33
+ handlerPath: string;
34
+ /** True when the data-envelope `in`/`out` names a declared domain type (so it is typed). */
35
+ typedIn: boolean;
36
+ typedOut: boolean;
37
+ /** The stub file contents. */
38
+ stub: string;
39
+ manifestEntry: StubManifestEntry;
40
+ }
41
+
42
+ export type SkipReason = "already-wired" | "orchestrator" | "duplicate";
43
+
44
+ export interface SkippedWorker {
45
+ taskType: string;
46
+ reason: SkipReason;
47
+ }
48
+
49
+ export interface WorkerScaffoldPlan {
50
+ plans: WorkerStubPlan[];
51
+ skipped: SkippedWorker[];
52
+ }
53
+
54
+ /** Suffix the imperative orchestrator task type carries (ADR 0054 / defineWorkflow). */
55
+ const ORCHESTRATOR_SUFFIX = ":__orchestrate";
56
+
57
+ /** Relative import from `workers/<slug>/worker.ts` to the generated worker-io types. */
58
+ const GENERATED_IMPORT = "../../nano-generated/worker-io.d.ts";
59
+
60
+ /** Turn a task type into a filesystem-safe directory slug. */
61
+ export function slugifyTaskType(taskType: string): string {
62
+ const s = taskType
63
+ .toLowerCase()
64
+ .replace(/[^a-z0-9]+/g, "-")
65
+ .replace(/^-+|-+$/g, "");
66
+ return s.length > 0 ? s : "worker";
67
+ }
68
+
69
+ /** Render a stub matching a real hand-authored worker (default-exported AppJobHandler). */
70
+ export function renderWorkerStub(
71
+ taskType: string,
72
+ typedIn: boolean,
73
+ typedOut: boolean,
74
+ ): string {
75
+ const key = JSON.stringify(taskType); // e.g. "pr.finalize" — matches the deriver's property keys
76
+ const syms: string[] = [];
77
+ if (typedIn) syms.push("WorkerInputs");
78
+ if (typedOut) syms.push("WorkerOutputs");
79
+
80
+ const generatedImport = syms.length > 0
81
+ ? `import type { ${syms.join(", ")} } from ${JSON.stringify(GENERATED_IMPORT)};\n`
82
+ : "";
83
+
84
+ let generic = "";
85
+ if (typedIn && typedOut) generic = `<WorkerInputs[${key}], WorkerOutputs[${key}]>`;
86
+ else if (typedIn) generic = `<WorkerInputs[${key}]>`;
87
+ else if (typedOut) generic = `<Record<string, unknown>, WorkerOutputs[${key}]>`;
88
+
89
+ const varsNote = typedIn
90
+ ? ` // \`job.variables\` is typed as WorkerInputs[${key}].`
91
+ : ` // \`job.variables\` is an open Record<string, unknown> (no declared input type).`;
92
+
93
+ return (
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` +
96
+ `// below (use \`app.data.table(...)\` for state) and delete the throw.\n` +
97
+ `import type { AppJobHandler } from "@nanobpm/urban/worker";\n` +
98
+ generatedImport +
99
+ `\n` +
100
+ `const handler: AppJobHandler${generic} = async (job, app) => {\n` +
101
+ varsNote +
102
+ `\n` +
103
+ ` // Reach app state and services through the injected \`app\` API.\n` +
104
+ ` throw new Error(${JSON.stringify(`worker not implemented: ${taskType}`)});\n` +
105
+ `};\n` +
106
+ `\n` +
107
+ `export default handler;\n`
108
+ );
109
+ }
110
+
111
+ /**
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).
116
+ */
117
+ export function planWorkerScaffold(
118
+ models: ModelSource[],
119
+ workers: ScaffoldWorker[] = [],
120
+ declaredTypeIds: Iterable<string> = [],
121
+ ): WorkerScaffoldPlan {
122
+ const wired = new Set(
123
+ workers
124
+ .map((w) => w?.taskType)
125
+ .filter((t): t is string => typeof t === "string" && t.length > 0),
126
+ );
127
+ const declared = new Set(declaredTypeIds);
128
+
129
+ const plans: WorkerStubPlan[] = [];
130
+ const skipped: SkippedWorker[] = [];
131
+ const seenTaskTypes = new Set<string>();
132
+ const usedSlugs = new Set<string>();
133
+
134
+ for (const model of [...models].sort((a, b) => a.path.localeCompare(b.path))) {
135
+ for (const io of scanModelWorkers(model.xml)) {
136
+ const taskType = io.taskType;
137
+ if (taskType.endsWith(ORCHESTRATOR_SUFFIX)) {
138
+ skipped.push({ taskType, reason: "orchestrator" });
139
+ continue;
140
+ }
141
+ if (wired.has(taskType)) {
142
+ skipped.push({ taskType, reason: "already-wired" });
143
+ continue;
144
+ }
145
+ if (seenTaskTypes.has(taskType)) {
146
+ skipped.push({ taskType, reason: "duplicate" });
147
+ continue;
148
+ }
149
+ seenTaskTypes.add(taskType);
150
+
151
+ let slug = slugifyTaskType(taskType);
152
+ if (usedSlugs.has(slug)) {
153
+ let n = 2;
154
+ while (usedSlugs.has(`${slug}-${n}`)) n++;
155
+ slug = `${slug}-${n}`;
156
+ }
157
+ usedSlugs.add(slug);
158
+
159
+ const typedIn = io.in != null && declared.has(io.in);
160
+ const typedOut = io.out != null && declared.has(io.out);
161
+ const handlerPath = `workers/${slug}/worker.ts`;
162
+
163
+ plans.push({
164
+ taskType,
165
+ process: io.process,
166
+ elementId: io.elementId,
167
+ slug,
168
+ handlerPath,
169
+ typedIn,
170
+ typedOut,
171
+ stub: renderWorkerStub(taskType, typedIn, typedOut),
172
+ manifestEntry: { taskType, handler: handlerPath },
173
+ });
174
+ }
175
+ }
176
+
177
+ return { plans, skipped };
178
+ }
@@ -0,0 +1,104 @@
1
+ // The stub-scaffolder's one impure edge (ADR 0056): read the manifest + models, run the pure
2
+ // planner, and — write-if-absent only — create each handler stub and wire it into the manifest.
3
+ // Dry-run by default (`write: false`); nothing is touched unless `write` is set. Stubs are
4
+ // human-owned, so an existing file is KEPT verbatim, never clobbered (the opposite of `runGen`).
5
+
6
+ import type { GenIO } from "./gen.ts";
7
+ import { joinPath, readModels } from "./gen.ts";
8
+ import {
9
+ planWorkerScaffold,
10
+ type ScaffoldWorker,
11
+ type SkippedWorker,
12
+ type StubManifestEntry,
13
+ } from "./scaffold/workers.ts";
14
+
15
+ /** The manifest fields the scaffolder reads. */
16
+ interface ScaffoldManifest {
17
+ types?: Record<string, unknown>;
18
+ models?: { processes?: string[] };
19
+ workers?: ScaffoldWorker[];
20
+ }
21
+
22
+ export interface ScaffoldOptions {
23
+ root: string;
24
+ io: GenIO;
25
+ manifestFile?: string;
26
+ /** Apply changes (create files + patch manifest). Default false = dry-run. */
27
+ write?: boolean;
28
+ }
29
+
30
+ export type StubStatus = "created" | "would-create" | "kept";
31
+
32
+ export interface StubOutcome {
33
+ taskType: string;
34
+ handlerPath: string;
35
+ status: StubStatus;
36
+ typedIn: boolean;
37
+ typedOut: boolean;
38
+ }
39
+
40
+ export interface ScaffoldRun {
41
+ outcomes: StubOutcome[];
42
+ skipped: SkippedWorker[];
43
+ /**
44
+ * Manifest entries that would be appended to `workers[]` — one per planned stub, so this is
45
+ * populated on a dry-run too (letting a caller preview the wiring). They are actually written
46
+ * to the manifest only when `write` is set (see `manifestPatched`).
47
+ */
48
+ wired: StubManifestEntry[];
49
+ manifestPatched: boolean;
50
+ write: boolean;
51
+ }
52
+
53
+ /** Scaffold write-once worker stubs from the model, wiring them into `manifest.workers[]`. */
54
+ export async function scaffoldWorkers(opts: ScaffoldOptions): Promise<ScaffoldRun> {
55
+ const { root, io } = opts;
56
+ const write = opts.write ?? false;
57
+ const manifestFile = opts.manifestFile ?? "nano.app.json";
58
+ const manifestPath = joinPath(root, manifestFile);
59
+
60
+ const manifest = JSON.parse(await io.readText(manifestPath)) as ScaffoldManifest;
61
+ const models = await readModels(root, io, manifest);
62
+ const declaredTypeIds = Object.keys(manifest.types ?? {});
63
+
64
+ const { plans, skipped } = planWorkerScaffold(models, manifest.workers ?? [], declaredTypeIds);
65
+
66
+ const outcomes: StubOutcome[] = [];
67
+ const wired: StubManifestEntry[] = [];
68
+
69
+ for (const plan of plans) {
70
+ const abs = joinPath(root, plan.handlerPath);
71
+ const exists = await io.exists(abs);
72
+ let status: StubStatus;
73
+ if (exists) {
74
+ status = "kept"; // human-owned — never clobber
75
+ } else if (write) {
76
+ await io.writeText(abs, plan.stub);
77
+ status = "created";
78
+ } else {
79
+ status = "would-create";
80
+ }
81
+ outcomes.push({
82
+ taskType: plan.taskType,
83
+ handlerPath: plan.handlerPath,
84
+ status,
85
+ typedIn: plan.typedIn,
86
+ typedOut: plan.typedOut,
87
+ });
88
+ // The planner already excluded already-wired task types, so every plan needs wiring —
89
+ // including an orphan stub file that exists on disk but isn't in the manifest yet.
90
+ wired.push(plan.manifestEntry);
91
+ }
92
+
93
+ let manifestPatched = false;
94
+ if (write && wired.length > 0) {
95
+ const withWorkers = {
96
+ ...manifest,
97
+ workers: [...(manifest.workers ?? []), ...wired],
98
+ };
99
+ await io.writeText(manifestPath, `${JSON.stringify(withWorkers, null, 2)}\n`);
100
+ manifestPatched = true;
101
+ }
102
+
103
+ return { outcomes, skipped, wired, manifestPatched, write };
104
+ }
@@ -0,0 +1,104 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { readFile } from "node:fs/promises";
4
+ import { fileURLToPath } from "node:url";
5
+ import { dirname, join, normalize } from "node:path";
6
+
7
+ // Guard for the `@nanobpm/urban/worker` authoring surface. Its job is to keep urban's OWN
8
+ // host adapters (runtime/adapters/node.ts, runtime/adapters/deno.ts and their node:* imports)
9
+ // out of the type graph a worker handler imports — the coupling that otherwise forces
10
+ // `@types/node` / `skipLibCheck` on a consumer just to typecheck a handler.
11
+ //
12
+ // This walks the *relative*-import closure from worker.ts, so it covers urban's own reachable
13
+ // sources. It does NOT follow the bare `@nanobpm/nano-sdk` dependency behind `AppApi.sdk`
14
+ // (engine/sdk.ts) — that dependency's own node-freeness is enforced separately by the
15
+ // `@nanobpm/nano-sdk` >= 1.2.2 floor in package.json (which pulls @camunda8 >= 10.0.0-alpha.20,
16
+ // whose public types no longer reference node:worker_threads). Together, urban's node-free
17
+ // relative closure + that SDK floor make a scaffolded stub (ADR 0056) typecheck on Node or Deno
18
+ // with no `@types/node` and no `skipLibCheck`.
19
+
20
+ const here = dirname(fileURLToPath(import.meta.url));
21
+
22
+ /** Collect relative-specifier imports/re-exports from a source file. */
23
+ function relativeSpecifiers(src: string): string[] {
24
+ const out: string[] = [];
25
+ const re = /\bfrom\s+["'](\.[^"']+)["']/g;
26
+ let m: RegExpExecArray | null;
27
+ while ((m = re.exec(src)) !== null) {
28
+ // Skip template-placeholder specifiers (e.g. `./${DOMAIN_DTS}` emitted as codegen strings),
29
+ // which are not real static import paths.
30
+ if (!m[1].includes("${")) out.push(m[1]);
31
+ }
32
+ return out;
33
+ }
34
+
35
+ /** Resolve a relative specifier (with explicit .ts) against a containing file. */
36
+ function resolveSpec(fromFile: string, spec: string): string {
37
+ return normalize(join(dirname(fromFile), spec));
38
+ }
39
+
40
+ async function closureFrom(entry: string): Promise<string[]> {
41
+ const seen = new Set<string>();
42
+ const stack = [entry];
43
+ while (stack.length > 0) {
44
+ const file = stack.pop()!;
45
+ if (seen.has(file)) continue;
46
+ seen.add(file);
47
+ let src: string;
48
+ try {
49
+ src = await readFile(file, "utf8");
50
+ } catch {
51
+ // A statically-extracted specifier that doesn't resolve to a real file (dynamic/generated
52
+ // path). Skip it — the purity assertions below only care about files that actually exist.
53
+ continue;
54
+ }
55
+ for (const spec of relativeSpecifiers(src)) {
56
+ stack.push(resolveSpec(file, spec));
57
+ }
58
+ }
59
+ return [...seen];
60
+ }
61
+
62
+ test("@nanobpm/urban/worker closure is free of node:* and Deno", async () => {
63
+ const files = await closureFrom(join(here, "worker.ts"));
64
+ // Sanity: the walk actually reached the core authoring modules.
65
+ assert.ok(
66
+ files.some((f) => f.endsWith(join("core", "modules", "workers.ts"))),
67
+ "closure should include core/modules/workers.ts",
68
+ );
69
+ const offenders: string[] = [];
70
+ for (const file of files) {
71
+ const src = await readFile(file, "utf8");
72
+ if (/from\s+["']node:/.test(src)) offenders.push(`${file}: imports node:*`);
73
+ if (/\bDeno\s*\./.test(src)) offenders.push(`${file}: references Deno.*`);
74
+ }
75
+ assert.deepEqual(offenders, [], `worker surface purity violations:\n${offenders.join("\n")}`);
76
+ });
77
+
78
+ test("@nanobpm/urban/worker excludes urban's host adapters (the barrel includes them)", async () => {
79
+ const workerClosure = await closureFrom(join(here, "worker.ts"));
80
+ const barrelClosure = await closureFrom(join(here, "index.ts"));
81
+ const adapters = [
82
+ join("runtime", "adapters", "node.ts"),
83
+ join("runtime", "adapters", "deno.ts"),
84
+ ];
85
+ for (const a of adapters) {
86
+ assert.ok(
87
+ !workerClosure.some((f) => f.endsWith(a)),
88
+ `worker closure must NOT include ${a}`,
89
+ );
90
+ assert.ok(
91
+ barrelClosure.some((f) => f.endsWith(a)),
92
+ `barrel closure is expected to include ${a} (control)`,
93
+ );
94
+ }
95
+ });
96
+
97
+ test("@nanobpm/urban/worker re-exports the authoring types", async () => {
98
+ const src = await readFile(join(here, "worker.ts"), "utf8");
99
+ for (const sym of ["AppJobHandler", "EngineJob", "JobHandler", "AppApi"]) {
100
+ assert.match(src, new RegExp(`\\b${sym}\\b`), `worker.ts should export ${sym}`);
101
+ }
102
+ // Types-only surface: no value exports (nothing that would emit runtime code).
103
+ assert.doesNotMatch(src, /^export\s+(?!type\b)/m, "worker.ts must be a types-only re-export");
104
+ });
package/src/worker.ts ADDED
@@ -0,0 +1,21 @@
1
+ // `@nanobpm/urban/worker` — the runtime-free worker-authoring surface.
2
+ //
3
+ // A service-task handler only needs a handful of *types*: the handler signature and the
4
+ // shapes it is handed. Those all live under `runtime/core/`, which is guaranteed free of any
5
+ // `node:*` import or `Deno` reference (the ADR 0052 purity invariant, enforced by
6
+ // core/purity.test.ts). Importing them from the package barrel (`@nanobpm/urban`) instead
7
+ // drags in `runtime/index.ts`, which re-exports the Node and Deno host adapters — so the
8
+ // barrel's type graph transitively references `node:*` and a consumer must have `@types/node`
9
+ // resolvable (or `skipLibCheck` on) just to typecheck a handler.
10
+ //
11
+ // This entry re-exports ONLY those core authoring types, so a worker module (e.g. a scaffolded
12
+ // stub, ADR 0056) typechecks on Node or Deno, with or without `skipLibCheck`, and never needs
13
+ // `@types/node`. It is types-only: nothing here emits runtime code.
14
+ //
15
+ // (One caveat: `AppApi.sdk` exposes the full `@nanobpm/nano-sdk` client by design, so the
16
+ // handler surface also reaches the SDK's types. Those are kept node-free by the
17
+ // `@nanobpm/nano-sdk` >= 1.2.2 floor in package.json — see ADR 0056.)
18
+
19
+ export type { AppJobHandler } from "./runtime/core/modules/workers.ts";
20
+ export type { EngineJob, JobHandler } from "./runtime/core/host.ts";
21
+ export type { AppApi } from "./runtime/core/context.ts";