@inixiative/json-rules 3.1.1 → 3.3.0

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
@@ -43,7 +43,7 @@ check(rule, { age: 16 }); // "Must be 18 or older"
43
43
  - `if` / `then` / `else`
44
44
  - array validation against nested object elements
45
45
  - array aggregates — `sum` and `avg` across numeric arrays or relation lists
46
- - ordered windowing — first/last `N` with `orderBy` / `take` / `skip` (check-only)
46
+ - ordered windowing — first/last `N` with `orderBy` / `take` / `skip` (`check()`; `toPrisma()` compiles the extremal case and a `filter` alone)
47
47
  - date comparisons with timezone-aware runtime evaluation
48
48
  - relative & calendar date expressions — "last 30 days", "this month" — via `within` and `ago`/`ahead`/`this`/`last`/`next`
49
49
  - relative value references via `path`, and `$$.` scope refs up through nested arrays
@@ -73,7 +73,9 @@ check(rule, { age: 16 }); // "Must be 18 or older"
73
73
  - `exists`
74
74
  - `notExists`
75
75
  - `startsWith`
76
+ - `notStartsWith`
76
77
  - `endsWith`
78
+ - `notEndsWith`
77
79
 
78
80
  ### Array Operators
79
81
 
@@ -221,8 +223,9 @@ NULL items are skipped, as SQL's `SUM` / `AVG` skip them.
221
223
 
222
224
  A date rule's `value` can be a structured, serializable expression instead of an
223
225
  absolute date. Magnitudes are always **positive** — direction lives in the keyword.
224
- Units are dayjs words: `day`, `week`, `isoWeek`, `month`, `quarter`, `year`,
225
- `hour`, `minute`, `second`.
226
+ A period (`this` / `last` / `next`) names a dayjs unit: `day`, `week`, `isoWeek`, `month`,
227
+ `quarter`, `year`, `hour`, `minute`, `second`. A rolling amount (`ago` / `ahead`) counts
228
+ `years`, `quarters`, `months`, `weeks`, `days`, `hours`, `minutes`, `seconds`.
226
229
 
227
230
  **Point expressions** — pair with `before` / `after` / `onOrBefore` / `onOrAfter` /
228
231
  `notBefore` / `notAfter`,
@@ -313,11 +316,15 @@ grant there under `all`). `orderBy` is a non-empty array of `{ field, dir: 'asc'
313
316
  > **Compilation.** `toPrisma()` compiles the **extremal** case — `take: 1`, a single
314
317
  > `orderBy`, and a monotonic condition on that same field, with the direction aligned so
315
318
  > the extremal element is binding (`all` + desc + `before`, `any` + desc + `after`, etc.).
316
- > It rewrites to `every` / `some` (e.g. the rule above → `{ fanMissions: { every: { completedAt:
317
- > { lt: <now-30d> } } } }`). Any other windowed rule — `take > 1`, `skip`, multi-key
318
- > `orderBy`, a different/non-monotonic condition, or a misaligned direction — throws a clear
319
- > "unsupported" error, and so does any `filter`. `toSql()` does not compile windowing at all (no relation subqueries in
320
- > a `WHERE` fragment). Evaluate the unsupported cases in memory with `check()`.
319
+ > It rewrites to relation filters: the rule above, with `completedAt` required, is "no missions,
320
+ > or some and none since the bound" — `{ OR: [{ fanMissions: { none: {} } }, { AND: [{ fanMissions:
321
+ > { some: {} } }, { fanMissions: { none: { completedAt: { gte: <now-30d> } } } }] }] }`. A `filter`
322
+ > alone (no `orderBy` / `take` / `skip`) folds into the rule: `all` through the exact complement
323
+ > of its condition, the rest as `filter AND condition`. Any other windowed rule — `take > 1`,
324
+ > `skip`, multi-key `orderBy`, a different/non-monotonic condition, a misaligned direction, or a
325
+ > `filter` beside an ordered window — throws a clear "unsupported" error. `toSql()` does not
326
+ > compile windowing at all (no relation subqueries in a `WHERE` fragment). Evaluate the
327
+ > unsupported cases in memory with `check()`.
321
328
 
322
329
  ## Path Semantics
323
330
 
@@ -494,15 +501,18 @@ string literal against a number or Boolean field, stamp the rule's `coerceType`
494
501
  does it from a lens). The compilers refuse a string literal on a number or Boolean column
495
502
  without one.
496
503
 
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
504
+ An enum compares exactly against its declared values. String, pattern and ordered operators
505
+ don't apply to one, and the compilers refuse them. A case-insensitive equality or membership, or
506
+ one naming a value the enum doesn't declare, compiles to a membership test over the declared
499
507
  values `check()` would match (plus the NULL arm for a negation). The field map must list the
500
508
  values (prisma-map does).
501
509
 
502
510
  ```ts
