Skip to content

Latest commit

 

History

History
350 lines (278 loc) · 12 KB

File metadata and controls

350 lines (278 loc) · 12 KB

Getting Started

Prerequisites

  • Node.js 20+
  • TypeScript 5.x
  • PostgreSQL 13+ (if using @duraflows/pg)

Installation

Install the packages you need:

# Core runtime (always required)
pnpm add @duraflows/core

# PostgreSQL adapter (if using pg)
pnpm add @duraflows/pg pg
pnpm add -D @types/pg

# NestJS integration (if using NestJS)
pnpm add @duraflows/nestjs

Database Setup

1. Create the Tables

You can set up the schema in two ways:

Option A: Copy the reference migrations

The @duraflows/pg package ships its reference SQL migrations at:

node_modules/@duraflows/pg/sql/dbmate/

Copy all migration files in that directory and apply them in order with your migration tool (dbmate, Flyway, Knex, Prisma, etc.). Applying only the first migration produces an incomplete schema — the current runtime requires the columns added by the later migrations. The migrations use gen_random_uuid() (PostgreSQL 13+) for history record UUIDs.

Option B: Generate a migration with generateMigrationSql()

Use this to choose between gen_random_uuid() (PG 13+) and uuidv7() (PG 18+, time-ordered):

import { generateMigrationSql } from "@duraflows/pg";

// For PostgreSQL 18+ (time-ordered UUIDs)
const { up, down } = generateMigrationSql({ uuidStrategy: "uuidv7" });

// For PostgreSQL 13-17 (random UUIDs, the default)
const { up, down } = generateMigrationSql();

Paste the returned up and down SQL into your migration file.

This choice also determines whether workflow_history rows written in the same transaction (e.g. an event plus its onEnter chain) can be read back in the order they happened -- see Persistence: Ordering within a multi-hop transition.


Both options create two tables:

workflow_instances -- stores the current state of each workflow instance:

Column Type Description
uuid uuid (PK) Supplied by the application (no DB-side default)
workflow_name text References the registered workflow definition
current_state text Current state name
version integer Incremented on each transition
expires_at timestamptz Active timeout deadline (null if none)
last_transition_at timestamptz When the last transition occurred
context_json jsonb Mutable workflow context (working memory, writable by commands)
metadata_json jsonb Immutable identity labels set at creation
created_at timestamptz Instance creation time
updated_at timestamptz Last modification time

workflow_history -- immutable audit log of every transition:

Column Type Description
uuid uuid (PK) Auto-generated by the database (gen_random_uuid() or uuidv7() depending on your migration)
workflow_instance_uuid uuid (FK) References workflow_instances.uuid
from_state text State before the transition
event_name text Event that was triggered
to_state text State after the transition
outcome text "success" or "failure"
error_message text Error description (if failure)
command_results_json jsonb Ordered results from commands
trigger_metadata_json jsonb Optional metadata about who/what triggered the transition
created_at timestamptz When this history entry was created

2. Indexes

The migration creates three indexes:

  • workflow_instances_workflow_name_idx -- lookup by workflow name
  • workflow_instances_expires_at_idx -- partial index for timeout processing (only non-null expires_at)
  • workflow_history_instance_created_idx -- history lookup ordered by created_at DESC

Your First Workflow

Step 1: Define the Workflow

Create a workflow definition as a plain TypeScript object:

import type { WorkflowDefinition } from "@duraflows/core";

export const ticketWorkflow: WorkflowDefinition = {
  name: "support-ticket",
  initialState: "open",
  states: {
    open: {
      events: {
        Assign: {
          targetState: "in_progress",
        },
        Close: {
          targetState: "closed",
        },
      },
    },
    in_progress: {
      events: {
        Resolve: {
          targetState: "resolved",
          commands: [{ name: "sendResolutionEmail" }],
        },
        Escalate: {
          targetState: "escalated",
        },
      },
    },
    escalated: {
      events: {
        Resolve: {
          targetState: "resolved",
          commands: [{ name: "sendResolutionEmail" }],
        },
      },
    },
    resolved: {
      events: {
        Reopen: {
          targetState: "open",
        },
        AutoClose: {
          targetState: "closed",
          timeout: { afterDays: 7 },
        },
      },
    },
    closed: {},
  },
};

Step 2: Implement Command Handlers

With NestJS -- use the @WorkflowCommand decorator to auto-register:

import { WorkflowCommand } from "@duraflows/nestjs";
import type {
  WorkflowCommand as WorkflowCommandInterface,
  CommandResult,
  WorkflowExecutionContext,
} from "@duraflows/core";

@WorkflowCommand("sendResolutionEmail")
export class SendResolutionEmailCommand implements WorkflowCommandInterface {
  async execute(subject: unknown, context: WorkflowExecutionContext): Promise<CommandResult> {
    const ticket = subject as Ticket;
    await emailService.send({
      to: ticket.customerEmail,
      template: "ticket-resolved",
      data: { ticketId: ticket.id },
    });
    return { ok: true, code: "EMAIL_SENT" };
  }
}

