@gjermundgaraba/effect-actions 0.19.0 → 0.21.0

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 (326) hide show
  1. package/README.md +19 -41
  2. package/dist/Action.d.mts +140 -48
  3. package/dist/Action.mjs +88 -44
  4. package/dist/ActionCli.d.mts +70 -35
  5. package/dist/ActionCli.mjs +53 -34
  6. package/dist/ActionHttp.d.mts +70 -38
  7. package/dist/ActionHttp.mjs +76 -25
  8. package/dist/ActionMcp.d.mts +56 -24
  9. package/dist/ActionMcp.mjs +104 -61
  10. package/dist/ActionRpc.d.mts +222 -57
  11. package/dist/ActionRpc.mjs +301 -49
  12. package/dist/ActionToolkit.d.mts +22 -7
  13. package/dist/ActionToolkit.mjs +37 -14
  14. package/dist/Authentication.d.mts +42 -12
  15. package/dist/Authentication.mjs +38 -32
  16. package/dist/Event.d.mts +370 -0
  17. package/dist/Event.mjs +103 -0
  18. package/dist/Testing.d.mts +1 -3
  19. package/dist/Testing.mjs +2 -3
  20. package/dist/Webhook.d.mts +146 -0
  21. package/dist/Webhook.mjs +72 -0
  22. package/dist/authentication/provider.d.mts +35 -12
  23. package/dist/authentication/provider.mjs +2 -3
  24. package/dist/authentication/refusal.mjs +3 -4
  25. package/dist/cli/commands.d.mts +9 -7
  26. package/dist/cli/commands.mjs +85 -35
  27. package/dist/contract/ast.mjs +5 -3
  28. package/dist/contract/authorizer.d.mts +8 -0
  29. package/dist/contract/authorizer.mjs +20 -0
  30. package/dist/contract/call.d.mts +9 -8
  31. package/dist/contract/call.mjs +49 -8
  32. package/dist/contract/callback.d.mts +7 -0
  33. package/dist/contract/callback.mjs +27 -0
  34. package/dist/contract/caller.d.mts +12 -3
  35. package/dist/contract/caller.mjs +23 -5
  36. package/dist/contract/canonical.mjs +10 -0
  37. package/dist/contract/codec.d.mts +9 -0
  38. package/dist/contract/codec.mjs +9 -0
  39. package/dist/contract/codes.mjs +81 -0
  40. package/dist/contract/declared.d.mts +0 -2
  41. package/dist/contract/declared.mjs +26 -11
  42. package/dist/contract/digest.mjs +6 -0
  43. package/dist/contract/errors.d.mts +9 -5
  44. package/dist/contract/errors.mjs +2 -3
  45. package/dist/contract/feed.d.mts +127 -0
  46. package/dist/contract/feed.mjs +249 -0
  47. package/dist/contract/hints.mjs +1 -2
  48. package/dist/contract/hub.d.mts +94 -0
  49. package/dist/contract/hub.mjs +133 -0
  50. package/dist/contract/idempotency.d.mts +47 -0
  51. package/dist/contract/idempotency.mjs +99 -0
  52. package/dist/contract/implementation.d.mts +73 -51
  53. package/dist/contract/implementation.mjs +94 -60
  54. package/dist/contract/input-check.d.mts +15 -0
  55. package/dist/contract/input-check.mjs +16 -0
  56. package/dist/contract/lease.mjs +28 -0
  57. package/dist/contract/listed.d.mts +4 -0
  58. package/dist/contract/listed.mjs +22 -0
  59. package/dist/contract/log.d.mts +41 -0
  60. package/dist/contract/log.mjs +144 -0
  61. package/dist/contract/media.d.mts +1 -3
  62. package/dist/contract/media.mjs +1 -2
  63. package/dist/contract/names.mjs +15 -6
  64. package/dist/contract/occurrence.d.mts +29 -0
  65. package/dist/contract/occurrence.mjs +45 -0
  66. package/dist/contract/pages.d.mts +13 -0
  67. package/dist/contract/pages.mjs +39 -0
  68. package/dist/contract/progress.d.mts +26 -0
  69. package/dist/contract/progress.mjs +72 -0
  70. package/dist/contract/push.d.mts +20 -0
  71. package/dist/contract/push.mjs +96 -0
  72. package/dist/contract/receiving.d.mts +29 -0
  73. package/dist/contract/receiving.mjs +42 -0
  74. package/dist/contract/recent.mjs +12 -0
  75. package/dist/contract/reported.mjs +16 -0
  76. package/dist/contract/serving.mjs +63 -0
  77. package/dist/contract/status.mjs +15 -7
  78. package/dist/contract/stream.d.mts +34 -0
  79. package/dist/contract/stream.mjs +31 -0
  80. package/dist/contract/subscribe.d.mts +45 -0
  81. package/dist/contract/subscribe.mjs +50 -0
  82. package/dist/contract/transient.mjs +28 -0
  83. package/dist/contract/watch.d.mts +18 -0
  84. package/dist/contract/watch.mjs +105 -0
  85. package/dist/contract/webhooks.d.mts +42 -0
  86. package/dist/contract/webhooks.mjs +53 -0
  87. package/dist/http/client.d.mts +37 -14
  88. package/dist/http/client.mjs +52 -14
  89. package/dist/http/events.d.mts +56 -0
  90. package/dist/http/events.mjs +102 -0
  91. package/dist/http/placed.d.mts +36 -0
  92. package/dist/http/placed.mjs +71 -0
  93. package/dist/http/schema-errors.mjs +2 -3
  94. package/dist/http/streamed.d.mts +21 -0
  95. package/dist/http/streamed.mjs +94 -0
  96. package/dist/http/watch.mjs +50 -0
  97. package/dist/mcp/bridge.mjs +283 -0
  98. package/dist/mcp/client.d.mts +30 -99
  99. package/dist/mcp/client.mjs +60 -134
  100. package/dist/mcp/events.mjs +142 -0
  101. package/dist/mcp/progress.mjs +34 -0
  102. package/dist/mcp/protocol.mjs +40 -15
  103. package/dist/mcp/reply.d.mts +79 -0
  104. package/dist/mcp/reply.mjs +99 -0
  105. package/dist/mcp/request.d.mts +40 -0
  106. package/dist/mcp/request.mjs +51 -0
  107. package/dist/mcp/watch.mjs +115 -0
  108. package/dist/rpc/watch.mjs +39 -0
  109. package/dist/stdio/console.mjs +14 -7
  110. package/dist/testing/memory.d.mts +1 -3
  111. package/dist/testing/memory.mjs +4 -5
  112. package/dist/webhook/address.mjs +47 -0
  113. package/dist/webhook/attempt.mjs +65 -0
  114. package/dist/webhook/receiver.mjs +67 -0
  115. package/dist/webhook/signing.mjs +28 -0
  116. package/dist/webhook/stored.mjs +57 -0
  117. package/dist/webhook/subscriptions.mjs +342 -0
  118. package/docs/Action-failures.md +2 -2
  119. package/docs/Action-streams.md +115 -0
  120. package/docs/Action.md +58 -124
  121. package/docs/ActionCli-failures.md +1 -2
  122. package/docs/ActionCli.md +83 -88
  123. package/docs/ActionHttp-failures.md +3 -3
  124. package/docs/ActionHttp.md +90 -70
  125. package/docs/ActionMcp-failures.md +2 -1
  126. package/docs/ActionMcp.md +120 -92
  127. package/docs/ActionRpc-events.md +126 -0
  128. package/docs/ActionRpc-failures.md +1 -2
  129. package/docs/ActionRpc.md +108 -79
  130. package/docs/ActionToolkit-failures.md +1 -2
  131. package/docs/ActionToolkit.md +33 -90
  132. package/docs/Authentication-advanced.md +57 -61
  133. package/docs/Authentication-failures.md +5 -4
  134. package/docs/Authentication.md +23 -58
  135. package/docs/Event-failures.md +15 -0
  136. package/docs/Event.md +281 -0
  137. package/docs/README.md +51 -82
  138. package/docs/Testing-failures.md +1 -5
  139. package/docs/Testing.md +126 -86
  140. package/docs/Webhook-failures.md +8 -0
  141. package/docs/Webhook.md +319 -0
  142. package/docs/setup-failures.md +2 -3
  143. package/docs/setup.md +32 -53
  144. package/examples/README.md +12 -37
  145. package/examples/assistant/README.md +21 -26
  146. package/examples/assistant/src/agent.ts +66 -24
  147. package/examples/assistant/src/cli.ts +7 -4
  148. package/examples/assistant/src/commands.ts +9 -4
  149. package/examples/assistant/src/identity/Identity.ts +5 -4
  150. package/examples/assistant/src/identity/authorize.ts +13 -5
  151. package/examples/assistant/src/identity/handlers.ts +3 -2
  152. package/examples/assistant/src/identity/permissions.ts +9 -3
  153. package/examples/assistant/src/platform/tasks-file.ts +15 -11
  154. package/examples/assistant/src/scripted.ts +7 -4
  155. package/examples/assistant/src/tasks/Tasks.ts +20 -6
  156. package/examples/assistant/src/tasks/handlers.ts +9 -5
  157. package/examples/assistant/src/tasks/store.ts +38 -11
  158. package/examples/assistant/src/toolkit.ts +11 -7
  159. package/examples/assistant/test/commands.test.ts +53 -27
  160. package/examples/assistant/test/identity/identity.test.ts +27 -11
  161. package/examples/assistant/test/platform/tasks-file.test.ts +39 -23
  162. package/examples/assistant/test/tasks/tasks.test.ts +62 -34
  163. package/examples/assistant/test/toolkit.test.ts +104 -75
  164. package/examples/build-events/README.md +83 -0
  165. package/examples/build-events/src/Api.ts +27 -0
  166. package/examples/build-events/src/Rpc.ts +26 -0
  167. package/examples/build-events/src/builds/Builds.ts +108 -0
  168. package/examples/build-events/src/builds/events.ts +54 -0
  169. package/examples/build-events/src/builds/handlers.ts +58 -0
  170. package/examples/build-events/src/builds/log.ts +30 -0
  171. package/examples/build-events/src/builds/status.ts +28 -0
  172. package/examples/build-events/src/builds/store.ts +130 -0
  173. package/examples/build-events/src/cli.ts +18 -0
  174. package/examples/build-events/src/commands.ts +32 -0
  175. package/examples/build-events/src/deploys/Deploys.ts +44 -0
  176. package/examples/build-events/src/deploys/approvers.ts +55 -0
  177. package/examples/build-events/src/deploys/events.ts +32 -0
  178. package/examples/build-events/src/deploys/handlers.ts +38 -0
  179. package/examples/build-events/src/identity/Identity.ts +29 -0
  180. package/examples/build-events/src/identity/members.ts +7 -0
  181. package/examples/build-events/src/identity/verifier.ts +22 -0
  182. package/examples/build-events/src/notifier/Notices.ts +26 -0
  183. package/examples/build-events/src/notifier/routes.ts +71 -0
  184. package/examples/build-events/src/notifier.ts +48 -0
  185. package/examples/build-events/src/routes.ts +71 -0
  186. package/examples/build-events/src/server.ts +62 -0
  187. package/examples/build-events/test/builds/events.test.ts +68 -0
  188. package/examples/build-events/test/builds/log.test.ts +71 -0
  189. package/examples/build-events/test/builds/progress.test.ts +57 -0
  190. package/examples/build-events/test/builds/status.test.ts +42 -0
  191. package/examples/build-events/test/commands.test.ts +103 -0
  192. package/examples/build-events/test/deploys/events.test.ts +52 -0
  193. package/examples/build-events/test/http.test.ts +95 -0
  194. package/examples/build-events/test/notifier.test.ts +80 -0
  195. package/examples/build-events/test/routes.test.ts +96 -0
  196. package/examples/build-events/test/rpc.test.ts +84 -0
  197. package/examples/build-events/test/support/as.ts +8 -0
  198. package/examples/build-events/test/support/memory.ts +43 -0
  199. package/examples/build-events/test/webhooks.test.ts +143 -0
  200. package/examples/kitchen-sink/README.md +178 -166
  201. package/examples/kitchen-sink/src/Actions.ts +11 -3
  202. package/examples/kitchen-sink/src/Api.ts +12 -6
  203. package/examples/kitchen-sink/src/Rpc.ts +7 -5
  204. package/examples/kitchen-sink/src/admin/Admin.ts +21 -10
  205. package/examples/kitchen-sink/src/admin/authenticate.ts +8 -4
  206. package/examples/kitchen-sink/src/admin/authorize.ts +13 -5
  207. package/examples/kitchen-sink/src/admin/grants.ts +5 -3
  208. package/examples/kitchen-sink/src/admin/handlers.ts +9 -5
  209. package/examples/kitchen-sink/src/agent.ts +14 -6
  210. package/examples/kitchen-sink/src/assistant/Assistant.ts +21 -15
  211. package/examples/kitchen-sink/src/assistant/conversations.ts +14 -9
  212. package/examples/kitchen-sink/src/assistant/demo-model.ts +2 -2
  213. package/examples/kitchen-sink/src/assistant/handlers.ts +42 -22
  214. package/examples/kitchen-sink/src/attachments/Attachments.ts +6 -6
  215. package/examples/kitchen-sink/src/attachments/download.ts +7 -6
  216. package/examples/kitchen-sink/src/attachments/handlers.ts +9 -4
  217. package/examples/kitchen-sink/src/attachments/store.ts +25 -18
  218. package/examples/kitchen-sink/src/browser.ts +10 -4
  219. package/examples/kitchen-sink/src/cli.ts +28 -12
  220. package/examples/kitchen-sink/src/config.ts +1 -1
  221. package/examples/kitchen-sink/src/identity/Identity.ts +24 -10
  222. package/examples/kitchen-sink/src/identity/authenticate.ts +19 -11
  223. package/examples/kitchen-sink/src/identity/authorize.ts +14 -4
  224. package/examples/kitchen-sink/src/identity/directory.ts +16 -7
  225. package/examples/kitchen-sink/src/identity/handlers.ts +1 -0
  226. package/examples/kitchen-sink/src/identity/members.ts +7 -2
  227. package/examples/kitchen-sink/src/identity/quota.ts +23 -20
  228. package/examples/kitchen-sink/src/issues/Issues.ts +91 -15
  229. package/examples/kitchen-sink/src/issues/assistant.ts +4 -2
  230. package/examples/kitchen-sink/src/issues/events.ts +46 -0
  231. package/examples/kitchen-sink/src/issues/export.ts +35 -0
  232. package/examples/kitchen-sink/src/issues/guide.ts +4 -3
  233. package/examples/kitchen-sink/src/issues/handlers.ts +20 -7
  234. package/examples/kitchen-sink/src/issues/store.ts +156 -67
  235. package/examples/kitchen-sink/src/issues/write-limit.ts +44 -18
  236. package/examples/kitchen-sink/src/memory.ts +11 -2
  237. package/examples/kitchen-sink/src/ops.ts +10 -5
  238. package/examples/kitchen-sink/src/request-policy.ts +25 -9
  239. package/examples/kitchen-sink/src/routes.ts +67 -27
  240. package/examples/kitchen-sink/src/sdk.ts +22 -12
  241. package/examples/kitchen-sink/src/seed.ts +15 -9
  242. package/examples/kitchen-sink/src/server.ts +24 -10
  243. package/examples/kitchen-sink/src/stdio.ts +9 -3
  244. package/examples/kitchen-sink/src/workspaces/Workspaces.ts +10 -7
  245. package/examples/kitchen-sink/src/workspaces/handlers.ts +3 -2
  246. package/examples/kitchen-sink/src/workspaces/registry.ts +16 -10
  247. package/examples/kitchen-sink/src/workspaces/tenant.ts +12 -5
  248. package/examples/kitchen-sink/test/admin/admin.test.ts +38 -23
  249. package/examples/kitchen-sink/test/assistant/assistant.test.ts +471 -299
  250. package/examples/kitchen-sink/test/attachments/attachments.test.ts +135 -94
  251. package/examples/kitchen-sink/test/identity/identity.test.ts +110 -82
  252. package/examples/kitchen-sink/test/identity/session.test.ts +65 -43
  253. package/examples/kitchen-sink/test/issues/assistant.test.ts +50 -31
  254. package/examples/kitchen-sink/test/issues/events.test.ts +273 -0
  255. package/examples/kitchen-sink/test/issues/export.test.ts +120 -0
  256. package/examples/kitchen-sink/test/issues/issues.test.ts +105 -75
  257. package/examples/kitchen-sink/test/request-policy.test.ts +130 -33
  258. package/examples/kitchen-sink/test/routes.test.ts +102 -57
  259. package/examples/kitchen-sink/test/support/as.ts +9 -0
  260. package/examples/kitchen-sink/test/support/scripted.ts +6 -5
  261. package/examples/kitchen-sink/test/support/services.ts +17 -0
  262. package/examples/live-rpc/README.md +33 -35
  263. package/examples/live-rpc/src/Rpc.ts +9 -5
  264. package/examples/live-rpc/src/browser.ts +39 -11
  265. package/examples/live-rpc/src/chat/Chat.ts +30 -16
  266. package/examples/live-rpc/src/chat/authorize.ts +2 -1
  267. package/examples/live-rpc/src/chat/events.ts +74 -0
  268. package/examples/live-rpc/src/chat/handlers.ts +13 -5
  269. package/examples/live-rpc/src/chat/room.ts +9 -5
  270. package/examples/live-rpc/src/chat/store.ts +51 -14
  271. package/examples/live-rpc/src/client.ts +22 -5
  272. package/examples/live-rpc/src/identity/Identity.ts +3 -3
  273. package/examples/live-rpc/src/identity/verifier.ts +8 -4
  274. package/examples/live-rpc/src/page.ts +58 -12
  275. package/examples/live-rpc/src/routes.ts +14 -10
  276. package/examples/live-rpc/src/server.ts +15 -5
  277. package/examples/live-rpc/test/browser.test.ts +127 -50
  278. package/examples/live-rpc/test/chat/chat.test.ts +215 -23
  279. package/examples/live-rpc/test/routes.test.ts +130 -46
  280. package/examples/live-rpc/test/server.test.ts +94 -33
  281. package/examples/mcp-tools/README.md +24 -38
  282. package/examples/mcp-tools/src/platform/fake-farm.ts +63 -24
  283. package/examples/mcp-tools/src/platform/print-farm.ts +59 -29
  284. package/examples/mcp-tools/src/printers/Printers.ts +18 -7
  285. package/examples/mcp-tools/src/printers/farm.ts +15 -5
  286. package/examples/mcp-tools/src/printers/handlers.ts +12 -6
  287. package/examples/mcp-tools/src/routes.ts +8 -6
  288. package/examples/mcp-tools/src/server.ts +12 -5
  289. package/examples/mcp-tools/src/stdio.ts +3 -2
  290. package/examples/mcp-tools/test/printers/printers.test.ts +132 -89
  291. package/examples/mcp-tools/test/routes.test.ts +140 -81
  292. package/examples/notes-service/README.md +30 -45
  293. package/examples/notes-service/src/Api.ts +1 -0
  294. package/examples/notes-service/src/browser.ts +15 -14
  295. package/examples/notes-service/src/cli.ts +8 -3
  296. package/examples/notes-service/src/commands.ts +46 -20
  297. package/examples/notes-service/src/identity/Identity.ts +2 -2
  298. package/examples/notes-service/src/identity/authorize.ts +6 -2
  299. package/examples/notes-service/src/identity/handlers.ts +3 -2
  300. package/examples/notes-service/src/identity/users.ts +2 -1
  301. package/examples/notes-service/src/identity/verifier.ts +12 -6
  302. package/examples/notes-service/src/notes/Notes.ts +46 -22
  303. package/examples/notes-service/src/notes/handlers.ts +18 -9
  304. package/examples/notes-service/src/notes/store.ts +70 -43
  305. package/examples/notes-service/src/routes.ts +7 -5
  306. package/examples/notes-service/src/server.ts +12 -5
  307. package/examples/notes-service/src/status/Status.ts +1 -1
  308. package/examples/notes-service/src/status/handlers.ts +5 -3
  309. package/examples/notes-service/src/stdio.ts +7 -3
  310. package/examples/notes-service/src/worker.ts +6 -3
  311. package/examples/notes-service/test/commands.test.ts +164 -91
  312. package/examples/notes-service/test/notes/as.ts +3 -1
  313. package/examples/notes-service/test/notes/idempotency.test.ts +97 -67
  314. package/examples/notes-service/test/notes/in-process.test.ts +26 -15
  315. package/examples/notes-service/test/notes/notes.test.ts +49 -31
  316. package/examples/notes-service/test/routes.test.ts +106 -71
  317. package/examples/notes-service/test/worker.test.ts +21 -13
  318. package/examples/quickstart/README.md +2 -6
  319. package/examples/quickstart/src/Api.ts +1 -0
  320. package/examples/quickstart/src/client.ts +6 -3
  321. package/examples/quickstart/src/greeting/Greeting.ts +1 -1
  322. package/examples/quickstart/src/greeting/handlers.ts +5 -2
  323. package/examples/quickstart/src/routes.ts +3 -2
  324. package/examples/quickstart/src/server.ts +6 -2
  325. package/examples/quickstart/test/routes.test.ts +3 -2
  326. package/package.json +19 -17
