@inixiative/json-rules 3.3.1 → 3.4.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
@@ -283,7 +283,7 @@ toSql(rule, { now });
283
283
  | Option | Default | Governs |
284
284
  | --- | --- | --- |
285
285
  | `now` | — (required when a relative/period expression is present) | the anchor instant |
286
- | `timeZone` | `'UTC'` | how `now` and period boundaries localize — a zone name, or a value source read from context or bindings |
286
+ | `timeZone` | `'UTC'` | how `now` and period boundaries localize — a zone name, or a `{ bind }` read from the bindings |
287
287
  | `weekStart` | `'monday'` (ISO / isoWeek) | start of `week` for `this`/`last`/`next` |
288
288
 
289
289
  Compilers resolve expressions to concrete `Date` bounds at compile time, so
@@ -328,11 +328,13 @@ grant there under `all`). `orderBy` is a non-empty array of `{ field, dir: 'asc'
328
328
 
329
329
  ## Path Semantics
330
330
 
331
- `path` lets a rule resolve its comparison value from somewhere other than `value`.
331
+ `path` lets a rule compare a field with another value on the row; caller-supplied values come
332
+ through `{ bind }` (`check(rule, row, { bindings })`; `bindRule` before compiling).
332
333
 
333
- ### Root Context Reference
334
+ ### Root Row Reference
334
335
 
335
- In runtime validation, a plain path is resolved from the root context:
336
+ A bare path reads the root row — the record `check()` evaluates, the table a compiler compiles
337
+ against:
336
338
 
337
339
  ```ts
