npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@directive-run/mutator

v0.3.0

Published

Discriminated mutation helper for Directive — collapse the pendingAction ceremony to a typed handler map.

Readme

@directive-run/mutator

Discriminated mutation helper for Directive — collapse the pendingAction ceremony to a typed handler map.

npm install @directive-run/mutator

Naming heads-up: the mutation discriminator is named kind, not type. Directive's event dispatcher reserves payload.type for its own event-name routing — type here would collide with MUTATE and route the dispatch to a non-existent event handler. Use kind everywhere; the typed mutate(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 pendingAction fact 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 (not type) discriminates the mutation variant. Directive's own event dispatcher reserves the type field for its own event-name routing — colliding here would route the dispatch to a nonexistent event handler. kind keeps 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 textContentnever 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 kind value on pendingMutation
  • a payload-constrained dispatch via mutate('key', payload)
  • a required handler in the map (TypeScript errors if you forget one)
  • a typed payload argument 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 derive instead 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

License

MIT OR Apache-2.0