npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@inixiative/json-rules

v2.17.1

Published

TypeScript-first JSON rules engine with intuitive syntax and detailed error messages

Readme

@inixiative/json-rules

A TypeScript-first JSON rules library for:

  • runtime validation with custom error messages
  • Prisma query planning
  • PostgreSQL WHERE generation

The same rule AST can be evaluated against in-memory data with check(), converted into a Prisma query plan with toPrisma(), or compiled into SQL with toSql().

Part of the inixiative ecosystem. See the full vocabulary for every primitive — Condition, FieldMap, lens, sources, builders, and the layers built on them — with what each solves and how it works.

Installation

npm install @inixiative/json-rules
# or
yarn add @inixiative/json-rules
# or
bun add @inixiative/json-rules

Quick Start

import { check, Operator } from '@inixiative/json-rules';

const rule = {
  field: 'age',
  operator: Operator.greaterThanEquals,
  value: 18,
  error: 'Must be 18 or older',
};

check(rule, { age: 21 }); // true
check(rule, { age: 16 }); // "Must be 18 or older"

What It Supports

  • scalar comparisons
  • nested logical conditions with all / any
  • if / then / else
  • array validation against nested object elements
  • array aggregates — sum and avg across numeric arrays or relation lists
  • ordered windowing — first/last N with orderBy / take / skip (check-only)
  • date comparisons with timezone-aware runtime evaluation
  • relative & calendar date expressions — "last 30 days", "this month" — via within and ago/ahead/this/last/next
  • relative value references via path
  • custom error messages on every rule
  • compilation to Prisma and PostgreSQL for supported subsets

Operators

Field Operators

  • equals
  • notEquals
  • lessThan
  • lessThanEquals
  • greaterThan
  • greaterThanEquals
  • contains
  • notContains
  • in
  • notIn
  • matches
  • notMatches
  • between
  • notBetween
  • isEmpty
  • notEmpty
  • exists
  • notExists
  • startsWith
  • endsWith

Array Operators

  • all
  • any
  • none
  • atLeast
  • atMost
  • exactly
  • empty
  • notEmpty

Aggregate Operators

Used in aggregate.mode:

  • sum
  • avg

Supported comparison operators for aggregate rules: equals, notEquals, lessThan, lessThanEquals, greaterThan, greaterThanEquals, between, notBetween.

Date Operators

  • before
  • after
  • onOrBefore
  • onOrAfter
  • within
  • between
  • notBetween
  • dayIn
  • dayNotIn

Rule Shapes

Field Rule

{
  field: 'status',
  operator: Operator.equals,
  value: 'active'
}

Logical Rules

{
  all: [
    { field: 'age', operator: Operator.greaterThanEquals, value: 18 },
    { field: 'hasLicense', operator: Operator.equals, value: true }
  ]
}

{
  any: [
    { field: 'role', operator: Operator.equals, value: 'admin' },
    { field: 'isOwner', operator: Operator.equals, value: true }
  ]
}

Conditional Rule

{
  if: { field: 'type', operator: Operator.equals, value: 'premium' },
  then: { field: 'discount', operator: Operator.greaterThan, value: 0 },
  else: { field: 'discount', operator: Operator.equals, value: 0 }
}

Array Rule

{
  field: 'orders',
  arrayOperator: ArrayOperator.all,
  condition: {
    field: 'total',
    operator: Operator.lessThanEquals,
    path: '$.maxBudget'
  }
}

Aggregate Rule

Computes sum or avg of an array and compares the result to a value.

// Primitive numeric array
{
  field: 'scores',
  aggregate: { mode: 'avg' },
  operator: Operator.greaterThanEquals,
  value: 80
}

// Object array — aggregate.field selects the numeric property per element
{
  field: 'orders',
  aggregate: { mode: 'sum', field: 'total' },
  operator: Operator.greaterThan,
  value: 1000
}

// Filtered aggregate — only aggregate elements matching a condition
{
  field: 'orders',
  aggregate: { mode: 'sum', field: 'total' },
  condition: { field: 'status', operator: Operator.equals, value: 'completed' },
  operator: Operator.greaterThan,
  value: 1000
}

