UNPKG

@grammyjs/conversations

Version:

Conversational interfaces for grammY

246 lines (245 loc) 8.78 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.ReplayEngine = void 0; const resolve_js_1 = require("./resolve.js"); const state_js_1 = require("./state.js"); /** * A replay engine takes control of the event loop of the JavaScript runtime and * lets you execute a JavaScript function in abnormal ways. The function * execution can be halted, resumed, aborted, and reversed. This lets you run a * function partially and persist the state of execution in a database. Later, * function execution can be resumed from where it was left off. * * Replay engines are the fundamental building block of the conversations * plugin. In a sense, everything else is just a number of wrapper layers to * make working with replay engines more convenient, and to integrate the power * of replay engines into your bot's middleware system. * * Using a standalone replay engine is straightforward. * * 1. Create an instance of this class and pass a normal JavaScript function to * the constructor. The function receives a {@link ReplayControls} object as * its only parameter. * 2. Call {@link ReplayEngine.play} to begin a new execution. It returns a * {@link ReplayResult} object. * 3. Use the {@link ReplayState} you obtained inside the result object and * resume execution by calling {@link ReplayEngine.replay}. * * The `ReplayEngine` class furthermore provides you with static helper methods * to supply values to interrupts, and to reset the replay state to a previously * created checkpoint. */ class ReplayEngine { /** * Constructs a new replay engine from a builder function. The function * receives a single parameter that can be used to control the replay. * * @param builder A builder function to be executed and replayed */ constructor(builder) { this.builder = builder; } /** * Begins a new execution of the builder function. This starts based on * fresh state. The execution is independent from any previously created * executions. * * A {@link ReplayResult} object is returned to communicate the outcome of * the execution. */ async play() { const state = (0, state_js_1.create)(); return await this.replay(state); } /** * Resumes execution based on a previously created replay state. This is the * most important method of this class. * * A {@link ReplayResult} object is returned to communicate the outcome of * the execution. * * @param state A previously created replay state */ async replay(state) { return await replayState(this.builder, state); } /** * Creates a new replay state with a single unresolved interrupt. This state * can be used as a starting point to replay arbitrary builder functions. * * You need to pass the collation key for the aforementioned first * interrupt. This must be the same value that the builder function will * pass to its first interrupt. * * @param key The builder functions first collation key */ static open(key) { const state = (0, state_js_1.create)(); const mut = (0, state_js_1.mutate)(state); const int = mut.op(key); return [state, int]; } /** * Mutates a given replay state by supplying a value for a given interrupt. * The next time the state is replayed, the targeted interrupt will return * this value. * * The interrupt value has to be one of the interrupts of a previously * received {@link Interrupted} result. * * In addition to mutating the replay state, a checkpoint is created and * returned. This checkpoint may be used to reset the replay state to its * previous value. This will undo this and all following mutations. * * @param state A replay state to mutate * @param interrupt An interrupt to resolve * @param value The value to supply */ static supply(state, interrupt, value) { const get = (0, state_js_1.inspect)(state); const checkpoint = get.checkpoint(); const mut = (0, state_js_1.mutate)(state); mut.done(interrupt, value); return checkpoint; } /** * Resets a given replay state to a previously received checkpoint by * mutating the replay state. * * @param state The state to mutate * @param checkpoint The checkpoint to which to return */ static reset(state, checkpoint) { const mut = (0, state_js_1.mutate)(state); mut.reset(checkpoint); } } exports.ReplayEngine = ReplayEngine; async function replayState(builder, state) { const cur = (0, state_js_1.cursor)(state); // Set up interrupt and action tracking let interrupted = false; const interrupts = []; let boundary = (0, resolve_js_1.resolver)(); const actions = new Set(); function updateBoundary() { if (interrupted && actions.size === 0) { boundary.resolve(); } } async function runBoundary() { while (!boundary.isResolved()) { await boundary.promise; // clear microtask queue and check if another action was started await new Promise((r) => setTimeout(r, 0)); } } // Set up event loop tracking to prevent // premature returns with floating promises let promises = 0; // counts the number of promises on the event loop let dirty = (0, resolve_js_1.resolver)(); // resolves as soon as the event loop is clear let complete = false; // locks the engine after the event loop has cleared function begin() { if (complete) { throw new Error("Cannot begin another operation after the replay has completed, are you missing an `await`?"); } promises++; if (boundary.isResolved()) { // new action was started after interrupt, reset boundary boundary = (0, resolve_js_1.resolver)(); } } function end() { promises--; if (promises === 0) { dirty.resolve(); dirty = (0, resolve_js_1.resolver)(); } } // Collect data to return to caller let canceled = false; let message = undefined; let returned = false; let returnValue = undefined; // Define replay controls async function interrupt(key) { if (returned || (interrupted && interrupts.length === 0)) { // Already returned or canceled, so we must no longer perform an interrupt. await boom(); } begin(); const res = await cur.perform(async (op) => { interrupted = true; interrupts.push(op); updateBoundary(); await boom(); }, key); end(); return res; } async function cancel(key) { if (complete) { throw new Error("Cannot perform a cancel operation after the replay has completed, are you missing an `await`?"); } canceled = true; interrupted = true; message = key; updateBoundary(); return await boom(); } async function action(fn, key) { begin(); const res = await cur.perform(async (op) => { actions.add(op); const ret = await fn(); actions.delete(op); updateBoundary(); return ret; }, key); end(); return res; } function checkpoint() { return cur.checkpoint(); } const controls = { interrupt, cancel, action, checkpoint }; // Perform replay async function run() { returnValue = await builder(controls); returned = true; // wait for pending ops to complete while (promises > 0) { await dirty.promise; // clear microtask queue and check again await new Promise((r) => setTimeout(r, 0)); } } try { const boundaryPromise = runBoundary(); const runPromise = run(); await Promise.race([boundaryPromise, runPromise]); if (returned) { return { type: "returned", returnValue }; } else if (boundary.isResolved()) { if (canceled) { return { type: "canceled", message }; } else { return { type: "interrupted", state, interrupts }; } } else { throw new Error("Neither returned nor interrupted!"); // should never happen } } catch (error) { return { type: "thrown", error }; } finally { complete = true; } } function boom() { return new Promise(() => { }); }