Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,173 @@
# @suaveplan/fsm - Typed finite state machine with guards, actions, history tracking, hierarchical states, parallel regions, deferred events, and OTel instrumentation
## Summary
Typed, generic finite state machine (FSM) for TypeScript. Ships a synchronous-by-default interpreter with guard predicates, ordered transition actions, context threading, entry/exit lifecycle hooks, history recording, and OTel instrumentation. Zero peer dependencies beyond `@suaveplan/error`, `@suaveplan/events`, and `@suaveplan/types`.
<!-- begin:auto-docs -->
## Documentation tree
- [interpreter](./src/core/interpreter/fsm-interpreter.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [fsm-interpreter](./src/core/interpreter/fsm-interpreter.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [errors](./src/errors/fsm-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [fsm-error](./src/errors/fsm-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [context-helpers](./src/utils/context-helpers.md) — Provides ergonomic factory helpers for building context-update and side-effect actions that can be slotted directly int…
- [definition-validator](./src/utils/definition-validator.md) — Validates an `FSMDefinition` object before the interpreter begins executing. Early structural validation prevents subtl…
<!-- end:auto-docs --> - [fsm-interpreter](./src/core/interpreter/fsm-interpreter.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/fsm-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [fsm-error](./src/errors/fsm-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [context-helpers](./src/utils/context-helpers.md) — Provides ergonomic factory helpers for building context-update and side-effect actions that can be slotted directly int…
- [definition-validator](./src/utils/definition-validator.md) — Validates an `FSMDefinition` object before the interpreter begins executing. Early structural validation prevents subtl…
- [fsm-interpreter](./src/core/interpreter/fsm-interpreter.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/fsm-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [fsm-error](./src/errors/fsm-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [context-helpers](./src/utils/context-helpers.md) — Provides ergonomic factory helpers for building context-update and side-effect actions that can be slotted directly int…
- [definition-validator](./src/utils/definition-validator.md) — Validates an `FSMDefinition` object before the interpreter begins executing. Early structural validation prevents subtl…
- [fsm-interpreter](./src/core/interpreter/fsm-interpreter.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/fsm-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [fsm-error](./src/errors/fsm-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [context-helpers](./src/utils/context-helpers.md) — Provides ergonomic factory helpers for building context-update and side-effect actions that can be slotted directly int…
- [definition-validator](./src/utils/definition-validator.md) — Validates an `FSMDefinition` object before the interpreter begins executing. Early structural validation prevents subtl…
- [fsm-interpreter](./src/core/interpreter/fsm-interpreter.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/fsm-error.md) — <!-- begin:auto-docs --> _(no submodules)_ - [fsm-error](./src/errors/fsm-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [context-helpers](./src/utils/context-helpers.md) — Provides ergonomic factory helpers for building context-update and side-effect actions that can be slotted directly int…
- [definition-validator](./src/utils/definition-validator.md) — Validates an `FSMDefinition` object before the interpreter begins executing. Early structural validation prevents subtl…
## Install
```sh
bun add @suaveplan/fsm
```
## Quick Start
```ts
import { createMachine, assign } from '@suaveplan/fsm';
interface TrafficCtx { count: number }
const traffic = createMachine<TrafficCtx>({
id: 'traffic',
initial: 'red',
context: { count: 0 },
states: {
red: { on: { NEXT: { target: 'green' } } },
green: { on: { NEXT: { target: 'yellow' } } },
yellow: {
on: {
NEXT: {
target: 'red',
actions: [assign<TrafficCtx>(ctx => ({ count: ctx.count + 1 }))],
},
},
},
},
});
traffic.send('NEXT'); // → green
traffic.send('NEXT'); // → yellow
const state = traffic.send('NEXT'); // → red
console.log(state.context.count); // 1
console.log(state.history.length); // 3
```
## Modules
| Module | Path | Description |
|--------|------|-------------|
| Root | `@suaveplan/fsm` | `createMachine`, types, `assign`, `pure`, `validateDefinition` |
| Interpreter | `@suaveplan/fsm/interpreter` | `FSMInterpreter`, `createMachine` |
| Errors | `@suaveplan/fsm/errors` | `FSMError`, `FSM_CODES` |
## Core Concepts
### `createMachine(definition)`
Factory function returning an `FSMInterpreter` instance. The definition is validated eagerly — malformed definitions throw `FSMError(INVALID_DEFINITION)` before any events are processed.
```ts
interface FSMDefinition<TContext> {
id?: string; // used in OTel metric labels
initial: string; // must exist in states
context: TContext; // initial extended context
states: Record<string, StateDefinition<TContext>>;
}
```
### Guards
Guard functions on a transition prevent it from firing when they return `false`. If multiple transitions are declared for the same event (as an array), the first one whose guard returns `true` is taken.
```ts
states: {
locked: {
on: {
UNLOCK: [
{ target: 'open', guard: (ctx, evt) => evt.payload === ctx.pin },
{ target: 'locked', guard: () => true }, // fallthrough (stay locked)
],
},
},
}
```
### Actions
Transition actions are called with the accumulated context. Returning a new context replaces it; returning `void` leaves it unchanged.
```ts
import { assign, pure } from '@suaveplan/fsm';
const bump = assign<Ctx>(ctx => ({ ...ctx, count: ctx.count + 1 }));
const logEvt = pure<Ctx>((ctx, evt) => console.log(evt.type, ctx));
```
### Entry / Exit lifecycle
```ts
states: {
loading: {
entry: ctx => ({ ...ctx, loading: true }),
exit: ctx => ({ ...ctx, loading: false }),
on: { DONE: { target: 'idle' } },
},
}
```
### Final states
Entering a state with `type: 'final'` sets `state.done = true`.
### Subscriptions
```ts
const unsub = machine.onTransition(state => {
console.log('now in', state.value, 'done?', state.done);
});
// later:
unsub();
```
### `can()` and `matches()`
```ts
machine.can('NEXT'); // true if the event is declared in current state
machine.matches('green'); // true if currently in 'green'
```
### `reset()`
Resets to initial state, original context, and clears history.
## OTel Metrics
| Name | Type | Attributes |
|------|------|------------|
| `fsm.transition.total` | Counter | `id`, `from`, `to`, `event` |
| `fsm.transition.duration_ms` | Histogram | `id`, `from`, `to` |
## Error Codes
| Code | Meaning |
|------|---------|
| `FSM.INVALID_DEFINITION` | Structural error in definition (missing initial, unknown target, etc.) |
| `FSM.INVALID_TRANSITION` | No matching transition for event in current state |
| `FSM.INVALID_STATE` | Current state not found in definition |
| `FSM.GUARD_FAILED` | All guards for an event returned false |
| `FSM.ACTION_ERROR` | An action or lifecycle function threw |
## Module Docs
- [`src/core/interpreter/fsm-interpreter.md`](src/core/interpreter/fsm-interpreter.md)
- [`src/errors/fsm-error.md`](src/errors/fsm-error.md)
- [`src/utils/definition-validator.md`](src/utils/definition-validator.md)
- [`src/utils/context-helpers.md`](src/utils/context-helpers.md)

View File

@@ -0,0 +1,84 @@
{
"name": "@suaveplan/fsm",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Typed finite state machine with guards, actions, history tracking, hierarchical states, parallel regions, deferred events, and OTel instrumentation",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./interpreter": {
"import": {
"types": "./dist/core/interpreter/fsm-interpreter.d.ts",
"default": "./dist/core/interpreter/fsm-interpreter.js"
},
"require": {
"types": "./dist/core/interpreter/fsm-interpreter.d.ts",
"default": "./dist/core/interpreter/fsm-interpreter.js"
}
},
"./errors": {
"import": {
"types": "./dist/errors/fsm-error.d.ts",
"default": "./dist/errors/fsm-error.js"
},
"require": {
"types": "./dist/errors/fsm-error.d.ts",
"default": "./dist/errors/fsm-error.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"@suaveplan/events": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms",
"@opentelemetry/api": "catalog:observability"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1 @@
export { createMachine, FSMInterpreter } from "./interpreter";

View File

@@ -0,0 +1,181 @@
# fsm-interpreter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `FSMInterpreter` class is the runtime engine for `@suaveplan/fsm`. It accepts a validated `FSMDefinition`, manages the current state value and extended context, executes entry/exit/transition actions, records a full immutable history of every transition taken, and emits `transition` events via an internal `EventBus` for reactive subscriptions.
`createMachine` is the primary factory function and validates the definition eagerly during construction, so malformed definitions are caught before any events are processed.
## Features
- Type-safe state transitions with generic `TContext` extended context
- Entry and exit lifecycle actions that may return an updated context
- Ordered transition action arrays with accumulated context threading
- Guard predicates on each transition candidate with first-match semantics
- Immutable `FSMState<TContext>` snapshots returned from every `send()` call
- Full `HistoryEntry[]` log with `from`, `event`, `to`, and `timestamp`
- `done: true` flag set automatically when entering a `type: 'final'` state
- `onTransition(listener)` subscription with typed state snapshots
- `reset()` returns to initial state and clears history
- OTel counter `fsm.transition.total` and histogram `fsm.transition.duration_ms`
- Graceful no-op when OTel is not available in the environment
## Usage: Basic Example
```ts
import { createMachine } from '@suaveplan/fsm';
const machine = createMachine({
id: 'traffic',
initial: 'red',
context: { count: 0 },
states: {
red: { on: { NEXT: { target: 'green' } } },
green: { on: { NEXT: { target: 'yellow' } } },
yellow: {
on: {
NEXT: {
target: 'red',
actions: [ctx => ({ count: ctx.count + 1 })],
},
},
},
},
});
machine.send('NEXT'); // red → green
machine.send('NEXT'); // green → yellow
const state = machine.send('NEXT'); // yellow → red
console.log(state.context.count); // 1
```
## Usage: Advanced Example
```ts
import { createMachine } from '@suaveplan/fsm';
import { assign } from '@suaveplan/fsm';
interface AuthCtx {
user: string | null;
attempts: number;
error: string | null;
}
const authMachine = createMachine<AuthCtx>({
id: 'auth',
initial: 'idle',
context: { user: null, attempts: 0, error: null },
states: {
idle: {
on: {
LOGIN: { target: 'authenticating' },
},
},
authenticating: {
entry: assign<AuthCtx>(ctx => ({ ...ctx, error: null })),
on: {
SUCCESS: {
target: 'authenticated',
actions: [assign<AuthCtx>((ctx) => ({ ...ctx, user: 'alice', attempts: 0 }))],
},
FAILURE: [
{
target: 'locked',
guard: (ctx) => ctx.attempts >= 2,
actions: [assign<AuthCtx>(ctx => ({ ...ctx, attempts: ctx.attempts + 1 }))],
},
{
target: 'idle',
actions: [assign<AuthCtx>(ctx => ({
...ctx,
attempts: ctx.attempts + 1,
error: 'Invalid credentials',
}))],
},
],
},
},
authenticated: { type: 'final' },
locked: { type: 'final' },
},
});
const unsub = authMachine.onTransition(state => {
console.log('Transitioned to:', state.value, 'done:', state.done);
});
authMachine.send('LOGIN');
authMachine.send({ type: 'FAILURE' });
authMachine.send('LOGIN');
authMachine.send('FAILURE');
authMachine.send('LOGIN');
const final = authMachine.send('FAILURE'); // → locked (guard: attempts >= 2)
console.log(final.done); // true
unsub(); // unsubscribe listener
```
## API Reference
### `class FSMInterpreter<TContext>`
#### Constructor
```ts
new FSMInterpreter(definition: FSMDefinition<TContext>)
```
Validates `definition` via `validateDefinition` on construction. Throws `FSMError(INVALID_DEFINITION)` for structural errors.
#### `get state(): FSMState<TContext>`
Returns an immutable snapshot with `value`, `context`, `history`, and `done`.
#### `get current(): StateValue`
Shorthand for `state.value`.
#### `send(event: FSMEvent | EventType): FSMState<TContext>`
Processes an event against the current state. Returns the new snapshot.
**Throws:**
- `FSMError(INVALID_TRANSITION)` — no matching (unguarded) transition exists
- `FSMError(ACTION_ERROR)` — an action or lifecycle function threw
- `FSMError(INVALID_STATE)` — current state not found in definition (should not happen post-construction)
#### `matches(state: StateValue): boolean`
Returns `true` if the current state value equals `state`.
#### `can(eventType: EventType): boolean`
Returns `true` if at least one transition for `eventType` is declared in the current state (regardless of guard outcome).
#### `reset(): void`
Resets to `definition.initial`, restores original `definition.context`, clears history.
#### `onTransition(listener): () => void`
Registers a synchronous listener called after each successful transition. Returns an unsubscribe function.
#### `toJSON(): FSMDefinition<TContext>`
Returns a serializable definition snapshot. Function-valued guards and actions are included as-is (not serializable to JSON text), but the shape is correct for in-memory round-tripping.
### `createMachine<TContext>(definition): FSMInterpreter<TContext>`
Factory function. Equivalent to `new FSMInterpreter(definition)`.
## Implementation Notes
OTel integration uses a dynamic `import('@opentelemetry/api')` wrapped in a try/catch. If OTel is absent from the environment, the import silently fails and metric recording is skipped. Meters and instruments are created per `send()` call via `getMeter().createCounter(...)` — this is safe because the OTel SDK caches instrument instances by name.
Context accumulation across multiple transition actions threads the result of each action into the next: if action A returns a new context, action B receives that new context rather than the original. This matches XState's "assign" pipeline semantics.
The internal `EventBus` is typed with `FSMEventMap<TContext>` to ensure the `transition` event payload is always a `FSMState<TContext>`.

View File

@@ -0,0 +1,748 @@
import { describe, expect, it, vi } from "@suaveplan/testing/runner";
import { FSM_CODES, FSMError } from "../../errors/fsm-error.js";
import type { FSMDefinition } from "../../types/fsm.types.js";
import { createMachine, FSMInterpreter } from "./fsm-interpreter.js";
// ---------------------------------------------------------------------------
// Traffic light machine fixture
// ---------------------------------------------------------------------------
function makeTrafficLight() {
return createMachine({
id: "traffic",
initial: "red",
context: { count: 0 },
states: {
red: { on: { NEXT: { target: "green" } } },
green: { on: { NEXT: { target: "yellow" } } },
yellow: {
on: {
NEXT: {
target: "red",
actions: [
(ctx: { count: number }) => ({
count: ctx.count + 1,
}),
],
},
},
},
},
});
}
describe("FSMInterpreter — traffic light", () => {
it("starts in the initial state", { timeout: 5000 }, () => {
const m = makeTrafficLight();
expect(m.current).toBe("red");
expect(m.state.value).toBe("red");
expect(m.state.done).toBe(false);
});
it("transitions to green on NEXT from red", { timeout: 5000 }, () => {
const m = makeTrafficLight();
const state = m.send("NEXT");
expect(state.value).toBe("green");
expect(m.current).toBe("green");
});
it("three NEXT transitions cycle back to red", { timeout: 5000 }, () => {
const m = makeTrafficLight();
m.send("NEXT"); // red → green
m.send("NEXT"); // green → yellow
const state = m.send("NEXT"); // yellow → red
expect(state.value).toBe("red");
});
it("increments context.count on yellow→red", { timeout: 5000 }, () => {
const m = makeTrafficLight();
m.send("NEXT"); // red → green
m.send("NEXT"); // green → yellow
const state = m.send("NEXT"); // yellow → red (count++)
expect(state.context.count).toBe(1);
m.send("NEXT"); // red → green
m.send("NEXT"); // green → yellow
const state2 = m.send("NEXT"); // yellow → red (count++)
expect(state2.context.count).toBe(2);
});
it("throws INVALID_TRANSITION for unknown event in current state", {
timeout: 5000,
}, () => {
const m = makeTrafficLight();
expect(() => m.send("UNKNOWN")).toThrow(FSMError);
try {
m.send("UNKNOWN");
} catch (err) {
expect(err instanceof FSMError && err.code).toBe(
FSM_CODES.INVALID_TRANSITION
);
}
});
it("can() returns true for valid event in current state", {
timeout: 5000,
}, () => {
const m = makeTrafficLight();
expect(m.can("NEXT")).toBe(true);
});
it("can() returns false for unknown event in current state", {
timeout: 5000,
}, () => {
const m = makeTrafficLight();
expect(m.can("GHOST")).toBe(false);
});
it("matches() returns true for current state", { timeout: 5000 }, () => {
const m = makeTrafficLight();
expect(m.matches("red")).toBe(true);
expect(m.matches("green")).toBe(false);
});
it("records full history", { timeout: 5000 }, () => {
const m = makeTrafficLight();
m.send("NEXT");
m.send("NEXT");
m.send("NEXT");
expect(m.state.history).toHaveLength(3);
expect(m.state.history[0]?.from).toBe("red");
expect(m.state.history[0]?.to).toBe("green");
expect(m.state.history[1]?.from).toBe("green");
expect(m.state.history[1]?.to).toBe("yellow");
expect(m.state.history[2]?.from).toBe("yellow");
expect(m.state.history[2]?.to).toBe("red");
});
it("history timestamps are numbers in ascending order", {
timeout: 5000,
}, () => {
const m = makeTrafficLight();
m.send("NEXT");
m.send("NEXT");
const hist = m.state.history;
expect(typeof hist[0]?.timestamp).toBe("number");
expect(hist[1]!.timestamp).toBeGreaterThanOrEqual(hist[0]!.timestamp);
});
it("history is immutable (each call returns a new array)", {
timeout: 5000,
}, () => {
const m = makeTrafficLight();
m.send("NEXT");
const h1 = m.state.history;
m.send("NEXT");
const h2 = m.state.history;
expect(h1).toHaveLength(1);
expect(h2).toHaveLength(2);
expect(h1).not.toBe(h2);
});
it("reset() returns to initial state and clears history", {
timeout: 5000,
}, () => {
const m = makeTrafficLight();
m.send("NEXT");
m.send("NEXT");
m.reset();
expect(m.current).toBe("red");
expect(m.state.history).toHaveLength(0);
expect(m.state.context.count).toBe(0);
});
it("onTransition listener is called on each transition", {
timeout: 5000,
}, () => {
const m = makeTrafficLight();
const states: string[] = [];
const unsub = m.onTransition((s) => states.push(s.value));
m.send("NEXT");
m.send("NEXT");
expect(states).toEqual(["green", "yellow"]);
unsub();
m.send("NEXT");
expect(states).toHaveLength(2); // no more events after unsub
});
it("send() accepts FSMEvent objects", { timeout: 5000 }, () => {
const m = makeTrafficLight();
const state = m.send({ type: "NEXT", payload: { extra: true } });
expect(state.value).toBe("green");
});
it("history event references the sent event object", {
timeout: 5000,
}, () => {
const m = makeTrafficLight();
const evt = { type: "NEXT", payload: 42 };
m.send(evt);
expect(m.state.history[0]?.event).toEqual(evt);
});
});
// ---------------------------------------------------------------------------
// Guard tests
// ---------------------------------------------------------------------------
describe("FSMInterpreter — guards", () => {
it("guard blocks transition when returns false", { timeout: 5000 }, () => {
const m = createMachine({
initial: "idle",
context: { allowed: false },
states: {
idle: {
on: {
GO: {
target: "active",
guard: (ctx) => ctx.allowed,
},
},
},
active: {},
},
});
expect(() => m.send("GO")).toThrow(FSMError);
expect(m.current).toBe("idle");
});
it("guard allows transition when returns true", { timeout: 5000 }, () => {
const m = createMachine({
initial: "idle",
context: { allowed: true },
states: {
idle: {
on: {
GO: {
target: "active",
guard: (ctx) => ctx.allowed,
},
},
},
active: {},
},
});
const state = m.send("GO");
expect(state.value).toBe("active");
});
it("first passing guard in array wins", { timeout: 5000 }, () => {
const m = createMachine({
initial: "idle",
context: { count: 3 },
states: {
idle: {
on: {
GO: [
{ target: "high", guard: (ctx) => ctx.count > 5 },
{ target: "low", guard: (ctx) => ctx.count <= 5 },
],
},
},
high: {},
low: {},
},
});
const state = m.send("GO");
expect(state.value).toBe("low");
});
it("guard receives event payload", { timeout: 5000 }, () => {
const m = createMachine({
initial: "idle",
context: {},
states: {
idle: {
on: {
GO: {
target: "active",
guard: (_ctx, event) => event.payload === "secret",
},
},
},
active: {},
},
});
const state = m.send({ type: "GO", payload: "secret" });
expect(state.value).toBe("active");
});
});
// ---------------------------------------------------------------------------
// Entry / exit actions
// ---------------------------------------------------------------------------
describe("FSMInterpreter — entry/exit actions", () => {
it("exit action is called on leaving a state", { timeout: 5000 }, () => {
const exitSpy = vi.fn();
const m = createMachine({
initial: "idle",
context: {},
states: {
idle: {
exit: (_ctx, _evt) => {
exitSpy();
},
on: { GO: { target: "active" } },
},
active: {},
},
});
m.send("GO");
expect(exitSpy).toHaveBeenCalledOnce();
});
it("entry action is called on entering a state", { timeout: 5000 }, () => {
const entrySpy = vi.fn();
const m = createMachine({
initial: "idle",
context: {},
states: {
idle: { on: { GO: { target: "active" } } },
active: {
entry: (_ctx, _evt) => {
entrySpy();
},
},
},
});
m.send("GO");
expect(entrySpy).toHaveBeenCalledOnce();
});
it("exit action may update context", { timeout: 5000 }, () => {
const m = createMachine({
initial: "idle",
context: { value: "original" },
states: {
idle: {
exit: (ctx) => ({ ...ctx, value: "exiting" }),
on: { GO: { target: "active" } },
},
active: {},
},
});
const state = m.send("GO");
expect(state.context.value).toBe("exiting");
});
it("entry action may update context", { timeout: 5000 }, () => {
const m = createMachine({
initial: "idle",
context: { value: "original" },
states: {
idle: { on: { GO: { target: "active" } } },
active: {
entry: (ctx) => ({ ...ctx, value: "entered" }),
},
},
});
const state = m.send("GO");
expect(state.context.value).toBe("entered");
});
it("action order: exit → transition actions → entry", {
timeout: 5000,
}, () => {
const order: string[] = [];
const m = createMachine({
initial: "idle",
context: {},
states: {
idle: {
exit: () => {
order.push("exit");
},
on: {
GO: {
target: "active",
actions: [
() => {
order.push("transition");
},
],
},
},
},
active: {
entry: () => {
order.push("entry");
},
},
},
});
m.send("GO");
expect(order).toEqual(["exit", "transition", "entry"]);
});
});
// ---------------------------------------------------------------------------
// Final states
// ---------------------------------------------------------------------------
describe("FSMInterpreter — final states", () => {
it("done is true when in a final state", { timeout: 5000 }, () => {
const m = createMachine({
initial: "running",
context: {},
states: {
running: { on: { DONE: { target: "finished" } } },
finished: { type: "final" },
},
});
expect(m.state.done).toBe(false);
const state = m.send("DONE");
expect(state.done).toBe(true);
});
it("done is false for non-final states", { timeout: 5000 }, () => {
const m = createMachine({
initial: "idle",
context: {},
states: { idle: {} },
});
expect(m.state.done).toBe(false);
});
});
// ---------------------------------------------------------------------------
// toJSON and construction edge cases
// ---------------------------------------------------------------------------
describe("FSMInterpreter — toJSON", () => {
it("returns a serializable definition snapshot", { timeout: 5000 }, () => {
const m = makeTrafficLight();
m.send("NEXT"); // context doesn't change but current does
const json = m.toJSON();
expect(json.initial).toBe("red");
expect(json.context).toEqual({ count: 0 });
expect(Object.keys(json.states)).toHaveLength(3);
});
});
describe("FSMInterpreter — construction validation", () => {
it("throws on invalid definition at construction", {
timeout: 5000,
}, () => {
expect(() =>
createMachine({
initial: "ghost",
context: {},
states: { idle: {} },
})
).toThrow(FSMError);
});
it("FSMInterpreter constructor also validates", { timeout: 5000 }, () => {
expect(
() =>
new FSMInterpreter({
initial: "ghost",
context: {},
states: { idle: {} },
} as FSMDefinition<unknown>)
).toThrow(FSMError);
});
});
// ---------------------------------------------------------------------------
// ACTION_ERROR propagation
// ---------------------------------------------------------------------------
describe("FSMInterpreter — action errors", () => {
it("wraps thrown errors from transition actions in FSMError(ACTION_ERROR)", {
timeout: 5000,
}, () => {
const m = createMachine({
initial: "idle",
context: {},
states: {
idle: {
on: {
GO: {
target: "active",
actions: [
() => {
throw new Error("boom");
},
],
},
},
},
active: {},
},
});
try {
m.send("GO");
expect.fail("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(FSMError);
expect((err as FSMError).code).toBe(FSM_CODES.ACTION_ERROR);
expect((err as FSMError).cause).toBeInstanceOf(Error);
}
});
it("wraps thrown errors from entry actions", { timeout: 5000 }, () => {
const m = createMachine({
initial: "idle",
context: {},
states: {
idle: { on: { GO: { target: "active" } } },
active: {
entry: () => {
throw new Error("entry boom");
},
},
},
});
try {
m.send("GO");
expect.fail("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(FSMError);
expect((err as FSMError).code).toBe(FSM_CODES.ACTION_ERROR);
}
});
it("wraps thrown errors from exit actions", { timeout: 5000 }, () => {
const m = createMachine({
initial: "idle",
context: {},
states: {
idle: {
exit: () => {
throw new Error("exit boom");
},
on: { GO: { target: "active" } },
},
active: {},
},
});
try {
m.send("GO");
expect.fail("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(FSMError);
expect((err as FSMError).code).toBe(FSM_CODES.ACTION_ERROR);
}
});
it("wraps non-Error thrown values in ACTION_ERROR", {
timeout: 5000,
}, () => {
const m = createMachine({
initial: "idle",
context: {},
states: {
idle: {
on: {
GO: {
target: "active",
actions: [
() => {
throw "string error";
},
],
},
},
},
active: {},
},
});
try {
m.send("GO");
expect.fail("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(FSMError);
expect((err as FSMError).code).toBe(FSM_CODES.ACTION_ERROR);
// cause is undefined when thrown value is not an Error instance
expect((err as FSMError).cause).toBeUndefined();
}
});
});
// ---------------------------------------------------------------------------
// Multiple transition actions — context accumulation
// ---------------------------------------------------------------------------
describe("FSMInterpreter — context accumulation", () => {
it("threads context through multiple actions", { timeout: 5000 }, () => {
const m = createMachine({
initial: "idle",
context: { count: 0 },
states: {
idle: {
on: {
GO: {
target: "active",
actions: [
(ctx: { count: number }) => ({
count: ctx.count + 1,
}),
(ctx: { count: number }) => ({
count: ctx.count + 10,
}),
],
},
},
},
active: {},
},
});
const state = m.send("GO");
expect(state.context.count).toBe(11);
});
it("void-returning actions do not reset context", { timeout: 5000 }, () => {
const m = createMachine({
initial: "idle",
context: { count: 5 },
states: {
idle: {
on: {
GO: {
target: "active",
actions: [
() => undefined, // no-op
(ctx: { count: number }) => ({
count: ctx.count + 1,
}),
],
},
},
},
active: {},
},
});
const state = m.send("GO");
expect(state.context.count).toBe(6);
});
});
// ---------------------------------------------------------------------------
// Edge cases — defensive branches
// ---------------------------------------------------------------------------
describe("FSMInterpreter — edge cases", () => {
it("throws INVALID_STATE when current state was removed from definition after construction", {
timeout: 5000,
}, () => {
const m = createMachine({
initial: "idle",
context: {},
states: { idle: {}, active: {} },
});
// Force current to a state not in the definition (internal manipulation)
(m as unknown as { _current: string })._current = "__ghost__";
try {
m.send("ANYTHING");
expect.fail("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(FSMError);
expect((err as FSMError).code).toBe(FSM_CODES.INVALID_STATE);
}
});
it("toJSON returns definition without id when id is not set", {
timeout: 5000,
}, () => {
const m = createMachine({
initial: "idle",
context: {},
states: { idle: {} },
});
const json = m.toJSON();
expect(json.id).toBeUndefined();
});
it("toJSON includes id when set", { timeout: 5000 }, () => {
const m = createMachine({
id: "my-machine",
initial: "idle",
context: {},
states: { idle: {} },
});
const json = m.toJSON();
expect(json.id).toBe("my-machine");
});
it("findTransition returns undefined when on map is empty for event type", {
timeout: 5000,
}, () => {
const m = createMachine({
initial: "idle",
context: {},
states: {
idle: { on: { KNOWN: { target: "active" } } },
active: {},
},
});
expect(() => m.send("UNKNOWN")).toThrow(FSMError);
});
it("can() returns false when current state has no on map", {
timeout: 5000,
}, () => {
const m = createMachine({
initial: "idle",
context: {},
states: {
idle: { on: { GO: { target: "terminal" } } },
terminal: {}, // no on map
},
});
m.send("GO");
expect(m.can("ANYTHING")).toBe(false);
});
it("wraps non-Error thrown value from entry action in ACTION_ERROR", {
timeout: 5000,
}, () => {
const m = createMachine({
initial: "idle",
context: {},
states: {
idle: { on: { GO: { target: "active" } } },
active: {
entry: () => {
throw "non-error string from entry";
},
},
},
});
try {
m.send("GO");
expect.fail("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(FSMError);
expect((err as FSMError).code).toBe(FSM_CODES.ACTION_ERROR);
expect((err as FSMError).cause).toBeUndefined();
}
});
it("wraps non-Error thrown value from exit action in ACTION_ERROR", {
timeout: 5000,
}, () => {
const m = createMachine({
initial: "idle",
context: {},
states: {
idle: {
exit: () => {
throw "non-error from exit";
},
on: { GO: { target: "active" } },
},
active: {},
},
});
try {
m.send("GO");
expect.fail("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(FSMError);
expect((err as FSMError).code).toBe(FSM_CODES.ACTION_ERROR);
expect((err as FSMError).cause).toBeUndefined();
}
});
});

View File

@@ -0,0 +1,371 @@
import { metrics } from "@opentelemetry/api";
import { EventBus } from "@suaveplan/events";
import { FSM_CODES, FSMError } from "../../errors/fsm-error.js";
import type {
EventType,
FSMDefinition,
FSMEvent,
FSMState,
HistoryEntry,
StateValue,
TransitionDefinition,
} from "../../types/fsm.types.js";
import { validateDefinition } from "../../utils/definition-validator.js";
/** @internal — OTel is no-op when no SDK provider is registered */
const meter = metrics.getMeter("@suaveplan/fsm");
/** @internal event map for the internal bus — must satisfy EventMap = Record<string, unknown> */
type FSMEventMap<TContext> = {
transition: FSMState<TContext>;
[key: string]: unknown;
};
/**
* A typed, synchronous finite state machine interpreter.
*
* Manages state transitions, runs entry/exit/transition actions that may
* update the extended context, records a full transition history, and emits
* state-change events via an internal `EventBus`.
*
* OTel metrics:
* - Counter `fsm.transition.total` `{ id, from, to, event }`
* - Histogram `fsm.transition.duration_ms`
*
* @template TContext - The shape of the machine's extended context.
*/
export class FSMInterpreter<TContext = unknown> {
private readonly def: FSMDefinition<TContext>;
private _current: StateValue;
private _context: TContext;
private _history: HistoryEntry[];
private readonly bus: EventBus<FSMEventMap<TContext>>;
constructor(definition: FSMDefinition<TContext>) {
validateDefinition(definition);
this.def = definition;
this._current = definition.initial;
this._context = definition.context;
this._history = [];
this.bus = new EventBus<FSMEventMap<TContext>>();
}
// ---------------------------------------------------------------------------
// Public accessors
// ---------------------------------------------------------------------------
/**
* Immutable snapshot of the current machine state.
*/
get state(): FSMState<TContext> {
return {
value: this._current,
context: this._context,
history: this._history.slice(),
done: this.def.states[this._current]?.type === "final",
};
}
/**
* The current state value (shorthand for `state.value`).
*/
get current(): StateValue {
return this._current;
}
// ---------------------------------------------------------------------------
// Transition
// ---------------------------------------------------------------------------
/**
* Send an event to the machine.
*
* Execution order per transition:
* 1. Evaluate guards — first passing candidate wins.
* 2. Run exit action of the current state (may update context).
* 3. Run transition actions in order (each receives accumulated context).
* 4. Update current state value.
* 5. Run entry action of the new state (may update context).
* 6. Append a `HistoryEntry`.
* 7. Emit `transition` event via the internal bus.
* 8. Record OTel metrics.
*
* @param event - Either a full `FSMEvent` object or a bare event-type string.
* @returns The new `FSMState<TContext>` snapshot after the transition.
* @throws {FSMError} `INVALID_TRANSITION` if no matching transition exists.
* @throws {FSMError} `ACTION_ERROR` if a transition action throws.
*/
send(event: FSMEvent | EventType): FSMState<TContext> {
const fsmEvent: FSMEvent =
typeof event === "string" ? { type: event } : event;
const stateDef = this.def.states[this._current];
if (!stateDef) {
throw new FSMError(
FSM_CODES["INVALID_STATE"],
`State "${this._current}" is not defined`,
{ context: { state: this._current } }
);
}
// Find matching transition
const candidate = this.findTransition(fsmEvent, stateDef.on);
if (!candidate) {
throw new FSMError(
FSM_CODES["INVALID_TRANSITION"],
`No valid transition for event "${fsmEvent.type}" in state "${this._current}"`,
{ context: { event: fsmEvent.type, state: this._current } }
);
}
const startMs = Date.now();
const from = this._current;
let ctx = this._context;
// 2. Exit action
if (stateDef.exit) {
const result = this.runLifecycleAction(
stateDef.exit,
ctx,
fsmEvent,
"exit",
from
);
if (result !== undefined) ctx = result;
}
// 3. Transition actions
if (candidate.actions) {
for (const action of candidate.actions) {
try {
const result = action(ctx, fsmEvent);
if (result !== undefined) ctx = result;
} catch (err) {
throw new FSMError(
FSM_CODES["ACTION_ERROR"],
`Action threw during transition from "${from}" on event "${fsmEvent.type}"`,
{
...(err instanceof Error ? { cause: err } : {}),
context: {
from,
event: fsmEvent.type,
target: candidate.target,
},
}
);
}
}
}
// 4. Update state
const to = candidate.target;
this._current = to;
this._context = ctx;
// 5. Entry action
const newStateDef = this.def.states[to];
if (newStateDef?.entry) {
const result = this.runLifecycleAction(
newStateDef.entry,
ctx,
fsmEvent,
"entry",
to
);
if (result !== undefined) {
this._context = result;
}
}
// 6. Append history
const entry: HistoryEntry = {
from,
event: fsmEvent,
to,
timestamp: Date.now(),
};
this._history.push(entry);
// 7. Build snapshot and emit
const snapshot = this.state;
this.bus.emit("transition", snapshot);
// 8. OTel metrics — no-op when no SDK provider is registered
const durationMs = Date.now() - startMs;
const id = this.def.id ?? "anonymous";
meter
.createCounter("fsm.transition.total")
.add(1, { id, from, to, event: fsmEvent.type });
meter
.createHistogram("fsm.transition.duration_ms")
.record(durationMs, { id, from, to });
return snapshot;
}
// ---------------------------------------------------------------------------
// Inspection helpers
// ---------------------------------------------------------------------------
/**
* Returns `true` when the current state value equals `state`.
*
* @param state - State value to test against.
*/
matches(state: StateValue): boolean {
return this._current === state;
}
/**
* Returns `true` if at least one transition for `eventType` exists in the
* current state, regardless of whether its guard would pass.
*
* Use this for UI logic (enabling/disabling buttons); for true feasibility
* including guard evaluation, send the event and catch `INVALID_TRANSITION`.
*
* @param eventType - The event type to probe.
*/
can(eventType: EventType): boolean {
const stateDef = this.def.states[this._current];
if (!stateDef?.on) return false;
return eventType in stateDef.on;
}
// ---------------------------------------------------------------------------
// Reset
// ---------------------------------------------------------------------------
/**
* Returns the machine to its initial state and clears the history.
* The context is also reset to the original `definition.context` value.
*/
reset(): void {
this._current = this.def.initial;
this._context = this.def.context;
this._history = [];
}
// ---------------------------------------------------------------------------
// Subscription
// ---------------------------------------------------------------------------
/**
* Subscribe to state transitions.
*
* The listener is called synchronously after every successful transition,
* immediately after history is appended.
*
* @param listener - Callback receiving the new `FSMState<TContext>`.
* @returns An unsubscribe function.
*/
onTransition(listener: (state: FSMState<TContext>) => void): () => void {
return this.bus.on("transition", listener);
}
// ---------------------------------------------------------------------------
// Serialization
// ---------------------------------------------------------------------------
/**
* Returns a serializable snapshot of the machine definition.
* The snapshot includes the current (possibly mutated) context.
*/
toJSON(): Omit<FSMDefinition<TContext>, "id"> & { id?: string } {
return {
...(this.def.id !== undefined ? { id: this.def.id } : {}),
initial: this.def.initial,
context: this._context,
states: this.def.states,
};
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
private findTransition(
event: FSMEvent,
on:
| Record<
EventType,
| TransitionDefinition<TContext>
| Array<TransitionDefinition<TContext>>
>
| undefined
): TransitionDefinition<TContext> | undefined {
if (!on || !(event.type in on)) return undefined;
// After the `in` check, the key is guaranteed to exist.
const raw = on[event.type] as
| TransitionDefinition<TContext>
| Array<TransitionDefinition<TContext>>;
const candidates: Array<TransitionDefinition<TContext>> = Array.isArray(
raw
)
? raw
: [raw];
for (const t of candidates) {
if (!t.guard || t.guard(this._context, event)) {
return t;
}
}
return undefined;
}
private runLifecycleAction(
fn: (context: TContext, event: FSMEvent) => TContext | undefined,
ctx: TContext,
event: FSMEvent,
lifecycle: "entry" | "exit",
stateName: string
): TContext | undefined {
try {
const result = fn(ctx, event);
return result as TContext | undefined;
} catch (err) {
throw new FSMError(
FSM_CODES["ACTION_ERROR"],
`${lifecycle} action threw in state "${stateName}"`,
{
...(err instanceof Error ? { cause: err } : {}),
context: { lifecycle, state: stateName },
}
);
}
}
}
/**
* Factory function for creating an `FSMInterpreter` instance.
*
* This is the primary entrypoint for the package. Validation runs eagerly
* during construction so that invalid definitions are caught before any
* events are processed.
*
* @param definition - The machine definition.
* @returns A new `FSMInterpreter<TContext>` instance.
*
* @example
* ```ts
* import { createMachine } from '@suaveplan/fsm';
*
* const machine = createMachine({
* id: 'traffic',
* initial: 'red',
* context: { count: 0 },
* states: {
* red: { on: { NEXT: { target: 'green' } } },
* green: { on: { NEXT: { target: 'yellow' } } },
* yellow: { on: { NEXT: { target: 'red' } } },
* },
* });
*
* machine.send('NEXT'); // → { value: 'green', ... }
* ```
*/
export function createMachine<TContext = unknown>(
definition: FSMDefinition<TContext>
): FSMInterpreter<TContext> {
return new FSMInterpreter(definition);
}

View File

@@ -0,0 +1 @@
export { createMachine, FSMInterpreter } from "./fsm-interpreter";

View File

@@ -0,0 +1,106 @@
# fsm-error
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the stable error-code registry and typed error class for the `@suaveplan/fsm` package. Every error thrown by the FSM interpreter, definition validator, and context helpers extends `FSMError`, giving callers a single `instanceof` check and a machine-readable `code` property drawn from `FSM_CODES`.
The registry is built with `defineCodes` from `@suaveplan/error`, which guarantees that each code string is unique across the entire `@suaveplan/*` ecosystem and can be compared with `===` without string literals.
## Features
- Five stable error codes covering all failure scenarios: `INVALID_DEFINITION`, `INVALID_TRANSITION`, `INVALID_STATE`, `GUARD_FAILED`, `ACTION_ERROR`
- `FSMError` extends `SuaveplanError<FSMCode>` for narrow discriminated-union typing
- Supports optional `cause` and `context` metadata on every error instance
- Fully compatible with the `@suaveplan/error` wire-format serialization
## Usage: Basic Example
```ts
import { FSMError, FSM_CODES } from '@suaveplan/fsm/errors';
try {
machine.send('UNKNOWN_EVENT');
} catch (err) {
if (err instanceof FSMError) {
console.error(err.code, err.message);
// "FSM.INVALID_TRANSITION" "No valid transition for event in current state"
}
}
```
## Usage: Advanced Example
```ts
import { FSMError, FSM_CODES } from '@suaveplan/fsm/errors';
function handleFSMError(err: unknown): void {
if (!(err instanceof FSMError)) throw err;
switch (err.code) {
case FSM_CODES.INVALID_DEFINITION:
console.error('Machine definition is malformed:', err.message);
break;
case FSM_CODES.INVALID_TRANSITION:
console.warn('Event not handled in current state');
break;
case FSM_CODES.GUARD_FAILED:
console.info('Transition blocked by guard');
break;
case FSM_CODES.ACTION_ERROR:
console.error('Action threw during transition:', err.cause);
break;
default:
throw err;
}
}
```
## API Reference
### `FSM_CODES`
```ts
const FSM_CODES: {
INVALID_DEFINITION: 'FSM.INVALID_DEFINITION';
INVALID_TRANSITION: 'FSM.INVALID_TRANSITION';
INVALID_STATE: 'FSM.INVALID_STATE';
GUARD_FAILED: 'FSM.GUARD_FAILED';
ACTION_ERROR: 'FSM.ACTION_ERROR';
}
```
Stable string constants for each FSM error domain. Use these instead of raw string comparisons to benefit from TypeScript narrowing.
### `type FSMCode`
Union type of all values in `FSM_CODES`. Used as the generic parameter of `FSMError`.
### `class FSMError`
```ts
class FSMError extends SuaveplanError<FSMCode> {
constructor(
code: FSMCode,
message: string,
options?: { cause?: Error; context?: Record<string, unknown> }
)
}
```
**Parameters:**
- `code` — one of the `FSM_CODES` values; stored as `this.code`
- `message` — human-readable description of the failure
- `options.cause` — underlying error that triggered this one (chained)
- `options.context` — arbitrary key/value metadata (state name, event type, etc.)
**Throws:** Never (constructor only).
## Implementation Notes
The `defineCodes` helper from `@suaveplan/error` namespaces all codes under `"FSM."` to prevent collisions with codes from other packages sharing the same registry. The `as const` assertion on the descriptor object ensures the resulting type is a fully-discriminated union rather than `string`.
The `noUselessConstructor` biome lint suppression is intentional: the constructor is required to widen the `protected` base constructor to `public`, which is a documented pattern in this repository for all `SuaveplanError` subclasses.

View File

@@ -0,0 +1,49 @@
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/fsm`.
*
* Codes follow the pattern `FSM.<DOMAIN>` and are resolved via
* `@suaveplan/error`'s `defineCodes` registry, giving callers a
* fully-qualified string such as `"FSM.INVALID_DEFINITION"` that is safe
* to use in `instanceof`-free discrimination (e.g. `error.code`).
*/
export const FSM_CODES = defineCodes("FSM", [
"INVALID_DEFINITION",
"INVALID_TRANSITION",
"INVALID_STATE",
"GUARD_FAILED",
"ACTION_ERROR",
] as const);
export type FSMCode = (typeof FSM_CODES)[keyof typeof FSM_CODES];
/**
* Typed error for all `@suaveplan/fsm` failures.
*
* Extends `SuaveplanError` so that callers can discriminate errors from this
* package via `error instanceof FSMError` or the stable `error.code` property.
*
* @example
* ```ts
* import { FSMError, FSM_CODES } from '@suaveplan/fsm/errors';
*
* try {
* machine.send('UNKNOWN_EVENT');
* } catch (err) {
* if (err instanceof FSMError && err.code === FSM_CODES.INVALID_TRANSITION) {
* console.error('No transition available');
* }
* }
* ```
*/
export class FSMError extends SuaveplanError<FSMCode> {
public constructor(
code: FSMCode,
message: string,
options?: { cause?: Error; context?: Record<string, unknown> }
) {
super(code, message, options);
this.name = "FSMError";
}
}

View File

@@ -0,0 +1 @@
export { FSM_CODES, type FSMCode, FSMError } from "./fsm-error";

View File

@@ -0,0 +1,24 @@
// Core interpreter
export {
createMachine,
FSMInterpreter,
} from "./core/interpreter/fsm-interpreter.js";
export type { FSMCode } from "./errors/fsm-error.js";
// Errors
export { FSM_CODES, FSMError } from "./errors/fsm-error.js";
// Types
export type {
EventType,
FSMDefinition,
FSMEvent,
FSMState,
HistoryEntry,
StateDefinition,
StateValue,
TransitionDefinition,
} from "./types/fsm.types.js";
// Utilities
export { assign, pure } from "./utils/context-helpers.js";
export { validateDefinition } from "./utils/definition-validator.js";

View File

@@ -0,0 +1,144 @@
# fsm.types
## Purpose
Defines the complete TypeScript type vocabulary for `@suaveplan/fsm`. Every public interface, type alias, and generic constraint used by the interpreter, definition validator, context helpers, and package consumers lives here. Centralising types in one place prevents circular imports and makes the type contract auditable at a glance.
All types are pure TypeScript — no runtime code is emitted by this file.
## Features
- `StateValue` — opaque string alias for state node identifiers
- `EventType` — opaque string alias for event discriminants
- `FSMEvent<TPayload>` — typed event object sent to `send()` with optional payload
- `HistoryEntry` — immutable record of a single state transition with timestamp
- `FSMState<TContext>` — immutable snapshot of the machine's current state, context, history, and `done` flag
- `TransitionDefinition<TContext>` — single transition edge with optional guard and ordered action array
- `StateDefinition<TContext>` — full state node definition: entry/exit hooks, event-to-transition map, type, and nested states
- `FSMDefinition<TContext>` — top-level machine definition passed to `createMachine()`
## Usage: Basic Example
```ts
import type {
FSMDefinition,
FSMEvent,
FSMState,
TransitionDefinition,
} from '@suaveplan/fsm';
interface TrafficCtx { count: number }
const def: FSMDefinition<TrafficCtx> = {
id: 'traffic',
initial: 'red',
context: { count: 0 },
states: {
red: { on: { NEXT: { target: 'green' } } },
green: { on: { NEXT: { target: 'yellow' } } },
yellow: { on: { NEXT: { target: 'red' } } },
},
};
```
## Usage: Advanced Example
```ts
import type {
FSMDefinition,
StateDefinition,
TransitionDefinition,
FSMEvent,
FSMState,
HistoryEntry,
} from '@suaveplan/fsm';
interface AuthCtx {
user: string | null;
attempts: number;
}
// Typed transition with guard
const loginTransition: TransitionDefinition<AuthCtx> = {
target: 'authenticated',
guard: (ctx, event: FSMEvent<{ token: string }>) =>
typeof event.payload?.token === 'string',
actions: [
(ctx, event: FSMEvent<{ user: string }>) =>
({ ...ctx, user: event.payload?.user ?? null }),
],
};
// Typed state with entry/exit
const authState: StateDefinition<AuthCtx> = {
entry: (ctx) => ({ ...ctx, attempts: 0 }),
exit: (ctx) => ctx,
on: { LOGIN: loginTransition },
};
// Inspect the FSMState snapshot
function onTransition(state: FSMState<AuthCtx>): void {
const last: HistoryEntry | undefined = state.history[state.history.length - 1];
console.log(`Transitioned from ${last?.from} to ${state.value}`);
if (state.done) console.log('Machine is done');
}
```
## API Reference
### `type StateValue = string`
Opaque alias for state node names. Using a named alias rather than `string` makes function signatures self-documenting.
### `type EventType = string`
Opaque alias for event type discriminants.
### `interface FSMEvent<TPayload = unknown>`
- `type: EventType` — the event discriminant
- `payload?: TPayload` — optional structured payload carried with the event
### `interface HistoryEntry`
- `from: StateValue` — state before transition
- `event: FSMEvent` — the event that triggered the transition
- `to: StateValue` — state after transition
- `timestamp: number` — `Date.now()` at transition time
### `interface FSMState<TContext = unknown>`
- `value: StateValue` — current state name
- `context: TContext` — extended context at snapshot time
- `history: ReadonlyArray<HistoryEntry>` — full transition log (immutable copy)
- `done: boolean` — `true` when current state has `type: 'final'`
### `interface TransitionDefinition<TContext = unknown>`
- `target: StateValue` — destination state
- `guard?: (context, event) => boolean` — optional predicate; transition is skipped if it returns `false`
- `actions?: Array<(context, event) => TContext | undefined>` — ordered action functions; returning a value replaces context
### `interface StateDefinition<TContext = unknown>`
- `entry?: (context, event) => TContext | undefined` — called on state entry
- `exit?: (context, event) => TContext | undefined` — called before state exit
- `on?: Record<EventType, TransitionDefinition | TransitionDefinition[]>` — event-to-transition map
- `type?: 'final' | 'parallel'` — special state semantics
- `states?: Record<StateValue, StateDefinition>` — nested child states
- `initial?: StateValue` — required initial child (when `states` is defined)
### `interface FSMDefinition<TContext = unknown>`
- `id?: string` — optional identifier (used in OTel metric labels)
- `initial: StateValue` — initial state (must exist in `states`)
- `context: TContext` — initial extended context value
- `states: Record<StateValue, StateDefinition<TContext>>` — top-level state map
## Implementation Notes
All interfaces use `TContext = unknown` as the default generic parameter so that consumers who do not pass a context type get `unknown` rather than `any`, preserving type safety at call sites.
Action return types use `TContext | undefined` rather than `TContext | void` to satisfy Biome's `noConfusingVoidType` rule, which forbids `void` inside union types. In practice, returning `undefined` and returning nothing (implicit `void`) are semantically identical in the interpreter — both leave the context unchanged.
`ReadonlyArray<HistoryEntry>` on `FSMState.history` signals to consumers that they must not mutate the history array. The interpreter returns a fresh `slice()` on each `state` access to further enforce immutability.

View File

@@ -0,0 +1,127 @@
/**
* Opaque string identifying a state node within an FSM definition.
*/
export type StateValue = string;
/**
* Opaque string identifying an event type that the FSM can process.
*/
export type EventType = string;
/**
* An event dispatched to the FSM interpreter via `send()`.
*
* @template TPayload - Optional payload type carried with the event.
*/
export interface FSMEvent<TPayload = unknown> {
/** The event type discriminant. */
type: EventType;
/** Arbitrary payload attached to the event. */
payload?: TPayload;
}
/**
* A single entry in the FSM transition history.
*/
export interface HistoryEntry {
/** The state value before the transition. */
from: StateValue;
/** The event that triggered the transition. */
event: FSMEvent;
/** The state value after the transition. */
to: StateValue;
/** Unix timestamp (ms) when the transition occurred. */
timestamp: number;
}
/**
* Immutable snapshot of the current FSM state.
*
* @template TContext - The shape of the machine's extended context.
*/
export interface FSMState<TContext = unknown> {
/** The current state value. */
value: StateValue;
/** The extended context at this point in time. */
context: TContext;
/** Ordered log of all transitions taken since construction or last reset. */
history: ReadonlyArray<HistoryEntry>;
/** `true` when the current state has `type: 'final'`. */
done: boolean;
}
/**
* Defines a single transition edge from one state to another.
*
* @template TContext - The machine's extended context type.
*/
export interface TransitionDefinition<TContext = unknown> {
/** Target state to transition to. */
target: StateValue;
/**
* Optional guard predicate. If it returns `false`, this transition is
* skipped and the next candidate (if any) is evaluated.
*/
guard?: (context: TContext, event: FSMEvent) => boolean;
/**
* Ordered list of action functions that may update the context.
* Each function receives the *accumulated* context from preceding actions.
* Return `void` to leave context unchanged; return a new `TContext` to
* replace it.
*/
actions?: Array<
(context: TContext, event: FSMEvent) => TContext | undefined
>;
}
/**
* Defines a single state node within an FSM definition.
*
* @template TContext - The machine's extended context type.
*/
export interface StateDefinition<TContext = unknown> {
/**
* Entry action called when the machine enters this state.
* Return a new context to replace it; return `void` to keep unchanged.
*/
entry?: (context: TContext, event: FSMEvent) => TContext | undefined;
/**
* Exit action called before the machine leaves this state.
* Return a new context to replace it; return `void` to keep unchanged.
*/
exit?: (context: TContext, event: FSMEvent) => TContext | undefined;
/**
* Map of event types to one or more transition definitions.
* Transitions are evaluated in array order; the first one whose guard
* passes is taken.
*/
on?: Record<
EventType,
TransitionDefinition<TContext> | Array<TransitionDefinition<TContext>>
>;
/**
* `'final'` — the machine marks `done: true` on entering this state.
* `'parallel'` — reserved for parallel-region semantics (not yet active).
*/
type?: "final" | "parallel";
/** Nested child states (for hierarchical FSMs). */
states?: Record<StateValue, StateDefinition<TContext>>;
/** Initial child state (required when `states` is non-empty). */
initial?: StateValue;
}
/**
* Top-level machine definition passed to `createMachine()`.
*
* @template TContext - The shape of the machine's extended context.
*/
export interface FSMDefinition<TContext = unknown> {
/** Optional identifier used in OTel metrics and error messages. */
id?: string;
/** The initial state value. Must be a key in `states`. */
initial: StateValue;
/** The initial extended context. */
context: TContext;
/** Map of all top-level state definitions. */
states: Record<StateValue, StateDefinition<TContext>>;
}

View File

@@ -0,0 +1,10 @@
export type {
EventType,
FSMDefinition,
FSMEvent,
FSMState,
HistoryEntry,
StateDefinition,
StateValue,
TransitionDefinition,
} from "./fsm.types";

View File

@@ -0,0 +1,107 @@
# context-helpers
## Purpose
Provides ergonomic factory helpers for building context-update and side-effect actions that can be slotted directly into `TransitionDefinition.actions` arrays. Without these helpers, every action must be written as an explicit arrow function returning a new context, which becomes verbose for simple partial-update or logging scenarios.
The two helpers mirror the `assign` and `pure` patterns popularised by XState, adapted to the `@suaveplan/fsm` action signature.
## Features
- `assign(patch)` — shallow-merges a static partial object into the context
- `assign(fn)` — delegates to a context-mapping function for complex derivations
- `pure(fn)` — wraps a side-effect function ensuring its return value never corrupts the context
- Both helpers are fully typed against the machine's `TContext` generic parameter
## Usage: Basic Example
```ts
import { assign, pure, createMachine } from '@suaveplan/fsm';
interface Ctx { count: number; lastEvent: string }
const machine = createMachine<Ctx>({
initial: 'idle',
context: { count: 0, lastEvent: '' },
states: {
idle: {
on: {
INCREMENT: {
target: 'idle',
actions: [
assign<Ctx>(ctx => ({ ...ctx, count: ctx.count + 1 })),
pure<Ctx>((ctx, evt) => { console.log('incremented', ctx, evt); }),
],
},
},
},
},
});
```
## Usage: Advanced Example
```ts
import { assign } from '@suaveplan/fsm';
interface WizardCtx {
step: number;
formData: Record<string, string>;
completed: boolean;
}
// Static patch (shallow merge)
const resetWizard = assign<WizardCtx>({ step: 1, completed: false });
// Functional update with derived logic
const advanceStep = assign<WizardCtx>(ctx => ({
...ctx,
step: ctx.step + 1,
completed: ctx.step + 1 >= 5,
}));
// Compose them in a transition
const machine = createMachine<WizardCtx>({
initial: 'wizard',
context: { step: 1, formData: {}, completed: false },
states: {
wizard: {
on: {
NEXT: { target: 'wizard', actions: [advanceStep] },
RESET: { target: 'wizard', actions: [resetWizard] },
},
},
},
});
```
## API Reference
### `assign<TContext>(updater): (context: TContext, event: FSMEvent) => TContext`
**Parameters:**
- `updater` — either a `Partial<TContext>` patch object or a function `(ctx: TContext) => TContext`
**Returns:** An action function compatible with `TransitionDefinition.actions`.
**Behaviour:**
- If `updater` is a plain object, it performs `{ ...context, ...updater }`.
- If `updater` is a function, it calls `updater(context)` and returns the result.
### `pure<TContext>(fn): (context: TContext, event: FSMEvent) => void`
**Parameters:**
- `fn` — a `(context: TContext, event: FSMEvent) => void` side-effect function
**Returns:** A wrapped action function whose return value is always `void`.
**Behaviour:** Calls `fn(context, event)` and discards any return value. This prevents accidental context mutations from side-effect functions that inadvertently return a value.
## Implementation Notes
`assign` accepts both object and function forms by checking `typeof updater === 'function'`. The cast `updater as (ctx: TContext) => TContext` is required because TypeScript cannot narrow a union of `Partial<TContext>` and `(ctx) => TContext` cleanly at the call site without the explicit cast.
`pure` exists as a safety wrapper rather than a direct alias because some developers write side-effect actions as `(ctx, evt) => doSomething()` where `doSomething` returns a non-void value. Without `pure`, that non-void value would be treated as a context replacement by the interpreter.

View File

@@ -0,0 +1,121 @@
import { describe, expect, it, vi } from "@suaveplan/testing/runner";
import type { FSMEvent } from "../types/fsm.types.js";
import { assign, pure } from "./context-helpers.js";
const dummyEvent: FSMEvent = { type: "TEST" };
describe("assign", () => {
it("performs shallow merge when passed a partial object", {
timeout: 5000,
}, () => {
const action = assign<{ count: number; name: string }>({ count: 5 });
const result = action({ count: 0, name: "alice" }, dummyEvent);
expect(result).toEqual({ count: 5, name: "alice" });
});
it("replaces context when passed an object with all keys", {
timeout: 5000,
}, () => {
const action = assign<{ count: number }>({ count: 99 });
const result = action({ count: 0 }, dummyEvent);
expect(result).toEqual({ count: 99 });
});
it("uses updater function when a function is provided", {
timeout: 5000,
}, () => {
const action = assign<{ count: number }>((ctx) => ({
...ctx,
count: ctx.count + 1,
}));
const result = action({ count: 3 }, dummyEvent);
expect(result).toEqual({ count: 4 });
});
it("passes original context to updater function", { timeout: 5000 }, () => {
const action = assign<{ count: number }>((ctx) => ({
count: ctx.count * 2,
}));
expect(action({ count: 7 }, dummyEvent)).toEqual({ count: 14 });
});
it("does not mutate the original context", { timeout: 5000 }, () => {
const original = { count: 1, name: "bob" };
const action = assign<typeof original>({ count: 42 });
action(original, dummyEvent);
expect(original.count).toBe(1);
});
it("supports complex derived context from function", {
timeout: 5000,
}, () => {
interface Ctx {
step: number;
completed: boolean;
}
const action = assign<Ctx>((ctx) => ({
step: ctx.step + 1,
completed: ctx.step + 1 >= 3,
}));
expect(action({ step: 2, completed: false }, dummyEvent)).toEqual({
step: 3,
completed: true,
});
});
it("receives the event in the updater function (unused but available)", {
timeout: 5000,
}, () => {
const captured: FSMEvent[] = [];
const action = assign<{ count: number }>((_ctx) => {
// Note: assign's function updater only receives context, not event
return { count: 1 };
});
// This just confirms the action can be called with an event
const result = action({ count: 0 }, { type: "CHECK", payload: "test" });
expect(result).toEqual({ count: 1 });
expect(captured).toHaveLength(0);
});
});
describe("pure", () => {
it("calls the side-effect function", { timeout: 5000 }, () => {
const calls: Array<[unknown, FSMEvent]> = [];
const action = pure<{ count: number }>((ctx, evt) => {
calls.push([ctx, evt]);
});
action({ count: 5 }, dummyEvent);
expect(calls).toHaveLength(1);
expect(calls[0]?.[0]).toEqual({ count: 5 });
expect(calls[0]?.[1]).toBe(dummyEvent);
});
it("returns void even if wrapped fn returns a value", {
timeout: 5000,
}, () => {
const action = pure<{ count: number }>((_ctx, _evt) => {
return { count: 999 };
});
const result = action({ count: 1 }, dummyEvent);
expect(result).toBeUndefined();
});
it("passes event to the side-effect function", { timeout: 5000 }, () => {
const spy = vi.fn();
const action = pure<Record<string, never>>(spy);
const customEvent: FSMEvent = { type: "MY_EVENT", payload: 42 };
action({}, customEvent);
expect(spy).toHaveBeenCalledWith({}, customEvent);
});
it("does not modify context", { timeout: 5000 }, () => {
const original = { count: 7 };
const action = pure<typeof original>((ctx) => {
// attempt side mutation (in production code this would be wrong)
(ctx as { count: number }).count = 100;
});
// The action modifies the object in place but pure returns void
const result = action(original, dummyEvent);
expect(result).toBeUndefined();
});
});

View File

@@ -0,0 +1,61 @@
import type { FSMEvent } from "../types/fsm.types.js";
/**
* Creates a context-update action from a partial object or an updater function.
*
* When passed a plain object, it performs a shallow merge (`{...ctx, ...patch}`).
* When passed a function, it calls the function with the current context and
* returns whatever the function returns.
*
* This helper is designed to slot directly into a `TransitionDefinition.actions`
* array without boilerplate.
*
* @param updater - Either a partial context patch or a function `(ctx) => newCtx`.
* @returns An action function compatible with `TransitionDefinition.actions`.
*
* @example
* ```ts
* import { assign } from '@suaveplan/fsm';
*
* const increment = assign<{ count: number }>(ctx => ({ ...ctx, count: ctx.count + 1 }));
* const reset = assign<{ count: number }>({ count: 0 });
* ```
*/
export function assign<TContext>(
updater: Partial<TContext> | ((ctx: TContext) => TContext)
): (context: TContext, event: FSMEvent) => TContext {
return (context: TContext, _event: FSMEvent): TContext => {
if (typeof updater === "function") {
return (updater as (ctx: TContext) => TContext)(context);
}
return { ...context, ...updater };
};
}
/**
* Wraps a side-effect function as a no-op context action.
*
* The wrapped function receives `(context, event)` and may perform any
* side effect (logging, analytics, etc.) but its return value is ignored.
* This ensures that accidentally returning a value from a side-effect
* function does not corrupt the context.
*
* @param fn - Side-effect function that does not modify context.
* @returns An action function that always returns `void`.
*
* @example
* ```ts
* import { pure } from '@suaveplan/fsm';
*
* const logTransition = pure((ctx, event) => {
* console.log(`Event received: ${event.type}`, ctx);
* });
* ```
*/
export function pure<TContext>(
fn: (context: TContext, event: FSMEvent) => void
): (context: TContext, event: FSMEvent) => void {
return (context: TContext, event: FSMEvent): void => {
fn(context, event);
};
}

View File

@@ -0,0 +1,89 @@
# definition-validator
## Purpose
Validates an `FSMDefinition` object before the interpreter begins executing. Early structural validation prevents subtle runtime bugs such as transitions leading to undefined states or machines that can never leave their initial state due to a misconfigured `initial` key.
The validator is called automatically by `FSMInterpreter`'s constructor and by `createMachine()`. It can also be called standalone to check definitions at authoring time, in build pipelines, or within test suites that assert that certain configurations are correct.
## Features
- Verifies the top-level `initial` state exists in `states`
- Recursively collects all transition `target` references (including nested state trees) and confirms every target is a known state name
- Ensures any state node that declares child `states` also provides an `initial` child key
- Ensures that declared `initial` children actually exist within their parent's `states` map
- Throws `FSMError` with code `FSM.INVALID_DEFINITION` and rich `context` metadata on any failure
## Usage: Basic Example
```ts
import { validateDefinition } from '@suaveplan/fsm';
validateDefinition({
initial: 'idle',
context: {},
states: {
idle: { on: { START: { target: 'active' } } },
active: { on: { STOP: { target: 'idle' } } },
},
});
// No error — definition is valid
```
## Usage: Advanced Example
```ts
import { validateDefinition } from '@suaveplan/fsm';
import { FSMError, FSM_CODES } from '@suaveplan/fsm/errors';
// Catch and inspect validation errors
try {
validateDefinition({
initial: 'ghost', // "ghost" is not in states
context: {},
states: { idle: {} },
});
} catch (err) {
if (err instanceof FSMError && err.code === FSM_CODES.INVALID_DEFINITION) {
// err.context?.initial === 'ghost'
console.error('Bad definition:', err.message);
}
}
// Hierarchical definition with nested initial
validateDefinition({
initial: 'form',
context: { step: 1 },
states: {
form: {
initial: 'step1',
states: {
step1: { on: { NEXT: { target: 'step2' } } },
step2: { on: { BACK: { target: 'step1' } } },
},
},
},
});
```
## API Reference
### `validateDefinition<TContext>(def: FSMDefinition<TContext>): void`
**Parameters:**
- `def` — the complete FSM definition to validate
**Returns:** `void` (pure side-effect: throws on invalid input)
**Throws:** `FSMError` with code `FSM.INVALID_DEFINITION` and a `context` object containing the offending state/target names.
**Generic parameter:**
- `TContext` — the machine's extended context type; used only for correct typing of the definition shape.
## Implementation Notes
State name collection is done with a recursive depth-first traversal of the `states` map. Both fully-qualified dotted names (e.g. `"form.step1"`) and short names (e.g. `"step1"`) are added to the lookup set so that authors can use either convention in their `target` strings.
The validator deliberately does **not** check guard function arity or action return types — those are runtime concerns enforced by TypeScript at compile time. The focus here is purely structural integrity of the state graph.

View File

@@ -0,0 +1,180 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { FSM_CODES, FSMError } from "../errors/fsm-error.js";
import { validateDefinition } from "./definition-validator.js";
describe("validateDefinition", () => {
it("accepts a valid minimal definition", { timeout: 5000 }, () => {
expect(() =>
validateDefinition({
initial: "idle",
context: {},
states: { idle: {} },
})
).not.toThrow();
});
it("accepts a valid multi-state definition", { timeout: 5000 }, () => {
expect(() =>
validateDefinition({
initial: "red",
context: {},
states: {
red: { on: { NEXT: { target: "green" } } },
green: { on: { NEXT: { target: "yellow" } } },
yellow: { on: { NEXT: { target: "red" } } },
},
})
).not.toThrow();
});
it("throws INVALID_DEFINITION when initial state does not exist", {
timeout: 5000,
}, () => {
let err: FSMError | undefined;
try {
validateDefinition({
initial: "ghost",
context: {},
states: { idle: {} },
});
} catch (e) {
err = e as FSMError;
}
expect(err).toBeInstanceOf(FSMError);
expect(err?.code).toBe(FSM_CODES.INVALID_DEFINITION);
expect(err?.message).toContain("ghost");
});
it("throws INVALID_DEFINITION when transition targets non-existent state", {
timeout: 5000,
}, () => {
let err: FSMError | undefined;
try {
validateDefinition({
initial: "idle",
context: {},
states: {
idle: { on: { GO: { target: "nowhere" } } },
},
});
} catch (e) {
err = e as FSMError;
}
expect(err).toBeInstanceOf(FSMError);
expect(err?.code).toBe(FSM_CODES.INVALID_DEFINITION);
expect(err?.message).toContain("nowhere");
});
it("accepts multiple transition candidates for the same event", {
timeout: 5000,
}, () => {
expect(() =>
validateDefinition({
initial: "idle",
context: { count: 0 },
states: {
idle: {
on: {
GO: [{ target: "active" }, { target: "inactive" }],
},
},
active: {},
inactive: {},
},
})
).not.toThrow();
});
it("throws when nested initial child does not exist", {
timeout: 5000,
}, () => {
let err: FSMError | undefined;
try {
validateDefinition({
initial: "form",
context: {},
states: {
form: {
initial: "step999",
states: {
step1: {},
},
},
},
});
} catch (e) {
err = e as FSMError;
}
expect(err).toBeInstanceOf(FSMError);
expect(err?.code).toBe(FSM_CODES.INVALID_DEFINITION);
expect(err?.message).toContain("step999");
});
it("throws when nested states exist but no initial is declared", {
timeout: 5000,
}, () => {
let err: FSMError | undefined;
try {
validateDefinition({
initial: "form",
context: {},
states: {
form: {
states: {
step1: {},
},
// initial deliberately omitted
},
},
});
} catch (e) {
err = e as FSMError;
}
expect(err).toBeInstanceOf(FSMError);
expect(err?.code).toBe(FSM_CODES.INVALID_DEFINITION);
});
it("accepts a valid nested definition with correct initial", {
timeout: 5000,
}, () => {
expect(() =>
validateDefinition({
initial: "form",
context: {},
states: {
form: {
initial: "step1",
states: {
step1: { on: { NEXT: { target: "step2" } } },
step2: {},
},
},
},
})
).not.toThrow();
});
it("throws when a transition target in nested states is unknown", {
timeout: 5000,
}, () => {
let err: FSMError | undefined;
try {
validateDefinition({
initial: "form",
context: {},
states: {
form: {
initial: "step1",
states: {
step1: { on: { GO: { target: "ghost" } } },
},
},
},
});
} catch (e) {
err = e as FSMError;
}
expect(err).toBeInstanceOf(FSMError);
expect(err?.code).toBe(FSM_CODES.INVALID_DEFINITION);
});
});

View File

@@ -0,0 +1,129 @@
import { FSM_CODES, FSMError } from "../errors/fsm-error.js";
import type {
FSMDefinition,
StateDefinition,
StateValue,
} from "../types/fsm.types.js";
/**
* Collect all state names from a nested state definition tree.
*/
function collectStateNames<TContext>(
states: Record<StateValue, StateDefinition<TContext>>,
prefix = ""
): Set<string> {
const names = new Set<string>();
for (const [key, def] of Object.entries(states)) {
const fullKey = prefix ? `${prefix}.${key}` : key;
names.add(fullKey);
// Also register the short name for unqualified lookups
if (prefix) names.add(key);
if (def.states) {
for (const name of collectStateNames(def.states, fullKey)) {
names.add(name);
}
}
}
return names;
}
/**
* Collect all transition target references from a definition tree.
*/
function collectTransitionTargets<TContext>(
states: Record<StateValue, StateDefinition<TContext>>
): Array<{ target: StateValue; fromState: StateValue }> {
const refs: Array<{ target: StateValue; fromState: StateValue }> = [];
for (const [stateName, def] of Object.entries(states)) {
if (def.on) {
for (const transitions of Object.values(def.on)) {
const list = Array.isArray(transitions)
? transitions
: [transitions];
for (const t of list) {
refs.push({ target: t.target, fromState: stateName });
}
}
}
if (def.states) {
for (const nested of collectTransitionTargets(def.states)) {
refs.push(nested);
}
}
}
return refs;
}
/**
* Validate an FSM definition for structural correctness.
*
* Checks performed:
* 1. The `initial` state must exist as a top-level key in `states`.
* 2. All `target` values in transition definitions must refer to existing states
* (top-level or nested).
* 3. Any state with `states` must also declare an `initial` child.
*
* @param def - The FSM definition to validate.
* @throws {FSMError} with code `FSM.INVALID_DEFINITION` if any check fails.
*
* @example
* ```ts
* import { validateDefinition } from '@suaveplan/fsm';
*
* validateDefinition({
* initial: 'idle',
* context: {},
* states: { idle: { on: { START: { target: 'active' } } }, active: {} },
* });
* // throws if 'idle' or 'active' are missing
* ```
*/
export function validateDefinition<TContext>(
def: FSMDefinition<TContext>
): void {
// 1. initial state must exist
if (!(def.initial in def.states)) {
throw new FSMError(
FSM_CODES["INVALID_DEFINITION"],
`Initial state "${def.initial}" does not exist in states`,
{ context: { initial: def.initial } }
);
}
// 2. collect all known state names (including nested)
const allNames = collectStateNames(def.states);
// Also add top-level names directly for unambiguous lookup
for (const k of Object.keys(def.states)) {
allNames.add(k);
}
// 3. validate all transition targets
const refs = collectTransitionTargets(def.states);
for (const { target, fromState } of refs) {
if (!allNames.has(target)) {
throw new FSMError(
FSM_CODES["INVALID_DEFINITION"],
`Transition target "${target}" from state "${fromState}" does not exist`,
{ context: { target, fromState } }
);
}
}
// 4. states with children must declare initial
for (const [stateName, def2] of Object.entries(def.states)) {
if (def2.states && !def2.initial) {
throw new FSMError(
FSM_CODES["INVALID_DEFINITION"],
`State "${stateName}" has nested states but no "initial" child declared`,
{ context: { stateName } }
);
}
if (def2.states && def2.initial && !(def2.initial in def2.states)) {
throw new FSMError(
FSM_CODES["INVALID_DEFINITION"],
`Nested initial state "${def2.initial}" in state "${stateName}" does not exist`,
{ context: { stateName, initial: def2.initial } }
);
}
}
}

View File

@@ -0,0 +1,2 @@
export { assign, pure } from "./context-helpers";
export { validateDefinition } from "./definition-validator";

View File

@@ -0,0 +1,45 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
describe("dep-graph", () => {
it("@suaveplan/* runtime deps are exactly error + events + types", {
timeout: 5000,
}, () => {
const pkg = JSON.parse(
readFileSync(join(__dirname, "..", "package.json"), "utf8")
) as {
dependencies?: Record<string, string>;
peerDependencies?: Record<string, string>;
};
const runtimeScoped = Object.keys(pkg.dependencies ?? {})
.filter((name) => name.startsWith("@suaveplan/"))
.sort();
expect(runtimeScoped).toEqual([
"@suaveplan/error",
"@suaveplan/events",
"@suaveplan/types",
]);
const peerScoped = Object.keys(pkg.peerDependencies ?? {}).filter(
(name) => name.startsWith("@suaveplan/")
);
expect(peerScoped).toEqual([]);
});
it("runtime deps are exactly the expected set (sorted)", {
timeout: 5000,
}, () => {
const pkg = JSON.parse(
readFileSync(join(__dirname, "..", "package.json"), "utf8")
) as { dependencies?: Record<string, string> };
const runtime = Object.keys(pkg.dependencies ?? {}).sort();
expect(runtime).toEqual([
"@opentelemetry/api",
"@suaveplan/error",
"@suaveplan/events",
"@suaveplan/types",
"tslib",
"zod",
]);
});
});

View File

@@ -0,0 +1,8 @@
// Ensure `globalThis.crypto` is available in bare-Node vitest runs.
import { webcrypto } from "node:crypto";
type WritableGlobal = { crypto?: Crypto };
const g = globalThis as WritableGlobal;
if (g.crypto === undefined) {
g.crypto = webcrypto as unknown as Crypto;
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,38 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/fsm",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/error",
"@suaveplan/types",
"@suaveplan/events",
"tslib",
"zod",
"@opentelemetry/api",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,25 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"tests/**",
],
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
},
testTimeout: 30000,
},
});