@gjermundgaraba/effect-actions 0.16.0 → 0.17.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 (67) hide show
  1. package/dist/Action.d.mts +3 -5
  2. package/dist/Action.mjs +3 -1
  3. package/dist/ActionCli.d.mts +2 -2
  4. package/dist/ActionCli.mjs +2 -1
  5. package/dist/ActionHttp.d.mts +3 -2
  6. package/dist/ActionHttp.mjs +3 -1
  7. package/dist/ActionMcp.d.mts +1 -1
  8. package/dist/ActionMcp.mjs +4 -1
  9. package/dist/ActionRpc.d.mts +5 -3
  10. package/dist/ActionRpc.mjs +2 -1
  11. package/dist/ActionToolkit.d.mts +3 -2
  12. package/dist/ActionToolkit.mjs +3 -1
  13. package/dist/Authentication.d.mts +1 -1
  14. package/dist/Authentication.mjs +1 -1
  15. package/dist/authentication/provider.d.mts +2 -2
  16. package/dist/authentication/refusal.mjs +1 -1
  17. package/dist/cli/commands.d.mts +3 -2
  18. package/dist/cli/commands.mjs +2 -1
  19. package/dist/contract/ast.mjs +10 -0
  20. package/dist/contract/call.d.mts +1 -1
  21. package/dist/contract/caller.d.mts +9 -0
  22. package/dist/contract/caller.mjs +12 -0
  23. package/dist/contract/{rules.d.mts → declared.d.mts} +6 -10
  24. package/dist/contract/declared.mjs +42 -0
  25. package/dist/contract/hints.mjs +14 -0
  26. package/dist/contract/implementation.d.mts +4 -3
  27. package/dist/contract/implementation.mjs +4 -8
  28. package/dist/contract/names.mjs +25 -0
  29. package/dist/contract/status.mjs +18 -0
  30. package/dist/http/client.d.mts +3 -2
  31. package/dist/mcp/client.d.mts +40 -7
  32. package/dist/mcp/client.mjs +60 -17
  33. package/dist/mcp/protocol.mjs +1 -1
  34. package/docs/Action.md +17 -3
  35. package/docs/ActionCli.md +1 -1
  36. package/docs/ActionMcp.md +2 -2
  37. package/docs/ActionToolkit.md +25 -2
  38. package/docs/Authentication-failures.md +1 -1
  39. package/docs/CONTEXT.md +2 -1
  40. package/docs/README.md +4 -4
  41. package/docs/Testing.md +3 -1
  42. package/examples/README.md +10 -2
  43. package/examples/assistant/README.md +24 -20
  44. package/examples/assistant/src/agent.ts +11 -7
  45. package/examples/assistant/src/cli.ts +2 -2
  46. package/examples/assistant/src/platform/tasks-file.ts +38 -0
  47. package/examples/assistant/src/tasks/store.ts +6 -38
  48. package/examples/assistant/src/toolkit.ts +11 -1
  49. package/examples/assistant/test/{tasks/store.test.ts → platform/tasks-file.test.ts} +2 -1
  50. package/examples/mcp-tools/README.md +14 -11
  51. package/examples/mcp-tools/src/{printers/fake.ts → platform/fake-farm.ts} +2 -2
  52. package/examples/mcp-tools/src/platform/print-farm.ts +118 -0
  53. package/examples/mcp-tools/src/printers/farm.ts +6 -122
  54. package/examples/mcp-tools/src/server.ts +4 -4
  55. package/examples/mcp-tools/src/stdio.ts +4 -4
  56. package/examples/mcp-tools/test/printers/printers.test.ts +3 -3
  57. package/examples/mcp-tools/test/routes.test.ts +4 -4
  58. package/examples/notes-service/README.md +32 -0
  59. package/examples/notes-service/src/identity/authorize.ts +1 -0
  60. package/examples/notes-service/src/notes/Notes.ts +22 -5
  61. package/examples/notes-service/src/notes/handlers.ts +2 -2
  62. package/examples/notes-service/src/notes/store.ts +36 -6
  63. package/examples/notes-service/test/notes/idempotency.test.ts +132 -0
  64. package/examples/notes-service/test/routes.test.ts +40 -1
  65. package/package.json +5 -5
  66. package/dist/_virtual/_rolldown/runtime.mjs +0 -13
  67. package/dist/contract/rules.mjs +0 -100
