@gjermundgaraba/effect-actions 0.12.0 → 0.14.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 (45) hide show
  1. package/README.md +4 -4
  2. package/dist/Action.d.mts +3 -1
  3. package/dist/ActionToolkit.d.mts +23 -1
  4. package/dist/ActionToolkit.mjs +28 -1
  5. package/dist/Testing.d.mts +3 -1
  6. package/dist/Testing.mjs +2 -2
  7. package/dist/mcp/protocol.d.mts +2 -1
  8. package/docs/Action.md +11 -6
  9. package/docs/ActionCli-failures.md +2 -0
  10. package/docs/ActionCli.md +7 -5
  11. package/docs/ActionHttp-failures.md +2 -2
  12. package/docs/ActionHttp.md +6 -3
  13. package/docs/ActionMcp.md +20 -20
  14. package/docs/ActionRpc.md +3 -2
  15. package/docs/ActionToolkit.md +15 -12
  16. package/docs/Authentication.md +3 -2
  17. package/docs/CONTEXT.md +3 -2
  18. package/docs/README.md +9 -5
  19. package/docs/Testing-failures.md +2 -1
  20. package/docs/Testing.md +10 -3
  21. package/docs/setup-failures.md +5 -2
  22. package/docs/setup.md +9 -7
  23. package/examples/kitchen-sink/README.md +5 -4
  24. package/examples/kitchen-sink/src/assistant/handlers.ts +18 -38
  25. package/examples/kitchen-sink/src/issues/Issues.ts +4 -2
  26. package/examples/kitchen-sink/src/issues/write-limit.ts +2 -1
  27. package/examples/kitchen-sink/src/server.ts +3 -3
  28. package/examples/live-rpc/README.md +1 -1
  29. package/examples/live-rpc/src/chat/Chat.ts +3 -1
  30. package/examples/live-rpc/src/chat/handlers.ts +2 -2
  31. package/examples/live-rpc/src/chat/store.ts +1 -1
  32. package/examples/live-rpc/src/server.ts +3 -3
  33. package/examples/live-rpc/test/chat/chat.test.ts +1 -1
  34. package/examples/mcp-tools/README.md +9 -4
  35. package/examples/mcp-tools/src/printers/Printers.ts +6 -4
  36. package/examples/mcp-tools/src/server.ts +3 -2
  37. package/examples/mcp-tools/test/routes.test.ts +2 -2
  38. package/examples/notes-service/README.md +6 -3
  39. package/examples/notes-service/src/commands.ts +50 -2
  40. package/examples/notes-service/src/notes/Notes.ts +32 -4
  41. package/examples/notes-service/src/notes/handlers.ts +8 -2
  42. package/examples/notes-service/src/notes/store.ts +11 -0
  43. package/examples/notes-service/src/server.ts +3 -3
  44. package/examples/notes-service/test/commands.test.ts +156 -2
  45. package/package.json +5 -5
@@ -1,12 +1,23 @@
1
1
  import { NodeServices } from "@effect/platform-node";
2
2
  import { describe, expect, it } from "@effect/vitest";
3
- import { ConfigProvider, Effect, FileSystem, Layer, Schema, Stdio, Stream } from "effect";
3
+ import {
4
+ Arbitrary,
5
+ ConfigProvider,
6
+ Effect,
7
+ FileSystem,
8
+ Layer,
9
+ Schema,
10
+ Stdio,
11
+ Stream,
12
+ } from "effect";
4
13
  import { CliError, Command } from "effect/cli";
5
14
  import { HttpClient, HttpClientRequest } from "effect/http";
6
15
  import { TestConsole } from "effect/testing";
16
+ import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
7
17
  import * as Testing from "@gjermundgaraba/effect-actions/Testing";
18
+ import { Http } from "../src/Api.js";
8
19
  import { commands, connection } from "../src/commands.js";
9
- import { Note, NoteNotFound } from "../src/notes/Notes.js";
20
+ import { ImportNotes, Note, NoteNotFound } from "../src/notes/Notes.js";
10
21
  import { NoteStore } from "../src/notes/store.js";
11
22
  import { routes } from "../src/routes.js";
12
23
 
@@ -42,6 +53,39 @@ const connected = (environment: Record<string, string>, args: ReadonlyArray<stri
42
53
  Effect.provide(NodeServices.layer),
43
54
  );
44
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
+
45
89
  describe("the commands", () => {
46
90
  it.effect("take an argument, a flag and stdin, and print what `render` returns", () =>
47
91
  Effect.gen(function* () {
@@ -82,6 +126,116 @@ describe("the commands", () => {
82
126
  }).pipe(Effect.provide(served), Effect.provide(NodeServices.layer)),
83
127
  );
84
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
+
85
239
  it.effect("fail as the server refuses, a command of their own included", () =>
86
240
  Effect.gen(function* () {
87
241
  const refused = yield* Effect.flip(run("mallory", ["export", "notes.md"]));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjermundgaraba/effect-actions",
3
- "version": "0.12.0",
3
+ "version": "0.14.0",
4
4
  "description": "Define Effect action contracts once and project them onto HTTP, MCP, Effect RPC, native Toolkits, and CLIs.",
5
5
  "keywords": [
6
6
  "ai-agents",
@@ -49,11 +49,11 @@
49
49
  "access": "public"
50
50
  },
51
51
  "devDependencies": {
52
- "@effect/platform-node": "4.0.2",
53
- "@effect/vitest": "4.0.2",
52
+ "@effect/platform-node": "4.0.4",
53
+ "@effect/vitest": "4.0.4",
54
54
  "@modelcontextprotocol/client": "2.3.1",
55
55
  "@types/node": "26.6.4",
56
- "effect": "4.0.2",
56
+ "effect": "4.0.4",
57
57
  "tsx": "4.23.15",
58
58
  "typescript": "7.0.2",
59
59
  "vite": "npm:@voidzero-dev/vite-plus-core@1.1.0",
@@ -61,7 +61,7 @@
61
61
  "vitest": "5.0.3"
62
62
  },
63
63
  "peerDependencies": {
64
- "effect": "~4.0.2"
64
+ "effect": "~4.0.4"
65
65
  },
66
66
  "devEngines": {
67
67
  "packageManager": {