aifsmjs 0.5.1 → 0.5.3

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 CHANGED
@@ -164,13 +164,34 @@ aifsmjs follows the XState v5 two-phase pattern (`setup().createMachine()`): the
164
164
 
165
165
  ```typescript
166
166
  function defineMachine<
167
- Ctx,
168
- Evt extends { type: string },
169
- States extends string,
170
- >(def: MachineDef<Ctx, Evt, States>): MachineDef<Ctx, Evt, States>;
167
+ Ctx = Record<string, never>,
168
+ Evt extends { type: string } = { type: string },
169
+ States extends string = string,
170
+ >(def: MachineConfig<Ctx, Evt, States>): MachineDef<Ctx, Evt, States>;
171
171
  ```
172
172
 
173
- Pure data builder. Freezes the whole definition and validates that `initial` exists in the `states` map.
173
+ Pure data builder. Validates that `initial` exists in the `states` map and returns the (normalized) definition.
174
+
175
+ `context` is **optional** — omit it for stateless machines and it defaults to `{}` (the type parameter defaults to `Record<string, never>`). Existing definitions that pass `context` are unaffected.
176
+
177
+ ```typescript
178
+ // No context needed — defaults to {}
179
+ const toggle = defineMachine({
180
+ id: "toggle",
181
+ initial: "off",
182
+ states: {
183
+ off: { on: { TOGGLE: "on" } }, // string shorthand, see below
184
+ on: { on: { TOGGLE: "off" } },
185
+ },
186
+ });
187
+ ```
188
+
189
+ **String-shorthand transitions.** A transition value may be either the full object form or a bare target-state string (à la XState). The string is normalized to `{ target }` before processing — it carries no guard or actions:
190
+
191
+ ```typescript
192
+ on: { NEXT: "green" } // shorthand for { target: "green" }
193
+ on: { NEXT: [{ target: "a", guard: "g" }, "b"] } // mixes with the object form
194
+ ```
174
195
 
175
196
  ### `createRuntime(def, impl, opts?)`
176
197
 
@@ -207,7 +228,7 @@ function step<C, E, S>(
207
228
  ): { snapshot: Snapshot<C, S>; effects: readonly Effect[] };
208
229
  ```
209
230
 
210
- **Pure function**. The invariant keeper for the whole library. It never dispatches effects, never mutates the snapshot, and never throws — a failing guard or unmapped event simply returns the original snapshot.
231
+ **Pure function**. The invariant keeper for the whole library. It never dispatches effects and never mutates the snapshot. A failing guard or unmapped event simply returns the original snapshot unchanged. It does throw on misuse (`UnknownGuardError`, `UnknownActionError`, `AsyncGuardError`) so guard/action wiring errors surface at development time rather than silently passing.
211
232
 
212
233
  ### `assign(updater)`
213
234
 
@@ -266,7 +287,7 @@ const runtime = createRuntime(def, impl, {
266
287
  });
267
288
  ```
268
289
 
269
- Koa-style `(ctx, next) => void` pipeline. `ctx` is `{ prev, next, event, effects }`, all `structuredClone`d and frozen. **Cannot cancel a transition** — `next()` must be called; the return value carries no meaning.
290
+ Koa-style `(ctx, next) => void` pipeline. `ctx` is `{ prev, next, event, effects, changed }`, all deep-frozen. **Cannot cancel a transition** — `next()` must be called; the return value carries no meaning.
270
291
 
271
292
  ### `aifsmjs/replay` — Pure event log replay
272
293
 
@@ -285,14 +306,25 @@ Never dispatches effects. For PBT, time-travel debugging, and incident reproduct
285
306
 
286
307
  ```typescript
287
308
  import fc from "fast-check";
288
- import { commandsFromMachine, properties } from "aifsmjs/pbt";
309
+ import { createRuntime } from "aifsmjs";
310
+ import { commandsFromMachine, initialModel, properties } from "aifsmjs/pbt";
311
+
312
+ // Use one of the six built-in generic properties, or assertAll for all at once:
313
+ properties.replayEqualsFold(def, impl, {
314
+ NEXT: fc.constant({ type: "NEXT" as const }),
315
+ });
289
316
 
317
+ // Or build a custom property using commandsFromMachine:
290
318
  fc.assert(
291
319
  fc.property(
292
320
  commandsFromMachine(def, impl, {
293
321
  NEXT: fc.constant({ type: "NEXT" as const }),
294
322
  }),
295
- (cmds) => properties.runDeterministic(def, impl, cmds),
323
+ (cmds) => {
324
+ const real = createRuntime(def, impl, { dispatchEffects: false });
325
+ fc.modelRun(() => ({ model: initialModel(def), real }), cmds);
326
+ return true;
327
+ },
296
328
  ),
297
329
  );
298
330
  ```
