@effect-app/infra 4.0.0-beta.20 → 4.0.0-beta.200

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (304) hide show
  1. package/CHANGELOG.md +1378 -0
  2. package/_check.sh +1 -1
  3. package/dist/CUPS.d.ts +15 -7
  4. package/dist/CUPS.d.ts.map +1 -1
  5. package/dist/CUPS.js +10 -12
  6. package/dist/Emailer/Sendgrid.d.ts +14 -14
  7. package/dist/Emailer/Sendgrid.d.ts.map +1 -1
  8. package/dist/Emailer/Sendgrid.js +16 -15
  9. package/dist/Emailer/fake.d.ts +1 -1
  10. package/dist/Emailer/service.d.ts +10 -4
  11. package/dist/Emailer/service.d.ts.map +1 -1
  12. package/dist/Emailer/service.js +3 -3
  13. package/dist/Emailer.d.ts +1 -1
  14. package/dist/MainFiberSet.d.ts +9 -9
  15. package/dist/MainFiberSet.d.ts.map +1 -1
  16. package/dist/MainFiberSet.js +3 -3
  17. package/dist/Model/Repository/Registry.d.ts +20 -0
  18. package/dist/Model/Repository/Registry.d.ts.map +1 -0
  19. package/dist/Model/Repository/Registry.js +17 -0
  20. package/dist/Model/Repository/ext.d.ts +33 -15
  21. package/dist/Model/Repository/ext.d.ts.map +1 -1
  22. package/dist/Model/Repository/ext.js +54 -2
  23. package/dist/Model/Repository/internal/internal.d.ts +6 -6
  24. package/dist/Model/Repository/internal/internal.d.ts.map +1 -1
  25. package/dist/Model/Repository/internal/internal.js +43 -32
  26. package/dist/Model/Repository/legacy.d.ts +1 -1
  27. package/dist/Model/Repository/makeRepo.d.ts +7 -6
  28. package/dist/Model/Repository/makeRepo.d.ts.map +1 -1
  29. package/dist/Model/Repository/makeRepo.js +5 -1
  30. package/dist/Model/Repository/service.d.ts +28 -23
  31. package/dist/Model/Repository/service.d.ts.map +1 -1
  32. package/dist/Model/Repository/validation.d.ts +142 -17
  33. package/dist/Model/Repository/validation.d.ts.map +1 -1
  34. package/dist/Model/Repository/validation.js +5 -5
  35. package/dist/Model/Repository.d.ts +2 -1
  36. package/dist/Model/Repository.d.ts.map +1 -1
  37. package/dist/Model/Repository.js +2 -1
  38. package/dist/Model/dsl.d.ts +4 -4
  39. package/dist/Model/dsl.d.ts.map +1 -1
  40. package/dist/Model/filter/filterApi.d.ts +5 -5
  41. package/dist/Model/filter/filterApi.d.ts.map +1 -1
  42. package/dist/Model/filter/types/errors.d.ts +1 -1
  43. package/dist/Model/filter/types/fields.d.ts +1 -1
  44. package/dist/Model/filter/types/path/common.d.ts +1 -1
  45. package/dist/Model/filter/types/path/eager.d.ts +1 -1
  46. package/dist/Model/filter/types/path/eager.d.ts.map +1 -1
  47. package/dist/Model/filter/types/path/eager.js +1 -1
  48. package/dist/Model/filter/types/path/index.d.ts +1 -1
  49. package/dist/Model/filter/types/utils.d.ts +1 -1
  50. package/dist/Model/filter/types/validator.d.ts +1 -1
  51. package/dist/Model/filter/types.d.ts +1 -1
  52. package/dist/Model/query/dsl.d.ts +16 -16
  53. package/dist/Model/query/dsl.d.ts.map +1 -1
  54. package/dist/Model/query/new-kid-interpreter.d.ts +6 -6
  55. package/dist/Model/query/new-kid-interpreter.d.ts.map +1 -1
  56. package/dist/Model/query/new-kid-interpreter.js +3 -3
  57. package/dist/Model/query.d.ts +1 -1
  58. package/dist/Model.d.ts +2 -1
  59. package/dist/Model.d.ts.map +1 -1
  60. package/dist/Model.js +2 -1
  61. package/dist/QueueMaker/SQLQueue.d.ts +5 -7
  62. package/dist/QueueMaker/SQLQueue.d.ts.map +1 -1
  63. package/dist/QueueMaker/SQLQueue.js +105 -114
  64. package/dist/QueueMaker/errors.d.ts +2 -2
  65. package/dist/QueueMaker/errors.d.ts.map +1 -1
  66. package/dist/QueueMaker/memQueue.d.ts +7 -4
  67. package/dist/QueueMaker/memQueue.d.ts.map +1 -1
  68. package/dist/QueueMaker/memQueue.js +51 -62
  69. package/dist/QueueMaker/sbqueue.d.ts +6 -3
  70. package/dist/QueueMaker/sbqueue.d.ts.map +1 -1
  71. package/dist/QueueMaker/sbqueue.js +37 -53
  72. package/dist/QueueMaker/service.d.ts +1 -1
  73. package/dist/RequestContext.d.ts +112 -26
  74. package/dist/RequestContext.d.ts.map +1 -1
  75. package/dist/RequestContext.js +7 -8
  76. package/dist/RequestFiberSet.d.ts +7 -7
  77. package/dist/RequestFiberSet.d.ts.map +1 -1
  78. package/dist/RequestFiberSet.js +5 -5
  79. package/dist/Store/ContextMapContainer.d.ts +19 -3
  80. package/dist/Store/ContextMapContainer.d.ts.map +1 -1
  81. package/dist/Store/ContextMapContainer.js +13 -3
  82. package/dist/Store/Cosmos/query.d.ts +1 -1
  83. package/dist/Store/Cosmos/query.d.ts.map +1 -1
  84. package/dist/Store/Cosmos/query.js +10 -12
  85. package/dist/Store/Cosmos.d.ts +1 -1
  86. package/dist/Store/Cosmos.d.ts.map +1 -1
  87. package/dist/Store/Cosmos.js +318 -240
  88. package/dist/Store/Disk.d.ts +2 -2
  89. package/dist/Store/Disk.d.ts.map +1 -1
  90. package/dist/Store/Disk.js +25 -22
  91. package/dist/Store/Memory.d.ts +4 -4
  92. package/dist/Store/Memory.d.ts.map +1 -1
  93. package/dist/Store/Memory.js +27 -22
  94. package/dist/Store/SQL/Pg.d.ts +4 -0
  95. package/dist/Store/SQL/Pg.d.ts.map +1 -0
  96. package/dist/Store/SQL/Pg.js +189 -0
  97. package/dist/Store/SQL/query.d.ts +38 -0
  98. package/dist/Store/SQL/query.d.ts.map +1 -0
  99. package/dist/Store/SQL/query.js +367 -0
  100. package/dist/Store/SQL.d.ts +20 -0
  101. package/dist/Store/SQL.d.ts.map +1 -0
  102. package/dist/Store/SQL.js +381 -0
  103. package/dist/Store/codeFilter.d.ts +1 -1
  104. package/dist/Store/codeFilter.d.ts.map +1 -1
  105. package/dist/Store/codeFilter.js +2 -1
  106. package/dist/Store/index.d.ts +5 -2
  107. package/dist/Store/index.d.ts.map +1 -1
  108. package/dist/Store/index.js +15 -3
  109. package/dist/Store/service.d.ts +18 -7
  110. package/dist/Store/service.d.ts.map +1 -1
  111. package/dist/Store/service.js +24 -6
  112. package/dist/Store/utils.d.ts +1 -1
  113. package/dist/Store/utils.d.ts.map +1 -1
  114. package/dist/Store/utils.js +3 -4
  115. package/dist/Store.d.ts +1 -1
  116. package/dist/adapters/SQL/Model.d.ts +28 -42
  117. package/dist/adapters/SQL/Model.d.ts.map +1 -1
  118. package/dist/adapters/SQL/Model.js +2 -2
  119. package/dist/adapters/SQL.d.ts +1 -1
  120. package/dist/adapters/ServiceBus.d.ts +11 -11
  121. package/dist/adapters/ServiceBus.d.ts.map +1 -1
  122. package/dist/adapters/ServiceBus.js +13 -15
  123. package/dist/adapters/cosmos-client.d.ts +3 -3
  124. package/dist/adapters/cosmos-client.d.ts.map +1 -1
  125. package/dist/adapters/cosmos-client.js +3 -3
  126. package/dist/adapters/index.d.ts +8 -2
  127. package/dist/adapters/index.d.ts.map +1 -1
  128. package/dist/adapters/index.js +8 -2
  129. package/dist/adapters/logger.d.ts +2 -2
  130. package/dist/adapters/logger.d.ts.map +1 -1
  131. package/dist/adapters/memQueue.d.ts +3 -3
  132. package/dist/adapters/memQueue.d.ts.map +1 -1
  133. package/dist/adapters/memQueue.js +3 -3
  134. package/dist/adapters/mongo-client.d.ts +3 -3
  135. package/dist/adapters/mongo-client.d.ts.map +1 -1
  136. package/dist/adapters/mongo-client.js +3 -3
  137. package/dist/adapters/redis-client.d.ts +3 -3
  138. package/dist/adapters/redis-client.d.ts.map +1 -1
  139. package/dist/adapters/redis-client.js +3 -3
  140. package/dist/api/ContextProvider.d.ts +8 -8
  141. package/dist/api/ContextProvider.d.ts.map +1 -1
  142. package/dist/api/ContextProvider.js +6 -6
  143. package/dist/api/codec.d.ts +1 -1
  144. package/dist/api/internal/RequestContextMiddleware.d.ts +2 -2
  145. package/dist/api/internal/RequestContextMiddleware.d.ts.map +1 -1
  146. package/dist/api/internal/RequestContextMiddleware.js +2 -2
  147. package/dist/api/internal/auth.d.ts +44 -6
  148. package/dist/api/internal/auth.d.ts.map +1 -1
  149. package/dist/api/internal/auth.js +160 -29
  150. package/dist/api/internal/events.d.ts +3 -3
  151. package/dist/api/internal/events.d.ts.map +1 -1
  152. package/dist/api/internal/events.js +9 -7
  153. package/dist/api/internal/health.d.ts +1 -1
  154. package/dist/api/layerUtils.d.ts +6 -6
  155. package/dist/api/layerUtils.d.ts.map +1 -1
  156. package/dist/api/layerUtils.js +5 -5
  157. package/dist/api/middlewares.d.ts +1 -1
  158. package/dist/api/reportError.d.ts +1 -1
  159. package/dist/api/routing/middleware/RouterMiddleware.d.ts +4 -4
  160. package/dist/api/routing/middleware/RouterMiddleware.d.ts.map +1 -1
  161. package/dist/api/routing/middleware/middleware.d.ts +50 -4
  162. package/dist/api/routing/middleware/middleware.d.ts.map +1 -1
  163. package/dist/api/routing/middleware/middleware.js +79 -17
  164. package/dist/api/routing/middleware.d.ts +1 -2
  165. package/dist/api/routing/middleware.d.ts.map +1 -1
  166. package/dist/api/routing/middleware.js +1 -2
  167. package/dist/api/routing/schema/jwt.d.ts +1 -1
  168. package/dist/api/routing/schema/jwt.d.ts.map +1 -1
  169. package/dist/api/routing/tsort.d.ts +1 -1
  170. package/dist/api/routing/tsort.d.ts.map +1 -1
  171. package/dist/api/routing/utils.d.ts +3 -3
  172. package/dist/api/routing/utils.d.ts.map +1 -1
  173. package/dist/api/routing.d.ts +25 -26
  174. package/dist/api/routing.d.ts.map +1 -1
  175. package/dist/api/routing.js +83 -35
  176. package/dist/api/setupRequest.d.ts +8 -5
  177. package/dist/api/setupRequest.d.ts.map +1 -1
  178. package/dist/api/setupRequest.js +12 -7
  179. package/dist/api/util.d.ts +1 -1
  180. package/dist/arbs.d.ts +1 -1
  181. package/dist/arbs.d.ts.map +1 -1
  182. package/dist/arbs.js +5 -3
  183. package/dist/errorReporter.d.ts +4 -4
  184. package/dist/errorReporter.d.ts.map +1 -1
  185. package/dist/errorReporter.js +20 -25
  186. package/dist/errors.d.ts +1 -1
  187. package/dist/fileUtil.d.ts +1 -1
  188. package/dist/fileUtil.d.ts.map +1 -1
  189. package/dist/index.d.ts +1 -1
  190. package/dist/logger/jsonLogger.d.ts +1 -1
  191. package/dist/logger/logFmtLogger.d.ts +1 -1
  192. package/dist/logger/shared.d.ts +1 -1
  193. package/dist/logger/shared.js +2 -2
  194. package/dist/logger.d.ts +1 -1
  195. package/dist/logger.d.ts.map +1 -1
  196. package/dist/rateLimit.d.ts +9 -3
  197. package/dist/rateLimit.d.ts.map +1 -1
  198. package/dist/rateLimit.js +5 -11
  199. package/dist/test.d.ts +2 -2
  200. package/dist/test.d.ts.map +1 -1
  201. package/dist/test.js +1 -1
  202. package/dist/vitest.d.ts +1 -1
  203. package/examples/query.ts +39 -35
  204. package/package.json +41 -37
  205. package/src/CUPS.ts +9 -11
  206. package/src/Emailer/Sendgrid.ts +17 -14
  207. package/src/Emailer/service.ts +9 -3
  208. package/src/MainFiberSet.ts +5 -6
  209. package/src/Model/Repository/Registry.ts +33 -0
  210. package/src/Model/Repository/ext.ts +96 -10
  211. package/src/Model/Repository/internal/internal.ts +97 -88
  212. package/src/Model/Repository/makeRepo.ts +12 -10
  213. package/src/Model/Repository/service.ts +31 -22
  214. package/src/Model/Repository/validation.ts +4 -4
  215. package/src/Model/Repository.ts +1 -0
  216. package/src/Model/dsl.ts +3 -3
  217. package/src/Model/filter/types/path/eager.ts +1 -2
  218. package/src/Model/query/dsl.ts +18 -18
  219. package/src/Model/query/new-kid-interpreter.ts +2 -2
  220. package/src/Model.ts +1 -0
  221. package/src/QueueMaker/SQLQueue.ts +121 -151
  222. package/src/QueueMaker/memQueue.ts +82 -103
  223. package/src/QueueMaker/sbqueue.ts +56 -86
  224. package/src/RequestContext.ts +8 -10
  225. package/src/RequestFiberSet.ts +4 -4
  226. package/src/Store/ContextMapContainer.ts +41 -2
  227. package/src/Store/Cosmos/query.ts +16 -20
  228. package/src/Store/Cosmos.ts +452 -342
  229. package/src/Store/Disk.ts +52 -49
  230. package/src/Store/Memory.ts +54 -48
  231. package/src/Store/SQL/Pg.ts +318 -0
  232. package/src/Store/SQL/query.ts +409 -0
  233. package/src/Store/SQL.ts +668 -0
  234. package/src/Store/codeFilter.ts +1 -0
  235. package/src/Store/index.ts +17 -2
  236. package/src/Store/service.ts +32 -8
  237. package/src/Store/utils.ts +23 -22
  238. package/src/adapters/SQL/Model.ts +10 -4
  239. package/src/adapters/ServiceBus.ts +112 -116
  240. package/src/adapters/cosmos-client.ts +2 -2
  241. package/src/adapters/index.ts +7 -0
  242. package/src/adapters/memQueue.ts +2 -2
  243. package/src/adapters/mongo-client.ts +2 -2
  244. package/src/adapters/redis-client.ts +2 -2
  245. package/src/api/ContextProvider.ts +12 -13
  246. package/src/api/internal/RequestContextMiddleware.ts +1 -1
  247. package/src/api/internal/auth.ts +246 -44
  248. package/src/api/internal/events.ts +12 -8
  249. package/src/api/layerUtils.ts +8 -8
  250. package/src/api/routing/middleware/RouterMiddleware.ts +4 -4
  251. package/src/api/routing/middleware/middleware.ts +112 -15
  252. package/src/api/routing/middleware.ts +0 -2
  253. package/src/api/routing.ts +142 -63
  254. package/src/api/setupRequest.ts +28 -8
  255. package/src/arbs.ts +4 -2
  256. package/src/errorReporter.ts +62 -74
  257. package/src/logger/shared.ts +1 -1
  258. package/src/rateLimit.ts +30 -22
  259. package/src/test.ts +1 -1
  260. package/test/auth.test.ts +101 -0
  261. package/test/contextProvider.test.ts +11 -11
  262. package/test/controller.test.ts +18 -16
  263. package/test/dist/auth.test.d.ts.map +1 -0
  264. package/test/dist/contextProvider.test.d.ts.map +1 -1
  265. package/test/dist/controller.test.d.ts.map +1 -1
  266. package/test/dist/fixtures.d.ts +26 -12
  267. package/test/dist/fixtures.d.ts.map +1 -1
  268. package/test/dist/fixtures.js +12 -10
  269. package/test/dist/query.test.d.ts.map +1 -1
  270. package/test/dist/rawQuery.test.d.ts.map +1 -1
  271. package/test/dist/repository-ext.test.d.ts.map +1 -0
  272. package/test/dist/requires.test.d.ts.map +1 -1
  273. package/test/dist/router-generator.test.d.ts.map +1 -0
  274. package/test/dist/routing-interruptibility.test.d.ts.map +1 -0
  275. package/test/dist/rpc-e2e-invalidation.test.d.ts.map +1 -0
  276. package/test/dist/rpc-multi-middleware.test.d.ts.map +1 -1
  277. package/test/dist/rpc-stream-fullstack.test.d.ts.map +1 -0
  278. package/test/dist/sql-store.test.d.ts.map +1 -0
  279. package/test/fixtures.ts +11 -9
  280. package/test/query.test.ts +216 -34
  281. package/test/rawQuery.test.ts +23 -19
  282. package/test/repository-ext.test.ts +60 -0
  283. package/test/requires.test.ts +6 -6
  284. package/test/router-generator.test.ts +180 -0
  285. package/test/routing-interruptibility.test.ts +63 -0
  286. package/test/rpc-e2e-invalidation.test.ts +507 -0
  287. package/test/rpc-multi-middleware.test.ts +78 -9
  288. package/test/rpc-stream-fullstack.test.ts +325 -0
  289. package/test/sql-store.test.ts +1064 -0
  290. package/test/validateSample.test.ts +15 -12
  291. package/tsconfig.examples.json +1 -1
  292. package/tsconfig.json +0 -1
  293. package/tsconfig.json.bak +2 -2
  294. package/tsconfig.src.json +35 -35
  295. package/tsconfig.test.json +2 -2
  296. package/dist/Operations.d.ts +0 -55
  297. package/dist/Operations.d.ts.map +0 -1
  298. package/dist/Operations.js +0 -102
  299. package/dist/OperationsRepo.d.ts +0 -41
  300. package/dist/OperationsRepo.d.ts.map +0 -1
  301. package/dist/OperationsRepo.js +0 -14
  302. package/eslint.config.mjs +0 -24
  303. package/src/Operations.ts +0 -235
  304. package/src/OperationsRepo.ts +0 -16
