@inixiative/json-rules 2.27.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -204,7 +204,8 @@ Computes `sum` or `avg` of an array and compares the result to a value.
204
204
  }
205
205
  ```
206
206
 
207
- Empty-array semantics: `sum([]) = 0`, `avg([]) = null` (comparison fails).
207
+ Empty-array semantics: `sum([]) = 0` and `avg([]) = 0`. A NULL or absent array is empty, and
208
+ NULL items are skipped, as SQL's `SUM` / `AVG` skip them.
208
209
 
209
210
  ### Date Rule
210
211
 
@@ -288,8 +289,9 @@ Compilers resolve expressions to concrete `Date` bounds at compile time, so
288
289
  ### Windowing — first/last with `orderBy` / `take` / `skip`
289
290
 
290
291
  Array and aggregate rules accept an ordered-window selector that runs **before** the
291
- predicate. Pipeline: order → skip → take. Direction comes from `orderBy.dir`, so
292
- "the last fanMission" is `order by date desc, take 1`.
292
+ predicate. Pipeline: filter → order → skip → take. Direction comes from `orderBy.dir`, so
293
+ "the last fanMission" is `order by date desc, take 1`. NULLs sort last in both directions, so
294
+ `orderBy views desc, take 1` is the largest non-null value.
293
295
 
294
296
  ```ts
295
297
  // "user whose last fanMission was more than 30 days ago"
@@ -302,7 +304,8 @@ predicate. Pipeline: order → skip → take. Direction comes from `orderBy.dir`
302
304
  }
303
305
  ```
304
306
 
305
- `orderBy` is a non-empty array of `{ field, dir: 'asc' | 'desc' }` (multi-key);
307
+ `filter` is a condition each element must pass to enter the window (`narrowRule` puts a lens
308
+ grant there under `all`). `orderBy` is a non-empty array of `{ field, dir: 'asc' | 'desc' }` (multi-key);
306
309
  `take`/`skip` are non-negative integers. **Empty-window semantics are author-driven**:
307
310
  `all` is vacuously true on an empty window, `atLeast: 1` (or `any`) is false. To require
308
311
  "the windowed element matches **and** one exists," combine `all` with `notEmpty` / `atLeast: 1`.
@@ -313,7 +316,7 @@ predicate. Pipeline: order → skip → take. Direction comes from `orderBy.dir`
313
316
  > It rewrites to `every` / `some` (e.g. the rule above → `{ fanMissions: { every: { completedAt:
314
317
  > { lt: <now-30d> } } } }`). Any other windowed rule — `take > 1`, `skip`, multi-key
315
318
  > `orderBy`, a different/non-monotonic condition, or a misaligned direction — throws a clear
316
- > "unsupported" error. `toSql()` does not compile windowing at all (no relation subqueries in
319
+ > "unsupported" error, and so does any `filter`. `toSql()` does not compile windowing at all (no relation subqueries in
317
320
  > a `WHERE` fragment). Evaluate the unsupported cases in memory with `check()`.
318
321
 
319
322
  ## Path Semantics
@@ -362,8 +365,8 @@ bare `path` is always the root context (`options.context`, defaulting to the roo
362
365
  ```
363
366
 
364
367
  A ref deeper than the nesting (`$$.` at the top level, `$$$.` one array deep) throws in
365
- `check()`, is a `scope_out_of_bounds` issue from `validateRule`, and a violation from
366
- `checkRuleAgainstLens`. A reachable ancestor that lacks the named key fails the comparison
368
+ `check()`, is a `scope_out_of_bounds` issue from `validateRule`, and an error from
369
+ `validateRuleInLens`. A reachable ancestor that lacks the named key fails the comparison
367
370
  like any absent field.
368
371
 
369
372
  `toSql()` keeps `path: '$.x'` as a same-row column comparison. Every other scope ref — a
@@ -395,8 +398,8 @@ instead of `now`:
395
398
  offset: { value: { ahead: { days: 4 } } } }
