Files
suaveplan/packages/foundation/flow/workflow/src/compensation/saga/saga.md
2026-05-27 23:47:53 +01:00

3.4 KiB

saga

(no submodules)

Purpose

Saga is the dedicated, opinionated orchestrator for the textbook saga pattern: sequential business steps where every successful step registers its inverse so that a later failure can roll back every side effect in strict reverse order. Compared to WorkflowEngine, Saga is intentionally narrower — it has no onError: "continue" mode, no transition hooks, and no lifecycle hook registry. Callers reach for it when they want the smaller surface area and the explicit per-step semantics.

The class owns its own Compensator and resets it at the start of every execute call, so a single Saga instance can be run repeatedly without leaking state across runs.

Features

  • addStep(step) fluent registration.
  • Sequential execution with first-failure short-circuit.
  • Automatic reverse-order compensation via the internal Compensator.
  • continueCompensationOnError (default true) — forwards to the compensator.
  • Skipped steps recorded but not compensated.
  • Injectable Clock propagates to the compensator too.

Usage: Basic Example

import { createSaga, createStep } from "@suaveplan/workflow";

const saga = createSaga<{ orderId: string }>({
	id: "booking",
	name: "Booking saga",
});

saga
	.addStep(
		createStep({
			id: "reserve",
			name: "Reserve seats",
			execute: (ctx) => reserveSeats(ctx),
			compensate: (ctx) => releaseSeats(ctx),
		}),
	)
	.addStep(
		createStep({
			id: "charge",
			name: "Charge payment",
			execute: (ctx) => chargeCard(ctx),
			compensate: (ctx) => refundCard(ctx),
		}),
	);

const result = await saga.execute({ orderId: "ord-1" });

Usage: Advanced Example

import { createSaga, createStep } from "@suaveplan/workflow";

const fakeClock = () => 1_000;
const saga = createSaga<{ id: string }>({
	id: "two-phase",
	name: "Two-phase commit",
	continueCompensationOnError: false,
	clock: fakeClock,
});

saga.addStep(
	createStep({
		id: "lock",
		name: "Lock resource",
		execute: (ctx) => acquireLock(ctx.id),
		compensate: (ctx) => releaseLock(ctx.id),
	}),
);
saga.addStep(
	createStep({
		id: "commit",
		name: "Commit",
		execute: () => {
			throw new Error("conflict");
		},
	}),
);

const result = await saga.execute({ id: "row-7" });
expect(result.status).toBe("failed");
expect(result.compensationRecords.length).toBe(1);

API Reference

  • SagaResult<TCtx> — { sagaId; status; stepResults; compensationRecords; startedAt; completedAt; error?; }.
  • SagaOptions — { id; name; continueCompensationOnError?; clock?; }.
  • Saga<TCtx> —
    • size, steps accessors
    • addStep(step) returns this
    • execute(context): Promise<SagaResult<TCtx>>
  • createSaga<TCtx>(options): Saga<TCtx>.

Implementation Notes

The compensator is rebuilt at construction time and clear()-ed at the start of every execute, so the saga is genuinely reusable. The internal extractStepError helper guarantees that a failed step always surfaces a real Error instance even if the underlying StepResult.error was absent — that case is rare (a step that returns status: "failed" without populating error is technically allowed by the type) but the saga has to handle it cleanly.

Skipped steps do NOT register a compensation; this preserves the saga contract that compensations correspond strictly to side effects that happened, not to side effects that were considered.