@nanobpm/urban 0.17.1 → 0.18.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/src/cli.ts CHANGED
@@ -2,7 +2,8 @@
2
2
  //
3
3
  // urban new <name> scaffold a new app (delegates to create-urban-app)
4
4
  // urban check load + validate the manifest, report issues
5
- // urban gen derive generated artifacts (migrations, worker I/O)
5
+ // urban gen derive generated artifacts (migrations, worker I/O, code-first models)
6
+ // urban derive derive code-first models only (workflows/*.ts → processes/*.bpmn)
6
7
  // urban run materialize + serve the app (deploy, data, workers, surfaces, triggers)
7
8
  // urban dev like run, plus watch sources and hot-reload on change
8
9
  // urban deploy deploy models only, then exit
@@ -19,7 +20,7 @@ import {
19
20
  selectHost,
20
21
  } from "./runtime/index.ts";
21
22
  import { scaffold, slugify } from "create-urban-app";
22
- import { createNodeGenIO, runGen, scaffoldWorkers } from "./toolkit/index.ts";
23
+ import { createNodeGenIO, previewModels, runGen, scaffoldWorkers } from "./toolkit/index.ts";
23
24
  import { pathToFileURL } from "node:url";
24
25
  import { readFileSync } from "node:fs";
25
26
 
