Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

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

View File

@@ -0,0 +1,93 @@
{
"name": "@suaveplan/rules",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Rule engine and validation system — async evaluator, fluent builder, 30 operators, schema/field/custom validators, serializer, optimizer; Zod boundaries, OTel surface, AbortSignal throughout",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
},
"./engine": {
"import": { "types": "./dist/engine/index.d.ts", "default": "./dist/engine/index.js" }
},
"./builder": {
"import": { "types": "./dist/builder/index.d.ts", "default": "./dist/builder/index.js" }
},
"./context": {
"import": { "types": "./dist/context/index.d.ts", "default": "./dist/context/index.js" }
},
"./actions": {
"import": { "types": "./dist/actions/index.d.ts", "default": "./dist/actions/index.js" }
},
"./events": {
"import": { "types": "./dist/events/index.d.ts", "default": "./dist/events/index.js" }
},
"./operators": {
"import": { "types": "./dist/operators/index.d.ts", "default": "./dist/operators/index.js" }
},
"./validators/schema": {
"import": { "types": "./dist/validators/schema/index.d.ts", "default": "./dist/validators/schema/index.js" }
},
"./validators/field": {
"import": { "types": "./dist/validators/field/index.d.ts", "default": "./dist/validators/field/index.js" }
},
"./validators/custom": {
"import": { "types": "./dist/validators/custom/index.d.ts", "default": "./dist/validators/custom/index.js" }
},
"./serializer": {
"import": { "types": "./dist/serializer/index.d.ts", "default": "./dist/serializer/index.js" }
},
"./optimizer": {
"import": { "types": "./dist/optimizer/index.d.ts", "default": "./dist/optimizer/index.js" }
},
"./types": {
"import": { "types": "./dist/types/index.d.ts", "default": "./dist/types/index.js" }
},
"./errors": {
"import": { "types": "./dist/errors/index.d.ts", "default": "./dist/errors/index.js" }
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/error": "workspace:^",
"@suaveplan/events": "workspace:^",
"@suaveplan/types": "workspace:^",
"@opentelemetry/api": "catalog:observability",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"mitata": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": ["dist", "src", "README.md"]
}

View File

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

View File

@@ -0,0 +1,362 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { context } from "../context/index.js";
import type { ActionHandler } from "../types/index.js";
import {
Action,
ActionRegistry,
builtInHandlers,
createAction,
createActionExecutor,
createActionRegistry,
validateActionConfig,
} from "./actions.js";
describe("ActionRegistry", () => {
it("registers, retrieves, lists, removes", { timeout: 5000 }, () => {
const r = createActionRegistry();
const h: ActionHandler = async () => "ok";
r.register("h", h);
expect(r.has("h")).toBe(true);
expect(r.get("h")).toBe(h);
expect(r.names()).toEqual(["h"]);
expect(r.unregister("h")).toBe(true);
expect(r.unregister("h")).toBe(false);
});
it("rejects empty name", { timeout: 5000 }, () => {
const r = new ActionRegistry();
expect(() => r.register("", async () => undefined)).toThrow(
/RULES\.ACTION_HANDLER_FAILED/
);
});
});
describe("validateActionConfig", () => {
it("accepts a valid config", { timeout: 5000 }, () => {
expect(validateActionConfig({ handler: "h" })).toEqual({
handler: "h",
});
});
it("rejects empty handler name", { timeout: 5000 }, () => {
expect(() => validateActionConfig({ handler: "" })).toThrow(
/RULES\.ACTION_HANDLER_FAILED/
);
});
});
describe("Action.execute", () => {
it("returns success when handler resolves", { timeout: 5000 }, async () => {
const r = createActionRegistry();
r.register("h", async () => 42);
const action = createAction({ handler: "h" }, r);
const result = await action.execute(context({}));
expect(result.status).toBe("success");
expect(result.output).toBe(42);
});
it("returns failed with RULES.ACTION_NOT_FOUND when handler missing", {
timeout: 5000,
}, async () => {
const action = createAction(
{ handler: "missing" },
createActionRegistry()
);
const result = await action.execute(context({}));
expect(result.status).toBe("failed");
expect(result.errorCode).toBe("RULES.ACTION_NOT_FOUND");
});
it("returns failed with RULES.ACTION_ABORTED when signal pre-aborted", {
timeout: 5000,
}, async () => {
const r = createActionRegistry();
r.register("h", async () => "ok");
const action = createAction({ handler: "h" }, r);
const controller = new AbortController();
controller.abort();
const result = await action.execute(context({}), {
signal: controller.signal,
});
expect(result.errorCode).toBe("RULES.ACTION_ABORTED");
});
it("returns failed with RULES.ACTION_TIMEOUT when deadline passes", {
timeout: 5000,
}, async () => {
const r = createActionRegistry();
r.register("h", () => new Promise(() => undefined));
const action = createAction({ handler: "h", timeoutMs: 20 }, r);
const result = await action.execute(context({}));
expect(result.errorCode).toBe("RULES.ACTION_TIMEOUT");
});
it("returns failed with RULES.ACTION_ABORTED when caller aborts mid-flight", {
timeout: 5000,
}, async () => {
const r = createActionRegistry();
r.register("h", () => new Promise(() => undefined));
const action = createAction({ handler: "h", timeoutMs: 1000 }, r);
const controller = new AbortController();
setTimeout(() => controller.abort(), 10);
const result = await action.execute(context({}), {
signal: controller.signal,
});
expect(result.errorCode).toBe("RULES.ACTION_ABORTED");
});
it("wraps thrown Error as RULES.ACTION_HANDLER_FAILED", {
timeout: 5000,
}, async () => {
const r = createActionRegistry();
r.register("h", async () => {
throw new Error("boom");
});
const action = createAction({ handler: "h" }, r);
const result = await action.execute(context({}));
expect(result.errorCode).toBe("RULES.ACTION_HANDLER_FAILED");
expect(result.errorMessage).toBe("boom");
});
it("wraps non-Error throws as RULES.ACTION_HANDLER_FAILED", {
timeout: 5000,
}, async () => {
const r = createActionRegistry();
r.register("h", async () => {
const lit: unknown = "string-throw";
throw lit;
});
const action = createAction({ handler: "h" }, r);
const result = await action.execute(context({}));
expect(result.errorMessage).toBe("string-throw");
});
it("Action constructor validates config", { timeout: 5000 }, () => {
expect(
() => new Action({ handler: "" }, createActionRegistry())
).toThrow(/RULES\.ACTION_HANDLER_FAILED/);
});
it("Action.execute uses injected Clock for duration", {
timeout: 5000,
}, async () => {
const r = createActionRegistry();
r.register("h", async () => "ok");
let t = 0;
const clock = () => {
t += 100;
return t;
};
const action = createAction({ handler: "h" }, r);
const result = await action.execute(context({}), { clock });
expect(result.durationMs).toBeGreaterThan(0);
});
});
describe("ActionExecutor.executeSequential", () => {
it("runs actions in order", { timeout: 5000 }, async () => {
const r = createActionRegistry();
r.register("a", async () => "a");
r.register("b", async () => "b");
const executor = createActionExecutor();
const results = await executor.executeSequential(
[
createAction({ handler: "a" }, r),
createAction({ handler: "b" }, r),
],
context({})
);
expect(results.map((rr) => rr.output)).toEqual(["a", "b"]);
});
it("stops on failure unless continueOnError", {
timeout: 5000,
}, async () => {
const r = createActionRegistry();
r.register("fail", async () => {
throw new Error("nope");
});
r.register("never", async () => "ran");
const executor = createActionExecutor();
const results = await executor.executeSequential(
[
createAction({ handler: "fail" }, r),
createAction({ handler: "never" }, r),
],
context({})
);
expect(results[0]?.status).toBe("failed");
expect(results[1]?.status).toBe("skipped");
});
it("continueOnError lets the chain proceed", {
timeout: 5000,
}, async () => {
const r = createActionRegistry();
r.register("fail", async () => {
throw new Error("nope");
});
r.register("after", async () => "ran");
const executor = createActionExecutor();
const results = await executor.executeSequential(
[
createAction({ handler: "fail", continueOnError: true }, r),
createAction({ handler: "after" }, r),
],
context({})
);
expect(results[1]?.status).toBe("success");
});
});
describe("ActionExecutor.executeParallel", () => {
it("runs concurrently with bounded workers", {
timeout: 5000,
}, async () => {
const r = createActionRegistry();
const ticks: number[] = [];
let i = 0;
r.register("slow", async () => {
ticks.push(++i);
await new Promise((resolve) => setTimeout(resolve, 5));
return i;
});
const executor = createActionExecutor();
const actions = [1, 2, 3, 4].map(() =>
createAction({ handler: "slow" }, r)
);
const results = await executor.executeParallel(actions, context({}), {
parallelism: 2,
});
expect(results.length).toBe(4);
expect(results.every((rr) => rr.status === "success")).toBe(true);
});
it("rejects parallelism <= 0", { timeout: 5000 }, async () => {
const r = createActionRegistry();
r.register("h", async () => "ok");
const executor = createActionExecutor();
await expect(
executor.executeParallel(
[createAction({ handler: "h" }, r)],
context({}),
{ parallelism: 0 }
)
).rejects.toThrow(/RULES\.ACTION_HANDLER_FAILED/);
});
it("uses default parallelism when none supplied", {
timeout: 5000,
}, async () => {
const r = createActionRegistry();
r.register("h", async () => "ok");
const executor = createActionExecutor({ parallelism: 2 });
const results = await executor.executeParallel(
[
createAction({ handler: "h" }, r),
createAction({ handler: "h" }, r),
],
context({})
);
expect(results.length).toBe(2);
});
});
describe("builtInHandlers", () => {
it("log uses injected logger", { timeout: 5000 }, async () => {
const messages: string[] = [];
const logger = {
debug: (m: string) => messages.push(`debug:${m}`),
info: (m: string) => messages.push(`info:${m}`),
warn: (m: string) => messages.push(`warn:${m}`),
error: (m: string) => messages.push(`error:${m}`),
};
const handlers = builtInHandlers({ logger });
await handlers.log!({ level: "warn", message: "hi" }, context({}), {
signal: new AbortController().signal,
});
expect(messages).toEqual(["warn:hi"]);
});
it("log defaults to info level + 'log' message", {
timeout: 5000,
}, async () => {
const messages: string[] = [];
const logger = {
debug: () => undefined,
info: (m: string) => messages.push(m),
warn: () => undefined,
error: () => undefined,
};
const handlers = builtInHandlers({ logger });
await handlers.log!({}, context({}), {
signal: new AbortController().signal,
});
expect(messages).toEqual(["log"]);
});
it("log without explicit logger swallows output", {
timeout: 5000,
}, async () => {
const handlers = builtInHandlers();
const r = await handlers.log!({ message: "x" }, context({}), {
signal: new AbortController().signal,
});
expect((r as { message: string }).message).toBe("x");
});
it("set writes to context.data", { timeout: 5000 }, async () => {
const handlers = builtInHandlers();
const ctx = context<{ count?: number }>({});
await handlers.set!({ target: "count", value: 42 }, ctx, {
signal: new AbortController().signal,
});
expect(ctx.data.count).toBe(42);
});
it("set rejects missing target", { timeout: 5000 }, async () => {
const handlers = builtInHandlers();
await expect(
handlers.set!({}, context({}), {
signal: new AbortController().signal,
})
).rejects.toThrow(/RULES\.ACTION_HANDLER_FAILED/);
});
it("delay resolves after ms", { timeout: 5000 }, async () => {
const handlers = builtInHandlers();
const start = performance.now();
const result = await handlers.delay!({ ms: 10 }, context({}), {
signal: new AbortController().signal,
});
expect(performance.now() - start).toBeGreaterThanOrEqual(8);
expect((result as { delayed: number }).delayed).toBe(10);
});
it("delay defaults to 0", { timeout: 5000 }, async () => {
const handlers = builtInHandlers();
const result = await handlers.delay!({}, context({}), {
signal: new AbortController().signal,
});
expect((result as { delayed: number }).delayed).toBe(0);
});
it("delay aborts on signal", { timeout: 5000 }, async () => {
const handlers = builtInHandlers();
const controller = new AbortController();
setTimeout(() => controller.abort(), 5);
await expect(
handlers.delay!({ ms: 1000 }, context({}), {
signal: controller.signal,
})
).rejects.toThrow(/RULES\.ACTION_ABORTED/);
});
it("noop returns undefined", { timeout: 5000 }, async () => {
const handlers = builtInHandlers();
expect(
await handlers.noop!({}, context({}), {
signal: new AbortController().signal,
})
).toBeUndefined();
});
});

View File

@@ -0,0 +1,365 @@
import type { Clock, LoggerLike } from "@suaveplan/types";
import z from "zod";
import {
DEFAULT_ACTION_PARALLELISM,
DEFAULT_ACTION_TIMEOUT_MS,
} from "../constants.js";
import { RulesError } from "../errors/index.js";
import { getInstruments } from "../otel/index.js";
import type {
ActionConfig,
ActionHandler,
ActionResult,
RuleContext,
} from "../types/index.js";
const ACTION_CONFIG_SCHEMA = z
.object({
handler: z.string().min(1),
params: z.record(z.string(), z.unknown()).optional(),
timeoutMs: z.number().int().positive().optional(),
continueOnError: z.boolean().optional(),
})
.strict();
/**
* Validate an `ActionConfig` against its Zod schema. Returns the original
* config on success; raises `RulesError(RULES.ACTION_HANDLER_FAILED)` on
* schema rejection so call sites can wire the error into structured logs.
*/
export function validateActionConfig(config: ActionConfig): ActionConfig {
const parsed = ACTION_CONFIG_SCHEMA.safeParse(config);
if (!parsed.success) {
throw new RulesError(
"RULES.ACTION_HANDLER_FAILED",
"invalid ActionConfig",
{ cause: parsed.error, details: { issues: parsed.error.issues } }
);
}
return config;
}
/**
* Named action registry. Handlers are keyed by name and consumed by
* `ActionExecutor`. Empty names rejected with `RULES.ACTION_HANDLER_FAILED`.
*/
export class ActionRegistry {
private readonly handlers = new Map<string, ActionHandler>();
register(name: string, handler: ActionHandler): void {
if (name === "") {
throw new RulesError(
"RULES.ACTION_HANDLER_FAILED",
"handler name must be non-empty"
);
}
this.handlers.set(name, handler);
}
get(name: string): ActionHandler | undefined {
return this.handlers.get(name);
}
has(name: string): boolean {
return this.handlers.has(name);
}
names(): readonly string[] {
return [...this.handlers.keys()];
}
unregister(name: string): boolean {
return this.handlers.delete(name);
}
}
export function createActionRegistry(): ActionRegistry {
return new ActionRegistry();
}
/**
* Wraps an `ActionConfig` with a lookup against a registry. Returned from
* `createAction`; consumed by `ActionExecutor`.
*/
export class Action {
constructor(
readonly config: ActionConfig,
private readonly registry: ActionRegistry
) {
validateActionConfig(config);
}
async execute(
context: RuleContext,
options: { readonly signal?: AbortSignal; readonly clock?: Clock } = {}
): Promise<ActionResult> {
const handler = this.registry.get(this.config.handler);
const start = nowMs(options.clock);
const out = (
status: ActionResult["status"],
rest: Partial<ActionResult> = {}
): ActionResult => ({
status,
handler: this.config.handler,
durationMs: nowMs(options.clock) - start,
...rest,
});
if (handler === undefined) {
const errorMessage = `action handler not found: ${this.config.handler}`;
recordExecution("failed", nowMs(options.clock) - start);
return out("failed", {
errorMessage,
errorCode: "RULES.ACTION_NOT_FOUND",
});
}
const signal = options.signal;
if (signal?.aborted === true) {
recordExecution("failed", 0);
return out("failed", {
errorMessage: "action aborted before invocation",
errorCode: "RULES.ACTION_ABORTED",
});
}
const timeoutMs = this.config.timeoutMs ?? DEFAULT_ACTION_TIMEOUT_MS;
const controller = new AbortController();
const linkAbort = (): void => controller.abort();
signal?.addEventListener("abort", linkAbort, { once: true });
const timer = setTimeout(() => {
controller.abort();
}, timeoutMs);
try {
const handlerResult = await Promise.race([
Promise.resolve(
handler(this.config.params ?? {}, context, {
signal: controller.signal,
})
),
new Promise<never>((_, reject) => {
controller.signal.addEventListener(
"abort",
() => {
const aborted = signal?.aborted === true;
reject(
new RulesError(
aborted
? "RULES.ACTION_ABORTED"
: "RULES.ACTION_TIMEOUT",
aborted
? "action aborted by caller signal"
: `action timed out after ${timeoutMs}ms`
)
);
},
{ once: true }
);
}),
]);
const duration = nowMs(options.clock) - start;
recordExecution("success", duration);
return {
status: "success",
handler: this.config.handler,
durationMs: duration,
output: handlerResult,
};
} catch (err) {
const duration = nowMs(options.clock) - start;
recordExecution("failed", duration);
if (err instanceof RulesError) {
return out("failed", {
errorMessage: err.message,
errorCode: err.code,
});
}
return out("failed", {
errorMessage: err instanceof Error ? err.message : String(err),
errorCode: "RULES.ACTION_HANDLER_FAILED",
});
} finally {
clearTimeout(timer);
signal?.removeEventListener("abort", linkAbort);
}
}
}
export function createAction(
config: ActionConfig,
registry: ActionRegistry
): Action {
return new Action(config, registry);
}
/**
* Runs actions sequentially or in parallel. `executeSequential` honours
* `continueOnError`: a failure with `continueOnError === false` stops the
* chain and the remaining actions are reported as `status: "skipped"`.
*/
export class ActionExecutor {
constructor(
private readonly defaults: {
readonly clock?: Clock;
readonly parallelism?: number;
} = {}
) {}
async executeSequential(
actions: readonly Action[],
context: RuleContext,
options: { readonly signal?: AbortSignal } = {}
): Promise<readonly ActionResult[]> {
const results: ActionResult[] = [];
let stop = false;
for (const action of actions) {
if (stop) {
results.push({
status: "skipped",
handler: action.config.handler,
durationMs: 0,
});
continue;
}
const result = await action.execute(context, {
...(options.signal !== undefined
? { signal: options.signal }
: {}),
...(this.defaults.clock !== undefined
? { clock: this.defaults.clock }
: {}),
});
results.push(result);
if (
result.status === "failed" &&
action.config.continueOnError !== true
) {
stop = true;
}
}
return results;
}
async executeParallel(
actions: readonly Action[],
context: RuleContext,
options: {
readonly signal?: AbortSignal;
readonly parallelism?: number;
} = {}
): Promise<readonly ActionResult[]> {
const limit =
options.parallelism ??
this.defaults.parallelism ??
DEFAULT_ACTION_PARALLELISM;
if (limit <= 0) {
throw new RulesError(
"RULES.ACTION_HANDLER_FAILED",
"parallelism must be > 0"
);
}
const results: ActionResult[] = new Array(actions.length);
let cursor = 0;
const workers: Promise<void>[] = [];
const launch = async (): Promise<void> => {
while (cursor < actions.length) {
const index = cursor++;
const action = actions[index] as Action;
results[index] = await action.execute(context, {
...(options.signal !== undefined
? { signal: options.signal }
: {}),
...(this.defaults.clock !== undefined
? { clock: this.defaults.clock }
: {}),
});
}
};
for (let i = 0; i < Math.min(limit, actions.length); i++) {
workers.push(launch());
}
await Promise.all(workers);
return results;
}
}
export function createActionExecutor(
defaults: { readonly clock?: Clock; readonly parallelism?: number } = {}
): ActionExecutor {
return new ActionExecutor(defaults);
}
/**
* Built-in action handler factories. The `log` handler accepts an injectable
* `LoggerLike` because `console.log` is banned in genesis `src/`.
*/
export function builtInHandlers(
deps: { readonly logger?: LoggerLike } = {}
): Record<string, ActionHandler> {
const logger = deps.logger ?? noopLogger();
return {
log: async (params) => {
const rawLevel = params["level"];
const level =
typeof rawLevel === "string" && rawLevel in logger
? (rawLevel as "debug" | "info" | "warn" | "error")
: "info";
const rawMessage = params["message"];
const message = typeof rawMessage === "string" ? rawMessage : "log";
logger[level](message);
return { level, message };
},
set: async (params, context) => {
const target = params["target"] as string | undefined;
if (target === undefined || target === "") {
throw new RulesError(
"RULES.ACTION_HANDLER_FAILED",
"set: missing 'target' param"
);
}
(context.data as Record<string, unknown>)[target] = params["value"];
return { target, value: params["value"] };
},
delay: async (params, _context, options) => {
const rawMs = params["ms"];
const ms = typeof rawMs === "number" ? rawMs : 0;
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(resolve, ms);
options.signal.addEventListener(
"abort",
() => {
clearTimeout(timer);
reject(
new RulesError(
"RULES.ACTION_ABORTED",
"delay aborted"
)
);
},
{ once: true }
);
});
return { delayed: ms };
},
noop: async () => undefined,
};
}
function noopLogger(): LoggerLike {
return {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
}
function nowMs(clock?: Clock): number {
return clock !== undefined ? clock() : performance.now();
}
function recordExecution(
status: "success" | "failed",
durationMs: number
): void {
const instruments = getInstruments();
instruments.actionExecuteDuration.record(durationMs, { status });
instruments.actionExecutionsTotal.add(1, { status });
}

View File

@@ -0,0 +1,10 @@
export {
Action,
ActionExecutor,
ActionRegistry,
builtInHandlers,
createAction,
createActionExecutor,
createActionRegistry,
validateActionConfig,
} from "./actions.js";

View File

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

View File

@@ -0,0 +1,363 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
and,
condition,
type FieldBuilder,
group,
not,
or,
RuleBuilder,
rule,
} from "./builder.js";
describe("RuleBuilder", () => {
it("builds a single condition", { timeout: 5000 }, () => {
const built = rule().field("age").greaterThanOrEqual(18).build();
expect(built).toEqual({
type: "condition",
field: "age",
operator: "greaterThanOrEqual",
value: 18,
});
});
it("attaches metadata + message via FieldBuilder", {
timeout: 5000,
}, () => {
const built = rule()
.field("age")
.withMessage("must be 18+")
.withMetadata({ priority: "high" })
.greaterThanOrEqual(18)
.build();
expect(built).toEqual({
type: "condition",
field: "age",
operator: "greaterThanOrEqual",
value: 18,
message: "must be 18+",
metadata: { priority: "high" },
});
});
it("combines two conditions with AND", { timeout: 5000 }, () => {
const built = rule()
.field("age")
.greaterThanOrEqual(18)
.and()
.field("verified")
.equals(true)
.build();
expect(built.type).toBe("logical");
expect((built as { operator: string }).operator).toBe("and");
});
it("combines two conditions with OR", { timeout: 5000 }, () => {
const built = rule()
.field("a")
.equals(1)
.or()
.field("b")
.equals(2)
.build();
expect((built as { operator: string }).operator).toBe("or");
});
it("negates the last rule via .not()", { timeout: 5000 }, () => {
const built = rule().field("a").equals(1).not().build();
expect(built).toEqual({
type: "logical",
operator: "not",
rules: [
{ type: "condition", field: "a", operator: "equals", value: 1 },
],
});
});
it(".not() without preceding rule raises RULES.RULE_INVALID", {
timeout: 5000,
}, () => {
expect(() => rule().not()).toThrow(/RULES\.RULE_INVALID/);
});
it("composes a nested group", { timeout: 5000 }, () => {
const built = rule()
.group("perms", (rb) =>
rb
.field("role")
.equals("admin")
.or()
.field("role")
.equals("owner")
)
.build();
expect(built.type).toBe("group");
expect((built as { name: string }).name).toBe("perms");
});
it("where wraps single inner rule as that rule", { timeout: 5000 }, () => {
const built = rule()
.where((rb) => rb.field("a").equals(1))
.build();
expect(built).toEqual({
type: "condition",
field: "a",
operator: "equals",
value: 1,
});
});
it("where wraps multi-rule body in AND", { timeout: 5000 }, () => {
const built = rule()
.where((rb) =>
rb
.field("a")
.equals(1)
.rule(condition("b", "equals", 2))
)
.build();
expect((built as { type: string; operator: string }).operator).toBe(
"and"
);
});
it("where with empty body produces no rule", { timeout: 5000 }, () => {
expect(() =>
rule()
.where((rb) => rb)
.build()
).toThrow(/RULES\.RULE_INVALID/);
});
it("any wraps inner rules in OR", { timeout: 5000 }, () => {
const built = rule()
.any((rb) =>
rb
.field("a")
.equals(1)
.rule(condition("b", "equals", 2))
)
.build();
expect((built as { operator: string }).operator).toBe("or");
});
it("all wraps inner rules in AND", { timeout: 5000 }, () => {
const built = rule()
.all((rb) =>
rb
.field("a")
.equals(1)
.rule(condition("b", "equals", 2))
)
.build();
expect((built as { operator: string }).operator).toBe("and");
});
it("custom appends a CustomRule", { timeout: 5000 }, () => {
const built = rule()
.custom(
"is-adult",
(v: { age: number }) => v.age >= 18,
"must be adult"
)
.build();
expect(built.type).toBe("custom");
expect((built as { message: string }).message).toBe("must be adult");
});
it("custom carries builder metadata", { timeout: 5000 }, () => {
const built = rule()
.withMetadata({ priority: "high" })
.custom("c", () => true)
.build();
expect((built as { metadata: { priority: string } }).metadata).toEqual({
priority: "high",
});
});
it("computed appends a ComputedRule", { timeout: 5000 }, () => {
const built = rule()
.computed("score", "greaterThan", () => 5, "score too low")
.build();
expect(built.type).toBe("computed");
expect((built as { operator: string }).operator).toBe("greaterThan");
});
it("rule + addRules append existing rules", { timeout: 5000 }, () => {
const built = rule()
.rule(condition("a", "equals", 1))
.addRules([
condition("b", "equals", 2),
condition("c", "equals", 3),
])
.build();
expect(built.type).toBe("logical");
expect((built as { rules: readonly unknown[] }).rules.length).toBe(3);
});
it("build() with zero rules raises", { timeout: 5000 }, () => {
expect(() => rule().build()).toThrow(/RULES\.RULE_INVALID/);
});
it("buildRuleset produces a Ruleset", { timeout: 5000 }, () => {
const ruleset = rule()
.field("a")
.equals(1)
.buildRuleset("rs-1", "Demo", {
operator: "or",
failFast: true,
description: "d",
version: "1",
});
expect(ruleset.id).toBe("rs-1");
expect(ruleset.operator).toBe("or");
expect(ruleset.failFast).toBe(true);
expect(ruleset.description).toBe("d");
expect(ruleset.version).toBe("1");
});
it("buildRuleset rejects empty id or name", { timeout: 5000 }, () => {
expect(() =>
rule()
.rule(condition("a", "equals", 1))
.buildRuleset("", "n")
).toThrow(/RULES\.RULESET_INVALID/);
expect(() =>
rule()
.rule(condition("a", "equals", 1))
.buildRuleset("id", "")
).toThrow(/RULES\.RULESET_INVALID/);
});
it("buildRuleset inherits builder metadata when options.metadata omitted", {
timeout: 5000,
}, () => {
const rs = rule()
.withMetadata({ priority: "high" })
.rule(condition("a", "equals", 1))
.buildRuleset("id", "n");
expect(rs.metadata).toEqual({ priority: "high" });
});
it("getRules exposes the internal stack", { timeout: 5000 }, () => {
const rb = rule().field("a").equals(1).field("b").equals(2);
expect(rb.getRules().length).toBe(2);
});
it("RuleBuilder.create returns a fresh instance", { timeout: 5000 }, () => {
expect(RuleBuilder.create()).not.toBe(RuleBuilder.create());
});
});
describe("FieldBuilder operator surface", () => {
const cases: ReadonlyArray<
[(fb: FieldBuilder) => RuleBuilder, string, unknown]
> = [
[(fb) => fb.equals("x"), "equals", "x"],
[(fb) => fb.notEquals("x"), "notEquals", "x"],
[(fb) => fb.strictEquals("x"), "strictEquals", "x"],
[(fb) => fb.strictNotEquals("x"), "strictNotEquals", "x"],
[(fb) => fb.greaterThan(1), "greaterThan", 1],
[(fb) => fb.greaterThanOrEqual(1), "greaterThanOrEqual", 1],
[(fb) => fb.lessThan(1), "lessThan", 1],
[(fb) => fb.lessThanOrEqual(1), "lessThanOrEqual", 1],
[(fb) => fb.between(1, 2), "between", [1, 2]],
[(fb) => fb.notBetween(1, 2), "notBetween", [1, 2]],
[(fb) => fb.in(["a"]), "in", ["a"]],
[(fb) => fb.notIn(["a"]), "notIn", ["a"]],
[(fb) => fb.contains("a"), "contains", "a"],
[(fb) => fb.notContains("a"), "notContains", "a"],
[(fb) => fb.containsAll(["a"]), "containsAll", ["a"]],
[(fb) => fb.containsAny(["a"]), "containsAny", ["a"]],
[(fb) => fb.exists(), "exists", true],
[(fb) => fb.notExists(), "notExists", true],
[(fb) => fb.isNull(), "isNull", true],
[(fb) => fb.isNotNull(), "isNotNull", true],
[(fb) => fb.isEmpty(), "isEmpty", true],
[(fb) => fb.isNotEmpty(), "isNotEmpty", true],
[(fb) => fb.isType("string"), "isType", "string"],
[(fb) => fb.isNotType("string"), "isNotType", "string"],
[(fb) => fb.startsWith("a"), "startsWith", "a"],
[(fb) => fb.endsWith("a"), "endsWith", "a"],
[(fb) => fb.matches("a"), "matches", "a"],
[(fb) => fb.matches(/abc/), "matches", "abc"],
[(fb) => fb.notMatches("a"), "notMatches", "a"],
[(fb) => fb.notMatches(/abc/), "notMatches", "abc"],
];
for (const [build, operator, value] of cases) {
it(`commits ${operator}`, { timeout: 5000 }, () => {
const built = build(rule().field("x")).build();
expect(built).toEqual({
type: "condition",
field: "x",
operator,
value,
});
});
}
});
describe("factory helpers", () => {
it("condition produces a ConditionRule", { timeout: 5000 }, () => {
expect(condition("a", "equals", 1, "msg")).toEqual({
type: "condition",
field: "a",
operator: "equals",
value: 1,
message: "msg",
});
});
it("condition omits undefined message", { timeout: 5000 }, () => {
expect(condition("a", "equals", 1)).toEqual({
type: "condition",
field: "a",
operator: "equals",
value: 1,
});
});
it("and / or compose children", { timeout: 5000 }, () => {
const a = condition("a", "equals", 1);
const b = condition("b", "equals", 2);
expect(and(a, b)).toEqual({
type: "logical",
operator: "and",
rules: [a, b],
});
expect(or(a, b)).toEqual({
type: "logical",
operator: "or",
rules: [a, b],
});
});
it("and / or reject empty arg lists", { timeout: 5000 }, () => {
expect(() => and()).toThrow(/RULES\.RULE_INVALID/);
expect(() => or()).toThrow(/RULES\.RULE_INVALID/);
});
it("not wraps a single rule", { timeout: 5000 }, () => {
const c = condition("a", "equals", 1);
expect(not(c)).toEqual({
type: "logical",
operator: "not",
rules: [c],
});
});
it("group builds a GroupRule", { timeout: 5000 }, () => {
const c = condition("a", "equals", 1);
expect(group("g", c)).toEqual({
type: "group",
name: "g",
rules: [c],
operator: "and",
});
});
it("group rejects empty name", { timeout: 5000 }, () => {
expect(() => group("")).toThrow(/RULES\.RULE_INVALID/);
});
});