338
340
  {
@@ -350,7 +352,7 @@ enclosing array operator, `$$$.` the one above that, up to the root row. Logical
350
352
  combinators (`all` / `any` / `if`) never add a level — only array and aggregate rules do.
351
353
 
352
354
  Both `field` and `path` take the prefix. A bare `field` is always the current element; a
353
- bare `path` is always the root context (`options.context`, defaulting to the root row).
355
+ bare `path` is always the root row.
354
356
 
355
357
  ```ts
356
358
  {
@@ -376,13 +378,20 @@ A ref deeper than the nesting (`$$.` at the top level, `$$$.` one array deep) th
376
378
  `validateRuleInLens`. A reachable ancestor that lacks the named key fails the comparison
377
379
  like any absent field.
378
380
 
379
- `toSql()` keeps `path: '$.x'` as a same-row column comparison. Every other scope ref — a
380
- `$$.` path or any prefixed `field` — is check-only; both compilers throw.
381
+ `toSql()` compiles a bare path or `path: '$.x'` as a same-row column comparison (equality,
382
+ ordered and set-free operators; a substring, pattern or set operator against a column throws).
383
+ `toPrisma()` compiles one only as a Prisma field reference: both columns of the same model at the
384
+ same visit (a bare path at the root, `$.` inside a relation filter), of exactly the same type, with
385
+ `equals` / `notEquals` / `lessThan` / `lessThanEquals` / `greaterThan` / `greaterThanEquals` and
386
+ no offset. The plan carries a `{ __field }` sentinel that `executePrismaPlan` resolves to
387
+ `prisma.<model>.fields.<column>`; read a plan's where only through it. Anything else throws, and
388
+ `validateRule(rule, { target: 'toPrisma', map, model })` / `describeRule` report it first. Every
389
+ other scope ref — a `$$.` path or any prefixed `field` — is check-only; both compilers throw.
381
390
 
382
391
  ### Offsets and Unit Amounts
383
392
 
384
393
  An `offset` moves the comparison value. It is a value source of its own, with the comparison
385
- value's contract: `{ value }`, `{ path }` (`$.` from the row, bare from context) or `{ bind }`
394
+ value's contract: `{ value }`, `{ path }` (`$.` from the element, bare from the root row) or `{ bind }`
386
395
  (with `bindOptional`). A field rule's offset reads a number, added to the comparison value; a
387
396
  date rule's reads a rolling shift (`{ ago }` / `{ ahead }`) anchored on the comparison value
388
397
  instead of `now`:
@@ -410,7 +419,7 @@ instead of `now`:
410
419
  `{ ago: … }`) is check-only; to size a shift from the row, read the amount instead.
411
420
 
412
421
  Any relative-date unit — in a `value` expression or an offset's rolling shift — is a number or a
413
- value source: `{ path }` from the row (`$.`) or context, `{ bind }`, or `{ value }`. A relative
422
+ value source: `{ path }` from the row, `{ bind }`, or `{ value }`. A relative
414
423
  window can take its size from the row it judges:
415
424
 
416
425
  ```ts
@@ -431,9 +440,9 @@ in double precision on every rail.
431
440
 
432
441
  | | `check()` | `toSql()` | `toPrisma()` |
433
442
  | --- | --- | --- | --- |
434
- | literal, bound or context offset / amount | yes | resolved to a parameter | resolved to a value |
435
- | `$.` numeric offset or unit amount | yes | `col + n` / `col ± make_interval(…)` | throws |
436
- | `$.` date offset (a stored `{ ago }`) | yes | throws | throws |
443
+ | literal or bound offset / amount | yes | resolved to a parameter | resolved to a value |
444
+ | row (`$.` or bare) numeric offset or unit amount | yes | `col + n` / `col ± make_interval(…)` | throws |
445
+ | row (`$.` or bare) date offset (a stored `{ ago }`) | yes | throws | throws |
437
446
  | `$$.` anything | yes | throws | throws |
438
447
 
439
448
  `validateRuleInLens` gates offset and magnitude refs like `path` (they must resolve through
@@ -595,14 +604,14 @@ parents outside the groups where it fails, so childless parents stay in.
595
604
  ### Compiling under a lens
596
605
 
597
606
  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.
607
+ (`validateRuleInLens`: a rule it refuses throws; a bare value `path` is a root-row column, gated
608
+ like a field), narrowed by it, and compiled against its base lens. `toSql` takes it the same way.
609
+ Passing both throws.
601
610
 
602
611
  ```ts
603
612
  const plan = toPrisma(rule, { lens: narrowing, now });
604
- // Gated by the lens, narrowed by it with each bare value `path` read as your `context` (narrowRule
605
- // alone would resolve it as a column), then compiled against the base lens's map and model.
613
+ // Gated by the lens, narrowed by it (a bare `path` too, as a root-row column), then compiled
614
+ // against the base lens's map and model.
606
615
  const where = await executePrismaPlan(plan, prisma);
607
616
  ```
608
617
 
@@ -703,9 +712,9 @@ Not every backend supports every rule shape.
703
712
  | Date expressions (`ago`/`ahead`/`this`/`last`/`next`/`start`/`end`) + `within` | Yes | Yes | Yes |
704
713
  | `dayIn` / `dayNotIn` | Yes | No | Yes |
705
714
  | Windowing (`filter` / `orderBy` / `take` / `skip`) | Yes | Extremal (`take: 1`, aligned, no `filter`), or a `filter` alone | No |
706
- | `path: '$.field'` current-element / same-row refs | Yes | No | Yes |
707
- | `offset` and unit amounts — value, bind or context | Yes | Yes | Yes |
708
- | `offset` and unit amounts — `$.` row refs | Yes | No | Yes (not a date offset's) |
715
+ | `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) |
716
+ | `offset` and unit amounts — value or bind | Yes | Yes | Yes |
717
+ | `offset` and unit amounts — row refs (`$.` or bare) | Yes | No | Yes (not a date offset's) |
709
718
  | `$$.` scope refs and `$`-prefixed `field` | Yes | No | No |
710
719
 
711
720
  ### NULL Semantics
@@ -721,7 +730,7 @@ compilers carry NULL rows explicitly:
721
730
  | `notIn ['x']` | matches | `(col <> ALL($1) OR col IS NULL)` | `{ OR: [{ col: { notIn: ['x'] } }, { col: { equals: null } }] }` |
722
731
  | `in ['x', null]` | matches | `(col = ANY($1) OR col IS NULL)` | `{ OR: [{ col: { in: ['x'] } }, { col: { equals: null } }] }` |
723
732
  | `notIn ['x', null]` | no match | `(col <> ALL($1) AND col IS NOT NULL)` | `{ AND: [{ col: { notIn: ['x'] } }, { col: { not: null } }] }` |
724
- | `equals` / `notEquals` with `path: '$.other'` | `null === null` | `IS [NOT] DISTINCT FROM` | — |
733
+ | `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` |
725
734
  | `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) |
726
735
 
727
736
  The **absent set** of a path is wider than a NULL leaf: an optional to-one hop can be NULL too,
@@ -852,8 +861,10 @@ A lens has three forms, each with its own job:
852
861
  - `projectLens(lens)` returns `Record<dottedPath, ProjectedVisit>` for per-path checks where
853
862
  sibling paths to the same model diverge.
854
863
  - `projectLens(lens, { by: 'model' })` returns the leak-safe total surface *as a Lens* — every
855
- reachable model with the full narrowing applied, unioned per model, `where` stripped. Use it as
856
- the server→client builder surface; it never exposes the raw lens.
864
+ model the relations turned on reach, with the full narrowing applied, unioned per model, `where`
865
+ stripped. Use it as the server→client builder surface; it never exposes the raw lens. It is a
866
+ view, not a gate: as a lens it is bare and turns no relation on, so gate rules against the
867
+ narrowing it came from.
857
868
 
858
869
  ```ts
859
870
  const records = storeLens(grantLens, ['user', 'org-acme', 'grant-7']); // persist each record
@@ -940,6 +951,26 @@ if (!result.ok) {
940
951
  assertValidRule(rule, { target: 'toPrisma' });
941
952
  ```
942
953
 
954
+ Two error classes say whose input went wrong; each sets `name`, so check with `instanceof` or
955
+ `error.name`:
956
+
957
+ | Class | Thrown when | Fix |
958
+ | --- | --- | --- |
959
+ | `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. |
960
+ | `LensRefusal` (`code`) | The lens itself can't do it: a later layer's grant reads what its parent hides, a grant 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. |
961
+
962
+ ```ts
963
+ import { LensRefusal, UsageError } from '@inixiative/json-rules';
964
+
965
+ try {
966
+ toPrisma(rule, { lens, now });
967
+ } catch (error) {
968
+ if (error instanceof UsageError) /* the caller's input */;
969
+ else if (error instanceof LensRefusal) /* the lens: error.code */;
970
+ else throw error; // the rule's shape — validateRule reports it
971
+ }
972
+ ```
973
+
943
974
  ## Root-Array Rules in `check()`
944
975
 
945
976
  When `data` is an array, the rule must be a tree of `all` / `any` whose leaves are **fieldless** `ArrayRule`s (no `field`, `arrayOperator` operates on the array itself).
@@ -1048,6 +1079,50 @@ const lens = createLens({
1048
1079
 
1049
1080
  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.
1050
1081
 
1082
+ ### Relations
1083
+
1084
+ Relations are fields, off by default. A bare lens reads its anchor model's columns and nothing
1085
+ else. The first narrowing over the base lens turns a relation on through the relation object —
1086
+ never through `picks`, which names columns only:
1087
+
1088
+ ```ts
1089
+ const narrowing: LensNarrowing = {
1090
+ parent: lens,
1091
+ root: { relations: { org: { relations: { parent: {} } } } }, // along the path
1092
+ mapDefaults: {
1093
+ prisma: { models: { Org: { relations: { users: {} } } } }, // wherever Org is visited
1094
+ },
1095
+ };
1096
+ validateRuleInLens({ field: 'org.parent.name', operator: Operator.equals, value: 'Acme' }, narrowing); // ok
1097
+ validateRuleInLens({ field: 'posts', arrayOperator: ArrayOperator.any, condition: true }, narrowing);
1098
+ // { ok: false, errors: [{ path: 'posts', code: 'not_in_lens', message: "'posts' is a relation the lens does not turn on …" }] }
1099
+ ```
1100
+
1101
+ - **Off is hidden.** A relation that isn't turned on can't be crossed or named (`exists` /
1102
+ `notExists` included): the gate refuses it (`not_in_lens`), `walkLensPath` and `readLensValue`
1103
+ report it `hidden`, `projectLens` doesn't list it, and `toLensSelect` / `projectRows` don't fetch
1104
+ or keep it. That covers every relation a rule, a value `path` or `$` ref, an offset, an
1105
+ `orderBy`, a source `label` / `groupBy`, or a read crosses. A bridge is turned on by its key
1106
+ (`'salesforce:Contact'`).
1107
+ - **The relation object carries that hop's narrowing.** `relations.org = { where, picks, omits,
1108
+ relations }` narrows Org at that hop; on a model default it narrows the hop wherever the model is
1109
+ visited, and may nest further relations.
1110
+ - **Only the first narrowing turns relations on; later layers narrow.** A later layer may omit a
1111
+ relation (`omits: ['org']`, which `picks` never conflicts with), or restate one its parent shows
1112
+ to add that hop's narrowing — a restatement hides nothing else. Naming a relation its parent
1113
+ doesn't show fails `validateNarrowing` (`not_visible`), and does nothing at runtime.
1114
+ - **The model defaults grow a tree.** From the anchor and every spelled path, model-default
1115
+ turn-ons are followed breadth-first, each model once, at its nearest reach (ties: the earlier
1116
+ parent, then the relation declared first). Anything else is reached by spelling it under
1117
+ `root.relations`; a spelled node grows its own tree. Every posture walks the same tree, so they
1118
+ agree and stay small. `lensVisit(lens, 'org.users')` resolves one visit on demand, as
1119
+ `projectLens` would give it, or `null`.
1120
+ - **Grants.** The first narrowing's `where`s (and source eligibility `where`s) may read any
1121
+ relation on the schema; a later layer's may read only what its parent shows — a delegate can't
1122
+ probe a relation it can't see (`validateNarrowing` reports it; every posture throws). A bare
1123
+ value `path` reads the root row, so only `root.where` may hold one. `toLensSelect` fetches exactly the columns grants read, and
1124
+ `projectRows({ keepGrantColumns: true })` keeps them for a re-check.
1125
+
1051
1126
  ### LensNarrowing & `where`
1052
1127
 
1053
1128
  `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:
@@ -1058,7 +1133,7 @@ const narrowing: LensNarrowing = {
1058
1133
  root: {
1059
1134
  // path-specific narrowing at the lens anchor (FanUser)
1060
1135
  picks: ['email', 'firstName', 'crmId'],
1061
- where: { field: 'tenantId', operator: Operator.equals, path: 'tenantId' },
1136
+ where: { field: 'tenantId', operator: Operator.equals, bind: 'tenantId' },
1062
1137
  },
1063
1138
  mapDefaults: {
1064
1139
  prisma: {
@@ -1071,20 +1146,21 @@ const narrowing: LensNarrowing = {
1071
1146
  };
1072
1147
  ```
1073
1148
 
1074
- 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.
1149
+ 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 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.
1075
1150
 
1076
1151
  ### Lens Utilities
1077
1152
 
1078
1153
  | Function | Purpose |
1079
1154
  | --- | --- |
1080
- | `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. |
1155
+ | `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. |
1081
1156
  | `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 } })`. |
1082
- | `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). |
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`. |
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. |
1157
+ | `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](./docs/LENS.md). |
1158
+ | `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`. |
1159
+ | `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. |
1085
1160
  | `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