@@ -44,6 +45,8 @@ interface Flags {
44
45
  check: boolean;
45
46
  write: boolean;
46
47
  deno: boolean;
48
+ models: boolean;
49
+ stdout: boolean;
47
50
  style?: "model" | "code";
48
51
  help: boolean;
49
52
  version: boolean;
@@ -51,7 +54,7 @@ interface Flags {
51
54
  }
52
55
 
53
56
  function parse(argv: string[]): Flags {
54
- const f: Flags = { root: ".", manifest: "nano.app.json", check: false, write: false, deno: false, help: false, version: false, _: [] };
57
+ const f: Flags = { root: ".", manifest: "nano.app.json", check: false, write: false, deno: false, models: true, stdout: false, help: false, version: false, _: [] };
55
58
  const need = (i: number, flag: string): string => {
56
59
  const v = argv[i];
57
60
  if (v === undefined || v.startsWith("-")) throw new Error(`flag ${flag} requires a value`);
@@ -64,6 +67,9 @@ function parse(argv: string[]): Flags {
64
67
  else if (a === "--check") f.check = true;
65
68
  else if (a === "--write") f.write = true;
66
69
  else if (a === "--deno") f.deno = true;
70
+ else if (a === "--no-models") f.models = false;
71
+ else if (a === "--models") f.models = true;
72
+ else if (a === "--stdout") f.stdout = true;
67
73
  else if (a === "--code-first") f.style = "code";
68
74
  else if (a === "--style") {
69
75
  const v = need(++i, a);
@@ -92,7 +98,10 @@ Usage:
92
98
  urban new <name> [--root <path>] [--deno] [--style model|code] scaffold a new Urban app
93
99
  (--code-first is shorthand for --style code)
94
100
  urban check validate the manifest
95
- urban gen [--check] derive artifacts (migrations, worker-io)
101
+ urban gen [--check] [--no-models] derive artifacts (migrations, worker-io, + code-first models)
102
+ (--no-models: skip writing derived .bpmn; type-contracts only)
103
+ urban derive [--check|--stdout] derive code-first models only (workflows/*.ts → processes/*.bpmn)
104
+ (--stdout: print {models,incomplete} JSON without writing)
96
105
  urban stubs [--write] scaffold write-once handler stubs from the model
97
106
  urban run materialize + serve the app
98
107
  urban dev run + watch sources, hot-reload on change
@@ -125,7 +134,12 @@ async function cmdCheck(f: Flags): Promise<number> {
125
134
 
126
135
  async function cmdGen(f: Flags): Promise<number> {
127
136
  const io = createNodeGenIO();
128
- const res = await runGen({ root: f.root, io, manifestFile: f.manifest, check: f.check });
137
+ const res = await runGen({ root: f.root, io, manifestFile: f.manifest, check: f.check, emitModels: f.models });
138
+ if (res.incomplete) {
139
+ console.error(`✖ ${res.modelErrors.length} workflow(s) failed to derive:`);
140
+ for (const e of res.modelErrors) console.error(` • ${e.path}: ${e.message}`);
141
+ return 1;
142
+ }
129
143
  if (f.check) {
130
144
  if (res.drift.length === 0) {
131
145
  console.log(`✔ generated artifacts are up to date (${res.artifacts.length} checked)`);
@@ -140,6 +154,55 @@ async function cmdGen(f: Flags): Promise<number> {
140
154
  return 0;
141
155
  }
142
156
 
157
+ async function cmdDerive(f: Flags): Promise<number> {
158
+ // `urban derive` IS model derivation, so `--no-models` is contradictory. Reject it explicitly
159
+ // rather than silently ignoring it (flags are global, so it's easy to pass by accident).
160
+ if (!f.models) {
161
+ console.error(
162
+ "✖ `urban derive` derives models — `--no-models` is contradictory. Use `urban gen --no-models` for type-contracts only.",
163
+ );
164
+ return 2;
165
+ }
166
+ const io = createNodeGenIO();
167
+
168
+ // Non-writing preview: emit `{ models: [{ id, kind, xml }], incomplete }` to stdout for the
169
+ // console's read-only model viewer (never touches `processes/`).
170
+ if (f.stdout) {
171
+ const d = await previewModels({ root: f.root, io, manifestFile: f.manifest });
172
+ process.stdout.write(
173
+ JSON.stringify({
174
+ models: d.list.map((m) => ({ id: m.id, kind: m.kind, xml: m.xml })),
175
+ incomplete: d.incomplete,
176
+ }) + "\n",
177
+ );
178
+ if (d.incomplete) {
179
+ for (const e of d.errors) console.error(` • ${e.path}: ${e.message}`);
180
+ return 1;
181
+ }
182
+ return 0;
183
+ }
184
+
185
+ // Derive + write models only (skip the type-contract derivers); `generate_models` delegates here.
186
+ const res = await runGen({ root: f.root, io, manifestFile: f.manifest, check: f.check, modelsOnly: true });
187
+ if (res.incomplete) {
188
+ console.error(`✖ ${res.modelErrors.length} workflow(s) failed to derive:`);
189
+ for (const e of res.modelErrors) console.error(` • ${e.path}: ${e.message}`);
190
+ return 1;
191
+ }
192
+ if (f.check) {
193
+ if (res.drift.length === 0) {
194
+ console.log(`✔ derived models are up to date (${res.artifacts.length} checked)`);
195
+ return 0;
196
+ }
197
+ console.error(`✖ ${res.drift.length} derived model(s) are out of date — run \`urban derive\`:`);
198
+ for (const p of res.drift) console.error(` • ${p}`);
199
+ return 1;
200
+ }
201
+ console.log(`✔ derived ${res.artifacts.length} model(s):`);
202
+ for (const a of res.artifacts) console.log(` • ${a.path}`);
203
+ return 0;
204
+ }
205
+
143
206
  async function cmdStubs(f: Flags): Promise<number> {
144
207
  const io = createNodeGenIO();
145
208
  const run = await scaffoldWorkers({ root: f.root, io, manifestFile: f.manifest, write: f.write });
@@ -243,6 +306,8 @@ export async function main(argv: string[]): Promise<number> {
243
306
  return cmdCheck(f);
244
307
  case "gen":
245
308
  return cmdGen(f);
309
+ case "derive":
310
+ return cmdDerive(f);
246
311
  case "stubs":
247
312
  return cmdStubs(f);
248
313
  case "run":
@@ -19,3 +19,26 @@ test("listDir returns empty for a missing directory", async () => {
19
19
  const io = createNodeGenIO();
20
20
  assert.deepEqual(await io.listDir(join(tmpdir(), "urban-fsio-does-not-exist-xyz")), []);
21
21
  });
22
+
23
+ test("importModule caches on mtime: unchanged reuses, edit reloads", async () => {
24
+ const dir = await mkdtemp(join(tmpdir(), "urban-fsio-imp-"));
25
+ const file = join(dir, "mod.mjs");
26
+ await writeFile(file, "export const v = 1;");
27
+ const io = createNodeGenIO();
28
+
29
+ const first = await io.importModule!(file);
30
+ assert.equal(first.v, 1);
31
+ // Same mtime → same module URL → cached instance (same object identity).
32
+ const again = await io.importModule!(file);
33
+ assert.equal(again, first);
34
+
35
+ // Edit with a strictly newer mtime → new URL → fresh module with the new value.
36
+ await new Promise((r) => setTimeout(r, 10));
37
+ await writeFile(file, "export const v = 2;");
38
+ const utimes = (await import("node:fs/promises")).utimes;
39
+ const t = Date.now() / 1000 + 5;
40
+ await utimes(file, t, t);
41
+ const edited = await io.importModule!(file);
42
+ assert.equal(edited.v, 2);
43
+ assert.notEqual(edited, first);
44
+ });
@@ -1,8 +1,9 @@
1
1
  // Node/Deno filesystem implementation of the GenIO port. Uses node:fs/promises, which both
2
2
  // Node and Deno provide, so a single implementation serves both runtimes.
3
3
 
4
- import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
5
- import { dirname } from "node:path";
4
+ import { mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
5
+ import { dirname, resolve } from "node:path";
6
+ import { pathToFileURL } from "node:url";
6
7
  import type { GenIO } from "./gen.ts";
7
8
 
8
9
  export function createNodeGenIO(): GenIO {
@@ -32,5 +33,24 @@ export function createNodeGenIO(): GenIO {
32
33
  return false;
33
34
  }
34
35
  },
36
+ async remove(path: string): Promise<void> {
37
+ await rm(path, { force: true });
38
+ },
39
+ // Import an app module for code-first model derivation. `import()` of a file URL loads TS on
40
+ // Deno natively and on Node with type-stripping (≥22.18 default, or `--experimental-strip-types`).
41
+ // The cache key is the file's mtime, not a monotonic clock: an unchanged workflow reuses the
42
+ // cached module instance (bounding the ESM module map in long-lived processes like `urban dev`)
43
+ // while an edited one still reloads.
44
+ async importModule(path: string): Promise<Record<string, unknown>> {
45
+ const abs = resolve(path);
46
+ let version = "";
47
+ try {
48
+ version = `?v=${(await stat(abs)).mtimeMs}`;
49
+ } catch {
50
+ // stat failed (e.g. a virtual path); fall back to no cache key and let import() surface it.
51
+ }
52
+ const href = `${pathToFileURL(abs).href}${version}`;
53
+ return import(href) as Promise<Record<string, unknown>>;
54
+ },
35
55
  };
36
56
  }
@@ -1,9 +1,14 @@
1
1
  import { test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import { runGen, collectArtifacts, type GenIO } from "./gen.ts";
4
+ import { defineFlow, envelope } from "@nanobpm/workflow";
5
+ import { MODEL_PROVENANCE } from "./models.ts";
4
6
 
5
7
  /** In-memory filesystem for deterministic, IO-free gen tests. */
6
- function memIO(files: Record<string, string>): GenIO & { files: Record<string, string> } {
8
+ function memIO(
9
+ files: Record<string, string>,
10
+ modules: Record<string, Record<string, unknown>> = {},
11
+ ): GenIO & { files: Record<string, string> } {
7
12
  return {
8
13
  files,
9
14
  async readText(p) {
@@ -27,6 +32,13 @@ function memIO(files: Record<string, string>): GenIO & { files: Record<string, s
27
32
  async exists(p) {
28
33
  return p in files;
29
34
  },
35
+ async remove(p) {
36
+ delete files[p];
37
+ },
38
+ async importModule(p) {
39
+ if (!(p in modules)) throw new Error(`no module registered for ${p}`);
40
+ return modules[p];
41
+ },
30
42
  };
31
43
  }
32
44
 
@@ -90,3 +102,66 @@ test("collectArtifacts touches no writes", async () => {
90
102
  await collectArtifacts({ root: "/app", io });
91
103
  assert.equal(Object.keys(io.files).length, before);
92
104
  });
105
+
106
+ // --- code-first model derivation (urban gen / urban derive) ---
107
+
108
+ const GreetIn = envelope("GreetIn", { who: "string" });
109
+ const GreetOut = envelope("GreetOut", { message: "string" });
110
+ const greetFlow = defineFlow("greet", { hello: { in: GreetIn, out: GreetOut } }, (w) => {
111
+ w.run("hello", async () => ({ message: "hi" }));
112
+ });
113
+
114
+ const CODE_FIRST_MANIFEST = JSON.stringify({
115
+ id: "cf",
116
+ data: { default: "app" },
117
+ models: { workflows: ["workflows/*.ts"] },
118
+ types: { greeting: { table: "greetings", fields: { who: { type: "string" } } } },
119
+ });
120
+
121
+ function codeFirstIO() {
122
+ return memIO(
123
+ {
124
+ "/cf/nano.app.json": CODE_FIRST_MANIFEST,
125
+ "/cf/workflows/greet.ts": "// derived in-memory via importModule stub",
126
+ },
127
+ { "/cf/workflows/greet.ts": { greet: greetFlow } },
128
+ );
129
+ }
130
+
131
+ test("runGen derives processes/<id>.bpmn from workflows/*.ts and feeds worker-io off it", async () => {
132
+ const io = codeFirstIO();
133
+ const res = await runGen({ root: "/cf", io });
134
+ const bpmn = io.files["/cf/processes/greet.bpmn"];
135
+ assert.ok(bpmn, "expected a derived processes/greet.bpmn");
136
+ assert.ok(bpmn.includes(MODEL_PROVENANCE), "derived .bpmn must be provenance-stamped");
137
+ assert.ok(bpmn.includes('type="greet:hello"'), "derived .bpmn carries the task type");
138
+ // worker-io is derived FROM the in-memory model, not from any on-disk file.
139
+ assert.ok(io.files["/cf/nano-generated/worker-io.d.ts"].includes("greet:hello"));
140
+ assert.equal(res.incomplete, false);
141
+ assert.deepEqual(res.modelErrors, []);
142
+ });
143
+
144
+ test("gen --no-models derives in-memory for worker-io but writes no .bpmn", async () => {
145
+ const io = codeFirstIO();
146
+ const res = await runGen({ root: "/cf", io, emitModels: false });
147
+ assert.equal(io.files["/cf/processes/greet.bpmn"], undefined, "must not write .bpmn");
148
+ // still derives the model in-memory so worker-io is populated.
149
+ assert.ok(io.files["/cf/nano-generated/worker-io.d.ts"].includes("greet:hello"));
150
+ assert.equal(res.incomplete, false);
151
+ });
152
+
153
+ test("the provenance sweep removes a stale derived .bpmn no longer backed by a workflow", async () => {
154
+ const io = codeFirstIO();
155
+ // a leftover derived model from a since-deleted flow, provenance-stamped.
156
+ io.files["/cf/processes/stale.bpmn"] = `<?xml version="1.0"?>\n${MODEL_PROVENANCE}\n<bpmn:x/>`;
157
+ await runGen({ root: "/cf", io });
158
+ assert.ok(io.files["/cf/processes/greet.bpmn"], "keeps the live derived model");
159
+ assert.equal(io.files["/cf/processes/stale.bpmn"], undefined, "sweeps the stale derived model");
160
+ });
161
+
162
+ test("the sweep never touches an authored (un-stamped) .bpmn", async () => {
163
+ const io = codeFirstIO();
164
+ io.files["/cf/processes/authored.bpmn"] = `<?xml version="1.0"?>\n<bpmn:authored/>`;
165
+ await runGen({ root: "/cf", io });
166
+ assert.ok(io.files["/cf/processes/authored.bpmn"], "authored .bpmn must survive the sweep");
167
+ });
@@ -10,6 +10,7 @@ import { deriveDomain } from "./derivers/domain.ts";
10
10
  import { deriveWorkerBindings, type ModelSource } from "./derivers/worker-io.ts";
11
11
  import { deriveMeta } from "./derivers/meta.ts";
12
12
  import { deriveMessageBindings } from "./derivers/messages.ts";
13
+ import { deriveModels, type DerivedModels, type ModelError, MODEL_PROVENANCE } from "./models.ts";
13
14
 
14
15
  /** Minimal filesystem port. Node/Deno impls live in `fsio.ts`. */
15
16
  export interface GenIO {
@@ -18,18 +19,40 @@ export interface GenIO {
18
19
  /** File names (not paths) in a directory; empty if it does not exist. */
19
20
  listDir(path: string): Promise<string[]>;
20
21
  exists(path: string): Promise<boolean>;
22
+ /**
23
+ * Import an app module and return its exports. Optional: only present when the caller runs on
24
+ * a runtime that can load the app's TypeScript (Node with type-stripping, or Deno). Code-first
25
+ * model derivation (`workflows/*.ts` → BPMN) needs it; when absent, gen still derives everything
26
+ * else (migrations, domain, worker I/O from authored/derived models).
27
+ */
28
+ importModule?(path: string): Promise<Record<string, unknown>>;
29
+ /** Delete a file. Optional: enables the provenance-scoped stale-model sweep (skipped when absent). */
30
+ remove?(path: string): Promise<void>;
21
31
  }
22
32
 
23
33
  export interface GenOptions {
24
34
  root: string;
25
35
  io: GenIO;
26
36
  manifestFile?: string;
37
+ /**
38
+ * Whether to emit (write) derived `.bpmn` models. Default true. When false, code-first models
39
+ * are still derived *in-memory* to feed the type-contract derivers (worker I/O, meta, messages)
40
+ * — they are just not written to disk or swept. This is the `urban gen --no-models` path, so
41
+ * the console's type-contract regen never mutates `processes/*.bpmn`.
42
+ */
43
+ emitModels?: boolean;
44
+ /** Derive ONLY the models (skip migrations/domain/worker-I/O). The `urban derive` path. */
45
+ modelsOnly?: boolean;
27
46
  }
28
47
 
29
48
  export interface GenResult {
30
49
  artifacts: DerivedArtifact[];
31
50
  /** Paths that differ from disk (only populated by `check`). */
32
51
  drift: string[];
52
+ /** True if any code-first workflow failed to import/derive. */
53
+ incomplete: boolean;
54
+ /** Per-file model-derivation errors (empty on success). */
55
+ modelErrors: ModelError[];
33
56
  }
34
57
 
35
58
  function join(root: string, rel: string): string {
@@ -44,7 +67,7 @@ function dirOf(p: string): string {
44
67
  }
45
68
 
46
69
  /** Resolve a `dir/*.ext` (or literal) manifest pattern to file paths relative to root. */
47
- async function expandPattern(root: string, io: GenIO, pattern: string): Promise<string[]> {
70
+ export async function expandPattern(root: string, io: GenIO, pattern: string): Promise<string[]> {
48
71
  const star = pattern.indexOf("*");
49
72
  if (star === -1) {
50
73
  return (await io.exists(join(root, pattern))) ? [pattern] : [];
@@ -78,36 +101,55 @@ export async function readModels(
78
101
 
79
102
  /** Collect all artifacts a run would produce, without touching disk beyond reads. */
80
103
  export async function collectArtifacts(opts: GenOptions): Promise<DerivedArtifact[]> {
104
+ return (await collectAll(opts)).artifacts;
105
+ }
106
+
107
+ /** Internal: collect artifacts AND the model-derivation details (needed by `runGen` for the
108
+ * incomplete signal + the provenance-scoped stale sweep). */
109
+ async function collectAll(opts: GenOptions): Promise<{ artifacts: DerivedArtifact[]; derived: DerivedModels }> {
81
110
  const { root, io } = opts;
111
+ const emitModels = opts.emitModels ?? true;
82
112
  const manifestPath = join(root, opts.manifestFile ?? "nano.app.json");
83
113
  const manifest = JSON.parse(await io.readText(manifestPath)) as ToolkitManifest & {
84
- models?: { processes?: string[] };
114
+ models?: { processes?: string[]; workflows?: string[] };
85
115
  };
86
116
 
87
117
  const artifacts: DerivedArtifact[] = [];
88
118
 
89
119
  // 1. types → migrations + domain row types (DomainTables spine + DomainTypes registry)
90
- if (manifest.types && Object.keys(manifest.types).length > 0) {
120
+ if (!opts.modelsOnly && manifest.types && Object.keys(manifest.types).length > 0) {
91
121
  artifacts.push(...deriveMigrations(manifest));
92
122
  artifacts.push(...deriveDomain(manifest));
93
123
  }
94
124
 
95
- // 2. models → worker I/O index + model-metadata accessor + message payload map
96
- const models = await readModels(root, io, manifest);
97
- if (models.length > 0) {
98
- const declaredTypeIds = Object.keys(manifest.types ?? {});
99
- artifacts.push(...deriveWorkerBindings(models, declaredTypeIds));
100
- artifacts.push(...deriveMeta(models));
101
- artifacts.push(...deriveMessageBindings(models, declaredTypeIds));
125
+ // 2. code-first models → derive BPMN from `workflows/*.ts` (executes the app's TS). The derived
126
+ // `.bpmn` are emitted only when `emitModels` (default), but always fed to the type-contract
127
+ // derivers below so worker I/O works for a code-first app even on a `--no-models` run.
128
+ const derived = await deriveModels(root, io, manifest);
129
+ if (emitModels) artifacts.push(...derived.artifacts);
130
+
131
+ // 3. models (authored on-disk + derived) → worker I/O index + meta accessor + message map.
132
+ // Drop any on-disk model whose path a derived model already covers, to avoid double-counting.
133
+ if (!opts.modelsOnly) {
134
+ const derivedPaths = new Set(derived.artifacts.map((a) => a.path));
135
+ const diskModels = (await readModels(root, io, manifest)).filter((m) => !derivedPaths.has(m.path));
136
+ const models = [...diskModels, ...derived.models];
137
+ if (models.length > 0) {
138
+ const declaredTypeIds = Object.keys(manifest.types ?? {});
139
+ artifacts.push(...deriveWorkerBindings(models, declaredTypeIds));
140
+ artifacts.push(...deriveMeta(models));
141
+ artifacts.push(...deriveMessageBindings(models, declaredTypeIds));
142
+ }
102
143
  }
103
144
 
104
- return sortArtifacts(artifacts);
145
+ return { artifacts: sortArtifacts(artifacts), derived };
105
146
  }
106
147
 
107
148
  /** Run the derivers and write artifacts (or, with `check`, report drift without writing). */
108
149
  export async function runGen(opts: GenOptions & { check?: boolean }): Promise<GenResult> {
109
- const artifacts = await collectArtifacts(opts);
150
+ const { artifacts, derived } = await collectAll(opts);
110
151
  const { root, io } = opts;
152
+ const emitModels = opts.emitModels ?? true;
111
153
  const drift: string[] = [];
112
154
 
113
155
  for (const a of artifacts) {
@@ -119,7 +161,33 @@ export async function runGen(opts: GenOptions & { check?: boolean }): Promise<Ge
119
161
  await io.writeText(abs, a.content);
120
162
  }
121
163
  }
122
- return { artifacts, drift };
164
+
165
+ // Provenance-scoped stale sweep: delete derived `.bpmn` for flows that no longer exist. Only
166
+ // in write mode, only when we emitted models, only on a complete derivation (never delete when
167
+ // a workflow failed to load), and only files bearing our marker (authored models are untouched).
168
+ if (!opts.check && emitModels && derived.attempted && !derived.incomplete && io.remove) {
169
+ const keep = new Set(derived.artifacts.map((a) => a.path));
170
+ for (const name of await io.listDir(join(root, derived.outDir))) {
171
+ if (!name.endsWith(".bpmn")) continue;
172
+ const rel = `${derived.outDir}/${name}`;
173
+ if (keep.has(rel)) continue;
174
+ const content = await io.readText(join(root, rel));
175
+ if (content.includes(MODEL_PROVENANCE)) await io.remove(join(root, rel));
176
+ }
177
+ }
178
+
179
+ return { artifacts, drift, incomplete: derived.incomplete, modelErrors: derived.errors };
123
180
  }
124
181
 
125
182
  export { dirOf, join as joinPath };
183
+
184
+ /** Derive code-first models in-memory (no writes) — backs `urban derive --stdout`, the
185
+ * non-mutating preview the console's read-only model viewer uses. */
186
+ export async function previewModels(opts: GenOptions): Promise<DerivedModels> {
187
+ const { root, io } = opts;
188
+ const manifestPath = join(root, opts.manifestFile ?? "nano.app.json");
189
+ const manifest = JSON.parse(await io.readText(manifestPath)) as {
190
+ models?: { processes?: string[]; workflows?: string[] };
191
+ };
192
+ return deriveModels(root, io, manifest);
193
+ }
@@ -83,10 +83,22 @@ import { messagesDeriver } from "./derivers/messages.ts";
83
83
  export const DERIVERS = [migrationsDeriver, domainDeriver, workerIoDeriver, metaDeriver, messagesDeriver] as const;
84
84
 
85
85
  // Gen orchestrator + IO
86
- export { collectArtifacts, runGen, joinPath, readModels } from "./gen.ts";
86
+ export { collectArtifacts, runGen, joinPath, readModels, expandPattern, previewModels } from "./gen.ts";
87
87
  export type { GenIO, GenOptions, GenResult } from "./gen.ts";
88
88
  export { createNodeGenIO } from "./fsio.ts";
89
89
 
90
+ // Code-first model derivation (workflows/*.ts → BPMN)
91
+ export {
92
+ deriveModels,
93
+ isWorkflow,
94
+ bpmnFilename,
95
+ processesOutDir,
96
+ DEFAULT_WORKFLOW_PATTERNS,
97
+ PROCESSES_DIR,
98
+ MODEL_PROVENANCE,
99
+ } from "./models.ts";
100
+ export type { DerivedModels, DerivedModelInfo, ModelError, ModelsManifest } from "./models.ts";
101
+
90
102
  // Worker-stub scaffolder (ADR 0056): write-once handler stubs from the model.
91
103
  export {
92
104
  planWorkerScaffold,
@@ -0,0 +1,159 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { defineFlow, envelope } from "@nanobpm/workflow";
4
+ import { deriveModels, isWorkflow, bpmnFilename, processesOutDir, MODEL_PROVENANCE } from "./models.ts";
5
+ import type { GenIO } from "./gen.ts";
6
+
7
+ /** In-memory GenIO whose `importModule` returns pre-registered module exports by path. */
8
+ function memIO(
9
+ files: Record<string, string>,
10
+ modules: Record<string, Record<string, unknown>> = {},
11
+ ): GenIO {
12
+ return {
13
+ async readText(p) {
14
+ if (!(p in files)) throw new Error(`ENOENT ${p}`);
15
+ return files[p];
16
+ },
17
+ async writeText(p, c) {
18
+ files[p] = c;
19
+ },
20
+ async listDir(p) {
21
+ const prefix = p.replace(/\/+$/, "") + "/";
22
+ const names = new Set<string>();
23
+ for (const f of Object.keys(files)) {
24
+ if (f.startsWith(prefix)) {
25
+ const rest = f.slice(prefix.length);
26
+ if (!rest.includes("/")) names.add(rest);
27
+ }
28
+ }
29
+ return [...names];
30
+ },
31
+ async exists(p) {
32
+ return p in files || p in modules;
33
+ },
34
+ importModule(p) {
35
+ const key = Object.keys(modules).find((k) => p.endsWith(k));
36
+ if (!key) return Promise.reject(new Error(`ENOENT ${p}`));
37
+ return Promise.resolve(modules[key]);
38
+ },
39
+ };
40
+ }
41
+
42
+ const GreetIn = envelope("GreetIn", { who: "string" });
43
+ const GreetOut = envelope("GreetOut", { message: "string" });
44
+
45
+ /** A real one-step declarative flow (exercises the actual toBpmn + layout pipeline). */
46
+ const greet = defineFlow("greet", { hello: { in: GreetIn, out: GreetOut } }, (w) => {
47
+ w.run("hello", async () => ({ message: "hi" }));
48
+ });
49
+
50
+ const CODE_FIRST_MANIFEST = { models: { workflows: ["workflows/*.ts"] } };
51
+
52
+ test("isWorkflow recognizes defineFlow exports, rejects everything else", () => {
53
+ assert.equal(isWorkflow(greet), true);
54
+ assert.equal(isWorkflow({ id: "x", kind: "imperative" }), true);
55
+ assert.equal(isWorkflow({ id: "x", kind: "nope" }), false);
56
+ assert.equal(isWorkflow({ kind: "declarative" }), false);
57
+ assert.equal(isWorkflow(null), false);
58
+ assert.equal(isWorkflow("greet"), false);
59
+ });
60
+
61
+ test("bpmnFilename sanitizes ids into safe .bpmn filenames", () => {
62
+ assert.equal(bpmnFilename("greet"), "greet.bpmn");
63
+ assert.equal(bpmnFilename("orders/place"), "orders-place.bpmn");
64
+ assert.equal(bpmnFilename("a b:c"), "a-b-c.bpmn");
65
+ });
66
+
67
+ test("processesOutDir defaults to processes/, or the dir of a models.processes glob", () => {
68
+ assert.equal(processesOutDir({}), "processes");
69
+ assert.equal(processesOutDir({ models: { processes: ["resources/processes/*.bpmn"] } }), "resources/processes");
70
+ assert.equal(processesOutDir({ models: { processes: ["src/models/*.bpmn"] } }), "src/models");
71
+ });
72
+
73
+ test("deriveModels turns workflows/*.ts into a provenance-stamped processes/<id>.bpmn", async () => {
74
+ const io = memIO(
75
+ { "/app/workflows/greet.ts": "// source" },
76
+ { "workflows/greet.ts": { greet } },
77
+ );
78
+ const d = await deriveModels("/app", io, CODE_FIRST_MANIFEST);
79
+
80
+ assert.equal(d.attempted, true);
81
+ assert.equal(d.incomplete, false);
82
+ assert.deepEqual(d.ids, ["greet"]);
83
+ assert.equal(d.artifacts.length, 1);
84
+ assert.equal(d.artifacts[0].path, "processes/greet.bpmn");
85
+ assert.match(d.artifacts[0].content, /<!--.*@nanobpm\/urban/);
86
+ assert.ok(d.artifacts[0].content.includes(MODEL_PROVENANCE));
87
+ assert.match(d.artifacts[0].content, /greet/);
88
+ // the in-memory ModelSource is fed to the type-contract derivers
89
+ assert.deepEqual(d.models.map((m) => m.path), ["processes/greet.bpmn"]);
90
+ assert.equal(d.list[0].id, "greet");
91
+ assert.equal(d.list[0].kind, "declarative");
92
+ });
93
+
94
+ test("deriveModels is a no-op without a module loader (pure/IDE callers)", async () => {
95
+ const io = memIO({ "/app/workflows/greet.ts": "// source" });
96
+ delete (io as { importModule?: unknown }).importModule;
97
+ const d = await deriveModels("/app", io, CODE_FIRST_MANIFEST);
98
+ assert.equal(d.attempted, false);
99
+ assert.equal(d.artifacts.length, 0);
100
+ });
101
+
102
+ test("deriveModels writes into the manifest's models.processes dir when declared", async () => {
103
+ const io = memIO(
104
+ { "/app/workflows/greet.ts": "// source" },
105
+ { "workflows/greet.ts": { greet } },
106
+ );
107
+ const d = await deriveModels("/app", io, {
108
+ models: { workflows: ["workflows/*.ts"], processes: ["resources/processes/*.bpmn"] },
109
+ });
110
+ assert.equal(d.outDir, "resources/processes");
111
+ assert.equal(d.artifacts[0].path, "resources/processes/greet.bpmn");
112
+ });
113
+
114
+ test("deriveModels records a failing workflow as incomplete without throwing", async () => {
115
+ const io = memIO(
116
+ { "/app/workflows/bad.ts": "// source" },
117
+ {}, // no module registered → importModule rejects
118
+ );
119
+ const d = await deriveModels("/app", io, CODE_FIRST_MANIFEST);
120
+ assert.equal(d.attempted, true);
121
+ assert.equal(d.incomplete, true);
122
+ assert.equal(d.errors.length, 1);
123
+ assert.equal(d.errors[0].path, "workflows/bad.ts");
124
+ });
125
+
126
+ test("deriveModels flags a duplicate flow id across two files", async () => {
127
+ const io = memIO(
128
+ { "/app/workflows/a.ts": "// a", "/app/workflows/b.ts": "// b" },
129
+ { "workflows/a.ts": { greet }, "workflows/b.ts": { greet } },
130
+ );
131
+ const d = await deriveModels("/app", io, CODE_FIRST_MANIFEST);
132
+ assert.equal(d.incomplete, true);
133
+ assert.ok(d.errors.some((e) => /duplicate flow id "greet"/.test(e.message)));
134
+ assert.equal(d.ids.length, 1); // first wins
135
+ });
136
+
137
+ test("deriveModels flags two distinct flows sharing an id in the same module", async () => {
138
+ // two SEPARATE defineFlow objects, same id, same file — a real collision, not a re-export.
139
+ const greetA = defineFlow("greet", { hello: { in: GreetIn, out: GreetOut } }, (w) => {
140
+ w.run("hello", async () => ({ message: "a" }));
141
+ });
142
+ const greetB = defineFlow("greet", { hello: { in: GreetIn, out: GreetOut } }, (w) => {
143
+ w.run("hello", async () => ({ message: "b" }));
144
+ });
145
+ const io = memIO({ "/app/workflows/dup.ts": "// dup" }, { "workflows/dup.ts": { greetA, greetB } });
146
+ const d = await deriveModels("/app", io, CODE_FIRST_MANIFEST);
147
+ assert.equal(d.incomplete, true);
148
+ assert.ok(d.errors.some((e) => /duplicate flow id "greet" \(also defined in the same module\)/.test(e.message)));
149
+ assert.equal(d.ids.length, 1); // only the first is derived
150
+ });
151
+
152
+ test("deriveModels treats a genuine same-module re-export as one model, no error", async () => {
153
+ // the SAME flow object exported under two names in one file — harmless.
154
+ const io = memIO({ "/app/workflows/re.ts": "// re" }, { "workflows/re.ts": { greet, alsoGreet: greet } });
155
+ const d = await deriveModels("/app", io, CODE_FIRST_MANIFEST);
156
+ assert.equal(d.incomplete, false);
157
+ assert.deepEqual(d.errors, []);
158
+ assert.equal(d.ids.length, 1);
159
+ });