@@ -0,0 +1,507 @@
1
+ /**
2
+ * E2E tests for commands and queries using in-memory RPC transport.
3
+ *
4
+ * These tests exercise the full server-side pipeline:
5
+ * - `InvalidationMiddlewareLive` — reads the `Invalidates` annotation, pre-populates the
6
+ * `InvalidationSet`, and wraps command results in `{ payload, metadata }`.
7
+ * - `RequestType` annotation — decides whether to wrap (command) or not (query).
8
+ * - `InvalidationSet.use()` — dynamic key accumulation inside a handler.
9
+ *
10
+ * Transport is in-memory via `RpcTest.makeClient`, so no HTTP server is needed.
11
+ */
12
+ import { expect, it } from "@effect/vitest"
13
+ import { Effect, Layer, Ref, Stream } from "effect"
14
+ import { S } from "effect-app"
15
+ import { InvalidationKeysFromServer, makeInvalidationKeysService } from "effect-app/client"
16
+ import { InvalidationMiddleware } from "effect-app/middleware"
17
+ import { Invalidation } from "effect-app/rpc"
18
+ import { Rpc, RpcGroup, RpcTest } from "effect/unstable/rpc"
19
+ import { InvalidationMiddlewareLive, RequestType } from "../src/api/routing/middleware.js"
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Shared test keys
23
+ // ---------------------------------------------------------------------------
24
+
25
+ const StaticKey: Invalidation.InvalidationKey = ["static", "key"]
26
+ const DynamicKey: Invalidation.InvalidationKey = ["dynamic", "key"]
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // RPC group definition
30
+ //
31
+ // The success schema for commands is defined as the PLAIN type. At runtime,
32
+ // `InvalidationMiddlewareLive` wraps command results into
33
+ // `{ payload: <plain>, metadata: { invalidateQueries: [...] } }`.
34
+ // `RpcTest.makeClient` uses no-serialization transport, so the wrapped runtime
35
+ // value is what the test receives — no codec is applied to coerce it back.
36
+ // ---------------------------------------------------------------------------
37
+
38
+ const E2eRpcs = RpcGroup.make(
39
+ // Plain query — result is not wrapped
40
+ Rpc
41
+ .make("getGreeting", {
42
+ payload: S.Struct({ name: S.String }),
43
+ success: S.String
44
+ })
45
+ .annotate(RequestType, "query")
46
+ .middleware(InvalidationMiddleware),
47
+ // Command — no invalidation keys
48
+ Rpc
49
+ .make("doNothing", { success: S.Void })
50
+ .annotate(RequestType, "command")
51
+ .middleware(InvalidationMiddleware),
52
+ // Command — static `Invalidates` annotation
53
+ Rpc
54
+ .make("doWithStaticKey", {
55
+ success: S.Struct({ count: S.Number })
56
+ })
57
+ .annotate(RequestType, "command")
58
+ .annotate(Invalidation.Invalidates, [StaticKey])
59
+ .middleware(InvalidationMiddleware),
60
+ // Command — dynamic key added via `InvalidationSet.use`
61
+ Rpc
62
+ .make("doWithDynamicKey", { success: S.String })
63
+ .annotate(RequestType, "command")
64
+ .middleware(InvalidationMiddleware),
65
+ // Command — static annotation + dynamic key combined
66
+ Rpc
67
+ .make("doWithBothKeys", { success: S.Number })
68
+ .annotate(RequestType, "command")
69
+ .annotate(Invalidation.Invalidates, [StaticKey])
70
+ .middleware(InvalidationMiddleware),
71
+ // Command — fails, V2: failure should include accumulated keys
72
+ Rpc
73
+ .make("doAndFail", {
74
+ success: S.Void,
75
+ error: S.Struct({ message: S.String })
76
+ })
77
+ .annotate(RequestType, "command")
78
+ .annotate(Invalidation.Invalidates, [StaticKey])
79
+ .middleware(InvalidationMiddleware),
80
+ // Stream — no input
81
+ Rpc
82
+ .make("streamTicks", {
83
+ success: S.Number,
84
+ stream: true
85
+ })
86
+ .annotate(RequestType, "query")
87
+ .middleware(InvalidationMiddleware),
88
+ // Stream — with input payload
89
+ Rpc
90
+ .make("streamCountTo", {
91
+ payload: S.Struct({ to: S.Number }),
92
+ success: S.Number,
93
+ stream: true
94
+ })
95
+ .annotate(RequestType, "query")
96
+ .middleware(InvalidationMiddleware)
97
+ )
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // Server implementation layer
101
+ //
102
+ // Handlers return the PLAIN success value. `InvalidationMiddlewareLive`
103
+ // intercepts every command call and wraps the result into
104
+ // `{ payload, metadata: { invalidateQueries } }` before it reaches the client.
105
+ // ---------------------------------------------------------------------------
106
+
107
+ const E2eImplLayer = E2eRpcs.toLayer({
108
+ getGreeting: ({ name }) => Effect.succeed(`Hello, ${name}!`),
109
+ doNothing: () => Effect.void,
110
+ doWithStaticKey: () => Effect.succeed({ count: 42 }),
111
+ doWithDynamicKey: Effect.fnUntraced(function*() {
112
+ yield* Invalidation.InvalidationSet.use((_) => _.add(DynamicKey))
113
+ return "done"
114
+ }),
115
+ doWithBothKeys: Effect.fnUntraced(function*() {
116
+ yield* Invalidation.InvalidationSet.use((_) => _.add(DynamicKey))
117
+ return 99
118
+ }),
119
+ // V2: command that fails — middleware wraps the failure with accumulated keys
120
+ doAndFail: Effect.fnUntraced(function*() {
121
+ yield* Invalidation.InvalidationSet.use((_) => _.add(DynamicKey))
122
+ return yield* Effect.fail({ message: "intentional failure" })
123
+ }),
124
+ streamTicks: () => Stream.fromIterable([1, 2, 3]),
125
+ streamCountTo: ({ to }) => Stream.range(1, to)
126
+ })
127
+
128
+ const E2eTestLayer = Layer.merge(E2eImplLayer, InvalidationMiddlewareLive)
129
+
130
+ // Helper: validates that the runtime-wrapped command result has the expected shape.
131
+ // `RpcTest` skips codec encoding/decoding, so the value in the client IS the
132
+ // wrapped object produced by `InvalidationMiddlewareLive`, even when the declared
133
+ // schema is the plain type.
134
+ type CommandResult = { payload: unknown; metadata: { invalidateQueries: ReadonlyArray<Invalidation.InvalidationKey> } }
135
+
136
+ const isCommandResult = (value: unknown): value is CommandResult =>
137
+ typeof value === "object"
138
+ && value !== null
139
+ && "payload" in value
140
+ && "metadata" in value
141
+ && typeof (value as Record<string, unknown>)["metadata"] === "object"
142
+ && (value as Record<string, unknown>)["metadata"] !== null
143
+ && "invalidateQueries" in ((value as Record<string, unknown>)["metadata"] as object)
144
+
145
+ const asCommand = (value: unknown): CommandResult => {
146
+ if (!isCommandResult(value)) throw new Error(`Expected a wrapped command result, got: ${JSON.stringify(value)}`)
147
+ return value
148
+ }
149
+
150
+ // ---------------------------------------------------------------------------
151
+ // Tests
152
+ // ---------------------------------------------------------------------------
153
+
154
+ it.live(
155
+ "query returns the correct value",
156
+ Effect.fnUntraced(function*() {
157
+ const client = yield* RpcTest.makeClient(E2eRpcs)
158
+ const result = yield* client.getGreeting({ name: "World" })
159
+ expect(result).toBe("Hello, World!")
160
+ }, Effect.provide(E2eTestLayer))
161
+ )
162
+
163
+ it.live(
164
+ "query result is NOT wrapped in CommandResponseWithMetaData",
165
+ Effect.fnUntraced(function*() {
166
+ const client = yield* RpcTest.makeClient(E2eRpcs)
167
+ const result = yield* client.getGreeting({ name: "Check" })
168
+ expect(typeof result).toBe("string")
169
+ expect(isCommandResult(result)).toBe(false)
170
+ }, Effect.provide(E2eTestLayer))
171
+ )
172
+
173
+ it.live(
174
+ "command with no invalidation keys has empty invalidateQueries in metadata",
175
+ Effect.fnUntraced(function*() {
176
+ const client = yield* RpcTest.makeClient(E2eRpcs)
177
+ const result = asCommand(yield* client.doNothing())
178
+ expect(result.metadata.invalidateQueries).toStrictEqual([])
179
+ }, Effect.provide(E2eTestLayer))
180
+ )
181
+
182
+ it.live(
183
+ "command with static Invalidates annotation propagates key to metadata",
184
+ Effect.fnUntraced(function*() {
185
+ const client = yield* RpcTest.makeClient(E2eRpcs)
186
+ const result = asCommand(yield* client.doWithStaticKey())
187
+ expect(result.payload).toStrictEqual({ count: 42 })
188
+ expect(result.metadata.invalidateQueries).toStrictEqual([StaticKey])
189
+ }, Effect.provide(E2eTestLayer))
190
+ )
191
+
192
+ it.live(
193
+ "command with dynamic InvalidationSet.use propagates key to metadata",
194
+ Effect.fnUntraced(function*() {
195
+ const client = yield* RpcTest.makeClient(E2eRpcs)
196
+ const result = asCommand(yield* client.doWithDynamicKey())
197
+ expect(result.payload).toBe("done")
198
+ expect(result.metadata.invalidateQueries).toStrictEqual([DynamicKey])
199
+ }, Effect.provide(E2eTestLayer))
200
+ )
201
+
202
+ it.live(
203
+ "command combining static annotation + dynamic key merges all into metadata",
204
+ Effect.fnUntraced(function*() {
205
+ const client = yield* RpcTest.makeClient(E2eRpcs)
206
+ const result = asCommand(yield* client.doWithBothKeys())
207
+ expect(result.payload).toBe(99)
208
+ expect(result.metadata.invalidateQueries).toStrictEqual([StaticKey, DynamicKey])
209
+ }, Effect.provide(E2eTestLayer))
210
+ )
211
+
212
+ it.live(
213
+ "stream RPC without input emits all values",
214
+ Effect.fnUntraced(function*() {
215
+ const client = yield* RpcTest.makeClient(E2eRpcs)
216
+ const values = yield* Stream.runCollect(client.streamTicks())
217
+ expect(values).toStrictEqual([1, 2, 3])
218
+ }, Effect.provide(E2eTestLayer))
219
+ )
220
+
221
+ it.live(
222
+ "stream RPC with input emits values driven by payload",
223
+ Effect.fnUntraced(function*() {
224
+ const client = yield* RpcTest.makeClient(E2eRpcs)
225
+ const values = yield* Stream.runCollect(client.streamCountTo({ to: 4 }))
226
+ expect(values).toStrictEqual([1, 2, 3, 4])
227
+ }, Effect.provide(E2eTestLayer))
228
+ )
229
+
230
+ it.live(
231
+ "per-request isolation: each command call has a fresh InvalidationSet",
232
+ Effect.fnUntraced(function*() {
233
+ const client = yield* RpcTest.makeClient(E2eRpcs)
234
+ const r1 = asCommand(yield* client.doWithDynamicKey())
235
+ const r2 = asCommand(yield* client.doWithDynamicKey())
236
+ // Each call must have exactly one key — no accumulation from prior calls
237
+ expect(r1.metadata.invalidateQueries).toStrictEqual([DynamicKey])
238
+ expect(r2.metadata.invalidateQueries).toStrictEqual([DynamicKey])
239
+ }, Effect.provide(E2eTestLayer))
240
+ )
241
+
242
+ // ---------------------------------------------------------------------------
243
+ // Client-side consumption tests
244
+ //
245
+ // These tests verify the full roundtrip for cache invalidation:
246
+ // 1. The server wraps command results in `{ payload, metadata: { invalidateQueries } }`.
247
+ // 2. The client-side `unwrapCommand` logic (as implemented in `apiClientFactory`) extracts
248
+ // the payload and forwards each key to `InvalidationKeysFromServer.add()`.
249
+ //
250
+ // `runAndCapture` replicates that logic using a fresh `Ref`-backed service so the test
251
+ // can inspect which keys the client received after the command completes.
252
+ // ---------------------------------------------------------------------------
253
+
254
+ /**
255
+ * Replicates `apiClientFactory`'s `unwrapCommand` + `InvalidationKeysFromServer` pattern:
256
+ * - Unwraps the `CommandResponseWithMetaData` result
257
+ * - Calls `svc.add(key)` for every key in `metadata.invalidateQueries`
258
+ * - Returns `{ payload, keys }` for assertion
259
+ */
260
+ const runAndCapture = <A, E, R>(eff: Effect.Effect<A, E, R>) =>
261
+ Effect.gen(function*() {
262
+ const keysRef = yield* Ref.make<ReadonlyArray<Invalidation.InvalidationKey>>([])
263
+ const svc = makeInvalidationKeysService(keysRef)
264
+ const cmd = asCommand(yield* eff)
265
+ yield* Effect.forEach(cmd.metadata.invalidateQueries, svc.add, { discard: true })
266
+ return { payload: cmd.payload, keys: yield* Ref.get(keysRef) }
267
+ })
268
+
269
+ it.live(
270
+ "client consumes static key: payload unwrapped and key forwarded to InvalidationKeysFromServer",
271
+ Effect.fnUntraced(function*() {
272
+ const client = yield* RpcTest.makeClient(E2eRpcs)
273
+ const { payload, keys } = yield* runAndCapture(client.doWithStaticKey())
274
+ expect(payload).toStrictEqual({ count: 42 })
275
+ expect(keys).toStrictEqual([StaticKey])
276
+ }, Effect.provide(E2eTestLayer))
277
+ )
278
+
279
+ it.live(
280
+ "client consumes dynamic key: payload unwrapped and key forwarded to InvalidationKeysFromServer",
281
+ Effect.fnUntraced(function*() {
282
+ const client = yield* RpcTest.makeClient(E2eRpcs)
283
+ const { payload, keys } = yield* runAndCapture(client.doWithDynamicKey())
284
+ expect(payload).toBe("done")
285
+ expect(keys).toStrictEqual([DynamicKey])
286
+ }, Effect.provide(E2eTestLayer))
287
+ )
288
+
289
+ it.live(
290
+ "client consumes combined static+dynamic keys: all keys forwarded to InvalidationKeysFromServer",
291
+ Effect.fnUntraced(function*() {
292
+ const client = yield* RpcTest.makeClient(E2eRpcs)
293
+ const { payload, keys } = yield* runAndCapture(client.doWithBothKeys())
294
+ expect(payload).toBe(99)
295
+ expect(keys).toStrictEqual([StaticKey, DynamicKey])
296
+ }, Effect.provide(E2eTestLayer))
297
+ )
298
+
299
+ // ---------------------------------------------------------------------------
300
+ // Stream metadata tests (V1)
301
+ //
302
+ // These tests verify the stream chunk wrapping: the routing layer wraps each
303
+ // emitted value as `{ _tag: "value", value }` and appends a final
304
+ // `{ _tag: "done", metadata: { invalidateQueries } }` chunk. The client
305
+ // side filters out "done" chunks (accumulating keys) and maps "value" chunks
306
+ // to extract the payload. The tests below exercise both layers independently
307
+ // using an RPC group whose success schema is `StreamResponseChunk(S.Number)`.
308
+ // ---------------------------------------------------------------------------
309
+
310
+ const StreamMetaRpcs = RpcGroup.make(
311
+ // Stream that emits plain numbers — the server wraps them as StreamResponseChunk items
312
+ Rpc
313
+ .make("streamWithMeta", {
314
+ success: Invalidation.StreamResponseChunk(S.Number),
315
+ stream: true
316
+ })
317
+ .annotate(RequestType, "query")
318
+ .middleware(InvalidationMiddleware)
319
+ )
320
+
321
+ const StreamKey: Invalidation.InvalidationKey = ["stream", "key"]
322
+
323
+ const StreamMetaImplLayer = StreamMetaRpcs.toLayer({
324
+ // Handler returns pre-wrapped chunks: simulates what routing.ts produces
325
+ streamWithMeta: () =>
326
+ Stream.fromIterable([
327
+ { _tag: "value" as const, value: 1 },
328
+ { _tag: "value" as const, value: 2 },
329
+ { _tag: "value" as const, value: 3 },
330
+ { _tag: "done" as const, metadata: { invalidateQueries: [StreamKey] } }
331
+ ])
332
+ })
333
+
334
+ const StreamMetaTestLayer = Layer.merge(StreamMetaImplLayer, InvalidationMiddlewareLive)
335
+
336
+ it.live(
337
+ "stream: client-side unwrapping delivers plain values and discards 'done' chunk",
338
+ Effect.fnUntraced(function*() {
339
+ const client = yield* RpcTest.makeClient(StreamMetaRpcs)
340
+ const raw = yield* Stream.runCollect(client.streamWithMeta())
341
+ // Client must filter out the "done" chunk and extract only values
342
+ const values = raw
343
+ .filter((item: any) => item._tag === "value")
344
+ .map((item: any) => item.value)
345
+ expect(values).toStrictEqual([1, 2, 3])
346
+ }, Effect.provide(StreamMetaTestLayer))
347
+ )
348
+
349
+ it.live(
350
+ "stream: client-side invalidation keys are collected from the 'done' chunk",
351
+ Effect.fnUntraced(function*() {
352
+ const keysRef = yield* Ref.make<ReadonlyArray<Invalidation.InvalidationKey>>([])
353
+ const svc = makeInvalidationKeysService(keysRef)
354
+ const client = yield* RpcTest.makeClient(StreamMetaRpcs)
355
+ const raw = yield* Stream.runCollect(client.streamWithMeta())
356
+ // Simulate what buildStream does: tap "done" items to accumulate keys
357
+ for (const item of raw) {
358
+ if ((item as any)._tag === "done") {
359
+ const meta = (item as any).metadata as Invalidation.CommandMetaData
360
+ yield* Effect.forEach(meta.invalidateQueries, svc.add, { discard: true })
361
+ }
362
+ }
363
+ const keys = yield* Ref.get(keysRef)
364
+ expect(keys).toStrictEqual([StreamKey])
365
+ }, Effect.provide(StreamMetaTestLayer))
366
+ )
367
+
368
+ it.live(
369
+ "stream: InvalidationKeysFromServer receives keys from 'done' chunk via buildStream-style tap",
370
+ Effect.fnUntraced(function*() {
371
+ const keysRef = yield* Ref.make<ReadonlyArray<Invalidation.InvalidationKey>>([])
372
+ const invKeys = makeInvalidationKeysService(keysRef)
373
+ const client = yield* RpcTest.makeClient(StreamMetaRpcs)
374
+ // Replicate the buildStream processing pipeline: tap must run in the same fiber
375
+ // context as the InvalidationKeysFromServer provider, so we use Effect.provideService
376
+ // (fiber-level) rather than Stream.provideService (element-level) to ensure the
377
+ // tap's Effect.use call resolves invKeys.
378
+ const values = yield* client.streamWithMeta().pipe(
379
+ Stream.tap((item: any) =>
380
+ item._tag === "done"
381
+ ? InvalidationKeysFromServer.use((s) =>
382
+ Effect.forEach(
383
+ (item.metadata as Invalidation.CommandMetaData).invalidateQueries,
384
+ s.add,
385
+ { discard: true }
386
+ )
387
+ )
388
+ : Effect.void
389
+ ),
390
+ Stream.filter((item: any) => item._tag === "value"),
391
+ Stream.map((item: any) => item.value),
392
+ Stream.runCollect,
393
+ Effect.provideService(InvalidationKeysFromServer, invKeys)
394
+ )
395
+ const keys = yield* Ref.get(keysRef)
396
+ expect(values).toStrictEqual([1, 2, 3])
397
+ expect(keys).toStrictEqual([StreamKey])
398
+ }, Effect.provide(StreamMetaTestLayer))
399
+ )
400
+
401
+ // ---------------------------------------------------------------------------
402
+ // V2 tests — invalidation keys included in failures
403
+ // ---------------------------------------------------------------------------
404
+
405
+ it.live(
406
+ "V2: command failure includes accumulated keys in CommandFailureWithMetaData",
407
+ Effect.fnUntraced(function*() {
408
+ const client = yield* RpcTest.makeClient(E2eRpcs)
409
+ const exit = yield* Effect.exit(client.doAndFail())
410
+ // Should fail with CommandFailureWithMetaData wrapping the original error
411
+ if (exit._tag === "Success") throw new Error("Expected failure")
412
+ const err = (exit.cause as any).reasons?.[0]?.error
413
+ expect(err?._tag).toBe("CommandFailureWithMetaData")
414
+ expect(err?.error).toStrictEqual({ message: "intentional failure" })
415
+ expect(err?.metadata?.invalidateQueries).toStrictEqual([StaticKey, DynamicKey])
416
+ }, Effect.provide(E2eTestLayer))
417
+ )
418
+
419
+ it.live(
420
+ "V2: client unwraps CommandFailureWithMetaData — re-fails with original error and forwards keys",
421
+ Effect.fnUntraced(function*() {
422
+ const keysRef = yield* Ref.make<ReadonlyArray<Invalidation.InvalidationKey>>([])
423
+ const svc = makeInvalidationKeysService(keysRef)
424
+ const client = yield* RpcTest.makeClient(E2eRpcs)
425
+
426
+ // Simulate apiClientFactory unwrapCommand: catch CommandFailureWithMetaData,
427
+ // forward keys, re-fail with the original error.
428
+ const exit = yield* Effect.exit(
429
+ client.doAndFail().pipe(
430
+ Effect.catch((err: any) =>
431
+ err?._tag === "CommandFailureWithMetaData"
432
+ ? Effect
433
+ .forEach(
434
+ (err.metadata?.invalidateQueries ?? []) as ReadonlyArray<Invalidation.InvalidationKey>,
435
+ svc.add,
436
+ { discard: true }
437
+ )
438
+ .pipe(Effect.flatMap(() => Effect.fail(err.error)))
439
+ : Effect.fail(err)
440
+ ),
441
+ Effect.provideService(InvalidationKeysFromServer, svc)
442
+ )
443
+ )
444
+
445
+ const keys = yield* Ref.get(keysRef)
446
+ if (exit._tag === "Success") throw new Error("Expected failure")
447
+ const originalErr = (exit.cause as any).reasons?.[0]?.error
448
+ expect(originalErr).toStrictEqual({ message: "intentional failure" })
449
+ expect(keys).toStrictEqual([StaticKey, DynamicKey])
450
+ }, Effect.provide(E2eTestLayer))
451
+ )
452
+
453
+ const StreamMetaV2Rpcs = RpcGroup.make(
454
+ // Stream with pre-wrapped failure chunk — simulates routing.ts V2 failure output
455
+ Rpc
456
+ .make("streamWithFailure", {
457
+ success: Invalidation.StreamResponseChunk(S.Number),
458
+ error: Invalidation.StreamFailureChunk(S.Struct({ msg: S.String })),
459
+ stream: true
460
+ })
461
+ .annotate(RequestType, "query")
462
+ .middleware(InvalidationMiddleware)
463
+ )
464
+
465
+ const StreamV2Key: Invalidation.InvalidationKey = ["stream-v2", "key"]
466
+
467
+ const StreamMetaV2ImplLayer = StreamMetaV2Rpcs.toLayer({
468
+ // Emits two values then fails with a StreamFailureChunk — simulates routing.ts wrapping
469
+ streamWithFailure: () =>
470
+ Stream.concat(
471
+ Stream.fromIterable([
472
+ { _tag: "value" as const, value: 1 },
473
+ { _tag: "value" as const, value: 2 }
474
+ ]),
475
+ Stream.fromEffect(
476
+ Effect.fail({
477
+ _tag: "error" as const,
478
+ error: { msg: "stream error" },
479
+ metadata: { invalidateQueries: [StreamV2Key] }
480
+ })
481
+ )
482
+ )
483
+ })
484
+
485
+ const StreamMetaV2TestLayer = Layer.merge(StreamMetaV2ImplLayer, InvalidationMiddlewareLive)
486
+
487
+ it.live(
488
+ "V2: stream failure chunk carries accumulated keys and original error",
489
+ Effect.fnUntraced(function*() {
490
+ const client = yield* RpcTest.makeClient(StreamMetaV2Rpcs)
491
+ const chunks: Array<any> = []
492
+ const exit = yield* Effect.exit(
493
+ Stream.runForEach(client.streamWithFailure(), (item) =>
494
+ Effect.sync(() => {
495
+ chunks.push(item)
496
+ }))
497
+ )
498
+ // Two value chunks should have been seen before the failure
499
+ expect(chunks.map((c: any) => c.value)).toStrictEqual([1, 2])
500
+ // The stream should fail with the StreamFailureChunk
501
+ if (exit._tag === "Success") throw new Error("Expected failure")
502
+ const err = (exit.cause as any).reasons?.[0]?.error
503
+ expect(err?._tag).toBe("error")
504
+ expect(err?.error).toStrictEqual({ msg: "stream error" })
505
+ expect(err?.metadata?.invalidateQueries).toStrictEqual([StreamV2Key])
506
+ }, Effect.provide(StreamMetaV2TestLayer))
507
+ )
@@ -1,19 +1,18 @@
1
1
  import { NodeHttpServer } from "@effect/platform-node"
