@doeixd/machine 0.0.4 → 0.0.5

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.
package/src/multi.ts ADDED
@@ -0,0 +1,1145 @@
1
+ /**
2
+ * @file multi.ts - Advanced operational patterns for state machine orchestration.
3
+ * @description
4
+ * This module provides optional, higher-level abstractions for managing machines.
5
+ * They solve common ergonomic and integration challenges without compromising the
6
+ * immutable core of the library.
7
+ *
8
+ * It introduces three patterns:
9
+ *
10
+ * 1. **Runner (`createRunner`):** A stateful controller for ergonomic control
11
+ * of a single, immutable machine. Solves state reassignment.
12
+ *
13
+ * 2. **Ensemble (`createEnsemble`):** A functional pattern for orchestrating logic
14
+ * over an external, framework-agnostic state store.
15
+ *
16
+ * 3. **MultiMachine (`createMultiMachine`):** A class-based alternative to the
17
+ * Ensemble for OOP-style orchestration.
18
+ */
19
+
20
+ import {
21
+ Machine,
22
+ Context,
23
+ TransitionArgs,
24
+ TransitionNames,
25
+ // Transitions,
26
+ } from './index';
27
+
28
+ // =============================================================================
29
+ // SECTION 1: THE MANAGED STATE RUNNER
30
+ // =============================================================================
31
+
32
+ /**
33
+ * A mapped type that creates a new object type with the same transition methods
34
+ * as the machine `M`, but pre-bound to update a Runner's internal state.
35
+ *
36
+ * When you call a method on `BoundTransitions`, it automatically transitions the
37
+ * runner's state and returns the new machine instance. This is the key mechanism
38
+ * that eliminates the need for manual state reassignment in imperative code.
39
+ *
40
+ * @template M - The machine type, which can be a union of multiple machine states.
41
+ *
42
+ * @example
43
+ * // If your machine has these transitions:
44
+ * // increment: () => Machine
45
+ * // add: (n: number) => Machine
46
+ * // Then BoundTransitions<typeof machine> provides:
47
+ * // increment: () => Machine (auto-updates runner state)
48
+ * // add: (n: number) => Machine (auto-updates runner state)
49
+ */
50
+ export type BoundTransitions<M extends Machine<any>> = {
51
+ [K in TransitionNames<M>]: (
52
+ ...args: TransitionArgs<M, K>
53
+ ) => M[K] extends (...args: any[]) => infer R ? R : never;
54
+ };
55
+
56
+ /**
57
+ * A stateful controller that wraps an immutable machine instance, providing a
58
+ * stable API for imperative state transitions without manual reassignment.
59
+ *
60
+ * The Runner holds the "current" machine state internally and updates it whenever
61
+ * an action is called. This solves the ergonomic problem of having to write:
62
+ * `machine = machine.transition()` over and over. Instead, you just call
63
+ * `runner.actions.transition()` and the runner manages the state for you.
64
+ *
65
+ * **Use Runner for:**
66
+ * - Complex local component state (React, Vue, Svelte components)
67
+ * - Scripts that need clean imperative state management
68
+ * - Situations where you have a single, self-contained state machine
69
+ *
70
+ * **Don't use Runner for:**
71
+ * - Global application state (use Ensemble instead)
72
+ * - Multiple interconnected machines
73
+ *
74
+ * @template M - The machine type (can be a union of states for Type-State patterns).
75
+ */
76
+ export type Runner<M extends Machine<any>> = {
77
+ /**
78
+ * The current, raw machine instance. This property is essential for
79
+ * type-narrowing in Type-State Programming patterns.
80
+ *
81
+ * Since machines can be unions of different state types, you can narrow
82
+ * the type by checking `runner.state.context` properties, and TypeScript
83
+ * will automatically narrow which transitions are available.
84
+ *
85
+ * @example
86
+ * if (runner.state.context.status === 'loggedIn') {
87
+ * // runner.state is now typed as LoggedInMachine
88
+ * console.log(runner.state.context.username);
89
+ * runner.actions.logout(); // Only available when logged in
90
+ * }
91
+ */
92
+ readonly state: M;
93
+
94
+ /**
95
+ * A direct, readonly accessor to the context of the current machine state.
96
+ * This is a convenience property equivalent to `runner.state.context`.
97
+ *
98
+ * @example
99
+ * console.log(runner.context.count); // Same as runner.state.context.count
100
+ */
101
+ readonly context: Context<M>;
102
+
103
+ /**
104
+ * A stable object containing all available transition methods, pre-bound to
105
+ * update the runner's state. This is the primary way to trigger transitions.
106
+ *
107
+ * When you call `runner.actions.someTransition()`, the runner automatically:
108
+ * 1. Calls the transition on the current machine
109
+ * 2. Updates `runner.state` with the new machine instance
110
+ * 3. Fires the `onChange` callback (if provided to createRunner)
111
+ * 4. Returns the new machine instance
112
+ *
113
+ * Note: For union-type machines, you must first narrow the type of `runner.state`
114
+ * to ensure a given action is available at compile time.
115
+ *
116
+ * @example
117
+ * runner.actions.increment(); // Automatically updates runner.state
118
+ * runner.actions.add(5); // Returns new machine instance
119
+ */
120
+ readonly actions: BoundTransitions<M>;
121
+
122
+ /**
123
+ * Manually sets the runner to a new machine state. Useful for resetting state
124
+ * or synchronizing with external events.
125
+ *
126
+ * This method bypasses the normal transition path and directly updates the
127
+ * runner's internal state. The `onChange` callback will be called.
128
+ *
129
+ * @param newState - The new machine instance to set.
130
+ *
131
+ * @example
132
+ * const reset = createCounterMachine({ count: 0 });
133
+ * runner.setState(reset); // Jump back to initial state
134
+ */
135
+ setState(newState: M): void;
136
+ };
137
+
138
+ /**
139
+ * Creates a Managed State Runner by wrapping a pure, immutable machine instance
140
+ * in a stateful controller. This eliminates the need for `machine = machine.transition()`
141
+ * reassignment, providing a more ergonomic, imperative API for complex local state.
142
+ *
143
+ * **How it works:**
144
+ * 1. The runner holds a reference to the current machine internally
145
+ * 2. When you call `runner.actions.transition()`, it calls the transition on the
146
+ * current machine and automatically updates the runner's internal state
147
+ * 3. The runner exposes a stable `actions` object that always reflects what
148
+ * transitions are available on the *current* machine (important for Type-State)
149
+ * 4. The `onChange` callback is invoked after every state change
150
+ *
151
+ * **Key difference from just calling transitions directly:**
152
+ * Instead of: `let machine = createMachine(...); machine = machine.increment();`
153
+ * You write: `const runner = createRunner(machine); runner.actions.increment();`
154
+ *
155
+ * The runner *is* the state holder, so you never need to reassign variables.
156
+ *
157
+ * @template M - The machine type.
158
+ * @param initialMachine - The starting machine instance.
159
+ * @param onChange - Optional callback fired after every state transition. Receives
160
+ * the new machine state, allowing you to react to changes (e.g., update a UI,
161
+ * log state changes, or trigger side effects).
162
+ * @returns A `Runner` instance with `state`, `context`, `actions`, and `setState()`.
163
+ *
164
+ * @example
165
+ * // Simple counter example
166
+ * const counterMachine = createCounterMachine({ count: 0 });
167
+ * const runner = createRunner(counterMachine, (newState) => {
168
+ * console.log('Count is now:', newState.context.count);
169
+ * });
170
+ *
171
+ * runner.actions.increment(); // Logs: "Count is now: 1"
172
+ * runner.actions.add(5); // Logs: "Count is now: 6"
173
+ * console.log(runner.context.count); // 6
174
+ *
175
+ * @example
176
+ * // Type-State example with conditional narrowing
177
+ * type AuthMachine = LoggedOutState | LoggedInState;
178
+ *
179
+ * const runner = createRunner(createLoggedOutMachine());
180
+ *
181
+ * // Narrow the type to access login
182
+ * if (runner.state.context.status === 'loggedOut') {
183
+ * runner.actions.login('alice'); // Only works in loggedOut state
184
+ * }
185
+ *
186
+ * // Now it's logged in, so we can call logout
187
+ * if (runner.state.context.status === 'loggedIn') {
188
+ * runner.actions.logout();
189
+ * }
190
+ */
191
+ export function createRunner<M extends Machine<any>>(
192
+ initialMachine: M,
193
+ onChange?: (newState: M) => void
194
+ ): Runner<M> {
195
+ let currentMachine = initialMachine;
196
+
197
+ const setState = (newState: M) => {
198
+ currentMachine = newState;
199
+ onChange?.(newState);
200
+ };
201
+
202
+ // Capture the original transitions from the initial machine
203
+ const { context: _initialContext, ...originalTransitions } = initialMachine;
204
+
205
+ const actions = new Proxy({} as BoundTransitions<M>, {
206
+ get(_target, prop: string) {
207
+ const transition = (currentMachine as any)[prop];
208
+ if (typeof transition !== 'function') {
209
+ // Return undefined for properties that aren't valid transitions on the current state
210
+ return undefined;
211
+ }
212
+
213
+ return (...args: any[]) => {
214
+ const nextState = transition.apply(currentMachine.context, args);
215
+ // Ensure the next state has all the original transitions
216
+ // by reconstructing it with the original transition functions
217
+ const nextStateWithTransitions = Object.assign(
218
+ { context: nextState.context },
219
+ originalTransitions
220
+ ) as M;
221
+ setState(nextStateWithTransitions);
222
+ return nextStateWithTransitions;
223
+ };
224
+ },
225
+ });
226
+
227
+ return {
228
+ get state() {
229
+ return currentMachine;
230
+ },
231
+ get context() {
232
+ return currentMachine.context;
233
+ },
234
+ actions,
235
+ setState,
236
+ };
237
+ }
238
+
239
+ // =============================================================================
240
+ // SECTION 2: THE ENSEMBLE (FRAMEWORK-AGNOSTIC ORCHESTRATION)
241
+ // =============================================================================
242
+
243
+ /**
244
+ * Defines the contract for an external, user-provided state store. The Ensemble
245
+ * uses this interface to read and write the machine's context, allowing it to
246
+ * plug into any state management solution (React, Solid, Zustand, etc.).
247
+ *
248
+ * **The power of this abstraction:**
249
+ * Your machine logic is completely decoupled from how or where the state is stored.
250
+ * The same machine factories can work with React's `useState`, Solid's `createSignal`,
251
+ * a plain object, or any custom store implementation.
252
+ *
253
+ * **Implementation examples:**
254
+ * - React: `{ getContext: () => state, setContext: setState }`
255
+ * - Solid: `{ getContext: () => store, setContext: (newCtx) => Object.assign(store, newCtx) }`
256
+ * - Plain object: `{ getContext: () => context, setContext: (ctx) => Object.assign(context, ctx) }`
257
+ *
258
+ * @template C - The shared context object type.
259
+ *
260
+ * @example
261
+ * // Implement a simple in-memory store
262
+ * let sharedContext = { status: 'idle' };
263
+ * const store: StateStore<typeof sharedContext> = {
264
+ * getContext: () => sharedContext,
265
+ * setContext: (newCtx) => { sharedContext = newCtx; }
266
+ * };
267
+ *
268
+ * @example
269
+ * // Implement a React-based store
270
+ * function useAppStore() {
271
+ * const [state, setState] = useState({ status: 'idle' });
272
+ * return {
273
+ * getContext: () => state,
274
+ * setContext: setState
275
+ * };
276
+ * }
277
+ */
278
+ export interface StateStore<C extends object> {
279
+ /**
280
+ * A function that returns the current, up-to-date context from the external store.
281
+ * Called whenever the Ensemble needs the latest state.
282
+ */
283
+ getContext: () => C;
284
+
285
+ /**
286
+ * A function that takes a new context and updates the external store.
287
+ * Called by transitions to persist state changes.
288
+ *
289
+ * @param newContext - The new context object to persist.
290
+ */
291
+ setContext: (newContext: C) => void;
292
+ }
293
+
294
+ /**
295
+ * A mapped type that finds all unique transition names across a union of machine types.
296
+ *
297
+ * This type extracts the union of all methods from all possible machine states,
298
+ * excluding the `context` property. This is used to create the `actions` object
299
+ * on an Ensemble, which can have methods from any of the machine states.
300
+ *
301
+ * At runtime, the Ensemble validates that an action is valid for the current state
302
+ * before executing it.
303
+ *
304
+ * @template AllMachines - A union of all possible machine types in an Ensemble.
305
+ *
306
+ * @example
307
+ * type IdleState = Machine<{ status: 'idle' }> & { fetch: () => LoadingState };
308
+ * type LoadingState = Machine<{ status: 'loading' }> & { cancel: () => IdleState };
309
+ * type AllStates = IdleState | LoadingState;
310
+ *
311
+ * // AllTransitions<AllStates> = { fetch: (...) => ..., cancel: (...) => ... }
312
+ * // (Both fetch and cancel are available, but each is only valid in its state)
313
+ */
314
+ type AllTransitions<AllMachines extends Machine<any>> = Omit<
315
+ { [K in keyof AllMachines]: AllMachines[K] }[keyof AllMachines],
316
+ 'context'
317
+ >;
318
+
319
+ /**
320
+ * The Ensemble object. It provides a stable, unified API for orchestrating a
321
+ * state machine whose context is managed by an external store.
322
+ *
323
+ * The Ensemble acts as the "director," determining which machine "actor" is
324
+ * currently active based on the state of the shared context. Unlike a Runner,
325
+ * which manages local state, an Ensemble plugs into external state management
326
+ * (like React's useState, Solid's signal, or a global store).
327
+ *
328
+ * **Key characteristics:**
329
+ * - Dynamically reconstructs the current machine based on context
330
+ * - Validates transitions at runtime for the current state
331
+ * - Integrates seamlessly with framework state managers
332
+ * - Same factories can be reused across different frameworks
333
+ *
334
+ * **Use Ensemble for:**
335
+ * - Global application state
336
+ * - Framework integration (React, Solid, Vue, etc.)
337
+ * - Complex workflows that span multiple components
338
+ * - Decoupling business logic from UI framework
339
+ *
340
+ * @template AllMachines - A union type of all possible machine states.
341
+ * @template C - The shared context type.
342
+ */
343
+ export type Ensemble<AllMachines extends Machine<any>, C extends object> = {
344
+ /**
345
+ * A direct, readonly accessor to the context from the provided `StateStore`.
346
+ * This is always up-to-date with the external store.
347
+ */
348
+ readonly context: C;
349
+
350
+ /**
351
+ * The current, fully-typed machine instance. This is dynamically created on-demand
352
+ * based on the context state. Use this for type-narrowing with Type-State patterns.
353
+ *
354
+ * The machine is reconstructed on every access, so it always reflects the
355
+ * current state of the context.
356
+ */
357
+ readonly state: AllMachines;
358
+
359
+ /**
360
+ * A stable object containing all possible actions from all machine states.
361
+ * The Ensemble performs a runtime check to ensure an action is valid for the
362
+ * current state before executing it.
363
+ *
364
+ * The `actions` object itself is stable (doesn't change), but the methods
365
+ * available on it dynamically change based on the current state.
366
+ */
367
+ readonly actions: AllTransitions<AllMachines>;
368
+ };
369
+
370
+ /**
371
+ * Creates an Ensemble to orchestrate a state machine over an external state store.
372
+ * This is the primary tool for framework integration, as it decouples pure state
373
+ * logic (defined in factories) from an application's state management solution
374
+ * (defined in store).
375
+ *
376
+ * **How it works:**
377
+ * 1. You provide a `StateStore` that can read and write your application's state
378
+ * 2. You define factory functions that create machines for each state
379
+ * 3. You provide a `getDiscriminant` accessor that tells the Ensemble which
380
+ * factory to use based on the current context
381
+ * 4. The Ensemble dynamically constructs the right machine and provides a stable
382
+ * `actions` object to call transitions
383
+ *
384
+ * **Why this pattern?**
385
+ * Your business logic (machines) is completely separated from your state management
386
+ * (React, Solid, Zustand). You can change state managers without rewriting machines,
387
+ * and you can test machines in isolation without framework dependencies.
388
+ *
389
+ * @template C - The shared context type.
390
+ * @template F - An object of functions that create machine instances for each state.
391
+ * Each factory receives the context and returns a Machine instance for that state.
392
+ * @param store - The user-provided `StateStore` that reads/writes the context.
393
+ * @param factories - An object mapping state discriminant keys to factory functions.
394
+ * Each factory receives the context and returns a machine instance.
395
+ * @param getDiscriminant - An accessor function that takes the context and returns
396
+ * the key of the current state in the `factories` object. This provides full
397
+ * refactoring safety—if you rename a property in your context, TypeScript will
398
+ * catch it at the accessor function.
399
+ * @returns An `Ensemble` instance with `context`, `state`, and `actions`.
400
+ *
401
+ * @example
402
+ * // Using a simple in-memory store
403
+ * let sharedContext = { status: 'idle' as const, data: null };
404
+ * const store = {
405
+ * getContext: () => sharedContext,
406
+ * setContext: (newCtx) => { sharedContext = newCtx; }
407
+ * };
408
+ *
409
+ * // Define factories for each state
410
+ * const factories = {
411
+ * idle: (ctx) => createMachine(ctx, {
412
+ * fetch: () => store.setContext({ ...ctx, status: 'loading' })
413
+ * }),
414
+ * loading: (ctx) => createMachine(ctx, {
415
+ * succeed: (data: any) => store.setContext({ status: 'success', data }),
416
+ * fail: (error: string) => store.setContext({ status: 'error', error })
417
+ * }),
418
+ * success: (ctx) => createMachine(ctx, {
419
+ * retry: () => store.setContext({ status: 'loading', data: null })
420
+ * }),
421
+ * error: (ctx) => createMachine(ctx, {
422
+ * retry: () => store.setContext({ status: 'loading', data: null })
423
+ * })
424
+ * };
425
+ *
426
+ * // Create the ensemble with a discriminant accessor
427
+ * const ensemble = createEnsemble(store, factories, (ctx) => ctx.status);
428
+ *
429
+ * // Use the ensemble
430
+ * ensemble.actions.fetch();
431
+ * console.log(ensemble.context.status); // 'loading'
432
+ *
433
+ * @example
434
+ * // React integration example
435
+ * function useAppEnsemble() {
436
+ * const [context, setContext] = useState({ status: 'idle' as const, data: null });
437
+ *
438
+ * const store: StateStore<typeof context> = {
439
+ * getContext: () => context,
440
+ * setContext: (newCtx) => setContext(newCtx)
441
+ * };
442
+ *
443
+ * const ensemble = useMemo(() =>
444
+ * createEnsemble(store, factories, (ctx) => ctx.status),
445
+ * [context] // Re-create ensemble if context changes
446
+ * );
447
+ *
448
+ * return ensemble;
449
+ * }
450
+ *
451
+ * // In your component:
452
+ * function MyComponent() {
453
+ * const ensemble = useAppEnsemble();
454
+ * return (
455
+ * <>
456
+ * <p>Status: {ensemble.context.status}</p>
457
+ * <button onClick={() => ensemble.actions.fetch()}>
458
+ * Fetch Data
459
+ * </button>
460
+ * </>
461
+ * );
462
+ * }
463
+ */
464
+ export function createEnsemble<
465
+ C extends object,
466
+ F extends Record<string, (context: C) => Machine<C>>
467
+ >(
468
+ store: StateStore<C>,
469
+ factories: F,
470
+ getDiscriminant: (context: C) => keyof F
471
+ ): Ensemble<ReturnType<F[keyof F]>, C> {
472
+ type AllMachines = ReturnType<F[keyof F]>;
473
+
474
+ const getCurrentMachine = (): AllMachines => {
475
+ const context = store.getContext();
476
+ const currentStateName = getDiscriminant(context);
477
+ const factory = factories[currentStateName];
478
+
479
+ if (!factory) {
480
+ throw new Error(
481
+ `[Ensemble] Invalid state: No factory found for state "${String(currentStateName)}".`
482
+ );
483
+ }
484
+ return factory(context) as AllMachines;
485
+ };
486
+
487
+ const actions = new Proxy({} as AllTransitions<AllMachines>, {
488
+ get(_target, prop: string) {
489
+ const currentMachine = getCurrentMachine();
490
+ const action = (currentMachine as any)[prop];
491
+
492
+ if (typeof action !== 'function') {
493
+ throw new Error(
494
+ `[Ensemble] Transition "${prop}" is not valid in the current state.`
495
+ );
496
+ }
497
+
498
+ // Return a function that, when called, executes the transition.
499
+ // The transition itself is responsible for calling `store.setContext`.
500
+ return (...args: any[]) => {
501
+ return action.apply(currentMachine.context, args);
502
+ };
503
+ },
504
+ });
505
+
506
+ return {
507
+ get context() {
508
+ return store.getContext();
509
+ },
510
+ get state() {
511
+ return getCurrentMachine();
512
+ },
513
+ actions,
514
+ };
515
+ }
516
+
517
+ // =============================================================================
518
+ // SECTION 3: GENERATOR INTEGRATION
519
+ // =============================================================================
520
+
521
+ /**
522
+ * Executes a generator-based workflow using a Managed State Runner.
523
+ *
524
+ * This provides the cleanest syntax for multi-step imperative workflows, as the
525
+ * `yield` keyword is only used for control flow, not state passing. Unlike the
526
+ * basic `run()` function from the core library, this works directly with a Runner,
527
+ * making it perfect for complex local state orchestration.
528
+ *
529
+ * **Syntax benefits:**
530
+ * - No need to manually thread state through a chain of transitions
531
+ * - `yield` is purely for control flow, not for passing state
532
+ * - Can use regular `if`/`for` statements without helpers
533
+ * - Generator return value is automatically your final result
534
+ *
535
+ * @param flow - A generator function that receives the `Runner` instance. The
536
+ * generator can yield values (returned by transitions) and use them for control
537
+ * flow, or just yield for side effects.
538
+ * @param initialMachine - The machine to start the flow with. A runner will be
539
+ * created from this automatically.
540
+ * @returns The final value returned by the generator (the `return` statement).
541
+ *
542
+ * @example
543
+ * // Simple sequential transitions
544
+ * const result = runWithRunner(function* (runner) {
545
+ * yield runner.actions.increment();
546
+ * yield runner.actions.add(10);
547
+ * if (runner.context.count > 5) {
548
+ * yield runner.actions.reset();
549
+ * }
550
+ * return runner.context;
551
+ * }, createCounterMachine());
552
+ * console.log(result); // { count: 0 }
553
+ *
554
+ * @example
555
+ * // Complex workflow with Type-State narrowing
556
+ * const result = runWithRunner(function* (runner) {
557
+ * // Start logged out
558
+ * if (runner.state.context.status === 'loggedOut') {
559
+ * yield runner.actions.login('alice');
560
+ * }
561
+ *
562
+ * // Now logged in, fetch profile
563
+ * if (runner.state.context.status === 'loggedIn') {
564
+ * yield runner.actions.fetchProfile();
565
+ * }
566
+ *
567
+ * // Return final context
568
+ * return runner.context;
569
+ * }, createAuthMachine());
570
+ */
571
+ export function runWithRunner<M extends Machine<any>, T>(
572
+ flow: (runner: Runner<M>) => Generator<any, T, any>,
573
+ initialMachine: M
574
+ ): T {
575
+ const runner = createRunner(initialMachine);
576
+ const generator = flow(runner);
577
+ let result = generator.next();
578
+ while (!result.done) {
579
+ result = generator.next();
580
+ }
581
+ return result.value;
582
+ }
583
+
584
+ /**
585
+ * Executes a generator-based workflow using an Ensemble.
586
+ *
587
+ * This pattern is ideal for orchestrating complex sagas or workflows that
588
+ * interact with a global, framework-managed state. Like `runWithRunner`,
589
+ * it provides clean imperative syntax for multi-step workflows, but operates
590
+ * on an Ensemble's external store rather than internal state.
591
+ *
592
+ * **Key differences from runWithRunner:**
593
+ * - Works with external state stores (React, Solid, etc.)
594
+ * - Useful for global workflows and sagas
595
+ * - State changes automatically propagate to the framework
596
+ * - Great for testing framework-agnostic state logic
597
+ *
598
+ * @param flow - A generator function that receives the `Ensemble` instance.
599
+ * The generator can read `ensemble.context` and call `ensemble.actions`.
600
+ * @param ensemble - The `Ensemble` to run the workflow against. Its context
601
+ * is shared across the entire workflow.
602
+ * @returns The final value returned by the generator (the `return` statement).
603
+ *
604
+ * @example
605
+ * // Multi-step workflow with an ensemble
606
+ * const result = runWithEnsemble(function* (ensemble) {
607
+ * // Fetch initial data
608
+ * if (ensemble.context.status === 'idle') {
609
+ * yield ensemble.actions.fetch();
610
+ * }
611
+ *
612
+ * // Process the data
613
+ * if (ensemble.context.status === 'success') {
614
+ * yield ensemble.actions.process(ensemble.context.data);
615
+ * }
616
+ *
617
+ * return ensemble.context;
618
+ * }, ensemble);
619
+ *
620
+ * @example
621
+ * // Testing a workflow without a UI framework
622
+ * const store: StateStore<AppContext> = {
623
+ * getContext: () => context,
624
+ * setContext: (newCtx) => Object.assign(context, newCtx)
625
+ * };
626
+ *
627
+ * const ensemble = createEnsemble(store, factories, (ctx) => ctx.status);
628
+ *
629
+ * // Run a complex workflow and assert the result
630
+ * const result = runWithEnsemble(function* (e) {
631
+ * yield e.actions.login('alice');
632
+ * yield e.actions.fetchProfile();
633
+ * yield e.actions.updateEmail('alice@example.com');
634
+ * return e.context;
635
+ * }, ensemble);
636
+ *
637
+ * expect(result.userEmail).toBe('alice@example.com');
638
+ */
639
+ export function runWithEnsemble<
640
+ AllMachines extends Machine<any>,
641
+ C extends object,
642
+ T
643
+ >(
644
+ flow: (ensemble: Ensemble<AllMachines, C>) => Generator<any, T, any>,
645
+ ensemble: Ensemble<AllMachines, C>
646
+ ): T {
647
+ const generator = flow(ensemble);
648
+ let result = generator.next();
649
+ while (!result.done) {
650
+ result = generator.next();
651
+ }
652
+ return result.value;
653
+ }
654
+
655
+ // =============================================================================
656
+ // SECTION 4: CLASS-BASED MULTI-MACHINE (OOP APPROACH)
657
+ // =============================================================================
658
+
659
+ /**
660
+ * The base class for creating a class-based state machine (MultiMachine).
661
+ * Extend this class to define your state machine's logic using instance methods
662
+ * as transitions.
663
+ *
664
+ * This approach is ideal for developers who prefer class-based architectures
665
+ * and want to manage a shared context directly through an external StateStore.
666
+ * It provides a familiar OOP interface while maintaining the decoupling benefits
667
+ * of the StateStore pattern.
668
+ *
669
+ * **Key features:**
670
+ * - Extend this class and define transition methods as instance methods
671
+ * - Protected `context` getter provides access to the current state
672
+ * - Protected `setContext()` method updates the external store
673
+ * - Works seamlessly with `createMultiMachine()`
674
+ *
675
+ * @template C - The shared context type. Should typically contain a discriminant
676
+ * property (like `status`) that identifies the current state.
677
+ *
678
+ * @example
679
+ * // Define your context type
680
+ * type AppContext = { status: 'idle' | 'loading' | 'error'; data?: any; error?: string };
681
+ *
682
+ * // Extend MultiMachineBase and define transitions as methods
683
+ * class AppMachine extends MultiMachineBase<AppContext> {
684
+ * async fetch(url: string) {
685
+ * // Notify subscribers we're loading
686
+ * this.setContext({ ...this.context, status: 'loading' });
687
+ *
688
+ * try {
689
+ * const data = await fetch(url).then(r => r.json());
690
+ * // Update state when done
691
+ * this.setContext({ ...this.context, status: 'idle', data });
692
+ * } catch (error) {
693
+ * // Handle errors
694
+ * this.setContext({
695
+ * ...this.context,
696
+ * status: 'error',
697
+ * error: error.message
698
+ * });
699
+ * }
700
+ * }
701
+ *
702
+ * reset() {
703
+ * this.setContext({ status: 'idle' });
704
+ * }
705
+ * }
706
+ */
707
+ export abstract class MultiMachineBase<C extends object> {
708
+ /**
709
+ * The external state store that manages the machine's context.
710
+ * @protected
711
+ */
712
+ protected store: StateStore<C>;
713
+
714
+ /**
715
+ * @param store - The StateStore that will manage this machine's context.
716
+ */
717
+ constructor(store: StateStore<C>) {
718
+ this.store = store;
719
+ }
720
+
721
+ /**
722
+ * Read-only access to the current context from the external store.
723
+ * This getter always returns the latest context from the store.
724
+ *
725
+ * @protected
726
+ *
727
+ * @example
728
+ * const currentStatus = this.context.status;
729
+ * const currentData = this.context.data;
730
+ */
731
+ protected get context(): C {
732
+ return this.store.getContext();
733
+ }
734
+
735
+ /**
736
+ * Update the shared context in the external store.
737
+ * Call this method in your transition methods to update the state.
738
+ *
739
+ * @protected
740
+ * @param newContext - The new context object. Should typically be a shallow
741
+ * copy with only the properties you're changing, merged with the current
742
+ * context using spread operators.
743
+ *
744
+ * @example
745
+ * // In a transition method:
746
+ * this.setContext({ ...this.context, status: 'loading' });
747
+ *
748
+ * @example
749
+ * // Updating nested properties:
750
+ * this.setContext({
751
+ * ...this.context,
752
+ * user: { ...this.context.user, name: 'Alice' }
753
+ * });
754
+ */
755
+ protected setContext(newContext: C): void {
756
+ this.store.setContext(newContext);
757
+ }
758
+ }
759
+
760
+ /**
761
+ * Creates a live, type-safe instance of a class-based state machine (MultiMachine).
762
+ *
763
+ * This is the class-based alternative to the functional `createEnsemble` pattern,
764
+ * designed for developers who prefer an OOP-style architecture. This function takes
765
+ * your MultiMachine class blueprint and an external state store, and wires them
766
+ * together. The returned object is a Proxy that dynamically exposes both context
767
+ * properties and the available transition methods from your class.
768
+ *
769
+ * **Key features:**
770
+ * - Directly access context properties as if they were on the machine object
771
+ * - Call transition methods to update state through the store
772
+ * - Type-safe integration with TypeScript
773
+ * - Seamless Proxy-based API (no special method names or API quirks)
774
+ *
775
+ * **How it works:**
776
+ * The returned Proxy intercepts property access. For context properties, it returns
777
+ * values from the store. For methods, it calls them on the MultiMachine instance.
778
+ * This creates the illusion of a single object that is both data and behavior.
779
+ *
780
+ * @template C - The shared context type.
781
+ * @template T - The MultiMachine class type.
782
+ *
783
+ * @param MachineClass - The class you defined that extends `MultiMachineBase<C>`.
784
+ * @param store - The `StateStore` that will manage the machine's context.
785
+ * @returns A Proxy that merges context properties with class methods, allowing
786
+ * direct access to both via a unified object interface.
787
+ *
788
+ * @example
789
+ * // Define your context type
790
+ * type CounterContext = { count: number };
791
+ *
792
+ * // Define your machine class
793
+ * class CounterMachine extends MultiMachineBase<CounterContext> {
794
+ * increment() {
795
+ * this.setContext({ count: this.context.count + 1 });
796
+ * }
797
+ *
798
+ * add(n: number) {
799
+ * this.setContext({ count: this.context.count + n });
800
+ * }
801
+ *
802
+ * reset() {
803
+ * this.setContext({ count: 0 });
804
+ * }
805
+ * }
806
+ *
807
+ * // Create a store
808
+ * let sharedContext = { count: 0 };
809
+ * const store = {
810
+ * getContext: () => sharedContext,
811
+ * setContext: (ctx) => { sharedContext = ctx; }
812
+ * };
813
+ *
814
+ * // Create the machine instance
815
+ * const machine = createMultiMachine(CounterMachine, store);
816
+ *
817
+ * // Use it naturally - properties and methods seamlessly integrated
818
+ * console.log(machine.count); // 0
819
+ * machine.increment();
820
+ * console.log(machine.count); // 1
821
+ * machine.add(5);
822
+ * console.log(machine.count); // 6
823
+ * machine.reset();
824
+ * console.log(machine.count); // 0
825
+ *
826
+ * @example
827
+ * // Status-based state machine with type discrimination
828
+ * type AppContext = {
829
+ * status: 'idle' | 'loading' | 'success' | 'error';
830
+ * data?: any;
831
+ * error?: string;
832
+ * };
833
+ *
834
+ * class AppMachine extends MultiMachineBase<AppContext> {
835
+ * async fetch() {
836
+ * this.setContext({ ...this.context, status: 'loading' });
837
+ * try {
838
+ * const data = await fetch('/api/data').then(r => r.json());
839
+ * this.setContext({ status: 'success', data });
840
+ * } catch (error) {
841
+ * this.setContext({
842
+ * status: 'error',
843
+ * error: error instanceof Error ? error.message : 'Unknown error'
844
+ * });
845
+ * }
846
+ * }
847
+ *
848
+ * reset() {
849
+ * this.setContext({ status: 'idle' });
850
+ * }
851
+ * }
852
+ *
853
+ * // Set up
854
+ * let context: AppContext = { status: 'idle' };
855
+ * const store = {
856
+ * getContext: () => context,
857
+ * setContext: (ctx) => { context = ctx; }
858
+ * };
859
+ *
860
+ * const app = createMultiMachine(AppMachine, store);
861
+ *
862
+ * // Use naturally with type discrimination
863
+ * console.log(app.status); // 'idle'
864
+ *
865
+ * if (app.status === 'idle') {
866
+ * app.fetch(); // Transition to loading
867
+ * }
868
+ *
869
+ * // Later: app.status === 'success'
870
+ * // console.log(app.data); // Access the data
871
+ */
872
+ export function createMultiMachine<
873
+ C extends object,
874
+ T extends MultiMachineBase<C>
875
+ >(
876
+ MachineClass: new (store: StateStore<C>) => T,
877
+ store: StateStore<C>
878
+ ): C & T {
879
+ const instance = new MachineClass(store);
880
+
881
+ return new Proxy({} as C & T, {
882
+ get(_target, prop: string | symbol) {
883
+ // 1. Prioritize properties from the context
884
+ const context = store.getContext();
885
+ if (prop in context) {
886
+ return (context as any)[prop];
887
+ }
888
+
889
+ // 2. Then check for methods on the instance
890
+ const method = (instance as any)[prop];
891
+ if (typeof method === 'function') {
892
+ return (...args: any[]) => {
893
+ return method.apply(instance, args);
894
+ };
895
+ }
896
+
897
+ return undefined;
898
+ },
899
+
900
+ set(_target, prop: string | symbol, value: any) {
901
+ // Allow direct mutation of context properties
902
+ const context = store.getContext();
903
+ if (prop in context) {
904
+ const newContext = { ...context, [prop]: value } as C;
905
+ store.setContext(newContext);
906
+ return true;
907
+ }
908
+ return false;
909
+ },
910
+
911
+ has(_target, prop: string | symbol) {
912
+ // Support `in` operator checks
913
+ const context = store.getContext();
914
+ return prop in context || typeof (instance as any)[prop] === 'function';
915
+ },
916
+
917
+ ownKeys(_target) {
918
+ // Support reflection APIs
919
+ const context = store.getContext();
920
+ const contextKeys = Object.keys(context);
921
+ const methodKeys = Object.getOwnPropertyNames(
922
+ Object.getPrototypeOf(instance)
923
+ ).filter((key) => key !== 'constructor' && typeof (instance as any)[key] === 'function');
924
+ return Array.from(new Set([...contextKeys, ...methodKeys]));
925
+ },
926
+
927
+ getOwnPropertyDescriptor(_target, prop) {
928
+ // Support property descriptors
929
+ const context = store.getContext();
930
+ if (prop in context || typeof (instance as any)[prop] === 'function') {
931
+ return {
932
+ value: undefined,
933
+ writable: true,
934
+ enumerable: true,
935
+ configurable: true,
936
+ };
937
+ }
938
+ return undefined;
939
+ },
940
+ });
941
+ }
942
+
943
+ // =============================================================================
944
+ // SECTION 5: THE MUTABLE MACHINE (EXPERIMENTAL)
945
+ // =============================================================================
946
+
947
+ /**
948
+ * A mapped type that defines the shape of a Mutable Machine: an intersection
949
+ * of the context `C` and all possible transitions.
950
+ */
951
+ type MutableMachine<C extends object, AllMachines extends Machine<any>> = C &
952
+ AllTransitions<AllMachines>;
953
+
954
+
955
+ /**
956
+ * Creates a Mutable Machine that uses a shared, mutable context. This primitive
957
+ * provides a stable object reference whose properties are mutated in place,
958
+ * offering a direct, imperative API.
959
+ *
960
+ * ---
961
+ *
962
+ * ### Key Characteristics & Trade-offs
963
+ *
964
+ * - **Stable Object Reference**: The machine is a single object. You can pass this
965
+ * reference around, and it will always reflect the current state.
966
+ * - **Direct Imperative API**: Transitions are called like methods directly on the
967
+ * object (`machine.login('user')`), and the object's properties update immediately.
968
+ * - **No State History**: Since the context is mutated, the history of previous
969
+ * states is not preserved, which makes patterns like time-travel debugging impossible.
970
+ * - **Not for Reactive UIs**: Most UI frameworks (React, Solid, Vue) rely on
971
+ * immutable state changes to trigger updates. Mutating the context directly
972
+ * will not cause components to re-render. Use the `Ensemble` primitive for UI integration.
973
+ *
974
+ * ---
975
+ *
976
+ * ### Best Suited For
977
+ *
978
+ * - **Backend Services & Game Logic**: Ideal for managing state in server-side
979
+ * processes, game loops, or other non-UI environments where performance and a
980
+ * stable state object are priorities.
981
+ * - **Complex Synchronous Scripts**: Useful for orchestrating data processing
982
+ * pipelines, command-line tools, or any script where state needs to be managed
983
+ * imperatively without passing it through a function chain.
984
+ *
985
+ * @template C - The shared context type.
986
+ * @template F - An object of functions that create machine instances for each state.
987
+ * **Crucially, transitions inside these machines must be pure functions that
988
+ * return the *next context object*, not a new machine instance.**
989
+ * @param sharedContext - The initial context object. This object will be mutated.
990
+ * @param factories - An object mapping state names to functions that create machine instances.
991
+ * @param getDiscriminant - An accessor function that takes the context and returns the key
992
+ * of the current state in the `factories` object. Provides refactoring safety.
993
+ * @returns A Proxy that acts as a stable, mutable machine instance.
994
+ *
995
+ * @example
996
+ * // ===== 1. Basic Authentication Example =====
997
+ *
998
+ * type AuthContext =
999
+ * | { status: 'loggedOut'; error?: string }
1000
+ * | { status: 'loggedIn'; username: string };
1001
+ *
1002
+ * const authFactories = {
1003
+ * loggedOut: (ctx: AuthContext) => ({
1004
+ * context: ctx,
1005
+ * // This transition is a PURE function that returns the NEXT CONTEXT
1006
+ * login: (username: string) => ({ status: 'loggedIn', username }),
1007
+ * }),
1008
+ * loggedIn: (ctx: AuthContext) => ({
1009
+ * context: ctx,
1010
+ * logout: () => ({ status: 'loggedOut' }),
1011
+ * }),
1012
+ * };
1013
+ *
1014
+ * const authUser = createMutableMachine(
1015
+ * { status: 'loggedOut' } as AuthContext,
1016
+ * authFactories,
1017
+ * 'status'
1018
+ * );
1019
+ *
1020
+ * const userReference = authUser; // Store a reference to the object
1021
+ *
1022
+ * console.log(authUser.status); // 'loggedOut'
1023
+ *
1024
+ * authUser.login('alice'); // Mutates the object in place
1025
+ *
1026
+ * console.log(authUser.status); // 'loggedIn'
1027
+ * console.log(authUser.username); // 'alice'
1028
+ *
1029
+ * // The original reference points to the same, mutated object
1030
+ * console.log(userReference.status); // 'loggedIn'
1031
+ * console.log(userReference === authUser); // true
1032
+ *
1033
+ * // --- Type-safe transitions ---
1034
+ * // `authUser.login('bob')` would now throw a runtime error because `login`
1035
+ * // is not a valid action in the 'loggedIn' state.
1036
+ *
1037
+ * if (authUser.status === 'loggedIn') {
1038
+ * // TypeScript correctly narrows the type here, allowing a safe call.
1039
+ * authUser.logout();
1040
+ * }
1041
+ * console.log(authUser.status); // 'loggedOut'
1042
+ *
1043
+ * @example
1044
+ * // ===== 2. Game State Loop Example =====
1045
+ *
1046
+ * type PlayerContext = {
1047
+ * state: 'idle' | 'walking' | 'attacking';
1048
+ * hp: number;
1049
+ * position: { x: number; y: number };
1050
+ * };
1051
+ *
1052
+ * const playerFactories = {
1053
+ * idle: (ctx: PlayerContext) => ({
1054
+ * context: ctx,
1055
+ * walk: (dx: number, dy: number) => ({ ...ctx, state: 'walking', position: { x: ctx.position.x + dx, y: ctx.position.y + dy } }),
1056
+ * attack: () => ({ ...ctx, state: 'attacking' }),
1057
+ * }),
1058
+ * walking: (ctx: PlayerContext) => ({
1059
+ * context: ctx,
1060
+ * stop: () => ({ ...ctx, state: 'idle' }),
1061
+ * }),
1062
+ * attacking: (ctx: PlayerContext) => ({
1063
+ * context: ctx,
1064
+ * finishAttack: () => ({ ...ctx, state: 'idle' }),
1065
+ * }),
1066
+ * };
1067
+ *
1068
+ * const player = createMutableMachine(
1069
+ * { state: 'idle', hp: 100, position: { x: 0, y: 0 } },
1070
+ * playerFactories,
1071
+ * (ctx) => ctx.state
1072
+ * );
1073
+ *
1074
+ * // Simulate a game loop
1075
+ * function processInput(input: 'move_right' | 'attack') {
1076
+ * if (player.state === 'idle') {
1077
+ * if (input === 'move_right') player.walk(1, 0);
1078
+ * if (input === 'attack') player.attack();
1079
+ * }
1080
+ * console.log(`State: ${player.state}, Position: (${player.position.x}, ${player.position.y})`);
1081
+ * }
1082
+ *
1083
+ * processInput('move_right'); // State: walking, Position: (1, 0)
1084
+ * player.stop();
1085
+ * processInput('attack'); // State: attacking, Position: (1, 0)
1086
+ */
1087
+ export function createMutableMachine<
1088
+ C extends object,
1089
+ F extends Record<string, (context: C) => Machine<C>>
1090
+ >(
1091
+ sharedContext: C,
1092
+ factories: F,
1093
+ getDiscriminant: (context: C) => keyof F
1094
+ ): MutableMachine<C, ReturnType<F[keyof F]>> {
1095
+ const getCurrentMachine = (): ReturnType<F[keyof F]> => {
1096
+ const currentStateName = getDiscriminant(sharedContext);
1097
+ const factory = factories[currentStateName];
1098
+ if (!factory) {
1099
+ throw new Error(
1100
+ `[MutableMachine] Invalid state: No factory for state "${String(currentStateName)}".`
1101
+ );
1102
+ }
1103
+ return factory(sharedContext) as ReturnType<F[keyof F]>;
1104
+ };
1105
+
1106
+ return new Proxy(sharedContext, {
1107
+ get(target, prop, _receiver) {
1108
+ // 1. Prioritize properties on the context object itself.
1109
+ if (prop in target) {
1110
+ return (target as any)[prop];
1111
+ }
1112
+
1113
+ // 2. If not on context, check if it's a valid transition for the current state.
1114
+ const currentMachine = getCurrentMachine();
1115
+ const transition = (currentMachine as any)[prop];
1116
+
1117
+ if (typeof transition === 'function') {
1118
+ return (...args: any[]) => {
1119
+ // This pattern requires transitions to be pure functions that return the next context.
1120
+ const nextContext = transition.apply(currentMachine.context, args);
1121
+ if (typeof nextContext !== 'object' || nextContext === null) {
1122
+ console.warn(`[MutableMachine] Transition "${String(prop)}" did not return a valid context object. State may be inconsistent.`);
1123
+ return;
1124
+ }
1125
+ // 3. Mutate the shared context with the result.
1126
+ // Clear existing keys before assigning to handle removed properties.
1127
+ Object.keys(target).forEach(key => delete (target as any)[key]);
1128
+ Object.assign(target, nextContext);
1129
+ };
1130
+ }
1131
+
1132
+ return undefined;
1133
+ },
1134
+ set(target, prop, value, _receiver) {
1135
+ // Allow direct mutation of the context
1136
+ (target as any)[prop] = value;
1137
+ return true;
1138
+ },
1139
+ has(target, prop) {
1140
+ // Let checks like `if ('login' in machine)` work correctly.
1141
+ const currentMachine = getCurrentMachine();
1142
+ return prop in target || typeof (currentMachine as any)[prop] === 'function';
1143
+ }
1144
+ }) as MutableMachine<C, ReturnType<F[keyof F]>>;
1145
+ }