aifsmjs 0.5.0 → 0.5.2

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
@@ -207,7 +207,7 @@ function step<C, E, S>(
207
207
  ): { snapshot: Snapshot<C, S>; effects: readonly Effect[] };
208
208
  ```
209
209
 
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.
210
+ **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
211
 
212
212
  ### `assign(updater)`
213
213
 
@@ -266,7 +266,7 @@ const runtime = createRuntime(def, impl, {
266
266
  });
267
267
  ```
268
268
 
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.
269
+ 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
270
 
271
271
  ### `aifsmjs/replay` — Pure event log replay
272
272
 
@@ -285,14 +285,25 @@ Never dispatches effects. For PBT, time-travel debugging, and incident reproduct
285
285
 
286
286
  ```typescript
287
287
  import fc from "fast-check";
288
- import { commandsFromMachine, properties } from "aifsmjs/pbt";
288
+ import { createRuntime } from "aifsmjs";
289
+ import { commandsFromMachine, initialModel, properties } from "aifsmjs/pbt";
290
+
291
+ // Use one of the six built-in generic properties, or assertAll for all at once:
292
+ properties.replayEqualsFold(def, impl, {
293
+ NEXT: fc.constant({ type: "NEXT" as const }),
294
+ });
289
295
 
296
+ // Or build a custom property using commandsFromMachine:
290
297
  fc.assert(
291
298
  fc.property(
292
299
  commandsFromMachine(def, impl, {
293
300
  NEXT: fc.constant({ type: "NEXT" as const }),
294
301
  }),
295
- (cmds) => properties.runDeterministic(def, impl, cmds),
302
+ (cmds) => {
303
+ const real = createRuntime(def, impl, { dispatchEffects: false });
304
+ fc.modelRun(() => ({ model: initialModel(def), real }), cmds);
305
+ return true;
306
+ },
296
307
  ),
297
308
  );
298
309
  ```
package/README_ZHTW.md CHANGED
@@ -206,7 +206,7 @@ function step<C, E, S>(
206
206
  ): { snapshot: Snapshot<C, S>; effects: readonly Effect[] };
207
207
  ```
208
208
 
209
- **Pure function**。整個 library 的 invariant 守護者。不會 dispatch effects、不會 mutate snapshot、不會丟錯——guard 沒過或 event 沒對應 transition 就回原 snapshot。
209
+ **Pure function**。整個 library 的 invariant 守護者。不會 dispatch effects、不會 mutate snapshot。Guard 沒過或 event 沒對應 transition 就回原 snapshot(不改變)。誤用(`UnknownGuardError`、`UnknownActionError`、`AsyncGuardError`)才會丟錯,讓配接錯誤在開發期間立即浮現、不被靜默略過。
210
210
 
211
211
  ### `assign(updater)`
212
212
 
@@ -265,7 +265,7 @@ const runtime = createRuntime(def, impl, {
265
265
  });
266
266
  ```
267
267
 
268
- Koa-style `(ctx, next) => void` pipeline。`ctx` 是 `{ prev, next, event, effects }`,全部 `structuredClone + freeze`。**不能中止 transition**——`next()` 必呼叫,回傳值無語意。
268
+ Koa-style `(ctx, next) => void` pipeline。`ctx` 是 `{ prev, next, event, effects, changed }`,全部 deep-frozen。**不能中止 transition**——`next()` 必呼叫,回傳值無語意。
269
269
 
270
270
  ### `aifsmjs/replay` — Pure event log replay
271
271
 
@@ -284,14 +284,25 @@ const finalSnap = replay(initialSnapshot, eventLog, def, impl);
284
284
 
285
285
  ```typescript
286
286
  import fc from "fast-check";
287
- import { commandsFromMachine, properties } from "aifsmjs/pbt";
287
+ import { createRuntime } from "aifsmjs";
288
+ import { commandsFromMachine, initialModel, properties } from "aifsmjs/pbt";
289
+
290
+ // 用任一內建 generic property,或 assertAll 一次跑全部:
291
+ properties.replayEqualsFold(def, impl, {
292
+ NEXT: fc.constant({ type: "NEXT" as const }),
293
+ });
288
294
 
295
+ // 或用 commandsFromMachine 自訂 property:
289
296
  fc.assert(
290
297
  fc.property(
291
298
  commandsFromMachine(def, impl, {
292
299
  NEXT: fc.constant({ type: "NEXT" as const }),
293
300
  }),
294
- (cmds) => properties.runDeterministic(def, impl, cmds),
301
+ (cmds) => {
302
+ const real = createRuntime(def, impl, { dispatchEffects: false });
303
+ fc.modelRun(() => ({ model: initialModel(def), real }), cmds);
304
+ return true;
305
+ },
295
306
  ),
296
307
  );
297
308
  ```
@@ -398,7 +409,7 @@ aifsmjs 在幾個常見議題上做了刻意取捨,跟主流 FSM library 寫
398
409
 
