Damat

9. Workflows (the saga engine)

For multi-step operations that must roll back cleanly on failure, use @damatjs/workflow-engine. It implements the saga pattern on Effect-TS: each step has a forward action and an optional compensation; if a later step fails, the engine runs the compensations of completed steps in reverse.

A step (with compensation)

The forward action returns a StepResponse(output, compensateInput?): output flows downstream to the next steps, while compensateInput is the payload handed to the compensation. The compensation receives only that payload (plus the context) — capture whatever rollback needs. The third generic C is the payload type; when it excludes undefined, supplying it is required at compile time.

import { createStep, StepResponse } from "@damatjs/workflow-engine";
import { getModule } from "@damatjs/framework";

export const createProfileStep = createStep<NewUser, User, string>(
  "create-profile",
  async (input, ctx) => {                       // forward
    const users = getModule("user");
    const user = await users.user.create({ data: { email: input.email }, returning: ["id", "email"] });
    // output = the user (downstream); compensateInput = its id (for rollback).
    return new StepResponse(user, user.id);
  },
  async (userId, ctx) => {                       // compensation (rollback)
    getModule("user").user.delete({ where: { id: userId } });
  },
  { timeoutMs: 10_000, description: "Create user profile" },
);

A workflow

A step is callable: step(input, ctx) is sugar for executeStep(step, input, ctx), so workflows compose steps inside an Effect generator by calling them directly — no executeStep noise:

import { createWorkflow, Effect } from "@damatjs/workflow-engine";

export const userOnboardingWorkflow = createWorkflow<NewUser, { user: User; emailSent: boolean }>(
  "user-onboarding",
  (input, ctx) =>
    Effect.gen(function* () {
      const user = yield* createProfileStep(input, ctx);

      // Optional third argument: override retry/timeout for THIS call only.
      // The step definition stays untouched; omit it to keep its configured
      // values. Layering is engine defaults < workflow defaults < step config
      // < this per-call override.
      const email = yield* sendWelcomeEmailStep(user, ctx, {
        timeoutMs: 15_000,
        retry: { maxAttempts: 3 },
      });

      return { user, emailSent: email.sent };
    }),
  { timeoutMs: 60_000 },
);

The explicit executeStep(step, input, ctx, overrideConfig?) form is still exported and takes the same optional override — reach for it when you need a step value the generator can't infer, or prefer the named call.

Running one

const result = await userOnboardingWorkflow.execute(input);
// or, to prevent concurrent runs for the same key:
const result = await userOnboardingWorkflow.executeWithLock(input, {
  lockId: input.email,
  ttlMs: 60_000,
});

if (result.success) {
  // result.result, result.executionId, result.durationMs
} else {
  // result.error.message, result.error.code, result.compensated
}

Retry policies, control flow (parallel / when / ifElse), distributed locking, and the error classes are all covered in the workflow-engine internals. Distributed locks are backed by @damatjs/redis.