@directive-run/mutator 0.2.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,12 +1,158 @@
1
1
  # @directive-run/mutator changelog
2
2
 
3
+ ## 0.3.1
4
+
5
+ ### Patch Changes
6
+
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:
26
+
27
+ ```
28
+ /plugin marketplace add directive-run/directive
29
+ /plugin install directive@directive-plugins
30
+ ```
31
+
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.
61
+
62
+ ## 0.3.0
63
+
64
+ ### Minor Changes
65
+
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.
67
+
68
+ ### Timeline (surface-compatible fixes)
69
+
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").
77
+
78
+ ### Mutator (additive Error subclasses)
79
+
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.
84
+
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
86
+
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:
88
+
89
+ - `kind: 'superseded' | 'timeout'`
90
+ - `afterMs?: number` (timeout only)
91
+ - `payload: P` – the dispatch that did NOT complete
92
+ - `dispatchSeq: number` – per-handler monotonic counter
93
+ - `facts: F` – live facts reference
94
+
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.
96
+
97
+ ```ts
98
+ import { defineMutator, recordReplayable } from "@directive-run/mutator";
99
+
100
+ const search = recordReplayable<MyFacts, { q: string }>(
101
+ {
102
+ supersedeOn: "self",
103
+ timeoutMs: 3_000,
104
+ onCancel: ({ facts, kind, payload, dispatchSeq }) => {
105
+ facts.cancellations.push({
106
+ kind,
107
+ queryAtCancel: payload.q,
108
+ seq: dispatchSeq,
109
+ });
110
+ },
111
+ },
112
+ async ({ payload, facts, signal }) => {
113
+ const res = await fetch(`/q?${payload.q}`, { signal });
114
+ facts.results = await res.json();
115
+ }
116
+ );
117
+ ```
118
+
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.
120
+
121
+ `onCancel` errors are caught and swallowed – the abort path stays clean.
122
+
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.
124
+
125
+ ### Patch Changes
126
+
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.
128
+
129
+ No new commands; existing surfaces gain better docs, cleaner types, and consistent semantics.
130
+
131
+ **Documentation:**
132
+
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.
136
+
137
+ **Type ergonomics:**
138
+
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`).
140
+
141
+ **Exit-code consistency:**
142
+
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.
144
+
145
+ **Docstring corrections:**
146
+
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.
148
+
3
149
  ## 0.2.0
4
150
 
5
151
  ### Minor Changes
6
152
 
7
- - [`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
8
154
 
9
- 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.
10
156
 
11
157
  ```ts
12
158
  import { defineMutator, cancellable } from "@directive-run/mutator";
@@ -27,11 +173,11 @@
27
173
 
28
174
  **Two cancellation triggers, both opt-in:**
29
175
 
30
- - `supersedeOn: 'self'` (default) new dispatch supersedes prior
31
- - `supersedeOn: 'never'` only timeout fires; parallel runs are fine
32
- - `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
33
179
 
34
- **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.
35
181
 
36
182
  The signal's `.reason` carries a typed `CancelReason`:
37
183
 
@@ -41,19 +187,19 @@
41
187
  | { kind: "timeout"; afterMs: number };
