@loradb/lora-graphql 0.16.2 → 0.18.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 CHANGED
@@ -109,16 +109,17 @@ Model:
109
109
  | `@key(generate:)` | field | Required, unique and immutable. The sort tie-breaker, cursor anchor and mutation address. `generate: true` fills a UUID on create |
110
110
  | `@unique` | field | Uniqueness constraint |
111
111
  | `@index(kind: RANGE \| TEXT \| POINT)` | field | An explicit index, usually inferred |
112
+ | `@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 |
112
113
  | `@relationship(type:, direction:, properties:, queryDirection:, onDelete:, nestedOperations:, aggregate:)` | field | An edge to a `@node` type, interface or union. `queryDirection: UNDIRECTED` reads both ways; `onDelete: DETACH \| CASCADE \| RESTRICT`; `nestedOperations` lists the nested writes inputs offer; `aggregate: false` drops its aggregates |
113
114
  | `@declareRelationship` | interface field | Every implementation declares this relationship (type and direction may differ); select it on the interface |
114
115
  | `@relationshipProperties` | type | Properties on a relationship type |
115
116
  | `@alias(property:)` | field | API name differs from the stored property |
116
117
  | `@private` | field | Stored, never exposed |
117
- | `@readonly` | field | Exposed, never client-settable |
118
- | `@settable(onCreate:, onUpdate:)` | field | Which mutations may set it, e.g. set once on create |
118
+ | `@readonly` | field | Exposed, never client-settable; on a relationship, absent from create and update inputs |
119
+ | `@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) |
119
120
  | `@selectable(onRead:, onAggregate:)` | field | `onRead: false` makes a field write-only |
120
- | `@default(value:)` | field | Stored on create when the input omits it |
121
- | `@timestamp(operations: [CREATE, UPDATE])` | field | Set to the current time; never client-settable |
121
+ | `@default(value:)` | field | Stored on create when the input omits it; on a relationship property, when the relationship is created |
122
+ | `@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 |
122
123
  | `@populatedBy(callback:, operations:)` | field | Computed by a named callback on write |
123
124
  | `@cardinality(max:)` | list relationship | Declared fan-out, for cost estimates |
124
125
  | `@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) |