Without NestJS:

import type { WorkflowCommand, CommandResult, WorkflowExecutionContext } from "@duraflows/core";

export class SendResolutionEmailCommand implements WorkflowCommand {
  async execute(subject: unknown, context: WorkflowExecutionContext): Promise<CommandResult> {
    const ticket = subject as Ticket;
    await emailService.send({
      to: ticket.customerEmail,
      template: "ticket-resolved",
      data: { ticketId: ticket.id },
    });
    return { ok: true, code: "EMAIL_SENT" };
  }
}

Step 3: Wire It Up

With NestJS:

import { Module } from "@nestjs/common";
import { Pool } from "pg";
import { WorkflowModule } from "@duraflows/nestjs";
import { pgWorkflowProviders } from "@duraflows/pg";
import { ticketWorkflow } from "./workflows/ticket.workflow";
import { SendResolutionEmailCommand } from "./commands/send-resolution-email.command";

const pool = new Pool({ connectionString: process.env.DATABASE_URL });

@Module({
  imports: [
    WorkflowModule.forRoot({
      workflows: [ticketWorkflow],
      persistence: pgWorkflowProviders(pool),
    }),
  ],
  providers: [SendResolutionEmailCommand],
})
export class AppModule {}

The @WorkflowCommand("sendResolutionEmail") decorator on the class handles registration -- no commands array needed. See NestJS Integration for details.

Without NestJS:

import {
  WorkflowRuntime,
  InMemoryDefinitionRegistry,
  InMemoryCommandRegistry,
  WorkflowValidator,
  WorkflowCompiler,
} from "@duraflows/core";
import { pgWorkflowProviders } from "@duraflows/pg";
import { Pool } from "pg";

const pool = new Pool({ connectionString: process.env.DATABASE_URL });

const definitionRegistry = new InMemoryDefinitionRegistry({
  validator: new WorkflowValidator(),
  compiler: new WorkflowCompiler(),
});
definitionRegistry.register(ticketWorkflow); // validates + compiles eagerly

const commandRegistry = new InMemoryCommandRegistry();
commandRegistry.register("sendResolutionEmail", new SendResolutionEmailCommand());

const runtime = new WorkflowRuntime({
  definitionRegistry,
  commandRegistry,
  ...pgWorkflowProviders(pool),
  clock: { now: () => new Date() },
});

Step 4: Create and Transition Instances

// Create a new ticket workflow instance
const instance = await runtime.createInstance({
  workflowName: "support-ticket",
  metadata: { ticketId: "TK-001" },
});
// instance.currentState === "open"

// Get a handle — binds the UUID, no DB call
const handle = runtime.getHandle(instance.uuid);

// Assign the ticket
const result = await handle.triggerEvent("Assign", {
  subject: ticket,
  triggerMetadata: { source: "user", actor: agentUuid },
});
// result.outcome === "success"
// result.toState === "in_progress"

// Resolve it (executes sendResolutionEmail command)
const resolveResult = await handle.triggerEvent("Resolve", {
  subject: ticket,
  triggerMetadata: { source: "user", actor: agentUuid },
});
// resolveResult.toState === "resolved"
// resolveResult.commandResults[0].ok === true

Step 5: Process Timeouts

The resolved state has a 7-day auto-close timeout. Run the timeout processor periodically:

// Process up to 100 expired instances
const result = await runtime.processExpiredWorkflows({ limit: 100 });
console.log(`Processed ${result.processed}, guard-rejected ${result.rejected}`);
if (result.failed.length > 0) {
  console.warn(`Failed: ${result.failed.map((f) => f.uuid).join(", ")}`);
}

In NestJS, use a cron job:

import { Injectable } from "@nestjs/common";
import { Cron, CronExpression } from "@nestjs/schedule";
import { WorkflowTimeoutService } from "@duraflows/nestjs";

@Injectable()
export class TimeoutScheduler {
  constructor(private readonly timeoutService: WorkflowTimeoutService) {}

  @Cron(CronExpression.EVERY_MINUTE)
  async handle() {
    await this.timeoutService.processExpiredWorkflows(100);
  }
}

Auto-Transitions with onEnter

States can define onEnter to automatically execute commands and/or transition when entered. This is useful for intermediate processing states:

states: {
  validating: {
    onEnter: {
      targetState: "validated",
      errorState: "validation_failed",
      commands: [{ name: "runValidation" }],
    },
  },
  validated: { /* ... */ },
  validation_failed: { /* ... */ },
}

When a workflow enters validating (via any event or timeout), the runValidation command executes automatically. If it succeeds, the workflow transitions to validated. If it fails, it goes to validation_failed. See onEnter for details.

Next Steps