@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.
Files changed (76) hide show
  1. package/README.md +3 -1
  2. package/dist/Action.d.mts +3 -5
  3. package/dist/Action.mjs +3 -1
  4. package/dist/ActionCli.d.mts +31 -42
  5. package/dist/ActionCli.mjs +6 -16
  6. package/dist/ActionHttp.d.mts +12 -8
  7. package/dist/ActionHttp.mjs +3 -1
  8. package/dist/ActionMcp.d.mts +1 -1
  9. package/dist/ActionMcp.mjs +4 -1
  10. package/dist/ActionRpc.d.mts +5 -3
  11. package/dist/ActionRpc.mjs +2 -1
  12. package/dist/ActionToolkit.d.mts +3 -2
  13. package/dist/ActionToolkit.mjs +3 -1
  14. package/dist/Authentication.d.mts +1 -1
  15. package/dist/Authentication.mjs +1 -1
  16. package/dist/authentication/provider.d.mts +2 -2
  17. package/dist/authentication/refusal.mjs +1 -1
  18. package/dist/cli/commands.d.mts +4 -3
  19. package/dist/cli/commands.mjs +6 -5
  20. package/dist/contract/ast.mjs +10 -0
  21. package/dist/contract/call.d.mts +1 -1
  22. package/dist/contract/caller.d.mts +9 -0
  23. package/dist/contract/caller.mjs +12 -0
  24. package/dist/contract/{rules.d.mts → declared.d.mts} +6 -10
  25. package/dist/contract/declared.mjs +42 -0
  26. package/dist/contract/hints.mjs +14 -0
  27. package/dist/contract/implementation.d.mts +4 -3
  28. package/dist/contract/implementation.mjs +4 -8
  29. package/dist/contract/names.mjs +25 -0
  30. package/dist/contract/status.mjs +18 -0
  31. package/dist/http/client.d.mts +19 -9
  32. package/dist/http/client.mjs +6 -1
  33. package/dist/mcp/client.d.mts +52 -12
  34. package/dist/mcp/client.mjs +62 -19
  35. package/dist/mcp/protocol.mjs +1 -1
  36. package/docs/Action.md +17 -3
  37. package/docs/ActionCli-failures.md +10 -9
  38. package/docs/ActionCli.md +22 -21
  39. package/docs/ActionHttp-failures.md +6 -0
  40. package/docs/ActionHttp.md +9 -4
  41. package/docs/ActionMcp.md +3 -3
  42. package/docs/ActionToolkit.md +25 -2
  43. package/docs/Authentication-failures.md +1 -1
  44. package/docs/CONTEXT.md +2 -1
  45. package/docs/README.md +4 -4
  46. package/docs/Testing.md +3 -1
  47. package/examples/README.md +10 -2
  48. package/examples/assistant/README.md +24 -20
  49. package/examples/assistant/src/agent.ts +11 -7
  50. package/examples/assistant/src/cli.ts +2 -2
  51. package/examples/assistant/src/platform/tasks-file.ts +38 -0
  52. package/examples/assistant/src/tasks/store.ts +6 -38
  53. package/examples/assistant/src/toolkit.ts +11 -1
  54. package/examples/assistant/test/{tasks/store.test.ts → platform/tasks-file.test.ts} +2 -1
  55. package/examples/kitchen-sink/src/cli.ts +19 -21
  56. package/examples/mcp-tools/README.md +14 -11
  57. package/examples/mcp-tools/src/{printers/fake.ts → platform/fake-farm.ts} +2 -2
  58. package/examples/mcp-tools/src/platform/print-farm.ts +118 -0
  59. package/examples/mcp-tools/src/printers/farm.ts +6 -122
  60. package/examples/mcp-tools/src/server.ts +4 -4
  61. package/examples/mcp-tools/src/stdio.ts +4 -4
  62. package/examples/mcp-tools/test/printers/printers.test.ts +3 -3
  63. package/examples/mcp-tools/test/routes.test.ts +4 -4
  64. package/examples/notes-service/README.md +34 -2
  65. package/examples/notes-service/src/cli.ts +3 -9
  66. package/examples/notes-service/src/commands.ts +40 -36
  67. package/examples/notes-service/src/identity/authorize.ts +1 -0
  68. package/examples/notes-service/src/notes/Notes.ts +22 -5
  69. package/examples/notes-service/src/notes/handlers.ts +2 -2
  70. package/examples/notes-service/src/notes/store.ts +36 -6
  71. package/examples/notes-service/test/commands.test.ts +13 -19
  72. package/examples/notes-service/test/notes/idempotency.test.ts +132 -0
  73. package/examples/notes-service/test/routes.test.ts +40 -1
  74. package/package.json +5 -5
  75. package/dist/_virtual/_rolldown/runtime.mjs +0 -13
  76. 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: (owner: string, title: string, body: string) => Effect.Effect<Note>;
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: memory, lost when the process stops. A real store is another Layer of this
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
- Effect.sync(() => save(owner, { id: crypto.randomUUID(), title, body, revision: 1 })),
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, connection } from "../src/commands.js";
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. The console of `it.effect`
30
- // keeps every line printed, so a run returns the lines it added.
31
- const run = (token: string, args: ReadonlyArray<string>, stdin = "") =>
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.updateService(
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
- // The commands as cli.ts connects them, with what it reads from the environment given as a
48
- // Layer, so the test decides whether a token is configured, whatever the shell holds.
49
- const connected = (environment: Record<string, string>, args: ReadonlyArray<string>) =>
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 its help needs none.
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.16.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.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 };