UNPKG

@qiwi/cyclone

Version:

"State machine" for basic purposes

181 lines (180 loc) 5.21 kB
import { INVALID_UNLOCK_KEY, LOCK_VIOLATION, MachineError, TRANSITION_VIOLATION, UNREACHABLE_STATE } from './error.js'; import { generateDate, generateId } from './generator.js'; import { log } from './log.js'; export const DELIMITER = '>'; export const DEFAULT_HANDLER = (...last) => last.pop(); export const DEFAULT_HISTORY_SIZE = 10; export const DEFAULT_OPTS = { transitions: {}, historySize: DEFAULT_HISTORY_SIZE, immutable: false }; export class Machine { /** * Machine options. * @property */ opts; /** * State history. * @property */ history; /** * Lock key. * @property */ key; /** * Unique machine id * @property */ id; /** * Transition handler map * @property */ transitions; constructor(opts) { this.opts = { ...DEFAULT_OPTS, ...opts }; this.history = []; this.key = null; this.id = generateId(); this.transitions = opts.transitions; if (typeof opts.initialState === 'string') { this.history.push({ state: opts.initialState, data: opts.initialData, id: generateId(), date: generateDate() }); } return this; } /** * Provides next state transition. * @param state Next state name. * @param payload Any data for handler. */ next(state, ...payload) { if (this.key) { throw new MachineError(LOCK_VIOLATION); } const handler = Machine.getHandler(state, this.history, this.transitions); const current = this.current(); const data = handler(current.data, ...payload); const id = generateId(); const date = generateDate(); this.history.push({ state, data, id, date }); if (this.history.length > Machine.getHistoryLimit(this.opts.historySize)) { log.debug('history limit reached'); this.history.shift(); } return this; } /** * Returns the machine's digest: state name and stored data. */ current() { return { ...this.history[this.history.length - 1] }; } /** * Returns the last state, that satisfies the condition */ last(condition) { if (condition === undefined) { return this.current(); } const filter = typeof condition === 'string' ? ({ state }) => state === condition : condition; return [...this.history].reverse().find(filter); } /** * Reverts current state to the previous. * @param state */ prev(state) { if (this.key) { throw new MachineError(LOCK_VIOLATION); } if (this.history.length < 2) { throw new MachineError(UNREACHABLE_STATE); } if (state === undefined) { this.history.pop(); return this; } const last = this.last(state); if (!last) { throw new MachineError(UNREACHABLE_STATE); } this.history.length = this.history.indexOf(last) + 1; return this; } /** * Locks the machine. Any transitions are prohibited before unlocking. * @param key */ lock(key) { this.key = key || `lock${generateId()}`; return this; } /** * Unlocks the machine. * @param key */ unlock(key) { if (this.key !== key) { throw new MachineError(INVALID_UNLOCK_KEY); } this.key = null; return this; } static getHistoryLimit(historySize) { if (historySize === undefined) { return DEFAULT_HISTORY_SIZE; } if (historySize === -1) { return Number.POSITIVE_INFINITY; } return historySize; } static getHandler(next, history, transitions) { const targetTransition = this.getTargetTransition(next, history); const nextTransition = this.getTransition(targetTransition, transitions); if (!nextTransition) { throw new MachineError(TRANSITION_VIOLATION); } const handler = transitions[nextTransition]; return typeof handler === 'function' ? handler : DEFAULT_HANDLER; } static getTransition(targetTransition, transitions) { // TODO Support wildcards // TODO Support OR operator // TODO Generate patterns in constructor return Object.keys(transitions) .filter(transition => targetTransition.length > transition.length ? new RegExp(`.*${transition}$`).test(targetTransition) : targetTransition === transition) .sort((a, b) => b.length - a.length)[0]; } static getTargetTransition(next, history) { return [...history.map(({ state }) => state), next] .join(DELIMITER); } /** * Returns the last passes argument as a result * @param {any} state * @param {any} [payload] * @return {any} */ DEFAULT_HANDLER = DEFAULT_HANDLER; }