machina 6.3.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/README.md CHANGED
@@ -76,7 +76,7 @@ light.dispose(); // tears down; all subsequent calls are silent no-ops
76
76
 
77
77
  `createBehavioralFsm` defines behavior once and applies it to any number of independent client objects. Per-client state lives in a `WeakMap` — no properties are stamped onto the client. The client object IS the context; handlers receive it as `ctx`.
78
78
 
79
- The client type is provided as an explicit type parameter since it can't be inferred from the config.
79
+ The client type can't be inferred from the config, so it's provided via a curried call — `createBehavioralFsm<Connection>()` — that fixes the client type before the config (and its literal state/input names) is inferred from the second call.
80
80
 
81
81
  ```ts
82
82
  import { createBehavioralFsm } from "machina";
@@ -86,7 +86,7 @@ interface Connection {
86
86
  retries: number;
87
87
  }
88
88
 
89
- const connFsm = createBehavioralFsm<Connection>({
89
+ const connFsm = createBehavioralFsm<Connection>()({
90
90
  id: "connectivity",
91
91
  initialState: "disconnected",
92
92
  states: {
package/dist/index.cjs CHANGED
@@ -51,6 +51,121 @@ var Emitter = class {
51
51
  }
52
52
  };
53
53
 
54
+ //#endregion
55
+ //#region src/json-safe.ts
56
+ /**
57
+ * Thrown internally when the walk hits a value that can't survive a
58
+ * serialization boundary. Callers catch this to attach FSM/input context
59
+ * before re-throwing a fully descriptive error — see `path`/`label` below.
60
+ */
61
+ var NonSerializableValueError = class extends Error {
62
+ constructor(path, label) {
63
+ super(`non-serializable value at ${path} (${label})`);
64
+ this.path = path;
65
+ this.label = label;
66
+ }
67
+ };
68
+ /**
69
+ * Deep-clones `value`, throwing `NonSerializableValueError` the moment it
70
+ * finds anything that isn't `null`, a boolean, a finite number, a string, a
71
+ * plain array, or a plain object. `rootPath` seeds the path used in error
72
+ * messages — e.g. `"args"` so a nested failure reads as `args[1].onComplete`.
73
+ *
74
+ * Cloning (rather than just validating) is what keeps a returned snapshot
75
+ * from aliasing live FSM state: mutating the snapshot afterward — or the
76
+ * original value passed into a deferred `handle()` call — can't reach back
77
+ * into the FSM's internal deferred queue.
78
+ */
79
+ const cloneJsonSafe = (value, rootPath) => {
80
+ return cloneNode(value, rootPath, /* @__PURE__ */ new Set());
81
+ };
82
+ const cloneNode = (value, path, ancestors) => {
83
+ if (value === null) return null;
84
+ switch (typeof value) {
85
+ case "string":
86
+ case "boolean": return value;
87
+ case "number":
88
+ if (!Number.isFinite(value)) throw new NonSerializableValueError(path, describeNonFiniteNumber(value));
89
+ return value;
90
+ case "object": return cloneObject(value, path, ancestors);
91
+ default: throw new NonSerializableValueError(path, typeof value);
92
+ }
93
+ };
94
+ const cloneObject = (obj, path, ancestors) => {
95
+ if (ancestors.has(obj)) throw new NonSerializableValueError(path, "circular reference");
96
+ if (Array.isArray(obj)) {
97
+ if (obj.length !== Object.keys(obj).length) throw new NonSerializableValueError(path, "sparse array or array with non-index properties");
98
+ ancestors.add(obj);
99
+ const cloned = obj.map((item, i) => cloneNode(item, `${path}[${i}]`, ancestors));
100
+ ancestors.delete(obj);
101
+ return cloned;
102
+ }
103
+ const proto = Object.getPrototypeOf(obj);
104
+ if (proto !== Object.prototype && proto !== null) throw new NonSerializableValueError(path, obj.constructor?.name ?? "object");
105
+ ancestors.add(obj);
106
+ const cloned = {};
107
+ for (const key of Object.keys(obj)) {
108
+ const value = cloneNode(obj[key], `${path}.${key}`, ancestors);
109
+ Object.defineProperty(cloned, key, {
110
+ value,
111
+ enumerable: true,
112
+ writable: true,
113
+ configurable: true
114
+ });
115
+ }
116
+ ancestors.delete(obj);
117
+ return cloned;
118
+ };
119
+ const describeNonFiniteNumber = (n) => {
120
+ if (Number.isNaN(n)) return "NaN";
121
+ return n > 0 ? "Infinity" : "-Infinity";
122
+ };
123
+ /**
124
+ * Deep-clones `value` WITHOUT validating it — unlike `cloneJsonSafe`, this
125
+ * never throws. Plain objects/arrays are cloned recursively (so a caller
126
+ * can't alias state back into whatever holds the result); anything else
127
+ * (functions, `Date`/`Map`/class instances, symbols, non-finite numbers,
128
+ * etc.) is passed through by reference as-is.
129
+ *
130
+ * This is `rehydrate()`'s side of the aliasing guarantee: `dehydrate()`
131
+ * validates-and-clones on the way OUT (via `cloneJsonSafe`), but `rehydrate()`
132
+ * trusts the snapshot it's given is already valid data — re-validating on
133
+ * the way IN would be a redundant, unwanted asymmetry (see the build plan's
134
+ * disclosed known gaps). We still need the clone so mutating the caller's
135
+ * snapshot object after `rehydrate()` returns can't reach into the live
136
+ * FSM's internal deferred queue.
137
+ *
138
+ * Cycle-safe: a value that contains itself is returned as-is (by reference)
139
+ * rather than cloned infinitely — there's no validation step here to make
140
+ * that throw, so silently keeping the shared reference is the only option
141
+ * that doesn't hang.
142
+ */
143
+ const cloneDeep = (value, ancestors = /* @__PURE__ */ new Set()) => {
144
+ if (value === null || typeof value !== "object") return value;
145
+ if (ancestors.has(value)) return value;
146
+ if (Array.isArray(value)) {
147
+ ancestors.add(value);
148
+ const cloned = value.map((item) => cloneDeep(item, ancestors));
149
+ ancestors.delete(value);
150
+ return cloned;
151
+ }
152
+ const proto = Object.getPrototypeOf(value);
153
+ if (proto !== Object.prototype && proto !== null) return value;
154
+ ancestors.add(value);
155
+ const cloned = {};
156
+ for (const key of Object.keys(value)) {
157
+ const clonedValue = cloneDeep(value[key], ancestors);
158
+ Object.defineProperty(cloned, key, {
159
+ value: clonedValue,
160
+ enumerable: true,
161
+ writable: true,
162
+ configurable: true
163
+ });
164
+ }
165
+ ancestors.delete(value);
166
+ return cloned;
167
+ };
168
+
54
169
  //#endregion
55
170
  //#region src/types.ts
56
171
  /**
@@ -78,6 +193,9 @@ const MAX_TRANSITION_DEPTH = 20;
78
193
  * so it can serve as a WeakMap key.
79
194
  * @typeParam TStateNames - String literal union of valid state names.
80
195
  * @typeParam TInputNames - String literal union of valid input names.
196
+ * @typeParam TBubbles - String literal union of inputs this FSM declares via
197
+ * `bubbles`. Type-only — carried so `BubblesOfInstance` can extract it from
198
+ * a constructed instance; nothing at runtime reads this generic.
81
199
  */
82
200
  var BehavioralFsm = class {
83
201
  id;
@@ -126,15 +244,20 @@ var BehavioralFsm = class {
126
244
  }
127
245
  /**
128
246
  * Returns true if the client's current state has a handler for `inputName`
129
- * (or a catch-all `"*"` handler). Does NOT initialize the client — no
130
- * `_onEnter`, no events, no side effects. Unseen clients are treated as
131
- * if they were already in `initialState`. Returns false when disposed.
247
+ * (or a catch-all `"*"` handler), or if the current state's `_child` chain
248
+ * can handle it — the check recurses to the same depth `handle()`'s
249
+ * delegation can actually reach, so a grandchild-only input answers true
250
+ * from the root. Does NOT initialize the client — no `_onEnter`, no
251
+ * events, no side effects. Unseen clients are treated as if they were
252
+ * already in `initialState`. Returns false when disposed.
132
253
  */
133
254
  canHandle(client, inputName) {
134
255
  if (this.disposed) return false;
135
256
  const state = this.clients.get(client)?.state ?? this.initialState;
136
257
  const stateObj = this.states[state];
137
- return !!(stateObj?.[inputName] ?? stateObj?.["*"]);
258
+ if (stateObj?.[inputName] ?? stateObj?.["*"]) return true;
259
+ const childLink = stateObj?._child;
260
+ return childLink ? childLink.canHandle(client, inputName) : false;
138
261
  }
139
262
  /**
140
263
  * Transition the client back to `initialState`, firing `_onEnter` and
@@ -169,7 +292,7 @@ var BehavioralFsm = class {
169
292
  const meta = this.getOrCreateClientMeta(client);
170
293
  const fromState = meta.state;
171
294
  if (toState === fromState) return;
172
- if (!(toState in this.states)) {
295
+ if (!Object.hasOwn(this.states, toState)) {
173
296
  this.emitter.emit("invalidstate", {
174
297
  stateName: toState,
175
298
  client
@@ -225,25 +348,96 @@ var BehavioralFsm = class {
225
348
  }
226
349
  return meta.state;
227
350
  }
351
+ rehydrate(client, input) {
352
+ if (this.disposed) return;
353
+ if (typeof input === "string") {
354
+ this.rehydrateCompositePath(client, input);
355
+ return;
356
+ }
357
+ const writes = this.planSnapshotWrites(client, input);
358
+ for (const write of writes) write();
359
+ }
228
360
  /**
229
- * Silently place `client` at `compositeState` with no lifecycle activity.
230
- * Designed to work with `compositeState()`, which produces the dot-path
231
- * string that `rehydrate()` consumes.
361
+ * Snapshot everything machina tracks for `client`: current state, pending
362
+ * deferred inputs, and — recursively — the same for every `_child` that has
363
+ * ever seen this client, active or not. Feed the result to the object form
364
+ * of `rehydrate()` to restore it later, deferrals included.
232
365
  *
233
- * No `_onEnter`, no `_onExit`, no `transitioning`/`transitioned` events.
234
- * If `compositeState` is a dot-delimited path (`"active.connecting"`), the client
235
- * is placed at each level of the hierarchy in turn. Subsequent `handle()` calls
236
- * proceed as if the client had reached that state through normal transitions.
366
+ * Returns `undefined` for a client this FSM has never seen (mirrors
367
+ * `currentState()`) — the call does NOT trigger initialization.
237
368
  *
238
- * Throws synchronously for unknown state names, missing `_child` at an inner level,
239
- * or Fsm children in the hierarchy (Fsm owns its context; nothing to rehydrate there).
369
+ * Throws if any deferred input's args contain a non-serializable value
370
+ * (function, undefined, symbol, bigint, non-finite number, Date/Map/class
371
+ * instance, or a circular reference) — naming the input, its `until` target
372
+ * if any, the FSM id, and the exact value path. Throws for an Fsm child
373
+ * that's on `client`'s active path *relative to the true root* (consistent
374
+ * with `rehydrate()`'s conditional throw) — an Fsm owns its own context, so
375
+ * there's nothing per-client to snapshot. An Fsm child declared at a state
376
+ * `client` never visited, OR nested under a `BehavioralFsm` child that is
377
+ * itself off-path from the root, is skipped rather than throwing — neither
378
+ * has any per-client state to lose, so one Fsm child anywhere in the
379
+ * hierarchy doesn't disable `dehydrate()` for clients that never reach that
380
+ * branch, no matter how deeply nested the Fsm child is.
240
381
  *
241
- * No-ops silently when disposed.
382
+ * Meant for clients at rest between `handle()` calls — `currentActionArgs`
383
+ * (the in-flight args mid-handler) has no meaning here and is excluded.
384
+ *
385
+ * @param isOnActivePath - @internal Whether this FSM itself is currently
386
+ * reachable from the true root's active path. Defaults to `true` for the
387
+ * public entry point (this FSM IS the root from its own perspective); the
388
+ * `ChildLink` adapter passes `false` down when recursing into a nested
389
+ * `BehavioralFsm` child that is itself off-path, so that child's own
390
+ * Fsm-child checks don't recompute reachability from its dormant local
391
+ * state alone.
242
392
  */
243
- rehydrate(client, compositeState) {
244
- if (this.disposed) return;
393
+ dehydrate(client, isOnActivePath = true) {
394
+ const meta = this.clients.get(client);
395
+ if (!meta) return;
396
+ const snapshot = {
397
+ state: meta.state,
398
+ deferred: meta.deferredQueue.map((item) => this.snapshotDeferredInput(item))
399
+ };
400
+ const children = this.collectChildSnapshots(client, meta.state, isOnActivePath);
401
+ if (children) snapshot.children = children;
402
+ return snapshot;
403
+ }
404
+ /**
405
+ * @internal
406
+ * Validates `snapshot` against this level's state graph and recurses into
407
+ * every declared child, returning write thunks to run only once the WHOLE
408
+ * tree — every level — validates successfully. Nothing is written here.
409
+ * Called by the object-form of `rehydrate()` and, recursively, by ChildLink
410
+ * so a nested BehavioralFsm participates in the same validate-then-write
411
+ * pass. Not part of the public persistence API — call `rehydrate()` instead.
412
+ */
413
+ planSnapshotWrites(client, snapshot) {
414
+ if (this.disposed) return [];
415
+ const { state, deferred, children } = snapshot;
416
+ if (!Object.hasOwn(this.states, state)) throw new Error(`rehydrate: unknown state "${state}" in FSM "${this.id}". Valid states: ${Object.keys(this.states).join(", ")}`);
417
+ const writes = [];
418
+ if (children) for (const stateName of Object.keys(children)) {
419
+ if (!Object.hasOwn(this.states, stateName)) throw new Error(`rehydrate: unknown state "${stateName}" in FSM "${this.id}" referenced by snapshot.children.`);
420
+ const childLink = this.states[stateName]?._child;
421
+ if (!childLink) throw new Error(`rehydrate: state "${stateName}" in FSM "${this.id}" has no _child, but the snapshot has a children["${stateName}"] entry.`);
422
+ writes.push(...childLink.planRehydrate(client, children[stateName]));
423
+ }
424
+ const deferredQueue = deferred.map((item) => ({
425
+ inputName: item.inputName,
426
+ args: cloneDeep(item.args),
427
+ ...item.untilState !== void 0 ? { untilState: item.untilState } : {}
428
+ }));
429
+ writes.push(() => {
430
+ if (!this.clients.has(client)) this.knownClients.add(new WeakRef(client));
431
+ this.clients.set(client, {
432
+ state,
433
+ deferredQueue
434
+ });
435
+ });
436
+ return writes;
437
+ }
438
+ rehydrateCompositePath(client, compositeState) {
245
439
  const [state, ...rest] = compositeState.split(".");
246
- if (!(state in this.states)) throw new Error(`rehydrate: unknown state "${state}" in FSM "${this.id}". Valid states: ${Object.keys(this.states).join(", ")}`);
440
+ if (!Object.hasOwn(this.states, state)) throw new Error(`rehydrate: unknown state "${state}" in FSM "${this.id}". Valid states: ${Object.keys(this.states).join(", ")}`);
247
441
  if (rest.length > 0) {
248
442
  const childPath = rest.join(".");
249
443
  const childLink = this.states[state]?._child;
@@ -256,6 +450,80 @@ var BehavioralFsm = class {
256
450
  deferredQueue: []
257
451
  });
258
452
  }
453
+ /**
454
+ * Walks every declared state's `_child`, dehydrating each one that has ever
455
+ * seen `client`. The same child instance can be declared under multiple
456
+ * state names (shared child) — it's dehydrated once and the result is
457
+ * reused under every declaring state name, matching how the engine already
458
+ * treats shared children elsewhere (dispose, event subscriptions).
459
+ *
460
+ * Declaring names are grouped by `childLink.instance` (the actual FSM
461
+ * instance, not the `ChildLink` wrapper — `wrapChildLinks()` mints a fresh
462
+ * wrapper per declaring state, so grouping by wrapper would never hit for a
463
+ * shared child) BEFORE any `onPath` is computed or any child is dehydrated.
464
+ * A shared child's `onPath` is the OR of `stateName === activeState` across
465
+ * every declaring name in its group: at most one declaring name can ever
466
+ * equal `activeState`, so this combined flag is correct and, critically,
467
+ * independent of `Object.keys(this.states)` iteration order — computing
468
+ * `onPath` per-declaring-name and caching whichever one happened to run
469
+ * first would silently launder an on-path client's Fsm-child throw through
470
+ * an unrelated off-path declaring name.
471
+ *
472
+ * An off-path Fsm child (declared only at states other than `activeState`,
473
+ * OR nested anywhere under a `BehavioralFsm` child that is itself off-path
474
+ * relative to the true root) is skipped entirely rather than dehydrated:
475
+ * Fsm state isn't tracked per-client to begin with, so an off-path Fsm
476
+ * child has nothing to lose by being skipped — unlike a BehavioralFsm
477
+ * child's off-path meta, which is real per-client data. `isOnActivePath`
478
+ * is the inherited "am I even reachable from the root" flag; combining it
479
+ * with the group's `stateNames.includes(activeState)` check (rather than
480
+ * using that check alone) is what keeps a nested Fsm grandchild from
481
+ * throwing when its immediate BehavioralFsm parent is itself off-path —
482
+ * the parent's own dormant `activeState` is irrelevant once the parent
483
+ * isn't reachable. This keeps one Fsm child anywhere in the hierarchy from
484
+ * disabling `dehydrate()` for every client, only for clients actually on
485
+ * that branch.
486
+ */
487
+ collectChildSnapshots(client, activeState, isOnActivePath) {
488
+ const declaringNamesByInstance = /* @__PURE__ */ new Map();
489
+ for (const stateName of Object.keys(this.states)) {
490
+ const childLink = this.states[stateName]?._child;
491
+ if (!childLink) continue;
492
+ const entry = declaringNamesByInstance.get(childLink.instance);
493
+ if (entry) entry.stateNames.push(stateName);
494
+ else declaringNamesByInstance.set(childLink.instance, {
495
+ childLink,
496
+ stateNames: [stateName]
497
+ });
498
+ }
499
+ let children;
500
+ for (const { childLink, stateNames } of declaringNamesByInstance.values()) {
501
+ const onPath = isOnActivePath && stateNames.includes(activeState);
502
+ if (childLink.instance[MACHINA_TYPE] === "Fsm" && !onPath) continue;
503
+ const childSnapshot = childLink.dehydrate(client, onPath);
504
+ if (childSnapshot) {
505
+ children ??= {};
506
+ for (const stateName of stateNames) children[stateName] = childSnapshot;
507
+ }
508
+ }
509
+ return children;
510
+ }
511
+ snapshotDeferredInput(item) {
512
+ let clonedArgs;
513
+ try {
514
+ clonedArgs = cloneJsonSafe(item.args, "args");
515
+ } catch (err) {
516
+ if (!(err instanceof NonSerializableValueError)) throw err;
517
+ const untilPart = item.untilState ? ` (until "${item.untilState}")` : "";
518
+ throw new Error(`dehydrate: deferred input "${item.inputName}"${untilPart} in FSM "${this.id}" has a non-serializable value at ${err.path} (${err.label})`);
519
+ }
520
+ const snapshot = {
521
+ inputName: item.inputName,
522
+ args: clonedArgs
523
+ };
524
+ if (item.untilState !== void 0) snapshot.untilState = item.untilState;
525
+ return snapshot;
526
+ }
259
527
  on(eventName, callback) {
260
528
  if (this.disposed) return { off() {} };
261
529
  return this.emitter.on(eventName, callback);
@@ -282,8 +550,8 @@ var BehavioralFsm = class {
282
550
  const seen = /* @__PURE__ */ new Set();
283
551
  for (const stateName of Object.keys(this.states)) {
284
552
  const childLink = this.states[stateName]?._child;
285
- if (childLink && !seen.has(childLink)) {
286
- seen.add(childLink);
553
+ if (childLink && !seen.has(childLink.instance)) {
554
+ seen.add(childLink.instance);
287
555
  childLink.dispose();
288
556
  }
289
557
  }
@@ -309,15 +577,23 @@ var BehavioralFsm = class {
309
577
  /**
310
578
  * Walks all states at construction time, finds states with _child, and
311
579
  * subscribes once to each unique child's wildcard events. Subscriptions are
312
- * stored for cleanup in dispose(). We deduplicate by child reference to
313
- * avoid double-subscribing when the same child appears in multiple states.
580
+ * stored for cleanup in dispose(). We deduplicate by `childLink.instance`
581
+ * (the underlying Fsm/BehavioralFsm instance), not the `ChildLink` wrapper —
582
+ * `wrapChildLinks()` mints a fresh wrapper per declaring state, so a child
583
+ * shared across states would otherwise get one subscription PER declaring
584
+ * state, each independently walking known clients and relaying events. That
585
+ * silently double-fires client-less relays (Fsm-child events, or a
586
+ * BehavioralFsm child's custom `emit()` with no `client` in the payload)
587
+ * whenever two different clients are active on two different declaring
588
+ * names at once — the same wrapper-vs-instance identity bug
589
+ * `collectChildSnapshots()` had before its #184 fix.
314
590
  */
315
591
  setupChildSubscriptions() {
316
- const seenChildren = /* @__PURE__ */ new Set();
592
+ const seenInstances = /* @__PURE__ */ new Set();
317
593
  for (const stateName of Object.keys(this.states)) {
318
594
  const childLink = this.states[stateName]?._child;
319
- if (!childLink || seenChildren.has(childLink)) continue;
320
- seenChildren.add(childLink);
595
+ if (!childLink || seenInstances.has(childLink.instance)) continue;
596
+ seenInstances.add(childLink.instance);
321
597
  const sub = childLink.onAny((eventName, data) => {
322
598
  if (eventName === "nohandler") {
323
599
  const payload = data;
@@ -363,13 +639,22 @@ var BehavioralFsm = class {
363
639
  }
364
640
  /**
365
641
  * Returns true if the given client is currently in a parent state whose
366
- * _child is the specified childLink. Returns false if the client has no
367
- * meta (never initialized) or is in a state with a different (or no) child.
642
+ * _child resolves to the same underlying instance as childLink. Returns
643
+ * false if the client has no meta (never initialized) or is in a state
644
+ * with a different (or no) child.
645
+ *
646
+ * Compares `.instance`, not the `ChildLink` wrapper itself — setupChildSubscriptions()
647
+ * dedupes subscriptions by instance and keeps only ONE representative wrapper
648
+ * per shared child, so the client's actual active declaring state may hold a
649
+ * DIFFERENT wrapper for that same instance (wrapChildLinks() mints one per
650
+ * declaring state). Comparing wrappers directly would only ever match the one
651
+ * declaring state whose wrapper happened to be kept for the subscription,
652
+ * silently breaking relay for every other declaring name of a shared child.
368
653
  */
369
654
  isChildActiveForClient(client, childLink) {
370
655
  const meta = this.clients.get(client);
371
656
  if (!meta) return false;
372
- return this.states[meta.state]?._child === childLink;
657
+ return (this.states[meta.state]?._child)?.instance === childLink.instance;
373
658
  }
374
659
  /**
375
660
  * The inner handler dispatch — no delegation, no initialization side effects
@@ -480,6 +765,12 @@ function createChildLink(child) {
480
765
  rehydrate(client, compositeState) {
481
766
  child.rehydrate(client, compositeState);
482
767
  },
768
+ dehydrate(client, isOnActivePath) {
769
+ return child.dehydrate(client, isOnActivePath);
770
+ },
771
+ planRehydrate(client, snapshot) {
772
+ return child.planSnapshotWrites(client, snapshot);
773
+ },
483
774
  dispose() {
484
775
  child.dispose();
485
776
  }
@@ -504,43 +795,20 @@ function createChildLink(child) {
504
795
  rehydrate(_client, _compositeState) {
505
796
  throw new Error("rehydrate: cannot rehydrate an Fsm child. Fsm owns its own context; rehydrate is only valid for BehavioralFsm hierarchies.");
506
797
  },
798
+ dehydrate(_client, _isOnActivePath) {
799
+ throw new Error("dehydrate: cannot dehydrate an Fsm child. Fsm owns its own context; dehydrate is only valid for BehavioralFsm hierarchies.");
800
+ },
801
+ planRehydrate(_client, _snapshot) {
802
+ throw new Error("rehydrate: cannot rehydrate an Fsm child. Fsm owns its own context; rehydrate is only valid for BehavioralFsm hierarchies.");
803
+ },
507
804
  dispose() {
508
805
  child.dispose();
509
806
  }
510
807
  };
511
808
  throw new Error(`createChildLink: expected an Fsm or BehavioralFsm instance, got [MACHINA_TYPE] = ${String(childType ?? "undefined")}`);
512
809
  }
513
- /**
514
- * Create a behavioral FSM (one definition, many clients) from a config object.
515
- *
516
- * Generic parameters are inferred automatically:
517
- * - `TClient` must be provided explicitly as a type parameter (it can't be
518
- * inferred from the config since no `context` property exists at the FSM level).
519
- * - `TStates` is captured with `const` inference to preserve string literal
520
- * types, enabling compile-time validation of transition targets and `handle()`
521
- * input names.
522
- *
523
- * State names, input names, and all handler signatures derive from `TStates`.
524
- *
525
- * @example
526
- * ```ts
527
- * interface Connection { url: string; retries: number; }
528
- *
529
- * const connFsm = createBehavioralFsm<Connection>({
530
- * id: "connectivity",
531
- * initialState: "disconnected",
532
- * states: {
533
- * disconnected: { connect: "connecting" },
534
- * connecting: { connected: "online", failed: "disconnected" },
535
- * online: { disconnect: "disconnected" },
536
- * },
537
- * });
538
- *
539
- * const conn = { url: "wss://example.com", retries: 0 };
540
- * connFsm.handle(conn, "connect");
541
- * ```
542
- */
543
810
  function createBehavioralFsm(config) {
811
+ if (config === void 0) return (curriedConfig) => new BehavioralFsm(curriedConfig);
544
812
  return new BehavioralFsm(config);
545
813
  }
546
814
 
@@ -558,6 +826,9 @@ function createBehavioralFsm(config) {
558
826
  * @typeParam TCtx - The context type, inferred from `config.context`.
559
827
  * @typeParam TStateNames - String literal union of valid state names.
560
828
  * @typeParam TInputNames - String literal union of valid input names.
829
+ * @typeParam TBubbles - String literal union of inputs this FSM declares via
830
+ * `bubbles`. Type-only — carried so `BubblesOfInstance` can extract it from
831
+ * a constructed instance; nothing at runtime reads this generic.
561
832
  */
562
833
  var Fsm = class {
563
834
  id;
@@ -594,8 +865,10 @@ var Fsm = class {
594
865
  }
595
866
  /**
596
867
  * Returns true if the current state has a handler for `inputName`
597
- * (or a catch-all `"*"` handler). Does not trigger initialization
598
- * or any side effects. Returns false when disposed.
868
+ * (or a catch-all `"*"` handler), or if the current state's `_child`
869
+ * chain can handle it (checked recursively, matching `handle()`'s
870
+ * delegation reach). Does not trigger initialization or any side
871
+ * effects. Returns false when disposed.
599
872
  */
600
873
  canHandle(inputName) {
601
874
  if (this.disposed) return false;