@gjermundgaraba/effect-actions 0.12.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.
- package/README.md +4 -4
- package/dist/Action.d.mts +3 -1
- package/dist/ActionToolkit.d.mts +23 -1
- package/dist/ActionToolkit.mjs +28 -1
- package/dist/Testing.d.mts +3 -1
- package/dist/Testing.mjs +2 -2
- package/dist/mcp/protocol.d.mts +2 -1
- package/docs/Action.md +11 -6
- package/docs/ActionCli-failures.md +2 -0
- package/docs/ActionCli.md +7 -5
- package/docs/ActionHttp-failures.md +2 -2
- package/docs/ActionHttp.md +6 -3
- package/docs/ActionMcp.md +20 -20
- package/docs/ActionRpc.md +3 -2
- package/docs/ActionToolkit.md +15 -12
- package/docs/Authentication.md +3 -2
- package/docs/CONTEXT.md +3 -2
- package/docs/README.md +9 -5
- package/docs/Testing-failures.md +2 -1
- package/docs/Testing.md +10 -3
- package/docs/setup-failures.md +5 -2
- package/docs/setup.md +9 -7
- package/examples/kitchen-sink/README.md +5 -4
- package/examples/kitchen-sink/src/assistant/handlers.ts +18 -38
- package/examples/kitchen-sink/src/issues/Issues.ts +4 -2
- package/examples/kitchen-sink/src/issues/write-limit.ts +2 -1
- package/examples/kitchen-sink/src/server.ts +3 -3
- package/examples/live-rpc/README.md +1 -1
- package/examples/live-rpc/src/chat/Chat.ts +3 -1
- package/examples/live-rpc/src/chat/handlers.ts +2 -2
- package/examples/live-rpc/src/chat/store.ts +1 -1
- package/examples/live-rpc/src/server.ts +3 -3
- package/examples/live-rpc/test/chat/chat.test.ts +1 -1
- package/examples/mcp-tools/README.md +9 -4
- package/examples/mcp-tools/src/printers/Printers.ts +6 -4
- package/examples/mcp-tools/src/server.ts +3 -2
- package/examples/mcp-tools/test/routes.test.ts +2 -2
- package/examples/notes-service/README.md +6 -3
- package/examples/notes-service/src/commands.ts +50 -2
- package/examples/notes-service/src/notes/Notes.ts +32 -4
- package/examples/notes-service/src/notes/handlers.ts +8 -2
- package/examples/notes-service/src/notes/store.ts +11 -0
- package/examples/notes-service/src/server.ts +3 -3
- package/examples/notes-service/test/commands.test.ts +156 -2
- 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 {
|
|
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.
|
|
3
|
+
"version": "0.13.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.
|
|
53
|
-
"@effect/vitest": "4.0.
|
|
52
|
+
"@effect/platform-node": "4.0.3",
|
|
53
|
+
"@effect/vitest": "4.0.3",
|
|
54
54
|
"@modelcontextprotocol/client": "2.3.1",
|
|
55
55
|
"@types/node": "26.6.4",
|
|
56
|
-
"effect": "4.0.
|
|
56
|
+
"effect": "4.0.3",
|
|
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.
|
|
64
|
+
"effect": "~4.0.3"
|
|
65
65
|
},
|
|
66
66
|
"devEngines": {
|
|
67
67
|
"packageManager": {
|