{"slug": "your-plugin-system-couldn-t-replace-plugins-so-here-s-the-transaction-that-you", "title": "Your plugin system couldn't replace plugins. So here's the transaction that you're missing.", "summary": "A developer has released @moult/runtime 0.1.1, a plugin runtime that treats plugin replacement as a transactional protocol rather than a simple assignment, so a failed upgrade leaves the previous version active and usable. The project ships with 145 tests run under both Node and a DOM environment (290 runs) and a comparison harness showing it survives a failed upgrade with zero leaked resources, unlike a naive registry. The developer notes the safety costs roughly 0.16ms per replace in the benchmark environment, calling it environment-specific evidence rather than a performance promise.", "body_md": "What if an upgrade fails halfway through activating? The question would be: what is still running?\n\nIn most plugin systems, the answer is: none. And in registries, the common bug could be as follows:\n\n```\n// the old plugin is already gone before the new one exists\nregistry.set('storage', await next.setup());\n```\n\nIf `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.\n\nThis 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.\n\nMoult 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:\n\nIf 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.\n\n``` js\nimport { capability, createRuntime } from '@moult/runtime';\n\nconst storage = capability<{ get(k: string): string | undefined }>('storage', '1.0.0');\nconst runtime = createRuntime();\n\nruntime.install({\n  id: 'memory.storage', version: '1.0.0',\n  provides: [{ capability: storage }],\n  setup: (ctx) => {\n    const map = new Map([['k', 'v1']]);\n    ctx.provide(storage, { get: (k) => map.get(k) });\n  },\n});\nawait runtime.start('memory.storage');\n\n// A broken replacement is rejected — and v1 keeps serving:\nawait runtime.replace({\n  id: 'memory.storage', version: '2.0.0',\n  provides: [{ capability: storage }],\n  setup: () => { throw new Error('bug in the new version'); },\n}).catch((e) => console.log(e.code)); // REPLACEMENT_FAILED\n\n// Usable, not just active: a consumer installed after the failure\n// still binds the old generation's value.\nlet seen: string | undefined;\nruntime.install({\n  id: 'reader', version: '1.0.0',\n  requires: [{ capability: storage, range: '^1.0.0' }],\n  setup: (ctx) => { seen = ctx.require(storage).get('k'); },\n});\nawait runtime.start('reader');\nconsole.log(seen); // 'v1' — the failed v2 never existed to readers\n```\n\nThat 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.\n\nThere'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.\n\nIt'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`:\n\n| Runner | Survives | Leaked | \n|---|---|---|\n| naive registry | no | 102 | \n| cordis 4.0.0-rc.9 | no | 0 | \n| @moult/runtime 0.1.1 | yes | 0 | \n\nExcerpt — the full table adds the blocked-stop diagnostic and the average-ms column.\n\n*(Their caveat, which I'll repeat: environment-specific evidence, not a performance promise.)*\n\nThat 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.\n\nA 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.\n\nThe 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. And 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.\n\nAround the runtime there's a small family: `@moult/events` (generation-scoped typed events), `@moult/react` (bindings for committed contributions), `@moult/test` (utilities for proving ownership, replacement, and leak behaviour), and `@moult/vite` (Vite HMR bindings that route module updates through the same transactional lifecycle).\n\n```\nnpm install @moult/runtime  # Node 22 or newer\n```\n\nRepo: [github.com/neryva-lab/moult](https://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.\n\n[docs/guarantees.md](https://github.com/neryva-lab/moult/blob/main/docs/guarantees.md)\n\n[demo/comparison/RESULTS.md](https://github.com/neryva-lab/moult/blob/main/demo/comparison/RESULTS.md)\n\n[@moult/runtime](https://www.npmjs.com/package/@moult/runtime)", "url": "https://wpnews.pro/news/your-plugin-system-couldn-t-replace-plugins-so-here-s-the-transaction-that-you", "canonical_source": "https://dev.to/luke_gree_fc8780a9e7/your-plugin-system-couldnt-replace-plugins-so-heres-the-transaction-that-youre-missing-21lm", "published_at": "2026-09-20 02:32:36+00:00", "updated_at": "2026-09-20 02:54:32.051918+00:00", "lang": "en", "topics": ["ai-agents", "developer-tools", "ai-tools"], "entities": ["Moult", "@moult/runtime", "cordis", "Node", "MoltError"], "alternates": {"html": "https://wpnews.pro/news/your-plugin-system-couldn-t-replace-plugins-so-here-s-the-transaction-that-you", "markdown": "https://wpnews.pro/news/your-plugin-system-couldn-t-replace-plugins-so-here-s-the-transaction-that-you.md", "text": "https://wpnews.pro/news/your-plugin-system-couldn-t-replace-plugins-so-here-s-the-transaction-that-you.txt", "jsonld": "https://wpnews.pro/news/your-plugin-system-couldn-t-replace-plugins-so-here-s-the-transaction-that-you.jsonld"}}