2
2
  import { expect, expectTypeOf, it } from "@effect/vitest"
3
- import { Console, Effect, Layer, Result } from "effect"
4
- import { S } from "effect-app"
3
+ import { Console, Effect, Layer, Ref, Result } from "effect"
4
+ import { Context, RpcX, S } from "effect-app"
5
5
  import { NotLoggedInError } from "effect-app/client"
6
6
  import { HttpRouter } from "effect-app/http"
7
7
  import { DefaultGenericMiddlewares } from "effect-app/middleware"
8
- import { MiddlewareMaker } from "effect-app/rpc"
9
- import { middlewareGroup } from "effect-app/rpc/MiddlewareMaker"
10
8
  import { FetchHttpClient } from "effect/unstable/http"
11
- import { RpcClient, RpcGroup, RpcSerialization, RpcServer, RpcTest } from "effect/unstable/rpc"
9
+ import { Rpc, RpcClient, RpcGroup, RpcSerialization, RpcServer, RpcTest } from "effect/unstable/rpc"
12
10
  import { createServer } from "http"
13
11
  import { DefaultGenericMiddlewaresLive } from "../src/api/routing.js"
14
12
  import { AllowAnonymous, AllowAnonymousLive, RequestContextMap, RequireRoles, RequireRolesLive, Some, SomeElseMiddleware, SomeElseMiddlewareLive, SomeMiddleware, SomeMiddlewareLive, SomeService, Test, TestLive, UserProfile } from "./fixtures.js"
