@gjermundgaraba/effect-actions 0.16.0 → 0.18.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 (76) hide show
  1. package/README.md +3 -1
  2. package/dist/Action.d.mts +3 -5
  3. package/dist/Action.mjs +3 -1
  4. package/dist/ActionCli.d.mts +31 -42
  5. package/dist/ActionCli.mjs +6 -16
  6. package/dist/ActionHttp.d.mts +12 -8
  7. package/dist/ActionHttp.mjs +3 -1
  8. package/dist/ActionMcp.d.mts +1 -1
  9. package/dist/ActionMcp.mjs +4 -1
  10. package/dist/ActionRpc.d.mts +5 -3
  11. package/dist/ActionRpc.mjs +2 -1
  12. package/dist/ActionToolkit.d.mts +3 -2
  13. package/dist/ActionToolkit.mjs +3 -1
  14. package/dist/Authentication.d.mts +1 -1
  15. package/dist/Authentication.mjs +1 -1
  16. package/dist/authentication/provider.d.mts +2 -2
  17. package/dist/authentication/refusal.mjs +1 -1
  18. package/dist/cli/commands.d.mts +4 -3
  19. package/dist/cli/commands.mjs +6 -5
  20. package/dist/contract/ast.mjs +10 -0
  21. package/dist/contract/call.d.mts +1 -1
  22. package/dist/contract/caller.d.mts +9 -0
  23. package/dist/contract/caller.mjs +12 -0
  24. package/dist/contract/{rules.d.mts → declared.d.mts} +6 -10
  25. package/dist/contract/declared.mjs +42 -0
  26. package/dist/contract/hints.mjs +14 -0
  27. package/dist/contract/implementation.d.mts +4 -3
  28. package/dist/contract/implementation.mjs +4 -8
  29. package/dist/contract/names.mjs +25 -0
  30. package/dist/contract/status.mjs +18 -0
  31. package/dist/http/client.d.mts +19 -9
  32. package/dist/http/client.mjs +6 -1
  33. package/dist/mcp/client.d.mts +52 -12
  34. package/dist/mcp/client.mjs +62 -19
  35. package/dist/mcp/protocol.mjs +1 -1
  36. package/docs/Action.md +17 -3
  37. package/docs/ActionCli-failures.md +10 -9
  38. package/docs/ActionCli.md +22 -21
  39. package/docs/ActionHttp-failures.md +6 -0
  40. package/docs/ActionHttp.md +9 -4
  41. package/docs/ActionMcp.md +3 -3
  42. package/docs/ActionToolkit.md +25 -2
  43. package/docs/Authentication-failures.md +1 -1
  44. package/docs/CONTEXT.md +2 -1
  45. package/docs/README.md +4 -4
  46. package/docs/Testing.md +3 -1
  47. package/examples/README.md +10 -2
  48. package/examples/assistant/README.md +24 -20
  49. package/examples/assistant/src/agent.ts +11 -7
  50. package/examples/assistant/src/cli.ts +2 -2
  51. package/examples/assistant/src/platform/tasks-file.ts +38 -0
  52. package/examples/assistant/src/tasks/store.ts +6 -38
  53. package/examples/assistant/src/toolkit.ts +11 -1
  54. package/examples/assistant/test/{tasks/store.test.ts → platform/tasks-file.test.ts} +2 -1
  55. package/examples/kitchen-sink/src/cli.ts +19 -21
  56. package/examples/mcp-tools/README.md +14 -11
  57. package/examples/mcp-tools/src/{printers/fake.ts → platform/fake-farm.ts} +2 -2
  58. package/examples/mcp-tools/src/platform/print-farm.ts +118 -0
  59. package/examples/mcp-tools/src/printers/farm.ts +6 -122
  60. package/examples/mcp-tools/src/server.ts +4 -4
  61. package/examples/mcp-tools/src/stdio.ts +4 -4
  62. package/examples/mcp-tools/test/printers/printers.test.ts +3 -3
  63. package/examples/mcp-tools/test/routes.test.ts +4 -4
  64. package/examples/notes-service/README.md +34 -2
  65. package/examples/notes-service/src/cli.ts +3 -9
  66. package/examples/notes-service/src/commands.ts +40 -36
  67. package/examples/notes-service/src/identity/authorize.ts +1 -0
  68. package/examples/notes-service/src/notes/Notes.ts +22 -5
  69. package/examples/notes-service/src/notes/handlers.ts +2 -2
  70. package/examples/notes-service/src/notes/store.ts +36 -6
  71. package/examples/notes-service/test/commands.test.ts +13 -19
  72. package/examples/notes-service/test/notes/idempotency.test.ts +132 -0
  73. package/examples/notes-service/test/routes.test.ts +40 -1
  74. package/package.json +5 -5
  75. package/dist/_virtual/_rolldown/runtime.mjs +0 -13
  76. package/dist/contract/rules.mjs +0 -100
