first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -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.

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.