# `state/state-machine` _(no submodules)_ ## Purpose The saga lifecycle has two coupled finite-state machines: the instance state (overall saga health) and the per-step state. This module pins both transition tables in one place so every other module — orchestrator, recovery, compensation — funnels every write through a single guard. The guard refuses transitions the table does not declare and raises `SAGA.INVALID_STATE_TRANSITION` so a buggy orchestrator path can never silently demote `completed` back into `running` (or, more dangerously, out of a terminal state into a runnable one). ## Features - Instance machine: `running → {completed, compensating, cancelled}` and `compensating → {compensated, compensation_failed}`. Terminal states (`completed`, `compensated`, `compensation_failed`, `cancelled`) have no outgoing transitions. - Step machine: `pending → in_flight → {completed, failed}`; completed steps may roll back via `compensating → {compensated, failed}`. - `assertInstanceTransition` / `assertStepTransition` throw `SagaError(SAGA.INVALID_STATE_TRANSITION)` with full context. - `isTerminalInstance` / `isTerminalStep` predicates so the recovery runner can skip already-settled instances. - Idempotent same-state transitions (`from === to`) are accepted as a no-op — useful for re-asserting state during recovery replay. ## Usage: Basic ```ts import { assertInstanceTransition, canTransitionInstance, } from "@suaveplan/api-saga/state/state-machine"; assertInstanceTransition("s_1", "running", "completed"); // ok canTransitionInstance("completed", "running"); // false ``` ## Usage: Advanced Compose with the persistence layer so every DB row write is gated on the state machine. Same-state writes are accepted but a forbidden transition halts the orchestrator before the DB is mutated: ```ts import { assertStepTransition, canTransitionStep, } from "@suaveplan/api-saga"; function updateStep( sagaId: string, stepName: string, current: SagaStepState, next: SagaStepState ): void { assertStepTransition(sagaId, stepName, current, next); if (current === next) { return; // idempotent — DB already reflects the state } persistence.updateStep(sagaId, stepName, next); } ``` ## API Reference - `SagaInstanceState` — `"running" | "completed" | "compensating" | "compensated" | "compensation_failed" | "cancelled"`. - `SagaStepState` — `"pending" | "in_flight" | "completed" | "failed" | "compensating" | "compensated"`. - `TERMINAL_INSTANCE_STATES`, `TERMINAL_STEP_STATES` — frozen sets. - `canTransitionInstance(from, to): boolean`, `canTransitionStep(from, to): boolean` — predicates. - `assertInstanceTransition(sagaId, from, to): void`, `assertStepTransition(sagaId, stepName, from, to): void` — guard functions that throw on rejected transitions. - `isTerminalInstance(state): boolean`, `isTerminalStep(state): boolean` — predicates the recovery runner uses to filter. ## Implementation Notes The transition tables are `ReadonlyMap>` so they're immutable at the type level — a downstream module cannot patch the table at runtime. The compensation machine intentionally allows `compensating → failed`: a single compensate-handler failure does not yet mean the saga's terminal failure (the compensation runner retries up to `maxCompensationAttempts`); the per-step `failed` state is transient in that sense, and the instance-level transition into `compensation_failed` only fires once every retry budget is exhausted.