layer-node.test.ts 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226
  1. import { describe, expect, test } from "bun:test"
  2. import { Context, Effect, Layer } from "effect"
  3. import { LayerNode } from "@opencode-ai/core/effect/layer-node"
  4. import { LayerNodeTree } from "@opencode-ai/core/effect/layer-node"
  5. class Value extends Context.Service<Value, { readonly value: string }>()("test/LayerNodeValue") {}
  6. class Greeting extends Context.Service<Greeting, { readonly value: string }>()("test/LayerNodeGreeting") {}
  7. class Left extends Context.Service<Left, { readonly value: string }>()("test/LayerNodeLeft") {}
  8. class Right extends Context.Service<Right, { readonly value: string }>()("test/LayerNodeRight") {}
  9. class Database extends Context.Service<Database, { readonly name: string }>()("test/GraphDatabase") {}
  10. class Users extends Context.Service<Users, { readonly list: Effect.Effect<string[]> }>()("test/GraphUsers") {}
  11. class App extends Context.Service<App, { readonly run: Effect.Effect<string[]> }>()("test/GraphApp") {}
  12. const tags = LayerNode.tags({ app: [] })
  13. const make = tags.make("app")
  14. const build = <A, E>(root: LayerNode.Node<A, E, any>, replacements?: readonly LayerNode.Replacement[]) =>
  15. LayerNodeTree.compile(
  16. root,
  17. new Map(replacements?.map((item) => [item.source, item.replacement])),
  18. ) as Layer.Layer<A, E>
  19. const valueLayer = Layer.succeed(Value, Value.of({ value: "production" }))
  20. const greetingLayer = Layer.effect(
  21. Greeting,
  22. Effect.map(Value, (value) => Greeting.of({ value: `hello ${value.value}` })),
  23. )
  24. const value = make({ service: Value, layer: valueLayer, deps: [] })
  25. const greeting = make({ service: Greeting, layer: greetingLayer, deps: [value] })
  26. describe("layer node", () => {
  27. test("builds an untagged graph", async () => {
  28. const value = LayerNode.make({ service: Value, layer: valueLayer, deps: [] })
  29. const greeting = LayerNode.make({ service: Greeting, layer: greetingLayer, deps: [value] })
  30. const program = Effect.map(Greeting, (item) => item.value).pipe(
  31. Effect.provide(LayerNodeTree.compile(LayerNode.group([greeting]))),
  32. )
  33. expect(await Effect.runPromise(program)).toBe("hello production")
  34. })
  35. test("builds a dependency graph", async () => {
  36. const program = Effect.map(Greeting, (item) => item.value).pipe(Effect.provide(build(LayerNode.group([greeting]))))
  37. expect(await Effect.runPromise(program)).toBe("hello production")
  38. })
  39. test("exposes roots but hides transitive dependencies", () => {
  40. const layer = build(LayerNode.group([greeting]))
  41. const check: Layer.Layer<Greeting> = layer
  42. void check
  43. })
  44. test("preserves branch-specific implementations across roots", async () => {
  45. const firstValue = make({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "first" })), deps: [] })
  46. const secondValue = make({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "second" })), deps: [] })
  47. const leftLayer = Layer.effect(
  48. Left,
  49. Effect.map(Value, (item) => Left.of({ value: item.value })),
  50. )
  51. const rightLayer = Layer.effect(
  52. Right,
  53. Effect.map(Value, (item) => Right.of({ value: item.value })),
  54. )
  55. const left = make({ service: Left, layer: leftLayer, deps: [firstValue] })
  56. const right = make({ service: Right, layer: rightLayer, deps: [secondValue] })
  57. const layer = build(LayerNode.group([left, right]))
  58. const program = Effect.gen(function* () {
  59. return [(yield* Left).value, (yield* Right).value]
  60. }).pipe(Effect.provide(layer))
  61. expect(await Effect.runPromise(program)).toEqual(["first", "second"])
  62. })
  63. test("requires unbound nodes to be bound before compilation", async () => {
  64. const unbound = LayerNode.unbound(Value, tags.values.app)
  65. const greeting = make({ service: Greeting, layer: greetingLayer, deps: [unbound] })
  66. const tree = LayerNode.group([greeting])
  67. expect(() => LayerNodeTree.compile(tree)).toThrow("Unbound layer node: test/LayerNodeValue")
  68. const bound = LayerNodeTree.bind(tree, unbound, value)
  69. const layer = LayerNodeTree.compile(bound) as Layer.Layer<Greeting>
  70. const program = Effect.map(Greeting, (item) => item.value).pipe(Effect.provide(layer))
  71. expect(await Effect.runPromise(program)).toBe("hello production")
  72. })
  73. test("replaces a layer by identity", async () => {
  74. const replacement = Layer.succeed(Value, Value.of({ value: "simulation" }))
  75. const program = Effect.map(Greeting, (item) => item.value).pipe(
  76. Effect.provide(build(LayerNode.group([greeting]), [LayerNode.replace(valueLayer, replacement)])),
  77. )
  78. expect(await Effect.runPromise(program)).toBe("hello simulation")
  79. })
  80. test("replaces every use of the same layer", async () => {
  81. const leftLayer = Layer.effect(
  82. Left,
  83. Effect.map(Value, (item) => Left.of({ value: item.value })),
  84. )
  85. const rightLayer = Layer.effect(
  86. Right,
  87. Effect.map(Value, (item) => Right.of({ value: item.value })),
  88. )
  89. const left = make({ service: Left, layer: leftLayer, deps: [value] })
  90. const right = make({ service: Right, layer: rightLayer, deps: [value] })
  91. const replacement = Layer.succeed(Value, Value.of({ value: "replaced" }))
  92. const layer = build(LayerNode.group([left, right]), [LayerNode.replace(valueLayer, replacement)])
  93. const program = Effect.gen(function* () {
  94. return [(yield* Left).value, (yield* Right).value]
  95. }).pipe(Effect.provide(layer))
  96. expect(await Effect.runPromise(program)).toEqual(["replaced", "replaced"])
  97. })
  98. test("does not acquire an unused replacement", async () => {
  99. let acquisitions = 0
  100. const other = Layer.succeed(Value, Value.of({ value: "other" }))
  101. const replacement = Layer.effect(
  102. Value,
  103. Effect.sync(() => {
  104. acquisitions++
  105. return Value.of({ value: "replacement" })
  106. }),
  107. )
  108. await Effect.runPromise(
  109. Effect.map(Greeting, (item) => item.value).pipe(
  110. Effect.provide(build(LayerNode.group([greeting]), [LayerNode.replace(other, replacement)])),
  111. ),
  112. )
  113. expect(acquisitions).toBe(0)
  114. })
  115. test("hoists and compiles tagged graphs", async () => {
  116. const tags = LayerNode.tags({ location: ["global"], global: [] })
  117. const global = tags.make("global")
  118. const location = tags.make("location")
  119. const database = global({
  120. service: Database,
  121. layer: Layer.succeed(Database, Database.of({ name: "Alice" })),
  122. deps: [],
  123. })
  124. const users = location({
  125. service: Users,
  126. layer: Layer.effect(
  127. Users,
  128. Effect.gen(function* () {
  129. const db = yield* Database
  130. return Users.of({ list: Effect.succeed([db.name]) })
  131. }),
  132. ),
  133. deps: [database],
  134. })
  135. const app = location({
  136. service: App,
  137. layer: Layer.effect(
  138. App,
  139. Effect.gen(function* () {
  140. const service = yield* Users
  141. return App.of({ run: service.list })
  142. }),
  143. ),
  144. deps: [users],
  145. })
  146. const result = LayerNodeTree.hoist(LayerNode.group([app]), tags.values.global)
  147. expect(result.node.dependencies[0]?.dependencies[0]?.dependencies[0]).toMatchObject({
  148. kind: "group",
  149. dependencies: [],
  150. })
  151. expect(result.hoisted.dependencies).toEqual([database])
  152. const layer = LayerNodeTree.compile(result.node).pipe(
  153. Layer.provide(LayerNodeTree.compile(result.hoisted)),
  154. ) as unknown as Layer.Layer<App>
  155. const program = Effect.gen(function* () {
  156. return yield* (yield* App).run
  157. }).pipe(Effect.provide(layer))
  158. expect(await Effect.runPromise(program)).toEqual(["Alice"])
  159. })
  160. test("rejects conflicting hoisted implementations", () => {
  161. const tags = LayerNode.tags({ location: ["global"], global: [] })
  162. const global = tags.make("global")
  163. const location = tags.make("location")
  164. const first = global({
  165. service: Database,
  166. layer: Layer.succeed(Database, Database.of({ name: "first" })),
  167. deps: [],
  168. })
  169. const second = global({
  170. service: Database,
  171. layer: Layer.succeed(Database, Database.of({ name: "second" })),
  172. deps: [],
  173. })
  174. const left = location({
  175. service: Users,
  176. layer: Layer.effect(Users, Effect.as(Database, Users.of({ list: Effect.succeed([]) }))),
  177. deps: [first],
  178. })
  179. const right = location({
  180. service: App,
  181. layer: Layer.effect(App, Effect.as(Database, App.of({ run: Effect.succeed([]) }))),
  182. deps: [second],
  183. })
  184. expect(() => LayerNodeTree.hoist(LayerNode.group([left, right]), tags.values.global)).toThrow(
  185. "Tag global has conflicting implementations for test/GraphDatabase",
  186. )
  187. })
  188. test("treats dependency groups as transparent while hoisting", () => {
  189. const tags = LayerNode.tags({ location: ["global"], global: [] })
  190. const global = tags.make("global")
  191. const location = tags.make("location")
  192. const database = global({
  193. service: Database,
  194. layer: Layer.succeed(Database, Database.of({ name: "Alice" })),
  195. deps: [],
  196. })
  197. const users = location({
  198. service: Users,
  199. layer: Layer.effect(Users, Effect.as(Database, Users.of({ list: Effect.succeed([]) }))),
  200. deps: [LayerNode.group([database])],
  201. })
  202. const result = LayerNodeTree.hoist(LayerNode.group([users]), tags.values.global)
  203. expect(result.node.dependencies[0]?.dependencies[0]?.dependencies[0]).toMatchObject({
  204. kind: "group",
  205. dependencies: [],
  206. })
  207. })
  208. })