@@ -0,0 +1,118 @@
1
+ import { Config, Effect, Layer, Redacted, Schema } from "effect";
2
+ import {
3
+ HttpBody,
4
+ HttpClient,
5
+ type HttpClientError,
6
+ HttpClientRequest,
7
+ HttpClientResponse,
8
+ } from "effect/http";
9
+ import { PrintFarm } from "../printers/farm.js";
10
+ import { Printer, PrintFarmError } from "../printers/Printers.js";
11
+
12
+ const fail = (message: string) => Effect.fail(new PrintFarmError({ message }));
13
+
14
+ const unreadable = "The print farm sent an answer this server could not read.";
15
+
16
+ // The farm answers a request it refuses with a status that is not 2xx and `{ "error": "..." }`.
17
+ // Its reason is passed on to the model, which can act on "The printer is busy with a job".
18
+ const refuseUnlessOk = HttpClientResponse.matchStatus({
19
+ "2xx": (response) => Effect.succeed(response),
20
+ orElse: (response) =>
21
+ HttpClientResponse.schemaBodyJson(Schema.Struct({ error: Schema.String }))(response).pipe(
22
+ Effect.matchEffect({
23
+ onFailure: () => fail(`The print farm refused the request (HTTP ${response.status}).`),
24
+ onSuccess: ({ error }) => fail(`The print farm refused: ${error}`),
25
+ }),
26
+ ),
27
+ });
28
+
29
+ // Every other way a call to the farm fails, as the same declared error: no connection, an
30
+ // answer of another shape than expected, or none in time. Nothing of the HTTP client's own
31
+ // errors leaves this file, so no tool can fail with something its contract does not declare.
32
+ const asPrintFarmError = <A>(
33
+ effect: Effect.Effect<A, PrintFarmError | HttpClientError.HttpClientError | Schema.SchemaError>,
34
+ ) =>
35
+ effect.pipe(
36
+ Effect.catchTag("HttpClientError", ({ response }) =>
37
+ fail(response === undefined ? "The print farm could not be reached." : unreadable),
38
+ ),
39
+ Effect.catchTag("SchemaError", () => fail(unreadable)),
40
+ Effect.timeoutOrElse({
41
+ duration: "30 seconds",
42
+ orElse: () => fail("The print farm did not answer in time."),
43
+ }),
44
+ );
45
+
46
+ const asImage = (response: HttpClientResponse.HttpClientResponse) =>
47
+ Effect.gen(function* () {
48
+ const mimeType = response.headers["content-type"];
49
+
50
+ if (mimeType === undefined || !mimeType.startsWith("image/")) return yield* fail(unreadable);
51
+
52
+ return { data: new Uint8Array(yield* response.arrayBuffer), mimeType };
53
+ });
54
+
55
+ // Built once, when the process starts. It requires an `HttpClient` and reads its settings
56
+ // from `Config`: the entrypoint decides which client, and the environment says which farm.
57
+ // Not `fetch`: as a service, a test replaces the network alone and opens no port
58
+ // (test/printers/printers.test.ts).
59
+ export const printFarm = Layer.effect(
60
+ PrintFarm,
61
+ Effect.gen(function* () {
62
+ const { url, user, password } = yield* Config.all({
63
+ url: Config.URL("FARM_URL"),
64
+ user: Config.NonEmptyString("FARM_USER"),
65
+ password: Config.Redacted("FARM_PASSWORD"),
66
+ });
67
+
68
+ const http = (yield* HttpClient.HttpClient).pipe(
69
+ HttpClient.mapRequest(HttpClientRequest.prependUrl(url.href)),
70
+ HttpClient.transformResponse(Effect.flatMap(refuseUnlessOk)),
71
+ );
72
+
73
+ // Signs in once, here, and every call below reuses the token, kept `Redacted` so no log
74
+ // prints it. A farm that refuses the sign-in stops the process at startup, with its
75
+ // reason, before any tool is listed. A real client also signs in again when a call
76
+ // answers 401, as tokens expire.
77
+ const session = yield* http
78
+ .post("/api/login", {
79
+ body: HttpBody.jsonUnsafe({ user, password: Redacted.value(password) }),
80
+ })
81
+ .pipe(
82
+ Effect.flatMap(
83
+ HttpClientResponse.schemaBodyJson(
84
+ Schema.Struct({ token: Schema.RedactedFromValue(Schema.String) }),
85
+ ),
86
+ ),
87
+ asPrintFarmError,
88
+ );
89
+
90
+ yield* Effect.logInfo(`Signed in to the print farm at ${url.host} as ${user}.`);
91
+
92
+ const api = HttpClient.mapRequest(http, HttpClientRequest.bearerToken(session.token));
93
+
94
+ // An id is written into the path as it is: the contract admits only letters, digits,
95
+ // `_` and `-`, so it is always one segment. Encoding would not make other text safe:
96
+ // `encodeURIComponent` leaves `..` alone, and a URL parser then walks up the path.
97
+ const at = (id: string, rest = "") => `/api/printers/${id}${rest}`;
98
+
99
+ const printer = (answer: ReturnType<typeof api.get>) =>
100
+ answer.pipe(Effect.flatMap(HttpClientResponse.schemaBodyJson(Printer)), asPrintFarmError);
101
+
102
+ return PrintFarm.of({
103
+ printers: api.get("/api/printers").pipe(
104
+ Effect.flatMap(
105
+ HttpClientResponse.schemaBodyJson(Schema.Struct({ printers: Schema.Array(Printer) })),
106
+ ),
107
+ Effect.map(({ printers }) => printers),
108
+ asPrintFarmError,
109
+ ),
110
+ printer: (id) => printer(api.get(at(id))),
111
+ snapshot: (id) => api.get(at(id, "/camera")).pipe(Effect.flatMap(asImage), asPrintFarmError),
112
+ start: (id, file) =>
113
+ printer(api.post(at(id, "/job"), { body: HttpBody.jsonUnsafe({ file }) })),
114
+ setPaused: (id, paused) => printer(api.post(at(id, paused ? "/pause" : "/resume"))),
115
+ cancel: (id) => printer(api.del(at(id, "/job"))),
116
+ });
117
+ }),
118
+ );
@@ -1,61 +1,11 @@
1
- import { Config, Context, Effect, Layer, Redacted, Schema } from "effect";
2
- import {
3
- HttpBody,
4
- HttpClient,
5
- type HttpClientError,
6
- HttpClientRequest,
7
- HttpClientResponse,
8
- } from "effect/http";
1
+ import { Context, type Effect } from "effect";
9
2
  import type * as Action from "@gjermundgaraba/effect-actions/Action";
