@directive-run/mutator 0.2.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 ADDED
@@ -0,0 +1,95 @@
1
+ # @directive-run/mutator changelog
2
+
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [`dc4ac7b`](https://github.com/directive-run/directive/commit/dc4ac7b93007104ce4973d86fb3d6f6a5d1fcded) Thanks [@jasoncomes](https://github.com/jasoncomes)! - Add `cancellable()` HOC — auto-cancel-on-supersede for mutator handlers (R1.C v0.1)
8
+
9
+ The third BUILD CANDIDATE from the AE-review-loop innovation pass. Wrap a mutator handler with `cancellable()` to get auto-cancellation: a fresh dispatch of the same wrapped handler aborts the prior in-flight invocation, OR an optional timeout fires the abort after N ms.
10
+
11
+ ```ts
12
+ import { defineMutator, cancellable } from "@directive-run/mutator";
13
+
14
+ const formMutator = defineMutator<MyMutations, MyFacts>({
15
+ search: cancellable(
16
+ { supersedeOn: "self", timeoutMs: 3_000 },
17
+ async ({ payload, facts, signal }) => {
18
+ const res = await fetch(`/q?${payload.q}`, { signal });
19
+ facts.results = await res.json();
20
+ }
21
+ ),
22
+ submit: async ({ payload, facts }) => {
23
+ facts.values = await deps.submit(payload.values);
24
+ },
25
+ });
26
+ ```
27
+
28
+ **Two cancellation triggers, both opt-in:**
29
+
30
+ - `supersedeOn: 'self'` (default) — new dispatch supersedes prior
31
+ - `supersedeOn: 'never'` — only timeout fires; parallel runs are fine
32
+ - `timeoutMs: number` — abort after N ms from invocation start
33
+
34
+ **Test ergonomics.** Pass `virtualClock.setTimeout` from `@directive-run/core` via the `setTimeout` option to make timeouts fire synchronously under `clock.advanceBy(ms)` — no real-time waits.
35
+
36
+ The signal's `.reason` carries a typed `CancelReason`:
37
+
38
+ ```ts
39
+ type CancelReason =
40
+ | { kind: "superseded" }
41
+ | { kind: "timeout"; afterMs: number };
42
+ ```
43
+
44
+ **Composition.** Drops in directly to `defineMutator`'s handler map slot. Two separate `cancellable()` HOCs around different handlers do NOT cancel each other — the supersession registry is closure-scoped per call.
45
+
46
+ **v0.1 scope:** `cancellable()` is a value-layer HOC; engine-side never sees a difference between a wrapped handler and a plain async one. v0.2 will explore the timeline integration so `expect(timeline).toCancel('search')` matchers can assert against the abort stream.
47
+
48
+ 9 new tests covering basic invocation, supersession (both modes), timeout (using virtualClock for determinism), supersession+timeout composition, HOC independence.
49
+
50
+ ## 0.1.0 — 2026-04-29
51
+
52
+ Initial release.
53
+
54
+ ### Added — v0.2 (R1.C cancellable)
55
+
56
+ - `cancellable(opts, handler)` — HOC that wraps a mutator handler with
57
+ auto-cancellation. Receives a `signal: AbortSignal` in the handler
58
+ context. Two cancellation triggers: `supersedeOn: 'self' | 'never'`
59
+ (default `'self'`) and `timeoutMs?: number`. The signal's `reason`
60
+ carries a typed `CancelReason` distinguishing `{kind:'superseded'}`
61
+ from `{kind:'timeout', afterMs}`. Pass `setTimeout` from
62
+ `virtualClock` for deterministic test timing.
63
+ - `CancellableOptions`, `CancellableHandlerContext<F, P>`,
64
+ `CancelReason` type exports.
65
+
66
+ ### Added — v0.1
67
+
68
+ - `defineMutator(handlers)` — typed builder that returns six fragments
69
+ (facts / events / requirements / eventHandlers / constraints /
70
+ resolvers) wiring a discriminated `pendingMutation` lifecycle into a
71
+ Directive module.
72
+ - `mutate(kind, payload?)` — typed payload constructor for `MUTATE`
73
+ dispatches.
74
+ - Single-flight concurrency model: new mutations overwrite in-flight ones
75
+ via the `pendingMutation` fact.
76
+ - Error capture: thrown handlers surface on `pendingMutation.error`
77
+ with `status: 'failed'` (a distinct status from `'running'` so the
78
+ UI can disambiguate; the constraint stops firing).
79
+ - Built on `@directive-run/core@^1.2.0` (requires `ctx.requeue` for
80
+ handler-cascade chains).
81
+
82
+ ### Known gaps
83
+
84
+ - Parallel-of-same-shape mutations not supported — last-write-wins.
85
+ - No runtime payload validation — TypeScript only at dispatch site.
86
+ - Optimistic / snapshot-rollback support belongs to upcoming
87
+ `@directive-run/optimistic`; do manual rollback inside handlers for
88
+ now.
89
+
90
+ ### Why the 0.x version
91
+
92
+ This package collapses a real-world boilerplate pattern but the API
93
+ shape (six-spread vs builder vs HOC) is still being validated against
94
+ production use. v1.0 ships once at least three external consumers have
95
+ worn the API end-to-end.
package/LICENSE ADDED
@@ -0,0 +1,26 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sizls ∞ Jason Comes
4
+
5
+ This project is dual-licensed under MIT OR Apache-2.0. You may choose
6
+ either license. See LICENSE-APACHE for the Apache License, Version 2.0.
7
+
8
+ ---
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,292 @@
1
+ # `@directive-run/mutator`
2
+
3
+ > Discriminated mutation helper for Directive — collapse the
4
+ > `pendingAction` ceremony to a typed handler map.
5
+
6
+ ```sh
7
+ npm install @directive-run/mutator
8
+ ```
9
+
10
+ > **Naming heads-up:** the mutation discriminator is named **`kind`**,
11
+ > not `type`. Directive's event dispatcher reserves `payload.type` for
12
+ > its own event-name routing — `type` here would collide with `MUTATE`
13
+ > and route the dispatch to a non-existent event handler. Use `kind`
14
+ > everywhere; the typed `mutate(kind, payload)` constructor builds the
15
+ > right shape for you.
16
+
17
+ ## What it solves
18
+
19
+ Across the 55-cycle Minglingo XState→Directive migration, **12 modules**
20
+ ended up with the same shape:
21
+
22
+ - a nullable `pendingAction` fact holding a discriminated union
23
+ - an event handler that sets it
24
+ - a constraint that fires while it's non-null
25
+ - a resolver that switches on the discriminator and clears the fact
26
+
27
+ That's ~50 lines of boilerplate per module. This package contributes all
28
+ four pieces from a single typed declaration, so you write only the
29
+ per-variant handler bodies.
30
+
31
+ ## Quick start
32
+
33
+ ```ts
34
+ import { createModule, createSystem, t } from '@directive-run/core';
35
+ import { defineMutator, mutate } from '@directive-run/mutator';
36
+
37
+ type FormMutations = {
38
+ submit: { values: FormValues };
39
+ cancel: {};
40
+ retry: { reason: string };
41
+ };
42
+
43
+ interface FormDeps {
44
+ submit: (values: FormValues) => Promise<FormValues>;
45
+ }
46
+
47
+ export function createFormModule(deps: FormDeps) {
48
+ // Idiomatic Directive: handlers close over deps from the factory scope.
49
+ const mut = defineMutator<FormMutations, FormFacts>({
50
+ submit: async ({ payload, facts }) => {
51
+ facts.values = await deps.submit(payload.values); // ← closure
52
+ },
53
+ cancel: ({ facts }) => { facts.values = null; },
54
+ retry: async ({ payload, facts }) => {
55
+ facts.lastRetryReason = payload.reason;
56
+ },
57
+ });
58
+
59
+ return createModule('form', {
60
+ schema: {
61
+ facts: {
62
+ ...mut.facts, // → adds `pendingMutation`
63
+ values: t.object<FormValues>().nullable(),
64
+ lastRetryReason: t.string().nullable(),
65
+ },
66
+ events: { ...mut.events }, // → adds `MUTATE` event
67
+ requirements: { ...mut.requirements }, // → adds PROCESS_MUTATION
68
+ },
69
+ init: (f) => {
70
+ f.pendingMutation = null;
71
+ f.values = null;
72
+ f.lastRetryReason = null;
73
+ },
74
+ events: { ...mut.eventHandlers }, // sets pendingMutation on MUTATE
75
+ constraints: { ...mut.constraints },
76
+ resolvers: { ...mut.resolvers },
77
+ });
78
+ }
79
+
80
+ // Usage:
81
+ const sys = createSystem({ module: createFormModule(deps), deps });
82
+ sys.start();
83
+ sys.events.MUTATE(mutate<FormMutations>('submit', { values }));
84
+ ```
85
+
86
+ The `mutate(kind, payload?)` helper is a typed payload constructor.
87
+ The `kind` argument restricts the payload shape — passing a
88
+ wrong-shape payload is a compile error.
89
+
90
+ ## Anatomy
91
+
92
+ `defineMutator(handlers)` returns six fragments. You spread each into the
93
+ matching position of your `createModule` config:
94
+
95
+ | Fragment | Spreads into | Contributes |
96
+ |---|---|---|
97
+ | `mut.facts` | `schema.facts` | `pendingMutation: t.object<DiscriminatedUnion>().nullable()` |
98
+ | `mut.events` | `schema.events` | `MUTATE: PendingMutation<M>` |
99
+ | `mut.requirements` | `schema.requirements` | `PROCESS_MUTATION: {}` |
100
+ | `mut.eventHandlers` | `events:` | `MUTATE` handler that sets `pendingMutation` |
101
+ | `mut.constraints` | `constraints:` | `pendingMutation: { when, require }` |
102
+ | `mut.resolvers` | `resolvers:` | dispatches to the handler matching the discriminator |
103
+
104
+ The total spread cost is six lines. The savings come from not writing the
105
+ constraint/resolver/dispatch bodies yourself.
106
+
107
+ ## Lifecycle
108
+
109
+ ```
110
+ sys.events.MUTATE({ kind, payload, status: 'pending', error: null })
111
+ → pendingMutation fact set to that value
112
+ → constraint fires (pendingMutation !== null && status === 'pending')
113
+ → resolver wakes
114
+ → marks status: 'running'
115
+ → looks up handler by kind
116
+ → calls handler({ payload, facts, deps, requeue })
117
+ → on success: pendingMutation = null
118
+ → on throw: pendingMutation.status = 'failed' + .error = message
119
+ (constraint stops firing — no infinite retry; UI can
120
+ disambiguate "still running" from "stopped on error")
121
+ ```
122
+
123
+ > `kind` (not `type`) discriminates the mutation variant. Directive's
124
+ > own event dispatcher reserves the `type` field for its own
125
+ > event-name routing — colliding here would route the dispatch to a
126
+ > nonexistent event handler. `kind` keeps the two namespaces separate.
127
+
128
+ A failed mutation leaves `pendingMutation` non-null with `status:
129
+ 'failed'` (a distinct status from `'running'`, so the UI can
130
+ disambiguate "still working" from "stopped on error"). Read
131
+ `pendingMutation.error` to surface to the UI; dispatch a fresh `MUTATE`
132
+ to retry (which overwrites the failed fact and re-fires).
133
+
134
+ **XSS warning.** `pendingMutation.error` is a plaintext `string` that
135
+ may echo handler-thrown messages, which in turn may have interpolated
136
+ user-controlled input. Render it via `{error}` in JSX (default-escaped)
137
+ or `textContent` — **never** via `dangerouslySetInnerHTML`, markdown
138
+ rendering, or any other HTML-evaluating sink. The runtime truncates
139
+ captured errors to 500 characters as a defense in depth, but that does
140
+ not sanitize content; only escape on render.
141
+
142
+ ## Concurrency
143
+
144
+ The default model is single-flight — one mutation in flight at a time. If
145
+ a new `MUTATE` arrives while a handler is running, it overwrites the fact
146
+ and the constraint re-fires once the in-flight handler completes (which
147
+ nulls the fact, then the new value triggers another firing).
148
+
149
+ If you need parallel mutations of different shapes (e.g. `submit` AND
150
+ `uploadFile` running concurrently), use two mutators with distinct fact
151
+ names — one per shape. v0.1 doesn't support parallel-of-same-shape; the
152
+ behaviour there is "last-write-wins."
153
+
154
+ ## Same-constraint re-fire (`requeue`)
155
+
156
+ When one handler dispatches another `MUTATE` synchronously, the new
157
+ mutation may stall behind same-flush suppression in Directive's engine.
158
+ Call `ctx.requeue()` inside the handler to opt into a re-fire:
159
+
160
+ ```ts
161
+ const mut = defineMutator<Mutations, MyFacts>({
162
+ step1: async ({ facts, requeue }) => {
163
+ facts.step1Done = true;
164
+ // queue step2:
165
+ facts.pendingMutation = mutate<Mutations>('step2');
166
+ requeue(); // explicit — without this, step2 may stall
167
+ },
168
+ step2: ({ facts }) => { facts.step2Done = true; },
169
+ });
170
+ ```
171
+
172
+ Most modules don't need `requeue` — the next user-event-driven `MUTATE`
173
+ fires fine. It's specifically for handler-cascades-into-handler.
174
+
175
+ See [Directive testing § same-constraint re-fire](https://docs.directive.run/testing/chained-pipelines#the-same-constraint-re-fire-stall).
176
+
177
+ ## Type safety
178
+
179
+ The `MutationMap` generic is the source of truth. Every variant key
180
+ becomes:
181
+ - a possible `kind` value on `pendingMutation`
182
+ - a payload-constrained dispatch via `mutate('key', payload)`
183
+ - a required handler in the map (TypeScript errors if you forget one)
184
+ - a typed `payload` argument inside that handler
185
+
186
+ There is no runtime variant validation today — the type system catches
187
+ mismatches at the dispatch site, but a malformed `MUTATE` from outside
188
+ TypeScript (e.g. WebSocket frame) will still hit the resolver. If you
189
+ need runtime checks, validate at the boundary before dispatch.
190
+
191
+ ## When NOT to use a mutator
192
+
193
+ - **One-off events with no error path.** A simple `event.handle('OPEN',
194
+ (f) => { f.isOpen = true; })` doesn't need this — there's no async
195
+ work, no rollback, no error fact.
196
+ - **Long-running streams.** Subscriptions, polls, websocket fan-in —
197
+ these aren't single-shot mutations. Wire them through normal events.
198
+ - **Pure derivations.** If the result is a function of existing facts,
199
+ use a `derive` instead of a mutator.
200
+
201
+ The mutator earns its weight when you have **multi-variant async work
202
+ with a discriminator**. That's the 12-instance shape from the migration.
203
+
204
+ ## Auto-cancel on supersede (R1.C `cancellable()`)
205
+
206
+ For mutations where a fresh dispatch should cancel the prior in-flight
207
+ one — type-ahead search, debounce, throttle, request dedup — wrap
208
+ the handler with `cancellable()`. The wrapped handler receives a
209
+ `signal: AbortSignal` that aborts when superseded or when an
210
+ optional timeout fires:
211
+
212
+ ```ts
213
+ import { defineMutator, cancellable } from '@directive-run/mutator';
214
+
215
+ const formMutator = defineMutator<MyMutations, MyFacts>({
216
+ search: cancellable(
217
+ { supersedeOn: 'self', timeoutMs: 3_000 },
218
+ async ({ payload, facts, signal }) => {
219
+ const res = await fetch(`/q?${payload.q}`, { signal });
220
+ facts.results = await res.json();
221
+ },
222
+ ),
223
+ submit: async ({ payload, facts }) => {
224
+ // No cancellation — plain handler.
225
+ facts.values = await deps.submit(payload.values);
226
+ },
227
+ });
228
+ ```
229
+
230
+ **Two cancellation triggers, both opt-in:**
231
+
232
+ - `supersedeOn: 'self'` (default) — a new dispatch of the same
233
+ wrapped handler aborts the prior in-flight invocation. Set
234
+ `'never'` if parallel runs are fine.
235
+ - `timeoutMs: number` — abort after N ms from invocation start.
236
+ Default unset (no timeout).
237
+
238
+ **Test ergonomics:** pass `virtualClock.setTimeout` from
239
+ `@directive-run/core` via the `setTimeout` option to make timeouts
240
+ fire synchronously under `clock.advanceBy(ms)`:
241
+
242
+ ```ts
243
+ import { virtualClock } from '@directive-run/core';
244
+ const clock = virtualClock(0);
245
+ const wrapped = cancellable(
246
+ { timeoutMs: 1_000, setTimeout: clock.setTimeout },
247
+ handler,
248
+ );
249
+ // In tests: clock.advanceBy(1_001) fires the timeout deterministically.
250
+ ```
251
+
252
+ **The signal's `reason` carries a `CancelReason`:**
253
+
254
+ ```ts
255
+ type CancelReason =
256
+ | { kind: 'superseded' }
257
+ | { kind: 'timeout'; afterMs: number };
258
+ ```
259
+
260
+ Use it inside handlers to distinguish how the cancellation arrived
261
+ (e.g. log a different message for timeouts vs supersession).
262
+
263
+ ## Optimistic updates + rollback
264
+
265
+ A future `@directive-run/optimistic` package will integrate with this
266
+ one — the planned `ctx.snapshot([keys])` API lets a handler snapshot
267
+ specific facts before mutating, with automatic rollback on throw. Until
268
+ that ships, do snapshots manually inside handlers:
269
+
270
+ ```ts
271
+ submit: async ({ payload, facts, deps }) => {
272
+ const previous = [...facts.values]; // manual snapshot
273
+ facts.values = optimisticGuess(payload); // optimistic write
274
+ try {
275
+ facts.values = await deps.submit(payload);
276
+ } catch (err) {
277
+ facts.values = previous; // rollback
278
+ throw err; // surface to pendingMutation.error
279
+ }
280
+ },
281
+ ```
282
+
283
+ ## See also
284
+
285
+ - [Directive core](https://www.npmjs.com/package/@directive-run/core)
286
+ - [Migrating from XState — `pendingAction` pattern](https://docs.directive.run/migrating-from-xstate#the-pendingaction-pattern-12-cycles-confirmed)
287
+ - [Internal events](https://docs.directive.run/patterns/internal-events) — when `status` alone is enough
288
+ - [`MIGRATION_FEEDBACK.md` items 17 + 19](https://github.com/directive-run/directive/blob/main/docs/MIGRATION_FEEDBACK.md)
289
+
290
+ ## License
291
+
292
+ MIT OR Apache-2.0
package/dist/index.cjs ADDED
@@ -0,0 +1,2 @@
1
+ 'use strict';var core=require('@directive-run/core');var c=500;function p(e){let n;if(e instanceof Error){let t;try{t=e.message;}catch{t="[mutator: error.message getter threw]";}n=typeof t=="string"?t:g(t);}else typeof e=="string"?n=e:n=g(e);return n.length<=c?n:`${n.slice(0,c-1)}\u2026`}function g(e){try{return String(e)}catch{return "[mutator: unstringifiable error]"}}function T(e){return {facts:{pendingMutation:core.t.object().nullable()},events:{MUTATE:void 0},requirements:{PROCESS_MUTATION:{}},eventHandlers:{MUTATE:(o,r)=>{o.pendingMutation={...r,status:"pending",error:null};}},constraints:{pendingMutation:{when:o=>o.pendingMutation!==null&&o.pendingMutation?.status==="pending",require:{type:"PROCESS_MUTATION"}}},resolvers:{mutationResolver:{requirement:"PROCESS_MUTATION",resolve:async(o,r)=>{let i=r.facts,a=i.pendingMutation;if(a===null)return;i.pendingMutation={...a,status:"running"};let M=Object.prototype.hasOwnProperty.call(e,a.kind)?e[a.kind]:void 0;if(typeof M!="function"){i.pendingMutation={...a,status:"failed",error:p(`[mutator] no handler registered for variant: ${String(a.kind)}`)};return}let f={facts:r.facts,payload:a.payload,requeue:r.requeue??(()=>{})};try{await M(f),i.pendingMutation?.status==="running"&&(i.pendingMutation=null);}catch(y){if(i.pendingMutation?.status!=="running")return;i.pendingMutation={...a,status:"failed",error:p(y)};}}}},initialPendingMutation:null}}function x(e,n){return {kind:e,payload:n??{},status:"pending",error:null}}function k(e,n){let t=e.supersedeOn??"self",u=e.timeoutMs,l=e.setTimeout??v,s;return async d=>{t==="self"&&s!==void 0&&s.abort({kind:"superseded"});let o=new AbortController;s=o;let r;typeof u=="number"&&u>0&&(r=l(()=>{o.abort({kind:"timeout",afterMs:u});},u));try{await n({facts:d.facts,payload:d.payload,requeue:d.requeue,signal:o.signal});}finally{r?.(),s===o&&(s=void 0);}}}function v(e,n){let t=globalThis.setTimeout(e,n);return ()=>globalThis.clearTimeout(t)}exports.cancellable=k;exports.defineMutator=T;exports.mutate=x;//# sourceMappingURL=index.cjs.map
2
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +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"]}
@@ -0,0 +1,350 @@
1
+ import { t } from '@directive-run/core';
2
+
3
+ /**
4
+ * @directive-run/mutator
5
+ *
6
+ * Discriminated mutation helper. Collapses the manual `pendingAction`
7
+ * ceremony — fact + event + constraint + resolver — into a typed handler
8
+ * map.
9
+ *
10
+ * Background: across the 55-cycle Minglingo migration, 12 modules ended
11
+ * up with the same shape:
12
+ * - a nullable `pendingAction` fact holding a discriminated union
13
+ * - an event that sets it
14
+ * - a constraint that fires on non-null
15
+ * - a resolver that switches on the discriminator and clears the fact
16
+ *
17
+ * That's ~50 lines of boilerplate per module times 12 modules. The
18
+ * `defineMutator` helper below contributes all four pieces from a single
19
+ * typed declaration, so a module that uses it spreads the fragments
20
+ * into its `createModule` config and writes only the per-variant handler
21
+ * bodies.
22
+ *
23
+ * @see ../README.md for the full API and a worked example.
24
+ */
25
+
26
+ /**
27
+ * A keyed map of variant payloads. Each key becomes a discriminator value
28
+ * for the mutation, each value is the payload type for that variant.
29
+ *
30
+ * @example
31
+ * ```ts
32
+ * type MyMutations = {
33
+ * submit: { values: FormValues };
34
+ * cancel: {};
35
+ * retry: { reason: string };
36
+ * };
37
+ * ```
38
+ */
39
+ type MutationMap = Record<string, Record<string, unknown>>;
40
+ /**
41
+ * The shape of a `pendingMutation` fact while a mutation is queued or
42
+ * running.
43
+ */
44
+ type PendingMutation<M extends MutationMap> = {
45
+ [K in keyof M]: {
46
+ kind: K;
47
+ payload: M[K];
48
+ /**
49
+ * `pending` — queued, constraint hasn't fired yet.
50
+ * `running` — handler is in flight.
51
+ * `failed` — handler threw; constraint stops firing. Caller can
52
+ * inspect `error` and dispatch a fresh MUTATE to retry.
53
+ */
54
+ status: "pending" | "running" | "failed";
55
+ /**
56
+ * Error message from the previous run, if any. Plaintext only —
57
+ * NEVER render this directly via `dangerouslySetInnerHTML` or
58
+ * markdown. Truncated to 500 chars to bound XSS surface from
59
+ * thrown messages that may echo user input.
60
+ */
61
+ error: string | null;
62
+ };
63
+ }[keyof M];
64
+ /**
65
+ * Handler context passed to each variant handler.
66
+ *
67
+ * Note: `deps` is NOT in the context. This matches the Directive resolver
68
+ * idiom — close over deps from the outer module-factory scope:
69
+ *
70
+ * ```ts
71
+ * function createFormModule(deps: FormDeps) {
72
+ * const mut = defineMutator<FormMutations, FormFacts>({
73
+ * submit: async ({ payload, facts }) => {
74
+ * facts.values = await deps.submit(payload.values); // ← closure
75
+ * },
76
+ * });
77
+ * return createModule('form', { ... });
78
+ * }
79
+ * ```
80
+ */
81
+ interface MutatorHandlerContext<F> {
82
+ /** Live facts proxy. Reads are cache-tracked; writes invalidate. */
83
+ facts: F;
84
+ /**
85
+ * Trigger a same-constraint re-fire after this handler returns. Useful
86
+ * when one mutation cascades into another — without `requeue`, the next
87
+ * mutation would stall behind same-flush suppression.
88
+ *
89
+ * @see https://docs.directive.run/testing/chained-pipelines
90
+ */
91
+ requeue: () => void;
92
+ }
93
+ /**
94
+ * Variant handler body. Receives the typed payload + a context; returns
95
+ * void or a Promise. Throwing is fine — the runtime captures into
96
+ * `pendingMutation.error` before clearing the fact.
97
+ */
98
+ type MutationHandler<M extends MutationMap, K extends keyof M, F> = keyof M[K] extends never ? (ctx: MutatorHandlerContext<F>) => void | Promise<void> : (ctx: MutatorHandlerContext<F> & {
99
+ payload: M[K];
100
+ }) => void | Promise<void>;
101
+ /**
102
+ * The full handler map. Every variant in `M` MUST have a handler.
103
+ */
104
+ type MutationHandlers<M extends MutationMap, F> = {
105
+ [K in keyof M]: MutationHandler<M, K, F>;
106
+ };
107
+ /**
108
+ * The fragments returned by `defineMutator`. Spread each fragment into
109
+ * the matching position of your `createModule` config.
110
+ *
111
+ * @internal Shape documented for type clarity; users typically just
112
+ * spread.
113
+ */
114
+ interface MutatorFragments<M extends MutationMap, F> {
115
+ /** Spread into `schema.facts`. Adds `pendingMutation`. */
116
+ facts: {
117
+ pendingMutation: ReturnType<typeof t.object>;
118
+ };
119
+ /** Spread into `schema.events`. Adds the `MUTATE` event. */
120
+ events: {
121
+ MUTATE: PendingMutation<M>;
122
+ };
123
+ /** Spread into `schema.requirements`. Adds `PROCESS_MUTATION`. */
124
+ requirements: {
125
+ PROCESS_MUTATION: Record<string, never>;
126
+ };
127
+ /** Spread into the `events` field. Sets pendingMutation on MUTATE. */
128
+ eventHandlers: {
129
+ MUTATE: (facts: F, payload: PendingMutation<M>) => void;
130
+ };
131
+ /** Spread into the `constraints` field. */
132
+ constraints: {
133
+ pendingMutation: {
134
+ when: (facts: F) => boolean;
135
+ require: {
136
+ type: "PROCESS_MUTATION";
137
+ };
138
+ };
139
+ };
140
+ /** Spread into the `resolvers` field. */
141
+ resolvers: {
142
+ mutationResolver: {
143
+ requirement: "PROCESS_MUTATION";
144
+ resolve: (req: {
145
+ type: "PROCESS_MUTATION";
146
+ }, ctx: {
147
+ facts: F;
148
+ }) => Promise<void>;
149
+ };
150
+ };
151
+ /**
152
+ * Convenience: the initial value for `pendingMutation`. Set this in
153
+ * your module's `init` if you don't use `t.X().default(...)` defaults.
154
+ */
155
+ initialPendingMutation: null;
156
+ }
157
+ /**
158
+ * Define a mutator fragment-set for a given variant map and handlers.
159
+ *
160
+ * @example
161
+ * ```ts
162
+ * type FormMutations = {
163
+ * submit: { values: FormValues };
164
+ * cancel: {};
165
+ * };
166
+ *
167
+ * // The second generic is the FACTS type (not deps). Deps are
168
+ * // captured in closure from the surrounding scope, not passed in
169
+ * // through the handler ctx.
170
+ * const formMutator = defineMutator<FormMutations, FormFacts>({
171
+ * submit: async ({ payload, facts }) => {
172
+ * facts.values = await deps.submit(payload.values); // deps closes over outer scope
173
+ * },
174
+ * cancel: ({ facts }) => { facts.values = []; },
175
+ * });
176
+ *
177
+ * createModule('form', {
178
+ * schema: {
179
+ * facts: { ...formMutator.facts, values: t.array<FormValues>() },
180
+ * events: { ...formMutator.events, REFRESH: {} },
181
+ * requirements: { ...formMutator.requirements },
182
+ * },
183
+ * init: (f) => { f.pendingMutation = null; f.values = []; },
184
+ * events: {
185
+ * ...formMutator.eventHandlers,
186
+ * REFRESH: (f) => { f.values = []; },
187
+ * },
188
+ * constraints: { ...formMutator.constraints },
189
+ * resolvers: { ...formMutator.resolvers },
190
+ * });
191
+ *
192
+ * // Usage:
193
+ * sys.events.MUTATE(mutate<FormMutations>('submit', { values: ... }));
194
+ *
195
+ * // Or, if constructing the payload manually, the discriminator is
196
+ * // `kind` (NOT `type` — `type` collides with Directive's event-name
197
+ * // dispatch convention):
198
+ * sys.events.MUTATE({
199
+ * kind: 'submit',
200
+ * payload: { values: ... },
201
+ * status: 'pending',
202
+ * error: null,
203
+ * });
204
+ * ```
205
+ *
206
+ * The handler-bound deps are captured at `defineMutator` call time. To
207
+ * inject deps from the caller, wrap `defineMutator` in your module
208
+ * factory:
209
+ *
210
+ * ```ts
211
+ * export function createFormModule(deps: FormDeps) {
212
+ * const mut = defineMutator<FormMutations, FormFacts>({
213
+ * submit: async ({ payload, facts }) => {
214
+ * facts.values = await deps.submit(payload.values);
215
+ * },
216
+ * cancel: ({ facts }) => { facts.values = []; },
217
+ * });
218
+ * return createModule('form', { ... });
219
+ * }
220
+ * ```
221
+ */
222
+ declare function defineMutator<M extends MutationMap, F = Record<string, unknown>>(handlers: MutationHandlers<M, F>): MutatorFragments<M, F>;
223
+ /**
224
+ * Helper for typed dispatch. Lets the caller construct a mutation payload
225
+ * with full type narrowing — the `kind` field auto-restricts the
226
+ * `payload` shape.
227
+ *
228
+ * @example
229
+ * ```ts
230
+ * sys.events.MUTATE(mutate<FormMutations>('submit', { values: ... }));
231
+ * sys.events.MUTATE(mutate<FormMutations>('cancel'));
232
+ * ```
233
+ */
234
+ declare function mutate<M extends MutationMap>(kind: keyof M & string, payload: M[typeof kind]): PendingMutation<M>;
235
+ declare function mutate<M extends MutationMap>(kind: keyof M & string): PendingMutation<M>;
236
+ /**
237
+ * Options for {@link cancellable}.
238
+ */
239
+ interface CancellableOptions {
240
+ /**
241
+ * When to fire the AbortSignal that the wrapped handler receives.
242
+ *
243
+ * - `'self'` (default): a new dispatch of the SAME handler aborts the
244
+ * prior in-flight invocation. The classic "cancel previous on new
245
+ * keystroke" pattern.
246
+ * - `'never'`: only the timeout fires the signal; new dispatches do
247
+ * NOT abort prior ones. Useful when you want a hard timeout but
248
+ * parallel runs are fine.
249
+ */
250
+ supersedeOn?: "self" | "never";
251
+ /**
252
+ * Maximum ms a handler may run before its signal is aborted. Counted
253
+ * from the start of THIS invocation. Combine with `realClock` for
254
+ * production or pass a `setTimeout` shim for deterministic testing.
255
+ */
256
+ timeoutMs?: number;
257
+ /**
258
+ * Optional `setTimeout` injection. Defaults to `globalThis.setTimeout`.
259
+ * For deterministic tests, pass `virtualClock.setTimeout` from
260
+ * `@directive-run/core` so the timeout fires under
261
+ * `clock.advanceBy()` instead of wall-clock real time.
262
+ *
263
+ * @example
264
+ * ```ts
265
+ * import { virtualClock } from '@directive-run/core';
266
+ * const clock = virtualClock(0);
267
+ * cancellable({ timeoutMs: 1_000, setTimeout: clock.setTimeout }, handler);
268
+ * ```
269
+ */
270
+ setTimeout?: (cb: () => void, ms: number) => () => void;
271
+ }
272
+ /**
273
+ * Handler context augmented with a cancellation signal.
274
+ */
275
+ interface CancellableHandlerContext<F, P> {
276
+ facts: F;
277
+ payload: P;
278
+ /** Aborts when a new dispatch supersedes this one OR the timeout fires. */
279
+ signal: AbortSignal;
280
+ requeue: () => void;
281
+ }
282
+ /**
283
+ * Reason a cancellable handler's signal aborted. Stamped on
284
+ * `signal.reason` so the handler can disambiguate.
285
+ */
286
+ type CancelReason = {
287
+ kind: "superseded";
288
+ } | {
289
+ kind: "timeout";
290
+ afterMs: number;
291
+ };
292
+ /**
293
+ * Wrap a mutator handler with auto-cancellation. The wrapped handler
294
+ * receives an extra `signal: AbortSignal` in its context. Use the
295
+ * signal to short-circuit awaitable work — pass it to `fetch(url, {
296
+ * signal })`, watch it inside long-running loops, etc.
297
+ *
298
+ * Two cancellation triggers, both opt-in via {@link CancellableOptions}:
299
+ *
300
+ * 1. **Supersession** (default `supersedeOn: 'self'`): when a new
301
+ * dispatch of the same wrapped handler arrives while a prior
302
+ * invocation is still running, the prior signal aborts.
303
+ * 2. **Timeout** (default `timeoutMs: undefined`, meaning no timeout):
304
+ * after `timeoutMs` ms from invocation start, the signal aborts.
305
+ *
306
+ * If a handler's signal aborts, the handler should observe the abort
307
+ * (via `signal.aborted` or the AbortError-throwing helpers) and
308
+ * return promptly. The signal's `reason` carries a {@link CancelReason}
309
+ * disambiguating which trigger fired.
310
+ *
311
+ * Compose with {@link defineMutator} by using `cancellable()` directly
312
+ * in the handler map:
313
+ *
314
+ * @example
315
+ * ```ts
316
+ * import { defineMutator, cancellable } from '@directive-run/mutator';
317
+ *
318
+ * const formMutator = defineMutator<MyMutations, MyFacts>({
319
+ * search: cancellable(
320
+ * { supersedeOn: 'self', timeoutMs: 3_000 },
321
+ * async ({ payload, facts, signal }) => {
322
+ * const res = await fetch(`/q?${payload.q}`, { signal });
323
+ * facts.results = await res.json();
324
+ * },
325
+ * ),
326
+ * submit: async ({ payload, facts }) => {
327
+ * // No cancellation needed for submit — plain handler.
328
+ * facts.values = await deps.submit(payload.values);
329
+ * },
330
+ * });
331
+ * ```
332
+ *
333
+ * **Idempotency note.** The wrapped handler stays a regular
334
+ * `MutationHandler<M, K, F>` from the mutator's perspective. The
335
+ * supersession registry is closure-scoped per `cancellable()` call —
336
+ * two separate `cancellable(...)` HOCs around different handlers do
337
+ * NOT cancel each other.
338
+ *
339
+ * **Test ergonomics.** Pass `virtualClock.setTimeout` via the
340
+ * `setTimeout` option to make timeouts deterministic under
341
+ * `clock.advanceBy(ms)`. Without that, timeouts use wall-clock
342
+ * `globalThis.setTimeout` and are real-time.
343
+ */
344
+ declare function cancellable<F, P>(opts: CancellableOptions, handler: (ctx: CancellableHandlerContext<F, P>) => Promise<void> | void): (ctx: {
345
+ facts: F;
346
+ payload: P;
347
+ requeue: () => void;
348
+ }) => Promise<void>;
349
+
350
+ export { type CancelReason, type CancellableHandlerContext, type CancellableOptions, type MutationHandler, type MutationHandlers, type MutationMap, type MutatorFragments, type MutatorHandlerContext, type PendingMutation, cancellable, defineMutator, mutate };
@@ -0,0 +1,350 @@
1
+ import { t } from '@directive-run/core';
2
+
3
+ /**
4
+ * @directive-run/mutator
5
+ *
6
+ * Discriminated mutation helper. Collapses the manual `pendingAction`
7
+ * ceremony — fact + event + constraint + resolver — into a typed handler
8
+ * map.
9
+ *
10
+ * Background: across the 55-cycle Minglingo migration, 12 modules ended
11
+ * up with the same shape:
12
+ * - a nullable `pendingAction` fact holding a discriminated union
13
+ * - an event that sets it
14
+ * - a constraint that fires on non-null
15
+ * - a resolver that switches on the discriminator and clears the fact
16
+ *
17
+ * That's ~50 lines of boilerplate per module times 12 modules. The
18
+ * `defineMutator` helper below contributes all four pieces from a single
19
+ * typed declaration, so a module that uses it spreads the fragments
20
+ * into its `createModule` config and writes only the per-variant handler
21
+ * bodies.
22
+ *
23
+ * @see ../README.md for the full API and a worked example.
24
+ */
25
+
26
+ /**
27
+ * A keyed map of variant payloads. Each key becomes a discriminator value
28
+ * for the mutation, each value is the payload type for that variant.
29
+ *
30
+ * @example
31
+ * ```ts
32
+ * type MyMutations = {
33
+ * submit: { values: FormValues };
34
+ * cancel: {};
35
+ * retry: { reason: string };
36
+ * };
37
+ * ```
38
+ */
39
+ type MutationMap = Record<string, Record<string, unknown>>;
40
+ /**
41
+ * The shape of a `pendingMutation` fact while a mutation is queued or
42
+ * running.
43
+ */
44
+ type PendingMutation<M extends MutationMap> = {
45
+ [K in keyof M]: {
46
+ kind: K;
47
+ payload: M[K];
48
+ /**
49
+ * `pending` — queued, constraint hasn't fired yet.
50
+ * `running` — handler is in flight.
51
+ * `failed` — handler threw; constraint stops firing. Caller can
52
+ * inspect `error` and dispatch a fresh MUTATE to retry.
53
+ */
54
+ status: "pending" | "running" | "failed";
55
+ /**
56
+ * Error message from the previous run, if any. Plaintext only —
57
+ * NEVER render this directly via `dangerouslySetInnerHTML` or
58
+ * markdown. Truncated to 500 chars to bound XSS surface from
59
+ * thrown messages that may echo user input.
60
+ */
61
+ error: string | null;
62
+ };
63
+ }[keyof M];
64
+ /**
65
+ * Handler context passed to each variant handler.
66
+ *
67
+ * Note: `deps` is NOT in the context. This matches the Directive resolver
68
+ * idiom — close over deps from the outer module-factory scope:
69
+ *
70
+ * ```ts
71
+ * function createFormModule(deps: FormDeps) {
72
+ * const mut = defineMutator<FormMutations, FormFacts>({
73
+ * submit: async ({ payload, facts }) => {
74
+ * facts.values = await deps.submit(payload.values); // ← closure
75
+ * },
76
+ * });
77
+ * return createModule('form', { ... });
78
+ * }
79
+ * ```
80
+ */
81
+ interface MutatorHandlerContext<F> {
82
+ /** Live facts proxy. Reads are cache-tracked; writes invalidate. */
83
+ facts: F;
84
+ /**
85
+ * Trigger a same-constraint re-fire after this handler returns. Useful
86
+ * when one mutation cascades into another — without `requeue`, the next
87
+ * mutation would stall behind same-flush suppression.
88
+ *
89
+ * @see https://docs.directive.run/testing/chained-pipelines
90
+ */
91
+ requeue: () => void;
92
+ }
93
+ /**
94
+ * Variant handler body. Receives the typed payload + a context; returns
95
+ * void or a Promise. Throwing is fine — the runtime captures into
96
+ * `pendingMutation.error` before clearing the fact.
97
+ */
98
+ type MutationHandler<M extends MutationMap, K extends keyof M, F> = keyof M[K] extends never ? (ctx: MutatorHandlerContext<F>) => void | Promise<void> : (ctx: MutatorHandlerContext<F> & {
99
+ payload: M[K];
100
+ }) => void | Promise<void>;
101
+ /**
102
+ * The full handler map. Every variant in `M` MUST have a handler.
103
+ */
104
+ type MutationHandlers<M extends MutationMap, F> = {
105
+ [K in keyof M]: MutationHandler<M, K, F>;
106
+ };
107
+ /**
108
+ * The fragments returned by `defineMutator`. Spread each fragment into
109
+ * the matching position of your `createModule` config.
110
+ *
111
+ * @internal Shape documented for type clarity; users typically just
112
+ * spread.
113
+ */
114
+ interface MutatorFragments<M extends MutationMap, F> {
115
+ /** Spread into `schema.facts`. Adds `pendingMutation`. */
116
+ facts: {
117
+ pendingMutation: ReturnType<typeof t.object>;
118
+ };
119
+ /** Spread into `schema.events`. Adds the `MUTATE` event. */
120
+ events: {
121
+ MUTATE: PendingMutation<M>;
122
+ };
123
+ /** Spread into `schema.requirements`. Adds `PROCESS_MUTATION`. */
124
+ requirements: {
125
+ PROCESS_MUTATION: Record<string, never>;
126
+ };
127
+ /** Spread into the `events` field. Sets pendingMutation on MUTATE. */
128
+ eventHandlers: {
129
+ MUTATE: (facts: F, payload: PendingMutation<M>) => void;
130
+ };
131
+ /** Spread into the `constraints` field. */
132
+ constraints: {
133
+ pendingMutation: {
134
+ when: (facts: F) => boolean;
135
+ require: {
136
+ type: "PROCESS_MUTATION";
137
+ };
138
+ };
139
+ };
140
+ /** Spread into the `resolvers` field. */
141
+ resolvers: {
142
+ mutationResolver: {
143
+ requirement: "PROCESS_MUTATION";
144
+ resolve: (req: {
145
+ type: "PROCESS_MUTATION";
146
+ }, ctx: {
147
+ facts: F;
148
+ }) => Promise<void>;
149
+ };
150
+ };
151
+ /**
152
+ * Convenience: the initial value for `pendingMutation`. Set this in
153
+ * your module's `init` if you don't use `t.X().default(...)` defaults.
154
+ */
155
+ initialPendingMutation: null;
156
+ }
157
+ /**
158
+ * Define a mutator fragment-set for a given variant map and handlers.
159
+ *
160
+ * @example
161
+ * ```ts
162
+ * type FormMutations = {
163
+ * submit: { values: FormValues };
164
+ * cancel: {};
165
+ * };
166
+ *
167
+ * // The second generic is the FACTS type (not deps). Deps are
168
+ * // captured in closure from the surrounding scope, not passed in
169
+ * // through the handler ctx.
170
+ * const formMutator = defineMutator<FormMutations, FormFacts>({
171
+ * submit: async ({ payload, facts }) => {
172
+ * facts.values = await deps.submit(payload.values); // deps closes over outer scope
173
+ * },
174
+ * cancel: ({ facts }) => { facts.values = []; },
175
+ * });
176
+ *
177
+ * createModule('form', {
178
+ * schema: {
179
+ * facts: { ...formMutator.facts, values: t.array<FormValues>() },
180
+ * events: { ...formMutator.events, REFRESH: {} },
181
+ * requirements: { ...formMutator.requirements },
182
+ * },
183
+ * init: (f) => { f.pendingMutation = null; f.values = []; },
184
+ * events: {
185
+ * ...formMutator.eventHandlers,
186
+ * REFRESH: (f) => { f.values = []; },
187
+ * },
188
+ * constraints: { ...formMutator.constraints },
189
+ * resolvers: { ...formMutator.resolvers },
190
+ * });
191
+ *
192
+ * // Usage:
193
+ * sys.events.MUTATE(mutate<FormMutations>('submit', { values: ... }));
194
+ *
195
+ * // Or, if constructing the payload manually, the discriminator is
196
+ * // `kind` (NOT `type` — `type` collides with Directive's event-name
197
+ * // dispatch convention):
198
+ * sys.events.MUTATE({
199
+ * kind: 'submit',
200
+ * payload: { values: ... },
201
+ * status: 'pending',
202
+ * error: null,
203
+ * });
204
+ * ```
205
+ *
206
+ * The handler-bound deps are captured at `defineMutator` call time. To
207
+ * inject deps from the caller, wrap `defineMutator` in your module
208
+ * factory:
209
+ *
210
+ * ```ts
211
+ * export function createFormModule(deps: FormDeps) {
212
+ * const mut = defineMutator<FormMutations, FormFacts>({
213
+ * submit: async ({ payload, facts }) => {
214
+ * facts.values = await deps.submit(payload.values);
215
+ * },
216
+ * cancel: ({ facts }) => { facts.values = []; },
217
+ * });
218
+ * return createModule('form', { ... });
219
+ * }
220
+ * ```
221
+ */
222
+ declare function defineMutator<M extends MutationMap, F = Record<string, unknown>>(handlers: MutationHandlers<M, F>): MutatorFragments<M, F>;
223
+ /**
224
+ * Helper for typed dispatch. Lets the caller construct a mutation payload
225
+ * with full type narrowing — the `kind` field auto-restricts the
226
+ * `payload` shape.
227
+ *
228
+ * @example
229
+ * ```ts
230
+ * sys.events.MUTATE(mutate<FormMutations>('submit', { values: ... }));
231
+ * sys.events.MUTATE(mutate<FormMutations>('cancel'));
232
+ * ```
233
+ */
234
+ declare function mutate<M extends MutationMap>(kind: keyof M & string, payload: M[typeof kind]): PendingMutation<M>;
235
+ declare function mutate<M extends MutationMap>(kind: keyof M & string): PendingMutation<M>;
236
+ /**
237
+ * Options for {@link cancellable}.
238
+ */
239
+ interface CancellableOptions {
240
+ /**
241
+ * When to fire the AbortSignal that the wrapped handler receives.
242
+ *
243
+ * - `'self'` (default): a new dispatch of the SAME handler aborts the
244
+ * prior in-flight invocation. The classic "cancel previous on new
245
+ * keystroke" pattern.
246
+ * - `'never'`: only the timeout fires the signal; new dispatches do
247
+ * NOT abort prior ones. Useful when you want a hard timeout but
248
+ * parallel runs are fine.
249
+ */
250
+ supersedeOn?: "self" | "never";
251
+ /**
252
+ * Maximum ms a handler may run before its signal is aborted. Counted
253
+ * from the start of THIS invocation. Combine with `realClock` for
254
+ * production or pass a `setTimeout` shim for deterministic testing.
255
+ */
256
+ timeoutMs?: number;
257
+ /**
258
+ * Optional `setTimeout` injection. Defaults to `globalThis.setTimeout`.
259
+ * For deterministic tests, pass `virtualClock.setTimeout` from
260
+ * `@directive-run/core` so the timeout fires under
261
+ * `clock.advanceBy()` instead of wall-clock real time.
262
+ *
263
+ * @example
264
+ * ```ts
265
+ * import { virtualClock } from '@directive-run/core';
266
+ * const clock = virtualClock(0);
267
+ * cancellable({ timeoutMs: 1_000, setTimeout: clock.setTimeout }, handler);
268
+ * ```
269
+ */
270
+ setTimeout?: (cb: () => void, ms: number) => () => void;
271
+ }
272
+ /**
273
+ * Handler context augmented with a cancellation signal.
274
+ */
275
+ interface CancellableHandlerContext<F, P> {
276
+ facts: F;
277
+ payload: P;
278
+ /** Aborts when a new dispatch supersedes this one OR the timeout fires. */
279
+ signal: AbortSignal;
280
+ requeue: () => void;
281
+ }
282
+ /**
283
+ * Reason a cancellable handler's signal aborted. Stamped on
284
+ * `signal.reason` so the handler can disambiguate.
285
+ */
286
+ type CancelReason = {
287
+ kind: "superseded";
288
+ } | {
289
+ kind: "timeout";
290
+ afterMs: number;
291
+ };
292
+ /**
293
+ * Wrap a mutator handler with auto-cancellation. The wrapped handler
294
+ * receives an extra `signal: AbortSignal` in its context. Use the
295
+ * signal to short-circuit awaitable work — pass it to `fetch(url, {
296
+ * signal })`, watch it inside long-running loops, etc.
297
+ *
298
+ * Two cancellation triggers, both opt-in via {@link CancellableOptions}:
299
+ *
300
+ * 1. **Supersession** (default `supersedeOn: 'self'`): when a new
301
+ * dispatch of the same wrapped handler arrives while a prior
302
+ * invocation is still running, the prior signal aborts.
303
+ * 2. **Timeout** (default `timeoutMs: undefined`, meaning no timeout):
304
+ * after `timeoutMs` ms from invocation start, the signal aborts.
305
+ *
306
+ * If a handler's signal aborts, the handler should observe the abort
307
+ * (via `signal.aborted` or the AbortError-throwing helpers) and
308
+ * return promptly. The signal's `reason` carries a {@link CancelReason}
309
+ * disambiguating which trigger fired.
310
+ *
311
+ * Compose with {@link defineMutator} by using `cancellable()` directly
312
+ * in the handler map:
313
+ *
314
+ * @example
315
+ * ```ts
316
+ * import { defineMutator, cancellable } from '@directive-run/mutator';
317
+ *
318
+ * const formMutator = defineMutator<MyMutations, MyFacts>({
319
+ * search: cancellable(
320
+ * { supersedeOn: 'self', timeoutMs: 3_000 },
321
+ * async ({ payload, facts, signal }) => {
322
+ * const res = await fetch(`/q?${payload.q}`, { signal });
323
+ * facts.results = await res.json();
324
+ * },
325
+ * ),
326
+ * submit: async ({ payload, facts }) => {
327
+ * // No cancellation needed for submit — plain handler.
328
+ * facts.values = await deps.submit(payload.values);
329
+ * },
330
+ * });
331
+ * ```
332
+ *
333
+ * **Idempotency note.** The wrapped handler stays a regular
334
+ * `MutationHandler<M, K, F>` from the mutator's perspective. The
335
+ * supersession registry is closure-scoped per `cancellable()` call —
336
+ * two separate `cancellable(...)` HOCs around different handlers do
337
+ * NOT cancel each other.
338
+ *
339
+ * **Test ergonomics.** Pass `virtualClock.setTimeout` via the
340
+ * `setTimeout` option to make timeouts deterministic under
341
+ * `clock.advanceBy(ms)`. Without that, timeouts use wall-clock
342
+ * `globalThis.setTimeout` and are real-time.
343
+ */
344
+ declare function cancellable<F, P>(opts: CancellableOptions, handler: (ctx: CancellableHandlerContext<F, P>) => Promise<void> | void): (ctx: {
345
+ facts: F;
346
+ payload: P;
347
+ requeue: () => void;
348
+ }) => Promise<void>;
349
+
350
+ export { type CancelReason, type CancellableHandlerContext, type CancellableOptions, type MutationHandler, type MutationHandlers, type MutationMap, type MutatorFragments, type MutatorHandlerContext, type PendingMutation, cancellable, defineMutator, mutate };
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ import {t}from'@directive-run/core';var c=500;function p(e){let n;if(e instanceof Error){let t;try{t=e.message;}catch{t="[mutator: error.message getter threw]";}n=typeof t=="string"?t:g(t);}else typeof e=="string"?n=e:n=g(e);return n.length<=c?n:`${n.slice(0,c-1)}\u2026`}function g(e){try{return String(e)}catch{return "[mutator: unstringifiable error]"}}function T(e){return {facts:{pendingMutation:t.object().nullable()},events:{MUTATE:void 0},requirements:{PROCESS_MUTATION:{}},eventHandlers:{MUTATE:(o,r)=>{o.pendingMutation={...r,status:"pending",error:null};}},constraints:{pendingMutation:{when:o=>o.pendingMutation!==null&&o.pendingMutation?.status==="pending",require:{type:"PROCESS_MUTATION"}}},resolvers:{mutationResolver:{requirement:"PROCESS_MUTATION",resolve:async(o,r)=>{let i=r.facts,a=i.pendingMutation;if(a===null)return;i.pendingMutation={...a,status:"running"};let M=Object.prototype.hasOwnProperty.call(e,a.kind)?e[a.kind]:void 0;if(typeof M!="function"){i.pendingMutation={...a,status:"failed",error:p(`[mutator] no handler registered for variant: ${String(a.kind)}`)};return}let f={facts:r.facts,payload:a.payload,requeue:r.requeue??(()=>{})};try{await M(f),i.pendingMutation?.status==="running"&&(i.pendingMutation=null);}catch(y){if(i.pendingMutation?.status!=="running")return;i.pendingMutation={...a,status:"failed",error:p(y)};}}}},initialPendingMutation:null}}function x(e,n){return {kind:e,payload:n??{},status:"pending",error:null}}function k(e,n){let t=e.supersedeOn??"self",u=e.timeoutMs,l=e.setTimeout??v,s;return async d=>{t==="self"&&s!==void 0&&s.abort({kind:"superseded"});let o=new AbortController;s=o;let r;typeof u=="number"&&u>0&&(r=l(()=>{o.abort({kind:"timeout",afterMs:u});},u));try{await n({facts:d.facts,payload:d.payload,requeue:d.requeue,signal:o.signal});}finally{r?.(),s===o&&(s=void 0);}}}function v(e,n){let t=globalThis.setTimeout(e,n);return ()=>globalThis.clearTimeout(t)}export{k as cancellable,T as defineMutator,x as mutate};//# sourceMappingURL=index.js.map
2
+ //# sourceMappingURL=index.js.map
@@ -0,0 +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"]}
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "@directive-run/mutator",
3
+ "version": "0.2.0",
4
+ "description": "Discriminated mutation helper for Directive — collapse the pendingAction ceremony to a typed handler map.",
5
+ "license": "(MIT OR Apache-2.0)",
6
+ "author": "Jason Comes",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/directive-run/directive",
10
+ "directory": "packages/mutator"
11
+ },
12
+ "homepage": "https://directive.run",
13
+ "bugs": {
14
+ "url": "https://github.com/directive-run/directive/issues"
15
+ },
16
+ "publishConfig": {
17
+ "access": "public",
18
+ "registry": "https://registry.npmjs.org"
19
+ },
20
+ "engines": {
21
+ "node": ">=18"
22
+ },
23
+ "keywords": [
24
+ "directive",
25
+ "mutator",
26
+ "state-management",
27
+ "discriminated-union",
28
+ "optimistic-update"
29
+ ],
30
+ "sideEffects": false,
31
+ "type": "module",
32
+ "main": "./dist/index.cjs",
33
+ "module": "./dist/index.js",
34
+ "types": "./dist/index.d.ts",
35
+ "exports": {
36
+ ".": {
37
+ "types": "./dist/index.d.ts",
38
+ "require": "./dist/index.cjs",
39
+ "import": "./dist/index.js"
40
+ }
41
+ },
42
+ "files": [
43
+ "dist",
44
+ "README.md",
45
+ "CHANGELOG.md"
46
+ ],
47
+ "peerDependencies": {
48
+ "@directive-run/core": "^1.2.0"
49
+ },
50
+ "devDependencies": {
51
+ "tsup": "^8.3.5",
52
+ "typescript": "^5.7.2",
53
+ "vitest": "^2.1.9",
54
+ "@directive-run/core": "1.3.0"
55
+ },
56
+ "scripts": {
57
+ "build": "tsup",
58
+ "dev": "tsup --watch",
59
+ "test": "vitest run",
60
+ "typecheck": "tsc --noEmit",
61
+ "clean": "rm -rf dist"
62
+ }
63
+ }