@directive-run/mutator 0.3.0 → 0.3.1

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/CHANGELOG.md CHANGED
@@ -1,54 +1,98 @@
1
1
  # @directive-run/mutator changelog
2
2
 
3
- ## 0.3.0
4
-
5
- ### Minor Changes
3
+ ## 0.3.1
6
4
 
7
- - [`02d80c4`](https://github.com/directive-run/directive/commit/02d80c427c3c6b989765dcd99aa51d1aa3770b8b) Thanks [@jasoncomes](https://github.com/jasoncomes)! - AE-review R2 fixes: critical security, correctness, and DX hardening across R1.A/B/C surfaces
5
+ ### Patch Changes
8
6
 
9
- Three parallel reviewers (security, architecture, DX, innovation) found 3 critical security + 2 critical architecture + 3 critical DX + ~20 major issues across the recently-shipped R1.A (timeline serialize/replay), R1.B (causal-graph matchers), and R1.C (cancellable mutator HOC) surfaces. This release closes the criticals and the highest-leverage majors.
7
+ - [`93cd8b8`](https://github.com/directive-run/directive/commit/93cd8b804c79ae3f08a52d9848312faf135f2cf5) Thanks [@jasoncomes](https://github.com/jasoncomes)! - Docs UX reconciliation: AI tooling becomes a first-class install path
8
+ and Directive's coding knowledge ships from one source-of-truth to
9
+ every assistant.
10
+
11
+ A new `/docs/ide-integration` page at directive.run is the canonical
12
+ decision tree across Claude Code, Cursor, GitHub Copilot, Windsurf,
13
+ Cline, OpenAI Codex, and the programmatic `@directive-run/knowledge`
14
+ API. The docs sidebar gets an "AI Tooling" section as item #2 between
15
+ Getting Started and Core API, surfacing the integration path
16
+ alongside the core learning journey. The `/llms.txt` route gains an
17
+ "Install paths for your AI assistant" block so LLM agents crawling
18
+ the docs at runtime learn how a downstream developer would install
19
+ the same knowledge they're consuming.
20
+
21
+ The Claude Code install path becomes real: a `.claude-plugin/
22
+ marketplace.json` is now committed to the directive monorepo root —
23
+ previously gitignored, which is why `/plugin marketplace add
24
+ directive-run/directive` returned 404 from GitHub. Users can now run
25
+ the two-step install the claude-plugin README has been documenting:
10
26
 
11
- ### Timeline (patch — surface-compatible fixes)
27
+ ```
28
+ /plugin marketplace add directive-run/directive
29
+ /plugin install directive@directive-plugins
30
+ ```
12
31
 
13
- - **R2 sec C-1: Spread-order RCE in `reconstructDispatch`.** `{ type: "MUTATE", ...next }` let an attacker-controlled `frames[i].event.next.type` field override the dispatch type. Untrusted prod-error JSON could re-route every replayed event to an arbitrary handler. Fix: spread-then-set (`{ ...next, type: "MUTATE" }`).
14
- - **R2 sec C-2: Frame-shape validation in `deserializeTimeline`.** Per-frame validation of `ts`/`event`/`event.type`. Untrusted JSON with malformed frames now produces precise `TypeError` rejections instead of crashing the replay loop with bare exceptions.
15
- - **R2 sec C-3: Matcher iteration robustness.** All five matchers now filter `frames()` through `isWellFormedFrame` before iterating. Hostile input produces clean assertion failures instead of TypeErrors.
16
- - **R2 sec M-2: Structural equality in `toReachInMs`.** Replaced `JSON.stringify` equality with `structuredEqual` NaN/undefined/Infinity no longer produce false-positive matches.
17
- - **R2 sec M-4: `maxFrames` cap on `replayTimeline`.** Default 100,000 frames; prevents unbounded synchronous loops on hostile JSON dumps.
18
- - **R2 arch M-2: `replayTimeline` returns `ReplayResult`** (`{ dispatched, skipped, truncated }`) instead of `void`. Lets callers verify the replay actually re-dispatched events instead of silently no-op'ing on non-mutator systems. Breaking change vs v0.2 only in type signature; existing call sites that ignored the return value continue to work.
19
- - **R2 DX naming: `dispatchableOnly?: boolean`** is the new option name; `dispatchable?` is kept as a deprecated alias for v0.x compatibility. The original name read backwards ("dispatchable: true" sounded like "this thing IS dispatchable" not "filter to dispatchable").
32
+ Every published adapter README (query, mutator, optimistic, timeline,
33
+ el, cli, vite-plugin-api-proxy) gains two new sections: a "Composes
34
+ with" footer linking the sibling packages it commonly composes with
35
+ (fixes the nav-orphan gap from R7 query had no links to mutator /
36
+ optimistic / timeline despite being designed to compose), and a "Use
37
+ this package with your AI assistant" hook tied to that package's
38
+ value prop. Each knowledge `.md` file in `@directive-run/knowledge`
39
+ gains a one-line top-of-file breadcrumb naming the package(s) it
40
+ documents, so a developer or LLM reading any file in isolation knows
41
+ immediately which import to use.
42
+
43
+ The top-level monorepo README gains an "AI tooling" section between
44
+ the existing AI Guardrails and React sections. The
45
+ `@directive-run/knowledge` README is restructured so consumer
46
+ pathways (plugin / CLI / programmatic / llms.txt) lead, instead of
47
+ the programmatic API which previously dominated above the fold.
48
+
49
+ Strategic FYIs for the v1.15 release notes — these are NOT shipping
50
+ in v1.15 but are explicitly tracked:
51
+
52
+ - `@directive-run/claude-plugin` npm publication is under evaluation;
53
+ the plugin stays Claude Code marketplace-only for v1.15.
54
+ - See-also cross-link footers across the 25 knowledge files are on
55
+ the v1.16 roadmap.
56
+ - MCP SSE server (`mcp.directive.run`) for live agent retrieval is on
57
+ the v1.16 roadmap.
58
+
59
+ No code changes; no API changes; this is the docs UX reconciliation
60
+ that makes v1.15's AI tooling story discoverable.
20
61
 
21
- ### Mutator (minor — additive Error subclasses)
62
+ ## 0.3.0
22
63
 
23
- - **R2 sec M-1: `CancelError` Error-subclass for `signal.reason`.** New runtime carriers `CancelError`, `TimeoutCancelError`, `SupersededCancelError` ensure `signal.reason instanceof Error` checks succeed downstream. Plain-object reasons silently failed `fetch(url, {signal})` re-throw paths and `.catch(err => err instanceof Error)` filters in logging frameworks. The `CancelReason` type still works (Error subclasses expose the same `kind` field), so existing `signal.reason?.kind === 'superseded'` checks remain valid.
24
- - **R2 arch M-5: Exported `cancelReason` factory** — `cancelReason.superseded()` and `cancelReason.timeout(afterMs)` produce typed Error subclasses. Single source of truth for both producers (cancellable internals) and consumers (handler abort observers).
25
- - **R2 sec M-3: `cancelTimeout` cleanup error-shadowing fix.** A throwing `setTimeout`-cancel-handle (e.g. a hostile virtual clock) no longer replaces the original handler's exception. The cleanup is wrapped in try/catch.
26
- - **R2 arch M-6: Peer dep tightened to `@directive-run/core@^1.3.0`.** `cancellable()`'s ergonomic test path imports `virtualClock` from core 1.3.0; consumers on 1.2.x would have hit a runtime error copying the README example.
64
+ ### Minor Changes
27
65
 
28
- ### Innovation captures
66
+ - [`02d80c4`](https://github.com/directive-run/directive/commit/02d80c427c3c6b989765dcd99aa51d1aa3770b8b) Thanks [@jasoncomes](https://github.com/jasoncomes)! - Security, correctness, and DX hardening for timeline replay, matchers, and the cancellable mutator HOC.
29
67
 
30
- `docs/IDEAS.md` updated with five new R2.A-E candidates surfaced post-ship — second-order ideas that ONLY became cheap to build because R1.A+B+C shipped together. Top pick: R2.A `directive bisect <good.json> <bad.json>` (2 days, BUILD CANDIDATE), git-bisect for timelines.
68
+ ### Timeline (surface-compatible fixes)
31
69
 
32
- ### Verification
70
+ - **Spread-order RCE in `reconstructDispatch`.** `{ type: "MUTATE", ...next }` let an attacker-controlled `frames[i].event.next.type` field override the dispatch type. Untrusted production-error JSON could re-route every replayed event to an arbitrary handler. Fix: spread-then-set (`{ ...next, type: "MUTATE" }`).
71
+ - **Frame-shape validation in `deserializeTimeline`.** Per-frame validation of `ts`/`event`/`event.type`. Untrusted JSON with malformed frames now produces precise `TypeError` rejections instead of crashing the replay loop with bare exceptions.
72
+ - **Matcher iteration robustness.** All five matchers now filter `frames()` through `isWellFormedFrame` before iterating. Hostile input produces clean assertion failures instead of TypeErrors.
73
+ - **Structural equality in `toReachInMs`.** Replaced `JSON.stringify` equality with `structuredEqual` – NaN/undefined/Infinity no longer produce false-positive matches.
74
+ - **`maxFrames` cap on `replayTimeline`.** Default 100,000 frames; prevents unbounded synchronous loops on hostile JSON dumps.
75
+ - **`replayTimeline` returns `ReplayResult`** (`{ dispatched, skipped, truncated }`) instead of `void`. Lets callers verify the replay actually re-dispatched events instead of silently no-op'ing on non-mutator systems. Breaking change vs v0.2 only in type signature; existing call sites that ignored the return value continue to work.
76
+ - **`dispatchableOnly?: boolean`** is the new option name; `dispatchable?` is kept as a deprecated alias for v0.x compatibility. The original name read backwards ("dispatchable: true" sounded like "this thing IS dispatchable" not "filter to dispatchable").
33
77
 
34
- - 4032 / 4033 tests pass workspace-wide (1 skipped, 0 failures).
35
- - Per-package: timeline 30 / 30 (16 timeline + 14 matchers); mutator 30 / 30 (16 mutator + 14 cancellable, including new R2 regression tests for `CancelError` instance checks + finally-block error-shadowing).
36
- - `pnpm -r --filter './packages/*' typecheck`: clean.
37
- - `pnpm -r --filter './packages/*' build`: clean.
78
+ ### Mutator (additive Error subclasses)
38
79
 
39
- The Round 1 AE-review-loop typically converges in 3-5 rounds. R2 ships these critical+major fixes; R3 (verification round) is the next session if you want full convergence to "0 critical + 0 major."
80
+ - **`CancelError` Error subclass for `signal.reason`.** New runtime carriers `CancelError`, `TimeoutCancelError`, `SupersededCancelError` ensure `signal.reason instanceof Error` checks succeed downstream. Plain-object reasons silently failed `fetch(url, {signal})` re-throw paths and `.catch(err => err instanceof Error)` filters in logging frameworks. The `CancelReason` type still works (Error subclasses expose the same `kind` field), so existing `signal.reason?.kind === 'superseded'` checks remain valid.
81
+ - **Exported `cancelReason` factory** – `cancelReason.superseded()` and `cancelReason.timeout(afterMs)` produce typed Error subclasses. Single source of truth for both producers (cancellable internals) and consumers (handler abort observers).
82
+ - **`cancelTimeout` cleanup error-shadowing fix.** A throwing `setTimeout`-cancel-handle (e.g. a hostile virtual clock) no longer replaces the original handler's exception. The cleanup is wrapped in try/catch.
83
+ - **Peer dep tightened to `@directive-run/core@^1.3.0`.** `cancellable()`'s ergonomic test path imports `virtualClock` from core 1.3.0; consumers on 1.2.x would have hit a runtime error copying the README example.
40
84
 
41
- - [`f70bd70`](https://github.com/directive-run/directive/commit/f70bd70071d2bc2fab5af6b6866f8e7c6ce559b1) Thanks [@jasoncomes](https://github.com/jasoncomes)! - R2.B: `recordReplayable()` HOC structured cancellation events for replay-aware mutations
85
+ - [`f70bd70`](https://github.com/directive-run/directive/commit/f70bd70071d2bc2fab5af6b6866f8e7c6ce559b1) Thanks [@jasoncomes](https://github.com/jasoncomes)! - `recordReplayable()` HOC structured cancellation events for replay-aware mutations
42
86
 
43
87
  Wraps a mutator handler with the same supersession + timeout semantics as `cancellable()`, plus a synchronous `onCancel` callback that fires the moment the AbortController calls `abort()`. The callback receives a `CancelEvent<F, P>` carrying:
44
88
 
45
89
  - `kind: 'superseded' | 'timeout'`
46
90
  - `afterMs?: number` (timeout only)
47
- - `payload: P` the dispatch that did NOT complete
48
- - `dispatchSeq: number` per-handler monotonic counter
49
- - `facts: F` live facts reference
91
+ - `payload: P` the dispatch that did NOT complete
92
+ - `dispatchSeq: number` per-handler monotonic counter
93
+ - `facts: F` live facts reference
50
94
 
51
- Use `onCancel` to pin cancellations into a place that survives in the timeline (typically a facts array). Without that, a replay re-dispatches the same MUTATE events but has no record of which were superseded vs which completed so timeline diff/bisect tools cannot reason about cancellations without parsing free-form error strings.
95
+ Use `onCancel` to pin cancellations into a place that survives in the timeline (typically a facts array). Without that, a replay re-dispatches the same MUTATE events but has no record of which were superseded vs which completed so timeline diff/bisect tools cannot reason about cancellations without parsing free-form error strings.
52
96
 
53
97
  ```ts
54
98
  import { defineMutator, recordReplayable } from "@directive-run/mutator";
@@ -72,45 +116,43 @@
72
116
  );
73
117
  ```
74
118
 
75
- Implementation note: `recordReplayable()` is `cancellable(opts, innerHandler)` where `innerHandler` adds a `signal.addEventListener('abort')` around the user's handler. Timeout / supersession semantics are EXACTLY those of `cancellable()` the HOC is purely additive. The abort listener fires synchronously, BEFORE the handler's pending await rejects with AbortError, so the callback sees the freshest possible state.
119
+ Implementation note: `recordReplayable()` is `cancellable(opts, innerHandler)` where `innerHandler` adds a `signal.addEventListener('abort')` around the user's handler. Timeout / supersession semantics are EXACTLY those of `cancellable()` the HOC is purely additive. The abort listener fires synchronously, BEFORE the handler's pending await rejects with AbortError, so the callback sees the freshest possible state.
76
120
 
77
- `onCancel` errors are caught and swallowed the abort path stays clean.
121
+ `onCancel` errors are caught and swallowed the abort path stays clean.
78
122
 
79
123
  9 new tests covering: clean run no-op, supersession callback delivery, dispatchSeq monotonic per HOC, two HOCs maintain independent counters, timeout callback delivery with afterMs preserved, onCancel-throw robustness, non-CancelError abort filter, CancelError class hierarchy.
80
124
 
81
125
  ### Patch Changes
82
126
 
83
- - [`0d8cae5`](https://github.com/directive-run/directive/commit/0d8cae57e7e9b28ecb64e98588458a264dbd06c1) Thanks [@jasoncomes](https://github.com/jasoncomes)! - R5 hardening pack production-readiness pass on the R2 ship
127
+ - [`0d8cae5`](https://github.com/directive-run/directive/commit/0d8cae57e7e9b28ecb64e98588458a264dbd06c1) Thanks [@jasoncomes](https://github.com/jasoncomes)! - Production-readiness pass: better docs, cleaner types, and consistent semantics.
84
128
 
85
- After the R5 AE-review-loop closed criticals, this pack lands the load-bearing DX/Arch findings so the substrate is ready for production use. No new commands; existing surfaces gain better docs, cleaner types, and consistent semantics.
129
+ No new commands; existing surfaces gain better docs, cleaner types, and consistent semantics.
86
130
 
87
- **Documentation (R5 DX C3):**
131
+ **Documentation:**
88
132
 
89
- - `@directive-run/timeline` README replaces the outdated "v0.4 diff mode (deferred)" Roadmap with shipped reality. New "Serialize, replay, bisect, diff" section walks all four operational entry points end-to-end with library + CLI examples for each.
90
- - `@directive-run/cli` README adds full sections for `directive replay`, `directive bisect` (with security note for `--assert`), and `directive timeline diff` (with exit-code documentation).
91
- - `@directive-run/mutator` README new "Recording cancellations for replay" section covers `recordReplayable()` end-to-end.
133
+ - `@directive-run/timeline` README replaces the outdated "v0.4 diff mode (deferred)" roadmap with shipped reality. New "Serialize, replay, bisect, diff" section walks all four operational entry points end-to-end with library and CLI examples for each.
134
+ - `@directive-run/cli` README adds full sections for `directive replay`, `directive bisect` (with a security note for `--assert`), and `directive timeline diff` (with exit-code documentation).
135
+ - `@directive-run/mutator` README new "Recording cancellations for replay" section covers `recordReplayable()` end-to-end.
92
136
 
93
- **Type ergonomics (R5 DX M1):**
137
+ **Type ergonomics:**
94
138
 
95
139
  - `BisectResult` now carries a `kind: 'found' | 'no-failure' | 'fails-on-empty' | 'non-deterministic'` discriminator. Consumers can `switch (result.kind)` for clean type-narrowed access instead of juggling three booleans plus an optional index. Legacy boolean fields stay populated for back-compat (marked `@deprecated`).
96
140
 
97
- **Exit-code consistency (R5 DX M3):**
98
-
99
- - `directive bisect` now exits `2` on a "standard hit" (located the first failing frame). Aligns with `directive timeline diff` (exit 2 = differences found), so CI gates can branch uniformly: `0 = clean, 1 = CLI error, 2 = problem found / refused`. Documented in CLI README.
141
+ **Exit-code consistency:**
100
142
 
101
- **Docstring corrections (R5 Arch M5):**
143
+ - `directive bisect` now exits `2` on a "standard hit" (located the first failing frame). Aligns with `directive timeline diff` (exit 2 = differences found), so CI gates can branch uniformly: `0 = clean, 1 = CLI error, 2 = problem found / refused`. Documented in the CLI README.
102
144
 
103
- - `recordReplayable()` JSDoc reframed: the function is a generic "call me when abort fires" hook. Pinning into facts is one use case; Sentry breadcrumbs / Redux logs / OpenTelemetry / metrics are equally valid. Removes the misleading "pairs with timeline" framing that overstated the coupling.
145
+ **Docstring corrections:**
104
146
 
105
- **Tests:** +1 test verifying the new `BisectResult.kind` field across all four outcomes. Workspace: 4090 4091.
147
+ - `recordReplayable()` JSDoc reframed: the function is a generic "call me when abort fires" hook. Pinning into facts is one use case; Sentry breadcrumbs, Redux logs, OpenTelemetry, and metrics are equally valid. Removes the misleading "pairs with timeline" framing that overstated the coupling.
106
148
 
107
149
  ## 0.2.0
108
150
 
109
151
  ### Minor Changes
110
152
 
111
- - [`dc4ac7b`](https://github.com/directive-run/directive/commit/dc4ac7b93007104ce4973d86fb3d6f6a5d1fcded) Thanks [@jasoncomes](https://github.com/jasoncomes)! - Add `cancellable()` HOC auto-cancel-on-supersede for mutator handlers (R1.C v0.1)
153
+ - [`dc4ac7b`](https://github.com/directive-run/directive/commit/dc4ac7b93007104ce4973d86fb3d6f6a5d1fcded) Thanks [@jasoncomes](https://github.com/jasoncomes)! - Add `cancellable()` HOC auto-cancel-on-supersede for mutator handlers
112
154
 
113
- The third BUILD CANDIDATE from the AE-review-loop innovation pass. Wrap a mutator handler with `cancellable()` to get auto-cancellation: a fresh dispatch of the same wrapped handler aborts the prior in-flight invocation, OR an optional timeout fires the abort after N ms.
155
+ Wrap a mutator handler with `cancellable()` to get auto-cancellation: a fresh dispatch of the same wrapped handler aborts the prior in-flight invocation, or an optional timeout fires the abort after N ms.
114
156
 
115
157
  ```ts
116
158
  import { defineMutator, cancellable } from "@directive-run/mutator";
@@ -131,11 +173,11 @@
131
173
 
132
174
  **Two cancellation triggers, both opt-in:**
133
175
 
134
- - `supersedeOn: 'self'` (default) new dispatch supersedes prior
135
- - `supersedeOn: 'never'` only timeout fires; parallel runs are fine
136
- - `timeoutMs: number` abort after N ms from invocation start
176
+ - `supersedeOn: 'self'` (default) new dispatch supersedes prior
177
+ - `supersedeOn: 'never'` only timeout fires; parallel runs are fine
178
+ - `timeoutMs: number` abort after N ms from invocation start
137
179
 
138
- **Test ergonomics.** Pass `virtualClock.setTimeout` from `@directive-run/core` via the `setTimeout` option to make timeouts fire synchronously under `clock.advanceBy(ms)` no real-time waits.
180
+ **Test ergonomics.** Pass `virtualClock.setTimeout` from `@directive-run/core` via the `setTimeout` option to make timeouts fire synchronously under `clock.advanceBy(ms)` no real-time waits.
139
181
 
140
182
  The signal's `.reason` carries a typed `CancelReason`:
141
183
 
@@ -145,19 +187,19 @@
145
187
  | { kind: "timeout"; afterMs: number };
146
188
  ```
147
189
 
148
- **Composition.** Drops in directly to `defineMutator`'s handler map slot. Two separate `cancellable()` HOCs around different handlers do NOT cancel each other the supersession registry is closure-scoped per call.
190
+ **Composition.** Drops in directly to `defineMutator`'s handler map slot. Two separate `cancellable()` HOCs around different handlers do NOT cancel each other the supersession registry is closure-scoped per call.
149
191
 
150
192
  **v0.1 scope:** `cancellable()` is a value-layer HOC; engine-side never sees a difference between a wrapped handler and a plain async one. v0.2 will explore the timeline integration so `expect(timeline).toCancel('search')` matchers can assert against the abort stream.
151
193
 
152
194
  9 new tests covering basic invocation, supersession (both modes), timeout (using virtualClock for determinism), supersession+timeout composition, HOC independence.
153
195
 
154
- ## 0.1.0 2026-04-29
196
+ ## 0.1.0 2026-04-29
155
197
 
156
198
  Initial release.
157
199
 
158
- ### Added v0.2 (R1.C cancellable)
200
+ ### Added v0.2 (cancellable)
159
201
 
160
- - `cancellable(opts, handler)` HOC that wraps a mutator handler with
202
+ - `cancellable(opts, handler)` HOC that wraps a mutator handler with
161
203
  auto-cancellation. Receives a `signal: AbortSignal` in the handler
162
204
  context. Two cancellation triggers: `supersedeOn: 'self' | 'never'`
163
205
  (default `'self'`) and `timeoutMs?: number`. The signal's `reason`
@@ -167,13 +209,13 @@ Initial release.
167
209
  - `CancellableOptions`, `CancellableHandlerContext<F, P>`,
168
210
  `CancelReason` type exports.
169
211
 
170
- ### Added v0.1
212
+ ### Added v0.1
171
213
 
172
- - `defineMutator(handlers)` typed builder that returns six fragments
214
+ - `defineMutator(handlers)` typed builder that returns six fragments
173
215
  (facts / events / requirements / eventHandlers / constraints /
174
216
  resolvers) wiring a discriminated `pendingMutation` lifecycle into a
175
217
  Directive module.
176
- - `mutate(kind, payload?)` typed payload constructor for `MUTATE`
218
+ - `mutate(kind, payload?)` typed payload constructor for `MUTATE`
177
219
  dispatches.
178
220
  - Single-flight concurrency model: new mutations overwrite in-flight ones
179
221
  via the `pendingMutation` fact.
@@ -185,8 +227,8 @@ Initial release.
185
227
 
186
228
  ### Known gaps
187
229
 
188
- - Parallel-of-same-shape mutations not supported last-write-wins.
189
- - No runtime payload validation TypeScript only at dispatch site.
230
+ - Parallel-of-same-shape mutations not supported last-write-wins.
231
+ - No runtime payload validation TypeScript only at dispatch site.
190
232
  - Optimistic / snapshot-rollback support belongs to upcoming
191
233
  `@directive-run/optimistic`; do manual rollback inside handlers for
192
234
  now.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # `@directive-run/mutator`
2
2
 
3
- > Discriminated mutation helper for Directive collapse the
3
+ > Discriminated mutation helper for Directive collapse the
4
4
  > `pendingAction` ceremony to a typed handler map.
5
5
 
6
6
  ```sh
@@ -9,7 +9,7 @@ npm install @directive-run/mutator
9
9
 
10
10
  > **Naming heads-up:** the mutation discriminator is named **`kind`**,
11
11
  > not `type`. Directive's event dispatcher reserves `payload.type` for
12
- > its own event-name routing `type` here would collide with `MUTATE`
12
+ > its own event-name routing `type` here would collide with `MUTATE`
13
13
  > and route the dispatch to a non-existent event handler. Use `kind`
14
14
  > everywhere; the typed `mutate(kind, payload)` constructor builds the
15
15
  > right shape for you.
@@ -84,7 +84,7 @@ sys.events.MUTATE(mutate<FormMutations>('submit', { values }));
84
84
  ```
85
85
 
86
86
  The `mutate(kind, payload?)` helper is a typed payload constructor.
87
- The `kind` argument restricts the payload shape passing a
87
+ The `kind` argument restricts the payload shape passing a
88
88
  wrong-shape payload is a compile error.
89
89
 
90
90
  ## Anatomy
@@ -116,13 +116,13 @@ sys.events.MUTATE({ kind, payload, status: 'pending', error: null })
116
116
  → calls handler({ payload, facts, deps, requeue })
117
117
  → on success: pendingMutation = null
118
118
  → on throw: pendingMutation.status = 'failed' + .error = message
119
- (constraint stops firing no infinite retry; UI can
119
+ (constraint stops firing no infinite retry; UI can
120
120
  disambiguate "still running" from "stopped on error")
121
121
  ```
122
122
 
123
123
  > `kind` (not `type`) discriminates the mutation variant. Directive's
124
124
  > own event dispatcher reserves the `type` field for its own
125
- > event-name routing colliding here would route the dispatch to a
125
+ > event-name routing colliding here would route the dispatch to a
126
126
  > nonexistent event handler. `kind` keeps the two namespaces separate.
127
127
 
128
128
  A failed mutation leaves `pendingMutation` non-null with `status:
@@ -134,21 +134,21 @@ to retry (which overwrites the failed fact and re-fires).
134
134
  **XSS warning.** `pendingMutation.error` is a plaintext `string` that
135
135
  may echo handler-thrown messages, which in turn may have interpolated
136
136
  user-controlled input. Render it via `{error}` in JSX (default-escaped)
137
- or `textContent` **never** via `dangerouslySetInnerHTML`, markdown
137
+ or `textContent` **never** via `dangerouslySetInnerHTML`, markdown
138
138
  rendering, or any other HTML-evaluating sink. The runtime truncates
139
139
  captured errors to 500 characters as a defense in depth, but that does
140
140
  not sanitize content; only escape on render.
141
141
 
142
142
  ## Concurrency
143
143
 
144
- The default model is single-flight one mutation in flight at a time. If
144
+ The default model is single-flight one mutation in flight at a time. If
145
145
  a new `MUTATE` arrives while a handler is running, it overwrites the fact
146
146
  and the constraint re-fires once the in-flight handler completes (which
147
147
  nulls the fact, then the new value triggers another firing).
148
148
 
149
149
  If you need parallel mutations of different shapes (e.g. `submit` AND
150
150
  `uploadFile` running concurrently), use two mutators with distinct fact
151
- names one per shape. v0.1 doesn't support parallel-of-same-shape; the
151
+ names one per shape. v0.1 doesn't support parallel-of-same-shape; the
152
152
  behaviour there is "last-write-wins."
153
153
 
154
154
  ## Same-constraint re-fire (`requeue`)
@@ -163,13 +163,13 @@ const mut = defineMutator<Mutations, MyFacts>({
163
163
  facts.step1Done = true;
164
164
  // queue step2:
165
165
  facts.pendingMutation = mutate<Mutations>('step2');
166
- requeue(); // explicit without this, step2 may stall
166
+ requeue(); // explicit without this, step2 may stall
167
167
  },
168
168
  step2: ({ facts }) => { facts.step2Done = true; },
169
169
  });
170
170
  ```
171
171
 
172
- Most modules don't need `requeue` the next user-event-driven `MUTATE`
172
+ Most modules don't need `requeue` the next user-event-driven `MUTATE`
173
173
  fires fine. It's specifically for handler-cascades-into-handler.
174
174
 
175
175
  See [Directive testing § same-constraint re-fire](https://docs.directive.run/testing/chained-pipelines#the-same-constraint-re-fire-stall).
@@ -183,7 +183,7 @@ becomes:
183
183
  - a required handler in the map (TypeScript errors if you forget one)
184
184
  - a typed `payload` argument inside that handler
185
185
 
186
- There is no runtime variant validation today the type system catches
186
+ There is no runtime variant validation today the type system catches
187
187
  mismatches at the dispatch site, but a malformed `MUTATE` from outside
188
188
  TypeScript (e.g. WebSocket frame) will still hit the resolver. If you
189
189
  need runtime checks, validate at the boundary before dispatch.
@@ -191,9 +191,9 @@ need runtime checks, validate at the boundary before dispatch.
191
191
  ## When NOT to use a mutator
192
192
 
193
193
  - **One-off events with no error path.** A simple `event.handle('OPEN',
194
- (f) => { f.isOpen = true; })` doesn't need this there's no async
194
+ (f) => { f.isOpen = true; })` doesn't need this there's no async
195
195
  work, no rollback, no error fact.
196
- - **Long-running streams.** Subscriptions, polls, websocket fan-in
196
+ - **Long-running streams.** Subscriptions, polls, websocket fan-in
197
197
  these aren't single-shot mutations. Wire them through normal events.
198
198
  - **Pure derivations.** If the result is a function of existing facts,
199
199
  use a `derive` instead of a mutator.
@@ -204,7 +204,7 @@ with a discriminator**. That's the 12-instance shape from the migration.
204
204
  ## Auto-cancel on supersede (R1.C `cancellable()`)
205
205
 
206
206
  For mutations where a fresh dispatch should cancel the prior in-flight
207
- one type-ahead search, debounce, throttle, request dedup wrap
207
+ one type-ahead search, debounce, throttle, request dedup wrap
208
208
  the handler with `cancellable()`. The wrapped handler receives a
209
209
  `signal: AbortSignal` that aborts when superseded or when an
210
210
  optional timeout fires:
@@ -221,7 +221,7 @@ const formMutator = defineMutator<MyMutations, MyFacts>({
221
221
  },
222
222
  ),
223
223
  submit: async ({ payload, facts }) => {
224
- // No cancellation plain handler.
224
+ // No cancellation plain handler.
225
225
  facts.values = await deps.submit(payload.values);
226
226
  },
227
227
  });
@@ -229,10 +229,10 @@ const formMutator = defineMutator<MyMutations, MyFacts>({
229
229
 
230
230
  **Two cancellation triggers, both opt-in:**
231
231
 
232
- - `supersedeOn: 'self'` (default) a new dispatch of the same
232
+ - `supersedeOn: 'self'` (default) a new dispatch of the same
233
233
  wrapped handler aborts the prior in-flight invocation. Set
234
234
  `'never'` if parallel runs are fine.
235
- - `timeoutMs: number` abort after N ms from invocation start.
235
+ - `timeoutMs: number` abort after N ms from invocation start.
236
236
  Default unset (no timeout).
237
237
 
238
238
  **Test ergonomics:** pass `virtualClock.setTimeout` from
@@ -263,7 +263,7 @@ Use it inside handlers to distinguish how the cancellation arrived
263
263
  ## Recording cancellations for replay (R2.B `recordReplayable()`)
264
264
 
265
265
  `recordReplayable()` is `cancellable()` plus a synchronous `onCancel`
266
- callback that fires the moment the AbortController calls `abort()`
266
+ callback that fires the moment the AbortController calls `abort()`
267
267
  *before* the handler's pending await rejects with AbortError. The
268
268
  callback receives a structured `CancelEvent` with the cancel kind,
269
269
  payload, dispatch sequence, and a live facts reference, so you can
@@ -271,7 +271,7 @@ pin cancellations into a place that survives in the timeline.
271
271
 
272
272
  Use this when you record a timeline (with `@directive-run/timeline`)
273
273
  and want a replay or `directive bisect` to reason about *which*
274
- dispatches were superseded vs which completed not just see a
274
+ dispatches were superseded vs which completed not just see a
275
275
  free-form error string.
276
276
 
277
277
  ```ts
@@ -302,14 +302,14 @@ const search = recordReplayable<MyFacts, { q: string }>(
302
302
  );
303
303
  ```
304
304
 
305
- `recordReplayable()` is implemented as `cancellable(opts, innerHandler)` where `innerHandler` adds an `addEventListener('abort')` around the user's handler timeout/supersession semantics are exactly those of `cancellable()`. The callback is generic ("call me when abort fires"); pinning into facts is one use case among many. Wire `onCancel` to Sentry breadcrumbs, a Redux action log, or a metrics sink with equal ease.
305
+ `recordReplayable()` is implemented as `cancellable(opts, innerHandler)` where `innerHandler` adds an `addEventListener('abort')` around the user's handler timeout/supersession semantics are exactly those of `cancellable()`. The callback is generic ("call me when abort fires"); pinning into facts is one use case among many. Wire `onCancel` to Sentry breadcrumbs, a Redux action log, or a metrics sink with equal ease.
306
306
 
307
- `onCancel` errors are caught and swallowed the abort path stays clean.
307
+ `onCancel` errors are caught and swallowed the abort path stays clean.
308
308
 
309
309
  ## Optimistic updates + rollback
310
310
 
311
311
  A future `@directive-run/optimistic` package will integrate with this
312
- one the planned `ctx.snapshot([keys])` API lets a handler snapshot
312
+ one the planned `ctx.snapshot([keys])` API lets a handler snapshot
313
313
  specific facts before mutating, with automatic rollback on throw. Until
314
314
  that ships, do snapshots manually inside handlers:
315
315
 
@@ -326,11 +326,30 @@ submit: async ({ payload, facts, deps }) => {
326
326
  },
327
327
  ```
328
328
 
329
+ ## Composes with
330
+
331
+ - [`@directive-run/query`](../query) — declarative data fetching; pair mutators with query's invalidation tags so a successful mutation refetches the right resources
332
+ - [`@directive-run/optimistic`](../optimistic) — `withOptimistic` / `withOptimisticHandlers` wrap a mutator handler with snapshot + rollback
333
+ - [`@directive-run/timeline`](../timeline) — records every mutation cycle into a timeline for replayable testing
334
+
335
+ ## Use this package with your AI assistant
336
+
337
+ Discriminated-union mutators are what AI assistants need to write correct optimistic updates first try — pair `defineMutator` with [Directive's IDE Integration](https://directive.run/docs/ide-integration) so your assistant knows when to reach for `cancellable()` / `recordReplayable()` vs. plain handlers.
338
+
339
+ ```
340
+ # Claude Code
341
+ /plugin marketplace add directive-run/directive
342
+ /plugin install directive@directive-plugins
343
+
344
+ # Cursor / Copilot / Windsurf / Cline / Codex
345
+ npx directive ai-rules init
346
+ ```
347
+
329
348
  ## See also
330
349
 
331
350
  - [Directive core](https://www.npmjs.com/package/@directive-run/core)
332
- - [Migrating from XState `pendingAction` pattern](https://docs.directive.run/migrating-from-xstate#the-pendingaction-pattern-12-cycles-confirmed)
333
- - [Internal events](https://docs.directive.run/patterns/internal-events) when `status` alone is enough
351
+ - [Migrating from XState `pendingAction` pattern](https://docs.directive.run/migrating-from-xstate#the-pendingaction-pattern-12-cycles-confirmed)
352
+ - [Internal events](https://docs.directive.run/patterns/internal-events) when `status` alone is enough
334
353
  - [`MIGRATION_FEEDBACK.md` items 17 + 19](https://github.com/directive-run/directive/blob/main/docs/MIGRATION_FEEDBACK.md)
335
354
 
336
355
  ## License
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"names":["MAX_ERROR_LEN","truncateError","input","str","raw","safeStringCoerce","value","defineMutator","handlers","t","facts","payload","_req","ctx","factsRef","pending","handler","handlerCtx","err","mutate","kind","CancelError","message","TimeoutCancelError","afterMs","SupersededCancelError","cancelReason","cancellable","opts","supersedeOn","timeoutMs","scheduleTimeout","defaultSetTimeout","priorController","controller","cancelTimeout","cb","ms","handle","recordReplayable","dispatchSeq","seq","onAbort","reason","info"],"mappings":"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","file":"index.cjs","sourcesContent":["/**\n * @directive-run/mutator\n *\n * Discriminated mutation helper. Collapses the manual `pendingAction`\n * ceremony — fact + event + constraint + resolver — into a typed handler\n * map.\n *\n * Background: across the 55-cycle Minglingo migration, 12 modules ended\n * up with the same shape:\n * - a nullable `pendingAction` fact holding a discriminated union\n * - an event that sets it\n * - a constraint that fires on non-null\n * - a resolver that switches on the discriminator and clears the fact\n *\n * That's ~50 lines of boilerplate per module times 12 modules. The\n * `defineMutator` helper below contributes all four pieces from a single\n * typed declaration, so a module that uses it spreads the fragments\n * into its `createModule` config and writes only the per-variant handler\n * bodies.\n *\n * @see ../README.md for the full API and a worked example.\n */\n\nimport { t } from \"@directive-run/core\";\n\n/**\n * A keyed map of variant payloads. Each key becomes a discriminator value\n * for the mutation, each value is the payload type for that variant.\n *\n * @example\n * ```ts\n * type MyMutations = {\n * submit: { values: FormValues };\n * cancel: {};\n * retry: { reason: string };\n * };\n * ```\n */\nexport type MutationMap = Record<string, Record<string, unknown>>;\n\n/**\n * The shape of a `pendingMutation` fact while a mutation is queued or\n * running.\n */\nexport type PendingMutation<M extends MutationMap> = {\n [K in keyof M]: {\n kind: K;\n payload: M[K];\n /**\n * `pending` — queued, constraint hasn't fired yet.\n * `running` — handler is in flight.\n * `failed` — handler threw; constraint stops firing. Caller can\n * inspect `error` and dispatch a fresh MUTATE to retry.\n */\n status: \"pending\" | \"running\" | \"failed\";\n /**\n * Error message from the previous run, if any. Plaintext only —\n * NEVER render this directly via `dangerouslySetInnerHTML` or\n * markdown. Truncated to 500 chars to bound XSS surface from\n * thrown messages that may echo user input.\n */\n error: string | null;\n };\n}[keyof M];\n\n/** Maximum length of a captured error message — bounded to limit XSS surface. */\nconst MAX_ERROR_LEN = 500;\n\n/**\n * Coerce a thrown value to a bounded plaintext string for storage on\n * `pendingMutation.error`. Defends against:\n * - non-Error throws (`throw \"string\"`, `throw 42`, `throw {}`)\n * - Errors with non-string `.message` (overridden numeric / Symbol /\n * buffer); naive `.length` / `.slice` would `TypeError` otherwise\n * - extremely long messages with attacker-influenced input\n *\n * Returns at most {@link MAX_ERROR_LEN} characters of plaintext.\n * Plaintext rendering is still the only supported path on the\n * consumer side; this is defense in depth, not an XSS sanitizer.\n *\n * (R2 sec M-R2-1.)\n */\nfunction truncateError(input: unknown): string {\n let str: string;\n if (input instanceof Error) {\n // Reading `.message` is wrapped in try/catch because a maliciously\n // constructed Error subclass may install a throwing getter on\n // `message`. Without this guard the throw escapes truncateError →\n // escapes the resolver's catch → propagates uncaught. (R4 backlog.)\n let raw: unknown;\n try {\n raw = input.message;\n } catch {\n raw = \"[mutator: error.message getter threw]\";\n }\n str = typeof raw === \"string\" ? raw : safeStringCoerce(raw);\n } else if (typeof input === \"string\") {\n str = input;\n } else {\n str = safeStringCoerce(input);\n }\n if (str.length <= MAX_ERROR_LEN) return str;\n return `${str.slice(0, MAX_ERROR_LEN - 1)}…`;\n}\n\n/** String() may throw for some Symbols + objects with hostile toString. */\nfunction safeStringCoerce(value: unknown): string {\n try {\n return String(value);\n } catch {\n return \"[mutator: unstringifiable error]\";\n }\n}\n\n/**\n * Handler context passed to each variant handler.\n *\n * Note: `deps` is NOT in the context. This matches the Directive resolver\n * idiom — close over deps from the outer module-factory scope:\n *\n * ```ts\n * function createFormModule(deps: FormDeps) {\n * const mut = defineMutator<FormMutations, FormFacts>({\n * submit: async ({ payload, facts }) => {\n * facts.values = await deps.submit(payload.values); // ← closure\n * },\n * });\n * return createModule('form', { ... });\n * }\n * ```\n */\nexport interface MutatorHandlerContext<F> {\n /** Live facts proxy. Reads are cache-tracked; writes invalidate. */\n facts: F;\n /**\n * Trigger a same-constraint re-fire after this handler returns. Useful\n * when one mutation cascades into another — without `requeue`, the next\n * mutation would stall behind same-flush suppression.\n *\n * @see https://docs.directive.run/testing/chained-pipelines\n */\n requeue: () => void;\n}\n\n/**\n * Variant handler body. Receives the typed payload + a context; returns\n * void or a Promise. Throwing is fine — the runtime captures into\n * `pendingMutation.error` before clearing the fact.\n */\nexport type MutationHandler<\n M extends MutationMap,\n K extends keyof M,\n F,\n> = keyof M[K] extends never\n ? (ctx: MutatorHandlerContext<F>) => void | Promise<void>\n : (\n ctx: MutatorHandlerContext<F> & { payload: M[K] },\n ) => void | Promise<void>;\n\n/**\n * The full handler map. Every variant in `M` MUST have a handler.\n */\nexport type MutationHandlers<M extends MutationMap, F> = {\n [K in keyof M]: MutationHandler<M, K, F>;\n};\n\n/**\n * The fragments returned by `defineMutator`. Spread each fragment into\n * the matching position of your `createModule` config.\n *\n * @internal Shape documented for type clarity; users typically just\n * spread.\n */\nexport interface MutatorFragments<M extends MutationMap, F> {\n /** Spread into `schema.facts`. Adds `pendingMutation`. */\n facts: {\n pendingMutation: ReturnType<typeof t.object>;\n };\n /** Spread into `schema.events`. Adds the `MUTATE` event. */\n events: {\n MUTATE: PendingMutation<M>;\n };\n /** Spread into `schema.requirements`. Adds `PROCESS_MUTATION`. */\n requirements: {\n PROCESS_MUTATION: Record<string, never>;\n };\n /** Spread into the `events` field. Sets pendingMutation on MUTATE. */\n eventHandlers: {\n MUTATE: (facts: F, payload: PendingMutation<M>) => void;\n };\n /** Spread into the `constraints` field. */\n constraints: {\n pendingMutation: {\n when: (facts: F) => boolean;\n require: { type: \"PROCESS_MUTATION\" };\n };\n };\n /** Spread into the `resolvers` field. */\n resolvers: {\n mutationResolver: {\n requirement: \"PROCESS_MUTATION\";\n resolve: (\n req: { type: \"PROCESS_MUTATION\" },\n ctx: { facts: F },\n ) => Promise<void>;\n };\n };\n /**\n * Convenience: the initial value for `pendingMutation`. Set this in\n * your module's `init` if you don't use `t.X().default(...)` defaults.\n */\n initialPendingMutation: null;\n}\n\n/**\n * Define a mutator fragment-set for a given variant map and handlers.\n *\n * @example\n * ```ts\n * type FormMutations = {\n * submit: { values: FormValues };\n * cancel: {};\n * };\n *\n * // The second generic is the FACTS type (not deps). Deps are\n * // captured in closure from the surrounding scope, not passed in\n * // through the handler ctx.\n * const formMutator = defineMutator<FormMutations, FormFacts>({\n * submit: async ({ payload, facts }) => {\n * facts.values = await deps.submit(payload.values); // deps closes over outer scope\n * },\n * cancel: ({ facts }) => { facts.values = []; },\n * });\n *\n * createModule('form', {\n * schema: {\n * facts: { ...formMutator.facts, values: t.array<FormValues>() },\n * events: { ...formMutator.events, REFRESH: {} },\n * requirements: { ...formMutator.requirements },\n * },\n * init: (f) => { f.pendingMutation = null; f.values = []; },\n * events: {\n * ...formMutator.eventHandlers,\n * REFRESH: (f) => { f.values = []; },\n * },\n * constraints: { ...formMutator.constraints },\n * resolvers: { ...formMutator.resolvers },\n * });\n *\n * // Usage:\n * sys.events.MUTATE(mutate<FormMutations>('submit', { values: ... }));\n *\n * // Or, if constructing the payload manually, the discriminator is\n * // `kind` (NOT `type` — `type` collides with Directive's event-name\n * // dispatch convention):\n * sys.events.MUTATE({\n * kind: 'submit',\n * payload: { values: ... },\n * status: 'pending',\n * error: null,\n * });\n * ```\n *\n * The handler-bound deps are captured at `defineMutator` call time. To\n * inject deps from the caller, wrap `defineMutator` in your module\n * factory:\n *\n * ```ts\n * export function createFormModule(deps: FormDeps) {\n * const mut = defineMutator<FormMutations, FormFacts>({\n * submit: async ({ payload, facts }) => {\n * facts.values = await deps.submit(payload.values);\n * },\n * cancel: ({ facts }) => { facts.values = []; },\n * });\n * return createModule('form', { ... });\n * }\n * ```\n */\nexport function defineMutator<\n M extends MutationMap,\n F = Record<string, unknown>,\n>(handlers: MutationHandlers<M, F>): MutatorFragments<M, F> {\n type Pending = PendingMutation<M>;\n\n const facts = {\n pendingMutation: t.object<Pending>().nullable() as ReturnType<\n typeof t.object\n >,\n } as MutatorFragments<M, F>[\"facts\"];\n\n // Schema event marker — the runtime uses this for typing and devtools.\n // Payload validation happens at dispatch time via t-schema check.\n const events = {\n MUTATE: undefined as unknown as Pending,\n } as MutatorFragments<M, F>[\"events\"];\n\n const requirements = {\n PROCESS_MUTATION: {} as Record<string, never>,\n } as MutatorFragments<M, F>[\"requirements\"];\n\n const eventHandlers = {\n MUTATE: (facts: F, payload: Pending) => {\n // Overwrite is intentional — caller is responsible for ordering.\n // If a previous mutation is mid-flight, the new one queues by\n // overwriting; the in-flight handler will null the fact when it\n // completes, then the constraint re-fires for the new one.\n // (Same as the manual pattern across all 12 audited modules.)\n (facts as { pendingMutation: Pending | null }).pendingMutation = {\n ...payload,\n status: \"pending\",\n error: null,\n };\n },\n } as MutatorFragments<M, F>[\"eventHandlers\"];\n\n const constraints = {\n pendingMutation: {\n when: (facts: F) =>\n (facts as { pendingMutation: Pending | null }).pendingMutation !==\n null &&\n (facts as { pendingMutation: Pending | null }).pendingMutation\n ?.status === \"pending\",\n require: { type: \"PROCESS_MUTATION\" } as const,\n },\n } as MutatorFragments<M, F>[\"constraints\"];\n\n const resolvers = {\n mutationResolver: {\n requirement: \"PROCESS_MUTATION\" as const,\n resolve: async (\n _req: { type: \"PROCESS_MUTATION\" },\n ctx: {\n facts: F;\n requeue?: () => void;\n },\n ) => {\n const factsRef = ctx.facts as { pendingMutation: Pending | null };\n const pending = factsRef.pendingMutation;\n if (pending === null) return;\n\n // Stamp the in-flight transition with the running status. The\n // status itself is the supersession marker: only this resolver\n // ever writes 'running'; the eventHandler always writes\n // 'pending'. So if a fresh MUTATE arrives mid-flight, the\n // post-handler check sees status: 'pending' instead of\n // 'running' and knows to leave the new dispatch alone.\n // (Sec M5 / DX M2.)\n factsRef.pendingMutation = { ...pending, status: \"running\" };\n\n // Prototype-pollution defense: only accept handlers OWNED by\n // the user-provided map. Without this, dispatching with kind:\n // 'constructor' / 'toString' / '__proto__' would resolve via\n // the prototype chain and either crash or invoke an inherited\n // function with arbitrary payload. (Sec C1.)\n const ownsHandler = Object.prototype.hasOwnProperty.call(\n handlers,\n pending.kind as PropertyKey,\n );\n const handler = ownsHandler\n ? (handlers as Record<string, unknown>)[pending.kind as string]\n : undefined;\n\n if (typeof handler !== \"function\") {\n factsRef.pendingMutation = {\n ...pending,\n status: \"failed\",\n error: truncateError(\n `[mutator] no handler registered for variant: ${String(\n pending.kind,\n )}`,\n ),\n };\n return;\n }\n\n const handlerCtx = {\n facts: ctx.facts,\n payload: pending.payload,\n requeue: ctx.requeue ?? (() => {}),\n };\n\n try {\n await (handler as (c: typeof handlerCtx) => Promise<void> | void)(\n handlerCtx,\n );\n // Success: clear the fact ONLY if this resolver's `running`\n // marker is still live (status === 'running'). If a fresh\n // MUTATE arrived mid-flight via the eventHandler, the fact\n // now has status: 'pending' — leave it alone so the next\n // constraint fire picks it up. (Sec M5 / DX M2.)\n if (factsRef.pendingMutation?.status === \"running\") {\n factsRef.pendingMutation = null;\n }\n } catch (err) {\n // Failure: only stamp the error if our running marker is\n // still live. If a fresh MUTATE arrived mid-flight, the new\n // dispatch wins; the failed mutation's error is dropped on\n // the floor (caller wanted to move on anyway). (Sec M5.)\n if (factsRef.pendingMutation?.status !== \"running\") return;\n factsRef.pendingMutation = {\n ...pending,\n // 'failed' is a distinct status (Sec M6 / Arch C2) — UI can\n // disambiguate \"still in flight\" from \"stopped on error\".\n status: \"failed\",\n // truncateError handles the unknown shape safely — non-Error\n // throws, non-string Error.message, hostile toString. (R2\n // sec M-R2-1.)\n error: truncateError(err),\n };\n }\n },\n },\n } as MutatorFragments<M, F>[\"resolvers\"];\n\n return {\n facts,\n events,\n requirements,\n eventHandlers,\n constraints,\n resolvers,\n initialPendingMutation: null,\n };\n}\n\n/**\n * Helper for typed dispatch. Lets the caller construct a mutation payload\n * with full type narrowing — the `kind` field auto-restricts the\n * `payload` shape.\n *\n * @example\n * ```ts\n * sys.events.MUTATE(mutate<FormMutations>('submit', { values: ... }));\n * sys.events.MUTATE(mutate<FormMutations>('cancel'));\n * ```\n */\n// Single type parameter (M) so callers can specialize with\n// `mutate<FormMutations>(...)` without TypeScript demanding the\n// inferred K. TS's strict type-argument rules treat <M, K> as\n// \"supply both or supply neither\" — single-arg call sites failed\n// with \"Expected 2 type arguments, but got 1.\" Single-M form sidesteps\n// that while keeping payload typing tight via the `kind` lookup.\nexport function mutate<M extends MutationMap>(\n kind: keyof M & string,\n payload: M[typeof kind],\n): PendingMutation<M>;\nexport function mutate<M extends MutationMap>(\n kind: keyof M & string,\n): PendingMutation<M>;\nexport function mutate<M extends MutationMap>(\n kind: keyof M & string,\n payload?: M[typeof kind],\n): PendingMutation<M> {\n return {\n kind,\n payload: (payload ?? {}) as M[typeof kind],\n status: \"pending\",\n error: null,\n } as PendingMutation<M>;\n}\n\n// ============================================================================\n// cancellable() — auto-cancel-on-supersede (R1.C v0.1)\n// ============================================================================\n\n/**\n * Options for {@link cancellable}.\n */\nexport interface CancellableOptions {\n /**\n * When to fire the AbortSignal that the wrapped handler receives.\n *\n * - `'self'` (default): a new dispatch of the SAME handler aborts the\n * prior in-flight invocation. The classic \"cancel previous on new\n * keystroke\" pattern.\n * - `'never'`: only the timeout fires the signal; new dispatches do\n * NOT abort prior ones. Useful when you want a hard timeout but\n * parallel runs are fine.\n */\n supersedeOn?: \"self\" | \"never\";\n\n /**\n * Maximum ms a handler may run before its signal is aborted. Counted\n * from the start of THIS invocation. Combine with `realClock` for\n * production or pass a `setTimeout` shim for deterministic testing.\n */\n timeoutMs?: number;\n\n /**\n * Optional `setTimeout` injection. Defaults to `globalThis.setTimeout`.\n * For deterministic tests, pass `virtualClock.setTimeout` from\n * `@directive-run/core` so the timeout fires under\n * `clock.advanceBy()` instead of wall-clock real time.\n *\n * @example\n * ```ts\n * import { virtualClock } from '@directive-run/core';\n * const clock = virtualClock(0);\n * cancellable({ timeoutMs: 1_000, setTimeout: clock.setTimeout }, handler);\n * ```\n */\n setTimeout?: (cb: () => void, ms: number) => () => void;\n}\n\n/**\n * Handler context augmented with a cancellation signal.\n */\nexport interface CancellableHandlerContext<F, P> {\n facts: F;\n payload: P;\n /** Aborts when a new dispatch supersedes this one OR the timeout fires. */\n signal: AbortSignal;\n requeue: () => void;\n}\n\n/**\n * Reason a cancellable handler's signal aborted. Stamped on\n * `signal.reason` so the handler can disambiguate.\n *\n * Note: at runtime the value is a {@link CancelError} subclass with\n * the same shape — `signal.reason instanceof CancelError` is the\n * canonical check. Older usages that did `signal.reason?.kind ===\n * 'superseded'` continue to work because the Error subclass exposes\n * the same `kind` field. (R2 sec M-1.)\n */\nexport type CancelReason =\n | { kind: \"superseded\" }\n | { kind: \"timeout\"; afterMs: number };\n\n/**\n * Runtime carrier for {@link CancelReason}. Subclasses `Error` so the\n * value survives transit through `fetch(url, { signal })` and other\n * web-platform APIs that re-throw `signal.reason`. Older mutator\n * versions passed plain objects, which `.catch(err => err instanceof\n * Error)` filters silently dropped. (R2 sec M-1.)\n */\nexport class CancelError extends Error {\n constructor(public readonly kind: CancelReason[\"kind\"], message?: string) {\n super(message ?? `[mutator] cancellable: ${kind}`);\n this.name = \"CancelError\";\n }\n}\n\n/** Subclass of {@link CancelError} for timeout-driven aborts. */\nexport class TimeoutCancelError extends CancelError {\n constructor(public readonly afterMs: number) {\n super(\"timeout\", `[mutator] cancellable: timeout after ${afterMs}ms`);\n this.name = \"TimeoutCancelError\";\n }\n}\n\n/** Subclass of {@link CancelError} for supersede-driven aborts. */\nexport class SupersededCancelError extends CancelError {\n constructor() {\n super(\"superseded\", \"[mutator] cancellable: superseded by new dispatch\");\n this.name = \"SupersededCancelError\";\n }\n}\n\n/**\n * Factory for cancel-reason values. Pure — these are the runtime\n * counterparts of the {@link CancelReason} type. Use them in custom\n * cancellation flows that need a typed reason without re-typing the\n * literal.\n */\nexport const cancelReason = {\n superseded: (): SupersededCancelError => new SupersededCancelError(),\n timeout: (afterMs: number): TimeoutCancelError =>\n new TimeoutCancelError(afterMs),\n} as const;\n\n/**\n * Wrap a mutator handler with auto-cancellation. The wrapped handler\n * receives an extra `signal: AbortSignal` in its context. Use the\n * signal to short-circuit awaitable work — pass it to `fetch(url, {\n * signal })`, watch it inside long-running loops, etc.\n *\n * Two cancellation triggers, both opt-in via {@link CancellableOptions}:\n *\n * 1. **Supersession** (default `supersedeOn: 'self'`): when a new\n * dispatch of the same wrapped handler arrives while a prior\n * invocation is still running, the prior signal aborts.\n * 2. **Timeout** (default `timeoutMs: undefined`, meaning no timeout):\n * after `timeoutMs` ms from invocation start, the signal aborts.\n *\n * If a handler's signal aborts, the handler should observe the abort\n * (via `signal.aborted` or the AbortError-throwing helpers) and\n * return promptly. The signal's `reason` carries a {@link CancelReason}\n * disambiguating which trigger fired.\n *\n * Compose with {@link defineMutator} by using `cancellable()` directly\n * in the handler map:\n *\n * @example\n * ```ts\n * import { defineMutator, cancellable } from '@directive-run/mutator';\n *\n * const formMutator = defineMutator<MyMutations, MyFacts>({\n * search: cancellable(\n * { supersedeOn: 'self', timeoutMs: 3_000 },\n * async ({ payload, facts, signal }) => {\n * const res = await fetch(`/q?${payload.q}`, { signal });\n * facts.results = await res.json();\n * },\n * ),\n * submit: async ({ payload, facts }) => {\n * // No cancellation needed for submit — plain handler.\n * facts.values = await deps.submit(payload.values);\n * },\n * });\n * ```\n *\n * **Idempotency note.** The wrapped handler stays a regular\n * `MutationHandler<M, K, F>` from the mutator's perspective. The\n * supersession registry is closure-scoped per `cancellable()` call —\n * two separate `cancellable(...)` HOCs around different handlers do\n * NOT cancel each other.\n *\n * **Test ergonomics.** Pass `virtualClock.setTimeout` via the\n * `setTimeout` option to make timeouts deterministic under\n * `clock.advanceBy(ms)`. Without that, timeouts use wall-clock\n * `globalThis.setTimeout` and are real-time.\n */\nexport function cancellable<F, P>(\n opts: CancellableOptions,\n handler: (ctx: CancellableHandlerContext<F, P>) => Promise<void> | void,\n): (ctx: { facts: F; payload: P; requeue: () => void }) => Promise<void> {\n const supersedeOn = opts.supersedeOn ?? \"self\";\n const timeoutMs = opts.timeoutMs;\n const scheduleTimeout = opts.setTimeout ?? defaultSetTimeout;\n\n // Closure-scoped supersession slot — one entry for the wrapped\n // handler. When a new invocation arrives, the prior entry's\n // controller aborts before the new one starts.\n let priorController: AbortController | undefined;\n\n return async (ctx: { facts: F; payload: P; requeue: () => void }) => {\n // Supersession: abort the prior in-flight invocation, if any.\n // R2 sec M-1: pass an Error subclass so signal.reason survives\n // re-throw through fetch / .catch(err => err instanceof Error)\n // / logging frameworks. Plain object reasons silently fail those\n // checks downstream.\n if (supersedeOn === \"self\" && priorController !== undefined) {\n priorController.abort(cancelReason.superseded());\n }\n\n const controller = new AbortController();\n priorController = controller;\n\n // Timeout: schedule an abort after `timeoutMs`. The cancel handle\n // returned by `scheduleTimeout` lets us clear the timer if the\n // handler completes first (saves leaking timers under a barrage\n // of dispatches).\n let cancelTimeout: (() => void) | undefined;\n if (typeof timeoutMs === \"number\" && timeoutMs > 0) {\n cancelTimeout = scheduleTimeout(() => {\n controller.abort(cancelReason.timeout(timeoutMs));\n }, timeoutMs);\n }\n\n try {\n await handler({\n facts: ctx.facts,\n payload: ctx.payload,\n requeue: ctx.requeue,\n signal: controller.signal,\n });\n } finally {\n // Clean up: clear the timeout (if it hasn't fired) and release\n // the supersession slot if it still belongs to this invocation.\n // R2 sec M-3: wrap cancelTimeout in try/catch so a hostile\n // setTimeout-cancel-handle (e.g. a custom virtual clock that\n // throws on cancel) cannot shadow the original handler's\n // exception.\n try {\n cancelTimeout?.();\n } catch {\n /* swallow — the cancel-handle's failure is not the caller's problem */\n }\n if (priorController === controller) {\n priorController = undefined;\n }\n }\n };\n}\n\n/**\n * Default `setTimeout` shim — wraps `globalThis.setTimeout` to match\n * the cancel-handle signature `cancellable()` expects.\n */\nfunction defaultSetTimeout(cb: () => void, ms: number): () => void {\n const handle = globalThis.setTimeout(cb, ms);\n return () => globalThis.clearTimeout(handle);\n}\n\n// ────────────────────────────────────────────────────────────────────────────\n// recordReplayable — R2.B\n// ────────────────────────────────────────────────────────────────────────────\n\n/**\n * Structured event surfaced to {@link RecordReplayableOptions.onCancel}\n * the moment a wrapped invocation's signal aborts. Carries enough\n * information for the caller to pin the cancellation into the timeline\n * (e.g. by writing onto a facts array) so a later replay can\n * reconstruct the cancellation race exactly.\n *\n * @typeParam F — caller's facts type (passed through unchanged from the handler context).\n * @typeParam P — caller's payload type for THIS handler.\n */\nexport interface CancelEvent<F, P> {\n /** Whether this cancel was driven by a new dispatch superseding the old, or by the timeout firing. */\n kind: CancelReason[\"kind\"];\n /** When `kind === 'timeout'`, the original `timeoutMs` that fired. Undefined for `'superseded'`. */\n afterMs?: number;\n /** The payload of the dispatch that was cancelled — i.e. the work that did NOT complete. */\n payload: P;\n /**\n * Per-handler monotonic counter, incremented once at the start of\n * every invocation. Useful for timeline diff: cancel of seq=4 is the\n * 4th dispatch this handler ever saw. The counter is closure-scoped\n * to a single `recordReplayable()` call — separate HOCs do NOT share\n * the counter.\n */\n dispatchSeq: number;\n /** Live facts reference, mirroring {@link CancellableHandlerContext.facts}. */\n facts: F;\n}\n\n/**\n * Options for {@link recordReplayable}. Strict superset of\n * {@link CancellableOptions} — every field on `CancellableOptions`\n * passes through unchanged to the underlying {@link cancellable} call.\n */\nexport interface RecordReplayableOptions<F, P> extends CancellableOptions {\n /**\n * Synchronous callback invoked the moment a wrapped invocation's\n * AbortController fires `abort()`. Runs BEFORE the handler's pending\n * await rejects with AbortError, so the callback sees the freshest\n * possible state.\n *\n * Use this to pin the cancellation into a place that survives in the\n * timeline (typically a facts field — `facts.cancellations.push(info)`).\n * Without that, a replay re-dispatches the same MUTATE events but has\n * no record of which ones were superseded vs which completed.\n *\n * Throws inside `onCancel` are caught and swallowed — the abort path\n * must remain robust. If you need to fail loudly, log to your own\n * sink before re-throwing.\n */\n onCancel?: (info: CancelEvent<F, P>) => void;\n}\n\n/**\n * `cancellable()` plus a synchronous on-abort callback. Wrap your\n * handler with `recordReplayable()` instead of `cancellable()` when\n * you want a hook that fires the moment a supersession or timeout\n * aborts the in-flight invocation — *before* the handler's pending\n * await rejects with AbortError. The callback receives a structured\n * {@link CancelEvent} carrying the cancel kind, payload, dispatch\n * sequence, and a live facts reference.\n *\n * The callback is GENERIC (\"call me when abort fires\"); the most\n * common use case is pinning cancel events into facts so a recorded\n * timeline carries them — which then lets `replayTimeline()`,\n * `bisectTimeline()`, and `diffTimelines()` (all in\n * `@directive-run/timeline`) reason about which dispatches were\n * superseded vs which completed without parsing free-form error\n * strings. But the same callback works equally well for Sentry\n * breadcrumbs, a Redux action log, OpenTelemetry spans, or a metrics\n * sink — `recordReplayable` doesn't know or care about the timeline.\n *\n * @example Pin cancel events into facts so the timeline carries them:\n * ```ts\n * import { defineMutator, recordReplayable } from '@directive-run/mutator';\n *\n * const search = recordReplayable<MyFacts, { q: string }>(\n * {\n * supersedeOn: 'self',\n * timeoutMs: 3_000,\n * onCancel: ({ facts, kind, payload, dispatchSeq }) => {\n * facts.cancellations.push({ kind, queryAtCancel: payload.q, seq: dispatchSeq });\n * },\n * },\n * async ({ payload, facts, signal }) => {\n * const res = await fetch(`/q?${payload.q}`, { signal });\n * facts.results = await res.json();\n * },\n * );\n * ```\n *\n * Implementation note: `recordReplayable()` is `cancellable(opts,\n * innerHandler)` where `innerHandler` adds an `addEventListener('abort')`\n * around the user's handler. This means timeout / supersession\n * semantics are EXACTLY those of `cancellable()` — the HOC is purely\n * additive.\n */\nexport function recordReplayable<F, P>(\n opts: RecordReplayableOptions<F, P>,\n handler: (ctx: CancellableHandlerContext<F, P>) => Promise<void> | void,\n): (ctx: { facts: F; payload: P; requeue: () => void }) => Promise<void> {\n let dispatchSeq = 0;\n\n const wrappedHandler = async (\n ctx: CancellableHandlerContext<F, P>,\n ): Promise<void> => {\n const seq = ++dispatchSeq;\n const onAbort = (): void => {\n // Only fire onCancel for our typed CancelError reasons; if the\n // signal was aborted via some other path (caller's own\n // controller, etc), let it pass silently.\n const reason = ctx.signal.reason;\n if (!(reason instanceof CancelError)) return;\n const info: CancelEvent<F, P> = {\n kind: reason.kind,\n afterMs:\n reason instanceof TimeoutCancelError ? reason.afterMs : undefined,\n payload: ctx.payload,\n dispatchSeq: seq,\n facts: ctx.facts,\n };\n try {\n opts.onCancel?.(info);\n } catch {\n // Callback errors must NOT bubble up the abort path. The user's\n // handler is still about to receive AbortError — we don't want\n // to mask that with an onCancel-side throw, and we don't want\n // to crash the controller in the middle of cleanup.\n }\n };\n ctx.signal.addEventListener(\"abort\", onAbort, { once: true });\n try {\n await handler(ctx);\n } finally {\n ctx.signal.removeEventListener(\"abort\", onAbort);\n }\n };\n\n return cancellable<F, P>(opts, wrappedHandler);\n}\n"]}
1
+ {"version":3,"sources":["../src/index.ts"],"names":["MAX_ERROR_LEN","truncateError","input","str","raw","safeStringCoerce","value","defineMutator","handlers","t","facts","payload","_req","ctx","factsRef","pending","handler","handlerCtx","err","mutate","kind","CancelError","message","TimeoutCancelError","afterMs","SupersededCancelError","cancelReason","cancellable","opts","supersedeOn","timeoutMs","scheduleTimeout","defaultSetTimeout","priorController","controller","cancelTimeout","cb","ms","handle","recordReplayable","dispatchSeq","seq","onAbort","reason","info"],"mappings":"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","file":"index.cjs","sourcesContent":["/**\n * @directive-run/mutator\n *\n * Discriminated mutation helper. Collapses the manual `pendingAction`\n * ceremony — fact + event + constraint + resolver — into a typed handler\n * map.\n *\n * Background: across the 55-cycle Minglingo migration, 12 modules ended\n * up with the same shape:\n * - a nullable `pendingAction` fact holding a discriminated union\n * - an event that sets it\n * - a constraint that fires on non-null\n * - a resolver that switches on the discriminator and clears the fact\n *\n * That's ~50 lines of boilerplate per module times 12 modules. The\n * `defineMutator` helper below contributes all four pieces from a single\n * typed declaration, so a module that uses it spreads the fragments\n * into its `createModule` config and writes only the per-variant handler\n * bodies.\n *\n * @see ../README.md for the full API and a worked example.\n */\n\nimport { t } from \"@directive-run/core\";\n\n/**\n * A keyed map of variant payloads. Each key becomes a discriminator value\n * for the mutation, each value is the payload type for that variant.\n *\n * @example\n * ```ts\n * type MyMutations = {\n * submit: { values: FormValues };\n * cancel: {};\n * retry: { reason: string };\n * };\n * ```\n */\nexport type MutationMap = Record<string, Record<string, unknown>>;\n\n/**\n * The shape of a `pendingMutation` fact while a mutation is queued or\n * running.\n */\nexport type PendingMutation<M extends MutationMap> = {\n [K in keyof M]: {\n kind: K;\n payload: M[K];\n /**\n * `pending` — queued, constraint hasn't fired yet.\n * `running` — handler is in flight.\n * `failed` — handler threw; constraint stops firing. Caller can\n * inspect `error` and dispatch a fresh MUTATE to retry.\n */\n status: \"pending\" | \"running\" | \"failed\";\n /**\n * Error message from the previous run, if any. Plaintext only —\n * NEVER render this directly via `dangerouslySetInnerHTML` or\n * markdown. Truncated to 500 chars to bound XSS surface from\n * thrown messages that may echo user input.\n */\n error: string | null;\n };\n}[keyof M];\n\n/** Maximum length of a captured error message — bounded to limit XSS surface. */\nconst MAX_ERROR_LEN = 500;\n\n/**\n * Coerce a thrown value to a bounded plaintext string for storage on\n * `pendingMutation.error`. Defends against:\n * - non-Error throws (`throw \"string\"`, `throw 42`, `throw {}`)\n * - Errors with non-string `.message` (overridden numeric / Symbol /\n * buffer); naive `.length` / `.slice` would `TypeError` otherwise\n * - extremely long messages with attacker-influenced input\n *\n * Returns at most {@link MAX_ERROR_LEN} characters of plaintext.\n * Plaintext rendering is still the only supported path on the\n * consumer side; this is defense in depth, not an XSS sanitizer.\n *\n * (R2 sec M-R2-1.)\n */\nfunction truncateError(input: unknown): string {\n let str: string;\n if (input instanceof Error) {\n // Reading `.message` is wrapped in try/catch because a maliciously\n // constructed Error subclass may install a throwing getter on\n // `message`. Without this guard the throw escapes truncateError →\n // escapes the resolver's catch → propagates uncaught. (R4 backlog.)\n let raw: unknown;\n try {\n raw = input.message;\n } catch {\n raw = \"[mutator: error.message getter threw]\";\n }\n str = typeof raw === \"string\" ? raw : safeStringCoerce(raw);\n } else if (typeof input === \"string\") {\n str = input;\n } else {\n str = safeStringCoerce(input);\n }\n if (str.length <= MAX_ERROR_LEN) return str;\n return `${str.slice(0, MAX_ERROR_LEN - 1)}…`;\n}\n\n/** String() may throw for some Symbols + objects with hostile toString. */\nfunction safeStringCoerce(value: unknown): string {\n try {\n return String(value);\n } catch {\n return \"[mutator: unstringifiable error]\";\n }\n}\n\n/**\n * Handler context passed to each variant handler.\n *\n * Note: `deps` is NOT in the context. This matches the Directive resolver\n * idiom — close over deps from the outer module-factory scope:\n *\n * ```ts\n * function createFormModule(deps: FormDeps) {\n * const mut = defineMutator<FormMutations, FormFacts>({\n * submit: async ({ payload, facts }) => {\n * facts.values = await deps.submit(payload.values); // ← closure\n * },\n * });\n * return createModule('form', { ... });\n * }\n * ```\n */\nexport interface MutatorHandlerContext<F> {\n /** Live facts proxy. Reads are cache-tracked; writes invalidate. */\n facts: F;\n /**\n * Trigger a same-constraint re-fire after this handler returns. Useful\n * when one mutation cascades into another — without `requeue`, the next\n * mutation would stall behind same-flush suppression.\n *\n * @see https://docs.directive.run/testing/chained-pipelines\n */\n requeue: () => void;\n}\n\n/**\n * Variant handler body. Receives the typed payload + a context; returns\n * void or a Promise. Throwing is fine — the runtime captures into\n * `pendingMutation.error` before clearing the fact.\n */\nexport type MutationHandler<\n M extends MutationMap,\n K extends keyof M,\n F,\n> = keyof M[K] extends never\n ? (ctx: MutatorHandlerContext<F>) => void | Promise<void>\n : (ctx: MutatorHandlerContext<F> & { payload: M[K] }) => void | Promise<void>;\n\n/**\n * The full handler map. Every variant in `M` MUST have a handler.\n */\nexport type MutationHandlers<M extends MutationMap, F> = {\n [K in keyof M]: MutationHandler<M, K, F>;\n};\n\n/**\n * The fragments returned by `defineMutator`. Spread each fragment into\n * the matching position of your `createModule` config.\n *\n * @internal Shape documented for type clarity; users typically just\n * spread.\n */\nexport interface MutatorFragments<M extends MutationMap, F> {\n /** Spread into `schema.facts`. Adds `pendingMutation`. */\n facts: {\n pendingMutation: ReturnType<typeof t.object>;\n };\n /** Spread into `schema.events`. Adds the `MUTATE` event. */\n events: {\n MUTATE: PendingMutation<M>;\n };\n /** Spread into `schema.requirements`. Adds `PROCESS_MUTATION`. */\n requirements: {\n PROCESS_MUTATION: Record<string, never>;\n };\n /** Spread into the `events` field. Sets pendingMutation on MUTATE. */\n eventHandlers: {\n MUTATE: (facts: F, payload: PendingMutation<M>) => void;\n };\n /** Spread into the `constraints` field. */\n constraints: {\n pendingMutation: {\n when: (facts: F) => boolean;\n require: { type: \"PROCESS_MUTATION\" };\n };\n };\n /** Spread into the `resolvers` field. */\n resolvers: {\n mutationResolver: {\n requirement: \"PROCESS_MUTATION\";\n resolve: (\n req: { type: \"PROCESS_MUTATION\" },\n ctx: { facts: F },\n ) => Promise<void>;\n };\n };\n /**\n * Convenience: the initial value for `pendingMutation`. Set this in\n * your module's `init` if you don't use `t.X().default(...)` defaults.\n */\n initialPendingMutation: null;\n}\n\n/**\n * Define a mutator fragment-set for a given variant map and handlers.\n *\n * @example\n * ```ts\n * type FormMutations = {\n * submit: { values: FormValues };\n * cancel: {};\n * };\n *\n * // The second generic is the FACTS type (not deps). Deps are\n * // captured in closure from the surrounding scope, not passed in\n * // through the handler ctx.\n * const formMutator = defineMutator<FormMutations, FormFacts>({\n * submit: async ({ payload, facts }) => {\n * facts.values = await deps.submit(payload.values); // deps closes over outer scope\n * },\n * cancel: ({ facts }) => { facts.values = []; },\n * });\n *\n * createModule('form', {\n * schema: {\n * facts: { ...formMutator.facts, values: t.array<FormValues>() },\n * events: { ...formMutator.events, REFRESH: {} },\n * requirements: { ...formMutator.requirements },\n * },\n * init: (f) => { f.pendingMutation = null; f.values = []; },\n * events: {\n * ...formMutator.eventHandlers,\n * REFRESH: (f) => { f.values = []; },\n * },\n * constraints: { ...formMutator.constraints },\n * resolvers: { ...formMutator.resolvers },\n * });\n *\n * // Usage:\n * sys.events.MUTATE(mutate<FormMutations>('submit', { values: ... }));\n *\n * // Or, if constructing the payload manually, the discriminator is\n * // `kind` (NOT `type` — `type` collides with Directive's event-name\n * // dispatch convention):\n * sys.events.MUTATE({\n * kind: 'submit',\n * payload: { values: ... },\n * status: 'pending',\n * error: null,\n * });\n * ```\n *\n * The handler-bound deps are captured at `defineMutator` call time. To\n * inject deps from the caller, wrap `defineMutator` in your module\n * factory:\n *\n * ```ts\n * export function createFormModule(deps: FormDeps) {\n * const mut = defineMutator<FormMutations, FormFacts>({\n * submit: async ({ payload, facts }) => {\n * facts.values = await deps.submit(payload.values);\n * },\n * cancel: ({ facts }) => { facts.values = []; },\n * });\n * return createModule('form', { ... });\n * }\n * ```\n */\nexport function defineMutator<\n M extends MutationMap,\n F = Record<string, unknown>,\n>(handlers: MutationHandlers<M, F>): MutatorFragments<M, F> {\n type Pending = PendingMutation<M>;\n\n const facts = {\n pendingMutation: t.object<Pending>().nullable() as ReturnType<\n typeof t.object\n >,\n } as MutatorFragments<M, F>[\"facts\"];\n\n // Schema event marker — the runtime uses this for typing and devtools.\n // Payload validation happens at dispatch time via t-schema check.\n const events = {\n MUTATE: undefined as unknown as Pending,\n } as MutatorFragments<M, F>[\"events\"];\n\n const requirements = {\n PROCESS_MUTATION: {} as Record<string, never>,\n } as MutatorFragments<M, F>[\"requirements\"];\n\n const eventHandlers = {\n MUTATE: (facts: F, payload: Pending) => {\n // Overwrite is intentional — caller is responsible for ordering.\n // If a previous mutation is mid-flight, the new one supersedes by\n // writing `status: \"pending\"` over the in-flight `status: \"running\"`\n // marker. The resolver's `if (status === \"running\")` checks at the\n // success and failure paths then leave the fresh dispatch untouched\n // so the next constraint fire picks it up — the in-flight result is\n // dropped on the floor (caller wanted to move on). See sec M5 / DX M2\n // in the AE review for the full reasoning.\n (facts as { pendingMutation: Pending | null }).pendingMutation = {\n ...payload,\n status: \"pending\",\n error: null,\n };\n },\n } as MutatorFragments<M, F>[\"eventHandlers\"];\n\n const constraints = {\n pendingMutation: {\n when: (facts: F) =>\n (facts as { pendingMutation: Pending | null }).pendingMutation !==\n null &&\n (facts as { pendingMutation: Pending | null }).pendingMutation\n ?.status === \"pending\",\n require: { type: \"PROCESS_MUTATION\" } as const,\n },\n } as MutatorFragments<M, F>[\"constraints\"];\n\n const resolvers = {\n mutationResolver: {\n requirement: \"PROCESS_MUTATION\" as const,\n resolve: async (\n _req: { type: \"PROCESS_MUTATION\" },\n ctx: {\n facts: F;\n requeue?: () => void;\n },\n ) => {\n const factsRef = ctx.facts as { pendingMutation: Pending | null };\n const pending = factsRef.pendingMutation;\n if (pending === null) return;\n\n // Stamp the in-flight transition with the running status. The\n // status itself is the supersession marker: only this resolver\n // ever writes 'running'; the eventHandler always writes\n // 'pending'. So if a fresh MUTATE arrives mid-flight, the\n // post-handler check sees status: 'pending' instead of\n // 'running' and knows to leave the new dispatch alone.\n // (Sec M5 / DX M2.)\n factsRef.pendingMutation = { ...pending, status: \"running\" };\n\n // Prototype-pollution defense: only accept handlers OWNED by\n // the user-provided map. Without this, dispatching with kind:\n // 'constructor' / 'toString' / '__proto__' would resolve via\n // the prototype chain and either crash or invoke an inherited\n // function with arbitrary payload. (Sec C1.)\n const ownsHandler = Object.prototype.hasOwnProperty.call(\n handlers,\n pending.kind as PropertyKey,\n );\n const handler = ownsHandler\n ? (handlers as Record<string, unknown>)[pending.kind as string]\n : undefined;\n\n if (typeof handler !== \"function\") {\n factsRef.pendingMutation = {\n ...pending,\n status: \"failed\",\n error: truncateError(\n `[mutator] no handler registered for variant: ${String(\n pending.kind,\n )}`,\n ),\n };\n return;\n }\n\n const handlerCtx = {\n facts: ctx.facts,\n payload: pending.payload,\n requeue: ctx.requeue ?? (() => {}),\n };\n\n try {\n await (handler as (c: typeof handlerCtx) => Promise<void> | void)(\n handlerCtx,\n );\n // Success: clear the fact ONLY if this resolver's `running`\n // marker is still live (status === 'running'). If a fresh\n // MUTATE arrived mid-flight via the eventHandler, the fact\n // now has status: 'pending' — leave it alone so the next\n // constraint fire picks it up. (Sec M5 / DX M2.)\n if (factsRef.pendingMutation?.status === \"running\") {\n factsRef.pendingMutation = null;\n }\n } catch (err) {\n // Failure: only stamp the error if our running marker is\n // still live. If a fresh MUTATE arrived mid-flight, the new\n // dispatch wins; the failed mutation's error is dropped on\n // the floor (caller wanted to move on anyway). (Sec M5.)\n if (factsRef.pendingMutation?.status !== \"running\") return;\n factsRef.pendingMutation = {\n ...pending,\n // 'failed' is a distinct status (Sec M6 / Arch C2) — UI can\n // disambiguate \"still in flight\" from \"stopped on error\".\n status: \"failed\",\n // truncateError handles the unknown shape safely — non-Error\n // throws, non-string Error.message, hostile toString. (R2\n // sec M-R2-1.)\n error: truncateError(err),\n };\n }\n },\n },\n } as MutatorFragments<M, F>[\"resolvers\"];\n\n return {\n facts,\n events,\n requirements,\n eventHandlers,\n constraints,\n resolvers,\n initialPendingMutation: null,\n };\n}\n\n/**\n * Helper for typed dispatch. Lets the caller construct a mutation payload\n * with full type narrowing — the `kind` field auto-restricts the\n * `payload` shape.\n *\n * @example\n * ```ts\n * sys.events.MUTATE(mutate<FormMutations>('submit', { values: ... }));\n * sys.events.MUTATE(mutate<FormMutations>('cancel'));\n * ```\n */\n// Single type parameter (M) so callers can specialize with\n// `mutate<FormMutations>(...)` without TypeScript demanding the\n// inferred K. TS's strict type-argument rules treat <M, K> as\n// \"supply both or supply neither\" — single-arg call sites failed\n// with \"Expected 2 type arguments, but got 1.\" Single-M form sidesteps\n// that while keeping payload typing tight via the `kind` lookup.\nexport function mutate<M extends MutationMap>(\n kind: keyof M & string,\n payload: M[typeof kind],\n): PendingMutation<M>;\nexport function mutate<M extends MutationMap>(\n kind: keyof M & string,\n): PendingMutation<M>;\nexport function mutate<M extends MutationMap>(\n kind: keyof M & string,\n payload?: M[typeof kind],\n): PendingMutation<M> {\n return {\n kind,\n payload: (payload ?? {}) as M[typeof kind],\n status: \"pending\",\n error: null,\n } as PendingMutation<M>;\n}\n\n// ============================================================================\n// cancellable() — auto-cancel-on-supersede (R1.C v0.1)\n// ============================================================================\n\n/**\n * Options for {@link cancellable}.\n */\nexport interface CancellableOptions {\n /**\n * When to fire the AbortSignal that the wrapped handler receives.\n *\n * - `'self'` (default): a new dispatch of the SAME handler aborts the\n * prior in-flight invocation. The classic \"cancel previous on new\n * keystroke\" pattern.\n * - `'never'`: only the timeout fires the signal; new dispatches do\n * NOT abort prior ones. Useful when you want a hard timeout but\n * parallel runs are fine.\n */\n supersedeOn?: \"self\" | \"never\";\n\n /**\n * Maximum ms a handler may run before its signal is aborted. Counted\n * from the start of THIS invocation. Combine with `realClock` for\n * production or pass a `setTimeout` shim for deterministic testing.\n */\n timeoutMs?: number;\n\n /**\n * Optional `setTimeout` injection. Defaults to `globalThis.setTimeout`.\n * For deterministic tests, pass `virtualClock.setTimeout` from\n * `@directive-run/core` so the timeout fires under\n * `clock.advanceBy()` instead of wall-clock real time.\n *\n * @example\n * ```ts\n * import { virtualClock } from '@directive-run/core';\n * const clock = virtualClock(0);\n * cancellable({ timeoutMs: 1_000, setTimeout: clock.setTimeout }, handler);\n * ```\n */\n setTimeout?: (cb: () => void, ms: number) => () => void;\n}\n\n/**\n * Handler context augmented with a cancellation signal.\n */\nexport interface CancellableHandlerContext<F, P> {\n facts: F;\n payload: P;\n /** Aborts when a new dispatch supersedes this one OR the timeout fires. */\n signal: AbortSignal;\n requeue: () => void;\n}\n\n/**\n * Reason a cancellable handler's signal aborted. Stamped on\n * `signal.reason` so the handler can disambiguate.\n *\n * Note: at runtime the value is a {@link CancelError} subclass with\n * the same shape — `signal.reason instanceof CancelError` is the\n * canonical check. Older usages that did `signal.reason?.kind ===\n * 'superseded'` continue to work because the Error subclass exposes\n * the same `kind` field. (R2 sec M-1.)\n */\nexport type CancelReason =\n | { kind: \"superseded\" }\n | { kind: \"timeout\"; afterMs: number };\n\n/**\n * Runtime carrier for {@link CancelReason}. Subclasses `Error` so the\n * value survives transit through `fetch(url, { signal })` and other\n * web-platform APIs that re-throw `signal.reason`. Older mutator\n * versions passed plain objects, which `.catch(err => err instanceof\n * Error)` filters silently dropped. (R2 sec M-1.)\n */\nexport class CancelError extends Error {\n constructor(\n public readonly kind: CancelReason[\"kind\"],\n message?: string,\n ) {\n super(message ?? `[mutator] cancellable: ${kind}`);\n this.name = \"CancelError\";\n }\n}\n\n/** Subclass of {@link CancelError} for timeout-driven aborts. */\nexport class TimeoutCancelError extends CancelError {\n constructor(public readonly afterMs: number) {\n super(\"timeout\", `[mutator] cancellable: timeout after ${afterMs}ms`);\n this.name = \"TimeoutCancelError\";\n }\n}\n\n/** Subclass of {@link CancelError} for supersede-driven aborts. */\nexport class SupersededCancelError extends CancelError {\n constructor() {\n super(\"superseded\", \"[mutator] cancellable: superseded by new dispatch\");\n this.name = \"SupersededCancelError\";\n }\n}\n\n/**\n * Factory for cancel-reason values. Pure — these are the runtime\n * counterparts of the {@link CancelReason} type. Use them in custom\n * cancellation flows that need a typed reason without re-typing the\n * literal.\n */\nexport const cancelReason = {\n superseded: (): SupersededCancelError => new SupersededCancelError(),\n timeout: (afterMs: number): TimeoutCancelError =>\n new TimeoutCancelError(afterMs),\n} as const;\n\n/**\n * Wrap a mutator handler with auto-cancellation. The wrapped handler\n * receives an extra `signal: AbortSignal` in its context. Use the\n * signal to short-circuit awaitable work — pass it to `fetch(url, {\n * signal })`, watch it inside long-running loops, etc.\n *\n * Two cancellation triggers, both opt-in via {@link CancellableOptions}:\n *\n * 1. **Supersession** (default `supersedeOn: 'self'`): when a new\n * dispatch of the same wrapped handler arrives while a prior\n * invocation is still running, the prior signal aborts.\n * 2. **Timeout** (default `timeoutMs: undefined`, meaning no timeout):\n * after `timeoutMs` ms from invocation start, the signal aborts.\n *\n * If a handler's signal aborts, the handler should observe the abort\n * (via `signal.aborted` or the AbortError-throwing helpers) and\n * return promptly. The signal's `reason` carries a {@link CancelReason}\n * disambiguating which trigger fired.\n *\n * Compose with {@link defineMutator} by using `cancellable()` directly\n * in the handler map:\n *\n * @example\n * ```ts\n * import { defineMutator, cancellable } from '@directive-run/mutator';\n *\n * const formMutator = defineMutator<MyMutations, MyFacts>({\n * search: cancellable(\n * { supersedeOn: 'self', timeoutMs: 3_000 },\n * async ({ payload, facts, signal }) => {\n * const res = await fetch(`/q?${payload.q}`, { signal });\n * facts.results = await res.json();\n * },\n * ),\n * submit: async ({ payload, facts }) => {\n * // No cancellation needed for submit — plain handler.\n * facts.values = await deps.submit(payload.values);\n * },\n * });\n * ```\n *\n * **Idempotency note.** The wrapped handler stays a regular\n * `MutationHandler<M, K, F>` from the mutator's perspective. The\n * supersession registry is closure-scoped per `cancellable()` call —\n * two separate `cancellable(...)` HOCs around different handlers do\n * NOT cancel each other.\n *\n * **Test ergonomics.** Pass `virtualClock.setTimeout` via the\n * `setTimeout` option to make timeouts deterministic under\n * `clock.advanceBy(ms)`. Without that, timeouts use wall-clock\n * `globalThis.setTimeout` and are real-time.\n */\nexport function cancellable<F, P>(\n opts: CancellableOptions,\n handler: (ctx: CancellableHandlerContext<F, P>) => Promise<void> | void,\n): (ctx: { facts: F; payload: P; requeue: () => void }) => Promise<void> {\n const supersedeOn = opts.supersedeOn ?? \"self\";\n const timeoutMs = opts.timeoutMs;\n const scheduleTimeout = opts.setTimeout ?? defaultSetTimeout;\n\n // Closure-scoped supersession slot — one entry for the wrapped\n // handler. When a new invocation arrives, the prior entry's\n // controller aborts before the new one starts.\n let priorController: AbortController | undefined;\n\n return async (ctx: { facts: F; payload: P; requeue: () => void }) => {\n // Supersession: abort the prior in-flight invocation, if any.\n // R2 sec M-1: pass an Error subclass so signal.reason survives\n // re-throw through fetch / .catch(err => err instanceof Error)\n // / logging frameworks. Plain object reasons silently fail those\n // checks downstream.\n if (supersedeOn === \"self\" && priorController !== undefined) {\n priorController.abort(cancelReason.superseded());\n }\n\n const controller = new AbortController();\n priorController = controller;\n\n // Timeout: schedule an abort after `timeoutMs`. The cancel handle\n // returned by `scheduleTimeout` lets us clear the timer if the\n // handler completes first (saves leaking timers under a barrage\n // of dispatches).\n let cancelTimeout: (() => void) | undefined;\n if (typeof timeoutMs === \"number\" && timeoutMs > 0) {\n cancelTimeout = scheduleTimeout(() => {\n controller.abort(cancelReason.timeout(timeoutMs));\n }, timeoutMs);\n }\n\n try {\n await handler({\n facts: ctx.facts,\n payload: ctx.payload,\n requeue: ctx.requeue,\n signal: controller.signal,\n });\n } finally {\n // Clean up: clear the timeout (if it hasn't fired) and release\n // the supersession slot if it still belongs to this invocation.\n // R2 sec M-3: wrap cancelTimeout in try/catch so a hostile\n // setTimeout-cancel-handle (e.g. a custom virtual clock that\n // throws on cancel) cannot shadow the original handler's\n // exception.\n try {\n cancelTimeout?.();\n } catch {\n /* swallow — the cancel-handle's failure is not the caller's problem */\n }\n if (priorController === controller) {\n priorController = undefined;\n }\n }\n };\n}\n\n/**\n * Default `setTimeout` shim — wraps `globalThis.setTimeout` to match\n * the cancel-handle signature `cancellable()` expects.\n */\nfunction defaultSetTimeout(cb: () => void, ms: number): () => void {\n const handle = globalThis.setTimeout(cb, ms);\n return () => globalThis.clearTimeout(handle);\n}\n\n// ────────────────────────────────────────────────────────────────────────────\n// recordReplayable — R2.B\n// ────────────────────────────────────────────────────────────────────────────\n\n/**\n * Structured event surfaced to {@link RecordReplayableOptions.onCancel}\n * the moment a wrapped invocation's signal aborts. Carries enough\n * information for the caller to pin the cancellation into the timeline\n * (e.g. by writing onto a facts array) so a later replay can\n * reconstruct the cancellation race exactly.\n *\n * @typeParam F — caller's facts type (passed through unchanged from the handler context).\n * @typeParam P — caller's payload type for THIS handler.\n */\nexport interface CancelEvent<F, P> {\n /** Whether this cancel was driven by a new dispatch superseding the old, or by the timeout firing. */\n kind: CancelReason[\"kind\"];\n /** When `kind === 'timeout'`, the original `timeoutMs` that fired. Undefined for `'superseded'`. */\n afterMs?: number;\n /** The payload of the dispatch that was cancelled — i.e. the work that did NOT complete. */\n payload: P;\n /**\n * Per-handler monotonic counter, incremented once at the start of\n * every invocation. Useful for timeline diff: cancel of seq=4 is the\n * 4th dispatch this handler ever saw. The counter is closure-scoped\n * to a single `recordReplayable()` call — separate HOCs do NOT share\n * the counter.\n */\n dispatchSeq: number;\n /** Live facts reference, mirroring {@link CancellableHandlerContext.facts}. */\n facts: F;\n}\n\n/**\n * Options for {@link recordReplayable}. Strict superset of\n * {@link CancellableOptions} — every field on `CancellableOptions`\n * passes through unchanged to the underlying {@link cancellable} call.\n */\nexport interface RecordReplayableOptions<F, P> extends CancellableOptions {\n /**\n * Synchronous callback invoked the moment a wrapped invocation's\n * AbortController fires `abort()`. Runs BEFORE the handler's pending\n * await rejects with AbortError, so the callback sees the freshest\n * possible state.\n *\n * Use this to pin the cancellation into a place that survives in the\n * timeline (typically a facts field — `facts.cancellations.push(info)`).\n * Without that, a replay re-dispatches the same MUTATE events but has\n * no record of which ones were superseded vs which completed.\n *\n * Throws inside `onCancel` are caught and swallowed — the abort path\n * must remain robust. If you need to fail loudly, log to your own\n * sink before re-throwing.\n */\n onCancel?: (info: CancelEvent<F, P>) => void;\n}\n\n/**\n * `cancellable()` plus a synchronous on-abort callback. Wrap your\n * handler with `recordReplayable()` instead of `cancellable()` when\n * you want a hook that fires the moment a supersession or timeout\n * aborts the in-flight invocation — *before* the handler's pending\n * await rejects with AbortError. The callback receives a structured\n * {@link CancelEvent} carrying the cancel kind, payload, dispatch\n * sequence, and a live facts reference.\n *\n * The callback is GENERIC (\"call me when abort fires\"); the most\n * common use case is pinning cancel events into facts so a recorded\n * timeline carries them — which then lets `replayTimeline()`,\n * `bisectTimeline()`, and `diffTimelines()` (all in\n * `@directive-run/timeline`) reason about which dispatches were\n * superseded vs which completed without parsing free-form error\n * strings. But the same callback works equally well for Sentry\n * breadcrumbs, a Redux action log, OpenTelemetry spans, or a metrics\n * sink — `recordReplayable` doesn't know or care about the timeline.\n *\n * @example Pin cancel events into facts so the timeline carries them:\n * ```ts\n * import { defineMutator, recordReplayable } from '@directive-run/mutator';\n *\n * const search = recordReplayable<MyFacts, { q: string }>(\n * {\n * supersedeOn: 'self',\n * timeoutMs: 3_000,\n * onCancel: ({ facts, kind, payload, dispatchSeq }) => {\n * facts.cancellations.push({ kind, queryAtCancel: payload.q, seq: dispatchSeq });\n * },\n * },\n * async ({ payload, facts, signal }) => {\n * const res = await fetch(`/q?${payload.q}`, { signal });\n * facts.results = await res.json();\n * },\n * );\n * ```\n *\n * Implementation note: `recordReplayable()` is `cancellable(opts,\n * innerHandler)` where `innerHandler` adds an `addEventListener('abort')`\n * around the user's handler. This means timeout / supersession\n * semantics are EXACTLY those of `cancellable()` — the HOC is purely\n * additive.\n */\nexport function recordReplayable<F, P>(\n opts: RecordReplayableOptions<F, P>,\n handler: (ctx: CancellableHandlerContext<F, P>) => Promise<void> | void,\n): (ctx: { facts: F; payload: P; requeue: () => void }) => Promise<void> {\n let dispatchSeq = 0;\n\n const wrappedHandler = async (\n ctx: CancellableHandlerContext<F, P>,\n ): Promise<void> => {\n const seq = ++dispatchSeq;\n const onAbort = (): void => {\n // Only fire onCancel for our typed CancelError reasons; if the\n // signal was aborted via some other path (caller's own\n // controller, etc), let it pass silently.\n const reason = ctx.signal.reason;\n if (!(reason instanceof CancelError)) return;\n const info: CancelEvent<F, P> = {\n kind: reason.kind,\n afterMs:\n reason instanceof TimeoutCancelError ? reason.afterMs : undefined,\n payload: ctx.payload,\n dispatchSeq: seq,\n facts: ctx.facts,\n };\n try {\n opts.onCancel?.(info);\n } catch {\n // Callback errors must NOT bubble up the abort path. The user's\n // handler is still about to receive AbortError — we don't want\n // to mask that with an onCancel-side throw, and we don't want\n // to crash the controller in the middle of cleanup.\n }\n };\n ctx.signal.addEventListener(\"abort\", onAbort, { once: true });\n try {\n await handler(ctx);\n } finally {\n ctx.signal.removeEventListener(\"abort\", onAbort);\n }\n };\n\n return cancellable<F, P>(opts, wrappedHandler);\n}\n"]}
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"names":["MAX_ERROR_LEN","truncateError","input","str","raw","safeStringCoerce","value","defineMutator","handlers","t","facts","payload","_req","ctx","factsRef","pending","handler","handlerCtx","err","mutate","kind","CancelError","message","TimeoutCancelError","afterMs","SupersededCancelError","cancelReason","cancellable","opts","supersedeOn","timeoutMs","scheduleTimeout","defaultSetTimeout","priorController","controller","cancelTimeout","cb","ms","handle","recordReplayable","dispatchSeq","seq","onAbort","reason","info"],"mappings":"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","file":"index.js","sourcesContent":["/**\n * @directive-run/mutator\n *\n * Discriminated mutation helper. Collapses the manual `pendingAction`\n * ceremony — fact + event + constraint + resolver — into a typed handler\n * map.\n *\n * Background: across the 55-cycle Minglingo migration, 12 modules ended\n * up with the same shape:\n * - a nullable `pendingAction` fact holding a discriminated union\n * - an event that sets it\n * - a constraint that fires on non-null\n * - a resolver that switches on the discriminator and clears the fact\n *\n * That's ~50 lines of boilerplate per module times 12 modules. The\n * `defineMutator` helper below contributes all four pieces from a single\n * typed declaration, so a module that uses it spreads the fragments\n * into its `createModule` config and writes only the per-variant handler\n * bodies.\n *\n * @see ../README.md for the full API and a worked example.\n */\n\nimport { t } from \"@directive-run/core\";\n\n/**\n * A keyed map of variant payloads. Each key becomes a discriminator value\n * for the mutation, each value is the payload type for that variant.\n *\n * @example\n * ```ts\n * type MyMutations = {\n * submit: { values: FormValues };\n * cancel: {};\n * retry: { reason: string };\n * };\n * ```\n */\nexport type MutationMap = Record<string, Record<string, unknown>>;\n\n/**\n * The shape of a `pendingMutation` fact while a mutation is queued or\n * running.\n */\nexport type PendingMutation<M extends MutationMap> = {\n [K in keyof M]: {\n kind: K;\n payload: M[K];\n /**\n * `pending` — queued, constraint hasn't fired yet.\n * `running` — handler is in flight.\n * `failed` — handler threw; constraint stops firing. Caller can\n * inspect `error` and dispatch a fresh MUTATE to retry.\n */\n status: \"pending\" | \"running\" | \"failed\";\n /**\n * Error message from the previous run, if any. Plaintext only —\n * NEVER render this directly via `dangerouslySetInnerHTML` or\n * markdown. Truncated to 500 chars to bound XSS surface from\n * thrown messages that may echo user input.\n */\n error: string | null;\n };\n}[keyof M];\n\n/** Maximum length of a captured error message — bounded to limit XSS surface. */\nconst MAX_ERROR_LEN = 500;\n\n/**\n * Coerce a thrown value to a bounded plaintext string for storage on\n * `pendingMutation.error`. Defends against:\n * - non-Error throws (`throw \"string\"`, `throw 42`, `throw {}`)\n * - Errors with non-string `.message` (overridden numeric / Symbol /\n * buffer); naive `.length` / `.slice` would `TypeError` otherwise\n * - extremely long messages with attacker-influenced input\n *\n * Returns at most {@link MAX_ERROR_LEN} characters of plaintext.\n * Plaintext rendering is still the only supported path on the\n * consumer side; this is defense in depth, not an XSS sanitizer.\n *\n * (R2 sec M-R2-1.)\n */\nfunction truncateError(input: unknown): string {\n let str: string;\n if (input instanceof Error) {\n // Reading `.message` is wrapped in try/catch because a maliciously\n // constructed Error subclass may install a throwing getter on\n // `message`. Without this guard the throw escapes truncateError →\n // escapes the resolver's catch → propagates uncaught. (R4 backlog.)\n let raw: unknown;\n try {\n raw = input.message;\n } catch {\n raw = \"[mutator: error.message getter threw]\";\n }\n str = typeof raw === \"string\" ? raw : safeStringCoerce(raw);\n } else if (typeof input === \"string\") {\n str = input;\n } else {\n str = safeStringCoerce(input);\n }\n if (str.length <= MAX_ERROR_LEN) return str;\n return `${str.slice(0, MAX_ERROR_LEN - 1)}…`;\n}\n\n/** String() may throw for some Symbols + objects with hostile toString. */\nfunction safeStringCoerce(value: unknown): string {\n try {\n return String(value);\n } catch {\n return \"[mutator: unstringifiable error]\";\n }\n}\n\n/**\n * Handler context passed to each variant handler.\n *\n * Note: `deps` is NOT in the context. This matches the Directive resolver\n * idiom — close over deps from the outer module-factory scope:\n *\n * ```ts\n * function createFormModule(deps: FormDeps) {\n * const mut = defineMutator<FormMutations, FormFacts>({\n * submit: async ({ payload, facts }) => {\n * facts.values = await deps.submit(payload.values); // ← closure\n * },\n * });\n * return createModule('form', { ... });\n * }\n * ```\n */\nexport interface MutatorHandlerContext<F> {\n /** Live facts proxy. Reads are cache-tracked; writes invalidate. */\n facts: F;\n /**\n * Trigger a same-constraint re-fire after this handler returns. Useful\n * when one mutation cascades into another — without `requeue`, the next\n * mutation would stall behind same-flush suppression.\n *\n * @see https://docs.directive.run/testing/chained-pipelines\n */\n requeue: () => void;\n}\n\n/**\n * Variant handler body. Receives the typed payload + a context; returns\n * void or a Promise. Throwing is fine — the runtime captures into\n * `pendingMutation.error` before clearing the fact.\n */\nexport type MutationHandler<\n M extends MutationMap,\n K extends keyof M,\n F,\n> = keyof M[K] extends never\n ? (ctx: MutatorHandlerContext<F>) => void | Promise<void>\n : (\n ctx: MutatorHandlerContext<F> & { payload: M[K] },\n ) => void | Promise<void>;\n\n/**\n * The full handler map. Every variant in `M` MUST have a handler.\n */\nexport type MutationHandlers<M extends MutationMap, F> = {\n [K in keyof M]: MutationHandler<M, K, F>;\n};\n\n/**\n * The fragments returned by `defineMutator`. Spread each fragment into\n * the matching position of your `createModule` config.\n *\n * @internal Shape documented for type clarity; users typically just\n * spread.\n */\nexport interface MutatorFragments<M extends MutationMap, F> {\n /** Spread into `schema.facts`. Adds `pendingMutation`. */\n facts: {\n pendingMutation: ReturnType<typeof t.object>;\n };\n /** Spread into `schema.events`. Adds the `MUTATE` event. */\n events: {\n MUTATE: PendingMutation<M>;\n };\n /** Spread into `schema.requirements`. Adds `PROCESS_MUTATION`. */\n requirements: {\n PROCESS_MUTATION: Record<string, never>;\n };\n /** Spread into the `events` field. Sets pendingMutation on MUTATE. */\n eventHandlers: {\n MUTATE: (facts: F, payload: PendingMutation<M>) => void;\n };\n /** Spread into the `constraints` field. */\n constraints: {\n pendingMutation: {\n when: (facts: F) => boolean;\n require: { type: \"PROCESS_MUTATION\" };\n };\n };\n /** Spread into the `resolvers` field. */\n resolvers: {\n mutationResolver: {\n requirement: \"PROCESS_MUTATION\";\n resolve: (\n req: { type: \"PROCESS_MUTATION\" },\n ctx: { facts: F },\n ) => Promise<void>;\n };\n };\n /**\n * Convenience: the initial value for `pendingMutation`. Set this in\n * your module's `init` if you don't use `t.X().default(...)` defaults.\n */\n initialPendingMutation: null;\n}\n\n/**\n * Define a mutator fragment-set for a given variant map and handlers.\n *\n * @example\n * ```ts\n * type FormMutations = {\n * submit: { values: FormValues };\n * cancel: {};\n * };\n *\n * // The second generic is the FACTS type (not deps). Deps are\n * // captured in closure from the surrounding scope, not passed in\n * // through the handler ctx.\n * const formMutator = defineMutator<FormMutations, FormFacts>({\n * submit: async ({ payload, facts }) => {\n * facts.values = await deps.submit(payload.values); // deps closes over outer scope\n * },\n * cancel: ({ facts }) => { facts.values = []; },\n * });\n *\n * createModule('form', {\n * schema: {\n * facts: { ...formMutator.facts, values: t.array<FormValues>() },\n * events: { ...formMutator.events, REFRESH: {} },\n * requirements: { ...formMutator.requirements },\n * },\n * init: (f) => { f.pendingMutation = null; f.values = []; },\n * events: {\n * ...formMutator.eventHandlers,\n * REFRESH: (f) => { f.values = []; },\n * },\n * constraints: { ...formMutator.constraints },\n * resolvers: { ...formMutator.resolvers },\n * });\n *\n * // Usage:\n * sys.events.MUTATE(mutate<FormMutations>('submit', { values: ... }));\n *\n * // Or, if constructing the payload manually, the discriminator is\n * // `kind` (NOT `type` — `type` collides with Directive's event-name\n * // dispatch convention):\n * sys.events.MUTATE({\n * kind: 'submit',\n * payload: { values: ... },\n * status: 'pending',\n * error: null,\n * });\n * ```\n *\n * The handler-bound deps are captured at `defineMutator` call time. To\n * inject deps from the caller, wrap `defineMutator` in your module\n * factory:\n *\n * ```ts\n * export function createFormModule(deps: FormDeps) {\n * const mut = defineMutator<FormMutations, FormFacts>({\n * submit: async ({ payload, facts }) => {\n * facts.values = await deps.submit(payload.values);\n * },\n * cancel: ({ facts }) => { facts.values = []; },\n * });\n * return createModule('form', { ... });\n * }\n * ```\n */\nexport function defineMutator<\n M extends MutationMap,\n F = Record<string, unknown>,\n>(handlers: MutationHandlers<M, F>): MutatorFragments<M, F> {\n type Pending = PendingMutation<M>;\n\n const facts = {\n pendingMutation: t.object<Pending>().nullable() as ReturnType<\n typeof t.object\n >,\n } as MutatorFragments<M, F>[\"facts\"];\n\n // Schema event marker — the runtime uses this for typing and devtools.\n // Payload validation happens at dispatch time via t-schema check.\n const events = {\n MUTATE: undefined as unknown as Pending,\n } as MutatorFragments<M, F>[\"events\"];\n\n const requirements = {\n PROCESS_MUTATION: {} as Record<string, never>,\n } as MutatorFragments<M, F>[\"requirements\"];\n\n const eventHandlers = {\n MUTATE: (facts: F, payload: Pending) => {\n // Overwrite is intentional — caller is responsible for ordering.\n // If a previous mutation is mid-flight, the new one queues by\n // overwriting; the in-flight handler will null the fact when it\n // completes, then the constraint re-fires for the new one.\n // (Same as the manual pattern across all 12 audited modules.)\n (facts as { pendingMutation: Pending | null }).pendingMutation = {\n ...payload,\n status: \"pending\",\n error: null,\n };\n },\n } as MutatorFragments<M, F>[\"eventHandlers\"];\n\n const constraints = {\n pendingMutation: {\n when: (facts: F) =>\n (facts as { pendingMutation: Pending | null }).pendingMutation !==\n null &&\n (facts as { pendingMutation: Pending | null }).pendingMutation\n ?.status === \"pending\",\n require: { type: \"PROCESS_MUTATION\" } as const,\n },\n } as MutatorFragments<M, F>[\"constraints\"];\n\n const resolvers = {\n mutationResolver: {\n requirement: \"PROCESS_MUTATION\" as const,\n resolve: async (\n _req: { type: \"PROCESS_MUTATION\" },\n ctx: {\n facts: F;\n requeue?: () => void;\n },\n ) => {\n const factsRef = ctx.facts as { pendingMutation: Pending | null };\n const pending = factsRef.pendingMutation;\n if (pending === null) return;\n\n // Stamp the in-flight transition with the running status. The\n // status itself is the supersession marker: only this resolver\n // ever writes 'running'; the eventHandler always writes\n // 'pending'. So if a fresh MUTATE arrives mid-flight, the\n // post-handler check sees status: 'pending' instead of\n // 'running' and knows to leave the new dispatch alone.\n // (Sec M5 / DX M2.)\n factsRef.pendingMutation = { ...pending, status: \"running\" };\n\n // Prototype-pollution defense: only accept handlers OWNED by\n // the user-provided map. Without this, dispatching with kind:\n // 'constructor' / 'toString' / '__proto__' would resolve via\n // the prototype chain and either crash or invoke an inherited\n // function with arbitrary payload. (Sec C1.)\n const ownsHandler = Object.prototype.hasOwnProperty.call(\n handlers,\n pending.kind as PropertyKey,\n );\n const handler = ownsHandler\n ? (handlers as Record<string, unknown>)[pending.kind as string]\n : undefined;\n\n if (typeof handler !== \"function\") {\n factsRef.pendingMutation = {\n ...pending,\n status: \"failed\",\n error: truncateError(\n `[mutator] no handler registered for variant: ${String(\n pending.kind,\n )}`,\n ),\n };\n return;\n }\n\n const handlerCtx = {\n facts: ctx.facts,\n payload: pending.payload,\n requeue: ctx.requeue ?? (() => {}),\n };\n\n try {\n await (handler as (c: typeof handlerCtx) => Promise<void> | void)(\n handlerCtx,\n );\n // Success: clear the fact ONLY if this resolver's `running`\n // marker is still live (status === 'running'). If a fresh\n // MUTATE arrived mid-flight via the eventHandler, the fact\n // now has status: 'pending' — leave it alone so the next\n // constraint fire picks it up. (Sec M5 / DX M2.)\n if (factsRef.pendingMutation?.status === \"running\") {\n factsRef.pendingMutation = null;\n }\n } catch (err) {\n // Failure: only stamp the error if our running marker is\n // still live. If a fresh MUTATE arrived mid-flight, the new\n // dispatch wins; the failed mutation's error is dropped on\n // the floor (caller wanted to move on anyway). (Sec M5.)\n if (factsRef.pendingMutation?.status !== \"running\") return;\n factsRef.pendingMutation = {\n ...pending,\n // 'failed' is a distinct status (Sec M6 / Arch C2) — UI can\n // disambiguate \"still in flight\" from \"stopped on error\".\n status: \"failed\",\n // truncateError handles the unknown shape safely — non-Error\n // throws, non-string Error.message, hostile toString. (R2\n // sec M-R2-1.)\n error: truncateError(err),\n };\n }\n },\n },\n } as MutatorFragments<M, F>[\"resolvers\"];\n\n return {\n facts,\n events,\n requirements,\n eventHandlers,\n constraints,\n resolvers,\n initialPendingMutation: null,\n };\n}\n\n/**\n * Helper for typed dispatch. Lets the caller construct a mutation payload\n * with full type narrowing — the `kind` field auto-restricts the\n * `payload` shape.\n *\n * @example\n * ```ts\n * sys.events.MUTATE(mutate<FormMutations>('submit', { values: ... }));\n * sys.events.MUTATE(mutate<FormMutations>('cancel'));\n * ```\n */\n// Single type parameter (M) so callers can specialize with\n// `mutate<FormMutations>(...)` without TypeScript demanding the\n// inferred K. TS's strict type-argument rules treat <M, K> as\n// \"supply both or supply neither\" — single-arg call sites failed\n// with \"Expected 2 type arguments, but got 1.\" Single-M form sidesteps\n// that while keeping payload typing tight via the `kind` lookup.\nexport function mutate<M extends MutationMap>(\n kind: keyof M & string,\n payload: M[typeof kind],\n): PendingMutation<M>;\nexport function mutate<M extends MutationMap>(\n kind: keyof M & string,\n): PendingMutation<M>;\nexport function mutate<M extends MutationMap>(\n kind: keyof M & string,\n payload?: M[typeof kind],\n): PendingMutation<M> {\n return {\n kind,\n payload: (payload ?? {}) as M[typeof kind],\n status: \"pending\",\n error: null,\n } as PendingMutation<M>;\n}\n\n// ============================================================================\n// cancellable() — auto-cancel-on-supersede (R1.C v0.1)\n// ============================================================================\n\n/**\n * Options for {@link cancellable}.\n */\nexport interface CancellableOptions {\n /**\n * When to fire the AbortSignal that the wrapped handler receives.\n *\n * - `'self'` (default): a new dispatch of the SAME handler aborts the\n * prior in-flight invocation. The classic \"cancel previous on new\n * keystroke\" pattern.\n * - `'never'`: only the timeout fires the signal; new dispatches do\n * NOT abort prior ones. Useful when you want a hard timeout but\n * parallel runs are fine.\n */\n supersedeOn?: \"self\" | \"never\";\n\n /**\n * Maximum ms a handler may run before its signal is aborted. Counted\n * from the start of THIS invocation. Combine with `realClock` for\n * production or pass a `setTimeout` shim for deterministic testing.\n */\n timeoutMs?: number;\n\n /**\n * Optional `setTimeout` injection. Defaults to `globalThis.setTimeout`.\n * For deterministic tests, pass `virtualClock.setTimeout` from\n * `@directive-run/core` so the timeout fires under\n * `clock.advanceBy()` instead of wall-clock real time.\n *\n * @example\n * ```ts\n * import { virtualClock } from '@directive-run/core';\n * const clock = virtualClock(0);\n * cancellable({ timeoutMs: 1_000, setTimeout: clock.setTimeout }, handler);\n * ```\n */\n setTimeout?: (cb: () => void, ms: number) => () => void;\n}\n\n/**\n * Handler context augmented with a cancellation signal.\n */\nexport interface CancellableHandlerContext<F, P> {\n facts: F;\n payload: P;\n /** Aborts when a new dispatch supersedes this one OR the timeout fires. */\n signal: AbortSignal;\n requeue: () => void;\n}\n\n/**\n * Reason a cancellable handler's signal aborted. Stamped on\n * `signal.reason` so the handler can disambiguate.\n *\n * Note: at runtime the value is a {@link CancelError} subclass with\n * the same shape — `signal.reason instanceof CancelError` is the\n * canonical check. Older usages that did `signal.reason?.kind ===\n * 'superseded'` continue to work because the Error subclass exposes\n * the same `kind` field. (R2 sec M-1.)\n */\nexport type CancelReason =\n | { kind: \"superseded\" }\n | { kind: \"timeout\"; afterMs: number };\n\n/**\n * Runtime carrier for {@link CancelReason}. Subclasses `Error` so the\n * value survives transit through `fetch(url, { signal })` and other\n * web-platform APIs that re-throw `signal.reason`. Older mutator\n * versions passed plain objects, which `.catch(err => err instanceof\n * Error)` filters silently dropped. (R2 sec M-1.)\n */\nexport class CancelError extends Error {\n constructor(public readonly kind: CancelReason[\"kind\"], message?: string) {\n super(message ?? `[mutator] cancellable: ${kind}`);\n this.name = \"CancelError\";\n }\n}\n\n/** Subclass of {@link CancelError} for timeout-driven aborts. */\nexport class TimeoutCancelError extends CancelError {\n constructor(public readonly afterMs: number) {\n super(\"timeout\", `[mutator] cancellable: timeout after ${afterMs}ms`);\n this.name = \"TimeoutCancelError\";\n }\n}\n\n/** Subclass of {@link CancelError} for supersede-driven aborts. */\nexport class SupersededCancelError extends CancelError {\n constructor() {\n super(\"superseded\", \"[mutator] cancellable: superseded by new dispatch\");\n this.name = \"SupersededCancelError\";\n }\n}\n\n/**\n * Factory for cancel-reason values. Pure — these are the runtime\n * counterparts of the {@link CancelReason} type. Use them in custom\n * cancellation flows that need a typed reason without re-typing the\n * literal.\n */\nexport const cancelReason = {\n superseded: (): SupersededCancelError => new SupersededCancelError(),\n timeout: (afterMs: number): TimeoutCancelError =>\n new TimeoutCancelError(afterMs),\n} as const;\n\n/**\n * Wrap a mutator handler with auto-cancellation. The wrapped handler\n * receives an extra `signal: AbortSignal` in its context. Use the\n * signal to short-circuit awaitable work — pass it to `fetch(url, {\n * signal })`, watch it inside long-running loops, etc.\n *\n * Two cancellation triggers, both opt-in via {@link CancellableOptions}:\n *\n * 1. **Supersession** (default `supersedeOn: 'self'`): when a new\n * dispatch of the same wrapped handler arrives while a prior\n * invocation is still running, the prior signal aborts.\n * 2. **Timeout** (default `timeoutMs: undefined`, meaning no timeout):\n * after `timeoutMs` ms from invocation start, the signal aborts.\n *\n * If a handler's signal aborts, the handler should observe the abort\n * (via `signal.aborted` or the AbortError-throwing helpers) and\n * return promptly. The signal's `reason` carries a {@link CancelReason}\n * disambiguating which trigger fired.\n *\n * Compose with {@link defineMutator} by using `cancellable()` directly\n * in the handler map:\n *\n * @example\n * ```ts\n * import { defineMutator, cancellable } from '@directive-run/mutator';\n *\n * const formMutator = defineMutator<MyMutations, MyFacts>({\n * search: cancellable(\n * { supersedeOn: 'self', timeoutMs: 3_000 },\n * async ({ payload, facts, signal }) => {\n * const res = await fetch(`/q?${payload.q}`, { signal });\n * facts.results = await res.json();\n * },\n * ),\n * submit: async ({ payload, facts }) => {\n * // No cancellation needed for submit — plain handler.\n * facts.values = await deps.submit(payload.values);\n * },\n * });\n * ```\n *\n * **Idempotency note.** The wrapped handler stays a regular\n * `MutationHandler<M, K, F>` from the mutator's perspective. The\n * supersession registry is closure-scoped per `cancellable()` call —\n * two separate `cancellable(...)` HOCs around different handlers do\n * NOT cancel each other.\n *\n * **Test ergonomics.** Pass `virtualClock.setTimeout` via the\n * `setTimeout` option to make timeouts deterministic under\n * `clock.advanceBy(ms)`. Without that, timeouts use wall-clock\n * `globalThis.setTimeout` and are real-time.\n */\nexport function cancellable<F, P>(\n opts: CancellableOptions,\n handler: (ctx: CancellableHandlerContext<F, P>) => Promise<void> | void,\n): (ctx: { facts: F; payload: P; requeue: () => void }) => Promise<void> {\n const supersedeOn = opts.supersedeOn ?? \"self\";\n const timeoutMs = opts.timeoutMs;\n const scheduleTimeout = opts.setTimeout ?? defaultSetTimeout;\n\n // Closure-scoped supersession slot — one entry for the wrapped\n // handler. When a new invocation arrives, the prior entry's\n // controller aborts before the new one starts.\n let priorController: AbortController | undefined;\n\n return async (ctx: { facts: F; payload: P; requeue: () => void }) => {\n // Supersession: abort the prior in-flight invocation, if any.\n // R2 sec M-1: pass an Error subclass so signal.reason survives\n // re-throw through fetch / .catch(err => err instanceof Error)\n // / logging frameworks. Plain object reasons silently fail those\n // checks downstream.\n if (supersedeOn === \"self\" && priorController !== undefined) {\n priorController.abort(cancelReason.superseded());\n }\n\n const controller = new AbortController();\n priorController = controller;\n\n // Timeout: schedule an abort after `timeoutMs`. The cancel handle\n // returned by `scheduleTimeout` lets us clear the timer if the\n // handler completes first (saves leaking timers under a barrage\n // of dispatches).\n let cancelTimeout: (() => void) | undefined;\n if (typeof timeoutMs === \"number\" && timeoutMs > 0) {\n cancelTimeout = scheduleTimeout(() => {\n controller.abort(cancelReason.timeout(timeoutMs));\n }, timeoutMs);\n }\n\n try {\n await handler({\n facts: ctx.facts,\n payload: ctx.payload,\n requeue: ctx.requeue,\n signal: controller.signal,\n });\n } finally {\n // Clean up: clear the timeout (if it hasn't fired) and release\n // the supersession slot if it still belongs to this invocation.\n // R2 sec M-3: wrap cancelTimeout in try/catch so a hostile\n // setTimeout-cancel-handle (e.g. a custom virtual clock that\n // throws on cancel) cannot shadow the original handler's\n // exception.\n try {\n cancelTimeout?.();\n } catch {\n /* swallow — the cancel-handle's failure is not the caller's problem */\n }\n if (priorController === controller) {\n priorController = undefined;\n }\n }\n };\n}\n\n/**\n * Default `setTimeout` shim — wraps `globalThis.setTimeout` to match\n * the cancel-handle signature `cancellable()` expects.\n */\nfunction defaultSetTimeout(cb: () => void, ms: number): () => void {\n const handle = globalThis.setTimeout(cb, ms);\n return () => globalThis.clearTimeout(handle);\n}\n\n// ────────────────────────────────────────────────────────────────────────────\n// recordReplayable — R2.B\n// ────────────────────────────────────────────────────────────────────────────\n\n/**\n * Structured event surfaced to {@link RecordReplayableOptions.onCancel}\n * the moment a wrapped invocation's signal aborts. Carries enough\n * information for the caller to pin the cancellation into the timeline\n * (e.g. by writing onto a facts array) so a later replay can\n * reconstruct the cancellation race exactly.\n *\n * @typeParam F — caller's facts type (passed through unchanged from the handler context).\n * @typeParam P — caller's payload type for THIS handler.\n */\nexport interface CancelEvent<F, P> {\n /** Whether this cancel was driven by a new dispatch superseding the old, or by the timeout firing. */\n kind: CancelReason[\"kind\"];\n /** When `kind === 'timeout'`, the original `timeoutMs` that fired. Undefined for `'superseded'`. */\n afterMs?: number;\n /** The payload of the dispatch that was cancelled — i.e. the work that did NOT complete. */\n payload: P;\n /**\n * Per-handler monotonic counter, incremented once at the start of\n * every invocation. Useful for timeline diff: cancel of seq=4 is the\n * 4th dispatch this handler ever saw. The counter is closure-scoped\n * to a single `recordReplayable()` call — separate HOCs do NOT share\n * the counter.\n */\n dispatchSeq: number;\n /** Live facts reference, mirroring {@link CancellableHandlerContext.facts}. */\n facts: F;\n}\n\n/**\n * Options for {@link recordReplayable}. Strict superset of\n * {@link CancellableOptions} — every field on `CancellableOptions`\n * passes through unchanged to the underlying {@link cancellable} call.\n */\nexport interface RecordReplayableOptions<F, P> extends CancellableOptions {\n /**\n * Synchronous callback invoked the moment a wrapped invocation's\n * AbortController fires `abort()`. Runs BEFORE the handler's pending\n * await rejects with AbortError, so the callback sees the freshest\n * possible state.\n *\n * Use this to pin the cancellation into a place that survives in the\n * timeline (typically a facts field — `facts.cancellations.push(info)`).\n * Without that, a replay re-dispatches the same MUTATE events but has\n * no record of which ones were superseded vs which completed.\n *\n * Throws inside `onCancel` are caught and swallowed — the abort path\n * must remain robust. If you need to fail loudly, log to your own\n * sink before re-throwing.\n */\n onCancel?: (info: CancelEvent<F, P>) => void;\n}\n\n/**\n * `cancellable()` plus a synchronous on-abort callback. Wrap your\n * handler with `recordReplayable()` instead of `cancellable()` when\n * you want a hook that fires the moment a supersession or timeout\n * aborts the in-flight invocation — *before* the handler's pending\n * await rejects with AbortError. The callback receives a structured\n * {@link CancelEvent} carrying the cancel kind, payload, dispatch\n * sequence, and a live facts reference.\n *\n * The callback is GENERIC (\"call me when abort fires\"); the most\n * common use case is pinning cancel events into facts so a recorded\n * timeline carries them — which then lets `replayTimeline()`,\n * `bisectTimeline()`, and `diffTimelines()` (all in\n * `@directive-run/timeline`) reason about which dispatches were\n * superseded vs which completed without parsing free-form error\n * strings. But the same callback works equally well for Sentry\n * breadcrumbs, a Redux action log, OpenTelemetry spans, or a metrics\n * sink — `recordReplayable` doesn't know or care about the timeline.\n *\n * @example Pin cancel events into facts so the timeline carries them:\n * ```ts\n * import { defineMutator, recordReplayable } from '@directive-run/mutator';\n *\n * const search = recordReplayable<MyFacts, { q: string }>(\n * {\n * supersedeOn: 'self',\n * timeoutMs: 3_000,\n * onCancel: ({ facts, kind, payload, dispatchSeq }) => {\n * facts.cancellations.push({ kind, queryAtCancel: payload.q, seq: dispatchSeq });\n * },\n * },\n * async ({ payload, facts, signal }) => {\n * const res = await fetch(`/q?${payload.q}`, { signal });\n * facts.results = await res.json();\n * },\n * );\n * ```\n *\n * Implementation note: `recordReplayable()` is `cancellable(opts,\n * innerHandler)` where `innerHandler` adds an `addEventListener('abort')`\n * around the user's handler. This means timeout / supersession\n * semantics are EXACTLY those of `cancellable()` — the HOC is purely\n * additive.\n */\nexport function recordReplayable<F, P>(\n opts: RecordReplayableOptions<F, P>,\n handler: (ctx: CancellableHandlerContext<F, P>) => Promise<void> | void,\n): (ctx: { facts: F; payload: P; requeue: () => void }) => Promise<void> {\n let dispatchSeq = 0;\n\n const wrappedHandler = async (\n ctx: CancellableHandlerContext<F, P>,\n ): Promise<void> => {\n const seq = ++dispatchSeq;\n const onAbort = (): void => {\n // Only fire onCancel for our typed CancelError reasons; if the\n // signal was aborted via some other path (caller's own\n // controller, etc), let it pass silently.\n const reason = ctx.signal.reason;\n if (!(reason instanceof CancelError)) return;\n const info: CancelEvent<F, P> = {\n kind: reason.kind,\n afterMs:\n reason instanceof TimeoutCancelError ? reason.afterMs : undefined,\n payload: ctx.payload,\n dispatchSeq: seq,\n facts: ctx.facts,\n };\n try {\n opts.onCancel?.(info);\n } catch {\n // Callback errors must NOT bubble up the abort path. The user's\n // handler is still about to receive AbortError — we don't want\n // to mask that with an onCancel-side throw, and we don't want\n // to crash the controller in the middle of cleanup.\n }\n };\n ctx.signal.addEventListener(\"abort\", onAbort, { once: true });\n try {\n await handler(ctx);\n } finally {\n ctx.signal.removeEventListener(\"abort\", onAbort);\n }\n };\n\n return cancellable<F, P>(opts, wrappedHandler);\n}\n"]}
1
+ {"version":3,"sources":["../src/index.ts"],"names":["MAX_ERROR_LEN","truncateError","input","str","raw","safeStringCoerce","value","defineMutator","handlers","t","facts","payload","_req","ctx","factsRef","pending","handler","handlerCtx","err","mutate","kind","CancelError","message","TimeoutCancelError","afterMs","SupersededCancelError","cancelReason","cancellable","opts","supersedeOn","timeoutMs","scheduleTimeout","defaultSetTimeout","priorController","controller","cancelTimeout","cb","ms","handle","recordReplayable","dispatchSeq","seq","onAbort","reason","info"],"mappings":"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","file":"index.js","sourcesContent":["/**\n * @directive-run/mutator\n *\n * Discriminated mutation helper. Collapses the manual `pendingAction`\n * ceremony — fact + event + constraint + resolver — into a typed handler\n * map.\n *\n * Background: across the 55-cycle Minglingo migration, 12 modules ended\n * up with the same shape:\n * - a nullable `pendingAction` fact holding a discriminated union\n * - an event that sets it\n * - a constraint that fires on non-null\n * - a resolver that switches on the discriminator and clears the fact\n *\n * That's ~50 lines of boilerplate per module times 12 modules. The\n * `defineMutator` helper below contributes all four pieces from a single\n * typed declaration, so a module that uses it spreads the fragments\n * into its `createModule` config and writes only the per-variant handler\n * bodies.\n *\n * @see ../README.md for the full API and a worked example.\n */\n\nimport { t } from \"@directive-run/core\";\n\n/**\n * A keyed map of variant payloads. Each key becomes a discriminator value\n * for the mutation, each value is the payload type for that variant.\n *\n * @example\n * ```ts\n * type MyMutations = {\n * submit: { values: FormValues };\n * cancel: {};\n * retry: { reason: string };\n * };\n * ```\n */\nexport type MutationMap = Record<string, Record<string, unknown>>;\n\n/**\n * The shape of a `pendingMutation` fact while a mutation is queued or\n * running.\n */\nexport type PendingMutation<M extends MutationMap> = {\n [K in keyof M]: {\n kind: K;\n payload: M[K];\n /**\n * `pending` — queued, constraint hasn't fired yet.\n * `running` — handler is in flight.\n * `failed` — handler threw; constraint stops firing. Caller can\n * inspect `error` and dispatch a fresh MUTATE to retry.\n */\n status: \"pending\" | \"running\" | \"failed\";\n /**\n * Error message from the previous run, if any. Plaintext only —\n * NEVER render this directly via `dangerouslySetInnerHTML` or\n * markdown. Truncated to 500 chars to bound XSS surface from\n * thrown messages that may echo user input.\n */\n error: string | null;\n };\n}[keyof M];\n\n/** Maximum length of a captured error message — bounded to limit XSS surface. */\nconst MAX_ERROR_LEN = 500;\n\n/**\n * Coerce a thrown value to a bounded plaintext string for storage on\n * `pendingMutation.error`. Defends against:\n * - non-Error throws (`throw \"string\"`, `throw 42`, `throw {}`)\n * - Errors with non-string `.message` (overridden numeric / Symbol /\n * buffer); naive `.length` / `.slice` would `TypeError` otherwise\n * - extremely long messages with attacker-influenced input\n *\n * Returns at most {@link MAX_ERROR_LEN} characters of plaintext.\n * Plaintext rendering is still the only supported path on the\n * consumer side; this is defense in depth, not an XSS sanitizer.\n *\n * (R2 sec M-R2-1.)\n */\nfunction truncateError(input: unknown): string {\n let str: string;\n if (input instanceof Error) {\n // Reading `.message` is wrapped in try/catch because a maliciously\n // constructed Error subclass may install a throwing getter on\n // `message`. Without this guard the throw escapes truncateError →\n // escapes the resolver's catch → propagates uncaught. (R4 backlog.)\n let raw: unknown;\n try {\n raw = input.message;\n } catch {\n raw = \"[mutator: error.message getter threw]\";\n }\n str = typeof raw === \"string\" ? raw : safeStringCoerce(raw);\n } else if (typeof input === \"string\") {\n str = input;\n } else {\n str = safeStringCoerce(input);\n }\n if (str.length <= MAX_ERROR_LEN) return str;\n return `${str.slice(0, MAX_ERROR_LEN - 1)}…`;\n}\n\n/** String() may throw for some Symbols + objects with hostile toString. */\nfunction safeStringCoerce(value: unknown): string {\n try {\n return String(value);\n } catch {\n return \"[mutator: unstringifiable error]\";\n }\n}\n\n/**\n * Handler context passed to each variant handler.\n *\n * Note: `deps` is NOT in the context. This matches the Directive resolver\n * idiom — close over deps from the outer module-factory scope:\n *\n * ```ts\n * function createFormModule(deps: FormDeps) {\n * const mut = defineMutator<FormMutations, FormFacts>({\n * submit: async ({ payload, facts }) => {\n * facts.values = await deps.submit(payload.values); // ← closure\n * },\n * });\n * return createModule('form', { ... });\n * }\n * ```\n */\nexport interface MutatorHandlerContext<F> {\n /** Live facts proxy. Reads are cache-tracked; writes invalidate. */\n facts: F;\n /**\n * Trigger a same-constraint re-fire after this handler returns. Useful\n * when one mutation cascades into another — without `requeue`, the next\n * mutation would stall behind same-flush suppression.\n *\n * @see https://docs.directive.run/testing/chained-pipelines\n */\n requeue: () => void;\n}\n\n/**\n * Variant handler body. Receives the typed payload + a context; returns\n * void or a Promise. Throwing is fine — the runtime captures into\n * `pendingMutation.error` before clearing the fact.\n */\nexport type MutationHandler<\n M extends MutationMap,\n K extends keyof M,\n F,\n> = keyof M[K] extends never\n ? (ctx: MutatorHandlerContext<F>) => void | Promise<void>\n : (ctx: MutatorHandlerContext<F> & { payload: M[K] }) => void | Promise<void>;\n\n/**\n * The full handler map. Every variant in `M` MUST have a handler.\n */\nexport type MutationHandlers<M extends MutationMap, F> = {\n [K in keyof M]: MutationHandler<M, K, F>;\n};\n\n/**\n * The fragments returned by `defineMutator`. Spread each fragment into\n * the matching position of your `createModule` config.\n *\n * @internal Shape documented for type clarity; users typically just\n * spread.\n */\nexport interface MutatorFragments<M extends MutationMap, F> {\n /** Spread into `schema.facts`. Adds `pendingMutation`. */\n facts: {\n pendingMutation: ReturnType<typeof t.object>;\n };\n /** Spread into `schema.events`. Adds the `MUTATE` event. */\n events: {\n MUTATE: PendingMutation<M>;\n };\n /** Spread into `schema.requirements`. Adds `PROCESS_MUTATION`. */\n requirements: {\n PROCESS_MUTATION: Record<string, never>;\n };\n /** Spread into the `events` field. Sets pendingMutation on MUTATE. */\n eventHandlers: {\n MUTATE: (facts: F, payload: PendingMutation<M>) => void;\n };\n /** Spread into the `constraints` field. */\n constraints: {\n pendingMutation: {\n when: (facts: F) => boolean;\n require: { type: \"PROCESS_MUTATION\" };\n };\n };\n /** Spread into the `resolvers` field. */\n resolvers: {\n mutationResolver: {\n requirement: \"PROCESS_MUTATION\";\n resolve: (\n req: { type: \"PROCESS_MUTATION\" },\n ctx: { facts: F },\n ) => Promise<void>;\n };\n };\n /**\n * Convenience: the initial value for `pendingMutation`. Set this in\n * your module's `init` if you don't use `t.X().default(...)` defaults.\n */\n initialPendingMutation: null;\n}\n\n/**\n * Define a mutator fragment-set for a given variant map and handlers.\n *\n * @example\n * ```ts\n * type FormMutations = {\n * submit: { values: FormValues };\n * cancel: {};\n * };\n *\n * // The second generic is the FACTS type (not deps). Deps are\n * // captured in closure from the surrounding scope, not passed in\n * // through the handler ctx.\n * const formMutator = defineMutator<FormMutations, FormFacts>({\n * submit: async ({ payload, facts }) => {\n * facts.values = await deps.submit(payload.values); // deps closes over outer scope\n * },\n * cancel: ({ facts }) => { facts.values = []; },\n * });\n *\n * createModule('form', {\n * schema: {\n * facts: { ...formMutator.facts, values: t.array<FormValues>() },\n * events: { ...formMutator.events, REFRESH: {} },\n * requirements: { ...formMutator.requirements },\n * },\n * init: (f) => { f.pendingMutation = null; f.values = []; },\n * events: {\n * ...formMutator.eventHandlers,\n * REFRESH: (f) => { f.values = []; },\n * },\n * constraints: { ...formMutator.constraints },\n * resolvers: { ...formMutator.resolvers },\n * });\n *\n * // Usage:\n * sys.events.MUTATE(mutate<FormMutations>('submit', { values: ... }));\n *\n * // Or, if constructing the payload manually, the discriminator is\n * // `kind` (NOT `type` — `type` collides with Directive's event-name\n * // dispatch convention):\n * sys.events.MUTATE({\n * kind: 'submit',\n * payload: { values: ... },\n * status: 'pending',\n * error: null,\n * });\n * ```\n *\n * The handler-bound deps are captured at `defineMutator` call time. To\n * inject deps from the caller, wrap `defineMutator` in your module\n * factory:\n *\n * ```ts\n * export function createFormModule(deps: FormDeps) {\n * const mut = defineMutator<FormMutations, FormFacts>({\n * submit: async ({ payload, facts }) => {\n * facts.values = await deps.submit(payload.values);\n * },\n * cancel: ({ facts }) => { facts.values = []; },\n * });\n * return createModule('form', { ... });\n * }\n * ```\n */\nexport function defineMutator<\n M extends MutationMap,\n F = Record<string, unknown>,\n>(handlers: MutationHandlers<M, F>): MutatorFragments<M, F> {\n type Pending = PendingMutation<M>;\n\n const facts = {\n pendingMutation: t.object<Pending>().nullable() as ReturnType<\n typeof t.object\n >,\n } as MutatorFragments<M, F>[\"facts\"];\n\n // Schema event marker — the runtime uses this for typing and devtools.\n // Payload validation happens at dispatch time via t-schema check.\n const events = {\n MUTATE: undefined as unknown as Pending,\n } as MutatorFragments<M, F>[\"events\"];\n\n const requirements = {\n PROCESS_MUTATION: {} as Record<string, never>,\n } as MutatorFragments<M, F>[\"requirements\"];\n\n const eventHandlers = {\n MUTATE: (facts: F, payload: Pending) => {\n // Overwrite is intentional — caller is responsible for ordering.\n // If a previous mutation is mid-flight, the new one supersedes by\n // writing `status: \"pending\"` over the in-flight `status: \"running\"`\n // marker. The resolver's `if (status === \"running\")` checks at the\n // success and failure paths then leave the fresh dispatch untouched\n // so the next constraint fire picks it up — the in-flight result is\n // dropped on the floor (caller wanted to move on). See sec M5 / DX M2\n // in the AE review for the full reasoning.\n (facts as { pendingMutation: Pending | null }).pendingMutation = {\n ...payload,\n status: \"pending\",\n error: null,\n };\n },\n } as MutatorFragments<M, F>[\"eventHandlers\"];\n\n const constraints = {\n pendingMutation: {\n when: (facts: F) =>\n (facts as { pendingMutation: Pending | null }).pendingMutation !==\n null &&\n (facts as { pendingMutation: Pending | null }).pendingMutation\n ?.status === \"pending\",\n require: { type: \"PROCESS_MUTATION\" } as const,\n },\n } as MutatorFragments<M, F>[\"constraints\"];\n\n const resolvers = {\n mutationResolver: {\n requirement: \"PROCESS_MUTATION\" as const,\n resolve: async (\n _req: { type: \"PROCESS_MUTATION\" },\n ctx: {\n facts: F;\n requeue?: () => void;\n },\n ) => {\n const factsRef = ctx.facts as { pendingMutation: Pending | null };\n const pending = factsRef.pendingMutation;\n if (pending === null) return;\n\n // Stamp the in-flight transition with the running status. The\n // status itself is the supersession marker: only this resolver\n // ever writes 'running'; the eventHandler always writes\n // 'pending'. So if a fresh MUTATE arrives mid-flight, the\n // post-handler check sees status: 'pending' instead of\n // 'running' and knows to leave the new dispatch alone.\n // (Sec M5 / DX M2.)\n factsRef.pendingMutation = { ...pending, status: \"running\" };\n\n // Prototype-pollution defense: only accept handlers OWNED by\n // the user-provided map. Without this, dispatching with kind:\n // 'constructor' / 'toString' / '__proto__' would resolve via\n // the prototype chain and either crash or invoke an inherited\n // function with arbitrary payload. (Sec C1.)\n const ownsHandler = Object.prototype.hasOwnProperty.call(\n handlers,\n pending.kind as PropertyKey,\n );\n const handler = ownsHandler\n ? (handlers as Record<string, unknown>)[pending.kind as string]\n : undefined;\n\n if (typeof handler !== \"function\") {\n factsRef.pendingMutation = {\n ...pending,\n status: \"failed\",\n error: truncateError(\n `[mutator] no handler registered for variant: ${String(\n pending.kind,\n )}`,\n ),\n };\n return;\n }\n\n const handlerCtx = {\n facts: ctx.facts,\n payload: pending.payload,\n requeue: ctx.requeue ?? (() => {}),\n };\n\n try {\n await (handler as (c: typeof handlerCtx) => Promise<void> | void)(\n handlerCtx,\n );\n // Success: clear the fact ONLY if this resolver's `running`\n // marker is still live (status === 'running'). If a fresh\n // MUTATE arrived mid-flight via the eventHandler, the fact\n // now has status: 'pending' — leave it alone so the next\n // constraint fire picks it up. (Sec M5 / DX M2.)\n if (factsRef.pendingMutation?.status === \"running\") {\n factsRef.pendingMutation = null;\n }\n } catch (err) {\n // Failure: only stamp the error if our running marker is\n // still live. If a fresh MUTATE arrived mid-flight, the new\n // dispatch wins; the failed mutation's error is dropped on\n // the floor (caller wanted to move on anyway). (Sec M5.)\n if (factsRef.pendingMutation?.status !== \"running\") return;\n factsRef.pendingMutation = {\n ...pending,\n // 'failed' is a distinct status (Sec M6 / Arch C2) — UI can\n // disambiguate \"still in flight\" from \"stopped on error\".\n status: \"failed\",\n // truncateError handles the unknown shape safely — non-Error\n // throws, non-string Error.message, hostile toString. (R2\n // sec M-R2-1.)\n error: truncateError(err),\n };\n }\n },\n },\n } as MutatorFragments<M, F>[\"resolvers\"];\n\n return {\n facts,\n events,\n requirements,\n eventHandlers,\n constraints,\n resolvers,\n initialPendingMutation: null,\n };\n}\n\n/**\n * Helper for typed dispatch. Lets the caller construct a mutation payload\n * with full type narrowing — the `kind` field auto-restricts the\n * `payload` shape.\n *\n * @example\n * ```ts\n * sys.events.MUTATE(mutate<FormMutations>('submit', { values: ... }));\n * sys.events.MUTATE(mutate<FormMutations>('cancel'));\n * ```\n */\n// Single type parameter (M) so callers can specialize with\n// `mutate<FormMutations>(...)` without TypeScript demanding the\n// inferred K. TS's strict type-argument rules treat <M, K> as\n// \"supply both or supply neither\" — single-arg call sites failed\n// with \"Expected 2 type arguments, but got 1.\" Single-M form sidesteps\n// that while keeping payload typing tight via the `kind` lookup.\nexport function mutate<M extends MutationMap>(\n kind: keyof M & string,\n payload: M[typeof kind],\n): PendingMutation<M>;\nexport function mutate<M extends MutationMap>(\n kind: keyof M & string,\n): PendingMutation<M>;\nexport function mutate<M extends MutationMap>(\n kind: keyof M & string,\n payload?: M[typeof kind],\n): PendingMutation<M> {\n return {\n kind,\n payload: (payload ?? {}) as M[typeof kind],\n status: \"pending\",\n error: null,\n } as PendingMutation<M>;\n}\n\n// ============================================================================\n// cancellable() — auto-cancel-on-supersede (R1.C v0.1)\n// ============================================================================\n\n/**\n * Options for {@link cancellable}.\n */\nexport interface CancellableOptions {\n /**\n * When to fire the AbortSignal that the wrapped handler receives.\n *\n * - `'self'` (default): a new dispatch of the SAME handler aborts the\n * prior in-flight invocation. The classic \"cancel previous on new\n * keystroke\" pattern.\n * - `'never'`: only the timeout fires the signal; new dispatches do\n * NOT abort prior ones. Useful when you want a hard timeout but\n * parallel runs are fine.\n */\n supersedeOn?: \"self\" | \"never\";\n\n /**\n * Maximum ms a handler may run before its signal is aborted. Counted\n * from the start of THIS invocation. Combine with `realClock` for\n * production or pass a `setTimeout` shim for deterministic testing.\n */\n timeoutMs?: number;\n\n /**\n * Optional `setTimeout` injection. Defaults to `globalThis.setTimeout`.\n * For deterministic tests, pass `virtualClock.setTimeout` from\n * `@directive-run/core` so the timeout fires under\n * `clock.advanceBy()` instead of wall-clock real time.\n *\n * @example\n * ```ts\n * import { virtualClock } from '@directive-run/core';\n * const clock = virtualClock(0);\n * cancellable({ timeoutMs: 1_000, setTimeout: clock.setTimeout }, handler);\n * ```\n */\n setTimeout?: (cb: () => void, ms: number) => () => void;\n}\n\n/**\n * Handler context augmented with a cancellation signal.\n */\nexport interface CancellableHandlerContext<F, P> {\n facts: F;\n payload: P;\n /** Aborts when a new dispatch supersedes this one OR the timeout fires. */\n signal: AbortSignal;\n requeue: () => void;\n}\n\n/**\n * Reason a cancellable handler's signal aborted. Stamped on\n * `signal.reason` so the handler can disambiguate.\n *\n * Note: at runtime the value is a {@link CancelError} subclass with\n * the same shape — `signal.reason instanceof CancelError` is the\n * canonical check. Older usages that did `signal.reason?.kind ===\n * 'superseded'` continue to work because the Error subclass exposes\n * the same `kind` field. (R2 sec M-1.)\n */\nexport type CancelReason =\n | { kind: \"superseded\" }\n | { kind: \"timeout\"; afterMs: number };\n\n/**\n * Runtime carrier for {@link CancelReason}. Subclasses `Error` so the\n * value survives transit through `fetch(url, { signal })` and other\n * web-platform APIs that re-throw `signal.reason`. Older mutator\n * versions passed plain objects, which `.catch(err => err instanceof\n * Error)` filters silently dropped. (R2 sec M-1.)\n */\nexport class CancelError extends Error {\n constructor(\n public readonly kind: CancelReason[\"kind\"],\n message?: string,\n ) {\n super(message ?? `[mutator] cancellable: ${kind}`);\n this.name = \"CancelError\";\n }\n}\n\n/** Subclass of {@link CancelError} for timeout-driven aborts. */\nexport class TimeoutCancelError extends CancelError {\n constructor(public readonly afterMs: number) {\n super(\"timeout\", `[mutator] cancellable: timeout after ${afterMs}ms`);\n this.name = \"TimeoutCancelError\";\n }\n}\n\n/** Subclass of {@link CancelError} for supersede-driven aborts. */\nexport class SupersededCancelError extends CancelError {\n constructor() {\n super(\"superseded\", \"[mutator] cancellable: superseded by new dispatch\");\n this.name = \"SupersededCancelError\";\n }\n}\n\n/**\n * Factory for cancel-reason values. Pure — these are the runtime\n * counterparts of the {@link CancelReason} type. Use them in custom\n * cancellation flows that need a typed reason without re-typing the\n * literal.\n */\nexport const cancelReason = {\n superseded: (): SupersededCancelError => new SupersededCancelError(),\n timeout: (afterMs: number): TimeoutCancelError =>\n new TimeoutCancelError(afterMs),\n} as const;\n\n/**\n * Wrap a mutator handler with auto-cancellation. The wrapped handler\n * receives an extra `signal: AbortSignal` in its context. Use the\n * signal to short-circuit awaitable work — pass it to `fetch(url, {\n * signal })`, watch it inside long-running loops, etc.\n *\n * Two cancellation triggers, both opt-in via {@link CancellableOptions}:\n *\n * 1. **Supersession** (default `supersedeOn: 'self'`): when a new\n * dispatch of the same wrapped handler arrives while a prior\n * invocation is still running, the prior signal aborts.\n * 2. **Timeout** (default `timeoutMs: undefined`, meaning no timeout):\n * after `timeoutMs` ms from invocation start, the signal aborts.\n *\n * If a handler's signal aborts, the handler should observe the abort\n * (via `signal.aborted` or the AbortError-throwing helpers) and\n * return promptly. The signal's `reason` carries a {@link CancelReason}\n * disambiguating which trigger fired.\n *\n * Compose with {@link defineMutator} by using `cancellable()` directly\n * in the handler map:\n *\n * @example\n * ```ts\n * import { defineMutator, cancellable } from '@directive-run/mutator';\n *\n * const formMutator = defineMutator<MyMutations, MyFacts>({\n * search: cancellable(\n * { supersedeOn: 'self', timeoutMs: 3_000 },\n * async ({ payload, facts, signal }) => {\n * const res = await fetch(`/q?${payload.q}`, { signal });\n * facts.results = await res.json();\n * },\n * ),\n * submit: async ({ payload, facts }) => {\n * // No cancellation needed for submit — plain handler.\n * facts.values = await deps.submit(payload.values);\n * },\n * });\n * ```\n *\n * **Idempotency note.** The wrapped handler stays a regular\n * `MutationHandler<M, K, F>` from the mutator's perspective. The\n * supersession registry is closure-scoped per `cancellable()` call —\n * two separate `cancellable(...)` HOCs around different handlers do\n * NOT cancel each other.\n *\n * **Test ergonomics.** Pass `virtualClock.setTimeout` via the\n * `setTimeout` option to make timeouts deterministic under\n * `clock.advanceBy(ms)`. Without that, timeouts use wall-clock\n * `globalThis.setTimeout` and are real-time.\n */\nexport function cancellable<F, P>(\n opts: CancellableOptions,\n handler: (ctx: CancellableHandlerContext<F, P>) => Promise<void> | void,\n): (ctx: { facts: F; payload: P; requeue: () => void }) => Promise<void> {\n const supersedeOn = opts.supersedeOn ?? \"self\";\n const timeoutMs = opts.timeoutMs;\n const scheduleTimeout = opts.setTimeout ?? defaultSetTimeout;\n\n // Closure-scoped supersession slot — one entry for the wrapped\n // handler. When a new invocation arrives, the prior entry's\n // controller aborts before the new one starts.\n let priorController: AbortController | undefined;\n\n return async (ctx: { facts: F; payload: P; requeue: () => void }) => {\n // Supersession: abort the prior in-flight invocation, if any.\n // R2 sec M-1: pass an Error subclass so signal.reason survives\n // re-throw through fetch / .catch(err => err instanceof Error)\n // / logging frameworks. Plain object reasons silently fail those\n // checks downstream.\n if (supersedeOn === \"self\" && priorController !== undefined) {\n priorController.abort(cancelReason.superseded());\n }\n\n const controller = new AbortController();\n priorController = controller;\n\n // Timeout: schedule an abort after `timeoutMs`. The cancel handle\n // returned by `scheduleTimeout` lets us clear the timer if the\n // handler completes first (saves leaking timers under a barrage\n // of dispatches).\n let cancelTimeout: (() => void) | undefined;\n if (typeof timeoutMs === \"number\" && timeoutMs > 0) {\n cancelTimeout = scheduleTimeout(() => {\n controller.abort(cancelReason.timeout(timeoutMs));\n }, timeoutMs);\n }\n\n try {\n await handler({\n facts: ctx.facts,\n payload: ctx.payload,\n requeue: ctx.requeue,\n signal: controller.signal,\n });\n } finally {\n // Clean up: clear the timeout (if it hasn't fired) and release\n // the supersession slot if it still belongs to this invocation.\n // R2 sec M-3: wrap cancelTimeout in try/catch so a hostile\n // setTimeout-cancel-handle (e.g. a custom virtual clock that\n // throws on cancel) cannot shadow the original handler's\n // exception.\n try {\n cancelTimeout?.();\n } catch {\n /* swallow — the cancel-handle's failure is not the caller's problem */\n }\n if (priorController === controller) {\n priorController = undefined;\n }\n }\n };\n}\n\n/**\n * Default `setTimeout` shim — wraps `globalThis.setTimeout` to match\n * the cancel-handle signature `cancellable()` expects.\n */\nfunction defaultSetTimeout(cb: () => void, ms: number): () => void {\n const handle = globalThis.setTimeout(cb, ms);\n return () => globalThis.clearTimeout(handle);\n}\n\n// ────────────────────────────────────────────────────────────────────────────\n// recordReplayable — R2.B\n// ────────────────────────────────────────────────────────────────────────────\n\n/**\n * Structured event surfaced to {@link RecordReplayableOptions.onCancel}\n * the moment a wrapped invocation's signal aborts. Carries enough\n * information for the caller to pin the cancellation into the timeline\n * (e.g. by writing onto a facts array) so a later replay can\n * reconstruct the cancellation race exactly.\n *\n * @typeParam F — caller's facts type (passed through unchanged from the handler context).\n * @typeParam P — caller's payload type for THIS handler.\n */\nexport interface CancelEvent<F, P> {\n /** Whether this cancel was driven by a new dispatch superseding the old, or by the timeout firing. */\n kind: CancelReason[\"kind\"];\n /** When `kind === 'timeout'`, the original `timeoutMs` that fired. Undefined for `'superseded'`. */\n afterMs?: number;\n /** The payload of the dispatch that was cancelled — i.e. the work that did NOT complete. */\n payload: P;\n /**\n * Per-handler monotonic counter, incremented once at the start of\n * every invocation. Useful for timeline diff: cancel of seq=4 is the\n * 4th dispatch this handler ever saw. The counter is closure-scoped\n * to a single `recordReplayable()` call — separate HOCs do NOT share\n * the counter.\n */\n dispatchSeq: number;\n /** Live facts reference, mirroring {@link CancellableHandlerContext.facts}. */\n facts: F;\n}\n\n/**\n * Options for {@link recordReplayable}. Strict superset of\n * {@link CancellableOptions} — every field on `CancellableOptions`\n * passes through unchanged to the underlying {@link cancellable} call.\n */\nexport interface RecordReplayableOptions<F, P> extends CancellableOptions {\n /**\n * Synchronous callback invoked the moment a wrapped invocation's\n * AbortController fires `abort()`. Runs BEFORE the handler's pending\n * await rejects with AbortError, so the callback sees the freshest\n * possible state.\n *\n * Use this to pin the cancellation into a place that survives in the\n * timeline (typically a facts field — `facts.cancellations.push(info)`).\n * Without that, a replay re-dispatches the same MUTATE events but has\n * no record of which ones were superseded vs which completed.\n *\n * Throws inside `onCancel` are caught and swallowed — the abort path\n * must remain robust. If you need to fail loudly, log to your own\n * sink before re-throwing.\n */\n onCancel?: (info: CancelEvent<F, P>) => void;\n}\n\n/**\n * `cancellable()` plus a synchronous on-abort callback. Wrap your\n * handler with `recordReplayable()` instead of `cancellable()` when\n * you want a hook that fires the moment a supersession or timeout\n * aborts the in-flight invocation — *before* the handler's pending\n * await rejects with AbortError. The callback receives a structured\n * {@link CancelEvent} carrying the cancel kind, payload, dispatch\n * sequence, and a live facts reference.\n *\n * The callback is GENERIC (\"call me when abort fires\"); the most\n * common use case is pinning cancel events into facts so a recorded\n * timeline carries them — which then lets `replayTimeline()`,\n * `bisectTimeline()`, and `diffTimelines()` (all in\n * `@directive-run/timeline`) reason about which dispatches were\n * superseded vs which completed without parsing free-form error\n * strings. But the same callback works equally well for Sentry\n * breadcrumbs, a Redux action log, OpenTelemetry spans, or a metrics\n * sink — `recordReplayable` doesn't know or care about the timeline.\n *\n * @example Pin cancel events into facts so the timeline carries them:\n * ```ts\n * import { defineMutator, recordReplayable } from '@directive-run/mutator';\n *\n * const search = recordReplayable<MyFacts, { q: string }>(\n * {\n * supersedeOn: 'self',\n * timeoutMs: 3_000,\n * onCancel: ({ facts, kind, payload, dispatchSeq }) => {\n * facts.cancellations.push({ kind, queryAtCancel: payload.q, seq: dispatchSeq });\n * },\n * },\n * async ({ payload, facts, signal }) => {\n * const res = await fetch(`/q?${payload.q}`, { signal });\n * facts.results = await res.json();\n * },\n * );\n * ```\n *\n * Implementation note: `recordReplayable()` is `cancellable(opts,\n * innerHandler)` where `innerHandler` adds an `addEventListener('abort')`\n * around the user's handler. This means timeout / supersession\n * semantics are EXACTLY those of `cancellable()` — the HOC is purely\n * additive.\n */\nexport function recordReplayable<F, P>(\n opts: RecordReplayableOptions<F, P>,\n handler: (ctx: CancellableHandlerContext<F, P>) => Promise<void> | void,\n): (ctx: { facts: F; payload: P; requeue: () => void }) => Promise<void> {\n let dispatchSeq = 0;\n\n const wrappedHandler = async (\n ctx: CancellableHandlerContext<F, P>,\n ): Promise<void> => {\n const seq = ++dispatchSeq;\n const onAbort = (): void => {\n // Only fire onCancel for our typed CancelError reasons; if the\n // signal was aborted via some other path (caller's own\n // controller, etc), let it pass silently.\n const reason = ctx.signal.reason;\n if (!(reason instanceof CancelError)) return;\n const info: CancelEvent<F, P> = {\n kind: reason.kind,\n afterMs:\n reason instanceof TimeoutCancelError ? reason.afterMs : undefined,\n payload: ctx.payload,\n dispatchSeq: seq,\n facts: ctx.facts,\n };\n try {\n opts.onCancel?.(info);\n } catch {\n // Callback errors must NOT bubble up the abort path. The user's\n // handler is still about to receive AbortError — we don't want\n // to mask that with an onCancel-side throw, and we don't want\n // to crash the controller in the middle of cleanup.\n }\n };\n ctx.signal.addEventListener(\"abort\", onAbort, { once: true });\n try {\n await handler(ctx);\n } finally {\n ctx.signal.removeEventListener(\"abort\", onAbort);\n }\n };\n\n return cancellable<F, P>(opts, wrappedHandler);\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@directive-run/mutator",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Discriminated mutation helper for Directive — collapse the pendingAction ceremony to a typed handler map.",
5
5
  "license": "(MIT OR Apache-2.0)",
6
6
  "author": "Jason Comes",
@@ -51,7 +51,7 @@
51
51
  "tsup": "^8.3.5",
52
52
  "typescript": "^5.7.2",
53
53
  "vitest": "^2.1.9",
54
- "@directive-run/core": "1.4.0"
54
+ "@directive-run/core": "1.15.0"
55
55
  },
56
56
  "scripts": {
57
57
  "build": "tsup",