10
- import { Printer, PrintFarmError } from "./Printers.js";
11
-
12
- const fail = (message: string) => Effect.fail(new PrintFarmError({ message }));
13
-
14
- const unreadable = "The print farm sent an answer this server could not read.";
15
-
16
- // The farm answers a request it refuses with a status that is not 2xx and `{ "error": "..." }`.
17
- // Its reason is passed on to the model, which can act on "The printer is busy with a job".
18
- const refuseUnlessOk = HttpClientResponse.matchStatus({
19
- "2xx": (response) => Effect.succeed(response),
20
- orElse: (response) =>
21
- HttpClientResponse.schemaBodyJson(Schema.Struct({ error: Schema.String }))(response).pipe(
22
- Effect.matchEffect({
23
- onFailure: () => fail(`The print farm refused the request (HTTP ${response.status}).`),
24
- onSuccess: ({ error }) => fail(`The print farm refused: ${error}`),
25
- }),
26
- ),
27
- });
28
-
29
- // Every other way a call to the farm fails, as the same declared error: no connection, an
30
- // answer of another shape than expected, or none in time. Nothing of the HTTP client's own
31
- // errors leaves this file, so no tool can fail with something its contract does not declare.
32
- const asPrintFarmError = <A>(
33
- effect: Effect.Effect<A, PrintFarmError | HttpClientError.HttpClientError | Schema.SchemaError>,
34
- ) =>
35
- effect.pipe(
36
- Effect.catchTag("HttpClientError", ({ response }) =>
37
- fail(response === undefined ? "The print farm could not be reached." : unreadable),
38
- ),
39
- Effect.catchTag("SchemaError", () => fail(unreadable)),
40
- Effect.timeoutOrElse({
41
- duration: "30 seconds",
42
- orElse: () => fail("The print farm did not answer in time."),
43
- }),
44
- );
45
-
46
- const asImage = (response: HttpClientResponse.HttpClientResponse) =>
47
- Effect.gen(function* () {
48
- const mimeType = response.headers["content-type"];
49
-
50
- if (mimeType === undefined || !mimeType.startsWith("image/")) return yield* fail(unreadable);
51
-
52
- return { data: new Uint8Array(yield* response.arrayBuffer), mimeType };
53
- });
3
+ import type { Printer, PrintFarmError } from "./Printers.js";
54
4
 
55
5
  /**
56
- * The print farm's HTTP API, the one upstream of this server. Its methods require nothing and
57
- * fail only with the error the tools declare: the layer has captured the HTTP client, the
58
- * farm's URL and the session.
6
+ * The print farm, the one upstream of this server. Its methods require nothing and fail only
7
+ * with the error the tools declare: its layer, `printFarm` in src/platform/print-farm.ts, has
8
+ * captured the HTTP client, the farm's URL and the session.
59
9
  */
60
10
  export class PrintFarm extends Context.Service<
61
11
  PrintFarm,
@@ -67,70 +17,4 @@ export class PrintFarm extends Context.Service<
67
17
  readonly setPaused: (id: string, paused: boolean) => Effect.Effect<Printer, PrintFarmError>;
68
18
  readonly cancel: (id: string) => Effect.Effect<Printer, PrintFarmError>;
69
19
  }