@@ -406,6 +438,8 @@ aifsmjs ships a few opinionated calls that look different from the typical FSM l
406
438
  - **Guards and reducers are sync.** A non-deterministic guard would break the PBT determinism property (#1 in the generic suite). Move async predicates into events: send `FETCH_REQUEST`, then later `FETCH_DONE` with the resolved value as payload.
407
439
  - **Effects are descriptors, not inline callbacks.** Actions enqueue `{ type, payload }` via `enqueue.effect(...)`; the runtime collects them and the dispatcher invokes user handlers. This keeps machine definitions serializable (JSON round-trippable when no inline functions are used), enables `replay()` to fold an event log into the same snapshot, and lets `inspect/persist` middleware capture effects for audit logs.
408
440
  - **Two factory paths coexist.** `setup<Ctx, Evt>().defineMachine(...)` is the type-friendly form (States inferred from `keyof states`). `createMachine(def, impl, opts?)` is the spec-style single-factory shortcut from the ai*js ecosystem review. Plain `defineMachine<Ctx, Evt, States>(def)` remains for explicit generic control. Pick whichever reads best at the call site.
441
+ - **Transitions accept a string shorthand.** `on: { EVENT: "targetState" }` is sugar for `on: { EVENT: { target: "targetState" } }`, normalized in the resolver before any guard/action processing. The shorthand has no guard or actions; reach for the object form when you need them. It composes inside the array form too, so guard-fallthrough lists can mix `{ target, guard }` objects with bare target strings. The full object form is unchanged — this is purely additive.
442
+ - **`context` is optional.** Omit it for stateless machines and it defaults to `{}` (`Ctx` defaults to `Record<string, never>`). Definitions that already pass `context` keep their inferred type and behave identically.
409
443
  - **`subscribe(listener)` and `on(type, fn, { signal, once })` both exist.** The typed `on()` matches the platform `EventTarget` semantics (signal + once) and emits `'transition'`, `'error'`, `'dispose'`. The older `subscribe()` keeps the React `useSyncExternalStore` shape — pass it directly. They are not exclusive.
410
444
 
411
445
  ---
package/README_ZHTW.md CHANGED
@@ -163,13 +163,34 @@ aifsmjs 走 XState v5 `setup().createMachine()` 雙階段路線:definition 用
163
163
 
164
164
  ```typescript
165
165
  function defineMachine<
166
- Ctx,
167
- Evt extends { type: string },
168
- States extends string,
169
- >(def: MachineDef<Ctx, Evt, States>): MachineDef<Ctx, Evt, States>;
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
170
  ```
171
171
 
172
- 純資料 builder。會 freeze 整個 def 並驗證 `initial` 在 `states` 集合內。
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
+ ```
173
194
 
174
195
  ### `createRuntime(def, impl, opts?)`
175
196
 
@@ -206,7 +227,7 @@ function step<C, E, S>(
206
227
  ): { snapshot: Snapshot<C, S>; effects: readonly Effect[] };
207
228
  ```
208
229
 
209
- **Pure function**。整個 library 的 invariant 守護者。不會 dispatch effects、不會 mutate snapshot、不會丟錯——guard 沒過或 event 沒對應 transition 就回原 snapshot。
230
+ **Pure function**。整個 library 的 invariant 守護者。不會 dispatch effects、不會 mutate snapshot。Guard 沒過或 event 沒對應 transition 就回原 snapshot(不改變)。誤用(`UnknownGuardError`、`UnknownActionError`、`AsyncGuardError`)才會丟錯,讓配接錯誤在開發期間立即浮現、不被靜默略過。
210
231
 
211
232
  ### `assign(updater)`
212
233
 
@@ -265,7 +286,7 @@ const runtime = createRuntime(def, impl, {
265
286
  });
266
287
  ```
267
288
 
268
- Koa-style `(ctx, next) => void` pipeline。`ctx` 是 `{ prev, next, event, effects }`,全部 `structuredClone + freeze`。**不能中止 transition**——`next()` 必呼叫,回傳值無語意。
289
+ Koa-style `(ctx, next) => void` pipeline。`ctx` 是 `{ prev, next, event, effects, changed }`,全部 deep-frozen。**不能中止 transition**——`next()` 必呼叫,回傳值無語意。
269
290
 
270
291
  ### `aifsmjs/replay` — Pure event log replay
271
292
 
@@ -284,14 +305,25 @@ const finalSnap = replay(initialSnapshot, eventLog, def, impl);
284
305
 
285
306
  ```typescript
286
307
  import fc from "fast-check";
287
- import { commandsFromMachine, properties } from "aifsmjs/pbt";
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
+ });
288
315
 
