machina 6.2.0 → 7.0.0

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/dist/index.d.cts CHANGED
@@ -8,10 +8,366 @@ type Subscription = {
8
8
  off(): void;
9
9
  };
10
10
  //#endregion
11
+ //#region src/behavioral-fsm.d.ts
12
+ /**
13
+ * Defines FSM behavior (states + transitions) while tracking per-client state
14
+ * in a `WeakMap`. A single `BehavioralFsm` instance can drive any number of
15
+ * independent client objects simultaneously — each gets its own state,
16
+ * deferred queue, and lifecycle.
17
+ *
18
+ * Prefer `createBehavioralFsm()` over constructing this directly — the factory
19
+ * infers all generic parameters from the config object.
20
+ *
21
+ * All public methods silently no-op after `dispose()` is called.
22
+ *
23
+ * @typeParam TClient - The client object type. Must be an object (non-primitive)
24
+ * so it can serve as a WeakMap key.
25
+ * @typeParam TStateNames - String literal union of valid state names.
26
+ * @typeParam TInputNames - String literal union of valid input names.
27
+ * @typeParam TBubbles - String literal union of inputs this FSM declares via
28
+ * `bubbles`. Type-only — carried so `BubblesOfInstance` can extract it from
29
+ * a constructed instance; nothing at runtime reads this generic.
30
+ */
31
+ declare class BehavioralFsm<TClient extends object, TStateNames extends string, TInputNames extends string, TBubbles extends string = never> {
32
+ readonly id: string;
33
+ readonly initialState: TStateNames;
34
+ readonly [MACHINA_TYPE]: "BehavioralFsm";
35
+ readonly states: Record<string, Record<string, unknown>>;
36
+ private readonly emitter;
37
+ private readonly clients;
38
+ private readonly knownClients;
39
+ private readonly childSubscriptions;
40
+ private disposed;
41
+ private transitionDepth;
42
+ constructor(config: FsmConfig<TClient, Record<string, Record<string, unknown>>>);
43
+ /**
44
+ * Dispatch an input to the given client's current state handler.
45
+ *
46
+ * Delegation order: if the current state has a `_child` FSM that can
47
+ * handle the input, it is dispatched there. If the child emits `nohandler`,
48
+ * the input bubbles up to this FSM's local handler. If no handler exists
49
+ * here either, `nohandler` is emitted on this FSM's emitter.
50
+ *
51
+ * No-ops silently when disposed.
52
+ */
53
+ handle(client: TClient, inputName: TInputNames, ...args: unknown[]): void;
54
+ /**
55
+ * Returns true if the client's current state has a handler for `inputName`
56
+ * (or a catch-all `"*"` handler), or if the current state's `_child` chain
57
+ * can handle it — the check recurses to the same depth `handle()`'s
58
+ * delegation can actually reach, so a grandchild-only input answers true
59
+ * from the root. Does NOT initialize the client — no `_onEnter`, no
60
+ * events, no side effects. Unseen clients are treated as if they were
61
+ * already in `initialState`. Returns false when disposed.
62
+ */
63
+ canHandle(client: TClient, inputName: string): boolean;
64
+ /**
65
+ * Transition the client back to `initialState`, firing `_onEnter` and
66
+ * lifecycle events as if entering it fresh. No-ops when disposed.
67
+ */
68
+ reset(client: TClient): void;
69
+ /**
70
+ * Returns the client's current state, or `undefined` if the client has
71
+ * never been initialized (i.e. `handle()`, `transition()`, or `reset()`
72
+ * have never been called for it). Does NOT trigger initialization.
73
+ */
74
+ currentState(client: TClient): TStateNames | undefined;
75
+ /**
76
+ * Directly transition `client` to `toState`, running the full lifecycle:
77
+ * `_onExit` for the current state → `transitioning` event → update state →
78
+ * `_onEnter` for new state → `transitioned` event → child reset → deferred
79
+ * queue replay → bounce (if `_onEnter` returned a state name).
80
+ *
81
+ * Same-state transitions are silently ignored. Transitions to unknown state
82
+ * names emit `invalidstate` instead of throwing. Throws if the transition
83
+ * depth exceeds `MAX_TRANSITION_DEPTH` (likely an `_onEnter` → transition loop).
84
+ *
85
+ * No-ops when disposed.
86
+ */
87
+ transition(client: TClient, toState: TStateNames): void;
88
+ /**
89
+ * Returns the client's state as a dot-delimited path including any active
90
+ * child FSM states (e.g. `"active.connecting.retrying"`). Returns just the
91
+ * current state name when no child is active. Returns `""` for clients that
92
+ * have never been initialized (unlike `currentState()` which returns `undefined`).
93
+ */
94
+ compositeState(client: TClient): string;
95
+ /**
96
+ * Silently place `client` at `compositeState` with no lifecycle activity —
97
+ * no `_onEnter`, no `_onExit`, no `transitioning`/`transitioned` events.
98
+ * Designed to work with `compositeState()`, which produces the dot-path
99
+ * string that the string form consumes.
100
+ *
101
+ * The snapshot form (pass a `ClientSnapshot` from `dehydrate()`) additionally
102
+ * requeues each level's pending deferred inputs, so a client resumes with the
103
+ * same replay-on-next-transition behavior it had when dehydrated. Both forms
104
+ * validate the ENTIRE hierarchy before writing anything — a throw at any level
105
+ * leaves every level, including this one, unwritten.
106
+ *
107
+ * Throws synchronously for unknown state names, missing `_child` at an inner
108
+ * level, or Fsm children in the hierarchy (Fsm owns its own context; nothing
109
+ * per-client to rehydrate there).
110
+ *
111
+ * No-ops silently when disposed.
112
+ */
113
+ rehydrate(client: TClient, compositeState: string): void;
114
+ rehydrate(client: TClient, snapshot: ClientSnapshot): void;
115
+ /**
116
+ * Snapshot everything machina tracks for `client`: current state, pending
117
+ * deferred inputs, and — recursively — the same for every `_child` that has
118
+ * ever seen this client, active or not. Feed the result to the object form
119
+ * of `rehydrate()` to restore it later, deferrals included.
120
+ *
121
+ * Returns `undefined` for a client this FSM has never seen (mirrors
122
+ * `currentState()`) — the call does NOT trigger initialization.
123
+ *
124
+ * Throws if any deferred input's args contain a non-serializable value
125
+ * (function, undefined, symbol, bigint, non-finite number, Date/Map/class
126
+ * instance, or a circular reference) — naming the input, its `until` target
127
+ * if any, the FSM id, and the exact value path. Throws for an Fsm child
128
+ * that's on `client`'s active path *relative to the true root* (consistent
129
+ * with `rehydrate()`'s conditional throw) — an Fsm owns its own context, so
130
+ * there's nothing per-client to snapshot. An Fsm child declared at a state
131
+ * `client` never visited, OR nested under a `BehavioralFsm` child that is
132
+ * itself off-path from the root, is skipped rather than throwing — neither
133
+ * has any per-client state to lose, so one Fsm child anywhere in the
134
+ * hierarchy doesn't disable `dehydrate()` for clients that never reach that
135
+ * branch, no matter how deeply nested the Fsm child is.
136
+ *
137
+ * Meant for clients at rest between `handle()` calls — `currentActionArgs`
138
+ * (the in-flight args mid-handler) has no meaning here and is excluded.
139
+ *
140
+ * @param isOnActivePath - @internal Whether this FSM itself is currently
141
+ * reachable from the true root's active path. Defaults to `true` for the
142
+ * public entry point (this FSM IS the root from its own perspective); the
143
+ * `ChildLink` adapter passes `false` down when recursing into a nested
144
+ * `BehavioralFsm` child that is itself off-path, so that child's own
145
+ * Fsm-child checks don't recompute reachability from its dormant local
146
+ * state alone.
147
+ */
148
+ dehydrate(client: TClient, isOnActivePath?: boolean): ClientSnapshot | undefined;
149
+ /**
150
+ * @internal
151
+ * Validates `snapshot` against this level's state graph and recurses into
152
+ * every declared child, returning write thunks to run only once the WHOLE
153
+ * tree — every level — validates successfully. Nothing is written here.
154
+ * Called by the object-form of `rehydrate()` and, recursively, by ChildLink
155
+ * so a nested BehavioralFsm participates in the same validate-then-write
156
+ * pass. Not part of the public persistence API — call `rehydrate()` instead.
157
+ */
158
+ planSnapshotWrites(client: TClient, snapshot: ClientSnapshot): Array<() => void>;
159
+ private rehydrateCompositePath;
160
+ /**
161
+ * Walks every declared state's `_child`, dehydrating each one that has ever
162
+ * seen `client`. The same child instance can be declared under multiple
163
+ * state names (shared child) — it's dehydrated once and the result is
164
+ * reused under every declaring state name, matching how the engine already
165
+ * treats shared children elsewhere (dispose, event subscriptions).
166
+ *
167
+ * Declaring names are grouped by `childLink.instance` (the actual FSM
168
+ * instance, not the `ChildLink` wrapper — `wrapChildLinks()` mints a fresh
169
+ * wrapper per declaring state, so grouping by wrapper would never hit for a
170
+ * shared child) BEFORE any `onPath` is computed or any child is dehydrated.
171
+ * A shared child's `onPath` is the OR of `stateName === activeState` across
172
+ * every declaring name in its group: at most one declaring name can ever
173
+ * equal `activeState`, so this combined flag is correct and, critically,
174
+ * independent of `Object.keys(this.states)` iteration order — computing
175
+ * `onPath` per-declaring-name and caching whichever one happened to run
176
+ * first would silently launder an on-path client's Fsm-child throw through
177
+ * an unrelated off-path declaring name.
178
+ *
179
+ * An off-path Fsm child (declared only at states other than `activeState`,
180
+ * OR nested anywhere under a `BehavioralFsm` child that is itself off-path
181
+ * relative to the true root) is skipped entirely rather than dehydrated:
182
+ * Fsm state isn't tracked per-client to begin with, so an off-path Fsm
183
+ * child has nothing to lose by being skipped — unlike a BehavioralFsm
184
+ * child's off-path meta, which is real per-client data. `isOnActivePath`
185
+ * is the inherited "am I even reachable from the root" flag; combining it
186
+ * with the group's `stateNames.includes(activeState)` check (rather than
187
+ * using that check alone) is what keeps a nested Fsm grandchild from
188
+ * throwing when its immediate BehavioralFsm parent is itself off-path —
189
+ * the parent's own dormant `activeState` is irrelevant once the parent
190
+ * isn't reachable. This keeps one Fsm child anywhere in the hierarchy from
191
+ * disabling `dehydrate()` for every client, only for clients actually on
192
+ * that branch.
193
+ */
194
+ private collectChildSnapshots;
195
+ private snapshotDeferredInput;
196
+ /**
197
+ * Subscribe to a built-in lifecycle event or the wildcard.
198
+ *
199
+ * Named overload: typed payload includes `{ client: TClient }` so you can
200
+ * identify which client the event pertains to. Wildcard (`"*"`) receives
201
+ * `(eventName, data)` for every event. Returns a no-op `Subscription`
202
+ * when disposed.
203
+ */
204
+ on<K extends keyof BehavioralFsmEventMap<TClient, TStateNames> & string>(eventName: K, callback: (data: BehavioralFsmEventMap<TClient, TStateNames>[K]) => void): Subscription;
205
+ on(eventName: "*", callback: (eventName: string, data: unknown) => void): Subscription;
206
+ /**
207
+ * Emit a custom event through the FSM. Built-in lifecycle events are
208
+ * emitted automatically — this is for user-defined events from handlers.
209
+ * No-ops when disposed.
210
+ */
211
+ emit(eventName: string, data?: unknown): void;
212
+ /**
213
+ * Permanently shut down this FSM. Irreversible — all subsequent method
214
+ * calls become silent no-ops. Tears down child subscriptions, clears all
215
+ * listeners, and cascades disposal to child FSMs (unless `preserveChildren`
216
+ * is set). The same child appearing in multiple states is disposed once.
217
+ */
218
+ dispose(options?: DisposeOptions): void;
219
+ /**
220
+ * Walks all states at construction time, detects raw FSM instances assigned
221
+ * to _child, and wraps them into ChildLink adapters via createChildLink().
222
+ * Must run BEFORE setupChildSubscriptions() so the subscriptions see
223
+ * ChildLink objects, not raw FSM instances.
224
+ */
225
+ private wrapChildLinks;
226
+ /**
227
+ * Walks all states at construction time, finds states with _child, and
228
+ * subscribes once to each unique child's wildcard events. Subscriptions are
229
+ * stored for cleanup in dispose(). We deduplicate by `childLink.instance`
230
+ * (the underlying Fsm/BehavioralFsm instance), not the `ChildLink` wrapper —
231
+ * `wrapChildLinks()` mints a fresh wrapper per declaring state, so a child
232
+ * shared across states would otherwise get one subscription PER declaring
233
+ * state, each independently walking known clients and relaying events. That
234
+ * silently double-fires client-less relays (Fsm-child events, or a
235
+ * BehavioralFsm child's custom `emit()` with no `client` in the payload)
236
+ * whenever two different clients are active on two different declaring
237
+ * names at once — the same wrapper-vs-instance identity bug
238
+ * `collectChildSnapshots()` had before its #184 fix.
239
+ */
240
+ private setupChildSubscriptions;
241
+ /**
242
+ * Bubbles a child nohandler to the parent for the given client.
243
+ * Only fires if the client is currently in a state that has this childLink.
244
+ * Extracted from the lambda in setupChildSubscriptions to keep it readable.
245
+ */
246
+ private bubbleNohandler;
247
+ /**
248
+ * Returns true if the given client is currently in a parent state whose
249
+ * _child resolves to the same underlying instance as childLink. Returns
250
+ * false if the client has no meta (never initialized) or is in a state
251
+ * with a different (or no) child.
252
+ *
253
+ * Compares `.instance`, not the `ChildLink` wrapper itself — setupChildSubscriptions()
254
+ * dedupes subscriptions by instance and keeps only ONE representative wrapper
255
+ * per shared child, so the client's actual active declaring state may hold a
256
+ * DIFFERENT wrapper for that same instance (wrapChildLinks() mints one per
257
+ * declaring state). Comparing wrappers directly would only ever match the one
258
+ * declaring state whose wrapper happened to be kept for the subscription,
259
+ * silently breaking relay for every other declaring name of a shared child.
260
+ */
261
+ private isChildActiveForClient;
262
+ /**
263
+ * The inner handler dispatch — no delegation, no initialization side effects
264
+ * beyond what getOrCreateClientMeta already did. Called by handle() after
265
+ * the delegation check, and by the nohandler child listener for bubbling.
266
+ */
267
+ private handleLocally;
268
+ private getOrCreateClientMeta;
269
+ private buildHandlerArgs;
270
+ private processQueue;
271
+ }
272
+ /**
273
+ * Create a behavioral FSM (one definition, many clients) from a config object.
274
+ *
275
+ * `TClient` can't be inferred from the config — there's no `context` property
276
+ * at the FSM level for it to hook into, unlike `createFsm`'s `TCtx`. Supplying
277
+ * it as a single explicit type argument (`createBehavioralFsm<Connection>({...})`)
278
+ * doesn't work either: TypeScript has no partial type-argument inference, so
279
+ * providing one of two type parameters disables inference for the other,
280
+ * widening `TStates` to `Record<string, Record<string, unknown>>` and
281
+ * discarding all literal-type validation.
282
+ *
283
+ * The fix is currying: call with zero arguments to fix `TClient`, then call
284
+ * the returned function with the config to infer `TStates` from it, `const`
285
+ * literal capture and all. This is the recommended way to type a client.
286
+ *
287
+ * If you'd rather skip the type argument entirely, annotate `ctx` inline on
288
+ * at least one handler (e.g. `disconnect({ ctx }: { ctx: Connection }) {...}`)
289
+ * and call with zero type arguments — `TClient` is then inferred from that
290
+ * annotation. This only works when the annotation is visible directly on a
291
+ * handler; it does NOT work through object spread (`...guards`), since
292
+ * TypeScript won't look inside a spread for the annotation.
293
+ *
294
+ * @example
295
+ * ```ts
296
+ * interface Connection { url: string; retries: number; }
297
+ *
298
+ * const connFsm = createBehavioralFsm<Connection>()({
299
+ * id: "connectivity",
300
+ * initialState: "disconnected",
301
+ * states: {
302
+ * disconnected: { connect: "connecting" },
303
+ * connecting: { connected: "online", failed: "disconnected" },
304
+ * online: { disconnect: "disconnected" },
305
+ * },
306
+ * });
307
+ *
308
+ * const conn = { url: "wss://example.com", retries: 0 };
309
+ * connFsm.handle(conn, "connect");
310
+ * ```
311
+ *
312
+ * @example Inline-annotated `ctx`, zero type arguments
313
+ * ```ts
314
+ * const connFsm = createBehavioralFsm({
315
+ * id: "connectivity",
316
+ * initialState: "disconnected",
317
+ * states: {
318
+ * disconnected: {
319
+ * connect({ ctx }: { ctx: Connection }) { return "connecting"; },
320
+ * },
321
+ * connecting: { connected: "online", failed: "disconnected" },
322
+ * online: { disconnect: "disconnected" },
323
+ * },
324
+ * });
325
+ * ```
326
+ *
327
+ * @example Declaring bubbled inputs under the curried form
328
+ * ```ts
329
+ * // childFsm fires "phaseComplete" at itself but never handles it — it
330
+ * // expects whatever mounts it via `_child` to catch the bubble.
331
+ * const childFsm = createBehavioralFsm<Connection>()({
332
+ * id: "child",
333
+ * initialState: "green",
334
+ * bubbles: ["phaseComplete"],
335
+ * states: {
336
+ * green: { advance: "red" },
337
+ * red: {},
338
+ * },
339
+ * });
340
+ *
341
+ * // Mounting it without handling (or re-declaring) "phaseComplete" is a
342
+ * // compile error on `_child` below.
343
+ * const parentFsm = createBehavioralFsm<Connection>()({
344
+ * id: "parent",
345
+ * initialState: "active",
346
+ * states: {
347
+ * active: {
348
+ * _child: childFsm,
349
+ * phaseComplete: "cooldown", // covers the bubble
350
+ * },
351
+ * cooldown: { advance: "active" },
352
+ * },
353
+ * });
354
+ * ```
355
+ */
356
+ declare function createBehavioralFsm<TClient extends object>(): <const TStates extends Record<string, Record<string, unknown>>, TStateNames extends string = keyof TStates & string, TBubbles extends string = never>(config: FsmConfig<TClient, TStates, TStateNames, TBubbles>) => BehavioralFsm<TClient, keyof TStates & string, Exclude<{ [S in keyof TStates]: keyof TStates[S] & string }[keyof TStates], SpecialStateKeys> | { [S in keyof TStates]: TStates[S] extends {
357
+ _child: infer C;
358
+ } ? InputNamesOfInstance<C> : never }[keyof TStates] | TBubbles, TBubbles>;
359
+ declare function createBehavioralFsm<TClient extends object, const TStates extends Record<string, Record<string, unknown>>, TStateNames extends string = keyof TStates & string, TBubbles extends string = never>(config: FsmConfig<TClient, TStates, TStateNames, TBubbles>): BehavioralFsm<TClient, keyof TStates & string, Exclude<{ [S in keyof TStates]: keyof TStates[S] & string }[keyof TStates], SpecialStateKeys> | { [S in keyof TStates]: TStates[S] extends {
360
+ _child: infer C;
361
+ } ? InputNamesOfInstance<C> : never }[keyof TStates] | TBubbles, TBubbles>;
362
+ //#endregion
11
363
  //#region src/types.d.ts