1161
  | `getLensRoot(lensOrNarrowing)` | The base lens a narrowing chain is rooted at; a lens is its own. Throws on a cyclic chain. |
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`. |
1162
+ | `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. |
1163
+ | `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`. |
1088
1164
  | `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. |
1089
1165
  | `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`. |
1090
1166
  | `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. |
@@ -1149,9 +1225,11 @@ const narrowing: LensNarrowing = {
1149
1225
  // Against a database: one DISTINCT query per sourced field, in Prisma and SQL form.
1150
1226
  const [query] = toSourceQueries(narrowing);
1151
1227
  // query.path => 'User', query.field => 'region'
1152
- // query.composedWhere => the node's `where` AND the source's eligibility
1228
+ // query.composedWhere => the node's `where` AND the source's eligibility, narrowed as a rule is
1153
1229
  // query.prisma => { model: 'User', distinct: ['region'], select: { region: true }, where: { AND: [...] } }
1154
1230
  // query.sql => { sql: 'SELECT DISTINCT "t0"."region" FROM "User" AS "t0" WHERE (...)', params: ['t-42', true] }
1231
+ // `prisma` is null for a source read across a bridge (see "Sources across a bridge" below).
1232
+ if (query.prisma === null) throw new Error(query.sql.error);
1155
1233
  const { distinct, select, where } = query.prisma;
1156
1234
  const rows = await prisma.user.findMany({ distinct, select, where });
1157
1235
  const values = materializeSourceQuery(query, rows); // { path, mapName, model, field, options: [{ value }] }
@@ -1165,17 +1243,24 @@ const projection = projectLens(narrowing, { sourceValues: [values] });
1165
1243
 
1166
1244
  | Function | Purpose |
1167
1245
  | --- | --- |
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`. |
1246
+ | `toSourceQueries(lensOrNarrowing, options?)` | `SourceQuery[]`, one per sourced field: `{ path, mapName, model, field, label?, groupBy?, composedWhere, prisma, sql }`. `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`. `sql.sql` is `null` with an `error` when SQL can't express the where. A where that crosses a bridge has no query at all: `prisma` is `null` and `sql.error` says so — a database holds one side of it, so materialize it with `materializeSources` over rows holding both. `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. |
1169
1247
  | `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. |
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). |
1248
+ | `materializeSources(lensOrNarrowing, rows, options?)` | `SourceValues[]` for every sourced field, from the rows the lens fetches — `toLensSelect`'s rows as fetched, or as `projectRows(…, { keepGrantColumns: 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 grant 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 grants met, and each row it reaches must meet, through `check()` with `options`, its visit's grants, 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). |
1171
1249
 
1172
1250
  Options never offer a value the lens disallows: `projectLens` drops fetched values outside a
1173
- field's allowed set.
1251
+ field's allowed set. Nor do they come through a row the lens hides: each source `where` is
1252
+ narrowed under the whole lens as `narrowRule` narrows a rule — every relation it crosses carries
1253
+ that visit's grants, inside an array condition (into its `condition`, or its `filter` under `all`,
1254
+ a window or no condition) and on each hop and terminal relation of a dotted path. A grant a rule
1255
+ couldn't carry there (one narrowRule can't re-root, a to-many relation read flat) is refused, as
1256
+ it is for a rule; so is a source whose query has a window toPrisma can't compile (by its shape,
1257
+ before anything compiles — `materializeSources` refuses it too, so the two never disagree).
1174
1258
 
