@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.
@@ -1 +1 @@
1
- {"version":3,"file":"facade.d.ts","sourceRoot":"","sources":["../src/facade.ts"],"names":[],"mappings":"AAEA,OAAO,EAAC,UAAU,EAAC,MAAM,kBAAkB,CAAC;AAE5C,OAAO,KAAK,EAAC,YAAY,EAAgB,kBAAkB,EAAC,MAAM,WAAW,CAAC;AAE9E;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;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,CAoB5F"}
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"}
@@ -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
- /** The set of cleanup functions for currently active state actors. */
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
- constructor(config_: StateMachineConfig<TState, TEvent, TContext>, stateSignal__: StateSignal<MachineState<TState, TContext>> | PersistentStateSignal<MachineState<TState, TContext>>);
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 process is atomic and follows the Run-to-Completion (RTC) model.
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 and context by evaluating guards.
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 context The current machine context.
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
- * This process is atomic (all-or-nothing). If any assigner fails, the original
57
- * context is returned, and all updates are discarded.
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
- * of the initial/current state.
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 all internal signals and subscriptions
80
- * to prevent memory leaks.
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":"AACA,OAAO,EAAe,KAAK,YAAY,EAAC,MAAM,gBAAgB,CAAC;AAC/D,OAAO,EAEL,KAAK,WAAW,EAChB,KAAK,qBAAqB,EAE1B,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;IAc9D,SAAS,CAAC,QAAQ,CAAC,OAAO,EAAE,kBAAkB,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,CAAC;IACxE,OAAO,CAAC,QAAQ,CAAC,aAAa;IAbhC,SAAS,CAAC,QAAQ,CAAC,OAAO,EAAE,YAAY,CAAC;IAEzC,8DAA8D;IAC9D,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAsB;IAEpD,+EAA+E;IAC/E,SAAgB,WAAW,EAAE,eAAe,CAAC,YAAY,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC,CAAC;IAE7E,sEAAsE;IACtE,OAAO,CAAC,QAAQ,CAAC,qBAAqB,CAAyB;gBAG1C,OAAO,EAAE,kBAAkB,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,CAAC,EACvD,aAAa,EAC1B,WAAW,CAAC,YAAY,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC,GAC3C,qBAAqB,CAAC,YAAY,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC;IAe3D;;;;OAIG;IACH,SAAgB,QAAQ,GAAI,OAAO,MAAM,KAAG,IAAI,CAG9C;IAEF;;;;;OAKG;IACH,OAAO,CAAC,mBAAmB;IA0C3B;;;;;;OAMG;IACH,OAAO,CAAC,gBAAgB;IA0CxB;;;;;;;OAOG;IACH,OAAO,CAAC,gBAAgB;IAqCxB;;;;;;;;;OASG;IACH,OAAO,CAAC,gBAAgB;IAsCxB;;;OAGG;IACH,SAAS,CAAC,MAAM,IAAI,IAAI;IASxB;;OAEG;IACH,OAAO,CAAC,aAAa;IAkCrB;;OAEG;IACH,OAAO,CAAC,eAAe;IAYvB;;;OAGG;IACI,OAAO,IAAI,IAAI;CAMvB"}
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.31.0 */
2
- import{createPersistentStateSignal as U,createStateSignal as D}from"@alwatr/signal";import{createLogger as k}from"@alwatr/logger";import{createEventSignal as P}from"@alwatr/signal";class j{config_;stateSignal__;logger_;eventSignal__;stateSignal;activeActorCleanups__=new Set;constructor(q,z){this.config_=q;this.stateSignal__=z;this.logger_=k(`fsm:${this.config_.name}`),this.logger_.logMethodArgs?.("constructor",q),this.stateSignal=this.stateSignal__.asReadonly(),this.eventSignal__=P({name:`fsm-event-${this.config_.name}`}),this.eventSignal__.subscribe((J)=>this.processTransition__(J),{receivePrevious:!1}),this.start_()}dispatch=(q)=>{this.logger_.logMethodArgs?.("dispatch",{event:q}),this.eventSignal__.dispatch(q)};processTransition__(q){let z=this.stateSignal__.get();this.logger_.logMethodArgs?.("processTransition__",{state:z.name,event:q});let J=this.findTransition__(q,z.context);if(!J){this.logger_.incident?.("processTransition__","ignored_event","No valid transition found for event",{state:z.name,event:q});return}let X=J.target??z.name,K=J.target!==void 0;if(K)this.executeEffects__(q,z.context,this.config_.states[z.name]?.exit),this.cleanupActors__();let W=this.applyAssigners__(q,z.context,J.assigners),Q={name:X,context:W};if(this.stateSignal__.set(Q),K)this.executeEffects__(q,Q.context,this.config_.states[Q.name]?.entry),this.spawnActors__(q,Q.context,this.config_.states[Q.name]?.actors)}findTransition__(q,z){this.logger_.logMethod?.("findTransition__");let J=this.stateSignal__.get().name,K=this.config_.states[J]?.on?.[q.type];if(!K)return;let W=Array.isArray(K)?K:[K];for(let Q=0;Q<W.length;Q++){let Y=W[Q];if(!Y.guard)return Y;try{let Z=Y.guard({event:q,context:z});if(this.logger_.logStep?.("findTransition__","check_guard",{state:J,eventType:q.type,transitionIndex:Q,guard:Y.guard.name||"anonymous",result:Z}),Z)return Y}catch(Z){this.logger_.error("findTransition__","guard_failed",Z,{state:J,eventType:q.type,transitionIndex:Q,guard:Y.guard.name||"anonymous"})}}return}executeEffects__(q,z,J){if(!J){this.logger_.logMethodArgs?.("executeEffects__//skipped",{count:0});return}let X=Array.isArray(J)?J:[J];this.logger_.logMethodArgs?.("executeEffects__",{count:X.length});for(let K of X)try{let W=K({event:q,context:z});if(W instanceof Promise)W.catch((Q)=>{this.logger_.error("executeEffects__","effect_failed",Q,{effect:K.name||"anonymous",state:this.stateSignal__.get().name,event:q,context:z})})}catch(W){this.logger_.error("executeEffects__","effect_failed",W,{effect:K.name||"anonymous",state:this.stateSignal__.get().name,event:q,context:z})}}applyAssigners__(q,z,J){if(!J)return this.logger_.logMethodArgs?.("applyAssigners__//skipped",{count:0}),z;let X=Array.isArray(J)?J:[J];this.logger_.logMethodArgs?.("applyAssigners__",{count:X.length});try{let K=z;for(let W of X){let Q=W({event:q,context:K});if(this.logger_.logMethodFull?.(`event.${q.type}.action.${W.name||"anonymous"}`,{event:q,accContext:K},Q),Q!==void 0&&Q!==null)K=Q}return K}catch(K){return this.logger_.error("applyAssigners__","assigner_failed_atomic",K,{event:q,context:z}),z}}start_(){if(this.eventSignal__.isDestroyed)return;this.logger_.logMethod?.("start_");let q=this.stateSignal__.get(),z={type:"__init__"};this.executeEffects__(z,q.context,this.config_.states[q.name]?.entry),this.spawnActors__(z,q.context,this.config_.states[q.name]?.actors)}spawnActors__(q,z,J){if(!J){this.logger_.logMethodArgs?.("spawnActors__//skipped",{count:0});return}let X=Array.isArray(J)?J:[J];this.logger_.logMethodArgs?.("spawnActors__",{count:X.length});for(let K of X)try{let W=K({event:q,context:z,dispatch:this.dispatch});if(typeof W==="function")this.activeActorCleanups__.add(W)}catch(W){this.logger_.error("spawnActors__","actor_failed",W,{actor:K.name||"anonymous",state:this.stateSignal__.get().name,event:q,context:z})}}cleanupActors__(){this.logger_.logMethodArgs?.("cleanupActors__",{count:this.activeActorCleanups__.size});for(let q of this.activeActorCleanups__)try{q()}catch(z){this.logger_.error("cleanupActors__","cleanup_failed",z)}this.activeActorCleanups__.clear()}destroy(){this.logger_.logMethod?.("destroy"),this.cleanupActors__(),this.eventSignal__.destroy(),this.stateSignal__.destroy()}}function O(q){let z={name:q.initial,context:q.context},J=q.persistent?U({name:`fsm-state-${q.name}`,storageKey:q.persistent.storageKey??q.name,initialValue:z,schemaVersion:q.persistent.schemaVersion}):D({name:`fsm-state-${q.name}`,initialValue:z});return new j(q,J)}export{O as createFsmService,j as FsmService};
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=E06E90A5ADCE4D7164756E2164756E21
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/facade.ts", "../src/fsm-service.ts"],
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,sCAAQ,uBAA6B,uBCCrC,uBAAQ,uBACR,4BACE,uBAkBK,MAAM,CAIX,CAaqB,QACF,cAbA,QAGF,cAGD,YAGC,sBAAwB,IAAI,IAE7C,WAAW,CACU,EACF,EAGjB,CAJmB,eACF,qBAIjB,KAAK,QAAU,EAAa,OAAO,KAAK,QAAQ,MAAM,EACtD,KAAK,QAAQ,gBAAgB,cAAe,CAAO,EAEnD,KAAK,YAAc,KAAK,cAAc,WAAW,EACjD,KAAK,cAAgB,EAA0B,CAC7C,KAAM,aAAa,KAAK,QAAQ,MAClC,CAAC,EACD,KAAK,cAAc,UAAU,CAAC,IAAU,KAAK,oBAAoB,CAAK,EAAG,CAAC,gBAAiB,EAAK,CAAC,EAGjG,KAAK,OAAO,EAQE,SAAW,CAAC,IAAwB,CAClD,KAAK,QAAQ,gBAAgB,WAAY,CAAC,OAAK,CAAC,EAChD,KAAK,cAAc,SAAS,CAAK,GAS3B,mBAAmB,CAAC,EAAqB,CAC/C,IAAM,EAAe,KAAK,cAAc,IAAI,EAC5C,KAAK,QAAQ,gBAAgB,sBAAuB,CAAC,MAAO,EAAa,KAAM,OAAK,CAAC,EAErF,IAAM,EAAa,KAAK,iBAAiB,EAAO,EAAa,OAAO,EAEpE,GAAI,CAAC,EAAY,CACf,KAAK,QAAQ,WAAW,sBAAuB,gBAAiB,sCAAuC,CACrG,MAAO,EAAa,KACpB,OACF,CAAC,EACD,OAGF,IAAM,EAAkB,EAAW,QAAU,EAAa,KACpD,EAAuB,EAAW,SAAW,OAGnD,GAAI,EACF,KAAK,iBAAiB,EAAO,EAAa,QAAS,KAAK,QAAQ,OAAO,EAAa,OAAO,IAAI,EAC/F,KAAK,gBAAgB,EAIvB,IAAM,EAAc,KAAK,iBAAiB,EAAO,EAAa,QAAS,EAAW,SAAS,EAGrF,EAA4C,CAChD,KAAM,EACN,QAAS,CACX,EAMA,GAHA,KAAK,cAAc,IAAI,CAAS,EAG5B,EACF,KAAK,iBAAiB,EAAO,EAAU,QAAS,KAAK,QAAQ,OAAO,EAAU,OAAO,KAAK,EAC1F,KAAK,cAAc,EAAO,EAAU,QAAS,KAAK,QAAQ,OAAO,EAAU,OAAO,MAAM,EAWpF,gBAAgB,CACtB,EACA,EACkD,CAClD,KAAK,QAAQ,YAAY,kBAAkB,EAE3C,IAAM,EAAmB,KAAK,cAAc,IAAI,EAAE,KAE5C,EADqB,KAAK,QAAQ,OAAO,IACP,KAAK,EAAM,MAInD,GAAI,CAAC,EAAa,OAElB,IAAM,EAAmB,MAAM,QAAQ,CAAW,EAAI,EAAc,CAAC,CAAW,EAEhF,QAAS,EAAQ,EAAG,EAAQ,EAAiB,OAAQ,IAAS,CAC5D,IAAM,EAAa,EAAiB,GACpC,GAAI,CAAC,EAAW,MAAO,OAAO,EAC9B,GAAI,CACF,IAAM,EAAW,EAAW,MAAM,CAAC,QAAO,SAAO,CAAC,EAQlD,GAPA,KAAK,QAAQ,UAAU,mBAAoB,cAAe,CACxD,MAAO,EACP,UAAW,EAAM,KACjB,gBAAiB,EACjB,MAAO,EAAW,MAAM,MAAQ,YAChC,OAAQ,CACV,CAAC,EACG,EAAU,OAAO,EACrB,MAAO,EAAO,CACd,KAAK,QAAQ,MAAM,mBAAoB,eAAgB,EAAO,CAC5D,MAAO,EACP,UAAW,EAAM,KACjB,gBAAiB,EACjB,MAAO,EAAW,MAAM,MAAQ,WAClC,CAAC,GAIL,OAWM,gBAAgB,CACtB,EACA,EACA,EACM,CACN,GAAI,CAAC,EAAS,CACZ,KAAK,QAAQ,gBAAgB,4BAA6B,CAAC,MAAO,CAAC,CAAC,EACpE,OAEF,IAAM,EAAe,MAAM,QAAQ,CAAO,EAAI,EAAU,CAAC,CAAO,EAEhE,KAAK,QAAQ,gBAAgB,mBAAoB,CAAC,MAAO,EAAa,MAAM,CAAC,EAE7E,QAAW,KAAU,EACnB,GAAI,CACF,IAAM,EAAS,EAAO,CAAC,QAAO,SAAO,CAAC,EACtC,GAAI,aAAkB,QACpB,EAAO,MAAM,CAAC,IAAU,CACtB,KAAK,QAAQ,MAAM,mBAAoB,gBAAiB,EAAO,CAC7D,OAAQ,EAAO,MAAQ,YACvB,MAAO,KAAK,cAAc,IAAI,EAAE,KAChC,QACA,SACF,CAAC,EACF,EAEH,MAAO,EAAO,CACd,KAAK,QAAQ,MAAM,mBAAoB,gBAAiB,EAAO,CAC7D,OAAQ,EAAO,MAAQ,YACvB,MAAO,KAAK,cAAc,IAAI,EAAE,KAChC,QACA,SACF,CAAC,GAeC,gBAAgB,CACtB,EACA,EACA,EACU,CACV,GAAI,CAAC,EAEH,OADA,KAAK,QAAQ,gBAAgB,4BAA6B,CAAC,MAAO,CAAC,CAAC,EAC7D,EAGT,IAAM,EAAiB,MAAM,QAAQ,CAAS,EAAI,EAAY,CAAC,CAAS,EAExE,KAAK,QAAQ,gBAAgB,mBAAoB,CAAC,MAAO,EAAe,MAAM,CAAC,EAE/E,GAAI,CACF,IAAI,EAAa,EACjB,QAAW,KAAY,EAAgB,CACrC,IAAM,EAAc,EAAS,CAAC,QAAO,QAAS,CAAU,CAAC,EAMzD,GALA,KAAK,QAAQ,gBACX,SAAS,EAAM,eAAe,EAAS,MAAQ,cAC/C,CAAC,QAAO,YAAU,EAClB,CACF,EACI,IAAgB,QAAa,IAAgB,KAC/C,EAAa,EAGjB,OAAO,EACP,MAAO,EAAO,CAMd,OALA,KAAK,QAAQ,MAAM,mBAAoB,yBAA0B,EAAO,CACtE,QACA,SACF,CAAC,EAEM,GAQD,MAAM,EAAS,CACvB,GAAI,KAAK,cAAc,YAAa,OACpC,KAAK,QAAQ,YAAY,QAAQ,EACjC,IAAM,EAAe,KAAK,cAAc,IAAI,EACtC,EAAY,CAAC,KAAM,UAAU,EACnC,KAAK,iBAAiB,EAAW,EAAa,QAAS,KAAK,QAAQ,OAAO,EAAa,OAAO,KAAK,EACpG,KAAK,cAAc,EAAW,EAAa,QAAS,KAAK,QAAQ,OAAO,EAAa,OAAO,MAAM,EAM5F,aAAa,CACnB,EACA,EACA,EACM,CACN,GAAI,CAAC,EAAQ,CACX,KAAK,QAAQ,gBAAgB,yBAA0B,CAAC,MAAO,CAAC,CAAC,EACjE,OAEF,IAAM,EAAc,MAAM,QAAQ,CAAM,EAAI,EAAS,CAAC,CAAM,EAE5D,KAAK,QAAQ,gBAAgB,gBAAiB,CAAC,MAAO,EAAY,MAAM,CAAC,EAEzE,QAAW,KAAS,EAClB,GAAI,CACF,IAAM,EAAU,EAAM,CACpB,QACA,UACA,SAAU,KAAK,QACjB,CAAC,EACD,GAAI,OAAO,IAAY,WACrB,KAAK,sBAAsB,IAAI,CAAO,EAExC,MAAO,EAAO,CACd,KAAK,QAAQ,MAAM,gBAAiB,eAAgB,EAAO,CACzD,MAAO,EAAM,MAAQ,YACrB,MAAO,KAAK,cAAc,IAAI,EAAE,KAChC,QACA,SACF,CAAC,GAQC,eAAe,EAAS,CAC9B,KAAK,QAAQ,gBAAgB,kBAAmB,CAAC,MAAO,KAAK,sBAAsB,IAAI,CAAC,EACxF,QAAW,KAAW,KAAK,sBACzB,GAAI,CACF,EAAQ,EACR,MAAO,EAAO,CACd,KAAK,QAAQ,MAAM,kBAAmB,iBAAkB,CAAK,EAGjE,KAAK,sBAAsB,MAAM,EAO5B,OAAO,EAAS,CACrB,KAAK,QAAQ,YAAY,SAAS,EAClC,KAAK,gBAAgB,EACrB,KAAK,cAAc,QAAQ,EAC3B,KAAK,cAAc,QAAQ,EAE/B,CDzQO,SAAS,CAIf,CAAC,EAA4F,CAC5F,IAAM,EAA+C,CACnD,KAAM,EAAO,QACb,QAAS,EAAO,OAClB,EAEM,EACJ,EAAO,WACL,EAA4D,CAC1D,KAAM,aAAa,EAAO,OAC1B,WAAY,EAAO,WAAW,YAAc,EAAO,KACnD,eACA,cAAe,EAAO,WAAW,aACnC,CAAC,EACD,EAAkD,CAChD,KAAM,aAAa,EAAO,OAC1B,aAAc,CAChB,CAAC,EAEL,OAAO,IAAI,EAAW,EAAQ,CAAW",
9
- "debugId": "E06E90A5ADCE4D7164756E2164756E21",
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 { Awaitable, SingleOrArray } from '@alwatr/type-helper';
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]: unknown;
25
+ /** An event can carry an optional, serializable payload. */
26
+ [key: string]: JsonValue;
27
27
  }