@@ -91,9 +91,11 @@ and no `name` or `arguments`.
91
91
  src/
92
92
  ├── printers/
93
93
  │ ├── Printers.ts contract: the printer, the one declared error, the five tools
94
- │ ├── farm.ts PrintFarm, the upstream API as a service, and its Layer
95
- │ ├── fake.ts DEMO ONLY: the farm's API answered in memory, as an HttpClient
94
+ │ ├── farm.ts PrintFarm, the upstream as a service: what the handlers call
96
95
  │ └── handlers.ts Action.implement: tool to service
96
+ ├── platform/
97
+ │ ├── print-farm.ts printFarm, PrintFarm's Layer over the farm's HTTP API
98
+ │ └── fake-farm.ts DEMO ONLY: the farm's API answered in memory, as an HttpClient
97
99
  ├── instructions.ts the server's name, version and its instructions for the model
98
100
  ├── routes.ts the HTTP surface: /mcp and /healthz
99
101
  ├── server.ts entrypoint: MCP over HTTP
@@ -103,13 +105,14 @@ test/
103
105
  └── routes.test.ts over MCP in memory, with Testing.layer
104
106
  ```
105
107
 
106
- - **The upstream is a service, built once.** `PrintFarm.layer` reads its URL and credentials
107
- from `Config`, takes whichever `HttpClient` the entrypoint provides, and signs in while it is
108
- built. The handlers get it already signed in and only delegate: `handlers.ts` maps each tool
109
- onto a method of `farm.ts`, which knows nothing of tools, so a second caller of the farm, a
110
- job or another surface, takes the service alone.
108
+ - **The upstream is a service, built once.** `farm.ts` declares what the handlers need of the
109
+ farm, and `platform/print-farm.ts` is its Layer over the farm's HTTP API: it reads its URL and
110
+ credentials from `Config`, takes whichever `HttpClient` the entrypoint provides, and signs in
111
+ while it is built. The handlers get it already signed in and only delegate: `handlers.ts`
112
+ maps each tool onto a method of `PrintFarm`, which knows nothing of tools or HTTP, so a second
113
+ caller of the farm, a job or another surface, takes the service alone.
111
114
  - **One declared error, mapped in one place.** A refusal, an unreachable farm, an answer of
112
- another shape and one that never comes each leave `farm.ts` as a `PrintFarmError` with a
115
+ another shape and one that never comes each leave `platform/print-farm.ts` as a `PrintFarmError` with a
113
116
  message a model can act on. No handler catches anything.
114
117
  - **What a model sends is checked by the contract.** A printer id becomes part of the farm's
115
118
  URL, so its schema admits only what is safe there, and anything else is refused before a
@@ -128,14 +131,14 @@ test/
128
131
  | One declared error shared by every tool | `src/printers/Printers.ts` |
129
132
  | An input a model cannot use to reach another URL | `src/printers/Printers.ts` |
130
133
  | A tool answering an image beside other fields | `src/printers/Printers.ts` |
131
- | An upstream client reading `Config`, signing in once | `src/printers/farm.ts` |
132
- | An upstream's failures mapped to the declared error | `src/printers/farm.ts` |
134
+ | An upstream client reading `Config`, signing in once | `src/platform/print-farm.ts` |
135
+ | An upstream's failures mapped to the declared error | `src/platform/print-farm.ts` |
133
136
  | A builder yielding a service, and handlers that only delegate | `src/printers/handlers.ts` |
134
137
  | Server `instructions`, shared by both transports | `src/instructions.ts` |
135
138
  | Allowed origins from `Config`, their CORS, and `/healthz` | `src/routes.ts` |
136
139
  | A request body limit, and where an unauthenticated server binds | `src/server.ts` |
137
140
  | A stdio server whose logs go to stderr | `src/stdio.ts` |
138
- | A fake upstream provided as a Layer, to a test or an entrypoint | `src/printers/fake.ts` |
141
+ | A fake upstream provided as a Layer, to a test or an entrypoint | `src/platform/fake-farm.ts` |
139
142
  | Testing tools and each upstream failure's message in process | `test/printers/printers.test.ts` |
140
143
  | Testing listed hints, an image and the origin check in memory | `test/routes.test.ts` |
141
144
  | Testing CORS for one configured origin and for several | `test/routes.test.ts` |
@@ -1,10 +1,10 @@
1
1
  import { Buffer } from "node:buffer";
2
2
  import { Effect, Layer, Option, Schema } from "effect";
3
3
  import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
4
- import type { Printer } from "./Printers.js";
4
+ import type { Printer } from "../printers/Printers.js";
5
5
 
6
6
  // DEMO ONLY: the farm's HTTP API answered in memory, so the example and its tests run without
7
- // a farm. It is an `HttpClient`, so `PrintFarm.layer` sends it the requests it would send over
7
+ // a farm. It is an `HttpClient`, so `printFarm` sends it the requests it would send over
8
8
  // the network. A real application has no such file: its entrypoints provide a real client,
9
9
  // `FetchHttpClient.layer` from `effect/http`.
10
10
 
@@ -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
@@ -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` |
@@ -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)),
@@ -1,5 +1,5 @@
1
1
  import { Context, Effect, Layer } from "effect";
