Your plugin system couldn't replace plugins. So here's the transaction that you're missing.
DEV Community

Your plugin system couldn't replace plugins. So here's the transaction that you're missing.

The Failure Mode That Matters Most

What if an upgrade fails halfway through activating? The question would be: what is still running? In most plugin systems, the answer is: none. And in registries, the common bug could be as follows:

// the old plugin is already gone before the new one exists
registry.set('storage', await next.setup());

If setup() throws, the old working value is destroyed, and the new one never arrives. Now, your host is missing a capability it had a millisecond ago. In a daemon, that's an outage, not an error. This is the failure mode that matters most for systems which can't restart: long-running CLI daemons, editors, dev tools, and the new wave of AI-agent extension hosts, where shipping an upgrade should be routine, not a maintenance window.

Replacing a Live Plugin Is a Transaction

Moult treats it like one. A plugin is a versioned capability provider with an owned resource scope, and replacing it runs a protocol, not an assignment:

  • Setup runs in a private scope - the candidate builds itself while the old generation keeps serving.
  • Verify checks its provides and conflicts - staged capabilities stay invisible to observers until commit.
  • Commit swaps the active generation in a single atomic step.
  • Dispose tears down the old generation, resources released in reverse acquisition order (LIFO).

If anything fails before the commit, the candidate scope is disposed, and the failure stays invisible: the previous generation remains active and usable. Like a crab, the system only sheds its shell once the new one is ready.

~30 Lines, Zero Downtime on Failed Upgrade

import { capability, createRuntime } from '@moult/runtime';

const storage = capability('storage', '1.0.0');

const runtime = createRuntime();
runtime.install({
  id: 'memory.storage',
  version: '1.0.0',
  provides: [{ capability: storage }],
  setup: (ctx) => {
    const map = new Map([['k', 'v1']]);
    ctx.provide(storage, { get: (k) => map.get(k) });
  },
});
await runtime.start('memory.storage');

// A broken replacement is rejected - and v1 keeps serving:
await runtime.replace({
  id: 'memory.storage',
  version: '2.0.0',
  provides: [{ capability: storage }],
  setup: () => {
    throw new Error('bug in the new version');
  },
}).catch((e) => console.log(e.code));
// REPLACEMENT_FAILED

// Usable, not just active: a consumer installed after the failure
// still binds the old generation's value.
let seen: string | undefined;
runtime.install({
  id: 'reader',
  version: '1.0.0',
  requires: [{ capability: storage, range: '^1.0.0' }],
  setup: (ctx) => {
    seen = ctx.require(storage).get('k');
  },
});
await runtime.start('reader');
console.log(seen);
// 'v1' - the failed v2 never existed to readers

That is the entire guarantee, runnable: the rejection is a structured MoltError, and a consumer installed after the failure still reads v1's value - usable, not just active.

There's a second half to "versioned capability provider" that the snippet hides: capabilities carry a semver version, consumers declare semver ranges, and each token declares whether it accepts exactly one provider or aggregates many (multiple: true). Resolution runs during preparation; a candidate whose requirements don't resolve fails before setup, so a version mismatch can never become visible either.

The Guarantee Is the Test Suite

It's what's enforced. Nine replacement transaction tests cover failed setup, failed validation, disposal ordering, and resource cleanup, on top of property-based and stress suites: 145 tests, each run under both Node and a DOM environment - 290 runs, all green.

The 15 invariants are written down in docs/guarantees.md, and the repo's comparison harness runs the same failed-upgrade scenario against a naive registry, cordis, and Moult, publishing the raw numbers in demo/comparison/RESULTS.md:

Runner Survives Leaked
naive registry no 102
cordis 4.0.0-rc.9 no 0
@moult/runtime 0.1.1 yes 0

Excerpt - the full table adds the blocked-stop diagnostic and the average-ms column. (Their caveat, which I'll repeat: environment-specific evidence, not a performance promise.)

Performance Cost

That safety isn't free: the whole benchmark scenario - install, one failed replace, and one hundred successful replaces - averages ~16ms against ~0.14ms for the naive registry (roughly ~0.16ms per replace in that environment, not ~16ms; scenario average, not per-call). You pay it per replace, never per capability read.

A Note on the Neighbours

HMR reloads modules, and Module Federation shares them. Both are code-delivery mechanisms - neither promises that an upgrade either fully applies or fully rolls back. Moult owns a different layer: the replacement transaction itself. And if you use Vite, @moult/vite routes module updates through the same replace path, so import/setup failures preserve the old generation and the bridge doesn't commit anything itself; the runtime's transaction does.

Honest Limits

Because a runtime that hides its limits can't be trusted with your uptime:

  • Moult is not a sandbox - plugins are trusted code, and it governs lifecycle and capability visibility, not permissions.
  • It's not a loader or bundler; there's no global registry.
  • v1 refuses to silently rebind dependents - replacing a provider with active dependents is rejected outright with a structured REPLACEMENT_FAILED error that names the dependent path, instead of quietly re-wiring them.

Moult preserves service, not state: every generation gets a fresh scope, so in-memory handles don't migrate - React component state is explicitly not promised to survive replacement, and durable state belongs in a host-provided capability.

The success path is just as strict: replaced fires before the old scope is disposed, old resources release in LIFO order, and if the old generation's disposal fails after commit, the failure is recorded and inspectable - the replacement stands; there is no rollback.

Around the Runtime

There's a small family of packages:

  • @moult/events - generation-scoped typed events
  • @moult/react - bindings for committed contributions
  • @moult/test - utilities for proving ownership, replacement, and leak behaviour
  • @moult/vite - Vite HMR bindings that route module updates through the same transactional lifecycle

Try Breaking It

npm install @moult/runtime  # Node 22 or newer

Repo: github.com/neryva-lab/moult - if you have an upgrade scenario your system can't survive, open an issue with the repro. I'd genuinely like to see it fail.

  • docs/guarantees.md
  • demo/comparison/RESULTS.md
  • @moult/runtime
Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.