@sphereon/ssi-sdk.xstate-machine-persistence
Version:
410 lines (397 loc) • 17.3 kB
text/typescript
import { IAgentContext, TAgent } from '@veramo/core'
import { DefaultContext, EventObject, Interpreter, State, StateSchema, TypegenDisabled, Typestate } from 'xstate'
import { waitFor } from 'xstate/lib/waitFor'
import {
IMachineStatePersistence,
InitMachineStateArgs,
MachineStateInfo,
MachineStateInit,
MachineStateInitType,
MachineStatePersistenceOpts,
MachineStatePersistEventType,
StartedInterpreterInfo,
} from '../types'
import { emitMachineStatePersistEvent } from './stateEventEmitter'
import { machineStateToMachineInit, machineStateToStoreInfo } from './stateMapper'
/**
* Initialize the machine state persistence. Returns a unique instanceId and the machine name amongst others
*
* @param {Object} opts - The options for initializing the machine state persistence.
* @param {InitMachineStateArgs} opts - The arguments for initializing the machine state.
* @param {IAgentContext<any>} opts.context - The agent context.
* @returns {Promise<MachineStateInit | undefined>} - A promise that resolves to the initialized machine state, or undefined if the agent isn't using the Xstate plugin.
*/
export const machineStatePersistInit = async (
opts: InitMachineStateArgs &
Pick<MachineStatePersistenceOpts, 'existingInstanceId' | 'customInstanceId'> & {
context: IAgentContext<any> // We use any as this method could be called from an agent with access to, but not exposing this plugin
},
): Promise<MachineStateInit | undefined> => {
// make sure the machine context does not end up in the machine state init args
const { context, ...args } = opts
if (!(context.agent.availableMethods().includes('machineStateInit') && 'machineStateInit' in context.agent)) {
console.log(`IMachineStatePersistence was not exposed in the current agent. Not initializing new persistence object`)
return
}
return await (context as IAgentContext<IMachineStatePersistence>).agent.machineStateInit(args)
}
/**
* This function allows for the persistence of machine state on every xstate transition. It emits an event with the new state
* and other relevant data to be handled by the persistence plugin when enabled.
*
* @param {Object} opts - The options object.
* @param {Interpreter} opts.instance - The XState machine interpreter instance.
* @param {IAgentContext<any>} opts.context - The agent context.
* @param {MachineStateInit} opts.init - The initial persistence options, containing the unique instanceId.
* @returns {Promise<void>} - A promise that resolves when the persistence event is emitted.
*/
export const machineStatePersistOnTransition = async <
TContext = DefaultContext,
TStateSchema extends StateSchema = any,
TEvent extends EventObject = EventObject,
TTypestate extends Typestate<TContext> = {
value: any
context: TContext
},
TResolvedTypesMeta = TypegenDisabled,
>(opts: {
interpreter: Interpreter<TContext, TStateSchema, TEvent, TTypestate, TResolvedTypesMeta>
context: IAgentContext<any> // We use any as this method could be called from an agent with access to, but not exposing this plugin
init: MachineStateInit
cleanupOnFinalState?: boolean
}): Promise<void> => {
const { cleanupOnFinalState, context, init, interpreter } = opts
const { machineState, ...initEventData } = init
if (!(context.agent.availableMethods().includes('machineStatePersist') && 'machineStatePersist' in context.agent)) {
console.log(`IMachineStatePersistence was not exposed in the current agent. Disabling machine state persistence events`)
return
}
// We are using the event counter and evenDate to ensure we do not overwrite newer states. Events could come in out of order
let _eventCounter = init.machineState?.updatedCount ?? 0
// XState persistence plugin is available. So let's emit events on every transition, so it can persist the state
interpreter.onChange(async (_machineContext) => {
/*await (context.agent as TAgent<IMachineStatePersistence>).machineStatePersist({
...initEventData, // init value with machineState removed, as we are getting the latest state here
state: interpreter.getSnapshot(),
updatedCount: ++_eventCounter,
cleanupOnFinalState: cleanupOnFinalState !== false,
})*/
emitMachineStatePersistEvent(
{
type: MachineStatePersistEventType.EVERY,
data: {
...initEventData, // init value with machineState removed, as we are getting the latest state here
state: interpreter.getSnapshot(),
_eventCounter: ++_eventCounter,
_eventDate: new Date(),
_cleanupOnFinalState: cleanupOnFinalState !== false,
},
},
context,
)
})
if (cleanupOnFinalState && context.agent.availableMethods().includes('machineStateDelete')) {
interpreter.onDone((doneEvent) => {
;(context.agent as TAgent<IMachineStatePersistence>).machineStateDelete({
tenantId: initEventData.tenantId,
instanceId: initEventData.instanceId,
})
})
}
}
/**
* Persist the initial state of a machine and register it with the given machine instance.
*
* @param {InitMachineStateArgs & {instance: Interpreter<TContext, TStateSchema, TEvent, TTypestate, TResolvedTypesMeta>, context: IAgentContext<any>}} args - The options for initializing
* machine state and registering it.
* @returns {Promise<MachineStateInit | undefined>} - A promise that resolves to the initial state of the machine, or undefined if the agent isn't using the Xstate plugin.
*/
export const machineStatePersistRegistration = async <
TContext = DefaultContext,
TStateSchema extends StateSchema = any,
TEvent extends EventObject = EventObject,
TTypestate extends Typestate<TContext> = {
value: any
context: TContext
},
TResolvedTypesMeta = TypegenDisabled,
>(
args: Omit<InitMachineStateArgs, 'machineName'> &
Partial<Pick<InitMachineStateArgs, 'machineName'>> &
MachineStatePersistenceOpts & {
cleanupOnFinalState?: boolean
cleanupAllOtherInstances?: boolean
interpreter: Interpreter<TContext, TStateSchema, TEvent, TTypestate, TResolvedTypesMeta>
context: IAgentContext<any> // We use any as this method could be called from an agent with access to, but not exposing this plugin
},
): Promise<MachineStateInit | undefined> => {
const { disablePersistence } = args
if (disablePersistence === true) {
return
}
// We use expires in MS first. If not provided, look at expires at. If not provided, the persistence will not expire
const expiresAt = args.expireInMS ? new Date(Date.now() + args.expireInMS) : args.expiresAt
const machineName = args.machineName ?? args.interpreter.machine.id ?? args.interpreter.id
const init = await machineStatePersistInit({ ...args, machineName, expiresAt })
if (init) {
await machineStatePersistOnTransition({ ...args, init })
}
return init
}
const assertNonExpired = (args: { expiresAt?: Date; machineName: string }) => {
const { expiresAt, machineName } = args
if (expiresAt && expiresAt.getTime() < Date.now()) {
throw new Error(`Cannot resume ${machineName}. It expired at ${expiresAt.toLocaleString()}`)
}
}
/**
* Resumes the interpreter from a given state.
*
* @param {Object} args - The arguments for resuming the interpreter.
* @param {MachineStateInfo} args.machineState - The machine state information.
* @param {boolean} [args.noRegistration] - If true, no registration will be performed.
* @param {Interpreter} args.interpreter - The interpreter instance.
* @param {IAgentContext<IMachineStatePersistence>} args.context - The context for machine state persistence.
*
* @returns {Promise<Interpreter>} - A promise that resolves to the resumed interpreter.
*/
export const interpreterResumeFromState = async <
TContext = DefaultContext,
TStateSchema extends StateSchema = any,
TEvent extends EventObject = EventObject,
TTypestate extends Typestate<TContext> = {
value: any
context: TContext
},
TResolvedTypesMeta = TypegenDisabled,
>(args: {
machineState: MachineStateInfo
noRegistration?: boolean
cleanupAllOtherInstances?: boolean
cleanupOnFinalState?: boolean
interpreter: Interpreter<TContext, TStateSchema, TEvent, TTypestate, TResolvedTypesMeta>
context: IAgentContext<IMachineStatePersistence>
}): Promise<StartedInterpreterInfo<TContext, TStateSchema, TEvent, TTypestate, TResolvedTypesMeta>> => {
const { interpreter, machineState, context, noRegistration, cleanupAllOtherInstances, cleanupOnFinalState } = args
const { machineName, instanceId, tenantId } = machineState
assertNonExpired(machineState)
if (noRegistration !== true) {
await machineStatePersistRegistration({
stateType: 'existing',
machineName,
tenantId,
existingInstanceId: instanceId,
cleanupAllOtherInstances,
cleanupOnFinalState,
context,
interpreter,
})
}
const state = State.from(machineState.state.value, machineState.state.context)
// @ts-ignore
interpreter.start(state)
// @ts-ignore
await waitFor(interpreter, (awaitState) => awaitState.matches(state.value))
return {
machineState,
init: machineStateToMachineInit(
{
...machineState,
stateType: 'existing',
},
machineStateToStoreInfo({ ...machineState, stateType: 'existing' }),
),
interpreter,
}
}
/**
* Resumes or starts the interpreter from the initial machine state.
*
* @async
* @param {Object} args - The arguments for the function.
* @param {MachineStateInit & {stateType?: MachineStateInitType}} args.init - The initialization state of the machine.
* @param {boolean} args.noRegistration - Whether registration is required, defaults to false.
* @param {Interpreter<TContext, TStateSchema, TEvent, TTypestate, TResolvedTypesMeta>} args.interpreter - The interpreter object.
* @param {IAgentContext<IMachineStatePersistence>} args.context - The context object.
* @returns {Promise} - A promise that resolves to the interpreter instance.
* @throws {Error} - If the machine name from init does not match the interpreter id.
*/
export const interpreterStartOrResumeFromInit = async <
TContext = DefaultContext,
TStateSchema extends StateSchema = any,
TEvent extends EventObject = EventObject,
TTypestate extends Typestate<TContext> = {
value: any
context: TContext
},
TResolvedTypesMeta = TypegenDisabled,
>(args: {
init: MachineStateInit & { stateType?: MachineStateInitType }
cleanupAllOtherInstances?: boolean
cleanupOnFinalState?: boolean
noRegistration?: boolean
interpreter: Interpreter<TContext, TStateSchema, TEvent, TTypestate, TResolvedTypesMeta>
context: IAgentContext<IMachineStatePersistence>
}): Promise<StartedInterpreterInfo<TContext, TStateSchema, TEvent, TTypestate, TResolvedTypesMeta>> => {
const { init, noRegistration, interpreter, cleanupOnFinalState, cleanupAllOtherInstances, context } = args
const { stateType, instanceId, machineName, tenantId, expiresAt } = init
if (init.machineName !== interpreter.id) {
throw new Error(`Machine state init machine name ${init.machineName} does not match name from state machine interpreter ${interpreter.id}`)
}
assertNonExpired({ machineName, expiresAt })
if (noRegistration !== true) {
await machineStatePersistRegistration({
stateType: stateType ?? 'existing',
machineName,
tenantId,
...(stateType === 'existing' && { existingInstanceId: instanceId }),
...(stateType === 'new' && { customInstanceId: instanceId }),
cleanupAllOtherInstances,
cleanupOnFinalState,
context,
interpreter,
})
}
let machineState: MachineStateInfo | undefined
if (stateType === 'new') {
interpreter.start()
} else {
machineState = await context.agent.machineStateGet({ tenantId, instanceId })
// @ts-ignore
interpreter.start(machineState.state)
}
// We are waiting a bit
await new Promise((res) => setTimeout(res, 50))
return {
interpreter,
machineState,
init,
}
}
/**
* Starts or resumes the given state machine interpreter.
*
* @async
* @param {Object} args - The arguments for starting or resuming the interpreter.
* @param {MachineStateInitType | 'auto'} [args.stateType] - The state type. Defaults to 'auto'.
* @param {string} [args.instanceId] - The instance ID.
* @param {string} [args.machineName] - The machine name.
* @param {string} [args.tenantId] - The tenant ID.
* @param {boolean} args.singletonCheck - Whether to perform a singleton check or not. If more than one machine instance is found an error will be thrown
* @param {boolean} [args.noRegistration] - Whether to skip state change event registration or not.
* @param {Interpreter<TContext, TStateSchema, TEvent, TTypestate, TResolvedTypesMeta>} args.interpreter - The interpreter to start or resume.
* @param {IAgentContext<IMachineStatePersistence>} args.context - The agent context.
* @returns {Promise} A promise that resolves when the interpreter is started or resumed.
* @throws {Error} If there are multiple active instances of the machine and singletonCheck is true.
* @throws {Error} If a new instance was requested with the same ID as an existing active instance.
* @throws {Error} If the existing state machine with the given machine name and instance ID cannot be found.
*/
export const interpreterStartOrResume = async <
TContext = DefaultContext,
TStateSchema extends StateSchema = any,
TEvent extends EventObject = EventObject,
TTypestate extends Typestate<TContext> = {
value: any
context: TContext
},
TResolvedTypesMeta = TypegenDisabled,
>(args: {
stateType?: MachineStateInitType | 'auto'
instanceId?: string
machineName?: string
tenantId?: string
singletonCheck: boolean
noRegistration?: boolean
cleanupAllOtherInstances?: boolean
cleanupOnFinalState?: boolean
interpreter: Interpreter<TContext, TStateSchema, TEvent, TTypestate, TResolvedTypesMeta>
context: IAgentContext<IMachineStatePersistence>
}): Promise<StartedInterpreterInfo<TContext, TStateSchema, TEvent, TTypestate, TResolvedTypesMeta>> => {
const { stateType, singletonCheck, instanceId, tenantId, noRegistration, context, interpreter, cleanupAllOtherInstances, cleanupOnFinalState } =
args
const machineName = args.machineName ?? interpreter.id
let activeStates = await context.agent.machineStatesFindActive({
machineName,
tenantId,
instanceId,
})
if (stateType === 'new' && activeStates.length > 0 && cleanupAllOtherInstances) {
// We cleanup here to not influence the logic below. Normally the agent machineStateInit method does the cleanup
await Promise.all(
activeStates.map((state) =>
context.agent.machineStateDelete({
tenantId: args.tenantId,
instanceId: state.instanceId,
}),
),
)
// We search again, given the delete is using the passed in tenantId, instead of relying on the persisted tenantId. Should not matter, but just making sure
activeStates = await context.agent.machineStatesFindActive({
machineName,
tenantId,
instanceId,
})
}
if (singletonCheck && activeStates.length > 0) {
if (
stateType === 'new' ||
(stateType === 'existing' &&
((!instanceId && activeStates.length > 1) || (instanceId && activeStates.every((state) => state.instanceId !== instanceId))))
) {
return Promise.reject(new Error(`Found ${activeStates.length} active '${machineName}' instances, but only one is allows at the same time`))
}
}
if (stateType === 'new') {
if (instanceId && activeStates.length > 0) {
// Since an instanceId was provided it means the activeStates includes a machine with this instance. But stateType is 'new'
return Promise.reject(
new Error(`Found an active '${machineName}' instance with id ${instanceId}, but a new instance was requested with the same id`),
)
}
const init = await context.agent.machineStateInit({
stateType: 'new',
customInstanceId: instanceId,
machineName: machineName ?? interpreter.id,
tenantId,
cleanupAllOtherInstances,
})
return await interpreterStartOrResumeFromInit({
init,
noRegistration,
interpreter,
context,
cleanupOnFinalState,
cleanupAllOtherInstances,
})
}
if (activeStates.length === 0) {
if (stateType === 'existing') {
return Promise.reject(new Error(`Could not find existing state machine ${machineName}, instanceId ${instanceId}`))
}
const init = await context.agent.machineStateInit({
stateType: 'new',
customInstanceId: instanceId,
machineName: machineName ?? interpreter.id,
tenantId,
cleanupAllOtherInstances,
})
return await interpreterStartOrResumeFromInit({
init,
noRegistration,
interpreter,
context,
cleanupOnFinalState,
cleanupAllOtherInstances,
})
}
// activeStates length >= 1
const activeState = activeStates[0]
return interpreterResumeFromState({
machineState: activeState,
noRegistration,
interpreter,
context,
cleanupOnFinalState,
cleanupAllOtherInstances,
})
}