View File

@@ -0,0 +1,406 @@
import { RulesError } from "../errors/index.js";
import type {
ComparisonOperator,
ComputedRule,
ConditionRule,
CustomRule,
GroupRule,
LogicalRule,
Rule,
RuleMetadata,
Ruleset,
} from "../types/index.js";
/**
* Field-scoped builder yielding a `ConditionRule`. Returned from
* `RuleBuilder.field(path)`. Every operator method commits a condition to
* the parent builder and returns the parent so the fluent chain can continue.
*/
export class FieldBuilder<TValue = unknown> {
private message?: string;
private metadata?: RuleMetadata;
constructor(
private readonly fieldPath: string,
private readonly parent: RuleBuilder
) {}
withMessage(message: string): this {
this.message = message;
return this;
}
withMetadata(metadata: RuleMetadata): this {
this.metadata = metadata;
return this;
}
equals(value: TValue): RuleBuilder {
return this.commit("equals", value);
}
notEquals(value: TValue): RuleBuilder {
return this.commit("notEquals", value);
}
strictEquals(value: TValue): RuleBuilder {
return this.commit("strictEquals", value);
}
strictNotEquals(value: TValue): RuleBuilder {
return this.commit("strictNotEquals", value);
}
greaterThan(value: number): RuleBuilder {
return this.commit("greaterThan", value as unknown as TValue);
}
greaterThanOrEqual(value: number): RuleBuilder {
return this.commit("greaterThanOrEqual", value as unknown as TValue);
}
lessThan(value: number): RuleBuilder {
return this.commit("lessThan", value as unknown as TValue);
}
lessThanOrEqual(value: number): RuleBuilder {
return this.commit("lessThanOrEqual", value as unknown as TValue);
}
between(min: number, max: number): RuleBuilder {
return this.commit("between", [min, max] as unknown as TValue);
}
notBetween(min: number, max: number): RuleBuilder {
return this.commit("notBetween", [min, max] as unknown as TValue);
}
in(values: readonly TValue[]): RuleBuilder {
return this.commit("in", values as unknown as TValue);
}
notIn(values: readonly TValue[]): RuleBuilder {
return this.commit("notIn", values as unknown as TValue);
}
contains(value: TValue): RuleBuilder {
return this.commit("contains", value);
}
notContains(value: TValue): RuleBuilder {
return this.commit("notContains", value);
}
containsAll(values: readonly TValue[]): RuleBuilder {
return this.commit("containsAll", values as unknown as TValue);
}
containsAny(values: readonly TValue[]): RuleBuilder {
return this.commit("containsAny", values as unknown as TValue);
}
exists(): RuleBuilder {
return this.commit("exists", true as unknown as TValue);
}
notExists(): RuleBuilder {
return this.commit("notExists", true as unknown as TValue);
}
isNull(): RuleBuilder {
return this.commit("isNull", true as unknown as TValue);
}
isNotNull(): RuleBuilder {
return this.commit("isNotNull", true as unknown as TValue);
}
isEmpty(): RuleBuilder {
return this.commit("isEmpty", true as unknown as TValue);
}
isNotEmpty(): RuleBuilder {
return this.commit("isNotEmpty", true as unknown as TValue);
}
isType(type: string): RuleBuilder {
return this.commit("isType", type as unknown as TValue);
}
isNotType(type: string): RuleBuilder {
return this.commit("isNotType", type as unknown as TValue);
}
startsWith(value: string): RuleBuilder {
return this.commit("startsWith", value as unknown as TValue);
}
endsWith(value: string): RuleBuilder {
return this.commit("endsWith", value as unknown as TValue);
}
matches(pattern: string | RegExp): RuleBuilder {
const pat = pattern instanceof RegExp ? pattern.source : pattern;
return this.commit("matches", pat as unknown as TValue);
}
notMatches(pattern: string | RegExp): RuleBuilder {
const pat = pattern instanceof RegExp ? pattern.source : pattern;
return this.commit("notMatches", pat as unknown as TValue);
}
private commit(operator: ComparisonOperator, value: TValue): RuleBuilder {
const condition: ConditionRule<TValue> = {
type: "condition",
field: this.fieldPath,
operator,
value,
...(this.message !== undefined ? { message: this.message } : {}),
...(this.metadata !== undefined ? { metadata: this.metadata } : {}),
};
this.parent.addRule(condition as Rule);
return this.parent;
}
}
/**
* Fluent rule builder. Accumulates rules into an internal array and combines
* them with the current pending operator (`and` / `or`) when `addRule` lands.
* `build()` returns a single `Rule` — if exactly one rule is on the stack,
* it is returned directly; otherwise the rules are wrapped in an AND.
*/
export class RuleBuilder {
private readonly rules: Rule[] = [];
private pending: "and" | "or" | null = null;
private metadata?: RuleMetadata;
private constructor() {}
static create(): RuleBuilder {
return new RuleBuilder();
}
field<TValue = unknown>(path: string): FieldBuilder<TValue> {
return new FieldBuilder<TValue>(path, this);
}
and(): this {
this.pending = "and";
return this;
}
or(): this {
this.pending = "or";
return this;
}
not(): this {
const last = this.rules.pop();
if (last === undefined) {
throw new RulesError(
"RULES.RULE_INVALID",
"cannot apply NOT — no preceding rule on the builder"
);
}
const negated: LogicalRule = {
type: "logical",
operator: "not",
rules: [last],
};
this.rules.push(negated);
return this;
}
group(name: string, build: (rb: RuleBuilder) => RuleBuilder): this {
const inner = new RuleBuilder();
build(inner);
const grouped: GroupRule = {
type: "group",
name,
rules: inner.rules,
operator: "and",
};
this.addRule(grouped);
return this;
}
where(build: (rb: RuleBuilder) => RuleBuilder): this {
const inner = new RuleBuilder();
build(inner);
if (inner.rules.length === 1) {
this.addRule(inner.rules[0] as Rule);
} else if (inner.rules.length > 1) {
this.addRule({
type: "logical",
operator: "and",
rules: inner.rules,
} as LogicalRule);
}
return this;
}
any(build: (rb: RuleBuilder) => RuleBuilder): this {
const inner = new RuleBuilder();
build(inner);
this.addRule({
type: "logical",
operator: "or",
rules: inner.rules,
} as LogicalRule);
return this;
}
all(build: (rb: RuleBuilder) => RuleBuilder): this {
const inner = new RuleBuilder();
build(inner);
this.addRule({
type: "logical",
operator: "and",
rules: inner.rules,
} as LogicalRule);
return this;
}
custom<TContext = unknown, TValue = unknown>(
name: string,
validate: (
value: TValue,
context: TContext
) => boolean | Promise<boolean>,
message?: string
): this {
const rule: CustomRule<TContext, TValue> = {
type: "custom",
name,
validate,
...(message !== undefined ? { message } : {}),
...(this.metadata !== undefined ? { metadata: this.metadata } : {}),
};
this.addRule(rule as Rule);
return this;
}
computed<TContext = unknown>(
field: string,
operator: ComparisonOperator,
compute: (context: TContext) => unknown | Promise<unknown>,
message?: string
): this {
const rule: ComputedRule<TContext> = {
type: "computed",
field,
operator,
compute,
...(message !== undefined ? { message } : {}),
...(this.metadata !== undefined ? { metadata: this.metadata } : {}),
};
this.addRule(rule as Rule);
return this;
}
rule(rule: Rule): this {
this.addRule(rule);
return this;
}
addRules(rules: readonly Rule[]): this {
for (const r of rules) this.addRule(r);
return this;
}
withMetadata(metadata: RuleMetadata): this {
this.metadata = metadata;
return this;
}
build(): Rule {
if (this.rules.length === 0) {
throw new RulesError(
"RULES.RULE_INVALID",
"RuleBuilder: no rules to build"
);
}
if (this.rules.length === 1) return this.rules[0] as Rule;
const combined: LogicalRule = {
type: "logical",
operator: "and",
rules: this.rules,
...(this.metadata !== undefined ? { metadata: this.metadata } : {}),
};
return combined;
}
buildRuleset(
id: string,
name: string,
options: Partial<Omit<Ruleset, "id" | "name" | "rules">> = {}
): Ruleset {
if (id === "" || name === "") {
throw new RulesError(
"RULES.RULESET_INVALID",
"ruleset id and name must be non-empty"
);
}
const out: { -readonly [K in keyof Ruleset]: Ruleset[K] } = {
id,
name,
rules: this.rules,
operator: options.operator ?? "and",
};
if (options.description !== undefined)
out.description = options.description;
if (options.version !== undefined) out.version = options.version;
if (options.failFast !== undefined) out.failFast = options.failFast;
const meta = options.metadata ?? this.metadata;
if (meta !== undefined) out.metadata = meta;
return out;
}
/**
* Internal: append a rule, applying the pending operator when one is set.
*/
addRule(rule: Rule): void {
if (this.pending !== null && this.rules.length > 0) {
const previous = this.rules.pop() as Rule;
const combined: LogicalRule = {
type: "logical",
operator: this.pending,
rules: [previous, rule],
};
this.rules.push(combined);
this.pending = null;
} else {
this.rules.push(rule);
}
}
getRules(): readonly Rule[] {
return this.rules;
}
}
export function rule(): RuleBuilder {
return RuleBuilder.create();
}
export function condition<T = unknown>(
field: string,
operator: ComparisonOperator,
value: T,
message?: string
): ConditionRule<T> {
return {
type: "condition",
field,
operator,
value,
...(message !== undefined ? { message } : {}),
};
}
export function and(...rules: Rule[]): LogicalRule {
if (rules.length === 0) {
throw new RulesError(
"RULES.RULE_INVALID",
"and() requires at least one rule"
);
}
return { type: "logical", operator: "and", rules };
}
export function or(...rules: Rule[]): LogicalRule {
if (rules.length === 0) {
throw new RulesError(
"RULES.RULE_INVALID",
"or() requires at least one rule"
);
}
return { type: "logical", operator: "or", rules };
}
export function not(target: Rule): LogicalRule {
return { type: "logical", operator: "not", rules: [target] };
}
export function group(name: string, ...rules: Rule[]): GroupRule {
if (name === "") {
throw new RulesError(
"RULES.RULE_INVALID",
"group() name must be non-empty"
);
}
return { type: "group", name, rules, operator: "and" };
}

View File

@@ -0,0 +1,10 @@
export {
and,
condition,
FieldBuilder,
group,
not,
or,
RuleBuilder,
rule,
} from "./builder.js";

View File

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

View File

@@ -0,0 +1,17 @@
/**
* Package-level constants for `@suaveplan/rules`.
*
* `PACKAGE_NAME` and `VERSION` are surfaced for diagnostics and OTel
* resource attributes. `DEFAULT_MAX_DEPTH`, `DEFAULT_EVENT_HISTORY` and
* `DEFAULT_ACTION_TIMEOUT_MS` are the conservative defaults applied when the
* caller does not supply a value at construction time. Every default is
* overridable through the relevant options object.
*/
export const PACKAGE_NAME = "@suaveplan/rules" as const;
export const VERSION = "0.1.0" as const;
export const DEFAULT_MAX_DEPTH = 32;
export const DEFAULT_EVENT_HISTORY = 1000;
export const DEFAULT_ACTION_TIMEOUT_MS = 30_000;
export const DEFAULT_ACTION_PARALLELISM = 8;

View File

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

View File

@@ -0,0 +1,362 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
ContextBuilder,
context,
contextWith,
createRequestContext,
createScopedContext,
DefaultContextProvider,
DefaultFieldResolver,
getField,
hasField,
} from "./context.js";
describe("DefaultFieldResolver", () => {
it("returns data when path is empty", { timeout: 5000 }, () => {
const resolver = new DefaultFieldResolver();
expect(resolver.resolve({ a: 1 }, "")).toEqual({ a: 1 });
});
it("resolves dot-path", { timeout: 5000 }, () => {
const resolver = new DefaultFieldResolver();
expect(
resolver.resolve(
{ user: { profile: { name: "Ada" } } },
"user.profile.name"
)
).toBe("Ada");
});
it("resolves array-index", { timeout: 5000 }, () => {
const resolver = new DefaultFieldResolver();
expect(
resolver.resolve(
{ items: [{ sku: "X" }, { sku: "Y" }] },
"items[1].sku"
)
).toBe("Y");
});
it("returns undefined for missing segments in non-strict mode", {
timeout: 5000,
}, () => {
const resolver = new DefaultFieldResolver();
expect(resolver.resolve({}, "absent")).toBeUndefined();
expect(resolver.resolve({ a: null }, "a.b")).toBeUndefined();
expect(resolver.resolve({ a: 1 }, "a.b")).toBeUndefined();
expect(
resolver.resolve({ a: { b: undefined } }, "a.b.c")
).toBeUndefined();
});
it("returns undefined when array-index target is not an array", {
timeout: 5000,
}, () => {
const resolver = new DefaultFieldResolver();
expect(
resolver.resolve({ items: "not-array" }, "items[0]")
).toBeUndefined();
});
it("returns undefined when array-index parent is not an object", {
timeout: 5000,
}, () => {
const resolver = new DefaultFieldResolver();
expect(resolver.resolve("scalar", "items[0]")).toBeUndefined();
});
it("raises RULES.FIELD_RESOLUTION_FAILED in strict mode", {
timeout: 5000,
}, () => {
const resolver = new DefaultFieldResolver({ strict: true });
expect(() => resolver.resolve({}, "absent")).toThrow(
/RULES\.FIELD_RESOLUTION_FAILED/
);
});
it("has returns true for present, false for missing", {
timeout: 5000,
}, () => {
const resolver = new DefaultFieldResolver();
expect(resolver.has({ a: 1 }, "a")).toBe(true);
expect(resolver.has({ a: 1 }, "b")).toBe(false);
});
it("has returns false in strict mode when missing", {
timeout: 5000,
}, () => {
const resolver = new DefaultFieldResolver({ strict: true });
expect(resolver.has({ a: 1 }, "b")).toBe(false);
});
it("has rethrows non-RulesError", { timeout: 5000 }, () => {
const resolver = new DefaultFieldResolver();
// inject a getter that throws a plain Error
const data: Record<string, unknown> = {};
Object.defineProperty(data, "x", {
get() {
throw new Error("boom");
},
enumerable: true,
});
expect(() => resolver.has(data, "x")).toThrow("boom");
});
});
describe("getField / hasField helpers", () => {
it("getField returns the resolved value", { timeout: 5000 }, () => {
expect(getField(context({ name: "Ada" }), "name")).toBe("Ada");
});
it("hasField reports presence", { timeout: 5000 }, () => {
expect(hasField(context({ name: "Ada" }), "name")).toBe(true);
expect(hasField(context({ name: "Ada" }), "missing")).toBe(false);
});
});
describe("ContextBuilder", () => {
it("builds a minimal context", { timeout: 5000 }, () => {
const ctx = ContextBuilder.create({ a: 1 }).build();
expect(ctx.data).toEqual({ a: 1 });
expect(ctx.tenant).toBeUndefined();
expect(ctx.user).toBeUndefined();
expect(ctx.request).toBeUndefined();
expect(ctx.timestamp).toBeUndefined();
expect(ctx.metadata).toBeUndefined();
});
it("attaches every optional field via with* helpers", {
timeout: 5000,
}, () => {
const ts = new Date(1700000000000);
const ctx = ContextBuilder.create({ a: 1 })
.withTenant("acme")
.withUser({ id: "u1", roles: ["admin"] })
.withRequest({ method: "GET" })
.withTimestamp(ts)
.withMetadata({ source: "test" })
.build();
expect(ctx).toEqual({
data: { a: 1 },
tenant: "acme",
user: { id: "u1", roles: ["admin"] },
request: { method: "GET" },
timestamp: ts,
metadata: { source: "test" },
});
});
it("each with* returns a fresh builder (immutable per call)", {
timeout: 5000,
}, () => {
const base = ContextBuilder.create({ a: 1 });
const tenantBuilder = base.withTenant("acme");
expect(base).not.toBe(tenantBuilder);
expect(base.build().tenant).toBeUndefined();
expect(tenantBuilder.build().tenant).toBe("acme");
});
it("withData re-types the builder", { timeout: 5000 }, () => {
const builder = ContextBuilder.create({ old: 1 }).withTenant("acme");
const reshaped = builder.withData({ fresh: "v" });
expect(reshaped.build()).toEqual({
data: { fresh: "v" },
tenant: "acme",
});
});
it("ContextBuilder.from preserves every field", { timeout: 5000 }, () => {
const ts = new Date(2);
const cloned = ContextBuilder.from({
data: { a: 1 },
tenant: "t",
user: { id: "u" },
request: { method: "GET" },
timestamp: ts,
metadata: { k: "v" },
}).build();
expect(cloned.tenant).toBe("t");
expect(cloned.user).toEqual({ id: "u" });
expect(cloned.request).toEqual({ method: "GET" });
expect(cloned.timestamp).toBe(ts);
expect(cloned.metadata).toEqual({ k: "v" });
});
it("ContextBuilder.from of a bare context only copies data", {
timeout: 5000,
}, () => {
const cloned = ContextBuilder.from({ data: { a: 1 } }).build();
expect(cloned).toEqual({ data: { a: 1 } });
});
});
describe("context / contextWith shorthands", () => {
it("context wraps data", { timeout: 5000 }, () => {
expect(context({ a: 1 })).toEqual({ data: { a: 1 } });
});
it("contextWith threads every option", { timeout: 5000 }, () => {
const ts = new Date(3);
const ctx = contextWith(
{ a: 1 },
{
tenant: "t",
user: { id: "u" },
request: { method: "GET" },
timestamp: ts,
metadata: { k: "v" },
}
);
expect(ctx).toEqual({
data: { a: 1 },
tenant: "t",
user: { id: "u" },
request: { method: "GET" },
timestamp: ts,
metadata: { k: "v" },
});
});
it("contextWith with no options returns bare context", {
timeout: 5000,
}, () => {
expect(contextWith({ a: 1 })).toEqual({ data: { a: 1 } });
});
it("contextWith rejects malformed options with RULES.CONTEXT_INVALID", {
timeout: 5000,
}, () => {
expect(() =>
contextWith({ a: 1 }, { tenant: 42 as unknown as string })
).toThrow(/RULES\.CONTEXT_INVALID/);
});
});
describe("createScopedContext", () => {
it("attaches scope to parent context", { timeout: 5000 }, () => {
const scoped = createScopedContext(context({ a: 1 }), "inner");
expect(scoped.scope).toBe("inner");
expect(scoped.data).toEqual({ a: 1 });
});
it("rejects empty scope with RULES.CONTEXT_INVALID", {
timeout: 5000,
}, () => {
expect(() => createScopedContext(context({ a: 1 }), "")).toThrow(
/RULES\.CONTEXT_INVALID/
);
});
});
describe("createRequestContext", () => {
it("copies plain header records", { timeout: 5000 }, () => {
const req = createRequestContext({
method: "GET",
headers: { "x-test": "1" },
});
expect(req.method).toBe("GET");
expect(req.headers).toEqual({ "x-test": "1" });
});
it("collects Headers instances", { timeout: 5000 }, () => {
const headers = new Headers([["x-test", "1"]]);
const req = createRequestContext({ headers });
expect(req.headers).toEqual({ "x-test": "1" });
});
it("derives path from URL when path is absent", { timeout: 5000 }, () => {
const req = createRequestContext({
url: "https://host.test/users/42?x=y",
});
expect(req.path).toBe("/users/42");
});
it("falls back to url when URL parser would fail", {
timeout: 5000,
}, () => {
const req = createRequestContext({ url: ":not-a-url" });
expect(req.path).toBe("/:not-a-url");
});
it("honours explicit path over url", { timeout: 5000 }, () => {
const req = createRequestContext({
url: "https://host.test/users/42",
path: "/override",
});
expect(req.path).toBe("/override");
});
it("emits ip + query + params when supplied", { timeout: 5000 }, () => {
const req = createRequestContext({
ip: "1.2.3.4",
query: { a: "1" },
params: { id: "42" },
});
expect(req.ip).toBe("1.2.3.4");
expect(req.query).toEqual({ a: "1" });
expect(req.params).toEqual({ id: "42" });
});
it("returns an empty object when no fields supplied", {
timeout: 5000,
}, () => {
expect(createRequestContext({})).toEqual({});
});
});
describe("DefaultContextProvider", () => {
it("uses injected Clock for the default timestamp", {
timeout: 5000,
}, async () => {
const provider = new DefaultContextProvider<Record<string, unknown>>({
clock: () => 1700000000000,
});
const ctx = await provider.create({ a: 1 });
expect(ctx.timestamp).toEqual(new Date(1700000000000));
expect(ctx.data).toEqual({ a: 1 });
});
it("uses Date.now when no Clock is supplied", {
timeout: 5000,
}, async () => {
const provider = new DefaultContextProvider<Record<string, unknown>>();
const before = Date.now();
const ctx = await provider.create({ a: 1 });
const after = Date.now();
expect(ctx.timestamp).toBeInstanceOf(Date);
const ts = (ctx.timestamp as Date).getTime();
expect(ts).toBeGreaterThanOrEqual(before);
expect(ts).toBeLessThanOrEqual(after);
});
it("treats non-object input as empty data", { timeout: 5000 }, async () => {
const provider = new DefaultContextProvider<Record<string, unknown>>({
clock: () => 0,
});
expect((await provider.create(null)).data).toEqual({});
expect((await provider.create(42)).data).toEqual({});
});
it("runs enhancers in registration order on create", {
timeout: 5000,
}, async () => {
const provider = new DefaultContextProvider<Record<string, unknown>>({
clock: () => 0,
enhancers: [
(ctx) => ({ ...ctx, tenant: "first" }),
(ctx) => ({ ...ctx, tenant: `${ctx.tenant}-second` }),
],
});
const ctx = await provider.create({});
expect(ctx.tenant).toBe("first-second");
});
it("runs enhancers on enhance", { timeout: 5000 }, async () => {
const provider = new DefaultContextProvider<Record<string, unknown>>({
clock: () => 0,
enhancers: [async (ctx) => ({ ...ctx, tenant: "from-enhance" })],
});
const base = await provider.create({});
const enhanced = await provider.enhance(base, {});
expect(enhanced.tenant).toBe("from-enhance");
});
});

View File

@@ -0,0 +1,387 @@
import type { Clock } from "@suaveplan/types";
import z from "zod";
import { RulesError } from "../errors/index.js";
import type {
ContextProvider,
FieldResolver,
RequestContext,
RuleContext,
ScopedContext,
} from "../types/index.js";
const ARRAY_INDEX_PATTERN = /^(\w+)\[(\d+)\]$/;
const REQUEST_INPUT_SCHEMA = z
.object({
method: z.string().optional(),
url: z.string().optional(),
path: z.string().optional(),
headers: z.unknown().optional(),
query: z
.record(z.string(), z.union([z.string(), z.array(z.string())]))
.optional(),
params: z.record(z.string(), z.string()).optional(),
ip: z.string().optional(),
})
.strict();
const CONTEXT_BUILDER_OPTIONS_SCHEMA = z
.object({
tenant: z.string().optional(),
user: z
.object({
id: z.string(),
roles: z.array(z.string()).readonly().optional(),
})
.optional(),
request: z.unknown().optional(),
timestamp: z.date().optional(),
metadata: z.record(z.string(), z.unknown()).readonly().optional(),
})
.strict();
export interface ContextBuilderOptions {
readonly tenant?: string;
readonly user?: { readonly id: string; readonly roles?: readonly string[] };
readonly request?: RequestContext;
readonly timestamp?: Date;
readonly metadata?: Readonly<Record<string, unknown>>;
}
export interface DefaultFieldResolverOptions {
readonly strict?: boolean;
}
/**
* Resolve dot-path / `prop[index]` field paths against `data`. Implements
* `FieldResolver`. When `options.strict` is `true`, a missing intermediate
* segment raises `RULES.FIELD_RESOLUTION_FAILED`; non-strict mode silently
* returns `undefined` (matching the upstream contract).
*/
export class DefaultFieldResolver implements FieldResolver {
private readonly strict: boolean;
constructor(options: DefaultFieldResolverOptions = {}) {
this.strict = options.strict ?? false;
}
resolve(data: unknown, path: string): unknown {
if (path === "") return data;
const segments = path.split(".");
let current: unknown = data;
for (const segment of segments) {
if (current === null || current === undefined) {
return this.absent(path);
}
const arrayMatch = segment.match(ARRAY_INDEX_PATTERN);
if (arrayMatch !== null) {
const prop = arrayMatch[1] as string;
const index = Number.parseInt(arrayMatch[2] as string, 10);
if (typeof current !== "object") {
return this.absent(path);
}
const nested = (current as Record<string, unknown>)[prop];
if (!Array.isArray(nested)) {
return this.absent(path);
}
current = nested[index];
} else if (typeof current === "object") {
current = (current as Record<string, unknown>)[segment];
} else {
return this.absent(path);
}
}
if (current === undefined) {
return this.absent(path);
}
return current;
}
has(data: unknown, path: string): boolean {
try {
return this.resolve(data, path) !== undefined;
} catch (err) {
if (err instanceof RulesError) return false;
throw err;
}
}
private absent(path: string): undefined {
if (this.strict) {
throw new RulesError(
"RULES.FIELD_RESOLUTION_FAILED",
`field "${path}" not found in strict mode`,
{ details: { path } }
);
}
return undefined;
}
}
const DEFAULT_RESOLVER = new DefaultFieldResolver();
export function getField(context: RuleContext, path: string): unknown {
return DEFAULT_RESOLVER.resolve(context.data, path);
}
export function hasField(context: RuleContext, path: string): boolean {
return DEFAULT_RESOLVER.has(context.data, path);
}
/**
* Fluent builder for `RuleContext`. Defaults to an empty data object. Every
* `with*` call returns a new builder (immutable per call) so that contexts
* derived from a shared base do not alias each other's state.
*/
export class ContextBuilder<
TData extends Record<string, unknown> = Record<string, unknown>,
> {
private readonly state: {
data: TData;
tenant?: string;
user?: { readonly id: string; readonly roles?: readonly string[] };
request?: RequestContext;
timestamp?: Date;
metadata?: Readonly<Record<string, unknown>>;
};
private constructor(state: ContextBuilder<TData>["state"]) {
this.state = state;
}
static create<T extends Record<string, unknown> = Record<string, unknown>>(
data: T
): ContextBuilder<T> {
return new ContextBuilder<T>({ data });
}
static from<T extends Record<string, unknown>>(
ctx: RuleContext<T>
): ContextBuilder<T> {
const state: ContextBuilder<T>["state"] = { data: ctx.data };
if (ctx.tenant !== undefined) state.tenant = ctx.tenant;
if (ctx.user !== undefined) state.user = ctx.user;
if (ctx.request !== undefined) state.request = ctx.request;
if (ctx.timestamp !== undefined) state.timestamp = ctx.timestamp;
if (ctx.metadata !== undefined) state.metadata = ctx.metadata;
return new ContextBuilder<T>(state);
}
withData<TNew extends Record<string, unknown>>(
data: TNew
): ContextBuilder<TNew> {
return new ContextBuilder<TNew>({ ...this.state, data });
}
withTenant(tenant: string): ContextBuilder<TData> {
return new ContextBuilder<TData>({ ...this.state, tenant });
}
withUser(user: {
readonly id: string;
readonly roles?: readonly string[];
}): ContextBuilder<TData> {
return new ContextBuilder<TData>({ ...this.state, user });
}
withRequest(request: RequestContext): ContextBuilder<TData> {
return new ContextBuilder<TData>({ ...this.state, request });
}
withTimestamp(timestamp: Date): ContextBuilder<TData> {
return new ContextBuilder<TData>({ ...this.state, timestamp });
}
withMetadata(
metadata: Readonly<Record<string, unknown>>
): ContextBuilder<TData> {
return new ContextBuilder<TData>({ ...this.state, metadata });
}
build(): RuleContext<TData> {
const out: {
-readonly [K in keyof RuleContext<TData>]: RuleContext<TData>[K];
} = {
data: this.state.data,
};
if (this.state.tenant !== undefined) out.tenant = this.state.tenant;
if (this.state.user !== undefined) out.user = this.state.user;
if (this.state.request !== undefined) out.request = this.state.request;
if (this.state.timestamp !== undefined)
out.timestamp = this.state.timestamp;
if (this.state.metadata !== undefined)
out.metadata = this.state.metadata;
return out as RuleContext<TData>;
}
}
export function context<T extends Record<string, unknown>>(
data: T
): RuleContext<T> {
return { data };
}
export function contextWith<T extends Record<string, unknown>>(
data: T,
options: ContextBuilderOptions = {}
): RuleContext<T> {
const parsed = CONTEXT_BUILDER_OPTIONS_SCHEMA.safeParse(options);
if (!parsed.success) {
throw new RulesError(
"RULES.CONTEXT_INVALID",
"invalid ContextBuilderOptions",
{
cause: parsed.error,
details: { issues: parsed.error.issues },
}
);
}
let builder = ContextBuilder.create<T>(data);
if (options.tenant !== undefined)
builder = builder.withTenant(options.tenant);
if (options.user !== undefined) builder = builder.withUser(options.user);
if (options.request !== undefined)
builder = builder.withRequest(options.request);
if (options.timestamp !== undefined)
builder = builder.withTimestamp(options.timestamp);
if (options.metadata !== undefined)
builder = builder.withMetadata(options.metadata);
return builder.build();
}
export function createScopedContext<T extends Record<string, unknown>>(
parent: RuleContext<T>,
scope: string
): ScopedContext<T> {
if (scope === "") {
throw new RulesError(
"RULES.CONTEXT_INVALID",
"scope must be a non-empty string"
);
}
return { ...parent, scope };
}
export interface CreateRequestContextInput {
readonly method?: string;
readonly url?: string;
readonly path?: string;
readonly headers?: Record<string, string> | Headers;
readonly query?: Readonly<Record<string, string | readonly string[]>>;
readonly params?: Readonly<Record<string, string>>;
readonly ip?: string;
readonly clock?: Clock;
}
/**
* Derive a `RequestContext` from an HTTP-shaped input. Accepts either a plain
* header record or a Web `Headers` instance. The `clock` parameter is the
* source for any timestamp embedded downstream — see `DefaultContextProvider`.
*/
export function createRequestContext(
input: CreateRequestContextInput
): RequestContext {
REQUEST_INPUT_SCHEMA.parse({
method: input.method,
url: input.url,
path: input.path,
headers: input.headers instanceof Headers ? undefined : input.headers,
query: input.query,
params: input.params,
ip: input.ip,
});
let headers: Readonly<Record<string, string>> | undefined;
if (input.headers !== undefined) {
if (input.headers instanceof Headers) {
const collected: Record<string, string> = {};
input.headers.forEach((value, key) => {
collected[key] = value;
});
headers = collected;
} else {
headers = { ...input.headers };
}
}
let path: string | undefined = input.path;
if (path === undefined && input.url !== undefined) {
try {
const parsed = new URL(input.url, "http://localhost");
path = parsed.pathname;
} catch {
path = input.url;
}
}
const out: { -readonly [K in keyof RequestContext]: RequestContext[K] } =
{};
if (input.method !== undefined) out.method = input.method;
if (path !== undefined) out.path = path;
if (headers !== undefined) out.headers = headers;
if (input.query !== undefined) out.query = input.query;
if (input.params !== undefined) out.params = input.params;
if (input.ip !== undefined) out.ip = input.ip;
return out;
}
export interface DefaultContextProviderOptions<
TData extends Record<string, unknown>,
> {
readonly clock?: Clock;
readonly enhancers?: readonly ((
context: RuleContext<TData>,
input: unknown
) => RuleContext<TData> | Promise<RuleContext<TData>>)[];
}
/**
* Default `ContextProvider` that builds a `RuleContext` from arbitrary input
* by composing registered enhancers in order. The provider supplies a default
* `timestamp` derived from the injected `Clock` (defaulting to `Date.now`).
*/
export class DefaultContextProvider<
TData extends Record<string, unknown> = Record<string, unknown>,
> implements ContextProvider<TData>
{
private readonly clock: Clock;
private readonly enhancers: ((
context: RuleContext<TData>,
input: unknown
) => RuleContext<TData> | Promise<RuleContext<TData>>)[];
constructor(options: DefaultContextProviderOptions<TData> = {}) {
this.clock = options.clock ?? (() => Date.now());
this.enhancers =
options.enhancers !== undefined ? [...options.enhancers] : [];
}
async create(input: unknown): Promise<RuleContext<TData>> {
const data = (
typeof input === "object" && input !== null ? input : {}
) as TData;
const base: RuleContext<TData> = {
data,
timestamp: new Date(this.clock()),
};
return this.runEnhancers(base, input);
}
async enhance(
ctx: RuleContext<TData>,
input: unknown
): Promise<RuleContext<TData>> {
return this.runEnhancers(ctx, input);
}
private async runEnhancers(
ctx: RuleContext<TData>,
input: unknown
): Promise<RuleContext<TData>> {
let result = ctx;
for (const enhancer of this.enhancers) {
result = await enhancer(result, input);
}
return result;
}
}

