@statewalker/fsm 0.37.0 → 0.38.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@statewalker/fsm",
3
- "version": "0.37.0",
3
+ "version": "0.38.0",
4
4
  "description": "HFSM Implementation",
5
5
  "keywords": [],
6
6
  "homepage": "https://github.com/statewalker/statewalker-fsm",
@@ -10,11 +10,7 @@
10
10
  },
11
11
  "license": "MIT",
12
12
  "type": "module",
13
- "bin": {
14
- "fsm": "./bin/cli.js"
15
- },
16
13
  "files": [
17
- "bin",
18
14
  "dist",
19
15
  "src"
20
16
  ],
@@ -28,19 +24,13 @@
28
24
  "types": "./dist/index.d.ts",
29
25
  "import": "./dist/index.js",
30
26
  "default": "./dist/index.js"
31
- },
32
- "./*": {
33
- "source": "./src/*.ts",
34
- "types": "./dist/*.d.ts",
35
- "import": "./dist/*.js",
36
- "default": "./dist/*.js"
37
27
  }
38
28
  },
39
29
  "devDependencies": {
40
- "@biomejs/biome": "^2.3.0",
41
- "tsdown": "^0.12.4",
42
- "typescript": "^5.9.2",
43
- "vitest": "^3.2.4"
30
+ "@biomejs/biome": "catalog:",
31
+ "tsdown": "catalog:",
32
+ "typescript": "^6.0.3",
33
+ "vitest": "catalog:"
44
34
  },
