174 lines
4.9 KiB
Markdown
174 lines
4.9 KiB
Markdown
# Registry
|
|
|
|
The `OperationRegistry` is the central store for operation specs and handlers. It handles registration, validation, access control enforcement, and execution.
|
|
|
|
## Operation Types
|
|
|
|
```ts
|
|
enum OperationType {
|
|
QUERY = "query",
|
|
MUTATION = "mutation",
|
|
SUBSCRIPTION = "subscription",
|
|
}
|
|
```
|
|
|
|
- **Query** — read-only, no side effects
|
|
- **Mutation** — writes state, side effects
|
|
- **Subscription** — streams results over time (async generator handler)
|
|
|
|
## Defining an Operation
|
|
|
|
Every operation has a **spec** (serializable metadata) and optionally a **handler** (the function that runs).
|
|
|
|
```ts
|
|
import { Type } from "@alkdev/typebox";
|
|
import { OperationType } from "@alkdev/operations";
|
|
|
|
const createTask = {
|
|
name: "create",
|
|
namespace: "task",
|
|
version: "1.0.0",
|
|
type: OperationType.MUTATION,
|
|
description: "Create a new task",
|
|
inputSchema: Type.Object({
|
|
title: Type.String(),
|
|
priority: Type.Optional(Type.Union([Type.Literal("low"), Type.Literal("high")])),
|
|
}),
|
|
outputSchema: Type.Object({
|
|
id: Type.String(),
|
|
title: Type.String(),
|
|
}),
|
|
accessControl: {
|
|
requiredScopes: ["task:write"],
|
|
},
|
|
handler: async (input: { title: string; priority?: string }) => {
|
|
return { id: crypto.randomUUID(), title: input.title };
|
|
},
|
|
};
|
|
```
|
|
|
|
### Spec Fields
|
|
|
|
| Field | Type | Required | Description |
|
|
|-------|------|----------|-------------|
|
|
| `name` | `string` | yes | Operation name within its namespace |
|
|
| `namespace` | `string` | yes | Grouping (e.g. `"task"`, `"user"`) |
|
|
| `version` | `string` | yes | Semantic version |
|
|
| `type` | `OperationType` | yes | `query`, `mutation`, or `subscription` |
|
|
| `description` | `string` | yes | Human-readable description |
|
|
| `inputSchema` | `TSchema` | yes | TypeBox schema for input validation |
|
|
| `outputSchema` | `TSchema` | yes | TypeBox schema for output; use `Type.Unknown()` if untyped |
|
|
| `accessControl` | `AccessControl` | yes | Scopes and resource requirements (see [Access Control](access-control.md)) |
|
|
| `title` | `string` | no | Human-readable title |
|
|
| `tags` | `string[]` | no | Tags for filtering/grouping |
|
|
| `errorSchemas` | `ErrorDefinition[]` | no | Declared error codes and their schemas |
|
|
| `_meta` | `Record<string, unknown>` | no | Arbitrary metadata |
|
|
|
|
### Handler Signature
|
|
|
|
**Query/Mutation** handlers return a value (or `ResponseEnvelope`):
|
|
|
|
```ts
|
|
type OperationHandler<TInput, TOutput> = (
|
|
input: TInput,
|
|
context: OperationContext,
|
|
) => Promise<TOutput> | TOutput;
|
|
```
|
|
|
|
**Subscription** handlers must be async generators:
|
|
|
|
```ts
|
|
type SubscriptionHandler<TInput, TOutput> = (
|
|
input: TInput,
|
|
context: OperationContext,
|
|
) => AsyncGenerator<TOutput, void, unknown>;
|
|
```
|
|
|
|
## Registering Operations
|
|
|
|
### Combined registration
|
|
|
|
```ts
|
|
const registry = new OperationRegistry();
|
|
registry.register(createTask);
|
|
```
|
|
|
|
`register()` stores both the spec and the handler. The `handler` field is optional — you can register the spec first and add the handler later.
|
|
|
|
### Batch registration
|
|
|
|
```ts
|
|
registry.registerAll([createTask, listTasks, deleteTask]);
|
|
```
|
|
|
|
### Separate spec and handler
|
|
|
|
```ts
|
|
registry.registerSpec(mySpec);
|
|
registry.registerHandler("task.create", myHandler);
|
|
```
|
|
|
|
This is useful when specs come from one source (e.g., OpenAPI import) and handlers from another.
|
|
|
|
> `registerHandler` throws if no spec exists for the operation ID.
|
|
|
|
## Executing Operations
|
|
|
|
```ts
|
|
const envelope = await registry.execute(
|
|
"task.create",
|
|
{ title: "Ship it" },
|
|
{ identity: { id: "user-1", scopes: ["task:write"] } },
|
|
);
|
|
```
|
|
|
|
What `execute()` does:
|
|
|
|
1. Looks up the spec by `namespace.name`
|
|
2. Looks up the handler
|
|
3. **Enforces access control** (unless `context.trusted` is `true`)
|
|
4. **Validates input** against the spec's `inputSchema`
|
|
5. Runs the handler
|
|
6. **Wraps the result** in a `ResponseEnvelope` if not already one
|
|
7. **Casts the output** through `outputSchema` and warns on validation errors
|
|
|
|
Returns `ResponseEnvelope<TOutput>`. See [Response Envelopes](response-envelopes.md).
|
|
|
|
### Execution context
|
|
|
|
```ts
|
|
interface OperationContext {
|
|
requestId?: string;
|
|
parentRequestId?: string;
|
|
identity?: Identity;
|
|
trusted?: boolean; // set by buildEnv, not by callers
|
|
metadata?: Record<string, unknown>;
|
|
env?: OperationEnv; // injected by buildEnv for inter-op calls
|
|
}
|
|
```
|
|
|
|
## Querying the Registry
|
|
|
|
```ts
|
|
registry.get("task.create"); // spec + handler, or undefined
|
|
registry.getSpec("task.create"); // spec only
|
|
registry.getHandler("task.create"); // handler only
|
|
registry.getByName("task", "create"); // same as get("task.create")
|
|
registry.list(); // all specs + handlers
|
|
registry.getAllSpecs(); // all specs only
|
|
```
|
|
|
|
## Schema Adapters
|
|
|
|
By default, the registry expects TypeBox schemas. If you use Zod or Valibot, pass a schema adapter:
|
|
|
|
```ts
|
|
import { zodAdapter } from "@alkdev/operations/from-typemap";
|
|
|
|
const adapter = zodAdapter();
|
|
await adapter.init();
|
|
|
|
const registry = new OperationRegistry({ schemaAdapter: adapter });
|
|
```
|
|
|
|
See [Adapters](adapters.md) for details. |