@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 +106 -26
- package/dist/analyze/plans.d.ts +6 -0
- package/dist/cli.js +271 -262
- package/dist/cli.js.map +1 -1
- package/dist/compile/auth.d.ts +32 -0
- package/dist/compile/selection.d.ts +13 -0
- package/dist/{diff-BVP1Jzh3.js → diff-fpWH5KUa.js} +2 -2
- package/dist/{diff-BVP1Jzh3.js.map → diff-fpWH5KUa.js.map} +1 -1
- package/dist/{driver-C8vA5fV-.js → driver-CQj9gkvl.js} +3628 -3175
- package/dist/driver-CQj9gkvl.js.map +1 -0
- package/dist/errors.d.ts +1 -1
- package/dist/index.js +2 -2
- package/dist/lora-graphql.d.ts +27 -9
- package/dist/model/cypher-lexer.d.ts +6 -0
- package/dist/model/inputs.d.ts +12 -0
- package/dist/model/types.d.ts +12 -0
- package/dist/schema/mutations.d.ts +2 -4
- package/dist/testing.js +1 -1
- package/package.json +4 -2
- package/dist/driver-C8vA5fV-.js.map +0 -1
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
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
601
|
-
|
|
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.
|
|
636
|
-
|
|
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.
|
|
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
|
|
729
|
-
process made it (hand-written
|
|
730
|
-
|
|
731
|
-
|
|
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()`
|
|
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`)
|
|
870
|
-
`DATABASE_ERROR` (with an `id`, also given to `onError`)
|
|
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`
|
|
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,
|
|
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
|
```
|
package/dist/analyze/plans.d.ts
CHANGED
|
@@ -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;
|