@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 +86 -20
- 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-8nsBSSNP.js} +2 -2
- package/dist/{diff-BVP1Jzh3.js.map → diff-8nsBSSNP.js.map} +1 -1
- package/dist/{driver-C8vA5fV-.js → driver-BmnRtkpd.js} +3487 -3125
- package/dist/driver-BmnRtkpd.js.map +1 -0
- package/dist/errors.d.ts +1 -1
- package/dist/index.js +2 -2
- package/dist/lora-graphql.d.ts +11 -4
- 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.
|
|
@@ -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
|
|
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.
|
|
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.
|
|
636
|
-
|
|
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
|
|
729
|
-
process made it (hand-written
|
|
730
|
-
|
|
731
|
-
|
|
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`)
|
|
870
|
-
`DATABASE_ERROR` (with an `id`, also given to `onError`)
|
|
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`
|
|
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,
|
|
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
|
```
|
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;
|