@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.
- package/LICENSE +87 -0
- package/README.md +994 -0
- package/dist/analyze/cypher-check.d.ts +8 -0
- package/dist/analyze/diff.d.ts +32 -0
- package/dist/analyze/indexes.d.ts +61 -0
- package/dist/analyze/lint.d.ts +6 -0
- package/dist/analyze/plans.d.ts +31 -0
- package/dist/analyze/statistics.d.ts +19 -0
- package/dist/cli.d.ts +6 -0
- package/dist/cli.js +631 -0
- package/dist/cli.js.map +1 -0
- package/dist/codegen.d.ts +32 -0
- package/dist/compile/aggregate.d.ts +17 -0
- package/dist/compile/auth.d.ts +46 -0
- package/dist/compile/context.d.ts +43 -0
- package/dist/compile/cursor.d.ts +4 -0
- package/dist/compile/cypher.d.ts +175 -0
- package/dist/compile/filter.d.ts +19 -0
- package/dist/compile/hmac.d.ts +4 -0
- package/dist/compile/read.d.ts +89 -0
- package/dist/compile/selection.d.ts +18 -0
- package/dist/diff-BVP1Jzh3.js +99 -0
- package/dist/diff-BVP1Jzh3.js.map +1 -0
- package/dist/driver-C8vA5fV-.js +9127 -0
- package/dist/driver-C8vA5fV-.js.map +1 -0
- package/dist/driver.d.ts +117 -0
- package/dist/errors.d.ts +20 -0
- package/dist/execute/changes.d.ts +61 -0
- package/dist/execute/cypher-mutation.d.ts +4 -0
- package/dist/execute/feed.d.ts +10 -0
- package/dist/execute/mutate.d.ts +57 -0
- package/dist/execute/transaction.d.ts +17 -0
- package/dist/guards.d.ts +42 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -0
- package/dist/lora-graphql.d.ts +291 -0
- package/dist/migrate.d.ts +8 -0
- package/dist/model/build.d.ts +25 -0
- package/dist/model/cypher-lexer.d.ts +10 -0
- package/dist/model/directives.d.ts +3 -0
- package/dist/model/points.d.ts +12 -0
- package/dist/model/relations.d.ts +7 -0
- package/dist/model/types.d.ts +311 -0
- package/dist/observe.d.ts +72 -0
- package/dist/schema/build.d.ts +38 -0
- package/dist/schema/global-id.d.ts +6 -0
- package/dist/schema/guard.d.ts +5 -0
- package/dist/schema/mutations.d.ts +44 -0
- package/dist/schema/names.d.ts +37 -0
- package/dist/schema/scalars.d.ts +9 -0
- package/dist/testing.d.ts +36 -0
- package/dist/testing.js +56 -0
- package/dist/testing.js.map +1 -0
- 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
|
+
```
|