/**
* @file Higher-Level Abstractions for @doeixd/machine
* @description
* This module provides a collection of powerful, pre-built patterns and primitives
* on top of the core `@doeixd/machine` library. These utilities are designed to
* solve common, recurring problems in state management, such as data fetching,
* hierarchical state, and toggling boolean context properties.
*
* Think of this as the "standard library" of common machine patterns.
*/
import { MachineBase } from './base'; // Import from base to avoid circular dependency
import {
Machine,
Transitions,
// AsyncMachine,
setContext,
Context,
// MaybePromise,
} from './index';
// =============================================================================
// SECTION 1: CUSTOM PRIMITIVES FOR COMPOSITION
// =============================================================================
/**
* A type utility to infer the child machine type from a parent.
*/
type ChildMachine
= P extends MachineBase<{ child: infer C }> ? C : never;
type BooleanKey = {
[K in keyof T]-?: T[K] extends boolean ? K : never
}[keyof T];
/**
* Creates a transition method that delegates a call to a child machine.
*
* This is a higher-order function that reduces boilerplate when implementing
* hierarchical state machines. It generates a method for the parent machine that:
* 1. Checks if the specified action exists on the current child state.
* 2. If it exists, calls the action on the child.
* 3. Reconstructs the parent machine with the new child state returned by the action.
* 4. If the action doesn't exist on the child, it returns the parent machine unchanged.
*
* @template P - The parent machine type, which must have a `child` property in its context.
* @template K - The name of the action on the child machine to delegate to.
* @param actionName - The string name of the child's transition method.
* @param ...args - Any arguments to pass to the child's transition method.
* @returns The parent machine instance, with its `child` state potentially updated.
*
* @example
* ```typescript
* class Parent extends MachineBase<{ child: ChildMachine }> {
* // Instead of writing a manual delegation method...
* // save = () => {
* // if ('save' in this.context.child) {
* // const newChild = this.context.child.save();
* // return setContext(this, { child: newChild });
* // }
* // return this;
* // }
*
* // ...you can just use the primitive.
* save = delegateToChild('save');
* edit = delegateToChild('edit');
* }
* ```
*/
export function delegateToChild<
P extends MachineBase<{ child: MachineBase }>,
K extends keyof ChildMachine & string
>(
actionName: K
): (
...args: ChildMachine
[K] extends (...a: infer A) => any ? A : never
) => P {
return function(this: P, ...args: any[]): P {
const child = this.context.child as any;
if (typeof child[actionName] === 'function') {
const newChildState = child[actionName].apply(child, args);
return setContext(this as any, { ...this.context, child: newChildState }) as P;
}
// If the action is not available on the current child state, do nothing.
return this;
};
}
/**
* Creates a transition method that toggles a boolean property within the machine's context.
*
* This is a simple utility to reduce boilerplate for managing boolean flags.
*
* @template M - The machine type.
* @template K - The key of the boolean property in the machine's context.
* @param prop - The string name of the context property to toggle.
* @returns A new machine instance with the toggled property.
*
* @example
* ```typescript
* class SettingsMachine extends MachineBase<{ notifications: boolean; darkMode: boolean }> {
* toggleNotifications = toggle('notifications');
* toggleDarkMode = toggle('darkMode');
* }
* ```
*/
export function toggle<
M extends MachineBase,
K extends BooleanKey>
>(
prop: K
): (this: M) => M {
return function(this: M): M {
if (typeof this.context[prop] !== 'boolean') {
throw new TypeError(`Cannot toggle non-boolean context property '${String(prop)}'.`);
}
return setContext(this as any, {
...this.context,
[prop]: !this.context[prop],
}) as M;
};
}
// =============================================================================
// SECTION 2: PRE-BUILT, CUSTOMIZABLE MACHINES
// =============================================================================
/**
* A fully-featured, pre-built state machine for data fetching.
* It handles loading, success, error states, cancellation, and retry logic out of the box.
*
* This machine is highly customizable through its configuration options.
*/
// --- Types for the Fetch Machine ---
/**
* Abort-aware request function consumed by {@link createFetchMachine}.
*
* @typeParam T - Successful data type.
* @typeParam P - Request parameter type.
*/
export type Fetcher = (
params: P,
options: { signal: AbortSignal }
) => Promise;
/**
* Success callback invoked once when a fetch attempt resolves.
* @typeParam T - Successful data type.
*/
export type OnSuccess = (data: T) => void;
/**
* Final-failure callback invoked after the retry budget is exhausted.
* @typeParam E - Normalized error type.
*/
export type OnError = (error: E) => void;
/**
* Configuration for {@link createFetchMachine}.
*
* @typeParam T - Successful data type.
* @typeParam E - Normalized error type exposed by error typestates.
* @typeParam P - Parameters accepted by `fetch`, `retry`, and `refetch`.
*/
export interface FetchMachineConfig {
/** Performs one request and should honor the supplied abort signal. */
fetcher: Fetcher;
/** Used when a transition does not supply explicit parameters. */
initialParams?: P;
/** Retries available after the first failed attempt. Defaults to `3`. */
maxRetries?: number;
/** Observes successful data before the success snapshot is returned. */
onSuccess?: OnSuccess;
/** Observes the normalized error after no retries remain. */
onError?: OnError;
/** Converts unknown thrown values into the declared error type. */
mapError?: (error: unknown) => E;
}
// --- Contexts for Fetch States ---
type IdleContext = { status: 'idle' };
type LoadingContext = { status: 'loading'; abortController: AbortController; attempts: number };
type RetryingContext = { status: 'retrying'; error: E; attempts: number };
type SuccessContext = { status: 'success'; data: T };
type ErrorContext = { status: 'error'; error: E };
type CanceledContext = { status: 'canceled' };
// --- Machine State Classes (internal) ---
class IdleMachine extends MachineBase {
constructor(private config: FetchMachineConfig) { super({ status: 'idle' }); }
fetch = (params?: P) => new LoadingMachine(this.config, selectParams(params, this.config.initialParams), 1);
}
type LoadingResult = SuccessMachine | RetryingMachine | ErrorMachine | CanceledMachine;
class LoadingMachine extends MachineBase {
private readonly completion: Promise>;
constructor(private config: FetchMachineConfig, private params: P, attempts: number) {
super({ status: 'loading', abortController: new AbortController(), attempts });
this.completion = this.execute();
}
/** Resolves to the typestate produced by the configured fetch operation. */
done = (): Promise> => this.completion;
private async execute(): Promise> {
try {
const data = await this.config.fetcher(this.params, {
signal: this.context.abortController.signal,
});
if (this.context.abortController.signal.aborted) {
return new CanceledMachine(this.config);
}
return this.succeed(data);
} catch (cause) {
if (this.context.abortController.signal.aborted) {
return new CanceledMachine(this.config);
}
const error = this.config.mapError ? this.config.mapError(cause) : cause as E;
return this.fail(error);
}
}
succeed = (data: T) => {
this.config.onSuccess?.(data);
return new SuccessMachine(this.config, { status: 'success', data });
};
fail = (error: E) => {
const maxRetries = this.config.maxRetries ?? 3;
if (this.context.attempts <= maxRetries) {
return new RetryingMachine(this.config, this.params, error, this.context.attempts);
}
this.config.onError?.(error);
return new ErrorMachine(this.config, { status: 'error', error });
};
cancel = () => {
this.context.abortController.abort();
return new CanceledMachine(this.config);
};
}
class RetryingMachine extends MachineBase> {
constructor(private config: FetchMachineConfig, private params: P, error: E, attempts: number) {
super({ status: 'retrying', error, attempts });
}
retry = (params?: P) => new LoadingMachine(this.config, selectParams(params, this.params), this.context.attempts + 1);
}
class SuccessMachine extends MachineBase> {
constructor(private config: FetchMachineConfig, context: SuccessContext) { super(context); }
refetch = (params?: P) => new LoadingMachine(this.config, selectParams(params, this.config.initialParams), 1);
}
class ErrorMachine extends MachineBase> {
constructor(private config: FetchMachineConfig, context: ErrorContext) { super(context); }
retry = (params?: P) => new LoadingMachine(this.config, selectParams(params, this.config.initialParams), 1);
}
class CanceledMachine extends MachineBase {
constructor(private config: FetchMachineConfig) { super({ status: 'canceled' }); }
refetch = (params?: P) => new LoadingMachine(this.config, selectParams(params, this.config.initialParams), 1);
}
function selectParams(provided: P | undefined, fallback: P | undefined): P {
return (provided === undefined ? fallback : provided) as P;
}
/**
* Complete typestate union returned by {@link createFetchMachine} transitions.
*
* Narrow `context.status` before calling state-specific operations such as
* `done`, `retry`, `cancel`, or `refetch`.
*
* @typeParam T - Successful data type.
* @typeParam E - Normalized error type.
* @typeParam P - Request parameter type.
*/
export type FetchMachine =
| IdleMachine
| LoadingMachine
| RetryingMachine
| SuccessMachine
| ErrorMachine
| CanceledMachine;
/**
* Creates a pre-built, highly configurable async data-fetching machine.
*
* This factory function returns a state machine that handles the entire lifecycle
* of a data request, including loading, success, error, cancellation, and retries.
*
* @template T - The type of the data to be fetched.
* @template E - The type of the error.
* @template P - The type of parameters accepted by fetch operations.
* @param config - Configuration object.
* @param config.fetcher - An async function that takes params and returns the data.
* @param [config.maxRetries=3] - The number of times to retry on failure.
* @param [config.onSuccess] - Optional callback fired with the data on success.
* @param [config.onError] - Optional callback fired with the error on final failure.
* @param [config.mapError] - Converts an unknown thrown value to `E`.
* @returns An `IdleMachine` instance, ready to start fetching.
* @throws {TypeError} If `config.fetcher` is not a function.
* @throws {RangeError} If `maxRetries` is negative or not an integer.
*
* @example
* ```typescript
* // 1. Define your data fetching logic
* async function fetchUser(id: number): Promise<{ id: number; name: string }> {
* const res = await fetch(`/api/users/${id}`);
* if (!res.ok) throw new Error('User not found');
* return res.json();
* }
*
* // 2. Create the machine
* const userMachine = createFetchMachine({
* fetcher: fetchUser,
* onSuccess: (user) => console.log(`Fetched: ${user.name}`),
* });
*
* // 3. Use it (e.g., in a React hook)
* if (userMachine.context.status === 'idle') {
* const loading = userMachine.fetch(123);
* const result = await loading.done();
* }
* ```
*
* @note This is a simplified example. For a real-world implementation, you would
* typically use this machine with a runner (like `runMachine` or `useMachine`) to
* manage the async transitions and state updates automatically.
*/
export function createFetchMachine(
config: FetchMachineConfig
): FetchMachine {
if (typeof config.fetcher !== 'function') {
throw new TypeError('createFetchMachine requires a fetcher function.');
}
if (config.maxRetries !== undefined && (!Number.isInteger(config.maxRetries) || config.maxRetries < 0)) {
throw new RangeError('maxRetries must be a non-negative integer.');
}
return new IdleMachine(config);
}
/**
* The core type for a Parallel Machine.
* It combines two machines, M1 and M2, into a single, unified type.
* @template M1 - The first machine in the parallel composition.
* @template M2 - The second machine in the parallel composition.
*/
export type ParallelMachine<
M1 extends Machine,
M2 extends Machine
> = Machine & Context> & {
// Map transitions from M1. When called, they return a new ParallelMachine
// where M1 has transitioned but M2 remains the same.
[K in keyof Transitions]: Transitions[K] extends (...args: infer A) => infer R
? R extends Machine
? (...args: A) => ParallelMachine
: never
: never;
} & {
// Map transitions from M2. When called, they return a new ParallelMachine
// where M2 has transitioned but M1 remains the same.
[K in keyof Transitions]: Transitions[K] extends (...args: infer A) => infer R
? R extends Machine
? (...args: A) => ParallelMachine
: never
: never;
};
/**
* Creates a parallel machine by composing two independent machines.
*
* This function takes two machines and merges them into a single machine entity.
* Transitions from either machine can be called, and they will only affect
* their respective part of the combined state.
*
* Transition names must be unique across the two machines. A collision throws
* instead of silently choosing one implementation.
*
* @param m1 The first machine instance.
* @param m2 The second machine instance.
* @returns A new ParallelMachine instance.
* @throws {Error} If the inputs share a context key or transition name.
* @typeParam M1 - First machine type.
* @typeParam M2 - Second machine type.
*
* @example
* ```ts
* const combined = createParallelMachine(counter, panel);
* const updated = combined.increment().toggle();
* ```
*/
export function createParallelMachine<
M1 extends Machine,
M2 extends Machine
>(m1: M1, m2: M2): ParallelMachine {
// 1. Combine the contexts
const contextCollision = Object.keys(m1.context).find(key => key in m2.context);
if (contextCollision) {
throw new Error(`Cannot compose parallel machines: context key '${contextCollision}' exists on both machines.`);
}
const combinedContext = { ...m1.context, ...m2.context };
const transitions1 = collectTransitions(m1) as unknown as Transitions;
const transitions2 = collectTransitions(m2) as unknown as Transitions;
const collision = Object.keys(transitions1).find(key => key in transitions2);
if (collision) {
throw new Error(`Cannot compose parallel machines: transition '${collision}' exists on both machines.`);
}
const combinedTransitions = {} as any;
// 2. Re-wire transitions from the first machine
for (const key in transitions1) {
const transitionFn = (transitions1 as any)[key];
combinedTransitions[key] = (...args: any[]) => {
const nextM1 = transitionFn.apply(m1, args);
// Recursively create a new parallel machine with the new M1 state
return createParallelMachine(nextM1, m2);
};
}
// 3. Re-wire transitions from the second machine
for (const key in transitions2) {
const transitionFn = (transitions2 as any)[key];
combinedTransitions[key] = (...args: any[]) => {
const nextM2 = transitionFn.apply(m2, args);
// Recursively create a new parallel machine with the new M2 state
return createParallelMachine(m1, nextM2);
};
}
return {
context: combinedContext,
...combinedTransitions,
} as ParallelMachine;
}
function collectTransitions(machine: Machine): Record any> {
const transitions: Record any> = {};
let current: object | null = machine;
while (current && current !== Object.prototype) {
for (const key of Object.getOwnPropertyNames(current)) {
if (key === 'constructor' || key === 'context' || key in transitions) continue;
const value = (machine as Record)[key];
if (typeof value === 'function') transitions[key] = value as (...args: any[]) => any;
}
current = Object.getPrototypeOf(current);
}
return transitions;
}
/**
* Rewrites every transition return type while retaining its name and parameters.
*
* @typeParam M - Machine whose transitions are inspected.
* @typeParam T - Replacement return type for every transition.
* @example
* ```ts
* type Chained = RemapTransitions;
* ```
*/
export type RemapTransitions, T> = {
[K in keyof Transitions]: Transitions[K] extends (...args: infer A) => any
? (...args: A) => T
: never;
};