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