aifsmjs 0.5.5 → 0.5.8

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.
Files changed (59) hide show
  1. package/README.md +44 -513
  2. package/README_ZHTW.md +44 -505
  3. package/dist/chunk-2MME5V4F.cjs +384 -0
  4. package/dist/chunk-2MME5V4F.cjs.map +1 -0
  5. package/dist/chunk-3B2USJ3H.cjs +18 -0
  6. package/dist/chunk-3B2USJ3H.cjs.map +1 -0
  7. package/dist/chunk-A7U7QQL5.js +52 -0
  8. package/dist/chunk-A7U7QQL5.js.map +1 -0
  9. package/dist/chunk-CDK25FTD.cjs +59 -0
  10. package/dist/chunk-CDK25FTD.cjs.map +1 -0
  11. package/dist/chunk-FHTQ7LSQ.cjs +21 -0
  12. package/dist/chunk-FHTQ7LSQ.cjs.map +1 -0
  13. package/dist/chunk-I354FONA.cjs +153 -0
  14. package/dist/chunk-I354FONA.cjs.map +1 -0
  15. package/dist/chunk-JKZAOPQC.js +16 -0
  16. package/dist/chunk-JKZAOPQC.js.map +1 -0
  17. package/dist/chunk-NEJYZAKR.js +19 -0
  18. package/dist/chunk-NEJYZAKR.js.map +1 -0
  19. package/dist/chunk-PZ5AY32C.js +9 -0
  20. package/dist/chunk-PZ5AY32C.js.map +1 -0
  21. package/dist/chunk-Q7SFCCGT.cjs +11 -0
  22. package/dist/chunk-Q7SFCCGT.cjs.map +1 -0
  23. package/dist/chunk-VZCSHTOI.js +374 -0
  24. package/dist/chunk-VZCSHTOI.js.map +1 -0
  25. package/dist/chunk-ZLQ7HZCE.js +142 -0
  26. package/dist/chunk-ZLQ7HZCE.js.map +1 -0
  27. package/dist/effects/index.cjs +8 -14
  28. package/dist/effects/index.cjs.map +1 -1
  29. package/dist/effects/index.js +5 -14
  30. package/dist/effects/index.js.map +1 -1
  31. package/dist/guards/index.cjs +9 -13
  32. package/dist/guards/index.cjs.map +1 -1
  33. package/dist/guards/index.js +8 -12
  34. package/dist/guards/index.js.map +1 -1
  35. package/dist/index.cjs +101 -591
  36. package/dist/index.cjs.map +1 -1
  37. package/dist/index.js +5 -571
  38. package/dist/index.js.map +1 -1
  39. package/dist/inspect/index.cjs +2 -0
  40. package/dist/inspect/index.cjs.map +1 -1
  41. package/dist/inspect/index.js +2 -0
  42. package/dist/inspect/index.js.map +1 -1
  43. package/dist/pbt/index.cjs +26 -525
  44. package/dist/pbt/index.cjs.map +1 -1
  45. package/dist/pbt/index.d.cts +7 -3
  46. package/dist/pbt/index.d.ts +7 -3
  47. package/dist/pbt/index.js +12 -511
  48. package/dist/pbt/index.js.map +1 -1
  49. package/dist/replay/index.cjs +9 -195
  50. package/dist/replay/index.cjs.map +1 -1
  51. package/dist/replay/index.js +5 -198
  52. package/dist/replay/index.js.map +1 -1
  53. package/dist/timer/index.cjs +30 -9
  54. package/dist/timer/index.cjs.map +1 -1
  55. package/dist/timer/index.js +30 -9
  56. package/dist/timer/index.js.map +1 -1
  57. package/llms-full.txt +107 -932
  58. package/llms.txt +8 -35
  59. package/package.json +5 -3
package/README.md CHANGED
@@ -1,554 +1,85 @@
1
1
  # aifsmjs
2
2
 