70
- >()("mcp-tools/PrintFarm") {
71
- // Built once, when the process starts. It requires an `HttpClient` and reads its settings
72
- // from `Config`: the entrypoint decides which client, and the environment says which farm.
73
- // Not `fetch`: as a service, a test replaces the network alone and opens no port
74
- // (test/printers/printers.test.ts).
75
- static readonly layer = Layer.effect(
76
- PrintFarm,
77
- Effect.gen(function* () {
78
- const { url, user, password } = yield* Config.all({
79
- url: Config.URL("FARM_URL"),
80
- user: Config.NonEmptyString("FARM_USER"),
81
- password: Config.Redacted("FARM_PASSWORD"),
82
- });
83
-
84
- const http = (yield* HttpClient.HttpClient).pipe(
85
- HttpClient.mapRequest(HttpClientRequest.prependUrl(url.href)),
86
- HttpClient.transformResponse(Effect.flatMap(refuseUnlessOk)),
87
- );
88
-
89
- // Signs in once, here, and every call below reuses the token, kept `Redacted` so no log
90
- // prints it. A farm that refuses the sign-in stops the process at startup, with its
91
- // reason, before any tool is listed. A real client also signs in again when a call
92
- // answers 401, as tokens expire.
93
- const session = yield* http
94
- .post("/api/login", {
95
- body: HttpBody.jsonUnsafe({ user, password: Redacted.value(password) }),
96
- })
97
- .pipe(
98
- Effect.flatMap(
99
- HttpClientResponse.schemaBodyJson(
100
- Schema.Struct({ token: Schema.RedactedFromValue(Schema.String) }),
101
- ),
102
- ),
103
- asPrintFarmError,
104
- );
105
-
106
- yield* Effect.logInfo(`Signed in to the print farm at ${url.host} as ${user}.`);
107
-
108
- const api = HttpClient.mapRequest(http, HttpClientRequest.bearerToken(session.token));
109
-
110
- // An id is written into the path as it is: the contract admits only letters, digits,
111
- // `_` and `-`, so it is always one segment. Encoding would not make other text safe:
112
- // `encodeURIComponent` leaves `..` alone, and a URL parser then walks up the path.
113
- const at = (id: string, rest = "") => `/api/printers/${id}${rest}`;
114
-
115
- const printer = (answer: ReturnType<typeof api.get>) =>
116
- answer.pipe(Effect.flatMap(HttpClientResponse.schemaBodyJson(Printer)), asPrintFarmError);
117
-
118
- return PrintFarm.of({
119
- printers: api.get("/api/printers").pipe(
120
- Effect.flatMap(
121
- HttpClientResponse.schemaBodyJson(Schema.Struct({ printers: Schema.Array(Printer) })),
122
- ),
123
- Effect.map(({ printers }) => printers),
124
- asPrintFarmError,
125
- ),
126
- printer: (id) => printer(api.get(at(id))),
127
- snapshot: (id) =>
128
- api.get(at(id, "/camera")).pipe(Effect.flatMap(asImage), asPrintFarmError),
129
- start: (id, file) =>
130
- printer(api.post(at(id, "/job"), { body: HttpBody.jsonUnsafe({ file }) })),
131
- setPaused: (id, paused) => printer(api.post(at(id, paused ? "/pause" : "/resume"))),
132
- cancel: (id) => printer(api.del(at(id, "/job"))),
133
- });
134
- }),
135
- );
136
- }
20
+ >()("mcp-tools/PrintFarm") {}
@@ -2,8 +2,8 @@ import { createServer } from "node:http";
2
2
  import { NodeHttpServer, NodeRuntime } from "@effect/platform-node";
3
3
  import { ByteSize, Config, Effect, Layer } from "effect";
4
4
  import { HttpRouter, HttpServerRequest } from "effect/http";
5
- import { fakeFarm } from "./printers/fake.js";
6
- import { PrintFarm } from "./printers/farm.js";
5
+ import { fakeFarm } from "./platform/fake-farm.js";
6
+ import { printFarm } from "./platform/print-farm.js";
7
7
  import { routes } from "./routes.js";
8
8
 
9
9
  // Entrypoint: `node --import tsx examples/mcp-tools/src/server.ts`, with FARM_URL, FARM_USER
@@ -22,8 +22,8 @@ const server = Layer.unwrap(
22
22
  // short fields, so anything larger is not one.
23
23
  Layer.provide(Layer.succeed(HttpServerRequest.MaxBodySize, ByteSize.kibibytes(64))),
24
24
  // DEMO ONLY: `fakeFarm` answers the farm's API in memory. A real server provides
25
- // `FetchHttpClient.layer` in its place, and `PrintFarm.layer` calls the farm at FARM_URL.
26
- Layer.provide(PrintFarm.layer.pipe(Layer.provide(fakeFarm))),
25
+ // `FetchHttpClient.layer` in its place, and `printFarm` calls the farm at FARM_URL.
26
+ Layer.provide(printFarm.pipe(Layer.provide(fakeFarm))),
27
27
  );
28
28
  }),
29
29
  );
@@ -3,8 +3,8 @@ import { Effect, Layer } from "effect";
3
3
  import * as ActionCli from "@gjermundgaraba/effect-actions/ActionCli";
4
4
  import * as ActionMcp from "@gjermundgaraba/effect-actions/ActionMcp";
5
5
  import { serverInfo } from "./instructions.js";
