@gjermundgaraba/effect-actions 0.16.0 → 0.17.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 (67) hide show
  1. package/dist/Action.d.mts +3 -5
  2. package/dist/Action.mjs +3 -1
  3. package/dist/ActionCli.d.mts +2 -2
  4. package/dist/ActionCli.mjs +2 -1
  5. package/dist/ActionHttp.d.mts +3 -2
  6. package/dist/ActionHttp.mjs +3 -1
  7. package/dist/ActionMcp.d.mts +1 -1
  8. package/dist/ActionMcp.mjs +4 -1
  9. package/dist/ActionRpc.d.mts +5 -3
  10. package/dist/ActionRpc.mjs +2 -1
  11. package/dist/ActionToolkit.d.mts +3 -2
  12. package/dist/ActionToolkit.mjs +3 -1
  13. package/dist/Authentication.d.mts +1 -1
  14. package/dist/Authentication.mjs +1 -1
  15. package/dist/authentication/provider.d.mts +2 -2
  16. package/dist/authentication/refusal.mjs +1 -1
  17. package/dist/cli/commands.d.mts +3 -2
  18. package/dist/cli/commands.mjs +2 -1
  19. package/dist/contract/ast.mjs +10 -0
  20. package/dist/contract/call.d.mts +1 -1
  21. package/dist/contract/caller.d.mts +9 -0
  22. package/dist/contract/caller.mjs +12 -0
  23. package/dist/contract/{rules.d.mts → declared.d.mts} +6 -10
  24. package/dist/contract/declared.mjs +42 -0
  25. package/dist/contract/hints.mjs +14 -0
  26. package/dist/contract/implementation.d.mts +4 -3
  27. package/dist/contract/implementation.mjs +4 -8
  28. package/dist/contract/names.mjs +25 -0
  29. package/dist/contract/status.mjs +18 -0
  30. package/dist/http/client.d.mts +3 -2
  31. package/dist/mcp/client.d.mts +40 -7
  32. package/dist/mcp/client.mjs +60 -17
  33. package/dist/mcp/protocol.mjs +1 -1
  34. package/docs/Action.md +17 -3
  35. package/docs/ActionCli.md +1 -1
  36. package/docs/ActionMcp.md +2 -2
  37. package/docs/ActionToolkit.md +25 -2
  38. package/docs/Authentication-failures.md +1 -1
  39. package/docs/CONTEXT.md +2 -1
  40. package/docs/README.md +4 -4
  41. package/docs/Testing.md +3 -1
  42. package/examples/README.md +10 -2
  43. package/examples/assistant/README.md +24 -20
  44. package/examples/assistant/src/agent.ts +11 -7
  45. package/examples/assistant/src/cli.ts +2 -2
  46. package/examples/assistant/src/platform/tasks-file.ts +38 -0
  47. package/examples/assistant/src/tasks/store.ts +6 -38
  48. package/examples/assistant/src/toolkit.ts +11 -1
  49. package/examples/assistant/test/{tasks/store.test.ts → platform/tasks-file.test.ts} +2 -1
  50. package/examples/mcp-tools/README.md +14 -11
  51. package/examples/mcp-tools/src/{printers/fake.ts → platform/fake-farm.ts} +2 -2
  52. package/examples/mcp-tools/src/platform/print-farm.ts +118 -0
  53. package/examples/mcp-tools/src/printers/farm.ts +6 -122
  54. package/examples/mcp-tools/src/server.ts +4 -4
  55. package/examples/mcp-tools/src/stdio.ts +4 -4
  56. package/examples/mcp-tools/test/printers/printers.test.ts +3 -3
  57. package/examples/mcp-tools/test/routes.test.ts +4 -4
  58. package/examples/notes-service/README.md +32 -0
  59. package/examples/notes-service/src/identity/authorize.ts +1 -0
  60. package/examples/notes-service/src/notes/Notes.ts +22 -5
  61. package/examples/notes-service/src/notes/handlers.ts +2 -2
  62. package/examples/notes-service/src/notes/store.ts +36 -6
  63. package/examples/notes-service/test/notes/idempotency.test.ts +132 -0
  64. package/examples/notes-service/test/routes.test.ts +40 -1
  65. package/package.json +5 -5
  66. package/dist/_virtual/_rolldown/runtime.mjs +0 -13
  67. package/dist/contract/rules.mjs +0 -100
@@ -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.16.0",
3
+ "version": "0.17.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.4",
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.1.0",
60
- "vite-plus": "1.1.0",
61
- "vitest": "5.0.3"
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 };
@@ -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 };