Getting started
minisagas runs a list of async steps in order. If one of them throws, every step that already succeeded is rolled back, in reverse, using the compensate function you gave it.
That is the whole idea. The rest of these docs is options.
Install
pnpm add minisagasnpm install minisagasyarn add minisagasESM only, TypeScript types included, no runtime dependencies.
Runs on Node 20.3+ (the line CI tests), Deno, Bun, and browsers from Chrome/Edge 100, Firefox 97 and Safari 15.4. AbortSignal's throwIfAborted and abort(reason) are the whole compatibility story: nothing else in the bundle is newer, and nothing in it is Node-specific.
Your first saga
import { defineSaga } from "minisagas";
type OrderInput = { orderId: string; amount: number };
type Services = { payments: PaymentApi; warehouse: WarehouseApi };
const saga = defineSaga<OrderInput, Services>("checkout")
.task({
name: "reserve",
execute: async ({ input, context }) => context.warehouse.hold(input.orderId),
compensate: async ({ result, context }) => context.warehouse.release(result.holdId),
})
.task({
name: "charge",
execute: async ({ input, context }) => context.payments.charge(input.amount),
compensate: async ({ result, context }) => context.payments.refund(result.chargeId),
})
.build({ payments, warehouse });
const result = await saga.execute({ orderId: "ord-1", amount: 4200 });If charge throws, reserve's compensation runs and the hold is released. You get a result object either way; execute does not reject.
Reading the result
execute resolves to a discriminated union. Narrow on success:
if (result.success) {
result.results.charge.chargeId; // typed from what charge returned
} else {
result.error; // what went wrong
result.failedTask; // which task it went wrong in
result.compensated; // names of the tasks that were rolled back
}Both branches also carry input, context, executedTasks and duration.
The three arguments
Every callback (execute, compensate, iterator, when, key) receives one object with the same three properties:
| Property | What it is |
|---|---|
input | What you passed to execute(input). The same value for every task. |
results | What the tasks before this one returned, keyed by task name. |
context | What you passed to build(context). Your services, config, clients. |
The split matters: input is per-run data, context is per-deployment wiring. Keeping your database client in context rather than input is what makes a saga testable: swap the context, keep the flow.
results grows as you chain. Inside the second task, results.reserve exists and is typed; results.charge does not exist yet, and TypeScript says so.
Build once, execute many
build(context) freezes the node list and returns a Saga. That saga is reusable; call execute on it as often as you like, with different inputs. Nothing is stored on the saga between runs.
const saga = buildCheckout(services); // once, at startup
await saga.execute(orderA); // per request
await saga.execute(orderB);Next
- Tasks: single tasks, mapped tasks, reusable definitions
- Task options: retry, timeout,
optional,when - Compensation: what rolls back, and what does not