@alwatr/fsm 9.32.0 → 9.33.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.
- package/dist/dev/main.js +5 -0
- package/dist/dev/main.js.map +11 -0
- package/dist/facade.d.ts.map +1 -1
- package/dist/fsm-service.d.ts +50 -17
- package/dist/fsm-service.d.ts.map +1 -1
- package/dist/main.js +3 -3
- package/dist/main.js.map +5 -5
- package/dist/type.d.ts +63 -43
- package/dist/type.d.ts.map +1 -1
- package/package.json +14 -10
- package/src/facade.ts +2 -22
- package/src/fsm-service.ts +229 -124
- package/src/type.ts +75 -43
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alwatr/fsm",
|
|
3
|
-
"version": "9.
|
|
3
|
+
"version": "9.33.1",
|
|
4
4
|
"description": "A tiny, type-safe, declarative, and reactive finite state machine (FSM) library for modern TypeScript applications, built on top of Alwatr Signals.",
|
|
5
5
|
"license": "MPL-2.0",
|
|
6
6
|
"author": "S. Ali Mihandoost <ali.mihandoost@gmail.com> (https://ali.mihandoost.com)",
|
|
@@ -15,35 +15,39 @@
|
|
|
15
15
|
"exports": {
|
|
16
16
|
".": {
|
|
17
17
|
"types": "./dist/main.d.ts",
|
|
18
|
+
"development": "./dist/dev/main.js",
|
|
18
19
|
"import": "./dist/main.js",
|
|
19
20
|
"default": "./dist/main.js"
|
|
20
21
|
}
|
|
21
22
|
},
|
|
22
23
|
"sideEffects": false,
|
|
23
24
|
"dependencies": {
|
|
24
|
-
"@alwatr/
|
|
25
|
-
"@alwatr/
|
|
25
|
+
"@alwatr/delay": "9.33.1",
|
|
26
|
+
"@alwatr/logger": "9.33.1",
|
|
27
|
+
"@alwatr/signal": "9.33.1"
|
|
26
28
|
},
|
|
27
29
|
"devDependencies": {
|
|
28
|
-
"@alwatr/nano-build": "9.
|
|
29
|
-
"@alwatr/standard": "9.
|
|
30
|
-
"@alwatr/type-helper": "9.
|
|
30
|
+
"@alwatr/nano-build": "9.33.1",
|
|
31
|
+
"@alwatr/standard": "9.33.0",
|
|
32
|
+
"@alwatr/type-helper": "9.33.1",
|
|
31
33
|
"typescript": "^6.0.3"
|
|
32
34
|
},
|
|
33
35
|
"scripts": {
|
|
34
36
|
"b": "bun run build",
|
|
35
37
|
"build": "bun run build:ts && bun run build:es",
|
|
36
|
-
"build:es": "
|
|
38
|
+
"build:es": "bun run build:es:dev && bun run build:es:prod",
|
|
39
|
+
"build:es:dev": "nano-build --preset=module --outdir=dist/dev src/main.ts",
|
|
40
|
+
"build:es:prod": "NODE_ENV=production nano-build --preset=module --outdir=dist src/main.ts",
|
|
37
41
|
"build:ts": "tsc --build",
|
|
38
42
|
"cl": "bun run clean",
|
|
39
43
|
"clean": "rm -rfv dist *.tsbuildinfo",
|
|
40
44
|
"format": "prettier --write \"src/**/*.ts\"",
|
|
41
45
|
"lint": "eslint src/ --ext .ts",
|
|
42
46
|
"t": "bun run test",
|
|
43
|
-
"test": "
|
|
47
|
+
"test": "bun test",
|
|
44
48
|
"w": "bun run watch",
|
|
45
49
|
"watch": "bun run watch:ts & bun run watch:es",
|
|
46
|
-
"watch:es": "bun run build:es --watch",
|
|
50
|
+
"watch:es": "bun run build:es:dev --watch",
|
|
47
51
|
"watch:ts": "bun run build:ts --watch --preserveWatchOutput"
|
|
48
52
|
},
|
|
49
53
|
"files": [
|
|
@@ -65,5 +69,5 @@
|
|
|
65
69
|
"state",
|
|
66
70
|
"typescript"
|
|
67
71
|
],
|
|
68
|
-
"gitHead": "
|
|
72
|
+
"gitHead": "84fba5b7b428188be17aaaaf062b0b7eaae96555"
|
|
69
73
|
}
|
package/src/facade.ts
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
|
-
import {createPersistentStateSignal, createStateSignal} from '@alwatr/signal';
|
|
2
|
-
|
|
3
1
|
import {FsmService} from './fsm-service.js';
|
|
4
2
|
|
|
5
|
-
import type {MachineEvent,
|
|
3
|
+
import type {MachineEvent, StateMachineConfig} from './type.js';
|
|
6
4
|
|
|
7
5
|
/**
|
|
8
6
|
* A simple and clean factory function for creating an `FsmService` instance.
|
|
@@ -69,23 +67,5 @@ export function createFsmService<
|
|
|
69
67
|
TEvent extends MachineEvent,
|
|
70
68
|
TContext extends Record<string, unknown> = Record<string, never>,
|
|
71
69
|
>(config: StateMachineConfig<TState, TEvent, TContext>): FsmService<TState, TEvent, TContext> {
|
|
72
|
-
|
|
73
|
-
name: config.initial,
|
|
74
|
-
context: config.context,
|
|
75
|
-
};
|
|
76
|
-
|
|
77
|
-
const stateSignal =
|
|
78
|
-
config.persistent ?
|
|
79
|
-
createPersistentStateSignal<MachineState<TState, TContext>>({
|
|
80
|
-
name: `fsm-state-${config.name}`,
|
|
81
|
-
storageKey: config.persistent.storageKey ?? config.name,
|
|
82
|
-
initialValue,
|
|
83
|
-
schemaVersion: config.persistent.schemaVersion,
|
|
84
|
-
})
|
|
85
|
-
: createStateSignal<MachineState<TState, TContext>>({
|
|
86
|
-
name: `fsm-state-${config.name}`,
|
|
87
|
-
initialValue: initialValue,
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
return new FsmService(config, stateSignal);
|
|
70
|
+
return new FsmService(config);
|
|
91
71
|
}
|
package/src/fsm-service.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import type {SingleOrArray} from '@alwatr/type-helper';
|
|
2
1
|
import {createLogger, type AlwatrLogger} from '@alwatr/logger';
|
|
2
|
+
import {queueMicrotask} from '@alwatr/delay';
|
|
3
|
+
import type {SingleOrArray} from '@alwatr/type-helper';
|
|
3
4
|
import {
|
|
4
|
-
|
|
5
|
+
createPersistentStateSignal,
|
|
6
|
+
createStateSignal,
|
|
5
7
|
type StateSignal,
|
|
6
8
|
type PersistentStateSignal,
|
|
7
|
-
EventSignal,
|
|
8
9
|
type IReadonlySignal,
|
|
9
10
|
} from '@alwatr/signal';
|
|
10
11
|
|
|
@@ -26,135 +27,218 @@ export class FsmService<
|
|
|
26
27
|
> {
|
|
27
28
|
protected readonly logger_: AlwatrLogger;
|
|
28
29
|
|
|
29
|
-
/** The private event signal for sending events to the FSM. */
|
|
30
|
-
private readonly eventSignal__: EventSignal<TEvent>;
|
|
31
|
-
|
|
32
30
|
/** The public, read-only state signal. Subscribe to react to state changes. */
|
|
33
31
|
public readonly stateSignal: IReadonlySignal<MachineState<TState, TContext>>;
|
|
34
32
|
|
|
35
|
-
/**
|
|
36
|
-
|
|
33
|
+
/**
|
|
34
|
+
* The FIFO event mailbox. Events are processed strictly in dispatch order.
|
|
35
|
+
*/
|
|
36
|
+
private readonly mailbox__: TEvent[] = [];
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* RTC re-entrancy guard. While `true`, an active loop is draining the mailbox;
|
|
40
|
+
* re-entrant dispatches just enqueue and return.
|
|
41
|
+
*/
|
|
42
|
+
private processing__ = true;
|
|
43
|
+
|
|
44
|
+
/** Set once by `destroy()`. All dispatches after destruction are ignored (and logged). */
|
|
45
|
+
private destroyed__ = false;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Cleanup callbacks for currently active state actors, in spawn order.
|
|
49
|
+
* Executed in REVERSE (LIFO) order on state exit — standard resource semantics
|
|
50
|
+
* (last acquired, first released).
|
|
51
|
+
*/
|
|
52
|
+
private readonly activeActorCleanups__: (() => void)[] = [];
|
|
53
|
+
|
|
54
|
+
private readonly stateSignal__:
|
|
55
|
+
| StateSignal<MachineState<TState, TContext>>
|
|
56
|
+
| PersistentStateSignal<MachineState<TState, TContext>>;
|
|
37
57
|
|
|
38
58
|
constructor(
|
|
39
59
|
protected readonly config_: StateMachineConfig<TState, TEvent, TContext>,
|
|
40
|
-
|
|
41
|
-
| StateSignal<MachineState<TState, TContext>>
|
|
42
|
-
| PersistentStateSignal<MachineState<TState, TContext>>,
|
|
60
|
+
stateSignal?: StateSignal<MachineState<TState, TContext>> | PersistentStateSignal<MachineState<TState, TContext>>,
|
|
43
61
|
) {
|
|
44
62
|
this.logger_ = createLogger(`fsm:${this.config_.name}`);
|
|
45
|
-
this.logger_.logMethodArgs?.('constructor', config_);
|
|
63
|
+
DEV_MODE && this.logger_.logMethodArgs?.('constructor', config_);
|
|
64
|
+
|
|
65
|
+
const initialValue: MachineState<TState, TContext> = {
|
|
66
|
+
name: config_.initial,
|
|
67
|
+
context: config_.context,
|
|
68
|
+
};
|
|
69
|
+
this.stateSignal__ =
|
|
70
|
+
stateSignal
|
|
71
|
+
?? (config_.persistent ?
|
|
72
|
+
createPersistentStateSignal<MachineState<TState, TContext>>({
|
|
73
|
+
name: `fsm-state-${config_.name}`,
|
|
74
|
+
storageKey: config_.persistent.storageKey ?? config_.name,
|
|
75
|
+
initialValue,
|
|
76
|
+
schemaVersion: config_.persistent.schemaVersion,
|
|
77
|
+
})
|
|
78
|
+
: createStateSignal<MachineState<TState, TContext>>({
|
|
79
|
+
name: `fsm-state-${config_.name}`,
|
|
80
|
+
initialValue,
|
|
81
|
+
}));
|
|
46
82
|
|
|
47
83
|
this.stateSignal = this.stateSignal__.asReadonly();
|
|
48
|
-
this.eventSignal__ = createEventSignal<TEvent>({
|
|
49
|
-
name: `fsm-event-${this.config_.name}`,
|
|
50
|
-
});
|
|
51
|
-
this.eventSignal__.subscribe((event) => this.processTransition__(event), {receivePrevious: false});
|
|
52
84
|
|
|
53
|
-
// Execute initial state entry effects and actors.
|
|
54
|
-
this.start_();
|
|
85
|
+
// Execute initial/rehydrated state entry effects and spawn its actors.
|
|
86
|
+
queueMicrotask(() => this.start_());
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Synchronous accessor for the current machine state.
|
|
91
|
+
* Prefer `stateSignal.subscribe()` for reactive consumers; use this getter for
|
|
92
|
+
* imperative checks inside controllers/services.
|
|
93
|
+
*/
|
|
94
|
+
public get state(): MachineState<TState, TContext> {
|
|
95
|
+
return this.stateSignal__.get();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Convenience predicate: returns true if the current finite state matches any
|
|
100
|
+
* of the given names. Sugar for `service.state.name === 'x' || ...`.
|
|
101
|
+
*/
|
|
102
|
+
public matches(...names: TState[]): boolean {
|
|
103
|
+
return names.includes(this.stateSignal__.get().name);
|
|
55
104
|
}
|
|
56
105
|
|
|
57
106
|
/**
|
|
58
107
|
* Dispatches an event to the FSM mailbox.
|
|
59
108
|
*
|
|
109
|
+
* Events are processed with Run-to-Completion semantics: if dispatched while a
|
|
110
|
+
* transition is in flight (re-entrant dispatch from a guard/effect/actor), the
|
|
111
|
+
* event is enqueued and processed deterministically right after the current
|
|
112
|
+
* transition completes — in the same call stack, in FIFO order, with no loss.
|
|
113
|
+
*
|
|
60
114
|
* @param event The event to process.
|
|
61
115
|
*/
|
|
62
116
|
public readonly dispatch = (event: TEvent): void => {
|
|
63
|
-
this.logger_.logMethodArgs?.('dispatch', {event});
|
|
64
|
-
this.
|
|
117
|
+
DEV_MODE && this.logger_.logMethodArgs?.('dispatch', {event});
|
|
118
|
+
this.mailbox__.push(event);
|
|
119
|
+
this.processMailbox__();
|
|
65
120
|
};
|
|
66
121
|
|
|
122
|
+
private processMailbox__(): void {
|
|
123
|
+
// RTC guard: an active loop is already draining the mailbox; it will pick
|
|
124
|
+
// this event up after the current transition finishes.
|
|
125
|
+
if (this.processing__) return;
|
|
126
|
+
DEV_MODE && this.logger_.logMethod?.('processMailbox__');
|
|
127
|
+
if (this.destroyed__) {
|
|
128
|
+
DEV_MODE && this.logger_.incident?.('dispatch', 'dispatch_after_destroy');
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
this.processing__ = true;
|
|
132
|
+
try {
|
|
133
|
+
// Do NOT cache length. New events may be added during processing, and they MUST be processed in the same order (FIFO).
|
|
134
|
+
for (let index = 0; index < this.mailbox__.length; index++) {
|
|
135
|
+
this.processTransition__(this.mailbox__[index]);
|
|
136
|
+
if (this.destroyed__) break;
|
|
137
|
+
}
|
|
138
|
+
} finally {
|
|
139
|
+
this.processing__ = false;
|
|
140
|
+
this.mailbox__.length = 0;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
67
144
|
/**
|
|
68
145
|
* The core FSM logic that processes a single event and transitions the machine to a new state.
|
|
69
|
-
* This
|
|
146
|
+
* This step is atomic: exit effects -> assigners -> state commit -> entry effects -> actors.
|
|
70
147
|
*
|
|
71
148
|
* @param event The event to process.
|
|
72
149
|
*/
|
|
73
150
|
private processTransition__(event: TEvent): void {
|
|
74
151
|
const currentState = this.stateSignal__.get();
|
|
75
|
-
this.logger_.logMethodArgs?.('processTransition__', {state: currentState.name, event});
|
|
152
|
+
DEV_MODE && this.logger_.logMethodArgs?.('processTransition__', {state: currentState.name, event});
|
|
76
153
|
|
|
77
|
-
const transition = this.findTransition__(event, currentState
|
|
154
|
+
const transition = this.findTransition__(event, currentState);
|
|
78
155
|
|
|
79
156
|
if (!transition) {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
157
|
+
DEV_MODE
|
|
158
|
+
&& this.logger_.incident?.('processTransition__', 'ignored_event', 'No valid transition found for event', {
|
|
159
|
+
state: currentState.name,
|
|
160
|
+
event,
|
|
161
|
+
});
|
|
84
162
|
return; // Event ignored, no transition occurs.
|
|
85
163
|
}
|
|
86
164
|
|
|
87
165
|
const targetStateName = transition.target ?? currentState.name;
|
|
88
166
|
const isExternalTransition = transition.target !== undefined;
|
|
89
167
|
|
|
90
|
-
// 1.
|
|
168
|
+
// 1. External transition: run exit effects (with the OLD context, per SCXML semantics) and tear down the current state's actors.
|
|
91
169
|
if (isExternalTransition) {
|
|
92
170
|
this.executeEffects__(event, currentState.context, this.config_.states[currentState.name]?.exit);
|
|
93
171
|
this.cleanupActors__();
|
|
94
172
|
}
|
|
95
173
|
|
|
96
|
-
// 2. Apply assigners to compute the next context
|
|
97
|
-
const nextContext = this.applyAssigners__(event, currentState.context, transition.
|
|
174
|
+
// 2. Apply assigners to compute the next context (pure, atomic).
|
|
175
|
+
const nextContext = this.applyAssigners__(event, currentState.context, transition.assigner);
|
|
98
176
|
|
|
99
|
-
// 3.
|
|
177
|
+
// 3. Commit the new state, notifying all subscribers (async via signal layer).
|
|
100
178
|
const nextState: MachineState<TState, TContext> = {
|
|
101
179
|
name: targetStateName,
|
|
102
180
|
context: nextContext,
|
|
103
181
|
};
|
|
104
|
-
|
|
105
|
-
// 4. Set the new state, notifying all subscribers.
|
|
106
182
|
this.stateSignal__.set(nextState);
|
|
107
183
|
|
|
108
|
-
//
|
|
184
|
+
// 4. External transition: run entry effects (with the NEW context) and spawn the target state's actors.
|
|
109
185
|
if (isExternalTransition) {
|
|
110
186
|
this.executeEffects__(event, nextState.context, this.config_.states[nextState.name]?.entry);
|
|
111
|
-
this.spawnActors__(event, nextState.context, this.config_.states[nextState.name]?.
|
|
187
|
+
this.spawnActors__(event, nextState.context, this.config_.states[nextState.name]?.actor);
|
|
112
188
|
}
|
|
113
189
|
}
|
|
114
190
|
|
|
115
191
|
/**
|
|
116
|
-
* Finds the first valid transition for the given event
|
|
192
|
+
* Finds the first valid transition for the given event by evaluating guards in declaration order. A guard-less transition acts as an unconditional fallback.
|
|
117
193
|
*
|
|
118
194
|
* @param event The triggering event.
|
|
119
|
-
* @param
|
|
195
|
+
* @param currentState The current state of the machine.
|
|
120
196
|
* @returns The first matching transition or `undefined` if none are found.
|
|
121
197
|
*/
|
|
122
198
|
private findTransition__(
|
|
123
199
|
event: TEvent,
|
|
124
|
-
|
|
200
|
+
currentState: MachineState<TState, TContext>,
|
|
125
201
|
): Transition<TState, TEvent, TContext> | undefined {
|
|
126
|
-
this.logger_.logMethod?.('findTransition__');
|
|
202
|
+
DEV_MODE && this.logger_.logMethod?.('findTransition__');
|
|
127
203
|
|
|
128
|
-
const
|
|
129
|
-
const currentStateConfig = this.config_.states[currentStateName];
|
|
204
|
+
const currentStateConfig = this.config_.states[currentState.name];
|
|
130
205
|
const transitions = currentStateConfig?.on?.[event.type as TEvent['type']] as
|
|
131
206
|
| SingleOrArray<Transition<TState, TEvent, TContext>>
|
|
132
207
|
| undefined;
|
|
133
208
|
|
|
134
209
|
if (!transitions) return undefined;
|
|
135
210
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
for (let index = 0; index < transitionsArray.length; index++) {
|
|
139
|
-
const transition = transitionsArray[index];
|
|
140
|
-
if (!transition.guard) return transition;
|
|
211
|
+
if (!Array.isArray(transitions)) {
|
|
212
|
+
if (!transitions.guard) return transitions; // Unconditional fallback branch.
|
|
141
213
|
try {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
214
|
+
if (transitions.guard(event, currentState.context)) {
|
|
215
|
+
return transitions;
|
|
216
|
+
}
|
|
217
|
+
} catch (error) {
|
|
218
|
+
this.logger_.error('findTransition__', 'guard_failed', error, {
|
|
219
|
+
state: currentState.name,
|
|
145
220
|
eventType: event.type,
|
|
146
|
-
transitionIndex: index,
|
|
147
|
-
guard: transition.guard.name || 'anonymous',
|
|
148
|
-
result: guardMet,
|
|
149
221
|
});
|
|
150
|
-
|
|
222
|
+
}
|
|
223
|
+
return undefined;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// else if transitions is an array
|
|
227
|
+
|
|
228
|
+
for (let index = 0; index < transitions.length; index++) {
|
|
229
|
+
const transition = transitions[index];
|
|
230
|
+
if (!transition.guard) return transition; // Unconditional fallback branch.
|
|
231
|
+
try {
|
|
232
|
+
if (transition.guard(event, currentState.context)) {
|
|
233
|
+
return transition;
|
|
234
|
+
}
|
|
151
235
|
} catch (error) {
|
|
152
236
|
this.logger_.error('findTransition__', 'guard_failed', error, {
|
|
153
|
-
state:
|
|
237
|
+
state: currentState.name,
|
|
154
238
|
eventType: event.type,
|
|
155
|
-
|
|
156
|
-
guard: transition.guard.name || 'anonymous',
|
|
239
|
+
index,
|
|
157
240
|
});
|
|
241
|
+
// Treated as guard === false: continue evaluating the next branch.
|
|
158
242
|
}
|
|
159
243
|
}
|
|
160
244
|
|
|
@@ -162,7 +246,7 @@ export class FsmService<
|
|
|
162
246
|
}
|
|
163
247
|
|
|
164
248
|
/**
|
|
165
|
-
* Sequentially executes a list of effects (side-effects).
|
|
249
|
+
* Sequentially executes a list of synchronous effects (side-effects).
|
|
166
250
|
* Errors are caught and logged without stopping the FSM.
|
|
167
251
|
*
|
|
168
252
|
* @param event The event that triggered these effects.
|
|
@@ -175,41 +259,45 @@ export class FsmService<
|
|
|
175
259
|
effects?: SingleOrArray<Effect<TEvent, TContext>>,
|
|
176
260
|
): void {
|
|
177
261
|
if (!effects) {
|
|
178
|
-
this.logger_.
|
|
262
|
+
DEV_MODE && this.logger_.logMethod?.('executeEffects__.skipped');
|
|
179
263
|
return;
|
|
180
264
|
}
|
|
181
|
-
const effectsArray = Array.isArray(effects) ? effects : [effects];
|
|
182
265
|
|
|
183
|
-
this.logger_.
|
|
266
|
+
DEV_MODE && this.logger_.logMethod?.('executeEffects__');
|
|
184
267
|
|
|
185
|
-
|
|
268
|
+
if (!Array.isArray(effects)) {
|
|
186
269
|
try {
|
|
187
|
-
|
|
188
|
-
if (result instanceof Promise) {
|
|
189
|
-
result.catch((error) => {
|
|
190
|
-
this.logger_.error('executeEffects__', 'effect_failed', error, {
|
|
191
|
-
effect: effect.name || 'anonymous',
|
|
192
|
-
state: this.stateSignal__.get().name,
|
|
193
|
-
event,
|
|
194
|
-
context,
|
|
195
|
-
});
|
|
196
|
-
});
|
|
197
|
-
}
|
|
270
|
+
effects(event, context);
|
|
198
271
|
} catch (error) {
|
|
199
272
|
this.logger_.error('executeEffects__', 'effect_failed', error, {
|
|
200
|
-
effect: effect.name || 'anonymous',
|
|
201
|
-
state: this.stateSignal__.get().name,
|
|
202
273
|
event,
|
|
203
274
|
context,
|
|
204
275
|
});
|
|
205
276
|
}
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// else if effects is an array
|
|
281
|
+
|
|
282
|
+
for (let index = 0; index < effects.length; index++) {
|
|
283
|
+
const effect = effects[index];
|
|
284
|
+
try {
|
|
285
|
+
effect(event, context);
|
|
286
|
+
} catch (error) {
|
|
287
|
+
this.logger_.error('executeEffects__', 'effect_failed', error, {
|
|
288
|
+
event,
|
|
289
|
+
context,
|
|
290
|
+
index,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
206
293
|
}
|
|
207
294
|
}
|
|
208
295
|
|
|
209
296
|
/**
|
|
210
297
|
* Applies all assigner functions to the context to produce a new, updated context.
|
|
211
|
-
*
|
|
212
|
-
*
|
|
298
|
+
*
|
|
299
|
+
* This process is atomic (all-or-nothing): if any assigner throws, the original
|
|
300
|
+
* context is returned and all updates are discarded.
|
|
213
301
|
*
|
|
214
302
|
* @param event The event that triggered the transition.
|
|
215
303
|
* @param context The current context.
|
|
@@ -222,49 +310,55 @@ export class FsmService<
|
|
|
222
310
|
assigners?: SingleOrArray<Assigner<TEvent, TContext>>,
|
|
223
311
|
): TContext {
|
|
224
312
|
if (!assigners) {
|
|
225
|
-
this.logger_.
|
|
313
|
+
DEV_MODE && this.logger_.logMethod?.('applyAssigners__.skipped');
|
|
226
314
|
return context;
|
|
227
315
|
}
|
|
228
316
|
|
|
229
|
-
|
|
317
|
+
DEV_MODE && this.logger_.logMethod?.('applyAssigners__');
|
|
230
318
|
|
|
231
|
-
|
|
319
|
+
if (!Array.isArray(assigners)) {
|
|
320
|
+
try {
|
|
321
|
+
return assigners(event, context) ?? context;
|
|
322
|
+
} catch (error) {
|
|
323
|
+
this.logger_.error('applyAssigners__', 'assigner_failed_atomic', error, {event, context});
|
|
324
|
+
}
|
|
325
|
+
return context;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// else if assigners is an array
|
|
232
329
|
|
|
233
330
|
try {
|
|
234
331
|
let accContext = context;
|
|
235
|
-
for (
|
|
236
|
-
const
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
{event, accContext},
|
|
240
|
-
nextContext,
|
|
241
|
-
);
|
|
242
|
-
if (nextContext !== undefined && nextContext !== null) {
|
|
332
|
+
for (let index = 0; index < assigners.length; index++) {
|
|
333
|
+
const assigner = assigners[index];
|
|
334
|
+
const nextContext = assigner(event, accContext);
|
|
335
|
+
if (nextContext) {
|
|
243
336
|
accContext = nextContext;
|
|
244
337
|
}
|
|
245
338
|
}
|
|
246
339
|
return accContext;
|
|
247
340
|
} catch (error) {
|
|
248
|
-
this.logger_.error('applyAssigners__', 'assigner_failed_atomic', error, {
|
|
249
|
-
event,
|
|
250
|
-
context, // Log the original context for debugging.
|
|
251
|
-
});
|
|
341
|
+
this.logger_.error('applyAssigners__', 'assigner_failed_atomic', error, {event, context});
|
|
252
342
|
// On ANY failure, discard all changes and return the original context.
|
|
253
343
|
return context;
|
|
254
344
|
}
|
|
255
345
|
}
|
|
256
346
|
|
|
257
347
|
/**
|
|
258
|
-
* Starts the FSM by executing the entry effects and spawning the actors
|
|
259
|
-
*
|
|
348
|
+
* Starts the FSM by executing the entry effects and spawning the actors of the
|
|
349
|
+
* initial (or rehydrated) state, using the synthetic `{type: '__init__'}` event.
|
|
260
350
|
*/
|
|
261
351
|
protected start_(): void {
|
|
262
|
-
if (this.
|
|
263
|
-
this.logger_.logMethod?.('start_');
|
|
352
|
+
if (this.destroyed__) return;
|
|
353
|
+
DEV_MODE && this.logger_.logMethod?.('start_');
|
|
264
354
|
const currentState = this.stateSignal__.get();
|
|
265
355
|
const initEvent = {type: '__init__'} as unknown as TEvent;
|
|
266
356
|
this.executeEffects__(initEvent, currentState.context, this.config_.states[currentState.name]?.entry);
|
|
267
|
-
this.spawnActors__(initEvent, currentState.context, this.config_.states[currentState.name]?.
|
|
357
|
+
this.spawnActors__(initEvent, currentState.context, this.config_.states[currentState.name]?.actor);
|
|
358
|
+
this.processing__ = false; // Allow processing of dispatched events after the initial setup is complete.
|
|
359
|
+
if (this.mailbox__.length > 0) {
|
|
360
|
+
this.processMailbox__(); // Process any events that were dispatched during the initial setup.
|
|
361
|
+
}
|
|
268
362
|
}
|
|
269
363
|
|
|
270
364
|
/**
|
|
@@ -276,57 +370,68 @@ export class FsmService<
|
|
|
276
370
|
actors?: SingleOrArray<Actor<TEvent, TContext>>,
|
|
277
371
|
): void {
|
|
278
372
|
if (!actors) {
|
|
279
|
-
this.logger_.
|
|
373
|
+
DEV_MODE && this.logger_.logMethod?.('spawnActors__.skipped');
|
|
280
374
|
return;
|
|
281
375
|
}
|
|
282
|
-
const actorsArray = Array.isArray(actors) ? actors : [actors];
|
|
283
376
|
|
|
284
|
-
this.logger_.
|
|
377
|
+
DEV_MODE && this.logger_.logMethod?.('spawnActors__');
|
|
285
378
|
|
|
286
|
-
|
|
379
|
+
if (!Array.isArray(actors)) {
|
|
287
380
|
try {
|
|
288
|
-
const cleanup =
|
|
289
|
-
event,
|
|
290
|
-
context,
|
|
291
|
-
dispatch: this.dispatch,
|
|
292
|
-
});
|
|
381
|
+
const cleanup = actors(context, this.dispatch);
|
|
293
382
|
if (typeof cleanup === 'function') {
|
|
294
|
-
this.activeActorCleanups__.
|
|
383
|
+
this.activeActorCleanups__.push(cleanup);
|
|
295
384
|
}
|
|
296
385
|
} catch (error) {
|
|
297
|
-
this.logger_.error('spawnActors__', 'actor_failed', error, {
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
386
|
+
this.logger_.error('spawnActors__', 'actor_failed', error, {event, context});
|
|
387
|
+
}
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// else if actors is an array
|
|
392
|
+
|
|
393
|
+
for (let index = 0; index < actors.length; index++) {
|
|
394
|
+
const actor = actors[index];
|
|
395
|
+
try {
|
|
396
|
+
const cleanup = actor(context, this.dispatch);
|
|
397
|
+
if (typeof cleanup === 'function') {
|
|
398
|
+
this.activeActorCleanups__.push(cleanup);
|
|
399
|
+
}
|
|
400
|
+
} catch (error) {
|
|
401
|
+
this.logger_.error('spawnActors__', 'actor_failed', error, {event, context, index});
|
|
303
402
|
}
|
|
304
403
|
}
|
|
305
404
|
}
|
|
306
405
|
|
|
307
406
|
/**
|
|
308
|
-
* Cleans up (destroys) all currently active state actors.
|
|
407
|
+
* Cleans up (destroys) all currently active state actors in REVERSE (LIFO) spawn order — standard resource-release semantics.
|
|
309
408
|
*/
|
|
310
409
|
private cleanupActors__(): void {
|
|
311
|
-
this.logger_.logMethodArgs?.('cleanupActors__', {count: this.activeActorCleanups__.
|
|
312
|
-
for (
|
|
410
|
+
DEV_MODE && this.logger_.logMethodArgs?.('cleanupActors__', {count: this.activeActorCleanups__.length});
|
|
411
|
+
for (let index = this.activeActorCleanups__.length - 1; index >= 0; index--) {
|
|
313
412
|
try {
|
|
314
|
-
|
|
413
|
+
this.activeActorCleanups__[index]();
|
|
315
414
|
} catch (error) {
|
|
316
|
-
this.logger_.error('cleanupActors__', 'cleanup_failed', error);
|
|
415
|
+
this.logger_.error('cleanupActors__', 'cleanup_failed', error, {index});
|
|
317
416
|
}
|
|
318
417
|
}
|
|
319
|
-
this.activeActorCleanups__.
|
|
418
|
+
this.activeActorCleanups__.length = 0;
|
|
320
419
|
}
|
|
321
420
|
|
|
322
421
|
/**
|
|
323
|
-
* Destroys the service, cleaning up
|
|
324
|
-
*
|
|
422
|
+
* Destroys the service, cleaning up actors, the mailbox, and owned signals to
|
|
423
|
+
* prevent memory leaks. Idempotent — safe to call multiple times.
|
|
424
|
+
*
|
|
425
|
+
* @param destroyState If `true` (default), also destroys the state signal, preventing any future subscriptions or updates. Set to `false` to preserve the last state value for late subscribers even after destruction.
|
|
325
426
|
*/
|
|
326
|
-
public destroy(): void {
|
|
327
|
-
this.
|
|
427
|
+
public destroy(destroyState = true): void {
|
|
428
|
+
if (this.destroyed__) return;
|
|
429
|
+
DEV_MODE && this.logger_.logMethod?.('destroy');
|
|
430
|
+
this.destroyed__ = true;
|
|
431
|
+
this.mailbox__.length = 0;
|
|
328
432
|
this.cleanupActors__();
|
|
329
|
-
|
|
330
|
-
|
|
433
|
+
if (destroyState) {
|
|
434
|
+
this.stateSignal__.destroy();
|
|
435
|
+
}
|
|
331
436
|
}
|
|
332
437
|
}
|