@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,19 +1,20 @@
1
1
  import { describe, expect, it } from "@effect/vitest";
2
- import { Effect, flow, Layer } from "effect";
3
- import { HttpClient, HttpClientRequest } from "effect/http";
4
2
  import * as Action from "@gjermundgaraba/effect-actions/Action";
5
3
  import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
6
4
  import * as ActionMcp from "@gjermundgaraba/effect-actions/ActionMcp";
7
5
  import * as Testing from "@gjermundgaraba/effect-actions/Testing";
6
+ import { Effect, flow, Layer } from "effect";
7
+ import { HttpClient, HttpClientRequest } from "effect/http";
8
+
8
9
  import { Tools } from "../src/Actions.js";
9
10
  import { AdminHttp, Http } from "../src/Api.js";
10
- import { memory } from "../src/memory.js";
11
11
  import { adminRoutes, builders, publicRoutes } from "../src/routes.js";
12
+ import { services } from "./support/services.js";
12
13
 
13
14
  // The host's surfaces, as routes.ts builds them, answering in memory: no port is opened. Each
14
15
  // port is a `Testing.layer` of its own, and what they share is provided above both, as
15
16
  // server.ts does.
16
- const shared = builders.pipe(Layer.provideMerge(memory));
17
+ const shared = builders.pipe(Layer.provideMerge(services()));
17
18
 
18
19
  const onPublicPort = Effect.provide(Testing.layer(publicRoutes));
19
20
 
@@ -21,59 +22,92 @@ const onAdminPort = Effect.provide(Testing.layer(adminRoutes));
21
22
 
22
23
  const ada = {
23
24
  transformClient: HttpClient.mapRequest(
24
- flow(HttpClientRequest.bearerToken("ada"), HttpClientRequest.setHeader("x-workspace", "acme")),
25
+ flow(
26
+ HttpClientRequest.bearerToken("ada"),
27
+ HttpClientRequest.setHeader("x-workspace", "acme")
28
+ )
25
29
  ),
26
30
  };
27
31
 
28
32
  const ops = {
29
- transformClient: HttpClient.mapRequest(HttpClientRequest.setHeader("x-operator-key", "ops-key")),
33
+ transformClient: HttpClient.mapRequest(
34
+ HttpClientRequest.setHeader("x-operator-key", "ops-key")
35
+ ),
30
36
  };
31
37
 
32
38
  const post = (path: string) =>
33
- HttpClient.execute(HttpClientRequest.post(path).pipe(HttpClientRequest.bodyJsonUnsafe({})));
39
+ HttpClient.execute(
40
+ HttpClientRequest.post(path).pipe(HttpClientRequest.bodyJsonUnsafe({}))
41
+ );
34
42
 
35
- describe("the public port", () => {
36
- it.effect("answers the public action without a token and refuses the rest", () =>
37
- Effect.gen(function* () {
38
- const anyone = yield* ActionHttp.client(Http);
43
+ const from = (url: string, origin: string) =>
44
+ HttpClient.execute(
45
+ ActionMcp.request("tools/list", {}, { url, headers: { origin } })
46
+ );
39
47
 
40
- expect(yield* anyone.getStatus()).toMatchObject({ service: "tracker", workspaces: 2 });
41
- expect(yield* Effect.flip(anyone.whoAmI())).toBeInstanceOf(Action.Unauthenticated);
42
- }).pipe(onPublicPort, Effect.provide(shared)),
48
+ describe("the public port", () => {
49
+ it.effect(
50
+ "answers the public action without a token and refuses the rest",
51
+ () =>
52
+ Effect.gen(function* () {
53
+ const anyone = yield* ActionHttp.client(Http);
54
+
55
+ expect(yield* anyone.getStatus()).toMatchObject({
56
+ service: "tracker",
57
+ workspaces: 2,
58
+ });
59
+ expect(yield* Effect.flip(anyone.whoAmI())).toBeInstanceOf(
60
+ Action.Unauthenticated
61
+ );
62
+ }).pipe(onPublicPort, Effect.provide(shared))
43
63
  );
44
64
 
45
- it.effect("lists the tools to a signed-out MCP client, the unlisted action left out", () =>
46
- Effect.gen(function* () {
47
- const listing = yield* HttpClient.execute(ActionMcp.request("tools/list"));
48
- const text = yield* listing.text;
65
+ it.effect(
66
+ "lists the tools to a signed-out MCP client, the unlisted action left out",
67
+ () =>
68
+ Effect.gen(function* () {
69
+ const listing = yield* HttpClient.execute(
70
+ ActionMcp.request("tools/list")
71
+ );
49
72
 
50
- expect(listing.status).toBe(200);
51
- expect(text).toContain('"name":"createIssue"');
52
- expect(text).not.toContain('"name":"attachImage"');
53
- }).pipe(onPublicPort, Effect.provide(shared)),
73
+ const text = yield* listing.text;
74
+
75
+ expect(listing.status).toBe(200);
76
+ expect(text).toContain('"name":"createIssue"');
77
+ expect(text).not.toContain('"name":"attachImage"');
78
+ }).pipe(onPublicPort, Effect.provide(shared))
54
79
  );
55
80
 
56
- it.effect("serves one store to both surfaces: an issue filed over HTTP is read as a tool", () =>
57
- Effect.gen(function* () {
58
- const http = yield* ActionHttp.client(Http, ada);
59
- const mcp = yield* ActionMcp.client(Tools, ada);
60
- const issue = yield* http.createIssue({ title: "Crash", body: "On save." });
81
+ it.effect(
82
+ "serves one store to both surfaces: an issue filed over HTTP is read as a tool",
83
+ () =>
84
+ Effect.gen(function* () {
85
+ const http = yield* ActionHttp.client(Http, ada);
86
+ const mcp = yield* ActionMcp.client(Tools, ada);
61
87
 
62
- expect(yield* mcp.getIssue({ id: issue.id })).toEqual(issue);
63
- }).pipe(onPublicPort, Effect.provide(shared)),
88
+ const issue = yield* http.createIssue({
89
+ title: "Crash",
90
+ body: "On save.",
91
+ });
92
+
93
+ expect(yield* mcp.getIssue({ id: issue.id })).toStrictEqual(issue);
94
+ }).pipe(onPublicPort, Effect.provide(shared))
64
95
  );
65
96
 
66
- it.effect("refuses, at the MCP endpoint, a page of this host that is not on its list", () =>
67
- Effect.gen(function* () {
68
- // The request policy lets both through: one is the request's own origin, one is listed.
69
- const from = (url: string, origin: string) =>
70
- HttpClient.execute(ActionMcp.request("tools/list", {}, { url, headers: { origin } }));
97
+ it.effect(
98
+ "refuses, at the MCP endpoint, a page of this host that is not on its list",
99
+ () =>
100
+ Effect.gen(function* () {
101
+ // The request policy lets both through: one is the request's own origin, one is listed.
102
+ const listed = yield* from("/mcp", "http://localhost:6274");
71
103
 
72
- const listed = yield* from("/mcp", "http://localhost:6274");
73
- const sameHost = yield* from("http://127.0.0.1/mcp", "http://127.0.0.1");
104
+ const sameHost = yield* from(
105
+ "http://127.0.0.1/mcp",
106
+ "http://127.0.0.1"
107
+ );
74
108
 
75
- expect([listed.status, sameHost.status]).toEqual([200, 403]);
76
- }).pipe(onPublicPort, Effect.provide(shared)),
109
+ expect([listed.status, sameHost.status]).toStrictEqual([200, 403]);
110
+ }).pipe(onPublicPort, Effect.provide(shared))
77
111
  );
78
112
  });
79
113
 
@@ -83,42 +117,53 @@ describe("the two ports", () => {
83
117
  const admin = yield* post("/admin/listWorkspaces").pipe(onPublicPort);
84
118
  const member = yield* post("/api/v1/whoAmI").pipe(onAdminPort);
85
119
 
86
- expect([admin.status, member.status]).toEqual([404, 404]);
87
- }).pipe(Effect.provide(shared)),
120
+ expect([admin.status, member.status]).toStrictEqual([404, 404]);
121
+ }).pipe(Effect.provide(shared))
88
122
  );
89
123
 
90
124
  it.effect("run a builder once for an implementation both serve", () =>
91
125
  Effect.gen(function* () {
92
- const onPublic = yield* Effect.flatMap(ActionHttp.client(Http), (client) =>
93
- client.getStatus(),
126
+ const onPublic = yield* Effect.flatMap(
127
+ ActionHttp.client(Http),
128
+ (client) => client.getStatus()
94
129
  ).pipe(onPublicPort);
95
130
 
96
- const onAdmin = yield* Effect.flatMap(ActionHttp.client(AdminHttp), (client) =>
97
- client.getStatus(),
131
+ const onAdmin = yield* Effect.flatMap(
132
+ ActionHttp.client(AdminHttp),
133
+ (client) => client.getStatus()
98
134
  ).pipe(onAdminPort);
99
135
 
100
136
  // `status`'s builder names the start it ran in. With `builders` above both ports it ran
101
137
  // once. Provide `memory` alone instead, and each port runs it: two instances.
102
138
  expect(onAdmin.instance).toBe(onPublic.instance);
103
- }).pipe(Effect.provide(shared)),
139
+ }).pipe(Effect.provide(shared))
104
140
  );
105
141
 
106
- it.effect("share their services: a workspace suspended on one is refused on the other", () =>
107
- Effect.gen(function* () {
108
- const whoAmI = Effect.flatMap(ActionHttp.client(Http, ada), (client) => client.whoAmI());
142
+ it.effect(
143
+ "share their services: a workspace suspended on one is refused on the other",
144
+ () =>
145
+ Effect.gen(function* () {
146
+ const whoAmI = Effect.flatMap(ActionHttp.client(Http, ada), (client) =>
147
+ client.whoAmI()
148
+ );
109
149
 
110
- expect(yield* whoAmI.pipe(onPublicPort)).toMatchObject({ id: "ada" });
150
+ expect(yield* whoAmI.pipe(onPublicPort)).toMatchObject({ id: "ada" });
111
151
 
112
- const suspended = yield* Effect.flatMap(ActionHttp.client(AdminHttp, ops), (client) =>
113
- client.suspendWorkspace({ workspace: "acme" }),
114
- ).pipe(onAdminPort);
152
+ const suspended = yield* Effect.flatMap(
153
+ ActionHttp.client(AdminHttp, ops),
154
+ (client) => client.suspendWorkspace({ workspace: "acme" })
155
+ ).pipe(onAdminPort);
115
156
 
116
- expect(suspended).toEqual({ slug: "acme", name: "Acme", suspended: true });
157
+ expect(suspended).toStrictEqual({
158
+ slug: "acme",
159
+ name: "Acme",
160
+ suspended: true,
161
+ });
117
162
 
118
- const refused = yield* Effect.flip(whoAmI).pipe(onPublicPort);
163
+ const refused = yield* Effect.flip(whoAmI).pipe(onPublicPort);
119
164
 
120
- expect(refused).toBeInstanceOf(Action.Forbidden);
121
- expect(refused.message).toBe("This workspace is suspended.");
122
- }).pipe(Effect.provide(shared)),
165
+ expect(refused).toBeInstanceOf(Action.Forbidden);
166
+ expect(refused.message).toBe("This workspace is suspended.");
167
+ }).pipe(Effect.provide(shared))
123
168
  );
124
169
  });
@@ -0,0 +1,9 @@
1
+ import { Effect } from "effect";
2
+
3
+ import { CurrentMember } from "../../src/identity/Identity.js";
4
+ import { members } from "../../src/identity/members.js";
5
+
6
+ // What the implementation does, with no transport: `Action.client` runs the same decoding,
7
+ // `authorize` and handler a server runs. Each call or watch names its caller, as a request would.
8
+ export const as = (member: keyof typeof members) =>
9
+ Effect.provideService(CurrentMember, members[member]);
@@ -1,7 +1,8 @@
1
1
  import { Effect, Layer, Ref, Stream } from "effect";
2
- import { AiError, LanguageModel, type Response } from "effect/ai";
2
+ import { AiError, LanguageModel } from "effect/ai";
3
+ import type { Response } from "effect/ai";
3
4
 
4
- type Reply = ReadonlyArray<Response.PartEncoded>;
5
+ type Reply = readonly Response.PartEncoded[];
5
6
 
6
7
  // A model for a test: it answers its first request with the first reply it was given, its
7
8
  // second with the second, whatever the prompt says. A reply is the parts a provider sends, or
@@ -24,11 +25,11 @@ export const scripted = (
24
25
  return Effect.isEffect(reply)
25
26
  ? Effect.map(reply, (parts) => [...parts])
26
27
  : Effect.succeed([...reply]);
27
- },
28
+ }
28
29
  ),
29
30
  streamText: () => Stream.empty,
30
31
  });
31
- }),
32
+ })
32
33
  );
33
34
 
34
35
  export const down: Effect.Effect<never, AiError.AiError> = Effect.fail(
@@ -36,5 +37,5 @@ export const down: Effect.Effect<never, AiError.AiError> = Effect.fail(
36
37
  module: "scripted",
37
38
  method: "generateText",
38
39
  reason: new AiError.UnknownError({ description: "The provider is down." }),
39
- }),
40
+ })
40
41
  );
@@ -0,0 +1,17 @@
1
+ import * as Testing from "@gjermundgaraba/effect-actions/Testing";
2
+ import { Layer } from "effect";
3
+ import { Dns } from "effect/net";
4
+
5
+ import { memory } from "../../src/memory.js";
6
+
7
+ const unreached = async () => new Response(null, { status: 503 });
8
+
9
+ // What the public routes need, in memory: the application's services, and for webhook delivery
10
+ // a client and a resolver. `subscribers` answers each POST in place of a member's server, and
11
+ // hooks.example.com resolves to a public address: delivery refuses a private or loopback one.
12
+ export const services = (subscribers = Testing.layer(unreached)) =>
13
+ Layer.mergeAll(
14
+ memory,
15
+ subscribers,
16
+ Dns.layerStatic({ hosts: { "hooks.example.com": ["93.184.216.34"] } })
17
+ );
@@ -1,15 +1,10 @@
1
1
  # live-rpc
