@gjermundgaraba/effect-actions 0.20.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 (321) hide show
  1. package/README.md +19 -41
  2. package/dist/Action.d.mts +140 -46
  3. package/dist/Action.mjs +87 -42
  4. package/dist/ActionCli.d.mts +69 -33
  5. package/dist/ActionCli.mjs +51 -32
  6. package/dist/ActionHttp.d.mts +69 -35
  7. package/dist/ActionHttp.mjs +75 -23
  8. package/dist/ActionMcp.d.mts +56 -22
  9. package/dist/ActionMcp.mjs +94 -69
  10. package/dist/ActionRpc.d.mts +222 -55
  11. package/dist/ActionRpc.mjs +299 -46
  12. package/dist/ActionToolkit.d.mts +22 -5
  13. package/dist/ActionToolkit.mjs +36 -12
  14. package/dist/Authentication.d.mts +42 -10
  15. package/dist/Authentication.mjs +37 -30
  16. package/dist/Event.d.mts +370 -0
  17. package/dist/Event.mjs +103 -0
  18. package/dist/Testing.mjs +1 -1
  19. package/dist/Webhook.d.mts +146 -0
  20. package/dist/Webhook.mjs +72 -0
  21. package/dist/authentication/provider.d.mts +35 -10
  22. package/dist/authentication/provider.mjs +1 -1
  23. package/dist/authentication/refusal.mjs +2 -2
  24. package/dist/cli/commands.d.mts +9 -5
  25. package/dist/cli/commands.mjs +84 -33
  26. package/dist/contract/ast.mjs +4 -1
  27. package/dist/contract/authorizer.d.mts +8 -0
  28. package/dist/contract/authorizer.mjs +20 -0
  29. package/dist/contract/call.d.mts +9 -6
  30. package/dist/contract/call.mjs +48 -6
  31. package/dist/contract/callback.d.mts +7 -0
  32. package/dist/contract/callback.mjs +27 -0
  33. package/dist/contract/caller.d.mts +13 -2
  34. package/dist/contract/caller.mjs +22 -3
  35. package/dist/contract/canonical.mjs +10 -0
  36. package/dist/contract/codec.d.mts +9 -0
  37. package/dist/contract/codec.mjs +9 -0
  38. package/dist/contract/codes.mjs +81 -0
  39. package/dist/contract/declared.mjs +25 -9
  40. package/dist/contract/digest.mjs +6 -0
  41. package/dist/contract/errors.d.mts +9 -3
  42. package/dist/contract/errors.mjs +1 -1
  43. package/dist/contract/feed.d.mts +127 -0
  44. package/dist/contract/feed.mjs +249 -0
  45. package/dist/contract/hub.d.mts +94 -0
  46. package/dist/contract/hub.mjs +133 -0
  47. package/dist/contract/idempotency.d.mts +47 -0
  48. package/dist/contract/idempotency.mjs +99 -0
  49. package/dist/contract/implementation.d.mts +73 -47
  50. package/dist/contract/implementation.mjs +92 -58
  51. package/dist/contract/input-check.d.mts +15 -0
  52. package/dist/contract/input-check.mjs +16 -0
  53. package/dist/contract/lease.mjs +28 -0
  54. package/dist/contract/listed.d.mts +4 -0
  55. package/dist/contract/listed.mjs +22 -0
  56. package/dist/contract/log.d.mts +41 -0
  57. package/dist/contract/log.mjs +144 -0
  58. package/dist/contract/names.mjs +14 -4
  59. package/dist/contract/occurrence.d.mts +29 -0
  60. package/dist/contract/occurrence.mjs +45 -0
  61. package/dist/contract/pages.d.mts +13 -0
  62. package/dist/contract/pages.mjs +39 -0
  63. package/dist/contract/progress.d.mts +26 -0
  64. package/dist/contract/progress.mjs +72 -0
  65. package/dist/contract/push.d.mts +20 -0
  66. package/dist/contract/push.mjs +96 -0
  67. package/dist/contract/receiving.d.mts +29 -0
  68. package/dist/contract/receiving.mjs +42 -0
  69. package/dist/contract/recent.mjs +12 -0
  70. package/dist/contract/reported.mjs +16 -0
  71. package/dist/contract/serving.mjs +63 -0
  72. package/dist/contract/status.mjs +14 -5
  73. package/dist/contract/stream.d.mts +34 -0
  74. package/dist/contract/stream.mjs +31 -0
  75. package/dist/contract/subscribe.d.mts +45 -0
  76. package/dist/contract/subscribe.mjs +50 -0
  77. package/dist/contract/transient.mjs +28 -0
  78. package/dist/contract/watch.d.mts +18 -0
  79. package/dist/contract/watch.mjs +105 -0
  80. package/dist/contract/webhooks.d.mts +42 -0
  81. package/dist/contract/webhooks.mjs +53 -0
  82. package/dist/http/client.d.mts +37 -12
  83. package/dist/http/client.mjs +51 -12
  84. package/dist/http/events.d.mts +56 -0
  85. package/dist/http/events.mjs +102 -0
  86. package/dist/http/placed.d.mts +36 -0
  87. package/dist/http/placed.mjs +71 -0
  88. package/dist/http/schema-errors.mjs +1 -1
  89. package/dist/http/streamed.d.mts +21 -0
  90. package/dist/http/streamed.mjs +94 -0
  91. package/dist/http/watch.mjs +50 -0
  92. package/dist/mcp/bridge.mjs +283 -0
  93. package/dist/mcp/client.d.mts +30 -97
  94. package/dist/mcp/client.mjs +59 -132
  95. package/dist/mcp/events.mjs +142 -0
  96. package/dist/mcp/progress.mjs +34 -0
  97. package/dist/mcp/protocol.mjs +39 -13
  98. package/dist/mcp/reply.d.mts +79 -0
  99. package/dist/mcp/reply.mjs +99 -0
  100. package/dist/mcp/request.d.mts +40 -0
  101. package/dist/mcp/request.mjs +51 -0
  102. package/dist/mcp/watch.mjs +115 -0
  103. package/dist/rpc/watch.mjs +39 -0
  104. package/dist/stdio/console.mjs +13 -5
  105. package/dist/testing/memory.d.mts +1 -1
  106. package/dist/testing/memory.mjs +3 -3
  107. package/dist/webhook/address.mjs +47 -0
  108. package/dist/webhook/attempt.mjs +65 -0
  109. package/dist/webhook/receiver.mjs +67 -0
  110. package/dist/webhook/signing.mjs +28 -0
  111. package/dist/webhook/stored.mjs +57 -0
  112. package/dist/webhook/subscriptions.mjs +342 -0
  113. package/docs/Action-failures.md +2 -2
  114. package/docs/Action-streams.md +115 -0
  115. package/docs/Action.md +58 -124
  116. package/docs/ActionCli-failures.md +1 -2
  117. package/docs/ActionCli.md +83 -88
  118. package/docs/ActionHttp-failures.md +3 -3
  119. package/docs/ActionHttp.md +90 -70
  120. package/docs/ActionMcp-failures.md +2 -1
  121. package/docs/ActionMcp.md +120 -92
  122. package/docs/ActionRpc-events.md +126 -0
  123. package/docs/ActionRpc-failures.md +1 -2
  124. package/docs/ActionRpc.md +108 -79
  125. package/docs/ActionToolkit-failures.md +1 -2
  126. package/docs/ActionToolkit.md +33 -90
  127. package/docs/Authentication-advanced.md +52 -60
  128. package/docs/Authentication-failures.md +5 -4
  129. package/docs/Authentication.md +23 -58
  130. package/docs/Event-failures.md +15 -0
  131. package/docs/Event.md +281 -0
  132. package/docs/README.md +51 -82
  133. package/docs/Testing-failures.md +1 -5
  134. package/docs/Testing.md +126 -86
  135. package/docs/Webhook-failures.md +8 -0
  136. package/docs/Webhook.md +319 -0
  137. package/docs/setup-failures.md +2 -3
  138. package/docs/setup.md +32 -52
  139. package/examples/README.md +12 -37
  140. package/examples/assistant/README.md +21 -26
  141. package/examples/assistant/src/agent.ts +66 -24
  142. package/examples/assistant/src/cli.ts +7 -4
  143. package/examples/assistant/src/commands.ts +9 -4
  144. package/examples/assistant/src/identity/Identity.ts +5 -4
  145. package/examples/assistant/src/identity/authorize.ts +13 -5
  146. package/examples/assistant/src/identity/handlers.ts +3 -2
  147. package/examples/assistant/src/identity/permissions.ts +9 -3
  148. package/examples/assistant/src/platform/tasks-file.ts +15 -11
  149. package/examples/assistant/src/scripted.ts +7 -4
  150. package/examples/assistant/src/tasks/Tasks.ts +20 -6
  151. package/examples/assistant/src/tasks/handlers.ts +9 -5
  152. package/examples/assistant/src/tasks/store.ts +38 -11
  153. package/examples/assistant/src/toolkit.ts +11 -7
  154. package/examples/assistant/test/commands.test.ts +53 -27
  155. package/examples/assistant/test/identity/identity.test.ts +27 -11
  156. package/examples/assistant/test/platform/tasks-file.test.ts +39 -23
  157. package/examples/assistant/test/tasks/tasks.test.ts +62 -34
  158. package/examples/assistant/test/toolkit.test.ts +104 -75
  159. package/examples/build-events/README.md +83 -0
  160. package/examples/build-events/src/Api.ts +27 -0
  161. package/examples/build-events/src/Rpc.ts +26 -0
  162. package/examples/build-events/src/builds/Builds.ts +108 -0
  163. package/examples/build-events/src/builds/events.ts +54 -0
  164. package/examples/build-events/src/builds/handlers.ts +58 -0
  165. package/examples/build-events/src/builds/log.ts +30 -0
  166. package/examples/build-events/src/builds/status.ts +28 -0
  167. package/examples/build-events/src/builds/store.ts +130 -0
  168. package/examples/build-events/src/cli.ts +18 -0
  169. package/examples/build-events/src/commands.ts +32 -0
  170. package/examples/build-events/src/deploys/Deploys.ts +44 -0
  171. package/examples/build-events/src/deploys/approvers.ts +55 -0
  172. package/examples/build-events/src/deploys/events.ts +32 -0
  173. package/examples/build-events/src/deploys/handlers.ts +38 -0
  174. package/examples/build-events/src/identity/Identity.ts +29 -0
  175. package/examples/build-events/src/identity/members.ts +7 -0
  176. package/examples/build-events/src/identity/verifier.ts +22 -0
  177. package/examples/build-events/src/notifier/Notices.ts +26 -0
  178. package/examples/build-events/src/notifier/routes.ts +71 -0
  179. package/examples/build-events/src/notifier.ts +48 -0
  180. package/examples/build-events/src/routes.ts +71 -0
  181. package/examples/build-events/src/server.ts +62 -0
  182. package/examples/build-events/test/builds/events.test.ts +68 -0
  183. package/examples/build-events/test/builds/log.test.ts +71 -0
  184. package/examples/build-events/test/builds/progress.test.ts +57 -0
  185. package/examples/build-events/test/builds/status.test.ts +42 -0
  186. package/examples/build-events/test/commands.test.ts +103 -0
  187. package/examples/build-events/test/deploys/events.test.ts +52 -0
  188. package/examples/build-events/test/http.test.ts +95 -0
  189. package/examples/build-events/test/notifier.test.ts +80 -0
  190. package/examples/build-events/test/routes.test.ts +96 -0
  191. package/examples/build-events/test/rpc.test.ts +84 -0
  192. package/examples/build-events/test/support/as.ts +8 -0
  193. package/examples/build-events/test/support/memory.ts +43 -0
  194. package/examples/build-events/test/webhooks.test.ts +143 -0
  195. package/examples/kitchen-sink/README.md +178 -166
  196. package/examples/kitchen-sink/src/Actions.ts +11 -3
  197. package/examples/kitchen-sink/src/Api.ts +12 -6
  198. package/examples/kitchen-sink/src/Rpc.ts +7 -5
  199. package/examples/kitchen-sink/src/admin/Admin.ts +21 -10
  200. package/examples/kitchen-sink/src/admin/authenticate.ts +8 -4
  201. package/examples/kitchen-sink/src/admin/authorize.ts +13 -5
  202. package/examples/kitchen-sink/src/admin/grants.ts +5 -3
  203. package/examples/kitchen-sink/src/admin/handlers.ts +9 -5
  204. package/examples/kitchen-sink/src/agent.ts +14 -6
  205. package/examples/kitchen-sink/src/assistant/Assistant.ts +21 -15
  206. package/examples/kitchen-sink/src/assistant/conversations.ts +14 -9
  207. package/examples/kitchen-sink/src/assistant/demo-model.ts +2 -2
  208. package/examples/kitchen-sink/src/assistant/handlers.ts +42 -22
  209. package/examples/kitchen-sink/src/attachments/Attachments.ts +6 -6
  210. package/examples/kitchen-sink/src/attachments/download.ts +7 -6
  211. package/examples/kitchen-sink/src/attachments/handlers.ts +9 -4
  212. package/examples/kitchen-sink/src/attachments/store.ts +25 -18
  213. package/examples/kitchen-sink/src/browser.ts +10 -4
  214. package/examples/kitchen-sink/src/cli.ts +28 -12
  215. package/examples/kitchen-sink/src/config.ts +1 -1
  216. package/examples/kitchen-sink/src/identity/Identity.ts +24 -10
  217. package/examples/kitchen-sink/src/identity/authenticate.ts +19 -11
  218. package/examples/kitchen-sink/src/identity/authorize.ts +14 -4
  219. package/examples/kitchen-sink/src/identity/directory.ts +16 -7
  220. package/examples/kitchen-sink/src/identity/handlers.ts +1 -0
  221. package/examples/kitchen-sink/src/identity/members.ts +7 -2
  222. package/examples/kitchen-sink/src/identity/quota.ts +23 -20
  223. package/examples/kitchen-sink/src/issues/Issues.ts +91 -15
  224. package/examples/kitchen-sink/src/issues/assistant.ts +4 -2
  225. package/examples/kitchen-sink/src/issues/events.ts +46 -0
  226. package/examples/kitchen-sink/src/issues/export.ts +35 -0
  227. package/examples/kitchen-sink/src/issues/guide.ts +4 -3
  228. package/examples/kitchen-sink/src/issues/handlers.ts +20 -7
  229. package/examples/kitchen-sink/src/issues/store.ts +156 -67
  230. package/examples/kitchen-sink/src/issues/write-limit.ts +44 -18
  231. package/examples/kitchen-sink/src/memory.ts +11 -2
  232. package/examples/kitchen-sink/src/ops.ts +10 -5
  233. package/examples/kitchen-sink/src/request-policy.ts +21 -9
  234. package/examples/kitchen-sink/src/routes.ts +67 -27
  235. package/examples/kitchen-sink/src/sdk.ts +22 -12
  236. package/examples/kitchen-sink/src/seed.ts +15 -9
  237. package/examples/kitchen-sink/src/server.ts +24 -10
  238. package/examples/kitchen-sink/src/stdio.ts +9 -3
  239. package/examples/kitchen-sink/src/workspaces/Workspaces.ts +10 -7
  240. package/examples/kitchen-sink/src/workspaces/handlers.ts +3 -2
  241. package/examples/kitchen-sink/src/workspaces/registry.ts +16 -10
  242. package/examples/kitchen-sink/src/workspaces/tenant.ts +12 -5
  243. package/examples/kitchen-sink/test/admin/admin.test.ts +38 -23
  244. package/examples/kitchen-sink/test/assistant/assistant.test.ts +471 -299
  245. package/examples/kitchen-sink/test/attachments/attachments.test.ts +135 -94
  246. package/examples/kitchen-sink/test/identity/identity.test.ts +110 -82
  247. package/examples/kitchen-sink/test/identity/session.test.ts +65 -43
  248. package/examples/kitchen-sink/test/issues/assistant.test.ts +50 -31
  249. package/examples/kitchen-sink/test/issues/events.test.ts +273 -0
  250. package/examples/kitchen-sink/test/issues/export.test.ts +120 -0
  251. package/examples/kitchen-sink/test/issues/issues.test.ts +105 -75
  252. package/examples/kitchen-sink/test/request-policy.test.ts +108 -47
  253. package/examples/kitchen-sink/test/routes.test.ts +102 -57
  254. package/examples/kitchen-sink/test/support/as.ts +9 -0
  255. package/examples/kitchen-sink/test/support/scripted.ts +6 -5
  256. package/examples/kitchen-sink/test/support/services.ts +17 -0
  257. package/examples/live-rpc/README.md +33 -35
  258. package/examples/live-rpc/src/Rpc.ts +9 -5
  259. package/examples/live-rpc/src/browser.ts +39 -11
  260. package/examples/live-rpc/src/chat/Chat.ts +30 -16
  261. package/examples/live-rpc/src/chat/authorize.ts +2 -1
  262. package/examples/live-rpc/src/chat/events.ts +74 -0
  263. package/examples/live-rpc/src/chat/handlers.ts +13 -5
  264. package/examples/live-rpc/src/chat/room.ts +9 -5
  265. package/examples/live-rpc/src/chat/store.ts +51 -14
  266. package/examples/live-rpc/src/client.ts +22 -5
  267. package/examples/live-rpc/src/identity/Identity.ts +3 -3
  268. package/examples/live-rpc/src/identity/verifier.ts +8 -4
  269. package/examples/live-rpc/src/page.ts +58 -12
  270. package/examples/live-rpc/src/routes.ts +14 -10
  271. package/examples/live-rpc/src/server.ts +15 -5
  272. package/examples/live-rpc/test/browser.test.ts +127 -50
  273. package/examples/live-rpc/test/chat/chat.test.ts +215 -23
  274. package/examples/live-rpc/test/routes.test.ts +130 -46
  275. package/examples/live-rpc/test/server.test.ts +94 -33
  276. package/examples/mcp-tools/README.md +24 -38
  277. package/examples/mcp-tools/src/platform/fake-farm.ts +63 -24
  278. package/examples/mcp-tools/src/platform/print-farm.ts +59 -29
  279. package/examples/mcp-tools/src/printers/Printers.ts +18 -7
  280. package/examples/mcp-tools/src/printers/farm.ts +15 -5
  281. package/examples/mcp-tools/src/printers/handlers.ts +12 -6
  282. package/examples/mcp-tools/src/routes.ts +8 -6
  283. package/examples/mcp-tools/src/server.ts +12 -5
  284. package/examples/mcp-tools/src/stdio.ts +3 -2
  285. package/examples/mcp-tools/test/printers/printers.test.ts +132 -89
  286. package/examples/mcp-tools/test/routes.test.ts +140 -81
  287. package/examples/notes-service/README.md +30 -45
  288. package/examples/notes-service/src/Api.ts +1 -0
  289. package/examples/notes-service/src/browser.ts +15 -14
  290. package/examples/notes-service/src/cli.ts +8 -3
  291. package/examples/notes-service/src/commands.ts +46 -20
  292. package/examples/notes-service/src/identity/Identity.ts +2 -2
  293. package/examples/notes-service/src/identity/authorize.ts +6 -2
  294. package/examples/notes-service/src/identity/handlers.ts +3 -2
  295. package/examples/notes-service/src/identity/users.ts +2 -1
  296. package/examples/notes-service/src/identity/verifier.ts +12 -6
  297. package/examples/notes-service/src/notes/Notes.ts +46 -22
  298. package/examples/notes-service/src/notes/handlers.ts +18 -9
  299. package/examples/notes-service/src/notes/store.ts +70 -43
  300. package/examples/notes-service/src/routes.ts +7 -5
  301. package/examples/notes-service/src/server.ts +12 -5
  302. package/examples/notes-service/src/status/Status.ts +1 -1
  303. package/examples/notes-service/src/status/handlers.ts +5 -3
  304. package/examples/notes-service/src/stdio.ts +7 -3
  305. package/examples/notes-service/src/worker.ts +6 -3
  306. package/examples/notes-service/test/commands.test.ts +164 -91
  307. package/examples/notes-service/test/notes/as.ts +3 -1
  308. package/examples/notes-service/test/notes/idempotency.test.ts +97 -67
  309. package/examples/notes-service/test/notes/in-process.test.ts +26 -15
  310. package/examples/notes-service/test/notes/notes.test.ts +49 -31
  311. package/examples/notes-service/test/routes.test.ts +106 -71
  312. package/examples/notes-service/test/worker.test.ts +21 -13
  313. package/examples/quickstart/README.md +2 -6
  314. package/examples/quickstart/src/Api.ts +1 -0
  315. package/examples/quickstart/src/client.ts +6 -3
  316. package/examples/quickstart/src/greeting/Greeting.ts +1 -1
  317. package/examples/quickstart/src/greeting/handlers.ts +5 -2
  318. package/examples/quickstart/src/routes.ts +3 -2
  319. package/examples/quickstart/src/server.ts +6 -2
  320. package/examples/quickstart/test/routes.test.ts +3 -2
  321. package/package.json +10 -4
