UNPKG

@prostojs/wf

Version:

Generic workflow framework

373 lines (372 loc) 11.1 kB
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 };