grafeo-ogm 2.1.1 → 2.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.
Files changed (95) hide show
  1. package/CHANGELOG.md +126 -0
  2. package/README.md +98 -1
  3. package/dist/cjs/compilers/mutation.compiler.d.ts +94 -31
  4. package/dist/cjs/compilers/mutation.compiler.d.ts.map +1 -1
  5. package/dist/cjs/compilers/mutation.compiler.js +381 -343
  6. package/dist/cjs/compilers/mutation.compiler.js.map +1 -1
  7. package/dist/cjs/compilers/selection.compiler.d.ts +1 -1
  8. package/dist/cjs/compilers/selection.compiler.d.ts.map +1 -1
  9. package/dist/cjs/compilers/selection.compiler.js +33 -39
  10. package/dist/cjs/compilers/selection.compiler.js.map +1 -1
  11. package/dist/cjs/compilers/where.compiler.d.ts +206 -6
  12. package/dist/cjs/compilers/where.compiler.d.ts.map +1 -1
  13. package/dist/cjs/compilers/where.compiler.js +525 -176
  14. package/dist/cjs/compilers/where.compiler.js.map +1 -1
  15. package/dist/cjs/execution/executor.d.ts +11 -1
  16. package/dist/cjs/execution/executor.d.ts.map +1 -1
  17. package/dist/cjs/execution/executor.js +6 -3
  18. package/dist/cjs/execution/executor.js.map +1 -1
  19. package/dist/cjs/index.d.ts +2 -2
  20. package/dist/cjs/index.d.ts.map +1 -1
  21. package/dist/cjs/index.js.map +1 -1
  22. package/dist/cjs/interface-model.d.ts +20 -10
  23. package/dist/cjs/interface-model.d.ts.map +1 -1
  24. package/dist/cjs/interface-model.js +47 -104
  25. package/dist/cjs/interface-model.js.map +1 -1
  26. package/dist/cjs/model.d.ts +42 -30
  27. package/dist/cjs/model.d.ts.map +1 -1
  28. package/dist/cjs/model.js +134 -99
  29. package/dist/cjs/model.js.map +1 -1
  30. package/dist/cjs/ogm.d.ts.map +1 -1
  31. package/dist/cjs/ogm.js +1 -0
  32. package/dist/cjs/ogm.js.map +1 -1
  33. package/dist/cjs/policy/explain.d.ts +59 -0
  34. package/dist/cjs/policy/explain.d.ts.map +1 -0
  35. package/dist/cjs/policy/explain.js +175 -0
  36. package/dist/cjs/policy/explain.js.map +1 -0
  37. package/dist/cjs/policy/nested-writes.d.ts +35 -0
  38. package/dist/cjs/policy/nested-writes.d.ts.map +1 -0
  39. package/dist/cjs/policy/nested-writes.js +199 -0
  40. package/dist/cjs/policy/nested-writes.js.map +1 -0
  41. package/dist/cjs/policy/resolver.d.ts +26 -2
  42. package/dist/cjs/policy/resolver.d.ts.map +1 -1
  43. package/dist/cjs/policy/resolver.js +103 -33
  44. package/dist/cjs/policy/resolver.js.map +1 -1
  45. package/dist/cjs/policy/types.d.ts +105 -0
  46. package/dist/cjs/policy/types.d.ts.map +1 -1
  47. package/dist/cjs/policy/types.js +24 -0
  48. package/dist/cjs/policy/types.js.map +1 -1
  49. package/dist/esm/compilers/mutation.compiler.d.ts +94 -31
  50. package/dist/esm/compilers/mutation.compiler.d.ts.map +1 -1
  51. package/dist/esm/compilers/mutation.compiler.js +381 -344
  52. package/dist/esm/compilers/mutation.compiler.js.map +1 -1
  53. package/dist/esm/compilers/selection.compiler.d.ts +1 -1
  54. package/dist/esm/compilers/selection.compiler.d.ts.map +1 -1
  55. package/dist/esm/compilers/selection.compiler.js +33 -39
  56. package/dist/esm/compilers/selection.compiler.js.map +1 -1
  57. package/dist/esm/compilers/where.compiler.d.ts +206 -6
  58. package/dist/esm/compilers/where.compiler.d.ts.map +1 -1
  59. package/dist/esm/compilers/where.compiler.js +524 -178
  60. package/dist/esm/compilers/where.compiler.js.map +1 -1
  61. package/dist/esm/execution/executor.d.ts +11 -1
  62. package/dist/esm/execution/executor.d.ts.map +1 -1
  63. package/dist/esm/execution/executor.js +6 -3
  64. package/dist/esm/execution/executor.js.map +1 -1
  65. package/dist/esm/index.d.ts +2 -2
  66. package/dist/esm/index.d.ts.map +1 -1
  67. package/dist/esm/index.js.map +1 -1
  68. package/dist/esm/interface-model.d.ts +20 -10
  69. package/dist/esm/interface-model.d.ts.map +1 -1
  70. package/dist/esm/interface-model.js +47 -104
  71. package/dist/esm/interface-model.js.map +1 -1
  72. package/dist/esm/model.d.ts +42 -30
  73. package/dist/esm/model.d.ts.map +1 -1
  74. package/dist/esm/model.js +136 -101
  75. package/dist/esm/model.js.map +1 -1
  76. package/dist/esm/ogm.d.ts.map +1 -1
  77. package/dist/esm/ogm.js +1 -0
  78. package/dist/esm/ogm.js.map +1 -1
  79. package/dist/esm/policy/explain.d.ts +59 -0
  80. package/dist/esm/policy/explain.d.ts.map +1 -0
  81. package/dist/esm/policy/explain.js +169 -0
  82. package/dist/esm/policy/explain.js.map +1 -0
  83. package/dist/esm/policy/nested-writes.d.ts +35 -0
  84. package/dist/esm/policy/nested-writes.d.ts.map +1 -0
  85. package/dist/esm/policy/nested-writes.js +193 -0
  86. package/dist/esm/policy/nested-writes.js.map +1 -0
  87. package/dist/esm/policy/resolver.d.ts +26 -2
  88. package/dist/esm/policy/resolver.d.ts.map +1 -1
  89. package/dist/esm/policy/resolver.js +102 -33
  90. package/dist/esm/policy/resolver.js.map +1 -1
  91. package/dist/esm/policy/types.d.ts +105 -0
  92. package/dist/esm/policy/types.d.ts.map +1 -1
  93. package/dist/esm/policy/types.js +23 -0
  94. package/dist/esm/policy/types.js.map +1 -1
  95. package/package.json +1 -1
@@ -1,6 +1,9 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.WhereCompiler = void 0;
4
+ exports.stitchUserAndPolicy = stitchUserAndPolicy;
5
+ exports.traversalBundle = traversalBundle;
6
+ exports.policyValueExpression = policyValueExpression;
4
7
  const errors_1 = require("../errors");
5
8
  const types_1 = require("../policy/types");
6
9
  const utils_1 = require("../schema/utils");
@@ -83,46 +86,196 @@ class WhereCompiler {
83
86
  : `[OGM] ${context}.AND has zero effective conditions — no filter emitted (matches everything).`);