316
+ // 或用 commandsFromMachine 自訂 property:
289
317
  fc.assert(
290
318
  fc.property(
291
319
  commandsFromMachine(def, impl, {
292
320
  NEXT: fc.constant({ type: "NEXT" as const }),
293
321
  }),
294
- (cmds) => properties.runDeterministic(def, impl, cmds),
322
+ (cmds) => {
323
+ const real = createRuntime(def, impl, { dispatchEffects: false });
324
+ fc.modelRun(() => ({ model: initialModel(def), real }), cmds);
325
+ return true;
326
+ },
295
327
  ),
296
328
  );
297
329
  ```
@@ -398,8 +430,10 @@ aifsmjs 在幾個常見議題上做了刻意取捨,跟主流 FSM library 寫
398
430
 
399
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`。
400
432
  - **Guards / reducers 只能 sync**。非確定性 guard 會破壞 PBT determinism property (#1)。把 async 改寫成 event:先送 `FETCH_REQUEST`,handler 完成後送 `FETCH_DONE`,payload 帶結果。
401
- - **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。
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。
402
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` 的定義型別推導與行為完全相同。
403
437
  - **`subscribe(listener)` 與 `on(type, fn, { signal, once })` 並存**。Typed `on()` 對齊平台 `EventTarget` 語意(signal + once),emit `'transition'`、`'error'`、`'dispose'`。原本的 `subscribe()` 保留 React `useSyncExternalStore` shape,可直接傳。兩者不互斥。
404
438
 
405
439
  ---
@@ -1,4 +1,4 @@
1
- import { c as Enqueuer, E as Effect, b as EffectHandler } from '../types-CGKk6Rur.cjs';
1
+ import { c as Enqueuer, E as Effect, b as EffectHandler } from '../types-DIM7QTtf.cjs';
2
2
 
3
3
  /**
4
4
  * Build a closure-based Enqueuer that pushes effects into the supplied sink.
@@ -1,4 +1,4 @@
1
- import { c as Enqueuer, E as Effect, b as EffectHandler } from '../types-CGKk6Rur.js';
1
+ import { c as Enqueuer, E as Effect, b as EffectHandler } from '../types-DIM7QTtf.js';
2
2
 
3
3
  /**
4
4
  * Build a closure-based Enqueuer that pushes effects into the supplied sink.
@@ -1,4 +1,4 @@
1
- import { e as GuardRef, G as Guard } from '../types-CGKk6Rur.cjs';
1
+ import { e as GuardRef, G as Guard } from '../types-DIM7QTtf.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>;
@@ -1,4 +1,4 @@
1
- import { e as GuardRef, G as Guard } from '../types-CGKk6Rur.js';
1
+ import { e as GuardRef, G as Guard } from '../types-DIM7QTtf.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
@@ -52,6 +52,23 @@ function evalGuard(ref, context, event, impl, value) {
52
52
  return result;
53
53
  }
54
54
 
55
+ // src/fsm/resolver.ts
56
+ function normalizeTransition(entry) {
57
+ return typeof entry === "string" ? { target: entry } : entry;
58
+ }
59
+ function normalizeTransitions(entry) {
60
+ if (entry === void 0) return [];
61
+ if (Array.isArray(entry)) {
62
+ return entry.some((t) => typeof t === "string") ? entry.map((t) => normalizeTransition(t)) : entry;
63
+ }
64
+ return [normalizeTransition(entry)];
65
+ }
66
+ function resolveTransitions(def, stateValue, eventType) {
67
+ const state = def.states[stateValue];
68
+ if (!state || !state.on) return [];
69
+ return normalizeTransitions(state.on[eventType]);
70
+ }
71
+
55
72
  // src/effects/enqueuer.ts
56
73
  function createEnqueuer(sink) {
57
74
  return Object.freeze({
@@ -154,8 +171,7 @@ function step(def, snapshot, event, impl) {
154
171
  if (!state) {
155
172
  return Object.freeze({ snapshot, effects: [], changed: false });
156
173
  }
157
- const candidates = state.on?.[event.type];
158
- const candidateList = candidates ? Array.isArray(candidates) ? candidates : [candidates] : [];
174
+ const candidateList = normalizeTransitions(state.on?.[event.type]);
159
175
  const chosen = pickTransition(candidateList, snapshot.context, event, impl, snapshot.value);
160
176
  if (!chosen) {
161
177
  return Object.freeze({ snapshot, effects: [], changed: false });
@@ -282,9 +298,8 @@ function createRuntime(def, impl, opts = {}) {
282
298
  function findChosenIsExternal(value, event, context) {
283
299
  const state = def.states[value];
284
300
  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];
301
+ const list = normalizeTransitions(state.on[event.type]);
302
+ if (list.length === 0) return false;
288
303
  for (const t of list) {
289
304
  if (!t.guard || evalGuard(t.guard, context, event, impl, value)) {
290
305
  return t.target !== void 0;
@@ -385,9 +400,8 @@ function createRuntime(def, impl, opts = {}) {
385
400
  if (disposed || snapshot.status === "final") return false;
386
401
  const state = def.states[snapshot.value];
387
402
  if (!state) return false;
388
- const candidates = state.on?.[event.type];
389
- if (!candidates) return false;
390
- const list = Array.isArray(candidates) ? candidates : [candidates];
403
+ const list = normalizeTransitions(state.on?.[event.type]);
404
+ if (list.length === 0) return false;
391
405
  for (const t of list) {
392
406
  if (!t.guard) return true;
393
407
  if (evalGuard(t.guard, snapshot.context, event, impl, snapshot.value)) return true;
@@ -514,7 +528,7 @@ function validateDefinition(def) {
514
528
  }
515
529
  if (!stateDef.on) continue;
516
530
  for (const [evtType, entry] of Object.entries(stateDef.on)) {
517
- const transitions = Array.isArray(entry) ? entry : [entry];
531
+ const transitions = normalizeTransitions(entry);
518
532
  for (const t of transitions) {
519
533
  if (t.target !== void 0 && !stateKeys.includes(t.target)) {
520
534
  throw new InvalidDefinitionError(
@@ -531,13 +545,14 @@ function validateDefinition(def) {
531
545
  }
532
546
  }
533
547
  function defineMachine(def) {
534
- validateDefinition(def);
535
- return def;
548
+ const normalized = !("context" in def) ? { ...def, context: {} } : def;
549
+ validateDefinition(normalized);
550
+ return normalized;
536
551
  }
537
552
  function setup() {
538
553
  return {
539
554
  defineMachine: (def) => {
540
- const cast = def;
555
+ const cast = !("context" in def) ? { ...def, context: {} } : def;
541
556
  validateDefinition(cast);
542
557
  return cast;
543
558
  }
@@ -555,15 +570,6 @@ function createMachine(def, impl, opts) {
555
570
  return createRuntime(defineMachine(def), impl, opts ?? {});
556
571
  }
557
572
 
558
- // src/fsm/resolver.ts
559
- function resolveTransitions(def, stateValue, eventType) {
560
- const state = def.states[stateValue];
561
- if (!state || !state.on) return [];
562
- const entry = state.on[eventType];
563
- if (!entry) return [];
564
- return Array.isArray(entry) ? entry : [entry];
565
- }
566
-
567
573
  exports.AsyncGuardError = AsyncGuardError;
568
574
  exports.InvalidDefinitionError = InvalidDefinitionError;
569
575
  exports.RESET_EVENT_TYPE = RESET_EVENT_TYPE;
@@ -582,6 +588,8 @@ exports.freezeSnapshot = freezeSnapshot;
582
588
  exports.initialSnapshot = initialSnapshot;
583
589
  exports.isAsyncGuardFn = isAsyncGuardFn;
584
590
  exports.mergeContext = mergeContext;
591
+ exports.normalizeTransition = normalizeTransition;
592
+ exports.normalizeTransitions = normalizeTransitions;
585
593
  exports.resolveGuard = resolveGuard;
586
594
  exports.resolveTransitions = resolveTransitions;
587
595
  exports.setup = setup;