396
399
  ```
397
400
 
398
- `resolveBindings` resolves an offset's bind as it does the comparison value's, and
399
- `bindingNames` / `requiredBindings` list it. A date offset read per row (a column holding
401
+ `bindRule` resolves an offset's bind as it does the comparison value's, and
402
+ `listBindings` lists it. A date offset read per row (a column holding
400
403
  `{ ago: … }`) is check-only; to size a shift from the row, read the amount instead.
401
404
 
402
405
  Any relative-date unit — in a `value` expression or an offset's rolling shift — is a number or a
@@ -426,7 +429,7 @@ in double precision on every rail.
426
429
  | `$.` date offset (a stored `{ ago }`) | yes | throws | throws |
427
430
  | `$$.` anything | yes | throws | throws |
428
431
 
429
- `checkRuleAgainstLens` gates offset and magnitude refs like `path` (they must resolve through
432
+ `validateRuleInLens` gates offset and magnitude refs like `path` (they must resolve through
430
433
  the lens and read a number), and an offset must fit the field's kind: a number on a numeric
431
434
  field, a rolling shift on a DateTime.
432
435
 
@@ -438,9 +441,9 @@ format grows a node type, and it goes blind silently.
438
441
 
439
442
  | Function | Purpose |
440
443
  | --- | --- |
441
- | `requiredBindings(rule)` | Names a bindings map must cover — every `{ bind }` token not marked `bindOptional`. A name optional at one leaf and required at another is required. |
442
- | `bindingNames(rule)` | Every `{ bind }` name in the tree, optional or not — what a lens declares. |
443
- | `resolveBindings(rule, bindings)` | Substitutes covered binds with their values, leaving uncovered tokens in place (partial resolution). |
444
+ | `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. |
445
+ | `listBindings(rule)` | Every `{ bind }` name in the tree, optional or not, sorted — what a lens declares. |
446
+ | `bindRule(rule, bindings)` | Substitutes covered binds with their values, leaving uncovered tokens in place (partial resolution). |
444
447
 
445
448
  A leaf may mark its bind optional: `{ field, operator, bind: 'region', bindOptional: true }`. An
446
449
  unsupplied required bind is a caller bug — `check()` throws, and both compilers refuse a
@@ -482,6 +485,26 @@ check(rule, {
482
485
  }); // true
483
486
  ```
484
487
 
488
+ ### Value Comparison
489
+
490
+ Values compare as JSON does. Lists and objects compare by value, deeply. Types never cross:
491
+ `"3"` never equals `3`, and an ordered comparison or a range holds only between two numbers, two
492
+ strings or two dates. A `Date` field value compares as a DateTime without help. To compare a
493
+ string literal against a number or Boolean field, stamp the rule's `coerceType` (`coerceRule`
494
+ does it from a lens). The compilers refuse a string literal on a number or Boolean column
495
+ without one.
496
+
497
+ An enum compares against its declared values. A case-insensitive comparison, a string operator,
498
+ a pattern or an ordered comparison on an enum compiles to a membership test over the declared
499
+ values `check()` would match (plus the NULL arm for a negation). The field map must list the
500
+ values (prisma-map does).
501
+
502
+ ```ts
503
+ // map: { models: { U: { fields: { role: { kind: 'enum', type: 'Role' } } } }, enums: { Role: ['admin', 'member'] } }
504
+ toSql({ field: 'role', operator: Operator.startsWith, value: 'adm' }, { map, model: 'U' });
505
+ // { sql: '"t0"."role"::text = ANY($1)', params: [['admin']], joins: [] }
506
+ ```
507
+
485
508
  ### Custom Errors
486
509
 
487
510
  Every rule can define its own error:
@@ -511,13 +534,13 @@ const plan = toPrisma({
511
534
  // plan.steps => [{ operation: 'where', where: { status: { equals: 'active' } } }]
512
535
  ```
513
536
 
514
- 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.
537
+ 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.
515
538
 
516
539
  ```ts
517
540
  import {
518
541
  ArrayOperator,
519
542
  Operator,
520
- executePrismaQueryPlan,
543
+ executePrismaPlan,
521
544
  toPrisma,
522
545
  } from '@inixiative/json-rules';
523
546
 
@@ -535,7 +558,7 @@ const plan = toPrisma(
535
558
  { map, model: 'User' },
536
559
  );
537
560
 
538
- const where = await executePrismaQueryPlan(plan, { post: prisma.post });
561
+ const where = await executePrismaPlan(plan, { post: prisma.post });
539
562
  await prisma.user.findMany({ where });
540
563
  ```
541
564
 
@@ -552,10 +575,53 @@ const plan = toPrisma(
552
575
  { map, model: 'User' },
553
576
  );
554
577
 
555
- const where = await executePrismaQueryPlan(plan, { order: prisma.order });
578
+ const where = await executePrismaPlan(plan, { order: prisma.order });
556
579
  await prisma.user.findMany({ where }); // users whose orders sum to more than 1000
