qiwi/cyclone

View on GitHub
src/main/ts/machine.ts

Summary

Maintainability
A
0 mins
Test Coverage
A
100%
import {
  INVALID_UNLOCK_KEY,
  LOCK_VIOLATION,
  MachineError,
  TRANSITION_VIOLATION,
  UNREACHABLE_STATE
} from './error'
import {
  generateDate,
  generateId
} from './generator'
import { log } from './log'

type IState = string

type IAny = any

type IHandler = (data: IAny, ...payload: Array<IAny>) => IAny

type ITransitions = {
  [key: string]: IHandler | null | boolean
}

type IMachineOpts = {
  transitions: ITransitions,
  initialState?: IState,
  initialData?: IAny,
  immutable?: boolean,
  historySize?: number
}

export const DELIMITER = '>'
export const DEFAULT_HANDLER: IHandler = (...last) => last.pop()
export const DEFAULT_HISTORY_SIZE = 10
export const DEFAULT_OPTS: IMachineOpts = {
  transitions: {},
  historySize: DEFAULT_HISTORY_SIZE,
  immutable: false
}

type IKey = string | null

type IDigest = {
  state?: IState,
  data?: IAny
}

type IHistoryItem = {
  state: IState,
  data: IAny,
  id: string,
  date: Date
}
type IHistory = IHistoryItem[]

interface IMachine {
  next(state: IState, ...payload: Array<IAny>): IMachine,
  prev(state?: IState): IMachine,
  current(): IDigest,
  lock(key?: IKey): IMachine,
  unlock(key: IKey): IMachine,

  transitions: ITransitions,
  history: IHistory,
  opts: IMachineOpts,
  key: IKey,
  id: string,
}

type IPredicate = (item: IHistoryItem) => boolean

export class Machine implements IMachine {

  /**
   * Machine options.
   * @property
   */
  public opts: IMachineOpts

  /**
   * State history.
   * @property
   */
  public history: IHistory

  /**
   * Lock key.
   * @property
   */
  public key: IKey

  /**
   * Unique machine id
   * @property
   */
  public id: string

  /**
   * Transition handler map
   * @property
   */
  public transitions: ITransitions

  constructor(opts: IMachineOpts) {
    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.
   */
  public next(state: IState, ...payload: Array<IAny>): IMachine {
    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.
   */
  public current(): IHistoryItem {
    return { ...this.history[this.history.length - 1] }
  }

  /**
   * Returns the last state, that satisfies the condition
   */
  public last(condition?: string | IPredicate): IHistoryItem | void {
    if (condition === undefined) {
      return this.current()
    }

    const filter = typeof condition === 'string'
      ? ({ state }: IHistoryItem) => state === condition
      : condition

    return [...this.history].reverse().find(filter)
  }

  /**
   * Reverts current state to the previous.
   * @param state
   */
  public prev(state?: string | IPredicate): IMachine {
    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
   */
  public lock(key?: IKey): IMachine {
    this.key = key || `lock${generateId()}`

    return this
  }

  /**
   * Unlocks the machine.
   * @param key
   */
  public unlock(key: IKey): IMachine {
    if (this.key !== key) {
      throw new MachineError(INVALID_UNLOCK_KEY)
    }

    this.key = null

    return this
  }

  public static getHistoryLimit(historySize?: number): number {
    if (historySize === undefined) {
      return DEFAULT_HISTORY_SIZE
    }

    if (historySize === -1) {
      return Number.POSITIVE_INFINITY
    }

    return historySize
  }

  public static getHandler(next: IState, history: IHistory, transitions: ITransitions): IHandler {
    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
  }

  public static getTransition(targetTransition: string, transitions: ITransitions): string | void {
    // 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]
  }

  public static getTargetTransition(next: IState, history: IHistory): string {
    return [...history.map(({ state }: IHistoryItem) => state), next]
      .join(DELIMITER)
  }

  /**
   * Returns the last passes argument as a result
   * @param {any} state
   * @param {any} [payload]
   * @return {any}
   */
  public DEFAULT_HANDLER = DEFAULT_HANDLER

}

export {
  IMachine, IHistory, ITransitions, IHistoryItem, IHandler, IMachineOpts
}