@gjermundgaraba/effect-actions 0.12.0 → 0.13.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 (45) hide show
  1. package/README.md +4 -4
  2. package/dist/Action.d.mts +3 -1
  3. package/dist/ActionToolkit.d.mts +23 -1
  4. package/dist/ActionToolkit.mjs +28 -1
  5. package/dist/Testing.d.mts +3 -1
  6. package/dist/Testing.mjs +2 -2
  7. package/dist/mcp/protocol.d.mts +2 -1
  8. package/docs/Action.md +11 -6
  9. package/docs/ActionCli-failures.md +2 -0
  10. package/docs/ActionCli.md +7 -5
  11. package/docs/ActionHttp-failures.md +2 -2
  12. package/docs/ActionHttp.md +6 -3
  13. package/docs/ActionMcp.md +20 -20
  14. package/docs/ActionRpc.md +3 -2
  15. package/docs/ActionToolkit.md +15 -12
  16. package/docs/Authentication.md +3 -2
  17. package/docs/CONTEXT.md +3 -2
  18. package/docs/README.md +9 -5
  19. package/docs/Testing-failures.md +2 -1
  20. package/docs/Testing.md +10 -3
  21. package/docs/setup-failures.md +5 -2
  22. package/docs/setup.md +9 -7
  23. package/examples/kitchen-sink/README.md +5 -4
  24. package/examples/kitchen-sink/src/assistant/handlers.ts +18 -38
  25. package/examples/kitchen-sink/src/issues/Issues.ts +4 -2
  26. package/examples/kitchen-sink/src/issues/write-limit.ts +2 -1
  27. package/examples/kitchen-sink/src/server.ts +3 -3
  28. package/examples/live-rpc/README.md +1 -1
  29. package/examples/live-rpc/src/chat/Chat.ts +3 -1
  30. package/examples/live-rpc/src/chat/handlers.ts +2 -2
  31. package/examples/live-rpc/src/chat/store.ts +1 -1
  32. package/examples/live-rpc/src/server.ts +3 -3
  33. package/examples/live-rpc/test/chat/chat.test.ts +1 -1
  34. package/examples/mcp-tools/README.md +9 -4
  35. package/examples/mcp-tools/src/printers/Printers.ts +6 -4
  36. package/examples/mcp-tools/src/server.ts +3 -2
  37. package/examples/mcp-tools/test/routes.test.ts +2 -2
  38. package/examples/notes-service/README.md +6 -3
  39. package/examples/notes-service/src/commands.ts +50 -2
  40. package/examples/notes-service/src/notes/Notes.ts +32 -4
  41. package/examples/notes-service/src/notes/handlers.ts +8 -2
  42. package/examples/notes-service/src/notes/store.ts +11 -0
  43. package/examples/notes-service/src/server.ts +3 -3
  44. package/examples/notes-service/test/commands.test.ts +156 -2
  45. package/package.json +5 -5
package/docs/setup.md CHANGED
@@ -3,26 +3,28 @@
3
3
  Install the package, import its modules, write the smallest program, lay out an application and
4
4
  bundle a browser client.
5
5
 
6
- Read this page alone, in one tool call: pages read together are cut. When something fails,
7
- search [setup-failures.md](setup-failures.md). Reading order: [README.md](README.md).
6
+ Read this page alone, in one tool call; if its output says it was truncated, read it again alone.
7
+ When something fails, search [setup-failures.md](setup-failures.md). Reading order:
8
+ [README.md](README.md).
8
9
 
9
10
  ## Install
10
11
 
11
12
  ```sh
12
- pnpm add @gjermundgaraba/effect-actions effect@~4.0.2
13
+ pnpm add @gjermundgaraba/effect-actions effect@~4.0.3
13
14
  ```
14
15
 
15
16
  | Need | Add |
16
17
  | ----------------------------------- | ---------------------------------------------------------------- |
17
- | Node HTTP server, stdio host or CLI | `@effect/platform-node@~4.0.2` |
18
- | Tests | `vitest@^5.0.3` and `@effect/vitest@~4.0.2`, as dev dependencies |
18
+ | Node HTTP server, stdio host or CLI | `@effect/platform-node@~4.0.3` |
19
+ | Tests | `vitest@^5.0.3` and `@effect/vitest@~4.0.3`, as dev dependencies |
19
20
  | Type checking | `typescript@^7.0.2`, as a dev dependency |
21
+ | Running a TypeScript entrypoint | `tsx`, as a dev dependency: `node --import tsx <entrypoint>.ts` |
20
22
 
21
23
  The `effect` peer admits only the patches of the Effect release the package is tested against:
22
24
  a minor release may change the unstable Effect modules the surfaces build on.
23
25
 
24
- Keep the tilde in `package.json`: `^4.0.2`, which npm writes for `effect@4.0.2`, admits a minor
25
- release outside the peer range. `npm install effect@~4.0.2` keeps it.
26
+ Keep the tilde in `package.json`: `^4.0.3`, which npm writes for `effect@4.0.3`, admits a minor
27
+ release outside the peer range. `npm install effect@~4.0.3` keeps it.
26
28
 
27
29
  Node `^22.12.0 || ^24.0.0 || >=26.0.0`: the package's `engines`, where its `devEngines` is
28
30
  what developing the library itself takes. TypeScript 7 or newer. ESM only. No example ships a
@@ -36,10 +36,11 @@ node --import tsx examples/kitchen-sink/src/agent.ts # the assis
36
36
  vp test examples/kitchen-sink
37
37
  ```
38
38
 
39
- `PORT`, `ADMIN_PORT`, `PUBLIC_URL` and `MCP_ORIGIN` configure the server; `TRACKER_URL` points the
40
- CLI and the seed at it. `stdio.ts` is an MCP server for a local agent to start as a subprocess.
41
- `sdk.ts` and `browser.ts` are imported by client code, not run. The OpenAPI document is at
42
- `/api/v1/openapi.json`, Swagger UI at `/docs`, the MCP endpoint at `/mcp`, the socket at `/rpc`.
39
+ `HOST`, `PORT`, `ADMIN_PORT`, `PUBLIC_URL` and `MCP_ORIGIN` configure the server; `TRACKER_URL`
40
+ points the CLI and the seed at it. `stdio.ts` is an MCP server for a local agent to start as a
41
+ subprocess. `sdk.ts` and `browser.ts` are imported by client code, not run. The OpenAPI
42
+ document is at `/api/v1/openapi.json`, Swagger UI at `/docs`, the MCP endpoint at `/mcp`, the
43
+ socket at `/rpc`.
43
44
 
44
45
  Everything is a demo: stores are memory, and the credentials are fixed strings. Replace both
45
46
  before deploying anything.
@@ -1,6 +1,7 @@
1
1
  import { Effect, Ref, Schema } from "effect";
2
2
  import { type Chat, LanguageModel, type Prompt } from "effect/ai";
3
3
  import * as Action from "@gjermundgaraba/effect-actions/Action";
4
+ import * as ActionToolkit from "@gjermundgaraba/effect-actions/ActionToolkit";
4
5
  import { CurrentMember } from "../identity/Identity.js";
5
6
  import { toolkit } from "../issues/assistant.js";
6
7
  import { AssistantFailed, Assistant } from "./Assistant.js";
@@ -15,7 +16,8 @@ const approvalsOf = (response: Response) =>
15
16
  response.content.filter((part) => part.type === "tool-approval-request");
16
17
 
17
18
  // What Effect gives the model as the result of a call that did not run: one its member denied,
18
- // or one in a reply the provider cut short.
19
+ // or one in a reply the provider cut short. Left out of `ran` below, which lists the calls that
20
+ // ran.
19
21
  const didNotRun = Schema.is(
20
22
  Schema.Struct({ type: Schema.Literals(["execution-denied", "execution-interrupted"]) }),
21
23
  );
@@ -23,45 +25,12 @@ const didNotRun = Schema.is(
23
25
  const callsToRead = (response: Response) =>
24
26
  response.toolCalls.length > 0 && approvalsOf(response).length === 0;
25
27
 
26
- // Every request of a conversation that an answer would still act on, with the call it would
27
- // run: read from the `Chat`'s history, by the rule Effect applies when an answer arrives. A
28
- // request is open until its call has a result, a denial's included, and its call is the last
29
- // in the history with that id. So a request an earlier answer left open is listed in every
30
- // turn until it is answered, and what is listed is what an approval runs.
31
- const openRequestsOf = ({ content: messages }: Prompt.Prompt) => {
32
- const resolved = new Set(
33
- messages.flatMap((message) =>
34
- message.role === "tool"
35
- ? message.content.flatMap((part) => (part.type === "tool-result" ? [part.id] : []))
36
- : [],
37
- ),
38
- );
39
-
40
- const said = messages.flatMap((message) => (message.role === "assistant" ? message.content : []));
41
- const calls = said.filter((part) => part.type === "tool-call");
42
-
43
- return said.flatMap((request) => {
44
- if (request.type !== "tool-approval-request" || resolved.has(request.toolCallId)) return [];
45
-
46
- const call = calls.findLast(({ id }) => id === request.toolCallId);
47
-
48
- return call === undefined
49
- ? []
50
- : [
51
- {
52
- approvalId: request.approvalId,
53
- tool: call.name,
54
- arguments: JSON.stringify(call.params),
55
- },
56
- ];
57
- });
58
- };
59
-
60
28
  // The model loop behind a route. The builder runs once, at startup, and yields what a turn is
61
29
  // built from: the tools with their handlers, the model, and where conversations are kept. A
62
30
  // handler yielding them would owe them per request, which no route provides. `CurrentMember`
63
31
  // is the one per-request service: each tool call of a turn runs as the request's caller,
64
- // through that tool's own `authorize`.
32
+ // through that tool's own `authorize`. A conversation keeps its owner's id, never the member
33
+ // itself, whose scopes would go stale between requests.
65
34
  export const assistant = Action.implement(
66
35
  Assistant,
67
36
  Effect.gen(function* () {
@@ -92,7 +61,17 @@ export const assistant = Action.implement(
92
61
  ran: results
93
62
  .filter(({ result }) => !didNotRun(result))
94
63
  .map(({ name, isFailure }) => ({ tool: name, failed: isFailure })),
95
- waiting: openRequestsOf(yield* Ref.get(chat.history)),
64
+ // Every request of the conversation still waiting for an answer, with the call it
65
+ // would run, read from the `Chat`'s history by the rule Effect applies when an answer
66
+ // arrives: one an earlier answer left open is listed in every turn until answered.
67
+ // The arguments are shown as JSON, as the member approves them.
68
+ waiting: ActionToolkit.openApprovals(yield* Ref.get(chat.history)).map(
69
+ ({ approvalId, name, params }) => ({
70
+ approvalId,
71
+ tool: name,
72
+ arguments: JSON.stringify(params),
73
+ }),
74
+ ),
96
75
  };
97
76
  }).pipe(
98
77
  // The model is a startup service, given to each turn here.
@@ -126,7 +105,8 @@ export const assistant = Action.implement(
126
105
  : turn(conversation, prompt),
127
106
  // The answers start the next turn, in which an approved call runs, as this request's
128
107
  // caller, before the model is asked again. Only the member who was asked reaches the
129
- // conversation, and an `approvalId` that matches no open request of it runs nothing.
108
+ // conversation, and a turn that fails ends it, so no answer is left without its result:
109
+ // an `approvalId` that `openApprovals` would not list runs nothing.
130
110
  answerAssistant: ({ conversation, answers }) =>
131
111
  turn(conversation, [
132
112
  {
@@ -43,8 +43,9 @@ const change = {
43
43
  caller: CurrentMember,
44
44
  error: IssueWriteErrors,
45
45
  // MCP hints, in MCP's names. They tell a model what a tool does and enforce nothing; the
46
- // read-only hint is `readOnly` itself.
47
- mcp: { destructiveHint: false, openWorldHint: false },
46
+ // read-only hint is `readOnly` itself. A write is destructive unless it only adds, as a
47
+ // comment does: closing is not stated `false`.
48
+ mcp: { openWorldHint: false },
48
49
  } as const;
49
50
 
50
51
  const Text = Schema.String.check(Schema.isMinLength(1));
@@ -95,6 +96,7 @@ export const CommentOnIssue = Action.make("commentOnIssue", {
95
96
  input: { id: Schema.String, text: Text },
96
97
  success: Issue,
97
98
  readOnly: false,
99
+ mcp: { ...change.mcp, destructiveHint: false },
98
100
  });
99
101
 
100
102
  export const CloseIssue = Action.make("closeIssue", {
@@ -10,7 +10,8 @@ import { RateLimited } from "./Issues.js";
10
10
  // as any declared error. The limiter is a service, so its counts are shared wherever it is
11
11
  // provided: memory.ts provides Effect's own, `RateLimiter.layer` over a store. `consume` counts
12
12
  // in a fixed window unless given `algorithm: "token-bucket"`: ten writes in each minute, so
13
- // twenty may pass across the boundary between two.
13
+ // twenty may pass across the boundary between two. A strict limit, ten in any sixty seconds,
14
+ // is a service of your own of the same shape, yielded here, never part of the issue store.
14
15
  export const writeLimit = Effect.gen(function* () {
15
16
  const limiter = yield* RateLimiter.RateLimiter;
16
17
 
@@ -19,6 +19,8 @@ const socket = rpc.pipe(
19
19
 
20
20
  const server = Layer.unwrap(
21
21
  Effect.gen(function* () {
22
+ // The loopback default serves this machine alone; HOST=0.0.0.0 lets others reach it.
23
+ const host = yield* Config.String("HOST").pipe(Config.withDefault("127.0.0.1"));
22
24
  const publicPort = yield* Config.Port("PORT").pipe(Config.withDefault(3000));
23
25
  const adminPort = yield* Config.Port("ADMIN_PORT").pipe(Config.withDefault(3001));
24
26
 
@@ -26,9 +28,7 @@ const server = Layer.unwrap(
26
28
 
27
29
  const listen = (port: number) =>
28
30
  NodeHttpServer.layer(createServer, {
29
- // This machine alone. A server others reach reads its host from config:
30
- // `Config.String("HOST").pipe(Config.withDefault("127.0.0.1"))`.
31
- host: "127.0.0.1",
31
+ host,
32
32
  port,
33
33
  // A message on the WebSocket of `/rpc` is no request body, so the limit below does not
34
34
  // bound it: Node's WebSocket server reads one of up to 100 MiB whole unless told
@@ -15,7 +15,7 @@ From the repository root, after `vp install` (from an installed package, see
15
15
  [Run one](../README.md#run-one)):
16
16
 
17
17
  ```sh