1175
1259
  #### Two kinds of source
1176
1260
 
1177
- A source declared down a relation path offers the rows **reachable** from there: every grant above
1178
- it is carried down, so a Tag source under `User.tagAttachments.tag` offers the tags a live
1261
+ A source declared down a relation path offers the rows **reachable** from there: the path is
1262
+ carried down through each relation's inverse (where the map declares one), and every grant above
1263
+ it with it, so a Tag source under `User.tagAttachments.tag` offers the tags a live
1179
1264
  attachment of an in-tenant user points at. When a field should offer every row the lens lets its
1180
1265
  model show — linked or not, what a rule may *name* — point the path source at the model's own
1181
1266
  source:
@@ -1212,15 +1297,29 @@ theirs, so a child's pointer can only narrow what its parent gave, and a tenant
1212
1297
  pointer always narrows it. How depends on how it scopes: through `mapDefaults` (the model's own
1213
1298
  grant or source) the pointer still offers unlinked rows; through a root `where` the grant can only
1214
1299
  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.
1300
+ grant crosses, no query at all (it is routed to caller rows, below). Scope tenancy through `mapDefaults` to keep a pointer's unlinked rows.
1216
1301
  A pointer whose model declares no source fails `validateNarrowing` (`invalid_source`) and throws
1217
1302
  from `projectLens` (by path) / `toSourceQueries` / `materializeSources`, even where a layer hides
