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