import { Brand, Context, Layer } from "effect" type AnyNode = Node type RuntimeLayer = Layer.Layer type NodeList = readonly [] | readonly [Item, ...Item[]] export type Output = [Item] extends [never] ? never : Item extends Node ? A : never export type Error = [Item] extends [never] ? never : Item extends Node ? E : never type NodeTag = [Item] extends [never] ? undefined : Item extends Node ? T : never type Missing = Exclude> type CheckDependencies = [ Missing, Dependencies>, ] extends [never] ? unknown : { readonly "Missing dependencies": Missing, Dependencies> } declare const $OutputType: unique symbol declare const $ErrorType: unique symbol export type Tag = Name & Brand.Brand<"LayerNode.Tag"> const makeTag = Brand.nominal() export interface Node { readonly kind: "layer" | "unbound" | "group" readonly name: string readonly service?: Context.Service.Any readonly implementation?: Layer.Any readonly dependencies: readonly AnyNode[] readonly tag?: T readonly [$OutputType]?: () => A readonly [$ErrorType]?: () => E } type NodeIdentity = | { readonly service: Context.Service.Any; readonly name?: never } | { readonly name: string; readonly service?: never } type DistributiveOmit = A extends unknown ? Omit : never export type TagConfig = Readonly> type TagNames = keyof Config & string type NodeInTags = Node | undefined> type CheckTags = [Exclude>] extends [ never, ] ? unknown : { readonly "Invalid tag dependencies": Exclude> } export interface Tags { readonly values: { readonly [Name in TagNames]: Tag } readonly make: >( name: Name, ) => ( input: DistributiveOmit>, "tag"> & CheckTags>, ) => Node, Layer.Error | Error, Tag> } export function tags( config: Config, ): Tags { const names = Object.keys(config) as TagNames[] const values = Object.fromEntries(names.map((name) => [name, makeTag(name)])) as Tags["values"] return { values, make: ((name: TagNames) => (input: DistributiveOmit, "tag">) => make({ ...input, tag: values[name] })) as Tags["make"], } } // Nodes --------------------------------------------------------------------- type MakeInput< Implementation extends Layer.Any, Items extends NodeList, T extends Tag | undefined = undefined, > = NodeIdentity & { readonly layer: Implementation readonly deps: Items & CheckDependencies> readonly tag?: T } export function make< const Implementation extends Layer.Any, const Items extends NodeList, const T extends Tag | undefined = undefined, >( input: MakeInput, ): Node, Layer.Error | Error, T> { return { kind: "layer", name: input.service !== undefined ? input.service.key : input.name, service: input.service, implementation: input.layer, dependencies: input.deps, tag: input.tag, } } export function unbound(service: Context.Key, tag: T): Node { return { kind: "unbound", name: service.key, service, dependencies: [], tag, } } export function group( dependencies: Items, ): Node, Error, NodeTag> { return { kind: "group", name: "group", dependencies } } export type Replacement = { readonly source: Layer.Any readonly replacement: Layer.Any } type CheckReplacementErrors = [Exclude] extends [never] ? unknown : { readonly "New replacement errors": Exclude } export function replace( source: Layer.Layer, replacement: Layer.Layer, E2, never> & CheckReplacementErrors>, ): Replacement { return { source, replacement } } // Tree ----------------------------------------------------------------------- type Visit = (node: AnyNode, context: VisitContext) => Result type VisitContext = { readonly cache: Map readonly visit: (node: AnyNode) => Result } function walk( root: AnyNode, visit: Visit, options: { readonly cache?: Map readonly resolve?: (node: AnyNode) => AnyNode readonly detectCycles?: boolean } = {}, ) { const cache = options.cache ?? new Map() const visiting = new Set() const stack: AnyNode[] = [] const recur = (node: AnyNode): Result => { const target = options.resolve?.(node) ?? node const cached = cache.get(target) if (cached !== undefined || cache.has(target)) return cached! if (options.detectCycles !== false && visiting.has(target)) { const start = stack.indexOf(target) throw new Error( `Cycle detected in layer tree: ${[...stack.slice(start), target].map((item) => item.name).join(" -> ")}`, ) } visiting.add(target) stack.push(target) try { const result = visit(target, { cache, visit: recur }) if (!cache.has(target)) cache.set(target, result) return result } finally { stack.pop() visiting.delete(target) } } return recur(root) } export function hoist( root: Node, tag: T, ): { readonly node: Node readonly hoisted: Node } { const hoisted = new Map() const node = walk(root, (node, context) => { if (node.kind === "group") { return { ...node, dependencies: node.dependencies.map(context.visit) } } if (node.tag === tag) { const existing = hoisted.get(node.name) if (existing && existing !== node) { throw new Error(`Tag ${tag} has conflicting implementations for ${node.name}`) } hoisted.set(node.name, node) return group([]) } if (node.kind === "unbound") { return node } return { ...node, dependencies: node.dependencies.map(context.visit) } }) return { node: node as Node, hoisted: group(Array.from(hoisted.values())) as Node, } } export function compile( root: Node, replacements?: ReadonlyMap, ): Layer.Layer { const cache = new Map() const compileNode = (node: AnyNode) => walk( node, (node, context) => { if (node.kind === "unbound") throw new Error(`Unbound layer node: ${node.name}`) const dependencies = node.dependencies.flatMap(flatten).map(context.visit) const implementation = (replacements?.get(node.implementation!) ?? node.implementation!) as RuntimeLayer return dependencies.length === 0 ? implementation : implementation.pipe(Layer.provide(dependencies as [RuntimeLayer, ...RuntimeLayer[]])) }, { cache }, ) const layers = flatten(root).map((node) => compileNode(node)) const layer = layers.reduce((result, layer) => layer.pipe(Layer.provideMerge(result)), Layer.empty) return layer as Layer.Layer } export function hasUnbound(root: Node, source: AnyNode): boolean { if (source.kind !== "unbound") throw new Error(`Cannot check non-unbound layer node: ${source.name}`) return walk(root, (node, context) => { if (node === source) return true return node.dependencies.some(context.visit) }) } export function bind( root: Node, source: AnyNode, replacement: AnyNode, ): Node { if (source.kind !== "unbound") throw new Error(`Cannot bind non-unbound layer node: ${source.name}`) if (source.name !== replacement.name) { throw new Error(`Cannot bind ${source.name} to ${replacement.name}`) } if (source.tag !== replacement.tag) { throw new Error(`Cannot bind ${source.name} across tags`) } return walk( root, (target, context) => { if (target.kind === "unbound") return target const dependencies: AnyNode[] = [] const clone = { ...target, dependencies } context.cache.set(target, clone) dependencies.push(...target.dependencies.map(context.visit)) return clone }, { detectCycles: false, resolve: (node) => (node === source ? replacement : node) }, ) as Node } function flatten(node: AnyNode): readonly AnyNode[] { return node.kind === "group" ? node.dependencies.flatMap(flatten) : [node] } export * as LayerNode from "./layer-node"