# `idempotency` _(no submodules)_ ## Purpose Per design doc §9.1 the saga orchestrator MUST wrap every step invocation in an idempotency guard keyed by `{sagaId, stepName}` so a replayed step (e.g. after a host restart or a transient network-induced retry) returns the cached outcome without re-entering the handler. This module is the thin wrapper layered over the foundation `@suaveplan/idempotency` package so the orchestrator does not have to know the underlying guard's API. The wrapper also honours the per-action `idempotency: "manual"` opt-out — the participant registration can declare that the action handles its own dedup, in which case the wrapper invokes the handler directly and the foundation guard is bypassed. This matches the "Idempotency contract" bullet under design §9.1. ## Features - `stepIdempotencyKey(sagaId, stepName)` — stable composite key shared between orchestrator and recovery so a replayed step keeps hashing to the same record. - `SagaIdempotencyWrapper.run(...)` — single entry point the orchestrator funnels every step invocation through. - Honours the per-action `idempotency: "manual"` opt-out: the wrapper short-circuits before the guard runs. - Composable with any `IdempotencyGuard` shape — the foundation package ships memory, database, and Redis store adapters; the saga wrapper picks whichever one the host has wired. ## Usage: Basic ```ts import { IdempotencyGuard, MemoryIdempotencyStore } from "@suaveplan/idempotency"; import { SagaIdempotencyWrapper } from "@suaveplan/api-saga/idempotency/idempotency"; const guard = new IdempotencyGuard({ store: new MemoryIdempotencyStore() }); const wrapper = new SagaIdempotencyWrapper(guard); const output = await wrapper.run("s_1", "charge", "auto", async () => { return { chargeId: "ch_1" }; }); ``` ## Usage: Advanced Compose the wrapper with the orchestrator step-dispatch path. The wrapper is intentionally generic over the result type so a typed step handler keeps its return type through the wrap: ```ts import { SagaIdempotencyWrapper } from "@suaveplan/api-saga"; async function dispatchStep>>( sagaId: string, step: StepDeclaration, wrapper: SagaIdempotencyWrapper, handler: () => Promise ): Promise { return wrapper.run(sagaId, step.action, registration.idempotency, handler); } ``` A retried step whose first attempt completed under the same `{sagaId, stepName}` key returns the cached outcome — the handler body is not re-entered. A retried step whose previous attempt is still `pending` (lock not timed out) throws `IdempotencyConflictError`; the orchestrator interprets this as "let the live attempt finish" and does not re-fire the step. ## API Reference - `stepIdempotencyKey(sagaId, stepName): string` — `saga::`. - `SagaIdempotencyWrapper(guard: IdempotencyGuard)` — constructor. - `wrapper.run(sagaId, stepName, mode, fn): Promise` — returns the cached or newly produced outcome. ## Implementation Notes The wrapper is intentionally NOT a subclass of `IdempotencyGuard` — it composes one. The foundation guard owns fingerprinting and TTL; the saga wrapper only needs the `execute(key, fn)` entry point. Subclassing would expose internal state through `protected` slots and turn this module into a tight binding to the guard's class hierarchy. Manual mode is deliberately a no-op rather than a "bypass + warn" — some saga steps (e.g. external API calls whose dedup is already handled by the API's request-id contract) genuinely don't want a second layer of idempotency.