503
511
  // 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' });
512
+ toSql({ field: 'role', operator: Operator.equals, value: 'ADMIN', caseInsensitive: true }, { map, model: 'U' });
505
513
  // { sql: '"t0"."role"::text = ANY($1)', params: [['admin']], joins: [] }
514
+ toSql({ field: 'role', operator: Operator.startsWith, value: 'adm' }, { map, model: 'U' });
515
+ // throws: 'startsWith' does not apply to the enum 'role'; compare its values with equals / in.
506
516
  ```
507
517
 
508
518
  ### Custom Errors
@@ -582,6 +592,20 @@ await prisma.user.findMany({ where }); // users whose orders sum to more than 10
582
592
  A user with no orders sums to 0, as in `check()`: a comparison that holds at 0 selects the
583
593
  parents outside the groups where it fails, so childless parents stay in.
584
594
 
595
+ ### Compiling under a lens
596
+
597
+ Pass `lens` instead of `map` / `mapName` / `model` and the rule is gated by the lens
598
+ (`validateRuleInLens`: a rule it refuses throws; a bare value `path` is your `context` on these
599
+ rails, not a column, so it isn't resolved through the lens), narrowed by it, and compiled against
600
+ its base lens. `toSql` takes it the same way. Passing both throws.
601
+
602
+ ```ts
603
+ const plan = toPrisma(rule, { lens: narrowing, now });
604
+ // = toPrisma(narrowRule(rule, narrowing), { map: base, mapName: base.mapName, model: base.model, now }),
605
+ // once validateRuleInLens(rule, narrowing) passes
606
+ const where = await executePrismaPlan(plan, prisma);
607
+ ```
608
+
585
609
  ### Json null checks
586
610
 
587
611
  A Json column holds a DB NULL or a JSON `null`, and a path inside it can be absent — `check()`
@@ -678,7 +702,7 @@ Not every backend supports every rule shape.
678
702
  | Date comparisons | Yes | Most | Yes |
679
703
  | Date expressions (`ago`/`ahead`/`this`/`last`/`next`/`start`/`end`) + `within` | Yes | Yes | Yes |
680
704
  | `dayIn` / `dayNotIn` | Yes | No | Yes |
681
- | Windowing (`filter` / `orderBy` / `take` / `skip`) | Yes | Extremal (`take:1`, aligned, no `filter`) | No |
705
+ | Windowing (`filter` / `orderBy` / `take` / `skip`) | Yes | Extremal (`take: 1`, aligned, no `filter`), or a `filter` alone | No |
682
706
  | `path: '$.field'` current-element / same-row refs | Yes | No | Yes |
683
707
  | `offset` and unit amounts — value, bind or context | Yes | Yes | Yes |
684
708
  | `offset` and unit amounts — `$.` row refs | Yes | No | Yes (not a date offset's) |
@@ -693,12 +717,12 @@ compilers carry NULL rows explicitly:
693
717
 
694
718
  | Rule | `check()` on `{ col: null }` | `toSql()` | `toPrisma()` (nullable column) |
695
719
  | --- | --- | --- | --- |
696
- | `notEquals 'x'` / `notContains` / `notMatches` / `notBetween` | matches | `(col <> $1 OR col IS NULL)` | `{ OR: [{ col: { not: 'x' } }, { col: { equals: null } }] }` |
720
+ | `notEquals 'x'` / `notContains` / `notMatches` / `notBetween` | matches | `(col <> $1 OR col IS NULL)` | `{ OR: [{ col: { not: 'x' } }, { col: { equals: null } }] }` (`notMatches` has no Prisma form) |
697
721
  | `notIn ['x']` | matches | `(col <> ALL($1) OR col IS NULL)` | `{ OR: [{ col: { notIn: ['x'] } }, { col: { equals: null } }] }` |
698
722
  | `in ['x', null]` | matches | `(col = ANY($1) OR col IS NULL)` | `{ OR: [{ col: { in: ['x'] } }, { col: { equals: null } }] }` |
699
723
  | `notIn ['x', null]` | no match | `(col <> ALL($1) AND col IS NOT NULL)` | `{ AND: [{ col: { notIn: ['x'] } }, { col: { not: null } }] }` |
700
724
  | `equals` / `notEquals` with `path: '$.other'` | `null === null` | `IS [NOT] DISTINCT FROM` | — |
701
- | `exists` / `notExists` | `!= null` / `== null` | `IS NOT NULL` / `IS NULL` | `{ not: null }` / `{ equals: null }` |
725
+ | `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) |
702
726
 
