first commit
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
# compensator
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`Compensator` is the explicit, registry-based rollback engine used by
|
||||
`@suaveplan/workflow`. The workflow engine and the saga orchestrator both
|
||||
delegate to it: every step that completes successfully and exposes a
|
||||
`compensate` hook registers a closure here so a later failure can unwind
|
||||
the side effects in reverse order. Surfacing the registry as its own class
|
||||
(rather than burying it inside the engine) makes it usable in custom
|
||||
orchestration code where the built-in engine is not the right fit.
|
||||
|
||||
The compensator also records every attempt, success or failure, as a
|
||||
structured `CompensationRecord`. Callers can inspect the records to drive
|
||||
alerts, telemetry beyond OTel, or partial-recovery resume strategies.
|
||||
|
||||
## Features
|
||||
|
||||
- `register(stepId, fn)` — append-only ordered registry.
|
||||
- `compensateAll(ctx)` — runs in reverse registration order.
|
||||
- `compensateSteps(ctx, ids)` — runs only the matching ids, still reverse.
|
||||
- `continueOnError` (default `true`) — toggle whether a thrown
|
||||
compensation halts the rest.
|
||||
- `CompensationRecord` history with timestamps via injected `Clock`.
|
||||
- OTel emission on every attempt (`recordCompensation`).
|
||||
- `clear()` to reuse the same instance across runs (used by `Saga`).
|
||||
- `hasFailures` quick-check.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createCompensator } from "@suaveplan/workflow";
|
||||
|
||||
const comp = createCompensator<{ orderId: string }>();
|
||||
comp.register("charge", async (ctx) => refundCharge(ctx.orderId));
|
||||
comp.register("ship", async (ctx) => recallShipment(ctx.orderId));
|
||||
|
||||
await comp.compensateAll({ orderId: "ord-42" });
|
||||
// ship.compensate runs first, then charge.compensate.
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { createCompensator } from "@suaveplan/workflow";
|
||||
|
||||
const comp = createCompensator<{ id: string }>({
|
||||
continueOnError: false,
|
||||
clock: () => 0,
|
||||
});
|
||||
|
||||
comp.register("a", () => {
|
||||
throw new Error("a failed");
|
||||
});
|
||||
comp.register("b", () => undefined);
|
||||
|
||||
await comp.compensateAll({ id: "x" });
|
||||
// Records contain a single entry for "b" with status "compensated"
|
||||
// then the loop breaks because continueOnError is false.
|
||||
if (comp.hasFailures === false) {
|
||||
// Re-attempt the failed compensations later.
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `CompensationRecord` — `{ stepId; status; error?; timestamp; }`.
|
||||
- `CompensatorOptions` — `{ continueOnError?; clock?; }`.
|
||||
- `Compensator<TCtx>` —
|
||||
- `size`, `records`, `hasFailures` accessors
|
||||
- `register(stepId, fn): void`
|
||||
- `compensateAll(ctx): Promise<ReadonlyArray<CompensationRecord>>`
|
||||
- `compensateSteps(ctx, ids): Promise<ReadonlyArray<CompensationRecord>>`
|
||||
- `clear(): void`
|
||||
- `createCompensator<TCtx>(options?): Compensator<TCtx>`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The internal `_runEntry` helper is shared by `compensateAll` and
|
||||
`compensateSteps` to keep the failure-handling identical. It returns
|
||||
`true` when the loop should break (i.e. an error was raised and
|
||||
`continueOnError` is false) so the callers can short-circuit without
|
||||
duplicating the predicate.
|
||||
|
||||
`compensateSteps` filters by id *before* reversing, so the relative order
|
||||
between matching ids is preserved as the inverse of registration order —
|
||||
mirroring the `compensateAll` contract.
|
||||
|
||||
OTel attributes carry `step_id` and `outcome` so dashboards can chart
|
||||
per-step compensation success rates without re-joining with the original
|
||||
step registry.
|
||||
120
docs/foundation/flow/workflow/src/compensation/saga/saga.md
Normal file
120
docs/foundation/flow/workflow/src/compensation/saga/saga.md
Normal 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.
|
||||
Reference in New Issue
Block a user