@0dep/bpmn-extensions 0.0.1

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.
@@ -0,0 +1,3 @@
1
+ {
2
+ "type": "commonjs"
3
+ }
package/package.json ADDED
@@ -0,0 +1,91 @@
1
+ {
2
+ "name": "@0dep/bpmn-extensions",
3
+ "version": "0.0.1",
4
+ "description": "Flow extensions for bpmn-elements: FEEL expressions and the zeebe-namespace BPMN extension elements",
5
+ "type": "module",
6
+ "module": "src/index.js",
7
+ "main": "dist/index.js",
8
+ "types": "./types/index.d.ts",
9
+ "sideEffects": false,
10
+ "exports": {
11
+ ".": {
12
+ "types": "./types/index.d.ts",
13
+ "require": "./dist/index.js",
14
+ "import": "./src/index.js"
15
+ }
16
+ },
17
+ "engines": {
18
+ "node": ">=22"
19
+ },
20
+ "scripts": {
21
+ "test": "mocha",
22
+ "test:lcov": "c8 -r lcov mocha && npm run lint",
23
+ "test:md": "texample",
24
+ "cov:html": "c8 mocha && c8 report --reporter html",
25
+ "posttest": "npm run lint && npm run test:md && npm run prepack",
26
+ "lint": "eslint . --cache && prettier . --check --cache",
27
+ "format": "eslint . --fix && prettier . --write",
28
+ "dist": "rollup -c",
29
+ "types": "dts-buddy types/index.d.ts -m @0dep/bpmn-extensions:src/index.js",
30
+ "prepack": "npm run dist && npm run types"
31
+ },
32
+ "keywords": [
33
+ "bpmn",
34
+ "bpmn-elements",
35
+ "camunda",
36
+ "camunda8",
37
+ "zeebe",
38
+ "feel",
39
+ "extensions"
40
+ ],
41
+ "repository": {
42
+ "type": "git",
43
+ "url": "git+https://github.com/zerodep/bpmn-extensions.git"
44
+ },
45
+ "author": {
46
+ "name": "Pål Edman",
47
+ "url": "https://github.com/paed01"
48
+ },
49
+ "license": "MIT",
50
+ "publishConfig": {
51
+ "access": "public"
52
+ },
53
+ "peerDependencies": {
54
+ "bpmn-elements": ">=18.0.4"
55
+ },
56
+ "dependencies": {
57
+ "feelin": "^7.0.1"
58
+ },
59
+ "devDependencies": {
60
+ "@eslint/js": "^10.0.1",
61
+ "@rollup/plugin-commonjs": "^29.0.3",
62
+ "@rollup/plugin-node-resolve": "^16.0.3",
63
+ "bpmn-elements": "^18.0.4",
64
+ "bpmn-moddle": "^9.0.1",
65
+ "c8": "^11.0.0",
66
+ "chai": "^6.2.2",
67
+ "debug": "^4.4.3",
68
+ "dts-buddy": "^0.8.2",
69
+ "eslint": "^10.6.0",
70
+ "globals": "^17.7.0",
71
+ "mocha": "^11.7.6",
72
+ "mocha-cakes-2": "^3.3.0",
73
+ "moddle-context-serializer": "^6.0.1",
74
+ "prettier": "^3.9.1",
75
+ "rollup": "^4.62.2",
76
+ "texample": "^1.0.2",
77
+ "typescript": "^6.0.3",
78
+ "zeebe-bpmn-moddle": "^1.15.0"
79
+ },
80
+ "files": [
81
+ "src",
82
+ "dist",
83
+ "types"
84
+ ],
85
+ "c8": {
86
+ "exclude": [
87
+ "dist",
88
+ "test"
89
+ ]
90
+ }
91
+ }
@@ -0,0 +1,142 @@
1
+ import { getExtensions } from './getExtensions.js';
2
+
3
+ const SUBPROCESS_TYPES = new Set(['bpmn:SubProcess', 'bpmn:AdHocSubProcess', 'bpmn:Transaction']);
4
+
5
+ /**
6
+ * Activity-level extensions.
7
+ *
8
+ * Formatting and io/listener side effects are injected through the activity's `format-run-q`
9
+ * queue so the activity waits for them (including async execution listeners) before
10
+ * proceeding — the same mechanism bpmn-elements uses for extension formatting.
11
+ */
12
+ export class ElementExtensions {
13
+ constructor(activity) {
14
+ this.activity = activity;
15
+ this.formatQ = activity.broker.getQueue('format-run-q');
16
+
17
+ this.extensions = getExtensions(activity);
18
+ const Service = this.extensions.Service;
19
+ if (Service) activity.behaviour.Service = Service;
20
+ }
21
+ activate(message) {
22
+ const activity = this.activity;
23
+ const listeners = this.extensions.listeners;
24
+
25
+ if (message.fields.redelivered && message.fields.routingKey === 'run.start') {
26
+ activity.on('start', (api) => this._onEnter(api), { consumerTag: '0dep-bpmn-extensions:on-enter' });
27
+ } else {
28
+ activity.on('enter', (api) => this._onEnter(api), { consumerTag: '0dep-bpmn-extensions:on-enter' });
29
+ }
30
+
31
+ activity.on('activity.execution.completed', (api) => this._onExecuted(api), {
32
+ consumerTag: '0dep-bpmn-extensions:on-executed',
33
+ });
34
+
35
+ if (listeners?.onStart) {
36
+ activity.on('start', (api) => this._onListener('start', api), { consumerTag: '0dep-bpmn-extensions:on-start' });
37
+ }
38
+ if (listeners?.onEnd) {
39
+ activity.on('end', (api) => this._onListener('end', api), { consumerTag: '0dep-bpmn-extensions:on-end' });
40
+ }
41
+ }
42
+ deactivate() {
43
+ const broker = this.activity.broker;
44
+ broker.cancel('0dep-bpmn-extensions:on-enter');
45
+ broker.cancel('0dep-bpmn-extensions:on-executed');
46
+ broker.cancel('0dep-bpmn-extensions:on-start');
47
+ broker.cancel('0dep-bpmn-extensions:on-end');
48
+ }
49
+ /** @internal Shared with SubProcessExtensions. */
50
+ async _onEnter(elementApi) {
51
+ this.formatQ.queueMessage({ routingKey: 'run.enter.format' }, { endRoutingKey: 'run.enter.complete' }, { persistent: false });
52
+ try {
53
+ const format = await this.#formatOnEnter(elementApi);
54
+ elementApi.broker.publish('format', 'run.enter.complete', format, { persistent: false });
55
+ } catch (err) {
56
+ elementApi.broker.publish('format', 'run.enter.error', { error: err }, { persistent: false });
57
+ }
58
+ }
59
+ /** @internal Shared with SubProcessExtensions. */
60
+ async _onExecuted(elementApi) {
61
+ this.formatQ.queueMessage({ routingKey: 'run.end.format' }, { endRoutingKey: 'run.end.complete' }, { persistent: false });
62
+ try {
63
+ const format = await this.#formatOnExecuted(elementApi);
64
+ elementApi.broker.publish('format', 'run.end.complete', { ...format }, { persistent: false });
65
+ } catch (err) {
66
+ elementApi.broker.publish('format', 'run.end.error', { error: err }, { persistent: false });
67
+ }
68
+ }
69
+ /** @internal Shared with SubProcessExtensions. */
70
+ async _onListener(eventType, elementApi) {
71
+ const routingKey = `run.listener.${eventType}`;
72
+ this.formatQ.queueMessage({ routingKey }, { endRoutingKey: `${routingKey}.complete` }, { persistent: false });
73
+ try {
74
+ await this.extensions.listeners.execute(eventType, elementApi);
75
+ elementApi.broker.publish('format', `${routingKey}.complete`, {}, { persistent: false });
76
+ } catch (err) {
77
+ this.activity.logger.error(`<${this.activity.id}> ${eventType} execution listener error`, err);
78
+ elementApi.broker.publish('format', `${routingKey}.error`, { error: err }, { persistent: false });
79
+ }
80
+ }
81
+ #formatOnEnter(elementApi) {
82
+ const { format, io, headers, properties, form } = this.extensions;
83
+ const result = { ...format.resolve(elementApi) };
84
+
85
+ if (headers) result.headers = headers.resolve();
86
+ if (properties) result.properties = properties.resolve(elementApi);
87
+ if (form) result.form = form.resolve(elementApi);
88
+ if (io?.hasInput) {
89
+ result.input = io.getInput(elementApi);
90
+ // A (sub) process input mapping creates local variables visible to its children; a job
91
+ // (service task) or call activity keeps the input on the content (passed to the worker /
92
+ // propagated to the called process) instead of polluting the variables.
93
+ if (SUBPROCESS_TYPES.has(this.activity.type)) this.activity.environment.assignVariables(result.input);
94
+ }
95
+
96
+ Object.assign(elementApi.content, result);
97
+ return result;
98
+ }
99
+ #formatOnExecuted(elementApi) {
100
+ const { io, script, loop } = this.extensions;
101
+ let jobResult = elementApi.content.output;
102
+
103
+ // Multi-instance: aggregate the per-instance outputs into the `outputCollection` array.
104
+ if (loop?.hasOutputCollection) {
105
+ const output = { [loop.outputCollection]: loop.aggregate(jobResult, elementApi.environment.variables) };
106
+ this.#assignOutput(elementApi, output);
107
+ return { output };
108
+ }
109
+
110
+ // A call activity wraps the called process output as `{ executionId, output }`. Unwrap it so
111
+ // output mapping / the variable merge operates on the called process's variables.
112
+ if (this.activity.type === 'bpmn:CallActivity' && jobResult?.executionId && jobResult.output) {
113
+ jobResult = jobResult.output;
114
+ }
115
+
116
+ // A script task's FEEL result is named by its `resultVariable`; a job's result is already
117
+ // an object of variables.
118
+ const resultObject = script ? (script.resultVariable ? { [script.resultVariable]: jobResult } : undefined) : jobResult;
119
+
120
+ let output;
121
+ if (io?.hasOutput) {
122
+ output = io.getOutput(elementApi, resultObject);
123
+ } else if (resultObject !== null && typeof resultObject === 'object') {
124
+ // No output mapping: merge the whole result into the process variables.
125
+ output = resultObject;
126
+ }
127
+
128
+ if (output) {
129
+ this.#assignOutput(elementApi, output);
130
+ return { output };
131
+ }
132
+ return {};
133
+ }
134
+ #assignOutput(elementApi, output) {
135
+ const environment = this.activity.environment;
136
+ // Visible to downstream FEEL within the process, and surfaced as process output
137
+ // (bpmn-elements bubbles process `environment.output` up to the definition on completion).
138
+ environment.assignVariables(output);
139
+ Object.assign(environment.output, output);
140
+ Object.assign(elementApi.content, { output });
141
+ }
142
+ }
package/src/Errors.js ADDED
@@ -0,0 +1,15 @@
1
+ export class ServiceError extends Error {
2
+ constructor(jobType) {
3
+ super(`service for job type <${jobType}> is not implemented`);
4
+ this.code = 'EBPMN_SERVICE_NOT_IMPLEMENTED';
5
+ this.output = { statusCode: 501 };
6
+ }
7
+ }
8
+
9
+ export class FormatError extends Error {
10
+ constructor(elementId, err) {
11
+ super(`<${elementId}> ${err.message}`);
12
+ this.code = 'EBPMN_FORMAT';
13
+ this.output = { statusCode: 500 };
14
+ }
15
+ }
@@ -0,0 +1,49 @@
1
+ import { isFeelExpression, evaluateFeel } from './feel.js';
2
+
3
+ /**
4
+ * FEEL-aware expressions implementation for bpmn-elements.
5
+ *
6
+ * Plug it into the engine via the environment `expressions` option so that the whole
7
+ * definition resolves FEEL expressions (`= ...`) instead of the default
8
+ * `${...}` template expressions.
9
+ *
10
+ * @returns {import('bpmn-elements').IExpressions}
11
+ */
12
+ export function FeelExpressions() {
13
+ return {
14
+ resolveExpression,
15
+ isExpression,
16
+ hasExpression,
17
+ };
18
+ }
19
+
20
+ function isExpression(text) {
21
+ return isFeelExpression(text);
22
+ }
23
+
24
+ function hasExpression(text) {
25
+ return isFeelExpression(text);
26
+ }
27
+
28
+ /**
29
+ * Resolve a templated string. Static literals are returned as-is; FEEL expressions are
30
+ * evaluated against the environment variables (overlaid with any element-local variables).
31
+ * @param {string} templatedString
32
+ * @param {{ environment?: import('bpmn-elements').Environment, content?: any }} [context]
33
+ */
34
+ function resolveExpression(templatedString, context) {
35
+ if (!isFeelExpression(templatedString)) return templatedString;
36
+ return evaluateFeel(templatedString, getScope(context));
37
+ }
38
+
39
+ /**
40
+ * Build the FEEL variable scope from a resolution context. Process/definition variables form
41
+ * the base scope, overlaid with any local variables carried on the element message content
42
+ * (e.g. a multi-instance item).
43
+ */
44
+ function getScope(context) {
45
+ const environment = context && context.environment;
46
+ const variables = environment ? environment.variables : {};
47
+ const local = context && context.content && context.content.variables;
48
+ return { ...variables, ...local };
49
+ }
@@ -0,0 +1,49 @@
1
+ import { FormatError } from './Errors.js';
2
+ import { getExtensions } from './getExtensions.js';
3
+
4
+ /**
5
+ * Process-level extensions. Formats the process on enter (documentation) and assigns
6
+ * the result to the process variables.
7
+ */
8
+ export class ProcessExtensions {
9
+ constructor(bp) {
10
+ this.process = bp;
11
+ this.extensions = getExtensions(bp);
12
+ }
13
+ activate() {
14
+ // bpmn-elements (>=18.0.3) calls activate() on `run.enter`, before `process.enter` is published.
15
+ const bp = this.process;
16
+ bp.broker.subscribeTmp('event', 'process.enter', (_, message) => this.#onEnter(bp.getApi(message)), {
17
+ noAck: true,
18
+ consumerTag: '0dep-bpmn-extensions:on-enter',
19
+ });
20
+ }
21
+ deactivate() {
22
+ this.process.broker.cancel('0dep-bpmn-extensions:on-enter');
23
+ }
24
+ #onEnter(elementApi) {
25
+ try {
26
+ // bpmn-elements (>=18.0.4) forwards a call activity's formatted input — its io mapping plus,
27
+ // for a multi-instance, the loop element variable — on the called process's inbound content.
28
+ // Promote it to top-level process variables (addressable by name; bpmn-elements itself only
29
+ // exposes it nested under `input`).
30
+ const inbound = elementApi.content.inbound;
31
+ if (Array.isArray(inbound)) {
32
+ for (const from of inbound) {
33
+ if (from?.input && typeof from.input === 'object') elementApi.environment.assignVariables(from.input);
34
+ }
35
+ }
36
+
37
+ const result = this.extensions.format.resolve(elementApi);
38
+ Object.assign(elementApi.content, result);
39
+ elementApi.environment.assignVariables(result);
40
+ } catch (err) {
41
+ elementApi.broker.publish(
42
+ 'event',
43
+ 'process.error',
44
+ { ...elementApi.content, error: new FormatError(this.process.id, err) },
45
+ { mandatory: true, type: 'error' }
46
+ );
47
+ }
48
+ }
49
+ }
@@ -0,0 +1,39 @@
1
+ import { ElementExtensions } from './ElementExtensions.js';
2
+
3
+ /**
4
+ * Sub-process extensions.
5
+ *
6
+ * A sub-process shares its broker with the child activities it contains, so their
7
+ * `activity.*` events bubble through it. Subscribing with `activity.on(...)` (as a leaf
8
+ * element does) would therefore fire the extension handlers for every child and spam the
9
+ * sub-process format queue, stalling it. Instead, subscribe on the broker and filter to the
10
+ * sub-process's own id.
11
+ */
12
+ export class SubProcessExtensions extends ElementExtensions {
13
+ activate(message) {
14
+ const listeners = this.extensions.listeners;
15
+ const enterKey = message.fields.redelivered && message.fields.routingKey === 'run.start' ? 'activity.start' : 'activity.enter';
16
+
17
+ this.#subscribe(enterKey, (api) => this._onEnter(api), '0dep-bpmn-extensions:on-enter');
18
+ this.#subscribe('activity.execution.completed', (api) => this._onExecuted(api), '0dep-bpmn-extensions:on-executed');
19
+
20
+ if (listeners?.onStart) {
21
+ this.#subscribe('activity.start', (api) => this._onListener('start', api), '0dep-bpmn-extensions:on-start');
22
+ }
23
+ if (listeners?.onEnd) {
24
+ this.#subscribe('activity.end', (api) => this._onListener('end', api), '0dep-bpmn-extensions:on-end');
25
+ }
26
+ }
27
+ #subscribe(routingKey, callback, consumerTag) {
28
+ const activity = this.activity;
29
+ activity.broker.subscribeTmp(
30
+ 'event',
31
+ routingKey,
32
+ (_, message) => {
33
+ if (activity.id !== message.content.id) return;
34
+ return callback(activity.getApi(message));
35
+ },
36
+ { noAck: true, consumerTag }
37
+ );
38
+ }
39
+ }
@@ -0,0 +1,58 @@
1
+ import { ServiceError } from '../Errors.js';
2
+
3
+ /**
4
+ * `zeebe:executionListeners`.
5
+ *
6
+ * Each listener invokes a job worker (an environment service named by the listener `type`)
7
+ * either before the element runs (`eventType: "start"`) or after it completes
8
+ * (`eventType: "end"`). A listener is called as `(elementApi, { retries, headers }, callback)` and
9
+ * may either call the callback or return a promise — either way the element blocks until it
10
+ * settles. `retries` and `headers` are only present when the listener declares them (no defaults
11
+ * are invented).
12
+ */
13
+ export class ExecutionListeners {
14
+ constructor(activity, executionListeners) {
15
+ this.activity = activity;
16
+ this.listeners = executionListeners.listeners || [];
17
+ }
18
+ get onStart() {
19
+ return this.listeners.some((l) => l.eventType === 'start');
20
+ }
21
+ get onEnd() {
22
+ return this.listeners.some((l) => l.eventType === 'end');
23
+ }
24
+ /**
25
+ * Execute all listeners registered for an event type, in order.
26
+ * @param {'start'|'end'} eventType
27
+ * @param {import('bpmn-elements').IApi<any>} elementApi
28
+ */
29
+ async execute(eventType, elementApi) {
30
+ const environment = this.activity.environment;
31
+ for (const listener of this.listeners) {
32
+ if (listener.eventType !== eventType) continue;
33
+ const serviceFn = environment.services[listener.type];
34
+ if (typeof serviceFn !== 'function') throw new ServiceError(listener.type);
35
+
36
+ const options = {};
37
+ if (listener.retries !== undefined) {
38
+ const retries = Number(listener.retries);
39
+ options.retries = Number.isFinite(retries) ? retries : listener.retries;
40
+ }
41
+
42
+ const headerValues = listener.headers?.values;
43
+ if (headerValues?.length) {
44
+ const headers = {};
45
+ for (const { key, value } of headerValues) {
46
+ if (key !== undefined) headers[key] = value;
47
+ }
48
+ options.headers = headers;
49
+ }
50
+
51
+ // A listener may either call the callback or return a promise; await whichever settles.
52
+ await new Promise((resolve, reject) => {
53
+ const returned = serviceFn.call(this.activity, elementApi, options, (err, result) => (err ? reject(err) : resolve(result)));
54
+ if (returned && typeof returned.then === 'function') returned.then(resolve, reject);
55
+ });
56
+ }
57
+ }
58
+ }
@@ -0,0 +1,49 @@
1
+ import { isFeelExpression, evaluateFeel } from '../feel.js';
2
+
3
+ /**
4
+ * A bpmn-elements `scripts` implementation for script tasks.
5
+ *
6
+ * A script task carries a `zeebe:script` extension with a FEEL `expression` (and a
7
+ * `resultVariable`). There is no embedded script body and no `scriptFormat` attribute, so this
8
+ * registry ignores the script format and reads the expression straight off the element.
9
+ *
10
+ * Install it on the environment via the `scripts` option, alongside `FeelExpressions()`.
11
+ *
12
+ * @returns {import('bpmn-elements').IScripts}
13
+ */
14
+ export function FeelScripts() {
15
+ return new FeelScriptRegistry();
16
+ }
17
+
18
+ class FeelScriptRegistry {
19
+ register() {}
20
+ getScript(_scriptFormat, activity) {
21
+ const expression = getScriptExpression(activity);
22
+ if (expression === undefined) return undefined;
23
+ return new FeelScript(expression);
24
+ }
25
+ }
26
+
27
+ class FeelScript {
28
+ constructor(expression) {
29
+ this.expression = expression;
30
+ }
31
+ execute(scope, callback) {
32
+ try {
33
+ const value = isFeelExpression(this.expression) ? evaluateFeel(this.expression, { ...scope.environment.variables }) : this.expression;
34
+ callback(null, value);
35
+ } catch (err) {
36
+ callback(err);
37
+ }
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Read the `zeebe:script` FEEL expression from a script task's extension elements.
43
+ */
44
+ function getScriptExpression(activity) {
45
+ const values = activity.behaviour?.extensionElements?.values;
46
+ if (!values) return undefined;
47
+ const script = values.find((v) => v.$type === 'zeebe:Script');
48
+ return script?.expression;
49
+ }
@@ -0,0 +1,26 @@
1
+ import { resolveValue } from '../feel.js';
2
+
3
+ /**
4
+ * `zeebe:formDefinition`.
5
+ *
6
+ * User tasks (and form-bearing start events) reference their form here: a linked
7
+ * form (`formId`), a deprecated form key (`formKey`), or an external form
8
+ * (`externalReference`, which may be a FEEL expression). Resolved to a plain object and made
9
+ * available on the element content as `form` so a task list can render it.
10
+ */
11
+ export class Form {
12
+ constructor(formDefinition) {
13
+ this.formDefinition = formDefinition;
14
+ }
15
+ resolve(elementApi) {
16
+ const scope = elementApi.environment.variables;
17
+ const { formId, formKey, externalReference, bindingType, versionTag } = this.formDefinition;
18
+ return {
19
+ ...(formId !== undefined && { formId: resolveValue(formId, scope) }),
20
+ ...(formKey !== undefined && { formKey: resolveValue(formKey, scope) }),
21
+ ...(externalReference !== undefined && { externalReference: resolveValue(externalReference, scope) }),
22
+ ...(bindingType !== undefined && { bindingType }),
23
+ ...(versionTag !== undefined && { versionTag }),
24
+ };
25
+ }
26
+ }
@@ -0,0 +1,68 @@
1
+ import { resolveValue } from '../feel.js';
2
+
3
+ /**
4
+ * `zeebe:ioMapping`.
5
+ *
6
+ * Input parameters are evaluated in the parent (process) scope when the element is entered,
7
+ * producing the local variables the job runs with. Output parameters are evaluated in the
8
+ * job-result scope when the element completes, merging the result back into the process
9
+ * variables.
10
+ *
11
+ * Each parameter has a `source` (a FEEL expression or static value) and a `target`
12
+ * (a, possibly dotted, variable name).
13
+ */
14
+ export class IoMapping {
15
+ constructor(activity, ioMapping) {
16
+ this.activity = activity;
17
+ this.inputParameters = ioMapping.inputParameters || [];
18
+ this.outputParameters = ioMapping.outputParameters || [];
19
+ }
20
+ get hasInput() {
21
+ return this.inputParameters.length > 0;
22
+ }
23
+ get hasOutput() {
24
+ return this.outputParameters.length > 0;
25
+ }
26
+ /**
27
+ * Resolve input parameters against the current process variables.
28
+ * @param {import('bpmn-elements').IApi<any>} elementApi
29
+ */
30
+ getInput(elementApi) {
31
+ const scope = elementApi.environment.variables;
32
+ const result = {};
33
+ for (const { source, target } of this.inputParameters) {
34
+ if (!target) continue;
35
+ setPath(result, target, resolveValue(source, scope));
36
+ }
37
+ return result;
38
+ }
39
+ /**
40
+ * Resolve output parameters against the job result overlaid on the process variables.
41
+ * @param {import('bpmn-elements').IApi<any>} elementApi
42
+ * @param {Record<string, any>} [jobResult] Variables produced by the job
43
+ */
44
+ getOutput(elementApi, jobResult) {
45
+ const scope = { ...elementApi.environment.variables, ...jobResult };
46
+ const result = {};
47
+ for (const { source, target } of this.outputParameters) {
48
+ if (!target) continue;
49
+ setPath(result, target, resolveValue(source, scope));
50
+ }
51
+ return result;
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Assign a value to a (possibly dotted) `target` path, creating intermediate objects as needed —
57
+ * mirrors how an output `target` such as `order.id` maps to a nested object.
58
+ */
59
+ function setPath(target, path, value) {
60
+ const keys = String(path).split('.');
61
+ let cursor = target;
62
+ for (let i = 0; i < keys.length - 1; i++) {
63
+ const key = keys[i];
64
+ if (typeof cursor[key] !== 'object' || cursor[key] === null) cursor[key] = {};
65
+ cursor = cursor[key];
66
+ }
67
+ cursor[keys[keys.length - 1]] = value;
68
+ }
@@ -0,0 +1,44 @@
1
+ import { resolveValue } from '../feel.js';
2
+
3
+ /**
4
+ * Output side of `zeebe:loopCharacteristics`.
5
+ *
6
+ * A multi-instance is aggregated into an `outputCollection` array: for each instance the
7
+ * `outputElement` FEEL expression is evaluated in that instance's result scope and collected, in
8
+ * input order, into the named collection variable.
9
+ *
10
+ * bpmn-elements collects instance outputs into an index-keyed object on the activity content
11
+ * (`{ 0: <out>, 1: <out>, ... }`, where a call activity instance wraps its result as
12
+ * `{ executionId, output }`). This reduces that to the output-collection array.
13
+ */
14
+ export class LoopCharacteristics {
15
+ constructor(loopCharacteristics) {
16
+ this.outputCollection = loopCharacteristics.outputCollection;
17
+ this.outputElement = loopCharacteristics.outputElement;
18
+ }
19
+ get hasOutputCollection() {
20
+ return Boolean(this.outputCollection);
21
+ }
22
+ /**
23
+ * @param {Record<string, any>|undefined} indexedOutput bpmn-elements index-keyed instance outputs
24
+ * @param {Record<string, any>} baseScope process variables in scope for `outputElement`
25
+ * @returns {any[]}
26
+ */
27
+ aggregate(indexedOutput, baseScope) {
28
+ const result = [];
29
+ if (!indexedOutput || typeof indexedOutput !== 'object') return result;
30
+
31
+ const indexes = Object.keys(indexedOutput)
32
+ .filter((key) => /^\d+$/.test(key))
33
+ .sort((a, b) => Number(a) - Number(b));
34
+
35
+ for (const index of indexes) {
36
+ const instance = indexedOutput[index];
37
+ // A call activity instance wraps its result as { executionId, output }.
38
+ const instanceOutput = instance?.executionId && instance.output ? instance.output : instance;
39
+ const scope = { ...baseScope, ...instanceOutput };
40
+ result.push(this.outputElement ? resolveValue(this.outputElement, scope) : instanceOutput);
41
+ }
42
+ return result;
43
+ }
44
+ }
@@ -0,0 +1,22 @@
1
+ import { resolveValue } from '../feel.js';
2
+
3
+ /**
4
+ * `zeebe:properties`.
5
+ *
6
+ * Named properties whose values may be FEEL expressions. Resolved to a plain object and made
7
+ * available on the element content as `properties`.
8
+ */
9
+ export class Properties {
10
+ constructor(properties) {
11
+ this.properties = properties.properties || [];
12
+ }
13
+ resolve(elementApi) {
14
+ const scope = elementApi.environment.variables;
15
+ const result = {};
16
+ for (const { name, value } of this.properties) {
17
+ if (name === undefined) continue;
18
+ result[name] = resolveValue(value, scope);
19
+ }
20
+ return result;
21
+ }
22
+ }