// Dot-path field traversal — aggregate through relations
{
  field: 'department.projects',
  aggregate: { mode: 'sum', field: 'budget' },
  condition: { field: 'status', operator: Operator.equals, value: 'active' },
  operator: Operator.greaterThan,
  value: 50000
}

Empty-array semantics: sum([]) = 0, avg([]) = null (comparison fails).

Date Rule

{
  field: 'expiryDate',
  dateOperator: DateOperator.after,
  value: '2026-01-01'
}

Relative & Calendar Date Expressions

A date rule's value can be a structured, serializable expression instead of an absolute date. Magnitudes are always positive — direction lives in the keyword. Units are dayjs words: day, week, isoWeek, month, quarter, year, hour, minute, second.

Point expressions — pair with before / after / onOrBefore / onOrAfter, or as between endpoints:

// "more than 30 days ago"
{ field: 'completedAt', dateOperator: DateOperator.before, value: { ago: { days: 30 } } }

// "within the next 2 months"
{ field: 'dueAt', dateOperator: DateOperator.after, value: { ahead: { months: 2 } } }

// a named edge of a calendar period
{ field: 'completedAt', dateOperator: DateOperator.before, value: { end: { last: 'month' } } }

Range expressions — pair with the within operator:

// "this month"
{ field: 'completedAt', dateOperator: DateOperator.within, value: { this: 'month' } }

// "last week", "next quarter"
{ field: 'completedAt', dateOperator: DateOperator.within, value: { last: 'week' } }

// rolling window: "within the last 30 days"  →  [now - 30d, now]
{ field: 'completedAt', dateOperator: DateOperator.within, value: { ago: { days: 30 } } }

A bare period with before / after resolves to the only sensible edge — before { last: 'month' } is before the start of last month, after { next: 'month' } is after the end of next month. Use { start: … } / { end: … } for the other edge.

The now contract and config

Relative/calendar expressions need a reference instant. now is an explicit evaluator input — there is no implicit Date.now() inside the library. Pass it on the same options bag as everything else; check/toPrisma/toSql throw if a relative expression is used without it.

check(rule, data, { now, timeZone: 'America/New_York', weekStart: 'sunday' });
toPrisma(rule, { map, model, now });
toSql(rule, { now });

| Option | Default | Governs | | --- | --- | --- | | now | — (required when a relative/period expression is present) | the anchor instant | | timeZone | 'UTC' | how now and period boundaries localize | | weekStart | 'monday' (ISO / isoWeek) | start of week for this/last/next |

Compilers resolve expressions to concrete Date bounds at compile time, so check(), toPrisma(), and toSql() all compare the same instant.

Windowing — first/last with orderBy / take / skip

Array and aggregate rules accept an ordered-window selector that runs before the predicate. Pipeline: order → skip → take. Direction comes from orderBy.dir, so "the last fanMission" is order by date desc, take 1.

// "user whose last fanMission was more than 30 days ago"
{
  field: 'fanMissions',
  orderBy: [{ field: 'completedAt', dir: 'desc' }],
  take: 1,
  arrayOperator: ArrayOperator.all,
  condition: { field: 'completedAt', dateOperator: DateOperator.before, value: { ago: { days: 30 } } },
}

orderBy is a non-empty array of { field, dir: 'asc' | 'desc' } (multi-key); take/skip are non-negative integers. Empty-window semantics are author-driven: all is vacuously true on an empty window, atLeast: 1 (or any) is false. To require "the windowed element matches and one exists," combine all with notEmpty / atLeast: 1.

Compilation. toPrisma() compiles the extremal case — take: 1, a single orderBy, and a monotonic condition on that same field, with the direction aligned so the extremal element is binding (all + desc + before, any + desc + after, etc.). It rewrites to every / some (e.g. the rule above → { fanMissions: { every: { completedAt: { lt: <now-30d> } } } }). Any other windowed rule — take > 1, skip, multi-key orderBy, a different/non-monotonic condition, or a misaligned direction — throws a clear "unsupported" error. toSql() does not compile windowing at all (no relation subqueries in a WHERE fragment). Evaluate the unsupported cases in memory with check().

Path Semantics

path lets a rule resolve its comparison value from somewhere other than value.

Root Context Reference

In runtime validation, a plain path is resolved from the root context:

{
  field: 'confirmPassword',
  operator: Operator.equals,
  path: 'password'
}