28
28
  /**
29
- * Defines an assigner (synchronous action) that updates the context during transitions.
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
- * @template TContext The type of the machine's context.
33
- * @template TEvent The type of the event that triggered this assigner.
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>> = (params: {
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 (fire-and-forget side-effect action) executed on state entry/exit.
42
- * It can interact with the outside world, but does not return new events to trigger transitions.
37
+ * Defines an effect — a **strictly synchronous**, fire-and-forget side-effect
38
+ * executed on state entry/exit.
43
39
  *
44
- * @template TContext The type of the machine's context.
45
- * @template TEvent The type of the event that triggered this effect.
46
- * @returns void or a Promise<void>.
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>> = (params: {
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
- * @template TContext The type of the machine's context.
57
- * @template TEvent The type of the event.
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>> = (params: {
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 (asynchronous lifecycle process) invoked on state entry.
66
- * It starts an operation and can send events back to the parent FSM via `dispatch`.
67
- * It can return a cleanup function to be called when exiting the state or destroying the machine.
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>> = (params: {
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
- * actions, and an optional guard.
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
- /** An array of assigners to execute. These update context synchronously. */
91
- readonly assigners?: SingleOrArray<Assigner<TEvent, TContext>>;
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
- /** An array of side-effect effects to execute upon entering this state. */
152
+ /** Synchronous side-effects executed upon entering this state. */
133
153
  readonly entry?: SingleOrArray<Effect<TEvent, TContext>>;