12
364
  /**
13
365
  * Keys on a state object that have special meaning and are NOT input names.
14
366
  * Used by InputNamesOf to filter these out when collecting input names.
367
+ *
368
+ * @internal Exported from this module only so the factory return types in
369
+ * fsm.ts / behavioral-fsm.ts can inline InputNamesOf's definition for
370
+ * readable error text; not re-exported via index.ts.
15
371
  */
16
372
  type SpecialStateKeys = "_onEnter" | "_onExit" | "_child" | "*";
17
373
  /**
@@ -25,16 +381,19 @@ type SpecialStateKeys = "_onEnter" | "_onExit" | "_child" | "*";
25
381
  */
26
382
  type StateNamesOf<TStates> = keyof TStates & string;
27
383
  /**
28
- * Extracts input names as a string literal union from a states config object.
29
- * Collects all handler keys across ALL states, then strips out lifecycle hooks
30
- * and special keys (_onEnter, _onExit, _child, *).
384
+ * Extracts a state config's OWN input names — the handler keys declared
385
+ * directly on its states, before any child FSM's inputs are folded in.
386
+ * Collects all handler keys across ALL states, then strips out lifecycle
387
+ * hooks and special keys (_onEnter, _onExit, _child, *).
31
388
  *
32
- * This is what flows into `handle(inputName)` to provide compile-time
33
- * validation of input names.
389
+ * Split out from `InputNamesOf` so the child-coverage machinery (`CoverageOf`)
390
+ * can ask "what does this FSM handle locally?" without pulling in every
391
+ * descendant's inputs too — coverage is about what THIS level absorbs, not
392
+ * what its children happen to also expose.
34
393
  *
35
394
  * @example
36
395
  * ```ts
37
- * type I = InputNamesOf<{
396
+ * type I = OwnInputNamesOf<{
38
397
  * idle: { start: "running", reset: fn };
39
398
  * running: { pause: "paused", stop: "idle" };
40
399
  * }>;
@@ -48,7 +407,65 @@ type StateNamesOf<TStates> = keyof TStates & string;
48
407
  * handler keys across every state
49
408
  * 3. `Exclude<..., SpecialStateKeys>` — strips lifecycle/special keys
50
409
  */
