grafeo-ogm 1.6.0 → 1.7.0-beta.2

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.
Files changed (91) hide show
  1. package/CHANGELOG.md +89 -0
  2. package/README.md +183 -0
  3. package/dist/cjs/compilers/selection.compiler.d.ts +21 -1
  4. package/dist/cjs/compilers/selection.compiler.d.ts.map +1 -1
  5. package/dist/cjs/compilers/selection.compiler.js +135 -43
  6. package/dist/cjs/compilers/selection.compiler.js.map +1 -1
  7. package/dist/cjs/compilers/where.compiler.d.ts +23 -0
  8. package/dist/cjs/compilers/where.compiler.d.ts.map +1 -1
  9. package/dist/cjs/compilers/where.compiler.js +172 -4
  10. package/dist/cjs/compilers/where.compiler.js.map +1 -1
  11. package/dist/cjs/execution/executor.d.ts +20 -0
  12. package/dist/cjs/execution/executor.d.ts.map +1 -1
  13. package/dist/cjs/execution/executor.js +28 -4
  14. package/dist/cjs/execution/executor.js.map +1 -1
  15. package/dist/cjs/generator/type-emitters/model-map-emitter.d.ts +12 -8
  16. package/dist/cjs/generator/type-emitters/model-map-emitter.d.ts.map +1 -1
  17. package/dist/cjs/generator/type-emitters/model-map-emitter.js +15 -73
  18. package/dist/cjs/generator/type-emitters/model-map-emitter.js.map +1 -1
  19. package/dist/cjs/index.d.ts +5 -2
  20. package/dist/cjs/index.d.ts.map +1 -1
  21. package/dist/cjs/index.js +11 -1
  22. package/dist/cjs/index.js.map +1 -1
  23. package/dist/cjs/interface-model.d.ts +43 -1
  24. package/dist/cjs/interface-model.d.ts.map +1 -1
  25. package/dist/cjs/interface-model.js +211 -9
  26. package/dist/cjs/interface-model.js.map +1 -1
  27. package/dist/cjs/model.d.ts +127 -13
  28. package/dist/cjs/model.d.ts.map +1 -1
  29. package/dist/cjs/model.js +263 -38
  30. package/dist/cjs/model.js.map +1 -1
  31. package/dist/cjs/ogm.d.ts +115 -4
  32. package/dist/cjs/ogm.d.ts.map +1 -1
  33. package/dist/cjs/ogm.js +189 -2
  34. package/dist/cjs/ogm.js.map +1 -1
  35. package/dist/cjs/policy/errors.d.ts +22 -0
  36. package/dist/cjs/policy/errors.d.ts.map +1 -0
  37. package/dist/cjs/policy/errors.js +23 -0
  38. package/dist/cjs/policy/errors.js.map +1 -0
  39. package/dist/cjs/policy/resolver.d.ts +42 -0
  40. package/dist/cjs/policy/resolver.d.ts.map +1 -0
  41. package/dist/cjs/policy/resolver.js +116 -0
  42. package/dist/cjs/policy/resolver.js.map +1 -0
  43. package/dist/cjs/policy/types.d.ts +182 -0
  44. package/dist/cjs/policy/types.d.ts.map +1 -0
  45. package/dist/cjs/policy/types.js +130 -0
  46. package/dist/cjs/policy/types.js.map +1 -0
  47. package/dist/esm/compilers/selection.compiler.d.ts +21 -1
  48. package/dist/esm/compilers/selection.compiler.d.ts.map +1 -1
  49. package/dist/esm/compilers/selection.compiler.js +135 -43
  50. package/dist/esm/compilers/selection.compiler.js.map +1 -1
  51. package/dist/esm/compilers/where.compiler.d.ts +23 -0
  52. package/dist/esm/compilers/where.compiler.d.ts.map +1 -1
  53. package/dist/esm/compilers/where.compiler.js +172 -4
  54. package/dist/esm/compilers/where.compiler.js.map +1 -1
  55. package/dist/esm/execution/executor.d.ts +20 -0
  56. package/dist/esm/execution/executor.d.ts.map +1 -1
  57. package/dist/esm/execution/executor.js +28 -4
  58. package/dist/esm/execution/executor.js.map +1 -1
  59. package/dist/esm/generator/type-emitters/model-map-emitter.d.ts +12 -8
  60. package/dist/esm/generator/type-emitters/model-map-emitter.d.ts.map +1 -1
  61. package/dist/esm/generator/type-emitters/model-map-emitter.js +15 -73
  62. package/dist/esm/generator/type-emitters/model-map-emitter.js.map +1 -1
  63. package/dist/esm/index.d.ts +5 -2
  64. package/dist/esm/index.d.ts.map +1 -1
  65. package/dist/esm/index.js +4 -1
  66. package/dist/esm/index.js.map +1 -1
  67. package/dist/esm/interface-model.d.ts +43 -1
  68. package/dist/esm/interface-model.d.ts.map +1 -1
  69. package/dist/esm/interface-model.js +211 -9
  70. package/dist/esm/interface-model.js.map +1 -1
  71. package/dist/esm/model.d.ts +127 -13
  72. package/dist/esm/model.d.ts.map +1 -1
  73. package/dist/esm/model.js +263 -38
  74. package/dist/esm/model.js.map +1 -1
  75. package/dist/esm/ogm.d.ts +115 -4
  76. package/dist/esm/ogm.d.ts.map +1 -1
  77. package/dist/esm/ogm.js +187 -1
  78. package/dist/esm/ogm.js.map +1 -1
  79. package/dist/esm/policy/errors.d.ts +22 -0
  80. package/dist/esm/policy/errors.d.ts.map +1 -0
  81. package/dist/esm/policy/errors.js +19 -0
  82. package/dist/esm/policy/errors.js.map +1 -0
  83. package/dist/esm/policy/resolver.d.ts +42 -0
  84. package/dist/esm/policy/resolver.d.ts.map +1 -0
  85. package/dist/esm/policy/resolver.js +111 -0
  86. package/dist/esm/policy/resolver.js.map +1 -0
  87. package/dist/esm/policy/types.d.ts +182 -0
  88. package/dist/esm/policy/types.d.ts.map +1 -0
  89. package/dist/esm/policy/types.js +123 -0
  90. package/dist/esm/policy/types.js.map +1 -0
  91. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,94 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.7.0-beta.2] (2026-05-02)
