@qiwi/cyclone
Version:
"State machine" for basic purposes
156 lines (155 loc) • 4.94 kB
JavaScript
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 {
constructor(opts) {
/**
* Returns the last passes argument as a result
* @param {any} state
* @param {any} [payload]
* @return {any}
*/
this.DEFAULT_HANDLER = DEFAULT_HANDLER;
this.opts = Object.assign(Object.assign({}, 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 Object.assign({}, 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);
}
}