@bpmnkit/engine 0.1.30 → 0.1.31

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 CHANGED
@@ -9,7 +9,7 @@
9
9
  [![ai-assisted](https://img.shields.io/badge/AI--assisted-claude-8b5cf6?style=flat-square)](https://github.com/bpmnkit/monorepo)
10
10
  [![experimental](https://img.shields.io/badge/status-experimental-f59e0b?style=flat-square)](https://github.com/bpmnkit/monorepo)
11
11
 
12
- [Website](https://bpmnkit.com) · [Documentation](https://docs.bpmnkit.com) · [GitHub](https://github.com/bpmnkit/monorepo) · [Changelog](https://github.com/bpmnkit/monorepo/blob/main/packages/engine/CHANGELOG.md)
12
+ [Website](https://bpmnkit.com) · [Documentation](https://bpmnkit.com/docs) · [GitHub](https://github.com/bpmnkit/monorepo) · [Changelog](https://github.com/bpmnkit/monorepo/blob/main/packages/engine/CHANGELOG.md)
13
13
  </div>
14
14
 
15
15
  ---
package/dist/dmn.js CHANGED
@@ -9,8 +9,14 @@ export function evaluateDecision(decision, vars) {
9
9
  return null;
10
10
  const hitPolicy = table.hitPolicy ?? "UNIQUE";
11
11
  const matchedOutputs = [];
12
+ // Input expressions do not depend on the rule, so evaluate each column once
13
+ // instead of once per rule.
14
+ const inputValues = table.inputs.map((col) => {
15
+ const inputExpr = col.inputExpression.text ?? "";
16
+ return inputExpr.trim() === "" ? null : evalExpression(inputExpr, vars);
17
+ });
12
18
  for (const rule of table.rules) {
13
- if (ruleMatches(table.inputs, rule.inputEntries, vars)) {
19
+ if (ruleMatches(table.inputs, inputValues, rule.inputEntries, vars)) {
14
20
  const output = {};
15
21
  for (let i = 0; i < table.outputs.length; i++) {
16
22
  const col = table.outputs[i];
@@ -27,7 +33,7 @@ export function evaluateDecision(decision, vars) {
27
33
  }
28
34
  return buildResult(hitPolicy, table.aggregation, matchedOutputs, table.outputs.length);
29
35
  }
30
- function ruleMatches(inputs, inputEntries, vars) {
36
+ function ruleMatches(inputs, inputValues, inputEntries, vars) {
31
37
  const feelVars = vars;
32
38
  for (let i = 0; i < inputs.length; i++) {
33
39
  const col = inputs[i];
@@ -36,8 +42,7 @@ function ruleMatches(inputs, inputEntries, vars) {
36
42
  continue;
37
43
  if (entry.text.trim() === "")
38
44
  continue; // empty = "any"
39
- const inputExpr = col.inputExpression.text ?? "";
40
- const inputValue = inputExpr.trim() === "" ? null : evalExpression(inputExpr, vars);
45
+ const inputValue = inputValues[i] ?? null;
41
46
  const parsed = parseUnaryTests(entry.text);
42
47
  if (parsed.ast === null)
43
48
  continue;
@@ -9,6 +9,10 @@ export declare class ProcessInstance {
9
9
  private _error;
10
10
  /** tokenId → Token */
11
11
  private readonly allTokens;
12
+ /** elementId → tokens currently sitting on that element. */
13
+ private readonly tokensByElement;
14
+ /** Zeebe extensions parsed once per element for the life of this instance. */
15
+ private readonly zeebeExtCache;
12
16
  /** Scope stack: rootScopeId + any active sub-process scopes */
13
17
  private readonly scopes;
14
18
  /** Message correlation: messageName → resolve callback */
@@ -46,6 +50,9 @@ export declare class ProcessInstance {
46
50
  deliverMessage(messageName: string): void;
47
51
  private buildScopeCtx;
48
52
  private createToken;
53
+ /** The first token currently on `elementId`, if any. */
54
+ private tokenOn;
55
+ private zeebeExt;
49
56
  private removeToken;
50
57
  /** elementId → set of incomingFlowIds received */
51
58
  private readonly joins;
package/dist/instance.js CHANGED
@@ -13,6 +13,10 @@ export class ProcessInstance {
13
13
  _error;
14
14
  /** tokenId → Token */
15
15
  allTokens = new Map();
16
+ /** elementId → tokens currently sitting on that element. */
17
+ tokensByElement = new Map();
18
+ /** Zeebe extensions parsed once per element for the life of this instance. */
19
+ zeebeExtCache = new WeakMap();
16
20
  /** Scope stack: rootScopeId + any active sub-process scopes */
17
21
  scopes = new Map();
18
22
  /** Message correlation: messageName → resolve callback */
@@ -78,6 +82,7 @@ export class ProcessInstance {
78
82
  this._state = "terminated";
79
83
  this.cancelAllTimers();
80
84
  this.allTokens.clear();
85
+ this.tokensByElement.clear();
81
86
  for (const ctx of this.scopes.values())
82
87
  ctx.tokens.clear();
83
88
  }
@@ -141,11 +146,39 @@ export class ProcessInstance {
141
146
  createToken(elementId, scopeId) {
142
147
  const token = { id: generateId("tok"), elementId, scopeId };
143
148
  this.allTokens.set(token.id, token);
149
+ const onElement = this.tokensByElement.get(elementId);
150
+ if (onElement)
151
+ onElement.add(token);
152
+ else
153
+ this.tokensByElement.set(elementId, new Set([token]));
144
154
  this.scopes.get(scopeId)?.tokens.add(token.id);
145
155
  return token;
146
156
  }
157
+ /** The first token currently on `elementId`, if any. */
158
+ tokenOn(elementId) {
159
+ const onElement = this.tokensByElement.get(elementId);
160
+ if (onElement === undefined)
161
+ return undefined;
162
+ for (const token of onElement)
163
+ return token;
164
+ return undefined;
165
+ }
166
+ zeebeExt(el) {
167
+ let ext = this.zeebeExtCache.get(el);
168
+ if (ext === undefined) {
169
+ ext = parseZeebeExt(el.extensionElements);
170
+ this.zeebeExtCache.set(el, ext);
171
+ }
172
+ return ext;
173
+ }
147
174
  removeToken(token) {
148
175
  this.allTokens.delete(token.id);
176
+ const onElement = this.tokensByElement.get(token.elementId);
177
+ if (onElement !== undefined) {
178
+ onElement.delete(token);
179
+ if (onElement.size === 0)
180
+ this.tokensByElement.delete(token.elementId);
181
+ }
149
182
  this.scopes.get(token.scopeId)?.tokens.delete(token.id);
150
183
  this.timerCancels.get(token.id)?.();
151
184
  this.timerCancels.delete(token.id);
@@ -186,7 +219,7 @@ export class ProcessInstance {
186
219
  }
187
220
  this.emit({ type: "element:entering", elementId, elementName: el.name, elementType: el.type });
188
221
  // Apply ioMapping inputs
189
- const ext = parseZeebeExt(el.extensionElements);
222
+ const ext = this.zeebeExt(el);
190
223
  if (ext.ioMapping) {
191
224
  for (const inp of ext.ioMapping.inputs) {
192
225
  let val;
@@ -290,12 +323,11 @@ export class ProcessInstance {
290
323
  const eventDef = el.eventDefinitions[0];
291
324
  if (eventDef?.type === "terminate") {
292
325
  this.cancelAllTimers();
293
- for (const [id] of this.allTokens) {
294
- const tok = this.allTokens.get(id);
295
- if (tok !== undefined)
296
- this.scopes.get(tok.scopeId)?.tokens.delete(id);
326
+ for (const tok of this.allTokens.values()) {
327
+ this.scopes.get(tok.scopeId)?.tokens.delete(tok.id);
297
328
  }
298
329
  this.allTokens.clear();
330
+ this.tokensByElement.clear();
299
331
  this.emit({
300
332
  type: "element:leaving",
301
333
  elementId: el.id,
@@ -431,7 +463,7 @@ export class ProcessInstance {
431
463
  });
432
464
  return;
433
465
  }
434
- const result = evaluateDecision(decision, this.variables.getAll(scopeId));
466
+ const result = evaluateDecision(decision, this.variables.snapshot(scopeId));
435
467
  this.variables.set(scopeId, cd.resultVariable, result);
436
468
  this.emit({ type: "variable:set", name: cd.resultVariable, value: result, scopeId });
437
469
  }
@@ -553,12 +585,9 @@ export class ProcessInstance {
553
585
  const cancel = scheduleTimer(timerDef, () => {
554
586
  this.boundaryTimerCancels.delete(elementId);
555
587
  // Remove the parent token
556
- for (const [, tok] of this.allTokens) {
557
- if (tok.elementId === elementId) {
558
- this.removeToken(tok);
559
- break;
560
- }
561
- }
588
+ const parentToken = this.tokenOn(elementId);
589
+ if (parentToken !== undefined)
590
+ this.removeToken(parentToken);
562
591
  if (be.cancelActivity !== false) {
563
592
  void this.activate(be.id, scopeId, undefined);
564
593
  }
@@ -579,12 +608,9 @@ export class ProcessInstance {
579
608
  continue;
580
609
  if (errDef.errorRef !== undefined && errDef.errorRef !== errorCode)
581
610
  continue;
582
- for (const [, tok] of this.allTokens) {
583
- if (tok.elementId === attachedTo) {
584
- this.removeToken(tok);
585
- break;
586
- }
587
- }
611
+ const hostToken = this.tokenOn(attachedTo);
612
+ if (hostToken !== undefined)
613
+ this.removeToken(hostToken);
588
614
  void this.activate(be.id, ctx.scopeId, undefined);
589
615
  return;
590
616
  }
@@ -608,7 +634,7 @@ export class ProcessInstance {
608
634
  const el = ctx.elements.get(token.elementId);
609
635
  if (el === undefined)
610
636
  return;
611
- const ext = parseZeebeExt(el.extensionElements);
637
+ const ext = this.zeebeExt(el);
612
638
  // Apply ioMapping outputs
613
639
  if (ext.ioMapping) {
614
640
  for (const out of ext.ioMapping.outputs) {
@@ -676,14 +702,15 @@ export class ProcessInstance {
676
702
  }
677
703
  // ── FEEL helpers ───────────────────────────────────────────────────────────
678
704
  evalFeel(expr, scopeId, emitCtx) {
679
- const vars = this.variables.getAll(scopeId);
705
+ const vars = this.variables.snapshot(scopeId);
680
706
  // Strip Camunda FEEL prefix ("= expr") — the leading "=" is a type indicator, not part of the expression.
681
707
  const normalized = expr.trim().replace(/^=\s*/, "");
682
708
  const parsed = parseExpression(normalized);
683
709
  if (parsed.ast === null)
684
710
  return undefined;
685
711
  const result = evaluate(parsed.ast, { vars: vars });
686
- if (emitCtx !== undefined) {
712
+ // The event carries a copy of every variable; skip building it when nobody listens.
713
+ if (emitCtx !== undefined && this.listeners.length > 0) {
687
714
  this.emit({
688
715
  type: "feel:evaluated",
689
716
  elementId: emitCtx.elementId,
@@ -6,6 +6,11 @@
6
6
  export declare class VariableStore {
7
7
  private readonly scopes;
8
8
  private readonly parents;
9
+ /**
10
+ * Merged root → scope views, built lazily and then kept current by every
11
+ * write, so expression evaluation never re-merges the scope chain.
12
+ */
13
+ private readonly snapshots;
9
14
  createScope(id: string, parentId?: string): void;
10
15
  removeScope(id: string): void;
11
16
  /** Walk up the chain and return the value, or undefined if not found. */
@@ -17,8 +22,16 @@ export declare class VariableStore {
17
22
  set(scopeId: string, name: string, value: unknown): void;
18
23
  /** Set a variable in this scope only, regardless of parent state. */
19
24
  setLocal(scopeId: string, name: string, value: unknown): void;
20
- /** Return all variables merged from root → this scope (child wins). */
25
+ /** Return a fresh copy of all variables merged from root → this scope (child wins). */
21
26
  getAll(scopeId: string): Record<string, unknown>;
27
+ /**
28
+ * All variables merged from root → this scope, as a shared object that
29
+ * later writes update in place. Callers must treat it as read-only and not
30
+ * hold it across a write.
31
+ */
32
+ snapshot(scopeId: string): Record<string, unknown>;
33
+ /** Store the value and patch every cached view that sees this scope's copy of `name`. */
34
+ private write;
22
35
  private hasOwn;
23
36
  private ancestorHas;
24
37
  }
package/dist/variables.js CHANGED
@@ -6,6 +6,11 @@
6
6
  export class VariableStore {
7
7
  scopes = new Map();
8
8
  parents = new Map();
9
+ /**
10
+ * Merged root → scope views, built lazily and then kept current by every
11
+ * write, so expression evaluation never re-merges the scope chain.
12
+ */
13
+ snapshots = new Map();
9
14
  createScope(id, parentId) {
10
15
  this.scopes.set(id, new Map());
11
16
  if (parentId !== undefined) {
@@ -15,6 +20,7 @@ export class VariableStore {
15
20
  removeScope(id) {
16
21
  this.scopes.delete(id);
17
22
  this.parents.delete(id);
23
+ this.snapshots.delete(id);
18
24
  }
19
25
  /** Walk up the chain and return the value, or undefined if not found. */
20
26
  get(scopeId, name) {
@@ -34,7 +40,7 @@ export class VariableStore {
34
40
  */
35
41
  set(scopeId, name, value) {
36
42
  if (this.hasOwn(scopeId, name)) {
37
- this.scopes.get(scopeId)?.set(name, value);
43
+ this.write(scopeId, name, value);
38
44
  return;
39
45
  }
40
46
  const parentId = this.parents.get(scopeId);
@@ -42,25 +48,55 @@ export class VariableStore {
42
48
  this.set(parentId, name, value);
43
49
  return;
44
50
  }
45
- this.scopes.get(scopeId)?.set(name, value);
51
+ this.write(scopeId, name, value);
46
52
  }
47
53
  /** Set a variable in this scope only, regardless of parent state. */
48
54
  setLocal(scopeId, name, value) {
49
- this.scopes.get(scopeId)?.set(name, value);
55
+ this.write(scopeId, name, value);
50
56
  }
51
- /** Return all variables merged from root → this scope (child wins). */
57
+ /** Return a fresh copy of all variables merged from root → this scope (child wins). */
52
58
  getAll(scopeId) {
59
+ return { ...this.snapshot(scopeId) };
60
+ }
61
+ /**
62
+ * All variables merged from root → this scope, as a shared object that
63
+ * later writes update in place. Callers must treat it as read-only and not
64
+ * hold it across a write.
65
+ */
66
+ snapshot(scopeId) {
67
+ const cached = this.snapshots.get(scopeId);
68
+ if (cached !== undefined)
69
+ return cached;
53
70
  const parentId = this.parents.get(scopeId);
54
- const parentVars = parentId !== undefined ? this.getAll(parentId) : {};
71
+ const parentVars = parentId !== undefined ? this.snapshot(parentId) : {};
55
72
  const scope = this.scopes.get(scopeId);
56
73
  if (scope === undefined)
57
74
  return parentVars;
58
75
  const result = { ...parentVars };
59
- for (const [k, v] of scope) {
76
+ for (const [k, v] of scope)
60
77
  result[k] = v;
61
- }
78
+ this.snapshots.set(scopeId, result);
62
79
  return result;
63
80
  }
81
+ /** Store the value and patch every cached view that sees this scope's copy of `name`. */
82
+ write(scopeId, name, value) {
83
+ const scope = this.scopes.get(scopeId);
84
+ if (scope === undefined)
85
+ return;
86
+ scope.set(name, value);
87
+ for (const [viewId, view] of this.snapshots) {
88
+ // Walk from the view's scope up to the writer; a scope in between that
89
+ // owns the name shadows the write for this view.
90
+ let current = viewId;
91
+ while (current !== undefined && current !== scopeId) {
92
+ if (this.scopes.get(current)?.has(name))
93
+ break;
94
+ current = this.parents.get(current);
95
+ }
96
+ if (current === scopeId)
97
+ view[name] = value;
98
+ }
99
+ }
64
100
  hasOwn(scopeId, name) {
65
101
  return this.scopes.get(scopeId)?.has(name) ?? false;
66
102
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bpmnkit/engine",
3
- "version": "0.1.30",
3
+ "version": "0.1.31",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -21,7 +21,7 @@
21
21
  "dist/**/*.d.ts"
22
22
  ],
23
23
  "dependencies": {
24
- "@bpmnkit/core": "0.1.2",
24
+ "@bpmnkit/core": "0.2.0",
25
25
  "@bpmnkit/feel": "0.0.20"
26
26
  },
27
27
  "optionalDependencies": {