layer-node.test.ts 9.0 KB

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