134
- /** An array of side-effect effects to execute upon exiting this state. */
154
+ /** Synchronous side-effects executed upon exiting this state. */
135
155
  readonly exit?: SingleOrArray<Effect<TEvent, TContext>>;
136
- /** An array of actors to spawn upon entering this state, cleaned up when leaving. */
137
- readonly actors?: SingleOrArray<Actor<TEvent, TContext>>;
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
  }
@@ -1 +1 @@
1
- {"version":3,"file":"type.d.ts","sourceRoot":"","sources":["../src/type.ts"],"names":[],"mappings":"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"}
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.31.0",
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/logger": "9.31.0",
25
- "@alwatr/signal": "9.31.0"
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.16.0",
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": "a2bdb86945c56f8b99b6dd0f8ec714df70dfa3e5"
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, MachineState, StateMachineConfig} from './type.js';
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
- const initialValue: MachineState<TState, TContext> = {
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
  }
@@ -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
- createEventSignal,
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
- /** The set of cleanup functions for currently active state actors. */
36
- private readonly activeActorCleanups__ = new Set<() => void>();
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
- private readonly stateSignal__:
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.eventSignal__.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
+ 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 process is atomic and follows the Run-to-Completion (RTC) model.
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.context);
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. Execute exit effects and cleanup actors of the current state if it's an external transition.
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. This is a pure function.
97
- const nextContext = this.applyAssigners__(event, currentState.context, transition.assigners);
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. Create the final next state object.
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
- // 5. Execute entry effects and spawn actors of the new state if it's an external transition.
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]?.actors);
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 and context by evaluating guards.
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 context The current machine context.
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
- context: Readonly<TContext>,
199
+ currentState: MachineState<TState, TContext>,
125
200
  ): Transition<TState, TEvent, TContext> | undefined {
126
201
  this.logger_.logMethod?.('findTransition__');
127
202
 
128
- const currentStateName = this.stateSignal__.get().name;
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
- const transitionsArray = Array.isArray(transitions) ? transitions : [transitions];
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
- const guardMet = transition.guard({event, context});
143
- this.logger_.logStep?.('findTransition__', 'check_guard', {
144
- state: currentStateName,
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
- if (guardMet) return transition;
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: currentStateName,
236
+ state: currentState.name,
154
237
  eventType: event.type,
155
- transitionIndex: index,
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_.logMethodArgs?.('executeEffects__//skipped', {count: 0});
261
+ this.logger_.logMethod?.('executeEffects__.skipped');
179
262
  return;
180
263
  }
181
- const effectsArray = Array.isArray(effects) ? effects : [effects];
182
264
 
183
- this.logger_.logMethodArgs?.('executeEffects__', {count: effectsArray.length});
265
+ this.logger_.logMethod?.('executeEffects__');
184
266
 
185
- for (const effect of effectsArray) {
267
+ if (!Array.isArray(effects)) {
186
268
  try {
187
- const result = effect({event, context});
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
- * This process is atomic (all-or-nothing). If any assigner fails, the original
212
- * context is returned, and all updates are discarded.
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_.logMethodArgs?.('applyAssigners__//skipped', {count: 0});
312
+ this.logger_.logMethod?.('applyAssigners__.skipped');
226
313
  return context;
227
314
  }
228
315
 
229
- const assignersArray = Array.isArray(assigners) ? assigners : [assigners];
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
- this.logger_.logMethodArgs?.('applyAssigners__', {count: assignersArray.length});
327
+ // else if assigners is an array
232
328
 
233
329
  try {
234
330
  let accContext = context;
235
- for (const assigner of assignersArray) {
236
- const nextContext = assigner({event, context: accContext});
237
- this.logger_.logMethodFull?.(
238
- `event.${event.type}.action.${assigner.name || 'anonymous'}`,
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
- * of the initial/current state.
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.eventSignal__.isDestroyed) return;
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]?.actors);
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_.logMethodArgs?.('spawnActors__//skipped', {count: 0});
372
+ this.logger_.logMethod?.('spawnActors__.skipped');
280
373
  return;
281
374
  }
282
- const actorsArray = Array.isArray(actors) ? actors : [actors];
283
375
 
284
- this.logger_.logMethodArgs?.('spawnActors__', {count: actorsArray.length});
376
+ this.logger_.logMethod?.('spawnActors__');
285
377
 
286
- for (const actor of actorsArray) {
378
+ if (!Array.isArray(actors)) {
287
379
  try {
288
- const cleanup = actor({
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__.add(cleanup);
382
+ this.activeActorCleanups__.push(cleanup);
295
383
  }
296
384
  } catch (error) {
297
- this.logger_.error('spawnActors__', 'actor_failed', error, {
298
- actor: actor.name || 'anonymous',
299
- state: this.stateSignal__.get().name,
300
- event,
301
- context,
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__.size});
312
- for (const cleanup of this.activeActorCleanups__) {
409
+ this.logger_.logMethodArgs?.('cleanupActors__', {count: this.activeActorCleanups__.length});
410
+ for (let index = this.activeActorCleanups__.length - 1; index >= 0; index--) {
313
411
  try {
314
- cleanup();
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__.clear();
417
+ this.activeActorCleanups__.length = 0;
320
418
  }
321
419
 
322
420
  /**
323
- * Destroys the service, cleaning up all internal signals and subscriptions
324
- * to prevent memory leaks.
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
- this.eventSignal__.destroy();
330
- this.stateSignal__.destroy();
432
+ if (destroyState) {
433
+ this.stateSignal__.destroy();
434
+ }
331
435
  }
332
436
  }
package/src/type.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type {Awaitable, SingleOrArray} from '@alwatr/type-helper';
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]: unknown;
27
+ /** An event can carry an optional, serializable payload. */
28
+ [key: string]: JsonValue;
29
29
  }
30
30
 
31
31
  /**
32
- * Defines an assigner (synchronous action) that updates the context during transitions.
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
- * @template TContext The type of the machine's context.
36
- * @template TEvent The type of the event that triggered this assigner.
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>> = (params: {
40
- readonly event: Readonly<TEvent>;
41
- readonly context: TContext;
42
- }) => TContext | void;
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 (fire-and-forget side-effect action) executed on state entry/exit.
46
- * It can interact with the outside world, but does not return new events to trigger transitions.
44
+ * Defines an effect — a **strictly synchronous**, fire-and-forget side-effect
45
+ * executed on state entry/exit.
47
46
  *
48
- * @template TContext The type of the machine's context.
49
- * @template TEvent The type of the event that triggered this effect.
50
- * @returns void or a Promise<void>.
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>> = (params: {
53
- readonly event: Readonly<TEvent>;
54
- readonly context: Readonly<TContext>;
55
- }) => Awaitable<void>;
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
- * @template TContext The type of the machine's context.
62
- * @template TEvent The type of the event.
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>> = (params: {
66
- readonly event: Readonly<TEvent>;
67
- readonly context: Readonly<TContext>;
68
- }) => boolean;
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 (asynchronous lifecycle process) invoked on state entry.
72
- * It starts an operation and can send events back to the parent FSM via `dispatch`.
73
- * It can return a cleanup function to be called when exiting the state or destroying the machine.
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>> = (params: {
79
- readonly event: Readonly<TEvent>;
80
- readonly context: Readonly<TContext>;
81
- readonly dispatch: (event: TEvent) => void;
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
- * actions, and an optional guard.
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
- /** An array of assigners to execute. These update context synchronously. */
102
- readonly assigners?: SingleOrArray<Assigner<TEvent, TContext>>;
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
- /** An array of side-effect effects to execute upon entering this state. */
183
+ /** Synchronous side-effects executed upon entering this state. */
152
184
  readonly entry?: SingleOrArray<Effect<TEvent, TContext>>;
153
- /** An array of side-effect effects to execute upon exiting this state. */
185
+ /** Synchronous side-effects executed upon exiting this state. */
154
186
  readonly exit?: SingleOrArray<Effect<TEvent, TContext>>;
155
- /** An array of actors to spawn upon entering this state, cleaned up when leaving. */
156
- readonly actors?: SingleOrArray<Actor<TEvent, TContext>>;
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
  }