Current Array Element Reference

Inside array conditions, $. means "read from the current element":

{
  field: 'orders',
  arrayOperator: ArrayOperator.all,
  condition: {
    field: 'total',
    operator: Operator.lessThanEquals,
    path: '$.maxBudget'
  }
}

Runtime Validation

check() evaluates a rule against data and returns:

  • true when the rule passes
  • a string when the rule fails
import { ArrayOperator, check, Operator } from '@inixiative/json-rules';

const rule = {
  all: [
    { field: 'status', operator: Operator.equals, value: 'active' },
    {
      field: 'orders',
      arrayOperator: ArrayOperator.atLeast,
      count: 2,
      condition: { field: 'status', operator: Operator.equals, value: 'completed' },
    },
  ],
};

check(rule, {
  status: 'active',
  orders: [
    { status: 'completed' },
    { status: 'pending' },
    { status: 'completed' },
  ],
}); // true

Custom Errors

Every rule can define its own error:

{
  field: 'email',
  operator: Operator.matches,
  value: /^[^@]+@[^@]+\.[^@]+$/,
  error: 'Please enter a valid email address'
}

Prisma Query Planning

toPrisma() converts a rule into a Prisma query plan.

import { Operator, toPrisma } from '@inixiative/json-rules';

const plan = toPrisma({
  field: 'status',
  operator: Operator.equals,
  value: 'active',
});

// plan.steps => [{ operation: 'where', where: { status: { equals: 'active' } } }]

Aggregate relation filters (sum, avg) and count-based filters (atLeast, atMost, exactly) can produce multi-step plans. Use executePrismaQueryPlan() to resolve groupBy step references before passing the final where into Prisma.

import {
  ArrayOperator,
  Operator,
  executePrismaQueryPlan,
  toPrisma,
} from '@inixiative/json-rules';

const plan = toPrisma(
  {
    field: 'posts',
    arrayOperator: ArrayOperator.atLeast,
    count: 3,
    condition: {
      field: 'published',
      operator: Operator.equals,
      value: true,
    },
  },
  { map, model: 'User' },
);

const where = await executePrismaQueryPlan(plan, { post: prisma.post });
await prisma.user.findMany({ where });

Aggregate rules on relation lists work the same way:

const plan = toPrisma(
  {
    field: 'orders',
    aggregate: { mode: 'sum', field: 'total' },
    operator: Operator.greaterThan,
    value: 1000,
  },
  { map, model: 'User' },
);

const where = await executePrismaQueryPlan(plan, { order: prisma.order });
await prisma.user.findMany({ where }); // users whose orders sum to more than 1000

PostgreSQL SQL Generation

toSql() converts a rule into a parameterized PostgreSQL WHERE clause.

import { Operator, toSql } from '@inixiative/json-rules';

const result = toSql({
  field: 'status',
  operator: Operator.equals,
  value: 'active',
});

// {
//   sql: '"status" = $1',
//   params: ['active'],
//   joins: []
// }

With a field map and model, toSql() can generate LEFT JOINs for relation traversal:

const result = toSql(
  { field: 'author.email', operator: Operator.equals, value: '[email protected]' },
  { map, model: 'Post', alias: 't0' },
);

// result.sql   => '"t1"."email" = $1'
// result.joins => ['LEFT JOIN "User" AS "t1" ON "t1"."id" = "t0"."authorId"']

Backend Support Matrix

Not every backend supports every rule shape.

| Capability | check() | toPrisma() | toSql() | | --- | --- | --- | --- | | Field operators | Yes | Most | Yes | | matches / notMatches | Yes | No | Yes | | Logical operators | Yes | Yes | Yes | | Array all / any / none | Yes | Yes | No | | Array atLeast / atMost / exactly | Yes | Yes, with map + model | No | | Array empty / notEmpty | Yes | Yes | Yes | | Aggregate sum / avg — primitive or object array | Yes | No | Yes | | Aggregate sum / avg — relation list | Yes | Yes, with map + model | No | | Date comparisons | Yes | Most | Yes | | Date expressions (ago/ahead/this/last/next/start/end) + within | Yes | Yes | Yes | | dayIn / dayNotIn | Yes | No | Yes | | Windowing (orderBy / take / skip) | Yes | Extremal (take:1, aligned) | No | | path: '$.field' current-element / same-row refs | Yes | No | Yes |