399
410
  - **`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
411
  - **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。
412
+ - **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
413
  - **兩種 factory 並存**。`setup<Ctx, Evt>().defineMachine(...)` 是型別友善版(States 從 `keyof states` 推導)。`createMachine(def, impl, opts?)` 是來自 ai*js 生態 spec 的 single-factory 捷徑。顯式 `defineMachine<Ctx, Evt, States>(def)` 仍保留作完全顯式控制。看 call site 哪個讀起來順手就用哪個。
403
414
  - **`subscribe(listener)` 與 `on(type, fn, { signal, once })` 並存**。Typed `on()` 對齊平台 `EventTarget` 語意(signal + once),emit `'transition'`、`'error'`、`'dispose'`。原本的 `subscribe()` 保留 React `useSyncExternalStore` shape,可直接傳。兩者不互斥。
404
415
 
@@ -14,17 +14,20 @@ function after(ms, fn, opts) {
14
14
  const { st, ct } = resolveTimers(opts);
15
15
  let fired = false;
16
16
  let cancelled = false;
17
- const handle = st(() => {
18
- fired = true;
19
- if (cancelled) return;
20
- fn();
21
- }, ms);
17
+ const timer = {};
22
18
  const cancel = () => {
23
19
  if (fired || cancelled) return;
24
20
  cancelled = true;
25
- ct(handle);
21
+ if (timer.handle !== void 0) ct(timer.handle);
22
+ if (opts?.signal) opts.signal.removeEventListener("abort", cancel);
26
23
  };
27
- if (opts?.signal) {
24
+ timer.handle = st(() => {
25
+ fired = true;
26
+ if (cancelled) return;
27
+ if (opts?.signal) opts.signal.removeEventListener("abort", cancel);
28
+ fn();
29
+ }, ms);
30
+ if (opts?.signal && !fired) {
28
31
  opts.signal.addEventListener("abort", cancel, { once: true });
29
32
  }
30
33
  return Object.freeze({ cancel });
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/timer/scheduler.ts"],"names":[],"mappings":";;;AAuBA,IAAM,IAAA,GAAoB,MAAA,CAAO,MAAA,CAAO,EAAE,QAAQ,MAAM;AAAC,CAAA,EAAG,CAAA;AAE5D,SAAS,cAAc,IAAA,EAGrB;AACA,EAAA,OAAO;AAAA,IACL,EAAA,EAAI,MAAM,UAAA,KAAe,CAAC,IAAI,EAAA,KAAO,UAAA,CAAW,UAAA,CAAW,EAAA,EAAI,EAAE,CAAA,CAAA;AAAA,IACjE,IAAI,IAAA,EAAM,YAAA,KAAiB,CAAC,CAAA,KAAM,UAAA,CAAW,aAAa,CAAW,CAAA;AAAA,GACvE;AACF;AAUO,SAAS,KAAA,CAAM,EAAA,EAAY,EAAA,EAAgB,IAAA,EAAkC;AAClF,EAAA,IAAI,IAAA,EAAM,MAAA,EAAQ,OAAA,EAAS,OAAO,IAAA;AAElC,EAAA,MAAM,EAAE,EAAA,EAAI,EAAA,EAAG,GAAI,cAAc,IAAI,CAAA;AACrC,EAAA,IAAI,KAAA,GAAQ,KAAA;AACZ,EAAA,IAAI,SAAA,GAAY,KAAA;AAEhB,EAAA,MAAM,MAAA,GAAS,GAAG,MAAM;AACtB,IAAA,KAAA,GAAQ,IAAA;AAER,IAAA,IAAI,SAAA,EAAW;AACf,IAAA,EAAA,EAAG;AAAA,EACL,GAAG,EAAE,CAAA;AAEL,EAAA,MAAM,SAAS,MAAM;AACnB,IAAA,IAAI,SAAS,SAAA,EAAW;AACxB,IAAA,SAAA,GAAY,IAAA;AACZ,IAAA,EAAA,CAAG,MAAM,CAAA;AAAA,EACX,CAAA;AAEA,EAAA,IAAI,MAAM,MAAA,EAAQ;AAChB,IAAA,IAAA,CAAK,OAAO,gBAAA,CAAiB,OAAA,EAAS,QAAQ,EAAE,IAAA,EAAM,MAAM,CAAA;AAAA,EAC9D;AAEA,EAAA,OAAO,MAAA,CAAO,MAAA,CAAO,EAAE,MAAA,EAAQ,CAAA;AACjC;AAgBO,SAAS,gBAAgB,QAAA,EAAoC;AAClE,EAAA,MAAM,OAAA,uBAAc,GAAA,EAAiB;AAErC,EAAA,MAAM,KAAA,GAAmB;AAAA,IACvB,KAAA,CAAM,EAAA,EAAI,EAAA,EAAI,IAAA,EAAM;AAClB,MAAA,MAAM,MAAA,GAAuB,EAAE,GAAG,QAAA,EAAU,GAAG,IAAA,EAAK;AAGpD,MAAA,MAAM,OAA8B,EAAC;AACrC,MAAA,MAAM,UAAU,MAAM;AACpB,QAAA,IAAI,IAAA,CAAK,GAAA,EAAK,OAAA,CAAQ,MAAA,CAAO,KAAK,GAAG,CAAA;AACrC,QAAA,EAAA,EAAG;AAAA,MACL,CAAA;AACA,MAAA,MAAM,KAAA,GAAQ,KAAA,CAAM,EAAA,EAAI,OAAA,EAAS,MAAM,CAAA;AACvC,MAAA,MAAM,MAAA,GAAsB,OAAO,MAAA,CAAO;AAAA,QACxC,MAAA,GAAS;AACP,UAAA,KAAA,CAAM,MAAA,EAAO;AACb,UAAA,IAAI,IAAA,CAAK,GAAA,EAAK,OAAA,CAAQ,MAAA,CAAO,KAAK,GAAG,CAAA;AAAA,QACvC;AAAA,OACD,CAAA;AACD,MAAA,IAAA,CAAK,GAAA,GAAM,MAAA;AACX,MAAA,OAAA,CAAQ,IAAI,MAAM,CAAA;AAClB,MAAA,OAAO,MAAA;AAAA,IACT,CAAA;AAAA,IACA,SAAA,GAAY;AACV,MAAA,KAAA,MAAW,CAAA,IAAK,OAAA,EAAS,CAAA,CAAE,MAAA,EAAO;AAClC,MAAA,OAAA,CAAQ,KAAA,EAAM;AAAA,IAChB,CAAA;AAAA,IACA,IAAI,IAAA,GAAO;AACT,MAAA,OAAO,OAAA,CAAQ,IAAA;AAAA,IACjB;AAAA,GACF;AACA,EAAA,OAAO,KAAA;AACT","file":"index.cjs","sourcesContent":["export type AfterHandle = Readonly<{\n cancel(): void;\n}>;\n\nexport type SetTimeoutFn = (fn: () => void, ms: number) => unknown;\nexport type ClearTimeoutFn = (handle: unknown) => void;\n\nexport type AfterOptions = Readonly<{\n /**\n * If supplied and aborted, the callback never runs and any pending timer is\n * cleared. Aborting after fire is a no-op.\n */\n signal?: AbortSignal;\n /**\n * Override `setTimeout` (testing, SSR, custom loops). Defaults to globalThis.\n */\n setTimeout?: SetTimeoutFn;\n /**\n * Override `clearTimeout`. Must match the `setTimeout` you injected.\n */\n clearTimeout?: ClearTimeoutFn;\n}>;\n\nconst NOOP: AfterHandle = Object.freeze({ cancel: () => {} });\n\nfunction resolveTimers(opts: AfterOptions | undefined): {\n st: SetTimeoutFn;\n ct: ClearTimeoutFn;\n} {\n return {\n st: opts?.setTimeout ?? ((fn, ms) => globalThis.setTimeout(fn, ms)),\n ct: opts?.clearTimeout ?? ((h) => globalThis.clearTimeout(h as number)),\n };\n}\n\n/**\n * Schedule `fn` to run after `ms` milliseconds. Returns a handle whose\n * `cancel()` clears the pending timer. Optional `signal` aborts the timer when\n * triggered. Aborting after the callback fires is a no-op.\n *\n * Per Node guidance, the abort listener is registered with `{ once: true }` to\n * avoid leaking listeners when the same signal is reused across many timers.\n */\nexport function after(ms: number, fn: () => void, opts?: AfterOptions): AfterHandle {\n if (opts?.signal?.aborted) return NOOP;\n\n const { st, ct } = resolveTimers(opts);\n let fired = false;\n let cancelled = false;\n\n const handle = st(() => {\n fired = true;\n /* v8 ignore next — defensive race guard: cancel() sets cancelled=true and clears the timer, but if a custom setTimeout fires after clear, this short-circuits fn(). */\n if (cancelled) return;\n fn();\n }, ms);\n\n const cancel = () => {\n if (fired || cancelled) return;\n cancelled = true;\n ct(handle);\n };\n\n if (opts?.signal) {\n opts.signal.addEventListener(\"abort\", cancel, { once: true });\n }\n\n return Object.freeze({ cancel });\n}\n\nexport type Scheduler = Readonly<{\n after(ms: number, fn: () => void, opts?: AfterOptions): AfterHandle;\n cancelAll(): void;\n readonly size: number;\n}>;\n\n/**\n * Build a scheduler that tracks every pending `after()` so they can be\n * cancelled together (e.g. on machine destroy). Each `after` returns a handle\n * whose `cancel()` also removes it from the tracking set.\n *\n * `defaults` are merged into every call — typically you inject `setTimeout` /\n * `clearTimeout` once at construction.\n */\nexport function createScheduler(defaults?: AfterOptions): Scheduler {\n const pending = new Set<AfterHandle>();\n\n const sched: Scheduler = {\n after(ms, fn, opts) {\n const merged: AfterOptions = { ...defaults, ...opts };\n // Forward-reference slot so `wrapped` can find the tracked handle\n // before it is constructed below.\n const slot: { ref?: AfterHandle } = {};\n const wrapped = () => {\n if (slot.ref) pending.delete(slot.ref);\n fn();\n };\n const inner = after(ms, wrapped, merged);\n const handle: AfterHandle = Object.freeze({\n cancel() {\n inner.cancel();\n if (slot.ref) pending.delete(slot.ref);\n },\n });\n slot.ref = handle;\n pending.add(handle);\n return handle;\n },\n cancelAll() {\n for (const h of pending) h.cancel();\n pending.clear();\n },\n get size() {\n return pending.size;\n },\n };\n return sched;\n}\n"]}
1
+ {"version":3,"sources":["../../src/timer/scheduler.ts"],"names":[],"mappings":"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","file":"index.cjs","sourcesContent":["export type AfterHandle = Readonly<{\n cancel(): void;\n}>;\n\nexport type SetTimeoutFn = (fn: () => void, ms: number) => unknown;\nexport type ClearTimeoutFn = (handle: unknown) => void;\n\nexport type AfterOptions = Readonly<{\n /**\n * If supplied and aborted, the callback never runs and any pending timer is\n * cleared. Aborting after fire is a no-op.\n */\n signal?: AbortSignal;\n /**\n * Override `setTimeout` (testing, SSR, custom loops). Defaults to globalThis.\n */\n setTimeout?: SetTimeoutFn;\n /**\n * Override `clearTimeout`. Must match the `setTimeout` you injected.\n */\n clearTimeout?: ClearTimeoutFn;\n}>;\n\nconst NOOP: AfterHandle = Object.freeze({ cancel: () => {} });\n\nfunction resolveTimers(opts: AfterOptions | undefined): {\n st: SetTimeoutFn;\n ct: ClearTimeoutFn;\n} {\n return {\n st: opts?.setTimeout ?? ((fn, ms) => globalThis.setTimeout(fn, ms)),\n ct: opts?.clearTimeout ?? ((h) => globalThis.clearTimeout(h as number)),\n };\n}\n\n/**\n * Schedule `fn` to run after `ms` milliseconds. Returns a handle whose\n * `cancel()` clears the pending timer. Optional `signal` aborts the timer when\n * triggered. Aborting after the callback fires is a no-op.\n *\n * The abort listener is registered with `{ once: true }` as a baseline, but\n * `{ once: true }` alone does NOT prevent listener accumulation when the same\n * signal is reused across many timers: it only removes the listener when the\n * signal aborts, not when the timer fires normally or `cancel()` is called.\n * We therefore explicitly call `signal.removeEventListener(\"abort\", cancel)`\n * inside the fire callback and at the end of `cancel()` so that a shared,\n * long-lived signal never accumulates dead listeners across timer reuse.\n */\nexport function after(ms: number, fn: () => void, opts?: AfterOptions): AfterHandle {\n if (opts?.signal?.aborted) return NOOP;\n\n const { st, ct } = resolveTimers(opts);\n let fired = false;\n let cancelled = false;\n // `cancel` and the timer handle reference each other. A const cell holds the\n // handle so `cancel` can be defined BEFORE `st(...)` runs (letting a custom\n // `st` that fires its callback synchronously reference `cancel` without\n // hitting the temporal-dead-zone) while still being able to clear the handle\n // assigned afterwards. A synchronous fire sets `fired=true`, so cancel() never\n // reads the still-unset handle in that path.\n const timer: { handle?: ReturnType<typeof st> } = {};\n\n const cancel = () => {\n if (fired || cancelled) return;\n cancelled = true;\n if (timer.handle !== undefined) ct(timer.handle);\n // Detach the abort listener so a reused signal does not accumulate dead\n // closures after this timer is cancelled.\n if (opts?.signal) opts.signal.removeEventListener(\"abort\", cancel);\n };\n\n timer.handle = st(() => {\n fired = true;\n /* v8 ignore next — defensive race guard: cancel() sets cancelled=true and clears the timer, but if a custom setTimeout fires after clear, this short-circuits fn(). */\n if (cancelled) return;\n // Detach the abort listener now that the timer has fired — the listener\n // will never be invoked and must not accumulate on a reused signal.\n if (opts?.signal) opts.signal.removeEventListener(\"abort\", cancel);\n fn();\n }, ms);\n\n // Attach only if the timer has not already fired synchronously (a custom `st`\n // may fire inline); otherwise the listener would be registered AFTER the fire\n // path's removal ran and would then leak until the signal aborts.\n if (opts?.signal && !fired) {\n opts.signal.addEventListener(\"abort\", cancel, { once: true });\n }\n\n return Object.freeze({ cancel });\n}\n\nexport type Scheduler = Readonly<{\n after(ms: number, fn: () => void, opts?: AfterOptions): AfterHandle;\n cancelAll(): void;\n readonly size: number;\n}>;\n\n/**\n * Build a scheduler that tracks every pending `after()` so they can be\n * cancelled together (e.g. on machine destroy). Each `after` returns a handle\n * whose `cancel()` also removes it from the tracking set.\n *\n * `defaults` are merged into every call — typically you inject `setTimeout` /\n * `clearTimeout` once at construction.\n */\nexport function createScheduler(defaults?: AfterOptions): Scheduler {\n const pending = new Set<AfterHandle>();\n\n const sched: Scheduler = {\n after(ms, fn, opts) {\n const merged: AfterOptions = { ...defaults, ...opts };\n // Forward-reference slot so `wrapped` can find the tracked handle\n // before it is constructed below.\n const slot: { ref?: AfterHandle } = {};\n const wrapped = () => {\n if (slot.ref) pending.delete(slot.ref);\n fn();\n };\n const inner = after(ms, wrapped, merged);\n const handle: AfterHandle = Object.freeze({\n cancel() {\n inner.cancel();\n if (slot.ref) pending.delete(slot.ref);\n },\n });\n slot.ref = handle;\n pending.add(handle);\n return handle;\n },\n cancelAll() {\n for (const h of pending) h.cancel();\n pending.clear();\n },\n get size() {\n return pending.size;\n },\n };\n return sched;\n}\n"]}
@@ -23,8 +23,13 @@ type AfterOptions = Readonly<{
23
23
  * `cancel()` clears the pending timer. Optional `signal` aborts the timer when
24
24
  * triggered. Aborting after the callback fires is a no-op.
25
25
  *
26
- * Per Node guidance, the abort listener is registered with `{ once: true }` to
27
- * avoid leaking listeners when the same signal is reused across many timers.
26
+ * The abort listener is registered with `{ once: true }` as a baseline, but
27
+ * `{ once: true }` alone does NOT prevent listener accumulation when the same
28
+ * signal is reused across many timers: it only removes the listener when the
29
+ * signal aborts, not when the timer fires normally or `cancel()` is called.
30
+ * We therefore explicitly call `signal.removeEventListener("abort", cancel)`
31
+ * inside the fire callback and at the end of `cancel()` so that a shared,
32
+ * long-lived signal never accumulates dead listeners across timer reuse.
28
33
  */
29
34
  declare function after(ms: number, fn: () => void, opts?: AfterOptions): AfterHandle;
30
35
  type Scheduler = Readonly<{
@@ -23,8 +23,13 @@ type AfterOptions = Readonly<{
23
23
  * `cancel()` clears the pending timer. Optional `signal` aborts the timer when
24
24
  * triggered. Aborting after the callback fires is a no-op.
25
25
  *
26
- * Per Node guidance, the abort listener is registered with `{ once: true }` to
27
- * avoid leaking listeners when the same signal is reused across many timers.
26
+ * The abort listener is registered with `{ once: true }` as a baseline, but
27
+ * `{ once: true }` alone does NOT prevent listener accumulation when the same
28
+ * signal is reused across many timers: it only removes the listener when the
29
+ * signal aborts, not when the timer fires normally or `cancel()` is called.
30
+ * We therefore explicitly call `signal.removeEventListener("abort", cancel)`
31
+ * inside the fire callback and at the end of `cancel()` so that a shared,
32
+ * long-lived signal never accumulates dead listeners across timer reuse.
28
33
  */
29
34
  declare function after(ms: number, fn: () => void, opts?: AfterOptions): AfterHandle;
30
35
  type Scheduler = Readonly<{
@@ -12,17 +12,20 @@ function after(ms, fn, opts) {
12
12
  const { st, ct } = resolveTimers(opts);
13
13
  let fired = false;
14
14
  let cancelled = false;
15
- const handle = st(() => {
16
- fired = true;
17
- if (cancelled) return;
18
- fn();
19
- }, ms);
15
+ const timer = {};
20
16
  const cancel = () => {
21
17
  if (fired || cancelled) return;
22
18
  cancelled = true;
23
- ct(handle);
19
+ if (timer.handle !== void 0) ct(timer.handle);
20
+ if (opts?.signal) opts.signal.removeEventListener("abort", cancel);
24
21
  };
25
- if (opts?.signal) {
22
+ timer.handle = st(() => {
23
+ fired = true;
24
+ if (cancelled) return;
25
+ if (opts?.signal) opts.signal.removeEventListener("abort", cancel);
26
+ fn();
27
+ }, ms);
28
+ if (opts?.signal && !fired) {
26
29
  opts.signal.addEventListener("abort", cancel, { once: true });
27
30
  }
28
31
  return Object.freeze({ cancel });
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/timer/scheduler.ts"],"names":[],"mappings":";AAuBA,IAAM,IAAA,GAAoB,MAAA,CAAO,MAAA,CAAO,EAAE,QAAQ,MAAM;AAAC,CAAA,EAAG,CAAA;AAE5D,SAAS,cAAc,IAAA,EAGrB;AACA,EAAA,OAAO;AAAA,IACL,EAAA,EAAI,MAAM,UAAA,KAAe,CAAC,IAAI,EAAA,KAAO,UAAA,CAAW,UAAA,CAAW,EAAA,EAAI,EAAE,CAAA,CAAA;AAAA,IACjE,IAAI,IAAA,EAAM,YAAA,KAAiB,CAAC,CAAA,KAAM,UAAA,CAAW,aAAa,CAAW,CAAA;AAAA,GACvE;AACF;AAUO,SAAS,KAAA,CAAM,EAAA,EAAY,EAAA,EAAgB,IAAA,EAAkC;AAClF,EAAA,IAAI,IAAA,EAAM,MAAA,EAAQ,OAAA,EAAS,OAAO,IAAA;AAElC,EAAA,MAAM,EAAE,EAAA,EAAI,EAAA,EAAG,GAAI,cAAc,IAAI,CAAA;AACrC,EAAA,IAAI,KAAA,GAAQ,KAAA;AACZ,EAAA,IAAI,SAAA,GAAY,KAAA;AAEhB,EAAA,MAAM,MAAA,GAAS,GAAG,MAAM;AACtB,IAAA,KAAA,GAAQ,IAAA;AAER,IAAA,IAAI,SAAA,EAAW;AACf,IAAA,EAAA,EAAG;AAAA,EACL,GAAG,EAAE,CAAA;AAEL,EAAA,MAAM,SAAS,MAAM;AACnB,IAAA,IAAI,SAAS,SAAA,EAAW;AACxB,IAAA,SAAA,GAAY,IAAA;AACZ,IAAA,EAAA,CAAG,MAAM,CAAA;AAAA,EACX,CAAA;AAEA,EAAA,IAAI,MAAM,MAAA,EAAQ;AAChB,IAAA,IAAA,CAAK,OAAO,gBAAA,CAAiB,OAAA,EAAS,QAAQ,EAAE,IAAA,EAAM,MAAM,CAAA;AAAA,EAC9D;AAEA,EAAA,OAAO,MAAA,CAAO,MAAA,CAAO,EAAE,MAAA,EAAQ,CAAA;AACjC;AAgBO,SAAS,gBAAgB,QAAA,EAAoC;AAClE,EAAA,MAAM,OAAA,uBAAc,GAAA,EAAiB;AAErC,EAAA,MAAM,KAAA,GAAmB;AAAA,IACvB,KAAA,CAAM,EAAA,EAAI,EAAA,EAAI,IAAA,EAAM;AAClB,MAAA,MAAM,MAAA,GAAuB,EAAE,GAAG,QAAA,EAAU,GAAG,IAAA,EAAK;AAGpD,MAAA,MAAM,OAA8B,EAAC;AACrC,MAAA,MAAM,UAAU,MAAM;AACpB,QAAA,IAAI,IAAA,CAAK,GAAA,EAAK,OAAA,CAAQ,MAAA,CAAO,KAAK,GAAG,CAAA;AACrC,QAAA,EAAA,EAAG;AAAA,MACL,CAAA;AACA,MAAA,MAAM,KAAA,GAAQ,KAAA,CAAM,EAAA,EAAI,OAAA,EAAS,MAAM,CAAA;AACvC,MAAA,MAAM,MAAA,GAAsB,OAAO,MAAA,CAAO;AAAA,QACxC,MAAA,GAAS;AACP,UAAA,KAAA,CAAM,MAAA,EAAO;AACb,UAAA,IAAI,IAAA,CAAK,GAAA,EAAK,OAAA,CAAQ,MAAA,CAAO,KAAK,GAAG,CAAA;AAAA,QACvC;AAAA,OACD,CAAA;AACD,MAAA,IAAA,CAAK,GAAA,GAAM,MAAA;AACX,MAAA,OAAA,CAAQ,IAAI,MAAM,CAAA;AAClB,MAAA,OAAO,MAAA;AAAA,IACT,CAAA;AAAA,IACA,SAAA,GAAY;AACV,MAAA,KAAA,MAAW,CAAA,IAAK,OAAA,EAAS,CAAA,CAAE,MAAA,EAAO;AAClC,MAAA,OAAA,CAAQ,KAAA,EAAM;AAAA,IAChB,CAAA;AAAA,IACA,IAAI,IAAA,GAAO;AACT,MAAA,OAAO,OAAA,CAAQ,IAAA;AAAA,IACjB;AAAA,GACF;AACA,EAAA,OAAO,KAAA;AACT","file":"index.js","sourcesContent":["export type AfterHandle = Readonly<{\n cancel(): void;\n}>;\n\nexport type SetTimeoutFn = (fn: () => void, ms: number) => unknown;\nexport type ClearTimeoutFn = (handle: unknown) => void;\n\nexport type AfterOptions = Readonly<{\n /**\n * If supplied and aborted, the callback never runs and any pending timer is\n * cleared. Aborting after fire is a no-op.\n */\n signal?: AbortSignal;\n /**\n * Override `setTimeout` (testing, SSR, custom loops). Defaults to globalThis.\n */\n setTimeout?: SetTimeoutFn;\n /**\n * Override `clearTimeout`. Must match the `setTimeout` you injected.\n */\n clearTimeout?: ClearTimeoutFn;\n}>;\n\nconst NOOP: AfterHandle = Object.freeze({ cancel: () => {} });\n\nfunction resolveTimers(opts: AfterOptions | undefined): {\n st: SetTimeoutFn;\n ct: ClearTimeoutFn;\n} {\n return {\n st: opts?.setTimeout ?? ((fn, ms) => globalThis.setTimeout(fn, ms)),\n ct: opts?.clearTimeout ?? ((h) => globalThis.clearTimeout(h as number)),\n };\n}\n\n/**\n * Schedule `fn` to run after `ms` milliseconds. Returns a handle whose\n * `cancel()` clears the pending timer. Optional `signal` aborts the timer when\n * triggered. Aborting after the callback fires is a no-op.\n *\n * Per Node guidance, the abort listener is registered with `{ once: true }` to\n * avoid leaking listeners when the same signal is reused across many timers.\n */\nexport function after(ms: number, fn: () => void, opts?: AfterOptions): AfterHandle {\n if (opts?.signal?.aborted) return NOOP;\n\n const { st, ct } = resolveTimers(opts);\n let fired = false;\n let cancelled = false;\n\n const handle = st(() => {\n fired = true;\n /* v8 ignore next — defensive race guard: cancel() sets cancelled=true and clears the timer, but if a custom setTimeout fires after clear, this short-circuits fn(). */\n if (cancelled) return;\n fn();\n }, ms);\n\n const cancel = () => {\n if (fired || cancelled) return;\n cancelled = true;\n ct(handle);\n };\n\n if (opts?.signal) {\n opts.signal.addEventListener(\"abort\", cancel, { once: true });\n }\n\n return Object.freeze({ cancel });\n}\n\nexport type Scheduler = Readonly<{\n after(ms: number, fn: () => void, opts?: AfterOptions): AfterHandle;\n cancelAll(): void;\n readonly size: number;\n}>;\n\n/**\n * Build a scheduler that tracks every pending `after()` so they can be\n * cancelled together (e.g. on machine destroy). Each `after` returns a handle\n * whose `cancel()` also removes it from the tracking set.\n *\n * `defaults` are merged into every call — typically you inject `setTimeout` /\n * `clearTimeout` once at construction.\n */\nexport function createScheduler(defaults?: AfterOptions): Scheduler {\n const pending = new Set<AfterHandle>();\n\n const sched: Scheduler = {\n after(ms, fn, opts) {\n const merged: AfterOptions = { ...defaults, ...opts };\n // Forward-reference slot so `wrapped` can find the tracked handle\n // before it is constructed below.\n const slot: { ref?: AfterHandle } = {};\n const wrapped = () => {\n if (slot.ref) pending.delete(slot.ref);\n fn();\n };\n const inner = after(ms, wrapped, merged);\n const handle: AfterHandle = Object.freeze({\n cancel() {\n inner.cancel();\n if (slot.ref) pending.delete(slot.ref);\n },\n });\n slot.ref = handle;\n pending.add(handle);\n return handle;\n },\n cancelAll() {\n for (const h of pending) h.cancel();\n pending.clear();\n },\n get size() {\n return pending.size;\n },\n };\n return sched;\n}\n"]}
1
+ {"version":3,"sources":["../../src/timer/scheduler.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["export type AfterHandle = Readonly<{\n cancel(): void;\n}>;\n\nexport type SetTimeoutFn = (fn: () => void, ms: number) => unknown;\nexport type ClearTimeoutFn = (handle: unknown) => void;\n\nexport type AfterOptions = Readonly<{\n /**\n * If supplied and aborted, the callback never runs and any pending timer is\n * cleared. Aborting after fire is a no-op.\n */\n signal?: AbortSignal;\n /**\n * Override `setTimeout` (testing, SSR, custom loops). Defaults to globalThis.\n */\n setTimeout?: SetTimeoutFn;\n /**\n * Override `clearTimeout`. Must match the `setTimeout` you injected.\n */\n clearTimeout?: ClearTimeoutFn;\n}>;\n\nconst NOOP: AfterHandle = Object.freeze({ cancel: () => {} });\n\nfunction resolveTimers(opts: AfterOptions | undefined): {\n st: SetTimeoutFn;\n ct: ClearTimeoutFn;\n} {\n return {\n st: opts?.setTimeout ?? ((fn, ms) => globalThis.setTimeout(fn, ms)),\n ct: opts?.clearTimeout ?? ((h) => globalThis.clearTimeout(h as number)),\n };\n}\n\n/**\n * Schedule `fn` to run after `ms` milliseconds. Returns a handle whose\n * `cancel()` clears the pending timer. Optional `signal` aborts the timer when\n * triggered. Aborting after the callback fires is a no-op.\n *\n * The abort listener is registered with `{ once: true }` as a baseline, but\n * `{ once: true }` alone does NOT prevent listener accumulation when the same\n * signal is reused across many timers: it only removes the listener when the\n * signal aborts, not when the timer fires normally or `cancel()` is called.\n * We therefore explicitly call `signal.removeEventListener(\"abort\", cancel)`\n * inside the fire callback and at the end of `cancel()` so that a shared,\n * long-lived signal never accumulates dead listeners across timer reuse.\n */\nexport function after(ms: number, fn: () => void, opts?: AfterOptions): AfterHandle {\n if (opts?.signal?.aborted) return NOOP;\n\n const { st, ct } = resolveTimers(opts);\n let fired = false;\n let cancelled = false;\n // `cancel` and the timer handle reference each other. A const cell holds the\n // handle so `cancel` can be defined BEFORE `st(...)` runs (letting a custom\n // `st` that fires its callback synchronously reference `cancel` without\n // hitting the temporal-dead-zone) while still being able to clear the handle\n // assigned afterwards. A synchronous fire sets `fired=true`, so cancel() never\n // reads the still-unset handle in that path.\n const timer: { handle?: ReturnType<typeof st> } = {};\n\n const cancel = () => {\n if (fired || cancelled) return;\n cancelled = true;\n if (timer.handle !== undefined) ct(timer.handle);\n // Detach the abort listener so a reused signal does not accumulate dead\n // closures after this timer is cancelled.\n if (opts?.signal) opts.signal.removeEventListener(\"abort\", cancel);\n };\n\n timer.handle = st(() => {\n fired = true;\n /* v8 ignore next — defensive race guard: cancel() sets cancelled=true and clears the timer, but if a custom setTimeout fires after clear, this short-circuits fn(). */\n if (cancelled) return;\n // Detach the abort listener now that the timer has fired — the listener\n // will never be invoked and must not accumulate on a reused signal.\n if (opts?.signal) opts.signal.removeEventListener(\"abort\", cancel);\n fn();\n }, ms);\n\n // Attach only if the timer has not already fired synchronously (a custom `st`\n // may fire inline); otherwise the listener would be registered AFTER the fire\n // path's removal ran and would then leak until the signal aborts.\n if (opts?.signal && !fired) {\n opts.signal.addEventListener(\"abort\", cancel, { once: true });\n }\n\n return Object.freeze({ cancel });\n}\n\nexport type Scheduler = Readonly<{\n after(ms: number, fn: () => void, opts?: AfterOptions): AfterHandle;\n cancelAll(): void;\n readonly size: number;\n}>;\n\n/**\n * Build a scheduler that tracks every pending `after()` so they can be\n * cancelled together (e.g. on machine destroy). Each `after` returns a handle\n * whose `cancel()` also removes it from the tracking set.\n *\n * `defaults` are merged into every call — typically you inject `setTimeout` /\n * `clearTimeout` once at construction.\n */\nexport function createScheduler(defaults?: AfterOptions): Scheduler {\n const pending = new Set<AfterHandle>();\n\n const sched: Scheduler = {\n after(ms, fn, opts) {\n const merged: AfterOptions = { ...defaults, ...opts };\n // Forward-reference slot so `wrapped` can find the tracked handle\n // before it is constructed below.\n const slot: { ref?: AfterHandle } = {};\n const wrapped = () => {\n if (slot.ref) pending.delete(slot.ref);\n fn();\n };\n const inner = after(ms, wrapped, merged);\n const handle: AfterHandle = Object.freeze({\n cancel() {\n inner.cancel();\n if (slot.ref) pending.delete(slot.ref);\n },\n });\n slot.ref = handle;\n pending.add(handle);\n return handle;\n },\n cancelAll() {\n for (const h of pending) h.cancel();\n pending.clear();\n },\n get size() {\n return pending.size;\n },\n };\n return sched;\n}\n"]}
package/llms-full.txt CHANGED
@@ -220,7 +220,7 @@ function step<C, E, S>(
220
220
  ): { snapshot: Snapshot<C, S>; effects: readonly Effect[] };
221
221
  ```
222
222
 
223
- **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.
223
+ **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.
224
224
 
225
225
  ### `assign(updater)`
226
226
 
@@ -279,7 +279,7 @@ const runtime = createRuntime(def, impl, {
279
279
  });
280
280
  ```
281
281
 
282
- 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.
282
+ 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.
283
283
 
284
284
  ### `aifsmjs/replay` — Pure event log replay
285
285
 
@@ -298,14 +298,25 @@ Never dispatches effects. For PBT, time-travel debugging, and incident reproduct
298
298
 
299
299
  ```typescript
300
300
  import fc from "fast-check";
301
- import { commandsFromMachine, properties } from "aifsmjs/pbt";
301
+ import { createRuntime } from "aifsmjs";
302
+ import { commandsFromMachine, initialModel, properties } from "aifsmjs/pbt";
303
+
304
+ // Use one of the six built-in generic properties, or assertAll for all at once:
305
+ properties.replayEqualsFold(def, impl, {
306
+ NEXT: fc.constant({ type: "NEXT" as const }),
307
+ });
302
308
 
309
+ // Or build a custom property using commandsFromMachine:
303
310
  fc.assert(
304
311
  fc.property(
305
312
  commandsFromMachine(def, impl, {
306
313
  NEXT: fc.constant({ type: "NEXT" as const }),
307
314
  }),
308
- (cmds) => properties.runDeterministic(def, impl, cmds),
315
+ (cmds) => {
316
+ const real = createRuntime(def, impl, { dispatchEffects: false });
317
+ fc.modelRun(() => ({ model: initialModel(def), real }), cmds);
318
+ return true;
319
+ },
309
320
  ),
310
321
  );
311
322
  ```
@@ -543,6 +554,41 @@ All notable changes to this project will be documented in this file.
543
554
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
544
555
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
545
556
 
557
+ ## [Unreleased]
558
+
559
+ ## [0.5.2] - 2026-06-05
560
+
561
+ ### Docs
562
+
563
+ - Review-driven documentation fixes (`README.md`, `README_ZHTW.md`, `llms-full.txt`): clarity and accuracy from a cross-package code review. No runtime or API change; `dist` byte-identical to 0.5.1.
564
+
565
+ ## [0.5.1] - 2026-06-02
566
+
567
+ ### Fixed
568
+
569
+ - **Memory: `after()` and `createScheduler().after()` accumulated dead abort
570
+ listeners on a reused `AbortSignal`.** `{ once: true }` only removes the
571
+ listener when the signal fires — not when the timer fires normally or
572
+ `cancel()` is called. Scheduling many timers on one long-lived signal
573
+ therefore accumulated dead `"abort"` closures. The fix explicitly calls
574
+ `signal.removeEventListener("abort", cancel)` inside the fire callback (so
575
+ a fired timer detaches immediately) and at the end of `cancel()` (so a
576
+ cancelled timer also detaches). Same class of leak as the [0.1.2] and
577
+ [0.3.1] abort-listener fixes; the timer subpath was the remaining gap.
578
+ Four regression tests added to `test/timer/scheduler.test.ts` covering
579
+ both `after()` and `createScheduler().after()`, fire and cancel paths.
580
+ (`src/timer/scheduler.ts`)
581
+
582
+ ### Changed
583
+
584
+ - **`fast-check` peer dependency range extended to `^3.20.0 || ^4.0.0`.**
585
+ The ai\*js family standard is fast-check v4.8; consumers who have already
586
+ upgraded to v4 no longer need to suppress a peer warning. The devDependency
587
+ is pinned to `^4.8.0` so CI runs against v4. Consumers who remain on v3
588
+ are fully unaffected — the `||` range keeps v3 satisfied. The `aifsmjs/pbt`
589
+ subpath is the only entry point that imports fast-check; the core and all
590
+ other subpaths are tree-shake-free of it.
591
+
546
592
  ## [0.4.1] — 2026-05-29
547
593
 
548
594
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aifsmjs",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "Small, strict FSM library for deterministic, replayable state machines in any TypeScript/JS app — multi-step forms, checkout funnels, auth flows, tutorials, scene flow. Pure step() lifecycle, opt-in effects, inspect, replay, and a fast-check property-based testing adapter. Browser / Node / Bun / Deno / WebView / Worker friendly.",
5
5
  "keywords": [
6
6
  "fsm",
@@ -97,7 +97,7 @@
97
97
  "prepublishOnly": "pnpm typecheck && pnpm lint && pnpm coverage && pnpm build && pnpm verify:exports && pnpm verify:llms && pnpm check:size"
98
98
  },
99
99
  "peerDependencies": {
100
- "fast-check": "^3.20.0"
100
+ "fast-check": "^3.20.0 || ^4.0.0"
101
101
  },
102
102
  "peerDependenciesMeta": {
103
103
  "fast-check": {
@@ -108,7 +108,7 @@
108
108
  "@biomejs/biome": "^1.9.0",
109
109
  "@types/node": "^22.0.0",
110
110
  "@vitest/coverage-v8": "^4.1.7",
111
- "fast-check": "^3.20.0",
111
+ "fast-check": "^4.8.0",
112
112
  "tsup": "^8.3.0",
113
113
  "tsx": "^4.22.3",
114
114
  "typescript": "^5.6.0",