3.6 KiB
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
IdempotencyGuardshape — the foundation package ships memory, database, and Redis store adapters; the saga wrapper picks whichever one the host has wired.
Usage: Basic
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:
import { SagaIdempotencyWrapper } from "@suaveplan/api-saga";
async function dispatchStep<T extends Readonly<Record<string, unknown>>>(
sagaId: string,
step: StepDeclaration,
wrapper: SagaIdempotencyWrapper,
handler: () => Promise<T>
): Promise<T> {
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:<sagaId>:<stepName>.SagaIdempotencyWrapper(guard: IdempotencyGuard)— constructor.wrapper.run<T>(sagaId, stepName, mode, fn): Promise<T>— 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.