@grammyjs/conversations
Version:
Conversational interfaces for grammY
246 lines (245 loc) • 8.78 kB
JavaScript
;
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(() => { });
}