The @duraflows/core package provides the framework-agnostic workflow runtime, types, validation, compilation, and execution engine.
The main entry point for all workflow operations.
import { WorkflowRuntime } from "@duraflows/core";new WorkflowRuntime(options: WorkflowRuntimeOptions)WorkflowRuntimeOptions:
| Property | Type | Description |
|---|---|---|
definitionRegistry |
WorkflowDefinitionRegistry |
Registry of workflow definitions |
commandRegistry |
WorkflowCommandRegistry |
Registry of command handlers |
instanceStore |
WorkflowInstanceStore |
Persistence for workflow instances |
historyStore |
WorkflowHistoryStore |
Persistence for history records |
transactionRunner |
WorkflowTransactionRunner |
Transaction management |
clock |
WorkflowClock |
Clock for timestamps (injectable for testing) |
maxOnEnterDepth |
number |
Maximum depth for onEnter auto-transition chains (default: 10) |
observers |
readonly WorkflowObserver[] |
Optional observers notified post-commit on every state entry |
guardRegistry |
WorkflowGuardRegistry |
Optional registry of guard implementations; required when any workflow definition references a guard |
definitionStore |
WorkflowDefinitionStore |
Optional store for definition snapshots; when present, initialize() syncs registered definitions into it and enforces the version-bump guard. Omit to leave definition versioning inert. |
Syncs registered workflow definitions into the configured definitionStore and enforces the version-bump guard.
async initialize(): Promise<void>Behavior:
- For each definition in the
definitionRegistry, computes its content hash and callsdefinitionStore.ensure(), which inserts a snapshot for that(workflowName, version)if one doesn't already exist, or returns the existing one untouched. - Compares the returned snapshot's stored content hash against the freshly computed one. A mismatch means a known version was re-registered with different content.
- No-ops entirely when the runtime was constructed without a
definitionStore— definition versioning is inert in that case.
Idempotent and safe to call repeatedly or concurrently: the first call starts the sync and every caller — concurrent or later — shares that same in-flight (or already-settled) result, so definitions are synced once. A failed sync is not cached — the next call to initialize() retries from scratch rather than replaying the failure.
Calling it explicitly is optional: createInstance(), triggerEvent(), and processExpiredWorkflows() each call initialize() first, so the sync also happens lazily on whichever mutating operation runs first. Calling it explicitly at boot is still recommended — the NestJS module does this automatically (see Startup Validation) — because it makes a version-bump violation fail application startup instead of surfacing unpredictably on the first workflow operation.
Throws:
WorkflowDefinitionError— a known(workflowName, version)pair's stored content hash differs from the registered definition's, meaning the definition's content changed without itsversionbeing bumped.
Example:
await runtime.initialize(); // recommended at boot, before serving trafficCreates a new workflow instance at the initial state.
async createInstance(input: CreateWorkflowInstanceInput): Promise<WorkflowInstance>Parameters:
| Property | Type | Required | Description |
|---|---|---|---|
workflowName |
string |
Yes | Must match a registered workflow definition |
context |
Record<string, unknown> |
No | Initial mutable context data. Merged with state-defined context on entry. See Context and Metadata. |
metadata |
Record<string, unknown> |
No | Immutable identity labels (e.g., { orderId: "..." }). Never modified after creation. |
triggerMetadata |
Record<string, unknown> |
No | Optional metadata about who/what created the instance |
Behavior:
- Resolves the workflow definition from the registry (already validated and compiled at registration time)
- Seeds context from the initial state's defaults first, then merges
input.contexton top (user input wins) - Computes timeout deadline for the initial state (if applicable)
- Persists via
instanceStore.create() - If the initial state has an
onEnterdefinition, processes the onEnter chain (runs inside a transaction). See onEnter.
Returns: The created WorkflowInstance (reflecting the final state after any onEnter chain).
Example:
const instance = await runtime.createInstance({
workflowName: "order",
metadata: { orderId: "ORD-123", customerId: "CUST-456" },
});
console.log(instance.uuid); // "a1b2c3d4-..."
console.log(instance.currentState); // "new"
console.log(instance.version); // 0Triggers an event on a workflow instance, executing commands and transitioning state.
async triggerEvent(input: TriggerWorkflowEventInput): Promise<WorkflowExecutionResult>Parameters:
| Property | Type | Required | Description |
|---|---|---|---|
workflowInstanceUuid |
string |
Yes | The instance to trigger the event on |
eventName |
string |
Yes | Must be a valid event on the current state |
subject |
unknown |
No | Domain entity passed to command handlers |
triggerMetadata |
Record<string, unknown> |
No | Optional metadata about who/what triggered the event |
Behavior:
- Runs inside
transactionRunner.runInTransaction() - Locks the instance via
instanceStore.lockByUuid()(row-level lock) - Builds
WorkflowExecutionContextwith the instance'scontext(mutable copy) andmetadata(frozen) - Validates event exists on current state (
InvalidEventErrorif not) - Executes commands sequentially (fail-fast). Commands can read/write
contextand readmetadata. - Determines outcome (
"success"or"failure") - Triggers finita state transition via compiled process
- Persists context: command mutations first, then new state's
contextmerged on top (state context wins) - Updates instance (state, version++, context, timeout deadline)
- Appends history record
- If the new state has an
onEnterdefinition, processes the onEnter chain (each hop updates the instance, appends a history record witheventName: "onEnter"andtriggerMetadata: { source: "onEnter" }, and merges context). See onEnter. - Returns the final landing state (after any onEnter hops)
- Commits transaction
Returns:
interface WorkflowExecutionResult {
outcome: "success" | "failure";
fromState: string;
toState: string;
commandResults: CommandResult[];
historyUuid: string;
}outcome is aggregated across both the event execution and the subsequent onEnter chain:
outcome = eventResult.outcome === "failure" || onEnterChain.outcome === "failure"
? "failure"
: "success"
- A best-effort command returning
ok: falsedoes not taintoutcome. - A mandatory command routing to
errorStatesurfaces asoutcome: "failure"even if subsequent onEnter hops succeed.
Example:
const result = await runtime.triggerEvent({
workflowInstanceUuid: instance.uuid,
eventName: "Export",
subject: orderEntity,
});
console.log(result.outcome); // "success" or "failure"
console.log(result.fromState); // "exportable"
console.log(result.toState); // "exported" or "export_failed"
console.log(result.commandResults.length); // 2
console.log(result.commandResults[0].ok); // trueWhen an event has a guard and the guard returns false, the runtime returns:
{
outcome: "guard-rejected",
fromState: "<currentState>",
toState: "<currentState>", // unchanged
commandResults: [],
rejectedBy: "<guard name>",
historyUuid: "<id of the recorded rejection>",
}A history row is appended (with outcome = "guard-rejected" and rejectedBy set), but:
- the instance state, version, and context are not updated
onEnterchains do not run for the unchanged state- observer
onEnterevents do not fire (no transition occurred)
Register guards via the guardRegistry option:
import { InMemoryGuardRegistry, WorkflowRuntime } from "@duraflows/core";
const guardRegistry = new InMemoryGuardRegistry();
guardRegistry.register("submitterIsVerified", {
name: "submitterIsVerified",
evaluate: (subject, ctx) => ctx.context.submitterVerified === true,
});
const runtime = new WorkflowRuntime({
/* ... */
guardRegistry,
});Batch-processes workflow instances whose timeout deadline has passed.
async processExpiredWorkflows(input?: ProcessExpiredWorkflowsInput): Promise<ProcessExpiredWorkflowsResult>Parameters:
| Property | Type | Default | Description |
|---|---|---|---|
limit |
number |
100 | Maximum instances to process in this batch |
Behavior:
- Opens a short-lived scan transaction that finds up to
limitexpired instances viainstanceStore.findExpired(limit, now)(usesFOR UPDATE SKIP LOCKEDin PostgreSQL). The scan's row locks are released when this transaction ends;nowcomes from the injected clock rather than the database'snow(). - Processes each expired instance in its own transaction:
- Re-locks the instance with
lockByUuidand re-checksexpiresAt— the scan's locks were released, so another worker may have processed the instance in between. Instances no longer expired are skipped. - Resolves the timeout event name from the freshly locked state. If the
definition changed and no timeout event exists, clears
expiresAt. - Triggers the event with
triggerMetadata: { source: "timeout" }and runs the destination state's on-enter chain.
- Re-locks the instance with
- A failure in one instance's transaction rolls back only that instance and
is collected into
failed; it does not stop the batch. Short lock duration and per-instance failure isolation are the reason for this two-phase model. - After all per-instance transactions, fires observers post-commit for the instances that committed.
Returns:
interface ProcessExpiredWorkflowsResult {
processed: number;
rejected: number;
businessFailed: Array<{ uuid: string; finalState: string }>;
failed: Array<{ uuid: string; error: string }>;
}| Property | Type | Description |
|---|---|---|
processed |
number |
Instances whose timeout event transitioned — including those that ended on an error path (see businessFailed) |
rejected |
number |
Instances whose timeout event was short-circuited by a guard returning false |
businessFailed |
Array<{ uuid: string; finalState: string }> |
Subset of processed whose event commands or on-enter chain failed and routed to an errorState |
failed |
Array<{ uuid: string; error: string }> |
Infrastructure failures: the instance's transaction rolled back |
Example:
const result = await runtime.processExpiredWorkflows({ limit: 50 });
console.log(`Processed ${result.processed}, guard-rejected ${result.rejected}`);
if (result.businessFailed.length > 0) {
console.warn(`Business errors: ${result.businessFailed.map((f) => `${f.uuid} → ${f.finalState}`).join(", ")}`);
}
if (result.failed.length > 0) {
console.warn(`Infrastructure failures: ${result.failed.map((f) => f.uuid).join(", ")}`);
}Returns the events available for a workflow instance in its current state.
async getAvailableEvents(input: GetAvailableEventsInput): Promise<AvailableWorkflowEvent[]>Parameters:
| Property | Type | Required | Description |
|---|---|---|---|
workflowInstanceUuid |
string |
Yes | The instance to query |
Returns:
interface AvailableWorkflowEvent {
eventName: string;
targetState?: string;
errorState?: string;
hasCommands: boolean;
hasTimeout: boolean;
metadata?: Record<string, unknown>;
}Example:
const events = await runtime.getAvailableEvents({
workflowInstanceUuid: instance.uuid,
});Returns a workflow instance by UUID, or null if not found.
async getInstance(uuid: string): Promise<WorkflowInstance | null>Example:
const instance = await runtime.getInstance("a1b2c3d4-...");
if (instance) {
console.log(instance.currentState); // "exportable"
console.log(instance.context); // { paymentStatus: "paid" }
}Returns the transition history for a workflow instance.
async getHistory(
workflowInstanceUuid: string,
options?: { limit?: number; offset?: number },
): Promise<WorkflowHistoryRecord[]>Parameters:
| Property | Type | Required | Description |
|---|---|---|---|
workflowInstanceUuid |
string |
Yes | The instance to query |
options.limit |
number |
No | Maximum number of records to return |
options.offset |
number |
No | Number of records to skip (pagination) |
Example:
const history = await runtime.getHistory(instance.uuid, { limit: 50 });
for (const record of history) {
console.log(`${record.fromState} → ${record.toState} via ${record.eventName} at ${record.createdAt}`);
}Each record's createdAt is when the store recorded that transition (populated on read; adapters that predate this field return undefined). Caveat: every history row written inside the same database transaction -- an event plus its entire onEnter chain -- shares an identical createdAt, so it must not be used to reconstruct the order of steps within a single multi-hop transition, only roughly when the transition happened.
The array returned by getHistory() (and findByInstanceUuid() underneath it) is itself ordered created_at DESC, uuid DESC, so this caveat is also about the order of the returned array, not just the createdAt field on each record. With the @duraflows/pg default uuidStrategy (gen_random_uuid), a multi-hop transition's rows come back in an arbitrary (but stable) order; pass uuidStrategy: "uuidv7" to generateMigrationSql() (PostgreSQL 18+ only) to make them come back in the order they actually happened. See Persistence: Ordering within a multi-hop transition for the full explanation and a verified empirical example.
Returns a WorkflowHandle -- a thin proxy that binds the instance UUID and delegates all operations to the runtime. The handle caches nothing; every method is a fresh call.
getHandle(uuid: string): WorkflowHandleThis method is synchronous -- it does not hit the database. It simply creates a WorkflowHandle that holds the UUID and a reference to the runtime.
Example:
const handle = runtime.getHandle(instance.uuid);
// Now use the handle instead of passing the UUID everywhereSee WorkflowHandle below for the full API.
A lightweight proxy that binds a workflow instance UUID and delegates all operations to the runtime. Inspired by Temporal's workflow handle pattern.
The handle is the recommended way to interact with an existing workflow instance. It eliminates UUID repetition and provides a discoverable API.
import { WorkflowHandle } from "@duraflows/core";Key properties:
- No cached state -- every method call hits the persistence layer
- Synchronous creation --
getHandle()does not query the database - Safe to pass around -- the handle is just a UUID + a runtime reference
| Method | Returns | Description |
|---|---|---|
getInstance() |
Promise<WorkflowInstance | null> |
Get the current instance data |
triggerEvent(eventName, options?) |
Promise<WorkflowExecutionResult> |
Trigger an event on the instance |
getAvailableEvents() |
Promise<AvailableWorkflowEvent[]> |
Get events available in current state |
getHistory(options?) |
Promise<WorkflowHistoryRecord[]> |
Get transition history |
triggerEvent options:
| Property | Type | Required | Description |
|---|---|---|---|
subject |
unknown |
No | Domain entity passed to commands |
triggerMetadata |
Record<string, unknown> |
No | Metadata about who/what triggered it |
// Create an instance, then get a handle
const instance = await runtime.createInstance({
workflowName: "order",
metadata: { orderId: "ORD-123" },
});
const handle = runtime.getHandle(instance.uuid);
// Read current state
const current = await handle.getInstance();
console.log(current?.currentState); // "new"
// Check what events are available
const events = await handle.getAvailableEvents();
console.log(events.map((e) => e.eventName)); // ["PaymentReceived", "Cancel"]
// Trigger an event
const result = await handle.triggerEvent("PaymentReceived", {
subject: orderEntity,
triggerMetadata: { source: "webhook" },
});
console.log(result.toState); // "exportable"
// View history
const history = await handle.getHistory({ limit: 10 });Validates a workflow definition for structural correctness.
import { WorkflowValidator } from "@duraflows/core";
const validator = new WorkflowValidator();
const result = validator.validate(definition);
if (!result.valid) {
for (const error of result.errors) {
console.error(`${error.path}: ${error.message}`);
}
}validate(definition: WorkflowDefinition, options?: WorkflowValidationOptions): ValidationResultWorkflowValidationOptions:
| Property | Type | Description |
|---|---|---|
knownCommandNames |
Set<string> |
If provided, validates that all command name references in events exist in this set |
Returns:
interface ValidationResult {
valid: boolean;
errors: ValidationError[];
}
interface ValidationError {
path: string; // e.g. "states.new.events.Export.targetState"
message: string; // e.g. 'Target state "unknown" does not exist in states'
}Compiles a WorkflowDefinition into a finita Process with CallbackCondition guards.
import { WorkflowCompiler } from "@duraflows/core";
const compiler = new WorkflowCompiler();
const compiled = compiler.compile(definition);
// compiled.process is a finita ProcessInterface
// compiled.definition is the original definitioncompile(definition: WorkflowDefinition): CompiledWorkflowCompilation strategy:
- Creates a finita
Statefor each workflow state - For each event with a
targetState: creates a success transition guarded bycontext.get("workflow:eventOutcome") === "success" - For each event with an
errorState: creates a failure transition guarded bycontext.get("workflow:eventOutcome") === "failure" - Caches compiled processes by definition name. Uses a JSON hash of the definition to detect changes — if the same name is recompiled with a different definition, the cache is invalidated and a fresh compilation is performed
Returns:
interface CompiledWorkflow {
definition: WorkflowDefinition;
process: ProcessInterface; // finita Process
}Executes commands sequentially with a fail-fast policy.
import { CommandExecutor } from "@duraflows/core";
const executor = new CommandExecutor(commandRegistry);
const result = await executor.execute(commands, subject, context);new CommandExecutor(commandRegistry: WorkflowCommandRegistry)async execute(
commands: WorkflowCommandRef[],
subject: unknown,
context: WorkflowExecutionContext,
): Promise<CommandExecutionResult>Behavior:
- If
commandsis empty, returns{ outcome: "success", commandResults: [] } - For each command in order:
- Resolves the handler from
commandRegistry.get(name) - Calls
command.execute(subject, context) - If the command throws and
command.bestEffort === true, catches the exception and records{ ok: false, code: "BEST_EFFORT_THROWN", ... }— chain continues - If
result.ok === falseandcommand.bestEffort === true, records the result and continues — chain is not stopped - If
result.ok === falseand the command is not best-effort, stops immediately (remaining commands are skipped) - If the command throws and is not best-effort, the exception propagates (not caught)
- Resolves the handler from
Returns:
interface CommandExecutionResult {
outcome: "success" | "failure";
commandResults: CommandResult[];
}Processes onEnter auto-transition chains. Given a starting state, it checks for onEnter definitions and follows the chain until reaching a state without onEnter (or hitting the depth limit).
import { OnEnterExecutor } from "@duraflows/core";
const executor = new OnEnterExecutor(commandExecutor);
const result = await executor.executeChain(definition, startingState, instanceUuid, subject, context, maxDepth);new OnEnterExecutor(commandExecutor: CommandExecutor)async executeChain(
definition: WorkflowDefinition,
startingState: string,
instanceUuid: string,
subject: unknown,
context: WorkflowExecutionContext,
maxDepth: number,
): Promise<OnEnterChainResult>Behavior:
- Checks if the current state has an
onEnterdefinition -- if not, returns immediately - Executes commands via
CommandExecutor(fail-fast, same pattern as events) - On success +
targetState: records hop, transitions. On failure +errorState: records hop, transitions. On failure + noerrorState: throwsCommandFailureError. On success + notargetState: records hop (commands ran as side effects), stops. - Increments depth counter, throws
OnEnterDepthExceededErrorif exceeded - Repeats from step 1 with the new state
The context object is shared by reference -- command mutations accumulate across hops.
Returns:
interface OnEnterChainResult {
finalState: string;
outcome: "success" | "failure"; // "failure" if ANY hop routed to errorState, else "success"
hops: OnEnterHopResult[];
}
interface OnEnterHopResult {
fromState: string;
toState: string;
transitionUuid: string;
outcome: "success" | "failure";
commandResults: CommandResult[];
}OnEnterChainResult.outcome is "failure" if any hop routed to errorState, otherwise "success". Prefer inspecting outcome directly rather than examining the last hop or last command result.
Orchestrates the full event lifecycle: validation, command execution, and finita state transition.
import { EventExecutor } from "@duraflows/core";
const eventExecutor = new EventExecutor(commandExecutor);
const result = await eventExecutor.execute(compiled, currentState, eventName, instanceUuid, subject, context);new EventExecutor(commandExecutor: CommandExecutor)async execute(
compiledWorkflow: CompiledWorkflow,
currentState: string,
eventName: string,
instanceUuid: string,
subject: unknown,
context: WorkflowExecutionContext,
): Promise<EventExecutionResult>Behavior:
- Validates the event exists on the current state
- Executes commands via
CommandExecutor - If failure and no
errorState, throwsCommandFailureError - Sets
workflow:eventOutcomeon finita context - Creates a finita
Statemachinefrom the compiled process at the current state - Calls
statemachine.triggerEvent() - Returns the resulting state
Returns:
interface EventExecutionResult {
outcome: "success" | "failure";
fromState: string;
toState: string;
commandResults: CommandResult[];
}Computes timeout deadlines and finds timeout event names.
import { TimeoutResolver } from "@duraflows/core";
const resolver = new TimeoutResolver();computeDeadline(definition: WorkflowDefinition, stateName: string, now: Date): Date | nullReturns the deadline Date for the timeout event on the given state, or null if no timeout event exists.
Duration calculation: Sums all defined fields:
totalMs = (afterMinutes * 60000) + (afterHours * 3600000) + (afterDays * 86400000)
deadline = new Date(now.getTime() + totalMs)
getTimeoutEventName(definition: WorkflowDefinition, stateName: string): string | nullReturns the name of the event that has a timeout definition on the given state, or null.
Generates a Mermaid flowchart diagram from a workflow definition. The output visualizes states, events, error paths, timeouts, onEnter auto-transitions, and optionally command names.
import { toMermaidDiagram } from "@duraflows/core";function toMermaidDiagram(definition: WorkflowDefinition, options?: MermaidDiagramOptions): string;| Option | Type | Default | Description |
|---|---|---|---|
showCommands |
boolean |
false |
Show command names on event nodes |
direction |
"TB" | "LR" |
"TB" |
Diagram direction: top-bottom or left-right |
- States are rendered as rectangles with bold text and a light grey background
- Events are rendered as stadium (rounded) nodes between the source state and target state
- Success paths (event → targetState) use green arrows
- Error paths (event → errorState) use red dashed arrows
- Timeouts show a hourglass emoji with the duration (e.g.,
⧖14d) - onEnter auto-transitions are rendered as
🗲nodes - Terminal states (no outbound events or onEnter targets) connect to an end node
// Default — clean overview
const diagram = toMermaidDiagram(orderWorkflow);
// Show command names
const detailed = toMermaidDiagram(orderWorkflow, { showCommands: true });
// Left-to-right layout
const horizontal = toMermaidDiagram(orderWorkflow, { direction: "LR" });The returned string is valid Mermaid flowchart syntax. Paste it into any Mermaid-compatible renderer (GitHub markdown, mermaid.live, Docusaurus, etc.).
Command handlers implement this interface:
interface WorkflowCommand<TSubject = unknown> {
readonly bestEffort?: boolean;
execute(subject: TSubject, context: WorkflowExecutionContext): Promise<CommandResult> | CommandResult;
}When bestEffort is true the command is treated as a fire-and-forget side effect. Failure semantics differ from mandatory commands:
- If the command returns
{ ok: false, ... }, the result is recorded but the chain continues as if the command had succeeded. - If the command throws, the runtime catches the exception and converts it to a
CommandResult:The chain continues. The{ ok: false, code: "BEST_EFFORT_THROWN", message, error: { name, message, stack? } }
errorfield is a serializable shape (not the raw thrown value) — safe to persist viaJSON.stringify. - A best-effort
ok: falseresult does not taintoutcome—outcomeremains"success"unless a mandatory command fails.
Non-best-effort commands retain the current behavior: ok: false stops the chain (or routes to errorState); throws propagate.
Example:
class EmitMetricsCommand implements WorkflowCommand {
readonly bestEffort = true;
async execute(subject: unknown, ctx: WorkflowExecutionContext): Promise<CommandResult> {
try {
await metrics.increment("transition", { to: ctx.toState });
return { ok: true };
} catch (error) {
return { ok: false, code: "METRICS_DOWN", error };
}
}
}interface CommandResult {
ok: boolean;
code?: string;
message?: string;
metadata?: Record<string, unknown>;
error?: unknown;
}| Property | Type | Description |
|---|---|---|
ok |
boolean |
true if the command succeeded, false if it failed |
code |
string |
Machine-readable result code (e.g., "PAYMENT_CHARGED", "INSUFFICIENT_FUNDS") |
message |
string |
Human-readable description |
metadata |
Record<string, unknown> |
Additional command-specific data |
error |
unknown |
Error details (for failures) |
Passed to every command during execution. Provides access to the workflow's mutable context and immutable metadata.
interface WorkflowExecutionContext {
triggerMetadata: Readonly<Record<string, unknown>>;
now: Date;
context: Record<string, unknown>;
metadata: Readonly<Record<string, unknown>>;
readonly commandMetadata: Readonly<Record<string, unknown>>;
readonly fromState: string | null;
readonly toState: string;
readonly transitionUuid: string;
}| Property | Type | Description |
|---|---|---|
triggerMetadata |
Readonly<Record<string, unknown>> |
Optional metadata about who/what triggered the event (frozen object) |
now |
Date |
Current timestamp from the injected clock |
context |
Record<string, unknown> |
Mutable. The workflow's working memory. Commands can read and write. Changes are persisted after the transition. |
metadata |
Readonly<Record<string, unknown>> |
Read-only. The workflow's immutable identity labels set at creation. Attempting to write will be ignored (frozen object). |
commandMetadata |
Readonly<Record<string, unknown>> |
Read-only. A deep-cloned, deep-frozen copy of the metadata field from the WorkflowCommandRef that invoked this command. {} when the ref defines no metadata. Each command in a chain sees its own metadata, not a prior command's. |
fromState |
string | null |
The state the workflow is leaving. null when entering the initial state on create. |
toState |
string |
The state being entered for this command's execution. May differ from the eventual final state in an onEnter chain. |
transitionUuid |
string |
UUID identifying this state entry. All commands running on entry to a given state (event commands + onEnter commands for that hop) share the same UUID. A fresh UUID is generated when the chain transitions to a new state. The matching observer StateEnterEvent carries the same UUID. |
See Context and Metadata for a full explanation of the difference.
class ChargePaymentCommand implements WorkflowCommand<Order> {
constructor(private readonly paymentGateway: PaymentGateway) {}
async execute(subject: Order, ctx: WorkflowExecutionContext): Promise<CommandResult> {
// Read immutable metadata
const customerId = ctx.metadata.customerId as string;
try {
const charge = await this.paymentGateway.charge({
amount: subject.total,
currency: subject.currency,
customerId,
});
// Write to mutable context — persisted after transition
ctx.context.chargeId = charge.id;
ctx.context.chargedAt = ctx.now.toISOString();
return {
ok: true,
code: "CHARGED",
metadata: { chargeId: charge.id },
};
} catch (err) {
// Track retry count in context
const retryCount = (ctx.context.paymentRetries as number) ?? 0;
ctx.context.paymentRetries = retryCount + 1;
return {
ok: false,
code: "PAYMENT_FAILED",
message: err instanceof Error ? err.message : String(err),
error: err,
};
}
}
}The shared interface exposes read-only access. Registration is on the concrete class only.
interface WorkflowDefinitionRegistry {
get(workflowName: string): WorkflowDefinition;
has(workflowName: string): boolean;
getAll(): WorkflowDefinition[];
}InMemoryDefinitionRegistry is the built-in implementation. It accepts an optional WorkflowValidator and WorkflowCompiler as constructor dependencies. When provided, register() validates and pre-compiles the definition eagerly -- so invalid definitions fail at startup, not at runtime. You can also pass validationOptions (e.g., knownCommandNames) to enable command name cross-validation.
import { InMemoryDefinitionRegistry, WorkflowValidator, WorkflowCompiler } from "@duraflows/core";
const registry = new InMemoryDefinitionRegistry({
validator: new WorkflowValidator(),
compiler: new WorkflowCompiler(),
// Optional: cross-validate command references against known names
// validationOptions: { knownCommandNames: new Set(["chargePayment", "sendEmail"]) },
});
// register() is on the concrete class, not the interface
registry.register(orderWorkflow); // validates + compiles immediately
registry.register(ticketWorkflow);
const def = registry.get("order"); // returns the definition
registry.has("order"); // true
registry.getAll(); // [orderWorkflow, ticketWorkflow]Throws WorkflowDefinitionError on duplicate registration, validation failure, or unknown lookup.
The shared interface exposes read-only access. Registration is on the concrete class only.
interface WorkflowCommandRegistry {
get(name: string): WorkflowCommand;
has(name: string): boolean;
}InMemoryCommandRegistry is the built-in implementation. Throws WorkflowError on duplicate registration or unknown lookup.
import { InMemoryCommandRegistry } from "@duraflows/core";
const registry = new InMemoryCommandRegistry();
// register() is on the concrete class, not the interface
registry.register("chargePayment", new ChargePaymentCommand(gateway));
registry.register("sendEmail", new SendEmailCommand(mailer));
const cmd = registry.get("chargePayment"); // returns the command instanceinterface WorkflowClock {
now(): Date;
}The clock is used for all timestamps (creation, transitions, timeout deadlines). Inject a custom clock for deterministic testing:
let currentTime = new Date("2025-01-01T00:00:00Z");
const testClock: WorkflowClock = {
now: () => currentTime,
};
const runtime = new WorkflowRuntime({
// ...
clock: testClock,
});
// Advance time for testing
currentTime = new Date("2025-01-15T00:00:00Z");Observers receive a notification every time the runtime enters a new state. They are intended for cross-cutting concerns — audit logging, metrics, cache invalidation — that must not affect runtime correctness.
Both types are exported from @duraflows/core.
interface WorkflowObserver {
readonly name: string;
onEnter?(event: StateEnterEvent): void | Promise<void>;
}
interface StateEnterEvent {
readonly workflowName: string;
readonly instanceUuid: string;
readonly state: string;
readonly fromState: string | null;
readonly toState: string;
readonly transitionUuid: string;
readonly triggerEvent: string | null;
readonly context: Readonly<Record<string, unknown>>;
readonly metadata: Readonly<Record<string, unknown>>;
readonly triggerMetadata: Readonly<Record<string, unknown>>;
readonly occurredAt: Date;
}Pass observers at construction time via WorkflowRuntimeOptions.observers, or register them dynamically with runtime.addObserver():
const auditObserver: WorkflowObserver = {
name: "audit-trail",
onEnter: async (event) => {
await auditLog.record({
instance: event.instanceUuid,
state: event.state,
at: event.occurredAt,
transitionUuid: event.transitionUuid,
});
},
};
const runtime = new WorkflowRuntime({
// ... other config ...
observers: [auditObserver],
});
// Or register dynamically:
runtime.addObserver(auditObserver);- Observers fire post-commit — only after the state-entering transaction has committed successfully.
- Observers fire sequentially in registration order.
- Each state entry fires observers at-most-once. If the process crashes between commit and the observer call, the event is skipped (no outbox pattern; at-least-once delivery is deferred to a future version).
- If an observer's
onEnterthrows, the error is caught and routed toonObserverError(default:console.warn). If the handler itself throws, the runtime falls back to the default handler and continues with the remaining observers — a throwing handler never rejects a committed operation. Observer errors do not cause rollback or affect runtime correctness.
context, metadata, and triggerMetadata on the event are deep-cloned via structuredClone and deep-frozen at event time. Consumers may hold references to these objects indefinitely — mutations to the live instance after the event fires do not affect the snapshot.
transitionUuid on the event matches the transitionUuid on the WorkflowExecutionContext seen by commands that ran during that state entry, making it straightforward to correlate command results with observer events.