@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/dist/cli.js +69 -5
- package/dist/toolkit/fsio.js +31 -2
- package/dist/toolkit/gen.d.ts +28 -0
- package/dist/toolkit/gen.js +55 -12
- package/dist/toolkit/index.d.ts +3 -1
- package/dist/toolkit/index.js +3 -1
- package/dist/toolkit/models.d.ts +63 -0
- package/dist/toolkit/models.js +142 -0
- package/package.json +1 -1
- package/src/cli.ts +70 -5
- package/src/toolkit/fsio.test.ts +23 -0
- package/src/toolkit/fsio.ts +22 -2
- package/src/toolkit/gen.test.ts +76 -1
- package/src/toolkit/gen.ts +81 -13
- package/src/toolkit/index.ts +13 -1
- package/src/toolkit/models.test.ts +159 -0
- package/src/toolkit/models.ts +193 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// Model derivation for code-first apps — the toolkit's one *executing* edge.
|
|
2
|
+
//
|
|
3
|
+
// Unlike the pure derivers (migrations, domain, worker I/O), which transform strings, this
|
|
4
|
+
// imports the app's `workflows/*.ts`, turns each `defineFlow` into executable BPMN via
|
|
5
|
+
// `@nanobpm/workflow` (`toBpmn` + `bpmn-auto-layout` DI), and writes `processes/<id>.bpmn`.
|
|
6
|
+
// Executing the app's TypeScript needs a runtime module loader, so this is gated on the
|
|
7
|
+
// optional `GenIO.importModule` (present on Node with type-stripping, and on Deno). When the
|
|
8
|
+
// loader is absent — or the app authors its models directly (model-first) — derivation is a
|
|
9
|
+
// no-op and the rest of `urban gen` proceeds on the authored/derived models unchanged.
|
|
10
|
+
|
|
11
|
+
import { toDeployableBpmn } from "@nanobpm/workflow";
|
|
12
|
+
import type { Workflow } from "@nanobpm/workflow";
|
|
13
|
+
import type { DerivedArtifact } from "./artifact.ts";
|
|
14
|
+
import type { GenIO } from "./gen.ts";
|
|
15
|
+
import { expandPattern, joinPath } from "./gen.ts";
|
|
16
|
+
import type { ModelSource } from "./derivers/worker-io.ts";
|
|
17
|
+
|
|
18
|
+
/** Default source glob for code-first flows (matches the code-first scaffold layout). */
|
|
19
|
+
export const DEFAULT_WORKFLOW_PATTERNS = ["workflows/*.ts"];
|
|
20
|
+
|
|
21
|
+
/** Default output dir for derived models (matches the model-first scaffold, so a derived
|
|
22
|
+
* model is ejectable to model-first and picked up by `models.processes: ["processes/*.bpmn"]`). */
|
|
23
|
+
export const PROCESSES_DIR = "processes";
|
|
24
|
+
|
|
25
|
+
/** Marker stamped into every derived `.bpmn` (an XML comment) so re-gen overwrites and the
|
|
26
|
+
* stale sweep deletes ONLY derived output — never an authored, human-owned model. */
|
|
27
|
+
export const MODEL_PROVENANCE =
|
|
28
|
+
"Generated by @nanobpm/urban from workflows/*.ts — do not edit; edit the flow and re-run `urban gen`.";
|
|
29
|
+
|
|
30
|
+
/** Minimal shape a code-first manifest declares for model derivation. */
|
|
31
|
+
export interface ModelsManifest {
|
|
32
|
+
models?: { processes?: string[]; workflows?: string[] };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A `defineFlow` export: has a string `id` and an `imperative`/`declarative` `kind`. */
|
|
36
|
+
export function isWorkflow(v: unknown): v is Workflow {
|
|
37
|
+
if (!v || typeof v !== "object") return false;
|
|
38
|
+
const o = v as { id?: unknown; kind?: unknown };
|
|
39
|
+
return typeof o.id === "string" && (o.kind === "imperative" || o.kind === "declarative");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Sanitize a flow id into a safe `.bpmn` filename (mirrors the host generator). */
|
|
43
|
+
export function bpmnFilename(id: string): string {
|
|
44
|
+
return `${id.replace(/[^A-Za-z0-9._-]/g, "-")}.bpmn`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The directory derived models are written to: the dir of the first `models.processes`
|
|
48
|
+
* glob if the manifest declares one, else the `processes/` default. */
|
|
49
|
+
export function processesOutDir(manifest: ModelsManifest): string {
|
|
50
|
+
for (const pat of manifest.models?.processes ?? []) {
|
|
51
|
+
const star = pat.indexOf("*");
|
|
52
|
+
const slash = star === -1 ? pat.lastIndexOf("/") : pat.lastIndexOf("/", star);
|
|
53
|
+
if (slash > 0) return pat.slice(0, slash);
|
|
54
|
+
}
|
|
55
|
+
return PROCESSES_DIR;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Insert the provenance marker as an XML comment right after the `<?xml … ?>` prolog. */
|
|
59
|
+
function withProvenance(xml: string): string {
|
|
60
|
+
const comment = `<!-- ${MODEL_PROVENANCE} -->`;
|
|
61
|
+
const m = xml.match(/^\uFEFF?\s*<\?xml[^>]*\?>/);
|
|
62
|
+
if (m) return `${m[0]}\n${comment}\n${xml.slice(m[0].length).replace(/^\r?\n/, "")}`;
|
|
63
|
+
return `${comment}\n${xml}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface ModelError {
|
|
67
|
+
path: string;
|
|
68
|
+
message: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface DerivedModels {
|
|
72
|
+
/** `processes/<id>.bpmn` artifacts (path + provenance-stamped content). */
|
|
73
|
+
artifacts: DerivedArtifact[];
|
|
74
|
+
/** The same models, in-memory, for feeding the type-contract derivers (worker I/O, meta, messages). */
|
|
75
|
+
models: ModelSource[];
|
|
76
|
+
/** Derived flow ids (used for the provenance-scoped stale sweep). */
|
|
77
|
+
ids: string[];
|
|
78
|
+
/** Per-flow `{ id, kind, xml }` for the non-writing preview (`urban derive --stdout`). */
|
|
79
|
+
list: DerivedModelInfo[];
|
|
80
|
+
/** The output directory the artifacts were written into. */
|
|
81
|
+
outDir: string;
|
|
82
|
+
/** True once we attempted derivation (loader present AND at least one workflow file matched). */
|
|
83
|
+
attempted: boolean;
|
|
84
|
+
/** True if any workflow module failed to import/derive — suppresses the destructive sweep. */
|
|
85
|
+
incomplete: boolean;
|
|
86
|
+
/** Per-file derivation errors (empty on full success). */
|
|
87
|
+
errors: ModelError[];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface DerivedModelInfo {
|
|
91
|
+
id: string;
|
|
92
|
+
kind: Workflow["kind"];
|
|
93
|
+
xml: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function emptyDerived(outDir: string): DerivedModels {
|
|
97
|
+
return {
|
|
98
|
+
artifacts: [],
|
|
99
|
+
models: [],
|
|
100
|
+
ids: [],
|
|
101
|
+
list: [],
|
|
102
|
+
outDir,
|
|
103
|
+
attempted: false,
|
|
104
|
+
incomplete: false,
|
|
105
|
+
errors: [],
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function messageOf(e: unknown): string {
|
|
110
|
+
return e instanceof Error ? e.message : String(e);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Append an actionable hint when a `.ts` import fails because the runtime can't strip types. */
|
|
114
|
+
function importHint(rel: string, e: unknown): string {
|
|
115
|
+
const base = messageOf(e);
|
|
116
|
+
const code = (e as { code?: string })?.code ?? "";
|
|
117
|
+
const looksLikeNoTsLoader =
|
|
118
|
+
rel.endsWith(".ts") &&
|
|
119
|
+
(code === "ERR_UNKNOWN_FILE_EXTENSION" || /Unknown file extension "\.ts"/.test(base));
|
|
120
|
+
if (!looksLikeNoTsLoader) return base;
|
|
121
|
+
return (
|
|
122
|
+
`${base}\n ↳ importing TypeScript workflows needs a runtime that strips types: ` +
|
|
123
|
+
"Node ≥22.18 (default) or `node --experimental-strip-types`, or run under Deno."
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Derive executable BPMN models from an app's code-first `workflows/*.ts`.
|
|
129
|
+
*
|
|
130
|
+
* Returns everything in-memory (artifacts + `ModelSource`s + ids). The caller decides whether
|
|
131
|
+
* to write the `.bpmn` to disk (`urban gen` / `urban derive`) or keep them in memory to feed
|
|
132
|
+
* the type-contract derivers only (`urban gen --no-models`) or preview them (`urban derive
|
|
133
|
+
* --stdout`). Never throws on a bad workflow: a failing module is recorded in `errors`,
|
|
134
|
+
* `incomplete` is set, and the sweep is suppressed so a partial failure can't delete good models.
|
|
135
|
+
*/
|
|
136
|
+
export async function deriveModels(
|
|
137
|
+
root: string,
|
|
138
|
+
io: GenIO,
|
|
139
|
+
manifest: ModelsManifest,
|
|
140
|
+
): Promise<DerivedModels> {
|
|
141
|
+
const outDir = processesOutDir(manifest);
|
|
142
|
+
const importModule = io.importModule;
|
|
143
|
+
if (!importModule) return emptyDerived(outDir);
|
|
144
|
+
|
|
145
|
+
const patterns = manifest.models?.workflows ?? DEFAULT_WORKFLOW_PATTERNS;
|
|
146
|
+
const files: string[] = [];
|
|
147
|
+
for (const pat of patterns) {
|
|
148
|
+
for (const rel of await expandPattern(root, io, pat)) files.push(rel);
|
|
149
|
+
}
|
|
150
|
+
if (files.length === 0) return emptyDerived(outDir);
|
|
151
|
+
|
|
152
|
+
const out: DerivedModels = { ...emptyDerived(outDir), attempted: true };
|
|
153
|
+
// id → { source file, workflow ref } — distinguishes a genuine re-export (same
|
|
154
|
+
// object under two export names) from a true collision (two distinct flows, one id).
|
|
155
|
+
const byId = new Map<string, { rel: string; ref: unknown }>();
|
|
156
|
+
|
|
157
|
+
for (const rel of files.sort()) {
|
|
158
|
+
let mod: Record<string, unknown>;
|
|
159
|
+
try {
|
|
160
|
+
mod = await importModule(joinPath(root, rel));
|
|
161
|
+
} catch (e) {
|
|
162
|
+
out.incomplete = true;
|
|
163
|
+
out.errors.push({ path: rel, message: importHint(rel, e) });
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
for (const value of Object.values(mod)) {
|
|
167
|
+
if (!isWorkflow(value)) continue;
|
|
168
|
+
const id = value.id;
|
|
169
|
+
const prior = byId.get(id);
|
|
170
|
+
if (prior) {
|
|
171
|
+
if (prior.ref === value && prior.rel === rel) continue; // genuine intra-module re-export
|
|
172
|
+
out.incomplete = true;
|
|
173
|
+
const where = prior.rel === rel ? "also defined in the same module" : `also defined in ${prior.rel}`;
|
|
174
|
+
out.errors.push({ path: rel, message: `duplicate flow id "${id}" (${where})` });
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
byId.set(id, { rel, ref: value });
|
|
178
|
+
try {
|
|
179
|
+
const semantic = await toDeployableBpmn(value);
|
|
180
|
+
const xml = withProvenance(semantic);
|
|
181
|
+
const path = `${outDir}/${bpmnFilename(id)}`;
|
|
182
|
+
out.artifacts.push({ path, content: xml });
|
|
183
|
+
out.models.push({ path, xml });
|
|
184
|
+
out.list.push({ id, kind: value.kind, xml });
|
|
185
|
+
out.ids.push(id);
|
|
186
|
+
} catch (e) {
|
|
187
|
+
out.incomplete = true;
|
|
188
|
+
out.errors.push({ path: rel, message: `deriving "${id}": ${messageOf(e)}` });
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return out;
|
|
193
|
+
}
|