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_ZHTW.md CHANGED
@@ -1,548 +1,85 @@
1
1
  # aifsmjs
2
2
 
3
- [![npm version](https://img.shields.io/npm/v/aifsmjs.svg)](https://www.npmjs.com/package/aifsmjs)
4
- [![CI](https://github.com/islumina/aifsmjs/actions/workflows/ci.yml/badge.svg)](https://github.com/islumina/aifsmjs/actions/workflows/ci.yml)
5
- [![License](https://img.shields.io/badge/license-MIT-brightgreen.svg)](LICENSE)
6
- [![AI Generated](https://img.shields.io/badge/AI_Generated-Claude_Code_Opus_4.7_Max-blueviolet.svg)](https://www.anthropic.com/claude-code)
7
- [![English](https://img.shields.io/badge/lang-English-blue.svg)](README.md)
3
+ 小型 deterministic FSM 函式庫,適合可 replay 的 TypeScript/JavaScript state machine。Definition 是 plain data;guards/actions/effects 在 runtime 注入。
8
4
 
9
- > 一個小而嚴格的 FSM library,為任何需要可重現、可重播狀態流轉的 TypeScript/JS app 而生:把 lifecycle 寫成 pure `step()`,把 Chain-of-Responsibility 直覺收斂到 cross-cutting concerns(observe / persist / replay)── 而非 transition 主流程。
5
+ > **狀態:0.5.9 - 穩定 1.0 軌道核心。** Core FSM、guards、effects、inspect、replay、PBT helpers、scheduler、sub-machines 都已可用。
10
6
 
11
- 隸屬 [ai\*js micro-runtime 生態系](https://github.com/islumina) ─ 另見 [aibridgejs](https://github.com/islumina/aibridgejs)(cross-context RPC)與 [aiecsjs](https://github.com/islumina/aiecsjs)(ECS)。
12
-
13
- > **狀態:0.5.6。** 核心 FSM、階層式 sub-machine、guards 與 effects、scheduler、replay、inspect、PBT helpers 全部上線。發版歷史見 [CHANGELOG.md](CHANGELOG.md)。
14
-
15
- **主要受眾**:所有處理 stateful flow 的工程師 ── 多步驟表單、checkout 流程、auth flow、教學引導步驟、文件審批狀態機、互動 app 的 scene flow,以及瀏覽器遊戲的相同模式(PixiJS / Svelte 5 / 純 Canvas / WebGL)。Library 本身**環境中立**(pure core + adapter 邊界):browser、Node、Bun、Deno、Flutter WebView、Web Worker 全部都跑。Roadmap 段把遊戲特有的便利功能(tick hook、ECS bridge)保留為 opt-in subpath,不進 core surface。
16
-
17
- ---
18
-
19
- ## 為什麼有 aifsmjs
20
-
21
- 從 C# 帶著 CoR 慣性轉到 JS/TS 的人,通常會把 lifecycle 拆成可中止的 middleware chain,這在 FSM 領域會破壞 determinism 與 replay 能力。網頁遊戲對「可重放、可序列化、可在 worker 跑」的需求尤其重,aifsmjs 反其道:
22
-
23
- - **Lifecycle 是 pure function**:`step(def, snapshot, event, impl)` 一次完成 `guards → exit → action → entry`,順序固定、不可中止、不可注入。
24
- - **CoR 思維只用在橫切層**:`inspect/` 提供 Koa-style middleware pipeline,但只能觀察 snapshot 與發出事件,**不能改 transition 結果**。
25
- - **Definition 純資料**:guards / actions / effects 用 string ref 引用,runtime 才注入實作。可序列化、可在 Web Worker 之間傳遞、可存 DB。
26
- - **PBT first-class**:內建 `fast-check` `fc.commands` adapter 與 6 條 generic property tests,市場目前沒有同類產品做這件事。
27
-
28
- 對應到既有生態:思路接近 Robot3 的 functional composition + XState v5 的 `and/or/not` guard 組合子 + `@xstate/store` v3 的 `enq.effect()` 雙軌副作用,core 實測 ~2.8KB ESM gzipped(v0.1.0),每個 opt-in subpath 獨立可 tree-shake。
29
-
30
- ---
31
-
32
- ## Quick Start
7
+ ## 安裝
33
8
 
34
9
  ```bash
35
10
  pnpm add aifsmjs
36
11
  ```
37
12
 
38
- ```typescript
39
- import { setup, createRuntime, assign } from "aifsmjs";
13
+ ```ts
14
+ import { assign, createRuntime, setup } from "aifsmjs";
15
+ ```
40
16
 
17
+ ## 快速開始
18
+
19
+ ```ts
41
20
  type Ctx = { ticks: number };
42
21
  type Evt = { type: "NEXT" };
43
22
 
44
- // 1. Definition 是純資料;setup<Ctx, Evt>() 後 States 由 states keys 自動推導
45
23
  const trafficLight = setup<Ctx, Evt>().defineMachine({
46
24
  id: "trafficLight",
47
25
  initial: "red",
48
26
  context: { ticks: 0 },
49
27
  states: {
50
- red: { on: { NEXT: { target: "green", actions: ["bump"] } } },
51
- green: { on: { NEXT: { target: "yellow", actions: ["bump"] } } },
52
- yellow: { on: { NEXT: { target: "red", actions: ["bump"] } } },
28
+ red: { on: { NEXT: { target: "green", actions: ["bump"] } } },
29
+ green: { on: { NEXT: { target: "yellow", actions: ["bump"] } } },
30
+ yellow: { on: { NEXT: { target: "red", actions: ["bump"] } } },
53
31
  },
54
32
  });
55
33
 
56
- // 2. Implementations 是 runtime 才注入的函式
57
34
  const runtime = createRuntime(trafficLight, {
58
35
  actions: {
59
36
  bump: assign(({ context }) => ({ ticks: context.ticks + 1 })),
60
37
  },
61
38
  });
62
39
 
63
- // 3. 互動
64
40
  runtime.send({ type: "NEXT" });
65
- console.log(runtime.getSnapshot().value); // "green"
66
- console.log(runtime.getSnapshot().context); // { ticks: 1 }
41
+ console.log(runtime.getSnapshot().value); // "green"
67
42
  ```
68
43
 
69
- > 也可以用 `defineMachine<Ctx, Evt, States>({...})` 直接傳三個型別參數(escape hatch;當你需要對 union event types 完全顯式控制時)。一般情況下 `setup().defineMachine()` 更省事。
70
-
71
- ---
72
-
73
- ## Mental Model
74
-
75
- ```
76
- ┌──────────────────────┐ ┌──────────────────────┐
77
- │ MachineDefinition │ │ Implementations │
78
- │ (純資料、可序列化) │ + │ (guards/actions/ │
79
- │ • states │ │ effects fn map) │
80
- │ • on / target │ │ │
81
- │ • string refs │ │ │
82
- └──────────┬───────────┘ └──────────┬───────────┘
83
- │ │
84
- └──────────────┬───────────────┘
85
- ▼
86
- ┌────────────────────────┐
87
- │ step(def, snap, evt, │ ← pure function
88
- │ impl) │ 固定順序、不可中止
89
- └───────────┬────────────┘
90
- ▼
91
- ┌────────────────────────┐
92
- │ { snapshot, │
93
- │ effects: [...] } │ effects 由 caller
94
- └───────────┬────────────┘ 決定何時 dispatch
95
- ▼
96
- ┌────────────────────────┐
97
- │ createRuntime(...) │ ← 薄包裝
98
- │ state holder + send │
99
- └────────────────────────┘
100
- ```
101
-
102
- 三個分層完全解耦:你可以單獨拿 `step()` 做 replay、或單獨拿 `MachineDefinition` 做 visualization,runtime 只是把這兩個黏起來的便利層。
103
-
104
- ---
105
-
106
- ## Capabilities / Limitations
107
-
108
- | 會做(v1) | 不會做 |
109
- | --------------------------------------------------- | ------------------------------------------------- |
110
- | Flat states + transitions | Parallel state regions |
111
- | 透過 `state.sub` 的階層式 sugar(stable since 0.4.0) | Definition 內直接綁 closure(會無法序列化) |
112
- | Guards(sync only;inline async 在 `defineMachine` 時丟 `InvalidDefinitionError`;runtime 偵測到 thenable 回傳則丟 `AsyncGuardError`) | Async guards |
113
- | Actions(assign + enqueue effects) | 在 action 內呼叫 async API(請放到 effect) |
114
- | Fire-and-forget effects | Actor invocation / spawn |
115
- | Read-only inspect middleware | 可中止 transition 的 middleware |
116
- | `replay(initial, log, def, impl)` 純函式 | Time travel debugger(v2 再評估) |
117
- | `fast-check` `fc.commands` adapter | 自家 PBT framework |
118
- | String ref + runtime injection | 從 root 一次 import 全部 |
119
- | Tree-shake friendly subpath exports | ECS / Pixi bridges(opt-in subpath,不進 core) |
120
-
121
- ---
122
-
123
- ## Design Philosophy
124
-
125
- <details>
126
- <summary>為何 lifecycle 不能套 middleware(點開展開)</summary>
127
-
128
- UML statechart 與 SCXML 都規定 `exit → transition action → entry` 是 atomic sequence。一旦允許中間 handler 呼叫 `next()` 或丟錯中止,就會出現「進入新 state 但舊 state 沒 exit」的無效狀態,破壞:
129
-
130
- 1. **Determinism**:同一 event sequence 不再保證得到同一 snapshot。
131
- 2. **Replay**:event log 無法在獨立環境重現結果。
132
- 3. **PBT shrinking**:fast-check 的反例最小化前提是 deterministic state machine。
133
-
134
- XState v5 從 v4 的「actions 順序不穩」教訓走到 `predictableActionArguments` 不再需要存在(永遠 predictable),就是這個教訓。Spring StateMachine 把可中止的 Interceptor 標為「relatively deep internal feature」也是同一原因。
135
-
136
- 所以 aifsmjs 把 CoR 的 chain 思維拆兩半:
137
-
138
- | 場景 | 處理方式 |
139
- | ------------------- | --------------------------------------------------- |
140
- | Guard 鏈式判斷 | `and/or/not` 三個 higher-order combinators |
141
- | Action 多步驟順序 | `actions: [...]` array,按序執行、跑完為止 |
142
- | 跨橫切(log/persist)| `inspect/` middleware,僅讀,無中止能力 |
143
-
144
- </details>
145
-
146
- <details>
147
- <summary>為何 definition 是純資料</summary>
148
-
149
- 只要 definition 含 closure,就無法:
150
-
151
- - 透過 `JSON.stringify` 存 DB / localStorage
152
- - 透過 `postMessage` 傳到 Web Worker
153
- - 透過 visualizer 工具靜態分析 reachability
154
- - 透過 PBT adapter 自動產生 event arbitraries
155
-
156
- aifsmjs 走 XState v5 `setup().createMachine()` 雙階段路線:definition 用 string ref,`createRuntime()` 時才注入 fn map。Inline function 仍允許,但標為 escape hatch。
157
-
158
- </details>
159
-
160
- ---
161
-
162
- ## Core API
163
-
164
- ### `defineMachine<C, E, S>(def)`
165
-
166
- ```typescript
167
- function defineMachine<
168
- Ctx = Record<string, never>,
169
- Evt extends { type: string } = { type: string },
170
- States extends string = string,
171
- >(def: MachineConfig<Ctx, Evt, States>): MachineDef<Ctx, Evt, States>;
172
- ```
173
-
174
- 純資料 builder。會驗證 `initial` 在 `states` 集合內並回傳(正規化後的)def。
175
-
176
- `context` 為**選填**——無狀態機器可省略,會預設為 `{}`(型別參數預設為 `Record<string, never>`)。原本就有傳 `context` 的定義完全不受影響。
177
-
178
- ```typescript
179
- // 不需要 context——預設為 {}
180
- const toggle = defineMachine({
181
- id: "toggle",
182
- initial: "off",
183
- states: {
184
- off: { on: { TOGGLE: "on" } }, // 字串簡寫,見下
185
- on: { on: { TOGGLE: "off" } },
186
- },
187
- });
188
- ```
189
-
190
- **字串簡寫 transition。** transition 的值可以是完整物件形式,也可以是單純的目標 state 字串(仿 XState)。字串會在進入任何 guard/action 處理前被正規化成 `{ target }`——它不帶 guard 或 actions:
191
-
192
- ```typescript
193
- on: { NEXT: "green" } // 等同 { target: "green" }
194
- on: { NEXT: [{ target: "a", guard: "g" }, "b"] } // 可與物件形式混用
195
- ```
196
-
197
- ### `createRuntime(def, impl, opts?)`
198
-
199
- ```typescript
200
- function createRuntime<C, E, S>(
201
- def: MachineDef<C, E, S>,
202
- impl: Implementations<C, E>,
203
- opts?: { middleware?: readonly Middleware<C, E, S>[] },
204
- ): Runtime<C, E, S>;
205
-
206
- interface Runtime<C, E, S> {
207
- getSnapshot(): Snapshot<C, S>;
208
- send(event: E): Snapshot<C, S>;
209
- subscribe(listener: (snap: Snapshot<C, S>) => void): () => void;
210
- reset(event?: E): Snapshot<C, S>;
211
- dispose(): void;
212
- readonly disposed: boolean;
213
- readonly signal: AbortSignal;
214
- }
215
- ```
216
-
217
- 薄包裝。內部呼叫 `step()` 並 dispatch effects。`dispose()` 會 abort 內建 `AbortController`、清空 listeners,並讓後續的 `send()` / `reset()` 丟 `RuntimeDisposedError`。`reset()` 把 snapshot 拉回 `initialSnapshot(def)`、觸發 listeners,但**不會跑 entry actions**(reset 是「整個 runtime 回出生點」,不是 transition)。
218
-
219
- `runtime.signal` 是這個 runtime 的生命週期 signal,會在 dispose 時 abort 一次。被傳進每個 `EffectHandler` 的 `args.signal`;外部整合(React unmount、game scene teardown)也可以 `runtime.signal.addEventListener("abort", ...)` 串自己的收尾。
220
-
221
- ### `step(def, snapshot, event, impl)`
222
-
223
- ```typescript
224
- function step<C, E, S>(
225
- def: MachineDef<C, E, S>,
226
- snapshot: Snapshot<C, S>,
227
- event: E,
228
- impl: Implementations<C, E>,
229
- ): { snapshot: Snapshot<C, S>; effects: readonly Effect[] };
230
- ```
231
-
232
- **Pure function**。整個 library 的 invariant 守護者。不會 dispatch effects、不會 mutate snapshot。Guard 沒過或 event 沒對應 transition 就回原 snapshot(不改變)。誤用(`UnknownGuardError`、`UnknownActionError`、`AsyncGuardError`)才會丟錯,讓配接錯誤在開發期間立即浮現、不被靜默略過。
233
-
234
- ### `assign(updater)`
235
-
236
- ```typescript
237
- function assign<C, E>(
238
- updater: (args: { context: C; event: E }) => Partial<C>,
239
- ): Action<C, E>;
240
- ```
241
-
242
- 純 context 更新 helper。回傳新 context(partial merge),不含副作用。
243
-
244
- ---
245
-
246
- ## Opt-in Modules
247
-
248
- 每個 opt-in 都是獨立 subpath,不引入則 tree-shake 完全清除。
249
-
250
- ### `aifsmjs/guards` — Guard combinators
251
-
252
- ```typescript
253
- import { and, or, not, stateIn } from "aifsmjs/guards";
254
-
255
- const canCheckout = and([
256
- "isAuthenticated",
257
- or(["isAdmin", "isOwner"]),
258
- not("isBanned"),
259
- ]);
260
- ```
261
-
262
- `and/or/not` 對 sync guard 做短路求值。`stateIn(...states)` 是常用 sugar:「目前 state 在這群之內就通過」。
263
-
264
- ### `aifsmjs/effects` — Fire-and-forget effects
265
-
266
- ```typescript
267
- import { type Action } from "aifsmjs";
268
-
269
- const checkout: Action<Ctx, Evt> = ({ context, enqueue }) => {
270
- enqueue.effect("trackAnalytics", { event: "checkout", ctx: context });
271
- // 回傳值代表新 context(不回傳則沿用舊 context)
272
- };
273
- ```
274
-
275
- `enqueue.effect(type, payload)` 把副作用宣告排隊,由 `step()` 收集後回傳給 caller。Runtime 預設在 transition 完成後 dispatch;replay 模式下可關掉 dispatch,只做 snapshot fold。
276
-
277
- ### `aifsmjs/inspect` — Read-only middleware
278
-
279
- ```typescript
280
- import { createRuntime } from "aifsmjs";
281
- import { logger, persist } from "aifsmjs/inspect";
282
-
283
- const runtime = createRuntime(def, impl, {
284
- middleware: [
285
- logger(console.log),
286
- persist({ key: "machine-state", storage: localStorage }),
287
- ],
288
- });
289
- ```
290
-
291
- Koa-style `(ctx, next) => void` pipeline。`ctx` 是 `{ prev, next, event, effects, changed }`,全部 deep-frozen。**不能中止 transition**——`next()` 必呼叫,回傳值無語意。
292
-
293
- 兩個須留意的邊界(1.x 內穩定,詳見 [STABILITY.md](STABILITY.md)):跳過 `next()` **不會**被擋下,且該 event 之後的 middleware(含 `recorder` / `persist` sink)全部被靜默丟棄;middleware 同步 throw(例如 `persist` 遇非可序列化 context)會傳回 caller,但 snapshot **已 commit 卻未通知**——`notify()` 與 `on('transition')` 被跳過。請**勿**在 pipeline 內呼叫 `runtime.send()`:inner event 會先跑完整條 pipeline 才輪到 outer frame,導致 `recorder` log 順序錯置、`replay()` 發散。
294
-
295
- ```typescript
296
- import { replay } from "aifsmjs/replay";
297
-
298
- const finalSnap = replay(initialSnapshot, eventLog, def, impl);
299
- // 等價於 eventLog.reduce((s, e) => step(def, s, e, impl).snapshot, initial)
300
- ```
301
-
302
- 不會 dispatch effects。用於 PBT、time-travel debug、incident reproduction。
303
-
304
- ### `aifsmjs/pbt` — fast-check adapter
305
-
306
- > **需另裝 peer**:`pnpm add -D fast-check`(^3.20.0)。aifsmjs 把 fast-check 列為 optional peer dependency,使用 pbt 模組才需安裝。
307
-
308
- ```typescript
309
- import fc from "fast-check";
310
- import { createRuntime } from "aifsmjs";
311
- import { commandsFromMachine, initialModel, properties } from "aifsmjs/pbt";
312
-
313
- // 用任一內建 generic property,或 assertAll 一次跑全部:
314
- properties.replayEqualsFold(def, impl, {
315
- NEXT: fc.constant({ type: "NEXT" as const }),
316
- });
317
-
318
- // 或用 commandsFromMachine 自訂 property:
319
- fc.assert(
320
- fc.property(
321
- commandsFromMachine(def, impl, {
322
- NEXT: fc.constant({ type: "NEXT" as const }),
323
- }),
324
- (cmds) => {
325
- const real = createRuntime(def, impl, { dispatchEffects: false });
326
- fc.modelRun(() => ({ model: initialModel(def), real }), cmds);
327
- return true;
328
- },
329
- ),
330
- );
331
- ```
332
-
333
- `properties.*` 提供 6 條 generic property(見 [Testing Strategy](#testing-strategy))。fast-check 是 `peerDependenciesMeta.optional`,不裝就不用付。
334
-
335
- ### `aifsmjs/timer` — Cancellable delayed callbacks
336
-
337
- ```typescript
338
- import { after, createScheduler } from "aifsmjs/timer";
339
-
340
- // One-shot
341
- const handle = after(5000, () => runtime.send({ type: "TIMEOUT" }));
342
- handle.cancel(); // 還沒燒到的話,取消
343
-
344
- // 與 AbortSignal 整合
345
- const ac = new AbortController();
346
- after(5000, () => runtime.send({ type: "TIMEOUT" }), { signal: ac.signal });
347
- ac.abort(); // 同樣取消
348
-
349
- // Scheduler:把一群 timers 綁在一起,destroy 時 cancelAll
350
- const sched = createScheduler();
351
- sched.after(1000, () => {});
352
- sched.after(2000, () => {});
353
- sched.cancelAll();
354
- ```
355
-
356
- - 純包 `setTimeout` / `clearTimeout`,可注入測試替身(vitest fake timers 已驗證)
357
- - AbortSignal listener 用 `{ once: true }` 註冊,避免 leak
358
- - 與 FSM 本體解耦:你自己決定何時把 timer 燒出的事件 `runtime.send(...)`
359
-
360
- ---
361
-
362
- ## Lifecycle Invariants
363
-
364
- `step()` 的固定順序(永遠如此,無法改變):
365
-
366
- ```
367
- 1. resolveTransitions(def, snapshot.value, event)
368
- → 拿到該 event 在此 state 上的候選 transitions
369
- 2. evaluate guard on each candidate, in declaration order
370
- → 第一個通過的 transition 被選中;都沒通過則回原 snapshot
371
- 3. exit actions of old state (目前 v1 為單層,無階層)
372
- 4. transition.actions[],按宣告順序循序執行
373
- → 每個 action 可呼叫 enqueue.effect()
374
- → 每個 action 的回傳 partial ctx 會 merge 到 current ctx
375
- 5. entry actions of new state
376
- 6. 回傳 { snapshot, effects } — caller 決定何時 dispatch effects
377
- ```
378
-
379
- **契約**:
380
-
381
- 保證:
382
-
383
- - Guards 永遠 sync、永遠 pure(不 mutate ctx)
384
- - Actions 永遠跑完(無中止機制)
385
- - Effects 是宣告(type + payload),不是 callback —— 序列化友善
386
- - Snapshot 不可變;dev mode deep-freeze 偵錯,prod shallow 省效能
387
-
388
- 不做:
389
-
390
- - async lifecycle hook
391
- - Inspect middleware 影響 transition 結果
392
-
393
- ### Sub-machine lifecycle(stable since 0.4.0)
394
-
395
- 當一個 state 宣告 `sub` 時,每次 transition 的執行順序為:
396
-
397
- 1. Parent `step()` 執行:`exit actions → transition.actions → entry actions`。
398
- 2. 舊 child(若有)`dispose()` — 同步執行;例外會包成 `SubMachineError(phase: "dispose")`。
399
- 3. 新 child(若 next state 有 `sub`)實例化 — 例外會包成 `SubMachineError(phase: "init")`。
400
- 4. Parent snapshot 確認提交。
401
- 5. Middleware pipeline 執行。
402
- 6. Effects 派發。
403
- 7. `'transition'` 事件發給 `on()` / `onTransition()` 的訂閱者。
404
-
405
- 若步驟 2 或 3 丟例外,parent snapshot **不會**提交(回滾至 `prev`);middleware、effects、`'transition'` 都不會執行。
406
-
407
- `runtime.dispose()` 透過 `controller.signal` 的 abort listener 以及顯式的 `child.dispose()` 呼叫,將 dispose 行為串聯到 child。Cascade 會吞掉 child 的例外,以遵守永不丟錯的 dispose 契約。
408
-
409
- ---
410
-
411
- ## Lifecycle Protocol
412
-
413
- aifsmjs 是「極簡 AI 工具鏈」家族的第一個套件,這條 lifecycle protocol 會被未來的 `aitaskjs / aibridgejs / aiaudiojs` 等共用:
414
-
415
- | 動詞 | aifsmjs 對應 | 語意 |
416
- |---|---|---|
417
- | `createX()` | `createRuntime` / `createScheduler` / `defineMachine` / `setup` | 工廠 fn,回傳值即「實例」 |
418
- | `dispose()` | `runtime.dispose()` / `scheduler.cancelAll()` | 釋放資源;idempotent;post-dispose API 拋已知 error |
419
- | `reset()` | `runtime.reset()` | 把狀態歸零,不釋放資源 |
420
- | `on/off` | `runtime.subscribe(fn)` 回傳 unsubscribe | 訂閱模式;明寫 unsubscribe |
421
- | `AbortSignal` | `runtime.signal` / `after(_, _, { signal })` | 所有 long-running / async 任務的取消通道 |
422
- | Pure core | `step()` | 不碰 I/O、可序列化、可 replay |
423
- | Error 明寫 | `RuntimeDisposedError` / `UnknownGuardError` / `UnknownActionError` / `InvalidDefinitionError` | named error class,不靠 throw string |
424
-
425
- 未來其他 ai\*js 套件遇到「該不該加 dispose?」「signal 怎麼接?」這類問題,以此表為 baseline。
426
-
427
- ---
428
-
429
- ## 設計取捨:與常見模式的差異
430
-
431
- aifsmjs 在幾個常見議題上做了刻意取捨,跟主流 FSM library 寫法不同。把理由寫在這裡,從 XState、statecharts、或一般 event-emitter library 過來的讀者不必跳進 source 就能掌握。
432
-
433
- - **`send()` 是同步、回傳 `Snapshot` 而非 `Promise<Snapshot>`**。Pure `step()` 設計上就是 sync,`replay(initial, log)` 與 PBT shrinking 才能單純。Effect handler 仍可 async;runtime 觸發後忽略結果,async rejection 走 `'error'` event channel。要 await effect 完成的人可自己包一層 `Promise.all`。
434
- - **Guards / reducers 只能 sync**。非確定性 guard 會破壞 PBT determinism property (#1)。把 async 改寫成 event:先送 `FETCH_REQUEST`,handler 完成後送 `FETCH_DONE`,payload 帶結果。
435
- - **Effects 是描述子,不是 inline callback**。Action 透過 `enqueue.effect(type, payload?)` 排隊;runtime 收集後 dispatcher 才執行 user handler。好處:machine definition 可序列化(沒 inline fn 時 JSON round-trip)、`replay()` 可把 event log 摺成同樣 snapshot、`inspect/persist` middleware 抓得到 effects 做 audit log。
436
- - **兩種 factory 並存**。`setup<Ctx, Evt>().defineMachine(...)` 是型別友善版(States 從 `keyof states` 推導)。`createMachine(def, impl, opts?)` 是來自 ai*js 生態 spec 的 single-factory 捷徑。顯式 `defineMachine<Ctx, Evt, States>(def)` 仍保留作完全顯式控制。看 call site 哪個讀起來順手就用哪個。
437
- - **transition 支援字串簡寫**。`on: { EVENT: "targetState" }` 是 `on: { EVENT: { target: "targetState" } }` 的語法糖,在 resolver 於任何 guard/action 處理前正規化。簡寫不帶 guard 或 actions;需要時改用物件形式。它也能在陣列形式中混用,所以 guard-fallthrough 清單可以同時放 `{ target, guard }` 物件與單純的目標字串。完整物件形式不變——此為純加法。
438
- - **`context` 為選填**。無狀態機器可省略,預設為 `{}`(`Ctx` 預設為 `Record<string, never>`)。原本就傳 `context` 的定義型別推導與行為完全相同。
439
- - **`subscribe(listener)` 與 `on(type, fn, { signal, once })` 並存**。Typed `on()` 對齊平台 `EventTarget` 語意(signal + once),emit `'transition'`、`'error'`、`'dispose'`。原本的 `subscribe()` 保留 React `useSyncExternalStore` shape,可直接傳。兩者不互斥。
440
-
441
- ---
442
-
443
- ## AI-Agent Reading Guide
444
-
445
- > 此區塊為 LLM 與 code-search agent 量身設計,把不變量、型別、誤用模式集中於此。
446
-
447
- ### Serializable fields
448
-
449
- 下列欄位皆為 plain data,可 `JSON.stringify` round-trip:
450
-
451
- - `MachineDef` 全結構(前提:未用 inline fn)
452
- - `Snapshot` 全結構(前提:`context` 為 plain data)
453
- - `Effect` 全結構(`{ type: string; payload?: unknown }`)
454
-
455
- 下列**不可序列化**,會破壞 PBT/replay:
456
-
457
- - `Implementations` 內所有 fn
458
- - Middleware closure
459
-
460
- ### Invariants(請勿違反)
461
-
462
- 1. `step()` 是 pure:對同 `(def, snapshot, event, impl)` 必回相同 `{ snapshot, effects }`。
463
- 2. Snapshot frozen:dev mode 違反 freeze 立即拋錯。
464
- 3. Guards never mutate context:違反者 PBT property #2 會抓到。
465
- 4. Effects 永遠 fire-and-forget:runtime 不等 effect 完成才更新 snapshot。
466
- 5. `dispose()` idempotent;post-dispose 呼叫 `send()` / `reset()` 拋 `RuntimeDisposedError`。
467
- 6. `runtime.signal.aborted` 在 dispose 後永遠為 `true`;effect handler 拿到的 signal 即此。
468
- 7. `reset()` 只重置 snapshot 與通知 listener,**不跑 entry actions**;listeners 只在 `prev.value !== initial.value` 時被通知(與 `send()` 對齊)。Middleware 永遠看得到該次呼叫(含 `changed: false`)。
469
- 8. `MiddlewareContext.event` 是 `Evt | ResetEvent`;無事件的 `reset()` 會塞入 `RESET_EVENT_TYPE` (`"@@aifsmjs/RESET"`) 哨兵。
470
-
471
- ### Common misuses
472
-
473
- | 反模式 | 正確寫法 |
474
- | --------------------------------------------------- | ------------------------------------------------------- |
475
- | 在 guard 內呼叫 `fetch()` 等 async API | 把 async 改寫成 event:先送 `FETCH_REQUEST`,handler 完成後送 `FETCH_DONE` |
476
- | 在 action 內 `setTimeout` 後 mutate context | 改用 `enqueue.effect("delayedThing", ...)` |
477
- | 用 middleware 攔截並改變 next state | 不可能;middleware 為 read-only。改寫成 guard。 |
478
- | Definition 內直接寫 inline fn(可行但破壞序列化) | 拆出 string ref,於 `createRuntime` 注入 |
479
-
480
- ### Machine-readable schema
481
-
482
- `MachineDef` 的 JSON schema 之後將發佈於 `dist/schema/machine.schema.json`。v1 階段尚未提供,但型別定義集中在 [src/fsm/types.ts](src/fsm/types.ts),agent 可從 TS 型別直接推導。
483
-
484
- ---
485
-
486
- ## Testing Strategy
487
-
488
- 例子驅動為主,PBT 補強。借鑑 jssm 的教訓:「3000+ tests / 100% coverage」中只有 < 12% coverage 來自 stochastic tests,其餘來自 example specs。
489
-
490
- - **Example tests**(vitest):對每個 src module 寫 happy path + 邊界 + error message 三類。
491
- - **PBT smoke**:每條 generic property 跑 50 runs,作為 invariant guard,不追求 coverage。
492
- - **CI 強制門檻**:`@vitest/coverage-v8` 設 **100% lines / 100% functions / ≥95% statements / ≥90% branches**。少數 defensive invariant-guard 分支(例如 runtime determinism mismatch)標 `/* v8 ignore */` 並寫明原因。
493
- - **Size budget**:`scripts/check-size.mjs` 在 CI 檢查每個 subpath gzip 大小,量測單位是 entry 的 transitive closure(entry + 共享 chunk——build 採 code-splitting 以維持 error class 跨 subpath identity),超過預算(core ≤6.5 KB、pbt ≤8.5 KB、replay ≤3.3 KB、effects ≤1.7 KB、guards ≤1.5 KB、timer ≤1.2 KB、inspect ≤1 KB)即 fail。
494
-
495
- ### 內建的 6 條 generic properties
496
-
497
- | # | Property | 一句話 |
498
- | --- | --------------------------------- | --------------------------------------------------- |
499
- | 1 | snapshotAlwaysFrozen | 任意 event 序列後,snapshot 仍 frozen |
500
- | 2 | unknownEventNoOp | 未宣告 event 不會改變 snapshot |
501
- | 3 | reachableStatesSubsetDeclared | 跑到的所有 state 必在 `def.states` 集合內 |
502
- | 4 | replayEqualsFold | `replay(init, log)` 等價於 `events.reduce(step)` |
503
- | 5 | guardsFalseNoTransition | 所有 guards 失敗時 state 不變 |
504
- | 6 | assignDoesNotMutate | `assign` 不修改前一個 ctx |
505
-
506
- ---
507
-
508
- ## Comparison
44
+ 一般情況請用 `setup<Ctx, Evt>().defineMachine()` 讓 states 自動推斷;需要完整 generic 控制時再用裸 `defineMachine<Ctx, Evt, States>()`。
509
45
 
510
- | | aifsmjs | XState v5 | Robot3 | @xstate/store | Zag.js |
511
- | -------------------------- | -------------- | ----------------- | ----------------- | ----------------- | ----------------- |
512
- | Core size (gzip) | ~2.8KB | ~15KB | ~1KB | < 1KB | per-component |
513
- | Hierarchical states | Sugar (0.3.0) | Yes | No | N/A | Yes |
514
- | Async invoke / actor | No | Yes | No | N/A | No |
515
- | Guard combinators | and/or/not | and/or/not | No | N/A | No |
516
- | Effects 雙軌 | enqueue | enqueueActions | reduce/action | enq.effect() | array of names |
517
- | Inspect / observe | read-only | inspect API | No | 社群提案中 | watch ctx |
518
- | Serializable definition | Yes | Yes | Partial | Partial | Yes |
519
- | fast-check adapter | built-in | No | No | No | No |
520
- | Tree-shake subpath imports | Yes | Partial | Yes | Yes | Yes |
46
+ ## Public Surface
521
47
 
522
- ---
48
+ | Import | 用途 |
49
+ | --- | --- |
50
+ | `aifsmjs` | `setup`、`defineMachine`、`createRuntime`、`createMachine`、`step`、`assign`、snapshots、runtime/errors/types。 |
51
+ | `aifsmjs/guards` | `and`、`or`、`not`、`stateIn`。Guard 必須同步。 |
52
+ | `aifsmjs/effects` | `enqueue.effect()` descriptor 與 `runEffects()`。 |
53
+ | `aifsmjs/inspect` | Read-only middleware helpers:`logger`、`persist`、`recorder`。 |
54
+ | `aifsmjs/replay` | 純 event-log replay。 |
55
+ | `aifsmjs/pbt` | fast-check property helpers。 |
56
+ | `aifsmjs/timer` | `after()` 與 `createScheduler()`。 |
523
57
 
524
- ## Roadmap
58
+ ## Lifecycle Rules
525
59
 
526
- | 版本 | 範圍 |
527
- | ---- | ----------------------------------------------------------------- |
528
- | v0.1 | core + guards + effects + inspect + replay + pbt(本次發佈) |
529
- | v0.2 | Async-guard 偵測、coverage 調整、llms-full.txt verify gate |
530
- | v0.3 | 透過 `state.sub` 的階層式 sugar(experimental) |
531
- | v0.4 | sub-machine API 升 stable;降依賴 cycle |
532
- | v0.5 | `aifsmjs-bridge-bitecs` / `aifsmjs-bridge-pixi`(獨立 sub-package) |
533
- | v1.0 | API freeze 與 stability guarantee |
60
+ - `step(def, snapshot, event, impl)` 是 pure function,回傳 `{ snapshot, effects, changed }`。
61
+ - `createRuntime()` 持有 mutable runtime state,commit 後 dispatch effects,並送出 transition/error/dispose events。
62
+ - Guards 與 reducers 必須同步。Thenable guards 會丟 `AsyncGuardError`。
63
+ - Effects 是 fire-and-forget descriptors。Async rejection 會送到 runtime `"error"` channel。
64
+ - `reset()` 會回到 initial snapshot 並通知 listeners,但不執行 entry actions。
65
+ - `dispose()` 可重複呼叫;dispose 後 `send()`/`reset()` 會丟 `RuntimeDisposedError`。
534
66
 
535
- **不在 v1 範圍內**:
67
+ ## 注意事項
536
68
 
537
- - **Parallel state regions**(v1 不做)
538
- - **Actor invocation / spawn**(v1 不做)
539
- - **Tick / game-loop hook**(v1 不做)
540
- - **ECS / Pixi bridges**(v1 不做)
69
+ - Middleware 與同步 effect throw 發生在 snapshot commit 之後;可能留下已 commit snapshot 但後續通知中斷。
70
+ - Sub-machine replacement 在 init failure 時可 rollback,但 dispose failure 時舊 child 已被 teardown。
71
+ - 若外部自行 dispose child,`subRuntime()` 可能回傳 disposed handle;只有 parent 離開並重新進入 sub state 才會重建。
72
+ - `setup().defineMachine()` 使用 `NoInfer`,讓 states 從 `keyof states` 推斷;請保留 exact optional property 的回歸測試。
73
+ - 不要在 guards 或 actions 內做 async I/O;請從 effects 發事件回來。
541
74
 
542
- **未來候選**:`historyState` — re-entry 時自動恢復上一次的 sub-state。0.3.0 的暫代方案:透過 `onTransition` snapshot sub-runtime 的 value,之後手動還原。
75
+ ## AI Context
543
76
 
544
- ---
77
+ - 短索引:[`llms.txt`](llms.txt)
78
+ - 完整生成內容:[`llms-full.txt`](llms-full.txt)
79
+ - 穩定度契約:[`STABILITY.md`](STABILITY.md)
80
+ - 目前 review backlog:[`REVIEW.md`](REVIEW.md)
81
+ - 版本紀錄:[`CHANGELOG.md`](CHANGELOG.md)
545
82
 
546
83
  ## License
547
84
 
548
- [MIT](LICENSE)
85
+ MIT
@@ -11,7 +11,7 @@ var InvalidDefinitionError = class extends Error {
11
11
  this.name = "InvalidDefinitionError";
12
12
  }
13
13
  };
14
- function validateDefinition(def) {
14
+ function validateDefinition(def, seen = /* @__PURE__ */ new WeakSet()) {
15
15
  if (!def.id || typeof def.id !== "string") {
16
16
  throw new InvalidDefinitionError("definition must have a non-empty string `id`");
17
17
  }
@@ -39,6 +39,10 @@ function validateDefinition(def) {
39
39
  `state "${stateName}".sub is not a valid sub-machine definition (missing states or initial)`
40
40
  );
41
41
  }
42
+ if (!seen.has(sub)) {
43
+ seen.add(sub);
44
+ validateDefinition(sub, seen);
45
+ }
42
46
  }
43
47
  if (!stateDef.on) continue;
44
48
  for (const [evtType, entry] of Object.entries(stateDef.on)) {
@@ -334,10 +338,14 @@ function createRuntime(def, impl, opts = {}) {
334
338
  }
335
339
  controller.abort();
336
340
  listeners.clear();
337
- emit("dispose", void 0);
338
- for (const set of Object.values(eventListeners)) set.clear();
339
- for (const cleanup of externalAbortCleanups) cleanup();
340
- externalAbortCleanups.clear();
341
+ try {
342
+ emit("dispose", void 0);
343
+ } catch {
344
+ } finally {
345
+ for (const set of Object.values(eventListeners)) set.clear();
346
+ for (const cleanup of externalAbortCleanups) cleanup();
347
+ externalAbortCleanups.clear();
348
+ }
341
349
  }
342
350
  const runtime = {
343
351
  getSnapshot: () => snapshot,
@@ -370,5 +378,5 @@ function createRuntime(def, impl, opts = {}) {
370
378
  }
371
379
 
372
380
  export { InvalidDefinitionError, RESET_EVENT_TYPE, RuntimeDisposedError, SubMachineError, createMachine, createRuntime, defineMachine, initialSnapshot, setup };
373
- //# sourceMappingURL=chunk-VZCSHTOI.js.map
374
- //# sourceMappingURL=chunk-VZCSHTOI.js.map
381
+ //# sourceMappingURL=chunk-LG2AH5X6.js.map
382
+ //# sourceMappingURL=chunk-LG2AH5X6.js.map