opencode/packages/util/docs/layer-node.md
Kit Langton 36ac35a7c8
refactor(util): make layer graphs opaque and composable
Replace exposed layer graph assembly with opaque declarations, checked substitutions, and lifetime-aware compilation. Preserve deep replacement, ordered startup, and Effect-owned resource lifetimes; migrate callers and verify source and published package contracts.
2026-08-31 13:46:27 -04:00

7.1 KiB

Layer Graphs

LayerNode describes replaceable wiring for ordinary Effect Layers. Declarations and compilation do not acquire resources. Effect still owns acquisition, memoization, scopes, and finalization.

There are three values to compose:

  • A Node is a replaceable declaration with a fixed output, error, and tag contract. make supplies its default implementation; unbound leaves that implementation for the caller to supply.
  • A Graph is a node or an ordered group of graphs. Grouping exposes the selected roots, not their transitive dependencies. Groups are not replacement targets.
  • A Replacement is a checked instruction made by node.replace(...). Replacement arrays can be stored and concatenated without losing their checks.
import { Context, Effect, Layer } from "effect"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"

class Database extends Context.Service<Database, { readonly name: string }>()("example/Database") {}
class Users extends Context.Service<Users, { readonly name: string }>()("example/Users") {}

const tags = LayerNode.tags({ global: [], location: ["global"] })
const global = tags.make("global")
const location = tags.make("location")

const database = global({
  service: Database,
  layer: Layer.succeed(Database, { name: "live" }),
  deps: [],
})
const users = location({
  service: Users,
  layer: Layer.effect(
    Users,
    Effect.map(Database, (db) => ({ name: db.name })),
  ),
  deps: [database],
})

const replacements: LayerNode.Replacements = [database.replace(Layer.succeed(Database, { name: "test" }))]

const layer = LayerNode.compile(LayerNode.group([database, users]), {
  replacements,
  shared: tags.values.global,
})
// Layer<Database | Users>: both selected roots remain available.

Substitution

Replacements target the exact declaration, not its display name or service key. Two independently declared nodes can provide the same service in different branches without capturing each other's overrides.

Keep the original declaration as the target when configuring a default:

const replacements = [Database.node.replace(Database.configured(options.database)), ...profile.replacements]

The replacement may be a closed Layer or another node. It must provide all the source's outputs, may provide additional outputs, must not introduce new errors, and must retain the source's tag. An open Layer must first be wired into a node. Replacing a node prunes its original dependencies; replacing it with another node uses that node's dependencies instead.

The rules are:

  • The last replacement for the same source wins, before any traversal.
  • Replacements also apply inside replacement dependency graphs and through replacement chains.
  • Replacing a node with itself is the identity operation.
  • Unreachable replacements neither acquire resources nor traverse their graphs.
  • Unbound nodes and cycles in the effective graph fail during compilation.

A node's contract is invariant. It cannot be widened or narrowed to authorize a replacement that would violate another use of the original declaration. A graph can forget outputs like an ordinary Layer, but cannot be used as a weaker replacement handle.

Choice and composition are different: group([a, b]) provides both roots, while group([condition ? a : b]) promises only outputs common to both alternatives. The same rule applies to conditional implementation Layers. Use tuples for known roots; a dynamic array can be empty and therefore guarantees no service outputs. Every alternative of a conditional replacement must satisfy the source contract.

Decoration

Use mapLayer to decorate a default implementation without duplicating or inspecting its dependencies:

const observed = database.mapLayer((layer) => layer.pipe(Layer.tap(() => Effect.logDebug("database ready"))))
const replacements = [database.replace(observed)]

The callback is parametric in the Layer's requirements: it must preserve the declared wiring and service/error contract. mapLayer returns a new node; it does not mutate or automatically override the original. Only implemented nodes offer this operation, not unbound slots or groups.

Lifetimes

Without shared, compilation uses Effect's enclosing memo map throughout. With shared: tag, matching nodes and their dependency subgraphs use that map; remaining wiring uses one fresh map per build. A shared service's dependencies must live at least as long as the service, including untagged helpers. This keeps application globals shared while isolating each Location, without exposing incomplete graphs or losing shared root outputs.

The enclosing memo map remains ambient during construction, so a lazy LayerMap captures the shared ancestry rather than a Location's private cache. Cache selection is internal metadata, not an extra service output of each dependency.

Native Layer identity still controls acquisition within each memo map. Use a distinct implementation Layer for differently wired definitions; compilation rejects conflicting wiring of the same Layer within one graph's memo domain. Compiled wiring is memoized once per build and memo domain, so a shared dependency graph is not expanded into a tree during acquisition. Original Layer identities still determine service sharing across separately compiled builds. An explicitly fresh implementation refreshes its acquisition when its wiring is built.

Groups preserve root startup order and associate transparently. A node's dependencies are built concurrently through native Layer.provide. Named startup actions and Context references are supported even when their required service output type is never.

Migration

Previous API Current API
[source, replacement] source.replace(replacement)
compile(graph, replacements) compile(graph, { replacements })
hoist, compile both halves, fresh, provide compile(graph, { replacements, shared: tag })
Spread a node and overwrite implementation node.mapLayer(transform)
Generic root parameter Node<A, E, any> Graph<A, E>

An application can prepend a default binding and append caller overrides instead of inspecting the original graph with hasUnbound. Unused defaults are pruned, and defaults introduced as dependencies by an override are found naturally.

Code that reconstructed a replacement source by name must retain the original declaration instead. Compiler representation fields are no longer public.

PersistentPty.configured(options) now returns a wired node, like the other Core configuration factories. PersistentPty.layer remains the default raw Layer.

Verification

Run bun run typecheck:dist in packages/util to build the package and check the same positive and negative contract tests against its emitted declarations. This ensures package consumers receive the same inference and replacement checks as callers importing the source.