@prostojs/wf
Version:
Generic workflow framework
373 lines (372 loc) • 11.1 kB
JavaScript
import { FtringsPool } from "@prostojs/ftring";
//#region src/types.ts
var StepRetriableError = class extends Error {
constructor(originalError, errorList, inputRequired, expires) {
super(originalError.message);
this.originalError = originalError;
this.errorList = errorList;
this.inputRequired = inputRequired;
this.expires = expires;
this.name = "StepRetriableError";
}
};
//#endregion
//#region src/workflow.ts
function toFlowOutput(result, resumeFn) {
if (result.finished) return {
finished: true,
state: result.state,
stepId: result.stepId
};
if (result.error) return {
finished: false,
state: result.state,
stepId: result.stepId,
error: result.error,
retry: resumeFn,
inputRequired: result.inputRequired,
expires: result.expires,
errorList: result.errorList
};
return {
finished: false,
state: result.state,
stepId: result.stepId,
inputRequired: result.inputRequired,
resume: resumeFn,
expires: result.expires,
errorList: result.errorList
};
}
/**
* Workflow container
*
* @example
* const steps = [
* createStep('add', {
* input: 'number',
* handler: 'ctx.result += input',
* }),
* createStep('mul', {
* input: 'number',
* handler: 'ctx.result *= input',
* }),
* createStep('div', {
* input: 'number',
* handler: 'ctx.result = ctx.result / input',
* }),
* createStep('error', {
* handler: 'ctx.result < 0 ? new StepRetriableError(new Error("test error")) : undefined',
* }),
* ]
* const flow = new Workflow<{ result: number }>(steps)
* flow.register('add-mul-div', [
* 'add', 'mul', 'div',
* ])
* const result = await flow.start('add-mul-div', { result: 1 })
*/
var Workflow = class {
constructor(steps) {
this.mappedSteps = {};
this.schemas = {};
this.schemaPrefix = {};
this.spies = [];
this.fnPool = new FtringsPool();
for (const step of steps) {
if (this.mappedSteps[step.id]) throw new Error(`Duplicate step id "${step.id}"`);
this.mappedSteps[step.id] = step;
}
}
resolveStep(stepId) {
return this.mappedSteps[stepId];
}
attachSpy(fn) {
this.spies.push(fn);
return () => this.detachSpy(fn);
}
detachSpy(fn) {
this.spies = this.spies.filter((spy) => spy !== fn);
}
addStep(step) {
if (this.mappedSteps[step.id]) throw new Error(`Duplicate step id "${step.id}"`);
this.mappedSteps[step.id] = step;
}
emit(spy, event, eventOutput, flowOutput, ms) {
if (!spy && this.spies.length === 0) return;
callSpy(spy);
for (const s of this.spies) callSpy(s);
function callSpy(fn) {
if (fn) try {
fn(event, eventOutput, flowOutput, ms);
} catch (e) {
console.error(e.message || "Workflow spy uncaught exception.", e.stack);
}
}
}
/**
* Validate that schema refers only to existing step IDs
* @param schemaId
* @param item
*/
validateSchema(schemaId, item, prefix) {
const { stepId, steps } = this.normalizeWorkflowItem(item, prefix);
if (typeof stepId === "string") {
if (!this.resolveStep(stepId)) error(stepId);
} else if (steps) for (const step of steps) this.validateSchema(schemaId, step, prefix);
function error(id) {
throw new Error(`Workflow schema "${schemaId}" refers to an unknown step id "${id}".`);
}
}
/**
* Register flow (sequence of steps) under ID
* @param id
* @param schema
* @param prefix adds to steps that not starting from '/'
*/
register(id, schema, prefix) {
if (!this.schemas[id]) {
for (const step of schema) this.validateSchema(id, step, prefix);
this.schemas[id] = schema;
if (prefix) this.schemaPrefix[id] = prefix;
} else throw new Error(`Workflow schema with id "${id}" already registered.`);
}
/**
* Start flow by ID
* @param schemaId
* @param initialContext initial context
* @param input initial input (for the first step if required)
* @returns
*/
async start(schemaId, initialContext, input, spy) {
const schema = this.schemas[schemaId];
if (!schema) throw new Error(`Workflow schema id "${schemaId}" does not exist.`);
const result = await this.loopInto("workflow", {
schemaId,
context: initialContext,
schema,
input,
spy
});
return this.convertToOutput(result, spy);
}
async callConditionFn(spy, event, fn, result) {
let conditionResult = false;
const now = Date.now();
if (typeof fn === "string") conditionResult = await this.fnPool.call(fn, result.state.context);
else conditionResult = await fn(result.state.context);
this.emit(spy, event, {
fn,
result: conditionResult
}, result, Date.now() - now);
return conditionResult;
}
async loopInto(event, opts) {
const prefix = this.schemaPrefix[opts.schemaId];
const schema = opts.schema;
const level = opts.level || 0;
const indexes = opts.indexes = opts.indexes || [];
const startIndex = indexes[level] || 0;
let skipCondition = indexes.length > level + 1;
indexes[level] = startIndex;
let input = opts.input;
let result = {
state: {
schemaId: opts.schemaId,
context: opts.context,
indexes
},
finished: false,
stepId: ""
};
this.emit(opts.spy, event + "-start", event === "subflow" ? "" : opts.schemaId, result);
try {
for (let i = startIndex; i < schema.length; i++) {
indexes[level] = i;
const item = this.normalizeWorkflowItem(schema[i], prefix);
if (item.continueFn) {
if (await this.callConditionFn(opts.spy, "eval-continue-fn", item.continueFn, result)) {
result.break = false;
break;
}
}
if (item.breakFn) {
if (await this.callConditionFn(opts.spy, "eval-break-fn", item.breakFn, result)) {
result.break = true;
break;
}
}
if (!skipCondition && item.conditionFn) {
if (!await this.callConditionFn(opts.spy, "eval-condition-fn", item.conditionFn, result)) continue;
}
skipCondition = false;
if (typeof item.stepId === "string") {
result.stepId = item.stepId;
const step = this.resolveStep(item.stepId);
if (!step) throw new Error(`Step "${item.stepId}" not found.`);
let mergedInput = input;
if (typeof item.input !== "undefined") {
if (typeof input === "undefined") mergedInput = item.input;
else if (typeof item.input === "object" && typeof input === "object") mergedInput = {
...item.input,
...input
};
}
const now = Date.now();
const stepResult = await step.handle(result.state.context, mergedInput);
const ms = Date.now() - now;
if (stepResult && stepResult.inputRequired) {
result.interrupt = true;
result.inputRequired = stepResult.inputRequired;
result.expires = stepResult.expires;
result.errorList = stepResult.errorList;
this.emit(opts.spy, "step", item.stepId, result, ms);
break;
}
if (stepResult && stepResult instanceof StepRetriableError) {
retriableError(stepResult);
this.emit(opts.spy, "step", item.stepId, result, ms);
break;
}
this.emit(opts.spy, "step", item.stepId, result, ms);
} else if (item.steps) {
while (true) {
if (item.whileFn) {
if (!await this.callConditionFn(opts.spy, "eval-while-cond", item.whileFn, result)) break;
}
result = await this.loopInto("subflow", {
schemaId: opts.schemaId,
schema: item.steps,
context: result.state.context,
indexes,
input,
level: level + 1,
spy: opts.spy
});
if (!item.whileFn || result.break || result.interrupt) break;
}
result.break = false;
if (result.interrupt) break;
}
input = void 0;
}
} catch (e) {
if (e instanceof StepRetriableError) retriableError(e);
else {
this.emit(opts.spy, "error", e.message || "", result);
throw e;
}
}
function retriableError(e) {
result.interrupt = true;
result.error = e.originalError;
result.inputRequired = e.inputRequired;
result.errorList = e.errorList;
result.expires = e.expires;
}
if (result.interrupt) {
this.emit(opts.spy, event + "-interrupt", event === "subflow" ? "" : opts.schemaId, result);
return result;
}
if (level === 0) result.finished = true;
this.emit(opts.spy, event + "-end", event === "subflow" ? "" : opts.schemaId, result);
indexes.pop();
return result;
}
prefixStepId(id, prefix) {
if (!id) return id;
return prefix && id[0] !== "/" ? [prefix, id].join("/") : id;
}
getItemStepId(item, prefix) {
return this.prefixStepId(typeof item === "string" ? item : item.id, prefix);
}
normalizeWorkflowItem(item, prefix) {
const stepId = this.getItemStepId(item, prefix);
const input = typeof item === "object" ? item.input : void 0;
const conditionFn = typeof item === "object" && item.condition;
const continueFn = typeof item === "object" && item.continue;
const breakFn = typeof item === "object" && item.break;
const whileFn = typeof item === "object" && item.while;
return {
stepId,
conditionFn,
steps: typeof item === "object" && item.steps,
continueFn,
breakFn,
input,
whileFn
};
}
convertToOutput(result, spy) {
const resumeFn = (input) => this.resume(result.state, input, spy);
return toFlowOutput(result, resumeFn);
}
resumeLoop(state, input, spy) {
const schema = this.schemas[state.schemaId];
if (!schema) throw new Error(`Workflow schema id "${state.schemaId}" does not exist.`);
return this.loopInto("resume", {
schemaId: state.schemaId,
context: state.context,
indexes: state.indexes,
schema,
input,
spy
});
}
/**
* Resume (re-try) interrupted flow
* @param state workflow state from a previous TFlowOutput
* @param input input for the interrupted step
* @returns
*/
async resume(state, input, spy) {
const result = await this.resumeLoop(state, input, spy);
return this.convertToOutput(result, spy);
}
};
//#endregion
//#region src/step.ts
const fnPool = new FtringsPool();
/**
* Workflow Step
*
* A minimum action withing workflow
*
* @example
* new Step('step0', (ctx, input) => {
* ctx.step0Data = 'completed'
* console.log('step0 completed')
* })
*/
var Step = class {
constructor(id, handler, opts = {}) {
this.id = id;
this.handler = handler;
this.opts = opts;
}
getGlobals(ctx, input) {
const globals = Object.assign({ StepRetriableError }, this.opts.globals);
globals.ctx = ctx;
globals.input = input;
return globals;
}
handle(ctx, input) {
if (this.opts.input && typeof input === "undefined") return { inputRequired: this.opts.input };
if (!this._handler) if (typeof this.handler === "string") {
const code = this.handler;
this._handler = (c, i) => fnPool.call(code, this.getGlobals(c, i));
} else this._handler = this.handler;
return this._handler(ctx, input);
}
};
/**
* Shortcut for creating a workflow step
* @param id step id
* @param opts.input optional - instructions for step inputs
* @param opts.handler step handler
* @returns Step
*/
function createStep(id, opts) {
return new Step(id, opts.handler, { input: opts.input });
}
//#endregion
export { Step, StepRetriableError, Workflow, createStep };