mirror of
https://github.com/robonen/tools.git
synced 2026-03-20 10:54:44 +00:00
feat(core/stdlib): implement LinkedList, PriorityQueue, and Queue data structures
This commit is contained in:
55
core/stdlib/src/patterns/behavioral/Command/async.ts
Normal file
55
core/stdlib/src/patterns/behavioral/Command/async.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { BaseCommandHistory } from './base';
|
||||
import type { AsyncCommand } from './types';
|
||||
|
||||
/**
|
||||
* @name AsyncCommandHistory
|
||||
* @category Patterns
|
||||
* @description Async command history with undo/redo/batch support
|
||||
*
|
||||
* @since 0.0.8
|
||||
*/
|
||||
export class AsyncCommandHistory extends BaseCommandHistory<AsyncCommand> {
|
||||
async execute(command: AsyncCommand): Promise<void> {
|
||||
await command.execute();
|
||||
this.pushUndo(command);
|
||||
}
|
||||
|
||||
async undo(): Promise<boolean> {
|
||||
const command = this.undoStack.pop();
|
||||
|
||||
if (!command)
|
||||
return false;
|
||||
|
||||
await command.undo();
|
||||
this.moveToRedo(command);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async redo(): Promise<boolean> {
|
||||
const command = this.redoStack.pop();
|
||||
|
||||
if (!command)
|
||||
return false;
|
||||
|
||||
await command.execute();
|
||||
this.moveToUndo(command);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async batch(commands: AsyncCommand[]): Promise<void> {
|
||||
const macro: AsyncCommand = {
|
||||
execute: async () => {
|
||||
for (const c of commands)
|
||||
await c.execute();
|
||||
},
|
||||
undo: async () => {
|
||||
for (let i = commands.length - 1; i >= 0; i--)
|
||||
await commands[i]!.undo();
|
||||
},
|
||||
};
|
||||
|
||||
await this.execute(macro);
|
||||
}
|
||||
}
|
||||
49
core/stdlib/src/patterns/behavioral/Command/base.ts
Normal file
49
core/stdlib/src/patterns/behavioral/Command/base.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* @name BaseCommandHistory
|
||||
* @category Patterns
|
||||
* @description Base class with shared undo/redo stack management
|
||||
*
|
||||
* @since 0.0.8
|
||||
*/
|
||||
export abstract class BaseCommandHistory<C> {
|
||||
protected undoStack: C[] = [];
|
||||
protected redoStack: C[] = [];
|
||||
protected readonly maxSize: number;
|
||||
|
||||
constructor(options?: { maxSize?: number }) {
|
||||
this.maxSize = options?.maxSize ?? Infinity;
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.undoStack.length;
|
||||
}
|
||||
|
||||
get canUndo(): boolean {
|
||||
return this.undoStack.length > 0;
|
||||
}
|
||||
|
||||
get canRedo(): boolean {
|
||||
return this.redoStack.length > 0;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.undoStack.length = 0;
|
||||
this.redoStack.length = 0;
|
||||
}
|
||||
|
||||
protected pushUndo(command: C): void {
|
||||
this.undoStack.push(command);
|
||||
this.redoStack.length = 0;
|
||||
|
||||
if (this.undoStack.length > this.maxSize)
|
||||
this.undoStack.splice(0, this.undoStack.length - this.maxSize);
|
||||
}
|
||||
|
||||
protected moveToRedo(command: C): void {
|
||||
this.redoStack.push(command);
|
||||
}
|
||||
|
||||
protected moveToUndo(command: C): void {
|
||||
this.undoStack.push(command);
|
||||
}
|
||||
}
|
||||
281
core/stdlib/src/patterns/behavioral/Command/index.test.ts
Normal file
281
core/stdlib/src/patterns/behavioral/Command/index.test.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { CommandHistory, AsyncCommandHistory } from '.';
|
||||
import type { Command, AsyncCommand } from '.';
|
||||
|
||||
describe('commandHistory', () => {
|
||||
let history: CommandHistory;
|
||||
let items: string[];
|
||||
|
||||
function addItem(item: string): Command {
|
||||
return {
|
||||
execute: () => { items.push(item); },
|
||||
undo: () => { items.pop(); },
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
history = new CommandHistory();
|
||||
items = [];
|
||||
});
|
||||
|
||||
describe('execute', () => {
|
||||
it('executes a command', () => {
|
||||
history.execute(addItem('a'));
|
||||
|
||||
expect(items).toEqual(['a']);
|
||||
});
|
||||
|
||||
it('tracks size', () => {
|
||||
history.execute(addItem('a'));
|
||||
history.execute(addItem('b'));
|
||||
|
||||
expect(history.size).toBe(2);
|
||||
});
|
||||
|
||||
it('clears redo stack on new execute', () => {
|
||||
history.execute(addItem('a'));
|
||||
history.undo();
|
||||
|
||||
expect(history.canRedo).toBe(true);
|
||||
|
||||
history.execute(addItem('b'));
|
||||
|
||||
expect(history.canRedo).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('undo', () => {
|
||||
it('undoes the last command', () => {
|
||||
history.execute(addItem('a'));
|
||||
history.execute(addItem('b'));
|
||||
history.undo();
|
||||
|
||||
expect(items).toEqual(['a']);
|
||||
});
|
||||
|
||||
it('returns true when undo was performed', () => {
|
||||
history.execute(addItem('a'));
|
||||
|
||||
expect(history.undo()).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when nothing to undo', () => {
|
||||
expect(history.undo()).toBe(false);
|
||||
});
|
||||
|
||||
it('multiple undos', () => {
|
||||
history.execute(addItem('a'));
|
||||
history.execute(addItem('b'));
|
||||
history.execute(addItem('c'));
|
||||
history.undo();
|
||||
history.undo();
|
||||
history.undo();
|
||||
|
||||
expect(items).toEqual([]);
|
||||
expect(history.canUndo).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('redo', () => {
|
||||
it('redoes the last undone command', () => {
|
||||
history.execute(addItem('a'));
|
||||
history.undo();
|
||||
history.redo();
|
||||
|
||||
expect(items).toEqual(['a']);
|
||||
});
|
||||
|
||||
it('returns true when redo was performed', () => {
|
||||
history.execute(addItem('a'));
|
||||
history.undo();
|
||||
|
||||
expect(history.redo()).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when nothing to redo', () => {
|
||||
expect(history.redo()).toBe(false);
|
||||
});
|
||||
|
||||
it('undo then redo multiple times', () => {
|
||||
history.execute(addItem('a'));
|
||||
history.execute(addItem('b'));
|
||||
history.undo();
|
||||
history.undo();
|
||||
history.redo();
|
||||
history.redo();
|
||||
|
||||
expect(items).toEqual(['a', 'b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('batch', () => {
|
||||
it('executes multiple commands', () => {
|
||||
history.batch([addItem('a'), addItem('b'), addItem('c')]);
|
||||
|
||||
expect(items).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('undoes all batched commands in one step', () => {
|
||||
history.batch([addItem('a'), addItem('b'), addItem('c')]);
|
||||
history.undo();
|
||||
|
||||
expect(items).toEqual([]);
|
||||
});
|
||||
|
||||
it('counts as single history entry', () => {
|
||||
history.batch([addItem('a'), addItem('b'), addItem('c')]);
|
||||
|
||||
expect(history.size).toBe(1);
|
||||
});
|
||||
|
||||
it('undoes batch in reverse order', () => {
|
||||
const order: string[] = [];
|
||||
|
||||
history.batch([
|
||||
{ execute: () => order.push('exec-1'), undo: () => order.push('undo-1') },
|
||||
{ execute: () => order.push('exec-2'), undo: () => order.push('undo-2') },
|
||||
{ execute: () => order.push('exec-3'), undo: () => order.push('undo-3') },
|
||||
]);
|
||||
history.undo();
|
||||
|
||||
expect(order).toEqual(['exec-1', 'exec-2', 'exec-3', 'undo-3', 'undo-2', 'undo-1']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('maxSize', () => {
|
||||
it('limits undo stack', () => {
|
||||
const limited = new CommandHistory({ maxSize: 2 });
|
||||
items = [];
|
||||
|
||||
limited.execute(addItem('a'));
|
||||
limited.execute(addItem('b'));
|
||||
limited.execute(addItem('c'));
|
||||
|
||||
expect(limited.size).toBe(2);
|
||||
expect(limited.canUndo).toBe(true);
|
||||
|
||||
limited.undo();
|
||||
limited.undo();
|
||||
|
||||
expect(limited.canUndo).toBe(false);
|
||||
expect(items).toEqual(['a']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clear', () => {
|
||||
it('clears all history', () => {
|
||||
history.execute(addItem('a'));
|
||||
history.execute(addItem('b'));
|
||||
history.undo();
|
||||
history.clear();
|
||||
|
||||
expect(history.canUndo).toBe(false);
|
||||
expect(history.canRedo).toBe(false);
|
||||
expect(history.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canUndo / canRedo', () => {
|
||||
it('initially false', () => {
|
||||
expect(history.canUndo).toBe(false);
|
||||
expect(history.canRedo).toBe(false);
|
||||
});
|
||||
|
||||
it('canUndo after execute', () => {
|
||||
history.execute(addItem('a'));
|
||||
|
||||
expect(history.canUndo).toBe(true);
|
||||
expect(history.canRedo).toBe(false);
|
||||
});
|
||||
|
||||
it('canRedo after undo', () => {
|
||||
history.execute(addItem('a'));
|
||||
history.undo();
|
||||
|
||||
expect(history.canUndo).toBe(false);
|
||||
expect(history.canRedo).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('asyncCommandHistory', () => {
|
||||
let history: AsyncCommandHistory;
|
||||
let items: string[];
|
||||
|
||||
function addItemAsync(item: string): AsyncCommand {
|
||||
return {
|
||||
execute: async () => {
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
items.push(item);
|
||||
},
|
||||
undo: async () => {
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
items.pop();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
history = new AsyncCommandHistory();
|
||||
items = [];
|
||||
});
|
||||
|
||||
it('executes async command', async () => {
|
||||
await history.execute(addItemAsync('a'));
|
||||
|
||||
expect(items).toEqual(['a']);
|
||||
});
|
||||
|
||||
it('undoes async command', async () => {
|
||||
await history.execute(addItemAsync('a'));
|
||||
await history.undo();
|
||||
|
||||
expect(items).toEqual([]);
|
||||
});
|
||||
|
||||
it('redoes async command', async () => {
|
||||
await history.execute(addItemAsync('a'));
|
||||
await history.undo();
|
||||
await history.redo();
|
||||
|
||||
expect(items).toEqual(['a']);
|
||||
});
|
||||
|
||||
it('batches async commands', async () => {
|
||||
await history.batch([addItemAsync('a'), addItemAsync('b'), addItemAsync('c')]);
|
||||
|
||||
expect(items).toEqual(['a', 'b', 'c']);
|
||||
expect(history.size).toBe(1);
|
||||
});
|
||||
|
||||
it('undoes async batch', async () => {
|
||||
await history.batch([addItemAsync('a'), addItemAsync('b')]);
|
||||
await history.undo();
|
||||
|
||||
expect(items).toEqual([]);
|
||||
});
|
||||
|
||||
it('works with sync commands too', async () => {
|
||||
await history.execute({
|
||||
execute: () => { items.push('sync'); },
|
||||
undo: () => { items.pop(); },
|
||||
});
|
||||
|
||||
expect(items).toEqual(['sync']);
|
||||
|
||||
await history.undo();
|
||||
|
||||
expect(items).toEqual([]);
|
||||
});
|
||||
|
||||
it('respects maxSize', async () => {
|
||||
const limited = new AsyncCommandHistory({ maxSize: 2 });
|
||||
items = [];
|
||||
|
||||
await limited.execute(addItemAsync('a'));
|
||||
await limited.execute(addItemAsync('b'));
|
||||
await limited.execute(addItemAsync('c'));
|
||||
|
||||
expect(limited.size).toBe(2);
|
||||
});
|
||||
});
|
||||
3
core/stdlib/src/patterns/behavioral/Command/index.ts
Normal file
3
core/stdlib/src/patterns/behavioral/Command/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export type * from './types';
|
||||
export * from './sync';
|
||||
export * from './async';
|
||||
52
core/stdlib/src/patterns/behavioral/Command/sync.ts
Normal file
52
core/stdlib/src/patterns/behavioral/Command/sync.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { BaseCommandHistory } from './base';
|
||||
import type { Command } from './types';
|
||||
|
||||
/**
|
||||
* @name CommandHistory
|
||||
* @category Patterns
|
||||
* @description Command history with undo/redo/batch support
|
||||
*
|
||||
* @since 0.0.8
|
||||
*/
|
||||
export class CommandHistory extends BaseCommandHistory<Command> {
|
||||
execute(command: Command): void {
|
||||
command.execute();
|
||||
this.pushUndo(command);
|
||||
}
|
||||
|
||||
undo(): boolean {
|
||||
const command = this.undoStack.pop();
|
||||
|
||||
if (!command)
|
||||
return false;
|
||||
|
||||
command.undo();
|
||||
this.moveToRedo(command);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
redo(): boolean {
|
||||
const command = this.redoStack.pop();
|
||||
|
||||
if (!command)
|
||||
return false;
|
||||
|
||||
command.execute();
|
||||
this.moveToUndo(command);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
batch(commands: Command[]): void {
|
||||
const macro: Command = {
|
||||
execute: () => commands.forEach((c) => c.execute()),
|
||||
undo: () => {
|
||||
for (let i = commands.length - 1; i >= 0; i--)
|
||||
commands[i]!.undo();
|
||||
},
|
||||
};
|
||||
|
||||
this.execute(macro);
|
||||
}
|
||||
}
|
||||
11
core/stdlib/src/patterns/behavioral/Command/types.ts
Normal file
11
core/stdlib/src/patterns/behavioral/Command/types.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { MaybePromise } from '../../../types';
|
||||
|
||||
export type Command = {
|
||||
execute: () => void;
|
||||
undo: () => void;
|
||||
};
|
||||
|
||||
export type AsyncCommand = {
|
||||
execute: () => MaybePromise<void>;
|
||||
undo: () => MaybePromise<void>;
|
||||
};
|
||||
132
core/stdlib/src/patterns/behavioral/StateMachine/async.ts
Normal file
132
core/stdlib/src/patterns/behavioral/StateMachine/async.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { BaseStateMachine } from './base';
|
||||
import type { AsyncStateNodeConfig, ExtractStates, ExtractEvents } from './types';
|
||||
|
||||
/**
|
||||
* @name AsyncStateMachine
|
||||
* @category Patterns
|
||||
* @description Async finite state machine with support for async guards, actions, and hooks
|
||||
*
|
||||
* @since 0.0.8
|
||||
*
|
||||
* @template States - Union of state names
|
||||
* @template Events - Union of event names
|
||||
* @template Context - Machine context type
|
||||
*/
|
||||
export class AsyncStateMachine<
|
||||
States extends string = string,
|
||||
Events extends string = string,
|
||||
Context = undefined,
|
||||
> extends BaseStateMachine<States, Events, Context, AsyncStateNodeConfig<Context>> {
|
||||
/**
|
||||
* Send an event to the machine, awaiting async guards, actions, and hooks
|
||||
*
|
||||
* @param event - Event name
|
||||
* @returns The current state after processing the event
|
||||
*/
|
||||
async send(event: Events): Promise<States> {
|
||||
const stateNode = this._states[this._current];
|
||||
|
||||
if (!stateNode?.on)
|
||||
return this._current;
|
||||
|
||||
const transition = stateNode.on[event];
|
||||
|
||||
if (transition === undefined)
|
||||
return this._current;
|
||||
|
||||
let target: string;
|
||||
|
||||
if (typeof transition === 'string') {
|
||||
target = transition;
|
||||
} else {
|
||||
if (transition.guard && !(await transition.guard(this._context)))
|
||||
return this._current;
|
||||
|
||||
await transition.action?.(this._context);
|
||||
target = transition.target;
|
||||
}
|
||||
|
||||
await stateNode.exit?.(this._context);
|
||||
this._current = target as States;
|
||||
await this._states[this._current]?.entry?.(this._context);
|
||||
|
||||
return this._current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an event can trigger a transition, awaiting async guards
|
||||
*
|
||||
* @param event - Event to check
|
||||
*/
|
||||
async can(event: Events): Promise<boolean> {
|
||||
const stateNode = this._states[this._current];
|
||||
|
||||
if (!stateNode?.on)
|
||||
return false;
|
||||
|
||||
const transition = stateNode.on[event];
|
||||
|
||||
if (transition === undefined)
|
||||
return false;
|
||||
|
||||
if (typeof transition !== 'string' && transition.guard)
|
||||
return await transition.guard(this._context);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a type-safe async finite state machine with context
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const machine = createAsyncMachine({
|
||||
* initial: 'idle',
|
||||
* context: { data: '' },
|
||||
* states: {
|
||||
* idle: {
|
||||
* on: {
|
||||
* FETCH: {
|
||||
* target: 'loaded',
|
||||
* guard: async () => await hasPermission(),
|
||||
* action: async (ctx) => { ctx.data = await fetchData(); },
|
||||
* },
|
||||
* },
|
||||
* },
|
||||
* loaded: {
|
||||
* entry: async (ctx) => { await saveToCache(ctx.data); },
|
||||
* },
|
||||
* },
|
||||
* });
|
||||
*
|
||||
* await machine.send('FETCH'); // 'loaded'
|
||||
* ```
|
||||
*/
|
||||
export function createAsyncMachine<
|
||||
const States extends Record<string, AsyncStateNodeConfig<Context>>,
|
||||
Context,
|
||||
>(config: {
|
||||
initial: NoInfer<ExtractStates<States>>;
|
||||
context: Context;
|
||||
states: States;
|
||||
}): AsyncStateMachine<ExtractStates<States>, ExtractEvents<States>, Context>;
|
||||
|
||||
export function createAsyncMachine<
|
||||
const States extends Record<string, AsyncStateNodeConfig<undefined>>,
|
||||
>(config: {
|
||||
initial: NoInfer<ExtractStates<States>>;
|
||||
states: States;
|
||||
}): AsyncStateMachine<ExtractStates<States>, ExtractEvents<States>, undefined>;
|
||||
|
||||
export function createAsyncMachine(config: {
|
||||
initial: string;
|
||||
context?: unknown;
|
||||
states: Record<string, AsyncStateNodeConfig<any>>;
|
||||
}): AsyncStateMachine {
|
||||
return new AsyncStateMachine(
|
||||
config.initial,
|
||||
config.states,
|
||||
config.context as undefined,
|
||||
);
|
||||
}
|
||||
47
core/stdlib/src/patterns/behavioral/StateMachine/base.ts
Normal file
47
core/stdlib/src/patterns/behavioral/StateMachine/base.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Base class for state machines — holds shared state, getters, and matches()
|
||||
*
|
||||
* @template States - Union of state names
|
||||
* @template Events - Union of event names
|
||||
* @template Context - Machine context type
|
||||
* @template NodeConfig - State node configuration type
|
||||
*/
|
||||
export class BaseStateMachine<
|
||||
States extends string,
|
||||
Events extends string,
|
||||
Context,
|
||||
NodeConfig,
|
||||
> {
|
||||
protected _current: States;
|
||||
protected _context: Context;
|
||||
protected _states: Record<string, NodeConfig>;
|
||||
|
||||
constructor(
|
||||
initial: States,
|
||||
states: Record<string, NodeConfig>,
|
||||
context: Context,
|
||||
) {
|
||||
this._current = initial;
|
||||
this._context = context;
|
||||
this._states = states;
|
||||
}
|
||||
|
||||
/** Current state of the machine */
|
||||
get current(): States {
|
||||
return this._current;
|
||||
}
|
||||
|
||||
/** Machine context */
|
||||
get context(): Context {
|
||||
return this._context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the machine is in a specific state
|
||||
*
|
||||
* @param state - State to check
|
||||
*/
|
||||
matches(state: States): boolean {
|
||||
return this._current === state;
|
||||
}
|
||||
}
|
||||
688
core/stdlib/src/patterns/behavioral/StateMachine/index.test.ts
Normal file
688
core/stdlib/src/patterns/behavioral/StateMachine/index.test.ts
Normal file
@@ -0,0 +1,688 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { createMachine, createAsyncMachine, StateMachine, AsyncStateMachine } from '.';
|
||||
|
||||
describe('stateMachine', () => {
|
||||
describe('createMachine (without context)', () => {
|
||||
let machine: ReturnType<typeof createSimpleMachine>;
|
||||
|
||||
function createSimpleMachine() {
|
||||
return createMachine({
|
||||
initial: 'idle',
|
||||
states: {
|
||||
idle: {
|
||||
on: {
|
||||
START: 'running',
|
||||
},
|
||||
},
|
||||
running: {
|
||||
on: {
|
||||
STOP: 'idle',
|
||||
PAUSE: 'paused',
|
||||
},
|
||||
},
|
||||
paused: {
|
||||
on: {
|
||||
RESUME: 'running',
|
||||
STOP: 'idle',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
machine = createSimpleMachine();
|
||||
});
|
||||
|
||||
it('initializes with the initial state', () => {
|
||||
expect(machine.current).toBe('idle');
|
||||
});
|
||||
|
||||
it('transitions on send', () => {
|
||||
machine.send('START');
|
||||
|
||||
expect(machine.current).toBe('running');
|
||||
});
|
||||
|
||||
it('returns new state from send', () => {
|
||||
const result = machine.send('START');
|
||||
|
||||
expect(result).toBe('running');
|
||||
});
|
||||
|
||||
it('handles multiple transitions', () => {
|
||||
machine.send('START');
|
||||
machine.send('PAUSE');
|
||||
machine.send('RESUME');
|
||||
machine.send('STOP');
|
||||
|
||||
expect(machine.current).toBe('idle');
|
||||
});
|
||||
|
||||
it('ignores unhandled events', () => {
|
||||
machine.send('STOP');
|
||||
|
||||
expect(machine.current).toBe('idle');
|
||||
});
|
||||
|
||||
it('ignores events not defined in current state', () => {
|
||||
machine.send('PAUSE');
|
||||
|
||||
expect(machine.current).toBe('idle');
|
||||
});
|
||||
|
||||
it('matches current state', () => {
|
||||
expect(machine.matches('idle')).toBe(true);
|
||||
expect(machine.matches('running')).toBe(false);
|
||||
|
||||
machine.send('START');
|
||||
|
||||
expect(machine.matches('idle')).toBe(false);
|
||||
expect(machine.matches('running')).toBe(true);
|
||||
});
|
||||
|
||||
it('checks if event can be handled', () => {
|
||||
expect(machine.can('START')).toBe(true);
|
||||
expect(machine.can('STOP')).toBe(false);
|
||||
expect(machine.can('PAUSE')).toBe(false);
|
||||
|
||||
machine.send('START');
|
||||
|
||||
expect(machine.can('START')).toBe(false);
|
||||
expect(machine.can('STOP')).toBe(true);
|
||||
expect(machine.can('PAUSE')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createMachine (with context)', () => {
|
||||
function createContextMachine() {
|
||||
return createMachine({
|
||||
initial: 'idle',
|
||||
context: { count: 0, log: '' },
|
||||
states: {
|
||||
idle: {
|
||||
on: {
|
||||
START: {
|
||||
target: 'running',
|
||||
action: (ctx) => { ctx.count = 0; },
|
||||
},
|
||||
},
|
||||
},
|
||||
running: {
|
||||
on: {
|
||||
INCREMENT: {
|
||||
target: 'running',
|
||||
action: (ctx) => { ctx.count++; },
|
||||
},
|
||||
STOP: 'idle',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
it('provides typed context', () => {
|
||||
const machine = createContextMachine();
|
||||
|
||||
expect(machine.context).toEqual({ count: 0, log: '' });
|
||||
});
|
||||
|
||||
it('runs action on transition', () => {
|
||||
const machine = createContextMachine();
|
||||
|
||||
machine.send('START');
|
||||
machine.send('INCREMENT');
|
||||
machine.send('INCREMENT');
|
||||
machine.send('INCREMENT');
|
||||
|
||||
expect(machine.context.count).toBe(3);
|
||||
});
|
||||
|
||||
it('resets context via action', () => {
|
||||
const machine = createContextMachine();
|
||||
|
||||
machine.send('START');
|
||||
machine.send('INCREMENT');
|
||||
machine.send('INCREMENT');
|
||||
machine.send('STOP');
|
||||
machine.send('START');
|
||||
|
||||
expect(machine.context.count).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('guards', () => {
|
||||
function createGuardedMachine() {
|
||||
return createMachine({
|
||||
initial: 'idle',
|
||||
context: { retries: 0 },
|
||||
states: {
|
||||
idle: {
|
||||
on: {
|
||||
TRY: {
|
||||
target: 'attempting',
|
||||
action: (ctx) => { ctx.retries++; },
|
||||
},
|
||||
},
|
||||
},
|
||||
attempting: {
|
||||
on: {
|
||||
FAIL: {
|
||||
target: 'idle',
|
||||
guard: (ctx) => ctx.retries < 3,
|
||||
},
|
||||
SUCCESS: 'done',
|
||||
},
|
||||
},
|
||||
done: {},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
it('allows transition when guard returns true', () => {
|
||||
const machine = createGuardedMachine();
|
||||
|
||||
machine.send('TRY');
|
||||
machine.send('FAIL');
|
||||
|
||||
expect(machine.current).toBe('idle');
|
||||
expect(machine.context.retries).toBe(1);
|
||||
});
|
||||
|
||||
it('blocks transition when guard returns false', () => {
|
||||
const machine = createGuardedMachine();
|
||||
|
||||
machine.send('TRY');
|
||||
machine.send('FAIL');
|
||||
machine.send('TRY');
|
||||
machine.send('FAIL');
|
||||
machine.send('TRY');
|
||||
machine.send('FAIL');
|
||||
|
||||
expect(machine.current).toBe('attempting');
|
||||
expect(machine.context.retries).toBe(3);
|
||||
});
|
||||
|
||||
it('reflects guard in can()', () => {
|
||||
const machine = createGuardedMachine();
|
||||
|
||||
machine.send('TRY');
|
||||
expect(machine.can('FAIL')).toBe(true);
|
||||
|
||||
machine.send('FAIL');
|
||||
machine.send('TRY');
|
||||
machine.send('FAIL');
|
||||
machine.send('TRY');
|
||||
|
||||
expect(machine.can('FAIL')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('entry/exit hooks', () => {
|
||||
it('calls exit on previous state and entry on next state', () => {
|
||||
const exitIdle = vi.fn();
|
||||
const enterRunning = vi.fn();
|
||||
|
||||
const machine = createMachine({
|
||||
initial: 'idle',
|
||||
states: {
|
||||
idle: {
|
||||
on: { START: 'running' },
|
||||
exit: exitIdle,
|
||||
},
|
||||
running: {
|
||||
on: { STOP: 'idle' },
|
||||
entry: enterRunning,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
machine.send('START');
|
||||
|
||||
expect(exitIdle).toHaveBeenCalledOnce();
|
||||
expect(enterRunning).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does not call hooks when transition is blocked by guard', () => {
|
||||
const exitHook = vi.fn();
|
||||
const entryHook = vi.fn();
|
||||
|
||||
const machine = createMachine({
|
||||
initial: 'locked',
|
||||
context: { unlocked: false },
|
||||
states: {
|
||||
locked: {
|
||||
on: {
|
||||
UNLOCK: {
|
||||
target: 'unlocked',
|
||||
guard: (ctx) => ctx.unlocked,
|
||||
},
|
||||
},
|
||||
exit: exitHook,
|
||||
},
|
||||
unlocked: {
|
||||
entry: entryHook,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
machine.send('UNLOCK');
|
||||
|
||||
expect(exitHook).not.toHaveBeenCalled();
|
||||
expect(entryHook).not.toHaveBeenCalled();
|
||||
expect(machine.current).toBe('locked');
|
||||
});
|
||||
|
||||
it('calls hooks with context', () => {
|
||||
const entryHook = vi.fn();
|
||||
|
||||
const machine = createMachine({
|
||||
initial: 'idle',
|
||||
context: { value: 42 },
|
||||
states: {
|
||||
idle: {
|
||||
on: { GO: 'active' },
|
||||
},
|
||||
active: {
|
||||
entry: entryHook,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
machine.send('GO');
|
||||
|
||||
expect(entryHook).toHaveBeenCalledWith({ value: 42 });
|
||||
});
|
||||
|
||||
it('calls exit and entry on self-transitions', () => {
|
||||
const exitHook = vi.fn();
|
||||
const entryHook = vi.fn();
|
||||
|
||||
const machine = createMachine({
|
||||
initial: 'active',
|
||||
states: {
|
||||
active: {
|
||||
on: { REFRESH: 'active' },
|
||||
entry: entryHook,
|
||||
exit: exitHook,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
machine.send('REFRESH');
|
||||
|
||||
expect(exitHook).toHaveBeenCalledOnce();
|
||||
expect(entryHook).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe('StateMachine class', () => {
|
||||
it('can be instantiated directly', () => {
|
||||
const machine = new StateMachine<'on' | 'off', 'TOGGLE'>(
|
||||
'off',
|
||||
{
|
||||
off: { on: { TOGGLE: 'on' } },
|
||||
on: { on: { TOGGLE: 'off' } },
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(machine.current).toBe('off');
|
||||
|
||||
machine.send('TOGGLE');
|
||||
|
||||
expect(machine.current).toBe('on');
|
||||
|
||||
machine.send('TOGGLE');
|
||||
|
||||
expect(machine.current).toBe('off');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('handles state with no transitions', () => {
|
||||
const machine = createMachine({
|
||||
initial: 'start',
|
||||
states: {
|
||||
start: {
|
||||
on: { GO: 'end' },
|
||||
},
|
||||
end: {},
|
||||
},
|
||||
});
|
||||
|
||||
machine.send('GO');
|
||||
|
||||
expect(machine.current).toBe('end');
|
||||
expect(machine.send('GO')).toBe('end');
|
||||
});
|
||||
|
||||
it('handles action that modifies context before guard on next transition', () => {
|
||||
const machine = createMachine({
|
||||
initial: 'a',
|
||||
context: { step: 0 },
|
||||
states: {
|
||||
a: {
|
||||
on: {
|
||||
NEXT: {
|
||||
target: 'b',
|
||||
action: (ctx) => { ctx.step = 1; },
|
||||
},
|
||||
},
|
||||
},
|
||||
b: {
|
||||
on: {
|
||||
NEXT: {
|
||||
target: 'c',
|
||||
guard: (ctx) => ctx.step === 1,
|
||||
action: (ctx) => { ctx.step = 2; },
|
||||
},
|
||||
},
|
||||
},
|
||||
c: {},
|
||||
},
|
||||
});
|
||||
|
||||
machine.send('NEXT');
|
||||
machine.send('NEXT');
|
||||
|
||||
expect(machine.current).toBe('c');
|
||||
expect(machine.context.step).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('asyncStateMachine', () => {
|
||||
describe('createAsyncMachine (without context)', () => {
|
||||
it('handles simple string transitions', async () => {
|
||||
const machine = createAsyncMachine({
|
||||
initial: 'idle',
|
||||
states: {
|
||||
idle: { on: { START: 'running' } },
|
||||
running: { on: { STOP: 'idle' } },
|
||||
},
|
||||
});
|
||||
|
||||
const result = await machine.send('START');
|
||||
|
||||
expect(result).toBe('running');
|
||||
expect(machine.current).toBe('running');
|
||||
});
|
||||
|
||||
it('ignores unhandled events', async () => {
|
||||
const machine = createAsyncMachine({
|
||||
initial: 'idle',
|
||||
states: {
|
||||
idle: { on: { START: 'running' } },
|
||||
running: {},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await machine.send('STOP');
|
||||
|
||||
expect(result).toBe('idle');
|
||||
});
|
||||
});
|
||||
|
||||
describe('async guards', () => {
|
||||
it('allows transition on async guard returning true', async () => {
|
||||
const machine = createAsyncMachine({
|
||||
initial: 'idle',
|
||||
context: { allowed: true },
|
||||
states: {
|
||||
idle: {
|
||||
on: {
|
||||
GO: {
|
||||
target: 'active',
|
||||
guard: async (ctx) => ctx.allowed,
|
||||
},
|
||||
},
|
||||
},
|
||||
active: {},
|
||||
},
|
||||
});
|
||||
|
||||
await machine.send('GO');
|
||||
|
||||
expect(machine.current).toBe('active');
|
||||
});
|
||||
|
||||
it('blocks transition on async guard returning false', async () => {
|
||||
const machine = createAsyncMachine({
|
||||
initial: 'idle',
|
||||
context: { allowed: false },
|
||||
states: {
|
||||
idle: {
|
||||
on: {
|
||||
GO: {
|
||||
target: 'active',
|
||||
guard: async (ctx) => ctx.allowed,
|
||||
},
|
||||
},
|
||||
},
|
||||
active: {},
|
||||
},
|
||||
});
|
||||
|
||||
await machine.send('GO');
|
||||
|
||||
expect(machine.current).toBe('idle');
|
||||
});
|
||||
});
|
||||
|
||||
describe('async actions', () => {
|
||||
it('awaits async action before entering target', async () => {
|
||||
const order: string[] = [];
|
||||
|
||||
const machine = createAsyncMachine({
|
||||
initial: 'idle',
|
||||
context: { data: '' },
|
||||
states: {
|
||||
idle: {
|
||||
on: {
|
||||
FETCH: {
|
||||
target: 'done',
|
||||
action: async (ctx) => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
ctx.data = 'fetched';
|
||||
order.push('action');
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
done: {
|
||||
entry: () => { order.push('entry'); },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await machine.send('FETCH');
|
||||
|
||||
expect(machine.context.data).toBe('fetched');
|
||||
expect(order).toEqual(['action', 'entry']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('async entry/exit hooks', () => {
|
||||
it('awaits async exit and entry hooks in order', async () => {
|
||||
const order: string[] = [];
|
||||
|
||||
const machine = createAsyncMachine({
|
||||
initial: 'a',
|
||||
states: {
|
||||
a: {
|
||||
on: { GO: 'b' },
|
||||
exit: async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
order.push('exit-a');
|
||||
},
|
||||
},
|
||||
b: {
|
||||
entry: async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
order.push('entry-b');
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await machine.send('GO');
|
||||
|
||||
expect(machine.current).toBe('b');
|
||||
expect(order).toEqual(['exit-a', 'entry-b']);
|
||||
});
|
||||
|
||||
it('does not call hooks when async guard blocks', async () => {
|
||||
const exitHook = vi.fn();
|
||||
const entryHook = vi.fn();
|
||||
|
||||
const machine = createAsyncMachine({
|
||||
initial: 'locked',
|
||||
context: { unlocked: false },
|
||||
states: {
|
||||
locked: {
|
||||
on: {
|
||||
UNLOCK: {
|
||||
target: 'unlocked',
|
||||
guard: async (ctx) => ctx.unlocked,
|
||||
},
|
||||
},
|
||||
exit: exitHook,
|
||||
},
|
||||
unlocked: {
|
||||
entry: entryHook,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await machine.send('UNLOCK');
|
||||
|
||||
expect(exitHook).not.toHaveBeenCalled();
|
||||
expect(entryHook).not.toHaveBeenCalled();
|
||||
expect(machine.current).toBe('locked');
|
||||
});
|
||||
});
|
||||
|
||||
describe('can()', () => {
|
||||
it('evaluates async guard', async () => {
|
||||
const machine = createAsyncMachine({
|
||||
initial: 'idle',
|
||||
context: { ready: true },
|
||||
states: {
|
||||
idle: {
|
||||
on: {
|
||||
GO: {
|
||||
target: 'active',
|
||||
guard: async (ctx) => ctx.ready,
|
||||
},
|
||||
},
|
||||
},
|
||||
active: {},
|
||||
},
|
||||
});
|
||||
|
||||
expect(await machine.can('GO')).toBe(true);
|
||||
|
||||
machine.context.ready = false;
|
||||
|
||||
expect(await machine.can('GO')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for undefined events', async () => {
|
||||
const machine = createAsyncMachine({
|
||||
initial: 'idle',
|
||||
states: {
|
||||
idle: { on: { START: 'running' } },
|
||||
running: {},
|
||||
},
|
||||
});
|
||||
|
||||
expect(await machine.can('STOP')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for transitions without guard', async () => {
|
||||
const machine = createAsyncMachine({
|
||||
initial: 'idle',
|
||||
states: {
|
||||
idle: { on: { START: 'running' } },
|
||||
running: {},
|
||||
},
|
||||
});
|
||||
|
||||
expect(await machine.can('START')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('matches()', () => {
|
||||
it('checks current state synchronously', async () => {
|
||||
const machine = createAsyncMachine({
|
||||
initial: 'idle',
|
||||
states: {
|
||||
idle: { on: { GO: 'active' } },
|
||||
active: {},
|
||||
},
|
||||
});
|
||||
|
||||
expect(machine.matches('idle')).toBe(true);
|
||||
|
||||
await machine.send('GO');
|
||||
|
||||
expect(machine.matches('active')).toBe(true);
|
||||
expect(machine.matches('idle')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AsyncStateMachine class', () => {
|
||||
it('can be instantiated directly', async () => {
|
||||
const machine = new AsyncStateMachine<'on' | 'off', 'TOGGLE'>(
|
||||
'off',
|
||||
{
|
||||
off: { on: { TOGGLE: 'on' } },
|
||||
on: { on: { TOGGLE: 'off' } },
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(machine.current).toBe('off');
|
||||
|
||||
await machine.send('TOGGLE');
|
||||
|
||||
expect(machine.current).toBe('on');
|
||||
|
||||
await machine.send('TOGGLE');
|
||||
|
||||
expect(machine.current).toBe('off');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sync callbacks work too', () => {
|
||||
it('handles sync guard/action/hooks in async machine', async () => {
|
||||
const entryHook = vi.fn();
|
||||
|
||||
const machine = createAsyncMachine({
|
||||
initial: 'idle',
|
||||
context: { count: 0 },
|
||||
states: {
|
||||
idle: {
|
||||
on: {
|
||||
GO: {
|
||||
target: 'active',
|
||||
guard: (ctx) => ctx.count === 0,
|
||||
action: (ctx) => { ctx.count++; },
|
||||
},
|
||||
},
|
||||
},
|
||||
active: {
|
||||
entry: entryHook,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await machine.send('GO');
|
||||
|
||||
expect(machine.current).toBe('active');
|
||||
expect(machine.context.count).toBe(1);
|
||||
expect(entryHook).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
export type * from './types';
|
||||
export * from './sync';
|
||||
export * from './async';
|
||||
133
core/stdlib/src/patterns/behavioral/StateMachine/sync.ts
Normal file
133
core/stdlib/src/patterns/behavioral/StateMachine/sync.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { BaseStateMachine } from './base';
|
||||
import type { SyncStateNodeConfig, ExtractStates, ExtractEvents } from './types';
|
||||
|
||||
/**
|
||||
* @name StateMachine
|
||||
* @category Patterns
|
||||
* @description Simple, performant, and type-safe finite state machine
|
||||
*
|
||||
* @since 0.0.8
|
||||
*
|
||||
* @template States - Union of state names
|
||||
* @template Events - Union of event names
|
||||
* @template Context - Machine context type
|
||||
*/
|
||||
export class StateMachine<
|
||||
States extends string = string,
|
||||
Events extends string = string,
|
||||
Context = undefined,
|
||||
> extends BaseStateMachine<States, Events, Context, SyncStateNodeConfig<Context>> {
|
||||
/**
|
||||
* Send an event to the machine, potentially causing a state transition
|
||||
*
|
||||
* @param event - Event name
|
||||
* @returns The current state after processing the event
|
||||
*/
|
||||
send(event: Events): States {
|
||||
const stateNode = this._states[this._current];
|
||||
|
||||
if (!stateNode?.on)
|
||||
return this._current;
|
||||
|
||||
const transition = stateNode.on[event];
|
||||
|
||||
if (transition === undefined)
|
||||
return this._current;
|
||||
|
||||
let target: string;
|
||||
|
||||
if (typeof transition === 'string') {
|
||||
target = transition;
|
||||
} else {
|
||||
if (transition.guard && !transition.guard(this._context))
|
||||
return this._current;
|
||||
|
||||
transition.action?.(this._context);
|
||||
target = transition.target;
|
||||
}
|
||||
|
||||
stateNode.exit?.(this._context);
|
||||
this._current = target as States;
|
||||
this._states[this._current]?.entry?.(this._context);
|
||||
|
||||
return this._current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an event can trigger a transition from the current state
|
||||
*
|
||||
* @param event - Event to check
|
||||
*/
|
||||
can(event: Events): boolean {
|
||||
const stateNode = this._states[this._current];
|
||||
|
||||
if (!stateNode?.on)
|
||||
return false;
|
||||
|
||||
const transition = stateNode.on[event];
|
||||
|
||||
if (transition === undefined)
|
||||
return false;
|
||||
|
||||
if (typeof transition !== 'string' && transition.guard)
|
||||
return transition.guard(this._context);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a type-safe synchronous finite state machine with context
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const machine = createMachine({
|
||||
* initial: 'idle',
|
||||
* context: { retries: 0 },
|
||||
* states: {
|
||||
* idle: {
|
||||
* on: { START: 'running' },
|
||||
* },
|
||||
* running: {
|
||||
* on: {
|
||||
* FAIL: {
|
||||
* target: 'idle',
|
||||
* guard: (ctx) => ctx.retries < 3,
|
||||
* action: (ctx) => { ctx.retries++; },
|
||||
* },
|
||||
* STOP: 'idle',
|
||||
* },
|
||||
* },
|
||||
* },
|
||||
* });
|
||||
*
|
||||
* machine.send('START'); // 'running'
|
||||
* ```
|
||||
*/
|
||||
export function createMachine<
|
||||
const States extends Record<string, SyncStateNodeConfig<Context>>,
|
||||
Context,
|
||||
>(config: {
|
||||
initial: NoInfer<ExtractStates<States>>;
|
||||
context: Context;
|
||||
states: States;
|
||||
}): StateMachine<ExtractStates<States>, ExtractEvents<States>, Context>;
|
||||
|
||||
export function createMachine<
|
||||
const States extends Record<string, SyncStateNodeConfig<undefined>>,
|
||||
>(config: {
|
||||
initial: NoInfer<ExtractStates<States>>;
|
||||
states: States;
|
||||
}): StateMachine<ExtractStates<States>, ExtractEvents<States>, undefined>;
|
||||
|
||||
export function createMachine(config: {
|
||||
initial: string;
|
||||
context?: unknown;
|
||||
states: Record<string, SyncStateNodeConfig<any>>;
|
||||
}): StateMachine {
|
||||
return new StateMachine(
|
||||
config.initial,
|
||||
config.states,
|
||||
config.context as undefined,
|
||||
);
|
||||
}
|
||||
64
core/stdlib/src/patterns/behavioral/StateMachine/types.ts
Normal file
64
core/stdlib/src/patterns/behavioral/StateMachine/types.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type { MaybePromise } from '../../../types';
|
||||
|
||||
/**
|
||||
* Configuration for a state transition
|
||||
*
|
||||
* @template Context - Machine context type
|
||||
* @template Guard - Guard return type (boolean or MaybePromise\<boolean\>)
|
||||
* @template Action - Action return type (void or MaybePromise\<void\>)
|
||||
*/
|
||||
export type TransitionConfig<
|
||||
Context,
|
||||
Guard = boolean,
|
||||
Action = void,
|
||||
> = {
|
||||
/** Target state to transition to */
|
||||
target: string;
|
||||
/** Guard condition — transition only occurs if this returns true */
|
||||
guard?: (context: Context) => Guard;
|
||||
/** Side effect executed during transition (before entering target state) */
|
||||
action?: (context: Context) => Action;
|
||||
};
|
||||
|
||||
/**
|
||||
* A transition can be a target state name or a detailed configuration
|
||||
*/
|
||||
export type Transition<
|
||||
Context,
|
||||
Guard = boolean,
|
||||
Action = void,
|
||||
> = string | TransitionConfig<Context, Guard, Action>;
|
||||
|
||||
/**
|
||||
* Configuration for a state node
|
||||
*
|
||||
* @template Context - Machine context type
|
||||
* @template Guard - Guard return type
|
||||
* @template Hook - Hook return type (entry/exit/action)
|
||||
*/
|
||||
export type StateNodeConfig<
|
||||
Context,
|
||||
Guard = boolean,
|
||||
Hook = void,
|
||||
> = {
|
||||
/** Map of event names to transitions */
|
||||
on?: Record<string, Transition<Context, Guard, Hook>>;
|
||||
/** Hook called when entering this state */
|
||||
entry?: (context: Context) => Hook;
|
||||
/** Hook called when exiting this state */
|
||||
exit?: (context: Context) => Hook;
|
||||
};
|
||||
|
||||
/** Sync state node config — guards return boolean, hooks return void */
|
||||
export type SyncStateNodeConfig<Context> = StateNodeConfig<Context, boolean, void>;
|
||||
|
||||
/** Async state node config — guards return MaybePromise\<boolean\>, hooks return MaybePromise\<void\> */
|
||||
export type AsyncStateNodeConfig<Context> = StateNodeConfig<Context, MaybePromise<boolean>, MaybePromise<void>>;
|
||||
|
||||
export type ExtractStates<T> = keyof T & string;
|
||||
|
||||
export type ExtractEvents<T> = {
|
||||
[K in keyof T]: T[K] extends { readonly on?: Readonly<Record<infer E extends string, any>> }
|
||||
? E
|
||||
: never;
|
||||
}[keyof T];
|
||||
@@ -1,9 +1,3 @@
|
||||
import type { AnyFunction } from '../../../types';
|
||||
|
||||
export type Subscriber = AnyFunction;
|
||||
|
||||
export type EventHandlerMap = Record<PropertyKey, Subscriber>;
|
||||
|
||||
/**
|
||||
* @name PubSub
|
||||
* @category Patterns
|
||||
@@ -11,9 +5,9 @@ export type EventHandlerMap = Record<PropertyKey, Subscriber>;
|
||||
*
|
||||
* @since 0.0.2
|
||||
*
|
||||
* @template Events - Event map where all values are function types
|
||||
* @template Events - Event map where keys are event names and values are listener signatures
|
||||
*/
|
||||
export class PubSub<Events extends EventHandlerMap> {
|
||||
export class PubSub<Events extends Record<string, (...args: any[]) => any>> {
|
||||
/**
|
||||
* Events map
|
||||
*
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
export * from './behavioral/pubsub';
|
||||
export * from './behavioral/Command';
|
||||
export * from './behavioral/PubSub';
|
||||
export * from './behavioral/StateMachine';
|
||||
Reference in New Issue
Block a user