Skip to content

Instantly share code, notes, and snippets.

@raganwald
Last active January 3, 2022 20:38
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save raganwald/e4e92910e3039a5bd513cf36b6a7f95d to your computer and use it in GitHub Desktop.
Save raganwald/e4e92910e3039a5bd513cf36b6a7f95d to your computer and use it in GitHub Desktop.
State Machines
// 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
}
}
}
}
});
// 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
}
}
}
}
});
// 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
})
}
}
});
// 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
})
}
}
});
@raganwald
Copy link
Author

dot(account, Account)
  //=>

dot

Which produces:

account-final-1

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment