@doeixd/machine 0.0.6 → 0.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,364 @@
1
+ /**
2
+ * @file Higher-Level Abstractions for @doeixd/machine
3
+ * @description
4
+ * This module provides a collection of powerful, pre-built patterns and primitives
5
+ * on top of the core `@doeixd/machine` library. These utilities are designed to
6
+ * solve common, recurring problems in state management, such as data fetching,
7
+ * hierarchical state, and toggling boolean context properties.
8
+ *
9
+ * Think of this as the "standard library" of common machine patterns.
10
+ */
11
+
12
+ import {
13
+ MachineBase,
14
+ Machine,
15
+ Transitions,
16
+ // AsyncMachine,
17
+ setContext,
18
+ Context,
19
+ // MaybePromise,
20
+ } from './index'; // Assuming this is a sibling package or in the same project
21
+
22
+ // =============================================================================
23
+ // SECTION 1: CUSTOM PRIMITIVES FOR COMPOSITION
24
+ // =============================================================================
25
+
26
+ /**
27
+ * A type utility to infer the child machine type from a parent.
28
+ */
29
+ type ChildMachine<P> = P extends MachineBase<{ child: infer C }> ? C : never;
30
+
31
+ /**
32
+ * Creates a transition method that delegates a call to a child machine.
33
+ *
34
+ * This is a higher-order function that reduces boilerplate when implementing
35
+ * hierarchical state machines. It generates a method for the parent machine that:
36
+ * 1. Checks if the specified action exists on the current child state.
37
+ * 2. If it exists, calls the action on the child.
38
+ * 3. Reconstructs the parent machine with the new child state returned by the action.
39
+ * 4. If the action doesn't exist on the child, it returns the parent machine unchanged.
40
+ *
41
+ * @template P - The parent machine type, which must have a `child` property in its context.
42
+ * @template K - The name of the action on the child machine to delegate to.
43
+ * @param actionName - The string name of the child's transition method.
44
+ * @param ...args - Any arguments to pass to the child's transition method.
45
+ * @returns The parent machine instance, with its `child` state potentially updated.
46
+ *
47
+ * @example
48
+ * ```typescript
49
+ * class Parent extends MachineBase<{ child: ChildMachine }> {
50
+ * // Instead of writing a manual delegation method...
51
+ * // save = () => {
52
+ * // if ('save' in this.context.child) {
53
+ * // const newChild = this.context.child.save();
54
+ * // return setContext(this, { child: newChild });
55
+ * // }
56
+ * // return this;
57
+ * // }
58
+ *
59
+ * // ...you can just use the primitive.
60
+ * save = delegateToChild('save');
61
+ * edit = delegateToChild('edit');
62
+ * }
63
+ * ```
64
+ */
65
+ export function delegateToChild<
66
+ P extends MachineBase<{ child: MachineBase<any> }>,
67
+ K extends keyof ChildMachine<P> & string
68
+ >(
69
+ actionName: K
70
+ ): (
71
+ ...args: ChildMachine<P>[K] extends (...a: infer A) => any ? A : never
72
+ ) => P {
73
+ return function(this: P, ...args: any[]): P {
74
+ const child = this.context.child as any;
75
+
76
+ if (typeof child[actionName] === 'function') {
77
+ const newChildState = child[actionName](...args);
78
+ return setContext(this as any, { ...this.context, child: newChildState }) as P;
79
+ }
80
+
81
+ // If the action is not available on the current child state, do nothing.
82
+ return this;
83
+ };
84
+ }
85
+
86
+ /**
87
+ * Creates a transition method that toggles a boolean property within the machine's context.
88
+ *
89
+ * This is a simple utility to reduce boilerplate for managing boolean flags.
90
+ *
91
+ * @template M - The machine type.
92
+ * @template K - The key of the boolean property in the machine's context.
93
+ * @param prop - The string name of the context property to toggle.
94
+ * @returns A new machine instance with the toggled property.
95
+ *
96
+ * @example
97
+ * ```typescript
98
+ * class SettingsMachine extends MachineBase<{ notifications: boolean; darkMode: boolean }> {
99
+ * toggleNotifications = toggle('notifications');
100
+ * toggleDarkMode = toggle('darkMode');
101
+ * }
102
+ * ```
103
+ */
104
+ export function toggle<
105
+ M extends MachineBase<any>,
106
+ K extends keyof Context<M>
107
+ >(
108
+ prop: K
109
+ ): (this: M) => M {
110
+ return function(this: M): M {
111
+ // Ensure the property is boolean-like for a sensible toggle
112
+ if (typeof this.context[prop] !== 'boolean') {
113
+ console.warn(`[toggle primitive] Property '${String(prop)}' is not a boolean. Toggling may have unexpected results.`);
114
+ }
115
+ return setContext(this as any, {
116
+ ...this.context,
117
+ [prop]: !this.context[prop],
118
+ }) as M;
119
+ };
120
+ }
121
+
122
+
123
+ // =============================================================================
124
+ // SECTION 2: PRE-BUILT, CUSTOMIZABLE MACHINES
125
+ // =============================================================================
126
+
127
+ /**
128
+ * A fully-featured, pre-built state machine for data fetching.
129
+ * It handles loading, success, error states, cancellation, and retry logic out of the box.
130
+ *
131
+ * This machine is highly customizable through its configuration options.
132
+ */
133
+
134
+ // --- Types for the Fetch Machine ---
135
+
136
+ export type Fetcher<T, _E = Error> = (params: any) => Promise<T>;
137
+ export type OnSuccess<T> = (data: T) => void;
138
+ export type OnError<E> = (error: E) => void;
139
+
140
+ export interface FetchMachineConfig<T, E = Error> {
141
+ fetcher: Fetcher<T, E>;
142
+ initialParams?: any;
143
+ maxRetries?: number;
144
+ onSuccess?: OnSuccess<T>;
145
+ onError?: OnError<E>;
146
+ }
147
+
148
+ // --- Contexts for Fetch States ---
149
+ type IdleContext = { status: 'idle' };
150
+ type LoadingContext = { status: 'loading'; abortController: AbortController; attempts: number };
151
+ type RetryingContext = { status: 'retrying'; error: any; attempts: number };
152
+ type SuccessContext<T> = { status: 'success'; data: T };
153
+ type ErrorContext<E> = { status: 'error'; error: E };
154
+ type CanceledContext = { status: 'canceled' };
155
+
156
+ // --- Machine State Classes (internal) ---
157
+
158
+ class IdleMachine<T, E> extends MachineBase<IdleContext> {
159
+ constructor(private config: FetchMachineConfig<T, E>) { super({ status: 'idle' }); }
160
+ fetch = (params?: any) => new LoadingMachine(this.config, params ?? this.config.initialParams, 1);
161
+ }
162
+
163
+ class LoadingMachine<T, E> extends MachineBase<LoadingContext> {
164
+ constructor(private config: FetchMachineConfig<T, E>, private params: any, attempts: number) {
165
+ super({ status: 'loading', abortController: new AbortController(), attempts });
166
+ this.execute(); // Auto-execute on creation
167
+ }
168
+
169
+ private async execute() {
170
+ // This is a "fire-and-forget" call that transitions the machine internally.
171
+ // In a real implementation, this would be managed by an external runner.
172
+ // For this example, we assume an external mechanism calls `succeed`, `fail`, etc.
173
+ }
174
+
175
+ succeed = (data: T) => {
176
+ this.config.onSuccess?.(data);
177
+ return new SuccessMachine<T, E>(this.config, { status: 'success', data });
178
+ };
179
+
180
+ fail = (error: E) => {
181
+ const maxRetries = this.config.maxRetries ?? 3;
182
+ if (this.context.attempts < maxRetries) {
183
+ return new RetryingMachine<T, E>(this.config, this.params, error, this.context.attempts);
184
+ }
185
+ this.config.onError?.(error);
186
+ return new ErrorMachine<T, E>(this.config, { status: 'error', error });
187
+ };
188
+
189
+ cancel = () => {
190
+ this.context.abortController.abort();
191
+ return new CanceledMachine<T, E>(this.config);
192
+ };
193
+ }
194
+
195
+ class RetryingMachine<T, E> extends MachineBase<RetryingContext> {
196
+ constructor(private config: FetchMachineConfig<T, E>, private params: any, error: E, attempts: number) {
197
+ super({ status: 'retrying', error, attempts });
198
+ // In a real implementation, you'd have a delay here (e.g., exponential backoff)
199
+ // before transitioning to LoadingMachine again.
200
+ }
201
+
202
+ // This would be called after a delay.
203
+ retry = (params?: any) => new LoadingMachine<T, E>(this.config, params ?? this.params, this.context.attempts + 1);
204
+ }
205
+
206
+ class SuccessMachine<T, E> extends MachineBase<SuccessContext<T>> {
207
+ constructor(private config: FetchMachineConfig<T, E>, context: SuccessContext<T>) { super(context); }
208
+ refetch = (params?: any) => new LoadingMachine(this.config, params ?? this.config.initialParams, 1);
209
+ }
210
+
211
+ class ErrorMachine<T, E> extends MachineBase<ErrorContext<E>> {
212
+ constructor(private config: FetchMachineConfig<T, E>, context: ErrorContext<E>) { super(context); }
213
+ retry = (params?: any) => new LoadingMachine(this.config, params ?? this.config.initialParams, 1);
214
+ }
215
+
216
+ class CanceledMachine<T, E> extends MachineBase<CanceledContext> {
217
+ constructor(private config: FetchMachineConfig<T, E>) { super({ status: 'canceled' }); }
218
+ refetch = (params?: any) => new LoadingMachine(this.config, params ?? this.config.initialParams, 1);
219
+ }
220
+
221
+ export type FetchMachine<T, E = Error> =
222
+ | IdleMachine<T, E>
223
+ | LoadingMachine<T, E>
224
+ | RetryingMachine<T, E>
225
+ | SuccessMachine<T, E>
226
+ | ErrorMachine<T, E>
227
+ | CanceledMachine<T, E>;
228
+
229
+ /**
230
+ * Creates a pre-built, highly configurable async data-fetching machine.
231
+ *
232
+ * This factory function returns a state machine that handles the entire lifecycle
233
+ * of a data request, including loading, success, error, cancellation, and retries.
234
+ *
235
+ * @template T - The type of the data to be fetched.
236
+ * @template E - The type of the error.
237
+ * @param config - Configuration object.
238
+ * @param config.fetcher - An async function that takes params and returns the data.
239
+ * @param [config.maxRetries=3] - The number of times to retry on failure.
240
+ * @param [config.onSuccess] - Optional callback fired with the data on success.
241
+ * @param [config.onError] - Optional callback fired with the error on final failure.
242
+ * @returns An `IdleMachine` instance, ready to start fetching.
243
+ *
244
+ * @example
245
+ * ```typescript
246
+ * // 1. Define your data fetching logic
247
+ * async function fetchUser(id: number): Promise<{ id: number; name: string }> {
248
+ * const res = await fetch(`/api/users/${id}`);
249
+ * if (!res.ok) throw new Error('User not found');
250
+ * return res.json();
251
+ * }
252
+ *
253
+ * // 2. Create the machine
254
+ * const userMachine = createFetchMachine({
255
+ * fetcher: fetchUser,
256
+ * onSuccess: (user) => console.log(`Fetched: ${user.name}`),
257
+ * });
258
+ *
259
+ * // 3. Use it (e.g., in a React hook)
260
+ * // let machine = userMachine;
261
+ * // machine = await machine.fetch(123); // Transitions to Loading, then Success/Error
262
+ * ```
263
+ *
264
+ * @note This is a simplified example. For a real-world implementation, you would
265
+ * typically use this machine with a runner (like `runMachine` or `useMachine`) to
266
+ * manage the async transitions and state updates automatically.
267
+ */
268
+ export function createFetchMachine<T, E = Error>(
269
+ config: FetchMachineConfig<T, E>
270
+ ): FetchMachine<T, E> {
271
+ // A more robust implementation would validate the config here.
272
+ return new IdleMachine<T, E>(config);
273
+ }
274
+
275
+ /**
276
+ * The core type for a Parallel Machine.
277
+ * It combines two machines, M1 and M2, into a single, unified type.
278
+ * @template M1 - The first machine in the parallel composition.
279
+ * @template M2 - The second machine in the parallel composition.
280
+ */
281
+ export type ParallelMachine<
282
+ M1 extends Machine<any>,
283
+ M2 extends Machine<any>
284
+ > = Machine<Context<M1> & Context<M2>> & {
285
+ // Map transitions from M1. When called, they return a new ParallelMachine
286
+ // where M1 has transitioned but M2 remains the same.
287
+ [K in keyof Transitions<M1>]: Transitions<M1>[K] extends (...args: infer A) => infer R
288
+ ? R extends Machine<any>
289
+ ? (...args: A) => ParallelMachine<R, M2>
290
+ : never
291
+ : never;
292
+ } & {
293
+ // Map transitions from M2. When called, they return a new ParallelMachine
294
+ // where M2 has transitioned but M1 remains the same.
295
+ [K in keyof Transitions<M2>]: Transitions<M2>[K] extends (...args: infer A) => infer R
296
+ ? R extends Machine<any>
297
+ ? (...args: A) => ParallelMachine<M1, R>
298
+ : never
299
+ : never;
300
+ };
301
+
302
+
303
+ /**
304
+ * Creates a parallel machine by composing two independent machines.
305
+ *
306
+ * This function takes two machines and merges them into a single machine entity.
307
+ * Transitions from either machine can be called, and they will only affect
308
+ * their respective part of the combined state.
309
+ *
310
+ * NOTE: This primitive assumes that the transition names between the two
311
+ * machines do not collide. If both machines have a transition named `next`,
312
+ * the behavior is undefined.
313
+ *
314
+ * @param m1 The first machine instance.
315
+ * @param m2 The second machine instance.
316
+ * @returns A new ParallelMachine instance.
317
+ */
318
+ export function createParallelMachine<
319
+ M1 extends Machine<any>,
320
+ M2 extends Machine<any>
321
+ >(m1: M1, m2: M2): ParallelMachine<M1, M2> {
322
+ // 1. Combine the contexts
323
+ const combinedContext = { ...m1.context, ...m2.context };
324
+
325
+ const transitions1 = { ...m1 } as Transitions<M1>;
326
+ const transitions2 = { ...m2 } as Transitions<M2>;
327
+ delete (transitions1 as any).context;
328
+ delete (transitions2 as any).context;
329
+
330
+ const combinedTransitions = {} as any;
331
+
332
+ // 2. Re-wire transitions from the first machine
333
+ for (const key in transitions1) {
334
+ const transitionFn = (transitions1 as any)[key];
335
+ combinedTransitions[key] = (...args: any[]) => {
336
+ const nextM1 = transitionFn.apply(m1.context, args);
337
+ // Recursively create a new parallel machine with the new M1 state
338
+ return createParallelMachine(nextM1, m2);
339
+ };
340
+ }
341
+
342
+ // 3. Re-wire transitions from the second machine
343
+ for (const key in transitions2) {
344
+ const transitionFn = (transitions2 as any)[key];
345
+ combinedTransitions[key] = (...args: any[]) => {
346
+ const nextM2 = transitionFn.apply(m2.context, args);
347
+ // Recursively create a new parallel machine with the new M2 state
348
+ return createParallelMachine(m1, nextM2);
349
+ };
350
+ }
351
+
352
+ return {
353
+ context: combinedContext,
354
+ ...combinedTransitions,
355
+ } as ParallelMachine<M1, M2>;
356
+ }
357
+
358
+ // A mapped type that transforms the return types of a machine's transitions.
359
+ // For a transition that returns `NewMachineState`, this will transform it to return `T`.
360
+ export type RemapTransitions<M extends Machine<any>, T> = {
361
+ [K in keyof Transitions<M>]: Transitions<M>[K] extends (...args: infer A) => any
362
+ ? (...args: A) => T
363
+ : never;
364
+ };
package/src/index.ts CHANGED
@@ -574,4 +574,21 @@ export {
574
574
  export { RUNTIME_META, type RuntimeTransitionMeta } from './primitives';
575
575
 
576
576
 
577
- export * from './multi'
577
+ export * from './multi'
578
+
579
+ export * from './higher-order'
580
+
581
+ // =============================================================================
582
+ // SECTION: UTILITIES & HELPERS
583
+ // =============================================================================
584
+
585
+ export {
586
+ isState,
587
+ createEvent,
588
+ mergeContext,
589
+ pipeTransitions,
590
+ logState,
591
+ call,
592
+ bindTransitions,
593
+ BoundMachine
594
+ } from './utils';
package/src/utils.ts CHANGED
@@ -158,10 +158,176 @@ export async function pipeTransitions<M extends AsyncMachine<any>>(
158
158
  * );
159
159
  */
160
160
  export function logState<M extends Machine<any>>(machine: M, label?: string): M {
161
- if (label) {
162
- console.log(label, machine.context);
163
- } else {
164
- console.log(machine.context);
161
+ if (label) {
162
+ console.log(label, machine.context);
163
+ } else {
164
+ console.log(machine.context);
165
+ }
166
+ return machine;
167
+ }
168
+
169
+ // =============================================================================
170
+ // SECTION: TRANSITION BINDING HELPERS
171
+ // =============================================================================
172
+
173
+ /**
174
+ * Calls a transition function with an explicit `this` context.
175
+ * Useful for invoking transition methods with proper context binding.
176
+ *
177
+ * @template C - The context type that the function expects as `this`.
178
+ * @template F - The function type with a `this` parameter.
179
+ * @template A - The argument types for the function.
180
+ * @param fn - The transition function to call.
181
+ * @param context - The context object to bind as `this`.
182
+ * @param args - Arguments to pass to the function.
183
+ * @returns The result of calling the function with the given context and arguments.
184
+ *
185
+ * @example
186
+ * type MyContext = { count: number };
187
+ * const increment = function(this: MyContext) { return this.count + 1; };
188
+ * const result = call(increment, { count: 5 }); // Returns 6
189
+ *
190
+ * // Particularly useful with machine transitions:
191
+ * import { call } from '@doeixd/machine/utils';
192
+ * const nextMachine = yield* step(call(m.increment, m.context));
193
+ */
194
+ export function call<C, F extends (this: C, ...args: any[]) => any>(
195
+ fn: F,
196
+ context: C,
197
+ ...args: Parameters<F> extends [any, ...infer Rest] ? Rest : never
198
+ ): ReturnType<F> {
199
+ return fn.apply(context, args);
200
+ }
201
+
202
+ /**
203
+ * Binds all transition methods of a machine to its context automatically.
204
+ * Returns a Proxy that intercepts method calls and binds them to `machine.context`.
205
+ * This eliminates the need to use `.call(m.context, ...)` for every transition.
206
+ *
207
+ * Automatically recursively wraps returned machines, enabling seamless chaining
208
+ * in generator-based flows.
209
+ *
210
+ * @template M - The machine type with a `context` property and transition methods.
211
+ * @param machine - The machine instance to wrap.
212
+ * @returns A Proxy of the machine where all callable properties (transitions) are automatically bound to the machine's context.
213
+ *
214
+ * @example
215
+ * type CounterContext = { count: number };
216
+ * const counter = bindTransitions(createMachine({ count: 0 }, {
217
+ * increment(this: CounterContext) { return createCounter(this.count + 1); }
218
+ * }));
219
+ *
220
+ * // Now you can call transitions directly without .call():
221
+ * const next = counter.increment(); // Works! This is automatically bound.
222
+ *
223
+ * // Particularly useful with generators:
224
+ * const result = run(function* (m) {
225
+ * m = yield* step(m.increment()); // Clean syntax
226
+ * m = yield* step(m.add(5)); // No .call() needed
227
+ * return m;
228
+ * }, bindTransitions(counter));
229
+ *
230
+ * @remarks
231
+ * The Proxy preserves all original properties and methods. Non-callable properties
232
+ * are accessed directly from the machine. Callable properties are wrapped to bind
233
+ * them to `machine.context` before invocation. Returned machines are automatically
234
+ * re-wrapped to maintain binding across transition chains.
235
+ */
236
+ export function bindTransitions<M extends { context: any }>(machine: M): M {
237
+ return new Proxy(machine, {
238
+ get(target, prop) {
239
+ const value = target[prop as keyof M];
240
+
241
+ // If it's a callable property (transition method), bind it to context
242
+ if (typeof value === 'function') {
243
+ return function(...args: any[]) {
244
+ const result = value.apply(target.context, args);
245
+ // Recursively wrap returned machines to maintain binding
246
+ if (result && typeof result === 'object' && 'context' in result) {
247
+ return bindTransitions(result);
248
+ }
249
+ return result;
250
+ };
251
+ }
252
+
253
+ // Otherwise, return the value as-is
254
+ return value;
255
+ },
256
+ }) as M;
257
+ }
258
+
259
+ /**
260
+ * A strongly-typed wrapper class for binding transitions to machine context.
261
+ * Unlike the Proxy-based `bindTransitions`, this class preserves full type safety
262
+ * and provides better IDE support through explicit property forwarding.
263
+ *
264
+ * @template M - The machine type with a `context` property and transition methods.
265
+ *
266
+ * @example
267
+ * type CounterContext = { count: number };
268
+ * const counter = createMachine({ count: 0 }, {
269
+ * increment(this: CounterContext) { return createCounter(this.count + 1); }
270
+ * });
271
+ *
272
+ * const bound = new BoundMachine(counter);
273
+ *
274
+ * // All transitions are automatically bound to context
275
+ * const result = run(function* (m) {
276
+ * m = yield* step(m.increment());
277
+ * m = yield* step(m.add(5));
278
+ * return m.context.count;
279
+ * }, bound);
280
+ *
281
+ * @remarks
282
+ * Advantages over Proxy-based `bindTransitions`:
283
+ * - Full type safety with TypeScript's type system
284
+ * - Returned machines are automatically re-wrapped
285
+ * - Better IDE autocompletion and hover information
286
+ * - No type casting needed
287
+ *
288
+ * Disadvantages:
289
+ * - Requires explicit instance creation: `new BoundMachine(m)` vs `bindTransitions(m)`
290
+ * - Not a transparent drop-in replacement for the original machine
291
+ */
292
+ export class BoundMachine<M extends { context: any }> {
293
+ private readonly wrappedMachine: M;
294
+ [key: string | symbol]: any;
295
+
296
+ constructor(machine: M) {
297
+ this.wrappedMachine = machine;
298
+
299
+ // Create a proxy to intercept property access
300
+ return new Proxy(this, {
301
+ get: (target, prop) => {
302
+ // Handle direct property access to wrapped machine
303
+ if (prop === 'wrappedMachine' || prop === 'context') {
304
+ return Reflect.get(target, prop);
305
+ }
306
+
307
+ const value = this.wrappedMachine[prop as keyof M];
308
+
309
+ // Bind transition methods to context
310
+ if (typeof value === 'function') {
311
+ return (...args: any[]) => {
312
+ const result = value.apply(this.wrappedMachine.context, args);
313
+ // Recursively wrap returned machines
314
+ if (result && typeof result === 'object' && 'context' in result) {
315
+ return new BoundMachine(result);
316
+ }
317
+ return result;
318
+ };
319
+ }
320
+
321
+ // Return non-function properties directly
322
+ return value;
323
+ },
324
+ }) as any;
325
+ }
326
+
327
+ /**
328
+ * Access the underlying machine's context directly.
329
+ */
330
+ get context(): M extends { context: infer C } ? C : never {
331
+ return this.wrappedMachine.context;
165
332
  }
166
- return machine;
167
333
  }