3
- [![npm version](https://img.shields.io/npm/v/aifsmjs.svg)](https://www.npmjs.com/package/aifsmjs)
4
- [![CI](https://github.com/yshengliao/aifsmjs/actions/workflows/ci.yml/badge.svg)](https://github.com/yshengliao/aifsmjs/actions/workflows/ci.yml)
5
- [![License](https://img.shields.io/badge/license-MIT-brightgreen.svg)](LICENSE)
6
- [![AI Generated](https://img.shields.io/badge/AI_Generated-Claude_Code_Opus_4.7_Max-blueviolet.svg)](https://www.anthropic.com/claude-code)
7
- [![繁體中文](https://img.shields.io/badge/lang-繁體中文-red.svg)](README_ZHTW.md)
3
+ Small deterministic FSM library for replayable TypeScript/JavaScript state machines. Definitions are plain data; guards/actions/effects are injected at runtime.
8
4
 
9
- > A small, strict FSM library for any TypeScript/JS app that needs deterministic, replayable state transitions. Lifecycle is a pure `step()` function. Chain-of-Responsibility intuition is reserved for cross-cutting concerns (observe / persist / replay), never for the transition core.
5
+ > **Status: 0.5.8 - stable 1.0-track core.** Core FSM, guards, effects, inspect, replay, PBT helpers, scheduler, and sub-machines are live.
10
6
 
11
- Part of the [ai\*js micro-runtime ecosystem](https://github.com/yshengliao) — see also [aibridgejs](https://github.com/yshengliao/aibridgejs) (cross-context RPC) and [aiecsjs](https://github.com/yshengliao/aiecsjs) (ECS).
12
-
13
- **Primary audience**: developers building stateful flows — multi-step forms, checkout funnels, auth flows, tutorial sequences, document-status workflows, scene flow in interactive apps, and the same patterns in browser-based games (PixiJS / Svelte 5 / plain Canvas / WebGL). The core is **environment-neutral** (pure function + adapter boundary): browser, Node, Bun, Deno, Flutter WebView, and Web Workers all work. The Roadmap section keeps gaming-specific niceties (tick hook, ECS bridge) as opt-in subpaths, not core surface.
14
-
15
- ---
16
-
17
- ## Why aifsmjs
18
-
19
- Developers coming from C# Chain-of-Responsibility instinctively wrap FSM lifecycle in a cancellable middleware chain. In FSM territory that breaks determinism and replay. Web games in particular need replayable, serializable, worker-friendly state, so aifsmjs goes the other way:
20
-
21
- - **Lifecycle is a pure function**: `step(def, snapshot, event, impl)` runs `guards → exit → action → entry` in a fixed, uninterruptible order.
22
- - **CoR intuition is reserved for cross-cutting layers**: `inspect/` provides a Koa-style middleware pipeline, but it can only observe — **never alter the transition outcome**.
23
- - **Definition is plain data**: guards / actions / effects are referenced by string; implementations are injected only at runtime. Serializable, transferable across Web Workers, persistable to a database.
24
- - **PBT is first-class**: built-in `fast-check` `fc.commands` adapter plus 6 generic property tests. No comparable library currently ships this.
25
-
26
- In ecosystem terms: closer to Robot3's functional composition + XState v5's `and/or/not` guard combinators + `@xstate/store` v3's `enq.effect()` dual-track side effects. The core measures ~2.8KB ESM gzipped (v0.1.0); every opt-in subpath is independently tree-shakeable.
27
-
28
- ---
29
-
30
- ## Quick Start
7
+ ## Install
31
8
 
32
9
  ```bash
33
10
  pnpm add aifsmjs
34
11
  ```
35
12
 
36
- ```typescript
37
- import { setup, createRuntime, assign } from "aifsmjs";
13
+ ```ts
14
+ import { assign, createRuntime, setup } from "aifsmjs";
15
+ ```
38
16
 
17
+ ## Quick Start
18
+
19
+ ```ts
39
20
  type Ctx = { ticks: number };
40
21
  type Evt = { type: "NEXT" };
41
22
 
42
- // 1. Definition is plain data; setup<Ctx, Evt>() lets States be inferred from
43
- // the keys of `states`, so you don't have to repeat them.
44
23
  const trafficLight = setup<Ctx, Evt>().defineMachine({
45
24
  id: "trafficLight",
46
25
  initial: "red",
47
26
  context: { ticks: 0 },
48
27
  states: {
49
- red: { on: { NEXT: { target: "green", actions: ["bump"] } } },
50
- green: { on: { NEXT: { target: "yellow", actions: ["bump"] } } },
51
- yellow: { on: { NEXT: { target: "red", actions: ["bump"] } } },
28
+ red: { on: { NEXT: { target: "green", actions: ["bump"] } } },
29
+ green: { on: { NEXT: { target: "yellow", actions: ["bump"] } } },
30
+ yellow: { on: { NEXT: { target: "red", actions: ["bump"] } } },
52
31
  },
53
32
  });
54
33
 
55
- // 2. Implementations are injected only at runtime
56
34
  const runtime = createRuntime(trafficLight, {
57
35
  actions: {
58
36
  bump: assign(({ context }) => ({ ticks: context.ticks + 1 })),
59
37
  },
60
38
  });
61
39
 
62
- // 3. Interact
63
40
  runtime.send({ type: "NEXT" });
64
- console.log(runtime.getSnapshot().value); // "green"
65
- console.log(runtime.getSnapshot().context); // { ticks: 1 }
66
- ```
67
-
68
- > The bare `defineMachine<Ctx, Evt, States>({...})` form is still available as an escape hatch when you need explicit control over union event types. In normal cases prefer `setup().defineMachine()`.
69
-
70
- ---
71
-
72
- ## Mental Model
73
-
74
- ```
75
- ┌──────────────────────┐ ┌──────────────────────┐
76
- │ MachineDefinition │ │ Implementations │
77
- │ (plain data, JSON) │ + │ (guards/actions/ │
78
- │ • states │ │ effects fn map) │
79
- │ • on / target │ │ │
80
- │ • string refs │ │ │
81
- └──────────┬───────────┘ └──────────┬───────────┘
82
- │ │
83
- └──────────────┬───────────────┘
84
- ▼
85
- ┌────────────────────────┐
86
- │ step(def, snap, evt, │ ← pure function
87
- │ impl) │ fixed order, uninterruptible
88
- └───────────┬────────────┘
89
- ▼
90
- ┌────────────────────────┐
91
- │ { snapshot, │
92
- │ effects: [...] } │ caller decides when
93
- └───────────┬────────────┘ to dispatch effects
94
- ▼
95
- ┌────────────────────────┐
96
- │ createRuntime(...) │ ← thin wrapper
97
- │ state holder + send │
98
- └────────────────────────┘
99
- ```
100
-
101
- The three layers are fully decoupled: take `step()` alone for replay, take `MachineDefinition` alone for visualization, and `createRuntime` is just the convenience layer that glues them.
102
-
103
- ---
104
-
105
- ## Capabilities / Limitations
106
-
107
- | Will do (v1) | Won't do |
108
- | --------------------------------------------------- | ------------------------------------------------- |
109
- | Flat states + transitions | Parallel state regions |
110
- | Hierarchical sugar via `state.sub` (stable since 0.4.0) | Closures embedded in definition (breaks serialize) |
111
- | Guards (sync only; inline async throws `InvalidDefinitionError` at `defineMachine`; runtime throws `AsyncGuardError` on thenable return) | Async guards |
112
- | Actions (assign + enqueue effects) | Async API inside an action (use an effect) |
113
- | Fire-and-forget effects | Actor invocation / spawn |
114
- | Read-only inspect middleware | Cancellable transition middleware |
115
- | `replay(initial, log, def, impl)` pure function | Time-travel debugger (v2 candidate) |
116
- | `fast-check` `fc.commands` adapter | Custom PBT framework |
117
- | String ref + runtime injection | Single root import for everything |
118
- | Tree-shake friendly subpath exports | ECS / Pixi bridges (opt-in subpath, not core) |
119
-
120
- ---
121
-
122
- ## Design Philosophy
123
-
124
- <details>
125
- <summary>Why lifecycle cannot be middleware (click to expand)</summary>
126
-
127
- UML statecharts and SCXML both mandate `exit → transition action → entry` as an atomic sequence. The moment a middleware handler can call `next()` or throw to abort, you can land in an invalid state — "entered the new state but never exited the old one" — which destroys:
128
-
129
- 1. **Determinism**: the same event sequence no longer guarantees the same snapshot.
130
- 2. **Replay**: event logs cannot reproduce the same outcome in another environment.
131
- 3. **PBT shrinking**: fast-check's counter-example minimization presumes a deterministic machine.
132
-
133
- XState v5 removed the `predictableActionArguments` flag (actions are now always predictable) precisely because of this lesson from v4. Spring StateMachine flags its cancellable Interceptor as a "relatively deep internal feature" for the same reason.
134
-
135
- So aifsmjs splits the CoR chain instinct two ways:
136
-
137
- | Use case | How it is handled |
138
- | ------------------------------ | ------------------------------------------------------- |
139
- | Chained guard predicates | `and/or/not` higher-order combinators |
140
- | Multi-step action sequencing | `actions: [...]` array, runs in order to completion |
141
- | Cross-cutting (log/persist) | `inspect/` middleware — read-only, no cancel ability |
142
-
143
- </details>
144
-
145
- <details>
146
- <summary>Why the definition is plain data</summary>
147
-
148
- The moment definitions contain closures, you lose:
149
-
150
- - `JSON.stringify` round-trip for DB / localStorage persistence
151
- - `postMessage` transfer to a Web Worker
152
- - Static reachability analysis by a visualizer tool
153
- - Auto-generated event arbitraries from a PBT adapter
154
-
155
- aifsmjs follows the XState v5 two-phase pattern (`setup().createMachine()`): the definition uses string refs; the function map is injected at `createRuntime()`. Inline functions are still allowed but flagged as an escape hatch.
156
-
157
- </details>
158
-
159
- ---
160
-
161
- ## Core API
162
-
163
- ### `defineMachine<C, E, S>(def)`
164
-
165
- ```typescript
166
- function defineMachine<
167
- Ctx = Record<string, never>,
168
- Evt extends { type: string } = { type: string },
169
- States extends string = string,
170
- >(def: MachineConfig<Ctx, Evt, States>): MachineDef<Ctx, Evt, States>;
171
- ```
172
-
173
- Pure data builder. Validates that `initial` exists in the `states` map and returns the (normalized) definition.
174
-
175
- `context` is **optional** — omit it for stateless machines and it defaults to `{}` (the type parameter defaults to `Record<string, never>`). Existing definitions that pass `context` are unaffected.
176
-
177
- ```typescript
178
- // No context needed — defaults to {}
179
- const toggle = defineMachine({
180
- id: "toggle",
181
- initial: "off",
182
- states: {
183
- off: { on: { TOGGLE: "on" } }, // string shorthand, see below
184
- on: { on: { TOGGLE: "off" } },
185
- },
186
- });
187
- ```
188
-
189
- **String-shorthand transitions.** A transition value may be either the full object form or a bare target-state string (à la XState). The string is normalized to `{ target }` before processing — it carries no guard or actions:
190
-
191
- ```typescript
192
- on: { NEXT: "green" } // shorthand for { target: "green" }
193
- on: { NEXT: [{ target: "a", guard: "g" }, "b"] } // mixes with the object form
194
- ```
195
-
196
- ### `createRuntime(def, impl, opts?)`
197
-
198
- ```typescript
199
- function createRuntime<C, E, S>(
200
- def: MachineDef<C, E, S>,
201
- impl: Implementations<C, E>,
202
- opts?: { middleware?: readonly Middleware<C, E, S>[] },
203
- ): Runtime<C, E, S>;
204
-
205
- interface Runtime<C, E, S> {
206
- getSnapshot(): Snapshot<C, S>;
207
- send(event: E): Snapshot<C, S>;
208
- subscribe(listener: (snap: Snapshot<C, S>) => void): () => void;
209
- reset(event?: E): Snapshot<C, S>;
210
- dispose(): void;
211
- readonly disposed: boolean;
212
- readonly signal: AbortSignal;
213
- }
214
- ```
215
-
216
- Thin wrapper. Internally calls `step()` and dispatches effects. `dispose()` aborts the built-in `AbortController`, clears listeners, and causes subsequent `send()` / `reset()` calls to throw `RuntimeDisposedError`. `reset()` rewinds the snapshot to `initialSnapshot(def)` and notifies subscribers, but **does not run entry actions** — reset is "the runtime is reborn", not a transition.
217
-
218
- `runtime.signal` is the runtime's lifetime signal; it fires once on dispose. Every `EffectHandler` receives it via `args.signal`. External integrations (React unmount, game scene teardown) can attach `runtime.signal.addEventListener("abort", ...)` to chain their own cleanup.
219
-
220
- ### `step(def, snapshot, event, impl)`
221
-
222
- ```typescript
223
- function step<C, E, S>(
224
- def: MachineDef<C, E, S>,
225
- snapshot: Snapshot<C, S>,
226
- event: E,
227
- impl: Implementations<C, E>,
228
- ): { snapshot: Snapshot<C, S>; effects: readonly Effect[] };
229
- ```
230
-
231
- **Pure function**. The invariant keeper for the whole library. It never dispatches effects and never mutates the snapshot. A failing guard or unmapped event simply returns the original snapshot unchanged. It does throw on misuse (`UnknownGuardError`, `UnknownActionError`, `AsyncGuardError`) so guard/action wiring errors surface at development time rather than silently passing.
232
-
233
- ### `assign(updater)`
234
-
235
- ```typescript
236
- function assign<C, E>(
237
- updater: (args: { context: C; event: E }) => Partial<C>,
238
- ): Action<C, E>;
239
- ```
240
-
241
- Pure context update helper. Returns a partial that is merged into the context. No side effects.
242
-
243
- ---
244
-
245
- ## Opt-in Modules
246
-
247
- Each opt-in lives on its own subpath. If you don't import it, it is fully tree-shaken away.
248
-
249
- ### `aifsmjs/guards` — Guard combinators
250
-
251
- ```typescript
252
- import { and, or, not, stateIn } from "aifsmjs/guards";
253
-
254
- const canCheckout = and([
255
- "isAuthenticated",
256
- or(["isAdmin", "isOwner"]),
257
- not("isBanned"),
258
- ]);
259
- ```
260
-
261
- `and/or/not` short-circuit over sync guards. `stateIn(...states)` is a sugar predicate: "current state is one of these".
262
-
263
- ### `aifsmjs/effects` — Fire-and-forget effects
264
-
265
- ```typescript
266
- import { type Action } from "aifsmjs";
267
-
268
- const checkout: Action<Ctx, Evt> = ({ context, enqueue }) => {
269
- enqueue.effect("trackAnalytics", { event: "checkout", ctx: context });
270
- // Return value becomes the new context (omit to keep current context)
271
- };
272
- ```
273
-
274
- `enqueue.effect(type, payload)` queues a side-effect declaration. `step()` collects them and hands them back to the caller. Runtime dispatches after the transition; replay mode disables dispatch and keeps only the snapshot fold.
275
-
276
- ### `aifsmjs/inspect` — Read-only middleware
277
-
278
- ```typescript
279
- import { createRuntime } from "aifsmjs";
280
- import { logger, persist } from "aifsmjs/inspect";
281
-
282
- const runtime = createRuntime(def, impl, {
283
- middleware: [
284
- logger(console.log),
285
- persist({ key: "machine-state", storage: localStorage }),
286
- ],
287
- });
288
- ```
289
-
290
- Koa-style `(ctx, next) => void` pipeline. `ctx` is `{ prev, next, event, effects, changed }`, all deep-frozen. **Cannot cancel a transition** — `next()` must be called; the return value carries no meaning.
291
-
292
- ### `aifsmjs/replay` — Pure event log replay
293
-
294
- ```typescript
295
- import { replay } from "aifsmjs/replay";
296
-
297
- const finalSnap = replay(initialSnapshot, eventLog, def, impl);
298
- // Equivalent to eventLog.reduce((s, e) => step(def, s, e, impl).snapshot, initial)
299
- ```
300
-
301
- Never dispatches effects. For PBT, time-travel debugging, and incident reproduction.
302
-
303
- ### `aifsmjs/pbt` — fast-check adapter
304
-
305
- > **Install the peer**: `pnpm add -D fast-check` (^3.20.0). aifsmjs lists fast-check as an optional peer; you only need it when importing this subpath.
306
-
307
- ```typescript
308
- import fc from "fast-check";
309
- import { createRuntime } from "aifsmjs";
310
- import { commandsFromMachine, initialModel, properties } from "aifsmjs/pbt";
311
-
312
- // Use one of the six built-in generic properties, or assertAll for all at once:
313
- properties.replayEqualsFold(def, impl, {
314
- NEXT: fc.constant({ type: "NEXT" as const }),
315
- });
316
-
317
- // Or build a custom property using commandsFromMachine:
318
- fc.assert(
319
- fc.property(
320
- commandsFromMachine(def, impl, {
321
- NEXT: fc.constant({ type: "NEXT" as const }),
322
- }),
323
- (cmds) => {
324
- const real = createRuntime(def, impl, { dispatchEffects: false });
325
- fc.modelRun(() => ({ model: initialModel(def), real }), cmds);
326
- return true;
327
- },
328
- ),
329
- );
330
- ```
331
-
332
- `properties.*` ships 6 generic properties (see [Testing Strategy](#testing-strategy)). `fast-check` is `peerDependenciesMeta.optional`; no install penalty if you don't use it.
333
-
334
- ### `aifsmjs/timer` — Cancellable delayed callbacks
335
-
336
- ```typescript
337
- import { after, createScheduler } from "aifsmjs/timer";
338
-
339
- // One-shot
340
- const handle = after(5000, () => runtime.send({ type: "TIMEOUT" }));
341
- handle.cancel(); // cancels if not yet fired
342
-
343
- // AbortSignal integration
344
- const ac = new AbortController();
345
- after(5000, () => runtime.send({ type: "TIMEOUT" }), { signal: ac.signal });
346
- ac.abort(); // also cancels
347
-
348
- // Scheduler: bundle a group of timers and cancel them together on teardown
349
- const sched = createScheduler();
350
- sched.after(1000, () => {});
351
- sched.after(2000, () => {});
352
- sched.cancelAll();
41
+ console.log(runtime.getSnapshot().value); // "green"
353
42
  ```
354
43
 
355
- - Thin wrapper over `setTimeout` / `clearTimeout`, with injectable timer functions (validated by vitest fake timers)
356
- - AbortSignal listener registered with `{ once: true }` to avoid leaks
357
- - Decoupled from the FSM core: you decide when to forward a fired timer as `runtime.send(...)`
358
-
359
- ---
360
-
361
- ## Lifecycle Invariants
362
-
363
- The fixed order inside `step()` (always, no escape hatch):
364
-
365
- ```
366
- 1. resolveTransitions(def, snapshot.value, event)
367
- → candidate transitions for (state, event)
368
- 2. evaluate guard on each candidate in declaration order
369
- → first passing transition is chosen; otherwise the original snapshot is returned
370
- 3. exit actions of the old state (v1 is flat, no hierarchy)
371
- 4. transition.actions[] run in declaration order
372
- → each action may call enqueue.effect()
373
- → each action's returned partial context is merged into the current context
374
- 5. entry actions of the new state
375
- 6. return { snapshot, effects } — the caller decides when to dispatch effects
376
- ```
377
-
378
- **Contracts**:
379
-
380
- Guarantees:
381
-
382
- - Guards are sync and pure (never mutate context)
383
- - Actions always run to completion (no cancel mechanism)
384
- - Effects are declarations (type + payload), not callbacks — serializable
385
- - Snapshot is immutable; dev mode deep-freezes for diagnostics, prod is shallow for speed
386
-
387
- Non-goals:
388
-
389
- - No async lifecycle hook
390
- - Inspect middleware cannot alter the transition outcome
391
-
392
- ### Sub-machine lifecycle (stable since 0.4.0)
393
-
394
- When a state declares `sub`, the per-transition ordering is:
395
-
396
- 1. Parent `step()` runs: `exit actions → transition.actions → entry actions`.
397
- 2. Old child (if any) `dispose()` — synchronous; exceptions become
398
- `SubMachineError(phase: "dispose")`.
399
- 3. New child (if next state has `sub`) instantiation — exceptions become
400
- `SubMachineError(phase: "init")`.
401
- 4. Parent snapshot commits.
402
- 5. Middleware pipeline runs.
403
- 6. Effects dispatch.
404
- 7. `'transition'` event emits to `on()` / `onTransition()` subscribers.
405
-
406
- If step 2 or 3 throws, the parent snapshot is **not** committed (rollback
407
- to `prev`); no middleware / effects / `'transition'` runs.
408
-
409
- `runtime.dispose()` cascades to the child via `controller.signal`'s abort
410
- listener and an explicit `child.dispose()` call. Cascade swallows child
411
- exceptions to honour the never-throws dispose contract.
412
-
413
- ---
414
-
415
- ## Lifecycle Protocol
416
-
417
- aifsmjs is the first package in a "minimal AI toolchain" family. This lifecycle protocol is meant to be reused by future packages (`aitaskjs / aibridgejs / aiaudiojs` and friends):
418
-
419
- | Verb | aifsmjs equivalent | Semantics |
420
- |---|---|---|
421
- | `createX()` | `createRuntime` / `createScheduler` / `defineMachine` / `setup` | Factory function returning the instance |
422
- | `dispose()` | `runtime.dispose()` / `scheduler.cancelAll()` | Release resources; idempotent; post-dispose API throws a known error |
423
- | `reset()` | `runtime.reset()` | Zero out state without releasing resources |
424
- | `on/off` | `runtime.subscribe(fn)` returning an unsubscribe fn | Subscription pattern; explicit unsubscribe |
425
- | `AbortSignal` | `runtime.signal` / `after(_, _, { signal })` | Cancellation channel for any long-running / async work |
426
- | Pure core | `step()` | No I/O, serializable, replayable |
427
- | Explicit errors | `RuntimeDisposedError` / `UnknownGuardError` / `UnknownActionError` / `InvalidDefinitionError` | Named error classes, never bare `throw "string"` |
428
-
429
- When future ai\*js packages ask "should this have a dispose?" or "where does the signal plug in?", this table is the baseline.
430
-
431
- ---
432
-
433
- ## Design choices: divergence from common patterns
434
-
435
- aifsmjs ships a few opinionated calls that look different from the typical FSM library. The rationale below explains what we chose and why, so readers coming from XState, statecharts, or general event-emitter libraries can skip the source dive.
436
-
437
- - **`send()` is synchronous, returning `Snapshot` instead of `Promise<Snapshot>`.** The pure `step()` core is sync by construction so that `replay(initial, log)` and PBT shrinking remain trivial. Effect handlers may still be async; the runtime fires them and forwards async rejections to the `'error'` event channel. If you need to await effect completion, build a small wrapper that returns `Promise.all` over your handler results.
438
- - **Guards and reducers are sync.** A non-deterministic guard would break the PBT determinism property (#1 in the generic suite). Move async predicates into events: send `FETCH_REQUEST`, then later `FETCH_DONE` with the resolved value as payload.
439
- - **Effects are descriptors, not inline callbacks.** Actions enqueue `{ type, payload }` via `enqueue.effect(...)`; the runtime collects them and the dispatcher invokes user handlers. This keeps machine definitions serializable (JSON round-trippable when no inline functions are used), enables `replay()` to fold an event log into the same snapshot, and lets `inspect/persist` middleware capture effects for audit logs.
440
- - **Two factory paths coexist.** `setup<Ctx, Evt>().defineMachine(...)` is the type-friendly form (States inferred from `keyof states`). `createMachine(def, impl, opts?)` is the spec-style single-factory shortcut from the ai*js ecosystem review. Plain `defineMachine<Ctx, Evt, States>(def)` remains for explicit generic control. Pick whichever reads best at the call site.
441
- - **Transitions accept a string shorthand.** `on: { EVENT: "targetState" }` is sugar for `on: { EVENT: { target: "targetState" } }`, normalized in the resolver before any guard/action processing. The shorthand has no guard or actions; reach for the object form when you need them. It composes inside the array form too, so guard-fallthrough lists can mix `{ target, guard }` objects with bare target strings. The full object form is unchanged — this is purely additive.
442
- - **`context` is optional.** Omit it for stateless machines and it defaults to `{}` (`Ctx` defaults to `Record<string, never>`). Definitions that already pass `context` keep their inferred type and behave identically.
443
- - **`subscribe(listener)` and `on(type, fn, { signal, once })` both exist.** The typed `on()` matches the platform `EventTarget` semantics (signal + once) and emits `'transition'`, `'error'`, `'dispose'`. The older `subscribe()` keeps the React `useSyncExternalStore` shape — pass it directly. They are not exclusive.
444
-
445
- ---
446
-
447
- ## AI-Agent Reading Guide
448
-
449
- > This section is for LLMs and code-search agents. Invariants, types, and misuse patterns are concentrated here.
450
-
451
- ### Serializable fields
452
-
453
- The following are plain data, safe to `JSON.stringify` round-trip:
454
-
455
- - The entire `MachineDef` (provided no inline functions are used)
456
- - The entire `Snapshot` (provided `context` is plain data)
457
- - The entire `Effect` (`{ type: string; payload?: unknown }`)
458
-
459
- The following are **not serializable** and will break PBT/replay:
460
-
461
- - Every function inside `Implementations`
462
- - Middleware closures
463
-
464
- ### Invariants (do not violate)
465
-
466
- 1. `step()` is pure: identical `(def, snapshot, event, impl)` always returns identical `{ snapshot, effects }`.
467
- 2. Snapshots are frozen: in dev mode any mutation throws immediately.
468
- 3. Guards never mutate context: violators are caught by PBT property #2.
469
- 4. Effects are always fire-and-forget: the runtime never waits for an effect before updating the snapshot.
470
- 5. `dispose()` is idempotent; post-dispose `send()` / `reset()` throws `RuntimeDisposedError`.
471
- 6. `runtime.signal.aborted` is `true` for the rest of time once disposed; the effect handler's `signal` is the same one.
472
- 7. `reset()` only resets the snapshot and notifies listeners — it does **not** run entry actions. Listeners are notified only when `prev.value !== initial.value` (parity with `send()`). Middleware always observes the call (possibly with `changed: false`).
473
- 8. `MiddlewareContext.event` is typed `Evt | ResetEvent`; a `reset()` without an event injects the `RESET_EVENT_TYPE` sentinel (`"@@aifsmjs/RESET"`).
474
-
475
- ### Common misuses
476
-
477
- | Anti-pattern | Correct form |
478
- | --------------------------------------------------------- | ------------------------------------------------------------- |
479
- | Calling `fetch()` (or any async API) inside a guard | Rewrite as events: send `FETCH_REQUEST`, then `FETCH_DONE` |
480
- | `setTimeout`-and-mutate inside an action | Use `enqueue.effect("delayedThing", ...)` |
481
- | Using middleware to alter the next state | Not possible — middleware is read-only. Rewrite as a guard. |
482
- | Inline functions inside a definition (works but breaks serialize) | Pull out as string refs, inject at `createRuntime` |
483
-
484
- ### Machine-readable schema
485
-
486
- A JSON schema for `MachineDef` will ship at `dist/schema/machine.schema.json`. Not yet available in v1; types live in [src/fsm/types.ts](src/fsm/types.ts) for agents to derive from.
487
-
488
- ---
489
-
490
- ## Testing Strategy
491
-
492
- Example-first, PBT-augmented. Lesson from jssm: "3000+ tests / 100% coverage" turns out to have < 12% coverage from stochastic tests — the rest is example specs.
493
-
494
- - **Example tests** (vitest): for every src module, write happy path + edge + error-message triplets.
495
- - **PBT smoke**: each generic property runs 50 iterations as an invariant guard, not as a coverage source.
496
- - **CI-enforced thresholds**: `@vitest/coverage-v8` is wired to **100% statements / 100% lines / 100% functions / ≥90% branches**. The few defensive invariant-guard branches (e.g. runtime determinism mismatch) carry `/* v8 ignore */` annotations with rationale.
497
- - **Size budget**: `scripts/check-size.mjs` enforces per-subpath gzip caps in CI — core ≤4.7 KB (raised in 0.3.0 for sub-machine sugar), replay ≤1.8 KB, pbt ≤5.5 KB (raised in 0.3.0 because `pbt` transitively imports `createRuntime`), others ≤1 KB. Exceeding any cap fails the build.
498
-
499
- ### The 6 built-in generic properties
500
-
501
- | # | Property | One-liner |
502
- | --- | --------------------------------- | -------------------------------------------------------- |
503
- | 1 | snapshotAlwaysFrozen | After any event sequence, the snapshot remains frozen |
504
- | 2 | unknownEventNoOp | Undeclared events do not change the snapshot |
505
- | 3 | reachableStatesSubsetDeclared | Every reachable state belongs to `def.states` |
506
- | 4 | replayEqualsFold | `replay(init, log)` equals `events.reduce(step)` |
507
- | 5 | guardsFalseNoTransition | When all guards fail, the state is unchanged |
508
- | 6 | assignDoesNotMutate | `assign` never modifies the previous context |
509
-
510
- ---
511
-
512
- ## Comparison
44
+ Prefer `setup<Ctx, Evt>().defineMachine()` for state inference. Use bare `defineMachine<Ctx, Evt, States>()` only when you need explicit generic control.
513
45
 
514
- | | aifsmjs | XState v5 | Robot3 | @xstate/store | Zag.js |
515
- | -------------------------- | -------------- | ----------------- | ----------------- | ----------------- | ----------------- |
516
- | Core size (gzip) | ~2.8KB | ~15KB | ~1KB | < 1KB | per-component |
517
- | Hierarchical states | Sugar (0.3.0) | Yes | No | N/A | Yes |
518
- | Async invoke / actor | No | Yes | No | N/A | No |
519
- | Guard combinators | and/or/not | and/or/not | No | N/A | No |
520
- | Effects dual-track | enqueue | enqueueActions | reduce/action | enq.effect() | array of names |
521
- | Inspect / observe | read-only | inspect API | No | proposed | watch ctx |
522
- | Serializable definition | Yes | Yes | Partial | Partial | Yes |
523
- | fast-check adapter | built-in | No | No | No | No |
524
- | Tree-shake subpath imports | Yes | Partial | Yes | Yes | Yes |
46
+ ## Public Surface
525
47
 
526
- ---
48
+ | Import | Purpose |
49
+ | --- | --- |
50
+ | `aifsmjs` | `setup`, `defineMachine`, `createRuntime`, `createMachine`, `step`, `assign`, snapshots, runtime/errors/types. |
51
+ | `aifsmjs/guards` | `and`, `or`, `not`, `stateIn`. Guards must be synchronous. |
52
+ | `aifsmjs/effects` | `enqueue.effect()` descriptors and `runEffects()`. |
53
+ | `aifsmjs/inspect` | Read-only middleware helpers: `logger`, `persist`, `recorder`. |
54
+ | `aifsmjs/replay` | Pure event-log replay. |
55
+ | `aifsmjs/pbt` | fast-check property helpers. |
56
+ | `aifsmjs/timer` | `after()` and `createScheduler()`. |
527
57
 
528
- ## Roadmap
58
+ ## Lifecycle Rules
529
59
 
530
- | Version | Scope |
531
- | ------- | ------------------------------------------------------------------ |
532
- | v0.1 | core + guards + effects + inspect + replay + pbt (this release) |
533
- | v0.2 | Async-guard detection, coverage tuning, llms-full.txt verify gate |
534
- | v0.3 | Hierarchical sugar via `state.sub` (experimental) |
535
- | v0.4 | Sub-machine API promoted to stable; dependency-reduction cycle |
536
- | v0.5 | `aifsmjs-bridge-bitecs` / `aifsmjs-bridge-pixi` (separate sub-packages) |
537
- | v1.0 | API freeze and stability guarantee |
60
+ - `step(def, snapshot, event, impl)` is pure and returns `{ snapshot, effects, changed }`.
61
+ - `createRuntime()` owns mutable runtime state, dispatches effects after commit, and emits transition/error/dispose events.
62
+ - Guards and reducers are sync. Thenable guards throw `AsyncGuardError`.
63
+ - Effects are fire-and-forget descriptors. Async rejection is routed to the runtime `"error"` channel.
64
+ - `reset()` rewinds the snapshot and notifies listeners, but does not run entry actions.
65
+ - `dispose()` is idempotent; post-dispose `send()`/`reset()` throw `RuntimeDisposedError`.
538
66
 
539
- **Out of scope (v1)**:
67
+ ## Sharp Edges
540
68
 
541
- - **Parallel state regions** (out of scope for v1)
542
- - **Actor invocation / spawn** (out of scope for v1)
543
- - **Tick / game-loop hook** (out of scope for v1)
544
- - **ECS / Pixi bridges** (out of scope for v1)
69
+ - Middleware and synchronous effect throws happen after snapshot commit. A throw can leave the committed snapshot visible without later notification.
70
+ - Sub-machine replacement can roll back on init failure, but dispose failure has already torn down the old child.
71
+ - `subRuntime()` can return a disposed child handle if external code disposed it; it is recreated only after the parent exits and re-enters the sub state.
72
+ - `setup().defineMachine()` uses `NoInfer` so states infer from `keyof states`; keep regression tests for exact optional property configurations.
73
+ - Do not perform async I/O inside guards or actions. Send events from effects instead.
545
74
 
546
- **Future candidate**: `historyState` — remember last active sub-state on
547
- re-entry. Workaround today: snapshot via `onTransition` and restore
548
- manually.
75
+ ## AI Context
549
76
 
550
- ---
77
+ - Short index: [`llms.txt`](llms.txt)
78
+ - Full generated context: [`llms-full.txt`](llms-full.txt)
79
+ - Stability contract: [`STABILITY.md`](STABILITY.md)
80
+ - Current review backlog: [`REVIEW.md`](REVIEW.md)
81
+ - Release history: [`CHANGELOG.md`](CHANGELOG.md)
551
82
 
552
83
  ## License
553
84
 
554
- [MIT](LICENSE)
85
+ MIT