@unlaxer/tramli 1.1.0 → 1.2.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.
@@ -47,6 +47,9 @@ export declare class Builder<S extends string> {
47
47
  private checkRequiresProducesFrom;
48
48
  private checkAutoExternalConflict;
49
49
  private checkTerminalNoOutgoing;
50
+ private checkSubFlowNestingDepth;
51
+ private checkSubFlowCircularRef;
52
+ private checkSubFlowExitCompleteness;
50
53
  }
51
54
  export declare class FromBuilder<S extends string> {
52
55
  private readonly builder;
@@ -55,6 +58,16 @@ export declare class FromBuilder<S extends string> {
55
58
  auto(to: S, processor: StateProcessor<S>): Builder<S>;
56
59
  external(to: S, guard: TransitionGuard<S>, processor?: StateProcessor<S>): Builder<S>;
57
60
  branch(branch: BranchProcessor<S>): BranchBuilder<S>;
61
+ subFlow(subFlowDef: FlowDefinition<any>): SubFlowBuilder<S>;
62
+ }
63
+ export declare class SubFlowBuilder<S extends string> {
64
+ private readonly builder;
65
+ private readonly fromState;
66
+ private readonly subFlowDef;
67
+ private readonly exitMap;
68
+ constructor(builder: Builder<S>, fromState: S, subFlowDef: FlowDefinition<any>);
69
+ onExit(terminalName: string, parentState: S): this;
70
+ endSubFlow(): Builder<S>;
58
71
  }
59
72
  export declare class BranchBuilder<S extends string> {
60
73
  private readonly builder;
@@ -118,6 +118,9 @@ export class Builder {
118
118
  this.checkRequiresProduces(def, errors);
119
119
  this.checkAutoExternalConflict(def, errors);
120
120
  this.checkTerminalNoOutgoing(def, errors);
121
+ this.checkSubFlowExitCompleteness(def, errors);
122
+ this.checkSubFlowNestingDepth(def, errors, 0);
123
+ this.checkSubFlowCircularRef(def, errors, new Set());
121
124
  if (errors.length > 0) {
122
125
  throw new FlowError('INVALID_FLOW_DEFINITION', `Flow '${this.name}' has ${errors.length} validation error(s):\n - ${errors.join('\n - ')}`);
123
126
  }
@@ -131,6 +134,15 @@ export class Builder {
131
134
  while (queue.length > 0) {
132
135
  const current = queue.shift();
133
136
  for (const t of def.transitionsFrom(current)) {
137
+ if (t.type === 'sub_flow' && t.exitMappings) {
138
+ for (const target of t.exitMappings.values()) {
139
+ if (!visited.has(target)) {
140
+ visited.add(target);
141
+ queue.push(target);
142
+ }
143
+ }
144
+ continue;
145
+ }
134
146
  if (!visited.has(t.to)) {
135
147
  visited.add(t.to);
136
148
  queue.push(t.to);
@@ -163,6 +175,13 @@ export class Builder {
163
175
  return false;
164
176
  visited.add(state);
165
177
  for (const t of def.transitionsFrom(state)) {
178
+ if (t.type === 'sub_flow' && t.exitMappings) {
179
+ for (const target of t.exitMappings.values()) {
180
+ if (this.canReachTerminal(def, target, visited))
181
+ return true;
182
+ }
183
+ continue;
184
+ }
166
185
  if (this.canReachTerminal(def, t.to, visited))
167
186
  return true;
168
187
  }
@@ -300,11 +319,46 @@ export class Builder {
300
319
  }
301
320
  checkTerminalNoOutgoing(def, errors) {
302
321
  for (const t of def.transitions) {
303
- if (def.stateConfig[t.from].terminal) {
322
+ if (def.stateConfig[t.from].terminal && t.type !== 'sub_flow') {
304
323
  errors.push(`Terminal state ${t.from} has an outgoing transition to ${t.to}`);
305
324
  }
306
325
  }
307
326
  }
327
+ checkSubFlowNestingDepth(def, errors, depth) {
328
+ if (depth > 3) {
329
+ errors.push(`SubFlow nesting depth exceeds maximum of 3 (flow: ${def.name})`);
330
+ return;
331
+ }
332
+ for (const t of def.transitions) {
333
+ if (t.type === 'sub_flow' && t.subFlowDefinition) {
334
+ this.checkSubFlowNestingDepth(t.subFlowDefinition, errors, depth + 1);
335
+ }
336
+ }
337
+ }
338
+ checkSubFlowCircularRef(def, errors, visited) {
339
+ if (visited.has(def.name)) {
340
+ errors.push(`Circular sub-flow reference detected: ${[...visited].join(' -> ')} -> ${def.name}`);
341
+ return;
342
+ }
343
+ visited.add(def.name);
344
+ for (const t of def.transitions) {
345
+ if (t.type === 'sub_flow' && t.subFlowDefinition) {
346
+ this.checkSubFlowCircularRef(t.subFlowDefinition, errors, new Set(visited));
347
+ }
348
+ }
349
+ }
350
+ checkSubFlowExitCompleteness(def, errors) {
351
+ for (const t of def.transitions) {
352
+ if (t.type !== 'sub_flow' || !t.subFlowDefinition)
353
+ continue;
354
+ const subDef = t.subFlowDefinition;
355
+ for (const terminal of subDef.terminalStates) {
356
+ if (!t.exitMappings?.has(terminal)) {
357
+ errors.push(`SubFlow '${subDef.name}' at ${t.from} has terminal state ${terminal} with no onExit mapping`);
358
+ }
359
+ }
360
+ }
361
+ }
308
362
  }
309
363
  export class FromBuilder {
310
364
  builder;
@@ -330,6 +384,34 @@ export class FromBuilder {
330
384
  branch(branch) {
331
385
  return new BranchBuilder(this.builder, this.fromState, branch);
332
386
  }
387
+ subFlow(subFlowDef) {
388
+ return new SubFlowBuilder(this.builder, this.fromState, subFlowDef);
389
+ }
390
+ }
391
+ export class SubFlowBuilder {
392
+ builder;
393
+ fromState;
394
+ subFlowDef;
395
+ exitMap = new Map();
396
+ constructor(builder, fromState, subFlowDef) {
397
+ this.builder = builder;
398
+ this.fromState = fromState;
399
+ this.subFlowDef = subFlowDef;
400
+ }
401
+ onExit(terminalName, parentState) {
402
+ this.exitMap.set(terminalName, parentState);
403
+ return this;
404
+ }
405
+ endSubFlow() {
406
+ this.builder.addTransition({
407
+ from: this.fromState, to: this.fromState, type: 'sub_flow',
408
+ processor: undefined, guard: undefined, branch: undefined,
409
+ branchTargets: new Map(),
410
+ subFlowDefinition: this.subFlowDef,
411
+ exitMappings: new Map(this.exitMap),
412
+ });
413
+ return this.builder;
414
+ }
333
415
  }
334
416
  export class BranchBuilder {
335
417
  builder;
@@ -1,22 +1,13 @@
1
1
  import type { FlowDefinition } from './flow-definition.js';
2
2
  import { FlowInstance } from './flow-instance.js';
3
3
  import type { InMemoryFlowStore } from './in-memory-flow-store.js';
4
- /**
5
- * Generic engine that drives all flow state machines.
6
- *
7
- * Exceptions:
8
- * - FLOW_NOT_FOUND: resumeAndExecute with unknown or completed flowId
9
- * - INVALID_TRANSITION: resumeAndExecute when no external transition exists
10
- * - MAX_CHAIN_DEPTH: auto-chain exceeded 10 steps
11
- * - EXPIRED: flow TTL exceeded at resumeAndExecute entry
12
- *
13
- * Processor and branch exceptions are caught and routed to error transitions.
14
- */
15
4
  export declare class FlowEngine {
16
5
  private readonly store;
17
6
  constructor(store: InMemoryFlowStore);
18
7
  startFlow<S extends string>(definition: FlowDefinition<S>, sessionId: string, initialData: Map<string, unknown>): Promise<FlowInstance<S>>;
19
8
  resumeAndExecute<S extends string>(flowId: string, definition: FlowDefinition<S>, externalData?: Map<string, unknown>): Promise<FlowInstance<S>>;
20
9
  private executeAutoChain;
10
+ private executeSubFlow;
11
+ private resumeSubFlow;
21
12
  private handleError;
22
13
  }
@@ -2,17 +2,6 @@ import { FlowContext } from './flow-context.js';
2
2
  import { FlowInstance } from './flow-instance.js';
3
3
  import { FlowError } from './flow-error.js';
4
4
  const MAX_CHAIN_DEPTH = 10;
5
- /**
6
- * Generic engine that drives all flow state machines.
7
- *
8
- * Exceptions:
9
- * - FLOW_NOT_FOUND: resumeAndExecute with unknown or completed flowId
10
- * - INVALID_TRANSITION: resumeAndExecute when no external transition exists
11
- * - MAX_CHAIN_DEPTH: auto-chain exceeded 10 steps
12
- * - EXPIRED: flow TTL exceeded at resumeAndExecute entry
13
- *
14
- * Processor and branch exceptions are caught and routed to error transitions.
15
- */
16
5
  export class FlowEngine {
17
6
  store;
18
7
  constructor(store) {
@@ -46,6 +35,10 @@ export class FlowEngine {
46
35
  this.store.save(flow);
47
36
  return flow;
48
37
  }
38
+ // If actively in a sub-flow, delegate resume
39
+ if (flow.activeSubFlow) {
40
+ return this.resumeSubFlow(flow, definition);
41
+ }
49
42
  const currentState = flow.currentState;
50
43
  const transition = definition.externalFrom(currentState);
51
44
  if (!transition)
@@ -108,6 +101,15 @@ export class FlowEngine {
108
101
  break;
109
102
  }
110
103
  const transitions = flow.definition.transitionsFrom(current);
104
+ // Check for sub-flow transition
105
+ const subFlowT = transitions.find(t => t.type === 'sub_flow');
106
+ if (subFlowT) {
107
+ const advanced = await this.executeSubFlow(flow, subFlowT);
108
+ depth += advanced;
109
+ if (advanced === 0)
110
+ break; // sub-flow stopped at external
111
+ continue;
112
+ }
111
113
  const autoOrBranch = transitions.find(t => t.type === 'auto' || t.type === 'branch');
112
114
  if (!autoOrBranch)
113
115
  break;
@@ -145,6 +147,81 @@ export class FlowEngine {
145
147
  if (depth >= MAX_CHAIN_DEPTH)
146
148
  throw FlowError.maxChainDepth();
147
149
  }
150
+ async executeSubFlow(parentFlow, subFlowTransition) {
151
+ const subDef = subFlowTransition.subFlowDefinition;
152
+ const exitMappings = subFlowTransition.exitMappings;
153
+ const subInitial = subDef.initialState;
154
+ const subFlow = new FlowInstance(parentFlow.id, parentFlow.sessionId, subDef, parentFlow.context, subInitial, parentFlow.expiresAt);
155
+ parentFlow.setActiveSubFlow(subFlow);
156
+ await this.executeAutoChain(subFlow);
157
+ if (subFlow.isCompleted) {
158
+ parentFlow.setActiveSubFlow(null);
159
+ const target = exitMappings.get(subFlow.exitState);
160
+ if (target) {
161
+ const from = parentFlow.currentState;
162
+ parentFlow.transitionTo(target);
163
+ this.store.recordTransition(parentFlow.id, from, target, `subFlow:${subDef.name}/${subFlow.exitState}`, parentFlow.context);
164
+ return 1;
165
+ }
166
+ // Error bubbling: no exit mapping → fall back to parent's error transitions
167
+ this.handleError(parentFlow, parentFlow.currentState);
168
+ return 1;
169
+ }
170
+ return 0; // sub-flow stopped at external
171
+ }
172
+ async resumeSubFlow(parentFlow, parentDef) {
173
+ const subFlow = parentFlow.activeSubFlow;
174
+ const subDef = subFlow.definition;
175
+ const transition = subDef.externalFrom(subFlow.currentState);
176
+ if (!transition) {
177
+ throw new FlowError('INVALID_TRANSITION', `No external transition from sub-flow state ${subFlow.currentState}`);
178
+ }
179
+ const guard = transition.guard;
180
+ if (guard) {
181
+ const output = await guard.validate(parentFlow.context);
182
+ if (output.type === 'accepted') {
183
+ if (output.data) {
184
+ for (const [key, value] of output.data)
185
+ parentFlow.context.put(key, value);
186
+ }
187
+ subFlow.transitionTo(transition.to);
188
+ this.store.recordTransition(parentFlow.id, subFlow.currentState, transition.to, guard.name, parentFlow.context);
189
+ }
190
+ else if (output.type === 'rejected') {
191
+ subFlow.incrementGuardFailure();
192
+ if (subFlow.guardFailureCount >= subDef.maxGuardRetries) {
193
+ subFlow.complete('ERROR');
194
+ }
195
+ this.store.save(parentFlow);
196
+ return parentFlow;
197
+ }
198
+ else {
199
+ parentFlow.complete('EXPIRED');
200
+ this.store.save(parentFlow);
201
+ return parentFlow;
202
+ }
203
+ }
204
+ else {
205
+ subFlow.transitionTo(transition.to);
206
+ }
207
+ await this.executeAutoChain(subFlow);
208
+ if (subFlow.isCompleted) {
209
+ parentFlow.setActiveSubFlow(null);
210
+ const subFlowT = parentDef.transitionsFrom(parentFlow.currentState)
211
+ .find(t => t.type === 'sub_flow');
212
+ if (subFlowT?.exitMappings) {
213
+ const target = subFlowT.exitMappings.get(subFlow.exitState);
214
+ if (target) {
215
+ const from = parentFlow.currentState;
216
+ parentFlow.transitionTo(target);
217
+ this.store.recordTransition(parentFlow.id, from, target, `subFlow:${subDef.name}/${subFlow.exitState}`, parentFlow.context);
218
+ await this.executeAutoChain(parentFlow);
219
+ }
220
+ }
221
+ }
222
+ this.store.save(parentFlow);
223
+ return parentFlow;
224
+ }
148
225
  handleError(flow, fromState) {
149
226
  const errorTarget = flow.definition.errorTransitions.get(fromState);
150
227
  if (errorTarget) {
@@ -11,6 +11,7 @@ export declare class FlowInstance<S extends string> {
11
11
  readonly createdAt: Date;
12
12
  readonly expiresAt: Date;
13
13
  private _exitState;
14
+ private _activeSubFlow;
14
15
  constructor(id: string, sessionId: string, definition: FlowDefinition<S>, context: FlowContext, currentState: S, expiresAt: Date);
15
16
  /**
16
17
  * Restore a FlowInstance from persisted state.
@@ -22,8 +23,10 @@ export declare class FlowInstance<S extends string> {
22
23
  get version(): number;
23
24
  get exitState(): string | null;
24
25
  get isCompleted(): boolean;
26
+ get activeSubFlow(): FlowInstance<any> | null;
25
27
  /** @internal */ transitionTo(state: S): void;
26
28
  /** @internal */ incrementGuardFailure(): void;
27
29
  /** @internal */ complete(exitState: string): void;
28
30
  /** @internal */ setVersion(version: number): void;
31
+ /** @internal */ setActiveSubFlow(sub: FlowInstance<any> | null): void;
29
32
  }
@@ -9,6 +9,7 @@ export class FlowInstance {
9
9
  createdAt;
10
10
  expiresAt;
11
11
  _exitState;
12
+ _activeSubFlow = null;
12
13
  constructor(id, sessionId, definition, context, currentState, expiresAt) {
13
14
  this.id = id;
14
15
  this.sessionId = sessionId;
@@ -45,8 +46,10 @@ export class FlowInstance {
45
46
  get version() { return this._version; }
46
47
  get exitState() { return this._exitState; }
47
48
  get isCompleted() { return this._exitState !== null; }
49
+ get activeSubFlow() { return this._activeSubFlow; }
48
50
  /** @internal */ transitionTo(state) { this._currentState = state; }
49
51
  /** @internal */ incrementGuardFailure() { this._guardFailureCount++; }
50
52
  /** @internal */ complete(exitState) { this._exitState = exitState; }
51
53
  /** @internal */ setVersion(version) { this._version = version; }
54
+ /** @internal */ setActiveSubFlow(sub) { this._activeSubFlow = sub; }
52
55
  }
package/dist/index.d.ts CHANGED
@@ -2,7 +2,7 @@ export { Tramli } from './tramli.js';
2
2
  export { FlowEngine } from './flow-engine.js';
3
3
  export { FlowContext } from './flow-context.js';
4
4
  export { FlowInstance } from './flow-instance.js';
5
- export { FlowDefinition, Builder, FromBuilder, BranchBuilder } from './flow-definition.js';
5
+ export { FlowDefinition, Builder, FromBuilder, BranchBuilder, SubFlowBuilder } from './flow-definition.js';
6
6
  export { FlowError } from './flow-error.js';
7
7
  export { InMemoryFlowStore } from './in-memory-flow-store.js';
8
8
  export type { TransitionRecord } from './in-memory-flow-store.js';
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@ export { Tramli } from './tramli.js';
2
2
  export { FlowEngine } from './flow-engine.js';
3
3
  export { FlowContext } from './flow-context.js';
4
4
  export { FlowInstance } from './flow-instance.js';
5
- export { FlowDefinition, Builder, FromBuilder, BranchBuilder } from './flow-definition.js';
5
+ export { FlowDefinition, Builder, FromBuilder, BranchBuilder, SubFlowBuilder } from './flow-definition.js';
6
6
  export { FlowError } from './flow-error.js';
7
7
  export { InMemoryFlowStore } from './in-memory-flow-store.js';
8
8
  export { MermaidGenerator } from './mermaid-generator.js';
package/dist/types.d.ts CHANGED
@@ -16,7 +16,7 @@ export type GuardOutput = {
16
16
  type: 'expired';
17
17
  };
18
18
  /** Transition types. */
19
- export type TransitionType = 'auto' | 'external' | 'branch';
19
+ export type TransitionType = 'auto' | 'external' | 'branch' | 'sub_flow';
20
20
  /** A single transition in the flow definition. */
21
21
  export interface Transition<S extends string> {
22
22
  from: S;
@@ -26,6 +26,8 @@ export interface Transition<S extends string> {
26
26
  guard?: TransitionGuard<S>;
27
27
  branch?: BranchProcessor<S>;
28
28
  branchTargets: Map<string, S>;
29
+ subFlowDefinition?: import('./flow-definition.js').FlowDefinition<any>;
30
+ exitMappings?: Map<string, S>;
29
31
  }
30
32
  /**
31
33
  * Processes a state transition.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unlaxer/tramli",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
4
4
  "description": "Constrained flow engine — state machines that prevent invalid transitions at build time",
5
5
  "type": "module",
6
6
  "exports": {