703
727
  The **absent set** of a path is wider than a NULL leaf: an optional to-one hop can be NULL too,
704
728
  and `{ rel: { col: { equals: null } } }` only matches when the relation exists. So every negation
@@ -757,6 +781,19 @@ positive operator, ask for them:
757
781
  - `exactly`
758
782
  - `toSql()` generates `WHERE` fragments and `LEFT JOIN`s, not complete queries
759
783
 
784
+ ### Where the Rails Differ
785
+
786
+ `check()`, `toSql()` and `toPrisma()` agree on every rule they all compile, except where the
787
+ engines themselves differ:
788
+
789
+ - Case-insensitive comparison follows each engine's case mapping: JavaScript's `toLowerCase` and
790
+ Postgres's `LOWER` under the database collation can differ on letters like `İ`.
791
+ - Ordered string comparisons (`lessThan`, `between` on text) follow each engine's order:
792
+ `check()` compares UTF-16 code units, Postgres the column's collation.
793
+ - An array or aggregate rule on a Json value that isn't an array is a data error. `check()`
794
+ throws on it; SQL can't raise per row, so it reads the value as an empty array — for an
795
+ aggregate and for `empty` / `notEmpty` alike.
796
+
760
797
  ## TypeScript Types
761
798
 
762
799
  The public rule types are generic over comparison payloads:
@@ -764,7 +801,7 @@ The public rule types are generic over comparison payloads:
764
801
  ```ts
765
802
  type Condition<TRuleValue = RuleValue, TDateValue = DateRuleValue> =
766
803
  | Rule<TRuleValue>
767
- | AggregateRule
804
+ | AggregateRule<TRuleValue, TDateValue>
768
805
  | ArrayRule<TRuleValue, TDateValue>
769
806
  | DateRule<TDateValue>
770
807
  | All<TRuleValue, TDateValue>
@@ -784,7 +821,9 @@ Rules:
784
821
  - `Condition`, `StrictCondition`, `Rule`, `AggregateRule`, `AggregateMode`, `ArrayRule`, `DateRule`, `Row`, `CheckData`
785
822
  - `GroupByStep`, `WhereStep`, `PrismaStep`, `PrismaWhere`, `StepRef` (a Prisma plan's steps); `ScopeRef`, `ScopedRef`, `ScopeOutOfBounds` (scope refs)
786
823
  - `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`
824
+ - rule parts: `All`, `Any`, `IfThenElse`, `RuleValue`, `RuleScalar`, `OrderedRuleValue`, `ValueSourceOf`, `ValueSourceFields`, `NumberOffset`, `Magnitude`, `WindowFields`, `OrderBy`, `SortDir`
825
+ - dates: `DateRuleValue`, `DateInputValue`, `DateInputOrExpr`, `DateExpr`, `RollingExpr`, `PeriodExpr`, `EdgeExpr`, `PeriodUnit`, `RelativeUnits`, `DateOffset`, `DateConfig`, `TimeZoneConfig`, `WeekStart`
826
+ - 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`
788
827
  - `engineGlobals`, `EngineGlobalsState`, `PrismaProvider`, `FuzzyConfig`
789
828
 
790
829
  Lens & bridges:
@@ -793,8 +832,9 @@ Lens & bridges:
793
832
  - `FieldMap`, `FieldMapEntry`, `ModelEntry`, `SourceOption`, `FieldMapSet`, `Bridge`, `BridgeEndpoint`, `BridgeCardinality`, `BridgeDictionary`
794
833
  - `createLens`, `storeLens`, `composeLens`, `StoredLens`, `stitchFieldMaps`, `indexBridges`, `validateFieldMaps`, `assertValidFieldMaps`
