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 @@
# `state/state-machine`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## 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<State, ReadonlySet<State>>` 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.