Skip to content

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

sh
pnpm add minisagas
sh
npm install minisagas
sh
yarn add minisagas

ESM 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

ts
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:

ts
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:

PropertyWhat it is
inputWhat you passed to execute(input). The same value for every task.
resultsWhat the tasks before this one returned, keyed by task name.
contextWhat 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.

ts
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

MIT Licensed