1218
1303
  its field; `projectLens(…, { by: 'model' })` and `describeRuleSources` read the lens without
1219
1304
  validating it. Across a bridge it is how a picker gets options at all: the
1220
1305
  model source compiles against the far map alone, with that map's own tenancy, where a path source
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
1306
+ has no query (see "Sources across a bridge"). A grant a path can't carry down — a relation whose
1307
+ map declares no inverse — is a `LensRefusal`, never an empty list. `materializeSources` refuses a pointer — a fetched collection can't hold unlinked
1222
1308
  rows; query it with `toSourceQueries` and `materializeSourceQuery`.
1223
1309
 
1310
+ #### Sources across a bridge
1311
+
1312
+ A source whose path, `where`, `label` or an axis reads across a bridge has neither a database form (no
1313
+ database holds both sides) nor a fetch form (`toLensSelect` selects no bridge). `toSourceQueries`
1314
+ returns it with `prisma: null`, `sql.sql: null` and an `sql.error` that names the path. Materialize
1315
+ it yourself: pass `materializeSources` the rows at the source's path, each holding the bridged
1316
+ row inline under its bridge field — for a `FanUser.email` source reading
1317
+ `salesforce:Contact.industry`, `{ id, email, crmId, 'salesforce:Contact': { id, industry } }`
1318
+ (the `indexBridges` output). A pointer whose model source crosses a bridge is materialized the same
1319
+ way, over the rows you supply. Every key a read walks must be there — the bridged row as one row
1320
+ where the bridge names one, each column a source or a grant reads — or `materializeSources` throws a
1321
+ `UsageError` rather than offer a wrong set.
1322
+
1224
1323
  ### Fetching Under a Lens