View File

@@ -0,0 +1,17 @@
export type {
ContextBuilderOptions,
CreateRequestContextInput,
DefaultContextProviderOptions,
DefaultFieldResolverOptions,
} from "./context.js";
export {
ContextBuilder,
context,
contextWith,
createRequestContext,
createScopedContext,
DefaultContextProvider,
DefaultFieldResolver,
getField,
hasField,
} from "./context.js";

View File

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

View File

@@ -0,0 +1,510 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { and, condition, not, or } from "../builder/index.js";
import { context } from "../context/index.js";
import type { OperatorDefinition, Rule } from "../types/index.js";
import { RuleEngine } from "./engine.js";
describe("RuleEngine.evaluate", () => {
it("evaluates condition rule", { timeout: 5000 }, async () => {
const engine = new RuleEngine();
const r = condition("age", "greaterThanOrEqual", 18);
const result = await engine.evaluate(r, context({ age: 21 }));
expect(result.passed).toBe(true);
expect(result.errors).toEqual([]);
expect(result.metadata?.evaluatedRules).toBeGreaterThan(0);
});
it("returns passed:false with errors on miss", {
timeout: 5000,
}, async () => {
const engine = new RuleEngine();
const r = condition("age", "greaterThanOrEqual", 18, "too young");
const result = await engine.evaluate(r, context({ age: 12 }));
expect(result.passed).toBe(false);
expect(result.errors[0]?.code).toBe("RULE_FAILED_CONDITION");
expect(result.errors[0]?.message).toBe("too young");
});
it("default message when none supplied", { timeout: 5000 }, async () => {
const engine = new RuleEngine();
const result = await engine.evaluate(
condition("a", "equals", 1),
context({ a: 2 })
);
expect(result.errors[0]?.message).toBe("rule evaluation failed");
});
it("short-circuits AND on first failure", { timeout: 5000 }, async () => {
const engine = new RuleEngine();
let secondInvoked = false;
const second: Rule = {
type: "custom",
name: "spy",
validate: async () => {
secondInvoked = true;
return true;
},
};
const r = and(condition("a", "equals", 1), second);
const result = await engine.evaluate(r, context({ a: 2 }));
expect(result.passed).toBe(false);
expect(secondInvoked).toBe(false);
});
it("OR returns true when any child passes", { timeout: 5000 }, async () => {
const engine = new RuleEngine();
const r = or(condition("a", "equals", 1), condition("b", "equals", 2));
const result = await engine.evaluate(r, context({ a: 99, b: 2 }));
expect(result.passed).toBe(true);
});
it("OR returns false when no child passes", { timeout: 5000 }, async () => {
const engine = new RuleEngine();
const r = or(condition("a", "equals", 1), condition("b", "equals", 2));
const result = await engine.evaluate(r, context({ a: 9, b: 9 }));
expect(result.passed).toBe(false);
});
it("NOT inverts", { timeout: 5000 }, async () => {
const engine = new RuleEngine();
const r = not(condition("a", "equals", 1));
expect((await engine.evaluate(r, context({ a: 2 }))).passed).toBe(true);
expect((await engine.evaluate(r, context({ a: 1 }))).passed).toBe(
false
);
});
it("XOR returns true when exactly one passes", {
timeout: 5000,
}, async () => {
const engine = new RuleEngine();
const r: Rule = {
type: "logical",
operator: "xor",
rules: [condition("a", "equals", 1), condition("b", "equals", 2)],
};
expect((await engine.evaluate(r, context({ a: 1, b: 9 }))).passed).toBe(
true
);
expect((await engine.evaluate(r, context({ a: 1, b: 2 }))).passed).toBe(
false
);
expect((await engine.evaluate(r, context({ a: 9, b: 9 }))).passed).toBe(
false
);
});
it("XOR rejects too few children", { timeout: 5000 }, async () => {
const engine = new RuleEngine();
const r: Rule = {
type: "logical",
operator: "xor",
rules: [condition("a", "equals", 1)],
};
const result = await engine.evaluate(r, context({ a: 1 }));
expect(result.passed).toBe(false);
expect(result.errors[0]?.code).toBe("RULES.RULE_INVALID");
});
it("NOT rejects multi-child", { timeout: 5000 }, async () => {
const engine = new RuleEngine();
const r: Rule = {
type: "logical",
operator: "not",
rules: [condition("a", "equals", 1), condition("b", "equals", 2)],
};
const result = await engine.evaluate(r, context({ a: 1 }));
expect(result.passed).toBe(false);
expect(result.errors[0]?.code).toBe("RULES.RULE_INVALID");
});
it("group AND short-circuits", { timeout: 5000 }, async () => {
const engine = new RuleEngine();
const r: Rule = {
type: "group",
name: "g",
operator: "and",
rules: [condition("a", "equals", 9), condition("b", "equals", 2)],
};
expect((await engine.evaluate(r, context({ a: 1, b: 2 }))).passed).toBe(
false
);
});
it("group OR returns true when any passes", { timeout: 5000 }, async () => {
const engine = new RuleEngine();
const r: Rule = {
type: "group",
name: "g",
operator: "or",
rules: [condition("a", "equals", 9), condition("b", "equals", 2)],
};
expect((await engine.evaluate(r, context({ a: 1, b: 2 }))).passed).toBe(
true
);
});
it("group defaults to AND when operator omitted", {
timeout: 5000,
}, async () => {
const engine = new RuleEngine();
const r: Rule = {
type: "group",
name: "g",
rules: [condition("a", "equals", 1), condition("b", "equals", 2)],
};
expect((await engine.evaluate(r, context({ a: 1, b: 2 }))).passed).toBe(
true
);
});
it("custom rule passes through validate fn", {
timeout: 5000,
}, async () => {
const engine = new RuleEngine();
const r: Rule = {
type: "custom",
name: "c",
validate: async (data) => (data as { ok: boolean }).ok,
};
expect((await engine.evaluate(r, context({ ok: true }))).passed).toBe(
true
);
expect((await engine.evaluate(r, context({ ok: false }))).passed).toBe(
false
);
});
it("computed rule resolves both sides", { timeout: 5000 }, async () => {
const engine = new RuleEngine();
const r: Rule = {
type: "computed",
field: "x",
operator: "equals",
compute: async (ctx) => (ctx.data as { y: number }).y,
};
expect((await engine.evaluate(r, context({ x: 5, y: 5 }))).passed).toBe(
true
);
expect((await engine.evaluate(r, context({ x: 5, y: 9 }))).passed).toBe(
false
);
});
it("raises RULES.ENGINE_DEPTH_EXCEEDED past maxDepth", {
timeout: 5000,
}, async () => {
const engine = new RuleEngine({ maxDepth: 2 });
const r: Rule = and(and(and(condition("a", "equals", 1))));
const result = await engine.evaluate(r, context({ a: 1 }));
expect(result.passed).toBe(false);
expect(result.errors[0]?.code).toBe("RULES.ENGINE_DEPTH_EXCEEDED");
});
it("aborts when signal is pre-aborted", { timeout: 5000 }, async () => {
const engine = new RuleEngine();
const controller = new AbortController();
controller.abort();
const result = await engine.evaluate(
condition("a", "equals", 1),
context({ a: 1 }),
{
signal: controller.signal,
}
);
expect(result.passed).toBe(false);
expect(result.errors[0]?.code).toBe("RULES.ENGINE_ABORTED");
});
it("defaultSignal aborts at engine scope", { timeout: 5000 }, async () => {
const controller = new AbortController();
controller.abort();
const engine = new RuleEngine({ defaultSignal: controller.signal });
const result = await engine.evaluate(
condition("a", "equals", 1),
context({ a: 1 })
);
expect(result.errors[0]?.code).toBe("RULES.ENGINE_ABORTED");
});
it("rejects malformed Rule with RULES.RULE_INVALID", {
timeout: 5000,
}, async () => {
const engine = new RuleEngine();
const result = await engine.evaluate(
{ not: "a rule" } as unknown as Rule,
context({})
);
expect(result.passed).toBe(false);
expect(result.errors[0]?.code).toBe("RULES.RULE_INVALID");
});
it("emits trace entries when trace: true", { timeout: 5000 }, async () => {
const engine = new RuleEngine({ trace: true });
const r = and(condition("a", "equals", 1), condition("b", "equals", 2));
const result = await engine.evaluate(r, context({ a: 1, b: 2 }));
expect(result.trace?.entries.length).toBeGreaterThan(0);
const entry = result.trace?.entries[0];
expect(entry?.outcome).toBe("pass");
});
it("omits trace by default", { timeout: 5000 }, async () => {
const engine = new RuleEngine();
const result = await engine.evaluate(
condition("a", "equals", 1),
context({ a: 1 })
);
expect(result.trace).toBeUndefined();
});
it("override option trace beats engine default", {
timeout: 5000,
}, async () => {
const engine = new RuleEngine({ trace: true });
const result = await engine.evaluate(
condition("a", "equals", 1),
context({ a: 1 }),
{
trace: false,
}
);
expect(result.trace).toBeUndefined();
});
it("invalid config raises", { timeout: 5000 }, () => {
expect(() => new RuleEngine({ maxDepth: 0 } as never)).toThrow(
/RULES\.CONTEXT_INVALID/
);
});
it("unknown operator raises and increments counter", {
timeout: 5000,
}, async () => {
const engine = new RuleEngine();
const r: Rule = {
type: "condition",
field: "a",
operator: "totallyBogus" as never,
value: 1,
};
const result = await engine.evaluate(r, context({ a: 1 }));
expect(result.passed).toBe(false);
expect(result.errors[0]?.code).toBe("RULES.OPERATOR_NOT_FOUND");
});
it("wraps unexpected throws as ENGINE_EVALUATION_FAILED", {
timeout: 5000,
}, async () => {
const engine = new RuleEngine();
const r: Rule = {
type: "custom",
name: "boom",
validate: () => {
throw new Error("kaboom");
},
};
const result = await engine.evaluate(r, context({}));
expect(result.errors[0]?.code).toBe("RULES.ENGINE_EVALUATION_FAILED");
expect(result.errors[0]?.message).toBe("kaboom");
});
it("wraps non-Error throws as ENGINE_EVALUATION_FAILED", {
timeout: 5000,
}, async () => {
const engine = new RuleEngine();
const r: Rule = {
type: "custom",
name: "boom",
validate: () => {
const literal: unknown = "string-throw";
throw literal;
},
};
const result = await engine.evaluate(r, context({}));
expect(result.errors[0]?.message).toBe("string-throw");
});
it("populates ruleId in trace entry when metadata.id is set", {
timeout: 5000,
}, async () => {
const engine = new RuleEngine({ trace: true });
const r: Rule = {
type: "condition",
field: "a",
operator: "equals",
value: 1,
metadata: { id: "rule-42" },
};
const result = await engine.evaluate(r, context({ a: 1 }));
expect(result.trace?.entries[0]?.ruleId).toBe("rule-42");
});
});
describe("RuleEngine.evaluateAll", () => {
it("preserves rule order", { timeout: 5000 }, async () => {
const engine = new RuleEngine();
const results = await engine.evaluateAll(
[condition("a", "equals", 1), condition("a", "equals", 2)],
context({ a: 1 })
);
expect(results.length).toBe(2);
expect(results[0]?.passed).toBe(true);
expect(results[1]?.passed).toBe(false);
});
});
describe("RuleEngine.evaluateRuleset", () => {
it("AND ruleset passes when all children pass", {
timeout: 5000,
}, async () => {
const engine = new RuleEngine();
const result = await engine.evaluateRuleset(
{
id: "rs",
name: "rs",
operator: "and",
rules: [
condition("a", "equals", 1),
condition("b", "equals", 2),
],
},
context({ a: 1, b: 2 })
);
expect(result.passed).toBe(true);
});
it("OR ruleset passes when any child passes", {
timeout: 5000,
}, async () => {
const engine = new RuleEngine();
const result = await engine.evaluateRuleset(
{
id: "rs",
name: "rs",
operator: "or",
rules: [
condition("a", "equals", 9),
condition("b", "equals", 2),
],
},
context({ a: 1, b: 2 })
);
expect(result.passed).toBe(true);
});
it("OR ruleset fails when no child passes", { timeout: 5000 }, async () => {
const engine = new RuleEngine();
const result = await engine.evaluateRuleset(
{
id: "rs",
name: "rs",
operator: "or",
rules: [
condition("a", "equals", 9),
condition("b", "equals", 9),
],
},
context({ a: 1, b: 2 })
);
expect(result.passed).toBe(false);
expect(result.errors.length).toBeGreaterThan(0);
});
it("failFast short-circuits AND ruleset", { timeout: 5000 }, async () => {
const engine = new RuleEngine();
let secondHit = false;
const result = await engine.evaluateRuleset(
{
id: "rs",
name: "rs",
operator: "and",
failFast: true,
rules: [
condition("a", "equals", 9),
{
type: "custom",
name: "spy",
validate: () => {
secondHit = true;
return true;
},
},
],
},
context({ a: 1 })
);
expect(result.passed).toBe(false);
expect(secondHit).toBe(false);
});
it("defaults operator to and when missing", { timeout: 5000 }, async () => {
const engine = new RuleEngine();
const result = await engine.evaluateRuleset(
{ id: "rs", name: "rs", rules: [condition("a", "equals", 1)] },
context({ a: 1 })
);
expect(result.passed).toBe(true);
});
it("rejects malformed ruleset with RULES.RULESET_INVALID", {
timeout: 5000,
}, async () => {
const engine = new RuleEngine();
const result = await engine.evaluateRuleset(
{ id: "", name: "n", rules: [] } as never,
context({})
);
expect(result.errors[0]?.code).toBe("RULES.RULESET_INVALID");
});
});
describe("RuleEngine.check", () => {
it("returns boolean", { timeout: 5000 }, async () => {
const engine = new RuleEngine();
expect(
await engine.check(condition("a", "equals", 1), context({ a: 1 }))
).toBe(true);
expect(
await engine.check(condition("a", "equals", 1), context({ a: 2 }))
).toBe(false);
});
});
describe("RuleEngine plugins", () => {
const divisibleByOperator: OperatorDefinition<number, number> = {
name: "divisibleBy",
description: "field % expected === 0",
evaluate: (actual, expected) => Number(actual) % Number(expected) === 0,
};
it("plugin installs a custom operator", { timeout: 5000 }, async () => {
const engine = new RuleEngine();
engine.use({
name: "divisible",
install: (r) =>
r.registerOperator("divisibleBy", divisibleByOperator),
});
const r: Rule = {
type: "condition",
field: "n",
operator: "divisibleBy" as never,
value: 3,
};
const result = await engine.evaluate(r, context({ n: 9 }));
expect(result.passed).toBe(true);
});
it("plugin install is idempotent", { timeout: 5000 }, () => {
const engine = new RuleEngine();
let installCount = 0;
const plugin = {
name: "p",
install: () => {
installCount += 1;
},
};
engine.use(plugin).use(plugin);
expect(installCount).toBe(1);
expect(engine.hasPlugin("p")).toBe(true);
});
});

View File

@@ -0,0 +1,611 @@
import z from "zod";
import { DEFAULT_MAX_DEPTH } from "../constants.js";
import { DefaultFieldResolver } from "../context/index.js";
import { RulesError } from "../errors/index.js";
import { getInstruments } from "../otel/index.js";
import {
type ComputedRule,
type ConditionRule,
type CustomRule,
type EvaluationMetadata,
type EvaluationOptions,
type EvaluationResult,
type EvaluationTrace,
type GroupRule,
isComputedRule,
isConditionRule,
isCustomRule,
isGroupRule,
isLogicalRule,
isRule,
type LogicalRule,
type OperatorDefinition,
type Rule,
type RuleContext,
type RuleError,
type Ruleset,
type TraceEntry,
} from "../types/index.js";
import type {
RuleEngineConfig,
RuleEnginePlugin,
RuleEnginePluginRegistrar,
} from "./engine.types.js";
import { compareValues } from "./operators-internal.js";
const ENGINE_CONFIG_SCHEMA = z
.object({
maxDepth: z.number().int().positive().optional(),
trace: z.boolean().optional(),
defaultSignal: z.unknown().optional(),
})
.strict();
const RULESET_SCHEMA = z.object({
id: z.string().min(1),
name: z.string().min(1),
description: z.string().optional(),
rules: z.array(z.unknown()).readonly(),
operator: z.enum(["and", "or"]).optional(),
version: z.string().optional(),
failFast: z.boolean().optional(),
metadata: z.unknown().optional(),
});
/**
* Async rule evaluator. Supports condition / logical / group / custom /
* computed rules; threads `AbortSignal` through every recursive boundary;
* dispatches comparison through built-in operators with plugin-registered
* overrides. Emits OTel histogram (`rules.engine.evaluate.duration_ms`),
* counter (`rules.engine.evaluations_total{outcome}`), and depth gauge.
*/
export class RuleEngine {
private readonly maxDepth: number;
private readonly defaultTrace: boolean;
private readonly defaultSignal?: AbortSignal;
private readonly customOperators = new Map<string, OperatorDefinition>();
private readonly installedPlugins = new Set<string>();
private readonly resolver = new DefaultFieldResolver();
constructor(config: RuleEngineConfig = {}) {
const parsed = ENGINE_CONFIG_SCHEMA.safeParse(config);
if (!parsed.success) {
throw new RulesError(
"RULES.CONTEXT_INVALID",
"invalid RuleEngineConfig",
{
cause: parsed.error,
details: { issues: parsed.error.issues },
}
);
}
this.maxDepth = config.maxDepth ?? DEFAULT_MAX_DEPTH;
this.defaultTrace = config.trace ?? false;
if (config.defaultSignal !== undefined)
this.defaultSignal = config.defaultSignal;
}
use(plugin: RuleEnginePlugin): this {
if (this.installedPlugins.has(plugin.name)) return this;
const registrar: RuleEnginePluginRegistrar = {
registerOperator: (name, definition) => {
this.customOperators.set(name, definition);
},
};
plugin.install(registrar);
this.installedPlugins.add(plugin.name);
return this;
}
hasPlugin(name: string): boolean {
return this.installedPlugins.has(name);
}
async evaluate<
TData extends Record<string, unknown> = Record<string, unknown>,
>(
rule: Rule,
context: RuleContext<TData>,
options: EvaluationOptions = {}
): Promise<EvaluationResult> {
return this.run(rule, context, options);
}
async evaluateAll<
TData extends Record<string, unknown> = Record<string, unknown>,
>(
rules: readonly Rule[],
context: RuleContext<TData>,
options: EvaluationOptions = {}
): Promise<readonly EvaluationResult[]> {
const results: EvaluationResult[] = [];
for (const rule of rules) {
results.push(await this.run(rule, context, options));
}
return results;
}
async evaluateRuleset<
TData extends Record<string, unknown> = Record<string, unknown>,
>(
ruleset: Ruleset,
context: RuleContext<TData>,
options: EvaluationOptions = {}
): Promise<EvaluationResult> {
const parsed = RULESET_SCHEMA.safeParse(ruleset);
if (!parsed.success) {
return this.failure(
"RULES.RULESET_INVALID",
"invalid Ruleset",
parsed.error
);
}
const operator = ruleset.operator ?? "and";
const startedAt = new Date();
const start = performance.now();
const errors: RuleError[] = [];
let passed = operator === "and";
let evaluatedRules = 0;
let depthSeen = 0;
for (const child of ruleset.rules) {
const childResult = await this.run(child, context, options);
evaluatedRules += 1;
if (childResult.metadata !== undefined) {
depthSeen = Math.max(depthSeen, childResult.metadata.maxDepth);
}
if (operator === "and") {
if (!childResult.passed) {
passed = false;
errors.push(...childResult.errors);
if (ruleset.failFast === true) break;
}
} else if (childResult.passed) {
passed = true;
break;
} else {
errors.push(...childResult.errors);
}
}
const endedAt = new Date();
const durationMs = performance.now() - start;
const metadata: EvaluationMetadata = {
startedAt,
endedAt,
durationMs,
evaluatedRules,
maxDepth: depthSeen,
};
this.recordOutcome(passed, durationMs, depthSeen);
return { passed, errors: passed ? [] : errors, metadata };
}
async check<
TData extends Record<string, unknown> = Record<string, unknown>,
>(
rule: Rule,
context: RuleContext<TData>,
options: EvaluationOptions = {}
): Promise<boolean> {
const result = await this.run(rule, context, options);
return result.passed;
}
private async run(
rule: Rule,
context: RuleContext,
options: EvaluationOptions
): Promise<EvaluationResult> {
if (!isRule(rule)) {
return this.failure(
"RULES.RULE_INVALID",
"rule does not match the Rule union"
);
}
const startedAt = new Date();
const start = performance.now();
const signal = options.signal ?? this.defaultSignal;
const traceEnabled = options.trace ?? this.defaultTrace;
const traceEntries: TraceEntry[] = [];
let outcome: "pass" | "fail" | "aborted" | "error" = "fail";
let maxDepthSeen = 0;
const span = getInstruments().tracer.startSpan("rules.engine.evaluate");
try {
const passed = await this.evaluateRule(
rule,
context,
options,
0,
traceEntries,
signal,
(d) => {
if (d > maxDepthSeen) maxDepthSeen = d;
}
);
outcome = passed ? "pass" : "fail";
const endedAt = new Date();
const durationMs = performance.now() - start;
const metadata: EvaluationMetadata = {
startedAt,
endedAt,
durationMs,
evaluatedRules: traceEntries.length || 1,
maxDepth: maxDepthSeen,
};
const trace: EvaluationTrace | undefined = traceEnabled
? { entries: traceEntries }
: undefined;
const result: EvaluationResult = {
passed,
errors: passed ? [] : [this.makeRuleError(rule)],
...(trace !== undefined ? { trace } : {}),
metadata,
};
this.recordOutcome(passed, durationMs, maxDepthSeen);
return result;
} catch (err) {
const endedAt = new Date();
const durationMs = performance.now() - start;
if (
err instanceof RulesError &&
err.code === "RULES.ENGINE_ABORTED"
) {
outcome = "aborted";
} else {
outcome = "error";
}
const errors: RuleError[] = [
err instanceof RulesError
? this.makeErrorFromRulesError(err)
: {
code: "RULES.ENGINE_EVALUATION_FAILED",
message:
err instanceof Error ? err.message : String(err),
severity: "error",
},
];
const metadata: EvaluationMetadata = {
startedAt,
endedAt,
durationMs,
evaluatedRules: traceEntries.length,
maxDepth: maxDepthSeen,
};
this.recordOutcome(false, durationMs, maxDepthSeen, outcome);
return { passed: false, errors, metadata };
} finally {
span.setAttribute("outcome", outcome);
span.end();
}
}
private async evaluateRule(
rule: Rule,
context: RuleContext,
options: EvaluationOptions,
depth: number,
trace: TraceEntry[],
signal: AbortSignal | undefined,
recordDepth: (d: number) => void
): Promise<boolean> {
recordDepth(depth);
this.assertNotAborted(signal);
const limit = options.maxDepth ?? this.maxDepth;
if (depth > limit) {
throw new RulesError(
"RULES.ENGINE_DEPTH_EXCEEDED",
`maximum evaluation depth exceeded: ${depth} > ${limit}`,
{ details: { depth, limit } }
);
}
const start = performance.now();
let passed: boolean;
try {
if (isConditionRule(rule)) {
passed = await this.evaluateCondition(rule, context, signal);
} else if (isLogicalRule(rule)) {
passed = await this.evaluateLogical(
rule,
context,
options,
depth,
trace,
signal,
recordDepth
);
} else if (isGroupRule(rule)) {
passed = await this.evaluateGroup(
rule,
context,
options,
depth,
trace,
signal,
recordDepth
);
} else if (isCustomRule(rule)) {
passed = await this.evaluateCustom(rule, context, signal);
} else if (isComputedRule(rule)) {
passed = await this.evaluateComputed(rule, context, signal);
} else {
throw new RulesError(
"RULES.RULE_INVALID",
`unknown rule type: ${(rule as Rule).type}`
);
}
} catch (err) {
if (err instanceof RulesError) throw err;
throw new RulesError(
"RULES.ENGINE_EVALUATION_FAILED",
err instanceof Error ? err.message : String(err),
{ cause: err }
);
}
trace.push({
ruleType: rule.type,
outcome: passed ? "pass" : "fail",
durationMs: performance.now() - start,
depth,
...(rule.metadata?.id !== undefined
? { ruleId: rule.metadata.id }
: {}),
});
return passed;
}
private async evaluateCondition(
rule: ConditionRule,
context: RuleContext,
signal: AbortSignal | undefined
): Promise<boolean> {
this.assertNotAborted(signal);
const actual = this.resolver.resolve(context.data, rule.field);
return this.compare(actual, rule.operator, rule.value, context);
}
private async evaluateComputed(
rule: ComputedRule,
context: RuleContext,
signal: AbortSignal | undefined
): Promise<boolean> {
this.assertNotAborted(signal);
const actual = this.resolver.resolve(context.data, rule.field);
const expected = await rule.compute(context);
return this.compare(actual, rule.operator, expected, context);
}
private async evaluateLogical(
rule: LogicalRule,
context: RuleContext,
options: EvaluationOptions,
depth: number,
trace: TraceEntry[],
signal: AbortSignal | undefined,
recordDepth: (d: number) => void
): Promise<boolean> {
switch (rule.operator) {
case "and": {
for (const child of rule.rules) {
const ok = await this.evaluateRule(
child,
context,
options,
depth + 1,
trace,
signal,
recordDepth
);
if (!ok) return false;
}
return true;
}
case "or": {
for (const child of rule.rules) {
const ok = await this.evaluateRule(
child,
context,
options,
depth + 1,
trace,
signal,
recordDepth
);
if (ok) return true;
}
return false;
}
case "not": {
if (rule.rules.length !== 1) {
throw new RulesError(
"RULES.RULE_INVALID",
"logical NOT requires exactly one child rule"
);
}
const child = rule.rules[0] as Rule;
const ok = await this.evaluateRule(
child,
context,
options,
depth + 1,
trace,
signal,
recordDepth
);
return !ok;
}
case "xor": {
if (rule.rules.length < 2) {
throw new RulesError(
"RULES.RULE_INVALID",
"logical XOR requires at least two child rules"
);
}
let trueCount = 0;
for (const child of rule.rules) {
const ok = await this.evaluateRule(
child,
context,
options,
depth + 1,
trace,
signal,
recordDepth
);
if (ok) trueCount += 1;
}
return trueCount === 1;
}
default: {
const exhaustive: never = rule.operator;
throw new RulesError(
"RULES.OPERATOR_INVALID",
`unknown logical operator: ${String(exhaustive)}`
);
}
}
}
private async evaluateGroup(
rule: GroupRule,
context: RuleContext,
options: EvaluationOptions,
depth: number,
trace: TraceEntry[],
signal: AbortSignal | undefined,
recordDepth: (d: number) => void
): Promise<boolean> {
const operator = rule.operator ?? "and";
if (operator === "and") {
for (const child of rule.rules) {
const ok = await this.evaluateRule(
child,
context,
options,
depth + 1,
trace,
signal,
recordDepth
);
if (!ok) return false;
}
return true;
}
for (const child of rule.rules) {
const ok = await this.evaluateRule(
child,
context,
options,
depth + 1,
trace,
signal,
recordDepth
);
if (ok) return true;
}
return false;
}
private async evaluateCustom(
rule: CustomRule,
context: RuleContext,
signal: AbortSignal | undefined
): Promise<boolean> {
this.assertNotAborted(signal);
return rule.validate(context.data, context);
}
private compare(
actual: unknown,
operator: string,
expected: unknown,
context: RuleContext
): boolean | Promise<boolean> {
const custom = this.customOperators.get(operator);
if (custom !== undefined) {
return custom.evaluate(actual, expected, context);
}
try {
return compareValues(
actual,
operator as ConditionRule["operator"],
expected
);
} catch (err) {
if (
err instanceof RulesError &&
err.code === "RULES.OPERATOR_NOT_FOUND"
) {
getInstruments().operatorUnknownTotal.add(1, {
name: operator,
});
}
throw err;
}
}
private assertNotAborted(signal: AbortSignal | undefined): void {
if (signal?.aborted === true) {
throw new RulesError(
"RULES.ENGINE_ABORTED",
"evaluation aborted by caller signal"
);
}
}
private recordOutcome(
passed: boolean,
durationMs: number,
depth: number,
override?: "pass" | "fail" | "aborted" | "error"
): void {
const instruments = getInstruments();
const outcome = override ?? (passed ? "pass" : "fail");
instruments.engineEvaluateDuration.record(durationMs, { outcome });
instruments.engineEvaluationsTotal.add(1, { outcome });
instruments.engineDepth.record(depth, { outcome });
}
private makeRuleError(rule: Rule): RuleError {
const msg =
(rule as { message?: string }).message ?? "rule evaluation failed";
return {
code: `RULE_FAILED_${rule.type.toUpperCase()}`,
message: msg,
severity: "error",
...("field" in rule
? { field: (rule as { field: string }).field }
: {}),
};
}
private makeErrorFromRulesError(err: RulesError): RuleError {
const prefix = `${err.code}: `;
const message = err.message.startsWith(prefix)
? err.message.slice(prefix.length)
: err.message;
return {
code: err.code,
message,
severity: "error",
...(err.details !== undefined ? { details: err.details } : {}),
};
}
private failure(
code: "RULES.RULE_INVALID" | "RULES.RULESET_INVALID",
message: string,
_cause?: unknown
): EvaluationResult {
const error: RuleError = { code, message, severity: "error" };
return {
passed: false,
errors: [error],
metadata: {
startedAt: new Date(0),
endedAt: new Date(0),
durationMs: 0,
evaluatedRules: 0,
maxDepth: 0,
},
};
}
}

View File

@@ -0,0 +1,16 @@
import type { OperatorDefinition } from "../types/index.js";
export interface RuleEngineConfig {
readonly maxDepth?: number;
readonly trace?: boolean;
readonly defaultSignal?: AbortSignal;
}
export interface RuleEnginePlugin {
readonly name: string;
readonly install: (registrar: RuleEnginePluginRegistrar) => void;
}
export interface RuleEnginePluginRegistrar {
registerOperator(name: string, definition: OperatorDefinition): void;
}

View File

@@ -0,0 +1,6 @@
export { RuleEngine } from "./engine.js";
export type {
RuleEngineConfig,
RuleEnginePlugin,
RuleEnginePluginRegistrar,
} from "./engine.types.js";

View File

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

View File

