Move docs/ package mirror into document-navigator plugin
Relocate the ~6k-file docs tree from the repo root into plugins/document-navigator/docs/ so the plugin is self-contained and portable. docs/superpowers/ (specs and plans) stays at the repo root. Moved via cp+rm rather than git mv because directory rename() fails with EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the identical-content files at commit time. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
111
plugins/document-navigator/docs/foundation/flow/rules/README.md
Normal file
111
plugins/document-navigator/docs/foundation/flow/rules/README.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# @suaveplan/rules - Rule engine and validation system — async evaluator, fluent builder, 30 operators, schema/field/custom validators, serializer, optimizer; Zod boundaries, OTel surface, AbortSignal throughout
|
||||
|
||||
## Summary
|
||||
|
||||
Universal rule engine + validation system. Comprehensive `RuleEngine` with async evaluation, depth limit, AbortSignal, plugin registration; fluent `RuleBuilder` + factory functions; 33-operator `OperatorRegistry`; three validator families (`SchemaValidator`, `FieldValidator`, `CustomValidator`); JSON serializer with function-placeholder strategy; AST optimiser (flatten / dedupe / simplify / sort); action subsystem with timeout + abort; typed event bus with priority hooks + bounded history. Zod boundaries at every public surface; OTel spans + histograms + counters from production paths; `RulesError` extends `SuaveplanError` with a 24-code registry.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [actions](./src/actions/actions.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [actions](./src/actions/actions.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [builder](./src/builder/builder.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [builder](./src/builder/builder.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [context](./src/context/context.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [context](./src/context/context.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [engine](./src/engine/engine.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - operators-internal — `compareValues(actual, operato…
|
||||
- [engine](./src/engine/engine.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - operators-internal — `compareValues(actual, operato…
|
||||
- [operators-internal](./src/engine/operators-internal.md) — `compareValues(actual, operator, expected)` is the built-in comparison dispatcher used by `RuleEngine` until the dedica…
|
||||
- [errors](./src/errors/rules-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [rules-error](./src/errors/rules-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [events](./src/events/event-bus.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [event-bus](./src/events/event-bus.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [operators](./src/operators/operators.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [operators](./src/operators/operators.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [optimizer](./src/optimizer/optimizer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [optimizer](./src/optimizer/optimizer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [serializer](./src/serializer/serializer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [serializer](./src/serializer/serializer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [custom](./src/validators/custom/custom.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [custom](./src/validators/custom/custom.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [field](./src/validators/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [field](./src/validators/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [schema](./src/validators/schema/schema.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [schema](./src/validators/schema/schema.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [constants](./src/constants.md) — This module exposes the package-level constants consumed across the rules engine, validators, action executor, and even…
|
||||
<!-- end:auto-docs --> - [actions](./src/actions/actions.md) — <!-- begin:auto-docs --> _(no submodules)_- [builder](./src/builder/builder.md) — <!-- begin:auto-docs --> _(no submodules)_- [builder](./src/builder/builder.md) — <!-- begin:auto-docs --> _(no submodules)_- [context](./src/context/context.md) — <!-- begin:auto-docs --> _(no submodules)_- [context](./src/context/context.md) — <!-- begin:auto-docs --> _(no submodules)_- [engine](./src/engine/engine.md) — <!-- begin:auto-docs --> _(no submodules)_- operators-internal — `compareValues(actual, operato…
|
||||
- [engine](./src/engine/engine.md) — <!-- begin:auto-docs --> _(no submodules)_- operators-internal — `compareValues(actual, operato…
|
||||
- [operators-internal](./src/engine/operators-internal.md) — `compareValues(actual, operator, expected)` is the built-in comparison dispatcher used by `RuleEngine` until the dedica…
|
||||
- [errors](./src/errors/rules-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [rules-error](./src/errors/rules-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [events](./src/events/event-bus.md) — <!-- begin:auto-docs --> _(no submodules)_- [event-bus](./src/events/event-bus.md) — <!-- begin:auto-docs --> _(no submodules)_- [operators](./src/operators/operators.md) — <!-- begin:auto-docs --> _(no submodules)_- [operators](./src/operators/operators.md) — <!-- begin:auto-docs --> _(no submodules)_- [optimizer](./src/optimizer/optimizer.md) — <!-- begin:auto-docs --> _(no submodules)_- [optimizer](./src/optimizer/optimizer.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [serializer](./src/serializer/serializer.md) — <!-- begin:auto-docs --> _(no submodules)_- [serializer](./src/serializer/serializer.md) — <!-- begin:auto-docs --> _(no submodules)_- [custom](./src/validators/custom/custom.md) — <!-- begin:auto-docs --> _(no submodules)_- [custom](./src/validators/custom/custom.md) — <!-- begin:auto-docs --> _(no submodules)_- [field](./src/validators/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_- [field](./src/validators/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_- [schema](./src/validators/schema/schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [schema](./src/validators/schema/schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [constants](./src/constants.md) — This module exposes the package-level constants consumed across the rules engine, validators, action executor, and even…
|
||||
- [actions](./src/actions/actions.md) — <!-- begin:auto-docs --> _(no submodules)_- [builder](./src/builder/builder.md) — <!-- begin:auto-docs --> _(no submodules)_- [builder](./src/builder/builder.md) — <!-- begin:auto-docs --> _(no submodules)_- [context](./src/context/context.md) — <!-- begin:auto-docs --> _(no submodules)_- [context](./src/context/context.md) — <!-- begin:auto-docs --> _(no submodules)_- [engine](./src/engine/engine.md) — <!-- begin:auto-docs --> _(no submodules)_- operators-internal — `compareValues(actual, operato…
|
||||
- [engine](./src/engine/engine.md) — <!-- begin:auto-docs --> _(no submodules)_- operators-internal — `compareValues(actual, operato…
|
||||
- [operators-internal](./src/engine/operators-internal.md) — `compareValues(actual, operator, expected)` is the built-in comparison dispatcher used by `RuleEngine` until the dedica…
|
||||
- [errors](./src/errors/rules-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [rules-error](./src/errors/rules-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [events](./src/events/event-bus.md) — <!-- begin:auto-docs --> _(no submodules)_- [event-bus](./src/events/event-bus.md) — <!-- begin:auto-docs --> _(no submodules)_- [operators](./src/operators/operators.md) — <!-- begin:auto-docs --> _(no submodules)_- [operators](./src/operators/operators.md) — <!-- begin:auto-docs --> _(no submodules)_- [optimizer](./src/optimizer/optimizer.md) — <!-- begin:auto-docs --> _(no submodules)_- [optimizer](./src/optimizer/optimizer.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [serializer](./src/serializer/serializer.md) — <!-- begin:auto-docs --> _(no submodules)_- [serializer](./src/serializer/serializer.md) — <!-- begin:auto-docs --> _(no submodules)_- [custom](./src/validators/custom/custom.md) — <!-- begin:auto-docs --> _(no submodules)_- [custom](./src/validators/custom/custom.md) — <!-- begin:auto-docs --> _(no submodules)_- [field](./src/validators/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_- [field](./src/validators/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_- [schema](./src/validators/schema/schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [schema](./src/validators/schema/schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [constants](./src/constants.md) — This module exposes the package-level constants consumed across the rules engine, validators, action executor, and even…
|
||||
- [actions](./src/actions/actions.md) — <!-- begin:auto-docs --> _(no submodules)_- [builder](./src/builder/builder.md) — <!-- begin:auto-docs --> _(no submodules)_- [builder](./src/builder/builder.md) — <!-- begin:auto-docs --> _(no submodules)_- [context](./src/context/context.md) — <!-- begin:auto-docs --> _(no submodules)_- [context](./src/context/context.md) — <!-- begin:auto-docs --> _(no submodules)_- [engine](./src/engine/engine.md) — <!-- begin:auto-docs --> _(no submodules)_- operators-internal — `compareValues(actual, operato…
|
||||
- [engine](./src/engine/engine.md) — <!-- begin:auto-docs --> _(no submodules)_- operators-internal — `compareValues(actual, operato…
|
||||
- [operators-internal](./src/engine/operators-internal.md) — `compareValues(actual, operator, expected)` is the built-in comparison dispatcher used by `RuleEngine` until the dedica…
|
||||
- [errors](./src/errors/rules-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [rules-error](./src/errors/rules-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [events](./src/events/event-bus.md) — <!-- begin:auto-docs --> _(no submodules)_- [event-bus](./src/events/event-bus.md) — <!-- begin:auto-docs --> _(no submodules)_- [operators](./src/operators/operators.md) — <!-- begin:auto-docs --> _(no submodules)_- [operators](./src/operators/operators.md) — <!-- begin:auto-docs --> _(no submodules)_- [optimizer](./src/optimizer/optimizer.md) — <!-- begin:auto-docs --> _(no submodules)_- [optimizer](./src/optimizer/optimizer.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [serializer](./src/serializer/serializer.md) — <!-- begin:auto-docs --> _(no submodules)_- [serializer](./src/serializer/serializer.md) — <!-- begin:auto-docs --> _(no submodules)_- [custom](./src/validators/custom/custom.md) — <!-- begin:auto-docs --> _(no submodules)_- [custom](./src/validators/custom/custom.md) — <!-- begin:auto-docs --> _(no submodules)_- [field](./src/validators/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_- [field](./src/validators/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_- [schema](./src/validators/schema/schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [schema](./src/validators/schema/schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [constants](./src/constants.md) — This module exposes the package-level constants consumed across the rules engine, validators, action executor, and even…
|
||||
- [actions](./src/actions/actions.md) — <!-- begin:auto-docs --> _(no submodules)_- [builder](./src/builder/builder.md) — <!-- begin:auto-docs --> _(no submodules)_ - [builder](./src/builder/builder.md) — <!-- begin:auto-docs --> _(no submodules)_- [context](./src/context/context.md) — <!-- begin:auto-docs --> _(no submodules)_ - [context](./src/context/context.md) — <!-- begin:auto-docs --> _(no submodules)_- [engine](./src/engine/engine.md) — <!-- begin:auto-docs --> _(no submodules)_ - [engine](./src/engine/engine.md) — <!-- begin:auto-docs --> _(no submodules)_ - [operators-internal](./src/engine/operators-internal.md) — `compareValues(actual, operator, expected)` is the built-in comparison dispatcher used by `RuleEngine` until the dedica…
|
||||
- [errors](./src/errors/rules-error.md) — <!-- begin:auto-docs --> _(no submodules)_ - [rules-error](./src/errors/rules-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [events](./src/events/event-bus.md) — <!-- begin:auto-docs --> _(no submodules)_ - [event-bus](./src/events/event-bus.md) — <!-- begin:auto-docs --> _(no submodules)_- [operators](./src/operators/operators.md) — <!-- begin:auto-docs --> _(no submodules)_ - [operators](./src/operators/operators.md) — <!-- begin:auto-docs --> _(no submodules)_- [optimizer](./src/optimizer/optimizer.md) — <!-- begin:auto-docs --> _(no submodules)_ - [optimizer](./src/optimizer/optimizer.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_ - [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [serializer](./src/serializer/serializer.md) — <!-- begin:auto-docs --> _(no submodules)_ - [serializer](./src/serializer/serializer.md) — <!-- begin:auto-docs --> _(no submodules)_- [custom](./src/validators/custom/custom.md) — <!-- begin:auto-docs --> _(no submodules)_ - [custom](./src/validators/custom/custom.md) — <!-- begin:auto-docs --> _(no submodules)_- [field](./src/validators/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_ - [field](./src/validators/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_- [schema](./src/validators/schema/schema.md) — <!-- begin:auto-docs --> _(no submodules)_ - [schema](./src/validators/schema/schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [constants](./src/constants.md) — This module exposes the package-level constants consumed across the rules engine, validators, action executor, and even…
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/rules
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { context } from "@suaveplan/rules/context";
|
||||
import { rule } from "@suaveplan/rules/builder";
|
||||
import { RuleEngine } from "@suaveplan/rules/engine";
|
||||
|
||||
const policy = rule()
|
||||
.field("age").greaterThanOrEqual(18)
|
||||
.and()
|
||||
.field("region").in(["US", "CA"])
|
||||
.build();
|
||||
|
||||
const engine = new RuleEngine();
|
||||
const result = await engine.evaluate(policy, context({ age: 21, region: "US" }));
|
||||
// result.passed === true
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
- [`context`](src/context/context.md) — `RuleContext` builder, dot-path / array-index field resolver, request-context derivation
|
||||
- [`builder`](src/builder/builder.md) — `RuleBuilder` + `FieldBuilder` fluent DSL + factory functions
|
||||
- [`engine`](src/engine/engine.md) — `RuleEngine` with evaluate / evaluateAll / evaluateRuleset / check + plugin registration
|
||||
- [`operators`](src/operators/operators.md) — `OperatorRegistry` + 33 built-in operator definitions
|
||||
- [`actions`](src/actions/actions.md) — `Action` / `ActionRegistry` / `ActionExecutor` + 4 built-in handlers
|
||||
- [`events`](src/events/event-bus.md) — `EventBus` + `HookManager` with priority hooks and `waitFor`
|
||||
- [`validators/schema`](src/validators/schema/schema.md) — recursive JSON-schema-like validator
|
||||
- [`validators/field`](src/validators/field/field.md) — chainable field validator with 12 presets
|
||||
- [`validators/custom`](src/validators/custom/custom.md) — named validators + registry + 11 built-ins + 5 parameterised factories
|
||||
- [`serializer`](src/serializer/serializer.md) — round-trip JSON with function-placeholder strategy
|
||||
- [`optimizer`](src/optimizer/optimizer.md) — AST optimiser (flatten / dedupe / simplify / sort) + analytics
|
||||
- [`errors`](src/errors/rules-error.md) — `RulesError` + 24-code registry
|
||||
- [`otel`](src/otel/otel.md) — lazy OTel instrument surface
|
||||
- Constants — [`constants.md`](src/constants.md)
|
||||
|
||||
## Internal references
|
||||
|
||||
- [Engine internal compare dispatcher](src/engine/operators-internal.md) — bridges into the operator surface
|
||||
- [Type guards](src/types/) — `isConditionRule`, `isLogicalRule`, `isGroupRule`, `isCustomRule`, `isComputedRule`
|
||||
|
||||
## Conventions
|
||||
|
||||
- Every public method validates input via Zod and raises `RulesError` with a typed code on rejection.
|
||||
- `AbortSignal` is threaded through every async path; pre-aborted signals short-circuit before work begins.
|
||||
- `Clock` / `Rng` / `LoggerLike` are injectable per CLAUDE.md "no module-scope `Date.now`/`Math.random`" rule.
|
||||
- 100% test coverage; sibling `.md` per implementation file ≥ 200 words.
|
||||
- Tests import runner primitives from `@suaveplan/testing/runner` exclusively (no direct `vitest` / `bun:test`).
|
||||
- No `export *` in feature barrels — every public symbol is named explicitly.
|
||||
|
||||
## See also
|
||||
|
||||
- [`@suaveplan/rules-hono`](../rules-hono/README.md) — Hono middleware wrapping this package.
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,108 @@
|
||||
# Builder module — `@suaveplan/rules/builder`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
The builder module is the ergonomic, type-safe entry point for constructing rules without hand-authoring the discriminated `Rule` union. It exposes the `RuleBuilder` fluent class, the field-scoped `FieldBuilder` returned by `RuleBuilder.field(path)`, and a set of factory functions (`rule`, `condition`, `and`, `or`, `not`, `group`) for callers who prefer a function-style API to the chainable builder. Every builder output round-trips through the serializer and is consumed by the engine without further translation.
|
||||
|
||||
The builder is intentionally a thin layer over the `Rule` types — it does not validate that field paths exist, operators are registered, or values type-check against the operator. Those checks land in the engine at evaluation time so that the builder remains a pure constructor and tests for the builder are exclusively about shape correctness.
|
||||
|
||||
## Features
|
||||
|
||||
- `RuleBuilder` — fluent class with `field()`, `and()`, `or()`, `not()`, `group()`, `where()`, `any()`, `all()`, `custom()`, `computed()`, `rule()`, `addRules()`, `withMetadata()`, `build()`, `buildRuleset()`, `getRules()`. Pending-operator pattern: `.and()` / `.or()` flag the next `addRule` to combine with the previous rule into a `LogicalRule`.
|
||||
- `FieldBuilder` — yields a `ConditionRule` via any of the 30+ operator methods. Returns the parent `RuleBuilder` so the fluent chain continues.
|
||||
- Factory helpers — `rule()` (alias for `RuleBuilder.create()`), `condition()`, `and(...)`, `or(...)`, `not(...)`, `group(name, ...)` for callers who want plain functions.
|
||||
- Structured errors at every misuse — `.not()` without a preceding rule, `and()`/`or()` with zero args, empty group / ruleset names — all surface `RulesError` with the canonical code (`RULES.RULE_INVALID` / `RULES.RULESET_INVALID`).
|
||||
- Optional `message` / `metadata` on `FieldBuilder` propagate into the emitted `ConditionRule`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { rule } from "@suaveplan/rules/builder";
|
||||
|
||||
const adultAndVerified = rule()
|
||||
.field("age").greaterThanOrEqual(18)
|
||||
.and()
|
||||
.field("verified").equals(true)
|
||||
.build();
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { and, condition, group, not, or, rule } from "@suaveplan/rules/builder";
|
||||
|
||||
// Function-style composition
|
||||
const policy = and(
|
||||
condition("age", "greaterThanOrEqual", 18),
|
||||
or(
|
||||
condition("region", "in", ["US", "CA"]),
|
||||
condition("hasExplicitConsent", "equals", true),
|
||||
),
|
||||
not(condition("blocked", "equals", true)),
|
||||
);
|
||||
|
||||
// Fluent composition with nested groups + ruleset metadata
|
||||
const ruleset = rule()
|
||||
.withMetadata({ priority: "high", tags: ["compliance", "kyc"] })
|
||||
.group("identity", (rb) => rb
|
||||
.field("email").matches(/@example\.com$/)
|
||||
.and()
|
||||
.field("verified").equals(true))
|
||||
.all((rb) => rb
|
||||
.field("country").in(["US", "CA"])
|
||||
.field("age").greaterThanOrEqual(18))
|
||||
.custom("not-on-watchlist", async (data: { id: string }) => {
|
||||
return !(await isOnWatchlist(data.id));
|
||||
})
|
||||
.buildRuleset("rs-onboarding", "Onboarding policy", {
|
||||
operator: "and",
|
||||
failFast: false,
|
||||
version: "2.1.0",
|
||||
});
|
||||
|
||||
declare function isOnWatchlist(id: string): Promise<boolean>;
|
||||
declare const policy: unknown;
|
||||
declare const ruleset: unknown;
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class RuleBuilder`
|
||||
|
||||
- `static create()` — entry point.
|
||||
- `field<TValue>(path)` → `FieldBuilder<TValue>`.
|
||||
- `and()` / `or()` — set pending operator; next `addRule` combines into a binary `LogicalRule`.
|
||||
- `not()` — wrap the most recent rule in `LogicalRule { operator: "not" }`. Raises `RULES.RULE_INVALID` if the stack is empty.
|
||||
- `group(name, build)` — build an inner `RuleBuilder` and wrap its rules in a `GroupRule` (default operator `and`).
|
||||
- `where(build)` — single inner rule passes through; multiple inner rules wrap in `LogicalRule { operator: "and" }`; empty body is a no-op.
|
||||
- `any(build)` / `all(build)` — explicit OR / AND grouping.
|
||||
- `custom(name, validate, message?)` — append a `CustomRule`.
|
||||
- `computed(field, operator, compute, message?)` — append a `ComputedRule`.
|
||||
- `rule(r)` / `addRules(rules)` — append an existing rule or array of rules.
|
||||
- `withMetadata(meta)` — set metadata applied to subsequent `custom`, `computed`, and `buildRuleset` outputs.
|
||||
- `build()` → `Rule` — single rule returned directly; multiple rules wrap in `LogicalRule { operator: "and" }`. Empty stack raises `RULES.RULE_INVALID`.
|
||||
- `buildRuleset(id, name, options?)` → `Ruleset` — empty `id` / `name` raises `RULES.RULESET_INVALID`. Inherits builder metadata when `options.metadata` is omitted.
|
||||
- `getRules()` — read-only view of the internal stack.
|
||||
|
||||
### `class FieldBuilder<TValue>`
|
||||
|
||||
- `withMessage(s)` / `withMetadata(m)` — both return `this` and propagate into the emitted `ConditionRule`.
|
||||
- 30 operator methods: `equals`, `notEquals`, `strictEquals`, `strictNotEquals`, `greaterThan`, `greaterThanOrEqual`, `lessThan`, `lessThanOrEqual`, `between(min, max)`, `notBetween(min, max)`, `in(values)`, `notIn(values)`, `contains`, `notContains`, `containsAll(values)`, `containsAny(values)`, `exists`, `notExists`, `isNull`, `isNotNull`, `isEmpty`, `isNotEmpty`, `isType(type)`, `isNotType(type)`, `startsWith`, `endsWith`, `matches(pattern)` (string or RegExp), `notMatches(pattern)` (string or RegExp). Each commits a `ConditionRule` to the parent builder and returns the parent.
|
||||
|
||||
### Function factories
|
||||
|
||||
- `rule()` — alias for `RuleBuilder.create()`.
|
||||
- `condition(field, operator, value, message?)` — produce a `ConditionRule` directly.
|
||||
- `and(...rules)` / `or(...rules)` — empty args raise `RULES.RULE_INVALID`.
|
||||
- `not(rule)` — single-target wrapper.
|
||||
- `group(name, ...rules)` — empty name raises `RULES.RULE_INVALID`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The pending-operator pattern matches the upstream behaviour exactly: `.and()` / `.or()` flag a transition, the next `addRule` consumes the flag, builds a binary `LogicalRule`, and clears the flag. Subsequent rules without an explicit operator simply append to the stack (and are wrapped in an outer AND at `build()` time).
|
||||
- All optional fields (`message`, `metadata`) are omitted from the emitted rule when undefined, so output equality checks in tests can use plain `toEqual` without redundant `undefined` keys.
|
||||
- `FieldBuilder.matches(/regex/)` extracts the source string via `pattern.source` — the engine reconstructs the `RegExp` per evaluation (cheap, but means flags are NOT carried through; pass flags inside the pattern: `(?i)abc`).
|
||||
- The `addRule` method is intentionally part of the public class surface (not private) because `FieldBuilder.commit()` calls it from outside the class. Marking it `@internal` would still leave it on the public typing; the trade-off is accepted to keep the friendliness of the public API.
|
||||
@@ -0,0 +1,51 @@
|
||||
# Package constants — `@suaveplan/rules`
|
||||
|
||||
## Purpose
|
||||
|
||||
This module exposes the package-level constants consumed across the rules engine, validators, action executor, and event bus. Constants are deliberately separated from feature code so that one canonical source defines the conservative defaults — depth limits, history capacities, timeout deadlines — for the entire package. Every default is overridable through the options object on the relevant constructor or factory function; the defaults exist for the "I just want it to work" call path, not as a behavioural contract.
|
||||
|
||||
`PACKAGE_NAME` and `VERSION` are surfaced for diagnostics (error wire format, OTel resource attributes, telemetry headers). They are `const` literals so that downstream tree-shaking can fold them into the bundle as inline strings.
|
||||
|
||||
## Features
|
||||
|
||||
- `PACKAGE_NAME` — the canonical `@suaveplan/rules` identifier; matches the `name` field in `package.json`.
|
||||
- `VERSION` — synced to `package.json`'s `version` field at publish time.
|
||||
- `DEFAULT_MAX_DEPTH` — the recursion depth at which the engine throws `RULES.ENGINE_DEPTH_EXCEEDED`. Set conservatively to 32; well above any realistic business-rule tree, but low enough to detect cyclic or pathological inputs quickly.
|
||||
- `DEFAULT_EVENT_HISTORY` — the size of the bounded ring buffer maintained by `EventBus`. At 1000 entries the bus keeps recent context for debugging without growing without bound under sustained emission.
|
||||
- `DEFAULT_ACTION_TIMEOUT_MS` — the default per-action timeout used by `ActionExecutor` when the caller does not supply one. 30s matches the convention used by `@suaveplan/retry` for "long-running but bounded" operations.
|
||||
- `DEFAULT_ACTION_PARALLELISM` — the concurrency cap applied by `ActionExecutor.executeParallel` when the caller does not supply one. 8 is the sweet spot for IO-bound handlers without saturating an event loop.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { DEFAULT_MAX_DEPTH, PACKAGE_NAME, VERSION } from "@suaveplan/rules";
|
||||
|
||||
console.log(`${PACKAGE_NAME}@${VERSION}, max-depth=${DEFAULT_MAX_DEPTH}`);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { RuleEngine } from "@suaveplan/rules/engine";
|
||||
import { DEFAULT_MAX_DEPTH } from "@suaveplan/rules";
|
||||
|
||||
// Halve the default depth limit for a constrained edge function.
|
||||
const engine = new RuleEngine({ maxDepth: DEFAULT_MAX_DEPTH / 2 });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Export | Type | Description |
|
||||
|---|---|---|
|
||||
| `PACKAGE_NAME` | `"@suaveplan/rules"` (literal `const`) | Package identifier. |
|
||||
| `VERSION` | `"0.1.0"` (literal `const`) | Current package version; bumped to `1.0.0` at graduation. |
|
||||
| `DEFAULT_MAX_DEPTH` | `number` | Engine recursion ceiling. |
|
||||
| `DEFAULT_EVENT_HISTORY` | `number` | EventBus ring-buffer capacity. |
|
||||
| `DEFAULT_ACTION_TIMEOUT_MS` | `number` | Per-action timeout in milliseconds. |
|
||||
| `DEFAULT_ACTION_PARALLELISM` | `number` | Maximum concurrent actions in `executeParallel`. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The constants file ships zero runtime initialisation and zero `import`s of other workspace packages. It is intentionally side-effect free so that tree-shakers can prune it cleanly when unused.
|
||||
- Numbers are declared with `_` digit separators (`30_000`) for readability. The TypeScript target is ES2022, where numeric separators are supported by every published runtime.
|
||||
- Changes to default values are behaviour changes per AGENTS.md §13a and require a `## Migration` note in the OpenSpec proposal.
|
||||
@@ -0,0 +1,107 @@
|
||||
# Context module — `@suaveplan/rules/context`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
The context module supplies the evaluation environment threaded through every rule, action, and validator in the engine. It owns the `RuleContext` shape, the canonical `FieldResolver` implementation (dot-path + `prop[index]` array indexing), a fluent `ContextBuilder` whose `with*` calls each return a fresh builder for safe sharing across requests, and a `DefaultContextProvider` that composes registered enhancers and stamps a deterministic `timestamp` from an injectable `Clock`. The module also exports HTTP-shaped helpers (`createRequestContext`, `createScopedContext`) and shorthand factories (`context`, `contextWith`, `getField`, `hasField`) that cover the common call sites.
|
||||
|
||||
The module is intentionally side-effect free: no module-level state, no `new Date()` at import time, no global registries. Every default that touches wall-clock time runs through the injectable `Clock` parameter so deterministic tests pin behaviour with `fakeClock(...)` and production code uses `Date.now`.
|
||||
|
||||
## Features
|
||||
|
||||
- `DefaultFieldResolver` — dot-path + `prop[index]` traversal with optional strict mode raising `RULES.FIELD_RESOLUTION_FAILED` on missing intermediates.
|
||||
- `ContextBuilder` — immutable-per-call fluent API for `RuleContext`; `static create` / `static from` entry points; `withData<TNew>` re-types the builder while preserving optional fields.
|
||||
- `DefaultContextProvider` — `ContextProvider` impl with injectable `Clock` and async enhancer pipeline; `create` runs enhancers against an empty base, `enhance` runs them against an existing context.
|
||||
- `createRequestContext` — accepts plain header records or a Web `Headers` instance; derives `path` from `url` when explicit `path` is absent; survives malformed URLs.
|
||||
- `createScopedContext` — adds a `scope` label to a parent context; rejects empty scopes with `RULES.CONTEXT_INVALID`.
|
||||
- `context` / `contextWith` / `getField` / `hasField` — shorthand factories. `contextWith` Zod-validates its options at the boundary.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { context, getField } from "@suaveplan/rules/context";
|
||||
|
||||
const ctx = context({ user: { age: 21 } });
|
||||
const age = getField(ctx, "user.age"); // 21
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
ContextBuilder,
|
||||
createRequestContext,
|
||||
DefaultContextProvider,
|
||||
DefaultFieldResolver,
|
||||
} from "@suaveplan/rules/context";
|
||||
|
||||
const resolver = new DefaultFieldResolver({ strict: true });
|
||||
const sku = resolver.resolve(
|
||||
{ items: [{ sku: "X" }, { sku: "Y" }] },
|
||||
"items[1].sku",
|
||||
); // "Y"
|
||||
|
||||
const provider = new DefaultContextProvider<{ user: { id: string } }>({
|
||||
clock: () => 1700000000000,
|
||||
enhancers: [
|
||||
async (ctx) => ({ ...ctx, tenant: "acme" }),
|
||||
async (ctx) => ({
|
||||
...ctx,
|
||||
user: { ...ctx.data.user, id: ctx.data.user.id.toUpperCase() },
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const baseCtx = await provider.create({ user: { id: "ada" } });
|
||||
|
||||
const requestCtx = ContextBuilder.from(baseCtx)
|
||||
.withRequest(createRequestContext({ method: "GET", url: "/api/users/42" }))
|
||||
.build();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class DefaultFieldResolver implements FieldResolver`
|
||||
|
||||
- `new DefaultFieldResolver(options?)` — `options.strict` enables strict mode.
|
||||
- `resolve(data, path) -> unknown` — traverses dot-path segments and `prop[N]` array indices. Returns `data` when `path === ""`. In strict mode, missing intermediates raise `RulesError(RULES.FIELD_RESOLUTION_FAILED)`.
|
||||
- `has(data, path) -> boolean` — wraps `resolve` and swallows `RulesError` to return `false`; rethrows other errors.
|
||||
|
||||
### `class ContextBuilder<TData extends Record<string, unknown>>`
|
||||
|
||||
- `static create<T>(data)` / `static from<T>(context)` — entry points.
|
||||
- `withData<TNew>(data)` — re-types and forks the builder.
|
||||
- `withTenant`, `withUser`, `withRequest`, `withTimestamp`, `withMetadata` — each returns a fresh builder with the field set.
|
||||
- `build()` — produces an immutable `RuleContext<TData>` with only the supplied optional fields populated (undefined keys are omitted from the output object).
|
||||
|
||||
### `class DefaultContextProvider<TData> implements ContextProvider<TData>`
|
||||
|
||||
- `new DefaultContextProvider(options?)` — `options.clock` defaults to `() => Date.now()`; `options.enhancers` is copied into an internal array.
|
||||
- `create(input) -> Promise<RuleContext<TData>>` — wraps non-object input as `{}`, stamps `timestamp = new Date(clock())`, runs every enhancer in order.
|
||||
- `enhance(context, input) -> Promise<RuleContext<TData>>` — runs every enhancer in order against the supplied context.
|
||||
|
||||
### `function context(data)` / `function contextWith(data, options?)`
|
||||
|
||||
Shorthand factories. `contextWith` validates `options` via Zod and raises `RulesError(RULES.CONTEXT_INVALID)` on rejection.
|
||||
|
||||
### `function getField(context, path)` / `function hasField(context, path)`
|
||||
|
||||
Convenience wrappers around a module-local non-strict `DefaultFieldResolver`.
|
||||
|
||||
### `function createRequestContext(input)`
|
||||
|
||||
Derives a `RequestContext` from an HTTP-shaped input. Validates input via Zod; preserves an explicit `path` over a URL-derived one; falls back to the raw `url` string when the URL parser rejects the value.
|
||||
|
||||
### `function createScopedContext(parent, scope)`
|
||||
|
||||
Returns `{ ...parent, scope }`. Empty `scope` raises `RulesError(RULES.CONTEXT_INVALID)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The `ARRAY_INDEX_PATTERN` regex captures both the property name and the index in one match, so `items[0]` resolves the property `items` and then the array element at `0`.
|
||||
- `ContextBuilder.build()` deliberately omits `undefined` keys so that `expect(ctx).toEqual({ data: { a: 1 } })` matches exactly without redundant optional fields polluting the output.
|
||||
- The default resolver used by `getField` / `hasField` is non-strict. Callers that want strict-mode resolution must construct their own `new DefaultFieldResolver({ strict: true })`.
|
||||
- `DefaultContextProvider.create` calls `this.clock()` exactly once per invocation, so the resulting `timestamp` is stable regardless of enhancer wall-time.
|
||||
- All Zod schemas are declared once at module scope and reused per call to avoid per-invocation schema construction overhead.
|
||||
@@ -0,0 +1,141 @@
|
||||
# Engine module — `@suaveplan/rules/engine`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs --> - operators-internal — `compareValues(actual, operato…
|
||||
- [operators-internal](./operators-internal.md) — `compareValues(actual, operator, expected)` is the built-in comparison dispatcher used by `RuleEngine` until the dedica…
|
||||
- operators-internal — `compareValues(actual, operato…
|
||||
|
||||
- [operators-internal](./operators-internal.md) — `compareValues(actual, operator, expected)` is the built-in comparison dispatcher used by `RuleEngine` until the dedica…
|
||||
|
||||
- operators-internal — `compareValues(actual, operato…
|
||||
|
||||
- [operators-internal](./operators-internal.md) — `compareValues(actual, operator, expected)` is the built-in comparison dispatcher used by `RuleEngine` until the dedica…
|
||||
|
||||
- operators-internal — `compareValues(actual, operato…
|
||||
|
||||
- [operators-internal](./operators-internal.md) — `compareValues(actual, operator, expected)` is the built-in comparison dispatcher used by `RuleEngine` until the dedica…
|
||||
- [operators-internal](./operators-internal.md) — `compareValues(actual, operator, expected)` is the built-in comparison dispatcher used by `RuleEngine` until the dedica…
|
||||
|
||||
## Purpose
|
||||
|
||||
The `RuleEngine` evaluates rules from the discriminated `Rule` union against a `RuleContext`. It threads `AbortSignal` through every recursive boundary, enforces a configurable max depth, accepts plugin-registered custom operators, emits OTel histograms / counters / spans for every evaluation, and translates every failure into a typed `RulesError` code. The engine is the central public entrypoint of the package: callers can use it standalone or wire it into Hono middleware via `@suaveplan/rules-hono`.
|
||||
|
||||
The engine consumes built-in comparison operators from an internal dispatcher (`operators-internal.ts`) so it remains usable without the dedicated `operators` module installed. When the `operators` module ships, plugins can register or override operator definitions through `engine.use(plugin)` without touching the engine class itself — this is the same composition pattern used by `@suaveplan/crypto`'s engine adapters.
|
||||
|
||||
## Features
|
||||
|
||||
- `evaluate(rule, context, options?)` — single-rule evaluation returning `EvaluationResult` with `passed`, `errors`, optional `trace`, and a populated `metadata` block (start / end times, duration, evaluated count, max depth).
|
||||
- `evaluateAll(rules, context, options?)` — array variant returning `readonly EvaluationResult[]` in input order.
|
||||
- `evaluateRuleset(ruleset, context, options?)` — Zod-validates the ruleset, applies the ruleset's `operator` (`and` / `or`) over per-rule outcomes, honours `failFast`.
|
||||
- `check(rule, context, options?)` — boolean-only convenience.
|
||||
- `use(plugin)` — install a `RuleEnginePlugin` with idempotent registration; `hasPlugin(name)` reports installation status.
|
||||
- Built-in operators: 33-entry comparison dispatcher covering equality / numeric / collection / existence / type / length / string operators.
|
||||
- AbortSignal: pre-aborted signals short-circuit before any rule fires; signals abort across recursive boundaries; engine-level `defaultSignal` applies when no per-call signal is supplied.
|
||||
- OTel: `rules.engine.evaluate.duration_ms`, `rules.engine.evaluations_total{outcome}`, `rules.engine.depth`, `rules.operator.unknown_total{name}`, plus a `rules.engine.evaluate` span with `outcome` attribute.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { context } from "@suaveplan/rules/context";
|
||||
import { condition } from "@suaveplan/rules/builder";
|
||||
import { RuleEngine } from "@suaveplan/rules/engine";
|
||||
|
||||
const engine = new RuleEngine();
|
||||
const result = await engine.evaluate(
|
||||
condition("age", "greaterThanOrEqual", 18),
|
||||
context({ age: 21 }),
|
||||
);
|
||||
result.passed; // true
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { and, condition } from "@suaveplan/rules/builder";
|
||||
import { context } from "@suaveplan/rules/context";
|
||||
import { RuleEngine } from "@suaveplan/rules/engine";
|
||||
import type { OperatorDefinition } from "@suaveplan/rules";
|
||||
|
||||
const divisibleBy: OperatorDefinition<number, number> = {
|
||||
name: "divisibleBy",
|
||||
description: "actual % expected === 0",
|
||||
evaluate: (actual, expected) => Number(actual) % Number(expected) === 0,
|
||||
};
|
||||
|
||||
const engine = new RuleEngine({ maxDepth: 16, trace: true });
|
||||
engine.use({
|
||||
name: "math-plugin",
|
||||
install: (r) => r.registerOperator("divisibleBy", divisibleBy),
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => controller.abort(), 25); // hard 25ms deadline
|
||||
|
||||
const policy = and(
|
||||
condition("age", "greaterThanOrEqual", 18),
|
||||
condition("score", "divisibleBy", 3),
|
||||
);
|
||||
|
||||
const result = await engine.evaluateRuleset(
|
||||
{
|
||||
id: "rs-onboarding",
|
||||
name: "Onboarding",
|
||||
operator: "and",
|
||||
failFast: true,
|
||||
rules: [policy, condition("region", "in", ["US", "CA"])],
|
||||
},
|
||||
context({ age: 21, score: 9, region: "US" }),
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
|
||||
if (!result.passed) {
|
||||
for (const err of result.errors) console.warn(err.code, err.message);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class RuleEngine`
|
||||
|
||||
- `new RuleEngine(config?)` — Zod-validates config. Throws `RulesError(RULES.CONTEXT_INVALID)` on invalid input.
|
||||
- `config.maxDepth: number > 0` — defaults to `DEFAULT_MAX_DEPTH` (32).
|
||||
- `config.trace: boolean` — engine-wide default for trace emission.
|
||||
- `config.defaultSignal: AbortSignal` — applies when per-call `options.signal` is absent.
|
||||
- `use(plugin) -> this` — install once per `plugin.name`. Subsequent `use(samePlugin)` are no-ops.
|
||||
- `hasPlugin(name) -> boolean`.
|
||||
- `evaluate(rule, context, options?) -> Promise<EvaluationResult>` — full evaluation, returns the result rather than throwing on miss.
|
||||
- `evaluateAll(rules, context, options?) -> Promise<readonly EvaluationResult[]>` — preserves input order.
|
||||
- `evaluateRuleset(ruleset, context, options?) -> Promise<EvaluationResult>` — Zod-validates the ruleset; rejects with `RULES.RULESET_INVALID` if the shape is wrong; applies `ruleset.operator` over child outcomes; honours `failFast`.
|
||||
- `check(rule, context, options?) -> Promise<boolean>` — convenience wrapper.
|
||||
|
||||
### `interface RuleEngineConfig`
|
||||
|
||||
| Field | Type | Default | Meaning |
|
||||
|---|---|---|---|
|
||||
| `maxDepth` | `number` | `32` | Recursion ceiling. Past this, evaluation raises `RULES.ENGINE_DEPTH_EXCEEDED` (surfaced in `result.errors`). |
|
||||
| `trace` | `boolean` | `false` | Engine-wide default for trace emission; per-call `options.trace` overrides. |
|
||||
| `defaultSignal` | `AbortSignal` | — | Applied when per-call `options.signal` is omitted. |
|
||||
|
||||
### `interface RuleEnginePlugin`
|
||||
|
||||
- `name: string` — used for idempotency.
|
||||
- `install(registrar): void` — registrar exposes `registerOperator(name, definition)`.
|
||||
|
||||
### `interface EvaluationOptions`
|
||||
|
||||
| Field | Type | Meaning |
|
||||
|---|---|---|
|
||||
| `signal` | `AbortSignal` | Per-call abort. Pre-aborted signals short-circuit before any rule fires. |
|
||||
| `maxDepth` | `number` | Per-call depth ceiling; overrides engine config. |
|
||||
| `trace` | `boolean` | Per-call trace toggle; overrides engine config. |
|
||||
| `cache` | `boolean` | Reserved for cache integration (not yet implemented; documented in OpenSpec). |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The engine returns `passed: false` with `errors` populated rather than throwing for any normal evaluation outcome (miss, depth exceeded, abort, unknown operator). Throwing is reserved for truly exceptional cases — `RulesError(RULES.CONTEXT_INVALID)` on invalid config supplied at construction. This contract makes the engine safe to call from middleware that must not throw.
|
||||
- The internal compare dispatcher is a single switch over 33 operator names. The fall-through `default` is typed as `never` so adding a new operator to the union surfaces as a compile error in the engine.
|
||||
- Trace emission is `O(rules-visited)`. The trace array is captured per call and never shared across evaluations.
|
||||
- `evaluateRuleset` deliberately re-runs every child through `evaluate(...)` (the trace boundary) so its own metadata aggregation is straightforward; it does not call the internal `evaluateRule` recursion. The cost is one extra `performance.now()` per child, which is negligible.
|
||||
- The OTel surface is wired through `getInstruments()` (lazy, cache-stable). Tests asserting emission install an in-memory `MeterProvider` and call `resetInstruments()` to bind to it before exercising the engine.
|
||||
- The engine instance is thread-safe in the JS sense: no per-call mutable state lives on `this`; per-call state (`traceEntries`, `maxDepthSeen`) is stack-local. Two concurrent `evaluate` calls do not interfere.
|
||||
@@ -0,0 +1,66 @@
|
||||
# Internal operator dispatcher — `@suaveplan/rules/engine/operators-internal`
|
||||
|
||||
## Purpose
|
||||
|
||||
`compareValues(actual, operator, expected)` is the built-in comparison dispatcher used by `RuleEngine` until the dedicated `operators` module ships. It maps every member of the 33-entry `ComparisonOperator` union to a deterministic boolean over `(actual, expected)`. Unknown operators raise `RulesError(RULES.OPERATOR_NOT_FOUND)`; the engine intercepts that error to increment the `rules.operator.unknown_total{name}` counter and converts the failure into a `passed: false` evaluation result.
|
||||
|
||||
This file is private to the engine (not re-exported from the package root). The public `operators` module — when it lands — will register the same 30+ operators against a real `OperatorRegistry` and the engine will dispatch through that registry instead. Both paths satisfy the same `OperatorDefinition.evaluate` contract, so call sites do not change.
|
||||
|
||||
## Features
|
||||
|
||||
- Single-function module — no class, no state, no module-level side effects.
|
||||
- Exhaustive `switch` over `ComparisonOperator`; `default` is typed `never` so adding a new operator name to the union is a compile error here.
|
||||
- Handles `null` / `undefined` / non-string / non-array inputs gracefully — every operator has a documented behaviour on the wrong shape rather than throwing.
|
||||
- `matches` / `notMatches` accept either a string source or a `RegExp` instance; the source is recompiled to a `RegExp` per call.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { compareValues } from "@suaveplan/rules/engine/operators-internal";
|
||||
|
||||
compareValues(21, "greaterThanOrEqual", 18); // true
|
||||
compareValues([1, 2, 3], "containsAll", [1, 2]); // true
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { compareValues } from "@suaveplan/rules/engine/operators-internal";
|
||||
import { RulesError, isRulesError } from "@suaveplan/rules/errors";
|
||||
|
||||
function safeCompare(a: unknown, op: string, b: unknown): boolean {
|
||||
try {
|
||||
return compareValues(a, op as never, b);
|
||||
} catch (err) {
|
||||
if (isRulesError(err) && err.code === "RULES.OPERATOR_NOT_FOUND") {
|
||||
return false; // unknown operator behaves as "no match"
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `compareValues(actual: unknown, operator: ComparisonOperator, expected: unknown): boolean`
|
||||
|
||||
Returns the boolean outcome of applying `operator` to `(actual, expected)`. Documented per-operator semantics:
|
||||
|
||||
- **equality**: `equals` / `strictEquals` use `===`; `notEquals` / `strictNotEquals` use `!==`. Both pairs are equivalent — the strict variants are kept so consumers using a JSON DSL can be explicit.
|
||||
- **numeric**: `greaterThan` / `greaterThanOrEqual` / `lessThan` / `lessThanOrEqual` cast both sides to `number`. `between` / `notBetween` accept `[min, max]` tuples and are inclusive on both bounds.
|
||||
- **collection**: `in` / `notIn` test membership of `actual` in `expected` (an array). `contains` / `notContains` work on either arrays or strings; non-array, non-string `actual` returns `false` for `contains` and `true` for `notContains`. `containsAll` / `containsAny` / `overlaps` require both sides to be arrays.
|
||||
- **existence**: `exists` is `actual !== undefined`; `notExists` is the inverse. `isNull` / `isNotNull` are strict `=== null` checks. `isEmpty` / `isNotEmpty` cover string / array / object / null / undefined.
|
||||
- **type**: `isType` is `typeof actual === expected`; `isNotType` is the inverse. `hasProperty` is `Object.hasOwn(actual, expected)` over objects (false otherwise).
|
||||
- **length**: `lengthEquals` / `lengthGreaterThan` / `lengthLessThan` operate on string / array / object (object uses `Object.keys` count). Other types yield `NaN` which makes the comparison fall through to `false`.
|
||||
- **string**: `startsWith` / `endsWith` require `typeof actual === "string"`. `matches` accepts a string or `RegExp`; on non-string `actual`, `matches` is `false` and `notMatches` is `true`.
|
||||
|
||||
### Throws
|
||||
|
||||
- `RulesError(RULES.OPERATOR_NOT_FOUND)` when `operator` is outside the documented union.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The dispatcher does NOT memoise the per-call `RegExp` construction in `matches` / `notMatches`. For rules that fire frequently with the same pattern, callers can pre-compile and pass the `RegExp` instance instead of a string — `compareValues` accepts both.
|
||||
- The `default` branch uses `const unknownOperator: never = operator;` to leverage the compiler's exhaustiveness checking; the runtime `throw` is a defensive belt-and-braces against `as never` casts at the boundary.
|
||||
- `countable(value)` is the shared length helper; non-countable inputs return `NaN` so every length comparison naturally yields `false` (NaN comparisons are always false).
|
||||
- The function is intentionally synchronous. Async operators are out of scope for the built-in dispatcher — plugins that register async operators on the engine go through the `OperatorDefinition.evaluate` contract which accepts a `Promise<boolean>` return.
|
||||
@@ -0,0 +1,104 @@
|
||||
# `RulesError` — Typed error registry for `@suaveplan/rules`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`RulesError` is the single error class thrown across the entire `@suaveplan/rules` surface. It extends `SuaveplanError<RulesErrorCode>` so every failure carries a stable, machine-readable code drawn from a frozen 24-entry registry, plus the standard `SuaveplanError` wire format, OTel-adapter integration, and cause-chain preservation. Callers should never catch a generic `Error` from this package — `instanceof RulesError` (or `isRulesError(value)`) is the canonical discrimination path.
|
||||
|
||||
The registry is defined at module load via `defineCodes("RULES", [...] as const)`, which exports `RULES_CODES` as a readonly tuple literal. The `RulesErrorCode` type is derived from that tuple, so the TypeScript compiler enforces that every `new RulesError(code, ...)` call site uses a code that actually exists in the registry. Adding a new code requires an OpenSpec change (the conformance script verifies the registry against the spec's `## ADDED Requirements`).
|
||||
|
||||
## Features
|
||||
|
||||
- 24 stable codes covering engine / operator / field-resolution / action / event / validator / serializer / optimizer / plugin failure modes.
|
||||
- `RulesError` class extends `SuaveplanError` for cross-package wire-format and OTel-adapter compatibility.
|
||||
- Optional `cause`, `traceId`, and `details` fields preserved through construction.
|
||||
- `isRulesError(value)` type guard for catch arms that need to discriminate package errors from unrelated runtime errors.
|
||||
- Every code is forward-declared at module load — no dynamic registration, no string-typed escape hatches.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { RulesError, isRulesError } from "@suaveplan/rules/errors";
|
||||
|
||||
function evaluate(rule: unknown) {
|
||||
if (typeof rule !== "object" || rule === null) {
|
||||
throw new RulesError("RULES.RULE_INVALID", "rule must be an object");
|
||||
}
|
||||
// ... real evaluation
|
||||
}
|
||||
|
||||
try {
|
||||
evaluate(null);
|
||||
} catch (err) {
|
||||
if (isRulesError(err) && err.code === "RULES.RULE_INVALID") {
|
||||
// structured handling
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { RulesError } from "@suaveplan/rules/errors";
|
||||
|
||||
async function resolveField(data: unknown, path: string, strict: boolean) {
|
||||
try {
|
||||
return walk(data, path);
|
||||
} catch (cause) {
|
||||
throw new RulesError(
|
||||
"RULES.FIELD_RESOLUTION_FAILED",
|
||||
`cannot resolve "${path}" in strict mode`,
|
||||
{
|
||||
cause,
|
||||
traceId: currentTraceId(),
|
||||
details: { path, strict, hasData: data !== undefined },
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `details` object becomes part of the structured wire format when the error
|
||||
crosses a process boundary (for example, when a Hono middleware serialises a
|
||||
denied request into a JSON envelope). The OTel adapter on `SuaveplanError`
|
||||
projects `code`, `traceId`, and `details` as span attributes automatically.
|
||||
|
||||
## API Reference
|
||||
|
||||
### `RULES_CODES: readonly RulesErrorCode[]`
|
||||
|
||||
The frozen 24-entry registry of stable error codes. Every entry is prefixed
|
||||
with `RULES.` and corresponds to a documented failure mode in the package's
|
||||
OpenSpec contract.
|
||||
|
||||
### `type RulesErrorCode`
|
||||
|
||||
Literal-union type derived from `RULES_CODES`. Constructor calls that pass a
|
||||
string not in the union are rejected at compile time.
|
||||
|
||||
### `class RulesError extends SuaveplanError<RulesErrorCode>`
|
||||
|
||||
- `new RulesError(code, message, opts?)` — constructor.
|
||||
- `code: RulesErrorCode` — required, must be a member of `RULES_CODES`.
|
||||
- `message: string` — required, human-readable summary.
|
||||
- `opts.cause?: unknown` — original thrown value when wrapping.
|
||||
- `opts.traceId?: string` — propagated to the SuaveplanError base; appears in OTel span attributes.
|
||||
- `opts.details?: Readonly<Record<string, unknown>>` — structured context; omitted from `err.details` when not supplied.
|
||||
- Inherits `code`, `message`, `cause`, `traceId`, `toWire()` from `SuaveplanError`.
|
||||
- `name` is `"RulesError"`.
|
||||
|
||||
### `isRulesError(value: unknown): value is RulesError`
|
||||
|
||||
Returns `true` only when `value` is a `RulesError` instance. Plain `Error`
|
||||
objects with a `code` field do not satisfy the guard.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The registry is intentionally flat (no nested namespaces). Sub-domain grouping is expressed via the prefix (`ENGINE_*`, `ACTION_*`, `VALIDATOR_*`) rather than a structured tree, matching the convention used by `@suaveplan/crypto-rsa` and `@suaveplan/webauthn`.
|
||||
- `details` is stored only when explicitly supplied; the property is `undefined` (not an empty object) otherwise. This keeps the wire format compact and lets receivers test `if (err.details)` without false positives.
|
||||
- `RulesError` is `extends SuaveplanError` not `extends Error` — the AGENTS.md §2c rule against deleting registry entries to satisfy a gate applies here. If a code is "unused", the fix is to wire it into the path the proposal designed it for, not to remove it.
|
||||
- Cross-runtime: tests import runner primitives from `@suaveplan/testing/runner` per CLAUDE.md rule 11. Direct `vitest` / `bun:test` imports are banned in this package.
|
||||
@@ -0,0 +1,100 @@
|
||||
# EventBus module — `@suaveplan/rules/events`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Pub/sub bus used by the engine and downstream consumers (Hono middleware, audit logs, observability sinks) to broadcast lifecycle events without coupling. Topics are drawn from the typed `RuleEngineEvent` union — adding a new topic is a type change rather than a string literal. The bus supports plain `on` / `off` / `once` subscriptions, priority-ordered `hook` registrations (higher fires first), a bounded ring-buffer history (default 1000 entries), an awaitable `waitFor(event, { timeoutMs?, signal? })` API, and OTel emission counting every payload by `type`.
|
||||
|
||||
A sibling `HookManager` class scopes the priority-hook surface to a single topic so callers can manage hooks without holding the parent bus — useful for engine plugins that own a handful of lifecycle hooks but should not have a back-channel to the global event stream.
|
||||
|
||||
## Features
|
||||
|
||||
- Typed topic API — every `on` / `emit` is keyed by `RuleEngineEvent`.
|
||||
- Priority hooks — `hook(event, { priority, listener })`; sorted descending by priority on register; finite-priority enforcement raises `RULES.HOOK_INVALID`.
|
||||
- Awaitable `waitFor` — resolves with the payload on first match; rejects with `RULES.EVENT_TIMEOUT` past `timeoutMs` or `RULES.EVENT_ABORTED` when the supplied `AbortSignal` fires (including pre-aborted).
|
||||
- Bounded history — `getHistory()` returns the last N payloads in arrival order; `clearHistory()` drops the buffer.
|
||||
- Injectable `Clock` — `bus.now()` for callers that timestamp their own payloads.
|
||||
- OTel counter — every `emit` / `emitSync` increments `rules.event.emitted_total{type}`.
|
||||
- `HookManager` — standalone priority-hook bag; `register` / `remove` / `invoke` / `size`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createEventBus } from "@suaveplan/rules/events";
|
||||
|
||||
const bus = createEventBus();
|
||||
bus.on("rule.passed", (payload) => {
|
||||
console.log(`rule passed at ${payload.timestamp}`);
|
||||
});
|
||||
await bus.emit({ type: "rule.passed", timestamp: Date.now() });
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { createEventBus, createHookManager } from "@suaveplan/rules/events";
|
||||
|
||||
const bus = createEventBus({ historySize: 200, clock: () => Date.now() });
|
||||
|
||||
// Priority-ordered hooks — higher fires first
|
||||
bus.hook("rule.passed", {
|
||||
priority: 100,
|
||||
listener: async (payload) => audit.append(payload),
|
||||
});
|
||||
bus.hook("rule.passed", {
|
||||
priority: 10,
|
||||
listener: async (payload) => metrics.increment("rule.passed", { type: payload.type }),
|
||||
});
|
||||
|
||||
// Await the next event with a hard 5s ceiling and caller-side abort
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => controller.abort(), 5000);
|
||||
try {
|
||||
const payload = await bus.waitFor("ruleset.completed", {
|
||||
timeoutMs: 5000,
|
||||
signal: controller.signal,
|
||||
});
|
||||
console.log("ruleset finished:", payload.timestamp);
|
||||
} catch (err) {
|
||||
console.warn("waitFor failed:", err);
|
||||
}
|
||||
|
||||
// Standalone hook manager — convenient for engine plugin internals
|
||||
const mgr = createHookManager();
|
||||
mgr.register({ priority: 1, listener: () => undefined });
|
||||
await mgr.invoke({ type: "rule.failed", timestamp: bus.now() });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class EventBus`
|
||||
|
||||
- `new EventBus(options?)` — `options.clock` defaults to `() => Date.now()`. `options.historySize` defaults to `DEFAULT_EVENT_HISTORY = 1000`; `<= 0` raises `RULES.HOOK_INVALID`.
|
||||
- `on(event, listener)` → unsubscribe fn.
|
||||
- `off(event, listener)` → boolean.
|
||||
- `once(event, listener)` → unsubscribe fn; auto-removes after first fire.
|
||||
- `hook(event, { priority, listener, id? })` → unsubscribe fn. Sorted descending; non-finite priority raises `RULES.HOOK_INVALID`.
|
||||
- `emit(payload)` → `Promise<void>`. Records history, increments OTel counter, runs hooks first (in priority order), then listeners (in registration order). Awaits every callback.
|
||||
- `emitSync(payload)` — fire-and-forget variant; same ordering but callbacks are not awaited.
|
||||
- `waitFor(event, options?)` → `Promise<EventPayload>`. Rejects with `RULES.EVENT_TIMEOUT` or `RULES.EVENT_ABORTED`.
|
||||
- `getHistory()` / `clearHistory()` — history-buffer accessors.
|
||||
- `now()` — convenience over the injected clock.
|
||||
|
||||
### `class HookManager`
|
||||
|
||||
Standalone priority-hook bag scoped to one topic. Methods: `register`, `remove`, `invoke`, `size`. Same priority + finite-number rules as the bus.
|
||||
|
||||
### Factory aliases
|
||||
|
||||
`createEventBus(options?)`, `createHookManager()`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The bus runs hooks **before** listeners on `emit`. This lets plugins inspect and (in future) decorate the payload before user-land code observes it.
|
||||
- `on` / `hook` snapshots their bucket via `[...bucket]` before iterating so that unsubscribing during emission does not skip subsequent callbacks.
|
||||
- `waitFor` registers a one-shot listener plus optional abort + timeout handlers. Every cleanup path (resolve, reject-timeout, reject-abort, pre-abort) runs every cleanup function exactly once via the local `cleanup: (() => void)[]` queue.
|
||||
- `historyBuffer` is a plain array trimmed with `shift()`. Trimming via a ring index would shave a microsecond per insert but adds bookkeeping; the cost is dominated by listener / hook execution in any realistic workload.
|
||||
- OTel emission is per-`emit` and not per-listener, so the counter measures bus activity rather than work done by subscribers. Subscribers that want their own metrics emit via their own meter.
|
||||
- `emitSync` does not return a value (no `Promise` to await), but callback exceptions are NOT silently swallowed — the runtime will surface them as unhandled promise rejections through the `void` discard. Callers that need synchronous error handling should use `emit` and `await` it.
|
||||
@@ -0,0 +1,84 @@
|
||||
# Operators module — `@suaveplan/rules/operators`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Exposes the canonical `OperatorRegistry` class plus 33 built-in operator definitions covering equality, numeric comparison, collection membership, existence, type, length, and string-shape checks. The engine can dispatch comparison through this registry (or a caller-supplied alternative) instead of its internal `compareValues` table — both paths satisfy the same `OperatorDefinition.evaluate` contract, so call sites do not change.
|
||||
|
||||
The four logical operators (`and`, `or`, `not`, `xor`) are surfaced as placeholder `OperatorDefinition`s for callers who enumerate the universe of operators (e.g. building a UI). Their `evaluate` throws — the engine handles logical operators through its `evaluateLogical` path, not the registry.
|
||||
|
||||
## Features
|
||||
|
||||
- `OperatorRegistry` — `register`, `get`, `mustGet` (raises `RULES.OPERATOR_NOT_FOUND`), `has`, `names`, `unregister`. Supports per-operator `aliases`.
|
||||
- 30 working comparison operators wrapping the engine's internal `compareValues` dispatcher so behaviour is identical between the registry path and the engine's built-in fast path.
|
||||
- 4 logical placeholder operators (`and`/`or`/`not`/`xor`) that throw on direct invocation — they exist for enumeration and discoverability.
|
||||
- `BUILTIN_OPERATORS` — readonly tuple of every built-in.
|
||||
- `createDefaultOperatorRegistry()` — fresh registry instance pre-populated with the built-ins. Recommended path for tests and callers that want isolation.
|
||||
- `defaultOperatorRegistry` — process-global pre-populated singleton for one-shot convenience.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { defaultOperatorRegistry } from "@suaveplan/rules/operators";
|
||||
|
||||
const op = defaultOperatorRegistry.mustGet("greaterThan");
|
||||
await op.evaluate(21, 18); // true
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
OperatorRegistry,
|
||||
createDefaultOperatorRegistry,
|
||||
} from "@suaveplan/rules/operators";
|
||||
import type { OperatorDefinition } from "@suaveplan/rules";
|
||||
|
||||
const isOddOperator: OperatorDefinition<number, void> = {
|
||||
name: "isOdd",
|
||||
description: "actual % 2 !== 0",
|
||||
evaluate: (actual) => Number(actual) % 2 !== 0,
|
||||
};
|
||||
|
||||
const registry: OperatorRegistry = createDefaultOperatorRegistry();
|
||||
registry.register("isOdd", isOddOperator);
|
||||
// register an alias too:
|
||||
registry.register("notDivisibleByTwo", { ...isOddOperator, aliases: ["odd"] });
|
||||
|
||||
registry.has("odd"); // true (alias)
|
||||
registry.unregister("isOdd"); // true
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class OperatorRegistry implements OperatorRegistryLike`
|
||||
|
||||
| Method | Returns | Notes |
|
||||
|---|---|---|
|
||||
| `register(name, definition)` | `void` | Empty name → `RULES.OPERATOR_INVALID`. Definition aliases are registered as additional names. Re-registration replaces the prior entry. |
|
||||
| `get(name)` | `OperatorDefinition \| undefined` | Lenient lookup. |
|
||||
| `mustGet(name)` | `OperatorDefinition` | Strict lookup; raises `RulesError(RULES.OPERATOR_NOT_FOUND)` when absent. |
|
||||
| `has(name)` | `boolean` | Includes aliases. |
|
||||
| `names()` | `readonly string[]` | Snapshot of all keys (includes aliases). |
|
||||
| `unregister(name)` | `boolean` | `true` when removed, `false` when absent. |
|
||||
|
||||
### Built-in definitions
|
||||
|
||||
Every name in the `ComparisonOperator` union has a matching `<name>Operator` export — for example `equalsOperator`, `greaterThanOperator`, `betweenOperator`, `containsOperator`, `startsWithOperator`, `matchesOperator`, `lengthEqualsOperator`. Each definition delegates to `compareValues(actual, name, expected)` so registry-path and engine-internal-path behaviour are bit-identical.
|
||||
|
||||
### `createDefaultOperatorRegistry(): OperatorRegistry`
|
||||
|
||||
Returns a fresh registry pre-populated with `BUILTIN_OPERATORS`. Prefer this over `defaultOperatorRegistry` when you need isolation between tests, requests, or tenants.
|
||||
|
||||
### `defaultOperatorRegistry: OperatorRegistry`
|
||||
|
||||
Process-global pre-populated registry. Mutable — callers who register custom operators against it will see those changes from every other consumer of the same singleton. Use with care; prefer `createDefaultOperatorRegistry()` when in doubt.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The four logical placeholder operators (`andOperator`, `orOperator`, `notOperator`, `xorOperator`) throw on direct `evaluate` call because logical operators in this package have always been engine-managed (they need access to the child rule list, depth tracker, and abort signal — none of which fit the `OperatorDefinition.evaluate(actual, expected, context?)` shape). They are kept for enumeration completeness and would be removed if a future change unifies the dispatch model.
|
||||
- `BUILTIN_OPERATORS` is `Object.freeze`d to make its shape part of the public contract — consumers can iterate it but cannot mutate.
|
||||
- The registry's alias handling is deliberately simple: aliases register additional keys pointing at the same `OperatorDefinition` reference. `unregister(alias)` removes only the alias, not the primary name.
|
||||
- This module does NOT register itself against the engine at import time. Engine consumers wire it explicitly via `engine.use(plugin)` when they want registry-based dispatch.
|
||||
@@ -0,0 +1,106 @@
|
||||
# Optimizer — `@suaveplan/rules/optimizer`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Pure functional optimiser for the `Rule` tree. Applies four independent passes — **flatten** (collapse nested AND-of-AND / OR-of-OR), **dedupe** (drop structurally-equal siblings via `hashRule`), **simplify** (unwrap single-child AND/OR), **sort** (rearrange AND/OR children by ascending estimated cost for short-circuit benefit) — each toggleable via `OptimizeOptions`. Companion analytics — `analyzeRule`, `estimateCost`, `findFields`, `hasAsyncComponents`, `countRules`, `isOptimisationStable` — expose the shapes the optimiser uses internally so callers can introspect rules without re-walking.
|
||||
|
||||
The optimiser never changes rule semantics — both passes either reduce work or reorder commutative siblings. NOT is preserved unchanged (single-child by design); group operators are sorted only when explicitly `and` / `or`.
|
||||
|
||||
## Features
|
||||
|
||||
- Four toggleable passes; every pass defaults to `true`.
|
||||
- Structural hashing via `serializer.hashRule` for dedupe equality.
|
||||
- `RuleStats` analytics: `totalRules`, `maxDepth`, `fields`, `hasAsync`, `estimatedCost`.
|
||||
- `OptimizeResult` reports `before` and `after` stats so callers can measure savings.
|
||||
- Per-operator cost estimates favour short-circuiting cheap conditions first.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { and, condition } from "@suaveplan/rules/builder";
|
||||
import { optimizeRule } from "@suaveplan/rules/optimizer";
|
||||
|
||||
const r = and(
|
||||
and(condition("a", "equals", 1), condition("b", "equals", 2)),
|
||||
condition("c", "equals", 3),
|
||||
);
|
||||
const { rule: optimised, before, after } = optimizeRule(r);
|
||||
// optimised is the flat 3-child AND; before.totalRules > after.totalRules
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { and, condition } from "@suaveplan/rules/builder";
|
||||
import {
|
||||
analyzeRule,
|
||||
findFields,
|
||||
optimizeRule,
|
||||
} from "@suaveplan/rules/optimizer";
|
||||
|
||||
const policy = and(
|
||||
condition("region", "matches", "^US|CA$"), // cost 10
|
||||
condition("age", "greaterThanOrEqual", 18), // cost 1
|
||||
condition("verified", "equals", true), // cost 1
|
||||
);
|
||||
|
||||
// Only run the sort pass — keep nested structure intact
|
||||
const { rule } = optimizeRule(policy, {
|
||||
flatten: false,
|
||||
dedupe: false,
|
||||
simplify: false,
|
||||
sort: true,
|
||||
});
|
||||
|
||||
const stats = analyzeRule(rule);
|
||||
console.log(stats); // { totalRules: 4, maxDepth: 1, fields: [...], hasAsync: false, estimatedCost: 12 }
|
||||
console.log(findFields(rule)); // ["region", "age", "verified"]
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `optimizeRule(rule, options?)` → `OptimizeResult`
|
||||
|
||||
Runs the configured passes. Returns `{ rule, before, after }` where stats are from `analyzeRule`.
|
||||
|
||||
### `interface OptimizeOptions`
|
||||
|
||||
| Option | Default | Effect |
|
||||
|---|---|---|
|
||||
| `flatten` | `true` | Collapse same-operator nested logicals (AND-of-AND, OR-of-OR). |
|
||||
| `dedupe` | `true` | Drop structurally-equal siblings (uses `hashRule`). |
|
||||
| `simplify` | `true` | Unwrap single-child AND/OR (NOT is preserved). |
|
||||
| `sort` | `true` | Reorder AND/OR children by ascending estimated cost. |
|
||||
|
||||
### Analytics
|
||||
|
||||
- `analyzeRule(rule)` → `RuleStats`.
|
||||
- `estimateCost(rule)` → number — sum of per-node costs.
|
||||
- `findFields(rule)` → list of distinct field paths touched.
|
||||
- `hasAsyncComponents(rule)` → `true` when any node is a `CustomRule` or `ComputedRule`.
|
||||
- `countRules(rule)` → total node count.
|
||||
- `isOptimisationStable(rule)` → `true` when `optimizeRule(rule).rule` hashes the same as the input.
|
||||
|
||||
### Cost model
|
||||
|
||||
| Node | Cost |
|
||||
|---|---|
|
||||
| `equals` / `notEquals` / `strict*` / `gt` / `gte` / `lt` / `lte` / `isNull` / `isNotNull` / `exists` / `notExists` | 1 |
|
||||
| `matches` / `notMatches` | 10 |
|
||||
| `containsAll` / `containsAny` / `overlaps` | 5 |
|
||||
| every other condition operator | 2 |
|
||||
| `custom` / `computed` | 20 |
|
||||
| `logical` / `group` | 0 (cost lives on children) |
|
||||
|
||||
Per-node costs are constants; the model assumes no live profiling of operator throughput. Callers that have measured workloads can build their own cost model and reorder explicitly via the builder.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The dedupe pass canonicalises children by `hashRule` so commutative-order-different but structurally-identical children collapse together. The first occurrence wins.
|
||||
- Sort is stable per the JS spec; equal-cost children preserve input order.
|
||||
- The `simplify` pass intentionally does NOT unwrap `not` because a single-child NOT is the only valid form — collapsing it would lose the inversion. Similarly, single-child groups are not unwrapped because their `name` is a contract for callers.
|
||||
- `isOptimisationStable` is intended for build-time CI checks: rules that hash differently before vs after optimisation indicate either dead code in the rule tree or an opportunity to commit the optimised form back into source.
|
||||
- Field discovery (`findFields`) walks both `ConditionRule.field` and `ComputedRule.field`. Group `name` is NOT considered a field.
|
||||
@@ -0,0 +1,81 @@
|
||||
# OTel instrument surface — `@suaveplan/rules`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Centralises the OpenTelemetry meter, tracer, and pre-declared instruments consumed across the rules engine, validators, action executor, event bus, and serializer. Instruments are created lazily on first access against the global `MeterProvider` and `TracerProvider`, which keeps the package usable with or without an SDK installed and means no module-level side effects fire at import time. Tests install an in-memory `MeterProvider` per the AGENTS.md §13 row 5 rule ("OTel instruments emitted from production paths, asserted with a real in-memory `MeterProvider`") and read counters / histograms back via the SDK's reader API; no `vi.mock` is required or permitted.
|
||||
|
||||
The instrument names are the canonical surface declared in the OpenSpec spec delta. They are stable across minor versions of the package and act as a contract for downstream dashboards and alerts.
|
||||
|
||||
## Features
|
||||
|
||||
- `getInstruments()` — returns a frozen-shape `RulesInstruments` record on first call, then returns the cached reference on every subsequent call (lifecycle dedup — see AGENTS.md §13 row 6).
|
||||
- `resetInstruments()` — test-only helper that drops the cache so the next `getInstruments()` rebuilds against a freshly-installed provider. Production code must never call it.
|
||||
- Tracer + 10 instruments covering engine evaluate / depth / operator-unknown, action execute / executions, event emission, validator validate / validations, and serializer duration.
|
||||
- No SDK dependency: the package depends only on `@opentelemetry/api`. When no SDK is installed the no-op meter is used and the instruments still satisfy the contract.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { getInstruments } from "@suaveplan/rules/otel";
|
||||
|
||||
const { engineEvaluateDuration, engineEvaluationsTotal } = getInstruments();
|
||||
const start = performance.now();
|
||||
// ... evaluate ...
|
||||
const elapsed = performance.now() - start;
|
||||
engineEvaluateDuration.record(elapsed);
|
||||
engineEvaluationsTotal.add(1, { outcome: "pass" });
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { MeterProvider, InMemoryMetricReader } from "@opentelemetry/sdk-metrics";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import { getInstruments, resetInstruments } from "@suaveplan/rules/otel";
|
||||
|
||||
const reader = new InMemoryMetricReader();
|
||||
const provider = new MeterProvider({ readers: [reader] });
|
||||
metrics.setGlobalMeterProvider(provider);
|
||||
resetInstruments(); // force the cache to pick up the fresh provider
|
||||
|
||||
const { engineEvaluationsTotal } = getInstruments();
|
||||
engineEvaluationsTotal.add(1, { outcome: "pass" });
|
||||
|
||||
const { resourceMetrics } = await reader.collect();
|
||||
// assert against resourceMetrics.scopeMetrics[0].metrics ...
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `interface RulesInstruments`
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `tracer` | `Tracer` | Package-scoped tracer used for spans. |
|
||||
| `engineEvaluateDuration` | `Histogram` | Engine `evaluate*` wall-time in ms. |
|
||||
| `engineEvaluationsTotal` | `Counter` | Engine evaluation count, labelled `{outcome}`. |
|
||||
| `engineDepth` | `Histogram` | Max recursion depth reached during an evaluate. |
|
||||
| `operatorUnknownTotal` | `Counter` | Engine hit on an unknown operator name. |
|
||||
| `actionExecuteDuration` | `Histogram` | Action handler wall-time in ms. |
|
||||
| `actionExecutionsTotal` | `Counter` | Action execution count, labelled `{status}`. |
|
||||
| `eventEmittedTotal` | `Counter` | EventBus emissions, labelled `{type}`. |
|
||||
| `validatorValidateDuration` | `Histogram` | Validator wall-time in ms, labelled `{kind}`. |
|
||||
| `validatorValidationsTotal` | `Counter` | Validator invocations, labelled `{kind, outcome}`. |
|
||||
| `serializerDuration` | `Histogram` | Serializer wall-time in ms, labelled `{op}`. |
|
||||
|
||||
### `getInstruments(): RulesInstruments`
|
||||
|
||||
Returns the cached instrument record; constructs it on first call.
|
||||
|
||||
### `resetInstruments(): void`
|
||||
|
||||
Drops the cache. Tests use this between cases to bind to a fresh `MeterProvider`. Production code must never call this — it would force every downstream caller to re-resolve instruments on the next emission.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The `cached` module-local is the only mutable state in the file. It is `null` initially and is set exactly once per process under normal operation (or repeatedly under `resetInstruments()` in tests).
|
||||
- Instrument names are namespaced under `rules.` and `rules_hono.` (the latter lives in the sibling package). Cardinality is kept low: outcomes are bounded enums (`pass | fail | aborted | error`) and `kind` / `op` enums are documented per call site.
|
||||
- Re-importing the module after `resetInstruments()` is safe because the cache lives in module scope (ES module specifier identity guarantees one instance per process).
|
||||
@@ -0,0 +1,98 @@
|
||||
# Serializer — `@suaveplan/rules/serializer`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
JSON-based serializer for the `Rule` discriminated union and `Ruleset`. Rules round-trip through compact or pretty JSON, with a documented placeholder strategy for function-valued fields (`CustomRule.validate`, `ComputedRule.compute`). On deserialization, callers supply name-keyed resolvers (`customResolvers`, `computeResolvers`) that the serializer wires back into the reconstructed tree — missing resolvers raise `RULES.DESERIALIZE_FAILED` so callers can detect partial serialisation up front.
|
||||
|
||||
The module also exposes structural utilities — `cloneRule` (deep copy via round-trip), `hashRule` (commutative-order-insensitive FNV-1a hash), `areRulesEqual` (hash-based equality), and `validateSerializedRule` (boundary Zod validator).
|
||||
|
||||
## Features
|
||||
|
||||
- `serializeRule` / `serializeRuleToObject` / `serializeRuleset` / `serializeRulesetToObject` — JSON / object forms.
|
||||
- `deserializeRule` / `deserializeRuleFromObject` / `deserializeRuleset` / `deserializeRulesetFromObject` — accept JSON or object input.
|
||||
- `FUNCTION_PLACEHOLDER` constant exposed as the literal marker substituted for `validate` / `compute`.
|
||||
- `cloneRule` produces a structurally-equal but distinct rule tree.
|
||||
- `hashRule` produces a stable hash that is invariant under AND/OR/group child reordering.
|
||||
- `areRulesEqual` is the hash-based equality.
|
||||
- `validateSerializedRule` Zod-validates a `SerializedRule` shape; raises `RULES.SERIALIZE_FAILED` on rejection.
|
||||
- OTel histogram `rules.serializer.duration_ms{op}` emitted for both `serialize` and `deserialize`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { condition } from "@suaveplan/rules/builder";
|
||||
import { deserializeRule, serializeRule } from "@suaveplan/rules/serializer";
|
||||
|
||||
const rule = condition("age", "greaterThanOrEqual", 18);
|
||||
const json = serializeRule(rule);
|
||||
const back = deserializeRule(json);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { rule } from "@suaveplan/rules/builder";
|
||||
import {
|
||||
deserializeRuleset,
|
||||
serializeRuleset,
|
||||
} from "@suaveplan/rules/serializer";
|
||||
|
||||
const policy = rule()
|
||||
.field("age").greaterThanOrEqual(18)
|
||||
.and()
|
||||
.custom("kyc-cleared", async (data: { id: string }) => verifyKyc(data.id))
|
||||
.buildRuleset("rs-onboarding", "Onboarding");
|
||||
|
||||
const json = serializeRuleset(policy, { pretty: true });
|
||||
|
||||
const back = deserializeRuleset(json, {
|
||||
customResolvers: {
|
||||
"kyc-cleared": async (data: unknown) =>
|
||||
verifyKyc((data as { id: string }).id),
|
||||
},
|
||||
});
|
||||
|
||||
declare function verifyKyc(id: string): Promise<boolean>;
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Serialization
|
||||
|
||||
- `serializeRuleToObject(rule)` → `SerializedRule`
|
||||
- `serializeRule(rule, options?)` → `string` (pretty option indents)
|
||||
- `serializeRulesetToObject(ruleset)` → `SerializedRuleset`
|
||||
- `serializeRuleset(ruleset, options?)` → `string`
|
||||
|
||||
### Deserialization
|
||||
|
||||
- `deserializeRuleFromObject(input, options?)` → `Rule`
|
||||
- `deserializeRule(json, options?)` → `Rule`
|
||||
- `deserializeRulesetFromObject(input, options?)` → `Ruleset`
|
||||
- `deserializeRuleset(json, options?)` → `Ruleset`
|
||||
|
||||
`options.customResolvers: Record<string, validator>` — keyed by `CustomRule.name`. `options.computeResolvers: Record<string, computeFn>` — keyed by `ComputedRule.field`.
|
||||
|
||||
### Structural utilities
|
||||
|
||||
- `cloneRule(rule)` → `Rule` — deep copy via round-trip.
|
||||
- `hashRule(rule)` → `string` — FNV-1a (8 hex chars). Commutative-order-insensitive for AND / OR / group children.
|
||||
- `areRulesEqual(a, b)` → `boolean` — hash equality.
|
||||
- `validateSerializedRule(input)` → `SerializedRule` — Zod validator; raises `RULES.SERIALIZE_FAILED` on bad input.
|
||||
|
||||
### Errors
|
||||
|
||||
- `RULES.SERIALIZE_FAILED` — unknown rule type at serialize time, or `validateSerializedRule` schema rejection.
|
||||
- `RULES.DESERIALIZE_FAILED` — bad JSON, schema rejection, missing resolver, or malformed ruleset.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Function-valued fields use the literal `"__suaveplan_rules_function__"` placeholder. Callers MUST supply a name-keyed resolver to reconstruct them — there is no implicit fallback, by design: silently dropping a custom validator would change rule semantics in production.
|
||||
- `hashRule` canonicalises the rule tree by sorting children of `and`/`or`/`group` operators by their JSON representation before serialising. This means `and(a, b)` and `and(b, a)` hash to the same value, but order matters for `not` (which has exactly one child) and for sequence-sensitive future operators.
|
||||
- The Zod schema for `SerializedRule` is a `z.discriminatedUnion` on `type`. The `value` field on conditions is `z.unknown()` because legitimate rule values include arbitrary JSON.
|
||||
- The serializer is intentionally narrow on metadata: `RuleMetadata` is passed through with `passthrough()` so callers that extend it (e.g. with `tenant: string` field) survive round-trip without recompiling the schema.
|
||||
- FNV-1a is sufficient for a structural hash (8 hex chars, ~32 bits, low collision rate for the rule sizes we expect). Cryptographic-strength hashing is not the goal; a stable, fast equality check is.
|
||||
- The OTel histogram is recorded once per top-level entrypoint (`serializeRuleToObject` / `deserializeRuleFromObject`) rather than per-recursive-call to keep cardinality manageable.
|
||||
@@ -0,0 +1,103 @@
|
||||
# CustomValidator — `@suaveplan/rules/validators/custom`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`CustomValidator` is a single-purpose, named validator backed by a predicate function. Validators are registered against a `ValidatorRegistry` (so they can be looked up by name from caller schemas, plugins, and middleware) and exposed through 11 built-in instances (`emailValidator`, `urlValidator`, `uuidValidator`, `isoDateValidator`, `creditCardValidator`, `phoneValidator`, `strongPasswordValidator`, `alphanumericValidator`, `hexColorValidator`, `slugValidator`, `jsonValidator`) plus 5 parameterised factories (`enumValidator`, `lengthValidator`, `patternValidator`, `rangeValidator`, `asyncValidator`) and one composer (`composeValidators`).
|
||||
|
||||
The credit-card validator implements the **Luhn checksum** (mod-10 / mod-N) per ISO/IEC 7812-1 — every other validator that needs structural shape uses a regex plus a tightening predicate.
|
||||
|
||||
## Features
|
||||
|
||||
- `CustomValidator<T>` class with `validate(value, context?)` returning `ValidationResult<T>` and `isValid(value)` returning `boolean`.
|
||||
- `ValidatorRegistry` with `register`, `get`, `mustGet` (raises `RULES.VALIDATOR_NOT_FOUND`), `has`, `names`, `unregister`.
|
||||
- 11 built-in validators ready for use.
|
||||
- 5 parameterised factories.
|
||||
- `composeValidators` for short-circuit sequential composition.
|
||||
- `defaultValidatorRegistry` pre-populated with every built-in; `createDefaultValidatorRegistry()` for callers that need isolation.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
createValidator,
|
||||
emailValidator,
|
||||
} from "@suaveplan/rules/validators/custom";
|
||||
|
||||
const result = await emailValidator.validate("ada@example.com");
|
||||
result.valid; // true
|
||||
|
||||
const custom = createValidator<string>({
|
||||
name: "ada-only",
|
||||
validate: (v) => (v === "ada" ? true : "must equal 'ada'"),
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
asyncValidator,
|
||||
composeValidators,
|
||||
createDefaultValidatorRegistry,
|
||||
creditCardValidator,
|
||||
rangeValidator,
|
||||
} from "@suaveplan/rules/validators/custom";
|
||||
|
||||
const registry = createDefaultValidatorRegistry();
|
||||
const enterpriseAge = composeValidators([
|
||||
rangeValidator(18, 100),
|
||||
asyncValidator<number>(async (value) => {
|
||||
const allowed = await isMinimumAgeForRegion(value);
|
||||
return allowed ? true : "below regional minimum age";
|
||||
}),
|
||||
]);
|
||||
registry.register(enterpriseAge);
|
||||
|
||||
const result = await creditCardValidator.validate("4111111111111111");
|
||||
if (!result.valid) console.warn(result.errors[0]?.message);
|
||||
|
||||
declare function isMinimumAgeForRegion(age: number): Promise<boolean>;
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class CustomValidator<T>`
|
||||
|
||||
- `new CustomValidator(definition)` — empty `name` raises `RULES.VALIDATOR_INVALID`.
|
||||
- `validate(value, context?)` → `Promise<ValidationResult<T>>`.
|
||||
- `isValid(value, context?)` → `Promise<boolean>` convenience.
|
||||
|
||||
### `class ValidatorRegistry`
|
||||
|
||||
Standard register/get/mustGet/has/names/unregister surface.
|
||||
|
||||
### Built-in validators
|
||||
|
||||
`emailValidator`, `urlValidator`, `uuidValidator`, `isoDateValidator`, `creditCardValidator` (Luhn), `phoneValidator`, `strongPasswordValidator` (length 8 + upper + lower + digit + symbol), `alphanumericValidator`, `hexColorValidator`, `slugValidator`, `jsonValidator`.
|
||||
|
||||
### Parameterised factories
|
||||
|
||||
- `enumValidator<T>(values)` — `value ∈ values`.
|
||||
- `lengthValidator(min, max)` — string length window.
|
||||
- `patternValidator(re, message?)` — string regex match.
|
||||
- `rangeValidator(min, max)` — numeric range.
|
||||
- `asyncValidator(fn)` — async predicate wrapper.
|
||||
|
||||
### `composeValidators(validators)` → `CustomValidator`
|
||||
|
||||
Returns a synthetic `"composed"` validator that runs each child in order, short-circuiting on the first failure.
|
||||
|
||||
### `createDefaultValidatorRegistry()` / `defaultValidatorRegistry`
|
||||
|
||||
Fresh and process-global registries pre-populated with every built-in.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The Luhn algorithm in `creditCardValidator` requires the digit-only input length to fall in `[12, 19]` (ISO 7812-1). Non-string inputs return `false` immediately.
|
||||
- `strongPasswordValidator` returns a specific per-failure message (`min length 8`, `needs uppercase`, etc.) so callers can present targeted feedback. Tests assert each branch.
|
||||
- `composeValidators` constructs a top-level `CustomValidator` whose `validate` synthesises an aggregate message from the first child failure. The synthetic validator name `"composed"` is suitable as-is for OTel labels.
|
||||
- The parameterised factories are functions rather than classes so callers can hold references to identical instances when needed (`const v = lengthValidator(1, 10); /* reused */`).
|
||||
- `defaultValidatorRegistry` is mutable. Production code that needs isolation between requests / tenants should construct its own via `createDefaultValidatorRegistry()`.
|
||||
@@ -0,0 +1,88 @@
|
||||
# FieldValidator — `@suaveplan/rules/validators/field`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`FieldValidator` is a chainable, immutable-per-call validator for a single value. Every `withX` method returns a fresh validator with the new stage appended, so preset chains can be shared across requests without aliasing concerns. `validateSync(value)` runs purely-sync chains; `validate(value, options?)` returns a Promise and threads `AbortSignal` through every stage. The module ships 12 preset chains covering the common shapes (email, password, username, phone, URL, UUID, date, integer, age, currency, percentage, positive number).
|
||||
|
||||
The chain is the natural fit when validating one value at a time (form field, query parameter, header). For object-tree validation, prefer `SchemaValidator`; for plug-in-style reusable validators with a registry, prefer `CustomValidator`.
|
||||
|
||||
## Features
|
||||
|
||||
- Immutable chain — every `.withX` returns a new `FieldValidator`.
|
||||
- Sync + async stages; `validateSync` throws `RULES.VALIDATOR_INVALID` when invoked on an async chain.
|
||||
- 9 builder methods: `required`, `minLength`, `maxLength`, `min`, `max`, `pattern`, `oneOf`, `custom`, `customAsync`.
|
||||
- 12 preset factories.
|
||||
- OTel histogram (`rules.validator.validate.duration_ms{kind:"field"}`) + counter (`rules.validator.validations_total{kind:"field",outcome}`).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { field } from "@suaveplan/rules/validators/field";
|
||||
|
||||
const validator = field().required().minLength(3).maxLength(20);
|
||||
const result = await validator.validate("ada");
|
||||
result.valid; // true
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
field,
|
||||
passwordFieldValidator,
|
||||
usernameFieldValidator,
|
||||
} from "@suaveplan/rules/validators/field";
|
||||
|
||||
// Compose a preset with caller-specific extensions
|
||||
const adminPasswordValidator = passwordFieldValidator()
|
||||
.minLength(12, "admin passwords must be at least 12 chars")
|
||||
.customAsync(async (value) => {
|
||||
const breached = await checkHibp(value as string);
|
||||
return breached ? "password appears in known-breach corpus" : true;
|
||||
});
|
||||
|
||||
const username = usernameFieldValidator();
|
||||
expect((await username.validate("ada42")).valid).toBe(true);
|
||||
|
||||
declare function checkHibp(password: string): Promise<boolean>;
|
||||
declare const expect: { (received: unknown): { toBe(v: unknown): void } };
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class FieldValidator`
|
||||
|
||||
- `FieldValidator.create()` / `createFieldValidator()` / `field()` — entry points.
|
||||
- `required(message?)`, `minLength(min, message?)`, `maxLength(max, message?)`, `min(min, message?)`, `max(max, message?)`, `pattern(re, message?)`, `oneOf(values, message?)`, `custom(fn, message?)`, `customAsync(fn, message?)` — each returns a new `FieldValidator`.
|
||||
- `validate(value, options?)` → `Promise<FieldValidatorResult>` where `FieldValidatorResult = { valid: boolean; errors: readonly string[] }`. Honours `options.signal`.
|
||||
- `validateSync(value)` → `FieldValidatorResult`. Throws `RulesError(RULES.VALIDATOR_INVALID)` when the chain has any async stage.
|
||||
- `isAsync()` → `true` when the chain contains any async stage.
|
||||
|
||||
### Preset chains
|
||||
|
||||
| Factory | Behaviour |
|
||||
|---|---|
|
||||
| `emailFieldValidator()` | required + email regex |
|
||||
| `urlFieldValidator()` | required + `^https?://` |
|
||||
| `uuidFieldValidator()` | required + RFC-4122 8-4-4-4-12 |
|
||||
| `phoneFieldValidator()` | required + `^\+?[\d\s\-().]{7,}$` |
|
||||
| `usernameFieldValidator()` | required + `^[a-z0-9_]{3,32}$` (case-insensitive) |
|
||||
| `passwordFieldValidator()` | required + minLength 8 + upper + lower + digit |
|
||||
| `dateFieldValidator()` | `instanceof Date && !NaN` |
|
||||
| `integerFieldValidator()` | `Number.isInteger` |
|
||||
| `ageValidator()` | required + range 0..150 |
|
||||
| `currencyValidator()` | required + non-negative number |
|
||||
| `percentageValidator()` | required + range 0..100 |
|
||||
| `positiveNumberValidator()` | required + > 0 |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Every stage runs against the original input value (not the previous stage's output) — `FieldValidator` is a multi-predicate aggregator, not a transform pipeline. For transforms, use `SchemaValidator` with the `transforms` field.
|
||||
- `validate` always records every stage that fails — it does not short-circuit. This makes the error list complete in a single pass, which matches the convention used by Zod and Joi.
|
||||
- A pre-aborted signal short-circuits the loop and records the synthetic `"validation aborted"` error. Subsequent stages do not run.
|
||||
- The async-stage detector (`isAsync`) is `O(stages)` and runs once on `validateSync`. Callers that want fast-path sync validation should check `isAsync()` first.
|
||||
- `custom` accepts a fn returning `true | false | string`. `false` falls back to either the supplied `message` or the default `"custom validation failed"`; a string return is treated as the error message verbatim.
|
||||
- Preset chains are factory functions (not module-level constants) so consumers get an independent instance per call and can chain further without affecting other callers.
|
||||
@@ -0,0 +1,129 @@
|
||||
# SchemaValidator — `@suaveplan/rules/validators/schema`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`SchemaValidator` validates a `Record<string, unknown>` against a JSON-schema-like `Schema` definition. It covers the 8 primitive `FieldType`s (`string`, `number`, `integer`, `boolean`, `object`, `array`, `date`, `datetime`, `any`), 7 string formats (`email`, `url`, `uuid`, `date`, `datetime`, `ipv4`, `ipv6`, `regex`), 5 transforms (`trim`, `lowercase`, `uppercase`, `parseNumber`, `parseDate`), `enum` checks, `min`/`max`/`minLength`/`maxLength`/`pattern` constraints, recursive nested objects, recursive array items, and caller-supplied custom validators keyed by field path.
|
||||
|
||||
Every input schema is Zod-validated at construction — malformed schemas raise `RulesError(RULES.SCHEMA_INVALID)`. The `validate` method returns a `ValidationResult` rather than throwing for any normal failure mode (missing required, wrong type, format miss, custom-refinement failure). OTel histogram and counter fire per invocation.
|
||||
|
||||
## Features
|
||||
|
||||
- 8 field types with strict matching (e.g. `integer` rejects non-integer numbers).
|
||||
- 7 format checks via shared regex map.
|
||||
- 5 transforms applied before validation; non-applicable transforms (e.g. `lowercase` on a number) are no-ops.
|
||||
- Nested objects + arrays validated recursively with structured paths (`user.profile.name`, `items[3].sku`).
|
||||
- `enum` membership checks.
|
||||
- `min` / `max` / `minLength` / `maxLength` / `pattern` constraints.
|
||||
- Custom validators registered by field path; return `true`, `false`, or a string error message.
|
||||
- Zod-validated `Schema` at construction → `RULES.SCHEMA_INVALID` on rejection.
|
||||
- Pre-aborted `AbortSignal` short-circuits the recursion with `RULES.SCHEMA_INVALID`.
|
||||
- Returns `{ valid, errors, value }` where `value` carries the transformed data tree.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { SchemaValidator } from "@suaveplan/rules/validators/schema";
|
||||
|
||||
const validator = new SchemaValidator({
|
||||
email: { type: "string", required: true, format: "email" },
|
||||
age: { type: "integer", min: 18 },
|
||||
});
|
||||
|
||||
const result = await validator.validate({ email: "ada@example.com", age: 30 });
|
||||
result.valid; // true
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { SchemaValidator } from "@suaveplan/rules/validators/schema";
|
||||
|
||||
const validator = new SchemaValidator(
|
||||
{
|
||||
username: {
|
||||
type: "string",
|
||||
required: true,
|
||||
minLength: 3,
|
||||
maxLength: 20,
|
||||
transforms: ["trim", "lowercase"],
|
||||
},
|
||||
profile: {
|
||||
type: "object",
|
||||
properties: {
|
||||
bio: { type: "string", maxLength: 240 },
|
||||
tags: {
|
||||
type: "array",
|
||||
items: { type: "string", minLength: 2 },
|
||||
},
|
||||
},
|
||||
},
|
||||
registeredAt: { type: "date", transforms: ["parseDate"], required: true },
|
||||
},
|
||||
{
|
||||
customValidators: {
|
||||
username: async (value) =>
|
||||
typeof value === "string" && !value.startsWith("admin")
|
||||
? true
|
||||
: "reserved username prefix",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const result = await validator.validate({
|
||||
username: " ADA ",
|
||||
profile: { bio: "Mathematician", tags: ["math", "logic"] },
|
||||
registeredAt: "1815-12-10",
|
||||
});
|
||||
|
||||
if (!result.valid) {
|
||||
for (const err of result.errors) console.warn(err.path, err.code, err.message);
|
||||
}
|
||||
console.log(result.value); // { username: "ada", profile: {...}, registeredAt: Date }
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class SchemaValidator`
|
||||
|
||||
- `new SchemaValidator(schema, options?)` — Zod-validates the schema; raises `RULES.SCHEMA_INVALID` on rejection.
|
||||
- `options.customValidators: Record<string, CustomValidatorFn>` — keyed by field path. Each validator receives `(value, ValidatorContext)` and returns `true`, `false`, or a `string` error.
|
||||
- `validate(data, options?)` → `Promise<ValidationResult>`. `options.signal` short-circuits.
|
||||
|
||||
### `interface Schema`
|
||||
|
||||
`Record<string, FieldSchema>` where `FieldSchema` carries `type`, `required`, `format`, `min`, `max`, `minLength`, `maxLength`, `pattern`, `enum`, `items` (recursive), `properties` (recursive), `transforms`, `default`.
|
||||
|
||||
### Format dispatch
|
||||
|
||||
| Format | Regex |
|
||||
|---|---|
|
||||
| `email` | `^[^\s@]+@[^\s@]+\.[^\s@]+$` |
|
||||
| `url` | `^https?://.+` (case-insensitive) |
|
||||
| `uuid` | RFC-4122 8-4-4-4-12 hex |
|
||||
| `date` | `^\d{4}-\d{2}-\d{2}$` |
|
||||
| `datetime` | ISO 8601 prefix `YYYY-MM-DDTHH:MM:SS` |
|
||||
| `ipv4` | dotted-quad shape |
|
||||
| `ipv6` | hex-and-colons (not strictly compliant; pre-filter only) |
|
||||
| `regex` | accepts anything (`.*`) — placeholder slot for future tightening |
|
||||
|
||||
### Transform dispatch
|
||||
|
||||
| Transform | Applies when | Effect |
|
||||
|---|---|---|
|
||||
| `trim` | string | `value.trim()` |
|
||||
| `lowercase` | string | `value.toLowerCase()` |
|
||||
| `uppercase` | string | `value.toUpperCase()` |
|
||||
| `parseNumber` | string | `Number(value)` if not `NaN`; else leave |
|
||||
| `parseDate` | string | `new Date(value)` if valid; else leave |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `validateField` is recursive. Pre-aborted signals are checked at each top-level field boundary; nested recursion inherits the signal via the `ValidatorContext`.
|
||||
- The transformed value is accumulated into a parallel `transformed` tree mirroring the schema shape. Callers consume `result.value` to get the post-transform view of the input.
|
||||
- `format: "regex"` is a placeholder that always matches. The field-level `pattern` option is the way to assert a specific regex; future hardening can replace the format slot with a per-pattern compile-cache.
|
||||
- The validator deliberately does NOT short-circuit on first error — it accumulates every issue across the tree so callers can present them all at once. Tests assert specific codes against the first error for clarity; production callers should iterate `result.errors`.
|
||||
- The CustomValidatorFn return type `boolean | string | Promise<boolean | string>` matches the convention used by zxcvbn and Joi: a string return is treated as a custom error message, otherwise a falsy return generates the default message.
|
||||
- Type matching is strict: `number` rejects `NaN`; `date` / `datetime` accept only `Date` instances with valid `getTime()`. The `parseDate` transform converts ISO strings into `Date` before the type check fires.
|
||||
Reference in New Issue
Block a user