layer-node.test.ts 10 KB

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