@loradb/lora-graphql 0.20.2 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +523 -77
- package/dist/analyze/access.d.ts +45 -2
- package/dist/analyze/statistics.d.ts +1 -0
- package/dist/cli.js +386 -323
- package/dist/cli.js.map +1 -1
- package/dist/compile/auth.d.ts +49 -0
- package/dist/compile/cache.d.ts +11 -1
- package/dist/compile/context.d.ts +20 -0
- package/dist/compile/cost.d.ts +36 -0
- package/dist/compile/read.d.ts +7 -0
- package/dist/{diff-BO2igqdY.js → diff-B9iDdELF.js} +13 -12
- package/dist/{diff-BO2igqdY.js.map → diff-B9iDdELF.js.map} +1 -1
- package/dist/driver-C5dSoSD8.js +13905 -0
- package/dist/driver-C5dSoSD8.js.map +1 -0
- package/dist/driver.d.ts +2 -0
- package/dist/errors.d.ts +29 -1
- package/dist/execute/budget.d.ts +22 -0
- package/dist/execute/changes.d.ts +2 -0
- package/dist/execute/cypher-mutation.d.ts +4 -1
- package/dist/execute/mutate.d.ts +29 -1
- package/dist/guards.d.ts +19 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +19 -17
- package/dist/lora-graphql.d.ts +141 -13
- package/dist/model/desugar.d.ts +10 -0
- package/dist/model/directives.d.ts +1 -1
- package/dist/model/inputs.d.ts +9 -0
- package/dist/model/types.d.ts +46 -3
- package/dist/model/unique-together.d.ts +3 -0
- package/dist/schema/build.d.ts +6 -1
- package/dist/testing.js +1 -1
- package/package.json +2 -2
- package/dist/driver-XeJM24iP.js +0 -11778
- package/dist/driver-XeJM24iP.js.map +0 -1
package/README.md
CHANGED
|
@@ -44,6 +44,7 @@ const lora = new LoraGraphQL({ typeDefs, driver: loraDriver(db) });
|
|
|
44
44
|
await lora.assertSchema({ create: true }); // the constraints and indexes the API needs
|
|
45
45
|
const yoga = createYoga({
|
|
46
46
|
schema: lora.getSchema(),
|
|
47
|
+
plugins: [lora.envelopPlugin()], // document guards (and atomic mutations)
|
|
47
48
|
context: ({ request }) => ({
|
|
48
49
|
jwt: verifiedClaims(request),
|
|
49
50
|
signal: request.signal,
|
|
@@ -85,6 +86,7 @@ them off):
|
|
|
85
86
|
| `searchFestivals(query, where, limit)` | Full-text search, with `@fulltext` |
|
|
86
87
|
| `similarFestivals(vector or to, where, limit)` | Vector similarity, with a `@vector` field |
|
|
87
88
|
| `node(id:)` | Any `@relayId` type by global id |
|
|
89
|
+
| `nodes(ids:)` | Many global ids at once, in order; null where unknown or hidden (at most `maxLimit`) |
|
|
88
90
|
| `events(where, sort, limit)` | An interface's or union's members together |
|
|
89
91
|
| `createFestivals`, `upsertFestivals` | With `@mutation(CREATE)` (upsert also needs `UPDATE`) |
|
|
90
92
|
| `updateFestival`, `updateFestivals(where, limit)` | With `@mutation(UPDATE)`: by key, or bulk by `where` |
|
|
@@ -108,31 +110,41 @@ field, a `@selectable` on a field of an object type without `@node`, a
|
|
|
108
110
|
position, never silently ignored. `DIRECTIVE_POSITIONS` in
|
|
109
111
|
`src/model/positions.ts` is the full table.
|
|
110
112
|
|
|
113
|
+
A directive on an extension (`extend type Secret @authorization(...)`,
|
|
114
|
+
`extend interface`, `extend union`, `extend scalar`, `extend schema`)
|
|
115
|
+
applies exactly as on the definition, so a type's rules may live in
|
|
116
|
+
another file. Repeatable directives (`@uniqueTogether`,
|
|
117
|
+
`@authorizationRule`) add up across the definition and its extensions;
|
|
118
|
+
any other directive written on both is an error. `extend type X @node`
|
|
119
|
+
makes a plain `type X` a node type; extending a type that is never
|
|
120
|
+
defined is an error.
|
|
121
|
+
|
|
111
122
|
Model:
|
|
112
123
|
|
|
113
|
-
| Directive | On | Meaning
|
|
114
|
-
| ---------------------------------------------------------------------------------------------------------- | ----------------- |
|
|
115
|
-
| `@node(labels:, plural:)` | type | A node label set; default: the type name
|
|
116
|
-
| `@key(generate:)` | field | Required, unique and immutable. The sort tie-breaker, cursor anchor and mutation address. `generate: true` fills a UUID on create
|
|
117
|
-
| `@unique` | field | Uniqueness constraint
|
|
118
|
-
| `@
|
|
119
|
-
| `@
|
|
120
|
-
| `@
|
|
121
|
-
| `@
|
|
122
|
-
| `@
|
|
123
|
-
| `@
|
|
124
|
-
| `@
|
|
125
|
-
| `@
|
|
126
|
-
| `@
|
|
127
|
-
| `@
|
|
128
|
-
| `@
|
|
129
|
-
| `@
|
|
130
|
-
| `@
|
|
131
|
-
| `@
|
|
132
|
-
| `@
|
|
133
|
-
| `@
|
|
134
|
-
| `@
|
|
135
|
-
| `@
|
|
124
|
+
| Directive | On | Meaning |
|
|
125
|
+
| ---------------------------------------------------------------------------------------------------------- | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
126
|
+
| `@node(labels:, plural:)` | type | A node label set; default: the type name |
|
|
127
|
+
| `@key(generate:)` | field | Required, unique and immutable. The sort tie-breaker, cursor anchor and mutation address. `generate: true` fills a UUID on create |
|
|
128
|
+
| `@unique` | field | Uniqueness constraint |
|
|
129
|
+
| `@uniqueTogether(fields:, where:)` | type | No two nodes (matching `where`) share these fields: scalars, single relationships, at most one list relationship as a set. Repeatable; see [Mutations](#mutations) |
|
|
130
|
+
| `@index(kind: RANGE \| TEXT \| POINT)` | field | An explicit index, usually inferred |
|
|
131
|
+
| `@storedAs(type:)` | custom scalar | How a custom scalar is stored (`STRING`, `INT`, `FLOAT`, `BOOLEAN`, `DATETIME`, `DATE`); its SDL description is what clients see, whatever implementation `scalars` passes |
|
|
132
|
+
| `@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 |
|
|
133
|
+
| `@declareRelationship` | interface field | Every implementation declares this relationship (type and direction may differ); select it on the interface |
|
|
134
|
+
| `@relationshipProperties` | type | Properties on a relationship type |
|
|
135
|
+
| `@alias(property:)` | field | API name differs from the stored property |
|
|
136
|
+
| `@private` | field | Stored, never exposed |
|
|
137
|
+
| `@readonly` | field | Exposed, never client-settable; on a relationship, absent from create and update inputs |
|
|
138
|
+
| `@settable(onCreate:, onUpdate:)` | field | Which mutations may set it, e.g. set once on create; on a relationship, whether the inputs offer it (an upsert of an existing node keeps it); on a relationship property, `onUpdate: false` also refuses a re-connect that would change it |
|
|
139
|
+
| `@selectable(onRead:, onAggregate:)` | field | `onRead: false` makes a field write-only; on a relationship property, it leaves the edge type too (`onAggregate: false`, the edge aggregates); with every property hidden, the edge has no `properties` |
|
|
140
|
+
| `@default(value:)` | field | Stored on create when the input omits it; on a relationship property, when the relationship is created |
|
|
141
|
+
| `@timestamp(operations: [CREATE, UPDATE])` | field | Set to the current time; client-settable only with an explicit `@settable` and a field rule (see Mutations). On a relationship property: CREATE when the relationship is created, UPDATE on edge updates and re-connects that set properties |
|
|
142
|
+
| `@populatedBy(callback:, operations:)` | field | Computed by a named callback on write |
|
|
143
|
+
| `@cardinality(max:)` | list relationship | Declared fan-out, for cost estimates |
|
|
144
|
+
| `@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) |
|
|
145
|
+
| `@fulltext(indexes: [{ name, fields, analyzer, queryName }])` | type | FULLTEXT indexes, each with a search root field |
|
|
146
|
+
| `@vector(dimensions:, similarity:, queryName:)` | `[Float!]` field | A VECTOR index and a similarity root field |
|
|
147
|
+
| `@plural(value:)` | interface, union | The root field's name |
|
|
136
148
|
|
|
137
149
|
API:
|
|
138
150
|
|
|
@@ -145,6 +157,8 @@ API:
|
|
|
145
157
|
| `@sortable` | field | Sort and paginate by this field (on a relationship property: `sort: [{ edge: { ... } }]`) |
|
|
146
158
|
| `@groupBy` | field | A grouping key of `<plural>Grouped(by:)` (needs `@query(aggregate: true)`) |
|
|
147
159
|
| `@limit(default:, max:)` | type, interface, union, list relationship | Page size bounds |
|
|
160
|
+
| `@size(max:)` | list argument of a `@cypher` field | The most items it takes (default `maxListArgument`) |
|
|
161
|
+
| `@range(min:, max:)` | Int / Float argument of a `@cypher` field | Bounds of its value (each item of a list); outside is `BAD_USER_INPUT` |
|
|
148
162
|
| `@relayId` | `@key` field | Adds a global `id` and the `Node` interface |
|
|
149
163
|
| `@authentication(operations:, jwt:)` | type, field | Needs an authenticated request, whose claims satisfy `jwt` |
|
|
150
164
|
| `@authorization(filter:, validate:)` | type (filter and validate), field (validate) | Row-level rules, compiled into statements |
|
|
@@ -216,7 +230,10 @@ string), `Date`, `Time`, `LocalTime`, `DateTime`, `LocalDateTime`,
|
|
|
216
230
|
out of the `OR`, and an `OR` or `NOT` with nothing left is left out
|
|
217
231
|
entirely, so an unset variable never widens a filter to every row; a
|
|
218
232
|
literal `OR: []` matches nothing. A relationship quantifier whose filter
|
|
219
|
-
is empty is left out too: ask `count: { gt: 0 }` for "has any".
|
|
233
|
+
is empty is left out too: ask `count: { gt: 0 }` for "has any". A single
|
|
234
|
+
relationship takes `<field>Exists: Boolean` for "is set" (`venueExists:
|
|
235
|
+
false`: festivals without a venue); as in every relationship filter, a
|
|
236
|
+
related node the reader may not see counts as none.
|
|
220
237
|
- `count` and `single` count related nodes once each, however many
|
|
221
238
|
relationships lead to them. `all` holds on an empty set, and a missing
|
|
222
239
|
property fails it.
|
|
@@ -414,6 +431,21 @@ mutation {
|
|
|
414
431
|
remain.
|
|
415
432
|
- **`@populatedBy(callback: "slug")`** computes a field with
|
|
416
433
|
`callbacks: { slug: ({ input, key, context, operation }) => … }`.
|
|
434
|
+
- **Supplying a computed field.** `@timestamp` and `@populatedBy` fields
|
|
435
|
+
are not in the inputs, unless `@settable(onCreate: true)` (or
|
|
436
|
+
`onUpdate: true`) says so explicitly and a field-level
|
|
437
|
+
`@authorization(validate:)` rule for that operation decides who may
|
|
438
|
+
supply the value. A supplied value is stored as given (a seed backfilling
|
|
439
|
+
history, an import keeping its dates); an omitted one is computed as
|
|
440
|
+
before. Without such a rule the combination is a model error, so a
|
|
441
|
+
computed field never becomes client-settable by accident: the schema's
|
|
442
|
+
bypass and `@authentication` are not rules here, and a bare `@settable`
|
|
443
|
+
(its defaults) changes nothing. Relationship properties cannot opt in.
|
|
444
|
+
|
|
445
|
+
```graphql
|
|
446
|
+
createdAt: DateTime! @timestamp(operations: [CREATE]) @settable(onCreate: true)
|
|
447
|
+
@authorization(validate: [{ operations: [CREATE], where: { jwt: { roles: { includes: "admin" } } } }])
|
|
448
|
+
```
|
|
417
449
|
|
|
418
450
|
Each mutation runs in one interactive transaction and checks, before it
|
|
419
451
|
commits:
|
|
@@ -422,19 +454,67 @@ commits:
|
|
|
422
454
|
- a single relationship stays single and a required one stays set, even
|
|
423
455
|
when written from the other side or when its target is deleted
|
|
424
456
|
(`CONSTRAINT_VIOLATION`);
|
|
457
|
+
- every `@uniqueTogether` holds (`CONSTRAINT_VIOLATION`, below);
|
|
425
458
|
- `@authorization` validate rules, type and field level (`FORBIDDEN`).
|
|
426
459
|
|
|
460
|
+
`@uniqueTogether` states a uniqueness the engine's per-property constraints
|
|
461
|
+
cannot: over relationship ends.
|
|
462
|
+
|
|
463
|
+
```graphql
|
|
464
|
+
type ConnectionRequest @node @uniqueTogether(fields: ["from", "to"]) {
|
|
465
|
+
key: ID! @key(generate: true)
|
|
466
|
+
from: Person! @relationship(type: "SENT", direction: IN)
|
|
467
|
+
to: Person! @relationship(type: "TO", direction: OUT)
|
|
468
|
+
}
|
|
469
|
+
type Conversation
|
|
470
|
+
@node
|
|
471
|
+
@uniqueTogether(fields: ["participants"], where: { kind: { eq: DIRECT } }) {
|
|
472
|
+
key: ID! @key
|
|
473
|
+
kind: ConversationKind!
|
|
474
|
+
participants: [Person!]! @relationship(type: "IN", direction: IN)
|
|
475
|
+
}
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
`fields` names scalar fields (compared by value), single relationships (by
|
|
479
|
+
the target's `@key`) and at most one list relationship (by the set of
|
|
480
|
+
target keys, in any order); `where` limits the nodes compared. A
|
|
481
|
+
combination with a null scalar, no target or an empty set is exempt, as a
|
|
482
|
+
null is in a unique index. Every generated mutation checks it after its
|
|
483
|
+
writes — creates, updates, upserts, nested creates, and connects,
|
|
484
|
+
disconnects and deletes from either side — for every caller, the bypass
|
|
485
|
+
included: it is a data invariant, not a rule. Each check seeks the written
|
|
486
|
+
nodes and compares only with nodes sharing their first relationship end
|
|
487
|
+
(or, with scalars only, their first scalar's value).
|
|
488
|
+
|
|
489
|
+
A `@cypher` mutation's write-set is unknown, so after its statement it
|
|
490
|
+
checks every `@uniqueTogether` type the statement may write: one whose
|
|
491
|
+
label, constrained relationship type or constrained scalar property
|
|
492
|
+
(`.prop`) the statement text names. That check compares every node of the
|
|
493
|
+
type (a scan, in the same transaction), and the model warns about each such
|
|
494
|
+
mutation (`check()` reports it under `warnings`); prefer a generated
|
|
495
|
+
mutation for a constrained type. Cypher of your own (`tx.execute()`, other
|
|
496
|
+
clients) is not checked.
|
|
497
|
+
|
|
427
498
|
Any failure rolls the whole mutation back. Atomicity is per root field:
|
|
428
499
|
in an operation with several root fields, each runs in its own
|
|
429
500
|
transaction, so a later failure leaves the earlier ones committed. Pass
|
|
430
501
|
`mutationTransaction: "operation"` to run every root field of a mutation
|
|
431
502
|
in one transaction through `execute()` (persisted operations included):
|
|
432
503
|
it commits only when the operation reports no error, and otherwise rolls
|
|
433
|
-
back and returns `data: null`.
|
|
434
|
-
|
|
504
|
+
back and returns `data: null`. GraphQL Yoga and other Envelop servers on
|
|
505
|
+
`getSchema()` get the same from `lora.envelopPlugin()`. With another
|
|
506
|
+
server calling graphql-js directly, put a `lora.begin()` transaction in
|
|
507
|
+
the context (see [Transactions](#transactions)); without one, each root
|
|
508
|
+
field commits on its own, and the first such mutation logs a warning. Engine
|
|
435
509
|
constraint errors come
|
|
436
|
-
back as `CONSTRAINT_VIOLATION` naming the type and field. A mutation
|
|
437
|
-
|
|
510
|
+
back as `CONSTRAINT_VIOLATION` naming the type and field. A mutation writes
|
|
511
|
+
at most `maxBatch` nodes (default 1000): created and updated nodes count
|
|
512
|
+
together (every `upsert` input, nested creates, each nested `update`
|
|
513
|
+
entry), and a delete reaches at most `maxBatch` nodes through
|
|
514
|
+
`onDelete: CASCADE`. Relationships written (connects, each key of a
|
|
515
|
+
`disconnect` list, nested `update` entries) count up to ten times
|
|
516
|
+
`maxBatch`. Going over is `LIMIT_EXCEEDED`, before the writes that
|
|
517
|
+
would exceed it. `@key` is not updatable.
|
|
438
518
|
`info` reports `nodesCreated`, `nodesUpdated`, `nodesDeleted`,
|
|
439
519
|
`relationshipsCreated` and `relationshipsDeleted`.
|
|
440
520
|
|
|
@@ -446,7 +526,11 @@ bounded like bulk deletes and following `onDelete`. `limit` defaults to
|
|
|
446
526
|
`maxBatch`; more matches is `LIMIT_EXCEEDED`.
|
|
447
527
|
`@relationship(nestedOperations: [CONNECT])` keeps only the listed nested
|
|
448
528
|
writes in the inputs, and `aggregate: false` removes the relationship's
|
|
449
|
-
aggregates and aggregate filter.
|
|
529
|
+
aggregates and aggregate filter. A nested `update: [{ key, edge, node }]`
|
|
530
|
+
(`UPDATE`) changes the connected node and the relationship's properties;
|
|
531
|
+
`UPDATE_EDGE` offers `update: [{ key, edge }]` alone, so an input can keep
|
|
532
|
+
"set my RSVP" without advertising "edit the festival" (it needs
|
|
533
|
+
relationship properties).
|
|
450
534
|
|
|
451
535
|
An input left with no field is left out, with what would take it: a
|
|
452
536
|
relationship without settable properties has no `edge` input, and a type
|
|
@@ -498,6 +582,18 @@ the library asks it for four times the page when a filter is present.
|
|
|
498
582
|
read back as `[Float!]`. Both indexes are part of S1 and created by
|
|
499
583
|
`assertSchema({ create: true })`; read rules apply to every result.
|
|
500
584
|
|
|
585
|
+
An index matches and ranks by stored values, whatever the read rules
|
|
586
|
+
say, so search never reaches a value the reader may not read:
|
|
587
|
+
|
|
588
|
+
- A `@fulltext` index over a field with a mask, a field-level READ
|
|
589
|
+
`validate` rule or `@authentication` for READ is a model error naming the
|
|
590
|
+
field: searching it would tell which rows contain a hidden word. Leave
|
|
591
|
+
the field out of the index (index a public copy if it must be found).
|
|
592
|
+
- A vector search, with `vector` or `to`, needs the vector field's
|
|
593
|
+
`@authentication`, and ranks only nodes passing its READ `validate`
|
|
594
|
+
rules (the anchor of `to` too); a node whose vector the reader may not
|
|
595
|
+
read is not a candidate. A mask on a `@vector` field is a model error.
|
|
596
|
+
|
|
501
597
|
Every search also has a connection: `searchDocsConnection(query:, where:,
|
|
502
598
|
first:, after:)` pages by keyset on (score, key). A vector index returns
|
|
503
599
|
its top candidates before any filter, so vector connections page within
|
|
@@ -533,23 +629,100 @@ type Mutation {
|
|
|
533
629
|
```
|
|
534
630
|
|
|
535
631
|
`this` is the parent node; arguments are `$parameters`; `$jwt` holds the
|
|
536
|
-
request's claims
|
|
537
|
-
top-level `RETURN` has one item,
|
|
538
|
-
|
|
539
|
-
items, set it.
|
|
632
|
+
request's claims; `$viewer` is the caller (below). `columnName` is
|
|
633
|
+
inferred when the statement's last top-level `RETURN` has one item,
|
|
634
|
+
`RETURN x` or `RETURN … AS x` (commas inside calls, lists, maps and
|
|
635
|
+
`CALL { }` do not count); with several items, set it.
|
|
540
636
|
Fields returning `@node` types are projected with the selection like any
|
|
541
637
|
other node, and read filters apply to them.
|
|
542
638
|
|
|
543
639
|
Statements are checked, not trusted:
|
|
544
640
|
|
|
545
|
-
- at startup: every `$parameter` must be an argument
|
|
546
|
-
object fields may not contain write clauses, and
|
|
547
|
-
`OPTIONAL MATCH` and statements that never use
|
|
548
|
-
(`lora.model.warnings`);
|
|
641
|
+
- at startup: every `$parameter` must be an argument, `$jwt` or
|
|
642
|
+
`$viewer`, Query and object fields may not contain write clauses, and
|
|
643
|
+
unused arguments, `OPTIONAL MATCH` and statements that never use
|
|
644
|
+
`this` are warnings (`lora.model.warnings`);
|
|
549
645
|
- in `check()`: every statement is planned with `explain()`, so a syntax
|
|
550
646
|
error, an unknown function or a missing column fails CI with the engine's
|
|
551
647
|
message, not the first request.
|
|
552
648
|
|
|
649
|
+
### The caller: `$viewer`
|
|
650
|
+
|
|
651
|
+
With a `@viewer` claim, `$viewer` is the caller's node's `@key`, in field
|
|
652
|
+
and mutation statements alike, so a statement names the viewer, not the
|
|
653
|
+
claim:
|
|
654
|
+
|
|
655
|
+
```graphql
|
|
656
|
+
type Mutation {
|
|
657
|
+
createPost(key: String!, caption: String!): Post
|
|
658
|
+
@authentication
|
|
659
|
+
@cypher(
|
|
660
|
+
statement: """
|
|
661
|
+
MATCH (a:Person) WHERE a.key = $viewer
|
|
662
|
+
CREATE (a)-[:POSTED]->(p:Post {key: $key, caption: $caption})
|
|
663
|
+
RETURN p
|
|
664
|
+
"""
|
|
665
|
+
)
|
|
666
|
+
}
|
|
667
|
+
```
|
|
668
|
+
|
|
669
|
+
When `@viewer` maps to the key, `$viewer` is the claim itself. When it
|
|
670
|
+
maps to another field (an opaque `subject`), it is read in the statement
|
|
671
|
+
with one seek by the claim (`head([(v:Person {subject: $claim}) |
|
|
672
|
+
v.key])`), so the statement above keeps working, and keeps seeking, when
|
|
673
|
+
`@viewer` moves from `Person.key` to `Person.subject`. `$viewer` is null
|
|
674
|
+
signed out, for a claim that is not a string or number, and for a token
|
|
675
|
+
naming no node. A statement using it without a `@viewer` claim is a
|
|
676
|
+
model error, and `viewer` is reserved as an argument name, like `jwt`.
|
|
677
|
+
|
|
678
|
+
### Guarding root fields
|
|
679
|
+
|
|
680
|
+
`@authentication` and `@authorization(validate:)` guard a Query or
|
|
681
|
+
Mutation `@cypher` field before its statement runs. With no node, a rule
|
|
682
|
+
tests claims (`jwt`) and the caller's own node (`viewer`); `node` and
|
|
683
|
+
filter rules are model errors, and `operations` / `when` do not matter:
|
|
684
|
+
each rule guards the call.
|
|
685
|
+
|
|
686
|
+
```graphql
|
|
687
|
+
type Mutation {
|
|
688
|
+
verify: Person
|
|
689
|
+
@authentication
|
|
690
|
+
@authorization(
|
|
691
|
+
validate: [{ where: { viewer: { verified: { eq: true } } } }]
|
|
692
|
+
)
|
|
693
|
+
@cypher(
|
|
694
|
+
statement: "MATCH (p:Person) WHERE p.key = $viewer SET p.verified = true RETURN p"
|
|
695
|
+
)
|
|
696
|
+
}
|
|
697
|
+
```
|
|
698
|
+
|
|
699
|
+
As anywhere, any passing rule grants, and the schema's bypass skips them.
|
|
700
|
+
Claim tests are decided in JavaScript: a refusal (`FORBIDDEN`, or
|
|
701
|
+
`UNAUTHENTICATED` without a token) runs no statement. A `viewer` test is
|
|
702
|
+
one seek, in the mutation's transaction, before the statement. Every root
|
|
703
|
+
`@cypher` field is in the access matrix, guarded or not (Mutation fields
|
|
704
|
+
under the operation `EXECUTE`).
|
|
705
|
+
|
|
706
|
+
### List and number arguments
|
|
707
|
+
|
|
708
|
+
The statement sees its arguments as sent, so list arguments are capped:
|
|
709
|
+
at most `@size(max:)` items, or `maxListArgument` (default 1000) without
|
|
710
|
+
it. More is `BAD_USER_INPUT` before any statement runs; each level of a
|
|
711
|
+
nested list counts.
|
|
712
|
+
|
|
713
|
+
```graphql
|
|
714
|
+
createPost(key: String!, hashtags: [String!] = [] @size(max: 30)): Post
|
|
715
|
+
```
|
|
716
|
+
|
|
717
|
+
Int and Float arguments take bounds the same way: `@range(min:, max:)`
|
|
718
|
+
(either or both, inclusive) checks the value, or each item of a list,
|
|
719
|
+
before the statement runs, and a default outside the bounds is a model
|
|
720
|
+
error. Null is not checked; that is the type's business.
|
|
721
|
+
|
|
722
|
+
```graphql
|
|
723
|
+
nearby(lat: Float! @range(min: -90, max: 90), km: Int = 10 @range(min: 1, max: 500)): [Festival!]!
|
|
724
|
+
```
|
|
725
|
+
|
|
553
726
|
### Filters, sorts and richer results
|
|
554
727
|
|
|
555
728
|
A scalar `@cypher` field of a `@node` type may take `@filterable` and
|
|
@@ -643,6 +816,19 @@ type Post
|
|
|
643
816
|
for keys built from the caller's key while the claim is an opaque
|
|
644
817
|
subject: `key: { endsWith: ":${viewer.key}" }`, `key: { eq:
|
|
645
818
|
"${viewer.key}" }`. It stands for one value, not inside a list.
|
|
819
|
+
- **The rule's own node.** `"${node.path}"` in a node part reads the node
|
|
820
|
+
the rule is about, so a rule can relate two of its paths: "the request's
|
|
821
|
+
recipient is in the conversation it gates" is
|
|
822
|
+
`{ node: { conversation: { participants: { some: { key: { eq:
|
|
823
|
+
"${node.to.key}" } } } } } }`. The path ends on a scalar field and steps
|
|
824
|
+
only through single relationships; the value is read in the statement
|
|
825
|
+
(the stored one, as rules see it) and stands for one value, not inside a
|
|
826
|
+
list. In a relationship field's rules, `${source.path}`, `${target.path}`
|
|
827
|
+
and `${edge.property}` read its ends and properties: "the payer is on the
|
|
828
|
+
expense's trip" is `{ source: { trip: { members: { some: { key: { eq:
|
|
829
|
+
"${target.key}" } } } } } }` on `Expense.paidBy`'s CONNECT. A named rule
|
|
830
|
+
that reads `${node.…}` can't stand inside another node's filter, where it
|
|
831
|
+
would read the outer node.
|
|
646
832
|
- A whole string starting with `$` must be a placeholder (`$jwt.<claim>`,
|
|
647
833
|
`$context.<path>`): a misspelt one (`"$jtw.sub"`) is a model error, not
|
|
648
834
|
a literal. Write a literal `$…` as `"\\$…"`.
|
|
@@ -670,6 +856,19 @@ type Post
|
|
|
670
856
|
own connects, so a filter that depends on the new relationship (a
|
|
671
857
|
request visible to its sender) does not block a nested create. Filter rules for `CREATE_RELATIONSHIP` and
|
|
672
858
|
`DELETE_RELATIONSHIP` guard both ends of connects and disconnects.
|
|
859
|
+
Update and delete targets (by key, bulk and nested) must pass the
|
|
860
|
+
type's `READ` filter as well as its `UPDATE` / `DELETE` filter: a key
|
|
861
|
+
the caller cannot read answers like a missing one (`null`,
|
|
862
|
+
`nodesDeleted: 0`), never `FORBIDDEN` from a `validate` rule.
|
|
863
|
+
- **Write errors never name a node the caller cannot read.** A delete
|
|
864
|
+
that would leave such a node without a required relationship fails
|
|
865
|
+
with `a Secret the caller can't read requires a Person (Secret.holder)`;
|
|
866
|
+
`onDelete: RESTRICT` held by such nodes fails with `Org "o" cannot be
|
|
867
|
+
deleted: Org.docs has onDelete: RESTRICT`. Replacing a single
|
|
868
|
+
relationship whose current target the caller cannot read is refused
|
|
869
|
+
with `FORBIDDEN` (`not allowed to replace F.genre`), and leaves it in
|
|
870
|
+
place: the caller cannot remove a relationship of a node they cannot
|
|
871
|
+
see.
|
|
673
872
|
- `validate` rules fail the request with `FORBIDDEN`: `BEFORE` an update or
|
|
674
873
|
delete, `AFTER` a create or update (rolling it back), and for `READ`: on
|
|
675
874
|
any returned node, and on cursors, counts and aggregates that cover one.
|
|
@@ -677,7 +876,12 @@ type Post
|
|
|
677
876
|
- Field-level `@authorization(validate:)` guards one field: reading it on a
|
|
678
877
|
row that fails is `FORBIDDEN`, filtering by it only matches rows that
|
|
679
878
|
pass, sorting or aggregating by it is refused, and writing it checks the
|
|
680
|
-
rule.
|
|
879
|
+
rule. On a row failing the rule (or where it is unknown, a null
|
|
880
|
+
property) the filter is false, never null, so `NOT` over it matches
|
|
881
|
+
every such row whatever the hidden value: a negated filter reveals no
|
|
882
|
+
more than the filter itself. The same holds for relationship and
|
|
883
|
+
`@cypher` fields with READ rules, `<field>Exists` and
|
|
884
|
+
`<field>Connection`. A write is what the input sets: a create that leaves the field out
|
|
681
885
|
is not checked against it, even when `@default` or `@populatedBy` fills
|
|
682
886
|
it, so a CREATE rule can keep a `verified: Boolean! @default(value: false)`
|
|
683
887
|
settable by admins only while anyone creates the node. Field-level
|
|
@@ -801,10 +1005,19 @@ type Post
|
|
|
801
1005
|
and relationship rules included; `@authentication` still applies.
|
|
802
1006
|
`bypass` tests claims only, so it is decided before the statement is
|
|
803
1007
|
built: an admin's statement carries no rule predicate. A type keeps its
|
|
804
|
-
rules for everyone with `@authorization(bypass: false)
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
1008
|
+
rules for everyone with `@authorization(bypass: false)`;
|
|
1009
|
+
`@authorization(bypass: true)` is the default made explicit, and quiets
|
|
1010
|
+
`check()`'s note that the bypass skips the type's field rules. `mutations` is
|
|
1011
|
+
the write rule (`CREATE`, `UPDATE`, `DELETE`) of every `@mutation` type,
|
|
1012
|
+
per operation: it guards each of those operations that none of the
|
|
1013
|
+
type's own rules covers. A `validate` rule covers the operations it
|
|
1014
|
+
lists; a `filter` rule covers `UPDATE` and `DELETE` when it lists them
|
|
1015
|
+
(by default it does), never `CREATE`, since there is no node to filter
|
|
1016
|
+
before it exists. So a type with only `@authorization(filter: [...])`
|
|
1017
|
+
keeps the default on `CREATE`, and one with a `validate` rule for
|
|
1018
|
+
`UPDATE` only keeps it on `CREATE` and `DELETE`. Where a type's rule
|
|
1019
|
+
covers an operation, it replaces the default for that operation, never
|
|
1020
|
+
merges with it. `check()` fails on a `@mutation` type whose
|
|
808
1021
|
writes nothing guards, unless it says `@authorization(public: [...])`.
|
|
809
1022
|
|
|
810
1023
|
Relationship and `@cypher` fields take field-level `@authorization`
|
|
@@ -812,14 +1025,33 @@ with READ validate rules: a row failing the rule reads the field as
|
|
|
812
1025
|
`FORBIDDEN`, and filtering through the field applies the rule too.
|
|
813
1026
|
|
|
814
1027
|
Relationship properties take field-level `@authentication` and
|
|
815
|
-
`@authorization(validate:)` for `READ`, `CREATE` and `UPDATE`.
|
|
816
|
-
|
|
817
|
-
rules test claims (`jwt`) only; a `node` part is a model error. Setting
|
|
1028
|
+
`@authorization(validate:)` for `READ`, `CREATE` and `UPDATE`. They test
|
|
1029
|
+
claims (`jwt`); a `node` part is a model error. Setting
|
|
818
1030
|
the property on connect or nested create checks `CREATE` for a new
|
|
819
1031
|
relationship and `UPDATE` for one that already exists; `update: { edge }`
|
|
820
1032
|
checks `UPDATE`. A request the READ rules refuse reads the property as
|
|
821
1033
|
`FORBIDDEN` and cannot filter, sort or aggregate by it.
|
|
822
1034
|
|
|
1035
|
+
When every relationship field using the properties type declares the
|
|
1036
|
+
same ends (owner and target `@node` types), READ rules may also test the
|
|
1037
|
+
relationship's `source`, `target` and `edge`, as a relationship rule
|
|
1038
|
+
does, and `viewer`. They decide per relationship: one edge can carry an
|
|
1039
|
+
`rsvp` every member reads and a marker only its member reads. A
|
|
1040
|
+
relationship failing them reads that property as `FORBIDDEN` (the
|
|
1041
|
+
others still read), and nothing may filter, sort or aggregate by it.
|
|
1042
|
+
Writes stay claims-only: such a part in a `CREATE` or `UPDATE` rule, or
|
|
1043
|
+
on a type whose fields disagree on the ends, is a model error.
|
|
1044
|
+
|
|
1045
|
+
```graphql
|
|
1046
|
+
type Membership @relationshipProperties {
|
|
1047
|
+
rsvp: String
|
|
1048
|
+
lastReadAt: String
|
|
1049
|
+
@authorization(
|
|
1050
|
+
validate: [{ operations: [READ], where: { target: { isViewer: true } } }]
|
|
1051
|
+
)
|
|
1052
|
+
}
|
|
1053
|
+
```
|
|
1054
|
+
|
|
823
1055
|
### Rules on relationships
|
|
824
1056
|
|
|
825
1057
|
A relationship field also takes validate rules for the relationship's
|
|
@@ -943,9 +1175,33 @@ by key starts from that node and expands, instead of scanning the label.
|
|
|
943
1175
|
Every operation has a cost estimate (rows touched, multiplying page sizes
|
|
944
1176
|
through nested lists, capped by `@cardinality`), summed across its root
|
|
945
1177
|
fields, and an operation over `maxCost` (default 50 000) fails with
|
|
946
|
-
`COST_EXCEEDED` before it runs. `lora.analyze()`
|
|
947
|
-
relationship degrees
|
|
948
|
-
|
|
1178
|
+
`COST_EXCEEDED` before it runs. `lora.analyze()` counts nodes and
|
|
1179
|
+
measures relationship degrees: a nested list is then estimated at its
|
|
1180
|
+
relationship's maximum degree, measured over every node and capped by the
|
|
1181
|
+
page size, instead of the page size alone. Not a percentile: the caller
|
|
1182
|
+
picks the parents (by key, or by following a hub), so any lower bound is
|
|
1183
|
+
one it can exceed at will.
|
|
1184
|
+
|
|
1185
|
+
Filters are charged the rows they examine, not only the rows they return:
|
|
1186
|
+
|
|
1187
|
+
- a root filter no index answers (`contains`, `endsWith`,
|
|
1188
|
+
`caseInsensitive`, `NOT`, `OR`, a computed field) costs the label's
|
|
1189
|
+
node count, or the page size without statistics; an equality, range,
|
|
1190
|
+
prefix or point predicate seeks and costs nothing extra;
|
|
1191
|
+
- each relationship a filter follows (`some`, `none`, `all`, `single`,
|
|
1192
|
+
`count`, `aggregate`, connection filters, single relationships) costs
|
|
1193
|
+
the related nodes it visits per candidate, multiplied per level: the
|
|
1194
|
+
mean degree over a scanned label, the maximum degree from parents the
|
|
1195
|
+
caller picked (by key, or the parents of a nested list), the default
|
|
1196
|
+
page size without statistics;
|
|
1197
|
+
- `totalCount` and aggregates read every match: the label's node count
|
|
1198
|
+
when no key narrows them.
|
|
1199
|
+
|
|
1200
|
+
`maxFilterDepth` (default 2) refuses a `where` nesting more relationship
|
|
1201
|
+
levels with `BAD_USER_INPUT`, counted through single relationships,
|
|
1202
|
+
`<field>Exists` and connection filters too. Without `analyze()` a two-level
|
|
1203
|
+
filter over a large label still passes, priced at the page size: run it
|
|
1204
|
+
in production so estimates use real counts.
|
|
949
1205
|
|
|
950
1206
|
**S7: one SDL, two diffs.** `diffSchemas(before, after)` reports the
|
|
951
1207
|
database statements a change needs (index and constraint changes, relabels,
|
|
@@ -978,6 +1234,33 @@ read carries a read-set (labels and relationship types);
|
|
|
978
1234
|
that falls `maxQueuedChanges` (default 1000) behind is ended with an error
|
|
979
1235
|
rather than buffering without bound.
|
|
980
1236
|
|
|
1237
|
+
`execute({ ..., readSet: true })` returns the operation's read-set as a
|
|
1238
|
+
non-enumerable `readSet` on the result (it never reaches the client), so
|
|
1239
|
+
a response cache can drop only the entries a write may have changed
|
|
1240
|
+
instead of clearing everything:
|
|
1241
|
+
|
|
1242
|
+
```ts
|
|
1243
|
+
const cache = new Map<string, LoraExecutionResult>();
|
|
1244
|
+
|
|
1245
|
+
async function cachedExecute(key: string, args: ExecuteArgs) {
|
|
1246
|
+
const hit = cache.get(key);
|
|
1247
|
+
if (hit) return hit;
|
|
1248
|
+
const result = await lora.execute({ ...args, readSet: true });
|
|
1249
|
+
if (!result.errors) cache.set(key, result);
|
|
1250
|
+
return result;
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
lora.onWrite((change) => {
|
|
1254
|
+
for (const [key, result] of cache)
|
|
1255
|
+
if (lora.affects(result.readSet!, change)) cache.delete(key);
|
|
1256
|
+
});
|
|
1257
|
+
```
|
|
1258
|
+
|
|
1259
|
+
A read-set is label-level, and over-approximates: a `@cypher` statement
|
|
1260
|
+
can read anything, so a read that ran one (and any mutation) carries
|
|
1261
|
+
`opaque: true`, which every change affects. `@customResolver` fields
|
|
1262
|
+
are not seen; key the cache so they do not need to be.
|
|
1263
|
+
|
|
981
1264
|
## Subscriptions
|
|
982
1265
|
|
|
983
1266
|
```graphql
|
|
@@ -1002,18 +1285,43 @@ writes made elsewhere are not seen). A node that gained or lost a
|
|
|
1002
1285
|
relationship is an `UPDATE`. `where` tests the node as it is after the
|
|
1003
1286
|
write; `node` is read when the event is delivered, through the normal read
|
|
1004
1287
|
path. Events for nodes the subscriber cannot read are dropped (checked in
|
|
1005
|
-
one query per write, not per event)
|
|
1006
|
-
|
|
1007
|
-
`where
|
|
1288
|
+
one query per write, not per event). A deletion cannot be checked after
|
|
1289
|
+
the fact: it goes only to subscribers following that `key` without a
|
|
1290
|
+
`where`, and when read rules apply, only to those who could read the node
|
|
1291
|
+
as it was, checked inside the deleting transaction (one statement per
|
|
1292
|
+
distinct check; claims that settle the rules need none). Under
|
|
1293
|
+
`changeFeed` there is no such transaction, so a deletion reaches only
|
|
1294
|
+
followers whose claims settle the rules. `@authentication(operations: [SUBSCRIBE])` guards the subscription
|
|
1008
1295
|
itself. Pass a `signal` in the context to end the stream with the request.
|
|
1009
1296
|
|
|
1297
|
+
A subscription's `where` runs on every change, so it is bounded when the
|
|
1298
|
+
subscription starts: relationship filters may nest
|
|
1299
|
+
`maxSubscriptionFilterDepth` (default 1) deep, and its estimated rows per
|
|
1300
|
+
changed node (each relationship filter's degree, from `analyze()`, else
|
|
1301
|
+
its cardinality, else a page of the target) are charged against
|
|
1302
|
+
`maxCost` / `budget` (`COST_EXCEEDED`). One scope, by default the context
|
|
1303
|
+
object (one per graphql-ws connection when the server reuses it; set
|
|
1304
|
+
`subscriptionScope` to count per connection or user otherwise), holds at
|
|
1305
|
+
most `maxSubscriptions` (default 100) live subscriptions; one more is
|
|
1306
|
+
`LIMIT_EXCEEDED`. The statements that check a change run under
|
|
1307
|
+
`subscriptionTimeoutMs` (default 2000, or `timeoutMs` when lower); one
|
|
1308
|
+
that fails or times out ends that subscription with the error.
|
|
1309
|
+
|
|
1010
1310
|
Every event has a `timestamp` (when the write was committed). With
|
|
1011
1311
|
`@subscription(relationships: true)` a type also gets `CONNECT` and
|
|
1012
1312
|
`DISCONNECT` events, one per relationship, with `relationship { field type
|
|
1013
|
-
relatedType relatedKey }`.
|
|
1313
|
+
relatedType relatedKey }`. Such an event is sent only when the
|
|
1314
|
+
subscriber may read the related node (its type's READ rules) through the
|
|
1315
|
+
declaring field (that field's READ rules and `@authentication`), checked
|
|
1316
|
+
after the write; otherwise it is dropped, not sent with the key left out.
|
|
1317
|
+
A related node that is gone (a DISCONNECT by deletion) cannot be checked,
|
|
1318
|
+
so its event reaches only subscribers whose claims settle those rules. With `@subscription(previousState: true)`,
|
|
1014
1319
|
`UPDATE` and `DELETE` events carry `previousState`: the stored values
|
|
1015
|
-
before the write (readable scalar fields without field-level
|
|
1016
|
-
the cost of one read per write.
|
|
1320
|
+
before the write (readable scalar fields without field-level validate
|
|
1321
|
+
rules), at the cost of one read per write. Field-level `@authentication`
|
|
1322
|
+
applies to it as to any read, and masks are decided by the subscriber's
|
|
1323
|
+
claims: a mask whose `unless` depends on the node reads as its `value`,
|
|
1324
|
+
since the node as it was cannot be tested after the write. Subscribers whose checks compile to the
|
|
1017
1325
|
same statement share it: twenty subscribers with the same `where` and
|
|
1018
1326
|
claims cost one visibility query and one node read per write.
|
|
1019
1327
|
|
|
@@ -1053,7 +1361,15 @@ try {
|
|
|
1053
1361
|
With `transaction` in the context, every operation of the request runs in
|
|
1054
1362
|
it, next to the application's own `tx.execute(cypher)`: they commit or roll
|
|
1055
1363
|
back together, reads see the transaction's writes, and change events wait
|
|
1056
|
-
for the commit. A failed mutation rolls the transaction back.
|
|
1364
|
+
for the commit. A failed mutation rolls the transaction back. `@cypher`
|
|
1365
|
+
mutations run in it too, like generated ones: their statement shares the
|
|
1366
|
+
transaction, and their (broad) change event waits for the commit.
|
|
1367
|
+
|
|
1368
|
+
A write transaction holds LoraDB's writer lock until it ends, and other
|
|
1369
|
+
writes wait for it. That wait is bounded by `timeoutMs` (and a `signal`
|
|
1370
|
+
in the context): a mutation that cannot get the lock in time fails with
|
|
1371
|
+
`DATABASE_ERROR` instead of queueing forever. Keep `lora.begin()`
|
|
1372
|
+
transactions short.
|
|
1057
1373
|
|
|
1058
1374
|
## CLI
|
|
1059
1375
|
|
|
@@ -1063,7 +1379,8 @@ lora-graphql requirements schema.graphql --ddl # constraints and indexes, as DDL
|
|
|
1063
1379
|
lora-graphql check schema.graphql --operations src/operations
|
|
1064
1380
|
lora-graphql compile schema.graphql --operations src/operations --out generated
|
|
1065
1381
|
lora-graphql analyze schema.graphql --database ./data # statistics JSON
|
|
1066
|
-
lora-graphql diff old.graphql new.graphql # exit 1 on breaking changes
|
|
1382
|
+
lora-graphql diff old.graphql new.graphql # exit 1 on breaking or destructive changes
|
|
1383
|
+
lora-graphql diff --base origin/main schema/ # the same, against a git ref
|
|
1067
1384
|
lora-graphql directives # directive SDL for editors
|
|
1068
1385
|
```
|
|
1069
1386
|
|
|
@@ -1084,17 +1401,67 @@ files, and exits non-zero on any finding. It is the CI gate. Options:
|
|
|
1084
1401
|
- `--database dir [--name app]`: check an existing database as it is, and
|
|
1085
1402
|
report indexes it has that the API does not use.
|
|
1086
1403
|
|
|
1087
|
-
`
|
|
1088
|
-
|
|
1404
|
+
`diff` exits 1 when the change breaks clients or needs a destructive
|
|
1405
|
+
database statement (a dropped constraint or index, a relabel, a moved
|
|
1406
|
+
property); `--allow-breaking` accepts both, e.g. when a PR is labelled
|
|
1407
|
+
for it. `--base <ref>` takes the schema as files or directories (searched
|
|
1408
|
+
recursively for `.graphql` / `.gql`), concatenates each side in path
|
|
1409
|
+
order, and compares the files at that git ref with the working tree, so
|
|
1410
|
+
a schema split over many files needs no script. A ref that has none of
|
|
1411
|
+
the paths has nothing to compare (exit 0); a ref that is not a commit is
|
|
1412
|
+
an error.
|
|
1413
|
+
|
|
1414
|
+
`access` prints who may do what: for every type, guarded field (READ,
|
|
1415
|
+
and CREATE / UPDATE where field-level rules guard the write), rule on a
|
|
1416
|
+
relationship property (READ, CREATE, UPDATE) and root `@cypher` field,
|
|
1417
|
+
each operation as each kind of caller (anonymous, authenticated, and each role
|
|
1089
1418
|
the rules test, such as `roles:admin`), with the verdict (`allowed`,
|
|
1090
1419
|
`filtered`, `validated`, `masked`, `denied`, `unauthenticated`) and the
|
|
1091
|
-
rules that decide it.
|
|
1420
|
+
rules that decide it. A `@key(scope: VIEWER)` type's CREATE reads
|
|
1421
|
+
`unauthenticated` for anonymous callers and `validated` by `key scope`
|
|
1422
|
+
for the rest.
|
|
1423
|
+
|
|
1424
|
+
`lora.operationAccess(document | persistedId, operationName?)` answers
|
|
1425
|
+
the same question for one operation: each root field (fragments
|
|
1426
|
+
followed) with its type, the operations it needs (an upsert needs
|
|
1427
|
+
`CREATE` and `UPDATE`) and the verdict per caller, plus the most
|
|
1428
|
+
restrictive verdict per caller over all root fields. A field over
|
|
1429
|
+
several types (`node`, a union) reports the most restrictive member.
|
|
1430
|
+
Root fields only: nested selections answer to their own types' rules.
|
|
1431
|
+
To keep admin-only operations out of client bundles:
|
|
1432
|
+
|
|
1433
|
+
````ts
|
|
1434
|
+
const { verdicts } = lora.operationAccess(source);
|
|
1435
|
+
const adminOnly = ["anonymous", "authenticated"].every((p) =>
|
|
1436
|
+
["denied", "unauthenticated"].includes(verdicts[p]!),
|
|
1437
|
+
);
|
|
1438
|
+
``` `lora.accessMatrix()` returns the same list; its
|
|
1092
1439
|
order is stable, so a snapshot in CI turns access changes into diffs.
|
|
1093
1440
|
|
|
1094
|
-
`check` lints authorization too:
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1441
|
+
`check` lints authorization too:
|
|
1442
|
+
|
|
1443
|
+
- a filter rule every signed-in caller passes;
|
|
1444
|
+
- a rule whose default `requireAuthentication` refuses anonymous callers
|
|
1445
|
+
a branch that needs no claims;
|
|
1446
|
+
- field rules the schema's bypass skips, on a type that says neither
|
|
1447
|
+
`@authorization(bypass: false)` nor `bypass: true`;
|
|
1448
|
+
- an UPDATE rule testing a single relationship the update input can
|
|
1449
|
+
re-point (`connect`, `disconnect`, `create`): the rule sees the node
|
|
1450
|
+
before (and, validated `AFTER`, after) the write, so a caller passing it
|
|
1451
|
+
on two targets moves the node. Tests that name the caller's own node
|
|
1452
|
+
(`isViewer`) are skipped. Fix:
|
|
1453
|
+
`@settable(onCreate: true, onUpdate: false)`;
|
|
1454
|
+
- a CREATE validate rule testing a field UPDATE can change while no UPDATE
|
|
1455
|
+
rule (or field-level UPDATE rule) tests it: created as the rule demands,
|
|
1456
|
+
then changed;
|
|
1457
|
+
- a validate rule (not `READ` or `CREATE`) or relationship rule testing a
|
|
1458
|
+
masked field the claims do not settle: rules see the stored value, so
|
|
1459
|
+
success versus `FORBIDDEN` tells the caller what the mask hides;
|
|
1460
|
+
- nested `create`, `update` or `delete` into a type whose rules for that
|
|
1461
|
+
write refuse every signed-in caller without a role (an admin-only type,
|
|
1462
|
+
or the `@authorizationDefaults(mutations:)` default), offered by an input
|
|
1463
|
+
that caller can use: surface only admins can use. Fix:
|
|
1464
|
+
`nestedOperations: [CONNECT, DISCONNECT]`.
|
|
1098
1465
|
|
|
1099
1466
|
A `@mutation` type with a generated write no rule guards (no
|
|
1100
1467
|
`@authentication` or `@authorization` rule for it, and no
|
|
@@ -1145,7 +1512,7 @@ await expectSeeks(
|
|
|
1145
1512
|
`{ festivals(where: { name: { eq: "Sunland" } }) { key } }`,
|
|
1146
1513
|
);
|
|
1147
1514
|
t.close();
|
|
1148
|
-
|
|
1515
|
+
````
|
|
1149
1516
|
|
|
1150
1517
|
`createTestLoraGraphQL` builds an in-memory database with the schema
|
|
1151
1518
|
asserted, runs the seed, and records every statement in `t.statements`.
|
|
@@ -1188,10 +1555,17 @@ binding has, so the WASM binding serves reads.
|
|
|
1188
1555
|
|
|
1189
1556
|
| Option | Default | Meaning |
|
|
1190
1557
|
| ---------------------------- | ------------- | --------------------------------------------------- |
|
|
1191
|
-
| `timeoutMs` | 10 000 | Per statement; a `signal`
|
|
1558
|
+
| `timeoutMs` | 10 000 | Per statement and lock wait; a `signal` cancels |
|
|
1559
|
+
| `operationTimeoutMs` | 2 × timeout | All root fields of one query; then `TIMEOUT` |
|
|
1560
|
+
| `maxConcurrentStatements` | 2 | Statements one operation runs at once |
|
|
1192
1561
|
| `maxCost` | 50 000 | Estimated rows per operation |
|
|
1193
|
-
| `maxBatch` | 1000 | Nodes
|
|
1562
|
+
| `maxBatch` | 1000 | Nodes written per mutation; bulk `limit` by default |
|
|
1563
|
+
| `compileCacheBytes` | 64 MiB | Compile cache and document cache, each |
|
|
1194
1564
|
| `maxQueuedChanges` | 1000 | How far a change consumer may fall behind |
|
|
1565
|
+
| `maxSubscriptions` | 100 | Live subscriptions per `subscriptionScope` |
|
|
1566
|
+
| `subscriptionScope` | the context | What `maxSubscriptions` counts per |
|
|
1567
|
+
| `maxSubscriptionFilterDepth` | 1 | Relationship filter nesting in a subscription where |
|
|
1568
|
+
| `subscriptionTimeoutMs` | 2000 | Per statement checking a change for a subscriber |
|
|
1195
1569
|
| `defaultLimit` / `maxLimit` | 25 / 100 | Global page sizes; `@limit` may only lower `max` |
|
|
1196
1570
|
| `callbacks` | | Named callbacks for `@populatedBy` |
|
|
1197
1571
|
| `jwt` | `context.jwt` | Where the claims are |
|
|
@@ -1204,16 +1578,34 @@ binding has, so the WASM binding serves reads.
|
|
|
1204
1578
|
| `budget` | | Cost limit per request, from the context |
|
|
1205
1579
|
| `onCost` | | Each root field's estimate, total and limit |
|
|
1206
1580
|
| `onStatementEnd` | | Duration, rows and error of every statement call |
|
|
1581
|
+
| `timing` | false | `extensions.timing` in `execute()` results |
|
|
1207
1582
|
| `tracer` / `traceStatements` | | OpenTelemetry-style spans; Cypher text on request |
|
|
1208
1583
|
| `metrics` | | Counters and histograms (see below) |
|
|
1209
1584
|
|
|
1210
1585
|
Errors carry `extensions.code`: `BAD_USER_INPUT`, `INVALID_CURSOR`,
|
|
1211
|
-
`LIMIT_EXCEEDED`, `COST_EXCEEDED`, `
|
|
1586
|
+
`LIMIT_EXCEEDED`, `COST_EXCEEDED`, `TIMEOUT` (with `operationTimeoutMs`),
|
|
1587
|
+
`UNAUTHENTICATED`, `FORBIDDEN`,
|
|
1212
1588
|
`NOT_FOUND`, `CONSTRAINT_VIOLATION` (with `type` and `field`),
|
|
1213
1589
|
`DATABASE_ERROR` (with an `id`, also given to `onError`),
|
|
1214
1590
|
`PERSISTED_QUERY_ONLY` (`execute()` or `subscribe()` got a document under
|
|
1215
1591
|
`persistedOnly`) and `WRONG_OPERATION_TYPE` (`execute()` got a
|
|
1216
|
-
subscription, or `subscribe()` a query or mutation).
|
|
1592
|
+
subscription, or `subscribe()` a query or mutation). Identical errors at
|
|
1593
|
+
paths that differ only in list indices (an `@authentication` field read
|
|
1594
|
+
anonymously on every row of a page) come back once, at the first path,
|
|
1595
|
+
with `extensions.count` and `extensions.pathPattern` (indices as `"*"`);
|
|
1596
|
+
`execute()` and `lora.envelopPlugin()` both do this. The same list of
|
|
1597
|
+
codes is exported as `LORA_GRAPHQL_ERROR_CODES`, and
|
|
1598
|
+
`isLoraGraphQLError(err)` tells a library error (also a serialized one)
|
|
1599
|
+
from anything else:
|
|
1600
|
+
|
|
1601
|
+
```ts
|
|
1602
|
+
import { isLoraGraphQLError } from "@loradb/lora-graphql";
|
|
1603
|
+
|
|
1604
|
+
for (const err of result.errors ?? [])
|
|
1605
|
+
if (isLoraGraphQLError(err) && err.extensions.code === "FORBIDDEN") deny();
|
|
1606
|
+
```
|
|
1607
|
+
|
|
1608
|
+
An invalid SDL
|
|
1217
1609
|
throws one `ModelError` listing every problem, each located by type and
|
|
1218
1610
|
field.
|
|
1219
1611
|
|
|
@@ -1224,20 +1616,43 @@ the document before that. `execute()`, `subscribe()` and `persist()` apply them,
|
|
|
1224
1616
|
`lora.validationRules()` / `lora.envelopPlugin()` bring them to any other
|
|
1225
1617
|
server (GraphQL Yoga takes the plugin as is):
|
|
1226
1618
|
|
|
1227
|
-
| Guard | Default | Limit
|
|
1228
|
-
| ----------------------- | ---------- |
|
|
1229
|
-
| `maxDepth` | 12 | Field nesting, through fragments
|
|
1230
|
-
| `maxIntrospectionDepth` | 20 | Nesting under `__schema` / `__type`
|
|
1231
|
-
| `maxAliases` | 30 | Aliased fields per document
|
|
1232
|
-
| `maxRootFields` | 20 | Root fields per operation
|
|
1233
|
-
| `maxTokens` | 5000 | Lexer tokens per document, while parsing
|
|
1234
|
-
| `
|
|
1619
|
+
| Guard | Default | Limit |
|
|
1620
|
+
| ----------------------- | ---------- | ---------------------------------------------------------------------- |
|
|
1621
|
+
| `maxDepth` | 12 | Field nesting, through fragments |
|
|
1622
|
+
| `maxIntrospectionDepth` | 20 | Nesting under `__schema` / `__type` |
|
|
1623
|
+
| `maxAliases` | 30 | Aliased fields per document |
|
|
1624
|
+
| `maxRootFields` | 20 | Root fields per operation |
|
|
1625
|
+
| `maxTokens` | 5000 | Lexer tokens per document, while parsing |
|
|
1626
|
+
| `maxListArgument` | 1000 | Items per list argument of a `@cypher` field (`@size(max:)` overrides) |
|
|
1627
|
+
| `maxFilterDepth` | 2 | Relationship levels one `where` nests |
|
|
1628
|
+
| `maxListFilter` | 1000 | Items in an `in` filter operand |
|
|
1629
|
+
| `maxStringFilter` | 10 000 | Characters in a string filter operand (`eq`, `contains`, `in` items…) |
|
|
1630
|
+
| `introspection` | production | Off when `NODE_ENV` is `production` |
|
|
1631
|
+
|
|
1632
|
+
One query also has a time budget and a share of the engine: its root
|
|
1633
|
+
fields (aliases included) run at most `maxConcurrentStatements` (default 2) statements at once, so one request cannot take every libuv worker
|
|
1634
|
+
from the others, and after `operationTimeoutMs` (default twice
|
|
1635
|
+
`timeoutMs`, 20 s) its statements are aborted and its unfinished fields
|
|
1636
|
+
fail with `TIMEOUT`. Each statement also gets no more than the time the
|
|
1637
|
+
operation has left. Mutations keep `timeoutMs` per statement, and
|
|
1638
|
+
subscriptions are not bounded by the operation budget.
|
|
1235
1639
|
|
|
1236
1640
|
With `NODE_ENV=production`, database errors are masked and introspection
|
|
1237
1641
|
is off unless configured otherwise. See
|
|
1238
1642
|
[the threat model](../../docs/design/graphql-threat-model.md) for what
|
|
1239
1643
|
the library trusts and where each check runs.
|
|
1240
1644
|
|
|
1645
|
+
The plugin also checks, once, that the server runs the library's own
|
|
1646
|
+
`graphql` copy. With two copies (a nested `node_modules/graphql`, a
|
|
1647
|
+
dual CJS/ESM load) the library's errors are not instances of the
|
|
1648
|
+
server's `GraphQLError`, so Yoga masks every `FORBIDDEN` or
|
|
1649
|
+
`BAD_USER_INPUT` as "Unexpected error". The first schema or validation
|
|
1650
|
+
from a foreign copy logs a `console.error` naming the problem and the
|
|
1651
|
+
fix: dedupe `graphql` (`npm dedupe`, or `pnpm.overrides` / yarn
|
|
1652
|
+
`resolutions`) until `npm ls graphql` shows one copy. The standalone
|
|
1653
|
+
`envelopPlugin(guards, onRealmMismatch)` takes a reporter in place of
|
|
1654
|
+
`console.error`.
|
|
1655
|
+
|
|
1241
1656
|
### Observability
|
|
1242
1657
|
|
|
1243
1658
|
`onStatement` fires before a statement runs; `onStatementEnd` after, with
|
|
@@ -1245,6 +1660,28 @@ the library trusts and where each check runs.
|
|
|
1245
1660
|
name and the persisted id. Reads report their batch of statements in one
|
|
1246
1661
|
event; mutations report each statement.
|
|
1247
1662
|
|
|
1663
|
+
With `timing`, `execute()` also returns the request's timings to the
|
|
1664
|
+
client:
|
|
1665
|
+
|
|
1666
|
+
```json
|
|
1667
|
+
"extensions": {
|
|
1668
|
+
"cost": 51,
|
|
1669
|
+
"timing": {
|
|
1670
|
+
"totalMs": 4.21,
|
|
1671
|
+
"databaseMs": 3.05,
|
|
1672
|
+
"fields": { "all": { "totalMs": 2.9, "databaseMs": 2.4 }, "one": { "totalMs": 0.8, "databaseMs": 0.65 } }
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
```
|
|
1676
|
+
|
|
1677
|
+
`totalMs` covers the whole `execute()` call (parse, validation,
|
|
1678
|
+
execution); `databaseMs` the statements LoraDB ran; `fields` both per root
|
|
1679
|
+
field, by response key. Pass `true`, or a function of the context to
|
|
1680
|
+
decide per request (`timing: (ctx) => ctx.jwt?.roles?.includes("admin")`).
|
|
1681
|
+
It is off by default: timings sent to clients can act as a timing side
|
|
1682
|
+
channel. Servers calling graphql-js on `getSchema()` directly use
|
|
1683
|
+
`onStatementEnd` or `tracer` instead.
|
|
1684
|
+
|
|
1248
1685
|
Pass an OpenTelemetry tracer (`trace.getTracer("lora-graphql")`) as
|
|
1249
1686
|
`tracer` and each root field gets a `lora.graphql.field` span holding a
|
|
1250
1687
|
`lora.cypher` span per statement call, with `db.system`,
|
|
@@ -1257,7 +1694,9 @@ object with `counter(name, value, attributes)` and
|
|
|
1257
1694
|
|
|
1258
1695
|
`budget(context)` sets the cost limit per request (a plan, a user), and
|
|
1259
1696
|
`onCost` sees every estimate. `execute()` also returns the operation's
|
|
1260
|
-
estimate as `extensions.cost`, so clients can tune their queries.
|
|
1697
|
+
estimate as `extensions.cost`, so clients can tune their queries. The
|
|
1698
|
+
limit holds per execution: a context reused across requests (one per
|
|
1699
|
+
graphql-ws connection) does not add their costs up.
|
|
1261
1700
|
|
|
1262
1701
|
### Compile cache
|
|
1263
1702
|
|
|
@@ -1269,6 +1708,13 @@ drops from 0.14 ms to 0.06 ms end to end. Servers that parse every request
|
|
|
1269
1708
|
themselves get new field nodes each time and do not benefit; use
|
|
1270
1709
|
`execute()` or persisted operations.
|
|
1271
1710
|
|
|
1711
|
+
Both caches are bounded by size as well as by count: `compileCacheBytes`
|
|
1712
|
+
(default 64 MiB, approximate) caps the compile cache and, separately, the
|
|
1713
|
+
parsed documents (about 100 bytes per source character), evicting the
|
|
1714
|
+
oldest first. A request whose variables for the field exceed 16 KiB (a
|
|
1715
|
+
long `in:` list, an embedding vector) is compiled but not cached, and an
|
|
1716
|
+
entry does not keep its document alive once the document cache drops it.
|
|
1717
|
+
|
|
1272
1718
|
`check({ rowBudget })` flags statements whose largest engine row estimate
|
|
1273
1719
|
exceeds the budget; every plan report carries `estimatedRows` either way.
|
|
1274
1720
|
|