@loradb/lora-graphql 0.16.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/LICENSE +87 -0
  2. package/README.md +994 -0
  3. package/dist/analyze/cypher-check.d.ts +8 -0
  4. package/dist/analyze/diff.d.ts +32 -0
  5. package/dist/analyze/indexes.d.ts +61 -0
  6. package/dist/analyze/lint.d.ts +6 -0
  7. package/dist/analyze/plans.d.ts +31 -0
  8. package/dist/analyze/statistics.d.ts +19 -0
  9. package/dist/cli.d.ts +6 -0
  10. package/dist/cli.js +631 -0
  11. package/dist/cli.js.map +1 -0
  12. package/dist/codegen.d.ts +32 -0
  13. package/dist/compile/aggregate.d.ts +17 -0
  14. package/dist/compile/auth.d.ts +46 -0
  15. package/dist/compile/context.d.ts +43 -0
  16. package/dist/compile/cursor.d.ts +4 -0
  17. package/dist/compile/cypher.d.ts +175 -0
  18. package/dist/compile/filter.d.ts +19 -0
  19. package/dist/compile/hmac.d.ts +4 -0
  20. package/dist/compile/read.d.ts +89 -0
  21. package/dist/compile/selection.d.ts +18 -0
  22. package/dist/diff-BVP1Jzh3.js +99 -0
  23. package/dist/diff-BVP1Jzh3.js.map +1 -0
  24. package/dist/driver-C8vA5fV-.js +9127 -0
  25. package/dist/driver-C8vA5fV-.js.map +1 -0
  26. package/dist/driver.d.ts +117 -0
  27. package/dist/errors.d.ts +20 -0
  28. package/dist/execute/changes.d.ts +61 -0
  29. package/dist/execute/cypher-mutation.d.ts +4 -0
  30. package/dist/execute/feed.d.ts +10 -0
  31. package/dist/execute/mutate.d.ts +57 -0
  32. package/dist/execute/transaction.d.ts +17 -0
  33. package/dist/guards.d.ts +42 -0
  34. package/dist/index.d.ts +21 -0
  35. package/dist/index.js +24 -0
  36. package/dist/index.js.map +1 -0
  37. package/dist/lora-graphql.d.ts +291 -0
  38. package/dist/migrate.d.ts +8 -0
  39. package/dist/model/build.d.ts +25 -0
  40. package/dist/model/cypher-lexer.d.ts +10 -0
  41. package/dist/model/directives.d.ts +3 -0
  42. package/dist/model/points.d.ts +12 -0
  43. package/dist/model/relations.d.ts +7 -0
  44. package/dist/model/types.d.ts +311 -0
  45. package/dist/observe.d.ts +72 -0
  46. package/dist/schema/build.d.ts +38 -0
  47. package/dist/schema/global-id.d.ts +6 -0
  48. package/dist/schema/guard.d.ts +5 -0
  49. package/dist/schema/mutations.d.ts +44 -0
  50. package/dist/schema/names.d.ts +37 -0
  51. package/dist/schema/scalars.d.ts +9 -0
  52. package/dist/testing.d.ts +36 -0
  53. package/dist/testing.js +56 -0
  54. package/dist/testing.js.map +1 -0
  55. package/package.json +85 -0