15
13
 
16
- const incomplete = MiddlewareMaker
14
+ const incomplete = RpcX
15
+ .MiddlewareMaker
17
16
  .Tag<middleware>()("MiddlewareMaker", RequestContextMap)
18
17
  .middleware(RequireRoles)
19
18
  .middleware(AllowAnonymous, Test)
@@ -21,7 +20,8 @@ const incomplete = MiddlewareMaker
21
20
  // this extension is allowed otherwise the error is quite obscure
22
21
  export class incompleteMiddleware extends incomplete {}
23
22
 
24
- class middleware extends MiddlewareMaker
23
+ class middleware extends RpcX
24
+ .MiddlewareMaker
25
25
  .Tag<middleware>()("MiddlewareMaker", RequestContextMap)
26
26
  .middleware(RequireRoles)
27
27
  .middleware(AllowAnonymous, Test)
@@ -29,7 +29,7 @@ class middleware extends MiddlewareMaker
29
29
  .middleware(...DefaultGenericMiddlewares)
30
30
  {}
31
31
 
32
- const UserRpcs = middlewareGroup(middleware)(
32
+ const UserRpcs = RpcX.MiddlewareMaker.middlewareGroup(middleware)(
33
33
  RpcGroup.make(
34
34
  middleware.rpc("getUser", {
35
35
  success: S.Literal("awesome")
@@ -56,7 +56,7 @@ const impl = UserRpcs
56
56
 
57
57
  expectTypeOf<Layer.Services<typeof impl>>().toEqualTypeOf<never>()
58
58
 
59
- const UserRpcsBad = middlewareGroup(middleware)(
59
+ const UserRpcsBad = RpcX.MiddlewareMaker.middlewareGroup(middleware)(
60
60
  RpcGroup.make(
61
61
  middleware.rpc("doSomethingElse", {
62
62
  success: S.Literal("also-awesome2"),
@@ -136,3 +136,72 @@ it.live(
136
136
  Effect.provide(RpcTestLayer)
137
137
  )
138
138
  )
139
+
140
+ // Per-request service isolation test
141
+
142
+ class PerRequestCounter extends Context.Service<PerRequestCounter>()(
143
+ "PerRequestCounter",
144
+ { make: Effect.sync(() => ({ a: 0 })) }
145
+ ) {
146
+ static Default = Layer.effect(this, this.make)
147
+ }
148
+
149
+ class GlobalCounter extends Context.Service<GlobalCounter, {
150
+ readonly ref: Ref.Ref<number>
151
+ }>()("GlobalCounter") {}
152
+
153
+ const CounterRpcs = RpcGroup.make(
154
+ Rpc.make("incrementA", {
155
+ success: S.Number
156
+ }),
157
+ Rpc.make("incrementB", {
158
+ success: S.Number
159
+ })
160
+ )
161
+
162
+ const counterImpl = CounterRpcs
163
+ .toLayer({
164
+ incrementA: Effect.fn(function*() {
165
+ const counter = yield* PerRequestCounter
166
+ counter.a++
167
+ const global = yield* GlobalCounter
168
+ yield* Ref.update(global.ref, (n) => n + 1)
169
+ return counter.a
170
+ }, Effect.provide(PerRequestCounter.Default)),
171
+ incrementB: Effect.fn(function*() {
172
+ const counter = yield* PerRequestCounter
173
+ counter.a++
174
+ const global = yield* GlobalCounter
175
+ yield* Ref.update(global.ref, (n) => n + 1)
176
+ return counter.a
177
+ }, Effect.provide(PerRequestCounter.Default))
178
+ })
179
+
180
+ const GlobalCounterLive = Layer.effect(
181
+ GlobalCounter,
182
+ Ref.make(0).pipe(Effect.map((ref) => ({ ref })))
183
+ )
184
+
185
+ const CounterTestLayer = counterImpl.pipe(Layer.provideMerge(GlobalCounterLive))
186
+
187
+ it.live(
188
+ "per-request service isolation with shared global counter",
189
+ Effect.fnUntraced(
190
+ function*() {
191
+ const client = yield* RpcTest.makeClient(CounterRpcs)
192
+ const global = yield* GlobalCounter
193
+
194
+ const r1 = yield* client.incrementA()
195
+ const r2 = yield* client.incrementB()
196
+
197
+ // per-request counter is fresh each time → both return 1
198
+ expect(r1).toBe(1)
199
+ expect(r2).toBe(1)
200
+
201
+ // global counter is shared across requests → accumulates to 2
202
+ const globalCount = yield* Ref.get(global.ref)
203
+ expect(globalCount).toBe(2)
204
+ },
205
+ Effect.provide(CounterTestLayer)
206
+ )
207
+ )