@gjermundgaraba/effect-actions 0.18.1 → 0.19.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 (144) hide show
  1. package/README.md +20 -47
  2. package/dist/Action.d.mts +96 -47
  3. package/dist/Action.mjs +11 -14
  4. package/dist/ActionCli.d.mts +82 -43
  5. package/dist/ActionCli.mjs +53 -34
  6. package/dist/ActionHttp.d.mts +84 -42
  7. package/dist/ActionHttp.mjs +2 -2
  8. package/dist/ActionMcp.d.mts +54 -27
  9. package/dist/ActionMcp.mjs +5 -5
  10. package/dist/ActionRpc.d.mts +48 -35
  11. package/dist/ActionRpc.mjs +1 -1
  12. package/dist/ActionToolkit.d.mts +32 -13
  13. package/dist/ActionToolkit.mjs +10 -7
  14. package/dist/Authentication.d.mts +92 -40
  15. package/dist/Authentication.mjs +37 -35
  16. package/dist/Testing.d.mts +22 -24
  17. package/dist/authentication/provider.d.mts +1 -4
  18. package/dist/authentication/provider.mjs +1 -1
  19. package/dist/authentication/refusal.mjs +2 -1
  20. package/dist/cli/commands.d.mts +34 -11
  21. package/dist/cli/commands.mjs +47 -22
  22. package/dist/contract/caller.mjs +1 -1
  23. package/dist/contract/declared.mjs +27 -14
  24. package/dist/contract/errors.d.mts +42 -17
  25. package/dist/contract/errors.mjs +42 -17
  26. package/dist/contract/hints.mjs +3 -2
  27. package/dist/contract/implementation.d.mts +5 -8
  28. package/dist/contract/implementation.mjs +29 -17
  29. package/dist/contract/media.d.mts +6 -2
  30. package/dist/contract/media.mjs +6 -2
  31. package/dist/contract/names.mjs +14 -12
  32. package/dist/contract/status.mjs +3 -10
  33. package/dist/http/client.d.mts +15 -4
  34. package/dist/http/client.mjs +1 -1
  35. package/dist/mcp/client.d.mts +21 -8
  36. package/dist/mcp/client.mjs +6 -8
  37. package/docs/Action-failures.md +7 -16
  38. package/docs/Action.md +159 -191
  39. package/docs/ActionCli-failures.md +12 -92
  40. package/docs/ActionCli.md +102 -156
  41. package/docs/ActionHttp-failures.md +14 -76
  42. package/docs/ActionHttp.md +150 -176
  43. package/docs/ActionMcp-failures.md +2 -22
  44. package/docs/ActionMcp.md +79 -170
  45. package/docs/ActionRpc-failures.md +3 -12
  46. package/docs/ActionRpc.md +183 -120
  47. package/docs/ActionToolkit-failures.md +3 -10
  48. package/docs/ActionToolkit.md +112 -109
  49. package/docs/Authentication-advanced.md +167 -108
  50. package/docs/Authentication-failures.md +11 -91
  51. package/docs/Authentication.md +114 -135
  52. package/docs/README.md +47 -31
  53. package/docs/Testing-failures.md +2 -6
  54. package/docs/Testing.md +126 -159
  55. package/docs/setup-failures.md +8 -32
  56. package/docs/setup.md +48 -43
  57. package/examples/README.md +6 -10
  58. package/examples/assistant/README.md +20 -38
  59. package/examples/assistant/src/agent.ts +2 -3
  60. package/examples/assistant/src/cli.ts +4 -9
  61. package/examples/assistant/src/commands.ts +6 -4
  62. package/examples/assistant/src/identity/authorize.ts +3 -3
  63. package/examples/assistant/src/tasks/Tasks.ts +1 -2
  64. package/examples/assistant/src/tasks/handlers.ts +3 -3
  65. package/examples/assistant/src/toolkit.ts +6 -8
  66. package/examples/assistant/test/commands.test.ts +16 -11
  67. package/examples/assistant/test/tasks/tasks.test.ts +0 -2
  68. package/examples/assistant/test/toolkit.test.ts +0 -1
  69. package/examples/kitchen-sink/README.md +42 -64
  70. package/examples/kitchen-sink/src/Actions.ts +2 -3
  71. package/examples/kitchen-sink/src/Api.ts +3 -4
  72. package/examples/kitchen-sink/src/admin/Admin.ts +1 -1
  73. package/examples/kitchen-sink/src/admin/authenticate.ts +3 -3
  74. package/examples/kitchen-sink/src/admin/authorize.ts +1 -2
  75. package/examples/kitchen-sink/src/agent.ts +1 -1
  76. package/examples/kitchen-sink/src/assistant/handlers.ts +1 -6
  77. package/examples/kitchen-sink/src/attachments/Attachments.ts +4 -8
  78. package/examples/kitchen-sink/src/attachments/download.ts +5 -8
  79. package/examples/kitchen-sink/src/attachments/handlers.ts +2 -3
  80. package/examples/kitchen-sink/src/browser.ts +3 -4
  81. package/examples/kitchen-sink/src/cli.ts +6 -7
  82. package/examples/kitchen-sink/src/config.ts +2 -0
  83. package/examples/kitchen-sink/src/identity/Identity.ts +7 -7
  84. package/examples/kitchen-sink/src/identity/authenticate.ts +3 -5
  85. package/examples/kitchen-sink/src/identity/authorize.ts +4 -3
  86. package/examples/kitchen-sink/src/identity/quota.ts +1 -1
  87. package/examples/kitchen-sink/src/issues/Issues.ts +2 -2
  88. package/examples/kitchen-sink/src/issues/assistant.ts +3 -3
  89. package/examples/kitchen-sink/src/issues/guide.ts +2 -3
  90. package/examples/kitchen-sink/src/ops.ts +3 -6
  91. package/examples/kitchen-sink/src/request-policy.ts +3 -3
  92. package/examples/kitchen-sink/src/routes.ts +4 -6
  93. package/examples/kitchen-sink/src/seed.ts +0 -1
  94. package/examples/kitchen-sink/src/server.ts +11 -12
  95. package/examples/kitchen-sink/src/stdio.ts +4 -2
  96. package/examples/kitchen-sink/src/workspaces/tenant.ts +3 -1
  97. package/examples/kitchen-sink/test/admin/admin.test.ts +0 -1
  98. package/examples/kitchen-sink/test/assistant/assistant.test.ts +1 -1
  99. package/examples/kitchen-sink/test/attachments/attachments.test.ts +1 -2
  100. package/examples/kitchen-sink/test/identity/identity.test.ts +0 -1
  101. package/examples/kitchen-sink/test/issues/issues.test.ts +0 -2
  102. package/examples/kitchen-sink/test/request-policy.test.ts +1 -1
  103. package/examples/kitchen-sink/test/routes.test.ts +3 -3
  104. package/examples/live-rpc/README.md +8 -49
  105. package/examples/live-rpc/src/browser.ts +8 -41
  106. package/examples/live-rpc/src/chat/Chat.ts +9 -3
  107. package/examples/live-rpc/src/chat/room.ts +1 -1
  108. package/examples/live-rpc/src/client.ts +2 -3
  109. package/examples/live-rpc/src/page.ts +34 -0
  110. package/examples/live-rpc/src/routes.ts +6 -7
  111. package/examples/live-rpc/src/server.ts +6 -4
  112. package/examples/live-rpc/test/browser.test.ts +6 -5
  113. package/examples/live-rpc/test/routes.test.ts +16 -15
  114. package/examples/live-rpc/test/server.test.ts +1 -1
  115. package/examples/mcp-tools/README.md +22 -45
  116. package/examples/mcp-tools/src/printers/Printers.ts +3 -7
  117. package/examples/mcp-tools/src/routes.ts +0 -7
  118. package/examples/mcp-tools/src/server.ts +1 -1
  119. package/examples/mcp-tools/test/printers/printers.test.ts +2 -3
  120. package/examples/mcp-tools/test/routes.test.ts +2 -3
  121. package/examples/notes-service/README.md +10 -44
  122. package/examples/notes-service/src/cli.ts +0 -3
  123. package/examples/notes-service/src/commands.ts +7 -17
  124. package/examples/notes-service/src/identity/authorize.ts +2 -5
  125. package/examples/notes-service/src/identity/verifier.ts +3 -4
  126. package/examples/notes-service/src/notes/Notes.ts +2 -6
  127. package/examples/notes-service/src/notes/store.ts +5 -1
  128. package/examples/notes-service/src/routes.ts +6 -8
  129. package/examples/notes-service/src/server.ts +4 -3
  130. package/examples/notes-service/src/stdio.ts +5 -3
  131. package/examples/notes-service/test/commands.test.ts +1 -1
  132. package/examples/notes-service/test/notes/as.ts +6 -0
  133. package/examples/notes-service/test/notes/idempotency.test.ts +1 -4
  134. package/examples/notes-service/test/notes/in-process.test.ts +50 -0
  135. package/examples/notes-service/test/notes/notes.test.ts +3 -42
  136. package/examples/notes-service/test/routes.test.ts +2 -2
  137. package/examples/quickstart/README.md +1 -1
  138. package/examples/quickstart/src/client.ts +1 -0
  139. package/examples/quickstart/src/server.ts +2 -4
  140. package/examples/quickstart/test/{greeting/greeting.test.ts → routes.test.ts} +3 -3
  141. package/package.json +5 -5
  142. package/docs/Action-in-process.md +0 -93
  143. package/docs/CONTEXT.md +0 -46
  144. package/docs/guarantees.md +0 -44
