aifsmjs 0.2.0 → 0.3.0
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 +43 -10
- package/README_ZHTW.md +36 -8
- package/STABILITY.md +77 -0
- package/dist/effects/index.d.cts +1 -1
- package/dist/effects/index.d.ts +1 -1
- package/dist/guards/index.d.cts +1 -1
- package/dist/guards/index.d.ts +1 -1
- package/dist/index.cjs +139 -23
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +21 -3
- package/dist/index.d.ts +21 -3
- package/dist/index.js +139 -24
- package/dist/index.js.map +1 -1
- package/dist/inspect/index.d.cts +1 -1
- package/dist/inspect/index.d.ts +1 -1
- package/dist/pbt/index.cjs +129 -23
- package/dist/pbt/index.cjs.map +1 -1
- package/dist/pbt/index.d.cts +1 -1
- package/dist/pbt/index.d.ts +1 -1
- package/dist/pbt/index.js +129 -23
- package/dist/pbt/index.js.map +1 -1
- package/dist/replay/index.d.cts +1 -1
- package/dist/replay/index.d.ts +1 -1
- package/dist/{types-BLwKAHnW.d.cts → types-CGKk6Rur.d.cts} +70 -1
- package/dist/{types-BLwKAHnW.d.ts → types-CGKk6Rur.d.ts} +70 -1
- package/llms-full.txt +110 -10
- package/package.json +6 -4
package/README.md
CHANGED
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
|
|
9
9
|
> A small, strict FSM library for any TypeScript/JS app that needs deterministic, replayable state transitions. Lifecycle is a pure `step()` function. Chain-of-Responsibility intuition is reserved for cross-cutting concerns (observe / persist / replay), never for the transition core.
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
Part of the [ai\*js micro-runtime ecosystem](https://github.com/yshengliao) — see also [aibridgejs](https://github.com/yshengliao/aibridgejs) (cross-context RPC) and [aiecsjs](https://github.com/yshengliao/aiecsjs) (ECS).
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
**Primary audience**: developers building stateful flows — multi-step forms, checkout funnels, auth flows, tutorial sequences, document-status workflows, scene flow in interactive apps, and the same patterns in browser-based games (PixiJS / Svelte 5 / plain Canvas / WebGL). The core is **environment-neutral** (pure function + adapter boundary): browser, Node, Bun, Deno, Flutter WebView, and Web Workers all work. The Roadmap section keeps gaming-specific niceties (tick hook, ECS bridge) as opt-in subpaths, not core surface.
|
|
14
14
|
|
|
15
15
|
---
|
|
16
16
|
|
|
@@ -106,15 +106,16 @@ The three layers are fully decoupled: take `step()` alone for replay, take `Mach
|
|
|
106
106
|
|
|
107
107
|
| Will do (v1) | Won't do |
|
|
108
108
|
| --------------------------------------------------- | ------------------------------------------------- |
|
|
109
|
-
| Flat states + transitions |
|
|
109
|
+
| Flat states + transitions | Parallel state regions |
|
|
110
|
+
| Hierarchical sugar via `state.sub` (experimental, since 0.3.0) | Closures embedded in definition (breaks serialize) |
|
|
110
111
|
| Guards (sync only; inline async throws `InvalidDefinitionError` at `defineMachine`; runtime throws `AsyncGuardError` on thenable return) | Async guards |
|
|
111
112
|
| Actions (assign + enqueue effects) | Async API inside an action (use an effect) |
|
|
112
113
|
| Fire-and-forget effects | Actor invocation / spawn |
|
|
113
114
|
| Read-only inspect middleware | Cancellable transition middleware |
|
|
114
115
|
| `replay(initial, log, def, impl)` pure function | Time-travel debugger (v2 candidate) |
|
|
115
116
|
| `fast-check` `fc.commands` adapter | Custom PBT framework |
|
|
116
|
-
| String ref + runtime injection |
|
|
117
|
-
| Tree-shake friendly subpath exports |
|
|
117
|
+
| String ref + runtime injection | Single root import for everything |
|
|
118
|
+
| Tree-shake friendly subpath exports | ECS / Pixi bridges (opt-in subpath, not core) |
|
|
118
119
|
|
|
119
120
|
---
|
|
120
121
|
|
|
@@ -356,6 +357,27 @@ Non-goals:
|
|
|
356
357
|
- No async lifecycle hook
|
|
357
358
|
- Inspect middleware cannot alter the transition outcome
|
|
358
359
|
|
|
360
|
+
### Sub-machine lifecycle (since 0.3.0, experimental)
|
|
361
|
+
|
|
362
|
+
When a state declares `sub`, the per-transition ordering is:
|
|
363
|
+
|
|
364
|
+
1. Parent `step()` runs: `exit actions → transition.actions → entry actions`.
|
|
365
|
+
2. Old child (if any) `dispose()` — synchronous; exceptions become
|
|
366
|
+
`SubMachineError(phase: "dispose")`.
|
|
367
|
+
3. New child (if next state has `sub`) instantiation — exceptions become
|
|
368
|
+
`SubMachineError(phase: "init")`.
|
|
369
|
+
4. Parent snapshot commits.
|
|
370
|
+
5. Middleware pipeline runs.
|
|
371
|
+
6. Effects dispatch.
|
|
372
|
+
7. `'transition'` event emits to `on()` / `onTransition()` subscribers.
|
|
373
|
+
|
|
374
|
+
If step 2 or 3 throws, the parent snapshot is **not** committed (rollback
|
|
375
|
+
to `prev`); no middleware / effects / `'transition'` runs.
|
|
376
|
+
|
|
377
|
+
`runtime.dispose()` cascades to the child via `controller.signal`'s abort
|
|
378
|
+
listener and an explicit `child.dispose()` call. Cascade swallows child
|
|
379
|
+
exceptions to honour the never-throws dispose contract.
|
|
380
|
+
|
|
359
381
|
---
|
|
360
382
|
|
|
361
383
|
## Lifecycle Protocol
|
|
@@ -438,7 +460,7 @@ Example-first, PBT-augmented. Lesson from jssm: "3000+ tests / 100% coverage" tu
|
|
|
438
460
|
- **Example tests** (vitest): for every src module, write happy path + edge + error-message triplets.
|
|
439
461
|
- **PBT smoke**: each generic property runs 50 iterations as an invariant guard, not as a coverage source.
|
|
440
462
|
- **CI-enforced thresholds**: `@vitest/coverage-v8` is wired to **100% statements / 100% lines / 100% functions / ≥90% branches**. The few defensive invariant-guard branches (e.g. runtime determinism mismatch) carry `/* v8 ignore */` annotations with rationale.
|
|
441
|
-
- **Size budget**: `scripts/check-size.mjs` enforces per-subpath gzip caps in CI — core ≤3
|
|
463
|
+
- **Size budget**: `scripts/check-size.mjs` enforces per-subpath gzip caps in CI — core ≤4.7 KB (raised in 0.3.0 for sub-machine sugar), replay ≤1.8 KB, pbt ≤5.5 KB (raised in 0.3.0 because `pbt` transitively imports `createRuntime`), others ≤1 KB. Exceeding any cap fails the build.
|
|
442
464
|
|
|
443
465
|
### The 6 built-in generic properties
|
|
444
466
|
|
|
@@ -458,7 +480,7 @@ Example-first, PBT-augmented. Lesson from jssm: "3000+ tests / 100% coverage" tu
|
|
|
458
480
|
| | aifsmjs | XState v5 | Robot3 | @xstate/store | Zag.js |
|
|
459
481
|
| -------------------------- | -------------- | ----------------- | ----------------- | ----------------- | ----------------- |
|
|
460
482
|
| Core size (gzip) | ~2.8KB | ~15KB | ~1KB | < 1KB | per-component |
|
|
461
|
-
| Hierarchical states |
|
|
483
|
+
| Hierarchical states | Sugar (0.3.0) | Yes | No | N/A | Yes |
|
|
462
484
|
| Async invoke / actor | No | Yes | No | N/A | No |
|
|
463
485
|
| Guard combinators | and/or/not | and/or/not | No | N/A | No |
|
|
464
486
|
| Effects dual-track | enqueue | enqueueActions | reduce/action | enq.effect() | array of names |
|
|
@@ -474,12 +496,23 @@ Example-first, PBT-augmented. Lesson from jssm: "3000+ tests / 100% coverage" tu
|
|
|
474
496
|
| Version | Scope |
|
|
475
497
|
| ------- | ------------------------------------------------------------------ |
|
|
476
498
|
| v0.1 | core + guards + effects + inspect + replay + pbt (this release) |
|
|
477
|
-
| v0.2 |
|
|
478
|
-
| v0.3 |
|
|
479
|
-
| v0.4 |
|
|
499
|
+
| v0.2 | Async-guard detection, coverage tuning, llms-full.txt verify gate |
|
|
500
|
+
| v0.3 | Hierarchical sugar via `state.sub` (experimental, this release) |
|
|
501
|
+
| v0.4 | `historyState` — remember last active sub-state on re-entry |
|
|
480
502
|
| v0.5 | `aifsmjs-bridge-bitecs` / `aifsmjs-bridge-pixi` (separate sub-packages) |
|
|
481
503
|
| v1.0 | API freeze and stability guarantee |
|
|
482
504
|
|
|
505
|
+
**Out of scope (v1)**:
|
|
506
|
+
|
|
507
|
+
- **Parallel state regions** (out of scope for v1)
|
|
508
|
+
- **Actor invocation / spawn** (out of scope for v1)
|
|
509
|
+
- **Tick / game-loop hook** (out of scope for v1)
|
|
510
|
+
- **ECS / Pixi bridges** (out of scope for v1)
|
|
511
|
+
|
|
512
|
+
**v0.4 candidate**: `historyState` — remember last active sub-state on
|
|
513
|
+
re-entry. Workaround today: snapshot via `onTransition` and restore
|
|
514
|
+
manually.
|
|
515
|
+
|
|
483
516
|
---
|
|
484
517
|
|
|
485
518
|
## License
|
package/README_ZHTW.md
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
|
|
9
9
|
> 一個小而嚴格的 FSM library,為任何需要可重現、可重播狀態流轉的 TypeScript/JS app 而生:把 lifecycle 寫成 pure `step()`,把 Chain-of-Responsibility 直覺收斂到 cross-cutting concerns(observe / persist / replay)── 而非 transition 主流程。
|
|
10
10
|
|
|
11
|
+
隸屬 [ai\*js micro-runtime 生態系](https://github.com/yshengliao) ─ 另見 [aibridgejs](https://github.com/yshengliao/aibridgejs)(cross-context RPC)與 [aiecsjs](https://github.com/yshengliao/aiecsjs)(ECS)。
|
|
12
|
+
|
|
11
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。
|
|
12
14
|
|
|
13
15
|
---
|
|
@@ -103,15 +105,16 @@ console.log(runtime.getSnapshot().context); // { ticks: 1 }
|
|
|
103
105
|
|
|
104
106
|
| 會做(v1) | 不會做 |
|
|
105
107
|
| --------------------------------------------------- | ------------------------------------------------- |
|
|
106
|
-
| Flat states + transitions |
|
|
108
|
+
| Flat states + transitions | Parallel state regions |
|
|
109
|
+
| 透過 `state.sub` 的階層式 sugar(experimental,since 0.3.0) | Definition 內直接綁 closure(會無法序列化) |
|
|
107
110
|
| Guards(sync only;inline async 在 `defineMachine` 時丟 `InvalidDefinitionError`;runtime 偵測到 thenable 回傳則丟 `AsyncGuardError`) | Async guards |
|
|
108
111
|
| Actions(assign + enqueue effects) | 在 action 內呼叫 async API(請放到 effect) |
|
|
109
112
|
| Fire-and-forget effects | Actor invocation / spawn |
|
|
110
113
|
| Read-only inspect middleware | 可中止 transition 的 middleware |
|
|
111
114
|
| `replay(initial, log, def, impl)` 純函式 | Time travel debugger(v2 再評估) |
|
|
112
115
|
| `fast-check` `fc.commands` adapter | 自家 PBT framework |
|
|
113
|
-
| String ref + runtime injection |
|
|
114
|
-
| Tree-shake friendly subpath exports |
|
|
116
|
+
| String ref + runtime injection | 從 root 一次 import 全部 |
|
|
117
|
+
| Tree-shake friendly subpath exports | ECS / Pixi bridges(opt-in subpath,不進 core) |
|
|
115
118
|
|
|
116
119
|
---
|
|
117
120
|
|
|
@@ -353,6 +356,22 @@ sched.cancelAll();
|
|
|
353
356
|
- async lifecycle hook
|
|
354
357
|
- Inspect middleware 影響 transition 結果
|
|
355
358
|
|
|
359
|
+
### Sub-machine lifecycle(since 0.3.0,experimental)
|
|
360
|
+
|
|
361
|
+
當一個 state 宣告 `sub` 時,每次 transition 的執行順序為:
|
|
362
|
+
|
|
363
|
+
1. Parent `step()` 執行:`exit actions → transition.actions → entry actions`。
|
|
364
|
+
2. 舊 child(若有)`dispose()` — 同步執行;例外會包成 `SubMachineError(phase: "dispose")`。
|
|
365
|
+
3. 新 child(若 next state 有 `sub`)實例化 — 例外會包成 `SubMachineError(phase: "init")`。
|
|
366
|
+
4. Parent snapshot 確認提交。
|
|
367
|
+
5. Middleware pipeline 執行。
|
|
368
|
+
6. Effects 派發。
|
|
369
|
+
7. `'transition'` 事件發給 `on()` / `onTransition()` 的訂閱者。
|
|
370
|
+
|
|
371
|
+
若步驟 2 或 3 丟例外,parent snapshot **不會**提交(回滾至 `prev`);middleware、effects、`'transition'` 都不會執行。
|
|
372
|
+
|
|
373
|
+
`runtime.dispose()` 透過 `controller.signal` 的 abort listener 以及顯式的 `child.dispose()` 呼叫,將 dispose 行為串聯到 child。Cascade 會吞掉 child 的例外,以遵守永不丟錯的 dispose 契約。
|
|
374
|
+
|
|
356
375
|
---
|
|
357
376
|
|
|
358
377
|
## Lifecycle Protocol
|
|
@@ -435,7 +454,7 @@ aifsmjs 在幾個常見議題上做了刻意取捨,跟主流 FSM library 寫
|
|
|
435
454
|
- **Example tests**(vitest):對每個 src module 寫 happy path + 邊界 + error message 三類。
|
|
436
455
|
- **PBT smoke**:每條 generic property 跑 50 runs,作為 invariant guard,不追求 coverage。
|
|
437
456
|
- **CI 強制門檻**:`@vitest/coverage-v8` 設 **100% statements / 100% lines / 100% functions / ≥90% branches**。少數 defensive invariant-guard 分支(例如 runtime determinism mismatch)標 `/* v8 ignore */` 並寫明原因。
|
|
438
|
-
- **Size budget**:`scripts/check-size.mjs` 在 CI 檢查每個 subpath gzip 大小,超過預算(core ≤
|
|
457
|
+
- **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。
|
|
439
458
|
|
|
440
459
|
### 內建的 6 條 generic properties
|
|
441
460
|
|
|
@@ -455,7 +474,7 @@ aifsmjs 在幾個常見議題上做了刻意取捨,跟主流 FSM library 寫
|
|
|
455
474
|
| | aifsmjs | XState v5 | Robot3 | @xstate/store | Zag.js |
|
|
456
475
|
| -------------------------- | -------------- | ----------------- | ----------------- | ----------------- | ----------------- |
|
|
457
476
|
| Core size (gzip) | ~2.8KB | ~15KB | ~1KB | < 1KB | per-component |
|
|
458
|
-
| Hierarchical states |
|
|
477
|
+
| Hierarchical states | Sugar (0.3.0) | Yes | No | N/A | Yes |
|
|
459
478
|
| Async invoke / actor | No | Yes | No | N/A | No |
|
|
460
479
|
| Guard combinators | and/or/not | and/or/not | No | N/A | No |
|
|
461
480
|
| Effects 雙軌 | enqueue | enqueueActions | reduce/action | enq.effect() | array of names |
|
|
@@ -471,12 +490,21 @@ aifsmjs 在幾個常見議題上做了刻意取捨,跟主流 FSM library 寫
|
|
|
471
490
|
| 版本 | 範圍 |
|
|
472
491
|
| ---- | ----------------------------------------------------------------- |
|
|
473
492
|
| v0.1 | core + guards + effects + inspect + replay + pbt(本次發佈) |
|
|
474
|
-
| v0.2 |
|
|
475
|
-
| v0.3 |
|
|
476
|
-
| v0.4 |
|
|
493
|
+
| v0.2 | Async-guard 偵測、coverage 調整、llms-full.txt verify gate |
|
|
494
|
+
| v0.3 | 透過 `state.sub` 的階層式 sugar(experimental,本次發佈) |
|
|
495
|
+
| v0.4 | `historyState` — re-entry 時恢復上一次的 sub-state |
|
|
477
496
|
| v0.5 | `aifsmjs-bridge-bitecs` / `aifsmjs-bridge-pixi`(獨立 sub-package) |
|
|
478
497
|
| v1.0 | API freeze 與 stability guarantee |
|
|
479
498
|
|
|
499
|
+
**不在 v1 範圍內**:
|
|
500
|
+
|
|
501
|
+
- **Parallel state regions**(v1 不做)
|
|
502
|
+
- **Actor invocation / spawn**(v1 不做)
|
|
503
|
+
- **Tick / game-loop hook**(v1 不做)
|
|
504
|
+
- **ECS / Pixi bridges**(v1 不做)
|
|
505
|
+
|
|
506
|
+
**v0.4 候選**:`historyState` — re-entry 時自動恢復上一次的 sub-state。0.3.0 的暫代方案:透過 `onTransition` snapshot sub-runtime 的 value,之後手動還原。
|
|
507
|
+
|
|
480
508
|
---
|
|
481
509
|
|
|
482
510
|
## License
|
package/STABILITY.md
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Stability
|
|
2
|
+
|
|
3
|
+
This document defines the stability tier of every public symbol exported by
|
|
4
|
+
`aifsmjs`. Tiers govern what breaks may occur in future minor / major bumps.
|
|
5
|
+
|
|
6
|
+
## Stable (since 0.1.0)
|
|
7
|
+
|
|
8
|
+
Fully stable. Breaking changes only at a major version bump (1.0+).
|
|
9
|
+
|
|
10
|
+
- `createMachine`, `defineMachine`, `setup`, `createRuntime`, `initialSnapshot`
|
|
11
|
+
- `step`, `resolveTransitions`, `evalGuard`, `resolveGuard`, `isAsyncGuardFn`
|
|
12
|
+
- `assign`, `mergeContext`, `createSnapshot`, `deepFreeze`, `freezeSnapshot`
|
|
13
|
+
- `Runtime.send`, `Runtime.reset`, `Runtime.can`, `Runtime.getSnapshot`,
|
|
14
|
+
`Runtime.snapshot`, `Runtime.subscribe`, `Runtime.on`, `Runtime.dispose`,
|
|
15
|
+
`Runtime.signal`, `Runtime.disposed`
|
|
16
|
+
- All error classes from 0.1.0–0.2.1: `RuntimeDisposedError`,
|
|
17
|
+
`InvalidDefinitionError`, `UnknownActionError`, `UnknownGuardError`,
|
|
18
|
+
`AsyncGuardError`
|
|
19
|
+
- Types: `MachineDef`, `StateDef` (fields `on`, `entry`, `exit`, `final`),
|
|
20
|
+
`TransitionDef`, `Snapshot`, `Implementations`, `Guard`, `Action`,
|
|
21
|
+
`EffectHandler`, `Effect`, `Enqueuer`, `Middleware`, `MiddlewareContext`,
|
|
22
|
+
`RuntimeOptions`, `StepResult`, `ResetEvent`, `RESET_EVENT_TYPE`,
|
|
23
|
+
`RuntimeTransitionEvent`, `RuntimeErrorEvent`, `RuntimeEventMap`
|
|
24
|
+
- All subpath exports: `aifsmjs/guards`, `aifsmjs/effects`, `aifsmjs/inspect`,
|
|
25
|
+
`aifsmjs/replay`, `aifsmjs/pbt`, `aifsmjs/timer`
|
|
26
|
+
- `Runtime.onTransition` (added in 0.3.0) — pure sugar over the stable
|
|
27
|
+
`on('transition', ...)` API; listed under Stable because the underlying
|
|
28
|
+
contract is unchanged.
|
|
29
|
+
|
|
30
|
+
## Experimental (since 0.3.0)
|
|
31
|
+
|
|
32
|
+
Shape and behaviour may change in any minor bump until 1.0. Production use
|
|
33
|
+
is OK; expect a one-line patch on minor upgrades.
|
|
34
|
+
|
|
35
|
+
- `StateDef.sub` (optional `SubMachineDef`) — when present, a child runtime
|
|
36
|
+
is lazily initialised on entry and disposed on exit. Per-transition
|
|
37
|
+
ordering is parent `step()` (exit / actions / entry) → child dispose →
|
|
38
|
+
child init → snapshot commit.
|
|
39
|
+
- `StateDef.subImpl` (optional `Implementations`) — paired with `sub`;
|
|
40
|
+
passed to the child `createRuntime`. Defaults to `{}`.
|
|
41
|
+
- `Runtime.subRuntime()` — returns the live child handle, or `undefined`.
|
|
42
|
+
Returned generic is `Runtime<unknown, { type: string }, string>`; caller
|
|
43
|
+
narrows via cast if necessary.
|
|
44
|
+
- `SubMachineError` — thrown by `send()` / `reset()` on child init/dispose
|
|
45
|
+
failure. Fields: `parentState`, `phase ("init" | "dispose")`, `cause`.
|
|
46
|
+
- `SubMachineDef` type alias.
|
|
47
|
+
|
|
48
|
+
### Known boundaries (0.3.0)
|
|
49
|
+
|
|
50
|
+
- **Replay / PBT do not see child state.** `replay()` and
|
|
51
|
+
`commandsFromMachine` only inspect parent snapshots. If your business
|
|
52
|
+
logic lives in the parent layer, replay is still deterministic.
|
|
53
|
+
- **`subRuntime()` may return a disposed handle** if an external caller
|
|
54
|
+
disposed it. The handle is not reinitialised until the parent leaves and
|
|
55
|
+
re-enters the sub-bearing state. Detect with `child.disposed`.
|
|
56
|
+
- **Self-targeting external (`A → A`) is treated as full exit/entry**:
|
|
57
|
+
child is disposed and reinitialised. The 0.3.0 dispatcher re-resolves
|
|
58
|
+
guards to identify the chosen transition before deciding external vs
|
|
59
|
+
internal, so guarded internal transitions on the same event no longer
|
|
60
|
+
trigger a reinit.
|
|
61
|
+
- **Init-failure mid-transition leaves the parent without a live child.**
|
|
62
|
+
If `applySubLifecycle` successfully disposes the old child and then the
|
|
63
|
+
new child's `createRuntime` throws, the parent's snapshot is rolled back
|
|
64
|
+
to `prev` but `subRuntime()` returns `undefined`. Callers catching
|
|
65
|
+
`SubMachineError(phase: "init")` should treat the runtime as quarantined
|
|
66
|
+
— call `runtime.dispose()` (idempotent) or `runtime.reset()` (which
|
|
67
|
+
attempts re-init) before sending further events. A future major may
|
|
68
|
+
switch to a two-phase "init before dispose" commit strategy; for 0.3.x
|
|
69
|
+
this remains opt-in only when child failures are expected.
|
|
70
|
+
|
|
71
|
+
## Draft (planned, not implemented in 0.3.0)
|
|
72
|
+
|
|
73
|
+
API sketched, not shipped. May change before release.
|
|
74
|
+
|
|
75
|
+
- `historyState` (v0.4 candidate) — opt-in pseudo-state that remembers
|
|
76
|
+
the last active sub-state on re-entry. Workaround in 0.3.0: snapshot
|
|
77
|
+
the sub-runtime's value on exit via `onTransition`, restore manually.
|
package/dist/effects/index.d.cts
CHANGED
package/dist/effects/index.d.ts
CHANGED
package/dist/guards/index.d.cts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { e as GuardRef, G as Guard } from '../types-
|
|
1
|
+
import { e as GuardRef, G as Guard } from '../types-CGKk6Rur.cjs';
|
|
2
2
|
|
|
3
3
|
/** Logical AND over guards. Short-circuits on the first `false`. */
|
|
4
4
|
declare function and<Ctx, Evt>(items: readonly GuardRef<Ctx, Evt>[]): Guard<Ctx, Evt>;
|
package/dist/guards/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { e as GuardRef, G as Guard } from '../types-
|
|
1
|
+
import { e as GuardRef, G as Guard } from '../types-CGKk6Rur.js';
|
|
2
2
|
|
|
3
3
|
/** Logical AND over guards. Short-circuits on the first `false`. */
|
|
4
4
|
declare function and<Ctx, Evt>(items: readonly GuardRef<Ctx, Evt>[]): Guard<Ctx, Evt>;
|
package/dist/index.cjs
CHANGED
|
@@ -192,6 +192,18 @@ var RuntimeDisposedError = class extends Error {
|
|
|
192
192
|
this.name = "RuntimeDisposedError";
|
|
193
193
|
}
|
|
194
194
|
};
|
|
195
|
+
var SubMachineError = class extends Error {
|
|
196
|
+
parentState;
|
|
197
|
+
phase;
|
|
198
|
+
cause;
|
|
199
|
+
constructor(parentState, phase, cause) {
|
|
200
|
+
super(`aifsmjs: sub-machine ${phase} failed at parent state "${parentState}"`, { cause });
|
|
201
|
+
this.name = "SubMachineError";
|
|
202
|
+
this.parentState = parentState;
|
|
203
|
+
this.phase = phase;
|
|
204
|
+
this.cause = cause;
|
|
205
|
+
}
|
|
206
|
+
};
|
|
195
207
|
var RESET_EVENT = Object.freeze({ type: RESET_EVENT_TYPE });
|
|
196
208
|
function composeMiddleware(middleware) {
|
|
197
209
|
return (ctx, finalNext) => {
|
|
@@ -216,6 +228,8 @@ function createRuntime(def, impl, opts = {}) {
|
|
|
216
228
|
const shouldDispatch = opts.dispatchEffects !== false;
|
|
217
229
|
const controller = new AbortController();
|
|
218
230
|
let disposed = false;
|
|
231
|
+
let childRuntime;
|
|
232
|
+
let childAbortCleanup;
|
|
219
233
|
const eventListeners = {
|
|
220
234
|
transition: /* @__PURE__ */ new Set(),
|
|
221
235
|
error: /* @__PURE__ */ new Set(),
|
|
@@ -231,14 +245,7 @@ function createRuntime(def, impl, opts = {}) {
|
|
|
231
245
|
}
|
|
232
246
|
function runMiddleware(prev, event, effects, changed) {
|
|
233
247
|
if (!middlewareChain) return;
|
|
234
|
-
|
|
235
|
-
prev,
|
|
236
|
-
next: snapshot,
|
|
237
|
-
event,
|
|
238
|
-
effects,
|
|
239
|
-
changed
|
|
240
|
-
});
|
|
241
|
-
middlewareChain(mwCtx, () => {
|
|
248
|
+
middlewareChain(deepFreeze({ prev, next: snapshot, event, effects, changed }), () => {
|
|
242
249
|
});
|
|
243
250
|
}
|
|
244
251
|
function dispatchEffects(effects, context, event) {
|
|
@@ -249,52 +256,128 @@ function createRuntime(def, impl, opts = {}) {
|
|
|
249
256
|
const r = handler(eff, { context, event, signal: controller.signal });
|
|
250
257
|
if (r instanceof Promise) {
|
|
251
258
|
r.catch((err) => {
|
|
252
|
-
|
|
253
|
-
emit("error", payload);
|
|
259
|
+
emit("error", { error: err, event });
|
|
254
260
|
});
|
|
255
261
|
}
|
|
256
262
|
}
|
|
257
263
|
}
|
|
264
|
+
function wireChildAbort(child) {
|
|
265
|
+
if (controller.signal.aborted) {
|
|
266
|
+
try {
|
|
267
|
+
child.dispose();
|
|
268
|
+
} catch {
|
|
269
|
+
}
|
|
270
|
+
return () => {
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
const onAbort = () => {
|
|
274
|
+
try {
|
|
275
|
+
child.dispose();
|
|
276
|
+
} catch {
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
controller.signal.addEventListener("abort", onAbort, { once: true });
|
|
280
|
+
return () => controller.signal.removeEventListener("abort", onAbort);
|
|
281
|
+
}
|
|
282
|
+
function findChosenIsExternal(value, event, context) {
|
|
283
|
+
const state = def.states[value];
|
|
284
|
+
if (!state?.on) return false;
|
|
285
|
+
const candidates = state.on[event.type];
|
|
286
|
+
if (!candidates) return false;
|
|
287
|
+
const list = Array.isArray(candidates) ? candidates : [candidates];
|
|
288
|
+
for (const t of list) {
|
|
289
|
+
if (!t.guard || evalGuard(t.guard, context, event, impl, value)) {
|
|
290
|
+
return t.target !== void 0;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
function applySubLifecycle(prevValue, nextValue) {
|
|
296
|
+
const prevStateDef = def.states[prevValue];
|
|
297
|
+
const nextStateDef = def.states[nextValue];
|
|
298
|
+
if (prevStateDef?.sub !== void 0 && childRuntime !== void 0) {
|
|
299
|
+
const child = childRuntime;
|
|
300
|
+
childRuntime = void 0;
|
|
301
|
+
childAbortCleanup?.();
|
|
302
|
+
childAbortCleanup = void 0;
|
|
303
|
+
try {
|
|
304
|
+
child.dispose();
|
|
305
|
+
} catch (cause) {
|
|
306
|
+
throw new SubMachineError(prevValue, "dispose", cause);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
if (nextStateDef?.sub !== void 0) {
|
|
310
|
+
let newChild;
|
|
311
|
+
try {
|
|
312
|
+
newChild = createRuntime(nextStateDef.sub, nextStateDef.subImpl ?? {});
|
|
313
|
+
} catch (cause) {
|
|
314
|
+
throw new SubMachineError(nextValue, "init", cause);
|
|
315
|
+
}
|
|
316
|
+
childRuntime = newChild;
|
|
317
|
+
childAbortCleanup = wireChildAbort(newChild);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
258
320
|
function send(event) {
|
|
259
321
|
if (disposed) throw new RuntimeDisposedError();
|
|
260
322
|
const prev = snapshot;
|
|
261
323
|
const result = step(def, prev, event, impl);
|
|
324
|
+
const isExternal = result.changed && (prev.value !== result.snapshot.value || findChosenIsExternal(prev.value, event, prev.context));
|
|
325
|
+
if (result.changed && isExternal) applySubLifecycle(prev.value, result.snapshot.value);
|
|
262
326
|
snapshot = result.snapshot;
|
|
263
327
|
const committed = result.snapshot;
|
|
264
328
|
runMiddleware(prev, event, result.effects, result.changed);
|
|
265
|
-
if (shouldDispatch)
|
|
266
|
-
dispatchEffects(result.effects, committed.context, event);
|
|
267
|
-
}
|
|
329
|
+
if (shouldDispatch) dispatchEffects(result.effects, committed.context, event);
|
|
268
330
|
if (result.changed) {
|
|
269
331
|
notify(committed);
|
|
270
|
-
|
|
332
|
+
emit("transition", {
|
|
271
333
|
prev,
|
|
272
334
|
next: committed,
|
|
273
335
|
event,
|
|
274
336
|
effects: result.effects,
|
|
275
337
|
changed: true
|
|
276
|
-
};
|
|
277
|
-
emit("transition", payload);
|
|
338
|
+
});
|
|
278
339
|
}
|
|
279
340
|
return snapshot;
|
|
280
341
|
}
|
|
281
342
|
function reset(event) {
|
|
282
343
|
if (disposed) throw new RuntimeDisposedError();
|
|
283
344
|
const prev = snapshot;
|
|
284
|
-
|
|
285
|
-
const changed = prev.value !==
|
|
345
|
+
const nextSnap = initialSnapshot(def);
|
|
346
|
+
const changed = prev.value !== nextSnap.value;
|
|
347
|
+
if (childRuntime) {
|
|
348
|
+
const child = childRuntime;
|
|
349
|
+
childRuntime = void 0;
|
|
350
|
+
childAbortCleanup?.();
|
|
351
|
+
childAbortCleanup = void 0;
|
|
352
|
+
try {
|
|
353
|
+
child.dispose();
|
|
354
|
+
} catch (cause) {
|
|
355
|
+
throw new SubMachineError(prev.value, "dispose", cause);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
const initStateDef = def.states[nextSnap.value];
|
|
359
|
+
if (initStateDef?.sub) {
|
|
360
|
+
let newChild;
|
|
361
|
+
try {
|
|
362
|
+
newChild = createRuntime(initStateDef.sub, initStateDef.subImpl ?? {});
|
|
363
|
+
} catch (cause) {
|
|
364
|
+
throw new SubMachineError(nextSnap.value, "init", cause);
|
|
365
|
+
}
|
|
366
|
+
childRuntime = newChild;
|
|
367
|
+
childAbortCleanup = wireChildAbort(newChild);
|
|
368
|
+
}
|
|
369
|
+
snapshot = nextSnap;
|
|
286
370
|
const triggerEvent = event ?? RESET_EVENT;
|
|
287
371
|
runMiddleware(prev, triggerEvent, [], changed);
|
|
288
372
|
if (changed) {
|
|
289
373
|
notify();
|
|
290
|
-
|
|
374
|
+
emit("transition", {
|
|
291
375
|
prev,
|
|
292
376
|
next: snapshot,
|
|
293
377
|
event: triggerEvent,
|
|
294
378
|
effects: [],
|
|
295
379
|
changed: true
|
|
296
|
-
};
|
|
297
|
-
emit("transition", payload);
|
|
380
|
+
});
|
|
298
381
|
}
|
|
299
382
|
return snapshot;
|
|
300
383
|
}
|
|
@@ -345,6 +428,15 @@ function createRuntime(def, impl, opts = {}) {
|
|
|
345
428
|
function dispose() {
|
|
346
429
|
if (disposed) return;
|
|
347
430
|
disposed = true;
|
|
431
|
+
if (childRuntime) {
|
|
432
|
+
childAbortCleanup?.();
|
|
433
|
+
childAbortCleanup = void 0;
|
|
434
|
+
try {
|
|
435
|
+
childRuntime.dispose();
|
|
436
|
+
} catch {
|
|
437
|
+
}
|
|
438
|
+
childRuntime = void 0;
|
|
439
|
+
}
|
|
348
440
|
controller.abort();
|
|
349
441
|
listeners.clear();
|
|
350
442
|
emit("dispose", void 0);
|
|
@@ -352,7 +444,7 @@ function createRuntime(def, impl, opts = {}) {
|
|
|
352
444
|
for (const cleanup of externalAbortCleanups) cleanup();
|
|
353
445
|
externalAbortCleanups.clear();
|
|
354
446
|
}
|
|
355
|
-
|
|
447
|
+
const runtime = {
|
|
356
448
|
getSnapshot: () => snapshot,
|
|
357
449
|
snapshot: () => snapshot,
|
|
358
450
|
send,
|
|
@@ -371,8 +463,22 @@ function createRuntime(def, impl, opts = {}) {
|
|
|
371
463
|
};
|
|
372
464
|
listeners.add(listener);
|
|
373
465
|
return () => listeners.delete(listener);
|
|
374
|
-
}
|
|
466
|
+
},
|
|
467
|
+
subRuntime: () => childRuntime,
|
|
468
|
+
onTransition: (handler, options) => on("transition", handler, options)
|
|
375
469
|
};
|
|
470
|
+
const bootStateDef = def.states[snapshot.value];
|
|
471
|
+
if (bootStateDef?.sub) {
|
|
472
|
+
let newChild;
|
|
473
|
+
try {
|
|
474
|
+
newChild = createRuntime(bootStateDef.sub, bootStateDef.subImpl ?? {});
|
|
475
|
+
} catch (cause) {
|
|
476
|
+
throw new SubMachineError(snapshot.value, "init", cause);
|
|
477
|
+
}
|
|
478
|
+
childRuntime = newChild;
|
|
479
|
+
childAbortCleanup = wireChildAbort(newChild);
|
|
480
|
+
}
|
|
481
|
+
return runtime;
|
|
376
482
|
}
|
|
377
483
|
|
|
378
484
|
// src/fsm/definition.ts
|
|
@@ -399,6 +505,15 @@ function validateDefinition(def) {
|
|
|
399
505
|
);
|
|
400
506
|
}
|
|
401
507
|
for (const [stateName, stateDef] of Object.entries(def.states)) {
|
|
508
|
+
if (stateDef.sub !== void 0) {
|
|
509
|
+
const sub = stateDef.sub;
|
|
510
|
+
const subStates = sub.states;
|
|
511
|
+
if (typeof sub !== "object" || sub === null || typeof subStates !== "object" || subStates === null || typeof sub.initial !== "string") {
|
|
512
|
+
throw new InvalidDefinitionError(
|
|
513
|
+
`state "${stateName}".sub is not a valid sub-machine definition (missing states or initial)`
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
402
517
|
if (!stateDef.on) continue;
|
|
403
518
|
for (const [evtType, entry] of Object.entries(stateDef.on)) {
|
|
404
519
|
const transitions = Array.isArray(entry) ? entry : [entry];
|
|
@@ -455,6 +570,7 @@ exports.AsyncGuardError = AsyncGuardError;
|
|
|
455
570
|
exports.InvalidDefinitionError = InvalidDefinitionError;
|
|
456
571
|
exports.RESET_EVENT_TYPE = RESET_EVENT_TYPE;
|
|
457
572
|
exports.RuntimeDisposedError = RuntimeDisposedError;
|
|
573
|
+
exports.SubMachineError = SubMachineError;
|
|
458
574
|
exports.UnknownActionError = UnknownActionError;
|
|
459
575
|
exports.UnknownGuardError = UnknownGuardError;
|
|
460
576
|
exports.assign = assign;
|