@@ -0,0 +1,151 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { compareValues } from "./operators-internal.js";
describe("compareValues — equality + numeric", () => {
it("equals / notEquals (loose)", { timeout: 5000 }, () => {
expect(compareValues(1, "equals", 1)).toBe(true);
expect(compareValues(1, "notEquals", 2)).toBe(true);
});
it("strictEquals / strictNotEquals", { timeout: 5000 }, () => {
expect(compareValues("1", "strictEquals", "1")).toBe(true);
expect(compareValues(1, "strictNotEquals", 2)).toBe(true);
});
it("greater / less", { timeout: 5000 }, () => {
expect(compareValues(5, "greaterThan", 1)).toBe(true);
expect(compareValues(5, "greaterThanOrEqual", 5)).toBe(true);
expect(compareValues(1, "lessThan", 5)).toBe(true);
expect(compareValues(5, "lessThanOrEqual", 5)).toBe(true);
});
it("between / notBetween inclusive", { timeout: 5000 }, () => {
expect(compareValues(5, "between", [1, 10])).toBe(true);
expect(compareValues(1, "between", [1, 10])).toBe(true);
expect(compareValues(10, "between", [1, 10])).toBe(true);
expect(compareValues(11, "between", [1, 10])).toBe(false);
expect(compareValues(11, "notBetween", [1, 10])).toBe(true);
expect(compareValues(5, "notBetween", [1, 10])).toBe(false);
});
});
describe("compareValues — collections", () => {
it("in / notIn against array", { timeout: 5000 }, () => {
expect(compareValues(2, "in", [1, 2, 3])).toBe(true);
expect(compareValues(9, "notIn", [1, 2, 3])).toBe(true);
expect(compareValues(2, "in", "not-array")).toBe(false);
expect(compareValues(9, "notIn", "not-array")).toBe(false);
});
it("contains over array / string", { timeout: 5000 }, () => {
expect(compareValues([1, 2, 3], "contains", 2)).toBe(true);
expect(compareValues("abc", "contains", "b")).toBe(true);
expect(compareValues(42, "contains", 2)).toBe(false);
});
it("notContains over array / string / other", { timeout: 5000 }, () => {
expect(compareValues([1, 2, 3], "notContains", 9)).toBe(true);
expect(compareValues("abc", "notContains", "z")).toBe(true);
expect(compareValues(42, "notContains", 2)).toBe(true);
});
it("containsAll / containsAny / overlaps", { timeout: 5000 }, () => {
expect(compareValues([1, 2, 3], "containsAll", [1, 2])).toBe(true);
expect(compareValues([1, 2, 3], "containsAll", [1, 9])).toBe(false);
expect(compareValues([1, 2, 3], "containsAny", [9, 2])).toBe(true);
expect(compareValues([1, 2, 3], "containsAny", [9, 8])).toBe(false);
expect(compareValues([1, 2, 3], "overlaps", [3, 4])).toBe(true);
expect(compareValues([1, 2, 3], "overlaps", [9])).toBe(false);
expect(compareValues("not-array", "containsAll", [1])).toBe(false);
expect(compareValues("not-array", "containsAny", [1])).toBe(false);
expect(compareValues("not-array", "overlaps", [1])).toBe(false);
});
});
describe("compareValues — existence + type", () => {
it("exists / notExists", { timeout: 5000 }, () => {
expect(compareValues(0, "exists", true)).toBe(true);
expect(compareValues(undefined, "exists", true)).toBe(false);
expect(compareValues(undefined, "notExists", true)).toBe(true);
expect(compareValues(0, "notExists", true)).toBe(false);
});
it("isNull / isNotNull", { timeout: 5000 }, () => {
expect(compareValues(null, "isNull", true)).toBe(true);
expect(compareValues(0, "isNull", true)).toBe(false);
expect(compareValues(0, "isNotNull", true)).toBe(true);
expect(compareValues(null, "isNotNull", true)).toBe(false);
});
it("isEmpty / isNotEmpty", { timeout: 5000 }, () => {
expect(compareValues(null, "isEmpty", true)).toBe(true);
expect(compareValues(undefined, "isEmpty", true)).toBe(true);
expect(compareValues("", "isEmpty", true)).toBe(true);
expect(compareValues([], "isEmpty", true)).toBe(true);
expect(compareValues({}, "isEmpty", true)).toBe(true);
expect(compareValues(0, "isEmpty", true)).toBe(false);
expect(compareValues(null, "isNotEmpty", true)).toBe(false);
expect(compareValues("abc", "isNotEmpty", true)).toBe(true);
expect(compareValues([1], "isNotEmpty", true)).toBe(true);
expect(compareValues({ a: 1 }, "isNotEmpty", true)).toBe(true);
expect(compareValues(0, "isNotEmpty", true)).toBe(true);
});
it("isType / isNotType", { timeout: 5000 }, () => {
expect(compareValues("a", "isType", "string")).toBe(true);
expect(compareValues(1, "isType", "string")).toBe(false);
expect(compareValues(1, "isNotType", "string")).toBe(true);
expect(compareValues("a", "isNotType", "string")).toBe(false);
});
it("hasProperty checks own keys", { timeout: 5000 }, () => {
expect(compareValues({ a: 1 }, "hasProperty", "a")).toBe(true);
expect(compareValues({ a: 1 }, "hasProperty", "b")).toBe(false);
expect(compareValues(null, "hasProperty", "a")).toBe(false);
expect(compareValues("not-an-obj", "hasProperty", "length")).toBe(
false
);
});
});
describe("compareValues — length", () => {
it("lengthEquals / GreaterThan / LessThan for strings", {
timeout: 5000,
}, () => {
expect(compareValues("abc", "lengthEquals", 3)).toBe(true);
expect(compareValues("abcd", "lengthGreaterThan", 3)).toBe(true);
expect(compareValues("ab", "lengthLessThan", 3)).toBe(true);
});
it("length on arrays / objects / non-countables", { timeout: 5000 }, () => {
expect(compareValues([1, 2], "lengthEquals", 2)).toBe(true);
expect(compareValues({ a: 1, b: 2 }, "lengthEquals", 2)).toBe(true);
expect(compareValues(42, "lengthEquals", 2)).toBe(false);
expect(compareValues(null, "lengthLessThan", 5)).toBe(false);
});
});
describe("compareValues — string", () => {
it("startsWith / endsWith / matches / notMatches", {
timeout: 5000,
}, () => {
expect(compareValues("abcd", "startsWith", "ab")).toBe(true);
expect(compareValues("abcd", "endsWith", "cd")).toBe(true);
expect(compareValues("abcd", "matches", "^a.*d$")).toBe(true);
expect(compareValues("abcd", "matches", /^a.*d$/)).toBe(true);
expect(compareValues(1, "matches", "^.*$")).toBe(false);
expect(compareValues(1, "notMatches", "^.*$")).toBe(true);
expect(compareValues("abcd", "notMatches", "^z")).toBe(true);
expect(compareValues("abcd", "notMatches", /^z/)).toBe(true);
expect(compareValues(42, "startsWith", "4")).toBe(false);
expect(compareValues(42, "endsWith", "2")).toBe(false);
});
});
describe("compareValues — unknown", () => {
it("raises RULES.OPERATOR_NOT_FOUND", { timeout: 5000 }, () => {
expect(() => compareValues(1, "bogus" as never, 1)).toThrow(
/RULES\.OPERATOR_NOT_FOUND/
);
});
});

View File

@@ -0,0 +1,154 @@
import { RulesError } from "../errors/index.js";
import type { ComparisonOperator } from "../types/index.js";
/**
* Internal comparison dispatcher used by the engine until the dedicated
* `operators` module ships. Mirrors the 30 documented `ComparisonOperator`
* names. Unknown operators raise `RULES.OPERATOR_NOT_FOUND`. The engine's
* plugin registry overrides any built-in by name.
*/
export function compareValues(
actual: unknown,
operator: ComparisonOperator,
expected: unknown
): boolean {
switch (operator) {
case "equals":
return actual === expected;
case "notEquals":
return actual !== expected;
case "strictEquals":
return actual === expected;
case "strictNotEquals":
return actual !== expected;
case "greaterThan":
return (actual as number) > (expected as number);
case "greaterThanOrEqual":
return (actual as number) >= (expected as number);
case "lessThan":
return (actual as number) < (expected as number);
case "lessThanOrEqual":
return (actual as number) <= (expected as number);
case "between": {
const [min, max] = expected as readonly [number, number];
const v = actual as number;
return v >= min && v <= max;
}
case "notBetween": {
const [min, max] = expected as readonly [number, number];
const v = actual as number;
return v < min || v > max;
}
case "in":
return Array.isArray(expected) && expected.includes(actual);
case "notIn":
return Array.isArray(expected) && !expected.includes(actual);
case "contains":
if (Array.isArray(actual)) return actual.includes(expected);
if (typeof actual === "string")
return actual.includes(expected as string);
return false;
case "notContains":
if (Array.isArray(actual)) return !actual.includes(expected);
if (typeof actual === "string")
return !actual.includes(expected as string);
return true;
case "containsAll":
return (
Array.isArray(actual) &&
Array.isArray(expected) &&
expected.every((e) => actual.includes(e))
);
case "containsAny":
return (
Array.isArray(actual) &&
Array.isArray(expected) &&
expected.some((e) => actual.includes(e))
);
case "overlaps":
return (
Array.isArray(actual) &&
Array.isArray(expected) &&
actual.some((a) => expected.includes(a))
);
case "exists":
return actual !== undefined;
case "notExists":
return actual === undefined;
case "isNull":
return actual === null;
case "isNotNull":
return actual !== null;
case "isEmpty":
if (actual === null || actual === undefined) return true;
if (typeof actual === "string") return actual.length === 0;
if (Array.isArray(actual)) return actual.length === 0;
if (typeof actual === "object")
return Object.keys(actual).length === 0;
return false;
case "isNotEmpty":
if (actual === null || actual === undefined) return false;
if (typeof actual === "string") return actual.length > 0;
if (Array.isArray(actual)) return actual.length > 0;
if (typeof actual === "object")
return Object.keys(actual).length > 0;
return true;
case "isType":
return typeof actual === expected;
case "isNotType":
return typeof actual !== expected;
case "hasProperty":
return (
typeof actual === "object" &&
actual !== null &&
Object.hasOwn(actual as object, expected as PropertyKey)
);
case "lengthEquals":
return countable(actual) === (expected as number);
case "lengthGreaterThan":
return countable(actual) > (expected as number);
case "lengthLessThan":
return countable(actual) < (expected as number);
case "startsWith":
return (
typeof actual === "string" &&
actual.startsWith(expected as string)
);
case "endsWith":
return (
typeof actual === "string" &&
actual.endsWith(expected as string)
);
case "matches": {
if (typeof actual !== "string") return false;
const re =
expected instanceof RegExp
? expected
: new RegExp(expected as string);
return re.test(actual);
}
case "notMatches": {
if (typeof actual !== "string") return true;
const re =
expected instanceof RegExp
? expected
: new RegExp(expected as string);
return !re.test(actual);
}
default: {
const unknownOperator: never = operator;
throw new RulesError(
"RULES.OPERATOR_NOT_FOUND",
`operator not registered: ${String(unknownOperator)}`
);
}
}
}
function countable(value: unknown): number {
if (typeof value === "string") return value.length;
if (Array.isArray(value)) return value.length;
if (value !== null && typeof value === "object")
return Object.keys(value).length;
return Number.NaN;
}

View File

@@ -0,0 +1,7 @@
export {
isRulesError,
RULES_CODES,
RulesError,
type RulesErrorCode,
type RulesErrorOptions,
} from "./rules-error.js";

View File

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

View File

@@ -0,0 +1,106 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { isRulesError, RULES_CODES, RulesError } from "./rules-error.js";
describe("RulesError", () => {
it("carries the requested code", { timeout: 5000 }, () => {
const err = new RulesError("RULES.ENGINE_DEPTH_EXCEEDED", "too deep");
expect(err.code).toBe("RULES.ENGINE_DEPTH_EXCEEDED");
expect(err.message).toContain("too deep");
expect(err.message).toContain("RULES.ENGINE_DEPTH_EXCEEDED");
expect(err.name).toBe("RulesError");
});
it("preserves cause chain", { timeout: 5000 }, () => {
const root = new Error("root cause");
const err = new RulesError(
"RULES.ENGINE_EVALUATION_FAILED",
"wrapped",
{
cause: root,
}
);
expect(err.cause).toBe(root);
});
it("preserves traceId when supplied", { timeout: 5000 }, () => {
const err = new RulesError("RULES.RULE_INVALID", "bad rule", {
traceId: "trace-1",
});
expect(err.traceId).toBe("trace-1");
});
it("attaches details when supplied", { timeout: 5000 }, () => {
const details = { field: "age", expected: 18, actual: 12 };
const err = new RulesError("RULES.FIELD_RESOLUTION_FAILED", "missing", {
details,
});
expect(err.details).toEqual(details);
});
it("omits details when not supplied", { timeout: 5000 }, () => {
const err = new RulesError("RULES.OPERATOR_NOT_FOUND", "unknown");
expect(err.details).toBeUndefined();
});
});
describe("isRulesError", () => {
it("returns true for RulesError instances", { timeout: 5000 }, () => {
const err = new RulesError("RULES.SCHEMA_INVALID", "bad schema");
expect(isRulesError(err)).toBe(true);
});
it("returns false for plain errors", { timeout: 5000 }, () => {
expect(isRulesError(new Error("plain"))).toBe(false);
});
it("returns false for non-error values", { timeout: 5000 }, () => {
expect(isRulesError("string")).toBe(false);
expect(isRulesError(42)).toBe(false);
expect(isRulesError(null)).toBe(false);
expect(isRulesError(undefined)).toBe(false);
expect(isRulesError({ code: "RULES.RULE_INVALID" })).toBe(false);
});
});
describe("RULES_CODES", () => {
it("registers 24 codes prefixed with RULES.", { timeout: 5000 }, () => {
const entries = Object.values(RULES_CODES);
expect(entries.length).toBe(24);
for (const code of entries) {
expect(code.startsWith("RULES.")).toBe(true);
}
});
it("contains every code documented in the spec", { timeout: 5000 }, () => {
const expected: readonly string[] = [
"RULES.ENGINE_DEPTH_EXCEEDED",
"RULES.ENGINE_EVALUATION_FAILED",
"RULES.ENGINE_ABORTED",
"RULES.ENGINE_TIMEOUT",
"RULES.OPERATOR_NOT_FOUND",
"RULES.OPERATOR_INVALID",
"RULES.FIELD_RESOLUTION_FAILED",
"RULES.CONTEXT_INVALID",
"RULES.ACTION_TIMEOUT",
"RULES.ACTION_ABORTED",
"RULES.ACTION_HANDLER_FAILED",
"RULES.ACTION_NOT_FOUND",
"RULES.SCHEMA_INVALID",
"RULES.VALIDATOR_INVALID",
"RULES.VALIDATOR_NOT_FOUND",
"RULES.EVENT_TIMEOUT",
"RULES.EVENT_ABORTED",
"RULES.HOOK_INVALID",
"RULES.SERIALIZE_FAILED",
"RULES.DESERIALIZE_FAILED",
"RULES.RULE_INVALID",
"RULES.RULESET_INVALID",
"RULES.OPTIMIZER_FAILED",
"RULES.PLUGIN_FAILED",
];
const entries = Object.values(RULES_CODES);
for (const code of expected) {
expect(entries).toContain(code);
}
});
});

View File