package/README.md CHANGED
@@ -4,21 +4,16 @@ Define an Effect action once. Serve it over HTTP, MCP and Effect RPC, hand it to
4
4
  native Toolkit, and run it from a CLI. Same schemas, same handler, Effect's own servers and clients
5
5
  underneath.
6
6
 
7
- **If you are a coding agent**, start at [docs/README.md](docs/README.md), not here, and read
8
- one page per tool call, again alone if its output says it was truncated. It routes every task,
9
- protecting an action and limiting request bodies included, to its page and its example.
7
+ **If you are a coding agent**, start at [docs/README.md](docs/README.md), not here. It routes
8
+ every task, protecting an action and limiting request bodies included, to its page and its
9
+ example.
10
10
 
11
11
  ## Looks like this
12
12
 
13
13
  A contract says what an action takes and answers, and who may call it:
14
14
 
15
- ```ts example=quickstart/src/greeting/Greeting.ts
16
- import { Schema } from "effect";
17
- import * as Action from "@gjermundgaraba/effect-actions/Action";
18
-
19
- // A contract: what the action takes and answers, whether it changes anything, and who may
20
- // call it. It imports no server code, so any client can import it, a browser page included.
21
- export const Greet = Action.make("greet", {
15
+ ```ts
16
+ const Greet = Action.make("greet", {
22
17
  description: "Greet someone by name.",
23
18
  input: { name: Schema.String },
24
19
  success: Schema.String,
@@ -29,43 +24,27 @@ export const Greet = Action.make("greet", {
29
24
 
30
25
  An implementation binds it to its handler:
31
26
 
32
- ```ts example=quickstart/src/greeting/handlers.ts
33
- import { Effect } from "effect";
34
- import * as Action from "@gjermundgaraba/effect-actions/Action";
35
- import { Greet } from "./Greeting.js";
36
-
37
- // The implementation: the contract bound to its handler. Every surface takes this one value.
38
- export const greeting = Action.implement(Greet, ({ name }) => Effect.succeed(`Hello, ${name}!`));
27
+ ```ts
28
+ const greeting = Action.implement(Greet, ({ name }) => Effect.succeed(`Hello, ${name}!`));
39
29
  ```
40
30
 
41
31
  A binding gives it a route, for the server to serve and a client to call:
42
32
 
43
- ```ts example=quickstart/src/Api.ts
44
- import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
45
- import { Greet } from "./greeting/Greeting.js";
46
-
47
- // The HTTP binding: `POST /api/greet`. The server serves it and the client calls it.
48
- export const Http = ActionHttp.make([Greet]);
33
+ ```ts
34
+ const Http = ActionHttp.make([Greet]); // POST /api/greet
49
35
  ```
50
36
 
51
37
  And the same implementation is served on every surface the host wants:
52
38
 
53
- ```ts example=quickstart/src/routes.ts
54
- import { Layer } from "effect";
55
- import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
56
- import * as ActionMcp from "@gjermundgaraba/effect-actions/ActionMcp";
57
- import { Http } from "./Api.js";
58
- import { greeting } from "./greeting/handlers.js";
59
-
60
- // One implementation, two surfaces: a JSON route and an MCP tool at `/mcp`.
61
- export const routes = Layer.mergeAll(
39
+ ```ts
40
+ const routes = Layer.mergeAll(
62
41
  ActionHttp.layer(Http, greeting),
63
42
  ActionMcp.layerHttp(greeting, { name: "greetings", version: "1.0.0" }),
64
43
  );
65
44
  ```
66
45
 
67
46
  Serve `routes` with Effect's `HttpRouter.serve` and a platform server, as
68
- [examples/quickstart/src/server.ts](examples/quickstart/src/server.ts) does, and you have
47
+ [examples/quickstart](examples/quickstart) does, file by file, and you have
69
48
  `POST /api/greet` and an MCP tool named `greet` at `/mcp`. The same `greeting`
70
49
  implementation is also:
71
50
 
@@ -82,10 +61,10 @@ const rpc = ActionRpc.layer(ActionRpc.make([Greet]), greeting); // Effect RPC, o
82
61
  And the client is Effect's own `HttpApiClient`, typed from the same contract, one method per action:
83
62
 
84
63
  ```ts
85
- const greeting = Effect.gen(function* () {
64
+ Effect.gen(function* () {
86
65
  const client = yield* ActionHttp.client(Http, { baseUrl });
87
66
 
88
- return yield* client.greet({ name: "Ada" });
67
+ return yield* client.greet({ name: "Ada" }); // "Hello, Ada!"
89
68
  });
90
69
  ```
91
70
 
@@ -95,7 +74,7 @@ const greeting = Effect.gen(function* () {
95
74
  - A handler can fail only with the errors its action declares and three built-in ones (400, 401, 403), and every client decodes them as typed values.
96
75
  - Every contract states who may call it: `caller: Action.Anyone`, or the identity service a caller must have. A surface authenticates a protected action's request before decoding its input, with the provider the application builds from its own token verifier.
97
76
  - Authorization is written once, on the implementation: `Action.implement(actions, handlers, { authorize })`, required for protected actions. Every surface runs it before each handler, and the rule reads the contract's `readOnly` instead of a hand-maintained list of mutation names.
98
- - Public and protected routes share one binding, one OpenAPI document and one client, and a handler's startup services are built once per [layer graph](docs/Action.md#implement-actions), however many surfaces serve it.
77
+ - Public and protected routes share one binding, one OpenAPI document and one client, and a handler's startup services are built once per [layer graph](docs/Action.md#share-one-build-across-servers), however many surfaces serve it.
99
78
  - The pieces are Effect's own: a native `HttpApi`, `McpServer`, `RpcServer` and `RpcClient`, with no SDK runtime dependency, so OpenAPI, Swagger, Scalar and `HttpApiClient` work unchanged.
100
79
  - Tests run in memory: `Action.client` calls an implementation in process, and `Testing.layer(routes)` serves the routes and tools to typed clients without opening a port.
101
80
 
@@ -106,7 +85,7 @@ pnpm add @gjermundgaraba/effect-actions effect@~4.0.4
106
85
  ```
107
86
 
108
87
  It needs TypeScript 7 or newer and is ESM only
109
- ([docs/setup.md](docs/setup.md#install)). Add `@effect/platform-node@~4.0.4` to serve or run a CLI from Node. Every module is a
88
+ ([docs/setup.md](docs/setup.md#install)). Add `@effect/platform-node@~4.0.4` to serve or run a CLI from Node, and `@types/node` for its `node:` imports. Every module is a
110
89
  subpath import (`.../Action`, `.../ActionHttp`, ...); there is no package root, so a
111
90
  contracts-only bundle never loads a server.
112
91
 
@@ -130,13 +109,8 @@ type-checked and tested with the library and ship in the package.
130
109
  ## Docs
131
110
 
132
111
  [docs/](docs/README.md) is written for coding agents: one short page per module, organized by
133
- task, each linking to the example files that show the task working.
134
-
135
- - [docs/README.md](docs/README.md): start here: which page answers which task, and which
136
- surface to use for which caller.
137
- - [docs/guarantees.md](docs/guarantees.md): what each case looks like on the wire, on every
138
- surface, and what the library leaves to the application.
139
- - [docs/CONTEXT.md](docs/CONTEXT.md): the vocabulary the docs and the code use.
112
+ task, each linking to the example files that show the task working. Start at
113
+ [docs/README.md](docs/README.md).
140
114
 
141
115
  ## For agents
142
116
 
@@ -169,9 +143,8 @@ the pages of the installed version are beside them, in
169
143
 
170
144
  ## Status
171
145
 
172
- The `effect` peer admits Effect 4.0.x patches from the oldest whose APIs the package uses, since
173
- the modules the surfaces build on may change in a minor release (see [docs/setup.md](docs/setup.md#install)). Actions are unary JSON over HTTP, MCP and Effect RPC, whose protocols speak JSON alone: no streaming or uploads.
174
- Token verification and the authorization rule belong to the application; the library supplies the authentication descriptor and provider, runs the rule, and answers the refusals. See [docs/guarantees.md](docs/guarantees.md#scope).
146
+ The `effect` peer range is in [docs/setup.md](docs/setup.md#install). Actions are unary JSON over HTTP, MCP and Effect RPC, whose protocols speak JSON alone: no streaming or uploads.
147
+ Token verification and the authorization rule belong to the application; the library supplies the authentication descriptor and provider, runs the rule, and answers the refusals. See [what the library leaves to you](docs/README.md#what-the-library-leaves-to-you).
175
148
 
176
149
  ## Acknowledgements
177
150
 
package/dist/Action.d.mts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Anyone } from "./contract/caller.mjs";
2
- import { BuiltIn, BuiltIns, Forbidden, InvalidInput, Issue, Refusal, Unauthenticated } from "./contract/errors.mjs";
2
+ import { BuiltIn, Forbidden, InvalidInput, Issue, Refusal, Unauthenticated } from "./contract/errors.mjs";
3
3
  import { ActionOf, AnyImplementation, Authorize, BuildError, BuildServices, Holding, Implementation, Known, Member, Offered, OptionalUnless, Protected, RequestOf, SelectedOf, Served, Serving } from "./contract/implementation.mjs";
4
4
  import { Call } from "./contract/call.mjs";
5
5
  import { Image } from "./contract/media.mjs";
@@ -40,28 +40,64 @@ export interface Mcp {
40
40
  export interface Options {
41
41
  /**
42
42
  * Who may call the action: `Action.Anyone`, or the identity service a caller must have,
43
- * which every surface enforces. Required: an action that names no caller is not public.
43
+ * which every surface enforces whether or not the handler reads it. Required: an action that
44
+ * names no caller is not public. A remote surface gets the identity by authenticating the
45
+ * request; on a local surface the host provides it.
46
+ *
47
+ * **Gotchas**
48
+ *
49
+ * - Declare the identity as a `Context.Service`, in a contract file, apart from its verifier.
50
+ * - One identity per action, and every protected action one remote surface serves names the same one; a tenant is a request-time requirement of the handler, not a second identity.
51
+ * - No surface provides a public action an identity: an action whose answer depends on the caller is protected.
52
+ * - Never provide an identity at startup, to a surface's layer: a call no authentication reaches, a public action's or a local surface's, runs as that identity, and the types do not stop it.
44
53
  */
45
- readonly caller: typeof Anyone | Context.Key<unknown, unknown>;
54
+ readonly caller: typeof Anyone | (Context.Key<unknown, unknown> & {
55
+ readonly defaultValue?: "An identity is a Context.Service, not a Context.Reference";
56
+ });
57
+ /** A model reads it beside the tool; a CLI prints it in `--help`. */
46
58
  readonly description: string;
47
59
  /**
48
60
  * A schema or struct fields. Omit, or give `{}`, for an action without arguments. A field's
49
61
  * `description` annotation is what a model reads in a tool's listing and a flag's help in a CLI.
62
+ *
63
+ * **Gotchas**
64
+ *
65
+ * - Never `Schema.Struct({})` for no arguments: it accepts any value but `null` and `undefined`, and MCP refuses it as a tool's input. Omit `input`: the action then takes `{}`.
66
+ * - A field callers may omit is `Schema.optional`: on the wire `null` is also accepted and read as left out, as a tool's schema lists, while a typed client refuses `null`. `Schema.optionalKey` takes omission alone; `Schema.NullOr` keeps `null` as a value.
67
+ * - A field with a decoding default, `Schema.withDecodingDefault`, is still required in a typed client's argument, whose type is the decoded one; the argument itself may be left out only when every field has one, as `{}` then encodes.
68
+ * - An input that is a class encodes only its instances: a typed client given a plain object fails with `InvalidInput`, `Expected <Class>`.
69
+ * - A `Schema.makeFilter` check adds nothing to a tool's listed input schema, and `Schema.isPattern` its `pattern` only for a RegExp with the `u` flag: say such a rule in the field's `description`, annotated before the check, since after a custom one it is lost too.
50
70
  */
51
71
  readonly input?: Codec | Fields | undefined;
52
- /** A schema or struct fields. Omit for an action that returns nothing: `Schema.Void`. */
72
+ /**
73
+ * A schema or struct fields. Omit for an action that returns nothing, `Schema.Void`; what each
74
+ * surface answers is on its `layer`, `layerHttp`, `runStdio`, `make` or `command`. A top-level field whose schema is
75
+ * `Action.Image` is a media field: see `Action.Image`.
76
+ */
53
77
  readonly success?: Codec | Fields | undefined;
54
78
  /**
55
79
  * The declared failures: one schema, or a list of them, as `HttpApiEndpoint` takes them;
56
80
  * each keeps its own HTTP status annotation, the third argument of
57
- * `Schema.TaggedError<NoteNotFound>()("NoteNotFound", { id: Schema.String }, { httpApiStatus: 404 })`,
58
- * and answers 422 without one. Defaults to none. None with a built-in error's `_tag`.
81
+ * `Schema.TaggedError<NoteNotFound>()("NoteNotFound", { id: Schema.String }, { httpApiStatus: 404 })`.
82
+ * Defaults to none. A handler fails only with these and the built-in errors; every client
83
+ * decodes each as a typed value.
84
+ *
85
+ * **Gotchas**
86
+ *
87
+ * - Over HTTP a declared error answers with its `httpApiStatus`, 422 without one; what the other surfaces answer is on their `layerHttp`, `runStdio`, `layer`, `make` or `command`.
88
+ * - Two schemas sharing a `_tag` throw at `make`, `error _tag "<tag>" names two error schemas`, or at a binding's `make`, `binding of "<name>"`, for one of its errors against the action's or the authentication's; the message says the fix.
89
+ * - A schema reached twice, listed again, through a union or `Schema.suspend`, is one schema; a union is its members unless it has its own `httpApiStatus`, checks or a transformation, so each member's `_tag` is checked against every other error's.
90
+ * - Errors without a `_tag` are told apart by their other fields, the first that decodes winning: a failure an earlier schema also accepts is decoded as that one.
91
+ * - An entry that is a `Schema.suspend` whose thunk reads a `const` declared further down throws a `ReferenceError` at `make`, which reads every `_tag` when called: declare the error first.
92
+ * - A failure the schemas reject, undeclared or breaking a check, is the call's defect on every surface, beside the `SchemaError` saying why; a class error built with `disableChecks` is accepted and fails to encode instead.
59
93
  */
60
94
  readonly error?: Codec | ReadonlyArray<Codec> | undefined;
61
95
  /**
62
96
  * Whether the action leaves its resource unchanged. Required: an action nobody classified
63
97
  * is the one a reviewer must check. An implementation's `authorize` may read it, and MCP's
64
- * `readOnlyHint` is it; the library itself authorizes nothing with it.
98
+ * `readOnlyHint` is it; the library itself authorizes nothing with it. Derive authorization
99
+ * from it, never from an `mcp` hint: changing an action's `readOnly` then changes who may
100
+ * call it.
65
101
  */
66
102
  readonly readOnly: boolean;
67
103
  /** How the action's tool presents itself over MCP, and on a native Toolkit's tools. */
@@ -74,24 +110,16 @@ type KnownMcp<O> = {
74
110
  readonly mcp?: { readonly [K in UnknownMcp<O>]: never; };
75
111
  };
76
112
  type ErrorMembers<G> = G extends ReadonlyArray<infer E> ? E : G;
77
- type OwnErrors<O> = O extends {
78
- readonly error: infer G;
79
- } ? [Extract<ErrorMembers<G>, BuiltIns>] extends [never] ? unknown : {
80
- readonly error: Exclude<ErrorMembers<G>, BuiltIns> | ReadonlyArray<Exclude<ErrorMembers<G>, BuiltIns>>;
81
- } : unknown;
82
113
  type OnlyOptions<O> = { readonly [K in Exclude<O extends unknown ? keyof O : never, keyof Options>]: never; };
83
- type ServiceCaller<O> = O extends {
84
- readonly caller: {
85
- readonly defaultValue: unknown;
86
- };
87
- } ? {
88
- readonly caller: "An identity is a Context.Service, not a Context.Reference";
89
- } : unknown;
90
- type Rules<O> = Options extends O ? unknown : OnlyOptions<O> & ServiceCaller<O> & OwnErrors<O> & KnownMcp<O>;
114
+ type Rules<O> = Options extends O ? unknown : OnlyOptions<O> & KnownMcp<O>;
91
115
  type OptionOf<O, K extends keyof Options, Default> = O extends unknown ? K extends keyof O ? Exclude<O[K], undefined> | ({} extends Pick<O, K> ? Default : undefined extends O[K] ? Default : never) : Default : never;
92
116
  type SchemaOf<O, K extends "input" | "success", Default extends Codec> = CodecOf<Extract<OptionOf<O, K, Default>, Codec | Fields>>;
93
117
  type ErrorSchemasOf<O> = ReadonlyArray<ErrorMembers<Extract<OptionOf<O, "error", []>, Codec | ReadonlyArray<Codec>>>>;
94
- /** A pure contract: schemas and transport metadata. Handlers are bound by `implement`. */
118
+ /**
119
+ * A pure contract: schemas and transport metadata, bound to handlers by `implement`. Its
120
+ * `name`, `input`, `success`, `error` and `readOnly` are public: reuse them, as in
121
+ * `Schema.Array(SaveLink.input)`.
122
+ */
95
123
  export interface Action<Name extends string, Input extends Codec, Success extends Codec, Errors extends ReadonlyArray<Codec>, ReadOnly extends boolean = boolean, Caller extends Options["caller"] = Options["caller"]> {
96
124
  readonly caller: Caller;
97
125
  readonly name: Name;
@@ -124,9 +152,16 @@ export type Handler<A extends Any, R = never> = (input: A["input"]["Type"]) => E
124
152
  */
125
153
  export type Handlers<A extends ReadonlyArray<Any>, R = never> = { readonly [Action in A[number] as Action["name"]]: Handler<Action, R>; };
126
154
  /**
127
- * Define an action contract. Names are `[A-Za-z0-9_-]{1,128}`, other than `then`: the name
128
- * is also the route segment, the client method and the tool name.
155
+ * Define an action contract: what it takes and answers, whether it changes anything, and who
156
+ * may call it. The name is also the route segment, the client method and the tool name.
129
157
  * Guide: `docs/Action.md` in this package; in an application: `examples/notes-service/src/notes/`.
158
+ *
159
+ * **Gotchas**
160
+ *
161
+ * - A name is unique within each binding, MCP endpoint, Toolkit, CLI and client holding it; its form is in the message `Invalid action name` throws.
162
+ * - A contract says nothing about where it is served: an HTTP or RPC binding, or a surface given `actions`, serves an action only once it is listed; MCP, a Toolkit and a CLI given implementations alone serve every action they hold.
163
+ * - Surfaces match an implementation to a contract by object identity, never by name: implement, bind and list the one exported value. A list narrowed at run time is still typed as holding every member.
164
+ * - In a helper whose type parameter stands for `mcp`, `make` refuses the option, since it cannot check the hints: type the parameter `Action.Mcp`, or spread a constant.
130
165
  */
131
166
  export declare function make<const Name extends string, const O extends Options>(name: Name, options: O & NoInfer<Rules<O>>): Action<Name, SchemaOf<O, "input", typeof NoInput>, SchemaOf<O, "success", typeof Schema.Void>, ErrorSchemasOf<O>, O["readOnly"], O["caller"]>;
132
167
  /**
@@ -152,21 +187,25 @@ type HandlersOf<T extends Target, R, RequestServices> = Single<T, R> | Several<T
152
187
  type Deferred<T> = [T][T extends unknown ? 0 : never];
153
188
  type Authorizer<A extends Any, RA, EAX, RAX> = ((action: A) => Effect.Effect<void, Refusal, RA>) | Effect.Effect<(action: A) => Effect.Effect<void, Refusal, RA>, EAX, RAX>;
154
189
  type Implemented<T extends Target, RequestServices, R, EX, RX, RA, EAX, RAX> = Implementation<ActionsOf<T>, { readonly [K in NamesOf<T>]: Exclude<T extends ReadonlyArray<Any> ? RequestServices[K & keyof RequestServices] : R, Scope.Scope>; }, Deferred<EX>, Deferred<Exclude<RX, Scope.Scope>>, Exclude<RA, Scope.Scope>, Deferred<EAX>, Deferred<Exclude<RAX, Scope.Scope>>>;
155
- /**
156
- * Every authenticated caller may call a protected action. The contract's identity
157
- * requirement is enforced independently, including when its handler reads no identity.
158
- */
190
+ /** Every authenticated caller may call a protected action. */
159
191
  export declare const allowAll: Authorize<Any>;
160
192
  /**
161
- * Bind handlers to contracts, behind authorization. Pass one action and its handler, or a list of
162
- * actions and a record of handlers keyed by action name. Either may instead be an Effect
163
- * that builds them: its services are startup requirements, resolved once per layer graph
164
- * however many surfaces serve the result, while services a handler yields are per-request
165
- * requirements.
193
+ * Bind handlers to contracts, behind authorization: one action and its handler, or a list of
194
+ * actions and a record of handlers keyed by action name, given directly or built by an Effect
195
+ * whose services are startup requirements, while what a handler yields is a request-time
196
+ * requirement. `authorize` is required when the list holds a protected contract and runs only
197
+ * for those.
198
+ *
199
+ * **Gotchas**
166
200
  *
167
- * `authorize` is required when the target includes protected contracts and runs only for
168
- * them. `Action.allowAll` permits every authenticated caller. An Effect may build the
169
- * authorizer once, with startup requirements separate from its per-call requirements.
201
+ * - A builder runs once per layer graph, however many surfaces serve its implementation; two `implement` calls are two builders, even of one builder Effect.
202
+ * - What the builder yields is shared by every call; what a handler yields is resolved per call, as the caller must be. A builder yielding the caller is refused where the server is launched.
203
+ * - Each call has a scope of its own: a resource that must outlive a call is the builder's.
204
+ * - Pass the builder to `implement` itself: through `.pipe(...)` or a wrapper such as `Effect.withSpan`, an `Effect.fn` handler it returns takes an `any` input. A builder written apart returns its record `satisfies Action.Handlers<typeof actions>`.
205
+ * - A record holds one own function per action of the list and no other key, or `implement` throws, a builder's when its layer builds; an inherited method does not count.
206
+ * - Calling the function you passed to `implement` skips authorization and the codecs: call the implementation through `Action.client`.
207
+ * - What a plain `authorize` yields is a request-time requirement of every surface; to read a store once, make `authorize` an Effect that yields it and returns the function.
208
+ * - An authorizer fails only with `Unauthenticated` or `Forbidden`; a limit callers should see, such as a rate limit, is the handler's, declared in the action's `error`.
170
209
  */
171
210
  export declare function implement<const T extends Target, RequestServices extends (T extends ReadonlyArray<Any> ? { readonly [K in NamesOf<T>]: unknown; } : never), R = never, EX = never, RX = never, RA = never, EAX = never, RAX = never>(actions: T, handlers: HandlersOf<T, R, RequestServices> | Effect.Effect<HandlersOf<T, R, RequestServices>, EX, RX>, options: [Protected<ActionsOf<T>>] extends [never] ? {
172
211
  readonly authorize?: "A public-only target takes no authorize";
@@ -177,8 +216,14 @@ export declare function implement<const T extends Target, RequestServices extend
177
216
  /**
178
217
  * The builders of `implementations`, as one layer providing nothing. Surfaces build them
179
218
  * themselves, once per layer graph, but routes under `HttpRouter.serve` or `Testing.layer`
180
- * are built in a graph of their own: provided above both, it runs each builder once for every
181
- * surface, with the services it is given.
219
+ * are built in a graph of their own, sharing nothing they build: provided above the server and
220
+ * its siblings, it runs each builder once for every surface, with the services it is given.
221
+ *
222
+ * **Gotchas**
223
+ *
224
+ * - It builds every implementation it is given, those no surface serves included, so it owes their startup services.
225
+ * - A job merged beside `HttpRouter.serve` with no `Action.layer` above both shares the server's build only when its own side builds first, which depends on timing: put the job inside the layer the server serves, or provide this layer above both.
226
+ * - Do not wrap a server in `Layer.fresh`: two servers already share no build. It is for two surfaces in one graph that must each have a build of their own; provide what they share, authentication included, outside it.
182
227
  */
183
228
  export declare function layer<const Apps extends Served>(implementations: Apps): Layer.Layer<never, BuildError<Member<Apps>>, BuildServices<Member<Apps>>>;
184
229
  type Method<A extends Any, R> = Call<A, Effect.Effect<A["success"]["Type"], A["error"][number]["Type"] | BuiltIn, R>>;
@@ -196,18 +241,22 @@ export interface ClientOptions<A extends Any> {
196
241
  readonly actions?: ReadonlyArray<A> | undefined;
197
242
  }
198
243
  /**
199
- * Call implementations in process: one method per action, taking its input directly, as
244
+ * Call implementations in process: one method per action, taking its input directly as
200
245
  * `ActionHttp.client`'s methods do, `client.renameUser({ id, name })`, so moving between the
201
- * two changes the line acquiring it. A call runs as a remote one does: its input passes through
202
- * its JSON codec, encoded then decoded, then the implementation's `authorize` and the handler
203
- * run, in a scope of their own, and the success or the failure passes through its
204
- * codec. It fails with the action's errors and the built-in ones, and owes, per call, what
205
- * they read, the caller's identity included; the caller provides it around the call.
246
+ * two changes the line acquiring it. A call runs as a remote one does, its input and its answer
247
+ * through their JSON codecs, the implementation's `authorize` and the handler in a scope of
248
+ * their own, failing with the action's errors and the built-in ones. Acquiring it builds the
249
+ * implementations' builders into the layer graph it is acquired in, as a layer does, and its
250
+ * scope holds them: acquire it where builders live.
251
+ *
252
+ * **Gotchas**
206
253
  *
207
- * Acquiring it builds the implementations' builders, as a layer does, into the layer graph it
208
- * is acquired in, and its scope holds them: in a builder, it shares their one run with every
209
- * surface of that graph. Acquire it where builders live, in a builder, a layer or a scoped
210
- * program, never per request.
254
+ * - Provide the caller around each call, and startup services around the acquisition: a call reads its own context, never the one the client was acquired in, so one client serves every caller.
255
+ * - Acquiring it needs a `Scope`: `it.effect` supplies one, elsewhere `Effect.scoped` does; acquired in a handler, it builds on every request.
256
+ * - Two acquisitions share a build only inside one layer graph, such as under a layer provided around the program; with none around them, each builds its own.
257
+ * - Every implemented action has a method, whether or not a surface serves it; `actions` lists the ones to call, and an implementation holding none of them is left unbuilt.
258
+ * - It drops undeclared input fields as it encodes, and fails with `InvalidInput`, running nothing, for an input that does not encode.
259
+ * - A builder acquiring a client of its own implementation is refused by the compiler, TS7022, and with the types bypassed never completes: call the services both use instead.
211
260
  */
212
261
  export declare function client<const Apps extends Served, const O extends ClientOptions<ActionOf<Member<Apps>>> = {}>(implementations: Apps, ...options: OptionalUnless<O, ClientOptions<ActionOf<Member<Apps>>> & O & NoInfer<Known<O, ClientOptions<Any>>>>): Effect.Effect<Client<Apps, Offered<O, ActionOf<Member<Apps>>>>, BuildError<Holding<Member<Apps>, SelectedOf<O, Apps>>, SelectedOf<O, Apps>>, BuildServices<Holding<Member<Apps>, SelectedOf<O, Apps>>, SelectedOf<O, Apps>> | Scope.Scope>;
213
262
  //#endregion
package/dist/Action.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { Anyone } from "./contract/caller.mjs";
2
2
  import { BuiltIn, Forbidden, InvalidInput, Refusal, Unauthenticated } from "./contract/errors.mjs";
3
- import { assertKnown, assertName, assertOnce } from "./contract/names.mjs";
4
- import { assertOwnTags, errorList, projectedErrors } from "./contract/declared.mjs";
3
+ import { assertKnown, assertName, assertOnce, heldOnce } from "./contract/names.mjs";
4
+ import { assertDistinctTags, errorList, projectedErrors } from "./contract/declared.mjs";
5
5
  import { Implementation, builders, built, memoized, select, toList } from "./contract/implementation.mjs";
6
6
  import { inputOf } from "./contract/call.mjs";
7
7
  import { Image } from "./contract/media.mjs";
@@ -10,11 +10,11 @@ import { Array as Array$1, Cause, Context, Effect, Predicate, Schema } from "eff
10
10
  const NoInput = Schema.Record(Schema.String, Schema.Never);
11
11
  const codecOf = (schema) => Schema.isSchema(schema) ? schema : Reflect.ownKeys(schema).length === 0 ? NoInput : Schema.Struct(schema);
12
12
  function make(name, options) {
13
- assertName("action name", name);
13
+ assertName(name);
14
14
  const { caller, readOnly } = options;
15
15
  if (caller !== Anyone && !Context.isKey(caller)) throw new Error("Missing caller: declare Action.Anyone or an identity service key");
16
16
  if (caller !== Anyone && Context.isReference(caller)) throw new Error("Invalid caller: an identity is a Context.Service, not a Context.Reference");
17
- if (readOnly !== true && readOnly !== false) throw new Error(`Invalid readOnly: ${String(readOnly)}`);
17
+ if (readOnly !== true && readOnly !== false) throw new Error(`Invalid readOnly: ${String(readOnly)} (set true or false)`);
18
18
  const action = {
19
19
  caller,
20
20
  name,
@@ -25,20 +25,17 @@ function make(name, options) {
25
25
  readOnly,
26
26
  mcp: { ...options.mcp }
27
27
  };
28
- assertOwnTags(`Action "${action.name}"`, action.error);
28
+ assertDistinctTags(`Action "${action.name}"`, projectedErrors(action));
29
29
  return action;
30
30
  }
31
31
  function byName(actions) {
32
32
  assertOnce("action", actions);
33
33
  return Object.fromEntries(actions.map((action) => [action.name, action]));
34
34
  }
35
- /**
36
- * Every authenticated caller may call a protected action. The contract's identity
37
- * requirement is enforced independently, including when its handler reads no identity.
38
- */
35
+ /** Every authenticated caller may call a protected action. */
39
36
  const allowAll = () => Effect.void;
40
37
  const assertAuthorizer = (before) => {
41
- if (!Predicate.isFunction(before)) throw new Error("Missing authorize: pass an authorization function, or Action.allowAll");
38
+ if (!Predicate.isFunction(before)) throw new Error("Missing authorize: not a function (pass your rule, Action.allowAll, or an Effect returning one)");
42
39
  return before;
43
40
  };
44
41
  const assertAuthorization = (authorize) => {
@@ -46,7 +43,7 @@ const assertAuthorization = (authorize) => {
46
43
  return authorize;
47
44
  };
48
45
  const assertNoAuthorization = (authorize) => {
49
- if (authorize !== void 0) throw new Error("A public-only target takes no authorize");
46
+ if (authorize !== void 0) throw new Error("A public-only target takes no authorize (remove it, or list a protected action)");
50
47
  return allowAll;
51
48
  };
52
49
  const authorizerOf = (before) => memoized(Effect.isEffect(before) ? Effect.map(before, assertAuthorizer) : Effect.succeed(assertAuthorizer(before)));
@@ -57,13 +54,13 @@ function implement(actions, handlers, options) {
57
54
  const authorizer = authorizerOf(listed.some((action) => action.caller !== Anyone) ? assertAuthorization(options?.authorize) : assertNoAuthorization(options?.authorize));
58
55
  const pairWithActions = (built) => {
59
56
  const keyed = Predicate.isFunction(built) ? Object.fromEntries(Array.isArray(actions) ? [] : listed.map(({ name }) => [name, built])) : built;
60
- assertKnown("handlers", Object.keys(keyed), names);
57
+ assertKnown("handlers", Object.keys(keyed), names, "remove it, or list its action");
61
58
  const bound = listed.flatMap((action) => {
62
59
  const handle = Object.hasOwn(keyed, action.name) ? keyed[action.name] : void 0;
63
60
  return Predicate.isFunction(handle) ? [[action, handle]] : [];
64
61
  });
65
62
  const missing = names.filter((name) => !bound.some(([action]) => action.name === name));
66
- if (missing.length > 0) throw new Error(`Missing handlers: ${missing.join(", ")}`);
63
+ if (missing.length > 0) throw new Error(`Missing handlers: ${missing.join(", ")} (return the handler for one action, or a record with an own function per listed action)`);
67
64
  return bound;
68
65
  };
69
66
  const recorded = Effect.isEffect(handlers) ? Effect.map(handlers, pairWithActions) : Effect.succeed(pairWithActions(handlers));
@@ -90,7 +87,7 @@ const methodOf = (action, run) => {
90
87
  };
91
88
  function client(served, options) {
92
89
  const apps = select(toList(served), options?.actions);
93
- assertOnce("action", apps.flatMap((app) => app.actions));
90
+ assertOnce("action", apps.flatMap((app) => app.actions), heldOnce);
94
91
  return Effect.map(built(apps), (bound) => Object.fromEntries(bound.map(([action, run]) => [action.name, methodOf(action, run)])));
95
92
  }
96
93
  //#endregion