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/README.md +24 -8
- package/dist/index.cjs +357 -47
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +656 -221
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +656 -221
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +357 -47
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
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
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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
|
-
*
|
|
33
|
-
*
|
|
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 =
|
|
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
|
|
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
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
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
|
-
*
|
|
204
|
-
*
|
|
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
|
|
216
|
-
*
|
|
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
|
|
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
|
|
227
|
-
*
|
|
228
|
-
*
|
|
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
|
|
253
|
-
* narrow
|
|
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<
|
|
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
|
-
/**
|
|
266
|
-
|
|
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
|
-
|
|
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)
|
|
407
|
-
*
|
|
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
|
|
483
|
-
|
|
484
|
-
|
|
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
|