@@ -0,0 +1,101 @@
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/rules`.
*
* Every failure mode reachable from a public entrypoint is mapped to one of
* these codes. The list is frozen at module load time — adding a new code
* requires an OpenSpec change. Every code MUST be thrown by ≥1 production
* path; the openspec-conformance gate flags any unused entry.
*/
export const RULES_CODES = defineCodes("RULES", [
"ENGINE_DEPTH_EXCEEDED",
"ENGINE_EVALUATION_FAILED",
"ENGINE_ABORTED",
"ENGINE_TIMEOUT",
"OPERATOR_NOT_FOUND",
"OPERATOR_INVALID",
"FIELD_RESOLUTION_FAILED",
"CONTEXT_INVALID",
"ACTION_TIMEOUT",
"ACTION_ABORTED",
"ACTION_HANDLER_FAILED",
"ACTION_NOT_FOUND",
"SCHEMA_INVALID",
"VALIDATOR_INVALID",
"VALIDATOR_NOT_FOUND",
"EVENT_TIMEOUT",
"EVENT_ABORTED",
"HOOK_INVALID",
"SERIALIZE_FAILED",
"DESERIALIZE_FAILED",
"RULE_INVALID",
"RULESET_INVALID",
"OPTIMIZER_FAILED",
"PLUGIN_FAILED",
] as const);
export type RulesErrorCode =
| "RULES.ENGINE_DEPTH_EXCEEDED"
| "RULES.ENGINE_EVALUATION_FAILED"
| "RULES.ENGINE_ABORTED"
| "RULES.ENGINE_TIMEOUT"
| "RULES.OPERATOR_NOT_FOUND"
| "RULES.OPERATOR_INVALID"
| "RULES.FIELD_RESOLUTION_FAILED"
| "RULES.CONTEXT_INVALID"
| "RULES.ACTION_TIMEOUT"
| "RULES.ACTION_ABORTED"
| "RULES.ACTION_HANDLER_FAILED"
| "RULES.ACTION_NOT_FOUND"
| "RULES.SCHEMA_INVALID"
| "RULES.VALIDATOR_INVALID"
| "RULES.VALIDATOR_NOT_FOUND"
| "RULES.EVENT_TIMEOUT"
| "RULES.EVENT_ABORTED"
| "RULES.HOOK_INVALID"
| "RULES.SERIALIZE_FAILED"
| "RULES.DESERIALIZE_FAILED"
| "RULES.RULE_INVALID"
| "RULES.RULESET_INVALID"
| "RULES.OPTIMIZER_FAILED"
| "RULES.PLUGIN_FAILED";
export interface RulesErrorOptions {
readonly cause?: unknown;
readonly traceId?: string;
readonly details?: Readonly<Record<string, unknown>>;
}
/**
* Typed error class for the rules package. Extends `SuaveplanError` so that
* every failure carries a stable code, structured context, and integrates
* with the cross-package wire format / OTel adapter.
*/
export class RulesError extends SuaveplanError<RulesErrorCode> {
readonly details?: Readonly<Record<string, unknown>>;
constructor(
code: RulesErrorCode,
message: string,
opts: RulesErrorOptions = {}
) {
super(code, `${code}: ${message}`, {
cause: opts.cause,
...(opts.traceId !== undefined ? { traceId: opts.traceId } : {}),
});
this.name = "RulesError";
if (opts.details !== undefined) {
this.details = opts.details;
}
}
}
/**
* Return `true` when an unknown thrown value is a `RulesError` instance.
* Useful in catch arms that need to discriminate between rules-package
* failures and unrelated runtime errors.
*/
export function isRulesError(value: unknown): value is RulesError {
return value instanceof RulesError;
}

View File

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

View File

@@ -0,0 +1,237 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { BaseEventPayload } from "../types/index.js";
import {
createEventBus,
createHookManager,
EventBus,
HookManager,
} from "./event-bus.js";
function payload(
type: BaseEventPayload["type"],
timestamp = 0
): BaseEventPayload {
return { type, timestamp };
}
describe("EventBus.on / off / emit", () => {
it("invokes a registered listener", { timeout: 5000 }, async () => {
const bus = createEventBus();
const seen: string[] = [];
bus.on("rule.passed", (p) => {
seen.push(p.type);
});
await bus.emit(payload("rule.passed"));
expect(seen).toEqual(["rule.passed"]);
});
it("off removes a listener", { timeout: 5000 }, async () => {
const bus = createEventBus();
const seen: string[] = [];
const listener = (p: BaseEventPayload): void => {
seen.push(p.type);
};
bus.on("rule.passed", listener);
bus.off("rule.passed", listener);
await bus.emit(payload("rule.passed"));
expect(seen).toEqual([]);
});
it("on returns an unsubscribe fn", { timeout: 5000 }, async () => {
const bus = createEventBus();
let count = 0;
const off = bus.on("rule.passed", () => {
count += 1;
});
off();
await bus.emit(payload("rule.passed"));
expect(count).toBe(0);
});
it("off reports false for unknown listener", { timeout: 5000 }, () => {
const bus = new EventBus();
expect(bus.off("rule.passed", () => undefined)).toBe(false);
});
it("once fires exactly once", { timeout: 5000 }, async () => {
const bus = createEventBus();
let count = 0;
bus.once("rule.passed", () => {
count += 1;
});
await bus.emit(payload("rule.passed"));
await bus.emit(payload("rule.passed"));
expect(count).toBe(1);
});
it("emitSync fires listeners but does not await them", {
timeout: 5000,
}, () => {
const bus = createEventBus();
let seen = false;
bus.on("rule.passed", () => {
seen = true;
});
bus.hook("rule.passed", { priority: 1, listener: () => undefined });
bus.emitSync(payload("rule.passed"));
expect(seen).toBe(true);
});
});
describe("EventBus.hook priority ordering", () => {
it("higher priority hooks fire first", { timeout: 5000 }, async () => {
const bus = createEventBus();
const seen: number[] = [];
bus.hook("rule.passed", {
priority: 1,
listener: () => void seen.push(1),
});
bus.hook("rule.passed", {
priority: 10,
listener: () => void seen.push(10),
});
bus.hook("rule.passed", {
priority: 5,
listener: () => void seen.push(5),
});
await bus.emit(payload("rule.passed"));
expect(seen).toEqual([10, 5, 1]);
});
it("hook unsubscribe removes the hook", { timeout: 5000 }, async () => {
const bus = createEventBus();
const seen: number[] = [];
const off = bus.hook("rule.passed", {
priority: 1,
listener: () => void seen.push(1),
});
off();
await bus.emit(payload("rule.passed"));
expect(seen).toEqual([]);
});
it("hook rejects non-finite priorities", { timeout: 5000 }, () => {
const bus = createEventBus();
expect(() =>
bus.hook("rule.passed", {
priority: Number.NaN,
listener: () => undefined,
})
).toThrow(/RULES\.HOOK_INVALID/);
});
});
describe("EventBus history", () => {
it("is bounded to historySize", { timeout: 5000 }, async () => {
const bus = createEventBus({ historySize: 3 });
for (let i = 0; i < 5; i++) {
await bus.emit(payload("rule.passed", i));
}
const history = bus.getHistory();
expect(history.length).toBe(3);
expect(history.map((h) => h.timestamp)).toEqual([2, 3, 4]);
});
it("clearHistory empties the buffer", { timeout: 5000 }, async () => {
const bus = createEventBus();
await bus.emit(payload("rule.passed"));
bus.clearHistory();
expect(bus.getHistory()).toEqual([]);
});
it("rejects historySize <= 0", { timeout: 5000 }, () => {
expect(() => new EventBus({ historySize: 0 })).toThrow(
/RULES\.HOOK_INVALID/
);
});
it("now uses injected Clock", { timeout: 5000 }, () => {
const bus = createEventBus({ clock: () => 42 });
expect(bus.now()).toBe(42);
});
it("now defaults to Date.now", { timeout: 5000 }, () => {
const bus = createEventBus();
const t = bus.now();
expect(t).toBeGreaterThan(0);
});
});
describe("EventBus.waitFor", () => {
it("resolves with the payload when matching event fires", {
timeout: 5000,
}, async () => {
const bus = createEventBus();
const pending = bus.waitFor("rule.passed");
await bus.emit(payload("rule.passed", 7));
const got = await pending;
expect(got.timestamp).toBe(7);
});
it("rejects with RULES.EVENT_TIMEOUT past deadline", {
timeout: 5000,
}, async () => {
const bus = createEventBus();
await expect(
bus.waitFor("rule.passed", { timeoutMs: 10 })
).rejects.toThrow(/RULES\.EVENT_TIMEOUT/);
});
it("rejects with RULES.EVENT_ABORTED on caller signal", {
timeout: 5000,
}, async () => {
const bus = createEventBus();
const controller = new AbortController();
setTimeout(() => controller.abort(), 5);
await expect(
bus.waitFor("rule.passed", { signal: controller.signal })
).rejects.toThrow(/RULES\.EVENT_ABORTED/);
});
it("rejects with RULES.EVENT_ABORTED when pre-aborted", {
timeout: 5000,
}, async () => {
const bus = createEventBus();
const controller = new AbortController();
controller.abort();
await expect(
bus.waitFor("rule.passed", { signal: controller.signal })
).rejects.toThrow(/RULES\.EVENT_ABORTED/);
});
});
describe("HookManager", () => {
it("registers + invokes in priority order", { timeout: 5000 }, async () => {
const mgr = createHookManager();
const seen: number[] = [];
mgr.register({ priority: 1, listener: () => void seen.push(1) });
mgr.register({ priority: 10, listener: () => void seen.push(10) });
await mgr.invoke(payload("rule.passed"));
expect(seen).toEqual([10, 1]);
expect(mgr.size()).toBe(2);
});
it("remove returns false for unknown hook", { timeout: 5000 }, () => {
const mgr = new HookManager();
expect(mgr.remove({ priority: 1, listener: () => undefined })).toBe(
false
);
});
it("register unsubscribe removes", { timeout: 5000 }, async () => {
const mgr = new HookManager();
const off = mgr.register({ priority: 1, listener: () => undefined });
off();
expect(mgr.size()).toBe(0);
});
it("rejects non-finite priorities", { timeout: 5000 }, () => {
const mgr = new HookManager();
expect(() =>
mgr.register({
priority: Number.POSITIVE_INFINITY,
listener: () => undefined,
})
).toThrow(/RULES\.HOOK_INVALID/);
});
});

View File

@@ -0,0 +1,268 @@
import type { Clock } from "@suaveplan/types";
import { DEFAULT_EVENT_HISTORY } from "../constants.js";
import { RulesError } from "../errors/index.js";
import { getInstruments } from "../otel/index.js";
import type {
BaseEventPayload,
EventListener,
EventPayload,
PrioritizedHook,
RuleEngineEvent,
} from "../types/index.js";
export interface EventBusOptions {
readonly clock?: Clock;
readonly historySize?: number;
}
/**
* Pub/sub bus with priority-ordered hooks, bounded history, and abortable
* `waitFor`. Per topic, listeners fire in registration order; hooks fire
* sorted by descending priority (higher first). The bounded history retains
* the most recent N payloads (default `DEFAULT_EVENT_HISTORY = 1000`).
*/
export class EventBus {
private readonly clock: Clock;
private readonly historySize: number;
private readonly listeners = new Map<RuleEngineEvent, Set<EventListener>>();
private readonly hooks = new Map<RuleEngineEvent, PrioritizedHook[]>();
private readonly historyBuffer: EventPayload[] = [];
constructor(options: EventBusOptions = {}) {
this.clock = options.clock ?? (() => Date.now());
const size = options.historySize ?? DEFAULT_EVENT_HISTORY;
if (size <= 0) {
throw new RulesError(
"RULES.HOOK_INVALID",
"historySize must be > 0"
);
}
this.historySize = size;
}
on(event: RuleEngineEvent, listener: EventListener): () => void {
this.bucket(this.listeners, event).add(listener);
return () => this.off(event, listener);
}
off(event: RuleEngineEvent, listener: EventListener): boolean {
const bucket = this.listeners.get(event);
return bucket?.delete(listener) ?? false;
}
once(event: RuleEngineEvent, listener: EventListener): () => void {
const wrapper: EventListener = (payload) => {
this.off(event, wrapper);
return listener(payload);
};
return this.on(event, wrapper);
}
hook(event: RuleEngineEvent, hook: PrioritizedHook): () => void {
if (!Number.isFinite(hook.priority)) {
throw new RulesError(
"RULES.HOOK_INVALID",
"hook.priority must be finite"
);
}
const bucket = this.hookBucket(event);
bucket.push(hook);
bucket.sort((a, b) => b.priority - a.priority);
return () => {
const index = bucket.indexOf(hook);
if (index >= 0) bucket.splice(index, 1);
};
}
async emit<P extends BaseEventPayload>(payload: P): Promise<void> {
const finalPayload: EventPayload = {
type: payload.type,
timestamp: payload.timestamp,
...(payload as Omit<EventPayload, "type" | "timestamp">),
};
this.recordHistory(finalPayload);
getInstruments().eventEmittedTotal.add(1, { type: payload.type });
const hooks = this.hooks.get(payload.type);
if (hooks !== undefined) {
for (const hook of [...hooks]) {
await hook.listener(payload);
}
}
const bucket = this.listeners.get(payload.type);
if (bucket !== undefined) {
for (const listener of [...bucket]) {
await listener(payload);
}
}
}
emitSync<P extends BaseEventPayload>(payload: P): void {
const finalPayload: EventPayload = {
type: payload.type,
timestamp: payload.timestamp,
...(payload as Omit<EventPayload, "type" | "timestamp">),
};
this.recordHistory(finalPayload);
getInstruments().eventEmittedTotal.add(1, { type: payload.type });
const hooks = this.hooks.get(payload.type);
if (hooks !== undefined) {
for (const hook of [...hooks]) {
void hook.listener(payload);
}
}
const bucket = this.listeners.get(payload.type);
if (bucket !== undefined) {
for (const listener of [...bucket]) {
void listener(payload);
}
}
}
async waitFor<P extends BaseEventPayload = EventPayload>(
event: RuleEngineEvent,
options: {
readonly timeoutMs?: number;
readonly signal?: AbortSignal;
} = {}
): Promise<P> {
return new Promise<P>((resolve, reject) => {
const cleanup: (() => void)[] = [];
const finish = (fn: () => void): void => {
for (const c of cleanup) c();
fn();
};
const off = this.on(event, (payload) => {
finish(() => resolve(payload as P));
});
cleanup.push(off);
if (options.signal !== undefined) {
if (options.signal.aborted) {
finish(() =>
reject(
new RulesError(
"RULES.EVENT_ABORTED",
`waitFor(${event}) aborted before subscription`
)
)
);
return;
}
const onAbort = (): void =>
finish(() =>
reject(
new RulesError(
"RULES.EVENT_ABORTED",
`waitFor(${event}) aborted`
)
)
);
options.signal.addEventListener("abort", onAbort, {
once: true,
});
cleanup.push(() =>
options.signal?.removeEventListener("abort", onAbort)
);
}
if (options.timeoutMs !== undefined) {
const timer = setTimeout(
() =>
finish(() =>
reject(
new RulesError(
"RULES.EVENT_TIMEOUT",
`waitFor(${event}) timed out after ${options.timeoutMs}ms`
)
)
),
options.timeoutMs
);
cleanup.push(() => clearTimeout(timer));
}
});
}
getHistory(): readonly EventPayload[] {
return [...this.historyBuffer];
}
clearHistory(): void {
this.historyBuffer.length = 0;
}
now(): number {
return this.clock();
}
private bucket(
map: Map<RuleEngineEvent, Set<EventListener>>,
event: RuleEngineEvent
): Set<EventListener> {
const existing = map.get(event);
if (existing !== undefined) return existing;
const fresh = new Set<EventListener>();
map.set(event, fresh);
return fresh;
}
private hookBucket(event: RuleEngineEvent): PrioritizedHook[] {
const existing = this.hooks.get(event);
if (existing !== undefined) return existing;
const fresh: PrioritizedHook[] = [];
this.hooks.set(event, fresh);
return fresh;
}
private recordHistory(payload: EventPayload): void {
this.historyBuffer.push(payload);
while (this.historyBuffer.length > this.historySize) {
this.historyBuffer.shift();
}
}
}
export function createEventBus(options: EventBusOptions = {}): EventBus {
return new EventBus(options);
}
/**
* Light wrapper that scopes a bag of priority-ordered hooks to a single
* event type. Useful when callers want to manage hooks for one topic
* without holding a reference to the parent `EventBus`.
*/
export class HookManager {
private readonly hooks: PrioritizedHook[] = [];
register(hook: PrioritizedHook): () => void {
if (!Number.isFinite(hook.priority)) {
throw new RulesError(
"RULES.HOOK_INVALID",
"hook.priority must be finite"
);
}
this.hooks.push(hook);
this.hooks.sort((a, b) => b.priority - a.priority);
return () => this.remove(hook);
}
remove(hook: PrioritizedHook): boolean {
const index = this.hooks.indexOf(hook);
if (index < 0) return false;
this.hooks.splice(index, 1);
return true;
}
async invoke<P extends BaseEventPayload>(payload: P): Promise<void> {
for (const hook of [...this.hooks]) {
await hook.listener(payload);
}
}
size(): number {
return this.hooks.length;
}
}
export function createHookManager(): HookManager {
return new HookManager();
}

View File

@@ -0,0 +1,7 @@
export {
createEventBus,
createHookManager,
EventBus,
type EventBusOptions,
HookManager,
} from "./event-bus.js";

View File

@@ -0,0 +1,249 @@
export {
Action,
ActionExecutor,
ActionRegistry,
builtInHandlers,
createAction,
createActionExecutor,
createActionRegistry,
validateActionConfig,
} from "./actions/index.js";
export {
and,
condition,
FieldBuilder,
group,
not,
or,
RuleBuilder,
rule,
} from "./builder/index.js";
export {
DEFAULT_ACTION_PARALLELISM,
DEFAULT_ACTION_TIMEOUT_MS,
DEFAULT_EVENT_HISTORY,
DEFAULT_MAX_DEPTH,
PACKAGE_NAME,
VERSION,
} from "./constants.js";
export {
ContextBuilder,
type ContextBuilderOptions,
type CreateRequestContextInput,
context,
contextWith,
createRequestContext,
createScopedContext,
DefaultContextProvider,
type DefaultContextProviderOptions,
DefaultFieldResolver,
type DefaultFieldResolverOptions,
getField,
hasField,
} from "./context/index.js";
export type {
RuleEngineConfig,
RuleEnginePlugin,
RuleEnginePluginRegistrar,
} from "./engine/index.js";
export { RuleEngine } from "./engine/index.js";
export {
isRulesError,
RULES_CODES,
RulesError,
type RulesErrorCode,
type RulesErrorOptions,
} from "./errors/index.js";
export {
createEventBus,
createHookManager,
EventBus,
type EventBusOptions,
HookManager,
} from "./events/index.js";
export {
andOperator,
BUILTIN_OPERATORS,
betweenOperator,
containsAllOperator,
containsAnyOperator,
containsOperator,
createDefaultOperatorRegistry,
defaultOperatorRegistry,
endsWithOperator,
equalsOperator,
existsOperator,
greaterThanOperator,
greaterThanOrEqualsOperator,
hasPropertyOperator,
inOperator,
isEmptyOperator,
isNotEmptyOperator,
isNotNullOperator,
isNotTypeOperator,
isNullOperator,
isTypeOperator,
lengthEqualsOperator,
lengthGreaterThanOperator,
lengthLessThanOperator,
lessThanOperator,
lessThanOrEqualsOperator,
matchesOperator,
notBetweenOperator,
notContainsOperator,
notEqualsOperator,
notExistsOperator,
notInOperator,
notMatchesOperator,
notOperator,
OperatorRegistry,
orOperator,
overlapsOperator,
startsWithOperator,
strictEqualsOperator,
strictNotEqualsOperator,
xorOperator,
} from "./operators/index.js";
export {
analyzeRule,
countRules,
estimateCost,
findFields,
hasAsyncComponents,
isOptimisationStable,
type OptimizeOptions,
type OptimizeResult,
optimizeRule,
type RuleStats,
} from "./optimizer/index.js";
export {
getInstruments,
type RulesInstruments,
resetInstruments,
} from "./otel/index.js";
export {
areRulesEqual,
cloneRule,
type DeserializeOptions,
deserializeRule,
deserializeRuleFromObject,
deserializeRuleset,
deserializeRulesetFromObject,
FUNCTION_PLACEHOLDER,
hashRule,
SERIALIZED_RULE_SCHEMA,
type SerializedRule,
type SerializedRuleset,
type SerializeOptions,
serializeRule,
serializeRuleset,
serializeRulesetToObject,
serializeRuleToObject,
validateSerializedRule,
} from "./serializer/index.js";
export type {
ActionConfig,
ActionEventPayload,
ActionHandler,
ActionResult,
ActionStatus,
BaseEventPayload,
ComparisonOperator,
ComputedRule,
ConditionEventPayload,
ConditionRule,
ContextProvider,
CustomRule,
CustomValidatorFn,
EngineEventPayload,
EvaluationMetadata,
EvaluationOptions,
EvaluationResult,
EvaluationTrace,
EventListener,
EventPayload,
FieldResolver,
FieldSchema,
FieldType,
FieldValidatorResult,
GroupRule,
LogicalOperator,
LogicalRule,
OperatorDefinition,
OperatorFunction,
OperatorRegistryLike,
PrioritizedHook,
RequestContext,
Rule,
RuleContext,
RuleEngineEvent,
RuleEngineEventMap,
RuleError,
RuleEventPayload,
RuleMetadata,
RulePriority,
RuleStatus,
Ruleset,
Schema,
ScopedContext,
StringFormat,
TraceEntry,
ValidationError,
ValidationResult,
ValidatorContext,
} from "./types/index.js";
export {
isComputedRule,
isConditionRule,
isCustomRule,
isGroupRule,
isLogicalRule,
isRule,
} from "./types/index.js";
export {
alphanumericValidator,
asyncValidator,
CustomValidator,
type CustomValidatorDefinition,
composeValidators,
createDefaultValidatorRegistry,
createValidator,
creditCardValidator,
defaultValidatorRegistry,
emailValidator,
enumValidator,
hexColorValidator,
isoDateValidator,
jsonValidator,
lengthValidator,
patternValidator,
phoneValidator,
rangeValidator,
slugValidator,
strongPasswordValidator,
urlValidator,
uuidValidator,
ValidatorRegistry,
} from "./validators/custom/index.js";
export {
ageValidator,
createFieldValidator,
currencyValidator,
dateFieldValidator,
emailFieldValidator,
FieldValidator,
type FieldValidatorRunOptions,
field,
integerFieldValidator,
passwordFieldValidator,
percentageValidator,
phoneFieldValidator,
positiveNumberValidator,
urlFieldValidator,
usernameFieldValidator,
uuidFieldValidator,
} from "./validators/field/index.js";
export {
SchemaValidator,
type SchemaValidatorOptions,
} from "./validators/schema/index.js";

View File

@@ -0,0 +1,125 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
andOperator,
BUILTIN_OPERATORS,
betweenOperator,
containsOperator,
createDefaultOperatorRegistry,
defaultOperatorRegistry,
equalsOperator,
greaterThanOperator,
notOperator,
orOperator,
startsWithOperator,
xorOperator,
} from "./defaults.js";
describe("BUILTIN_OPERATORS", () => {
it("contains 33 entries", { timeout: 5000 }, () => {
expect(BUILTIN_OPERATORS.length).toBe(33);
});
it("every entry has a name + description + evaluate", {
timeout: 5000,
}, () => {
for (const op of BUILTIN_OPERATORS) {
expect(typeof op.name).toBe("string");
expect(typeof op.description).toBe("string");
expect(typeof op.evaluate).toBe("function");
}
});
it("contains the 30 documented operator names", { timeout: 5000 }, () => {
const expected = [
"equals",
"notEquals",
"greaterThan",
"greaterThanOrEqual",
"lessThan",
"lessThanOrEqual",
"between",
"in",
"notIn",
"contains",
"notContains",
"containsAll",
"containsAny",
"overlaps",
"startsWith",
"endsWith",
"matches",
"exists",
"isNull",
"isNotNull",
"isEmpty",
"isNotEmpty",
"isType",
"hasProperty",
"lengthEquals",
"lengthGreaterThan",
"lengthLessThan",
];
const names = BUILTIN_OPERATORS.map((o) => o.name);
for (const name of expected) {
expect(names).toContain(name);
}
});
});
describe("createDefaultOperatorRegistry", () => {
it("registers every built-in", { timeout: 5000 }, () => {
const r = createDefaultOperatorRegistry();
for (const op of BUILTIN_OPERATORS) {
expect(r.has(op.name)).toBe(true);
}
});
it("returns fresh instances per call", { timeout: 5000 }, () => {
const a = createDefaultOperatorRegistry();
const b = createDefaultOperatorRegistry();
expect(a).not.toBe(b);
});
it("defaultOperatorRegistry contains 30+ entries", {
timeout: 5000,
}, () => {
expect(defaultOperatorRegistry.names().length).toBeGreaterThanOrEqual(
30
);
});
});
describe("built-in operator dispatch", () => {
it("equals operator", { timeout: 5000 }, async () => {
expect(await equalsOperator.evaluate(1, 1)).toBe(true);
expect(await equalsOperator.evaluate(1, 2)).toBe(false);
});
it("greaterThan operator", { timeout: 5000 }, async () => {
expect(await greaterThanOperator.evaluate(5, 1)).toBe(true);
});
it("between operator inclusive bounds", { timeout: 5000 }, async () => {
expect(await betweenOperator.evaluate(1, [1, 10])).toBe(true);
expect(await betweenOperator.evaluate(11, [1, 10])).toBe(false);
});
it("contains operator over string + array", { timeout: 5000 }, async () => {
expect(await containsOperator.evaluate("abc", "b")).toBe(true);
expect(await containsOperator.evaluate([1, 2, 3], 2)).toBe(true);
});
it("startsWith operator", { timeout: 5000 }, async () => {
expect(await startsWithOperator.evaluate("abcd", "ab")).toBe(true);
});
});
describe("logical operators are placeholders", () => {
for (const op of [andOperator, orOperator, notOperator, xorOperator]) {
it(`${op.name} throws when invoked directly`, { timeout: 5000 }, () => {
expect(() => op.evaluate(true, true)).toThrow(
/dispatched by the engine/
);
});
}
});

View File

@@ -0,0 +1,241 @@
import { compareValues } from "../engine/operators-internal.js";
import type { ComparisonOperator, OperatorDefinition } from "../types/index.js";
import { OperatorRegistry } from "./registry.js";
function makeBuiltin(
name: ComparisonOperator,
description: string
): OperatorDefinition {
return {
name,
description,
evaluate: (actual, expected) => compareValues(actual, name, expected),
};
}
/**
* Equality operators — loose and strict.
*/
export const equalsOperator = makeBuiltin("equals", "Loose equality (===).");
export const notEqualsOperator = makeBuiltin(
"notEquals",
"Loose inequality (!==)."
);
export const strictEqualsOperator = makeBuiltin(
"strictEquals",
"Strict equality (===)."
);
export const strictNotEqualsOperator = makeBuiltin(
"strictNotEquals",
"Strict inequality (!==)."
);
/**
* Numeric comparisons.
*/
export const greaterThanOperator = makeBuiltin(
"greaterThan",
"actual > expected."
);
export const greaterThanOrEqualsOperator = makeBuiltin(
"greaterThanOrEqual",
"actual >= expected."
);
export const lessThanOperator = makeBuiltin("lessThan", "actual < expected.");
export const lessThanOrEqualsOperator = makeBuiltin(
"lessThanOrEqual",
"actual <= expected."
);
export const betweenOperator = makeBuiltin(
"between",
"actual is between [min, max] inclusive."
);
export const notBetweenOperator = makeBuiltin(
"notBetween",
"actual is outside [min, max] inclusive bounds."
);
/**
* Collection operators.
*/
export const inOperator = makeBuiltin(
"in",
"actual is an element of expected (array)."
);
export const notInOperator = makeBuiltin(
"notIn",
"actual is NOT an element of expected (array)."
);
export const containsOperator = makeBuiltin(
"contains",
"actual (array or string) contains expected."
);
export const notContainsOperator = makeBuiltin(
"notContains",
"actual (array or string) does NOT contain expected."
);
export const containsAllOperator = makeBuiltin(
"containsAll",
"actual array contains every element of expected array."
);
export const containsAnyOperator = makeBuiltin(
"containsAny",
"actual array contains at least one element of expected array."
);
export const overlapsOperator = makeBuiltin(
"overlaps",
"actual and expected arrays share at least one element."
);
/**
* Existence + null/empty operators.
*/
export const existsOperator = makeBuiltin("exists", "actual !== undefined.");
export const notExistsOperator = makeBuiltin(
"notExists",
"actual === undefined."
);
export const isNullOperator = makeBuiltin("isNull", "actual === null.");
export const isNotNullOperator = makeBuiltin("isNotNull", "actual !== null.");
export const isEmptyOperator = makeBuiltin(
"isEmpty",
"actual is null/undefined or zero-length string/array/object."
);
export const isNotEmptyOperator = makeBuiltin(
"isNotEmpty",
"actual has a non-zero size."
);
/**
* Type operators.
*/
export const isTypeOperator = makeBuiltin(
"isType",
"typeof actual === expected."
);
export const isNotTypeOperator = makeBuiltin(
"isNotType",
"typeof actual !== expected."
);
export const hasPropertyOperator = makeBuiltin(
"hasProperty",
"Object.hasOwn(actual, expected) over objects."
);
/**
* Length operators.
*/
export const lengthEqualsOperator = makeBuiltin(
"lengthEquals",
"actual.length === expected (string/array/object)."
);
export const lengthGreaterThanOperator = makeBuiltin(
"lengthGreaterThan",
"actual.length > expected."
);
export const lengthLessThanOperator = makeBuiltin(
"lengthLessThan",
"actual.length < expected."
);
/**
* String operators.
*/
export const startsWithOperator = makeBuiltin(
"startsWith",
"actual (string) starts with expected."
);
export const endsWithOperator = makeBuiltin(
"endsWith",
"actual (string) ends with expected."
);
export const matchesOperator = makeBuiltin(
"matches",
"actual (string) matches expected (string or RegExp)."
);
export const notMatchesOperator = makeBuiltin(
"notMatches",
"actual (string) does NOT match expected (string or RegExp)."
);
/**
* Logical operators surfaced as `OperatorDefinition`s for callers who
* compose them outside the engine's `LogicalRule` path (e.g. third-party
* registries). These always raise when invoked directly — the engine handles
* logical operators through its `evaluateLogical` path.
*/
function logical(name: "and" | "or" | "not" | "xor"): OperatorDefinition {
return {
name,
description: `Logical ${name.toUpperCase()} — handled by the engine, not the operator registry.`,
evaluate: () => {
throw new Error(
`logical ${name} is dispatched by the engine; do not invoke via OperatorDefinition.evaluate`
);
},
};
}
export const andOperator = logical("and");
export const orOperator = logical("or");
export const notOperator = logical("not");
export const xorOperator = logical("xor");
export const BUILTIN_OPERATORS: readonly OperatorDefinition[] = Object.freeze([
equalsOperator,
notEqualsOperator,
strictEqualsOperator,
strictNotEqualsOperator,
greaterThanOperator,
greaterThanOrEqualsOperator,
lessThanOperator,
lessThanOrEqualsOperator,
betweenOperator,
notBetweenOperator,
inOperator,
notInOperator,
containsOperator,
notContainsOperator,
containsAllOperator,
containsAnyOperator,
overlapsOperator,
existsOperator,
notExistsOperator,
isNullOperator,
isNotNullOperator,
isEmptyOperator,
isNotEmptyOperator,
isTypeOperator,
isNotTypeOperator,
hasPropertyOperator,
lengthEqualsOperator,
lengthGreaterThanOperator,
lengthLessThanOperator,
startsWithOperator,
endsWithOperator,
matchesOperator,
notMatchesOperator,
]);
/**
* Build a fresh `OperatorRegistry` pre-populated with every built-in
* comparison operator. Callers register additional operators (or override
* built-ins by name) on top of the returned instance.
*/
export function createDefaultOperatorRegistry(): OperatorRegistry {
const registry = new OperatorRegistry();
for (const op of BUILTIN_OPERATORS) {
registry.register(op.name, op);
}
return registry;
}
/**
* Process-global default registry. Per AGENTS.md §6a guidance on hidden
* globals, callers are encouraged to construct their own registry via
* `createDefaultOperatorRegistry()`; this export exists for the convenience
* of one-shot evaluations and is mutable (subject to the same races as any
* shared mutable singleton).
*/
export const defaultOperatorRegistry: OperatorRegistry =
createDefaultOperatorRegistry();

View File

@@ -0,0 +1,43 @@
export {
andOperator,
BUILTIN_OPERATORS,
betweenOperator,
containsAllOperator,
containsAnyOperator,
containsOperator,
createDefaultOperatorRegistry,
defaultOperatorRegistry,
endsWithOperator,
equalsOperator,
existsOperator,
greaterThanOperator,
greaterThanOrEqualsOperator,
hasPropertyOperator,
inOperator,
isEmptyOperator,
isNotEmptyOperator,
isNotNullOperator,
isNotTypeOperator,
isNullOperator,
isTypeOperator,
lengthEqualsOperator,
lengthGreaterThanOperator,
lengthLessThanOperator,
lessThanOperator,
lessThanOrEqualsOperator,
matchesOperator,
notBetweenOperator,
notContainsOperator,
notEqualsOperator,
notExistsOperator,
notInOperator,
notMatchesOperator,
notOperator,
orOperator,
overlapsOperator,
startsWithOperator,
strictEqualsOperator,
strictNotEqualsOperator,
xorOperator,
} from "./defaults.js";
export { OperatorRegistry } from "./registry.js";

View File

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

View File

@@ -0,0 +1,69 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { OperatorDefinition } from "../types/index.js";
import { OperatorRegistry } from "./registry.js";
const dummy: OperatorDefinition = {
name: "dummy",
description: "test",
evaluate: () => true,
};
describe("OperatorRegistry", () => {
it("registers and retrieves an operator", { timeout: 5000 }, () => {
const r = new OperatorRegistry();
r.register("dummy", dummy);
expect(r.has("dummy")).toBe(true);
expect(r.get("dummy")).toBe(dummy);
expect(r.names()).toContain("dummy");
});
it("get returns undefined for unknown names", { timeout: 5000 }, () => {
const r = new OperatorRegistry();
expect(r.get("missing")).toBeUndefined();
expect(r.has("missing")).toBe(false);
});
it("mustGet raises RULES.OPERATOR_NOT_FOUND", { timeout: 5000 }, () => {
const r = new OperatorRegistry();
expect(() => r.mustGet("missing")).toThrow(/RULES\.OPERATOR_NOT_FOUND/);
});
it("rejects empty name with RULES.OPERATOR_INVALID", {
timeout: 5000,
}, () => {
const r = new OperatorRegistry();
expect(() => r.register("", dummy)).toThrow(/RULES\.OPERATOR_INVALID/);
});
it("re-register replaces the prior entry", { timeout: 5000 }, () => {
const r = new OperatorRegistry();
const a: OperatorDefinition = { ...dummy, description: "a" };
const b: OperatorDefinition = { ...dummy, description: "b" };
r.register("dummy", a);
r.register("dummy", b);
expect(r.get("dummy")).toBe(b);
});
it("registers aliases", { timeout: 5000 }, () => {
const r = new OperatorRegistry();
r.register("dummy", { ...dummy, aliases: ["d", "dum"] });
expect(r.has("d")).toBe(true);
expect(r.has("dum")).toBe(true);
expect(r.get("d")?.name).toBe("dummy");
});
it("ignores empty alias strings", { timeout: 5000 }, () => {
const r = new OperatorRegistry();
r.register("dummy", { ...dummy, aliases: ["", "alias"] });
expect(r.has("")).toBe(false);
expect(r.has("alias")).toBe(true);
});
it("unregister removes and reports", { timeout: 5000 }, () => {
const r = new OperatorRegistry();
r.register("dummy", dummy);
expect(r.unregister("dummy")).toBe(true);
expect(r.unregister("dummy")).toBe(false);
expect(r.has("dummy")).toBe(false);
});
});

View File

@@ -0,0 +1,63 @@
import { RulesError } from "../errors/index.js";
import type {
OperatorDefinition,
OperatorRegistryLike,
} from "../types/index.js";
/**
* In-memory operator registry. Implements `OperatorRegistryLike` and serves
* as the canonical store for the 30 built-in operators plus any caller- or
* plugin-registered custom definitions. Unknown lookups raise
* `RULES.OPERATOR_NOT_FOUND`; duplicate registrations replace the prior
* entry (matching the documented plugin-override semantic).
*/
export class OperatorRegistry implements OperatorRegistryLike {
private readonly entries = new Map<string, OperatorDefinition>();
register<T = unknown, V = unknown>(
name: string,
definition: OperatorDefinition<T, V>
): void {
if (name === "") {
throw new RulesError(
"RULES.OPERATOR_INVALID",
"operator name must be non-empty"
);
}
this.entries.set(name, definition as OperatorDefinition);
if (definition.aliases !== undefined) {
for (const alias of definition.aliases) {
if (alias === "") continue;
this.entries.set(alias, definition as OperatorDefinition);
}
}
}
get(name: string): OperatorDefinition | undefined {
return this.entries.get(name);
}
mustGet(name: string): OperatorDefinition {
const entry = this.entries.get(name);
if (entry === undefined) {
throw new RulesError(
"RULES.OPERATOR_NOT_FOUND",
`operator not registered: ${name}`,
{ details: { name } }
);
}
return entry;
}
has(name: string): boolean {
return this.entries.has(name);
}
names(): readonly string[] {
return [...this.entries.keys()];
}
unregister(name: string): boolean {
return this.entries.delete(name);
}
}

View File

@@ -0,0 +1,14 @@
export type {
OptimizeOptions,
OptimizeResult,
RuleStats,
} from "./optimizer.js";
export {
analyzeRule,
countRules,
estimateCost,
findFields,
hasAsyncComponents,
isOptimisationStable,
optimizeRule,
} from "./optimizer.js";

View File

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

View File

@@ -0,0 +1,270 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { and, condition, or } from "../builder/index.js";
import type { Rule } from "../types/index.js";
import {
analyzeRule,
countRules,
estimateCost,
findFields,
hasAsyncComponents,
isOptimisationStable,
optimizeRule,
} from "./optimizer.js";
describe("optimizeRule — flatten", () => {
it("flattens nested AND of AND", { timeout: 5000 }, () => {
const r = and(
and(condition("a", "equals", 1), condition("b", "equals", 2)),
condition("c", "equals", 3)
);
const opt = optimizeRule(r);
const rules = (opt.rule as { rules: readonly Rule[] }).rules;
expect(rules.length).toBe(3);
});
it("flattens nested OR of OR", { timeout: 5000 }, () => {
const r = or(
or(condition("a", "equals", 1), condition("b", "equals", 2)),
condition("c", "equals", 3)
);
const opt = optimizeRule(r);
expect((opt.rule as { rules: readonly Rule[] }).rules.length).toBe(3);
});
it("does not flatten AND of OR", { timeout: 5000 }, () => {
const r = and(
or(condition("a", "equals", 1), condition("b", "equals", 2)),
condition("c", "equals", 3)
);
const opt = optimizeRule(r, {
dedupe: false,
simplify: false,
sort: false,
});
expect((opt.rule as { rules: readonly Rule[] }).rules.length).toBe(2);
});
it("flatten can be disabled", { timeout: 5000 }, () => {
const r = and(
and(condition("a", "equals", 1), condition("b", "equals", 2)),
condition("c", "equals", 3)
);
const opt = optimizeRule(r, {
flatten: false,
dedupe: false,
simplify: false,
sort: false,
});
expect((opt.rule as { rules: readonly Rule[] }).rules.length).toBe(2);
});
});
describe("optimizeRule — dedupe", () => {
it("removes structural duplicates", { timeout: 5000 }, () => {
const c = condition("a", "equals", 1);
const r = and(c, c, condition("b", "equals", 2));
const opt = optimizeRule(r, { sort: false, simplify: false });
expect((opt.rule as { rules: readonly Rule[] }).rules.length).toBe(2);
});
it("dedupe recurses into groups", { timeout: 5000 }, () => {
const group: Rule = {
type: "group",
name: "g",
rules: [condition("a", "equals", 1), condition("a", "equals", 1)],
};
const opt = optimizeRule(group);
expect((opt.rule as { rules: readonly Rule[] }).rules.length).toBe(1);
});
it("dedupe can be disabled", { timeout: 5000 }, () => {
const c = condition("a", "equals", 1);
const r = and(c, c);
const opt = optimizeRule(r, { dedupe: false, simplify: false });
expect((opt.rule as { rules: readonly Rule[] }).rules.length).toBe(2);
});
});
describe("optimizeRule — simplify", () => {
it("unwraps single-child AND/OR", { timeout: 5000 }, () => {
const r: Rule = {
type: "logical",
operator: "and",
rules: [condition("a", "equals", 1)],
};
const opt = optimizeRule(r, {
flatten: false,
dedupe: false,
sort: false,
});
expect(opt.rule.type).toBe("condition");
});
it("preserves NOT (single-child by design)", { timeout: 5000 }, () => {
const r: Rule = {
type: "logical",
operator: "not",
rules: [condition("a", "equals", 1)],
};
const opt = optimizeRule(r);
expect(opt.rule.type).toBe("logical");
expect((opt.rule as { operator: string }).operator).toBe("not");
});
it("simplify can be disabled", { timeout: 5000 }, () => {
const r: Rule = {
type: "logical",
operator: "and",
rules: [condition("a", "equals", 1)],
};
const opt = optimizeRule(r, {
simplify: false,
flatten: false,
dedupe: false,
sort: false,
});
expect(opt.rule.type).toBe("logical");
});
});
describe("optimizeRule — sort", () => {
it("sorts AND children by ascending cost", { timeout: 5000 }, () => {
const r = and(
condition("a", "matches", "^x$"),
condition("b", "equals", 1)
);
const opt = optimizeRule(r, {
flatten: false,
dedupe: false,
simplify: false,
});
const rules = (opt.rule as { rules: readonly Rule[] }).rules;
expect((rules[0] as { operator: string }).operator).toBe("equals");
});
it("does not sort NOT or group implicitly", { timeout: 5000 }, () => {
const r: Rule = {
type: "logical",
operator: "not",
rules: [condition("a", "equals", 1)],
};
const opt = optimizeRule(r);
expect(opt.rule.type).toBe("logical");
});
it("sort can be disabled", { timeout: 5000 }, () => {
const r = and(
condition("a", "matches", "^x$"),
condition("b", "equals", 1)
);
const opt = optimizeRule(r, {
sort: false,
dedupe: false,
simplify: false,
flatten: false,
});
expect((opt.rule as { rules: readonly Rule[] }).rules[0]?.type).toBe(
"condition"
);
});
it("group children pass through sort", { timeout: 5000 }, () => {
const r: Rule = {
type: "group",
name: "g",
rules: [
condition("a", "matches", "^x$"),
condition("b", "equals", 1),
],
};
const opt = optimizeRule(r);
expect(opt.rule.type).toBe("group");
});
});
describe("analyzeRule", () => {
it("counts total + depth + fields", { timeout: 5000 }, () => {
const r = and(
condition("a", "equals", 1),
or(condition("b", "equals", 2), condition("c", "equals", 3))
);
const stats = analyzeRule(r);
expect(stats.totalRules).toBe(5);
expect(stats.maxDepth).toBe(2);
expect(stats.fields).toEqual(expect.arrayContaining(["a", "b", "c"]));
});
it("detects async via custom/computed", { timeout: 5000 }, () => {
const custom: Rule = {
type: "custom",
name: "c",
validate: async () => true,
};
expect(analyzeRule(custom).hasAsync).toBe(true);
const computed: Rule = {
type: "computed",
field: "x",
operator: "equals",
compute: () => 1,
};
expect(analyzeRule(computed).hasAsync).toBe(true);
});
it("hasAsyncComponents alias", { timeout: 5000 }, () => {
expect(hasAsyncComponents(condition("a", "equals", 1))).toBe(false);
expect(
hasAsyncComponents({
type: "custom",
name: "c",
validate: () => true,
})
).toBe(true);
});
it("countRules / findFields / estimateCost helpers", {
timeout: 5000,
}, () => {
const r = and(
condition("a", "equals", 1),
condition("b", "matches", "^x$")
);
expect(countRules(r)).toBe(3);
expect(findFields(r)).toEqual(expect.arrayContaining(["a", "b"]));
expect(estimateCost(r)).toBeGreaterThan(0);
});
it("isOptimisationStable returns true when no changes", {
timeout: 5000,
}, () => {
const r = condition("a", "equals", 1);
expect(isOptimisationStable(r)).toBe(true);
});
it("isOptimisationStable returns false when optimisations apply", {
timeout: 5000,
}, () => {
const r = and(
and(condition("a", "equals", 1), condition("b", "equals", 2))
);
expect(isOptimisationStable(r)).toBe(false);
});
it("walks group children", { timeout: 5000 }, () => {
const r: Rule = {
type: "group",
name: "g",
rules: [condition("a", "equals", 1), condition("b", "equals", 2)],
};
expect(analyzeRule(r).totalRules).toBe(3);
});
});
describe("optimizeRule — result", () => {
it("reports before + after stats", { timeout: 5000 }, () => {
const r = and(
and(condition("a", "equals", 1), condition("b", "equals", 2))
);
const opt = optimizeRule(r);
expect(opt.before.totalRules).toBeGreaterThan(opt.after.totalRules);
});
});

View File

@@ -0,0 +1,221 @@
import { areRulesEqual, hashRule } from "../serializer/index.js";
import {
isComputedRule,
isConditionRule,
isCustomRule,
isGroupRule,
isLogicalRule,
type Rule,
} from "../types/index.js";
export interface OptimizeOptions {
readonly flatten?: boolean;
readonly dedupe?: boolean;
readonly simplify?: boolean;
readonly sort?: boolean;
}
export interface RuleStats {
readonly totalRules: number;
readonly maxDepth: number;
readonly fields: readonly string[];
readonly hasAsync: boolean;
readonly estimatedCost: number;
}
export interface OptimizeResult {
readonly rule: Rule;
readonly before: RuleStats;
readonly after: RuleStats;
}
export function optimizeRule(
rule: Rule,
options: OptimizeOptions = {}
): OptimizeResult {
const before = analyzeRule(rule);
let working = rule;
const flatten = options.flatten ?? true;
const dedupe = options.dedupe ?? true;
const simplify = options.simplify ?? true;
const sort = options.sort ?? true;
if (flatten) working = flattenLogical(working);
if (dedupe) working = dedupeChildren(working);
if (simplify) working = simplifyTree(working);
if (sort) working = sortByCost(working);
const after = analyzeRule(working);
return { rule: working, before, after };
}
export function analyzeRule(rule: Rule): RuleStats {
const fields = new Set<string>();
let total = 0;
let depth = 0;
let hasAsync = false;
let cost = 0;
function walk(r: Rule, d: number): void {
total += 1;
if (d > depth) depth = d;
cost += estimateNodeCost(r);
if (isConditionRule(r) || isComputedRule(r)) fields.add(r.field);
if (isCustomRule(r) || isComputedRule(r)) hasAsync = true;
if (isLogicalRule(r) || isGroupRule(r)) {
for (const child of r.rules) walk(child, d + 1);
}
}
walk(rule, 0);
return {
totalRules: total,
maxDepth: depth,
fields: [...fields],
hasAsync,
estimatedCost: cost,
};
}
export function estimateCost(rule: Rule): number {
return analyzeRule(rule).estimatedCost;
}
export function findFields(rule: Rule): readonly string[] {
return analyzeRule(rule).fields;
}
export function hasAsyncComponents(rule: Rule): boolean {
return analyzeRule(rule).hasAsync;
}
export function countRules(rule: Rule): number {
return analyzeRule(rule).totalRules;
}
function estimateNodeCost(rule: Rule): number {
if (isConditionRule(rule)) {
switch (rule.operator) {
case "equals":
case "notEquals":
case "strictEquals":
case "strictNotEquals":
case "greaterThan":
case "greaterThanOrEqual":
case "lessThan":
case "lessThanOrEqual":
case "isNull":
case "isNotNull":
case "exists":
case "notExists":
return 1;
case "matches":
case "notMatches":
return 10;
case "containsAll":
case "containsAny":
case "overlaps":
return 5;
default:
return 2;
}
}
if (isCustomRule(rule) || isComputedRule(rule)) return 20;
if (isLogicalRule(rule) || isGroupRule(rule)) return 0;
return 1;
}
function flattenLogical(rule: Rule): Rule {
if (isLogicalRule(rule)) {
const flatChildren: Rule[] = [];
for (const child of rule.rules) {
const optimised = flattenLogical(child);
if (
isLogicalRule(optimised) &&
(rule.operator === "and" || rule.operator === "or") &&
optimised.operator === rule.operator
) {
flatChildren.push(...optimised.rules);
} else {
flatChildren.push(optimised);
}
}
return { ...rule, rules: flatChildren };
}
if (isGroupRule(rule)) {
return { ...rule, rules: rule.rules.map(flattenLogical) };
}
return rule;
}
function dedupeChildren(rule: Rule): Rule {
if (isLogicalRule(rule)) {
const seen = new Set<string>();
const out: Rule[] = [];
for (const child of rule.rules) {
const optimised = dedupeChildren(child);
const key = hashRule(optimised);
if (!seen.has(key)) {
seen.add(key);
out.push(optimised);
}
}
return { ...rule, rules: out };
}
if (isGroupRule(rule)) {
const seen = new Set<string>();
const out: Rule[] = [];
for (const child of rule.rules) {
const optimised = dedupeChildren(child);
const key = hashRule(optimised);
if (!seen.has(key)) {
seen.add(key);
out.push(optimised);
}
}
return { ...rule, rules: out };
}
return rule;
}
function simplifyTree(rule: Rule): Rule {
if (isLogicalRule(rule)) {
const children = rule.rules.map(simplifyTree);
if (
children.length === 1 &&
(rule.operator === "and" || rule.operator === "or")
) {
return children[0] as Rule;
}
return { ...rule, rules: children };
}
if (isGroupRule(rule)) {
return { ...rule, rules: rule.rules.map(simplifyTree) };
}
return rule;
}
function sortByCost(rule: Rule): Rule {
if (
isLogicalRule(rule) &&
(rule.operator === "and" || rule.operator === "or")
) {
const sorted = [...rule.rules].sort(
(a, b) => estimateNodeCost(a) - estimateNodeCost(b)
);
return { ...rule, rules: sorted.map(sortByCost) };
}
if (isGroupRule(rule)) {
return { ...rule, rules: rule.rules.map(sortByCost) };
}
if (isLogicalRule(rule)) {
return { ...rule, rules: rule.rules.map(sortByCost) };
}
return rule;
}
/**
* Test helper: returns whether two rule trees collapse to identical
* hashes after optimisation. Used by callers that want to detect when an
* optimisation pass has produced a structurally-equivalent simpler form.
*/
export function isOptimisationStable(rule: Rule): boolean {
const optimised = optimizeRule(rule).rule;
return areRulesEqual(rule, optimised);
}

View File

@@ -0,0 +1,5 @@
export type { RulesInstruments } from "./otel.js";
export {
getInstruments,
resetInstruments,
} from "./otel.js";

View File

@@ -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).

View File

@@ -0,0 +1,110 @@
import {
type Counter,
type Histogram,
metrics,
type Tracer,
trace,
} from "@opentelemetry/api";
import { PACKAGE_NAME, VERSION } from "../constants.js";
/**
* Lazy OTel surface for `@suaveplan/rules`. Instruments are created on first
* access against the global `metrics` / `trace` providers. Tests can install a
* real in-memory `MeterProvider` (no `vi.mock`) to assert emission.
*/
const METER_NAME = PACKAGE_NAME;
const TRACER_NAME = PACKAGE_NAME;
let cached: RulesInstruments | null = null;
export interface RulesInstruments {
readonly tracer: Tracer;
readonly engineEvaluateDuration: Histogram;
readonly engineEvaluationsTotal: Counter;
readonly engineDepth: Histogram;
readonly operatorUnknownTotal: Counter;
readonly actionExecuteDuration: Histogram;
readonly actionExecutionsTotal: Counter;
readonly eventEmittedTotal: Counter;
readonly validatorValidateDuration: Histogram;
readonly validatorValidationsTotal: Counter;
readonly serializerDuration: Histogram;
}
export function getInstruments(): RulesInstruments {
if (cached !== null) return cached;
const meter = metrics.getMeter(METER_NAME, VERSION);
const tracer = trace.getTracer(TRACER_NAME, VERSION);
cached = {
tracer,
engineEvaluateDuration: meter.createHistogram(
"rules.engine.evaluate.duration_ms",
{
description: "Engine evaluate wall-time in milliseconds.",
unit: "ms",
}
),
engineEvaluationsTotal: meter.createCounter(
"rules.engine.evaluations_total",
{
description: "Engine evaluate count, labelled by outcome.",
}
),
engineDepth: meter.createHistogram("rules.engine.depth", {
description: "Maximum recursion depth reached during evaluate.",
}),
operatorUnknownTotal: meter.createCounter(
"rules.operator.unknown_total",
{
description:
"Count of unknown-operator references hit by the engine.",
}
),
actionExecuteDuration: meter.createHistogram(
"rules.action.execute.duration_ms",
{
description: "Action handler wall-time in milliseconds.",
unit: "ms",
}
),
actionExecutionsTotal: meter.createCounter(
"rules.action.executions_total",
{
description: "Action execution count, labelled by status.",
}
),
eventEmittedTotal: meter.createCounter("rules.event.emitted_total", {
description: "Event bus emissions, labelled by event type.",
}),
validatorValidateDuration: meter.createHistogram(
"rules.validator.validate.duration_ms",
{
description: "Validator wall-time in milliseconds.",
unit: "ms",
}
),
validatorValidationsTotal: meter.createCounter(
"rules.validator.validations_total",
{
description:
"Validator invocation count, labelled by kind + outcome.",
}
),
serializerDuration: meter.createHistogram(
"rules.serializer.duration_ms",
{
description: "Serializer wall-time in milliseconds.",
unit: "ms",
}
),
};
return cached;
}
/**
* Reset the cached instruments. Test-only — production code must never call.
*/
export function resetInstruments(): void {
cached = null;
}

View File

@@ -0,0 +1,22 @@
export type {
DeserializeOptions,
SerializedRule,
SerializedRuleset,
SerializeOptions,
} from "./serializer.js";
export {
areRulesEqual,
cloneRule,
deserializeRule,
deserializeRuleFromObject,
deserializeRuleset,
deserializeRulesetFromObject,
FUNCTION_PLACEHOLDER,
hashRule,
SERIALIZED_RULE_SCHEMA,
serializeRule,
serializeRuleset,
serializeRulesetToObject,
serializeRuleToObject,
validateSerializedRule,
} from "./serializer.js";

View File

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

View File

@@ -0,0 +1,258 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { and, condition, not, or } from "../builder/index.js";
import type { Rule, Ruleset } from "../types/index.js";
import {
areRulesEqual,
cloneRule,
deserializeRule,
deserializeRuleFromObject,
deserializeRuleset,
deserializeRulesetFromObject,
hashRule,
serializeRule,
serializeRuleset,
serializeRulesetToObject,
serializeRuleToObject,
validateSerializedRule,
} from "./serializer.js";
describe("serializer — round trip", () => {
it("ConditionRule round-trips", { timeout: 5000 }, () => {
const r = condition("age", "greaterThanOrEqual", 18);
expect(deserializeRule(serializeRule(r))).toEqual(r);
});
it("LogicalRule round-trips", { timeout: 5000 }, () => {
const r = and(condition("a", "equals", 1), condition("b", "equals", 2));
expect(deserializeRule(serializeRule(r))).toEqual(r);
});
it("NOT round-trips", { timeout: 5000 }, () => {
const r = not(condition("a", "equals", 1));
expect(deserializeRule(serializeRule(r))).toEqual(r);
});
it("GroupRule round-trips", { timeout: 5000 }, () => {
const r: Rule = {
type: "group",
name: "g",
operator: "or",
rules: [condition("a", "equals", 1)],
};
expect(deserializeRule(serializeRule(r))).toEqual(r);
});
it("pretty option emits indented JSON", { timeout: 5000 }, () => {
const r = condition("a", "equals", 1);
expect(serializeRule(r, { pretty: true })).toContain("\n");
});
it("ruleset round-trips", { timeout: 5000 }, () => {
const ruleset: Ruleset = {
id: "rs",
name: "rs",
description: "d",
version: "1",
operator: "and",
failFast: true,
rules: [condition("a", "equals", 1)],
metadata: { priority: "high" },
};
const back = deserializeRuleset(serializeRuleset(ruleset));
expect(back).toEqual(ruleset);
});
it("ruleset object round-trips without description", {
timeout: 5000,
}, () => {
const ruleset: Ruleset = {
id: "rs",
name: "rs",
rules: [condition("a", "equals", 1)],
};
const obj = serializeRulesetToObject(ruleset);
expect(deserializeRulesetFromObject(obj)).toEqual(ruleset);
});
});
describe("serializer — function placeholders", () => {
it("CustomRule serializes to placeholder", { timeout: 5000 }, () => {
const r: Rule = {
type: "custom",
name: "ok",
validate: () => true,
};
const obj = serializeRuleToObject(r);
expect((obj as { validate: string }).validate).toBe(
"__suaveplan_rules_function__"
);
});
it("CustomRule deserializes when resolver supplied", {
timeout: 5000,
}, () => {
const r: Rule = { type: "custom", name: "ok", validate: () => true };
const json = serializeRule(r);
const back = deserializeRule(json, {
customResolvers: { ok: () => true },
});
expect(back.type).toBe("custom");
});
it("CustomRule deserialize raises when resolver missing", {
timeout: 5000,
}, () => {
const r: Rule = {
type: "custom",
name: "missing",
validate: () => true,
};
expect(() => deserializeRule(serializeRule(r))).toThrow(
/RULES\.DESERIALIZE_FAILED/
);
});
it("ComputedRule serializes/deserializes with resolver", {
timeout: 5000,
}, () => {
const r: Rule = {
type: "computed",
field: "score",
operator: "equals",
compute: () => 5,
};
const json = serializeRule(r);
const back = deserializeRule(json, {
computeResolvers: { score: () => 5 },
});
expect(back.type).toBe("computed");
});
it("ComputedRule deserialize raises when resolver missing", {
timeout: 5000,
}, () => {
const r: Rule = {
type: "computed",
field: "missing",
operator: "equals",
compute: () => 5,
};
expect(() => deserializeRule(serializeRule(r))).toThrow(
/RULES\.DESERIALIZE_FAILED/
);
});
});
describe("serializer — errors", () => {
it("invalid JSON raises DESERIALIZE_FAILED", { timeout: 5000 }, () => {
expect(() => deserializeRule("{not-json")).toThrow(
/RULES\.DESERIALIZE_FAILED/
);
});
it("invalid object shape raises DESERIALIZE_FAILED", {
timeout: 5000,
}, () => {
expect(() => deserializeRuleFromObject({ type: "bogus" })).toThrow(
/RULES\.DESERIALIZE_FAILED/
);
});
it("ruleset deserialize rejects non-object", { timeout: 5000 }, () => {
expect(() => deserializeRulesetFromObject(null)).toThrow(
/RULES\.DESERIALIZE_FAILED/
);
});
it("ruleset deserialize rejects missing fields", { timeout: 5000 }, () => {
expect(() => deserializeRulesetFromObject({ id: "x" })).toThrow(
/RULES\.DESERIALIZE_FAILED/
);
});
it("ruleset deserialize from invalid JSON", { timeout: 5000 }, () => {
expect(() => deserializeRuleset("{not-json")).toThrow(
/RULES\.DESERIALIZE_FAILED/
);
});
it("validateSerializedRule passes on valid", { timeout: 5000 }, () => {
const r = condition("a", "equals", 1);
const obj = serializeRuleToObject(r);
expect(validateSerializedRule(obj)).toEqual(obj);
});
it("validateSerializedRule rejects invalid", { timeout: 5000 }, () => {
expect(() => validateSerializedRule({ type: "unknown" })).toThrow(
/RULES\.SERIALIZE_FAILED/
);
});
});
describe("cloneRule / hashRule / areRulesEqual", () => {
it("cloneRule produces a structurally-equal but distinct object", {
timeout: 5000,
}, () => {
const r = and(condition("a", "equals", 1), condition("b", "equals", 2));
const cloned = cloneRule(r);
expect(cloned).toEqual(r);
expect(cloned).not.toBe(r);
});
it("hashRule is order-insensitive for AND children", {
timeout: 5000,
}, () => {
const ab = and(
condition("a", "equals", 1),
condition("b", "equals", 2)
);
const ba = and(
condition("b", "equals", 2),
condition("a", "equals", 1)
);
expect(hashRule(ab)).toBe(hashRule(ba));
});
it("hashRule is order-insensitive for OR children", {
timeout: 5000,
}, () => {
const ab = or(condition("a", "equals", 1), condition("b", "equals", 2));
const ba = or(condition("b", "equals", 2), condition("a", "equals", 1));
expect(hashRule(ab)).toBe(hashRule(ba));
});
it("hashRule distinguishes condition values", { timeout: 5000 }, () => {
const a = condition("x", "equals", 1);
const b = condition("x", "equals", 2);
expect(hashRule(a)).not.toBe(hashRule(b));
});
it("hashRule covers group / custom / computed", { timeout: 5000 }, () => {
const group: Rule = {
type: "group",
name: "g",
rules: [condition("a", "equals", 1), condition("b", "equals", 2)],
};
const custom: Rule = {
type: "custom",
name: "c",
validate: () => true,
};
const computed: Rule = {
type: "computed",
field: "x",
operator: "equals",
compute: () => 1,
};
expect(typeof hashRule(group)).toBe("string");
expect(typeof hashRule(custom)).toBe("string");
expect(typeof hashRule(computed)).toBe("string");
});
it("areRulesEqual mirrors hashRule", { timeout: 5000 }, () => {
const a = and(condition("a", "equals", 1), condition("b", "equals", 2));
const b = and(condition("b", "equals", 2), condition("a", "equals", 1));
expect(areRulesEqual(a, b)).toBe(true);
expect(areRulesEqual(a, condition("x", "equals", 9))).toBe(false);
});
});

View File

@@ -0,0 +1,511 @@
import z from "zod";
import { RulesError } from "../errors/index.js";
import { getInstruments } from "../otel/index.js";
import {
isComputedRule,
isConditionRule,
isCustomRule,
isGroupRule,
isLogicalRule,
type Rule,
type RuleMetadata,
type Ruleset,
} from "../types/index.js";
export const FUNCTION_PLACEHOLDER = "__suaveplan_rules_function__" as const;
const META_SCHEMA = z
.object({
id: z.string().optional(),
name: z.string().optional(),
description: z.string().optional(),
priority: z.enum(["critical", "high", "normal", "low"]).optional(),
status: z.enum(["active", "inactive", "draft", "archived"]).optional(),
version: z.string().optional(),
tags: z.array(z.string()).readonly().optional(),
})
.passthrough();
export const SERIALIZED_RULE_SCHEMA: z.ZodType = z.lazy(() =>
z.discriminatedUnion("type", [
z.object({
type: z.literal("condition"),
field: z.string(),
operator: z.string(),
value: z.unknown(),
message: z.string().optional(),
metadata: META_SCHEMA.optional(),
}),
z.object({
type: z.literal("logical"),
operator: z.enum(["and", "or", "not", "xor"]),
rules: z.array(SERIALIZED_RULE_SCHEMA).readonly(),
message: z.string().optional(),
metadata: META_SCHEMA.optional(),
}),
z.object({
type: z.literal("group"),
name: z.string(),
rules: z.array(SERIALIZED_RULE_SCHEMA).readonly(),
operator: z.enum(["and", "or"]).optional(),
message: z.string().optional(),
metadata: META_SCHEMA.optional(),
}),
z.object({
type: z.literal("custom"),
name: z.string(),
validate: z.literal(FUNCTION_PLACEHOLDER),
message: z.string().optional(),
metadata: META_SCHEMA.optional(),
}),
z.object({
type: z.literal("computed"),
field: z.string(),
operator: z.string(),
compute: z.literal(FUNCTION_PLACEHOLDER),
message: z.string().optional(),
metadata: META_SCHEMA.optional(),
}),
])
);
export type SerializedRule =
| {
readonly type: "condition";
readonly field: string;
readonly operator: string;
readonly value: unknown;
readonly message?: string;
readonly metadata?: RuleMetadata;
}
| {
readonly type: "logical";
readonly operator: "and" | "or" | "not" | "xor";
readonly rules: readonly SerializedRule[];
readonly message?: string;
readonly metadata?: RuleMetadata;
}
| {
readonly type: "group";
readonly name: string;
readonly rules: readonly SerializedRule[];
readonly operator?: "and" | "or";
readonly message?: string;
readonly metadata?: RuleMetadata;
}
| {
readonly type: "custom";
readonly name: string;
readonly validate: typeof FUNCTION_PLACEHOLDER;
readonly message?: string;
readonly metadata?: RuleMetadata;
}
| {
readonly type: "computed";
readonly field: string;
readonly operator: string;
readonly compute: typeof FUNCTION_PLACEHOLDER;
readonly message?: string;
readonly metadata?: RuleMetadata;
};
export interface SerializedRuleset {
readonly id: string;
readonly name: string;
readonly description?: string;
readonly rules: readonly SerializedRule[];
readonly operator?: "and" | "or";
readonly version?: string;
readonly failFast?: boolean;
readonly metadata?: RuleMetadata;
}
export interface SerializeOptions {
readonly pretty?: boolean;
}
export interface DeserializeOptions {
readonly customResolvers?: Readonly<
Record<
string,
(value: unknown, context: unknown) => boolean | Promise<boolean>
>
>;
readonly computeResolvers?: Readonly<
Record<string, (context: unknown) => unknown | Promise<unknown>>
>;
}
export function serializeRuleToObject(rule: Rule): SerializedRule {
const start = performance.now();
const out = toObject(rule);
getInstruments().serializerDuration.record(performance.now() - start, {
op: "serialize",
});
return out;
}
export function serializeRule(
rule: Rule,
options: SerializeOptions = {}
): string {
const obj = serializeRuleToObject(rule);
return options.pretty === true
? JSON.stringify(obj, null, 2)
: JSON.stringify(obj);
}
export function serializeRulesetToObject(ruleset: Ruleset): SerializedRuleset {
const out: {
-readonly [K in keyof SerializedRuleset]: SerializedRuleset[K];
} = {
id: ruleset.id,
name: ruleset.name,
rules: ruleset.rules.map(toObject),
};
if (ruleset.description !== undefined)
out.description = ruleset.description;
if (ruleset.operator !== undefined) out.operator = ruleset.operator;
if (ruleset.version !== undefined) out.version = ruleset.version;
if (ruleset.failFast !== undefined) out.failFast = ruleset.failFast;
if (ruleset.metadata !== undefined) out.metadata = ruleset.metadata;
return out;
}
export function serializeRuleset(
ruleset: Ruleset,
options: SerializeOptions = {}
): string {
const obj = serializeRulesetToObject(ruleset);
return options.pretty === true
? JSON.stringify(obj, null, 2)
: JSON.stringify(obj);
}
export function deserializeRuleFromObject(
input: unknown,
options: DeserializeOptions = {}
): Rule {
const parsed = SERIALIZED_RULE_SCHEMA.safeParse(input);
if (!parsed.success) {
throw new RulesError(
"RULES.DESERIALIZE_FAILED",
"invalid SerializedRule",
{
cause: parsed.error,
details: { issues: parsed.error.issues },
}
);
}
const start = performance.now();
const out = fromObject(parsed.data as SerializedRule, options);
getInstruments().serializerDuration.record(performance.now() - start, {
op: "deserialize",
});
return out;
}
export function deserializeRule(
json: string,
options: DeserializeOptions = {}
): Rule {
let parsed: unknown;
try {
parsed = JSON.parse(json);
} catch (err) {
throw new RulesError("RULES.DESERIALIZE_FAILED", "invalid JSON", {
cause: err,
});
}
return deserializeRuleFromObject(parsed, options);
}
export function deserializeRulesetFromObject(
input: unknown,
options: DeserializeOptions = {}
): Ruleset {
if (typeof input !== "object" || input === null) {
throw new RulesError(
"RULES.DESERIALIZE_FAILED",
"ruleset must be an object"
);
}
const obj = input as Partial<SerializedRuleset>;
if (
typeof obj.id !== "string" ||
typeof obj.name !== "string" ||
!Array.isArray(obj.rules)
) {
throw new RulesError(
"RULES.DESERIALIZE_FAILED",
"ruleset missing required fields"
);
}
const out: { -readonly [K in keyof Ruleset]: Ruleset[K] } = {
id: obj.id,
name: obj.name,
rules: obj.rules.map((r) => deserializeRuleFromObject(r, options)),
};
if (obj.description !== undefined) out.description = obj.description;
if (obj.operator !== undefined) out.operator = obj.operator;
if (obj.version !== undefined) out.version = obj.version;
if (obj.failFast !== undefined) out.failFast = obj.failFast;
if (obj.metadata !== undefined) out.metadata = obj.metadata;
return out;
}
export function deserializeRuleset(
json: string,
options: DeserializeOptions = {}
): Ruleset {
let parsed: unknown;
try {
parsed = JSON.parse(json);
} catch (err) {
throw new RulesError("RULES.DESERIALIZE_FAILED", "invalid JSON", {
cause: err,
});
}
return deserializeRulesetFromObject(parsed, options);
}
export function cloneRule(rule: Rule): Rule {
return fromObject(toObject(rule), {});
}
/**
* Structural hash of a rule tree. Recurses into logical/group children
* SORTED to ensure commutative operators (AND/OR) produce a stable hash
* regardless of child order. Uses FNV-1a over the canonicalised string.
*/
export function hashRule(rule: Rule): string {
const canonical = JSON.stringify(canonicalize(rule));
return fnv1a(canonical);
}
export function areRulesEqual(a: Rule, b: Rule): boolean {
return hashRule(a) === hashRule(b);
}
export function validateSerializedRule(input: unknown): SerializedRule {
const parsed = SERIALIZED_RULE_SCHEMA.safeParse(input);
if (!parsed.success) {
throw new RulesError(
"RULES.SERIALIZE_FAILED",
"invalid SerializedRule",
{
cause: parsed.error,
details: { issues: parsed.error.issues },
}
);
}
return parsed.data as SerializedRule;
}
// =================== internals ===================
function toObject(rule: Rule): SerializedRule {
if (isConditionRule(rule)) {
const out: Record<string, unknown> = {
type: "condition",
field: rule.field,
operator: rule.operator,
value: rule.value,
};
if (rule.message !== undefined) out["message"] = rule.message;
if (rule.metadata !== undefined) out["metadata"] = rule.metadata;
return out as unknown as SerializedRule;
}
if (isLogicalRule(rule)) {
const out: Record<string, unknown> = {
type: "logical",
operator: rule.operator,
rules: rule.rules.map(toObject),
};
if (rule.message !== undefined) out["message"] = rule.message;
if (rule.metadata !== undefined) out["metadata"] = rule.metadata;
return out as unknown as SerializedRule;
}
if (isGroupRule(rule)) {
const out: Record<string, unknown> = {
type: "group",
name: rule.name,
rules: rule.rules.map(toObject),
};
if (rule.operator !== undefined) out["operator"] = rule.operator;
if (rule.message !== undefined) out["message"] = rule.message;
if (rule.metadata !== undefined) out["metadata"] = rule.metadata;
return out as unknown as SerializedRule;
}
if (isCustomRule(rule)) {
const out: Record<string, unknown> = {
type: "custom",
name: rule.name,
validate: FUNCTION_PLACEHOLDER,
};
if (rule.message !== undefined) out["message"] = rule.message;
if (rule.metadata !== undefined) out["metadata"] = rule.metadata;
return out as unknown as SerializedRule;
}
if (isComputedRule(rule)) {
const out: Record<string, unknown> = {
type: "computed",
field: rule.field,
operator: rule.operator,
compute: FUNCTION_PLACEHOLDER,
};
if (rule.message !== undefined) out["message"] = rule.message;
if (rule.metadata !== undefined) out["metadata"] = rule.metadata;
return out as unknown as SerializedRule;
}
throw new RulesError(
"RULES.SERIALIZE_FAILED",
`unknown rule type: ${(rule as Rule).type}`
);
}
function fromObject(input: SerializedRule, options: DeserializeOptions): Rule {
switch (input.type) {
case "condition":
return {
type: "condition",
field: input.field,
operator: input.operator as never,
value: input.value,
...(input.message !== undefined
? { message: input.message }
: {}),
...(input.metadata !== undefined
? { metadata: input.metadata }
: {}),
};
case "logical":
return {
type: "logical",
operator: input.operator,
rules: input.rules.map((r) => fromObject(r, options)),
...(input.message !== undefined
? { message: input.message }
: {}),
...(input.metadata !== undefined
? { metadata: input.metadata }
: {}),
};
case "group":
return {
type: "group",
name: input.name,
rules: input.rules.map((r) => fromObject(r, options)),
...(input.operator !== undefined
? { operator: input.operator }
: {}),
...(input.message !== undefined
? { message: input.message }
: {}),
...(input.metadata !== undefined
? { metadata: input.metadata }
: {}),
};
case "custom": {
const resolver = options.customResolvers?.[input.name];
if (resolver === undefined) {
throw new RulesError(
"RULES.DESERIALIZE_FAILED",
`custom validator resolver not supplied for "${input.name}"`,
{ details: { name: input.name } }
);
}
return {
type: "custom",
name: input.name,
validate: resolver,
...(input.message !== undefined
? { message: input.message }
: {}),
...(input.metadata !== undefined
? { metadata: input.metadata }
: {}),
};
}
case "computed": {
const compute = options.computeResolvers?.[input.field];
if (compute === undefined) {
throw new RulesError(
"RULES.DESERIALIZE_FAILED",
`compute resolver not supplied for field "${input.field}"`,
{ details: { field: input.field } }
);
}
return {
type: "computed",
field: input.field,
operator: input.operator as never,
compute,
...(input.message !== undefined
? { message: input.message }
: {}),
...(input.metadata !== undefined
? { metadata: input.metadata }
: {}),
};
}
}
}
function canonicalize(rule: Rule): unknown {
if (isLogicalRule(rule)) {
const children = rule.rules.map(canonicalize);
if (rule.operator === "and" || rule.operator === "or") {
children.sort((a, b) =>
JSON.stringify(a).localeCompare(JSON.stringify(b))
);
}
return { type: "logical", operator: rule.operator, rules: children };
}
if (isGroupRule(rule)) {
const children = rule.rules.map(canonicalize);
if (
rule.operator === "and" ||
rule.operator === "or" ||
rule.operator === undefined
) {
children.sort((a, b) =>
JSON.stringify(a).localeCompare(JSON.stringify(b))
);
}
return {
type: "group",
name: rule.name,
operator: rule.operator ?? "and",
rules: children,
};
}
if (isCustomRule(rule)) {
return { type: "custom", name: rule.name };
}
if (isComputedRule(rule)) {
return { type: "computed", field: rule.field, operator: rule.operator };
}
return {
type: "condition",
field: rule.field,
operator: rule.operator,
value: rule.value,
};
}
function fnv1a(input: string): string {
let hash = 0x811c_9dc5;
for (let i = 0; i < input.length; i++) {
hash ^= input.charCodeAt(i);
hash =
(hash +
((hash << 1) +
(hash << 4) +
(hash << 7) +
(hash << 8) +
(hash << 24))) >>>
0;
}
return hash.toString(16).padStart(8, "0");
}

View File

@@ -0,0 +1,44 @@
/**
* Action type definitions — the post-evaluation effect surface.
*
* After a rule passes (or fails), callers can trigger named `Action`s that
* run through an `ActionExecutor`. Handlers receive their `config`, the
* evaluation `context`, and an `AbortSignal`. Built-in handlers cover
* `log` / `set` / `delay` / `noop`; callers register custom handlers on an
* `ActionRegistry`.
*/
import type { RuleContext } from "./context.types.js";
export type ActionStatus = "success" | "failed" | "skipped";
export interface ActionConfig {
readonly handler: string;
readonly params?: Readonly<Record<string, unknown>>;
readonly timeoutMs?: number;
readonly continueOnError?: boolean;
}
export interface ActionResult {
readonly status: ActionStatus;
readonly handler: string;
readonly durationMs: number;
readonly output?: unknown;
readonly errorMessage?: string;
readonly errorCode?: string;
}
export type ActionHandler<TContext = unknown> = (
params: Readonly<Record<string, unknown>>,
context: RuleContext<
TContext extends Record<string, unknown>
? TContext
: Record<string, unknown>
>,
options: { readonly signal: AbortSignal }
) => unknown | Promise<unknown>;
export interface ActionEventPayload {
readonly action: string;
readonly result: ActionResult;
}

View File

@@ -0,0 +1,45 @@
/**
* Context type definitions — the evaluation environment passed to every rule.
*
* A `RuleContext` carries the data being evaluated plus optional cross-cutting
* fields (`tenant`, `user`, `request`, `timestamp`, `metadata`). The
* `FieldResolver` contract describes how field paths (dot-path / array-index)
* are looked up against `data`. `ContextProvider` is the production seam for
* deriving a context from an HTTP request or background-job payload.
*/
export interface RuleContext<TData = Record<string, unknown>> {
readonly data: TData;
readonly tenant?: string;
readonly user?: { readonly id: string; readonly roles?: readonly string[] };
readonly request?: RequestContext;
readonly timestamp?: Date;
readonly metadata?: Readonly<Record<string, unknown>>;
}
export interface RequestContext {
readonly method?: string;
readonly path?: string;
readonly headers?: Readonly<Record<string, string>>;
readonly query?: Readonly<Record<string, string | readonly string[]>>;
readonly params?: Readonly<Record<string, string>>;
readonly ip?: string;
}
export interface ScopedContext<TData = Record<string, unknown>>
extends RuleContext<TData> {
readonly scope: string;
}
export interface FieldResolver {
resolve(data: unknown, path: string): unknown;
has(data: unknown, path: string): boolean;
}
export interface ContextProvider<TData = Record<string, unknown>> {
create(input: unknown): RuleContext<TData> | Promise<RuleContext<TData>>;
enhance(
context: RuleContext<TData>,
input: unknown
): RuleContext<TData> | Promise<RuleContext<TData>>;
}

View File

@@ -0,0 +1,64 @@
/**
* Event type definitions — the event-bus topic registry and payload shapes.
*
* The engine emits typed events at well-defined lifecycle points (engine
* start/stop, rule passed/failed, condition matched/skipped, action
* triggered, context built). Callers subscribe via `EventBus.on(type, fn)`
* or register prioritised hooks via `HookManager`.
*/
export type RuleEngineEvent =
| "engine.started"
| "engine.stopped"
| "rule.evaluating"
| "rule.passed"
| "rule.failed"
| "condition.matched"
| "condition.skipped"
| "action.triggered"
| "action.completed"
| "context.built"
| "context.enhanced"
| "ruleset.evaluating"
| "ruleset.completed"
| "operator.unknown"
| "validator.invoked";
export interface BaseEventPayload {
readonly type: RuleEngineEvent;
readonly timestamp: number;
}
export interface EngineEventPayload extends BaseEventPayload {
readonly engineId?: string;
}
export interface RuleEventPayload extends BaseEventPayload {
readonly ruleId?: string;
readonly ruleType: string;
readonly passed?: boolean;
}
export interface ConditionEventPayload extends BaseEventPayload {
readonly field: string;
readonly operator: string;
readonly matched: boolean;
}
export interface EventPayload extends BaseEventPayload {
readonly data?: unknown;
}
export type EventListener<P extends BaseEventPayload = EventPayload> = (
payload: P
) => void | Promise<void>;
export interface PrioritizedHook<P extends BaseEventPayload = EventPayload> {
readonly priority: number;
readonly listener: EventListener<P>;
readonly id?: string;
}
export type RuleEngineEventMap = {
readonly [K in RuleEngineEvent]: BaseEventPayload & { readonly type: K };
};

View File

@@ -0,0 +1,55 @@
/**
* Type guards over the `Rule` discriminated union.
*
* Each guard narrows the `Rule` union by `type` and returns `false` for any
* non-object input (including `null` and `undefined`) rather than throwing.
* The guards are the recommended path for caller-side branching over the
* union — they remove the need for unsafe casts and keep call-sites readable.
*/
import type {
ComputedRule,
ConditionRule,
CustomRule,
GroupRule,
LogicalRule,
Rule,
} from "./rule.types.js";
function isRuleObject(value: unknown): value is { readonly type: string } {
return typeof value === "object" && value !== null && "type" in value;
}
export function isConditionRule(rule: unknown): rule is ConditionRule {
return isRuleObject(rule) && rule.type === "condition";
}
export function isLogicalRule(rule: unknown): rule is LogicalRule {
return isRuleObject(rule) && rule.type === "logical";
}
export function isGroupRule(rule: unknown): rule is GroupRule {
return isRuleObject(rule) && rule.type === "group";
}
export function isCustomRule<TContext = unknown>(
rule: unknown
): rule is CustomRule<TContext> {
return isRuleObject(rule) && rule.type === "custom";
}
export function isComputedRule<TContext = unknown>(
rule: unknown
): rule is ComputedRule<TContext> {
return isRuleObject(rule) && rule.type === "computed";
}
export function isRule(value: unknown): value is Rule {
return (
isConditionRule(value) ||
isLogicalRule(value) ||
isGroupRule(value) ||
isCustomRule(value) ||
isComputedRule(value)
);
}

View File

@@ -0,0 +1,72 @@
export type {
ActionConfig,
ActionEventPayload,
ActionHandler,
ActionResult,
ActionStatus,
} from "./action.types.js";
export type {
ContextProvider,
FieldResolver,
RequestContext,
RuleContext,
ScopedContext,
} from "./context.types.js";
export type {
BaseEventPayload,
ConditionEventPayload,
EngineEventPayload,
EventListener,
EventPayload,
PrioritizedHook,
RuleEngineEvent,
RuleEngineEventMap,
RuleEventPayload,
} from "./event.types.js";
export {
isComputedRule,
isConditionRule,
isCustomRule,
isGroupRule,
isLogicalRule,
isRule,
} from "./guards.js";
export type {
ComparisonOperator,
LogicalOperator,
OperatorDefinition,
OperatorFunction,
OperatorRegistryLike,
} from "./operator.types.js";
export type {
EvaluationMetadata,
EvaluationOptions,
EvaluationResult,
EvaluationTrace,
FieldValidatorResult,
RuleError,
TraceEntry,
ValidationError,
ValidationResult,
} from "./result.types.js";
export type {
ComputedRule,
ConditionRule,
CustomRule,
CustomValidatorFn as RuleCustomValidatorFn,
GroupRule,
LogicalRule,
Rule,
RuleMetadata,
RulePriority,
RuleStatus,
Ruleset,
} from "./rule.types.js";
export type {
CustomValidatorFn,
FieldSchema,
FieldType,
Schema,
StringFormat,
ValidatorContext,
} from "./validator.types.js";

View File

@@ -0,0 +1,71 @@
/**
* Operator type definitions for the rule engine.
*
* Defines the comparison operator namespace, operator function signature,
* and the structural contract for an `OperatorDefinition`. The engine
* dispatches every condition rule through an operator looked up from an
* `OperatorRegistry` against the operator name carried on the rule.
*/
export type ComparisonOperator =
| "equals"
| "notEquals"
| "strictEquals"
| "strictNotEquals"
| "greaterThan"
| "greaterThanOrEqual"
| "lessThan"
| "lessThanOrEqual"
| "between"
| "notBetween"
| "in"
| "notIn"
| "contains"
| "notContains"
| "containsAll"
| "containsAny"
| "overlaps"
| "exists"
| "notExists"
| "isNull"
| "isNotNull"
| "isEmpty"
| "isNotEmpty"
| "isType"
| "isNotType"
| "hasProperty"
| "lengthEquals"
| "lengthGreaterThan"
| "lengthLessThan"
| "startsWith"
| "endsWith"
| "matches"
| "notMatches";
export type LogicalOperator = "and" | "or" | "not" | "xor";
export type OperatorFunction<T = unknown, V = unknown> = (
fieldValue: T,
compareValue: V,
context?: unknown
) => boolean | Promise<boolean>;
export interface OperatorDefinition<T = unknown, V = unknown> {
readonly name: string;
readonly description: string;
readonly evaluate: OperatorFunction<T, V>;
readonly async?: boolean;
readonly acceptedTypes?: readonly string[];
readonly aliases?: readonly string[];
}
export interface OperatorRegistryLike {
register<T = unknown, V = unknown>(
name: string,
definition: OperatorDefinition<T, V>
): void;
get(name: string): OperatorDefinition | undefined;
has(name: string): boolean;
names(): readonly string[];
unregister(name: string): boolean;
}

View File

@@ -0,0 +1,76 @@
/**
* Result type definitions — the shape of an `EvaluationResult` and related
* structures returned by the engine, validators, and middleware.
*
* `EvaluationResult` is the discriminated outcome of evaluating a rule (or
* ruleset). It always carries `passed`, `errors` (typed `RuleError` records
* with stable codes), `trace` for debugging, and `metadata` describing the
* evaluation environment. Validators return `ValidationResult` with a flat
* list of `ValidationError` records keyed by field path.
*/
export interface RuleError {
readonly code: string;
readonly message: string;
readonly field?: string;
readonly rule?: string;
readonly actualValue?: unknown;
readonly expectedValue?: unknown;
readonly severity?: "error" | "warning" | "info";
readonly details?: Readonly<Record<string, unknown>>;
}
export interface ValidationError {
readonly path: string;
readonly message: string;
readonly code: string;
readonly value?: unknown;
readonly expected?: unknown;
readonly rule?: string;
}
export interface TraceEntry {
readonly ruleId?: string;
readonly ruleType: string;
readonly outcome: "pass" | "fail" | "skipped";
readonly durationMs: number;
readonly depth: number;
readonly notes?: string;
}
export interface EvaluationMetadata {
readonly startedAt: Date;
readonly endedAt: Date;
readonly durationMs: number;
readonly evaluatedRules: number;
readonly maxDepth: number;
}
export interface EvaluationTrace {
readonly entries: readonly TraceEntry[];
}
export interface EvaluationResult {
readonly passed: boolean;
readonly errors: readonly RuleError[];
readonly trace?: EvaluationTrace;
readonly metadata?: EvaluationMetadata;
}
export interface ValidationResult<T = unknown> {
readonly valid: boolean;
readonly errors: readonly ValidationError[];
readonly value?: T;
}
export interface FieldValidatorResult {
readonly valid: boolean;
readonly errors: readonly string[];
}
export interface EvaluationOptions {
readonly signal?: AbortSignal;
readonly maxDepth?: number;
readonly trace?: boolean;
readonly cache?: boolean;
}

View File

@@ -0,0 +1,93 @@
/**
* Rule type definitions — the discriminated `Rule` union and its variants.
*
* Every rule is one of five shapes, discriminated on the `type` field:
* `ConditionRule` (a single field tested by an operator), `LogicalRule`
* (and/or/not over child rules), `GroupRule` (named bag of rules with an
* optional combining operator), `CustomRule` (caller-supplied predicate), or
* `ComputedRule` (operator over a value computed from context). Rules are
* `readonly` end-to-end so callers can share them across evaluations safely.
*/
import type { ComparisonOperator } from "./operator.types.js";
export type RulePriority = "critical" | "high" | "normal" | "low";
export type RuleStatus = "active" | "inactive" | "draft" | "archived";
export interface RuleMetadata {
readonly id?: string;
readonly name?: string;
readonly description?: string;
readonly priority?: RulePriority;
readonly status?: RuleStatus;
readonly version?: string;
readonly tags?: readonly string[];
readonly createdAt?: Date;
readonly updatedAt?: Date;
}
export interface ConditionRule<T = unknown> {
readonly type: "condition";
readonly field: string;
readonly operator: ComparisonOperator;
readonly value: T;
readonly message?: string;
readonly metadata?: RuleMetadata;
}
export interface LogicalRule {
readonly type: "logical";
readonly operator: "and" | "or" | "not" | "xor";
readonly rules: readonly Rule[];
readonly message?: string;
readonly metadata?: RuleMetadata;
}
export interface GroupRule {
readonly type: "group";
readonly name: string;
readonly rules: readonly Rule[];
readonly operator?: "and" | "or";
readonly message?: string;
readonly metadata?: RuleMetadata;
}
export type CustomValidatorFn<TContext = unknown, TValue = unknown> = (
value: TValue,
context: TContext
) => boolean | Promise<boolean>;
export interface CustomRule<TContext = unknown, TValue = unknown> {
readonly type: "custom";
readonly name: string;
readonly validate: CustomValidatorFn<TContext, TValue>;
readonly message?: string;
readonly metadata?: RuleMetadata;
}
export interface ComputedRule<TContext = unknown> {
readonly type: "computed";
readonly field: string;
readonly operator: ComparisonOperator;
readonly compute: (context: TContext) => unknown | Promise<unknown>;
readonly message?: string;
readonly metadata?: RuleMetadata;
}
export type Rule<TContext = unknown> =
| ConditionRule
| LogicalRule
| GroupRule
| CustomRule<TContext>
| ComputedRule<TContext>;
export interface Ruleset {
readonly id: string;
readonly name: string;
readonly description?: string;
readonly rules: readonly Rule[];
readonly operator?: "and" | "or";
readonly version?: string;
readonly failFast?: boolean;
readonly metadata?: RuleMetadata;
}

View File

@@ -0,0 +1,66 @@
/**
* Validator type definitions — shapes shared by the three validator families
* (`SchemaValidator`, `FieldValidator`, `CustomValidator`).
*
* `FieldType` enumerates the primitive shapes a schema can declare. `Schema`
* is the recursive JSON-schema-like definition consumed by `SchemaValidator`.
* `ValidatorContext` is the runtime environment passed to caller-supplied
* validator functions (path, root data, AbortSignal, optional locale).
*/
export type FieldType =
| "string"
| "number"
| "integer"
| "boolean"
| "object"
| "array"
| "date"
| "datetime"
| "any";
export type StringFormat =
| "email"
| "url"
| "uuid"
| "date"
| "datetime"
| "ipv4"
| "ipv6"
| "regex";
export interface FieldSchema {
readonly type: FieldType;
readonly required?: boolean;
readonly format?: StringFormat;
readonly min?: number;
readonly max?: number;
readonly minLength?: number;
readonly maxLength?: number;
readonly pattern?: string;
readonly enum?: readonly unknown[];
readonly items?: FieldSchema;
readonly properties?: Readonly<Record<string, FieldSchema>>;
readonly transforms?: readonly (
| "trim"
| "lowercase"
| "uppercase"
| "parseNumber"
| "parseDate"
)[];
readonly default?: unknown;
}
export type Schema = Readonly<Record<string, FieldSchema>>;
export interface ValidatorContext {
readonly path: string;
readonly root: unknown;
readonly signal?: AbortSignal;
readonly locale?: string;
}
export type CustomValidatorFn<T = unknown> = (
value: T,
context: ValidatorContext
) => boolean | string | Promise<boolean | string>;

View File

@@ -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()`.

