grafeo-ogm 1.5.0 → 1.7.0-beta.1

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 (99) hide show
  1. package/CHANGELOG.md +110 -1
  2. package/README.md +289 -2
  3. package/dist/cjs/compilers/mutation.compiler.d.ts +3 -0
  4. package/dist/cjs/compilers/mutation.compiler.d.ts.map +1 -1
  5. package/dist/cjs/compilers/mutation.compiler.js +8 -0
  6. package/dist/cjs/compilers/mutation.compiler.js.map +1 -1
  7. package/dist/cjs/compilers/selection.compiler.d.ts +31 -1
  8. package/dist/cjs/compilers/selection.compiler.d.ts.map +1 -1
  9. package/dist/cjs/compilers/selection.compiler.js +158 -34
  10. package/dist/cjs/compilers/selection.compiler.js.map +1 -1
  11. package/dist/cjs/compilers/where.compiler.d.ts +57 -0
  12. package/dist/cjs/compilers/where.compiler.d.ts.map +1 -1
  13. package/dist/cjs/compilers/where.compiler.js +259 -26
  14. package/dist/cjs/compilers/where.compiler.js.map +1 -1
  15. package/dist/cjs/execution/executor.d.ts +20 -0
  16. package/dist/cjs/execution/executor.d.ts.map +1 -1
  17. package/dist/cjs/execution/executor.js +28 -4
  18. package/dist/cjs/execution/executor.js.map +1 -1
  19. package/dist/cjs/index.d.ts +5 -2
  20. package/dist/cjs/index.d.ts.map +1 -1
  21. package/dist/cjs/index.js +11 -1
  22. package/dist/cjs/index.js.map +1 -1
  23. package/dist/cjs/interface-model.d.ts +43 -1
  24. package/dist/cjs/interface-model.d.ts.map +1 -1
  25. package/dist/cjs/interface-model.js +231 -12
  26. package/dist/cjs/interface-model.js.map +1 -1
  27. package/dist/cjs/model.d.ts +118 -1
  28. package/dist/cjs/model.d.ts.map +1 -1
  29. package/dist/cjs/model.js +341 -51
  30. package/dist/cjs/model.js.map +1 -1
  31. package/dist/cjs/ogm.d.ts +115 -4
  32. package/dist/cjs/ogm.d.ts.map +1 -1
  33. package/dist/cjs/ogm.js +189 -2
  34. package/dist/cjs/ogm.js.map +1 -1
  35. package/dist/cjs/policy/errors.d.ts +22 -0
  36. package/dist/cjs/policy/errors.d.ts.map +1 -0
  37. package/dist/cjs/policy/errors.js +23 -0
  38. package/dist/cjs/policy/errors.js.map +1 -0
  39. package/dist/cjs/policy/resolver.d.ts +42 -0
  40. package/dist/cjs/policy/resolver.d.ts.map +1 -0
  41. package/dist/cjs/policy/resolver.js +116 -0
  42. package/dist/cjs/policy/resolver.js.map +1 -0
  43. package/dist/cjs/policy/types.d.ts +182 -0
  44. package/dist/cjs/policy/types.d.ts.map +1 -0
  45. package/dist/cjs/policy/types.js +130 -0
  46. package/dist/cjs/policy/types.js.map +1 -0
  47. package/dist/cjs/utils/cypher-field-projection.d.ts +64 -0
  48. package/dist/cjs/utils/cypher-field-projection.d.ts.map +1 -0
  49. package/dist/cjs/utils/cypher-field-projection.js +102 -0
  50. package/dist/cjs/utils/cypher-field-projection.js.map +1 -0
  51. package/dist/esm/compilers/mutation.compiler.d.ts +3 -0
  52. package/dist/esm/compilers/mutation.compiler.d.ts.map +1 -1
  53. package/dist/esm/compilers/mutation.compiler.js +8 -0
  54. package/dist/esm/compilers/mutation.compiler.js.map +1 -1
  55. package/dist/esm/compilers/selection.compiler.d.ts +31 -1
  56. package/dist/esm/compilers/selection.compiler.d.ts.map +1 -1
  57. package/dist/esm/compilers/selection.compiler.js +158 -34
  58. package/dist/esm/compilers/selection.compiler.js.map +1 -1
  59. package/dist/esm/compilers/where.compiler.d.ts +57 -0
  60. package/dist/esm/compilers/where.compiler.d.ts.map +1 -1
  61. package/dist/esm/compilers/where.compiler.js +259 -26
  62. package/dist/esm/compilers/where.compiler.js.map +1 -1
  63. package/dist/esm/execution/executor.d.ts +20 -0
  64. package/dist/esm/execution/executor.d.ts.map +1 -1
  65. package/dist/esm/execution/executor.js +28 -4
  66. package/dist/esm/execution/executor.js.map +1 -1
  67. package/dist/esm/index.d.ts +5 -2
  68. package/dist/esm/index.d.ts.map +1 -1
  69. package/dist/esm/index.js +4 -1
  70. package/dist/esm/index.js.map +1 -1
  71. package/dist/esm/interface-model.d.ts +43 -1
  72. package/dist/esm/interface-model.d.ts.map +1 -1
  73. package/dist/esm/interface-model.js +231 -12
  74. package/dist/esm/interface-model.js.map +1 -1
  75. package/dist/esm/model.d.ts +118 -1
  76. package/dist/esm/model.d.ts.map +1 -1
  77. package/dist/esm/model.js +341 -51
  78. package/dist/esm/model.js.map +1 -1
  79. package/dist/esm/ogm.d.ts +115 -4
  80. package/dist/esm/ogm.d.ts.map +1 -1
  81. package/dist/esm/ogm.js +187 -1
  82. package/dist/esm/ogm.js.map +1 -1
  83. package/dist/esm/policy/errors.d.ts +22 -0
  84. package/dist/esm/policy/errors.d.ts.map +1 -0
  85. package/dist/esm/policy/errors.js +19 -0
  86. package/dist/esm/policy/errors.js.map +1 -0
  87. package/dist/esm/policy/resolver.d.ts +42 -0
  88. package/dist/esm/policy/resolver.d.ts.map +1 -0
  89. package/dist/esm/policy/resolver.js +111 -0
  90. package/dist/esm/policy/resolver.js.map +1 -0
  91. package/dist/esm/policy/types.d.ts +182 -0
  92. package/dist/esm/policy/types.d.ts.map +1 -0
  93. package/dist/esm/policy/types.js +123 -0
  94. package/dist/esm/policy/types.js.map +1 -0
  95. package/dist/esm/utils/cypher-field-projection.d.ts +64 -0
  96. package/dist/esm/utils/cypher-field-projection.d.ts.map +1 -0
  97. package/dist/esm/utils/cypher-field-projection.js +97 -0
  98. package/dist/esm/utils/cypher-field-projection.js.map +1 -0
  99. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,114 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.7.0-beta.1] (2026-05-02)