18
- node --import tsx examples/live-rpc/src/server.ts # 127.0.0.1:3000, PORT to change
18
+ node --import tsx examples/live-rpc/src/server.ts # 127.0.0.1:3000, HOST and PORT to change
19
19
  node --import tsx examples/live-rpc/src/client.ts # CHAT_URL to change, ws://127.0.0.1:3000/rpc
20
20
 
21
21
  vp test examples/live-rpc
@@ -21,9 +21,11 @@ export class MessageNotFound extends Schema.TaggedError<MessageNotFound>()(
21
21
  { httpApiStatus: 404 },
22
22
  ) {}
23
23
 
24
+ // A refusal carries what the caller sent and no more: who wrote the message is for the actions
25
+ // that read it, under their own rule.
24
26
  export class NotAuthor extends Schema.TaggedError<NotAuthor>()(
25
27
  "NotAuthor",
26
- { id: Schema.Int, author: Schema.String },
28
+ { id: Schema.Int },
27
29
  { httpApiStatus: 403 },
28
30
  ) {}
29
31
 
@@ -16,8 +16,8 @@ export const rooms = Action.implement(
16
16
  }),
17
17
  );
18
18
 
19
- // The builder runs once, at startup: it is where a handler gets its services. The caller and
20
- // the room are read per call, inside each handler, because every message has its own.
19
+ // The builder runs once, at startup: it is where a handler gets its startup services. The
20
+ // caller and the room are read per call, inside each handler, because every message has its own.
21
21
  export const messages = Action.implement(
22
22
  Messages,
23
23
  Effect.gen(function* () {
@@ -45,7 +45,7 @@ export class MessageStore extends Context.Service<
45
45
 
46
46
  if (message === undefined) return yield* new MessageNotFound({ id });
47
47
 
48
- if (message.author !== author) return yield* new NotAuthor({ id, author: message.author });
48
+ if (message.author !== author) return yield* new NotAuthor({ id });
49
49
 
50
50
  rooms.set(name, { last, messages: messages.filter((kept) => kept !== message) });
51
51
  }),
@@ -20,14 +20,14 @@ const socket = rpc.pipe(
20
20
 
21
21
  const server = Layer.unwrap(
22
22
  Effect.gen(function* () {
23
+ // The loopback default serves this machine alone; HOST=0.0.0.0 lets others reach it.
24
+ const host = yield* Config.String("HOST").pipe(Config.withDefault("127.0.0.1"));
23
25
  const port = yield* Config.Port("PORT").pipe(Config.withDefault(3000));
24
26
 
25
27
  return HttpRouter.serve(socket).pipe(
26
28
  Layer.provide(
27
29
  NodeHttpServer.layer(createServer, {
28
- // This machine alone. A server others reach reads its host from config:
29
- // `Config.String("HOST").pipe(Config.withDefault("127.0.0.1"))`.
30
- host: "127.0.0.1",
30
+ host,
31
31
  port,
32
32
  // A message on a WebSocket is no request body, so `HttpServerRequest.MaxBodySize`
33
33
  // does not bound it: Node's WebSocket server reads one of up to 100 MiB whole unless
@@ -42,7 +42,7 @@ describe("messages, in process", () => {
42
42
  const { id } = yield* client.postMessage({ text: "Oops." }).pipe(as("ada", "lobby"));
43
43
 
44
44
  expect(yield* Effect.flip(client.deleteMessage({ id }).pipe(as("grace", "lobby")))).toEqual(
45
- new NotAuthor({ id, author: "ada" }),
45
+ new NotAuthor({ id }),
46
46
  );
47
47
 
48
48
  yield* client.deleteMessage({ id }).pipe(as("ada", "lobby"));
@@ -5,8 +5,10 @@ authentication of its own. It signs in to the farm once, with its own account, a
5
5
  reach it may call every tool, so it runs as a local subprocess of an agent or on a network only
6
6
  trusted clients are on.
7
7
 
8
- Start here if you are putting an existing API in front of a model. For tools that belong to the
9
- signed-in user, start from [notes-service](../notes-service/).
8
+ Start here if you are putting an existing API in front of a model, then read
9
+ [ActionMcp.md](../../docs/ActionMcp.md). For tools that need a token, its "Protect tools" shows
10
+ the verifier, and [notes-service](../notes-service/) is a server whose tools belong to the
11
+ signed-in user.
10
12
 
11
13
  ## Run it
12
14
 
@@ -28,7 +30,8 @@ vp test examples/mcp-tools
28
30
 
29
31
  The farm is a fake, answered in memory, so any URL, user and password work: replace it with a
30
32
  real HTTP client before deploying anything. `ALLOWED_ORIGINS` lists the browser origins the HTTP
31
- endpoint accepts and answers with CORS headers, comma-separated; `HOST` is the interface it listens on.
33
+ endpoint accepts and answers with CORS headers, comma-separated; `HOST` is the interface it
34
+ listens on.
32
35
 
33
36
  ## Connect a client
34
37
 
@@ -102,7 +105,9 @@ test/
102
105
 
103
106
  - **The upstream is a service, built once.** `PrintFarm.layer` reads its URL and credentials
104
107
  from `Config`, takes whichever `HttpClient` the entrypoint provides, and signs in while it is
105
- built. The handlers get it already signed in and only delegate.
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.
106
111
  - **One declared error, mapped in one place.** A refusal, an unreachable farm, an answer of
107
112
  another shape and one that never comes each leave `farm.ts` as a `PrintFarmError` with a
108
113
  message a model can act on. No handler catches anything.
@@ -67,8 +67,8 @@ export const GetCameraSnapshot = Action.make("get_camera_snapshot", {
67
67
  mcp: { title: "Camera snapshot" },
68
68
  });
69
69
 
70
- // A write that adds something and removes nothing says so: MCP assumes a write is destructive
71
- // unless `destructiveHint` is false. A second call is refused, so it is not idempotent.
70
+ // Destructive, MCP's default for a write: a print sets a machine to work and spends material,
71
+ // which is more than adding a record. A second call is refused, so it is not idempotent.
72
72
  export const StartPrint = Action.make("start_print", {
73
73
  ...tool,
74
74
  description:
@@ -80,7 +80,7 @@ export const StartPrint = Action.make("start_print", {
80
80
  },
81
81
  success: Printer,
82
82
  readOnly: false,
83
- mcp: { title: "Start a print", destructiveHint: false },
83
+ mcp: { title: "Start a print" },
84
84
  });
85
85
 
86
86
  // Idempotent: the same call again leaves the printer as the first left it.
@@ -95,7 +95,9 @@ export const SetPaused = Action.make("set_paused", {
95
95
  },
96
96
  success: Printer,
97
97
  readOnly: false,
98
- mcp: { title: "Pause or resume a print", destructiveHint: false, idempotentHint: true },
98
+ // Destructive, MCP's default for a write: a pause interrupts a running print. Only a write
99
+ // that only adds, such as creating a record, says `destructiveHint: false`.
100
+ mcp: { title: "Pause or resume a print", idempotentHint: true },
99
101
  });
100
102
 
101
103
  // Destructive, which is MCP's default for a write, so no `destructiveHint` is stated.
@@ -8,8 +8,9 @@ import { routes } from "./routes.js";
8
8
 
9
9
  // Entrypoint: `node --import tsx examples/mcp-tools/src/server.ts`, with FARM_URL, FARM_USER
10
10
  // and FARM_PASSWORD set. MCP over HTTP, for clients that connect to a URL.
11
- // Nothing here authenticates a caller, so where it listens is its access control: the loopback
12
- // interface by default, or a network only trusted clients are on.
11
+ // These tools are public: no route asks for a token, so where the server listens is its access
12
+ // control, the loopback interface by default or a network only trusted clients are on. Tools
13
+ // that need a caller take a verifier, as docs/ActionMcp.md's "Protect tools" shows.
13
14
  const server = Layer.unwrap(
14
15
  Effect.gen(function* () {
15
16
  const host = yield* Config.String("HOST").pipe(Config.withDefault("127.0.0.1"));
@@ -79,11 +79,11 @@ describe("the tools, over MCP", () => {
79
79
  },
80
80
  {
81
81
  name: "start_print",
82
- annotations: { title: "Start a print", ...hints(false, false, false) },
82
+ annotations: { title: "Start a print", ...hints(false, true, false) },
83
83
  },
84
84
  {
85
85
  name: "set_paused",
86
- annotations: { title: "Pause or resume a print", ...hints(false, false, true) },
86
+ annotations: { title: "Pause or resume a print", ...hints(false, true, true) },
87
87
  },
88
88
  {
89
89
  name: "cancel_print",
@@ -23,6 +23,7 @@ node --import tsx examples/notes-service/src/cli.ts --help
23
23
  echo "First draft" | node --import tsx examples/notes-service/src/cli.ts create-note --title Plan
24
24
  node --import tsx examples/notes-service/src/cli.ts list-notes
25
25
  node --import tsx examples/notes-service/src/cli.ts export notes.md # a command written by hand
26
+ node --import tsx examples/notes-service/src/cli.ts import notes.md # one importNotes call
26
27
 
27
28
  vp test examples/notes-service
28
29
  ```
@@ -36,8 +37,8 @@ codex mcp add notes --url http://127.0.0.1:3000/mcp --bearer-token-env-var NOTES
36
37
 
37
38
  `src/stdio.ts` is the other way in: a subprocess the host starts, with a store of its own.
38
39
 
39
- The server reads `PORT` (default 3000) and `PUBLIC_URL`, the address it tells OAuth clients it
40
- is at; the CLI reads `NOTES_URL` and `NOTES_TOKEN`. Change the port and set all three to match.
40
+ The server reads `HOST` (default `127.0.0.1`), `PORT` (default 3000) and `PUBLIC_URL`, the
41
+ address it tells OAuth clients it is at; the CLI reads `NOTES_URL` and `NOTES_TOKEN`. Change the port and set all three to match.
41
42
  Every command asks for `NOTES_TOKEN`, the public `get-status` included: without it a command
42
43
  prints one message on stderr saying to set it, and exits 1. `--help` needs none.
43
44
 
@@ -77,7 +78,8 @@ test/
77
78
  `contracts/` or `handlers/` folder.
78
79
  - **The PascalCase file is the contract.** `Notes.ts`, `Identity.ts`, `Status.ts` and `Api.ts`
79
80
  import no server code, so a browser or a CLI imports them. The lowercase files beside them run
80
- on the server.
81
+ on the server, each named for its role, never the domain: `notes.ts` beside `Notes.ts` is one
82
+ file on a disk that ignores case (TS1149).
81
83
  - **One entrypoint per way of running it.** Each chooses its own implementations: `server.ts`
82
84
  picks the store and the HTTP server, `stdio.ts` names the local user, a test provides its own
83
85
  store. Nothing below an entrypoint knows which it got.
@@ -99,6 +101,7 @@ test/
99
101
  | The same actions over stdio, the host naming the caller | `src/stdio.ts` |
100
102
  | A CLI derived from the binding: an argument, a field on stdin | `src/commands.ts` |
101
103
  | A command written by hand beside the derived ones | `src/commands.ts` |
104
+ | A bulk import as one action, its command reading a file | `src/notes/`, `src/commands.ts` |
102
105
  | The CLI's connection, read from config when a command runs | `src/commands.ts`, `src/cli.ts` |
103
106
  | A missing token as one message, not a `ConfigError` report | `src/commands.ts` |
104
107
  | A promise client handling each declared error by its tag | `src/browser.ts` |
@@ -5,6 +5,13 @@ 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
+ // The file's format: each note a `# ` title line, a blank line and its body, the notes apart by
9
+ // a blank line. A body line that starts as a title would is written with a backslash before it,
10
+ // as Markdown escapes one, and `import` takes it off, so every note reads back as it was.
11
+ const escape = (body: string) => body.replaceAll(/^(?=\\*# )/gmu, "\\");
12
+
13
+ const unescape = (body: string) => body.replaceAll(/^\\(?=\\*# )/gmu, "");
14
+
8
15
  // A command of your own, for what no single action does: this one calls two and writes a file.
9
16
  // It is a native Effect CLI command, so its syntax and its output are what you write: `notes
10
17
  // export notes.md` takes its argument from `Argument`, as a flag would come from `Flag`. It
@@ -22,7 +29,7 @@ const exportNotes = Command.make(
22
29
 
23
30
  yield* files.writeFileString(
24
31
  file,
25
- whole.map((note) => `# ${note.title}\n\n${note.body}`).join("\n\n"),
32
+ whole.map((note) => `# ${note.title}\n\n${escape(note.body)}`).join("\n\n"),
26
33
  );
27
34
 
28
35
  yield* Console.log(`Wrote ${whole.length} notes to ${file}`);
@@ -40,6 +47,47 @@ const exportNotes = Command.make(
40
47
  ),
41
48
  ).pipe(Command.withDescription("Write every note to a file, as Markdown."));
42
49
 
50
+ // The other half of `export`: it reads such a file and creates its notes with one call of the
51
+ // bulk action, so the server checks every note before it writes any, and a file of more than
52
+ // the action's 100 is refused whole. The command reads the file and nothing more.
53
+ // `Argument.FileText` reads it, and a missing file is a usage error naming it, where
54
+ // `Argument.String` and `FileSystem` would print the platform's error. Text before the first
55
+ // title is refused, not dropped: the file is not one `export` wrote. The derived `import-notes`
56
+ // stays beside it, taking the notes as a JSON array in `--notes`.
57
+ const importNotes = Command.make(
58
+ "import",
59
+ {
60
+ text: Argument.FileText("file").pipe(
61
+ Argument.withDescription("A Markdown file as `notes export` writes it."),
62
+ ),
63
+ },
64
+ ({ text }) =>
65
+ Effect.gen(function* () {
66
+ const client = yield* ActionHttp.client(Http);
67
+
68
+ if (text === "") return yield* Console.log("Created 0 notes");
69
+
70
+ if (!text.startsWith("# ")) {
71
+ return yield* new CliError.UserError({
72
+ cause: text.split("\n", 1)[0],
73
+ userMessage: "The file does not start with a `# ` title, as `notes export` writes one.",
74
+ });
75
+ }
76
+
77
+ const notes = text.split(/\n\n(?=# )/u).map((note) => {
78
+ const [title = "", ...body] = note.slice(2).split("\n\n");
79
+
80
+ return { title, body: unescape(body.join("\n\n")) };
81
+ });
82
+
83
+ const { notes: created } = yield* client.importNotes({ notes });
84
+
85
+ yield* Console.log(`Created ${created.length} notes`);
86
+ }).pipe(ActionCli.toUserError(Http)),
87
+ ).pipe(
88
+ Command.withDescription("Create the notes of a file `export` wrote, all or none, at most 100."),
89
+ );
90
+
43
91
  // A command per action of the binding, a flag per input field, each calling the server over
44
92
  // HTTP. It imports the binding alone, never a handler. It is apart from cli.ts so a test runs
45
93
  // it: test/commands.test.ts.
@@ -54,7 +102,7 @@ export const commands = ActionCli.remote(Http, {
54
102
  },
55
103
  // `Command.withSubcommands` on the result would replace the derived commands, so your own
56
104
  // go here.
57
- subcommands: [exportNotes],
105
+ subcommands: [exportNotes, importNotes],
58
106
  });
59
107
 
60
108
  // Where the server is and who is calling, read from config: the `HttpClient` every command
@@ -32,8 +32,13 @@ const Id = Schema.String.annotate({
32
32
  description: "The note's id, as the list of your notes shows it.",
33
33
  });
34
34
 
35
- const Title = Schema.String.check(Schema.isMinLength(1)).annotate({
36
- description: "A short name for the note, at least one character.",
35
+ // A check's `message` is what a caller reads when the value fails it, in place of the
36
+ // generic "Expected a value with a length of at least 1". A model and a CLI user read it too.
37
+ const Title = Schema.String.check(
38
+ Schema.isMinLength(1, { message: "A note needs a title." }),
39
+ Schema.isPattern(/^[^\r\n\u2028\u2029]*$/u, { message: "A title is one line." }),
40
+ ).annotate({
41
+ description: "A short name for the note: one line, at least one character.",
37
42
  });
38
43
 
39
44
  const Body = Schema.String.annotate({ description: "The note's whole text." });
@@ -77,7 +82,8 @@ export const UpdateNote = Action.make("updateNote", {
77
82
  error: [NoteNotFound, NoteChanged],
78
83
  readOnly: false,
79
84
  caller: CurrentUser,
80
- mcp: { destructiveHint: false },
85
+ // No `destructiveHint`: an update replaces the body, so it is destructive, MCP's default for
86
+ // a write. `createNote` and `importNotes`, which only add, say `false`.
81
87
  });
82
88
 
83
89
  export const DeleteNote = Action.make("deleteNote", {
@@ -88,4 +94,26 @@ export const DeleteNote = Action.make("deleteNote", {
88
94
  caller: CurrentUser,
89
95
  });
90
96
 
91
- export const Notes = [ListNotes, GetNote, CreateNote, UpdateNote, DeleteNote] as const;
97
+ // Work every surface should offer is an action, a bulk import included: the HTTP API, the MCP
98
+ // tools and the CLI then all have it, and the whole list is decoded, so checked, before the
99
+ // handler writes any of it. A command of your own only reads the file and calls this once
100
+ // (commands.ts). `importNotes` adds and removes nothing, so `destructiveHint` is `false`.
101
+ export const ImportNotes = Action.make("importNotes", {
102
+ description:
103
+ "Create several notes at once, such as those `notes export` wrote. Every note is checked " +
104
+ "before any is written: an import holding one that is not valid writes none.",
105
+ input: {
106
+ notes: Schema.Array(Schema.Struct({ title: Title, body: Body }))
107
+ .check(
108
+ Schema.isMinLength(1, { message: "An import holds at least one note." }),
109
+ Schema.isMaxLength(100, { message: "An import holds at most 100 notes." }),
110
+ )
111
+ .annotate({ description: "The notes to create, in order: from 1 to 100." }),
112
+ },
113
+ success: { notes: Schema.Array(Note) },
114
+ readOnly: false,
115
+ caller: CurrentUser,
116
+ mcp: { destructiveHint: false },
117
+ });
118
+
119
+ export const Notes = [ListNotes, GetNote, CreateNote, UpdateNote, DeleteNote, ImportNotes] as const;
@@ -6,8 +6,9 @@ import { Notes } from "./Notes.js";
6
6
  import { NoteStore } from "./store.js";
7
7
 
8
8
  // The builder runs once, at startup, however many surfaces serve these actions: it is where a
9
- // handler gets its services. `CurrentUser` is read per call. Each handler is a thin step from
10
- // the contract to the store, and none checks a scope: `authorize` has run before it.
9
+ // handler gets the services that last the process, never the caller. `CurrentUser` is read per
10
+ // call, inside each handler. Each handler is a thin step from the contract to the store, and
11
+ // none checks a scope: `authorize` has run before it.
11
12
  export const notes = Action.implement(
12
13
  Notes,
13
14
  Effect.gen(function* () {
@@ -27,6 +28,11 @@ export const notes = Action.implement(
27
28
  updateNote: ({ id, body, revision }) =>
28
29
  Effect.flatMap(CurrentUser, (user) => store.update(user.id, id, body, revision)),
29
30
  deleteNote: ({ id }) => Effect.flatMap(CurrentUser, (user) => store.remove(user.id, id)),
31
+ importNotes: ({ notes }) =>
32
+ Effect.map(
33
+ Effect.flatMap(CurrentUser, (user) => store.createAll(user.id, notes)),
34
+ (created) => ({ notes: created }),
35
+ ),
30
36
  };
31
37
  }),
32
38
  { authorize },
@@ -11,6 +11,11 @@ export class NoteStore extends Context.Service<
11
11
  readonly list: (owner: string) => Effect.Effect<ReadonlyArray<Note>>;
12
12
  readonly get: (owner: string, id: string) => Effect.Effect<Note, NoteNotFound>;
13
13
  readonly create: (owner: string, title: string, body: string) => Effect.Effect<Note>;
14
+ /** Every note or none: a database does this in one transaction. */
15
+ readonly createAll: (
16
+ owner: string,
17
+ notes: ReadonlyArray<{ readonly title: string; readonly body: string }>,
18
+ ) => Effect.Effect<ReadonlyArray<Note>>;
14
19
  readonly update: (
15
20
  owner: string,
16
21
  id: string,
@@ -48,6 +53,12 @@ export class NoteStore extends Context.Service<
48
53
  get,
49
54
  create: (owner, title, body) =>
50
55
  Effect.sync(() => save(owner, { id: crypto.randomUUID(), title, body, revision: 1 })),
56
+ createAll: (owner, all) =>
57
+ Effect.sync(() =>
58
+ all.map(({ title, body }) =>
59
+ save(owner, { id: crypto.randomUUID(), title, body, revision: 1 }),
60
+ ),
61
+ ),
51
62
  // No `yield*` between the read and the write: a fiber may be set aside at one, and two
52
63
  // updates that each read revision 1 before either wrote would both succeed. A database
53
64
  // does this with a conditional update or a transaction.
@@ -10,12 +10,12 @@ import { routes } from "./routes.js";
10
10
  // and starts Node's HTTP server; nothing below it knows which of those it got.
11
11
  const server = Layer.unwrap(
12
12
  Effect.gen(function* () {
13
+ // The loopback default serves this machine alone; HOST=0.0.0.0 lets others reach it.
14
+ const host = yield* Config.String("HOST").pipe(Config.withDefault("127.0.0.1"));
13
15
  const port = yield* Config.Port("PORT").pipe(Config.withDefault(3000));
14
16
 
15
17
  return HttpRouter.serve(routes).pipe(
16
- // `127.0.0.1` serves this machine alone. A server others reach reads its host from
17
- // config: `Config.String("HOST").pipe(Config.withDefault("127.0.0.1"))`.
18
- Layer.provide(NodeHttpServer.layer(createServer, { host: "127.0.0.1", port })),
18
+ Layer.provide(NodeHttpServer.layer(createServer, { host, port })),
19
19
  // Request bodies have no size limit unless the host sets one. This covers every route.
20
20
  Layer.provide(Layer.succeed(HttpServerRequest.MaxBodySize, ByteSize.mebibytes(1))),
21
21
  Layer.provide(NoteStore.layerMemory),