@gjermundgaraba/effect-actions 0.16.0 → 0.18.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 +3 -1
- package/dist/Action.d.mts +3 -5
- package/dist/Action.mjs +3 -1
- package/dist/ActionCli.d.mts +31 -42
- package/dist/ActionCli.mjs +6 -16
- package/dist/ActionHttp.d.mts +12 -8
- package/dist/ActionHttp.mjs +3 -1
- package/dist/ActionMcp.d.mts +1 -1
- package/dist/ActionMcp.mjs +4 -1
- package/dist/ActionRpc.d.mts +5 -3
- package/dist/ActionRpc.mjs +2 -1
- package/dist/ActionToolkit.d.mts +3 -2
- package/dist/ActionToolkit.mjs +3 -1
- package/dist/Authentication.d.mts +1 -1
- package/dist/Authentication.mjs +1 -1
- package/dist/authentication/provider.d.mts +2 -2
- package/dist/authentication/refusal.mjs +1 -1
- package/dist/cli/commands.d.mts +4 -3
- package/dist/cli/commands.mjs +6 -5
- package/dist/contract/ast.mjs +10 -0
- package/dist/contract/call.d.mts +1 -1
- package/dist/contract/caller.d.mts +9 -0
- package/dist/contract/caller.mjs +12 -0
- package/dist/contract/{rules.d.mts → declared.d.mts} +6 -10
- package/dist/contract/declared.mjs +42 -0
- package/dist/contract/hints.mjs +14 -0
- package/dist/contract/implementation.d.mts +4 -3
- package/dist/contract/implementation.mjs +4 -8
- package/dist/contract/names.mjs +25 -0
- package/dist/contract/status.mjs +18 -0
- package/dist/http/client.d.mts +19 -9
- package/dist/http/client.mjs +6 -1
- package/dist/mcp/client.d.mts +52 -12
- package/dist/mcp/client.mjs +62 -19
- package/dist/mcp/protocol.mjs +1 -1
- package/docs/Action.md +17 -3
- package/docs/ActionCli-failures.md +10 -9
- package/docs/ActionCli.md +22 -21
- package/docs/ActionHttp-failures.md +6 -0
- package/docs/ActionHttp.md +9 -4
- package/docs/ActionMcp.md +3 -3
- package/docs/ActionToolkit.md +25 -2
- package/docs/Authentication-failures.md +1 -1
- package/docs/CONTEXT.md +2 -1
- package/docs/README.md +4 -4
- package/docs/Testing.md +3 -1
- package/examples/README.md +10 -2
- package/examples/assistant/README.md +24 -20
- package/examples/assistant/src/agent.ts +11 -7
- package/examples/assistant/src/cli.ts +2 -2
- package/examples/assistant/src/platform/tasks-file.ts +38 -0
- package/examples/assistant/src/tasks/store.ts +6 -38
- package/examples/assistant/src/toolkit.ts +11 -1
- package/examples/assistant/test/{tasks/store.test.ts → platform/tasks-file.test.ts} +2 -1
- package/examples/kitchen-sink/src/cli.ts +19 -21
- package/examples/mcp-tools/README.md +14 -11
- package/examples/mcp-tools/src/{printers/fake.ts → platform/fake-farm.ts} +2 -2
- package/examples/mcp-tools/src/platform/print-farm.ts +118 -0
- package/examples/mcp-tools/src/printers/farm.ts +6 -122
- package/examples/mcp-tools/src/server.ts +4 -4
- package/examples/mcp-tools/src/stdio.ts +4 -4
- package/examples/mcp-tools/test/printers/printers.test.ts +3 -3
- package/examples/mcp-tools/test/routes.test.ts +4 -4
- package/examples/notes-service/README.md +34 -2
- package/examples/notes-service/src/cli.ts +3 -9
- package/examples/notes-service/src/commands.ts +40 -36
- package/examples/notes-service/src/identity/authorize.ts +1 -0
- package/examples/notes-service/src/notes/Notes.ts +22 -5
- package/examples/notes-service/src/notes/handlers.ts +2 -2
- package/examples/notes-service/src/notes/store.ts +36 -6
- package/examples/notes-service/test/commands.test.ts +13 -19
- package/examples/notes-service/test/notes/idempotency.test.ts +132 -0
- package/examples/notes-service/test/routes.test.ts +40 -1
- package/package.json +5 -5
- package/dist/_virtual/_rolldown/runtime.mjs +0 -13
- package/dist/contract/rules.mjs +0 -100
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Context, Effect, Layer } from "effect";
|
|
2
|
-
import { type Note, NoteChanged, NoteNotFound } from "./Notes.js";
|
|
2
|
+
import { type Note, NoteChanged, NoteNotFound, RequestConflict } from "./Notes.js";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Where notes live, each owned by one user. Ownership is checked here, in the data access, so no
|
|
@@ -10,7 +10,12 @@ export class NoteStore extends Context.Service<
|
|
|
10
10
|
{
|
|
11
11
|
readonly list: (owner: string) => Effect.Effect<ReadonlyArray<Note>>;
|
|
12
12
|
readonly get: (owner: string, id: string) => Effect.Effect<Note, NoteNotFound>;
|
|
13
|
-
readonly create: (
|
|
13
|
+
readonly create: (
|
|
14
|
+
owner: string,
|
|
15
|
+
title: string,
|
|
16
|
+
body: string,
|
|
17
|
+
requestId?: string,
|
|
18
|
+
) => Effect.Effect<Note, RequestConflict>;
|
|
14
19
|
/** Every note or none: a database does this in one transaction. */
|
|
15
20
|
readonly createAll: (
|
|
16
21
|
owner: string,
|
|
@@ -26,12 +31,14 @@ export class NoteStore extends Context.Service<
|
|
|
26
31
|
readonly count: Effect.Effect<number>;
|
|
27
32
|
}
|
|
28
33
|
>()("notes-service/NoteStore") {
|
|
29
|
-
// DEMO ONLY:
|
|
30
|
-
// service, chosen by the entrypoint.
|
|
34
|
+
// DEMO ONLY: notes and replay records live in this process's memory, without eviction.
|
|
31
35
|
static readonly layerMemory = Layer.sync(NoteStore, () => {
|
|
32
36
|
// One map per owner: a note is only ever looked up among its owner's, whatever id is asked.
|
|
33
37
|
const notes = new Map<string, Map<string, Note>>();
|
|
34
38
|
|
|
39
|
+
// Keep the original result even after the note is updated or deleted.
|
|
40
|
+
const creations = new Map<string, Map<string, Note>>();
|
|
41
|
+
|
|
35
42
|
const ownedBy = (owner: string) => notes.get(owner) ?? new Map<string, Note>();
|
|
36
43
|
|
|
37
44
|
const save = (owner: string, note: Note) => {
|
|
@@ -51,8 +58,31 @@ export class NoteStore extends Context.Service<
|
|
|
51
58
|
return NoteStore.of({
|
|
52
59
|
list: (owner) => Effect.sync(() => Array.from(ownedBy(owner).values())),
|
|
53
60
|
get,
|
|
54
|
-
create: (owner, title, body)
|
|
55
|
-
|
|
61
|
+
create: Effect.fn("NoteStore.create")(function* (owner, title, body, requestId) {
|
|
62
|
+
if (requestId !== undefined) {
|
|
63
|
+
const recorded = creations.get(owner)?.get(requestId);
|
|
64
|
+
|
|
65
|
+
if (recorded !== undefined) {
|
|
66
|
+
if (recorded.title !== title || recorded.body !== body) {
|
|
67
|
+
return yield* new RequestConflict({ requestId });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return recorded;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// No yield between lookup, mutation and recording: concurrent calls cannot interleave
|
|
75
|
+
// or interruption leave a note without its result.
|
|
76
|
+
const note = save(owner, { id: crypto.randomUUID(), title, body, revision: 1 });
|
|
77
|
+
|
|
78
|
+
if (requestId !== undefined) {
|
|
79
|
+
const requests = creations.get(owner) ?? new Map<string, Note>();
|
|
80
|
+
requests.set(requestId, note);
|
|
81
|
+
creations.set(owner, requests);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return note;
|
|
85
|
+
}),
|
|
56
86
|
createAll: (owner, all) =>
|
|
57
87
|
Effect.sync(() =>
|
|
58
88
|
all.map(({ title, body }) =>
|
|
@@ -16,7 +16,7 @@ import { TestConsole } from "effect/testing";
|
|
|
16
16
|
import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
|
|
17
17
|
import * as Testing from "@gjermundgaraba/effect-actions/Testing";
|
|
18
18
|
import { Http } from "../src/Api.js";
|
|
19
|
-
import { commands
|
|
19
|
+
import { commands } from "../src/commands.js";
|
|
20
20
|
import { ImportNotes, Note, NoteNotFound } from "../src/notes/Notes.js";
|
|
21
21
|
import { NoteStore } from "../src/notes/store.js";
|
|
22
22
|
import { routes } from "../src/routes.js";
|
|
@@ -26,17 +26,16 @@ import { routes } from "../src/routes.js";
|
|
|
26
26
|
// service a builder yields is provided here too (routes.test.ts).
|
|
27
27
|
const served = Testing.layer(routes).pipe(Layer.provide(NoteStore.layerMemory));
|
|
28
28
|
|
|
29
|
-
// Effect's own runner, given arguments instead of the process's
|
|
30
|
-
//
|
|
31
|
-
|
|
29
|
+
// Effect's own runner, given arguments instead of the process's, and what the commands read from
|
|
30
|
+
// the environment as a Layer, so the test decides whether a token is configured, whatever the
|
|
31
|
+
// shell holds. The console of `it.effect` keeps every line printed, so a run returns the lines
|
|
32
|
+
// it added.
|
|
33
|
+
const connected = (environment: Record<string, string>, args: ReadonlyArray<string>, stdin = "") =>
|
|
32
34
|
Effect.gen(function* () {
|
|
33
35
|
const before = (yield* TestConsole.logLines).length;
|
|
34
36
|
|
|
35
37
|
yield* Command.runWith(commands, { version: "0" })(args).pipe(
|
|
36
|
-
Effect.
|
|
37
|
-
HttpClient.HttpClient,
|
|
38
|
-
HttpClient.mapRequest(HttpClientRequest.bearerToken(token)),
|
|
39
|
-
),
|
|
38
|
+
Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnvRecord(environment))),
|
|
40
39
|
Effect.provide(Stdio.layerTest({ stdin: Stream.make(new TextEncoder().encode(stdin)) })),
|
|
41
40
|
Effect.provide(NodeServices.layer),
|
|
42
41
|
);
|
|
@@ -44,15 +43,9 @@ const run = (token: string, args: ReadonlyArray<string>, stdin = "") =>
|
|
|
44
43
|
return (yield* TestConsole.logLines).slice(before);
|
|
45
44
|
});
|
|
46
45
|
|
|
47
|
-
//
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
Command.runWith(commands.pipe(Command.provideEffect(HttpClient.HttpClient, connection)), {
|
|
51
|
-
version: "0",
|
|
52
|
-
})(args).pipe(
|
|
53
|
-
Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnvRecord(environment))),
|
|
54
|
-
Effect.provide(NodeServices.layer),
|
|
55
|
-
);
|
|
46
|
+
// A run as the caller `token` names.
|
|
47
|
+
const run = (token: string, args: ReadonlyArray<string>, stdin = "") =>
|
|
48
|
+
connected({ NOTES_TOKEN: token }, args, stdin);
|
|
56
49
|
|
|
57
50
|
const fitsAFile = (text: string) =>
|
|
58
51
|
new TextDecoder().decode(new TextEncoder().encode(text)) === text;
|
|
@@ -270,9 +263,10 @@ describe("the commands", () => {
|
|
|
270
263
|
const refused = yield* Effect.flip(connected({}, ["list-notes"]));
|
|
271
264
|
|
|
272
265
|
expect(refused).toBeInstanceOf(CliError.UserError);
|
|
266
|
+
expect(refused).toHaveProperty("cause._tag", "MissingToken");
|
|
273
267
|
expect(refused).toHaveProperty(
|
|
274
268
|
"userMessage",
|
|
275
|
-
"NOTES_TOKEN is not set. Set it to your token: export NOTES_TOKEN=<your token>",
|
|
269
|
+
"MissingToken: NOTES_TOKEN is not set. Set it to your token: export NOTES_TOKEN=<your token>",
|
|
276
270
|
);
|
|
277
271
|
|
|
278
272
|
// What a person sees: that message, once, on stderr, and no report of a `ConfigError`.
|
|
@@ -280,7 +274,7 @@ describe("the commands", () => {
|
|
|
280
274
|
expect.stringContaining("NOTES_TOKEN is not set."),
|
|
281
275
|
]);
|
|
282
276
|
|
|
283
|
-
// The token is read when a command runs, so
|
|
277
|
+
// The token is read when a command runs, so help needs none.
|
|
284
278
|
yield* connected({}, ["list-notes", "--help"]);
|
|
285
279
|
|
|
286
280
|
expect(yield* TestConsole.logLines).toHaveLength(2);
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { expect, it } from "@effect/vitest";
|
|
2
|
+
import { Arbitrary, Array, Effect, References, Schema } 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 { CreateNote, RequestConflict } from "../../src/notes/Notes.js";
|
|
8
|
+
import { NoteStore } from "../../src/notes/store.js";
|
|
9
|
+
|
|
10
|
+
const as = (user: keyof typeof users) => Effect.provideService(CurrentUser, users[user]);
|
|
11
|
+
|
|
12
|
+
it.effect("creates a new note on every call without a request key", () =>
|
|
13
|
+
Effect.gen(function* () {
|
|
14
|
+
const client = yield* Action.client(notes);
|
|
15
|
+
const input = { title: "Plan", body: "Draft" };
|
|
16
|
+
const first = yield* client.createNote(input);
|
|
17
|
+
const second = yield* client.createNote(input);
|
|
18
|
+
|
|
19
|
+
expect(second.id).not.toBe(first.id);
|
|
20
|
+
expect(yield* client.listNotes()).toEqual({
|
|
21
|
+
notes: [
|
|
22
|
+
{ id: first.id, title: input.title },
|
|
23
|
+
{ id: second.id, title: input.title },
|
|
24
|
+
],
|
|
25
|
+
});
|
|
26
|
+
}).pipe(as("ada"), Effect.provide(NoteStore.layerMemory)),
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
it.effect.prop(
|
|
30
|
+
"replays a creation with the same key and content",
|
|
31
|
+
{
|
|
32
|
+
input: Arbitrary.schema(
|
|
33
|
+
Schema.Struct({
|
|
34
|
+
...CreateNote.input.fields,
|
|
35
|
+
requestId: Schema.requiredKey(CreateNote.input.fields.requestId),
|
|
36
|
+
}),
|
|
37
|
+
),
|
|
38
|
+
},
|
|
39
|
+
({ input }) =>
|
|
40
|
+
Effect.gen(function* () {
|
|
41
|
+
const client = yield* Action.client(notes);
|
|
42
|
+
const first = yield* client.createNote(input);
|
|
43
|
+
const replay = yield* client.createNote(input);
|
|
44
|
+
|
|
45
|
+
expect(first).toMatchObject({ title: input.title, body: input.body, revision: 1 });
|
|
46
|
+
expect(replay).toEqual(first);
|
|
47
|
+
expect(yield* client.listNotes()).toEqual({
|
|
48
|
+
notes: [{ id: first.id, title: input.title }],
|
|
49
|
+
});
|
|
50
|
+
}).pipe(as("ada"), Effect.provide(NoteStore.layerMemory)),
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
it.effect.each([
|
|
54
|
+
{ title: "Changed", body: "Draft" },
|
|
55
|
+
{ title: "Plan", body: "Changed" },
|
|
56
|
+
])("refuses reuse of a key with changed content: %j", (changed) =>
|
|
57
|
+
Effect.gen(function* () {
|
|
58
|
+
const client = yield* Action.client(notes);
|
|
59
|
+
const input = { requestId: "plan", title: "Plan", body: "Draft" };
|
|
60
|
+
const first = yield* client.createNote(input);
|
|
61
|
+
|
|
62
|
+
expect(
|
|
63
|
+
yield* Effect.flip(client.createNote({ requestId: input.requestId, ...changed })),
|
|
64
|
+
).toEqual(new RequestConflict({ requestId: input.requestId }));
|
|
65
|
+
expect(yield* client.createNote(input)).toEqual(first);
|
|
66
|
+
expect(yield* client.listNotes()).toEqual({ notes: [{ id: first.id, title: "Plan" }] });
|
|
67
|
+
}).pipe(as("ada"), Effect.provide(NoteStore.layerMemory)),
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
it.effect("scopes keys to the caller, and treats a new key as a new creation", () =>
|
|
71
|
+
Effect.gen(function* () {
|
|
72
|
+
const client = yield* Action.client(notes);
|
|
73
|
+
const input = { requestId: "plan", title: "Plan", body: "Draft" };
|
|
74
|
+
const ada = yield* client.createNote(input).pipe(as("ada"));
|
|
75
|
+
const grace = yield* client.createNote({ ...input, body: "Grace's draft" }).pipe(as("grace"));
|
|
76
|
+
const another = yield* client.createNote({ ...input, requestId: "another" }).pipe(as("ada"));
|
|
77
|
+
|
|
78
|
+
expect(new Set([ada.id, grace.id, another.id]).size).toBe(3);
|
|
79
|
+
expect(yield* client.createNote(input).pipe(as("ada"))).toEqual(ada);
|
|
80
|
+
expect(yield* client.listNotes().pipe(as("ada"))).toEqual({
|
|
81
|
+
notes: [
|
|
82
|
+
{ id: ada.id, title: "Plan" },
|
|
83
|
+
{ id: another.id, title: "Plan" },
|
|
84
|
+
],
|
|
85
|
+
});
|
|
86
|
+
expect(yield* client.listNotes().pipe(as("grace"))).toEqual({
|
|
87
|
+
notes: [{ id: grace.id, title: "Plan" }],
|
|
88
|
+
});
|
|
89
|
+
}).pipe(Effect.provide(NoteStore.layerMemory)),
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
it.effect("retains the original result after the note changes or is deleted", () =>
|
|
93
|
+
Effect.gen(function* () {
|
|
94
|
+
const client = yield* Action.client(notes);
|
|
95
|
+
const input = { requestId: "plan", title: "Plan", body: "Draft" };
|
|
96
|
+
const original = yield* client.createNote(input);
|
|
97
|
+
|
|
98
|
+
yield* client.updateNote({ id: original.id, body: "Final", revision: 1 });
|
|
99
|
+
|
|
100
|
+
expect(yield* client.createNote(input)).toEqual(original);
|
|
101
|
+
expect(yield* client.getNote({ id: original.id })).toMatchObject({
|
|
102
|
+
body: "Final",
|
|
103
|
+
revision: 2,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
yield* client.deleteNote({ id: original.id });
|
|
107
|
+
|
|
108
|
+
expect(yield* client.createNote(input)).toEqual(original);
|
|
109
|
+
expect(yield* client.listNotes()).toEqual({ notes: [] });
|
|
110
|
+
}).pipe(as("ada"), Effect.provide(NoteStore.layerMemory)),
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
it.effect("creates one note for concurrent duplicates, including under frequent fiber yields", () =>
|
|
114
|
+
Effect.gen(function* () {
|
|
115
|
+
const client = yield* Action.client(notes);
|
|
116
|
+
const steps = Array.range(3, 60);
|
|
117
|
+
|
|
118
|
+
yield* Effect.forEach(steps, (limit) =>
|
|
119
|
+
Effect.gen(function* () {
|
|
120
|
+
const input = { requestId: `concurrent-${limit}`, title: "Plan", body: "Draft" };
|
|
121
|
+
|
|
122
|
+
const created = yield* Effect.all(Array.replicate(client.createNote(input), 3), {
|
|
123
|
+
concurrency: "unbounded",
|
|
124
|
+
}).pipe(Effect.provideService(References.MaxOpsBeforeYield, limit));
|
|
125
|
+
|
|
126
|
+
expect(created).toEqual(Array.replicate(created[0], 3));
|
|
127
|
+
}),
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
expect((yield* client.listNotes()).notes).toHaveLength(steps.length);
|
|
131
|
+
}).pipe(as("ada"), Effect.provide(NoteStore.layerMemory)),
|
|
132
|
+
);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from "@effect/vitest";
|
|
2
2
|
import { Effect, Layer } from "effect";
|
|
3
|
-
import { HttpClient, HttpClientRequest } from "effect/http";
|
|
3
|
+
import { HttpClient, HttpClientError, HttpClientRequest } from "effect/http";
|
|
4
4
|
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
5
5
|
import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
|
|
6
6
|
import * as ActionMcp from "@gjermundgaraba/effect-actions/ActionMcp";
|
|
@@ -84,4 +84,43 @@ describe("the service, over HTTP and MCP", () => {
|
|
|
84
84
|
expect(yield* response.text).toContain('"name":"createNote"');
|
|
85
85
|
}).pipe(Effect.provide(served)),
|
|
86
86
|
);
|
|
87
|
+
|
|
88
|
+
it.effect("retries a committed HTTP write over MCP after its response is lost", () =>
|
|
89
|
+
Effect.gen(function* () {
|
|
90
|
+
const http = yield* ActionHttp.client(Http, {
|
|
91
|
+
transformClient: (client) =>
|
|
92
|
+
client.pipe(
|
|
93
|
+
as("ada").transformClient,
|
|
94
|
+
// The real server finishes the write, but the caller receives no response.
|
|
95
|
+
HttpClient.transform((response, request) =>
|
|
96
|
+
response.pipe(
|
|
97
|
+
Effect.andThen(
|
|
98
|
+
Effect.fail(
|
|
99
|
+
new HttpClientError.HttpClientError({
|
|
100
|
+
reason: new HttpClientError.TransportError({
|
|
101
|
+
request,
|
|
102
|
+
description: "Response lost after commit",
|
|
103
|
+
}),
|
|
104
|
+
}),
|
|
105
|
+
),
|
|
106
|
+
),
|
|
107
|
+
),
|
|
108
|
+
),
|
|
109
|
+
),
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const mcp = yield* ActionMcp.client([GetStatus, ...Notes], as("ada"));
|
|
113
|
+
const input = { requestId: "plan", title: "Plan", body: "Draft" };
|
|
114
|
+
|
|
115
|
+
expect(yield* Effect.flip(http.createNote(input))).toBeInstanceOf(
|
|
116
|
+
HttpClientError.HttpClientError,
|
|
117
|
+
);
|
|
118
|
+
expect(yield* mcp.getStatus()).toEqual({ service: "notes-service", notes: 1 });
|
|
119
|
+
|
|
120
|
+
const replay = yield* mcp.createNote(input);
|
|
121
|
+
|
|
122
|
+
expect(replay).toMatchObject({ title: "Plan", body: "Draft", revision: 1 });
|
|
123
|
+
expect(yield* mcp.listNotes()).toEqual({ notes: [{ id: replay.id, title: "Plan" }] });
|
|
124
|
+
}).pipe(Effect.provide(served)),
|
|
125
|
+
);
|
|
87
126
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gjermundgaraba/effect-actions",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.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",
|
|
@@ -52,13 +52,13 @@
|
|
|
52
52
|
"@effect/platform-node": "4.0.4",
|
|
53
53
|
"@effect/vitest": "4.0.4",
|
|
54
54
|
"@modelcontextprotocol/client": "2.3.1",
|
|
55
|
-
"@types/node": "26.6.
|
|
55
|
+
"@types/node": "26.6.5",
|
|
56
56
|
"effect": "4.0.4",
|
|
57
57
|
"tsx": "4.23.15",
|
|
58
58
|
"typescript": "7.0.2",
|
|
59
|
-
"vite": "npm:@voidzero-dev/vite-plus-core@1.
|
|
60
|
-
"vite-plus": "1.
|
|
61
|
-
"vitest": "5.0.
|
|
59
|
+
"vite": "npm:@voidzero-dev/vite-plus-core@1.2.0",
|
|
60
|
+
"vite-plus": "1.2.0",
|
|
61
|
+
"vitest": "5.0.4"
|
|
62
62
|
},
|
|
63
63
|
"peerDependencies": {
|
|
64
64
|
"effect": "~4.0.4"
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
//#region \0rolldown/runtime.js
|
|
2
|
-
var __defProp = Object.defineProperty;
|
|
3
|
-
var __exportAll = (all, no_symbols) => {
|
|
4
|
-
let target = {};
|
|
5
|
-
for (var name in all) __defProp(target, name, {
|
|
6
|
-
get: all[name],
|
|
7
|
-
enumerable: true
|
|
8
|
-
});
|
|
9
|
-
if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
|
|
10
|
-
return target;
|
|
11
|
-
};
|
|
12
|
-
//#endregion
|
|
13
|
-
export { __exportAll };
|
package/dist/contract/rules.mjs
DELETED
|
@@ -1,100 +0,0 @@
|
|
|
1
|
-
import { builtIns } from "./errors.mjs";
|
|
2
|
-
import { Predicate, Schema, SchemaAST } from "effect";
|
|
3
|
-
//#region src/contract/rules.ts
|
|
4
|
-
const errorList = (error) => error === void 0 ? [] : Schema.isSchema(error) ? [error] : error;
|
|
5
|
-
const projectedErrors = (action, surface = []) => [.../* @__PURE__ */ new Set([
|
|
6
|
-
...builtIns,
|
|
7
|
-
...action.error,
|
|
8
|
-
...surface
|
|
9
|
-
])];
|
|
10
|
-
/**
|
|
11
|
-
* Every error a binding's call of `action` declares: the built-in ones, the action's, the
|
|
12
|
-
* binding's and, where `action` is protected, its authentication's.
|
|
13
|
-
*/
|
|
14
|
-
const bindingErrors = (action, binding) => projectedErrors(action, action.caller === Anyone || binding.authentication === void 0 ? binding.error : [...binding.error, ...binding.authentication.error]);
|
|
15
|
-
const assertAuthentication = (actions, authentication) => {
|
|
16
|
-
for (const action of actions) if (action.caller !== Anyone && (authentication === void 0 || action.caller.key !== authentication.identity.key)) throw new Error(`Protected action '${action.name}' requires its matching authentication descriptor`);
|
|
17
|
-
};
|
|
18
|
-
/**
|
|
19
|
-
* The caller of a public action, `caller: Action.Anyone`: anyone, signed in or not, and no
|
|
20
|
-
* authorizer runs for it. Registered globally, so two copies of the package agree on it, as they
|
|
21
|
-
* agree on a name.
|
|
22
|
-
*/
|
|
23
|
-
const Anyone = Symbol.for("@gjermundgaraba/effect-actions/Anyone");
|
|
24
|
-
const validName = /^[A-Za-z0-9_-]{1,128}$/;
|
|
25
|
-
const assertName = (what, name) => {
|
|
26
|
-
if (!validName.test(name) || name === "then") throw new Error(`Invalid ${what}: ${name}`);
|
|
27
|
-
};
|
|
28
|
-
const assertDistinct = (what, items, nameOf, claimantOf = nameOf) => {
|
|
29
|
-
const seen = /* @__PURE__ */ new Map();
|
|
30
|
-
for (const item of items) {
|
|
31
|
-
const name = nameOf(item);
|
|
32
|
-
const claimant = claimantOf(item);
|
|
33
|
-
const other = seen.get(name);
|
|
34
|
-
if (other !== void 0) {
|
|
35
|
-
const by = other === claimant ? "" : `, claimed by ${other} and ${claimant}`;
|
|
36
|
-
throw new Error(`Duplicate ${what}: ${name}${by}`);
|
|
37
|
-
}
|
|
38
|
-
seen.set(name, claimant);
|
|
39
|
-
}
|
|
40
|
-
};
|
|
41
|
-
const assertOnce = (what, actions) => assertDistinct(what, actions, (action) => action.name);
|
|
42
|
-
const assertKnown = (what, keys, names) => {
|
|
43
|
-
const unknown = keys.filter((key) => !names.includes(key));
|
|
44
|
-
if (unknown.length > 0) throw new Error(`Unknown ${what}: ${unknown.join(", ")}`);
|
|
45
|
-
};
|
|
46
|
-
const unsuspended = (ast) => SchemaAST.isSuspend(ast) ? unsuspended(ast.thunk()) : ast;
|
|
47
|
-
const members = (ast) => {
|
|
48
|
-
const resolved = unsuspended(ast);
|
|
49
|
-
return SchemaAST.isUnion(resolved) ? resolved.types.flatMap(members) : [resolved];
|
|
50
|
-
};
|
|
51
|
-
const literalValues = (ast) => SchemaAST.isLiteral(ast) ? [ast.literal] : SchemaAST.isEnum(ast) ? ast.enums.map(([, value]) => value) : [void 0];
|
|
52
|
-
const encodedTags = (ast) => members(SchemaAST.toEncoded(ast)).flatMap((member) => {
|
|
53
|
-
const tag = SchemaAST.isObjects(member) ? member.propertySignatures.find((property) => property.name === "_tag")?.type : void 0;
|
|
54
|
-
return tag === void 0 ? [] : members(tag).flatMap(literalValues).filter(Predicate.isString);
|
|
55
|
-
});
|
|
56
|
-
const builtInTags = new Set(builtIns.flatMap((error) => encodedTags(error.ast)));
|
|
57
|
-
const assertOwnTags = (what, errors) => {
|
|
58
|
-
const tag = errors.flatMap((error) => encodedTags(error.ast)).find((tag) => builtInTags.has(tag));
|
|
59
|
-
if (tag !== void 0) throw new Error(`${what}: error _tag "${tag}" is built in, and declared on every surface`);
|
|
60
|
-
};
|
|
61
|
-
/**
|
|
62
|
-
* The checks `ActionHttp.make` and `ActionRpc.make` share: each action once, no built-in error in
|
|
63
|
-
* `error`, and the descriptor every protected action names. Returns `error` as a list.
|
|
64
|
-
*/
|
|
65
|
-
const bindingOf = (what, actions, options) => {
|
|
66
|
-
assertOnce("action", actions);
|
|
67
|
-
const error = errorList(options.error);
|
|
68
|
-
assertOwnTags(what, error);
|
|
69
|
-
assertAuthentication(actions, options.authentication);
|
|
70
|
-
return {
|
|
71
|
-
error,
|
|
72
|
-
authentication: options.authentication
|
|
73
|
-
};
|
|
74
|
-
};
|
|
75
|
-
const httpApiStatusOf = SchemaAST.resolveAt("httpApiStatus");
|
|
76
|
-
const statusThroughSuspensions = (ast) => httpApiStatusOf(ast) ?? (SchemaAST.isSuspend(ast) ? statusThroughSuspensions(ast.thunk()) : void 0);
|
|
77
|
-
/**
|
|
78
|
-
* `error` as an endpoint declares it: each member of a union without a status of its own apart,
|
|
79
|
-
* each with its status, 422 without one.
|
|
80
|
-
*/
|
|
81
|
-
const declared = (error) => {
|
|
82
|
-
const status = statusThroughSuspensions(error.ast);
|
|
83
|
-
if (status !== void 0) return [httpApiStatusOf(error.ast) === void 0 ? error.annotate({ httpApiStatus: status }) : error];
|
|
84
|
-
const resolved = unsuspended(error.ast);
|
|
85
|
-
if (SchemaAST.isUnion(resolved) && resolved.checks === void 0 && resolved.encoding === void 0) return resolved.types.flatMap((member) => declared(Schema.make(member)));
|
|
86
|
-
return [error.annotate({ httpApiStatus: 422 })];
|
|
87
|
-
};
|
|
88
|
-
/**
|
|
89
|
-
* An action's MCP tool hints, MCP's defaults where its `mcp` leaves one out: a write destructive,
|
|
90
|
-
* a read not, no tool idempotent and every tool open-world. A given value passes through, as MCP
|
|
91
|
-
* calls `destructiveHint` and `idempotentHint` meaningless on a read-only tool.
|
|
92
|
-
*/
|
|
93
|
-
const hintsOf = ({ readOnly, mcp }) => ({
|
|
94
|
-
readOnlyHint: readOnly,
|
|
95
|
-
destructiveHint: mcp.destructiveHint ?? !readOnly,
|
|
96
|
-
idempotentHint: mcp.idempotentHint ?? false,
|
|
97
|
-
openWorldHint: mcp.openWorldHint ?? true
|
|
98
|
-
});
|
|
99
|
-
//#endregion
|
|
100
|
-
export { Anyone, assertAuthentication, assertDistinct, assertKnown, assertName, assertOnce, assertOwnTags, bindingErrors, bindingOf, declared, errorList, hintsOf, httpApiStatusOf, literalValues, members, projectedErrors, unsuspended };
|