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(defaulttrue) — forwards to the compensator.- Skipped steps recorded but not compensated.
- Injectable
Clockpropagates 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,stepsaccessorsaddStep(step)returnsthisexecute(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.