@@ -1,27 +1,29 @@
1
1
  import { describe, expect, it } from "@effect/vitest";
2
- import { Array, Effect, flow, Layer } from "effect";
3
- import { HttpClient, HttpClientRequest } from "effect/http";
4
- import { RpcClient, RpcSerialization, RpcServer } from "effect/rpc";
5
2
  import * as Action from "@gjermundgaraba/effect-actions/Action";
6
3
  import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
7
4
  import * as ActionRpc from "@gjermundgaraba/effect-actions/ActionRpc";
8
5
  import * as Testing from "@gjermundgaraba/effect-actions/Testing";
6
+ import { Array, Effect, flow, Layer } from "effect";
7
+ import { HttpClient, HttpClientRequest } from "effect/http";
8
+ import { RpcClient, RpcSerialization, RpcServer } from "effect/rpc";
9
+
9
10
  import { Http } from "../../src/Api.js";
10
11
  import { QuotaExceeded } from "../../src/identity/Identity.js";
11
12
  import { memory } from "../../src/memory.js";
12
13
  import { publicRoutes, rpc } from "../../src/routes.js";
13
14
  import { Rpc } from "../../src/Rpc.js";
14
15
  import { WorkspaceRegistry } from "../../src/workspaces/registry.js";
16
+ import { services } from "../support/services.js";
15
17
 