51
- type InputNamesOf<TStates> = Exclude<{ [S in keyof TStates]: keyof TStates[S] & string }[keyof TStates], SpecialStateKeys>;
410
+ type OwnInputNamesOf<TStates> = Exclude<{ [S in keyof TStates]: keyof TStates[S] & string }[keyof TStates], SpecialStateKeys>;
411
+ /**
412
+ * Extracts input names as a string literal union from a states config object.
413
+ * This is what flows into `handle(inputName)` to provide compile-time
414
+ * validation of input names.
415
+ *
416
+ * A parent's `handle()` genuinely accepts everything its `_child` FSMs accept:
417
+ * the engine checks the child first and only falls through to the parent's
418
+ * own handlers if the child can't handle the input (see `BehavioralFsm.handle`).
419
+ * `InputNamesOf` mirrors that at the type level by unioning the config's own
420
+ * input names with every declared child's input names (recursively —
421
+ * grandchildren ride along because each child's own `InputNamesOfInstance`
422
+ * already folded in ITS children when that child was created).
423
+ *
424
+ * @example
425
+ * ```ts
426
+ * type I = InputNamesOf<{
427
+ * idle: { start: "running", reset: fn };
428
+ * running: { pause: "paused", stop: "idle" };
429
+ * }>;
430
+ * // => "start" | "reset" | "pause" | "stop"
431
+ * ```
432
+ */
433
+ type InputNamesOf<TStates> = OwnInputNamesOf<TStates> | ChildInputNamesOf<TStates>;
434
+ /**
435
+ * Walks every state looking for a `_child`, and folds in that child instance's
436
+ * own input names (via `InputNamesOfInstance`). Not exported — `InputNamesOf`
437
+ * is the public surface; this is just the "look inside _child" half of it.
438
+ */
439
+ type ChildInputNamesOf<TStates> = { [S in keyof TStates]: TStates[S] extends {
440
+ _child: infer C;
441
+ } ? InputNamesOfInstance<C> : never }[keyof TStates];
442
+ /**
443
+ * Extracts state names from a concrete machina FSM instance.
444
+ *
445
+ * Unlike `keyof TFsm`, this reads the class generic that stores user-defined
446
+ * state names, so adapter signatures stay tied to the configured states
447
+ * instead of widening to method/property names.
448
+ */
449
+ type StateNamesOfInstance<TFsm> = TFsm extends Fsm<infer _TCtx extends object, infer TStateNames extends string, infer _TInputNames extends string> ? TStateNames : TFsm extends BehavioralFsm<infer _TClient extends object, infer TStateNames extends string, infer _TInputNames extends string> ? TStateNames : never;
450
+ /**
451
+ * Extracts input names from a concrete machina FSM instance.
452
+ */
453
+ type InputNamesOfInstance<TFsm> = TFsm extends Fsm<infer _TCtx extends object, infer _TStateNames extends string, infer TInputNames extends string> ? TInputNames : TFsm extends BehavioralFsm<infer _TClient extends object, infer _TStateNames extends string, infer TInputNames extends string> ? TInputNames : never;
454
+ /**
455
+ * Extracts the context object type from a concrete single-client FSM instance.
456
+ */
457
+ type ContextOf<TFsm> = TFsm extends Fsm<infer TCtx extends object, infer _TStateNames extends string, infer _TInputNames extends string> ? TCtx : never;
458
+ /**
459
+ * Extracts the client object type from a concrete behavioral FSM instance.
460
+ */
461
+ type ClientOf<TFsm> = TFsm extends BehavioralFsm<infer TClient extends object, infer _TStateNames extends string, infer _TInputNames extends string> ? TClient : never;
462
+ /**
463
+ * Extracts the declared `bubbles` union from a concrete machina FSM instance —
464
+ * the inputs that FSM fires at itself without handling, expecting a `_child`
465
+ * mount point to catch them (see `ChildCoverage`). `never` for an instance
466
+ * that declared no `bubbles` (the common case) or for a non-machina type.
467
+ */
468
+ type BubblesOfInstance<TFsm> = TFsm extends Fsm<infer _TCtx extends object, infer _TStateNames extends string, infer _TInputNames extends string, infer TBubbles extends string> ? TBubbles : TFsm extends BehavioralFsm<infer _TClient extends object, infer _TStateNames extends string, infer _TInputNames extends string, infer TBubbles extends string> ? TBubbles : never;
52
469
  /**
53
470
  * The single combined object passed to every handler.
54
471
  *
@@ -158,30 +575,14 @@ type HandlerFn<TCtx, TStateNames extends string = string> = (args: HandlerArgs<T
158
575
  *
159
576
  * - `TStateNames` — string shorthand, auto-transitions to that state
160
577
  * - `HandlerFn` — function that returns a state name (transition) or void (stay)
161
- *
162
- * @example
163
- * ```ts
164
- * states: {
165
- * green: {
166
- * timeout: "yellow", // string shorthand
167
- * tick({ ctx }) { ctx.tickCount++; }, // function, no transition
168
- * emergency({ ctx }) { // function, conditional transition
169
- * if (ctx.severity > 5) return "red";
170
- * },
171
- * },
172
- * }
173
- * ```
174
- */
175
- /**
176
- * The union of valid handler definition forms for a state input.
177
- *
178
- * - `TStateNames` — string shorthand, auto-transitions to that state
179
- * - `HandlerFn` — function that returns a state name (transition) or void (stay)
180
- * - `MachinaInstance` — included to satisfy TypeScript's structural widening
181
- * when `ValidateStates` falls back to its constraint type. The per-key
182
- * restriction on `_child` is still enforced by `ValidateStates`; this
183
- * union member just prevents the inference engine from rejecting
184
- * `_child: childFsm` before the conditional mapping can evaluate it.
578
+ * - `MachinaInstance` — NOT a handler you'd write under an input key. It's here
579
+ * because `ValidateStates` types every state as the SAME `Record` value type,
580
+ * which combines named properties (`_onEnter`, `_onExit`, `_child`, `"*"`)
581
+ * with a `[input: string]: HandlerDef<...>` index signature covering
582
+ * everything else. TypeScript requires every named property's type to be
583
+ * assignable to the index signature's type, so `_child?: MachinaInstance`
584
+ * only type-checks if `HandlerDef` itself includes `MachinaInstance` in its
585
+ * union. Remove this member and `_child: someChildFsm` stops compiling.
185
586
  *
186
587
  * @example
187
588
  * ```ts
@@ -200,8 +601,11 @@ type HandlerDef<TCtx, TStateNames extends string = string> = TStateNames | Handl
200
601
  /**
201
602
  * Validates and constrains the states object at the type level.
202
603
  *
203
- * This is a mapped type that walks every state and every property within
204
- * each state, assigning the correct expected type based on the property key:
604
+ * A `Record` keyed by `TStateNames` (see the module-level comment above for
605
+ * why it's keyed by this dedicated parameter rather than re-deriving keys
606
+ * from `TStates`). Every state gets the same value shape: named optional
607
+ * keys for lifecycle hooks / `_child` / catch-all, plus an index signature
608
+ * for ordinary inputs.
205
609
  *
206
610
  * | Key | Expected type |
207
611
  * |------------------|-----------------------------------------|
@@ -212,10 +616,63 @@ type HandlerDef<TCtx, TStateNames extends string = string> = TStateNames | Handl
212
616
  * | anything else | HandlerDef (string or fn) |
213
617
  *
214
618
  * @typeParam TCtx - Context/client type, flows into handler signatures
215
- * @typeParam TStates - The literal states object type captured by the
216
- * factory function's generic parameter
619
+ * @typeParam TStates - The literal states object type. Only used here to
620
+ * default `TStateNames` — every other reference in this type uses
621
+ * `TStateNames` directly, never `TStates`, to avoid re-entering inference.
622
+ * @typeParam TStateNames - The state-name union. Defaults to
623
+ * `keyof TStates & string`, but callers that already resolved it
624
+ * (the factory functions) pass it through explicitly.
625
+ */
626
+ type ValidateStates<TCtx, TStates extends Record<string, Record<string, unknown>>, TStateNames extends string = keyof TStates & string> = Record<TStateNames, {
627
+ _onEnter?: HandlerFn<TCtx, NoInfer<TStateNames>>;
628
+ _onExit?: HandlerFn<TCtx, NoInfer<TStateNames>>;
629
+ _child?: MachinaInstance;
630
+ "*"?: HandlerFn<TCtx, NoInfer<TStateNames>>;
631
+ } & {
632
+ [input: string]: HandlerDef<TCtx, NoInfer<TStateNames>>;
633
+ }>;
634
+ /**
635
+ * True when at least one state in `TStates` declares a `"*"` catch-all —
636
+ * a catch-all absorbs every input, including any bubbled one, so it counts
637
+ * as coverage for the whole FSM regardless of which state it lives on.
638
+ */
639
+ type HasCatchAll<TStates> = { [S in keyof TStates]: "*" extends keyof TStates[S] ? true : never }[keyof TStates] extends never ? false : true;
640
+ /**
641
+ * What this FSM absorbs, for the purpose of covering a mounted child's
642
+ * bubbled inputs: everything it handles locally (`OwnInputNamesOf`), plus
643
+ * everything it re-declares as its OWN `bubbles` (re-exporting the
644
+ * obligation to whatever mounts THIS FSM), plus — if a catch-all exists
645
+ * anywhere — literally anything.
217
646
  */
