Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,120 @@
# saga
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## 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
```ts
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
```ts
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.