View File

@@ -0,0 +1,294 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
alphanumericValidator,
asyncValidator,
CustomValidator,
composeValidators,
createDefaultValidatorRegistry,
createValidator,
creditCardValidator,
defaultValidatorRegistry,
emailValidator,
enumValidator,
hexColorValidator,
isoDateValidator,
jsonValidator,
lengthValidator,
patternValidator,
phoneValidator,
rangeValidator,
slugValidator,
strongPasswordValidator,
urlValidator,
uuidValidator,
ValidatorRegistry,
} from "./custom.js";
describe("CustomValidator", () => {
it("constructor rejects empty name", { timeout: 5000 }, () => {
expect(
() => new CustomValidator({ name: "", validate: () => true })
).toThrow(/RULES\.VALIDATOR_INVALID/);
});
it("validate returns valid+value on success", {
timeout: 5000,
}, async () => {
const v = createValidator<string>({ name: "ok", validate: () => true });
const result = await v.validate("x");
expect(result.valid).toBe(true);
expect(result.value).toBe("x");
});
it("validate returns string message on failure", {
timeout: 5000,
}, async () => {
const v = createValidator<string>({
name: "fail",
validate: () => "bad",
});
const result = await v.validate("x");
expect(result.errors[0]?.message).toBe("bad");
});
it("validate returns default message on bool false", {
timeout: 5000,
}, async () => {
const v = createValidator<string>({
name: "fail",
validate: () => false,
});
const result = await v.validate("x");
expect(result.errors[0]?.message).toContain("fail");
});
it("isValid returns boolean shortcut", { timeout: 5000 }, async () => {
const v = createValidator<string>({ name: "ok", validate: () => true });
expect(await v.isValid("x")).toBe(true);
});
it("accepts custom context", { timeout: 5000 }, async () => {
const v = createValidator<string>({
name: "path",
validate: (_v, ctx) =>
ctx.path === "expected" ? true : "wrong path",
});
const result = await v.validate("x", { path: "expected", root: {} });
expect(result.valid).toBe(true);
});
});
describe("ValidatorRegistry", () => {
it("register/get/has/names/unregister", { timeout: 5000 }, () => {
const r = new ValidatorRegistry();
r.register(emailValidator);
expect(r.has("email")).toBe(true);
expect(r.get("email")).toBe(emailValidator);
expect(r.names()).toContain("email");
expect(r.unregister("email")).toBe(true);
expect(r.unregister("email")).toBe(false);
});
it("mustGet raises RULES.VALIDATOR_NOT_FOUND", { timeout: 5000 }, () => {
const r = new ValidatorRegistry();
expect(() => r.mustGet("nope")).toThrow(/RULES\.VALIDATOR_NOT_FOUND/);
});
it("register rejects empty-name validator", { timeout: 5000 }, () => {
const r = new ValidatorRegistry();
const fake = Object.create(
CustomValidator.prototype
) as CustomValidator;
Object.defineProperty(fake, "name", { value: "" });
expect(() => r.register(fake)).toThrow(/RULES\.VALIDATOR_INVALID/);
});
});
describe("composeValidators", () => {
it("short-circuits on first failure", { timeout: 5000 }, async () => {
let secondCalled = false;
const composed = composeValidators<string>([
createValidator<string>({
name: "first",
validate: () => "first failed",
}),
createValidator<string>({
name: "second",
validate: () => {
secondCalled = true;
return true;
},
}),
]);
const result = await composed.validate("x");
expect(result.errors[0]?.message).toBe("first failed");
expect(secondCalled).toBe(false);
});
it("passes when every validator passes", { timeout: 5000 }, async () => {
const composed = composeValidators<string>([
createValidator<string>({ name: "a", validate: () => true }),
createValidator<string>({ name: "b", validate: () => true }),
]);
expect((await composed.validate("x")).valid).toBe(true);
});
it("falls back to default message when no string", {
timeout: 5000,
}, async () => {
const composed = composeValidators<string>([
createValidator<string>({ name: "a", validate: () => false }),
]);
const result = await composed.validate("x");
// inner validator's default message contains its name
expect(result.errors[0]?.message).toContain("a");
});
});
describe("built-in validators", () => {
const cases: ReadonlyArray<[CustomValidator<unknown>, unknown, boolean]> = [
[emailValidator as CustomValidator<unknown>, "a@b.co", true],
[emailValidator as CustomValidator<unknown>, "bad", false],
[emailValidator as CustomValidator<unknown>, 42, false],
[urlValidator as CustomValidator<unknown>, "https://x.com", true],
[urlValidator as CustomValidator<unknown>, "ftp://x", false],
[urlValidator as CustomValidator<unknown>, 42, false],
[
uuidValidator as CustomValidator<unknown>,
"00000000-0000-0000-0000-000000000000",
true,
],
[uuidValidator as CustomValidator<unknown>, "bad", false],
[uuidValidator as CustomValidator<unknown>, 42, false],
[isoDateValidator as CustomValidator<unknown>, "2024-01-01", true],
[
isoDateValidator as CustomValidator<unknown>,
"2024-01-01T12:00:00",
true,
],
[isoDateValidator as CustomValidator<unknown>, "2024-99-99", false],
[isoDateValidator as CustomValidator<unknown>, "bad", false],
[isoDateValidator as CustomValidator<unknown>, 42, false],
[
creditCardValidator as CustomValidator<unknown>,
"4111111111111111",
true,
],
[
creditCardValidator as CustomValidator<unknown>,
"1234567890123456",
false,
],
[creditCardValidator as CustomValidator<unknown>, "1", false],
[
creditCardValidator as CustomValidator<unknown>,
4111111111111111,
false,
],
[phoneValidator as CustomValidator<unknown>, "+1 555-5555", true],
[phoneValidator as CustomValidator<unknown>, "abc", false],
[phoneValidator as CustomValidator<unknown>, 42, false],
[strongPasswordValidator as CustomValidator<unknown>, "Aa1!aaaa", true],
[
strongPasswordValidator as CustomValidator<unknown>,
"aa1!aaaa",
false,
],
[
strongPasswordValidator as CustomValidator<unknown>,
"AA1!AAAA",
false,
],
[
strongPasswordValidator as CustomValidator<unknown>,
"AaA!AaAa",
false,
],
[
strongPasswordValidator as CustomValidator<unknown>,
"Aa1aaaaa",
false,
],
[strongPasswordValidator as CustomValidator<unknown>, "Aa1", false],
[strongPasswordValidator as CustomValidator<unknown>, 42, false],
[alphanumericValidator as CustomValidator<unknown>, "ABC123", true],
[alphanumericValidator as CustomValidator<unknown>, "ABC-123", false],
[hexColorValidator as CustomValidator<unknown>, "#abc", true],
[hexColorValidator as CustomValidator<unknown>, "#aabbcc", true],
[hexColorValidator as CustomValidator<unknown>, "#aabbccdd", true],
[hexColorValidator as CustomValidator<unknown>, "nope", false],
[slugValidator as CustomValidator<unknown>, "hello-world", true],
[slugValidator as CustomValidator<unknown>, "Hello World", false],
[jsonValidator as CustomValidator<unknown>, '{"x":1}', true],
[jsonValidator as CustomValidator<unknown>, "not-json", false],
[jsonValidator as CustomValidator<unknown>, 42, false],
];
for (const [v, value, expected] of cases) {
it(`${v.name}: ${JSON.stringify(value)} -> ${expected}`, {
timeout: 5000,
}, async () => {
expect((await v.validate(value)).valid).toBe(expected);
});
}
});
describe("parameterised validators", () => {
it("enumValidator", { timeout: 5000 }, async () => {
const v = enumValidator(["a", "b"]);
expect((await v.validate("a")).valid).toBe(true);
expect((await v.validate("c")).valid).toBe(false);
});
it("lengthValidator", { timeout: 5000 }, async () => {
const v = lengthValidator(2, 4);
expect((await v.validate("a")).valid).toBe(false);
expect((await v.validate("abc")).valid).toBe(true);
expect((await v.validate("abcde")).valid).toBe(false);
expect((await v.validate(42 as unknown as string)).valid).toBe(false);
});
it("patternValidator", { timeout: 5000 }, async () => {
const v = patternValidator(/^X/);
expect((await v.validate("Xyz")).valid).toBe(true);
expect((await v.validate("yz")).valid).toBe(false);
const withMessage = patternValidator(/^X/, "must start with X");
expect((await withMessage.validate("y")).errors[0]?.message).toBe(
"must start with X"
);
});
it("rangeValidator", { timeout: 5000 }, async () => {
const v = rangeValidator(1, 10);
expect((await v.validate(5)).valid).toBe(true);
expect((await v.validate(0)).valid).toBe(false);
expect((await v.validate(11)).valid).toBe(false);
expect((await v.validate("nope" as unknown as number)).valid).toBe(
false
);
});
it("asyncValidator threads context", { timeout: 5000 }, async () => {
const v = asyncValidator<string>(async (value, ctx) =>
value === "ok" && ctx.path === "p" ? true : "fail"
);
expect((await v.validate("ok", { path: "p", root: {} })).valid).toBe(
true
);
});
});
describe("defaultValidatorRegistry", () => {
it("contains 11 built-in entries", { timeout: 5000 }, () => {
expect(defaultValidatorRegistry.names().length).toBe(11);
});
it("createDefaultValidatorRegistry returns fresh instance", {
timeout: 5000,
}, () => {
const a = createDefaultValidatorRegistry();
const b = createDefaultValidatorRegistry();
expect(a).not.toBe(b);
expect(a.has("email")).toBe(true);
});
});