6
- import { fakeFarm } from "./printers/fake.js";
7
- import { PrintFarm } from "./printers/farm.js";
6
+ import { fakeFarm } from "./platform/fake-farm.js";
7
+ import { printFarm } from "./platform/print-farm.js";
8
8
  import { printers } from "./printers/handlers.js";
9
9
 
10
10
  // Entrypoint: an MCP server a local agent starts as a subprocess, with FARM_URL, FARM_USER and
@@ -17,10 +17,10 @@ ActionMcp.runStdio(printers, serverInfo).pipe(
17
17
  NodeStdio.layer,
18
18
  // DEMO ONLY: `fakeFarm` answers the farm's API in memory. A real server provides
19
19
  // `FetchHttpClient.layer` in its place.
20
- PrintFarm.layer.pipe(Layer.provide(fakeFarm)),
20
+ printFarm.pipe(Layer.provide(fakeFarm)),
21
21
  ]),
22
22
  // stdout carries the protocol alone. Applied last, this sends every log to stderr, the
23
- // sign-in line `PrintFarm.layer` logs included, and a startup failure too.
23
+ // sign-in line `printFarm` logs included, and a startup failure too.
24
24
  ActionCli.logToStderr,
25
25
  NodeRuntime.runMain,
26
26
  );
@@ -8,8 +8,8 @@ import {
8
8
  } from "effect/http";
9
9
  import { TestClock } from "effect/testing";
10
10
  import * as Action from "@gjermundgaraba/effect-actions/Action";
11
- import { fakeFarm } from "../../src/printers/fake.js";
12
- import { PrintFarm } from "../../src/printers/farm.js";
11
+ import { fakeFarm } from "../../src/platform/fake-farm.js";
12
+ import { printFarm } from "../../src/platform/print-farm.js";
13
13
  import { printers } from "../../src/printers/handlers.js";
14
14
  import { PrintFarmError } from "../../src/printers/Printers.js";
15
15
 
@@ -19,7 +19,7 @@ import { PrintFarmError } from "../../src/printers/Printers.js";
19
19
  // The real `PrintFarm` over an upstream of the test's: its requests, its sign-in and its error
20
20
  // mapping all run, and only the network is replaced. Its settings come from a Layer too.
21
21
  const farmOver = (upstream: Layer.Layer<HttpClient.HttpClient>) =>
