@directive-run/mutator 0.2.0 → 0.3.0
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 +104 -0
- package/README.md +46 -0
- package/dist/index.cjs +1 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +137 -1
- package/dist/index.d.ts +137 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,109 @@
|
|
|
1
1
|
# @directive-run/mutator changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [`02d80c4`](https://github.com/directive-run/directive/commit/02d80c427c3c6b989765dcd99aa51d1aa3770b8b) Thanks [@jasoncomes](https://github.com/jasoncomes)! - AE-review R2 fixes: critical security, correctness, and DX hardening across R1.A/B/C surfaces
|
|
8
|
+
|
|
9
|
+
Three parallel reviewers (security, architecture, DX, innovation) found 3 critical security + 2 critical architecture + 3 critical DX + ~20 major issues across the recently-shipped R1.A (timeline serialize/replay), R1.B (causal-graph matchers), and R1.C (cancellable mutator HOC) surfaces. This release closes the criticals and the highest-leverage majors.
|
|
10
|
+
|
|
11
|
+
### Timeline (patch — surface-compatible fixes)
|
|
12
|
+
|
|
13
|
+
- **R2 sec C-1: Spread-order RCE in `reconstructDispatch`.** `{ type: "MUTATE", ...next }` let an attacker-controlled `frames[i].event.next.type` field override the dispatch type. Untrusted prod-error JSON could re-route every replayed event to an arbitrary handler. Fix: spread-then-set (`{ ...next, type: "MUTATE" }`).
|
|
14
|
+
- **R2 sec C-2: Frame-shape validation in `deserializeTimeline`.** Per-frame validation of `ts`/`event`/`event.type`. Untrusted JSON with malformed frames now produces precise `TypeError` rejections instead of crashing the replay loop with bare exceptions.
|
|
15
|
+
- **R2 sec C-3: Matcher iteration robustness.** All five matchers now filter `frames()` through `isWellFormedFrame` before iterating. Hostile input produces clean assertion failures instead of TypeErrors.
|
|
16
|
+
- **R2 sec M-2: Structural equality in `toReachInMs`.** Replaced `JSON.stringify` equality with `structuredEqual` — NaN/undefined/Infinity no longer produce false-positive matches.
|
|
17
|
+
- **R2 sec M-4: `maxFrames` cap on `replayTimeline`.** Default 100,000 frames; prevents unbounded synchronous loops on hostile JSON dumps.
|
|
18
|
+
- **R2 arch M-2: `replayTimeline` returns `ReplayResult`** (`{ dispatched, skipped, truncated }`) instead of `void`. Lets callers verify the replay actually re-dispatched events instead of silently no-op'ing on non-mutator systems. Breaking change vs v0.2 only in type signature; existing call sites that ignored the return value continue to work.
|
|
19
|
+
- **R2 DX naming: `dispatchableOnly?: boolean`** is the new option name; `dispatchable?` is kept as a deprecated alias for v0.x compatibility. The original name read backwards ("dispatchable: true" sounded like "this thing IS dispatchable" not "filter to dispatchable").
|
|
20
|
+
|
|
21
|
+
### Mutator (minor — additive Error subclasses)
|
|
22
|
+
|
|
23
|
+
- **R2 sec M-1: `CancelError` Error-subclass for `signal.reason`.** New runtime carriers `CancelError`, `TimeoutCancelError`, `SupersededCancelError` ensure `signal.reason instanceof Error` checks succeed downstream. Plain-object reasons silently failed `fetch(url, {signal})` re-throw paths and `.catch(err => err instanceof Error)` filters in logging frameworks. The `CancelReason` type still works (Error subclasses expose the same `kind` field), so existing `signal.reason?.kind === 'superseded'` checks remain valid.
|
|
24
|
+
- **R2 arch M-5: Exported `cancelReason` factory** — `cancelReason.superseded()` and `cancelReason.timeout(afterMs)` produce typed Error subclasses. Single source of truth for both producers (cancellable internals) and consumers (handler abort observers).
|
|
25
|
+
- **R2 sec M-3: `cancelTimeout` cleanup error-shadowing fix.** A throwing `setTimeout`-cancel-handle (e.g. a hostile virtual clock) no longer replaces the original handler's exception. The cleanup is wrapped in try/catch.
|
|
26
|
+
- **R2 arch M-6: Peer dep tightened to `@directive-run/core@^1.3.0`.** `cancellable()`'s ergonomic test path imports `virtualClock` from core 1.3.0; consumers on 1.2.x would have hit a runtime error copying the README example.
|
|
27
|
+
|
|
28
|
+
### Innovation captures
|
|
29
|
+
|
|
30
|
+
`docs/IDEAS.md` updated with five new R2.A-E candidates surfaced post-ship — second-order ideas that ONLY became cheap to build because R1.A+B+C shipped together. Top pick: R2.A `directive bisect <good.json> <bad.json>` (2 days, BUILD CANDIDATE), git-bisect for timelines.
|
|
31
|
+
|
|
32
|
+
### Verification
|
|
33
|
+
|
|
34
|
+
- 4032 / 4033 tests pass workspace-wide (1 skipped, 0 failures).
|
|
35
|
+
- Per-package: timeline 30 / 30 (16 timeline + 14 matchers); mutator 30 / 30 (16 mutator + 14 cancellable, including new R2 regression tests for `CancelError` instance checks + finally-block error-shadowing).
|
|
36
|
+
- `pnpm -r --filter './packages/*' typecheck`: clean.
|
|
37
|
+
- `pnpm -r --filter './packages/*' build`: clean.
|
|
38
|
+
|
|
39
|
+
The Round 1 AE-review-loop typically converges in 3-5 rounds. R2 ships these critical+major fixes; R3 (verification round) is the next session if you want full convergence to "0 critical + 0 major."
|
|
40
|
+
|
|
41
|
+
- [`f70bd70`](https://github.com/directive-run/directive/commit/f70bd70071d2bc2fab5af6b6866f8e7c6ce559b1) Thanks [@jasoncomes](https://github.com/jasoncomes)! - R2.B: `recordReplayable()` HOC — structured cancellation events for replay-aware mutations
|
|
42
|
+
|
|
43
|
+
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
|
+
|
|
45
|
+
- `kind: 'superseded' | 'timeout'`
|
|
46
|
+
- `afterMs?: number` (timeout only)
|
|
47
|
+
- `payload: P` — the dispatch that did NOT complete
|
|
48
|
+
- `dispatchSeq: number` — per-handler monotonic counter
|
|
49
|
+
- `facts: F` — live facts reference
|
|
50
|
+
|
|
51
|
+
Use `onCancel` to pin cancellations into a place that survives in the timeline (typically a facts array). Without that, a replay re-dispatches the same MUTATE events but has no record of which were superseded vs which completed — so timeline diff/bisect tools cannot reason about cancellations without parsing free-form error strings.
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
import { defineMutator, recordReplayable } from "@directive-run/mutator";
|
|
55
|
+
|
|
56
|
+
const search = recordReplayable<MyFacts, { q: string }>(
|
|
57
|
+
{
|
|
58
|
+
supersedeOn: "self",
|
|
59
|
+
timeoutMs: 3_000,
|
|
60
|
+
onCancel: ({ facts, kind, payload, dispatchSeq }) => {
|
|
61
|
+
facts.cancellations.push({
|
|
62
|
+
kind,
|
|
63
|
+
queryAtCancel: payload.q,
|
|
64
|
+
seq: dispatchSeq,
|
|
65
|
+
});
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
async ({ payload, facts, signal }) => {
|
|
69
|
+
const res = await fetch(`/q?${payload.q}`, { signal });
|
|
70
|
+
facts.results = await res.json();
|
|
71
|
+
}
|
|
72
|
+
);
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Implementation note: `recordReplayable()` is `cancellable(opts, innerHandler)` where `innerHandler` adds a `signal.addEventListener('abort')` around the user's handler. Timeout / supersession semantics are EXACTLY those of `cancellable()` — the HOC is purely additive. The abort listener fires synchronously, BEFORE the handler's pending await rejects with AbortError, so the callback sees the freshest possible state.
|
|
76
|
+
|
|
77
|
+
`onCancel` errors are caught and swallowed — the abort path stays clean.
|
|
78
|
+
|
|
79
|
+
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
|
+
|
|
81
|
+
### Patch Changes
|
|
82
|
+
|
|
83
|
+
- [`0d8cae5`](https://github.com/directive-run/directive/commit/0d8cae57e7e9b28ecb64e98588458a264dbd06c1) Thanks [@jasoncomes](https://github.com/jasoncomes)! - R5 hardening pack — production-readiness pass on the R2 ship
|
|
84
|
+
|
|
85
|
+
After the R5 AE-review-loop closed criticals, this pack lands the load-bearing DX/Arch findings so the substrate is ready for production use. No new commands; existing surfaces gain better docs, cleaner types, and consistent semantics.
|
|
86
|
+
|
|
87
|
+
**Documentation (R5 DX C3):**
|
|
88
|
+
|
|
89
|
+
- `@directive-run/timeline` README — replaces the outdated "v0.4 — diff mode (deferred)" Roadmap with shipped reality. New "Serialize, replay, bisect, diff" section walks all four operational entry points end-to-end with library + CLI examples for each.
|
|
90
|
+
- `@directive-run/cli` README — adds full sections for `directive replay`, `directive bisect` (with security note for `--assert`), and `directive timeline diff` (with exit-code documentation).
|
|
91
|
+
- `@directive-run/mutator` README — new "Recording cancellations for replay" section covers `recordReplayable()` end-to-end.
|
|
92
|
+
|
|
93
|
+
**Type ergonomics (R5 DX M1):**
|
|
94
|
+
|
|
95
|
+
- `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
|
+
|
|
97
|
+
**Exit-code consistency (R5 DX M3):**
|
|
98
|
+
|
|
99
|
+
- `directive bisect` now exits `2` on a "standard hit" (located the first failing frame). Aligns with `directive timeline diff` (exit 2 = differences found), so CI gates can branch uniformly: `0 = clean, 1 = CLI error, 2 = problem found / refused`. Documented in CLI README.
|
|
100
|
+
|
|
101
|
+
**Docstring corrections (R5 Arch M5):**
|
|
102
|
+
|
|
103
|
+
- `recordReplayable()` JSDoc reframed: the function is a generic "call me when abort fires" hook. Pinning into facts is one use case; Sentry breadcrumbs / Redux logs / OpenTelemetry / metrics are equally valid. Removes the misleading "pairs with timeline" framing that overstated the coupling.
|
|
104
|
+
|
|
105
|
+
**Tests:** +1 test verifying the new `BisectResult.kind` field across all four outcomes. Workspace: 4090 → 4091.
|
|
106
|
+
|
|
3
107
|
## 0.2.0
|
|
4
108
|
|
|
5
109
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -260,6 +260,52 @@ type CancelReason =
|
|
|
260
260
|
Use it inside handlers to distinguish how the cancellation arrived
|
|
261
261
|
(e.g. log a different message for timeouts vs supersession).
|
|
262
262
|
|
|
263
|
+
## Recording cancellations for replay (R2.B `recordReplayable()`)
|
|
264
|
+
|
|
265
|
+
`recordReplayable()` is `cancellable()` plus a synchronous `onCancel`
|
|
266
|
+
callback that fires the moment the AbortController calls `abort()` —
|
|
267
|
+
*before* the handler's pending await rejects with AbortError. The
|
|
268
|
+
callback receives a structured `CancelEvent` with the cancel kind,
|
|
269
|
+
payload, dispatch sequence, and a live facts reference, so you can
|
|
270
|
+
pin cancellations into a place that survives in the timeline.
|
|
271
|
+
|
|
272
|
+
Use this when you record a timeline (with `@directive-run/timeline`)
|
|
273
|
+
and want a replay or `directive bisect` to reason about *which*
|
|
274
|
+
dispatches were superseded vs which completed — not just see a
|
|
275
|
+
free-form error string.
|
|
276
|
+
|
|
277
|
+
```ts
|
|
278
|
+
import { defineMutator, recordReplayable } from '@directive-run/mutator';
|
|
279
|
+
|
|
280
|
+
interface MyFacts {
|
|
281
|
+
results: string[];
|
|
282
|
+
cancellations: Array<{ kind: string; queryAtCancel: string; seq: number }>;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const search = recordReplayable<MyFacts, { q: string }>(
|
|
286
|
+
{
|
|
287
|
+
supersedeOn: 'self',
|
|
288
|
+
timeoutMs: 3_000,
|
|
289
|
+
onCancel: ({ facts, kind, payload, dispatchSeq }) => {
|
|
290
|
+
// Pin the cancel event into facts so the timeline carries it.
|
|
291
|
+
facts.cancellations.push({
|
|
292
|
+
kind,
|
|
293
|
+
queryAtCancel: payload.q,
|
|
294
|
+
seq: dispatchSeq,
|
|
295
|
+
});
|
|
296
|
+
},
|
|
297
|
+
},
|
|
298
|
+
async ({ payload, facts, signal }) => {
|
|
299
|
+
const res = await fetch(`/q?${payload.q}`, { signal });
|
|
300
|
+
facts.results = await res.json();
|
|
301
|
+
},
|
|
302
|
+
);
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
`recordReplayable()` is implemented as `cancellable(opts, innerHandler)` where `innerHandler` adds an `addEventListener('abort')` around the user's handler — timeout/supersession semantics are exactly those of `cancellable()`. The callback is generic ("call me when abort fires"); pinning into facts is one use case among many. Wire `onCancel` to Sentry breadcrumbs, a Redux action log, or a metrics sink with equal ease.
|
|
306
|
+
|
|
307
|
+
`onCancel` errors are caught and swallowed — the abort path stays clean.
|
|
308
|
+
|
|
263
309
|
## Optimistic updates + rollback
|
|
264
310
|
|
|
265
311
|
A future `@directive-run/optimistic` package will integrate with this
|
package/dist/index.cjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
'use strict';var core=require('@directive-run/core');var
|
|
1
|
+
'use strict';var core=require('@directive-run/core');var g=500;function m(e){let n;if(e instanceof Error){let t;try{t=e.message;}catch{t="[mutator: error.message getter threw]";}n=typeof t=="string"?t:y(t);}else typeof e=="string"?n=e:n=y(e);return n.length<=g?n:`${n.slice(0,g-1)}\u2026`}function y(e){try{return String(e)}catch{return "[mutator: unstringifiable error]"}}function R(e){return {facts:{pendingMutation:core.t.object().nullable()},events:{MUTATE:void 0},requirements:{PROCESS_MUTATION:{}},eventHandlers:{MUTATE:(r,a)=>{r.pendingMutation={...a,status:"pending",error:null};}},constraints:{pendingMutation:{when:r=>r.pendingMutation!==null&&r.pendingMutation?.status==="pending",require:{type:"PROCESS_MUTATION"}}},resolvers:{mutationResolver:{requirement:"PROCESS_MUTATION",resolve:async(r,a)=>{let l=a.facts,u=l.pendingMutation;if(u===null)return;l.pendingMutation={...u,status:"running"};let f=Object.prototype.hasOwnProperty.call(e,u.kind)?e[u.kind]:void 0;if(typeof f!="function"){l.pendingMutation={...u,status:"failed",error:m(`[mutator] no handler registered for variant: ${String(u.kind)}`)};return}let P={facts:a.facts,payload:u.payload,requeue:a.requeue??(()=>{})};try{await f(P),l.pendingMutation?.status==="running"&&(l.pendingMutation=null);}catch(F){if(l.pendingMutation?.status!=="running")return;l.pendingMutation={...u,status:"failed",error:m(F)};}}}},initialPendingMutation:null}}function O(e,n){return {kind:e,payload:n??{},status:"pending",error:null}}var c=class extends Error{constructor(t,i){super(i??`[mutator] cancellable: ${t}`);this.kind=t;this.name="CancelError";}},p=class extends c{constructor(t){super("timeout",`[mutator] cancellable: timeout after ${t}ms`);this.afterMs=t;this.name="TimeoutCancelError";}},M=class extends c{constructor(){super("superseded","[mutator] cancellable: superseded by new dispatch"),this.name="SupersededCancelError";}},v={superseded:()=>new M,timeout:e=>new p(e)};function x(e,n){let t=e.supersedeOn??"self",i=e.timeoutMs,o=e.setTimeout??C,s;return async d=>{t==="self"&&s!==void 0&&s.abort(v.superseded());let r=new AbortController;s=r;let a;typeof i=="number"&&i>0&&(a=o(()=>{r.abort(v.timeout(i));},i));try{await n({facts:d.facts,payload:d.payload,requeue:d.requeue,signal:r.signal});}finally{try{a?.();}catch{}s===r&&(s=void 0);}}}function C(e,n){let t=globalThis.setTimeout(e,n);return ()=>globalThis.clearTimeout(t)}function S(e,n){let t=0;return x(e,async o=>{let s=++t,d=()=>{let r=o.signal.reason;if(!(r instanceof c))return;let a={kind:r.kind,afterMs:r instanceof p?r.afterMs:void 0,payload:o.payload,dispatchSeq:s,facts:o.facts};try{e.onCancel?.(a);}catch{}};o.signal.addEventListener("abort",d,{once:!0});try{await n(o);}finally{o.signal.removeEventListener("abort",d);}})}exports.CancelError=c;exports.SupersededCancelError=M;exports.TimeoutCancelError=p;exports.cancelReason=v;exports.cancellable=x;exports.defineMutator=R;exports.mutate=O;exports.recordReplayable=S;//# sourceMappingURL=index.cjs.map
|
|
2
2
|
//# sourceMappingURL=index.cjs.map
|
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","cancellable","opts","supersedeOn","timeoutMs","scheduleTimeout","defaultSetTimeout","priorController","controller","cancelTimeout","cb","ms","handle"],"mappings":"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","file":"index.cjs","sourcesContent":["/**\n * @directive-run/mutator\n *\n * Discriminated mutation helper. Collapses the manual `pendingAction`\n * ceremony — fact + event + constraint + resolver — into a typed handler\n * map.\n *\n * Background: across the 55-cycle Minglingo migration, 12 modules ended\n * up with the same shape:\n * - a nullable `pendingAction` fact holding a discriminated union\n * - an event that sets it\n * - a constraint that fires on non-null\n * - a resolver that switches on the discriminator and clears the fact\n *\n * That's ~50 lines of boilerplate per module times 12 modules. The\n * `defineMutator` helper below contributes all four pieces from a single\n * typed declaration, so a module that uses it spreads the fragments\n * into its `createModule` config and writes only the per-variant handler\n * bodies.\n *\n * @see ../README.md for the full API and a worked example.\n */\n\nimport { t } from \"@directive-run/core\";\n\n/**\n * A keyed map of variant payloads. Each key becomes a discriminator value\n * for the mutation, each value is the payload type for that variant.\n *\n * @example\n * ```ts\n * type MyMutations = {\n * submit: { values: FormValues };\n * cancel: {};\n * retry: { reason: string };\n * };\n * ```\n */\nexport type MutationMap = Record<string, Record<string, unknown>>;\n\n/**\n * The shape of a `pendingMutation` fact while a mutation is queued or\n * running.\n */\nexport type PendingMutation<M extends MutationMap> = {\n [K in keyof M]: {\n kind: K;\n payload: M[K];\n /**\n * `pending` — queued, constraint hasn't fired yet.\n * `running` — handler is in flight.\n * `failed` — handler threw; constraint stops firing. Caller can\n * inspect `error` and dispatch a fresh MUTATE to retry.\n */\n status: \"pending\" | \"running\" | \"failed\";\n /**\n * Error message from the previous run, if any. Plaintext only —\n * NEVER render this directly via `dangerouslySetInnerHTML` or\n * markdown. Truncated to 500 chars to bound XSS surface from\n * thrown messages that may echo user input.\n */\n error: string | null;\n };\n}[keyof M];\n\n/** Maximum length of a captured error message — bounded to limit XSS surface. */\nconst MAX_ERROR_LEN = 500;\n\n/**\n * Coerce a thrown value to a bounded plaintext string for storage on\n * `pendingMutation.error`. Defends against:\n * - non-Error throws (`throw \"string\"`, `throw 42`, `throw {}`)\n * - Errors with non-string `.message` (overridden numeric / Symbol /\n * buffer); naive `.length` / `.slice` would `TypeError` otherwise\n * - extremely long messages with attacker-influenced input\n *\n * Returns at most {@link MAX_ERROR_LEN} characters of plaintext.\n * Plaintext rendering is still the only supported path on the\n * consumer side; this is defense in depth, not an XSS sanitizer.\n *\n * (R2 sec M-R2-1.)\n */\nfunction truncateError(input: unknown): string {\n let str: string;\n if (input instanceof Error) {\n // Reading `.message` is wrapped in try/catch because a maliciously\n // constructed Error subclass may install a throwing getter on\n // `message`. Without this guard the throw escapes truncateError →\n // escapes the resolver's catch → propagates uncaught. (R4 backlog.)\n let raw: unknown;\n try {\n raw = input.message;\n } catch {\n raw = \"[mutator: error.message getter threw]\";\n }\n str = typeof raw === \"string\" ? raw : safeStringCoerce(raw);\n } else if (typeof input === \"string\") {\n str = input;\n } else {\n str = safeStringCoerce(input);\n }\n if (str.length <= MAX_ERROR_LEN) return str;\n return `${str.slice(0, MAX_ERROR_LEN - 1)}…`;\n}\n\n/** String() may throw for some Symbols + objects with hostile toString. */\nfunction safeStringCoerce(value: unknown): string {\n try {\n return String(value);\n } catch {\n return \"[mutator: unstringifiable error]\";\n }\n}\n\n/**\n * Handler context passed to each variant handler.\n *\n * Note: `deps` is NOT in the context. This matches the Directive resolver\n * idiom — close over deps from the outer module-factory scope:\n *\n * ```ts\n * function createFormModule(deps: FormDeps) {\n * const mut = defineMutator<FormMutations, FormFacts>({\n * submit: async ({ payload, facts }) => {\n * facts.values = await deps.submit(payload.values); // ← closure\n * },\n * });\n * return createModule('form', { ... });\n * }\n * ```\n */\nexport interface MutatorHandlerContext<F> {\n /** Live facts proxy. Reads are cache-tracked; writes invalidate. */\n facts: F;\n /**\n * Trigger a same-constraint re-fire after this handler returns. Useful\n * when one mutation cascades into another — without `requeue`, the next\n * mutation would stall behind same-flush suppression.\n *\n * @see https://docs.directive.run/testing/chained-pipelines\n */\n requeue: () => void;\n}\n\n/**\n * Variant handler body. Receives the typed payload + a context; returns\n * void or a Promise. Throwing is fine — the runtime captures into\n * `pendingMutation.error` before clearing the fact.\n */\nexport type MutationHandler<\n M extends MutationMap,\n K extends keyof M,\n F,\n> = keyof M[K] extends never\n ? (ctx: MutatorHandlerContext<F>) => void | Promise<void>\n : (\n ctx: MutatorHandlerContext<F> & { payload: M[K] },\n ) => void | Promise<void>;\n\n/**\n * The full handler map. Every variant in `M` MUST have a handler.\n */\nexport type MutationHandlers<M extends MutationMap, F> = {\n [K in keyof M]: MutationHandler<M, K, F>;\n};\n\n/**\n * The fragments returned by `defineMutator`. Spread each fragment into\n * the matching position of your `createModule` config.\n *\n * @internal Shape documented for type clarity; users typically just\n * spread.\n */\nexport interface MutatorFragments<M extends MutationMap, F> {\n /** Spread into `schema.facts`. Adds `pendingMutation`. */\n facts: {\n pendingMutation: ReturnType<typeof t.object>;\n };\n /** Spread into `schema.events`. Adds the `MUTATE` event. */\n events: {\n MUTATE: PendingMutation<M>;\n };\n /** Spread into `schema.requirements`. Adds `PROCESS_MUTATION`. */\n requirements: {\n PROCESS_MUTATION: Record<string, never>;\n };\n /** Spread into the `events` field. Sets pendingMutation on MUTATE. */\n eventHandlers: {\n MUTATE: (facts: F, payload: PendingMutation<M>) => void;\n };\n /** Spread into the `constraints` field. */\n constraints: {\n pendingMutation: {\n when: (facts: F) => boolean;\n require: { type: \"PROCESS_MUTATION\" };\n };\n };\n /** Spread into the `resolvers` field. */\n resolvers: {\n mutationResolver: {\n requirement: \"PROCESS_MUTATION\";\n resolve: (\n req: { type: \"PROCESS_MUTATION\" },\n ctx: { facts: F },\n ) => Promise<void>;\n };\n };\n /**\n * Convenience: the initial value for `pendingMutation`. Set this in\n * your module's `init` if you don't use `t.X().default(...)` defaults.\n */\n initialPendingMutation: null;\n}\n\n/**\n * Define a mutator fragment-set for a given variant map and handlers.\n *\n * @example\n * ```ts\n * type FormMutations = {\n * submit: { values: FormValues };\n * cancel: {};\n * };\n *\n * // The second generic is the FACTS type (not deps). Deps are\n * // captured in closure from the surrounding scope, not passed in\n * // through the handler ctx.\n * const formMutator = defineMutator<FormMutations, FormFacts>({\n * submit: async ({ payload, facts }) => {\n * facts.values = await deps.submit(payload.values); // deps closes over outer scope\n * },\n * cancel: ({ facts }) => { facts.values = []; },\n * });\n *\n * createModule('form', {\n * schema: {\n * facts: { ...formMutator.facts, values: t.array<FormValues>() },\n * events: { ...formMutator.events, REFRESH: {} },\n * requirements: { ...formMutator.requirements },\n * },\n * init: (f) => { f.pendingMutation = null; f.values = []; },\n * events: {\n * ...formMutator.eventHandlers,\n * REFRESH: (f) => { f.values = []; },\n * },\n * constraints: { ...formMutator.constraints },\n * resolvers: { ...formMutator.resolvers },\n * });\n *\n * // Usage:\n * sys.events.MUTATE(mutate<FormMutations>('submit', { values: ... }));\n *\n * // Or, if constructing the payload manually, the discriminator is\n * // `kind` (NOT `type` — `type` collides with Directive's event-name\n * // dispatch convention):\n * sys.events.MUTATE({\n * kind: 'submit',\n * payload: { values: ... },\n * status: 'pending',\n * error: null,\n * });\n * ```\n *\n * The handler-bound deps are captured at `defineMutator` call time. To\n * inject deps from the caller, wrap `defineMutator` in your module\n * factory:\n *\n * ```ts\n * export function createFormModule(deps: FormDeps) {\n * const mut = defineMutator<FormMutations, FormFacts>({\n * submit: async ({ payload, facts }) => {\n * facts.values = await deps.submit(payload.values);\n * },\n * cancel: ({ facts }) => { facts.values = []; },\n * });\n * return createModule('form', { ... });\n * }\n * ```\n */\nexport function defineMutator<\n M extends MutationMap,\n F = Record<string, unknown>,\n>(handlers: MutationHandlers<M, F>): MutatorFragments<M, F> {\n type Pending = PendingMutation<M>;\n\n const facts = {\n pendingMutation: t.object<Pending>().nullable() as ReturnType<\n typeof t.object\n >,\n } as MutatorFragments<M, F>[\"facts\"];\n\n // Schema event marker — the runtime uses this for typing and devtools.\n // Payload validation happens at dispatch time via t-schema check.\n const events = {\n MUTATE: undefined as unknown as Pending,\n } as MutatorFragments<M, F>[\"events\"];\n\n const requirements = {\n PROCESS_MUTATION: {} as Record<string, never>,\n } as MutatorFragments<M, F>[\"requirements\"];\n\n const eventHandlers = {\n MUTATE: (facts: F, payload: Pending) => {\n // Overwrite is intentional — caller is responsible for ordering.\n // If a previous mutation is mid-flight, the new one queues by\n // overwriting; the in-flight handler will null the fact when it\n // completes, then the constraint re-fires for the new one.\n // (Same as the manual pattern across all 12 audited modules.)\n (facts as { pendingMutation: Pending | null }).pendingMutation = {\n ...payload,\n status: \"pending\",\n error: null,\n };\n },\n } as MutatorFragments<M, F>[\"eventHandlers\"];\n\n const constraints = {\n pendingMutation: {\n when: (facts: F) =>\n (facts as { pendingMutation: Pending | null }).pendingMutation !==\n null &&\n (facts as { pendingMutation: Pending | null }).pendingMutation\n ?.status === \"pending\",\n require: { type: \"PROCESS_MUTATION\" } as const,\n },\n } as MutatorFragments<M, F>[\"constraints\"];\n\n const resolvers = {\n mutationResolver: {\n requirement: \"PROCESS_MUTATION\" as const,\n resolve: async (\n _req: { type: \"PROCESS_MUTATION\" },\n ctx: {\n facts: F;\n requeue?: () => void;\n },\n ) => {\n const factsRef = ctx.facts as { pendingMutation: Pending | null };\n const pending = factsRef.pendingMutation;\n if (pending === null) return;\n\n // Stamp the in-flight transition with the running status. The\n // status itself is the supersession marker: only this resolver\n // ever writes 'running'; the eventHandler always writes\n // 'pending'. So if a fresh MUTATE arrives mid-flight, the\n // post-handler check sees status: 'pending' instead of\n // 'running' and knows to leave the new dispatch alone.\n // (Sec M5 / DX M2.)\n factsRef.pendingMutation = { ...pending, status: \"running\" };\n\n // Prototype-pollution defense: only accept handlers OWNED by\n // the user-provided map. Without this, dispatching with kind:\n // 'constructor' / 'toString' / '__proto__' would resolve via\n // the prototype chain and either crash or invoke an inherited\n // function with arbitrary payload. (Sec C1.)\n const ownsHandler = Object.prototype.hasOwnProperty.call(\n handlers,\n pending.kind as PropertyKey,\n );\n const handler = ownsHandler\n ? (handlers as Record<string, unknown>)[pending.kind as string]\n : undefined;\n\n if (typeof handler !== \"function\") {\n factsRef.pendingMutation = {\n ...pending,\n status: \"failed\",\n error: truncateError(\n `[mutator] no handler registered for variant: ${String(\n pending.kind,\n )}`,\n ),\n };\n return;\n }\n\n const handlerCtx = {\n facts: ctx.facts,\n payload: pending.payload,\n requeue: ctx.requeue ?? (() => {}),\n };\n\n try {\n await (handler as (c: typeof handlerCtx) => Promise<void> | void)(\n handlerCtx,\n );\n // Success: clear the fact ONLY if this resolver's `running`\n // marker is still live (status === 'running'). If a fresh\n // MUTATE arrived mid-flight via the eventHandler, the fact\n // now has status: 'pending' — leave it alone so the next\n // constraint fire picks it up. (Sec M5 / DX M2.)\n if (factsRef.pendingMutation?.status === \"running\") {\n factsRef.pendingMutation = null;\n }\n } catch (err) {\n // Failure: only stamp the error if our running marker is\n // still live. If a fresh MUTATE arrived mid-flight, the new\n // dispatch wins; the failed mutation's error is dropped on\n // the floor (caller wanted to move on anyway). (Sec M5.)\n if (factsRef.pendingMutation?.status !== \"running\") return;\n factsRef.pendingMutation = {\n ...pending,\n // 'failed' is a distinct status (Sec M6 / Arch C2) — UI can\n // disambiguate \"still in flight\" from \"stopped on error\".\n status: \"failed\",\n // truncateError handles the unknown shape safely — non-Error\n // throws, non-string Error.message, hostile toString. (R2\n // sec M-R2-1.)\n error: truncateError(err),\n };\n }\n },\n },\n } as MutatorFragments<M, F>[\"resolvers\"];\n\n return {\n facts,\n events,\n requirements,\n eventHandlers,\n constraints,\n resolvers,\n initialPendingMutation: null,\n };\n}\n\n/**\n * Helper for typed dispatch. Lets the caller construct a mutation payload\n * with full type narrowing — the `kind` field auto-restricts the\n * `payload` shape.\n *\n * @example\n * ```ts\n * sys.events.MUTATE(mutate<FormMutations>('submit', { values: ... }));\n * sys.events.MUTATE(mutate<FormMutations>('cancel'));\n * ```\n */\n// Single type parameter (M) so callers can specialize with\n// `mutate<FormMutations>(...)` without TypeScript demanding the\n// inferred K. TS's strict type-argument rules treat <M, K> as\n// \"supply both or supply neither\" — single-arg call sites failed\n// with \"Expected 2 type arguments, but got 1.\" Single-M form sidesteps\n// that while keeping payload typing tight via the `kind` lookup.\nexport function mutate<M extends MutationMap>(\n kind: keyof M & string,\n payload: M[typeof kind],\n): PendingMutation<M>;\nexport function mutate<M extends MutationMap>(\n kind: keyof M & string,\n): PendingMutation<M>;\nexport function mutate<M extends MutationMap>(\n kind: keyof M & string,\n payload?: M[typeof kind],\n): PendingMutation<M> {\n return {\n kind,\n payload: (payload ?? {}) as M[typeof kind],\n status: \"pending\",\n error: null,\n } as PendingMutation<M>;\n}\n\n// ============================================================================\n// cancellable() — auto-cancel-on-supersede (R1.C v0.1)\n// ============================================================================\n\n/**\n * Options for {@link cancellable}.\n */\nexport interface CancellableOptions {\n /**\n * When to fire the AbortSignal that the wrapped handler receives.\n *\n * - `'self'` (default): a new dispatch of the SAME handler aborts the\n * prior in-flight invocation. The classic \"cancel previous on new\n * keystroke\" pattern.\n * - `'never'`: only the timeout fires the signal; new dispatches do\n * NOT abort prior ones. Useful when you want a hard timeout but\n * parallel runs are fine.\n */\n supersedeOn?: \"self\" | \"never\";\n\n /**\n * Maximum ms a handler may run before its signal is aborted. Counted\n * from the start of THIS invocation. Combine with `realClock` for\n * production or pass a `setTimeout` shim for deterministic testing.\n */\n timeoutMs?: number;\n\n /**\n * Optional `setTimeout` injection. Defaults to `globalThis.setTimeout`.\n * For deterministic tests, pass `virtualClock.setTimeout` from\n * `@directive-run/core` so the timeout fires under\n * `clock.advanceBy()` instead of wall-clock real time.\n *\n * @example\n * ```ts\n * import { virtualClock } from '@directive-run/core';\n * const clock = virtualClock(0);\n * cancellable({ timeoutMs: 1_000, setTimeout: clock.setTimeout }, handler);\n * ```\n */\n setTimeout?: (cb: () => void, ms: number) => () => void;\n}\n\n/**\n * Handler context augmented with a cancellation signal.\n */\nexport interface CancellableHandlerContext<F, P> {\n facts: F;\n payload: P;\n /** Aborts when a new dispatch supersedes this one OR the timeout fires. */\n signal: AbortSignal;\n requeue: () => void;\n}\n\n/**\n * Reason a cancellable handler's signal aborted. Stamped on\n * `signal.reason` so the handler can disambiguate.\n */\nexport type CancelReason =\n | { kind: \"superseded\" }\n | { kind: \"timeout\"; afterMs: number };\n\n/**\n * Wrap a mutator handler with auto-cancellation. The wrapped handler\n * receives an extra `signal: AbortSignal` in its context. Use the\n * signal to short-circuit awaitable work — pass it to `fetch(url, {\n * signal })`, watch it inside long-running loops, etc.\n *\n * Two cancellation triggers, both opt-in via {@link CancellableOptions}:\n *\n * 1. **Supersession** (default `supersedeOn: 'self'`): when a new\n * dispatch of the same wrapped handler arrives while a prior\n * invocation is still running, the prior signal aborts.\n * 2. **Timeout** (default `timeoutMs: undefined`, meaning no timeout):\n * after `timeoutMs` ms from invocation start, the signal aborts.\n *\n * If a handler's signal aborts, the handler should observe the abort\n * (via `signal.aborted` or the AbortError-throwing helpers) and\n * return promptly. The signal's `reason` carries a {@link CancelReason}\n * disambiguating which trigger fired.\n *\n * Compose with {@link defineMutator} by using `cancellable()` directly\n * in the handler map:\n *\n * @example\n * ```ts\n * import { defineMutator, cancellable } from '@directive-run/mutator';\n *\n * const formMutator = defineMutator<MyMutations, MyFacts>({\n * search: cancellable(\n * { supersedeOn: 'self', timeoutMs: 3_000 },\n * async ({ payload, facts, signal }) => {\n * const res = await fetch(`/q?${payload.q}`, { signal });\n * facts.results = await res.json();\n * },\n * ),\n * submit: async ({ payload, facts }) => {\n * // No cancellation needed for submit — plain handler.\n * facts.values = await deps.submit(payload.values);\n * },\n * });\n * ```\n *\n * **Idempotency note.** The wrapped handler stays a regular\n * `MutationHandler<M, K, F>` from the mutator's perspective. The\n * supersession registry is closure-scoped per `cancellable()` call —\n * two separate `cancellable(...)` HOCs around different handlers do\n * NOT cancel each other.\n *\n * **Test ergonomics.** Pass `virtualClock.setTimeout` via the\n * `setTimeout` option to make timeouts deterministic under\n * `clock.advanceBy(ms)`. Without that, timeouts use wall-clock\n * `globalThis.setTimeout` and are real-time.\n */\nexport function cancellable<F, P>(\n opts: CancellableOptions,\n handler: (ctx: CancellableHandlerContext<F, P>) => Promise<void> | void,\n): (ctx: { facts: F; payload: P; requeue: () => void }) => Promise<void> {\n const supersedeOn = opts.supersedeOn ?? \"self\";\n const timeoutMs = opts.timeoutMs;\n const scheduleTimeout = opts.setTimeout ?? defaultSetTimeout;\n\n // Closure-scoped supersession slot — one entry for the wrapped\n // handler. When a new invocation arrives, the prior entry's\n // controller aborts before the new one starts.\n let priorController: AbortController | undefined;\n\n return async (ctx: { facts: F; payload: P; requeue: () => void }) => {\n // Supersession: abort the prior in-flight invocation, if any.\n if (supersedeOn === \"self\" && priorController !== undefined) {\n priorController.abort({ kind: \"superseded\" } satisfies CancelReason);\n }\n\n const controller = new AbortController();\n priorController = controller;\n\n // Timeout: schedule an abort after `timeoutMs`. The cancel handle\n // returned by `scheduleTimeout` lets us clear the timer if the\n // handler completes first (saves leaking timers under a barrage\n // of dispatches).\n let cancelTimeout: (() => void) | undefined;\n if (typeof timeoutMs === \"number\" && timeoutMs > 0) {\n cancelTimeout = scheduleTimeout(() => {\n controller.abort({ kind: \"timeout\", afterMs: timeoutMs } satisfies CancelReason);\n }, timeoutMs);\n }\n\n try {\n await handler({\n facts: ctx.facts,\n payload: ctx.payload,\n requeue: ctx.requeue,\n signal: controller.signal,\n });\n } finally {\n // Clean up: clear the timeout (if it hasn't fired) and release\n // the supersession slot if it still belongs to this invocation.\n cancelTimeout?.();\n if (priorController === controller) {\n priorController = undefined;\n }\n }\n };\n}\n\n/**\n * Default `setTimeout` shim — wraps `globalThis.setTimeout` to match\n * the cancel-handle signature `cancellable()` expects.\n */\nfunction defaultSetTimeout(cb: () => void, ms: number): () => void {\n const handle = globalThis.setTimeout(cb, ms);\n return () => globalThis.clearTimeout(handle);\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"names":["MAX_ERROR_LEN","truncateError","input","str","raw","safeStringCoerce","value","defineMutator","handlers","t","facts","payload","_req","ctx","factsRef","pending","handler","handlerCtx","err","mutate","kind","CancelError","message","TimeoutCancelError","afterMs","SupersededCancelError","cancelReason","cancellable","opts","supersedeOn","timeoutMs","scheduleTimeout","defaultSetTimeout","priorController","controller","cancelTimeout","cb","ms","handle","recordReplayable","dispatchSeq","seq","onAbort","reason","info"],"mappings":"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","file":"index.cjs","sourcesContent":["/**\n * @directive-run/mutator\n *\n * Discriminated mutation helper. Collapses the manual `pendingAction`\n * ceremony — fact + event + constraint + resolver — into a typed handler\n * map.\n *\n * Background: across the 55-cycle Minglingo migration, 12 modules ended\n * up with the same shape:\n * - a nullable `pendingAction` fact holding a discriminated union\n * - an event that sets it\n * - a constraint that fires on non-null\n * - a resolver that switches on the discriminator and clears the fact\n *\n * That's ~50 lines of boilerplate per module times 12 modules. The\n * `defineMutator` helper below contributes all four pieces from a single\n * typed declaration, so a module that uses it spreads the fragments\n * into its `createModule` config and writes only the per-variant handler\n * bodies.\n *\n * @see ../README.md for the full API and a worked example.\n */\n\nimport { t } from \"@directive-run/core\";\n\n/**\n * A keyed map of variant payloads. Each key becomes a discriminator value\n * for the mutation, each value is the payload type for that variant.\n *\n * @example\n * ```ts\n * type MyMutations = {\n * submit: { values: FormValues };\n * cancel: {};\n * retry: { reason: string };\n * };\n * ```\n */\nexport type MutationMap = Record<string, Record<string, unknown>>;\n\n/**\n * The shape of a `pendingMutation` fact while a mutation is queued or\n * running.\n */\nexport type PendingMutation<M extends MutationMap> = {\n [K in keyof M]: {\n kind: K;\n payload: M[K];\n /**\n * `pending` — queued, constraint hasn't fired yet.\n * `running` — handler is in flight.\n * `failed` — handler threw; constraint stops firing. Caller can\n * inspect `error` and dispatch a fresh MUTATE to retry.\n */\n status: \"pending\" | \"running\" | \"failed\";\n /**\n * Error message from the previous run, if any. Plaintext only —\n * NEVER render this directly via `dangerouslySetInnerHTML` or\n * markdown. Truncated to 500 chars to bound XSS surface from\n * thrown messages that may echo user input.\n */\n error: string | null;\n };\n}[keyof M];\n\n/** Maximum length of a captured error message — bounded to limit XSS surface. */\nconst MAX_ERROR_LEN = 500;\n\n/**\n * Coerce a thrown value to a bounded plaintext string for storage on\n * `pendingMutation.error`. Defends against:\n * - non-Error throws (`throw \"string\"`, `throw 42`, `throw {}`)\n * - Errors with non-string `.message` (overridden numeric / Symbol /\n * buffer); naive `.length` / `.slice` would `TypeError` otherwise\n * - extremely long messages with attacker-influenced input\n *\n * Returns at most {@link MAX_ERROR_LEN} characters of plaintext.\n * Plaintext rendering is still the only supported path on the\n * consumer side; this is defense in depth, not an XSS sanitizer.\n *\n * (R2 sec M-R2-1.)\n */\nfunction truncateError(input: unknown): string {\n let str: string;\n if (input instanceof Error) {\n // Reading `.message` is wrapped in try/catch because a maliciously\n // constructed Error subclass may install a throwing getter on\n // `message`. Without this guard the throw escapes truncateError →\n // escapes the resolver's catch → propagates uncaught. (R4 backlog.)\n let raw: unknown;\n try {\n raw = input.message;\n } catch {\n raw = \"[mutator: error.message getter threw]\";\n }\n str = typeof raw === \"string\" ? raw : safeStringCoerce(raw);\n } else if (typeof input === \"string\") {\n str = input;\n } else {\n str = safeStringCoerce(input);\n }\n if (str.length <= MAX_ERROR_LEN) return str;\n return `${str.slice(0, MAX_ERROR_LEN - 1)}…`;\n}\n\n/** String() may throw for some Symbols + objects with hostile toString. */\nfunction safeStringCoerce(value: unknown): string {\n try {\n return String(value);\n } catch {\n return \"[mutator: unstringifiable error]\";\n }\n}\n\n/**\n * Handler context passed to each variant handler.\n *\n * Note: `deps` is NOT in the context. This matches the Directive resolver\n * idiom — close over deps from the outer module-factory scope:\n *\n * ```ts\n * function createFormModule(deps: FormDeps) {\n * const mut = defineMutator<FormMutations, FormFacts>({\n * submit: async ({ payload, facts }) => {\n * facts.values = await deps.submit(payload.values); // ← closure\n * },\n * });\n * return createModule('form', { ... });\n * }\n * ```\n */\nexport interface MutatorHandlerContext<F> {\n /** Live facts proxy. Reads are cache-tracked; writes invalidate. */\n facts: F;\n /**\n * Trigger a same-constraint re-fire after this handler returns. Useful\n * when one mutation cascades into another — without `requeue`, the next\n * mutation would stall behind same-flush suppression.\n *\n * @see https://docs.directive.run/testing/chained-pipelines\n */\n requeue: () => void;\n}\n\n/**\n * Variant handler body. Receives the typed payload + a context; returns\n * void or a Promise. Throwing is fine — the runtime captures into\n * `pendingMutation.error` before clearing the fact.\n */\nexport type MutationHandler<\n M extends MutationMap,\n K extends keyof M,\n F,\n> = keyof M[K] extends never\n ? (ctx: MutatorHandlerContext<F>) => void | Promise<void>\n : (\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"]}
|
package/dist/index.d.cts
CHANGED
|
@@ -282,6 +282,12 @@ interface CancellableHandlerContext<F, P> {
|
|
|
282
282
|
/**
|
|
283
283
|
* Reason a cancellable handler's signal aborted. Stamped on
|
|
284
284
|
* `signal.reason` so the handler can disambiguate.
|
|
285
|
+
*
|
|
286
|
+
* Note: at runtime the value is a {@link CancelError} subclass with
|
|
287
|
+
* the same shape — `signal.reason instanceof CancelError` is the
|
|
288
|
+
* canonical check. Older usages that did `signal.reason?.kind ===
|
|
289
|
+
* 'superseded'` continue to work because the Error subclass exposes
|
|
290
|
+
* the same `kind` field. (R2 sec M-1.)
|
|
285
291
|
*/
|
|
286
292
|
type CancelReason = {
|
|
287
293
|
kind: "superseded";
|
|
@@ -289,6 +295,36 @@ type CancelReason = {
|
|
|
289
295
|
kind: "timeout";
|
|
290
296
|
afterMs: number;
|
|
291
297
|
};
|
|
298
|
+
/**
|
|
299
|
+
* Runtime carrier for {@link CancelReason}. Subclasses `Error` so the
|
|
300
|
+
* value survives transit through `fetch(url, { signal })` and other
|
|
301
|
+
* web-platform APIs that re-throw `signal.reason`. Older mutator
|
|
302
|
+
* versions passed plain objects, which `.catch(err => err instanceof
|
|
303
|
+
* Error)` filters silently dropped. (R2 sec M-1.)
|
|
304
|
+
*/
|
|
305
|
+
declare class CancelError extends Error {
|
|
306
|
+
readonly kind: CancelReason["kind"];
|
|
307
|
+
constructor(kind: CancelReason["kind"], message?: string);
|
|
308
|
+
}
|
|
309
|
+
/** Subclass of {@link CancelError} for timeout-driven aborts. */
|
|
310
|
+
declare class TimeoutCancelError extends CancelError {
|
|
311
|
+
readonly afterMs: number;
|
|
312
|
+
constructor(afterMs: number);
|
|
313
|
+
}
|
|
314
|
+
/** Subclass of {@link CancelError} for supersede-driven aborts. */
|
|
315
|
+
declare class SupersededCancelError extends CancelError {
|
|
316
|
+
constructor();
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Factory for cancel-reason values. Pure — these are the runtime
|
|
320
|
+
* counterparts of the {@link CancelReason} type. Use them in custom
|
|
321
|
+
* cancellation flows that need a typed reason without re-typing the
|
|
322
|
+
* literal.
|
|
323
|
+
*/
|
|
324
|
+
declare const cancelReason: {
|
|
325
|
+
readonly superseded: () => SupersededCancelError;
|
|
326
|
+
readonly timeout: (afterMs: number) => TimeoutCancelError;
|
|
327
|
+
};
|
|
292
328
|
/**
|
|
293
329
|
* Wrap a mutator handler with auto-cancellation. The wrapped handler
|
|
294
330
|
* receives an extra `signal: AbortSignal` in its context. Use the
|
|
@@ -346,5 +382,105 @@ declare function cancellable<F, P>(opts: CancellableOptions, handler: (ctx: Canc
|
|
|
346
382
|
payload: P;
|
|
347
383
|
requeue: () => void;
|
|
348
384
|
}) => Promise<void>;
|
|
385
|
+
/**
|
|
386
|
+
* Structured event surfaced to {@link RecordReplayableOptions.onCancel}
|
|
387
|
+
* the moment a wrapped invocation's signal aborts. Carries enough
|
|
388
|
+
* information for the caller to pin the cancellation into the timeline
|
|
389
|
+
* (e.g. by writing onto a facts array) so a later replay can
|
|
390
|
+
* reconstruct the cancellation race exactly.
|
|
391
|
+
*
|
|
392
|
+
* @typeParam F — caller's facts type (passed through unchanged from the handler context).
|
|
393
|
+
* @typeParam P — caller's payload type for THIS handler.
|
|
394
|
+
*/
|
|
395
|
+
interface CancelEvent<F, P> {
|
|
396
|
+
/** Whether this cancel was driven by a new dispatch superseding the old, or by the timeout firing. */
|
|
397
|
+
kind: CancelReason["kind"];
|
|
398
|
+
/** When `kind === 'timeout'`, the original `timeoutMs` that fired. Undefined for `'superseded'`. */
|
|
399
|
+
afterMs?: number;
|
|
400
|
+
/** The payload of the dispatch that was cancelled — i.e. the work that did NOT complete. */
|
|
401
|
+
payload: P;
|
|
402
|
+
/**
|
|
403
|
+
* Per-handler monotonic counter, incremented once at the start of
|
|
404
|
+
* every invocation. Useful for timeline diff: cancel of seq=4 is the
|
|
405
|
+
* 4th dispatch this handler ever saw. The counter is closure-scoped
|
|
406
|
+
* to a single `recordReplayable()` call — separate HOCs do NOT share
|
|
407
|
+
* the counter.
|
|
408
|
+
*/
|
|
409
|
+
dispatchSeq: number;
|
|
410
|
+
/** Live facts reference, mirroring {@link CancellableHandlerContext.facts}. */
|
|
411
|
+
facts: F;
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Options for {@link recordReplayable}. Strict superset of
|
|
415
|
+
* {@link CancellableOptions} — every field on `CancellableOptions`
|
|
416
|
+
* passes through unchanged to the underlying {@link cancellable} call.
|
|
417
|
+
*/
|
|
418
|
+
interface RecordReplayableOptions<F, P> extends CancellableOptions {
|
|
419
|
+
/**
|
|
420
|
+
* Synchronous callback invoked the moment a wrapped invocation's
|
|
421
|
+
* AbortController fires `abort()`. Runs BEFORE the handler's pending
|
|
422
|
+
* await rejects with AbortError, so the callback sees the freshest
|
|
423
|
+
* possible state.
|
|
424
|
+
*
|
|
425
|
+
* Use this to pin the cancellation into a place that survives in the
|
|
426
|
+
* timeline (typically a facts field — `facts.cancellations.push(info)`).
|
|
427
|
+
* Without that, a replay re-dispatches the same MUTATE events but has
|
|
428
|
+
* no record of which ones were superseded vs which completed.
|
|
429
|
+
*
|
|
430
|
+
* Throws inside `onCancel` are caught and swallowed — the abort path
|
|
431
|
+
* must remain robust. If you need to fail loudly, log to your own
|
|
432
|
+
* sink before re-throwing.
|
|
433
|
+
*/
|
|
434
|
+
onCancel?: (info: CancelEvent<F, P>) => void;
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* `cancellable()` plus a synchronous on-abort callback. Wrap your
|
|
438
|
+
* handler with `recordReplayable()` instead of `cancellable()` when
|
|
439
|
+
* you want a hook that fires the moment a supersession or timeout
|
|
440
|
+
* aborts the in-flight invocation — *before* the handler's pending
|
|
441
|
+
* await rejects with AbortError. The callback receives a structured
|
|
442
|
+
* {@link CancelEvent} carrying the cancel kind, payload, dispatch
|
|
443
|
+
* sequence, and a live facts reference.
|
|
444
|
+
*
|
|
445
|
+
* The callback is GENERIC ("call me when abort fires"); the most
|
|
446
|
+
* common use case is pinning cancel events into facts so a recorded
|
|
447
|
+
* timeline carries them — which then lets `replayTimeline()`,
|
|
448
|
+
* `bisectTimeline()`, and `diffTimelines()` (all in
|
|
449
|
+
* `@directive-run/timeline`) reason about which dispatches were
|
|
450
|
+
* superseded vs which completed without parsing free-form error
|
|
451
|
+
* strings. But the same callback works equally well for Sentry
|
|
452
|
+
* breadcrumbs, a Redux action log, OpenTelemetry spans, or a metrics
|
|
453
|
+
* sink — `recordReplayable` doesn't know or care about the timeline.
|
|
454
|
+
*
|
|
455
|
+
* @example Pin cancel events into facts so the timeline carries them:
|
|
456
|
+
* ```ts
|
|
457
|
+
* import { defineMutator, recordReplayable } from '@directive-run/mutator';
|
|
458
|
+
*
|
|
459
|
+
* const search = recordReplayable<MyFacts, { q: string }>(
|
|
460
|
+
* {
|
|
461
|
+
* supersedeOn: 'self',
|
|
462
|
+
* timeoutMs: 3_000,
|
|
463
|
+
* onCancel: ({ facts, kind, payload, dispatchSeq }) => {
|
|
464
|
+
* facts.cancellations.push({ kind, queryAtCancel: payload.q, seq: dispatchSeq });
|
|
465
|
+
* },
|
|
466
|
+
* },
|
|
467
|
+
* async ({ payload, facts, signal }) => {
|
|
468
|
+
* const res = await fetch(`/q?${payload.q}`, { signal });
|
|
469
|
+
* facts.results = await res.json();
|
|
470
|
+
* },
|
|
471
|
+
* );
|
|
472
|
+
* ```
|
|
473
|
+
*
|
|
474
|
+
* Implementation note: `recordReplayable()` is `cancellable(opts,
|
|
475
|
+
* innerHandler)` where `innerHandler` adds an `addEventListener('abort')`
|
|
476
|
+
* around the user's handler. This means timeout / supersession
|
|
477
|
+
* semantics are EXACTLY those of `cancellable()` — the HOC is purely
|
|
478
|
+
* additive.
|
|
479
|
+
*/
|
|
480
|
+
declare function recordReplayable<F, P>(opts: RecordReplayableOptions<F, P>, handler: (ctx: CancellableHandlerContext<F, P>) => Promise<void> | void): (ctx: {
|
|
481
|
+
facts: F;
|
|
482
|
+
payload: P;
|
|
483
|
+
requeue: () => void;
|
|
484
|
+
}) => Promise<void>;
|
|
349
485
|
|
|
350
|
-
export { type CancelReason, type CancellableHandlerContext, type CancellableOptions, type MutationHandler, type MutationHandlers, type MutationMap, type MutatorFragments, type MutatorHandlerContext, type PendingMutation, cancellable, defineMutator, mutate };
|
|
486
|
+
export { CancelError, type CancelEvent, type CancelReason, type CancellableHandlerContext, type CancellableOptions, type MutationHandler, type MutationHandlers, type MutationMap, type MutatorFragments, type MutatorHandlerContext, type PendingMutation, type RecordReplayableOptions, SupersededCancelError, TimeoutCancelError, cancelReason, cancellable, defineMutator, mutate, recordReplayable };
|
package/dist/index.d.ts
CHANGED
|
@@ -282,6 +282,12 @@ interface CancellableHandlerContext<F, P> {
|
|
|
282
282
|
/**
|
|
283
283
|
* Reason a cancellable handler's signal aborted. Stamped on
|
|
284
284
|
* `signal.reason` so the handler can disambiguate.
|
|
285
|
+
*
|
|
286
|
+
* Note: at runtime the value is a {@link CancelError} subclass with
|
|
287
|
+
* the same shape — `signal.reason instanceof CancelError` is the
|
|
288
|
+
* canonical check. Older usages that did `signal.reason?.kind ===
|
|
289
|
+
* 'superseded'` continue to work because the Error subclass exposes
|
|
290
|
+
* the same `kind` field. (R2 sec M-1.)
|
|
285
291
|
*/
|
|
286
292
|
type CancelReason = {
|
|
287
293
|
kind: "superseded";
|
|
@@ -289,6 +295,36 @@ type CancelReason = {
|
|
|
289
295
|
kind: "timeout";
|
|
290
296
|
afterMs: number;
|
|
291
297
|
};
|
|
298
|
+
/**
|
|
299
|
+
* Runtime carrier for {@link CancelReason}. Subclasses `Error` so the
|
|
300
|
+
* value survives transit through `fetch(url, { signal })` and other
|
|
301
|
+
* web-platform APIs that re-throw `signal.reason`. Older mutator
|
|
302
|
+
* versions passed plain objects, which `.catch(err => err instanceof
|
|
303
|
+
* Error)` filters silently dropped. (R2 sec M-1.)
|
|
304
|
+
*/
|
|
305
|
+
declare class CancelError extends Error {
|
|
306
|
+
readonly kind: CancelReason["kind"];
|
|
307
|
+
constructor(kind: CancelReason["kind"], message?: string);
|
|
308
|
+
}
|
|
309
|
+
/** Subclass of {@link CancelError} for timeout-driven aborts. */
|
|
310
|
+
declare class TimeoutCancelError extends CancelError {
|
|
311
|
+
readonly afterMs: number;
|
|
312
|
+
constructor(afterMs: number);
|
|
313
|
+
}
|
|
314
|
+
/** Subclass of {@link CancelError} for supersede-driven aborts. */
|
|
315
|
+
declare class SupersededCancelError extends CancelError {
|
|
316
|
+
constructor();
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Factory for cancel-reason values. Pure — these are the runtime
|
|
320
|
+
* counterparts of the {@link CancelReason} type. Use them in custom
|
|
321
|
+
* cancellation flows that need a typed reason without re-typing the
|
|
322
|
+
* literal.
|
|
323
|
+
*/
|
|
324
|
+
declare const cancelReason: {
|
|
325
|
+
readonly superseded: () => SupersededCancelError;
|
|
326
|
+
readonly timeout: (afterMs: number) => TimeoutCancelError;
|
|
327
|
+
};
|
|
292
328
|
/**
|
|
293
329
|
* Wrap a mutator handler with auto-cancellation. The wrapped handler
|
|
294
330
|
* receives an extra `signal: AbortSignal` in its context. Use the
|
|
@@ -346,5 +382,105 @@ declare function cancellable<F, P>(opts: CancellableOptions, handler: (ctx: Canc
|
|
|
346
382
|
payload: P;
|
|
347
383
|
requeue: () => void;
|
|
348
384
|
}) => Promise<void>;
|
|
385
|
+
/**
|
|
386
|
+
* Structured event surfaced to {@link RecordReplayableOptions.onCancel}
|
|
387
|
+
* the moment a wrapped invocation's signal aborts. Carries enough
|
|
388
|
+
* information for the caller to pin the cancellation into the timeline
|
|
389
|
+
* (e.g. by writing onto a facts array) so a later replay can
|
|
390
|
+
* reconstruct the cancellation race exactly.
|
|
391
|
+
*
|
|
392
|
+
* @typeParam F — caller's facts type (passed through unchanged from the handler context).
|
|
393
|
+
* @typeParam P — caller's payload type for THIS handler.
|
|
394
|
+
*/
|
|
395
|
+
interface CancelEvent<F, P> {
|
|
396
|
+
/** Whether this cancel was driven by a new dispatch superseding the old, or by the timeout firing. */
|
|
397
|
+
kind: CancelReason["kind"];
|
|
398
|
+
/** When `kind === 'timeout'`, the original `timeoutMs` that fired. Undefined for `'superseded'`. */
|
|
399
|
+
afterMs?: number;
|
|
400
|
+
/** The payload of the dispatch that was cancelled — i.e. the work that did NOT complete. */
|
|
401
|
+
payload: P;
|
|
402
|
+
/**
|
|
403
|
+
* Per-handler monotonic counter, incremented once at the start of
|
|
404
|
+
* every invocation. Useful for timeline diff: cancel of seq=4 is the
|
|
405
|
+
* 4th dispatch this handler ever saw. The counter is closure-scoped
|
|
406
|
+
* to a single `recordReplayable()` call — separate HOCs do NOT share
|
|
407
|
+
* the counter.
|
|
408
|
+
*/
|
|
409
|
+
dispatchSeq: number;
|
|
410
|
+
/** Live facts reference, mirroring {@link CancellableHandlerContext.facts}. */
|
|
411
|
+
facts: F;
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Options for {@link recordReplayable}. Strict superset of
|
|
415
|
+
* {@link CancellableOptions} — every field on `CancellableOptions`
|
|
416
|
+
* passes through unchanged to the underlying {@link cancellable} call.
|
|
417
|
+
*/
|
|
418
|
+
interface RecordReplayableOptions<F, P> extends CancellableOptions {
|
|
419
|
+
/**
|
|
420
|
+
* Synchronous callback invoked the moment a wrapped invocation's
|
|
421
|
+
* AbortController fires `abort()`. Runs BEFORE the handler's pending
|
|
422
|
+
* await rejects with AbortError, so the callback sees the freshest
|
|
423
|
+
* possible state.
|
|
424
|
+
*
|
|
425
|
+
* Use this to pin the cancellation into a place that survives in the
|
|
426
|
+
* timeline (typically a facts field — `facts.cancellations.push(info)`).
|
|
427
|
+
* Without that, a replay re-dispatches the same MUTATE events but has
|
|
428
|
+
* no record of which ones were superseded vs which completed.
|
|
429
|
+
*
|
|
430
|
+
* Throws inside `onCancel` are caught and swallowed — the abort path
|
|
431
|
+
* must remain robust. If you need to fail loudly, log to your own
|
|
432
|
+
* sink before re-throwing.
|
|
433
|
+
*/
|
|
434
|
+
onCancel?: (info: CancelEvent<F, P>) => void;
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* `cancellable()` plus a synchronous on-abort callback. Wrap your
|
|
438
|
+
* handler with `recordReplayable()` instead of `cancellable()` when
|
|
439
|
+
* you want a hook that fires the moment a supersession or timeout
|
|
440
|
+
* aborts the in-flight invocation — *before* the handler's pending
|
|
441
|
+
* await rejects with AbortError. The callback receives a structured
|
|
442
|
+
* {@link CancelEvent} carrying the cancel kind, payload, dispatch
|
|
443
|
+
* sequence, and a live facts reference.
|
|
444
|
+
*
|
|
445
|
+
* The callback is GENERIC ("call me when abort fires"); the most
|
|
446
|
+
* common use case is pinning cancel events into facts so a recorded
|
|
447
|
+
* timeline carries them — which then lets `replayTimeline()`,
|
|
448
|
+
* `bisectTimeline()`, and `diffTimelines()` (all in
|
|
449
|
+
* `@directive-run/timeline`) reason about which dispatches were
|
|
450
|
+
* superseded vs which completed without parsing free-form error
|
|
451
|
+
* strings. But the same callback works equally well for Sentry
|
|
452
|
+
* breadcrumbs, a Redux action log, OpenTelemetry spans, or a metrics
|
|
453
|
+
* sink — `recordReplayable` doesn't know or care about the timeline.
|
|
454
|
+
*
|
|
455
|
+
* @example Pin cancel events into facts so the timeline carries them:
|
|
456
|
+
* ```ts
|
|
457
|
+
* import { defineMutator, recordReplayable } from '@directive-run/mutator';
|
|
458
|
+
*
|
|
459
|
+
* const search = recordReplayable<MyFacts, { q: string }>(
|
|
460
|
+
* {
|
|
461
|
+
* supersedeOn: 'self',
|
|
462
|
+
* timeoutMs: 3_000,
|
|
463
|
+
* onCancel: ({ facts, kind, payload, dispatchSeq }) => {
|
|
464
|
+
* facts.cancellations.push({ kind, queryAtCancel: payload.q, seq: dispatchSeq });
|
|
465
|
+
* },
|
|
466
|
+
* },
|
|
467
|
+
* async ({ payload, facts, signal }) => {
|
|
468
|
+
* const res = await fetch(`/q?${payload.q}`, { signal });
|
|
469
|
+
* facts.results = await res.json();
|
|
470
|
+
* },
|
|
471
|
+
* );
|
|
472
|
+
* ```
|
|
473
|
+
*
|
|
474
|
+
* Implementation note: `recordReplayable()` is `cancellable(opts,
|
|
475
|
+
* innerHandler)` where `innerHandler` adds an `addEventListener('abort')`
|
|
476
|
+
* around the user's handler. This means timeout / supersession
|
|
477
|
+
* semantics are EXACTLY those of `cancellable()` — the HOC is purely
|
|
478
|
+
* additive.
|
|
479
|
+
*/
|
|
480
|
+
declare function recordReplayable<F, P>(opts: RecordReplayableOptions<F, P>, handler: (ctx: CancellableHandlerContext<F, P>) => Promise<void> | void): (ctx: {
|
|
481
|
+
facts: F;
|
|
482
|
+
payload: P;
|
|
483
|
+
requeue: () => void;
|
|
484
|
+
}) => Promise<void>;
|
|
349
485
|
|
|
350
|
-
export { type CancelReason, type CancellableHandlerContext, type CancellableOptions, type MutationHandler, type MutationHandlers, type MutationMap, type MutatorFragments, type MutatorHandlerContext, type PendingMutation, cancellable, defineMutator, mutate };
|
|
486
|
+
export { CancelError, type CancelEvent, type CancelReason, type CancellableHandlerContext, type CancellableOptions, type MutationHandler, type MutationHandlers, type MutationMap, type MutatorFragments, type MutatorHandlerContext, type PendingMutation, type RecordReplayableOptions, SupersededCancelError, TimeoutCancelError, cancelReason, cancellable, defineMutator, mutate, recordReplayable };
|
package/dist/index.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import {t}from'@directive-run/core';var
|
|
1
|
+
import {t}from'@directive-run/core';var g=500;function m(e){let n;if(e instanceof Error){let t;try{t=e.message;}catch{t="[mutator: error.message getter threw]";}n=typeof t=="string"?t:y(t);}else typeof e=="string"?n=e:n=y(e);return n.length<=g?n:`${n.slice(0,g-1)}\u2026`}function y(e){try{return String(e)}catch{return "[mutator: unstringifiable error]"}}function R(e){return {facts:{pendingMutation:t.object().nullable()},events:{MUTATE:void 0},requirements:{PROCESS_MUTATION:{}},eventHandlers:{MUTATE:(r,a)=>{r.pendingMutation={...a,status:"pending",error:null};}},constraints:{pendingMutation:{when:r=>r.pendingMutation!==null&&r.pendingMutation?.status==="pending",require:{type:"PROCESS_MUTATION"}}},resolvers:{mutationResolver:{requirement:"PROCESS_MUTATION",resolve:async(r,a)=>{let l=a.facts,u=l.pendingMutation;if(u===null)return;l.pendingMutation={...u,status:"running"};let f=Object.prototype.hasOwnProperty.call(e,u.kind)?e[u.kind]:void 0;if(typeof f!="function"){l.pendingMutation={...u,status:"failed",error:m(`[mutator] no handler registered for variant: ${String(u.kind)}`)};return}let P={facts:a.facts,payload:u.payload,requeue:a.requeue??(()=>{})};try{await f(P),l.pendingMutation?.status==="running"&&(l.pendingMutation=null);}catch(F){if(l.pendingMutation?.status!=="running")return;l.pendingMutation={...u,status:"failed",error:m(F)};}}}},initialPendingMutation:null}}function O(e,n){return {kind:e,payload:n??{},status:"pending",error:null}}var c=class extends Error{constructor(t,i){super(i??`[mutator] cancellable: ${t}`);this.kind=t;this.name="CancelError";}},p=class extends c{constructor(t){super("timeout",`[mutator] cancellable: timeout after ${t}ms`);this.afterMs=t;this.name="TimeoutCancelError";}},M=class extends c{constructor(){super("superseded","[mutator] cancellable: superseded by new dispatch"),this.name="SupersededCancelError";}},v={superseded:()=>new M,timeout:e=>new p(e)};function x(e,n){let t=e.supersedeOn??"self",i=e.timeoutMs,o=e.setTimeout??C,s;return async d=>{t==="self"&&s!==void 0&&s.abort(v.superseded());let r=new AbortController;s=r;let a;typeof i=="number"&&i>0&&(a=o(()=>{r.abort(v.timeout(i));},i));try{await n({facts:d.facts,payload:d.payload,requeue:d.requeue,signal:r.signal});}finally{try{a?.();}catch{}s===r&&(s=void 0);}}}function C(e,n){let t=globalThis.setTimeout(e,n);return ()=>globalThis.clearTimeout(t)}function S(e,n){let t=0;return x(e,async o=>{let s=++t,d=()=>{let r=o.signal.reason;if(!(r instanceof c))return;let a={kind:r.kind,afterMs:r instanceof p?r.afterMs:void 0,payload:o.payload,dispatchSeq:s,facts:o.facts};try{e.onCancel?.(a);}catch{}};o.signal.addEventListener("abort",d,{once:!0});try{await n(o);}finally{o.signal.removeEventListener("abort",d);}})}export{c as CancelError,M as SupersededCancelError,p as TimeoutCancelError,v as cancelReason,x as cancellable,R as defineMutator,O as mutate,S as recordReplayable};//# sourceMappingURL=index.js.map
|
|
2
2
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts"],"names":["MAX_ERROR_LEN","truncateError","input","str","raw","safeStringCoerce","value","defineMutator","handlers","t","facts","payload","_req","ctx","factsRef","pending","handler","handlerCtx","err","mutate","kind","cancellable","opts","supersedeOn","timeoutMs","scheduleTimeout","defaultSetTimeout","priorController","controller","cancelTimeout","cb","ms","handle"],"mappings":"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","file":"index.js","sourcesContent":["/**\n * @directive-run/mutator\n *\n * Discriminated mutation helper. Collapses the manual `pendingAction`\n * ceremony — fact + event + constraint + resolver — into a typed handler\n * map.\n *\n * Background: across the 55-cycle Minglingo migration, 12 modules ended\n * up with the same shape:\n * - a nullable `pendingAction` fact holding a discriminated union\n * - an event that sets it\n * - a constraint that fires on non-null\n * - a resolver that switches on the discriminator and clears the fact\n *\n * That's ~50 lines of boilerplate per module times 12 modules. The\n * `defineMutator` helper below contributes all four pieces from a single\n * typed declaration, so a module that uses it spreads the fragments\n * into its `createModule` config and writes only the per-variant handler\n * bodies.\n *\n * @see ../README.md for the full API and a worked example.\n */\n\nimport { t } from \"@directive-run/core\";\n\n/**\n * A keyed map of variant payloads. Each key becomes a discriminator value\n * for the mutation, each value is the payload type for that variant.\n *\n * @example\n * ```ts\n * type MyMutations = {\n * submit: { values: FormValues };\n * cancel: {};\n * retry: { reason: string };\n * };\n * ```\n */\nexport type MutationMap = Record<string, Record<string, unknown>>;\n\n/**\n * The shape of a `pendingMutation` fact while a mutation is queued or\n * running.\n */\nexport type PendingMutation<M extends MutationMap> = {\n [K in keyof M]: {\n kind: K;\n payload: M[K];\n /**\n * `pending` — queued, constraint hasn't fired yet.\n * `running` — handler is in flight.\n * `failed` — handler threw; constraint stops firing. Caller can\n * inspect `error` and dispatch a fresh MUTATE to retry.\n */\n status: \"pending\" | \"running\" | \"failed\";\n /**\n * Error message from the previous run, if any. Plaintext only —\n * NEVER render this directly via `dangerouslySetInnerHTML` or\n * markdown. Truncated to 500 chars to bound XSS surface from\n * thrown messages that may echo user input.\n */\n error: string | null;\n };\n}[keyof M];\n\n/** Maximum length of a captured error message — bounded to limit XSS surface. */\nconst MAX_ERROR_LEN = 500;\n\n/**\n * Coerce a thrown value to a bounded plaintext string for storage on\n * `pendingMutation.error`. Defends against:\n * - non-Error throws (`throw \"string\"`, `throw 42`, `throw {}`)\n * - Errors with non-string `.message` (overridden numeric / Symbol /\n * buffer); naive `.length` / `.slice` would `TypeError` otherwise\n * - extremely long messages with attacker-influenced input\n *\n * Returns at most {@link MAX_ERROR_LEN} characters of plaintext.\n * Plaintext rendering is still the only supported path on the\n * consumer side; this is defense in depth, not an XSS sanitizer.\n *\n * (R2 sec M-R2-1.)\n */\nfunction truncateError(input: unknown): string {\n let str: string;\n if (input instanceof Error) {\n // Reading `.message` is wrapped in try/catch because a maliciously\n // constructed Error subclass may install a throwing getter on\n // `message`. Without this guard the throw escapes truncateError →\n // escapes the resolver's catch → propagates uncaught. (R4 backlog.)\n let raw: unknown;\n try {\n raw = input.message;\n } catch {\n raw = \"[mutator: error.message getter threw]\";\n }\n str = typeof raw === \"string\" ? raw : safeStringCoerce(raw);\n } else if (typeof input === \"string\") {\n str = input;\n } else {\n str = safeStringCoerce(input);\n }\n if (str.length <= MAX_ERROR_LEN) return str;\n return `${str.slice(0, MAX_ERROR_LEN - 1)}…`;\n}\n\n/** String() may throw for some Symbols + objects with hostile toString. */\nfunction safeStringCoerce(value: unknown): string {\n try {\n return String(value);\n } catch {\n return \"[mutator: unstringifiable error]\";\n }\n}\n\n/**\n * Handler context passed to each variant handler.\n *\n * Note: `deps` is NOT in the context. This matches the Directive resolver\n * idiom — close over deps from the outer module-factory scope:\n *\n * ```ts\n * function createFormModule(deps: FormDeps) {\n * const mut = defineMutator<FormMutations, FormFacts>({\n * submit: async ({ payload, facts }) => {\n * facts.values = await deps.submit(payload.values); // ← closure\n * },\n * });\n * return createModule('form', { ... });\n * }\n * ```\n */\nexport interface MutatorHandlerContext<F> {\n /** Live facts proxy. Reads are cache-tracked; writes invalidate. */\n facts: F;\n /**\n * Trigger a same-constraint re-fire after this handler returns. Useful\n * when one mutation cascades into another — without `requeue`, the next\n * mutation would stall behind same-flush suppression.\n *\n * @see https://docs.directive.run/testing/chained-pipelines\n */\n requeue: () => void;\n}\n\n/**\n * Variant handler body. Receives the typed payload + a context; returns\n * void or a Promise. Throwing is fine — the runtime captures into\n * `pendingMutation.error` before clearing the fact.\n */\nexport type MutationHandler<\n M extends MutationMap,\n K extends keyof M,\n F,\n> = keyof M[K] extends never\n ? (ctx: MutatorHandlerContext<F>) => void | Promise<void>\n : (\n ctx: MutatorHandlerContext<F> & { payload: M[K] },\n ) => void | Promise<void>;\n\n/**\n * The full handler map. Every variant in `M` MUST have a handler.\n */\nexport type MutationHandlers<M extends MutationMap, F> = {\n [K in keyof M]: MutationHandler<M, K, F>;\n};\n\n/**\n * The fragments returned by `defineMutator`. Spread each fragment into\n * the matching position of your `createModule` config.\n *\n * @internal Shape documented for type clarity; users typically just\n * spread.\n */\nexport interface MutatorFragments<M extends MutationMap, F> {\n /** Spread into `schema.facts`. Adds `pendingMutation`. */\n facts: {\n pendingMutation: ReturnType<typeof t.object>;\n };\n /** Spread into `schema.events`. Adds the `MUTATE` event. */\n events: {\n MUTATE: PendingMutation<M>;\n };\n /** Spread into `schema.requirements`. Adds `PROCESS_MUTATION`. */\n requirements: {\n PROCESS_MUTATION: Record<string, never>;\n };\n /** Spread into the `events` field. Sets pendingMutation on MUTATE. */\n eventHandlers: {\n MUTATE: (facts: F, payload: PendingMutation<M>) => void;\n };\n /** Spread into the `constraints` field. */\n constraints: {\n pendingMutation: {\n when: (facts: F) => boolean;\n require: { type: \"PROCESS_MUTATION\" };\n };\n };\n /** Spread into the `resolvers` field. */\n resolvers: {\n mutationResolver: {\n requirement: \"PROCESS_MUTATION\";\n resolve: (\n req: { type: \"PROCESS_MUTATION\" },\n ctx: { facts: F },\n ) => Promise<void>;\n };\n };\n /**\n * Convenience: the initial value for `pendingMutation`. Set this in\n * your module's `init` if you don't use `t.X().default(...)` defaults.\n */\n initialPendingMutation: null;\n}\n\n/**\n * Define a mutator fragment-set for a given variant map and handlers.\n *\n * @example\n * ```ts\n * type FormMutations = {\n * submit: { values: FormValues };\n * cancel: {};\n * };\n *\n * // The second generic is the FACTS type (not deps). Deps are\n * // captured in closure from the surrounding scope, not passed in\n * // through the handler ctx.\n * const formMutator = defineMutator<FormMutations, FormFacts>({\n * submit: async ({ payload, facts }) => {\n * facts.values = await deps.submit(payload.values); // deps closes over outer scope\n * },\n * cancel: ({ facts }) => { facts.values = []; },\n * });\n *\n * createModule('form', {\n * schema: {\n * facts: { ...formMutator.facts, values: t.array<FormValues>() },\n * events: { ...formMutator.events, REFRESH: {} },\n * requirements: { ...formMutator.requirements },\n * },\n * init: (f) => { f.pendingMutation = null; f.values = []; },\n * events: {\n * ...formMutator.eventHandlers,\n * REFRESH: (f) => { f.values = []; },\n * },\n * constraints: { ...formMutator.constraints },\n * resolvers: { ...formMutator.resolvers },\n * });\n *\n * // Usage:\n * sys.events.MUTATE(mutate<FormMutations>('submit', { values: ... }));\n *\n * // Or, if constructing the payload manually, the discriminator is\n * // `kind` (NOT `type` — `type` collides with Directive's event-name\n * // dispatch convention):\n * sys.events.MUTATE({\n * kind: 'submit',\n * payload: { values: ... },\n * status: 'pending',\n * error: null,\n * });\n * ```\n *\n * The handler-bound deps are captured at `defineMutator` call time. To\n * inject deps from the caller, wrap `defineMutator` in your module\n * factory:\n *\n * ```ts\n * export function createFormModule(deps: FormDeps) {\n * const mut = defineMutator<FormMutations, FormFacts>({\n * submit: async ({ payload, facts }) => {\n * facts.values = await deps.submit(payload.values);\n * },\n * cancel: ({ facts }) => { facts.values = []; },\n * });\n * return createModule('form', { ... });\n * }\n * ```\n */\nexport function defineMutator<\n M extends MutationMap,\n F = Record<string, unknown>,\n>(handlers: MutationHandlers<M, F>): MutatorFragments<M, F> {\n type Pending = PendingMutation<M>;\n\n const facts = {\n pendingMutation: t.object<Pending>().nullable() as ReturnType<\n typeof t.object\n >,\n } as MutatorFragments<M, F>[\"facts\"];\n\n // Schema event marker — the runtime uses this for typing and devtools.\n // Payload validation happens at dispatch time via t-schema check.\n const events = {\n MUTATE: undefined as unknown as Pending,\n } as MutatorFragments<M, F>[\"events\"];\n\n const requirements = {\n PROCESS_MUTATION: {} as Record<string, never>,\n } as MutatorFragments<M, F>[\"requirements\"];\n\n const eventHandlers = {\n MUTATE: (facts: F, payload: Pending) => {\n // Overwrite is intentional — caller is responsible for ordering.\n // If a previous mutation is mid-flight, the new one queues by\n // overwriting; the in-flight handler will null the fact when it\n // completes, then the constraint re-fires for the new one.\n // (Same as the manual pattern across all 12 audited modules.)\n (facts as { pendingMutation: Pending | null }).pendingMutation = {\n ...payload,\n status: \"pending\",\n error: null,\n };\n },\n } as MutatorFragments<M, F>[\"eventHandlers\"];\n\n const constraints = {\n pendingMutation: {\n when: (facts: F) =>\n (facts as { pendingMutation: Pending | null }).pendingMutation !==\n null &&\n (facts as { pendingMutation: Pending | null }).pendingMutation\n ?.status === \"pending\",\n require: { type: \"PROCESS_MUTATION\" } as const,\n },\n } as MutatorFragments<M, F>[\"constraints\"];\n\n const resolvers = {\n mutationResolver: {\n requirement: \"PROCESS_MUTATION\" as const,\n resolve: async (\n _req: { type: \"PROCESS_MUTATION\" },\n ctx: {\n facts: F;\n requeue?: () => void;\n },\n ) => {\n const factsRef = ctx.facts as { pendingMutation: Pending | null };\n const pending = factsRef.pendingMutation;\n if (pending === null) return;\n\n // Stamp the in-flight transition with the running status. The\n // status itself is the supersession marker: only this resolver\n // ever writes 'running'; the eventHandler always writes\n // 'pending'. So if a fresh MUTATE arrives mid-flight, the\n // post-handler check sees status: 'pending' instead of\n // 'running' and knows to leave the new dispatch alone.\n // (Sec M5 / DX M2.)\n factsRef.pendingMutation = { ...pending, status: \"running\" };\n\n // Prototype-pollution defense: only accept handlers OWNED by\n // the user-provided map. Without this, dispatching with kind:\n // 'constructor' / 'toString' / '__proto__' would resolve via\n // the prototype chain and either crash or invoke an inherited\n // function with arbitrary payload. (Sec C1.)\n const ownsHandler = Object.prototype.hasOwnProperty.call(\n handlers,\n pending.kind as PropertyKey,\n );\n const handler = ownsHandler\n ? (handlers as Record<string, unknown>)[pending.kind as string]\n : undefined;\n\n if (typeof handler !== \"function\") {\n factsRef.pendingMutation = {\n ...pending,\n status: \"failed\",\n error: truncateError(\n `[mutator] no handler registered for variant: ${String(\n pending.kind,\n )}`,\n ),\n };\n return;\n }\n\n const handlerCtx = {\n facts: ctx.facts,\n payload: pending.payload,\n requeue: ctx.requeue ?? (() => {}),\n };\n\n try {\n await (handler as (c: typeof handlerCtx) => Promise<void> | void)(\n handlerCtx,\n );\n // Success: clear the fact ONLY if this resolver's `running`\n // marker is still live (status === 'running'). If a fresh\n // MUTATE arrived mid-flight via the eventHandler, the fact\n // now has status: 'pending' — leave it alone so the next\n // constraint fire picks it up. (Sec M5 / DX M2.)\n if (factsRef.pendingMutation?.status === \"running\") {\n factsRef.pendingMutation = null;\n }\n } catch (err) {\n // Failure: only stamp the error if our running marker is\n // still live. If a fresh MUTATE arrived mid-flight, the new\n // dispatch wins; the failed mutation's error is dropped on\n // the floor (caller wanted to move on anyway). (Sec M5.)\n if (factsRef.pendingMutation?.status !== \"running\") return;\n factsRef.pendingMutation = {\n ...pending,\n // 'failed' is a distinct status (Sec M6 / Arch C2) — UI can\n // disambiguate \"still in flight\" from \"stopped on error\".\n status: \"failed\",\n // truncateError handles the unknown shape safely — non-Error\n // throws, non-string Error.message, hostile toString. (R2\n // sec M-R2-1.)\n error: truncateError(err),\n };\n }\n },\n },\n } as MutatorFragments<M, F>[\"resolvers\"];\n\n return {\n facts,\n events,\n requirements,\n eventHandlers,\n constraints,\n resolvers,\n initialPendingMutation: null,\n };\n}\n\n/**\n * Helper for typed dispatch. Lets the caller construct a mutation payload\n * with full type narrowing — the `kind` field auto-restricts the\n * `payload` shape.\n *\n * @example\n * ```ts\n * sys.events.MUTATE(mutate<FormMutations>('submit', { values: ... }));\n * sys.events.MUTATE(mutate<FormMutations>('cancel'));\n * ```\n */\n// Single type parameter (M) so callers can specialize with\n// `mutate<FormMutations>(...)` without TypeScript demanding the\n// inferred K. TS's strict type-argument rules treat <M, K> as\n// \"supply both or supply neither\" — single-arg call sites failed\n// with \"Expected 2 type arguments, but got 1.\" Single-M form sidesteps\n// that while keeping payload typing tight via the `kind` lookup.\nexport function mutate<M extends MutationMap>(\n kind: keyof M & string,\n payload: M[typeof kind],\n): PendingMutation<M>;\nexport function mutate<M extends MutationMap>(\n kind: keyof M & string,\n): PendingMutation<M>;\nexport function mutate<M extends MutationMap>(\n kind: keyof M & string,\n payload?: M[typeof kind],\n): PendingMutation<M> {\n return {\n kind,\n payload: (payload ?? {}) as M[typeof kind],\n status: \"pending\",\n error: null,\n } as PendingMutation<M>;\n}\n\n// ============================================================================\n// cancellable() — auto-cancel-on-supersede (R1.C v0.1)\n// ============================================================================\n\n/**\n * Options for {@link cancellable}.\n */\nexport interface CancellableOptions {\n /**\n * When to fire the AbortSignal that the wrapped handler receives.\n *\n * - `'self'` (default): a new dispatch of the SAME handler aborts the\n * prior in-flight invocation. The classic \"cancel previous on new\n * keystroke\" pattern.\n * - `'never'`: only the timeout fires the signal; new dispatches do\n * NOT abort prior ones. Useful when you want a hard timeout but\n * parallel runs are fine.\n */\n supersedeOn?: \"self\" | \"never\";\n\n /**\n * Maximum ms a handler may run before its signal is aborted. Counted\n * from the start of THIS invocation. Combine with `realClock` for\n * production or pass a `setTimeout` shim for deterministic testing.\n */\n timeoutMs?: number;\n\n /**\n * Optional `setTimeout` injection. Defaults to `globalThis.setTimeout`.\n * For deterministic tests, pass `virtualClock.setTimeout` from\n * `@directive-run/core` so the timeout fires under\n * `clock.advanceBy()` instead of wall-clock real time.\n *\n * @example\n * ```ts\n * import { virtualClock } from '@directive-run/core';\n * const clock = virtualClock(0);\n * cancellable({ timeoutMs: 1_000, setTimeout: clock.setTimeout }, handler);\n * ```\n */\n setTimeout?: (cb: () => void, ms: number) => () => void;\n}\n\n/**\n * Handler context augmented with a cancellation signal.\n */\nexport interface CancellableHandlerContext<F, P> {\n facts: F;\n payload: P;\n /** Aborts when a new dispatch supersedes this one OR the timeout fires. */\n signal: AbortSignal;\n requeue: () => void;\n}\n\n/**\n * Reason a cancellable handler's signal aborted. Stamped on\n * `signal.reason` so the handler can disambiguate.\n */\nexport type CancelReason =\n | { kind: \"superseded\" }\n | { kind: \"timeout\"; afterMs: number };\n\n/**\n * Wrap a mutator handler with auto-cancellation. The wrapped handler\n * receives an extra `signal: AbortSignal` in its context. Use the\n * signal to short-circuit awaitable work — pass it to `fetch(url, {\n * signal })`, watch it inside long-running loops, etc.\n *\n * Two cancellation triggers, both opt-in via {@link CancellableOptions}:\n *\n * 1. **Supersession** (default `supersedeOn: 'self'`): when a new\n * dispatch of the same wrapped handler arrives while a prior\n * invocation is still running, the prior signal aborts.\n * 2. **Timeout** (default `timeoutMs: undefined`, meaning no timeout):\n * after `timeoutMs` ms from invocation start, the signal aborts.\n *\n * If a handler's signal aborts, the handler should observe the abort\n * (via `signal.aborted` or the AbortError-throwing helpers) and\n * return promptly. The signal's `reason` carries a {@link CancelReason}\n * disambiguating which trigger fired.\n *\n * Compose with {@link defineMutator} by using `cancellable()` directly\n * in the handler map:\n *\n * @example\n * ```ts\n * import { defineMutator, cancellable } from '@directive-run/mutator';\n *\n * const formMutator = defineMutator<MyMutations, MyFacts>({\n * search: cancellable(\n * { supersedeOn: 'self', timeoutMs: 3_000 },\n * async ({ payload, facts, signal }) => {\n * const res = await fetch(`/q?${payload.q}`, { signal });\n * facts.results = await res.json();\n * },\n * ),\n * submit: async ({ payload, facts }) => {\n * // No cancellation needed for submit — plain handler.\n * facts.values = await deps.submit(payload.values);\n * },\n * });\n * ```\n *\n * **Idempotency note.** The wrapped handler stays a regular\n * `MutationHandler<M, K, F>` from the mutator's perspective. The\n * supersession registry is closure-scoped per `cancellable()` call —\n * two separate `cancellable(...)` HOCs around different handlers do\n * NOT cancel each other.\n *\n * **Test ergonomics.** Pass `virtualClock.setTimeout` via the\n * `setTimeout` option to make timeouts deterministic under\n * `clock.advanceBy(ms)`. Without that, timeouts use wall-clock\n * `globalThis.setTimeout` and are real-time.\n */\nexport function cancellable<F, P>(\n opts: CancellableOptions,\n handler: (ctx: CancellableHandlerContext<F, P>) => Promise<void> | void,\n): (ctx: { facts: F; payload: P; requeue: () => void }) => Promise<void> {\n const supersedeOn = opts.supersedeOn ?? \"self\";\n const timeoutMs = opts.timeoutMs;\n const scheduleTimeout = opts.setTimeout ?? defaultSetTimeout;\n\n // Closure-scoped supersession slot — one entry for the wrapped\n // handler. When a new invocation arrives, the prior entry's\n // controller aborts before the new one starts.\n let priorController: AbortController | undefined;\n\n return async (ctx: { facts: F; payload: P; requeue: () => void }) => {\n // Supersession: abort the prior in-flight invocation, if any.\n if (supersedeOn === \"self\" && priorController !== undefined) {\n priorController.abort({ kind: \"superseded\" } satisfies CancelReason);\n }\n\n const controller = new AbortController();\n priorController = controller;\n\n // Timeout: schedule an abort after `timeoutMs`. The cancel handle\n // returned by `scheduleTimeout` lets us clear the timer if the\n // handler completes first (saves leaking timers under a barrage\n // of dispatches).\n let cancelTimeout: (() => void) | undefined;\n if (typeof timeoutMs === \"number\" && timeoutMs > 0) {\n cancelTimeout = scheduleTimeout(() => {\n controller.abort({ kind: \"timeout\", afterMs: timeoutMs } satisfies CancelReason);\n }, timeoutMs);\n }\n\n try {\n await handler({\n facts: ctx.facts,\n payload: ctx.payload,\n requeue: ctx.requeue,\n signal: controller.signal,\n });\n } finally {\n // Clean up: clear the timeout (if it hasn't fired) and release\n // the supersession slot if it still belongs to this invocation.\n cancelTimeout?.();\n if (priorController === controller) {\n priorController = undefined;\n }\n }\n };\n}\n\n/**\n * Default `setTimeout` shim — wraps `globalThis.setTimeout` to match\n * the cancel-handle signature `cancellable()` expects.\n */\nfunction defaultSetTimeout(cb: () => void, ms: number): () => void {\n const handle = globalThis.setTimeout(cb, ms);\n return () => globalThis.clearTimeout(handle);\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"names":["MAX_ERROR_LEN","truncateError","input","str","raw","safeStringCoerce","value","defineMutator","handlers","t","facts","payload","_req","ctx","factsRef","pending","handler","handlerCtx","err","mutate","kind","CancelError","message","TimeoutCancelError","afterMs","SupersededCancelError","cancelReason","cancellable","opts","supersedeOn","timeoutMs","scheduleTimeout","defaultSetTimeout","priorController","controller","cancelTimeout","cb","ms","handle","recordReplayable","dispatchSeq","seq","onAbort","reason","info"],"mappings":"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","file":"index.js","sourcesContent":["/**\n * @directive-run/mutator\n *\n * Discriminated mutation helper. Collapses the manual `pendingAction`\n * ceremony — fact + event + constraint + resolver — into a typed handler\n * map.\n *\n * Background: across the 55-cycle Minglingo migration, 12 modules ended\n * up with the same shape:\n * - a nullable `pendingAction` fact holding a discriminated union\n * - an event that sets it\n * - a constraint that fires on non-null\n * - a resolver that switches on the discriminator and clears the fact\n *\n * That's ~50 lines of boilerplate per module times 12 modules. The\n * `defineMutator` helper below contributes all four pieces from a single\n * typed declaration, so a module that uses it spreads the fragments\n * into its `createModule` config and writes only the per-variant handler\n * bodies.\n *\n * @see ../README.md for the full API and a worked example.\n */\n\nimport { t } from \"@directive-run/core\";\n\n/**\n * A keyed map of variant payloads. Each key becomes a discriminator value\n * for the mutation, each value is the payload type for that variant.\n *\n * @example\n * ```ts\n * type MyMutations = {\n * submit: { values: FormValues };\n * cancel: {};\n * retry: { reason: string };\n * };\n * ```\n */\nexport type MutationMap = Record<string, Record<string, unknown>>;\n\n/**\n * The shape of a `pendingMutation` fact while a mutation is queued or\n * running.\n */\nexport type PendingMutation<M extends MutationMap> = {\n [K in keyof M]: {\n kind: K;\n payload: M[K];\n /**\n * `pending` — queued, constraint hasn't fired yet.\n * `running` — handler is in flight.\n * `failed` — handler threw; constraint stops firing. Caller can\n * inspect `error` and dispatch a fresh MUTATE to retry.\n */\n status: \"pending\" | \"running\" | \"failed\";\n /**\n * Error message from the previous run, if any. Plaintext only —\n * NEVER render this directly via `dangerouslySetInnerHTML` or\n * markdown. Truncated to 500 chars to bound XSS surface from\n * thrown messages that may echo user input.\n */\n error: string | null;\n };\n}[keyof M];\n\n/** Maximum length of a captured error message — bounded to limit XSS surface. */\nconst MAX_ERROR_LEN = 500;\n\n/**\n * Coerce a thrown value to a bounded plaintext string for storage on\n * `pendingMutation.error`. Defends against:\n * - non-Error throws (`throw \"string\"`, `throw 42`, `throw {}`)\n * - Errors with non-string `.message` (overridden numeric / Symbol /\n * buffer); naive `.length` / `.slice` would `TypeError` otherwise\n * - extremely long messages with attacker-influenced input\n *\n * Returns at most {@link MAX_ERROR_LEN} characters of plaintext.\n * Plaintext rendering is still the only supported path on the\n * consumer side; this is defense in depth, not an XSS sanitizer.\n *\n * (R2 sec M-R2-1.)\n */\nfunction truncateError(input: unknown): string {\n let str: string;\n if (input instanceof Error) {\n // Reading `.message` is wrapped in try/catch because a maliciously\n // constructed Error subclass may install a throwing getter on\n // `message`. Without this guard the throw escapes truncateError →\n // escapes the resolver's catch → propagates uncaught. (R4 backlog.)\n let raw: unknown;\n try {\n raw = input.message;\n } catch {\n raw = \"[mutator: error.message getter threw]\";\n }\n str = typeof raw === \"string\" ? raw : safeStringCoerce(raw);\n } else if (typeof input === \"string\") {\n str = input;\n } else {\n str = safeStringCoerce(input);\n }\n if (str.length <= MAX_ERROR_LEN) return str;\n return `${str.slice(0, MAX_ERROR_LEN - 1)}…`;\n}\n\n/** String() may throw for some Symbols + objects with hostile toString. */\nfunction safeStringCoerce(value: unknown): string {\n try {\n return String(value);\n } catch {\n return \"[mutator: unstringifiable error]\";\n }\n}\n\n/**\n * Handler context passed to each variant handler.\n *\n * Note: `deps` is NOT in the context. This matches the Directive resolver\n * idiom — close over deps from the outer module-factory scope:\n *\n * ```ts\n * function createFormModule(deps: FormDeps) {\n * const mut = defineMutator<FormMutations, FormFacts>({\n * submit: async ({ payload, facts }) => {\n * facts.values = await deps.submit(payload.values); // ← closure\n * },\n * });\n * return createModule('form', { ... });\n * }\n * ```\n */\nexport interface MutatorHandlerContext<F> {\n /** Live facts proxy. Reads are cache-tracked; writes invalidate. */\n facts: F;\n /**\n * Trigger a same-constraint re-fire after this handler returns. Useful\n * when one mutation cascades into another — without `requeue`, the next\n * mutation would stall behind same-flush suppression.\n *\n * @see https://docs.directive.run/testing/chained-pipelines\n */\n requeue: () => void;\n}\n\n/**\n * Variant handler body. Receives the typed payload + a context; returns\n * void or a Promise. Throwing is fine — the runtime captures into\n * `pendingMutation.error` before clearing the fact.\n */\nexport type MutationHandler<\n M extends MutationMap,\n K extends keyof M,\n F,\n> = keyof M[K] extends never\n ? (ctx: MutatorHandlerContext<F>) => void | Promise<void>\n : (\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"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@directive-run/mutator",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Discriminated mutation helper for Directive — collapse the pendingAction ceremony to a typed handler map.",
|
|
5
5
|
"license": "(MIT OR Apache-2.0)",
|
|
6
6
|
"author": "Jason Comes",
|
|
@@ -45,13 +45,13 @@
|
|
|
45
45
|
"CHANGELOG.md"
|
|
46
46
|
],
|
|
47
47
|
"peerDependencies": {
|
|
48
|
-
"@directive-run/core": "^1.
|
|
48
|
+
"@directive-run/core": "^1.3.0"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"tsup": "^8.3.5",
|
|
52
52
|
"typescript": "^5.7.2",
|
|
53
53
|
"vitest": "^2.1.9",
|
|
54
|
-
"@directive-run/core": "1.
|
|
54
|
+
"@directive-run/core": "1.4.0"
|
|
55
55
|
},
|
|
56
56
|
"scripts": {
|
|
57
57
|
"build": "tsup",
|