package/README.md ADDED
@@ -0,0 +1,994 @@
1
+ # @loradb/lora-graphql
2
+
3
+ Schema-first GraphQL for LoraDB. One annotated SDL describes the graph and
4
+ the public API. The library turns it into an executable `graphql-js` schema
5
+ whose operations compile to parameterised Cypher statements, and it reasons
6
+ about those statements: it derives the indexes they need, checks with
7
+ `explain()` that they use them, bounds their cost before they run, compiles
8
+ authorization into them, and reports exactly what every mutation wrote.
9
+
10
+ ```ts
11
+ import { createDatabase } from "@loradb/lora-node";
12
+ import { LoraGraphQL, loraDriver } from "@loradb/lora-graphql";
13
+ import { createYoga } from "graphql-yoga";
14
+
15
+ const typeDefs = /* GraphQL */ `
16
+ type Festival @node @mutation @query(aggregate: true) {
17
+ key: ID! @key(generate: true) @relayId
18
+ name: String! @filterable(byValue: [EQ, CONTAINS]) @sortable
19
+ capacity: Int @filterable(byValue: [GTE, LT]) @sortable
20
+ location: Point @filterable(byValue: [WITHIN_BBOX, DISTANCE])
21
+ createdAt: DateTime @timestamp(operations: [CREATE])
22
+ genre: Genre @relationship(type: "IN_GENRE", direction: OUT) @filterable
23
+ followers: [User!]!
24
+ @relationship(type: "FOLLOWS", direction: IN, properties: "Follows")
25
+ @filterable
26
+ followerCount: Int!
27
+ @cypher(statement: "RETURN size([(this)<-[:FOLLOWS]-(:User) | 1]) AS n")
28
+ }
29
+ type Genre @node {
30
+ key: String! @key
31
+ name: String! @filterable
32
+ }
33
+ type User @node @mutation {
34
+ key: String! @key
35
+ name: String @sortable
36
+ }
37
+ type Follows @relationshipProperties {
38
+ since: Int @default(value: 2026)
39
+ }
40
+ `;
41
+
42
+ const db = await createDatabase();
43
+ const lora = new LoraGraphQL({ typeDefs, driver: loraDriver(db) });
44
+ await lora.assertSchema({ create: true }); // the constraints and indexes the API needs
45
+ const yoga = createYoga({
46
+ schema: lora.getSchema(),
47
+ context: ({ request }) => ({
48
+ jwt: verifiedClaims(request),
49
+ signal: request.signal,
50
+ }),
51
+ });
52
+ ```
53
+
54
+ ## Contents
55
+
56
+ - [What it generates](#what-it-generates)
57
+ - [Directives](#directives)
58
+ - [Queries](#queries)
59
+ - [Interfaces and unions](#interfaces-and-unions)
60
+ - [Mutations](#mutations)
61
+ - [Search](#search)
62
+ - [@cypher fields](#cypher-fields)
63
+ - [Authorization](#authorization)
64
+ - [The smart layer](#the-smart-layer)
65
+ - [Change tracking](#change-tracking)
66
+ - [Subscriptions](#subscriptions)
67
+ - [Transactions](#transactions)
68
+ - [CLI](#cli)
69
+ - [Drivers, limits and errors](#drivers-limits-and-errors)
70
+ - [Translation rules](#translation-rules)
71
+ - [Coming from @neo4j/graphql](#coming-from-neo4jgraphql)
72
+ - [LoraDB behaviours this works around](#loradb-behaviours-this-works-around)
73
+
74
+ ## What it generates
75
+
76
+ For each `@node` type (reads are on by default; `@query(read: false)` turns
77
+ them off):
78
+
79
+ | Field | Does |
80
+ | -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
81
+ | `festivals(where, sort, limit)` | A bounded list |
82
+ | `festivalsConnection(where, sort, first, after, last, before)` | Relay connection with keyset cursors in both directions, `totalCount` and `aggregate`; never `SKIP` |
83
+ | `festival(key:)` | Lookup by `@key` |
84
+ | `festivalsAggregate(where)` | `count`, and `min` / `max` (`avg` / `sum` for numbers) of sortable fields, with `@query(aggregate: true)` |
85
+ | `searchFestivals(query, where, limit)` | Full-text search, with `@fulltext` |
86
+ | `similarFestivals(vector or to, where, limit)` | Vector similarity, with a `@vector` field |
87
+ | `node(id:)` | Any `@relayId` type by global id |
88
+ | `events(where, sort, limit)` | An interface's or union's members together |
89
+ | `createFestivals`, `upsertFestivals` | With `@mutation(CREATE)` (upsert also needs `UPDATE`) |
90
+ | `updateFestival`, `updateFestivals(where, limit)` | With `@mutation(UPDATE)`: by key, or bulk by `where` |
91
+ | `deleteFestival`, `deleteFestivals(where, limit)` | With `@mutation(DELETE)`: by key, or bulk by `where` |
92
+ | `festivalChanged(key, operations, where)` | A subscription, with `@subscription` |
93
+
94
+ Relationship fields take `where`, `sort` and `limit`; list relationships
95
+ also get `…Connection`, whose edges carry the relationship properties and
96
+ filter on them (`where: { node, edge }`).
97
+
98
+ The surface is restrictive: a field is filterable only with `@filterable`,
99
+ and only by the operators listed; sortable only with `@sortable`;
100
+ `printPublicSchema()` prints exactly what clients see, with no directives.
101
+
102
+ ## Directives
103
+
104
+ Model:
105
+
106
+ | Directive | On | Meaning |
107
+ | ---------------------------------------------------------------------------------------------------------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
108
+ | `@node(labels:, plural:)` | type | A node label set; default: the type name |
109
+ | `@key(generate:)` | field | Required, unique and immutable. The sort tie-breaker, cursor anchor and mutation address. `generate: true` fills a UUID on create |
110
+ | `@unique` | field | Uniqueness constraint |
111
+ | `@index(kind: RANGE \| TEXT \| POINT)` | field | An explicit index, usually inferred |
112
+ | `@relationship(type:, direction:, properties:, queryDirection:, onDelete:, nestedOperations:, aggregate:)` | field | An edge to a `@node` type, interface or union. `queryDirection: UNDIRECTED` reads both ways; `onDelete: DETACH \| CASCADE \| RESTRICT`; `nestedOperations` lists the nested writes inputs offer; `aggregate: false` drops its aggregates |
113
+ | `@declareRelationship` | interface field | Every implementation declares this relationship (type and direction may differ); select it on the interface |
114
+ | `@relationshipProperties` | type | Properties on a relationship type |
115
+ | `@alias(property:)` | field | API name differs from the stored property |
116
+ | `@private` | field | Stored, never exposed |
117
+ | `@readonly` | field | Exposed, never client-settable |
118
+ | `@settable(onCreate:, onUpdate:)` | field | Which mutations may set it, e.g. set once on create |
119
+ | `@selectable(onRead:, onAggregate:)` | field | `onRead: false` makes a field write-only |
120
+ | `@default(value:)` | field | Stored on create when the input omits it |
121
+ | `@timestamp(operations: [CREATE, UPDATE])` | field | Set to the current time; never client-settable |
122
+ | `@populatedBy(callback:, operations:)` | field | Computed by a named callback on write |
123
+ | `@cardinality(max:)` | list relationship | Declared fan-out, for cost estimates |
124
+ | `@cypher(statement:, columnName:)` | field | A field backed by a Cypher statement. Returns scalars, `@node` types, interfaces or unions over them, or object types without `@node` (read from a map) |
125
+ | `@fulltext(indexes: [{ name, fields, analyzer, queryName }])` | type | FULLTEXT indexes, each with a search root field |
126
+ | `@vector(dimensions:, similarity:, queryName:)` | `[Float!]` field | A VECTOR index and a similarity root field |
127
+ | `@plural(value:)` | interface, union | The root field's name |
128
+
129
+ API:
130
+
131
+ | Directive | On | Meaning |
132
+ | ----------------------------------------------------- | -------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
133
+ | `@query(read:, aggregate:)` | type, interface, union | Generated reads |
134
+ | `@mutation(operations: [CREATE, UPDATE, DELETE])` | type | Generated mutations; none without it |
135
+ | `@subscription(operations: [CREATE, UPDATE, DELETE])` | type | Generated subscriptions; none without it |
136
+ | `@filterable(byValue: [...])` | field | Filter operators. Bare: `EQ` and `IN` (lists: `INCLUDES`). On a relationship: enables relationship filters |
137
+ | `@sortable` | field | Sort and paginate by this field (on a relationship property: `sort: [{ edge: { ... } }]`) |
138
+ | `@groupBy` | field | A grouping key of `<plural>Grouped(by:)` (needs `@query(aggregate: true)`) |
139
+ | `@limit(default:, max:)` | type, interface, union, list relationship | Page size bounds |
140
+ | `@relayId` | `@key` field | Adds a global `id` and the `Node` interface |
141
+ | `@authentication(operations:, jwt:)` | type, field | Needs an authenticated request, whose claims satisfy `jwt` |
142
+ | `@authorization(filter:, validate:)` | type (filter and validate), field (validate) | Row-level rules, compiled into statements |
143
+ | `@jwt`, `@jwtClaim(path:)` | type, field | The claims shape; rules may only use declared claims |
144
+
145
+ `directiveTypeDefs` (or `lora-graphql directives`) prints these as SDL for
146
+ editors and codegen.
147
+
148
+ Filter operators: `EQ`, `IN`, `LT`, `LTE`, `GT`, `GTE`, `CONTAINS`,
149
+ `STARTS_WITH`, `ENDS_WITH`, `CASE_INSENSITIVE` (strings), `IS_NULL`
150
+ (nullable fields), `INCLUDES` (lists), and on points `WITHIN_BBOX` and
151
+ `DISTANCE`. Each is checked against the field's type.
152
+
153
+ Types: `String`, `ID`, `Int`, `Float`, `Boolean`, enums, `BigInt` (a decimal
154
+ string), `Date`, `Time`, `LocalTime`, `DateTime`, `LocalDateTime`,
155
+ `Duration` (ISO-8601 strings), `Point` and `CartesianPoint` (objects; set with
156
+ `PointInput` / `CartesianPointInput`), and non-null lists of these.
157
+
158
+ ## Queries
159
+
160
+ ```graphql
161
+ {
162
+ festivalsConnection(
163
+ first: 10
164
+ after: $cursor
165
+ where: {
166
+ name: { caseInsensitive: { contains: "land" } }
167
+ followers: { some: { key: { eq: "u1" } } }
168
+ followersConnection: { some: { edge: { since: { gte: 2020 } } } }
169
+ OR: [{ capacity: { gte: 5000 } }, { genre: { name: { eq: "Techno" } } }]
170
+ }
171
+ sort: [{ capacity: DESC }]
172
+ ) {
173
+ totalCount
174
+ aggregate {
175
+ count
176
+ node {
177
+ capacity {
178
+ max
179
+ avg
180
+ }
181
+ }
182
+ }
183
+ edges {
184
+ cursor
185
+ node {
186
+ key
187
+ name
188
+ followerCount
189
+ }
190
+ }
191
+ pageInfo {
192
+ hasNextPage
193
+ endCursor
194
+ }
195
+ }
196
+ }
197
+ ```
198
+
199
+ - Filters nest, with `AND` / `OR` / `NOT`. List relationships take `some`,
200
+ `all`, `none`, `single`, `count` and `aggregate` (`node` / `edge` field
201
+ `min`, `max`, `avg`, `sum`); single relationships take the target's filter
202
+ directly; `<field>Connection` quantifies over node and relationship
203
+ properties together.
204
+ - **Absent and `null` filters are left out of the statement**, so a filter
205
+ bound to an unset variable costs nothing. `eq: null` does not mean
206
+ `IS NULL`: use `isNull: true`. A relationship quantifier whose filter is
207
+ empty is left out too: ask `count: { gt: 0 }` for "has any".
208
+ - `count` and `single` count related nodes once each, however many
209
+ relationships lead to them. `all` holds on an empty set, and a missing
210
+ property fails it.
211
+ - `CASE_INSENSITIVE` compares lowercased values and cannot use an index:
212
+ prefer `@fulltext` for search over large labels.
213
+ - Connections page forward with `first` / `after` and backward with
214
+ `last` / `before`; one cursor works in both directions. `aggregate`
215
+ covers every match, not only the page; selecting only `totalCount` or
216
+ `aggregate` reads no page.
217
+ - `sort` takes one field per item. Lists sort by the requested fields only;
218
+ connections always end on a unique, non-null field (the `@key`, or an
219
+ earlier required `@unique` field) so cursors are stable. A cursor is tagged
220
+ with its sort, and replaying it under another sort is an `INVALID_CURSOR`
221
+ error. With `cursorSecret` it is also signed (HMAC-SHA-256), and any
222
+ cursor the server did not issue is rejected. Nested lists without a sort come in `@key` order.
223
+ - Nulls sort last ascending and first descending, as in Cypher, and keyset
224
+ pages handle them.
225
+ - Every list is bounded: `limit` / `first` default to `@limit(default:)`
226
+ (global 25) and asking for more than `max` (global 100) is a
227
+ `LIMIT_EXCEEDED` error, not a silent clamp.
228
+
229
+ ### More query surface
230
+
231
+ - **Edge sort.** A relationship connection sorts by `@sortable`
232
+ relationship properties: `followsConnection(sort: [{ edge: { since: DESC } }, { name: ASC }])`.
233
+ Cursors carry the edge value; relationship properties have no index, so
234
+ this sorts per parent, bounded by the page.
235
+ - **Grouped aggregates.** `sessionsGrouped(by: [kind, room], where:, limit:)`
236
+ returns `[{ by { kind room } aggregate { count minutes { sum } } }]`,
237
+ ordered by the group values, at most `limit` groups.
238
+ - **Richer aggregates.** Strings add `shortest` / `longest`, and aggregate
239
+ filters take `shortestLength`, `longestLength` and `averageLength`.
240
+ Durations aggregate `min`, `max`, `sum` and `avg`. Relationship
241
+ connections count `count { nodes edges }`: they differ when several
242
+ relationships lead to the same node.
243
+
244
+ ## Interfaces and unions
245
+
246
+ ```graphql
247
+ interface Event @limit(default: 10) {
248
+ key: String!
249
+ title: String! @filterable(byValue: [EQ, CONTAINS]) @sortable
250
+ starts: Int @sortable
251
+ }
252
+ type Concert implements Event @node @mutation {
253
+ key: String! @key
254
+ title: String!
255
+ starts: Int
256
+ band: String
257
+ }
258
+ type Exhibition implements Event @node @mutation {
259
+ key: String! @key
260
+ title: String!
261
+ starts: Int
262
+ artist: String
263
+ }
264
+ union Headline = Concert | Exhibition
265
+ type Venue @node @mutation {
266
+ key: String! @key
267
+ events: [Event!]! @relationship(type: "HOSTS", direction: OUT) @filterable
268
+ headline: Headline @relationship(type: "HEADLINES", direction: OUT)
269
+ }
270
+ ```
271
+
272
+ ```graphql
273
+ {
274
+ events(
275
+ where: { title: { contains: "Rock" }, typename: [Concert] }
276
+ sort: [{ starts: ASC }]
277
+ ) {
278
+ __typename
279
+ key
280
+ title
281
+ ... on Concert {
282
+ band
283
+ }
284
+ }
285
+ headlines(where: { Exhibition: { title: { eq: "Modern art" } } }) {
286
+ __typename
287
+ }
288
+ venue(key: "v1") {
289
+ events(limit: 5) {
290
+ key
291
+ }
292
+ }
293
+ }
294
+ ```
295
+
296
+ Interfaces and unions range over `@node` types. An interface's
297
+ `@filterable` and `@sortable` fields apply to every implementation, and so
298
+ does index inference: each implementation's label gets the index. A root
299
+ list or relationship field over an interface runs one sorted, limited
300
+ subquery per implementation, each able to use its own index, and merges
301
+ them by the requested sort, then type name, then key. An interface `where`
302
+ takes the interface's fields plus `typename`; a union `where` takes one
303
+ filter per member, and once any member is named, members not named are left
304
+ out. Mutations connect, create and disconnect per member:
305
+ `events: { connect: { Concert: [{ key: "c1" }] } }`. A single relationship
306
+ to a union holds one node across all members.
307
+
308
+ ### Interface relationships
309
+
310
+ An interface field under `@declareRelationship` is a relationship every
311
+ implementation declares (with the same target and shape; the type and
312
+ direction may differ), so `events { venue { name } }` works at interface
313
+ level.
314
+
315
+ ## Mutations
316
+
317
+ Mutations exist only for types with `@mutation`, and address nodes by
318
+ `@key`, so every write-set is exact.
319
+
320
+ ```graphql
321
+ mutation {
322
+ createFestivals(
323
+ input: [
324
+ {
325
+ name: "Sunland"
326
+ genre: { create: { node: { key: "techno", name: "Techno" } } }
327
+ followers: { connect: [{ key: "u1", edge: { since: 2020 } }] }
328
+ }
329
+ ]
330
+ ) {
331
+ festivals {
332
+ key
333
+ name
334
+ genre {
335
+ name
336
+ }
337
+ }
338
+ info {
339
+ nodesCreated
340
+ relationshipsCreated
341
+ }
342
+ }
343
+ updateFestival(
344
+ key: "f1"
345
+ update: {
346
+ capacity: null # removes the property
347
+ genre: { connect: { key: "house" } } # replaces the single relationship
348
+ followers: {
349
+ disconnect: ["u2"]
350
+ update: [{ key: "u1", edge: { since: 2021 } }] # in place
351
+ }
352
+ }
353
+ adjust: { visits: { add: 1 }, tags: { push: ["summer"] } }
354
+ ) {
355
+ festival {
356
+ key
357
+ }
358
+ }
359
+ updateFestivals(
360
+ where: { capacity: { lt: 100 } }
361
+ adjust: { capacity: { multiply: 2 } }
362
+ limit: 50
363
+ ) {
364
+ info {
365
+ nodesUpdated
366
+ }
367
+ }
368
+ deleteFestival(key: "f2") {
369
+ nodesDeleted
370
+ relationshipsDeleted
371
+ }
372
+ }
373
+ ```
374
+
375
+ - **Updates** set fields (`null` removes one) and relationships (`connect`,
376
+ `create`, `disconnect`, and `update` of connected nodes and relationship
377
+ properties in place). Connecting an already-connected pair keeps one
378
+ relationship and updates its properties.
379
+ - **`adjust`** applies math (`add`, `subtract`, `multiply`, `divide`) and
380
+ list (`push`, `pop`, `remove`) operators to the stored value atomically;
381
+ a missing number counts as 0 and a missing list as empty.
382
+ - **Bulk `updateFestivals` / `deleteFestivals`** resolve the keys `where`
383
+ matches under the authorization filter, then run the keyed path, so their
384
+ write-sets are exact too. More matches than `limit` (default `maxBatch`)
385
+ is an error that writes nothing, and an empty `where` is refused.
386
+ - **`upsertFestivals`** creates the inputs whose key is new and updates the
387
+ rest; fields required on create are required only for new keys. A key the
388
+ caller may not see reads as taken.
389
+ - **Deletes** follow `onDelete`: `DETACH` (default) removes the
390
+ relationships, `CASCADE` deletes what the field reaches (checking the
391
+ caller may delete each node), `RESTRICT` refuses while related nodes
392
+ remain.
393
+ - **`@populatedBy(callback: "slug")`** computes a field with
394
+ `callbacks: { slug: ({ input, key, context, operation }) => … }`.
395
+
396
+ Each mutation runs in one interactive transaction and checks, before it
397
+ commits:
398
+
399
+ - every `connect` target exists and is visible to the caller (`NOT_FOUND`);
400
+ - a single relationship stays single and a required one stays set, even
401
+ when written from the other side or when its target is deleted
402
+ (`CONSTRAINT_VIOLATION`);
403
+ - `@authorization` validate rules, type and field level (`FORBIDDEN`).
404
+
405
+ Any failure rolls the whole mutation back. Engine constraint errors come
406
+ back as `CONSTRAINT_VIOLATION` naming the type and field. A mutation creates
407
+ or deletes at most `maxBatch` nodes (default 1000). `@key` is not updatable.
408
+ `info` reports `nodesCreated`, `nodesUpdated`, `nodesDeleted`,
409
+ `relationshipsCreated` and `relationshipsDeleted`.
410
+
411
+ ### Nested delete and trimmed inputs
412
+
413
+ `update: { stages: { delete: { where: { size: { gt: 2 } }, limit: 10 } } }`
414
+ deletes connected nodes (a single relationship takes `delete: true`),
415
+ bounded like bulk deletes and following `onDelete`. `limit` defaults to
416
+ `maxBatch`; more matches is `LIMIT_EXCEEDED`.
417
+ `@relationship(nestedOperations: [CONNECT])` keeps only the listed nested
418
+ writes in the inputs, and `aggregate: false` removes the relationship's
419
+ aggregates and aggregate filter.
420
+
421
+ ## Search
422
+
423
+ ```graphql
424
+ type Event @node @fulltext(indexes: [{ fields: ["title", "summary"] }]) {
425
+ key: String! @key
426
+ title: String!
427
+ summary: String
428
+ embedding: [Float!] @vector(dimensions: 384, similarity: COSINE)
429
+ }
430
+ ```
431
+
432
+ ```graphql
433
+ {
434
+ searchEvents(query: "techno sun*", where: { city: { eq: "Ams" } }) {
435
+ score
436
+ node {
437
+ key
438
+ title
439
+ }
440
+ }
441
+ similarEvents(to: "e1", limit: 5) {
442
+ score
443
+ node {
444
+ key
445
+ }
446
+ }
447
+ }
448
+ ```
449
+
450
+ Full-text queries AND their terms, fold case and accents, and treat a
451
+ trailing `*` as a prefix. Vector search takes a query `vector` or the key of
452
+ a node whose embedding to start from (`to`, which is left out of the
453
+ results). The index returns its top candidates before `where` applies, so
454
+ the library asks it for four times the page when a filter is present.
455
+ `@vector` fields are stored as VECTOR values, which the index requires, and
456
+ read back as `[Float!]`. Both indexes are part of S1 and created by
457
+ `assertSchema({ create: true })`; read rules apply to every result.
458
+
459
+ Every search also has a connection: `searchDocsConnection(query:, where:,
460
+ first:, after:)` pages by keyset on (score, key). A vector index returns
461
+ its top candidates before any filter, so vector connections page within
462
+ `4 × @limit(max:)` candidates.
463
+
464
+ ## @cypher fields
465
+
466
+ ```graphql
467
+ type Festival @node {
468
+ key: ID! @key
469
+ similar(limit: Int = 3): [Festival!]!
470
+ @cypher(
471
+ statement: """
472
+ MATCH (this)-[:IN_GENRE]->(:Genre)<-[:IN_GENRE]-(other:Festival)
473
+ WHERE other.key <> this.key
474
+ RETURN other ORDER BY other.name LIMIT $limit
475
+ """
476
+ columnName: "other"
477
+ )
478
+ }
479
+
480
+ type Query {
481
+ festivalCount: Int!
482
+ @cypher(statement: "MATCH (f:Festival) RETURN count(f) AS n")
483
+ }
484
+
485
+ type Mutation {
486
+ renameGenre(key: String!, name: String!): Genre
487
+ @cypher(
488
+ statement: "MATCH (g:Genre) WHERE g.key = $key SET g.name = $name RETURN g"
489
+ )
490
+ }
491
+ ```
492
+
493
+ `this` is the parent node; arguments are `$parameters`; `$jwt` holds the
494
+ request's claims. `columnName` is inferred from `RETURN x` or `RETURN … AS x`.
495
+ Fields returning `@node` types are projected with the selection like any
496
+ other node, and read filters apply to them.
497
+
498
+ Statements are checked, not trusted:
499
+
500
+ - at startup: every `$parameter` must be an argument or `$jwt`, Query and
501
+ object fields may not contain write clauses, and unused arguments,
502
+ `OPTIONAL MATCH` and statements that never use `this` are warnings
503
+ (`lora.model.warnings`);
504
+ - in `check()`: every statement is planned with `explain()`, so a syntax
505
+ error, an unknown function or a missing column fails CI with the engine's
506
+ message, not the first request.
507
+
508
+ ### Filters, sorts and richer results
509
+
510
+ A scalar `@cypher` field of a `@node` type may take `@filterable` and
511
+ `@sortable`: the statement then runs per node in a `CALL` before the
512
+ filter, in root fields only (through a relationship it is refused). No
513
+ index applies, and the model warns so `check` reports it.
514
+
515
+ A `@cypher` field may return an interface or union over `@node` types
516
+ (each node is projected as the member its label says) or an object type
517
+ without `@node`, whose fields are read from the returned map:
518
+ `RETURN { events: count(e), titles: collect(e.title) } AS s`.
519
+
520
+ ## Authorization
521
+
522
+ The library does not verify tokens. Verify them in your server and put the
523
+ claims in the context as `jwt` (or pass `jwt: (context) => claims`).
524
+
525
+ ```graphql
526
+ type Claims @jwt {
527
+ sub: String!
528
+ roles: [String!] @jwtClaim(path: "app_metadata.roles")
529
+ }
530
+
531
+ type Post
532
+ @node
533
+ @mutation
534
+ @authentication(operations: [CREATE, UPDATE, DELETE])
535
+ @authorization(
536
+ filter: [
537
+ {
538
+ where: { node: { published: { eq: true } } }
539
+ requireAuthentication: false
540
+ }
541
+ { where: { node: { author: { key: { eq: "$jwt.sub" } } } } }
542
+ { where: { node: { tenant: { eq: "$context.tenant" } } } }
543
+ { where: { jwt: { roles: { includes: "admin" } } } }
544
+ ]
545
+ validate: [
546
+ {
547
+ operations: [CREATE, UPDATE]
548
+ when: [AFTER]
549
+ where: {
550
+ OR: [
551
+ { node: { author: { key: { eq: "$jwt.sub" } } } }
552
+ { jwt: { roles: { includes: "admin" } } }
553
+ ]
554
+ }
555
+ }
556
+ ]
557
+ ) {
558
+ key: ID! @key(generate: true)
559
+ title: String!
560
+ tenant: String!
561
+ published: Boolean! @default(value: false)
562
+ notes: String @authentication(operations: [READ])
563
+ royalties: Int
564
+ @authorization(
565
+ validate: [
566
+ {
567
+ operations: [READ]
568
+ where: { node: { author: { key: { eq: "$jwt.sub" } } } }
569
+ }
570
+ ]
571
+ )
572
+ author: User! @relationship(type: "WROTE", direction: IN)
573
+ }
574
+ ```
575
+
576
+ - A rule is `{ node, jwt, AND, OR, NOT }`. `node` is a filter over the type,
577
+ where `"$jwt.path"` strings become the caller's claims and
578
+ `"$context.path"` strings values from the GraphQL context. `jwt` tests
579
+ claims (`eq`, `in`, `includes`, `contains`, `startsWith`, `endsWith`,
580
+ `lt`, `lte`, `gt`, `gte`, `exists`).
581
+ - **Claim tests run in JavaScript at compile time**, so an admin's
582
+ statement carries no filter at all. Statements stay specialised and
583
+ index-friendly.
584
+ - **A test that needs a claim or context value the request lacks is
585
+ unknown**: false where it stands, and a `NOT` over it is false too, so
586
+ negation can never turn a missing claim into a grant. Node conditions
587
+ follow Cypher: `NOT` over a null property is not true.
588
+ - `filter` rules (any passing rule grants) make other nodes invisible:
589
+ in lists, lookups, counts, aggregates, search, nested relationships,
590
+ relationship filters, subscriptions, and as targets of updates, deletes
591
+ and connects. Filter rules for `CREATE_RELATIONSHIP` and
592
+ `DELETE_RELATIONSHIP` guard both ends of connects and disconnects.
593
+ - `validate` rules fail the request with `FORBIDDEN`: `BEFORE` an update or
594
+ delete, `AFTER` a create or update (rolling it back), and for `READ`: on
595
+ any returned node, and on cursors, counts and aggregates that cover one.
596
+ They do not hide nodes from filters; use `filter` rules for that.
597
+ - Field-level `@authorization(validate:)` guards one field: reading it on a
598
+ row that fails is `FORBIDDEN`, filtering by it only matches rows that
599
+ pass, sorting or aggregating by it is refused, and writing it checks the
600
+ rule. Field-level `@authentication` also guards filtering, sorting and
601
+ aggregating on the field.
602
+ - `@authentication(operations:, jwt:)` covers `READ`, `CREATE`, `UPDATE`,
603
+ `DELETE`, `CREATE_RELATIONSHIP`, `DELETE_RELATIONSHIP` and `SUBSCRIBE`,
604
+ and may require claims.
605
+ - Rules are checked against the model at startup: an unknown field,
606
+ operator or (with `@jwt`) claim, or a test that is empty or null, is an
607
+ error, not an open door.
608
+
609
+ Relationship and `@cypher` fields take field-level `@authorization`
610
+ with READ validate rules: a row failing the rule reads the field as
611
+ `FORBIDDEN`, and filtering through the field applies the rule too.
612
+
613
+ ## The smart layer
614
+
615
+ **S1: indexes from the API.** `requirements()` derives every constraint and
616
+ index the API needs, with the reason for each:
617
+
618
+ | API declares | Needs |
619
+ | -------------------------------------- | --------------------------------------- |
620
+ | `@key` | node key constraint |
621
+ | `@unique` | uniqueness constraint |
622
+ | non-null `@sortable` field | existence constraint |
623
+ | `EQ`, `IN` | nothing: LoraDB indexes equality lazily |
624
+ | `LT`, `LTE`, `GT`, `GTE`, `@sortable` | RANGE index |
625
+ | `CONTAINS`, `STARTS_WITH`, `ENDS_WITH` | TEXT index |
626
+ | `WITHIN_BBOX`, `DISTANCE` | POINT index |
627
+ | `@fulltext`, `@vector` | FULLTEXT and VECTOR indexes, by name |
628
+
629
+ `assertSchema()` reports what the database lacks;
630
+ `assertSchema({ create: true })` creates it, idempotently.
631
+
632
+ **S2: plans are checked.** Every compiled statement records the access path
633
+ it was written for. `lora.explain(query, variables)` plans each statement and
634
+ reports a label scan where a seek was expected, a mutating plan behind a
635
+ read, or result columns that do not match. `lora.check({ operations })`
636
+ runs this over your operations; the CLI does it in CI.
637
+
638
+ **S3: compile once.** `lora.persist({ id: source })` parses and validates
639
+ persisted operations at startup, and `lora.execute({ id, variables, context })`
640
+ runs them with no parsing or validation. Ad hoc documents passed to
641
+ `execute({ source })` are cached too. Translation itself takes about 0.13 ms
642
+ for a nested page, and statement text depends only on the shape of the
643
+ input, so LoraDB's own plan cache is hit for every repeat.
644
+
645
+ **S5: read-sets and write-sets.** See [change tracking](#change-tracking).
646
+
647
+ **S6: statistics and cost.** A relationship filter that names a related node
648
+ by key starts from that node and expands, instead of scanning the label.
649
+ Every operation has a cost estimate (rows touched, multiplying page sizes
650
+ through nested lists, capped by `@cardinality`), summed across its root
651
+ fields, and an operation over `maxCost` (default 50 000) fails with
652
+ `COST_EXCEEDED` before it runs. `lora.analyze()` samples node counts and
653
+ relationship degrees so estimates use the measured p99 degree instead of
654
+ the page size.
655
+
656
+ **S7: one SDL, two diffs.** `diffSchemas(before, after)` reports the
657
+ database statements a change needs (index and constraint changes, relabels,
658
+ property renames, destructive ones flagged) and the API's breaking and
659
+ dangerous changes. A field renamed with `@alias` over the same property is
660
+ reported as an API break with no data migration.
661
+
662
+ ## Change tracking
663
+
664
+ ```ts
665
+ lora.onWrite((change) => {
666
+ // Per type: lists, counts and connections of these types may have
667
+ // changed, not only the entities. A broad change (a @cypher mutation)
668
+ // names nothing, so it invalidates every type.
669
+ const types = change.broad ? allNodeTypes : change.types;
670
+ cache.invalidate(types.map((typename) => ({ typename })));
671
+ });
672
+
673
+ for await (const change of lora.changes({ signal })) publish(change);
674
+ ```
675
+
676
+ A `WriteChange` lists the nodes `created`, `updated` and `deleted`, the
677
+ relationships `connected` and `disconnected` (by field and both keys),
678
+ `entities` (every node whose observable state changed, relationship ends
679
+ included), and the touched `types` and `relationshipTypes`. Every compiled
680
+ read carries a read-set (labels and relationship types);
681
+ `lora.affects(reads, change)` tells whether a cached read may be stale.
682
+ `@cypher` mutations have no known write-set and are reported with
683
+ `broad: true`. Only writes made through the library are seen. A consumer
684
+ that falls `maxQueuedChanges` (default 1000) behind is ended with an error
685
+ rather than buffering without bound.
686
+
687
+ ## Subscriptions
688
+
689
+ ```graphql
690
+ subscription {
691
+ festivalChanged(
692
+ operations: [CREATE, UPDATE]
693
+ where: { capacity: { gt: 1000 } }
694
+ ) {
695
+ operation
696
+ key
697
+ node {
698
+ name
699
+ capacity
700
+ }
701
+ }
702
+ }
703
+ ```
704
+
705
+ A type with `@subscription` gets `<type>Changed`, fed by the write-sets of
706
+ mutations made through the library (`@cypher` mutations have none, and
707
+ writes made elsewhere are not seen). A node that gained or lost a
708
+ relationship is an `UPDATE`. `where` tests the node as it is after the
709
+ write; `node` is read when the event is delivered, through the normal read
710
+ path. Events for nodes the subscriber cannot read are dropped (checked in
711
+ one query per write, not per event), and deletions, which cannot be checked
712
+ after the fact, go only to subscribers following that `key` without a
713
+ `where`. `@authentication(operations: [SUBSCRIBE])` guards the subscription
714
+ itself. Pass a `signal` in the context to end the stream with the request.
715
+
716
+ Every event has a `timestamp` (when the write was committed). With
717
+ `@subscription(relationships: true)` a type also gets `CONNECT` and
718
+ `DISCONNECT` events, one per relationship, with `relationship { field type
719
+ relatedType relatedKey }`. With `@subscription(previousState: true)`,
720
+ `UPDATE` and `DELETE` events carry `previousState`: the stored values
721
+ before the write (readable scalar fields without field-level rules), at
722
+ the cost of one read per write. Subscribers whose checks compile to the
723
+ same statement share it: twenty subscribers with the same `where` and
724
+ claims cost one visibility query and one node read per write.
725
+
726
+ By default subscriptions see the writes this instance makes. With
727
+ `changeFeed: true` (lora-node), subscriptions and `changes()` are fed by
728
+ the engine's committed change feed instead: every write, whichever path or
729
+ process made it (hand-written Cypher, `@cypher` mutations, imports), in
730
+ commit order, with relationship ends resolved to their `@key`s. A consumer
731
+ that falls behind resumes from its last position. `onWrite` still reports
732
+ this instance's mutations, and `previousState` needs them: the feed carries
733
+ the state after the write. Call `lora.close()` to stop the feed.
734
+
735
+ ## Transactions
736
+
737
+ ```ts
738
+ const tx = await lora.begin();
739
+ try {
740
+ await graphql({
741
+ schema,
742
+ source: createOrder,
743
+ contextValue: { jwt, transaction: tx },
744
+ });
745
+ await tx.execute("MATCH (s:Stock {sku: $sku}) SET s.count = s.count - 1", {
746
+ sku,
747
+ });
748
+ await tx.commit(); // change events fire now
749
+ } catch (err) {
750
+ await tx.rollback();
751
+ throw err;
752
+ }
753
+ ```
754
+
755
+ With `transaction` in the context, every operation of the request runs in
756
+ it, next to the application's own `tx.execute(cypher)`: they commit or roll
757
+ back together, reads see the transaction's writes, and change events wait
758
+ for the commit. A failed mutation rolls the transaction back.
759
+
760
+ ## CLI
761
+
762
+ ```sh
763
+ lora-graphql print schema.graphql # the public SDL
764
+ lora-graphql requirements schema.graphql --ddl # constraints and indexes, as DDL
765
+ lora-graphql check schema.graphql --operations src/operations
766
+ lora-graphql compile schema.graphql --operations src/operations --out generated
767
+ lora-graphql analyze schema.graphql --database ./data # statistics JSON
768
+ lora-graphql diff old.graphql new.graphql # exit 1 on breaking changes
769
+ lora-graphql directives # directive SDL for editors
770
+ ```
771
+
772
+ `check` builds an in-memory LoraDB (needs `@loradb/lora-node`), asserts the
773
+ schema, plans every `@cypher` statement and every query in the operation
774
+ files, and exits non-zero on any finding. It is the CI gate. Options:
775
+
776
+ - `--variables vars.json`: variables per operation name, instead of
777
+ sample values for the required ones.
778
+ - `--baseline plans.json`: the operators of every statement; a plan that
779
+ differs fails, so plan changes show up in review (`--update-baseline`
780
+ accepts them; a missing file is written).
781
+ - `--row-budget n`: fail statements the engine estimates to scan more rows.
782
+ - `--database dir [--name app]`: check an existing database as it is, and
783
+ report indexes it has that the API does not use.
784
+
785
+ It also prints lint notes that do not fail the run: mutations without
786
+ rules, `CASE_INSENSITIVE` and `IS_NULL` filters (no index applies), and
787
+ list relationships without `@cardinality` or statistics.
788
+
789
+ `compile` validates persisted operations (`.graphql` files, one entry per
790
+ operation, or a JSON map of id to source) into `manifest.json`, which
791
+ `lora.loadManifest()` registers without parsing or validating again, and
792
+ `operations.d.ts` with `<Operation>Variables` and `<Operation>Result`
793
+ types. The manifest records a hash of the public schema and is refused
794
+ for any other. Statement text is not in it: it depends on variable values
795
+ and claims, and is compiled per request (and cached).
796
+
797
+ `analyze` samples a database's label counts and relationship degrees; pass
798
+ its output to `lora.useStatistics()`.
799
+
800
+ `migrate neo4j schema.graphql [--operations dir]` rewrites an
801
+ `@neo4j/graphql` SDL: `@id` becomes `@key(generate: true)`, `@node` is
802
+ added, `@fulltext`, `@subscription(events:)` and `@relationship` arguments
803
+ are translated, and what has no equivalent (`@coalesce`, federation,
804
+ `connectOrCreate`, type-level `@vector`) is removed and listed as
805
+ `# TODO(migrate)` lines. With the client's operations (both the neo4j 5
806
+ `title_CONTAINS` and neo4j 6 `{ title: { contains } }` filter forms),
807
+ `@mutation`, `@filterable` and `@sortable` follow what they use; without
808
+ them, every type keeps its mutations and a TODO says to narrow them.
809
+
810
+ ### Testing
811
+
812
+ ```ts
813
+ import {
814
+ createTestLoraGraphQL,
815
+ expectSeeks,
816
+ } from "@loradb/lora-graphql/testing";
817
+
818
+ const t = await createTestLoraGraphQL({
819
+ typeDefs,
820
+ seed: ["CREATE (:Festival {key: 'f1', name: 'Sunland'})"],
821
+ });
822
+ expect(await t.data(`{ festival(key: "f1") { name } }`)).toEqual({
823
+ festival: { name: "Sunland" },
824
+ });
825
+ await expectSeeks(
826
+ t.lora,
827
+ `{ festivals(where: { name: { eq: "Sunland" } }) { key } }`,
828
+ );
829
+ t.close();
830
+ ```
831
+
832
+ `createTestLoraGraphQL` builds an in-memory database with the schema
833
+ asserted, runs the seed, and records every statement in `t.statements`.
834
+ `expectSeeks` throws, listing each plan finding, unless every root field of
835
+ the query uses the index access it was compiled for (`rowBudget` too).
836
+ Both need `@loradb/lora-node` and work with any test runner.
837
+
838
+ ## Drivers, limits and errors
839
+
840
+ `loraDriver(db)` adapts a `Database` from `@loradb/lora-node` or
841
+ `@loradb/lora-wasm`. Single-statement reads stream; multi-statement reads
842
+ run in one read-only transaction; mutations need interactive transactions,
843
+ which only the Node binding has, so the WASM binding serves reads.
844
+ `explain()`, and so plan checks, need the Node binding too.
845
+
846
+ | Option | Default | Meaning |
847
+ | ---------------------------- | ------------- | --------------------------------------------------- |
848
+ | `timeoutMs` | 10 000 | Per statement; a `signal` in the context cancels |
849
+ | `maxCost` | 50 000 | Estimated rows per operation |
850
+ | `maxBatch` | 1000 | Nodes created or deleted per mutation; bulk `limit` |
851
+ | `maxQueuedChanges` | 1000 | How far a change consumer may fall behind |
852
+ | `defaultLimit` / `maxLimit` | 25 / 100 | Global page sizes; `@limit` may only lower `max` |
853
+ | `callbacks` | | Named callbacks for `@populatedBy` |
854
+ | `jwt` | `context.jwt` | Where the claims are |
855
+ | `onStatement` | | Observe every statement |
856
+ | `cursorSecret` | | Sign cursors; reject unsigned or forged ones |
857
+ | `maskErrors` | production | Clients get `DATABASE_ERROR` and an `id` only |
858
+ | `onError` | | Receives each database error's detail and `id` |
859
+ | `guards` | see below | Document limits; `false` turns them off |
860
+ | `persistedOnly` | false | `execute()` runs persisted operations only |
861
+ | `budget` | | Cost limit per request, from the context |
862
+ | `onCost` | | Each root field's estimate, total and limit |
863
+ | `onStatementEnd` | | Duration, rows and error of every statement call |
864
+ | `tracer` / `traceStatements` | | OpenTelemetry-style spans; Cypher text on request |
865
+ | `metrics` | | Counters and histograms (see below) |
866
+
867
+ Errors carry `extensions.code`: `BAD_USER_INPUT`, `INVALID_CURSOR`,
868
+ `LIMIT_EXCEEDED`, `COST_EXCEEDED`, `UNAUTHENTICATED`, `FORBIDDEN`,
869
+ `NOT_FOUND`, `CONSTRAINT_VIOLATION` (with `type` and `field`) and
870
+ `DATABASE_ERROR` (with an `id`, also given to `onError`). An invalid SDL
871
+ throws one `ModelError` listing every problem, each located by type and
872
+ field.
873
+
874
+ ### Security defaults
875
+
876
+ `maxCost` bounds the rows an operation touches; the document guards bound
877
+ the document before that. `execute()` and `persist()` apply them, and
878
+ `lora.validationRules()` / `lora.envelopPlugin()` bring them to any other
879
+ server (GraphQL Yoga takes the plugin as is):
880
+
881
+ | Guard | Default | Limit |
882
+ | --------------- | ---------- | ---------------------------------------- |
883
+ | `maxDepth` | 12 | Field nesting, through fragments |
884
+ | `maxAliases` | 30 | Aliased fields per document |
885
+ | `maxRootFields` | 20 | Root fields per operation |
886
+ | `maxTokens` | 5000 | Lexer tokens per document, while parsing |
887
+ | `introspection` | production | Off when `NODE_ENV` is `production` |
888
+
889
+ With `NODE_ENV=production`, database errors are masked and introspection
890
+ is off unless configured otherwise. See
891
+ [the threat model](../../docs/design/graphql-threat-model.md) for what
892
+ the library trusts and where each check runs.
893
+
894
+ ### Observability
895
+
896
+ `onStatement` fires before a statement runs; `onStatementEnd` after, with
897
+ `durationMs`, `rows`, `error`, `mode`, the cost estimate, the operation
898
+ name and the persisted id. Reads report their batch of statements in one
899
+ event; mutations report each statement.
900
+
901
+ Pass an OpenTelemetry tracer (`trace.getTracer("lora-graphql")`) as
902
+ `tracer` and each root field gets a `lora.graphql.field` span holding a
903
+ `lora.cypher` span per statement call, with `db.system`,
904
+ `db.operation.name` and `db.response.returned_rows`. The Cypher text goes
905
+ in `db.statement` only with `traceStatements: true`. `metrics` takes any
906
+ object with `counter(name, value, attributes)` and
907
+ `histogram(name, value, attributes)`: it receives
908
+ `lora.graphql.statements`, `lora.graphql.errors`,
909
+ `lora.graphql.statement.duration` (ms) and `lora.graphql.cost`.
910
+
911
+ `budget(context)` sets the cost limit per request (a plan, a user), and
912
+ `onCost` sees every estimate. `execute()` also returns the operation's
913
+ estimate as `extensions.cost`, so clients can tune their queries.
914
+
915
+ ### Compile cache
916
+
917
+ `execute()` caches parsed documents, and each read root field caches its
918
+ compiled statements per field node, exact variables, claims and the
919
+ `$context` values the compile read (claims are folded into the text, so
920
+ each distinct set gets its own compile). A repeated `festivals(limit: 20)`
921
+ drops from 0.14 ms to 0.06 ms end to end. Servers that parse every request
922
+ themselves get new field nodes each time and do not benefit; use
923
+ `execute()` or persisted operations.
924
+
925
+ `check({ rowBudget })` flags statements whose largest engine row estimate
926
+ exceeds the budget; every plan report carries `estimatedRows` either way.
927
+
928
+ ### Versions
929
+
930
+ `@loradb/lora-graphql` is released in lockstep with `@loradb/lora-node`:
931
+ version X.Y.Z declares `"@loradb/lora-node": "^X.Y.Z"` as its peer and is
932
+ tested against that binding. Upgrade both together.
933
+
934
+ ## Translation rules
935
+
936
+ Measured on LoraDB 0.15 over 20 000 festivals and 100 000 relationships
937
+ (`yarn bench`):
938
+
939
+ | Rule | Why |
940
+ | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ |
941
+ | Relationship filters as `size([… \| 1])`; aggregates of related values with `reduce` | No `OPTIONAL MATCH`; `EXISTS { }` does not parse; aggregates nest safely |
942
+ | `CALL { }` for nested lists, nested connections, `@cypher` and interface members | The only way to sort, limit and aggregate per parent |
943
+ | Ordered by an always-present string: `WHERE s >= ""` | The planner walks the index in order and stops at the limit: 0.03 ms instead of 7 ms |
944
+ | Mutation statements seek the key in their own `MATCH`, then expand | The plan no longer depends on the optimizer finding the seek: 0.06 ms per connect |
945
+ | A relationship filter naming a key starts from that node | 0.07 ms instead of 9.3 ms |
946
+ | Keyset predicates written out, led by `sortKey >= $v` on non-null keys | `[a, b] > $list` silently matches nothing; the lead bound gets a range scan |
947
+ | Lists sort by the requested fields only; connections add a unique tie-breaker | Two sort keys cannot stream from an index |
948
+ | Every value a parameter; every identifier from the model, escaped | No injection, stable statement text |
949
+ | Absent filters left out, never `($p IS NULL OR …)` | Keeps the predicate visible to the planner |
950
+
951
+ ## Coming from @neo4j/graphql
952
+
953
+ | @neo4j/graphql | lora-graphql |
954
+ | ----------------------------------------------------------- | ---------------------------------------------------------------- |
955
+ | Every type gets every operation | Reads by default; mutations and subscriptions opt in |
956
+ | Every field filterable by every operator | `@filterable(byValue:)`, checked against the type |
957
+ | Offset cursors (`arrayconnection:N`) | Keyset cursors tagged with their sort (signed with a secret) |
958
+ | `update`/`delete` with an optional `where` | By `@key`, or bulk with a required, bounded `where` |
959
+ | `connect: { where }`, silently a no-op when nothing matches | Connect by key; a missing target is `NOT_FOUND` |
960
+ | Single relationships not enforced; required ones refused | Enforced from both sides; required ones supported |
961
+ | `{ viewers: { add: 1 } }` | `adjust: { viewers: { add: 1 } }` |
962
+ | `@id`, `@populatedBy`, `@timestamp` | `@key(generate: true)`, `@populatedBy`, `@timestamp` |
963
+ | `@authorization` rules evaluated in Cypher | Claim checks folded in JavaScript; node rules compiled |
964
+ | You pick indexes | Inferred from the API, and verified with `explain()` |
965
+ | Unbounded lists; complexity left to you | Every list bounded; a cost limit per operation |
966
+ | Subscriptions over CDC | `@subscription`, from library-made writes |
967
+ | Interfaces and unions with `UNION` | Per-member subqueries merged by sort, each using its own indexes |
968
+ | Federation | Not supported |
969
+
970
+ ## LoraDB behaviours this works around
971
+
972
+ Found while building this package; each workaround goes away with its fix.
973
+ Fixed in the engine and no longer worked around: labels after the first
974
+ node of a `MATCH`, early `LIMIT` under a deadline or in a transaction,
975
+ `MERGE` with a bound end node (connect is one `MERGE`), writes in
976
+ `CALL { }`, temporal RANGE indexes (inferred again for temporal fields),
977
+ `x IN $list` seeks, `null` values in property maps, and existence checks
978
+ before a following `SET`.
979
+
980
+ | Behaviour | Workaround |
981
+ | ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- |
982
+ | An aggregate nested in a call (`head(collect(x))`, `collect(x)[0..2]`) is not aggregated | `WITH collect(x) AS c RETURN head(c)`; `reduce` for per-parent aggregates |
983
+ | `max`, `sum` and `avg` over durations are wrong | Duration aggregates are folded with `reduce` |
984
+ | Integer division returns a float; negative list slices (`l[..-1]`) return `[]` | `toInteger(a / b)` for Int fields; `l[..size(l) - n]` |
985
+ | `COUNT { … RETURN DISTINCT x }`, `EXISTS { }` and `UNION` inside `CALL` do not parse | `reduce` for distinct counts; comprehensions; per-member subqueries |
986
+ | `[a, b] > $list` matches nothing; `first()` is unknown | See translation rules |
987
+
988
+ ## Development
989
+
990
+ ```sh
991
+ yarn test # vitest: model, TCK snapshots, integration, auth, mutations, CLI
992
+ yarn bench # latency on a seeded graph
993
+ yarn typecheck && yarn lint && yarn build
994
+ ```