@gjermundgaraba/effect-actions 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -4
- package/dist/Action.d.mts +18 -6
- package/dist/Action.mjs +3 -1
- package/dist/ActionCli.d.mts +16 -9
- package/dist/ActionCli.mjs +4 -3
- package/dist/ActionCliClient.d.mts +9 -8
- package/dist/ActionCliClient.mjs +1 -1
- package/dist/ActionGroup.d.mts +16 -4
- package/dist/ActionGroup.mjs +5 -1
- package/dist/ActionHttp.d.mts +32 -12
- package/dist/ActionHttp.mjs +39 -14
- package/dist/ActionMcp.d.mts +24 -7
- package/dist/ActionMcp.mjs +13 -5
- package/dist/ActionToolkit.d.mts +25 -9
- package/dist/ActionToolkit.mjs +7 -2
- package/dist/Testing.mjs +1 -1
- package/dist/internal/actions.mjs +7 -1
- package/dist/internal/implementation.mjs +5 -2
- package/dist/internal/tools.mjs +13 -11
- package/docs/Action.md +35 -12
- package/docs/ActionCatalog.md +2 -1
- package/docs/ActionCli.md +25 -11
- package/docs/ActionGroup.md +25 -18
- package/docs/ActionHttp.md +44 -16
- package/docs/ActionMcp.md +23 -10
- package/docs/ActionToolkit.md +23 -7
- package/docs/Authentication.md +5 -2
- package/docs/CONTEXT.md +6 -2
- package/docs/README.md +5 -5
- package/docs/Testing.md +1 -1
- package/docs/guarantees.md +13 -4
- package/docs/setup.md +1 -1
- package/package.json +1 -1
package/dist/ActionToolkit.d.mts
CHANGED
|
@@ -1,25 +1,41 @@
|
|
|
1
|
-
import { Action_d_exports, Any } from "./Action.mjs";
|
|
1
|
+
import { Action_d_exports, Any, Codec } from "./Action.mjs";
|
|
2
2
|
import { AnyImplementation, BuildContext, BuildError, HandlerContext, Implementation } from "./internal/implementation.mjs";
|
|
3
|
-
import { Layer, Schema } from "effect";
|
|
3
|
+
import { Effect, Layer, Schema } from "effect";
|
|
4
4
|
import { Tool, Toolkit } from "effect/unstable/ai";
|
|
5
5
|
//#region src/ActionToolkit.d.ts
|
|
6
|
+
/** What `make` binds around the implementations it projects. */
|
|
7
|
+
export interface Options<Errors extends ReadonlyArray<Codec> = [], R = never> {
|
|
8
|
+
/**
|
|
9
|
+
* Failures the caller answers with instead of a handler: authorization, rate
|
|
10
|
+
* limits. Declared on every tool, so a refusal is returned as a tool failure
|
|
11
|
+
* exactly like an action's own error.
|
|
12
|
+
*/
|
|
13
|
+
readonly errors?: Errors;
|
|
14
|
+
/**
|
|
15
|
+
* Runs once per tool call, before the selected handler, with the action contract
|
|
16
|
+
* it is about to run. It fails with this binding's `errors`. Its services join
|
|
17
|
+
* each tool's request requirements, like a handler's.
|
|
18
|
+
*/
|
|
19
|
+
readonly before?: (action: Any) => Effect.Effect<void, Errors[number]["Type"], R>;
|
|
20
|
+
}
|
|
6
21
|
/** A native tool corresponding to an MCP-enabled action. */
|
|
7
|
-
type NativeTool<A extends Any, R> = A extends {
|
|
22
|
+
type NativeTool<A extends Any, E extends Codec, R> = A extends {
|
|
8
23
|
readonly mcp: {
|
|
9
24
|
readonly name: infer Name extends string;
|
|
10
25
|
};
|
|
11
26
|
} ? Tool.Tool<Name, {
|
|
12
27
|
readonly parameters: A["input"];
|
|
13
28
|
readonly success: A["success"];
|
|
14
|
-
readonly failure: Schema.Union<A["errors"]
|
|
29
|
+
readonly failure: Schema.Union<ReadonlyArray<A["errors"][number] | E>>;
|
|
15
30
|
readonly failureMode: "return";
|
|
16
31
|
}, R> : never;
|
|
17
32
|
type HandlersOf<App extends AnyImplementation> = App extends Implementation<any, infer H, any, any> ? H : never;
|
|
18
|
-
|
|
33
|
+
/** A tool needs what its handler needs, plus what the binding's `before` hook needs. */
|
|
34
|
+
type ToolForAction<App extends AnyImplementation, A extends Any, E extends Codec, RB> = A extends {
|
|
19
35
|
readonly mcp: object;
|
|
20
|
-
} ? NativeTool<A, HandlerContext<HandlersOf<App>, Extract<A["name"], keyof HandlersOf<App
|
|
21
|
-
type ToolsFor<App extends AnyImplementation> = App extends AnyImplementation ? ToolForAction<App, App["group"]["actions"][number]> : never;
|
|
22
|
-
type ToolkitTools<Apps extends ReadonlyArray<AnyImplementation
|
|
36
|
+
} ? NativeTool<A, E, HandlerContext<HandlersOf<App>, Extract<A["name"], keyof HandlersOf<App>>> | RB> : never;
|
|
37
|
+
type ToolsFor<App extends AnyImplementation, E extends Codec, RB> = App extends AnyImplementation ? ToolForAction<App, App["group"]["actions"][number], E, RB> : never;
|
|
38
|
+
type ToolkitTools<Apps extends ReadonlyArray<AnyImplementation>, E extends Codec, RB> = { readonly [T in ToolsFor<Apps[number], E, RB> as T["name"]]: T; };
|
|
23
39
|
/** Native tools and the layer that binds their action implementations. */
|
|
24
40
|
export interface Binding<Tools extends Record<string, Tool.Any>, E = never, R = never> {
|
|
25
41
|
readonly toolkit: Toolkit.Toolkit<Tools>;
|
|
@@ -33,5 +49,5 @@ export interface Binding<Tools extends Record<string, Tool.Any>, E = never, R =
|
|
|
33
49
|
* Build services are needed to construct `layer`; request services are needed
|
|
34
50
|
* when the resulting toolkit handles a call.
|
|
35
51
|
*/
|
|
36
|
-
export declare function make<const Apps extends ReadonlyArray<AnyImplementation
|
|
52
|
+
export declare function make<const Apps extends ReadonlyArray<AnyImplementation>, const Errors extends ReadonlyArray<Codec> = [], RB = never>(options: Options<Errors, RB>, ...apps: Apps): Binding<ToolkitTools<Apps, Errors[number], RB>, BuildError<Apps[number]>, BuildContext<Apps[number]>>;
|
|
37
53
|
//#endregion
|
package/dist/ActionToolkit.mjs
CHANGED
|
@@ -3,8 +3,13 @@ import { bindTools } from "./internal/tools.mjs";
|
|
|
3
3
|
import "effect";
|
|
4
4
|
import "effect/unstable/ai";
|
|
5
5
|
//#region src/ActionToolkit.ts
|
|
6
|
-
function make(...apps) {
|
|
7
|
-
|
|
6
|
+
function make(options, ...apps) {
|
|
7
|
+
const before = options.before;
|
|
8
|
+
const toolOptions = {
|
|
9
|
+
errors: options.errors,
|
|
10
|
+
before
|
|
11
|
+
};
|
|
12
|
+
return bindTools(apps, "native", toolOptions);
|
|
8
13
|
}
|
|
9
14
|
//#endregion
|
|
10
15
|
export { make };
|
package/dist/Testing.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Effect, Layer, Predicate } from "effect";
|
|
2
|
-
import { HttpApiClient } from "effect/unstable/httpapi";
|
|
3
2
|
import { FetchHttpClient } from "effect/unstable/http";
|
|
3
|
+
import { HttpApiClient } from "effect/unstable/httpapi";
|
|
4
4
|
//#region src/Testing.ts
|
|
5
5
|
/**
|
|
6
6
|
* The native grouped HTTP client, calling `handler` in memory instead of the network.
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
//#region src/internal/actions.ts
|
|
2
|
+
/**
|
|
3
|
+
* An action's own failures plus the ones its surface answers with, which is what
|
|
4
|
+
* a projection of that action declares. A schema the action already declares is
|
|
5
|
+
* not repeated.
|
|
6
|
+
*/
|
|
7
|
+
const projectedErrors = (action, surface) => [...action.errors, ...(surface ?? []).filter((error) => !action.errors.includes(error))];
|
|
2
8
|
const validName = /^[A-Za-z0-9_-]+$/;
|
|
3
9
|
/** Names become path segments, OpenAPI identifiers and client method keys. */
|
|
4
10
|
const assertName = (what, name) => {
|
|
@@ -13,4 +19,4 @@ const assertDistinct = (what, names) => {
|
|
|
13
19
|
}
|
|
14
20
|
};
|
|
15
21
|
//#endregion
|
|
16
|
-
export { assertDistinct, assertName };
|
|
22
|
+
export { assertDistinct, assertName, projectedErrors };
|
|
@@ -23,14 +23,17 @@ var Implementation = class Implementation {
|
|
|
23
23
|
};
|
|
24
24
|
/**
|
|
25
25
|
* Select and invoke a handler after an adapter has selected a concrete action.
|
|
26
|
+
* The surface's pre-handler hook runs first, outside the action's span, so a
|
|
27
|
+
* refusal is attributed to the surface rather than to a handler that never ran.
|
|
26
28
|
* `R` remains in the returned effect so transport layers cannot erase required
|
|
27
29
|
* per-request services while assembling routes.
|
|
28
30
|
*/
|
|
29
|
-
const dispatch = (group, action, handlers) => {
|
|
31
|
+
const dispatch = (group, action, handlers, before) => {
|
|
30
32
|
const handle = handlers[action.name];
|
|
31
33
|
if (handle === void 0) throw new Error(`Missing handler: ${action.name}`);
|
|
32
34
|
return (input) => {
|
|
33
|
-
|
|
35
|
+
const handled = Effect.withSpan(Effect.suspend(() => handle(input)), `${group.name}.${action.name}`, { captureStackTrace: false });
|
|
36
|
+
return before === void 0 ? handled : Effect.flatMap(before(action), () => handled);
|
|
34
37
|
};
|
|
35
38
|
};
|
|
36
39
|
//#endregion
|
package/dist/internal/tools.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { projectedErrors } from "./actions.mjs";
|
|
1
2
|
import { dispatch } from "./implementation.mjs";
|
|
2
3
|
import { Effect, Schema } from "effect";
|
|
3
4
|
import { Tool, Toolkit } from "effect/unstable/ai";
|
|
@@ -30,22 +31,22 @@ const annotate = (tool, mcp) => tool.annotate(Tool.Readonly, mcp.readOnly).annot
|
|
|
30
31
|
* A native Effect AI tool. Its schemas retain action transforms and its result
|
|
31
32
|
* is the action result itself, rather than an MCP response envelope.
|
|
32
33
|
*/
|
|
33
|
-
const nativeTool = (action) => annotate(Tool.make(action.mcp.name, {
|
|
34
|
+
const nativeTool = (action, errors) => annotate(Tool.make(action.mcp.name, {
|
|
34
35
|
description: action.description,
|
|
35
36
|
parameters: action.input,
|
|
36
37
|
success: action.success,
|
|
37
|
-
failure: Schema.Union(
|
|
38
|
+
failure: Schema.Union(errors),
|
|
38
39
|
failureMode: "return"
|
|
39
40
|
}), action.mcp);
|
|
40
41
|
/**
|
|
41
42
|
* MCP has a JSON-only wire contract. Success uses its documented `{ value }`
|
|
42
43
|
* structured-content envelope; declared failures are returned as JSON text.
|
|
43
44
|
*/
|
|
44
|
-
const mcpTool = (action) => annotate(Tool.make(action.mcp.name, {
|
|
45
|
+
const mcpTool = (action, errors) => annotate(Tool.make(action.mcp.name, {
|
|
45
46
|
description: action.description,
|
|
46
47
|
parameters: Schema.toCodecJson(action.input),
|
|
47
48
|
success: Schema.toCodecJson(Schema.Struct({ value: action.success })),
|
|
48
|
-
failure: Schema.toCodecJson(Schema.Union(
|
|
49
|
+
failure: Schema.toCodecJson(Schema.Union(errors)),
|
|
49
50
|
failureMode: "return"
|
|
50
51
|
}), action.mcp);
|
|
51
52
|
/** MCP's input schema must describe a JSON object, including no-argument tools. */
|
|
@@ -53,14 +54,15 @@ const assertMcpObjectInput = (action, tool) => {
|
|
|
53
54
|
const root = Tool.getJsonSchemaFromSchema(tool.parametersSchema);
|
|
54
55
|
if (root.$ref === void 0 && root.type !== "object") throw new Error(`${action.name}: MCP input must have an object root; omit input for no arguments`);
|
|
55
56
|
};
|
|
56
|
-
const project = (projection, entry) => {
|
|
57
|
-
const
|
|
57
|
+
const project = (projection, entry, options) => {
|
|
58
|
+
const errors = projectedErrors(entry.action, options.errors);
|
|
59
|
+
const tool = projection === "native" ? nativeTool(entry.action, errors) : mcpTool(entry.action, errors);
|
|
58
60
|
if (projection === "native") return tool;
|
|
59
61
|
assertMcpObjectInput(entry.action, tool);
|
|
60
62
|
return tool.annotate(Tool.Strict, true);
|
|
61
63
|
};
|
|
62
|
-
const handler = (projection, app, action, handlers) => {
|
|
63
|
-
const run = (input) => dispatch(app.group, action, handlers)(input);
|
|
64
|
+
const handler = (projection, app, action, handlers, before) => {
|
|
65
|
+
const run = (input) => dispatch(app.group, action, handlers, before)(input);
|
|
64
66
|
return projection === "native" ? run : (input) => Effect.map(run(input), (value) => ({ value }));
|
|
65
67
|
};
|
|
66
68
|
/**
|
|
@@ -68,16 +70,16 @@ const handler = (projection, app, action, handlers) => {
|
|
|
68
70
|
* Native and MCP differ only in tool codecs and the MCP success envelope; selection,
|
|
69
71
|
* scoped acquisition, dispatch and native Toolkit binding stay identical.
|
|
70
72
|
*/
|
|
71
|
-
const bindTools = (apps, projection) => {
|
|
73
|
+
const bindTools = (apps, projection, options) => {
|
|
72
74
|
const served = toolApps(apps);
|
|
73
75
|
const selected = entries(served);
|
|
74
76
|
assertDistinctToolNames(selected);
|
|
75
|
-
const toolkit = Toolkit.make(...selected.map((entry) => project(projection, entry)));
|
|
77
|
+
const toolkit = Toolkit.make(...selected.map((entry) => project(projection, entry, options)));
|
|
76
78
|
return {
|
|
77
79
|
toolkit,
|
|
78
80
|
layer: toolkit.toLayer(Effect.map(Effect.forEach(served, ({ app, actions }) => Effect.map(app.build, (handlers) => {
|
|
79
81
|
const record = handlers;
|
|
80
|
-
return actions.map((action) => [action.mcp.name, handler(projection, app, action, record)]);
|
|
82
|
+
return actions.map((action) => [action.mcp.name, handler(projection, app, action, record, options.before)]);
|
|
81
83
|
})), (built) => Object.fromEntries(built.flat())))
|
|
82
84
|
};
|
|
83
85
|
};
|
package/docs/Action.md
CHANGED
|
@@ -11,34 +11,39 @@ import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
|
11
11
|
/** Any service-free schema. */
|
|
12
12
|
type Codec = Schema.Codec<unknown, unknown, never, never>;
|
|
13
13
|
|
|
14
|
-
function make<Name, Input = NoInput, Output, Errors = [], Http = true, Mcp = undefined>(
|
|
14
|
+
function make<Name, Input = NoInput, Output, Errors = [], Acc extends Access, Http = true, Mcp = undefined>(
|
|
15
15
|
name: Name,
|
|
16
|
-
options: Options<Input, Output, Errors, Http, Mcp>,
|
|
17
|
-
): Action<Name, Input, Output, Errors, Http, Mcp>;
|
|
16
|
+
options: Options<Input, Output, Errors, Acc, Http, Mcp>,
|
|
17
|
+
): Action<Name, Input, Output, Errors, Acc, Http, Mcp>;
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
/** What the action does to its resource. Authorization metadata, not an MCP hint. */
|
|
20
|
+
type Access = "read" | "write";
|
|
21
|
+
|
|
22
|
+
interface Options<Input, Output, Errors, Acc, Http, Mcp> {
|
|
20
23
|
readonly description: string;
|
|
21
24
|
readonly input?: Input; // omit for an action without arguments
|
|
22
25
|
readonly success: Output;
|
|
23
26
|
readonly errors?: Errors; // ReadonlyArray<Codec>; defaults to []
|
|
27
|
+
readonly access: Acc; // required, no default
|
|
24
28
|
readonly http?: Http; // false hides the action from HTTP routes and clients
|
|
25
29
|
readonly mcp?: Mcp; // false hides the action from MCP; otherwise McpOptions
|
|
26
30
|
}
|
|
27
31
|
|
|
28
32
|
interface McpOptions {
|
|
29
33
|
readonly name?: string; // tool name; defaults to the action name; ^[A-Za-z0-9_-]{1,128}$
|
|
30
|
-
readonly readOnly?: boolean; // readOnlyHint, default
|
|
34
|
+
readonly readOnly?: boolean; // readOnlyHint, default access === "read"
|
|
31
35
|
readonly destructive?: boolean; // destructiveHint, default !readOnly
|
|
32
36
|
readonly idempotent?: boolean; // idempotentHint, default false
|
|
33
37
|
readonly openWorld?: boolean; // openWorldHint, default true
|
|
34
38
|
}
|
|
35
39
|
|
|
36
|
-
interface Action<Name, Input, Output, Errors, Http, Mcp> {
|
|
40
|
+
interface Action<Name, Input, Output, Errors, Acc, Http, Mcp> {
|
|
37
41
|
readonly name: Name;
|
|
38
42
|
readonly description: string;
|
|
39
43
|
readonly input: Input;
|
|
40
44
|
readonly success: Output;
|
|
41
45
|
readonly errors: Errors;
|
|
46
|
+
readonly access: Acc; // the literal it was declared with
|
|
42
47
|
readonly http: Http;
|
|
43
48
|
readonly mcp: false | { name; readOnly; destructive; idempotent; openWorld }; // resolved
|
|
44
49
|
}
|
|
@@ -63,27 +68,41 @@ export class UserNotFound extends Schema.TaggedError<UserNotFound>()(
|
|
|
63
68
|
{ httpApiStatus: 404 },
|
|
64
69
|
) {}
|
|
65
70
|
|
|
71
|
+
// `access: "read"` states the fact once: it is what a surface's `before` hook
|
|
72
|
+
// authorizes on, and it is where `mcp.readOnly` comes from.
|
|
66
73
|
export const GetUser = Action.make("getUser", {
|
|
67
74
|
description: "Look up a user in your tenant.",
|
|
68
75
|
input: Schema.Struct({ id: Schema.String }),
|
|
69
76
|
success: Schema.Struct({ id: Schema.String, name: Schema.String }),
|
|
70
77
|
errors: [UserNotFound],
|
|
71
|
-
|
|
78
|
+
access: "read",
|
|
79
|
+
mcp: { name: "get_user" },
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// A write. Every action says which it is; there is no default to fall back on.
|
|
83
|
+
export const RenameUser = Action.make("renameUser", {
|
|
84
|
+
description: "Rename a user in your tenant.",
|
|
85
|
+
input: Schema.Struct({ id: Schema.String, name: Schema.String }),
|
|
86
|
+
success: Schema.Struct({ id: Schema.String, name: Schema.String }),
|
|
87
|
+
errors: [UserNotFound],
|
|
88
|
+
access: "write",
|
|
89
|
+
mcp: { name: "rename_user", destructive: false },
|
|
72
90
|
});
|
|
73
91
|
|
|
74
92
|
// No arguments: omit `input`. Identity comes from request context, never from input.
|
|
75
93
|
export const WhoAmI = Action.make("whoAmI", {
|
|
76
94
|
description: "Inspect the authenticated actor.",
|
|
77
95
|
success: Schema.Struct({ id: Schema.String, tenantId: Schema.String }),
|
|
78
|
-
|
|
96
|
+
access: "read",
|
|
79
97
|
});
|
|
80
98
|
|
|
81
99
|
// MCP-only: no HTTP route, no client method.
|
|
82
100
|
export const ListChanges = Action.make("listChanges", {
|
|
83
101
|
description: "List renames in your tenant, oldest first.",
|
|
84
102
|
success: Schema.Struct({ changes: Schema.Array(Schema.String) }),
|
|
103
|
+
access: "read",
|
|
85
104
|
http: false,
|
|
86
|
-
mcp: { name: "list_changes"
|
|
105
|
+
mcp: { name: "list_changes" },
|
|
87
106
|
});
|
|
88
107
|
|
|
89
108
|
// Input decoding is the schema's: "21" on the wire, 21 in the handler.
|
|
@@ -91,7 +110,7 @@ export const Double = Action.make("double", {
|
|
|
91
110
|
description: "Double a finite number supplied as a string.",
|
|
92
111
|
input: Schema.Struct({ value: Schema.FiniteFromString }),
|
|
93
112
|
success: Schema.Finite,
|
|
94
|
-
|
|
113
|
+
access: "read",
|
|
95
114
|
});
|
|
96
115
|
```
|
|
97
116
|
|
|
@@ -101,13 +120,15 @@ export const Double = Action.make("double", {
|
|
|
101
120
|
- Omit `input` for a no-argument action. The default is an empty object schema, which satisfies MCP's object-root requirement. Clients still pass `{ payload: {} }`.
|
|
102
121
|
- `errors` is a list of schemas, default none. Each keeps its own `httpApiStatus` annotation; an unannotated error is served as HTTP 500. Group-level `errors` are appended to every action of the group (see [ActionGroup.md](ActionGroup.md)).
|
|
103
122
|
- A handler may fail only with the declared errors. Anything else is a defect.
|
|
123
|
+
- `access` is `"read"` or `"write"` and is required. It stays a literal on the action, so a rule may switch on it at the type level. It is authorization metadata for a surface's `before` hook (see [guarantees.md](guarantees.md)); the library itself authorizes nothing and no adapter reads it.
|
|
124
|
+
- `access` is independent of `mcp`. A local-only action (`mcp: false`) still has one, and an action may set `access: "write"` with `mcp: { readOnly: true }` if the tool hint should say something else. Derive authorization from `access`, never from a tool hint.
|
|
104
125
|
- Both transports are on by default. `http: false` removes the route and the client method. `mcp: false` removes the tool. Both `false` makes a local-only action, reachable through `ActionCli` only.
|
|
105
126
|
- MCP input must have an object-root JSON Schema. Scalar or array input is fine for HTTP, but `ActionMcp.layerHttp`, `ActionMcp.layerStdio`, and `ActionToolkit.make` throw when called with such an action. Success and error schemas may be any shape.
|
|
106
127
|
- `mcp.name` is the tool name, at most 128 characters, unique within the group and within any Toolkit or MCP projection that serves the group. Default is the action name.
|
|
107
|
-
- Hint defaults: `readOnly:
|
|
128
|
+
- Hint defaults: `readOnly: access === "read"`, `destructive: !readOnly`, `idempotent: false`, `openWorld: true`. Hints are metadata for the model. They do not enforce authorization, approval, or retries.
|
|
108
129
|
- Schemas must be service-free. Put service access in the handler.
|
|
109
130
|
- The group name, not the action, is the OpenAPI tag and operation-ID prefix: `<group>.<action>`.
|
|
110
|
-
- HTTP strips undeclared input fields. MCP tools are strict (`Tool.Strict`): undeclared arguments are an invalid-arguments result and input schemas publish `additionalProperties: false`. HTTP cannot be strict
|
|
131
|
+
- HTTP strips undeclared input fields. MCP tools are strict (`Tool.Strict`): undeclared arguments are an invalid-arguments result and input schemas publish `additionalProperties: false`. HTTP is not strict, and cannot be made strict without a hack: Effect merges one `HttpApi.ParseOptions` per endpoint and uses it for payload decoding _and_ error encoding, so `onExcessProperty: "error"` also rejects a `TaggedError` instance's own `message` and `stack`, turning a declared 409 into an empty 500. Making the payload schema itself strict would require wrapping it in an open schema, which erases its OpenAPI shape. Declare the fields you accept and treat extra HTTP fields as ignored.
|
|
111
132
|
|
|
112
133
|
## Failure modes
|
|
113
134
|
|
|
@@ -115,3 +136,5 @@ export const Double = Action.make("double", {
|
|
|
115
136
|
- Type error `Effect<..., X, ...> is not assignable` in `implement`: the handler fails with an undeclared error `X`. Add it to `errors` or handle it.
|
|
116
137
|
- `<action>: MCP input must have an object root` thrown by `ActionMcp.layerHttp`, `ActionMcp.layerStdio`, or `ActionToolkit.make`: an MCP-enabled action has non-object input. Wrap it in `Schema.Struct` or set `mcp: false`.
|
|
117
138
|
- Handler receives a string where a number was expected: the schema is `Schema.String`, not a transforming codec such as `Schema.FiniteFromString`.
|
|
139
|
+
- `Property 'access' is missing` at `make`: every action declares `"read"` or `"write"`. There is no default.
|
|
140
|
+
- A tool shows `readOnlyHint: false` for a read: the action sets `mcp: { readOnly: false }` explicitly, which wins over `access`.
|
package/docs/ActionCatalog.md
CHANGED
|
@@ -46,7 +46,8 @@ process.stdout.write(JSON.stringify(catalog, null, 2));
|
|
|
46
46
|
- `id` is the stable `<group>.<action>` identity, equal to the HTTP operation ID.
|
|
47
47
|
- Schemas describe encoded action values: the JSON on the wire, not MCP's `{ value }` envelope and not deployment URLs.
|
|
48
48
|
- Each schema owns its `$defs`. Equal schema identifiers in different entries never replace each other. Recursive references stay local to their schema.
|
|
49
|
-
- `errors` includes group-level errors. HTTP schema-policy errors are listed separately in `httpSchemaErrors`.
|
|
49
|
+
- `errors` includes group-level errors. HTTP schema-policy errors are listed separately in `httpSchemaErrors`. Surface errors are not listed: they belong to a binding, not to the contract.
|
|
50
|
+
- `access` is not in the document. It is authorization metadata for a surface's hook, and the catalog is descriptive; read it from the contract (`group.actions`) when you need it.
|
|
50
51
|
- Local-only actions (`http: false`, `mcp: false`) are included. Presence in a catalog is descriptive. It is not authorization, tool publication, or proof that a route is mounted. The host decides what to publish.
|
|
51
52
|
- The package adds no search, no catalog HTTP endpoint, and no TypeScript code generation.
|
|
52
53
|
|
package/docs/ActionCli.md
CHANGED
|
@@ -9,25 +9,30 @@ nothing is fetched over HTTP.
|
|
|
9
9
|
import * as ActionCli from "@gjermundgaraba/effect-actions/ActionCli";
|
|
10
10
|
|
|
11
11
|
/** One explicitly selected action. */
|
|
12
|
-
const command: <G, Name extends G["actions"][number]["name"], H, EX, RX, Parameters = never>(
|
|
12
|
+
const command: <G, Name extends G["actions"][number]["name"], H, EX, RX, EB, RB, Parameters = never>(
|
|
13
13
|
app: Implementation<G, H, EX, RX>,
|
|
14
14
|
name: Name,
|
|
15
|
-
options?: Options<SuccessType, Parameters>,
|
|
16
|
-
) => Command.Command<string, never, {}, EX | SchemaError | DeclaredErrors, Exclude<RX | HandlerContext, Scope>>;
|
|
15
|
+
options?: Options<SuccessType, EB, RB, Parameters>,
|
|
16
|
+
) => Command.Command<string, never, {}, EX | EB | SchemaError | DeclaredErrors, Exclude<RX | RB | HandlerContext, Scope>>;
|
|
17
17
|
|
|
18
18
|
/** Every action of the group, including local-only ones, under the group name. */
|
|
19
|
-
const group: <G, H, EX, RX>(
|
|
19
|
+
const group: <G, H, EX, RX, EB, RB>(
|
|
20
20
|
app: Implementation<G, H, EX, RX>,
|
|
21
|
-
options?: GroupOptions,
|
|
22
|
-
) => Command.Command<string, {}, {}, EX | SchemaError | DeclaredErrors, ...>;
|
|
21
|
+
options?: GroupOptions<EB, RB>,
|
|
22
|
+
) => Command.Command<string, {}, {}, EX | EB | SchemaError | DeclaredErrors, ...>;
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
/** The hook both projections accept. A CLI encodes nothing, so it declares no errors. */
|
|
25
|
+
interface BeforeOptions<EB, RB> {
|
|
26
|
+
readonly before?: (action: Action.Any) => Effect.Effect<void, EB, RB>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface GroupOptions<EB, RB> extends BeforeOptions<EB, RB> {
|
|
25
30
|
readonly name?: string; // override the group command name
|
|
26
31
|
}
|
|
27
32
|
|
|
28
|
-
type Options<Output, Parameters extends Command.Command.Config = never> =
|
|
29
|
-
| JsonOptions<Output>
|
|
30
|
-
| ParametersOptions<Output, Parameters
|
|
33
|
+
type Options<Output, EB, RB, Parameters extends Command.Command.Config = never> =
|
|
34
|
+
| (JsonOptions<Output> & BeforeOptions<EB, RB>)
|
|
35
|
+
| (ParametersOptions<Output, Parameters> & BeforeOptions<EB, RB>);
|
|
31
36
|
|
|
32
37
|
interface JsonOptions<Output> {
|
|
33
38
|
readonly name?: string; // override the command name; default is the action name
|
|
@@ -49,21 +54,27 @@ import { Console, Effect, Logger } from "effect";
|
|
|
49
54
|
import { Command, Flag } from "effect/unstable/cli";
|
|
50
55
|
import { NodeRuntime, NodeServices } from "@effect/platform-node";
|
|
51
56
|
import * as ActionCli from "@gjermundgaraba/effect-actions/ActionCli";
|
|
57
|
+
import { actors, authorize, CurrentActor } from "./auth.js";
|
|
52
58
|
import { UserApp } from "./handlers.js";
|
|
53
59
|
import { Users } from "./users.js";
|
|
54
60
|
|
|
55
61
|
// Explicit syntax: `double --value 21`. No `--input` on this command.
|
|
62
|
+
// The CLI binds the same hook as the servers; a local caller is not trusted more.
|
|
56
63
|
const command = ActionCli.command(UserApp, "double", {
|
|
57
64
|
parameters: { value: Flag.String("value") },
|
|
58
65
|
input: ({ value }) => ({ value }),
|
|
66
|
+
before: authorize,
|
|
59
67
|
});
|
|
60
68
|
|
|
61
69
|
// Default syntax for a whole group: `users double --input '{"value":"21"}'`.
|
|
62
|
-
const all = ActionCli.group(UserApp);
|
|
70
|
+
const all = ActionCli.group(UserApp, { before: authorize });
|
|
63
71
|
|
|
64
72
|
Command.runWith(command, { version: "0.1.0" })(process.argv.slice(2)).pipe(
|
|
65
73
|
Effect.tapCause((cause) => Console.error(cause)),
|
|
66
74
|
Effect.provideService(Logger.LogToStderr, true),
|
|
75
|
+
// The hook runs here too, so a local caller supplies the identity it reads
|
|
76
|
+
// exactly as HTTP middleware does for a request.
|
|
77
|
+
Effect.provideService(CurrentActor, actors.alice),
|
|
67
78
|
Effect.provide(Users.layerMemory),
|
|
68
79
|
Effect.provide(NodeServices.layer),
|
|
69
80
|
NodeRuntime.runMain({ disableErrorReporting: true }),
|
|
@@ -80,6 +91,8 @@ Command.runWith(command, { version: "0.1.0" })(process.argv.slice(2)).pipe(
|
|
|
80
91
|
- Output is validated and encoded before printing. Default output is JSON. `render(decoded)` gives human output; `--json` selects JSON again. Rendering cannot bypass validation.
|
|
81
92
|
- Explicit flag names must not collide with the renderer's `--json` flag. A payload field named `json` is ordinary data; only the flag name is reserved.
|
|
82
93
|
- Each invocation acquires the implementation in a scope and releases it after the call. Domain services and authority come from the host's provided layers. There is no HTTP fallback.
|
|
94
|
+
- `before` runs before the selected handler, on `command` and on every subcommand of `group`. Its failure joins the command's error channel and its services are the caller's to provide, so a local CLI supplies the identity the hook reads exactly as HTTP middleware does. A CLI is not a trusted bypass.
|
|
95
|
+
- The CLI declares no `errors`: it encodes nothing, so a refusal is simply a typed failure of the command effect. The native parser also decodes `--input` before the command runs, so unlike HTTP the hook runs after input decoding.
|
|
83
96
|
- Group commands use the default JSON syntax. For a custom tree, compose individual `command` results with native `Command` combinators.
|
|
84
97
|
|
|
85
98
|
## Failure modes
|
|
@@ -88,4 +101,5 @@ Command.runWith(command, { version: "0.1.0" })(process.argv.slice(2)).pipe(
|
|
|
88
101
|
- Command rejects `--input`: the command was built with `parameters`. Use its flags.
|
|
89
102
|
- Type error: `name` is not an action of the group. Check `group.actions` names, not MCP tool names.
|
|
90
103
|
- Handler cannot find a service: provide its Layer to the runtime (`Effect.provide`) before `runMain`. The command does not supply services.
|
|
104
|
+
- A command requires a request-identity tag no handler yields: the `before` hook yields it. Provide it, or build the command without a hook for local use.
|
|
91
105
|
- `--json` flag conflict at definition: rename the native flag; the config property can keep its name.
|
package/docs/ActionGroup.md
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
A named set of actions. The group is what adapters serve, what clients are grouped by, and
|
|
4
4
|
what `implement` binds handlers to. Shared errors and the HTTP schema-error policy live here.
|
|
5
|
+
The pre-handler hook does not: each surface binds its own (see [guarantees.md](guarantees.md)).
|
|
5
6
|
|
|
6
7
|
## API
|
|
7
8
|
|
|
@@ -39,6 +40,13 @@ class Implementation<G, H, EX, RX> {
|
|
|
39
40
|
readonly group: G;
|
|
40
41
|
get build(): Effect.Effect<H, EX, RX | Scope.Scope>;
|
|
41
42
|
}
|
|
43
|
+
|
|
44
|
+
/** Every action of the supplied groups, keyed `<group>.<action>`. */
|
|
45
|
+
function contracts<const Groups extends ReadonlyArray<Group>>(
|
|
46
|
+
...groups: Groups
|
|
47
|
+
): Contracts<Groups>;
|
|
48
|
+
|
|
49
|
+
type Contracts<Groups>; // { "users.getUser": typeof GetUser, "users.renameUser": ... }
|
|
42
50
|
```
|
|
43
51
|
|
|
44
52
|
`HandlersFrom<Actions>` is `{ [action name]: Handler<Action, R> }`, one key per action,
|
|
@@ -50,7 +58,7 @@ all required.
|
|
|
50
58
|
import { Effect, Schema } from "effect";
|
|
51
59
|
import type { HttpApiError } from "effect/unstable/httpapi";
|
|
52
60
|
import * as ActionGroup from "@gjermundgaraba/effect-actions/ActionGroup";
|
|
53
|
-
import {
|
|
61
|
+
import { CurrentActor } from "./auth.js";
|
|
54
62
|
import { GetUser, RenameUser, WhoAmI } from "./contracts.js";
|
|
55
63
|
import { Users } from "./users.js";
|
|
56
64
|
|
|
@@ -74,33 +82,24 @@ const schemaError = {
|
|
|
74
82
|
: new InvalidRequest({ message: "The request does not match the action's input." }),
|
|
75
83
|
};
|
|
76
84
|
|
|
77
|
-
// `Forbidden` is declared once and joins every action's errors.
|
|
78
85
|
export const UserActions = ActionGroup.make(
|
|
79
|
-
{ name: "users",
|
|
86
|
+
{ name: "users", schemaError },
|
|
80
87
|
GetUser,
|
|
81
88
|
RenameUser,
|
|
82
89
|
WhoAmI,
|
|
83
90
|
);
|
|
84
91
|
|
|
85
92
|
// Build-time services (Users) are yielded in the builder, once per adapter layer.
|
|
86
|
-
// Request-time services (CurrentActor) are yielded inside handlers.
|
|
93
|
+
// Request-time services (CurrentActor) are yielded inside handlers. Authorization
|
|
94
|
+
// is not here: each adapter binds one `before` hook for every group it serves.
|
|
87
95
|
export const UserApp = UserActions.implement(
|
|
88
96
|
Effect.gen(function* () {
|
|
89
97
|
const users = yield* Users;
|
|
90
98
|
|
|
91
99
|
return {
|
|
92
|
-
getUser: ({ id }) =>
|
|
93
|
-
Effect.gen(function* () {
|
|
94
|
-
const actor = yield* authorize("users:read");
|
|
95
|
-
|
|
96
|
-
return yield* users.get(actor.tenantId, id);
|
|
97
|
-
}),
|
|
100
|
+
getUser: ({ id }) => Effect.flatMap(CurrentActor, (actor) => users.get(actor.tenantId, id)),
|
|
98
101
|
renameUser: ({ id, name }) =>
|
|
99
|
-
Effect.
|
|
100
|
-
const actor = yield* authorize("users:write");
|
|
101
|
-
|
|
102
|
-
return yield* users.rename(actor, id, name);
|
|
103
|
-
}),
|
|
102
|
+
Effect.flatMap(CurrentActor, (actor) => users.rename(actor, id, name)),
|
|
104
103
|
whoAmI: () => Effect.map(CurrentActor, ({ id, tenantId }) => ({ id, tenantId })),
|
|
105
104
|
};
|
|
106
105
|
}),
|
|
@@ -108,17 +107,22 @@ export const UserApp = UserActions.implement(
|
|
|
108
107
|
|
|
109
108
|
// No build-time services: pass the record directly.
|
|
110
109
|
export const MathApp = MathActions.implement({ double: ({ value }) => Effect.succeed(value * 2) });
|
|
110
|
+
|
|
111
|
+
// One typed map of every contract, keyed "<group>.<action>".
|
|
112
|
+
export const contracts = ActionGroup.contracts(UserActions, MathActions);
|
|
113
|
+
const renames: "renameUser" = contracts["users.renameUser"].name;
|
|
111
114
|
```
|
|
112
115
|
|
|
113
116
|
## Rules
|
|
114
117
|
|
|
115
118
|
- `name` matches `[A-Za-z0-9_-]+` and must be unique within one `ActionHttp.make`. Action names must be unique within the group. MCP tool names must be unique within the group and within each Toolkit or MCP projection that serves it. Duplicates fail at `make`.
|
|
116
|
-
- Group `errors` are appended to each action's own `errors`, so every handler of the group may fail with them.
|
|
117
|
-
- `implement` takes a complete record or an Effect producing one. The record must have exactly one handler per action; a missing key is a compile error.
|
|
119
|
+
- Group `errors` are appended to each action's own `errors`, so every handler of the group may fail with them. A failure that only the surface produces belongs on the adapter instead (`ActionHttp.make`'s `errors`, `ActionMcp`'s and `ActionToolkit`'s `errors`), not here.
|
|
120
|
+
- `implement` takes a complete record or an Effect producing one. The record must have exactly one handler per action; a missing key is a compile error. It takes nothing else.
|
|
118
121
|
- The builder Effect runs once per adapter layer that serves the implementation, in that layer's scope. An implementation served by HTTP and MCP is built twice. Acquire shared state in a Layer you provide to the adapters, not in the builder.
|
|
119
122
|
- Services yielded in the builder are build-time requirements (`RX`). Services yielded in a handler are request-time requirements (`R` of the handler). Adapters keep these separate in their types. Use distinct tags for each kind; never provide a request-identity tag at startup (see [guarantees.md](guarantees.md)).
|
|
120
123
|
- `Implementation` is nominal. Spreading its properties does not produce an implementation. Each `implement` call is a separate binding.
|
|
121
|
-
- `build` exists for direct handler tests under `Effect.scoped`. It bypasses transport validation and is not a substitute for adapter tests. There is no public handler service or Layer.
|
|
124
|
+
- `build` exists for direct handler tests under `Effect.scoped`. It bypasses transport validation and the hook, and is not a substitute for adapter tests. There is no public handler service or Layer.
|
|
125
|
+
- `contracts(...groups)` returns one record keyed `<group>.<action>`, with each action's exact contract type. Duplicate group names throw. Use it where routes, tools, jobs or commands must name an action: the key set is derived, so an added action cannot be missed.
|
|
122
126
|
|
|
123
127
|
### Schema-error policy
|
|
124
128
|
|
|
@@ -130,6 +134,7 @@ export const MathApp = MathActions.implement({ double: ({ value }) => Effect.suc
|
|
|
130
134
|
- The policy runs only on the server. Client-side codec failures stay `SchemaError`.
|
|
131
135
|
- HTTP decodes with `errors: "all"`, so `cause` carries every issue. Issues never retain the rejected values.
|
|
132
136
|
- Groups served by one adapter may have different policies; each action answers with its own group's.
|
|
137
|
+
- A `before` hook refuses before decoding, so a refused request never reaches the policy.
|
|
133
138
|
- Domain errors, defects, interruptions, and protocol errors are not remapped. An unencodable declared error is a defect. A broken policy error is not recursively remapped.
|
|
134
139
|
|
|
135
140
|
## Failure modes
|
|
@@ -137,5 +142,7 @@ export const MathApp = MathActions.implement({ double: ({ value }) => Effect.suc
|
|
|
137
142
|
- Throws at `make`: duplicate action name, duplicate MCP tool name, invalid group name.
|
|
138
143
|
- `Property 'x' is missing in type` at `implement`: the record lacks a handler for action `x`.
|
|
139
144
|
- Handler compiles but returns an error not in `errors`: type error on the handler's error channel. Declare it on the action or the group.
|
|
145
|
+
- `Expected 1 arguments, but got 2` at `implement`: the second options object is gone. Move `before` to the adapter that serves the group.
|
|
140
146
|
- `map` is not typed from `errors` when declared as a standalone constant: annotate its parameter as `HttpApiError.HttpApiSchemaError` (type import from `effect/unstable/httpapi`).
|
|
141
147
|
- A service is resolved once and shared across requests when it should be per request: it was yielded in the builder. Move the `yield*` into the handler.
|
|
148
|
+
- `Duplicate contract group` thrown by `contracts`: two groups share a name, so their keys would collide. Rename one.
|