795
834
  - `validateNarrowing`, `assertValidNarrowing`, `validateRuleInLens`, `narrowRule`, `coerceRule`
796
- - `bindLens`, `listLensBindings`
797
- - `projectLens`, `walkLensPath`, `describeRule`, `describeRuleSources`
835
+ - `bindLens`, `listLensBindings`, `getLensRoot`
836
+ - `projectLens`, `walkLensPath`, `readLensValue`, `LensValue`, `describeRule`, `describeRuleSources`
837
+ - `toLensSelect`, `projectRows`, `LensSelect`, `LensRelationSelect`, `LensSelectOptions`, `ProjectRowsOptions`
798
838
  - `toSourceQueries`, `materializeSources`, `materializeSourceQuery`
799
839
  - `PathProjection`, `ProjectedVisit`, `ProjectLensOptions`, `LensPathHop`, `LensPathResolution`, `RuleDescription`, `RuleSourceDescription`, `SourceQuery`, `SourcePrismaQuery`, `SourceSqlQuery`, `SourceSelect`, `SourceValues`, `SourceRowShape`, `MaterializeSourceQueryOptions`
800
840
 
@@ -923,7 +963,7 @@ check(
923
963
  );
924
964
  ```
925
965
 
926
- `check()` throws if `data` is an array but the rule contains any field-based leaf, or if the rule is a fieldless `ArrayRule` and `data` is not an array. Root-array compilation to Prisma/SQL is not yet implemented — these are `check()`-only.
966
+ `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`.
927
967
 
928
968
  ## Lens & Multi-Source Data
929
969
 
@@ -1043,7 +1083,9 @@ Composition across chained narrowings is pure intersection. `where` clauses are
1043
1083
  | `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
1084
  | `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
1085
  | `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. |
1086
+ | `getLensRoot(lensOrNarrowing)` | The base lens a narrowing chain is rooted at; a lens is its own. Throws on a cyclic chain. |
1046
1087
  | `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`. |
1088
+ | `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 grants: one a grant hides, or a missing one, reads `null`. Only own properties are read, into a Json column too. `options` is what each grant is checked with (`now`, `bindings`). For values a template interpolates. |
1047
1089
  | `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
1090
  | `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
1091
 
@@ -1075,7 +1117,7 @@ const bound = bindLens(narrowing, { tenantId: 't-42' });
1075
1117
  | Function | Purpose |
1076
1118
  | --- | --- |
1077
1119
  | `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. |
1120
+ | `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. |
1079
1121
 
1080
1122
  A layer may not re-declare a bind name an ancestor declares; it reads the inherited one as
1081
1123
  `parent:name`. `validateNarrowing` reports a collision or a dangling `parent:` reference as
@@ -1125,7 +1167,7 @@ const projection = projectLens(narrowing, { sourceValues: [values] });
1125
1167
  | --- | --- |
1126
1168
  | `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
