@gjermundgaraba/effect-actions 0.11.0 → 0.13.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 (215) hide show
  1. package/README.md +102 -41
  2. package/dist/Action.d.mts +31 -117
  3. package/dist/Action.mjs +14 -53
  4. package/dist/ActionCli.d.mts +60 -28
  5. package/dist/ActionCli.mjs +54 -48
  6. package/dist/ActionHttp.d.mts +18 -67
  7. package/dist/ActionHttp.mjs +20 -70
  8. package/dist/ActionMcp.d.mts +23 -29
  9. package/dist/ActionMcp.mjs +56 -114
  10. package/dist/ActionRpc.d.mts +147 -0
  11. package/dist/ActionRpc.mjs +113 -0
  12. package/dist/ActionToolkit.d.mts +28 -9
  13. package/dist/ActionToolkit.mjs +31 -4
  14. package/dist/Authentication.d.mts +15 -15
  15. package/dist/Authentication.mjs +42 -96
  16. package/dist/Testing.d.mts +30 -23
  17. package/dist/Testing.mjs +27 -44
  18. package/dist/{internal/authentication.d.mts → authentication/provider.d.mts} +41 -9
  19. package/dist/authentication/provider.mjs +12 -0
  20. package/dist/authentication/refusal.mjs +36 -0
  21. package/dist/{internal/cli.d.mts → cli/commands.d.mts} +16 -12
  22. package/dist/cli/commands.mjs +302 -0
  23. package/dist/{internal → contract}/call.d.mts +2 -2
  24. package/dist/contract/call.mjs +10 -0
  25. package/dist/{internal → contract}/errors.d.mts +13 -10
  26. package/dist/{internal → contract}/errors.mjs +9 -14
  27. package/dist/{internal → contract}/implementation.d.mts +37 -21
  28. package/dist/{internal → contract}/implementation.mjs +18 -49
  29. package/dist/contract/media.d.mts +13 -0
  30. package/dist/contract/media.mjs +18 -0
  31. package/dist/contract/rules.d.mts +25 -0
  32. package/dist/{internal/actions.mjs → contract/rules.mjs} +6 -42
  33. package/dist/{internal → http}/client.d.mts +7 -9
  34. package/dist/http/client.mjs +17 -0
  35. package/dist/http/declared.mjs +20 -0
  36. package/dist/http/schema-errors.mjs +9 -0
  37. package/dist/mcp/protocol.d.mts +18 -0
  38. package/dist/{internal/mcp.mjs → mcp/protocol.mjs} +29 -62
  39. package/dist/stdio/console.mjs +64 -0
  40. package/dist/{internal → testing}/memory.d.mts +1 -1
  41. package/dist/{internal → testing}/memory.mjs +5 -17
  42. package/dist/toolkit/tools.mjs +29 -0
  43. package/docs/Action-failures.md +47 -0
  44. package/docs/Action-in-process.md +93 -0
  45. package/docs/Action.md +211 -426
  46. package/docs/ActionCli-failures.md +104 -0
  47. package/docs/ActionCli.md +208 -293
  48. package/docs/ActionHttp-failures.md +83 -0
  49. package/docs/ActionHttp.md +205 -282
  50. package/docs/ActionMcp-failures.md +31 -0
  51. package/docs/ActionMcp.md +222 -271
  52. package/docs/ActionRpc-failures.md +21 -0
  53. package/docs/ActionRpc.md +179 -0
  54. package/docs/ActionToolkit-failures.md +15 -0
  55. package/docs/ActionToolkit.md +103 -119
  56. package/docs/Authentication-advanced.md +139 -0
  57. package/docs/Authentication-failures.md +99 -0
  58. package/docs/Authentication.md +143 -392
  59. package/docs/CONTEXT.md +40 -24
  60. package/docs/README.md +75 -35
  61. package/docs/Testing-failures.md +21 -0
  62. package/docs/Testing.md +193 -151
  63. package/docs/guarantees.md +35 -96
  64. package/docs/setup-failures.md +37 -0
  65. package/docs/setup.md +90 -92
  66. package/examples/README.md +68 -0
  67. package/examples/assistant/README.md +92 -0
  68. package/examples/assistant/src/agent.ts +92 -0
  69. package/examples/assistant/src/cli.ts +34 -0
  70. package/examples/assistant/src/commands.ts +25 -0
  71. package/examples/assistant/src/identity/Identity.ts +33 -0
  72. package/examples/assistant/src/identity/authorize.ts +24 -0
  73. package/examples/assistant/src/identity/handlers.ts +10 -0
  74. package/examples/assistant/src/identity/members.ts +9 -0
  75. package/examples/assistant/src/identity/permissions.ts +23 -0
  76. package/examples/assistant/src/scripted.ts +23 -0
  77. package/examples/assistant/src/tasks/Tasks.ts +63 -0
  78. package/examples/assistant/src/tasks/handlers.ts +25 -0
  79. package/examples/assistant/src/tasks/store.ts +96 -0
  80. package/examples/assistant/src/toolkit.ts +30 -0
  81. package/examples/assistant/test/commands.test.ts +57 -0
  82. package/examples/assistant/test/identity/identity.test.ts +43 -0
  83. package/examples/assistant/test/tasks/store.test.ts +48 -0
  84. package/examples/assistant/test/tasks/tasks.test.ts +75 -0
  85. package/examples/assistant/test/toolkit.test.ts +121 -0
  86. package/examples/kitchen-sink/README.md +318 -0
  87. package/examples/kitchen-sink/src/Actions.ts +19 -0
  88. package/examples/kitchen-sink/src/Api.ts +24 -0
  89. package/examples/kitchen-sink/src/Rpc.ts +12 -0
  90. package/examples/kitchen-sink/src/admin/Admin.ts +59 -0
  91. package/examples/kitchen-sink/src/admin/authenticate.ts +18 -0
  92. package/examples/kitchen-sink/src/admin/authorize.ts +27 -0
  93. package/examples/kitchen-sink/src/admin/grants.ts +22 -0
  94. package/examples/kitchen-sink/src/admin/handlers.ts +21 -0
  95. package/examples/kitchen-sink/src/admin/keys.ts +8 -0
  96. package/examples/kitchen-sink/src/agent.ts +47 -0
  97. package/examples/kitchen-sink/src/assistant/Assistant.ts +82 -0
  98. package/examples/kitchen-sink/src/assistant/conversations.ts +71 -0
  99. package/examples/kitchen-sink/src/assistant/demo-model.ts +33 -0
  100. package/examples/kitchen-sink/src/assistant/handlers.ts +126 -0
  101. package/examples/kitchen-sink/src/attachments/Attachments.ts +48 -0
  102. package/examples/kitchen-sink/src/attachments/download.ts +49 -0
  103. package/examples/kitchen-sink/src/attachments/handlers.ts +38 -0
  104. package/examples/kitchen-sink/src/attachments/store.ts +58 -0
  105. package/examples/kitchen-sink/src/browser.ts +33 -0
  106. package/examples/kitchen-sink/src/cli.ts +85 -0
  107. package/examples/kitchen-sink/src/config.ts +18 -0
  108. package/examples/kitchen-sink/src/identity/Identity.ts +61 -0
  109. package/examples/kitchen-sink/src/identity/authenticate.ts +87 -0
  110. package/examples/kitchen-sink/src/identity/authorize.ts +16 -0
  111. package/examples/kitchen-sink/src/identity/directory.ts +52 -0
  112. package/examples/kitchen-sink/src/identity/handlers.ts +9 -0
  113. package/examples/kitchen-sink/src/identity/members.ts +13 -0
  114. package/examples/kitchen-sink/src/identity/quota.ts +62 -0
  115. package/examples/kitchen-sink/src/issues/Issues.ts +111 -0
  116. package/examples/kitchen-sink/src/issues/assistant.ts +23 -0
  117. package/examples/kitchen-sink/src/issues/guide.ts +37 -0
  118. package/examples/kitchen-sink/src/issues/handlers.ts +37 -0
  119. package/examples/kitchen-sink/src/issues/store.ts +96 -0
  120. package/examples/kitchen-sink/src/issues/write-limit.ts +42 -0
  121. package/examples/kitchen-sink/src/memory.ts +30 -0
  122. package/examples/kitchen-sink/src/ops.ts +42 -0
  123. package/examples/kitchen-sink/src/request-policy.ts +63 -0
  124. package/examples/kitchen-sink/src/routes.ts +120 -0
  125. package/examples/kitchen-sink/src/sdk.ts +46 -0
  126. package/examples/kitchen-sink/src/seed.ts +65 -0
  127. package/examples/kitchen-sink/src/server.ts +62 -0
  128. package/examples/kitchen-sink/src/stdio.ts +34 -0
  129. package/examples/kitchen-sink/src/workspaces/Workspaces.ts +28 -0
  130. package/examples/kitchen-sink/src/workspaces/handlers.ts +23 -0
  131. package/examples/kitchen-sink/src/workspaces/registry.ts +46 -0
  132. package/examples/kitchen-sink/src/workspaces/tenant.ts +17 -0
  133. package/examples/kitchen-sink/test/admin/admin.test.ts +67 -0
  134. package/examples/kitchen-sink/test/assistant/assistant.test.ts +435 -0
  135. package/examples/kitchen-sink/test/attachments/attachments.test.ts +161 -0
  136. package/examples/kitchen-sink/test/identity/identity.test.ts +137 -0
  137. package/examples/kitchen-sink/test/identity/session.test.ts +110 -0
  138. package/examples/kitchen-sink/test/issues/assistant.test.ts +75 -0
  139. package/examples/kitchen-sink/test/issues/issues.test.ts +135 -0
  140. package/examples/kitchen-sink/test/request-policy.test.ts +125 -0
  141. package/examples/kitchen-sink/test/routes.test.ts +123 -0
  142. package/examples/kitchen-sink/test/support/scripted.ts +40 -0
  143. package/examples/live-rpc/README.md +107 -0
  144. package/examples/live-rpc/src/Rpc.ts +8 -0
  145. package/examples/live-rpc/src/browser.ts +98 -0
  146. package/examples/live-rpc/src/chat/Chat.ts +77 -0
  147. package/examples/live-rpc/src/chat/authorize.ts +16 -0
  148. package/examples/live-rpc/src/chat/handlers.ts +50 -0
  149. package/examples/live-rpc/src/chat/room.ts +23 -0
  150. package/examples/live-rpc/src/chat/store.ts +54 -0
  151. package/examples/live-rpc/src/client.ts +37 -0
  152. package/examples/live-rpc/src/identity/Identity.ts +19 -0
  153. package/examples/live-rpc/src/identity/members.ts +8 -0
  154. package/examples/live-rpc/src/identity/verifier.ts +23 -0
  155. package/examples/live-rpc/src/routes.ts +22 -0
  156. package/examples/live-rpc/src/server.ts +43 -0
  157. package/examples/live-rpc/test/browser.test.ts +105 -0
  158. package/examples/live-rpc/test/chat/chat.test.ts +69 -0
  159. package/examples/live-rpc/test/routes.test.ts +99 -0
  160. package/examples/live-rpc/test/server.test.ts +75 -0
  161. package/examples/mcp-tools/README.md +141 -0
  162. package/examples/mcp-tools/src/instructions.ts +12 -0
  163. package/examples/mcp-tools/src/printers/Printers.ts +121 -0
  164. package/examples/mcp-tools/src/printers/fake.ts +115 -0
  165. package/examples/mcp-tools/src/printers/farm.ts +136 -0
  166. package/examples/mcp-tools/src/printers/handlers.ts +29 -0
  167. package/examples/mcp-tools/src/routes.ts +52 -0
  168. package/examples/mcp-tools/src/server.ts +31 -0
  169. package/examples/mcp-tools/src/stdio.ts +26 -0
  170. package/examples/mcp-tools/test/printers/printers.test.ts +189 -0
  171. package/examples/mcp-tools/test/routes.test.ts +202 -0
  172. package/examples/notes-service/README.md +111 -0
  173. package/examples/notes-service/src/Api.ts +13 -0
  174. package/examples/notes-service/src/browser.ts +41 -0
  175. package/examples/notes-service/src/cli.ts +22 -0
  176. package/examples/notes-service/src/commands.ts +137 -0
  177. package/examples/notes-service/src/identity/Identity.ts +31 -0
  178. package/examples/notes-service/src/identity/authorize.ts +18 -0
  179. package/examples/notes-service/src/identity/handlers.ts +10 -0
  180. package/examples/notes-service/src/identity/users.ts +9 -0
  181. package/examples/notes-service/src/identity/verifier.ts +36 -0
  182. package/examples/notes-service/src/notes/Notes.ts +119 -0
  183. package/examples/notes-service/src/notes/handlers.ts +39 -0
  184. package/examples/notes-service/src/notes/store.ts +84 -0
  185. package/examples/notes-service/src/routes.ts +45 -0
  186. package/examples/notes-service/src/server.ts +26 -0
  187. package/examples/notes-service/src/status/Status.ts +10 -0
  188. package/examples/notes-service/src/status/handlers.ts +14 -0
  189. package/examples/notes-service/src/stdio.ts +25 -0
  190. package/examples/notes-service/test/commands.test.ts +288 -0
  191. package/examples/notes-service/test/notes/notes.test.ts +103 -0
  192. package/examples/notes-service/test/routes.test.ts +84 -0
  193. package/examples/quickstart/README.md +31 -0
  194. package/examples/quickstart/src/Api.ts +5 -0
  195. package/examples/quickstart/src/client.ts +13 -0
  196. package/examples/quickstart/src/greeting/Greeting.ts +12 -0
  197. package/examples/quickstart/src/greeting/handlers.ts +6 -0
  198. package/examples/quickstart/src/routes.ts +11 -0
  199. package/examples/quickstart/src/server.ts +16 -0
  200. package/examples/quickstart/test/greeting/greeting.test.ts +18 -0
  201. package/package.json +27 -9
  202. package/dist/internal/actions.d.mts +0 -9
  203. package/dist/internal/authentication.mjs +0 -11
  204. package/dist/internal/call.mjs +0 -12
  205. package/dist/internal/cli.mjs +0 -328
  206. package/dist/internal/client.mjs +0 -25
  207. package/dist/internal/console.mjs +0 -77
  208. package/dist/internal/declared.d.mts +0 -13
  209. package/dist/internal/declared.mjs +0 -34
  210. package/dist/internal/mcp.d.mts +0 -14
  211. package/dist/internal/media.d.mts +0 -8
  212. package/dist/internal/media.mjs +0 -26
  213. package/dist/internal/refusal.mjs +0 -67
  214. package/dist/internal/schema-errors.mjs +0 -20
  215. package/dist/internal/tools.mjs +0 -44
