@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 +95 -0
- package/LICENSE +26 -0
- package/README.md +292 -0
- package/dist/index.cjs +2 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +350 -0
- package/dist/index.d.ts +350 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/package.json +63 -0
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"]}
|
package/dist/index.d.cts
ADDED
|
@@ -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.d.ts
ADDED
|
@@ -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
|
+
}
|