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.
npm install @inixiative/json-rules
# or
yarn add @inixiative/json-rules
# or
bun add @inixiative/json-rulesimport { 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"- 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();toPrisma()compiles the extremal case and afilteralone) - 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, and$$.scope refs up through nested arrays - custom error messages on every rule
- compilation to Prisma and PostgreSQL for supported subsets
equalsnotEqualslessThanlessThanEqualsgreaterThangreaterThanEqualscontainsnotContainsinnotInmatchesnotMatchesbetweennotBetweenisEmptynotEmptyexistsnotExistsstartsWithnotStartsWithendsWithnotEndsWith
allanynoneatLeastatMostexactlyemptynotEmpty
Used in aggregate.mode:
sumavg
Supported comparison operators for aggregate rules: equals, notEquals, lessThan, lessThanEquals, greaterThan, greaterThanEquals, between, notBetween.
beforeafteronOrBeforeonOrAfternotBeforenotAfterwithinnotWithinbetweennotBetweendayIndayNotIn
{
field: 'status',
operator: Operator.equals,
value: 'active'
}{
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 }
]
}{
if: { field: 'type', operator: Operator.equals, value: 'premium' },
then: { field: 'discount', operator: Operator.greaterThan, value: 0 },
else: { field: 'discount', operator: Operator.equals, value: 0 }
}{
field: 'orders',
arrayOperator: ArrayOperator.all,
condition: {
field: 'total',
operator: Operator.lessThanEquals,
path: '$.maxBudget'
}
}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 and avg([]) = 0. A NULL or absent array is empty, and
NULL items are skipped, as SQL's SUM / AVG skip them.
{
field: 'expiryDate',
dateOperator: DateOperator.after,
value: '2026-01-01'
}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.
A period (this / last / next) names a dayjs unit: day, week, isoWeek, month,
quarter, year, hour, minute, second. A rolling amount (ago / ahead) counts
years, quarters, months, weeks, days, hours, minutes, seconds.
Point expressions — pair with before / after / onOrBefore / onOrAfter /
notBefore / notAfter,
or as between endpoints:
// "more than 30 days ago"
{ field: 'completedAt', dateOperator: DateOperator.before, value: { ago: { days: 30 } } }
// "not since 30 days ago" — on/before the point OR never (notAfter is the null-carrying
// complement of after; onOrBefore is positive and skips NULL). Takes a literal date too.
{ field: 'lastLoginAt', dateOperator: DateOperator.notAfter, 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 within / notWithin:
// "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 } } }
// its complement: "not in the last 30 days" — a never-set date is not in the window, so
// NULL matches (see NULL Semantics). The dormancy rule, in one leaf.
{ field: 'lastLoginAt', dateOperator: DateOperator.notWithin, 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.
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 — a zone name, or a { bind } read from the bindings |
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.
Array and aggregate rules accept an ordered-window selector that runs before the
predicate. Pipeline: filter → order → skip → take. Direction comes from orderBy.dir, so
"the last fanMission" is order by date desc, take 1. NULLs sort last in both directions, so
orderBy views desc, take 1 is the largest non-null value.
// "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 } } },
}filter is a condition each element must pass to enter the window (narrowRule puts a lens
clamp there under all). 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 to relation filters: the rule above, withcompletedAtrequired, is "no missions, or some and none since the bound" —{ OR: [{ fanMissions: { none: {} } }, { AND: [{ fanMissions: { some: {} } }, { fanMissions: { none: { completedAt: { gte: <now-30d> } } } }] }] }. Afilteralone (noorderBy/take/skip) folds into the rule:allthrough the exact complement of its condition, the rest asfilter AND condition. Any other windowed rule —take > 1,skip, multi-keyorderBy, a different/non-monotonic condition, a misaligned direction, or afilterbeside an ordered window — 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 lets a rule compare a field with another value on the row; caller-supplied values come
through { bind } (check(rule, row, { bindings }); bindRule before compiling).
A bare path reads the root row — the record check() evaluates, the table a compiler compiles
against:
{
field: 'confirmPassword',
operator: Operator.equals,
path: 'password'
}Inside an array operator's condition or filter, $. reads from the current element.
Each additional $ reaches one enclosing element further out: $$. is the element of the
enclosing array operator, $$$. the one above that, up to the root row. Logical
combinators (all / any / if) never add a level — only array and aggregate rules do.
Both field and path take the prefix. A bare field is always the current element; a
bare path is always the root row.
{
field: 'orders',
arrayOperator: ArrayOperator.all,
condition: {
field: 'lineItems',
arrayOperator: ArrayOperator.all,
condition: {
all: [
// line item qty against its order's cap
{ field: 'qty', operator: Operator.lessThanEquals, path: '$$.maxQty' },
// order cap against the root row's limit — neither side is the line item
{ field: '$$.maxQty', operator: Operator.lessThanEquals, path: '$$$.orgLimit' },
],
},
},
}A ref deeper than the nesting ($$. at the top level, $$$. one array deep) throws in
check(), is a scope_out_of_bounds issue from validateRule, and an error from
validateRuleInLens. A reachable ancestor that lacks the named key fails the comparison
like any absent field.
toSql() compiles a bare path or path: '$.x' as a same-row column comparison (equality,
ordered and set-free operators; a substring, pattern or set operator against a column throws).
toPrisma() compiles one only as a Prisma field reference: both columns of the same model at the
same visit (a bare path at the root, $. inside a relation filter), of exactly the same type, with
equals / notEquals / lessThan / lessThanEquals / greaterThan / greaterThanEquals and
no offset. The plan carries a { __field } sentinel that executePrismaPlan resolves to
prisma.<model>.fields.<column>; read a plan's where only through it. Anything else throws, and
validateRule(rule, { target: 'toPrisma', map, model }) / describeRule report it first. Every
other scope ref — a $$. path or any prefixed field — is check-only; both compilers throw.
An offset moves the comparison value. It is a value source of its own, with the comparison
value's contract: { value }, { path } ($. from the element, bare from the root row) or { bind }
(with bindOptional). A field rule's offset reads a number, added to the comparison value; a
date rule's reads a rolling shift ({ ago } / { ahead }) anchored on the comparison value
instead of now:
// net score at or under par: gross <= par + handicap
{ field: 'grossScore', operator: Operator.lessThanEquals, path: '$.par',
offset: { path: '$.handicap' } }
// within budget plus a tolerance supplied at evaluation
{ field: 'spend', operator: Operator.lessThanEquals, path: '$.budget',
offset: { bind: 'tolerance' } }
// completed within 30 days before the created date
{ field: 'completedAt', dateOperator: DateOperator.onOrAfter, path: '$.createdDate',
offset: { value: { ago: { days: 30 } } } }
// on or after the fifth of this month — an edge the expression grammar can't name alone
{ field: 'paidAt', dateOperator: DateOperator.onOrAfter, value: { start: { this: 'month' } },
offset: { value: { ahead: { days: 4 } } } }bindRule resolves an offset's bind as it does the comparison value's, and
listBindings lists it. A date offset read per row (a column holding
{ ago: … }) is check-only; to size a shift from the row, read the amount instead.
Any relative-date unit — in a value expression or an offset's rolling shift — is a number or a
value source: { path } from the row, { bind }, or { value }. A relative
window can take its size from the row it judges:
// quiet for longer than this incident's rule allows
{ field: 'lastBreachedAt', dateOperator: DateOperator.before,
value: { ago: { seconds: { path: '$.platformAlertRule.autoResolveAfterSeconds' } } } }Offsets apply to the comparison operators (equals … greaterThanEquals, before …
notAfter) and to both ends of between / notBetween. Units apply as Postgres applies an
interval to a wall-clock time in the evaluation's timeZone (UTC by default): months (years,
quarters, months), then days (weeks, days), then time — so every rail lands on the same instant
at a month end and across a DST change. Calendar units (years … days) are whole numbers and every
unit is non-negative: a literal that isn't fails validation, and a value read from data that
isn't reads as null. A null comparison value, offset or magnitude, or a range missing an end,
matches nothing (SQL's NULL arithmetic); a negation keeps null fields only. Numeric offsets add
in double precision on every rail.
check() |
toSql() |
toPrisma() |
|
|---|---|---|---|
| literal or bound offset / amount | yes | resolved to a parameter | resolved to a value |
row ($. or bare) numeric offset or unit amount |
yes | col + n / col ± make_interval(…) |
throws |
row ($. or bare) date offset (a stored { ago }) |
yes | throws | throws |
$$. anything |
yes | throws | throws |
validateRuleInLens gates offset and magnitude refs like path (they must resolve through
the lens and read a number), and an offset must fit the field's kind: a number on a numeric
field, a rolling shift on a DateTime.
Reading a stored rule's own content — which values it names, which bindings it needs — is engine work, not caller work: a walk written outside the engine goes blind the day the rule format grows a node type, and it goes blind silently.
| Function | Purpose |
|---|---|
listBindings(rule, { required: true }) |
Names a bindings map must cover — every { bind } token not marked bindOptional, sorted. A name optional at one leaf and required at another is required. |
listBindings(rule) |
Every { bind } name in the tree, optional or not, sorted — what a lens declares. |
bindRule(rule, bindings) |
Substitutes covered binds with their values, leaving uncovered tokens in place (partial resolution). |
A leaf may mark its bind optional: { field, operator, bind: 'region', bindOptional: true }. An
unsupplied required bind is a caller bug — check() throws, and both compilers refuse a
surviving token. An unsupplied optional bind is null wherever absence is final: check()
compares against null, toPrisma / toSql compile the token as null. The rule is evaluated
as written — the leaf is never pruned, so in {{bind}} with nothing bound matches nothing rather
than everything.
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' },
],
}); // trueValues compare as JSON does. Lists and objects compare by value, deeply. Types never cross:
"3" never equals 3, and an ordered comparison or a range holds only between two numbers, two
strings or two dates. A Date field value compares as a DateTime without help. To compare a
string literal against a number or Boolean field, stamp the rule's coerceType (coerceRule
does it from a lens). The compilers refuse a string literal on a number or Boolean column
without one.
An enum compares exactly against its declared values. String, pattern and ordered operators
don't apply to one, and the compilers refuse them. A case-insensitive equality or membership, or
one naming a value the enum doesn't declare, compiles to a membership test over the declared
values check() would match (plus the NULL arm for a negation). The field map must list the
values (prisma-map does).
// map: { models: { U: { fields: { role: { kind: 'enum', type: 'Role' } } } }, enums: { Role: ['admin', 'member'] } }
toSql({ field: 'role', operator: Operator.equals, value: 'ADMIN', caseInsensitive: true }, { map, model: 'U' });
// { sql: '"t0"."role"::text = ANY($1)', params: [['admin']], joins: [] }
toSql({ field: 'role', operator: Operator.startsWith, value: 'adm' }, { map, model: 'U' });
// throws: 'startsWith' does not apply to the enum 'role'; compare its values with equals / in.Every rule can define its own error:
{
field: 'email',
operator: Operator.matches,
value: /^[^@]+@[^@]+\.[^@]+$/,
error: 'Please enter a valid email address'
}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 executePrismaPlan() to resolve groupBy step references before passing the final where into Prisma.
import {
ArrayOperator,
Operator,
executePrismaPlan,
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 executePrismaPlan(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 executePrismaPlan(plan, { order: prisma.order });
await prisma.user.findMany({ where }); // users whose orders sum to more than 1000A user with no orders sums to 0, as in check(): a comparison that holds at 0 selects the
parents outside the groups where it fails, so childless parents stay in.
Pass lens instead of map / mapName / model and the rule is gated by the lens
(validateRuleInLens: a rule it refuses throws; a bare value path is a root-row column, gated
like a field), narrowed by it, and compiled against its base lens. toSql takes it the same way.
Passing both throws.
const plan = toPrisma(rule, { lens: narrowing, now });
// Gated by the lens, narrowed by it (a bare `path` too, as a root-row column), then compiled
// against the base lens's map and model.
const where = await executePrismaPlan(plan, prisma);A Json column holds a DB NULL or a JSON null, and a path inside it can be absent — check()
reads all three as null, and Prisma matches them together only with its AnyNull instance,
which it knows by identity. toPrisma() takes it from your installed @prisma/client (an
optional peer dependency), so there is nothing to configure; set prismaOptions.anyNull only to
use a different client's. Without @prisma/client, a Json null check throws.
Prisma filters follow the column kind the map
declares: on Json, contains / startsWith / endsWith become string_contains / … and in
becomes one equals per value; on a scalar list, contains becomes has and emptiness
isEmpty; caseInsensitive adds mode: 'insensitive' on text only.
engineGlobals holds process-wide defaults. Keys are dotted paths into one state object.
| Key | Default | Governs |
|---|---|---|
string.caseInsensitive |
false |
Default for a rule's caseInsensitive. A rule's own flag wins. Read by check(), toSql() and toPrisma(). |
string.fuzzy |
false |
Default for a rule's fuzzy (true or a FuzzyConfig { maxDistance?, maxRatio? }): typo-tolerant contains / notContains on strings. A rule's own flag wins. The compilers have no fuzzy form: they refuse contains / notContains whenever fuzzy is on, by the rule or by this default. |
prismaOptions.datasource.provider |
'postgresql' |
The Prisma connector. toPrisma() emits mode: 'insensitive' only for postgresql, cockroachdb and mongodb; the others are case-insensitive by collation and reject it. toPrisma(rule, { datasource: { provider } }) overrides it per call. |
prismaOptions.anyNull |
your @prisma/client's |
Prisma's AnyNull for Json null checks (see above); set it only to use another client's. |
import { engineGlobals } from '@inixiative/json-rules';
engineGlobals.set('string.caseInsensitive', true);
engineGlobals.get('string.caseInsensitive'); // true
engineGlobals.set('prismaOptions.datasource.provider', 'mysql');
engineGlobals.reset(); // back to the defaults
// A scoped override: merged over the current state for the duration of a synchronous callback.
const result = engineGlobals.with({ string: { caseInsensitive: true } }, () => check(rule, data));with() restores the previous state when the callback returns or throws. The callback must be
synchronous: one that returns a Promise throws. set() copies plain data and keeps a class
instance (like AnyNull) as given, since Prisma recognizes it by identity.
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: 'a@b.com' },
{ map, model: 'Post', alias: 't0' },
);
// result.sql => '"t1"."email" = $1'
// result.joins => ['LEFT JOIN "User" AS "t1" ON "t1"."id" = "t0"."authorId"']map is a FieldMap, or a FieldMapSet (a lens works) with mapName naming the map to read,
as toPrisma() takes it. A set without mapName throws.
toSql(rule, { map: lens, mapName: 'prisma', model: 'Post' });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 (list and Json columns, not relations) |
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 (filter / orderBy / take / skip) |
Yes | Extremal (take: 1, aligned, no filter), or a filter alone |
No |
path — a bare root-row column, or $. the current element's |
Yes (reads the row) | Same model, same visit, exactly the same type, with equals / notEquals / lessThan(Equals) / greaterThan(Equals) — a Prisma field reference, resolved by executePrismaPlan; anything else throws |
Yes (a column; not in a relation filter, not a substring or set operator) |
offset and unit amounts — value or bind |
Yes | Yes | Yes |
offset and unit amounts — row refs ($. or bare) |
Yes | No | Yes (not a date offset's) |
$$. scope refs and $-prefixed field |
Yes | No | No |
A negated operator is the complement of its positive form — the same answer
check() gives, where null !== 'x' is simply true. SQL's three-valued logic
disagrees (col <> 'x' is NULL, never true, for a NULL column), so the
compilers carry NULL rows explicitly:
| Rule | check() on { col: null } |
toSql() |
toPrisma() (nullable column) |
|---|---|---|---|
notEquals 'x' / notContains / notMatches / notBetween |
matches | (col <> $1 OR col IS NULL) |
{ OR: [{ col: { not: 'x' } }, { col: { equals: null } }] } (notMatches has no Prisma form) |
notIn ['x'] |
matches | (col <> ALL($1) OR col IS NULL) |
{ OR: [{ col: { notIn: ['x'] } }, { col: { equals: null } }] } |
in ['x', null] |
matches | (col = ANY($1) OR col IS NULL) |
{ OR: [{ col: { in: ['x'] } }, { col: { equals: null } }] } |
notIn ['x', null] |
no match | (col <> ALL($1) AND col IS NOT NULL) |
{ AND: [{ col: { notIn: ['x'] } }, { col: { not: null } }] } |
equals / notEquals with a column path |
null === null |
IS [NOT] DISTINCT FROM |
{ col: { equals: fields.other } } plus the NULL arms of IS [NOT] DISTINCT FROM |
exists / notExists |
!= null / == null |
IS NOT NULL / IS NULL |
{ not: null } / { equals: null }; on a required column, whether its row is there ({ rel: { is: {} } } / NOT, always / never at the root) |
The absent set of a path is wider than a NULL leaf: an optional to-one hop can be NULL too,
and { rel: { col: { equals: null } } } only matches when the relation exists. So every negation
also carries { rel: { is: null } } for each optional to-one hop on the path (licensed by the
relation entry's isRequired: false) — profile.bio notEquals 'x' compiles to
{ OR: [{ profile: { bio: { not: 'x' } } }, { profile: { bio: { equals: null } } }, { profile: { is: null } }] },
matching check() (a missing hop reads as NULL) and toSql (LEFT JOIN + IS NULL). equals null
and in [null, …] carry the same hop arms: a user with no profile has a NULL profile.bio.
toPrisma() can only add the null arm when it knows the column is nullable —
an equals: null on a NOT NULL column is a Prisma validation error. Nullability
comes from the field map: FieldMapEntry.isRequired: false (prisma-map emits it).
Without { map, model }, or on an entry that doesn't declare it, the bare
not / notIn is emitted and NULL rows fall out, as they always did.
Date rules follow the same split. The positive operators answer non-match on both
rails — a bare boundary is not something a NULL column satisfies, so check()
reports the rule's ordinary non-match (honoring error) and the compilers keep the
bare < / BETWEEN. The negative-flavored ones (notBefore, notAfter, notWithin, notBetween, dayNotIn) follow
the negation ruling instead: a never-set date is not in the range, so a null column
MATCHES, and the compilers carry the IS NULL arm. To match never-seen rows under a
positive operator, ask for them:
{ any: [{ field, operator: 'notExists' }, { field, dateOperator: 'before', … }] }.
| Rule | check() on { col: null } |
toSql() |
toPrisma() |
|---|---|---|---|
positive dateOperator (before, between, dayIn, …) |
no match | col < $1 (NULL never satisfies) |
{ col: { lt: … } } |
notAfter X / notBefore X |
matches | (col <= $1 OR col IS NULL) / (col >= $1 OR col IS NULL) |
{ OR: [{ col: { lte } }, { col: { equals: null } }] } (nullable column) |
notWithin { ago: { days: 30 } } |
matches | (col NOT BETWEEN $1 AND $2 OR col IS NULL) |
{ OR: [{ NOT: { col: { gte, lte } } }, { col: { equals: null } }] } (nullable column) |
notBetween |
matches | (col NOT BETWEEN $1 AND $2 OR col IS NULL) |
{ OR: [{ NOT: { col: … } }, { col: { equals: null } }] } (nullable column, same field-map licensing as above) |
dayNotIn |
matches | (EXTRACT(DOW FROM col) <> ALL($1) OR col IS NULL) |
— (no Prisma output) |
notExists OR before |
matches via the first arm | (col IS NULL OR col < $1) |
{ OR: [{ col: { equals: null } }, { col: { lt: … } }] } |
0 is an instant (1970-01-01) and compares; '' is malformed data and raises
"is not a valid date".
matchesandnotMatchesare not supported by Prisma outputdayInanddayNotInare not supported by Prisma outputpath: '$.field'column-to-column comparisons are not supported by PrismaWHERE; no scope ref ($$.path, prefixedfield) compiles- count-based and aggregate relation operators require
{ map, model } - aggregate rules on JSON/native stored arrays are not supported by Prisma — use
toSql()orcheck()for those - element conditions (
all/any/none/ counts) over a scalar list or a Json array are not supported by Prisma; test a list's membership withcontains - a field path through a to-many relation (
posts.title) is an error on both compilers — compare its rows with an array rule onposts - Prisma loads a NULL scalar-list column as
[], socheck()over Prisma-loaded rows reads it as an empty list while the compilers read NULL; a list Prisma writes is never NULL, so this only matters for rows written outside Prisma
- complex array element operators are not supported in SQL output:
allanynoneatLeastatMostexactly
toSql()generatesWHEREfragments andLEFT JOINs, not complete queries
check(), toSql() and toPrisma() agree on every rule they all compile, except where the
engines themselves differ:
- Case-insensitive comparison follows each engine's case mapping: JavaScript's
toLowerCaseand Postgres'sLOWERunder the database collation can differ on letters likeİ. - Ordered string comparisons (
lessThan,betweenon text) follow each engine's order:check()compares UTF-16 code units, Postgres the column's collation. - An array or aggregate rule on a Json value that isn't an array is a data error.
check()throws on it; SQL can't raise per row, so it reads the value as an empty array — for an aggregate and forempty/notEmptyalike.
The public rule types are generic over comparison payloads:
type Condition<TRuleValue = RuleValue, TDateValue = DateRuleValue> =
| Rule<TRuleValue>
| AggregateRule<TRuleValue, TDateValue>
| ArrayRule<TRuleValue, TDateValue>
| DateRule<TDateValue>
| All<TRuleValue, TDateValue>
| Any<TRuleValue, TDateValue>
| IfThenElse<TRuleValue, TDateValue>
| boolean;The public API has one name per operation. docs/VERBS.md lists every exported function by verb.
Rules:
check,toPrisma,executePrismaPlan,toSqlvalidateRule,assertValidRule,bindRule,listBindingsOperator,ArrayOperator,DateOperatorCondition,StrictCondition,Rule,AggregateRule,AggregateMode,ArrayRule,DateRule,Row,CheckDataGroupByStep,WhereStep,PrismaStep,PrismaWhere,StepRef(a Prisma plan's steps);ScopeRef,ScopedRef,ScopeOutOfBounds(scope refs)CheckOptions,CompileOptions,ToPrismaOptions,ToSqlOptions,ToSqlResult,ToPrismaResult,ValidateRuleOptions,ListBindingsOptions,ValidationIssue,ValidationResult- rule parts:
All,Any,IfThenElse,RuleValue,RuleScalar,OrderedRuleValue,ValueSourceOf,ValueSourceFields,NumberOffset,Magnitude,WindowFields,OrderBy,SortDir(andorderRecordsto sort records by anOrderBy) - dates:
DateRuleValue,DateInputValue,DateInputOrExpr,DateExpr,RollingExpr,PeriodExpr,EdgeExpr,PeriodUnit,RelativeUnits,DateOffset,DateConfig,TimeZoneConfig,WeekStart - the strict shapes, which pair each operator with its operand's type:
StrictCondition,StrictAll,StrictAny,StrictIfThenElse,StrictRule,StrictEqualityRule,StrictMembershipRule,StrictOrderedComparisonRule,StrictRangeRule,StrictContainsRule,StrictStringBoundaryRule,StrictPatternRule,StrictPresenceRule,StrictDateRule,StrictDateComparisonRule,StrictDateRangeRule,StrictDateDayRule,StrictArrayRule,StrictArrayPredicateRule,StrictArrayCountRule,StrictArrayPresenceRule,StrictAggregateRule engineGlobals,EngineGlobalsState,PrismaProvider,FuzzyConfig
Lens & bridges:
Lens,LensNarrowing,ModelNarrowing,ModelDefaultNarrowing,NarrowingDefaults,EnumNarrowing,SourceSpec,SourceEntryFieldMap,FieldMapEntry,ModelEntry,SourceOption,FieldMapSet,Bridge,BridgeEndpoint,BridgeCardinality,BridgeDictionarycreateLens,storeLens,composeLens,StoredLens,stitchFieldMaps,indexBridges,validateFieldMaps,assertValidFieldMapsvalidateNarrowing,assertValidNarrowing,validateRuleInLens,narrowRule,coerceRulebindLens,listLensBindings,getLensRoot,clampLens,LensClampsprojectLens,walkLensPath,readLensValue,LensValue,describeRule,describeRuleSourcestoLensSelect,projectRows,LensSelect,LensRelationSelect,LensSelectOptions,ProjectRowsOptionstoSourceQueries,materializeSources,materializeSourceQueryPathProjection,ProjectedVisit,ProjectLensOptions,LensPathHop,LensPathResolution,RuleDescription,RuleSourceDescription,SourceQuery,SourcePrismaQuery,SourceSqlQuery,SourceSelect,SourceValues,SourceRowShape,MaterializeSourceQueryOptions
A lens has three forms, each with its own job:
- Composed — a
Lens, or aLensNarrowingwhoseparentholds the layer above it as an object, down to the base lens. Every evaluator takes this form. - Stored —
StoredLens, one record per layer: itsid,parents(the ids of every layer it composes with, the base lens first) and its own part. The base lens is the root-most record, stored as itself with no parents.storeLens(lens, ids)writes a composed lens out as records;composeLens(id, records)reads them back — fetch the layer, then the ids it lists — validating each layer against the ones above it and failing closed on a missing record, a base out of place, or a parent whose own list disagrees. - Projected — what a lens exposes, which never leads back to the lens:
projectLens(lens)returnsRecord<dottedPath, ProjectedVisit>for per-path checks where sibling paths to the same model diverge.projectLens(lens, { by: 'model' })returns the leak-safe total surface as a Lens — every model the relations turned on reach, with the full narrowing applied, unioned per model,wherestripped. Use it as the server→client builder surface; it never exposes the raw lens. It is a view, not a gate: as a lens it is bare and turns no relation on, so gate rules against the narrowing it came from.
const records = storeLens(delegateLens, ['user', 'org-acme', 'delegate-7']); // persist each record
// later: fetch 'delegate-7', then the ids in its `parents`
const lens = composeLens('delegate-7', { user, 'org-acme': orgAcme, 'delegate-7': delegate7 });What a rule builder can offer for a field. The catalog's constants are FieldKind,
RuleTarget, ValueShape, NUMERIC_KINDS and ALL_KINDS; getValueShape(operator, family) takes an
OperatorFamily ('field' | 'date' | 'array').
import {
getAggregateOperators,
getArrayOperators,
getOperatorsForKind,
getValueShape,
} from '@inixiative/json-rules';
getOperatorsForKind('Int', 'toPrisma');
// { field: ['equals', 'notEquals', 'lessThan', …], date: [] } — operators that kind takes on that target
getArrayOperators('toSql'); // ['empty', 'notEmpty']
getAggregateOperators(); // ['equals', 'notEquals', 'lessThan', …, 'between', 'notBetween']
getValueShape('between', 'field'); // 'range'
getValueShape('between', 'date'); // 'dateRange'| Function | Purpose |
|---|---|
getOperatorsForKind(kind, target?) |
{ field, date }: the field and date operators a FieldKind takes, narrowed to one target when given. |
getArrayOperators(target?) |
The array operators, narrowed to one target when given. |
getAggregateOperators() |
The comparisons an aggregate rule takes. Every target compiles all of them. |
getValueShape(operator, family) |
The operand an operator takes ('scalar', 'range', 'dayList', …). family is 'field', 'date' or 'array', since between is both a field and a date operator. Throws on an operator the family doesn't have. |
To ask whether a whole rule runs on a target (windows, scope refs and operators together), use
validateRule(rule, { target }).
parseScopeRef and readScopeRef read the $-prefixed refs described in
Scope References, for code that resolves refs of its own.
import { parseScopeRef, readScopeRef } from '@inixiative/json-rules';
parseScopeRef('$$.maxQty'); // { depth: 2, path: 'maxQty' }
parseScopeRef('maxQty'); // null — a bare ref
// scopes run outermost first; `$.` is the last, `$$.` the one before it
readScopeRef('$$.maxQty', [rootRow, order, lineItem]); // { scope: order, path: 'maxQty' }
readScopeRef('maxQty', [rootRow, order]); // { scope: order, path: 'maxQty' }
readScopeRef('$$$.x', [rootRow, order]);
// { outOfBounds: "Scope ref '$$$.x' needs depth 3 but only 2 scopes are in reach" }readScopeRef returns the scope a ref names and the path left to read in it. It never throws:
a ref deeper than the stack comes back as { outOfBounds } with the message.
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' });Two error classes say whose input went wrong; each sets name, so check with instanceof or
error.name:
| Class | Thrown when | Fix |
|---|---|---|
UsageError |
The caller's input is missing or malformed: no now for a relative date, an invalid now or time zone, a bind never bound (bindRule / bindLens before compiling; bindings for check). |
Supply the input. |
LensRefusal (code) |
The lens itself can't do it: a later layer's clamp reads what its parent hides, a clamp or source a compile has no form for, a source path that can't carry its link. | Fix the lens — validateNarrowing reports the same refusal as an issue. |
import { LensRefusal, UsageError } from '@inixiative/json-rules';
try {
toPrisma(rule, { lens, now });
} catch (error) {
if (error instanceof UsageError) /* the caller's input */;
else if (error instanceof LensRefusal) /* the lens: error.code */;
else throw error; // the rule's shape — validateRule reports it
}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 rules are check()-only: both compilers throw on a fieldless ArrayRule.
For the full 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.
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 —
validateRuleInLens, at author time; - the row-scope
whereis the clamp, applied server-side at execution vianarrowRule— the authored rule never sees it and can't escape it; - what reaches an untrusted party reveals nothing hidden —
projectLens(…, { by: 'model' }).
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 clamp
(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.
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).
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 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.
Relations are fields, off by default. A bare lens reads its anchor model's columns and nothing
else. The first narrowing over the base lens turns a relation on through the relation object —
never through picks, which names columns only:
const narrowing: LensNarrowing = {
parent: lens,
root: { relations: { org: { relations: { parent: {} } } } }, // along the path
mapDefaults: {
prisma: { models: { Org: { relations: { users: {} } } } }, // wherever Org is visited
},
};
validateRuleInLens({ field: 'org.parent.name', operator: Operator.equals, value: 'Acme' }, narrowing); // ok
validateRuleInLens({ field: 'posts', arrayOperator: ArrayOperator.any, condition: true }, narrowing);
// { ok: false, errors: [{ path: 'posts', code: 'not_in_lens', message: "'posts' is a relation the lens does not turn on …" }] }- Off is hidden. A relation that isn't turned on can't be crossed or named (
exists/notExistsincluded): the gate refuses it (not_in_lens),walkLensPathandreadLensValuereport ithidden,projectLensdoesn't list it, andtoLensSelect/projectRowsdon't fetch or keep it. That covers every relation a rule, a valuepathor$ref, an offset, anorderBy, a sourcelabel/groupBy, or a read crosses. A bridge is turned on by its key ('salesforce:Contact'). - The relation object carries that hop's narrowing.
relations.org = { where, picks, omits, relations }narrows Org at that hop; on a model default it narrows the hop wherever the model is visited, and may nest further relations. - Only the first narrowing turns relations on; later layers narrow. A later layer may omit a
relation (
omits: ['org'], whichpicksnever conflicts with), or restate one its parent shows to add that hop's narrowing — a restatement hides nothing else. Naming a relation its parent doesn't show failsvalidateNarrowing(not_visible), and does nothing at runtime. - The model defaults grow a tree. From the anchor and every spelled path, model-default
turn-ons are followed breadth-first, each model once, at its nearest reach (ties: the earlier
parent, then the relation declared first). Anything else is reached by spelling it under
root.relations; a spelled node grows its own tree. Every posture walks the same tree, so they agree and stay small.lensVisit(lens, 'org.users')resolves one visit on demand, asprojectLenswould give it, ornull. - Clamps. The first narrowing's
wheres (and source eligibilitywheres) may read any relation on the schema; a later layer's may read only what its parent shows — a delegate can't probe a relation it can't see (validateNarrowingreports it; every posture throws). A bare valuepathreads the root row, so onlyroot.wheremay hold one.toLensSelectfetches exactly the columns clamps read, andprojectRows({ keepClampColumns: true })keeps them for a re-check.
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, bind: '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: relations are turned on by the first narrowing and only hidden after it. 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. Under the all array operator the clamp goes into the rule's window filter, so out-of-scope rows are dropped before the user's "every row matches" check; check() and toPrisma run it. See docs/LENS.md for the full anchor semantics.
| Function | Purpose |
|---|---|
validateNarrowing(narrowing) |
Returns { ok, errors: { path, message, code }[] } for structural or chain problems, one code each: not_in_lens, not_visible (an item an ancestor hid, or a relation the parent doesn't show), conflicting_selection (picks beside an omitted column), wrong_kind (a relation named in picks, among others), value_not_allowed, invalid_source (a source label / axis crossing a relation that is off, among others), invalid_binding, plus the lens gate's codes for a where. assertValidNarrowing throws instead. Call at narrowing construction. |
assertValidFieldMaps(set) |
Throws when a field name in any map holds . or : (a path step and a bridge marker). validateFieldMaps(set) returns { ok, errors } with code invalid_field_name. To check a single map, wrap it: assertValidFieldMaps({ maps: { prisma: map } }). |
projectLens(lens) |
Returns Record<dottedPath, ProjectedVisit> — each path the relations turned on reach keys its own resolved narrowing (path picks/omits/enums chain-intersected ∩ mapDefaults for the target model); a relation field appears only where it is on. Sibling paths to the same model stay independent. Use for SDK-contract / OpenAPI emission, search-field enumeration, validation whitelists. See docs/LENS.md §10. |
describeRuleSources(rule, lens) |
The values a rule names at each source the lens declares, keyed like projectLens (path + field, with the source's mapName / model). Resolved through the lens like walkLensPath, so mapDefaults sources answer wherever a relation turned on reaches their model. dynamic: true when the set can't be enumerated: a path / bind leaf, an offset or a read amount that moves the value, a substring / pattern / range / window operator, or an operator the catalog doesn't know — callers fail closed on it. The reverse question for a reference registry ("which rows does this rule name") — join model + values. |
validateRuleInLens(rule, lens) |
Validates a user rule's field paths and enum values against the narrowed lens, path-aware. Returns { ok, errors: { path, message, code }[] } like validateRule (codes such as not_in_lens, operator_kind_mismatch, invalid_value, value_not_allowed). Every relation a field, a value ref, an offset or an orderBy / aggregate field crosses must be turned on. A bare value path is a root-row column, gated like a field. The security gate. |
describeRule(rule, lens) |
{ sources, bridgesCrossed, supportedTargets, errors }: the maps a rule reads, whether it crosses a bridge, which of check / toPrisma / toSql can run it (by the rule's shape, as validateRule reads it — a compiler may still refuse a field's kind, such as a date rule on a String column on Prisma), and the lens gate's ValidationIssues. For routing and UX; validateRuleInLens stays the gate. |
getLensRoot(lensOrNarrowing) |
The base lens a narrowing chain is rooted at; a lens is its own. Throws on a cyclic chain. |
clampLens(lens, clamps) |
The lens with clamps ({ root?: { where }, mapDefaults?: { [map]: { models: { [model]: { where?, sources? } } } } }) ANDed into its first narrowing over the base lens, one added over a bare lens; later layers kept as they were. Only the first layer's clamps read the whole schema, so a clamp on what a later layer hides goes here. A source's where ANDs; its label / groupBy win, and a source clamp on a field with none adds that option set. Wheres only narrow. |
lensVisit(lens, relationPath, options?) |
One visit as projectLens (by path) gives it — its shown fields with values and options, sources, labels and axes — at a dotted relation path from the anchor ('' for the anchor), resolved on demand: nothing is enumerated, so it is cheap on any schema. null when a relation on the path isn't shown there (off, omitted, or outside the model-default tree). A builder walks a lens with it instead of re-deriving the lens's rules. |
walkLensPath(lens, path) |
Resolves one dotted path through the lens hop by hop: { outcome: 'resolved', hops, terminal, jsonSubPath }, or hidden (a column it doesn't keep, or a relation it doesn't turn on there) / missing / pastScalar with the failing index. |
readLensValue(lens, row, path, options?) |
One value off a row, as the lens shows it: { ok: true, value }, or { ok: false, reason } — hidden / missing / pastScalar (the walk validateRuleInLens gates a field with), relation (the path ends on rows, not a value) or list (it crosses a to-many). Each row on the way, the root included, is checked against its visit's clamps: one a clamp hides, or a missing one, reads null. Only own properties are read, into a Json column too. options is what each clamp is checked with (now, bindings). For values a template interpolates. |
orderRecords(items, orderBy) |
Records sorted by an OrderBy exactly as a window orders them on every rail: each key in turn, own-property path reads, dir order, NULL or absent last in either direction, ties in input order. Returns a new array. Strings sort in JavaScript code-unit order, which can differ from a database's collation (see Where the Rails Differ). |
narrowRule(rule, narrowing) |
Composes the user rule with the lens's where clauses, injecting each at its anchor in the rule tree. Under an all, the clamp goes into the rule's window filter, which check() evaluates and toPrisma folds into the rule (toSql compiles no relation arrays). Other rules pass to check / toPrisma / toSql. |
coerceRule(rule, lens) |
Stamps each field rule with its field's coerceType from the lens (Int, Float, Decimal, BigInt, DateTime, Boolean, String). Leaves date rules, aggregate comparisons, rules that already carry a coerceType, and anything below a Json column alone. |
import { coerceRule } from '@inixiative/json-rules';
// User.age is { kind: 'scalar', type: 'Int' } in the lens's map
coerceRule({ field: 'age', operator: Operator.equals, value: '3' }, lens);
// { field: 'age', operator: 'equals', value: '3', coerceType: 'Int' }A narrowing's where and sources conditions can hold { bind } tokens, filled per request.
import { bindLens, listLensBindings } from '@inixiative/json-rules';
const narrowing: LensNarrowing = {
parent: lens,
root: { where: { field: 'tenantId', operator: Operator.equals, bind: 'tenantId' } },
};
listLensBindings(narrowing); // ['tenantId']
const bound = bindLens(narrowing, { tenantId: 't-42' });
// bound.root.where => { field: 'tenantId', operator: 'equals', value: 't-42' }| Function | Purpose |
|---|---|
listLensBindings(lensOrNarrowing) |
The bind names the whole narrowing chain requires, sorted. bindOptional tokens are left out, and a parent:name reference counts as name. |
bindLens(lensOrNarrowing, bindings) |
Returns a new narrowing chain with every covered token replaced by its value; uncovered tokens stay, so binding can happen in stages. parent:name draws the value of name. A bare lens comes back unchanged. The result has the input's type (bindLens<T extends Lens | LensNarrowing>(…): T). The input is not mutated. |
A layer may not re-declare a bind name an ancestor declares; it reads the inherited one as
parent:name. validateNarrowing reports a collision or a dangling parent: reference as
invalid_binding.
A narrowing node's sources declares where a field's selectable values come from: a bare
eligibility Condition, or a SourceSpec { where?, label?, groupBy? }. Two functions turn
the declarations into option sets, and projectLens(lens, { sourceValues }) attaches the result
to each field as options.
import {
executePrismaPlan,
materializeSourceQuery,
materializeSources,
projectLens,
toSourceQueries,
} from '@inixiative/json-rules';
const narrowing: LensNarrowing = {
parent: lens,
root: {
where: { field: 'tenantId', operator: Operator.equals, value: 't-42' },
sources: { region: { field: 'active', operator: Operator.equals, value: true } },
},
};
// Against a database: one DISTINCT query per sourced field, in Prisma and SQL form.
const [query] = toSourceQueries(narrowing);
// query.path => 'User', query.field => 'region'
// query.composedWhere => the node's `where` AND the source's eligibility, narrowed as a rule is
// query.prisma => { model: 'User', distinct: ['region'], select: { region: true }, where: { AND: [...] } }
// query.sql => { sql: 'SELECT DISTINCT "t0"."region" FROM "User" AS "t0" WHERE (...)', params: ['t-42', true] }
// A query with `recheck` (a source across a bridge) returns candidates; see "Sources across a bridge".
// `steps` (a count step, a column reference) resolve through executePrismaPlan first.
const { distinct, select, where, steps } = query.prisma; // query.model === 'User'
const rows = await prisma.user.findMany({
distinct,
select,
where: steps ? await executePrismaPlan({ steps }, prisma) : where,
});
const values = materializeSourceQuery(query, rows); // { path, mapName, model, field, options: [{ value }] }
// Or from rows already fetched under the lens (relations inline):
const all = materializeSources(narrowing, users, { now });
const projection = projectLens(narrowing, { sourceValues: [values] });
// projection.User.fields.region.options => [{ value: 'eu' }, { value: 'us' }]| Function | Purpose |
|---|---|
toSourceQueries(lensOrNarrowing, options?) |
SourceQuery[], one per sourced field: { path, mapName, model, field, label?, groupBy?, composedWhere, prisma, sql, recheck? }. options is the clock (now, timeZone, weekStart) a relative date in the where compiles with — required for one, a plain usage error without it; bind a lens's binds with bindLens first. prisma.steps is present when the where needs executePrismaPlan (a count step, or a column reference to resolve): query with the where it returns. sql.sql is null with an error when SQL can't express the where. A source that reads across a bridge gets an over-fetching query and a recheck: its rows are candidates, not options (see "Sources across a bridge"). distinct is the value and a sibling label column; a grouped source or a dotted label drops it, so every label comes back and the least one is picked. |
materializeSourceQuery(query, rows, { rowShape?, lens?, now?, … }) |
One query's fetched rows as SourceValues. rowShape is 'prisma' (default: a dotted label and each groupBy axis come nested) or 'sql' (they come flat as __label / __group_i). Options are deduplicated and sorted. A query with recheck needs lens and rows holding the far side: it re-checks each candidate (check, with the clock and bindings given) and reads a bridged label or axis from the far side; a missing far side is a UsageError. |
materializeSources(lensOrNarrowing, rows, options?) |
SourceValues[] for every sourced field, from the rows the lens fetches — toLensSelect's rows as fetched, or as projectRows(…, { keepClampColumns: true }) keeps them. A viewer's projection drops what sources read: a row lacking any key a read walks — through each relation and list element to the column — that a source or a clamp on its path reads throws a UsageError (a fetch returns every key it selects, NULL as null). The path is walked down the rows — the tree is its link — each level's clamps met, and each row it reaches must meet, through check() with options, its visit's clamps, its source where narrowed as a rule is, the guards of the relations its label and axes cross and the values the lens allows — so it offers what toSourceQueries does. A scalar-list field gives one option per element; a value takes its least label. A from: 'mapDefaults' source throws (see below). |
Options never offer a value the lens disallows: projectLens drops fetched values outside a
field's allowed set. Nor do they come through a row the lens hides: each source where is
narrowed under the whole lens as narrowRule narrows a rule — every relation it crosses carries
that visit's clamps, inside an array condition (into its condition, or its filter under all,
a window or no condition) and on each hop and terminal relation of a dotted path. A clamp a rule
couldn't carry there (one narrowRule can't re-root, a to-many relation read flat) is refused, as
it is for a rule; so is a source whose query has a window toPrisma can't compile (by its shape,
before anything compiles — materializeSources refuses it too, so the two never disagree).
A source declared down a relation path offers the rows reachable from there: the path is
carried down through each relation's inverse (where the map declares one), and every clamp above
it with it, so a Tag source under User.tagAttachments.tag offers the tags a live
attachment of an in-tenant user points at. When a field should offer every row the lens lets its
model show — linked or not, what a rule may name — point the path source at the model's own
source:
root: {
where: { field: 'orgId', operator: Operator.equals, bind: 'orgId' },
relations: {
tagAttachments: {
where: { field: 'deletedAt', operator: Operator.isEmpty },
relations: { tag: { sources: { id: { from: 'mapDefaults' } } } }, // the model's own source
},
},
},
mapDefaults: {
app: {
models: {
Tag: {
where: { field: 'deletedAt', operator: Operator.isEmpty },
sources: {
id: { where: { field: 'ownerId', operator: Operator.equals, bind: 'orgId' }, label: 'name' },
},
},
},
},
},from: 'mapDefaults' resolves where it sits — mapDefaults[<this path's map>].models[<this path's model>].sources[<field>] — and takes that source's eligibility (tenancy included), label and
axes; its own where, and child layers, only narrow it. A pointer drops the clamps the path
carries down only in the layer that declares it: every layer before or after it still carries
theirs, so a child's pointer can only narrow what its parent gave, and a tenant layer added after a
pointer always narrows it. How depends on how it scopes: through mapDefaults (the model's own
clamp or source) the pointer still offers unlinked rows; through a root where the clamp can only
reach the pointer down the path, so it offers just the linked rows — and across a bridge the
clamp comes back as the query's recheck (below). Scope tenancy through mapDefaults to keep a pointer's unlinked rows.
A pointer whose model declares no source fails validateNarrowing (invalid_source) and throws
from projectLens (by path) / toSourceQueries / materializeSources, even where a layer hides
its field; projectLens(…, { by: 'model' }) and describeRuleSources read the lens without
validating it. Across a bridge it is how a picker gets options at all: the
model source compiles against the far map alone, with that map's own tenancy, where a path source
over-fetches and re-checks (see "Sources across a bridge"). A clamp a path can't carry down — a relation whose
map declares no inverse — is a LensRefusal, never an empty list. materializeSources refuses a pointer — a fetched collection can't hold unlinked
rows; query it with toSourceQueries and materializeSourceQuery.
A source whose path, where, label or an axis reads across a bridge can't be decided by one
database — each holds one side. toSourceQueries still returns a real query for the side it can
see, and marks it with recheck:
- The query over-fetches. What reads across the bridge compiles to TRUE (the compilers' over-fetch), so the query never pre-filters on what it can't see: its rows are a superset of the true options. Everything local is still decided by the database.
recheckis what the database couldn't decide — the conjuncts ofcomposedWherethat read across a bridge, ortruewhen only the label or an axis does. Ifrecheckis present, the query's rows are candidates, not options. A source that reads no bridge has norecheck.- The query selects local columns only: the value, a local label and local axes, the local
columns
recheckreads, and each crossed bridge's localonkey (under the local relations the read crosses first). It dropsdistinct, since candidates differ in what the re-check reads. A label or axis across the bridge is not selected. - Load the far side, then materialize. Put the far row inline on each candidate under its
bridge field (the
indexBridgesshape — one row, or a list where the bridge names many) and callmaterializeSourceQuery(query, rows, { lens, rowShape?, now? }). It requires every key the re-check and a bridged label or axis read, asmaterializeSourcesdoes — a candidate without the far side, a far row without a column read, or a list where the bridge names one row throws aUsageError— keeps the candidatescheck(recheck, row)holds, and reads the label and axes across the bridge from the far side, in either row shape. Withoutlensit throws. - A source past a bridge (its path crosses one) is queried against its own model's map; the
clamps above it are carried back across the bridge through the far model's bridge field, and
come back as its
recheck— so each candidate holds the near rows inline ({ id, industry, 'prisma:FanUser': [{ email, … }] }). - A re-check reading into a Json column selects the column whole (
meta: true, never a nested select into it); the re-check reads inside it in memory. - SQL rows are flat, so a bridged query whose re-check reads through a local relation has
sql.sql: null(withsql.error); run the Prisma form.
const [query] = toSourceQueries(lens, { now });
const { distinct, select, where, steps } = query.prisma; // query.model === 'User'
const candidates = await prisma.user.findMany({
distinct,
select,
where: steps ? await executePrismaPlan({ steps }, prisma) : where,
}); // a superset when `recheck` is present
const rows = query.recheck === undefined ? candidates : await loadFarSide(candidates); // your join
const values = materializeSourceQuery(query, rows, { lens, now });materializeSources over fetched rows still answers a path source across a bridge when the caller
supplies the root rows with the far side inline ({ id, email, crmId, 'salesforce:Contact': { id, industry } }); a pointer (from: 'mapDefaults') always goes through the query.
toLensSelect and projectRows fetch the rows a lens shows and cut them to it.
import { check, executePrismaPlan, narrowRule, projectRows, toLensSelect, toPrisma } from '@inixiative/json-rules';
const where = await executePrismaPlan(toPrisma(true, { lens: narrowing, now }), prisma);
const rows = await prisma.user.findMany({ where, ...toLensSelect(narrowing, { now }) });
const shown = projectRows(narrowing, rows, { now }); // what a viewer may see
// To re-test the clamps in memory later — never to return to a viewer:
const forRecheck = projectRows(narrowing, rows, { keepClampColumns: true, now });
const holds = forRecheck.filter((row) => check(narrowRule(rule, narrowing), row, { now }) === true);| Function | Purpose |
|---|---|
toLensSelect(lensOrNarrowing, options?) |
{ select } for findMany at the base model. It selects each visit's visible columns and the relations turned on there (one that is off is not fetched; the model-default tree bounds it), and every column a where on the way reads. A to-many relation carries its visit's clamps compiled as its where, so related rows come pre-narrowed — unless a clamp reads that list: a clamp reads it whole, as the database does, so it is fetched whole and projectRows cuts it. A to-one relation takes no where in Prisma, so projectRows drops one its clamp hides. A relation that shows no column — one turned on with all its columns hidden, or one a clamp reads only for presence or a count — is fetched by its key alone — the join key, else id — even a hidden one, as a clamp's columns are; never another column. The fetch carries it for the re-check and a viewer's projection drops it; a model with no key is not fetched, and presence on it can't be re-checked from fetched rows. A root that shows no column is selected by its id likewise. Bridges are skipped. It also selects what each projected source reads — the value, its label and axes, and every column and relation its option query's condition reads — as it does a clamp's columns, so materializeSources over the fetched rows offers what the database does; a viewer's projection drops them. For a source across a bridge that is its local reads: what it reads on this side, hidden or not, and each crossed bridge's local on key, so the rows plus the far side loaded under its bridge field answer it. A to-many relation's clamp that needs a counting step (a count or an aggregate), or has a window toPrisma can't compile, is refused (a LensRefusal, which validateNarrowing reports) before anything compiles. options is the clock for compiling the clamps. The root's own clamps are the query's where: toPrisma(rule, { lens }). |
projectRows(lensOrNarrowing, rows, options?) |
Rows cut to what the lens shows, recursively. Hidden columns, and relations that are off or omitted, are removed. A row a visit's where hides is dropped from the root or a list, and a to-one row becomes null. keepClampColumns: true keeps the columns those wheres and the projected sources read, even hidden ones, and a hidden to-one row, or a hidden row of a list a clamp reads, as those columns alone, so check(narrowRule(rule, lens), row) re-tests the clamps as the database does, for any rule the lens admits; that output carries hidden values, so never return it to a viewer. The other options (now, bindings) are what each where is checked with. Plain JSON in and out. |
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 — a path read follows own properties one segment at a time and doesn't fan out across array elements. Use a numeric index (crm:MarketingEvent.0.campaignorcrm:MarketingEvent[0].campaign) orarrayOperatoron thefield:side to iterate. A name onObject.prototype(constructor,toString) reads as absent. - Bridge keys are plain object properties. The engine doesn't consult
lens.bridgesat eval time — callers structuredatacorrectly using the schema as a guide. UseindexBridges(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: 'a@b.com', 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: 'a@b.com' };
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.
See examples/basic-validation.ts, examples/array-operations.ts, examples/aggregate-rules.ts, examples/date-operations.ts, and examples/advanced-features.ts.
MIT