Skip to content

Commit

Permalink
feat(fsm): rewrite state machine
Browse files Browse the repository at this point in the history
  • Loading branch information
AliMD committed Mar 17, 2023
1 parent f52c70c commit c8d91eb
Show file tree
Hide file tree
Showing 2 changed files with 144 additions and 66 deletions.
131 changes: 65 additions & 66 deletions core/fsm/src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,98 +2,69 @@ 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<TState extends string, TEventId extends string, TContext extends Stringifyable>
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<TState extends string, TEventId extends string> {
[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<TState, TEventId> = {
to: this.config.initial,
from: 'init',
target: this.config.initial,
from: this.config.initial,
by: 'INIT',
};

context = this.config.context;

signal = contextConsumer.bind<StateContext<TState, TEventId>>('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<void> {
const state = (this.state = {
target: target,
from: this.signal.getValue()?.target ?? target,
by,
};
dispatch<StateContext<TState, TEventId>>(this.signal.id, this.state, {debounce: 'NextCycle'});
});

dispatch<StateContext<TState, TEventId>>(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<MachineConfig<TState, TEventId, TContext>>) {
constructor(public readonly config: Readonly<FsmConfig<TState, TEventId, TContext>>) {
this._logger.logMethodArgs('constructor', config);
dispatch<StateContext<TState, TEventId>>(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);
}
}

/**
* Machine transition.
*/
transition(event: TEventId, context?: Partial<TContext>): 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<TContext>): Promise<void> {
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 = {
Expand All @@ -102,21 +73,49 @@ export class FiniteStateMachine<
};
}

if (toState == null) {
if (transitionConfig == null) {
this._logger.incident(
'transition',
'invalid_target_state',
'Defined target state for this event not found in state config',
{
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<void>>): Promise<void> {
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<T>(fn?: () => T): T | void {
if (typeof fn !== 'function') return;
return fn();
}
}
79 changes: 79 additions & 0 deletions core/fsm/src/type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import type {MaybeArray, MaybePromise, StringifyableRecord} from '@alwatr/type';


export interface FsmConfig<TState extends string, TEventId extends string, TContext 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;

/**
* Define state list
*/
stateRecord: {
[S in TState | '$all']: {
/**
* On state exit actions
*/
exit?: MaybeArray<() => MaybePromise<void>>;

/**
* On state entry actions
*/
entry?: MaybeArray<() => MaybePromise<void>>;

/**
* 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<TState>;
};
};
};
}

export interface StateContext<TState extends string, TEventId extends string> {
[T: string]: string | undefined;
/**
* Current state
*/
target: TState;
/**
* Last state
*/
from: TState;
/**
* Transition event
*/
by: TEventId | 'INIT';
}

export interface TransitionConfig<TState extends string> {
target?: TState;
condition?: () => MaybePromise<boolean>;
actions?: MaybeArray<() => MaybePromise<void>>;
}

0 comments on commit c8d91eb

Please sign in to comment.