16
18
  // The web app's rpcs in memory. The server serves them over a WebSocket; here the same layer
17
19
  // is given Effect's HTTP protocol, which a client reaches through `Testing.layer`.
18
20
  const server = rpc.pipe(
19
21
  Layer.provide(RpcServer.layerProtocolHttp({ path: "/rpc" })),
20
- Layer.provide(RpcSerialization.layerJson),
22
+ Layer.provide(RpcSerialization.layerJson)
21
23
  );
22
24
 
23
25
  const protocol = RpcClient.layerProtocolHttp({ url: "/rpc" }).pipe(
24
- Layer.provide(RpcSerialization.layerJson),
26
+ Layer.provide(RpcSerialization.layerJson)
25
27
  );
26
28
 
27
29
  const socket = protocol.pipe(Layer.provide(Testing.layer(server)));
@@ -34,38 +36,50 @@ const served = socket.pipe(Layer.provideMerge(memory));
34
36
  // with the `HttpClient` the rpc client's protocol is given.
35
37
  const servedWithHttp = protocol.pipe(
36
38
  Layer.provideMerge(Testing.layer(Layer.merge(server, publicRoutes))),
37
- Layer.provide(memory),
39
+ Layer.provide(services())
38
40
  );
39
41
 
40
42
  // Each call carries its caller's session cookie, as a browser attaches it, so one client