4
+
5
+ > **BETA — republish of beta.0 with no code changes.** Install with `npm install grafeo-ogm@beta`.
6
+
7
+ ### Fixed
8
+
9
+ - **CI publish workflow** now passes `--tag` to `npm publish`, derived from the version's pre-release suffix (`beta`, `alpha`, `rc`, etc.), defaulting to `latest` for stable releases. Required because npm refuses to publish prerelease versions without an explicit dist-tag. v1.7.0-beta.0 was tagged on GitHub but never reached npm because of this; v1.7.0-beta.1 is the first beta artifact actually available on the registry.
10
+
11
+ ## [1.7.0-beta.0] (2026-05-02)
12
+
13
+ > **BETA — API may change before 1.7.0 final.** Install with `npm install grafeo-ogm@beta` (after the package is published to the `beta` dist-tag). The shape of the public API is settled, but feedback during the beta window may cause minor adjustments.
14
+
15
+ ### Features
16
+
17
+ - **Node-Level Security (NLS).** New optional `policies` config on `OGMConfig` plus `OGM.withContext(ctx)` give you a Postgres-RLS-style filter layer that compiles into the existing WHERE pipeline. Policies return `<Node>Where` partials — every operator, quantifier, connection filter, and nested traversal already supported by `WhereCompiler` is automatically available. Three policy kinds: `override` (compile-time short-circuit; admin path is byte-identical to no-policy), `permissive` (OR'd grants with optional `appliesWhen` compile-time gate), `restrictive` (split into read-side and write-side flavors — see "Contract change" below). Vocabulary-neutral — no hardcoded role strings, you compose your own.
18
+ - **Nested-selection enforcement.** `SelectionCompiler` injects every target type's `'read'` policy into pattern comprehensions, connection edges, and union branches. Policies cannot be bypassed by traversal.
19
+ - **Default-deny baseline.** `policyDefaults.onDeny: 'empty'` (default) emits `WHERE false` when no permissive matches; `'throw'` raises `PolicyDeniedError` before the query runs.
20
+ - **Audit metadata.** Every OGM-emitted query when policies are configured attaches `tx.setMetaData({ ogmPolicySetVersion, ctxFingerprint, modelType, operation, policiesEvaluated, bypassed })`. `ctxFingerprint` is a SHA-256 of the SORTED ctx KEYS only — never values, never anything sensitive.
21
+ - **Escape hatches.** `ogm.unsafe.bypassPolicies()` returns a non-policy-aware OGM (logged via `logger.warn`); per-method `unsafe: { bypassPolicies: true }` skips policies for a single call (also logged).
22
+ - **Interface-aware enforcement.** `InterfaceModel.find()` / `aggregate()` emit a CASE-per-label WHERE that AND-combines each implementer's `'read'` policy with the interface-level policy. Concrete-type policies are NOT bypassed when querying through the interface.
23
+ - **`PolicyDeniedError`** — new public error class extending `OGMError`. Carries `typeName`, `operation`, `reason` (`'no-permissive-matched' | 'restrictive-rejected-input' | 'override-failed-validation'`), and optional `policyName`.
24
+
25
+ ### Contract change during beta — read/write restrictive split
26
+
27
+ The pre-fix beta invoked `RestrictivePolicy.when` twice on every write path: once at the application layer with `(ctx, input)` (WITH CHECK semantics) and once at WHERE-compile with `(ctx)` only (row predicate). Side-effecting callbacks fired inconsistently and any predicate that legitimately depended on `input` returned `false` at compile time → `WHERE false` → reads silently blocked.
28
+
29
+ **Fix:** `RestrictivePolicy` is now a discriminated union over `operations`.
30
+
31
+ - **`ReadRestrictivePolicy`** — `operations` includes only `read|delete|aggregate|count`. `when(ctx)` returns a `<Node>Where` partial OR `false`. Compiles into the WHERE clause via `WhereCompiler`. Invoked exactly once per read-side query.
32
+ - **`WriteRestrictivePolicy`** — `operations` includes only `create|update`. `when(ctx, input)` returns a boolean (where-partial returns are no longer accepted; if you need a row filter on `update`, register a `ReadRestrictive` on `read`/`delete`). Runs at the application layer ("WITH CHECK"). Invoked exactly once per write op. The `cypher` escape hatch is not supported on write restrictives.
33
+ - **Mixed-operation arrays are rejected** at construction time with `OGMError`. Split a single `restrictive({ operations: ['read', 'create'], … })` into two restrictives — one per kind.
34
+
35
+ The `restrictive()` constructor uses TypeScript overloads so authoring code gets the correct `when` signature inferred from the literal `operations` tuple. New runtime helpers `isReadRestrictive` / `isWriteRestrictive` are exported for users who need to inspect a policy at runtime.
36
+
37
+ This is a contract change inside the beta window — the API has shifted before `1.7.0` final, which is exactly what the beta dist-tag exists for. No public types were removed; `RestrictivePolicy` remains a public type and resolves to the union of the two new flavors.
38
+
39
+ ### Public API additions (additive only)
40
+
41
+ - New exports: `override`, `permissive`, `restrictive`, `isReadRestrictive`, `isWriteRestrictive`, `OGMWithContext`, `PolicyDeniedError`.
42
+ - New types: `Policy`, `OverridePolicy`, `PermissivePolicy`, `RestrictivePolicy`, `ReadRestrictivePolicy`, `WriteRestrictivePolicy`, `PolicyContext`, `PolicyContextBundle`, `Operation`, `OperationOrWildcard`, `ReadOperation`, `WriteOperation`, `PoliciesByModel`, `PolicyDefaults`, `ResolvedPolicies`, `UnsafeOptions`.
43
+ - `OGMConfig` gains optional `policies?` and `policyDefaults?`.
44
+ - `OGM` gains `withContext<C>(ctx: C)` and `unsafe.bypassPolicies()`.
45
+ - Every Model / InterfaceModel method's params bag gains optional `unsafe?: { bypassPolicies?: boolean }`.
46
+ - All existing call signatures remain valid. Calling code that doesn't pass policies must compile and run identically — byte-identical Cypher for non-policy calls.
47
+
48
+ ### Install command (beta)
49
+
50
+ ```bash
51
+ npm install grafeo-ogm@beta
52
+ # or
53
+ pnpm add grafeo-ogm@beta
54
+ ```
55
+
56
+ ### Known limits in this beta
57
+
58
+ - **`@cypher` scalar fields inside a policy `where`-partial throw when the policy is injected into nested-selection enforcement.** Refactor the policy to use stored properties or a relationship traversal. (Top-level WHERE on the root model still supports `@cypher` filters.)
59
+ - **`upsert` evaluates create- and update-side policies at the application layer.** MERGE has no WHERE we can stitch into; the WHERE-side enforcement only covers the matching path. Documented limit; full MERGE-aware enforcement is deferred to v1.7.1.
60
+ - **Restrictive read/write split — fixed in this iteration.** The dual-invocation contract bug present in earlier beta builds is resolved. `ReadRestrictivePolicy` (`when(ctx)`) is invoked exactly once at WHERE-compile; `WriteRestrictivePolicy` (`when(ctx, input)`) is invoked exactly once at the application layer. See the "Contract change during beta" section above for the new shape and migration notes.
61
+ - **InterfaceModel CASE-per-label fallback.** When an interface has policies registered but a concrete implementer does not, the implementer's branch falls back to interface-level enforcement only (rather than default-denying the unlisted implementer). The OGM emits a `logger.warn` at construction time when this configuration is detected so it never passes silently. Strict per-implementer default-deny is being evaluated for `1.7.0` final.
62
+ - **AsyncLocalStorage opt-in is deferred to v1.7.1.** Beta is explicit `withContext()` only — create one wrapper per request and discard it.
63
+ - **Index requirement declaration deferred to v1.8.0.** No `requires.indexes` config in this beta.
64
+ - **EXPLAIN-in-test mode deferred to v1.8.0.** No dev hook in beta.
65
+ - **No live Neo4j integration test in this beta.** Mock-driver coverage is extensive (1317 specs incl. byte-identical regression of every v1.6.0 emission and the C1 read/write contract proofs), but a live `pnpm run test:integration` against a real Neo4j is a `1.7.0` final blocker.
66
+
67
+ ### Unaffected paths (byte-identical to v1.6.0)
68
+
69
+ - An OGM constructed without `policies` emits identical Cypher to v1.6.0 for every covered operation. Verified in `tests/policy/byte-identical.spec.ts`.
70
+ - An OGM with `policies` but invoked via the bare `OGM.model()` path (no `withContext`) emits identical Cypher to v1.6.0.
71
+ - An override match emits identical Cypher to a no-policy query.
72
+ - `ogm.unsafe.bypassPolicies()` and per-call `unsafe: { bypassPolicies: true }` both emit identical Cypher to v1.6.0.
73
+
74
+ ### Generated types
75
+
76
+ - Existing `<Node>Where`, `<Node>CreateInput`, `<Node>UpdateInput` are sufficient for typing policy callbacks. The generator's `ModelMap` already exposed `Where`, `CreateInput`, and `UpdateInput` keys per model so `PoliciesByModel<M, C>` can index into them. **No regeneration required when upgrading from v1.6.0 → v1.7.0-beta.0.**
77
+
78
+ ### Migration
79
+
80
+ - v1.6.0 → v1.7.0-beta.0 is purely additive — no changes required unless you opt into NLS.
81
+
82
+ ## 1.6.0 (2026-05-01)
83
+
84
+ ### Features
85
+
86
+ - **`@cypher` scalar fields are now resolved at runtime in `WHERE`** — `find()`, `findFirst()`, `findUnique()`, `count()`, `aggregate()`, `update()`, `updateMany()`, `delete()`, `deleteMany()`, `upsert()`, `setLabels()`, `searchByVector()`, `searchByPhrase()`, and `InterfaceModel.find()` / `aggregate()` now resolve `@cypher` scalar fields when they appear as filter keys (with or without operator suffix — `_EQ`, `_CONTAINS`, `_GT`, `_LT`, `_GTE`, `_LTE`, `_IN`, `_NOT`, `_NOT_IN`, `_STARTS_WITH`, `_ENDS_WITH`, `_MATCHES`, `_NOT_CONTAINS`, etc.). Pre-1.6.0, the typed `<Node>Where` surface emitted these fields and the WHERE compiler would compile `n.<cypherField>` against a property that doesn't exist on the node — predicate always false → silent data omission. Each `@cypher` filter is now compiled into a `CALL { WITH n; WITH n AS this; <user statement> }` prelude before the `WHERE`, with the projected column renamed to a unique `__where_n_<fieldName>` alias the predicate references.
87
+ - **`@cypher` scalar fields are now resolved at runtime in `SELECT`** — both `selectionSet: '...'` (string SDL) and `select: { field: true }` (typed) paths now project `@cypher` scalar fields. Compiled into a `CALL { ... } WITH n, <col> AS __sel_n_<fieldName>` prelude before the `RETURN`, then projected into the map as `<fieldName>: __sel_n_<fieldName>`. Pre-1.6.0 these fields were silently emitted as `.\`<fieldName>\`` and returned NULL.
88
+ - **AND/OR/NOT composition de-dupes references** — a `@cypher` field referenced multiple times at the same scope (e.g. `OR: [{ field_GT: 1 }, { field_LT: 10 }]`) emits a single `CALL` prelude shared by all references.
89
+ - **Relationship-quantifier inner WHERE** — `@cypher` scalar fields on related nodes now work inside `_SOME` / `_NONE` / `_ALL` filters (e.g. `hasStatus_SOME: { statusLowerName_CONTAINS: 'act' }`). The inner `CALL` prelude is stitched directly inside the `EXISTS { MATCH pattern <prelude> WHERE <inner> }` body so each iteration projects the field for its own bound relationship variable.
90
+ - **Combined WHERE + SELECT + sort** — all three preludes can co-exist in the same query. Each uses a disjoint alias namespace (`__where_*`, `__sel_*`, `__sort_*`) and the carry chain is threaded so later `WITH` clauses preserve aliases from earlier preludes.
91
+ - **Mutation projections now project `@cypher` fields** — `update()`, `upsert()`, and `create()` honour `@cypher` fields in their `select` / `selectionSet` projections via the same SELECT prelude pipeline.
92
+
93
+ ### Internal
94
+
95
+ - New helper `src/utils/cypher-field-projection.ts` exporting `buildCypherFieldCall()` and `CypherFieldScope`. `CypherFieldScope` is the central state holder used by both `WhereCompiler` and `SelectionCompiler`: it dedupes per-`(nodeVar, fieldName)` registrations, threads carried aliases through every emitted `WITH`, and accepts a `preserveVars` array so callers can keep surrounding pipeline vars (`__typename`, `score`) in scope.
96
+ - `WhereCompiler.compile()` now returns `WhereResult { cypher, params, preludes? }`. Top-level preludes are returned for the caller to stitch between MATCH and WHERE; inner-scope preludes (relationship quantifiers, union members) are stitched directly into the `EXISTS` body. A new `compile(...)` option `preserveVars` lets callers keep `score` (vector / fulltext) or `__typename` (interface) in scope across the prelude `WITH` chain.
97
+ - `MutationCompiler.compileUpdate()`, `compileDelete()`, and `compileSetLabels()` accept `whereResult.preludes` and emit them between MATCH and WHERE.
98
+ - `SelectionCompiler.compile()` accepts an optional `cypherScope` argument. When supplied, top-level `@cypher` scalar fields register there and the caller stitches the scope's emitted lines before the RETURN. When omitted (the default for nested recursive calls), `@cypher` scalar fields throw — pattern comprehensions cannot host CALL subqueries.
99
+ - `Model.compileOptions()` now accepts `preserveVars` so the sort prelude carries forward any aliases already projected by an earlier SELECT prelude.
100
+
101
+ ### Notes & Limits
102
+
103
+ - **Scalar return types only.** Mirrors the v1.5.0 sort scope. `@cypher` fields whose declared return type is a node / interface / union are still projection-only (the `where-emitter` already excludes them, and the SELECT pipeline does not synthesize a sub-projection from a `@cypher` statement).
104
+ - **Aggregations are out of scope.** `aggregate()` continues to skip `@cypher` fields when emitting `min(n.<f>) / max / avg`. The aggregation emitters (`<Type>AggregationSelection`, `<Type>EdgeAggregateSelection`, etc.) already exclude `@cypher` from the typed surface.
105
+ - **`_SINGLE` quantifiers reject `@cypher` filters.** Both relationship `_SINGLE` and `Connection_SINGLE` use list comprehensions (`size([(pattern WHERE inner) | 1]) = 1`) which cannot host `CALL { ... }` subqueries. Using a `@cypher` field inside `_SINGLE` throws `OGMError` with a clear message — refactor to `_SOME` + `_NONE`.
106
+ - **Nested-relationship SELECT projection of `@cypher` fields is rejected.** `select: { hasStatus: { select: { statusLowerName: true } } }` (selecting a `@cypher` field on a related node inside a nested selection) throws `OGMError`. Nested relationships use list comprehensions for their pattern, and CALL subqueries cannot be embedded there. Top-level `@cypher` fields on the root model are fully supported. Workaround: query the related node directly via its own model.
107
+ - **Connection `where` does not accept `@cypher` filters.** `select: { hasStatusConnection: { where: { node: { statusLowerName_CONTAINS: 'x' } } } }` throws — same constraint as above.
108
+ - **`select.where` (Prisma-style relationship filtering) does not accept `@cypher` filters.** Same root cause; same error.
109
+ - **Stored-only queries are byte-identical to 1.5.0.** Only queries that reference at least one `@cypher` field gain the new prelude machinery. Every previous test passes without modification (1031 → 1092 tests after this release).
110
+ - **No new generic parameters or breaking type changes.** The `<Node>Where` and `<Node>Select` types are unchanged; if you regenerated against 1.4.x or 1.5.x, those types already exposed scalar `@cypher` fields and now they are correctly resolved at runtime.
111
+
3
112
  ## 1.5.0 (2026-04-30)
4
113
 
5
114
  ### Features
@@ -23,7 +132,7 @@
23
132
 
24
133
  - **Sort-only.** This release implements `@cypher` resolution exclusively for `ORDER BY`. `select`, `selectionSet`, `where`, and `aggregate` continue to skip `@cypher` fields at runtime as in 1.4.0 — those use cases will be addressed in a separate release.
25
134
  - **Pre-existing `where-emitter` footgun unchanged**: scalar `@cypher` fields are still emitted in `<Node>Where`, and the WHERE compiler still produces `n.<cypherField>` (which fails at Neo4j). This was already broken in 1.4.x and is out of scope for 1.5.0; track in a separate issue.
26
- - **Statement convention.** `@cypher` statements must reference the bound node as `this` and end with `RETURN <expr> AS <columnName>` (or `RETURN <expr>` alone — the column name then defaults to the GraphQL field name). Example: `@cypher(statement: "RETURN toLower(this.drugName) AS insensitiveDrugName")`. The OGM never modifies the user's statement — `this` is rebound by the wrapping `CALL` subquery.
135
+ - **Statement convention.** `@cypher` statements must reference the bound node as `this` and end with `RETURN <expr> AS <columnName>` (or `RETURN <expr>` alone — the column name then defaults to the GraphQL field name). Example: `@cypher(statement: "RETURN toLower(this.title) AS insensitiveTitle")`. The OGM never modifies the user's statement — `this` is rebound by the wrapping `CALL` subquery.
27
136
  - **Stored-field sorts produce byte-identical Cypher to 1.4.0.** Only sorts that resolve to a `@cypher` field gain the new CALL/WITH machinery; everything else is unchanged.
28
137
  - **No new generic parameters or breaking type changes.** `TSort` keeps its 1.4.0 default (`Record<string, 'ASC' | 'DESC'>`) — un-regenerated consumers continue to compile against the older `<Node>Sort` shape.
29
138
 
package/README.md CHANGED
@@ -213,6 +213,24 @@ await ogm.$transaction(async (ctx) => {
213
213
 
214
214
  ---
215
215
 
216
+ ## Beta features
217
+
218
+ The current beta release exposes one new feature behind opt-in config:
219
+
220
+ - **Node-Level Security (NLS)** — a Postgres-RLS-style filter layer that compiles into the existing WHERE pipeline. See **[Advanced Features → Node-Level Security (Beta)](#node-level-security-beta)**.
221
+
222
+ The beta is purely additive: an OGM constructed without the new `policies` option emits **byte-identical Cypher** to v1.6.0. Install with:
223
+
224
+ ```bash
225
+ npm install grafeo-ogm@beta
226
+ # or
227
+ pnpm add grafeo-ogm@beta
228
+ ```
229
+
230
+ The shape of the public API is settled, but the beta window is for collecting integration feedback. Minor adjustments may land before `1.7.0` final.
231
+
232
+ ---
233
+
216
234
  ## Features Overview
217
235
 
218
236
  | Feature | Description |
@@ -250,7 +268,7 @@ grafeo-ogm uses standard GraphQL SDL with Neo4j directives to define your graph
250
268
  | `@relationshipProperties` | Type | Marks a type as relationship properties (edge data) |
251
269
  | `@id` | Field | Auto-generates a UUID on create |
252
270
  | `@unique` | Field | Creates a uniqueness constraint in Neo4j |
253
- | `@cypher` | Field | Computed field resolved via a custom Cypher statement |
271
+ | `@cypher` | Field | Computed field resolved via a custom Cypher statement. Scalar return types are resolved at runtime in `select` / `selectionSet`, `where`, and `options.sort` (see [Computed Fields with @cypher](#computed-fields-with-cypher)); node/interface return types remain projection-only |
254
272
  | `@default` | Field | Sets a default value on create |
255
273
  | `@fulltext` | Type | Defines fulltext search indexes with `name` and `fields` |
256
274
  | `@vector` | Type | Registers one or more Neo4j vector indexes on a node (see [Vector Search](#vector-search)) |
@@ -814,6 +832,275 @@ const deleteResult = await deleteSubgraph(
814
832
  );
815
833
  ```
816
834
 
835
+ ### Computed Fields with @cypher
836
+
837
+ The `@cypher` directive declares a field whose value is produced by a custom Cypher statement at query time. As of v1.6.0, scalar `@cypher` fields are resolved in three scopes — `select` / `selectionSet`, `where`, and `options.sort` — so you can project, filter, and order by them just like stored properties. Pre-1.6.0, references in `select` and `where` silently returned `NULL`.
838
+
839
+ #### Declaration
840
+
841
+ ```graphql
842
+ type Book @node {
843
+ id: ID! @id
844
+ title: String!
845
+ published: DateTime
846
+
847
+ # Computed: lowercased title for case-insensitive sort/filter
848
+ insensitiveTitle: String @cypher(
849
+ statement: "RETURN toLower(this.title) AS result"
850
+ columnName: "result"
851
+ )
852
+
853
+ # Computed: count of related reviews
854
+ reviewCount: Int @cypher(
855
+ statement: "MATCH (this)-[:HAS_REVIEW]->(r) RETURN count(r) AS count"
856
+ columnName: "count"
857
+ )
858
+
859
+ # Computed: average review rating (aggregating relationship-property data)
860
+ averageRating: Float @cypher(
861
+ statement: "MATCH (this)-[:HAS_REVIEW]->(r) RETURN avg(r.rating) AS avg"
862
+ columnName: "avg"
863
+ )
864
+ }
865
+ ```
866
+
867
+ `statement` is the Cypher body — `this` is bound to the matched node. `columnName` is the alias the OGM reads from the `RETURN` clause; if omitted it defaults to the field name.
868
+
869
+ #### Using @cypher in SELECT
870
+
871
+ ```typescript
872
+ const books = await Book.find({
873
+ select: {
874
+ id: true,
875
+ title: true,
876
+ insensitiveTitle: true,
877
+ reviewCount: true,
878
+ averageRating: true,
879
+ },
880
+ });
881
+
882
+ // Or via the legacy selectionSet API:
883
+ const books = await Book.find({
884
+ selectionSet: '{ id title insensitiveTitle reviewCount averageRating }',
885
+ });
886
+ ```
887
+
888
+ #### Using @cypher in WHERE
889
+
890
+ ```typescript
891
+ // Case-insensitive prefix match
892
+ await Book.find({
893
+ where: { insensitiveTitle_STARTS_WITH: 'the' },
894
+ });
895
+
896
+ // Combined with stored fields under AND/OR
897
+ await Book.find({
898
+ where: {
899
+ AND: [
900
+ { insensitiveTitle_CONTAINS: 'graph' },
901
+ { reviewCount_GT: 5 },
902
+ ],
903
+ },
904
+ });
905
+
906
+ // Nested inside a relationship quantifier
907
+ // Find authors who have at least one book with more than 5 reviews
908
+ await Author.find({
909
+ where: { books_SOME: { reviewCount_GT: 5 } },
910
+ });
911
+ ```
912
+
913
+ All standard operator suffixes are supported (`_EQ`, `_NOT`, `_IN`, `_NOT_IN`, `_LT`, `_LTE`, `_GT`, `_GTE`, `_CONTAINS`, `_NOT_CONTAINS`, `_STARTS_WITH`, `_ENDS_WITH`, `_MATCHES`).
914
+
915
+ #### Using @cypher in ORDER BY
916
+
917
+ ```typescript
918
+ await Book.find({
919
+ where: { insensitiveTitle_STARTS_WITH: 'g' },
920
+ options: { sort: [{ insensitiveTitle: 'ASC' }] },
921
+ select: { id: true, title: true, reviewCount: true },
922
+ });
923
+ ```
924
+
925
+ #### Limitations
926
+
927
+ The following positions throw `OGMError` at compile time with a clear hint, rather than silently misbehaving:
928
+
929
+ - **Connection `where` and `select.where`**: Prisma-style relationship filtering with `@cypher` is unsupported. Workaround: filter on the parent type, or query the related node directly via its own model.
930
+ - **`_SINGLE` / `Connection_SINGLE` quantifiers**: unsupported. Workaround: combine `_SOME` and `_NONE` to express "exactly one match."
931
+ - **Nested-relationship SELECT projection**: `select: { rel: { select: { cypherField: true } } }` is unsupported. Workaround: query the related node directly via its own model and join in application code.
932
+
933
+ These are not OGM bugs — they are Cypher-language constraints. Pattern comprehensions (`[(n)-[:R]->(m) | m { ... }]`) cannot host `CALL` subqueries, so resolving an `@cypher` field inside a projection or relationship-scoped predicate is impossible at the language level.
934
+
935
+ #### Performance
936
+
937
+ Each scope (`where`, `select`, sort) emits its own `CALL { WITH n; WITH n AS this; <statement> }` block per `@cypher` field reference. A query that references the same `@cypher` field in all three scopes emits three `CALL` blocks. This is functionally correct and parameter-isolated, but worth knowing if your statement is expensive (multi-hop traversals, aggregations over large fan-outs). For frequently-accessed computed values, consider materializing as a stored property updated on write instead.
938
+
939
+ ### Node-Level Security (Beta)
940
+
941
+ > **Beta — install with `npm install grafeo-ogm@beta`.**
942
+
943
+ Node-Level Security (NLS) is a per-request filter layer that compiles into the existing WHERE pipeline. Policies return `<Node>Where` partials, so every operator, quantifier, connection filter, and nested traversal already supported by `WhereCompiler` is automatically available inside policies — no new DSL.
944
+
945
+ #### Declaration
946
+
947
+ ```typescript
948
+ import { OGM, override, permissive, restrictive } from 'grafeo-ogm';
949
+
950
+ interface RequestCtx {
951
+ userId: string;
952
+ capabilities: string[];
953
+ tierIds: string[];
954
+ }
955
+
956
+ const ogm = new OGM<ModelMap>({
957
+ typeDefs,
958
+ driver,
959
+ policies: {
960
+ Book: [
961
+ // Compile-time short-circuit. When `when(ctx)` returns true, ALL
962
+ // other policies for `Book.read` are dropped — query is byte-
963
+ // identical to a no-policy query. Zero runtime cost for admins.
964
+ override({
965
+ operations: ['*'],
966
+ when: (c: RequestCtx) => c.capabilities.includes('admin'),
967
+ name: 'admin-bypass',
968
+ }),
969
+
970
+ // OR-grant. Multiple permissives compose with OR — any match grants.
971
+ permissive({
972
+ operations: ['read'],
973
+ when: (c: RequestCtx) => ({ ownerId: c.userId }),
974
+ name: 'owner-can-read',
975
+ }),
976
+
977
+ // Read-side restrictive — AND-row predicate. Compiles into the
978
+ // WHERE clause and applies to read|delete|aggregate|count.
979
+ // `when` receives only `ctx` and returns a where-partial (or
980
+ // `false` for a hard deny).
981
+ restrictive({
982
+ operations: ['read'],
983
+ when: (c: RequestCtx) => ({ tierIds_IN: c.tierIds }),
984
+ name: 'tier-isolation',
985
+ }),
986
+
987
+ // Write-side restrictive — Postgres `WITH CHECK` semantics. Runs
988
+ // at the application layer for create|update only; receives both
989
+ // `ctx` and the user-submitted `input`. MUST return a boolean.
990
+ // The OGM rejects mixed read+write `operations` arrays — split a
991
+ // single restrictive into two if you need both read- and write-
992
+ // side coverage.
993
+ restrictive({
994
+ operations: ['create', 'update'],
995
+ when: (c, input) =>
996
+ (input as { ownerId?: string }).ownerId === c.userId,
997
+ name: 'cannot-set-other-owner',
998
+ }),
999
+ ],
1000
+ },
1001
+ policyDefaults: {
1002
+ onDeny: 'empty', // Or 'throw' — raises PolicyDeniedError pre-compile.
1003
+ auditMetadata: true,
1004
+ },
1005
+ });
1006
+
1007
+ // Per request:
1008
+ const Books = ogm.withContext({ userId, capabilities, tierIds }).model('Book');
1009
+ const visible = await Books.find({ where: { published_GT: '2024-01-01' } });
1010
+ ```
1011
+
1012
+ The Cypher emitted for the call above is roughly:
1013
+
1014
+ ```cypher
1015
+ MATCH (n:`Book`)
1016
+ WHERE (n.`published` > $param0) AND ((n.`ownerId` = $param1) AND n.`tierIds` IN $param2)
1017
+ RETURN n { .id, .title, .ownerId, .tierIds, ... }
1018
+ ```
1019
+
1020
+ #### Hierarchical ACL example
1021
+
1022
+ Combine permissives to express "owner OR member of an allowed tier":
1023
+
1024
+ ```typescript
1025
+ policies: {
1026
+ Book: [
1027
+ permissive({
1028
+ operations: ['read'],
1029
+ when: (c) => ({ ownerId: c.userId }),
1030
+ name: 'owner-can-read',
1031
+ }),
1032
+ permissive({
1033
+ operations: ['read'],
1034
+ // Returns a where-partial that uses the existing _SOME quantifier.
1035
+ when: (c) => ({ tiers_SOME: { id_IN: c.tierIds } }),
1036
+ name: 'tier-can-read',
1037
+ }),
1038
+ ],
1039
+ }
1040
+ ```
1041
+
1042
+ Both permissives OR together — a row passes if either branch matches.
1043
+
1044
+ #### Interface inheritance
1045
+
1046
+ Policies on an interface AND with policies on every implementing concrete type. Concrete types can only narrow restrictives, never broaden — predictable. `OR` composition for permissives lets concrete types add allow paths their interface didn't anticipate.
1047
+
1048
+ ```typescript
1049
+ policies: {
1050
+ Resource: [
1051
+ // Tenant isolation applies to every type that implements Resource.
1052
+ restrictive({ operations: ['read'], when: (c) => ({ tenantId: c.tenantId }) }),
1053
+ ],
1054
+ Book: [
1055
+ // Additional grant only for Book — composed via OR with any
1056
+ // Resource-level permissives.
1057
+ permissive({ operations: ['read'], when: (c) => ({ authorId: c.userId }) }),
1058
+ ],
1059
+ }
1060
+ ```
1061
+
1062
+ #### Enforcement boundary
1063
+
1064
+ > **The OGM enforces policies at the compiler.** Policies do NOT apply to `ogm.$queryRaw`, `ogm.$executeRaw`, or to `@cypher` directive bodies that traverse from a stored field. If you need raw-Cypher enforcement, write the predicate into your raw Cypher manually or layer a Neo4j role/RBAC at the database.
1065
+
1066
+ #### Escape hatches
1067
+
1068
+ ```typescript
1069
+ // Disable policies for a single call. Logged via logger.warn.
1070
+ await Books.find({ where: { id: 'b1' }, unsafe: { bypassPolicies: true } });
1071
+
1072
+ // Disable policies for a derived OGM (data migrations, admin scripts).
1073
+ const adminOgm = ogm.unsafe.bypassPolicies();
1074
+ ```
1075
+
1076
+ Both paths emit byte-identical Cypher to a no-policy OGM.
1077
+
1078
+ #### Audit metadata
1079
+
1080
+ When `policies` is configured, every OGM-emitted query attaches transaction metadata:
1081
+
1082
+ ```typescript
1083
+ {
1084
+ ogmPolicySetVersion: '1.7.0-beta.0',
1085
+ ctxFingerprint: '<sha256 of sorted ctx KEYS — never values>',
1086
+ modelType: 'Book',
1087
+ operation: 'read',
1088
+ policiesEvaluated: ['owner-can-read', 'tier-isolation'],
1089
+ bypassed: false,
1090
+ }
1091
+ ```
1092
+
1093
+ Disable via `policyDefaults: { auditMetadata: false }`. The fingerprint is intentionally key-only — no ctx values are leaked.
1094
+
1095
+ #### Beta limits
1096
+
1097
+ - **`@cypher` scalar fields inside a policy `where`-partial throw when the policy is injected into nested-selection enforcement.** Refactor the policy to use stored properties or a relationship traversal.
1098
+ - **`upsert`** evaluates create- and update-side policies at the application layer (MERGE has no WHERE). Documented limit; full MERGE-aware enforcement is deferred to v1.7.1.
1099
+ - **Restrictives are split into read-side and write-side flavors.** A restrictive's `operations` array determines which `when` signature applies. Read-side ops (`read|delete|aggregate|count`) → `when(ctx)` returns a where-partial or boolean. Write-side ops (`create|update`) → `when(ctx, input)` returns a boolean only. Mixed arrays (e.g. `['read', 'create']`) are rejected at construction time — split into two restrictives. Each flavor is invoked exactly once per query (read-side at compile, write-side at the application layer); the dual-invocation contract bug from earlier beta iterations is fixed. Use `isReadRestrictive` / `isWriteRestrictive` if you need to inspect a policy at runtime.
1100
+ - **InterfaceModel CASE-per-label fallback.** Implementers without a registered policy fall back to interface-level enforcement on their branch. The OGM emits a `logger.warn` at construction time when an interface has policies and one of its implementers does not — silence the warning by registering an explicit policy on each implementer.
1101
+ - **AsyncLocalStorage** opt-in is deferred to v1.7.1. Beta is explicit `withContext()` only — create one wrapper per request, discard after.
1102
+ - **Live Neo4j integration tests are not part of the beta.** Mock-driver coverage is extensive but a real-DB suite is a `1.7.0` final blocker.
1103
+
817
1104
  ### Raw Cypher
818
1105
 
819
1106
  ```typescript
@@ -1299,7 +1586,7 @@ Pure query builders (e.g. `cypher-query-builder`) help you compose Cypher fragme
1299
1586
 
1300
1587
  ### Is grafeo-ogm production-ready?
1301
1588
 
1302
- Yes. The library has 892 unit tests covering compilers, mutations, fulltext, transactions, security, scalar type mapping, and edge cases. It targets Neo4j 5.x and follows semver — see [CHANGELOG.md](CHANGELOG.md).
1589
+ Yes. The library has 1,093 unit tests covering compilers, mutations, fulltext, transactions, security, scalar type mapping, and edge cases. It targets Neo4j 5.x and follows semver — see [CHANGELOG.md](CHANGELOG.md).
1303
1590
 
1304
1591
  ### Does grafeo-ogm work with Neo4j Aura?
1305
1592
 
@@ -28,6 +28,7 @@ export declare class MutationCompiler {
28
28
  compileUpdate(_where: Record<string, unknown>, update: Record<string, unknown> | undefined, connect: Record<string, unknown> | undefined, disconnect: Record<string, unknown> | undefined, nodeDef: NodeDefinition, whereResult: {
29
29
  cypher: string;
30
30
  params: Record<string, unknown>;
31
+ preludes?: string[];
31
32
  }, labels?: string[], returnMode?: 'node' | 'count'): MutationResult;
32
33
  /**
33
34
  * Generate DELETE Cypher with optional cascade.
@@ -35,6 +36,7 @@ export declare class MutationCompiler {
35
36
  compileDelete(nodeDef: NodeDefinition, whereResult: {
36
37
  cypher: string;
37
38
  params: Record<string, unknown>;
39
+ preludes?: string[];
38
40
  }, deleteInput?: Record<string, unknown>): MutationResult;
39
41
  /**
40
42
  * Generate MERGE (upsert) Cypher with ON CREATE SET / ON MATCH SET.
@@ -47,6 +49,7 @@ export declare class MutationCompiler {
47
49
  compileSetLabels(nodeDef: NodeDefinition, whereResult: {
48
50
  cypher: string;
49
51
  params: Record<string, unknown>;
52
+ preludes?: string[];
50
53
  }, addLabels?: string[], removeLabels?: string[]): MutationResult;
51
54
  /**
52
55
  * Generate batch CREATE (or MERGE for skipDuplicates) Cypher via UNWIND.
@@ -1 +1 @@
1
- {"version":3,"file":"mutation.compiler.d.ts","sourceRoot":"","sources":["../../../src/compilers/mutation.compiler.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"mutation.compiler.d.ts","sourceRoot":"","sources":["../../../src/compilers/mutation.compiler.ts"],"names":[],"mappings":"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"}
@@ -83,6 +83,10 @@ class MutationCompiler {
83
83
  }
84
84
  else
85
85
  lines.push(`MATCH (n:${labelStr})`);
86
+ // CALL preludes for `@cypher` fields referenced in the WHERE — must be
87
+ // emitted between MATCH and WHERE so the projected aliases are in scope.
88
+ if (whereResult.preludes && whereResult.preludes.length > 0)
89
+ lines.push(...whereResult.preludes);
86
90
  if (whereResult.cypher)
87
91
  lines.push(`WHERE ${whereResult.cypher}`);
88
92
  // SET properties
@@ -127,6 +131,8 @@ class MutationCompiler {
127
131
  const lines = [];
128
132
  const params = { ...whereResult.params };
129
133
  lines.push(`MATCH (n:${labelStr})`);
134
+ if (whereResult.preludes && whereResult.preludes.length > 0)
135
+ lines.push(...whereResult.preludes);
130
136
  if (whereResult.cypher)
131
137
  lines.push(`WHERE ${whereResult.cypher}`);
132
138
  if (deleteInput && Object.keys(deleteInput).length > 0) {
@@ -223,6 +229,8 @@ class MutationCompiler {
223
229
  const lines = [];
224
230
  const params = { ...whereResult.params };
225
231
  lines.push(`MATCH (n:${labelStr})`);
232
+ if (whereResult.preludes && whereResult.preludes.length > 0)
233
+ lines.push(...whereResult.preludes);
226
234
  if (whereResult.cypher)
227
235
  lines.push(`WHERE ${whereResult.cypher}`);
228
236
  if (addLabels && addLabels.length > 0)