# `compensation` _(no submodules)_ ## Purpose On step failure the saga orchestrator walks completed steps in reverse order and invokes each step's compensation handler. Per design doc §9.1 those handlers MUST be idempotent + retryable up to a configured budget (`maxCompensationAttempts`, default 5). This module owns that retry loop and the terminal-failure shape — the orchestrator threads a per-step `run(attempt)` callback that bundles "actually invoke the plugin's compensate handler, update persistence, emit the event" into a single closure the runner can retry. The runner intentionally knows nothing about persistence or events — it owns ONLY the retry loop and the reverse-walk. This keeps the module unit-testable without any infrastructure and lets the orchestrator reuse it for the recovery replay path (which also needs "walk completed steps in reverse, retry up to N"). ## Features - Reverse-order walk of completed steps. - Per-step retry loop up to `maxAttempts` (default 5). - `compensation_failed` returned on the first step whose retry budget is exhausted; remaining steps are NOT attempted (the saga is already in a broken-rollback terminal state). - `raiseCompensationExhausted(...)` builder for the terminal error, chaining the underlying cause. - Configurable per-saga `maxAttempts` so a saga with very expensive compensation handlers can opt into a smaller budget. - Stateless module — every call yields a fresh `CompensationRunResult`. ## Usage: Basic ```ts import { runCompensation, } from "@suaveplan/api-saga/compensation/compensation"; const result = await runCompensation({ completedSteps: [ { stepIndex: 0, stepName: "charge", run: async () => refund() }, { stepIndex: 1, stepName: "ship", run: async () => cancelShipment() }, ], }); if (result.outcome === "compensated") { // every step's compensate succeeded } ``` ## Usage: Advanced Plug in per-saga retry budget and per-step instrumentation. The runner's `run(attempt)` callback receives the current attempt number so it can update telemetry and the step row before the actual compensate invocation: ```ts import { raiseCompensationExhausted, runCompensation, } from "@suaveplan/api-saga"; const result = await runCompensation({ maxAttempts: 3, completedSteps: completedRows.map((row) => ({ stepIndex: row.stepIndex, stepName: row.name, run: async (attempt) => { instruments.compensationsTotal.add(1, { name: sagaName, step: row.name, }); persistence.transitionStep(sagaId, row.stepIndex, "compensating"); await participant.compensate( { input: row.payload.input, output: row.payload.output }, ctx ); persistence.transitionStep(sagaId, row.stepIndex, "compensated"); }, })), }); if (result.outcome === "compensation_failed") { raiseCompensationExhausted({ sagaId, sagaName, stepName: result.failedStep ?? "?", attempts: result.perStep[result.perStep.length - 1]?.attempts ?? 0, cause: new Error("see prior errors"), }); } ``` ## API Reference - `DEFAULT_MAX_COMPENSATION_ATTEMPTS = 5`. - `CompensableStep { stepIndex, stepName, run(attempt) }` — input contract. - `CompensationOutcome` — per-step union of `compensated | failed`. - `CompensationRunResult` — overall outcome: `{ outcome: "compensated" | "compensation_failed", perStep, failedStep? }`. - `runCompensation({ completedSteps, maxAttempts? })` — entry point. - `raiseCompensationExhausted({ sagaId, sagaName, stepName, attempts, cause }): never` — terminal error builder. ## Implementation Notes The reverse-walk runs sequentially rather than in parallel. Design §9.1 explicitly calls out "reverse order" because step N's compensation may depend on side-effects that step N+1's compensation removes (e.g. a fulfilment-cancel that walks an order id that the order-cancel step is about to delete). Sequencing keeps that guarantee. The first compensation-failed step short-circuits the remaining walk by design — once one step's compensation is unrecoverable, the saga is already in a broken state and continuing would risk double-undoing earlier steps if a later compensation succeeded in isolation but referenced state the failed compensation had already mutated.