@loradb/lora-graphql 0.16.2 → 0.17.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.
@@ -418,6 +422,14 @@ bounded like bulk deletes and following `onDelete`. `limit` defaults to
418
422
  writes in the inputs, and `aggregate: false` removes the relationship's
419
423
  aggregates and aggregate filter.
420
424
 
425
+ An input left with no field is left out, with what would take it: a
426
+ relationship without settable properties has no `edge` input, and a type
427
+ with nothing settable on update (no settable field, no relationship with
428
+ a nested write) has no update mutations; the model warns when
429
+ `@mutation(operations: [UPDATE])` asked for them. A generated schema
430
+ graphql-js would reject is a `ModelError` from `getSchema()`, never an
431
+ error on every request.
432
+
421
433
  ## Search
422
434
 
423
435
  ```graphql
@@ -491,7 +503,10 @@ type Mutation {
491
503
  ```
492
504
 
493
505
  `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`.
506
+ request's claims. `columnName` is inferred when the statement's last
507
+ top-level `RETURN` has one item, `RETURN x` or `RETURN … AS x` (commas
508
+ inside calls, lists, maps and `CALL { }` do not count); with several
509
+ items, set it.
495
510
  Fields returning `@node` types are projected with the selection like any
496
511
  other node, and read filters apply to them.
497
512
 
@@ -578,6 +593,12 @@ type Post
578
593
  `"$context.path"` strings values from the GraphQL context. `jwt` tests
579
594
  claims (`eq`, `in`, `includes`, `contains`, `startsWith`, `endsWith`,
580
595
  `lt`, `lte`, `gt`, `gte`, `exists`).
596
+ - Inside a longer string, write `${jwt.path}` or `${context.path}`:
597
+ `key: { startsWith: "${jwt.sub}:" }` confines a user to keys that begin
598
+ with their `sub` and `:`. The claim must be a string, number or boolean;
599
+ otherwise the rule denies. Pick a separator no `sub` contains: with `-`,
600
+ user `a` could take `a-b-…`, the key space of user `a-b`. See
601
+ [docs/design/graphql-claim-interpolation.md](../../docs/design/graphql-claim-interpolation.md).
581
602
  - **Claim tests run in JavaScript at compile time**, so an admin's
582
603
  statement carries no filter at all. Statements stay specialised and
583
604
  index-friendly.
@@ -588,7 +609,9 @@ type Post
588
609
  - `filter` rules (any passing rule grants) make other nodes invisible:
589
610
  in lists, lookups, counts, aggregates, search, nested relationships,
590
611
  relationship filters, subscriptions, and as targets of updates, deletes
591
- and connects. Filter rules for `CREATE_RELATIONSHIP` and
612
+ and connects. A node the same mutation creates is not hidden from its
613
+ own connects, so a filter that depends on the new relationship (a
614
+ request visible to its sender) does not block a nested create. Filter rules for `CREATE_RELATIONSHIP` and
592
615
  `DELETE_RELATIONSHIP` guard both ends of connects and disconnects.
593
616
  - `validate` rules fail the request with `FORBIDDEN`: `BEFORE` an update or
594
617
  delete, `AFTER` a create or update (rolling it back), and for `READ`: on
@@ -599,6 +622,12 @@ type Post
599
622
  pass, sorting or aggregating by it is refused, and writing it checks the
600
623
  rule. Field-level `@authentication` also guards filtering, sorting and
601
624
  aggregating on the field.
625
+ - Reading a guarded field is checked per row, token or not: without the
626
+ token a rule needs, each row reads the field as `UNAUTHENTICATED`. The
627
+ statement has the same shape either way, so `compile()`, `explain()`,
628
+ `check()` and `expectSeeks` plan-check such operations without a token;
629
+ pass `context` (per operation in `check({ operations })`) to compile
630
+ them as a signed-in caller.
602
631
  - `@authentication(operations:, jwt:)` covers `READ`, `CREATE`, `UPDATE`,
603
632
  `DELETE`, `CREATE_RELATIONSHIP`, `DELETE_RELATIONSHIP` and `SUBSCRIBE`,
604
633
  and may require claims.
@@ -610,6 +639,29 @@ Relationship and `@cypher` fields take field-level `@authorization`
610
639
  with READ validate rules: a row failing the rule reads the field as
611
640
  `FORBIDDEN`, and filtering through the field applies the rule too.
612
641
 
642
+ Relationship properties take field-level `@authentication` and
643
+ `@authorization(validate:)` for `READ`, `CREATE` and `UPDATE`. One
644
+ `@relationshipProperties` type can serve fields on both ends, so these
645
+ rules test claims (`jwt`) only; a `node` part is a model error. Setting
646
+ the property on connect or nested create checks `CREATE` for a new
647
+ relationship and `UPDATE` for one that already exists; `update: { edge }`
648
+ checks `UPDATE`. A request the READ rules refuse reads the property as
649
+ `FORBIDDEN` and cannot filter, sort or aggregate by it.
650
+
651
+ ```graphql
652
+ type Membership @relationshipProperties {
653
+ role: String
654
+ @authorization(
655
+ validate: [
656
+ {
657
+ operations: [CREATE, UPDATE]
658
+ where: { jwt: { roles: { includes: "admin" } } }
659
+ }
660
+ ]
661
+ )
662
+ }
663
+ ```
664
+
613
665
  ## The smart layer
614
666
 
615
667
  **S1: indexes from the API.** `requirements()` derives every constraint and
@@ -632,8 +684,11 @@ index the API needs, with the reason for each:
632
684
  **S2: plans are checked.** Every compiled statement records the access path
633
685
  it was written for. `lora.explain(query, variables)` plans each statement and
634
686
  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.
687
+ read, or result columns that do not match. The access path checked is the
688
+ root field's own, outside every `CALL { }`: a label scan inside one (a
689
+ `@cypher` statement's `MATCH`) is reported as a lint-level `notes` entry,
690
+ not blamed on the root. `lora.check({ operations })` runs this over your
691
+ operations; the CLI does it in CI.
637
692
 
638
693
  **S3: compile once.** `lora.persist({ id: source })` parses and validates
639
694
  persisted operations at startup, and `lora.execute({ id, variables, context })`
@@ -725,10 +780,14 @@ claims cost one visibility query and one node read per write.
725
780
 
726
781
  By default subscriptions see the writes this instance makes. With
727
782
  `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
783
+ the engine's committed change feed instead: every committed write to the
784
+ database, whichever path in the owning process made it (hand-written
785
+ Cypher, `@cypher` mutations, imports, other instances), in commit order,
786
+ with relationship ends resolved to their `@key`s. A database directory is
787
+ open in one process at a time, so this is not a cross-process feed. If the
788
+ reader falls behind the engine it resumes from its last position, which is
789
+ kept in memory: a new instance starts at the current commit and does not
790
+ replay earlier writes. `onWrite` still reports
732
791
  this instance's mutations, and `previousState` needs them: the feed carries
733
792
  the state after the write. Call `lora.close()` to stop the feed.
734
793
 
@@ -775,6 +834,10 @@ files, and exits non-zero on any finding. It is the CI gate. Options:
775
834
 
776
835
  - `--variables vars.json`: variables per operation name, instead of
777
836
  sample values for the required ones.
837
+ - `--context ctx.json`: the GraphQL context per operation name (`*` for
838
+ the rest), e.g. `{ "*": { "jwt": { "sub": "u1" } } }`, so rules compile
839
+ as they do for a signed-in caller. `check({ operations })` takes the
840
+ same as `context` on each operation.
778
841
  - `--baseline plans.json`: the operators of every statement; a plan that
779
842
  differs fails, so plan changes show up in review (`--update-baseline`
780
843
  accepts them; a missing file is written).
@@ -866,8 +929,9 @@ which only the Node binding has, so the WASM binding serves reads.
866
929
 
867
930
  Errors carry `extensions.code`: `BAD_USER_INPUT`, `INVALID_CURSOR`,
868
931
  `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
932
+ `NOT_FOUND`, `CONSTRAINT_VIOLATION` (with `type` and `field`),
933
+ `DATABASE_ERROR` (with an `id`, also given to `onError`) and
934
+ `PERSISTED_QUERY_ONLY` (`execute()` got a document under `persistedOnly`). An invalid SDL
871
935
  throws one `ModelError` listing every problem, each located by type and
872
936
  field.
873
937
 
@@ -929,7 +993,8 @@ exceeds the budget; every plan report carries `estimatedRows` either way.
929
993
 
930
994
  `@loradb/lora-graphql` is released in lockstep with `@loradb/lora-node`:
931
995
  version X.Y.Z declares `"@loradb/lora-node": "^X.Y.Z"` as its peer and is
932
- tested against that binding. Upgrade both together.
996
+ tested against that binding. Upgrade both together. `graphql` 16 and 17 are
997
+ both supported peers; the test suite runs on each.
933
998
 
934
999
  ## Translation rules
935
1000
 
@@ -943,7 +1008,7 @@ Measured on LoraDB 0.15 over 20 000 festivals and 100 000 relationships
943
1008
  | 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
1009
  | 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
1010
  | 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 |
1011
+ | 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
1012
  | Lists sort by the requested fields only; connections add a unique tie-breaker | Two sort keys cannot stream from an index |
948
1013
  | Every value a parameter; every identifier from the model, escaped | No injection, stable statement text |
949
1014
  | Absent filters left out, never `($p IS NULL OR …)` | Keeps the predicate visible to the planner |
@@ -974,8 +1039,9 @@ Fixed in the engine and no longer worked around: labels after the first
974
1039
  node of a `MATCH`, early `LIMIT` under a deadline or in a transaction,
975
1040
  `MERGE` with a bound end node (connect is one `MERGE`), writes in
976
1041
  `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`.
1042
+ `x IN $list` seeks, `null` values in property maps, existence checks
1043
+ before a following `SET`, list comparison (`[a, b] > $list`) and
1044
+ `first()`.
979
1045
 
980
1046
  | Behaviour | Workaround |
981
1047
  | ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- |
@@ -983,12 +1049,12 @@ before a following `SET`.
983
1049
  | `max`, `sum` and `avg` over durations are wrong | Duration aggregates are folded with `reduce` |
984
1050
  | Integer division returns a float; negative list slices (`l[..-1]`) return `[]` | `toInteger(a / b)` for Int fields; `l[..size(l) - n]` |
985
1051
  | `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
1052
 
988
1053
  ## Development
989
1054
 
990
1055
  ```sh
991
1056
  yarn test # vitest: model, TCK snapshots, integration, auth, mutations, CLI
1057
+ yarn test:graphql17 # the same suite on graphql 17
992
1058
  yarn bench # latency on a seeded graph
993
1059
  yarn typecheck && yarn lint && yarn build
994
1060
  ```
@@ -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;