aifsmjs 0.5.6 → 0.5.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,558 +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/islumina/aifsmjs/actions/workflows/ci.yml/badge.svg)](https://github.com/islumina/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.9 - 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/islumina) — see also [aibridgejs](https://github.com/islumina/aibridgejs) (cross-context RPC) and [aiecsjs](https://github.com/islumina/aiecsjs) (ECS).
12
-
13
- > **Status: 0.5.6.** Core FSM, hierarchical sub-machines, guards & effects, scheduler, replay, inspect, and PBT helpers are all live. See [CHANGELOG.md](CHANGELOG.md) for release history.
14
-
15
- **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.
16
-
17
- ---
18
-
19
- ## Why aifsmjs
20
-
21
- 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:
22
-
23
- - **Lifecycle is a pure function**: `step(def, snapshot, event, impl)` runs `guards → exit → action → entry` in a fixed, uninterruptible order.
24
- - **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**.
25
- - **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.
26
- - **PBT is first-class**: built-in `fast-check` `fc.commands` adapter plus 6 generic property tests. No comparable library currently ships this.
27
-
28
- 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.
29
-
30
- ---
31
-
32
- ## Quick Start
7
+ ## Install
33
8
 
34
9
  ```bash
35
10
  pnpm add aifsmjs
36
11
  ```
37
12
 
38
- ```typescript
39
- import { setup, createRuntime, assign } from "aifsmjs";
13
+ ```ts
14
+ import { assign, createRuntime, setup } from "aifsmjs";
15
+ ```
16
+
17
+ ## Quick Start
40
18
 
19
+ ```ts
41
20
  type Ctx = { ticks: number };
42
21
  type Evt = { type: "NEXT" };
43
22
 
44
- // 1. Definition is plain data; setup<Ctx, Evt>() lets States be inferred from
45
- // the keys of `states`, so you don't have to repeat them.
46
23
  const trafficLight = setup<Ctx, Evt>().defineMachine({
47
24
  id: "trafficLight",
48
25
  initial: "red",
49
26
  context: { ticks: 0 },
50
27
  states: {
51
- red: { on: { NEXT: { target: "green", actions: ["bump"] } } },
52
- green: { on: { NEXT: { target: "yellow", actions: ["bump"] } } },
53
- 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"] } } },
54
31
  },
55
32
  });
56
33
 
57
- // 2. Implementations are injected only at runtime
58
34
  const runtime = createRuntime(trafficLight, {
59
35
  actions: {
60
36
  bump: assign(({ context }) => ({ ticks: context.ticks + 1 })),
61
37
  },
62
38
  });
63
39
 
64
- // 3. Interact
65
40
  runtime.send({ type: "NEXT" });
66
- console.log(runtime.getSnapshot().value); // "green"
67
- console.log(runtime.getSnapshot().context); // { ticks: 1 }
68
- ```
69
-
70
- > 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()`.
71
-
72
- ---
73
-
74
- ## Mental Model
75
-
76
- ```
77
- ┌──────────────────────┐ ┌──────────────────────┐
78
- │ MachineDefinition │ │ Implementations │
79
- │ (plain data, JSON) │ + │ (guards/actions/ │
80
- │ • states │ │ effects fn map) │
81
- │ • on / target │ │ │
82
- │ • string refs │ │ │
83
- └──────────┬───────────┘ └──────────┬───────────┘
84
- │ │
85
- └──────────────┬───────────────┘
86
- ▼
87
- ┌────────────────────────┐
88
- │ step(def, snap, evt, │ ← pure function
89
- │ impl) │ fixed order, uninterruptible
90
- └───────────┬────────────┘
91
- ▼
92
- ┌────────────────────────┐
93
- │ { snapshot, │
94
- │ effects: [...] } │ caller decides when
95
- └───────────┬────────────┘ to dispatch effects
96
- ▼
97
- ┌────────────────────────┐
98
- │ createRuntime(...) │ ← thin wrapper
99
- │ state holder + send │
100
- └────────────────────────┘
101
- ```
102
-
103
- 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.
104
-
105
- ---
106
-
107
- ## Capabilities / Limitations
108
-
109
- | Will do (v1) | Won't do |
110
- | --------------------------------------------------- | ------------------------------------------------- |
111
- | Flat states + transitions | Parallel state regions |
112
- | Hierarchical sugar via `state.sub` (stable since 0.4.0) | Closures embedded in definition (breaks serialize) |
113
- | Guards (sync only; inline async throws `InvalidDefinitionError` at `defineMachine`; runtime throws `AsyncGuardError` on thenable return) | Async guards |
114
- | Actions (assign + enqueue effects) | Async API inside an action (use an effect) |
115
- | Fire-and-forget effects | Actor invocation / spawn |
116
- | Read-only inspect middleware | Cancellable transition middleware |
117
- | `replay(initial, log, def, impl)` pure function | Time-travel debugger (v2 candidate) |
118
- | `fast-check` `fc.commands` adapter | Custom PBT framework |
119
- | String ref + runtime injection | Single root import for everything |
120
- | Tree-shake friendly subpath exports | ECS / Pixi bridges (opt-in subpath, not core) |
121
-
122
- ---
123
-
124
- ## Design Philosophy
125
-
126
- <details>
127
- <summary>Why lifecycle cannot be middleware (click to expand)</summary>
128
-
129
- 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:
130
-
131
- 1. **Determinism**: the same event sequence no longer guarantees the same snapshot.
132
- 2. **Replay**: event logs cannot reproduce the same outcome in another environment.
133
- 3. **PBT shrinking**: fast-check's counter-example minimization presumes a deterministic machine.
134
-
135
- 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.
136
-
137
- So aifsmjs splits the CoR chain instinct two ways:
138
-
139
- | Use case | How it is handled |
140
- | ------------------------------ | ------------------------------------------------------- |
141
- | Chained guard predicates | `and/or/not` higher-order combinators |
142
- | Multi-step action sequencing | `actions: [...]` array, runs in order to completion |
143
- | Cross-cutting (log/persist) | `inspect/` middleware — read-only, no cancel ability |
144
-
145
- </details>
146
-
147
- <details>
148
- <summary>Why the definition is plain data</summary>
149
-
150
- The moment definitions contain closures, you lose:
151
-
152
- - `JSON.stringify` round-trip for DB / localStorage persistence
153
- - `postMessage` transfer to a Web Worker
154
- - Static reachability analysis by a visualizer tool
155
- - Auto-generated event arbitraries from a PBT adapter
156
-
157
- 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.
158
-
159
- </details>
160
-
161
- ---
162
-
163
- ## Core API
164
-
165
- ### `defineMachine<C, E, S>(def)`
166
-
167
- ```typescript
168
- function defineMachine<
169
- Ctx = Record<string, never>,
170
- Evt extends { type: string } = { type: string },
171
- States extends string = string,
172
- >(def: MachineConfig<Ctx, Evt, States>): MachineDef<Ctx, Evt, States>;
173
- ```
174
-
175
- Pure data builder. Validates that `initial` exists in the `states` map and returns the (normalized) definition.
176
-
177
- `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.
178
-
179
- ```typescript
180
- // No context needed — defaults to {}
181
- const toggle = defineMachine({
182
- id: "toggle",
183
- initial: "off",
184
- states: {
185
- off: { on: { TOGGLE: "on" } }, // string shorthand, see below
186
- on: { on: { TOGGLE: "off" } },
187
- },
188
- });
189
- ```
190
-
191
- **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:
192
-
193
- ```typescript
194
- on: { NEXT: "green" } // shorthand for { target: "green" }
195
- on: { NEXT: [{ target: "a", guard: "g" }, "b"] } // mixes with the object form
196
- ```
197
-
198
- ### `createRuntime(def, impl, opts?)`
199
-
200
- ```typescript
201
- function createRuntime<C, E, S>(
202
- def: MachineDef<C, E, S>,
203
- impl: Implementations<C, E>,
204
- opts?: { middleware?: readonly Middleware<C, E, S>[] },
205
- ): Runtime<C, E, S>;
206
-
207
- interface Runtime<C, E, S> {
208
- getSnapshot(): Snapshot<C, S>;
209
- send(event: E): Snapshot<C, S>;
210
- subscribe(listener: (snap: Snapshot<C, S>) => void): () => void;
211
- reset(event?: E): Snapshot<C, S>;
212
- dispose(): void;
213
- readonly disposed: boolean;
214
- readonly signal: AbortSignal;
215
- }
216
- ```
217
-
218
- 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.
219
-
220
- `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.
221
-
222
- ### `step(def, snapshot, event, impl)`
223
-
224
- ```typescript
225
- function step<C, E, S>(
226
- def: MachineDef<C, E, S>,
227
- snapshot: Snapshot<C, S>,
228
- event: E,
229
- impl: Implementations<C, E>,
230
- ): { snapshot: Snapshot<C, S>; effects: readonly Effect[] };
231
- ```
232
-
233
- **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.
234
-
235
- ### `assign(updater)`
236
-
237
- ```typescript
238
- function assign<C, E>(
239
- updater: (args: { context: C; event: E }) => Partial<C>,
240
- ): Action<C, E>;
241
- ```
242
-
243
- Pure context update helper. Returns a partial that is merged into the context. No side effects.
244
-
245
- ---
246
-
247
- ## Opt-in Modules
248
-
249
- Each opt-in lives on its own subpath. If you don't import it, it is fully tree-shaken away.
250
-
251
- ### `aifsmjs/guards` — Guard combinators
252
-
253
- ```typescript
254
- import { and, or, not, stateIn } from "aifsmjs/guards";
255
-
256
- const canCheckout = and([
257
- "isAuthenticated",
258
- or(["isAdmin", "isOwner"]),
259
- not("isBanned"),
260
- ]);
261
- ```
262
-
263
- `and/or/not` short-circuit over sync guards. `stateIn(...states)` is a sugar predicate: "current state is one of these".
264
-
265
- ### `aifsmjs/effects` — Fire-and-forget effects
266
-
267
- ```typescript
268
- import { type Action } from "aifsmjs";
269
-
270
- const checkout: Action<Ctx, Evt> = ({ context, enqueue }) => {
271
- enqueue.effect("trackAnalytics", { event: "checkout", ctx: context });
272
- // Return value becomes the new context (omit to keep current context)
273
- };
274
- ```
275
-
276
- `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.
277
-
278
- ### `aifsmjs/inspect` — Read-only middleware
279
-
280
- ```typescript
281
- import { createRuntime } from "aifsmjs";
282
- import { logger, persist } from "aifsmjs/inspect";
283
-
284
- const runtime = createRuntime(def, impl, {
285
- middleware: [
286
- logger(console.log),
287
- persist({ key: "machine-state", storage: localStorage }),
288
- ],
289
- });
290
- ```
291
-
292
- 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.
293
-
294
- Two boundaries to respect (both stable for 1.x, detailed in [STABILITY.md](STABILITY.md)): skipping `next()` is **not** enforced and silently drops every later middleware (and the `recorder` / `persist` sinks) for that event; and a synchronous throw from a middleware (e.g. `persist` on non-serialisable context) propagates to the caller but leaves the snapshot **committed yet unannounced** — `notify()` and `on('transition')` are skipped. Do **not** call `runtime.send()` from inside the pipeline: the inner event runs its full pipeline before the outer frame finishes, so the `recorder` log is reordered and `replay()` diverges.
295
-
296
- ### `aifsmjs/replay` — Pure event log replay
297
-
298
- ```typescript
299
- import { replay } from "aifsmjs/replay";
300
-
301
- const finalSnap = replay(initialSnapshot, eventLog, def, impl);
302
- // Equivalent to eventLog.reduce((s, e) => step(def, s, e, impl).snapshot, initial)
303
- ```
304
-
305
- Never dispatches effects. For PBT, time-travel debugging, and incident reproduction.
306
-
307
- ### `aifsmjs/pbt` — fast-check adapter
308
-
309
- > **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.
310
-
311
- ```typescript
312
- import fc from "fast-check";
313
- import { createRuntime } from "aifsmjs";
314
- import { commandsFromMachine, initialModel, properties } from "aifsmjs/pbt";
315
-
316
- // Use one of the six built-in generic properties, or assertAll for all at once:
317
- properties.replayEqualsFold(def, impl, {
318
- NEXT: fc.constant({ type: "NEXT" as const }),
319
- });
320
-
321
- // Or build a custom property using commandsFromMachine:
322
- fc.assert(
323
- fc.property(
324
- commandsFromMachine(def, impl, {
325
- NEXT: fc.constant({ type: "NEXT" as const }),
326
- }),
327
- (cmds) => {
328
- const real = createRuntime(def, impl, { dispatchEffects: false });
329
- fc.modelRun(() => ({ model: initialModel(def), real }), cmds);
330
- return true;
331
- },
332
- ),
333
- );
334
- ```
335
-
336
- `properties.*` ships 6 generic properties (see [Testing Strategy](#testing-strategy)). `fast-check` is `peerDependenciesMeta.optional`; no install penalty if you don't use it.
337
-
338
- ### `aifsmjs/timer` — Cancellable delayed callbacks
339
-
340
- ```typescript
341
- import { after, createScheduler } from "aifsmjs/timer";
342
-
343
- // One-shot
344
- const handle = after(5000, () => runtime.send({ type: "TIMEOUT" }));
345
- handle.cancel(); // cancels if not yet fired
346
-
347
- // AbortSignal integration
348
- const ac = new AbortController();
349
- after(5000, () => runtime.send({ type: "TIMEOUT" }), { signal: ac.signal });
350
- ac.abort(); // also cancels
351
-
352
- // Scheduler: bundle a group of timers and cancel them together on teardown
353
- const sched = createScheduler();
354
- sched.after(1000, () => {});
355
- sched.after(2000, () => {});
356
- sched.cancelAll();
41
+ console.log(runtime.getSnapshot().value); // "green"
357
42
  ```
358
43
 
359
- - Thin wrapper over `setTimeout` / `clearTimeout`, with injectable timer functions (validated by vitest fake timers)
360
- - AbortSignal listener registered with `{ once: true }` to avoid leaks
361
- - Decoupled from the FSM core: you decide when to forward a fired timer as `runtime.send(...)`
362
-
363
- ---
364
-
365
- ## Lifecycle Invariants
366
-
367
- The fixed order inside `step()` (always, no escape hatch):
368
-
369
- ```
370
- 1. resolveTransitions(def, snapshot.value, event)
371
- → candidate transitions for (state, event)
372
- 2. evaluate guard on each candidate in declaration order
373
- → first passing transition is chosen; otherwise the original snapshot is returned
374
- 3. exit actions of the old state (v1 is flat, no hierarchy)
375
- 4. transition.actions[] run in declaration order
376
- → each action may call enqueue.effect()
377
- → each action's returned partial context is merged into the current context
378
- 5. entry actions of the new state
379
- 6. return { snapshot, effects } — the caller decides when to dispatch effects
380
- ```
381
-
382
- **Contracts**:
383
-
384
- Guarantees:
385
-
386
- - Guards are sync and pure (never mutate context)
387
- - Actions always run to completion (no cancel mechanism)
388
- - Effects are declarations (type + payload), not callbacks — serializable
389
- - Snapshot is immutable; dev mode deep-freezes for diagnostics, prod is shallow for speed
390
-
391
- Non-goals:
392
-
393
- - No async lifecycle hook
394
- - Inspect middleware cannot alter the transition outcome
395
-
396
- ### Sub-machine lifecycle (stable since 0.4.0)
397
-
398
- When a state declares `sub`, the per-transition ordering is:
399
-
400
- 1. Parent `step()` runs: `exit actions → transition.actions → entry actions`.
401
- 2. Old child (if any) `dispose()` — synchronous; exceptions become
402
- `SubMachineError(phase: "dispose")`.
403
- 3. New child (if next state has `sub`) instantiation — exceptions become
404
- `SubMachineError(phase: "init")`.
405
- 4. Parent snapshot commits.
406
- 5. Middleware pipeline runs.
407
- 6. Effects dispatch.
408
- 7. `'transition'` event emits to `on()` / `onTransition()` subscribers.
409
-
410
- If step 2 or 3 throws, the parent snapshot is **not** committed (rollback
411
- to `prev`); no middleware / effects / `'transition'` runs.
412
-
413
- `runtime.dispose()` cascades to the child via `controller.signal`'s abort
414
- listener and an explicit `child.dispose()` call. Cascade swallows child
415
- exceptions to honour the never-throws dispose contract.
416
-
417
- ---
418
-
419
- ## Lifecycle Protocol
420
-
421
- 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):
422
-
423
- | Verb | aifsmjs equivalent | Semantics |
424
- |---|---|---|
425
- | `createX()` | `createRuntime` / `createScheduler` / `defineMachine` / `setup` | Factory function returning the instance |
426
- | `dispose()` | `runtime.dispose()` / `scheduler.cancelAll()` | Release resources; idempotent; post-dispose API throws a known error |
427
- | `reset()` | `runtime.reset()` | Zero out state without releasing resources |
428
- | `on/off` | `runtime.subscribe(fn)` returning an unsubscribe fn | Subscription pattern; explicit unsubscribe |
429
- | `AbortSignal` | `runtime.signal` / `after(_, _, { signal })` | Cancellation channel for any long-running / async work |
430
- | Pure core | `step()` | No I/O, serializable, replayable |
431
- | Explicit errors | `RuntimeDisposedError` / `UnknownGuardError` / `UnknownActionError` / `InvalidDefinitionError` | Named error classes, never bare `throw "string"` |
432
-
433
- When future ai\*js packages ask "should this have a dispose?" or "where does the signal plug in?", this table is the baseline.
434
-
435
- ---
436
-
437
- ## Design choices: divergence from common patterns
438
-
439
- 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.
440
-
441
- - **`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.
442
- - **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.
443
- - **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.
444
- - **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.
445
- - **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.
446
- - **`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.
447
- - **`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.
448
-
449
- ---
450
-
451
- ## AI-Agent Reading Guide
452
-
453
- > This section is for LLMs and code-search agents. Invariants, types, and misuse patterns are concentrated here.
454
-
455
- ### Serializable fields
456
-
457
- The following are plain data, safe to `JSON.stringify` round-trip:
458
-
459
- - The entire `MachineDef` (provided no inline functions are used)
460
- - The entire `Snapshot` (provided `context` is plain data)
461
- - The entire `Effect` (`{ type: string; payload?: unknown }`)
462
-
463
- The following are **not serializable** and will break PBT/replay:
464
-
465
- - Every function inside `Implementations`
466
- - Middleware closures
467
-
468
- ### Invariants (do not violate)
469
-
470
- 1. `step()` is pure: identical `(def, snapshot, event, impl)` always returns identical `{ snapshot, effects }`.
471
- 2. Snapshots are frozen: in dev mode any mutation throws immediately.
472
- 3. Guards never mutate context: violators are caught by PBT property #2.
473
- 4. Effects are always fire-and-forget: the runtime never waits for an effect before updating the snapshot.
474
- 5. `dispose()` is idempotent; post-dispose `send()` / `reset()` throws `RuntimeDisposedError`.
475
- 6. `runtime.signal.aborted` is `true` for the rest of time once disposed; the effect handler's `signal` is the same one.
476
- 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`).
477
- 8. `MiddlewareContext.event` is typed `Evt | ResetEvent`; a `reset()` without an event injects the `RESET_EVENT_TYPE` sentinel (`"@@aifsmjs/RESET"`).
478
-
479
- ### Common misuses
480
-
481
- | Anti-pattern | Correct form |
482
- | --------------------------------------------------------- | ------------------------------------------------------------- |
483
- | Calling `fetch()` (or any async API) inside a guard | Rewrite as events: send `FETCH_REQUEST`, then `FETCH_DONE` |
484
- | `setTimeout`-and-mutate inside an action | Use `enqueue.effect("delayedThing", ...)` |
485
- | Using middleware to alter the next state | Not possible — middleware is read-only. Rewrite as a guard. |
486
- | Inline functions inside a definition (works but breaks serialize) | Pull out as string refs, inject at `createRuntime` |
487
-
488
- ### Machine-readable schema
489
-
490
- 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.
491
-
492
- ---
493
-
494
- ## Testing Strategy
495
-
496
- 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.
497
-
498
- - **Example tests** (vitest): for every src module, write happy path + edge + error-message triplets.
499
- - **PBT smoke**: each generic property runs 50 iterations as an invariant guard, not as a coverage source.
500
- - **CI-enforced thresholds**: `@vitest/coverage-v8` is wired to **100% lines / 100% functions / ≥95% statements / ≥90% branches**. The few defensive invariant-guard branches (e.g. runtime determinism mismatch) carry `/* v8 ignore */` annotations with rationale.
501
- - **Size budget**: `scripts/check-size.mjs` enforces per-subpath gzip caps in CI, measured as each entry's transitive closure (entry + shared chunks — the build code-splits so error classes keep cross-subpath identity) — core ≤6.5 KB, pbt ≤8.5 KB (transitively imports `createRuntime`), replay ≤3.3 KB, effects ≤1.7 KB, guards ≤1.5 KB, timer ≤1.2 KB, inspect ≤1 KB. Exceeding any cap fails the build.
502
-
503
- ### The 6 built-in generic properties
504
-
505
- | # | Property | One-liner |
506
- | --- | --------------------------------- | -------------------------------------------------------- |
507
- | 1 | snapshotAlwaysFrozen | After any event sequence, the snapshot remains frozen |
508
- | 2 | unknownEventNoOp | Undeclared events do not change the snapshot |
509
- | 3 | reachableStatesSubsetDeclared | Every reachable state belongs to `def.states` |
510
- | 4 | replayEqualsFold | `replay(init, log)` equals `events.reduce(step)` |
511
- | 5 | guardsFalseNoTransition | When all guards fail, the state is unchanged |
512
- | 6 | assignDoesNotMutate | `assign` never modifies the previous context |
513
-
514
- ---
515
-
516
- ## Comparison
44
+ Prefer `setup<Ctx, Evt>().defineMachine()` for state inference. Use bare `defineMachine<Ctx, Evt, States>()` only when you need explicit generic control.
517
45
 
518
- | | aifsmjs | XState v5 | Robot3 | @xstate/store | Zag.js |
519
- | -------------------------- | -------------- | ----------------- | ----------------- | ----------------- | ----------------- |
520
- | Core size (gzip) | ~2.8KB | ~15KB | ~1KB | < 1KB | per-component |
521
- | Hierarchical states | Sugar (0.3.0) | Yes | No | N/A | Yes |
522
- | Async invoke / actor | No | Yes | No | N/A | No |
523
- | Guard combinators | and/or/not | and/or/not | No | N/A | No |
524
- | Effects dual-track | enqueue | enqueueActions | reduce/action | enq.effect() | array of names |
525
- | Inspect / observe | read-only | inspect API | No | proposed | watch ctx |
526
- | Serializable definition | Yes | Yes | Partial | Partial | Yes |
527
- | fast-check adapter | built-in | No | No | No | No |
528
- | Tree-shake subpath imports | Yes | Partial | Yes | Yes | Yes |
46
+ ## Public Surface
529
47
 
530
- ---
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()`. |
531
57
 
532
- ## Roadmap
58
+ ## Lifecycle Rules
533
59
 
534
- | Version | Scope |
535
- | ------- | ------------------------------------------------------------------ |
536
- | v0.1 | core + guards + effects + inspect + replay + pbt (this release) |
537
- | v0.2 | Async-guard detection, coverage tuning, llms-full.txt verify gate |
538
- | v0.3 | Hierarchical sugar via `state.sub` (experimental) |
539
- | v0.4 | Sub-machine API promoted to stable; dependency-reduction cycle |
540
- | v0.5 | `aifsmjs-bridge-bitecs` / `aifsmjs-bridge-pixi` (separate sub-packages) |
541
- | 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`.
542
66
 
543
- **Out of scope (v1)**:
67
+ ## Sharp Edges
544
68
 
545
- - **Parallel state regions** (out of scope for v1)
546
- - **Actor invocation / spawn** (out of scope for v1)
547
- - **Tick / game-loop hook** (out of scope for v1)
548
- - **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.
549
74
 
550
- **Future candidate**: `historyState` — remember last active sub-state on
551
- re-entry. Workaround today: snapshot via `onTransition` and restore
552
- manually.
75
+ ## AI Context
553
76
 
554
- ---
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)
555
82
 
556
83
  ## License
557
84
 
558
- [MIT](LICENSE)
85
+ MIT