@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
@@ -0,0 +1,48 @@
1
+ import { createServer } from "node:http";
2
+
3
+ import { NodeHttpServer, NodeRuntime } from "@effect/platform-node";
4
+ import { ByteSize, Config, Effect, Layer } from "effect";
5
+ import { FetchHttpClient, HttpRouter, HttpServerRequest } from "effect/http";
6
+ import { KeyValueStore } from "effect/persistence";
7
+
8
+ import { Notices } from "./notifier/Notices.js";
9
+ import { notifier } from "./notifier/routes.js";
10
+
11
+ // Entrypoint of the second service: `BUILDS_TOKEN=grace PUBLIC_URL=https://<its public name>
12
+ // node --import tsx examples/build-events/src/notifier.ts`, with server.ts running. build-events
13
+ // POSTs only to a globally reachable address, so on one machine give it a tunnel's name.
14
+ const server = Layer.unwrap(
15
+ Effect.gen(function* () {
16
+ const buildEvents = yield* Config.String("BUILDS_URL").pipe(
17
+ Config.withDefault("http://127.0.0.1:3000")
18
+ );
19
+
20
+ // Redacted, so a log or an error printing the configuration never shows the token.
21
+ const token = yield* Config.Redacted("BUILDS_TOKEN");
22
+ const publicUrl = yield* Config.String("PUBLIC_URL");
23
+
24
+ // The loopback default serves this machine alone, which a tunnel reaches; HOST=0.0.0.0 lets
25
+ // build-events reach it directly.
26
+ const host = yield* Config.String("HOST").pipe(
27
+ Config.withDefault("127.0.0.1")
28
+ );
29
+
30
+ const port = yield* Config.Port("PORT").pipe(Config.withDefault(3001));
31
+
32
+ return HttpRouter.serve(notifier({ buildEvents, token, publicUrl })).pipe(
33
+ Layer.provide(NodeHttpServer.layer(createServer, { host, port })),
34
+ // The receiver's route is public and reads each body whole: a delivery is at most 256 KiB.
35
+ Layer.provide(
36
+ Layer.succeed(HttpServerRequest.MaxBodySize, ByteSize.kibibytes(256))
37
+ ),
38
+ // DEMO ONLY: notices and the watch's cursor live in this process's memory.
39
+ Layer.provide([
40
+ Notices.layerMemory,
41
+ KeyValueStore.layerMemory,
42
+ FetchHttpClient.layer,
43
+ ])
44
+ );
45
+ })
46
+ );
47
+
48
+ Layer.launch(server).pipe(NodeRuntime.runMain());
@@ -0,0 +1,71 @@
1
+ import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
2
+ import * as ActionMcp from "@gjermundgaraba/effect-actions/ActionMcp";
3
+ import * as ActionRpc from "@gjermundgaraba/effect-actions/ActionRpc";
4
+ import * as Webhook from "@gjermundgaraba/effect-actions/Webhook";
5
+ import { Layer } from "effect";
6
+ import { RpcSerialization, RpcServer } from "effect/rpc";
7
+
8
+ import { Http } from "./Api.js";
9
+ import { buildEvents } from "./builds/events.js";
10
+ import { builds } from "./builds/handlers.js";
11
+ import { logs } from "./builds/log.js";
12
+ import { statusEvents } from "./builds/status.js";
13
+ import { deployEvents } from "./deploys/events.js";
14
+ import { deploys } from "./deploys/handlers.js";
15
+ import { Login } from "./identity/Identity.js";
16
+ import { authenticate } from "./identity/verifier.js";
17
+ import { Rpc } from "./Rpc.js";
18
+
19
+ // Every domain's implementation, once: the events beside the actions, in one list.
20
+ const implementations = [
21
+ builds,
22
+ logs,
23
+ buildEvents,
24
+ statusEvents,
25
+ deploys,
26
+ deployEvents,
27
+ ];
28
+
29
+ // The actions as MCP tools and the events as MCP events, at `/mcp`. A client polls with
30
+ // `events/poll`, or holds `events/stream` open and is pushed each occurrence. Both events are
31
+ // protected, so each request needs a member's token, and each subscriber sees what its token
32
+ // lets it. This long-lived process holds the deploy event's `subscription` hook between polls and
33
+ // its log in memory, and pushes each occurrence: a `requestScoped` endpoint would serve the events
34
+ // by poll and webhook alone, and a serverless host, where no process lasts, check sources alone,
35
+ // without `Webhook.layer`.
36
+ //
37
+ // A tool answers one result, so `tailLog` would collect a whole log before answering, all of it
38
+ // in a model's context: its implementation, `logs`, is left out, and the endpoint serves every
39
+ // other action and every event.
40
+ const mcp = ActionMcp.layerHttp(
41
+ [builds, buildEvents, statusEvents, deploys, deployEvents],
42
+ {
43
+ name: "build-events",
44
+ version: "1.0.0",
45
+ authentication: Login,
46
+ }
47
+ );
48
+
49
+ // The same implementations as routes under `/api`, for a client that speaks HTTP rather than
50
+ // MCP: one implementation of each event serves every surface.
51
+ const api = ActionHttp.layer(Http, implementations);
52
+
53
+ // And as Effect RPC at `/rpc`, over HTTP. A watch is a stream rpc that never ends, so the
54
+ // serialization frames each element as it is sent: `layerNdjson` does, where `layerJson` would
55
+ // answer once every stream of the request ended, which a watch never does.
56
+ const rpc = ActionRpc.layer(Rpc, implementations).pipe(
57
+ Layer.provide(RpcServer.layerProtocolHttp({ path: "/rpc" })),
58
+ Layer.provide(RpcSerialization.layerNdjson)
59
+ );
60
+
61
+ // Webhook delivery for MCP and HTTP alike: a member names a callback URL and a secret, with
62
+ // MCP's `events/subscribe` or `POST /api/events/<name>/subscribe`, and each occurrence it may see
63
+ // is POSTed there, signed. One layer holds the subscriptions of both, so either surface refreshes
64
+ // one made over the other. `persist` keeps each in a `KeyValueStore`, so a restart goes on
65
+ // delivering from where it reached, as the subscriber `Login`'s `schema` keeps. It needs an
66
+ // `HttpClient` to POST, `Dns` to check where each callback resolves, and the store, which
67
+ // `server.ts` provides.
68
+ export const routes = Layer.mergeAll(mcp, api, rpc).pipe(
69
+ Layer.provide(authenticate),
70
+ Layer.provide(Webhook.layer({ persist: true }))
71
+ );
@@ -0,0 +1,62 @@
1
+ import { createServer } from "node:http";
2
+
3
+ import {
4
+ NodeDns,
5
+ NodeFileSystem,
6
+ NodeHttpServer,
7
+ NodePath,
8
+ NodeRuntime,
9
+ } from "@effect/platform-node";
10
+ import * as Event from "@gjermundgaraba/effect-actions/Event";
11
+ import { ByteSize, Config, Effect, Layer } from "effect";
12
+ import { FetchHttpClient, HttpRouter, HttpServerRequest } from "effect/http";
13
+ import { KeyValueStore } from "effect/persistence";
14
+
15
+ import { BuildStore } from "./builds/store.js";
16
+ import { Approvers } from "./deploys/approvers.js";
17
+ import { routes } from "./routes.js";
18
+
19
+ // Entrypoint: `node --import tsx examples/build-events/src/server.ts`.
20
+ // It chooses the stores, reads the configuration and starts Node's HTTP server.
21
+ const server = Layer.unwrap(
22
+ Effect.gen(function* () {
23
+ // The loopback default serves this machine alone; HOST=0.0.0.0 lets others reach it.
24
+ const host = yield* Config.String("HOST").pipe(
25
+ Config.withDefault("127.0.0.1")
26
+ );
27
+
28
+ const port = yield* Config.Port("PORT").pipe(Config.withDefault(3000));
29
+
30
+ const webhooks = yield* Config.String("WEBHOOKS_DIR").pipe(
31
+ Config.withDefault(".build-events/webhooks")
32
+ );
33
+
34
+ return HttpRouter.serve(routes).pipe(
35
+ Layer.provide(NodeHttpServer.layer(createServer, { host, port })),
36
+ // Request bodies have no size limit unless the host sets one. This covers every route
37
+ // that reads its body whole, the MCP endpoint and the webhook subscribe routes included.
38
+ Layer.provide(
39
+ Layer.succeed(HttpServerRequest.MaxBodySize, ByteSize.mebibytes(1))
40
+ ),
41
+ // DEMO ONLY: builds and approvers live in this process's memory; a deployment provides
42
+ // stores that last.
43
+ Layer.provide([BuildStore.layerMemory, Approvers.layerMemory]),
44
+ // The events this process announces: a notify after a build finishes, and each deploy
45
+ // request published. One for the whole server, which holds every subscriber's connection.
46
+ Layer.provide(Event.layerMemory),
47
+ // Webhook subscriptions are kept as files under WEBHOOKS_DIR, so a restart goes on
48
+ // delivering them; after one, the memory store above sends each a gap first. The files
49
+ // hold each subscriber's secret: give the directory to this server alone, and each server
50
+ // process its own, since the layer is its store's one writer.
51
+ Layer.provide(
52
+ KeyValueStore.layerFileSystem(webhooks).pipe(
53
+ Layer.provide([NodeFileSystem.layer, NodePath.layer])
54
+ )
55
+ ),
56
+ // Webhook delivery POSTs with fetch, and resolves each callback with the system's resolver.
57
+ Layer.provide([FetchHttpClient.layer, NodeDns.layer])
58
+ );
59
+ })
60
+ );
61
+
62
+ Layer.launch(server).pipe(NodeRuntime.runMain());
@@ -0,0 +1,68 @@
1
+ import { 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 { Effect, Fiber, Stream } from "effect";
5
+ import { TestClock } from "effect/testing";
6
+
7
+ import { BuildFinished } from "../../src/builds/Builds.js";
8
+ import { buildEvents } from "../../src/builds/events.js";
9
+ import { builds } from "../../src/builds/handlers.js";
10
+ import { BuildStore } from "../../src/builds/store.js";
11
+ import { as } from "../support/as.js";
12
+
13
+ it.effect(
14
+ "delivers each build a member may see as it finishes, as the subscription selects",
15
+ () =>
16
+ Effect.gen(function* () {
17
+ // One client holds both: the action that finishes builds and the event that reports them.
18
+ const client = yield* Action.client([builds, buildEvents]);
19
+
20
+ const failed = yield* client
21
+ .watch(BuildFinished, { status: "failed" })
22
+ .pipe(Stream.take(1), Stream.runCollect, as("grace"), Effect.forkChild);
23
+
24
+ // The watch starts from now: let it take its first position before builds finish.
25
+ yield* TestClock.adjust("0 millis");
26
+ yield* client
27
+ .finishBuild({ pipeline: "api", status: "failed" })
28
+ .pipe(as("ada"));
29
+ yield* client
30
+ .finishBuild({ pipeline: "web", status: "passed" })
31
+ .pipe(as("ada"));
32
+ // Each build finished notifies: the watch reads at once, with no wait for its next poll.
33
+ yield* client
34
+ .finishBuild({ pipeline: "web", status: "failed" })
35
+ .pipe(as("ada"));
36
+
37
+ // grace sees only the web pipeline, and asked only for failures.
38
+ expect(yield* Fiber.join(failed)).toMatchObject([
39
+ {
40
+ _tag: "Occurrence",
41
+ data: { id: "build-3", pipeline: "web", status: "failed" },
42
+ },
43
+ ]);
44
+ }).pipe(Effect.provide([BuildStore.layerMemory, Event.layerMemory]))
45
+ );
46
+
47
+ it.effect(
48
+ "sends a gap first to a subscriber resuming from a cursor the store never gave",
49
+ () =>
50
+ Effect.gen(function* () {
51
+ const client = yield* Action.client([builds, buildEvents]);
52
+
53
+ yield* client
54
+ .finishBuild({ pipeline: "web", status: "passed" })
55
+ .pipe(as("ada"));
56
+
57
+ // A cursor of the store before a restart, its offset one this store has too: what it named
58
+ // is gone, so the watch is told.
59
+ const resumed = yield* client
60
+ .watch(BuildFinished, {}, { cursor: "an-earlier-epoch:0" })
61
+ .pipe(Stream.take(2), Stream.runCollect, as("grace"));
62
+
63
+ expect(resumed).toMatchObject([
64
+ { _tag: "Gap" },
65
+ { _tag: "Occurrence", data: { id: "build-1", pipeline: "web" } },
66
+ ]);
67
+ }).pipe(Effect.provide([BuildStore.layerMemory, Event.layerMemory]))
68
+ );
@@ -0,0 +1,71 @@
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 } from "effect/rpc";
8
+
9
+ import { Http } from "../../src/Api.js";
10
+ import { BuildNotFound } from "../../src/builds/Builds.js";
11
+ import { routes } from "../../src/routes.js";
12
+ import { Rpc } from "../../src/Rpc.js";
13
+ import { inMemory } from "../support/memory.js";
14
+
15
+ const served = Testing.layer(routes).pipe(Layer.provide(inMemory()));
16
+
17
+ const overRpc = RpcClient.layerProtocolHttp({ url: "/rpc" }).pipe(
18
+ Layer.provide(RpcSerialization.layerNdjson),
19
+ Layer.provide(served)
20
+ );
21
+
22
+ const bearer = (token: string) =>
23
+ HttpClient.mapRequest(HttpClientRequest.bearerToken(token));
24
+
25
+ const log = [
26
+ { line: "install: passed" },
27
+ { line: "build: passed" },
28
+ { line: "test: passed" },
29
+ { line: "passed" },
30
+ ];
31
+
32
+ describe("a build's log", () => {
33
+ it.effect("streams each line of a build over HTTP and RPC", () =>
34
+ Effect.gen(function* () {
35
+ const http = yield* ActionHttp.client(Http, {
36
+ transformClient: bearer("ada"),
37
+ });
38
+
39
+ const rpc = yield* ActionRpc.client(Rpc);
40
+ const { id } = yield* http.startBuild({ pipeline: "api" });
41
+
42
+ // Each call returns a `Stream`: a line is read as the server sends it.
43
+ const lines = [
44
+ yield* Stream.runCollect(http.tailLog({ buildId: id })),
45
+ yield* Stream.runCollect(rpc.tailLog({ buildId: id })).pipe(
46
+ RpcClient.withHeaders({ authorization: "Bearer ada" })
47
+ ),
48
+ ];
49
+
50
+ expect(lines).toStrictEqual([log, log]);
51
+ }).pipe(Effect.scoped, Effect.provide(Layer.merge(served, overRpc)))
52
+ );
53
+
54
+ it.effect("is not found for a member who may not see its pipeline", () =>
55
+ Effect.gen(function* () {
56
+ const ada = yield* ActionHttp.client(Http, {
57
+ transformClient: bearer("ada"),
58
+ });
59
+
60
+ const grace = yield* ActionHttp.client(Http, {
61
+ transformClient: bearer("grace"),
62
+ });
63
+
64
+ const { id } = yield* ada.startBuild({ pipeline: "api" });
65
+
66
+ expect(
67
+ yield* Effect.exit(Stream.runDrain(grace.tailLog({ buildId: id })))
68
+ ).toStrictEqual(Exit.fail(new BuildNotFound({ buildId: id })));
69
+ }).pipe(Effect.provide(served))
70
+ );
71
+ });
@@ -0,0 +1,57 @@
1
+ import { describe, expect, it } from "@effect/vitest";
2
+ import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
3
+ import * as ActionMcp from "@gjermundgaraba/effect-actions/ActionMcp";
4
+ import * as Testing from "@gjermundgaraba/effect-actions/Testing";
5
+ import { Effect, Layer } from "effect";
6
+ import { HttpClient, HttpClientRequest } from "effect/http";
7
+
8
+ import { Http } from "../../src/Api.js";
9
+ import { StartBuild } from "../../src/builds/Builds.js";
10
+ import { Login } from "../../src/identity/Identity.js";
11
+ import { routes } from "../../src/routes.js";
12
+ import { inMemory } from "../support/memory.js";
13
+
14
+ const served = Testing.layer(routes).pipe(Layer.provide(inMemory()));
15
+
16
+ const bearer = (token: string) =>
17
+ HttpClient.mapRequest(HttpClientRequest.bearerToken(token));
18
+
19
+ const steps = [
20
+ { step: "install", done: 0, total: 3 },
21
+ { step: "build", done: 1, total: 3 },
22
+ { step: "test", done: 2, total: 3 },
23
+ ];
24
+
25
+ describe("a build started by a member", () => {
26
+ it.effect(
27
+ "reports each step to a caller that asks, over HTTP and MCP, then answers",
28
+ () =>
29
+ Effect.gen(function* () {
30
+ const http = yield* ActionHttp.client(Http, {
31
+ transformClient: bearer("ada"),
32
+ });
33
+
34
+ const mcp = yield* ActionMcp.client([StartBuild], {
35
+ authentication: Login,
36
+ transformClient: bearer("ada"),
37
+ });
38
+
39
+ const seen: unknown[] = [];
40
+
41
+ const onProgress = (step: (typeof steps)[number]) =>
42
+ Effect.sync(() => seen.push(step));
43
+
44
+ // A caller asks for the steps by giving `onProgress`; without it, the call answers alone.
45
+ const answers = [
46
+ yield* http.startBuild({ pipeline: "api" }, { onProgress }),
47
+ yield* mcp.startBuild({ pipeline: "api" }, { onProgress }),
48
+ ];
49
+
50
+ expect(seen).toStrictEqual([...steps, ...steps]);
51
+ expect(answers).toStrictEqual([
52
+ { id: "build-1", pipeline: "api", status: "passed" },
53
+ { id: "build-2", pipeline: "api", status: "passed" },
54
+ ]);
55
+ }).pipe(Effect.provide(served))
56
+ );
57
+ });
@@ -0,0 +1,42 @@
1
+ import { 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 { Effect, Fiber, Stream } from "effect";
5
+ import { TestClock } from "effect/testing";
6
+
7
+ import { PipelineStatus } from "../../src/builds/Builds.js";
8
+ import { builds } from "../../src/builds/handlers.js";
9
+ import { statusEvents } from "../../src/builds/status.js";
10
+ import { BuildStore } from "../../src/builds/store.js";
11
+ import { as } from "../support/as.js";
12
+
13
+ it.effect(
14
+ "sends a pipeline's status as it is, then as each of its builds ends, and nothing for another's",
15
+ () =>
16
+ Effect.gen(function* () {
17
+ const client = yield* Action.client([builds, statusEvents]);
18
+
19
+ const watching = yield* client
20
+ .watch(PipelineStatus, { pipeline: "web" })
21
+ .pipe(Stream.take(3), Stream.runCollect, as("grace"), Effect.forkChild);
22
+
23
+ yield* TestClock.adjust("0 millis");
24
+ yield* client
25
+ .finishBuild({ pipeline: "api", status: "failed" })
26
+ .pipe(as("ada"));
27
+ yield* TestClock.adjust("0 millis");
28
+ yield* client
29
+ .finishBuild({ pipeline: "web", status: "failed" })
30
+ .pipe(as("ada"));
31
+ yield* TestClock.adjust("0 millis");
32
+ yield* client
33
+ .finishBuild({ pipeline: "web", status: "passed" })
34
+ .pipe(as("ada"));
35
+
36
+ expect(yield* Fiber.join(watching)).toMatchObject([
37
+ { _tag: "Occurrence", data: { status: null } },
38
+ { _tag: "Occurrence", data: { status: "failed" } },
39
+ { _tag: "Occurrence", data: { status: "passed" } },
40
+ ]);
41
+ }).pipe(Effect.provide([BuildStore.layerMemory, Event.layerMemory]))
42
+ );
@@ -0,0 +1,103 @@
1
+ import { NodeServices } from "@effect/platform-node";
2
+ import { describe, expect, it } from "@effect/vitest";
3
+ import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
4
+ import * as Testing from "@gjermundgaraba/effect-actions/Testing";
5
+ import { ConfigProvider, Effect, Fiber, Layer, Schema } from "effect";
6
+ import { Command } from "effect/cli";
7
+ import { HttpClient, HttpClientRequest } from "effect/http";
8
+ import { TestClock, TestConsole } from "effect/testing";
9
+
10
+ import { Http } from "../src/Api.js";
11
+ import { commands } from "../src/commands.js";
12
+ import { routes } from "../src/routes.js";
13
+ import { inMemory } from "./support/memory.js";
14
+
15
+ const served = Testing.layer(routes).pipe(Layer.provide(inMemory()));
16
+
17
+ // Effect's own runner, given arguments instead of the process's, as the member `token` names.
18
+ const run = (token: string, args: readonly string[]) =>
19
+ Command.runWith(commands, { version: "0" })(args).pipe(
20
+ Effect.provide(
21
+ ConfigProvider.layer(
22
+ ConfigProvider.fromEnvRecord({ BUILDS_TOKEN: token })
23
+ ).pipe(Layer.provideMerge(NodeServices.layer))
24
+ )
25
+ );
26
+
27
+ describe("the commands", () => {
28
+ it.effect(
29
+ "print each build a member may see as a line of JSON, until stopped",
30
+ () =>
31
+ Effect.gen(function* () {
32
+ // A watch runs until it is stopped, as Ctrl-C stops it: here, by interrupting it.
33
+ const watching = yield* Effect.forkChild(
34
+ run("grace", ["watch", "build.finished"])
35
+ );
36
+
37
+ yield* TestClock.adjust("0 millis");
38
+
39
+ const ada = yield* ActionHttp.client(Http, {
40
+ transformClient: HttpClient.mapRequest(
41
+ HttpClientRequest.bearerToken("ada")
42
+ ),
43
+ });
44
+
45
+ yield* ada.finishBuild({ pipeline: "api", status: "passed" });
46
+ yield* ada.finishBuild({ pipeline: "web", status: "passed" });
47
+ yield* TestClock.adjust("5 seconds");
48
+ yield* Fiber.interrupt(watching);
49
+
50
+ // Each line carries its cursor: `--cursor` given it resumes after that build.
51
+ const lines = yield* Schema.decodeUnknownEffect(
52
+ Schema.Array(Schema.fromJsonString(Schema.Unknown))
53
+ )(yield* TestConsole.logLines);
54
+
55
+ expect(lines).toMatchObject([
56
+ { _tag: "Occurrence", data: { id: "build-2", pipeline: "web" } },
57
+ ]);
58
+ expect(JSON.stringify(lines)).toMatch(/"cursor":"[^"]+:2"/u);
59
+ }).pipe(Effect.provide(served))
60
+ );
61
+
62
+ it.effect(
63
+ "print each step of a build on stderr as it starts, and the build on stdout",
64
+ () =>
65
+ Effect.gen(function* () {
66
+ yield* run("ada", ["start-build", "--pipeline", "api"]);
67
+
68
+ // stdout holds the answer alone, so `builds start-build ... | jq` reads the build.
69
+ expect(yield* TestConsole.logLines).toStrictEqual([
70
+ JSON.stringify(
71
+ { id: "build-1", pipeline: "api", status: "passed" },
72
+ null,
73
+ 2
74
+ ),
75
+ ]);
76
+ expect(yield* TestConsole.errorLines).toStrictEqual([
77
+ '{"step":"install","done":0,"total":3}',
78
+ '{"step":"build","done":1,"total":3}',
79
+ '{"step":"test","done":2,"total":3}',
80
+ ]);
81
+ }).pipe(Effect.provide(served))
82
+ );
83
+
84
+ it.effect(
85
+ "print each line of a build's log as a line of JSON, as it arrives",
86
+ () =>
87
+ Effect.gen(function* () {
88
+ yield* run("ada", [
89
+ "finish-build",
90
+ "--pipeline",
91
+ "web",
92
+ "--status",
93
+ "failed",
94
+ ]);
95
+ yield* run("ada", ["tail-log", "--build-id", "build-1"]);
96
+
97
+ // A line per element, as JSON Lines: `builds tail-log ... | jq -r .line` prints the log.
98
+ expect((yield* TestConsole.logLines).slice(1)).toStrictEqual([
99
+ '{"line":"failed"}',
100
+ ]);
101
+ }).pipe(Effect.provide(served))
102
+ );
103
+ });
@@ -0,0 +1,52 @@
1
+ import { 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 { Effect, Fiber, Stream } from "effect";
5
+ import { TestClock } from "effect/testing";
6
+
7
+ import { Approvers } from "../../src/deploys/approvers.js";
8
+ import { DeployRequested } from "../../src/deploys/Deploys.js";
9
+ import { deployEvents } from "../../src/deploys/events.js";
10
+ import { deploys } from "../../src/deploys/handlers.js";
11
+ import { as } from "../support/as.js";
12
+
13
+ it.effect(
14
+ "delivers each deploy request to the subscribers of its environment, who stand by to approve it",
15
+ () =>
16
+ Effect.gen(function* () {
17
+ const client = yield* Action.client([deploys, deployEvents]);
18
+
19
+ const production = yield* client
20
+ .watch(DeployRequested, { environment: "production" })
21
+ .pipe(Stream.take(1), Stream.runCollect, as("grace"), Effect.forkChild);
22
+
23
+ yield* TestClock.adjust("0 millis");
24
+
25
+ const staging = yield* client
26
+ .requestDeploy({ environment: "staging", version: "1.1.0" })
27
+ .pipe(as("ada"));
28
+
29
+ yield* client
30
+ .requestDeploy({ environment: "production", version: "1.0.9" })
31
+ .pipe(as("ada"));
32
+
33
+ expect(staging.approvers).toStrictEqual([]);
34
+ expect(yield* Fiber.join(production)).toMatchObject([
35
+ {
36
+ data: {
37
+ environment: "production",
38
+ version: "1.0.9",
39
+ requestedBy: "ada",
40
+ approvers: ["grace"],
41
+ },
42
+ },
43
+ ]);
44
+
45
+ // The stream ended, and with it grace's standing by.
46
+ const later = yield* client
47
+ .requestDeploy({ environment: "production", version: "1.1.0" })
48
+ .pipe(as("ada"));
49
+
50
+ expect(later.approvers).toStrictEqual([]);
51
+ }).pipe(Effect.provide([Approvers.layerMemory, Event.layerMemory]))
52
+ );
@@ -0,0 +1,95 @@
1
+ import { describe, expect, it } from "@effect/vitest";
2
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
3
+ import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
4
+ import * as Testing from "@gjermundgaraba/effect-actions/Testing";
5
+ import { Effect, Fiber, Layer, Stream } from "effect";
6
+ import { HttpClient, HttpClientRequest } from "effect/http";
7
+ import { TestClock } from "effect/testing";
8
+
9
+ import { Http } from "../src/Api.js";
10
+ import { BuildFinished } from "../src/builds/Builds.js";
11
+ import { DeployRequested } from "../src/deploys/Deploys.js";
12
+ import { routes } from "../src/routes.js";
13
+ import { inMemory } from "./support/memory.js";
14
+
15
+ const served = Testing.layer(routes).pipe(Layer.provide(inMemory()));
16
+
17
+ // One client of the binding per member: the token is the member, as in production.
18
+ const as = (token: string) =>
19
+ ActionHttp.client(Http, {
20
+ transformClient: HttpClient.mapRequest(
21
+ HttpClientRequest.bearerToken(token)
22
+ ),
23
+ });
24
+
25
+ describe("the service, over HTTP", () => {
26
+ it.effect(
27
+ "streams each build a member may see, as SSE, as a call finishes it",
28
+ () =>
29
+ Effect.gen(function* () {
30
+ const ada = yield* as("ada");
31
+ const grace = yield* as("grace");
32
+
33
+ // `watch` asks for `text/event-stream`, and the route holds the stream open.
34
+ const watched = yield* grace
35
+ .watch(BuildFinished)
36
+ .pipe(Stream.take(1), Stream.runCollect, Effect.forkChild);
37
+
38
+ // The watch starts from now: let it take its first position before builds finish.
39
+ yield* TestClock.adjust("0 millis");
40
+ yield* ada.finishBuild({ pipeline: "api", status: "passed" });
41
+ yield* ada.finishBuild({ pipeline: "web", status: "passed" });
42
+
43
+ expect(yield* Fiber.join(watched)).toMatchObject([
44
+ { _tag: "Occurrence", data: { id: "build-2", pipeline: "web" } },
45
+ ]);
46
+ }).pipe(Effect.provide(served))
47
+ );
48
+
49
+ it.effect(
50
+ "delivers deploy requests to a member that polls the route for JSON pages",
51
+ () =>
52
+ Effect.gen(function* () {
53
+ const ada = yield* as("ada");
54
+ const grace = yield* as("grace");
55
+
56
+ // `mode: "poll"` asks for a page at a time, as a client that cannot hold a stream does.
57
+ const polled = yield* grace
58
+ .watch(
59
+ DeployRequested,
60
+ { environment: "production" },
61
+ { mode: "poll" }
62
+ )
63
+ .pipe(Stream.take(1), Stream.runCollect, Effect.forkChild);
64
+
65
+ yield* TestClock.adjust("0 millis");
66
+
67
+ const deploy = yield* ada.requestDeploy({
68
+ environment: "production",
69
+ version: "1.0.9",
70
+ });
71
+
72
+ yield* TestClock.adjust("5 seconds");
73
+
74
+ expect(deploy.approvers).toStrictEqual(["grace"]);
75
+ expect(yield* Fiber.join(polled)).toMatchObject([
76
+ {
77
+ _tag: "Occurrence",
78
+ data: { version: "1.0.9", approvers: ["grace"] },
79
+ },
80
+ ]);
81
+ }).pipe(Effect.provide(served))
82
+ );
83
+
84
+ it.effect(
85
+ "refuses a watch without a member's token, as the route's 401",
86
+ () =>
87
+ Effect.gen(function* () {
88
+ const stranger = yield* as("mallory");
89
+
90
+ expect(
91
+ yield* Effect.flip(Stream.runDrain(stranger.watch(BuildFinished)))
92
+ ).toBeInstanceOf(Action.Unauthenticated);
93
+ }).pipe(Effect.provide(served))
94
+ );
95
+ });