first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View 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.