7.8 KiB
Actions module — @suaveplan/rules/actions
(no submodules)
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 withRULES.ACTION_HANDLER_FAILED.Action— wraps anActionConfig+ActionRegistry; constructor Zod-validates the config;execute(context, options?)returns anActionResultrather than throwing.ActionExecutor—executeSequential(respectscontinueOnError, reports later actions asskippedon hard stop);executeParallel(bounded concurrency viaparallelism, defaults from constants module).builtInHandlers({ logger? })— factory returning the{ log, set, delay, noop }handler bundle bound to a caller-suppliedLoggerLike.validateActionConfig,createAction,createActionRegistry,createActionExecutor— function factories matching the convention used by every other package.
Usage: Basic Example
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
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-validatesconfig; raisesRULES.ACTION_HANDLER_FAILEDon rejection.execute(context, options?)→Promise<ActionResult>. Never throws — every failure becomes anActionResult { 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.
- Pre-aborted signal →
class ActionExecutor
new ActionExecutor(defaults?)—defaults.clockis used for duration measurement;defaults.parallelism(defaultDEFAULT_ACTION_PARALLELISM = 8) is the executeParallel ceiling when not overridden.executeSequential(actions, context, options?)→Promise<readonly ActionResult[]>. Stops on first failure unlessaction.config.continueOnError === true; remaining actions returnstatus: "skipped"withdurationMs: 0.executeParallel(actions, context, options?)→Promise<readonly ActionResult[]>. Bounded byoptions.parallelism ?? defaults.parallelism ?? DEFAULT_ACTION_PARALLELISM.parallelism <= 0raisesRULES.ACTION_HANDLER_FAILED.
function builtInHandlers(deps?)
Returns the { log, set, delay, noop } handler bundle.
log(params, context, { signal })—params.level(one ofdebug/info/warn/error, defaultinfo),params.message(default"log"). Callsdeps.logger[level](message); no-op fallback whendeps.loggeris omitted.set(params, context, { signal })— writesparams.valueontocontext.data[params.target]. Missing or emptyparams.targetraisesRULES.ACTION_HANDLER_FAILED.delay(params, context, { signal })—params.msmilliseconds (default 0). Aborts cleanly withRULES.ACTION_ABORTEDwhen the signal fires.noop— always returnsundefined.
Factory aliases
createActionRegistry(), createAction(config, registry), createActionExecutor(defaults?), validateActionConfig(config).
Implementation Notes
Action.executeuses a localAbortControllerlinked to the caller signal AND asetTimeout-driven timeout abort. The race is implemented asPromise.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 thefinallyclears the timeout and removes the listener, so a long-lived caller signal does not accumulate listeners across many actions. executeSequentialreports 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.executeParalleluses a shared cursor withMath.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 underAbortSignalwithout 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 runwithout aMeterProviderstill execute correctly. - The
delayhandler is intentionally simple — it does not return aPromise.racewith asignal.reasonbecause callers can readsignal.reasonthemselves; surfacing it inside the handler would coupleRulesErrorshape toDOMExceptionshape.