Last active
January 3, 2022 20:38
-
-
Save raganwald/e4e92910e3039a5bd513cf36b6a7f95d to your computer and use it in GitHub Desktop.
State Machines
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// A state machine that works from a description including explicit transitions, | |
// and can generate a transition map and a digraph | |
const STATES = Symbol("states"); | |
const STARTING_STATE = Symbol("starting-state"); | |
const TRANSITIONS = Symbol("transitions"); | |
const RESERVED = [STARTING_STATE, TRANSITIONS]; | |
const MACHINES_TO_CURRENT_STATE_NAMES = new WeakMap(); | |
const MACHINES_TO_STARTING_STATES = new WeakMap(); | |
const MACHINES_TO_NAMES_TO_STATES = new WeakMap(); | |
const MACHINES_TO_TRANSITIONS = new WeakMap(); | |
function isStateMachine (object) { | |
return MACHINES_TO_NAMES_TO_STATES.has(object); | |
} | |
function getStateName (machine) { | |
return MACHINES_TO_CURRENT_STATE_NAMES.get(machine); | |
} | |
function getState (machine) { | |
return MACHINES_TO_NAMES_TO_STATES.get(machine)[getStateName(machine)]; | |
} | |
function setState (machine, stateName) { | |
MACHINES_TO_CURRENT_STATE_NAMES.set(machine, stateName); | |
Object.setPrototypeOf(machine, getState(machine)); | |
} | |
function transitionsTo (stateName, fn) { | |
return function (...args) { | |
const returnValue = fn.apply(this, args); | |
setState(this, stateName); | |
return returnValue; | |
}; | |
} | |
function TransitionOrientedStateMachine (description) { | |
const machine = {}; | |
// Handle all the initial states and/or methods | |
const propertiesAndMethods = Object.keys(description).filter(property => !RESERVED.includes(property)); | |
for (const property of propertiesAndMethods) { | |
machine[property] = description[property]; | |
} | |
// set the transitions for later reflection | |
MACHINES_TO_TRANSITIONS.set(machine, description[TRANSITIONS]); | |
// create its top-level state prototypes | |
MACHINES_TO_NAMES_TO_STATES.set(machine, Object.create(null)); | |
for (const state of Object.keys(MACHINES_TO_TRANSITIONS.get(machine))) { | |
const stateObject = Object.create(null); | |
const stateDescription = MACHINES_TO_TRANSITIONS.get(machine)[state]; | |
const nonTransitioningMethods = Object.keys(stateDescription).filter(name => typeof stateDescription[name] === 'function'); | |
const destinationStates = Object.keys(stateDescription).filter(name => typeof stateDescription[name] !== 'function'); | |
for (const nonTransitioningMethodName of nonTransitioningMethods) { | |
const nonTransitioningMethod = stateDescription[nonTransitioningMethodName]; | |
stateObject[nonTransitioningMethodName] = nonTransitioningMethod; | |
} | |
for (const destinationState of destinationStates) { | |
const destinationStateDescription = stateDescription[destinationState]; | |
const transitioningMethodNames = Object.keys(destinationStateDescription).filter(name => typeof destinationStateDescription[name] === 'function'); | |
for (const transitioningMethodName of transitioningMethodNames) { | |
const transitioningMethod = destinationStateDescription[transitioningMethodName]; | |
stateObject[transitioningMethodName] = transitionsTo(destinationState, transitioningMethod); | |
} | |
} | |
MACHINES_TO_NAMES_TO_STATES.get(machine)[state] = stateObject; | |
} | |
// set the starting state | |
MACHINES_TO_STARTING_STATES.set(machine, description[STARTING_STATE]); | |
setState(machine, MACHINES_TO_STARTING_STATES.get(machine)); | |
// we're done | |
return machine; | |
} | |
function getTransitions (machine) { | |
const description = { [STARTING_STATE]: MACHINES_TO_STARTING_STATES.get(machine) }; | |
const transitions = MACHINES_TO_TRANSITIONS.get(machine); | |
for (const state of Object.keys(transitions)) { | |
const stateDescription = transitions[state]; | |
description[state] = Object.create(null); | |
const selfTransitions = []; | |
for (const descriptionKey of Object.keys(stateDescription)) { | |
const innerDescription = stateDescription[descriptionKey]; | |
if (typeof innerDescription === 'function' ) { | |
selfTransitions.push(descriptionKey); | |
} else { | |
const destinationState = descriptionKey; | |
const transitionEvents = Object.keys(innerDescription); | |
description[state][destinationState] = transitionEvents; | |
} | |
} | |
if (selfTransitions.length > 0) { | |
description[state][state] = selfTransitions; | |
} | |
} | |
return description; | |
} | |
function dot (machine, name) { | |
const transitionsForMachine = getTransitions(machine); | |
const startingState = transitionsForMachine[STARTING_STATE]; | |
const dot = []; | |
dot.push(`digraph ${name} {`); | |
dot.push(''); | |
dot.push(' start [label="", fixedsize="false", width=0, height=0, shape=none];'); | |
dot.push(` start -> ${startingState} [color=darkslategrey];`); | |
for (const state of Object.keys(transitionsForMachine)) { | |
dot.push(''); | |
dot.push(` ${state}`); | |
dot.push(''); | |
const stateDescription = transitionsForMachine[state]; | |
for (const destinationState of Object.keys(stateDescription)) { | |
const events = stateDescription[destinationState]; | |
dot.push(` ${state} -> ${destinationState} [color=blue, label="${events.join(', ')}"];`); | |
} | |
} | |
dot.push('}'); | |
return dot.join("\r"); | |
} | |
function methodsOf (obj) { | |
const list = []; | |
for (const key in obj) { | |
if (typeof obj[key] === 'function') { | |
list.push(key); | |
} | |
} | |
return list; | |
} | |
const account = TransitionOrientedStateMachine({ | |
balance: 0, | |
[STARTING_STATE]: 'open', | |
[TRANSITIONS]: { | |
open: { | |
deposit (amount) { this.balance = this.balance + amount; }, | |
withdraw (amount) { this.balance = this.balance - amount; }, | |
availableToWithdraw () { return (this.balance > 0) ? this.balance : 0; }, | |
held: { | |
placeHold () {} | |
}, | |
closed: { | |
close () { | |
if (this.balance > 0) { | |
// ...transfer balance to suspension account | |
} | |
} | |
} | |
}, | |
held: { | |
deposit (amount) { this.balance = this.balance + amount; }, | |
availableToWithdraw () { return 0; }, | |
open: { | |
removeHold () {} | |
}, | |
closed: { | |
close () { | |
if (this.balance > 0) { | |
// ...transfer balance to suspension account | |
} | |
} | |
} | |
}, | |
closed: { | |
open: { | |
reopen () { | |
// ...restore balance if applicable | |
} | |
} | |
} | |
} | |
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// A hierarchal state machine | |
const STATES = Symbol("states"); | |
const STARTING_STATE = Symbol("starting-state"); | |
const TRANSITIONS = Symbol("transitions"); | |
const INNER = Symbol("inner"); | |
const RESERVED = [STARTING_STATE, TRANSITIONS]; | |
const MACHINES_TO_CURRENT_STATE_NAMES = new WeakMap(); | |
const MACHINES_TO_STARTING_STATES = new WeakMap(); | |
const MACHINES_TO_NAMES_TO_STATES = new WeakMap(); | |
const MACHINES_TO_TRANSITIONS = new WeakMap(); | |
function isStateMachine (object) { | |
return MACHINES_TO_NAMES_TO_STATES.has(object); | |
} | |
function getStateName (machine) { | |
return MACHINES_TO_CURRENT_STATE_NAMES.get(machine); | |
} | |
function getState (machine) { | |
return MACHINES_TO_NAMES_TO_STATES.get(machine)[getStateName(machine)]; | |
} | |
function setState (machine, stateName) { | |
const currentState = getState(machine); | |
if (hasState(machine, stateName)) { | |
setDirectState(machine, stateName) | |
} else if (isStateMachine(currentState)) { | |
setState(currentState, stateName); | |
} else { | |
console.log(`illegal transition to ${stateName}`, machine); | |
} | |
} | |
function setDirectState (machine, stateName) { | |
MACHINES_TO_CURRENT_STATE_NAMES.set(machine, stateName); | |
const newState = getState(machine); | |
Object.setPrototypeOf(machine, newState); | |
if (isStateMachine(newState)) { | |
setState(newState, MACHINES_TO_STARTING_STATES.get(newState)); | |
} | |
} | |
function transitionsTo (stateName, fn) { | |
return function (...args) { | |
const returnValue = fn.apply(this, args); | |
setState(this, stateName); | |
return returnValue; | |
}; | |
} | |
function hasState (machine, stateName) { | |
return MACHINES_TO_NAMES_TO_STATES.get(machine)[stateName] != null; | |
} | |
function HierarchalStateMachine (description, prototype = Object.prototype) { | |
const machine = new Object(); | |
// Handle all the initial states and/or methods | |
const propertiesAndMethods = Object.keys(description).filter(property => !RESERVED.includes(property)); | |
for (const property of propertiesAndMethods) { | |
machine[property] = description[property]; | |
} | |
// set the transitions for later reflection | |
MACHINES_TO_TRANSITIONS.set(machine, description[TRANSITIONS]); | |
// create its top-level state prototypes | |
MACHINES_TO_NAMES_TO_STATES.set(machine, Object.create(null)); | |
for (const state of Object.keys(MACHINES_TO_TRANSITIONS.get(machine))) { | |
const stateObject = Object.create(prototype); | |
const stateDescription = MACHINES_TO_TRANSITIONS.get(machine)[state]; | |
const nonTransitioningMethods = Object.keys(stateDescription).filter(name => typeof stateDescription[name] === 'function'); | |
const destinationStates = Object.keys(stateDescription).filter(name => typeof stateDescription[name] !== 'function'); | |
const innerStateMachineDescription = stateDescription[INNER]; | |
for (const nonTransitioningMethodName of nonTransitioningMethods) { | |
const nonTransitioningMethod = stateDescription[nonTransitioningMethodName]; | |
stateObject[nonTransitioningMethodName] = nonTransitioningMethod; | |
} | |
for (const destinationState of destinationStates) { | |
const destinationStateDescription = stateDescription[destinationState]; | |
const transitioningMethodNames = Object.keys(destinationStateDescription).filter(name => typeof destinationStateDescription[name] === 'function'); | |
for (const transitioningMethodName of transitioningMethodNames) { | |
const transitioningMethod = destinationStateDescription[transitioningMethodName]; | |
stateObject[transitioningMethodName] = transitionsTo(destinationState, transitioningMethod); | |
} | |
} | |
if (innerStateMachineDescription == null) { | |
MACHINES_TO_NAMES_TO_STATES.get(machine)[state] = stateObject; | |
} else { | |
const innerStateMachine = HierarchalStateMachine( | |
innerStateMachineDescription, | |
stateObject | |
); | |
MACHINES_TO_NAMES_TO_STATES.get(machine)[state] = innerStateMachine; | |
} | |
} | |
// set the starting state | |
MACHINES_TO_STARTING_STATES.set(machine, description[STARTING_STATE]); | |
setState(machine, MACHINES_TO_STARTING_STATES.get(machine)); | |
// we're done | |
return machine; | |
} | |
const hierarchalAccount = HierarchalStateMachine({ | |
balance: 0, | |
debug: 'outer', | |
[STARTING_STATE]: 'open', | |
[TRANSITIONS]: { | |
open: { | |
deposit (amount) { this.balance = this.balance + amount; }, | |
closed: { | |
close () { | |
if (this.balance > 0) { | |
// ...transfer balance to suspension account | |
} | |
} | |
}, | |
[INNER]: { | |
debug: 'inner', | |
[STARTING_STATE]: 'not-held', | |
[TRANSITIONS]: { | |
['not-held']: { | |
withdraw (amount) { this.balance = this.balance - amount; }, | |
availableToWithdraw () { return (this.balance > 0) ? this.balance : 0; }, | |
held: { | |
placeHold () {} | |
} | |
}, | |
held: { | |
availableToWithdraw () { return 0; }, | |
['not-held']: { | |
removeHold () {} | |
} | |
} | |
} | |
} | |
}, | |
closed: { | |
open: { | |
reopen () { | |
// ...restore balance if applicable | |
} | |
} | |
} | |
} | |
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// The naïve state machine extracted from http://raganwald.com/2018/02/23/forde.html | |
// Modified to use weak maps for "private" state | |
const STATES = Symbol("states"); | |
const STARTING_STATE = Symbol("starting-state"); | |
const RESERVED = [STARTING_STATE, STATES]; | |
const MACHINES_TO_CURRENT_STATE_NAMES = new WeakMap(); | |
const MACHINES_TO_STARTING_STATES = new WeakMap(); | |
const MACHINES_TO_NAMES_TO_STATES = new WeakMap(); | |
function getStateName (machine) { | |
return MACHINES_TO_CURRENT_STATE_NAMES.get(machine); | |
} | |
function getState (machine) { | |
return MACHINES_TO_NAMES_TO_STATES.get(machine)[getStateName(machine)]; | |
} | |
function setState (machine, stateName) { | |
MACHINES_TO_CURRENT_STATE_NAMES.set(machine, stateName); | |
} | |
function transitionsTo (stateName, fn) { | |
return function (...args) { | |
const returnValue = fn.apply(this, args); | |
setState(this, stateName); | |
return returnValue; | |
}; | |
} | |
function BasicStateMachine (description) { | |
const machine = {}; | |
// Handle all the initial states and/or methods | |
const propertiesAndMethods = Object.keys(description).filter(property => !RESERVED.includes(property)); | |
for (const property of propertiesAndMethods) { | |
machine[property] = description[property]; | |
} | |
// now its states | |
MACHINES_TO_NAMES_TO_STATES.set(machine, description[STATES]); | |
// what event handlers does it have? | |
const eventNames = Object.entries(MACHINES_TO_NAMES_TO_STATES.get(machine)).reduce( | |
(eventNames, [state, stateDescription]) => { | |
const eventNamesForThisState = Object.keys(stateDescription); | |
for (const eventName of eventNamesForThisState) { | |
eventNames.add(eventName); | |
} | |
return eventNames; | |
}, | |
new Set() | |
); | |
// define the delegating methods | |
for (const eventName of eventNames) { | |
machine[eventName] = function (...args) { | |
const handler = getState(machine)[eventName]; | |
if (typeof handler === 'function') { | |
return getState(machine)[eventName].apply(this, args); | |
} else { | |
throw `invalid event ${eventName}`; | |
} | |
} | |
} | |
// set the starting state | |
MACHINES_TO_STARTING_STATES.set(machine, description[STARTING_STATE]); | |
setState(machine, MACHINES_TO_STARTING_STATES.get(machine)); | |
// we're done | |
return machine; | |
} | |
const account = BasicStateMachine({ | |
balance: 0, | |
[STARTING_STATE]: 'open', | |
[STATES]: { | |
open: { | |
deposit (amount) { this.balance = this.balance + amount; }, | |
withdraw (amount) { this.balance = this.balance - amount; }, | |
availableToWithdraw () { return (this.balance > 0) ? this.balance : 0; }, | |
placeHold: transitionsTo('held', () => undefined), | |
close: transitionsTo('closed', function () { | |
if (this.balance > 0) { | |
// ...transfer balance to suspension account | |
} | |
}) | |
}, | |
held: { | |
removeHold: transitionsTo('open', () => undefined), | |
deposit (amount) { this.balance = this.balance + amount; }, | |
availableToWithdraw () { return 0; }, | |
close: transitionsTo('closed', function () { | |
if (this.balance > 0) { | |
// ...transfer balance to suspension account | |
} | |
}) | |
}, | |
closed: { | |
reopen: transitionsTo('open', function () { | |
// ...restore balance if applicable | |
}) | |
} | |
} | |
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// A prototype-mongling implementation that correctly reports valid methods | |
const STATES = Symbol("states"); | |
const STARTING_STATE = Symbol("starting-state"); | |
const RESERVED = [STARTING_STATE, STATES]; | |
const MACHINES_TO_CURRENT_STATE_NAMES = new WeakMap(); | |
const MACHINES_TO_STARTING_STATES = new WeakMap(); | |
const MACHINES_TO_NAMES_TO_STATES = new WeakMap(); | |
function getStateName (machine) { | |
return MACHINES_TO_CURRENT_STATE_NAMES.get(machine); | |
} | |
function getState (machine) { | |
return MACHINES_TO_NAMES_TO_STATES.get(machine)[getStateName(machine)]; | |
} | |
function setState (machine, stateName) { | |
MACHINES_TO_CURRENT_STATE_NAMES.set(machine, stateName); | |
Object.setPrototypeOf(machine, getState(machine)); | |
} | |
function transitionsTo (stateName, fn) { | |
return function (...args) { | |
const returnValue = fn.apply(this, args); | |
setState(this, stateName); | |
return returnValue; | |
}; | |
} | |
function RefectiveStateMachine (description) { | |
const machine = {}; | |
// Handle all the initial states and/or methods | |
const propertiesAndMethods = Object.keys(description).filter(property => !RESERVED.includes(property)); | |
for (const property of propertiesAndMethods) { | |
machine[property] = description[property]; | |
} | |
// now its states | |
MACHINES_TO_NAMES_TO_STATES.set(machine, description[STATES]); | |
// set the starting state | |
MACHINES_TO_STARTING_STATES.set(machine, description[STARTING_STATE]); | |
setState(machine, MACHINES_TO_STARTING_STATES.get(machine)); | |
// we're done | |
return machine; | |
} | |
function methodsOf (obj) { | |
const list = []; | |
for (const key in obj) { | |
if (typeof obj[key] === 'function') { | |
list.push(key); | |
} | |
} | |
return list; | |
} | |
const account = RefectiveStateMachine({ | |
balance: 0, | |
[STARTING_STATE]: 'open', | |
[STATES]: { | |
open: { | |
deposit (amount) { this.balance = this.balance + amount; }, | |
withdraw (amount) { this.balance = this.balance - amount; }, | |
availableToWithdraw () { return (this.balance > 0) ? this.balance : 0; }, | |
placeHold: transitionsTo('held', () => undefined), | |
close: transitionsTo('closed', function () { | |
if (this.balance > 0) { | |
// ...transfer balance to suspension account | |
} | |
}) | |
}, | |
held: { | |
removeHold: transitionsTo('open', () => undefined), | |
deposit (amount) { this.balance = this.balance + amount; }, | |
availableToWithdraw () { return 0; }, | |
close: transitionsTo('closed', function () { | |
if (this.balance > 0) { | |
// ...transfer balance to suspension account | |
} | |
}) | |
}, | |
closed: { | |
reopen: transitionsTo('open', function () { | |
// ...restore balance if applicable | |
}) | |
} | |
} | |
}); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Which produces: