@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,35 @@
1
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
2
+ import { Effect, Stream } from "effect";
3
+
4
+ import { authorize } from "../identity/authorize.js";
5
+ import { ExportIssues } from "./Issues.js";
6
+ import { IssueStore } from "./store.js";
7
+ import { exportLimit } from "./write-limit.js";
8
+
9
+ export const exports = Action.implement(
10
+ ExportIssues,
11
+ Effect.gen(function* () {
12
+ const store = yield* IssueStore;
13
+ const limited = yield* exportLimit;
14
+
15
+ // The handler returns a `Stream`. The limit is spent before the first issue, so a refused
16
+ // export fails as an action answering one value does, a 429 over HTTP; a failure after an
17
+ // issue would end the stream instead, the issues before it already sent.
18
+ return ({ status }, { progress }) =>
19
+ Stream.unwrap(
20
+ limited((member) =>
21
+ Effect.map(store.count(member.workspace, status), (total) =>
22
+ store.all(member.workspace, status).pipe(
23
+ Stream.zipWithIndex,
24
+ // `progress` never waits for the caller, dropping the oldest values a slow one has not taken:
25
+ // `exported` may skip numbers, so it says how far the export is, not what arrived.
26
+ Stream.mapEffect(([issue, index]) =>
27
+ Effect.as(progress({ exported: index + 1, total }), issue)
28
+ )
29
+ )
30
+ )
31
+ )
32
+ );
33
+ }),
34
+ { authorize }
35
+ );
@@ -1,6 +1,7 @@
1
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
1
2
  import { Effect, Layer, Schema } from "effect";
2
3
  import { McpServer } from "effect/ai";
3
- import * as Action from "@gjermundgaraba/effect-actions/Action";
4
+
4
5
  import { Issues } from "./Issues.js";
5
6
 
6
7
  const guide = `# Triage
@@ -30,7 +31,7 @@ export const features = Layer.mergeAll(
30
31
  parameters: { focus: Schema.String },
31
32
  content: ({ focus }) =>
32
33
  Effect.succeed(
33
- `Call ${tools.listIssues.name} for the open issues and ${tools.getIssue.name} for those about ${focus}. For each, propose ${tools.commentOnIssue.name} or ${tools.closeIssue.name}. Follow tracker://guides/triage.`,
34
+ `Call ${tools.listIssues.name} for the open issues and ${tools.getIssue.name} for those about ${focus}. For each, propose ${tools.commentOnIssue.name} or ${tools.closeIssue.name}. Follow tracker://guides/triage.`
34
35
  ),
35
- }),
36
+ })
36
37
  );
@@ -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 { authorize } from "../identity/authorize.js";
4
5
  import { CurrentMember } from "../identity/Identity.js";
5
6
  import { Issues } from "./Issues.js";
@@ -22,16 +23,28 @@ export const issues = Action.implement(
22
23
  const member = yield* CurrentMember;
23
24
  const listed = yield* store.list(member.workspace, status, limit);
24
25
 
25
- return { issues: listed.map(({ id, title, status }) => ({ id, title, status })) };
26
+ return {
27
+ issues: listed.map((issue) => ({
28
+ id: issue.id,
29
+ title: issue.title,
30
+ status: issue.status,
31
+ })),
32
+ };
26
33
  }),
27
34
  getIssue: ({ id }) =>
28
- Effect.flatMap(CurrentMember, (member) => store.get(member.workspace, id)),
35
+ Effect.flatMap(CurrentMember, (member) =>
36
+ store.get(member.workspace, id)
37
+ ),
29
38
  // One function wraps every limited handler, and gives it the member it counted.
30
- createIssue: (draft) => limited((member) => store.create(member.workspace, draft)),
39
+ createIssue: (draft) =>
40
+ limited((member) => store.create(member.workspace, draft)),
31
41
  commentOnIssue: ({ id, text }) =>
32
- limited((member) => store.comment(member.workspace, id, { author: member.id, text })),
33
- closeIssue: ({ id }) => limited((member) => store.close(member.workspace, id)),
42
+ limited((member) =>
43
+ store.comment(member.workspace, id, { author: member.id, text })
44
+ ),
45
+ closeIssue: ({ id }) =>
46
+ limited((member) => store.close(member.workspace, id)),
34
47
  };
35
48
  }),
36
- { authorize },
49
+ { authorize }
37
50
  );
@@ -1,9 +1,11 @@
1
- import { Context, Effect, Layer } from "effect";
2
- import { type Comment, type Issue, IssueNotFound, type Status } from "./Issues.js";
1
+ import { Context, Effect, Layer, PubSub, Stream } from "effect";
2
+
3
+ import { IssueNotFound } from "./Issues.js";
4
+ import type { Comment, Issue, IssueChange, Status } from "./Issues.js";
3
5
 
4
6
  /**
5
- * Where issues live, each in one workspace. Every method takes the workspace, so tenant
6
- * isolation is the data access's: no surface and no handler can skip it, and an issue of
7
+ * Where issues live, each in one workspace. Every method but `changes` takes the workspace, so
8
+ * tenant isolation is the data access's: no surface and no handler can skip it, and an issue of
7
9
  * another workspace is simply not found.
8
10
  */
9
11
  export class IssueStore extends Context.Service<
@@ -12,85 +14,172 @@ export class IssueStore extends Context.Service<
12
14
  readonly list: (
13
15
  workspace: string,
14
16
  status: typeof Status.Type | undefined,
15
- limit: number,
16
- ) => Effect.Effect<ReadonlyArray<Issue>>;
17
- readonly get: (workspace: string, id: string) => Effect.Effect<Issue, IssueNotFound>;
17
+ limit: number
18
+ ) => Effect.Effect<readonly Issue[]>;
19
+ /** Every issue, oldest first. */
20
+ readonly all: (
21
+ workspace: string,
22
+ status: typeof Status.Type | undefined
23
+ ) => Stream.Stream<Issue>;
24
+ readonly count: (
25
+ workspace: string,
26
+ status: typeof Status.Type | undefined
27
+ ) => Effect.Effect<number>;
28
+ readonly get: (
29
+ workspace: string,
30
+ id: string
31
+ ) => Effect.Effect<Issue, IssueNotFound>;
18
32
  readonly create: (
19
33
  workspace: string,
20
- draft: Pick<Issue, "title" | "body" | "assignee">,
34
+ draft: Pick<Issue, "title" | "body" | "assignee">
21
35
  ) => Effect.Effect<Issue>;
22
36
  readonly comment: (
23
37
  workspace: string,
24
38
  id: string,
25
- comment: typeof Comment.Type,
39
+ comment: typeof Comment.Type
26
40
  ) => Effect.Effect<Issue, IssueNotFound>;
27
- readonly close: (workspace: string, id: string) => Effect.Effect<Issue, IssueNotFound>;
41
+ readonly close: (
42
+ workspace: string,
43
+ id: string
44
+ ) => Effect.Effect<Issue, IssueNotFound>;
45
+ /**
46
+ * Every change from now on, in the order the store makes them, of every workspace: whatever
47
+ * reads it filters by workspace itself, as `issueEvents`' `match` does.
48
+ */
49
+ readonly changes: Stream.Stream<typeof IssueChange.Type>;
28
50
  }