41
43
  // serves several callers. Authentication is per message.
42
- const as = (session: string) => RpcClient.withHeaders({ cookie: `tracker_session=${session}` });
44
+ const as = (session: string) =>
45
+ RpcClient.withHeaders({ cookie: `tracker_session=${session}` });
43
46
 
44
47
  describe("a session cookie, over RPC", () => {
45
48
  it.effect("authenticates each message", () =>
46
49
  Effect.gen(function* () {
47
50
  const client = yield* ActionRpc.client(Rpc);
48
- const issue = yield* client.createIssue({ title: "Crash", body: "" }).pipe(as("ada-at-acme"));
51
+
52
+ const issue = yield* client
53
+ .createIssue({ title: "Crash", body: "" })
54
+ .pipe(as("ada-at-acme"));
49
55
 
50
56
  expect(issue.id).toBe("ACME-1");
51
57
  expect(yield* client.whoAmI().pipe(as("grace-at-globex"))).toMatchObject({
52
58
  workspace: "globex",
53
59
  });
54
- expect(yield* client.listIssues().pipe(as("grace-at-globex"))).toEqual({ issues: [] });
55
- }).pipe(Effect.scoped, Effect.provide(served)),
60
+ expect(
61
+ yield* client.listIssues().pipe(as("grace-at-globex"))
62
+ ).toStrictEqual({
63
+ issues: [],
64
+ });
65
+ }).pipe(Effect.scoped, Effect.provide(served))
56
66
  );
