@gjermundgaraba/effect-actions 0.18.2 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -47
- package/dist/Action.d.mts +96 -49
- package/dist/Action.mjs +12 -16
- package/dist/ActionCli.d.mts +82 -44
- package/dist/ActionCli.mjs +54 -35
- package/dist/ActionHttp.d.mts +84 -45
- package/dist/ActionHttp.mjs +3 -4
- package/dist/ActionMcp.d.mts +54 -29
- package/dist/ActionMcp.mjs +33 -15
- package/dist/ActionRpc.d.mts +48 -37
- package/dist/ActionRpc.mjs +3 -4
- package/dist/ActionToolkit.d.mts +32 -15
- package/dist/ActionToolkit.mjs +11 -9
- package/dist/Authentication.d.mts +92 -42
- package/dist/Authentication.mjs +38 -37
- package/dist/Testing.d.mts +23 -27
- package/dist/Testing.mjs +1 -2
- package/dist/authentication/provider.d.mts +1 -6
- package/dist/authentication/provider.mjs +2 -3
- package/dist/authentication/refusal.mjs +3 -3
- package/dist/cli/commands.d.mts +34 -13
- package/dist/cli/commands.mjs +48 -24
- package/dist/contract/ast.mjs +1 -2
- package/dist/contract/call.d.mts +1 -3
- package/dist/contract/call.mjs +1 -2
- package/dist/contract/caller.d.mts +1 -3
- package/dist/contract/caller.mjs +2 -3
- package/dist/contract/declared.d.mts +0 -2
- package/dist/contract/declared.mjs +28 -16
- package/dist/contract/errors.d.mts +42 -19
- package/dist/contract/errors.mjs +43 -19
- package/dist/contract/hints.mjs +4 -4
- package/dist/contract/implementation.d.mts +7 -14
- package/dist/contract/implementation.mjs +31 -19
- package/dist/contract/media.d.mts +7 -5
- package/dist/contract/media.mjs +7 -4
- package/dist/contract/names.mjs +15 -14
- package/dist/contract/status.mjs +4 -12
- package/dist/http/client.d.mts +15 -6
- package/dist/http/client.mjs +2 -3
- package/dist/http/schema-errors.mjs +1 -2
- package/dist/mcp/client.d.mts +21 -10
- package/dist/mcp/client.mjs +8 -11
- package/dist/mcp/protocol.mjs +1 -2
- package/dist/stdio/console.mjs +1 -2
- package/dist/testing/memory.d.mts +1 -3
- package/dist/testing/memory.mjs +1 -2
- package/docs/Action-failures.md +6 -15
- package/docs/Action.md +159 -191
- package/docs/ActionCli-failures.md +12 -92
- package/docs/ActionCli.md +102 -156
- package/docs/ActionHttp-failures.md +14 -76
- package/docs/ActionHttp.md +150 -176
- package/docs/ActionMcp-failures.md +2 -22
- package/docs/ActionMcp.md +79 -170
- package/docs/ActionRpc-failures.md +3 -12
- package/docs/ActionRpc.md +183 -120
- package/docs/ActionToolkit-failures.md +3 -10
- package/docs/ActionToolkit.md +112 -109
- package/docs/Authentication-advanced.md +171 -108
- package/docs/Authentication-failures.md +11 -91
- package/docs/Authentication.md +114 -135
- package/docs/README.md +45 -30
- package/docs/Testing-failures.md +2 -6
- package/docs/Testing.md +126 -159
- package/docs/setup-failures.md +8 -32
- package/docs/setup.md +42 -38
- package/examples/README.md +6 -10
- package/examples/assistant/README.md +20 -38
- package/examples/assistant/src/agent.ts +2 -3
- package/examples/assistant/src/cli.ts +4 -9
- package/examples/assistant/src/commands.ts +6 -4
- package/examples/assistant/src/identity/authorize.ts +3 -3
- package/examples/assistant/src/tasks/Tasks.ts +1 -2
- package/examples/assistant/src/tasks/handlers.ts +3 -3
- package/examples/assistant/src/toolkit.ts +6 -8
- package/examples/assistant/test/commands.test.ts +16 -11
- package/examples/assistant/test/tasks/tasks.test.ts +0 -2
- package/examples/assistant/test/toolkit.test.ts +0 -1
- package/examples/kitchen-sink/README.md +30 -52
- package/examples/kitchen-sink/src/Actions.ts +2 -3
- package/examples/kitchen-sink/src/Api.ts +3 -4
- package/examples/kitchen-sink/src/admin/Admin.ts +1 -1
- package/examples/kitchen-sink/src/admin/authenticate.ts +3 -3
- package/examples/kitchen-sink/src/admin/authorize.ts +1 -2
- package/examples/kitchen-sink/src/agent.ts +1 -1
- package/examples/kitchen-sink/src/assistant/handlers.ts +1 -6
- package/examples/kitchen-sink/src/attachments/Attachments.ts +4 -8
- package/examples/kitchen-sink/src/attachments/download.ts +5 -8
- package/examples/kitchen-sink/src/attachments/handlers.ts +2 -3
- package/examples/kitchen-sink/src/browser.ts +3 -4
- package/examples/kitchen-sink/src/cli.ts +6 -7
- package/examples/kitchen-sink/src/config.ts +2 -0
- package/examples/kitchen-sink/src/identity/Identity.ts +7 -7
- package/examples/kitchen-sink/src/identity/authenticate.ts +3 -5
- package/examples/kitchen-sink/src/identity/authorize.ts +4 -3
- package/examples/kitchen-sink/src/identity/quota.ts +1 -1
- package/examples/kitchen-sink/src/issues/Issues.ts +2 -2
- package/examples/kitchen-sink/src/issues/assistant.ts +3 -3
- package/examples/kitchen-sink/src/issues/guide.ts +2 -3
- package/examples/kitchen-sink/src/ops.ts +3 -6
- package/examples/kitchen-sink/src/request-policy.ts +8 -4
- package/examples/kitchen-sink/src/routes.ts +4 -6
- package/examples/kitchen-sink/src/seed.ts +0 -1
- package/examples/kitchen-sink/src/server.ts +11 -12
- package/examples/kitchen-sink/src/stdio.ts +3 -2
- package/examples/kitchen-sink/src/workspaces/tenant.ts +3 -1
- package/examples/kitchen-sink/test/admin/admin.test.ts +0 -1
- package/examples/kitchen-sink/test/assistant/assistant.test.ts +1 -1
- package/examples/kitchen-sink/test/attachments/attachments.test.ts +1 -2
- package/examples/kitchen-sink/test/identity/identity.test.ts +0 -1
- package/examples/kitchen-sink/test/issues/issues.test.ts +0 -2
- package/examples/kitchen-sink/test/request-policy.test.ts +39 -3
- package/examples/kitchen-sink/test/routes.test.ts +3 -3
- package/examples/live-rpc/README.md +8 -49
- package/examples/live-rpc/src/browser.ts +8 -41
- package/examples/live-rpc/src/chat/Chat.ts +9 -3
- package/examples/live-rpc/src/chat/room.ts +1 -1
- package/examples/live-rpc/src/client.ts +2 -3
- package/examples/live-rpc/src/page.ts +34 -0
- package/examples/live-rpc/src/routes.ts +6 -7
- package/examples/live-rpc/src/server.ts +6 -4
- package/examples/live-rpc/test/browser.test.ts +6 -5
- package/examples/live-rpc/test/routes.test.ts +16 -15
- package/examples/live-rpc/test/server.test.ts +1 -1
- package/examples/mcp-tools/README.md +22 -45
- package/examples/mcp-tools/src/printers/Printers.ts +3 -7
- package/examples/mcp-tools/src/routes.ts +0 -7
- package/examples/mcp-tools/src/server.ts +1 -1
- package/examples/mcp-tools/test/printers/printers.test.ts +2 -3
- package/examples/mcp-tools/test/routes.test.ts +2 -3
- package/examples/notes-service/README.md +9 -43
- package/examples/notes-service/src/cli.ts +0 -3
- package/examples/notes-service/src/commands.ts +7 -17
- package/examples/notes-service/src/identity/authorize.ts +2 -5
- package/examples/notes-service/src/identity/verifier.ts +3 -4
- package/examples/notes-service/src/notes/Notes.ts +2 -6
- package/examples/notes-service/src/notes/store.ts +5 -1
- package/examples/notes-service/src/routes.ts +6 -8
- package/examples/notes-service/src/server.ts +4 -3
- package/examples/notes-service/src/stdio.ts +4 -3
- package/examples/notes-service/test/commands.test.ts +1 -1
- package/examples/notes-service/test/notes/as.ts +6 -0
- package/examples/notes-service/test/notes/idempotency.test.ts +1 -4
- package/examples/notes-service/test/notes/in-process.test.ts +50 -0
- package/examples/notes-service/test/notes/notes.test.ts +3 -42
- package/examples/notes-service/test/routes.test.ts +2 -2
- package/examples/quickstart/README.md +1 -1
- package/examples/quickstart/src/client.ts +1 -0
- package/examples/quickstart/src/server.ts +2 -4
- package/examples/quickstart/test/{greeting/greeting.test.ts → routes.test.ts} +3 -3
- package/package.json +12 -16
- package/docs/Action-in-process.md +0 -93
- package/docs/CONTEXT.md +0 -46
- 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
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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:
|
|
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
|
|
65
|
-
uses every
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
([
|
|
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
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
126
|
-
|
|
127
|
-
|
|
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;
|
|
88
|
+
const store = yield* NoteStore;
|
|
134
89
|
|
|
135
90
|
return {
|
|
136
|
-
|
|
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
|
-
|
|
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
|
|
146
|
-
|
|
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
|
-
- **
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
|
184
|
-
|
|
185
|
-
([notes-service/src/status/handlers.ts](../examples/notes-service/src/status/handlers.ts))
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
`
|
|
205
|
-
|
|
206
|
-
- **A
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
- **
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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,
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
- `
|
|
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
|
-
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
- `
|
|
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`.
|