|
@@ -1,6 +1,7 @@
|
|
|
import { Brand, Context, Layer } from "effect"
|
|
import { Brand, Context, Layer } from "effect"
|
|
|
|
|
|
|
|
type AnyNode = Node<unknown, unknown, any>
|
|
type AnyNode = Node<unknown, unknown, any>
|
|
|
|
|
+type RuntimeLayer = Layer.Layer<never, unknown, unknown>
|
|
|
type NodeList<Item extends AnyNode = AnyNode> = readonly [] | readonly [Item, ...Item[]]
|
|
type NodeList<Item extends AnyNode = AnyNode> = readonly [] | readonly [Item, ...Item[]]
|
|
|
export type Output<Item> = [Item] extends [never] ? never : Item extends Node<infer A, unknown, any> ? A : never
|
|
export type Output<Item> = [Item] extends [never] ? never : Item extends Node<infer A, unknown, any> ? A : never
|
|
|
export type Error<Item> = [Item] extends [never] ? never : Item extends Node<unknown, infer E, any> ? E : never
|
|
export type Error<Item> = [Item] extends [never] ? never : Item extends Node<unknown, infer E, any> ? E : never
|
|
@@ -34,6 +35,39 @@ type NodeIdentity =
|
|
|
| { readonly name: string; readonly service?: never }
|
|
| { readonly name: string; readonly service?: never }
|
|
|
type DistributiveOmit<A, K extends PropertyKey> = A extends unknown ? Omit<A, K> : never
|
|
type DistributiveOmit<A, K extends PropertyKey> = A extends unknown ? Omit<A, K> : never
|
|
|
|
|
|
|
|
|
|
+export type TagConfig = Readonly<Record<string, readonly string[]>>
|
|
|
|
|
+type TagNames<Config extends TagConfig> = keyof Config & string
|
|
|
|
|
+type NodeInTags<Names extends string> = Node<unknown, unknown, Tag<Names> | undefined>
|
|
|
|
|
+type CheckTags<Items extends NodeList, Names extends string> = [Exclude<Items[number], NodeInTags<Names>>] extends [
|
|
|
|
|
+ never,
|
|
|
|
|
+]
|
|
|
|
|
+ ? unknown
|
|
|
|
|
+ : { readonly "Invalid tag dependencies": Exclude<Items[number], NodeInTags<Names>> }
|
|
|
|
|
+
|
|
|
|
|
+export interface Tags<Config extends TagConfig> {
|
|
|
|
|
+ readonly values: { readonly [Name in TagNames<Config>]: Tag<Name> }
|
|
|
|
|
+ readonly make: <Name extends TagNames<Config>>(
|
|
|
|
|
+ name: Name,
|
|
|
|
|
+ ) => <const Implementation extends Layer.Any, const Items extends NodeList>(
|
|
|
|
|
+ input: DistributiveOmit<MakeInput<Implementation, Items, Tag<Name>>, "tag"> &
|
|
|
|
|
+ CheckTags<Items, Name | Extract<Config[Name][number], string>>,
|
|
|
|
|
+ ) => Node<Layer.Success<Implementation>, Layer.Error<Implementation> | Error<Items[number]>, Tag<Name>>
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export function tags<const Config extends { readonly [Name in keyof Config]: readonly (keyof Config & string)[] }>(
|
|
|
|
|
+ config: Config,
|
|
|
|
|
+): Tags<Config> {
|
|
|
|
|
+ const names = Object.keys(config) as TagNames<Config>[]
|
|
|
|
|
+ const values = Object.fromEntries(names.map((name) => [name, makeTag(name)])) as Tags<Config>["values"]
|
|
|
|
|
+ return {
|
|
|
|
|
+ values,
|
|
|
|
|
+ make: ((name: TagNames<Config>) => (input: DistributiveOmit<MakeInput<Layer.Any, NodeList, Tag>, "tag">) =>
|
|
|
|
|
+ make({ ...input, tag: values[name] })) as Tags<Config>["make"],
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// Nodes ---------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
type MakeInput<
|
|
type MakeInput<
|
|
|
Implementation extends Layer.Any,
|
|
Implementation extends Layer.Any,
|
|
|
Items extends NodeList,
|
|
Items extends NodeList,
|
|
@@ -77,37 +111,6 @@ export function group<const Items extends readonly AnyNode[]>(
|
|
|
return { kind: "group", name: "group", dependencies }
|
|
return { kind: "group", name: "group", dependencies }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-export type TagConfig = Readonly<Record<string, readonly string[]>>
|
|
|
|
|
-type TagNames<Config extends TagConfig> = keyof Config & string
|
|
|
|
|
-type NodeInTags<Names extends string> = Node<unknown, unknown, Tag<Names> | undefined>
|
|
|
|
|
-type CheckTags<Items extends NodeList, Names extends string> = [Exclude<Items[number], NodeInTags<Names>>] extends [
|
|
|
|
|
- never,
|
|
|
|
|
-]
|
|
|
|
|
- ? unknown
|
|
|
|
|
- : { readonly "Invalid tag dependencies": Exclude<Items[number], NodeInTags<Names>> }
|
|
|
|
|
-
|
|
|
|
|
-export interface Tags<Config extends TagConfig> {
|
|
|
|
|
- readonly values: { readonly [Name in TagNames<Config>]: Tag<Name> }
|
|
|
|
|
- readonly make: <Name extends TagNames<Config>>(
|
|
|
|
|
- name: Name,
|
|
|
|
|
- ) => <const Implementation extends Layer.Any, const Items extends NodeList>(
|
|
|
|
|
- input: DistributiveOmit<MakeInput<Implementation, Items, Tag<Name>>, "tag"> &
|
|
|
|
|
- CheckTags<Items, Name | Extract<Config[Name][number], string>>,
|
|
|
|
|
- ) => Node<Layer.Success<Implementation>, Layer.Error<Implementation> | Error<Items[number]>, Tag<Name>>
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-export function tags<const Config extends { readonly [Name in keyof Config]: readonly (keyof Config & string)[] }>(
|
|
|
|
|
- config: Config,
|
|
|
|
|
-): Tags<Config> {
|
|
|
|
|
- const names = Object.keys(config) as TagNames<Config>[]
|
|
|
|
|
- const values = Object.fromEntries(names.map((name) => [name, makeTag(name)])) as Tags<Config>["values"]
|
|
|
|
|
- return {
|
|
|
|
|
- values,
|
|
|
|
|
- make: ((name: TagNames<Config>) => (input: DistributiveOmit<MakeInput<Layer.Any, NodeList, Tag>, "tag">) =>
|
|
|
|
|
- make({ ...input, tag: values[name] })) as Tags<Config>["make"],
|
|
|
|
|
- }
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
export type Replacement = {
|
|
export type Replacement = {
|
|
|
readonly source: Layer.Any
|
|
readonly source: Layer.Any
|
|
|
readonly replacement: Layer.Any
|
|
readonly replacement: Layer.Any
|
|
@@ -124,4 +127,147 @@ export function replace<A, E, R, E2>(
|
|
|
return { source, replacement }
|
|
return { source, replacement }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+// Tree -----------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+type Visit<Result> = (node: AnyNode, context: VisitContext<Result>) => Result
|
|
|
|
|
+
|
|
|
|
|
+type VisitContext<Result> = {
|
|
|
|
|
+ readonly cache: Map<AnyNode, Result>
|
|
|
|
|
+ readonly visit: (node: AnyNode) => Result
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function walk<Result>(
|
|
|
|
|
+ root: AnyNode,
|
|
|
|
|
+ visit: Visit<Result>,
|
|
|
|
|
+ options: {
|
|
|
|
|
+ readonly cache?: Map<AnyNode, Result>
|
|
|
|
|
+ readonly resolve?: (node: AnyNode) => AnyNode
|
|
|
|
|
+ readonly detectCycles?: boolean
|
|
|
|
|
+ } = {},
|
|
|
|
|
+) {
|
|
|
|
|
+ const cache = options.cache ?? new Map<AnyNode, Result>()
|
|
|
|
|
+ const visiting = new Set<AnyNode>()
|
|
|
|
|
+ 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<A, E, T extends Tag>(
|
|
|
|
|
+ root: Node<A, E, any>,
|
|
|
|
|
+ tag: T,
|
|
|
|
|
+): {
|
|
|
|
|
+ readonly node: Node<A, E>
|
|
|
|
|
+ readonly hoisted: Node<unknown, E>
|
|
|
|
|
+} {
|
|
|
|
|
+ const hoisted = new Map<string, AnyNode>()
|
|
|
|
|
+
|
|
|
|
|
+ const node = walk<AnyNode>(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<A, E>,
|
|
|
|
|
+ hoisted: group(Array.from(hoisted.values())) as Node<unknown, E>,
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export function compile<A, E>(
|
|
|
|
|
+ root: Node<A, E, any>,
|
|
|
|
|
+ replacements?: ReadonlyMap<Layer.Any, Layer.Any>,
|
|
|
|
|
+): Layer.Layer<A, E> {
|
|
|
|
|
+ const cache = new Map<AnyNode, RuntimeLayer>()
|
|
|
|
|
+ const compileNode = (node: AnyNode) =>
|
|
|
|
|
+ walk<RuntimeLayer>(
|
|
|
|
|
+ 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<RuntimeLayer>((result, layer) => layer.pipe(Layer.provideMerge(result)), Layer.empty)
|
|
|
|
|
+ return layer as Layer.Layer<A, E>
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export function hasUnbound(root: Node<unknown, unknown, any>, source: AnyNode): boolean {
|
|
|
|
|
+ if (source.kind !== "unbound") throw new Error(`Cannot check non-unbound layer node: ${source.name}`)
|
|
|
|
|
+ return walk<boolean>(root, (node, context) => {
|
|
|
|
|
+ if (node === source) return true
|
|
|
|
|
+ return node.dependencies.some(context.visit)
|
|
|
|
|
+ })
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export function bind<A, E, T extends Tag | undefined>(
|
|
|
|
|
+ root: Node<A, E, T>,
|
|
|
|
|
+ source: AnyNode,
|
|
|
|
|
+ replacement: AnyNode,
|
|
|
|
|
+): Node<A, E, T> {
|
|
|
|
|
+ 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<AnyNode>(
|
|
|
|
|
+ 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<A, E, T>
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function flatten(node: AnyNode): readonly AnyNode[] {
|
|
|
|
|
+ return node.kind === "group" ? node.dependencies.flatMap(flatten) : [node]
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
export * as LayerNode from "./layer-node"
|
|
export * as LayerNode from "./layer-node"
|