Prisma Limitations

  • matches and notMatches are not supported by Prisma output
  • dayIn and dayNotIn are not supported by Prisma output
  • path: '$.field' column-to-column comparisons are not supported by Prisma WHERE
  • count-based and aggregate relation operators require { map, model }
  • aggregate rules with notBetween are not supported by Prisma output
  • aggregate rules on JSON/native stored arrays are not supported by Prisma — use toSql() or check() for those

SQL Limitations

  • complex array element operators are not supported in SQL output:
    • all
    • any
    • none
    • atLeast
    • atMost
    • exactly
  • toSql() generates WHERE fragments and LEFT JOINs, not complete queries

TypeScript Types

The public rule types are generic over comparison payloads:

type Condition<TRuleValue = RuleValue, TDateValue = DateRuleValue> =
  | Rule<TRuleValue>
  | AggregateRule
  | ArrayRule<TRuleValue, TDateValue>
  | DateRule<TDateValue>
  | All<TRuleValue, TDateValue>
  | Any<TRuleValue, TDateValue>
  | IfThenElse<TRuleValue, TDateValue>
  | boolean;

Useful exports:

  • check
  • toPrisma
  • executePrismaQueryPlan
  • toSql
  • validateRule
  • assertValidRule
  • Operator
  • ArrayOperator
  • DateOperator
  • Condition
  • StrictCondition
  • Rule
  • AggregateRule
  • AggregateMode
  • ArrayRule
  • DateRule

Lens & bridges:

  • Lens, LensNarrowing, ModelNarrowing, ModelDefaultNarrowing, NarrowingDefaults, EnumNarrowing
  • FieldMapSet, Bridge, BridgeEndpoint, BridgeCardinality
  • createLens, stitchFieldMaps, validateFieldMap, validateFieldMapSet
  • validateNarrowing, projectByPath, exposedSurface, describeRule, checkRuleAgainstLens, applyLens
  • PathProjection, ProjectedVisit, RuleDescription
  • buildBridgeDictionary

Two shapes come out of a lens, and they are different things:

  • Lens (maps intact — the navigable graph): exposedSurface(lensOrNarrowing) returns the leak-safe total exposed surface as a Lens — every reachable model with the full narrowing applied (root + path-specific + mapDefaults), unioned per model, where stripped. Use it as the server→client builder surface; it never exposes the raw, un-narrowed lens.
  • Projection (path-keyed view — graph flattened away): projectByPath(lens) returns Map<dottedPath, ProjectedVisit> for per-path checks where sibling paths to the same model diverge.

Operator catalog (builder-facing):

  • FIELD_OPERATOR_CATALOG, DATE_OPERATOR_CATALOG, ARRAY_OPERATOR_CATALOG
  • FieldKind, RuleTarget, ValueShape
  • NUMERIC_KINDS, ORDERABLE_KINDS, STRINGY_KINDS, EQUATABLE_KINDS, ALL_KINDS
  • getOperatorsForKind, getArrayOperators, getValueShape, isOperatorSupportedForTarget
  • WINDOW_SELECTOR, WindowSupport, getWindowSupport (windowing fields + per-ruleType×target support)

Error Handling

The library throws when a rule is structurally invalid, for example:

  • array operators used against non-arrays
  • missing count for count-based array rules
  • invalid date values
  • unsupported backend translations

It returns string errors only from runtime check().

If rules come from JSON, a database, an API, or an editor, validate them first:

import { assertValidRule, validateRule } from '@inixiative/json-rules';

const result = validateRule(rule, { target: 'check' });
if (!result.ok) {
  console.error(result.errors);
}

assertValidRule(rule, { target: 'toPrisma' });

Root-Array Rules in check()

When data is an array, the rule must be a tree of all / any whose leaves are fieldless ArrayRules (no field, arrayOperator operates on the array itself).

const users = [
  { industry: 'tech', status: 'active' },
  { industry: 'finance', status: 'active' },
  { industry: 'tech', status: 'inactive' },
];

