- Node.js 20+
- TypeScript 5.x
- PostgreSQL 13+ (if using
@duraflows/pg)
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/nestjsYou 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 |
The migration creates three indexes:
workflow_instances_workflow_name_idx-- lookup by workflow nameworkflow_instances_expires_at_idx-- partial index for timeout processing (only non-nullexpires_at)workflow_history_instance_created_idx-- history lookup ordered bycreated_at DESC
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: {},
},
};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" };
}
}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() },
});// 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 === trueThe 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);
}
}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.
- Workflow Definitions -- deep dive into states, events, commands, timeouts, context, metadata, and onEnter
- Core Runtime API -- full API reference
- Persistence -- custom adapters for Prisma, Drizzle, etc.
- NestJS Integration -- module configuration, services, controllers
- Error Handling -- error types and recovery strategies