@bernierllc/state-machine
v1.2.0
Published
Workflow finite state machine for content lifecycle with transition validation and event emission
Readme
@bernierllc/state-machine
Workflow finite state machine for content lifecycle with transition validation and event emission.
Installation
npm install @bernierllc/state-machineFeatures
- Finite State Machine (FSM) - Define states and allowed transitions
- Transition Guards - Validate transitions with custom guard functions
- Transition Callbacks - Execute code before/after state changes
- Event Emission - Subscribe to state change events
- State History - Track complete history of state transitions
- TypeScript Support - Full type safety with strict mode
- Async Support - Guards and callbacks support async operations
- Zero Dependencies - Uses only @bernierllc/event-emitter and @bernierllc/logger
Usage
Basic State Machine
import { StateMachine } from '@bernierllc/state-machine';
// Create a simple workflow
const workflow = new StateMachine({
initial: 'draft',
states: ['draft', 'review', 'approved', 'published'],
transitions: {
draft: ['review'],
review: ['draft', 'approved'],
approved: ['published', 'review'],
published: [],
},
});
// Check current state
console.log(workflow.currentState); // 'draft'
// Execute transitions
const result = await workflow.transition('review');
console.log(result.success); // true
console.log(workflow.currentState); // 'review'
// Check allowed transitions
console.log(workflow.getAllowedTransitions()); // ['draft', 'approved']Using Factory Functions
import { createWorkflowStateMachine } from '@bernierllc/state-machine';
// Create pre-configured content workflow
const workflow = createWorkflowStateMachine();
// States: draft -> review -> approved -> publishedTransition Guards
Guards validate whether a transition should be allowed:
const sm = new StateMachine({
initial: 'draft',
states: ['draft', 'review', 'published'],
transitions: {
draft: ['review'],
review: ['published'],
published: [],
},
});
// Add guard to check if user has permission
sm.addTransitionConfig('review', 'published', {
guards: [
(context) => {
// Only allow if data contains approval
return context.data?.approved === true;
},
],
});
// This will fail (guard returns false)
await sm.transition('review');
await sm.transition('published'); // Blocked by guard
// This will succeed
await sm.transition('published', { approved: true }); // AllowedAsync Guards
Guards can be async for database checks, API calls, etc:
sm.addTransitionConfig('draft', 'review', {
guards: [
async (context) => {
// Check database for user permissions
const user = await database.getUser(context.data.userId);
return user.role === 'reviewer';
},
],
});Multiple Guards
All guards must pass for transition to be allowed:
sm.addTransitionConfig('review', 'published', {
guards: [
(ctx) => ctx.data.approved === true,
(ctx) => ctx.data.reviewer !== null,
(ctx) => ctx.data.checks.length === 0, // No outstanding checks
],
});Transition Callbacks
Execute code before and after transitions:
sm.addTransitionConfig('draft', 'review', {
before: [
(context) => {
console.log(`Starting review for ${context.data.documentId}`);
// Send notification to reviewer
notificationService.notify(context.data.reviewerId);
},
],
after: [
(context) => {
console.log(`Review started at ${context.timestamp}`);
// Log to analytics
analytics.track('review_started', {
document: context.data.documentId,
});
},
],
});Event Subscription
Subscribe to state machine events:
// Listen for any state change
sm.on('state:changed', (context) => {
console.log(`State changed: ${context.from} -> ${context.to}`);
console.log('Data:', context.data);
console.log('Timestamp:', context.timestamp);
});
// Listen for before transition
sm.on('transition:before', (context) => {
console.log(`About to transition: ${context.from} -> ${context.to}`);
});
// Listen for after transition
sm.on('transition:after', (context) => {
console.log(`Completed transition: ${context.from} -> ${context.to}`);
});
// Listen for failed transitions
sm.on('transition:failed', (context) => {
console.log(`Failed: ${context.from} -> ${context.to}`);
console.log('Error:', context.error);
});State History
Track complete history of state changes:
// Get full history
console.log(workflow.history);
// [
// { state: 'draft', timestamp: Date, data: undefined },
// { state: 'review', timestamp: Date, data: { reviewerId: 123 } },
// { state: 'published', timestamp: Date, data: { publishedBy: 456 } }
// ]
// Get state at specific index
const firstState = workflow.getStateAt(0);
console.log(firstState?.state); // 'draft'
// Limit history size
const sm = new StateMachine({
initial: 'draft',
states: ['draft', 'published'],
transitions: { draft: ['published'], published: [] },
maxHistoryLength: 100, // Keep only last 100 entries
});Check Transition Validity
Check if a transition is allowed without executing it:
const canTransition = await workflow.canTransition('published');
if (canTransition) {
await workflow.transition('published');
} else {
console.log('Transition not allowed');
}Reset State Machine
Reset to initial state and clear history:
workflow.reset();
console.log(workflow.currentState); // Back to 'draft'
console.log(workflow.history.length); // 1 (only initial state)API Reference
StateMachine Class
Constructor
new StateMachine<S extends string = string, T = any>(config: StateMachineConfig<S, T>)Properties
currentState: S- Current state (readonly)history: StateHistoryEntry[]- State history (readonly)
Methods
canTransition(to: S, data?: T): Promise<boolean>- Check if transition is allowedtransition(to: S, data?: T): Promise<TransitionResult>- Execute state transitiongetAllowedTransitions(): S[]- Get allowed transitions from current stateon(event: string, handler: Function): void- Subscribe to eventsreset(): void- Reset to initial stategetStateAt(index: number): StateHistoryEntry | undefined- Get state at indexaddTransitionConfig(from: S, to: S, config: TransitionConfig<T>): void- Add transition configuration
Types
StateMachineConfig
interface StateMachineConfig<S extends string = string, T = any> {
initial: S;
states: S[];
transitions: Record<S, S[]>;
transitionConfigs?: Record<string, TransitionConfig<T>>;
maxHistoryLength?: number;
}TransitionResult
interface TransitionResult {
success: boolean;
error?: string;
previousState?: string;
newState?: string;
}TransitionContext
interface TransitionContext<T = any> {
from: string;
to: string;
data?: T;
timestamp: Date;
}TransitionConfig
interface TransitionConfig<T = any> {
guards?: TransitionGuard<T>[];
before?: BeforeTransitionCallback<T>[];
after?: AfterTransitionCallback<T>[];
}Factory Functions
createStateMachine<S, T>(config: StateMachineConfig<S, T>): StateMachine<S, T>createWorkflowStateMachine(): StateMachine<'draft' | 'review' | 'approved' | 'published'>
Real-world Examples
Content Approval Workflow
import { StateMachine } from '@bernierllc/state-machine';
interface ApprovalData {
contentId: string;
reviewerId?: string;
approverId?: string;
comments?: string;
}
const approvalWorkflow = new StateMachine<
'draft' | 'review' | 'approved' | 'published',
ApprovalData
>({
initial: 'draft',
states: ['draft', 'review', 'approved', 'published'],
transitions: {
draft: ['review'],
review: ['draft', 'approved'],
approved: ['published', 'review'],
published: [],
},
});
// Require reviewer assignment
approvalWorkflow.addTransitionConfig('draft', 'review', {
guards: [
(ctx) => {
if (!ctx.data?.reviewerId) {
return false;
}
return true;
},
],
after: [
async (ctx) => {
// Notify reviewer
await emailService.send({
to: ctx.data.reviewerId,
subject: 'New content for review',
body: `Content ${ctx.data.contentId} needs your review`,
});
},
],
});
// Require approval before publishing
approvalWorkflow.addTransitionConfig('review', 'approved', {
guards: [
(ctx) => ctx.data?.approverId !== undefined,
],
});
// Usage
await approvalWorkflow.transition('review', {
contentId: 'doc-123',
reviewerId: 'user-456',
});Deployment Pipeline
import { StateMachine } from '@bernierllc/state-machine';
interface DeploymentData {
version: string;
tests?: { passed: boolean; coverage: number };
securityScan?: { passed: boolean };
}
const pipeline = new StateMachine<
'development' | 'testing' | 'staging' | 'production',
DeploymentData
>({
initial: 'development',
states: ['development', 'testing', 'staging', 'production'],
transitions: {
development: ['testing'],
testing: ['development', 'staging'],
staging: ['production', 'testing'],
production: [],
},
});
// Require tests to pass
pipeline.addTransitionConfig('testing', 'staging', {
guards: [
(ctx) => {
const tests = ctx.data?.tests;
return tests?.passed === true && tests.coverage >= 80;
},
],
});
// Require security scan
pipeline.addTransitionConfig('staging', 'production', {
guards: [
(ctx) => ctx.data?.securityScan?.passed === true,
],
before: [
async (ctx) => {
// Final backup before production
await backupService.create(ctx.data.version);
},
],
after: [
async (ctx) => {
// Notify team
await slackService.notify({
channel: '#deployments',
message: `Version ${ctx.data.version} deployed to production`,
});
},
],
});Integration Status
Logger Integration
Status: Integrated
Justification: This package uses @bernierllc/logger for structured logging of state transitions. Logs include state changes, transition attempts, guard evaluations, and errors to help with debugging and monitoring workflow execution.
Pattern: Direct integration - logger is a required dependency for this package.
NeverHub Integration
Status: Optional (recommended for workflow orchestration)
Justification: While this package can function without NeverHub, it's highly recommended to integrate @bernierllc/neverhub-adapter for workflow orchestration and event publishing. State machines often need to coordinate with other services, and NeverHub provides a service mesh for publishing state change events that other services can subscribe to. However, the package is designed to work without NeverHub for simpler use cases.
Pattern: Optional integration - package works without NeverHub, but NeverHub enhances workflow orchestration capabilities.
Example Integration:
import { NeverHubAdapter } from '@bernierllc/neverhub-adapter';
import { StateMachine } from '@bernierllc/state-machine';
const neverhub = new NeverHubAdapter({ service: 'content-workflow' });
const workflow = new StateMachine({ /* config */ });
// Publish state changes to NeverHub
workflow.on('state:changed', async (context) => {
await neverhub.publish('content.state.changed', {
contentId: context.data?.contentId,
from: context.from,
to: context.to,
timestamp: context.timestamp
});
});Docs-Suite Integration
Status: Ready
Format: TypeDoc-compatible JSDoc comments are included throughout the source code. All public APIs are documented with examples and type information.
License
Copyright (c) 2025 Bernier LLC. All rights reserved.
This file is licensed to the client under a limited-use license. The client may use and modify this code only within the scope of the project it was delivered for. Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