218
- type ValidateStates<TCtx, TStates extends Record<string, Record<string, unknown>>> = { [S in keyof TStates]: { [K in keyof TStates[S]]: K extends "_onEnter" | "_onExit" ? HandlerFn<TCtx, keyof TStates & string> : K extends "_child" ? MachinaInstance : K extends "*" ? HandlerFn<TCtx, keyof TStates & string> : HandlerDef<TCtx, keyof TStates & string> } };
647
+ type CoverageOf<TStates, TBubbles extends string> = OwnInputNamesOf<TStates> | TBubbles | (HasCatchAll<TStates> extends true ? string : never);
648
+ /**
649
+ * For state `S`, if it mounts a `_child`, the child's declared bubbles that
650
+ * this FSM's `CoverageOf` doesn't account for. `never` when the state has no
651
+ * `_child`, or when every bubble is covered.
652
+ */
653
+ type UncoveredChildBubbles<TStates extends Record<string, Record<string, unknown>>, S extends string, TBubbles extends string> = TStates[S] extends {
654
+ _child: infer C;
655
+ } ? Exclude<BubblesOfInstance<C>, CoverageOf<TStates, TBubbles>> : never;
656
+ /**
657
+ * Enforces the bubbled-input wiring contract across an entire `states`
658
+ * config. Intersected onto `FsmConfig.states` alongside `TStates` and
659
+ * `ValidateStates`, so a violation reports on the `states` object itself.
660
+ *
661
+ * Per state: `unknown` (a no-op intersection member) when that state's
662
+ * `_child` mount — if any — has no uncovered bubbles. Otherwise, an
663
+ * impossible-to-satisfy `_child` type whose single property name IS the
664
+ * error message, naming exactly which inputs are uncovered. Coverage is
665
+ * FSM-wide rather than mount-state-local: a bubbled input re-dispatches
666
+ * against whatever state the parent happens to be in when the bubble
667
+ * fires, which can be any state — so coverage declared ANYWHERE in the
668
+ * config (or in this FSM's own `bubbles`, or via a `"*"` anywhere) counts,
669
+ * matching that runtime reality exactly.
670
+ */
671
+ type ChildCoverage<TStates extends Record<string, Record<string, unknown>>, TStateNames extends string, TBubbles extends string> = { [S in TStateNames]: UncoveredChildBubbles<NoInfer<TStates>, S, NoInfer<TBubbles>> extends never ? unknown : {
672
+ _child: {
673
+ "child bubbles up inputs this FSM neither handles nor re-declares": UncoveredChildBubbles<NoInfer<TStates>, S, NoInfer<TBubbles>>;
674
+ };
675
+ } };
219
676
  /**
220
677
  * Configuration object for creating an FSM.
221
678
  *
@@ -223,9 +680,20 @@ type ValidateStates<TCtx, TStates extends Record<string, Record<string, unknown>
223
680
  * For Fsm, this is inferred from the `context` property. For BehavioralFsm,
224
681
  * it's the client object type provided explicitly or as a generic parameter.
225
682
  *
226
- * @typeParam TStates - The literal states object type. Captured by the factory
227
- * function's generic parameter (ideally with `const` to preserve string
228
- * literal types). Defaults to a loose record for unconstrained usage.
683
+ * @typeParam TStates - The literal states object type. Captured directly from
684
+ * the naked `states: TStates & ...` intersection member below (ideally with
685
+ * `const` on the factory's generic to preserve string literal types) —
686
+ * deliberately NOT derived from `ValidateStates`, so an empty or
687
+ * function-only state can't disable inference (see `ValidateStates`'
688
+ * module comment for the full mechanism). Defaults to a loose record for
689
+ * unconstrained usage.
690
+ *
691
+ * @typeParam TStateNames - The state-name union, defaulted from `TStates` but
692
+ * captured as its OWN parameter (see `ValidateStates`) so it's available,
693
+ * fully resolved, while handler bodies are still being type-checked.
694
+ *
695
+ * @typeParam TBubbles - The union of inputs this FSM declares via `bubbles`.
696
+ * Defaults to `never` — most FSMs bubble nothing.
229
697
  *
230
698
  * @example
231
699
  * ```ts
@@ -241,19 +709,52 @@ type ValidateStates<TCtx, TStates extends Record<string, Record<string, unknown>
241
709
  * },
242
710
  * });
243
711
  * ```
712
+ *
713
+ * @example Declaring bubbled inputs for a child FSM
714
+ * ```ts
715
+ * // This FSM fires "phaseComplete" at itself but never handles it — it
716
+ * // expects whatever mounts it via `_child` to catch the bubble.
717
+ * const phaseController = createFsm({
718
+ * id: "phase-controller",
719
+ * initialState: "green",
720
+ * bubbles: ["phaseComplete"],
721
+ * states: {
722
+ * green: { advance: "red" },
723
+ * red: {
724
+ * _onEnter({ ctx }) {
725
+ * setTimeout(() => phaseController.handle("phaseComplete"), 0);
726
+ * },
727
+ * },
728
+ * },
729
+ * });
730
+ *
731
+ * // Mounting it without handling "phaseComplete" (and without re-declaring
732
+ * // it in this FSM's own `bubbles`) is a compile error on `_child` below.
733
+ * const intersection = createFsm({
734
+ * id: "intersection",
735
+ * initialState: "northSouth",
736
+ * states: {
737
+ * northSouth: {
738
+ * _child: phaseController,
739
+ * phaseComplete: "clearance", // this line is what covers the bubble
740
+ * },
741
+ * clearance: { advance: "northSouth" },
742
+ * },
743
+ * });
744
+ * ```
244
745
  */