2
- import { type Note, NoteChanged, NoteNotFound } from "./Notes.js";
2
+ import { type Note, NoteChanged, NoteNotFound, RequestConflict } from "./Notes.js";
3
3
 
4
4
  /**
5
5
  * Where notes live, each owned by one user. Ownership is checked here, in the data access, so no
@@ -10,7 +10,12 @@ export class NoteStore extends Context.Service<
10
10
  {
11
11
  readonly list: (owner: string) => Effect.Effect<ReadonlyArray<Note>>;
12
12
  readonly get: (owner: string, id: string) => Effect.Effect<Note, NoteNotFound>;
13
- readonly create: (owner: string, title: string, body: string) => Effect.Effect<Note>;
13
+ readonly create: (
14
+ owner: string,
15
+ title: string,
16
+ body: string,
17
+ requestId?: string,
18
+ ) => Effect.Effect<Note, RequestConflict>;
14
19
  /** Every note or none: a database does this in one transaction. */
15
20
  readonly createAll: (
16
21
  owner: string,
@@ -26,12 +31,14 @@ export class NoteStore extends Context.Service<
26
31
  readonly count: Effect.Effect<number>;
27
32
  }
28
33
  >()("notes-service/NoteStore") {
29
- // DEMO ONLY: memory, lost when the process stops. A real store is another Layer of this
30
- // service, chosen by the entrypoint.
34
+ // DEMO ONLY: notes and replay records live in this process's memory, without eviction.
31
35
  static readonly layerMemory = Layer.sync(NoteStore, () => {
32
36
  // One map per owner: a note is only ever looked up among its owner's, whatever id is asked.
33
37
  const notes = new Map<string, Map<string, Note>>();
34
38
 
39
+ // Keep the original result even after the note is updated or deleted.
40
+ const creations = new Map<string, Map<string, Note>>();
41
+
35
42
  const ownedBy = (owner: string) => notes.get(owner) ?? new Map<string, Note>();
36
43
 
37
44
  const save = (owner: string, note: Note) => {
@@ -51,8 +58,31 @@ export class NoteStore extends Context.Service<
51
58
  return NoteStore.of({
52
59
  list: (owner) => Effect.sync(() => Array.from(ownedBy(owner).values())),
53
60
  get,
54
- create: (owner, title, body) =>
55
- Effect.sync(() => save(owner, { id: crypto.randomUUID(), title, body, revision: 1 })),
61
+ create: Effect.fn("NoteStore.create")(function* (owner, title, body, requestId) {
62
+ if (requestId !== undefined) {
63
+ const recorded = creations.get(owner)?.get(requestId);
64
+
65
+ if (recorded !== undefined) {
66
+ if (recorded.title !== title || recorded.body !== body) {
67
+ return yield* new RequestConflict({ requestId });
68
+ }
69
+
70
+ return recorded;
71
+ }
72
+ }
73
+
74
+ // No yield between lookup, mutation and recording: concurrent calls cannot interleave
75
+ // or interruption leave a note without its result.
76
+ const note = save(owner, { id: crypto.randomUUID(), title, body, revision: 1 });
77
+
78
+ if (requestId !== undefined) {
79
+ const requests = creations.get(owner) ?? new Map<string, Note>();
80
+ requests.set(requestId, note);
81
+ creations.set(owner, requests);
82
+ }
83
+
84
+ return note;
85
+ }),
56
86
  createAll: (owner, all) =>
57
87
  Effect.sync(() =>
58
88
  all.map(({ title, body }) =>