Replace plugins
Graft.replace swaps plugins for new versions. Every fork of an old plugin
restarts with its replacement, and loading the old plugin later loads the new
one. It returns once the restarted forks have started:
import { Array, Effect, Option, Stream } from "effect"
import { Graft, Plugin } from "@graftland/graft"
const greeterV1 = Plugin.make({ name: "app/greeterV1", apply: () => Effect.void,})const greeterV2 = Plugin.make({ name: "app/greeterV2", apply: () => Effect.void,})
const outcome = await Effect.runPromise( Effect.gen(function* () { const running = yield* Graft.plugin(greeterV1) yield* running.await const result = yield* Graft.replace([[greeterV1, greeterV2]]) const later = yield* Graft.plugin(greeterV1) yield* later.await const tree = yield* Stream.runHead(Graft.forks) return [ result._tag, Option.map( tree, Array.map((fork) => fork.name), ), ] }).pipe(Effect.scoped, Effect.provide(Graft.layer)),)
outcome // => ["Replaced", Option.some(["app/greeterV2", "app/greeterV2"])]Type rules
Section titled “Type rules”TypeScript checks each pair on its own:
| Part of the target | Must be |
|---|---|
| Input type | Exactly the same as the source’s. |
| Errors | A subset of the source’s error bound, which includes decoding failures (SchemaError). |
This keeps existing fork handles valid after replacement. A definition’s input and error contracts are invariant, so you cannot bypass the check by assigning the same object to a wider error type or a narrower input type. Declare the error union you need when you create the plugin, before you load it.
Plugin identity
Section titled “Plugin identity”A plugin’s identity is the reference to its definition object, not its name or fields.
- The same object is the same plugin.
{ ...plugin }creates a different plugin. Replacing one copy does not replace another copy or the original.- Loading, alias lookup and conflict checks all follow this rule, including for
frozen objects and definitions with custom
EqualorHashimplementations. Graft does not change those implementations on objects the caller owns.
Transaction
Section titled “Transaction”Graft swaps all pairs in one transaction, so no fork sees half of a replacement.
Then each affected fork stops and starts as usual, in the
withdrawal order.
Hot reload uses replace.
Rollback
Section titled “Rollback”If a fork running a replacement fails to start, Graft rolls the whole batch
back: every fork returns to the old plugin, loading the old plugin loads it
again, and replace returns RolledBack with the failures once the old plugins
have restarted:
import { Array, Effect, Option, Stream } from "effect"
import { Graft, Plugin } from "@graftland/graft"
// The error bound admits the failures of later versions; see Type rules.const greeterV1 = Plugin.make({ name: "app/greeterV1", apply: (): Effect.Effect<void, string> => Effect.void,})const broken = Plugin.make({ name: "app/greeterV2", apply: () => Effect.fail("no greeting"),})
const outcome = await Effect.runPromise( Effect.gen(function* () { const running = yield* Graft.plugin(greeterV1) yield* running.await const result = yield* Graft.replace([[greeterV1, broken]]) const failed = result._tag === "RolledBack" ? Array.map(result.failures, (failure) => failure.plugin) : [] const tree = yield* Stream.runHead(Graft.forks) return [ failed, Option.map( tree, Array.map((fork) => fork.name), ), ] }).pipe(Effect.scoped, Effect.provide(Graft.layer)),)
outcome // => [["app/greeterV2"], Option.some(["app/greeterV1"])]| Situation | Outcome |
|---|---|
Every fork running a replacement is Active or Pending |
Replaced. |
| One fails, including a child that a replaced parent loads | RolledBack, with a Failed failure and its full Cause. |
One is still starting when timeout passes |
RolledBack, with a TimedOut failure. |
The plugin has a retry schedule |
The first failure decides; Graft does not wait for retries. |
A fork had already failed before replace |
Its result does not count, but it is rolled back too. |
A fork asked for a target itself, before or during replace |
It is not judged and stays on the target. |
A later replace or restart takes a fork over |
That fork and its children no longer count, and it is not rolled back. |
The caller is interrupted while replace waits |
The swap stays. |
timeoutis an option:Graft.replace(pairs, { timeout: "30 seconds" }). Without it,replacewaits as long as the forks take. Hot reload waits 30 seconds by default.- Each failure names the
forkid and thepluginname. Graft.resolve(plugin)returns the plugin that loadingpluginloads now, so code that was interrupted whilereplacewaited can see whether the swap stayed.- Plugins that use a service of a rolled-back plugin restart twice: for the new version and again for the old one. Their own failures do not cause a rollback.
Conflicts
Section titled “Conflicts”Some batches cannot be represented by the runtime’s aliases. They fail with
Graft.ReplacementConflict, whose indices field names the conflicting pairs.
| Conflicting batch | Example |
|---|---|
| Different targets for one source | P → Q, P → R |
| Overlapping changes | P → Q, Q → R |
| Stale source identities that would leave forks on a different plugin | — |
On failure, the transaction leaves aliases and forks unchanged. Two cases still work: pointing several sources at one unchanged target, and rolling back to an older version.