@directive-run/mutator
v0.3.0
Published
Discriminated mutation helper for Directive — collapse the pendingAction ceremony to a typed handler map.
Maintainers
Readme
@directive-run/mutator
Discriminated mutation helper for Directive — collapse the
pendingActionceremony to a typed handler map.
npm install @directive-run/mutatorNaming heads-up: the mutation discriminator is named
kind, nottype. Directive's event dispatcher reservespayload.typefor its own event-name routing —typehere would collide withMUTATEand route the dispatch to a non-existent event handler. Usekindeverywhere; the typedmutate(kind, payload)constructor builds the right shape for you.
What it solves
Across the 55-cycle Minglingo XState→Directive migration, 12 modules ended up with the same shape:
- a nullable
pendingActionfact holding a discriminated union - an event handler that sets it
- a constraint that fires while it's non-null
- a resolver that switches on the discriminator and clears the fact
That's ~50 lines of boilerplate per module. This package contributes all four pieces from a single typed declaration, so you write only the per-variant handler bodies.
Quick start
import { createModule, createSystem, t } from '@directive-run/core';
import { defineMutator, mutate } from '@directive-run/mutator';
type FormMutations = {
submit: { values: FormValues };
cancel: {};
retry: { reason: string };
};
interface FormDeps {
submit: (values: FormValues) => Promise<FormValues>;
}
export function createFormModule(deps: FormDeps) {
// Idiomatic Directive: handlers close over deps from the factory scope.
const mut = defineMutator<FormMutations, FormFacts>({
submit: async ({ payload, facts }) => {
facts.values = await deps.submit(payload.values); // ← closure
},
cancel: ({ facts }) => { facts.values = null; },
retry: async ({ payload, facts }) => {
facts.lastRetryReason = payload.reason;
},
});
return createModule('form', {
schema: {
facts: {
...mut.facts, // → adds `pendingMutation`
values: t.object<FormValues>().nullable(),
lastRetryReason: t.string().nullable(),
},
events: { ...mut.events }, // → adds `MUTATE` event
requirements: { ...mut.requirements }, // → adds PROCESS_MUTATION
},
init: (f) => {
f.pendingMutation = null;
f.values = null;
f.lastRetryReason = null;
},
events: { ...mut.eventHandlers }, // sets pendingMutation on MUTATE
constraints: { ...mut.constraints },
resolvers: { ...mut.resolvers },
});
}
// Usage:
const sys = createSystem({ module: createFormModule(deps), deps });
sys.start();
sys.events.MUTATE(mutate<FormMutations>('submit', { values }));The mutate(kind, payload?) helper is a typed payload constructor.
The kind argument restricts the payload shape — passing a
wrong-shape payload is a compile error.
Anatomy
defineMutator(handlers) returns six fragments. You spread each into the
matching position of your createModule config:
| Fragment | Spreads into | Contributes |
|---|---|---|
| mut.facts | schema.facts | pendingMutation: t.object<DiscriminatedUnion>().nullable() |
| mut.events | schema.events | MUTATE: PendingMutation<M> |
| mut.requirements | schema.requirements | PROCESS_MUTATION: {} |
| mut.eventHandlers | events: | MUTATE handler that sets pendingMutation |
| mut.constraints | constraints: | pendingMutation: { when, require } |
| mut.resolvers | resolvers: | dispatches to the handler matching the discriminator |
The total spread cost is six lines. The savings come from not writing the constraint/resolver/dispatch bodies yourself.
Lifecycle
sys.events.MUTATE({ kind, payload, status: 'pending', error: null })
→ pendingMutation fact set to that value
→ constraint fires (pendingMutation !== null && status === 'pending')
→ resolver wakes
→ marks status: 'running'
→ looks up handler by kind
→ calls handler({ payload, facts, deps, requeue })
→ on success: pendingMutation = null
→ on throw: pendingMutation.status = 'failed' + .error = message
(constraint stops firing — no infinite retry; UI can
disambiguate "still running" from "stopped on error")
kind(nottype) discriminates the mutation variant. Directive's own event dispatcher reserves thetypefield for its own event-name routing — colliding here would route the dispatch to a nonexistent event handler.kindkeeps the two namespaces separate.
A failed mutation leaves pendingMutation non-null with status:
'failed' (a distinct status from 'running', so the UI can
disambiguate "still working" from "stopped on error"). Read
pendingMutation.error to surface to the UI; dispatch a fresh MUTATE
to retry (which overwrites the failed fact and re-fires).
XSS warning. pendingMutation.error is a plaintext string that
may echo handler-thrown messages, which in turn may have interpolated
user-controlled input. Render it via {error} in JSX (default-escaped)
or textContent — never via dangerouslySetInnerHTML, markdown
rendering, or any other HTML-evaluating sink. The runtime truncates
captured errors to 500 characters as a defense in depth, but that does
not sanitize content; only escape on render.
Concurrency
The default model is single-flight — one mutation in flight at a time. If
a new MUTATE arrives while a handler is running, it overwrites the fact
and the constraint re-fires once the in-flight handler completes (which
nulls the fact, then the new value triggers another firing).
If you need parallel mutations of different shapes (e.g. submit AND
uploadFile running concurrently), use two mutators with distinct fact
names — one per shape. v0.1 doesn't support parallel-of-same-shape; the
behaviour there is "last-write-wins."
Same-constraint re-fire (requeue)
When one handler dispatches another MUTATE synchronously, the new
mutation may stall behind same-flush suppression in Directive's engine.
Call ctx.requeue() inside the handler to opt into a re-fire:
const mut = defineMutator<Mutations, MyFacts>({
step1: async ({ facts, requeue }) => {
facts.step1Done = true;
// queue step2:
facts.pendingMutation = mutate<Mutations>('step2');
requeue(); // explicit — without this, step2 may stall
},
step2: ({ facts }) => { facts.step2Done = true; },
});Most modules don't need requeue — the next user-event-driven MUTATE
fires fine. It's specifically for handler-cascades-into-handler.
See Directive testing § same-constraint re-fire.
Type safety
The MutationMap generic is the source of truth. Every variant key
becomes:
- a possible
kindvalue onpendingMutation - a payload-constrained dispatch via
mutate('key', payload) - a required handler in the map (TypeScript errors if you forget one)
- a typed
payloadargument inside that handler
There is no runtime variant validation today — the type system catches
mismatches at the dispatch site, but a malformed MUTATE from outside
TypeScript (e.g. WebSocket frame) will still hit the resolver. If you
need runtime checks, validate at the boundary before dispatch.
When NOT to use a mutator
- One-off events with no error path. A simple
event.handle('OPEN', (f) => { f.isOpen = true; })doesn't need this — there's no async work, no rollback, no error fact. - Long-running streams. Subscriptions, polls, websocket fan-in — these aren't single-shot mutations. Wire them through normal events.
- Pure derivations. If the result is a function of existing facts,
use a
deriveinstead of a mutator.
The mutator earns its weight when you have multi-variant async work with a discriminator. That's the 12-instance shape from the migration.
Auto-cancel on supersede (R1.C cancellable())
For mutations where a fresh dispatch should cancel the prior in-flight
one — type-ahead search, debounce, throttle, request dedup — wrap
the handler with cancellable(). The wrapped handler receives a
signal: AbortSignal that aborts when superseded or when an
optional timeout fires:
import { defineMutator, cancellable } from '@directive-run/mutator';
const formMutator = defineMutator<MyMutations, MyFacts>({
search: cancellable(
{ supersedeOn: 'self', timeoutMs: 3_000 },
async ({ payload, facts, signal }) => {
const res = await fetch(`/q?${payload.q}`, { signal });
facts.results = await res.json();
},
),
submit: async ({ payload, facts }) => {
// No cancellation — plain handler.
facts.values = await deps.submit(payload.values);
},
});Two cancellation triggers, both opt-in:
supersedeOn: 'self'(default) — a new dispatch of the same wrapped handler aborts the prior in-flight invocation. Set'never'if parallel runs are fine.timeoutMs: number— abort after N ms from invocation start. Default unset (no timeout).
Test ergonomics: pass virtualClock.setTimeout from
@directive-run/core via the setTimeout option to make timeouts
fire synchronously under clock.advanceBy(ms):
import { virtualClock } from '@directive-run/core';
const clock = virtualClock(0);
const wrapped = cancellable(
{ timeoutMs: 1_000, setTimeout: clock.setTimeout },
handler,
);
// In tests: clock.advanceBy(1_001) fires the timeout deterministically.The signal's reason carries a CancelReason:
type CancelReason =
| { kind: 'superseded' }
| { kind: 'timeout'; afterMs: number };Use it inside handlers to distinguish how the cancellation arrived (e.g. log a different message for timeouts vs supersession).
Recording cancellations for replay (R2.B recordReplayable())
recordReplayable() is cancellable() plus a synchronous onCancel
callback that fires the moment the AbortController calls abort() —
before the handler's pending await rejects with AbortError. The
callback receives a structured CancelEvent with the cancel kind,
payload, dispatch sequence, and a live facts reference, so you can
pin cancellations into a place that survives in the timeline.
Use this when you record a timeline (with @directive-run/timeline)
and want a replay or directive bisect to reason about which
dispatches were superseded vs which completed — not just see a
free-form error string.
import { defineMutator, recordReplayable } from '@directive-run/mutator';
interface MyFacts {
results: string[];
cancellations: Array<{ kind: string; queryAtCancel: string; seq: number }>;
}
const search = recordReplayable<MyFacts, { q: string }>(
{
supersedeOn: 'self',
timeoutMs: 3_000,
onCancel: ({ facts, kind, payload, dispatchSeq }) => {
// Pin the cancel event into facts so the timeline carries it.
facts.cancellations.push({
kind,
queryAtCancel: payload.q,
seq: dispatchSeq,
});
},
},
async ({ payload, facts, signal }) => {
const res = await fetch(`/q?${payload.q}`, { signal });
facts.results = await res.json();
},
);recordReplayable() is implemented as cancellable(opts, innerHandler) where innerHandler adds an addEventListener('abort') around the user's handler — timeout/supersession semantics are exactly those of cancellable(). The callback is generic ("call me when abort fires"); pinning into facts is one use case among many. Wire onCancel to Sentry breadcrumbs, a Redux action log, or a metrics sink with equal ease.
onCancel errors are caught and swallowed — the abort path stays clean.
Optimistic updates + rollback
A future @directive-run/optimistic package will integrate with this
one — the planned ctx.snapshot([keys]) API lets a handler snapshot
specific facts before mutating, with automatic rollback on throw. Until
that ships, do snapshots manually inside handlers:
submit: async ({ payload, facts, deps }) => {
const previous = [...facts.values]; // manual snapshot
facts.values = optimisticGuess(payload); // optimistic write
try {
facts.values = await deps.submit(payload);
} catch (err) {
facts.values = previous; // rollback
throw err; // surface to pendingMutation.error
}
},See also
- Directive core
- Migrating from XState —
pendingActionpattern - Internal events — when
statusalone is enough MIGRATION_FEEDBACK.mditems 17 + 19
License
MIT OR Apache-2.0