4
+
5
+ > **BETA — type-safety hardening.** Install with `npm install grafeo-ogm@beta`. No runtime behavior changes.
6
+
7
+ ### Changed
8
+
9
+ - **`ModelInterface` and `Model` gain a 12th generic `TFulltext`**, defaulting to the loose `FulltextInput`. The generated type file now passes `<Node>FulltextInput` as that generic for every node with a fulltext index (direct or via a relationship-properties type). The previous `Omit<..., 'find' | ...> & { find(...) }` override pattern is gone — typed fulltext flows through `find`, `findFirst`, `findFirstOrThrow`, `count`, and `aggregate` purely via generic substitution. Same mechanism `<Node>Sort` already used.
10
+ - **Result: typos in fulltext index names are now compile errors at every nesting level**, including inside `OR` / `AND` / `NOT` logical compositions. The recursive `<Node>FulltextInput` shape closes over the per-node leaf, so `{ OR: [{ TpoIndex: { phrase: '...' } }] }` fails type-checking against `<Node>FulltextInput[]`.
11
+ - Public API surface: no breaking changes. The new `TFulltext` parameter has a default, so existing `Model<...>` consumers without the generated `<ModelMap>` see the same loose `FulltextInput` they always did. Generated types narrow it automatically.
12
+
13
+ ## [1.7.0-beta.1] (2026-05-02)
14
+
15
+ > **BETA — republish of beta.0 with no code changes.** Install with `npm install grafeo-ogm@beta`.
16
+
17
+ ### Fixed
18
+
19
+ - **CI publish workflow** now passes `--tag` to `npm publish`, derived from the version's pre-release suffix (`beta`, `alpha`, `rc`, etc.), defaulting to `latest` for stable releases. Required because npm refuses to publish prerelease versions without an explicit dist-tag. v1.7.0-beta.0 was tagged on GitHub but never reached npm because of this; v1.7.0-beta.1 is the first beta artifact actually available on the registry.
20
+
21
+ ## [1.7.0-beta.0] (2026-05-02)
22
+
23
+ > **BETA — API may change before 1.7.0 final.** Install with `npm install grafeo-ogm@beta` (after the package is published to the `beta` dist-tag). The shape of the public API is settled, but feedback during the beta window may cause minor adjustments.
24
+
25
+ ### Features
26
+
27
+ - **Node-Level Security (NLS).** New optional `policies` config on `OGMConfig` plus `OGM.withContext(ctx)` give you a Postgres-RLS-style filter layer that compiles into the existing WHERE pipeline. Policies return `<Node>Where` partials — every operator, quantifier, connection filter, and nested traversal already supported by `WhereCompiler` is automatically available. Three policy kinds: `override` (compile-time short-circuit; admin path is byte-identical to no-policy), `permissive` (OR'd grants with optional `appliesWhen` compile-time gate), `restrictive` (split into read-side and write-side flavors — see "Contract change" below). Vocabulary-neutral — no hardcoded role strings, you compose your own.
28
+ - **Nested-selection enforcement.** `SelectionCompiler` injects every target type's `'read'` policy into pattern comprehensions, connection edges, and union branches. Policies cannot be bypassed by traversal.
29
+ - **Default-deny baseline.** `policyDefaults.onDeny: 'empty'` (default) emits `WHERE false` when no permissive matches; `'throw'` raises `PolicyDeniedError` before the query runs.
30
+ - **Audit metadata.** Every OGM-emitted query when policies are configured attaches `tx.setMetaData({ ogmPolicySetVersion, ctxFingerprint, modelType, operation, policiesEvaluated, bypassed })`. `ctxFingerprint` is a SHA-256 of the SORTED ctx KEYS only — never values, never anything sensitive.
31
+ - **Escape hatches.** `ogm.unsafe.bypassPolicies()` returns a non-policy-aware OGM (logged via `logger.warn`); per-method `unsafe: { bypassPolicies: true }` skips policies for a single call (also logged).
32
+ - **Interface-aware enforcement.** `InterfaceModel.find()` / `aggregate()` emit a CASE-per-label WHERE that AND-combines each implementer's `'read'` policy with the interface-level policy. Concrete-type policies are NOT bypassed when querying through the interface.
33
+ - **`PolicyDeniedError`** — new public error class extending `OGMError`. Carries `typeName`, `operation`, `reason` (`'no-permissive-matched' | 'restrictive-rejected-input' | 'override-failed-validation'`), and optional `policyName`.
34
+
35
+ ### Contract change during beta — read/write restrictive split
36
+
37
+ The pre-fix beta invoked `RestrictivePolicy.when` twice on every write path: once at the application layer with `(ctx, input)` (WITH CHECK semantics) and once at WHERE-compile with `(ctx)` only (row predicate). Side-effecting callbacks fired inconsistently and any predicate that legitimately depended on `input` returned `false` at compile time → `WHERE false` → reads silently blocked.
38
+
39
+ **Fix:** `RestrictivePolicy` is now a discriminated union over `operations`.
40
+
41
+ - **`ReadRestrictivePolicy`** — `operations` includes only `read|delete|aggregate|count`. `when(ctx)` returns a `<Node>Where` partial OR `false`. Compiles into the WHERE clause via `WhereCompiler`. Invoked exactly once per read-side query.
42
+ - **`WriteRestrictivePolicy`** — `operations` includes only `create|update`. `when(ctx, input)` returns a boolean (where-partial returns are no longer accepted; if you need a row filter on `update`, register a `ReadRestrictive` on `read`/`delete`). Runs at the application layer ("WITH CHECK"). Invoked exactly once per write op. The `cypher` escape hatch is not supported on write restrictives.
43
+ - **Mixed-operation arrays are rejected** at construction time with `OGMError`. Split a single `restrictive({ operations: ['read', 'create'], … })` into two restrictives — one per kind.
44
+
45
+ The `restrictive()` constructor uses TypeScript overloads so authoring code gets the correct `when` signature inferred from the literal `operations` tuple. New runtime helpers `isReadRestrictive` / `isWriteRestrictive` are exported for users who need to inspect a policy at runtime.
46
+
47
+ This is a contract change inside the beta window — the API has shifted before `1.7.0` final, which is exactly what the beta dist-tag exists for. No public types were removed; `RestrictivePolicy` remains a public type and resolves to the union of the two new flavors.
48
+
49
+ ### Public API additions (additive only)
50
+
51
+ - New exports: `override`, `permissive`, `restrictive`, `isReadRestrictive`, `isWriteRestrictive`, `OGMWithContext`, `PolicyDeniedError`.
52
+ - New types: `Policy`, `OverridePolicy`, `PermissivePolicy`, `RestrictivePolicy`, `ReadRestrictivePolicy`, `WriteRestrictivePolicy`, `PolicyContext`, `PolicyContextBundle`, `Operation`, `OperationOrWildcard`, `ReadOperation`, `WriteOperation`, `PoliciesByModel`, `PolicyDefaults`, `ResolvedPolicies`, `UnsafeOptions`.
53
+ - `OGMConfig` gains optional `policies?` and `policyDefaults?`.
54
+ - `OGM` gains `withContext<C>(ctx: C)` and `unsafe.bypassPolicies()`.
55
+ - Every Model / InterfaceModel method's params bag gains optional `unsafe?: { bypassPolicies?: boolean }`.
56
+ - All existing call signatures remain valid. Calling code that doesn't pass policies must compile and run identically — byte-identical Cypher for non-policy calls.
57
+
58
+ ### Install command (beta)
59
+
60
+ ```bash
61
+ npm install grafeo-ogm@beta
62
+ # or
63
+ pnpm add grafeo-ogm@beta
64
+ ```
65
+
66
+ ### Known limits in this beta
67
+
68
+ - **`@cypher` scalar fields inside a policy `where`-partial throw when the policy is injected into nested-selection enforcement.** Refactor the policy to use stored properties or a relationship traversal. (Top-level WHERE on the root model still supports `@cypher` filters.)
69
+ - **`upsert` evaluates create- and update-side policies at the application layer.** MERGE has no WHERE we can stitch into; the WHERE-side enforcement only covers the matching path. Documented limit; full MERGE-aware enforcement is deferred to v1.7.1.
70
+ - **Restrictive read/write split — fixed in this iteration.** The dual-invocation contract bug present in earlier beta builds is resolved. `ReadRestrictivePolicy` (`when(ctx)`) is invoked exactly once at WHERE-compile; `WriteRestrictivePolicy` (`when(ctx, input)`) is invoked exactly once at the application layer. See the "Contract change during beta" section above for the new shape and migration notes.
71
+ - **InterfaceModel CASE-per-label fallback.** When an interface has policies registered but a concrete implementer does not, the implementer's branch falls back to interface-level enforcement only (rather than default-denying the unlisted implementer). The OGM emits a `logger.warn` at construction time when this configuration is detected so it never passes silently. Strict per-implementer default-deny is being evaluated for `1.7.0` final.
72
+ - **AsyncLocalStorage opt-in is deferred to v1.7.1.** Beta is explicit `withContext()` only — create one wrapper per request and discard it.
73
+ - **Index requirement declaration deferred to v1.8.0.** No `requires.indexes` config in this beta.
74
+ - **EXPLAIN-in-test mode deferred to v1.8.0.** No dev hook in beta.
75
+ - **No live Neo4j integration test in this beta.** Mock-driver coverage is extensive (1317 specs incl. byte-identical regression of every v1.6.0 emission and the C1 read/write contract proofs), but a live `pnpm run test:integration` against a real Neo4j is a `1.7.0` final blocker.
76
+
77
+ ### Unaffected paths (byte-identical to v1.6.0)
78
+
79
+ - An OGM constructed without `policies` emits identical Cypher to v1.6.0 for every covered operation. Verified in `tests/policy/byte-identical.spec.ts`.
80
+ - An OGM with `policies` but invoked via the bare `OGM.model()` path (no `withContext`) emits identical Cypher to v1.6.0.
81
+ - An override match emits identical Cypher to a no-policy query.
82
+ - `ogm.unsafe.bypassPolicies()` and per-call `unsafe: { bypassPolicies: true }` both emit identical Cypher to v1.6.0.
83
+
84
+ ### Generated types
85
+
86
+ - Existing `<Node>Where`, `<Node>CreateInput`, `<Node>UpdateInput` are sufficient for typing policy callbacks. The generator's `ModelMap` already exposed `Where`, `CreateInput`, and `UpdateInput` keys per model so `PoliciesByModel<M, C>` can index into them. **No regeneration required when upgrading from v1.6.0 → v1.7.0-beta.0.**
87
+
88
+ ### Migration
89
+
90
+ - v1.6.0 → v1.7.0-beta.0 is purely additive — no changes required unless you opt into NLS.
91
+
3
92
  ## 1.6.0 (2026-05-01)
4
93
 
5
94
  ### Features
package/README.md CHANGED
@@ -213,6 +213,24 @@ await ogm.$transaction(async (ctx) => {
213
213
 
214
214
  ---
215
215
 
216
+ ## Beta features
217
+
218
+ The current beta release exposes one new feature behind opt-in config:
219
+
220
+ - **Node-Level Security (NLS)** — a Postgres-RLS-style filter layer that compiles into the existing WHERE pipeline. See **[Advanced Features → Node-Level Security (Beta)](#node-level-security-beta)**.
221
+
222
+ The beta is purely additive: an OGM constructed without the new `policies` option emits **byte-identical Cypher** to v1.6.0. Install with:
223
+
224
+ ```bash
225
+ npm install grafeo-ogm@beta
226
+ # or
227
+ pnpm add grafeo-ogm@beta
228
+ ```
229
+
230
+ The shape of the public API is settled, but the beta window is for collecting integration feedback. Minor adjustments may land before `1.7.0` final.
231
+
232
+ ---
233
+
216
234
  ## Features Overview
217
235
 
218
236
  | Feature | Description |
@@ -918,6 +936,171 @@ These are not OGM bugs — they are Cypher-language constraints. Pattern compreh
918
936
 
919
937
  Each scope (`where`, `select`, sort) emits its own `CALL { WITH n; WITH n AS this; <statement> }` block per `@cypher` field reference. A query that references the same `@cypher` field in all three scopes emits three `CALL` blocks. This is functionally correct and parameter-isolated, but worth knowing if your statement is expensive (multi-hop traversals, aggregations over large fan-outs). For frequently-accessed computed values, consider materializing as a stored property updated on write instead.
920
938
 
939
+ ### Node-Level Security (Beta)
940
+
941
+ > **Beta — install with `npm install grafeo-ogm@beta`.**
942
+
943
+ Node-Level Security (NLS) is a per-request filter layer that compiles into the existing WHERE pipeline. Policies return `<Node>Where` partials, so every operator, quantifier, connection filter, and nested traversal already supported by `WhereCompiler` is automatically available inside policies — no new DSL.
944
+
945
+ #### Declaration
946
+
947
+ ```typescript
948
+ import { OGM, override, permissive, restrictive } from 'grafeo-ogm';
949
+
950
+ interface RequestCtx {
951
+ userId: string;
952
+ capabilities: string[];
953
+ tierIds: string[];
954
+ }
955
+
956
+ const ogm = new OGM<ModelMap>({
957
+ typeDefs,
958
+ driver,
959
+ policies: {
960
+ Book: [
961
+ // Compile-time short-circuit. When `when(ctx)` returns true, ALL
962
+ // other policies for `Book.read` are dropped — query is byte-
963
+ // identical to a no-policy query. Zero runtime cost for admins.
964
+ override({
965
+ operations: ['*'],
966
+ when: (c: RequestCtx) => c.capabilities.includes('admin'),
967
+ name: 'admin-bypass',
968
+ }),
969
+
970
+ // OR-grant. Multiple permissives compose with OR — any match grants.
971
+ permissive({
972
+ operations: ['read'],
973
+ when: (c: RequestCtx) => ({ ownerId: c.userId }),
974
+ name: 'owner-can-read',
975
+ }),
976
+
977
+ // Read-side restrictive — AND-row predicate. Compiles into the
978
+ // WHERE clause and applies to read|delete|aggregate|count.
979
+ // `when` receives only `ctx` and returns a where-partial (or
980
+ // `false` for a hard deny).
981
+ restrictive({
982
+ operations: ['read'],
983
+ when: (c: RequestCtx) => ({ tierIds_IN: c.tierIds }),
984
+ name: 'tier-isolation',
985
+ }),
986
+
987
+ // Write-side restrictive — Postgres `WITH CHECK` semantics. Runs
988
+ // at the application layer for create|update only; receives both
989
+ // `ctx` and the user-submitted `input`. MUST return a boolean.
990
+ // The OGM rejects mixed read+write `operations` arrays — split a
991
+ // single restrictive into two if you need both read- and write-
992
+ // side coverage.
993
+ restrictive({
994
+ operations: ['create', 'update'],
995
+ when: (c, input) =>
996
+ (input as { ownerId?: string }).ownerId === c.userId,
997
+ name: 'cannot-set-other-owner',
998
+ }),
999
+ ],
1000
+ },
1001
+ policyDefaults: {
1002
+ onDeny: 'empty', // Or 'throw' — raises PolicyDeniedError pre-compile.
1003
+ auditMetadata: true,
1004
+ },
1005
+ });
1006
+
1007
+ // Per request:
1008
+ const Books = ogm.withContext({ userId, capabilities, tierIds }).model('Book');
1009
+ const visible = await Books.find({ where: { published_GT: '2024-01-01' } });
1010
+ ```
1011
+
1012
+ The Cypher emitted for the call above is roughly:
1013
+
1014
+ ```cypher
1015
+ MATCH (n:`Book`)
1016
+ WHERE (n.`published` > $param0) AND ((n.`ownerId` = $param1) AND n.`tierIds` IN $param2)
1017
+ RETURN n { .id, .title, .ownerId, .tierIds, ... }
1018
+ ```
1019
+
1020
+ #### Hierarchical ACL example
1021
+
1022
+ Combine permissives to express "owner OR member of an allowed tier":
1023
+
1024
+ ```typescript
1025
+ policies: {
1026
+ Book: [
1027
+ permissive({
1028
+ operations: ['read'],
1029
+ when: (c) => ({ ownerId: c.userId }),
1030
+ name: 'owner-can-read',
1031
+ }),
1032
+ permissive({
1033
+ operations: ['read'],
1034
+ // Returns a where-partial that uses the existing _SOME quantifier.
1035
+ when: (c) => ({ tiers_SOME: { id_IN: c.tierIds } }),
1036
+ name: 'tier-can-read',
1037
+ }),
1038
+ ],
1039
+ }
1040
+ ```
1041
+
1042
+ Both permissives OR together — a row passes if either branch matches.
1043
+
1044
+ #### Interface inheritance
1045
+
1046
+ Policies on an interface AND with policies on every implementing concrete type. Concrete types can only narrow restrictives, never broaden — predictable. `OR` composition for permissives lets concrete types add allow paths their interface didn't anticipate.
1047
+
1048
+ ```typescript
1049
+ policies: {
1050
+ Resource: [
1051
+ // Tenant isolation applies to every type that implements Resource.
1052
+ restrictive({ operations: ['read'], when: (c) => ({ tenantId: c.tenantId }) }),
1053
+ ],
1054
+ Book: [
1055
+ // Additional grant only for Book — composed via OR with any
1056
+ // Resource-level permissives.
1057
+ permissive({ operations: ['read'], when: (c) => ({ authorId: c.userId }) }),
1058
+ ],
1059
+ }
1060
+ ```
1061
+
1062
+ #### Enforcement boundary
1063
+
1064
+ > **The OGM enforces policies at the compiler.** Policies do NOT apply to `ogm.$queryRaw`, `ogm.$executeRaw`, or to `@cypher` directive bodies that traverse from a stored field. If you need raw-Cypher enforcement, write the predicate into your raw Cypher manually or layer a Neo4j role/RBAC at the database.
1065
+
1066
+ #### Escape hatches
1067
+
1068
+ ```typescript
1069
+ // Disable policies for a single call. Logged via logger.warn.
1070
+ await Books.find({ where: { id: 'b1' }, unsafe: { bypassPolicies: true } });
1071
+
1072
+ // Disable policies for a derived OGM (data migrations, admin scripts).
1073
+ const adminOgm = ogm.unsafe.bypassPolicies();
1074
+ ```
1075
+
1076
+ Both paths emit byte-identical Cypher to a no-policy OGM.
1077
+
1078
+ #### Audit metadata
1079
+
1080
+ When `policies` is configured, every OGM-emitted query attaches transaction metadata:
1081
+
1082
+ ```typescript
1083
+ {
1084
+ ogmPolicySetVersion: '1.7.0-beta.0',
1085
+ ctxFingerprint: '<sha256 of sorted ctx KEYS — never values>',
1086
+ modelType: 'Book',
1087
+ operation: 'read',
1088
+ policiesEvaluated: ['owner-can-read', 'tier-isolation'],
1089
+ bypassed: false,
1090
+ }
1091
+ ```
1092
+
1093
+ Disable via `policyDefaults: { auditMetadata: false }`. The fingerprint is intentionally key-only — no ctx values are leaked.
1094
+
1095
+ #### Beta limits
1096
+
1097
+ - **`@cypher` scalar fields inside a policy `where`-partial throw when the policy is injected into nested-selection enforcement.** Refactor the policy to use stored properties or a relationship traversal.
1098
+ - **`upsert`** evaluates create- and update-side policies at the application layer (MERGE has no WHERE). Documented limit; full MERGE-aware enforcement is deferred to v1.7.1.
1099
+ - **Restrictives are split into read-side and write-side flavors.** A restrictive's `operations` array determines which `when` signature applies. Read-side ops (`read|delete|aggregate|count`) → `when(ctx)` returns a where-partial or boolean. Write-side ops (`create|update`) → `when(ctx, input)` returns a boolean only. Mixed arrays (e.g. `['read', 'create']`) are rejected at construction time — split into two restrictives. Each flavor is invoked exactly once per query (read-side at compile, write-side at the application layer); the dual-invocation contract bug from earlier beta iterations is fixed. Use `isReadRestrictive` / `isWriteRestrictive` if you need to inspect a policy at runtime.
1100
+ - **InterfaceModel CASE-per-label fallback.** Implementers without a registered policy fall back to interface-level enforcement on their branch. The OGM emits a `logger.warn` at construction time when an interface has policies and one of its implementers does not — silence the warning by registering an explicit policy on each implementer.
1101
+ - **AsyncLocalStorage** opt-in is deferred to v1.7.1. Beta is explicit `withContext()` only — create one wrapper per request, discard after.
1102
+ - **Live Neo4j integration tests are not part of the beta.** Mock-driver coverage is extensive but a real-DB suite is a `1.7.0` final blocker.
1103
+
921
1104
  ### Raw Cypher
922
1105
 
923
1106
  ```typescript
@@ -1,3 +1,4 @@
1
+ import type { PolicyContextBundle } from '../policy/types';
1
2
  import type { NodeDefinition, SchemaMetadata } from '../schema/types';
2
3
  import { CypherFieldScope } from '../utils/cypher-field-projection';
3
4
  import type { WhereCompiler } from './where.compiler';
@@ -70,7 +71,15 @@ export declare class SelectionCompiler {
70
71
  * throw — this is the case inside nested relationship pattern
71
72
  * comprehensions where CALL { ... } subqueries are not allowed.
72
73
  */
73
- cypherScope?: CypherFieldScope | null): string;
74
+ cypherScope?: CypherFieldScope | null,
75
+ /**
76
+ * Policy context for nested-selection enforcement. When set, every
77
+ * relationship pattern comprehension, connection edge, and union
78
+ * branch resolves the target type's `'read'` policy via
79
+ * `policyContext.resolveForType` and AND-stitches it into the inner
80
+ * WHERE clause. Without this, traversals would bypass policies.
81
+ */
82
+ policyContext?: PolicyContextBundle | null): string;
74
83
  /**
75
84
  * Parse a GraphQL selection set string into SelectionNode[].
76
85
  *
@@ -110,6 +119,17 @@ export declare class SelectionCompiler {
110
119
  * Compile a connection field into a Cypher pattern comprehension with edge properties.
111
120
  */
112
121
  private compileConnection;
122
+ /**
123
+ * AND-combine an optional user where (`select.where` / `connectionWhere`)
124
+ * with the target type's `'read'` policy and emit a `WHERE …` fragment
125
+ * (with leading space). Returns `''` when nothing applies.
126
+ *
127
+ * The returned where lives inside a pattern comprehension so any
128
+ * `@cypher` field used by the policy MUST throw — the comprehension
129
+ * cannot host CALL { ... } subqueries. The error message points to the
130
+ * documented limitation.
131
+ */
132
+ private compileNestedWhere;
113
133
  /**
114
134
  * Compile a __typename expression for a node.
115
135
  * For union targets: picks the label matching a union member name.
@@ -1 +1 @@
1
- {"version":3,"file":"selection.compiler.d.ts","sourceRoot":"","sources":["../../../src/compilers/selection.compiler.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,cAAc,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAEtE,OAAO,EAAE,gBAAgB,EAAE,MAAM,kCAAkC,CAAC;AAOpE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAEtD;;GAEG;AACH,MAAM,WAAW,aAAa;IAC5B,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,OAAO,CAAC;IAClB,cAAc,EAAE,OAAO,CAAC;IACxB,YAAY,EAAE,OAAO,CAAC;IACtB,QAAQ,CAAC,EAAE,aAAa,EAAE,CAAC;IAC3B,gFAAgF;IAChF,iBAAiB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC5C,eAAe,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC1C,YAAY,CAAC,EAAE,aAAa,EAAE,CAAC;IAC/B,mFAAmF;IACnF,OAAO,CAAC,EAAE,KAAK,CAAC;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,KAAK,GAAG,MAAM,CAAA;KAAE,CAAC,CAAC;IAC9D;;;OAGG;IACH,iBAAiB,CAAC,EAAE,KAAK,CAAC;QACxB,KAAK,EAAE,MAAM,CAAC;QACd,SAAS,EAAE,KAAK,GAAG,MAAM,CAAC;QAC1B,KAAK,EAAE,MAAM,GAAG,MAAM,CAAC;KACxB,CAAC,CAAC;CACJ;AAED;;;;;GAKG;AACH,qBAAa,iBAAiB;IAW1B,OAAO,CAAC,MAAM;IAVhB,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,iBAAiB,CAAK;IAC9C;;;;OAIG;IACH,OAAO,CAAC,UAAU,CAAsC;IACxD,OAAO,CAAC,aAAa,CAAC,CAAgB;gBAG5B,MAAM,EAAE,cAAc,EAC9B,aAAa,CAAC,EAAE,aAAa;IAK/B,8EAA8E;IAC9E,UAAU,IAAI,IAAI;IAIlB;;;;;;;;;OASG;IACH,OAAO,CACL,SAAS,EAAE,aAAa,EAAE,EAC1B,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,cAAc,EACvB,QAAQ,GAAE,MAA4C,EACtD,YAAY,GAAE,MAAU,EACxB,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAChC,YAAY,CAAC,EAAE;QAAE,KAAK,EAAE,MAAM,CAAA;KAAE;IAChC;;;;;;;OAOG;IACH,WAAW,GAAE,gBAAgB,GAAG,IAAW,GAC1C,MAAM;IAmHT;;;;;OAKG;IACH,iBAAiB,CAAC,YAAY,EAAE,MAAM,GAAG,aAAa,EAAE;IA8BxD;;OAEG;IACH,OAAO,CAAC,mBAAmB;IAgD3B;;OAEG;IACH,OAAO,CAAC,uBAAuB;IA0B/B;;OAEG;IACH,OAAO,CAAC,mBAAmB;IAuF3B;;;;;;;;;;;;;;OAcG;IACH,OAAO,CAAC,6BAA6B;IA6DrC;;OAEG;IACH,OAAO,CAAC,iBAAiB;IAuIzB;;;;OAIG;IACH,OAAO,CAAC,eAAe;IAoBvB;;;OAGG;IACH,OAAO,CAAC,kBAAkB;CAsC3B"}
1
+ {"version":3,"file":"selection.compiler.d.ts","sourceRoot":"","sources":["../../../src/compilers/selection.compiler.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,iBAAiB,CAAC;AAC3D,OAAO,KAAK,EAAE,cAAc,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAEtE,OAAO,EAAE,gBAAgB,EAAE,MAAM,kCAAkC,CAAC;AAOpE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAEtD;;GAEG;AACH,MAAM,WAAW,aAAa;IAC5B,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,OAAO,CAAC;IAClB,cAAc,EAAE,OAAO,CAAC;IACxB,YAAY,EAAE,OAAO,CAAC;IACtB,QAAQ,CAAC,EAAE,aAAa,EAAE,CAAC;IAC3B,gFAAgF;IAChF,iBAAiB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC5C,eAAe,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC1C,YAAY,CAAC,EAAE,aAAa,EAAE,CAAC;IAC/B,mFAAmF;IACnF,OAAO,CAAC,EAAE,KAAK,CAAC;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,KAAK,GAAG,MAAM,CAAA;KAAE,CAAC,CAAC;IAC9D;;;OAGG;IACH,iBAAiB,CAAC,EAAE,KAAK,CAAC;QACxB,KAAK,EAAE,MAAM,CAAC;QACd,SAAS,EAAE,KAAK,GAAG,MAAM,CAAC;QAC1B,KAAK,EAAE,MAAM,GAAG,MAAM,CAAC;KACxB,CAAC,CAAC;CACJ;AAED;;;;;GAKG;AACH,qBAAa,iBAAiB;IAW1B,OAAO,CAAC,MAAM;IAVhB,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,iBAAiB,CAAK;IAC9C;;;;OAIG;IACH,OAAO,CAAC,UAAU,CAAsC;IACxD,OAAO,CAAC,aAAa,CAAC,CAAgB;gBAG5B,MAAM,EAAE,cAAc,EAC9B,aAAa,CAAC,EAAE,aAAa;IAK/B,8EAA8E;IAC9E,UAAU,IAAI,IAAI;IAIlB;;;;;;;;;OASG;IACH,OAAO,CACL,SAAS,EAAE,aAAa,EAAE,EAC1B,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,cAAc,EACvB,QAAQ,GAAE,MAA4C,EACtD,YAAY,GAAE,MAAU,EACxB,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAChC,YAAY,CAAC,EAAE;QAAE,KAAK,EAAE,MAAM,CAAA;KAAE;IAChC;;;;;;;OAOG;IACH,WAAW,GAAE,gBAAgB,GAAG,IAAW;IAC3C;;;;;;OAMG;IACH,aAAa,GAAE,mBAAmB,GAAG,IAAW,GAC/C,MAAM;IAsHT;;;;;OAKG;IACH,iBAAiB,CAAC,YAAY,EAAE,MAAM,GAAG,aAAa,EAAE;IA8BxD;;OAEG;IACH,OAAO,CAAC,mBAAmB;IAgD3B;;OAEG;IACH,OAAO,CAAC,uBAAuB;IA0B/B;;OAEG;IACH,OAAO,CAAC,mBAAmB;IA0E3B;;;;;;;;;;;;;;OAcG;IACH,OAAO,CAAC,6BAA6B;IA8ErC;;OAEG;IACH,OAAO,CAAC,iBAAiB;IAiJzB;;;;;;;;;OASG;IACH,OAAO,CAAC,kBAAkB;IA2F1B;;;;OAIG;IACH,OAAO,CAAC,eAAe;IAoBvB;;;OAGG;IACH,OAAO,CAAC,kBAAkB;CAsC3B"}
@@ -45,7 +45,15 @@ class SelectionCompiler {
45
45
  * throw — this is the case inside nested relationship pattern
46
46
  * comprehensions where CALL { ... } subqueries are not allowed.
47
47
  */
48
- cypherScope = null) {
48
+ cypherScope = null,
49
+ /**
50
+ * Policy context for nested-selection enforcement. When set, every
51
+ * relationship pattern comprehension, connection edge, and union
52
+ * branch resolves the target type's `'read'` policy via
53
+ * `policyContext.resolveForType` and AND-stitches it into the inner
54
+ * WHERE clause. Without this, traversals would bypass policies.
55
+ */
56
+ policyContext = null) {
49
57
  if (selection.length === 0)
50
58
  return `${nodeVar} { .id }`;
51
59
  // Check if this nodeDef is a resolved union type — if so, relationship
@@ -100,7 +108,7 @@ class SelectionCompiler {
100
108
  continue;
101
109
  }
102
110
  if (node.isConnection) {
103
- const connectionCypher = this.compileConnection(node, nodeVar, nodeDef, maxDepth, currentDepth, params, paramCounter);
111
+ const connectionCypher = this.compileConnection(node, nodeVar, nodeDef, maxDepth, currentDepth, params, paramCounter, policyContext);
104
112
  if (connectionCypher)
105
113
  parts.push(`${node.fieldName}: ${connectionCypher}`);
106
114
  continue;
@@ -109,12 +117,12 @@ class SelectionCompiler {
109
117
  // For union targets, relationship fields may have different relationship
110
118
  // types across members. Generate CASE WHEN branches per member.
111
119
  if (unionMembers && unionMembers.length > 0) {
112
- const caseCypher = this.compileUnionRelationshipField(node, nodeVar, unionMembers, maxDepth, currentDepth, params, paramCounter);
120
+ const caseCypher = this.compileUnionRelationshipField(node, nodeVar, unionMembers, maxDepth, currentDepth, params, paramCounter, policyContext);
113
121
  if (caseCypher)
114
122
  parts.push(`${node.fieldName}: ${caseCypher}`);
115
123
  continue;
116
124
  }
117
- const relCypher = this.compileRelationship(node, nodeVar, nodeDef, maxDepth, currentDepth, params, paramCounter);
125
+ const relCypher = this.compileRelationship(node, nodeVar, nodeDef, maxDepth, currentDepth, params, paramCounter, policyContext);
118
126
  if (relCypher)
119
127
  parts.push(`${node.fieldName}: ${relCypher}`);
120
128
  continue;
@@ -220,7 +228,7 @@ class SelectionCompiler {
220
228
  /**
221
229
  * Compile a relationship field into a Cypher pattern comprehension.
222
230
  */
223
- compileRelationship(node, parentVar, parentDef, maxDepth, currentDepth, params, paramCounter) {
231
+ compileRelationship(node, parentVar, parentDef, maxDepth, currentDepth, params, paramCounter, policyContext = null) {
224
232
  if (currentDepth >= maxDepth)
225
233
  return null;
226
234
  const relDef = parentDef.relationships.get(node.fieldName);
@@ -238,26 +246,19 @@ class SelectionCompiler {
238
246
  schema: this.schema,
239
247
  });
240
248
  const children = node.children ?? [];
241
- const innerProjection = this.compile(children, childVar, targetDef, maxDepth, currentDepth + 1, params, paramCounter);
242
- // Compile WHERE clause for relationship filtering (Prisma-like select)
243
- let whereClause = '';
244
- if (node.relationshipWhere &&
245
- Object.keys(node.relationshipWhere).length > 0 &&
246
- this.whereCompiler &&
247
- params &&
248
- paramCounter) {
249
- const whereResult = this.whereCompiler.compile(node.relationshipWhere, childVar, targetDef, paramCounter);
250
- // Pattern comprehensions cannot host CALL { ... } subqueries, so we
251
- // cannot stitch `@cypher` field preludes here. Reject with a clear
252
- // error rather than silently dropping them.
253
- if (whereResult.preludes && whereResult.preludes.length > 0)
254
- throw new errors_1.OGMError(`Filtering nested-select relationship "${node.fieldName}" by an @cypher field is not supported. ` +
255
- `@cypher fields can only appear in top-level WHERE filters, not inside select.where on relationships.`);
256
- if (whereResult.cypher) {
257
- (0, validation_1.mergeParams)(params, whereResult.params);
258
- whereClause = ` WHERE ${whereResult.cypher}`;
259
- }
260
- }
249
+ const innerProjection = this.compile(children, childVar, targetDef, maxDepth, currentDepth + 1, params, paramCounter, null, policyContext);
250
+ // Compile WHERE clause for relationship filtering (Prisma-like select).
251
+ // Combines user `select.where` with the target type's `'read'` policy.
252
+ const whereClause = this.compileNestedWhere({
253
+ node,
254
+ childVar,
255
+ targetDef,
256
+ params,
257
+ paramCounter,
258
+ policyContext,
259
+ contextLabel: `relationship "${node.fieldName}"`,
260
+ userWhere: node.relationshipWhere,
261
+ });
261
262
  const comprehension = `[${pattern}${whereClause} | ${innerProjection}]`;
262
263
  // Wrap singular relationships with head()
263
264
  if (!relDef.isArray)
@@ -291,7 +292,7 @@ class SelectionCompiler {
291
292
  * ELSE []
292
293
  * END
293
294
  */
294
- compileUnionRelationshipField(node, nodeVar, unionMembers, maxDepth, currentDepth, params, paramCounter) {
295
+ compileUnionRelationshipField(node, nodeVar, unionMembers, maxDepth, currentDepth, params, paramCounter, policyContext = null) {
295
296
  if (currentDepth >= maxDepth)
296
297
  return null;
297
298
  const branches = [];
@@ -311,7 +312,7 @@ class SelectionCompiler {
311
312
  isSingular = true;
312
313
  const childVar = `n${currentDepth}`;
313
314
  const children = node.children ?? [];
314
- const innerProjection = this.compile(children, childVar, targetDef, maxDepth, currentDepth + 1, params, paramCounter);
315
+ const innerProjection = this.compile(children, childVar, targetDef, maxDepth, currentDepth + 1, params, paramCounter, null, policyContext);
315
316
  const pattern = (0, utils_1.buildRelPattern)({
316
317
  sourceVar: nodeVar,
317
318
  relDef,
@@ -319,10 +320,23 @@ class SelectionCompiler {
319
320
  targetLabel: 'auto',
320
321
  schema: this.schema,
321
322
  });
323
+ // Inject the union member's `'read'` policy into the comprehension WHERE.
324
+ const memberPolicyWhere = this.compileNestedWhere({
325
+ node,
326
+ childVar,
327
+ targetDef,
328
+ params,
329
+ paramCounter,
330
+ policyContext,
331
+ contextLabel: `union member "${memberName}" of "${node.fieldName}"`,
332
+ // Union branches don't have a user select.where in the
333
+ // SelectionNode model — only policies contribute here.
334
+ userWhere: undefined,
335
+ });
322
336
  // Wrap singular relationships with head() to return object instead of array
323
337
  const comprehension = isSingular
324
- ? `head([${pattern} | ${innerProjection}])`
325
- : `[${pattern} | ${innerProjection}]`;
338
+ ? `head([${pattern}${memberPolicyWhere} | ${innerProjection}])`
339
+ : `[${pattern}${memberPolicyWhere} | ${innerProjection}]`;
326
340
  const memberLabel = (0, validation_1.escapeIdentifier)(memberName);
327
341
  branches.push(`WHEN ${nodeVar}:${memberLabel} THEN ${comprehension}`);
328
342
  }
@@ -334,7 +348,7 @@ class SelectionCompiler {
334
348
  /**
335
349
  * Compile a connection field into a Cypher pattern comprehension with edge properties.
336
350
  */
337
- compileConnection(node, parentVar, parentDef, maxDepth, currentDepth, params, paramCounter) {
351
+ compileConnection(node, parentVar, parentDef, maxDepth, currentDepth, params, paramCounter, policyContext = null) {
338
352
  if (currentDepth >= maxDepth)
339
353
  return null;
340
354
  // Strip 'Connection' suffix to find the relationship field
@@ -359,7 +373,7 @@ class SelectionCompiler {
359
373
  const projectionKeys = [];
360
374
  // Compile node projection
361
375
  if (node.children && node.children.length > 0) {
362
- const nodeProjection = this.compile(node.children, childVar, targetDef, maxDepth, currentDepth + 1, params, paramCounter);
376
+ const nodeProjection = this.compile(node.children, childVar, targetDef, maxDepth, currentDepth + 1, params, paramCounter, null, policyContext);
363
377
  mapParts.push(`node: ${nodeProjection}`);
364
378
  projectionKeys.push('node');
365
379
  }
@@ -386,7 +400,8 @@ class SelectionCompiler {
386
400
  sortKeyTokens.push(direction === 'ASC' ? `'^${syntheticKey}'` : `'${syntheticKey}'`);
387
401
  });
388
402
  const innerMap = `{ ${mapParts.join(', ')} }`;
389
- // Add WHERE clause if connectionWhere is present
403
+ // Add WHERE clause if connectionWhere is present, AND-combined with the
404
+ // target type's `'read'` policy.
390
405
  let whereClause = '';
391
406
  if (node.connectionWhere && Object.keys(node.connectionWhere).length > 0) {
392
407
  // Reject unsupported edge WHERE filters with a descriptive error
@@ -398,23 +413,35 @@ class SelectionCompiler {
398
413
  ? node.connectionWhere.node
399
414
  : node.connectionWhere;
400
415
  // If we have a full WhereCompiler, delegate to it for complete operator support
401
- if (this.whereCompiler && targetDef) {
402
- const whereResult = this.whereCompiler.compile(nodeWhere, childVar, targetDef, paramCounter);
403
- if (whereResult.preludes && whereResult.preludes.length > 0)
404
- throw new errors_1.OGMError(`Filtering connection "${node.fieldName}" by an @cypher field is not supported. ` +
405
- `@cypher fields can only appear in top-level WHERE filters, not inside connection where.`);
406
- if (whereResult.cypher) {
407
- if (params)
408
- (0, validation_1.mergeParams)(params, whereResult.params);
409
- whereClause = ` WHERE ${whereResult.cypher}`;
410
- }
411
- }
416
+ if (this.whereCompiler && targetDef)
417
+ whereClause = this.compileNestedWhere({
418
+ node,
419
+ childVar,
420
+ targetDef,
421
+ params,
422
+ paramCounter,
423
+ policyContext,
424
+ contextLabel: `connection "${node.fieldName}"`,
425
+ userWhere: nodeWhere,
426
+ });
412
427
  else {
413
428
  const simpleWhere = this.compileSimpleWhere(node.connectionWhere, childVar, params, paramCounter);
414
429
  if (simpleWhere)
415
430
  whereClause = ` WHERE ${simpleWhere}`;
416
431
  }
417
432
  }
433
+ else if (this.whereCompiler && targetDef)
434
+ // No user where but we still need the policy injected if active.
435
+ whereClause = this.compileNestedWhere({
436
+ node,
437
+ childVar,
438
+ targetDef,
439
+ params,
440
+ paramCounter,
441
+ policyContext,
442
+ contextLabel: `connection "${node.fieldName}"`,
443
+ userWhere: undefined,
444
+ });
418
445
  if (sortKeyTokens.length === 0)
419
446
  return `{ edges: [${pattern}${whereClause} | ${innerMap}] }`;
420
447
  // Re-project only the user-facing keys so synthetic __skN scalars don't
@@ -424,6 +451,71 @@ class SelectionCompiler {
424
451
  : `{}`;
425
452
  return `{ edges: [x IN apoc.coll.sortMulti([${pattern}${whereClause} | ${innerMap}], [${sortKeyTokens.join(', ')}]) | ${outerProjection}] }`;
426
453
  }
454
+ /**
455
+ * AND-combine an optional user where (`select.where` / `connectionWhere`)
456
+ * with the target type's `'read'` policy and emit a `WHERE …` fragment
457
+ * (with leading space). Returns `''` when nothing applies.
458
+ *
459
+ * The returned where lives inside a pattern comprehension so any
460
+ * `@cypher` field used by the policy MUST throw — the comprehension
461
+ * cannot host CALL { ... } subqueries. The error message points to the
462
+ * documented limitation.
463
+ */
464
+ compileNestedWhere(args) {
465
+ const { node, childVar, targetDef, params, paramCounter, policyContext, contextLabel, userWhere, } = args;
466
+ if (!this.whereCompiler || !params || !paramCounter) {
467
+ // Without these, the policy can't be compiled — fall back to user-only
468
+ // (matches v1.6.0 behavior when WhereCompiler isn't wired).
469
+ if (userWhere && Object.keys(userWhere).length > 0)
470
+ // We can't compile via whereCompiler without paramCounter; let
471
+ // the fallback simple where path handle it.
472
+ return '';
473
+ return '';
474
+ }
475
+ // Resolve the target type's read policy (if any).
476
+ const targetPolicy = policyContext
477
+ ? policyContext.resolveForType(targetDef.typeName, 'read')
478
+ : null;
479
+ const targetBundle = targetPolicy
480
+ ? {
481
+ ctx: policyContext.ctx,
482
+ operation: 'read',
483
+ resolved: targetPolicy,
484
+ resolveForType: policyContext.resolveForType,
485
+ defaults: policyContext.defaults,
486
+ }
487
+ : undefined;
488
+ // Compile combined user where + policy in one pass.
489
+ const compiled = this.whereCompiler.compile(userWhere, childVar, targetDef, paramCounter, targetBundle ? { policyContext: targetBundle } : undefined);
490
+ // Pattern comprehensions cannot host CALL { ... } subqueries.
491
+ if (compiled.preludes && compiled.preludes.length > 0) {
492
+ // Two distinct cases:
493
+ // - user where references @cypher → original v1.6.0 error path
494
+ // - policy references @cypher inside nested selection → new path
495
+ const policyHasCypher = targetBundle &&
496
+ compiled.cypher.length > 0 &&
497
+ // Heuristic: if the user where alone produces no cypher refs but
498
+ // adding policy did, then policy is the source. We use a clearer
499
+ // policy-targeted error since both paths are equivalent for the
500
+ // user.
501
+ true;
502
+ if (policyHasCypher && targetBundle)
503
+ throw new errors_1.OGMError(`Policy on "${targetDef.typeName}" requires @cypher field projection, ` +
504
+ `which is not supported inside nested-selection enforcement (${contextLabel}). ` +
505
+ `Refactor the policy to use stored properties or a relationship traversal.`);
506
+ throw new errors_1.OGMError(`Filtering ${contextLabel} by an @cypher field is not supported. ` +
507
+ `@cypher fields can only appear in top-level WHERE filters, not inside select.where on relationships.`);
508
+ }
509
+ if (compiled.cypher) {
510
+ (0, validation_1.mergeParams)(params, compiled.params);
511
+ return ` WHERE ${compiled.cypher}`;
512
+ }
513
+ // Compile produced no clause but may still have merged params.
514
+ if (compiled.params && Object.keys(compiled.params).length > 0)
515
+ (0, validation_1.mergeParams)(params, compiled.params);
516
+ void node;
517
+ return '';
518
+ }
427
519
  /**
428
520
  * Compile a __typename expression for a node.
429
521
  * For union targets: picks the label matching a union member name.