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 +44 -517
- package/README_ZHTW.md +44 -507
- package/dist/{chunk-VZCSHTOI.js → chunk-LG2AH5X6.js} +15 -7
- package/dist/chunk-LG2AH5X6.js.map +1 -0
- package/dist/{chunk-2MME5V4F.cjs → chunk-TPCDOVU4.cjs} +15 -7
- package/dist/chunk-TPCDOVU4.cjs.map +1 -0
- package/dist/index.cjs +10 -10
- package/dist/index.js +1 -1
- package/dist/pbt/index.cjs +16 -11
- package/dist/pbt/index.cjs.map +1 -1
- package/dist/pbt/index.d.cts +18 -1
- package/dist/pbt/index.d.ts +18 -1
- package/dist/pbt/index.js +9 -4
- package/dist/pbt/index.js.map +1 -1
- package/llms-full.txt +114 -1072
- package/llms.txt +8 -35
- package/package.json +1 -1
- package/dist/chunk-2MME5V4F.cjs.map +0 -1
- package/dist/chunk-VZCSHTOI.js.map +0 -1
package/llms-full.txt
CHANGED
|
@@ -13,562 +13,89 @@ The short index lives at `llms.txt` (see https://llmstxt.org/).
|
|
|
13
13
|
|
|
14
14
|
# aifsmjs
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
[](https://github.com/islumina/aifsmjs/actions/workflows/ci.yml)
|
|
18
|
-
[](LICENSE)
|
|
19
|
-
[](https://www.anthropic.com/claude-code)
|
|
20
|
-
[](README_ZHTW.md)
|
|
16
|
+
Small deterministic FSM library for replayable TypeScript/JavaScript state machines. Definitions are plain data; guards/actions/effects are injected at runtime.
|
|
21
17
|
|
|
22
|
-
>
|
|
18
|
+
> **Status: 0.5.9 - stable 1.0-track core.** Core FSM, guards, effects, inspect, replay, PBT helpers, scheduler, and sub-machines are live.
|
|
23
19
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
> **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.
|
|
27
|
-
|
|
28
|
-
**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.
|
|
29
|
-
|
|
30
|
-
---
|
|
31
|
-
|
|
32
|
-
## Why aifsmjs
|
|
33
|
-
|
|
34
|
-
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:
|
|
35
|
-
|
|
36
|
-
- **Lifecycle is a pure function**: `step(def, snapshot, event, impl)` runs `guards → exit → action → entry` in a fixed, uninterruptible order.
|
|
37
|
-
- **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**.
|
|
38
|
-
- **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.
|
|
39
|
-
- **PBT is first-class**: built-in `fast-check` `fc.commands` adapter plus 6 generic property tests. No comparable library currently ships this.
|
|
40
|
-
|
|
41
|
-
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.
|
|
42
|
-
|
|
43
|
-
---
|
|
44
|
-
|
|
45
|
-
## Quick Start
|
|
20
|
+
## Install
|
|
46
21
|
|
|
47
22
|
```bash
|
|
48
23
|
pnpm add aifsmjs
|
|
49
24
|
```
|
|
50
25
|
|
|
51
|
-
```
|
|
52
|
-
import {
|
|
26
|
+
```ts
|
|
27
|
+
import { assign, createRuntime, setup } from "aifsmjs";
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Quick Start
|
|
53
31
|
|
|
32
|
+
```ts
|
|
54
33
|
type Ctx = { ticks: number };
|
|
55
34
|
type Evt = { type: "NEXT" };
|
|
56
35
|
|
|
57
|
-
// 1. Definition is plain data; setup<Ctx, Evt>() lets States be inferred from
|
|
58
|
-
// the keys of `states`, so you don't have to repeat them.
|
|
59
36
|
const trafficLight = setup<Ctx, Evt>().defineMachine({
|
|
60
37
|
id: "trafficLight",
|
|
61
38
|
initial: "red",
|
|
62
39
|
context: { ticks: 0 },
|
|
63
40
|
states: {
|
|
64
|
-
red:
|
|
65
|
-
green:
|
|
66
|
-
yellow: { on: { NEXT: { target: "red",
|
|
41
|
+
red: { on: { NEXT: { target: "green", actions: ["bump"] } } },
|
|
42
|
+
green: { on: { NEXT: { target: "yellow", actions: ["bump"] } } },
|
|
43
|
+
yellow: { on: { NEXT: { target: "red", actions: ["bump"] } } },
|
|
67
44
|
},
|
|
68
45
|
});
|
|
69
46
|
|
|
70
|
-
// 2. Implementations are injected only at runtime
|
|
71
47
|
const runtime = createRuntime(trafficLight, {
|
|
72
48
|
actions: {
|
|
73
49
|
bump: assign(({ context }) => ({ ticks: context.ticks + 1 })),
|
|
74
50
|
},
|
|
75
51
|
});
|
|
76
52
|
|
|
77
|
-
// 3. Interact
|
|
78
53
|
runtime.send({ type: "NEXT" });
|
|
79
|
-
console.log(runtime.getSnapshot().value);
|
|
80
|
-
console.log(runtime.getSnapshot().context); // { ticks: 1 }
|
|
81
|
-
```
|
|
82
|
-
|
|
83
|
-
> 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()`.
|
|
84
|
-
|
|
85
|
-
---
|
|
86
|
-
|
|
87
|
-
## Mental Model
|
|
88
|
-
|
|
89
|
-
```
|
|
90
|
-
┌──────────────────────┐ ┌──────────────────────┐
|
|
91
|
-
│ MachineDefinition │ │ Implementations │
|
|
92
|
-
│ (plain data, JSON) │ + │ (guards/actions/ │
|
|
93
|
-
│ • states │ │ effects fn map) │
|
|
94
|
-
│ • on / target │ │ │
|
|
95
|
-
│ • string refs │ │ │
|
|
96
|
-
└──────────┬───────────┘ └──────────┬───────────┘
|
|
97
|
-
│ │
|
|
98
|
-
└──────────────┬───────────────┘
|
|
99
|
-
▼
|
|
100
|
-
┌────────────────────────┐
|
|
101
|
-
│ step(def, snap, evt, │ ← pure function
|
|
102
|
-
│ impl) │ fixed order, uninterruptible
|
|
103
|
-
└───────────┬────────────┘
|
|
104
|
-
▼
|
|
105
|
-
┌────────────────────────┐
|
|
106
|
-
│ { snapshot, │
|
|
107
|
-
│ effects: [...] } │ caller decides when
|
|
108
|
-
└───────────┬────────────┘ to dispatch effects
|
|
109
|
-
▼
|
|
110
|
-
┌────────────────────────┐
|
|
111
|
-
│ createRuntime(...) │ ← thin wrapper
|
|
112
|
-
│ state holder + send │
|
|
113
|
-
└────────────────────────┘
|
|
114
|
-
```
|
|
115
|
-
|
|
116
|
-
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.
|
|
117
|
-
|
|
118
|
-
---
|
|
119
|
-
|
|
120
|
-
## Capabilities / Limitations
|
|
121
|
-
|
|
122
|
-
| Will do (v1) | Won't do |
|
|
123
|
-
| --------------------------------------------------- | ------------------------------------------------- |
|
|
124
|
-
| Flat states + transitions | Parallel state regions |
|
|
125
|
-
| Hierarchical sugar via `state.sub` (stable since 0.4.0) | Closures embedded in definition (breaks serialize) |
|
|
126
|
-
| Guards (sync only; inline async throws `InvalidDefinitionError` at `defineMachine`; runtime throws `AsyncGuardError` on thenable return) | Async guards |
|
|
127
|
-
| Actions (assign + enqueue effects) | Async API inside an action (use an effect) |
|
|
128
|
-
| Fire-and-forget effects | Actor invocation / spawn |
|
|
129
|
-
| Read-only inspect middleware | Cancellable transition middleware |
|
|
130
|
-
| `replay(initial, log, def, impl)` pure function | Time-travel debugger (v2 candidate) |
|
|
131
|
-
| `fast-check` `fc.commands` adapter | Custom PBT framework |
|
|
132
|
-
| String ref + runtime injection | Single root import for everything |
|
|
133
|
-
| Tree-shake friendly subpath exports | ECS / Pixi bridges (opt-in subpath, not core) |
|
|
134
|
-
|
|
135
|
-
---
|
|
136
|
-
|
|
137
|
-
## Design Philosophy
|
|
138
|
-
|
|
139
|
-
<details>
|
|
140
|
-
<summary>Why lifecycle cannot be middleware (click to expand)</summary>
|
|
141
|
-
|
|
142
|
-
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:
|
|
143
|
-
|
|
144
|
-
1. **Determinism**: the same event sequence no longer guarantees the same snapshot.
|
|
145
|
-
2. **Replay**: event logs cannot reproduce the same outcome in another environment.
|
|
146
|
-
3. **PBT shrinking**: fast-check's counter-example minimization presumes a deterministic machine.
|
|
147
|
-
|
|
148
|
-
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.
|
|
149
|
-
|
|
150
|
-
So aifsmjs splits the CoR chain instinct two ways:
|
|
151
|
-
|
|
152
|
-
| Use case | How it is handled |
|
|
153
|
-
| ------------------------------ | ------------------------------------------------------- |
|
|
154
|
-
| Chained guard predicates | `and/or/not` higher-order combinators |
|
|
155
|
-
| Multi-step action sequencing | `actions: [...]` array, runs in order to completion |
|
|
156
|
-
| Cross-cutting (log/persist) | `inspect/` middleware — read-only, no cancel ability |
|
|
157
|
-
|
|
158
|
-
</details>
|
|
159
|
-
|
|
160
|
-
<details>
|
|
161
|
-
<summary>Why the definition is plain data</summary>
|
|
162
|
-
|
|
163
|
-
The moment definitions contain closures, you lose:
|
|
164
|
-
|
|
165
|
-
- `JSON.stringify` round-trip for DB / localStorage persistence
|
|
166
|
-
- `postMessage` transfer to a Web Worker
|
|
167
|
-
- Static reachability analysis by a visualizer tool
|
|
168
|
-
- Auto-generated event arbitraries from a PBT adapter
|
|
169
|
-
|
|
170
|
-
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.
|
|
171
|
-
|
|
172
|
-
</details>
|
|
173
|
-
|
|
174
|
-
---
|
|
175
|
-
|
|
176
|
-
## Core API
|
|
177
|
-
|
|
178
|
-
### `defineMachine<C, E, S>(def)`
|
|
179
|
-
|
|
180
|
-
```typescript
|
|
181
|
-
function defineMachine<
|
|
182
|
-
Ctx = Record<string, never>,
|
|
183
|
-
Evt extends { type: string } = { type: string },
|
|
184
|
-
States extends string = string,
|
|
185
|
-
>(def: MachineConfig<Ctx, Evt, States>): MachineDef<Ctx, Evt, States>;
|
|
186
|
-
```
|
|
187
|
-
|
|
188
|
-
Pure data builder. Validates that `initial` exists in the `states` map and returns the (normalized) definition.
|
|
189
|
-
|
|
190
|
-
`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.
|
|
191
|
-
|
|
192
|
-
```typescript
|
|
193
|
-
// No context needed — defaults to {}
|
|
194
|
-
const toggle = defineMachine({
|
|
195
|
-
id: "toggle",
|
|
196
|
-
initial: "off",
|
|
197
|
-
states: {
|
|
198
|
-
off: { on: { TOGGLE: "on" } }, // string shorthand, see below
|
|
199
|
-
on: { on: { TOGGLE: "off" } },
|
|
200
|
-
},
|
|
201
|
-
});
|
|
54
|
+
console.log(runtime.getSnapshot().value); // "green"
|
|
202
55
|
```
|
|
203
56
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
```typescript
|
|
207
|
-
on: { NEXT: "green" } // shorthand for { target: "green" }
|
|
208
|
-
on: { NEXT: [{ target: "a", guard: "g" }, "b"] } // mixes with the object form
|
|
209
|
-
```
|
|
210
|
-
|
|
211
|
-
### `createRuntime(def, impl, opts?)`
|
|
212
|
-
|
|
213
|
-
```typescript
|
|
214
|
-
function createRuntime<C, E, S>(
|
|
215
|
-
def: MachineDef<C, E, S>,
|
|
216
|
-
impl: Implementations<C, E>,
|
|
217
|
-
opts?: { middleware?: readonly Middleware<C, E, S>[] },
|
|
218
|
-
): Runtime<C, E, S>;
|
|
219
|
-
|
|
220
|
-
interface Runtime<C, E, S> {
|
|
221
|
-
getSnapshot(): Snapshot<C, S>;
|
|
222
|
-
send(event: E): Snapshot<C, S>;
|
|
223
|
-
subscribe(listener: (snap: Snapshot<C, S>) => void): () => void;
|
|
224
|
-
reset(event?: E): Snapshot<C, S>;
|
|
225
|
-
dispose(): void;
|
|
226
|
-
readonly disposed: boolean;
|
|
227
|
-
readonly signal: AbortSignal;
|
|
228
|
-
}
|
|
229
|
-
```
|
|
57
|
+
Prefer `setup<Ctx, Evt>().defineMachine()` for state inference. Use bare `defineMachine<Ctx, Evt, States>()` only when you need explicit generic control.
|
|
230
58
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
`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.
|
|
234
|
-
|
|
235
|
-
### `step(def, snapshot, event, impl)`
|
|
236
|
-
|
|
237
|
-
```typescript
|
|
238
|
-
function step<C, E, S>(
|
|
239
|
-
def: MachineDef<C, E, S>,
|
|
240
|
-
snapshot: Snapshot<C, S>,
|
|
241
|
-
event: E,
|
|
242
|
-
impl: Implementations<C, E>,
|
|
243
|
-
): { snapshot: Snapshot<C, S>; effects: readonly Effect[] };
|
|
244
|
-
```
|
|
245
|
-
|
|
246
|
-
**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.
|
|
247
|
-
|
|
248
|
-
### `assign(updater)`
|
|
249
|
-
|
|
250
|
-
```typescript
|
|
251
|
-
function assign<C, E>(
|
|
252
|
-
updater: (args: { context: C; event: E }) => Partial<C>,
|
|
253
|
-
): Action<C, E>;
|
|
254
|
-
```
|
|
255
|
-
|
|
256
|
-
Pure context update helper. Returns a partial that is merged into the context. No side effects.
|
|
257
|
-
|
|
258
|
-
---
|
|
259
|
-
|
|
260
|
-
## Opt-in Modules
|
|
261
|
-
|
|
262
|
-
Each opt-in lives on its own subpath. If you don't import it, it is fully tree-shaken away.
|
|
263
|
-
|
|
264
|
-
### `aifsmjs/guards` — Guard combinators
|
|
265
|
-
|
|
266
|
-
```typescript
|
|
267
|
-
import { and, or, not, stateIn } from "aifsmjs/guards";
|
|
268
|
-
|
|
269
|
-
const canCheckout = and([
|
|
270
|
-
"isAuthenticated",
|
|
271
|
-
or(["isAdmin", "isOwner"]),
|
|
272
|
-
not("isBanned"),
|
|
273
|
-
]);
|
|
274
|
-
```
|
|
275
|
-
|
|
276
|
-
`and/or/not` short-circuit over sync guards. `stateIn(...states)` is a sugar predicate: "current state is one of these".
|
|
277
|
-
|
|
278
|
-
### `aifsmjs/effects` — Fire-and-forget effects
|
|
279
|
-
|
|
280
|
-
```typescript
|
|
281
|
-
import { type Action } from "aifsmjs";
|
|
282
|
-
|
|
283
|
-
const checkout: Action<Ctx, Evt> = ({ context, enqueue }) => {
|
|
284
|
-
enqueue.effect("trackAnalytics", { event: "checkout", ctx: context });
|
|
285
|
-
// Return value becomes the new context (omit to keep current context)
|
|
286
|
-
};
|
|
287
|
-
```
|
|
288
|
-
|
|
289
|
-
`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.
|
|
290
|
-
|
|
291
|
-
### `aifsmjs/inspect` — Read-only middleware
|
|
292
|
-
|
|
293
|
-
```typescript
|
|
294
|
-
import { createRuntime } from "aifsmjs";
|
|
295
|
-
import { logger, persist } from "aifsmjs/inspect";
|
|
296
|
-
|
|
297
|
-
const runtime = createRuntime(def, impl, {
|
|
298
|
-
middleware: [
|
|
299
|
-
logger(console.log),
|
|
300
|
-
persist({ key: "machine-state", storage: localStorage }),
|
|
301
|
-
],
|
|
302
|
-
});
|
|
303
|
-
```
|
|
304
|
-
|
|
305
|
-
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.
|
|
306
|
-
|
|
307
|
-
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.
|
|
308
|
-
|
|
309
|
-
### `aifsmjs/replay` — Pure event log replay
|
|
310
|
-
|
|
311
|
-
```typescript
|
|
312
|
-
import { replay } from "aifsmjs/replay";
|
|
313
|
-
|
|
314
|
-
const finalSnap = replay(initialSnapshot, eventLog, def, impl);
|
|
315
|
-
// Equivalent to eventLog.reduce((s, e) => step(def, s, e, impl).snapshot, initial)
|
|
316
|
-
```
|
|
317
|
-
|
|
318
|
-
Never dispatches effects. For PBT, time-travel debugging, and incident reproduction.
|
|
319
|
-
|
|
320
|
-
### `aifsmjs/pbt` — fast-check adapter
|
|
321
|
-
|
|
322
|
-
> **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.
|
|
323
|
-
|
|
324
|
-
```typescript
|
|
325
|
-
import fc from "fast-check";
|
|
326
|
-
import { createRuntime } from "aifsmjs";
|
|
327
|
-
import { commandsFromMachine, initialModel, properties } from "aifsmjs/pbt";
|
|
328
|
-
|
|
329
|
-
// Use one of the six built-in generic properties, or assertAll for all at once:
|
|
330
|
-
properties.replayEqualsFold(def, impl, {
|
|
331
|
-
NEXT: fc.constant({ type: "NEXT" as const }),
|
|
332
|
-
});
|
|
333
|
-
|
|
334
|
-
// Or build a custom property using commandsFromMachine:
|
|
335
|
-
fc.assert(
|
|
336
|
-
fc.property(
|
|
337
|
-
commandsFromMachine(def, impl, {
|
|
338
|
-
NEXT: fc.constant({ type: "NEXT" as const }),
|
|
339
|
-
}),
|
|
340
|
-
(cmds) => {
|
|
341
|
-
const real = createRuntime(def, impl, { dispatchEffects: false });
|
|
342
|
-
fc.modelRun(() => ({ model: initialModel(def), real }), cmds);
|
|
343
|
-
return true;
|
|
344
|
-
},
|
|
345
|
-
),
|
|
346
|
-
);
|
|
347
|
-
```
|
|
348
|
-
|
|
349
|
-
`properties.*` ships 6 generic properties (see [Testing Strategy](#testing-strategy)). `fast-check` is `peerDependenciesMeta.optional`; no install penalty if you don't use it.
|
|
350
|
-
|
|
351
|
-
### `aifsmjs/timer` — Cancellable delayed callbacks
|
|
352
|
-
|
|
353
|
-
```typescript
|
|
354
|
-
import { after, createScheduler } from "aifsmjs/timer";
|
|
355
|
-
|
|
356
|
-
// One-shot
|
|
357
|
-
const handle = after(5000, () => runtime.send({ type: "TIMEOUT" }));
|
|
358
|
-
handle.cancel(); // cancels if not yet fired
|
|
359
|
-
|
|
360
|
-
// AbortSignal integration
|
|
361
|
-
const ac = new AbortController();
|
|
362
|
-
after(5000, () => runtime.send({ type: "TIMEOUT" }), { signal: ac.signal });
|
|
363
|
-
ac.abort(); // also cancels
|
|
364
|
-
|
|
365
|
-
// Scheduler: bundle a group of timers and cancel them together on teardown
|
|
366
|
-
const sched = createScheduler();
|
|
367
|
-
sched.after(1000, () => {});
|
|
368
|
-
sched.after(2000, () => {});
|
|
369
|
-
sched.cancelAll();
|
|
370
|
-
```
|
|
371
|
-
|
|
372
|
-
- Thin wrapper over `setTimeout` / `clearTimeout`, with injectable timer functions (validated by vitest fake timers)
|
|
373
|
-
- AbortSignal listener registered with `{ once: true }` to avoid leaks
|
|
374
|
-
- Decoupled from the FSM core: you decide when to forward a fired timer as `runtime.send(...)`
|
|
375
|
-
|
|
376
|
-
---
|
|
377
|
-
|
|
378
|
-
## Lifecycle Invariants
|
|
379
|
-
|
|
380
|
-
The fixed order inside `step()` (always, no escape hatch):
|
|
381
|
-
|
|
382
|
-
```
|
|
383
|
-
1. resolveTransitions(def, snapshot.value, event)
|
|
384
|
-
→ candidate transitions for (state, event)
|
|
385
|
-
2. evaluate guard on each candidate in declaration order
|
|
386
|
-
→ first passing transition is chosen; otherwise the original snapshot is returned
|
|
387
|
-
3. exit actions of the old state (v1 is flat, no hierarchy)
|
|
388
|
-
4. transition.actions[] run in declaration order
|
|
389
|
-
→ each action may call enqueue.effect()
|
|
390
|
-
→ each action's returned partial context is merged into the current context
|
|
391
|
-
5. entry actions of the new state
|
|
392
|
-
6. return { snapshot, effects } — the caller decides when to dispatch effects
|
|
393
|
-
```
|
|
59
|
+
## Public Surface
|
|
394
60
|
|
|
395
|
-
|
|
61
|
+
| Import | Purpose |
|
|
62
|
+
| --- | --- |
|
|
63
|
+
| `aifsmjs` | `setup`, `defineMachine`, `createRuntime`, `createMachine`, `step`, `assign`, snapshots, runtime/errors/types. |
|
|
64
|
+
| `aifsmjs/guards` | `and`, `or`, `not`, `stateIn`. Guards must be synchronous. |
|
|
65
|
+
| `aifsmjs/effects` | `enqueue.effect()` descriptors and `runEffects()`. |
|
|
66
|
+
| `aifsmjs/inspect` | Read-only middleware helpers: `logger`, `persist`, `recorder`. |
|
|
67
|
+
| `aifsmjs/replay` | Pure event-log replay. |
|
|
68
|
+
| `aifsmjs/pbt` | fast-check property helpers. |
|
|
69
|
+
| `aifsmjs/timer` | `after()` and `createScheduler()`. |
|
|
396
70
|
|
|
397
|
-
|
|
71
|
+
## Lifecycle Rules
|
|
398
72
|
|
|
399
|
-
-
|
|
400
|
-
-
|
|
401
|
-
-
|
|
402
|
-
-
|
|
73
|
+
- `step(def, snapshot, event, impl)` is pure and returns `{ snapshot, effects, changed }`.
|
|
74
|
+
- `createRuntime()` owns mutable runtime state, dispatches effects after commit, and emits transition/error/dispose events.
|
|
75
|
+
- Guards and reducers are sync. Thenable guards throw `AsyncGuardError`.
|
|
76
|
+
- Effects are fire-and-forget descriptors. Async rejection is routed to the runtime `"error"` channel.
|
|
77
|
+
- `reset()` rewinds the snapshot and notifies listeners, but does not run entry actions.
|
|
78
|
+
- `dispose()` is idempotent; post-dispose `send()`/`reset()` throw `RuntimeDisposedError`.
|
|
403
79
|
|
|
404
|
-
|
|
80
|
+
## Sharp Edges
|
|
405
81
|
|
|
406
|
-
-
|
|
407
|
-
-
|
|
82
|
+
- Middleware and synchronous effect throws happen after snapshot commit. A throw can leave the committed snapshot visible without later notification.
|
|
83
|
+
- Sub-machine replacement can roll back on init failure, but dispose failure has already torn down the old child.
|
|
84
|
+
- `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.
|
|
85
|
+
- `setup().defineMachine()` uses `NoInfer` so states infer from `keyof states`; keep regression tests for exact optional property configurations.
|
|
86
|
+
- Do not perform async I/O inside guards or actions. Send events from effects instead.
|
|
408
87
|
|
|
409
|
-
|
|
88
|
+
## AI Context
|
|
410
89
|
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
3. New child (if next state has `sub`) instantiation — exceptions become
|
|
417
|
-
`SubMachineError(phase: "init")`.
|
|
418
|
-
4. Parent snapshot commits.
|
|
419
|
-
5. Middleware pipeline runs.
|
|
420
|
-
6. Effects dispatch.
|
|
421
|
-
7. `'transition'` event emits to `on()` / `onTransition()` subscribers.
|
|
422
|
-
|
|
423
|
-
If step 2 or 3 throws, the parent snapshot is **not** committed (rollback
|
|
424
|
-
to `prev`); no middleware / effects / `'transition'` runs.
|
|
425
|
-
|
|
426
|
-
`runtime.dispose()` cascades to the child via `controller.signal`'s abort
|
|
427
|
-
listener and an explicit `child.dispose()` call. Cascade swallows child
|
|
428
|
-
exceptions to honour the never-throws dispose contract.
|
|
429
|
-
|
|
430
|
-
---
|
|
431
|
-
|
|
432
|
-
## Lifecycle Protocol
|
|
433
|
-
|
|
434
|
-
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):
|
|
435
|
-
|
|
436
|
-
| Verb | aifsmjs equivalent | Semantics |
|
|
437
|
-
|---|---|---|
|
|
438
|
-
| `createX()` | `createRuntime` / `createScheduler` / `defineMachine` / `setup` | Factory function returning the instance |
|
|
439
|
-
| `dispose()` | `runtime.dispose()` / `scheduler.cancelAll()` | Release resources; idempotent; post-dispose API throws a known error |
|
|
440
|
-
| `reset()` | `runtime.reset()` | Zero out state without releasing resources |
|
|
441
|
-
| `on/off` | `runtime.subscribe(fn)` returning an unsubscribe fn | Subscription pattern; explicit unsubscribe |
|
|
442
|
-
| `AbortSignal` | `runtime.signal` / `after(_, _, { signal })` | Cancellation channel for any long-running / async work |
|
|
443
|
-
| Pure core | `step()` | No I/O, serializable, replayable |
|
|
444
|
-
| Explicit errors | `RuntimeDisposedError` / `UnknownGuardError` / `UnknownActionError` / `InvalidDefinitionError` | Named error classes, never bare `throw "string"` |
|
|
445
|
-
|
|
446
|
-
When future ai\*js packages ask "should this have a dispose?" or "where does the signal plug in?", this table is the baseline.
|
|
447
|
-
|
|
448
|
-
---
|
|
449
|
-
|
|
450
|
-
## Design choices: divergence from common patterns
|
|
451
|
-
|
|
452
|
-
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.
|
|
453
|
-
|
|
454
|
-
- **`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.
|
|
455
|
-
- **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.
|
|
456
|
-
- **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.
|
|
457
|
-
- **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.
|
|
458
|
-
- **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.
|
|
459
|
-
- **`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.
|
|
460
|
-
- **`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.
|
|
461
|
-
|
|
462
|
-
---
|
|
463
|
-
|
|
464
|
-
## AI-Agent Reading Guide
|
|
465
|
-
|
|
466
|
-
> This section is for LLMs and code-search agents. Invariants, types, and misuse patterns are concentrated here.
|
|
467
|
-
|
|
468
|
-
### Serializable fields
|
|
469
|
-
|
|
470
|
-
The following are plain data, safe to `JSON.stringify` round-trip:
|
|
471
|
-
|
|
472
|
-
- The entire `MachineDef` (provided no inline functions are used)
|
|
473
|
-
- The entire `Snapshot` (provided `context` is plain data)
|
|
474
|
-
- The entire `Effect` (`{ type: string; payload?: unknown }`)
|
|
475
|
-
|
|
476
|
-
The following are **not serializable** and will break PBT/replay:
|
|
477
|
-
|
|
478
|
-
- Every function inside `Implementations`
|
|
479
|
-
- Middleware closures
|
|
480
|
-
|
|
481
|
-
### Invariants (do not violate)
|
|
482
|
-
|
|
483
|
-
1. `step()` is pure: identical `(def, snapshot, event, impl)` always returns identical `{ snapshot, effects }`.
|
|
484
|
-
2. Snapshots are frozen: in dev mode any mutation throws immediately.
|
|
485
|
-
3. Guards never mutate context: violators are caught by PBT property #2.
|
|
486
|
-
4. Effects are always fire-and-forget: the runtime never waits for an effect before updating the snapshot.
|
|
487
|
-
5. `dispose()` is idempotent; post-dispose `send()` / `reset()` throws `RuntimeDisposedError`.
|
|
488
|
-
6. `runtime.signal.aborted` is `true` for the rest of time once disposed; the effect handler's `signal` is the same one.
|
|
489
|
-
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`).
|
|
490
|
-
8. `MiddlewareContext.event` is typed `Evt | ResetEvent`; a `reset()` without an event injects the `RESET_EVENT_TYPE` sentinel (`"@@aifsmjs/RESET"`).
|
|
491
|
-
|
|
492
|
-
### Common misuses
|
|
493
|
-
|
|
494
|
-
| Anti-pattern | Correct form |
|
|
495
|
-
| --------------------------------------------------------- | ------------------------------------------------------------- |
|
|
496
|
-
| Calling `fetch()` (or any async API) inside a guard | Rewrite as events: send `FETCH_REQUEST`, then `FETCH_DONE` |
|
|
497
|
-
| `setTimeout`-and-mutate inside an action | Use `enqueue.effect("delayedThing", ...)` |
|
|
498
|
-
| Using middleware to alter the next state | Not possible — middleware is read-only. Rewrite as a guard. |
|
|
499
|
-
| Inline functions inside a definition (works but breaks serialize) | Pull out as string refs, inject at `createRuntime` |
|
|
500
|
-
|
|
501
|
-
### Machine-readable schema
|
|
502
|
-
|
|
503
|
-
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.
|
|
504
|
-
|
|
505
|
-
---
|
|
506
|
-
|
|
507
|
-
## Testing Strategy
|
|
508
|
-
|
|
509
|
-
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.
|
|
510
|
-
|
|
511
|
-
- **Example tests** (vitest): for every src module, write happy path + edge + error-message triplets.
|
|
512
|
-
- **PBT smoke**: each generic property runs 50 iterations as an invariant guard, not as a coverage source.
|
|
513
|
-
- **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.
|
|
514
|
-
- **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.
|
|
515
|
-
|
|
516
|
-
### The 6 built-in generic properties
|
|
517
|
-
|
|
518
|
-
| # | Property | One-liner |
|
|
519
|
-
| --- | --------------------------------- | -------------------------------------------------------- |
|
|
520
|
-
| 1 | snapshotAlwaysFrozen | After any event sequence, the snapshot remains frozen |
|
|
521
|
-
| 2 | unknownEventNoOp | Undeclared events do not change the snapshot |
|
|
522
|
-
| 3 | reachableStatesSubsetDeclared | Every reachable state belongs to `def.states` |
|
|
523
|
-
| 4 | replayEqualsFold | `replay(init, log)` equals `events.reduce(step)` |
|
|
524
|
-
| 5 | guardsFalseNoTransition | When all guards fail, the state is unchanged |
|
|
525
|
-
| 6 | assignDoesNotMutate | `assign` never modifies the previous context |
|
|
526
|
-
|
|
527
|
-
---
|
|
528
|
-
|
|
529
|
-
## Comparison
|
|
530
|
-
|
|
531
|
-
| | aifsmjs | XState v5 | Robot3 | @xstate/store | Zag.js |
|
|
532
|
-
| -------------------------- | -------------- | ----------------- | ----------------- | ----------------- | ----------------- |
|
|
533
|
-
| Core size (gzip) | ~2.8KB | ~15KB | ~1KB | < 1KB | per-component |
|
|
534
|
-
| Hierarchical states | Sugar (0.3.0) | Yes | No | N/A | Yes |
|
|
535
|
-
| Async invoke / actor | No | Yes | No | N/A | No |
|
|
536
|
-
| Guard combinators | and/or/not | and/or/not | No | N/A | No |
|
|
537
|
-
| Effects dual-track | enqueue | enqueueActions | reduce/action | enq.effect() | array of names |
|
|
538
|
-
| Inspect / observe | read-only | inspect API | No | proposed | watch ctx |
|
|
539
|
-
| Serializable definition | Yes | Yes | Partial | Partial | Yes |
|
|
540
|
-
| fast-check adapter | built-in | No | No | No | No |
|
|
541
|
-
| Tree-shake subpath imports | Yes | Partial | Yes | Yes | Yes |
|
|
542
|
-
|
|
543
|
-
---
|
|
544
|
-
|
|
545
|
-
## Roadmap
|
|
546
|
-
|
|
547
|
-
| Version | Scope |
|
|
548
|
-
| ------- | ------------------------------------------------------------------ |
|
|
549
|
-
| v0.1 | core + guards + effects + inspect + replay + pbt (this release) |
|
|
550
|
-
| v0.2 | Async-guard detection, coverage tuning, llms-full.txt verify gate |
|
|
551
|
-
| v0.3 | Hierarchical sugar via `state.sub` (experimental) |
|
|
552
|
-
| v0.4 | Sub-machine API promoted to stable; dependency-reduction cycle |
|
|
553
|
-
| v0.5 | `aifsmjs-bridge-bitecs` / `aifsmjs-bridge-pixi` (separate sub-packages) |
|
|
554
|
-
| v1.0 | API freeze and stability guarantee |
|
|
555
|
-
|
|
556
|
-
**Out of scope (v1)**:
|
|
557
|
-
|
|
558
|
-
- **Parallel state regions** (out of scope for v1)
|
|
559
|
-
- **Actor invocation / spawn** (out of scope for v1)
|
|
560
|
-
- **Tick / game-loop hook** (out of scope for v1)
|
|
561
|
-
- **ECS / Pixi bridges** (out of scope for v1)
|
|
562
|
-
|
|
563
|
-
**Future candidate**: `historyState` — remember last active sub-state on
|
|
564
|
-
re-entry. Workaround today: snapshot via `onTransition` and restore
|
|
565
|
-
manually.
|
|
566
|
-
|
|
567
|
-
---
|
|
90
|
+
- Short index: [`llms.txt`](llms.txt)
|
|
91
|
+
- Full generated context: [`llms-full.txt`](llms-full.txt)
|
|
92
|
+
- Stability contract: [`STABILITY.md`](STABILITY.md)
|
|
93
|
+
- Current review backlog: [`REVIEW.md`](REVIEW.md)
|
|
94
|
+
- Release history: [`CHANGELOG.md`](CHANGELOG.md)
|
|
568
95
|
|
|
569
96
|
## License
|
|
570
97
|
|
|
571
|
-
|
|
98
|
+
MIT
|
|
572
99
|
|
|
573
100
|
---
|
|
574
101
|
|
|
@@ -576,502 +103,85 @@ manually.
|
|
|
576
103
|
|
|
577
104
|
# Changelog
|
|
578
105
|
|
|
579
|
-
All notable changes to
|
|
580
|
-
|
|
581
|
-
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
582
|
-
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
106
|
+
All notable changes to aifsmjs are summarized here.
|
|
583
107
|
|
|
584
108
|
## [Unreleased]
|
|
585
109
|
|
|
586
|
-
## [0.5.
|
|
587
|
-
|
|
588
|
-
### Fixed
|
|
589
|
-
|
|
590
|
-
- **Guard combinators route through the async-guard safety net** — `and()` / `or()` / `not()` now pass each resolved guard's return value through the same `isThenable` check as top-level guards, throwing `AsyncGuardError` instead of coercing a pending thenable to `true`/`false`. The 0.2.0 async-guard protection was bypassable through any combinator. (Review wave 2026-06-10, FSM-S-01.)
|
|
591
|
-
- **`reset()` captures the committed snapshot before notifying** (mirroring the 0.2.0 `send()` fix), so a listener that re-enters `send()` during reset can no longer make the `'transition'` payload report a state pair that never occurred. (FSM-B-01.)
|
|
592
|
-
- **Scheduler `pending` registry no longer leaks** on any of its three escape paths: signal-abort cancellation, scheduling on an already-aborted signal, and a custom scheduler that fires synchronously during `after()`. Signal handling is owned by the scheduler layer now; fire/cancel/abort all remove the tracked handle and detach the abort listener. (FSM-R-01.)
|
|
593
|
-
- PBT property #5 `guardsFalseNoTransition` actually asserts `changed === false` (its body was vacuous — it returned `true` unconditionally); the other five generic properties were audited and are non-vacuous. (FSM-B-02.)
|
|
594
|
-
- Hierarchical `sub.initial` is validated as a member of `sub.states` at definition time. (FSM-S-02.)
|
|
595
|
-
- Effect results are detected with `isThenable` instead of `instanceof Promise` at both dispatch sites, so cross-realm / userland PromiseLike values are awaited and error-routed correctly. (FSM-B-03.)
|
|
596
|
-
- `emit()` / `notify()` snapshot their listener sets before iterating (family-canonical), pinning unsubscribe/re-subscribe-during-dispatch semantics. (FAM-S-03.)
|
|
597
|
-
|
|
598
|
-
### Changed
|
|
599
|
-
|
|
600
|
-
- **Code splitting enabled (`tsup splitting: true`)** — the published 0.5.x dist inlined a private copy of the shared runtime (including every error class) into each subpath bundle, so an error raised through `aifsmjs/guards` failed `instanceof` checks against the root export. Shared chunks restore cross-subpath class identity; total dist JS shrank 50.7 KB → 31.1 KB. `check:size` now measures each entry's transitive chunk closure, with budgets recalibrated and the README size-budget bullet updated to match.
|
|
601
|
-
- Triplicated child init/wire logic extracted into a single `initChildFor()` helper (behaviour-preserving).
|
|
602
|
-
- Supply-chain and release hardening: CI/publish actions SHA-pinned, npm CLI pinned (`11.16.0`), `permissions: contents: read` on CI, job timeouts, `npm publish --ignore-scripts`, manual dispatch defaults to dry-run, new `verify:docs` banner gate, two-stage typecheck (tests are now type-checked), `llms-full.txt` embeds `STABILITY.md`, and a cross-subpath dist smoke (`verify:dist`).
|
|
603
|
-
|
|
604
|
-
### Docs
|
|
605
|
-
|
|
606
|
-
- README coverage claim corrected to the enforced thresholds (95% statements); middleware sync-throw commit/notify boundary and `next()`-skip behaviour documented (with characterisation tests); README status banner added (EN + ZHTW).
|
|
110
|
+
## [0.5.9] - 2026-06-29
|
|
607
111
|
|
|
608
|
-
|
|
112
|
+
- Fixed: `dispose()` never throws and always completes teardown even if a `'dispose'` event listener throws (external-signal abort cleanups no longer leak); restores the never-throws / idempotency contract.
|
|
113
|
+
- Fixed: sub-machine definitions are now deep-validated at construction — an unknown transition target or a declared-async guard inside a `sub` is rejected by `defineMachine` (with a cycle guard) instead of surfacing only at `child.send()`.
|
|
114
|
+
- Fixed: PBT replay/assign oracles use structural deep-equality (`node:util` `isDeepStrictEqual`) instead of `JSON.stringify`, which mis-handled key order, `undefined` keys, `Map`/`Set`/`Date`, and `BigInt`.
|
|
115
|
+
- Docs: clarified that `replay()` reproduces parent value+context only (sub-machine state is not modelled) and that production snapshots are frozen at the top level only.
|
|
609
116
|
|
|
610
|
-
|
|
117
|
+
## [0.5.8] - 2026-06-14
|
|
611
118
|
|
|
612
|
-
-
|
|
119
|
+
- Documentation-only slimming pass across README, stability notes, review backlog, and LLM context. Family version alignment at 0.5.8 — no runtime or API change. `setup().defineMachine()` inference tests and an opt-in safer mode for post-commit synchronous throws remain documented follow-ups.
|
|
613
120
|
|
|
614
|
-
## [0.5.
|
|
615
|
-
|
|
616
|
-
### Added
|
|
617
|
-
|
|
618
|
-
- **String-shorthand transitions and optional `context` (both additive, non-breaking).** Transitions now accept a bare target string — `on: { EVENT: "target" }` is sugar for `{ target: "target" }`, normalized in the resolver and composable inside guard-fallthrough arrays; and `context` is now optional in `defineMachine` / `setup().defineMachine`, defaulting to `{}` (`Ctx` defaults to `Record<string, never>`). Existing object-form transitions and explicit-`context` definitions are unaffected. (`src/fsm/types.ts`, `src/fsm/resolver.ts`, `src/fsm/definition.ts`, `src/fsm/lifecycle.ts`, `src/fsm/runtime.ts`)
|
|
619
|
-
|
|
620
|
-
## [0.5.2] - 2026-06-05
|
|
621
|
-
|
|
622
|
-
### Docs
|
|
623
|
-
|
|
624
|
-
- Review-driven documentation fixes (`README.md`, `README_ZHTW.md`, `llms-full.txt`): clarity and accuracy from a cross-package code review. No runtime or API change; `dist` byte-identical to 0.5.1.
|
|
625
|
-
|
|
626
|
-
## [0.5.1] - 2026-06-02
|
|
627
|
-
|
|
628
|
-
### Fixed
|
|
629
|
-
|
|
630
|
-
- **Memory: `after()` and `createScheduler().after()` accumulated dead abort
|
|
631
|
-
listeners on a reused `AbortSignal`.** `{ once: true }` only removes the
|
|
632
|
-
listener when the signal fires — not when the timer fires normally or
|
|
633
|
-
`cancel()` is called. Scheduling many timers on one long-lived signal
|
|
634
|
-
therefore accumulated dead `"abort"` closures. The fix explicitly calls
|
|
635
|
-
`signal.removeEventListener("abort", cancel)` inside the fire callback (so
|
|
636
|
-
a fired timer detaches immediately) and at the end of `cancel()` (so a
|
|
637
|
-
cancelled timer also detaches). Same class of leak as the [0.1.2] and
|
|
638
|
-
[0.3.1] abort-listener fixes; the timer subpath was the remaining gap.
|
|
639
|
-
Four regression tests added to `test/timer/scheduler.test.ts` covering
|
|
640
|
-
both `after()` and `createScheduler().after()`, fire and cancel paths.
|
|
641
|
-
(`src/timer/scheduler.ts`)
|
|
642
|
-
|
|
643
|
-
### Changed
|
|
644
|
-
|
|
645
|
-
- **`fast-check` peer dependency range extended to `^3.20.0 || ^4.0.0`.**
|
|
646
|
-
The ai\*js family standard is fast-check v4.8; consumers who have already
|
|
647
|
-
upgraded to v4 no longer need to suppress a peer warning. The devDependency
|
|
648
|
-
is pinned to `^4.8.0` so CI runs against v4. Consumers who remain on v3
|
|
649
|
-
are fully unaffected — the `||` range keeps v3 satisfied. The `aifsmjs/pbt`
|
|
650
|
-
subpath is the only entry point that imports fast-check; the core and all
|
|
651
|
-
other subpaths are tree-shake-free of it.
|
|
652
|
-
|
|
653
|
-
## [0.4.1] — 2026-05-29
|
|
654
|
-
|
|
655
|
-
### Changed
|
|
656
|
-
|
|
657
|
-
- **`STABILITY.md` is now repo-only** — removed from the npm `files`
|
|
658
|
-
allowlist, aligning with the majority of the ai*js family (5 of 7
|
|
659
|
-
packages already ship the stability contract repo-only). The file stays
|
|
660
|
-
in the repository and remains visible on GitHub and the rendered npm
|
|
661
|
-
package page; it is simply no longer bundled inside the published
|
|
662
|
-
tarball. Packaging consistency patch: **no runtime API change, no
|
|
663
|
-
signature change**; the built bundles (`dist/`) are byte-identical to
|
|
664
|
-
0.4.0 (core gzip 4,387 B).
|
|
665
|
-
|
|
666
|
-
## [0.4.0] — 2026-05-29
|
|
667
|
-
|
|
668
|
-
### Changed
|
|
669
|
-
|
|
670
|
-
- **Sub-machine API promoted experimental → stable.** The hierarchical
|
|
671
|
-
sub-machine surface shipped in 0.3.0 — `StateDef.sub`, `StateDef.subImpl`,
|
|
672
|
-
`Runtime.subRuntime()`, `SubMachineError`, and the `SubMachineDef` type
|
|
673
|
-
alias — is now stable. Signatures and runtime semantics, including the
|
|
674
|
-
init-failure quarantine behaviour, are frozen for the 1.x line; the
|
|
675
|
-
boundaries documented in `STABILITY.md` are intentional design trade-offs,
|
|
676
|
-
not instability. **No signature changed from 0.3.x.**
|
|
677
|
-
|
|
678
|
-
### Dependency reduction
|
|
679
|
-
|
|
680
|
-
- Part of the ai*js v0.4.0 dependency-reduction cycle. `fast-check` remains
|
|
681
|
-
an **optional** peer dependency isolated to the `aifsmjs/pbt` subpath. A
|
|
682
|
-
fresh build confirms the core entry and the five non-pbt subpaths
|
|
683
|
-
(`guards` / `effects` / `inspect` / `replay` / `timer`) are tree-shake-free
|
|
684
|
-
of `fast-check` (only `dist/pbt/index.js` references it). `pnpm audit`
|
|
685
|
-
reports zero advisories. No `devDependency` changes.
|
|
686
|
-
|
|
687
|
-
### Compatibility
|
|
688
|
-
|
|
689
|
-
This release adds **no runtime API** and changes **no signature**. The core
|
|
690
|
-
bundle is byte-identical to 0.3.1 (gzip 4,387 B); existing 0.3.x consumer
|
|
691
|
-
code is unaffected. The only substantive change is the documented stability
|
|
692
|
-
tier of the sub-machine API.
|
|
693
|
-
|
|
694
|
-
## [0.3.1] — 2026-05-29
|
|
695
|
-
|
|
696
|
-
### Fixed
|
|
697
|
-
|
|
698
|
-
- **Memory: `on(type, fn, { once: true, signal })` left the abort listener
|
|
699
|
-
attached after the once-handler fired.** The `once` wrapper removed itself
|
|
700
|
-
from the listener set but did not detach the `AbortSignal` listener or drop
|
|
701
|
-
its entry from the internal cleanup set, so the closure lingered on the
|
|
702
|
-
external signal until the signal aborted or `dispose()` ran. With a
|
|
703
|
-
long-lived signal and repeated once+signal registration, dead listeners
|
|
704
|
-
accumulated. `on()` now routes the once-wrapper, the abort handler, and the
|
|
705
|
-
returned unsubscribe through a single `cleanup()` that always detaches the
|
|
706
|
-
abort listener. `runtime.onTransition(fn, { once, signal })` inherits the
|
|
707
|
-
fix (it delegates to `on`). Same class of leak as the [0.1.2] abort-listener
|
|
708
|
-
fix; `once` + `signal` together was the remaining gap. Present since 0.1.2.
|
|
709
|
-
|
|
710
|
-
This release is **non-breaking**. No API surface change; `once`-only,
|
|
711
|
-
`signal`-only, and no-option callers are byte-for-byte unaffected at runtime.
|
|
712
|
-
Core gzip 4,393 B → 4,387 B (the shared `cleanup` closure deduplicates).
|
|
713
|
-
|
|
714
|
-
## [0.3.0] — 2026-05-29
|
|
715
|
-
|
|
716
|
-
### Added
|
|
717
|
-
|
|
718
|
-
- **Hierarchical / sub-machine sugar** (experimental). `StateDef` accepts
|
|
719
|
-
two new optional fields: `sub` (a child `SubMachineDef`) and `subImpl`
|
|
720
|
-
(the child's `Implementations`). When the runtime enters a state with
|
|
721
|
-
`sub`, a child `Runtime` is lazily instantiated; when it exits, the child
|
|
722
|
-
is disposed. Access via `runtime.subRuntime()`. See `STABILITY.md` for
|
|
723
|
-
the experimental contract.
|
|
724
|
-
- New error: `SubMachineError` (`{ parentState, phase, cause }`) thrown
|
|
725
|
-
by `send()` / `reset()` on child init / dispose failure. `dispose()`
|
|
726
|
-
cascade swallows child dispose exceptions (idempotent + never-throws
|
|
727
|
-
contract).
|
|
728
|
-
- New type alias: `SubMachineDef<SubCtx, SubEvt, SubStates>`.
|
|
729
|
-
- **`runtime.onTransition(handler, opts?)`** — semantic sugar over
|
|
730
|
-
`runtime.on('transition', handler, opts)`. One-line delegation; shares
|
|
731
|
-
the same listener Set, so registration order across both APIs determines
|
|
732
|
-
invocation order. Returned unsubscribe identical to `on('transition', ...)`.
|
|
733
|
-
|
|
734
|
-
### Stability
|
|
735
|
-
|
|
736
|
-
- New file: `STABILITY.md`. Documents the three tiers: **stable**
|
|
737
|
-
(everything shipped 0.1.0–0.2.1), **experimental** (`sub`, `subImpl`,
|
|
738
|
-
`subRuntime`, `SubMachineError`, `SubMachineDef`), **draft**
|
|
739
|
-
(`historyState`, v0.4 candidate).
|
|
740
|
-
|
|
741
|
-
### Changed (positioning)
|
|
121
|
+
## [0.5.6] - 2026-06-10
|
|
742
122
|
|
|
743
|
-
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
- **README Lifecycle Invariants**: documented the sub-machine ordering —
|
|
747
|
-
parent `step()` lifecycle (exit / actions / entry) runs first, then
|
|
748
|
-
child dispose → child init → snapshot commit → middleware → effects →
|
|
749
|
-
`transition` emit.
|
|
123
|
+
- Hardened async guard rejection, reset snapshot integrity, sub-machine lifecycle cleanup, and scheduler abort cleanup.
|
|
124
|
+
- Clarified fire-and-forget effect semantics and post-commit ordering.
|
|
125
|
+
- Regenerated generated LLM context from canonical docs.
|
|
750
126
|
|
|
751
|
-
|
|
127
|
+
## Older releases
|
|
752
128
|
|
|
753
|
-
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
129
|
+
- `0.5.5` through `0.5.1` focused on release hygiene, docs accuracy, property tests, and lifecycle regressions.
|
|
130
|
+
- `0.4.x` stabilized sub-machine lifecycle semantics.
|
|
131
|
+
- `0.3.x` added inspect/replay/PBT/timer helpers and dependency reduction.
|
|
132
|
+
- `0.2.x` hardened definitions, guard/action resolution, and examples.
|
|
133
|
+
- `0.1.x` introduced `defineMachine`, `createRuntime`, `step`, `assign`, snapshots, and core error classes.
|
|
758
134
|
|
|
759
|
-
|
|
135
|
+
---
|
|
760
136
|
|
|
761
|
-
|
|
762
|
-
the new sub-machine fields. All existing API signatures, error types, and
|
|
763
|
-
runtime behaviour are byte-identical.
|
|
137
|
+
# Stability tiers (`STABILITY.md`)
|
|
764
138
|
|
|
765
|
-
|
|
139
|
+
# Stability
|
|
766
140
|
|
|
767
|
-
|
|
141
|
+
## Stable Surface
|
|
768
142
|
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
143
|
+
| Surface | Status | Notes |
|
|
144
|
+
| --- | --- | --- |
|
|
145
|
+
| `aifsmjs` root | Stable | Definition/runtime/step/snapshot APIs and core errors. |
|
|
146
|
+
| `aifsmjs/guards` | Stable | Sync guard combinators. |
|
|
147
|
+
| `aifsmjs/effects` | Stable | Effect descriptors and dispatcher helper. |
|
|
148
|
+
| `aifsmjs/inspect` | Stable | Read-only middleware helpers. |
|
|
149
|
+
| `aifsmjs/replay` | Stable | Pure log replay. |
|
|
150
|
+
| `aifsmjs/pbt` | Stable | fast-check helpers. |
|
|
151
|
+
| `aifsmjs/timer` | Stable | Timer/scheduler helpers. |
|
|
772
152
|
|
|
773
|
-
|
|
153
|
+
## Behavioral Contract
|
|
774
154
|
|
|
775
|
-
-
|
|
776
|
-
-
|
|
777
|
-
-
|
|
155
|
+
- Definition data is serializable when using string refs instead of inline functions.
|
|
156
|
+
- `step()` is pure and never dispatches effects.
|
|
157
|
+
- Runtime commit happens before middleware, effect dispatch, and listener notification.
|
|
158
|
+
- Async effects are fire-and-forget; rejections emit runtime `"error"`.
|
|
159
|
+
- `reset()` does not run entry actions.
|
|
160
|
+
- `dispose()` aborts runtime signal, clears listeners, and is idempotent. A throwing `'dispose'` listener is swallowed and never aborts teardown.
|
|
778
161
|
|
|
779
|
-
|
|
162
|
+
## Replay caveat
|
|
780
163
|
|
|
781
|
-
|
|
164
|
+
`replay()` and `step()` reproduce only the **parent** machine's `value` + `context` (`aifsmjs/replay`, "Pure event-log replay" in the README). Sub-machine state is **not** modelled: the pure lifecycle has no `sub` references, so a replayed/stepped snapshot reflects the parent state alone and never re-instantiates, advances, or restores any child runtime. To capture child state for time-travel or incident reproduction, snapshot the child separately from the live runtime via `subRuntime()`.
|
|
782
165
|
|
|
783
|
-
|
|
166
|
+
## Snapshot freezing depth
|
|
784
167
|
|
|
785
|
-
|
|
786
|
-
- **Definition time** (inline `async` guard) → `InvalidDefinitionError` from `defineMachine`'s `validateDefinition`.
|
|
787
|
-
- **Runtime** (string-ref or cast guard whose return value is thenable) → `AsyncGuardError` from `evalGuard`. The thenable check uses `typeof x?.then === "function"`, so cross-realm Promises (iframe / worker / vm) and user-defined PromiseLike values are also caught — not just same-realm `instanceof Promise`.
|
|
788
|
-
- New exports from `aifsmjs`: `AsyncGuardError`, `isAsyncGuardFn`.
|
|
789
|
-
- README's "Capabilities / Limitations" table updated to reflect the new runtime guarantee.
|
|
168
|
+
Snapshot freezing is depth-dependent on `NODE_ENV`:
|
|
790
169
|
|
|
791
|
-
|
|
170
|
+
- **Dev** (`NODE_ENV !== "production"`): the whole snapshot tree is deep-frozen, so accidental nested mutation throws immediately.
|
|
171
|
+
- **Production** (`NODE_ENV === "production"`): only the **top-level** snapshot object is frozen (`Object.freeze`). Nested `context` is **caller-owned and not deeply frozen** — treat it as read-only by convention; the library does not enforce immutability of nested context in prod.
|
|
792
172
|
|
|
793
|
-
|
|
794
|
-
- **`evalGuard` falls back to `<inline>` for anonymous-arrow guards** ([src/fsm/evaluator.ts](src/fsm/evaluator.ts)): switched `??` to `||` so an empty `Function.prototype.name` falls back instead of producing `guard "" must be sync;`.
|
|
795
|
-
- **README + README_ZHTW + llms-full.txt** now describe the two error paths separately (`InvalidDefinitionError` at definition time vs `AsyncGuardError` at runtime) instead of conflating them.
|
|
796
|
-
|
|
797
|
-
### Changed (positioning + meta)
|
|
798
|
-
|
|
799
|
-
- **`package.json#description`** rewritten from «for web game development» to lead with the broader use case set (multi-step forms, checkout funnels, auth flows, tutorials, scene flow). The README's "Primary audience" paragraph already moved away from game-only framing in v0.2.0; the package metadata now matches.
|
|
800
|
-
|
|
801
|
-
### Build & tooling
|
|
802
|
-
|
|
803
|
-
- **`verify:llms` is now build-agnostic** ([scripts/build-llms-full.mjs](scripts/build-llms-full.mjs)): the script accepts `--check` which builds the file in memory and compares against disk, exit 1 on diff. The previous form used `git diff --exit-code -- llms-full.txt` after running the build, which failed any time the working tree had uncommitted changes (not just llms-full.txt drift). The new form works identically pre-commit and in CI.
|
|
804
|
-
- **Per-subpath gzip budgets raised** ([scripts/check-size.mjs](scripts/check-size.mjs)): core 3500 → 3700 B and replay 1600 → 1800 B to absorb the AsyncGuardError + thenable detection cost; pbt 4500 → 4600 B for a small symbol additions. All entries still tracked at ≥95% headroom.
|
|
805
|
-
|
|
806
|
-
### Added (examples)
|
|
807
|
-
|
|
808
|
-
- `examples/03-checkout-funnel` — e-commerce checkout funnel with guarded staging, payment / analytics effects, and a `replay()` round-trip. Demonstrates that aifsmjs models classic web UX flows with no canvas / game loop involvement.
|
|
809
|
-
- `examples/04-form-wizard` — multi-step form wizard with back / next / jump-to-step navigation, per-step validation, and draft persistence via the `persist` middleware.
|
|
810
|
-
|
|
811
|
-
### Changed (positioning)
|
|
812
|
-
|
|
813
|
-
- `README.md` and `README_ZHTW.md`'s "Primary audience" paragraph now leads with stateful web flows (multi-step forms, checkout funnels, auth flows, tutorials, document workflows) and frames games as one application of the same pattern. The core remains environment-neutral; the only opt-in dependency is `fast-check` for the `aifsmjs/pbt` PBT adapter.
|
|
814
|
-
|
|
815
|
-
### Compatibility
|
|
816
|
-
|
|
817
|
-
This release is **non-breaking at runtime** for users who already wrote sync guards. Async guards previously slipped through and silently passed; they now throw. If you relied on this accidental behaviour, move the async work into an effect (`enq.effect(...)`) and dispatch a follow-up event when the work completes — the pattern is documented in the README's "Common pitfalls" table.
|
|
818
|
-
|
|
819
|
-
## [0.1.2] — 2026-05-28
|
|
820
|
-
|
|
821
|
-
### Fixed
|
|
822
|
-
|
|
823
|
-
- **Memory**: `runtime.on(type, fn, { signal })` previously left an
|
|
824
|
-
abort listener attached to the external `AbortSignal` after
|
|
825
|
-
`runtime.dispose()`. The listener (and its closure over the user's
|
|
826
|
-
callback) was retained until the signal eventually fired or was
|
|
827
|
-
garbage-collected. `dispose()` now removes each abort listener from
|
|
828
|
-
the signal it was attached to, and the unsubscribe function returned
|
|
829
|
-
by `on()` does the same on manual unsubscribe.
|
|
830
|
-
|
|
831
|
-
### Internal
|
|
832
|
-
|
|
833
|
-
- Narrowed `dispatchEffects` event parameter from `Evt | ResetEvent`
|
|
834
|
-
to `Evt`; the function is only reached via `send()`. Removed the
|
|
835
|
-
corresponding `as Evt` cast.
|
|
836
|
-
- Removed a redundant `snapshot.context as Ctx` cast in `step()`.
|
|
837
|
-
|
|
838
|
-
No public API changes; existing 0.1.1 callers run unchanged. Core gzip
|
|
839
|
-
3296 B → 3401 B (97% of 3500 B budget).
|
|
840
|
-
|
|
841
|
-
## [0.1.1] — 2026-05-28
|
|
842
|
-
|
|
843
|
-
### Changed
|
|
844
|
-
|
|
845
|
-
- **Release pipeline**: switched to npm OIDC trusted publisher. Releases
|
|
846
|
-
now ship with provenance attestation generated from the GitHub Action
|
|
847
|
-
via `id-token: write` + `--provenance`; no long-lived `NPM_TOKEN`
|
|
848
|
-
needed. The `Publish to npm` workflow is unchanged from v0.1.0; see
|
|
849
|
-
CONTRIBUTING for the `pnpm version patch && git push --follow-tags`
|
|
850
|
-
flow.
|
|
851
|
-
|
|
852
|
-
No code changes vs v0.1.0; runtime behaviour, API surface, and bundle
|
|
853
|
-
sizes are identical (core gzip 3.30 KB / 3.5 KB budget).
|
|
854
|
-
|
|
855
|
-
## [0.1.0] — 2026-05-28
|
|
856
|
-
|
|
857
|
-
Initial public release.
|
|
858
|
-
|
|
859
|
-
### Added
|
|
860
|
-
|
|
861
|
-
- **fsm/** (source folder, internal) — `defineMachine`, `setup<Ctx, Evt>()`
|
|
862
|
-
curried builder for inferred States, `createRuntime`, `step()` pure
|
|
863
|
-
function with fixed `guards → exit → action → entry` lifecycle order.
|
|
864
|
-
Runtime exposes `dispose()`, `reset(event?)`, `disposed`, `signal`
|
|
865
|
-
(internal `AbortController` lifetime) — see the Lifecycle Protocol section
|
|
866
|
-
of README. `RuntimeDisposedError` thrown on post-dispose calls. Snapshot
|
|
867
|
-
is frozen (deep-frozen in dev). Implementations injected at runtime via
|
|
868
|
-
string refs.
|
|
869
|
-
- **`aifsmjs/guards`** — `and / or / not / stateIn` higher-order combinators
|
|
870
|
-
with short-circuit evaluation. Both string-ref and inline `Guard` supported.
|
|
871
|
-
- **`aifsmjs/effects`** — `Enqueuer` API (`enqueue.effect(type, payload?)`)
|
|
872
|
-
and a standalone `runEffects()` dispatcher. Effects are descriptors, not
|
|
873
|
-
callbacks, so they remain serializable. `EffectHandler` receives the
|
|
874
|
-
runtime's `AbortSignal` in `args.signal`. `runEffects()` accepts
|
|
875
|
-
`args.signal` as optional — standalone callers may omit it and the
|
|
876
|
-
dispatcher supplies a never-aborting placeholder.
|
|
877
|
-
- **`Runtime.reset()`** — listeners notified only when `prev.value !==
|
|
878
|
-
initial.value` (parity with `send()`); middleware always observes the
|
|
879
|
-
call regardless. The triggering event is exposed on
|
|
880
|
-
`MiddlewareContext.event` as `Evt | ResetEvent`. The sentinel
|
|
881
|
-
`RESET_EVENT_TYPE` (`"@@aifsmjs/RESET"`) is exported for discrimination.
|
|
882
|
-
- **`aifsmjs/inspect`** — Koa-style read-only middleware pipeline. Built-in
|
|
883
|
-
`logger`, `persist`, and `recorder` middlewares. Middleware cannot alter a
|
|
884
|
-
transition outcome.
|
|
885
|
-
- **`aifsmjs/replay`** — Pure event-log fold via `step()`. Never dispatches
|
|
886
|
-
effects; suitable for PBT, time travel, and incident reproduction.
|
|
887
|
-
- **`aifsmjs/pbt`** — `fast-check` `fc.commands` adapter
|
|
888
|
-
(`commandsFromMachine`) plus six generic property tests
|
|
889
|
-
(`snapshotAlwaysFrozen`, `unknownEventNoOp`, `reachableStatesSubsetDeclared`,
|
|
890
|
-
`replayEqualsFold`, `guardsFalseNoTransition`, `assignDoesNotMutate`) and an
|
|
891
|
-
`assertAll` convenience runner. `fast-check` listed as optional peer.
|
|
892
|
-
- **`aifsmjs/timer`** — `after(ms, fn, { signal })` returning a cancellable
|
|
893
|
-
handle, plus `createScheduler()` for bundled cancellation. `AbortSignal`
|
|
894
|
-
listeners registered with `{ once: true }` to avoid leaks.
|
|
895
|
-
- TypeScript build with `strict + noUncheckedIndexedAccess +
|
|
896
|
-
exactOptionalPropertyTypes`; dual ESM/CJS output via tsup.
|
|
897
|
-
- Bilingual README (Traditional Chinese canonical + English mirror) with an
|
|
898
|
-
AI-Agent Reading Guide section, Lifecycle Invariants contract, and
|
|
899
|
-
comparison table against XState v5 / Robot3 / @xstate/store / Zag.js.
|
|
900
|
-
- 94 example-based tests (vitest) plus PBT smoke runs against a traffic-light
|
|
901
|
-
fixture.
|
|
902
|
-
|
|
903
|
-
### API additions for ai*js ecosystem alignment
|
|
904
|
-
|
|
905
|
-
- **`createMachine(def, impl, opts?)`** — single-factory convenience that
|
|
906
|
-
composes `defineMachine` + `createRuntime`. Spec-style entry point from
|
|
907
|
-
the ai*js micro-runtime review; the curried `setup().defineMachine` form
|
|
908
|
-
remains for States inference, and explicit `defineMachine<Ctx,Evt,States>`
|
|
909
|
-
remains as an escape hatch.
|
|
910
|
-
- **`runtime.snapshot()`** — alias for `runtime.getSnapshot()`; documented
|
|
911
|
-
as the preferred name going forward.
|
|
912
|
-
- **`runtime.can(event)`** — predicate that returns `true` iff sending the
|
|
913
|
-
event would fire a transition. Reuses `evalGuard`; guards must be pure
|
|
914
|
-
for `can` and `send` to agree.
|
|
915
|
-
- **`runtime.on(type, listener, { signal?, once? })`** — `EventTarget`-style
|
|
916
|
-
typed event API. Channels: `'transition'` (after a state-changing `send`
|
|
917
|
-
or `reset`), `'error'` (async effect handler rejections), `'dispose'`
|
|
918
|
-
(fires once on teardown). `subscribe(listener)` is unchanged and still
|
|
919
|
-
preferred for `useSyncExternalStore`.
|
|
920
|
-
|
|
921
|
-
Core gzip grew from 2.87 KB to ~3.3 KB; the size budget script raised the
|
|
922
|
-
core cap to 3.5 KB with rationale in `scripts/check-size.mjs`.
|
|
923
|
-
|
|
924
|
-
### Documentation restructure
|
|
925
|
-
|
|
926
|
-
- `README.md` is now the canonical English README; the Traditional Chinese
|
|
927
|
-
mirror moved to `README_ZHTW.md`.
|
|
928
|
-
- Added `llms.txt` and `llms-full.txt` following the [llmstxt.org](https://llmstxt.org/)
|
|
929
|
-
convention so LLM agents can ground in the project surface with one fetch.
|
|
930
|
-
`llms-full.txt` is generated by `scripts/build-llms-full.mjs`; `pnpm
|
|
931
|
-
verify:llms` re-runs the generator and diffs to catch drift.
|
|
932
|
-
- New "Design choices" section in both READMEs explains why send is sync,
|
|
933
|
-
guards are sync, effects are descriptors, why two factory forms exist,
|
|
934
|
-
and why `on` and `subscribe` both ship.
|
|
935
|
-
|
|
936
|
-
### CI guarantees
|
|
937
|
-
|
|
938
|
-
- **Coverage threshold**: 100% statements / 100% lines / 100% functions /
|
|
939
|
-
≥90% branches, enforced via `@vitest/coverage-v8` thresholds (actual on
|
|
940
|
-
v0.1.0 release: 100/100/100/98.81). Defensive invariant-guard branches
|
|
941
|
-
carry `/* v8 ignore */` annotations with rationale comments.
|
|
942
|
-
- **Per-subpath gzip size budget** (verified by `scripts/check-size.mjs`):
|
|
943
|
-
core ≤3 KB · replay ≤1.6 KB · pbt ≤4.5 KB · guards / effects / inspect /
|
|
944
|
-
timer ≤1 KB each. Tarball measured at ~98 KB / 48 files.
|
|
945
|
-
|
|
946
|
-
### Out of scope (v1)
|
|
947
|
-
|
|
948
|
-
Hierarchical / compound states, parallel state regions, actor invocation
|
|
949
|
-
(async), tick/game-loop hook, ECS / Pixi bridges. See Roadmap in README.
|
|
173
|
+
## Sub-machines
|
|
950
174
|
|
|
951
|
-
|
|
175
|
+
Sub-machines are stable but sharp:
|
|
952
176
|
|
|
953
|
-
|
|
177
|
+
- Entry lazily creates the child; exit disposes it.
|
|
178
|
+
- Init failure rolls back parent transition.
|
|
179
|
+
- Dispose failure happens after the old child is already torn down and surfaces as `SubMachineError`.
|
|
180
|
+
- External child disposal leaves a stale handle until the parent leaves/re-enters the state.
|
|
954
181
|
|
|
955
|
-
|
|
182
|
+
## Drafts
|
|
956
183
|
|
|
957
|
-
|
|
958
|
-
`aifsmjs`. Tiers govern what breaks may occur in future minor / major bumps.
|
|
959
|
-
|
|
960
|
-
## Stable (since 0.1.0)
|
|
961
|
-
|
|
962
|
-
Fully stable. Breaking changes only at a major version bump (1.0+).
|
|
963
|
-
|
|
964
|
-
- `createMachine`, `defineMachine`, `setup`, `createRuntime`, `initialSnapshot`
|
|
965
|
-
- `step`, `resolveTransitions`, `evalGuard`, `resolveGuard`, `isAsyncGuardFn`
|
|
966
|
-
- `assign`, `mergeContext`, `createSnapshot`, `deepFreeze`, `freezeSnapshot`
|
|
967
|
-
- `Runtime.send`, `Runtime.reset`, `Runtime.can`, `Runtime.getSnapshot`,
|
|
968
|
-
`Runtime.snapshot`, `Runtime.subscribe`, `Runtime.on`, `Runtime.dispose`,
|
|
969
|
-
`Runtime.signal`, `Runtime.disposed`
|
|
970
|
-
- All error classes from 0.1.0–0.2.1: `RuntimeDisposedError`,
|
|
971
|
-
`InvalidDefinitionError`, `UnknownActionError`, `UnknownGuardError`,
|
|
972
|
-
`AsyncGuardError`
|
|
973
|
-
- Types: `MachineDef`, `StateDef` (fields `on`, `entry`, `exit`, `final`),
|
|
974
|
-
`TransitionDef`, `Snapshot`, `Implementations`, `Guard`, `Action`,
|
|
975
|
-
`EffectHandler`, `Effect`, `Enqueuer`, `Middleware`, `MiddlewareContext`,
|
|
976
|
-
`RuntimeOptions`, `StepResult`, `ResetEvent`, `RESET_EVENT_TYPE`,
|
|
977
|
-
`RuntimeTransitionEvent`, `RuntimeErrorEvent`, `RuntimeEventMap`
|
|
978
|
-
- All subpath exports: `aifsmjs/guards`, `aifsmjs/effects`, `aifsmjs/inspect`,
|
|
979
|
-
`aifsmjs/replay`, `aifsmjs/pbt`, `aifsmjs/timer`
|
|
980
|
-
- `Runtime.onTransition` (added in 0.3.0) — pure sugar over the stable
|
|
981
|
-
`on('transition', ...)` API; listed under Stable because the underlying
|
|
982
|
-
contract is unchanged.
|
|
983
|
-
|
|
984
|
-
### Sub-machines (stable since 0.4.0)
|
|
985
|
-
|
|
986
|
-
The hierarchical sub-machine surface shipped experimentally in 0.3.0 is
|
|
987
|
-
stable as of 0.4.0. Signatures and runtime semantics — including the
|
|
988
|
-
init-failure quarantine behaviour described below — are frozen for the 1.x
|
|
989
|
-
line. The boundaries listed are intentional design trade-offs, not bugs or
|
|
990
|
-
pending instability.
|
|
991
|
-
|
|
992
|
-
- `StateDef.sub` (optional `SubMachineDef`) — when present, a child runtime
|
|
993
|
-
is lazily initialised on entry and disposed on exit. Per-transition
|
|
994
|
-
ordering is parent `step()` (exit / actions / entry) → child dispose →
|
|
995
|
-
child init → snapshot commit.
|
|
996
|
-
- `StateDef.subImpl` (optional `Implementations`) — paired with `sub`;
|
|
997
|
-
passed to the child `createRuntime`. Defaults to `{}`.
|
|
998
|
-
- `Runtime.subRuntime()` — returns the live child handle, or `undefined`.
|
|
999
|
-
Returned generic is `Runtime<unknown, { type: string }, string>`; caller
|
|
1000
|
-
narrows via cast if necessary.
|
|
1001
|
-
- `SubMachineError` — thrown by `send()` / `reset()` on child init/dispose
|
|
1002
|
-
failure. Fields: `parentState`, `phase ("init" | "dispose")`, `cause`.
|
|
1003
|
-
- `SubMachineDef` type alias.
|
|
1004
|
-
|
|
1005
|
-
#### Design boundaries
|
|
1006
|
-
|
|
1007
|
-
- **Replay / PBT do not see child state.** `replay()` and
|
|
1008
|
-
`commandsFromMachine` only inspect parent snapshots. If your business
|
|
1009
|
-
logic lives in the parent layer, replay is still deterministic.
|
|
1010
|
-
- **`subRuntime()` may return a disposed handle** if an external caller
|
|
1011
|
-
disposed it. The handle is not reinitialised until the parent leaves and
|
|
1012
|
-
re-enters the sub-bearing state. Detect with `child.disposed`.
|
|
1013
|
-
- **Self-targeting external (`A → A`) is treated as full exit/entry**:
|
|
1014
|
-
child is disposed and reinitialised. The dispatcher re-resolves guards to
|
|
1015
|
-
identify the chosen transition before deciding external vs internal, so
|
|
1016
|
-
guarded internal transitions on the same event do not trigger a reinit.
|
|
1017
|
-
- **Init-failure mid-transition leaves the parent without a live child.**
|
|
1018
|
-
If `applySubLifecycle` successfully disposes the old child and then the
|
|
1019
|
-
new child's `createRuntime` throws, the parent's snapshot is rolled back
|
|
1020
|
-
to `prev` but `subRuntime()` returns `undefined`. Callers catching
|
|
1021
|
-
`SubMachineError(phase: "init")` should treat the runtime as quarantined
|
|
1022
|
-
— call `runtime.dispose()` (idempotent) or `runtime.reset()` (which
|
|
1023
|
-
attempts re-init) before sending further events. This quarantine
|
|
1024
|
-
behaviour is part of the stable contract; a future **major** version may
|
|
1025
|
-
switch to a two-phase "init before dispose" commit strategy (a breaking
|
|
1026
|
-
change reserved for 1.0+), but the current semantics are frozen for the
|
|
1027
|
-
1.x line.
|
|
1028
|
-
|
|
1029
|
-
### Middleware, effects & notification ordering (documented boundaries)
|
|
1030
|
-
|
|
1031
|
-
These are intentional ordering boundaries of the read-only middleware /
|
|
1032
|
-
effect pipeline, not bugs. They are stable for the 1.x line.
|
|
1033
|
-
|
|
1034
|
-
- **A synchronous throw from a middleware or effect handler leaves the
|
|
1035
|
-
snapshot committed but unannounced.** Inside `send()` / `reset()` the new
|
|
1036
|
-
snapshot is committed (so `getSnapshot()` already reflects it) *before*
|
|
1037
|
-
`runMiddleware()` and effect dispatch run. If a middleware or effect
|
|
1038
|
-
handler throws synchronously, the throw propagates to the `send()` /
|
|
1039
|
-
`reset()` caller (as documented), but `notify()`, `on('transition')`
|
|
1040
|
-
listeners, and — for a middleware throw — the collected effects are
|
|
1041
|
-
skipped. Observers therefore desynchronise from `getSnapshot()`. The
|
|
1042
|
-
shipped `persist` middleware throws on non-serialisable context, making
|
|
1043
|
-
this reachable without exotic code. Wrap throwing middleware/effects in
|
|
1044
|
-
your own `try/catch` if you need observers to fire regardless. (Contrast:
|
|
1045
|
-
the sub-machine init/dispose failure path *rolls back* instead — see
|
|
1046
|
-
above.) A future **major** may move the commit after the pipeline; the
|
|
1047
|
-
current order is frozen for 1.x.
|
|
1048
|
-
- **`next()` must be called by every middleware; skipping it is not
|
|
1049
|
-
enforced.** Calling `next()` twice throws; calling it zero times is
|
|
1050
|
-
silently tolerated and drops every later middleware (and the recorder /
|
|
1051
|
-
persist sinks) for that event — no throw, no warning. The state transition
|
|
1052
|
-
itself is unaffected (the snapshot is committed before the pipeline). Treat
|
|
1053
|
-
`next()` as mandatory.
|
|
1054
|
-
- **Re-entrant `send()` from inside middleware reorders recorder logs.**
|
|
1055
|
-
Middleware is documented read-only; a middleware that re-entrantly calls
|
|
1056
|
-
`runtime.send()` runs the inner event's full pipeline (including the
|
|
1057
|
-
recorder push) before the outer frame reaches the recorder. The
|
|
1058
|
-
`recorder` sink — intended to feed `replay()` — then lists `[inner,
|
|
1059
|
-
outer]` for an application order of `[outer, inner]`, so replaying that log
|
|
1060
|
-
diverges. Do not `send()` from within the middleware pipeline.
|
|
1061
|
-
|
|
1062
|
-
## Experimental
|
|
1063
|
-
|
|
1064
|
-
No experimental APIs as of 0.4.0. The 0.3.0 sub-machine surface graduated to
|
|
1065
|
-
Stable in 0.4.0 — see "Sub-machines" above.
|
|
1066
|
-
|
|
1067
|
-
## Draft (planned, not implemented)
|
|
1068
|
-
|
|
1069
|
-
API sketched, not shipped. May change before release.
|
|
1070
|
-
|
|
1071
|
-
- `historyState` (candidate for a future minor) — opt-in pseudo-state that
|
|
1072
|
-
remembers the last active sub-state on re-entry. Workaround in 0.3.0:
|
|
1073
|
-
snapshot the sub-runtime's value on exit via `onTransition`, restore
|
|
1074
|
-
manually.
|
|
184
|
+
Parallel regions, actor spawning, async guards, and awaited effect completion are not implemented.
|
|
1075
185
|
|
|
1076
186
|
---
|
|
1077
187
|
|
|
@@ -1079,103 +189,35 @@ API sketched, not shipped. May change before release.
|
|
|
1079
189
|
|
|
1080
190
|
# Contributing to aifsmjs
|
|
1081
191
|
|
|
1082
|
-
|
|
1083
|
-
contributions that keep the surface narrow are easier to accept than ones
|
|
1084
|
-
that expand it.
|
|
192
|
+
Keep the deterministic core small and make lifecycle changes test-heavy.
|
|
1085
193
|
|
|
1086
|
-
##
|
|
194
|
+
## Local workflow
|
|
1087
195
|
|
|
1088
196
|
```bash
|
|
1089
197
|
pnpm install
|
|
1090
|
-
pnpm
|
|
1091
|
-
pnpm
|
|
1092
|
-
pnpm
|
|
1093
|
-
pnpm
|
|
1094
|
-
pnpm
|
|
1095
|
-
pnpm verify:exports
|
|
1096
|
-
pnpm
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
The full pre-publish gate is `pnpm prepublishOnly`, which runs typecheck,
|
|
1100
|
-
lint, coverage (with thresholds), build, exports verification, and size
|
|
1101
|
-
budget check — in that order.
|
|
1102
|
-
|
|
1103
|
-
## What gets in easily
|
|
1104
|
-
|
|
1105
|
-
- Bug fixes with a failing test added first
|
|
1106
|
-
- README / typing corrections
|
|
1107
|
-
- Tests that lock down existing behaviour
|
|
1108
|
-
- New `aifsmjs/<subpath>` opt-in modules that follow the same shape as
|
|
1109
|
-
`guards`, `effects`, `inspect`, `replay`, `pbt`, `timer`: independent,
|
|
1110
|
-
named exports only, no side effects, single responsibility
|
|
1111
|
-
|
|
1112
|
-
## What needs discussion first
|
|
1113
|
-
|
|
1114
|
-
- Anything that changes the `step()` signature or lifecycle order
|
|
1115
|
-
- New required fields on `MachineDef` or `Snapshot`
|
|
1116
|
-
- A change that would push the core gzip past ~3KB
|
|
1117
|
-
- Hierarchical / parallel / actor features (v0.2+ — open an issue with the
|
|
1118
|
-
use case)
|
|
1119
|
-
|
|
1120
|
-
## Design principles
|
|
1121
|
-
|
|
1122
|
-
aifsmjs follows a library-core priority order:
|
|
1123
|
-
|
|
1124
|
-
> Security > Correctness > Simplicity > YAGNI > Performance
|
|
1125
|
-
|
|
1126
|
-
In particular, `step()` must remain a pure function: identical
|
|
1127
|
-
`(def, snapshot, event, impl)` always returns identical
|
|
1128
|
-
`{ snapshot, effects, changed }`. Any change that breaks this invariant will
|
|
1129
|
-
be rejected.
|
|
1130
|
-
|
|
1131
|
-
## Commit & PR style
|
|
1132
|
-
|
|
1133
|
-
- Commit messages: imperative subject under 70 chars; body explains *why*.
|
|
1134
|
-
- PRs: keep scope to one topic. Link the issue if any.
|
|
1135
|
-
- Tests required for any behaviour change. PBT preferred for invariants;
|
|
1136
|
-
example tests preferred for behaviour you want documented.
|
|
1137
|
-
|
|
1138
|
-
## Reporting issues
|
|
1139
|
-
|
|
1140
|
-
- Minimal reproduction welcome (paste the smallest `defineMachine + step`
|
|
1141
|
-
pair that shows the bug).
|
|
1142
|
-
- For security issues, please email the maintainer rather than filing
|
|
1143
|
-
publicly.
|
|
1144
|
-
|
|
1145
|
-
## Release flow
|
|
1146
|
-
|
|
1147
|
-
Releases are automated via the **Publish to npm** GitHub Action
|
|
1148
|
-
([`.github/workflows/publish.yml`](.github/workflows/publish.yml)). The
|
|
1149
|
-
required `NPM_TOKEN` secret is already configured at the repo level. From
|
|
1150
|
-
a clean tree on `main`:
|
|
1151
|
-
|
|
1152
|
-
```bash
|
|
1153
|
-
# 1. Bump version + create commit + create tag (single command)
|
|
1154
|
-
pnpm version patch # or `minor` / `major`
|
|
1155
|
-
|
|
1156
|
-
# 2. Push the commit AND the tag in one go
|
|
1157
|
-
git push --follow-tags
|
|
198
|
+
pnpm typecheck
|
|
199
|
+
pnpm test
|
|
200
|
+
pnpm verify:docs
|
|
201
|
+
pnpm build:llms
|
|
202
|
+
pnpm verify:llms
|
|
203
|
+
pnpm verify:exports
|
|
204
|
+
pnpm verify:dist
|
|
205
|
+
pnpm check:size
|
|
1158
206
|
```
|
|
1159
207
|
|
|
1160
|
-
|
|
1161
|
-
publishing:
|
|
1162
|
-
|
|
1163
|
-
1. verify tag matches `package.json#version`
|
|
1164
|
-
2. typecheck / lint / coverage (with 100/100/100/90 thresholds)
|
|
1165
|
-
3. build / verify exports / check bundle sizes / verify llms-full.txt
|
|
1166
|
-
4. `pnpm publish --provenance --access public` (npm supply-chain
|
|
1167
|
-
attestation is generated automatically)
|
|
208
|
+
Run `pnpm lint` before PRs. If docs change, regenerate `llms-full.txt`.
|
|
1168
209
|
|
|
1169
|
-
|
|
1170
|
-
ships. If you need to test the gate without publishing, trigger the
|
|
1171
|
-
workflow manually via `workflow_dispatch` with `dry-run: true`.
|
|
210
|
+
## Rules
|
|
1172
211
|
|
|
1173
|
-
|
|
212
|
+
- Preserve `step()` purity and replay determinism.
|
|
213
|
+
- Keep guards synchronous; route I/O through effects and events.
|
|
214
|
+
- Add tests for runtime commit ordering, sub-machine lifecycle, reset, dispose, and scheduler cancellation.
|
|
215
|
+
- Keep subpath imports tree-shakeable.
|
|
216
|
+
- Discuss public type inference changes before implementation.
|
|
1174
217
|
|
|
1175
218
|
## License
|
|
1176
219
|
|
|
1177
|
-
|
|
1178
|
-
license that covers this project.
|
|
220
|
+
MIT
|
|
1179
221
|
|
|
1180
222
|
---
|
|
1181
223
|
|