@oofp/saga
@oofp/saga implements the saga pattern for orchestrating multi-step operations that need automatic rollback on failure. Each step defines an action and an optional compensation (undo). If any step fails, all previously completed compensations run in reverse order (LIFO).
pnpm add @oofp/sagaLicense: MIT | Peer dependency: @oofp/core
The Problem
Section titled “The Problem”Many workflows involve multiple side effects that must be treated as a logical transaction:
- Create a database record
- Register in an external service
- Send a notification
If step 3 fails, you need to undo steps 1 and 2. Without the saga pattern, you end up with deeply nested try-catch blocks and manual cleanup logic.
The entire API surface is three functions: step, chain, and run.
import * as Saga from "@oofp/saga";Defines a saga step with an action and an optional compensation function.
Saga.step({ name: string, action: RTE<R, E, A>, compensate?: (result: A) => RTE<R, E, void>,})// Returns: SagaStep<R, E, A>name— a label for debugging and loggingaction— theReaderTaskEitherto executecompensate— receives the action’s result and returns an RTE that undoes it
Sequences saga steps. The result of the previous step is available to build the next one.
Saga.chain((previousResult: A) => nextStep)// (SagaStep<R1, E1, A>) => SagaStep<R1 & R2, E1 | E2, B>Context types merge (R1 & R2) and error types widen (E1 | E2) automatically — just like RTE.chainwc.
Executes the saga. On success, returns the final result. On failure, runs all accumulated compensations in reverse order before returning the error.
Saga.run(sagaStep)// SagaStep<R, E, A> => RTE<R, E | Error, A>Complete Example
Section titled “Complete Example”import * as Saga from "@oofp/saga";import * as RTE from "@oofp/core/reader-task-either";import * as TE from "@oofp/core/task-either";import { pipe } from "@oofp/core/pipe";
// Typesinterface DbContext { db: { insert: (table: string, data: unknown) => Promise<{ id: string }>; delete: (table: string, id: string) => Promise<void>; };}
interface AuthContext { auth: { register: (email: string) => Promise<{ uid: string }>; delete: (uid: string) => Promise<void>; };}
interface MailContext { mailer: { send: (to: string, body: string) => Promise<void> };}
type Recruiter = { id: string; email: string };type User = { id: string; recruiterId: string };type AuthIdentity = { uid: string };
// Step 1: Create recruiter in DBconst createRecruiterStep = (email: string) => Saga.step({ name: "create-recruiter", action: pipe( RTE.ask<DbContext>(), RTE.chaint((ctx) => TE.tryCatch( () => ctx.db.insert("recruiters", { email }), (err) => new Error(`Insert failed: ${err}`), ), ), RTE.map((row): Recruiter => ({ id: row.id, email })), ), compensate: (recruiter) => pipe( RTE.ask<DbContext>(), RTE.chaint((ctx) => TE.tryCatch( () => ctx.db.delete("recruiters", recruiter.id), (err) => new Error(`Compensation failed: ${err}`), ), ), ), });
// Step 2: Register in auth serviceconst registerAuthStep = (recruiter: Recruiter) => Saga.step({ name: "register-auth", action: pipe( RTE.ask<AuthContext>(), RTE.chaint((ctx) => TE.tryCatch( () => ctx.auth.register(recruiter.email), (err) => new Error(`Auth registration failed: ${err}`), ), ), ), compensate: (identity) => pipe( RTE.ask<AuthContext>(), RTE.chaint((ctx) => TE.tryCatch( () => ctx.auth.delete(identity.uid), (err) => new Error(`Auth compensation failed: ${err}`), ), ), ), });
// Step 3: Send welcome email (no compensation needed)const sendWelcomeStep = (recruiter: Recruiter) => Saga.step({ name: "send-welcome", action: pipe( RTE.ask<MailContext>(), RTE.chaint((ctx) => TE.tryCatch( () => ctx.mailer.send(recruiter.email, "Welcome!"), (err) => new Error(`Email failed: ${err}`), ), ), ), // No compensate — can't unsend an email });
// Compose the sagaconst registerRecruiter = (email: string) => pipe( createRecruiterStep(email), Saga.chain((recruiter) => registerAuthStep(recruiter)), Saga.chain((identity) => Saga.step({ name: "finalize", action: RTE.of(identity), }), ), Saga.run, );// Type: RTE<DbContext & AuthContext, Error, AuthIdentity>How Compensation Works
Section titled “How Compensation Works”When Saga.run executes:
- Each step runs sequentially
- Successful steps accumulate their compensation functions
- If a step fails:
- Compensations run in reverse order (LIFO — last completed, first undone)
- The original error is returned after all compensations complete
- If all steps succeed, the final result is returned
Step 1 ✓ → Step 2 ✓ → Step 3 ✗ ↓ Compensate Step 2 ↓ Compensate Step 1 ↓ Return original errorTypes Reference
Section titled “Types Reference”type SagaStepConstructor<R, E, A> = { name: string; action: RTE<R, E, A>; compensate?: (result: A) => RTE<R, E, void>;};
type SagaStep<R, E, A> = RTE<R, Error, SagaState<R, E, A>>;
type SagaState<R, E, A> = { result: Either<E, A>; completedSteps: ReadonlyArray<SagaStepResult<R, E>>;};