@inixiative/json-rules
v2.17.1
Published
TypeScript-first JSON rules engine with intuitive syntax and detailed error messages
Maintainers
Readme
@inixiative/json-rules
A TypeScript-first JSON rules library for:
- runtime validation with custom error messages
- Prisma query planning
- PostgreSQL
WHEREgeneration
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-rulesQuick 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 —
sumandavgacross numeric arrays or relation lists - ordered windowing — first/last
NwithorderBy/take/skip(check-only) - date comparisons with timezone-aware runtime evaluation
- relative & calendar date expressions — "last 30 days", "this month" — via
withinandago/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
equalsnotEqualslessThanlessThanEqualsgreaterThangreaterThanEqualscontainsnotContainsinnotInmatchesnotMatchesbetweennotBetweenisEmptynotEmptyexistsnotExistsstartsWithendsWith
Array Operators
allanynoneatLeastatMostexactlyemptynotEmpty
Aggregate Operators
Used in aggregate.mode:
sumavg
Supported comparison operators for aggregate rules: equals, notEquals, lessThan, lessThanEquals, greaterThan, greaterThanEquals, between, notBetween.
Date Operators
beforeafteronOrBeforeonOrAfterwithinbetweennotBetweendayIndayNotIn
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 singleorderBy, 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 toevery/some(e.g. the rule above →{ fanMissions: { every: { completedAt: { lt: <now-30d> } } } }). Any other windowed rule —take > 1,skip, multi-keyorderBy, 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 aWHEREfragment). Evaluate the unsupported cases in memory withcheck().
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:
truewhen 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' },
],
}); // trueCustom 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 1000PostgreSQL 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
matchesandnotMatchesare not supported by Prisma outputdayInanddayNotInare not supported by Prisma outputpath: '$.field'column-to-column comparisons are not supported by PrismaWHERE- count-based and aggregate relation operators require
{ map, model } - aggregate rules with
notBetweenare not supported by Prisma output - aggregate rules on JSON/native stored arrays are not supported by Prisma — use
toSql()orcheck()for those
SQL Limitations
- complex array element operators are not supported in SQL output:
allanynoneatLeastatMostexactly
toSql()generatesWHEREfragments andLEFT 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:
checktoPrismaexecutePrismaQueryPlantoSqlvalidateRuleassertValidRuleOperatorArrayOperatorDateOperatorConditionStrictConditionRuleAggregateRuleAggregateModeArrayRuleDateRule
Lens & bridges:
Lens,LensNarrowing,ModelNarrowing,ModelDefaultNarrowing,NarrowingDefaults,EnumNarrowingFieldMapSet,Bridge,BridgeEndpoint,BridgeCardinalitycreateLens,stitchFieldMaps,validateFieldMap,validateFieldMapSetvalidateNarrowing,projectByPath,exposedSurface,describeRule,checkRuleAgainstLens,applyLensPathProjection,ProjectedVisit,RuleDescriptionbuildBridgeDictionary
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,wherestripped. 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)returnsMap<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_CATALOGFieldKind,RuleTarget,ValueShapeNUMERIC_KINDS,ORDERABLE_KINDS,STRINGY_KINDS,EQUATABLE_KINDS,ALL_KINDSgetOperatorsForKind,getArrayOperators,getValueShape,isOperatorSupportedForTargetWINDOW_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
countfor 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), thealloperator 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 data — what 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
whereis the grant, applied server-side at execution viaapplyLens— 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'orpath: 'crm:MarketingEvent.campaign'returnsundefinedwhen the bridge value is an array —lodash.getcan't fan out across array elements. Use a numeric index (crm:MarketingEvent.0.campaign) orarrayOperatoron thefield:side to iterate. - Bridge keys are plain object properties. The engine doesn't consult
lens.bridgesat eval time — callers structuredatacorrectly using the schema as a guide. UsebuildBridgeDictionary(lens, rawForeign)to pre-index foreign rows byonfield, 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
