@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
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
import { buildFlowElement, makeEventDef, } from "./compact.js";
|
|
2
|
+
import { bpmnElementName, ensureZeebeExtension, isZeebePlacementAllowed, } from "./zeebe-extensions.js";
|
|
3
|
+
/** Thrown by {@link applyBpmnOperations} in strict mode. */
|
|
4
|
+
export class OperationError extends Error {
|
|
5
|
+
problems;
|
|
6
|
+
constructor(problems) {
|
|
7
|
+
super(`${problems.length} operation(s) could not be applied: ${problems
|
|
8
|
+
.map((problem) => `[${problem.index}] ${problem.reason}`)
|
|
9
|
+
.join("; ")}`);
|
|
10
|
+
this.name = "OperationError";
|
|
11
|
+
this.problems = problems;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
function containersOf(definitions) {
|
|
15
|
+
const containers = [];
|
|
16
|
+
const visit = (container) => {
|
|
17
|
+
containers.push(container);
|
|
18
|
+
for (const element of container.flowElements) {
|
|
19
|
+
if ("flowElements" in element && "sequenceFlows" in element) {
|
|
20
|
+
visit(element);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
for (const process of definitions.processes)
|
|
25
|
+
visit(process);
|
|
26
|
+
return containers;
|
|
27
|
+
}
|
|
28
|
+
function findElement(definitions, id) {
|
|
29
|
+
for (const container of containersOf(definitions)) {
|
|
30
|
+
const element = container.flowElements.find((candidate) => candidate.id === id);
|
|
31
|
+
if (element)
|
|
32
|
+
return { container, element };
|
|
33
|
+
}
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
function findFlow(definitions, id) {
|
|
37
|
+
for (const container of containersOf(definitions)) {
|
|
38
|
+
const flow = container.sequenceFlows.find((candidate) => candidate.id === id);
|
|
39
|
+
if (flow)
|
|
40
|
+
return { container, flow };
|
|
41
|
+
}
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The container an operation should act in: a named process or sub-process, or
|
|
46
|
+
* the first process when none is named.
|
|
47
|
+
*/
|
|
48
|
+
function resolveContainer(definitions, parentId) {
|
|
49
|
+
if (parentId === undefined)
|
|
50
|
+
return definitions.processes[0];
|
|
51
|
+
const process = definitions.processes.find((candidate) => candidate.id === parentId);
|
|
52
|
+
if (process)
|
|
53
|
+
return process;
|
|
54
|
+
const found = findElement(definitions, parentId);
|
|
55
|
+
if (!found)
|
|
56
|
+
return undefined;
|
|
57
|
+
const element = found.element;
|
|
58
|
+
return "flowElements" in element && "sequenceFlows" in element
|
|
59
|
+
? element
|
|
60
|
+
: undefined;
|
|
61
|
+
}
|
|
62
|
+
function nextFlowId(definitions) {
|
|
63
|
+
const taken = new Set(containersOf(definitions).flatMap((container) => container.sequenceFlows.map((f) => f.id)));
|
|
64
|
+
let index = taken.size + 1;
|
|
65
|
+
while (taken.has(`flow_${index}`))
|
|
66
|
+
index++;
|
|
67
|
+
return `flow_${index}`;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Finds an extension element by name, creating it if it is not there yet.
|
|
71
|
+
*
|
|
72
|
+
* Zeebe extensions go through {@link ensureZeebeExtension}, which refuses a
|
|
73
|
+
* placement the schema forbids — writing `zeebe:calledDecision` onto a service
|
|
74
|
+
* task produces a file Camunda rejects at deploy time, and an operation that
|
|
75
|
+
* asks for it is a mistake worth reporting here rather than there.
|
|
76
|
+
*/
|
|
77
|
+
function ensureExtension(element, name) {
|
|
78
|
+
if (name.startsWith("zeebe:"))
|
|
79
|
+
return ensureZeebeExtension(element, name);
|
|
80
|
+
const existing = element.extensionElements.find((candidate) => candidate.name === name);
|
|
81
|
+
if (existing)
|
|
82
|
+
return existing;
|
|
83
|
+
const created = { name, attributes: {}, children: [] };
|
|
84
|
+
element.extensionElements.push(created);
|
|
85
|
+
return created;
|
|
86
|
+
}
|
|
87
|
+
/** The Zeebe extension each patch field writes to, for the placement check. */
|
|
88
|
+
const PATCH_EXTENSIONS = [
|
|
89
|
+
["jobType", "zeebe:taskDefinition"],
|
|
90
|
+
["formId", "zeebe:formDefinition"],
|
|
91
|
+
["calledProcess", "zeebe:calledElement"],
|
|
92
|
+
["decisionId", "zeebe:calledDecision"],
|
|
93
|
+
["taskHeaders", "zeebe:taskHeaders"],
|
|
94
|
+
];
|
|
95
|
+
/**
|
|
96
|
+
* Reports a patch field that would write an extension the Zeebe schema does not
|
|
97
|
+
* allow on this element — `decisionId` on a service task, say.
|
|
98
|
+
*
|
|
99
|
+
* Checked before anything is written so the operation stays atomic: it is
|
|
100
|
+
* reported like any other failed operation rather than thrown, which is what
|
|
101
|
+
* lets a non-strict caller apply the rest of the batch and show the user what
|
|
102
|
+
* was rejected.
|
|
103
|
+
*/
|
|
104
|
+
function misplacedExtensions(element, patch) {
|
|
105
|
+
const owner = bpmnElementName(element);
|
|
106
|
+
for (const [field, extension] of PATCH_EXTENSIONS) {
|
|
107
|
+
if (patch[field] === undefined)
|
|
108
|
+
continue;
|
|
109
|
+
if (isZeebePlacementAllowed(owner, extension))
|
|
110
|
+
continue;
|
|
111
|
+
return `${String(field)} writes <${extension}>, which the Zeebe schema does not allow on <${owner}>`;
|
|
112
|
+
}
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Sets one attribute on one extension element.
|
|
117
|
+
*
|
|
118
|
+
* Deliberately not a wholesale replacement: `zeebe:taskDefinition` also carries
|
|
119
|
+
* `retries`, `zeebe:calledElement` carries `propagateAllChildVariables`, and the
|
|
120
|
+
* compact form models none of them. Rebuilding the element from the compact
|
|
121
|
+
* fields would drop them, which is the loss this module exists to avoid.
|
|
122
|
+
*/
|
|
123
|
+
function setExtensionAttribute(element, extensionName, attribute, value) {
|
|
124
|
+
ensureExtension(element, extensionName).attributes[attribute] = value;
|
|
125
|
+
}
|
|
126
|
+
/** Replaces the `zeebe:taskHeaders` children, which are a map and move together. */
|
|
127
|
+
function setTaskHeaders(element, headers) {
|
|
128
|
+
const extension = ensureExtension(element, "zeebe:taskHeaders");
|
|
129
|
+
extension.children = Object.entries(headers).map(([key, value]) => ({
|
|
130
|
+
name: "zeebe:header",
|
|
131
|
+
attributes: { key, value },
|
|
132
|
+
children: [],
|
|
133
|
+
}));
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Points a service task's result at a variable, keeping the rest of its
|
|
137
|
+
* `zeebe:ioMapping`. On a business rule task the result variable lives on
|
|
138
|
+
* `zeebe:calledDecision` instead.
|
|
139
|
+
*/
|
|
140
|
+
function setResultVariable(element, resultVariable) {
|
|
141
|
+
const calledDecision = element.extensionElements.find((e) => e.name === "zeebe:calledDecision");
|
|
142
|
+
if (calledDecision) {
|
|
143
|
+
calledDecision.attributes.resultVariable = resultVariable;
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const mapping = ensureExtension(element, "zeebe:ioMapping");
|
|
147
|
+
const outputs = mapping.children.filter((child) => child.name === "zeebe:output");
|
|
148
|
+
// `compactify` reads the result variable from the single output of an
|
|
149
|
+
// ioMapping, whatever its source, so this has to write back to that same
|
|
150
|
+
// one. Matching on a particular source instead appends a second output and
|
|
151
|
+
// the mapping grows on every edit.
|
|
152
|
+
const single = outputs.length === 1 ? outputs[0] : undefined;
|
|
153
|
+
if (single) {
|
|
154
|
+
single.attributes.target = resultVariable;
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
// No output to update, or several with no way to tell which is primary:
|
|
158
|
+
// add one wired to the connector response, the shape `expand` produces.
|
|
159
|
+
mapping.children.push({
|
|
160
|
+
name: "zeebe:output",
|
|
161
|
+
attributes: { source: "= response", target: resultVariable },
|
|
162
|
+
children: [],
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Applies the compact fields a patch names onto an existing element.
|
|
167
|
+
*
|
|
168
|
+
* Only the named fields move, and each moves the smallest thing it can: one
|
|
169
|
+
* attribute rather than one extension element, so everything the compact form
|
|
170
|
+
* does not model stays exactly as it was.
|
|
171
|
+
*/
|
|
172
|
+
function patchElement(element, patch) {
|
|
173
|
+
if (patch.type !== undefined && patch.type !== element.type) {
|
|
174
|
+
return `changing type (${element.type} → ${patch.type}) needs a delete and an insert, so that what the new element should carry is explicit`;
|
|
175
|
+
}
|
|
176
|
+
const misplaced = misplacedExtensions(element, patch);
|
|
177
|
+
if (misplaced !== undefined)
|
|
178
|
+
return misplaced;
|
|
179
|
+
if (patch.name !== undefined)
|
|
180
|
+
element.name = patch.name;
|
|
181
|
+
if (patch.attachedTo !== undefined && "attachedToRef" in element) {
|
|
182
|
+
element.attachedToRef = patch.attachedTo;
|
|
183
|
+
}
|
|
184
|
+
if (patch.interrupting !== undefined && "cancelActivity" in element) {
|
|
185
|
+
element.cancelActivity = patch.interrupting;
|
|
186
|
+
}
|
|
187
|
+
if (patch.jobType !== undefined) {
|
|
188
|
+
setExtensionAttribute(element, "zeebe:taskDefinition", "type", patch.jobType);
|
|
189
|
+
}
|
|
190
|
+
if (patch.formId !== undefined) {
|
|
191
|
+
setExtensionAttribute(element, "zeebe:formDefinition", "formId", patch.formId);
|
|
192
|
+
}
|
|
193
|
+
if (patch.calledProcess !== undefined) {
|
|
194
|
+
setExtensionAttribute(element, "zeebe:calledElement", "processId", patch.calledProcess);
|
|
195
|
+
}
|
|
196
|
+
if (patch.decisionId !== undefined) {
|
|
197
|
+
setExtensionAttribute(element, "zeebe:calledDecision", "decisionId", patch.decisionId);
|
|
198
|
+
}
|
|
199
|
+
if (patch.resultVariable !== undefined)
|
|
200
|
+
setResultVariable(element, patch.resultVariable);
|
|
201
|
+
if (patch.taskHeaders !== undefined)
|
|
202
|
+
setTaskHeaders(element, patch.taskHeaders);
|
|
203
|
+
if (patch.eventType !== undefined && "eventDefinitions" in element) {
|
|
204
|
+
const current = element.eventDefinitions[0]?.type;
|
|
205
|
+
if (current !== patch.eventType) {
|
|
206
|
+
const replacement = makeEventDef(patch.eventType);
|
|
207
|
+
element.eventDefinitions = replacement ? [replacement] : [];
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return undefined;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Applies operations to a copy of the model.
|
|
214
|
+
*
|
|
215
|
+
* @param definitions - The model to edit. Never mutated.
|
|
216
|
+
* @param operations - Operations to apply, in order.
|
|
217
|
+
* @param options - `strict` (default `true`) throws instead of returning problems.
|
|
218
|
+
* @returns The edited model, how many operations landed, and what did not.
|
|
219
|
+
* @throws {OperationError} In strict mode, when any operation fails. Nothing is
|
|
220
|
+
* applied — the caller's model and the returned one are both untouched.
|
|
221
|
+
*
|
|
222
|
+
* @example
|
|
223
|
+
* ```typescript
|
|
224
|
+
* import { applyBpmnOperations } from "@bpmnkit/core"
|
|
225
|
+
*
|
|
226
|
+
* const { definitions } = applyBpmnOperations(parsed, [
|
|
227
|
+
* { op: "rename", id: "Task_1", name: "Approve invoice" },
|
|
228
|
+
* ])
|
|
229
|
+
* ```
|
|
230
|
+
*/
|
|
231
|
+
export function applyBpmnOperations(definitions, operations, options = {}) {
|
|
232
|
+
const draft = structuredClone(definitions);
|
|
233
|
+
const problems = [];
|
|
234
|
+
let applied = 0;
|
|
235
|
+
operations.forEach((operation, index) => {
|
|
236
|
+
const reason = applyOne(draft, operation);
|
|
237
|
+
if (reason === undefined)
|
|
238
|
+
applied++;
|
|
239
|
+
else
|
|
240
|
+
problems.push({ index, operation, reason });
|
|
241
|
+
});
|
|
242
|
+
if ((options.strict ?? true) && problems.length > 0) {
|
|
243
|
+
throw new OperationError(problems);
|
|
244
|
+
}
|
|
245
|
+
return { definitions: draft, applied, problems };
|
|
246
|
+
}
|
|
247
|
+
function applyOne(definitions, operation) {
|
|
248
|
+
switch (operation.op) {
|
|
249
|
+
case "rename": {
|
|
250
|
+
const found = findElement(definitions, operation.id);
|
|
251
|
+
if (!found)
|
|
252
|
+
return `no element with id "${operation.id}"`;
|
|
253
|
+
found.element.name = operation.name;
|
|
254
|
+
return undefined;
|
|
255
|
+
}
|
|
256
|
+
case "update": {
|
|
257
|
+
const found = findElement(definitions, operation.id);
|
|
258
|
+
if (!found)
|
|
259
|
+
return `no element with id "${operation.id}"`;
|
|
260
|
+
return patchElement(found.element, operation.patch);
|
|
261
|
+
}
|
|
262
|
+
case "delete": {
|
|
263
|
+
const found = findElement(definitions, operation.id);
|
|
264
|
+
if (!found)
|
|
265
|
+
return `no element with id "${operation.id}"`;
|
|
266
|
+
found.container.flowElements = found.container.flowElements.filter((element) => element.id !== operation.id);
|
|
267
|
+
// A flow to or from a removed element would dangle.
|
|
268
|
+
found.container.sequenceFlows = found.container.sequenceFlows.filter((flow) => flow.sourceRef !== operation.id && flow.targetRef !== operation.id);
|
|
269
|
+
recomputeRefs(found.container);
|
|
270
|
+
return undefined;
|
|
271
|
+
}
|
|
272
|
+
case "insert": {
|
|
273
|
+
const container = resolveContainer(definitions, operation.parent);
|
|
274
|
+
if (!container) {
|
|
275
|
+
return operation.parent === undefined
|
|
276
|
+
? "the document has no process to insert into"
|
|
277
|
+
: `no container with id "${operation.parent}"`;
|
|
278
|
+
}
|
|
279
|
+
if (findElement(definitions, operation.element.id)) {
|
|
280
|
+
return `id "${operation.element.id}" is already taken`;
|
|
281
|
+
}
|
|
282
|
+
const element = buildFlowElement(operation.element, [], []);
|
|
283
|
+
const anchor = operation.after ?? operation.before;
|
|
284
|
+
if (anchor !== undefined) {
|
|
285
|
+
const at = container.flowElements.findIndex((candidate) => candidate.id === anchor);
|
|
286
|
+
if (at === -1)
|
|
287
|
+
return `no element with id "${anchor}" to insert next to`;
|
|
288
|
+
container.flowElements.splice(operation.after !== undefined ? at + 1 : at, 0, element);
|
|
289
|
+
}
|
|
290
|
+
else {
|
|
291
|
+
container.flowElements.push(element);
|
|
292
|
+
}
|
|
293
|
+
return undefined;
|
|
294
|
+
}
|
|
295
|
+
case "add_flow": {
|
|
296
|
+
const container = resolveContainer(definitions, operation.parent);
|
|
297
|
+
if (!container) {
|
|
298
|
+
return operation.parent === undefined
|
|
299
|
+
? "the document has no process to add a flow to"
|
|
300
|
+
: `no container with id "${operation.parent}"`;
|
|
301
|
+
}
|
|
302
|
+
if (!findElement(definitions, operation.from))
|
|
303
|
+
return `no element with id "${operation.from}"`;
|
|
304
|
+
if (!findElement(definitions, operation.to))
|
|
305
|
+
return `no element with id "${operation.to}"`;
|
|
306
|
+
const id = operation.id ?? nextFlowId(definitions);
|
|
307
|
+
if (findFlow(definitions, id))
|
|
308
|
+
return `flow id "${id}" is already taken`;
|
|
309
|
+
const flow = {
|
|
310
|
+
id,
|
|
311
|
+
sourceRef: operation.from,
|
|
312
|
+
targetRef: operation.to,
|
|
313
|
+
extensionElements: [],
|
|
314
|
+
unknownAttributes: {},
|
|
315
|
+
};
|
|
316
|
+
if (operation.name)
|
|
317
|
+
flow.name = operation.name;
|
|
318
|
+
if (operation.condition) {
|
|
319
|
+
flow.conditionExpression = { text: operation.condition, attributes: {} };
|
|
320
|
+
}
|
|
321
|
+
container.sequenceFlows.push(flow);
|
|
322
|
+
recomputeRefs(container);
|
|
323
|
+
return undefined;
|
|
324
|
+
}
|
|
325
|
+
case "delete_flow": {
|
|
326
|
+
const found = findFlow(definitions, operation.id);
|
|
327
|
+
if (!found)
|
|
328
|
+
return `no sequence flow with id "${operation.id}"`;
|
|
329
|
+
found.container.sequenceFlows = found.container.sequenceFlows.filter((flow) => flow.id !== operation.id);
|
|
330
|
+
recomputeRefs(found.container);
|
|
331
|
+
return undefined;
|
|
332
|
+
}
|
|
333
|
+
case "redirect_flow": {
|
|
334
|
+
const found = findFlow(definitions, operation.id);
|
|
335
|
+
if (!found)
|
|
336
|
+
return `no sequence flow with id "${operation.id}"`;
|
|
337
|
+
if (operation.from !== undefined) {
|
|
338
|
+
if (!findElement(definitions, operation.from)) {
|
|
339
|
+
return `no element with id "${operation.from}"`;
|
|
340
|
+
}
|
|
341
|
+
found.flow.sourceRef = operation.from;
|
|
342
|
+
}
|
|
343
|
+
if (operation.to !== undefined) {
|
|
344
|
+
if (!findElement(definitions, operation.to))
|
|
345
|
+
return `no element with id "${operation.to}"`;
|
|
346
|
+
found.flow.targetRef = operation.to;
|
|
347
|
+
}
|
|
348
|
+
recomputeRefs(found.container);
|
|
349
|
+
return undefined;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
/** Rebuilds every element's `incoming`/`outgoing` from the container's flows. */
|
|
354
|
+
function recomputeRefs(container) {
|
|
355
|
+
for (const element of container.flowElements) {
|
|
356
|
+
element.incoming = [];
|
|
357
|
+
element.outgoing = [];
|
|
358
|
+
}
|
|
359
|
+
const byId = new Map(container.flowElements.map((element) => [element.id, element]));
|
|
360
|
+
for (const flow of container.sequenceFlows) {
|
|
361
|
+
byId.get(flow.sourceRef)?.outgoing.push(flow.id);
|
|
362
|
+
byId.get(flow.targetRef)?.incoming.push(flow.id);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Applies a {@link CompactDiagram} onto an existing model as a set of changes,
|
|
367
|
+
* rather than expanding it into a replacement.
|
|
368
|
+
*
|
|
369
|
+
* `expand(compact)` builds a whole new model from about fifteen properties per
|
|
370
|
+
* element, so using it to apply an edit destroys everything the compact form
|
|
371
|
+
* does not carry. This walks the same input as a description of the intended
|
|
372
|
+
* topology and turns it into {@link BpmnOperation}s: elements that already exist
|
|
373
|
+
* are patched in place and keep their extensions, new ones are inserted, and
|
|
374
|
+
* ones the input no longer mentions are removed.
|
|
375
|
+
*
|
|
376
|
+
* Processes are only ever added, never removed — a caller sending one process of
|
|
377
|
+
* a multi-process document means "this is how that process should look", not
|
|
378
|
+
* "delete the others".
|
|
379
|
+
*
|
|
380
|
+
* @param definitions - The model to update. Never mutated.
|
|
381
|
+
* @param compact - The intended topology.
|
|
382
|
+
* @param options - `strict` (default `true`) throws instead of returning problems.
|
|
383
|
+
* @returns The updated model, plus what applied and what did not.
|
|
384
|
+
*/
|
|
385
|
+
export function reconcileCompact(definitions, compact, options = {}) {
|
|
386
|
+
const draft = structuredClone(definitions);
|
|
387
|
+
for (const compactProcess of compact.processes) {
|
|
388
|
+
if (!draft.processes.some((process) => process.id === compactProcess.id)) {
|
|
389
|
+
draft.processes.push({
|
|
390
|
+
id: compactProcess.id,
|
|
391
|
+
name: compactProcess.name,
|
|
392
|
+
extensionElements: [],
|
|
393
|
+
flowElements: [],
|
|
394
|
+
sequenceFlows: [],
|
|
395
|
+
textAnnotations: [],
|
|
396
|
+
associations: [],
|
|
397
|
+
groups: [],
|
|
398
|
+
unknownAttributes: {},
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return applyBpmnOperations(draft, operationsForCompact(draft, compact), options);
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Derives the operations that would bring `definitions` in line with `compact`.
|
|
406
|
+
*
|
|
407
|
+
* Order matters. Removing an element also removes the flows attached to it, so
|
|
408
|
+
* flows are settled first and any flow the removal would take with it is
|
|
409
|
+
* re-added afterwards rather than redirected onto something that no longer
|
|
410
|
+
* exists.
|
|
411
|
+
*
|
|
412
|
+
* A flow whose endpoints move is redirected, keeping whatever else it carries.
|
|
413
|
+
* One whose name or condition changes is replaced, which does not preserve
|
|
414
|
+
* extensions on the flow itself — rare enough to be worth the simpler rule.
|
|
415
|
+
*/
|
|
416
|
+
function operationsForCompact(definitions, compact) {
|
|
417
|
+
const operations = [];
|
|
418
|
+
for (const compactProcess of compact.processes) {
|
|
419
|
+
const process = definitions.processes.find((candidate) => candidate.id === compactProcess.id);
|
|
420
|
+
if (!process)
|
|
421
|
+
continue;
|
|
422
|
+
const wanted = new Map(compactProcess.elements.map((element) => [element.id, element]));
|
|
423
|
+
const wantedFlows = new Map(compactProcess.flows.map((flow) => [flow.id, flow]));
|
|
424
|
+
const present = process.flowElements.map((element) => element.id);
|
|
425
|
+
const removedElements = present.filter((id) => !wanted.has(id));
|
|
426
|
+
const removed = new Set(removedElements);
|
|
427
|
+
const replacedFlows = new Set();
|
|
428
|
+
for (const flow of process.sequenceFlows) {
|
|
429
|
+
const want = wantedFlows.get(flow.id);
|
|
430
|
+
const attachedToRemoved = removed.has(flow.sourceRef) || removed.has(flow.targetRef);
|
|
431
|
+
if (want === undefined || attachedToRemoved) {
|
|
432
|
+
operations.push({ op: "delete_flow", id: flow.id });
|
|
433
|
+
if (want !== undefined)
|
|
434
|
+
replacedFlows.add(flow.id);
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
const sameLabel = (want.name ?? undefined) === (flow.name ?? undefined);
|
|
438
|
+
const sameCondition = (want.condition ?? undefined) === (flow.conditionExpression?.text ?? undefined);
|
|
439
|
+
if (!sameLabel || !sameCondition) {
|
|
440
|
+
operations.push({ op: "delete_flow", id: flow.id });
|
|
441
|
+
replacedFlows.add(flow.id);
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
if (want.from !== flow.sourceRef || want.to !== flow.targetRef) {
|
|
445
|
+
operations.push({ op: "redirect_flow", id: flow.id, from: want.from, to: want.to });
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
for (const id of removedElements)
|
|
449
|
+
operations.push({ op: "delete", id });
|
|
450
|
+
for (const [id, element] of wanted) {
|
|
451
|
+
if (removed.has(id))
|
|
452
|
+
continue;
|
|
453
|
+
if (present.includes(id)) {
|
|
454
|
+
const { id: _id, children: _children, ...patch } = element;
|
|
455
|
+
operations.push({ op: "update", id, patch });
|
|
456
|
+
}
|
|
457
|
+
else {
|
|
458
|
+
operations.push({ op: "insert", element, parent: process.id });
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
const existingFlowIds = new Set(process.sequenceFlows.map((flow) => flow.id));
|
|
462
|
+
for (const [id, flow] of wantedFlows) {
|
|
463
|
+
if (existingFlowIds.has(id) && !replacedFlows.has(id))
|
|
464
|
+
continue;
|
|
465
|
+
operations.push({
|
|
466
|
+
op: "add_flow",
|
|
467
|
+
id,
|
|
468
|
+
parent: process.id,
|
|
469
|
+
from: flow.from,
|
|
470
|
+
to: flow.to,
|
|
471
|
+
name: flow.name,
|
|
472
|
+
condition: flow.condition,
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
return operations;
|
|
477
|
+
}
|
|
478
|
+
//# sourceMappingURL=full-operations.js.map
|
package/dist/bpmn/index.d.ts
CHANGED
|
@@ -41,6 +41,25 @@ export declare const Bpmn: {
|
|
|
41
41
|
* ```
|
|
42
42
|
*/
|
|
43
43
|
readonly createProcess: (processId: string) => ProcessBuilder;
|
|
44
|
+
/**
|
|
45
|
+
* Continue an existing model instead of generating a replacement for it.
|
|
46
|
+
*
|
|
47
|
+
* `build()` returns that document with the named process's contents replaced,
|
|
48
|
+
* so other processes, the collaboration, lanes, diagram interchange, root
|
|
49
|
+
* elements and unmodelled content all survive. The input is not mutated.
|
|
50
|
+
*
|
|
51
|
+
* @param definitions - The parsed model to continue.
|
|
52
|
+
* @param processId - Which process to continue, named explicitly.
|
|
53
|
+
*
|
|
54
|
+
* @example
|
|
55
|
+
* ```typescript
|
|
56
|
+
* const updated = Bpmn.continueProcess(Bpmn.parse(xml), "order-process")
|
|
57
|
+
* .at("validate")
|
|
58
|
+
* .serviceTask("notify", { name: "Notify", taskType: "notify" })
|
|
59
|
+
* .build()
|
|
60
|
+
* ```
|
|
61
|
+
*/
|
|
62
|
+
readonly continueProcess: (definitions: BpmnDefinitions, processId: string) => ProcessBuilder;
|
|
44
63
|
/**
|
|
45
64
|
* Create a multi-process BPMN definitions document using the fluent builder API.
|
|
46
65
|
*
|
package/dist/bpmn/index.js
CHANGED
|
@@ -86,6 +86,27 @@ export const Bpmn = {
|
|
|
86
86
|
createProcess(processId) {
|
|
87
87
|
return new ProcessBuilder(processId);
|
|
88
88
|
},
|
|
89
|
+
/**
|
|
90
|
+
* Continue an existing model instead of generating a replacement for it.
|
|
91
|
+
*
|
|
92
|
+
* `build()` returns that document with the named process's contents replaced,
|
|
93
|
+
* so other processes, the collaboration, lanes, diagram interchange, root
|
|
94
|
+
* elements and unmodelled content all survive. The input is not mutated.
|
|
95
|
+
*
|
|
96
|
+
* @param definitions - The parsed model to continue.
|
|
97
|
+
* @param processId - Which process to continue, named explicitly.
|
|
98
|
+
*
|
|
99
|
+
* @example
|
|
100
|
+
* ```typescript
|
|
101
|
+
* const updated = Bpmn.continueProcess(Bpmn.parse(xml), "order-process")
|
|
102
|
+
* .at("validate")
|
|
103
|
+
* .serviceTask("notify", { name: "Notify", taskType: "notify" })
|
|
104
|
+
* .build()
|
|
105
|
+
* ```
|
|
106
|
+
*/
|
|
107
|
+
continueProcess(definitions, processId) {
|
|
108
|
+
return ProcessBuilder.from(definitions, processId);
|
|
109
|
+
},
|
|
89
110
|
/**
|
|
90
111
|
* Create a multi-process BPMN definitions document using the fluent builder API.
|
|
91
112
|
*
|
|
@@ -58,7 +58,7 @@ function scoreFeelExpression(expr, opts) {
|
|
|
58
58
|
// ---------------------------------------------------------------------------
|
|
59
59
|
export function analyzeFeel(p, opts) {
|
|
60
60
|
const findings = [];
|
|
61
|
-
const { bySource } = buildFlowIndex(p);
|
|
61
|
+
const { byId, bySource } = buildFlowIndex(p);
|
|
62
62
|
const processId = p.id;
|
|
63
63
|
// Track FEEL expressions across all sequence flows for duplicate detection
|
|
64
64
|
const exprToFlowIds = new Map();
|
|
@@ -66,7 +66,7 @@ export function analyzeFeel(p, opts) {
|
|
|
66
66
|
// Determine if source is an exclusive/inclusive gateway with more than one
|
|
67
67
|
// outgoing flow — a join (single outgoing flow) makes no decision, so its
|
|
68
68
|
// one outgoing flow needs neither a condition nor a default marker.
|
|
69
|
-
const srcEl =
|
|
69
|
+
const srcEl = byId.get(flow.sourceRef);
|
|
70
70
|
const srcIsDecisionGateway = srcEl !== undefined &&
|
|
71
71
|
(srcEl.type === "exclusiveGateway" || srcEl.type === "inclusiveGateway") &&
|
|
72
72
|
(bySource.get(srcEl.id)?.length ?? 0) > 1;
|
|
@@ -2,19 +2,25 @@ import { buildFlowIndex, readZeebeIoMapping, readZeebeTaskType } from "./utils.j
|
|
|
2
2
|
// ---------------------------------------------------------------------------
|
|
3
3
|
// Helpers
|
|
4
4
|
// ---------------------------------------------------------------------------
|
|
5
|
-
/**
|
|
6
|
-
function
|
|
5
|
+
/** hostId → event-definition types of the boundary events attached to it. */
|
|
6
|
+
function indexBoundaryTypes(p) {
|
|
7
|
+
const index = new Map();
|
|
7
8
|
for (const el of p.flowElements) {
|
|
8
9
|
if (el.type !== "boundaryEvent")
|
|
9
10
|
continue;
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
return true;
|
|
11
|
+
let types = index.get(el.attachedToRef);
|
|
12
|
+
if (!types) {
|
|
13
|
+
types = new Set();
|
|
14
|
+
index.set(el.attachedToRef, types);
|
|
15
15
|
}
|
|
16
|
+
for (const def of el.eventDefinitions)
|
|
17
|
+
types.add(def.type);
|
|
16
18
|
}
|
|
17
|
-
return
|
|
19
|
+
return index;
|
|
20
|
+
}
|
|
21
|
+
/** Returns true if the element has a boundary event of the given type attached. */
|
|
22
|
+
function hasBoundaryOf(elementId, eventType, boundaryTypes) {
|
|
23
|
+
return boundaryTypes.get(elementId)?.has(eventType) ?? false;
|
|
18
24
|
}
|
|
19
25
|
/** Returns true if the condition expression text appears to contain only literals (no variable names). */
|
|
20
26
|
function isLiteralOnlyCondition(text) {
|
|
@@ -36,7 +42,8 @@ function isLiteralOnlyCondition(text) {
|
|
|
36
42
|
export function analyzePatterns(p) {
|
|
37
43
|
const findings = [];
|
|
38
44
|
const processId = p.id;
|
|
39
|
-
const { bySource, byTarget } = buildFlowIndex(p);
|
|
45
|
+
const { byId, bySource, byTarget } = buildFlowIndex(p);
|
|
46
|
+
const boundaryTypes = indexBoundaryTypes(p);
|
|
40
47
|
// ── Rule 1: HTTP/REST service task without error boundary ───────────────
|
|
41
48
|
for (const el of p.flowElements) {
|
|
42
49
|
if (el.type !== "serviceTask")
|
|
@@ -47,7 +54,7 @@ export function analyzePatterns(p) {
|
|
|
47
54
|
jobType === "io.camunda.connector.HttpJson:1";
|
|
48
55
|
if (!isHttp)
|
|
49
56
|
continue;
|
|
50
|
-
if (!hasBoundaryOf(el.id, "error",
|
|
57
|
+
if (!hasBoundaryOf(el.id, "error", boundaryTypes)) {
|
|
51
58
|
findings.push({
|
|
52
59
|
id: "pattern/http-no-error-boundary",
|
|
53
60
|
category: "pattern",
|
|
@@ -82,7 +89,7 @@ export function analyzePatterns(p) {
|
|
|
82
89
|
for (const el of p.flowElements) {
|
|
83
90
|
if (el.type !== "subProcess" && el.type !== "adHocSubProcess" && el.type !== "transaction")
|
|
84
91
|
continue;
|
|
85
|
-
if (!hasBoundaryOf(el.id, "error",
|
|
92
|
+
if (!hasBoundaryOf(el.id, "error", boundaryTypes)) {
|
|
86
93
|
findings.push({
|
|
87
94
|
id: "pattern/subprocess-no-error-boundary",
|
|
88
95
|
category: "pattern",
|
|
@@ -98,7 +105,7 @@ export function analyzePatterns(p) {
|
|
|
98
105
|
for (const el of p.flowElements) {
|
|
99
106
|
if (el.type !== "callActivity")
|
|
100
107
|
continue;
|
|
101
|
-
if (!hasBoundaryOf(el.id, "error",
|
|
108
|
+
if (!hasBoundaryOf(el.id, "error", boundaryTypes)) {
|
|
102
109
|
findings.push({
|
|
103
110
|
id: "pattern/call-activity-no-error-boundary",
|
|
104
111
|
category: "pattern",
|
|
@@ -121,7 +128,7 @@ export function analyzePatterns(p) {
|
|
|
121
128
|
const branchTargets = [];
|
|
122
129
|
for (const flow of outflows) {
|
|
123
130
|
const targets = [];
|
|
124
|
-
const branchEl =
|
|
131
|
+
const branchEl = byId.get(flow.targetRef);
|
|
125
132
|
if (branchEl !== undefined) {
|
|
126
133
|
const io = readZeebeIoMapping(branchEl.extensionElements);
|
|
127
134
|
if (io !== null) {
|
|
@@ -158,7 +165,7 @@ export function analyzePatterns(p) {
|
|
|
158
165
|
for (const el of p.flowElements) {
|
|
159
166
|
if (el.type !== "userTask")
|
|
160
167
|
continue;
|
|
161
|
-
if (!hasBoundaryOf(el.id, "timer",
|
|
168
|
+
if (!hasBoundaryOf(el.id, "timer", boundaryTypes)) {
|
|
162
169
|
findings.push({
|
|
163
170
|
id: "pattern/user-task-no-timer",
|
|
164
171
|
category: "pattern",
|
|
@@ -200,7 +207,7 @@ export function analyzePatterns(p) {
|
|
|
200
207
|
continue;
|
|
201
208
|
const outflows = bySource.get(el.id) ?? [];
|
|
202
209
|
for (const flow of outflows) {
|
|
203
|
-
const target =
|
|
210
|
+
const target = byId.get(flow.targetRef);
|
|
204
211
|
if (target !== undefined && target.type === "endEvent") {
|
|
205
212
|
findings.push({
|
|
206
213
|
id: "pattern/catch-and-swallow",
|
|
@@ -348,7 +355,7 @@ export function analyzePatterns(p) {
|
|
|
348
355
|
if (cond === undefined || cond === "")
|
|
349
356
|
continue;
|
|
350
357
|
if (isLiteralOnlyCondition(cond)) {
|
|
351
|
-
const sourceEl =
|
|
358
|
+
const sourceEl = byId.get(flow.sourceRef);
|
|
352
359
|
findings.push({
|
|
353
360
|
id: "pattern/literal-condition",
|
|
354
361
|
category: "pattern",
|