@nanobpm/urban 0.1.0 → 0.3.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 (44) hide show
  1. package/README.md +93 -16
  2. package/package.json +16 -11
  3. package/src/cli.ts +6 -5
  4. package/src/index.ts +49 -0
  5. package/src/runtime/adapters/deno.ts +124 -0
  6. package/src/runtime/adapters/detect.ts +22 -0
  7. package/src/runtime/adapters/node.ts +119 -0
  8. package/src/runtime/core/context.ts +35 -0
  9. package/src/runtime/core/glob.test.ts +45 -0
  10. package/src/runtime/core/glob.ts +63 -0
  11. package/src/runtime/core/host.ts +130 -0
  12. package/src/runtime/core/manifest.test.ts +31 -0
  13. package/src/runtime/core/manifest.ts +112 -0
  14. package/src/runtime/core/modules/datasource.ts +220 -0
  15. package/src/runtime/core/modules/deploy.ts +41 -0
  16. package/src/runtime/core/modules/security.ts +53 -0
  17. package/src/runtime/core/modules/surfaces.test.ts +42 -0
  18. package/src/runtime/core/modules/surfaces.ts +103 -0
  19. package/src/runtime/core/modules/triggers.ts +143 -0
  20. package/src/runtime/core/modules/units.test.ts +22 -0
  21. package/src/runtime/core/modules/workers.ts +96 -0
  22. package/src/runtime/core/purity.test.ts +31 -0
  23. package/src/runtime/core/router.test.ts +16 -0
  24. package/src/runtime/core/router.ts +55 -0
  25. package/src/runtime/core/runtime.test.ts +258 -0
  26. package/src/runtime/core/runtime.ts +213 -0
  27. package/src/runtime/core/validate.test.ts +73 -0
  28. package/src/runtime/core/validate.ts +129 -0
  29. package/src/runtime/engine/nanosdk.test.ts +124 -0
  30. package/src/runtime/engine/nanosdk.ts +123 -0
  31. package/src/runtime/engine/rest.ts +243 -0
  32. package/src/runtime/index.ts +68 -0
  33. package/src/runtime/run.ts +91 -0
  34. package/src/runtime/schema/nano-app.schema.json +471 -0
  35. package/src/toolkit/artifact.ts +29 -0
  36. package/src/toolkit/derivers/migrations.test.ts +50 -0
  37. package/src/toolkit/derivers/migrations.ts +99 -0
  38. package/src/toolkit/derivers/worker-io.test.ts +75 -0
  39. package/src/toolkit/derivers/worker-io.ts +176 -0
  40. package/src/toolkit/fsio.test.ts +21 -0
  41. package/src/toolkit/fsio.ts +36 -0
  42. package/src/toolkit/gen.test.ts +85 -0
  43. package/src/toolkit/gen.ts +109 -0
  44. package/src/toolkit/index.ts +37 -0
