yoonite-saga
Version:
> Orchestration de workflows transactionnels avec gestion de compensation (pattern Saga)
221 lines (185 loc) • 5.84 kB
text/typescript
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);
}
}