@bpmnkit/core 0.1.2 → 0.2.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/README.md +30 -1
- package/dist/bpmn/bpmn-builder.d.ts +209 -3
- package/dist/bpmn/bpmn-builder.js +456 -16
- package/dist/bpmn/bpmn-model.d.ts +110 -0
- package/dist/bpmn/bpmn-parser.js +1413 -528
- package/dist/bpmn/bpmn-serializer.js +101 -19
- package/dist/bpmn/compact.d.ts +17 -2
- package/dist/bpmn/compact.js +3 -3
- package/dist/bpmn/full-operations.d.ts +89 -0
- package/dist/bpmn/full-operations.js +478 -0
- package/dist/bpmn/index.d.ts +19 -0
- package/dist/bpmn/index.js +21 -0
- package/dist/bpmn/optimize/feel.js +2 -2
- package/dist/bpmn/optimize/patterns.js +23 -16
- package/dist/bpmn/optimize/tasks.js +30 -7
- package/dist/bpmn/optimize/utils.js +2 -4
- package/dist/bpmn/optimize/variable-flow.js +58 -67
- package/dist/bpmn/semantic-hash.d.ts +93 -0
- package/dist/bpmn/semantic-hash.js +155 -0
- package/dist/bpmn/sha256.d.ts +17 -0
- package/dist/bpmn/sha256.js +95 -0
- package/dist/bpmn/zeebe-extensions.d.ts +56 -0
- package/dist/bpmn/zeebe-extensions.js +79 -0
- package/dist/bpmn/zeebe-placement.d.ts +12 -0
- package/dist/bpmn/zeebe-placement.js +140 -0
- package/dist/errors.d.ts +40 -1
- package/dist/errors.js +41 -0
- package/dist/index.d.ts +10 -4
- package/dist/index.js +7 -3
- package/dist/layout/semantic/graph.d.ts +9 -1
- package/dist/layout/semantic/graph.js +42 -17
- package/dist/layout/semantic/route.js +102 -42
- package/dist/node/index.d.ts +10 -0
- package/dist/node/index.js +9 -0
- package/dist/node/write.d.ts +81 -0
- package/dist/node/write.js +167 -0
- package/dist/types/id-generator.js +11 -3
- package/dist/xml/index.d.ts +3 -1
- package/dist/xml/index.js +2 -1
- package/dist/xml/xml-parser.d.ts +32 -0
- package/dist/xml/xml-parser.js +394 -143
- package/package.json +8 -1
|
@@ -13,6 +13,49 @@ const DETOUR_PENALTY = 0;
|
|
|
13
13
|
const CORRIDOR_SPACING = 20;
|
|
14
14
|
/** How many lanes deep a corridor may stack before routes are allowed to share. */
|
|
15
15
|
const CORRIDOR_LANES = 4;
|
|
16
|
+
/** Width of one bucket in the x-axis indexes below. */
|
|
17
|
+
const INDEX_CELL = 128;
|
|
18
|
+
/**
|
|
19
|
+
* Items bucketed by the x-range they cover, so a query for a segment's x-span
|
|
20
|
+
* visits only the shapes and routes near it instead of every one in the
|
|
21
|
+
* diagram. Orthogonal routes are mostly short, so this turns the per-edge
|
|
22
|
+
* obstacle and crossing checks from O(n) into near-constant work.
|
|
23
|
+
*/
|
|
24
|
+
class XIndex {
|
|
25
|
+
cells = new Map();
|
|
26
|
+
stamp = 0;
|
|
27
|
+
insert(minX, maxX, item) {
|
|
28
|
+
const entry = { item, stamp: 0 };
|
|
29
|
+
const lo = Math.floor(Math.min(minX, maxX) / INDEX_CELL);
|
|
30
|
+
const hi = Math.floor(Math.max(minX, maxX) / INDEX_CELL);
|
|
31
|
+
for (let c = lo; c <= hi; c++) {
|
|
32
|
+
const cell = this.cells.get(c);
|
|
33
|
+
if (cell)
|
|
34
|
+
cell.push(entry);
|
|
35
|
+
else
|
|
36
|
+
this.cells.set(c, [entry]);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/** Visit every item whose x-range may overlap [minX, maxX]; stop early when `visit` returns true. */
|
|
40
|
+
query(minX, maxX, visit) {
|
|
41
|
+
const stamp = ++this.stamp;
|
|
42
|
+
const lo = Math.floor(Math.min(minX, maxX) / INDEX_CELL);
|
|
43
|
+
const hi = Math.floor(Math.max(minX, maxX) / INDEX_CELL);
|
|
44
|
+
for (let c = lo; c <= hi; c++) {
|
|
45
|
+
const cell = this.cells.get(c);
|
|
46
|
+
if (!cell)
|
|
47
|
+
continue;
|
|
48
|
+
for (const entry of cell) {
|
|
49
|
+
if (entry.stamp === stamp)
|
|
50
|
+
continue;
|
|
51
|
+
entry.stamp = stamp;
|
|
52
|
+
if (visit(entry.item))
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
16
59
|
function centre(b) {
|
|
17
60
|
return { x: b.x + b.width / 2, y: b.y + b.height / 2 };
|
|
18
61
|
}
|
|
@@ -25,7 +68,17 @@ function centre(b) {
|
|
|
25
68
|
* the empty gutter in front of the target's column as the general fallback.
|
|
26
69
|
*/
|
|
27
70
|
export function routeFlows(graph, flows, bounds, bandLayout, gutterX) {
|
|
28
|
-
const
|
|
71
|
+
const obstacles = new XIndex();
|
|
72
|
+
for (const [id, b] of bounds)
|
|
73
|
+
obstacles.insert(b.x, b.x + b.width, { id, b });
|
|
74
|
+
const ctx = {
|
|
75
|
+
graph,
|
|
76
|
+
bounds,
|
|
77
|
+
obstacles,
|
|
78
|
+
gutterX,
|
|
79
|
+
reserved: [],
|
|
80
|
+
routed: new XIndex(),
|
|
81
|
+
};
|
|
29
82
|
const routed = new Map();
|
|
30
83
|
const pending = [];
|
|
31
84
|
for (const flow of flows) {
|
|
@@ -65,7 +118,7 @@ export function routeFlows(graph, flows, bounds, bandLayout, gutterX) {
|
|
|
65
118
|
const around = detour(ctx, item.source, item.target, item.sourceRank, item.targetRank, item.below, item.flow.sourceRef, item.flow.targetRef);
|
|
66
119
|
// Going around costs bends and length, so it has to save more than one
|
|
67
120
|
// crossing to be worth taking.
|
|
68
|
-
const cost = crossingCount(around, ctx
|
|
121
|
+
const cost = crossingCount(around, ctx) + DETOUR_PENALTY;
|
|
69
122
|
const waypoints = item.direct && item.direct.crossings <= cost ? item.direct.waypoints : around;
|
|
70
123
|
commit(ctx, routed, item.flow.id, waypoints);
|
|
71
124
|
}
|
|
@@ -94,7 +147,7 @@ function commit(ctx, routed, id, waypoints) {
|
|
|
94
147
|
const a = waypoints[i];
|
|
95
148
|
const b = waypoints[i + 1];
|
|
96
149
|
if (a && b)
|
|
97
|
-
ctx.routed.
|
|
150
|
+
ctx.routed.insert(Math.min(a.x, b.x), Math.max(a.x, b.x), [a, b]);
|
|
98
151
|
}
|
|
99
152
|
}
|
|
100
153
|
function hostOf(graph, eventId) {
|
|
@@ -106,18 +159,13 @@ function hostOf(graph, eventId) {
|
|
|
106
159
|
}
|
|
107
160
|
/** First candidate that crosses no shape other than its own endpoints. */
|
|
108
161
|
function pick(candidates, ctx, sourceId, targetId) {
|
|
109
|
-
const obstacles = [];
|
|
110
|
-
for (const [id, b] of ctx.bounds) {
|
|
111
|
-
if (id === sourceId || id === targetId)
|
|
112
|
-
continue;
|
|
113
|
-
// An expanded container legitimately holds its children's routes.
|
|
114
|
-
obstacles.push(b);
|
|
115
|
-
}
|
|
116
162
|
let best = null;
|
|
117
163
|
for (const candidate of candidates) {
|
|
118
|
-
|
|
164
|
+
// An expanded container legitimately holds its children's routes, so only
|
|
165
|
+
// the two endpoints are exempt from the obstacle check.
|
|
166
|
+
if (blocked(ctx, candidate, sourceId, targetId))
|
|
119
167
|
continue;
|
|
120
|
-
const crossings = crossingCount(candidate, ctx
|
|
168
|
+
const crossings = crossingCount(candidate, ctx);
|
|
121
169
|
if (crossings === 0)
|
|
122
170
|
return { waypoints: candidate, crossings };
|
|
123
171
|
if (!best || crossings < best.crossings)
|
|
@@ -131,44 +179,59 @@ function intersects(p, q, r, t) {
|
|
|
131
179
|
return side(p, q, r) !== side(p, q, t) && side(r, t, p) !== side(r, t, q);
|
|
132
180
|
}
|
|
133
181
|
/** How many already-routed edges a candidate would cut across. */
|
|
134
|
-
function crossingCount(waypoints,
|
|
182
|
+
function crossingCount(waypoints, ctx) {
|
|
135
183
|
let count = 0;
|
|
136
184
|
for (let i = 0; i + 1 < waypoints.length; i++) {
|
|
137
185
|
const a = waypoints[i];
|
|
138
186
|
const b = waypoints[i + 1];
|
|
139
187
|
if (!a || !b)
|
|
140
188
|
continue;
|
|
141
|
-
|
|
189
|
+
ctx.routed.query(Math.min(a.x, b.x), Math.max(a.x, b.x), ([c, d]) => {
|
|
142
190
|
if (intersects(a, b, c, d))
|
|
143
191
|
count++;
|
|
192
|
+
return false;
|
|
193
|
+
});
|
|
144
194
|
}
|
|
145
195
|
return count;
|
|
146
196
|
}
|
|
147
|
-
/**
|
|
148
|
-
function
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
197
|
+
/** True when the segment a→b passes through `o`, grazing allowed up to HIT_TOLERANCE. */
|
|
198
|
+
function segmentHits(a, b, o) {
|
|
199
|
+
const minX = Math.min(a.x, b.x) + HIT_TOLERANCE;
|
|
200
|
+
const maxX = Math.max(a.x, b.x) - HIT_TOLERANCE;
|
|
201
|
+
const minY = Math.min(a.y, b.y) + HIT_TOLERANCE;
|
|
202
|
+
const maxY = Math.max(a.y, b.y) - HIT_TOLERANCE;
|
|
203
|
+
if (maxX <= o.x || o.x + o.width <= minX)
|
|
204
|
+
return false;
|
|
205
|
+
if (maxY <= o.y || o.y + o.height <= minY)
|
|
206
|
+
return false;
|
|
207
|
+
return true;
|
|
154
208
|
}
|
|
155
|
-
|
|
209
|
+
/** How many shapes a route passes through, its own endpoints excepted. */
|
|
210
|
+
function hitCount(ctx, waypoints, sourceId, targetId) {
|
|
211
|
+
const hit = new Set();
|
|
156
212
|
for (let i = 0; i + 1 < waypoints.length; i++) {
|
|
157
213
|
const a = waypoints[i];
|
|
158
214
|
const b = waypoints[i + 1];
|
|
159
215
|
if (!a || !b)
|
|
160
216
|
continue;
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
217
|
+
ctx.obstacles.query(Math.min(a.x, b.x), Math.max(a.x, b.x), (o) => {
|
|
218
|
+
if (o.id !== sourceId && o.id !== targetId && segmentHits(a, b, o.b))
|
|
219
|
+
hit.add(o);
|
|
220
|
+
return false;
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
return hit.size;
|
|
224
|
+
}
|
|
225
|
+
/** True when any segment of the route passes through a shape other than its endpoints. */
|
|
226
|
+
function blocked(ctx, waypoints, sourceId, targetId) {
|
|
227
|
+
for (let i = 0; i + 1 < waypoints.length; i++) {
|
|
228
|
+
const a = waypoints[i];
|
|
229
|
+
const b = waypoints[i + 1];
|
|
230
|
+
if (!a || !b)
|
|
231
|
+
continue;
|
|
232
|
+
const hit = ctx.obstacles.query(Math.min(a.x, b.x), Math.max(a.x, b.x), (o) => o.id !== sourceId && o.id !== targetId && segmentHits(a, b, o.b));
|
|
233
|
+
if (hit)
|
|
170
234
|
return true;
|
|
171
|
-
}
|
|
172
235
|
}
|
|
173
236
|
return false;
|
|
174
237
|
}
|
|
@@ -310,7 +373,7 @@ isLoop = false, sourceId = "", targetId = "") {
|
|
|
310
373
|
const crossings = crossingCount([
|
|
311
374
|
{ x: left, y: option },
|
|
312
375
|
{ x: right, y: option },
|
|
313
|
-
], ctx
|
|
376
|
+
], ctx);
|
|
314
377
|
if (crossings < fewest) {
|
|
315
378
|
fewest = crossings;
|
|
316
379
|
base = option;
|
|
@@ -342,12 +405,8 @@ isLoop = false, sourceId = "", targetId = "") {
|
|
|
342
405
|
if (chosen)
|
|
343
406
|
return chosen.waypoints;
|
|
344
407
|
// Neither is clear: keep whichever grazes fewer shapes.
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
if (id !== sourceId && id !== targetId)
|
|
348
|
-
obstacles.push(b);
|
|
349
|
-
}
|
|
350
|
-
return hitCount(straightOut, obstacles) <= hitCount(viaGutter, obstacles)
|
|
408
|
+
return hitCount(ctx, straightOut, sourceId, targetId) <=
|
|
409
|
+
hitCount(ctx, viaGutter, sourceId, targetId)
|
|
351
410
|
? straightOut
|
|
352
411
|
: viaGutter;
|
|
353
412
|
}
|
|
@@ -356,7 +415,7 @@ isLoop = false, sourceId = "", targetId = "") {
|
|
|
356
415
|
* corridor is free, so two long routes stack instead of merging into one line.
|
|
357
416
|
*/
|
|
358
417
|
function reserve(ctx, left, right, base, direction) {
|
|
359
|
-
const hitsShape = (candidate) =>
|
|
418
|
+
const hitsShape = (candidate) => ctx.obstacles.query(left, right, ({ b }) => b.x + b.width > left &&
|
|
360
419
|
b.x < right &&
|
|
361
420
|
b.y - HIT_TOLERANCE < candidate &&
|
|
362
421
|
candidate < b.y + b.height + HIT_TOLERANCE);
|
|
@@ -389,11 +448,12 @@ floor,
|
|
|
389
448
|
/** Only consider corridors above this line. */
|
|
390
449
|
ceiling) {
|
|
391
450
|
const spans = [];
|
|
392
|
-
|
|
451
|
+
ctx.obstacles.query(left, right, ({ b }) => {
|
|
393
452
|
if (b.x + b.width > left && b.x < right) {
|
|
394
453
|
spans.push([b.y - ROUTING_MARGIN, b.y + b.height + ROUTING_MARGIN]);
|
|
395
454
|
}
|
|
396
|
-
|
|
455
|
+
return false;
|
|
456
|
+
});
|
|
397
457
|
if (spans.length === 0)
|
|
398
458
|
return preferredY;
|
|
399
459
|
spans.sort((a, b) => a[0] - b[0]);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node-only entry point.
|
|
3
|
+
*
|
|
4
|
+
* Everything here touches the filesystem, so it lives behind the
|
|
5
|
+
* `@bpmnkit/core/node` subpath. Importing `@bpmnkit/core` itself stays free of
|
|
6
|
+
* `node:` builtins and keeps working in browsers, workers and edge runtimes.
|
|
7
|
+
*/
|
|
8
|
+
export { writeBpmn } from "./write.js";
|
|
9
|
+
export type { WriteBpmnOptions, WriteBpmnResult } from "./write.js";
|
|
10
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node-only entry point.
|
|
3
|
+
*
|
|
4
|
+
* Everything here touches the filesystem, so it lives behind the
|
|
5
|
+
* `@bpmnkit/core/node` subpath. Importing `@bpmnkit/core` itself stays free of
|
|
6
|
+
* `node:` builtins and keeps working in browsers, workers and edge runtimes.
|
|
7
|
+
*/
|
|
8
|
+
export { writeBpmn } from "./write.js";
|
|
9
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { BpmnDefinitions } from "../bpmn/bpmn-model.js";
|
|
2
|
+
import { type SemanticDiff } from "../bpmn/semantic-hash.js";
|
|
3
|
+
/**
|
|
4
|
+
* The only place in this package that writes a BPMN file, and the only one that
|
|
5
|
+
* checks what it wrote.
|
|
6
|
+
*
|
|
7
|
+
* Node-only — it is reached through the `@bpmnkit/core/node` subpath so that
|
|
8
|
+
* importing `@bpmnkit/core` in a browser never pulls `node:fs` in.
|
|
9
|
+
*
|
|
10
|
+
* **What the verification does and does not cover.** Before anything reaches
|
|
11
|
+
* disk, the model is serialised, parsed back, and the two semantic hashes are
|
|
12
|
+
* compared. That catches the serialiser dropping or mangling something. It
|
|
13
|
+
* cannot catch the *parser* dropping something on the way in: content the
|
|
14
|
+
* parser never saw is absent from both sides and the hashes agree. Guarding
|
|
15
|
+
* that is the round-trip corpus gate's job (`tests/roundtrip-corpus.test.ts`),
|
|
16
|
+
* not this function's.
|
|
17
|
+
*
|
|
18
|
+
* There is deliberately no option to skip verification. Turning it off would
|
|
19
|
+
* only ever be used to get past the bug it exists to report; callers who want
|
|
20
|
+
* unchecked serialisation can still use `Bpmn.export()` and write it
|
|
21
|
+
* themselves.
|
|
22
|
+
*/
|
|
23
|
+
export interface WriteBpmnOptions {
|
|
24
|
+
/** Path to write to. */
|
|
25
|
+
output: string;
|
|
26
|
+
/**
|
|
27
|
+
* Replace `output` if it already exists. Default `false`, which refuses
|
|
28
|
+
* rather than overwrite.
|
|
29
|
+
*/
|
|
30
|
+
force?: boolean;
|
|
31
|
+
/**
|
|
32
|
+
* `"preserve"` (default) writes the diagram the model already carries.
|
|
33
|
+
* `"auto"` regenerates it first — the model is unchanged either way, which
|
|
34
|
+
* the verification step proves.
|
|
35
|
+
*/
|
|
36
|
+
layout?: "preserve" | "auto";
|
|
37
|
+
}
|
|
38
|
+
export interface WriteBpmnResult {
|
|
39
|
+
/** Absolute path written. */
|
|
40
|
+
destination: string;
|
|
41
|
+
/** Size of the written file in bytes. */
|
|
42
|
+
bytes: number;
|
|
43
|
+
/** SHA-256 of the exact bytes written. */
|
|
44
|
+
outputSha256: string;
|
|
45
|
+
/** Semantic hash of the model, as verified after reading it back. */
|
|
46
|
+
semanticHash: string;
|
|
47
|
+
/**
|
|
48
|
+
* What this write changed about the file that was already there, or
|
|
49
|
+
* `undefined` when the destination was newly created or the previous
|
|
50
|
+
* contents could not be parsed.
|
|
51
|
+
*/
|
|
52
|
+
changes?: SemanticDiff;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Serialises a model, verifies it survives a round trip, and writes it
|
|
56
|
+
* atomically.
|
|
57
|
+
*
|
|
58
|
+
* The file appears complete or not at all: the contents go to a temporary file
|
|
59
|
+
* in the destination's own directory and are then renamed into place, so an
|
|
60
|
+
* interrupted write cannot leave a half-written model behind. Without `force`
|
|
61
|
+
* the final step is a hard link, which fails if the destination appeared in the
|
|
62
|
+
* meantime rather than silently replacing it.
|
|
63
|
+
*
|
|
64
|
+
* @param definitions - The model to write.
|
|
65
|
+
* @param options - Destination and write behaviour.
|
|
66
|
+
* @returns Where it went, what it hashes to, and what it changed.
|
|
67
|
+
* @throws {WriteVerificationError} If reading the output back does not
|
|
68
|
+
* reproduce the model. Nothing is written.
|
|
69
|
+
* @throws {WriteError} If the destination exists and `force` was not given, or
|
|
70
|
+
* the filesystem refused the write.
|
|
71
|
+
*
|
|
72
|
+
* @example
|
|
73
|
+
* ```typescript
|
|
74
|
+
* import { writeBpmn } from "@bpmnkit/core/node"
|
|
75
|
+
*
|
|
76
|
+
* const result = await writeBpmn(definitions, { output: "flow.bpmn" })
|
|
77
|
+
* console.log(result.semanticHash, result.changes?.changed.length ?? 0)
|
|
78
|
+
* ```
|
|
79
|
+
*/
|
|
80
|
+
export declare function writeBpmn(definitions: BpmnDefinitions, options: WriteBpmnOptions): Promise<WriteBpmnResult>;
|
|
81
|
+
//# sourceMappingURL=write.d.ts.map
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { chmod, link, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, resolve } from "node:path";
|
|
4
|
+
import { applyAutoLayout } from "../bpmn/auto-layout.js";
|
|
5
|
+
import { parseBpmn } from "../bpmn/bpmn-parser.js";
|
|
6
|
+
import { serializeBpmn } from "../bpmn/bpmn-serializer.js";
|
|
7
|
+
import { diffSemantics, semanticHash } from "../bpmn/semantic-hash.js";
|
|
8
|
+
import { sha256Hex } from "../bpmn/sha256.js";
|
|
9
|
+
import { WriteError, WriteVerificationError } from "../errors.js";
|
|
10
|
+
async function pathExists(path) {
|
|
11
|
+
try {
|
|
12
|
+
await stat(path);
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
if (isErrnoCode(error, "ENOENT"))
|
|
17
|
+
return false;
|
|
18
|
+
throw error;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function isErrnoCode(error, code) {
|
|
22
|
+
return error instanceof Error && "code" in error && error.code === code;
|
|
23
|
+
}
|
|
24
|
+
/** Codes returned by filesystems that cannot make a hard link. */
|
|
25
|
+
const NO_HARD_LINKS = new Set(["ENOTSUP", "EOPNOTSUPP", "EPERM", "EXDEV", "EMLINK"]);
|
|
26
|
+
/**
|
|
27
|
+
* Serialises a model, verifies it survives a round trip, and writes it
|
|
28
|
+
* atomically.
|
|
29
|
+
*
|
|
30
|
+
* The file appears complete or not at all: the contents go to a temporary file
|
|
31
|
+
* in the destination's own directory and are then renamed into place, so an
|
|
32
|
+
* interrupted write cannot leave a half-written model behind. Without `force`
|
|
33
|
+
* the final step is a hard link, which fails if the destination appeared in the
|
|
34
|
+
* meantime rather than silently replacing it.
|
|
35
|
+
*
|
|
36
|
+
* @param definitions - The model to write.
|
|
37
|
+
* @param options - Destination and write behaviour.
|
|
38
|
+
* @returns Where it went, what it hashes to, and what it changed.
|
|
39
|
+
* @throws {WriteVerificationError} If reading the output back does not
|
|
40
|
+
* reproduce the model. Nothing is written.
|
|
41
|
+
* @throws {WriteError} If the destination exists and `force` was not given, or
|
|
42
|
+
* the filesystem refused the write.
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* ```typescript
|
|
46
|
+
* import { writeBpmn } from "@bpmnkit/core/node"
|
|
47
|
+
*
|
|
48
|
+
* const result = await writeBpmn(definitions, { output: "flow.bpmn" })
|
|
49
|
+
* console.log(result.semanticHash, result.changes?.changed.length ?? 0)
|
|
50
|
+
* ```
|
|
51
|
+
*/
|
|
52
|
+
export async function writeBpmn(definitions, options) {
|
|
53
|
+
const destination = resolve(options.output);
|
|
54
|
+
const force = options.force === true;
|
|
55
|
+
const exists = await pathExists(destination);
|
|
56
|
+
// Fail before doing the work, not after it.
|
|
57
|
+
if (exists && !force) {
|
|
58
|
+
throw new WriteError(`Refusing to overwrite ${options.output}. Pass force: true to replace it.`);
|
|
59
|
+
}
|
|
60
|
+
// applyAutoLayout returns a new model, so the caller's stays untouched.
|
|
61
|
+
const model = options.layout === "auto" ? applyAutoLayout(definitions) : definitions;
|
|
62
|
+
const expected = semanticHash(model);
|
|
63
|
+
const xml = serializeBpmn(model);
|
|
64
|
+
let reparsed;
|
|
65
|
+
try {
|
|
66
|
+
reparsed = parseBpmn(xml);
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
70
|
+
throw new WriteVerificationError(`Serialising the model produced BPMN that cannot be parsed back: ${reason}`, { added: [], removed: [], changed: [] });
|
|
71
|
+
}
|
|
72
|
+
const actual = semanticHash(reparsed);
|
|
73
|
+
if (actual !== expected) {
|
|
74
|
+
const changes = diffSemantics(model, reparsed);
|
|
75
|
+
throw new WriteVerificationError([
|
|
76
|
+
"Serialising the model did not reproduce it, so nothing was written.",
|
|
77
|
+
`Lost: ${changes.removed.join(", ") || "none"}.`,
|
|
78
|
+
`Added: ${changes.added.join(", ") || "none"}.`,
|
|
79
|
+
`Altered: ${changes.changed.map((entry) => entry.id).join(", ") || "none"}.`,
|
|
80
|
+
].join(" "), changes);
|
|
81
|
+
}
|
|
82
|
+
const changes = exists ? await changesAgainst(destination, reparsed) : undefined;
|
|
83
|
+
await writeAtomically(destination, xml, force);
|
|
84
|
+
return {
|
|
85
|
+
destination,
|
|
86
|
+
bytes: Buffer.byteLength(xml, "utf-8"),
|
|
87
|
+
outputSha256: sha256Hex(xml),
|
|
88
|
+
semanticHash: actual,
|
|
89
|
+
changes,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Diffs the model about to be written against the one already on disk. A
|
|
94
|
+
* previous file that cannot be read or parsed yields no report rather than
|
|
95
|
+
* failing the write — the old contents are being replaced either way.
|
|
96
|
+
*/
|
|
97
|
+
async function changesAgainst(destination, next) {
|
|
98
|
+
try {
|
|
99
|
+
return diffSemantics(parseBpmn(await readFile(destination, "utf-8")), next);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
async function writeAtomically(destination, contents, force) {
|
|
106
|
+
const temporary = resolve(dirname(destination), `.${basename(destination)}.${randomUUID()}.tmp`);
|
|
107
|
+
try {
|
|
108
|
+
await writeFile(temporary, contents, { encoding: "utf-8", flag: "wx" });
|
|
109
|
+
if (force) {
|
|
110
|
+
// Keep the permissions the file already had; a rename would otherwise
|
|
111
|
+
// hand it whatever the temporary file was created with.
|
|
112
|
+
const mode = await modeOf(destination);
|
|
113
|
+
if (mode !== undefined)
|
|
114
|
+
await chmod(temporary, mode);
|
|
115
|
+
await rename(temporary, destination);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
await linkOrCreateExclusively(temporary, destination, contents);
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
if (error instanceof WriteError)
|
|
122
|
+
throw error;
|
|
123
|
+
if (isErrnoCode(error, "EEXIST")) {
|
|
124
|
+
throw new WriteError(`Refusing to overwrite ${destination}: it appeared while writing. Pass force: true to replace it.`);
|
|
125
|
+
}
|
|
126
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
127
|
+
throw new WriteError(`Unable to write ${destination}: ${reason}`);
|
|
128
|
+
}
|
|
129
|
+
finally {
|
|
130
|
+
await rm(temporary, { force: true });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Creates the destination without replacing anything. `link` fails with EEXIST
|
|
135
|
+
* if the destination is taken, which `rename` would not, and it publishes the
|
|
136
|
+
* already-complete temporary file in one step.
|
|
137
|
+
*
|
|
138
|
+
* Filesystems without hard links fall back to an exclusive create, which is
|
|
139
|
+
* still safe against replacing an existing file but writes in place rather than
|
|
140
|
+
* atomically — an interrupted write there can leave a partial file.
|
|
141
|
+
*/
|
|
142
|
+
async function linkOrCreateExclusively(temporary, destination, contents) {
|
|
143
|
+
try {
|
|
144
|
+
await link(temporary, destination);
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
if (!isErrnoCode(error, "EEXIST") && isNoHardLinkSupport(error)) {
|
|
148
|
+
await writeFile(destination, contents, { encoding: "utf-8", flag: "wx" });
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
throw error;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
function isNoHardLinkSupport(error) {
|
|
155
|
+
return (error instanceof Error &&
|
|
156
|
+
"code" in error &&
|
|
157
|
+
NO_HARD_LINKS.has(error.code ?? ""));
|
|
158
|
+
}
|
|
159
|
+
async function modeOf(path) {
|
|
160
|
+
try {
|
|
161
|
+
return (await stat(path)).mode;
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
//# sourceMappingURL=write.js.map
|
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
const ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
|
2
2
|
const ID_SIZE = 8;
|
|
3
|
+
// Random bytes are drawn in batches: one getRandomValues call per id is
|
|
4
|
+
// dominated by the crypto call overhead, not by the bytes it returns.
|
|
5
|
+
const POOL_SIZE = 1024;
|
|
6
|
+
const pool = new Uint8Array(POOL_SIZE);
|
|
7
|
+
let poolOffset = POOL_SIZE;
|
|
3
8
|
function nanoId() {
|
|
4
|
-
|
|
5
|
-
|
|
9
|
+
if (poolOffset + ID_SIZE > POOL_SIZE) {
|
|
10
|
+
crypto.getRandomValues(pool);
|
|
11
|
+
poolOffset = 0;
|
|
12
|
+
}
|
|
6
13
|
let id = "";
|
|
7
14
|
for (let i = 0; i < ID_SIZE; i++) {
|
|
8
|
-
id += ALPHABET[
|
|
15
|
+
id += ALPHABET[pool[poolOffset + i] % ALPHABET.length];
|
|
9
16
|
}
|
|
17
|
+
poolOffset += ID_SIZE;
|
|
10
18
|
return id;
|
|
11
19
|
}
|
|
12
20
|
// Counter used only in deterministic test mode (activated by resetIdCounter())
|
package/dist/xml/index.d.ts
CHANGED
package/dist/xml/index.js
CHANGED
package/dist/xml/xml-parser.d.ts
CHANGED
|
@@ -1,4 +1,36 @@
|
|
|
1
1
|
import type { XmlElement } from "../types/xml-element.js";
|
|
2
|
+
/** What a sink wants the scanner to do with an element after its start tag. */
|
|
3
|
+
export declare const Visit: {
|
|
4
|
+
/** Report child elements and character data. */
|
|
5
|
+
readonly All: 0;
|
|
6
|
+
/**
|
|
7
|
+
* Skip the element's content: the scanner fast-forwards past the matching
|
|
8
|
+
* end tag without building attributes or decoding text for anything inside,
|
|
9
|
+
* and `end` is not called for it.
|
|
10
|
+
*/
|
|
11
|
+
readonly Skip: 1;
|
|
12
|
+
/** Report child elements but drop character data without decoding it. */
|
|
13
|
+
readonly ElementsOnly: 2;
|
|
14
|
+
};
|
|
15
|
+
export type Visit = (typeof Visit)[keyof typeof Visit];
|
|
16
|
+
/** Receives the events of one XML document from {@link scanXml}. */
|
|
17
|
+
export interface XmlSink {
|
|
18
|
+
/**
|
|
19
|
+
* @param name qualified name, e.g. "bpmn:task"
|
|
20
|
+
* @param local name without its prefix, e.g. "task" (same string as `name` when unprefixed)
|
|
21
|
+
* @param selfClosing true for `<a/>`; `end` follows immediately
|
|
22
|
+
*/
|
|
23
|
+
start(name: string, local: string, attributes: Record<string, string>, selfClosing: boolean): Visit;
|
|
24
|
+
/** Character data or CDATA directly inside the current element, entities decoded. */
|
|
25
|
+
text(text: string): void;
|
|
26
|
+
end(name: string): void;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Scan an XML document, reporting the root element and everything inside it to
|
|
30
|
+
* `sink`. Returns false when the document has no root element. Content after
|
|
31
|
+
* the root element is ignored.
|
|
32
|
+
*/
|
|
33
|
+
export declare function scanXml(xml: string, sink: XmlSink): boolean;
|
|
2
34
|
/**
|
|
3
35
|
* Parse an XML string into an XmlElement tree.
|
|
4
36
|
* Returns the root element with all namespace prefixes preserved.
|