84
87
  }
85
88
  compile(where, nodeVar, nodeDef, paramCounter = { count: 0 }, options) {
89
+ const split = this.compileUserAndPolicy(where, nodeVar, nodeDef, paramCounter, options);
90
+ if (!split)
91
+ return { cypher: '', params: {} };
92
+ const cypher = split.policy
93
+ ? stitchUserAndPolicy(split.userCypher, split.policy.clause)
94
+ : split.userCypher;
95
+ const result = {
96
+ cypher,
97
+ params: split.params,
98
+ };
99
+ if (split.preludes)
100
+ result.preludes = split.preludes;
101
+ return result;
102
+ }
103
+ /**
104
+ * Explain-path twin of `compile()`, used by `Model.explainPolicies`.
105
+ * Compiles the user `where` EXACTLY as `compile()` does — same order,
106
+ * same `paramCounter`, same `@cypher` prelude scope, same policy-aware
107
+ * relationship traversal — but returns the root policy clause
108
+ * separately instead of AND-stitching it: as per-policy fragments AND
109
+ * as the composed string `compile()` would have stitched. Both come
110
+ * from one compilation, so an explanation cannot drift from
111
+ * enforcement.
112
+ *
113
+ * `policy` is `null` when no root policy clause applies (an override
114
+ * fired).
115
+ *
116
+ * @internal
117
+ */
118
+ compileForExplain(where, nodeVar, nodeDef, paramCounter, policyContext) {
119
+ const split = this.compileUserAndPolicy(where, nodeVar, nodeDef, paramCounter, { policyContext });
120
+ return {
121
+ userCypher: split?.userCypher ?? '',
122
+ params: split?.params ?? {},
123
+ preludes: split?.preludes ?? [],
124
+ policy: split?.policy ?? null,
125
+ };
126
+ }
127
+ /**
128
+ * The policy clause guarding a node of a possibly ABSTRACT type for one
129
+ * operation (v2.3.0) — the single construction behind every target-
130
+ * policy consumer (nested selection, traversal filters, nested writes)
131
+ * and `InterfaceModel`'s root clause:
132
+ *
133
+ * - concrete type → its own composed clause for `op` (byte-identical
134
+ * to compiling it directly); `null` when it has no policy for `op`
135
+ * or an override fires.
136
+ * - interface / union →
137
+ * `(CASE WHEN v:M1 THEN <M1 clause> WHEN v:M2 THEN … ELSE false END)`
138
+ * over the concrete members, each branch being exactly that member's
139
+ * own clause (`resolveForType(M, op)` already folds in M's interface
140
+ * policies). A member without a policy for `op`, or whose override
141
+ * fires, is `true`; when EVERY member is `true` the result is `null`
142
+ * (unconstrained, like a concrete type without policies). `ELSE false`
143
+ * excludes a node carrying no known member label (defense in depth).
144
+ *
145
+ * Pre-2.3.0 abstract targets resolved the ABSTRACT type's name, so the
146
+ * implementers' own policies were never applied through relationships.
147
+ *
148
+ * `fallbackOp`: resolve it for a type with NO policy for `op` — the
149
+ * `aggregate` → `read` fallback `Model.aggregate` applies at the root.
150
+ *
151
+ * `preludes` (for `@cypher` fields referenced by a policy) are returned
152
+ * to the caller; nested contexts reject them.
153
+ *
154
+ * @internal
155
+ */
156
+ compileTargetPolicyClause(typeName, varName, op, policyContext, paramCounter, fallbackOp) {
157
+ const members = this.abstractMembers(typeName);
158
+ if (members === null)
159
+ return this.compileConcreteTargetClause(typeName, varName, op, policyContext, paramCounter, fallbackOp);
160
+ const params = {};
161
+ const preludes = [];
162
+ const branches = [];
163
+ let constrained = false;
164
+ for (const member of members) {
165
+ if (!this.schema.nodes.has(member))
166
+ continue;
167
+ const clause = this.compileConcreteTargetClause(member, varName, op, policyContext, paramCounter, fallbackOp);
168
+ (0, validation_1.mergeParams)(params, clause.params);
169
+ // Each member compiles in its own `@cypher` scope; chaining two
170
+ // scopes would drop the first one's aliases at the second's `WITH`.
171
+ if (preludes.length > 0 && clause.preludes.length > 0)
172
+ throw new errors_1.OGMError(`Policies on more than one member of "${typeName}" project @cypher ` +
173
+ `fields, which cannot be combined in one query. Refactor all but ` +
174
+ `one to stored properties.`);
175
+ preludes.push(...clause.preludes);
176
+ if (clause.cypher !== null)
177
+ constrained = true;
178
+ branches.push(`WHEN ${varName}:${(0, validation_1.escapeIdentifier)(member)} THEN ${clause.cypher ?? 'true'}`);
179
+ }
180
+ if (!constrained)
181
+ return { cypher: null, params: {}, preludes: [] };
182
+ return {
183
+ cypher: `(CASE ${branches.join(' ')} ELSE false END)`,
184
+ params,
185
+ preludes,
186
+ };
187
+ }
188
+ compileConcreteTargetClause(typeName, varName, op, policyContext, paramCounter, fallbackOp) {
189
+ const nodeDef = this.schema.nodes.get(typeName);
190
+ let resolvedOp = op;
191
+ let resolved = policyContext.resolveForType(typeName, op);
192
+ if (!resolved && fallbackOp) {
193
+ resolvedOp = fallbackOp;
194
+ resolved = policyContext.resolveForType(typeName, fallbackOp);
195
+ }
196
+ if (!nodeDef || !resolved || resolved.overridden)
197
+ return { cypher: null, params: {}, preludes: [] };
198
+ const compiled = this.compile(undefined, varName, nodeDef, paramCounter, {
199
+ policyContext: {
200
+ ctx: policyContext.ctx,
201
+ operation: resolvedOp,
202
+ resolved,
203
+ resolveForType: policyContext.resolveForType,
204
+ defaults: policyContext.defaults,
205
+ },
206
+ });
207
+ return {
208
+ cypher: compiled.cypher || null,
209
+ params: compiled.params,
210
+ preludes: compiled.preludes ?? [],
211
+ };
212
+ }
213
+ /** Concrete members of an interface / union; `null` for any other type. */
214
+ abstractMembers(typeName) {
215
+ if (this.schema.nodes.has(typeName))
216
+ return null;
217
+ const iface = this.schema.interfaces?.get(typeName);
218
+ if (iface)
219
+ return [...iface.implementedBy];
220
+ const union = this.schema.unions?.get(typeName);
221
+ if (union)
222
+ return [...union];
223
+ return null;
224
+ }
225
+ /**
226
+ * Shared body of `compile()` / `compileForExplain()`: compiles the user
227
+ * where, then the policy fragments, sharing one `paramCounter` and one
228
+ * top-level `@cypher` scope. Returns `null` when there is nothing to
229
+ * compile (no user where and no active policy).
230
+ */
231
+ compileUserAndPolicy(where, nodeVar, nodeDef, paramCounter, options) {
86
232
  const hasUserWhere = where != null && Object.keys(where).length > 0;
87
233
  const policyContext = options?.policyContext;
88
234
  const policyActive = policyContext !== undefined && !policyContext.resolved.overridden;
89
235
  if (!hasUserWhere && !policyActive)
90
- return { cypher: '', params: {} };
236
+ return null;
91
237
  // Top-level scope — preludes here are returned to the caller for stitching
92
238
  // BEFORE the WHERE clause. Nested scopes (relationship quantifiers) build
93
239
  // their own scopes and stitch their preludes into the EXISTS body inline.
94
- const scope = new cypher_field_projection_1.CypherFieldScope(nodeVar, options?.preserveVars ?? [], '__where');
240
+ const ownsScope = options?.scope === undefined;
241
+ const scope = options?.scope ??
242
+ new cypher_field_projection_1.CypherFieldScope(nodeVar, options?.preserveVars ?? [], '__where');
95
243
  const userBody = hasUserWhere
96
244
  ? this.compileConditions(where, nodeVar, nodeDef, paramCounter, 0, scope, undefined, policyContext)
97
245
  : { cypher: '', params: {} };
98
- let cypher = userBody.cypher;
99
246
  const params = { ...userBody.params };
247
+ let policy = null;
100
248
  if (policyActive) {
101
- const policyClause = this.compilePolicyClause(policyContext, nodeVar, nodeDef, paramCounter, scope, params);
102
- cypher = stitchUserAndPolicy(cypher, policyClause);
249
+ const fragments = this.compilePolicyFragments(policyContext, nodeVar, nodeDef, paramCounter, scope, params);
250
+ policy = { fragments, clause: composePolicyClause(fragments) };
103
251
  }
104
- const result = {
105
- cypher,
252
+ return {
253
+ userCypher: userBody.cypher,
106
254
  params,
255
+ preludes: ownsScope && scope.hasAny() ? scope.emit() : undefined,
256
+ policy,
107
257
  };
108
- if (scope.hasAny())
109
- result.preludes = scope.emit();
110
- return result;
111
258
  }
112
259
  /**
113
- * Compile the policy clause for a single (typeName, op) frame. AND-
114
- * stitches into the user's body via `stitchUserAndPolicy`. Shares the
115
- * same `paramCounter` and `scope` as the user where so that nothing
116
- * collides downstream.
260
+ * Compile each resolved policy of a single (typeName, op) frame into
261
+ * its boolean fragments ("parts"), WITHOUT combining them —
262
+ * `composePolicyClause` does that. Shares the same `paramCounter` and
263
+ * `scope` as the user where so that nothing collides downstream.
264
+ *
265
+ * The result is positionally aligned with `resolved.permissives` /
266
+ * `resolved.restrictives`: one entry per policy, in order, even when a
267
+ * policy contributes no part.
117
268
  *
118
269
  * Permissive `cypher.params` keys are namespaced with `policy_p<n>_`
119
270
  * to guarantee no collision with `param0..N`.
120
271
  */
121
- compilePolicyClause(bundle, nodeVar, nodeDef, paramCounter, scope, paramsTarget) {
122
- const { ctx, resolved, defaults, operation } = bundle;
123
- const permFrags = [];
272
+ compilePolicyFragments(bundle, nodeVar, nodeDef, paramCounter, scope, paramsTarget) {
273
+ const { ctx, resolved } = bundle;
274
+ const permissives = [];
124
275
  let policyParamIdx = 0;
125
276
  for (const p of resolved.permissives) {
277
+ const parts = [];
278
+ permissives.push({ policy: p, parts });
126
279
  // `when` returns a where-partial — compile it through the same
127
280
  // pipeline so every existing operator/quantifier just works.
128
281
  if (p.when) {
@@ -130,13 +283,13 @@ class WhereCompiler {
130
283
  if (partial && Object.keys(partial).length > 0) {
131
284
  const compiled = this.compileConditions(partial, nodeVar, nodeDef, paramCounter, 0, scope);
132
285
  if (compiled.cypher) {
133
- permFrags.push(`(${compiled.cypher})`);
286
+ parts.push(`(${compiled.cypher})`);
134
287
  (0, validation_1.mergeParams)(paramsTarget, compiled.params);
135
288
  }
136
289
  }
137
290
  else if (partial && Object.keys(partial).length === 0)
138
291
  // Empty partial means "match everything" — equivalent to true.
139
- permFrags.push('true');
292
+ parts.push('true');
140
293
  }
141
294
  // `cypher` escape hatch — raw fragment + parameterized params
142
295
  // namespaced with `policy_p<idx>_`.
@@ -148,12 +301,14 @@ class WhereCompiler {
148
301
  const rawParams = p.cypher.params(ctx) ?? {};
149
302
  const namespaced = namespacePolicyParams(rawParams, `policy_p${policyParamIdx++}_`);
150
303
  (0, validation_1.mergeParams)(paramsTarget, namespaced.values);
151
- permFrags.push(`(${rewritePolicyFragment(fragment, namespaced.map)})`);
304
+ parts.push(`(${rewritePolicyFragment(fragment, namespaced.map)})`);
152
305
  }
153
306
  }
154
307
  }
155
- const restFrags = [];
308
+ const restrictives = [];
156
309
  for (const p of resolved.restrictives) {
310
+ const parts = [];
311
+ restrictives.push({ policy: p, parts });
157
312
  // Only ReadRestrictive policies participate in the WHERE clause.
158
313
  // WriteRestrictive policies (create/update) are evaluated at the
159
314
  // application layer in Model.* — calling their `(ctx, input)`
@@ -162,7 +317,8 @@ class WhereCompiler {
162
317
  continue;
163
318
  // Compile-time gate. If `appliesWhen(ctx)` is false, the policy
164
319
  // contributes nothing to this query — same semantics as a
165
- // dropped permissive.
320
+ // dropped permissive. (The resolver evaluates this gate too; this
321
+ // check stays as defense in depth for hand-built bundles.)
166
322
  if (p.appliesWhen && !p.appliesWhen(ctx))
167
323
  continue;
168
324
  if (p.when) {
@@ -171,13 +327,13 @@ class WhereCompiler {
171
327
  if (partial === false)
172
328
  // Hard deny — compiles to `false` and short-circuits the
173
329
  // restrictive AND chain.
174
- restFrags.push('false');
330
+ parts.push('false');
175
331
  else if (partial && typeof partial === 'object') {
176
332
  const obj = partial;
177
333
  if (Object.keys(obj).length > 0) {
178
334
  const compiled = this.compileConditions(obj, nodeVar, nodeDef, paramCounter, 0, scope);
179
335
  if (compiled.cypher) {
180
- restFrags.push(`(${compiled.cypher})`);
336
+ parts.push(`(${compiled.cypher})`);
181
337
  (0, validation_1.mergeParams)(paramsTarget, compiled.params);
182
338
  }
183
339
  }
@@ -191,61 +347,11 @@ class WhereCompiler {
191
347
  const rawParams = p.cypher.params(ctx) ?? {};
192
348
  const namespaced = namespacePolicyParams(rawParams, `policy_p${policyParamIdx++}_`);
193
349
  (0, validation_1.mergeParams)(paramsTarget, namespaced.values);
194
- restFrags.push(`(${rewritePolicyFragment(fragment, namespaced.map)})`);
350
+ parts.push(`(${rewritePolicyFragment(fragment, namespaced.map)})`);
195
351
  }
196
352
  }
197
353
  }
198
- // Default-deny: no permissives matched. The Model call site is
199
- // responsible for raising `PolicyDeniedError` BEFORE compile when
200
- // `defaults.onDeny === 'throw'`. At compile time we always fall
201
- // back to `false` so the query is safe even if the call-site
202
- // throw is bypassed (defense in depth, not the primary path).
203
- if (resolved.permissives.length === 0) {
204
- void defaults;
205
- void operation;
206
- return 'false';
207
- }
208
- // At least one permissive matched. Compose `(perm) AND (rest)`.
209
- //
210
- // SECURITY FIX (v1.8.2 — CRITICAL): Pre-1.8.2 emitted `'true'` here
211
- // when `permFrags.length === 0`, on the assumption that an empty
212
- // permFrags meant "every permissive returned an empty partial,
213
- // which is match-anything". That assumption was WRONG and silently
214
- // inverted the deny default in three documented patterns:
215
- //
216
- // permissive: [{ when: (ctx) => ctx.userId ? {...} : null }]
217
- // ^^^ abstain
218
- // permissive: [{ when: (ctx) => undefined }]
219
- // permissive: [{ cypher: { fragment: () => '', params: ... } }]
220
- //
221
- // In every case, `permFrags` ended up empty and the compiler emitted
222
- // `WHERE true`, dumping unrestricted data. The bug survived TS
223
- // checks, code review, and the v1.7.0 NLS audit because "abstain"
224
- // and "match-anything" looked indistinguishable at this layer.
225
- //
226
- // Permissives are an ALLOW-LIST. If no rule fires, access is DENIED.
227
- // The explicit "match-anything" path is preserved: when a developer
228
- // writes `when: () => ({})`, line 257-259 still pushes `'true'` into
229
- // permFrags before this check, so permFrags.length > 0 and we take
230
- // the else branch. The migration path for users relying on the old
231
- // abstain-as-allow behaviour is to write `when: () => ({})` instead
232
- // of `when: () => null`.
233
- const permClause = permFrags.length === 0
234
- ? 'false'
235
- : permFrags.length === 1
236
- ? permFrags[0]
237
- : `(${permFrags.join(' OR ')})`;
238
- const restClause = restFrags.length === 0
239
- ? 'true'
240
- : restFrags.length === 1
241
- ? restFrags[0]
242
- : restFrags.join(' AND ');
243
- // Avoid the trivial `(... AND true)` formulation when there are no
244
- // restrictives — keeps emitted Cypher tighter and the byte-
245
- // identical regression cleaner.
246
- return restClause === 'true'
247
- ? permClause
248
- : `(${permClause} AND ${restClause})`;
354
+ return { permissives, restrictives };
249
355
  }
250
356
  /**
251
357
  * Build a `PolicyContextBundle` for a target type when crossing a
@@ -264,9 +370,12 @@ class WhereCompiler {
264
370
  buildTargetBundle(typeName, policyContext) {
265
371
  if (!policyContext)
266
372
  return undefined;
267
- const targetPolicy = policyContext.resolveForType(typeName, 'read');
268
- if (!targetPolicy)
269
- return undefined;
373
+ // v2.3.0 — a target WITHOUT a read policy still gets a bundle
374
+ // (`NO_ROOT_POLICY`: no clause of its own) so enforcement keeps
375
+ // cascading into deeper traversals. Pre-2.3.0 this returned
376
+ // `undefined`, making every policy-free type on a traversal path a
377
+ // gateway around the types beyond it (A → B(no policy) → C skipped C).
378
+ const targetPolicy = policyContext.resolveForType(typeName, 'read') ?? types_1.NO_ROOT_POLICY;
270
379
  return {
271
380
  ctx: policyContext.ctx,
272
381
  operation: 'read',
@@ -275,6 +384,73 @@ class WhereCompiler {
275
384
  defaults: policyContext.defaults,
276
385
  };
277
386
  }
387
+ /**
388
+ * Compile a traversal's filter against its target node with the
389
+ * caller's filter (`user`) and the target's `read` policy (`policy`)
390
+ * kept SEPARATE, so each quantifier composes them correctly — the
391
+ * policy must never sit inside a negation (v2.3.0). Pre-2.3.0 the policy
392
+ * was compiled INTO the filter, so `_ALL` (`NOT (user AND policy)`) was
393
+ * falsified by hidden related nodes (an existence oracle), and a
394
+ * connection `node_NOT` was SATISFIED by them.
395
+ *
396
+ * - no policy context → plain compile.
397
+ * - concrete target → one `compileUserAndPolicy` pass (shared `@cypher`
398
+ * prelude scope); the bundle cascades as `NO_ROOT_POLICY` at any depth.
399
+ * - interface target → filter via a `NO_ROOT_POLICY` traversal bundle,
400
+ * policy via `compileTargetPolicyClause` (CASE over the members'
401
+ * own clauses — pre-2.3.0 only the interface's own policies ran).
402
+ *
403
+ * `''` for an absent part. With `scope`, concrete-target preludes
404
+ * register into it (caller emits); otherwise they are returned.
405
+ * Shared with `SelectionCompiler`'s nested-projection filters.
406
+ *
407
+ * @internal
408
+ */
409
+ compileTargetBody(where, targetVar, targetDef, counter, policyContext, scope) {
410
+ if (!policyContext) {
411
+ const plain = this.compile(where, targetVar, targetDef, counter, {
412
+ scope,
413
+ });
414
+ return {
415
+ user: plain.cypher,
416
+ policy: '',
417
+ params: plain.params,
418
+ preludes: plain.preludes ?? [],
419
+ };
420
+ }
421
+ if (this.abstractMembers(targetDef.typeName) === null) {
422
+ const split = this.compileUserAndPolicy(where, targetVar, targetDef, counter, {
423
+ policyContext: this.buildTargetBundle(targetDef.typeName, policyContext),
424
+ scope,
425
+ });
426
+ if (!split)
427
+ return { user: '', policy: '', params: {}, preludes: [] };
428
+ return {
429
+ user: split.userCypher,
430
+ policy: split.policy?.clause ?? '',
431
+ params: split.params,
432
+ preludes: split.preludes ?? [],
433
+ };
434
+ }
435
+ const userPart = this.compile(where, targetVar, targetDef, counter, {
436
+ policyContext: traversalBundle(policyContext),
437
+ scope,
438
+ });
439
+ const policyPart = this.compileTargetPolicyClause(targetDef.typeName, targetVar, 'read', policyContext, counter);
440
+ const userHasPreludes = (userPart.preludes?.length ?? 0) > 0 || (scope?.hasAny() ?? false);
441
+ if (userHasPreludes && policyPart.preludes.length > 0)
442
+ throw new errors_1.OGMError(`Filtering "${targetDef.typeName}" by @cypher fields is not supported ` +
443
+ `when a member type's policy also projects @cypher fields. Filter on ` +
444
+ `stored properties, or refactor the member policy.`);
445
+ const params = { ...userPart.params };
446
+ (0, validation_1.mergeParams)(params, policyPart.params);
447
+ return {
448
+ user: userPart.cypher,
449
+ policy: policyPart.cypher ?? '',
450
+ params,
451
+ preludes: [...(userPart.preludes ?? []), ...policyPart.preludes],
452
+ };
453
+ }
278
454
  compileConditions(where, nodeVar, nodeDef, counter, depth, scope,
279
455
  /**
280
456
  * Optional shared params accumulator. When provided, every leaf
@@ -358,37 +534,10 @@ class WhereCompiler {
358
534
  clauses.push(`NOT (${sub.cypher})`);
359
535
  continue;
360
536
  }
361
- // Handle null values: relationship null → NOT EXISTS, scalar null → IS NULL
537
+ // Null values — the operator suffix is split off FIRST (v2.3.0); see
538
+ // `compileNullCondition`.
362
539
  if (value === null) {
363
- const relDef = nodeDef.relationships.get(key);
364
- if (relDef) {
365
- // Relationship null means "no such relationship exists"
366
- const targetNodeDef = (0, utils_1.resolveTargetDef)(relDef.target, this.schema);
367
- if (targetNodeDef) {
368
- const relVar = `r${counter.count}`;
369
- counter.count++;
370
- const pattern = (0, utils_1.buildRelPattern)({
371
- sourceVar: nodeVar,
372
- relDef,
373
- targetVar: relVar,
374
- targetLabel: 'auto',
375
- // Pass schema so that union/interface targets resolve to a
376
- // labelless target (relationship-type-only filter). Without
377
- // this, the literal abstract type name is escaped as a label
378
- // that no concrete node carries → NOT EXISTS is true for
379
- // every row → ALL rows match.
380
- schema: this.schema,
381
- });
382
- clauses.push(`NOT EXISTS { MATCH ${pattern} }`);
383
- }
384
- }
385
- else {
386
- // Scalar null means "property IS NULL". `@cypher` scalars need
387
- // to project through the scope first (NULL check on the alias).
388
- (0, validation_1.assertSafeIdentifier)(key, 'where clause');
389
- const fieldRef = this.resolveFieldRef(key, nodeVar, nodeDef, scope);
390
- clauses.push(`${fieldRef} IS NULL`);
391
- }
540
+ clauses.push(this.compileNullCondition(key, nodeVar, nodeDef, counter, scope));
392
541
  continue;
393
542
  }
394
543
  // Connection operators (e.g. hasStatusConnection_SOME)
@@ -490,16 +639,24 @@ class WhereCompiler {
490
639
  ? (this.schema.relationshipProperties.get(relDef.properties) ?? null)
491
640
  : null;
492
641
  const inner = this.compileConnectionWhereInput(value, relVar, edgeVar, targetNodeDef, propsDef, nodeScope, edgeScope, counter, depth, policyContext);
493
- const innerClauses = inner.cypher ? [inner.cypher] : [];
494
- const innerParams = inner.params;
642
+ const innerParams = { ...inner.params };
643
+ // v2.3.0 — the target's `'read'` policy, composed ONCE over the whole
644
+ // connection body (node, node_NOT, edge and logical groups alike) and
645
+ // kept outside every negation. Compiled after the caller's filter so
646
+ // parameter numbering matches the pre-2.3.0 single-`node` shape.
647
+ const target = policyContext
648
+ ? this.compileTargetBody(undefined, relVar, targetNodeDef, counter, policyContext, nodeScope)
649
+ : null;
650
+ const policy = target?.policy ?? '';
651
+ if (target)
652
+ (0, validation_1.mergeParams)(innerParams, target.params);
495
653
  // Stitch the inner preludes (CALL { ... } + WITH ...) INSIDE the
496
654
  // EXISTS body, between the MATCH pattern and the inner WHERE.
497
- // - `nodeScope.emit()` — preludes from `@cypher` fields registered
498
- // directly on the connection-where input's edge-side scope.
655
+ // - `nodeScope.emit()` — node-side `@cypher` projections: the
656
+ // caller's `node` filters and (concrete target) the target policy.
499
657
  // - `edgeScope.emit()` — same, but edge-side.
500
- // - `inner.preludes` (v1.8.5) — preludes returned by the recursive
501
- // `compile()` calls on the node side, which now own their own
502
- // scopes.
658
+ // - `inner.preludes` / `target.preludes` — scopes owned elsewhere
659
+ // (an abstract target's member policies).
503
660
  const innerPreludes = [];
504
661
  if (nodeScope.hasAny())
505
662
  innerPreludes.push(...nodeScope.emit());
@@ -507,10 +664,13 @@ class WhereCompiler {
507
664
  innerPreludes.push(...edgeScope.emit());
508
665
  if (inner.preludes && inner.preludes.length > 0)
509
666
  innerPreludes.push(...inner.preludes);
667
+ if (target && target.preludes.length > 0)
668
+ innerPreludes.push(...target.preludes);
510
669
  const preludeFragment = innerPreludes.length
511
670
  ? ` ${innerPreludes.join(' ')}`
512
671
  : '';
513
- const whereClause = innerClauses.length > 0 ? ` WHERE ${innerClauses.join(' AND ')}` : '';
672
+ const matched = stitchUserAndPolicy(inner.cypher, policy);
673
+ const whereClause = matched ? ` WHERE ${matched}` : '';
514
674
  switch (connSuffix) {
515
675
  case 'Connection':
516
676
  case 'Connection_SOME':
@@ -525,9 +685,9 @@ class WhereCompiler {
525
685
  params: innerParams,
526
686
  };
527
687
  case 'Connection_ALL':
528
- if (innerClauses.length > 0)
688
+ if (inner.cypher)
529
689
  return {
530
- cypher: `NOT EXISTS { MATCH ${pattern}${preludeFragment} WHERE NOT (${innerClauses.join(' AND ')}) }`,
690
+ cypher: `NOT EXISTS { MATCH ${pattern}${preludeFragment} WHERE ${stitchAllCounterexample(inner.cypher, policy)} }`,
531
691
  params: innerParams,
532
692
  };
533
693
  return { cypher: '', params: {} };
@@ -573,23 +733,23 @@ class WhereCompiler {
573
733
  * `MATCH ${pattern}` and `WHERE ...` inside the EXISTS body.
574
734
  */
575
735
  const extraPreludes = [];
576
- // Resolve the target type's `'read'` policy ONCE per connection-where
577
- // input. The same bundle is reused across every `node` / `node_NOT`
578
- // key we encounter at this level (and across nested AND/OR/NOT
579
- // groups via direct policyContext threading).
580
- const targetBundle = this.buildTargetBundle(targetNodeDef.typeName, policyContext);
581
736
  for (const [key, val] of Object.entries(value)) {
582
737
  if (val === undefined)
583
738
  continue;
584
739
  if (key === 'node' || key === 'node_NOT') {
585
- // Re-enter the top-level `compile()` so that the target type's
586
- // `'read'` policy AND-stitches into the same body that the user
587
- // filter produces. `compile()` manages its own inner scope and
588
- // returns any preludes via `WhereResult.preludes`; we hoist
589
- // those into `extraPreludes` so the surrounding `tryCompileConnection`
590
- // can stitch them inside the EXISTS body alongside the existing
591
- // `nodeScope.emit()` content.
592
- const nodeResult = this.compile(val, relVar, targetNodeDef, counter, targetBundle ? { policyContext: targetBundle } : undefined);
740
+ // v2.3.0 — the caller's node filter only. The target's `'read'`
741
+ // policy is composed ONCE by `tryCompileConnection` over the whole
742
+ // connection body, never here: stitched in here it sat inside the
743
+ // `node_NOT` negation (satisfied by hidden nodes) and was absent
744
+ // from edge-only filters. The traversal bundle keeps deeper
745
+ // traversals enforcing; `nodeScope` shares the `@cypher` chain
746
+ // with that policy.
747
+ const nodeResult = this.compile(val, relVar, targetNodeDef, counter, {
748
+ policyContext: policyContext
749
+ ? traversalBundle(policyContext)
750
+ : undefined,
751
+ scope: nodeScope,
752
+ });
593
753
  if (nodeResult.preludes && nodeResult.preludes.length > 0)
594
754
  extraPreludes.push(...nodeResult.preludes);
595
755
  if (nodeResult.cypher) {
@@ -709,28 +869,22 @@ class WhereCompiler {
709
869
  // EXISTS never matches concrete-typed nodes.
710
870
  schema: this.schema,
711
871
  });
712
- // v1.8.5 — re-resolve the TARGET type's `'read'` policy when crossing
713
- // the relationship boundary, mirroring `selection.compiler.ts:826-847`.
714
- // Pre-1.8.5 this call site invoked `compileConditions` directly,
715
- // skipping policy AND-stitching for the target — CRIT-3.
716
- const targetBundle = this.buildTargetBundle(targetNodeDef.typeName, policyContext);
717
- // Re-enter the top-level `compile()` so the target's policy clause
718
- // (when present) AND-stitches into the same body that the user filter
719
- // produces. `compile()` manages its own inner scope and returns any
720
- // preludes via `WhereResult.preludes` for stitching inside EXISTS.
721
- const innerResult = this.compile(value, relVar, targetNodeDef, counter, targetBundle ? { policyContext: targetBundle } : undefined);
722
- const innerPreludes = innerResult.preludes ?? [];
872
+ // v1.8.5 — the TARGET type's `'read'` policy applies when crossing the
873
+ // relationship boundary (CRIT-3). v2.3.0 — the caller's filter and the
874
+ // target's policy compile SEPARATELY and compose per quantifier with
875
+ // the policy OUTSIDE every negation (see `compileTargetBody`).
876
+ const body = this.compileTargetBody(value, relVar, targetNodeDef, counter, policyContext);
877
+ const matched = stitchUserAndPolicy(body.user, body.policy);
878
+ const innerPreludes = body.preludes;
723
879
  const innerPreludeFragment = innerPreludes.length
724
880
  ? ` ${innerPreludes.join(' ')}`
725
881
  : '';
726
- const whereClause = innerResult.cypher
727
- ? ` WHERE ${innerResult.cypher}`
728
- : '';
882
+ const whereClause = matched ? ` WHERE ${matched}` : '';
729
883
  switch (suffix) {
730
884
  case '_SOME':
731
885
  return {
732
886
  cypher: `EXISTS { MATCH ${pattern}${innerPreludeFragment}${whereClause} }`,
733
- params: innerResult.params,
887
+ params: body.params,
734
888
  };
735
889
  case '_NONE':
736
890
  case '_NOT':
@@ -741,14 +895,16 @@ class WhereCompiler {
741
895
  // Map → NULL → silent wrong rows.
742
896
  return {
743
897
  cypher: `NOT EXISTS { MATCH ${pattern}${innerPreludeFragment}${whereClause} }`,
744
- params: innerResult.params,
898
+ params: body.params,
745
899
  };
746
900
  case '_ALL':
747
- // All matching rels must satisfy: NOT EXISTS { MATCH pattern WHERE NOT (inner) }
748
- if (innerResult.cypher)
901
+ // No VISIBLE related node fails the filter. Without a caller filter
902
+ // the quantifier is vacuous — hidden nodes must not falsify it
903
+ // (pre-2.3.0 `_ALL: {}` leaked their existence).
904
+ if (body.user)
749
905
  return {
750
- cypher: `NOT EXISTS { MATCH ${pattern}${innerPreludeFragment} WHERE NOT (${innerResult.cypher}) }`,
751
- params: innerResult.params,
906
+ cypher: `NOT EXISTS { MATCH ${pattern}${innerPreludeFragment} WHERE ${stitchAllCounterexample(body.user, body.policy)} }`,
907
+ params: body.params,
752
908
  };
753
909
  return { cypher: '', params: {} };
754
910
  case '_SINGLE': {
@@ -758,7 +914,7 @@ class WhereCompiler {
758
914
  // produces preludes — distinguish that case so the error message
759
915
  // points at the right cause.
760
916
  if (innerPreludes.length > 0) {
761
- if (targetBundle)
917
+ if (body.policy)
762
918
  throw new errors_1.OGMError(`Policy on "${targetNodeDef.typeName}" requires @cypher field projection, ` +
763
919
  `which is not supported inside _SINGLE quantifiers. Refactor the policy ` +
764
920
  `to use stored properties, or refactor the predicate to _SOME + _NONE.`);
@@ -773,7 +929,7 @@ class WhereCompiler {
773
929
  // collisions if/when a future compiler shared this counter.
774
930
  return {
775
931
  cypher: `size([${relVar} IN [(${pattern}${whereClause} | ${relVar})] | ${relVar}]) = 1`,
776
- params: innerResult.params,
932
+ params: body.params,
777
933
  };
778
934
  }
779
935
  default:
@@ -819,27 +975,27 @@ class WhereCompiler {
819
975
  targetVar: relVar,
820
976
  targetLabelRaw: labelStr,
821
977
  });
822
- // Per-member target bundle. `null` member-policy → undefined bundle
823
- // → no policy stitching for this branch (original behavior).
824
- const targetBundle = this.buildTargetBundle(memberDef.typeName, policyContext);
825
- // Compile inner WHERE conditions for this member (if any properties
826
- // specified). When the member has its own `'read'` policy, we still
827
- // re-enter `compile()` so the policy AND-stitches into the EXISTS
828
- // body even when `memberValue` is empty/null.
978
+ // Caller filter and the member's own `'read'` policy, compiled
979
+ // separately (v2.3.0) so `_ALL` can keep the policy outside its
980
+ // negation. `_SOME`/`_NONE`/`_SINGLE` use the stitched form, which
981
+ // is byte-identical to the pre-2.3.0 single compile.
829
982
  const memberWhere = memberValue;
830
- const hasUserMemberWhere = memberWhere && Object.keys(memberWhere).length > 0;
831
- let inner = '';
832
- let preludeFragment = '';
833
- if (hasUserMemberWhere || targetBundle) {
834
- const memberCompile = this.compile(hasUserMemberWhere ? memberWhere : undefined, relVar, memberDef, counter, targetBundle ? { policyContext: targetBundle } : undefined);
835
- if (memberCompile.cypher) {
836
- inner = memberCompile.cypher;
837
- (0, validation_1.mergeParams)(allParams, memberCompile.params);
838
- }
839
- if (memberCompile.preludes && memberCompile.preludes.length > 0)
840
- preludeFragment = ` ${memberCompile.preludes.join(' ')}`;
841
- }
842
- members.push({ memberKey, pattern, relVar, inner, preludeFragment });
983
+ const body = this.compileTargetBody(memberWhere, relVar, memberDef, counter, policyContext);
984
+ const inner = stitchUserAndPolicy(body.user, body.policy);
985
+ if (inner)
986
+ (0, validation_1.mergeParams)(allParams, body.params);
987
+ const preludeFragment = body.preludes.length
988
+ ? ` ${body.preludes.join(' ')}`
989
+ : '';
990
+ members.push({
991
+ memberKey,
992
+ pattern,
993
+ relVar,
994
+ inner,
995
+ user: body.user,
996
+ policy: body.policy,
997
+ preludeFragment,
998
+ });
843
999
  }
844
1000
  if (members.length === 0)
845
1001
  return { cypher: '', params: {} };
@@ -866,9 +1022,12 @@ class WhereCompiler {
866
1022
  // satisfied (same as the non-union empty-inner short-circuit),
867
1023
  // and union members absent from the input are unconstrained —
868
1024
  // consistent with `_SOME`, where only mentioned members count.
1025
+ // v2.3.0 — only a VISIBLE member node (its policy holds) can fail
1026
+ // the predicate; a member with no caller filter is vacuous even
1027
+ // when it has a policy (pre-2.3.0 its hidden nodes falsified it).
869
1028
  const failClauses = members
870
- .filter((m) => m.inner)
871
- .map((m) => `NOT EXISTS { MATCH ${m.pattern}${m.preludeFragment} WHERE NOT (${m.inner}) }`);
1029
+ .filter((m) => m.user)
1030
+ .map((m) => `NOT EXISTS { MATCH ${m.pattern}${m.preludeFragment} WHERE ${stitchAllCounterexample(m.user, m.policy)} }`);
872
1031
  if (failClauses.length === 0)
873
1032
  return { cypher: '', params: {} };
874
1033
  return {
@@ -901,11 +1060,105 @@ class WhereCompiler {
901
1060
  return null;
902
1061
  }
903
1062
  }
1063
+ /**
1064
+ * Compile `<key>: null` on a node (v2.3.0). The operator suffix is split
1065
+ * off FIRST: pre-2.3.0 the whole key was treated as a property name, so
1066
+ * the common "is not null" idiom `{ deletedAt_NOT: null }` compiled to
1067
+ * `n.deletedAt_NOT IS NULL` — always true, silently matching every row
1068
+ * (and silently restricting nothing when used in a restrictive policy).
1069
+ *
1070
+ * field: null → field IS NULL
1071
+ * field_NOT: null → field IS NOT NULL
1072
+ * rel: null → NOT EXISTS { MATCH (n)-[:REL]->(…) }
1073
+ * rel_NOT: null → EXISTS { MATCH (n)-[:REL]->(…) }
1074
+ * any other operator → OGMError (null has no meaning there)
1075
+ * undeclared field → OGMError regardless of `strictWhere` — a null
1076
+ * filter on an undeclared field matches every
1077
+ * row (fail OPEN), unlike a non-null one
1078
+ *
1079
+ * A field literally named `key` always wins over suffix parsing.
1080
+ */
1081
+ compileNullCondition(key, nodeVar, nodeDef, counter, scope) {
1082
+ if (nodeDef.relationships.has(key))
1083
+ return this.compileRelationshipExistence(key, nodeVar, nodeDef, counter, false);
1084
+ if (!nodeDef.properties.has(key))
1085
+ for (const suffix of RELATIONSHIP_SUFFIXES) {
1086
+ if (!key.endsWith(suffix))
1087
+ continue;
1088
+ const field = key.slice(0, -suffix.length);
1089
+ if (!nodeDef.relationships.has(field))
1090
+ continue;
1091
+ if (suffix === '_NOT')
1092
+ return this.compileRelationshipExistence(field, nodeVar, nodeDef, counter, true);
1093
+ throw new errors_1.OGMError(`Relationship filter "${key}" cannot be null. Use "${field}: null" ` +
1094
+ `(no related node) or "${field}_NOT: null" (at least one related node).`);
1095
+ }
1096
+ return this.compileNullScalar(key, nodeVar, nodeDef, scope);
1097
+ }
1098
+ /**
1099
+ * Scalar half of the null semantics above, shared by node properties and
1100
+ * relationship-edge properties: `IS NULL` / `IS NOT NULL`, or an
1101
+ * `OGMError` for any other operator and for undeclared fields.
1102
+ */
1103
+ compileNullScalar(key, varName, propsHolder, scope) {
1104
+ if (propsHolder?.properties.has(key)) {
1105
+ (0, validation_1.assertSafeIdentifier)(key, 'where clause');
1106
+ // `@cypher` scalars project through the scope (NULL check on the alias).
1107
+ return `${this.resolveFieldRef(key, varName, propsHolder, scope)} IS NULL`;
1108
+ }
1109
+ for (const suffix of OPERATOR_SUFFIXES) {
1110
+ if (!key.endsWith(suffix))
1111
+ continue;
1112
+ const field = key.slice(0, -suffix.length);
1113
+ if (!propsHolder?.properties.has(field))
1114
+ continue;
1115
+ if (suffix !== '_NOT')
1116
+ throw new errors_1.OGMError(`Operator "${suffix}" cannot be used with null ("${key}"). Use ` +
1117
+ `"${field}: null" (IS NULL) or "${field}_NOT: null" (IS NOT NULL) to test for null.`);
1118
+ (0, validation_1.assertSafeIdentifier)(field, 'where clause');
1119
+ return `${this.resolveFieldRef(field, varName, propsHolder, scope)} IS NOT NULL`;
1120
+ }
1121
+ throw new errors_1.OGMError(`Unknown field "${key}" in where clause with a null value. A null ` +
1122
+ `filter on a field the type does not declare would match every row, ` +
1123
+ `so it is rejected (regardless of strictWhere). Check for typos.`);
1124
+ }
1125
+ /**
1126
+ * `NOT EXISTS` (no related node) / `EXISTS` (at least one) for a
1127
+ * relationship null filter. Byte-identical to the pre-2.3.0 `rel: null`
1128
+ * emission for the `NOT EXISTS` case.
1129
+ */
1130
+ compileRelationshipExistence(field, nodeVar, nodeDef, counter, exists) {
1131
+ const relDef = nodeDef.relationships.get(field);
1132
+ if (!(0, utils_1.resolveTargetDef)(relDef.target, this.schema))
1133
+ throw new errors_1.OGMError(`Cannot resolve the target type "${relDef.target}" of relationship "${field}".`);
1134
+ const relVar = `r${counter.count}`;
1135
+ counter.count++;
1136
+ const pattern = (0, utils_1.buildRelPattern)({
1137
+ sourceVar: nodeVar,
1138
+ relDef,
1139
+ targetVar: relVar,
1140
+ targetLabel: 'auto',
1141
+ // Pass schema so that union/interface targets resolve to a
1142
+ // labelless target (relationship-type-only filter). Without this,
1143
+ // the literal abstract type name is escaped as a label that no
1144
+ // concrete node carries → NOT EXISTS is true for every row → ALL
1145
+ // rows match.
1146
+ schema: this.schema,
1147
+ });
1148
+ return exists
1149
+ ? `EXISTS { MATCH ${pattern} }`
1150
+ : `NOT EXISTS { MATCH ${pattern} }`;
1151
+ }
904
1152
  compileEdgeConditions(edgeWhere, edgeVar, propsDef, edgeScope, counter) {
905
1153
  const clauses = [];
906
1154
  const params = {};
907
1155
  for (const [key, value] of Object.entries(edgeWhere)) {
908
1156
  (0, validation_1.assertSafeKey)(key, 'edge where input');
1157
+ // Same null semantics as node properties (v2.3.0).
1158
+ if (value === null) {
1159
+ clauses.push(this.compileNullScalar(key, edgeVar, propsDef, edgeScope));
1160
+ continue;
1161
+ }
909
1162
  const result = this.compileScalarCondition(key, value, edgeVar, propsDef, edgeScope, counter);
910
1163
  clauses.push(result.cypher);
911
1164
  (0, validation_1.mergeParams)(params, result.params);
@@ -1005,6 +1258,102 @@ function stitchUserAndPolicy(userBody, policyClause) {
1005
1258
  return userBody;
1006
1259
  return `(${userBody}) AND ${policyClause}`;
1007
1260
  }
1261
+ /**
1262
+ * `_ALL` body (v2.3.0): a related node the caller may SEE (`policy`) that
1263
+ * fails the caller's filter. The policy sits OUTSIDE the negation, so
1264
+ * hidden related nodes neither satisfy nor falsify the quantifier.
1265
+ */
1266
+ function stitchAllCounterexample(userBody, policyClause) {
1267
+ return policyClause
1268
+ ? `${policyClause} AND NOT (${userBody})`
1269
+ : `NOT (${userBody})`;
1270
+ }
1271
+ /**
1272
+ * The bundle a traversal's USER filter compiles under (v2.3.0): no clause
1273
+ * for the target itself (its policy is composed separately by the caller),
1274
+ * but `resolveForType` stays live so deeper traversals keep enforcing.
1275
+ *
1276
+ * @internal
1277
+ */
1278
+ function traversalBundle(policyContext) {
1279
+ return { ...policyContext, operation: 'read', resolved: types_1.NO_ROOT_POLICY };
1280
+ }
1281
+ /**
1282
+ * Compose compiled policy fragments into the single boolean clause
1283
+ * `(P1 OR P2 …) AND R1 AND R2 …` that `compile()` AND-stitches into the
1284
+ * WHERE. This is the ONLY place that composition happens: `compile()`
1285
+ * stitches its output, and `Model.explainPolicies` projects the very
1286
+ * same string as its enforcement verdict — so the two cannot disagree.
1287
+ */
1288
+ function composePolicyClause(fragments) {
1289
+ // Default-deny: no permissives matched. The Model call site is
1290
+ // responsible for raising `PolicyDeniedError` BEFORE compile when
1291
+ // `defaults.onDeny === 'throw'`. At compile time we always fall
1292
+ // back to `false` so the query is safe even if the call-site
1293
+ // throw is bypassed (defense in depth, not the primary path).
1294
+ if (fragments.permissives.length === 0)
1295
+ return 'false';
1296
+ const permFrags = fragments.permissives.flatMap((c) => c.parts);
1297
+ const restFrags = fragments.restrictives.flatMap((c) => c.parts);
1298
+ // At least one permissive matched. Compose `(perm) AND (rest)`.
1299
+ //
1300
+ // SECURITY FIX (v1.8.2 — CRITICAL): Pre-1.8.2 emitted `'true'` here
1301
+ // when `permFrags.length === 0`, on the assumption that an empty
1302
+ // permFrags meant "every permissive returned an empty partial,
1303
+ // which is match-anything". That assumption was WRONG and silently
1304
+ // inverted the deny default in three documented patterns:
1305
+ //
1306
+ // permissive: [{ when: (ctx) => ctx.userId ? {...} : null }]
1307
+ // ^^^ abstain
1308
+ // permissive: [{ when: (ctx) => undefined }]
1309
+ // permissive: [{ cypher: { fragment: () => '', params: ... } }]
1310
+ //
1311
+ // In every case, `permFrags` ended up empty and the compiler emitted
1312
+ // `WHERE true`, dumping unrestricted data. The bug survived TS
1313
+ // checks, code review, and the v1.7.0 NLS audit because "abstain"
1314
+ // and "match-anything" looked indistinguishable at this layer.
1315
+ //
1316
+ // Permissives are an ALLOW-LIST. If no rule fires, access is DENIED.
1317
+ // The explicit "match-anything" path is preserved: when a developer
1318
+ // writes `when: () => ({})`, `compilePolicyFragments` still pushes
1319
+ // `'true'` as that permissive's part, so permFrags.length > 0 and we
1320
+ // take the else branch. The migration path for users relying on the
1321
+ // old abstain-as-allow behaviour is to write `when: () => ({})`
1322
+ // instead of `when: () => null`.
1323
+ const permClause = permFrags.length === 0
1324
+ ? 'false'
1325
+ : permFrags.length === 1
1326
+ ? permFrags[0]
1327
+ : `(${permFrags.join(' OR ')})`;
1328
+ const restClause = restFrags.length === 0
1329
+ ? 'true'
1330
+ : restFrags.length === 1
1331
+ ? restFrags[0]
1332
+ : restFrags.join(' AND ');
1333
+ // Avoid the trivial `(... AND true)` formulation when there are no
1334
+ // restrictives — keeps emitted Cypher tighter and the byte-
1335
+ // identical regression cleaner.
1336
+ return restClause === 'true'
1337
+ ? permClause
1338
+ : `(${permClause} AND ${restClause})`;
1339
+ }
1340
+ /**
1341
+ * The boolean value ONE compiled policy contributes to
1342
+ * `composePolicyClause`, as a standalone expression (explain path): a
1343
+ * permissive's parts are OR-ed — they join the permissive disjunction —
1344
+ * and a restrictive's parts are AND-ed — they join the restrictive
1345
+ * conjunction. OR/AND are associative in Cypher's three-valued logic,
1346
+ * so grouping per policy preserves the composed clause's truth value.
1347
+ *
1348
+ * Returns `null` when the policy has no parts (it abstained).
1349
+ */
1350
+ function policyValueExpression(kind, parts) {
1351
+ if (parts.length === 0)
1352
+ return null;
1353
+ if (parts.length === 1)
1354
+ return parts[0];
1355
+ return `(${parts.join(kind === 'permissive' ? ' OR ' : ' AND ')})`;
1356
+ }
1008
1357
  /**
1009
1358
  * Namespace raw `cypher.params` keys with the given prefix and produce a
1010
1359
  * lookup map old-name → new-name. The fragment text is rewritten with