first commit
This commit is contained in:
133
docs/foundation/flow/rules/src/actions/actions.md
Normal file
133
docs/foundation/flow/rules/src/actions/actions.md
Normal file
@@ -0,0 +1,133 @@
|
||||
# Actions module — `@suaveplan/rules/actions`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
The actions module is the post-evaluation effect surface — named, timeout-bounded, abort-aware handlers that fire after a rule passes (or fails). Callers register handler functions on an `ActionRegistry`, wrap them in `Action` instances with `ActionConfig` (handler name, params, timeout, continue-on-error), and execute them sequentially or in parallel via `ActionExecutor`. The 4 built-in handlers (`log`, `set`, `delay`, `noop`) cover the common cases; the `log` handler takes an injectable `LoggerLike` rather than calling `console.log` directly (CLAUDE.md §2 bans `console.log` in `src/`).
|
||||
|
||||
Every async path threads `AbortSignal` so callers can cancel long-running effects; every failure mode maps to a typed `RulesError` code (`RULES.ACTION_TIMEOUT`, `RULES.ACTION_ABORTED`, `RULES.ACTION_HANDLER_FAILED`, `RULES.ACTION_NOT_FOUND`). The module emits OTel histogram (`rules.action.execute.duration_ms`) and counter (`rules.action.executions_total{status}`) for every action invocation.
|
||||
|
||||
## Features
|
||||
|
||||
- `ActionRegistry` — `register`, `get`, `has`, `names`, `unregister`. Empty names rejected with `RULES.ACTION_HANDLER_FAILED`.
|
||||
- `Action` — wraps an `ActionConfig` + `ActionRegistry`; constructor Zod-validates the config; `execute(context, options?)` returns an `ActionResult` rather than throwing.
|
||||
- `ActionExecutor` — `executeSequential` (respects `continueOnError`, reports later actions as `skipped` on hard stop); `executeParallel` (bounded concurrency via `parallelism`, defaults from constants module).
|
||||
- `builtInHandlers({ logger? })` — factory returning the `{ log, set, delay, noop }` handler bundle bound to a caller-supplied `LoggerLike`.
|
||||
- `validateActionConfig`, `createAction`, `createActionRegistry`, `createActionExecutor` — function factories matching the convention used by every other package.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { context } from "@suaveplan/rules/context";
|
||||
import {
|
||||
builtInHandlers,
|
||||
createAction,
|
||||
createActionExecutor,
|
||||
createActionRegistry,
|
||||
} from "@suaveplan/rules/actions";
|
||||
|
||||
const registry = createActionRegistry();
|
||||
for (const [name, handler] of Object.entries(builtInHandlers())) {
|
||||
registry.register(name, handler);
|
||||
}
|
||||
|
||||
const executor = createActionExecutor();
|
||||
const result = await executor.executeSequential(
|
||||
[createAction({ handler: "log", params: { message: "ran" } }, registry)],
|
||||
context({}),
|
||||
);
|
||||
result[0]?.status; // "success"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { context } from "@suaveplan/rules/context";
|
||||
import {
|
||||
Action,
|
||||
ActionExecutor,
|
||||
ActionRegistry,
|
||||
builtInHandlers,
|
||||
} from "@suaveplan/rules/actions";
|
||||
import type { LoggerLike } from "@suaveplan/types";
|
||||
|
||||
const logger: LoggerLike = {
|
||||
debug: (m, ctx) => process.stderr.write(`debug ${m} ${JSON.stringify(ctx)}\n`),
|
||||
info: (m, ctx) => process.stderr.write(`info ${m} ${JSON.stringify(ctx)}\n`),
|
||||
warn: (m, ctx) => process.stderr.write(`warn ${m} ${JSON.stringify(ctx)}\n`),
|
||||
error: (m, ctx) => process.stderr.write(`error ${m} ${JSON.stringify(ctx)}\n`),
|
||||
};
|
||||
|
||||
const registry = new ActionRegistry();
|
||||
for (const [name, handler] of Object.entries(builtInHandlers({ logger }))) {
|
||||
registry.register(name, handler);
|
||||
}
|
||||
registry.register("fetch", async (params, _ctx, { signal }) => {
|
||||
const response = await fetch(params.url as string, { signal });
|
||||
return response.json();
|
||||
});
|
||||
|
||||
const executor = new ActionExecutor({ parallelism: 4 });
|
||||
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => controller.abort(), 500); // hard deadline
|
||||
|
||||
const results = await executor.executeParallel(
|
||||
[
|
||||
new Action({ handler: "fetch", params: { url: "/api/a" }, timeoutMs: 200 }, registry),
|
||||
new Action({ handler: "fetch", params: { url: "/api/b" }, timeoutMs: 200 }, registry),
|
||||
new Action({ handler: "log", params: { message: "done" } }, registry),
|
||||
],
|
||||
context({}),
|
||||
{ signal: controller.signal, parallelism: 2 },
|
||||
);
|
||||
|
||||
for (const r of results) {
|
||||
if (r.status === "failed") console.warn(r.handler, r.errorCode, r.errorMessage);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class ActionRegistry`
|
||||
|
||||
Standard register/get/has/names/unregister surface. Empty handler name raises `RULES.ACTION_HANDLER_FAILED`.
|
||||
|
||||
### `class Action`
|
||||
|
||||
- `new Action(config, registry)` — Zod-validates `config`; raises `RULES.ACTION_HANDLER_FAILED` on rejection.
|
||||
- `execute(context, options?)` → `Promise<ActionResult>`. Never throws — every failure becomes an `ActionResult { status: "failed", errorCode, errorMessage }`.
|
||||
- Pre-aborted signal → `errorCode: "RULES.ACTION_ABORTED"`.
|
||||
- Timeout (config.timeoutMs or `DEFAULT_ACTION_TIMEOUT_MS`) → `errorCode: "RULES.ACTION_TIMEOUT"`.
|
||||
- Caller aborts mid-flight → `errorCode: "RULES.ACTION_ABORTED"`.
|
||||
- Handler throws → `errorCode: "RULES.ACTION_HANDLER_FAILED"`, original message preserved.
|
||||
|
||||
### `class ActionExecutor`
|
||||
|
||||
- `new ActionExecutor(defaults?)` — `defaults.clock` is used for duration measurement; `defaults.parallelism` (default `DEFAULT_ACTION_PARALLELISM = 8`) is the executeParallel ceiling when not overridden.
|
||||
- `executeSequential(actions, context, options?)` → `Promise<readonly ActionResult[]>`. Stops on first failure unless `action.config.continueOnError === true`; remaining actions return `status: "skipped"` with `durationMs: 0`.
|
||||
- `executeParallel(actions, context, options?)` → `Promise<readonly ActionResult[]>`. Bounded by `options.parallelism ?? defaults.parallelism ?? DEFAULT_ACTION_PARALLELISM`. `parallelism <= 0` raises `RULES.ACTION_HANDLER_FAILED`.
|
||||
|
||||
### `function builtInHandlers(deps?)`
|
||||
|
||||
Returns the `{ log, set, delay, noop }` handler bundle.
|
||||
|
||||
- `log(params, context, { signal })` — `params.level` (one of `debug` / `info` / `warn` / `error`, default `info`), `params.message` (default `"log"`). Calls `deps.logger[level](message)`; no-op fallback when `deps.logger` is omitted.
|
||||
- `set(params, context, { signal })` — writes `params.value` onto `context.data[params.target]`. Missing or empty `params.target` raises `RULES.ACTION_HANDLER_FAILED`.
|
||||
- `delay(params, context, { signal })` — `params.ms` milliseconds (default 0). Aborts cleanly with `RULES.ACTION_ABORTED` when the signal fires.
|
||||
- `noop` — always returns `undefined`.
|
||||
|
||||
### Factory aliases
|
||||
|
||||
`createActionRegistry()`, `createAction(config, registry)`, `createActionExecutor(defaults?)`, `validateActionConfig(config)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `Action.execute` uses a local `AbortController` linked to the caller signal AND a `setTimeout`-driven timeout abort. The race is implemented as `Promise.race([handlerPromise, abortPromise])`; the abort promise discriminates timeout vs caller abort by inspecting the caller's signal state at fire time.
|
||||
- The signal listener is registered with `{ once: true }` and the `finally` clears the timeout and removes the listener, so a long-lived caller signal does not accumulate listeners across many actions.
|
||||
- `executeSequential` reports skipped actions deterministically: every action after a hard-stop failure returns `{ status: "skipped", handler, durationMs: 0 }` in the result array, preserving 1:1 correspondence with the input.
|
||||
- `executeParallel` uses a shared cursor with `Math.min(limit, actions.length)` worker promises; each worker pulls the next index until the cursor exhausts. This is the simplest bounded-concurrency pattern that works under `AbortSignal` without an external semaphore library.
|
||||
- OTel emission is best-effort: a global no-op meter is used when no SDK is installed, so handlers in `vitest run` without a `MeterProvider` still execute correctly.
|
||||
- The `delay` handler is intentionally simple — it does not return a `Promise.race` with a `signal.reason` because callers can read `signal.reason` themselves; surfacing it inside the handler would couple `RulesError` shape to `DOMException` shape.
|
||||
Reference in New Issue
Block a user