45
35
  "repository": {
46
36
  "type": "git",
@@ -1,10 +1,29 @@
1
1
  type Handler<P extends unknown[] = unknown[], R = unknown> = (...args: P) => R;
2
2
 
3
+ /**
4
+ * Shared handler-registry substrate under both `FsmProcess` and `FsmState`.
5
+ *
6
+ * Both the process and every state need the same primitive: keep named lists of
7
+ * callbacks (`onEnter`, `onExit`, `dump`, …) and run them with consistent ordering
8
+ * and error routing. Centralising it here keeps the two subclasses thin and their
9
+ * hook methods one-liners. `handlers` maps a hook name to its ordered callback list;
10
+ * the protected `_addHandler` / `_removeHandler` / `_runHandler` manage and invoke
11
+ * them. Handlers run **sequentially** (awaited in turn); a throwing handler is routed
12
+ * to `_handleError` rather than aborting the batch. Subclasses expose typed sugar
13
+ * (e.g. `state.onEnter(fn)` calls `_addHandler("onEnter", fn)`) and override
14
+ * `_handleError` to forward errors.
15
+ */
3
16
  export class FsmBaseClass {
4
17
  handlers: Record<string, Handler[]> = {};
5
18
 
6
19
  // ----------------------------------------------
7
20
  // internal methods
21
+
22
+ /**
23
+ * Register `handler` under `type`; returns a disposer that removes it.
24
+ * `direct=false` prepends instead of appends — used so `onExit` handlers run in
25
+ * reverse (inner-to-outer) unwinding order.
26
+ */
8
27
  protected _addHandler<T>(type: string, handler: T, direct: boolean = true) {
9
28
  let list = this.handlers[type];
10
29
  if (!list) {
@@ -26,6 +45,7 @@ export class FsmBaseClass {
26
45
  }
27
46
  }
28
47
 
48
+ /** Run every handler of `type` in order, awaiting each; route throws to `_handleError`. */
29
49
  async _runHandler(type: string, ...args: unknown[]) {
30
50
  const list = this.handlers[type] || [];
31
51
  for (const handler of list) {
@@ -37,11 +57,16 @@ export class FsmBaseClass {
37
57
  }
38
58
  }
39
59
 
60
+ /** Default error sink — logs. `FsmState`/`FsmProcess` override it to fire `onStateError`. */
40
61
  async _handleError(error: Error | unknown) {
41
62
  console.error(error);
42
63
  }
43
64
  }
44
65
 
66
+ /**
67
+ * Bind the named methods of `obj` to `obj` in place, so they can be passed as bare
68
+ * callbacks (e.g. `ctx[KEY_DISPATCH] = process.dispatch`) without losing `this`.
69
+ */
45
70
  export function bindMethods<T>(obj: T, ...methods: (string | symbol)[]) {
46
71
  const o = obj as Record<string | symbol, unknown>;
47
72
  for (const methodName of methods) {
@@ -12,22 +12,36 @@ import {
12
12
  } from "./fsm-state-config.ts";
13
13
  import { FsmStateDescriptor } from "./fsm-state-descriptor.ts";
14
14
 
15
+ // Status bitmask: where the process is in the enter/exit cycle of `dispatch`.
16
+ // Why a bitmask (rather than an enum): the loop tests *phases* with cheap bitwise
17
+ // masks — `status & STATUS_ENTER`, `status & this.mask` — and composite masks
18
+ // (ENTER / EXIT) are just OR-combinations of the primitive bits.
19
+ /** Not started / no current transition. */
15
20
  export const STATUS_NONE = 0;
21
+ /** Entering: descended into a parent's first (initial) child. */
16
22
  export const STATUS_FIRST = 1;
23
+ /** Entering: advanced to a resolved target (sibling) state. */
17
24
  export const STATUS_NEXT = 2;
25
+ /** Rested on a leaf — dispatch returns control here (the default `mask`). */
18
26
  export const STATUS_LEAF = 4;
27
+ /** Exiting: popped back up to the parent state. */
19
28
  export const STATUS_LAST = 8;
29
+ /** Terminated: the machine has exited its root and cannot advance further. */
20
30
  export const STATUS_FINISHED = 16;
21
31
 
22
32
  //
33
+ /** Composite: any "entering" phase. */
23
34
  export const STATUS_ENTER = STATUS_FIRST | STATUS_NEXT;
35
+ /** Composite: any "exiting" phase. */
24
36
  export const STATUS_EXIT = STATUS_LEAF | STATUS_LAST;
25
37
 
38
+ /** A process-level handler (`onStateCreate` / `onStateError`). */
26
39
  export type FsmProcessHandler = (
27
40
  process: FsmProcess,
28
41
  ...args: unknown[]
29
42
  ) => void | Promise<void>;
30
43
 
44
+ /** Serialized form of the whole machine: status, last event, and the root→leaf state stack. */
31
45
  export type FsmProcessDump = Record<string, unknown> & {
32
46
  status: number;
33
47
  event?: string;
@@ -39,6 +53,19 @@ export type FsmProcessDumpHandler = (
39
53
  dump: FsmProcessDump,
40
54
  ) => void | Promise<void>;
41
55
 
56
+ /**
57
+ * The running state machine — owner of the active-state stack and the traversal.
58
+ *
59
+ * This is the engine: given a compiled config it drives the enter/exit walk in
60
+ * response to events, so callers reason in terms of states and events, not manual
61
+ * stack bookkeeping. `dispatch(event)` advances the machine to the next resting leaf;
62
+ * `shutdown(event?)` unwinds every active state; `state` is the current leaf;
63
+ * `onStateCreate(handler)` is the primary extension point (fires once per created
64
+ * state — attach that state's hooks there); and `dump()` / `restore(dump)` snapshot
65
+ * and rehydrate the whole stack. Typical use: `new FsmProcess(config)`, register
66
+ * `onStateCreate`, then `dispatch("")` to enter the initial state and
67
+ * `dispatch(event)` for each subsequent event.
68
+ */
42
69
  export class FsmProcess extends FsmBaseClass {
43
70
  state?: FsmState;
44
71
  event?: string;
@@ -56,6 +83,7 @@ export class FsmProcess extends FsmBaseClass {
56
83
  bindMethods(this, "dispatch", "dump", "restore");
57
84
  }
58
85
 
86
+ /** Force-exit the whole stack (root last), running each state's `onExit`; ends the machine. */
59
87
  async shutdown(event?: string) {
60
88
  while (this.state) {
61
89
  this.event = event;
@@ -65,6 +93,15 @@ export class FsmProcess extends FsmBaseClass {
65
93
  }
66
94
  }
67
95
 
96
+ /**
97
+ * Feed an event to the machine and run the enter/exit cycle until it rests on a
98
+ * leaf (`status & mask`) or finishes.
99
+ *
100
+ * If a dispatch is already running (e.g. an `onEnter` handler dispatches
101
+ * synchronously), the event is queued in `nextEvent` and applied when the current
102
+ * run settles — runs never nest. Returns `false` once the machine has finished,
103
+ * `true` otherwise.
104
+ */
68
105
  async dispatch(event: string): Promise<boolean> {
69
106
  this.nextEvent = event;
70
107
  if (!this.running && !(this.status & STATUS_FINISHED)) {
@@ -95,6 +132,10 @@ export class FsmProcess extends FsmBaseClass {
95
132
  return !(this.status & STATUS_FINISHED);
96
133
  }
97
134
 
135
+ /**
136
+ * Snapshot the machine to a plain object: `{ status, event, stack }` where the
137
+ * stack is root→leaf, each entry carrying whatever its `dump` hooks recorded.
138
+ */
98
139
  async dump(...args: unknown[]): Promise<FsmProcessDump> {
99
140
  const dumpState = async (state: FsmState) => {
100
141
  const stateDump: FsmStateDump = {
@@ -121,6 +162,11 @@ export class FsmProcess extends FsmBaseClass {
121
162
  return dump;
122
163
  }
123
164
 
165
+ /**
166
+ * Rebuild the machine from a `dump`: recreate each state in the stack (firing
167
+ * `onStateCreate`) and replay its `restore` hooks, leaving the process resumable
168
+ * from where it was snapshotted.
169
+ */
124
170
  async restore(dump: FsmProcessDump, ...args: unknown[]) {
125
171
  this.status = dump.status || 0;
126
172
  this.event = dump.event;
@@ -140,6 +186,7 @@ export class FsmProcess extends FsmBaseClass {
140
186
  return this;
141
187
  }
142
188
 
189
+ /** Primary extension point: fires once for every state the machine creates. */
143
190
  onStateCreate(handler: FsmStateHandler) {
144
191
  return this._addHandler("onStateCreate", handler, true);
145
192
  }
@@ -191,6 +238,14 @@ export class FsmProcess extends FsmBaseClass {
191
238
  return this._newState(parent, toState, descriptor);
192
239
  }
193
240
 
241
+ /**
242
+ * One step of the traversal: compute the next state and update `status`.
243
+ *
244
+ * Either descends to a child / advances to a resolved target (setting an ENTER
245
+ * status), or — when no target resolves — settles on the current leaf
246
+ * (`STATUS_LEAF`) or pops to the parent (`STATUS_LAST`), reaching `STATUS_FINISHED`
247
+ * once the root is exited. Returns `false` when finished.
248
+ */
194
249
  async _update() {
195
250
  if (this.status & STATUS_FINISHED) return false;
196
251
  const nextState =
@@ -1,11 +1,38 @@
1
+ // Sentinel keys used inside `FsmStateConfig.transitions` tuples. The raw `""` and
2
+ // `"*"` literals are ambiguous at the call site — `["", "start", "Active"]` does not
3
+ // visibly say "from the *initial* state" — so these named constants document intent
4
+ // while keeping the runtime value (a shared empty string / asterisk) identical, which
5
+ // keeps configs plain-serializable.
6
+
7
+ /** Wildcard `from`-state: a transition that applies in *any* state. */
1
8
  export const STATE_ANY = "*";
9
+ /** Empty `from`-state: the *initial* pseudo-state a parent enters into. */
2
10
  export const STATE_INITIAL = "";
11
+ /** Empty `to`-state: the *final* pseudo-state; entering it exits the parent. */
3
12
  export const STATE_FINAL = "";
13
+ /** Wildcard `event`: a transition triggered by *any* event. */
4
14
  export const EVENT_ANY = "*";
15
+ /** Empty event: the eventless/automatic transition (fires with no named event). */
5
16
  export const EVENT_EMPTY = "";
6
17
 
7
18
  export type FsmStateKey = string;
8
19
  export type FsmEventKey = string;
20
+
21
+ /**
22
+ * Declarative definition of a (possibly nested) state machine.
23
+ *
24
+ * One plain-object shape describes an entire HFSM, so machines stay serializable,
25
+ * diffable, and inspectable by tooling — the config is data, not code. A config has:
26
+ * - `key` — the state's name (unique among its siblings);
27
+ * - `transitions` — `[from, event, to]` tuples, where `from`/`to` name sibling
28
+ * states, `""` is the initial (`from`) or final (`to`) pseudo-state, and `"*"` is a
29
+ * wildcard (see the constants above);
30
+ * - `states` — nested child configs; entering this state descends into its initial
31
+ * child. The index signature lets consumers attach arbitrary metadata.
32
+ *
33
+ * Pass a config to `new FsmProcess(config)` or `startProcess(ctx, config, …)`; it is
34
+ * compiled once into an `FsmStateDescriptor` for fast lookup at runtime.
35
+ */
9
36
  export type FsmStateConfig = {
10
37
  key: FsmStateKey;
11
38
  transitions?: [from: FsmStateKey, event: FsmEventKey, to: FsmStateKey][];
@@ -5,10 +5,24 @@ import {
5
5
  STATE_FINAL,
6
6
  } from "./fsm-state-config.ts";
7
7
 
8
+ /**
9
+ * The *compiled* form of an `FsmStateConfig` subtree.
10
+ *
11
+ * Resolving a transition happens on every event, so the flat `[from, event, to]`
12
+ * tuple list is pre-indexed once into nested maps for O(1) lookup, and the
13
+ * wildcard-fallback order is applied here rather than re-derived on each dispatch.
14
+ * The shape is `transitions[from][event] = to` plus a `states` map of child
15
+ * descriptors. `FsmProcess` builds one from your config in its constructor; you
16
+ * rarely construct a descriptor directly.
17
+ */
8
18
  export class FsmStateDescriptor {
9
19
  transitions: Record<string, Record<string, string>> = {};
10
20
  states: Record<string, FsmStateDescriptor> = {};
11
21
 
22
+ /**
23
+ * Recursively compile a config (and its nested `states`) into descriptors,
24
+ * turning the tuple list into the indexed `transitions` map.
25
+ */
12
26
  static build(config: FsmStateConfig) {
13
27
  const descriptor = new FsmStateDescriptor();
14
28
  for (const [from, event, to] of config.transitions || []) {
@@ -27,6 +41,13 @@ export class FsmStateDescriptor {
27
41
  return descriptor;
28
42
  }
29
43
 
44
+ /**
45
+ * Resolve the target state for a `(stateKey, eventKey)` pair. Tries the most
46
+ * specific match first, then falls back through the wildcards —
47
+ * `(state, event)` → `(*, event)` → `(state, *)` → `(*, *)` — and returns
48
+ * `STATE_FINAL` if nothing matches, so an unmatched event exits the state rather
49
+ * than silently doing nothing.
50
+ */
30
51
  getTargetStateKey(stateKey: string, eventKey: string) {
31
52
  const pairs = [
32
53
  [stateKey, eventKey],
@@ -2,25 +2,41 @@ import { bindMethods, FsmBaseClass } from "./fsm-base-class.ts";
2
2
  import type { FsmProcess } from "./fsm-process.ts";
3
3
  import type { FsmStateDescriptor } from "./fsm-state-descriptor.ts";
4
4
 
5
+ /** Serialized form of a single state: its `key` plus the `data` bag its `dump` hooks filled. */
5
6
  export type FsmStateDump = Record<string, unknown> & {
6
7
  key: string;
7
8
  data: Record<string, unknown>;
8
9
  };
10
+ /** An `onEnter` / `onExit` callback; receives the state it fired on. */
9
11
  export type FsmStateHandler = (
10
12
  state: FsmState,
11
13
  ...args: unknown[]
12
14
  ) => void | Promise<void>;
13
15
 
16
+ /** A `dump` / `restore` callback; reads or fills the mutable per-state `data` bag. */
14
17
  export type FsmStateDumpHandler = (
15
18
  state: FsmState,
16
19
  dump: FsmStateDump,
17
20
  ) => void | Promise<void>;
18
21
 
22
+ /** An `onStateError` callback; receives the error thrown by another handler on this state. */
19
23
  export type FsmStateErrorHandler = (
20
24
  state: FsmState,
21
25
  error: unknown,
22
26
  ) => void | Promise<void>;
23
27
 
28
+ /**
29
+ * One live node in a running machine's active-state stack.
30
+ *
31
+ * A state needs a place to attach behaviour and record data while it is active.
32
+ * `FsmState` is that handle — created by the engine each time a state is entered,
33
+ * discarded when it exits — so handlers can capture per-activation closures instead
34
+ * of sharing mutable machine-wide state. It offers lifecycle hooks `onEnter` /
35
+ * `onExit` / `onStateError`, serialization hooks `dump` / `restore`, and the tree
36
+ * links `key` / `parent` / `descriptor`; every hook method returns a disposer.
37
+ * Attach hooks from within `FsmProcess.onStateCreate((state) => …)`, or let
38
+ * `startProcess` install them for you from a `load` callback.
39
+ */
24
40
  export class FsmState extends FsmBaseClass {
25
41
  process: FsmProcess;
26
42
  key: string;
@@ -41,18 +57,23 @@ export class FsmState extends FsmBaseClass {
41
57
  bindMethods(this, "onEnter", "onExit", "dump", "restore", "onStateError");
42
58
  }
43
59
 
60
+ /** Run when this state is entered. */
44
61
  onEnter(handler: FsmStateHandler) {
45
62
  return this._addHandler("onEnter", handler, true);
46
63
  }
64
+ /** Run when this state exits — registered inner-first so unwinding is inner-to-outer. */
47
65
  onExit(handler: FsmStateHandler) {
48
66
  return this._addHandler("onExit", handler, false);
49
67
  }
68
+ /** Handle an error thrown by another handler on this state (also bubbles to the process). */
50
69
  onStateError(handler: FsmStateErrorHandler) {
51
70
  return this._addHandler("onStateError", handler);
52
71
  }
72
+ /** Contribute to this state's snapshot: fill `dump.data` when the process is dumped. */
53
73
  dump(handler: FsmStateDumpHandler) {
54
74
  return this._addHandler("dump", handler, true);
55
75
  }
76
+ /** Rehydrate from this state's snapshot: read `dump.data` when the process is restored. */
56
77
  restore(handler: FsmStateDumpHandler) {
57
78
  return this._addHandler("restore", handler, true);
58
79
  }
@@ -0,0 +1,72 @@
1
+ import type { FsmProcess } from "./fsm-process.ts";
2
+ import type { FsmState } from "./fsm-state.ts";
3
+ import type { FsmStateDescriptor } from "./fsm-state-descriptor.ts";
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Transition introspection — read-only queries over the state/transition graph
7
+ // ---------------------------------------------------------------------------
8
+
9
+ /**
10
+ * List the transitions currently reachable from `state` — for a UI or viewer that
11
+ * needs to know which events can fire right now (e.g. to render only the enabled
12
+ * buttons). Collects `[from, event, to]` tuples by walking up the parent chain; the
13
+ * nearest state's rule for an event wins, so an outer fallback is masked by an inner
14
+ * override. The returned tuples are ordered outer→inner (root first).
15
+ */
16
+ export function getStateTransitions(
17
+ state?: FsmState,
18
+ ): [from: string, event: string, to: string][] {
19
+ const result: [from: string, event: string, to: string][] = [];
20
+ const index: Record<string, boolean> = {};
21
+ if (state) {
22
+ let prevStateKey = state.key;
23
+ for (let parent = state.parent; parent; parent = parent.parent) {
24
+ if (!parent.descriptor) continue;
25
+ result.push(
26
+ ...getTransitionsFromDescriptor(parent.descriptor, prevStateKey, index),
27
+ );
28
+ prevStateKey = parent.key;
29
+ }
30
+ }
31
+ return result.reverse();
32
+ }
33
+
34
+ function getTransitionsFromDescriptor(
35
+ descriptor: FsmStateDescriptor,
36
+ prevStateKey: string,
37
+ index: Record<string, boolean>,
38
+ ): [from: string, event: string, to: string][] {
39
+ const result: [from: string, event: string, to: string][] = [];
40
+ const prevStateKeys = [prevStateKey, "*"];
41
+ for (const prevKey of prevStateKeys) {
42
+ const targets = descriptor.transitions[prevKey];
43
+ if (targets) {
44
+ for (const [event, target] of Object.entries(targets)) {
45
+ if (index[event]) continue;
46
+ // Mark the event seen regardless of target — an inner exit-to-final
47
+ // (`to === ""`, a falsy target) must still mask an outer rule for the
48
+ // same event, matching what `dispatch` actually resolves.
49
+ index[event] = true;
50
+ result.push([prevStateKey, event, target]);
51
+ }
52
+ }
53
+ }
54
+ return result;
55
+ }
56
+
57
+ /**
58
+ * Guard: would `event` trigger any transition in the process's current state? Used
59
+ * to avoid dispatching dead events — the runner calls it before `dispatch`, and
60
+ * consumers use it to enable/disable controls. Returns `true` iff `event` appears
61
+ * among `getStateTransitions(process.state)`.
62
+ */
63
+ export function isStateTransitionEnabled(
64
+ process: FsmProcess,
65
+ event: string,
66
+ ): boolean {
67
+ const transitions = getStateTransitions(process.state);
68
+ for (const [, ev] of transitions) {
69
+ if (ev === event) return true;
70
+ }
71
+ return false;
72
+ }
package/src/core/index.ts CHANGED
@@ -3,3 +3,4 @@ export * from "./fsm-process.ts";
3
3
  export * from "./fsm-state.ts";
4
4
  export * from "./fsm-state-config.ts";
5
5
  export * from "./fsm-state-descriptor.ts";
6
+ export * from "./fsm-transitions.ts";
package/src/index.ts CHANGED
@@ -1,3 +1,3 @@
1
1
  export * from "./core/index.ts";
2
- export * from "./orchestrator/index.ts";
3
- export * from "./utils/index.ts";
2
+ export * from "./start-process.ts";
3
+ export * from "./trace/index.ts";
@@ -1,74 +1,49 @@
1
- import { FsmProcess, type FsmProcessDump } from "../core/fsm-process.ts";
2
- import type { FsmState } from "../core/fsm-state.ts";
3
- import type { FsmStateConfig } from "../core/fsm-state-config.ts";
4
- import type { FsmStateDescriptor } from "../core/fsm-state-descriptor.ts";
5
- import type { StageHandler } from "./handler-registry.ts";
6
-
1
+ import { FsmProcess, type FsmProcessDump } from "./core/fsm-process.ts";
2
+ import type { FsmState } from "./core/fsm-state.ts";
3
+ import type { FsmStateConfig } from "./core/fsm-state-config.ts";
4
+ import { isStateTransitionEnabled } from "./core/fsm-transitions.ts";
5
+
6
+ /**
7
+ * Per-state behaviour contract used by `startProcess`. One function shape expresses
8
+ * what to do while in a state, and its *return value* wires up the rest, so a state's
9
+ * setup and teardown live together. The handler runs on entry with the shared
10
+ * `context` and may return:
11
+ * - nothing — no teardown;
12
+ * - a cleanup `function` — registered as this state's `onExit`;
13
+ * - an async/sync generator — run concurrently, each yielded string dispatched back
14
+ * into the machine (self-driving/reactive states), auto-`return()`ed on exit.
15
+ */
16
+ export type StageHandler<C = Record<string, unknown>> = (
17
+ context: C,
18
+ ) =>
19
+ | void
20
+ | (() => void | Promise<void>)
21
+ | Promise<void | (() => void | Promise<void>)>
22
+ | AsyncGenerator<string, void, unknown>
23
+ | Generator<string, void, unknown>;
24
+
25
+ /**
26
+ * The caller's remote control returned by `startProcess`: stop the machine and
27
+ * snapshot/rehydrate it without holding a reference to the underlying `FsmProcess`.
28
+ */
7
29
  export interface ProcessHandle {
8
30
  shutdown(): Promise<void>;
9
31
  dump(...args: unknown[]): Promise<FsmProcessDump>;
10
32
  restore(dump: FsmProcessDump, ...args: unknown[]): Promise<void>;
11
33
  }
12
34
 
13
- // Context keys for binding FSM functions into the shared context object.
35
+ // Context keys under which `startProcess` binds the machine into the shared context
36
+ // object. Why via context (not closures): handlers reach the running machine
37
+ // uniformly — `(ctx[KEY_DISPATCH])(event)` — regardless of where they are defined.
38
+ /** Context key: `(event) => Promise<void>` that dispatches an event (guarded). */
14
39
  export const KEY_DISPATCH = "fsm:dispatch";
40
+ /** Context key: `() => Promise<void>` that shuts the machine down. */
15
41
  export const KEY_TERMINATE = "fsm:terminate";
42
+ /** Context key: the current state-key stack (root→leaf), refreshed on every transition. */
16
43
  export const KEY_STATES = "fsm:states";
44
+ /** Context key: the last dispatched event. */
17
45
  export const KEY_EVENT = "fsm:event";
18
46
 
19
- // ---------------------------------------------------------------------------
20
- // Transition introspection
21
- // ---------------------------------------------------------------------------
22
-
23
- export function getStateTransitions(
24
- state?: FsmState,
25
- ): [from: string, event: string, to: string][] {
26
- const result: [from: string, event: string, to: string][] = [];
27
- const index: Record<string, boolean> = {};
28
- if (state) {
29
- let prevStateKey = state.key;
30
- for (let parent = state.parent; parent; parent = parent.parent) {
31
- if (!parent.descriptor) continue;
32
- result.push(
33
- ...getTransitionsFromDescriptor(parent.descriptor, prevStateKey, index),
34
- );
35
- prevStateKey = parent.key;
36
- }
37
- }
38
- return result.reverse();
39
- }
40
-
41
- function getTransitionsFromDescriptor(
42
- descriptor: FsmStateDescriptor,
43
- prevStateKey: string,
44
- index: Record<string, boolean>,
45
- ): [from: string, event: string, to: string][] {
46
- const result: [from: string, event: string, to: string][] = [];
47
- const prevStateKeys = [prevStateKey, "*"];
48
- for (const prevKey of prevStateKeys) {
49
- const targets = descriptor.transitions[prevKey];
50
- if (targets) {
51
- for (const [event, target] of Object.entries(targets)) {
52
- if (index[event]) continue;
53
- if (target) index[event] = true;
54
- result.push([prevStateKey, event, target]);
55
- }
56
- }
57
- }
58
- return result;
59
- }
60
-
61
- export function isStateTransitionEnabled(
62
- process: FsmProcess,
63
- event: string,
64
- ): boolean {
65
- const transitions = getStateTransitions(process.state);
66
- for (const [, ev] of transitions) {
67
- if (ev === event) return true;
68
- }
69
- return false;
70
- }
71
-
72
47
  // ---------------------------------------------------------------------------
73
48
  // Generator detection
74
49
  // ---------------------------------------------------------------------------
@@ -90,6 +65,24 @@ function isGenerator(
90
65
  // startProcess (also exported as startFsmProcess for backward compat)
91
66
  // ---------------------------------------------------------------------------
92
67
 
68
+ /**
69
+ * Ergonomic runner: build a machine, attach behaviour via one `load` callback, and
70
+ * bind the machine into `context`.
71
+ *
72
+ * Doing this by hand (`new FsmProcess` + `onStateCreate` + `onEnter` + a loader +
73
+ * context wiring) is boilerplate every consumer repeats, so `startProcess` does it
74
+ * once — most callers use this instead of the raw engine. It creates the process; on
75
+ * each state entry calls `load(stateKey, event)` and installs the returned
76
+ * `StageHandler`s (their return values become `onExit` cleanups or event-yielding
77
+ * generators — see `StageHandler`); binds `KEY_DISPATCH` / `KEY_TERMINATE` /
78
+ * `KEY_STATES` / `KEY_EVENT` into `context`; dispatches `startEvent` to enter the
79
+ * initial state; and returns a `ProcessHandle`.
80
+ *
81
+ * @param context shared object handlers read/write and the machine is bound into
82
+ * @param config the declarative machine definition
83
+ * @param load returns the handler(s) to run for a given `(stateKey, event)`
84
+ * @param startEvent initial event (default `""`, the eventless start)
85
+ */
93
86
  export async function startProcess<C = unknown>(
94
87
  context: C,
95
88
  config: FsmStateConfig,
@@ -126,8 +119,12 @@ export async function startProcess<C = unknown>(
126
119
  await dispatch(event);
127
120
  if (stateExited || terminated) break;
128
121
  }
129
- } catch {
130
- // Swallow generator errors after return()
122
+ } catch (error) {
123
+ // A generator `return()` during state exit / termination throws an
124
+ // abort we intentionally swallow; a genuine error thrown by the
125
+ // generator body must surface through the state's error handling
126
+ // rather than vanish.
127
+ if (!stateExited && !terminated) await state._handleError(error);
131
128
  }
132
129
  })();
133
130
  } else if (typeof result === "function") {
@@ -184,5 +181,5 @@ export async function startProcess<C = unknown>(
184
181
  };
185
182
  }
186
183
 
187
- /** @deprecated Use `startProcess` instead */
184
+ /** Permanent equal alias of {@link startProcess} the name existing consumers import. */
188
185
  export const startFsmProcess = startProcess;
@@ -1,5 +1,13 @@
1
1
  import type { FsmProcess } from "../core/fsm-process.ts";
2
+
3
+ /** A sink for trace output — anything shaped like `console.log`. */
2
4
  export type Printer = (...args: unknown[]) => void;
5
+ /**
6
+ * Tuning for a `Printer`.
7
+ * - `prefix` — string prepended to every line (e.g. a process tag).
8
+ * - `print` — the underlying sink (defaults to `console.log`).
9
+ * - `lineNumbers` — prepend an incrementing `[n]` counter.
10
+ */
3
11
  export type PrinterConfig = {
4
12
  prefix?: string;
5
13
  print?: (...args: unknown[]) => void;
@@ -8,6 +16,11 @@ export type PrinterConfig = {
8
16
 
9
17
  const printerStore = new WeakMap<object, Printer>();
10
18
 
19
+ /**
20
+ * Build a `Printer` bound to `process` that indents each line by the current state
21
+ * nesting depth (two spaces per level), so log output visually mirrors the state
22
+ * tree — hierarchical indentation makes enter/exit traces readable at a glance.
23
+ */
11
24
  export function preparePrinter(
12
25
  process: FsmProcess,
13
26
  { prefix = "", print = console.log, lineNumbers = false }: PrinterConfig,
@@ -25,6 +38,11 @@ export function preparePrinter(
25
38
  return printer;
26
39
  }
27
40
 
41
+ /**
42
+ * Attach a printer to `process` (stored in a weak map so it is GC'd with the
43
+ * process). Handlers/tracers then reach it via {@link getProcessPrinter} /
44
+ * {@link getPrinter} rather than threading a logger through every call.
45
+ */
28
46
  export function setProcessPrinter(
29
47
  process: FsmProcess,
30
48
  config: PrinterConfig = {},
@@ -33,10 +51,12 @@ export function setProcessPrinter(
33
51
  printerStore.set(process, printer);
34
52
  }
35
53
 
54
+ /** The printer attached to `process`, or `console.log` if none was set. */
36
55
  export function getProcessPrinter(process: FsmProcess): Printer {
37
56
  return printerStore.get(process) || console.log;
38
57
  }
39
58
 
59
+ /** The printer for a state — its own if set, else its process's (see {@link getProcessPrinter}). */
40
60
  export function getPrinter(state: { process: FsmProcess }): Printer {
41
61
  return printerStore.get(state) || getProcessPrinter(state.process);
42
62
  }