42
188
  ```
43
189
 
44
- **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.
45
191
 
46
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.
47
193
 
48
194
  9 new tests covering basic invocation, supersession (both modes), timeout (using virtualClock for determinism), supersession+timeout composition, HOC independence.
49
195
 
50
- ## 0.1.0 2026-04-29
196
+ ## 0.1.0 2026-04-29
51
197
 
52
198
  Initial release.
53
199
 
54
- ### Added v0.2 (R1.C cancellable)
200
+ ### Added v0.2 (cancellable)
55
201
 
56
- - `cancellable(opts, handler)` HOC that wraps a mutator handler with
202
+ - `cancellable(opts, handler)` HOC that wraps a mutator handler with
57
203
  auto-cancellation. Receives a `signal: AbortSignal` in the handler
58
204
  context. Two cancellation triggers: `supersedeOn: 'self' | 'never'`
59
205
  (default `'self'`) and `timeoutMs?: number`. The signal's `reason`
@@ -63,13 +209,13 @@ Initial release.
63
209
  - `CancellableOptions`, `CancellableHandlerContext<F, P>`,
64
210
  `CancelReason` type exports.
65
211
 
66
- ### Added v0.1
212
+ ### Added v0.1
67
213
 
68
- - `defineMutator(handlers)` typed builder that returns six fragments
214
+ - `defineMutator(handlers)` typed builder that returns six fragments
69
215
  (facts / events / requirements / eventHandlers / constraints /
70
216
  resolvers) wiring a discriminated `pendingMutation` lifecycle into a
71
217
  Directive module.
72
- - `mutate(kind, payload?)` typed payload constructor for `MUTATE`
218
+ - `mutate(kind, payload?)` typed payload constructor for `MUTATE`
73
219
  dispatches.
74
220
  - Single-flight concurrency model: new mutations overwrite in-flight ones
75
221
  via the `pendingMutation` fact.
@@ -81,8 +227,8 @@ Initial release.
81
227
 
82
228
  ### Known gaps
83
229
 
84
- - Parallel-of-same-shape mutations not supported last-write-wins.
85
- - 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.
86
232
  - Optimistic / snapshot-rollback support belongs to upcoming
87
233
  `@directive-run/optimistic`; do manual rollback inside handlers for
88
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
@@ -260,10 +260,56 @@ type CancelReason =
260
260
  Use it inside handlers to distinguish how the cancellation arrived
261
261
  (e.g. log a different message for timeouts vs supersession).
262
262
 
263
+ ## Recording cancellations for replay (R2.B `recordReplayable()`)
264
+
265
+ `recordReplayable()` is `cancellable()` plus a synchronous `onCancel`
266
+ callback that fires the moment the AbortController calls `abort()` –
267
+ *before* the handler's pending await rejects with AbortError. The
268
+ callback receives a structured `CancelEvent` with the cancel kind,
269
+ payload, dispatch sequence, and a live facts reference, so you can
270
+ pin cancellations into a place that survives in the timeline.
271
+
272
+ Use this when you record a timeline (with `@directive-run/timeline`)
273
+ and want a replay or `directive bisect` to reason about *which*
274
+ dispatches were superseded vs which completed – not just see a
275
+ free-form error string.
276
+
277
+ ```ts
278
+ import { defineMutator, recordReplayable } from '@directive-run/mutator';
279
+
280
+ interface MyFacts {
281
+ results: string[];
282
+ cancellations: Array<{ kind: string; queryAtCancel: string; seq: number }>;
283
+ }
284
+
285
+ const search = recordReplayable<MyFacts, { q: string }>(
286
+ {
287
+ supersedeOn: 'self',
288
+ timeoutMs: 3_000,
289
+ onCancel: ({ facts, kind, payload, dispatchSeq }) => {
290
+ // Pin the cancel event into facts so the timeline carries it.
291
+ facts.cancellations.push({
292
+ kind,
293
+ queryAtCancel: payload.q,
294
+ seq: dispatchSeq,
295
+ });
296
+ },
297
+ },
298
+ async ({ payload, facts, signal }) => {
299
+ const res = await fetch(`/q?${payload.q}`, { signal });
300
+ facts.results = await res.json();
301
+ },
302
+ );
303
+ ```
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.
306
+
307
+ `onCancel` errors are caught and swallowed – the abort path stays clean.
308
+
263
309
  ## Optimistic updates + rollback
264
310
 
265
311
  A future `@directive-run/optimistic` package will integrate with this
266
- one the planned `ctx.snapshot([keys])` API lets a handler snapshot
312
+ one the planned `ctx.snapshot([keys])` API lets a handler snapshot
267
313
  specific facts before mutating, with automatic rollback on throw. Until
268
314
  that ships, do snapshots manually inside handlers:
269
315
 
@@ -280,11 +326,30 @@ submit: async ({ payload, facts, deps }) => {
280
326
  },
281
327
  ```
282
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
+
283
348
  ## See also
284
349
 
