@loradb/lora-graphql 0.21.0 → 0.22.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
- | `@index(kind: RANGE \| TEXT \| POINT)` | field | An explicit index, usually inferred |
119
- | `@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 |
120
- | `@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 |
121
- | `@declareRelationship` | interface field | Every implementation declares this relationship (type and direction may differ); select it on the interface |
122
- | `@relationshipProperties` | type | Properties on a relationship type |
123
- | `@alias(property:)` | field | API name differs from the stored property |
124
- | `@private` | field | Stored, never exposed |
125
- | `@readonly` | field | Exposed, never client-settable; on a relationship, absent from create and update inputs |
126
- | `@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 |
127
- | `@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` |
128
- | `@default(value:)` | field | Stored on create when the input omits it; on a relationship property, when the relationship is created |
129
- | `@timestamp(operations: [CREATE, UPDATE])` | field | Set to the current time; never client-settable. On a relationship property: CREATE when the relationship is created, UPDATE on edge updates and re-connects that set properties |
130
- | `@populatedBy(callback:, operations:)` | field | Computed by a named callback on write |
131
- | `@cardinality(max:)` | list relationship | Declared fan-out, for cost estimates |
132
- | `@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) |
133
- | `@fulltext(indexes: [{ name, fields, analyzer, queryName }])` | type | FULLTEXT indexes, each with a search root field |
134
- | `@vector(dimensions:, similarity:, queryName:)` | `[Float!]` field | A VECTOR index and a similarity root field |
135
- | `@plural(value:)` | interface, union | The root field's name |
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`. With another server, put a `lora.begin()`
434
- transaction in the context (see [Transactions](#transactions)). Engine
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 creates
437
- or deletes at most `maxBatch` nodes (default 1000). `@key` is not updatable.
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. `columnName` is inferred when the statement's last
537
- top-level `RETURN` has one item, `RETURN x` or `RETURN … AS x` (commas
538
- inside calls, lists, maps and `CALL { }` do not count); with several
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 or `$jwt`, Query and
546
- object fields may not contain write clauses, and unused arguments,
547
- `OPTIONAL MATCH` and statements that never use `this` are warnings
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. A write is what the input sets: a create that leaves the field out
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)`. `mutations` is
805
- the write rule (`CREATE`, `UPDATE`, `DELETE`) of every `@mutation` type
806
- that declares no rule for those operations; a type's own rules replace
807
- it, never merge with it. `check()` fails on a `@mutation` type whose
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`. One
816
- `@relationshipProperties` type can serve fields on both ends, so these
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()` samples node counts and
947
- relationship degrees so estimates use the measured p99 degree instead of
948
- the page size.
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), and deletions, which cannot be checked
1006
- after the fact, go only to subscribers following that `key` without a
1007
- `where`. `@authentication(operations: [SUBSCRIBE])` guards the subscription
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 }`. With `@subscription(previousState: true)`,
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 rules), at
1016
- the cost of one read per write. Subscribers whose checks compile to the
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
- `access` prints who may do what: for every type and guarded field, each
1088
- operation as each kind of caller (anonymous, authenticated, and each role
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. `lora.accessMatrix()` returns the same list; its
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: a filter rule every signed-in caller
1095
- passes, a rule whose default `requireAuthentication` refuses anonymous
1096
- callers a branch that needs no claims, and field rules the schema's
1097
- bypass skips.
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` in the context cancels |
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 created or deleted per mutation; bulk `limit` |
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 |
@@ -1209,12 +1583,29 @@ binding has, so the WASM binding serves reads.
1209
1583
  | `metrics` | | Counters and histograms (see below) |
1210
1584
 
1211
1585
  Errors carry `extensions.code`: `BAD_USER_INPUT`, `INVALID_CURSOR`,
1212
- `LIMIT_EXCEEDED`, `COST_EXCEEDED`, `UNAUTHENTICATED`, `FORBIDDEN`,
1586
+ `LIMIT_EXCEEDED`, `COST_EXCEEDED`, `TIMEOUT` (with `operationTimeoutMs`),
1587
+ `UNAUTHENTICATED`, `FORBIDDEN`,
1213
1588
  `NOT_FOUND`, `CONSTRAINT_VIOLATION` (with `type` and `field`),
1214
1589
  `DATABASE_ERROR` (with an `id`, also given to `onError`),
1215
1590
  `PERSISTED_QUERY_ONLY` (`execute()` or `subscribe()` got a document under
1216
1591
  `persistedOnly`) and `WRONG_OPERATION_TYPE` (`execute()` got a
1217
- subscription, or `subscribe()` a query or mutation). An invalid SDL
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
1218
1609
  throws one `ModelError` listing every problem, each located by type and
1219
1610
  field.
1220
1611
 
@@ -1225,20 +1616,43 @@ the document before that. `execute()`, `subscribe()` and `persist()` apply them,
1225
1616
  `lora.validationRules()` / `lora.envelopPlugin()` bring them to any other
1226
1617
  server (GraphQL Yoga takes the plugin as is):
1227
1618
 
1228
- | Guard | Default | Limit |
1229
- | ----------------------- | ---------- | ---------------------------------------- |
1230
- | `maxDepth` | 12 | Field nesting, through fragments |
1231
- | `maxIntrospectionDepth` | 20 | Nesting under `__schema` / `__type` |
1232
- | `maxAliases` | 30 | Aliased fields per document |
1233
- | `maxRootFields` | 20 | Root fields per operation |
1234
- | `maxTokens` | 5000 | Lexer tokens per document, while parsing |
1235
- | `introspection` | production | Off when `NODE_ENV` is `production` |
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.
1236
1639
 
1237
1640
  With `NODE_ENV=production`, database errors are masked and introspection
1238
1641
  is off unless configured otherwise. See
1239
1642
  [the threat model](../../docs/design/graphql-threat-model.md) for what
1240
1643
  the library trusts and where each check runs.
1241
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
+
1242
1656
  ### Observability
1243
1657
 
1244
1658
  `onStatement` fires before a statement runs; `onStatementEnd` after, with
@@ -1280,7 +1694,9 @@ object with `counter(name, value, attributes)` and
1280
1694
 
1281
1695
  `budget(context)` sets the cost limit per request (a plan, a user), and
1282
1696
  `onCost` sees every estimate. `execute()` also returns the operation's
1283
- 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.
1284
1700
 
1285
1701
  ### Compile cache
1286
1702
 
@@ -1292,6 +1708,13 @@ drops from 0.14 ms to 0.06 ms end to end. Servers that parse every request
1292
1708
  themselves get new field nodes each time and do not benefit; use
1293
1709
  `execute()` or persisted operations.
1294
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
+
1295
1718
  `check({ rowBudget })` flags statements whose largest engine row estimate
1296
1719
  exceeds the budget; every plan report carries `estimatedRows` either way.
1297
1720