2
2
 
3
- A chat served as Effect RPC over a WebSocket: one connection per page, many calls on it.
4
- Members post, read and delete messages in the rooms they belong to; anyone may list the rooms.
5
- Each message on the socket carries its own token and names its room.
6
- To serve these same actions as JSON too, see
7
- [ActionRpc.md](../../docs/ActionRpc.md#serve-the-rpcs).
3
+ A chat served as Effect RPC over a WebSocket: one connection per page, many calls on it. Members post, read and delete messages in the rooms they belong to, and watch a room, each post reaching their page as it is made; anyone may list the rooms. Each message on the socket carries its own token and names its room. To serve these same actions as JSON too, see [ActionRpc.md](../../docs/ActionRpc.md#serve-the-rpcs).
8
4
 
9
5
  ## Run it
10
6
 
11
- From the repository root, after `vp install` (from an installed package, see
12
- [Run one](../README.md#run-one)):
7
+ From the repository root, after `vp install` (from an installed package, see [Run one](../README.md#run-one)):
13
8
 
14
9
  ```sh
15
10
  node --import tsx examples/live-rpc/src/server.ts # 127.0.0.1:3000, HOST and PORT to change
@@ -18,54 +13,57 @@ node --import tsx examples/live-rpc/src/client.ts # CHAT_URL to change, ws:/
18
13
  vp test examples/live-rpc
19
14
  ```
20
15
 
21
- `client.ts` runs the browser's client from Node: two members on one socket, then each declared
22
- error and refusal a page handles. `browser.ts` is imported by a page, not run: `openChat` for a
23
- page that is an Effect program; `page.ts` wraps it as `connect(url)` for one that calls with promises.
16
+ `client.ts` runs the browser's client from Node: two members on one socket, a watch of the room, then each declared error and refusal a page handles. `browser.ts` is imported by a page, not run: `openChat` for a page that is an Effect program; `page.ts` wraps it as `connect(url)` for one that calls with promises.
24
17
 
25
18
  ## Layout
26
19
 
27
20
  ```text
28
21
  src/
29
22
  ├── chat/
30
- │ ├── Chat.ts contract: schemas, errors, actions, the room header and its default
23
+ │ ├── Chat.ts contract: schemas, errors, actions, MessagePosted, the room header and its default
31
24
  │ ├── store.ts MessageStore, the service the handlers call, and its Layer
32
25
  │ ├── room.ts CurrentRoom, and the RPC middleware providing it per message
33
26
  │ ├── authorize.ts the one authorization rule: a member of the room
34
- │ └── handlers.ts Action.implement: contract to store
27
+ │ ├── handlers.ts Action.implement: contract to store, a post notifying its room
28
+ │ └── events.ts Event.implement: MessagePosted read from the store, keyed by room
35
29
  ├── identity/
36
30
  │ ├── Identity.ts contract: CurrentMember and the Login descriptor
37
31
  │ ├── members.ts the demo members
38
32
  │ └── verifier.ts Authentication.layer: tokens to members, server only
39
- ├── Rpc.ts the RPC binding of every action
33
+ ├── Rpc.ts the RPC binding of every action and MessagePosted
40
34
  ├── routes.ts the rpcs, authenticated and with their middleware, not yet on a protocol
41
- ├── server.ts entrypoint: the rpcs over a WebSocket at /rpc, its messages bounded
35
+ ├── server.ts entrypoint: the rpcs over a WebSocket at /rpc, its messages bounded, one Events
42
36
  ├── browser.ts client module: the connection and the chat, as one Effect program
43
37
  ├── page.ts the same chat as promises, for a page that is not one
44
38
  └── client.ts entrypoint: that client, run from Node
45
39
  test/
46
- ├── chat/ in process, with Action.client
47
- ├── routes.test.ts over RPC in memory, with Testing.layer
40
+ ├── chat/ in process, with Action.client, a watch included
41
+ ├── routes.test.ts over RPC in memory, with Testing.layer, a watch over NDJSON
48
42
  ├── browser.test.ts the page's client against the same rpcs
49
- └── server.test.ts the socket itself: a listening port, connect(url), the bound on a message
43
+ └── server.test.ts the socket itself: a listening port, connect(url), a watch, the bound on a message
50
44
  ```
51
45
 
52
46
  ## Where to look
53
47
 
54
- | To see | Read |
55
- | ---------------------------------------------------------------------- | ----------------------------------------------- |
56
- | An RPC binding naming its authentication descriptor | `src/Rpc.ts` |
57
- | The rpcs on Effect's `RpcServer`, before a protocol is chosen | `src/routes.ts` |
58
- | Serving them over a WebSocket, speaking JSON | `src/server.ts` |
59
- | A verifier that runs for every message, not once per connection | `src/identity/verifier.ts` |
60
- | Native `RpcMiddleware` providing a per-request service, with a default | `src/chat/room.ts` |
61
- | Handlers reading that service, and the rule that reads it | `src/chat/handlers.ts`, `src/chat/authorize.ts` |
62
- | A public rpc beside protected ones | `src/chat/Chat.ts` |
63
- | A socket client sending its token and room on each message | `src/browser.ts` |
64
- | Who owns the socket, and how a promise page keeps it open | `src/browser.ts`, `src/page.ts` |
65
- | A declared error and the built-in refusals handled by tag | `src/browser.ts` |
66
- | Running that client from Node, two callers on one socket | `src/client.ts` |
67
- | Testing handlers and authorization with no transport | `test/chat/chat.test.ts` |
68
- | Testing rpcs, authentication and middleware in memory | `test/routes.test.ts` |
69
- | The binding's native `RpcGroup` called by Effect's own client | `test/routes.test.ts` |
70
- | Testing the client's handling of each error and refusal | `test/browser.test.ts` |
71
- | Testing over a real WebSocket, and a message over `maxPayload` | `test/server.test.ts` |
48
+ | To see | Read |
49
+ | --- | --- |
50
+ | An RPC binding naming its authentication descriptor | `src/Rpc.ts` |
51
+ | The rpcs on Effect's `RpcServer`, before a protocol is chosen | `src/routes.ts` |
52
+ | Serving them over a WebSocket, speaking JSON | `src/server.ts` |
53
+ | A verifier that runs for every message, not once per connection | `src/identity/verifier.ts` |
54
+ | Native `RpcMiddleware` providing a per-request service, with a default | `src/chat/room.ts` |
55
+ | Handlers reading that service, and the rule that reads it | `src/chat/handlers.ts`, `src/chat/authorize.ts` |
56
+ | A public rpc beside protected ones | `src/chat/Chat.ts` |
57
+ | A socket client sending its token and room on each message | `src/browser.ts` |
58
+ | Who owns the socket, and how a promise page keeps it open | `src/browser.ts`, `src/page.ts` |
59
+ | A declared error and the built-in refusals handled by tag | `src/browser.ts` |
60
+ | Running that client from Node, two callers on one socket | `src/client.ts` |
61
+ | Testing handlers and authorization with no transport | `test/chat/chat.test.ts` |
62
+ | Testing rpcs, authentication and middleware in memory | `test/routes.test.ts` |
63
+ | The binding's native `RpcGroup` called by Effect's own client | `test/routes.test.ts` |
64
+ | Testing the client's handling of each error and refusal | `test/browser.test.ts` |
65
+ | Testing over a real WebSocket, and a message over `maxPayload` | `test/server.test.ts` |
66
+ | An event read from the store, its `key` the room a header names | `src/chat/events.ts` |
67
+ | A handler notifying the room it posted to | `src/chat/handlers.ts` |
68
+ | A page watching a room, its headers on the stream, across drops | `src/browser.ts`, `src/page.ts` |
69
+ | Testing a watch in process, over RPC, and over a real WebSocket | `test/chat/chat.test.ts`, `test/routes.test.ts`, `test/server.test.ts` |
@@ -1,8 +1,12 @@
1
1
  import * as ActionRpc from "@gjermundgaraba/effect-actions/ActionRpc";
2
- import { ListRooms, Messages } from "./chat/Chat.js";
2
+
3
+ import { ListRooms, MessagePosted, Messages } from "./chat/Chat.js";
3
4
  import { Login } from "./identity/Identity.js";
4
5
 
5
- // The RPC binding: one native rpc per action, named after it, the protected ones requiring
6
- // `Login`. It is plain data and imports no server code, so the server and a browser page both
7
- // import this one value. `Rpc.group` is the native `RpcGroup`, for Effect's own `RpcClient.make`.
8
- export const Rpc = ActionRpc.make([ListRooms, ...Messages], { authentication: Login });
6
+ // The RPC binding: one native rpc per action, named after it, and a stream rpc for
7
+ // `MessagePosted`, the protected ones requiring `Login`. It is plain data and imports no server
8
+ // code, so the server and a browser page both import this one value. `Rpc.group` is the native
9
+ // `RpcGroup`, for Effect's own `RpcClient.make`.
10
+ export const Rpc = ActionRpc.make([ListRooms, ...Messages, MessagePosted], {
11
+ authentication: Login,
12
+ });
@@ -1,8 +1,10 @@
1
- import { Effect, Layer } from "effect";
1
+ import * as ActionRpc from "@gjermundgaraba/effect-actions/ActionRpc";
2
+ import { Effect, Layer, Stream } from "effect";
3
+ import { Headers } from "effect/http";
2
4
  import { RpcClient, RpcSerialization } from "effect/rpc";
3
5
  import { Socket } from "effect/socket";
4
- import * as ActionRpc from "@gjermundgaraba/effect-actions/ActionRpc";
5
- import { roomHeader } from "./chat/Chat.js";
6
+
7
+ import { MessagePosted, roomHeader } from "./chat/Chat.js";
6
8
  import { Rpc } from "./Rpc.js";
7
9
 
8
10
  // Client module: what a web page imports. It holds the binding and nothing of the server, so a
@@ -15,7 +17,7 @@ import { Rpc } from "./Rpc.js";
15
17
  export const connection = (url: string) =>
16
18
  RpcClient.layerProtocolSocket().pipe(
17
19
  Layer.provide(Socket.layerWebSocket(url)),
18
- Layer.provide(RpcSerialization.layerJson),
20
+ Layer.provide(RpcSerialization.layerJson)
19
21
  );
20
22
 
21
23
  // The chat as a page uses it, on the connection provided around it.
@@ -30,24 +32,50 @@ export const openChat = Effect.gen(function* () {
30
32
  room: (name: string, token: string) => {
31
33
  // A browser WebSocket cannot set an `Authorization` header on its upgrade, so the token
32
34
  // travels on each message, and the room beside it. A refreshed token needs no new socket.
33
- const here = RpcClient.withHeaders({ authorization: `Bearer ${token}`, [roomHeader]: name });
35
+ const headers = { authorization: `Bearer ${token}`, [roomHeader]: name };
36
+ const here = RpcClient.withHeaders(headers);
34
37
 
35
38
  return {
36
- // Called again and again while the page is open, with the last id it holds. A token
37
- // may expire while the page stays open, so each refusal becomes what the page shows.
39
+ // What the room holds after the last id the page has. A token may expire while the
40
+ // page stays open, so each refusal becomes what the page shows.
38
41
  read: (after: number) =>
39
42
  client.readMessages({ after }).pipe(
40
43
  here,
41
- Effect.map(({ messages }) => ({ access: "member" as const, messages })),
44
+ Effect.map(({ messages }) => ({
45
+ access: "member" as const,
46
+ messages,
47
+ })),
42
48
  Effect.catchTags({
43
49
  Unauthenticated: () =>
44
50
  Effect.succeed({ access: "signed-out" as const, messages: [] }),
45
- Forbidden: () => Effect.succeed({ access: "not-a-member" as const, messages: [] }),
46
- }),
51
+ Forbidden: () =>
52
+ Effect.succeed({
53
+ access: "not-a-member" as const,
54
+ messages: [],
55
+ }),
56
+ })
47
57
  ),
48
58
 
49
59
  post: (text: string) => client.postMessage({ text }).pipe(here),
50
60
 
61
+ // Each message posted to the room after the last id the page holds: a page opening a
62
+ // room reads it, then watches from the last id it read, so a post between the two is
63
+ // not lost; 0 sends the room's history first. `watch` connects again after a drop and
64
+ // resumes from the last message it delivered, so the page never subscribes by hand; a
65
+ // `Gap` says some were missed, or are another run's, so the page reads the room again
66
+ // with `read(0)`. The headers go with the
67
+ // stream, as `here` puts them on a call, and so with every request it sends again. A
68
+ // refusal ends it, as when it connects again with a token that has expired.
69
+ messages: (after: number) =>
70
+ client
71
+ .watch(MessagePosted, {}, { cursor: String(after) })
72
+ .pipe(
73
+ Stream.updateService(
74
+ RpcClient.CurrentHeaders,
75
+ Headers.merge(Headers.fromInput(headers))
76
+ )
77
+ ),
78
+
51
79
  // A declared error arrives as the contract's class. A message someone else already
52
80
  // removed is gone, which is what the page asked for.
53
81
  remove: (id: number) =>
@@ -57,7 +85,7 @@ export const openChat = Effect.gen(function* () {
57
85
  Effect.catchTags({
58
86
  MessageNotFound: () => Effect.succeed("removed" as const),
59
87
  NotAuthor: () => Effect.succeed("not-yours" as const),
60
- }),
88
+ })
61
89
  ),
62
90
  };
63
91
  },
@@ -1,5 +1,7 @@
1
- import { Schema } from "effect";
2
1
  import * as Action from "@gjermundgaraba/effect-actions/Action";
2
+ import * as Event from "@gjermundgaraba/effect-actions/Event";
3
+ import { Schema } from "effect";
4
+
3
5
  import { CurrentMember } from "../identity/Identity.js";
4
6
 
5
7
  // A message's `id` counts up within its room, so a client asks for what came after the last
@@ -15,19 +17,17 @@ export type Message = typeof Message.Type;
15
17
  // A declared error is part of the contract: every client decodes it as this class. Its status
16
18
  // is for a JSON binding, should these actions be served as one too: an rpc carries no status,
17
19
  // and an error without one answers 422 over HTTP.
18
- export class MessageNotFound extends Schema.TaggedError<MessageNotFound>()(
19
- "MessageNotFound",
20
- { id: Schema.Int },
21
- { httpApiStatus: 404 },
22
- ) {}
20
+ export class MessageNotFound extends Schema.TaggedError<
21
+ MessageNotFound,
22
+ { readonly brand: unique symbol }
23
+ >()("MessageNotFound", { id: Schema.Int }, { httpApiStatus: 404 }) {}
23
24
 
24
25
  // A refusal carries what the caller sent and no more: who wrote the message is for the actions
25
26
  // that read it, under their own rule.
26
- export class NotAuthor extends Schema.TaggedError<NotAuthor>()(
27
- "NotAuthor",
28
- { id: Schema.Int },
29
- { httpApiStatus: 403 },
30
- ) {}
27
+ export class NotAuthor extends Schema.TaggedError<
28
+ NotAuthor,
29
+ { readonly brand: unique symbol }
30
+ >()("NotAuthor", { id: Schema.Int }, { httpApiStatus: 403 }) {}
31
31
 
32
32
  // Which room a call is about is not an input of any action: it travels beside the call, in
33
33
  // this header, as the token does, because `authorize` sees the action and not its input, and
@@ -44,9 +44,12 @@ export const defaultRoom = "lobby";
44
44
  // tells a signed-out caller every room's name and how many messages it holds, a room appearing
45
45
  // once someone posts: decide whether yours may.
46
46
  export const ListRooms = Action.make("listRooms", {
47
- description: "List the rooms someone has posted in, and how many messages each holds.",
47
+ description:
48
+ "List the rooms someone has posted in, and how many messages each holds.",
48
49
  success: {
49
- rooms: Schema.Array(Schema.Struct({ name: Schema.String, messages: Schema.Int })),
50
+ rooms: Schema.Array(
51
+ Schema.Struct({ name: Schema.String, messages: Schema.Int })
52
+ ),
50
53
  },
51
54
  readOnly: true,
52
55
  caller: Action.Anyone,
@@ -54,8 +57,8 @@ export const ListRooms = Action.make("listRooms", {
54
57
 
55
58
  const signedIn = { caller: CurrentMember } as const;
56
59
 
57
- // Actions are unary: nothing streams. A page keeps up by calling this again on its open
58
- // socket, passing the last id it has seen.
60
+ // Reads once: a page opening a room reads what is there, then watches `MessagePosted` below from
61
+ // the last id it read.
59
62
  export const ReadMessages = Action.make("readMessages", {
60
63
  ...signedIn,
61
64
  description: "Read the room's messages after the given id; 0 reads them all.",
@@ -67,7 +70,9 @@ export const ReadMessages = Action.make("readMessages", {
67
70
  export const PostMessage = Action.make("postMessage", {
68
71
  ...signedIn,
69
72
  description: "Post a message to the room.",
70
- input: { text: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(500)) },
73
+ input: {
74
+ text: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(500)),
75
+ },
71
76
  success: Message,
72
77
  readOnly: false,
73
78
  });
@@ -81,3 +86,12 @@ export const DeleteMessage = Action.make("deleteMessage", {
81
86
  });
82
87
 
83
88
  export const Messages = [ReadMessages, PostMessage, DeleteMessage] as const;
89
+
90
+ // Each message posted to the room, as it is posted: a page watches it on its open socket,
91
+ // about the room its header names, as the actions are. Signed-in, so each watch authenticates
92
+ // and runs `authorize` as it starts, refused for a member outside the room.
93
+ export const MessagePosted = Event.make("message.posted", {
94
+ description: "A message posted to the room.",
95
+ payload: Message,
96
+ caller: CurrentMember,
97
+ });
@@ -1,5 +1,6 @@
1
- import { Effect } from "effect";
2
1
  import * as Action from "@gjermundgaraba/effect-actions/Action";
2
+ import { Effect } from "effect";
3
+
3
4
  import { CurrentMember } from "../identity/Identity.js";
4
5
  import { CurrentRoom } from "./room.js";
5
6