@nanobpm/urban 0.24.1 → 0.26.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/runtime/core/modules/dataops.d.ts +28 -4
- package/dist/runtime/core/modules/dataops.js +123 -5
- package/dist/runtime/core/modules/pages.js +17 -2
- package/dist/runtime/core/runtime.js +19 -0
- package/dist/toolkit/derivers/domain.d.ts +18 -0
- package/dist/toolkit/derivers/domain.js +134 -0
- package/dist/toolkit/derivers/messages.d.ts +14 -0
- package/dist/toolkit/derivers/messages.js +37 -0
- package/dist/toolkit/derivers/shapes.d.ts +96 -0
- package/dist/toolkit/derivers/shapes.js +498 -0
- package/dist/toolkit/derivers/worker-io.d.ts +26 -0
- package/dist/toolkit/derivers/worker-io.js +101 -1
- package/dist/toolkit/index.d.ts +6 -4
- package/dist/toolkit/index.js +4 -3
- package/package.json +1 -1
- package/src/runtime/core/modules/dataops.test.ts +132 -9
- package/src/runtime/core/modules/dataops.ts +180 -11
- package/src/runtime/core/modules/pages.test.ts +10 -0
- package/src/runtime/core/modules/pages.ts +17 -2
- package/src/runtime/core/runtime.test.ts +78 -0
- package/src/runtime/core/runtime.ts +18 -0
- package/src/toolkit/derivers/bindings-parity.test.ts +66 -0
- package/src/toolkit/derivers/domain.ts +171 -0
- package/src/toolkit/derivers/messages.ts +38 -0
- package/src/toolkit/derivers/shapes.test.ts +171 -0
- package/src/toolkit/derivers/shapes.ts +602 -0
- package/src/toolkit/derivers/worker-io.ts +103 -1
- package/src/toolkit/index.ts +24 -0
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
// Composed motion shapes: the fuse composition algebra (ADR 0040 §9/§10).
|
|
2
|
+
//
|
|
3
|
+
// A composed shape is a named, model-scoped declaration authored in the Modeller and carried in
|
|
4
|
+
// the `.bpmn` as a `nano:shape` (see the Rust scan `envelope_scan.rs`). It composes existing fused
|
|
5
|
+
// entities — DB tables, manifest `types`, and other shapes — via four ordered operations:
|
|
6
|
+
//
|
|
7
|
+
// carry(ref) spread every field of `ref`
|
|
8
|
+
// project(ref, fields, via) spread only the named fields of `ref`
|
|
9
|
+
// extend(name, type, ...) add a process-authored field
|
|
10
|
+
// reference(name, ref, ...) nest (or spread) another shape
|
|
11
|
+
//
|
|
12
|
+
// Resolution folds the ops, in author (XML) order, into a flat `DomainTypeDef`, so a composed
|
|
13
|
+
// shape enters the same `DomainTypes` registry a manifest `type` does — every downstream consumer
|
|
14
|
+
// (envelope pickers, `defineWorker`, `publishMessage`, FEEL scopes) types against
|
|
15
|
+
// `DomainTypes["ApprovedOrder"]` with no new codegen path. Broken shapes are omitted (degrade to
|
|
16
|
+
// untyped) and reported as diagnostics.
|
|
17
|
+
//
|
|
18
|
+
// Byte-faithful port of the console's `resolveShapes`/`emitDomainModelJson` (server
|
|
19
|
+
// `domain_types.ts`), so the toolkit is the single source of truth for the IDE's shape fuse
|
|
20
|
+
// (host dry-out nano-bpm#576).
|
|
21
|
+
import { isPrimitiveKeyword, sqliteAffinityToTs, } from "./domain.js";
|
|
22
|
+
/** Map one datasource column to a domain field. A nullable column widens to `optional` (the
|
|
23
|
+
* composed shape is a motion snapshot: an absent value reads as undefined, not SQL NULL). The
|
|
24
|
+
* manifest keyword is inferred from the column's SQLite affinity so it flows through `fieldTsType`
|
|
25
|
+
* like any declared field; affinities with no keyword (BLOB/opaque) fall back to `json`. */
|
|
26
|
+
export function columnToField(col) {
|
|
27
|
+
const ts = sqliteAffinityToTs(col.type);
|
|
28
|
+
const keyword = ts === "string" || ts === "number" || ts === "boolean" ? ts : "json";
|
|
29
|
+
const nullable = !(col.notNull || col.primaryKey);
|
|
30
|
+
return nullable ? { type: keyword, optional: true } : { type: keyword };
|
|
31
|
+
}
|
|
32
|
+
/** Build the leaf entity index the shape fold resolves references against: every datasource table
|
|
33
|
+
* (by raw wire name, and by `source.table` to disambiguate a name shared across sources) and every
|
|
34
|
+
* manifest `type` (by id). A raw table name shared across sources keeps the first
|
|
35
|
+
* (default-source-first) binding; the qualified `source.table` alias is always unambiguous.
|
|
36
|
+
*
|
|
37
|
+
* When a manifest `type` id collides with a bare table name, the **type wins** the unqualified id
|
|
38
|
+
* (it is the more first-class, `DomainTypes`-visible entity) and the collision is surfaced via
|
|
39
|
+
* `ambiguousIds` so resolution can warn; the table stays reachable through its `source.table`
|
|
40
|
+
* alias. `tableIds` records every id (bare + qualified) that resolves to a DB table, so a nominal
|
|
41
|
+
* reference (which the emitter can only express against `DomainTypes` keys) can be rejected. */
|
|
42
|
+
function leafEntityIndex(sources, types) {
|
|
43
|
+
const index = new Map();
|
|
44
|
+
const tableIds = new Set();
|
|
45
|
+
for (const s of sources) {
|
|
46
|
+
for (const t of s.tables) {
|
|
47
|
+
const fields = {};
|
|
48
|
+
const fks = {};
|
|
49
|
+
for (const c of t.columns)
|
|
50
|
+
fields[c.name] = columnToField(c);
|
|
51
|
+
// Store FK targets as the unambiguous `source.table` id (FKs are intra-source) so `via`-path
|
|
52
|
+
// following resolves the intended table, never a same-named table in another source or a
|
|
53
|
+
// type-preferred bare id.
|
|
54
|
+
for (const fk of t.foreignKeys ?? [])
|
|
55
|
+
fks[fk.column] = `${s.source}.${fk.refTable}`;
|
|
56
|
+
const entity = { fields, fks };
|
|
57
|
+
index.set(`${s.source}.${t.name}`, entity);
|
|
58
|
+
tableIds.add(`${s.source}.${t.name}`);
|
|
59
|
+
if (!index.has(t.name)) {
|
|
60
|
+
index.set(t.name, entity);
|
|
61
|
+
tableIds.add(t.name);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const ambiguousIds = new Set();
|
|
66
|
+
for (const [id, def] of Object.entries(types)) {
|
|
67
|
+
// The manifest type wins an unqualified id that also names a table; the table remains
|
|
68
|
+
// reachable by its `source.table` alias.
|
|
69
|
+
if (tableIds.has(id))
|
|
70
|
+
ambiguousIds.add(id);
|
|
71
|
+
index.set(id, { fields: { ...def.fields } });
|
|
72
|
+
}
|
|
73
|
+
return { index, tableIds, ambiguousIds };
|
|
74
|
+
}
|
|
75
|
+
/** The shape ids a shape references (carry/project/reference targets, and an `extend` whose type
|
|
76
|
+
* names a shape) — the edges of the shape dependency graph. */
|
|
77
|
+
function referencedShapeIds(shape, shapeIds) {
|
|
78
|
+
const refs = new Set();
|
|
79
|
+
for (const op of shape.ops) {
|
|
80
|
+
if (op.op === "carry" || op.op === "project" || op.op === "reference") {
|
|
81
|
+
if (shapeIds.has(op.ref))
|
|
82
|
+
refs.add(op.ref);
|
|
83
|
+
}
|
|
84
|
+
else if (op.op === "extend" && shapeIds.has(op.type)) {
|
|
85
|
+
refs.add(op.type);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return [...refs];
|
|
89
|
+
}
|
|
90
|
+
/** Structural equality of two domain field defs (`type` + normalized optional/list flags), so a
|
|
91
|
+
* differing property insertion order does not read as a conflict. */
|
|
92
|
+
function sameFieldDef(a, b) {
|
|
93
|
+
return a.type === b.type && !!a.optional === !!b.optional && !!a.list === !!b.list;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Resolve the project's composed shapes into `DomainTypeDef`s and diagnostics (ADR 0040 §10).
|
|
97
|
+
* Leaves (DB tables, manifest types) fuse first; shapes resolve in dependency order so a shape can
|
|
98
|
+
* carry another shape regardless of declaration order. A shape in a reference cycle, or one that
|
|
99
|
+
* names an unresolvable id, is omitted (degrades to untyped) with an `error` diagnostic; a
|
|
100
|
+
* deliberate field shadow with a differing type resolves with a `field-conflict` warning.
|
|
101
|
+
*/
|
|
102
|
+
export function resolveShapes(shapes, types, sources = []) {
|
|
103
|
+
const diagnostics = [];
|
|
104
|
+
const resolved = {};
|
|
105
|
+
if (shapes.length === 0)
|
|
106
|
+
return { types: resolved, diagnostics };
|
|
107
|
+
const { index, tableIds, ambiguousIds } = leafEntityIndex(sources, types);
|
|
108
|
+
// A nominal reference (an `extend` type or a non-spread `reference`) must resolve to a
|
|
109
|
+
// `DomainTypes` key at emit time — a manifest type or a resolved shape id. DB tables are
|
|
110
|
+
// spread-only (their fields flatten via carry/project); nominally referencing one would degrade
|
|
111
|
+
// to `unknown` in the emitted `.d.ts`, so we reject.
|
|
112
|
+
const nominalIds = new Set(Object.keys(types));
|
|
113
|
+
// Duplicate shape ids are fuse-identity collisions: since resolution keys by id, a later
|
|
114
|
+
// declaration would silently shadow an earlier one. Report every id that appears more than once
|
|
115
|
+
// and omit all of its declarations from resolution.
|
|
116
|
+
const idCounts = new Map();
|
|
117
|
+
for (const s of shapes)
|
|
118
|
+
if (s.id)
|
|
119
|
+
idCounts.set(s.id, (idCounts.get(s.id) ?? 0) + 1);
|
|
120
|
+
const duplicated = new Set();
|
|
121
|
+
for (const [id, n] of idCounts) {
|
|
122
|
+
if (n > 1) {
|
|
123
|
+
duplicated.add(id);
|
|
124
|
+
diagnostics.push({
|
|
125
|
+
shape: id,
|
|
126
|
+
kind: "duplicate-id",
|
|
127
|
+
severity: "error",
|
|
128
|
+
message: `shape id "${id}" is declared ${n} times; ids are fuse identities and must be unique`,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const byId = new Map();
|
|
133
|
+
for (const s of shapes)
|
|
134
|
+
if (s.id && !duplicated.has(s.id))
|
|
135
|
+
byId.set(s.id, s);
|
|
136
|
+
const shapeIds = new Set(byId.keys());
|
|
137
|
+
// Cycle detection over the shape-only dependency graph (DFS three-colour). Every shape on a back
|
|
138
|
+
// edge is failed; the reported path aids the maker's fix.
|
|
139
|
+
const failed = new Set();
|
|
140
|
+
const colour = new Map(); // 0=unvisited 1=on-stack 2=done
|
|
141
|
+
const visit = (id, stack) => {
|
|
142
|
+
colour.set(id, 1);
|
|
143
|
+
stack.push(id);
|
|
144
|
+
for (const dep of referencedShapeIds(byId.get(id), shapeIds)) {
|
|
145
|
+
const c = colour.get(dep) ?? 0;
|
|
146
|
+
if (c === 1) {
|
|
147
|
+
const cycle = stack.slice(stack.indexOf(dep)).concat(dep);
|
|
148
|
+
for (const n of cycle) {
|
|
149
|
+
if (!failed.has(n)) {
|
|
150
|
+
failed.add(n);
|
|
151
|
+
diagnostics.push({
|
|
152
|
+
shape: n,
|
|
153
|
+
kind: "reference-cycle",
|
|
154
|
+
severity: "error",
|
|
155
|
+
message: `shape "${n}" is part of a reference cycle: ${cycle.join(" → ")}`,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
else if (c === 0) {
|
|
161
|
+
visit(dep, stack);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
stack.pop();
|
|
165
|
+
colour.set(id, 2);
|
|
166
|
+
};
|
|
167
|
+
for (const id of byId.keys())
|
|
168
|
+
if ((colour.get(id) ?? 0) === 0)
|
|
169
|
+
visit(id, []);
|
|
170
|
+
// Topological resolution: resolve a shape only after its (non-failed) shape dependencies, adding
|
|
171
|
+
// each resolved shape to the index so later shapes see it.
|
|
172
|
+
const done = new Set();
|
|
173
|
+
const resolveOne = (shape) => {
|
|
174
|
+
if (done.has(shape.id) || failed.has(shape.id))
|
|
175
|
+
return;
|
|
176
|
+
// Resolve shape dependencies first (they may themselves be pending).
|
|
177
|
+
for (const dep of referencedShapeIds(shape, shapeIds)) {
|
|
178
|
+
const d = byId.get(dep);
|
|
179
|
+
if (d && !done.has(dep) && !failed.has(dep))
|
|
180
|
+
resolveOne(d);
|
|
181
|
+
}
|
|
182
|
+
const fields = {};
|
|
183
|
+
let broken = false;
|
|
184
|
+
const addField = (name, field) => {
|
|
185
|
+
const existing = fields[name];
|
|
186
|
+
if (existing && !sameFieldDef(existing, field)) {
|
|
187
|
+
diagnostics.push({
|
|
188
|
+
shape: shape.id,
|
|
189
|
+
kind: "field-conflict",
|
|
190
|
+
severity: "warning",
|
|
191
|
+
message: `field "${name}" is contributed twice with differing types; the later value wins (author-order fold)`,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
fields[name] = field; // last (author-order) wins
|
|
195
|
+
};
|
|
196
|
+
const lookup = (ref) => index.get(ref);
|
|
197
|
+
const unresolved = (ref) => {
|
|
198
|
+
broken = true;
|
|
199
|
+
diagnostics.push({
|
|
200
|
+
shape: shape.id,
|
|
201
|
+
kind: "unresolved-reference",
|
|
202
|
+
severity: "error",
|
|
203
|
+
message: `shape "${shape.id}" references unknown entity "${ref}"`,
|
|
204
|
+
});
|
|
205
|
+
};
|
|
206
|
+
// A bare id that names both a manifest type and a table resolves to the type; warn so the
|
|
207
|
+
// (silent) precedence is visible and the maker can qualify.
|
|
208
|
+
const noteAmbiguity = (ref) => {
|
|
209
|
+
if (ambiguousIds.has(ref)) {
|
|
210
|
+
diagnostics.push({
|
|
211
|
+
shape: shape.id,
|
|
212
|
+
kind: "ambiguous-reference",
|
|
213
|
+
severity: "warning",
|
|
214
|
+
message: `"${ref}" names both a manifest type and a table; resolved to the type — qualify as "<source>.${ref}" to target the table`,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
// Report a nominal reference (extend type / non-spread reference) that targets a DB table,
|
|
219
|
+
// which cannot be expressed as a `DomainTypes` ref (it would degrade to `unknown`); the maker
|
|
220
|
+
// should spread it instead (carry/project or spread=true).
|
|
221
|
+
const nominalTableRef = (kind, ref) => {
|
|
222
|
+
broken = true;
|
|
223
|
+
diagnostics.push({
|
|
224
|
+
shape: shape.id,
|
|
225
|
+
kind: "nominal-table-ref",
|
|
226
|
+
severity: "error",
|
|
227
|
+
message: `${kind} nominally references table "${ref}", which is not a DomainTypes entity; spread its fields (carry/project or reference spread="true") instead`,
|
|
228
|
+
});
|
|
229
|
+
};
|
|
230
|
+
for (const op of shape.ops) {
|
|
231
|
+
switch (op.op) {
|
|
232
|
+
case "carry": {
|
|
233
|
+
const e = lookup(op.ref);
|
|
234
|
+
if (!e) {
|
|
235
|
+
unresolved(op.ref);
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
noteAmbiguity(op.ref);
|
|
239
|
+
for (const [k, f] of Object.entries(e.fields))
|
|
240
|
+
addField(k, f);
|
|
241
|
+
break;
|
|
242
|
+
}
|
|
243
|
+
case "project": {
|
|
244
|
+
const e = lookup(op.ref);
|
|
245
|
+
if (!e) {
|
|
246
|
+
unresolved(op.ref);
|
|
247
|
+
break;
|
|
248
|
+
}
|
|
249
|
+
noteAmbiguity(op.ref);
|
|
250
|
+
for (const fname of op.fields) {
|
|
251
|
+
const f = e.fields[fname];
|
|
252
|
+
if (!f) {
|
|
253
|
+
broken = true;
|
|
254
|
+
diagnostics.push({
|
|
255
|
+
shape: shape.id,
|
|
256
|
+
kind: "unknown-field",
|
|
257
|
+
severity: "error",
|
|
258
|
+
message: `projected field "${fname}" is not a field of "${op.ref}"`,
|
|
259
|
+
});
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
addField(fname, f);
|
|
263
|
+
}
|
|
264
|
+
if (op.via)
|
|
265
|
+
validateVia(shape.id, op.via, index, diagnostics);
|
|
266
|
+
break;
|
|
267
|
+
}
|
|
268
|
+
case "extend": {
|
|
269
|
+
if (!isPrimitiveKeyword(op.type)) {
|
|
270
|
+
if (nominalIds.has(op.type)) {
|
|
271
|
+
// ok — a manifest type or an already-resolved shape id
|
|
272
|
+
}
|
|
273
|
+
else if (tableIds.has(op.type)) {
|
|
274
|
+
nominalTableRef(`extend field "${op.name}"`, op.type);
|
|
275
|
+
break;
|
|
276
|
+
}
|
|
277
|
+
else {
|
|
278
|
+
broken = true;
|
|
279
|
+
diagnostics.push({
|
|
280
|
+
shape: shape.id,
|
|
281
|
+
kind: "unresolved-reference",
|
|
282
|
+
severity: "error",
|
|
283
|
+
message: `extend field "${op.name}" has type "${op.type}", which is neither a scalar keyword nor a fused entity`,
|
|
284
|
+
});
|
|
285
|
+
break;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
const field = { type: op.type };
|
|
289
|
+
if (op.optional)
|
|
290
|
+
field.optional = true;
|
|
291
|
+
if (op.list)
|
|
292
|
+
field.list = true;
|
|
293
|
+
addField(op.name, field);
|
|
294
|
+
break;
|
|
295
|
+
}
|
|
296
|
+
case "reference": {
|
|
297
|
+
const e = lookup(op.ref);
|
|
298
|
+
if (!e) {
|
|
299
|
+
unresolved(op.ref);
|
|
300
|
+
break;
|
|
301
|
+
}
|
|
302
|
+
if (op.spread) {
|
|
303
|
+
noteAmbiguity(op.ref);
|
|
304
|
+
for (const [k, f] of Object.entries(e.fields))
|
|
305
|
+
addField(k, f);
|
|
306
|
+
}
|
|
307
|
+
else if (!nominalIds.has(op.ref)) {
|
|
308
|
+
// `e` exists but the id is not a DomainTypes key — it is a DB table.
|
|
309
|
+
nominalTableRef(`reference "${op.name}"`, op.ref);
|
|
310
|
+
}
|
|
311
|
+
else {
|
|
312
|
+
const field = { type: op.ref };
|
|
313
|
+
if (op.list)
|
|
314
|
+
field.list = true;
|
|
315
|
+
addField(op.name, field);
|
|
316
|
+
}
|
|
317
|
+
break;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
// same-id collision: the shape id shadows a leaf entity it does not source. The shape composes
|
|
322
|
+
// that entity when it references it under any id (bare or `source.table` alias) — since the
|
|
323
|
+
// index maps every alias to the same `FuseEntity` instance, compare by identity rather than by
|
|
324
|
+
// the bare id alone.
|
|
325
|
+
const collidingEntity = index.get(shape.id);
|
|
326
|
+
const composesColliding = collidingEntity !== undefined &&
|
|
327
|
+
shape.ops.some((o) => o.op !== "extend" && index.get(o.ref) === collidingEntity);
|
|
328
|
+
if (collidingEntity !== undefined && !composesColliding) {
|
|
329
|
+
diagnostics.push({
|
|
330
|
+
shape: shape.id,
|
|
331
|
+
kind: "same-id-collision",
|
|
332
|
+
severity: "error",
|
|
333
|
+
message: `shape id "${shape.id}" collides with an existing fused entity it does not compose`,
|
|
334
|
+
});
|
|
335
|
+
broken = true;
|
|
336
|
+
}
|
|
337
|
+
done.add(shape.id);
|
|
338
|
+
if (broken) {
|
|
339
|
+
failed.add(shape.id);
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
const def = { name: shape.name, fields };
|
|
343
|
+
resolved[shape.id] = def;
|
|
344
|
+
// Later shapes may carry this one; expose its resolved fields to the index and mark it
|
|
345
|
+
// nominal-referenceable (it will be a `DomainTypes` key).
|
|
346
|
+
index.set(shape.id, { fields });
|
|
347
|
+
nominalIds.add(shape.id);
|
|
348
|
+
};
|
|
349
|
+
for (const s of shapes)
|
|
350
|
+
if (s.id && !duplicated.has(s.id))
|
|
351
|
+
resolveOne(s);
|
|
352
|
+
return { types: resolved, diagnostics };
|
|
353
|
+
}
|
|
354
|
+
/** Validate a `project via` FK path (`Entity.column[.column...]`): the leading entity must resolve
|
|
355
|
+
* and carry the named column (an FK column on a DB table, or any field on a manifest type/shape).
|
|
356
|
+
* Deeper hops are validated leniently — the FK target is followed when the leading entity is a DB
|
|
357
|
+
* table with that FK. */
|
|
358
|
+
function validateVia(shape, via, index, diagnostics) {
|
|
359
|
+
const parts = via.split(".");
|
|
360
|
+
if (parts.length < 2) {
|
|
361
|
+
diagnostics.push({
|
|
362
|
+
shape,
|
|
363
|
+
kind: "unknown-field",
|
|
364
|
+
severity: "warning",
|
|
365
|
+
message: `via path "${via}" is not of the form Entity.column`,
|
|
366
|
+
});
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
// Resolve the starting entity as the *longest* dotted prefix present in the index (so a qualified
|
|
370
|
+
// `source.table` start works, not just a bare id), leaving at least one trailing segment as a hop
|
|
371
|
+
// column. The remaining segments are hop columns.
|
|
372
|
+
let entity;
|
|
373
|
+
let start = 0;
|
|
374
|
+
for (let p = 1; p < parts.length; p++) {
|
|
375
|
+
const candidate = parts.slice(0, p).join(".");
|
|
376
|
+
const hit = index.get(candidate);
|
|
377
|
+
if (hit) {
|
|
378
|
+
entity = hit;
|
|
379
|
+
start = p;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
if (!entity) {
|
|
383
|
+
diagnostics.push({
|
|
384
|
+
shape,
|
|
385
|
+
kind: "unknown-field",
|
|
386
|
+
severity: "warning",
|
|
387
|
+
message: `via path "${via}" starts at unknown entity "${parts.slice(0, -1).join(".")}"`,
|
|
388
|
+
});
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
let cursor = entity;
|
|
392
|
+
for (let i = start; i < parts.length; i++) {
|
|
393
|
+
const col = parts[i];
|
|
394
|
+
const hasField = Object.prototype.hasOwnProperty.call(cursor.fields, col);
|
|
395
|
+
const fkTarget = cursor.fks?.[col];
|
|
396
|
+
if (!hasField && !fkTarget) {
|
|
397
|
+
diagnostics.push({
|
|
398
|
+
shape,
|
|
399
|
+
kind: "unknown-field",
|
|
400
|
+
severity: "warning",
|
|
401
|
+
message: `via path "${via}" hops through unknown column "${col}"`,
|
|
402
|
+
});
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
// Follow the FK to the next entity when we can; otherwise stop (lenient).
|
|
406
|
+
const next = fkTarget ? index.get(fkTarget) : undefined;
|
|
407
|
+
if (!next)
|
|
408
|
+
return;
|
|
409
|
+
cursor = next;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
// --- the structured fused domain model: domain.json (ADR 0040 §1, OQ1) -----------------------
|
|
413
|
+
//
|
|
414
|
+
// The computed fuse is persisted as a generated, git-ignored `nano-generated/domain.json` — a
|
|
415
|
+
// fast-read structured index for the IDE/codegen so a reader need not re-scan every model +
|
|
416
|
+
// datasource. It is a *cache*, never a source: the `domaintypes` op regenerates it write-through on
|
|
417
|
+
// any source change, and an `inputsHash` over its own content lets a reader detect staleness
|
|
418
|
+
// cheaply.
|
|
419
|
+
/** The basename of the generated structured fuse cache (ADR 0040 §1). */
|
|
420
|
+
export const DOMAIN_MODEL_JSON = "domain.json";
|
|
421
|
+
/** FNV-1a (32-bit) hex of a string — a small, dependency-free content tag for the fuse cache's
|
|
422
|
+
* `inputsHash` (staleness detection, not security). */
|
|
423
|
+
function fnv1aHex(s) {
|
|
424
|
+
let h = 0x811c9dc5;
|
|
425
|
+
for (let i = 0; i < s.length; i++) {
|
|
426
|
+
h ^= s.charCodeAt(i);
|
|
427
|
+
h = Math.imul(h, 0x01000193);
|
|
428
|
+
}
|
|
429
|
+
return (h >>> 0).toString(16).padStart(8, "0");
|
|
430
|
+
}
|
|
431
|
+
/** Convert a resolved field map to the ordered `domain.json` field list. */
|
|
432
|
+
function fieldsJson(fields) {
|
|
433
|
+
return Object.entries(fields ?? {}).map(([name, f]) => {
|
|
434
|
+
const out = { name, type: f.type };
|
|
435
|
+
if (f.optional)
|
|
436
|
+
out.optional = true;
|
|
437
|
+
if (f.list)
|
|
438
|
+
out.list = true;
|
|
439
|
+
return out;
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* Emit `domain.json`: the structured Fused Domain Model (ADR 0040 §1). Assembles every fused
|
|
444
|
+
* entity — DB tables (`db:` provenance), manifest `types` (`manifest:`), and composed motion shapes
|
|
445
|
+
* (`model:<processId>`, or bare `model` for an unsaved editor shape with no process) — with its
|
|
446
|
+
* resolved fields, plus the model-level metadata and the shape diagnostics. An `inputsHash` —
|
|
447
|
+
* FNV-1a (hex) over the compact `JSON.stringify` of the model object with `inputsHash` omitted
|
|
448
|
+
* (property-insertion order, not the pretty-printed bytes) — tags the cache for staleness.
|
|
449
|
+
*/
|
|
450
|
+
export function emitDomainModelJson(input) {
|
|
451
|
+
const entities = [];
|
|
452
|
+
for (const s of input.sources) {
|
|
453
|
+
for (const t of s.tables) {
|
|
454
|
+
const fields = {};
|
|
455
|
+
for (const c of t.columns)
|
|
456
|
+
fields[c.name] = columnToField(c);
|
|
457
|
+
entities.push({
|
|
458
|
+
id: `${s.source}.${t.name}`,
|
|
459
|
+
kind: "table",
|
|
460
|
+
provenance: `db:${s.source}.${t.name}`,
|
|
461
|
+
fields: fieldsJson(fields),
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
for (const [id, def] of Object.entries(input.manifestTypes)) {
|
|
466
|
+
entities.push({
|
|
467
|
+
id,
|
|
468
|
+
kind: "type",
|
|
469
|
+
provenance: `manifest:${id}`,
|
|
470
|
+
...(def.name ? { name: def.name } : {}),
|
|
471
|
+
fields: fieldsJson(def.fields),
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
for (const { decl, def } of input.shapes) {
|
|
475
|
+
entities.push({
|
|
476
|
+
id: decl.id,
|
|
477
|
+
kind: "shape",
|
|
478
|
+
provenance: decl.process ? `model:${decl.process}` : "model",
|
|
479
|
+
...(def.name ?? decl.name ? { name: def.name ?? decl.name } : {}),
|
|
480
|
+
fields: fieldsJson(def.fields),
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
const meta = input.meta
|
|
484
|
+
.filter((m) => (m?.key ?? "").trim().length > 0)
|
|
485
|
+
.map((m) => ({ ...(m.process ? { process: m.process } : {}), key: m.key, value: m.value ?? "" }));
|
|
486
|
+
const model = {
|
|
487
|
+
$generated: "nanobpmn ADR 0040 §1 Fused Domain Model — do not edit; regenerated by the domaintypes op",
|
|
488
|
+
version: 1,
|
|
489
|
+
...(input.default ? { default: input.default } : {}),
|
|
490
|
+
sources: input.sources.map((s) => s.source),
|
|
491
|
+
entities,
|
|
492
|
+
meta,
|
|
493
|
+
diagnostics: input.diagnostics,
|
|
494
|
+
inputsHash: "",
|
|
495
|
+
};
|
|
496
|
+
model.inputsHash = fnv1aHex(JSON.stringify({ ...model, inputsHash: undefined }));
|
|
497
|
+
return `${JSON.stringify(model, null, 2)}\n`;
|
|
498
|
+
}
|
|
@@ -25,6 +25,9 @@ export interface WorkerIo {
|
|
|
25
25
|
}
|
|
26
26
|
/** Filename the console uses; kept identical so the artifact is a drop-in. */
|
|
27
27
|
export declare const WORKER_BINDINGS_DTS = "worker-io.d.ts";
|
|
28
|
+
/** The generated typed-`defineWorker` wrapper's basename — re-exports the worker SDK and overrides
|
|
29
|
+
* `defineWorker` with a taskType-keyed typed signature. */
|
|
30
|
+
export declare const WORKER_BINDINGS_TS = "workers.ts";
|
|
28
31
|
/** The domain type declarations `worker-io.d.ts` imports from (console: domain-rows.d.ts). */
|
|
29
32
|
export declare const DOMAIN_DTS = "domain-rows.d.ts";
|
|
30
33
|
/** Scan one BPMN document for service-task worker I/O. */
|
|
@@ -37,6 +40,10 @@ export interface WorkerBindingDecl {
|
|
|
37
40
|
taskType?: string;
|
|
38
41
|
inputType?: string;
|
|
39
42
|
outputType?: string;
|
|
43
|
+
/** The `zeebe:header` keys declared on the task; reified into a typed `job.customHeaders` shape
|
|
44
|
+
* (known keys, `string` values — headers are strings on the wire). Empty/absent leaves
|
|
45
|
+
* `customHeaders` the untyped fallback. */
|
|
46
|
+
headerKeys?: string[];
|
|
40
47
|
}
|
|
41
48
|
/** Emit `worker-io.d.ts` from worker bindings + the set of declared domain type ids. */
|
|
42
49
|
export declare function emitWorkerBindings(workers: WorkerBindingDecl[], declaredTypeIds: Iterable<string>): string;
|
|
@@ -50,3 +57,22 @@ export declare const workerIoDeriver: Deriver<{
|
|
|
50
57
|
models: ModelSource[];
|
|
51
58
|
declaredTypeIds: Iterable<string>;
|
|
52
59
|
}>;
|
|
60
|
+
/**
|
|
61
|
+
* Overlay the model-derived worker-IO map onto the manifest `workers[]`. The model is authoritative
|
|
62
|
+
* for the envelope (`inputType`/`outputType`), so every scanned `taskType` takes its I/O from
|
|
63
|
+
* `derived` (clearing a stale manifest value when the model carries none); manifest entries the scan
|
|
64
|
+
* did not cover (e.g. a worker declared only for an `llm` binding, with no service task) are
|
|
65
|
+
* preserved. New `taskType`s seen only in the model are appended. A byte-faithful port of the
|
|
66
|
+
* console's `overlayDerivedWorkerIo` (server `data_cli.ts`).
|
|
67
|
+
*/
|
|
68
|
+
export declare function overlayDerivedWorkerIo(manifest: WorkerBindingDecl[], derived: WorkerBindingDecl[]): WorkerBindingDecl[];
|
|
69
|
+
/**
|
|
70
|
+
* The static typed-`defineWorker` wrapper (`workers.ts`). It re-exports the whole worker SDK and
|
|
71
|
+
* overrides `defineWorker` with a signature that keys off the `type:` string literal: when a job
|
|
72
|
+
* type is present in the generated `WorkerInputs`/`WorkerOutputs` (ADR 0033 §3), the handler's
|
|
73
|
+
* `job.variables` and result are typed from the declared domain type; otherwise they fall back to
|
|
74
|
+
* `WorkerVars`. The body is a pass-through — the wire stays untyped JSON (ADR 0029 §3). This file
|
|
75
|
+
* never changes with the schema, so it is written verbatim (unlike the regenerated
|
|
76
|
+
* `worker-io.d.ts`). A byte-faithful port of the console's `emitWorkerBindingsRuntime`.
|
|
77
|
+
*/
|
|
78
|
+
export declare function emitWorkerBindingsRuntime(): string;
|
|
@@ -14,6 +14,9 @@ export function byModelPath(a, b) {
|
|
|
14
14
|
}
|
|
15
15
|
/** Filename the console uses; kept identical so the artifact is a drop-in. */
|
|
16
16
|
export const WORKER_BINDINGS_DTS = "worker-io.d.ts";
|
|
17
|
+
/** The generated typed-`defineWorker` wrapper's basename — re-exports the worker SDK and overrides
|
|
18
|
+
* `defineWorker` with a taskType-keyed typed signature. */
|
|
19
|
+
export const WORKER_BINDINGS_TS = "workers.ts";
|
|
17
20
|
/** The domain type declarations `worker-io.d.ts` imports from (console: domain-rows.d.ts). */
|
|
18
21
|
export const DOMAIN_DTS = "domain-rows.d.ts";
|
|
19
22
|
const ENVELOPE_IN = "io.nanobpm.dataEnvelope.in";
|
|
@@ -65,6 +68,22 @@ export function scanModelWorkers(xml) {
|
|
|
65
68
|
export function typeRefFor(id, declared) {
|
|
66
69
|
return id != null && declared.has(id) ? `DomainTypes[${JSON.stringify(id)}]` : undefined;
|
|
67
70
|
}
|
|
71
|
+
/** The TS type expression for a worker's declared custom-header keys: an object type mapping each
|
|
72
|
+
* declared key to `string` (Zeebe headers are strings on the wire) plus a `string` index signature
|
|
73
|
+
* so undeclared headers stay accessible with the same honest wire type. Returns `undefined` when no
|
|
74
|
+
* keys are declared (the caller omits the entry so the taskType falls back to `WorkerHdrs`). */
|
|
75
|
+
function headerRefFor(keys) {
|
|
76
|
+
const clean = [
|
|
77
|
+
...new Set((keys ?? [])
|
|
78
|
+
.filter((k) => typeof k === "string")
|
|
79
|
+
.map((k) => k.trim())
|
|
80
|
+
.filter((k) => k.length > 0)),
|
|
81
|
+
];
|
|
82
|
+
if (clean.length === 0)
|
|
83
|
+
return undefined;
|
|
84
|
+
const fields = clean.map((k) => `${JSON.stringify(k)}: string`).join("; ");
|
|
85
|
+
return `{ ${fields}; [key: string]: string }`;
|
|
86
|
+
}
|
|
68
87
|
/** Emit `worker-io.d.ts` from worker bindings + the set of declared domain type ids. */
|
|
69
88
|
export function emitWorkerBindings(workers, declaredTypeIds) {
|
|
70
89
|
const declared = new Set(declaredTypeIds);
|
|
@@ -79,6 +98,7 @@ export function emitWorkerBindings(workers, declaredTypeIds) {
|
|
|
79
98
|
: "string";
|
|
80
99
|
const inputs = [];
|
|
81
100
|
const outputs = [];
|
|
101
|
+
const headers = [];
|
|
82
102
|
for (const w of workers) {
|
|
83
103
|
if (typeof w?.taskType !== "string" || w.taskType.length === 0)
|
|
84
104
|
continue;
|
|
@@ -88,6 +108,9 @@ export function emitWorkerBindings(workers, declaredTypeIds) {
|
|
|
88
108
|
const outRef = typeRefFor(w.outputType, declared);
|
|
89
109
|
if (outRef)
|
|
90
110
|
outputs.push(` ${propKey(w.taskType)}: ${outRef};`);
|
|
111
|
+
const hdrRef = headerRefFor(w.headerKeys);
|
|
112
|
+
if (hdrRef)
|
|
113
|
+
headers.push(` ${propKey(w.taskType)}: ${hdrRef};`);
|
|
91
114
|
}
|
|
92
115
|
const header = "// AUTO-GENERATED by nanobpmn from the App manifest (ADR 0033 §3).\n" +
|
|
93
116
|
"// The bridge from the process model to the worker type system: each declared\n" +
|
|
@@ -105,7 +128,9 @@ export function emitWorkerBindings(workers, declaredTypeIds) {
|
|
|
105
128
|
const outputsIface = outputs.length > 0
|
|
106
129
|
? `export interface WorkerOutputs {\n${outputs.join("\n")}\n}\n`
|
|
107
130
|
: `export interface WorkerOutputs {}\n`;
|
|
108
|
-
const headersIface =
|
|
131
|
+
const headersIface = headers.length > 0
|
|
132
|
+
? `export interface WorkerHeaders {\n${headers.join("\n")}\n}\n`
|
|
133
|
+
: `export interface WorkerHeaders {}\n`;
|
|
109
134
|
return `${header}\n` +
|
|
110
135
|
importTypes +
|
|
111
136
|
`\n/** Untyped fallback for a job whose worker declares no input/output type. */\n` +
|
|
@@ -144,3 +169,78 @@ export const workerIoDeriver = {
|
|
|
144
169
|
describe: "Derive worker-io.d.ts (task type + data-envelope in/out) from BPMN models.",
|
|
145
170
|
derive: ({ models, declaredTypeIds }) => deriveWorkerBindings(models, declaredTypeIds),
|
|
146
171
|
};
|
|
172
|
+
/**
|
|
173
|
+
* Overlay the model-derived worker-IO map onto the manifest `workers[]`. The model is authoritative
|
|
174
|
+
* for the envelope (`inputType`/`outputType`), so every scanned `taskType` takes its I/O from
|
|
175
|
+
* `derived` (clearing a stale manifest value when the model carries none); manifest entries the scan
|
|
176
|
+
* did not cover (e.g. a worker declared only for an `llm` binding, with no service task) are
|
|
177
|
+
* preserved. New `taskType`s seen only in the model are appended. A byte-faithful port of the
|
|
178
|
+
* console's `overlayDerivedWorkerIo` (server `data_cli.ts`).
|
|
179
|
+
*/
|
|
180
|
+
export function overlayDerivedWorkerIo(manifest, derived) {
|
|
181
|
+
const byType = new Map();
|
|
182
|
+
for (const w of manifest) {
|
|
183
|
+
if (typeof w.taskType === "string")
|
|
184
|
+
byType.set(w.taskType, { ...w });
|
|
185
|
+
}
|
|
186
|
+
for (const d of derived) {
|
|
187
|
+
if (typeof d.taskType !== "string")
|
|
188
|
+
continue;
|
|
189
|
+
const existing = byType.get(d.taskType);
|
|
190
|
+
const merged = existing
|
|
191
|
+
? { ...existing, taskType: d.taskType }
|
|
192
|
+
: { taskType: d.taskType };
|
|
193
|
+
if (d.inputType)
|
|
194
|
+
merged.inputType = d.inputType;
|
|
195
|
+
else
|
|
196
|
+
delete merged.inputType;
|
|
197
|
+
if (d.outputType)
|
|
198
|
+
merged.outputType = d.outputType;
|
|
199
|
+
else
|
|
200
|
+
delete merged.outputType;
|
|
201
|
+
// Header keys are authored on the model, so the derived set is authoritative: replace (not
|
|
202
|
+
// union) the manifest's, and clear when the model declares none.
|
|
203
|
+
if (d.headerKeys && d.headerKeys.length > 0)
|
|
204
|
+
merged.headerKeys = [...d.headerKeys];
|
|
205
|
+
else
|
|
206
|
+
delete merged.headerKeys;
|
|
207
|
+
byType.set(d.taskType, merged);
|
|
208
|
+
}
|
|
209
|
+
return [...byType.values()];
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* The static typed-`defineWorker` wrapper (`workers.ts`). It re-exports the whole worker SDK and
|
|
213
|
+
* overrides `defineWorker` with a signature that keys off the `type:` string literal: when a job
|
|
214
|
+
* type is present in the generated `WorkerInputs`/`WorkerOutputs` (ADR 0033 §3), the handler's
|
|
215
|
+
* `job.variables` and result are typed from the declared domain type; otherwise they fall back to
|
|
216
|
+
* `WorkerVars`. The body is a pass-through — the wire stays untyped JSON (ADR 0029 §3). This file
|
|
217
|
+
* never changes with the schema, so it is written verbatim (unlike the regenerated
|
|
218
|
+
* `worker-io.d.ts`). A byte-faithful port of the console's `emitWorkerBindingsRuntime`.
|
|
219
|
+
*/
|
|
220
|
+
export function emitWorkerBindingsRuntime() {
|
|
221
|
+
return "// AUTO-GENERATED by nanobpmn (ADR 0033 §3): the typed `defineWorker`.\n" +
|
|
222
|
+
"// Re-exports the worker SDK and overrides `defineWorker` with a taskType-keyed\n" +
|
|
223
|
+
"// typed signature (job.variables, job.customHeaders + result typed from the\n" +
|
|
224
|
+
"// worker's declared input/output/header contract). Erased to a pass-through at\n" +
|
|
225
|
+
"// runtime. Do not edit.\n" +
|
|
226
|
+
"// eslint-disable\n\n" +
|
|
227
|
+
`import { defineWorker as defineWorkerRaw } from "./worker-sdk.ts";\n` +
|
|
228
|
+
`import type { WorkerOptions } from "./worker-sdk.ts";\n` +
|
|
229
|
+
`import type { WorkerInputs, WorkerOutputs, WorkerHeaders, WorkerTaskType, WorkerVars, WorkerHdrs } from "./${WORKER_BINDINGS_DTS}";\n\n` +
|
|
230
|
+
`export * from "./worker-sdk.ts";\n\n` +
|
|
231
|
+
`type InFor<K extends WorkerTaskType> = K extends keyof WorkerInputs ? WorkerInputs[K] : WorkerVars;\n` +
|
|
232
|
+
`type OutFor<K extends WorkerTaskType> = K extends keyof WorkerOutputs ? WorkerOutputs[K] : WorkerVars;\n` +
|
|
233
|
+
`type HdrFor<K extends WorkerTaskType> = K extends keyof WorkerHeaders ? WorkerHeaders[K] : WorkerHdrs;\n\n` +
|
|
234
|
+
`/**\n` +
|
|
235
|
+
` * Typed \`defineWorker\`: \`type\` is constrained to the model's declared job types\n` +
|
|
236
|
+
` * (\`WorkerTaskType\`, ADR 0033 §3) so it autocompletes and rejects unknown jobs,\n` +
|
|
237
|
+
` * and the handler's \`job.variables\`, \`job.customHeaders\` + result are typed from\n` +
|
|
238
|
+
` * the worker's declared \`inputType\`/\`outputType\`/header keys. A declared job type\n` +
|
|
239
|
+
` * with no declared type falls back to WorkerVars / WorkerHdrs.\n` +
|
|
240
|
+
` */\n` +
|
|
241
|
+
`export function defineWorker<K extends WorkerTaskType>(\n` +
|
|
242
|
+
` opts: { type: K } & WorkerOptions<InFor<K> & object, OutFor<K> & object, HdrFor<K> & object>,\n` +
|
|
243
|
+
`): void {\n` +
|
|
244
|
+
` defineWorkerRaw(opts as unknown as WorkerOptions);\n` +
|
|
245
|
+
`}\n`;
|
|
246
|
+
}
|