1169
  | `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. |
1170
+ | `materializeSources(lensOrNarrowing, rows, options?)` | `SourceValues[]` for every sourced field, from rows fetched under the lens (relations inline). Each row at the source's path must pass, through `check()` with `options`, its source `where`, the grants of the visits above it, the guards of the relations it crosses and the values the lens allows; its own visit's `where` is not re-applied, since the rows were fetched under it. A scalar-list field gives one option per element. A `from: 'mapDefaults'` source throws (see below). |
1129
1171
 
1130
1172
  Options never offer a value the lens disallows: `projectLens` drops fetched values outside a
1131
1173
  field's allowed set.
@@ -1164,16 +1206,41 @@ mapDefaults: {
1164
1206
 
1165
1207
  `from: 'mapDefaults'` resolves where it sits — `mapDefaults[<this path's map>].models[<this path's
1166
1208
  model>].sources[<field>]` — and takes that source's eligibility (tenancy included), label and
1167
- axes; its own `where`, and child layers, only narrow it. Nothing is carried from the path above.
1209
+ axes; its own `where`, and child layers, only narrow it. A pointer drops the grants the path
1210
+ carries down only in the layer that declares it: every layer before or after it still carries
1211
+ theirs, so a child's pointer can only narrow what its parent gave, and a tenant layer added after a
1212
+ pointer always narrows it. How depends on how it scopes: through `mapDefaults` (the model's own
1213
+ grant or source) the pointer still offers unlinked rows; through a root `where` the grant can only
1214
+ reach the pointer down the path, so it offers just the linked rows — and across a bridge, where no
1215
+ grant crosses, nothing. Scope tenancy through `mapDefaults` to keep a pointer's unlinked rows.
1168
1216
  A pointer whose model declares no source fails `validateNarrowing` (`invalid_source`) and throws
1169
- from `projectLens` (by path) / `toSourceQueries` / `materializeSources`; `projectLens(…, { by:
1170
- 'model' })` and `describeRuleSources` read the lens without validating it. A pointer drops the
1171
- path's carried grants from the layer that declares it on, so a tenant layer added *after* a pointer
1172
- scopes it through `mapDefaults` (the model's own grant or source), not a root `where`. Across a bridge it is how a picker gets options at all: the
1217
+ from `projectLens` (by path) / `toSourceQueries` / `materializeSources`, even where a layer hides
1218
+ its field; `projectLens(…, { by: 'model' })` and `describeRuleSources` read the lens without
1219
+ validating it. Across a bridge it is how a picker gets options at all: the
1173
1220
  model source compiles against the far map alone, with that map's own tenancy, where a path source
1174
- offers nothing. `materializeSources` refuses a pointer — a fetched collection can't hold unlinked
1221
+ offers nothing (until a later layer scopes by a root `where`, as above). `materializeSources` refuses a pointer — a fetched collection can't hold unlinked
1175
1222
  rows; query it with `toSourceQueries` and `materializeSourceQuery`.
1176
1223
 
1224
+ ### Fetching Under a Lens
1225
+
1226
+ `toLensSelect` and `projectRows` fetch the rows a lens shows and cut them to it.
1227
+
1228
+ ```ts
1229
+ import { check, executePrismaPlan, narrowRule, projectRows, toLensSelect, toPrisma } from '@inixiative/json-rules';
1230
+
1231
+ const where = await executePrismaPlan(toPrisma(true, { lens: narrowing, now }), prisma);
1232
+ const rows = await prisma.user.findMany({ where, ...toLensSelect(narrowing, { now, rules: [rule] }) });
1233
+ const shown = projectRows(narrowing, rows, { now }); // what a viewer may see
1234
+ // To re-test the grants in memory later — never to return to a viewer:
1235
+ const forRecheck = projectRows(narrowing, rows, { keepGrantColumns: true, rules: [rule], now });
1236
+ const holds = forRecheck.filter((row) => check(narrowRule(rule, narrowing), row, { now }) === true);
1237
+ ```
1238
+
1239
+ | Function | Purpose |
1240
+ | --- | --- |
1241
+ | `toLensSelect(lensOrNarrowing, options?)` | `{ select }` for `findMany` at the base model. It selects each projected path's visible columns, the relations its declared paths open (a visible relation off them brings its visible columns only), and every column a `where` on the way reads. A to-many relation carries its visit's grants compiled as its `where`, so related rows come pre-narrowed — unless a grant reads that list: a grant 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 grant hides. A relation that shows no column is fetched whole (Prisma can't select nothing). Bridges are skipped. A relation grant that needs a counting step throws. `rules` are the rules the rows will be re-checked with: each relation they read past the declared paths opens as a declared one (its grants applied, its visible columns kept). The rest of `options` is the clock and context for compiling the grants. The root's own grants are the query's `where`: `toPrisma(rule, { lens })`. |
1242
+ | `projectRows(lensOrNarrowing, rows, options?)` | Rows cut to what the lens shows, recursively. Hidden columns and relations are removed. A row a visit's `where` hides is dropped from the root or a list, and a to-one row becomes `null`. `keepGrantColumns: true` keeps the columns those `where`s read, even hidden ones, and a hidden to-one row, or a hidden row of a list a grant reads, as those columns alone, so `check(narrowRule(rule, lens), row)` re-tests the grants as the database does — for a rule passed in `rules` to both calls, or one that reads only the declared paths; that output carries hidden values, so never return it to a viewer. `rules` is as `toLensSelect`'s. The other options (`now`, `bindings`) are what each `where` is checked with. Plain JSON in and out. |
1243
+
1177
1244
  ### Evaluating Across Bridges
1178
1245
 
1179
1246
  `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.