@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.
- package/README.md +20 -47
- package/dist/Action.d.mts +96 -47
- package/dist/Action.mjs +11 -14
- package/dist/ActionCli.d.mts +81 -42
- package/dist/ActionCli.mjs +52 -33
- package/dist/ActionHttp.d.mts +84 -43
- package/dist/ActionHttp.mjs +2 -2
- package/dist/ActionMcp.d.mts +54 -27
- package/dist/ActionMcp.mjs +5 -5
- package/dist/ActionRpc.d.mts +48 -35
- package/dist/ActionRpc.mjs +1 -1
- package/dist/ActionToolkit.d.mts +32 -13
- package/dist/ActionToolkit.mjs +10 -7
- package/dist/Authentication.d.mts +92 -40
- package/dist/Authentication.mjs +37 -35
- package/dist/Testing.d.mts +22 -24
- package/dist/authentication/provider.d.mts +1 -4
- package/dist/authentication/provider.mjs +1 -1
- package/dist/authentication/refusal.mjs +2 -1
- package/dist/cli/commands.d.mts +34 -11
- package/dist/cli/commands.mjs +47 -22
- package/dist/contract/caller.mjs +1 -1
- package/dist/contract/declared.mjs +27 -14
- package/dist/contract/errors.d.mts +42 -17
- package/dist/contract/errors.mjs +42 -17
- package/dist/contract/hints.mjs +3 -2
- package/dist/contract/implementation.d.mts +5 -8
- package/dist/contract/implementation.mjs +29 -17
- package/dist/contract/media.d.mts +6 -2
- package/dist/contract/media.mjs +6 -2
- package/dist/contract/names.mjs +14 -12
- package/dist/contract/status.mjs +3 -10
- package/dist/http/client.d.mts +15 -4
- package/dist/http/client.mjs +1 -1
- package/dist/mcp/client.d.mts +21 -8
- package/dist/mcp/client.mjs +6 -8
- 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 +167 -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 -37
- 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 +3 -3
- 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 +1 -1
- 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 +1 -1
- package/docs/Action-in-process.md +0 -93
- package/docs/CONTEXT.md +0 -46
- package/docs/guarantees.md +0 -44
|
@@ -12,18 +12,14 @@ class MissingToken extends Schema.TaggedError<MissingToken>()("MissingToken", {
|
|
|
12
12
|
}) {}
|
|
13
13
|
|
|
14
14
|
// The binding's typed client, connected to where the server is and as who is calling, both read
|
|
15
|
-
// from config.
|
|
16
|
-
// runs it when it runs, so `--help` and a mistyped flag read nothing. It reaches these commands'
|
|
17
|
-
// requests and no others.
|
|
15
|
+
// from config. Every command here, the derived ones and your own, calls through this one.
|
|
18
16
|
const api = Effect.gen(function* () {
|
|
19
17
|
const baseUrl = yield* Config.String("NOTES_URL").pipe(
|
|
20
18
|
Config.withDefault("http://127.0.0.1:3000"),
|
|
21
19
|
);
|
|
22
20
|
|
|
23
|
-
// Required, since every command but `get-status` is refused without one
|
|
24
|
-
// missing
|
|
25
|
-
// do. A CLI whose public commands must answer signed out reads the token with `Config.option`
|
|
26
|
-
// instead and adds the header when it is there.
|
|
21
|
+
// Required, since every command but `get-status` is refused without one, and mapped so that a
|
|
22
|
+
// missing one prints what to do.
|
|
27
23
|
const token = yield* Config.Redacted("NOTES_TOKEN").pipe(
|
|
28
24
|
Effect.mapError(
|
|
29
25
|
() =>
|
|
@@ -68,15 +64,11 @@ const exportNotes = Command.make(
|
|
|
68
64
|
|
|
69
65
|
yield* Console.log(`Wrote ${whole.length} notes to ${file}`);
|
|
70
66
|
}).pipe(
|
|
71
|
-
// Context of your own for a failure
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
// so it says only what is true of them all.
|
|
67
|
+
// Context of your own for a failure, on stderr before it. The line prints for every
|
|
68
|
+
// failure of the command, a file that cannot be written included, so it says only what is
|
|
69
|
+
// true of them all.
|
|
75
70
|
Effect.tapError(() => Console.error(`The export to ${file} did not finish.`)),
|
|
76
|
-
//
|
|
77
|
-
// error printed as its JSON on stderr and any other, a file that cannot be written for
|
|
78
|
-
// one, named. A failure left as it is would be reported as a crash, with an `ERROR (#n)`
|
|
79
|
-
// line and a stack trace.
|
|
71
|
+
// So the export fails as a derived command does.
|
|
80
72
|
ActionCli.toUserError(Http),
|
|
81
73
|
),
|
|
82
74
|
).pipe(Command.withDescription("Write every note to a file, as Markdown."));
|
|
@@ -135,7 +127,5 @@ export const commands = ActionCli.remote(Http, {
|
|
|
135
127
|
// `notes update-note <id> --revision 1 < draft.md`: an argument, a flag and stdin together.
|
|
136
128
|
updateNote: { positional: ["id"], stdin: "body" },
|
|
137
129
|
},
|
|
138
|
-
// `Command.withSubcommands` on the result would replace the derived commands, so your own
|
|
139
|
-
// go here.
|
|
140
130
|
subcommands: [exportNotes, importNotes],
|
|
141
131
|
});
|
|
@@ -3,11 +3,8 @@ import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
|
3
3
|
import { CurrentUser, scopes } from "./Identity.js";
|
|
4
4
|
|
|
5
5
|
// The one rule, read from each contract: a read needs the read scope, anything else the write
|
|
6
|
-
// scope.
|
|
7
|
-
//
|
|
8
|
-
// answered a challenge, which only a server publishing discovery (verifier.ts) can follow up.
|
|
9
|
-
// Where tokens are fixed, such as API keys, name no scope: the refusal is then a plain one.
|
|
10
|
-
// So `scopes` below goes when `protectedResource` in verifier.ts does: copy both or neither.
|
|
6
|
+
// scope. Naming the missing scope lets an OAuth client ask for it and retry (`Forbidden`):
|
|
7
|
+
// `scopes` below goes with `protectedResource` in verifier.ts, copy both or neither.
|
|
11
8
|
// This named span measures authorization within the action's span, including a refusal.
|
|
12
9
|
export const authorize = Effect.fn("authorize")(function* (action: Action.Any) {
|
|
13
10
|
const scope = action.readOnly ? scopes.read : scopes.write;
|
|
@@ -17,10 +17,9 @@ const verify = (token: Redacted.Redacted<string>) => {
|
|
|
17
17
|
: Effect.fail(new Action.Unauthenticated({ message: "Unknown demo token." }));
|
|
18
18
|
};
|
|
19
19
|
|
|
20
|
-
// Server only: `Login`'s provider.
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
// The resource is the server's public URL, known only at startup, so it is read from config.
|
|
20
|
+
// Server only: `Login`'s provider. As an OAuth protected resource it also publishes discovery,
|
|
21
|
+
// so an MCP client that gets a 401 finds where to sign in, asking for read first. The resource
|
|
22
|
+
// is the server's public URL, known only at startup, so it is read from config.
|
|
24
23
|
export const authenticate = Authentication.layer(Login, verify, {
|
|
25
24
|
protectedResource: Effect.map(
|
|
26
25
|
Config.String("PUBLIC_URL").pipe(Config.withDefault("http://localhost:3000")),
|
|
@@ -81,6 +81,7 @@ export const CreateNote = Action.make("createNote", {
|
|
|
81
81
|
error: RequestConflict,
|
|
82
82
|
readOnly: false,
|
|
83
83
|
caller: CurrentUser,
|
|
84
|
+
// No `idempotentHint: true`: a call without a `requestId` creates a note each time.
|
|
84
85
|
mcp: { destructiveHint: false },
|
|
85
86
|
});
|
|
86
87
|
|
|
@@ -99,8 +100,7 @@ export const UpdateNote = Action.make("updateNote", {
|
|
|
99
100
|
readOnly: false,
|
|
100
101
|
caller: CurrentUser,
|
|
101
102
|
// No `destructiveHint`: an update replaces the body, so it is destructive, MCP's default for
|
|
102
|
-
// a write
|
|
103
|
-
// `createNote` and `importNotes`, which only add, say `false`.
|
|
103
|
+
// a write. `createNote` and `importNotes`, which only add, say `false`.
|
|
104
104
|
});
|
|
105
105
|
|
|
106
106
|
export const DeleteNote = Action.make("deleteNote", {
|
|
@@ -111,10 +111,6 @@ export const DeleteNote = Action.make("deleteNote", {
|
|
|
111
111
|
caller: CurrentUser,
|
|
112
112
|
});
|
|
113
113
|
|
|
114
|
-
// Work every surface should offer is an action, a bulk import included: the HTTP API, the MCP
|
|
115
|
-
// tools and the CLI then all have it, and the whole list is decoded, so checked, before the
|
|
116
|
-
// handler writes any of it. A command of your own only reads the file and calls this once
|
|
117
|
-
// (commands.ts). `importNotes` only adds notes, so `destructiveHint` is `false`.
|
|
118
114
|
// Imports share note content with `createNote`, but exclude request keys because bulk creation
|
|
119
115
|
// does not deduplicate.
|
|
120
116
|
export const ImportNotes = Action.make("importNotes", {
|
|
@@ -31,7 +31,11 @@ export class NoteStore extends Context.Service<
|
|
|
31
31
|
readonly count: Effect.Effect<number>;
|
|
32
32
|
}
|
|
33
33
|
>()("notes-service/NoteStore") {
|
|
34
|
-
// DEMO ONLY: notes and replay records live in this process's memory, without eviction
|
|
34
|
+
// DEMO ONLY: notes and replay records live in this process's memory, without eviction, so
|
|
35
|
+
// they neither survive a restart nor coordinate separate server instances. A deployed store
|
|
36
|
+
// commits the mutation and its replay record in one transaction, enforces uniqueness of
|
|
37
|
+
// caller, operation and key, and defines a retention window: an expired key no longer
|
|
38
|
+
// protects a retry. A separate network call is not made atomic by a local record.
|
|
35
39
|
static readonly layerMemory = Layer.sync(NoteStore, () => {
|
|
36
40
|
// One map per owner: a note is only ever looked up among its owner's, whatever id is asked.
|
|
37
41
|
const notes = new Map<string, Map<string, Note>>();
|
|
@@ -13,8 +13,7 @@ import { status } from "./status/handlers.js";
|
|
|
13
13
|
// Every domain's implementation, once. Each surface below takes the same list.
|
|
14
14
|
const implementations = [status, notes, identity];
|
|
15
15
|
|
|
16
|
-
// JSON routes at `/api/<action>`, the binding's
|
|
17
|
-
// reads its body; `getStatus` answers anyone.
|
|
16
|
+
// JSON routes at `/api/<action>`, the binding's; `getStatus` answers anyone.
|
|
18
17
|
const api = ActionHttp.layer(Http, implementations);
|
|
19
18
|
|
|
20
19
|
// The host's own routes sit beside the actions' on the same router.
|
|
@@ -30,12 +29,11 @@ const openApi = HttpRouter.add(
|
|
|
30
29
|
);
|
|
31
30
|
|
|
32
31
|
// What the host serves: the JSON routes, the same actions as MCP tools at `/mcp`, and the host's
|
|
33
|
-
// own routes.
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
// endpoint runs: one server for every request, or one per request.
|
|
32
|
+
// own routes. The MCP endpoint serves the public `getStatus` beside the protected tools, so a
|
|
33
|
+
// signed-out 2026-07-28 client may list the tools and call `getStatus`; what else needs a token
|
|
34
|
+
// is on `ActionMcp.LayerHttpOptions.authentication`. It still requires `NoteStore`: the
|
|
35
|
+
// entrypoint chooses which one, and a test chooses its own. The entrypoint also chooses how the
|
|
36
|
+
// MCP endpoint runs: one server for every request, or one per request.
|
|
39
37
|
const serving = (requestScoped: boolean) =>
|
|
40
38
|
Layer.mergeAll(
|
|
41
39
|
api,
|
|
@@ -6,8 +6,7 @@ import { NoteStore } from "./notes/store.js";
|
|
|
6
6
|
import { routes } from "./routes.js";
|
|
7
7
|
|
|
8
8
|
// Entrypoint: `node --import tsx examples/notes-service/src/server.ts`.
|
|
9
|
-
//
|
|
10
|
-
// and starts Node's HTTP server; nothing below it knows which of those it got.
|
|
9
|
+
// It chooses the store, reads the configuration and starts Node's HTTP server.
|
|
11
10
|
const server = Layer.unwrap(
|
|
12
11
|
Effect.gen(function* () {
|
|
13
12
|
// The loopback default serves this machine alone; HOST=0.0.0.0 lets others reach it.
|
|
@@ -16,8 +15,10 @@ const server = Layer.unwrap(
|
|
|
16
15
|
|
|
17
16
|
return HttpRouter.serve(routes).pipe(
|
|
18
17
|
Layer.provide(NodeHttpServer.layer(createServer, { host, port })),
|
|
19
|
-
// Request bodies have no size limit unless the host sets one. This covers every route
|
|
18
|
+
// Request bodies have no size limit unless the host sets one. This covers every route that
|
|
19
|
+
// reads its body whole.
|
|
20
20
|
Layer.provide(Layer.succeed(HttpServerRequest.MaxBodySize, ByteSize.mebibytes(1))),
|
|
21
|
+
// DEMO ONLY: notes live in this process's memory; a deployment provides a store that lasts.
|
|
21
22
|
Layer.provide(NoteStore.layerMemory),
|
|
22
23
|
);
|
|
23
24
|
}),
|
|
@@ -11,11 +11,12 @@ import { status } from "./status/handlers.js";
|
|
|
11
11
|
|
|
12
12
|
// Entrypoint: an MCP server a local agent starts as a subprocess, such as
|
|
13
13
|
// `claude mcp add notes -- "$PWD/node_modules/.bin/tsx" "$PWD/examples/notes-service/src/stdio.ts"`
|
|
14
|
-
// from the repository root
|
|
14
|
+
// from the repository root. The host starts the process in a directory it chooses, so `tsx`
|
|
15
|
+
// and the file are named by full path.
|
|
15
16
|
// The same implementations as the HTTP server, and the same `authorize`. No token arrives
|
|
16
17
|
// over stdio, so this host names the caller itself. It is a process of its own with services
|
|
17
|
-
// of its own: this in-memory store holds its notes, not the running server's. To
|
|
18
|
-
// both provide a store they both reach, such as a database.
|
|
18
|
+
// of its own. DEMO ONLY: this in-memory store holds its notes, not the running server's. To
|
|
19
|
+
// share them, both provide a store they both reach, such as a database.
|
|
19
20
|
ActionMcp.runStdio([status, notes, identity], { name: "notes-service", version: "1.0.0" }).pipe(
|
|
20
21
|
// DEMO ONLY: a real host reads who started it, from its own login or configuration.
|
|
21
22
|
Effect.provideService(CurrentUser, users.ada),
|
|
@@ -120,7 +120,7 @@ describe("the commands", () => {
|
|
|
120
120
|
}).pipe(Effect.provide(served), Effect.provide(NodeServices.layer)),
|
|
121
121
|
);
|
|
122
122
|
|
|
123
|
-
it.effect("import a file `export` wrote
|
|
123
|
+
it.effect("import a file `export` wrote, all its notes or none", () =>
|
|
124
124
|
Effect.gen(function* () {
|
|
125
125
|
const files = yield* FileSystem.FileSystem;
|
|
126
126
|
const directory = yield* files.makeTempDirectoryScoped();
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { CurrentUser } from "../../src/identity/Identity.js";
|
|
3
|
+
import { users } from "../../src/identity/users.js";
|
|
4
|
+
|
|
5
|
+
// The caller of one call, as a server provides it per request.
|
|
6
|
+
export const as = (user: keyof typeof users) => Effect.provideService(CurrentUser, users[user]);
|
|
@@ -1,13 +1,10 @@
|
|
|
1
1
|
import { expect, it } from "@effect/vitest";
|
|
2
2
|
import { Arbitrary, Array, Effect, References, Schema } from "effect";
|
|
3
3
|
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
4
|
-
import { CurrentUser } from "../../src/identity/Identity.js";
|
|
5
|
-
import { users } from "../../src/identity/users.js";
|
|
6
4
|
import { notes } from "../../src/notes/handlers.js";
|
|
7
5
|
import { CreateNote, RequestConflict } from "../../src/notes/Notes.js";
|
|
8
6
|
import { NoteStore } from "../../src/notes/store.js";
|
|
9
|
-
|
|
10
|
-
const as = (user: keyof typeof users) => Effect.provideService(CurrentUser, users[user]);
|
|
7
|
+
import { as } from "./as.js";
|
|
11
8
|
|
|
12
9
|
it.effect("creates a new note on every call without a request key", () =>
|
|
13
10
|
Effect.gen(function* () {
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, expect, it } from "@effect/vitest";
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
4
|
+
import { CurrentUser } from "../../src/identity/Identity.js";
|
|
5
|
+
import { users } from "../../src/identity/users.js";
|
|
6
|
+
import { notes } from "../../src/notes/handlers.js";
|
|
7
|
+
import { NoteNotFound } from "../../src/notes/Notes.js";
|
|
8
|
+
import { NoteStore } from "../../src/notes/store.js";
|
|
9
|
+
|
|
10
|
+
// What the implementation does, with no transport: `Action.client` runs the same decoding,
|
|
11
|
+
// `authorize` and handler a server runs. The caller is provided around each call, as a server
|
|
12
|
+
// provides it per request.
|
|
13
|
+
const as = (user: keyof typeof users) => Effect.provideService(CurrentUser, users[user]);
|
|
14
|
+
|
|
15
|
+
describe("notes, in process", () => {
|
|
16
|
+
it.effect("keeps each user's notes their own", () =>
|
|
17
|
+
Effect.gen(function* () {
|
|
18
|
+
const client = yield* Action.client(notes);
|
|
19
|
+
const note = yield* client.createNote({ title: "Plan", body: "Draft" }).pipe(as("ada"));
|
|
20
|
+
|
|
21
|
+
expect(yield* client.listNotes().pipe(as("ada"))).toEqual({
|
|
22
|
+
notes: [{ id: note.id, title: "Plan" }],
|
|
23
|
+
});
|
|
24
|
+
expect(yield* client.listNotes().pipe(as("grace"))).toEqual({ notes: [] });
|
|
25
|
+
|
|
26
|
+
const missing = new NoteNotFound({ id: note.id });
|
|
27
|
+
const update = { id: note.id, body: "Mine now", revision: note.revision };
|
|
28
|
+
|
|
29
|
+
expect(yield* Effect.flip(client.getNote({ id: note.id }).pipe(as("grace")))).toEqual(
|
|
30
|
+
missing,
|
|
31
|
+
);
|
|
32
|
+
expect(yield* Effect.flip(client.updateNote(update).pipe(as("grace")))).toEqual(missing);
|
|
33
|
+
expect(yield* Effect.flip(client.deleteNote({ id: note.id }).pipe(as("grace")))).toEqual(
|
|
34
|
+
missing,
|
|
35
|
+
);
|
|
36
|
+
expect(yield* client.getNote({ id: note.id }).pipe(as("ada"))).toEqual(note);
|
|
37
|
+
}).pipe(Effect.provide(NoteStore.layerMemory)),
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
it.effect("refuses a write from a caller holding the read scope alone", () =>
|
|
41
|
+
Effect.gen(function* () {
|
|
42
|
+
const client = yield* Action.client(notes);
|
|
43
|
+
const create = client.createNote({ title: "Plan", body: "Draft" });
|
|
44
|
+
const refusal = yield* Effect.flip(create.pipe(as("reader")));
|
|
45
|
+
|
|
46
|
+
expect(refusal).toBeInstanceOf(Action.Forbidden);
|
|
47
|
+
expect(refusal.message).toBe("Requires notes:write.");
|
|
48
|
+
}).pipe(Effect.provide(NoteStore.layerMemory)),
|
|
49
|
+
);
|
|
50
|
+
});
|
|
@@ -1,41 +1,15 @@
|
|
|
1
1
|
import { describe, expect, it } from "@effect/vitest";
|
|
2
2
|
import { Array, Effect, Exit, References } from "effect";
|
|
3
3
|
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
4
|
-
import { CurrentUser } from "../../src/identity/Identity.js";
|
|
5
|
-
import { users } from "../../src/identity/users.js";
|
|
6
4
|
import { notes } from "../../src/notes/handlers.js";
|
|
7
5
|
import { NoteChanged, NoteNotFound } from "../../src/notes/Notes.js";
|
|
8
6
|
import { NoteStore } from "../../src/notes/store.js";
|
|
7
|
+
import { as } from "./as.js";
|
|
9
8
|
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
const as = (user: keyof typeof users) => Effect.provideService(CurrentUser, users[user]);
|
|
9
|
+
// The store's rules, with no transport, as in-process.test.ts calls the implementation: a
|
|
10
|
+
// revision check, and a race two fibers run under every interleaving.
|
|
13
11
|
|
|
14
12
|
describe("notes, in process", () => {
|
|
15
|
-
it.effect("keeps each user's notes their own", () =>
|
|
16
|
-
Effect.gen(function* () {
|
|
17
|
-
const client = yield* Action.client(notes);
|
|
18
|
-
const note = yield* client.createNote({ title: "Plan", body: "Draft" }).pipe(as("ada"));
|
|
19
|
-
|
|
20
|
-
expect(yield* client.listNotes().pipe(as("ada"))).toEqual({
|
|
21
|
-
notes: [{ id: note.id, title: "Plan" }],
|
|
22
|
-
});
|
|
23
|
-
expect(yield* client.listNotes().pipe(as("grace"))).toEqual({ notes: [] });
|
|
24
|
-
|
|
25
|
-
const missing = new NoteNotFound({ id: note.id });
|
|
26
|
-
const update = { id: note.id, body: "Mine now", revision: note.revision };
|
|
27
|
-
|
|
28
|
-
expect(yield* Effect.flip(client.getNote({ id: note.id }).pipe(as("grace")))).toEqual(
|
|
29
|
-
missing,
|
|
30
|
-
);
|
|
31
|
-
expect(yield* Effect.flip(client.updateNote(update).pipe(as("grace")))).toEqual(missing);
|
|
32
|
-
expect(yield* Effect.flip(client.deleteNote({ id: note.id }).pipe(as("grace")))).toEqual(
|
|
33
|
-
missing,
|
|
34
|
-
);
|
|
35
|
-
expect(yield* client.getNote({ id: note.id }).pipe(as("ada"))).toEqual(note);
|
|
36
|
-
}).pipe(Effect.provide(NoteStore.layerMemory)),
|
|
37
|
-
);
|
|
38
|
-
|
|
39
13
|
it.effect("deletes a note for its owner", () =>
|
|
40
14
|
Effect.gen(function* () {
|
|
41
15
|
const client = yield* Action.client(notes);
|
|
@@ -87,17 +61,4 @@ describe("notes, in process", () => {
|
|
|
87
61
|
expect(succeeded).toEqual(Array.range(3, 60).map(() => 1));
|
|
88
62
|
}).pipe(Effect.provide(NoteStore.layerMemory)),
|
|
89
63
|
);
|
|
90
|
-
|
|
91
|
-
it.effect("refuses a write from a caller holding the read scope alone", () =>
|
|
92
|
-
Effect.gen(function* () {
|
|
93
|
-
const client = yield* Action.client(notes);
|
|
94
|
-
const create = client.createNote({ title: "Plan", body: "Draft" });
|
|
95
|
-
const refusal = yield* Effect.flip(create.pipe(as("reader")));
|
|
96
|
-
|
|
97
|
-
// `toEqual` on an error compares its fields and never its message, so the message is
|
|
98
|
-
// asserted on its own.
|
|
99
|
-
expect(refusal).toBeInstanceOf(Action.Forbidden);
|
|
100
|
-
expect(refusal.message).toBe("Requires notes:write.");
|
|
101
|
-
}).pipe(Effect.provide(NoteStore.layerMemory)),
|
|
102
|
-
);
|
|
103
64
|
});
|
|
@@ -22,7 +22,7 @@ const as = (token: string) => ({
|
|
|
22
22
|
});
|
|
23
23
|
|
|
24
24
|
describe("the service, over HTTP and MCP", () => {
|
|
25
|
-
it.effect("answers the public action without a token and refuses
|
|
25
|
+
it.effect("answers the public action without a token and refuses a protected one", () =>
|
|
26
26
|
Effect.gen(function* () {
|
|
27
27
|
const anyone = yield* ActionHttp.client(Http);
|
|
28
28
|
|
|
@@ -76,7 +76,7 @@ describe("the service, over HTTP and MCP", () => {
|
|
|
76
76
|
}).pipe(Effect.provide(served)),
|
|
77
77
|
);
|
|
78
78
|
|
|
79
|
-
it.effect("lists
|
|
79
|
+
it.effect("lists protected tools to a signed-out MCP client, as one tool is public", () =>
|
|
80
80
|
Effect.gen(function* () {
|
|
81
81
|
const response = yield* HttpClient.execute(ActionMcp.request("tools/list"));
|
|
82
82
|
|
|
@@ -6,6 +6,7 @@ import { Http } from "./Api.js";
|
|
|
6
6
|
|
|
7
7
|
// Entrypoint: `node --import tsx examples/quickstart/src/client.ts`, with the server running.
|
|
8
8
|
// One typed method per action of the binding.
|
|
9
|
+
// DEMO ONLY: a deployed client reads its `baseUrl` from configuration (kitchen-sink/src/seed.ts).
|
|
9
10
|
Effect.gen(function* () {
|
|
10
11
|
const client = yield* ActionHttp.client(Http, { baseUrl: "http://127.0.0.1:3000" });
|
|
11
12
|
|
|
@@ -5,10 +5,8 @@ import { HttpRouter } from "effect/http";
|
|
|
5
5
|
import { routes } from "./routes.js";
|
|
6
6
|
|
|
7
7
|
// Entrypoint: `node --import tsx examples/quickstart/src/server.ts`.
|
|
8
|
-
//
|
|
9
|
-
// `
|
|
10
|
-
// `127.0.0.1` serves this machine alone: a server others reach reads its host from config,
|
|
11
|
-
// `Config.String("HOST").pipe(Config.withDefault("127.0.0.1"))`.
|
|
8
|
+
// DEMO ONLY: a deployed server reads its host and port from config and limits request bodies,
|
|
9
|
+
// as notes-service/src/server.ts does; `127.0.0.1` serves this machine alone.
|
|
12
10
|
HttpRouter.serve(routes).pipe(
|
|
13
11
|
Layer.provide(NodeHttpServer.layer(createServer, { host: "127.0.0.1", port: 3000 })),
|
|
14
12
|
Layer.launch,
|
|
@@ -3,9 +3,9 @@ import { Effect } from "effect";
|
|
|
3
3
|
import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
|
|
4
4
|
import * as ActionMcp from "@gjermundgaraba/effect-actions/ActionMcp";
|
|
5
5
|
import * as Testing from "@gjermundgaraba/effect-actions/Testing";
|
|
6
|
-
import { Http } from "
|
|
7
|
-
import { Greet } from "
|
|
8
|
-
import { routes } from "
|
|
6
|
+
import { Http } from "../src/Api.js";
|
|
7
|
+
import { Greet } from "../src/greeting/Greeting.js";
|
|
8
|
+
import { routes } from "../src/routes.js";
|
|
9
9
|
|
|
10
10
|
// The routes answer in memory: no port, and the same code the server runs.
|
|
11
11
|
it.effect("greets over HTTP and as an MCP tool", () =>
|
package/package.json
CHANGED
|
@@ -1,93 +0,0 @@
|
|
|
1
|
-
# Action in process
|
|
2
|
-
|
|
3
|
-
Call actions from your own code, such as a job or a test, and share one build of the
|
|
4
|
-
implementations between servers and jobs.
|
|
5
|
-
|
|
6
|
-
Read [Action.md](Action.md) first, and this page alone, in one tool call. When something fails,
|
|
7
|
-
search [Action-failures.md](Action-failures.md). Reading order: [README.md](README.md).
|
|
8
|
-
|
|
9
|
-
## API
|
|
10
|
-
|
|
11
|
-
Import `@gjermundgaraba/effect-actions/Action`. Its other exports are in [Action.md](Action.md#api).
|
|
12
|
-
|
|
13
|
-
| Export | Purpose |
|
|
14
|
-
| --------------------------------------- | ------------------------------------------------------------------------ |
|
|
15
|
-
| `client(implementations, { actions? })` | An Effect of a client running them in process: `client.<action>(input)`. |
|
|
16
|
-
| `layer(implementations)` | Their builders as one layer: above several servers, each runs once. |
|
|
17
|
-
| `Client<Apps>` | What `client` gives for the implementations `Apps`, one or a list. |
|
|
18
|
-
| `ClientOptions` | What `client` takes: `actions`, the ones to call. |
|
|
19
|
-
|
|
20
|
-
## Call actions in process
|
|
21
|
-
|
|
22
|
-
`Action.client(implementations)` gives the methods `ActionHttp.client` gives. A call runs what a
|
|
23
|
-
remote call runs, without a transport: the input and the result pass through their JSON codecs,
|
|
24
|
-
and the authorizer runs before the handler.
|
|
25
|
-
|
|
26
|
-
```ts
|
|
27
|
-
const as = (user: keyof typeof users) => Effect.provideService(CurrentUser, users[user]);
|
|
28
|
-
|
|
29
|
-
const program = Effect.gen(function* () {
|
|
30
|
-
const client = yield* Action.client(notes);
|
|
31
|
-
|
|
32
|
-
return yield* client.createNote({ title: "Plan", body: "Draft" }).pipe(as("ada"));
|
|
33
|
-
}).pipe(Effect.scoped, Effect.provide(NoteStore.layerMemory));
|
|
34
|
-
```
|
|
35
|
-
|
|
36
|
-
Acquiring a client needs a `Scope`: under `it.effect` the test supplies it, as in
|
|
37
|
-
[notes-service/test/notes/notes.test.ts](../examples/notes-service/test/notes/notes.test.ts);
|
|
38
|
-
elsewhere `Effect.scoped` does, as in [kitchen-sink/src/agent.ts](../examples/kitchen-sink/src/agent.ts).
|
|
39
|
-
|
|
40
|
-
- **Provide the caller around each call, and startup services around the acquisition.** A call
|
|
41
|
-
reads its own context, never the one the client was acquired in, so one client serves every
|
|
42
|
-
caller ([assistant/test/identity/identity.test.ts](../examples/assistant/test/identity/identity.test.ts)).
|
|
43
|
-
- **Acquiring a client runs the builders**, and builds a built authorizer: it owes their startup
|
|
44
|
-
services. Acquire it once, in a builder, a layer, or a scoped program such as a test. Never in
|
|
45
|
-
a handler, which builds on every request.
|
|
46
|
-
- **Two acquisitions share a build only inside one layer graph**, such as under a layer provided
|
|
47
|
-
around the program. With none around them, each builds its own.
|
|
48
|
-
- **Every implemented action has a method**, whether or not a surface serves it. `actions` lists
|
|
49
|
-
the ones to call, and an implementation holding none of them is left unbuilt
|
|
50
|
-
([kitchen-sink/test/issues/issues.test.ts](../examples/kitchen-sink/test/issues/issues.test.ts)).
|
|
51
|
-
- **Test what an implementation does here**, and what a surface adds, its authentication,
|
|
52
|
-
statuses and codecs, in memory ([Testing.md](Testing.md#test-routes-in-memory)). Assert on a
|
|
53
|
-
failure as [Testing.md](Testing.md#assert-on-errors) says.
|
|
54
|
-
|
|
55
|
-
## Share one build across servers
|
|
56
|
-
|
|
57
|
-
Inside one `HttpRouter.serve` or one `Testing.layer`, a builder runs once, however many surfaces
|
|
58
|
-
serve it, a toolkit's `layer` provided to the routes included. Each builds its routes in a layer graph of its own, as `HttpRouter.provideRequest(layer)` builds its layer, which reuses what is provided from
|
|
59
|
-
outside it and shares nothing it builds itself: a builder's run, a built authorizer, or a
|
|
60
|
-
service of yours such as an in-memory store.
|
|
61
|
-
|
|
62
|
-
- **With one server, put everything the process runs inside the layer it serves**, a job or an
|
|
63
|
-
agent beside the routes included.
|
|
64
|
-
- **Otherwise, provide `Action.layer(implementations)` and the services they share above the
|
|
65
|
-
server and its siblings**: a second server, a program beside it, several `Testing.layer`s.
|
|
66
|
-
|
|
67
|
-
```ts
|
|
68
|
-
// kitchen-sink/src/routes.ts
|
|
69
|
-
export const builders = Action.layer([status, identity, issues, attachments, admin]);
|
|
70
|
-
|
|
71
|
-
// kitchen-sink/src/server.ts
|
|
72
|
-
Layer.mergeAll(
|
|
73
|
-
HttpRouter.serve(Layer.merge(publicRoutes, socket)).pipe(Layer.provide(listen(publicPort))),
|
|
74
|
-
HttpRouter.serve(adminRoutes).pipe(Layer.provide(listen(adminPort))),
|
|
75
|
-
).pipe(Layer.provide(builders), Layer.provide(memory));
|
|
76
|
-
```
|
|
77
|
-
|
|
78
|
-
That is [kitchen-sink/src/routes.ts](../examples/kitchen-sink/src/routes.ts) and
|
|
79
|
-
[kitchen-sink/src/server.ts](../examples/kitchen-sink/src/server.ts), two ports;
|
|
80
|
-
[kitchen-sink/test/routes.test.ts](../examples/kitchen-sink/test/routes.test.ts) does it with two
|
|
81
|
-
`Testing.layer`s, asserting one run of a builder both ports serve.
|
|
82
|
-
|
|
83
|
-
- **A job merged beside `HttpRouter.serve`, with no `Action.layer` above both, shares the
|
|
84
|
-
server's build only when its own side builds first.** Built after the routes, it builds
|
|
85
|
-
again, with state of its own. Which builds first depends on timing: it can pass in a test and
|
|
86
|
-
build twice once the job starts later.
|
|
87
|
-
- **`Action.layer` builds every implementation it is given**, those no surface serves included,
|
|
88
|
-
so it owes their startup services.
|
|
89
|
-
- **Inside one layer graph, a builder runs with the services of whichever surface builds it
|
|
90
|
-
first**, and services provided around one surface reach the others. Provide startup services
|
|
91
|
-
once, for every surface.
|
|
92
|
-
- **Do not wrap a server in `Layer.fresh`**: two servers already share no build. It is only for
|
|
93
|
-
two surfaces in one graph that must each have a build and startup services of their own.
|
package/docs/CONTEXT.md
DELETED
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
# Context
|
|
2
|
-
|
|
3
|
-
Vocabulary for `@gjermundgaraba/effect-actions`. The docs, the examples, the code and the tests
|
|
4
|
-
use these words with these meanings. Module names and Effect's own terms (`HttpApi`, `Tool`,
|
|
5
|
-
`Toolkit`, `McpServer`, `Rpc`, `RpcGroup`, `RpcServer`, `Command`) mean what their modules say.
|
|
6
|
-
|
|
7
|
-
Read this page alone, in one tool call; if its output says it was truncated, read it again alone.
|
|
8
|
-
Tasks are routed from [README.md](README.md), and each module's failures from its `-failures.md`
|
|
9
|
-
page.
|
|
10
|
-
|
|
11
|
-
## Terms
|
|
12
|
-
|
|
13
|
-
### Contracts and calls
|
|
14
|
-
|
|
15
|
-
- **Action**: one contract: a name, a description, input, success and declared error schemas, `readOnly`, `caller`, and its `mcp` options. Holds no behavior. Made by `Action.make`.
|
|
16
|
-
- **Read-only**: `readOnly`, required on every action: whether it leaves its resource unchanged. It enforces nothing itself.
|
|
17
|
-
- **Caller**: who may call an action, `caller`. `Action.Anyone` makes a **public action**; an identity service, such as `caller: CurrentUser`, makes a **protected action**.
|
|
18
|
-
- **Identity**: the service a protected action names as its caller. A **trusted identity** is one only a host supplies, on a local surface, which no token names.
|
|
19
|
-
- **Handler**: a function from an action's decoded input to an Effect of its decoded success. The services it yields per call, and an authorizer's, are **request-time requirements**.
|
|
20
|
-
- **Implementation**: what `Action.implement` returns: actions bound to their handlers and, where any is protected, their authorizer. Every surface that runs handlers takes one, or a list.
|
|
21
|
-
- **Builder**: an Effect given to `Action.implement` in place of handlers, which returns them, once per layer graph. The services it yields are **build-time requirements**, or **startup services** ([Action.md](Action.md#implement-actions)).
|
|
22
|
-
- **Layer graph**: the layers Effect builds together, each built once ([Action-in-process.md](Action-in-process.md#share-one-build-across-servers)).
|
|
23
|
-
- **Host**: the application that serves the surfaces: it builds their layers, provides their services and runs the server, subprocess or CLI.
|
|
24
|
-
- **Surface**: where callers reach actions. A **remote surface** authenticates its callers (`ActionHttp.layer`, `ActionMcp.layerHttp`, `ActionRpc.layer`). A **local surface** has no one to authenticate (`ActionToolkit`, a local `ActionCli` command, `ActionMcp.runStdio`, `Action.client`): the host provides the identity.
|
|
25
|
-
- **Binding**: what `ActionHttp.make` or `ActionRpc.make` returns: the actions its surface serves, as browser-safe data the server and every client share.
|
|
26
|
-
- **Client**: one method per action, `client.<action>(input)`: `ActionHttp.client`, `ActionRpc.client`, `ActionMcp.client` and, in process, `Action.client`.
|
|
27
|
-
- **Authorizer**: `authorize`, a function of the called action that an implementation of protected actions states. It succeeds or fails with a refusal ([Action.md](Action.md#authorize-calls)).
|
|
28
|
-
- **Authentication descriptor**: what `Authentication.make` returns: a named, browser-safe declaration of how a remote caller proves an identity. A binding or an MCP endpoint serving protected actions names it.
|
|
29
|
-
- **Authentication provider**: what `Authentication.layer` returns for a descriptor, holding its **verifier**: the server-only function from a credential to the identity.
|
|
30
|
-
- **Layer middleware**: native `HttpApiMiddleware` or `RpcMiddleware` given to `ActionHttp.layer` or `ActionRpc.layer` as `middleware`, run around every route or rpc the layer serves.
|
|
31
|
-
- **Mixed endpoint**: an MCP endpoint serving public and protected tools on one URL ([ActionMcp.md](ActionMcp.md#protect-tools)).
|
|
32
|
-
- **Declared error**: an error schema listed in an action's `error`, which its handler may fail with ([guarantees.md](guarantees.md#wire-behavior)).
|
|
33
|
-
- **Built-in errors**: `Action.InvalidInput`, `Action.Unauthenticated` and `Action.Forbidden`, together `Action.BuiltIn`: errors the library produces and every surface declares ([Action.md](Action.md#fail-with-declared-and-built-in-errors)).
|
|
34
|
-
- **Refusal**: `Action.Refusal`, `Unauthenticated | Forbidden`: what authentication or an authorizer refuses a caller with. A **step-up refusal** is one an OAuth client acts on: an `Unauthenticated`, or a `Forbidden` naming `scopes` ([Authentication.md](Authentication.md#publish-oauth-discovery)).
|
|
35
|
-
- **Media field**: a field of an action's success whose schema is a media kind, `Action.Image`, which an MCP tool **lifts** into a content block of its own ([ActionMcp.md](ActionMcp.md#return-images-and-other-media)).
|
|
36
|
-
|
|
37
|
-
### Layout of an application
|
|
38
|
-
|
|
39
|
-
The parts of the layout every example uses: [examples/README.md](../examples/README.md#how-they-are-laid-out).
|
|
40
|
-
|
|
41
|
-
- **Domain**: one subject of the application, such as notes or identity, and the folder holding everything about it. A folder is never a mechanism, such as `contracts/` or `handlers/`, but **Platform**.
|
|
42
|
-
- **Platform**: `platform/`, the one folder named for a mechanism: the adapters, each a service's Layer over a technology, such as a file or an upstream HTTP API. A domain imports none of them; an entrypoint provides them.
|
|
43
|
-
- **Contract file**: the PascalCase file of a domain, such as `notes/Notes.ts`: its schemas, errors and actions, and the identity and descriptor it declares. It imports no server code.
|
|
44
|
-
- **Routes**: `routes.ts`, the host's surfaces as one Layer. A route, singular, is one HTTP route.
|
|
45
|
-
- **Entrypoint**: a file that runs the application one way, such as `server.ts`, `stdio.ts` or `cli.ts`: the **composition root**, the one place that chooses the services, the platform and, on a local surface, the caller.
|
|
46
|
-
- **Demo shortcut**: what an example does in place of what a deployment needs, marked `DEMO ONLY` in a comment.
|