Skip to content

Transactions and change ​

A running application needs to install plugins, remove them and swap configuration. Dougong's guarantee is:

Transactions expose only committed state. A change either takes effect entirely, or the execution graph returns to what it was — never a half-built set of Instances.

A single change ​

ts
const installation = host.install(plugin, config)
await installation.ready()

await installation.update({ config: nextConfig })
await installation.remove()

Before host.start(), install() only records a declaration. After it, the call runs a Host transaction.

Atomic multi-Installation change: ChangeSet ​

When several operations must succeed or fail together, use change():

ts
const changes = host.change()
changes.install(newProvider)
changes.update(current, { plugin: nextVersion })
changes.remove(deprecated)
await changes.commit()

This is the canonical entry point for multi-Installation change. install() / update() / remove() are its single-item degenerate forms — they run the same path internally and own no second state machine.

Properties:

  • One-shot — no modification after commit(); calling it again returns the same promise
  • One operation per installation — the same installation cannot be both updated and removed in one ChangeSet
  • Nothing before commit — an abandoned draft never touches committed execution state

What failure does ​

Dougong has three levels of failure handling, in increasing severity.

1. Rollback ​

The new graph cannot start → restore the previous graph; host.status returns to active.

ts
const changes = host.change()
changes.install(brokenPlugin)
await expect(changes.commit()).rejects.toThrow("setup failed")

expect(host.status).toBe("active")

2. Fail closed ​

The old graph also cannot be restored (say an Instance's cleanup threw, so whether its resources were released is unknown) → do not pretend to be healthy. Stop the Host at idle and throw an error aggregating every cause.

Better for application code to see "I stopped, and here is why" than to be handed execution state that may be damaged.

3. Aggregated reporting ​

Multiple failures during shutdown aggregate into an AggregateError with every cause retained. No error is ever silently swallowed.

Validation precedes shutdown ​

Every affected Installation's Plugin config is validated before any running Instance is stopped.

ts
const changes = host.change()
changes.update(a, { config: validConfig })
changes.update(b, { config: invalidConfig })
await expect(changes.commit()).rejects.toMatchObject({ code: "CONFIG_INVALID" })

A misspelled config field never leaves your application halfway down.

Contribution sets are atomic too ​

Within one transaction, stopping Instances withdraw their contributions and starting ones add theirs. Both halves are published as a single step:

ts
const observer = definePlugin({
  name: "app.observer",
  requires: { views: VIEWS },
  setup(ctx) {
    ctx.views.subscribe(() => snapshots.push([...ctx.views.get().keys()]))
  },
})

const changes = host.change()
changes.update(outline, { plugin: outlineV2 })
changes.update(search, { plugin: searchV2 })
await changes.commit()

snapshots.length

This change produces one snapshot notification. Observers never see an intermediate empty set.

Without that batch boundary an observer would watch the set briefly empty in the middle of a successful change, then refill one entry at a time. This is the difference between HMR and two sequential updates, and it is exactly the count example 12 asserts.

Incremental restart ​

A change does not restart the whole application. Dougong computes the affected closure: the changed installations plus their transitive dependents, unioned over both the old and the new graph.

text
A ← B ← C        update B
D ← E            E is unrelated to B

Affected: B, C     Untouched: A, D, E

Instances of unrelated Installations are never stopped; their Service values, Lifetimes and background tasks survive intact.

The startup model ​

host.start() has four steps:

  1. Build the graph — derive dependencies from requires / provides, detect cycles and duplicate providers
  2. Validate — every config through its Standard Schema
  3. Start layers concurrently — Installations in the same topological layer run setup in parallel
  4. Commit the layer — only after every Service output in that layer validates are Services registered and staged listeners and contributions published

Step 4 is what "transactions expose only committed state" means at startup: listeners and contributions registered during setup are staged, and if any plugin in the layer fails, not one of them is published.

text
Observable result of a failed prepare, for that layer:

Published Services         0
Published contributions    0
Published listeners        0
Registered Contract kinds  0
Acquired resources         all release attempted

Graph-time errors ​

These are thrown before any Instance starts:

CodeCondition
SERVICE_CYCLEdependency cycle; the message carries the real path
SERVICE_CONFLICTtwo plugins provide the same Service
SERVICE_MISSINGa required Service has no provider
CONTRACT_CONFLICTone ID used as two kinds

Cycle detection reports the actual path, not "everything that failed to sort":

text
Installation dependency cycle: app.a:1 -> app.b:2 -> app.a:1

Group: an installation ownership tree ​

A Group manages a set of plugins as one unit:

ts
const feature = host.group("editor", (group) => {
  group.install(syntax)
  group.install(formatter)

  group.group("lsp", (nested) => {
    nested.install(languageServer)
  })
})

await feature.ready()
feature.status
await feature.remove()

The configure callback must be synchronous (returning a promise throws), because the entire Group's content is committed as one ChangeSet. Nested Groups share that commit, but an Installation created by nested.install() still belongs to the inner Group; calling nested.remove() removes only that inner subtree.

A Group can also run its own transaction:

ts
const changes = feature.change()
changes.install(extra)
await changes.commit()

A Group changes no visibility ​

The most common misreading

A Group expresses installation ownership only. It is not a capability scope, not a provider shadow tree, not a permission boundary and not a security sandbox.

Service resolution and ExtensionPoint/Event visibility are always Host-wide. Putting a Plugin inside a Group does not make it "see only" what is in that Group.

So how do you get those things:

NeedThe right tool
Several statically selected variants of one capability (a store per workspace)An explicit Contract family: service<Store>(`app/ws/${id}/store`)
Request-time tenant selectionAn ordinary method parameter: store.forTenant(id)
Security isolationA separate Host, Worker, iframe or process — a real boundary

An established Group is not poisoned by failure ​

ts
const group = host.group("stable", (p) => p.install(good))
await group.ready()

const changes = group.change()
changes.install(broken)
await expect(changes.commit()).rejects.toThrow()

expect(group.status).toBe("active")
await expect(group.ready()).resolves.toBeUndefined()

A Group that has been established at least once keeps presenting its last committed state after a change that rolled back completely. A Group that never established (its first commit failed) stays failed.

Observing state ​

ts
host.status

host.diagnostics.get()

host.diagnostics.subscribe(() => render())

host.status is idle, starting, active, changing or stopping. Diagnostics expose the Host name, status, Installation snapshots and Group snapshots.

The changing status exists for a reason: while a Host transaction is in flight the application-code read window is closed — host.get() throws SERVICE_UNAVAILABLE rather than handing you an intermediate state that is being replaced.

Next ​

Released under the MIT License.