saga-engine-ts
v0.1.0
Published
Framework-agnostic orchestration-based saga engine for multi-step async workflows with step-level idempotency, compensating transactions, and crash recovery.
Maintainers
Readme
saga-engine-ts
Framework-agnostic orchestration-based saga engine for multi-step async workflows with step-level idempotency, compensating transactions, and crash recovery.
Zero dependencies. Pure TypeScript. Bring your own persistence.
Install
npm install saga-engine-tsQuick Start
import {
SagaOrchestrator,
generateIdempotencyKey,
type SagaDefinition,
type SagaStore,
} from "saga-engine-ts";
// 1. Define your saga
const orderSaga: SagaDefinition<OrderContext> = {
type: "ORDER_PROCESSING",
steps: [
{
name: "ValidatePayload",
execute: async (ctx) => {
// Validate the order data
if (!ctx.orderId) throw new Error("Missing orderId");
return { validated: true };
},
// No compensate — nothing to undo
},
{
name: "CreateOrder",
execute: async (ctx) => {
const order = await db.orders.create(ctx.orderData);
return { createdOrderId: order.id };
},
compensate: async (ctx) => {
// Undo: delete the order
await db.orders.delete(ctx.createdOrderId);
},
},
{
name: "UpdateInventory",
execute: async (ctx) => {
await db.inventory.decrement(ctx.sku, ctx.quantity);
return { inventoryUpdated: true };
},
compensate: async (ctx) => {
// Undo: restore inventory
await db.inventory.increment(ctx.sku, ctx.quantity);
},
},
{
name: "SendNotification",
execute: async (ctx) => {
await notifications.send(ctx.createdOrderId);
return {};
},
// No compensate — best-effort, can't unsend
},
],
};
// 2. Implement the SagaStore interface (your persistence layer)
const store: SagaStore = new YourPrismaSagaStore(prisma);
// 3. Create orchestrator and execute
const orchestrator = new SagaOrchestrator(store);
const result = await orchestrator.execute(
orderSaga,
{ orderId: "123", orderData: { ... }, sku: "SKU-001", quantity: 2 },
generateIdempotencyKey("store-1", "order-123", "orders/create"),
"store-1"
);
console.log(result.outcome); // "COMPLETED" | "COMPENSATED" | "FAILED"Features
Step-Level Idempotency
Each step gets a unique idempotency key (sha256(sagaId + stepName)). If a saga is re-executed (e.g., after a crash), completed steps are skipped automatically.
Saga starts → Step 1 ✓ → Step 2 ✓ → [CRASH]
Saga resumes → Step 1 (skip) → Step 2 (skip) → Step 3 → Step 4 ✓Saga-Level Idempotency
The same idempotency key returns the cached result without re-executing.
// First call — executes all steps
await orchestrator.execute(saga, ctx, "key-123", "store-1");
// Second call with same key — returns cached result
await orchestrator.execute(saga, ctx, "key-123", "store-1");Compensating Transactions
If a step fails after max retries, the orchestrator runs compensate() on all previously completed steps in reverse order.
Step 1 ✓ → Step 2 ✓ → Step 3 ✗ (failed)
↓
Compensating: Step 2.compensate() → Step 1.compensate()Steps without a compensate function are skipped during compensation.
Configurable Retries
Each step can define its own retry count (default: 3).
{
name: "CallExternalAPI",
maxRetries: 5,
execute: async (ctx) => { ... },
compensate: async (ctx) => { ... },
}SagaStore Interface
You must implement the SagaStore interface for your persistence layer:
interface SagaStore {
createSagaInstance(data: {
idempotencyKey: string;
type: string;
status: string;
storeId: string;
input: Record<string, unknown>;
}): Promise<SagaInstanceRecord>;
findSagaByIdempotencyKey(key: string): Promise<SagaInstanceRecord | null>;
updateSagaInstance(
id: string,
data: Partial<SagaInstanceRecord>
): Promise<void>;
createSagaStep(data: {
sagaId: string;
stepName: string;
stepIndex: number;
idempotencyKey: string;
status: string;
}): Promise<SagaStepRecord>;
findSagaStep(
sagaId: string,
stepName: string
): Promise<SagaStepRecord | null>;
updateSagaStep(
id: string,
data: Partial<SagaStepRecord>
): Promise<void>;
}Example: Prisma Implementation
import type { PrismaClient } from "@prisma/client";
import type { SagaStore } from "saga-engine-ts";
export class PrismaSagaStore implements SagaStore {
constructor(private prisma: PrismaClient) {}
async createSagaInstance(data) {
return this.prisma.sagaInstance.create({ data });
}
async findSagaByIdempotencyKey(key) {
return this.prisma.sagaInstance.findUnique({
where: { idempotencyKey: key },
});
}
// ... implement all methods
}API
SagaOrchestrator
class SagaOrchestrator {
constructor(store: SagaStore, logger?: SagaLogger);
execute<T>(
definition: SagaDefinition<T>,
initialContext: T,
idempotencyKey: string,
storeId: string
): Promise<SagaExecutionResult<T>>;
}generateIdempotencyKey(...parts: string[]): string
Generates a deterministic SHA-256 key from input parts. Use this to create consistent idempotency keys.
SagaExecutionResult<T>
interface SagaExecutionResult<T> {
sagaId: string;
outcome: "COMPLETED" | "COMPENSATED" | "FAILED";
context: T;
error?: string;
}License
MIT
