Skip to content

Commit c945fac

Browse files
authored
0.5.0. (#7)
1 parent 63d598f commit c945fac

19 files changed

+226
-49
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
## 0.5.0
2+
3+
This version simplifies error handling:
4+
5+
* The `getSnapshot()` method now returns an instance of the `WorkflowMachineSnapshot` class, which includes three new methods: `isFinished()`, `isFailed()`, and `isInterrupted()`. Additionally, you can retrieve the id of the last executing step by calling the `tryGetCurrentStepId()` method.
6+
* The `unhandledError` property of the snapshot class is always an instance of the `MachineUnhandledError` class.
7+
18
## 0.4.0
29

310
Updated the `sequential-workflow-model` dependency to the version `0.2.0`.

machine/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "sequential-workflow-machine",
33
"description": "Powerful sequential workflow machine for frontend and backend applications.",
4-
"version": "0.4.0",
4+
"version": "0.5.0",
55
"type": "module",
66
"main": "./lib/esm/index.js",
77
"types": "./lib/index.d.ts",

machine/src/activities/atom-activity/atom-activity.spec.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { createAtomActivity, createAtomActivityFromHandler } from './atom-activity';
22
import { createActivitySet } from '../../core/activity-set';
33
import { createWorkflowMachineBuilder } from '../../workflow-machine-builder';
4-
import { STATE_FINISHED_ID, STATE_INTERRUPTED_ID, STATE_FAILED_ID } from '../../types';
54
import { Definition, Step } from 'sequential-workflow-model';
65
import { interrupt } from '../results/interrupt-result';
76

@@ -72,7 +71,9 @@ describe('AtomActivity', () => {
7271
interpreter.onDone(() => {
7372
const snapshot = interpreter.getSnapshot();
7473

75-
expect(snapshot.statePath[0]).toBe(STATE_FINISHED_ID);
74+
expect(snapshot.isFinished()).toBe(true);
75+
expect(snapshot.isFailed()).toBe(false);
76+
expect(snapshot.isInterrupted()).toBe(false);
7677
expect(snapshot.globalState.counter).toBe(20);
7778

7879
done();
@@ -99,7 +100,9 @@ describe('AtomActivity', () => {
99100
interpreter.onDone(() => {
100101
const snapshot = interpreter.getSnapshot();
101102

102-
expect(snapshot.statePath[0]).toBe(STATE_INTERRUPTED_ID);
103+
expect(snapshot.isInterrupted()).toBe(true);
104+
expect(snapshot.isFailed()).toBe(false);
105+
expect(snapshot.isFinished()).toBe(false);
103106
expect(snapshot.globalState.counter).toBe(0);
104107

105108
done();
@@ -126,9 +129,12 @@ describe('AtomActivity', () => {
126129
interpreter.onDone(() => {
127130
const snapshot = interpreter.getSnapshot();
128131

129-
expect(snapshot.statePath[0]).toBe(STATE_FAILED_ID);
132+
expect(snapshot.isFailed()).toBe(true);
133+
expect(snapshot.isFinished()).toBe(false);
134+
expect(snapshot.isInterrupted()).toBe(false);
130135
expect(snapshot.unhandledError).toBeInstanceOf(Error);
131-
expect((snapshot.unhandledError as Error).message).toBe('TEST_ERROR');
136+
expect(snapshot.unhandledError?.message).toBe('TEST_ERROR');
137+
expect(snapshot.unhandledError?.stepId).toBe('0x2');
132138

133139
done();
134140
});

machine/src/activities/break-activity/break-activity.spec.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import { createBreakActivity } from './break-activity';
44
import { createActivitySet } from '../../core';
55
import { createAtomActivity } from '../atom-activity';
66
import { createWorkflowMachineBuilder } from '../../workflow-machine-builder';
7-
import { STATE_FINISHED_ID } from '../../types';
87
import { break_ } from './break-result';
98

109
interface TestGlobalState {
@@ -98,7 +97,7 @@ describe('BreakActivity', () => {
9897
interpreter.onDone(() => {
9998
const snapshot = interpreter.getSnapshot();
10099

101-
expect(snapshot.statePath[0]).toBe(STATE_FINISHED_ID);
100+
expect(snapshot.isFinished()).toBe(true);
102101
expect(snapshot.globalState.trace).toBe(
103102
'(condition)(decrement)(break)(condition)(decrement)(break)(condition)(decrement)(break)'
104103
);

machine/src/activities/container-activity/container-activity.spec.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { createActivitySet } from '../../core/activity-set';
22
import { createWorkflowMachineBuilder } from '../../workflow-machine-builder';
3-
import { STATE_FINISHED_ID } from '../../types';
43
import { Definition, SequentialStep, Step } from 'sequential-workflow-model';
54
import { createContainerActivity } from './container-activity';
65
import { createAtomActivity } from '../atom-activity';
@@ -71,7 +70,9 @@ describe('ContainerActivity', () => {
7170
.onDone(() => {
7271
const snapshot = interpreter.getSnapshot();
7372

74-
expect(snapshot.statePath[0]).toBe(STATE_FINISHED_ID);
73+
expect(snapshot.isFinished()).toBe(true);
74+
expect(snapshot.isInterrupted()).toBe(false);
75+
expect(snapshot.isFailed()).toBe(false);
7576
expect(snapshot.globalState.counter).toBe(2);
7677
expect(snapshot.globalState.entered).toBe(true);
7778
expect(snapshot.globalState.leaved).toBe(true);

machine/src/activities/fork-activity/fork-activity.spec.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { createAtomActivity } from '../atom-activity/atom-activity';
22
import { createActivitySet } from '../../core/activity-set';
33
import { createWorkflowMachineBuilder } from '../../workflow-machine-builder';
44
import { createForkActivity } from './fork-activity';
5-
import { STATE_FAILED_ID, STATE_FINISHED_ID, STATE_INTERRUPTED_ID } from '../../types';
65
import { BranchedStep, Definition, Step } from 'sequential-workflow-model';
76
import { interrupt } from '../results/interrupt-result';
87
import { branchName } from '../results/branch-name-result';
@@ -105,7 +104,7 @@ describe('ForkActivity', () => {
105104
interpreter.onDone(() => {
106105
const snapshot = interpreter.getSnapshot();
107106

108-
expect(snapshot.statePath[0]).toBe(STATE_FINISHED_ID);
107+
expect(snapshot.isFinished()).toBe(true);
109108
expect(snapshot.globalState.message).toBe('(start)(true)(end)');
110109

111110
done();
@@ -124,7 +123,7 @@ describe('ForkActivity', () => {
124123
interpreter.onDone(() => {
125124
const snapshot = interpreter.getSnapshot();
126125

127-
expect(snapshot.statePath[0]).toBe(STATE_INTERRUPTED_ID);
126+
expect(snapshot.isInterrupted()).toBe(true);
128127
expect(snapshot.globalState.message).toBe('(start)');
129128

130129
done();
@@ -143,8 +142,9 @@ describe('ForkActivity', () => {
143142
interpreter.onDone(() => {
144143
const snapshot = interpreter.getSnapshot();
145144

146-
expect(snapshot.statePath[0]).toBe(STATE_FAILED_ID);
147-
expect((snapshot.unhandledError as Error).message).toBe('TEST_ERROR');
145+
expect(snapshot.isFailed()).toBe(true);
146+
expect(snapshot.unhandledError?.message).toBe('TEST_ERROR');
147+
expect(snapshot.unhandledError?.stepId).toBe('0x002');
148148
expect(snapshot.globalState.message).toBe('(start)');
149149

150150
done();

machine/src/activities/interruption-activity/interruption-activity.spec.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { createActivitySet } from '../../core/activity-set';
22
import { createWorkflowMachineBuilder } from '../../workflow-machine-builder';
3-
import { STATE_INTERRUPTED_ID } from '../../types';
43
import { Definition, Step } from 'sequential-workflow-model';
54
import { createInterruptionActivity } from './interruption-activity';
65

@@ -44,7 +43,9 @@ describe('InterruptionActivity', () => {
4443
interpreter.onDone(() => {
4544
const snapshot = interpreter.getSnapshot();
4645

47-
expect(snapshot.statePath[0]).toBe(STATE_INTERRUPTED_ID);
46+
expect(snapshot.isInterrupted()).toBe(true);
47+
expect(snapshot.isFinished()).toBe(false);
48+
expect(snapshot.isFailed()).toBe(false);
4849
expect(snapshot.globalState.called).toBe(true);
4950

5051
done();

machine/src/activities/loop-activity/loop-activity.spec.ts

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,16 +21,16 @@ function createTaskStep(id: string): Step {
2121

2222
const definition: Definition = {
2323
sequence: [
24-
createTaskStep('0x001'),
24+
createTaskStep('task_a'),
2525
{
26-
id: '0x002',
26+
id: 'loop',
2727
componentType: 'container',
2828
name: 'Loop',
2929
type: 'loop',
3030
properties: {},
31-
sequence: [createTaskStep('0x003')]
31+
sequence: [createTaskStep('task_b')]
3232
} as SequentialStep,
33-
createTaskStep('0x004')
33+
createTaskStep('task_c')
3434
],
3535
properties: {}
3636
};
@@ -73,6 +73,23 @@ const machine = builder.build(definition);
7373

7474
describe('LoopActivity', () => {
7575
it('should iterate', done => {
76+
const expectedRun = [
77+
{ id: 'loop', path: ['MAIN', 'STEP_loop', 'ENTER'] },
78+
{ id: 'loop', path: ['MAIN', 'STEP_loop', 'CONDITION'] },
79+
{
80+
id: 'task_b',
81+
path: ['MAIN', 'STEP_loop', 'LOOP', 'STEP_task_b']
82+
},
83+
{ id: 'loop', path: ['MAIN', 'STEP_loop', 'CONDITION'] },
84+
{
85+
id: 'task_b',
86+
path: ['MAIN', 'STEP_loop', 'LOOP', 'STEP_task_b']
87+
},
88+
{ id: 'loop', path: ['MAIN', 'STEP_loop', 'CONDITION'] },
89+
{ id: 'loop', path: ['MAIN', 'STEP_loop', 'LEAVE'] },
90+
{ id: 'task_c', path: ['MAIN', 'STEP_task_c'] },
91+
{ id: null, path: ['FINISHED'] }
92+
];
7693
const interpreter = machine
7794
.create({
7895
init: () => ({
@@ -81,10 +98,23 @@ describe('LoopActivity', () => {
8198
})
8299
})
83100
.start();
101+
let index = 0;
84102

103+
interpreter.onChange(() => {
104+
const snapshot = interpreter.getSnapshot();
105+
expect(snapshot.getStatePath()).toMatchObject(expectedRun[index].path);
106+
expect(snapshot.tryGetCurrentStepId()).toBe(expectedRun[index].id);
107+
expect(snapshot.isFailed()).toBe(false);
108+
expect(snapshot.isFinished()).toBe(index === 8);
109+
expect(snapshot.isInterrupted()).toBe(false);
110+
index++;
111+
});
85112
interpreter.onDone(() => {
86-
const globalState = interpreter.getSnapshot().globalState;
113+
const snapshot = interpreter.getSnapshot();
114+
const globalState = snapshot.globalState;
87115

116+
expect(index).toBe(9);
117+
expect(snapshot.isFinished()).toBe(true);
88118
expect(globalState.counter).toBe(4);
89119
expect(globalState.trace).toBe('(onEnter)(condition)(condition)(condition)(onLeave)');
90120

machine/src/core/catch-unhandled-error.spec.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { InvokeMeta } from 'xstate';
12
import { MachineContext } from '../types';
23
import { catchUnhandledError } from './catch-unhandled-error';
34

@@ -7,12 +8,16 @@ describe('catchUnhandledError()', () => {
78
activityStates: {},
89
globalState: {}
910
};
11+
const event = { type: 'x' };
12+
const meta = { src: { type: '(machine).MAIN.STEP_0x002.CONDITION:invocation[0]' } } as InvokeMeta;
1013

1114
catchUnhandledError(async () => {
1215
throw new Error('SOME_ERROR');
13-
})(context, { type: 'x' }).catch(e => {
16+
})(context, event, meta).catch(e => {
1417
expect((e as Error).message).toBe('SOME_ERROR');
15-
expect(context.unhandledError).toBe(e);
18+
expect(context.unhandledError?.cause).toBe(e);
19+
expect(context.unhandledError?.message).toBe('SOME_ERROR');
20+
expect(context.unhandledError?.stepId).toBe('0x002');
1621
done();
1722
});
1823
});

machine/src/core/catch-unhandled-error.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
1-
import { EventObject } from 'xstate';
1+
import { EventObject, InvokeMeta } from 'xstate';
22
import { MachineContext } from '../types';
3+
import { MachineUnhandledError } from '../machine-unhandled-error';
4+
import { readMetaPath } from './meta-path-reader';
35

4-
export function catchUnhandledError<TGlobalState>(callback: (context: MachineContext<TGlobalState>, event: EventObject) => Promise<void>) {
5-
return async (context: MachineContext<TGlobalState>, event: EventObject) => {
6+
export function catchUnhandledError<TGlobalState>(
7+
callback: (context: MachineContext<TGlobalState>, event: EventObject, meta: InvokeMeta) => Promise<void>
8+
) {
9+
return async (context: MachineContext<TGlobalState>, event: EventObject, meta: InvokeMeta) => {
610
try {
7-
await callback(context, event);
11+
await callback(context, event, meta);
812
} catch (e) {
9-
context.unhandledError = e;
13+
const message = e instanceof Error ? e.message : String(e);
14+
const stepId = readMetaPath(meta);
15+
context.unhandledError = new MachineUnhandledError(message, e, stepId);
1016
throw e;
1117
}
1218
};
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { InvokeMeta } from 'xstate';
2+
import { readMetaPath } from './meta-path-reader';
3+
4+
describe('readMetaPath()', () => {
5+
it('returns step name', () => {
6+
const meta = {
7+
src: {
8+
type: '(machine).MAIN.STEP_0x002.CONDITION:invocation[0]'
9+
}
10+
} as InvokeMeta;
11+
expect(readMetaPath(meta)).toBe('0x002');
12+
});
13+
14+
it('returns step name', () => {
15+
const meta = {
16+
src: {
17+
type: '(machine).MAIN.STEP_0x001.STEP_0x005.CONDITION:invocation[0]'
18+
}
19+
} as InvokeMeta;
20+
expect(readMetaPath(meta)).toBe('0x005');
21+
});
22+
23+
it('returns step name', () => {
24+
const meta = {
25+
src: {
26+
type: 'STEP_0x2:invocation[0]'
27+
}
28+
} as InvokeMeta;
29+
expect(readMetaPath(meta)).toBe('0x2');
30+
});
31+
32+
it('returns null', () => {
33+
const meta = {
34+
src: {
35+
type: '(machine)'
36+
}
37+
} as InvokeMeta;
38+
expect(readMetaPath(meta)).toBe(null);
39+
});
40+
41+
it('returns null', () => {
42+
const meta = {
43+
src: {
44+
type: 'machine.MAIN'
45+
}
46+
} as InvokeMeta;
47+
expect(readMetaPath(meta)).toBe(null);
48+
});
49+
});

machine/src/core/meta-path-reader.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { InvokeMeta } from 'xstate';
2+
import { STATE_STEP_ID_PREFIX } from './safe-node-id';
3+
4+
export function readMetaPath(meta: InvokeMeta): string | null {
5+
const path = meta.src.type;
6+
let start = path.lastIndexOf(STATE_STEP_ID_PREFIX);
7+
if (start < 0) {
8+
return null;
9+
}
10+
start += STATE_STEP_ID_PREFIX.length;
11+
let end = path.indexOf('.', start);
12+
if (end < 0) {
13+
end = path.indexOf(':', start);
14+
}
15+
return end < 0 ? path.substring(start) : path.substring(start, end);
16+
}

machine/src/core/safe-node-id.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
export const STATE_STEP_ID_PREFIX = 'STEP_';
2+
export const STATE_BRANCH_ID_PREFIX = 'BRANCH_';
3+
14
export function getStepNodeId(stepId: string): string {
2-
return `STEP_${stepId}`;
5+
return STATE_STEP_ID_PREFIX + stepId;
36
}
47

58
export function getBranchNodeId(branchName: string): string {
6-
return `BRANCH_${branchName}`;
9+
return STATE_BRANCH_ID_PREFIX + branchName;
710
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { MachineUnhandledError } from './machine-unhandled-error';
2+
3+
describe('MachineUnhandledError', () => {
4+
it('creates an instance', () => {
5+
const error = new MachineUnhandledError('message', 'cause', '0x12345');
6+
7+
expect(error).toBeInstanceOf(Error);
8+
expect(error).toBeInstanceOf(MachineUnhandledError);
9+
expect(error.message).toBe('message');
10+
expect(error.cause).toBe('cause');
11+
expect(error.stepId).toBe('0x12345');
12+
});
13+
});
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export class MachineUnhandledError extends Error {
2+
public constructor(message: string, public readonly cause: unknown, public readonly stepId: string | null) {
3+
super(message);
4+
}
5+
}

machine/src/types.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { EventObject, Interpreter, StateMachine, StateNodeConfig, StateSchema, Typestate } from 'xstate';
22
import { SequenceNodeBuilder } from './core/sequence-node-builder';
33
import { Definition, Step } from 'sequential-workflow-model';
4+
import { MachineUnhandledError } from './machine-unhandled-error';
45

56
export const STATE_INTERRUPTED_ID = 'INTERRUPTED';
67
export const STATE_INTERRUPTED_TARGET = `#${STATE_INTERRUPTED_ID}`;
@@ -11,8 +12,6 @@ export const STATE_FINISHED_TARGET = `#${STATE_FINISHED_ID}`;
1112
export const STATE_FAILED_ID = 'FAILED';
1213
export const STATE_FAILED_TARGET = `#${STATE_FAILED_ID}`;
1314

14-
export type MachineUnhandledError = unknown;
15-
1615
export interface MachineContext<TGlobalState> {
1716
interrupted?: string;
1817
unhandledError?: MachineUnhandledError;

0 commit comments

Comments
 (0)