// "Is there any tech user AND are at least 2 active?"
check(
  {
    all: [
      { arrayOperator: ArrayOperator.any, condition: { field: 'industry', operator: Operator.equals, value: 'tech' } },
      { arrayOperator: ArrayOperator.atLeast, count: 2, condition: { field: 'status', operator: Operator.equals, value: 'active' } },
    ],
  },
  users,
);

check() throws if data is an array but the rule contains any field-based leaf, or if the rule is a fieldless ArrayRule and data is not an array. Root-array compilation to Prisma/SQL is not yet implemented — these are check()-only.

Lens & Multi-Source Data

For the full v2.2 lens guide — including the three anchor layers for where (root, model-default, relation-descent), the all operator filter-first trick, per-model enum narrowing, and a validate-then-apply usage pattern — see docs/LENS.md. This section covers the high-level shape and the multi-source bridges.

What a lens is for

A lens is not a query filter. It's a composable, enforceable model of authority over datawhat a party can see and what it can do — delegated down a chain of trust boundaries (platform → org → space → subtenant → client). Each layer can only narrow, never widen (validateNarrowing keeps the chain monotonic), and the boundary is enforced, not documented:

  • a rule authored against a lens provably can't reference outside it — checkRuleAgainstLens, at author time;
  • the row-scope where is the grant, applied server-side at execution via applyLens — the authored rule never sees it and can't escape it;
  • what reaches an untrusted party reveals nothing hidden — exposedSurface.

A lens defines a surface area, reused for distinct, separately-enforced constraints that may diverge: the data-flow surface (what you receive / pass into an interpolated template / expose to a client) vs the reasoning surface (what you may author predicates against — which can be narrower than what you actually get back). One predicate DSL (Condition) expresses both the grant (where) and the use (rules), compiling to check / toPrisma / toSql. That's why the same primitive backs permissions, email targeting/conditions, feature flags, and state-transition guards — it's the authority/visibility spine they compose on, not a filter helper.

How it's built

The Lens primitive is a schema-aware view layer over one or more FieldMaps. It enables rule authoring against multi-source data (e.g. Prisma + an external CRM), with declarative cross-source Bridges and recursive Narrowings for both schema (picks/omits/enumPicks/enumOmits) and data (where).

FieldMap & FieldMapSet

A FieldMap is { models, enums? } — models keyed by name, plus an optional enum registry scoped to that source. A FieldMapSet groups one or more FieldMaps and declares the cross-source edges between them:

import { stitchFieldMaps } from '@inixiative/json-rules';

const prismaMap = {
  models: {
    FanUser: { fields: { /* ... */ } },
  },
  enums: { UserRole: ['admin', 'member'] },
};

const set = stitchFieldMaps({
  maps: { prisma: prismaMap, salesforce: salesforceMap },
  bridges: [
    {
      endpoints: [
        { fieldMap: 'salesforce', model: 'Contact', on: 'id' },
        { fieldMap: 'prisma',     model: 'FanUser', on: 'crmId' },
      ],
      cardinality: 'oneToMany',
    },
  ],
});

stitchFieldMaps() injects bridge entries as kind: 'bridge' fields on each endpoint model — addressable in rules via <fieldMap>:<Model> notation (e.g. salesforce:Contact.industry). Each endpoint's on is the symmetric join field used at eval time for hydration. Bridge cardinality controls list-vs-single on each side.

Lens

// Lens extends FieldMapSet — maps and bridges live at the top level.
// Use `createLens` (it stitches bridges internally) instead of constructing by hand.
import { createLens } from '@inixiative/json-rules';

const lens = createLens({
  maps: { prisma: prismaMap, salesforce: salesforceMap },
  bridges,
  mapName: 'prisma',         // which map in `maps` holds the anchor model
  model: 'FanUser',          // anchor model
});

The lens is schema only — no data lives on it. Runtime data (rows, foreign tables, FE picker sources) is passed alongside, separately, when you need it.

LensNarrowing & where

LensNarrowing is a recursive tree that narrows a parent Lens (or another LensNarrowing). Each narrowing can add schema picks/omits per model, per-field enum picks/omits, and where clauses for data scope:

const narrowing: LensNarrowing = {
  parent: lens,
  root: {
    // path-specific narrowing at the lens anchor (FanUser)
    picks: ['email', 'firstName', 'crmId'],
    where: { field: 'tenantId', operator: Operator.equals, path: 'tenantId' },
  },
  mapDefaults: {
    prisma: {
      // applies wherever FanUser appears, root or nested
      models: {
        FanUser: { where: { field: 'deletedAt', operator: Operator.isEmpty } },
      },
    },
  },
};

