@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
@@ -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. It is an Effect, not a client: every command here, the derived ones and your own,
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. Left as it is, a
24
- // missing token prints as `ConfigError: …`; mapped, it prints the message, which says what to
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 goes to stderr before it, on a line of its own. A
72
- // `UserError` wrapped around the failure instead would put its JSON mid-sentence. The
73
- // line prints for every failure of the command, a file that cannot be written included,
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
- // Once, around the whole command: it then fails as a derived command does, an action's
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. Every implementation of protected actions states it, so HTTP, MCP and stdio all run it
7
- // before the handler. Naming the missing scope lets an OAuth client ask for it and retry: it is
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. Every layer serving a protected action requires it and
21
- // authenticates before it decodes the input. As an OAuth protected resource it also publishes
22
- // discovery, so an MCP client that gets a 401 finds where to sign in, asking for read first.
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, as is any update of what is stored, a flag such as "read" included.
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. A protected route authenticates before it
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. Listing the tools and calling `getStatus` need no token, because `getStatus` is
34
- // public: an endpoint of protected tools alone asks a token for the listing too. Any other call
35
- // answers 401 with where to sign in, and so does a client of an MCP revision with sessions when
36
- // it opens one, since a session may call any tool. It still requires `NoteStore`: the entrypoint
37
- // chooses which one, and a test chooses its own. The entrypoint also chooses how the MCP
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
- // This is the composition root of the server. It chooses the store, reads the configuration
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: mcp-tools' README says why the paths are full.
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 share them,
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 with one call, all its notes or none", () =>
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
- // What the implementation does, with no transport: `Action.client` runs the same decoding,
11
- // `authorize` and handler a server runs. Each call names its caller, as a request would.
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 the rest", () =>
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 every tool to a signed-out MCP client, as one of them is public", () =>
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
 
@@ -27,5 +27,5 @@ src/
27
27
  ├── routes.ts the surfaces: POST /api/greet and /mcp
28
28
  ├── server.ts entrypoint
29
29
  └── client.ts entrypoint
30
- test/greeting/ the routes, called in memory
30
+ test/routes.test.ts the routes, called in memory
31
31
  ```
@@ -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
- // A real server reads its port from config, `Config.Port("PORT")`, and limits request bodies:
9
- // `Layer.provide(Layer.succeed(HttpServerRequest.MaxBodySize, ByteSize.mebibytes(1)))`.
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 "../../src/Api.js";
7
- import { Greet } from "../../src/greeting/Greeting.js";
8
- import { routes } from "../../src/routes.js";
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjermundgaraba/effect-actions",
3
- "version": "0.18.2",
3
+ "version": "0.19.0",
4
4
  "description": "Define Effect action contracts once and project them onto HTTP, MCP, Effect RPC, native Toolkits, and CLIs.",
5
5
  "keywords": [
6
6
  "ai-agents",
@@ -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.