29
51
  >()("tracker/IssueStore") {
30
52
  // DEMO ONLY: memory, lost when the process stops. A real store is another Layer of this
31
53
  // service, chosen by the entrypoint.
32
- static readonly layerMemory = Layer.sync(IssueStore, () => {
33
- const issues = new Map<string, Array<Issue>>();
54
+ static readonly layerMemory = Layer.effect(
55
+ IssueStore,
56
+ Effect.gen(function* () {
57
+ const issues = new Map<string, Issue[]>();
58
+ const changes = yield* PubSub.unbounded<typeof IssueChange.Type>();
34
59
 
35
- const of = (workspace: string) => issues.get(workspace) ?? [];
60
+ // Published in the step of the write, after it, so a subscriber never sees a change the
61
+ // store did not make, and sees them in the order they were made. A database publishes
62
+ // from its own change feed, or an outbox written in the same transaction.
63
+ const publish = (
64
+ workspace: string,
65
+ issue: Issue,
66
+ change: (typeof IssueChange.Type)["change"],
67
+ comment?: typeof Comment.Type
68
+ ) =>
69
+ PubSub.publishUnsafe(changes, {
70
+ workspace,
71
+ id: issue.id,
72
+ title: issue.title,
73
+ status: issue.status,
74
+ change,
75
+ ...(comment === undefined ? {} : { comment }),
76
+ });
36
77
 
37
- const get = Effect.fn("IssueStore.get")(function* (workspace: string, id: string) {
38
- const issue = of(workspace).find((candidate) => candidate.id === id);
78
+ const of = (workspace: string) => issues.get(workspace) ?? [];
39
79
 
40
- if (issue === undefined) return yield* new IssueNotFound({ id });
80
+ const having = (
81
+ workspace: string,
82
+ status: typeof Status.Type | undefined
83
+ ) =>
84
+ of(workspace).filter(
85
+ (issue) => status === undefined || issue.status === status
86
+ );
41
87
 
42
- return issue;
43
- });
88
+ const get = Effect.fn("IssueStore.get")(function* (
89
+ workspace: string,
90
+ id: string
91
+ ) {
92
+ const issue = of(workspace).find((candidate) => candidate.id === id);
44
93
 
45
- // No `yield*` between the read and the write: a fiber may be set aside at one, and two
46
- // changes that each read the issue before either wrote would keep only the second. A
47
- // database does this in one statement or a transaction.
48
- const change = Effect.fn("IssueStore.change")(function* (
49
- workspace: string,
50
- id: string,
51
- to: (issue: Issue) => Issue,
52
- ) {
53
- const issue = of(workspace).find((candidate) => candidate.id === id);
54
-
55
- if (issue === undefined) return yield* new IssueNotFound({ id });
56
-
57
- const changed = to(issue);
58
-
59
- issues.set(
60
- workspace,
61
- of(workspace).map((current) => (current === issue ? changed : current)),
62
- );
63
-
64
- return changed;
65
- });
66
-
67
- return IssueStore.of({
68
- list: (workspace, status, limit) =>
69
- Effect.sync(() =>
70
- of(workspace)
71
- .filter((issue) => status === undefined || issue.status === status)
72
- .toReversed()
73
- .slice(0, limit),
74
- ),
75
- get,
76
- create: (workspace, draft) =>
77
- Effect.sync(() => {
78
- const existing = of(workspace);
79
-
80
- const issue: Issue = {
81
- ...draft,
82
- id: `${workspace.toUpperCase()}-${existing.length + 1}`,
83
- status: "open",
84
- comments: [],
85
- };
86
-
87
- issues.set(workspace, [...existing, issue]);
94
+ if (issue === undefined) {
95
+ return yield* new IssueNotFound({ id });
96
+ }
97
+
98
+ return issue;
99
+ });
88
100
 
101
+ // No `yield*` between the read and the write: a fiber may be set aside at one, and two
102
+ // changes that each read the issue before either wrote would keep only the second. A
103
+ // database does this in one statement or a transaction.
104
+ // A change `to` answers with the same issue for is no change, and is not published.
105
+ const change = Effect.fn("IssueStore.change")(function* (
106
+ workspace: string,
107
+ id: string,
108
+ to: (issue: Issue) => Issue,
109
+ kind: (typeof IssueChange.Type)["change"],
110
+ comment?: typeof Comment.Type
111
+ ) {
112
+ const issue = of(workspace).find((candidate) => candidate.id === id);
113
+
114
+ if (issue === undefined) {
115
+ return yield* new IssueNotFound({ id });
116
+ }
117
+
118
+ const changed = to(issue);
119
+
120
+ if (changed === issue) {
89
121
  return issue;
90
- }),
91
- comment: (workspace, id, comment) =>
92
- change(workspace, id, (issue) => ({ ...issue, comments: [...issue.comments, comment] })),
93
- close: (workspace, id) => change(workspace, id, (issue) => ({ ...issue, status: "closed" })),
94
- });
95
- });
122
+ }
123
+
124
+ issues.set(
125
+ workspace,
126
+ of(workspace).map((current) =>
127
+ current === issue ? changed : current
128
+ )
129
+ );
130
+
131
+ publish(workspace, changed, kind, comment);
132
+
133
+ return changed;
134
+ });
135
+
136
+ return IssueStore.of({
137
+ list: (workspace, status, limit) =>
138
+ Effect.sync(() =>
139
+ having(workspace, status).toReversed().slice(0, limit)
140
+ ),
141
+ // A database reads it in pages, by id, so an export never holds the workspace whole.
142
+ all: (workspace, status) =>
143
+ Stream.suspend(() => Stream.fromIterable(having(workspace, status))),
144
+ count: (workspace, status) =>
145
+ Effect.sync(() => having(workspace, status).length),
146
+ get,
147
+ create: (workspace, draft) =>
148
+ Effect.sync(() => {
149
+ const existing = of(workspace);
150
+
151
+ const issue: Issue = {
152
+ ...draft,
153
+ id: `${workspace.toUpperCase()}-${existing.length + 1}`,
154
+ status: "open",
155
+ comments: [],
156
+ };
157
+
158
+ issues.set(workspace, [...existing, issue]);
159
+ publish(workspace, issue, "filed");
160
+
161
+ return issue;
162
+ }),
163
+ comment: (workspace, id, comment) =>
164
+ change(
165
+ workspace,
166
+ id,
167
+ (issue) => ({ ...issue, comments: [...issue.comments, comment] }),
168
+ "commented",
169
+ comment
170
+ ),
171
+ close: (workspace, id) =>
172
+ change(
173
+ workspace,
174
+ id,
175
+ (issue) =>
176
+ issue.status === "closed"
177
+ ? issue
178
+ : { ...issue, status: "closed" },
179
+ "closed"
180
+ ),
181
+ changes: Stream.fromPubSub(changes),
182
+ });
183
+ })
184
+ );
96
185
  }