@@ -0,0 +1,58 @@
1
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
2
+ import * as Event from "@gjermundgaraba/effect-actions/Event";
3
+ import { Effect } from "effect";
4
+
5
+ import {
6
+ BuildFinished,
7
+ FinishBuild,
8
+ PipelineStatus,
9
+ StartBuild,
10
+ } from "./Builds.js";
11
+ import type { Build } from "./Builds.js";
12
+ import { BuildStore } from "./store.js";
13
+
14
+ // DEMO ONLY: each step passes at once. A runner's steps take their time, which is why a caller
15
+ // wants to see them.
16
+ const steps = ["install", "build", "test"];
17
+
18
+ export const builds = Action.implement(
19
+ [FinishBuild, StartBuild],
20
+ Effect.gen(function* () {
21
+ const store = yield* BuildStore;
22
+ const events = yield* Event.Events;
23
+
24
+ // `build.finished` is a check source over the store: write the build there first, then wake
25
+ // its subscribers, who read it now rather than at their next poll. `pipeline.status` is a
26
+ // current source: the notify moves its pipeline's revision, so its subscribers alone read it.
27
+ const finished = (build: Effect.Effect<Build>) =>
28
+ Effect.tap(build, ({ pipeline }) =>
29
+ Effect.andThen(
30
+ events.notify(BuildFinished),
31
+ events.notify(PipelineStatus, { key: pipeline })
32
+ )
33
+ );
34
+
35
+ return {
36
+ finishBuild: ({ pipeline, status }) =>
37
+ finished(store.finish(pipeline, status, [status])),
38
+ // `progress` never waits for the caller: the build runs at its own pace.
39
+ startBuild: ({ pipeline }, { progress }) =>
40
+ Effect.andThen(
41
+ Effect.forEach(
42
+ steps,
43
+ (step, done) => progress({ step, done, total: steps.length }),
44
+ {
45
+ discard: true,
46
+ }
47
+ ),
48
+ finished(
49
+ store.finish(pipeline, "passed", [
50
+ ...steps.map((step) => `${step}: passed`),
51
+ "passed",
52
+ ])
53
+ )
54
+ ),
55
+ };
56
+ }),
57
+ { authorize: Action.allowAll }
58
+ );
@@ -0,0 +1,30 @@
1
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
2
+ import { Effect, Stream } from "effect";
3
+
4
+ import { CurrentMember } from "../identity/Identity.js";
5
+ import { TailLog } from "./Builds.js";
6
+ import { BuildStore } from "./store.js";
7
+
8
+ export const logs = Action.implement(
9
+ TailLog,
10
+ Effect.gen(function* () {
11
+ const store = yield* BuildStore;
12
+
13
+ // The handler returns a `Stream`, run in a scope of its own that closes when the caller
14
+ // stops reading, so what it acquires is released then.
15
+ return ({ buildId }) =>
16
+ Stream.unwrap(
17
+ Effect.gen(function* () {
18
+ const member = yield* CurrentMember;
19
+ // A failure before the first line, `BuildNotFound` here, fails the call as an action
20
+ // answering one value fails, a 404 over HTTP; one after a line ends the stream, the
21
+ // lines before it already read.
22
+ const log = yield* store.logOf(member.pipelines, buildId);
23
+
24
+ return Stream.fromIterable(log.map((line) => ({ line })));
25
+ })
26
+ );
27
+ }),
28
+ // Which builds a member may read is the store's check, by pipeline.
29
+ { authorize: Action.allowAll }
30
+ );
@@ -0,0 +1,28 @@
1
+ import * as Event from "@gjermundgaraba/effect-actions/Event";
2
+ import { Effect } from "effect";
3
+
4
+ import { CurrentMember } from "../identity/Identity.js";
5
+ import { PipelineStatus } from "./Builds.js";
6
+ import { BuildStore } from "./store.js";
7
+
8
+ // A current source: the value as it is, which the library sends first and again after each
9
+ // notify reaching the subscriber's key, keeping the revision that tells them apart, so the store
10
+ // keeps no cursor for it. The key is the pipeline: a build of another wakes no one here.
11
+ export const statusEvents = Event.implement(
12
+ PipelineStatus,
13
+ Effect.gen(function* () {
14
+ const store = yield* BuildStore;
15
+
16
+ return {
17
+ current: ({ pipeline }) =>
18
+ Effect.gen(function* () {
19
+ const member = yield* CurrentMember;
20
+
21
+ return { status: yield* store.statusOf(member.pipelines, pipeline) };
22
+ }),
23
+ key: ({ pipeline }) => pipeline,
24
+ };
25
+ }),
26
+ // Every member may watch; which pipelines each sees is the store's check.
27
+ { authorize: Event.allowAll }
28
+ );
@@ -0,0 +1,130 @@
1
+ import { Context, Effect, Layer } from "effect";
2
+
3
+ import { BuildNotFound } from "./Builds.js";
4
+ import type { Build } from "./Builds.js";
5
+
6
+ /** One page of the builds finished since a cursor. */
7
+ export interface Finished {
8
+ readonly builds: readonly Build[];
9
+ readonly cursor: string;
10
+ readonly hasMore: boolean;
11
+ /** Whether builds before the page may be missing: the cursor was not one of this store's. */
12
+ readonly truncated: boolean;
13
+ /** The store's epoch: a build's id is unique within it, and a restarted memory store starts another. */
14
+ readonly epoch: string;
15
+ }
16
+
17
+ // The position a cursor of `epoch` names, among `length` builds; none for another epoch's cursor
18
+ // or one that is not a cursor at all.
19
+ const positionOf = (
20
+ epoch: string,
21
+ cursor: string,
22
+ length: number
23
+ ): number | undefined => {
24
+ const [of, at] = cursor.split(":");
25
+ const position = Number(at);
26
+
27
+ return of === epoch &&
28
+ Number.isInteger(position) &&
29
+ position >= 0 &&
30
+ position <= length
31
+ ? position
32
+ : undefined;
33
+ };
34
+
35
+ /**
36
+ * Where finished builds are recorded, in the order they finished. Which pipelines a member may
37
+ * see is checked here, in the data access, so no subscription can skip it.
38
+ */
39
+ export class BuildStore extends Context.Service<
40
+ BuildStore,
41
+ {
42
+ /** Record a build that finished, with the lines its log holds. */
43
+ readonly finish: (
44
+ pipeline: string,
45
+ status: Build["status"],
46
+ log: readonly string[]
47
+ ) => Effect.Effect<Build>;
48
+ /** The log of build `buildId`, which must be one of `pipelines`. */
49
+ readonly logOf: (
50
+ pipelines: readonly string[],
51
+ buildId: string
52
+ ) => Effect.Effect<readonly string[], BuildNotFound>;
53
+ /**
54
+ * The builds of `pipelines` finished after `cursor`, at most `limit`; a `null` cursor is
55
+ * the current position, with none.
56
+ */
57
+ readonly finishedSince: (
58
+ pipelines: readonly string[],
59
+ cursor: string | null,
60
+ limit: number
61
+ ) => Effect.Effect<Finished>;
62
+ /** How `pipeline`'s last build ended, `null` before any did or where it is not of `pipelines`. */
63
+ readonly statusOf: (
64
+ pipelines: readonly string[],
65
+ pipeline: string
66
+ ) => Effect.Effect<Build["status"] | null>;
67
+ }
68
+ >()("build-events/BuildStore") {
69
+ // DEMO ONLY: builds live in this process's memory, so a restart forgets them and numbers them
70
+ // from 1 again. Its cursors carry an epoch of their own, so one from before the restart, or one
71
+ // it never gave, is answered from the start as `truncated`: the subscriber is sent a gap, never
72
+ // a silent loss. In a database the cursor is the position in its own change feed, such as a
73
+ // sequence column, so a subscriber resumes after a restart.
74
+ static readonly layerMemory = Layer.sync(BuildStore, () => {
75
+ const epoch = crypto.randomUUID();
76
+ const finished: Build[] = [];
77
+ const logs = new Map<string, readonly string[]>();
78
+
79
+ return BuildStore.of({
80
+ finish: (pipeline, status, log) =>
81
+ Effect.sync(() => {
82
+ const build = {
83
+ id: `build-${finished.length + 1}`,
84
+ pipeline,
85
+ status,
86
+ };
87
+
88
+ finished.push(build);
89
+ logs.set(build.id, log);
90
+
91
+ return build;
92
+ }),
93
+ logOf: (pipelines, buildId) => {
94
+ const build = finished.find(({ id }) => id === buildId);
95
+ const log = logs.get(buildId);
96
+
97
+ return build === undefined ||
98
+ log === undefined ||
99
+ !pipelines.includes(build.pipeline)
100
+ ? Effect.fail(new BuildNotFound({ buildId }))
101
+ : Effect.succeed(log);
102
+ },
103
+ finishedSince: (pipelines, cursor, limit) =>
104
+ Effect.sync(() => {
105
+ const at =
106
+ cursor === null
107
+ ? finished.length
108
+ : positionOf(epoch, cursor, finished.length);
109
+
110
+ const from = at ?? 0;
111
+ const next = finished.slice(from, from + limit);
112
+
113
+ return {
114
+ builds: next.filter((build) => pipelines.includes(build.pipeline)),
115
+ cursor: `${epoch}:${from + next.length}`,
116
+ hasMore: from + next.length < finished.length,
117
+ truncated: at === undefined,
118
+ epoch,
119
+ };
120
+ }),
121
+ statusOf: (pipelines, pipeline) =>
122
+ Effect.sync(() =>
123
+ pipelines.includes(pipeline)
124
+ ? (finished.findLast((build) => build.pipeline === pipeline)
125
+ ?.status ?? null)
126
+ : null
127
+ ),
128
+ });
129
+ });
130
+ }
@@ -0,0 +1,18 @@
1
+ import {
2
+ NodeHttpClient,
3
+ NodeRuntime,
4
+ NodeServices,
5
+ } from "@effect/platform-node";
6
+ import * as ActionCli from "@gjermundgaraba/effect-actions/ActionCli";
7
+ import { Effect } from "effect";
8
+ import { Command } from "effect/cli";
9
+
10
+ import { commands } from "./commands.js";
11
+
12
+ // Entrypoint: `BUILDS_TOKEN=grace node --import tsx examples/build-events/src/cli.ts watch
13
+ // build.finished`, with server.ts running.
14
+ Command.run(commands, { version: "1.0.0" }).pipe(
15
+ Effect.provide([NodeHttpClient.layerUndici, NodeServices.layer]),
16
+ ActionCli.logToStderr,
17
+ NodeRuntime.runMain()
18
+ );
@@ -0,0 +1,32 @@
1
+ import * as ActionCli from "@gjermundgaraba/effect-actions/ActionCli";
2
+ import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
3
+ import { Config, Effect } from "effect";
4
+ import { HttpClient, HttpClientRequest } from "effect/http";
5
+
6
+ import { Http } from "./Api.js";
7
+
8
+ // The binding's client, connected to where the server is and as which member, both read from
9
+ // config when a command runs.
10
+ const api = Effect.gen(function* () {
11
+ const baseUrl = yield* Config.String("BUILDS_URL").pipe(
12
+ Config.withDefault("http://127.0.0.1:3000")
13
+ );
14
+
15
+ const token = yield* Config.Redacted("BUILDS_TOKEN");
16
+
17
+ return yield* ActionHttp.client(Http, {
18
+ baseUrl,
19
+ transformClient: HttpClient.mapRequest(
20
+ HttpClientRequest.bearerToken(token)
21
+ ),
22
+ });
23
+ });
24
+
25
+ // A command per action, and `watch <event>` per event type: `builds watch build.finished
26
+ // --pipeline api` holds the event's stream open and prints each occurrence as a line of JSON,
27
+ // its `cursor` included, until it is stopped. `--cursor <cursor>` resumes after that line;
28
+ // omitted, the watch starts from now. The lines pipe into `jq` or a script as they come.
29
+ // `builds start-build --pipeline api` prints each step as a line of JSON on stderr as it
30
+ // starts, and the build on stdout, which a pipe reads alone. `builds tail-log --build-id
31
+ // build-1` prints each line of its log as a line of JSON as it arrives.
32
+ export const commands = ActionCli.remote(Http, { name: "builds", client: api });
@@ -0,0 +1,44 @@
1
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
2
+ import * as Event from "@gjermundgaraba/effect-actions/Event";
3
+ import { Schema } from "effect";
4
+
5
+ import { CurrentMember } from "../identity/Identity.js";
6
+
7
+ export const Environment = Schema.Literals(["staging", "production"]).annotate({
8
+ description: "Where to deploy.",
9
+ });
10
+
11
+ export type Environment = typeof Environment.Type;
12
+
13
+ export const Deploy = Schema.Struct({
14
+ id: Schema.String,
15
+ environment: Environment,
16
+ version: Schema.String,
17
+ requestedBy: Schema.String,
18
+ approvers: Schema.Array(Schema.String).annotate({
19
+ description:
20
+ "The members watching its environment when it was requested, asked to approve it.",
21
+ }),
22
+ });
23
+
24
+ export type Deploy = typeof Deploy.Type;
25
+
26
+ export const RequestDeploy = Action.make("requestDeploy", {
27
+ description: "Ask for a version to be deployed to an environment.",
28
+ input: { environment: Environment, version: Schema.String },
29
+ success: Deploy,
30
+ readOnly: false,
31
+ caller: CurrentMember,
32
+ });
33
+
34
+ export const DeployRequested = Event.make("deploy.requested", {
35
+ description:
36
+ "Someone asked for a deploy. Watching an environment stands you by to approve its deploys.",
37
+ input: {
38
+ environment: Schema.optional(Environment).annotate({
39
+ description: "Only deploys to here.",
40
+ }),
41
+ },
42
+ payload: Deploy,
43
+ caller: CurrentMember,
44
+ });
@@ -0,0 +1,55 @@
1
+ import { Context, Effect, Layer } from "effect";
2
+ import type { Scope } from "effect";
3
+
4
+ import type { Environment } from "./Deploys.js";
5
+
6
+ /** Who stands by to approve deploys, and to which environment. */
7
+ export class Approvers extends Context.Service<
8
+ Approvers,
9
+ {
10
+ /** Stands `member` by to approve deploys to `environment`, every one where undefined, until the scope closes. */
11
+ readonly standBy: (
12
+ environment: Environment | undefined,
13
+ member: string
14
+ ) => Effect.Effect<void, never, Scope.Scope>;
15
+ readonly standingBy: (
16
+ environment: Environment
17
+ ) => Effect.Effect<readonly string[]>;
18
+ }
19
+ >()("build-events/Approvers") {
20
+ // DEMO ONLY: approvers live in this process's memory, where a real one is a rota in a store.
21
+ static readonly layerMemory = Layer.sync(Approvers, () => {
22
+ const approvers: Array<{
23
+ readonly environment: Environment | undefined;
24
+ readonly member: string;
25
+ }> = [];
26
+
27
+ return Approvers.of({
28
+ standBy: (environment, member) =>
29
+ Effect.acquireRelease(
30
+ Effect.sync(() => {
31
+ const standing = { environment, member };
32
+
33
+ approvers.push(standing);
34
+
35
+ return standing;
36
+ }),
37
+ (standing) =>
38
+ Effect.sync(() => approvers.splice(approvers.indexOf(standing), 1))
39
+ ),
40
+ standingBy: (environment) =>
41
+ Effect.sync(() => [
42
+ ...new Set(
43
+ approvers
44
+ .values()
45
+ .filter(
46
+ (each) =>
47
+ each.environment === undefined ||
48
+ each.environment === environment
49
+ )
50
+ .map((each) => each.member)
51
+ ),
52
+ ]),
53
+ });
54
+ });
55
+ }
@@ -0,0 +1,32 @@
1
+ import * as Event from "@gjermundgaraba/effect-actions/Event";
2
+ import { Effect } from "effect";
3
+
4
+ import { CurrentMember } from "../identity/Identity.js";
5
+ import { Approvers } from "./approvers.js";
6
+ import { DeployRequested } from "./Deploys.js";
7
+
8
+ // A log source, for occurrences nothing else keeps: the library keeps each one `requestDeploy`
9
+ // publishes, for 10 minutes and at most 10,000 of them by default, and gives each subscriber
10
+ // what its `match` accepts. A subscriber resuming from a cursor gets what the log still holds:
11
+ // after a restart, or once its occurrences are dropped, a gap first. An upstream you can ask
12
+ // "what since?", such as a table, is a check source.
13
+ export const deployEvents = Event.implement(
14
+ DeployRequested,
15
+ Effect.gen(function* () {
16
+ const approvers = yield* Approvers;
17
+
18
+ return {
19
+ log: {},
20
+ match: ({ environment }, deploy) =>
21
+ environment === undefined || deploy.environment === environment,
22
+ // Set up while each subscription lasts, as the subscriber: a stream's as long as it is
23
+ // read, a poll's until 5 minutes after its last poll. It may run again for one
24
+ // subscription, after a restart, so standing by twice must be harmless.
25
+ subscription: ({ environment }) =>
26
+ Effect.flatMap(CurrentMember, (member) =>
27
+ approvers.standBy(environment, member.id)
28
+ ),
29
+ };
30
+ }),
31
+ { authorize: Event.allowAll }
32
+ );
@@ -0,0 +1,38 @@
1
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
2
+ import * as Event from "@gjermundgaraba/effect-actions/Event";
3
+ import { Effect } from "effect";
4
+
5
+ import { CurrentMember } from "../identity/Identity.js";
6
+ import { Approvers } from "./approvers.js";
7
+ import { DeployRequested, RequestDeploy } from "./Deploys.js";
8
+
9
+ export const deploys = Action.implement(
10
+ RequestDeploy,
11
+ Effect.gen(function* () {
12
+ const approvers = yield* Approvers;
13
+ const events = yield* Event.Events;
14
+
15
+ return ({ environment, version }) =>
16
+ Effect.gen(function* () {
17
+ const member = yield* CurrentMember;
18
+
19
+ const deploy = {
20
+ id: crypto.randomUUID(),
21
+ environment,
22
+ version,
23
+ requestedBy: member.id,
24
+ approvers: yield* approvers.standingBy(environment),
25
+ };
26
+
27
+ // Nothing else keeps a deploy request, so `deploy.requested` is a log source: the
28
+ // library keeps what is published here, and delivers it to each subscriber at once.
29
+ yield* events.publish(DeployRequested, {
30
+ eventId: deploy.id,
31
+ data: deploy,
32
+ });
33
+
34
+ return deploy;
35
+ });
36
+ }),
37
+ { authorize: Action.allowAll }
38
+ );
@@ -0,0 +1,29 @@
1
+ import * as Authentication from "@gjermundgaraba/effect-actions/Authentication";
2
+ import { Context, Schema } from "effect";
3
+
4
+ /** A member, and the pipelines whose builds it may see. */
5
+ export const Member = Schema.Struct({
6
+ id: Schema.String,
7
+ pipelines: Schema.Array(Schema.String),
8
+ });
9
+
10
+ export type Member = typeof Member.Type;
11
+
12
+ /**
13
+ * Who is subscribing or calling. Every event and action here declares it, `caller:
14
+ * CurrentMember`; authentication provides it per request.
15
+ */
16
+ export class CurrentMember extends Context.Service<CurrentMember, Member>()(
17
+ "build-events/CurrentMember"
18
+ ) {}
19
+
20
+ // How a remote caller proves it is a member: a bearer token. Clients import this descriptor
21
+ // with the contracts; its verifier stays on the server. `principal` names a member as one
22
+ // subscriber: a poll arrives as a request among others, and a surface keys what it holds
23
+ // between polls, deploy.requested's standing by, by it. Without it, MCP and HTTP refuse to
24
+ // serve that event, and no event is offered by webhook. `schema` keeps a webhook subscriber's
25
+ // identity, so the deliveries a restart restores run as that member.
26
+ export const Login = Authentication.make("build-events.Login", CurrentMember, {
27
+ principal: (member) => member.id,
28
+ schema: Member,
29
+ });
@@ -0,0 +1,7 @@
1
+ import type { Member } from "./Identity.js";
2
+
3
+ // DEMO ONLY: the members who may sign in, each under a token that is their name.
4
+ export const members = {
5
+ ada: { id: "ada", pipelines: ["api", "web"] },
6
+ grace: { id: "grace", pipelines: ["web"] },
7
+ } as const satisfies Readonly<Record<string, Member>>;
@@ -0,0 +1,22 @@
1
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
2
+ import * as Authentication from "@gjermundgaraba/effect-actions/Authentication";
3
+ import { Effect, Redacted } from "effect";
4
+
5
+ import { Login } from "./Identity.js";
6
+ import { members } from "./members.js";
7
+
8
+ const isMember = (token: string): token is keyof typeof members =>
9
+ Object.hasOwn(members, token);
10
+
11
+ // DEMO ONLY: a token is a member's name. A real verifier checks a signed token with your
12
+ // authorization server's library. An unknown token is the built-in `Unauthenticated`, which
13
+ // every client decodes, a watch's included.
14
+ export const authenticate = Authentication.layer(Login, (token) => {
15
+ const name = Redacted.value(token);
16
+
17
+ return isMember(name)
18
+ ? Effect.succeed(members[name])
19
+ : Effect.fail(
20
+ new Action.Unauthenticated({ message: "Unknown demo token." })
21
+ );
22
+ });
@@ -0,0 +1,26 @@
1
+ import { Context, Effect, Layer } from "effect";
2
+
3
+ import type { Build } from "../builds/Builds.js";
4
+
5
+ /** The failed builds the notifier was told of, in the order it was told. */
6
+ export class Notices extends Context.Service<
7
+ Notices,
8
+ {
9
+ readonly add: (build: Build) => Effect.Effect<void>;
10
+ readonly all: Effect.Effect<readonly Build[]>;
11
+ }
12
+ >()("build-events/Notices") {
13
+ // DEMO ONLY: notices live in this process's memory; a notifier sends each on, to a chat or a
14
+ // pager, as it arrives.
15
+ static readonly layerMemory = Layer.sync(Notices, () => {
16
+ const notices: Build[] = [];
17
+
18
+ return Notices.of({
19
+ add: (build) =>
20
+ Effect.sync(() => {
21
+ notices.push(build);
22
+ }),
23
+ all: Effect.sync(() => [...notices]),
24
+ });
25
+ });
26
+ }
@@ -0,0 +1,71 @@
1
+ import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
2
+ import * as Webhook from "@gjermundgaraba/effect-actions/Webhook";
3
+ import { Effect, Layer, Predicate, Stream } from "effect";
4
+ import type { Redacted } from "effect";
5
+ import { HttpClient, HttpClientRequest } from "effect/http";
6
+ import { KeyValueStore } from "effect/persistence";
7
+
8
+ import { Http } from "../Api.js";
9
+ import { BuildFinished } from "../builds/Builds.js";
10
+ import { Notices } from "./Notices.js";
11
+
12
+ /** Where build-events is, who the notifier signs in as there, and where it is reached itself. */
13
+ export interface NotifierOptions {
14
+ readonly buildEvents: string;
15
+ readonly token: string | Redacted.Redacted;
16
+ /** This process's public https origin, which build-events POSTs to. */
17
+ readonly publicUrl: string;
18
+ }
19
+
20
+ // The route build-events POSTs each failed build to, where the watch below reads them: the
21
+ // watch's delivery appends its path to this process's public URL.
22
+ const receiver = Webhook.layerReceiver({ path: "/hooks/builds" });
23
+
24
+ // A second service, the notifier: it watches build.finished for failures by webhook, the
25
+ // server POSTing each to it, rather than holding a stream open to build-events.
26
+ const watcher = ({ buildEvents, token, publicUrl }: NotifierOptions) =>
27
+ Layer.effectDiscard(
28
+ Effect.gen(function* () {
29
+ const notices = yield* Notices;
30
+
31
+ const builds = yield* ActionHttp.client(Http, {
32
+ baseUrl: buildEvents,
33
+ transformClient: HttpClient.mapRequest(
34
+ HttpClientRequest.bearerToken(token)
35
+ ),
36
+ });
37
+
38
+ yield* Effect.forkScoped(
39
+ builds
40
+ .watch(
41
+ BuildFinished,
42
+ { status: "failed" },
43
+ {
44
+ mode: Webhook.delivery({ baseUrl: publicUrl }),
45
+ // Where it reached is kept, so a restart resumes there; failures the server no
46
+ // longer keeps arrive as a Gap instead.
47
+ resume: {
48
+ store: yield* KeyValueStore.KeyValueStore,
49
+ key: "failed-builds",
50
+ },
51
+ }
52
+ )
53
+ .pipe(
54
+ // A Gap says failures were missed, which no notice can name: it is logged, for an
55
+ // operator to look the builds up.
56
+ Stream.runForEach((received) =>
57
+ Predicate.isTagged(received, "Occurrence")
58
+ ? notices.add(received.data)
59
+ : Effect.logWarning("Failed builds were missed", received)
60
+ ),
61
+ // A failure no reconnect outlasts, such as a wrong PUBLIC_URL or a revoked token,
62
+ // ends the watch: it is logged here, for a deployment to alert on or restart by.
63
+ Effect.tapCause(Effect.logError)
64
+ )
65
+ );
66
+ })
67
+ );
68
+
69
+ /** The notifier's routes: the receiver, and the watch reading from that same receiver. */
70
+ export const notifier = (options: NotifierOptions) =>
71
+ watcher(options).pipe(Layer.provideMerge(receiver));