57
67
 
58
- it.effect("fails a call without a session, or with an unknown one, as a typed refusal", () =>
59
- Effect.gen(function* () {
60
- const client = yield* ActionRpc.client(Rpc);
68
+ it.effect(
69
+ "fails a call without a session, or with an unknown one, as a typed refusal",
70
+ () =>
71
+ Effect.gen(function* () {
72
+ const client = yield* ActionRpc.client(Rpc);
61
73
 
62
- expect(yield* Effect.flip(client.whoAmI())).toBeInstanceOf(Action.Unauthenticated);
74
+ expect(yield* Effect.flip(client.whoAmI())).toBeInstanceOf(
75
+ Action.Unauthenticated
76
+ );
63
77
 
64
- const unknown = yield* Effect.flip(client.whoAmI().pipe(as("stolen")));
78
+ const unknown = yield* Effect.flip(client.whoAmI().pipe(as("stolen")));
65
79
 
66
- expect(unknown).toBeInstanceOf(Action.Unauthenticated);
67
- expect(unknown.message).toBe("Unknown demo session.");
68
- }).pipe(Effect.scoped, Effect.provide(served)),
80
+ expect(unknown).toBeInstanceOf(Action.Unauthenticated);
81
+ expect(unknown.message).toBe("Unknown demo session.");
82
+ }).pipe(Effect.scoped, Effect.provide(served))
69
83
  );
70
84
 
71
85
  it.effect("is refused once its workspace is suspended, as a token is", () =>
@@ -75,36 +89,44 @@ describe("a session cookie, over RPC", () => {
75
89
 
76
90
  yield* registry.setSuspended("acme", true);
77
91
 
78
- const refused = yield* Effect.flip(client.whoAmI().pipe(as("ada-at-acme")));
92
+ const refused = yield* Effect.flip(
93
+ client.whoAmI().pipe(as("ada-at-acme"))
94
+ );
79
95
 
80
96
  expect(refused).toBeInstanceOf(Action.Forbidden);
81
97
  expect(refused.message).toBe("This workspace is suspended.");
82
- expect(yield* client.whoAmI().pipe(as("grace-at-globex"))).toMatchObject({ id: "grace" });
83
- }).pipe(Effect.scoped, Effect.provide(served)),
98
+ expect(yield* client.whoAmI().pipe(as("grace-at-globex"))).toMatchObject({
99
+ id: "grace",
100
+ });
101
+ }).pipe(Effect.scoped, Effect.provide(served))
84
102
  );
85
103
 
86
- it.effect("spends the one request quota its member also spends over HTTP", () =>
87
- Effect.gen(function* () {
88
- const rpcs = yield* ActionRpc.client(Rpc);
89
-
90
- const http = yield* ActionHttp.client(Http, {
91
- transformClient: HttpClient.mapRequest(
92
- flow(
93
- HttpClientRequest.bearerToken("ada"),
94
- HttpClientRequest.setHeader("x-workspace", "acme"),
104
+ it.effect(
105
+ "spends the one request quota its member also spends over HTTP",
106
+ () =>
107
+ Effect.gen(function* () {
108
+ const rpcs = yield* ActionRpc.client(Rpc);
109
+
110
+ const http = yield* ActionHttp.client(Http, {
111
+ transformClient: HttpClient.mapRequest(
112
+ flow(
113
+ HttpClientRequest.bearerToken("ada"),
114
+ HttpClientRequest.setHeader("x-workspace", "acme")
115
+ )
95
116
  ),
96
- ),
97
- });
98
-
99
- // Sixty requests as ada in acme, half on each surface: each middleware counts, and the
100
- // next request is refused on either.
101
- yield* Effect.forEach(Array.range(1, 30), () => http.whoAmI());
102
- yield* Effect.forEach(Array.range(1, 30), () => rpcs.whoAmI().pipe(as("ada-at-acme")));
103
-
104
- expect(yield* Effect.flip(rpcs.whoAmI().pipe(as("ada-at-acme")))).toBeInstanceOf(
105
- QuotaExceeded,
106
- );
107
- expect(yield* Effect.flip(http.whoAmI())).toBeInstanceOf(QuotaExceeded);
108
- }).pipe(Effect.scoped, Effect.provide(servedWithHttp)),
117
+ });
118
+
119
+ // Sixty requests as ada in acme, half on each surface: each middleware counts, and the
120
+ // next request is refused on either.
121
+ yield* Effect.forEach(Array.range(1, 30), () => http.whoAmI());
122
+ yield* Effect.forEach(Array.range(1, 30), () =>
123
+ rpcs.whoAmI().pipe(as("ada-at-acme"))
124
+ );
125
+
126
+ expect(
127
+ yield* Effect.flip(rpcs.whoAmI().pipe(as("ada-at-acme")))
128
+ ).toBeInstanceOf(QuotaExceeded);
129
+ expect(yield* Effect.flip(http.whoAmI())).toBeInstanceOf(QuotaExceeded);
130
+ }).pipe(Effect.scoped, Effect.provide(servedWithHttp))
109
131
  );
110
132
  });
@@ -1,7 +1,8 @@
1
1
  import { describe, expect, it } from "@effect/vitest";
2
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
2
3
  import { Effect, Layer, Stream } from "effect";
3
4
  import { LanguageModel } from "effect/ai";
4
- import * as Action from "@gjermundgaraba/effect-actions/Action";
5
+
5
6
  import { CurrentMember } from "../../src/identity/Identity.js";
6
7
  import { members } from "../../src/identity/members.js";
7
8
  import { layer, toolkit, triage } from "../../src/issues/assistant.js";