1225
1324
 
1226
1325
  `toLensSelect` and `projectRows` fetch the rows a lens shows and cut them to it.
@@ -1229,17 +1328,17 @@ rows; query it with `toSourceQueries` and `materializeSourceQuery`.
1229
1328
  import { check, executePrismaPlan, narrowRule, projectRows, toLensSelect, toPrisma } from '@inixiative/json-rules';
1230
1329
 
1231
1330
  const where = await executePrismaPlan(toPrisma(true, { lens: narrowing, now }), prisma);
1232
- const rows = await prisma.user.findMany({ where, ...toLensSelect(narrowing, { now, rules: [rule] }) });
1331
+ const rows = await prisma.user.findMany({ where, ...toLensSelect(narrowing, { now }) });
1233
1332
  const shown = projectRows(narrowing, rows, { now }); // what a viewer may see
1234
1333
  // 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 });
1334
+ const forRecheck = projectRows(narrowing, rows, { keepGrantColumns: true, now });
1236
1335
  const holds = forRecheck.filter((row) => check(narrowRule(rule, narrowing), row, { now }) === true);
1237
1336
  ```
1238
1337
 
1239
1338
  | Function | Purpose |
1240
1339
  | --- | --- |
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. |
1340
+ | `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 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 — one turned on with all its columns hidden, or one a grant reads only for presence or a count — is fetched by its key alone — the join key, else `id` — even a hidden one, as a grant'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 grant's columns, so `materializeSources` over the fetched rows offers what the database does; a viewer's projection drops them. A to-many relation's grant 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 grants. The root's own grants are the query's `where`: `toPrisma(rule, { lens })`. |
1341
+ | `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`. `keepGrantColumns: true` keeps the columns those `where`s and the projected sources 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 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. |
1243
1342
 
1244
1343
  ### Evaluating Across Bridges
1245
1344