22
- PrintFarm.layer.pipe(
22
+ printFarm.pipe(
23
23
  Layer.provide(upstream),
24
24
  Layer.provide(
25
25
  ConfigProvider.layer(
@@ -3,8 +3,8 @@ import { ConfigProvider, Effect, Layer, Schema } from "effect";
3
3
  import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
4
4
  import * as ActionMcp from "@gjermundgaraba/effect-actions/ActionMcp";
5
5
  import * as Testing from "@gjermundgaraba/effect-actions/Testing";
6
- import { fakeFarm, picture } from "../src/printers/fake.js";
7
- import { PrintFarm } from "../src/printers/farm.js";
6
+ import { fakeFarm, picture } from "../src/platform/fake-farm.js";
7
+ import { printFarm } from "../src/platform/print-farm.js";
8
8
  import { PrintFarmError, Printers } from "../src/printers/Printers.js";
9
9
  import { routes } from "../src/routes.js";
10
10
 
@@ -15,10 +15,10 @@ import { routes } from "../src/routes.js";
15
15
  const farm = { FARM_URL: "https://farm.example.test", FARM_USER: "agent", FARM_PASSWORD: "secret" };
16
16
 
17
17
  // `Testing.layer` gives the test an `HttpClient` of its own, which reaches the routes. The
18
- // fake farm is another `HttpClient`, provided to `PrintFarm.layer` alone.
18
+ // fake farm is another `HttpClient`, provided to `printFarm` alone.
19
19
  const servedWith = (environment: Record<string, string>) =>
20
20
  Testing.layer(routes).pipe(
21
- Layer.provide(PrintFarm.layer.pipe(Layer.provide(fakeFarm))),
21
+ Layer.provide(printFarm.pipe(Layer.provide(fakeFarm))),
22
22
  Layer.provide(ConfigProvider.layer(ConfigProvider.fromEnvRecord(environment))),
23
23
  );
24
24
 
@@ -50,6 +50,37 @@ prints one message on stderr saying to set it, and exits 1. `--help` needs none.
50
50
 
51
51
  The tokens are fixed names and the store is memory: replace both before deploying anything.
52
52
 
53
+ ## Retry a creation
54
+
55
+ `createNote({ title, body })` creates a new note on every call. To retry safely, add an optional
56
+ `requestId`. Generate it once for a new creation, for example with `crypto.randomUUID()`, and
57
+ keep it with the pending operation outside any retry loop. After a lost response, send the
58
+ same key and content again. For the CLI, pass `--request-id` and keep it when repeating the
59
+ command:
60
+
61
+ ```sh
62
+ echo "First draft" | node --import tsx examples/notes-service/src/cli.ts create-note --request-id plan-1 --title Plan
63
+ ```
64
+
65
+ The key belongs to the signed-in caller and the `createNote` operation. Another caller can use
66
+ it independently. The same caller repeating the key and content gets the original result;
67
+ different content fails with `RequestConflict`. A new key creates a new note, even when its
68
+ content is identical. Every attempt still runs authorization.
69
+
70
+ The replay result is a snapshot of the creation. Updating or deleting the note leaves that
71
+ snapshot intact: replay neither changes the note back nor recreates it. `importNotes` has no
72
+ request key; each import creates new notes. Calls without a key are not idempotent, so the
73
+ creation tool does not advertise `idempotentHint: true`.
74
+
75
+ [src/notes/store.ts](src/notes/store.ts) owns creation and replay recording.
76
+
77
+ **DEMO ONLY:** notes and replay records live in this process's memory, without eviction. They
78
+ do not survive a restart or coordinate separate server instances. A deployed store must commit
79
+ the mutation and replay record in one transaction, enforce uniqueness of caller, operation and
80
+ key, and define a retention window. An expired key no longer protects a retry. External side
81
+ effects need the upstream system's cooperation: a local record cannot make a separate network
82
+ call atomic. The library supplies neither replay storage nor automatic retries.
83
+
53
84
  ## Layout
54
85
 
55
86
  ```text
@@ -73,7 +104,7 @@ src/
73
104
  ├── worker.ts entrypoint: a fetch handler for a serverless host
74
105
  ├── stdio.ts entrypoint: MCP for a local agent
75
106
  ├── commands.ts the commands calling the server, one written by hand, and their connection
76
- ├── cli.ts entrypoint: the commands, given that connection
107
+ ├── cli.ts entrypoint: the commands
77
108
  └── browser.ts client module: promises for a web page
78
109
  test/
79
110
  ├── notes/ in process, with Action.client
@@ -102,6 +133,7 @@ test/
102
133
  | A contract with declared errors and their statuses | `src/notes/Notes.ts` |
103
134
  | Input fields described, for `--help` and a tool's schema | `src/notes/Notes.ts` |
104
135
  | Handlers getting a service once and the caller per call | `src/notes/handlers.ts` |
136
+ | A write safe to retry with the same caller and request key | `src/notes/store.ts` |
105
137
  | A public action beside protected ones | `src/status/` |
106
138
  | Authorization from `readOnly`, refusing with a scope | `src/identity/authorize.ts` |
107
139
  | A token verifier and OAuth discovery read from config | `src/identity/verifier.ts` |
@@ -112,7 +144,7 @@ test/
112
144
  | A CLI derived from the binding: an argument, a field on stdin | `src/commands.ts` |
113
145
  | A command written by hand beside the derived ones | `src/commands.ts` |
114
146
  | A bulk import as one action, its command reading a file | `src/notes/`, `src/commands.ts` |
115
- | The CLI's connection, read from config when a command runs | `src/commands.ts`, `src/cli.ts` |
147
+ | The CLI's connection, read from config when a command runs | `src/commands.ts` |
116
148
  | A missing token as one message, not a `ConfigError` report | `src/commands.ts` |
117
149
  | A promise client handling each declared error by its tag | `src/browser.ts` |
118
150
  | Testing handlers and authorization with no transport | `test/notes/notes.test.ts` |
@@ -1,21 +1,15 @@
1
1
  import { NodeHttpClient, NodeRuntime, NodeServices } from "@effect/platform-node";
2
2
  import { Effect } from "effect";
3
3
  import { Command } from "effect/cli";
4
- import { HttpClient } from "effect/http";
5
4
  import * as ActionCli from "@gjermundgaraba/effect-actions/ActionCli";
6
- import { commands, connection } from "./commands.js";
5
+ import { commands } from "./commands.js";
7
6
 
8
7
  // Entrypoint: `node --import tsx examples/notes-service/src/cli.ts list-notes`.
9
- // The commands of commands.ts, connected to a server.
10
- const notes = commands.pipe(
11
- // Provided on the command, never around `Command.run`: the URL and the token are then read
12
- // when a command runs, so `--help` and a mistyped flag need neither.
13
- Command.provideEffect(HttpClient.HttpClient, connection),
14
- );
8
+ // The commands of commands.ts, which read where the server is and the token when one runs.
15
9
 
16
10
  // A result is printed on stdout. A failure goes to stderr, under an `ERROR` heading, as the JSON
17
11
  // HTTP sends, such as `{"_tag":"NoteNotFound","id":"9"}`, and the command exits 1.
18
- Command.run(notes, { version: "1.0.0" }).pipe(
12
+ Command.run(commands, { version: "1.0.0" }).pipe(
19
13
  Effect.provide([NodeHttpClient.layerUndici, NodeServices.layer]),
20
14
  ActionCli.logToStderr,
21
15
  NodeRuntime.runMain,
@@ -1,10 +1,44 @@
1
- import { Config, Console, Effect, FileSystem, flow } from "effect";
1
+ import { Config, Console, Effect, FileSystem, Schema } from "effect";
2
2
  import { Argument, CliError, Command } from "effect/cli";
3
3
  import { HttpClient, HttpClientRequest } from "effect/http";
4
4
  import * as ActionCli from "@gjermundgaraba/effect-actions/ActionCli";
5
5
  import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
6
6
  import { Http } from "./Api.js";
7
7
 
8
+ // What a command fails with when no token is configured: a tagged error of the CLI's own, printed
9
+ // on stderr as `MissingToken: <message>` under an `ERROR` heading, and exit 1.
10
+ class MissingToken extends Schema.TaggedError<MissingToken>()("MissingToken", {
11
+ message: Schema.String,
12
+ }) {}
13
+
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.
18
+ const api = Effect.gen(function* () {
19
+ const baseUrl = yield* Config.String("NOTES_URL").pipe(
20
+ Config.withDefault("http://127.0.0.1:3000"),
21
+ );
22
+
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.
27
+ const token = yield* Config.Redacted("NOTES_TOKEN").pipe(
28
+ Effect.mapError(
29
+ () =>
30
+ new MissingToken({
31
+ message: "NOTES_TOKEN is not set. Set it to your token: export NOTES_TOKEN=<your token>",
32
+ }),
33
+ ),
34
+ );
35
+
36
+ return yield* ActionHttp.client(Http, {
37
+ baseUrl,
38
+ transformClient: HttpClient.mapRequest(HttpClientRequest.bearerToken(token)),
39
+ });
40
+ });
41
+
8
42
  // The file's format: each note a `# ` title line, a blank line and its body, the notes apart by
9
43
  // a blank line. A body line that starts as a title would is written with a backslash before it,
10
44
  // as Markdown escapes one, and `import` takes it off, so every note reads back as it was.
@@ -15,14 +49,14 @@ const unescape = (body: string) => body.replaceAll(/^\\(?=\\*# )/gmu, "");
15
49
  // A command of your own, for what no single action does: this one calls two and writes a file.
16
50
  // It is a native Effect CLI command, so its syntax and its output are what you write: `notes
17
51
  // export notes.md` takes its argument from `Argument`, as a flag would come from `Flag`. It
18
- // calls the server through the binding's typed client, on the `HttpClient` the derived commands
19
- // use, and writes with the `FileSystem` every Effect CLI command has.
52
+ // calls the server through `api`, the client the derived commands call, and writes with the
53
+ // `FileSystem` every Effect CLI command has.
20
54
  const exportNotes = Command.make(
21
55
  "export",
22
56
  { file: Argument.String("file").pipe(Argument.withDescription("The Markdown file to write.")) },
23
57
  ({ file }) =>
24
58
  Effect.gen(function* () {
25
- const client = yield* ActionHttp.client(Http);
59
+ const client = yield* api;
26
60
  const files = yield* FileSystem.FileSystem;
27
61
  const { notes } = yield* client.listNotes();
28
62
  const whole = yield* Effect.forEach(notes, ({ id }) => client.getNote({ id }));
@@ -63,7 +97,7 @@ const importNotes = Command.make(
63
97
  },
64
98
  ({ text }) =>
65
99
  Effect.gen(function* () {
66
- const client = yield* ActionHttp.client(Http);
100
+ const client = yield* api;
67
101
 
68
102
  if (text === "") return yield* Console.log("Created 0 notes");
69
103
 
@@ -93,6 +127,7 @@ const importNotes = Command.make(
93
127
  // it: test/commands.test.ts.
94
128
  export const commands = ActionCli.remote(Http, {
95
129
  name: "notes",
130
+ client: api,
96
131
  commands: {
97
132
  // `notes create-note --title Draft < draft.md`: the body is piped in, not a flag.
98
133
  createNote: { stdin: "body" },
@@ -104,34 +139,3 @@ export const commands = ActionCli.remote(Http, {
104
139
  // go here.
105
140
  subcommands: [exportNotes, importNotes],
106
141
  });
107
-
108
- // Where the server is and who is calling, read from config: the `HttpClient` every command
109
- // above calls with. It is an Effect, not a value, so that cli.ts provides it on the commands
110
- // and it runs when one of them does.
111
- export const connection = Effect.gen(function* () {
112
- const url = yield* Config.String("NOTES_URL").pipe(Config.withDefault("http://127.0.0.1:3000"));
113
-
114
- // Required, since every command but `get-status` is refused without one. A failure of what a
115
- // command is provided is no action's error: left as it is, a missing token is reported as a
116
- // crash, the log line `ERROR (#n): ConfigError: SchemaError(Expected string at
117
- // ["NOTES_TOKEN"])`. Mapped to a `UserError`, it is printed as any failure of a command is:
118
- // the message under an `ERROR` heading on stderr, and exit 1. `userMessage` is all that is
119
- // printed, never `cause`: what the user needs from the cause goes into the message. A CLI
120
- // whose public commands must answer signed out reads the token with `Config.option` instead
121
- // and adds the header when it is there.
122
- const token = yield* Config.Redacted("NOTES_TOKEN").pipe(
123
- Effect.mapError(
124
- (cause) =>
125
- new CliError.UserError({
126
- cause,
127
- userMessage:
128
- "NOTES_TOKEN is not set. Set it to your token: export NOTES_TOKEN=<your token>",
129
- }),
130
- ),
131
- );
132
-
133
- return HttpClient.mapRequest(
134
- yield* HttpClient.HttpClient,
135
- flow(HttpClientRequest.prependUrl(url), HttpClientRequest.bearerToken(token)),
136
- );
137
- });
@@ -8,6 +8,7 @@ import { CurrentUser, scopes } from "./Identity.js";
8
8
  // answered a challenge, which only a server publishing discovery (verifier.ts) can follow up.
9
9
  // Where tokens are fixed, such as API keys, name no scope: the refusal is then a plain one.
10
10
  // So `scopes` below goes when `protectedResource` in verifier.ts does: copy both or neither.
11
+ // This named span measures authorization within the action's span, including a refusal.
11
12
  export const authorize = Effect.fn("authorize")(function* (action: Action.Any) {
12
13
  const scope = action.readOnly ? scopes.read : scopes.write;
13
14
  const user = yield* CurrentUser;
@@ -25,6 +25,12 @@ export class NoteChanged extends Schema.TaggedError<NoteChanged>()(
25
25
  { httpApiStatus: 409 },
26
26
  ) {}
27
27
 
28
+ export class RequestConflict extends Schema.TaggedError<RequestConflict>()(
29
+ "RequestConflict",
30
+ { requestId: Schema.String },
31
+ { httpApiStatus: 409 },
32
+ ) {}
33
+
28
34
  // A description on an input field is that field's help on every surface: beside its flag or
29
35
  // argument in a command's `--help`, and in the input schema a model reads from the MCP tool
30
36
  // listing and the OpenAPI document. A field without one has a flag with no help.
@@ -43,6 +49,8 @@ const Title = Schema.String.check(
43
49
 
44
50
  const Body = Schema.String.annotate({ description: "The note's whole text." });
45
51
 
52
+ const Content = Schema.Struct({ title: Title, body: Body });
53
+
46
54
  export const ListNotes = Action.make("listNotes", {
47
55
  description: "List your notes, without their bodies.",
48
56
  success: { notes: Schema.Array(Schema.Struct({ id: Schema.String, title: Schema.String })) },
@@ -60,9 +68,17 @@ export const GetNote = Action.make("getNote", {
60
68
  });
61
69
 
62
70
  export const CreateNote = Action.make("createNote", {
63
- description: "Create a note.",
64
- input: { title: Title, body: Body },
71
+ description:
72
+ "Create a note. To retry safely, supply a requestId and reuse it with the same content.",
73
+ input: {
74
+ requestId: Schema.optionalKey(Schema.NonEmptyString).annotate({
75
+ description:
76
+ "An optional key for this creation. Keep it for retries; without one, every call creates a note.",
77
+ }),
78
+ ...Content.fields,
79
+ },
65
80
  success: Note,
81
+ error: RequestConflict,
66
82
  readOnly: false,
67
83
  caller: CurrentUser,
68
84
  mcp: { destructiveHint: false },
@@ -98,14 +114,15 @@ export const DeleteNote = Action.make("deleteNote", {
98
114
  // Work every surface should offer is an action, a bulk import included: the HTTP API, the MCP
99
115
  // tools and the CLI then all have it, and the whole list is decoded, so checked, before the
100
116
  // handler writes any of it. A command of your own only reads the file and calls this once
101
- // (commands.ts). `importNotes` adds and removes nothing, so `destructiveHint` is `false`.
102
- // Each note is `createNote`'s input: an action's `input` is a schema already, used as it is.
117
+ // (commands.ts). `importNotes` only adds notes, so `destructiveHint` is `false`.
118
+ // Imports share note content with `createNote`, but exclude request keys because bulk creation
119
+ // does not deduplicate.
103
120
  export const ImportNotes = Action.make("importNotes", {
104
121
  description:
105
122
  "Create several notes at once, such as those `notes export` wrote. Every note is checked " +
106
123
  "before any is written: an import holding one that is not valid writes none.",
107
124
  input: {
108
- notes: Schema.Array(CreateNote.input)
125
+ notes: Schema.Array(Content)
109
126
  .check(
110
127
  Schema.isMinLength(1, { message: "An import holds at least one note." }),
111
128
  Schema.isMaxLength(100, { message: "An import holds at most 100 notes." }),
@@ -23,8 +23,8 @@ export const notes = Action.implement(
23
23
  return { notes: owned.map(({ id, title }) => ({ id, title })) };
24
24
  }),
25
25
  getNote: ({ id }) => Effect.flatMap(CurrentUser, (user) => store.get(user.id, id)),
26
- createNote: ({ title, body }) =>
27
- Effect.flatMap(CurrentUser, (user) => store.create(user.id, title, body)),
26
+ createNote: ({ requestId, title, body }) =>
27
+ Effect.flatMap(CurrentUser, (user) => store.create(user.id, title, body, requestId)),
28
28
  updateNote: ({ id, body, revision }) =>
29
29
  Effect.flatMap(CurrentUser, (user) => store.update(user.id, id, body, revision)),
30
30
  deleteNote: ({ id }) => Effect.flatMap(CurrentUser, (user) => store.remove(user.id, id)),