@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,602 @@
|
|
|
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
|
+
|
|
22
|
+
import type { ColumnMeta } from "../../runtime/core/modules/gateway.ts";
|
|
23
|
+
import {
|
|
24
|
+
type DomainFieldDef,
|
|
25
|
+
type DomainTypeDef,
|
|
26
|
+
type DomainTypeRegistry,
|
|
27
|
+
isPrimitiveKeyword,
|
|
28
|
+
type SourceSchema,
|
|
29
|
+
sqliteAffinityToTs,
|
|
30
|
+
} from "./domain.ts";
|
|
31
|
+
|
|
32
|
+
/** One composition operation of a `nano:shape`, in author (XML) order. */
|
|
33
|
+
export type ShapeOp =
|
|
34
|
+
| { op: "carry"; ref: string }
|
|
35
|
+
| { op: "project"; ref: string; fields: string[]; via?: string }
|
|
36
|
+
| { op: "extend"; name: string; type: string; optional?: boolean; list?: boolean }
|
|
37
|
+
| { op: "reference"; name: string; ref: string; spread?: boolean; list?: boolean };
|
|
38
|
+
|
|
39
|
+
/** A composed motion-shape declaration lifted from the model (ADR 0040 §9). */
|
|
40
|
+
export interface ShapeDecl {
|
|
41
|
+
/** The fuse identity; also the `DomainTypes` key the resolved shape lands under. */
|
|
42
|
+
id: string;
|
|
43
|
+
/** Optional human label (`nano:shape name`). */
|
|
44
|
+
name?: string;
|
|
45
|
+
/** The defining process id, for the `model:<processId>` provenance tag. */
|
|
46
|
+
process?: string;
|
|
47
|
+
/** The ordered composition operations. */
|
|
48
|
+
ops: ShapeOp[];
|
|
49
|
+
/** Model-level metadata (`nano:meta`), free-form key/value (ADR 0040 §5). */
|
|
50
|
+
meta?: Record<string, string>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A scan/resolve-time problem with a shape, surfaced like the `workers[]` drift warning (never a
|
|
54
|
+
* silent merge). A shape with any `error` diagnostic is omitted from the fuse; a `warning` (e.g. a
|
|
55
|
+
* deliberate field shadow) still resolves. */
|
|
56
|
+
export interface ShapeDiagnostic {
|
|
57
|
+
/** The offending shape id. */
|
|
58
|
+
shape: string;
|
|
59
|
+
kind:
|
|
60
|
+
| "unresolved-reference"
|
|
61
|
+
| "reference-cycle"
|
|
62
|
+
| "field-conflict"
|
|
63
|
+
| "unknown-field"
|
|
64
|
+
| "duplicate-id"
|
|
65
|
+
| "ambiguous-reference"
|
|
66
|
+
| "nominal-table-ref"
|
|
67
|
+
| "same-id-collision";
|
|
68
|
+
severity: "error" | "warning";
|
|
69
|
+
message: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The result of resolving the project's shapes against the leaf fuse. */
|
|
73
|
+
export interface ShapeResolution {
|
|
74
|
+
/** The resolved shapes as registry entries, keyed by shape id, ready to fold into the manifest
|
|
75
|
+
* `types` registry before `emitDomainModel`. */
|
|
76
|
+
types: DomainTypeRegistry;
|
|
77
|
+
diagnostics: ShapeDiagnostic[];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** An entity the fuse can resolve a `carry`/`project`/`reference` against: its field map, plus (DB
|
|
81
|
+
* tables only) its FK columns for `via`-path validation. */
|
|
82
|
+
interface FuseEntity {
|
|
83
|
+
fields: Record<string, DomainFieldDef>;
|
|
84
|
+
/** FK column name → referenced table, for `project via` validation. */
|
|
85
|
+
fks?: Record<string, string>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Map one datasource column to a domain field. A nullable column widens to `optional` (the
|
|
89
|
+
* composed shape is a motion snapshot: an absent value reads as undefined, not SQL NULL). The
|
|
90
|
+
* manifest keyword is inferred from the column's SQLite affinity so it flows through `fieldTsType`
|
|
91
|
+
* like any declared field; affinities with no keyword (BLOB/opaque) fall back to `json`. */
|
|
92
|
+
export function columnToField(col: ColumnMeta): DomainFieldDef {
|
|
93
|
+
const ts = sqliteAffinityToTs(col.type);
|
|
94
|
+
const keyword = ts === "string" || ts === "number" || ts === "boolean" ? ts : "json";
|
|
95
|
+
const nullable = !(col.notNull || col.primaryKey);
|
|
96
|
+
return nullable ? { type: keyword, optional: true } : { type: keyword };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Build the leaf entity index the shape fold resolves references against: every datasource table
|
|
100
|
+
* (by raw wire name, and by `source.table` to disambiguate a name shared across sources) and every
|
|
101
|
+
* manifest `type` (by id). A raw table name shared across sources keeps the first
|
|
102
|
+
* (default-source-first) binding; the qualified `source.table` alias is always unambiguous.
|
|
103
|
+
*
|
|
104
|
+
* When a manifest `type` id collides with a bare table name, the **type wins** the unqualified id
|
|
105
|
+
* (it is the more first-class, `DomainTypes`-visible entity) and the collision is surfaced via
|
|
106
|
+
* `ambiguousIds` so resolution can warn; the table stays reachable through its `source.table`
|
|
107
|
+
* alias. `tableIds` records every id (bare + qualified) that resolves to a DB table, so a nominal
|
|
108
|
+
* reference (which the emitter can only express against `DomainTypes` keys) can be rejected. */
|
|
109
|
+
function leafEntityIndex(
|
|
110
|
+
sources: SourceSchema[],
|
|
111
|
+
types: DomainTypeRegistry,
|
|
112
|
+
): { index: Map<string, FuseEntity>; tableIds: Set<string>; ambiguousIds: Set<string> } {
|
|
113
|
+
const index = new Map<string, FuseEntity>();
|
|
114
|
+
const tableIds = new Set<string>();
|
|
115
|
+
for (const s of sources) {
|
|
116
|
+
for (const t of s.tables) {
|
|
117
|
+
const fields: Record<string, DomainFieldDef> = {};
|
|
118
|
+
const fks: Record<string, string> = {};
|
|
119
|
+
for (const c of t.columns) fields[c.name] = columnToField(c);
|
|
120
|
+
// Store FK targets as the unambiguous `source.table` id (FKs are intra-source) so `via`-path
|
|
121
|
+
// following resolves the intended table, never a same-named table in another source or a
|
|
122
|
+
// type-preferred bare id.
|
|
123
|
+
for (const fk of t.foreignKeys ?? []) fks[fk.column] = `${s.source}.${fk.refTable}`;
|
|
124
|
+
const entity: FuseEntity = { fields, fks };
|
|
125
|
+
index.set(`${s.source}.${t.name}`, entity);
|
|
126
|
+
tableIds.add(`${s.source}.${t.name}`);
|
|
127
|
+
if (!index.has(t.name)) {
|
|
128
|
+
index.set(t.name, entity);
|
|
129
|
+
tableIds.add(t.name);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const ambiguousIds = new Set<string>();
|
|
134
|
+
for (const [id, def] of Object.entries(types)) {
|
|
135
|
+
// The manifest type wins an unqualified id that also names a table; the table remains
|
|
136
|
+
// reachable by its `source.table` alias.
|
|
137
|
+
if (tableIds.has(id)) ambiguousIds.add(id);
|
|
138
|
+
index.set(id, { fields: { ...def.fields } });
|
|
139
|
+
}
|
|
140
|
+
return { index, tableIds, ambiguousIds };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The shape ids a shape references (carry/project/reference targets, and an `extend` whose type
|
|
144
|
+
* names a shape) — the edges of the shape dependency graph. */
|
|
145
|
+
function referencedShapeIds(shape: ShapeDecl, shapeIds: Set<string>): string[] {
|
|
146
|
+
const refs = new Set<string>();
|
|
147
|
+
for (const op of shape.ops) {
|
|
148
|
+
if (op.op === "carry" || op.op === "project" || op.op === "reference") {
|
|
149
|
+
if (shapeIds.has(op.ref)) refs.add(op.ref);
|
|
150
|
+
} else if (op.op === "extend" && shapeIds.has(op.type)) {
|
|
151
|
+
refs.add(op.type);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return [...refs];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Structural equality of two domain field defs (`type` + normalized optional/list flags), so a
|
|
158
|
+
* differing property insertion order does not read as a conflict. */
|
|
159
|
+
function sameFieldDef(a: DomainFieldDef, b: DomainFieldDef): boolean {
|
|
160
|
+
return a.type === b.type && !!a.optional === !!b.optional && !!a.list === !!b.list;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Resolve the project's composed shapes into `DomainTypeDef`s and diagnostics (ADR 0040 §10).
|
|
165
|
+
* Leaves (DB tables, manifest types) fuse first; shapes resolve in dependency order so a shape can
|
|
166
|
+
* carry another shape regardless of declaration order. A shape in a reference cycle, or one that
|
|
167
|
+
* names an unresolvable id, is omitted (degrades to untyped) with an `error` diagnostic; a
|
|
168
|
+
* deliberate field shadow with a differing type resolves with a `field-conflict` warning.
|
|
169
|
+
*/
|
|
170
|
+
export function resolveShapes(
|
|
171
|
+
shapes: ShapeDecl[],
|
|
172
|
+
types: DomainTypeRegistry,
|
|
173
|
+
sources: SourceSchema[] = [],
|
|
174
|
+
): ShapeResolution {
|
|
175
|
+
const diagnostics: ShapeDiagnostic[] = [];
|
|
176
|
+
const resolved: DomainTypeRegistry = {};
|
|
177
|
+
if (shapes.length === 0) return { types: resolved, diagnostics };
|
|
178
|
+
|
|
179
|
+
const { index, tableIds, ambiguousIds } = leafEntityIndex(sources, types);
|
|
180
|
+
// A nominal reference (an `extend` type or a non-spread `reference`) must resolve to a
|
|
181
|
+
// `DomainTypes` key at emit time — a manifest type or a resolved shape id. DB tables are
|
|
182
|
+
// spread-only (their fields flatten via carry/project); nominally referencing one would degrade
|
|
183
|
+
// to `unknown` in the emitted `.d.ts`, so we reject.
|
|
184
|
+
const nominalIds = new Set<string>(Object.keys(types));
|
|
185
|
+
// Duplicate shape ids are fuse-identity collisions: since resolution keys by id, a later
|
|
186
|
+
// declaration would silently shadow an earlier one. Report every id that appears more than once
|
|
187
|
+
// and omit all of its declarations from resolution.
|
|
188
|
+
const idCounts = new Map<string, number>();
|
|
189
|
+
for (const s of shapes) if (s.id) idCounts.set(s.id, (idCounts.get(s.id) ?? 0) + 1);
|
|
190
|
+
const duplicated = new Set<string>();
|
|
191
|
+
for (const [id, n] of idCounts) {
|
|
192
|
+
if (n > 1) {
|
|
193
|
+
duplicated.add(id);
|
|
194
|
+
diagnostics.push({
|
|
195
|
+
shape: id,
|
|
196
|
+
kind: "duplicate-id",
|
|
197
|
+
severity: "error",
|
|
198
|
+
message: `shape id "${id}" is declared ${n} times; ids are fuse identities and must be unique`,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
const byId = new Map<string, ShapeDecl>();
|
|
203
|
+
for (const s of shapes) if (s.id && !duplicated.has(s.id)) byId.set(s.id, s);
|
|
204
|
+
const shapeIds = new Set(byId.keys());
|
|
205
|
+
|
|
206
|
+
// Cycle detection over the shape-only dependency graph (DFS three-colour). Every shape on a back
|
|
207
|
+
// edge is failed; the reported path aids the maker's fix.
|
|
208
|
+
const failed = new Set<string>();
|
|
209
|
+
const colour = new Map<string, 0 | 1 | 2>(); // 0=unvisited 1=on-stack 2=done
|
|
210
|
+
const visit = (id: string, stack: string[]): void => {
|
|
211
|
+
colour.set(id, 1);
|
|
212
|
+
stack.push(id);
|
|
213
|
+
for (const dep of referencedShapeIds(byId.get(id)!, shapeIds)) {
|
|
214
|
+
const c = colour.get(dep) ?? 0;
|
|
215
|
+
if (c === 1) {
|
|
216
|
+
const cycle = stack.slice(stack.indexOf(dep)).concat(dep);
|
|
217
|
+
for (const n of cycle) {
|
|
218
|
+
if (!failed.has(n)) {
|
|
219
|
+
failed.add(n);
|
|
220
|
+
diagnostics.push({
|
|
221
|
+
shape: n,
|
|
222
|
+
kind: "reference-cycle",
|
|
223
|
+
severity: "error",
|
|
224
|
+
message: `shape "${n}" is part of a reference cycle: ${cycle.join(" → ")}`,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
} else if (c === 0) {
|
|
229
|
+
visit(dep, stack);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
stack.pop();
|
|
233
|
+
colour.set(id, 2);
|
|
234
|
+
};
|
|
235
|
+
for (const id of byId.keys()) if ((colour.get(id) ?? 0) === 0) visit(id, []);
|
|
236
|
+
|
|
237
|
+
// Topological resolution: resolve a shape only after its (non-failed) shape dependencies, adding
|
|
238
|
+
// each resolved shape to the index so later shapes see it.
|
|
239
|
+
const done = new Set<string>();
|
|
240
|
+
const resolveOne = (shape: ShapeDecl): void => {
|
|
241
|
+
if (done.has(shape.id) || failed.has(shape.id)) return;
|
|
242
|
+
// Resolve shape dependencies first (they may themselves be pending).
|
|
243
|
+
for (const dep of referencedShapeIds(shape, shapeIds)) {
|
|
244
|
+
const d = byId.get(dep);
|
|
245
|
+
if (d && !done.has(dep) && !failed.has(dep)) resolveOne(d);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const fields: Record<string, DomainFieldDef> = {};
|
|
249
|
+
let broken = false;
|
|
250
|
+
const addField = (name: string, field: DomainFieldDef): void => {
|
|
251
|
+
const existing = fields[name];
|
|
252
|
+
if (existing && !sameFieldDef(existing, field)) {
|
|
253
|
+
diagnostics.push({
|
|
254
|
+
shape: shape.id,
|
|
255
|
+
kind: "field-conflict",
|
|
256
|
+
severity: "warning",
|
|
257
|
+
message:
|
|
258
|
+
`field "${name}" is contributed twice with differing types; the later value wins (author-order fold)`,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
fields[name] = field; // last (author-order) wins
|
|
262
|
+
};
|
|
263
|
+
const lookup = (ref: string): FuseEntity | undefined => index.get(ref);
|
|
264
|
+
const unresolved = (ref: string): void => {
|
|
265
|
+
broken = true;
|
|
266
|
+
diagnostics.push({
|
|
267
|
+
shape: shape.id,
|
|
268
|
+
kind: "unresolved-reference",
|
|
269
|
+
severity: "error",
|
|
270
|
+
message: `shape "${shape.id}" references unknown entity "${ref}"`,
|
|
271
|
+
});
|
|
272
|
+
};
|
|
273
|
+
// A bare id that names both a manifest type and a table resolves to the type; warn so the
|
|
274
|
+
// (silent) precedence is visible and the maker can qualify.
|
|
275
|
+
const noteAmbiguity = (ref: string): void => {
|
|
276
|
+
if (ambiguousIds.has(ref)) {
|
|
277
|
+
diagnostics.push({
|
|
278
|
+
shape: shape.id,
|
|
279
|
+
kind: "ambiguous-reference",
|
|
280
|
+
severity: "warning",
|
|
281
|
+
message:
|
|
282
|
+
`"${ref}" names both a manifest type and a table; resolved to the type — qualify as "<source>.${ref}" to target the table`,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
// Report a nominal reference (extend type / non-spread reference) that targets a DB table,
|
|
287
|
+
// which cannot be expressed as a `DomainTypes` ref (it would degrade to `unknown`); the maker
|
|
288
|
+
// should spread it instead (carry/project or spread=true).
|
|
289
|
+
const nominalTableRef = (kind: string, ref: string): void => {
|
|
290
|
+
broken = true;
|
|
291
|
+
diagnostics.push({
|
|
292
|
+
shape: shape.id,
|
|
293
|
+
kind: "nominal-table-ref",
|
|
294
|
+
severity: "error",
|
|
295
|
+
message:
|
|
296
|
+
`${kind} nominally references table "${ref}", which is not a DomainTypes entity; spread its fields (carry/project or reference spread="true") instead`,
|
|
297
|
+
});
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
for (const op of shape.ops) {
|
|
301
|
+
switch (op.op) {
|
|
302
|
+
case "carry": {
|
|
303
|
+
const e = lookup(op.ref);
|
|
304
|
+
if (!e) {
|
|
305
|
+
unresolved(op.ref);
|
|
306
|
+
break;
|
|
307
|
+
}
|
|
308
|
+
noteAmbiguity(op.ref);
|
|
309
|
+
for (const [k, f] of Object.entries(e.fields)) addField(k, f);
|
|
310
|
+
break;
|
|
311
|
+
}
|
|
312
|
+
case "project": {
|
|
313
|
+
const e = lookup(op.ref);
|
|
314
|
+
if (!e) {
|
|
315
|
+
unresolved(op.ref);
|
|
316
|
+
break;
|
|
317
|
+
}
|
|
318
|
+
noteAmbiguity(op.ref);
|
|
319
|
+
for (const fname of op.fields) {
|
|
320
|
+
const f = e.fields[fname];
|
|
321
|
+
if (!f) {
|
|
322
|
+
broken = true;
|
|
323
|
+
diagnostics.push({
|
|
324
|
+
shape: shape.id,
|
|
325
|
+
kind: "unknown-field",
|
|
326
|
+
severity: "error",
|
|
327
|
+
message: `projected field "${fname}" is not a field of "${op.ref}"`,
|
|
328
|
+
});
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
addField(fname, f);
|
|
332
|
+
}
|
|
333
|
+
if (op.via) validateVia(shape.id, op.via, index, diagnostics);
|
|
334
|
+
break;
|
|
335
|
+
}
|
|
336
|
+
case "extend": {
|
|
337
|
+
if (!isPrimitiveKeyword(op.type)) {
|
|
338
|
+
if (nominalIds.has(op.type)) {
|
|
339
|
+
// ok — a manifest type or an already-resolved shape id
|
|
340
|
+
} else if (tableIds.has(op.type)) {
|
|
341
|
+
nominalTableRef(`extend field "${op.name}"`, op.type);
|
|
342
|
+
break;
|
|
343
|
+
} else {
|
|
344
|
+
broken = true;
|
|
345
|
+
diagnostics.push({
|
|
346
|
+
shape: shape.id,
|
|
347
|
+
kind: "unresolved-reference",
|
|
348
|
+
severity: "error",
|
|
349
|
+
message:
|
|
350
|
+
`extend field "${op.name}" has type "${op.type}", which is neither a scalar keyword nor a fused entity`,
|
|
351
|
+
});
|
|
352
|
+
break;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
const field: DomainFieldDef = { type: op.type };
|
|
356
|
+
if (op.optional) field.optional = true;
|
|
357
|
+
if (op.list) field.list = true;
|
|
358
|
+
addField(op.name, field);
|
|
359
|
+
break;
|
|
360
|
+
}
|
|
361
|
+
case "reference": {
|
|
362
|
+
const e = lookup(op.ref);
|
|
363
|
+
if (!e) {
|
|
364
|
+
unresolved(op.ref);
|
|
365
|
+
break;
|
|
366
|
+
}
|
|
367
|
+
if (op.spread) {
|
|
368
|
+
noteAmbiguity(op.ref);
|
|
369
|
+
for (const [k, f] of Object.entries(e.fields)) addField(k, f);
|
|
370
|
+
} else if (!nominalIds.has(op.ref)) {
|
|
371
|
+
// `e` exists but the id is not a DomainTypes key — it is a DB table.
|
|
372
|
+
nominalTableRef(`reference "${op.name}"`, op.ref);
|
|
373
|
+
} else {
|
|
374
|
+
const field: DomainFieldDef = { type: op.ref };
|
|
375
|
+
if (op.list) field.list = true;
|
|
376
|
+
addField(op.name, field);
|
|
377
|
+
}
|
|
378
|
+
break;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// same-id collision: the shape id shadows a leaf entity it does not source. The shape composes
|
|
384
|
+
// that entity when it references it under any id (bare or `source.table` alias) — since the
|
|
385
|
+
// index maps every alias to the same `FuseEntity` instance, compare by identity rather than by
|
|
386
|
+
// the bare id alone.
|
|
387
|
+
const collidingEntity = index.get(shape.id);
|
|
388
|
+
const composesColliding =
|
|
389
|
+
collidingEntity !== undefined &&
|
|
390
|
+
shape.ops.some((o) => o.op !== "extend" && index.get(o.ref) === collidingEntity);
|
|
391
|
+
if (collidingEntity !== undefined && !composesColliding) {
|
|
392
|
+
diagnostics.push({
|
|
393
|
+
shape: shape.id,
|
|
394
|
+
kind: "same-id-collision",
|
|
395
|
+
severity: "error",
|
|
396
|
+
message:
|
|
397
|
+
`shape id "${shape.id}" collides with an existing fused entity it does not compose`,
|
|
398
|
+
});
|
|
399
|
+
broken = true;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
done.add(shape.id);
|
|
403
|
+
if (broken) {
|
|
404
|
+
failed.add(shape.id);
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
const def: DomainTypeDef = { name: shape.name, fields };
|
|
408
|
+
resolved[shape.id] = def;
|
|
409
|
+
// Later shapes may carry this one; expose its resolved fields to the index and mark it
|
|
410
|
+
// nominal-referenceable (it will be a `DomainTypes` key).
|
|
411
|
+
index.set(shape.id, { fields });
|
|
412
|
+
nominalIds.add(shape.id);
|
|
413
|
+
};
|
|
414
|
+
for (const s of shapes) if (s.id && !duplicated.has(s.id)) resolveOne(s);
|
|
415
|
+
|
|
416
|
+
return { types: resolved, diagnostics };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** Validate a `project via` FK path (`Entity.column[.column...]`): the leading entity must resolve
|
|
420
|
+
* and carry the named column (an FK column on a DB table, or any field on a manifest type/shape).
|
|
421
|
+
* Deeper hops are validated leniently — the FK target is followed when the leading entity is a DB
|
|
422
|
+
* table with that FK. */
|
|
423
|
+
function validateVia(
|
|
424
|
+
shape: string,
|
|
425
|
+
via: string,
|
|
426
|
+
index: Map<string, FuseEntity>,
|
|
427
|
+
diagnostics: ShapeDiagnostic[],
|
|
428
|
+
): void {
|
|
429
|
+
const parts = via.split(".");
|
|
430
|
+
if (parts.length < 2) {
|
|
431
|
+
diagnostics.push({
|
|
432
|
+
shape,
|
|
433
|
+
kind: "unknown-field",
|
|
434
|
+
severity: "warning",
|
|
435
|
+
message: `via path "${via}" is not of the form Entity.column`,
|
|
436
|
+
});
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
// Resolve the starting entity as the *longest* dotted prefix present in the index (so a qualified
|
|
440
|
+
// `source.table` start works, not just a bare id), leaving at least one trailing segment as a hop
|
|
441
|
+
// column. The remaining segments are hop columns.
|
|
442
|
+
let entity: FuseEntity | undefined;
|
|
443
|
+
let start = 0;
|
|
444
|
+
for (let p = 1; p < parts.length; p++) {
|
|
445
|
+
const candidate = parts.slice(0, p).join(".");
|
|
446
|
+
const hit = index.get(candidate);
|
|
447
|
+
if (hit) {
|
|
448
|
+
entity = hit;
|
|
449
|
+
start = p;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
if (!entity) {
|
|
453
|
+
diagnostics.push({
|
|
454
|
+
shape,
|
|
455
|
+
kind: "unknown-field",
|
|
456
|
+
severity: "warning",
|
|
457
|
+
message: `via path "${via}" starts at unknown entity "${parts.slice(0, -1).join(".")}"`,
|
|
458
|
+
});
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
let cursor: FuseEntity = entity;
|
|
462
|
+
for (let i = start; i < parts.length; i++) {
|
|
463
|
+
const col = parts[i];
|
|
464
|
+
const hasField = Object.prototype.hasOwnProperty.call(cursor.fields, col);
|
|
465
|
+
const fkTarget: string | undefined = cursor.fks?.[col];
|
|
466
|
+
if (!hasField && !fkTarget) {
|
|
467
|
+
diagnostics.push({
|
|
468
|
+
shape,
|
|
469
|
+
kind: "unknown-field",
|
|
470
|
+
severity: "warning",
|
|
471
|
+
message: `via path "${via}" hops through unknown column "${col}"`,
|
|
472
|
+
});
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
// Follow the FK to the next entity when we can; otherwise stop (lenient).
|
|
476
|
+
const next: FuseEntity | undefined = fkTarget ? index.get(fkTarget) : undefined;
|
|
477
|
+
if (!next) return;
|
|
478
|
+
cursor = next;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// --- the structured fused domain model: domain.json (ADR 0040 §1, OQ1) -----------------------
|
|
483
|
+
//
|
|
484
|
+
// The computed fuse is persisted as a generated, git-ignored `nano-generated/domain.json` — a
|
|
485
|
+
// fast-read structured index for the IDE/codegen so a reader need not re-scan every model +
|
|
486
|
+
// datasource. It is a *cache*, never a source: the `domaintypes` op regenerates it write-through on
|
|
487
|
+
// any source change, and an `inputsHash` over its own content lets a reader detect staleness
|
|
488
|
+
// cheaply.
|
|
489
|
+
|
|
490
|
+
/** The basename of the generated structured fuse cache (ADR 0040 §1). */
|
|
491
|
+
export const DOMAIN_MODEL_JSON = "domain.json";
|
|
492
|
+
|
|
493
|
+
/** A field of a fused entity in `domain.json`: the resolved domain field plus its name. `type` is
|
|
494
|
+
* a primitive keyword or the id of another fused entity. */
|
|
495
|
+
interface FusedFieldJson extends DomainFieldDef {
|
|
496
|
+
name: string;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** One fused entity in `domain.json`, tagged with its provenance and kind. */
|
|
500
|
+
interface FusedEntityJson {
|
|
501
|
+
id: string;
|
|
502
|
+
kind: "table" | "type" | "shape";
|
|
503
|
+
/** `db:<source>.<table>` | `manifest:<id>` | `model:<processId>`. */
|
|
504
|
+
provenance: string;
|
|
505
|
+
name?: string;
|
|
506
|
+
fields: FusedFieldJson[];
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/** FNV-1a (32-bit) hex of a string — a small, dependency-free content tag for the fuse cache's
|
|
510
|
+
* `inputsHash` (staleness detection, not security). */
|
|
511
|
+
function fnv1aHex(s: string): string {
|
|
512
|
+
let h = 0x811c9dc5;
|
|
513
|
+
for (let i = 0; i < s.length; i++) {
|
|
514
|
+
h ^= s.charCodeAt(i);
|
|
515
|
+
h = Math.imul(h, 0x01000193);
|
|
516
|
+
}
|
|
517
|
+
return (h >>> 0).toString(16).padStart(8, "0");
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** Convert a resolved field map to the ordered `domain.json` field list. */
|
|
521
|
+
function fieldsJson(fields: Record<string, DomainFieldDef>): FusedFieldJson[] {
|
|
522
|
+
return Object.entries(fields ?? {}).map(([name, f]) => {
|
|
523
|
+
const out: FusedFieldJson = { name, type: f.type };
|
|
524
|
+
if (f.optional) out.optional = true;
|
|
525
|
+
if (f.list) out.list = true;
|
|
526
|
+
return out;
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** One model-level metadata entry (`nano:meta`) as `emitDomainModelJson` consumes it. Mirrors
|
|
531
|
+
* `meta.ts`'s `MetaDecl` (kept local so the fuse cache does not depend on the meta deriver). */
|
|
532
|
+
export interface FusedMetaDecl {
|
|
533
|
+
process?: string;
|
|
534
|
+
key: string;
|
|
535
|
+
value: string;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Emit `domain.json`: the structured Fused Domain Model (ADR 0040 §1). Assembles every fused
|
|
540
|
+
* entity — DB tables (`db:` provenance), manifest `types` (`manifest:`), and composed motion shapes
|
|
541
|
+
* (`model:<processId>`, or bare `model` for an unsaved editor shape with no process) — with its
|
|
542
|
+
* resolved fields, plus the model-level metadata and the shape diagnostics. An `inputsHash` —
|
|
543
|
+
* FNV-1a (hex) over the compact `JSON.stringify` of the model object with `inputsHash` omitted
|
|
544
|
+
* (property-insertion order, not the pretty-printed bytes) — tags the cache for staleness.
|
|
545
|
+
*/
|
|
546
|
+
export function emitDomainModelJson(input: {
|
|
547
|
+
sources: SourceSchema[];
|
|
548
|
+
default?: string;
|
|
549
|
+
manifestTypes: DomainTypeRegistry;
|
|
550
|
+
/** Resolved shapes paired with their declaration (for provenance/label). */
|
|
551
|
+
shapes: { decl: ShapeDecl; def: DomainTypeDef }[];
|
|
552
|
+
meta: FusedMetaDecl[];
|
|
553
|
+
diagnostics: ShapeDiagnostic[];
|
|
554
|
+
}): string {
|
|
555
|
+
const entities: FusedEntityJson[] = [];
|
|
556
|
+
for (const s of input.sources) {
|
|
557
|
+
for (const t of s.tables) {
|
|
558
|
+
const fields: Record<string, DomainFieldDef> = {};
|
|
559
|
+
for (const c of t.columns) fields[c.name] = columnToField(c);
|
|
560
|
+
entities.push({
|
|
561
|
+
id: `${s.source}.${t.name}`,
|
|
562
|
+
kind: "table",
|
|
563
|
+
provenance: `db:${s.source}.${t.name}`,
|
|
564
|
+
fields: fieldsJson(fields),
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
for (const [id, def] of Object.entries(input.manifestTypes)) {
|
|
569
|
+
entities.push({
|
|
570
|
+
id,
|
|
571
|
+
kind: "type",
|
|
572
|
+
provenance: `manifest:${id}`,
|
|
573
|
+
...(def.name ? { name: def.name } : {}),
|
|
574
|
+
fields: fieldsJson(def.fields),
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
for (const { decl, def } of input.shapes) {
|
|
578
|
+
entities.push({
|
|
579
|
+
id: decl.id,
|
|
580
|
+
kind: "shape",
|
|
581
|
+
provenance: decl.process ? `model:${decl.process}` : "model",
|
|
582
|
+
...(def.name ?? decl.name ? { name: def.name ?? decl.name } : {}),
|
|
583
|
+
fields: fieldsJson(def.fields),
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
const meta = input.meta
|
|
587
|
+
.filter((m) => (m?.key ?? "").trim().length > 0)
|
|
588
|
+
.map((m) => ({ ...(m.process ? { process: m.process } : {}), key: m.key, value: m.value ?? "" }));
|
|
589
|
+
const model = {
|
|
590
|
+
$generated:
|
|
591
|
+
"nanobpmn ADR 0040 §1 Fused Domain Model — do not edit; regenerated by the domaintypes op",
|
|
592
|
+
version: 1,
|
|
593
|
+
...(input.default ? { default: input.default } : {}),
|
|
594
|
+
sources: input.sources.map((s) => s.source),
|
|
595
|
+
entities,
|
|
596
|
+
meta,
|
|
597
|
+
diagnostics: input.diagnostics,
|
|
598
|
+
inputsHash: "",
|
|
599
|
+
};
|
|
600
|
+
model.inputsHash = fnv1aHex(JSON.stringify({ ...model, inputsHash: undefined }));
|
|
601
|
+
return `${JSON.stringify(model, null, 2)}\n`;
|
|
602
|
+
}
|