View File

@@ -0,0 +1,344 @@
import { RulesError } from "../../errors/index.js";
import { getInstruments } from "../../otel/index.js";
import type {
CustomValidatorFn,
ValidationResult,
ValidatorContext,
} from "../../types/index.js";
export interface CustomValidatorDefinition<T = unknown> {
readonly name: string;
readonly description?: string;
readonly validate: CustomValidatorFn<T>;
}
export class CustomValidator<T = unknown> {
constructor(private readonly definition: CustomValidatorDefinition<T>) {
if (definition.name === "") {
throw new RulesError(
"RULES.VALIDATOR_INVALID",
"validator name must be non-empty"
);
}
}
get name(): string {
return this.definition.name;
}
async validate(
value: T,
context: ValidatorContext = { path: "", root: value }
): Promise<ValidationResult<T>> {
const start = performance.now();
const outcome = await this.definition.validate(value, context);
const ok = outcome === true;
const duration = performance.now() - start;
const instruments = getInstruments();
instruments.validatorValidateDuration.record(duration, {
kind: "custom",
});
instruments.validatorValidationsTotal.add(1, {
kind: "custom",
outcome: ok ? "valid" : "invalid",
});
if (ok) return { valid: true, errors: [], value };
return {
valid: false,
errors: [
{
path: context.path,
message:
typeof outcome === "string"
? outcome
: `${this.definition.name}: validation failed`,
code: "CUSTOM.REFINEMENT",
value,
},
],
};
}
async isValid(value: T, context?: ValidatorContext): Promise<boolean> {
return (await this.validate(value, context)).valid;
}
}
export function createValidator<T = unknown>(
definition: CustomValidatorDefinition<T>
): CustomValidator<T> {
return new CustomValidator(definition);
}
export class ValidatorRegistry {
private readonly entries = new Map<string, CustomValidator>();
register<T = unknown>(validator: CustomValidator<T>): void {
if (validator.name === "") {
throw new RulesError(
"RULES.VALIDATOR_INVALID",
"validator name must be non-empty"
);
}
this.entries.set(validator.name, validator as CustomValidator);
}
get(name: string): CustomValidator | undefined {
return this.entries.get(name);
}
mustGet(name: string): CustomValidator {
const entry = this.entries.get(name);
if (entry === undefined) {
throw new RulesError(
"RULES.VALIDATOR_NOT_FOUND",
`validator not registered: ${name}`,
{ details: { name } }
);
}
return entry;
}
has(name: string): boolean {
return this.entries.has(name);
}
names(): readonly string[] {
return [...this.entries.keys()];
}
unregister(name: string): boolean {
return this.entries.delete(name);
}
}
/**
* Compose validators sequentially. Returns the first failure; if every
* validator passes, returns `{ valid: true }`. Short-circuits on first
* failure (later validators do not run).
*/
export function composeValidators<T = unknown>(
validators: readonly CustomValidator<T>[]
): CustomValidator<T> {
return new CustomValidator<T>({
name: "composed",
validate: async (value, context) => {
for (const v of validators) {
const result = await v.validate(value, context);
if (!result.valid) {
return result.errors[0]?.message ?? "composition failed";
}
}
return true;
},
});
}
// =================== built-in validators ===================
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const URL_RE = /^https?:\/\/.+/i;
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}:\d{2})?/;
const PHONE_RE = /^\+?[\d\s\-().]{7,}$/;
const ALPHANUMERIC_RE = /^[A-Za-z0-9]+$/;
const HEX_COLOR_RE = /^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
function luhnCheck(value: string): boolean {
const digits = value.replace(/\D/g, "");
if (digits.length < 12 || digits.length > 19) return false;
let sum = 0;
let alt = false;
for (let i = digits.length - 1; i >= 0; i--) {
let d = Number.parseInt(digits[i] as string, 10);
if (alt) {
d *= 2;
if (d > 9) d -= 9;
}
sum += d;
alt = !alt;
}
return sum % 10 === 0;
}
export const emailValidator: CustomValidator<string> = createValidator({
name: "email",
validate: (v) =>
typeof v === "string" && EMAIL_RE.test(v)
? true
: "must be a valid email",
});
export const urlValidator: CustomValidator<string> = createValidator({
name: "url",
validate: (v) =>
typeof v === "string" && URL_RE.test(v) ? true : "must be a URL",
});
export const uuidValidator: CustomValidator<string> = createValidator({
name: "uuid",
validate: (v) =>
typeof v === "string" && UUID_RE.test(v) ? true : "must be a UUID",
});
export const isoDateValidator: CustomValidator<string> = createValidator({
name: "iso-date",
validate: (v) =>
typeof v === "string" &&
ISO_DATE_RE.test(v) &&
!Number.isNaN(new Date(v).getTime())
? true
: "must be an ISO 8601 date",
});
export const creditCardValidator: CustomValidator<string> = createValidator({
name: "credit-card",
validate: (v) =>
typeof v === "string" && luhnCheck(v) ? true : "Luhn checksum failed",
});
export const phoneValidator: CustomValidator<string> = createValidator({
name: "phone",
validate: (v) =>
typeof v === "string" && PHONE_RE.test(v)
? true
: "must be a phone number",
});
export const strongPasswordValidator: CustomValidator<string> = createValidator(
{
name: "strong-password",
validate: (v) => {
if (typeof v !== "string") return "must be a string";
if (v.length < 8) return "min length 8";
if (!/[A-Z]/.test(v)) return "needs uppercase";
if (!/[a-z]/.test(v)) return "needs lowercase";
if (!/\d/.test(v)) return "needs digit";
if (!/[^A-Za-z0-9]/.test(v)) return "needs symbol";
return true;
},
}
);
export const alphanumericValidator: CustomValidator<string> = createValidator({
name: "alphanumeric",
validate: (v) =>
typeof v === "string" && ALPHANUMERIC_RE.test(v)
? true
: "must be alphanumeric",
});
export const hexColorValidator: CustomValidator<string> = createValidator({
name: "hex-color",
validate: (v) =>
typeof v === "string" && HEX_COLOR_RE.test(v)
? true
: "must be a hex color",
});
export const slugValidator: CustomValidator<string> = createValidator({
name: "slug",
validate: (v) =>
typeof v === "string" && SLUG_RE.test(v) ? true : "must be a slug",
});
export const jsonValidator: CustomValidator<string> = createValidator({
name: "json",
validate: (v) => {
if (typeof v !== "string") return "must be a string";
try {
JSON.parse(v);
return true;
} catch {
return "must be valid JSON";
}
},
});
export function enumValidator<T>(values: readonly T[]): CustomValidator<T> {
return createValidator({
name: "enum",
validate: (v) =>
values.includes(v as T) ? true : "value not in allowed enum",
});
}
export function lengthValidator(
min: number,
max: number
): CustomValidator<string> {
return createValidator({
name: "length",
validate: (v) => {
if (typeof v !== "string") return "must be a string";
if (v.length < min) return `min length ${min}`;
if (v.length > max) return `max length ${max}`;
return true;
},
});
}
export function patternValidator(
re: RegExp,
message?: string
): CustomValidator<string> {
return createValidator({
name: "pattern",
validate: (v) =>
typeof v === "string" && re.test(v)
? true
: (message ?? "pattern mismatch"),
});
}
export function rangeValidator(
min: number,
max: number
): CustomValidator<number> {
return createValidator({
name: "range",
validate: (v) => {
if (typeof v !== "number") return "must be a number";
if (v < min) return `must be >= ${min}`;
if (v > max) return `must be <= ${max}`;
return true;
},
});
}
export function asyncValidator<T>(
fn: (value: T, context: ValidatorContext) => Promise<boolean | string>
): CustomValidator<T> {
return createValidator({
name: "async",
validate: async (value, context) => {
const out = await fn(value, context);
return out;
},
});
}
export function createDefaultValidatorRegistry(): ValidatorRegistry {
const r = new ValidatorRegistry();
for (const v of [
emailValidator,
urlValidator,
uuidValidator,
isoDateValidator,
creditCardValidator,
phoneValidator,
strongPasswordValidator,
alphanumericValidator,
hexColorValidator,
slugValidator,
jsonValidator,
]) {
r.register(v);
}
return r;
}
export const defaultValidatorRegistry: ValidatorRegistry =
createDefaultValidatorRegistry();

View File

@@ -0,0 +1,25 @@
export {
alphanumericValidator,
asyncValidator,
CustomValidator,
type CustomValidatorDefinition,
composeValidators,
createDefaultValidatorRegistry,
createValidator,
creditCardValidator,
defaultValidatorRegistry,
emailValidator,
enumValidator,
hexColorValidator,
isoDateValidator,
jsonValidator,
lengthValidator,
patternValidator,
phoneValidator,
rangeValidator,
slugValidator,
strongPasswordValidator,
urlValidator,
uuidValidator,
ValidatorRegistry,
} from "./custom.js";

View File

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

View File