557
580
  ```
558
581
 
582
+ A user with no orders sums to 0, as in `check()`: a comparison that holds at 0 selects the
583
+ parents outside the groups where it fails, so childless parents stay in.
584
+
585
+ ### Json null checks
586
+
587
+ A Json column holds a DB NULL or a JSON `null`, and a path inside it can be absent — `check()`
588
+ reads all three as null, and Prisma matches them together only with its `AnyNull` instance,
589
+ which it knows by identity. `toPrisma()` takes it from your installed `@prisma/client` (an
590
+ optional peer dependency), so there is nothing to configure; set `prismaOptions.anyNull` only to
591
+ use a different client's. Without `@prisma/client`, a Json null check throws.
592
+
593
+ Prisma filters follow the column kind the map
594
+ declares: on Json, `contains` / `startsWith` / `endsWith` become `string_contains` / … and `in`
595
+ becomes one `equals` per value; on a scalar list, `contains` becomes `has` and emptiness
596
+ `isEmpty`; `caseInsensitive` adds `mode: 'insensitive'` on text only.
597
+
598
+ ## Engine Globals
599
+
600
+ `engineGlobals` holds process-wide defaults. Keys are dotted paths into one state object.
601
+
602
+ | Key | Default | Governs |
603
+ | --- | --- | --- |
604
+ | `string.caseInsensitive` | `false` | Default for a rule's `caseInsensitive`. A rule's own flag wins. Read by `check()`, `toSql()` and `toPrisma()`. |
605
+ | `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. |
606
+ | `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. |
607
+ | `prismaOptions.anyNull` | your `@prisma/client`'s | Prisma's `AnyNull` for Json null checks (see above); set it only to use another client's. |
608
+
609
+ ```ts
610
+ import { engineGlobals } from '@inixiative/json-rules';
611
+
612
+ engineGlobals.set('string.caseInsensitive', true);
613
+ engineGlobals.get('string.caseInsensitive'); // true
614
+ engineGlobals.set('prismaOptions.datasource.provider', 'mysql');
615
+ engineGlobals.reset(); // back to the defaults
616
+
617
+ // A scoped override: merged over the current state for the duration of a synchronous callback.
618
+ const result = engineGlobals.with({ string: { caseInsensitive: true } }, () => check(rule, data));
619
+ ```
620
+
621
+ `with()` restores the previous state when the callback returns or throws. The callback must be
622
+ synchronous: one that returns a Promise throws. `set()` copies plain data and keeps a class
623
+ instance (like `AnyNull`) as given, since Prisma recognizes it by identity.
624
+
559
625
  ## PostgreSQL SQL Generation
560
626
 
561
627
  `toSql()` converts a rule into a parameterized PostgreSQL `WHERE` clause.
@@ -588,6 +654,13 @@ const result = toSql(
588
654
  // result.joins => ['LEFT JOIN "User" AS "t1" ON "t1"."id" = "t0"."authorId"']
589
655
  ```
590
656
 
657
+ `map` is a `FieldMap`, or a `FieldMapSet` (a lens works) with `mapName` naming the map to read,
658
+ as `toPrisma()` takes it. A set without `mapName` throws.
659
+
660
+ ```ts
661
+ toSql(rule, { map: lens, mapName: 'prisma', model: 'Post' });
662
+ ```
663
+
591
664
  ## Backend Support Matrix
592
665
 
593
666
  Not every backend supports every rule shape.
@@ -599,13 +672,13 @@ Not every backend supports every rule shape.
599
672
  | Logical operators | Yes | Yes | Yes |
600
673
  | Array `all` / `any` / `none` | Yes | Yes | No |
601
674
  | Array `atLeast` / `atMost` / `exactly` | Yes | Yes, with `map` + `model` | No |
602
- | Array `empty` / `notEmpty` | Yes | Yes | Yes |
675
+ | Array `empty` / `notEmpty` | Yes | Yes | Yes (list and Json columns, not relations) |
603
676
  | Aggregate `sum` / `avg` — primitive or object array | Yes | No | Yes |
604
677
  | Aggregate `sum` / `avg` — relation list | Yes | Yes, with `map` + `model` | No |
605
678
  | Date comparisons | Yes | Most | Yes |
606
679
  | Date expressions (`ago`/`ahead`/`this`/`last`/`next`/`start`/`end`) + `within` | Yes | Yes | Yes |
607
680
  | `dayIn` / `dayNotIn` | Yes | No | Yes |
608
- | Windowing (`orderBy` / `take` / `skip`) | Yes | Extremal (`take:1`, aligned) | No |
681
+ | Windowing (`filter` / `orderBy` / `take` / `skip`) | Yes | Extremal (`take:1`, aligned, no `filter`) | No |
609
682
  | `path: '$.field'` current-element / same-row refs | Yes | No | Yes |