Composition across chained narrowings is pure intersection. where clauses are anchored to the model they describe — root.where ANDs at the lens anchor, mapDefaults[X].models[Y].where injects at every visit of Y in map X, and root.relations[R]...where injects when the rule descends through R. The all array operator gets a filter-first rewrite via implication so out-of-scope rows don't fail the user's "every row matches" check. See docs/LENS.md for the full anchor semantics.

Lens Utilities

| Function | Purpose | | --- | --- | | validateNarrowing(narrowing) | Throws on structural or chain violations (incl. unresolvable where paths and items invisible from ancestors). Call at narrowing construction. | | projectByPath(lens) | Returns Map<dottedPath, ProjectedVisit> — each declared path keys its own resolved narrowing (path picks/omits/enums chain-intersected ∩ mapDefaults for the target model). Sibling paths to the same model stay independent. Use for SDK-contract / OpenAPI emission, search-field enumeration, validation whitelists. See docs/LENS.md §10. | | checkRuleAgainstLens(rule, lens) | Validates a user rule's field paths and enum values against the narrowed lens, path-aware. Returns { ok, violations }. The security gate. | | applyLens(rule, narrowing) | Composes the user rule with the lens's where clauses, injecting each at its anchor in the rule tree. Pass the result to check / toPrisma / toSql. |

Evaluating Across Bridges

path: refs (used for value comparisons) walk via the same dotted-path mechanism as field:. Bridge keys ('salesforce:Contact') are just plain object properties, so path: 'salesforce:Contact.industry' works in both field: (left side) and path: (right side) positions.

Limitations to know:

  • 1-many bridge arrays are not iterable mid-path. field: 'crm:MarketingEvent.campaign' or path: 'crm:MarketingEvent.campaign' returns undefined when the bridge value is an array — lodash.get can't fan out across array elements. Use a numeric index (crm:MarketingEvent.0.campaign) or arrayOperator on the field: side to iterate.
  • Bridge keys are plain object properties. The engine doesn't consult lens.bridges at eval time — callers structure data correctly using the schema as a guide. Use buildBridgeDictionary(lens, rawForeign) to pre-index foreign rows by on field, then embed under bridge keys per anchor row.

check() itself is bridge-unaware — it walks paths via plain property access. The lens primitive is schema metadata (what fields exist, what bridges link them, what on fields join each side). The caller is responsible for structuring data accordingly:

const fanUser = { id: 'u1', email: '[email protected]', crmId: 'c1' };
const contact = await fetchContact(fanUser.crmId);

// Embed the foreign row under its bridge key
const data = { ...fanUser, 'salesforce:Contact': contact };

// Rule references the bridge key in the path
const rule = { field: 'salesforce:Contact.industry', operator: Operator.equals, value: 'tech' };
check(rule, data);

For bidirectional traversal, use JavaScript object references — check's path walker handles circular structures fine because rules are finite trees and only resolve named paths:

fanUser['salesforce:Contact'] = contact;
contact['prisma:FanUser']     = fanUser;   // back-ref

// Rule walks fanUser → contact → fanUser → email
const rule = { field: 'salesforce:Contact.prisma:FanUser.email', operator: Operator.equals, value: '[email protected]' };
check(rule, fanUser);

For batch evaluation, build the anchor array yourself (engine supports root-array rules via fieldless arrayOperators):

const enriched = fanUsers.map((u) => ({
  ...u,
  'salesforce:Contact': contactsByCrmId[u.crmId],
  'crm:MarketingEvent': eventsByUserId[u.id],
}));

check(
  {
    arrayOperator: ArrayOperator.any,
    condition: { field: 'salesforce:Contact.industry', operator: Operator.equals, value: 'tech' },
  },
  enriched,
);

The lens schema (with Bridge.endpoints[*].on) is what tells the caller how to fetch and index foreign data. The runtime engine just walks the resulting structure.

Examples

See examples/basic-validation.ts, examples/array-operations.ts, examples/aggregate-rules.ts, examples/date-operations.ts, and examples/advanced-features.ts.

License

MIT