@@ -375,7 +376,10 @@ mutation {
375
376
  - **Updates** set fields (`null` removes one) and relationships (`connect`,
376
377
  `create`, `disconnect`, and `update` of connected nodes and relationship
377
378
  properties in place). Connecting an already-connected pair keeps one
378
- relationship and updates its properties.
379
+ relationship and sets the properties the input gives; the others,
380
+ `@default`s included, keep their values. Defaults apply when a
381
+ relationship is created. Re-connecting a single relationship to its
382
+ current target keeps that relationship.
379
383
  - **`adjust`** applies math (`add`, `subtract`, `multiply`, `divide`) and
380
384
  list (`push`, `pop`, `remove`) operators to the stored value atomically;
381
385
  a missing number counts as 0 and a missing list as empty.
@@ -385,7 +389,14 @@ mutation {
385
389
  is an error that writes nothing, and an empty `where` is refused.
386
390
  - **`upsertFestivals`** creates the inputs whose key is new and updates the
387
391
  rest; fields required on create are required only for new keys. A key the
388
- caller may not see reads as taken.
392
+ caller may not see is never updated, and never reported as taken: it gets
393
+ the answer a free key would (see below).
394
+ - **Creating under a hidden key** (`createFestivals`, `upsertFestivals`, a
395
+ nested `create`) answers as if the key were free: whatever error the free
396
+ key would get, and where it would be created, `FORBIDDEN` ("not allowed to
397
+ create"), never a `CONSTRAINT_VIOLATION` that would confirm the key
398
+ exists. A key held by a node the caller can read is still reported as
399
+ taken. See `docs/design/graphql-threat-model.md`.
389
400
  - **Deletes** follow `onDelete`: `DETACH` (default) removes the
390
401
  relationships, `CASCADE` deletes what the field reaches (checking the
391
402
  caller may delete each node), `RESTRICT` refuses while related nodes
@@ -418,6 +429,14 @@ bounded like bulk deletes and following `onDelete`. `limit` defaults to
418
429
  writes in the inputs, and `aggregate: false` removes the relationship's
419
430
  aggregates and aggregate filter.
420
431
 
432
+ An input left with no field is left out, with what would take it: a
433
+ relationship without settable properties has no `edge` input, and a type
434
+ with nothing settable on update (no settable field, no relationship with
435
+ a nested write) has no update mutations; the model warns when
436
+ `@mutation(operations: [UPDATE])` asked for them. A generated schema
437
+ graphql-js would reject is a `ModelError` from `getSchema()`, never an
438
+ error on every request.
439
+
421
440
  ## Search
422
441
 
423
442
  ```graphql
@@ -491,7 +510,10 @@ type Mutation {
491
510
  ```
492
511
 
493
512
  `this` is the parent node; arguments are `$parameters`; `$jwt` holds the
494
- request's claims. `columnName` is inferred from `RETURN x` or `RETURN … AS x`.
513
+ request's claims. `columnName` is inferred when the statement's last
514
+ top-level `RETURN` has one item, `RETURN x` or `RETURN … AS x` (commas
515
+ inside calls, lists, maps and `CALL { }` do not count); with several
516
+ items, set it.
495
517
  Fields returning `@node` types are projected with the selection like any
496
518
  other node, and read filters apply to them.
497
519
 
@@ -578,6 +600,12 @@ type Post
578
600
  `"$context.path"` strings values from the GraphQL context. `jwt` tests
579
601
  claims (`eq`, `in`, `includes`, `contains`, `startsWith`, `endsWith`,
580
602
  `lt`, `lte`, `gt`, `gte`, `exists`).
603
+ - Inside a longer string, write `${jwt.path}` or `${context.path}`:
604
+ `key: { startsWith: "${jwt.sub}:" }` confines a user to keys that begin
605
+ with their `sub` and `:`. The claim must be a string, number or boolean;
606
+ otherwise the rule denies. Pick a separator no `sub` contains: with `-`,
607
+ user `a` could take `a-b-…`, the key space of user `a-b`. See
608
+ [docs/design/graphql-claim-interpolation.md](../../docs/design/graphql-claim-interpolation.md).
581
609
  - **Claim tests run in JavaScript at compile time**, so an admin's
582
610
  statement carries no filter at all. Statements stay specialised and
583
611
  index-friendly.
@@ -588,7 +616,9 @@ type Post
588
616
  - `filter` rules (any passing rule grants) make other nodes invisible:
589
617
  in lists, lookups, counts, aggregates, search, nested relationships,
590
618
  relationship filters, subscriptions, and as targets of updates, deletes
591
- and connects. Filter rules for `CREATE_RELATIONSHIP` and
619
+ and connects. A node the same mutation creates is not hidden from its
620
+ own connects, so a filter that depends on the new relationship (a
621
+ request visible to its sender) does not block a nested create. Filter rules for `CREATE_RELATIONSHIP` and
592
622
  `DELETE_RELATIONSHIP` guard both ends of connects and disconnects.
593
623
  - `validate` rules fail the request with `FORBIDDEN`: `BEFORE` an update or
594
624
  delete, `AFTER` a create or update (rolling it back), and for `READ`: on
@@ -597,8 +627,18 @@ type Post
597
627
  - Field-level `@authorization(validate:)` guards one field: reading it on a
598
628
  row that fails is `FORBIDDEN`, filtering by it only matches rows that
599
629
  pass, sorting or aggregating by it is refused, and writing it checks the
600
- rule. Field-level `@authentication` also guards filtering, sorting and
601
- aggregating on the field.
630
+ rule. A write is what the input sets: a create that leaves the field out
631
+ is not checked against it, even when `@default` or `@populatedBy` fills
632
+ it, so a CREATE rule can keep a `verified: Boolean! @default(value: false)`
633
+ settable by admins only while anyone creates the node. Field-level
634
+ `@authentication` also guards filtering, sorting and aggregating on the
635
+ field.
636
+ - Reading a guarded field is checked per row, token or not: without the
637
+ token a rule needs, each row reads the field as `UNAUTHENTICATED`. The
638
+ statement has the same shape either way, so `compile()`, `explain()`,
639
+ `check()` and `expectSeeks` plan-check such operations without a token;
640
+ pass `context` (per operation in `check({ operations })`) to compile
641
+ them as a signed-in caller.
602
642
  - `@authentication(operations:, jwt:)` covers `READ`, `CREATE`, `UPDATE`,
603
643
  `DELETE`, `CREATE_RELATIONSHIP`, `DELETE_RELATIONSHIP` and `SUBSCRIBE`,
604
644
  and may require claims.
@@ -610,6 +650,29 @@ Relationship and `@cypher` fields take field-level `@authorization`
610
650
  with READ validate rules: a row failing the rule reads the field as
611
651
  `FORBIDDEN`, and filtering through the field applies the rule too.
612
652
 
653
+ Relationship properties take field-level `@authentication` and
654
+ `@authorization(validate:)` for `READ`, `CREATE` and `UPDATE`. One
655
+ `@relationshipProperties` type can serve fields on both ends, so these
656
+ rules test claims (`jwt`) only; a `node` part is a model error. Setting
657
+ the property on connect or nested create checks `CREATE` for a new
658
+ relationship and `UPDATE` for one that already exists; `update: { edge }`
659
+ checks `UPDATE`. A request the READ rules refuse reads the property as
660
+ `FORBIDDEN` and cannot filter, sort or aggregate by it.
661
+
662
+ ```graphql
663
+ type Membership @relationshipProperties {
664
+ role: String
665
+ @authorization(
666
+ validate: [
667
+ {
668
+ operations: [CREATE, UPDATE]
669
+ where: { jwt: { roles: { includes: "admin" } } }
670
+ }
671
+ ]
672
+ )
673
+ }
674
+ ```
675
+
613
676
  ## The smart layer
614
677
 
615
678
  **S1: indexes from the API.** `requirements()` derives every constraint and
@@ -632,13 +695,19 @@ index the API needs, with the reason for each:
632
695
  **S2: plans are checked.** Every compiled statement records the access path
633
696
  it was written for. `lora.explain(query, variables)` plans each statement and
634
697
  reports a label scan where a seek was expected, a mutating plan behind a
635
- read, or result columns that do not match. `lora.check({ operations })`
636
- runs this over your operations; the CLI does it in CI.
698
+ read, or result columns that do not match. The access path checked is the
699
+ root field's own, outside every `CALL { }`: a label scan inside one (a
700
+ `@cypher` statement's `MATCH`) is reported as a lint-level `notes` entry,
701
+ not blamed on the root. `lora.check({ operations })` runs this over your
702
+ operations; the CLI does it in CI.
637
703
 
638
704
  **S3: compile once.** `lora.persist({ id: source })` parses and validates
639
705
  persisted operations at startup, and `lora.execute({ id, variables, context })`
640
706
  runs them with no parsing or validation. Ad hoc documents passed to
641
- `execute({ source })` are cached too. Translation itself takes about 0.13 ms
707
+ `execute({ source })` are cached too. A subscription runs the same way with
708
+ `lora.subscribe({ id | source, variables, context })`, which returns an
709
+ async iterable of results (end it with `context.signal`); `execute()` answers
710
+ a subscription with an error pointing there. Translation itself takes about 0.13 ms
642
711
  for a nested page, and statement text depends only on the shape of the
643
712
  input, so LoraDB's own plan cache is hit for every repeat.
644
713
 
@@ -725,10 +794,14 @@ claims cost one visibility query and one node read per write.
725
794
 
726
795
  By default subscriptions see the writes this instance makes. With
727
796
  `changeFeed: true` (lora-node), subscriptions and `changes()` are fed by
728
- the engine's committed change feed instead: every write, whichever path or
729
- process made it (hand-written Cypher, `@cypher` mutations, imports), in
730
- commit order, with relationship ends resolved to their `@key`s. A consumer
731
- that falls behind resumes from its last position. `onWrite` still reports
797
+ the engine's committed change feed instead: every committed write to the
798
+ database, whichever path in the owning process made it (hand-written
799
+ Cypher, `@cypher` mutations, imports, other instances), in commit order,
800
+ with relationship ends resolved to their `@key`s. A database directory is
801
+ open in one process at a time, so this is not a cross-process feed. If the
802
+ reader falls behind the engine it resumes from its last position, which is
803
+ kept in memory: a new instance starts at the current commit and does not
804
+ replay earlier writes. `onWrite` still reports
732
805
  this instance's mutations, and `previousState` needs them: the feed carries
733
806
  the state after the write. Call `lora.close()` to stop the feed.
734
807
 
@@ -775,6 +848,10 @@ files, and exits non-zero on any finding. It is the CI gate. Options:
775
848
 
776
849
  - `--variables vars.json`: variables per operation name, instead of
777
850
  sample values for the required ones.
851
+ - `--context ctx.json`: the GraphQL context per operation name (`*` for
852
+ the rest), e.g. `{ "*": { "jwt": { "sub": "u1" } } }`, so rules compile
853
+ as they do for a signed-in caller. `check({ operations })` takes the
854
+ same as `context` on each operation.
778
855
  - `--baseline plans.json`: the operators of every statement; a plan that
779
856
  differs fails, so plan changes show up in review (`--update-baseline`
780
857
  accepts them; a missing file is written).
@@ -857,7 +934,7 @@ which only the Node binding has, so the WASM binding serves reads.
857
934
  | `maskErrors` | production | Clients get `DATABASE_ERROR` and an `id` only |
858
935
  | `onError` | | Receives each database error's detail and `id` |
859
936
  | `guards` | see below | Document limits; `false` turns them off |
860
- | `persistedOnly` | false | `execute()` runs persisted operations only |
937
+ | `persistedOnly` | false | `execute()`, `subscribe()` run persisted ops only |
861
938
  | `budget` | | Cost limit per request, from the context |
862
939
  | `onCost` | | Each root field's estimate, total and limit |
863
940
  | `onStatementEnd` | | Duration, rows and error of every statement call |
@@ -866,15 +943,16 @@ which only the Node binding has, so the WASM binding serves reads.
866
943
 
867
944
  Errors carry `extensions.code`: `BAD_USER_INPUT`, `INVALID_CURSOR`,
868
945
  `LIMIT_EXCEEDED`, `COST_EXCEEDED`, `UNAUTHENTICATED`, `FORBIDDEN`,
869
- `NOT_FOUND`, `CONSTRAINT_VIOLATION` (with `type` and `field`) and
870
- `DATABASE_ERROR` (with an `id`, also given to `onError`). An invalid SDL
946
+ `NOT_FOUND`, `CONSTRAINT_VIOLATION` (with `type` and `field`),
947
+ `DATABASE_ERROR` (with an `id`, also given to `onError`) and
948
+ `PERSISTED_QUERY_ONLY` (`execute()` or `subscribe()` got a document under `persistedOnly`). An invalid SDL
871
949
  throws one `ModelError` listing every problem, each located by type and
872
950
  field.
873
951
 
874
952
  ### Security defaults
875
953
 
876
954
  `maxCost` bounds the rows an operation touches; the document guards bound
877
- the document before that. `execute()` and `persist()` apply them, and
955
+ the document before that. `execute()`, `subscribe()` and `persist()` apply them, and
878
956
  `lora.validationRules()` / `lora.envelopPlugin()` bring them to any other
879
957
  server (GraphQL Yoga takes the plugin as is):
880
958
 
@@ -929,7 +1007,8 @@ exceeds the budget; every plan report carries `estimatedRows` either way.
929
1007
 
930
1008
  `@loradb/lora-graphql` is released in lockstep with `@loradb/lora-node`:
931
1009
  version X.Y.Z declares `"@loradb/lora-node": "^X.Y.Z"` as its peer and is
932
- tested against that binding. Upgrade both together.
1010
+ tested against that binding. Upgrade both together. `graphql` 16 and 17 are
1011
+ both supported peers; the test suite runs on each.
933
1012
 
934
1013
  ## Translation rules
935
1014
 
@@ -943,7 +1022,7 @@ Measured on LoraDB 0.15 over 20 000 festivals and 100 000 relationships
943
1022
  | Ordered by an always-present string: `WHERE s >= ""` | The planner walks the index in order and stops at the limit: 0.03 ms instead of 7 ms |
944
1023
  | Mutation statements seek the key in their own `MATCH`, then expand | The plan no longer depends on the optimizer finding the seek: 0.06 ms per connect |
945
1024
  | A relationship filter naming a key starts from that node | 0.07 ms instead of 9.3 ms |
946
- | Keyset predicates written out, led by `sortKey >= $v` on non-null keys | `[a, b] > $list` silently matches nothing; the lead bound gets a range scan |
1025
+ | Keyset predicates written out, led by `sortKey >= $v` on non-null keys | The lead bound gets a range scan; `[a, b] > $list` does not |
947
1026
  | Lists sort by the requested fields only; connections add a unique tie-breaker | Two sort keys cannot stream from an index |
948
1027
  | Every value a parameter; every identifier from the model, escaped | No injection, stable statement text |
949
1028
  | Absent filters left out, never `($p IS NULL OR …)` | Keeps the predicate visible to the planner |
@@ -974,8 +1053,9 @@ Fixed in the engine and no longer worked around: labels after the first
974
1053
  node of a `MATCH`, early `LIMIT` under a deadline or in a transaction,
975
1054
  `MERGE` with a bound end node (connect is one `MERGE`), writes in
976
1055
  `CALL { }`, temporal RANGE indexes (inferred again for temporal fields),
977
- `x IN $list` seeks, `null` values in property maps, and existence checks
978
- before a following `SET`.
1056
+ `x IN $list` seeks, `null` values in property maps, existence checks
1057
+ before a following `SET`, list comparison (`[a, b] > $list`) and
1058
+ `first()`.
979
1059
 
980
1060
  | Behaviour | Workaround |
981
1061
  | ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- |
@@ -983,12 +1063,12 @@ before a following `SET`.
983
1063
  | `max`, `sum` and `avg` over durations are wrong | Duration aggregates are folded with `reduce` |
984
1064
  | Integer division returns a float; negative list slices (`l[..-1]`) return `[]` | `toInteger(a / b)` for Int fields; `l[..size(l) - n]` |
985
1065
  | `COUNT { … RETURN DISTINCT x }`, `EXISTS { }` and `UNION` inside `CALL` do not parse | `reduce` for distinct counts; comprehensions; per-member subqueries |
986
- | `[a, b] > $list` matches nothing; `first()` is unknown | See translation rules |
987
1066
 
988
1067
  ## Development
989
1068
 
990
1069
  ```sh
991
1070
  yarn test # vitest: model, TCK snapshots, integration, auth, mutations, CLI
1071
+ yarn test:graphql17 # the same suite on graphql 17
992
1072
  yarn bench # latency on a seeded graph
993
1073
  yarn typecheck && yarn lint && yarn build
994
1074
  ```
@@ -17,6 +17,12 @@ export interface PlanReport {
17
17
  */
18
18
  estimatedRows: number | null;
19
19
  findings: PlanFinding[];
20
+ /**
21
+ * Lint-level observations that do not fail a check: label scans inside
22
+ * a `CALL { }` body, such as a `@cypher` statement's own access path.
23
+ * They are the statement's author's to review, not the root field's.
24
+ */
25
+ notes: PlanFinding[];
20
26
  }
21
27
  export declare function checkPlans(driver: LoraDriver, compiled: CompiledRead, options?: {
22
28
  rowBudget?: number;