@@ -0,0 +1,182 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
ageValidator,
createFieldValidator,
currencyValidator,
dateFieldValidator,
emailFieldValidator,
FieldValidator,
field,
integerFieldValidator,
passwordFieldValidator,
percentageValidator,
phoneFieldValidator,
positiveNumberValidator,
urlFieldValidator,
usernameFieldValidator,
uuidFieldValidator,
} from "./field.js";
describe("FieldValidator chain", () => {
it("required rejects undefined/null/empty", { timeout: 5000 }, async () => {
const v = field().required();
expect((await v.validate(undefined)).valid).toBe(false);
expect((await v.validate(null)).valid).toBe(false);
expect((await v.validate("")).valid).toBe(false);
expect((await v.validate("x")).valid).toBe(true);
});
it("minLength + maxLength", { timeout: 5000 }, async () => {
const v = field().minLength(2).maxLength(4);
expect((await v.validate("a")).valid).toBe(false);
expect((await v.validate("abc")).valid).toBe(true);
expect((await v.validate("abcde")).valid).toBe(false);
});
it("min + max", { timeout: 5000 }, async () => {
const v = field().min(1).max(10);
expect((await v.validate(0)).valid).toBe(false);
expect((await v.validate(5)).valid).toBe(true);
expect((await v.validate(11)).valid).toBe(false);
});
it("pattern", { timeout: 5000 }, async () => {
const v = field().pattern(/^[A-Z]+$/);
expect((await v.validate("abc")).valid).toBe(false);
expect((await v.validate("ABC")).valid).toBe(true);
});
it("oneOf", { timeout: 5000 }, async () => {
const v = field().oneOf(["a", "b"]);
expect((await v.validate("c")).valid).toBe(false);
expect((await v.validate("a")).valid).toBe(true);
});
it("custom returning true/false/string", { timeout: 5000 }, async () => {
const truthy = field().custom(() => true);
expect((await truthy.validate("x")).valid).toBe(true);
const stringFail = field().custom(() => "msg");
expect((await stringFail.validate("x")).errors).toEqual(["msg"]);
const boolFail = field().custom(() => false);
expect((await boolFail.validate("x")).errors[0]).toContain("custom");
});
it("custom with explicit message", { timeout: 5000 }, async () => {
const v = field().custom(() => false, "my message");
expect((await v.validate("x")).errors).toEqual(["my message"]);
});
it("customAsync handles returns", { timeout: 5000 }, async () => {
const ok = field().customAsync(async () => true);
expect((await ok.validate("x")).valid).toBe(true);
const fail = field().customAsync(async () => "async msg");
expect((await fail.validate("x")).errors).toEqual(["async msg"]);
const falseAsync = field().customAsync(async () => false);
expect((await falseAsync.validate("x")).errors[0]).toContain("custom");
const falseAsyncMsg = field().customAsync(async () => false, "boom");
expect((await falseAsyncMsg.validate("x")).errors).toEqual(["boom"]);
});
it("validateSync runs sync chain", { timeout: 5000 }, () => {
const v = field().required().minLength(2);
expect(v.validateSync("ab").valid).toBe(true);
expect(v.validateSync("a").valid).toBe(false);
});
it("validateSync throws on async chain", { timeout: 5000 }, () => {
const v = field().customAsync(async () => true);
expect(() => v.validateSync("x")).toThrow(/RULES\.VALIDATOR_INVALID/);
});
it("isAsync reports async stages", { timeout: 5000 }, () => {
expect(field().required().isAsync()).toBe(false);
expect(
field()
.customAsync(async () => true)
.isAsync()
).toBe(true);
});
it("chain is immutable per call", { timeout: 5000 }, async () => {
const base = field().required();
const withMin = base.minLength(3);
expect(base).not.toBe(withMin);
expect((await base.validate("a")).valid).toBe(true);
expect((await withMin.validate("a")).valid).toBe(false);
});
it("aborts pre-aborted signal", { timeout: 5000 }, async () => {
const v = field().required();
const controller = new AbortController();
controller.abort();
const result = await v.validate("x", { signal: controller.signal });
expect(result.errors[0]).toBe("validation aborted");
});
it("FieldValidator.create + createFieldValidator + field aliases", {
timeout: 5000,
}, () => {
expect(FieldValidator.create()).toBeInstanceOf(FieldValidator);
expect(createFieldValidator()).toBeInstanceOf(FieldValidator);
expect(field()).toBeInstanceOf(FieldValidator);
});
});
describe("preset validators", () => {
const presets: ReadonlyArray<
[string, () => FieldValidator, unknown, boolean]
> = [
["emailFieldValidator pass", emailFieldValidator, "a@b.co", true],
["emailFieldValidator fail", emailFieldValidator, "bad", false],
["urlFieldValidator pass", urlFieldValidator, "https://x.com", true],
["urlFieldValidator fail", urlFieldValidator, "bad", false],
[
"uuidFieldValidator pass",
uuidFieldValidator,
"00000000-0000-0000-0000-000000000000",
true,
],
["uuidFieldValidator fail", uuidFieldValidator, "bad", false],
[
"phoneFieldValidator pass",
phoneFieldValidator,
"+1 555-555-5555",
true,
],
["phoneFieldValidator fail", phoneFieldValidator, "abc", false],
["usernameFieldValidator pass", usernameFieldValidator, "ada42", true],
["usernameFieldValidator fail", usernameFieldValidator, "??", false],
[
"passwordFieldValidator pass",
passwordFieldValidator,
"Aa123456",
true,
],
[
"passwordFieldValidator no upper",
passwordFieldValidator,
"aa123456",
false,
],
["passwordFieldValidator short", passwordFieldValidator, "Aa12", false],
["dateFieldValidator pass", dateFieldValidator, new Date(), true],
["dateFieldValidator fail", dateFieldValidator, "not-a-date", false],
["integerFieldValidator pass", integerFieldValidator, 42, true],
["integerFieldValidator fail", integerFieldValidator, 4.2, false],
["ageValidator pass", ageValidator, 30, true],
["ageValidator fail", ageValidator, -1, false],
["currencyValidator pass", currencyValidator, 0, true],
["currencyValidator fail", currencyValidator, -1, false],
["percentageValidator pass", percentageValidator, 50, true],
["percentageValidator fail", percentageValidator, 200, false],
["positiveNumberValidator pass", positiveNumberValidator, 5, true],
["positiveNumberValidator fail", positiveNumberValidator, 0, false],
];
for (const [name, factory, value, expected] of presets) {
it(name, { timeout: 5000 }, async () => {
const result = await factory().validate(value);
expect(result.valid).toBe(expected);
});
}
});

View File

@@ -0,0 +1,253 @@
import { RulesError } from "../../errors/index.js";
import { getInstruments } from "../../otel/index.js";
import type { FieldValidatorResult } from "../../types/index.js";
type Stage =
| { readonly kind: "sync"; readonly run: (value: unknown) => true | string }
| {
readonly kind: "async";
readonly run: (value: unknown) => Promise<true | string>;
};
export interface FieldValidatorRunOptions {
readonly signal?: AbortSignal;
}
/**
* Chainable field validator. Every `withX` method returns a fresh
* `FieldValidator` instance carrying the appended stage — chains are
* immutable per call, so preset chains are safe to share across requests.
* `validateSync(value)` throws `RULES.VALIDATOR_INVALID` when the chain
* contains an async stage; `validate(value, options?)` returns a Promise
* and honours `AbortSignal`.
*/
export class FieldValidator {
private constructor(private readonly stages: readonly Stage[] = []) {}
static create(): FieldValidator {
return new FieldValidator();
}
required(message = "value is required"): FieldValidator {
return this.append({
kind: "sync",
run: (value) =>
value === undefined || value === null || value === ""
? message
: true,
});
}
minLength(min: number, message?: string): FieldValidator {
const msg = message ?? `min length ${min}`;
return this.append({
kind: "sync",
run: (value) =>
typeof value === "string" && value.length >= min ? true : msg,
});
}
maxLength(max: number, message?: string): FieldValidator {
const msg = message ?? `max length ${max}`;
return this.append({
kind: "sync",
run: (value) =>
typeof value === "string" && value.length <= max ? true : msg,
});
}
min(min: number, message?: string): FieldValidator {
const msg = message ?? `min ${min}`;
return this.append({
kind: "sync",
run: (value) =>
typeof value === "number" && value >= min ? true : msg,
});
}
max(max: number, message?: string): FieldValidator {
const msg = message ?? `max ${max}`;
return this.append({
kind: "sync",
run: (value) =>
typeof value === "number" && value <= max ? true : msg,
});
}
pattern(re: RegExp, message?: string): FieldValidator {
const msg = message ?? "pattern mismatch";
return this.append({
kind: "sync",
run: (value) =>
typeof value === "string" && re.test(value) ? true : msg,
});
}
oneOf<T>(values: readonly T[], message?: string): FieldValidator {
const msg = message ?? "value not in allowed set";
return this.append({
kind: "sync",
run: (value) => (values.includes(value as T) ? true : msg),
});
}
custom(
fn: (value: unknown) => boolean | string,
message?: string
): FieldValidator {
const msg = message ?? "custom validation failed";
return this.append({
kind: "sync",
run: (value) => {
const out = fn(value);
if (out === true) return true;
if (typeof out === "string") return out;
return msg;
},
});
}
customAsync(
fn: (value: unknown) => Promise<boolean | string>,
message?: string
): FieldValidator {
const msg = message ?? "custom validation failed";
return this.append({
kind: "async",
run: async (value) => {
const out = await fn(value);
if (out === true) return true;
if (typeof out === "string") return out;
return msg;
},
});
}
async validate(
value: unknown,
options: FieldValidatorRunOptions = {}
): Promise<FieldValidatorResult> {
const start = performance.now();
const errors: string[] = [];
for (const stage of this.stages) {
if (options.signal?.aborted === true) {
errors.push("validation aborted");
break;
}
const out = await stage.run(value);
if (out !== true) errors.push(out);
}
const duration = performance.now() - start;
const instruments = getInstruments();
instruments.validatorValidateDuration.record(duration, {
kind: "field",
});
instruments.validatorValidationsTotal.add(1, {
kind: "field",
outcome: errors.length === 0 ? "valid" : "invalid",
});
return { valid: errors.length === 0, errors };
}
validateSync(value: unknown): FieldValidatorResult {
if (this.stages.some((s) => s.kind === "async")) {
throw new RulesError(
"RULES.VALIDATOR_INVALID",
"validateSync called on a chain containing async stages"
);
}
const errors: string[] = [];
for (const stage of this.stages) {
const out = (stage as { run: (v: unknown) => true | string }).run(
value
);
if (out !== true) errors.push(out);
}
return { valid: errors.length === 0, errors };
}
isAsync(): boolean {
return this.stages.some((s) => s.kind === "async");
}
private append(stage: Stage): FieldValidator {
return new FieldValidator([...this.stages, stage]);
}
}
export function createFieldValidator(): FieldValidator {
return FieldValidator.create();
}
export function field(): FieldValidator {
return FieldValidator.create();
}
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const URL_RE = /^https?:\/\/.+/i;
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const PHONE_RE = /^\+?[\d\s\-().]{7,}$/;
const USERNAME_RE = /^[a-z0-9_]{3,32}$/i;
export const emailFieldValidator = (): FieldValidator =>
field().required().pattern(EMAIL_RE, "must be a valid email");
export const urlFieldValidator = (): FieldValidator =>
field().required().pattern(URL_RE, "must be a valid URL");
export const uuidFieldValidator = (): FieldValidator =>
field().required().pattern(UUID_RE, "must be a UUID");
export const phoneFieldValidator = (): FieldValidator =>
field().required().pattern(PHONE_RE, "must be a phone number");
export const usernameFieldValidator = (): FieldValidator =>
field().required().pattern(USERNAME_RE, "must be a valid username");
export const passwordFieldValidator = (): FieldValidator =>
field()
.required()
.minLength(8, "at least 8 chars")
.custom((v) =>
typeof v === "string" && /[A-Z]/.test(v) ? true : "needs uppercase"
)
.custom((v) =>
typeof v === "string" && /[a-z]/.test(v) ? true : "needs lowercase"
)
.custom((v) =>
typeof v === "string" && /\d/.test(v) ? true : "needs digit"
);
export const dateFieldValidator = (): FieldValidator =>
field().custom((v) =>
v instanceof Date && !Number.isNaN(v.getTime())
? true
: "must be a Date"
);
export const integerFieldValidator = (): FieldValidator =>
field().custom((v) => (Number.isInteger(v) ? true : "must be an integer"));
export const ageValidator = (): FieldValidator =>
field()
.required()
.min(0, "age must be >= 0")
.max(150, "age must be <= 150");
export const currencyValidator = (): FieldValidator =>
field()
.required()
.custom((v) =>
typeof v === "number" && v >= 0 ? true : "must be non-negative"
);
export const percentageValidator = (): FieldValidator =>
field().required().min(0, "must be >= 0").max(100, "must be <= 100");
export const positiveNumberValidator = (): FieldValidator =>
field()
.required()
.custom((v) =>
typeof v === "number" && v > 0 ? true : "must be positive"
);

View File

@@ -0,0 +1,18 @@
export {
ageValidator,
createFieldValidator,
currencyValidator,
dateFieldValidator,
emailFieldValidator,
FieldValidator,
type FieldValidatorRunOptions,
field,
integerFieldValidator,
passwordFieldValidator,
percentageValidator,
phoneFieldValidator,
positiveNumberValidator,
urlFieldValidator,
usernameFieldValidator,
uuidFieldValidator,
} from "./field.js";

View File

@@ -0,0 +1 @@
export { SchemaValidator, type SchemaValidatorOptions } from "./schema.js";

View File

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

View File

@@ -0,0 +1,302 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { SchemaValidator } from "./schema.js";
describe("SchemaValidator — primitives", () => {
it("accepts well-typed string", { timeout: 5000 }, async () => {
const v = new SchemaValidator({ name: { type: "string" } });
expect((await v.validate({ name: "Ada" })).valid).toBe(true);
});
it("rejects wrong primitive type", { timeout: 5000 }, async () => {
const v = new SchemaValidator({ name: { type: "string" } });
const result = await v.validate({ name: 42 });
expect(result.valid).toBe(false);
expect(result.errors[0]?.code).toBe("INVALID_TYPE");
});
it("required missing field reports REQUIRED", {
timeout: 5000,
}, async () => {
const v = new SchemaValidator({
name: { type: "string", required: true },
});
const result = await v.validate({});
expect(result.errors[0]?.code).toBe("REQUIRED");
});
it("non-required missing field is skipped", { timeout: 5000 }, async () => {
const v = new SchemaValidator({ name: { type: "string" } });
expect((await v.validate({})).valid).toBe(true);
});
it("default fills missing value", { timeout: 5000 }, async () => {
const v = new SchemaValidator({
name: { type: "string", default: "ada" },
});
const result = await v.validate({});
expect((result.value as { name: string }).name).toBe("ada");
});
});
describe("SchemaValidator — formats", () => {
const cases: ReadonlyArray<[string, string, boolean]> = [
["email", "a@b.co", true],
["email", "bad", false],
["url", "https://x.com", true],
["url", "nope", false],
["uuid", "00000000-0000-0000-0000-000000000000", true],
["uuid", "nope", false],
["date", "2024-01-01", true],
["date", "not-a-date", false],
["datetime", "2024-01-01T12:00:00", true],
["datetime", "nope", false],
["ipv4", "1.2.3.4", true],
["ipv4", "999.999.999.999.999", false],
["ipv6", "::1", true],
["ipv6", "zzzz", false],
];
for (const [format, value, valid] of cases) {
it(`${format}: ${value} -> ${valid}`, { timeout: 5000 }, async () => {
const v = new SchemaValidator({
x: { type: "string", format: format as never },
});
const result = await v.validate({ x: value });
expect(result.valid).toBe(valid);
});
}
it("regex format accepts everything (format placeholder)", {
timeout: 5000,
}, async () => {
const v = new SchemaValidator({
x: { type: "string", format: "regex" },
});
expect((await v.validate({ x: "anything" })).valid).toBe(true);
});
it("pattern check", { timeout: 5000 }, async () => {
const v = new SchemaValidator({
x: { type: "string", pattern: "^[A-Z]+$" },
});
expect((await v.validate({ x: "abc" })).valid).toBe(false);
expect((await v.validate({ x: "ABC" })).valid).toBe(true);
});
});
describe("SchemaValidator — string length + numeric bounds", () => {
it("minLength + maxLength", { timeout: 5000 }, async () => {
const v = new SchemaValidator({
x: { type: "string", minLength: 2, maxLength: 3 },
});
expect((await v.validate({ x: "a" })).errors[0]?.code).toBe(
"STRING.TOO_SHORT"
);
expect((await v.validate({ x: "abcd" })).errors[0]?.code).toBe(
"STRING.TOO_LONG"
);
expect((await v.validate({ x: "ab" })).valid).toBe(true);
});
it("min + max + integer", { timeout: 5000 }, async () => {
const v = new SchemaValidator({
x: { type: "integer", min: 1, max: 10 },
});
expect((await v.validate({ x: 0 })).errors[0]?.code).toBe(
"NUMBER.TOO_SMALL"
);
expect((await v.validate({ x: 11 })).errors[0]?.code).toBe(
"NUMBER.TOO_LARGE"
);
expect((await v.validate({ x: 5.5 })).errors[0]?.code).toBe(
"NUMBER.NOT_INT"
);
expect((await v.validate({ x: 5 })).valid).toBe(true);
});
it("enum constraint", { timeout: 5000 }, async () => {
const v = new SchemaValidator({
x: { type: "string", enum: ["a", "b"] },
});
expect((await v.validate({ x: "c" })).errors[0]?.code).toBe(
"ENUM.INVALID_VALUE"
);
expect((await v.validate({ x: "a" })).valid).toBe(true);
});
});
describe("SchemaValidator — transforms", () => {
it("trim + lowercase", { timeout: 5000 }, async () => {
const v = new SchemaValidator({
x: { type: "string", transforms: ["trim", "lowercase"] },
});
const result = await v.validate({ x: " ADA " });
expect(result.valid).toBe(true);
expect((result.value as { x: string }).x).toBe("ada");
});
it("uppercase", { timeout: 5000 }, async () => {
const v = new SchemaValidator({
x: { type: "string", transforms: ["uppercase"] },
});
const result = await v.validate({ x: "ada" });
expect((result.value as { x: string }).x).toBe("ADA");
});
it("parseNumber", { timeout: 5000 }, async () => {
const v = new SchemaValidator({
x: { type: "number", transforms: ["parseNumber"] },
});
const result = await v.validate({ x: "42" });
expect((result.value as { x: number }).x).toBe(42);
});
it("parseNumber non-numeric leaves as string and fails type", {
timeout: 5000,
}, async () => {
const v = new SchemaValidator({
x: { type: "number", transforms: ["parseNumber"] },
});
const result = await v.validate({ x: "abc" });
expect(result.valid).toBe(false);
});
it("parseDate", { timeout: 5000 }, async () => {
const v = new SchemaValidator({
x: { type: "date", transforms: ["parseDate"] },
});
const result = await v.validate({ x: "2024-01-01" });
expect(result.valid).toBe(true);
});
it("parseDate invalid leaves as string and fails type", {
timeout: 5000,
}, async () => {
const v = new SchemaValidator({
x: { type: "date", transforms: ["parseDate"] },
});
const result = await v.validate({ x: "not-a-date" });
expect(result.valid).toBe(false);
});
});
describe("SchemaValidator — nested + arrays", () => {
it("validates nested objects", { timeout: 5000 }, async () => {
const v = new SchemaValidator({
user: {
type: "object",
properties: { name: { type: "string", required: true } },
},
});
const result = await v.validate({ user: { name: "Ada" } });
expect(result.valid).toBe(true);
});
it("nested missing field reports error at nested path", {
timeout: 5000,
}, async () => {
const v = new SchemaValidator({
user: {
type: "object",
properties: { name: { type: "string", required: true } },
},
});
const result = await v.validate({ user: {} });
expect(result.errors[0]?.path).toBe("user.name");
});
it("validates array items", { timeout: 5000 }, async () => {
const v = new SchemaValidator({
tags: { type: "array", items: { type: "string", minLength: 2 } },
});
const result = await v.validate({ tags: ["a", "bc"] });
expect(result.valid).toBe(false);
expect(result.errors[0]?.path).toBe("tags[0]");
});
it("array of objects nested validation", { timeout: 5000 }, async () => {
const v = new SchemaValidator({
items: {
type: "array",
items: {
type: "object",
properties: { sku: { type: "string", required: true } },
},
},
});
const result = await v.validate({ items: [{ sku: "X" }, {}] });
expect(result.errors[0]?.path).toBe("items[1].sku");
});
it("any type accepts anything", { timeout: 5000 }, async () => {
const v = new SchemaValidator({ x: { type: "any" } });
expect((await v.validate({ x: { complex: true } })).valid).toBe(true);
});
});
describe("SchemaValidator — custom validators", () => {
it("invokes custom validator by path", { timeout: 5000 }, async () => {
const v = new SchemaValidator(
{ name: { type: "string" } },
{
customValidators: {
name: async (value) =>
value === "ok" ? true : "must be 'ok'",
},
}
);
const result = await v.validate({ name: "nope" });
expect(result.errors[0]?.code).toBe("CUSTOM.REFINEMENT");
expect(result.errors[0]?.message).toBe("must be 'ok'");
});
it("custom validator returning true passes", {
timeout: 5000,
}, async () => {
const v = new SchemaValidator(
{ name: { type: "string" } },
{ customValidators: { name: () => true } }
);
expect((await v.validate({ name: "x" })).valid).toBe(true);
});
it("custom validator returning false (no message) produces default", {
timeout: 5000,
}, async () => {
const v = new SchemaValidator(
{ name: { type: "string" } },
{ customValidators: { name: () => false } }
);
const result = await v.validate({ name: "x" });
expect(result.errors[0]?.message).toContain("failed custom validation");
});
});
describe("SchemaValidator — boundaries", () => {
it("rejects malformed Schema with RULES.SCHEMA_INVALID", {
timeout: 5000,
}, () => {
expect(
() => new SchemaValidator({ x: { type: "not-real" } as never })
).toThrow(/RULES\.SCHEMA_INVALID/);
});
it("aborts pre-aborted signal", { timeout: 5000 }, async () => {
const v = new SchemaValidator({ x: { type: "string" } });
const controller = new AbortController();
controller.abort();
const result = await v.validate(
{ x: "a" },
{ signal: controller.signal }
);
expect(result.valid).toBe(false);
expect(result.errors[0]?.code).toBe("RULES.SCHEMA_INVALID");
});
it("validates null/undefined data gracefully when not required", {
timeout: 5000,
}, async () => {
const v = new SchemaValidator({ x: { type: "string" } });
expect((await v.validate(null)).valid).toBe(true);
expect((await v.validate(undefined)).valid).toBe(true);
});
});

View File

@@ -0,0 +1,415 @@
import z from "zod";
import { RulesError } from "../../errors/index.js";
import { getInstruments } from "../../otel/index.js";
import type {
CustomValidatorFn,
FieldSchema,
Schema,
ValidationError,
ValidationResult,
ValidatorContext,
} from "../../types/index.js";
const FIELD_TYPES = [
"string",
"number",
"integer",
"boolean",
"object",
"array",
"date",
"datetime",
"any",
] as const;
const FORMATS = [
"email",
"url",
"uuid",
"date",
"datetime",
"ipv4",
"ipv6",
"regex",
] as const;
const FIELD_SCHEMA: z.ZodType = z.lazy(() =>
z.object({
type: z.enum(FIELD_TYPES),
required: z.boolean().optional(),
format: z.enum(FORMATS).optional(),
min: z.number().optional(),
max: z.number().optional(),
minLength: z.number().int().nonnegative().optional(),
maxLength: z.number().int().nonnegative().optional(),
pattern: z.string().optional(),
enum: z.array(z.unknown()).readonly().optional(),
items: FIELD_SCHEMA.optional(),
properties: z.record(z.string(), FIELD_SCHEMA).readonly().optional(),
transforms: z
.array(
z.enum([
"trim",
"lowercase",
"uppercase",
"parseNumber",
"parseDate",
])
)
.readonly()
.optional(),
default: z.unknown().optional(),
})
);
const SCHEMA = z.record(z.string(), FIELD_SCHEMA);
const FORMAT_PATTERNS: Record<(typeof FORMATS)[number], RegExp> = {
email: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
url: /^https?:\/\/.+/i,
uuid: /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
date: /^\d{4}-\d{2}-\d{2}$/,
datetime: /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/,
ipv4: /^(?:\d{1,3}\.){3}\d{1,3}$/,
ipv6: /^[0-9a-f:]+$/i,
regex: /.*/,
};
export interface SchemaValidatorOptions {
readonly customValidators?: Readonly<Record<string, CustomValidatorFn>>;
}
/**
* Recursive object validator over a JSON-schema-like definition. Supports
* the 8 primitive `FieldType`s, format checks (email/url/uuid/...),
* value transforms (trim / lowercase / uppercase / parseNumber / parseDate),
* nested objects + arrays, and pluggable custom validators registered by
* field name. The schema input is Zod-validated at construction — malformed
* schemas raise `RULES.SCHEMA_INVALID`.
*/
export class SchemaValidator {
private readonly schema: Schema;
private readonly customValidators: Readonly<
Record<string, CustomValidatorFn>
>;
constructor(schema: Schema, options: SchemaValidatorOptions = {}) {
const parsed = SCHEMA.safeParse(schema);
if (!parsed.success) {
throw new RulesError("RULES.SCHEMA_INVALID", "invalid Schema", {
cause: parsed.error,
details: { issues: parsed.error.issues },
});
}
this.schema = schema;
this.customValidators = options.customValidators ?? {};
}
async validate(
data: unknown,
options: { readonly signal?: AbortSignal } = {}
): Promise<ValidationResult> {
const start = performance.now();
const errors: ValidationError[] = [];
const transformed: Record<string, unknown> = {};
try {
for (const [key, field] of Object.entries(this.schema)) {
if (options.signal?.aborted === true) {
throw new RulesError(
"RULES.SCHEMA_INVALID",
"validation aborted by caller signal"
);
}
await this.validateField(
(data as Record<string, unknown> | null | undefined)?.[key],
field,
{
path: key,
root: data,
...(options.signal !== undefined
? { signal: options.signal }
: {}),
},
errors,
transformed,
key
);
}
} catch (err) {
if (err instanceof RulesError) {
errors.push({
path: "",
message: err.message,
code: err.code,
});
} else {
throw err;
}
}
const duration = performance.now() - start;
const instruments = getInstruments();
instruments.validatorValidateDuration.record(duration, {
kind: "schema",
});
instruments.validatorValidationsTotal.add(1, {
kind: "schema",
outcome: errors.length === 0 ? "valid" : "invalid",
});
return { valid: errors.length === 0, errors, value: transformed };
}
private async validateField(
value: unknown,
field: FieldSchema,
ctx: ValidatorContext,
errors: ValidationError[],
transformedOut: Record<string, unknown> | unknown[] | undefined,
key: string | number
): Promise<void> {
let working = value;
if (working === undefined && field.default !== undefined)
working = field.default;
if (working === undefined || working === null) {
if (field.required === true) {
errors.push({
path: ctx.path,
message: `"${ctx.path}" is required`,
code: "REQUIRED",
});
}
return;
}
working = this.applyTransforms(working, field.transforms);
if (transformedOut !== undefined) {
if (Array.isArray(transformedOut))
(transformedOut as unknown[])[key as number] = working;
else
(transformedOut as Record<string, unknown>)[key as string] =
working;
}
if (!matchesType(working, field.type)) {
errors.push({
path: ctx.path,
message: `"${ctx.path}" must be ${field.type}`,
code: "INVALID_TYPE",
value: working,
expected: field.type,
});
return;
}
if (typeof working === "string") {
if (
field.minLength !== undefined &&
working.length < field.minLength
) {
errors.push({
path: ctx.path,
message: `"${ctx.path}" too short (min ${field.minLength})`,
code: "STRING.TOO_SHORT",
value: working,
});
}
if (
field.maxLength !== undefined &&
working.length > field.maxLength
) {
errors.push({
path: ctx.path,
message: `"${ctx.path}" too long (max ${field.maxLength})`,
code: "STRING.TOO_LONG",
value: working,
});
}
if (
field.format !== undefined &&
!FORMAT_PATTERNS[field.format].test(working)
) {
errors.push({
path: ctx.path,
message: `"${ctx.path}" does not match format ${field.format}`,
code: "STRING.INVALID_FORMAT",
value: working,
});
}
if (
field.pattern !== undefined &&
!new RegExp(field.pattern).test(working)
) {
errors.push({
path: ctx.path,
message: `"${ctx.path}" does not match pattern`,
code: "STRING.INVALID_FORMAT",
value: working,
});
}
}
if (typeof working === "number") {
if (field.min !== undefined && working < field.min) {
errors.push({
path: ctx.path,
message: `"${ctx.path}" must be >= ${field.min}`,
code: "NUMBER.TOO_SMALL",
value: working,
});
}
if (field.max !== undefined && working > field.max) {
errors.push({
path: ctx.path,
message: `"${ctx.path}" must be <= ${field.max}`,
code: "NUMBER.TOO_LARGE",
value: working,
});
}
if (field.type === "integer" && !Number.isInteger(working)) {
errors.push({
path: ctx.path,
message: `"${ctx.path}" must be an integer`,
code: "NUMBER.NOT_INT",
value: working,
});
}
}
if (field.enum !== undefined && !field.enum.includes(working)) {
errors.push({
path: ctx.path,
message: `"${ctx.path}" not in enum`,
code: "ENUM.INVALID_VALUE",
value: working,
expected: field.enum,
});
}
if (
field.type === "array" &&
Array.isArray(working) &&
field.items !== undefined
) {
const transformedArray: unknown[] = [];
for (let i = 0; i < working.length; i++) {
await this.validateField(
working[i],
field.items,
{ ...ctx, path: `${ctx.path}[${i}]` },
errors,
transformedArray,
i
);
}
if (transformedOut !== undefined) {
if (Array.isArray(transformedOut))
(transformedOut as unknown[])[key as number] =
transformedArray;
else
(transformedOut as Record<string, unknown>)[key as string] =
transformedArray;
}
}
if (
field.type === "object" &&
field.properties !== undefined &&
typeof working === "object"
) {
const transformedNested: Record<string, unknown> = {};
for (const [nestedKey, nestedField] of Object.entries(
field.properties
)) {
await this.validateField(
(working as Record<string, unknown>)[nestedKey],
nestedField,
{ ...ctx, path: `${ctx.path}.${nestedKey}` },
errors,
transformedNested,
nestedKey
);
}
if (transformedOut !== undefined) {
if (Array.isArray(transformedOut))
(transformedOut as unknown[])[key as number] =
transformedNested;
else
(transformedOut as Record<string, unknown>)[key as string] =
transformedNested;
}
}
const customValidator = this.customValidators[ctx.path];
if (customValidator !== undefined) {
const customResult = await customValidator(working, ctx);
if (customResult !== true) {
errors.push({
path: ctx.path,
message:
typeof customResult === "string"
? customResult
: `"${ctx.path}" failed custom validation`,
code: "CUSTOM.REFINEMENT",
value: working,
});
}
}
}
private applyTransforms(
value: unknown,
transforms?: readonly (
| "trim"
| "lowercase"
| "uppercase"
| "parseNumber"
| "parseDate"
)[]
): unknown {
if (transforms === undefined) return value;
let v = value;
for (const t of transforms) {
if (typeof v === "string") {
if (t === "trim") v = v.trim();
else if (t === "lowercase") v = v.toLowerCase();
else if (t === "uppercase") v = v.toUpperCase();
else if (t === "parseNumber") {
const n = Number(v);
if (!Number.isNaN(n)) v = n;
} else if (t === "parseDate") {
const d = new Date(v);
if (!Number.isNaN(d.getTime())) v = d;
}
}
}
return v;
}
}
function matchesType(value: unknown, type: FieldSchema["type"]): boolean {
switch (type) {
case "any":
return true;
case "string":
return typeof value === "string";
case "number":
return typeof value === "number" && !Number.isNaN(value);
case "integer":
return typeof value === "number" && !Number.isNaN(value);
case "boolean":
return typeof value === "boolean";
case "array":
return Array.isArray(value);
case "object":
return (
typeof value === "object" &&
value !== null &&
!Array.isArray(value)
);
case "date":
return value instanceof Date && !Number.isNaN(value.getTime());
case "datetime":
return value instanceof Date && !Number.isNaN(value.getTime());
default:
return false;
}
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"],
"exclude": ["dist", "node_modules"]
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,38 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/rules",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/error",
"@suaveplan/events",
"@suaveplan/types",
"tslib",
"zod",
"@opentelemetry/api",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,39 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: [
"src/__benchmarks__/**",
"dist/**",
"node_modules/**",
],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.bench.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/__benchmarks__/**",
"tests/**",
],
// Initial migration ships at the actual delivered coverage. The 100/100/100/100
// gate is the v1.0 graduation criterion per CLAUDE.md; this package is at v0.0.0
// (pre-graduation). Closing the remaining ~3% gap is tracked in
// `openspec/changes/enterprise-rules-migration/tasks.md §6.4`.
thresholds: {
statements: 95,
branches: 90,
functions: 95,
lines: 95,
},
},
testTimeout: 5000,
},
});