From c8d91ebe53a02062e1220652fd5ce4722c1cb83f Mon Sep 17 00:00:00 2001 From: Ali Mihandoost Date: Thu, 9 Mar 2023 00:50:44 +0330 Subject: [PATCH] feat(fsm): rewrite state machine --- core/fsm/src/core.ts | 131 +++++++++++++++++++++---------------------- core/fsm/src/type.ts | 79 ++++++++++++++++++++++++++ 2 files changed, 144 insertions(+), 66 deletions(-) create mode 100644 core/fsm/src/type.ts diff --git a/core/fsm/src/core.ts b/core/fsm/src/core.ts index 62546737..61dbe648 100644 --- a/core/fsm/src/core.ts +++ b/core/fsm/src/core.ts @@ -2,80 +2,58 @@ import {createLogger, globalAlwatr} from '@alwatr/logger'; import {contextConsumer} from '@alwatr/signal'; import {dispatch} from '@alwatr/signal/core.js'; -import type {Stringifyable, StringifyableRecord} from '@alwatr/type'; +import type {FsmConfig, StateContext} from './type.js'; +import type {MaybeArray, MaybePromise, StringifyableRecord} from '@alwatr/type'; + +export type {FsmConfig, StateContext}; globalAlwatr.registeredList.push({ name: '@alwatr/fsm', version: _ALWATR_VERSION_, }); -export interface MachineConfig - extends StringifyableRecord { - /** - * Machine ID (It is used in the state change signal identifier, so it must be unique). - */ - id: string; - - /** - * Initial state. - */ - initial: TState; - - /** - * Initial context. - */ - context: TContext; - - /** - * States list - */ - states: { - [S in TState | '$all']: { - /** - * An object mapping eventId (keys) to state. - */ - on: { - [E in TEventId]?: TState | '$self'; - }; - }; - }; -} - -export interface StateContext { - [T: string]: string; - to: TState; - from: TState | 'init'; - by: TEventId | 'INIT'; -} - export class FiniteStateMachine< TState extends string = string, TEventId extends string = string, TContext extends StringifyableRecord = StringifyableRecord > { state: StateContext = { - to: this.config.initial, - from: 'init', + target: this.config.initial, + from: this.config.initial, by: 'INIT', }; + context = this.config.context; + signal = contextConsumer.bind>('finite-state-machine-' + this.config.id); protected _logger = createLogger(`alwatr/fsm:${this.config.id}`); - protected setState(to: TState, by: TEventId | 'INIT'): void { - this.state = { - to, - from: this.signal.getValue()?.to ?? 'init', + protected async setState(target: TState, by: TEventId): Promise { + const state = (this.state = { + target: target, + from: this.signal.getValue()?.target ?? target, by, - }; - dispatch>(this.signal.id, this.state, {debounce: 'NextCycle'}); + }); + + dispatch>(this.signal.id, state, {debounce: 'NextCycle'}); + + if (state.from !== state.target) { + await this.execActions(this.config.stateRecord.$all.exit); + await this.execActions(this.config.stateRecord[state.from]?.exit); + await this.execActions(this.config.stateRecord.$all.entry); + await this.execActions(this.config.stateRecord[state.target]?.entry); + } + await this.execActions( + this.config.stateRecord[state.from]?.on[state.by]?.actions ?? + this.config.stateRecord.$all.on[state.by]?.actions, + ); } - constructor(public readonly config: Readonly>) { + constructor(public readonly config: Readonly>) { this._logger.logMethodArgs('constructor', config); dispatch>(this.signal.id, this.state, {debounce: 'NextCycle'}); - if (!config.states[config.initial]) { + if (!config.stateRecord[config.initial]) { this._logger.error('constructor', 'invalid_initial_state', config); } } @@ -83,17 +61,10 @@ export class FiniteStateMachine< /** * Machine transition. */ - transition(event: TEventId, context?: Partial): TState | null { - const fromState = this.state.to; - - let toState: TState | '$self' | undefined = - this.config.states[fromState]?.on?.[event] ?? this.config.states.$all?.on?.[event]; - - if (toState === '$self') { - toState = fromState; - } - - this._logger.logMethodFull('transition', {fromState, event, context}, toState); + async transition(event: TEventId, context?: Partial): Promise { + const fromState = this.state.target; + const transitionConfig = this.config.stateRecord[fromState]?.on[event] ?? this.config.stateRecord.$all?.on[event]; + this._logger.logMethodArgs('transition', {fromState, event, context, target: transitionConfig?.target}); if (context !== undefined) { this.context = { @@ -102,7 +73,7 @@ export class FiniteStateMachine< }; } - if (toState == null) { + if (transitionConfig == null) { this._logger.incident( 'transition', 'invalid_target_state', @@ -110,13 +81,41 @@ export class FiniteStateMachine< { fromState, event, - events: {...this.config.states.$all?.on, ...this.config.states[fromState]?.on}, + events: {...this.config.stateRecord.$all?.on, ...this.config.stateRecord[fromState]?.on}, }, ); - return null; + return; } - this.setState(toState, event); - return toState; + if (await this.callFunction(transitionConfig.condition) === false) { + return; + } + + transitionConfig.target ??= fromState; + await this.setState(transitionConfig.target, event); + } + + protected async execActions(actions?: MaybeArray<() => MaybePromise>): Promise { + if (actions == null) return; + + try { + if (!Array.isArray(actions)) { + await this.callFunction(actions); + return; + } + + // else + for (const action of actions) { + await this.callFunction(action); + } + } + catch (error) { + this._logger.accident('execActions', 'action_error', 'Error in executing actions', error); + } + } + + protected callFunction(fn?: () => T): T | void { + if (typeof fn !== 'function') return; + return fn(); } } diff --git a/core/fsm/src/type.ts b/core/fsm/src/type.ts new file mode 100644 index 00000000..352aa5e2 --- /dev/null +++ b/core/fsm/src/type.ts @@ -0,0 +1,79 @@ +import type {MaybeArray, MaybePromise, StringifyableRecord} from '@alwatr/type'; + + +export interface FsmConfig { + /** + * Machine ID (It is used in the state change signal identifier, so it must be unique). + */ + id: string; + + /** + * Initial state. + */ + initial: TState; + + /** + * Initial context. + */ + context: TContext; + + /** + * Define state list + */ + stateRecord: { + [S in TState | '$all']: { + /** + * On state exit actions + */ + exit?: MaybeArray<() => MaybePromise>; + + /** + * On state entry actions + */ + entry?: MaybeArray<() => MaybePromise>; + + /** + * An object mapping eventId to state. + * + * Example: + * + * ```ts + * stateRecord: { + * on: { + * TIMER: { + * target: 'green', + * condition: () => car.gas > 0, + * actions: () => car.go(), + * } + * } + * } + * ``` + */ + on: { + [E in TEventId]?: TransitionConfig; + }; + }; + }; +} + +export interface StateContext { + [T: string]: string | undefined; + /** + * Current state + */ + target: TState; + /** + * Last state + */ + from: TState; + /** + * Transition event + */ + by: TEventId | 'INIT'; +} + +export interface TransitionConfig { + target?: TState; + condition?: () => MaybePromise; + actions?: MaybeArray<() => MaybePromise>; +}