285
350
  - [Directive core](https://www.npmjs.com/package/@directive-run/core)
286
- - [Migrating from XState `pendingAction` pattern](https://docs.directive.run/migrating-from-xstate#the-pendingaction-pattern-12-cycles-confirmed)
287
- - [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
288
353
  - [`MIGRATION_FEEDBACK.md` items 17 + 19](https://github.com/directive-run/directive/blob/main/docs/MIGRATION_FEEDBACK.md)
289
354
 
290
355
  ## License
package/dist/index.cjs CHANGED
@@ -1,2 +1,2 @@
1
- 'use strict';var core=require('@directive-run/core');var c=500;function p(e){let n;if(e instanceof Error){let t;try{t=e.message;}catch{t="[mutator: error.message getter threw]";}n=typeof t=="string"?t:g(t);}else typeof e=="string"?n=e:n=g(e);return n.length<=c?n:`${n.slice(0,c-1)}\u2026`}function g(e){try{return String(e)}catch{return "[mutator: unstringifiable error]"}}function T(e){return {facts:{pendingMutation:core.t.object().nullable()},events:{MUTATE:void 0},requirements:{PROCESS_MUTATION:{}},eventHandlers:{MUTATE:(o,r)=>{o.pendingMutation={...r,status:"pending",error:null};}},constraints:{pendingMutation:{when:o=>o.pendingMutation!==null&&o.pendingMutation?.status==="pending",require:{type:"PROCESS_MUTATION"}}},resolvers:{mutationResolver:{requirement:"PROCESS_MUTATION",resolve:async(o,r)=>{let i=r.facts,a=i.pendingMutation;if(a===null)return;i.pendingMutation={...a,status:"running"};let M=Object.prototype.hasOwnProperty.call(e,a.kind)?e[a.kind]:void 0;if(typeof M!="function"){i.pendingMutation={...a,status:"failed",error:p(`[mutator] no handler registered for variant: ${String(a.kind)}`)};return}let f={facts:r.facts,payload:a.payload,requeue:r.requeue??(()=>{})};try{await M(f),i.pendingMutation?.status==="running"&&(i.pendingMutation=null);}catch(y){if(i.pendingMutation?.status!=="running")return;i.pendingMutation={...a,status:"failed",error:p(y)};}}}},initialPendingMutation:null}}function x(e,n){return {kind:e,payload:n??{},status:"pending",error:null}}function k(e,n){let t=e.supersedeOn??"self",u=e.timeoutMs,l=e.setTimeout??v,s;return async d=>{t==="self"&&s!==void 0&&s.abort({kind:"superseded"});let o=new AbortController;s=o;let r;typeof u=="number"&&u>0&&(r=l(()=>{o.abort({kind:"timeout",afterMs:u});},u));try{await n({facts:d.facts,payload:d.payload,requeue:d.requeue,signal:o.signal});}finally{r?.(),s===o&&(s=void 0);}}}function v(e,n){let t=globalThis.setTimeout(e,n);return ()=>globalThis.clearTimeout(t)}exports.cancellable=k;exports.defineMutator=T;exports.mutate=x;//# sourceMappingURL=index.cjs.map
1
+ 'use strict';var core=require('@directive-run/core');var g=500;function m(e){let n;if(e instanceof Error){let t;try{t=e.message;}catch{t="[mutator: error.message getter threw]";}n=typeof t=="string"?t:y(t);}else typeof e=="string"?n=e:n=y(e);return n.length<=g?n:`${n.slice(0,g-1)}\u2026`}function y(e){try{return String(e)}catch{return "[mutator: unstringifiable error]"}}function R(e){return {facts:{pendingMutation:core.t.object().nullable()},events:{MUTATE:void 0},requirements:{PROCESS_MUTATION:{}},eventHandlers:{MUTATE:(r,a)=>{r.pendingMutation={...a,status:"pending",error:null};}},constraints:{pendingMutation:{when:r=>r.pendingMutation!==null&&r.pendingMutation?.status==="pending",require:{type:"PROCESS_MUTATION"}}},resolvers:{mutationResolver:{requirement:"PROCESS_MUTATION",resolve:async(r,a)=>{let l=a.facts,u=l.pendingMutation;if(u===null)return;l.pendingMutation={...u,status:"running"};let f=Object.prototype.hasOwnProperty.call(e,u.kind)?e[u.kind]:void 0;if(typeof f!="function"){l.pendingMutation={...u,status:"failed",error:m(`[mutator] no handler registered for variant: ${String(u.kind)}`)};return}let P={facts:a.facts,payload:u.payload,requeue:a.requeue??(()=>{})};try{await f(P),l.pendingMutation?.status==="running"&&(l.pendingMutation=null);}catch(F){if(l.pendingMutation?.status!=="running")return;l.pendingMutation={...u,status:"failed",error:m(F)};}}}},initialPendingMutation:null}}function O(e,n){return {kind:e,payload:n??{},status:"pending",error:null}}var c=class extends Error{constructor(t,i){super(i??`[mutator] cancellable: ${t}`);this.kind=t;this.name="CancelError";}},p=class extends c{constructor(t){super("timeout",`[mutator] cancellable: timeout after ${t}ms`);this.afterMs=t;this.name="TimeoutCancelError";}},M=class extends c{constructor(){super("superseded","[mutator] cancellable: superseded by new dispatch"),this.name="SupersededCancelError";}},v={superseded:()=>new M,timeout:e=>new p(e)};function x(e,n){let t=e.supersedeOn??"self",i=e.timeoutMs,o=e.setTimeout??C,s;return async d=>{t==="self"&&s!==void 0&&s.abort(v.superseded());let r=new AbortController;s=r;let a;typeof i=="number"&&i>0&&(a=o(()=>{r.abort(v.timeout(i));},i));try{await n({facts:d.facts,payload:d.payload,requeue:d.requeue,signal:r.signal});}finally{try{a?.();}catch{}s===r&&(s=void 0);}}}function C(e,n){let t=globalThis.setTimeout(e,n);return ()=>globalThis.clearTimeout(t)}function S(e,n){let t=0;return x(e,async o=>{let s=++t,d=()=>{let r=o.signal.reason;if(!(r instanceof c))return;let a={kind:r.kind,afterMs:r instanceof p?r.afterMs:void 0,payload:o.payload,dispatchSeq:s,facts:o.facts};try{e.onCancel?.(a);}catch{}};o.signal.addEventListener("abort",d,{once:!0});try{await n(o);}finally{o.signal.removeEventListener("abort",d);}})}exports.CancelError=c;exports.SupersededCancelError=M;exports.TimeoutCancelError=p;exports.cancelReason=v;exports.cancellable=x;exports.defineMutator=R;exports.mutate=O;exports.recordReplayable=S;//# sourceMappingURL=index.cjs.map
2
2
  //# sourceMappingURL=index.cjs.map
@@ -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","cancellable","opts","supersedeOn","timeoutMs","scheduleTimeout","defaultSetTimeout","priorController","controller","cancelTimeout","cb","ms","handle"],"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 */\nexport type CancelReason =\n | { kind: \"superseded\" }\n | { kind: \"timeout\"; afterMs: number };\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 if (supersedeOn === \"self\" && priorController !== undefined) {\n priorController.abort({ kind: \"superseded\" } satisfies CancelReason);\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({ kind: \"timeout\", afterMs: timeoutMs } satisfies CancelReason);\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 cancelTimeout?.();\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"]}
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.d.cts CHANGED
@@ -282,6 +282,12 @@ interface CancellableHandlerContext<F, P> {
282
282
  /**
283
283
  * Reason a cancellable handler's signal aborted. Stamped on
284
284
  * `signal.reason` so the handler can disambiguate.
285
+ *
286
+ * Note: at runtime the value is a {@link CancelError} subclass with
287
+ * the same shape — `signal.reason instanceof CancelError` is the
288
+ * canonical check. Older usages that did `signal.reason?.kind ===
289
+ * 'superseded'` continue to work because the Error subclass exposes
290
+ * the same `kind` field. (R2 sec M-1.)
285
291
  */
286
292
  type CancelReason = {
287
293
  kind: "superseded";
@@ -289,6 +295,36 @@ type CancelReason = {
289
295
  kind: "timeout";
290
296
  afterMs: number;
291
297
  };
298
+ /**
299
+ * Runtime carrier for {@link CancelReason}. Subclasses `Error` so the
300
+ * value survives transit through `fetch(url, { signal })` and other
301
+ * web-platform APIs that re-throw `signal.reason`. Older mutator
302
+ * versions passed plain objects, which `.catch(err => err instanceof
303
+ * Error)` filters silently dropped. (R2 sec M-1.)
304
+ */
305
+ declare class CancelError extends Error {
306
+ readonly kind: CancelReason["kind"];
307
+ constructor(kind: CancelReason["kind"], message?: string);
308
+ }
309
+ /** Subclass of {@link CancelError} for timeout-driven aborts. */
310
+ declare class TimeoutCancelError extends CancelError {
311
+ readonly afterMs: number;
312
+ constructor(afterMs: number);
313
+ }
314
+ /** Subclass of {@link CancelError} for supersede-driven aborts. */
315
+ declare class SupersededCancelError extends CancelError {
316
+ constructor();
317
+ }
318
+ /**
319
+ * Factory for cancel-reason values. Pure — these are the runtime
320
+ * counterparts of the {@link CancelReason} type. Use them in custom
321
+ * cancellation flows that need a typed reason without re-typing the
322
+ * literal.
323
+ */
324
+ declare const cancelReason: {
325
+ readonly superseded: () => SupersededCancelError;
326
+ readonly timeout: (afterMs: number) => TimeoutCancelError;
327
+ };
292
328
  /**
293
329
  * Wrap a mutator handler with auto-cancellation. The wrapped handler
294
330
  * receives an extra `signal: AbortSignal` in its context. Use the
@@ -346,5 +382,105 @@ declare function cancellable<F, P>(opts: CancellableOptions, handler: (ctx: Canc
346
382
  payload: P;
347
383
  requeue: () => void;
348
384
  }) => Promise<void>;
385
+ /**
386
+ * Structured event surfaced to {@link RecordReplayableOptions.onCancel}
387
+ * the moment a wrapped invocation's signal aborts. Carries enough
388
+ * information for the caller to pin the cancellation into the timeline
389
+ * (e.g. by writing onto a facts array) so a later replay can
390
+ * reconstruct the cancellation race exactly.
391
+ *
392
+ * @typeParam F — caller's facts type (passed through unchanged from the handler context).
393
+ * @typeParam P — caller's payload type for THIS handler.
394
+ */
395
+ interface CancelEvent<F, P> {
396
+ /** Whether this cancel was driven by a new dispatch superseding the old, or by the timeout firing. */
397
+ kind: CancelReason["kind"];
398
+ /** When `kind === 'timeout'`, the original `timeoutMs` that fired. Undefined for `'superseded'`. */
399
+ afterMs?: number;
400
+ /** The payload of the dispatch that was cancelled — i.e. the work that did NOT complete. */
401
+ payload: P;
402
+ /**
403
+ * Per-handler monotonic counter, incremented once at the start of
404
+ * every invocation. Useful for timeline diff: cancel of seq=4 is the
405
+ * 4th dispatch this handler ever saw. The counter is closure-scoped
406
+ * to a single `recordReplayable()` call — separate HOCs do NOT share
407
+ * the counter.
408
+ */
409
+ dispatchSeq: number;
410
+ /** Live facts reference, mirroring {@link CancellableHandlerContext.facts}. */
411
+ facts: F;
412
+ }
413
+ /**
414
+ * Options for {@link recordReplayable}. Strict superset of
415
+ * {@link CancellableOptions} — every field on `CancellableOptions`
416
+ * passes through unchanged to the underlying {@link cancellable} call.
417
+ */
418
+ interface RecordReplayableOptions<F, P> extends CancellableOptions {
419
+ /**
420
+ * Synchronous callback invoked the moment a wrapped invocation's
421
+ * AbortController fires `abort()`. Runs BEFORE the handler's pending
422
+ * await rejects with AbortError, so the callback sees the freshest
423
+ * possible state.
424
+ *
425
+ * Use this to pin the cancellation into a place that survives in the
426
+ * timeline (typically a facts field — `facts.cancellations.push(info)`).
427
+ * Without that, a replay re-dispatches the same MUTATE events but has
428
+ * no record of which ones were superseded vs which completed.
429
+ *
430
+ * Throws inside `onCancel` are caught and swallowed — the abort path
431
+ * must remain robust. If you need to fail loudly, log to your own
432
+ * sink before re-throwing.
433
+ */
434
+ onCancel?: (info: CancelEvent<F, P>) => void;
435
+ }
436
+ /**
437
+ * `cancellable()` plus a synchronous on-abort callback. Wrap your
438
+ * handler with `recordReplayable()` instead of `cancellable()` when
439
+ * you want a hook that fires the moment a supersession or timeout
440
+ * aborts the in-flight invocation — *before* the handler's pending
441
+ * await rejects with AbortError. The callback receives a structured
442
+ * {@link CancelEvent} carrying the cancel kind, payload, dispatch
443
+ * sequence, and a live facts reference.
444
+ *
445
+ * The callback is GENERIC ("call me when abort fires"); the most
446
+ * common use case is pinning cancel events into facts so a recorded
447
+ * timeline carries them — which then lets `replayTimeline()`,
448
+ * `bisectTimeline()`, and `diffTimelines()` (all in
449
+ * `@directive-run/timeline`) reason about which dispatches were
450
+ * superseded vs which completed without parsing free-form error
451
+ * strings. But the same callback works equally well for Sentry
452
+ * breadcrumbs, a Redux action log, OpenTelemetry spans, or a metrics
453
+ * sink — `recordReplayable` doesn't know or care about the timeline.
454
+ *
455
+ * @example Pin cancel events into facts so the timeline carries them:
456
+ * ```ts
457
+ * import { defineMutator, recordReplayable } from '@directive-run/mutator';
458
+ *
459
+ * const search = recordReplayable<MyFacts, { q: string }>(
460
+ * {
461
+ * supersedeOn: 'self',
462
+ * timeoutMs: 3_000,
463
+ * onCancel: ({ facts, kind, payload, dispatchSeq }) => {
464
+ * facts.cancellations.push({ kind, queryAtCancel: payload.q, seq: dispatchSeq });
465
+ * },
466
+ * },
467
+ * async ({ payload, facts, signal }) => {
468
+ * const res = await fetch(`/q?${payload.q}`, { signal });
469
+ * facts.results = await res.json();
470
+ * },
471
+ * );
472
+ * ```
473
+ *
474
+ * Implementation note: `recordReplayable()` is `cancellable(opts,
475
+ * innerHandler)` where `innerHandler` adds an `addEventListener('abort')`
476
+ * around the user's handler. This means timeout / supersession
477
+ * semantics are EXACTLY those of `cancellable()` — the HOC is purely
478
+ * additive.
479
+ */
480
+ declare function recordReplayable<F, P>(opts: RecordReplayableOptions<F, P>, handler: (ctx: CancellableHandlerContext<F, P>) => Promise<void> | void): (ctx: {
481
+ facts: F;
482
+ payload: P;
483
+ requeue: () => void;
484
+ }) => Promise<void>;
349
485
 
350
- export { type CancelReason, type CancellableHandlerContext, type CancellableOptions, type MutationHandler, type MutationHandlers, type MutationMap, type MutatorFragments, type MutatorHandlerContext, type PendingMutation, cancellable, defineMutator, mutate };
486
+ export { CancelError, type CancelEvent, type CancelReason, type CancellableHandlerContext, type CancellableOptions, type MutationHandler, type MutationHandlers, type MutationMap, type MutatorFragments, type MutatorHandlerContext, type PendingMutation, type RecordReplayableOptions, SupersededCancelError, TimeoutCancelError, cancelReason, cancellable, defineMutator, mutate, recordReplayable };
package/dist/index.d.ts CHANGED
@@ -282,6 +282,12 @@ interface CancellableHandlerContext<F, P> {
282
282
  /**
283
283
  * Reason a cancellable handler's signal aborted. Stamped on
284
284
  * `signal.reason` so the handler can disambiguate.
285
+ *
286
+ * Note: at runtime the value is a {@link CancelError} subclass with
287
+ * the same shape — `signal.reason instanceof CancelError` is the
288
+ * canonical check. Older usages that did `signal.reason?.kind ===
289
+ * 'superseded'` continue to work because the Error subclass exposes
290
+ * the same `kind` field. (R2 sec M-1.)
285
291
  */
286
292
  type CancelReason = {
287
293
  kind: "superseded";
@@ -289,6 +295,36 @@ type CancelReason = {
289
295
  kind: "timeout";
290
296
  afterMs: number;
291
297
  };
298
+ /**
299
+ * Runtime carrier for {@link CancelReason}. Subclasses `Error` so the
300
+ * value survives transit through `fetch(url, { signal })` and other
301
+ * web-platform APIs that re-throw `signal.reason`. Older mutator
302
+ * versions passed plain objects, which `.catch(err => err instanceof
303
+ * Error)` filters silently dropped. (R2 sec M-1.)
304
+ */
305
+ declare class CancelError extends Error {
306
+ readonly kind: CancelReason["kind"];
307
+ constructor(kind: CancelReason["kind"], message?: string);
308
+ }
309
+ /** Subclass of {@link CancelError} for timeout-driven aborts. */
310
+ declare class TimeoutCancelError extends CancelError {
311
+ readonly afterMs: number;
312
+ constructor(afterMs: number);
313
+ }
314
+ /** Subclass of {@link CancelError} for supersede-driven aborts. */
315
+ declare class SupersededCancelError extends CancelError {
316
+ constructor();
317
+ }
318
+ /**
319
+ * Factory for cancel-reason values. Pure — these are the runtime
320
+ * counterparts of the {@link CancelReason} type. Use them in custom
321
+ * cancellation flows that need a typed reason without re-typing the
322
+ * literal.
323
+ */
324
+ declare const cancelReason: {
325
+ readonly superseded: () => SupersededCancelError;
326
+ readonly timeout: (afterMs: number) => TimeoutCancelError;
327
+ };
292
328
  /**
293
329
  * Wrap a mutator handler with auto-cancellation. The wrapped handler
294
330
  * receives an extra `signal: AbortSignal` in its context. Use the
@@ -346,5 +382,105 @@ declare function cancellable<F, P>(opts: CancellableOptions, handler: (ctx: Canc
346
382
  payload: P;
347
383
  requeue: () => void;
348
384
  }) => Promise<void>;
385
+ /**
386
+ * Structured event surfaced to {@link RecordReplayableOptions.onCancel}
387
+ * the moment a wrapped invocation's signal aborts. Carries enough
388
+ * information for the caller to pin the cancellation into the timeline
389
+ * (e.g. by writing onto a facts array) so a later replay can
390
+ * reconstruct the cancellation race exactly.
391
+ *
392
+ * @typeParam F — caller's facts type (passed through unchanged from the handler context).
393
+ * @typeParam P — caller's payload type for THIS handler.
394
+ */
395
+ interface CancelEvent<F, P> {
396
+ /** Whether this cancel was driven by a new dispatch superseding the old, or by the timeout firing. */
397
+ kind: CancelReason["kind"];
398
+ /** When `kind === 'timeout'`, the original `timeoutMs` that fired. Undefined for `'superseded'`. */
399
+ afterMs?: number;
400
+ /** The payload of the dispatch that was cancelled — i.e. the work that did NOT complete. */
401
+ payload: P;
402
+ /**
403
+ * Per-handler monotonic counter, incremented once at the start of
404
+ * every invocation. Useful for timeline diff: cancel of seq=4 is the
405
+ * 4th dispatch this handler ever saw. The counter is closure-scoped
406
+ * to a single `recordReplayable()` call — separate HOCs do NOT share
407
+ * the counter.
408
+ */
409
+ dispatchSeq: number;
410
+ /** Live facts reference, mirroring {@link CancellableHandlerContext.facts}. */
411
+ facts: F;
412
+ }
413
+ /**
414
+ * Options for {@link recordReplayable}. Strict superset of
415
+ * {@link CancellableOptions} — every field on `CancellableOptions`
416
+ * passes through unchanged to the underlying {@link cancellable} call.
417
+ */
418
+ interface RecordReplayableOptions<F, P> extends CancellableOptions {
419
+ /**
420
+ * Synchronous callback invoked the moment a wrapped invocation's
421
+ * AbortController fires `abort()`. Runs BEFORE the handler's pending
422
+ * await rejects with AbortError, so the callback sees the freshest
423
+ * possible state.
424
+ *
425
+ * Use this to pin the cancellation into a place that survives in the
426
+ * timeline (typically a facts field — `facts.cancellations.push(info)`).
427
+ * Without that, a replay re-dispatches the same MUTATE events but has
428
+ * no record of which ones were superseded vs which completed.
429
+ *
430
+ * Throws inside `onCancel` are caught and swallowed — the abort path
431
+ * must remain robust. If you need to fail loudly, log to your own
432
+ * sink before re-throwing.
433
+ */
434
+ onCancel?: (info: CancelEvent<F, P>) => void;
435
+ }
436
+ /**
437
+ * `cancellable()` plus a synchronous on-abort callback. Wrap your
438
+ * handler with `recordReplayable()` instead of `cancellable()` when
439
+ * you want a hook that fires the moment a supersession or timeout
440
+ * aborts the in-flight invocation — *before* the handler's pending
441
+ * await rejects with AbortError. The callback receives a structured
442
+ * {@link CancelEvent} carrying the cancel kind, payload, dispatch
443
+ * sequence, and a live facts reference.
444
+ *
445
+ * The callback is GENERIC ("call me when abort fires"); the most
446
+ * common use case is pinning cancel events into facts so a recorded
447
+ * timeline carries them — which then lets `replayTimeline()`,
448
+ * `bisectTimeline()`, and `diffTimelines()` (all in
449
+ * `@directive-run/timeline`) reason about which dispatches were
450
+ * superseded vs which completed without parsing free-form error
451
+ * strings. But the same callback works equally well for Sentry
452
+ * breadcrumbs, a Redux action log, OpenTelemetry spans, or a metrics
453
+ * sink — `recordReplayable` doesn't know or care about the timeline.
454
+ *
455
+ * @example Pin cancel events into facts so the timeline carries them:
456
+ * ```ts
457
+ * import { defineMutator, recordReplayable } from '@directive-run/mutator';
458
+ *
459
+ * const search = recordReplayable<MyFacts, { q: string }>(
460
+ * {
461
+ * supersedeOn: 'self',
462
+ * timeoutMs: 3_000,
463
+ * onCancel: ({ facts, kind, payload, dispatchSeq }) => {
464
+ * facts.cancellations.push({ kind, queryAtCancel: payload.q, seq: dispatchSeq });
465
+ * },
466
+ * },
467
+ * async ({ payload, facts, signal }) => {
468
+ * const res = await fetch(`/q?${payload.q}`, { signal });
469
+ * facts.results = await res.json();
470
+ * },
471
+ * );
472
+ * ```
473
+ *
474
+ * Implementation note: `recordReplayable()` is `cancellable(opts,
475
+ * innerHandler)` where `innerHandler` adds an `addEventListener('abort')`
476
+ * around the user's handler. This means timeout / supersession
477
+ * semantics are EXACTLY those of `cancellable()` — the HOC is purely
478
+ * additive.
479
+ */
480
+ declare function recordReplayable<F, P>(opts: RecordReplayableOptions<F, P>, handler: (ctx: CancellableHandlerContext<F, P>) => Promise<void> | void): (ctx: {
481
+ facts: F;
482
+ payload: P;
483
+ requeue: () => void;
484
+ }) => Promise<void>;
349
485
 
350
- export { type CancelReason, type CancellableHandlerContext, type CancellableOptions, type MutationHandler, type MutationHandlers, type MutationMap, type MutatorFragments, type MutatorHandlerContext, type PendingMutation, cancellable, defineMutator, mutate };
486
+ export { CancelError, type CancelEvent, type CancelReason, type CancellableHandlerContext, type CancellableOptions, type MutationHandler, type MutationHandlers, type MutationMap, type MutatorFragments, type MutatorHandlerContext, type PendingMutation, type RecordReplayableOptions, SupersededCancelError, TimeoutCancelError, cancelReason, cancellable, defineMutator, mutate, recordReplayable };
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
1
- import {t}from'@directive-run/core';var c=500;function p(e){let n;if(e instanceof Error){let t;try{t=e.message;}catch{t="[mutator: error.message getter threw]";}n=typeof t=="string"?t:g(t);}else typeof e=="string"?n=e:n=g(e);return n.length<=c?n:`${n.slice(0,c-1)}\u2026`}function g(e){try{return String(e)}catch{return "[mutator: unstringifiable error]"}}function T(e){return {facts:{pendingMutation:t.object().nullable()},events:{MUTATE:void 0},requirements:{PROCESS_MUTATION:{}},eventHandlers:{MUTATE:(o,r)=>{o.pendingMutation={...r,status:"pending",error:null};}},constraints:{pendingMutation:{when:o=>o.pendingMutation!==null&&o.pendingMutation?.status==="pending",require:{type:"PROCESS_MUTATION"}}},resolvers:{mutationResolver:{requirement:"PROCESS_MUTATION",resolve:async(o,r)=>{let i=r.facts,a=i.pendingMutation;if(a===null)return;i.pendingMutation={...a,status:"running"};let M=Object.prototype.hasOwnProperty.call(e,a.kind)?e[a.kind]:void 0;if(typeof M!="function"){i.pendingMutation={...a,status:"failed",error:p(`[mutator] no handler registered for variant: ${String(a.kind)}`)};return}let f={facts:r.facts,payload:a.payload,requeue:r.requeue??(()=>{})};try{await M(f),i.pendingMutation?.status==="running"&&(i.pendingMutation=null);}catch(y){if(i.pendingMutation?.status!=="running")return;i.pendingMutation={...a,status:"failed",error:p(y)};}}}},initialPendingMutation:null}}function x(e,n){return {kind:e,payload:n??{},status:"pending",error:null}}function k(e,n){let t=e.supersedeOn??"self",u=e.timeoutMs,l=e.setTimeout??v,s;return async d=>{t==="self"&&s!==void 0&&s.abort({kind:"superseded"});let o=new AbortController;s=o;let r;typeof u=="number"&&u>0&&(r=l(()=>{o.abort({kind:"timeout",afterMs:u});},u));try{await n({facts:d.facts,payload:d.payload,requeue:d.requeue,signal:o.signal});}finally{r?.(),s===o&&(s=void 0);}}}function v(e,n){let t=globalThis.setTimeout(e,n);return ()=>globalThis.clearTimeout(t)}export{k as cancellable,T as defineMutator,x as mutate};//# sourceMappingURL=index.js.map
1
+ import {t}from'@directive-run/core';var g=500;function m(e){let n;if(e instanceof Error){let t;try{t=e.message;}catch{t="[mutator: error.message getter threw]";}n=typeof t=="string"?t:y(t);}else typeof e=="string"?n=e:n=y(e);return n.length<=g?n:`${n.slice(0,g-1)}\u2026`}function y(e){try{return String(e)}catch{return "[mutator: unstringifiable error]"}}function R(e){return {facts:{pendingMutation:t.object().nullable()},events:{MUTATE:void 0},requirements:{PROCESS_MUTATION:{}},eventHandlers:{MUTATE:(r,a)=>{r.pendingMutation={...a,status:"pending",error:null};}},constraints:{pendingMutation:{when:r=>r.pendingMutation!==null&&r.pendingMutation?.status==="pending",require:{type:"PROCESS_MUTATION"}}},resolvers:{mutationResolver:{requirement:"PROCESS_MUTATION",resolve:async(r,a)=>{let l=a.facts,u=l.pendingMutation;if(u===null)return;l.pendingMutation={...u,status:"running"};let f=Object.prototype.hasOwnProperty.call(e,u.kind)?e[u.kind]:void 0;if(typeof f!="function"){l.pendingMutation={...u,status:"failed",error:m(`[mutator] no handler registered for variant: ${String(u.kind)}`)};return}let P={facts:a.facts,payload:u.payload,requeue:a.requeue??(()=>{})};try{await f(P),l.pendingMutation?.status==="running"&&(l.pendingMutation=null);}catch(F){if(l.pendingMutation?.status!=="running")return;l.pendingMutation={...u,status:"failed",error:m(F)};}}}},initialPendingMutation:null}}function O(e,n){return {kind:e,payload:n??{},status:"pending",error:null}}var c=class extends Error{constructor(t,i){super(i??`[mutator] cancellable: ${t}`);this.kind=t;this.name="CancelError";}},p=class extends c{constructor(t){super("timeout",`[mutator] cancellable: timeout after ${t}ms`);this.afterMs=t;this.name="TimeoutCancelError";}},M=class extends c{constructor(){super("superseded","[mutator] cancellable: superseded by new dispatch"),this.name="SupersededCancelError";}},v={superseded:()=>new M,timeout:e=>new p(e)};function x(e,n){let t=e.supersedeOn??"self",i=e.timeoutMs,o=e.setTimeout??C,s;return async d=>{t==="self"&&s!==void 0&&s.abort(v.superseded());let r=new AbortController;s=r;let a;typeof i=="number"&&i>0&&(a=o(()=>{r.abort(v.timeout(i));},i));try{await n({facts:d.facts,payload:d.payload,requeue:d.requeue,signal:r.signal});}finally{try{a?.();}catch{}s===r&&(s=void 0);}}}function C(e,n){let t=globalThis.setTimeout(e,n);return ()=>globalThis.clearTimeout(t)}function S(e,n){let t=0;return x(e,async o=>{let s=++t,d=()=>{let r=o.signal.reason;if(!(r instanceof c))return;let a={kind:r.kind,afterMs:r instanceof p?r.afterMs:void 0,payload:o.payload,dispatchSeq:s,facts:o.facts};try{e.onCancel?.(a);}catch{}};o.signal.addEventListener("abort",d,{once:!0});try{await n(o);}finally{o.signal.removeEventListener("abort",d);}})}export{c as CancelError,M as SupersededCancelError,p as TimeoutCancelError,v as cancelReason,x as cancellable,R as defineMutator,O as mutate,S as recordReplayable};//# sourceMappingURL=index.js.map
2
2
  //# sourceMappingURL=index.js.map
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","cancellable","opts","supersedeOn","timeoutMs","scheduleTimeout","defaultSetTimeout","priorController","controller","cancelTimeout","cb","ms","handle"],"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 */\nexport type CancelReason =\n | { kind: \"superseded\" }\n | { kind: \"timeout\"; afterMs: number };\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 if (supersedeOn === \"self\" && priorController !== undefined) {\n priorController.abort({ kind: \"superseded\" } satisfies CancelReason);\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({ kind: \"timeout\", afterMs: timeoutMs } satisfies CancelReason);\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 cancelTimeout?.();\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"]}
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.2.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",
@@ -45,13 +45,13 @@
45
45
  "CHANGELOG.md"
46
46
  ],
47
47
  "peerDependencies": {
48
- "@directive-run/core": "^1.2.0"
48
+ "@directive-run/core": "^1.3.0"
49
49
  },
50
50
  "devDependencies": {
51
51
  "tsup": "^8.3.5",
52
52
  "typescript": "^5.7.2",
53
53
  "vitest": "^2.1.9",
54
- "@directive-run/core": "1.3.0"
54
+ "@directive-run/core": "1.15.0"
55
55
  },
56
56
  "scripts": {
57
57
  "build": "tsup",