@@ -12,8 +13,18 @@ import { memory } from "../../src/memory.js";
12
13
  const model = LanguageModel.make({
13
14
  generateText: () =>
14
15
  Effect.succeed([
15
- { type: "tool-call", id: "1", name: "getIssue", params: { id: "ACME-1" } },
16
- { type: "tool-call", id: "2", name: "closeIssue", params: { id: "ACME-1" } },
16
+ {
17
+ type: "tool-call",
18
+ id: "1",
19
+ name: "getIssue",
20
+ params: { id: "ACME-1" },
21
+ },
22
+ {
23
+ type: "tool-call",
24
+ id: "2",
25
+ name: "closeIssue",
26
+ params: { id: "ACME-1" },
27
+ },
17
28
  ] as const),
18
29
  streamText: () => Stream.empty,
19
30
  });
@@ -32,44 +43,52 @@ describe("the assistant's tools", () => {
32
43
 
33
44
  const turn = yield* triage("Close what is done.").pipe(
34
45
  asAda,
35
- Effect.provideServiceEffect(LanguageModel.LanguageModel, model),
46
+ Effect.provideServiceEffect(LanguageModel.LanguageModel, model)
36
47
  );
37
48
 
38
- expect(turn.toolResults.map(({ name }) => name)).toEqual(["getIssue"]);
49
+ expect(turn.toolResults.map(({ name }) => name)).toStrictEqual([
50
+ "getIssue",
51
+ ]);
39
52
  expect(
40
53
  turn.content.flatMap((part) =>
41
- part.type === "tool-approval-request" ? [part.toolCallId] : [],
42
- ),
43
- ).toEqual(["2"]);
44
- expect(yield* tracker.getIssue({ id: "ACME-1" }).pipe(asAda)).toMatchObject({
54
+ part.type === "tool-approval-request" ? [part.toolCallId] : []
55
+ )
56
+ ).toStrictEqual(["2"]);
57
+ expect(
58
+ yield* tracker.getIssue({ id: "ACME-1" }).pipe(asAda)
59
+ ).toMatchObject({
45
60
  status: "open",
46
61
  });
47
- }).pipe(Effect.provide(services)),
62
+ }).pipe(Effect.provide(services))
48
63
  );
49
64
 
50
- it.effect("offers the listed actions alone, and runs `authorize` for a call made by hand", () =>
51
- Effect.gen(function* () {
52
- const tools = yield* toolkit;
65
+ it.effect(
66
+ "offers the listed actions alone, and runs `authorize` for a call made by hand",
67
+ () =>
68
+ Effect.gen(function* () {
69
+ const tools = yield* toolkit;
53
70
 
54
- expect(Object.keys(tools.tools)).toEqual([
55
- "listIssues",
56
- "getIssue",
57
- "commentOnIssue",
58
- "closeIssue",
59
- ]);
71
+ expect(Object.keys(tools.tools)).toStrictEqual([
72
+ "listIssues",
73
+ "getIssue",
74
+ "commentOnIssue",
75
+ "closeIssue",
76
+ ]);
60
77
 
61
- // `tools.handle` is a caller that is not a model's turn: it asks no approval, and takes
62
- // the arguments as JSON. The implementation's `authorize` runs all the same.
63
- const refused = yield* tools
64
- .handle("closeIssue", { id: "ACME-1" })
65
- .pipe(
66
- Effect.flatMap(Stream.runCollect),
67
- Effect.provideService(CurrentMember, members.linus),
68
- );
78
+ // `tools.handle` is a caller that is not a model's turn: it asks no approval, and takes
79
+ // the arguments as JSON. The implementation's `authorize` runs all the same.
80
+ const refused = yield* tools
81
+ .handle("closeIssue", { id: "ACME-1" })
82
+ .pipe(
83
+ Effect.flatMap(Stream.runCollect),
84
+ Effect.provideService(CurrentMember, members.linus)
85
+ );
69
86
 
70
- expect(refused.map(({ isFailure, result }) => [isFailure, result])).toEqual([
71
- [true, new Action.Forbidden({ scopes: ["issues:write"] })],
72
- ]);
73
- }).pipe(Effect.provide(services)),
87
+ expect(
88
+ refused.map(({ isFailure, result }) => [isFailure, result])
89
+ ).toStrictEqual([
90
+ [true, new Action.Forbidden({ scopes: ["issues:write"] })],
91
+ ]);
92
+ }).pipe(Effect.provide(services))
74
93
  );
75
94
  });