610
683
  | `offset` and unit amounts — value, bind or context | Yes | Yes | Yes |
611
684
  | `offset` and unit amounts — `$.` row refs | Yes | No | Yes (not a date offset's) |
@@ -632,7 +705,8 @@ and `{ rel: { col: { equals: null } } }` only matches when the relation exists.
632
705
  also carries `{ rel: { is: null } }` for each optional to-one hop on the path (licensed by the
633
706
  relation entry's `isRequired: false`) — `profile.bio notEquals 'x'` compiles to
634
707
  `{ OR: [{ profile: { bio: { not: 'x' } } }, { profile: { bio: { equals: null } } }, { profile: { is: null } }] }`,
635
- matching check() (a missing hop reads as `undefined`) and toSql (LEFT JOIN + `IS NULL`).
708
+ matching check() (a missing hop reads as NULL) and toSql (LEFT JOIN + `IS NULL`). `equals null`
709
+ and `in [null, …]` carry the same hop arms: a user with no profile has a NULL `profile.bio`.
636
710
 
637
711
  `toPrisma()` can only add the null arm when it knows the column is nullable —
638
712
  an `equals: null` on a NOT NULL column is a Prisma validation error. Nullability
@@ -667,8 +741,10 @@ positive operator, ask for them:
667
741
  - `dayIn` and `dayNotIn` are not supported by Prisma output
668
742
  - `path: '$.field'` column-to-column comparisons are not supported by Prisma `WHERE`; no scope ref (`$$.` path, prefixed `field`) compiles
669
743
  - count-based and aggregate relation operators require `{ map, model }`
670
- - aggregate rules with `notBetween` are not supported by Prisma output
671
744
  - aggregate rules on JSON/native stored arrays are not supported by Prisma — use `toSql()` or `check()` for those
745
+ - element conditions (`all` / `any` / `none` / counts) over a scalar list or a Json array are not supported by Prisma; test a list's membership with `contains`
746
+ - a field path through a to-many relation (`posts.title`) is an error on both compilers — compare its rows with an array rule on `posts`
747
+ - Prisma loads a NULL scalar-list column as `[]`, so `check()` 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
672
748
 
673
749
  ### SQL Limitations
674
750
 
@@ -697,52 +773,108 @@ type Condition<TRuleValue = RuleValue, TDateValue = DateRuleValue> =
697
773
  | boolean;
698
774
  ```
699
775
 
700
- Useful exports:
701
-
702
- - `check`
703
- - `toPrisma`
704
- - `executePrismaQueryPlan`
705
- - `toSql`
706
- - `validateRule`
707
- - `assertValidRule`
708
- - `Operator`
709
- - `ArrayOperator`
710
- - `DateOperator`
711
- - `Condition`
712
- - `StrictCondition`
713
- - `Rule`
714
- - `AggregateRule`
715
- - `AggregateMode`
716
- - `ArrayRule`
717
- - `DateRule`
776
+ The public API has one name per operation. [docs/VERBS.md](./docs/VERBS.md) lists every
777
+ exported function by verb.
778
+
779
+ Rules:
780
+
781
+ - `check`, `toPrisma`, `executePrismaPlan`, `toSql`
782
+ - `validateRule`, `assertValidRule`, `bindRule`, `listBindings`
783
+ - `Operator`, `ArrayOperator`, `DateOperator`
784
+ - `Condition`, `StrictCondition`, `Rule`, `AggregateRule`, `AggregateMode`, `ArrayRule`, `DateRule`, `Row`, `CheckData`
785
+ - `GroupByStep`, `WhereStep`, `PrismaStep`, `PrismaWhere`, `StepRef` (a Prisma plan's steps); `ScopeRef`, `ScopedRef`, `ScopeOutOfBounds` (scope refs)
786
+ - `CheckOptions`, `CompileOptions`, `ToPrismaOptions`, `ToSqlOptions`, `ToSqlResult`, `ToPrismaResult`, `ValidateRuleOptions`, `ListBindingsOptions`, `ValidationIssue`, `ValidationResult`
787
+ - every rule-shape type in `src/types.ts` (`StrictRule`, `DateExpr`, `RelativeUnits`, …), listed in `index.ts`
788
+ - `engineGlobals`, `EngineGlobalsState`, `PrismaProvider`, `FuzzyConfig`
718
789
 
719
790
  Lens & bridges:
720
791
 
721
- - `Lens`, `LensNarrowing`, `ModelNarrowing`, `ModelDefaultNarrowing`, `NarrowingDefaults`, `EnumNarrowing`
722
- - `FieldMapSet`, `Bridge`, `BridgeEndpoint`, `BridgeCardinality`
723
- - `createLens`, `stitchFieldMaps`, `validateFieldMap`, `validateFieldMapSet`
724
- - `validateNarrowing`, `projectByPath`, `exposedSurface`, `describeRule`, `checkRuleAgainstLens`, `applyLens`
725
- - `PathProjection`, `ProjectedVisit`, `RuleDescription`
726
- - `buildBridgeDictionary`
792
+ - `Lens`, `LensNarrowing`, `ModelNarrowing`, `ModelDefaultNarrowing`, `NarrowingDefaults`, `EnumNarrowing`, `SourceSpec`, `SourceEntry`
793
+ - `FieldMap`, `FieldMapEntry`, `ModelEntry`, `SourceOption`, `FieldMapSet`, `Bridge`, `BridgeEndpoint`, `BridgeCardinality`, `BridgeDictionary`
794
+ - `createLens`, `storeLens`, `composeLens`, `StoredLens`, `stitchFieldMaps`, `indexBridges`, `validateFieldMaps`, `assertValidFieldMaps`
795
+ - `validateNarrowing`, `assertValidNarrowing`, `validateRuleInLens`, `narrowRule`, `coerceRule`
796
+ - `bindLens`, `listLensBindings`
797
+ - `projectLens`, `walkLensPath`, `describeRule`, `describeRuleSources`
798
+ - `toSourceQueries`, `materializeSources`, `materializeSourceQuery`
799
+ - `PathProjection`, `ProjectedVisit`, `ProjectLensOptions`, `LensPathHop`, `LensPathResolution`, `RuleDescription`, `RuleSourceDescription`, `SourceQuery`, `SourcePrismaQuery`, `SourceSqlQuery`, `SourceSelect`, `SourceValues`, `SourceRowShape`, `MaterializeSourceQueryOptions`
800
+
801
+ A lens has three forms, each with its own job:
802
+
803
+ - **Composed** — a `Lens`, or a `LensNarrowing` whose `parent` holds the layer above it as an
804
+ object, down to the base lens. Every evaluator takes this form.
805
+ - **Stored** — `StoredLens`, one record per layer: its `id`, `parents` (the ids of every layer it
806
+ composes with, the base lens first) and its own part. The base lens is the root-most record,
807
+ stored as itself with no parents. `storeLens(lens, ids)` writes a composed lens out as records;
808
+ `composeLens(id, records)` reads them back — fetch the layer, then the ids it lists — validating
809
+ each layer against the ones above it and failing closed on a missing record, a base out of
810
+ place, or a parent whose own list disagrees.
811
+ - **Projected** — what a lens exposes, which never leads back to the lens:
812
+ - `projectLens(lens)` returns `Record<dottedPath, ProjectedVisit>` for per-path checks where
813
+ sibling paths to the same model diverge.
814
+ - `projectLens(lens, { by: 'model' })` returns the leak-safe total surface *as a Lens* — every
815
+ reachable model with the full narrowing applied, unioned per model, `where` stripped. Use it as
816
+ the server→client builder surface; it never exposes the raw lens.
727
817
 
728
- Two shapes come out of a lens, and they are different things:
818
+ ```ts
819
+ const records = storeLens(grantLens, ['user', 'org-acme', 'grant-7']); // persist each record
820
+ // later: fetch 'grant-7', then the ids in its `parents`
821
+ const lens = composeLens('grant-7', { user, 'org-acme': orgAcme, 'grant-7': grant7 });
822
+ ```
823
+
824
+ ### Operator Catalog
729
825
 
730
- - **Lens** (maps intact — the navigable graph): `exposedSurface(lensOrNarrowing)`
731
- returns the leak-safe total exposed surface *as a Lens* — every reachable model
732
- with the full narrowing applied (root + path-specific + `mapDefaults`), unioned
733
- per model, `where` stripped. Use it as the server→client builder surface; it
734
- never exposes the raw, un-narrowed lens.
735
- - **Projection** (path-keyed view — graph flattened away): `projectByPath(lens)`
736
- returns `Map<dottedPath, ProjectedVisit>` for per-path checks where sibling
737
- paths to the same model diverge.
826
+ What a rule builder can offer for a field. The catalog's constants are `FieldKind`,
827
+ `RuleTarget`, `ValueShape`, `NUMERIC_KINDS` and `ALL_KINDS`; `getValueShape(operator, family)` takes an
828
+ `OperatorFamily` (`'field' | 'date' | 'array'`).
738
829
 
739
- Operator catalog (builder-facing):
830
+ ```ts
831
+ import {
832
+ getAggregateOperators,
833
+ getArrayOperators,
834
+ getOperatorsForKind,
835
+ getValueShape,
836
+ } from '@inixiative/json-rules';
740
837
 
741
- - `FIELD_OPERATOR_CATALOG`, `DATE_OPERATOR_CATALOG`, `ARRAY_OPERATOR_CATALOG`
742
- - `FieldKind`, `RuleTarget`, `ValueShape`
743
- - `NUMERIC_KINDS`, `ORDERABLE_KINDS`, `STRINGY_KINDS`, `EQUATABLE_KINDS`, `ALL_KINDS`
744
- - `getOperatorsForKind`, `getArrayOperators`, `getValueShape`, `isOperatorSupportedForTarget`
745
- - `WINDOW_SELECTOR`, `WindowSupport`, `getWindowSupport` (windowing fields + per-ruleType×target support)
838
+ getOperatorsForKind('Int', 'toPrisma');
839
+ // { field: ['equals', 'notEquals', 'lessThan', …], date: [] } — operators that kind takes on that target
840
+
841
+ getArrayOperators('toSql'); // ['empty', 'notEmpty']
842
+ getAggregateOperators(); // ['equals', 'notEquals', 'lessThan', …, 'between', 'notBetween']
843
+
844
+ getValueShape('between', 'field'); // 'range'
845
+ getValueShape('between', 'date'); // 'dateRange'
846
+ ```
847
+
848
+ | Function | Purpose |
849
+ | --- | --- |
850
+ | `getOperatorsForKind(kind, target?)` | `{ field, date }`: the field and date operators a `FieldKind` takes, narrowed to one target when given. |
851
+ | `getArrayOperators(target?)` | The array operators, narrowed to one target when given. |
852
+ | `getAggregateOperators()` | The comparisons an aggregate rule takes. Every target compiles all of them. |
853
+ | `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. |
854
+
855
+ To ask whether a whole rule runs on a target (windows, scope refs and operators together), use
856
+ `validateRule(rule, { target })`.
857
+
858
+ ### Scope Refs
859
+
860
+ `parseScopeRef` and `readScopeRef` read the `$`-prefixed refs described in
861
+ [Scope References](#scope-references), for code that resolves refs of its own.
862
+
863
+ ```ts
864
+ import { parseScopeRef, readScopeRef } from '@inixiative/json-rules';
865
+
866
+ parseScopeRef('$$.maxQty'); // { depth: 2, path: 'maxQty' }
867
+ parseScopeRef('maxQty'); // null — a bare ref
868
+
869
+ // scopes run outermost first; `$.` is the last, `$$.` the one before it
870
+ readScopeRef('$$.maxQty', [rootRow, order, lineItem]); // { scope: order, path: 'maxQty' }
871
+ readScopeRef('maxQty', [rootRow, order]); // { scope: order, path: 'maxQty' }
872
+ readScopeRef('$$$.x', [rootRow, order]);
873
+ // { outOfBounds: "Scope ref '$$$.x' needs depth 3 but only 2 scopes are in reach" }
874
+ ```
875
+
876
+ `readScopeRef` returns the scope a ref names and the path left to read in it. It never throws:
877
+ a ref deeper than the stack comes back as `{ outOfBounds }` with the message.
746
878
 
747
879
  ## Error Handling
748
880
 
@@ -795,7 +927,7 @@ check(
795
927
 
796
928
  ## Lens & Multi-Source Data
797
929
 
798
- > **For the full v2.2 lens guide — including the three anchor layers for `where`
930
+ > **For the full lens guide — including the three anchor layers for `where`
799
931
  > (root, model-default, relation-descent), the `all` operator filter-first
800
932
  > trick, per-model enum narrowing, and a validate-then-apply usage pattern —
801
933
  > see [docs/LENS.md](./docs/LENS.md).**
@@ -810,10 +942,10 @@ trust boundaries (platform → org → space → subtenant → client). Each lay
810
942
  boundary is **enforced, not documented**:
811
943
 
812
944
  - a rule authored against a lens provably can't reference outside it —
813
- `checkRuleAgainstLens`, at author time;
945
+ `validateRuleInLens`, at author time;
814
946
  - the row-scope **`where` is the grant, applied server-side at execution** via
815
- `applyLens` — the authored rule never sees it and can't escape it;
816
- - what reaches an untrusted party reveals nothing hidden — `exposedSurface`.
947
+ `narrowRule` — the authored rule never sees it and can't escape it;
948
+ - what reaches an untrusted party reveals nothing hidden — `projectLens(…, { by: 'model' })`.
817
949
 
818
950
  A lens defines a **surface area**, reused for distinct, separately-enforced
819
951
  constraints that may **diverge**: the *data-flow* surface (what you receive / pass
@@ -899,17 +1031,104 @@ const narrowing: LensNarrowing = {
899
1031
  };
900
1032
  ```
901
1033
 
902
- 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](./docs/LENS.md) for the full anchor semantics.
1034
+ 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. Under the `all` array operator the grant 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](./docs/LENS.md) for the full anchor semantics.
903
1035
 
904
1036
  ### Lens Utilities
905
1037
 
906
1038
  | Function | Purpose |
907
1039
  | --- | --- |
908
- | `validateNarrowing(narrowing)` | Throws on structural or chain violations (incl. unresolvable `where` paths and items invisible from ancestors). Call at narrowing construction. |
909
- | `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](./docs/LENS.md). |
910
- | `ruleSourceValues(lens, rule)` | The values a rule names at each source the lens declares, keyed like `projectByPath` (`path` + `field`, with the source's `mapName` / `model`). Resolved via `walkLensPath`, so `mapDefaults` sources answer wherever their model appears. `dynamic: true` when the set can't be enumerated: a `path` / `bind` leaf, 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`. |
911
- | `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. |
912
- | `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`. |
1040
+ | `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), `conflicting_selection`, `wrong_kind`, `value_not_allowed`, `invalid_source`, `invalid_binding`, plus the lens gate's codes for a `where`. `assertValidNarrowing` throws instead. Call at narrowing construction. |
1041
+ | `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 } })`. |
1042
+ | `projectLens(lens)` | Returns `Record<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](./docs/LENS.md). |
1043
+ | `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 their model appears. `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`. |
1044
+ | `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`). The security gate. |
1045
+ | `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 `ValidationIssue`s. For routing and UX; `validateRuleInLens` stays the gate. |
1046
+ | `walkLensPath(lens, path)` | Resolves one dotted path through the lens hop by hop: `{ outcome: 'resolved', hops, terminal, jsonSubPath }`, or `hidden` / `missing` / `pastScalar` with the failing `index`. |
1047
+ | `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 grant 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`. |
1048
+ | `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. |
1049
+
1050
+ ```ts
1051
+ import { coerceRule } from '@inixiative/json-rules';
1052
+
1053
+ // User.age is { kind: 'scalar', type: 'Int' } in the lens's map
1054
+ coerceRule({ field: 'age', operator: Operator.equals, value: '3' }, lens);
1055
+ // { field: 'age', operator: 'equals', value: '3', coerceType: 'Int' }
1056
+ ```
1057
+
1058
+ ### Bindings in a Lens
1059
+
1060
+ A narrowing's `where` and `sources` conditions can hold `{ bind }` tokens, filled per request.
1061
+
1062
+ ```ts
1063
+ import { bindLens, listLensBindings } from '@inixiative/json-rules';
1064
+
1065
+ const narrowing: LensNarrowing = {
1066
+ parent: lens,
1067
+ root: { where: { field: 'tenantId', operator: Operator.equals, bind: 'tenantId' } },
1068
+ };
1069
+
1070
+ listLensBindings(narrowing); // ['tenantId']
1071
+ const bound = bindLens(narrowing, { tenantId: 't-42' });
1072
+ // bound.root.where => { field: 'tenantId', operator: 'equals', value: 't-42' }
1073
+ ```
1074
+
1075
+ | Function | Purpose |
1076
+ | --- | --- |
1077
+ | `listLensBindings(lensOrNarrowing)` | The bind names the whole narrowing chain requires, sorted. `bindOptional` tokens are left out, and a `parent:name` reference counts as `name`. |
1078
+ | `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 input is not mutated. |
1079
+
1080
+ A layer may not re-declare a bind name an ancestor declares; it reads the inherited one as
1081
+ `parent:name`. `validateNarrowing` reports a collision or a dangling `parent:` reference as
1082
+ `invalid_binding`.
1083
+
1084
+ ### Option Sources
1085
+
1086
+ A narrowing node's `sources` declares where a field's selectable values come from: a bare
1087
+ eligibility `Condition`, or a `SourceSpec` `{ where?, label?, groupBy? }`. Two functions turn
1088
+ the declarations into option sets, and `projectLens(lens, { sourceValues })` attaches the result
1089
+ to each field as `options`.
1090
+
1091
+ ```ts
1092
+ import {
1093
+ materializeSourceQuery,
1094
+ materializeSources,
1095
+ projectLens,
1096
+ toSourceQueries,
1097
+ } from '@inixiative/json-rules';
1098
+
1099
+ const narrowing: LensNarrowing = {
1100
+ parent: lens,
1101
+ root: {
1102
+ where: { field: 'tenantId', operator: Operator.equals, value: 't-42' },
1103
+ sources: { region: { field: 'active', operator: Operator.equals, value: true } },
1104
+ },
1105
+ };
1106
+
1107
+ // Against a database: one DISTINCT query per sourced field, in Prisma and SQL form.
1108
+ const [query] = toSourceQueries(narrowing);
1109
+ // query.path => 'User', query.field => 'region'
1110
+ // query.composedWhere => the node's `where` AND the source's eligibility
1111
+ // query.prisma => { model: 'User', distinct: ['region'], select: { region: true }, where: { AND: [...] } }
1112
+ // query.sql => { sql: 'SELECT DISTINCT "t0"."region" FROM "User" AS "t0" WHERE (...)', params: ['t-42', true] }
1113
+ const { distinct, select, where } = query.prisma;
1114
+ const rows = await prisma.user.findMany({ distinct, select, where });
1115
+ const values = materializeSourceQuery(query, rows); // { path, mapName, model, field, options: [{ value }] }
1116
+
1117
+ // Or from rows already fetched under the lens (relations inline):
1118
+ const all = materializeSources(narrowing, users, { now });
1119
+
1120
+ const projection = projectLens(narrowing, { sourceValues: [values] });
1121
+ // projection.User.fields.region.options => [{ value: 'eu' }, { value: 'us' }]
1122
+ ```
1123
+
1124
+ | Function | Purpose |
1125
+ | --- | --- |
1126
+ | `toSourceQueries(lensOrNarrowing)` | `SourceQuery[]`, one per sourced field: `{ path, mapName, model, field, label?, groupBy?, composedWhere, prisma, sql }`. `prisma.steps` is present when the where needs `executePrismaPlan`. `sql.sql` is `null` with an `error` when SQL can't express the where. A grouped source drops `distinct`. |
1127
+ | `materializeSourceQuery(query, rows, { rowShape? })` | 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. |
1128
+ | `materializeSources(lensOrNarrowing, rows, options?)` | `SourceValues[]` for every sourced field, from rows fetched under the lens. Rows are taken as already lens-scoped, so only each source's own eligibility is applied, through `check()` with `options`. A scalar-list field gives one option per element. |
1129
+
1130
+ Options never offer a value the lens disallows: `projectLens` drops fetched values outside a
1131
+ field's allowed set.
913
1132
 
914
1133
  ### Evaluating Across Bridges
915
1134
 
@@ -917,8 +1136,8 @@ Composition across chained narrowings is pure intersection. `where` clauses are
917
1136
 
918
1137
  **Limitations to know:**
919
1138
 
920
- - **1-many bridge arrays are not iterable mid-path.** `field: 'crm:MarketingEvent.campaign'` or `path: 'crm:MarketingEvent.campaign'` returns `undefined` when the bridge value is an array — `lodash.get` can't fan out across array elements. Use a numeric index (`crm:MarketingEvent.0.campaign`) or `arrayOperator` on the `field:` side to iterate.
921
- - **Bridge keys are plain object properties.** The engine doesn't consult `lens.bridges` at eval time — callers structure `data` correctly using the schema as a guide. Use `buildBridgeDictionary(lens, rawForeign)` to pre-index foreign rows by `on` field, then embed under bridge keys per anchor row.
1139
+ - **1-many bridge arrays are not iterable mid-path.** `field: 'crm:MarketingEvent.campaign'` or `path: 'crm:MarketingEvent.campaign'` returns `undefined` when the bridge value is an array — 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.campaign` or `crm:MarketingEvent[0].campaign`) or `arrayOperator` on the `field:` side to iterate. A name on `Object.prototype` (`constructor`, `toString`) reads as absent.
1140
+ - **Bridge keys are plain object properties.** The engine doesn't consult `lens.bridges` at eval time — callers structure `data` correctly using the schema as a guide. Use `indexBridges(lens, rawForeign)` to pre-index foreign rows by `on` field, then embed under bridge keys per anchor row.
922
1141
 
923
1142
 
924
1143
  `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: