UNPKG

yoonite-saga

Version:

> Orchestration de workflows transactionnels avec gestion de compensation (pattern Saga)

221 lines (185 loc) 5.84 kB
import { plainToInstance } from "class-transformer"; import { validate } from "class-validator"; import { InvokeAction } from "./types/invoke.type"; import { SagaResponse } from "./types/saga-response"; import { ServicesList } from "./types/services.type"; import { Workflow } from "./types/workflow.type"; export class SagaProcessor<T> { private steps = []; private history = []; private services = {}; private ctx = {} as T; private currentStep = 0; private toCompensate = []; private errors: Error[] = []; private currentExecution = null; private errorFormatter = (err) => err; constructor(services: ServicesList = {}) { // Inject services this.services = services; } // -------------------- // Add a workflow to execute // -------------------- add(workflow: Workflow): SagaProcessor<T> { this.steps = [...this.steps, ...workflow]; return this; } // -------------------- // Change exception formatter to handle errors // -------------------- handleExceptions(formatter) { this.errorFormatter = formatter; } // -------------------- // Start workflows execution // -------------------- async start(): Promise<SagaResponse<T>> { this.debug("Run workflow"); await this.runStep(); return this.formatResponse(); } // -------------------- // Run a step // -------------------- private async runStep(): Promise<void> { // Get step to execute const currentStep = this.steps[this.currentStep]; const { name, condition, validate, invokes, withCompensation } = currentStep; try { // If there is a condition, check it // If condition is true, run step if (this.checkCondition(condition)) { // If we execute this step, we inject compensation to the backward stack if (validate) await this.validate(validate); if (withCompensation) this.toCompensate.push(withCompensation); this.debug("\tStart step:", name); // Execute each invoke of this step (cascade) await invokes.reduce( (acc, curr) => acc.then(() => this.runInvoke(curr)), Promise.resolve() ); } // Run next step await this.makeStepForward(this.currentStep + 1); } catch (err) { // An error occured, catch it this.errors.push(this.errorFormatter(err)); this.endLog("error"); await this.compensate(); } } // -------------------- // Execute a function into a step // -------------------- private async runInvoke(invoke: InvokeAction): Promise<void> { const { name, condition, action, withCompensation } = invoke; // If there is a condition, check it // If condition is wrong, jump invoke if (!this.checkCondition(condition)) return; // If we execute this step, we inject compensation to the backward stack if (withCompensation) this.toCompensate.push(withCompensation); // Recording state this.debug("\t\tRun invoke:", name); this.startLog(name); // Execute Invoke const response = await action(this.ctx, this.services); // Enrich context this.ctx = { ...this.ctx, ...response }; // End recording this.endLog("success"); } // -------------------- // Validate data // -------------------- async validate(dto) { if (!dto) return; if (typeof dto !== "function") { throw new Error("DTO for validation must be a class constructor"); } const objInstance: any = plainToInstance(dto, this.ctx || {}); const errors = (await validate(objInstance)) || []; if (errors.length > 0) { errors.map(({ property, constraints = {} }) => { this.errors.push( new Error( `Validation failed on "${property}" : ` + Object.values(constraints).join(", ") ) ); }); throw new Error("Stop saga"); } } // -------------------- // Run a condition // -------------------- checkCondition(condition) { if (!condition) return true; return condition({ ...this.ctx }); } // -------------------- // Run next step or handle workflow end // -------------------- private async makeStepForward(index: number) { if (index >= this.steps.length) { return this.ctx; } this.currentStep = index; return this.runStep(); } // -------------------- // Start to compensate // -------------------- private async compensate() { // Cascade compensation functions await this.toCompensate.reverse().reduce( async (acc, cur) => acc .then(() => cur(this.ctx, this.services)) .then((response = {}) => { // Enrich context this.ctx = { ...this.ctx, ...response }; }) .catch((err) => { this.errors.push(err); return; }), Promise.resolve() ); } // -------------------- // -------------------- private async formatResponse(): Promise<SagaResponse<T>> { const hasErrors = this.errors.length > 0; return { state: hasErrors ? "failed" : "success", context: this.ctx, errors: this.errors, history: this.history, }; } startLog(name) { const currentStep = this.steps[this.currentStep]; const execution = { step: this.currentStep, name: currentStep.name, invoke: name, startAt: new Date(), endAt: 0, state: "pending", }; this.currentExecution = execution; } endLog(state) { if (!this.currentExecution) return; this.currentExecution.state = state; this.currentExecution.endAt = new Date(); this.history.push({ ...this.currentExecution }); } debug(...params) { const currentStep = this.steps[this.currentStep]; if (currentStep.debug) console.log(...params); } }