@@ -0,0 +1,273 @@
1
+ import { describe, expect, it } from "@effect/vitest";
2
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
3
+ import * as Event from "@gjermundgaraba/effect-actions/Event";
4
+ import * as Testing from "@gjermundgaraba/effect-actions/Testing";
5
+ import { Effect, Fiber, Layer, Queue, Schema, Stream } from "effect";
6
+ import { HttpClient, HttpClientRequest } from "effect/http";
7
+ import { RateLimiter } from "effect/persistence";
8
+ import { TestClock } from "effect/testing";
9
+
10
+ import type { members } from "../../src/identity/members.js";
11
+ import { issueEvents } from "../../src/issues/events.js";
12
+ import { issues } from "../../src/issues/handlers.js";
13
+ import { IssueChanged } from "../../src/issues/Issues.js";
14
+ import { IssueStore } from "../../src/issues/store.js";
15
+ import { publicRoutes } from "../../src/routes.js";
16
+ import { as } from "../support/as.js";
17
+ import { services } from "../support/services.js";
18
+
19
+ const inProcess = Layer.mergeAll(
20
+ IssueStore.layerMemory,
21
+ RateLimiter.layer.pipe(Layer.provide(RateLimiter.layerStoreMemory)),
22
+ Event.layerMemory
23
+ );
24
+
25
+ // The changes a member's watch receives, as data, while `act` changes issues.
26
+ const watched = <E, R>(
27
+ member: keyof typeof members,
28
+ input: typeof IssueChanged.input.Type,
29
+ count: number,
30
+ act: (
31
+ client: Action.Client<[typeof issues, typeof issueEvents]>
32
+ ) => Effect.Effect<void, E, R>
33
+ ) =>
34
+ Effect.gen(function* () {
35
+ const client = yield* Action.client([issues, issueEvents]);
36
+
37
+ const watching = yield* client.watch(IssueChanged, input).pipe(
38
+ Stream.filter((received) => received._tag === "Occurrence"),
39
+ Stream.map((occurrence) => occurrence.data),
40
+ Stream.take(count),
41
+ Stream.runCollect,
42
+ as(member),
43
+ Effect.forkChild
44
+ );
45
+
46
+ // Lets the forked watch run up to its subscription first: started after `act`, it would wait
47
+ // for changes already made.
48
+ yield* TestClock.adjust("0 millis");
49
+ yield* act(client);
50
+
51
+ return yield* Fiber.join(watching);
52
+ });
53
+
54
+ describe("issue.changed", () => {
55
+ it.effect(
56
+ "gives a member the changes of their own workspace alone, as they happen",
57
+ () =>
58
+ Effect.gen(function* () {
59
+ const changes = yield* watched("linus", {}, 2, (client) =>
60
+ Effect.gen(function* () {
61
+ const filed = yield* client
62
+ .createIssue({ title: "Crash", body: "" })
63
+ .pipe(as("ada"));
64
+
65
+ yield* client
66
+ .createIssue({ title: "Elsewhere", body: "" })
67
+ .pipe(as("grace"));
68
+ yield* client
69
+ .commentOnIssue({ id: filed.id, text: "Seen it." })
70
+ .pipe(as("ada"));
71
+ })
72
+ );
73
+
74
+ expect(changes).toStrictEqual([
75
+ {
76
+ workspace: "acme",
77
+ id: "ACME-1",
78
+ title: "Crash",
79
+ status: "open",
80
+ change: "filed",
81
+ },
82
+ {
83
+ workspace: "acme",
84
+ id: "ACME-1",
85
+ title: "Crash",
86
+ status: "open",
87
+ change: "commented",
88
+ comment: { author: "ada", text: "Seen it." },
89
+ },
90
+ ]);
91
+ }).pipe(Effect.provide(inProcess))
92
+ );
93
+
94
+ it.effect("filters by status and leaves the comment out where asked", () =>
95
+ Effect.gen(function* () {
96
+ const changes = yield* watched(
97
+ "linus",
98
+ { status: "closed", comments: false },
99
+ 2,
100
+ (client) =>
101
+ Effect.gen(function* () {
102
+ const filed = yield* client
103
+ .createIssue({ title: "Crash", body: "" })
104
+ .pipe(as("ada"));
105
+
106
+ yield* client
107
+ .commentOnIssue({ id: filed.id, text: "Seen it." })
108
+ .pipe(as("ada"));
109
+ yield* client.closeIssue({ id: filed.id }).pipe(as("ada"));
110
+ // Closing it again changes nothing, and nothing is sent.
111
+ yield* client.closeIssue({ id: filed.id }).pipe(as("ada"));
112
+ yield* client
113
+ .commentOnIssue({ id: filed.id, text: "Fixed." })
114
+ .pipe(as("ada"));
115
+ })
116
+ );
117
+
118
+ expect(changes).toStrictEqual([
119
+ {
120
+ workspace: "acme",
121
+ id: "ACME-1",
122
+ title: "Crash",
123
+ status: "closed",
124
+ change: "closed",
125
+ },
126
+ {
127
+ workspace: "acme",
128
+ id: "ACME-1",
129
+ title: "Crash",
130
+ status: "closed",
131
+ change: "commented",
132
+ },
133
+ ]);
134
+ }).pipe(Effect.provide(inProcess))
135
+ );
136
+
137
+ it.effect("refuses a member without the read scope", () =>
138
+ Effect.gen(function* () {
139
+ const client = yield* Action.client([issues, issueEvents]);
140
+
141
+ expect(
142
+ yield* Effect.flip(
143
+ Stream.runDrain(client.watch(IssueChanged)).pipe(as("kay"))
144
+ )
145
+ ).toStrictEqual(
146
+ new Action.Forbidden({
147
+ message: "Requires issues:read.",
148
+ scopes: ["issues:read"],
149
+ })
150
+ );
151
+ }).pipe(Effect.provide(inProcess))
152
+ );
153
+ });
154
+
155
+ // The subscriber's secret: `whsec_` and 24 to 64 random bytes in base64.
156
+ const secret = `whsec_${Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("base64")}`;
157
+
158
+ const Delivered = Schema.fromJsonString(
159
+ Schema.Record(Schema.String, Schema.Json)
160
+ );
161
+
162
+ // linus's server: it echoes the verification's challenge, which proves it asked for these
163
+ // deliveries, and acknowledges each delivery with a 2xx.
164
+ const subscriber =
165
+ (received: Queue.Enqueue<typeof Delivered.Type>) =>
166
+ async (request: Request) => {
167
+ const body = Schema.decodeSync(Delivered)(await request.text());
168
+
169
+ Queue.offerUnsafe(received, body);
170
+
171
+ return body["type"] === "verification"
172
+ ? Response.json({ challenge: body["challenge"] })
173
+ : new Response(null);
174
+ };
175
+
176
+ const signedIn = (token: string, workspace: string) =>
177
+ HttpClient.mapRequest((request) =>
178
+ request.pipe(
179
+ HttpClientRequest.bearerToken(token),
180
+ HttpClientRequest.setHeader("x-workspace", workspace)
181
+ )
182
+ );
183
+
184
+ describe("issue.changed by webhook", () => {
185
+ it.effect(
186
+ "POSTs each change of the subscriber's workspace, as the subscriber sees it",
187
+ () =>
188
+ Effect.flatMap(Queue.unbounded<typeof Delivered.Type>(), (received) =>
189
+ Effect.gen(function* () {
190
+ const http = yield* HttpClient.HttpClient;
191
+
192
+ yield* http.pipe(signedIn("linus", "acme")).execute(
193
+ HttpClientRequest.post(
194
+ "/api/v1/events/issue.changed/subscribe"
195
+ ).pipe(
196
+ HttpClientRequest.bodyJsonUnsafe({
197
+ input: { comments: false },
198
+ delivery: { url: "https://hooks.example.com/linus", secret },
199
+ })
200
+ )
201
+ );
202
+
203
+ const verification = yield* Queue.take(received);
204
+
205
+ for (const [token, workspace, url, body] of [
206
+ [
207
+ "grace",
208
+ "globex",
209
+ "/api/v1/createIssue",
210
+ { title: "From grace", body: "" },
211
+ ],
212
+ [
213
+ "ada",
214
+ "acme",
215
+ "/api/v1/createIssue",
216
+ { title: "From ada", body: "" },
217
+ ],
218
+ [
219
+ "ada",
220
+ "acme",
221
+ "/api/v1/commentOnIssue",
222
+ { id: "ACME-1", text: "Seen it." },
223
+ ],
224
+ ] as const) {
225
+ yield* http
226
+ .pipe(signedIn(token, workspace))
227
+ .execute(
228
+ HttpClientRequest.post(url).pipe(
229
+ HttpClientRequest.bodyJsonUnsafe(body)
230
+ )
231
+ );
232
+ }
233
+
234
+ const deliveries = [
235
+ yield* Queue.take(received),
236
+ yield* Queue.take(received),
237
+ ];
238
+
239
+ expect(verification).toMatchObject({ type: "verification" });
240
+ expect(
241
+ deliveries.map(({ name, data }) => ({ name, data }))
242
+ ).toStrictEqual([
243
+ {
244
+ name: "issue.changed",
245
+ data: {
246
+ workspace: "acme",
247
+ id: "ACME-1",
248
+ title: "From ada",
249
+ status: "open",
250
+ change: "filed",
251
+ },
252
+ },
253
+ {
254
+ name: "issue.changed",
255
+ data: {
256
+ workspace: "acme",
257
+ id: "ACME-1",
258
+ title: "From ada",
259
+ status: "open",
260
+ change: "commented",
261
+ },
262
+ },
263
+ ]);
264
+ }).pipe(
265
+ Effect.provide(
266
+ Testing.layer(publicRoutes).pipe(
267
+ Layer.provide(services(Testing.layer(subscriber(received))))
268
+ )
269
+ )
270
+ )
271
+ )
272
+ );
273
+ });
@@ -0,0 +1,120 @@
1
+ import { describe, expect, it } from "@effect/vitest";
2
+ import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
3
+ import * as ActionRpc from "@gjermundgaraba/effect-actions/ActionRpc";
4
+ import * as Testing from "@gjermundgaraba/effect-actions/Testing";
5
+ import { Effect, Exit, Layer, Stream } from "effect";
6
+ import { HttpClient, HttpClientRequest } from "effect/http";
7
+ import { RpcClient, RpcSerialization, RpcServer } from "effect/rpc";
8
+
9
+ import { Http } from "../../src/Api.js";
10
+ import type { ExportIssues } from "../../src/issues/Issues.js";
11
+ import { RateLimited } from "../../src/issues/Issues.js";
12
+ import { publicRoutes, rpc } from "../../src/routes.js";
13
+ import { Rpc } from "../../src/Rpc.js";
14
+ import { services } from "../support/services.js";
15
+
16
+ // The public port's routes and the web app's rpcs under one `Testing.layer`, as server.ts
17
+ // serves them under one router.
18
+ const served = RpcClient.layerProtocolHttp({ url: "/rpc" }).pipe(
19
+ Layer.provide(RpcSerialization.layerJson),
20
+ Layer.provideMerge(
21
+ Testing.layer(
22
+ Layer.merge(
23
+ publicRoutes,
24
+ rpc.pipe(
25
+ Layer.provide(RpcServer.layerProtocolHttp({ path: "/rpc" })),
26
+ Layer.provide(RpcSerialization.layerJson)
27
+ )
28
+ )
29
+ )
30
+ ),
31
+ Layer.provide(services())
32
+ );
33
+
34
+ const ada = {
35
+ transformClient: HttpClient.mapRequest((request) =>
36
+ request.pipe(
37
+ HttpClientRequest.bearerToken("ada"),
38
+ HttpClientRequest.setHeader("x-workspace", "acme")
39
+ )
40
+ ),
41
+ };
42
+
43
+ const titles = (exported: ReadonlyArray<{ readonly title: string }>) =>
44
+ exported.map(({ title }) => title);
45
+
46
+ describe("exportIssues", () => {
47
+ it.effect(
48
+ "streams the issues asked for, oldest first, reporting how far it is, over HTTP and RPC",
49
+ () =>
50
+ Effect.gen(function* () {
51
+ const http = yield* ActionHttp.client(Http, ada);
52
+ const web = yield* ActionRpc.client(Rpc);
53
+
54
+ for (const title of ["First", "Second", "Third"]) {
55
+ yield* http.createIssue({ title, body: "" });
56
+ }
57
+
58
+ yield* http.closeIssue({ id: "ACME-2" });
59
+
60
+ const reported: Array<typeof ExportIssues.progress.Type> = [];
61
+
62
+ const overHttp = yield* Stream.runCollect(
63
+ http.exportIssues(
64
+ {},
65
+ {
66
+ onProgress: (progress) =>
67
+ Effect.sync(() => reported.push(progress)),
68
+ }
69
+ )
70
+ );
71
+
72
+ // The web app's call carries its session cookie, as a browser attaches it.
73
+ const overRpc = yield* Stream.runCollect(
74
+ web.exportIssues({ status: "open" })
75
+ ).pipe(
76
+ RpcClient.withHeaders({ cookie: "tracker_session=ada-at-acme" })
77
+ );
78
+
79
+ expect([titles(overHttp), titles(overRpc), reported]).toStrictEqual([
80
+ ["First", "Second", "Third"],
81
+ ["First", "Third"],
82
+ [
83
+ { exported: 1, total: 3 },
84
+ { exported: 2, total: 3 },
85
+ { exported: 3, total: 3 },
86
+ ],
87
+ ]);
88
+ }).pipe(Effect.scoped, Effect.provide(served))
89
+ );
90
+
91
+ it.effect(
92
+ "refuses a third export in a minute before its first issue, a 429",
93
+ () =>
94
+ Effect.gen(function* () {
95
+ const http = yield* ActionHttp.client(Http, ada);
96
+
97
+ yield* Stream.runDrain(http.exportIssues({}));
98
+ yield* Stream.runDrain(http.exportIssues({}));
99
+
100
+ const refused = yield* Effect.exit(
101
+ Stream.runDrain(http.exportIssues({}))
102
+ );
103
+
104
+ const response = yield* HttpClient.execute(
105
+ HttpClientRequest.post("/api/v1/exportIssues").pipe(
106
+ HttpClientRequest.bearerToken("ada"),
107
+ HttpClientRequest.setHeader("x-workspace", "acme"),
108
+ HttpClientRequest.bodyJsonUnsafe({})
109
+ )
110
+ );
111
+
112
+ expect(
113
+ Exit.isFailure(refused) && Exit.findErrorOption(refused)
114
+ ).toMatchObject({
115
+ value: new RateLimited({ retryAfterSeconds: 60 }),
116
+ });
117
+ expect(response.status).toBe(429);
118
+ }).pipe(Effect.scoped, Effect.provide(served))
119
+ );
120
+ });