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.
- package/README.md +44 -513
- package/README_ZHTW.md +44 -505
- package/dist/chunk-2MME5V4F.cjs +384 -0
- package/dist/chunk-2MME5V4F.cjs.map +1 -0
- package/dist/chunk-3B2USJ3H.cjs +18 -0
- package/dist/chunk-3B2USJ3H.cjs.map +1 -0
- package/dist/chunk-A7U7QQL5.js +52 -0
- package/dist/chunk-A7U7QQL5.js.map +1 -0
- package/dist/chunk-CDK25FTD.cjs +59 -0
- package/dist/chunk-CDK25FTD.cjs.map +1 -0
- package/dist/chunk-FHTQ7LSQ.cjs +21 -0
- package/dist/chunk-FHTQ7LSQ.cjs.map +1 -0
- package/dist/chunk-I354FONA.cjs +153 -0
- package/dist/chunk-I354FONA.cjs.map +1 -0
- package/dist/chunk-JKZAOPQC.js +16 -0
- package/dist/chunk-JKZAOPQC.js.map +1 -0
- package/dist/chunk-NEJYZAKR.js +19 -0
- package/dist/chunk-NEJYZAKR.js.map +1 -0
- package/dist/chunk-PZ5AY32C.js +9 -0
- package/dist/chunk-PZ5AY32C.js.map +1 -0
- package/dist/chunk-Q7SFCCGT.cjs +11 -0
- package/dist/chunk-Q7SFCCGT.cjs.map +1 -0
- package/dist/chunk-VZCSHTOI.js +374 -0
- package/dist/chunk-VZCSHTOI.js.map +1 -0
- package/dist/chunk-ZLQ7HZCE.js +142 -0
- package/dist/chunk-ZLQ7HZCE.js.map +1 -0
- package/dist/effects/index.cjs +8 -14
- package/dist/effects/index.cjs.map +1 -1
- package/dist/effects/index.js +5 -14
- package/dist/effects/index.js.map +1 -1
- package/dist/guards/index.cjs +9 -13
- package/dist/guards/index.cjs.map +1 -1
- package/dist/guards/index.js +8 -12
- package/dist/guards/index.js.map +1 -1
- package/dist/index.cjs +101 -591
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +5 -571
- package/dist/index.js.map +1 -1
- package/dist/inspect/index.cjs +2 -0
- package/dist/inspect/index.cjs.map +1 -1
- package/dist/inspect/index.js +2 -0
- package/dist/inspect/index.js.map +1 -1
- package/dist/pbt/index.cjs +26 -525
- package/dist/pbt/index.cjs.map +1 -1
- package/dist/pbt/index.d.cts +7 -3
- package/dist/pbt/index.d.ts +7 -3
- package/dist/pbt/index.js +12 -511
- package/dist/pbt/index.js.map +1 -1
- package/dist/replay/index.cjs +9 -195
- package/dist/replay/index.cjs.map +1 -1
- package/dist/replay/index.js +5 -198
- package/dist/replay/index.js.map +1 -1
- package/dist/timer/index.cjs +30 -9
- package/dist/timer/index.cjs.map +1 -1
- package/dist/timer/index.js +30 -9
- package/dist/timer/index.js.map +1 -1
- package/llms-full.txt +107 -932
- package/llms.txt +8 -35
- package/package.json +5 -3
package/README.md
CHANGED
|
@@ -1,554 +1,85 @@
|
|
|
1
1
|
# aifsmjs
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
[](https://github.com/yshengliao/aifsmjs/actions/workflows/ci.yml)
|
|
5
|
-
[](LICENSE)
|
|
6
|
-
[](https://www.anthropic.com/claude-code)
|
|
7
|
-
[](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
|
-
>
|
|
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
|
-
|
|
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
|
-
```
|
|
37
|
-
import {
|
|
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:
|
|
50
|
-
green:
|
|
51
|
-
yellow: { on: { NEXT: { target: "red",
|
|
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);
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
58
|
+
## Lifecycle Rules
|
|
529
59
|
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
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
|
-
|
|
67
|
+
## Sharp Edges
|
|
540
68
|
|
|
541
|
-
-
|
|
542
|
-
-
|
|
543
|
-
-
|
|
544
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
85
|
+
MIT
|