@@ -1,28 +1,25 @@
1
1
  import { Duration, Effect } from "effect";
2
2
  import { RateLimiter } from "effect/persistence";
3
- import { CurrentMember, type Member } from "../identity/Identity.js";
3
+
4
+ import { CurrentMember } from "../identity/Identity.js";
5
+ import type { Member } from "../identity/Identity.js";
4
6
  import { RateLimited } from "./Issues.js";
5
7
 
6
- // The write limit: ten writes a minute per member. It is the handlers', not a surface's, so it
7
- // holds wherever they run: HTTP, MCP, RPC, stdio and the assistant's tools all count against
8
- // the one limiter. A builder yields this once and wraps each limited handler in the function
9
- // it gets, which fails with an error those actions' contracts declare; every surface sends it
10
- // as any declared error. The limiter is a service, so its counts are shared wherever it is
11
- // provided: memory.ts provides Effect's own, `RateLimiter.layer` over a store. `consume` counts
12
- // in a fixed window unless given `algorithm: "token-bucket"`: ten writes in each minute, so
13
- // twenty may pass across the boundary between two. A strict limit, ten in any sixty seconds,
14
- // is a service of your own of the same shape, yielded here, never part of the issue store.
15
- export const writeLimit = Effect.gen(function* () {
8
+ const limitedBy = Effect.gen(function* () {
16
9
  const limiter = yield* RateLimiter.RateLimiter;
17
10
 
18
- return <A, E, R>(write: (member: Member) => Effect.Effect<A, E, R>) =>
11
+ return <A, E, R>(
12
+ name: string,
13
+ limit: number,
14
+ run: (member: Member) => Effect.Effect<A, E, R>
15
+ ) =>
19
16
  Effect.gen(function* () {
20
17
  const member = yield* CurrentMember;
21
18
 
22
19
  yield* limiter
23
20
  .consume({
24
- key: JSON.stringify(["writes", member.workspace, member.id]),
25
- limit: 10,
21
+ key: JSON.stringify([name, member.workspace, member.id]),
22
+ limit,
26
23
  window: "1 minute",
27
24
  onExceeded: "fail",
28
25
  })
@@ -31,12 +28,41 @@ export const writeLimit = Effect.gen(function* () {
31
28
  "RateLimiterError",
32
29
  "RateLimitExceeded",
33
30
  ({ retryAfter }) =>
34
- Effect.fail(new RateLimited({ retryAfterSeconds: Duration.toSeconds(retryAfter) })),
31
+ Effect.fail(
32
+ new RateLimited({
33
+ retryAfterSeconds: Duration.toSeconds(retryAfter),
34
+ })
35
+ ),
35
36
  // Any other reason is the limiter's store failing: a defect, not the caller's.
36
- Effect.die,
37
- ),
37
+ Effect.die
38
+ )
38
39
  );
39
40
 
40
- return yield* write(member);
41
+ return yield* run(member);
41
42
  });
42
43
  });
44
+
45
+ // The write limit: ten writes a minute per member. It is the handlers', not a surface's, so it
46
+ // holds wherever they run: HTTP, MCP, RPC, stdio and the assistant's tools all count against
47
+ // the one limiter. A builder yields this once and wraps each limited handler in the function
48
+ // it gets, which fails with an error those actions' contracts declare; every surface sends it
49
+ // as any declared error. The limiter is a service, so its counts are shared wherever it is
50
+ // provided: memory.ts provides Effect's own, `RateLimiter.layer` over a store. `consume` counts
51
+ // in a fixed window unless given `algorithm: "token-bucket"`: ten writes in each minute, so
52
+ // twenty may pass across the boundary between two. A strict limit, ten in any sixty seconds,
53
+ // is a service of your own of the same shape, yielded here, never part of the issue store.
54
+ export const writeLimit = Effect.gen(function* () {
55
+ const limited = yield* limitedBy;
56
+
57
+ return <A, E, R>(write: (member: Member) => Effect.Effect<A, E, R>) =>
58
+ limited("writes", 10, write);
59
+ });
60
+
61
+ // The export limit: two exports a minute per member, on the same limiter under a key of its
62
+ // own, so exporting spends no write.
63
+ export const exportLimit = Effect.gen(function* () {
64
+ const limited = yield* limitedBy;
65
+
66
+ return <A, E, R>(read: (member: Member) => Effect.Effect<A, E, R>) =>
67
+ limited("exports", 2, read);
68
+ });
@@ -1,5 +1,7 @@
1
+ import * as Event from "@gjermundgaraba/effect-actions/Event";
1
2
  import { Layer } from "effect";
2
3
  import { RateLimiter } from "effect/persistence";
4
+
3
5
  import { Grants } from "./admin/grants.js";
4
6
  import { Conversations } from "./assistant/conversations.js";
5
7
  import { demoModel } from "./assistant/demo-model.js";
@@ -13,10 +15,14 @@ import { WorkspaceRegistry } from "./workspaces/registry.js";
13
15
  // test replaces the one it is about.
14
16
 
15
17
  /** Both stores. The attachment store is built on the issue store: `provideMerge` keeps it. */
16
- export const stores = AttachmentStore.layerMemory.pipe(Layer.provideMerge(IssueStore.layerMemory));
18
+ export const stores = AttachmentStore.layerMemory.pipe(
19
+ Layer.provideMerge(IssueStore.layerMemory)
20
+ );
17
21
 
18
22
  /** Effect's own limiter, counting in memory. `RateLimiter.layerStoreRedis` counts across processes. */
19
- export const limiter = RateLimiter.layer.pipe(Layer.provide(RateLimiter.layerStoreMemory));
23
+ export const limiter = RateLimiter.layer.pipe(
24
+ Layer.provide(RateLimiter.layerStoreMemory)
25
+ );
20
26
 
21
27
  /** Everything the server needs, for both ports. A local entrypoint provides only what it serves. */
22
28
  export const memory = Layer.mergeAll(
@@ -27,4 +33,7 @@ export const memory = Layer.mergeAll(
27
33
  Grants.layerDemo,
28
34
  Conversations.layerMemory,
29
35
  demoModel,
36
+ // The issue changes' log, kept in this process for the subscribers its sockets and streams
37
+ // hold.
38
+ Event.layerMemory
30
39
  );
@@ -1,8 +1,10 @@
1
1
  import { NodeRuntime, NodeServices } from "@effect/platform-node";
2
+ import * as ActionCli from "@gjermundgaraba/effect-actions/ActionCli";
2
3
  import { Effect, Layer } from "effect";
3
4
  import { Command } from "effect/cli";
4
- import * as ActionCli from "@gjermundgaraba/effect-actions/ActionCli";
5
- import { Operator, type TrustedOperator } from "./admin/Admin.js";
5
+
6
+ import { Operator } from "./admin/Admin.js";
7
+ import type { TrustedOperator } from "./admin/Admin.js";
6
8
  import { Grants } from "./admin/grants.js";
7
9
  import { admin } from "./admin/handlers.js";
8
10
  import { WorkspaceRegistry } from "./workspaces/registry.js";
@@ -19,7 +21,10 @@ const ops = ActionCli.make(admin, {
19
21
  listWorkspaces: {
20
22
  render: ({ workspaces }) =>
21
23
  workspaces
22
- .map(({ slug, name, suspended }) => `${slug} ${name}${suspended ? " (suspended)" : ""}`)
24
+ .map(
25
+ ({ slug, name, suspended }) =>
26
+ `${slug} ${name}${suspended ? " (suspended)" : ""}`
27
+ )
23
28
  .join("\n"),
24
29
  },
25
30
  suspendWorkspace: { positional: ["workspace"] },
@@ -29,11 +34,11 @@ const ops = ActionCli.make(admin, {
29
34
  // Only what these actions are built from. DEMO ONLY: memory, so this console sees its own
30
35
  // process, not the server's data. A real one provides the database Layers the server does.
31
36
  Command.provide(Layer.merge(WorkspaceRegistry.layerMemory, Grants.layerDemo)),
32
- Command.provideSync(Operator, operator),
37
+ Command.provideSync(Operator, operator)
33
38
  );
34
39
 
35
40
  Command.run(ops, { version: "1.0.0" }).pipe(
36
41
  Effect.provide(NodeServices.layer),
37
42
  ActionCli.logToStderr,
38
- NodeRuntime.runMain,
43
+ NodeRuntime.runMain()
39
44
  );
@@ -1,5 +1,6 @@
1
1
  import { Effect, Layer, Option } from "effect";
2
2
  import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http";
3
+
3
4
  import { allowedOrigins, publicUrl } from "./config.js";
4
5
 
5
6
  const loopback = ["localhost", "127.0.0.1"];
@@ -13,32 +14,39 @@ const loopback = ["localhost", "127.0.0.1"];
13
14
  // authentication, too late.
14
15
  export const requestPolicy = HttpRouter.middleware(
15
16
  Effect.gen(function* () {
16
- const hosts = [...loopback, new URL(yield* publicUrl).hostname];
17
+ const hosts = new Set([...loopback, new URL(yield* publicUrl).hostname]);
17
18
  const origins = yield* allowedOrigins;
18
19
 
19
20
  return (httpEffect) =>
20
21
  Effect.gen(function* () {
21
22
  const request = yield* HttpServerRequest.HttpServerRequest;
22
- const url = HttpServerRequest.toURL(request.modify({ url: request.originalUrl }));
23
23
 
24
- if (Option.isNone(url) || !hosts.includes(url.value.hostname)) {
24
+ const url = HttpServerRequest.toURL(
25
+ request.modify({ url: request.originalUrl })
26
+ );
27
+
28
+ if (Option.isNone(url) || !hosts.has(url.value.hostname)) {
25
29
  return HttpServerResponse.text("Host not allowed", { status: 403 });
26
30
  }
27
31
 
28
- const origin = request.headers["origin"];
32
+ const { origin } = request.headers;
29
33
 
30
34
  // The request's own origin is that of a page served from this port directly. Behind a
31
35
  // proxy ending TLS it is an `http:` one from Effect 4.0.6, whose `originalUrl` takes the
32
36
  // scheme of the connection, whatever `X-Forwarded-Proto` says, so the public URL's
33
37
  // origin is among `origins`.
34
- if (origin !== undefined && origin !== url.value.origin && !origins.includes(origin)) {
38
+ if (
39
+ origin !== undefined &&
40
+ origin !== url.value.origin &&
41
+ !origins.includes(origin)
42
+ ) {
35
43
  return HttpServerResponse.text("Origin not allowed", { status: 403 });
36
44
  }
37
45
 
38
46
  return yield* httpEffect;
39
47
  });
40
48
  }),
41
- { global: true },
49
+ { global: true }
42
50
  );
43
51
 
44
52
  // CORS for the one cross-origin caller, a browser MCP client. `HttpRouter.cors` is global to
@@ -61,7 +69,11 @@ export const cors = Layer.unwrap(
61
69
  "MCP-Name",
62
70
  "Mcp-Session-Id",
63
71
  ],
64
- exposedHeaders: ["WWW-Authenticate", "MCP-Protocol-Version", "Mcp-Session-Id"],
65
- }),
66
- ),
72
+ exposedHeaders: [
73
+ "WWW-Authenticate",
74
+ "MCP-Protocol-Version",
75
+ "Mcp-Session-Id",
76
+ ],
77
+ })
78
+ )
67
79
  );