Initial Commit
This commit is contained in:
111
packages/foundation/flow/rules/README.md
Normal file
111
packages/foundation/flow/rules/README.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# @suaveplan/rules - Rule engine and validation system — async evaluator, fluent builder, 30 operators, schema/field/custom validators, serializer, optimizer; Zod boundaries, OTel surface, AbortSignal throughout
|
||||
|
||||
## Summary
|
||||
|
||||
Universal rule engine + validation system. Comprehensive `RuleEngine` with async evaluation, depth limit, AbortSignal, plugin registration; fluent `RuleBuilder` + factory functions; 33-operator `OperatorRegistry`; three validator families (`SchemaValidator`, `FieldValidator`, `CustomValidator`); JSON serializer with function-placeholder strategy; AST optimiser (flatten / dedupe / simplify / sort); action subsystem with timeout + abort; typed event bus with priority hooks + bounded history. Zod boundaries at every public surface; OTel spans + histograms + counters from production paths; `RulesError` extends `SuaveplanError` with a 24-code registry.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [actions](./src/actions/actions.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [actions](./src/actions/actions.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [builder](./src/builder/builder.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [builder](./src/builder/builder.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [context](./src/context/context.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [context](./src/context/context.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [engine](./src/engine/engine.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - operators-internal — `compareValues(actual, operato…
|
||||
- [engine](./src/engine/engine.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - operators-internal — `compareValues(actual, operato…
|
||||
- [operators-internal](./src/engine/operators-internal.md) — `compareValues(actual, operator, expected)` is the built-in comparison dispatcher used by `RuleEngine` until the dedica…
|
||||
- [errors](./src/errors/rules-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [rules-error](./src/errors/rules-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [events](./src/events/event-bus.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [event-bus](./src/events/event-bus.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [operators](./src/operators/operators.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [operators](./src/operators/operators.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [optimizer](./src/optimizer/optimizer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [optimizer](./src/optimizer/optimizer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [serializer](./src/serializer/serializer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [serializer](./src/serializer/serializer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [custom](./src/validators/custom/custom.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [custom](./src/validators/custom/custom.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [field](./src/validators/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [field](./src/validators/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [schema](./src/validators/schema/schema.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [schema](./src/validators/schema/schema.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [constants](./src/constants.md) — This module exposes the package-level constants consumed across the rules engine, validators, action executor, and even…
|
||||
<!-- end:auto-docs --> - [actions](./src/actions/actions.md) — <!-- begin:auto-docs --> _(no submodules)_- [builder](./src/builder/builder.md) — <!-- begin:auto-docs --> _(no submodules)_- [builder](./src/builder/builder.md) — <!-- begin:auto-docs --> _(no submodules)_- [context](./src/context/context.md) — <!-- begin:auto-docs --> _(no submodules)_- [context](./src/context/context.md) — <!-- begin:auto-docs --> _(no submodules)_- [engine](./src/engine/engine.md) — <!-- begin:auto-docs --> _(no submodules)_- operators-internal — `compareValues(actual, operato…
|
||||
- [engine](./src/engine/engine.md) — <!-- begin:auto-docs --> _(no submodules)_- operators-internal — `compareValues(actual, operato…
|
||||
- [operators-internal](./src/engine/operators-internal.md) — `compareValues(actual, operator, expected)` is the built-in comparison dispatcher used by `RuleEngine` until the dedica…
|
||||
- [errors](./src/errors/rules-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [rules-error](./src/errors/rules-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [events](./src/events/event-bus.md) — <!-- begin:auto-docs --> _(no submodules)_- [event-bus](./src/events/event-bus.md) — <!-- begin:auto-docs --> _(no submodules)_- [operators](./src/operators/operators.md) — <!-- begin:auto-docs --> _(no submodules)_- [operators](./src/operators/operators.md) — <!-- begin:auto-docs --> _(no submodules)_- [optimizer](./src/optimizer/optimizer.md) — <!-- begin:auto-docs --> _(no submodules)_- [optimizer](./src/optimizer/optimizer.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [serializer](./src/serializer/serializer.md) — <!-- begin:auto-docs --> _(no submodules)_- [serializer](./src/serializer/serializer.md) — <!-- begin:auto-docs --> _(no submodules)_- [custom](./src/validators/custom/custom.md) — <!-- begin:auto-docs --> _(no submodules)_- [custom](./src/validators/custom/custom.md) — <!-- begin:auto-docs --> _(no submodules)_- [field](./src/validators/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_- [field](./src/validators/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_- [schema](./src/validators/schema/schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [schema](./src/validators/schema/schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [constants](./src/constants.md) — This module exposes the package-level constants consumed across the rules engine, validators, action executor, and even…
|
||||
- [actions](./src/actions/actions.md) — <!-- begin:auto-docs --> _(no submodules)_- [builder](./src/builder/builder.md) — <!-- begin:auto-docs --> _(no submodules)_- [builder](./src/builder/builder.md) — <!-- begin:auto-docs --> _(no submodules)_- [context](./src/context/context.md) — <!-- begin:auto-docs --> _(no submodules)_- [context](./src/context/context.md) — <!-- begin:auto-docs --> _(no submodules)_- [engine](./src/engine/engine.md) — <!-- begin:auto-docs --> _(no submodules)_- operators-internal — `compareValues(actual, operato…
|
||||
- [engine](./src/engine/engine.md) — <!-- begin:auto-docs --> _(no submodules)_- operators-internal — `compareValues(actual, operato…
|
||||
- [operators-internal](./src/engine/operators-internal.md) — `compareValues(actual, operator, expected)` is the built-in comparison dispatcher used by `RuleEngine` until the dedica…
|
||||
- [errors](./src/errors/rules-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [rules-error](./src/errors/rules-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [events](./src/events/event-bus.md) — <!-- begin:auto-docs --> _(no submodules)_- [event-bus](./src/events/event-bus.md) — <!-- begin:auto-docs --> _(no submodules)_- [operators](./src/operators/operators.md) — <!-- begin:auto-docs --> _(no submodules)_- [operators](./src/operators/operators.md) — <!-- begin:auto-docs --> _(no submodules)_- [optimizer](./src/optimizer/optimizer.md) — <!-- begin:auto-docs --> _(no submodules)_- [optimizer](./src/optimizer/optimizer.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [serializer](./src/serializer/serializer.md) — <!-- begin:auto-docs --> _(no submodules)_- [serializer](./src/serializer/serializer.md) — <!-- begin:auto-docs --> _(no submodules)_- [custom](./src/validators/custom/custom.md) — <!-- begin:auto-docs --> _(no submodules)_- [custom](./src/validators/custom/custom.md) — <!-- begin:auto-docs --> _(no submodules)_- [field](./src/validators/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_- [field](./src/validators/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_- [schema](./src/validators/schema/schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [schema](./src/validators/schema/schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [constants](./src/constants.md) — This module exposes the package-level constants consumed across the rules engine, validators, action executor, and even…
|
||||
- [actions](./src/actions/actions.md) — <!-- begin:auto-docs --> _(no submodules)_- [builder](./src/builder/builder.md) — <!-- begin:auto-docs --> _(no submodules)_- [builder](./src/builder/builder.md) — <!-- begin:auto-docs --> _(no submodules)_- [context](./src/context/context.md) — <!-- begin:auto-docs --> _(no submodules)_- [context](./src/context/context.md) — <!-- begin:auto-docs --> _(no submodules)_- [engine](./src/engine/engine.md) — <!-- begin:auto-docs --> _(no submodules)_- operators-internal — `compareValues(actual, operato…
|
||||
- [engine](./src/engine/engine.md) — <!-- begin:auto-docs --> _(no submodules)_- operators-internal — `compareValues(actual, operato…
|
||||
- [operators-internal](./src/engine/operators-internal.md) — `compareValues(actual, operator, expected)` is the built-in comparison dispatcher used by `RuleEngine` until the dedica…
|
||||
- [errors](./src/errors/rules-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [rules-error](./src/errors/rules-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [events](./src/events/event-bus.md) — <!-- begin:auto-docs --> _(no submodules)_- [event-bus](./src/events/event-bus.md) — <!-- begin:auto-docs --> _(no submodules)_- [operators](./src/operators/operators.md) — <!-- begin:auto-docs --> _(no submodules)_- [operators](./src/operators/operators.md) — <!-- begin:auto-docs --> _(no submodules)_- [optimizer](./src/optimizer/optimizer.md) — <!-- begin:auto-docs --> _(no submodules)_- [optimizer](./src/optimizer/optimizer.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [serializer](./src/serializer/serializer.md) — <!-- begin:auto-docs --> _(no submodules)_- [serializer](./src/serializer/serializer.md) — <!-- begin:auto-docs --> _(no submodules)_- [custom](./src/validators/custom/custom.md) — <!-- begin:auto-docs --> _(no submodules)_- [custom](./src/validators/custom/custom.md) — <!-- begin:auto-docs --> _(no submodules)_- [field](./src/validators/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_- [field](./src/validators/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_- [schema](./src/validators/schema/schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [schema](./src/validators/schema/schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [constants](./src/constants.md) — This module exposes the package-level constants consumed across the rules engine, validators, action executor, and even…
|
||||
- [actions](./src/actions/actions.md) — <!-- begin:auto-docs --> _(no submodules)_- [builder](./src/builder/builder.md) — <!-- begin:auto-docs --> _(no submodules)_ - [builder](./src/builder/builder.md) — <!-- begin:auto-docs --> _(no submodules)_- [context](./src/context/context.md) — <!-- begin:auto-docs --> _(no submodules)_ - [context](./src/context/context.md) — <!-- begin:auto-docs --> _(no submodules)_- [engine](./src/engine/engine.md) — <!-- begin:auto-docs --> _(no submodules)_ - [engine](./src/engine/engine.md) — <!-- begin:auto-docs --> _(no submodules)_ - [operators-internal](./src/engine/operators-internal.md) — `compareValues(actual, operator, expected)` is the built-in comparison dispatcher used by `RuleEngine` until the dedica…
|
||||
- [errors](./src/errors/rules-error.md) — <!-- begin:auto-docs --> _(no submodules)_ - [rules-error](./src/errors/rules-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [events](./src/events/event-bus.md) — <!-- begin:auto-docs --> _(no submodules)_ - [event-bus](./src/events/event-bus.md) — <!-- begin:auto-docs --> _(no submodules)_- [operators](./src/operators/operators.md) — <!-- begin:auto-docs --> _(no submodules)_ - [operators](./src/operators/operators.md) — <!-- begin:auto-docs --> _(no submodules)_- [optimizer](./src/optimizer/optimizer.md) — <!-- begin:auto-docs --> _(no submodules)_ - [optimizer](./src/optimizer/optimizer.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_ - [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [serializer](./src/serializer/serializer.md) — <!-- begin:auto-docs --> _(no submodules)_ - [serializer](./src/serializer/serializer.md) — <!-- begin:auto-docs --> _(no submodules)_- [custom](./src/validators/custom/custom.md) — <!-- begin:auto-docs --> _(no submodules)_ - [custom](./src/validators/custom/custom.md) — <!-- begin:auto-docs --> _(no submodules)_- [field](./src/validators/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_ - [field](./src/validators/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_- [schema](./src/validators/schema/schema.md) — <!-- begin:auto-docs --> _(no submodules)_ - [schema](./src/validators/schema/schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [constants](./src/constants.md) — This module exposes the package-level constants consumed across the rules engine, validators, action executor, and even…
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/rules
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { context } from "@suaveplan/rules/context";
|
||||
import { rule } from "@suaveplan/rules/builder";
|
||||
import { RuleEngine } from "@suaveplan/rules/engine";
|
||||
|
||||
const policy = rule()
|
||||
.field("age").greaterThanOrEqual(18)
|
||||
.and()
|
||||
.field("region").in(["US", "CA"])
|
||||
.build();
|
||||
|
||||
const engine = new RuleEngine();
|
||||
const result = await engine.evaluate(policy, context({ age: 21, region: "US" }));
|
||||
// result.passed === true
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
- [`context`](src/context/context.md) — `RuleContext` builder, dot-path / array-index field resolver, request-context derivation
|
||||
- [`builder`](src/builder/builder.md) — `RuleBuilder` + `FieldBuilder` fluent DSL + factory functions
|
||||
- [`engine`](src/engine/engine.md) — `RuleEngine` with evaluate / evaluateAll / evaluateRuleset / check + plugin registration
|
||||
- [`operators`](src/operators/operators.md) — `OperatorRegistry` + 33 built-in operator definitions
|
||||
- [`actions`](src/actions/actions.md) — `Action` / `ActionRegistry` / `ActionExecutor` + 4 built-in handlers
|
||||
- [`events`](src/events/event-bus.md) — `EventBus` + `HookManager` with priority hooks and `waitFor`
|
||||
- [`validators/schema`](src/validators/schema/schema.md) — recursive JSON-schema-like validator
|
||||
- [`validators/field`](src/validators/field/field.md) — chainable field validator with 12 presets
|
||||
- [`validators/custom`](src/validators/custom/custom.md) — named validators + registry + 11 built-ins + 5 parameterised factories
|
||||
- [`serializer`](src/serializer/serializer.md) — round-trip JSON with function-placeholder strategy
|
||||
- [`optimizer`](src/optimizer/optimizer.md) — AST optimiser (flatten / dedupe / simplify / sort) + analytics
|
||||
- [`errors`](src/errors/rules-error.md) — `RulesError` + 24-code registry
|
||||
- [`otel`](src/otel/otel.md) — lazy OTel instrument surface
|
||||
- Constants — [`constants.md`](src/constants.md)
|
||||
|
||||
## Internal references
|
||||
|
||||
- [Engine internal compare dispatcher](src/engine/operators-internal.md) — bridges into the operator surface
|
||||
- [Type guards](src/types/) — `isConditionRule`, `isLogicalRule`, `isGroupRule`, `isCustomRule`, `isComputedRule`
|
||||
|
||||
## Conventions
|
||||
|
||||
- Every public method validates input via Zod and raises `RulesError` with a typed code on rejection.
|
||||
- `AbortSignal` is threaded through every async path; pre-aborted signals short-circuit before work begins.
|
||||
- `Clock` / `Rng` / `LoggerLike` are injectable per CLAUDE.md "no module-scope `Date.now`/`Math.random`" rule.
|
||||
- 100% test coverage; sibling `.md` per implementation file ≥ 200 words.
|
||||
- Tests import runner primitives from `@suaveplan/testing/runner` exclusively (no direct `vitest` / `bun:test`).
|
||||
- No `export *` in feature barrels — every public symbol is named explicitly.
|
||||
|
||||
## See also
|
||||
|
||||
- [`@suaveplan/rules-hono`](../rules-hono/README.md) — Hono middleware wrapping this package.
|
||||
|
||||
93
packages/foundation/flow/rules/package.json
Normal file
93
packages/foundation/flow/rules/package.json
Normal 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"]
|
||||
}
|
||||
133
packages/foundation/flow/rules/src/actions/actions.md
Normal file
133
packages/foundation/flow/rules/src/actions/actions.md
Normal file
@@ -0,0 +1,133 @@
|
||||
# Actions module — `@suaveplan/rules/actions`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
The actions module is the post-evaluation effect surface — named, timeout-bounded, abort-aware handlers that fire after a rule passes (or fails). Callers register handler functions on an `ActionRegistry`, wrap them in `Action` instances with `ActionConfig` (handler name, params, timeout, continue-on-error), and execute them sequentially or in parallel via `ActionExecutor`. The 4 built-in handlers (`log`, `set`, `delay`, `noop`) cover the common cases; the `log` handler takes an injectable `LoggerLike` rather than calling `console.log` directly (CLAUDE.md §2 bans `console.log` in `src/`).
|
||||
|
||||
Every async path threads `AbortSignal` so callers can cancel long-running effects; every failure mode maps to a typed `RulesError` code (`RULES.ACTION_TIMEOUT`, `RULES.ACTION_ABORTED`, `RULES.ACTION_HANDLER_FAILED`, `RULES.ACTION_NOT_FOUND`). The module emits OTel histogram (`rules.action.execute.duration_ms`) and counter (`rules.action.executions_total{status}`) for every action invocation.
|
||||
|
||||
## Features
|
||||
|
||||
- `ActionRegistry` — `register`, `get`, `has`, `names`, `unregister`. Empty names rejected with `RULES.ACTION_HANDLER_FAILED`.
|
||||
- `Action` — wraps an `ActionConfig` + `ActionRegistry`; constructor Zod-validates the config; `execute(context, options?)` returns an `ActionResult` rather than throwing.
|
||||
- `ActionExecutor` — `executeSequential` (respects `continueOnError`, reports later actions as `skipped` on hard stop); `executeParallel` (bounded concurrency via `parallelism`, defaults from constants module).
|
||||
- `builtInHandlers({ logger? })` — factory returning the `{ log, set, delay, noop }` handler bundle bound to a caller-supplied `LoggerLike`.
|
||||
- `validateActionConfig`, `createAction`, `createActionRegistry`, `createActionExecutor` — function factories matching the convention used by every other package.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { context } from "@suaveplan/rules/context";
|
||||
import {
|
||||
builtInHandlers,
|
||||
createAction,
|
||||
createActionExecutor,
|
||||
createActionRegistry,
|
||||
} from "@suaveplan/rules/actions";
|
||||
|
||||
const registry = createActionRegistry();
|
||||
for (const [name, handler] of Object.entries(builtInHandlers())) {
|
||||
registry.register(name, handler);
|
||||
}
|
||||
|
||||
const executor = createActionExecutor();
|
||||
const result = await executor.executeSequential(
|
||||
[createAction({ handler: "log", params: { message: "ran" } }, registry)],
|
||||
context({}),
|
||||
);
|
||||
result[0]?.status; // "success"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { context } from "@suaveplan/rules/context";
|
||||
import {
|
||||
Action,
|
||||
ActionExecutor,
|
||||
ActionRegistry,
|
||||
builtInHandlers,
|
||||
} from "@suaveplan/rules/actions";
|
||||
import type { LoggerLike } from "@suaveplan/types";
|
||||
|
||||
const logger: LoggerLike = {
|
||||
debug: (m, ctx) => process.stderr.write(`debug ${m} ${JSON.stringify(ctx)}\n`),
|
||||
info: (m, ctx) => process.stderr.write(`info ${m} ${JSON.stringify(ctx)}\n`),
|
||||
warn: (m, ctx) => process.stderr.write(`warn ${m} ${JSON.stringify(ctx)}\n`),
|
||||
error: (m, ctx) => process.stderr.write(`error ${m} ${JSON.stringify(ctx)}\n`),
|
||||
};
|
||||
|
||||
const registry = new ActionRegistry();
|
||||
for (const [name, handler] of Object.entries(builtInHandlers({ logger }))) {
|
||||
registry.register(name, handler);
|
||||
}
|
||||
registry.register("fetch", async (params, _ctx, { signal }) => {
|
||||
const response = await fetch(params.url as string, { signal });
|
||||
return response.json();
|
||||
});
|
||||
|
||||
const executor = new ActionExecutor({ parallelism: 4 });
|
||||
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => controller.abort(), 500); // hard deadline
|
||||
|
||||
const results = await executor.executeParallel(
|
||||
[
|
||||
new Action({ handler: "fetch", params: { url: "/api/a" }, timeoutMs: 200 }, registry),
|
||||
new Action({ handler: "fetch", params: { url: "/api/b" }, timeoutMs: 200 }, registry),
|
||||
new Action({ handler: "log", params: { message: "done" } }, registry),
|
||||
],
|
||||
context({}),
|
||||
{ signal: controller.signal, parallelism: 2 },
|
||||
);
|
||||
|
||||
for (const r of results) {
|
||||
if (r.status === "failed") console.warn(r.handler, r.errorCode, r.errorMessage);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class ActionRegistry`
|
||||
|
||||
Standard register/get/has/names/unregister surface. Empty handler name raises `RULES.ACTION_HANDLER_FAILED`.
|
||||
|
||||
### `class Action`
|
||||
|
||||
- `new Action(config, registry)` — Zod-validates `config`; raises `RULES.ACTION_HANDLER_FAILED` on rejection.
|
||||
- `execute(context, options?)` → `Promise<ActionResult>`. Never throws — every failure becomes an `ActionResult { status: "failed", errorCode, errorMessage }`.
|
||||
- Pre-aborted signal → `errorCode: "RULES.ACTION_ABORTED"`.
|
||||
- Timeout (config.timeoutMs or `DEFAULT_ACTION_TIMEOUT_MS`) → `errorCode: "RULES.ACTION_TIMEOUT"`.
|
||||
- Caller aborts mid-flight → `errorCode: "RULES.ACTION_ABORTED"`.
|
||||
- Handler throws → `errorCode: "RULES.ACTION_HANDLER_FAILED"`, original message preserved.
|
||||
|
||||
### `class ActionExecutor`
|
||||
|
||||
- `new ActionExecutor(defaults?)` — `defaults.clock` is used for duration measurement; `defaults.parallelism` (default `DEFAULT_ACTION_PARALLELISM = 8`) is the executeParallel ceiling when not overridden.
|
||||
- `executeSequential(actions, context, options?)` → `Promise<readonly ActionResult[]>`. Stops on first failure unless `action.config.continueOnError === true`; remaining actions return `status: "skipped"` with `durationMs: 0`.
|
||||
- `executeParallel(actions, context, options?)` → `Promise<readonly ActionResult[]>`. Bounded by `options.parallelism ?? defaults.parallelism ?? DEFAULT_ACTION_PARALLELISM`. `parallelism <= 0` raises `RULES.ACTION_HANDLER_FAILED`.
|
||||
|
||||
### `function builtInHandlers(deps?)`
|
||||
|
||||
Returns the `{ log, set, delay, noop }` handler bundle.
|
||||
|
||||
- `log(params, context, { signal })` — `params.level` (one of `debug` / `info` / `warn` / `error`, default `info`), `params.message` (default `"log"`). Calls `deps.logger[level](message)`; no-op fallback when `deps.logger` is omitted.
|
||||
- `set(params, context, { signal })` — writes `params.value` onto `context.data[params.target]`. Missing or empty `params.target` raises `RULES.ACTION_HANDLER_FAILED`.
|
||||
- `delay(params, context, { signal })` — `params.ms` milliseconds (default 0). Aborts cleanly with `RULES.ACTION_ABORTED` when the signal fires.
|
||||
- `noop` — always returns `undefined`.
|
||||
|
||||
### Factory aliases
|
||||
|
||||
`createActionRegistry()`, `createAction(config, registry)`, `createActionExecutor(defaults?)`, `validateActionConfig(config)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `Action.execute` uses a local `AbortController` linked to the caller signal AND a `setTimeout`-driven timeout abort. The race is implemented as `Promise.race([handlerPromise, abortPromise])`; the abort promise discriminates timeout vs caller abort by inspecting the caller's signal state at fire time.
|
||||
- The signal listener is registered with `{ once: true }` and the `finally` clears the timeout and removes the listener, so a long-lived caller signal does not accumulate listeners across many actions.
|
||||
- `executeSequential` reports skipped actions deterministically: every action after a hard-stop failure returns `{ status: "skipped", handler, durationMs: 0 }` in the result array, preserving 1:1 correspondence with the input.
|
||||
- `executeParallel` uses a shared cursor with `Math.min(limit, actions.length)` worker promises; each worker pulls the next index until the cursor exhausts. This is the simplest bounded-concurrency pattern that works under `AbortSignal` without an external semaphore library.
|
||||
- OTel emission is best-effort: a global no-op meter is used when no SDK is installed, so handlers in `vitest run` without a `MeterProvider` still execute correctly.
|
||||
- The `delay` handler is intentionally simple — it does not return a `Promise.race` with a `signal.reason` because callers can read `signal.reason` themselves; surfacing it inside the handler would couple `RulesError` shape to `DOMException` shape.
|
||||
362
packages/foundation/flow/rules/src/actions/actions.test.ts
Normal file
362
packages/foundation/flow/rules/src/actions/actions.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
365
packages/foundation/flow/rules/src/actions/actions.ts
Normal file
365
packages/foundation/flow/rules/src/actions/actions.ts
Normal 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 });
|
||||
}
|
||||
10
packages/foundation/flow/rules/src/actions/index.ts
Normal file
10
packages/foundation/flow/rules/src/actions/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export {
|
||||
Action,
|
||||
ActionExecutor,
|
||||
ActionRegistry,
|
||||
builtInHandlers,
|
||||
createAction,
|
||||
createActionExecutor,
|
||||
createActionRegistry,
|
||||
validateActionConfig,
|
||||
} from "./actions.js";
|
||||
108
packages/foundation/flow/rules/src/builder/builder.md
Normal file
108
packages/foundation/flow/rules/src/builder/builder.md
Normal 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.
|
||||
363
packages/foundation/flow/rules/src/builder/builder.test.ts
Normal file
363
packages/foundation/flow/rules/src/builder/builder.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
406
packages/foundation/flow/rules/src/builder/builder.ts
Normal file
406
packages/foundation/flow/rules/src/builder/builder.ts
Normal 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" };
|
||||
}
|
||||
10
packages/foundation/flow/rules/src/builder/index.ts
Normal file
10
packages/foundation/flow/rules/src/builder/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export {
|
||||
and,
|
||||
condition,
|
||||
FieldBuilder,
|
||||
group,
|
||||
not,
|
||||
or,
|
||||
RuleBuilder,
|
||||
rule,
|
||||
} from "./builder.js";
|
||||
51
packages/foundation/flow/rules/src/constants.md
Normal file
51
packages/foundation/flow/rules/src/constants.md
Normal 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.
|
||||
17
packages/foundation/flow/rules/src/constants.ts
Normal file
17
packages/foundation/flow/rules/src/constants.ts
Normal 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;
|
||||
107
packages/foundation/flow/rules/src/context/context.md
Normal file
107
packages/foundation/flow/rules/src/context/context.md
Normal 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.
|
||||
362
packages/foundation/flow/rules/src/context/context.test.ts
Normal file
362
packages/foundation/flow/rules/src/context/context.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
387
packages/foundation/flow/rules/src/context/context.ts
Normal file
387
packages/foundation/flow/rules/src/context/context.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
17
packages/foundation/flow/rules/src/context/index.ts
Normal file
17
packages/foundation/flow/rules/src/context/index.ts
Normal 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";
|
||||
141
packages/foundation/flow/rules/src/engine/engine.md
Normal file
141
packages/foundation/flow/rules/src/engine/engine.md
Normal 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.
|
||||
510
packages/foundation/flow/rules/src/engine/engine.test.ts
Normal file
510
packages/foundation/flow/rules/src/engine/engine.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
611
packages/foundation/flow/rules/src/engine/engine.ts
Normal file
611
packages/foundation/flow/rules/src/engine/engine.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
16
packages/foundation/flow/rules/src/engine/engine.types.ts
Normal file
16
packages/foundation/flow/rules/src/engine/engine.types.ts
Normal 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;
|
||||
}
|
||||
6
packages/foundation/flow/rules/src/engine/index.ts
Normal file
6
packages/foundation/flow/rules/src/engine/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { RuleEngine } from "./engine.js";
|
||||
export type {
|
||||
RuleEngineConfig,
|
||||
RuleEnginePlugin,
|
||||
RuleEnginePluginRegistrar,
|
||||
} from "./engine.types.js";
|
||||
@@ -0,0 +1,66 @@
|
||||
# Internal operator dispatcher — `@suaveplan/rules/engine/operators-internal`
|
||||
|
||||
## Purpose
|
||||
|
||||
`compareValues(actual, operator, expected)` is the built-in comparison dispatcher used by `RuleEngine` until the dedicated `operators` module ships. It maps every member of the 33-entry `ComparisonOperator` union to a deterministic boolean over `(actual, expected)`. Unknown operators raise `RulesError(RULES.OPERATOR_NOT_FOUND)`; the engine intercepts that error to increment the `rules.operator.unknown_total{name}` counter and converts the failure into a `passed: false` evaluation result.
|
||||
|
||||
This file is private to the engine (not re-exported from the package root). The public `operators` module — when it lands — will register the same 30+ operators against a real `OperatorRegistry` and the engine will dispatch through that registry instead. Both paths satisfy the same `OperatorDefinition.evaluate` contract, so call sites do not change.
|
||||
|
||||
## Features
|
||||
|
||||
- Single-function module — no class, no state, no module-level side effects.
|
||||
- Exhaustive `switch` over `ComparisonOperator`; `default` is typed `never` so adding a new operator name to the union is a compile error here.
|
||||
- Handles `null` / `undefined` / non-string / non-array inputs gracefully — every operator has a documented behaviour on the wrong shape rather than throwing.
|
||||
- `matches` / `notMatches` accept either a string source or a `RegExp` instance; the source is recompiled to a `RegExp` per call.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { compareValues } from "@suaveplan/rules/engine/operators-internal";
|
||||
|
||||
compareValues(21, "greaterThanOrEqual", 18); // true
|
||||
compareValues([1, 2, 3], "containsAll", [1, 2]); // true
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { compareValues } from "@suaveplan/rules/engine/operators-internal";
|
||||
import { RulesError, isRulesError } from "@suaveplan/rules/errors";
|
||||
|
||||
function safeCompare(a: unknown, op: string, b: unknown): boolean {
|
||||
try {
|
||||
return compareValues(a, op as never, b);
|
||||
} catch (err) {
|
||||
if (isRulesError(err) && err.code === "RULES.OPERATOR_NOT_FOUND") {
|
||||
return false; // unknown operator behaves as "no match"
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `compareValues(actual: unknown, operator: ComparisonOperator, expected: unknown): boolean`
|
||||
|
||||
Returns the boolean outcome of applying `operator` to `(actual, expected)`. Documented per-operator semantics:
|
||||
|
||||
- **equality**: `equals` / `strictEquals` use `===`; `notEquals` / `strictNotEquals` use `!==`. Both pairs are equivalent — the strict variants are kept so consumers using a JSON DSL can be explicit.
|
||||
- **numeric**: `greaterThan` / `greaterThanOrEqual` / `lessThan` / `lessThanOrEqual` cast both sides to `number`. `between` / `notBetween` accept `[min, max]` tuples and are inclusive on both bounds.
|
||||
- **collection**: `in` / `notIn` test membership of `actual` in `expected` (an array). `contains` / `notContains` work on either arrays or strings; non-array, non-string `actual` returns `false` for `contains` and `true` for `notContains`. `containsAll` / `containsAny` / `overlaps` require both sides to be arrays.
|
||||
- **existence**: `exists` is `actual !== undefined`; `notExists` is the inverse. `isNull` / `isNotNull` are strict `=== null` checks. `isEmpty` / `isNotEmpty` cover string / array / object / null / undefined.
|
||||
- **type**: `isType` is `typeof actual === expected`; `isNotType` is the inverse. `hasProperty` is `Object.hasOwn(actual, expected)` over objects (false otherwise).
|
||||
- **length**: `lengthEquals` / `lengthGreaterThan` / `lengthLessThan` operate on string / array / object (object uses `Object.keys` count). Other types yield `NaN` which makes the comparison fall through to `false`.
|
||||
- **string**: `startsWith` / `endsWith` require `typeof actual === "string"`. `matches` accepts a string or `RegExp`; on non-string `actual`, `matches` is `false` and `notMatches` is `true`.
|
||||
|
||||
### Throws
|
||||
|
||||
- `RulesError(RULES.OPERATOR_NOT_FOUND)` when `operator` is outside the documented union.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The dispatcher does NOT memoise the per-call `RegExp` construction in `matches` / `notMatches`. For rules that fire frequently with the same pattern, callers can pre-compile and pass the `RegExp` instance instead of a string — `compareValues` accepts both.
|
||||
- The `default` branch uses `const unknownOperator: never = operator;` to leverage the compiler's exhaustiveness checking; the runtime `throw` is a defensive belt-and-braces against `as never` casts at the boundary.
|
||||
- `countable(value)` is the shared length helper; non-countable inputs return `NaN` so every length comparison naturally yields `false` (NaN comparisons are always false).
|
||||
- The function is intentionally synchronous. Async operators are out of scope for the built-in dispatcher — plugins that register async operators on the engine go through the `OperatorDefinition.evaluate` contract which accepts a `Promise<boolean>` return.
|
||||
@@ -0,0 +1,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/
|
||||
);
|
||||
});
|
||||
});
|
||||
154
packages/foundation/flow/rules/src/engine/operators-internal.ts
Normal file
154
packages/foundation/flow/rules/src/engine/operators-internal.ts
Normal 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;
|
||||
}
|
||||
7
packages/foundation/flow/rules/src/errors/index.ts
Normal file
7
packages/foundation/flow/rules/src/errors/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
isRulesError,
|
||||
RULES_CODES,
|
||||
RulesError,
|
||||
type RulesErrorCode,
|
||||
type RulesErrorOptions,
|
||||
} from "./rules-error.js";
|
||||
104
packages/foundation/flow/rules/src/errors/rules-error.md
Normal file
104
packages/foundation/flow/rules/src/errors/rules-error.md
Normal 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.
|
||||
106
packages/foundation/flow/rules/src/errors/rules-error.test.ts
Normal file
106
packages/foundation/flow/rules/src/errors/rules-error.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
101
packages/foundation/flow/rules/src/errors/rules-error.ts
Normal file
101
packages/foundation/flow/rules/src/errors/rules-error.ts
Normal 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;
|
||||
}
|
||||
100
packages/foundation/flow/rules/src/events/event-bus.md
Normal file
100
packages/foundation/flow/rules/src/events/event-bus.md
Normal 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.
|
||||
237
packages/foundation/flow/rules/src/events/event-bus.test.ts
Normal file
237
packages/foundation/flow/rules/src/events/event-bus.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
268
packages/foundation/flow/rules/src/events/event-bus.ts
Normal file
268
packages/foundation/flow/rules/src/events/event-bus.ts
Normal 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();
|
||||
}
|
||||
7
packages/foundation/flow/rules/src/events/index.ts
Normal file
7
packages/foundation/flow/rules/src/events/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
createEventBus,
|
||||
createHookManager,
|
||||
EventBus,
|
||||
type EventBusOptions,
|
||||
HookManager,
|
||||
} from "./event-bus.js";
|
||||
249
packages/foundation/flow/rules/src/index.ts
Normal file
249
packages/foundation/flow/rules/src/index.ts
Normal 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";
|
||||
125
packages/foundation/flow/rules/src/operators/defaults.test.ts
Normal file
125
packages/foundation/flow/rules/src/operators/defaults.test.ts
Normal 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/
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
241
packages/foundation/flow/rules/src/operators/defaults.ts
Normal file
241
packages/foundation/flow/rules/src/operators/defaults.ts
Normal 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();
|
||||
43
packages/foundation/flow/rules/src/operators/index.ts
Normal file
43
packages/foundation/flow/rules/src/operators/index.ts
Normal 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";
|
||||
84
packages/foundation/flow/rules/src/operators/operators.md
Normal file
84
packages/foundation/flow/rules/src/operators/operators.md
Normal 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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
63
packages/foundation/flow/rules/src/operators/registry.ts
Normal file
63
packages/foundation/flow/rules/src/operators/registry.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
14
packages/foundation/flow/rules/src/optimizer/index.ts
Normal file
14
packages/foundation/flow/rules/src/optimizer/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export type {
|
||||
OptimizeOptions,
|
||||
OptimizeResult,
|
||||
RuleStats,
|
||||
} from "./optimizer.js";
|
||||
export {
|
||||
analyzeRule,
|
||||
countRules,
|
||||
estimateCost,
|
||||
findFields,
|
||||
hasAsyncComponents,
|
||||
isOptimisationStable,
|
||||
optimizeRule,
|
||||
} from "./optimizer.js";
|
||||
106
packages/foundation/flow/rules/src/optimizer/optimizer.md
Normal file
106
packages/foundation/flow/rules/src/optimizer/optimizer.md
Normal 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.
|
||||
270
packages/foundation/flow/rules/src/optimizer/optimizer.test.ts
Normal file
270
packages/foundation/flow/rules/src/optimizer/optimizer.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
221
packages/foundation/flow/rules/src/optimizer/optimizer.ts
Normal file
221
packages/foundation/flow/rules/src/optimizer/optimizer.ts
Normal 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);
|
||||
}
|
||||
5
packages/foundation/flow/rules/src/otel/index.ts
Normal file
5
packages/foundation/flow/rules/src/otel/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export type { RulesInstruments } from "./otel.js";
|
||||
export {
|
||||
getInstruments,
|
||||
resetInstruments,
|
||||
} from "./otel.js";
|
||||
81
packages/foundation/flow/rules/src/otel/otel.md
Normal file
81
packages/foundation/flow/rules/src/otel/otel.md
Normal 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).
|
||||
110
packages/foundation/flow/rules/src/otel/otel.ts
Normal file
110
packages/foundation/flow/rules/src/otel/otel.ts
Normal 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;
|
||||
}
|
||||
22
packages/foundation/flow/rules/src/serializer/index.ts
Normal file
22
packages/foundation/flow/rules/src/serializer/index.ts
Normal 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";
|
||||
98
packages/foundation/flow/rules/src/serializer/serializer.md
Normal file
98
packages/foundation/flow/rules/src/serializer/serializer.md
Normal 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.
|
||||
258
packages/foundation/flow/rules/src/serializer/serializer.test.ts
Normal file
258
packages/foundation/flow/rules/src/serializer/serializer.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
511
packages/foundation/flow/rules/src/serializer/serializer.ts
Normal file
511
packages/foundation/flow/rules/src/serializer/serializer.ts
Normal 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");
|
||||
}
|
||||
44
packages/foundation/flow/rules/src/types/action.types.ts
Normal file
44
packages/foundation/flow/rules/src/types/action.types.ts
Normal 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;
|
||||
}
|
||||
45
packages/foundation/flow/rules/src/types/context.types.ts
Normal file
45
packages/foundation/flow/rules/src/types/context.types.ts
Normal 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>>;
|
||||
}
|
||||
64
packages/foundation/flow/rules/src/types/event.types.ts
Normal file
64
packages/foundation/flow/rules/src/types/event.types.ts
Normal 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 };
|
||||
};
|
||||
55
packages/foundation/flow/rules/src/types/guards.ts
Normal file
55
packages/foundation/flow/rules/src/types/guards.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
72
packages/foundation/flow/rules/src/types/index.ts
Normal file
72
packages/foundation/flow/rules/src/types/index.ts
Normal 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";
|
||||
71
packages/foundation/flow/rules/src/types/operator.types.ts
Normal file
71
packages/foundation/flow/rules/src/types/operator.types.ts
Normal 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;
|
||||
}
|
||||
76
packages/foundation/flow/rules/src/types/result.types.ts
Normal file
76
packages/foundation/flow/rules/src/types/result.types.ts
Normal 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;
|
||||
}
|
||||
93
packages/foundation/flow/rules/src/types/rule.types.ts
Normal file
93
packages/foundation/flow/rules/src/types/rule.types.ts
Normal 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;
|
||||
}
|
||||
66
packages/foundation/flow/rules/src/types/validator.types.ts
Normal file
66
packages/foundation/flow/rules/src/types/validator.types.ts
Normal 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>;
|
||||
103
packages/foundation/flow/rules/src/validators/custom/custom.md
Normal file
103
packages/foundation/flow/rules/src/validators/custom/custom.md
Normal 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()`.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
344
packages/foundation/flow/rules/src/validators/custom/custom.ts
Normal file
344
packages/foundation/flow/rules/src/validators/custom/custom.ts
Normal 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();
|
||||
@@ -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";
|
||||
88
packages/foundation/flow/rules/src/validators/field/field.md
Normal file
88
packages/foundation/flow/rules/src/validators/field/field.md
Normal 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.
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
});
|
||||
253
packages/foundation/flow/rules/src/validators/field/field.ts
Normal file
253
packages/foundation/flow/rules/src/validators/field/field.ts
Normal 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"
|
||||
);
|
||||
18
packages/foundation/flow/rules/src/validators/field/index.ts
Normal file
18
packages/foundation/flow/rules/src/validators/field/index.ts
Normal 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";
|
||||
@@ -0,0 +1 @@
|
||||
export { SchemaValidator, type SchemaValidatorOptions } from "./schema.js";
|
||||
129
packages/foundation/flow/rules/src/validators/schema/schema.md
Normal file
129
packages/foundation/flow/rules/src/validators/schema/schema.md
Normal 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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
415
packages/foundation/flow/rules/src/validators/schema/schema.ts
Normal file
415
packages/foundation/flow/rules/src/validators/schema/schema.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
12
packages/foundation/flow/rules/tsconfig.json
Normal file
12
packages/foundation/flow/rules/tsconfig.json
Normal 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"]
|
||||
}
|
||||
12
packages/foundation/flow/rules/tsconfig.lib.json
Normal file
12
packages/foundation/flow/rules/tsconfig.lib.json
Normal 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"]
|
||||
}
|
||||
38
packages/foundation/flow/rules/vite.config.ts
Normal file
38
packages/foundation/flow/rules/vite.config.ts
Normal 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:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
39
packages/foundation/flow/rules/vitest.config.ts
Normal file
39
packages/foundation/flow/rules/vitest.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user