@alwatr/fsm 9.31.0 → 9.33.0
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/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 +6 -5
- package/src/facade.ts +2 -22
- package/src/fsm-service.ts +218 -114
- package/src/type.ts +75 -43
package/dist/facade.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"facade.d.ts","sourceRoot":"","sources":["../src/facade.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"facade.d.ts","sourceRoot":"","sources":["../src/facade.ts"],"names":[],"mappings":"AAAA,OAAO,EAAC,UAAU,EAAC,MAAM,kBAAkB,CAAC;AAE5C,OAAO,KAAK,EAAC,YAAY,EAAE,kBAAkB,EAAC,MAAM,WAAW,CAAC;AAEhE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2DG;AACH,wBAAgB,gBAAgB,CAC9B,MAAM,SAAS,MAAM,EACrB,MAAM,SAAS,YAAY,EAC3B,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,EAChE,MAAM,EAAE,kBAAkB,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,CAAC,GAAG,UAAU,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,CAAC,CAE5F"}
|
package/dist/fsm-service.d.ts
CHANGED
|
@@ -12,38 +12,68 @@ import type { StateMachineConfig, MachineState, MachineEvent } from './type.js';
|
|
|
12
12
|
*/
|
|
13
13
|
export declare class FsmService<TState extends string, TEvent extends MachineEvent, TContext extends Record<string, unknown> = Record<string, never>> {
|
|
14
14
|
protected readonly config_: StateMachineConfig<TState, TEvent, TContext>;
|
|
15
|
-
private readonly stateSignal__;
|
|
16
15
|
protected readonly logger_: AlwatrLogger;
|
|
17
|
-
/** The private event signal for sending events to the FSM. */
|
|
18
|
-
private readonly eventSignal__;
|
|
19
16
|
/** The public, read-only state signal. Subscribe to react to state changes. */
|
|
20
17
|
readonly stateSignal: IReadonlySignal<MachineState<TState, TContext>>;
|
|
21
|
-
/**
|
|
18
|
+
/**
|
|
19
|
+
* The FIFO event mailbox. Events are processed strictly in dispatch order.
|
|
20
|
+
*/
|
|
21
|
+
private readonly mailbox__;
|
|
22
|
+
/**
|
|
23
|
+
* RTC re-entrancy guard. While `true`, an active loop is draining the mailbox;
|
|
24
|
+
* re-entrant dispatches just enqueue and return.
|
|
25
|
+
*/
|
|
26
|
+
private processing__;
|
|
27
|
+
/** Set once by `destroy()`. All dispatches after destruction are ignored (and logged). */
|
|
28
|
+
private destroyed__;
|
|
29
|
+
/**
|
|
30
|
+
* Cleanup callbacks for currently active state actors, in spawn order.
|
|
31
|
+
* Executed in REVERSE (LIFO) order on state exit — standard resource semantics
|
|
32
|
+
* (last acquired, first released).
|
|
33
|
+
*/
|
|
22
34
|
private readonly activeActorCleanups__;
|
|
23
|
-
|
|
35
|
+
private readonly stateSignal__;
|
|
36
|
+
constructor(config_: StateMachineConfig<TState, TEvent, TContext>, stateSignal?: StateSignal<MachineState<TState, TContext>> | PersistentStateSignal<MachineState<TState, TContext>>);
|
|
37
|
+
/**
|
|
38
|
+
* Synchronous accessor for the current machine state.
|
|
39
|
+
* Prefer `stateSignal.subscribe()` for reactive consumers; use this getter for
|
|
40
|
+
* imperative checks inside controllers/services.
|
|
41
|
+
*/
|
|
42
|
+
get state(): MachineState<TState, TContext>;
|
|
43
|
+
/**
|
|
44
|
+
* Convenience predicate: returns true if the current finite state matches any
|
|
45
|
+
* of the given names. Sugar for `service.state.name === 'x' || ...`.
|
|
46
|
+
*/
|
|
47
|
+
matches(...names: TState[]): boolean;
|
|
24
48
|
/**
|
|
25
49
|
* Dispatches an event to the FSM mailbox.
|
|
26
50
|
*
|
|
51
|
+
* Events are processed with Run-to-Completion semantics: if dispatched while a
|
|
52
|
+
* transition is in flight (re-entrant dispatch from a guard/effect/actor), the
|
|
53
|
+
* event is enqueued and processed deterministically right after the current
|
|
54
|
+
* transition completes — in the same call stack, in FIFO order, with no loss.
|
|
55
|
+
*
|
|
27
56
|
* @param event The event to process.
|
|
28
57
|
*/
|
|
29
58
|
readonly dispatch: (event: TEvent) => void;
|
|
59
|
+
private processMailbox__;
|
|
30
60
|
/**
|
|
31
61
|
* The core FSM logic that processes a single event and transitions the machine to a new state.
|
|
32
|
-
* This
|
|
62
|
+
* This step is atomic: exit effects -> assigners -> state commit -> entry effects -> actors.
|
|
33
63
|
*
|
|
34
64
|
* @param event The event to process.
|
|
35
65
|
*/
|
|
36
66
|
private processTransition__;
|
|
37
67
|
/**
|
|
38
|
-
* Finds the first valid transition for the given event
|
|
68
|
+
* Finds the first valid transition for the given event by evaluating guards in declaration order. A guard-less transition acts as an unconditional fallback.
|
|
39
69
|
*
|
|
40
70
|
* @param event The triggering event.
|
|
41
|
-
* @param
|
|
71
|
+
* @param currentState The current state of the machine.
|
|
42
72
|
* @returns The first matching transition or `undefined` if none are found.
|
|
43
73
|
*/
|
|
44
74
|
private findTransition__;
|
|
45
75
|
/**
|
|
46
|
-
* Sequentially executes a list of effects (side-effects).
|
|
76
|
+
* Sequentially executes a list of synchronous effects (side-effects).
|
|
47
77
|
* Errors are caught and logged without stopping the FSM.
|
|
48
78
|
*
|
|
49
79
|
* @param event The event that triggered these effects.
|
|
@@ -53,8 +83,9 @@ export declare class FsmService<TState extends string, TEvent extends MachineEve
|
|
|
53
83
|
private executeEffects__;
|
|
54
84
|
/**
|
|
55
85
|
* Applies all assigner functions to the context to produce a new, updated context.
|
|
56
|
-
*
|
|
57
|
-
*
|
|
86
|
+
*
|
|
87
|
+
* This process is atomic (all-or-nothing): if any assigner throws, the original
|
|
88
|
+
* context is returned and all updates are discarded.
|
|
58
89
|
*
|
|
59
90
|
* @param event The event that triggered the transition.
|
|
60
91
|
* @param context The current context.
|
|
@@ -63,8 +94,8 @@ export declare class FsmService<TState extends string, TEvent extends MachineEve
|
|
|
63
94
|
*/
|
|
64
95
|
private applyAssigners__;
|
|
65
96
|
/**
|
|
66
|
-
* Starts the FSM by executing the entry effects and spawning the actors
|
|
67
|
-
*
|
|
97
|
+
* Starts the FSM by executing the entry effects and spawning the actors of the
|
|
98
|
+
* initial (or rehydrated) state, using the synthetic `{type: '__init__'}` event.
|
|
68
99
|
*/
|
|
69
100
|
protected start_(): void;
|
|
70
101
|
/**
|
|
@@ -72,13 +103,15 @@ export declare class FsmService<TState extends string, TEvent extends MachineEve
|
|
|
72
103
|
*/
|
|
73
104
|
private spawnActors__;
|
|
74
105
|
/**
|
|
75
|
-
* Cleans up (destroys) all currently active state actors.
|
|
106
|
+
* Cleans up (destroys) all currently active state actors in REVERSE (LIFO) spawn order — standard resource-release semantics.
|
|
76
107
|
*/
|
|
77
108
|
private cleanupActors__;
|
|
78
109
|
/**
|
|
79
|
-
* Destroys the service, cleaning up
|
|
80
|
-
*
|
|
110
|
+
* Destroys the service, cleaning up actors, the mailbox, and owned signals to
|
|
111
|
+
* prevent memory leaks. Idempotent — safe to call multiple times.
|
|
112
|
+
*
|
|
113
|
+
* @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.
|
|
81
114
|
*/
|
|
82
|
-
destroy(): void;
|
|
115
|
+
destroy(destroyState?: boolean): void;
|
|
83
116
|
}
|
|
84
117
|
//# sourceMappingURL=fsm-service.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"fsm-service.d.ts","sourceRoot":"","sources":["../src/fsm-service.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"fsm-service.d.ts","sourceRoot":"","sources":["../src/fsm-service.ts"],"names":[],"mappings":"AAAA,OAAO,EAAe,KAAK,YAAY,EAAC,MAAM,gBAAgB,CAAC;AAG/D,OAAO,EAGL,KAAK,WAAW,EAChB,KAAK,qBAAqB,EAC1B,KAAK,eAAe,EACrB,MAAM,gBAAgB,CAAC;AAExB,OAAO,KAAK,EAAC,kBAAkB,EAAE,YAAY,EAAE,YAAY,EAAsC,MAAM,WAAW,CAAC;AAEnH;;;;;;;;GAQG;AACH,qBAAa,UAAU,CACrB,MAAM,SAAS,MAAM,EACrB,MAAM,SAAS,YAAY,EAC3B,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC;IAiC9D,SAAS,CAAC,QAAQ,CAAC,OAAO,EAAE,kBAAkB,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,CAAC;IA/B1E,SAAS,CAAC,QAAQ,CAAC,OAAO,EAAE,YAAY,CAAC;IAEzC,+EAA+E;IAC/E,SAAgB,WAAW,EAAE,eAAe,CAAC,YAAY,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC,CAAC;IAE7E;;OAEG;IACH,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAgB;IAE1C;;;OAGG;IACH,OAAO,CAAC,YAAY,CAAQ;IAE5B,0FAA0F;IAC1F,OAAO,CAAC,WAAW,CAAS;IAE5B;;;;OAIG;IACH,OAAO,CAAC,QAAQ,CAAC,qBAAqB,CAAsB;IAE5D,OAAO,CAAC,QAAQ,CAAC,aAAa,CAE4B;gBAGrC,OAAO,EAAE,kBAAkB,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,CAAC,EACxE,WAAW,CAAC,EAAE,WAAW,CAAC,YAAY,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC,GAAG,qBAAqB,CAAC,YAAY,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC;IA6BnH;;;;OAIG;IACH,IAAW,KAAK,IAAI,YAAY,CAAC,MAAM,EAAE,QAAQ,CAAC,CAEjD;IAED;;;OAGG;IACI,OAAO,CAAC,GAAG,KAAK,EAAE,MAAM,EAAE,GAAG,OAAO;IAI3C;;;;;;;;;OASG;IACH,SAAgB,QAAQ,GAAI,OAAO,MAAM,KAAG,IAAI,CAI9C;IAEF,OAAO,CAAC,gBAAgB;IAsBxB;;;;;OAKG;IACH,OAAO,CAAC,mBAAmB;IAwC3B;;;;;;OAMG;IACH,OAAO,CAAC,gBAAgB;IAkDxB;;;;;;;OAOG;IACH,OAAO,CAAC,gBAAgB;IAwCxB;;;;;;;;;;OAUG;IACH,OAAO,CAAC,gBAAgB;IAwCxB;;;OAGG;IACH,SAAS,CAAC,MAAM,IAAI,IAAI;IAaxB;;OAEG;IACH,OAAO,CAAC,aAAa;IAuCrB;;OAEG;IACH,OAAO,CAAC,eAAe;IAYvB;;;;;OAKG;IACI,OAAO,CAAC,YAAY,UAAO,GAAG,IAAI;CAU1C"}
|
package/dist/main.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
/* 📦 @alwatr/fsm v9.
|
|
2
|
-
import{
|
|
1
|
+
/* 📦 @alwatr/fsm v9.33.0 */
|
|
2
|
+
import{createLogger as j}from"@alwatr/logger";import{queueMicrotask as q}from"@alwatr/delay";import{createPersistentStateSignal as P,createStateSignal as U}from"@alwatr/signal";class Z{config_;logger_;stateSignal;mailbox__=[];processing__=!0;destroyed__=!1;activeActorCleanups__=[];stateSignal__;constructor(z,J){this.config_=z;this.logger_=j(`fsm:${this.config_.name}`),this.logger_.logMethodArgs?.("constructor",z);let Q={name:z.initial,context:z.context};this.stateSignal__=J??(z.persistent?P({name:`fsm-state-${z.name}`,storageKey:z.persistent.storageKey??z.name,initialValue:Q,schemaVersion:z.persistent.schemaVersion}):U({name:`fsm-state-${z.name}`,initialValue:Q})),this.stateSignal=this.stateSignal__.asReadonly(),q(()=>this.start_())}get state(){return this.stateSignal__.get()}matches(...z){return z.includes(this.stateSignal__.get().name)}dispatch=(z)=>{this.logger_.logMethodArgs?.("dispatch",{event:z}),this.mailbox__.push(z),this.processMailbox__()};processMailbox__(){if(this.processing__)return;if(this.logger_.logMethod?.("processMailbox__"),this.destroyed__){this.logger_.incident?.("dispatch","dispatch_after_destroy",{event});return}this.processing__=!0;try{for(let z=0;z<this.mailbox__.length;z++)if(this.processTransition__(this.mailbox__[z]),this.destroyed__)break}finally{this.processing__=!1,this.mailbox__.length=0}}processTransition__(z){let J=this.stateSignal__.get();this.logger_.logMethodArgs?.("processTransition__",{state:J.name,event:z});let Q=this.findTransition__(z,J);if(!Q){this.logger_.incident?.("processTransition__","ignored_event","No valid transition found for event",{state:J.name,event:z});return}let K=Q.target??J.name,W=Q.target!==void 0;if(W)this.executeEffects__(z,J.context,this.config_.states[J.name]?.exit),this.cleanupActors__();let X=this.applyAssigners__(z,J.context,Q.assigner),Y={name:K,context:X};if(this.stateSignal__.set(Y),W)this.executeEffects__(z,Y.context,this.config_.states[Y.name]?.entry),this.spawnActors__(z,Y.context,this.config_.states[Y.name]?.actor)}findTransition__(z,J){this.logger_.logMethod?.("findTransition__");let K=this.config_.states[J.name]?.on?.[z.type];if(!K)return;if(!Array.isArray(K)){if(!K.guard)return K;try{if(K.guard(z,J.context))return K}catch(W){this.logger_.error("findTransition__","guard_failed",W,{state:J.name,eventType:z.type})}return}for(let W=0;W<K.length;W++){let X=K[W];if(!X.guard)return X;try{if(X.guard(z,J.context))return X}catch(Y){this.logger_.error("findTransition__","guard_failed",Y,{state:J.name,eventType:z.type,index:W})}}return}executeEffects__(z,J,Q){if(!Q){this.logger_.logMethod?.("executeEffects__.skipped");return}if(this.logger_.logMethod?.("executeEffects__"),!Array.isArray(Q)){try{Q(z,J)}catch(K){this.logger_.error("executeEffects__","effect_failed",K,{event:z,context:J})}return}for(let K=0;K<Q.length;K++){let W=Q[K];try{W(z,J)}catch(X){this.logger_.error("executeEffects__","effect_failed",X,{event:z,context:J,index:K})}}}applyAssigners__(z,J,Q){if(!Q)return this.logger_.logMethod?.("applyAssigners__.skipped"),J;if(this.logger_.logMethod?.("applyAssigners__"),!Array.isArray(Q)){try{return Q(z,J)??J}catch(K){this.logger_.error("applyAssigners__","assigner_failed_atomic",K,{event:z,context:J})}return J}try{let K=J;for(let W=0;W<Q.length;W++){let X=Q[W],Y=X(z,K);if(Y)K=Y}return K}catch(K){return this.logger_.error("applyAssigners__","assigner_failed_atomic",K,{event:z,context:J}),J}}start_(){if(this.destroyed__)return;this.logger_.logMethod?.("start_");let z=this.stateSignal__.get(),J={type:"__init__"};if(this.executeEffects__(J,z.context,this.config_.states[z.name]?.entry),this.spawnActors__(J,z.context,this.config_.states[z.name]?.actor),this.processing__=!1,this.mailbox__.length>0)this.processMailbox__()}spawnActors__(z,J,Q){if(!Q){this.logger_.logMethod?.("spawnActors__.skipped");return}if(this.logger_.logMethod?.("spawnActors__"),!Array.isArray(Q)){try{let K=Q(J,this.dispatch);if(typeof K==="function")this.activeActorCleanups__.push(K)}catch(K){this.logger_.error("spawnActors__","actor_failed",K,{event:z,context:J})}return}for(let K=0;K<Q.length;K++){let W=Q[K];try{let X=W(J,this.dispatch);if(typeof X==="function")this.activeActorCleanups__.push(X)}catch(X){this.logger_.error("spawnActors__","actor_failed",X,{event:z,context:J,index:K})}}}cleanupActors__(){this.logger_.logMethodArgs?.("cleanupActors__",{count:this.activeActorCleanups__.length});for(let z=this.activeActorCleanups__.length-1;z>=0;z--)try{this.activeActorCleanups__[z]()}catch(J){this.logger_.error("cleanupActors__","cleanup_failed",J,{index:z})}this.activeActorCleanups__.length=0}destroy(z=!0){if(this.destroyed__)return;if(this.logger_.logMethod?.("destroy"),this.destroyed__=!0,this.mailbox__.length=0,this.cleanupActors__(),z)this.stateSignal__.destroy()}}function B(z){return new Z(z)}export{B as createFsmService,Z as FsmService};
|
|
3
3
|
|
|
4
|
-
//# debugId=
|
|
4
|
+
//# debugId=8F76F872C92BD90364756E2164756E21
|
|
5
5
|
//# sourceMappingURL=main.js.map
|
package/dist/main.js.map
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
|
-
"sources": ["../src/
|
|
3
|
+
"sources": ["../src/fsm-service.ts", "../src/facade.ts"],
|
|
4
4
|
"sourcesContent": [
|
|
5
|
-
"import {createPersistentStateSignal, createStateSignal} from '@alwatr/signal';\n\nimport {FsmService} from './fsm-service.js';\n\nimport type {MachineEvent, MachineState, StateMachineConfig} from './type.js';\n\n/**\n * A simple and clean factory function for creating an `FsmService` instance.\n * This is the recommended way to instantiate a new state machine.\n *\n * @template TState - The union type of all possible states.\n * @template TEvent - The union type of all possible events.\n * @template TContext - The type of the machine's context.\n *\n * @param config - The machine's configuration object.\n * @returns A new, ready-to-use instance of `FsmService`.\n *\n * @example\n * ```ts\n * import {createFsmService} from '@alwatr/fsm';\n * import type {StateMachineConfig} from '@alwatr/fsm';\n *\n * // 1. Define types\n * type LightContext = {brightness: number};\n * type LightState = 'on' | 'off';\n * type LightEvent = {type: 'TOGGLE'} | {type: 'SET_BRIGHTNESS'; level: number};\n *\n * // 2. Config the state machine\n * const lightMachineConfig: StateMachineConfig<LightState, LightEvent, LightContext> = {\n * name: 'light-switch',\n * initial: 'off',\n * context: {brightness: 0},\n * states: {\n * off: {\n * on: {\n * TOGGLE: {\n * target: 'on',\n * assigners: [({context}) => ({...context, brightness: 100})],\n * },\n * },\n * },\n * on: {\n * on: {\n * TOGGLE: {target: 'off', assigners: [({context}) => ({...context, brightness: 0})]},\n * SET_BRIGHTNESS: {assigners: [({context, event}) => ({...context, brightness: event.level})]},\n * },\n * },\n * },\n * };\n *\n * // 3. Create the service\n * const lightService = createFsmService(lightMachineConfig);\n *\n * // 4. Use it in your application\n * lightService.stateSignal.subscribe((state) => {\n * console.log(`Light is ${state.name} with brightness ${state.context.brightness}`);\n * });\n *\n * lightService.dispatch({type: 'TOGGLE'}); // Light is on with brightness 100\n *\n * lightService.dispatch({type: 'SET_BRIGHTNESS', level: 50}); // Light is on with brightness 50\n *\n * // 5. Cleanup\n * // lightService.destroy();\n * ```\n */\nexport function createFsmService<\n TState extends string,\n TEvent extends MachineEvent,\n TContext extends Record<string, unknown> = Record<string, never>,\n>(config: StateMachineConfig<TState, TEvent, TContext>): FsmService<TState, TEvent, TContext> {\n const initialValue: MachineState<TState, TContext> = {\n name: config.initial,\n context: config.context,\n };\n\n const stateSignal =\n config.persistent ?\n createPersistentStateSignal<MachineState<TState, TContext>>({\n name: `fsm-state-${config.name}`,\n storageKey: config.persistent.storageKey ?? config.name,\n initialValue,\n schemaVersion: config.persistent.schemaVersion,\n })\n : createStateSignal<MachineState<TState, TContext>>({\n name: `fsm-state-${config.name}`,\n initialValue: initialValue,\n });\n\n return new FsmService(config, stateSignal);\n}\n",
|
|
6
|
-
"import type {SingleOrArray} from '@alwatr/type-helper';\nimport {createLogger, type AlwatrLogger} from '@alwatr/logger';\nimport {\n createEventSignal,\n type StateSignal,\n type PersistentStateSignal,\n EventSignal,\n type IReadonlySignal,\n} from '@alwatr/signal';\n\nimport type {StateMachineConfig, MachineState, MachineEvent, Transition, Effect, Assigner, Actor} from './type.js';\n\n/**\n * A generic, encapsulated service that creates, runs, and manages a finite state machine.\n * It handles signal creation, logic connection, and lifecycle management, providing a clean,\n * reactive API for interacting with the FSM.\n *\n * @template TState The union type of all possible state names.\n * @template TEvent The union type of all possible events.\n * @template TContext The type of the machine's context (extended state).\n */\nexport class FsmService<\n TState extends string,\n TEvent extends MachineEvent,\n TContext extends Record<string, unknown> = Record<string, never>,\n> {\n protected readonly logger_: AlwatrLogger;\n\n /** The private event signal for sending events to the FSM. */\n private readonly eventSignal__: EventSignal<TEvent>;\n\n /** The public, read-only state signal. Subscribe to react to state changes. */\n public readonly stateSignal: IReadonlySignal<MachineState<TState, TContext>>;\n\n /** The set of cleanup functions for currently active state actors. */\n private readonly activeActorCleanups__ = new Set<() => void>();\n\n constructor(\n protected readonly config_: StateMachineConfig<TState, TEvent, TContext>,\n private readonly stateSignal__:\n | StateSignal<MachineState<TState, TContext>>\n | PersistentStateSignal<MachineState<TState, TContext>>,\n ) {\n this.logger_ = createLogger(`fsm:${this.config_.name}`);\n this.logger_.logMethodArgs?.('constructor', config_);\n\n this.stateSignal = this.stateSignal__.asReadonly();\n this.eventSignal__ = createEventSignal<TEvent>({\n name: `fsm-event-${this.config_.name}`,\n });\n this.eventSignal__.subscribe((event) => this.processTransition__(event), {receivePrevious: false});\n\n // Execute initial state entry effects and actors.\n this.start_();\n }\n\n /**\n * Dispatches an event to the FSM mailbox.\n *\n * @param event The event to process.\n */\n public readonly dispatch = (event: TEvent): void => {\n this.logger_.logMethodArgs?.('dispatch', {event});\n this.eventSignal__.dispatch(event);\n };\n\n /**\n * The core FSM logic that processes a single event and transitions the machine to a new state.\n * This process is atomic and follows the Run-to-Completion (RTC) model.\n *\n * @param event The event to process.\n */\n private processTransition__(event: TEvent): void {\n const currentState = this.stateSignal__.get();\n this.logger_.logMethodArgs?.('processTransition__', {state: currentState.name, event});\n\n const transition = this.findTransition__(event, currentState.context);\n\n if (!transition) {\n this.logger_.incident?.('processTransition__', 'ignored_event', 'No valid transition found for event', {\n state: currentState.name,\n event,\n });\n return; // Event ignored, no transition occurs.\n }\n\n const targetStateName = transition.target ?? currentState.name;\n const isExternalTransition = transition.target !== undefined;\n\n // 1. Execute exit effects and cleanup actors of the current state if it's an external transition.\n if (isExternalTransition) {\n this.executeEffects__(event, currentState.context, this.config_.states[currentState.name]?.exit);\n this.cleanupActors__();\n }\n\n // 2. Apply assigners to compute the next context. This is a pure function.\n const nextContext = this.applyAssigners__(event, currentState.context, transition.assigners);\n\n // 3. Create the final next state object.\n const nextState: MachineState<TState, TContext> = {\n name: targetStateName,\n context: nextContext,\n };\n\n // 4. Set the new state, notifying all subscribers.\n this.stateSignal__.set(nextState);\n\n // 5. Execute entry effects and spawn actors of the new state if it's an external transition.\n if (isExternalTransition) {\n this.executeEffects__(event, nextState.context, this.config_.states[nextState.name]?.entry);\n this.spawnActors__(event, nextState.context, this.config_.states[nextState.name]?.actors);\n }\n }\n\n /**\n * Finds the first valid transition for the given event and context by evaluating guards.\n *\n * @param event The triggering event.\n * @param context The current machine context.\n * @returns The first matching transition or `undefined` if none are found.\n */\n private findTransition__(\n event: TEvent,\n context: Readonly<TContext>,\n ): Transition<TState, TEvent, TContext> | undefined {\n this.logger_.logMethod?.('findTransition__');\n\n const currentStateName = this.stateSignal__.get().name;\n const currentStateConfig = this.config_.states[currentStateName];\n const transitions = currentStateConfig?.on?.[event.type as TEvent['type']] as\n | SingleOrArray<Transition<TState, TEvent, TContext>>\n | undefined;\n\n if (!transitions) return undefined;\n\n const transitionsArray = Array.isArray(transitions) ? transitions : [transitions];\n\n for (let index = 0; index < transitionsArray.length; index++) {\n const transition = transitionsArray[index];\n if (!transition.guard) return transition;\n try {\n const guardMet = transition.guard({event, context});\n this.logger_.logStep?.('findTransition__', 'check_guard', {\n state: currentStateName,\n eventType: event.type,\n transitionIndex: index,\n guard: transition.guard.name || 'anonymous',\n result: guardMet,\n });\n if (guardMet) return transition;\n } catch (error) {\n this.logger_.error('findTransition__', 'guard_failed', error, {\n state: currentStateName,\n eventType: event.type,\n transitionIndex: index,\n guard: transition.guard.name || 'anonymous',\n });\n }\n }\n\n return undefined;\n }\n\n /**\n * Sequentially executes a list of effects (side-effects).\n * Errors are caught and logged without stopping the FSM.\n *\n * @param event The event that triggered these effects.\n * @param context The context at the time of execution.\n * @param effects A single effect or an array of effects.\n */\n private executeEffects__(\n event: TEvent,\n context: Readonly<TContext>,\n effects?: SingleOrArray<Effect<TEvent, TContext>>,\n ): void {\n if (!effects) {\n this.logger_.logMethodArgs?.('executeEffects__//skipped', {count: 0});\n return;\n }\n const effectsArray = Array.isArray(effects) ? effects : [effects];\n\n this.logger_.logMethodArgs?.('executeEffects__', {count: effectsArray.length});\n\n for (const effect of effectsArray) {\n try {\n const result = effect({event, context});\n if (result instanceof Promise) {\n result.catch((error) => {\n this.logger_.error('executeEffects__', 'effect_failed', error, {\n effect: effect.name || 'anonymous',\n state: this.stateSignal__.get().name,\n event,\n context,\n });\n });\n }\n } catch (error) {\n this.logger_.error('executeEffects__', 'effect_failed', error, {\n effect: effect.name || 'anonymous',\n state: this.stateSignal__.get().name,\n event,\n context,\n });\n }\n }\n }\n\n /**\n * Applies all assigner functions to the context to produce a new, updated context.\n * This process is atomic (all-or-nothing). If any assigner fails, the original\n * context is returned, and all updates are discarded.\n *\n * @param event The event that triggered the transition.\n * @param context The current context.\n * @param assigners A single assigner or an array of assigners.\n * @returns The new, updated context, or the original context if any assigner fails.\n */\n private applyAssigners__(\n event: TEvent,\n context: TContext,\n assigners?: SingleOrArray<Assigner<TEvent, TContext>>,\n ): TContext {\n if (!assigners) {\n this.logger_.logMethodArgs?.('applyAssigners__//skipped', {count: 0});\n return context;\n }\n\n const assignersArray = Array.isArray(assigners) ? assigners : [assigners];\n\n this.logger_.logMethodArgs?.('applyAssigners__', {count: assignersArray.length});\n\n try {\n let accContext = context;\n for (const assigner of assignersArray) {\n const nextContext = assigner({event, context: accContext});\n this.logger_.logMethodFull?.(\n `event.${event.type}.action.${assigner.name || 'anonymous'}`,\n {event, accContext},\n nextContext,\n );\n if (nextContext !== undefined && nextContext !== null) {\n accContext = nextContext;\n }\n }\n return accContext;\n } catch (error) {\n this.logger_.error('applyAssigners__', 'assigner_failed_atomic', error, {\n event,\n context, // Log the original context for debugging.\n });\n // On ANY failure, discard all changes and return the original context.\n return context;\n }\n }\n\n /**\n * Starts the FSM by executing the entry effects and spawning the actors\n * of the initial/current state.\n */\n protected start_(): void {\n if (this.eventSignal__.isDestroyed) return;\n this.logger_.logMethod?.('start_');\n const currentState = this.stateSignal__.get();\n const initEvent = {type: '__init__'} as unknown as TEvent;\n this.executeEffects__(initEvent, currentState.context, this.config_.states[currentState.name]?.entry);\n this.spawnActors__(initEvent, currentState.context, this.config_.states[currentState.name]?.actors);\n }\n\n /**\n * Spawns all configured actors for the entered state.\n */\n private spawnActors__(\n event: TEvent,\n context: Readonly<TContext>,\n actors?: SingleOrArray<Actor<TEvent, TContext>>,\n ): void {\n if (!actors) {\n this.logger_.logMethodArgs?.('spawnActors__//skipped', {count: 0});\n return;\n }\n const actorsArray = Array.isArray(actors) ? actors : [actors];\n\n this.logger_.logMethodArgs?.('spawnActors__', {count: actorsArray.length});\n\n for (const actor of actorsArray) {\n try {\n const cleanup = actor({\n event,\n context,\n dispatch: this.dispatch,\n });\n if (typeof cleanup === 'function') {\n this.activeActorCleanups__.add(cleanup);\n }\n } catch (error) {\n this.logger_.error('spawnActors__', 'actor_failed', error, {\n actor: actor.name || 'anonymous',\n state: this.stateSignal__.get().name,\n event,\n context,\n });\n }\n }\n }\n\n /**\n * Cleans up (destroys) all currently active state actors.\n */\n private cleanupActors__(): void {\n this.logger_.logMethodArgs?.('cleanupActors__', {count: this.activeActorCleanups__.size});\n for (const cleanup of this.activeActorCleanups__) {\n try {\n cleanup();\n } catch (error) {\n this.logger_.error('cleanupActors__', 'cleanup_failed', error);\n }\n }\n this.activeActorCleanups__.clear();\n }\n\n /**\n * Destroys the service, cleaning up all internal signals and subscriptions\n * to prevent memory leaks.\n */\n public destroy(): void {\n this.logger_.logMethod?.('destroy');\n this.cleanupActors__();\n this.eventSignal__.destroy();\n this.stateSignal__.destroy();\n }\n}\n"
|
|
5
|
+
"import {createLogger, type AlwatrLogger} from '@alwatr/logger';\nimport {queueMicrotask} from '@alwatr/delay';\nimport type {SingleOrArray} from '@alwatr/type-helper';\nimport {\n createPersistentStateSignal,\n createStateSignal,\n type StateSignal,\n type PersistentStateSignal,\n type IReadonlySignal,\n} from '@alwatr/signal';\n\nimport type {StateMachineConfig, MachineState, MachineEvent, Transition, Effect, Assigner, Actor} from './type.js';\n\n/**\n * A generic, encapsulated service that creates, runs, and manages a finite state machine.\n * It handles signal creation, logic connection, and lifecycle management, providing a clean,\n * reactive API for interacting with the FSM.\n *\n * @template TState The union type of all possible state names.\n * @template TEvent The union type of all possible events.\n * @template TContext The type of the machine's context (extended state).\n */\nexport class FsmService<\n TState extends string,\n TEvent extends MachineEvent,\n TContext extends Record<string, unknown> = Record<string, never>,\n> {\n protected readonly logger_: AlwatrLogger;\n\n /** The public, read-only state signal. Subscribe to react to state changes. */\n public readonly stateSignal: IReadonlySignal<MachineState<TState, TContext>>;\n\n /**\n * The FIFO event mailbox. Events are processed strictly in dispatch order.\n */\n private readonly mailbox__: TEvent[] = [];\n\n /**\n * RTC re-entrancy guard. While `true`, an active loop is draining the mailbox;\n * re-entrant dispatches just enqueue and return.\n */\n private processing__ = true;\n\n /** Set once by `destroy()`. All dispatches after destruction are ignored (and logged). */\n private destroyed__ = false;\n\n /**\n * Cleanup callbacks for currently active state actors, in spawn order.\n * Executed in REVERSE (LIFO) order on state exit — standard resource semantics\n * (last acquired, first released).\n */\n private readonly activeActorCleanups__: (() => void)[] = [];\n\n private readonly stateSignal__:\n | StateSignal<MachineState<TState, TContext>>\n | PersistentStateSignal<MachineState<TState, TContext>>;\n\n constructor(\n protected readonly config_: StateMachineConfig<TState, TEvent, TContext>,\n stateSignal?: StateSignal<MachineState<TState, TContext>> | PersistentStateSignal<MachineState<TState, TContext>>,\n ) {\n this.logger_ = createLogger(`fsm:${this.config_.name}`);\n this.logger_.logMethodArgs?.('constructor', config_);\n\n const initialValue: MachineState<TState, TContext> = {\n name: config_.initial,\n context: config_.context,\n };\n this.stateSignal__ =\n stateSignal\n ?? (config_.persistent ?\n createPersistentStateSignal<MachineState<TState, TContext>>({\n name: `fsm-state-${config_.name}`,\n storageKey: config_.persistent.storageKey ?? config_.name,\n initialValue,\n schemaVersion: config_.persistent.schemaVersion,\n })\n : createStateSignal<MachineState<TState, TContext>>({\n name: `fsm-state-${config_.name}`,\n initialValue,\n }));\n\n this.stateSignal = this.stateSignal__.asReadonly();\n\n // Execute initial/rehydrated state entry effects and spawn its actors.\n queueMicrotask(() => this.start_());\n }\n\n /**\n * Synchronous accessor for the current machine state.\n * Prefer `stateSignal.subscribe()` for reactive consumers; use this getter for\n * imperative checks inside controllers/services.\n */\n public get state(): MachineState<TState, TContext> {\n return this.stateSignal__.get();\n }\n\n /**\n * Convenience predicate: returns true if the current finite state matches any\n * of the given names. Sugar for `service.state.name === 'x' || ...`.\n */\n public matches(...names: TState[]): boolean {\n return names.includes(this.stateSignal__.get().name);\n }\n\n /**\n * Dispatches an event to the FSM mailbox.\n *\n * Events are processed with Run-to-Completion semantics: if dispatched while a\n * transition is in flight (re-entrant dispatch from a guard/effect/actor), the\n * event is enqueued and processed deterministically right after the current\n * transition completes — in the same call stack, in FIFO order, with no loss.\n *\n * @param event The event to process.\n */\n public readonly dispatch = (event: TEvent): void => {\n this.logger_.logMethodArgs?.('dispatch', {event});\n this.mailbox__.push(event);\n this.processMailbox__();\n };\n\n private processMailbox__(): void {\n // RTC guard: an active loop is already draining the mailbox; it will pick\n // this event up after the current transition finishes.\n if (this.processing__) return;\n this.logger_.logMethod?.('processMailbox__');\n if (this.destroyed__) {\n this.logger_.incident?.('dispatch', 'dispatch_after_destroy', {event});\n return;\n }\n this.processing__ = true;\n try {\n // Do NOT cache length. New events may be added during processing, and they MUST be processed in the same order (FIFO).\n for (let index = 0; index < this.mailbox__.length; index++) {\n this.processTransition__(this.mailbox__[index]);\n if (this.destroyed__) break;\n }\n } finally {\n this.processing__ = false;\n this.mailbox__.length = 0;\n }\n }\n\n /**\n * The core FSM logic that processes a single event and transitions the machine to a new state.\n * This step is atomic: exit effects -> assigners -> state commit -> entry effects -> actors.\n *\n * @param event The event to process.\n */\n private processTransition__(event: TEvent): void {\n const currentState = this.stateSignal__.get();\n this.logger_.logMethodArgs?.('processTransition__', {state: currentState.name, event});\n\n const transition = this.findTransition__(event, currentState);\n\n if (!transition) {\n this.logger_.incident?.('processTransition__', 'ignored_event', 'No valid transition found for event', {\n state: currentState.name,\n event,\n });\n return; // Event ignored, no transition occurs.\n }\n\n const targetStateName = transition.target ?? currentState.name;\n const isExternalTransition = transition.target !== undefined;\n\n // 1. External transition: run exit effects (with the OLD context, per SCXML semantics) and tear down the current state's actors.\n if (isExternalTransition) {\n this.executeEffects__(event, currentState.context, this.config_.states[currentState.name]?.exit);\n this.cleanupActors__();\n }\n\n // 2. Apply assigners to compute the next context (pure, atomic).\n const nextContext = this.applyAssigners__(event, currentState.context, transition.assigner);\n\n // 3. Commit the new state, notifying all subscribers (async via signal layer).\n const nextState: MachineState<TState, TContext> = {\n name: targetStateName,\n context: nextContext,\n };\n this.stateSignal__.set(nextState);\n\n // 4. External transition: run entry effects (with the NEW context) and spawn the target state's actors.\n if (isExternalTransition) {\n this.executeEffects__(event, nextState.context, this.config_.states[nextState.name]?.entry);\n this.spawnActors__(event, nextState.context, this.config_.states[nextState.name]?.actor);\n }\n }\n\n /**\n * Finds the first valid transition for the given event by evaluating guards in declaration order. A guard-less transition acts as an unconditional fallback.\n *\n * @param event The triggering event.\n * @param currentState The current state of the machine.\n * @returns The first matching transition or `undefined` if none are found.\n */\n private findTransition__(\n event: TEvent,\n currentState: MachineState<TState, TContext>,\n ): Transition<TState, TEvent, TContext> | undefined {\n this.logger_.logMethod?.('findTransition__');\n\n const currentStateConfig = this.config_.states[currentState.name];\n const transitions = currentStateConfig?.on?.[event.type as TEvent['type']] as\n | SingleOrArray<Transition<TState, TEvent, TContext>>\n | undefined;\n\n if (!transitions) return undefined;\n\n if (!Array.isArray(transitions)) {\n if (!transitions.guard) return transitions; // Unconditional fallback branch.\n try {\n if (transitions.guard(event, currentState.context)) {\n return transitions;\n }\n } catch (error) {\n this.logger_.error('findTransition__', 'guard_failed', error, {\n state: currentState.name,\n eventType: event.type,\n });\n }\n return undefined;\n }\n\n // else if transitions is an array\n\n for (let index = 0; index < transitions.length; index++) {\n const transition = transitions[index];\n if (!transition.guard) return transition; // Unconditional fallback branch.\n try {\n if (transition.guard(event, currentState.context)) {\n return transition;\n }\n } catch (error) {\n this.logger_.error('findTransition__', 'guard_failed', error, {\n state: currentState.name,\n eventType: event.type,\n index,\n });\n // Treated as guard === false: continue evaluating the next branch.\n }\n }\n\n return undefined;\n }\n\n /**\n * Sequentially executes a list of synchronous effects (side-effects).\n * Errors are caught and logged without stopping the FSM.\n *\n * @param event The event that triggered these effects.\n * @param context The context at the time of execution.\n * @param effects A single effect or an array of effects.\n */\n private executeEffects__(\n event: TEvent,\n context: Readonly<TContext>,\n effects?: SingleOrArray<Effect<TEvent, TContext>>,\n ): void {\n if (!effects) {\n this.logger_.logMethod?.('executeEffects__.skipped');\n return;\n }\n\n this.logger_.logMethod?.('executeEffects__');\n\n if (!Array.isArray(effects)) {\n try {\n effects(event, context);\n } catch (error) {\n this.logger_.error('executeEffects__', 'effect_failed', error, {\n event,\n context,\n });\n }\n return;\n }\n\n // else if effects is an array\n\n for (let index = 0; index < effects.length; index++) {\n const effect = effects[index];\n try {\n effect(event, context);\n } catch (error) {\n this.logger_.error('executeEffects__', 'effect_failed', error, {\n event,\n context,\n index,\n });\n }\n }\n }\n\n /**\n * Applies all assigner functions to the context to produce a new, updated context.\n *\n * This process is atomic (all-or-nothing): if any assigner throws, the original\n * context is returned and all updates are discarded.\n *\n * @param event The event that triggered the transition.\n * @param context The current context.\n * @param assigners A single assigner or an array of assigners.\n * @returns The new, updated context, or the original context if any assigner fails.\n */\n private applyAssigners__(\n event: TEvent,\n context: TContext,\n assigners?: SingleOrArray<Assigner<TEvent, TContext>>,\n ): TContext {\n if (!assigners) {\n this.logger_.logMethod?.('applyAssigners__.skipped');\n return context;\n }\n\n this.logger_.logMethod?.('applyAssigners__');\n\n if (!Array.isArray(assigners)) {\n try {\n return assigners(event, context) ?? context;\n } catch (error) {\n this.logger_.error('applyAssigners__', 'assigner_failed_atomic', error, {event, context});\n }\n return context;\n }\n\n // else if assigners is an array\n\n try {\n let accContext = context;\n for (let index = 0; index < assigners.length; index++) {\n const assigner = assigners[index];\n const nextContext = assigner(event, accContext);\n if (nextContext) {\n accContext = nextContext;\n }\n }\n return accContext;\n } catch (error) {\n this.logger_.error('applyAssigners__', 'assigner_failed_atomic', error, {event, context});\n // On ANY failure, discard all changes and return the original context.\n return context;\n }\n }\n\n /**\n * Starts the FSM by executing the entry effects and spawning the actors of the\n * initial (or rehydrated) state, using the synthetic `{type: '__init__'}` event.\n */\n protected start_(): void {\n if (this.destroyed__) return;\n this.logger_.logMethod?.('start_');\n const currentState = this.stateSignal__.get();\n const initEvent = {type: '__init__'} as unknown as TEvent;\n this.executeEffects__(initEvent, currentState.context, this.config_.states[currentState.name]?.entry);\n this.spawnActors__(initEvent, currentState.context, this.config_.states[currentState.name]?.actor);\n this.processing__ = false; // Allow processing of dispatched events after the initial setup is complete.\n if (this.mailbox__.length > 0) {\n this.processMailbox__(); // Process any events that were dispatched during the initial setup.\n }\n }\n\n /**\n * Spawns all configured actors for the entered state.\n */\n private spawnActors__(\n event: TEvent,\n context: Readonly<TContext>,\n actors?: SingleOrArray<Actor<TEvent, TContext>>,\n ): void {\n if (!actors) {\n this.logger_.logMethod?.('spawnActors__.skipped');\n return;\n }\n\n this.logger_.logMethod?.('spawnActors__');\n\n if (!Array.isArray(actors)) {\n try {\n const cleanup = actors(context, this.dispatch);\n if (typeof cleanup === 'function') {\n this.activeActorCleanups__.push(cleanup);\n }\n } catch (error) {\n this.logger_.error('spawnActors__', 'actor_failed', error, {event, context});\n }\n return;\n }\n\n // else if actors is an array\n\n for (let index = 0; index < actors.length; index++) {\n const actor = actors[index];\n try {\n const cleanup = actor(context, this.dispatch);\n if (typeof cleanup === 'function') {\n this.activeActorCleanups__.push(cleanup);\n }\n } catch (error) {\n this.logger_.error('spawnActors__', 'actor_failed', error, {event, context, index});\n }\n }\n }\n\n /**\n * Cleans up (destroys) all currently active state actors in REVERSE (LIFO) spawn order — standard resource-release semantics.\n */\n private cleanupActors__(): void {\n this.logger_.logMethodArgs?.('cleanupActors__', {count: this.activeActorCleanups__.length});\n for (let index = this.activeActorCleanups__.length - 1; index >= 0; index--) {\n try {\n this.activeActorCleanups__[index]();\n } catch (error) {\n this.logger_.error('cleanupActors__', 'cleanup_failed', error, {index});\n }\n }\n this.activeActorCleanups__.length = 0;\n }\n\n /**\n * Destroys the service, cleaning up actors, the mailbox, and owned signals to\n * prevent memory leaks. Idempotent — safe to call multiple times.\n *\n * @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.\n */\n public destroy(destroyState = true): void {\n if (this.destroyed__) return;\n this.logger_.logMethod?.('destroy');\n this.destroyed__ = true;\n this.mailbox__.length = 0;\n this.cleanupActors__();\n if (destroyState) {\n this.stateSignal__.destroy();\n }\n }\n}\n",
|
|
6
|
+
"import {FsmService} from './fsm-service.js';\n\nimport type {MachineEvent, StateMachineConfig} from './type.js';\n\n/**\n * A simple and clean factory function for creating an `FsmService` instance.\n * This is the recommended way to instantiate a new state machine.\n *\n * @template TState - The union type of all possible states.\n * @template TEvent - The union type of all possible events.\n * @template TContext - The type of the machine's context.\n *\n * @param config - The machine's configuration object.\n * @returns A new, ready-to-use instance of `FsmService`.\n *\n * @example\n * ```ts\n * import {createFsmService} from '@alwatr/fsm';\n * import type {StateMachineConfig} from '@alwatr/fsm';\n *\n * // 1. Define types\n * type LightContext = {brightness: number};\n * type LightState = 'on' | 'off';\n * type LightEvent = {type: 'TOGGLE'} | {type: 'SET_BRIGHTNESS'; level: number};\n *\n * // 2. Config the state machine\n * const lightMachineConfig: StateMachineConfig<LightState, LightEvent, LightContext> = {\n * name: 'light-switch',\n * initial: 'off',\n * context: {brightness: 0},\n * states: {\n * off: {\n * on: {\n * TOGGLE: {\n * target: 'on',\n * assigners: [({context}) => ({...context, brightness: 100})],\n * },\n * },\n * },\n * on: {\n * on: {\n * TOGGLE: {target: 'off', assigners: [({context}) => ({...context, brightness: 0})]},\n * SET_BRIGHTNESS: {assigners: [({context, event}) => ({...context, brightness: event.level})]},\n * },\n * },\n * },\n * };\n *\n * // 3. Create the service\n * const lightService = createFsmService(lightMachineConfig);\n *\n * // 4. Use it in your application\n * lightService.stateSignal.subscribe((state) => {\n * console.log(`Light is ${state.name} with brightness ${state.context.brightness}`);\n * });\n *\n * lightService.dispatch({type: 'TOGGLE'}); // Light is on with brightness 100\n *\n * lightService.dispatch({type: 'SET_BRIGHTNESS', level: 50}); // Light is on with brightness 50\n *\n * // 5. Cleanup\n * // lightService.destroy();\n * ```\n */\nexport function createFsmService<\n TState extends string,\n TEvent extends MachineEvent,\n TContext extends Record<string, unknown> = Record<string, never>,\n>(config: StateMachineConfig<TState, TEvent, TContext>): FsmService<TState, TEvent, TContext> {\n return new FsmService(config);\n}\n"
|
|
7
7
|
],
|
|
8
|
-
"mappings": ";AAAA,
|
|
9
|
-
"debugId": "
|
|
8
|
+
"mappings": "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",
|
|
9
|
+
"debugId": "8F76F872C92BD90364756E2164756E21",
|
|
10
10
|
"names": []
|
|
11
11
|
}
|
package/dist/type.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { JsonValue, SingleOrArray } from '@alwatr/type-helper';
|
|
2
2
|
import type { SignalConfig } from '@alwatr/signal';
|
|
3
3
|
/**
|
|
4
4
|
* Represents the state of a state machine, including its current finite state value
|
|
@@ -22,61 +22,73 @@ export type MachineState<TState extends string, TContext extends Record<string,
|
|
|
22
22
|
export interface MachineEvent<TEventType extends string = string> {
|
|
23
23
|
/** The unique type of the event. */
|
|
24
24
|
readonly type: TEventType;
|
|
25
|
-
/** An event can carry an optional payload. */
|
|
26
|
-
[key: string]:
|
|
25
|
+
/** An event can carry an optional, serializable payload. */
|
|
26
|
+
[key: string]: JsonValue;
|
|
27
27
|
}
|
|
28
28
|
/**
|
|
29
|
-
* Defines an assigner
|
|
30
|
-
* It returns the complete new context object or void/undefined if no changes are made.
|
|
29
|
+
* Defines an assigner — a **pure, synchronous context reducer** applied during transitions.
|
|
31
30
|
*
|
|
32
|
-
* @
|
|
33
|
-
* @
|
|
34
|
-
* @returns The complete next context object or void.
|
|
31
|
+
* @param event The event that triggered the transition. Readonly to prevent mutations.
|
|
32
|
+
* @param context The current context before the transition. Mutable for convenience, but treat it as immutable — return a new context object instead of mutating it.
|
|
33
|
+
* @returns The complete next context object, or void.
|
|
35
34
|
*/
|
|
36
|
-
export type Assigner<TEvent extends MachineEvent, TContext extends Record<string, unknown>> = (
|
|
37
|
-
readonly event: Readonly<TEvent>;
|
|
38
|
-
readonly context: TContext;
|
|
39
|
-
}) => TContext | void;
|
|
35
|
+
export type Assigner<TEvent extends MachineEvent, TContext extends Record<string, unknown>> = (event: Readonly<TEvent>, context: TContext) => TContext | void;
|
|
40
36
|
/**
|
|
41
|
-
* Defines an effect
|
|
42
|
-
*
|
|
37
|
+
* Defines an effect — a **strictly synchronous**, fire-and-forget side-effect
|
|
38
|
+
* executed on state entry/exit.
|
|
43
39
|
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
40
|
+
* ## Why synchronous-only? (Architectural decision)
|
|
41
|
+
*
|
|
42
|
+
* The FSM core is a deterministic, Run-to-Completion (RTC) step function:
|
|
43
|
+
* `(state, event) -> (state', effects)`. Allowing async effects inside the core
|
|
44
|
+
* creates ordering ambiguity — the continuation of an async effect may run against
|
|
45
|
+
* a state/context that no longer exists. This mirrors the design of SCXML actions,
|
|
46
|
+
* XState actions, and Erlang's gen_statem.
|
|
47
|
+
*
|
|
48
|
+
* **Any asynchronous work belongs in an {@link Actor}**, which has a proper
|
|
49
|
+
* lifecycle (spawn on entry, cleanup on exit) and communicates results back to
|
|
50
|
+
* the machine via `dispatch`, keeping the core deterministic.
|
|
51
|
+
*
|
|
52
|
+
* @param event The event that triggered the effect. Readonly to prevent mutations.
|
|
53
|
+
* @param context The current context of the machine. Readonly to prevent mutations.
|
|
47
54
|
*/
|
|
48
|
-
export type Effect<TEvent extends MachineEvent, TContext extends Record<string, unknown>> = (
|
|
49
|
-
readonly event: Readonly<TEvent>;
|
|
50
|
-
readonly context: Readonly<TContext>;
|
|
51
|
-
}) => Awaitable<void>;
|
|
55
|
+
export type Effect<TEvent extends MachineEvent, TContext extends Record<string, unknown>> = (event: Readonly<TEvent>, context: Readonly<TContext>) => void;
|
|
52
56
|
/**
|
|
53
57
|
* Defines a conditional guard function for a transition.
|
|
54
58
|
* The transition is only taken if this function returns true.
|
|
55
59
|
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
60
|
+
* Guards MUST be pure and synchronous. A guard that throws is treated as `false`
|
|
61
|
+
* (logged, transition branch skipped) so a single faulty predicate cannot brick
|
|
62
|
+
* the machine.
|
|
63
|
+
*
|
|
64
|
+
* @param event The event that triggered the transition. Readonly to prevent mutations.
|
|
65
|
+
* @param context The current context of the machine. Readonly to prevent mutations.
|
|
58
66
|
* @returns `true` if the transition should be taken, `false` otherwise.
|
|
59
67
|
*/
|
|
60
|
-
export type Guard<TEvent extends MachineEvent, TContext extends Record<string, unknown>> = (
|
|
61
|
-
readonly event: Readonly<TEvent>;
|
|
62
|
-
readonly context: Readonly<TContext>;
|
|
63
|
-
}) => boolean;
|
|
68
|
+
export type Guard<TEvent extends MachineEvent, TContext extends Record<string, unknown>> = (event: Readonly<TEvent>, context: Readonly<TContext>) => boolean;
|
|
64
69
|
/**
|
|
65
|
-
* Defines an actor
|
|
66
|
-
*
|
|
67
|
-
*
|
|
70
|
+
* Defines an actor — an **asynchronous lifecycle process** spawned on state entry.
|
|
71
|
+
*
|
|
72
|
+
* This is the ONLY sanctioned home for async work in the machine (network requests,
|
|
73
|
+
* polling intervals, websocket listeners, timers). An actor:
|
|
74
|
+
*
|
|
75
|
+
* 1. Is spawned when the machine enters the state.
|
|
76
|
+
* 2. Receives `dispatch` to asynchronously send events back to the parent FSM.
|
|
77
|
+
* 3. May return a synchronous cleanup function, executed automatically (in LIFO
|
|
78
|
+
* order) when the machine exits the state or is destroyed.
|
|
68
79
|
*
|
|
69
80
|
* @template TEvent The union type of all events in the machine.
|
|
70
81
|
* @template TContext The type of the machine's context.
|
|
71
82
|
*/
|
|
72
|
-
export type Actor<TEvent extends MachineEvent, TContext extends Record<string, unknown>> = (
|
|
73
|
-
readonly event: Readonly<TEvent>;
|
|
74
|
-
readonly context: Readonly<TContext>;
|
|
75
|
-
readonly dispatch: (event: TEvent) => void;
|
|
76
|
-
}) => (() => void) | void;
|
|
83
|
+
export type Actor<TEvent extends MachineEvent, TContext extends Record<string, unknown>> = (context: Readonly<TContext>, dispatch: (event: TEvent) => void) => VoidFunction | void;
|
|
77
84
|
/**
|
|
78
85
|
* Defines a transition for a given state and event. It specifies the target state,
|
|
79
|
-
*
|
|
86
|
+
* assigners, and an optional guard.
|
|
87
|
+
*
|
|
88
|
+
* - With `target`: an **external** transition — exit effects run, actors are cleaned
|
|
89
|
+
* up, then entry effects run and actors are re-spawned (even on self-transitions).
|
|
90
|
+
* - Without `target`: an **internal** transition — only assigners run; entry/exit
|
|
91
|
+
* effects and actors are untouched.
|
|
80
92
|
*
|
|
81
93
|
* @template TState The type of the state.
|
|
82
94
|
* @template TEvent The type of the event.
|
|
@@ -87,8 +99,8 @@ export interface Transition<TState extends string, TEvent extends MachineEvent,
|
|
|
87
99
|
readonly target?: TState;
|
|
88
100
|
/** A guard function that must return true for the transition to occur. */
|
|
89
101
|
readonly guard?: Guard<TEvent, TContext>;
|
|
90
|
-
/**
|
|
91
|
-
readonly
|
|
102
|
+
/** A single assigner or an ordered chain of assigners. Applied atomically. */
|
|
103
|
+
readonly assigner?: SingleOrArray<Assigner<TEvent, TContext>>;
|
|
92
104
|
}
|
|
93
105
|
/**
|
|
94
106
|
* Configuration options for persisting the FSM state in localStorage.
|
|
@@ -109,6 +121,14 @@ export interface FsmPersistenceConfig {
|
|
|
109
121
|
* The declarative configuration object for creating a state machine.
|
|
110
122
|
* This object defines the entire behavior of the machine.
|
|
111
123
|
*
|
|
124
|
+
* ## Persistence requirement
|
|
125
|
+
*
|
|
126
|
+
* When `persistent` is enabled, EVERY state — including terminal states with no
|
|
127
|
+
* transitions — MUST be declared in `states` (e.g. `success: {}`). The engine uses
|
|
128
|
+
* the presence of a state's config entry to validate rehydrated state names from
|
|
129
|
+
* storage; an undeclared state is treated as removed/renamed and the machine is
|
|
130
|
+
* reset to `initial`.
|
|
131
|
+
*
|
|
112
132
|
* @template TState The union type of all possible states.
|
|
113
133
|
* @template TEvent The union type of all possible events.
|
|
114
134
|
* @template TContext The type of the machine's context.
|
|
@@ -116,7 +136,7 @@ export interface FsmPersistenceConfig {
|
|
|
116
136
|
export interface StateMachineConfig<TState extends string, TEvent extends MachineEvent, TContext extends Record<string, unknown>> extends Pick<SignalConfig, 'name'> {
|
|
117
137
|
/** The initial finite state value. */
|
|
118
138
|
readonly initial: TState;
|
|
119
|
-
/** The initial context (extended state) of the machine. */
|
|
139
|
+
/** The initial context (extended state) of the machine. Must be serializable. */
|
|
120
140
|
readonly context: TContext;
|
|
121
141
|
/** If provided, the FSM's state will be persisted in localStorage. */
|
|
122
142
|
persistent?: FsmPersistenceConfig;
|
|
@@ -129,12 +149,12 @@ export interface StateMachineConfig<TState extends string, TEvent extends Machin
|
|
|
129
149
|
type: E;
|
|
130
150
|
}>, TContext>>;
|
|
131
151
|
};
|
|
132
|
-
/**
|
|
152
|
+
/** Synchronous side-effects executed upon entering this state. */
|
|
133
153
|
readonly entry?: SingleOrArray<Effect<TEvent, TContext>>;
|
|
134
|
-
/**
|
|
154
|
+
/** Synchronous side-effects executed upon exiting this state. */
|
|
135
155
|
readonly exit?: SingleOrArray<Effect<TEvent, TContext>>;
|
|
136
|
-
/**
|
|
137
|
-
readonly
|
|
156
|
+
/** Async lifecycle actors spawned upon entering this state, cleaned up (LIFO) when leaving. */
|
|
157
|
+
readonly actor?: SingleOrArray<Actor<TEvent, TContext>>;
|
|
138
158
|
};
|
|
139
159
|
};
|
|
140
160
|
}
|
package/dist/type.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"type.d.ts","sourceRoot":"","sources":["../src/type.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAC,SAAS,EAAE,aAAa,EAAC,MAAM,qBAAqB,CAAC;AAClE,OAAO,KAAK,EAAC,YAAY,EAAC,MAAM,gBAAgB,CAAC;AAEjD;;;;;;GAMG;AACH,MAAM,MAAM,YAAY,CAAC,MAAM,SAAS,MAAM,EAAE,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,IAAI;IAC1F,sCAAsC;IACtC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,8EAA8E;IAC9E,QAAQ,CAAC,OAAO,EAAE,QAAQ,CAAC;CAC5B,CAAC;AAEF;;;;;GAKG;AACH,MAAM,WAAW,YAAY,CAAC,UAAU,SAAS,MAAM,GAAG,MAAM;IAC9D,oCAAoC;IACpC,QAAQ,CAAC,IAAI,EAAE,UAAU,CAAC;IAC1B,
|
|
1
|
+
{"version":3,"file":"type.d.ts","sourceRoot":"","sources":["../src/type.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alwatr/fsm",
|
|
3
|
-
"version": "9.
|
|
3
|
+
"version": "9.33.0",
|
|
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)",
|
|
@@ -21,12 +21,13 @@
|
|
|
21
21
|
},
|
|
22
22
|
"sideEffects": false,
|
|
23
23
|
"dependencies": {
|
|
24
|
-
"@alwatr/
|
|
25
|
-
"@alwatr/
|
|
24
|
+
"@alwatr/delay": "9.33.0",
|
|
25
|
+
"@alwatr/logger": "9.33.0",
|
|
26
|
+
"@alwatr/signal": "9.33.0"
|
|
26
27
|
},
|
|
27
28
|
"devDependencies": {
|
|
28
29
|
"@alwatr/nano-build": "9.25.0",
|
|
29
|
-
"@alwatr/standard": "9.
|
|
30
|
+
"@alwatr/standard": "9.33.0",
|
|
30
31
|
"@alwatr/type-helper": "9.14.0",
|
|
31
32
|
"typescript": "^6.0.3"
|
|
32
33
|
},
|
|
@@ -65,5 +66,5 @@
|
|
|
65
66
|
"state",
|
|
66
67
|
"typescript"
|
|
67
68
|
],
|
|
68
|
-
"gitHead": "
|
|
69
|
+
"gitHead": "ba7e63efa62c86c7845269f94c4cc9924c38ff21"
|
|
69
70
|
}
|
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,47 +27,123 @@ 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
63
|
this.logger_.logMethodArgs?.('constructor', config_);
|
|
46
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
|
+
}));
|
|
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
117
|
this.logger_.logMethodArgs?.('dispatch', {event});
|
|
64
|
-
this.
|
|
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
|
+
this.logger_.logMethod?.('processMailbox__');
|
|
127
|
+
if (this.destroyed__) {
|
|
128
|
+
this.logger_.incident?.('dispatch', 'dispatch_after_destroy', {event});
|
|
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
|
*/
|
|
@@ -74,7 +151,7 @@ export class FsmService<
|
|
|
74
151
|
const currentState = this.stateSignal__.get();
|
|
75
152
|
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
157
|
this.logger_.incident?.('processTransition__', 'ignored_event', 'No valid transition found for event', {
|
|
@@ -87,74 +164,80 @@ export class FsmService<
|
|
|
87
164
|
const targetStateName = transition.target ?? currentState.name;
|
|
88
165
|
const isExternalTransition = transition.target !== undefined;
|
|
89
166
|
|
|
90
|
-
// 1.
|
|
167
|
+
// 1. External transition: run exit effects (with the OLD context, per SCXML semantics) and tear down the current state's actors.
|
|
91
168
|
if (isExternalTransition) {
|
|
92
169
|
this.executeEffects__(event, currentState.context, this.config_.states[currentState.name]?.exit);
|
|
93
170
|
this.cleanupActors__();
|
|
94
171
|
}
|
|
95
172
|
|
|
96
|
-
// 2. Apply assigners to compute the next context
|
|
97
|
-
const nextContext = this.applyAssigners__(event, currentState.context, transition.
|
|
173
|
+
// 2. Apply assigners to compute the next context (pure, atomic).
|
|
174
|
+
const nextContext = this.applyAssigners__(event, currentState.context, transition.assigner);
|
|
98
175
|
|
|
99
|
-
// 3.
|
|
176
|
+
// 3. Commit the new state, notifying all subscribers (async via signal layer).
|
|
100
177
|
const nextState: MachineState<TState, TContext> = {
|
|
101
178
|
name: targetStateName,
|
|
102
179
|
context: nextContext,
|
|
103
180
|
};
|
|
104
|
-
|
|
105
|
-
// 4. Set the new state, notifying all subscribers.
|
|
106
181
|
this.stateSignal__.set(nextState);
|
|
107
182
|
|
|
108
|
-
//
|
|
183
|
+
// 4. External transition: run entry effects (with the NEW context) and spawn the target state's actors.
|
|
109
184
|
if (isExternalTransition) {
|
|
110
185
|
this.executeEffects__(event, nextState.context, this.config_.states[nextState.name]?.entry);
|
|
111
|
-
this.spawnActors__(event, nextState.context, this.config_.states[nextState.name]?.
|
|
186
|
+
this.spawnActors__(event, nextState.context, this.config_.states[nextState.name]?.actor);
|
|
112
187
|
}
|
|
113
188
|
}
|
|
114
189
|
|
|
115
190
|
/**
|
|
116
|
-
* Finds the first valid transition for the given event
|
|
191
|
+
* 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
192
|
*
|
|
118
193
|
* @param event The triggering event.
|
|
119
|
-
* @param
|
|
194
|
+
* @param currentState The current state of the machine.
|
|
120
195
|
* @returns The first matching transition or `undefined` if none are found.
|
|
121
196
|
*/
|
|
122
197
|
private findTransition__(
|
|
123
198
|
event: TEvent,
|
|
124
|
-
|
|
199
|
+
currentState: MachineState<TState, TContext>,
|
|
125
200
|
): Transition<TState, TEvent, TContext> | undefined {
|
|
126
201
|
this.logger_.logMethod?.('findTransition__');
|
|
127
202
|
|
|
128
|
-
const
|
|
129
|
-
const currentStateConfig = this.config_.states[currentStateName];
|
|
203
|
+
const currentStateConfig = this.config_.states[currentState.name];
|
|
130
204
|
const transitions = currentStateConfig?.on?.[event.type as TEvent['type']] as
|
|
131
205
|
| SingleOrArray<Transition<TState, TEvent, TContext>>
|
|
132
206
|
| undefined;
|
|
133
207
|
|
|
134
208
|
if (!transitions) return undefined;
|
|
135
209
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
for (let index = 0; index < transitionsArray.length; index++) {
|
|
139
|
-
const transition = transitionsArray[index];
|
|
140
|
-
if (!transition.guard) return transition;
|
|
210
|
+
if (!Array.isArray(transitions)) {
|
|
211
|
+
if (!transitions.guard) return transitions; // Unconditional fallback branch.
|
|
141
212
|
try {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
213
|
+
if (transitions.guard(event, currentState.context)) {
|
|
214
|
+
return transitions;
|
|
215
|
+
}
|
|
216
|
+
} catch (error) {
|
|
217
|
+
this.logger_.error('findTransition__', 'guard_failed', error, {
|
|
218
|
+
state: currentState.name,
|
|
145
219
|
eventType: event.type,
|
|
146
|
-
transitionIndex: index,
|
|
147
|
-
guard: transition.guard.name || 'anonymous',
|
|
148
|
-
result: guardMet,
|
|
149
220
|
});
|
|
150
|
-
|
|
221
|
+
}
|
|
222
|
+
return undefined;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// else if transitions is an array
|
|
226
|
+
|
|
227
|
+
for (let index = 0; index < transitions.length; index++) {
|
|
228
|
+
const transition = transitions[index];
|
|
229
|
+
if (!transition.guard) return transition; // Unconditional fallback branch.
|
|
230
|
+
try {
|
|
231
|
+
if (transition.guard(event, currentState.context)) {
|
|
232
|
+
return transition;
|
|
233
|
+
}
|
|
151
234
|
} catch (error) {
|
|
152
235
|
this.logger_.error('findTransition__', 'guard_failed', error, {
|
|
153
|
-
state:
|
|
236
|
+
state: currentState.name,
|
|
154
237
|
eventType: event.type,
|
|
155
|
-
|
|
156
|
-
guard: transition.guard.name || 'anonymous',
|
|
238
|
+
index,
|
|
157
239
|
});
|
|
240
|
+
// Treated as guard === false: continue evaluating the next branch.
|
|
158
241
|
}
|
|
159
242
|
}
|
|
160
243
|
|
|
@@ -162,7 +245,7 @@ export class FsmService<
|
|
|
162
245
|
}
|
|
163
246
|
|
|
164
247
|
/**
|
|
165
|
-
* Sequentially executes a list of effects (side-effects).
|
|
248
|
+
* Sequentially executes a list of synchronous effects (side-effects).
|
|
166
249
|
* Errors are caught and logged without stopping the FSM.
|
|
167
250
|
*
|
|
168
251
|
* @param event The event that triggered these effects.
|
|
@@ -175,41 +258,45 @@ export class FsmService<
|
|
|
175
258
|
effects?: SingleOrArray<Effect<TEvent, TContext>>,
|
|
176
259
|
): void {
|
|
177
260
|
if (!effects) {
|
|
178
|
-
this.logger_.
|
|
261
|
+
this.logger_.logMethod?.('executeEffects__.skipped');
|
|
179
262
|
return;
|
|
180
263
|
}
|
|
181
|
-
const effectsArray = Array.isArray(effects) ? effects : [effects];
|
|
182
264
|
|
|
183
|
-
this.logger_.
|
|
265
|
+
this.logger_.logMethod?.('executeEffects__');
|
|
184
266
|
|
|
185
|
-
|
|
267
|
+
if (!Array.isArray(effects)) {
|
|
186
268
|
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
|
-
}
|
|
269
|
+
effects(event, context);
|
|
198
270
|
} catch (error) {
|
|
199
271
|
this.logger_.error('executeEffects__', 'effect_failed', error, {
|
|
200
|
-
effect: effect.name || 'anonymous',
|
|
201
|
-
state: this.stateSignal__.get().name,
|
|
202
272
|
event,
|
|
203
273
|
context,
|
|
204
274
|
});
|
|
205
275
|
}
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// else if effects is an array
|
|
280
|
+
|
|
281
|
+
for (let index = 0; index < effects.length; index++) {
|
|
282
|
+
const effect = effects[index];
|
|
283
|
+
try {
|
|
284
|
+
effect(event, context);
|
|
285
|
+
} catch (error) {
|
|
286
|
+
this.logger_.error('executeEffects__', 'effect_failed', error, {
|
|
287
|
+
event,
|
|
288
|
+
context,
|
|
289
|
+
index,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
206
292
|
}
|
|
207
293
|
}
|
|
208
294
|
|
|
209
295
|
/**
|
|
210
296
|
* Applies all assigner functions to the context to produce a new, updated context.
|
|
211
|
-
*
|
|
212
|
-
*
|
|
297
|
+
*
|
|
298
|
+
* This process is atomic (all-or-nothing): if any assigner throws, the original
|
|
299
|
+
* context is returned and all updates are discarded.
|
|
213
300
|
*
|
|
214
301
|
* @param event The event that triggered the transition.
|
|
215
302
|
* @param context The current context.
|
|
@@ -222,49 +309,55 @@ export class FsmService<
|
|
|
222
309
|
assigners?: SingleOrArray<Assigner<TEvent, TContext>>,
|
|
223
310
|
): TContext {
|
|
224
311
|
if (!assigners) {
|
|
225
|
-
this.logger_.
|
|
312
|
+
this.logger_.logMethod?.('applyAssigners__.skipped');
|
|
226
313
|
return context;
|
|
227
314
|
}
|
|
228
315
|
|
|
229
|
-
|
|
316
|
+
this.logger_.logMethod?.('applyAssigners__');
|
|
317
|
+
|
|
318
|
+
if (!Array.isArray(assigners)) {
|
|
319
|
+
try {
|
|
320
|
+
return assigners(event, context) ?? context;
|
|
321
|
+
} catch (error) {
|
|
322
|
+
this.logger_.error('applyAssigners__', 'assigner_failed_atomic', error, {event, context});
|
|
323
|
+
}
|
|
324
|
+
return context;
|
|
325
|
+
}
|
|
230
326
|
|
|
231
|
-
|
|
327
|
+
// else if assigners is an array
|
|
232
328
|
|
|
233
329
|
try {
|
|
234
330
|
let accContext = context;
|
|
235
|
-
for (
|
|
236
|
-
const
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
{event, accContext},
|
|
240
|
-
nextContext,
|
|
241
|
-
);
|
|
242
|
-
if (nextContext !== undefined && nextContext !== null) {
|
|
331
|
+
for (let index = 0; index < assigners.length; index++) {
|
|
332
|
+
const assigner = assigners[index];
|
|
333
|
+
const nextContext = assigner(event, accContext);
|
|
334
|
+
if (nextContext) {
|
|
243
335
|
accContext = nextContext;
|
|
244
336
|
}
|
|
245
337
|
}
|
|
246
338
|
return accContext;
|
|
247
339
|
} catch (error) {
|
|
248
|
-
this.logger_.error('applyAssigners__', 'assigner_failed_atomic', error, {
|
|
249
|
-
event,
|
|
250
|
-
context, // Log the original context for debugging.
|
|
251
|
-
});
|
|
340
|
+
this.logger_.error('applyAssigners__', 'assigner_failed_atomic', error, {event, context});
|
|
252
341
|
// On ANY failure, discard all changes and return the original context.
|
|
253
342
|
return context;
|
|
254
343
|
}
|
|
255
344
|
}
|
|
256
345
|
|
|
257
346
|
/**
|
|
258
|
-
* Starts the FSM by executing the entry effects and spawning the actors
|
|
259
|
-
*
|
|
347
|
+
* Starts the FSM by executing the entry effects and spawning the actors of the
|
|
348
|
+
* initial (or rehydrated) state, using the synthetic `{type: '__init__'}` event.
|
|
260
349
|
*/
|
|
261
350
|
protected start_(): void {
|
|
262
|
-
if (this.
|
|
351
|
+
if (this.destroyed__) return;
|
|
263
352
|
this.logger_.logMethod?.('start_');
|
|
264
353
|
const currentState = this.stateSignal__.get();
|
|
265
354
|
const initEvent = {type: '__init__'} as unknown as TEvent;
|
|
266
355
|
this.executeEffects__(initEvent, currentState.context, this.config_.states[currentState.name]?.entry);
|
|
267
|
-
this.spawnActors__(initEvent, currentState.context, this.config_.states[currentState.name]?.
|
|
356
|
+
this.spawnActors__(initEvent, currentState.context, this.config_.states[currentState.name]?.actor);
|
|
357
|
+
this.processing__ = false; // Allow processing of dispatched events after the initial setup is complete.
|
|
358
|
+
if (this.mailbox__.length > 0) {
|
|
359
|
+
this.processMailbox__(); // Process any events that were dispatched during the initial setup.
|
|
360
|
+
}
|
|
268
361
|
}
|
|
269
362
|
|
|
270
363
|
/**
|
|
@@ -276,57 +369,68 @@ export class FsmService<
|
|
|
276
369
|
actors?: SingleOrArray<Actor<TEvent, TContext>>,
|
|
277
370
|
): void {
|
|
278
371
|
if (!actors) {
|
|
279
|
-
this.logger_.
|
|
372
|
+
this.logger_.logMethod?.('spawnActors__.skipped');
|
|
280
373
|
return;
|
|
281
374
|
}
|
|
282
|
-
const actorsArray = Array.isArray(actors) ? actors : [actors];
|
|
283
375
|
|
|
284
|
-
this.logger_.
|
|
376
|
+
this.logger_.logMethod?.('spawnActors__');
|
|
285
377
|
|
|
286
|
-
|
|
378
|
+
if (!Array.isArray(actors)) {
|
|
287
379
|
try {
|
|
288
|
-
const cleanup =
|
|
289
|
-
event,
|
|
290
|
-
context,
|
|
291
|
-
dispatch: this.dispatch,
|
|
292
|
-
});
|
|
380
|
+
const cleanup = actors(context, this.dispatch);
|
|
293
381
|
if (typeof cleanup === 'function') {
|
|
294
|
-
this.activeActorCleanups__.
|
|
382
|
+
this.activeActorCleanups__.push(cleanup);
|
|
295
383
|
}
|
|
296
384
|
} catch (error) {
|
|
297
|
-
this.logger_.error('spawnActors__', 'actor_failed', error, {
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
385
|
+
this.logger_.error('spawnActors__', 'actor_failed', error, {event, context});
|
|
386
|
+
}
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// else if actors is an array
|
|
391
|
+
|
|
392
|
+
for (let index = 0; index < actors.length; index++) {
|
|
393
|
+
const actor = actors[index];
|
|
394
|
+
try {
|
|
395
|
+
const cleanup = actor(context, this.dispatch);
|
|
396
|
+
if (typeof cleanup === 'function') {
|
|
397
|
+
this.activeActorCleanups__.push(cleanup);
|
|
398
|
+
}
|
|
399
|
+
} catch (error) {
|
|
400
|
+
this.logger_.error('spawnActors__', 'actor_failed', error, {event, context, index});
|
|
303
401
|
}
|
|
304
402
|
}
|
|
305
403
|
}
|
|
306
404
|
|
|
307
405
|
/**
|
|
308
|
-
* Cleans up (destroys) all currently active state actors.
|
|
406
|
+
* Cleans up (destroys) all currently active state actors in REVERSE (LIFO) spawn order — standard resource-release semantics.
|
|
309
407
|
*/
|
|
310
408
|
private cleanupActors__(): void {
|
|
311
|
-
this.logger_.logMethodArgs?.('cleanupActors__', {count: this.activeActorCleanups__.
|
|
312
|
-
for (
|
|
409
|
+
this.logger_.logMethodArgs?.('cleanupActors__', {count: this.activeActorCleanups__.length});
|
|
410
|
+
for (let index = this.activeActorCleanups__.length - 1; index >= 0; index--) {
|
|
313
411
|
try {
|
|
314
|
-
|
|
412
|
+
this.activeActorCleanups__[index]();
|
|
315
413
|
} catch (error) {
|
|
316
|
-
this.logger_.error('cleanupActors__', 'cleanup_failed', error);
|
|
414
|
+
this.logger_.error('cleanupActors__', 'cleanup_failed', error, {index});
|
|
317
415
|
}
|
|
318
416
|
}
|
|
319
|
-
this.activeActorCleanups__.
|
|
417
|
+
this.activeActorCleanups__.length = 0;
|
|
320
418
|
}
|
|
321
419
|
|
|
322
420
|
/**
|
|
323
|
-
* Destroys the service, cleaning up
|
|
324
|
-
*
|
|
421
|
+
* Destroys the service, cleaning up actors, the mailbox, and owned signals to
|
|
422
|
+
* prevent memory leaks. Idempotent — safe to call multiple times.
|
|
423
|
+
*
|
|
424
|
+
* @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
425
|
*/
|
|
326
|
-
public destroy(): void {
|
|
426
|
+
public destroy(destroyState = true): void {
|
|
427
|
+
if (this.destroyed__) return;
|
|
327
428
|
this.logger_.logMethod?.('destroy');
|
|
429
|
+
this.destroyed__ = true;
|
|
430
|
+
this.mailbox__.length = 0;
|
|
328
431
|
this.cleanupActors__();
|
|
329
|
-
|
|
330
|
-
|
|
432
|
+
if (destroyState) {
|
|
433
|
+
this.stateSignal__.destroy();
|
|
434
|
+
}
|
|
331
435
|
}
|
|
332
436
|
}
|
package/src/type.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {JsonValue, SingleOrArray} from '@alwatr/type-helper';
|
|
2
2
|
import type {SignalConfig} from '@alwatr/signal';
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -24,66 +24,90 @@ export type MachineState<TState extends string, TContext extends Record<string,
|
|
|
24
24
|
export interface MachineEvent<TEventType extends string = string> {
|
|
25
25
|
/** The unique type of the event. */
|
|
26
26
|
readonly type: TEventType;
|
|
27
|
-
/** An event can carry an optional payload. */
|
|
28
|
-
[key: string]:
|
|
27
|
+
/** An event can carry an optional, serializable payload. */
|
|
28
|
+
[key: string]: JsonValue;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
|
-
* Defines an assigner
|
|
33
|
-
* It returns the complete new context object or void/undefined if no changes are made.
|
|
32
|
+
* Defines an assigner — a **pure, synchronous context reducer** applied during transitions.
|
|
34
33
|
*
|
|
35
|
-
* @
|
|
36
|
-
* @
|
|
37
|
-
* @returns The complete next context object or void.
|
|
34
|
+
* @param event The event that triggered the transition. Readonly to prevent mutations.
|
|
35
|
+
* @param context The current context before the transition. Mutable for convenience, but treat it as immutable — return a new context object instead of mutating it.
|
|
36
|
+
* @returns The complete next context object, or void.
|
|
38
37
|
*/
|
|
39
|
-
export type Assigner<TEvent extends MachineEvent, TContext extends Record<string, unknown>> = (
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
38
|
+
export type Assigner<TEvent extends MachineEvent, TContext extends Record<string, unknown>> = (
|
|
39
|
+
event: Readonly<TEvent>,
|
|
40
|
+
context: TContext,
|
|
41
|
+
) => TContext | void;
|
|
43
42
|
|
|
44
43
|
/**
|
|
45
|
-
* Defines an effect
|
|
46
|
-
*
|
|
44
|
+
* Defines an effect — a **strictly synchronous**, fire-and-forget side-effect
|
|
45
|
+
* executed on state entry/exit.
|
|
47
46
|
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
47
|
+
* ## Why synchronous-only? (Architectural decision)
|
|
48
|
+
*
|
|
49
|
+
* The FSM core is a deterministic, Run-to-Completion (RTC) step function:
|
|
50
|
+
* `(state, event) -> (state', effects)`. Allowing async effects inside the core
|
|
51
|
+
* creates ordering ambiguity — the continuation of an async effect may run against
|
|
52
|
+
* a state/context that no longer exists. This mirrors the design of SCXML actions,
|
|
53
|
+
* XState actions, and Erlang's gen_statem.
|
|
54
|
+
*
|
|
55
|
+
* **Any asynchronous work belongs in an {@link Actor}**, which has a proper
|
|
56
|
+
* lifecycle (spawn on entry, cleanup on exit) and communicates results back to
|
|
57
|
+
* the machine via `dispatch`, keeping the core deterministic.
|
|
58
|
+
*
|
|
59
|
+
* @param event The event that triggered the effect. Readonly to prevent mutations.
|
|
60
|
+
* @param context The current context of the machine. Readonly to prevent mutations.
|
|
51
61
|
*/
|
|
52
|
-
export type Effect<TEvent extends MachineEvent, TContext extends Record<string, unknown>> = (
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
62
|
+
export type Effect<TEvent extends MachineEvent, TContext extends Record<string, unknown>> = (
|
|
63
|
+
event: Readonly<TEvent>,
|
|
64
|
+
context: Readonly<TContext>,
|
|
65
|
+
) => void;
|
|
56
66
|
|
|
57
67
|
/**
|
|
58
68
|
* Defines a conditional guard function for a transition.
|
|
59
69
|
* The transition is only taken if this function returns true.
|
|
60
70
|
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
71
|
+
* Guards MUST be pure and synchronous. A guard that throws is treated as `false`
|
|
72
|
+
* (logged, transition branch skipped) so a single faulty predicate cannot brick
|
|
73
|
+
* the machine.
|
|
74
|
+
*
|
|
75
|
+
* @param event The event that triggered the transition. Readonly to prevent mutations.
|
|
76
|
+
* @param context The current context of the machine. Readonly to prevent mutations.
|
|
63
77
|
* @returns `true` if the transition should be taken, `false` otherwise.
|
|
64
78
|
*/
|
|
65
|
-
export type Guard<TEvent extends MachineEvent, TContext extends Record<string, unknown>> = (
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
79
|
+
export type Guard<TEvent extends MachineEvent, TContext extends Record<string, unknown>> = (
|
|
80
|
+
event: Readonly<TEvent>,
|
|
81
|
+
context: Readonly<TContext>,
|
|
82
|
+
) => boolean;
|
|
69
83
|
|
|
70
84
|
/**
|
|
71
|
-
* Defines an actor
|
|
72
|
-
*
|
|
73
|
-
*
|
|
85
|
+
* Defines an actor — an **asynchronous lifecycle process** spawned on state entry.
|
|
86
|
+
*
|
|
87
|
+
* This is the ONLY sanctioned home for async work in the machine (network requests,
|
|
88
|
+
* polling intervals, websocket listeners, timers). An actor:
|
|
89
|
+
*
|
|
90
|
+
* 1. Is spawned when the machine enters the state.
|
|
91
|
+
* 2. Receives `dispatch` to asynchronously send events back to the parent FSM.
|
|
92
|
+
* 3. May return a synchronous cleanup function, executed automatically (in LIFO
|
|
93
|
+
* order) when the machine exits the state or is destroyed.
|
|
74
94
|
*
|
|
75
95
|
* @template TEvent The union type of all events in the machine.
|
|
76
96
|
* @template TContext The type of the machine's context.
|
|
77
97
|
*/
|
|
78
|
-
export type Actor<TEvent extends MachineEvent, TContext extends Record<string, unknown>> = (
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
}) => (() => void) | void;
|
|
98
|
+
export type Actor<TEvent extends MachineEvent, TContext extends Record<string, unknown>> = (
|
|
99
|
+
context: Readonly<TContext>,
|
|
100
|
+
dispatch: (event: TEvent) => void,
|
|
101
|
+
) => VoidFunction | void;
|
|
83
102
|
|
|
84
103
|
/**
|
|
85
104
|
* Defines a transition for a given state and event. It specifies the target state,
|
|
86
|
-
*
|
|
105
|
+
* assigners, and an optional guard.
|
|
106
|
+
*
|
|
107
|
+
* - With `target`: an **external** transition — exit effects run, actors are cleaned
|
|
108
|
+
* up, then entry effects run and actors are re-spawned (even on self-transitions).
|
|
109
|
+
* - Without `target`: an **internal** transition — only assigners run; entry/exit
|
|
110
|
+
* effects and actors are untouched.
|
|
87
111
|
*
|
|
88
112
|
* @template TState The type of the state.
|
|
89
113
|
* @template TEvent The type of the event.
|
|
@@ -98,8 +122,8 @@ export interface Transition<
|
|
|
98
122
|
readonly target?: TState;
|
|
99
123
|
/** A guard function that must return true for the transition to occur. */
|
|
100
124
|
readonly guard?: Guard<TEvent, TContext>;
|
|
101
|
-
/**
|
|
102
|
-
readonly
|
|
125
|
+
/** A single assigner or an ordered chain of assigners. Applied atomically. */
|
|
126
|
+
readonly assigner?: SingleOrArray<Assigner<TEvent, TContext>>;
|
|
103
127
|
}
|
|
104
128
|
|
|
105
129
|
/**
|
|
@@ -123,6 +147,14 @@ export interface FsmPersistenceConfig {
|
|
|
123
147
|
* The declarative configuration object for creating a state machine.
|
|
124
148
|
* This object defines the entire behavior of the machine.
|
|
125
149
|
*
|
|
150
|
+
* ## Persistence requirement
|
|
151
|
+
*
|
|
152
|
+
* When `persistent` is enabled, EVERY state — including terminal states with no
|
|
153
|
+
* transitions — MUST be declared in `states` (e.g. `success: {}`). The engine uses
|
|
154
|
+
* the presence of a state's config entry to validate rehydrated state names from
|
|
155
|
+
* storage; an undeclared state is treated as removed/renamed and the machine is
|
|
156
|
+
* reset to `initial`.
|
|
157
|
+
*
|
|
126
158
|
* @template TState The union type of all possible states.
|
|
127
159
|
* @template TEvent The union type of all possible events.
|
|
128
160
|
* @template TContext The type of the machine's context.
|
|
@@ -135,7 +167,7 @@ export interface StateMachineConfig<
|
|
|
135
167
|
/** The initial finite state value. */
|
|
136
168
|
readonly initial: TState;
|
|
137
169
|
|
|
138
|
-
/** The initial context (extended state) of the machine. */
|
|
170
|
+
/** The initial context (extended state) of the machine. Must be serializable. */
|
|
139
171
|
readonly context: TContext;
|
|
140
172
|
|
|
141
173
|
/** If provided, the FSM's state will be persisted in localStorage. */
|
|
@@ -148,12 +180,12 @@ export interface StateMachineConfig<
|
|
|
148
180
|
readonly on?: {
|
|
149
181
|
readonly [E in TEvent['type']]?: SingleOrArray<Transition<TState, Extract<TEvent, {type: E}>, TContext>>;
|
|
150
182
|
};
|
|
151
|
-
/**
|
|
183
|
+
/** Synchronous side-effects executed upon entering this state. */
|
|
152
184
|
readonly entry?: SingleOrArray<Effect<TEvent, TContext>>;
|
|
153
|
-
/**
|
|
185
|
+
/** Synchronous side-effects executed upon exiting this state. */
|
|
154
186
|
readonly exit?: SingleOrArray<Effect<TEvent, TContext>>;
|
|
155
|
-
/**
|
|
156
|
-
readonly
|
|
187
|
+
/** Async lifecycle actors spawned upon entering this state, cleaned up (LIFO) when leaving. */
|
|
188
|
+
readonly actor?: SingleOrArray<Actor<TEvent, TContext>>;
|
|
157
189
|
};
|
|
158
190
|
};
|
|
159
191
|
}
|