@gjermundgaraba/effect-actions 0.3.0 → 0.4.1
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 +4 -4
- package/dist/Action.d.mts +9 -9
- package/dist/Action.mjs +2 -1
- package/dist/ActionCatalog.d.mts +1 -3
- package/dist/ActionCatalog.mjs +0 -1
- package/dist/ActionCli.d.mts +16 -7
- package/dist/ActionCli.mjs +4 -3
- package/dist/ActionCliClient.d.mts +1 -1
- package/dist/ActionCliClient.mjs +1 -1
- package/dist/ActionGroup.d.mts +18 -16
- package/dist/ActionGroup.mjs +7 -4
- package/dist/ActionHttp.d.mts +19 -7
- package/dist/ActionHttp.mjs +37 -14
- package/dist/ActionMcp.d.mts +24 -7
- package/dist/ActionMcp.mjs +13 -5
- package/dist/ActionToolkit.d.mts +27 -12
- package/dist/ActionToolkit.mjs +7 -2
- package/dist/Testing.mjs +1 -1
- package/dist/internal/actions.mjs +7 -1
- package/dist/internal/implementation.d.mts +7 -16
- package/dist/internal/implementation.mjs +8 -14
- package/dist/internal/tools.mjs +13 -11
- package/docs/Action.md +12 -13
- package/docs/ActionCatalog.md +2 -3
- package/docs/ActionCli.md +26 -18
- package/docs/ActionGroup.md +29 -50
- package/docs/ActionHttp.md +27 -14
- package/docs/ActionMcp.md +25 -12
- package/docs/ActionToolkit.md +23 -9
- package/docs/Authentication.md +1 -1
- package/docs/CONTEXT.md +5 -4
- package/docs/README.md +3 -3
- package/docs/Testing.md +1 -1
- package/docs/guarantees.md +11 -6
- package/docs/setup.md +1 -1
- package/package.json +1 -1
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 };
|
|
@@ -9,40 +9,31 @@ type ErasedHandler<R> = {
|
|
|
9
9
|
}["handle"];
|
|
10
10
|
/** An adapter's erased view of a record of handlers with request requirements `R`. */
|
|
11
11
|
export type Handlers<R> = Readonly<Record<string, ErasedHandler<R>>>;
|
|
12
|
-
/**
|
|
13
|
-
* An adapter's erased view of the pre-handler hook. It sees the selected action
|
|
14
|
-
* contract, so a group-wide policy reads `access` rather than the action name.
|
|
15
|
-
*/
|
|
16
|
-
export type Before<R> = (action: Any) => Effect.Effect<void, ErasedValue, R>;
|
|
17
12
|
/** Per-request requirements of the handler selected by an action name. */
|
|
18
13
|
export type HandlerContext<H, Name extends keyof H> = H[Name] extends ((input: never) => Effect.Effect<infer _A, infer _E, infer R>) ? R : never;
|
|
19
14
|
/** The union of per-request requirements declared by a handler record. */
|
|
20
15
|
export type HandlersContext<H> = { readonly [K in keyof H]: HandlerContext<H, K>; }[keyof H];
|
|
21
16
|
/**
|
|
22
|
-
* An acquired handler record for a group
|
|
17
|
+
* An acquired handler record for a group.
|
|
23
18
|
*
|
|
24
19
|
* The private field makes this class nominal: a structurally similar object,
|
|
25
20
|
* including one made by spreading an implementation, is not an implementation.
|
|
26
21
|
*/
|
|
27
|
-
export declare class Implementation<G extends Actions, H, EX, RX
|
|
22
|
+
export declare class Implementation<G extends Actions, H, EX, RX> {
|
|
28
23
|
#private;
|
|
29
24
|
/** The group whose exact handler record was built. */
|
|
30
25
|
readonly group: G;
|
|
31
26
|
private constructor();
|
|
32
27
|
/** Acquire the exact handler record in the adapter layer's scope. */
|
|
33
28
|
get build(): Effect.Effect<H, EX, RX | Scope.Scope>;
|
|
34
|
-
|
|
35
|
-
get before(): Before<RB> | undefined;
|
|
36
|
-
static make<G extends Actions, H, EX, RX, RB>(group: G, build: Effect.Effect<H, EX, RX | Scope.Scope>, before: Before<RB> | undefined): Implementation<G, H, EX, RX, RB>;
|
|
29
|
+
static make<G extends Actions, H, EX, RX>(group: G, build: Effect.Effect<H, EX, RX | Scope.Scope>): Implementation<G, H, EX, RX>;
|
|
37
30
|
}
|
|
38
31
|
/** Any nominal implementation of `G`, with its record and channels erased. */
|
|
39
|
-
export type AnyImplementation<G extends Actions = Actions> = Implementation<G, any, any, any
|
|
40
|
-
/** Pre-handler requirements of one implementation, or of a union of them. */
|
|
41
|
-
export type BeforeContext<App> = App extends Implementation<any, any, any, any, infer RB> ? RB : never;
|
|
32
|
+
export type AnyImplementation<G extends Actions = Actions> = Implementation<G, any, any, any>;
|
|
42
33
|
/** Per-request requirements of one implementation, or of a union of them. */
|
|
43
|
-
export type RequestContext<App> = App extends Implementation<any, infer H, any, any
|
|
34
|
+
export type RequestContext<App> = App extends Implementation<any, infer H, any, any> ? HandlersContext<H> : never;
|
|
44
35
|
/** Handler-acquisition failures of one implementation, or of a union of them. */
|
|
45
|
-
export type BuildError<App> = App extends Implementation<any, any, infer EX, any
|
|
36
|
+
export type BuildError<App> = App extends Implementation<any, any, infer EX, any> ? EX : never;
|
|
46
37
|
/** Handler-acquisition requirements of one implementation, or of a union of them. */
|
|
47
|
-
export type BuildContext<App> = App extends Implementation<any, any, any, infer RX
|
|
38
|
+
export type BuildContext<App> = App extends Implementation<any, any, any, infer RX> ? RX : never;
|
|
48
39
|
//#endregion
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Effect } from "effect";
|
|
2
2
|
//#region src/internal/implementation.ts
|
|
3
3
|
/**
|
|
4
|
-
* An acquired handler record for a group
|
|
4
|
+
* An acquired handler record for a group.
|
|
5
5
|
*
|
|
6
6
|
* The private field makes this class nominal: a structurally similar object,
|
|
7
7
|
* including one made by spreading an implementation, is not an implementation.
|
|
@@ -9,28 +9,22 @@ import { Effect } from "effect";
|
|
|
9
9
|
var Implementation = class Implementation {
|
|
10
10
|
group;
|
|
11
11
|
#build;
|
|
12
|
-
|
|
13
|
-
constructor(group, build, before) {
|
|
12
|
+
constructor(group, build) {
|
|
14
13
|
this.group = group;
|
|
15
14
|
this.#build = build;
|
|
16
|
-
this.#before = before;
|
|
17
15
|
}
|
|
18
16
|
/** Acquire the exact handler record in the adapter layer's scope. */
|
|
19
17
|
get build() {
|
|
20
18
|
return this.#build;
|
|
21
19
|
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
return this.#before;
|
|
25
|
-
}
|
|
26
|
-
static make(group, build, before) {
|
|
27
|
-
return new Implementation(group, build, before);
|
|
20
|
+
static make(group, build) {
|
|
21
|
+
return new Implementation(group, build);
|
|
28
22
|
}
|
|
29
23
|
};
|
|
30
24
|
/**
|
|
31
25
|
* Select and invoke a handler after an adapter has selected a concrete action.
|
|
32
|
-
* The pre-handler hook runs first,
|
|
33
|
-
*
|
|
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.
|
|
34
28
|
* `R` remains in the returned effect so transport layers cannot erase required
|
|
35
29
|
* per-request services while assembling routes.
|
|
36
30
|
*/
|
|
@@ -38,8 +32,8 @@ const dispatch = (group, action, handlers, before) => {
|
|
|
38
32
|
const handle = handlers[action.name];
|
|
39
33
|
if (handle === void 0) throw new Error(`Missing handler: ${action.name}`);
|
|
40
34
|
return (input) => {
|
|
41
|
-
const
|
|
42
|
-
return Effect.
|
|
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);
|
|
43
37
|
};
|
|
44
38
|
};
|
|
45
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,
|
|
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,20 +11,20 @@ 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
20
|
type Access = "read" | "write";
|
|
21
21
|
|
|
22
|
-
interface Options<Input, Output, Errors, Http, Mcp> {
|
|
22
|
+
interface Options<Input, Output, Errors, Acc, Http, Mcp> {
|
|
23
23
|
readonly description: string;
|
|
24
24
|
readonly input?: Input; // omit for an action without arguments
|
|
25
25
|
readonly success: Output;
|
|
26
26
|
readonly errors?: Errors; // ReadonlyArray<Codec>; defaults to []
|
|
27
|
-
readonly access
|
|
27
|
+
readonly access: Acc; // required, no default
|
|
28
28
|
readonly http?: Http; // false hides the action from HTTP routes and clients
|
|
29
29
|
readonly mcp?: Mcp; // false hides the action from MCP; otherwise McpOptions
|
|
30
30
|
}
|
|
@@ -37,13 +37,13 @@ interface McpOptions {
|
|
|
37
37
|
readonly openWorld?: boolean; // openWorldHint, default true
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
interface Action<Name, Input, Output, Errors, Http, Mcp> {
|
|
40
|
+
interface Action<Name, Input, Output, Errors, Acc, Http, Mcp> {
|
|
41
41
|
readonly name: Name;
|
|
42
42
|
readonly description: string;
|
|
43
43
|
readonly input: Input;
|
|
44
44
|
readonly success: Output;
|
|
45
45
|
readonly errors: Errors;
|
|
46
|
-
readonly access:
|
|
46
|
+
readonly access: Acc; // the literal it was declared with
|
|
47
47
|
readonly http: Http;
|
|
48
48
|
readonly mcp: false | { name; readOnly; destructive; idempotent; openWorld }; // resolved
|
|
49
49
|
}
|
|
@@ -68,7 +68,7 @@ export class UserNotFound extends Schema.TaggedError<UserNotFound>()(
|
|
|
68
68
|
{ httpApiStatus: 404 },
|
|
69
69
|
) {}
|
|
70
70
|
|
|
71
|
-
// `access: "read"` states the fact once: it is what
|
|
71
|
+
// `access: "read"` states the fact once: it is what a surface's `before` hook
|
|
72
72
|
// authorizes on, and it is where `mcp.readOnly` comes from.
|
|
73
73
|
export const GetUser = Action.make("getUser", {
|
|
74
74
|
description: "Look up a user in your tenant.",
|
|
@@ -79,8 +79,7 @@ export const GetUser = Action.make("getUser", {
|
|
|
79
79
|
mcp: { name: "get_user" },
|
|
80
80
|
});
|
|
81
81
|
|
|
82
|
-
// A write.
|
|
83
|
-
// not a statement, and an unclassified action is the one a reviewer must check.
|
|
82
|
+
// A write. Every action says which it is; there is no default to fall back on.
|
|
84
83
|
export const RenameUser = Action.make("renameUser", {
|
|
85
84
|
description: "Rename a user in your tenant.",
|
|
86
85
|
input: Schema.Struct({ id: Schema.String, name: Schema.String }),
|
|
@@ -121,7 +120,7 @@ export const Double = Action.make("double", {
|
|
|
121
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: {} }`.
|
|
122
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)).
|
|
123
122
|
- A handler may fail only with the declared errors. Anything else is a defect.
|
|
124
|
-
- `access` is `"read"` or `"write"` and
|
|
123
|
+
- `access` is `"read"` or `"write"` and is required. `make` also checks it at runtime, so a caller the compiler never sees cannot define an action no rule classifies. 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.
|
|
125
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.
|
|
126
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.
|
|
127
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.
|
|
@@ -133,9 +132,9 @@ export const Double = Action.make("double", {
|
|
|
133
132
|
|
|
134
133
|
## Failure modes
|
|
135
134
|
|
|
136
|
-
- Throws at `make`: invalid name, name `then`, `mcp.name` longer than 128 characters or not matching the pattern.
|
|
135
|
+
- Throws at `make`: invalid name, name `then`, an `access` that is neither `"read"` nor `"write"`, `mcp.name` longer than 128 characters or not matching the pattern.
|
|
137
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.
|
|
138
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`.
|
|
139
138
|
- Handler receives a string where a number was expected: the schema is `Schema.String`, not a transforming codec such as `Schema.FiniteFromString`.
|
|
140
|
-
-
|
|
139
|
+
- `Property 'access' is missing` at `make`: every action declares `"read"` or `"write"`. There is no default.
|
|
141
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
|
@@ -20,7 +20,6 @@ interface Entry {
|
|
|
20
20
|
readonly group: string;
|
|
21
21
|
readonly name: string;
|
|
22
22
|
readonly description: string;
|
|
23
|
-
readonly access: "read" | "write"; // resolved; "write" unless the contract says otherwise
|
|
24
23
|
readonly http: boolean;
|
|
25
24
|
readonly mcp: false | { name; readOnly; destructive; idempotent; openWorld };
|
|
26
25
|
readonly input: JsonSchema.JsonSchema;
|
|
@@ -47,8 +46,8 @@ process.stdout.write(JSON.stringify(catalog, null, 2));
|
|
|
47
46
|
- `id` is the stable `<group>.<action>` identity, equal to the HTTP operation ID.
|
|
48
47
|
- Schemas describe encoded action values: the JSON on the wire, not MCP's `{ value }` envelope and not deployment URLs.
|
|
49
48
|
- Each schema owns its `$defs`. Equal schema identifiers in different entries never replace each other. Recursive references stay local to their schema.
|
|
50
|
-
- `errors` includes group-level errors. HTTP schema-policy errors are listed separately in `httpSchemaErrors`.
|
|
51
|
-
- `access` is the
|
|
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.
|
|
52
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.
|
|
53
52
|
- The package adds no search, no catalog HTTP endpoint, and no TypeScript code generation.
|
|
54
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, RB, Parameters = never>(
|
|
13
|
-
app: Implementation<G, H, EX, RX
|
|
12
|
+
const command: <G, Name extends G["actions"][number]["name"], H, EX, RX, EB, RB, Parameters = never>(
|
|
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 | RB | 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, RB>(
|
|
20
|
-
app: Implementation<G, H, EX, RX
|
|
21
|
-
options?: GroupOptions,
|
|
22
|
-
) => Command.Command<string, {}, {}, EX | SchemaError | DeclaredErrors, ...>;
|
|
19
|
+
const group: <G, H, EX, RX, EB, RB>(
|
|
20
|
+
app: Implementation<G, H, EX, RX>,
|
|
21
|
+
options?: GroupOptions<EB, RB>,
|
|
22
|
+
) => Command.Command<string, {}, {}, EX | EB | SchemaError | DeclaredErrors, ...>;
|
|
23
|
+
|
|
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
|
+
}
|
|
23
28
|
|
|
24
|
-
interface GroupOptions {
|
|
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,24 +54,26 @@ 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";
|
|
52
|
-
import { actors, CurrentActor } from "./auth.js";
|
|
57
|
+
import { actors, authorize, CurrentActor } from "./auth.js";
|
|
53
58
|
import { UserApp } from "./handlers.js";
|
|
54
59
|
import { Users } from "./users.js";
|
|
55
60
|
|
|
56
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.
|
|
57
63
|
const command = ActionCli.command(UserApp, "double", {
|
|
58
64
|
parameters: { value: Flag.String("value") },
|
|
59
65
|
input: ({ value }) => ({ value }),
|
|
66
|
+
before: authorize,
|
|
60
67
|
});
|
|
61
68
|
|
|
62
69
|
// Default syntax for a whole group: `users double --input '{"value":"21"}'`.
|
|
63
|
-
const all = ActionCli.group(UserApp);
|
|
70
|
+
const all = ActionCli.group(UserApp, { before: authorize });
|
|
64
71
|
|
|
65
72
|
Command.runWith(command, { version: "0.1.0" })(process.argv.slice(2)).pipe(
|
|
66
73
|
Effect.tapCause((cause) => Console.error(cause)),
|
|
67
74
|
Effect.provideService(Logger.LogToStderr, true),
|
|
68
|
-
// The
|
|
69
|
-
//
|
|
75
|
+
// The hook runs here too, so a local caller supplies the identity it reads
|
|
76
|
+
// exactly as HTTP middleware does for a request.
|
|
70
77
|
Effect.provideService(CurrentActor, actors.alice),
|
|
71
78
|
Effect.provide(Users.layerMemory),
|
|
72
79
|
Effect.provide(NodeServices.layer),
|
|
@@ -84,7 +91,8 @@ Command.runWith(command, { version: "0.1.0" })(process.argv.slice(2)).pipe(
|
|
|
84
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.
|
|
85
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.
|
|
86
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.
|
|
87
|
-
-
|
|
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.
|
|
88
96
|
- Group commands use the default JSON syntax. For a custom tree, compose individual `command` results with native `Command` combinators.
|
|
89
97
|
|
|
90
98
|
## Failure modes
|
|
@@ -93,5 +101,5 @@ Command.runWith(command, { version: "0.1.0" })(process.argv.slice(2)).pipe(
|
|
|
93
101
|
- Command rejects `--input`: the command was built with `parameters`. Use its flags.
|
|
94
102
|
- Type error: `name` is not an action of the group. Check `group.actions` names, not MCP tool names.
|
|
95
103
|
- Handler cannot find a service: provide its Layer to the runtime (`Effect.provide`) before `runMain`. The command does not supply services.
|
|
96
|
-
- A command requires a request-identity tag no handler yields: the
|
|
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.
|
|
97
105
|
- `--json` flag conflict at definition: rename the native flag; the config property can keep its name.
|
package/docs/ActionGroup.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# ActionGroup
|
|
2
2
|
|
|
3
3
|
A named set of actions. The group is what adapters serve, what clients are grouped by, and
|
|
4
|
-
what `implement` binds handlers to. Shared errors
|
|
5
|
-
|
|
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)).
|
|
6
6
|
|
|
7
7
|
## API
|
|
8
8
|
|
|
@@ -12,7 +12,7 @@ import * as ActionGroup from "@gjermundgaraba/effect-actions/ActionGroup";
|
|
|
12
12
|
function make<Name, Actions, Errors = [], PolicyErrors = []>(
|
|
13
13
|
options: Options<Name, Errors, PolicyErrors>,
|
|
14
14
|
...actions: Actions
|
|
15
|
-
): Group<Name, WithErrors<Actions, Errors>, PolicyErrors
|
|
15
|
+
): Group<Name, WithErrors<Actions, Errors>, PolicyErrors>;
|
|
16
16
|
|
|
17
17
|
interface Options<Name, Errors, PolicyErrors> {
|
|
18
18
|
readonly name: Name; // HttpApiGroup identifier, OpenAPI tag, operation-ID prefix
|
|
@@ -25,34 +25,28 @@ interface SchemaErrorPolicy<Errors extends ReadonlyArray<Codec>> {
|
|
|
25
25
|
readonly map: (failure: HttpApiError.HttpApiSchemaError) => Errors[number]["Type"];
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
interface
|
|
29
|
-
/** Runs before every handler, on every surface. May fail only with the group's `errors`. */
|
|
30
|
-
readonly before: (action: Action.Any) => Effect.Effect<void, Errors[number]["Type"], RB>;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
interface Group<Name, Actions, PolicyErrors, Errors> {
|
|
28
|
+
interface Group<Name, Actions, PolicyErrors> {
|
|
34
29
|
readonly name: Name;
|
|
35
30
|
readonly actions: Actions;
|
|
36
31
|
readonly schemaError: SchemaErrorPolicy<PolicyErrors> | undefined;
|
|
37
32
|
/** Bind every handler at once; build-time services resolve once per adapter layer. */
|
|
38
|
-
readonly implement: <H extends HandlersFrom<Actions>, EX = never, RX = never
|
|
33
|
+
readonly implement: <H extends HandlersFrom<Actions>, EX = never, RX = never>(
|
|
39
34
|
build: H | Effect.Effect<H, EX, RX>,
|
|
40
|
-
|
|
41
|
-
) => Implementation<
|
|
42
|
-
Group<Name, Actions, PolicyErrors, Errors>,
|
|
43
|
-
H,
|
|
44
|
-
EX,
|
|
45
|
-
Exclude<RX, Scope.Scope>,
|
|
46
|
-
RB
|
|
47
|
-
>;
|
|
35
|
+
) => Implementation<Group<Name, Actions, PolicyErrors>, H, EX, Exclude<RX, Scope.Scope>>;
|
|
48
36
|
}
|
|
49
37
|
|
|
50
38
|
/** Nominal. `group` is the bound contract; `build` acquires the handler record in a scope. */
|
|
51
|
-
class Implementation<G, H, EX, RX
|
|
39
|
+
class Implementation<G, H, EX, RX> {
|
|
52
40
|
readonly group: G;
|
|
53
41
|
get build(): Effect.Effect<H, EX, RX | Scope.Scope>;
|
|
54
|
-
get before(): ((action: Action.Any) => Effect.Effect<void, unknown, RB>) | undefined;
|
|
55
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": ... }
|
|
56
50
|
```
|
|
57
51
|
|
|
58
52
|
`HandlersFrom<Actions>` is `{ [action name]: Handler<Action, R> }`, one key per action,
|
|
@@ -64,8 +58,7 @@ all required.
|
|
|
64
58
|
import { Effect, Schema } from "effect";
|
|
65
59
|
import type { HttpApiError } from "effect/unstable/httpapi";
|
|
66
60
|
import * as ActionGroup from "@gjermundgaraba/effect-actions/ActionGroup";
|
|
67
|
-
import
|
|
68
|
-
import { Forbidden, CurrentActor } from "./auth.js";
|
|
61
|
+
import { CurrentActor } from "./auth.js";
|
|
69
62
|
import { GetUser, RenameUser, WhoAmI } from "./contracts.js";
|
|
70
63
|
import { Users } from "./users.js";
|
|
71
64
|
|
|
@@ -89,25 +82,16 @@ const schemaError = {
|
|
|
89
82
|
: new InvalidRequest({ message: "The request does not match the action's input." }),
|
|
90
83
|
};
|
|
91
84
|
|
|
92
|
-
// `Forbidden` is declared once and joins every action's errors.
|
|
93
85
|
export const UserActions = ActionGroup.make(
|
|
94
|
-
{ name: "users",
|
|
86
|
+
{ name: "users", schemaError },
|
|
95
87
|
GetUser,
|
|
96
88
|
RenameUser,
|
|
97
89
|
WhoAmI,
|
|
98
90
|
);
|
|
99
91
|
|
|
100
|
-
// One rule for the whole group, read from each contract's own `access`. It runs
|
|
101
|
-
// on HTTP, MCP, Toolkit and CLI alike, so no handler contains authorization code.
|
|
102
|
-
const authorize = Effect.fn("authorize")(function* (action: Action.Any) {
|
|
103
|
-
const permission = action.access === "read" ? "users:read" : "users:write";
|
|
104
|
-
const actor = yield* CurrentActor;
|
|
105
|
-
|
|
106
|
-
if (!actor.permissions.includes(permission)) return yield* new Forbidden({ permission });
|
|
107
|
-
});
|
|
108
|
-
|
|
109
92
|
// Build-time services (Users) are yielded in the builder, once per adapter layer.
|
|
110
|
-
// 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.
|
|
111
95
|
export const UserApp = UserActions.implement(
|
|
112
96
|
Effect.gen(function* () {
|
|
113
97
|
const users = yield* Users;
|
|
@@ -119,31 +103,26 @@ export const UserApp = UserActions.implement(
|
|
|
119
103
|
whoAmI: () => Effect.map(CurrentActor, ({ id, tenantId }) => ({ id, tenantId })),
|
|
120
104
|
};
|
|
121
105
|
}),
|
|
122
|
-
{ before: authorize },
|
|
123
106
|
);
|
|
124
107
|
|
|
125
108
|
// No build-time services: pass the record directly.
|
|
126
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;
|
|
127
114
|
```
|
|
128
115
|
|
|
129
116
|
## Rules
|
|
130
117
|
|
|
131
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`.
|
|
132
|
-
- Group `errors` are appended to each action's own `errors`, so every handler of the group may fail with them.
|
|
133
|
-
- `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.
|
|
134
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.
|
|
135
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)).
|
|
136
123
|
- `Implementation` is nominal. Spreading its properties does not produce an implementation. Each `implement` call is a separate binding.
|
|
137
|
-
- `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.
|
|
138
|
-
|
|
139
|
-
### Pre-handler hook
|
|
140
|
-
|
|
141
|
-
- `implement(handlers, { before })` binds one hook to the whole implementation. It runs once per invocation, immediately before the selected handler, on **every** surface: `ActionHttp`, `ActionMcp.layerHttp`, `ActionMcp.layerStdio`, `ActionToolkit`, and `ActionCli`. There is no surface that skips it and no per-action opt-out.
|
|
142
|
-
- It receives the selected action contract (`Action.Any`), so a policy reads `action.access`, `action.name` or `action.mcp` instead of a hand-maintained list. Write authorization against `access`.
|
|
143
|
-
- It may fail only with the group's own `errors`, because those are the failures every action of the group declares. A hook that fails with anything else is a compile error; a group with no `errors` has no failure a hook could use. Its failure is encoded exactly like a handler's: the error's `httpApiStatus` on HTTP, an `isError` tool result on MCP.
|
|
144
|
-
- Services it yields are request-time requirements, like a handler's, and they join the adapter's request context. A local caller (`ActionCli`, `ActionToolkit`, stdio) supplies them itself.
|
|
145
|
-
- It runs inside the `<group>.<action>` span, before the handler. When it fails, the handler never runs.
|
|
146
|
-
- It is not authentication. Establish identity in middleware, then let the hook authorize what that identity may do.
|
|
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.
|
|
147
126
|
|
|
148
127
|
### Schema-error policy
|
|
149
128
|
|
|
@@ -155,6 +134,7 @@ export const MathApp = MathActions.implement({ double: ({ value }) => Effect.suc
|
|
|
155
134
|
- The policy runs only on the server. Client-side codec failures stay `SchemaError`.
|
|
156
135
|
- HTTP decodes with `errors: "all"`, so `cause` carries every issue. Issues never retain the rejected values.
|
|
157
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.
|
|
158
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.
|
|
159
139
|
|
|
160
140
|
## Failure modes
|
|
@@ -162,8 +142,7 @@ export const MathApp = MathActions.implement({ double: ({ value }) => Effect.suc
|
|
|
162
142
|
- Throws at `make`: duplicate action name, duplicate MCP tool name, invalid group name.
|
|
163
143
|
- `Property 'x' is missing in type` at `implement`: the record lacks a handler for action `x`.
|
|
164
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.
|
|
165
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`).
|
|
166
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.
|
|
167
|
-
-
|
|
168
|
-
- `Type 'X' is not assignable to type 'never'` on `before` in a group without `errors`: the group declares no shared failure. Declare one.
|
|
169
|
-
- The hook does not run for an action: it cannot happen through an adapter. A handler invoked directly through `build` is not dispatched and is not hooked; use an adapter, or `ActionCli`, in tests that must exercise the policy.
|
|
148
|
+
- `Duplicate contract group` thrown by `contracts`: two groups share a name, so their keys would collide. Rename one.
|