@directive-run/mutator 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +103 -61
- package/README.md +43 -24
- package/dist/index.cjs.map +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,54 +1,98 @@
|
|
|
1
1
|
# @directive-run/mutator changelog
|
|
2
2
|
|
|
3
|
-
## 0.3.
|
|
4
|
-
|
|
5
|
-
### Minor Changes
|
|
3
|
+
## 0.3.1
|
|
6
4
|
|
|
7
|
-
|
|
5
|
+
### Patch Changes
|
|
8
6
|
|
|
9
|
-
|
|
7
|
+
- [`93cd8b8`](https://github.com/directive-run/directive/commit/93cd8b804c79ae3f08a52d9848312faf135f2cf5) Thanks [@jasoncomes](https://github.com/jasoncomes)! - Docs UX reconciliation: AI tooling becomes a first-class install path
|
|
8
|
+
and Directive's coding knowledge ships from one source-of-truth to
|
|
9
|
+
every assistant.
|
|
10
|
+
|
|
11
|
+
A new `/docs/ide-integration` page at directive.run is the canonical
|
|
12
|
+
decision tree across Claude Code, Cursor, GitHub Copilot, Windsurf,
|
|
13
|
+
Cline, OpenAI Codex, and the programmatic `@directive-run/knowledge`
|
|
14
|
+
API. The docs sidebar gets an "AI Tooling" section as item #2 between
|
|
15
|
+
Getting Started and Core API, surfacing the integration path
|
|
16
|
+
alongside the core learning journey. The `/llms.txt` route gains an
|
|
17
|
+
"Install paths for your AI assistant" block so LLM agents crawling
|
|
18
|
+
the docs at runtime learn how a downstream developer would install
|
|
19
|
+
the same knowledge they're consuming.
|
|
20
|
+
|
|
21
|
+
The Claude Code install path becomes real: a `.claude-plugin/
|
|
22
|
+
marketplace.json` is now committed to the directive monorepo root —
|
|
23
|
+
previously gitignored, which is why `/plugin marketplace add
|
|
24
|
+
directive-run/directive` returned 404 from GitHub. Users can now run
|
|
25
|
+
the two-step install the claude-plugin README has been documenting:
|
|
10
26
|
|
|
11
|
-
|
|
27
|
+
```
|
|
28
|
+
/plugin marketplace add directive-run/directive
|
|
29
|
+
/plugin install directive@directive-plugins
|
|
30
|
+
```
|
|
12
31
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
32
|
+
Every published adapter README (query, mutator, optimistic, timeline,
|
|
33
|
+
el, cli, vite-plugin-api-proxy) gains two new sections: a "Composes
|
|
34
|
+
with" footer linking the sibling packages it commonly composes with
|
|
35
|
+
(fixes the nav-orphan gap from R7 — query had no links to mutator /
|
|
36
|
+
optimistic / timeline despite being designed to compose), and a "Use
|
|
37
|
+
this package with your AI assistant" hook tied to that package's
|
|
38
|
+
value prop. Each knowledge `.md` file in `@directive-run/knowledge`
|
|
39
|
+
gains a one-line top-of-file breadcrumb naming the package(s) it
|
|
40
|
+
documents, so a developer or LLM reading any file in isolation knows
|
|
41
|
+
immediately which import to use.
|
|
42
|
+
|
|
43
|
+
The top-level monorepo README gains an "AI tooling" section between
|
|
44
|
+
the existing AI Guardrails and React sections. The
|
|
45
|
+
`@directive-run/knowledge` README is restructured so consumer
|
|
46
|
+
pathways (plugin / CLI / programmatic / llms.txt) lead, instead of
|
|
47
|
+
the programmatic API which previously dominated above the fold.
|
|
48
|
+
|
|
49
|
+
Strategic FYIs for the v1.15 release notes — these are NOT shipping
|
|
50
|
+
in v1.15 but are explicitly tracked:
|
|
51
|
+
|
|
52
|
+
- `@directive-run/claude-plugin` npm publication is under evaluation;
|
|
53
|
+
the plugin stays Claude Code marketplace-only for v1.15.
|
|
54
|
+
- See-also cross-link footers across the 25 knowledge files are on
|
|
55
|
+
the v1.16 roadmap.
|
|
56
|
+
- MCP SSE server (`mcp.directive.run`) for live agent retrieval is on
|
|
57
|
+
the v1.16 roadmap.
|
|
58
|
+
|
|
59
|
+
No code changes; no API changes; this is the docs UX reconciliation
|
|
60
|
+
that makes v1.15's AI tooling story discoverable.
|
|
20
61
|
|
|
21
|
-
|
|
62
|
+
## 0.3.0
|
|
22
63
|
|
|
23
|
-
|
|
24
|
-
- **R2 arch M-5: Exported `cancelReason` factory** — `cancelReason.superseded()` and `cancelReason.timeout(afterMs)` produce typed Error subclasses. Single source of truth for both producers (cancellable internals) and consumers (handler abort observers).
|
|
25
|
-
- **R2 sec M-3: `cancelTimeout` cleanup error-shadowing fix.** A throwing `setTimeout`-cancel-handle (e.g. a hostile virtual clock) no longer replaces the original handler's exception. The cleanup is wrapped in try/catch.
|
|
26
|
-
- **R2 arch M-6: Peer dep tightened to `@directive-run/core@^1.3.0`.** `cancellable()`'s ergonomic test path imports `virtualClock` from core 1.3.0; consumers on 1.2.x would have hit a runtime error copying the README example.
|
|
64
|
+
### Minor Changes
|
|
27
65
|
|
|
28
|
-
|
|
66
|
+
- [`02d80c4`](https://github.com/directive-run/directive/commit/02d80c427c3c6b989765dcd99aa51d1aa3770b8b) Thanks [@jasoncomes](https://github.com/jasoncomes)! - Security, correctness, and DX hardening for timeline replay, matchers, and the cancellable mutator HOC.
|
|
29
67
|
|
|
30
|
-
|
|
68
|
+
### Timeline (surface-compatible fixes)
|
|
31
69
|
|
|
32
|
-
|
|
70
|
+
- **Spread-order RCE in `reconstructDispatch`.** `{ type: "MUTATE", ...next }` let an attacker-controlled `frames[i].event.next.type` field override the dispatch type. Untrusted production-error JSON could re-route every replayed event to an arbitrary handler. Fix: spread-then-set (`{ ...next, type: "MUTATE" }`).
|
|
71
|
+
- **Frame-shape validation in `deserializeTimeline`.** Per-frame validation of `ts`/`event`/`event.type`. Untrusted JSON with malformed frames now produces precise `TypeError` rejections instead of crashing the replay loop with bare exceptions.
|
|
72
|
+
- **Matcher iteration robustness.** All five matchers now filter `frames()` through `isWellFormedFrame` before iterating. Hostile input produces clean assertion failures instead of TypeErrors.
|
|
73
|
+
- **Structural equality in `toReachInMs`.** Replaced `JSON.stringify` equality with `structuredEqual` – NaN/undefined/Infinity no longer produce false-positive matches.
|
|
74
|
+
- **`maxFrames` cap on `replayTimeline`.** Default 100,000 frames; prevents unbounded synchronous loops on hostile JSON dumps.
|
|
75
|
+
- **`replayTimeline` returns `ReplayResult`** (`{ dispatched, skipped, truncated }`) instead of `void`. Lets callers verify the replay actually re-dispatched events instead of silently no-op'ing on non-mutator systems. Breaking change vs v0.2 only in type signature; existing call sites that ignored the return value continue to work.
|
|
76
|
+
- **`dispatchableOnly?: boolean`** is the new option name; `dispatchable?` is kept as a deprecated alias for v0.x compatibility. The original name read backwards ("dispatchable: true" sounded like "this thing IS dispatchable" not "filter to dispatchable").
|
|
33
77
|
|
|
34
|
-
|
|
35
|
-
- Per-package: timeline 30 / 30 (16 timeline + 14 matchers); mutator 30 / 30 (16 mutator + 14 cancellable, including new R2 regression tests for `CancelError` instance checks + finally-block error-shadowing).
|
|
36
|
-
- `pnpm -r --filter './packages/*' typecheck`: clean.
|
|
37
|
-
- `pnpm -r --filter './packages/*' build`: clean.
|
|
78
|
+
### Mutator (additive Error subclasses)
|
|
38
79
|
|
|
39
|
-
|
|
80
|
+
- **`CancelError` Error subclass for `signal.reason`.** New runtime carriers `CancelError`, `TimeoutCancelError`, `SupersededCancelError` ensure `signal.reason instanceof Error` checks succeed downstream. Plain-object reasons silently failed `fetch(url, {signal})` re-throw paths and `.catch(err => err instanceof Error)` filters in logging frameworks. The `CancelReason` type still works (Error subclasses expose the same `kind` field), so existing `signal.reason?.kind === 'superseded'` checks remain valid.
|
|
81
|
+
- **Exported `cancelReason` factory** – `cancelReason.superseded()` and `cancelReason.timeout(afterMs)` produce typed Error subclasses. Single source of truth for both producers (cancellable internals) and consumers (handler abort observers).
|
|
82
|
+
- **`cancelTimeout` cleanup error-shadowing fix.** A throwing `setTimeout`-cancel-handle (e.g. a hostile virtual clock) no longer replaces the original handler's exception. The cleanup is wrapped in try/catch.
|
|
83
|
+
- **Peer dep tightened to `@directive-run/core@^1.3.0`.** `cancellable()`'s ergonomic test path imports `virtualClock` from core 1.3.0; consumers on 1.2.x would have hit a runtime error copying the README example.
|
|
40
84
|
|
|
41
|
-
- [`f70bd70`](https://github.com/directive-run/directive/commit/f70bd70071d2bc2fab5af6b6866f8e7c6ce559b1) Thanks [@jasoncomes](https://github.com/jasoncomes)! -
|
|
85
|
+
- [`f70bd70`](https://github.com/directive-run/directive/commit/f70bd70071d2bc2fab5af6b6866f8e7c6ce559b1) Thanks [@jasoncomes](https://github.com/jasoncomes)! - `recordReplayable()` HOC – structured cancellation events for replay-aware mutations
|
|
42
86
|
|
|
43
87
|
Wraps a mutator handler with the same supersession + timeout semantics as `cancellable()`, plus a synchronous `onCancel` callback that fires the moment the AbortController calls `abort()`. The callback receives a `CancelEvent<F, P>` carrying:
|
|
44
88
|
|
|
45
89
|
- `kind: 'superseded' | 'timeout'`
|
|
46
90
|
- `afterMs?: number` (timeout only)
|
|
47
|
-
- `payload: P`
|
|
48
|
-
- `dispatchSeq: number`
|
|
49
|
-
- `facts: F`
|
|
91
|
+
- `payload: P` – the dispatch that did NOT complete
|
|
92
|
+
- `dispatchSeq: number` – per-handler monotonic counter
|
|
93
|
+
- `facts: F` – live facts reference
|
|
50
94
|
|
|
51
|
-
Use `onCancel` to pin cancellations into a place that survives in the timeline (typically a facts array). Without that, a replay re-dispatches the same MUTATE events but has no record of which were superseded vs which completed
|
|
95
|
+
Use `onCancel` to pin cancellations into a place that survives in the timeline (typically a facts array). Without that, a replay re-dispatches the same MUTATE events but has no record of which were superseded vs which completed – so timeline diff/bisect tools cannot reason about cancellations without parsing free-form error strings.
|
|
52
96
|
|
|
53
97
|
```ts
|
|
54
98
|
import { defineMutator, recordReplayable } from "@directive-run/mutator";
|
|
@@ -72,45 +116,43 @@
|
|
|
72
116
|
);
|
|
73
117
|
```
|
|
74
118
|
|
|
75
|
-
Implementation note: `recordReplayable()` is `cancellable(opts, innerHandler)` where `innerHandler` adds a `signal.addEventListener('abort')` around the user's handler. Timeout / supersession semantics are EXACTLY those of `cancellable()`
|
|
119
|
+
Implementation note: `recordReplayable()` is `cancellable(opts, innerHandler)` where `innerHandler` adds a `signal.addEventListener('abort')` around the user's handler. Timeout / supersession semantics are EXACTLY those of `cancellable()` – the HOC is purely additive. The abort listener fires synchronously, BEFORE the handler's pending await rejects with AbortError, so the callback sees the freshest possible state.
|
|
76
120
|
|
|
77
|
-
`onCancel` errors are caught and swallowed
|
|
121
|
+
`onCancel` errors are caught and swallowed – the abort path stays clean.
|
|
78
122
|
|
|
79
123
|
9 new tests covering: clean run no-op, supersession callback delivery, dispatchSeq monotonic per HOC, two HOCs maintain independent counters, timeout callback delivery with afterMs preserved, onCancel-throw robustness, non-CancelError abort filter, CancelError class hierarchy.
|
|
80
124
|
|
|
81
125
|
### Patch Changes
|
|
82
126
|
|
|
83
|
-
- [`0d8cae5`](https://github.com/directive-run/directive/commit/0d8cae57e7e9b28ecb64e98588458a264dbd06c1) Thanks [@jasoncomes](https://github.com/jasoncomes)! -
|
|
127
|
+
- [`0d8cae5`](https://github.com/directive-run/directive/commit/0d8cae57e7e9b28ecb64e98588458a264dbd06c1) Thanks [@jasoncomes](https://github.com/jasoncomes)! - Production-readiness pass: better docs, cleaner types, and consistent semantics.
|
|
84
128
|
|
|
85
|
-
|
|
129
|
+
No new commands; existing surfaces gain better docs, cleaner types, and consistent semantics.
|
|
86
130
|
|
|
87
|
-
**Documentation
|
|
131
|
+
**Documentation:**
|
|
88
132
|
|
|
89
|
-
- `@directive-run/timeline` README
|
|
90
|
-
- `@directive-run/cli` README
|
|
91
|
-
- `@directive-run/mutator` README
|
|
133
|
+
- `@directive-run/timeline` README – replaces the outdated "v0.4 – diff mode (deferred)" roadmap with shipped reality. New "Serialize, replay, bisect, diff" section walks all four operational entry points end-to-end with library and CLI examples for each.
|
|
134
|
+
- `@directive-run/cli` README – adds full sections for `directive replay`, `directive bisect` (with a security note for `--assert`), and `directive timeline diff` (with exit-code documentation).
|
|
135
|
+
- `@directive-run/mutator` README – new "Recording cancellations for replay" section covers `recordReplayable()` end-to-end.
|
|
92
136
|
|
|
93
|
-
**Type ergonomics
|
|
137
|
+
**Type ergonomics:**
|
|
94
138
|
|
|
95
139
|
- `BisectResult` now carries a `kind: 'found' | 'no-failure' | 'fails-on-empty' | 'non-deterministic'` discriminator. Consumers can `switch (result.kind)` for clean type-narrowed access instead of juggling three booleans plus an optional index. Legacy boolean fields stay populated for back-compat (marked `@deprecated`).
|
|
96
140
|
|
|
97
|
-
**Exit-code consistency
|
|
98
|
-
|
|
99
|
-
- `directive bisect` now exits `2` on a "standard hit" (located the first failing frame). Aligns with `directive timeline diff` (exit 2 = differences found), so CI gates can branch uniformly: `0 = clean, 1 = CLI error, 2 = problem found / refused`. Documented in CLI README.
|
|
141
|
+
**Exit-code consistency:**
|
|
100
142
|
|
|
101
|
-
|
|
143
|
+
- `directive bisect` now exits `2` on a "standard hit" (located the first failing frame). Aligns with `directive timeline diff` (exit 2 = differences found), so CI gates can branch uniformly: `0 = clean, 1 = CLI error, 2 = problem found / refused`. Documented in the CLI README.
|
|
102
144
|
|
|
103
|
-
|
|
145
|
+
**Docstring corrections:**
|
|
104
146
|
|
|
105
|
-
|
|
147
|
+
- `recordReplayable()` JSDoc reframed: the function is a generic "call me when abort fires" hook. Pinning into facts is one use case; Sentry breadcrumbs, Redux logs, OpenTelemetry, and metrics are equally valid. Removes the misleading "pairs with timeline" framing that overstated the coupling.
|
|
106
148
|
|
|
107
149
|
## 0.2.0
|
|
108
150
|
|
|
109
151
|
### Minor Changes
|
|
110
152
|
|
|
111
|
-
- [`dc4ac7b`](https://github.com/directive-run/directive/commit/dc4ac7b93007104ce4973d86fb3d6f6a5d1fcded) Thanks [@jasoncomes](https://github.com/jasoncomes)! - Add `cancellable()` HOC
|
|
153
|
+
- [`dc4ac7b`](https://github.com/directive-run/directive/commit/dc4ac7b93007104ce4973d86fb3d6f6a5d1fcded) Thanks [@jasoncomes](https://github.com/jasoncomes)! - Add `cancellable()` HOC – auto-cancel-on-supersede for mutator handlers
|
|
112
154
|
|
|
113
|
-
|
|
155
|
+
Wrap a mutator handler with `cancellable()` to get auto-cancellation: a fresh dispatch of the same wrapped handler aborts the prior in-flight invocation, or an optional timeout fires the abort after N ms.
|
|
114
156
|
|
|
115
157
|
```ts
|
|
116
158
|
import { defineMutator, cancellable } from "@directive-run/mutator";
|
|
@@ -131,11 +173,11 @@
|
|
|
131
173
|
|
|
132
174
|
**Two cancellation triggers, both opt-in:**
|
|
133
175
|
|
|
134
|
-
- `supersedeOn: 'self'` (default)
|
|
135
|
-
- `supersedeOn: 'never'`
|
|
136
|
-
- `timeoutMs: number`
|
|
176
|
+
- `supersedeOn: 'self'` (default) – new dispatch supersedes prior
|
|
177
|
+
- `supersedeOn: 'never'` – only timeout fires; parallel runs are fine
|
|
178
|
+
- `timeoutMs: number` – abort after N ms from invocation start
|
|
137
179
|
|
|
138
|
-
**Test ergonomics.** Pass `virtualClock.setTimeout` from `@directive-run/core` via the `setTimeout` option to make timeouts fire synchronously under `clock.advanceBy(ms)`
|
|
180
|
+
**Test ergonomics.** Pass `virtualClock.setTimeout` from `@directive-run/core` via the `setTimeout` option to make timeouts fire synchronously under `clock.advanceBy(ms)` – no real-time waits.
|
|
139
181
|
|
|
140
182
|
The signal's `.reason` carries a typed `CancelReason`:
|
|
141
183
|
|
|
@@ -145,19 +187,19 @@
|
|
|
145
187
|
| { kind: "timeout"; afterMs: number };
|
|
146
188
|
```
|
|
147
189
|
|
|
148
|
-
**Composition.** Drops in directly to `defineMutator`'s handler map slot. Two separate `cancellable()` HOCs around different handlers do NOT cancel each other
|
|
190
|
+
**Composition.** Drops in directly to `defineMutator`'s handler map slot. Two separate `cancellable()` HOCs around different handlers do NOT cancel each other – the supersession registry is closure-scoped per call.
|
|
149
191
|
|
|
150
192
|
**v0.1 scope:** `cancellable()` is a value-layer HOC; engine-side never sees a difference between a wrapped handler and a plain async one. v0.2 will explore the timeline integration so `expect(timeline).toCancel('search')` matchers can assert against the abort stream.
|
|
151
193
|
|
|
152
194
|
9 new tests covering basic invocation, supersession (both modes), timeout (using virtualClock for determinism), supersession+timeout composition, HOC independence.
|
|
153
195
|
|
|
154
|
-
## 0.1.0
|
|
196
|
+
## 0.1.0 – 2026-04-29
|
|
155
197
|
|
|
156
198
|
Initial release.
|
|
157
199
|
|
|
158
|
-
### Added
|
|
200
|
+
### Added – v0.2 (cancellable)
|
|
159
201
|
|
|
160
|
-
- `cancellable(opts, handler)`
|
|
202
|
+
- `cancellable(opts, handler)` – HOC that wraps a mutator handler with
|
|
161
203
|
auto-cancellation. Receives a `signal: AbortSignal` in the handler
|
|
162
204
|
context. Two cancellation triggers: `supersedeOn: 'self' | 'never'`
|
|
163
205
|
(default `'self'`) and `timeoutMs?: number`. The signal's `reason`
|
|
@@ -167,13 +209,13 @@ Initial release.
|
|
|
167
209
|
- `CancellableOptions`, `CancellableHandlerContext<F, P>`,
|
|
168
210
|
`CancelReason` type exports.
|
|
169
211
|
|
|
170
|
-
### Added
|
|
212
|
+
### Added – v0.1
|
|
171
213
|
|
|
172
|
-
- `defineMutator(handlers)`
|
|
214
|
+
- `defineMutator(handlers)` – typed builder that returns six fragments
|
|
173
215
|
(facts / events / requirements / eventHandlers / constraints /
|
|
174
216
|
resolvers) wiring a discriminated `pendingMutation` lifecycle into a
|
|
175
217
|
Directive module.
|
|
176
|
-
- `mutate(kind, payload?)`
|
|
218
|
+
- `mutate(kind, payload?)` – typed payload constructor for `MUTATE`
|
|
177
219
|
dispatches.
|
|
178
220
|
- Single-flight concurrency model: new mutations overwrite in-flight ones
|
|
179
221
|
via the `pendingMutation` fact.
|
|
@@ -185,8 +227,8 @@ Initial release.
|
|
|
185
227
|
|
|
186
228
|
### Known gaps
|
|
187
229
|
|
|
188
|
-
- Parallel-of-same-shape mutations not supported
|
|
189
|
-
- No runtime payload validation
|
|
230
|
+
- Parallel-of-same-shape mutations not supported – last-write-wins.
|
|
231
|
+
- No runtime payload validation – TypeScript only at dispatch site.
|
|
190
232
|
- Optimistic / snapshot-rollback support belongs to upcoming
|
|
191
233
|
`@directive-run/optimistic`; do manual rollback inside handlers for
|
|
192
234
|
now.
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# `@directive-run/mutator`
|
|
2
2
|
|
|
3
|
-
> Discriminated mutation helper for Directive
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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`
|
|
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
|
|
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
|
|
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
|
|
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`
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
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`
|
|
235
|
+
- `timeoutMs: number` – abort after N ms from invocation start.
|
|
236
236
|
Default unset (no timeout).
|
|
237
237
|
|
|
238
238
|
**Test ergonomics:** pass `virtualClock.setTimeout` from
|
|
@@ -263,7 +263,7 @@ Use it inside handlers to distinguish how the cancellation arrived
|
|
|
263
263
|
## Recording cancellations for replay (R2.B `recordReplayable()`)
|
|
264
264
|
|
|
265
265
|
`recordReplayable()` is `cancellable()` plus a synchronous `onCancel`
|
|
266
|
-
callback that fires the moment the AbortController calls `abort()`
|
|
266
|
+
callback that fires the moment the AbortController calls `abort()` –
|
|
267
267
|
*before* the handler's pending await rejects with AbortError. The
|
|
268
268
|
callback receives a structured `CancelEvent` with the cancel kind,
|
|
269
269
|
payload, dispatch sequence, and a live facts reference, so you can
|
|
@@ -271,7 +271,7 @@ pin cancellations into a place that survives in the timeline.
|
|
|
271
271
|
|
|
272
272
|
Use this when you record a timeline (with `@directive-run/timeline`)
|
|
273
273
|
and want a replay or `directive bisect` to reason about *which*
|
|
274
|
-
dispatches were superseded vs which completed
|
|
274
|
+
dispatches were superseded vs which completed – not just see a
|
|
275
275
|
free-form error string.
|
|
276
276
|
|
|
277
277
|
```ts
|
|
@@ -302,14 +302,14 @@ const search = recordReplayable<MyFacts, { q: string }>(
|
|
|
302
302
|
);
|
|
303
303
|
```
|
|
304
304
|
|
|
305
|
-
`recordReplayable()` is implemented as `cancellable(opts, innerHandler)` where `innerHandler` adds an `addEventListener('abort')` around the user's handler
|
|
305
|
+
`recordReplayable()` is implemented as `cancellable(opts, innerHandler)` where `innerHandler` adds an `addEventListener('abort')` around the user's handler – timeout/supersession semantics are exactly those of `cancellable()`. The callback is generic ("call me when abort fires"); pinning into facts is one use case among many. Wire `onCancel` to Sentry breadcrumbs, a Redux action log, or a metrics sink with equal ease.
|
|
306
306
|
|
|
307
|
-
`onCancel` errors are caught and swallowed
|
|
307
|
+
`onCancel` errors are caught and swallowed – the abort path stays clean.
|
|
308
308
|
|
|
309
309
|
## Optimistic updates + rollback
|
|
310
310
|
|
|
311
311
|
A future `@directive-run/optimistic` package will integrate with this
|
|
312
|
-
one
|
|
312
|
+
one – the planned `ctx.snapshot([keys])` API lets a handler snapshot
|
|
313
313
|
specific facts before mutating, with automatic rollback on throw. Until
|
|
314
314
|
that ships, do snapshots manually inside handlers:
|
|
315
315
|
|
|
@@ -326,11 +326,30 @@ submit: async ({ payload, facts, deps }) => {
|
|
|
326
326
|
},
|
|
327
327
|
```
|
|
328
328
|
|
|
329
|
+
## Composes with
|
|
330
|
+
|
|
331
|
+
- [`@directive-run/query`](../query) — declarative data fetching; pair mutators with query's invalidation tags so a successful mutation refetches the right resources
|
|
332
|
+
- [`@directive-run/optimistic`](../optimistic) — `withOptimistic` / `withOptimisticHandlers` wrap a mutator handler with snapshot + rollback
|
|
333
|
+
- [`@directive-run/timeline`](../timeline) — records every mutation cycle into a timeline for replayable testing
|
|
334
|
+
|
|
335
|
+
## Use this package with your AI assistant
|
|
336
|
+
|
|
337
|
+
Discriminated-union mutators are what AI assistants need to write correct optimistic updates first try — pair `defineMutator` with [Directive's IDE Integration](https://directive.run/docs/ide-integration) so your assistant knows when to reach for `cancellable()` / `recordReplayable()` vs. plain handlers.
|
|
338
|
+
|
|
339
|
+
```
|
|
340
|
+
# Claude Code
|
|
341
|
+
/plugin marketplace add directive-run/directive
|
|
342
|
+
/plugin install directive@directive-plugins
|
|
343
|
+
|
|
344
|
+
# Cursor / Copilot / Windsurf / Cline / Codex
|
|
345
|
+
npx directive ai-rules init
|
|
346
|
+
```
|
|
347
|
+
|
|
329
348
|
## See also
|
|
330
349
|
|
|
331
350
|
- [Directive core](https://www.npmjs.com/package/@directive-run/core)
|
|
332
|
-
- [Migrating from XState
|
|
333
|
-
- [Internal events](https://docs.directive.run/patterns/internal-events)
|
|
351
|
+
- [Migrating from XState – `pendingAction` pattern](https://docs.directive.run/migrating-from-xstate#the-pendingaction-pattern-12-cycles-confirmed)
|
|
352
|
+
- [Internal events](https://docs.directive.run/patterns/internal-events) – when `status` alone is enough
|
|
334
353
|
- [`MIGRATION_FEEDBACK.md` items 17 + 19](https://github.com/directive-run/directive/blob/main/docs/MIGRATION_FEEDBACK.md)
|
|
335
354
|
|
|
336
355
|
## License
|
package/dist/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts"],"names":["MAX_ERROR_LEN","truncateError","input","str","raw","safeStringCoerce","value","defineMutator","handlers","t","facts","payload","_req","ctx","factsRef","pending","handler","handlerCtx","err","mutate","kind","CancelError","message","TimeoutCancelError","afterMs","SupersededCancelError","cancelReason","cancellable","opts","supersedeOn","timeoutMs","scheduleTimeout","defaultSetTimeout","priorController","controller","cancelTimeout","cb","ms","handle","recordReplayable","dispatchSeq","seq","onAbort","reason","info"],"mappings":"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","file":"index.cjs","sourcesContent":["/**\n * @directive-run/mutator\n *\n * Discriminated mutation helper. Collapses the manual `pendingAction`\n * ceremony — fact + event + constraint + resolver — into a typed handler\n * map.\n *\n * Background: across the 55-cycle Minglingo migration, 12 modules ended\n * up with the same shape:\n * - a nullable `pendingAction` fact holding a discriminated union\n * - an event that sets it\n * - a constraint that fires on non-null\n * - a resolver that switches on the discriminator and clears the fact\n *\n * That's ~50 lines of boilerplate per module times 12 modules. The\n * `defineMutator` helper below contributes all four pieces from a single\n * typed declaration, so a module that uses it spreads the fragments\n * into its `createModule` config and writes only the per-variant handler\n * bodies.\n *\n * @see ../README.md for the full API and a worked example.\n */\n\nimport { t } from \"@directive-run/core\";\n\n/**\n * A keyed map of variant payloads. Each key becomes a discriminator value\n * for the mutation, each value is the payload type for that variant.\n *\n * @example\n * ```ts\n * type MyMutations = {\n * submit: { values: FormValues };\n * cancel: {};\n * retry: { reason: string };\n * };\n * ```\n */\nexport type MutationMap = Record<string, Record<string, unknown>>;\n\n/**\n * The shape of a `pendingMutation` fact while a mutation is queued or\n * running.\n */\nexport type PendingMutation<M extends MutationMap> = {\n [K in keyof M]: {\n kind: K;\n payload: M[K];\n /**\n * `pending` — queued, constraint hasn't fired yet.\n * `running` — handler is in flight.\n * `failed` — handler threw; constraint stops firing. Caller can\n * inspect `error` and dispatch a fresh MUTATE to retry.\n */\n status: \"pending\" | \"running\" | \"failed\";\n /**\n * Error message from the previous run, if any. Plaintext only —\n * NEVER render this directly via `dangerouslySetInnerHTML` or\n * markdown. Truncated to 500 chars to bound XSS surface from\n * thrown messages that may echo user input.\n */\n error: string | null;\n };\n}[keyof M];\n\n/** Maximum length of a captured error message — bounded to limit XSS surface. */\nconst MAX_ERROR_LEN = 500;\n\n/**\n * Coerce a thrown value to a bounded plaintext string for storage on\n * `pendingMutation.error`. Defends against:\n * - non-Error throws (`throw \"string\"`, `throw 42`, `throw {}`)\n * - Errors with non-string `.message` (overridden numeric / Symbol /\n * buffer); naive `.length` / `.slice` would `TypeError` otherwise\n * - extremely long messages with attacker-influenced input\n *\n * Returns at most {@link MAX_ERROR_LEN} characters of plaintext.\n * Plaintext rendering is still the only supported path on the\n * consumer side; this is defense in depth, not an XSS sanitizer.\n *\n * (R2 sec M-R2-1.)\n */\nfunction truncateError(input: unknown): string {\n let str: string;\n if (input instanceof Error) {\n // Reading `.message` is wrapped in try/catch because a maliciously\n // constructed Error subclass may install a throwing getter on\n // `message`. Without this guard the throw escapes truncateError →\n // escapes the resolver's catch → propagates uncaught. (R4 backlog.)\n let raw: unknown;\n try {\n raw = input.message;\n } catch {\n raw = \"[mutator: error.message getter threw]\";\n }\n str = typeof raw === \"string\" ? raw : safeStringCoerce(raw);\n } else if (typeof input === \"string\") {\n str = input;\n } else {\n str = safeStringCoerce(input);\n }\n if (str.length <= MAX_ERROR_LEN) return str;\n return `${str.slice(0, MAX_ERROR_LEN - 1)}…`;\n}\n\n/** String() may throw for some Symbols + objects with hostile toString. */\nfunction safeStringCoerce(value: unknown): string {\n try {\n return String(value);\n } catch {\n return \"[mutator: unstringifiable error]\";\n }\n}\n\n/**\n * Handler context passed to each variant handler.\n *\n * Note: `deps` is NOT in the context. This matches the Directive resolver\n * idiom — close over deps from the outer module-factory scope:\n *\n * ```ts\n * function createFormModule(deps: FormDeps) {\n * const mut = defineMutator<FormMutations, FormFacts>({\n * submit: async ({ payload, facts }) => {\n * facts.values = await deps.submit(payload.values); // ← closure\n * },\n * });\n * return createModule('form', { ... });\n * }\n * ```\n */\nexport interface MutatorHandlerContext<F> {\n /** Live facts proxy. Reads are cache-tracked; writes invalidate. */\n facts: F;\n /**\n * Trigger a same-constraint re-fire after this handler returns. Useful\n * when one mutation cascades into another — without `requeue`, the next\n * mutation would stall behind same-flush suppression.\n *\n * @see https://docs.directive.run/testing/chained-pipelines\n */\n requeue: () => void;\n}\n\n/**\n * Variant handler body. Receives the typed payload + a context; returns\n * void or a Promise. Throwing is fine — the runtime captures into\n * `pendingMutation.error` before clearing the fact.\n */\nexport type MutationHandler<\n M extends MutationMap,\n K extends keyof M,\n F,\n> = keyof M[K] extends never\n ? (ctx: MutatorHandlerContext<F>) => void | Promise<void>\n : (\n ctx: MutatorHandlerContext<F> & { payload: M[K] },\n ) => void | Promise<void>;\n\n/**\n * The full handler map. Every variant in `M` MUST have a handler.\n */\nexport type MutationHandlers<M extends MutationMap, F> = {\n [K in keyof M]: MutationHandler<M, K, F>;\n};\n\n/**\n * The fragments returned by `defineMutator`. Spread each fragment into\n * the matching position of your `createModule` config.\n *\n * @internal Shape documented for type clarity; users typically just\n * spread.\n */\nexport interface MutatorFragments<M extends MutationMap, F> {\n /** Spread into `schema.facts`. Adds `pendingMutation`. */\n facts: {\n pendingMutation: ReturnType<typeof t.object>;\n };\n /** Spread into `schema.events`. Adds the `MUTATE` event. */\n events: {\n MUTATE: PendingMutation<M>;\n };\n /** Spread into `schema.requirements`. Adds `PROCESS_MUTATION`. */\n requirements: {\n PROCESS_MUTATION: Record<string, never>;\n };\n /** Spread into the `events` field. Sets pendingMutation on MUTATE. */\n eventHandlers: {\n MUTATE: (facts: F, payload: PendingMutation<M>) => void;\n };\n /** Spread into the `constraints` field. */\n constraints: {\n pendingMutation: {\n when: (facts: F) => boolean;\n require: { type: \"PROCESS_MUTATION\" };\n };\n };\n /** Spread into the `resolvers` field. */\n resolvers: {\n mutationResolver: {\n requirement: \"PROCESS_MUTATION\";\n resolve: (\n req: { type: \"PROCESS_MUTATION\" },\n ctx: { facts: F },\n ) => Promise<void>;\n };\n };\n /**\n * Convenience: the initial value for `pendingMutation`. Set this in\n * your module's `init` if you don't use `t.X().default(...)` defaults.\n */\n initialPendingMutation: null;\n}\n\n/**\n * Define a mutator fragment-set for a given variant map and handlers.\n *\n * @example\n * ```ts\n * type FormMutations = {\n * submit: { values: FormValues };\n * cancel: {};\n * };\n *\n * // The second generic is the FACTS type (not deps). Deps are\n * // captured in closure from the surrounding scope, not passed in\n * // through the handler ctx.\n * const formMutator = defineMutator<FormMutations, FormFacts>({\n * submit: async ({ payload, facts }) => {\n * facts.values = await deps.submit(payload.values); // deps closes over outer scope\n * },\n * cancel: ({ facts }) => { facts.values = []; },\n * });\n *\n * createModule('form', {\n * schema: {\n * facts: { ...formMutator.facts, values: t.array<FormValues>() },\n * events: { ...formMutator.events, REFRESH: {} },\n * requirements: { ...formMutator.requirements },\n * },\n * init: (f) => { f.pendingMutation = null; f.values = []; },\n * events: {\n * ...formMutator.eventHandlers,\n * REFRESH: (f) => { f.values = []; },\n * },\n * constraints: { ...formMutator.constraints },\n * resolvers: { ...formMutator.resolvers },\n * });\n *\n * // Usage:\n * sys.events.MUTATE(mutate<FormMutations>('submit', { values: ... }));\n *\n * // Or, if constructing the payload manually, the discriminator is\n * // `kind` (NOT `type` — `type` collides with Directive's event-name\n * // dispatch convention):\n * sys.events.MUTATE({\n * kind: 'submit',\n * payload: { values: ... },\n * status: 'pending',\n * error: null,\n * });\n * ```\n *\n * The handler-bound deps are captured at `defineMutator` call time. To\n * inject deps from the caller, wrap `defineMutator` in your module\n * factory:\n *\n * ```ts\n * export function createFormModule(deps: FormDeps) {\n * const mut = defineMutator<FormMutations, FormFacts>({\n * submit: async ({ payload, facts }) => {\n * facts.values = await deps.submit(payload.values);\n * },\n * cancel: ({ facts }) => { facts.values = []; },\n * });\n * return createModule('form', { ... });\n * }\n * ```\n */\nexport function defineMutator<\n M extends MutationMap,\n F = Record<string, unknown>,\n>(handlers: MutationHandlers<M, F>): MutatorFragments<M, F> {\n type Pending = PendingMutation<M>;\n\n const facts = {\n pendingMutation: t.object<Pending>().nullable() as ReturnType<\n typeof t.object\n >,\n } as MutatorFragments<M, F>[\"facts\"];\n\n // Schema event marker — the runtime uses this for typing and devtools.\n // Payload validation happens at dispatch time via t-schema check.\n const events = {\n MUTATE: undefined as unknown as Pending,\n } as MutatorFragments<M, F>[\"events\"];\n\n const requirements = {\n PROCESS_MUTATION: {} as Record<string, never>,\n } as MutatorFragments<M, F>[\"requirements\"];\n\n const eventHandlers = {\n MUTATE: (facts: F, payload: Pending) => {\n // Overwrite is intentional — caller is responsible for ordering.\n // If a previous mutation is mid-flight, the new one queues by\n // overwriting; the in-flight handler will null the fact when it\n // completes, then the constraint re-fires for the new one.\n // (Same as the manual pattern across all 12 audited modules.)\n (facts as { pendingMutation: Pending | null }).pendingMutation = {\n ...payload,\n status: \"pending\",\n error: null,\n };\n },\n } as MutatorFragments<M, F>[\"eventHandlers\"];\n\n const constraints = {\n pendingMutation: {\n when: (facts: F) =>\n (facts as { pendingMutation: Pending | null }).pendingMutation !==\n null &&\n (facts as { pendingMutation: Pending | null }).pendingMutation\n ?.status === \"pending\",\n require: { type: \"PROCESS_MUTATION\" } as const,\n },\n } as MutatorFragments<M, F>[\"constraints\"];\n\n const resolvers = {\n mutationResolver: {\n requirement: \"PROCESS_MUTATION\" as const,\n resolve: async (\n _req: { type: \"PROCESS_MUTATION\" },\n ctx: {\n facts: F;\n requeue?: () => void;\n },\n ) => {\n const factsRef = ctx.facts as { pendingMutation: Pending | null };\n const pending = factsRef.pendingMutation;\n if (pending === null) return;\n\n // Stamp the in-flight transition with the running status. The\n // status itself is the supersession marker: only this resolver\n // ever writes 'running'; the eventHandler always writes\n // 'pending'. So if a fresh MUTATE arrives mid-flight, the\n // post-handler check sees status: 'pending' instead of\n // 'running' and knows to leave the new dispatch alone.\n // (Sec M5 / DX M2.)\n factsRef.pendingMutation = { ...pending, status: \"running\" };\n\n // Prototype-pollution defense: only accept handlers OWNED by\n // the user-provided map. Without this, dispatching with kind:\n // 'constructor' / 'toString' / '__proto__' would resolve via\n // the prototype chain and either crash or invoke an inherited\n // function with arbitrary payload. (Sec C1.)\n const ownsHandler = Object.prototype.hasOwnProperty.call(\n handlers,\n pending.kind as PropertyKey,\n );\n const handler = ownsHandler\n ? (handlers as Record<string, unknown>)[pending.kind as string]\n : undefined;\n\n if (typeof handler !== \"function\") {\n factsRef.pendingMutation = {\n ...pending,\n status: \"failed\",\n error: truncateError(\n `[mutator] no handler registered for variant: ${String(\n pending.kind,\n )}`,\n ),\n };\n return;\n }\n\n const handlerCtx = {\n facts: ctx.facts,\n payload: pending.payload,\n requeue: ctx.requeue ?? (() => {}),\n };\n\n try {\n await (handler as (c: typeof handlerCtx) => Promise<void> | void)(\n handlerCtx,\n );\n // Success: clear the fact ONLY if this resolver's `running`\n // marker is still live (status === 'running'). If a fresh\n // MUTATE arrived mid-flight via the eventHandler, the fact\n // now has status: 'pending' — leave it alone so the next\n // constraint fire picks it up. (Sec M5 / DX M2.)\n if (factsRef.pendingMutation?.status === \"running\") {\n factsRef.pendingMutation = null;\n }\n } catch (err) {\n // Failure: only stamp the error if our running marker is\n // still live. If a fresh MUTATE arrived mid-flight, the new\n // dispatch wins; the failed mutation's error is dropped on\n // the floor (caller wanted to move on anyway). (Sec M5.)\n if (factsRef.pendingMutation?.status !== \"running\") return;\n factsRef.pendingMutation = {\n ...pending,\n // 'failed' is a distinct status (Sec M6 / Arch C2) — UI can\n // disambiguate \"still in flight\" from \"stopped on error\".\n status: \"failed\",\n // truncateError handles the unknown shape safely — non-Error\n // throws, non-string Error.message, hostile toString. (R2\n // sec M-R2-1.)\n error: truncateError(err),\n };\n }\n },\n },\n } as MutatorFragments<M, F>[\"resolvers\"];\n\n return {\n facts,\n events,\n requirements,\n eventHandlers,\n constraints,\n resolvers,\n initialPendingMutation: null,\n };\n}\n\n/**\n * Helper for typed dispatch. Lets the caller construct a mutation payload\n * with full type narrowing — the `kind` field auto-restricts the\n * `payload` shape.\n *\n * @example\n * ```ts\n * sys.events.MUTATE(mutate<FormMutations>('submit', { values: ... }));\n * sys.events.MUTATE(mutate<FormMutations>('cancel'));\n * ```\n */\n// Single type parameter (M) so callers can specialize with\n// `mutate<FormMutations>(...)` without TypeScript demanding the\n// inferred K. TS's strict type-argument rules treat <M, K> as\n// \"supply both or supply neither\" — single-arg call sites failed\n// with \"Expected 2 type arguments, but got 1.\" Single-M form sidesteps\n// that while keeping payload typing tight via the `kind` lookup.\nexport function mutate<M extends MutationMap>(\n kind: keyof M & string,\n payload: M[typeof kind],\n): PendingMutation<M>;\nexport function mutate<M extends MutationMap>(\n kind: keyof M & string,\n): PendingMutation<M>;\nexport function mutate<M extends MutationMap>(\n kind: keyof M & string,\n payload?: M[typeof kind],\n): PendingMutation<M> {\n return {\n kind,\n payload: (payload ?? {}) as M[typeof kind],\n status: \"pending\",\n error: null,\n } as PendingMutation<M>;\n}\n\n// ============================================================================\n// cancellable() — auto-cancel-on-supersede (R1.C v0.1)\n// ============================================================================\n\n/**\n * Options for {@link cancellable}.\n */\nexport interface CancellableOptions {\n /**\n * When to fire the AbortSignal that the wrapped handler receives.\n *\n * - `'self'` (default): a new dispatch of the SAME handler aborts the\n * prior in-flight invocation. The classic \"cancel previous on new\n * keystroke\" pattern.\n * - `'never'`: only the timeout fires the signal; new dispatches do\n * NOT abort prior ones. Useful when you want a hard timeout but\n * parallel runs are fine.\n */\n supersedeOn?: \"self\" | \"never\";\n\n /**\n * Maximum ms a handler may run before its signal is aborted. Counted\n * from the start of THIS invocation. Combine with `realClock` for\n * production or pass a `setTimeout` shim for deterministic testing.\n */\n timeoutMs?: number;\n\n /**\n * Optional `setTimeout` injection. Defaults to `globalThis.setTimeout`.\n * For deterministic tests, pass `virtualClock.setTimeout` from\n * `@directive-run/core` so the timeout fires under\n * `clock.advanceBy()` instead of wall-clock real time.\n *\n * @example\n * ```ts\n * import { virtualClock } from '@directive-run/core';\n * const clock = virtualClock(0);\n * cancellable({ timeoutMs: 1_000, setTimeout: clock.setTimeout }, handler);\n * ```\n */\n setTimeout?: (cb: () => void, ms: number) => () => void;\n}\n\n/**\n * Handler context augmented with a cancellation signal.\n */\nexport interface CancellableHandlerContext<F, P> {\n facts: F;\n payload: P;\n /** Aborts when a new dispatch supersedes this one OR the timeout fires. */\n signal: AbortSignal;\n requeue: () => void;\n}\n\n/**\n * Reason a cancellable handler's signal aborted. Stamped on\n * `signal.reason` so the handler can disambiguate.\n *\n * Note: at runtime the value is a {@link CancelError} subclass with\n * the same shape — `signal.reason instanceof CancelError` is the\n * canonical check. Older usages that did `signal.reason?.kind ===\n * 'superseded'` continue to work because the Error subclass exposes\n * the same `kind` field. (R2 sec M-1.)\n */\nexport type CancelReason =\n | { kind: \"superseded\" }\n | { kind: \"timeout\"; afterMs: number };\n\n/**\n * Runtime carrier for {@link CancelReason}. Subclasses `Error` so the\n * value survives transit through `fetch(url, { signal })` and other\n * web-platform APIs that re-throw `signal.reason`. Older mutator\n * versions passed plain objects, which `.catch(err => err instanceof\n * Error)` filters silently dropped. (R2 sec M-1.)\n */\nexport class CancelError extends Error {\n constructor(public readonly kind: CancelReason[\"kind\"], message?: string) {\n super(message ?? `[mutator] cancellable: ${kind}`);\n this.name = \"CancelError\";\n }\n}\n\n/** Subclass of {@link CancelError} for timeout-driven aborts. */\nexport class TimeoutCancelError extends CancelError {\n constructor(public readonly afterMs: number) {\n super(\"timeout\", `[mutator] cancellable: timeout after ${afterMs}ms`);\n this.name = \"TimeoutCancelError\";\n }\n}\n\n/** Subclass of {@link CancelError} for supersede-driven aborts. */\nexport class SupersededCancelError extends CancelError {\n constructor() {\n super(\"superseded\", \"[mutator] cancellable: superseded by new dispatch\");\n this.name = \"SupersededCancelError\";\n }\n}\n\n/**\n * Factory for cancel-reason values. Pure — these are the runtime\n * counterparts of the {@link CancelReason} type. Use them in custom\n * cancellation flows that need a typed reason without re-typing the\n * literal.\n */\nexport const cancelReason = {\n superseded: (): SupersededCancelError => new SupersededCancelError(),\n timeout: (afterMs: number): TimeoutCancelError =>\n new TimeoutCancelError(afterMs),\n} as const;\n\n/**\n * Wrap a mutator handler with auto-cancellation. The wrapped handler\n * receives an extra `signal: AbortSignal` in its context. Use the\n * signal to short-circuit awaitable work — pass it to `fetch(url, {\n * signal })`, watch it inside long-running loops, etc.\n *\n * Two cancellation triggers, both opt-in via {@link CancellableOptions}:\n *\n * 1. **Supersession** (default `supersedeOn: 'self'`): when a new\n * dispatch of the same wrapped handler arrives while a prior\n * invocation is still running, the prior signal aborts.\n * 2. **Timeout** (default `timeoutMs: undefined`, meaning no timeout):\n * after `timeoutMs` ms from invocation start, the signal aborts.\n *\n * If a handler's signal aborts, the handler should observe the abort\n * (via `signal.aborted` or the AbortError-throwing helpers) and\n * return promptly. The signal's `reason` carries a {@link CancelReason}\n * disambiguating which trigger fired.\n *\n * Compose with {@link defineMutator} by using `cancellable()` directly\n * in the handler map:\n *\n * @example\n * ```ts\n * import { defineMutator, cancellable } from '@directive-run/mutator';\n *\n * const formMutator = defineMutator<MyMutations, MyFacts>({\n * search: cancellable(\n * { supersedeOn: 'self', timeoutMs: 3_000 },\n * async ({ payload, facts, signal }) => {\n * const res = await fetch(`/q?${payload.q}`, { signal });\n * facts.results = await res.json();\n * },\n * ),\n * submit: async ({ payload, facts }) => {\n * // No cancellation needed for submit — plain handler.\n * facts.values = await deps.submit(payload.values);\n * },\n * });\n * ```\n *\n * **Idempotency note.** The wrapped handler stays a regular\n * `MutationHandler<M, K, F>` from the mutator's perspective. The\n * supersession registry is closure-scoped per `cancellable()` call —\n * two separate `cancellable(...)` HOCs around different handlers do\n * NOT cancel each other.\n *\n * **Test ergonomics.** Pass `virtualClock.setTimeout` via the\n * `setTimeout` option to make timeouts deterministic under\n * `clock.advanceBy(ms)`. Without that, timeouts use wall-clock\n * `globalThis.setTimeout` and are real-time.\n */\nexport function cancellable<F, P>(\n opts: CancellableOptions,\n handler: (ctx: CancellableHandlerContext<F, P>) => Promise<void> | void,\n): (ctx: { facts: F; payload: P; requeue: () => void }) => Promise<void> {\n const supersedeOn = opts.supersedeOn ?? \"self\";\n const timeoutMs = opts.timeoutMs;\n const scheduleTimeout = opts.setTimeout ?? defaultSetTimeout;\n\n // Closure-scoped supersession slot — one entry for the wrapped\n // handler. When a new invocation arrives, the prior entry's\n // controller aborts before the new one starts.\n let priorController: AbortController | undefined;\n\n return async (ctx: { facts: F; payload: P; requeue: () => void }) => {\n // Supersession: abort the prior in-flight invocation, if any.\n // R2 sec M-1: pass an Error subclass so signal.reason survives\n // re-throw through fetch / .catch(err => err instanceof Error)\n // / logging frameworks. Plain object reasons silently fail those\n // checks downstream.\n if (supersedeOn === \"self\" && priorController !== undefined) {\n priorController.abort(cancelReason.superseded());\n }\n\n const controller = new AbortController();\n priorController = controller;\n\n // Timeout: schedule an abort after `timeoutMs`. The cancel handle\n // returned by `scheduleTimeout` lets us clear the timer if the\n // handler completes first (saves leaking timers under a barrage\n // of dispatches).\n let cancelTimeout: (() => void) | undefined;\n if (typeof timeoutMs === \"number\" && timeoutMs > 0) {\n cancelTimeout = scheduleTimeout(() => {\n controller.abort(cancelReason.timeout(timeoutMs));\n }, timeoutMs);\n }\n\n try {\n await handler({\n facts: ctx.facts,\n payload: ctx.payload,\n requeue: ctx.requeue,\n signal: controller.signal,\n });\n } finally {\n // Clean up: clear the timeout (if it hasn't fired) and release\n // the supersession slot if it still belongs to this invocation.\n // R2 sec M-3: wrap cancelTimeout in try/catch so a hostile\n // setTimeout-cancel-handle (e.g. a custom virtual clock that\n // throws on cancel) cannot shadow the original handler's\n // exception.\n try {\n cancelTimeout?.();\n } catch {\n /* swallow — the cancel-handle's failure is not the caller's problem */\n }\n if (priorController === controller) {\n priorController = undefined;\n }\n }\n };\n}\n\n/**\n * Default `setTimeout` shim — wraps `globalThis.setTimeout` to match\n * the cancel-handle signature `cancellable()` expects.\n */\nfunction defaultSetTimeout(cb: () => void, ms: number): () => void {\n const handle = globalThis.setTimeout(cb, ms);\n return () => globalThis.clearTimeout(handle);\n}\n\n// ────────────────────────────────────────────────────────────────────────────\n// recordReplayable — R2.B\n// ────────────────────────────────────────────────────────────────────────────\n\n/**\n * Structured event surfaced to {@link RecordReplayableOptions.onCancel}\n * the moment a wrapped invocation's signal aborts. Carries enough\n * information for the caller to pin the cancellation into the timeline\n * (e.g. by writing onto a facts array) so a later replay can\n * reconstruct the cancellation race exactly.\n *\n * @typeParam F — caller's facts type (passed through unchanged from the handler context).\n * @typeParam P — caller's payload type for THIS handler.\n */\nexport interface CancelEvent<F, P> {\n /** Whether this cancel was driven by a new dispatch superseding the old, or by the timeout firing. */\n kind: CancelReason[\"kind\"];\n /** When `kind === 'timeout'`, the original `timeoutMs` that fired. Undefined for `'superseded'`. */\n afterMs?: number;\n /** The payload of the dispatch that was cancelled — i.e. the work that did NOT complete. */\n payload: P;\n /**\n * Per-handler monotonic counter, incremented once at the start of\n * every invocation. Useful for timeline diff: cancel of seq=4 is the\n * 4th dispatch this handler ever saw. The counter is closure-scoped\n * to a single `recordReplayable()` call — separate HOCs do NOT share\n * the counter.\n */\n dispatchSeq: number;\n /** Live facts reference, mirroring {@link CancellableHandlerContext.facts}. */\n facts: F;\n}\n\n/**\n * Options for {@link recordReplayable}. Strict superset of\n * {@link CancellableOptions} — every field on `CancellableOptions`\n * passes through unchanged to the underlying {@link cancellable} call.\n */\nexport interface RecordReplayableOptions<F, P> extends CancellableOptions {\n /**\n * Synchronous callback invoked the moment a wrapped invocation's\n * AbortController fires `abort()`. Runs BEFORE the handler's pending\n * await rejects with AbortError, so the callback sees the freshest\n * possible state.\n *\n * Use this to pin the cancellation into a place that survives in the\n * timeline (typically a facts field — `facts.cancellations.push(info)`).\n * Without that, a replay re-dispatches the same MUTATE events but has\n * no record of which ones were superseded vs which completed.\n *\n * Throws inside `onCancel` are caught and swallowed — the abort path\n * must remain robust. If you need to fail loudly, log to your own\n * sink before re-throwing.\n */\n onCancel?: (info: CancelEvent<F, P>) => void;\n}\n\n/**\n * `cancellable()` plus a synchronous on-abort callback. Wrap your\n * handler with `recordReplayable()` instead of `cancellable()` when\n * you want a hook that fires the moment a supersession or timeout\n * aborts the in-flight invocation — *before* the handler's pending\n * await rejects with AbortError. The callback receives a structured\n * {@link CancelEvent} carrying the cancel kind, payload, dispatch\n * sequence, and a live facts reference.\n *\n * The callback is GENERIC (\"call me when abort fires\"); the most\n * common use case is pinning cancel events into facts so a recorded\n * timeline carries them — which then lets `replayTimeline()`,\n * `bisectTimeline()`, and `diffTimelines()` (all in\n * `@directive-run/timeline`) reason about which dispatches were\n * superseded vs which completed without parsing free-form error\n * strings. But the same callback works equally well for Sentry\n * breadcrumbs, a Redux action log, OpenTelemetry spans, or a metrics\n * sink — `recordReplayable` doesn't know or care about the timeline.\n *\n * @example Pin cancel events into facts so the timeline carries them:\n * ```ts\n * import { defineMutator, recordReplayable } from '@directive-run/mutator';\n *\n * const search = recordReplayable<MyFacts, { q: string }>(\n * {\n * supersedeOn: 'self',\n * timeoutMs: 3_000,\n * onCancel: ({ facts, kind, payload, dispatchSeq }) => {\n * facts.cancellations.push({ kind, queryAtCancel: payload.q, seq: dispatchSeq });\n * },\n * },\n * async ({ payload, facts, signal }) => {\n * const res = await fetch(`/q?${payload.q}`, { signal });\n * facts.results = await res.json();\n * },\n * );\n * ```\n *\n * Implementation note: `recordReplayable()` is `cancellable(opts,\n * innerHandler)` where `innerHandler` adds an `addEventListener('abort')`\n * around the user's handler. This means timeout / supersession\n * semantics are EXACTLY those of `cancellable()` — the HOC is purely\n * additive.\n */\nexport function recordReplayable<F, P>(\n opts: RecordReplayableOptions<F, P>,\n handler: (ctx: CancellableHandlerContext<F, P>) => Promise<void> | void,\n): (ctx: { facts: F; payload: P; requeue: () => void }) => Promise<void> {\n let dispatchSeq = 0;\n\n const wrappedHandler = async (\n ctx: CancellableHandlerContext<F, P>,\n ): Promise<void> => {\n const seq = ++dispatchSeq;\n const onAbort = (): void => {\n // Only fire onCancel for our typed CancelError reasons; if the\n // signal was aborted via some other path (caller's own\n // controller, etc), let it pass silently.\n const reason = ctx.signal.reason;\n if (!(reason instanceof CancelError)) return;\n const info: CancelEvent<F, P> = {\n kind: reason.kind,\n afterMs:\n reason instanceof TimeoutCancelError ? reason.afterMs : undefined,\n payload: ctx.payload,\n dispatchSeq: seq,\n facts: ctx.facts,\n };\n try {\n opts.onCancel?.(info);\n } catch {\n // Callback errors must NOT bubble up the abort path. The user's\n // handler is still about to receive AbortError — we don't want\n // to mask that with an onCancel-side throw, and we don't want\n // to crash the controller in the middle of cleanup.\n }\n };\n ctx.signal.addEventListener(\"abort\", onAbort, { once: true });\n try {\n await handler(ctx);\n } finally {\n ctx.signal.removeEventListener(\"abort\", onAbort);\n }\n };\n\n return cancellable<F, P>(opts, wrappedHandler);\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"names":["MAX_ERROR_LEN","truncateError","input","str","raw","safeStringCoerce","value","defineMutator","handlers","t","facts","payload","_req","ctx","factsRef","pending","handler","handlerCtx","err","mutate","kind","CancelError","message","TimeoutCancelError","afterMs","SupersededCancelError","cancelReason","cancellable","opts","supersedeOn","timeoutMs","scheduleTimeout","defaultSetTimeout","priorController","controller","cancelTimeout","cb","ms","handle","recordReplayable","dispatchSeq","seq","onAbort","reason","info"],"mappings":"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","file":"index.cjs","sourcesContent":["/**\n * @directive-run/mutator\n *\n * Discriminated mutation helper. Collapses the manual `pendingAction`\n * ceremony — fact + event + constraint + resolver — into a typed handler\n * map.\n *\n * Background: across the 55-cycle Minglingo migration, 12 modules ended\n * up with the same shape:\n * - a nullable `pendingAction` fact holding a discriminated union\n * - an event that sets it\n * - a constraint that fires on non-null\n * - a resolver that switches on the discriminator and clears the fact\n *\n * That's ~50 lines of boilerplate per module times 12 modules. The\n * `defineMutator` helper below contributes all four pieces from a single\n * typed declaration, so a module that uses it spreads the fragments\n * into its `createModule` config and writes only the per-variant handler\n * bodies.\n *\n * @see ../README.md for the full API and a worked example.\n */\n\nimport { t } from \"@directive-run/core\";\n\n/**\n * A keyed map of variant payloads. Each key becomes a discriminator value\n * for the mutation, each value is the payload type for that variant.\n *\n * @example\n * ```ts\n * type MyMutations = {\n * submit: { values: FormValues };\n * cancel: {};\n * retry: { reason: string };\n * };\n * ```\n */\nexport type MutationMap = Record<string, Record<string, unknown>>;\n\n/**\n * The shape of a `pendingMutation` fact while a mutation is queued or\n * running.\n */\nexport type PendingMutation<M extends MutationMap> = {\n [K in keyof M]: {\n kind: K;\n payload: M[K];\n /**\n * `pending` — queued, constraint hasn't fired yet.\n * `running` — handler is in flight.\n * `failed` — handler threw; constraint stops firing. Caller can\n * inspect `error` and dispatch a fresh MUTATE to retry.\n */\n status: \"pending\" | \"running\" | \"failed\";\n /**\n * Error message from the previous run, if any. Plaintext only —\n * NEVER render this directly via `dangerouslySetInnerHTML` or\n * markdown. Truncated to 500 chars to bound XSS surface from\n * thrown messages that may echo user input.\n */\n error: string | null;\n };\n}[keyof M];\n\n/** Maximum length of a captured error message — bounded to limit XSS surface. */\nconst MAX_ERROR_LEN = 500;\n\n/**\n * Coerce a thrown value to a bounded plaintext string for storage on\n * `pendingMutation.error`. Defends against:\n * - non-Error throws (`throw \"string\"`, `throw 42`, `throw {}`)\n * - Errors with non-string `.message` (overridden numeric / Symbol /\n * buffer); naive `.length` / `.slice` would `TypeError` otherwise\n * - extremely long messages with attacker-influenced input\n *\n * Returns at most {@link MAX_ERROR_LEN} characters of plaintext.\n * Plaintext rendering is still the only supported path on the\n * consumer side; this is defense in depth, not an XSS sanitizer.\n *\n * (R2 sec M-R2-1.)\n */\nfunction truncateError(input: unknown): string {\n let str: string;\n if (input instanceof Error) {\n // Reading `.message` is wrapped in try/catch because a maliciously\n // constructed Error subclass may install a throwing getter on\n // `message`. Without this guard the throw escapes truncateError →\n // escapes the resolver's catch → propagates uncaught. (R4 backlog.)\n let raw: unknown;\n try {\n raw = input.message;\n } catch {\n raw = \"[mutator: error.message getter threw]\";\n }\n str = typeof raw === \"string\" ? raw : safeStringCoerce(raw);\n } else if (typeof input === \"string\") {\n str = input;\n } else {\n str = safeStringCoerce(input);\n }\n if (str.length <= MAX_ERROR_LEN) return str;\n return `${str.slice(0, MAX_ERROR_LEN - 1)}…`;\n}\n\n/** String() may throw for some Symbols + objects with hostile toString. */\nfunction safeStringCoerce(value: unknown): string {\n try {\n return String(value);\n } catch {\n return \"[mutator: unstringifiable error]\";\n }\n}\n\n/**\n * Handler context passed to each variant handler.\n *\n * Note: `deps` is NOT in the context. This matches the Directive resolver\n * idiom — close over deps from the outer module-factory scope:\n *\n * ```ts\n * function createFormModule(deps: FormDeps) {\n * const mut = defineMutator<FormMutations, FormFacts>({\n * submit: async ({ payload, facts }) => {\n * facts.values = await deps.submit(payload.values); // ← closure\n * },\n * });\n * return createModule('form', { ... });\n * }\n * ```\n */\nexport interface MutatorHandlerContext<F> {\n /** Live facts proxy. Reads are cache-tracked; writes invalidate. */\n facts: F;\n /**\n * Trigger a same-constraint re-fire after this handler returns. Useful\n * when one mutation cascades into another — without `requeue`, the next\n * mutation would stall behind same-flush suppression.\n *\n * @see https://docs.directive.run/testing/chained-pipelines\n */\n requeue: () => void;\n}\n\n/**\n * Variant handler body. Receives the typed payload + a context; returns\n * void or a Promise. Throwing is fine — the runtime captures into\n * `pendingMutation.error` before clearing the fact.\n */\nexport type MutationHandler<\n M extends MutationMap,\n K extends keyof M,\n F,\n> = keyof M[K] extends never\n ? (ctx: MutatorHandlerContext<F>) => void | Promise<void>\n : (ctx: MutatorHandlerContext<F> & { payload: M[K] }) => void | Promise<void>;\n\n/**\n * The full handler map. Every variant in `M` MUST have a handler.\n */\nexport type MutationHandlers<M extends MutationMap, F> = {\n [K in keyof M]: MutationHandler<M, K, F>;\n};\n\n/**\n * The fragments returned by `defineMutator`. Spread each fragment into\n * the matching position of your `createModule` config.\n *\n * @internal Shape documented for type clarity; users typically just\n * spread.\n */\nexport interface MutatorFragments<M extends MutationMap, F> {\n /** Spread into `schema.facts`. Adds `pendingMutation`. */\n facts: {\n pendingMutation: ReturnType<typeof t.object>;\n };\n /** Spread into `schema.events`. Adds the `MUTATE` event. */\n events: {\n MUTATE: PendingMutation<M>;\n };\n /** Spread into `schema.requirements`. Adds `PROCESS_MUTATION`. */\n requirements: {\n PROCESS_MUTATION: Record<string, never>;\n };\n /** Spread into the `events` field. Sets pendingMutation on MUTATE. */\n eventHandlers: {\n MUTATE: (facts: F, payload: PendingMutation<M>) => void;\n };\n /** Spread into the `constraints` field. */\n constraints: {\n pendingMutation: {\n when: (facts: F) => boolean;\n require: { type: \"PROCESS_MUTATION\" };\n };\n };\n /** Spread into the `resolvers` field. */\n resolvers: {\n mutationResolver: {\n requirement: \"PROCESS_MUTATION\";\n resolve: (\n req: { type: \"PROCESS_MUTATION\" },\n ctx: { facts: F },\n ) => Promise<void>;\n };\n };\n /**\n * Convenience: the initial value for `pendingMutation`. Set this in\n * your module's `init` if you don't use `t.X().default(...)` defaults.\n */\n initialPendingMutation: null;\n}\n\n/**\n * Define a mutator fragment-set for a given variant map and handlers.\n *\n * @example\n * ```ts\n * type FormMutations = {\n * submit: { values: FormValues };\n * cancel: {};\n * };\n *\n * // The second generic is the FACTS type (not deps). Deps are\n * // captured in closure from the surrounding scope, not passed in\n * // through the handler ctx.\n * const formMutator = defineMutator<FormMutations, FormFacts>({\n * submit: async ({ payload, facts }) => {\n * facts.values = await deps.submit(payload.values); // deps closes over outer scope\n * },\n * cancel: ({ facts }) => { facts.values = []; },\n * });\n *\n * createModule('form', {\n * schema: {\n * facts: { ...formMutator.facts, values: t.array<FormValues>() },\n * events: { ...formMutator.events, REFRESH: {} },\n * requirements: { ...formMutator.requirements },\n * },\n * init: (f) => { f.pendingMutation = null; f.values = []; },\n * events: {\n * ...formMutator.eventHandlers,\n * REFRESH: (f) => { f.values = []; },\n * },\n * constraints: { ...formMutator.constraints },\n * resolvers: { ...formMutator.resolvers },\n * });\n *\n * // Usage:\n * sys.events.MUTATE(mutate<FormMutations>('submit', { values: ... }));\n *\n * // Or, if constructing the payload manually, the discriminator is\n * // `kind` (NOT `type` — `type` collides with Directive's event-name\n * // dispatch convention):\n * sys.events.MUTATE({\n * kind: 'submit',\n * payload: { values: ... },\n * status: 'pending',\n * error: null,\n * });\n * ```\n *\n * The handler-bound deps are captured at `defineMutator` call time. To\n * inject deps from the caller, wrap `defineMutator` in your module\n * factory:\n *\n * ```ts\n * export function createFormModule(deps: FormDeps) {\n * const mut = defineMutator<FormMutations, FormFacts>({\n * submit: async ({ payload, facts }) => {\n * facts.values = await deps.submit(payload.values);\n * },\n * cancel: ({ facts }) => { facts.values = []; },\n * });\n * return createModule('form', { ... });\n * }\n * ```\n */\nexport function defineMutator<\n M extends MutationMap,\n F = Record<string, unknown>,\n>(handlers: MutationHandlers<M, F>): MutatorFragments<M, F> {\n type Pending = PendingMutation<M>;\n\n const facts = {\n pendingMutation: t.object<Pending>().nullable() as ReturnType<\n typeof t.object\n >,\n } as MutatorFragments<M, F>[\"facts\"];\n\n // Schema event marker — the runtime uses this for typing and devtools.\n // Payload validation happens at dispatch time via t-schema check.\n const events = {\n MUTATE: undefined as unknown as Pending,\n } as MutatorFragments<M, F>[\"events\"];\n\n const requirements = {\n PROCESS_MUTATION: {} as Record<string, never>,\n } as MutatorFragments<M, F>[\"requirements\"];\n\n const eventHandlers = {\n MUTATE: (facts: F, payload: Pending) => {\n // Overwrite is intentional — caller is responsible for ordering.\n // If a previous mutation is mid-flight, the new one supersedes by\n // writing `status: \"pending\"` over the in-flight `status: \"running\"`\n // marker. The resolver's `if (status === \"running\")` checks at the\n // success and failure paths then leave the fresh dispatch untouched\n // so the next constraint fire picks it up — the in-flight result is\n // dropped on the floor (caller wanted to move on). See sec M5 / DX M2\n // in the AE review for the full reasoning.\n (facts as { pendingMutation: Pending | null }).pendingMutation = {\n ...payload,\n status: \"pending\",\n error: null,\n };\n },\n } as MutatorFragments<M, F>[\"eventHandlers\"];\n\n const constraints = {\n pendingMutation: {\n when: (facts: F) =>\n (facts as { pendingMutation: Pending | null }).pendingMutation !==\n null &&\n (facts as { pendingMutation: Pending | null }).pendingMutation\n ?.status === \"pending\",\n require: { type: \"PROCESS_MUTATION\" } as const,\n },\n } as MutatorFragments<M, F>[\"constraints\"];\n\n const resolvers = {\n mutationResolver: {\n requirement: \"PROCESS_MUTATION\" as const,\n resolve: async (\n _req: { type: \"PROCESS_MUTATION\" },\n ctx: {\n facts: F;\n requeue?: () => void;\n },\n ) => {\n const factsRef = ctx.facts as { pendingMutation: Pending | null };\n const pending = factsRef.pendingMutation;\n if (pending === null) return;\n\n // Stamp the in-flight transition with the running status. The\n // status itself is the supersession marker: only this resolver\n // ever writes 'running'; the eventHandler always writes\n // 'pending'. So if a fresh MUTATE arrives mid-flight, the\n // post-handler check sees status: 'pending' instead of\n // 'running' and knows to leave the new dispatch alone.\n // (Sec M5 / DX M2.)\n factsRef.pendingMutation = { ...pending, status: \"running\" };\n\n // Prototype-pollution defense: only accept handlers OWNED by\n // the user-provided map. Without this, dispatching with kind:\n // 'constructor' / 'toString' / '__proto__' would resolve via\n // the prototype chain and either crash or invoke an inherited\n // function with arbitrary payload. (Sec C1.)\n const ownsHandler = Object.prototype.hasOwnProperty.call(\n handlers,\n pending.kind as PropertyKey,\n );\n const handler = ownsHandler\n ? (handlers as Record<string, unknown>)[pending.kind as string]\n : undefined;\n\n if (typeof handler !== \"function\") {\n factsRef.pendingMutation = {\n ...pending,\n status: \"failed\",\n error: truncateError(\n `[mutator] no handler registered for variant: ${String(\n pending.kind,\n )}`,\n ),\n };\n return;\n }\n\n const handlerCtx = {\n facts: ctx.facts,\n payload: pending.payload,\n requeue: ctx.requeue ?? (() => {}),\n };\n\n try {\n await (handler as (c: typeof handlerCtx) => Promise<void> | void)(\n handlerCtx,\n );\n // Success: clear the fact ONLY if this resolver's `running`\n // marker is still live (status === 'running'). If a fresh\n // MUTATE arrived mid-flight via the eventHandler, the fact\n // now has status: 'pending' — leave it alone so the next\n // constraint fire picks it up. (Sec M5 / DX M2.)\n if (factsRef.pendingMutation?.status === \"running\") {\n factsRef.pendingMutation = null;\n }\n } catch (err) {\n // Failure: only stamp the error if our running marker is\n // still live. If a fresh MUTATE arrived mid-flight, the new\n // dispatch wins; the failed mutation's error is dropped on\n // the floor (caller wanted to move on anyway). (Sec M5.)\n if (factsRef.pendingMutation?.status !== \"running\") return;\n factsRef.pendingMutation = {\n ...pending,\n // 'failed' is a distinct status (Sec M6 / Arch C2) — UI can\n // disambiguate \"still in flight\" from \"stopped on error\".\n status: \"failed\",\n // truncateError handles the unknown shape safely — non-Error\n // throws, non-string Error.message, hostile toString. (R2\n // sec M-R2-1.)\n error: truncateError(err),\n };\n }\n },\n },\n } as MutatorFragments<M, F>[\"resolvers\"];\n\n return {\n facts,\n events,\n requirements,\n eventHandlers,\n constraints,\n resolvers,\n initialPendingMutation: null,\n };\n}\n\n/**\n * Helper for typed dispatch. Lets the caller construct a mutation payload\n * with full type narrowing — the `kind` field auto-restricts the\n * `payload` shape.\n *\n * @example\n * ```ts\n * sys.events.MUTATE(mutate<FormMutations>('submit', { values: ... }));\n * sys.events.MUTATE(mutate<FormMutations>('cancel'));\n * ```\n */\n// Single type parameter (M) so callers can specialize with\n// `mutate<FormMutations>(...)` without TypeScript demanding the\n// inferred K. TS's strict type-argument rules treat <M, K> as\n// \"supply both or supply neither\" — single-arg call sites failed\n// with \"Expected 2 type arguments, but got 1.\" Single-M form sidesteps\n// that while keeping payload typing tight via the `kind` lookup.\nexport function mutate<M extends MutationMap>(\n kind: keyof M & string,\n payload: M[typeof kind],\n): PendingMutation<M>;\nexport function mutate<M extends MutationMap>(\n kind: keyof M & string,\n): PendingMutation<M>;\nexport function mutate<M extends MutationMap>(\n kind: keyof M & string,\n payload?: M[typeof kind],\n): PendingMutation<M> {\n return {\n kind,\n payload: (payload ?? {}) as M[typeof kind],\n status: \"pending\",\n error: null,\n } as PendingMutation<M>;\n}\n\n// ============================================================================\n// cancellable() — auto-cancel-on-supersede (R1.C v0.1)\n// ============================================================================\n\n/**\n * Options for {@link cancellable}.\n */\nexport interface CancellableOptions {\n /**\n * When to fire the AbortSignal that the wrapped handler receives.\n *\n * - `'self'` (default): a new dispatch of the SAME handler aborts the\n * prior in-flight invocation. The classic \"cancel previous on new\n * keystroke\" pattern.\n * - `'never'`: only the timeout fires the signal; new dispatches do\n * NOT abort prior ones. Useful when you want a hard timeout but\n * parallel runs are fine.\n */\n supersedeOn?: \"self\" | \"never\";\n\n /**\n * Maximum ms a handler may run before its signal is aborted. Counted\n * from the start of THIS invocation. Combine with `realClock` for\n * production or pass a `setTimeout` shim for deterministic testing.\n */\n timeoutMs?: number;\n\n /**\n * Optional `setTimeout` injection. Defaults to `globalThis.setTimeout`.\n * For deterministic tests, pass `virtualClock.setTimeout` from\n * `@directive-run/core` so the timeout fires under\n * `clock.advanceBy()` instead of wall-clock real time.\n *\n * @example\n * ```ts\n * import { virtualClock } from '@directive-run/core';\n * const clock = virtualClock(0);\n * cancellable({ timeoutMs: 1_000, setTimeout: clock.setTimeout }, handler);\n * ```\n */\n setTimeout?: (cb: () => void, ms: number) => () => void;\n}\n\n/**\n * Handler context augmented with a cancellation signal.\n */\nexport interface CancellableHandlerContext<F, P> {\n facts: F;\n payload: P;\n /** Aborts when a new dispatch supersedes this one OR the timeout fires. */\n signal: AbortSignal;\n requeue: () => void;\n}\n\n/**\n * Reason a cancellable handler's signal aborted. Stamped on\n * `signal.reason` so the handler can disambiguate.\n *\n * Note: at runtime the value is a {@link CancelError} subclass with\n * the same shape — `signal.reason instanceof CancelError` is the\n * canonical check. Older usages that did `signal.reason?.kind ===\n * 'superseded'` continue to work because the Error subclass exposes\n * the same `kind` field. (R2 sec M-1.)\n */\nexport type CancelReason =\n | { kind: \"superseded\" }\n | { kind: \"timeout\"; afterMs: number };\n\n/**\n * Runtime carrier for {@link CancelReason}. Subclasses `Error` so the\n * value survives transit through `fetch(url, { signal })` and other\n * web-platform APIs that re-throw `signal.reason`. Older mutator\n * versions passed plain objects, which `.catch(err => err instanceof\n * Error)` filters silently dropped. (R2 sec M-1.)\n */\nexport class CancelError extends Error {\n constructor(\n public readonly kind: CancelReason[\"kind\"],\n message?: string,\n ) {\n super(message ?? `[mutator] cancellable: ${kind}`);\n this.name = \"CancelError\";\n }\n}\n\n/** Subclass of {@link CancelError} for timeout-driven aborts. */\nexport class TimeoutCancelError extends CancelError {\n constructor(public readonly afterMs: number) {\n super(\"timeout\", `[mutator] cancellable: timeout after ${afterMs}ms`);\n this.name = \"TimeoutCancelError\";\n }\n}\n\n/** Subclass of {@link CancelError} for supersede-driven aborts. */\nexport class SupersededCancelError extends CancelError {\n constructor() {\n super(\"superseded\", \"[mutator] cancellable: superseded by new dispatch\");\n this.name = \"SupersededCancelError\";\n }\n}\n\n/**\n * Factory for cancel-reason values. Pure — these are the runtime\n * counterparts of the {@link CancelReason} type. Use them in custom\n * cancellation flows that need a typed reason without re-typing the\n * literal.\n */\nexport const cancelReason = {\n superseded: (): SupersededCancelError => new SupersededCancelError(),\n timeout: (afterMs: number): TimeoutCancelError =>\n new TimeoutCancelError(afterMs),\n} as const;\n\n/**\n * Wrap a mutator handler with auto-cancellation. The wrapped handler\n * receives an extra `signal: AbortSignal` in its context. Use the\n * signal to short-circuit awaitable work — pass it to `fetch(url, {\n * signal })`, watch it inside long-running loops, etc.\n *\n * Two cancellation triggers, both opt-in via {@link CancellableOptions}:\n *\n * 1. **Supersession** (default `supersedeOn: 'self'`): when a new\n * dispatch of the same wrapped handler arrives while a prior\n * invocation is still running, the prior signal aborts.\n * 2. **Timeout** (default `timeoutMs: undefined`, meaning no timeout):\n * after `timeoutMs` ms from invocation start, the signal aborts.\n *\n * If a handler's signal aborts, the handler should observe the abort\n * (via `signal.aborted` or the AbortError-throwing helpers) and\n * return promptly. The signal's `reason` carries a {@link CancelReason}\n * disambiguating which trigger fired.\n *\n * Compose with {@link defineMutator} by using `cancellable()` directly\n * in the handler map:\n *\n * @example\n * ```ts\n * import { defineMutator, cancellable } from '@directive-run/mutator';\n *\n * const formMutator = defineMutator<MyMutations, MyFacts>({\n * search: cancellable(\n * { supersedeOn: 'self', timeoutMs: 3_000 },\n * async ({ payload, facts, signal }) => {\n * const res = await fetch(`/q?${payload.q}`, { signal });\n * facts.results = await res.json();\n * },\n * ),\n * submit: async ({ payload, facts }) => {\n * // No cancellation needed for submit — plain handler.\n * facts.values = await deps.submit(payload.values);\n * },\n * });\n * ```\n *\n * **Idempotency note.** The wrapped handler stays a regular\n * `MutationHandler<M, K, F>` from the mutator's perspective. The\n * supersession registry is closure-scoped per `cancellable()` call —\n * two separate `cancellable(...)` HOCs around different handlers do\n * NOT cancel each other.\n *\n * **Test ergonomics.** Pass `virtualClock.setTimeout` via the\n * `setTimeout` option to make timeouts deterministic under\n * `clock.advanceBy(ms)`. Without that, timeouts use wall-clock\n * `globalThis.setTimeout` and are real-time.\n */\nexport function cancellable<F, P>(\n opts: CancellableOptions,\n handler: (ctx: CancellableHandlerContext<F, P>) => Promise<void> | void,\n): (ctx: { facts: F; payload: P; requeue: () => void }) => Promise<void> {\n const supersedeOn = opts.supersedeOn ?? \"self\";\n const timeoutMs = opts.timeoutMs;\n const scheduleTimeout = opts.setTimeout ?? defaultSetTimeout;\n\n // Closure-scoped supersession slot — one entry for the wrapped\n // handler. When a new invocation arrives, the prior entry's\n // controller aborts before the new one starts.\n let priorController: AbortController | undefined;\n\n return async (ctx: { facts: F; payload: P; requeue: () => void }) => {\n // Supersession: abort the prior in-flight invocation, if any.\n // R2 sec M-1: pass an Error subclass so signal.reason survives\n // re-throw through fetch / .catch(err => err instanceof Error)\n // / logging frameworks. Plain object reasons silently fail those\n // checks downstream.\n if (supersedeOn === \"self\" && priorController !== undefined) {\n priorController.abort(cancelReason.superseded());\n }\n\n const controller = new AbortController();\n priorController = controller;\n\n // Timeout: schedule an abort after `timeoutMs`. The cancel handle\n // returned by `scheduleTimeout` lets us clear the timer if the\n // handler completes first (saves leaking timers under a barrage\n // of dispatches).\n let cancelTimeout: (() => void) | undefined;\n if (typeof timeoutMs === \"number\" && timeoutMs > 0) {\n cancelTimeout = scheduleTimeout(() => {\n controller.abort(cancelReason.timeout(timeoutMs));\n }, timeoutMs);\n }\n\n try {\n await handler({\n facts: ctx.facts,\n payload: ctx.payload,\n requeue: ctx.requeue,\n signal: controller.signal,\n });\n } finally {\n // Clean up: clear the timeout (if it hasn't fired) and release\n // the supersession slot if it still belongs to this invocation.\n // R2 sec M-3: wrap cancelTimeout in try/catch so a hostile\n // setTimeout-cancel-handle (e.g. a custom virtual clock that\n // throws on cancel) cannot shadow the original handler's\n // exception.\n try {\n cancelTimeout?.();\n } catch {\n /* swallow — the cancel-handle's failure is not the caller's problem */\n }\n if (priorController === controller) {\n priorController = undefined;\n }\n }\n };\n}\n\n/**\n * Default `setTimeout` shim — wraps `globalThis.setTimeout` to match\n * the cancel-handle signature `cancellable()` expects.\n */\nfunction defaultSetTimeout(cb: () => void, ms: number): () => void {\n const handle = globalThis.setTimeout(cb, ms);\n return () => globalThis.clearTimeout(handle);\n}\n\n// ────────────────────────────────────────────────────────────────────────────\n// recordReplayable — R2.B\n// ────────────────────────────────────────────────────────────────────────────\n\n/**\n * Structured event surfaced to {@link RecordReplayableOptions.onCancel}\n * the moment a wrapped invocation's signal aborts. Carries enough\n * information for the caller to pin the cancellation into the timeline\n * (e.g. by writing onto a facts array) so a later replay can\n * reconstruct the cancellation race exactly.\n *\n * @typeParam F — caller's facts type (passed through unchanged from the handler context).\n * @typeParam P — caller's payload type for THIS handler.\n */\nexport interface CancelEvent<F, P> {\n /** Whether this cancel was driven by a new dispatch superseding the old, or by the timeout firing. */\n kind: CancelReason[\"kind\"];\n /** When `kind === 'timeout'`, the original `timeoutMs` that fired. Undefined for `'superseded'`. */\n afterMs?: number;\n /** The payload of the dispatch that was cancelled — i.e. the work that did NOT complete. */\n payload: P;\n /**\n * Per-handler monotonic counter, incremented once at the start of\n * every invocation. Useful for timeline diff: cancel of seq=4 is the\n * 4th dispatch this handler ever saw. The counter is closure-scoped\n * to a single `recordReplayable()` call — separate HOCs do NOT share\n * the counter.\n */\n dispatchSeq: number;\n /** Live facts reference, mirroring {@link CancellableHandlerContext.facts}. */\n facts: F;\n}\n\n/**\n * Options for {@link recordReplayable}. Strict superset of\n * {@link CancellableOptions} — every field on `CancellableOptions`\n * passes through unchanged to the underlying {@link cancellable} call.\n */\nexport interface RecordReplayableOptions<F, P> extends CancellableOptions {\n /**\n * Synchronous callback invoked the moment a wrapped invocation's\n * AbortController fires `abort()`. Runs BEFORE the handler's pending\n * await rejects with AbortError, so the callback sees the freshest\n * possible state.\n *\n * Use this to pin the cancellation into a place that survives in the\n * timeline (typically a facts field — `facts.cancellations.push(info)`).\n * Without that, a replay re-dispatches the same MUTATE events but has\n * no record of which ones were superseded vs which completed.\n *\n * Throws inside `onCancel` are caught and swallowed — the abort path\n * must remain robust. If you need to fail loudly, log to your own\n * sink before re-throwing.\n */\n onCancel?: (info: CancelEvent<F, P>) => void;\n}\n\n/**\n * `cancellable()` plus a synchronous on-abort callback. Wrap your\n * handler with `recordReplayable()` instead of `cancellable()` when\n * you want a hook that fires the moment a supersession or timeout\n * aborts the in-flight invocation — *before* the handler's pending\n * await rejects with AbortError. The callback receives a structured\n * {@link CancelEvent} carrying the cancel kind, payload, dispatch\n * sequence, and a live facts reference.\n *\n * The callback is GENERIC (\"call me when abort fires\"); the most\n * common use case is pinning cancel events into facts so a recorded\n * timeline carries them — which then lets `replayTimeline()`,\n * `bisectTimeline()`, and `diffTimelines()` (all in\n * `@directive-run/timeline`) reason about which dispatches were\n * superseded vs which completed without parsing free-form error\n * strings. But the same callback works equally well for Sentry\n * breadcrumbs, a Redux action log, OpenTelemetry spans, or a metrics\n * sink — `recordReplayable` doesn't know or care about the timeline.\n *\n * @example Pin cancel events into facts so the timeline carries them:\n * ```ts\n * import { defineMutator, recordReplayable } from '@directive-run/mutator';\n *\n * const search = recordReplayable<MyFacts, { q: string }>(\n * {\n * supersedeOn: 'self',\n * timeoutMs: 3_000,\n * onCancel: ({ facts, kind, payload, dispatchSeq }) => {\n * facts.cancellations.push({ kind, queryAtCancel: payload.q, seq: dispatchSeq });\n * },\n * },\n * async ({ payload, facts, signal }) => {\n * const res = await fetch(`/q?${payload.q}`, { signal });\n * facts.results = await res.json();\n * },\n * );\n * ```\n *\n * Implementation note: `recordReplayable()` is `cancellable(opts,\n * innerHandler)` where `innerHandler` adds an `addEventListener('abort')`\n * around the user's handler. This means timeout / supersession\n * semantics are EXACTLY those of `cancellable()` — the HOC is purely\n * additive.\n */\nexport function recordReplayable<F, P>(\n opts: RecordReplayableOptions<F, P>,\n handler: (ctx: CancellableHandlerContext<F, P>) => Promise<void> | void,\n): (ctx: { facts: F; payload: P; requeue: () => void }) => Promise<void> {\n let dispatchSeq = 0;\n\n const wrappedHandler = async (\n ctx: CancellableHandlerContext<F, P>,\n ): Promise<void> => {\n const seq = ++dispatchSeq;\n const onAbort = (): void => {\n // Only fire onCancel for our typed CancelError reasons; if the\n // signal was aborted via some other path (caller's own\n // controller, etc), let it pass silently.\n const reason = ctx.signal.reason;\n if (!(reason instanceof CancelError)) return;\n const info: CancelEvent<F, P> = {\n kind: reason.kind,\n afterMs:\n reason instanceof TimeoutCancelError ? reason.afterMs : undefined,\n payload: ctx.payload,\n dispatchSeq: seq,\n facts: ctx.facts,\n };\n try {\n opts.onCancel?.(info);\n } catch {\n // Callback errors must NOT bubble up the abort path. The user's\n // handler is still about to receive AbortError — we don't want\n // to mask that with an onCancel-side throw, and we don't want\n // to crash the controller in the middle of cleanup.\n }\n };\n ctx.signal.addEventListener(\"abort\", onAbort, { once: true });\n try {\n await handler(ctx);\n } finally {\n ctx.signal.removeEventListener(\"abort\", onAbort);\n }\n };\n\n return cancellable<F, P>(opts, wrappedHandler);\n}\n"]}
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts"],"names":["MAX_ERROR_LEN","truncateError","input","str","raw","safeStringCoerce","value","defineMutator","handlers","t","facts","payload","_req","ctx","factsRef","pending","handler","handlerCtx","err","mutate","kind","CancelError","message","TimeoutCancelError","afterMs","SupersededCancelError","cancelReason","cancellable","opts","supersedeOn","timeoutMs","scheduleTimeout","defaultSetTimeout","priorController","controller","cancelTimeout","cb","ms","handle","recordReplayable","dispatchSeq","seq","onAbort","reason","info"],"mappings":"oCAkEA,IAAMA,CAAAA,CAAgB,GAAA,CAgBtB,SAASC,CAAAA,CAAcC,CAAAA,CAAwB,CAC7C,IAAIC,CAAAA,CACJ,GAAID,CAAAA,YAAiB,KAAA,CAAO,CAK1B,IAAIE,CAAAA,CACJ,GAAI,CACFA,CAAAA,CAAMF,EAAM,QACd,CAAA,KAAQ,CACNE,CAAAA,CAAM,wCACR,CACAD,CAAAA,CAAM,OAAOC,GAAQ,QAAA,CAAWA,CAAAA,CAAMC,CAAAA,CAAiBD,CAAG,EAC5D,CAAA,KAAW,OAAOF,CAAAA,EAAU,QAAA,CAC1BC,CAAAA,CAAMD,CAAAA,CAENC,CAAAA,CAAME,CAAAA,CAAiBH,CAAK,CAAA,CAE9B,OAAIC,EAAI,MAAA,EAAUH,CAAAA,CAAsBG,CAAAA,CACjC,CAAA,EAAGA,CAAAA,CAAI,KAAA,CAAM,CAAA,CAAGH,CAAAA,CAAgB,CAAC,CAAC,CAAA,MAAA,CAC3C,CAGA,SAASK,CAAAA,CAAiBC,CAAAA,CAAwB,CAChD,GAAI,CACF,OAAO,MAAA,CAAOA,CAAK,CACrB,CAAA,KAAQ,CACN,OAAO,kCACT,CACF,CAuKO,SAASC,CAAAA,CAGdC,CAAAA,CAA0D,CAqI1D,OAAO,CACL,MAnIY,CACZ,eAAA,CAAiBC,CAAAA,CAAE,MAAA,EAAgB,CAAE,QAAA,EAGvC,CAAA,CAgIE,OA5Ha,CACb,MAAA,CAAQ,MACV,CAAA,CA2HE,YAAA,CAzHmB,CACnB,gBAAA,CAAkB,EACpB,CAAA,CAwHE,aAAA,CAtHoB,CACpB,MAAA,CAAQ,CAACC,CAAAA,CAAUC,CAAAA,GAAqB,CAMrCD,CAAAA,CAA8C,eAAA,CAAkB,CAC/D,GAAGC,CAAAA,CACH,MAAA,CAAQ,SAAA,CACR,KAAA,CAAO,IACT,EACF,CACF,CAAA,CA0GE,WAAA,CAxGkB,CAClB,eAAA,CAAiB,CACf,IAAA,CAAOD,GACJA,CAAAA,CAA8C,eAAA,GAC7C,IAAA,EACDA,CAAAA,CAA8C,eAAA,EAC3C,MAAA,GAAW,SAAA,CACjB,OAAA,CAAS,CAAE,IAAA,CAAM,kBAAmB,CACtC,CACF,CAAA,CAgGE,SAAA,CA9FgB,CAChB,gBAAA,CAAkB,CAChB,WAAA,CAAa,kBAAA,CACb,OAAA,CAAS,MACPE,CAAAA,CACAC,CAAAA,GAIG,CACH,IAAMC,CAAAA,CAAWD,CAAAA,CAAI,KAAA,CACfE,CAAAA,CAAUD,CAAAA,CAAS,eAAA,CACzB,GAAIC,CAAAA,GAAY,KAAM,OAStBD,CAAAA,CAAS,eAAA,CAAkB,CAAE,GAAGC,CAAAA,CAAS,MAAA,CAAQ,SAAU,EAW3D,IAAMC,CAAAA,CAJc,MAAA,CAAO,SAAA,CAAU,cAAA,CAAe,IAAA,CAClDR,CAAAA,CACAO,CAAAA,CAAQ,IACV,CAAA,CAEKP,CAAAA,CAAqCO,CAAAA,CAAQ,IAAc,CAAA,CAC5D,MAAA,CAEJ,GAAI,OAAOC,CAAAA,EAAY,UAAA,CAAY,CACjCF,CAAAA,CAAS,eAAA,CAAkB,CACzB,GAAGC,CAAAA,CACH,OAAQ,QAAA,CACR,KAAA,CAAOd,CAAAA,CACL,CAAA,6CAAA,EAAgD,MAAA,CAC9Cc,CAAAA,CAAQ,IACV,CAAC,EACH,CACF,CAAA,CACA,MACF,CAEA,IAAME,CAAAA,CAAa,CACjB,KAAA,CAAOJ,CAAAA,CAAI,KAAA,CACX,OAAA,CAASE,CAAAA,CAAQ,OAAA,CACjB,OAAA,CAASF,CAAAA,CAAI,OAAA,GAAY,IAAM,CAAC,CAAA,CAClC,CAAA,CAEA,GAAI,CACF,MAAOG,CAAAA,CACLC,CACF,EAMIH,CAAAA,CAAS,eAAA,EAAiB,MAAA,GAAW,SAAA,GACvCA,CAAAA,CAAS,eAAA,CAAkB,IAAA,EAE/B,CAAA,MAASI,EAAK,CAKZ,GAAIJ,CAAAA,CAAS,eAAA,EAAiB,MAAA,GAAW,SAAA,CAAW,OACpDA,CAAAA,CAAS,gBAAkB,CACzB,GAAGC,CAAAA,CAGH,MAAA,CAAQ,QAAA,CAIR,KAAA,CAAOd,CAAAA,CAAciB,CAAG,CAC1B,EACF,CACF,CACF,CACF,CAAA,CASE,sBAAA,CAAwB,IAC1B,CACF,CA0BO,SAASC,CAAAA,CACdC,CAAAA,CACAT,CAAAA,CACoB,CACpB,OAAO,CACL,IAAA,CAAAS,EACA,OAAA,CAAUT,CAAAA,EAAW,EAAC,CACtB,MAAA,CAAQ,SAAA,CACR,KAAA,CAAO,IACT,CACF,CA6EO,IAAMU,CAAAA,CAAN,cAA0B,KAAM,CACrC,WAAA,CAA4BD,EAA4BE,CAAAA,CAAkB,CACxE,KAAA,CAAMA,CAAAA,EAAW,CAAA,uBAAA,EAA0BF,CAAI,CAAA,CAAE,CAAA,CADvB,UAAAA,CAAAA,CAE1B,IAAA,CAAK,IAAA,CAAO,cACd,CACF,CAAA,CAGaG,CAAAA,CAAN,cAAiCF,CAAY,CAClD,WAAA,CAA4BG,CAAAA,CAAiB,CAC3C,KAAA,CAAM,SAAA,CAAW,CAAA,qCAAA,EAAwCA,CAAO,CAAA,EAAA,CAAI,CAAA,CAD1C,IAAA,CAAA,OAAA,CAAAA,CAAAA,CAE1B,IAAA,CAAK,IAAA,CAAO,qBACd,CACF,EAGaC,CAAAA,CAAN,cAAoCJ,CAAY,CACrD,WAAA,EAAc,CACZ,KAAA,CAAM,YAAA,CAAc,mDAAmD,CAAA,CACvE,IAAA,CAAK,IAAA,CAAO,wBACd,CACF,CAAA,CAQaK,CAAAA,CAAe,CAC1B,WAAY,IAA6B,IAAID,CAAAA,CAC7C,OAAA,CAAUD,CAAAA,EACR,IAAID,CAAAA,CAAmBC,CAAO,CAClC,EAsDO,SAASG,CAAAA,CACdC,CAAAA,CACAZ,CAAAA,CACuE,CACvE,IAAMa,EAAcD,CAAAA,CAAK,WAAA,EAAe,MAAA,CAClCE,CAAAA,CAAYF,CAAAA,CAAK,SAAA,CACjBG,CAAAA,CAAkBH,CAAAA,CAAK,YAAcI,CAAAA,CAKvCC,CAAAA,CAEJ,OAAO,MAAOpB,CAAAA,EAAuD,CAM/DgB,CAAAA,GAAgB,MAAA,EAAUI,IAAoB,MAAA,EAChDA,CAAAA,CAAgB,KAAA,CAAMP,CAAAA,CAAa,UAAA,EAAY,CAAA,CAGjD,IAAMQ,CAAAA,CAAa,IAAI,eAAA,CACvBD,CAAAA,CAAkBC,CAAAA,CAMlB,IAAIC,CAAAA,CACA,OAAOL,GAAc,QAAA,EAAYA,CAAAA,CAAY,CAAA,GAC/CK,CAAAA,CAAgBJ,CAAAA,CAAgB,IAAM,CACpCG,CAAAA,CAAW,MAAMR,CAAAA,CAAa,OAAA,CAAQI,CAAS,CAAC,EAClD,CAAA,CAAGA,CAAS,CAAA,CAAA,CAGd,GAAI,CACF,MAAMd,CAAAA,CAAQ,CACZ,KAAA,CAAOH,CAAAA,CAAI,KAAA,CACX,OAAA,CAASA,CAAAA,CAAI,OAAA,CACb,OAAA,CAASA,CAAAA,CAAI,OAAA,CACb,MAAA,CAAQqB,CAAAA,CAAW,MACrB,CAAC,EACH,CAAA,OAAE,CAOA,GAAI,CACFC,CAAAA,KACF,CAAA,KAAQ,CAER,CACIF,CAAAA,GAAoBC,CAAAA,GACtBD,CAAAA,CAAkB,MAAA,EAEtB,CACF,CACF,CAMA,SAASD,CAAAA,CAAkBI,CAAAA,CAAgBC,CAAAA,CAAwB,CACjE,IAAMC,CAAAA,CAAS,UAAA,CAAW,UAAA,CAAWF,CAAAA,CAAIC,CAAE,CAAA,CAC3C,OAAO,IAAM,UAAA,CAAW,YAAA,CAAaC,CAAM,CAC7C,CAuGO,SAASC,CAAAA,CACdX,CAAAA,CACAZ,CAAAA,CACuE,CACvE,IAAIwB,CAAAA,CAAc,EAqClB,OAAOb,CAAAA,CAAkBC,CAAAA,CAnCF,MACrBf,CAAAA,EACkB,CAClB,IAAM4B,CAAAA,CAAM,EAAED,CAAAA,CACRE,CAAAA,CAAU,IAAY,CAI1B,IAAMC,CAAAA,CAAS9B,CAAAA,CAAI,MAAA,CAAO,MAAA,CAC1B,GAAI,EAAE8B,CAAAA,YAAkBtB,CAAAA,CAAAA,CAAc,OACtC,IAAMuB,CAAAA,CAA0B,CAC9B,IAAA,CAAMD,CAAAA,CAAO,IAAA,CACb,OAAA,CACEA,CAAAA,YAAkBpB,CAAAA,CAAqBoB,CAAAA,CAAO,OAAA,CAAU,OAC1D,OAAA,CAAS9B,CAAAA,CAAI,OAAA,CACb,WAAA,CAAa4B,CAAAA,CACb,KAAA,CAAO5B,CAAAA,CAAI,KACb,EACA,GAAI,CACFe,CAAAA,CAAK,QAAA,GAAWgB,CAAI,EACtB,CAAA,KAAQ,CAKR,CACF,CAAA,CACA/B,CAAAA,CAAI,MAAA,CAAO,gBAAA,CAAiB,OAAA,CAAS6B,CAAAA,CAAS,CAAE,IAAA,CAAM,EAAK,CAAC,CAAA,CAC5D,GAAI,CACF,MAAM1B,CAAAA,CAAQH,CAAG,EACnB,QAAE,CACAA,CAAAA,CAAI,MAAA,CAAO,mBAAA,CAAoB,OAAA,CAAS6B,CAAO,EACjD,CACF,CAE6C,CAC/C","file":"index.js","sourcesContent":["/**\n * @directive-run/mutator\n *\n * Discriminated mutation helper. Collapses the manual `pendingAction`\n * ceremony — fact + event + constraint + resolver — into a typed handler\n * map.\n *\n * Background: across the 55-cycle Minglingo migration, 12 modules ended\n * up with the same shape:\n * - a nullable `pendingAction` fact holding a discriminated union\n * - an event that sets it\n * - a constraint that fires on non-null\n * - a resolver that switches on the discriminator and clears the fact\n *\n * That's ~50 lines of boilerplate per module times 12 modules. The\n * `defineMutator` helper below contributes all four pieces from a single\n * typed declaration, so a module that uses it spreads the fragments\n * into its `createModule` config and writes only the per-variant handler\n * bodies.\n *\n * @see ../README.md for the full API and a worked example.\n */\n\nimport { t } from \"@directive-run/core\";\n\n/**\n * A keyed map of variant payloads. Each key becomes a discriminator value\n * for the mutation, each value is the payload type for that variant.\n *\n * @example\n * ```ts\n * type MyMutations = {\n * submit: { values: FormValues };\n * cancel: {};\n * retry: { reason: string };\n * };\n * ```\n */\nexport type MutationMap = Record<string, Record<string, unknown>>;\n\n/**\n * The shape of a `pendingMutation` fact while a mutation is queued or\n * running.\n */\nexport type PendingMutation<M extends MutationMap> = {\n [K in keyof M]: {\n kind: K;\n payload: M[K];\n /**\n * `pending` — queued, constraint hasn't fired yet.\n * `running` — handler is in flight.\n * `failed` — handler threw; constraint stops firing. Caller can\n * inspect `error` and dispatch a fresh MUTATE to retry.\n */\n status: \"pending\" | \"running\" | \"failed\";\n /**\n * Error message from the previous run, if any. Plaintext only —\n * NEVER render this directly via `dangerouslySetInnerHTML` or\n * markdown. Truncated to 500 chars to bound XSS surface from\n * thrown messages that may echo user input.\n */\n error: string | null;\n };\n}[keyof M];\n\n/** Maximum length of a captured error message — bounded to limit XSS surface. */\nconst MAX_ERROR_LEN = 500;\n\n/**\n * Coerce a thrown value to a bounded plaintext string for storage on\n * `pendingMutation.error`. Defends against:\n * - non-Error throws (`throw \"string\"`, `throw 42`, `throw {}`)\n * - Errors with non-string `.message` (overridden numeric / Symbol /\n * buffer); naive `.length` / `.slice` would `TypeError` otherwise\n * - extremely long messages with attacker-influenced input\n *\n * Returns at most {@link MAX_ERROR_LEN} characters of plaintext.\n * Plaintext rendering is still the only supported path on the\n * consumer side; this is defense in depth, not an XSS sanitizer.\n *\n * (R2 sec M-R2-1.)\n */\nfunction truncateError(input: unknown): string {\n let str: string;\n if (input instanceof Error) {\n // Reading `.message` is wrapped in try/catch because a maliciously\n // constructed Error subclass may install a throwing getter on\n // `message`. Without this guard the throw escapes truncateError →\n // escapes the resolver's catch → propagates uncaught. (R4 backlog.)\n let raw: unknown;\n try {\n raw = input.message;\n } catch {\n raw = \"[mutator: error.message getter threw]\";\n }\n str = typeof raw === \"string\" ? raw : safeStringCoerce(raw);\n } else if (typeof input === \"string\") {\n str = input;\n } else {\n str = safeStringCoerce(input);\n }\n if (str.length <= MAX_ERROR_LEN) return str;\n return `${str.slice(0, MAX_ERROR_LEN - 1)}…`;\n}\n\n/** String() may throw for some Symbols + objects with hostile toString. */\nfunction safeStringCoerce(value: unknown): string {\n try {\n return String(value);\n } catch {\n return \"[mutator: unstringifiable error]\";\n }\n}\n\n/**\n * Handler context passed to each variant handler.\n *\n * Note: `deps` is NOT in the context. This matches the Directive resolver\n * idiom — close over deps from the outer module-factory scope:\n *\n * ```ts\n * function createFormModule(deps: FormDeps) {\n * const mut = defineMutator<FormMutations, FormFacts>({\n * submit: async ({ payload, facts }) => {\n * facts.values = await deps.submit(payload.values); // ← closure\n * },\n * });\n * return createModule('form', { ... });\n * }\n * ```\n */\nexport interface MutatorHandlerContext<F> {\n /** Live facts proxy. Reads are cache-tracked; writes invalidate. */\n facts: F;\n /**\n * Trigger a same-constraint re-fire after this handler returns. Useful\n * when one mutation cascades into another — without `requeue`, the next\n * mutation would stall behind same-flush suppression.\n *\n * @see https://docs.directive.run/testing/chained-pipelines\n */\n requeue: () => void;\n}\n\n/**\n * Variant handler body. Receives the typed payload + a context; returns\n * void or a Promise. Throwing is fine — the runtime captures into\n * `pendingMutation.error` before clearing the fact.\n */\nexport type MutationHandler<\n M extends MutationMap,\n K extends keyof M,\n F,\n> = keyof M[K] extends never\n ? (ctx: MutatorHandlerContext<F>) => void | Promise<void>\n : (\n ctx: MutatorHandlerContext<F> & { payload: M[K] },\n ) => void | Promise<void>;\n\n/**\n * The full handler map. Every variant in `M` MUST have a handler.\n */\nexport type MutationHandlers<M extends MutationMap, F> = {\n [K in keyof M]: MutationHandler<M, K, F>;\n};\n\n/**\n * The fragments returned by `defineMutator`. Spread each fragment into\n * the matching position of your `createModule` config.\n *\n * @internal Shape documented for type clarity; users typically just\n * spread.\n */\nexport interface MutatorFragments<M extends MutationMap, F> {\n /** Spread into `schema.facts`. Adds `pendingMutation`. */\n facts: {\n pendingMutation: ReturnType<typeof t.object>;\n };\n /** Spread into `schema.events`. Adds the `MUTATE` event. */\n events: {\n MUTATE: PendingMutation<M>;\n };\n /** Spread into `schema.requirements`. Adds `PROCESS_MUTATION`. */\n requirements: {\n PROCESS_MUTATION: Record<string, never>;\n };\n /** Spread into the `events` field. Sets pendingMutation on MUTATE. */\n eventHandlers: {\n MUTATE: (facts: F, payload: PendingMutation<M>) => void;\n };\n /** Spread into the `constraints` field. */\n constraints: {\n pendingMutation: {\n when: (facts: F) => boolean;\n require: { type: \"PROCESS_MUTATION\" };\n };\n };\n /** Spread into the `resolvers` field. */\n resolvers: {\n mutationResolver: {\n requirement: \"PROCESS_MUTATION\";\n resolve: (\n req: { type: \"PROCESS_MUTATION\" },\n ctx: { facts: F },\n ) => Promise<void>;\n };\n };\n /**\n * Convenience: the initial value for `pendingMutation`. Set this in\n * your module's `init` if you don't use `t.X().default(...)` defaults.\n */\n initialPendingMutation: null;\n}\n\n/**\n * Define a mutator fragment-set for a given variant map and handlers.\n *\n * @example\n * ```ts\n * type FormMutations = {\n * submit: { values: FormValues };\n * cancel: {};\n * };\n *\n * // The second generic is the FACTS type (not deps). Deps are\n * // captured in closure from the surrounding scope, not passed in\n * // through the handler ctx.\n * const formMutator = defineMutator<FormMutations, FormFacts>({\n * submit: async ({ payload, facts }) => {\n * facts.values = await deps.submit(payload.values); // deps closes over outer scope\n * },\n * cancel: ({ facts }) => { facts.values = []; },\n * });\n *\n * createModule('form', {\n * schema: {\n * facts: { ...formMutator.facts, values: t.array<FormValues>() },\n * events: { ...formMutator.events, REFRESH: {} },\n * requirements: { ...formMutator.requirements },\n * },\n * init: (f) => { f.pendingMutation = null; f.values = []; },\n * events: {\n * ...formMutator.eventHandlers,\n * REFRESH: (f) => { f.values = []; },\n * },\n * constraints: { ...formMutator.constraints },\n * resolvers: { ...formMutator.resolvers },\n * });\n *\n * // Usage:\n * sys.events.MUTATE(mutate<FormMutations>('submit', { values: ... }));\n *\n * // Or, if constructing the payload manually, the discriminator is\n * // `kind` (NOT `type` — `type` collides with Directive's event-name\n * // dispatch convention):\n * sys.events.MUTATE({\n * kind: 'submit',\n * payload: { values: ... },\n * status: 'pending',\n * error: null,\n * });\n * ```\n *\n * The handler-bound deps are captured at `defineMutator` call time. To\n * inject deps from the caller, wrap `defineMutator` in your module\n * factory:\n *\n * ```ts\n * export function createFormModule(deps: FormDeps) {\n * const mut = defineMutator<FormMutations, FormFacts>({\n * submit: async ({ payload, facts }) => {\n * facts.values = await deps.submit(payload.values);\n * },\n * cancel: ({ facts }) => { facts.values = []; },\n * });\n * return createModule('form', { ... });\n * }\n * ```\n */\nexport function defineMutator<\n M extends MutationMap,\n F = Record<string, unknown>,\n>(handlers: MutationHandlers<M, F>): MutatorFragments<M, F> {\n type Pending = PendingMutation<M>;\n\n const facts = {\n pendingMutation: t.object<Pending>().nullable() as ReturnType<\n typeof t.object\n >,\n } as MutatorFragments<M, F>[\"facts\"];\n\n // Schema event marker — the runtime uses this for typing and devtools.\n // Payload validation happens at dispatch time via t-schema check.\n const events = {\n MUTATE: undefined as unknown as Pending,\n } as MutatorFragments<M, F>[\"events\"];\n\n const requirements = {\n PROCESS_MUTATION: {} as Record<string, never>,\n } as MutatorFragments<M, F>[\"requirements\"];\n\n const eventHandlers = {\n MUTATE: (facts: F, payload: Pending) => {\n // Overwrite is intentional — caller is responsible for ordering.\n // If a previous mutation is mid-flight, the new one queues by\n // overwriting; the in-flight handler will null the fact when it\n // completes, then the constraint re-fires for the new one.\n // (Same as the manual pattern across all 12 audited modules.)\n (facts as { pendingMutation: Pending | null }).pendingMutation = {\n ...payload,\n status: \"pending\",\n error: null,\n };\n },\n } as MutatorFragments<M, F>[\"eventHandlers\"];\n\n const constraints = {\n pendingMutation: {\n when: (facts: F) =>\n (facts as { pendingMutation: Pending | null }).pendingMutation !==\n null &&\n (facts as { pendingMutation: Pending | null }).pendingMutation\n ?.status === \"pending\",\n require: { type: \"PROCESS_MUTATION\" } as const,\n },\n } as MutatorFragments<M, F>[\"constraints\"];\n\n const resolvers = {\n mutationResolver: {\n requirement: \"PROCESS_MUTATION\" as const,\n resolve: async (\n _req: { type: \"PROCESS_MUTATION\" },\n ctx: {\n facts: F;\n requeue?: () => void;\n },\n ) => {\n const factsRef = ctx.facts as { pendingMutation: Pending | null };\n const pending = factsRef.pendingMutation;\n if (pending === null) return;\n\n // Stamp the in-flight transition with the running status. The\n // status itself is the supersession marker: only this resolver\n // ever writes 'running'; the eventHandler always writes\n // 'pending'. So if a fresh MUTATE arrives mid-flight, the\n // post-handler check sees status: 'pending' instead of\n // 'running' and knows to leave the new dispatch alone.\n // (Sec M5 / DX M2.)\n factsRef.pendingMutation = { ...pending, status: \"running\" };\n\n // Prototype-pollution defense: only accept handlers OWNED by\n // the user-provided map. Without this, dispatching with kind:\n // 'constructor' / 'toString' / '__proto__' would resolve via\n // the prototype chain and either crash or invoke an inherited\n // function with arbitrary payload. (Sec C1.)\n const ownsHandler = Object.prototype.hasOwnProperty.call(\n handlers,\n pending.kind as PropertyKey,\n );\n const handler = ownsHandler\n ? (handlers as Record<string, unknown>)[pending.kind as string]\n : undefined;\n\n if (typeof handler !== \"function\") {\n factsRef.pendingMutation = {\n ...pending,\n status: \"failed\",\n error: truncateError(\n `[mutator] no handler registered for variant: ${String(\n pending.kind,\n )}`,\n ),\n };\n return;\n }\n\n const handlerCtx = {\n facts: ctx.facts,\n payload: pending.payload,\n requeue: ctx.requeue ?? (() => {}),\n };\n\n try {\n await (handler as (c: typeof handlerCtx) => Promise<void> | void)(\n handlerCtx,\n );\n // Success: clear the fact ONLY if this resolver's `running`\n // marker is still live (status === 'running'). If a fresh\n // MUTATE arrived mid-flight via the eventHandler, the fact\n // now has status: 'pending' — leave it alone so the next\n // constraint fire picks it up. (Sec M5 / DX M2.)\n if (factsRef.pendingMutation?.status === \"running\") {\n factsRef.pendingMutation = null;\n }\n } catch (err) {\n // Failure: only stamp the error if our running marker is\n // still live. If a fresh MUTATE arrived mid-flight, the new\n // dispatch wins; the failed mutation's error is dropped on\n // the floor (caller wanted to move on anyway). (Sec M5.)\n if (factsRef.pendingMutation?.status !== \"running\") return;\n factsRef.pendingMutation = {\n ...pending,\n // 'failed' is a distinct status (Sec M6 / Arch C2) — UI can\n // disambiguate \"still in flight\" from \"stopped on error\".\n status: \"failed\",\n // truncateError handles the unknown shape safely — non-Error\n // throws, non-string Error.message, hostile toString. (R2\n // sec M-R2-1.)\n error: truncateError(err),\n };\n }\n },\n },\n } as MutatorFragments<M, F>[\"resolvers\"];\n\n return {\n facts,\n events,\n requirements,\n eventHandlers,\n constraints,\n resolvers,\n initialPendingMutation: null,\n };\n}\n\n/**\n * Helper for typed dispatch. Lets the caller construct a mutation payload\n * with full type narrowing — the `kind` field auto-restricts the\n * `payload` shape.\n *\n * @example\n * ```ts\n * sys.events.MUTATE(mutate<FormMutations>('submit', { values: ... }));\n * sys.events.MUTATE(mutate<FormMutations>('cancel'));\n * ```\n */\n// Single type parameter (M) so callers can specialize with\n// `mutate<FormMutations>(...)` without TypeScript demanding the\n// inferred K. TS's strict type-argument rules treat <M, K> as\n// \"supply both or supply neither\" — single-arg call sites failed\n// with \"Expected 2 type arguments, but got 1.\" Single-M form sidesteps\n// that while keeping payload typing tight via the `kind` lookup.\nexport function mutate<M extends MutationMap>(\n kind: keyof M & string,\n payload: M[typeof kind],\n): PendingMutation<M>;\nexport function mutate<M extends MutationMap>(\n kind: keyof M & string,\n): PendingMutation<M>;\nexport function mutate<M extends MutationMap>(\n kind: keyof M & string,\n payload?: M[typeof kind],\n): PendingMutation<M> {\n return {\n kind,\n payload: (payload ?? {}) as M[typeof kind],\n status: \"pending\",\n error: null,\n } as PendingMutation<M>;\n}\n\n// ============================================================================\n// cancellable() — auto-cancel-on-supersede (R1.C v0.1)\n// ============================================================================\n\n/**\n * Options for {@link cancellable}.\n */\nexport interface CancellableOptions {\n /**\n * When to fire the AbortSignal that the wrapped handler receives.\n *\n * - `'self'` (default): a new dispatch of the SAME handler aborts the\n * prior in-flight invocation. The classic \"cancel previous on new\n * keystroke\" pattern.\n * - `'never'`: only the timeout fires the signal; new dispatches do\n * NOT abort prior ones. Useful when you want a hard timeout but\n * parallel runs are fine.\n */\n supersedeOn?: \"self\" | \"never\";\n\n /**\n * Maximum ms a handler may run before its signal is aborted. Counted\n * from the start of THIS invocation. Combine with `realClock` for\n * production or pass a `setTimeout` shim for deterministic testing.\n */\n timeoutMs?: number;\n\n /**\n * Optional `setTimeout` injection. Defaults to `globalThis.setTimeout`.\n * For deterministic tests, pass `virtualClock.setTimeout` from\n * `@directive-run/core` so the timeout fires under\n * `clock.advanceBy()` instead of wall-clock real time.\n *\n * @example\n * ```ts\n * import { virtualClock } from '@directive-run/core';\n * const clock = virtualClock(0);\n * cancellable({ timeoutMs: 1_000, setTimeout: clock.setTimeout }, handler);\n * ```\n */\n setTimeout?: (cb: () => void, ms: number) => () => void;\n}\n\n/**\n * Handler context augmented with a cancellation signal.\n */\nexport interface CancellableHandlerContext<F, P> {\n facts: F;\n payload: P;\n /** Aborts when a new dispatch supersedes this one OR the timeout fires. */\n signal: AbortSignal;\n requeue: () => void;\n}\n\n/**\n * Reason a cancellable handler's signal aborted. Stamped on\n * `signal.reason` so the handler can disambiguate.\n *\n * Note: at runtime the value is a {@link CancelError} subclass with\n * the same shape — `signal.reason instanceof CancelError` is the\n * canonical check. Older usages that did `signal.reason?.kind ===\n * 'superseded'` continue to work because the Error subclass exposes\n * the same `kind` field. (R2 sec M-1.)\n */\nexport type CancelReason =\n | { kind: \"superseded\" }\n | { kind: \"timeout\"; afterMs: number };\n\n/**\n * Runtime carrier for {@link CancelReason}. Subclasses `Error` so the\n * value survives transit through `fetch(url, { signal })` and other\n * web-platform APIs that re-throw `signal.reason`. Older mutator\n * versions passed plain objects, which `.catch(err => err instanceof\n * Error)` filters silently dropped. (R2 sec M-1.)\n */\nexport class CancelError extends Error {\n constructor(public readonly kind: CancelReason[\"kind\"], message?: string) {\n super(message ?? `[mutator] cancellable: ${kind}`);\n this.name = \"CancelError\";\n }\n}\n\n/** Subclass of {@link CancelError} for timeout-driven aborts. */\nexport class TimeoutCancelError extends CancelError {\n constructor(public readonly afterMs: number) {\n super(\"timeout\", `[mutator] cancellable: timeout after ${afterMs}ms`);\n this.name = \"TimeoutCancelError\";\n }\n}\n\n/** Subclass of {@link CancelError} for supersede-driven aborts. */\nexport class SupersededCancelError extends CancelError {\n constructor() {\n super(\"superseded\", \"[mutator] cancellable: superseded by new dispatch\");\n this.name = \"SupersededCancelError\";\n }\n}\n\n/**\n * Factory for cancel-reason values. Pure — these are the runtime\n * counterparts of the {@link CancelReason} type. Use them in custom\n * cancellation flows that need a typed reason without re-typing the\n * literal.\n */\nexport const cancelReason = {\n superseded: (): SupersededCancelError => new SupersededCancelError(),\n timeout: (afterMs: number): TimeoutCancelError =>\n new TimeoutCancelError(afterMs),\n} as const;\n\n/**\n * Wrap a mutator handler with auto-cancellation. The wrapped handler\n * receives an extra `signal: AbortSignal` in its context. Use the\n * signal to short-circuit awaitable work — pass it to `fetch(url, {\n * signal })`, watch it inside long-running loops, etc.\n *\n * Two cancellation triggers, both opt-in via {@link CancellableOptions}:\n *\n * 1. **Supersession** (default `supersedeOn: 'self'`): when a new\n * dispatch of the same wrapped handler arrives while a prior\n * invocation is still running, the prior signal aborts.\n * 2. **Timeout** (default `timeoutMs: undefined`, meaning no timeout):\n * after `timeoutMs` ms from invocation start, the signal aborts.\n *\n * If a handler's signal aborts, the handler should observe the abort\n * (via `signal.aborted` or the AbortError-throwing helpers) and\n * return promptly. The signal's `reason` carries a {@link CancelReason}\n * disambiguating which trigger fired.\n *\n * Compose with {@link defineMutator} by using `cancellable()` directly\n * in the handler map:\n *\n * @example\n * ```ts\n * import { defineMutator, cancellable } from '@directive-run/mutator';\n *\n * const formMutator = defineMutator<MyMutations, MyFacts>({\n * search: cancellable(\n * { supersedeOn: 'self', timeoutMs: 3_000 },\n * async ({ payload, facts, signal }) => {\n * const res = await fetch(`/q?${payload.q}`, { signal });\n * facts.results = await res.json();\n * },\n * ),\n * submit: async ({ payload, facts }) => {\n * // No cancellation needed for submit — plain handler.\n * facts.values = await deps.submit(payload.values);\n * },\n * });\n * ```\n *\n * **Idempotency note.** The wrapped handler stays a regular\n * `MutationHandler<M, K, F>` from the mutator's perspective. The\n * supersession registry is closure-scoped per `cancellable()` call —\n * two separate `cancellable(...)` HOCs around different handlers do\n * NOT cancel each other.\n *\n * **Test ergonomics.** Pass `virtualClock.setTimeout` via the\n * `setTimeout` option to make timeouts deterministic under\n * `clock.advanceBy(ms)`. Without that, timeouts use wall-clock\n * `globalThis.setTimeout` and are real-time.\n */\nexport function cancellable<F, P>(\n opts: CancellableOptions,\n handler: (ctx: CancellableHandlerContext<F, P>) => Promise<void> | void,\n): (ctx: { facts: F; payload: P; requeue: () => void }) => Promise<void> {\n const supersedeOn = opts.supersedeOn ?? \"self\";\n const timeoutMs = opts.timeoutMs;\n const scheduleTimeout = opts.setTimeout ?? defaultSetTimeout;\n\n // Closure-scoped supersession slot — one entry for the wrapped\n // handler. When a new invocation arrives, the prior entry's\n // controller aborts before the new one starts.\n let priorController: AbortController | undefined;\n\n return async (ctx: { facts: F; payload: P; requeue: () => void }) => {\n // Supersession: abort the prior in-flight invocation, if any.\n // R2 sec M-1: pass an Error subclass so signal.reason survives\n // re-throw through fetch / .catch(err => err instanceof Error)\n // / logging frameworks. Plain object reasons silently fail those\n // checks downstream.\n if (supersedeOn === \"self\" && priorController !== undefined) {\n priorController.abort(cancelReason.superseded());\n }\n\n const controller = new AbortController();\n priorController = controller;\n\n // Timeout: schedule an abort after `timeoutMs`. The cancel handle\n // returned by `scheduleTimeout` lets us clear the timer if the\n // handler completes first (saves leaking timers under a barrage\n // of dispatches).\n let cancelTimeout: (() => void) | undefined;\n if (typeof timeoutMs === \"number\" && timeoutMs > 0) {\n cancelTimeout = scheduleTimeout(() => {\n controller.abort(cancelReason.timeout(timeoutMs));\n }, timeoutMs);\n }\n\n try {\n await handler({\n facts: ctx.facts,\n payload: ctx.payload,\n requeue: ctx.requeue,\n signal: controller.signal,\n });\n } finally {\n // Clean up: clear the timeout (if it hasn't fired) and release\n // the supersession slot if it still belongs to this invocation.\n // R2 sec M-3: wrap cancelTimeout in try/catch so a hostile\n // setTimeout-cancel-handle (e.g. a custom virtual clock that\n // throws on cancel) cannot shadow the original handler's\n // exception.\n try {\n cancelTimeout?.();\n } catch {\n /* swallow — the cancel-handle's failure is not the caller's problem */\n }\n if (priorController === controller) {\n priorController = undefined;\n }\n }\n };\n}\n\n/**\n * Default `setTimeout` shim — wraps `globalThis.setTimeout` to match\n * the cancel-handle signature `cancellable()` expects.\n */\nfunction defaultSetTimeout(cb: () => void, ms: number): () => void {\n const handle = globalThis.setTimeout(cb, ms);\n return () => globalThis.clearTimeout(handle);\n}\n\n// ────────────────────────────────────────────────────────────────────────────\n// recordReplayable — R2.B\n// ────────────────────────────────────────────────────────────────────────────\n\n/**\n * Structured event surfaced to {@link RecordReplayableOptions.onCancel}\n * the moment a wrapped invocation's signal aborts. Carries enough\n * information for the caller to pin the cancellation into the timeline\n * (e.g. by writing onto a facts array) so a later replay can\n * reconstruct the cancellation race exactly.\n *\n * @typeParam F — caller's facts type (passed through unchanged from the handler context).\n * @typeParam P — caller's payload type for THIS handler.\n */\nexport interface CancelEvent<F, P> {\n /** Whether this cancel was driven by a new dispatch superseding the old, or by the timeout firing. */\n kind: CancelReason[\"kind\"];\n /** When `kind === 'timeout'`, the original `timeoutMs` that fired. Undefined for `'superseded'`. */\n afterMs?: number;\n /** The payload of the dispatch that was cancelled — i.e. the work that did NOT complete. */\n payload: P;\n /**\n * Per-handler monotonic counter, incremented once at the start of\n * every invocation. Useful for timeline diff: cancel of seq=4 is the\n * 4th dispatch this handler ever saw. The counter is closure-scoped\n * to a single `recordReplayable()` call — separate HOCs do NOT share\n * the counter.\n */\n dispatchSeq: number;\n /** Live facts reference, mirroring {@link CancellableHandlerContext.facts}. */\n facts: F;\n}\n\n/**\n * Options for {@link recordReplayable}. Strict superset of\n * {@link CancellableOptions} — every field on `CancellableOptions`\n * passes through unchanged to the underlying {@link cancellable} call.\n */\nexport interface RecordReplayableOptions<F, P> extends CancellableOptions {\n /**\n * Synchronous callback invoked the moment a wrapped invocation's\n * AbortController fires `abort()`. Runs BEFORE the handler's pending\n * await rejects with AbortError, so the callback sees the freshest\n * possible state.\n *\n * Use this to pin the cancellation into a place that survives in the\n * timeline (typically a facts field — `facts.cancellations.push(info)`).\n * Without that, a replay re-dispatches the same MUTATE events but has\n * no record of which ones were superseded vs which completed.\n *\n * Throws inside `onCancel` are caught and swallowed — the abort path\n * must remain robust. If you need to fail loudly, log to your own\n * sink before re-throwing.\n */\n onCancel?: (info: CancelEvent<F, P>) => void;\n}\n\n/**\n * `cancellable()` plus a synchronous on-abort callback. Wrap your\n * handler with `recordReplayable()` instead of `cancellable()` when\n * you want a hook that fires the moment a supersession or timeout\n * aborts the in-flight invocation — *before* the handler's pending\n * await rejects with AbortError. The callback receives a structured\n * {@link CancelEvent} carrying the cancel kind, payload, dispatch\n * sequence, and a live facts reference.\n *\n * The callback is GENERIC (\"call me when abort fires\"); the most\n * common use case is pinning cancel events into facts so a recorded\n * timeline carries them — which then lets `replayTimeline()`,\n * `bisectTimeline()`, and `diffTimelines()` (all in\n * `@directive-run/timeline`) reason about which dispatches were\n * superseded vs which completed without parsing free-form error\n * strings. But the same callback works equally well for Sentry\n * breadcrumbs, a Redux action log, OpenTelemetry spans, or a metrics\n * sink — `recordReplayable` doesn't know or care about the timeline.\n *\n * @example Pin cancel events into facts so the timeline carries them:\n * ```ts\n * import { defineMutator, recordReplayable } from '@directive-run/mutator';\n *\n * const search = recordReplayable<MyFacts, { q: string }>(\n * {\n * supersedeOn: 'self',\n * timeoutMs: 3_000,\n * onCancel: ({ facts, kind, payload, dispatchSeq }) => {\n * facts.cancellations.push({ kind, queryAtCancel: payload.q, seq: dispatchSeq });\n * },\n * },\n * async ({ payload, facts, signal }) => {\n * const res = await fetch(`/q?${payload.q}`, { signal });\n * facts.results = await res.json();\n * },\n * );\n * ```\n *\n * Implementation note: `recordReplayable()` is `cancellable(opts,\n * innerHandler)` where `innerHandler` adds an `addEventListener('abort')`\n * around the user's handler. This means timeout / supersession\n * semantics are EXACTLY those of `cancellable()` — the HOC is purely\n * additive.\n */\nexport function recordReplayable<F, P>(\n opts: RecordReplayableOptions<F, P>,\n handler: (ctx: CancellableHandlerContext<F, P>) => Promise<void> | void,\n): (ctx: { facts: F; payload: P; requeue: () => void }) => Promise<void> {\n let dispatchSeq = 0;\n\n const wrappedHandler = async (\n ctx: CancellableHandlerContext<F, P>,\n ): Promise<void> => {\n const seq = ++dispatchSeq;\n const onAbort = (): void => {\n // Only fire onCancel for our typed CancelError reasons; if the\n // signal was aborted via some other path (caller's own\n // controller, etc), let it pass silently.\n const reason = ctx.signal.reason;\n if (!(reason instanceof CancelError)) return;\n const info: CancelEvent<F, P> = {\n kind: reason.kind,\n afterMs:\n reason instanceof TimeoutCancelError ? reason.afterMs : undefined,\n payload: ctx.payload,\n dispatchSeq: seq,\n facts: ctx.facts,\n };\n try {\n opts.onCancel?.(info);\n } catch {\n // Callback errors must NOT bubble up the abort path. The user's\n // handler is still about to receive AbortError — we don't want\n // to mask that with an onCancel-side throw, and we don't want\n // to crash the controller in the middle of cleanup.\n }\n };\n ctx.signal.addEventListener(\"abort\", onAbort, { once: true });\n try {\n await handler(ctx);\n } finally {\n ctx.signal.removeEventListener(\"abort\", onAbort);\n }\n };\n\n return cancellable<F, P>(opts, wrappedHandler);\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"names":["MAX_ERROR_LEN","truncateError","input","str","raw","safeStringCoerce","value","defineMutator","handlers","t","facts","payload","_req","ctx","factsRef","pending","handler","handlerCtx","err","mutate","kind","CancelError","message","TimeoutCancelError","afterMs","SupersededCancelError","cancelReason","cancellable","opts","supersedeOn","timeoutMs","scheduleTimeout","defaultSetTimeout","priorController","controller","cancelTimeout","cb","ms","handle","recordReplayable","dispatchSeq","seq","onAbort","reason","info"],"mappings":"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","file":"index.js","sourcesContent":["/**\n * @directive-run/mutator\n *\n * Discriminated mutation helper. Collapses the manual `pendingAction`\n * ceremony — fact + event + constraint + resolver — into a typed handler\n * map.\n *\n * Background: across the 55-cycle Minglingo migration, 12 modules ended\n * up with the same shape:\n * - a nullable `pendingAction` fact holding a discriminated union\n * - an event that sets it\n * - a constraint that fires on non-null\n * - a resolver that switches on the discriminator and clears the fact\n *\n * That's ~50 lines of boilerplate per module times 12 modules. The\n * `defineMutator` helper below contributes all four pieces from a single\n * typed declaration, so a module that uses it spreads the fragments\n * into its `createModule` config and writes only the per-variant handler\n * bodies.\n *\n * @see ../README.md for the full API and a worked example.\n */\n\nimport { t } from \"@directive-run/core\";\n\n/**\n * A keyed map of variant payloads. Each key becomes a discriminator value\n * for the mutation, each value is the payload type for that variant.\n *\n * @example\n * ```ts\n * type MyMutations = {\n * submit: { values: FormValues };\n * cancel: {};\n * retry: { reason: string };\n * };\n * ```\n */\nexport type MutationMap = Record<string, Record<string, unknown>>;\n\n/**\n * The shape of a `pendingMutation` fact while a mutation is queued or\n * running.\n */\nexport type PendingMutation<M extends MutationMap> = {\n [K in keyof M]: {\n kind: K;\n payload: M[K];\n /**\n * `pending` — queued, constraint hasn't fired yet.\n * `running` — handler is in flight.\n * `failed` — handler threw; constraint stops firing. Caller can\n * inspect `error` and dispatch a fresh MUTATE to retry.\n */\n status: \"pending\" | \"running\" | \"failed\";\n /**\n * Error message from the previous run, if any. Plaintext only —\n * NEVER render this directly via `dangerouslySetInnerHTML` or\n * markdown. Truncated to 500 chars to bound XSS surface from\n * thrown messages that may echo user input.\n */\n error: string | null;\n };\n}[keyof M];\n\n/** Maximum length of a captured error message — bounded to limit XSS surface. */\nconst MAX_ERROR_LEN = 500;\n\n/**\n * Coerce a thrown value to a bounded plaintext string for storage on\n * `pendingMutation.error`. Defends against:\n * - non-Error throws (`throw \"string\"`, `throw 42`, `throw {}`)\n * - Errors with non-string `.message` (overridden numeric / Symbol /\n * buffer); naive `.length` / `.slice` would `TypeError` otherwise\n * - extremely long messages with attacker-influenced input\n *\n * Returns at most {@link MAX_ERROR_LEN} characters of plaintext.\n * Plaintext rendering is still the only supported path on the\n * consumer side; this is defense in depth, not an XSS sanitizer.\n *\n * (R2 sec M-R2-1.)\n */\nfunction truncateError(input: unknown): string {\n let str: string;\n if (input instanceof Error) {\n // Reading `.message` is wrapped in try/catch because a maliciously\n // constructed Error subclass may install a throwing getter on\n // `message`. Without this guard the throw escapes truncateError →\n // escapes the resolver's catch → propagates uncaught. (R4 backlog.)\n let raw: unknown;\n try {\n raw = input.message;\n } catch {\n raw = \"[mutator: error.message getter threw]\";\n }\n str = typeof raw === \"string\" ? raw : safeStringCoerce(raw);\n } else if (typeof input === \"string\") {\n str = input;\n } else {\n str = safeStringCoerce(input);\n }\n if (str.length <= MAX_ERROR_LEN) return str;\n return `${str.slice(0, MAX_ERROR_LEN - 1)}…`;\n}\n\n/** String() may throw for some Symbols + objects with hostile toString. */\nfunction safeStringCoerce(value: unknown): string {\n try {\n return String(value);\n } catch {\n return \"[mutator: unstringifiable error]\";\n }\n}\n\n/**\n * Handler context passed to each variant handler.\n *\n * Note: `deps` is NOT in the context. This matches the Directive resolver\n * idiom — close over deps from the outer module-factory scope:\n *\n * ```ts\n * function createFormModule(deps: FormDeps) {\n * const mut = defineMutator<FormMutations, FormFacts>({\n * submit: async ({ payload, facts }) => {\n * facts.values = await deps.submit(payload.values); // ← closure\n * },\n * });\n * return createModule('form', { ... });\n * }\n * ```\n */\nexport interface MutatorHandlerContext<F> {\n /** Live facts proxy. Reads are cache-tracked; writes invalidate. */\n facts: F;\n /**\n * Trigger a same-constraint re-fire after this handler returns. Useful\n * when one mutation cascades into another — without `requeue`, the next\n * mutation would stall behind same-flush suppression.\n *\n * @see https://docs.directive.run/testing/chained-pipelines\n */\n requeue: () => void;\n}\n\n/**\n * Variant handler body. Receives the typed payload + a context; returns\n * void or a Promise. Throwing is fine — the runtime captures into\n * `pendingMutation.error` before clearing the fact.\n */\nexport type MutationHandler<\n M extends MutationMap,\n K extends keyof M,\n F,\n> = keyof M[K] extends never\n ? (ctx: MutatorHandlerContext<F>) => void | Promise<void>\n : (ctx: MutatorHandlerContext<F> & { payload: M[K] }) => void | Promise<void>;\n\n/**\n * The full handler map. Every variant in `M` MUST have a handler.\n */\nexport type MutationHandlers<M extends MutationMap, F> = {\n [K in keyof M]: MutationHandler<M, K, F>;\n};\n\n/**\n * The fragments returned by `defineMutator`. Spread each fragment into\n * the matching position of your `createModule` config.\n *\n * @internal Shape documented for type clarity; users typically just\n * spread.\n */\nexport interface MutatorFragments<M extends MutationMap, F> {\n /** Spread into `schema.facts`. Adds `pendingMutation`. */\n facts: {\n pendingMutation: ReturnType<typeof t.object>;\n };\n /** Spread into `schema.events`. Adds the `MUTATE` event. */\n events: {\n MUTATE: PendingMutation<M>;\n };\n /** Spread into `schema.requirements`. Adds `PROCESS_MUTATION`. */\n requirements: {\n PROCESS_MUTATION: Record<string, never>;\n };\n /** Spread into the `events` field. Sets pendingMutation on MUTATE. */\n eventHandlers: {\n MUTATE: (facts: F, payload: PendingMutation<M>) => void;\n };\n /** Spread into the `constraints` field. */\n constraints: {\n pendingMutation: {\n when: (facts: F) => boolean;\n require: { type: \"PROCESS_MUTATION\" };\n };\n };\n /** Spread into the `resolvers` field. */\n resolvers: {\n mutationResolver: {\n requirement: \"PROCESS_MUTATION\";\n resolve: (\n req: { type: \"PROCESS_MUTATION\" },\n ctx: { facts: F },\n ) => Promise<void>;\n };\n };\n /**\n * Convenience: the initial value for `pendingMutation`. Set this in\n * your module's `init` if you don't use `t.X().default(...)` defaults.\n */\n initialPendingMutation: null;\n}\n\n/**\n * Define a mutator fragment-set for a given variant map and handlers.\n *\n * @example\n * ```ts\n * type FormMutations = {\n * submit: { values: FormValues };\n * cancel: {};\n * };\n *\n * // The second generic is the FACTS type (not deps). Deps are\n * // captured in closure from the surrounding scope, not passed in\n * // through the handler ctx.\n * const formMutator = defineMutator<FormMutations, FormFacts>({\n * submit: async ({ payload, facts }) => {\n * facts.values = await deps.submit(payload.values); // deps closes over outer scope\n * },\n * cancel: ({ facts }) => { facts.values = []; },\n * });\n *\n * createModule('form', {\n * schema: {\n * facts: { ...formMutator.facts, values: t.array<FormValues>() },\n * events: { ...formMutator.events, REFRESH: {} },\n * requirements: { ...formMutator.requirements },\n * },\n * init: (f) => { f.pendingMutation = null; f.values = []; },\n * events: {\n * ...formMutator.eventHandlers,\n * REFRESH: (f) => { f.values = []; },\n * },\n * constraints: { ...formMutator.constraints },\n * resolvers: { ...formMutator.resolvers },\n * });\n *\n * // Usage:\n * sys.events.MUTATE(mutate<FormMutations>('submit', { values: ... }));\n *\n * // Or, if constructing the payload manually, the discriminator is\n * // `kind` (NOT `type` — `type` collides with Directive's event-name\n * // dispatch convention):\n * sys.events.MUTATE({\n * kind: 'submit',\n * payload: { values: ... },\n * status: 'pending',\n * error: null,\n * });\n * ```\n *\n * The handler-bound deps are captured at `defineMutator` call time. To\n * inject deps from the caller, wrap `defineMutator` in your module\n * factory:\n *\n * ```ts\n * export function createFormModule(deps: FormDeps) {\n * const mut = defineMutator<FormMutations, FormFacts>({\n * submit: async ({ payload, facts }) => {\n * facts.values = await deps.submit(payload.values);\n * },\n * cancel: ({ facts }) => { facts.values = []; },\n * });\n * return createModule('form', { ... });\n * }\n * ```\n */\nexport function defineMutator<\n M extends MutationMap,\n F = Record<string, unknown>,\n>(handlers: MutationHandlers<M, F>): MutatorFragments<M, F> {\n type Pending = PendingMutation<M>;\n\n const facts = {\n pendingMutation: t.object<Pending>().nullable() as ReturnType<\n typeof t.object\n >,\n } as MutatorFragments<M, F>[\"facts\"];\n\n // Schema event marker — the runtime uses this for typing and devtools.\n // Payload validation happens at dispatch time via t-schema check.\n const events = {\n MUTATE: undefined as unknown as Pending,\n } as MutatorFragments<M, F>[\"events\"];\n\n const requirements = {\n PROCESS_MUTATION: {} as Record<string, never>,\n } as MutatorFragments<M, F>[\"requirements\"];\n\n const eventHandlers = {\n MUTATE: (facts: F, payload: Pending) => {\n // Overwrite is intentional — caller is responsible for ordering.\n // If a previous mutation is mid-flight, the new one supersedes by\n // writing `status: \"pending\"` over the in-flight `status: \"running\"`\n // marker. The resolver's `if (status === \"running\")` checks at the\n // success and failure paths then leave the fresh dispatch untouched\n // so the next constraint fire picks it up — the in-flight result is\n // dropped on the floor (caller wanted to move on). See sec M5 / DX M2\n // in the AE review for the full reasoning.\n (facts as { pendingMutation: Pending | null }).pendingMutation = {\n ...payload,\n status: \"pending\",\n error: null,\n };\n },\n } as MutatorFragments<M, F>[\"eventHandlers\"];\n\n const constraints = {\n pendingMutation: {\n when: (facts: F) =>\n (facts as { pendingMutation: Pending | null }).pendingMutation !==\n null &&\n (facts as { pendingMutation: Pending | null }).pendingMutation\n ?.status === \"pending\",\n require: { type: \"PROCESS_MUTATION\" } as const,\n },\n } as MutatorFragments<M, F>[\"constraints\"];\n\n const resolvers = {\n mutationResolver: {\n requirement: \"PROCESS_MUTATION\" as const,\n resolve: async (\n _req: { type: \"PROCESS_MUTATION\" },\n ctx: {\n facts: F;\n requeue?: () => void;\n },\n ) => {\n const factsRef = ctx.facts as { pendingMutation: Pending | null };\n const pending = factsRef.pendingMutation;\n if (pending === null) return;\n\n // Stamp the in-flight transition with the running status. The\n // status itself is the supersession marker: only this resolver\n // ever writes 'running'; the eventHandler always writes\n // 'pending'. So if a fresh MUTATE arrives mid-flight, the\n // post-handler check sees status: 'pending' instead of\n // 'running' and knows to leave the new dispatch alone.\n // (Sec M5 / DX M2.)\n factsRef.pendingMutation = { ...pending, status: \"running\" };\n\n // Prototype-pollution defense: only accept handlers OWNED by\n // the user-provided map. Without this, dispatching with kind:\n // 'constructor' / 'toString' / '__proto__' would resolve via\n // the prototype chain and either crash or invoke an inherited\n // function with arbitrary payload. (Sec C1.)\n const ownsHandler = Object.prototype.hasOwnProperty.call(\n handlers,\n pending.kind as PropertyKey,\n );\n const handler = ownsHandler\n ? (handlers as Record<string, unknown>)[pending.kind as string]\n : undefined;\n\n if (typeof handler !== \"function\") {\n factsRef.pendingMutation = {\n ...pending,\n status: \"failed\",\n error: truncateError(\n `[mutator] no handler registered for variant: ${String(\n pending.kind,\n )}`,\n ),\n };\n return;\n }\n\n const handlerCtx = {\n facts: ctx.facts,\n payload: pending.payload,\n requeue: ctx.requeue ?? (() => {}),\n };\n\n try {\n await (handler as (c: typeof handlerCtx) => Promise<void> | void)(\n handlerCtx,\n );\n // Success: clear the fact ONLY if this resolver's `running`\n // marker is still live (status === 'running'). If a fresh\n // MUTATE arrived mid-flight via the eventHandler, the fact\n // now has status: 'pending' — leave it alone so the next\n // constraint fire picks it up. (Sec M5 / DX M2.)\n if (factsRef.pendingMutation?.status === \"running\") {\n factsRef.pendingMutation = null;\n }\n } catch (err) {\n // Failure: only stamp the error if our running marker is\n // still live. If a fresh MUTATE arrived mid-flight, the new\n // dispatch wins; the failed mutation's error is dropped on\n // the floor (caller wanted to move on anyway). (Sec M5.)\n if (factsRef.pendingMutation?.status !== \"running\") return;\n factsRef.pendingMutation = {\n ...pending,\n // 'failed' is a distinct status (Sec M6 / Arch C2) — UI can\n // disambiguate \"still in flight\" from \"stopped on error\".\n status: \"failed\",\n // truncateError handles the unknown shape safely — non-Error\n // throws, non-string Error.message, hostile toString. (R2\n // sec M-R2-1.)\n error: truncateError(err),\n };\n }\n },\n },\n } as MutatorFragments<M, F>[\"resolvers\"];\n\n return {\n facts,\n events,\n requirements,\n eventHandlers,\n constraints,\n resolvers,\n initialPendingMutation: null,\n };\n}\n\n/**\n * Helper for typed dispatch. Lets the caller construct a mutation payload\n * with full type narrowing — the `kind` field auto-restricts the\n * `payload` shape.\n *\n * @example\n * ```ts\n * sys.events.MUTATE(mutate<FormMutations>('submit', { values: ... }));\n * sys.events.MUTATE(mutate<FormMutations>('cancel'));\n * ```\n */\n// Single type parameter (M) so callers can specialize with\n// `mutate<FormMutations>(...)` without TypeScript demanding the\n// inferred K. TS's strict type-argument rules treat <M, K> as\n// \"supply both or supply neither\" — single-arg call sites failed\n// with \"Expected 2 type arguments, but got 1.\" Single-M form sidesteps\n// that while keeping payload typing tight via the `kind` lookup.\nexport function mutate<M extends MutationMap>(\n kind: keyof M & string,\n payload: M[typeof kind],\n): PendingMutation<M>;\nexport function mutate<M extends MutationMap>(\n kind: keyof M & string,\n): PendingMutation<M>;\nexport function mutate<M extends MutationMap>(\n kind: keyof M & string,\n payload?: M[typeof kind],\n): PendingMutation<M> {\n return {\n kind,\n payload: (payload ?? {}) as M[typeof kind],\n status: \"pending\",\n error: null,\n } as PendingMutation<M>;\n}\n\n// ============================================================================\n// cancellable() — auto-cancel-on-supersede (R1.C v0.1)\n// ============================================================================\n\n/**\n * Options for {@link cancellable}.\n */\nexport interface CancellableOptions {\n /**\n * When to fire the AbortSignal that the wrapped handler receives.\n *\n * - `'self'` (default): a new dispatch of the SAME handler aborts the\n * prior in-flight invocation. The classic \"cancel previous on new\n * keystroke\" pattern.\n * - `'never'`: only the timeout fires the signal; new dispatches do\n * NOT abort prior ones. Useful when you want a hard timeout but\n * parallel runs are fine.\n */\n supersedeOn?: \"self\" | \"never\";\n\n /**\n * Maximum ms a handler may run before its signal is aborted. Counted\n * from the start of THIS invocation. Combine with `realClock` for\n * production or pass a `setTimeout` shim for deterministic testing.\n */\n timeoutMs?: number;\n\n /**\n * Optional `setTimeout` injection. Defaults to `globalThis.setTimeout`.\n * For deterministic tests, pass `virtualClock.setTimeout` from\n * `@directive-run/core` so the timeout fires under\n * `clock.advanceBy()` instead of wall-clock real time.\n *\n * @example\n * ```ts\n * import { virtualClock } from '@directive-run/core';\n * const clock = virtualClock(0);\n * cancellable({ timeoutMs: 1_000, setTimeout: clock.setTimeout }, handler);\n * ```\n */\n setTimeout?: (cb: () => void, ms: number) => () => void;\n}\n\n/**\n * Handler context augmented with a cancellation signal.\n */\nexport interface CancellableHandlerContext<F, P> {\n facts: F;\n payload: P;\n /** Aborts when a new dispatch supersedes this one OR the timeout fires. */\n signal: AbortSignal;\n requeue: () => void;\n}\n\n/**\n * Reason a cancellable handler's signal aborted. Stamped on\n * `signal.reason` so the handler can disambiguate.\n *\n * Note: at runtime the value is a {@link CancelError} subclass with\n * the same shape — `signal.reason instanceof CancelError` is the\n * canonical check. Older usages that did `signal.reason?.kind ===\n * 'superseded'` continue to work because the Error subclass exposes\n * the same `kind` field. (R2 sec M-1.)\n */\nexport type CancelReason =\n | { kind: \"superseded\" }\n | { kind: \"timeout\"; afterMs: number };\n\n/**\n * Runtime carrier for {@link CancelReason}. Subclasses `Error` so the\n * value survives transit through `fetch(url, { signal })` and other\n * web-platform APIs that re-throw `signal.reason`. Older mutator\n * versions passed plain objects, which `.catch(err => err instanceof\n * Error)` filters silently dropped. (R2 sec M-1.)\n */\nexport class CancelError extends Error {\n constructor(\n public readonly kind: CancelReason[\"kind\"],\n message?: string,\n ) {\n super(message ?? `[mutator] cancellable: ${kind}`);\n this.name = \"CancelError\";\n }\n}\n\n/** Subclass of {@link CancelError} for timeout-driven aborts. */\nexport class TimeoutCancelError extends CancelError {\n constructor(public readonly afterMs: number) {\n super(\"timeout\", `[mutator] cancellable: timeout after ${afterMs}ms`);\n this.name = \"TimeoutCancelError\";\n }\n}\n\n/** Subclass of {@link CancelError} for supersede-driven aborts. */\nexport class SupersededCancelError extends CancelError {\n constructor() {\n super(\"superseded\", \"[mutator] cancellable: superseded by new dispatch\");\n this.name = \"SupersededCancelError\";\n }\n}\n\n/**\n * Factory for cancel-reason values. Pure — these are the runtime\n * counterparts of the {@link CancelReason} type. Use them in custom\n * cancellation flows that need a typed reason without re-typing the\n * literal.\n */\nexport const cancelReason = {\n superseded: (): SupersededCancelError => new SupersededCancelError(),\n timeout: (afterMs: number): TimeoutCancelError =>\n new TimeoutCancelError(afterMs),\n} as const;\n\n/**\n * Wrap a mutator handler with auto-cancellation. The wrapped handler\n * receives an extra `signal: AbortSignal` in its context. Use the\n * signal to short-circuit awaitable work — pass it to `fetch(url, {\n * signal })`, watch it inside long-running loops, etc.\n *\n * Two cancellation triggers, both opt-in via {@link CancellableOptions}:\n *\n * 1. **Supersession** (default `supersedeOn: 'self'`): when a new\n * dispatch of the same wrapped handler arrives while a prior\n * invocation is still running, the prior signal aborts.\n * 2. **Timeout** (default `timeoutMs: undefined`, meaning no timeout):\n * after `timeoutMs` ms from invocation start, the signal aborts.\n *\n * If a handler's signal aborts, the handler should observe the abort\n * (via `signal.aborted` or the AbortError-throwing helpers) and\n * return promptly. The signal's `reason` carries a {@link CancelReason}\n * disambiguating which trigger fired.\n *\n * Compose with {@link defineMutator} by using `cancellable()` directly\n * in the handler map:\n *\n * @example\n * ```ts\n * import { defineMutator, cancellable } from '@directive-run/mutator';\n *\n * const formMutator = defineMutator<MyMutations, MyFacts>({\n * search: cancellable(\n * { supersedeOn: 'self', timeoutMs: 3_000 },\n * async ({ payload, facts, signal }) => {\n * const res = await fetch(`/q?${payload.q}`, { signal });\n * facts.results = await res.json();\n * },\n * ),\n * submit: async ({ payload, facts }) => {\n * // No cancellation needed for submit — plain handler.\n * facts.values = await deps.submit(payload.values);\n * },\n * });\n * ```\n *\n * **Idempotency note.** The wrapped handler stays a regular\n * `MutationHandler<M, K, F>` from the mutator's perspective. The\n * supersession registry is closure-scoped per `cancellable()` call —\n * two separate `cancellable(...)` HOCs around different handlers do\n * NOT cancel each other.\n *\n * **Test ergonomics.** Pass `virtualClock.setTimeout` via the\n * `setTimeout` option to make timeouts deterministic under\n * `clock.advanceBy(ms)`. Without that, timeouts use wall-clock\n * `globalThis.setTimeout` and are real-time.\n */\nexport function cancellable<F, P>(\n opts: CancellableOptions,\n handler: (ctx: CancellableHandlerContext<F, P>) => Promise<void> | void,\n): (ctx: { facts: F; payload: P; requeue: () => void }) => Promise<void> {\n const supersedeOn = opts.supersedeOn ?? \"self\";\n const timeoutMs = opts.timeoutMs;\n const scheduleTimeout = opts.setTimeout ?? defaultSetTimeout;\n\n // Closure-scoped supersession slot — one entry for the wrapped\n // handler. When a new invocation arrives, the prior entry's\n // controller aborts before the new one starts.\n let priorController: AbortController | undefined;\n\n return async (ctx: { facts: F; payload: P; requeue: () => void }) => {\n // Supersession: abort the prior in-flight invocation, if any.\n // R2 sec M-1: pass an Error subclass so signal.reason survives\n // re-throw through fetch / .catch(err => err instanceof Error)\n // / logging frameworks. Plain object reasons silently fail those\n // checks downstream.\n if (supersedeOn === \"self\" && priorController !== undefined) {\n priorController.abort(cancelReason.superseded());\n }\n\n const controller = new AbortController();\n priorController = controller;\n\n // Timeout: schedule an abort after `timeoutMs`. The cancel handle\n // returned by `scheduleTimeout` lets us clear the timer if the\n // handler completes first (saves leaking timers under a barrage\n // of dispatches).\n let cancelTimeout: (() => void) | undefined;\n if (typeof timeoutMs === \"number\" && timeoutMs > 0) {\n cancelTimeout = scheduleTimeout(() => {\n controller.abort(cancelReason.timeout(timeoutMs));\n }, timeoutMs);\n }\n\n try {\n await handler({\n facts: ctx.facts,\n payload: ctx.payload,\n requeue: ctx.requeue,\n signal: controller.signal,\n });\n } finally {\n // Clean up: clear the timeout (if it hasn't fired) and release\n // the supersession slot if it still belongs to this invocation.\n // R2 sec M-3: wrap cancelTimeout in try/catch so a hostile\n // setTimeout-cancel-handle (e.g. a custom virtual clock that\n // throws on cancel) cannot shadow the original handler's\n // exception.\n try {\n cancelTimeout?.();\n } catch {\n /* swallow — the cancel-handle's failure is not the caller's problem */\n }\n if (priorController === controller) {\n priorController = undefined;\n }\n }\n };\n}\n\n/**\n * Default `setTimeout` shim — wraps `globalThis.setTimeout` to match\n * the cancel-handle signature `cancellable()` expects.\n */\nfunction defaultSetTimeout(cb: () => void, ms: number): () => void {\n const handle = globalThis.setTimeout(cb, ms);\n return () => globalThis.clearTimeout(handle);\n}\n\n// ────────────────────────────────────────────────────────────────────────────\n// recordReplayable — R2.B\n// ────────────────────────────────────────────────────────────────────────────\n\n/**\n * Structured event surfaced to {@link RecordReplayableOptions.onCancel}\n * the moment a wrapped invocation's signal aborts. Carries enough\n * information for the caller to pin the cancellation into the timeline\n * (e.g. by writing onto a facts array) so a later replay can\n * reconstruct the cancellation race exactly.\n *\n * @typeParam F — caller's facts type (passed through unchanged from the handler context).\n * @typeParam P — caller's payload type for THIS handler.\n */\nexport interface CancelEvent<F, P> {\n /** Whether this cancel was driven by a new dispatch superseding the old, or by the timeout firing. */\n kind: CancelReason[\"kind\"];\n /** When `kind === 'timeout'`, the original `timeoutMs` that fired. Undefined for `'superseded'`. */\n afterMs?: number;\n /** The payload of the dispatch that was cancelled — i.e. the work that did NOT complete. */\n payload: P;\n /**\n * Per-handler monotonic counter, incremented once at the start of\n * every invocation. Useful for timeline diff: cancel of seq=4 is the\n * 4th dispatch this handler ever saw. The counter is closure-scoped\n * to a single `recordReplayable()` call — separate HOCs do NOT share\n * the counter.\n */\n dispatchSeq: number;\n /** Live facts reference, mirroring {@link CancellableHandlerContext.facts}. */\n facts: F;\n}\n\n/**\n * Options for {@link recordReplayable}. Strict superset of\n * {@link CancellableOptions} — every field on `CancellableOptions`\n * passes through unchanged to the underlying {@link cancellable} call.\n */\nexport interface RecordReplayableOptions<F, P> extends CancellableOptions {\n /**\n * Synchronous callback invoked the moment a wrapped invocation's\n * AbortController fires `abort()`. Runs BEFORE the handler's pending\n * await rejects with AbortError, so the callback sees the freshest\n * possible state.\n *\n * Use this to pin the cancellation into a place that survives in the\n * timeline (typically a facts field — `facts.cancellations.push(info)`).\n * Without that, a replay re-dispatches the same MUTATE events but has\n * no record of which ones were superseded vs which completed.\n *\n * Throws inside `onCancel` are caught and swallowed — the abort path\n * must remain robust. If you need to fail loudly, log to your own\n * sink before re-throwing.\n */\n onCancel?: (info: CancelEvent<F, P>) => void;\n}\n\n/**\n * `cancellable()` plus a synchronous on-abort callback. Wrap your\n * handler with `recordReplayable()` instead of `cancellable()` when\n * you want a hook that fires the moment a supersession or timeout\n * aborts the in-flight invocation — *before* the handler's pending\n * await rejects with AbortError. The callback receives a structured\n * {@link CancelEvent} carrying the cancel kind, payload, dispatch\n * sequence, and a live facts reference.\n *\n * The callback is GENERIC (\"call me when abort fires\"); the most\n * common use case is pinning cancel events into facts so a recorded\n * timeline carries them — which then lets `replayTimeline()`,\n * `bisectTimeline()`, and `diffTimelines()` (all in\n * `@directive-run/timeline`) reason about which dispatches were\n * superseded vs which completed without parsing free-form error\n * strings. But the same callback works equally well for Sentry\n * breadcrumbs, a Redux action log, OpenTelemetry spans, or a metrics\n * sink — `recordReplayable` doesn't know or care about the timeline.\n *\n * @example Pin cancel events into facts so the timeline carries them:\n * ```ts\n * import { defineMutator, recordReplayable } from '@directive-run/mutator';\n *\n * const search = recordReplayable<MyFacts, { q: string }>(\n * {\n * supersedeOn: 'self',\n * timeoutMs: 3_000,\n * onCancel: ({ facts, kind, payload, dispatchSeq }) => {\n * facts.cancellations.push({ kind, queryAtCancel: payload.q, seq: dispatchSeq });\n * },\n * },\n * async ({ payload, facts, signal }) => {\n * const res = await fetch(`/q?${payload.q}`, { signal });\n * facts.results = await res.json();\n * },\n * );\n * ```\n *\n * Implementation note: `recordReplayable()` is `cancellable(opts,\n * innerHandler)` where `innerHandler` adds an `addEventListener('abort')`\n * around the user's handler. This means timeout / supersession\n * semantics are EXACTLY those of `cancellable()` — the HOC is purely\n * additive.\n */\nexport function recordReplayable<F, P>(\n opts: RecordReplayableOptions<F, P>,\n handler: (ctx: CancellableHandlerContext<F, P>) => Promise<void> | void,\n): (ctx: { facts: F; payload: P; requeue: () => void }) => Promise<void> {\n let dispatchSeq = 0;\n\n const wrappedHandler = async (\n ctx: CancellableHandlerContext<F, P>,\n ): Promise<void> => {\n const seq = ++dispatchSeq;\n const onAbort = (): void => {\n // Only fire onCancel for our typed CancelError reasons; if the\n // signal was aborted via some other path (caller's own\n // controller, etc), let it pass silently.\n const reason = ctx.signal.reason;\n if (!(reason instanceof CancelError)) return;\n const info: CancelEvent<F, P> = {\n kind: reason.kind,\n afterMs:\n reason instanceof TimeoutCancelError ? reason.afterMs : undefined,\n payload: ctx.payload,\n dispatchSeq: seq,\n facts: ctx.facts,\n };\n try {\n opts.onCancel?.(info);\n } catch {\n // Callback errors must NOT bubble up the abort path. The user's\n // handler is still about to receive AbortError — we don't want\n // to mask that with an onCancel-side throw, and we don't want\n // to crash the controller in the middle of cleanup.\n }\n };\n ctx.signal.addEventListener(\"abort\", onAbort, { once: true });\n try {\n await handler(ctx);\n } finally {\n ctx.signal.removeEventListener(\"abort\", onAbort);\n }\n };\n\n return cancellable<F, P>(opts, wrappedHandler);\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@directive-run/mutator",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Discriminated mutation helper for Directive — collapse the pendingAction ceremony to a typed handler map.",
|
|
5
5
|
"license": "(MIT OR Apache-2.0)",
|
|
6
6
|
"author": "Jason Comes",
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
"tsup": "^8.3.5",
|
|
52
52
|
"typescript": "^5.7.2",
|
|
53
53
|
"vitest": "^2.1.9",
|
|
54
|
-
"@directive-run/core": "1.
|
|
54
|
+
"@directive-run/core": "1.15.0"
|
|
55
55
|
},
|
|
56
56
|
"scripts": {
|
|
57
57
|
"build": "tsup",
|