245
- interface FsmConfig<TCtx, TStates extends Record<string, Record<string, unknown>> = Record<string, Record<string, unknown>>> {
746
+ interface FsmConfig<TCtx, TStates extends Record<string, Record<string, unknown>> = Record<string, Record<string, unknown>>, TStateNames extends string = keyof TStates & string, TBubbles extends string = never> {
246
747
  /** Unique identifier for this FSM */
247
748
  id: string;
248
749
  /**
249
750
  * The state to start in. Must be a key of `states`.
250
751
  *
251
752
  * Wrapped in NoInfer to prevent TypeScript from using this value as an
252
- * inference site for TStates. Without it, `initialState: "green"` could
253
- * narrow TStates to only have a "green" key. We want inference to come
254
- * exclusively from the `states` property.
753
+ * inference site for TStateNames. Without it, `initialState: "green"`
754
+ * could narrow the state-name union to only have a "green" key. We want
755
+ * inference to come exclusively from the `states` property.
255
756
  */
256
- initialState: NoInfer<keyof TStates & string>;
757
+ initialState: NoInfer<TStateNames>;
257
758
  /**
258
759
  * Initial context data. The type is inferred from this value and flows
259
760
  * into every handler's `ctx` parameter.
@@ -262,8 +763,37 @@ interface FsmConfig<TCtx, TStates extends Record<string, Record<string, unknown>
262
763
  * type constraint — the client object IS the context.
263
764
  */
264
765
  context?: TCtx;
265
- /** State definitions. Keys become the state name union. */
266
- states: ValidateStates<TCtx, TStates>;
766
+ /**
767
+ * Inputs this FSM fires at itself without handling them — expecting
768
+ * whatever mounts it via `_child` to catch them through machina's
769
+ * nohandler-bubbling mechanism (see the "Input delegation" section of the
770
+ * hierarchical states guide). Declaring a bubble does two things:
771
+ *
772
+ * 1. Joins this FSM's own typed input union, so a self-directed
773
+ * `fsm.handle("phaseComplete")` (e.g. from inside `_onEnter`)
774
+ * type-checks without a cast.
775
+ * 2. Becomes part of this FSM's mounting contract: any config that
776
+ * mounts it via `_child` must handle every declared bubble in some
777
+ * state, re-declare it in its OWN `bubbles` (passing the obligation
778
+ * up another level), or carry a `"*"` catch-all — enforced at compile
779
+ * time by `ChildCoverage`, which is intersected onto `states` below.
780
+ *
781
+ * A bubble name is NOT a state name — it can't be used as a string
782
+ * shorthand transition target. Omit `bubbles` entirely (the default) for
783
+ * an FSM that never expects a container to catch anything from it — such
784
+ * an FSM can be mounted via `_child` anywhere with no obligations.
785
+ */
786
+ bubbles?: readonly TBubbles[];
787
+ /**
788
+ * State definitions. Keys become the state name union.
789
+ *
790
+ * The intersection with the bare `TStates` is what makes literal-type
791
+ * capture work for empty and function-only states (see `ValidateStates`'
792
+ * module comment) — `ValidateStates` and `ChildCoverage` layer validation
793
+ * and the bubble-coverage contract on top without becoming the inference
794
+ * source themselves.
795
+ */
796
+ states: TStates & ValidateStates<TCtx, TStates, TStateNames> & ChildCoverage<TStates, TStateNames, TBubbles>;
267
797
  }
268
798
  /**
269
799
  * Symbol used as a property key to identify machina FSM instances at runtime.
@@ -298,6 +828,30 @@ interface ChildLink {
298
828
  };
299
829
  /** The child FSM's compositeState for the given client */
300
830
  compositeState(client: object): string;
831
+ /**
832
+ * Silently place `client` at the given composite state within the child hierarchy.
833
+ * Throws for Fsm children (no per-client state to rehydrate).
834
+ */
835
+ rehydrate(client: object, compositeState: string): void;
836
+ /**
837
+ * Snapshot everything this child tracks for `client`, recursing into its own
838
+ * children. `undefined` when the child has never seen this client. Throws for
839
+ * Fsm children that are on the active path — an Fsm owns its own context, so
840
+ * there's nothing per-client to snapshot. `isOnActivePath` is threaded down
841
+ * from the true root's dehydrate() call: it's `false` whenever ANY ancestor
842
+ * declaring state didn't match its own parent's active state, so a nested
843
+ * BehavioralFsm child correctly treats every one of its own Fsm children as
844
+ * off-path once the child itself is off-path, rather than recomputing
845
+ * reachability from its own (possibly dormant) state alone.
846
+ */
847
+ dehydrate(client: object, isOnActivePath: boolean): ClientSnapshot | undefined;
848
+ /**
849
+ * Validates `snapshot` against this child's state graph (recursing into its
850
+ * own children) and returns write thunks to run only once the ENTIRE tree
851
+ * validates — nothing is written here. Throws for Fsm children, matching
852
+ * `dehydrate()`.
853
+ */
854
+ planRehydrate(client: object, snapshot: ClientSnapshot): Array<() => void>;
301
855
  /** Dispose the child FSM */
302
856
  dispose(): void;
303
857
  /**
@@ -369,6 +923,49 @@ interface FsmEventMap<TStateNames extends string = string> {
369
923
  type BehavioralFsmEventMap<TClient, TStateNames extends string = string> = { [K in keyof FsmEventMap<TStateNames>]: FsmEventMap<TStateNames>[K] & {
370
924
  client: TClient;
371
925
  } };
926
+ /**
927
+ * A deferred input queue entry. Created when a handler calls
928
+ * deferUntilTransition() — the input is stored here and replayed
929
+ * after a future state transition.
930
+ */
931
+ interface DeferredInput {
932
+ /** The input name that was deferred */
933
+ inputName: string;
934
+ /** The original arguments passed to handle() for this input */
935
+ args: unknown[];
936
+ /**
937
+ * If set, only replay when entering this specific state.
938
+ * When undefined, replays on the next transition to any state.
939
+ */
940
+ untilState?: string;
941
+ }
942
+ /**
943
+ * Plain-data snapshot of everything machina tracks for one client at a given
944
+ * level of the FSM hierarchy. Produced by `dehydrate()`, consumed by the
945
+ * object-overload of `rehydrate()`.
946
+ *
947
+ * `children` covers every state whose `_child` holds tracking data for this
948
+ * client — active or not. A child the client never reached has no entry
949
+ * (there's nothing to restore). This full-fidelity walk is what makes a
950
+ * rehydrated client behaviorally indistinguishable from one that never left
951
+ * memory: off-path child state and its pending deferrals travel too, and
952
+ * replay/reset exactly as they would have in-memory on the parent's next
953
+ * re-entry.
954
+ */
955
+ interface ClientSnapshot {
956
+ /** This level's state name (not the composite dot-path). */
957
+ state: string;
958
+ /** This level's pending deferred inputs, in FIFO replay order. */
959
+ deferred: DeferredInput[];
960
+ /**
961
+ * One entry per state (at this level) whose `_child` has tracking data
962
+ * for this client, keyed by that state's name. Omitted entirely when no
963
+ * state's child has ever seen this client.
964
+ */
965
+ children?: {
966
+ [stateName: string]: ClientSnapshot;
967
+ };
968
+ }
372
969
  //#endregion
373
970
  //#region src/fsm.d.ts
374
971
  /**
@@ -383,14 +980,17 @@ type BehavioralFsmEventMap<TClient, TStateNames extends string = string> = { [K
383
980
  * @typeParam TCtx - The context type, inferred from `config.context`.
384
981
  * @typeParam TStateNames - String literal union of valid state names.
385
982
  * @typeParam TInputNames - String literal union of valid input names.
983
+ * @typeParam TBubbles - String literal union of inputs this FSM declares via
984
+ * `bubbles`. Type-only — carried so `BubblesOfInstance` can extract it from
985
+ * a constructed instance; nothing at runtime reads this generic.
386
986
  */
387
- declare class Fsm<TCtx extends object, TStateNames extends string, TInputNames extends string> {
987
+ declare class Fsm<TCtx extends object, TStateNames extends string, TInputNames extends string, TBubbles extends string = never> {
388
988
  readonly id: string;
389
989
  readonly initialState: TStateNames;
390
990
  readonly [MACHINA_TYPE]: "Fsm";
391
991
  readonly states: Record<string, Record<string, unknown>>;
392
992
  private readonly bfsm;
393
- private readonly context;
993
+ readonly context: TCtx;
394
994
  private readonly emitter;
395
995
  private disposed;
396
996
  constructor(config: FsmConfig<TCtx, Record<string, Record<string, unknown>>>);
@@ -403,8 +1003,10 @@ declare class Fsm<TCtx extends object, TStateNames extends string, TInputNames e
403
1003
  handle(inputName: TInputNames, ...args: unknown[]): void;
404
1004
  /**
405
1005
  * Returns true if the current state has a handler for `inputName`
406
- * (or a catch-all `"*"` handler). Does not trigger initialization
407
- * or any side effects. Returns false when disposed.
1006
+ * (or a catch-all `"*"` handler), or if the current state's `_child`
1007
+ * chain can handle it (checked recursively, matching `handle()`'s
1008
+ * delegation reach). Does not trigger initialization or any side
1009
+ * effects. Returns false when disposed.
408
1010
  */
409
1011
  canHandle(inputName: string): boolean;
410
1012
  /**
@@ -479,176 +1081,9 @@ declare class Fsm<TCtx extends object, TStateNames extends string, TInputNames e
479
1081
  * light.handle("timeout"); // transitions green → yellow
480
1082
  * ```
481
1083
  */
482
- declare function createFsm<TCtx extends object = Record<string, never>, const TStates extends Record<string, Record<string, unknown>> = Record<string, Record<string, unknown>>>(config: FsmConfig<TCtx, TStates>): Fsm<TCtx, StateNamesOf<TStates>, InputNamesOf<TStates>>;
483
- //#endregion
484
- //#region src/behavioral-fsm.d.ts
485
- /**
486
- * Defines FSM behavior (states + transitions) while tracking per-client state
487
- * in a `WeakMap`. A single `BehavioralFsm` instance can drive any number of
488
- * independent client objects simultaneously — each gets its own state,
489
- * deferred queue, and lifecycle.
490
- *
491
- * Prefer `createBehavioralFsm()` over constructing this directly — the factory
492
- * infers all generic parameters from the config object.
493
- *
494
- * All public methods silently no-op after `dispose()` is called.
495
- *
496
- * @typeParam TClient - The client object type. Must be an object (non-primitive)
497
- * so it can serve as a WeakMap key.
498
- * @typeParam TStateNames - String literal union of valid state names.
499
- * @typeParam TInputNames - String literal union of valid input names.
500
- */
501
- declare class BehavioralFsm<TClient extends object, TStateNames extends string, TInputNames extends string> {
502
- readonly id: string;
503
- readonly initialState: TStateNames;
504
- readonly [MACHINA_TYPE]: "BehavioralFsm";
505
- readonly states: Record<string, Record<string, unknown>>;
506
- private readonly emitter;
507
- private readonly clients;
508
- private readonly knownClients;
509
- private readonly childSubscriptions;
510
- private disposed;
511
- private transitionDepth;
512
- constructor(config: FsmConfig<TClient, Record<string, Record<string, unknown>>>);
513
- /**
514
- * Dispatch an input to the given client's current state handler.
515
- *
516
- * Delegation order: if the current state has a `_child` FSM that can
517
- * handle the input, it is dispatched there. If the child emits `nohandler`,
518
- * the input bubbles up to this FSM's local handler. If no handler exists
519
- * here either, `nohandler` is emitted on this FSM's emitter.
520
- *
521
- * No-ops silently when disposed.
522
- */
523
- handle(client: TClient, inputName: TInputNames, ...args: unknown[]): void;
524
- /**
525
- * Returns true if the client's current state has a handler for `inputName`
526
- * (or a catch-all `"*"` handler). Does NOT initialize the client — no
527
- * `_onEnter`, no events, no side effects. Unseen clients are treated as
528
- * if they were already in `initialState`. Returns false when disposed.
529
- */
530
- canHandle(client: TClient, inputName: string): boolean;
531
- /**
532
- * Transition the client back to `initialState`, firing `_onEnter` and
533
- * lifecycle events as if entering it fresh. No-ops when disposed.
534
- */
535
- reset(client: TClient): void;
536
- /**
537
- * Returns the client's current state, or `undefined` if the client has
538
- * never been initialized (i.e. `handle()`, `transition()`, or `reset()`
539
- * have never been called for it). Does NOT trigger initialization.
540
- */
541
- currentState(client: TClient): TStateNames | undefined;
542
- /**
543
- * Directly transition `client` to `toState`, running the full lifecycle:
544
- * `_onExit` for the current state → `transitioning` event → update state →
545
- * `_onEnter` for new state → `transitioned` event → child reset → deferred
546
- * queue replay → bounce (if `_onEnter` returned a state name).
547
- *
548
- * Same-state transitions are silently ignored. Transitions to unknown state
549
- * names emit `invalidstate` instead of throwing. Throws if the transition
550
- * depth exceeds `MAX_TRANSITION_DEPTH` (likely an `_onEnter` → transition loop).
551
- *
552
- * No-ops when disposed.
553
- */
554
- transition(client: TClient, toState: TStateNames): void;
555
- /**
556
- * Returns the client's state as a dot-delimited path including any active
557
- * child FSM states (e.g. `"active.connecting.retrying"`). Returns just the
558
- * current state name when no child is active. Returns `""` for clients that
559
- * have never been initialized (unlike `currentState()` which returns `undefined`).
560
- */
561
- compositeState(client: TClient): string;
562
- /**
563
- * Subscribe to a built-in lifecycle event or the wildcard.
564
- *
565
- * Named overload: typed payload includes `{ client: TClient }` so you can
566
- * identify which client the event pertains to. Wildcard (`"*"`) receives
567
- * `(eventName, data)` for every event. Returns a no-op `Subscription`
568
- * when disposed.
569
- */
570
- on<K extends keyof BehavioralFsmEventMap<TClient, TStateNames> & string>(eventName: K, callback: (data: BehavioralFsmEventMap<TClient, TStateNames>[K]) => void): Subscription;
571
- on(eventName: "*", callback: (eventName: string, data: unknown) => void): Subscription;
572
- /**
573
- * Emit a custom event through the FSM. Built-in lifecycle events are
574
- * emitted automatically — this is for user-defined events from handlers.
575
- * No-ops when disposed.
576
- */
577
- emit(eventName: string, data?: unknown): void;
578
- /**
579
- * Permanently shut down this FSM. Irreversible — all subsequent method
580
- * calls become silent no-ops. Tears down child subscriptions, clears all
581
- * listeners, and cascades disposal to child FSMs (unless `preserveChildren`
582
- * is set). The same child appearing in multiple states is disposed once.
583
- */
584
- dispose(options?: DisposeOptions): void;
585
- /**
586
- * Walks all states at construction time, detects raw FSM instances assigned
587
- * to _child, and wraps them into ChildLink adapters via createChildLink().
588
- * Must run BEFORE setupChildSubscriptions() so the subscriptions see
589
- * ChildLink objects, not raw FSM instances.
590
- */
591
- private wrapChildLinks;
592
- /**
593
- * Walks all states at construction time, finds states with _child, and
594
- * subscribes once to each unique child's wildcard events. Subscriptions are
595
- * stored for cleanup in dispose(). We deduplicate by child reference to
596
- * avoid double-subscribing when the same child appears in multiple states.
597
- */
598
- private setupChildSubscriptions;
599
- /**
600
- * Bubbles a child nohandler to the parent for the given client.
601
- * Only fires if the client is currently in a state that has this childLink.
602
- * Extracted from the lambda in setupChildSubscriptions to keep it readable.
603
- */
604
- private bubbleNohandler;
605
- /**
606
- * Returns true if the given client is currently in a parent state whose
607
- * _child is the specified childLink. Returns false if the client has no
608
- * meta (never initialized) or is in a state with a different (or no) child.
609
- */
610
- private isChildActiveForClient;
611
- /**
612
- * The inner handler dispatch — no delegation, no initialization side effects
613
- * beyond what getOrCreateClientMeta already did. Called by handle() after
614
- * the delegation check, and by the nohandler child listener for bubbling.
615
- */
616
- private handleLocally;
617
- private getOrCreateClientMeta;
618
- private buildHandlerArgs;
619
- private processQueue;
620
- }
621
- /**
622
- * Create a behavioral FSM (one definition, many clients) from a config object.
623
- *
624
- * Generic parameters are inferred automatically:
625
- * - `TClient` must be provided explicitly as a type parameter (it can't be
626
- * inferred from the config since no `context` property exists at the FSM level).
627
- * - `TStates` is captured with `const` inference to preserve string literal
628
- * types, enabling compile-time validation of transition targets and `handle()`
629
- * input names.
630
- *
631
- * State names, input names, and all handler signatures derive from `TStates`.
632
- *
633
- * @example
634
- * ```ts
635
- * interface Connection { url: string; retries: number; }
636
- *
637
- * const connFsm = createBehavioralFsm<Connection>({
638
- * id: "connectivity",
639
- * initialState: "disconnected",
640
- * states: {
641
- * disconnected: { connect: "connecting" },
642
- * connecting: { connected: "online", failed: "disconnected" },
643
- * online: { disconnect: "disconnected" },
644
- * },
645
- * });
646
- *
647
- * const conn = { url: "wss://example.com", retries: 0 };
648
- * connFsm.handle(conn, "connect");
649
- * ```
650
- */
651
- declare function createBehavioralFsm<TClient extends object, const TStates extends Record<string, Record<string, unknown>>>(config: FsmConfig<TClient, TStates>): BehavioralFsm<TClient, StateNamesOf<TStates>, InputNamesOf<TStates>>;
1084
+ declare function createFsm<TCtx extends object = Record<string, never>, const TStates extends Record<string, Record<string, unknown>> = Record<string, Record<string, unknown>>, TStateNames extends string = keyof TStates & string, TBubbles extends string = never>(config: FsmConfig<TCtx, TStates, TStateNames, TBubbles>): Fsm<TCtx, keyof TStates & string, Exclude<{ [S in keyof TStates]: keyof TStates[S] & string }[keyof TStates], SpecialStateKeys> | { [S in keyof TStates]: TStates[S] extends {
1085
+ _child: infer C;
1086
+ } ? InputNamesOfInstance<C> : never }[keyof TStates] | TBubbles, TBubbles>;
652
1087
  //#endregion
653
- export { BehavioralFsm, type BehavioralFsmEventMap, type ChildLink, type DisposeOptions, Fsm, type FsmConfig, type FsmEventMap, type HandlerArgs, type HandlerDef, type HandlerFn, type InputNamesOf, MACHINA_TYPE, type MachinaInstance, type StateNamesOf, type Subscription, createBehavioralFsm, createFsm };
1088
+ export { BehavioralFsm, type BehavioralFsmEventMap, type BubblesOfInstance, type ChildLink, type ClientOf, type ClientSnapshot, type ContextOf, type DeferredInput, type DisposeOptions, Fsm, type FsmConfig, type FsmEventMap, type HandlerArgs, type HandlerDef, type HandlerFn, type InputNamesOf, type InputNamesOfInstance, MACHINA_TYPE, type MachinaInstance, type StateNamesOf, type StateNamesOfInstance, type Subscription, createBehavioralFsm, createFsm };
654
1089
  //# sourceMappingURL=index.d.cts.map