@@ -0,0 +1,75 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import {
4
+ deriveWorkerBindings,
5
+ emitWorkerBindings,
6
+ scanModelWorkers,
7
+ type WorkerIo,
8
+ } from "./worker-io.ts";
9
+
10
+ const XML = `<?xml version="1.0"?>
11
+ <bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
12
+ xmlns:zeebe="http://camunda.org/schema/zeebe/1.0">
13
+ <bpmn:process id="orders">
14
+ <bpmn:serviceTask id="Charge" name="Charge card">
15
+ <bpmn:extensionElements>
16
+ <zeebe:taskDefinition type="orders.charge" />
17
+ <zeebe:ioMapping>
18
+ <zeebe:property name="io.nanobpm.dataEnvelope.in" value="Payment" />
19
+ <zeebe:property name="io.nanobpm.dataEnvelope.out" value="Receipt" />
20
+ </zeebe:ioMapping>
21
+ </bpmn:extensionElements>
22
+ </bpmn:serviceTask>
23
+ <bpmn:serviceTask id="Notify">
24
+ <bpmn:extensionElements>
25
+ <zeebe:taskDefinition type="orders.notify" />
26
+ </bpmn:extensionElements>
27
+ </bpmn:serviceTask>
28
+ </bpmn:process>
29
+ </bpmn:definitions>`;
30
+
31
+ test("scanModelWorkers extracts task type, element id, process, and envelope io", () => {
32
+ const w = scanModelWorkers(XML);
33
+ const charge = w.find((x) => x.taskType === "orders.charge") as WorkerIo;
34
+ assert.ok(charge);
35
+ assert.equal(charge.elementId, "Charge");
36
+ assert.equal(charge.process, "orders");
37
+ assert.equal(charge.in, "Payment");
38
+ assert.equal(charge.out, "Receipt");
39
+ const notify = w.find((x) => x.taskType === "orders.notify") as WorkerIo;
40
+ assert.equal(notify.in, undefined);
41
+ assert.equal(notify.out, undefined);
42
+ });
43
+
44
+ test("emitWorkerBindings matches the console format (union + DomainTypes refs)", () => {
45
+ const out = emitWorkerBindings(
46
+ [{ taskType: "orders.charge", inputType: "Payment", outputType: "Receipt" }],
47
+ ["Payment", "Receipt"],
48
+ );
49
+ assert.match(out, /AUTO-GENERATED by nanobpmn from the App manifest \(ADR 0033 §3\)/);
50
+ assert.match(out, /import type \{ DomainTypes \} from ".\/domain-rows.d.ts";/);
51
+ assert.match(out, /export type WorkerTaskType = "orders.charge";/);
52
+ assert.match(out, /export interface WorkerInputs \{\n {2}"orders.charge": DomainTypes\["Payment"\];\n\}/);
53
+ assert.match(out, /export interface WorkerOutputs \{\n {2}"orders.charge": DomainTypes\["Receipt"\];\n\}/);
54
+ });
55
+
56
+ test("emitWorkerBindings falls back to string union + no import when nothing is declared", () => {
57
+ const out = emitWorkerBindings([{ taskType: "orders.notify" }], []);
58
+ assert.match(out, /export type WorkerTaskType = "orders.notify";/);
59
+ assert.doesNotMatch(out, /import type \{ DomainTypes \}/);
60
+ assert.match(out, /export interface WorkerInputs \{\}/);
61
+ });
62
+
63
+ test("deriveWorkerBindings is deterministic and emits worker-io.d.ts", () => {
64
+ const models = [{ path: "processes/orders.bpmn", xml: XML }];
65
+ const a1 = deriveWorkerBindings(models, ["Payment", "Receipt"]);
66
+ const a2 = deriveWorkerBindings(models, ["Payment", "Receipt"]);
67
+ assert.deepEqual(a1, a2);
68
+ assert.equal(a1[0].path, "nano-generated/worker-io.d.ts");
69
+ assert.match(a1[0].content, /"orders.charge": DomainTypes\["Payment"\]/);
70
+ });
71
+
72
+ test("a service task without a task definition is ignored", () => {
73
+ const xml = `<bpmn:process id="p"><bpmn:serviceTask id="X"></bpmn:serviceTask></bpmn:process>`;
74
+ assert.deepEqual(scanModelWorkers(xml), []);
75
+ });
@@ -0,0 +1,176 @@
1
+ // Deriver: BPMN models → `worker-io.d.ts`, the worker type map (ADR 0033 §3). Scans each
2
+ // process for service tasks and extracts the Zeebe task type plus the data-envelope in/out
3
+ // contract (io.nanobpm.dataEnvelope.in / .out, carried as zeebe:property). It emits the SAME
4
+ // `worker-io.d.ts` the console generates (a faithful port of `emitWorkerBindings` in the
5
+ // server's domain_types.ts), so the toolkit is a drop-in for the IDE's codegen (ADR 0053).
6
+
7
+ import type { DerivedArtifact, Deriver } from "../artifact.ts";
8
+ import { GENERATED_DIR } from "../artifact.ts";
9
+
10
+ export interface ModelSource {
11
+ /** Path (used only for diagnostics/ordering). */
12
+ path: string;
13
+ xml: string;
14
+ }
15
+
16
+ export interface WorkerIo {
17
+ taskType: string;
18
+ elementId?: string;
19
+ process?: string;
20
+ /** Data-envelope input type id (io.nanobpm.dataEnvelope.in). */
21
+ in?: string;
22
+ /** Data-envelope output type id (io.nanobpm.dataEnvelope.out). */
23
+ out?: string;
24
+ }
25
+
26
+ /** Filename the console uses; kept identical so the artifact is a drop-in. */
27
+ export const WORKER_BINDINGS_DTS = "worker-io.d.ts";
28
+ /** The domain type declarations `worker-io.d.ts` imports from (console: domain-rows.d.ts). */
29
+ export const DOMAIN_DTS = "domain-rows.d.ts";
30
+
31
+ const ENVELOPE_IN = "io.nanobpm.dataEnvelope.in";
32
+ const ENVELOPE_OUT = "io.nanobpm.dataEnvelope.out";
33
+
34
+ function attr(tag: string, name: string): string | undefined {
35
+ const m = tag.match(new RegExp(`\\b${name}\\s*=\\s*"([^"]*)"`));
36
+ return m ? m[1] : undefined;
37
+ }
38
+
39
+ function processId(xml: string): string | undefined {
40
+ const m = xml.match(/<bpmn:process\b[^>]*>/);
41
+ return m ? attr(m[0], "id") : undefined;
42
+ }
43
+
44
+ /** Scan one BPMN document for service-task worker I/O. */
45
+ export function scanModelWorkers(xml: string): WorkerIo[] {
46
+ const proc = processId(xml);
47
+ const out: WorkerIo[] = [];
48
+ const blockRe = /<bpmn:serviceTask\b([^>]*)>([\s\S]*?)<\/bpmn:serviceTask>/g;
49
+ let m: RegExpExecArray | null;
50
+ while ((m = blockRe.exec(xml)) !== null) {
51
+ const openAttrs = m[1];
52
+ const body = m[2];
53
+ const elementId = attr(`<x ${openAttrs}>`, "id");
54
+ const tdMatch = body.match(/<zeebe:taskDefinition\b[^>]*>/);
55
+ if (!tdMatch) continue;
56
+ const taskType = attr(tdMatch[0], "type");
57
+ if (!taskType) continue;
58
+
59
+ const io: WorkerIo = { taskType, elementId, process: proc };
60
+ const propRe = /<zeebe:property\b[^>]*>/g;
61
+ let p: RegExpExecArray | null;
62
+ while ((p = propRe.exec(body)) !== null) {
63
+ const pname = attr(p[0], "name");
64
+ const pvalue = attr(p[0], "value");
65
+ if (pname === ENVELOPE_IN && pvalue) io.in = pvalue;
66
+ else if (pname === ENVELOPE_OUT && pvalue) io.out = pvalue;
67
+ }
68
+ out.push(io);
69
+ }
70
+ return out;
71
+ }
72
+
73
+ // --- Faithful port of the console's emitWorkerBindings (domain_types.ts) so the emitted
74
+ // worker-io.d.ts is byte-compatible with the IDE's own codegen. ---
75
+
76
+ function typeRefFor(id: string | undefined, declared: Set<string>): string | undefined {
77
+ return id != null && declared.has(id) ? `DomainTypes[${JSON.stringify(id)}]` : undefined;
78
+ }
79
+
80
+ export interface WorkerBindingDecl {
81
+ taskType?: string;
82
+ inputType?: string;
83
+ outputType?: string;
84
+ }
85
+
86
+ /** Emit `worker-io.d.ts` from worker bindings + the set of declared domain type ids. */
87
+ export function emitWorkerBindings(
88
+ workers: WorkerBindingDecl[],
89
+ declaredTypeIds: Iterable<string>,
90
+ ): string {
91
+ const declared = new Set(declaredTypeIds);
92
+ const propKey = (t: string) => JSON.stringify(t);
93
+ const taskTypes = [
94
+ ...new Set(
95
+ workers
96
+ .map((w) => w?.taskType)
97
+ .filter((t): t is string => typeof t === "string" && t.length > 0),
98
+ ),
99
+ ];
100
+ const taskTypeUnion = taskTypes.length > 0
101
+ ? taskTypes.map((t) => JSON.stringify(t)).join(" | ")
102
+ : "string";
103
+ const inputs: string[] = [];
104
+ const outputs: string[] = [];
105
+ for (const w of workers) {
106
+ if (typeof w?.taskType !== "string" || w.taskType.length === 0) continue;
107
+ const inRef = typeRefFor(w.inputType, declared);
108
+ if (inRef) inputs.push(` ${propKey(w.taskType)}: ${inRef};`);
109
+ const outRef = typeRefFor(w.outputType, declared);
110
+ if (outRef) outputs.push(` ${propKey(w.taskType)}: ${outRef};`);
111
+ }
112
+
113
+ const header =
114
+ "// AUTO-GENERATED by nanobpmn from the App manifest (ADR 0033 §3).\n" +
115
+ "// The bridge from the process model to the worker type system: each declared\n" +
116
+ "// worker's `taskType` maps to the TS type of its input payload (`job.variables`)\n" +
117
+ "// and result, so the typed `defineWorker` types a handler by its job type. Do\n" +
118
+ "// not edit — regenerated from the manifest. Erased to plain JS at compile.\n" +
119
+ "// eslint-disable\n";
120
+
121
+ const needsRegistry = inputs.length > 0 || outputs.length > 0;
122
+ const importTypes = needsRegistry
123
+ ? `import type { DomainTypes } from "./${DOMAIN_DTS}";\n`
124
+ : "";
125
+
126
+ const inputsIface = inputs.length > 0
127
+ ? `export interface WorkerInputs {\n${inputs.join("\n")}\n}\n`
128
+ : `export interface WorkerInputs {}\n`;
129
+ const outputsIface = outputs.length > 0
130
+ ? `export interface WorkerOutputs {\n${outputs.join("\n")}\n}\n`
131
+ : `export interface WorkerOutputs {}\n`;
132
+ const headersIface = `export interface WorkerHeaders {}\n`;
133
+
134
+ return `${header}\n` +
135
+ importTypes +
136
+ `\n/** Untyped fallback for a job whose worker declares no input/output type. */\n` +
137
+ `export type WorkerVars = Record<string, unknown>;\n\n` +
138
+ `/** Untyped fallback for a job whose worker declares no custom headers. */\n` +
139
+ `export type WorkerHdrs = Record<string, unknown>;\n\n` +
140
+ `/** Every declared worker \`taskType\` (ADR 0033 §3): the model-derived set the\n` +
141
+ ` * typed \`defineWorker\` accepts, so \`type\` autocompletes and rejects unknown jobs. */\n` +
142
+ `export type WorkerTaskType = ${taskTypeUnion};\n\n` +
143
+ `/** Input payload (\`job.variables\`) per declared worker, keyed by \`taskType\`. */\n` +
144
+ inputsIface +
145
+ `\n/** Output payload (worker result) per declared worker, keyed by \`taskType\`. */\n` +
146
+ outputsIface +
147
+ `\n/** Custom headers (\`job.customHeaders\`) per declared worker, keyed by \`taskType\`.\n` +
148
+ ` * Header values are strings on the wire, so each declared key maps to \`string\`;\n` +
149
+ ` * the extra index signature keeps undeclared headers accessible (non-breaking). */\n` +
150
+ headersIface;
151
+ }
152
+
153
+ /**
154
+ * Derive `nano-generated/worker-io.d.ts` from BPMN models. `declaredTypeIds` is the set of
155
+ * domain type ids declared in the manifest `types` — an envelope in/out only becomes a typed
156
+ * ref when it names a declared type (matches the console's typeRefFor).
157
+ */
158
+ export function deriveWorkerBindings(
159
+ models: ModelSource[],
160
+ declaredTypeIds: Iterable<string> = [],
161
+ ): DerivedArtifact[] {
162
+ const workers: WorkerBindingDecl[] = [];
163
+ for (const model of [...models].sort((a, b) => (a.path < b.path ? -1 : 1))) {
164
+ for (const w of scanModelWorkers(model.xml)) {
165
+ workers.push({ taskType: w.taskType, inputType: w.in, outputType: w.out });
166
+ }
167
+ }
168
+ const content = emitWorkerBindings(workers, declaredTypeIds);
169
+ return [{ path: `${GENERATED_DIR}/${WORKER_BINDINGS_DTS}`, content }];
170
+ }
171
+
172
+ export const workerIoDeriver: Deriver<{ models: ModelSource[]; declaredTypeIds: Iterable<string> }> = {
173
+ id: "model->worker-io",
174
+ describe: "Derive worker-io.d.ts (task type + data-envelope in/out) from BPMN models.",
175
+ derive: ({ models, declaredTypeIds }) => deriveWorkerBindings(models, declaredTypeIds),
176
+ };
@@ -0,0 +1,21 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtemp, mkdir, writeFile } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { createNodeGenIO } from "./fsio.ts";
7
+
8
+ test("listDir returns file names only, excluding subdirectories", async () => {
9
+ const dir = await mkdtemp(join(tmpdir(), "urban-fsio-"));
10
+ await writeFile(join(dir, "a.bpmn"), "<definitions/>");
11
+ await writeFile(join(dir, "b.txt"), "x");
12
+ await mkdir(join(dir, "nested"));
13
+ const io = createNodeGenIO();
14
+ const names = (await io.listDir(dir)).sort();
15
+ assert.deepEqual(names, ["a.bpmn", "b.txt"]);
16
+ });
17
+
18
+ test("listDir returns empty for a missing directory", async () => {
19
+ const io = createNodeGenIO();
20
+ assert.deepEqual(await io.listDir(join(tmpdir(), "urban-fsio-does-not-exist-xyz")), []);
21
+ });
@@ -0,0 +1,36 @@
1
+ // Node/Deno filesystem implementation of the GenIO port. Uses node:fs/promises, which both
2
+ // Node and Deno provide, so a single implementation serves both runtimes.
3
+
4
+ import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
5
+ import { dirname } from "node:path";
6
+ import type { GenIO } from "./gen.ts";
7
+
8
+ export function createNodeGenIO(): GenIO {
9
+ return {
10
+ async readText(path: string): Promise<string> {
11
+ return readFile(path, "utf8");
12
+ },
13
+ async writeText(path: string, content: string): Promise<void> {
14
+ await mkdir(dirname(path), { recursive: true });
15
+ await writeFile(path, content);
16
+ },
17
+ async listDir(path: string): Promise<string[]> {
18
+ try {
19
+ // The GenIO contract is file names only — exclude subdirectories so
20
+ // pattern expansion never hands a directory to readText()/JSON.parse.
21
+ const entries = await readdir(path, { withFileTypes: true });
22
+ return entries.filter((e) => e.isFile()).map((e) => e.name);
23
+ } catch {
24
+ return [];
25
+ }
26
+ },
27
+ async exists(path: string): Promise<boolean> {
28
+ try {
29
+ await stat(path);
30
+ return true;
31
+ } catch {
32
+ return false;
33
+ }
34
+ },
35
+ };
36
+ }
@@ -0,0 +1,85 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { runGen, collectArtifacts, type GenIO } from "./gen.ts";
4
+
5
+ /** In-memory filesystem for deterministic, IO-free gen tests. */
6
+ function memIO(files: Record<string, string>): GenIO & { files: Record<string, string> } {
7
+ return {
8
+ files,
9
+ async readText(p) {
10
+ if (!(p in files)) throw new Error(`ENOENT ${p}`);
11
+ return files[p];
12
+ },
13
+ async writeText(p, c) {
14
+ files[p] = c;
15
+ },
16
+ async listDir(p) {
17
+ const prefix = p.replace(/\/+$/, "") + "/";
18
+ const names = new Set<string>();
19
+ for (const f of Object.keys(files)) {
20
+ if (f.startsWith(prefix)) {
21
+ const rest = f.slice(prefix.length);
22
+ if (!rest.includes("/")) names.add(rest);
23
+ }
24
+ }
25
+ return [...names];
26
+ },
27
+ async exists(p) {
28
+ return p in files;
29
+ },
30
+ };
31
+ }
32
+
33
+ const MANIFEST = JSON.stringify({
34
+ id: "demo",
35
+ data: { default: "app" },
36
+ models: { processes: ["processes/*.bpmn"] },
37
+ types: { greeting: { table: "greetings", fields: { who: { type: "string" } } } },
38
+ });
39
+
40
+ const BPMN = `<bpmn:process id="p" xmlns:bpmn="x" xmlns:zeebe="y">
41
+ <bpmn:serviceTask id="T"><bpmn:extensionElements>
42
+ <zeebe:taskDefinition type="demo.do" />
43
+ </bpmn:extensionElements></bpmn:serviceTask>
44
+ </bpmn:process>`;
45
+
46
+ function fixture(): Record<string, string> {
47
+ return {
48
+ "/app/nano.app.json": MANIFEST,
49
+ "/app/processes/p.bpmn": BPMN,
50
+ };
51
+ }
52
+
53
+ test("runGen writes migrations and the worker index", async () => {
54
+ const io = memIO(fixture());
55
+ const res = await runGen({ root: "/app", io });
56
+ const paths = res.artifacts.map((a) => a.path).sort();
57
+ assert.deepEqual(paths, [
58
+ "nano-generated/app.schema.sql",
59
+ "nano-generated/worker-io.d.ts",
60
+ ]);
61
+ assert.ok(io.files["/app/nano-generated/app.schema.sql"].includes("CREATE TABLE"));
62
+ assert.ok(io.files["/app/nano-generated/worker-io.d.ts"].includes("demo.do"));
63
+ });
64
+
65
+ test("gen --check reports no drift right after a write", async () => {
66
+ const io = memIO(fixture());
67
+ await runGen({ root: "/app", io });
68
+ const res = await runGen({ root: "/app", io, check: true });
69
+ assert.deepEqual(res.drift, []);
70
+ });
71
+
72
+ test("gen --check detects drift when a generated file is stale", async () => {
73
+ const io = memIO(fixture());
74
+ await runGen({ root: "/app", io });
75
+ io.files["/app/nano-generated/worker-io.d.ts"] = "// stale";
76
+ const res = await runGen({ root: "/app", io, check: true });
77
+ assert.deepEqual(res.drift, ["nano-generated/worker-io.d.ts"]);
78
+ });
79
+
80
+ test("collectArtifacts touches no writes", async () => {
81
+ const io = memIO(fixture());
82
+ const before = Object.keys(io.files).length;
83
+ await collectArtifacts({ root: "/app", io });
84
+ assert.equal(Object.keys(io.files).length, before);
85
+ });
@@ -0,0 +1,109 @@
1
+ // The gen orchestrator: the one impure edge of the toolkit. It reads an app's manifest and
2
+ // models, runs the pure derivers, and either writes the artifacts to disk (`urban gen`) or
3
+ // compares them against what's on disk and reports drift (`urban gen --check`). The derivers stay
4
+ // pure; all IO is confined here behind a tiny FS port so the same code runs on Node and Deno.
5
+
6
+ import type { DerivedArtifact } from "./artifact.ts";
7
+ import { sortArtifacts } from "./artifact.ts";
8
+ import { deriveMigrations, type ToolkitManifest } from "./derivers/migrations.ts";
9
+ import { deriveWorkerBindings, type ModelSource } from "./derivers/worker-io.ts";
10
+
11
+ /** Minimal filesystem port. Node/Deno impls live in `fsio.ts`. */
12
+ export interface GenIO {
13
+ readText(path: string): Promise<string>;
14
+ writeText(path: string, content: string): Promise<void>;
15
+ /** File names (not paths) in a directory; empty if it does not exist. */
16
+ listDir(path: string): Promise<string[]>;
17
+ exists(path: string): Promise<boolean>;
18
+ }
19
+
20
+ export interface GenOptions {
21
+ root: string;
22
+ io: GenIO;
23
+ manifestFile?: string;
24
+ }
25
+
26
+ export interface GenResult {
27
+ artifacts: DerivedArtifact[];
28
+ /** Paths that differ from disk (only populated by `check`). */
29
+ drift: string[];
30
+ }
31
+
32
+ function join(root: string, rel: string): string {
33
+ // Trim either separator so callers may pass Windows-style paths; GenIO
34
+ // implementations accept forward slashes on all platforms.
35
+ return `${root.replace(/[/\\]+$/, "")}/${rel.replace(/^[/\\]+/, "")}`;
36
+ }
37
+
38
+ function dirOf(p: string): string {
39
+ const i = p.lastIndexOf("/");
40
+ return i <= 0 ? "." : p.slice(0, i);
41
+ }
42
+
43
+ /** Resolve a `dir/*.ext` (or literal) manifest pattern to file paths relative to root. */
44
+ async function expandPattern(root: string, io: GenIO, pattern: string): Promise<string[]> {
45
+ const star = pattern.indexOf("*");
46
+ if (star === -1) {
47
+ return (await io.exists(join(root, pattern))) ? [pattern] : [];
48
+ }
49
+ const slash = pattern.lastIndexOf("/", star);
50
+ const dir = slash === -1 ? "." : pattern.slice(0, slash);
51
+ const tail = pattern.slice(slash + 1); // e.g. "*.bpmn"
52
+ const ext = tail.startsWith("*") ? tail.slice(1) : tail;
53
+ const names = await io.listDir(join(root, dir));
54
+ return names
55
+ .filter((n) => n.endsWith(ext))
56
+ .map((n) => (dir === "." ? n : `${dir}/${n}`))
57
+ .sort();
58
+ }
59
+
60
+ /** Collect all artifacts a run would produce, without touching disk beyond reads. */
61
+ export async function collectArtifacts(opts: GenOptions): Promise<DerivedArtifact[]> {
62
+ const { root, io } = opts;
63
+ const manifestPath = join(root, opts.manifestFile ?? "nano.app.json");
64
+ const manifest = JSON.parse(await io.readText(manifestPath)) as ToolkitManifest & {
65
+ models?: { processes?: string[] };
66
+ };
67
+
68
+ const artifacts: DerivedArtifact[] = [];
69
+
70
+ // 1. types → migrations
71
+ if (manifest.types && Object.keys(manifest.types).length > 0) {
72
+ artifacts.push(...deriveMigrations(manifest));
73
+ }
74
+
75
+ // 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
+ }
83
+ if (models.length > 0) {
84
+ const declaredTypeIds = Object.keys(manifest.types ?? {});
85
+ artifacts.push(...deriveWorkerBindings(models, declaredTypeIds));
86
+ }
87
+
88
+ return sortArtifacts(artifacts);
89
+ }
90
+
91
+ /** Run the derivers and write artifacts (or, with `check`, report drift without writing). */
92
+ export async function runGen(opts: GenOptions & { check?: boolean }): Promise<GenResult> {
93
+ const artifacts = await collectArtifacts(opts);
94
+ const { root, io } = opts;
95
+ const drift: string[] = [];
96
+
97
+ for (const a of artifacts) {
98
+ const abs = join(root, a.path);
99
+ if (opts.check) {
100
+ const current = (await io.exists(abs)) ? await io.readText(abs) : null;
101
+ if (current !== a.content) drift.push(a.path);
102
+ } else {
103
+ await io.writeText(abs, a.content);
104
+ }
105
+ }
106
+ return { artifacts, drift };
107
+ }
108
+
109
+ export { dirOf, join as joinPath };
@@ -0,0 +1,37 @@
1
+ // The Urban derivation toolkit — the shared derivation library. The IDE and the
2
+ // `urban gen` CLI are peer callers of these pure derivers (ADR 0053). Code-first
3
+ // process authoring is provided by `@nanobpm/workflow` (`defineFlow`), not here
4
+ // (ADR 0054) — the toolkit derives migrations and worker I/O from the model.
5
+
6
+ // Artifact + deriver contract
7
+ export type { DerivedArtifact, Deriver } from "./artifact.ts";
8
+ export { GENERATED_DIR, sortArtifacts } from "./artifact.ts";
9
+
10
+ // Derivers
11
+ export {
12
+ deriveMigrations,
13
+ migrationsDeriver,
14
+ sqlType,
15
+ createTableSql,
16
+ } from "./derivers/migrations.ts";
17
+ export type { ToolkitManifest, ToolkitType, ToolkitField } from "./derivers/migrations.ts";
18
+
19
+ export {
20
+ deriveWorkerBindings,
21
+ emitWorkerBindings,
22
+ workerIoDeriver,
23
+ scanModelWorkers,
24
+ WORKER_BINDINGS_DTS,
25
+ DOMAIN_DTS,
26
+ } from "./derivers/worker-io.ts";
27
+ export type { ModelSource, WorkerIo, WorkerBindingDecl } from "./derivers/worker-io.ts";
28
+
29
+ // The registry of all derivers (for discovery / IDE migration).
30
+ import { migrationsDeriver } from "./derivers/migrations.ts";
31
+ import { workerIoDeriver } from "./derivers/worker-io.ts";
32
+ export const DERIVERS = [migrationsDeriver, workerIoDeriver] as const;
33
+
34
+ // Gen orchestrator + IO
35
+ export { collectArtifacts, runGen, joinPath } from "./gen.ts";
36
+ export type { GenIO, GenOptions, GenResult } from "./gen.ts";
37
+ export { createNodeGenIO } from "./fsio.ts";