@gjermundgaraba/effect-actions 0.18.2 → 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 +81 -42
  5. package/dist/ActionCli.mjs +52 -33
  6. package/dist/ActionHttp.d.mts +84 -43
  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 +6 -15
  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 +45 -30
  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 +42 -37
  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 +30 -52
  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 +3 -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 +9 -43
  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 +4 -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 +1 -1
  142. package/docs/Action-in-process.md +0 -93
  143. package/docs/CONTEXT.md +0 -46
  144. package/docs/guarantees.md +0 -44
package/docs/Action.md CHANGED
@@ -1,231 +1,173 @@
1
1
  # Action
2
2
 
3
- `Action.make` defines an action, and `Action.implement` binds handlers and an authorizer to
4
- contracts: every surface takes the implementation it returns, or a list of them.
5
-
6
- Read this page alone, in one tool call; if its output says it was truncated, read it again alone.
7
- When something fails, search [Action-failures.md](Action-failures.md). Reading order:
8
- [README.md](README.md).
9
-
10
- ## API
11
-
12
- Import `@gjermundgaraba/effect-actions/Action`.
13
-
14
- | Export | Purpose |
15
- | ---------------------------------------------- | ------------------------------------------------------------ |
16
- | `make(name, options)` | Define a contract. |
17
- | `byName(actions)` | A list of actions keyed by name, each its exact contract. |
18
- | `implement(actions, handlers, { authorize })` | Bind a handler, a record of them, or a builder of either. |
19
- | `allowAll` | The authorizer without a rule: any authenticated caller. |
20
- | `Anyone` | The `caller` of a public action: anyone, signed in or not. |
21
- | `client`, `layer`, `Client`, `ClientOptions` | In [Action-in-process.md](Action-in-process.md#api). |
22
- | `Image` | A media field's schema: `data` bytes and a `mimeType`. |
23
- | `InvalidInput`, `Unauthenticated`, `Forbidden` | Built-in errors: 400 with `issues`, 401, 403 with `scopes`. |
24
- | `InvalidInput.fromSchemaError(error)` | What every surface answers a `Schema.SchemaError` with. |
25
- | `Issue` | One of `InvalidInput`'s `issues`: a `path` and a `message`. |
26
- | `Refusal` | `Unauthenticated \| Forbidden`: an authorizer's failures. |
27
- | `BuiltIn` | `InvalidInput \| Refusal`: what any handler may fail with. |
28
- | `Errors` | A list of error schemas, as `error` holds it. |
29
- | `Action`, `Any`, `Implementation` | Concrete and erased contracts, and bound implementations. |
30
- | `AnyImplementation`, `AnyImplementation<A>` | Any implementation, or any of actions `A`, erased. |
31
- | `BuildServices`, `BuildError` | What builders read and fail with at startup. |
32
- | `implementation.actions` | Its exact contracts, as `ActionMcp.client` takes them. |
33
- | `Handler`, `Handlers`, `Authorize` | A typed handler, a list's record of them, and an authorizer. |
34
- | `Options`, `Mcp` | What `make` takes, and its `mcp` options. |
35
-
36
- | Option | Meaning |
37
- | ------------- | -------------------------------------------------------------- |
38
- | `description` | Required. A model reads it beside the tool; a CLI prints it. |
39
- | `readOnly` | Required: whether the action leaves its resource unchanged. |
40
- | `caller` | Required: `Action.Anyone`, or the caller's identity service. |
41
- | `input` | A schema or struct fields; omitted, no arguments. |
42
- | `success` | A schema or struct fields; omitted, nothing is returned. |
43
- | `error` | The declared errors: one schema or a list; defaults to none. |
44
- | `mcp` | The tool's MCP hints, in MCP's names, its `title` and `_meta`. |
3
+ `Action.make` defines a contract: what an action takes and answers, whether it changes anything,
4
+ and who may call it. `Action.implement` binds contracts to handlers, behind an authorizer. Every
5
+ surface takes the implementation it returns, or a list of them, and `Action.client` calls one in
6
+ process.
7
+
8
+ A message the library throws says its fix; for a compiler diagnostic or Effect's own error, search
9
+ [Action-failures.md](Action-failures.md). For one symbol, `grep -rn <symbol> node_modules/@gjermundgaraba/effect-actions/dist/`:
10
+ each declaration's JSDoc carries its rules and gotchas, and this page only what an example
11
+ cannot show.
45
12
 
46
13
  ## Define a contract
47
14
 
48
- Write one `Action.make` per action in the domain's contract file, and export the domain's
49
- actions as one `as const` list: `implement` and every surface take lists.
15
+ One `Action.make` per action, in the domain's contract file. Export the domain's actions as one
16
+ `as const` list ([notes-service/src/notes/Notes.ts](../examples/notes-service/src/notes/Notes.ts)):
17
+ `implement` and every surface take lists.
18
+
19
+ ```ts example=quickstart/src/greeting/Greeting.ts
20
+ import { Schema } from "effect";
21
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
50
22
 
51
- ```ts
52
- export const GetNote = Action.make("getNote", {
53
- description: "Read one of your notes.",
54
- input: { id: Schema.String },
55
- success: Note,
56
- error: NoteNotFound,
23
+ // A contract: what the action takes and answers, whether it changes anything, and who may
24
+ // call it. It imports no server code, so any client can import it, a browser page included.
25
+ export const Greet = Action.make("greet", {
26
+ description: "Greet someone by name.",
27
+ input: { name: Schema.String },
28
+ success: Schema.String,
57
29
  readOnly: true,
58
- caller: CurrentUser,
30
+ caller: Action.Anyone,
59
31
  });
60
32
  ```
61
33
 
62
- Start from [quickstart/src/greeting/Greeting.ts](../examples/quickstart/src/greeting/Greeting.ts).
63
34
  [notes-service/src/notes/Notes.ts](../examples/notes-service/src/notes/Notes.ts) adds declared
64
- errors and the list, and [kitchen-sink/src/issues/Issues.ts](../examples/kitchen-sink/src/issues/Issues.ts)
65
- uses every option, sharing some by spreading a constant (`IssueWriteErrors`, `change`).
66
-
67
- - **The contract file imports no server code and no transport**, so a browser or a CLI can
68
- import it ([setup.md](setup.md#bundle-for-a-browser)).
69
- - **The name** matches `[A-Za-z0-9_-]{1,128}`, is not `then`, and is unique within each binding,
70
- MCP endpoint, Toolkit, CLI and client holding it.
71
- - **For no arguments, omit `input`.** A `Schema.Struct({})` accepts any value but `null` and `undefined`, and MCP
72
- refuses it as a tool's input ([ActionMcp.md](ActionMcp.md#describe-tools-to-a-model)).
73
- - **A field callers may omit is `Schema.optional`.** On the wire it also takes `null`, read as
74
- left out, as a tool's schema lists; a typed client refuses `null`. `Schema.optionalKey` takes
75
- omission alone, and `Schema.NullOr` keeps `null` as a value. One with a decoding default (`limit` in
76
- `Issues.ts`) is still stated by a typed client given an argument
77
- ([ActionHttp.md](ActionHttp.md#call-the-api-from-effect)).
78
- - **An action's `name`, `input`, `success`, `error` and `readOnly` are public**: reuse them, as
79
- in `Schema.Array(SaveLink.input)`.
80
- - **Annotate each declared error with `httpApiStatus`**, or HTTP answers it as a 422.
81
- - **Give each declared error its own `_tag`**: a client tells errors sharing one, or having
82
- none, apart by their other fields alone, the first that decodes winning.
83
- - **A contract does not say where it is served**: each surface takes a list of actions. Keep
84
- those lists in one file beside the contracts
85
- ([kitchen-sink/src/Actions.ts](../examples/kitchen-sink/src/Actions.ts)): an action added
86
- later is no tool until listed there.
35
+ errors with their statuses, described fields and the list;
36
+ [kitchen-sink/src/issues/Issues.ts](../examples/kitchen-sink/src/issues/Issues.ts) uses every
37
+ option. The rules of each option, `input`'s optional fields and `error`'s tags included, are on
38
+ `Action.Options`; where an action must be listed to be served, and matching by object identity,
39
+ are on `Action.make`. The list each surface takes lives in one file beside the contracts
40
+ ([kitchen-sink/src/Actions.ts](../examples/kitchen-sink/src/Actions.ts)).
41
+
87
42
  - **List an action as a tool only where a model may hold what it takes and returns.** A tool
88
43
  call passes both through the model's context and records its arguments on the current span,
89
44
  a `Schema.Redacted` value included.
90
- - **Surfaces match an implementation to a contract by object identity, never by name**:
91
- implement, bind and list the one exported value. A list narrowed at run time, by a filter or a
92
- conditional element, is still typed as holding every member.
93
45
 
94
46
  ## Say who may call an action
95
47
 
96
- `caller: Action.Anyone` makes a public action. An identity service, `caller: CurrentUser`, makes
97
- a protected one: every surface requires that identity, whether or not the handler reads it. A
98
- remote surface gets it by authenticating the request
99
- ([Authentication.md](Authentication.md#declare-how-callers-sign-in)); on a local surface the
100
- host provides it ([ActionToolkit.md](ActionToolkit.md#name-the-caller),
48
+ `caller: Action.Anyone` makes a public action; an identity service, `caller: CurrentUser`, a
49
+ protected one, whose identity a remote surface gets by authenticating the request
50
+ ([Authentication.md](Authentication.md#declare-how-callers-sign-in)) and a local surface, which
51
+ has no one to authenticate, from the host ([ActionToolkit.md](ActionToolkit.md#name-the-caller),
101
52
  [ActionCli.md](ActionCli.md#name-the-caller), [ActionMcp.md](ActionMcp.md#serve-tools-over-stdio)).
102
53
 
103
- `CurrentUser` is in [notes-service/src/identity/Identity.ts](../examples/notes-service/src/identity/Identity.ts),
104
- and a public action in [notes-service/src/status/Status.ts](../examples/notes-service/src/status/Status.ts).
105
-
106
- - **Declare the identity as a `Context.Service`**, in a contract file, apart from its verifier.
107
- - **Never provide an identity, or a tenant, at startup**: not to a server's layer or root
108
- context, not to `Action.layer`, not around the acquisition of a client. A public handler or a
109
- route of your own reading the tag would serve every caller as that identity, and the types do
110
- not stop it. A test sends each caller's credential instead
111
- ([Testing.md](Testing.md#test-as-a-signed-in-caller)).
112
- - **One identity per action**, and every protected action one remote surface serves declares
113
- the same one. A tenant is a request-time requirement of the handler, not a second identity.
114
- A second audience takes its own identity, descriptor and binding (`Operator` in
115
- [kitchen-sink/src/admin/Admin.ts](../examples/kitchen-sink/src/admin/Admin.ts)).
116
- - **No surface provides a public action an identity.** An action whose answer depends on the
117
- caller is protected.
54
+ `CurrentUser` is declared in
55
+ [notes-service/src/identity/Identity.ts](../examples/notes-service/src/identity/Identity.ts), and
56
+ a public action in [notes-service/src/status/Status.ts](../examples/notes-service/src/status/Status.ts).
57
+ The rules of an identity, never provided at startup, are on `Action.Options.caller`; a test
58
+ sends each caller's credential instead
59
+ ([Testing.md](Testing.md#test-as-a-signed-in-caller)).
60
+
118
61
  - **One protected contract serves callers with a token over HTTP and a local host without
119
62
  one**, never a public copy of it: the host provides the caller around `runStdio`
120
- ([notes-service/src/stdio.ts](../examples/notes-service/src/stdio.ts)).
63
+ ([notes-service/src/stdio.ts](../examples/notes-service/src/stdio.ts)); a second audience is
64
+ `Operator` in [kitchen-sink/src/admin/Admin.ts](../examples/kitchen-sink/src/admin/Admin.ts).
121
65
 
122
66
  ## Implement actions
123
67
 
124
68
  `Action.implement` binds a handler to one action, or a record of handlers keyed by name to a
125
- list. Either may instead be a builder: an Effect, run once at startup, that returns the handler
126
- or the record. It is how a handler gets a startup service; what differs per call, the caller
127
- first, the handler reads itself.
128
-
129
- ```ts
69
+ list. Either may instead be a builder: an Effect returning the handler or the record, whose
70
+ services are startup services. What a handler yields per call, the caller first, is a
71
+ request-time requirement.
72
+
73
+ ```ts example=notes-service/src/notes/handlers.ts
74
+ import { Effect } from "effect";
75
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
76
+ import { authorize } from "../identity/authorize.js";
77
+ import { CurrentUser } from "../identity/Identity.js";
78
+ import { Notes } from "./Notes.js";
79
+ import { NoteStore } from "./store.js";
80
+
81
+ // The builder runs once, at startup, however many surfaces serve these actions: it is where a
82
+ // handler gets the services that last the process, never the caller. `CurrentUser` is read per
83
+ // call, inside each handler. Each handler is a thin step from the contract to the store, and
84
+ // none checks a scope: `authorize` has run before it.
130
85
  export const notes = Action.implement(
131
86
  Notes,
132
87
  Effect.gen(function* () {
133
- const store = yield* NoteStore; // a startup service: yielded once
88
+ const store = yield* NoteStore;
134
89
 
135
90
  return {
136
- // CurrentUser is a request-time requirement: yielded per call
91
+ listNotes: () =>
92
+ Effect.gen(function* () {
93
+ const user = yield* CurrentUser;
94
+ const owned = yield* store.list(user.id);
95
+
96
+ return { notes: owned.map(({ id, title }) => ({ id, title })) };
97
+ }),
137
98
  getNote: ({ id }) => Effect.flatMap(CurrentUser, (user) => store.get(user.id, id)),
138
- // ...one handler per action of the list
99
+ createNote: ({ requestId, title, body }) =>
100
+ Effect.flatMap(CurrentUser, (user) => store.create(user.id, title, body, requestId)),
101
+ updateNote: ({ id, body, revision }) =>
102
+ Effect.flatMap(CurrentUser, (user) => store.update(user.id, id, body, revision)),
103
+ deleteNote: ({ id }) => Effect.flatMap(CurrentUser, (user) => store.remove(user.id, id)),
104
+ importNotes: ({ notes }) =>
105
+ Effect.map(
106
+ Effect.flatMap(CurrentUser, (user) => store.createAll(user.id, notes)),
107
+ (created) => ({ notes: created }),
108
+ ),
139
109
  };
140
110
  }),
141
111
  { authorize },
142
112
  );
143
113
  ```
144
114
 
145
- A plain handler is [quickstart/src/greeting/handlers.ts](../examples/quickstart/src/greeting/handlers.ts),
146
- and the whole builder above [notes-service/src/notes/handlers.ts](../examples/notes-service/src/notes/handlers.ts).
115
+ A plain handler, no builder, is
116
+ [quickstart/src/greeting/handlers.ts](../examples/quickstart/src/greeting/handlers.ts). The
117
+ builder's rules, once per layer graph, a scope per call, passing the builder to `implement`
118
+ itself, calling through `Action.client` and never the function you passed, are on
119
+ `Action.implement`.
147
120
 
148
121
  - **One implementation may hold public and protected actions**, and actions only some
149
122
  surfaces serve: group by domain.
150
- - **What the builder yields is shared by every call; what a handler yields is resolved per
151
- call**, as the caller must be.
152
- - **A builder runs once per layer graph**, however many surfaces serve its implementation. Two
153
- servers are two graphs
154
- ([Share one build across servers](Action-in-process.md#share-one-build-across-servers)), and
155
- two `implement` calls are two builders, even of one builder Effect.
156
- - **Each call has a scope of its own**, so a resource that must outlive a call is the
157
- builder's.
158
- - **Pass the builder to `implement` itself.** Through `.pipe(...)` or a wrapper such as
159
- `Effect.withSpan(builder, name)`, an `Effect.fn` handler it returns takes an `any` input. A
160
- builder written apart returns its record `satisfies Action.Handlers<typeof actions>`.
161
- - **Leave a handler's `Effect.fn` unnamed** unless you need handler-only timing: each action
162
- already has a span ([Observe calls](#observe-calls)).
163
- - **Calling the function you passed to `implement` skips authorization.** Call the
164
- implementation through [`Action.client`](Action-in-process.md#call-actions-in-process).
165
-
166
- ## Observe calls
167
-
168
- The span named after an action includes authorization, handler execution and call cleanup.
169
- Its exit reflects the final dispatch outcome; the span and its logs carry `action.name` and
170
- `action.read_only`.
171
-
172
- Transport credential verification, input decoding and response encoding run outside this span.
173
- A request rejected by verification or decoding has no action span; response encoding can fail
174
- after the action span succeeds.
175
-
176
- Add a named handler span to measure handler time alone. The named authorizer in
177
- [notes-service/src/identity/authorize.ts](../examples/notes-service/src/identity/authorize.ts)
178
- similarly measures authorization as a child of the action span.
123
+ - **Leave a handler's `Effect.fn` unnamed** unless you need handler-only timing. The span named
124
+ after the action covers authorization, the handler and call cleanup, carrying `action.name`
125
+ and `action.read_only` on it and its logs. Credential verification and input decoding run
126
+ before it, so a request they reject has no action span; response encoding runs after it, so
127
+ it can fail after a successful one.
179
128
 
180
129
  ## Authorize calls
181
130
 
182
131
  An implementation holding a protected action states `{ authorize }`: a function of the called
183
- action that succeeds, or fails with a refusal. Every surface runs it before the handler, and
184
- none has a policy of its own. An implementation of public actions alone takes none
185
- ([notes-service/src/status/handlers.ts](../examples/notes-service/src/status/handlers.ts)), and
186
- the authorizer never runs for a public action.
187
-
188
- ```ts
132
+ action that succeeds, or fails with a refusal; no surface has a policy of its own. An
133
+ implementation of public actions alone takes none
134
+ ([notes-service/src/status/handlers.ts](../examples/notes-service/src/status/handlers.ts)).
135
+
136
+ ```ts example=notes-service/src/identity/authorize.ts
137
+ import { Effect } from "effect";
138
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
139
+ import { CurrentUser, scopes } from "./Identity.js";
140
+
141
+ // The one rule, read from each contract: a read needs the read scope, anything else the write
142
+ // scope. Naming the missing scope lets an OAuth client ask for it and retry (`Forbidden`):
143
+ // `scopes` below goes with `protectedResource` in verifier.ts, copy both or neither.
144
+ // This named span measures authorization within the action's span, including a refusal.
189
145
  export const authorize = Effect.fn("authorize")(function* (action: Action.Any) {
190
146
  const scope = action.readOnly ? scopes.read : scopes.write;
191
147
  const user = yield* CurrentUser;
192
148
 
193
149
  if (!user.scopes.includes(scope)) {
194
- return yield* new Action.Forbidden({ message: `Requires ${scope}.` });
150
+ return yield* new Action.Forbidden({ message: `Requires ${scope}.`, scopes: [scope] });
195
151
  }
196
152
  });
197
153
  ```
198
154
 
199
- [notes-service/src/identity/authorize.ts](../examples/notes-service/src/identity/authorize.ts)
200
- is that rule with `scopes: [scope]` added, the OAuth variant: it publishes discovery.
201
- Derive the rule from `action.readOnly`, never from an `mcp` hint: changing an action's
202
- `readOnly` then changes who may call it. Actions under another rule go in another
203
- `Action.implement` with its own `authorize`, or `Action.allowAll`, not a branch on
204
- `action.name`.
205
-
206
- - **A call runs authentication, then over HTTP and RPC the layer's `middleware`, input
207
- decoding, the authorizer, then the handler**, each refusing before the next. So a caller the
208
- authorizer would refuse can still receive an `InvalidInput` describing the input's schema. To
209
- refuse or limit before decoding, use that `middleware`
210
- ([ActionHttp.md](ActionHttp.md#serve-the-routes), [ActionRpc.md](ActionRpc.md#serve-the-rpcs)).
211
- - **Name `scopes` in a `Forbidden` only where re-authorizing can grant them.** An OAuth
212
- challenge answers them, which a client follows only where the server publishes discovery
213
- ([Authentication.md](Authentication.md#publish-oauth-discovery)); with fixed tokens, such as
214
- API keys, leave `scopes` out (it holds at least one: `scopes: []` is a type error). What a
215
- refusal answers on each surface is in [guarantees.md](guarantees.md#wire-behavior).
216
- - **What a plain authorizer yields is a request-time requirement of every surface.** To read a
217
- store, make `authorize` an Effect that yields the store once and returns the function, as a
218
- builder does
219
- ([assistant/src/identity/authorize.ts](../examples/assistant/src/identity/authorize.ts)). It
220
- is built once for each implementation stating it: keep state they share in a service it
221
- yields.
222
- - **A limit, such as a rate limit, is the handler's, not the authorizer's.** It fails with an
223
- error the action declares, and its limiter is a service the builder yields, apart from the
224
- store
225
- ([kitchen-sink/src/issues/write-limit.ts](../examples/kitchen-sink/src/issues/write-limit.ts)):
226
- a service is shared wherever it is provided
227
- ([kitchen-sink/src/memory.ts](../examples/kitchen-sink/src/memory.ts)), state kept in a
228
- builder only within one layer graph.
155
+ Actions under another rule go in another `Action.implement` with its own `authorize`, or
156
+ `Action.allowAll`, not a branch on `action.name`. What a `Forbidden` with and without `scopes`
157
+ answers on each surface is on `Forbidden`.
158
+ Reading a store once, by an `authorize` that is an Effect returning the function, is
159
+ [assistant/src/identity/authorize.ts](../examples/assistant/src/identity/authorize.ts); that
160
+ rule, and that a limit is the handler's, are on `Action.implement`.
161
+
162
+ - **A caller the authorizer would refuse can still receive an `InvalidInput` describing the
163
+ input's schema**, since input is decoded before the authorizer runs. To refuse or limit before
164
+ decoding, use the layer's `middleware`
165
+ ([ActionHttp.md](ActionHttp.md#split-a-binding-across-layers-and-add-middleware),
166
+ [ActionRpc.md](ActionRpc.md#serve-the-rpcs)).
167
+ - **A limiter is a service the builder yields, apart from the store**
168
+ ([kitchen-sink/src/issues/write-limit.ts](../examples/kitchen-sink/src/issues/write-limit.ts),
169
+ [kitchen-sink/src/memory.ts](../examples/kitchen-sink/src/memory.ts)): state kept in a builder
170
+ lasts only within one layer graph.
229
171
  - **A check on one record belongs in the handler's data access**
230
172
  ([notes-service/src/notes/store.ts](../examples/notes-service/src/notes/store.ts)). So does a
231
173
  rule across callers, such as uniqueness.
@@ -233,19 +175,45 @@ Derive the rule from `action.readOnly`, never from an `mcp` hint: changing an ac
233
175
  ## Fail with declared and built-in errors
234
176
 
235
177
  A handler fails with the errors its action lists in `error`, and with the three built-in
236
- errors, which every surface declares and every client decodes: never list those. No other
237
- failure compiles. What each surface answers is in [guarantees.md](guarantees.md#wire-behavior).
238
-
239
- - **Fail with `Action.InvalidInput` for input that decodes but cannot be served**, naming
240
- `issues` of your own, instead of declaring a second error for bad input
241
- ([kitchen-sink/src/attachments/handlers.ts](../examples/kitchen-sink/src/attachments/handlers.ts)).
242
- - **For input you decode yourself** on the server, such as a header or a route parameter,
243
- answer as the surfaces do, with every issue:
244
- `Schema.decodeUnknownEffect(schema)(value, { errors: "all" }).pipe(Effect.mapError(Action.InvalidInput.fromSchemaError))`.
245
- - **Map another service's refusals before failing with them.** A client that decodes the
246
- built-in errors, such as `ActionHttp.client`, hands a handler that service's `Unauthenticated`
247
- or `Forbidden`. Passed on, it is this server's own refusal: a caller whose token is valid is
248
- told to sign in again. Map it to an error the action declares, or to a defect.
249
- - **Map that client's `HttpClientError` and `SchemaError` to one declared error a caller can
250
- act on** ([mcp-tools/src/platform/print-farm.ts](../examples/mcp-tools/src/platform/print-farm.ts)). As
251
- defects, a model reads only a generic text.
178
+ errors, `InvalidInput`, `Unauthenticated` and `Forbidden`. What a refusal answers on each
179
+ surface is on `Action.Unauthenticated` and `Action.Forbidden`; what a declared error and bad
180
+ input answer, on each surface's `layer`, `layerHttp`, `runStdio`, `make` or `command`; when to
181
+ fail with `InvalidInput` yourself, on that class, as
182
+ [kitchen-sink/src/attachments/handlers.ts](../examples/kitchen-sink/src/attachments/handlers.ts)
183
+ does.
184
+
185
+ - **Map a client's `HttpClientError` and `SchemaError` to one declared error a caller can act
186
+ on** ([mcp-tools/src/platform/print-farm.ts](../examples/mcp-tools/src/platform/print-farm.ts)).
187
+ As defects, a model reads only a generic text.
188
+ - **Internal failures are never sent** over HTTP, MCP and RPC: what reaches the log and the
189
+ call's `ErrorReporter`s is on `ActionHttp.layer`, `ActionMcp.layerHttp` and `ActionRpc.layer`;
190
+ a Toolkit and a CLI make a defect the call's.
191
+
192
+ ## Call actions in process
193
+
194
+ `Action.client(implementations)` gives the methods `ActionHttp.client` gives, for a job, an
195
+ agent or a test, without a transport. It is acquired where builders live:
196
+ [kitchen-sink/src/agent.ts](../examples/kitchen-sink/src/agent.ts) in a scoped program,
197
+ [notes-service/test/notes/in-process.test.ts](../examples/notes-service/test/notes/in-process.test.ts)
198
+ under `it.effect`. The rules are on `Action.client`; testing with it is
199
+ [Testing.md](Testing.md#test-an-implementation-in-process).
200
+
201
+ ## Share one build across servers
202
+
203
+ Inside one `HttpRouter.serve` or one `Testing.layer`, a builder runs once, however many surfaces
204
+ serve it, a toolkit's `layer` provided to the routes included. Each `HttpRouter.serve` is a layer
205
+ graph of its own, the layers Effect builds together, each once, which shares nothing it builds
206
+ with another graph: a builder's run, a built authorizer, or an in-memory store of yours.
207
+
208
+ - **With one server, put everything the process runs inside the layer it serves**, a job or an
209
+ agent beside the routes included; why a job merged beside `HttpRouter.serve` is not enough is
210
+ on `Action.layer`.
211
+ - **Otherwise, provide `Action.layer(implementations)` and the services they share above the
212
+ server and its siblings**: a second server, a program beside it, several `Testing.layer`s.
213
+ [kitchen-sink/src/server.ts](../examples/kitchen-sink/src/server.ts) does it for two ports with
214
+ the `builders` layer of [kitchen-sink/src/routes.ts](../examples/kitchen-sink/src/routes.ts),
215
+ and [kitchen-sink/test/routes.test.ts](../examples/kitchen-sink/test/routes.test.ts) with two
216
+ `Testing.layer`s, asserting one run of a builder both ports serve.
217
+ - **Inside one layer graph, a builder runs with the services of whichever surface builds it
218
+ first**, and services provided around one surface reach the others: provide startup services
219
+ once, for every surface. Where two must differ is on `Action.layer`.
@@ -5,101 +5,21 @@ The tasks are in [ActionCli.md](ActionCli.md).
5
5
 
6
6
  ## Flags and input
7
7
 
8
- - `Unrecognized flag: --<field> in command …` for an input field: a field listed in `positional`
9
- has no flag. Pass its value as an argument.
10
- - `Missing value for flag --<flag>`, or `Unrecognized flag: -x`, for a value starting with `-`:
11
- Effect's parser reads a value written apart from its flag as a flag itself when it starts with
12
- `-`, `-` alone and a number such as `-5` excepted: `--note -x` is a missing value, and
13
- `--note --help` prints help. Write `--<flag>=<value>`; `--` does not help, since what follows
14
- it is positional.
15
- - `InvalidInput` naming an unexpected key: `--input` or a flag's JSON holds a field the schema
16
- does not declare, such as a misspelling.
17
- - `InvalidInput` from a command whose input is not a struct, run without `--input`: the schema
18
- rejects the default `{}`. Pass `--input`.
19
- - `InvalidInput` for a flag's value that looks right: a flag takes the encoded value, and reads
20
- its text as JSON when the field accepts that kind of JSON value. Quote a string that reads as
21
- JSON: `--id '"123"'` for a `String | Number` field. Malformed JSON is taken as text.
22
- - A command with a `stdin` field waits without output: nothing closes stdin, such as a parent
23
- process holding the pipe, or `ssh` run without `-n`. Pipe the value in, or redirect stdin from
24
- a file or `/dev/null`.
25
-
26
- ## Thrown when a command is built
27
-
28
- - `Required positional argument after an optional one: <field>`, or
29
- `Repeated positional argument before another one: <field>`: `positional` lists every required
30
- field before any optional one, and an array field last.
31
- - `Not an input field: <field>`: `positional` names no field of the input.
32
- - `Not a flag's input field: <field>`: an `aliases` key names a field that is positional, read
33
- from stdin, or no field.
34
- - `Not a required input field: <field>`: `stdin` names an optional field, or no field.
35
- - `Positional arguments need named input fields`, `Aliases need named input fields: <fields>`, or
36
- `Stdin needs named input fields: <field>`: the action's input is not a struct.
37
- - `Duplicate flag: --<name>, claimed by ...`: two flags of one command share a name, such as two
38
- fields with one kebab-case name (`userId`, `user_id`), a `json` field beside `render`'s
39
- `--json`, or an alias another flag's name or alias takes. Rename the field or the alias, or
40
- drop `render`. A field named like a global flag (`help`, `version`, `log-level`) is not a
41
- clash: its flag shadows the global one on that command.
42
- - `Duplicate command: <name>, claimed by action ... and action ...`: two actions of an aggregate
43
- have the same kebab-case name. Give one a `name` in `commands`, or use separate aggregates.
44
- - `Duplicate command: <name>, claimed by action ... and subcommands`: a command passed in
45
- `subcommands` has a derived command's name. Rename either.
46
- - `Unknown commands: <keys>`: a `commands` key names no action. Use the action's own name, not
47
- its kebab-case command name.
48
- - `Listed in actions, but no implementation holds it: <names>`, or
49
- `but the binding does not hold it`: `actions` lists a contract that the implementations, or the
50
- binding, do not hold, by identity. `(another contract)` marks one whose name they hold, as a
51
- second copy of the contracts module makes. List the contract they implement or bind.
52
- - `Action "x" has no implementation here`, or a type error at `command`: no implementation passed
53
- has that contract. Pass the implementation, and select with the exact contract value it
54
- implements; an equal-looking action does not match.
55
- - `Action "x" is not in this HTTP binding`, or a type error at `remoteCommand`: the action was not
56
- passed to this binding's `ActionHttp.make`. Select with the exact contract value it received.
8
+ - `Unrecognized flag: --<field> in command …` for an input field: a field listed in `positional` has no flag. Pass its value as an argument.
9
+ - `Missing value for flag --<flag>`, or `Unrecognized flag: -x`, for a value starting with `-`: Effect's parser reads a value written apart from its flag as a flag itself when it starts with `-`, `-` alone and a number such as `-5` excepted, so `--note -x` is a missing value and `--note --help` prints help. Write `--<flag>=<value>`; `--` does not help, since what follows it is positional.
57
10
 
58
11
  ## Types
59
12
 
60
- - An argument of type `Binding<…>` not assignable at `make` or `command`, an implementation not
61
- assignable at `remote` or `remoteCommand`, or `client` refused at `make` or `command`: `make`
62
- and `command` take implementations; `remote` and `remoteCommand` take a binding, and alone take
63
- `client`. To choose by a condition, build a command from each:
64
- `remotely ? ActionCli.remote(Http, options) : ActionCli.make(users, options)`.
65
- - `Type 'HttpClient' is not assignable to type 'never'` in the pipe that runs a remote command:
66
- provide `NodeHttpClient.layerUndici` (or `FetchHttpClient.layer`). Another service named there
67
- is what its `client` requires: provide it too.
68
- - An argument of type `Binding<…>` not assignable at `remote` or `remoteCommand`, beside a
69
- `client`: the client is another binding's. Make it with `ActionHttp.client` of this one.
70
- - A local command requires an identity service no handler yields: its action is protected, and
71
- requires the identity its contract declares. Provide it on the command
72
- ([Name the caller](ActionCli.md#name-the-caller)); do not make the contract public to satisfy
73
- the requirement.
74
- - Type error at `Effect.catchTag("UserNotFound", ...)` after `Command.run`: a command fails with
75
- `UserError`. Use `Effect.catchReason("UserError", "UserNotFound", ...)`.
76
- - Type error at `Effect.catchReason("UserError", "<Tag>", ...)`, its tag typed `never`: a failure
77
- of the command has no `_tag`, such as a builder's plain `Error`. Fail with a tagged error, such
78
- as a `Schema.TaggedError` or `Data.TaggedError`.
13
+ - An argument of type `Binding<…>` not assignable at `make` or `command`, an implementation not assignable at `remote` or `remoteCommand`, or `client` refused at `make` or `command`: `make` and `command` take implementations, `remote` and `remoteCommand` a binding and alone `client`. To choose by a condition, build a command from each: `remotely ? ActionCli.remote(Http, options) : ActionCli.make(users, options)`.
14
+ - `Type 'HttpClient' is not assignable to type 'never'` in the pipe that runs a remote command: provide `NodeHttpClient.layerUndici` (or `FetchHttpClient.layer`). Another service named there is what its `client` requires: provide it too.
15
+ - An argument of type `Binding<…>` not assignable at `remote` or `remoteCommand`, beside a `client`: the client is another binding's. Make it with `ActionHttp.client` of this one.
16
+ - Type error at `Effect.catchTag("UserNotFound", ...)` after `Command.run`: a command fails with `UserError`. Use `Effect.catchReason("UserError", "UserNotFound", ...)`.
17
+ - Type error at `Effect.catchReason("UserError", "<Tag>", ...)`, its tag typed `never`: a failure of the command has no `_tag`, such as a builder's plain `Error`. Fail with a tagged error, such as a `Schema.TaggedError` or `Data.TaggedError`.
18
+ - `Argument of type 'string | undefined' is not assignable` at a `UserError`'s `userMessage`: it is optional on the type. Read it with `?? ""`, or assert it with `toHaveProperty`.
79
19
 
80
20
  ## Running
81
21
 
82
- - `--help` or a mistyped flag connects to a database, or fails reading config: services provided
83
- around `Command.run` are built before parsing. Provide them on the command,
84
- `Command.provide(layer)`.
85
- - `ConfigError: …` or another error's `Tag: message` on stderr from a remote command, a public
86
- one too: its `client` failed, as when a required token is not set. Read an optional one with
87
- `Config.option` and add the header only when it is there. Where every command needs it, map
88
- the failure to a tagged error whose `message` says what to do:
89
- [notes-service/src/commands.ts](../examples/notes-service/src/commands.ts).
90
- - `HttpClientError` whose `reason._tag` is `InvalidUrlError`, from a remote command: its client
91
- has no `baseUrl` and prepends no URL.
92
- - 401 `Unauthenticated` from a remote command: a remote command adds no headers of its own. Add
93
- credentials in its client's `transformClient`.
94
- - Another command's request goes to the API's URL, or carries its token: an `HttpClient`
95
- configured around the whole program rewrites every request the program makes. Configure it in
96
- the `client` option.
97
- - A report with a stack on stdout, `ERROR (#1): ...`, instead of JSON on stderr: a defect, or a
98
- failure of a layer the host provides, which `runMain` reports. Fix the defect; apply
99
- `ActionCli.logToStderr` to keep such reports off stdout.
100
- - `ERROR (#n)` with a stack for a declared error, such as `UserNotFound`, from a command built
101
- with `Command.make`: a command of your own that calls `Action.client` or `ActionHttp.client`
102
- fails with the action's own error. Pipe the command's whole Effect through
103
- `ActionCli.toUserError`, given the binding or the actions it calls, or derive the command with `ActionCli.command`.
104
- - `Argument of type 'string | undefined' is not assignable` at a `UserError`'s `userMessage`: it
105
- is optional on the type. Read it with `?? ""`, or assert it with `toHaveProperty`.
22
+ - `ConfigError: …` or another error's `Tag: message` on stderr from a remote command, a public one too: its `client` failed, as when a required token is not set. Read an optional one with `Config.option` and add the header only when it is there; where every command needs it, map the failure to a tagged error whose `message` says what to do ([notes-service/src/commands.ts](../examples/notes-service/src/commands.ts)).
23
+ - `HttpClientError` whose `reason._tag` is `InvalidUrlError`, from a remote command: its client has no `baseUrl` and prepends no URL. Give `ActionHttp.client` a `baseUrl` in the `client` option.
24
+ - A report with a stack on stdout, `ERROR (#1): ...`, instead of JSON on stderr: a defect, or a failure of a layer the host provides, which `runMain` reports. Fix the defect; apply `ActionCli.logToStderr` to keep such reports off stdout.
25
+ - `ERROR (#n)` with a stack for a declared error, such as `UserNotFound`, from a command built with `Command.make`: a command of your own that calls `Action.client` or `ActionHttp.client` fails with the action's own error. Pipe its whole Effect through `ActionCli.toUserError`, given the binding or the actions it calls, or derive the command with `ActionCli.command`.