@@ -0,0 +1,288 @@
1
+ import { NodeServices } from "@effect/platform-node";
2
+ import { describe, expect, it } from "@effect/vitest";
3
+ import {
4
+ Arbitrary,
5
+ ConfigProvider,
6
+ Effect,
7
+ FileSystem,
8
+ Layer,
9
+ Schema,
10
+ Stdio,
11
+ Stream,
12
+ } from "effect";
13
+ import { CliError, Command } from "effect/cli";
14
+ import { HttpClient, HttpClientRequest } from "effect/http";
15
+ import { TestConsole } from "effect/testing";
16
+ import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
17
+ import * as Testing from "@gjermundgaraba/effect-actions/Testing";
18
+ import { Http } from "../src/Api.js";
19
+ import { commands, connection } from "../src/commands.js";
20
+ import { ImportNotes, Note, NoteNotFound } from "../src/notes/Notes.js";
21
+ import { NoteStore } from "../src/notes/store.js";
22
+ import { routes } from "../src/routes.js";
23
+
24
+ // What the commands add to the routes: their syntax and what they print. They call the real
25
+ // routes in memory, as a client with a token does, where cli.ts connects them to a server.
26
+ const served = Testing.layer(routes).pipe(Layer.provide(NoteStore.layerMemory));
27
+
28
+ // Effect's own runner, given arguments instead of the process's. The console of `it.effect`
29
+ // keeps every line printed, so a run returns the lines it added.
30
+ const run = (token: string, args: ReadonlyArray<string>, stdin = "") =>
31
+ Effect.gen(function* () {
32
+ const before = (yield* TestConsole.logLines).length;
33
+
34
+ yield* Command.runWith(commands, { version: "0" })(args).pipe(
35
+ Effect.updateService(
36
+ HttpClient.HttpClient,
37
+ HttpClient.mapRequest(HttpClientRequest.bearerToken(token)),
38
+ ),
39
+ Effect.provide(Stdio.layerTest({ stdin: Stream.make(new TextEncoder().encode(stdin)) })),
40
+ Effect.provide(NodeServices.layer),
41
+ );
42
+
43
+ return (yield* TestConsole.logLines).slice(before);
44
+ });
45
+
46
+ // The commands as cli.ts connects them, with what it reads from the environment given as a
47
+ // Layer, so the test decides whether a token is configured, whatever the shell holds.
48
+ const connected = (environment: Record<string, string>, args: ReadonlyArray<string>) =>
49
+ Command.runWith(commands.pipe(Command.provideEffect(HttpClient.HttpClient, connection)), {
50
+ version: "0",
51
+ })(args).pipe(
52
+ Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnvRecord(environment))),
53
+ Effect.provide(NodeServices.layer),
54
+ );
55
+
56
+ const fitsAFile = (text: string) =>
57
+ new TextDecoder().decode(new TextEncoder().encode(text)) === text;
58
+
59
+ // Lines a random string almost never holds, each one the format has to keep apart from a title.
60
+ const trickyLines = Arbitrary.array(
61
+ Arbitrary.schema(
62
+ Schema.Literals([
63
+ "",
64
+ "# A heading",
65
+ "\\# Escaped",
66
+ "\\\\# Twice",
67
+ "#tag",
68
+ "\r# After a carriage return",
69
+ "\u2028# After a line separator",
70
+ ]),
71
+ ),
72
+ );
73
+
74
+ const importable = Arbitrary.all([
75
+ Arbitrary.schema(ImportNotes.input),
76
+ Arbitrary.array(trickyLines),
77
+ ]).pipe(
78
+ Arbitrary.map(([{ notes }, extra]) =>
79
+ notes.map(({ title, body }, place) => ({
80
+ title,
81
+ body: [body, ...(extra[place] ?? [])].join("\n"),
82
+ })),
83
+ ),
84
+ Arbitrary.filter((notes) =>
85
+ notes.every(({ title, body }) => fitsAFile(title) && fitsAFile(body)),
86
+ ),
87
+ );
88
+
89
+ describe("the commands", () => {
90
+ it.effect("take an argument, a flag and stdin, and print what `render` returns", () =>
91
+ Effect.gen(function* () {
92
+ // A command without `render` prints its result as JSON.
93
+ const [created] = yield* run("ada", ["create-note", "--title", "Plan"], "First draft\n");
94
+ const { id } = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(Note))(created);
95
+
96
+ expect(yield* run("ada", ["get-note", id])).toEqual(["# Plan\n\nFirst draft"]);
97
+
98
+ yield* run("ada", ["update-note", id, "--revision", "1"], "Second draft\n");
99
+
100
+ expect(yield* run("ada", ["get-note", id])).toEqual(["# Plan\n\nSecond draft"]);
101
+ }).pipe(Effect.provide(served)),
102
+ );
103
+
104
+ it.effect("describe a flag in `--help` as the contract describes its field", () =>
105
+ Effect.gen(function* () {
106
+ // Help is one printed text. The description is the `revision` field's, from Notes.ts.
107
+ expect(yield* run("ada", ["update-note", "--help"])).toEqual([
108
+ expect.stringContaining("The revision you read: 1 for a new note"),
109
+ ]);
110
+ }).pipe(Effect.provide(served)),
111
+ );
112
+
113
+ it.effect("run a command of their own beside the derived ones", () =>
114
+ Effect.gen(function* () {
115
+ yield* run("ada", ["create-note", "--title", "Plan"], "First draft\n");
116
+ yield* run("ada", ["create-note", "--title", "Budget"], "Too small\n");
117
+
118
+ // The command writes with the real file system, so the test gives it a real directory.
119
+ const files = yield* FileSystem.FileSystem;
120
+ const file = `${yield* files.makeTempDirectoryScoped()}/notes.md`;
121
+
122
+ expect(yield* run("ada", ["export", file])).toEqual([`Wrote 2 notes to ${file}`]);
123
+ expect(yield* files.readFileString(file)).toBe(
124
+ "# Plan\n\nFirst draft\n\n# Budget\n\nToo small",
125
+ );
126
+ }).pipe(Effect.provide(served), Effect.provide(NodeServices.layer)),
127
+ );
128
+
129
+ it.effect("import a file `export` wrote with one call, all its notes or none", () =>
130
+ Effect.gen(function* () {
131
+ const files = yield* FileSystem.FileSystem;
132
+ const directory = yield* files.makeTempDirectoryScoped();
133
+ const file = `${directory}/notes.md`;
134
+
135
+ yield* files.writeFileString(file, "# Plan\n\nFirst draft\n\n# Budget\n\nToo small");
136
+
137
+ expect(yield* run("ada", ["import", file])).toEqual(["Created 2 notes"]);
138
+ expect(yield* run("grace", ["import", file])).toEqual(["Created 2 notes"]);
139
+
140
+ yield* files.writeFileString(`${directory}/untitled.md`, "# Plan\n\nKept?\n\n# \n\nNo title");
141
+
142
+ const refused = yield* Effect.flip(run("ada", ["import", `${directory}/untitled.md`]));
143
+
144
+ expect(refused).toHaveProperty(
145
+ "userMessage",
146
+ expect.stringContaining("A note needs a title."),
147
+ );
148
+ const [listed = ""] = yield* run("ada", ["list-notes"]);
149
+
150
+ const { notes } = yield* Schema.decodeUnknownEffect(
151
+ Schema.fromJsonString(
152
+ Schema.Struct({ notes: Schema.Array(Schema.Struct({ title: Schema.String })) }),
153
+ ),
154
+ )(listed);
155
+
156
+ expect(notes.map(({ title }) => title)).toEqual(["Plan", "Budget"]);
157
+
158
+ // A missing file is a usage error: the command's help, and the file named on stderr.
159
+ yield* Effect.flip(run("ada", ["import", `${directory}/nowhere.md`]));
160
+
161
+ expect(yield* TestConsole.errorLines).toContainEqual(
162
+ expect.stringMatching(
163
+ /Invalid value for argument <file>: .*nowhere\.md.*File does not exist/su,
164
+ ),
165
+ );
166
+ }).pipe(Effect.provide(served), Effect.provide(NodeServices.layer)),
167
+ );
168
+
169
+ // Any notes the contract accepts, generated from its schema, with lines that start as a title
170
+ // would added to their bodies. A file holds Unicode text, so a string JSON carries but no file
171
+ // can, one with a lone surrogate, is left out.
172
+ it.effect.prop(
173
+ "import every note `export` wrote as it was, whatever its body holds",
174
+ [importable],
175
+ ([notes]) =>
176
+ Effect.gen(function* () {
177
+ const files = yield* FileSystem.FileSystem;
178
+ const file = `${yield* files.makeTempDirectoryScoped()}/notes.md`;
179
+
180
+ const as = (token: string) =>
181
+ ActionHttp.client(Http).pipe(
182
+ Effect.updateService(
183
+ HttpClient.HttpClient,
184
+ HttpClient.mapRequest(HttpClientRequest.bearerToken(token)),
185
+ ),
186
+ );
187
+
188
+ const [ada, grace] = [yield* as("ada"), yield* as("grace")];
189
+
190
+ yield* ada.importNotes({ notes });
191
+ yield* run("ada", ["export", file]);
192
+ yield* run("grace", ["import", file]);
193
+
194
+ const { notes: listed } = yield* grace.listNotes();
195
+ const read = yield* Effect.forEach(listed, ({ id }) => grace.getNote({ id }));
196
+
197
+ expect(read.map(({ title, body }) => ({ title, body }))).toEqual(notes);
198
+ }).pipe(Effect.provide(served), Effect.provide(NodeServices.layer)),
199
+ { arbitrary: { runs: 50 } },
200
+ );
201
+
202
+ it.effect(
203
+ "import an empty file as no notes, and refuse one `export` did not write or too long",
204
+ () =>
205
+ Effect.gen(function* () {
206
+ const files = yield* FileSystem.FileSystem;
207
+ const directory = yield* files.makeTempDirectoryScoped();
208
+
209
+ yield* files.writeFileString(`${directory}/empty.md`, "");
210
+
211
+ expect(yield* run("grace", ["import", `${directory}/empty.md`])).toEqual([
212
+ "Created 0 notes",
213
+ ]);
214
+
215
+ yield* files.writeFileString(`${directory}/preamble.md`, "My notes\n\n# Plan\n\nDraft");
216
+
217
+ expect(
218
+ yield* Effect.flip(run("grace", ["import", `${directory}/preamble.md`])),
219
+ ).toHaveProperty(
220
+ "userMessage",
221
+ "The file does not start with a `# ` title, as `notes export` writes one.",
222
+ );
223
+
224
+ const many = Array.from({ length: 101 }, (_, place) => `# Note ${place}\n\nBody`);
225
+
226
+ yield* files.writeFileString(`${directory}/many.md`, many.join("\n\n"));
227
+
228
+ expect(yield* Effect.flip(run("grace", ["import", `${directory}/many.md`]))).toHaveProperty(
229
+ "userMessage",
230
+ expect.stringContaining("An import holds at most 100 notes."),
231
+ );
232
+
233
+ expect(yield* run("grace", ["list-notes"])).toEqual([
234
+ JSON.stringify({ notes: [] }, null, 2),
235
+ ]);
236
+ }).pipe(Effect.provide(served), Effect.provide(NodeServices.layer)),
237
+ );
238
+
239
+ it.effect("fail as the server refuses, a command of their own included", () =>
240
+ Effect.gen(function* () {
241
+ const refused = yield* Effect.flip(run("mallory", ["export", "notes.md"]));
242
+
243
+ expect(refused).toBeInstanceOf(CliError.UserError);
244
+ expect(refused).toHaveProperty(
245
+ "userMessage",
246
+ '{"_tag":"Unauthenticated","message":"Unknown demo token."}',
247
+ );
248
+ // The command's own context is a line of its own on stderr, the JSON left as it is.
249
+ expect(yield* TestConsole.errorLines).toContain("The export to notes.md did not finish.");
250
+
251
+ // Not an action's error: the file system's, named in the message.
252
+ const unwritable = yield* Effect.flip(run("ada", ["export", "no-such-directory/notes.md"]));
253
+
254
+ expect(unwritable).toBeInstanceOf(CliError.UserError);
255
+ expect(unwritable).toHaveProperty("userMessage", expect.stringMatching(/^PlatformError: /));
256
+
257
+ const missing = yield* Effect.flip(run("ada", ["get-note", "9"]));
258
+
259
+ expect(missing).toHaveProperty("cause", new NoteNotFound({ id: "9" }));
260
+ }).pipe(Effect.provide(served)),
261
+ );
262
+
263
+ it.effect("call as the configured token names, and ask for one where none is configured", () =>
264
+ Effect.gen(function* () {
265
+ yield* connected({ NOTES_TOKEN: "reader" }, ["who-am-i"]);
266
+
267
+ expect(yield* TestConsole.logLines).toEqual([expect.stringContaining('"id": "reader"')]);
268
+
269
+ const refused = yield* Effect.flip(connected({}, ["list-notes"]));
270
+
271
+ expect(refused).toBeInstanceOf(CliError.UserError);
272
+ expect(refused).toHaveProperty(
273
+ "userMessage",
274
+ "NOTES_TOKEN is not set. Set it to your token: export NOTES_TOKEN=<your token>",
275
+ );
276
+
277
+ // What a person sees: that message, once, on stderr, and no report of a `ConfigError`.
278
+ expect(yield* TestConsole.errorLines).toEqual([
279
+ expect.stringContaining("NOTES_TOKEN is not set."),
280
+ ]);
281
+
282
+ // The token is read when a command runs, so its help needs none.
283
+ yield* connected({}, ["list-notes", "--help"]);
284
+
285
+ expect(yield* TestConsole.logLines).toHaveLength(2);
286
+ }).pipe(Effect.provide(served)),
287
+ );
288
+ });
@@ -0,0 +1,103 @@
1
+ import { describe, expect, it } from "@effect/vitest";
2
+ import { Array, Effect, Exit, References } from "effect";
3
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
4
+ import { CurrentUser } from "../../src/identity/Identity.js";
5
+ import { users } from "../../src/identity/users.js";
6
+ import { notes } from "../../src/notes/handlers.js";
7
+ import { NoteChanged, NoteNotFound } from "../../src/notes/Notes.js";
8
+ import { NoteStore } from "../../src/notes/store.js";
9
+
10
+ // What the implementation does, with no transport: `Action.client` runs the same decoding,
11
+ // `authorize` and handler a server runs. Each call names its caller, as a request would.
12
+ const as = (user: keyof typeof users) => Effect.provideService(CurrentUser, users[user]);
13
+
14
+ describe("notes, in process", () => {
15
+ it.effect("keeps each user's notes their own", () =>
16
+ Effect.gen(function* () {
17
+ const client = yield* Action.client(notes);
18
+ const note = yield* client.createNote({ title: "Plan", body: "Draft" }).pipe(as("ada"));
19
+
20
+ expect(yield* client.listNotes().pipe(as("ada"))).toEqual({
21
+ notes: [{ id: note.id, title: "Plan" }],
22
+ });
23
+ expect(yield* client.listNotes().pipe(as("grace"))).toEqual({ notes: [] });
24
+
25
+ const missing = new NoteNotFound({ id: note.id });
26
+ const update = { id: note.id, body: "Mine now", revision: note.revision };
27
+
28
+ expect(yield* Effect.flip(client.getNote({ id: note.id }).pipe(as("grace")))).toEqual(
29
+ missing,
30
+ );
31
+ expect(yield* Effect.flip(client.updateNote(update).pipe(as("grace")))).toEqual(missing);
32
+ expect(yield* Effect.flip(client.deleteNote({ id: note.id }).pipe(as("grace")))).toEqual(
33
+ missing,
34
+ );
35
+ expect(yield* client.getNote({ id: note.id }).pipe(as("ada"))).toEqual(note);
36
+ }).pipe(Effect.provide(NoteStore.layerMemory)),
37
+ );
38
+
39
+ it.effect("deletes a note for its owner", () =>
40
+ Effect.gen(function* () {
41
+ const client = yield* Action.client(notes);
42
+ const note = yield* client.createNote({ title: "Plan", body: "Draft" }).pipe(as("ada"));
43
+
44
+ yield* client.deleteNote({ id: note.id }).pipe(as("ada"));
45
+
46
+ expect(yield* Effect.flip(client.getNote({ id: note.id }).pipe(as("ada")))).toEqual(
47
+ new NoteNotFound({ id: note.id }),
48
+ );
49
+ }).pipe(Effect.provide(NoteStore.layerMemory)),
50
+ );
51
+
52
+ it.effect("refuses an update of a revision that is no longer current", () =>
53
+ Effect.gen(function* () {
54
+ const client = yield* Action.client(notes);
55
+ const note = yield* client.createNote({ title: "Plan", body: "Draft" }).pipe(as("ada"));
56
+ const update = { id: note.id, body: "Final", revision: note.revision };
57
+
58
+ expect(yield* client.updateNote(update).pipe(as("ada"))).toMatchObject({ revision: 2 });
59
+ expect(yield* Effect.flip(client.updateNote(update).pipe(as("ada")))).toEqual(
60
+ new NoteChanged({ id: note.id, revision: 2 }),
61
+ );
62
+ }).pipe(Effect.provide(NoteStore.layerMemory)),
63
+ );
64
+
65
+ it.effect("lets one of two concurrent updates of the same revision through", () =>
66
+ Effect.gen(function* () {
67
+ const client = yield* Action.client(notes);
68
+
69
+ // A fiber is set aside after every `steps` steps, and two that run at once then take
70
+ // turns. Each count sets the first update aside at another point of its work; under
71
+ // three, the two do not finish.
72
+ const succeeded = yield* Effect.forEach(Array.range(3, 60), (steps) =>
73
+ Effect.gen(function* () {
74
+ const note = yield* client.createNote({ title: "Plan", body: "Draft" }).pipe(as("ada"));
75
+
76
+ const update = (body: string) =>
77
+ Effect.exit(client.updateNote({ id: note.id, body, revision: 1 }).pipe(as("ada")));
78
+
79
+ const exits = yield* Effect.all([update("One"), update("Two")], {
80
+ concurrency: "unbounded",
81
+ }).pipe(Effect.provideService(References.MaxOpsBeforeYield, steps));
82
+
83
+ return exits.filter(Exit.isSuccess).length;
84
+ }),
85
+ );
86
+
87
+ expect(succeeded).toEqual(Array.range(3, 60).map(() => 1));
88
+ }).pipe(Effect.provide(NoteStore.layerMemory)),
89
+ );
90
+
91
+ it.effect("refuses a write from a caller holding the read scope alone", () =>
92
+ Effect.gen(function* () {
93
+ const client = yield* Action.client(notes);
94
+ const create = client.createNote({ title: "Plan", body: "Draft" });
95
+ const refusal = yield* Effect.flip(create.pipe(as("reader")));
96
+
97
+ // `toEqual` on an error compares its fields and never its message, so the message is
98
+ // asserted on its own.
99
+ expect(refusal).toBeInstanceOf(Action.Forbidden);
100
+ expect(refusal.message).toBe("Requires notes:write.");
101
+ }).pipe(Effect.provide(NoteStore.layerMemory)),
102
+ );
103
+ });
@@ -0,0 +1,84 @@
1
+ import { describe, expect, it } from "@effect/vitest";
2
+ import { Effect, Layer } from "effect";
3
+ import { HttpClient, HttpClientRequest } from "effect/http";
4
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
5
+ import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
6
+ import * as Testing from "@gjermundgaraba/effect-actions/Testing";
7
+ import { Http } from "../src/Api.js";
8
+ import { Notes } from "../src/notes/Notes.js";
9
+ import { NoteStore } from "../src/notes/store.js";
10
+ import { routes } from "../src/routes.js";
11
+ import { GetStatus } from "../src/status/Status.js";
12
+
13
+ // What the wire adds: the real routes, behind their real authentication, answering in memory.
14
+ // No port is opened. The caller is the token each client sends, as in production.
15
+ const served = Testing.layer(routes).pipe(Layer.provide(NoteStore.layerMemory));
16
+
17
+ const as = (token: string) => ({
18
+ transformClient: HttpClient.mapRequest(HttpClientRequest.bearerToken(token)),
19
+ });
20
+
21
+ describe("the service, over HTTP and MCP", () => {
22
+ it.effect("answers the public action without a token and refuses the rest", () =>
23
+ Effect.gen(function* () {
24
+ const anyone = yield* ActionHttp.client(Http);
25
+
26
+ expect(yield* anyone.getStatus()).toEqual({ service: "notes-service", notes: 0 });
27
+ expect(yield* Effect.flip(anyone.whoAmI())).toBeInstanceOf(Action.Unauthenticated);
28
+ }).pipe(Effect.provide(served)),
29
+ );
30
+
31
+ it.effect("refuses a token no user holds", () =>
32
+ Effect.gen(function* () {
33
+ const stranger = yield* ActionHttp.client(Http, as("mallory"));
34
+
35
+ const refusal = yield* Effect.flip(stranger.whoAmI());
36
+
37
+ expect(refusal).toBeInstanceOf(Action.Unauthenticated);
38
+ expect(refusal.message).toBe("Unknown demo token.");
39
+ }).pipe(Effect.provide(served)),
40
+ );
41
+
42
+ it.effect("serves the host's own routes beside the actions'", () =>
43
+ Effect.gen(function* () {
44
+ const health = yield* HttpClient.get("/healthz");
45
+ const openApi = yield* HttpClient.get("/api/openapi.json");
46
+
47
+ expect(yield* health.text).toBe("ok");
48
+ expect(yield* openApi.text).toContain('"/api/createNote"');
49
+ }).pipe(Effect.provide(served)),
50
+ );
51
+
52
+ it.effect("names the scope a caller lacks, so an OAuth client can ask for it", () =>
53
+ Effect.gen(function* () {
54
+ const reader = yield* ActionHttp.client(Http, as("reader"));
55
+
56
+ expect(yield* reader.whoAmI()).toEqual({ id: "reader", scopes: ["notes:read"] });
57
+
58
+ const refusal = yield* Effect.flip(reader.createNote({ title: "Plan", body: "" }));
59
+
60
+ expect(refusal).toBeInstanceOf(Action.Forbidden);
61
+ expect(refusal).toHaveProperty("scopes", ["notes:write"]);
62
+ }).pipe(Effect.provide(served)),
63
+ );
64
+
65
+ it.effect("serves one store to both surfaces: a note written over HTTP is read as a tool", () =>
66
+ Effect.gen(function* () {
67
+ const http = yield* ActionHttp.client(Http, as("ada"));
68
+ const mcp = yield* Testing.mcpClient([GetStatus, ...Notes], as("ada"));
69
+ const note = yield* http.createNote({ title: "Plan", body: "Draft" });
70
+
71
+ expect(yield* mcp.getNote({ id: note.id })).toEqual(note);
72
+ expect(yield* mcp.getStatus()).toEqual({ service: "notes-service", notes: 1 });
73
+ }).pipe(Effect.provide(served)),
74
+ );
75
+
76
+ it.effect("lists every tool to a signed-out MCP client, as one of them is public", () =>
77
+ Effect.gen(function* () {
78
+ const response = yield* HttpClient.execute(Testing.mcpRequest("tools/list"));
79
+
80
+ expect(response.status).toBe(200);
81
+ expect(yield* response.text).toContain('"name":"createNote"');
82
+ }).pipe(Effect.provide(served)),
83
+ );
84
+ });
@@ -0,0 +1,31 @@
1
+ # quickstart
2
+
3
+ The smallest complete program: one action, served as a JSON route and as an MCP tool, called
4
+ with its typed client. It is the skeleton of [notes-service](../notes-service/), which adds
5
+ authentication, authorization, declared errors, a store, a CLI and a browser client to the same
6
+ layout.
7
+
8
+ ## Run it
9
+
10
+ From the repository root, after `vp install` (from an installed package, see
11
+ [Run one](../README.md#run-one)):
12
+
13
+ ```sh
14
+ node --import tsx examples/quickstart/src/server.ts # 127.0.0.1:3000
15
+ node --import tsx examples/quickstart/src/client.ts # Hello, Ada!
16
+ vp test examples/quickstart
17
+ ```
18
+
19
+ ## Layout
20
+
21
+ ```text
22
+ src/
23
+ ├── greeting/
24
+ │ ├── Greeting.ts the contract
25
+ │ └── handlers.ts its implementation
26
+ ├── Api.ts the HTTP binding, shared by server and client
27
+ ├── routes.ts the surfaces: POST /api/greet and /mcp
28
+ ├── server.ts entrypoint
29
+ └── client.ts entrypoint
30
+ test/greeting/ the routes, called in memory
31
+ ```
@@ -0,0 +1,5 @@
1
+ import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
2
+ import { Greet } from "./greeting/Greeting.js";
3
+
4
+ // The HTTP binding: `POST /api/greet`. The server serves it and the client calls it.
5
+ export const Http = ActionHttp.make([Greet]);
@@ -0,0 +1,13 @@
1
+ import { NodeRuntime } from "@effect/platform-node";
2
+ import { Console, Effect } from "effect";
3
+ import { FetchHttpClient } from "effect/http";
4
+ import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
5
+ import { Http } from "./Api.js";
6
+
7
+ // Entrypoint: `node --import tsx examples/quickstart/src/client.ts`, with the server running.
8
+ // One typed method per action of the binding.
9
+ Effect.gen(function* () {
10
+ const client = yield* ActionHttp.client(Http, { baseUrl: "http://127.0.0.1:3000" });
11
+
12
+ yield* Console.log(yield* client.greet({ name: "Ada" }));
13
+ }).pipe(Effect.provide(FetchHttpClient.layer), NodeRuntime.runMain);
@@ -0,0 +1,12 @@
1
+ import { Schema } from "effect";
2
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
3
+
4
+ // A contract: what the action takes and answers, whether it changes anything, and who may
5
+ // call it. It imports no server code, so any client can import it, a browser page included.
6
+ export const Greet = Action.make("greet", {
7
+ description: "Greet someone by name.",
8
+ input: { name: Schema.String },
9
+ success: Schema.String,
10
+ readOnly: true,
11
+ caller: Action.Anyone,
12
+ });
@@ -0,0 +1,6 @@
1
+ import { Effect } from "effect";
2
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
3
+ import { Greet } from "./Greeting.js";
4
+
5
+ // The implementation: the contract bound to its handler. Every surface takes this one value.
6
+ export const greeting = Action.implement(Greet, ({ name }) => Effect.succeed(`Hello, ${name}!`));
@@ -0,0 +1,11 @@
1
+ import { Layer } from "effect";
2
+ import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
3
+ import * as ActionMcp from "@gjermundgaraba/effect-actions/ActionMcp";
4
+ import { Http } from "./Api.js";
5
+ import { greeting } from "./greeting/handlers.js";
6
+
7
+ // One implementation, two surfaces: a JSON route and an MCP tool at `/mcp`.
8
+ export const routes = Layer.mergeAll(
9
+ ActionHttp.layer(Http, greeting),
10
+ ActionMcp.layerHttp(greeting, { name: "greetings", version: "1.0.0" }),
11
+ );
@@ -0,0 +1,16 @@
1
+ import { createServer } from "node:http";
2
+ import { NodeHttpServer, NodeRuntime } from "@effect/platform-node";
3
+ import { Layer } from "effect";
4
+ import { HttpRouter } from "effect/http";
5
+ import { routes } from "./routes.js";
6
+
7
+ // Entrypoint: `node --import tsx examples/quickstart/src/server.ts`.
8
+ // A real server reads its port from config, `Config.Port("PORT")`, and limits request bodies:
9
+ // `Layer.provide(Layer.succeed(HttpServerRequest.MaxBodySize, ByteSize.mebibytes(1)))`.
10
+ // `127.0.0.1` serves this machine alone: a server others reach reads its host from config,
11
+ // `Config.String("HOST").pipe(Config.withDefault("127.0.0.1"))`.
12
+ HttpRouter.serve(routes).pipe(
13
+ Layer.provide(NodeHttpServer.layer(createServer, { host: "127.0.0.1", port: 3000 })),
14
+ Layer.launch,
15
+ NodeRuntime.runMain,
16
+ );
@@ -0,0 +1,18 @@
1
+ import { expect, it } from "@effect/vitest";
2
+ import { Effect } from "effect";
3
+ import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
4
+ import * as Testing from "@gjermundgaraba/effect-actions/Testing";
5
+ import { Http } from "../../src/Api.js";
6
+ import { Greet } from "../../src/greeting/Greeting.js";
7
+ import { routes } from "../../src/routes.js";
8
+
9
+ // The routes answer in memory: no port, and the same code the server runs.
10
+ it.effect("greets over HTTP and as an MCP tool", () =>
11
+ Effect.gen(function* () {
12
+ const http = yield* ActionHttp.client(Http);
13
+ const mcp = yield* Testing.mcpClient([Greet]);
14
+
15
+ expect(yield* http.greet({ name: "Ada" })).toBe("Hello, Ada!");
16
+ expect(yield* mcp.greet({ name: "Ada" })).toBe("Hello, Ada!");
17
+ }).pipe(Effect.provide(Testing.layer(routes))),
18
+ );