@loradb/lora-graphql
v0.24.1
Published
Schema-first GraphQL for LoraDB: one annotated SDL becomes an executable, index-aware GraphQL API that compiles every operation to a single Cypher statement.
Maintainers
Readme
@loradb/lora-graphql
Schema-first GraphQL for LoraDB. One annotated SDL describes the graph and
the public API. The library turns it into an executable graphql-js schema
whose operations compile to parameterised Cypher statements, and it reasons
about those statements: it derives the indexes they need, checks with
explain() that they use them, bounds their cost before they run, compiles
authorization into them, and reports exactly what every mutation wrote.
import { createDatabase } from "@loradb/lora-node";
import { LoraGraphQL, loraDriver } from "@loradb/lora-graphql";
import { createYoga } from "graphql-yoga";
const typeDefs = /* GraphQL */ `
type Festival @node @mutation @query(aggregate: true) {
key: ID! @key(generate: true) @relayId
name: String! @filterable(byValue: [EQ, CONTAINS]) @sortable
capacity: Int @filterable(byValue: [GTE, LT]) @sortable
location: Point @filterable(byValue: [WITHIN_BBOX, DISTANCE])
createdAt: DateTime @timestamp(operations: [CREATE])
genre: Genre @relationship(type: "IN_GENRE", direction: OUT) @filterable
followers: [User!]!
@relationship(type: "FOLLOWS", direction: IN, properties: "Follows")
@filterable
followerCount: Int!
@cypher(statement: "RETURN size([(this)<-[:FOLLOWS]-(:User) | 1]) AS n")
}
type Genre @node {
key: String! @key
name: String! @filterable
}
type User @node @mutation {
key: String! @key
name: String @sortable
}
type Follows @relationshipProperties {
since: Int @default(value: 2026)
}
`;
const db = await createDatabase();
const lora = new LoraGraphQL({ typeDefs, driver: loraDriver(db) });
await lora.assertSchema({ create: true }); // the constraints and indexes the API needs
const yoga = createYoga({
schema: lora.getSchema(),
plugins: [lora.envelopPlugin()], // document guards (and atomic mutations)
context: ({ request }) => ({
jwt: verifiedClaims(request),
signal: request.signal,
}),
});Contents
- What it generates
- Directives
- Queries
- Interfaces and unions
- Mutations
- Search
- @cypher fields
- Authorization
- The smart layer
- Change tracking
- Subscriptions
- Transactions
- CLI
- Drivers, limits and errors
- Translation rules
- Coming from @neo4j/graphql
- LoraDB behaviours this works around
What it generates
For each @node type (reads are on by default; @query(read: false) turns
them off):
| Field | Does |
| -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| festivals(where, sort, limit) | A bounded list |
| festivalsConnection(where, sort, first, after, last, before) | Relay connection with keyset cursors in both directions, totalCount and aggregate; never SKIP |
| festival(key:) | Lookup by @key |
| festivalsAggregate(where) | count, and min / max (avg / sum for numbers) of sortable fields, with @query(aggregate: true) |
| searchFestivals(query, where, limit) | Full-text search, with @fulltext |
| similarFestivals(vector or to, where, limit) | Vector similarity, with a @vector field |
| node(id:) | Any @relayId type by global id |
| nodes(ids:) | Many global ids at once, in order; null where unknown or hidden (at most maxLimit) |
| events(where, sort, limit) | An interface's or union's members together |
| createFestivals, upsertFestivals | With @mutation(CREATE) (upsert also needs UPDATE) |
| updateFestival, updateFestivals(where, limit) | With @mutation(UPDATE): by key, or bulk by where |
| deleteFestival, deleteFestivals(where, limit) | With @mutation(DELETE): by key, or bulk by where |
| festivalChanged(key, operations, where) | A subscription, with @subscription |
Relationship fields take where, sort and limit; list relationships
also get …Connection, whose edges carry the relationship properties and
filter on them (where: { node, edge }).
The surface is restrictive: a field is filterable only with @filterable,
and only by the operators listed; sortable only with @sortable;
printPublicSchema() prints exactly what clients see, with no directives.
Directives
A directive applies where the tables below say, and nowhere else: one in
a position the model would not apply (an @authorization on an interface
field, a @selectable on a field of an object type without @node, a
@limit on a scalar field) is a model error naming the directive and the
position, never silently ignored. DIRECTIVE_POSITIONS in
src/model/positions.ts is the full table.
A directive on an extension (extend type Secret @authorization(...),
extend interface, extend union, extend scalar, extend schema)
applies exactly as on the definition, so a type's rules may live in
another file. Repeatable directives (@uniqueTogether,
@authorizationRule) add up across the definition and its extensions;
any other directive written on both is an error. extend type X @node
makes a plain type X a node type; extending a type that is never
defined is an error.
Model:
| Directive | On | Meaning |
| ---------------------------------------------------------------------------------------------------------- | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| @node(labels:, plural:) | type | A node label set; default: the type name |
| @key(generate:) | field | Required, unique and immutable. The sort tie-breaker, cursor anchor and mutation address. generate: true fills a UUID on create |
| @unique | field | Uniqueness constraint |
| @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 |
| @index(kind: RANGE \| TEXT \| POINT) | field | An explicit index, usually inferred |
| @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 |
| @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 |
| @declareRelationship | interface field | Every implementation declares this relationship (type and direction may differ); select it on the interface |
| @relationshipProperties | type | Properties on a relationship type |
| @alias(property:) | field | API name differs from the stored property |
| @private | field | Stored, never exposed |
| @readonly | field | Exposed, never client-settable; on a relationship, absent from create and update inputs |
| @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 |
| @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 |
| @default(value:) | field | Stored on create when the input omits it; on a relationship property, when the relationship is created |
| @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 |
| @populatedBy(callback:, operations:) | field | Computed by a named callback on write |
| @cardinality(max:) | list relationship | Declared fan-out, for cost estimates |
| @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) |
| @fulltext(indexes: [{ name, fields, analyzer, queryName }]) | type | FULLTEXT indexes, each with a search root field |
| @vector(dimensions:, similarity:, queryName:) | [Float!] field | A VECTOR index and a similarity root field |
| @plural(value:) | interface, union | The root field's name |
API:
| Directive | On | Meaning |
| ----------------------------------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| @query(read:, aggregate:) | type, interface, union | Generated reads |
| @mutation(operations: [CREATE, UPDATE, DELETE]) | type | Generated mutations; none without it |
| @subscription(operations: [CREATE, UPDATE, DELETE]) | type | Generated subscriptions; none without it |
| @filterable(byValue: [...]) | field | Filter operators. Bare: EQ and IN (lists: INCLUDES). On a relationship: enables relationship filters |
| @sortable | field | Sort and paginate by this field (on a relationship property: sort: [{ edge: { ... } }]) |
| @groupBy | field | A grouping key of <plural>Grouped(by:) (needs @query(aggregate: true)) |
| @limit(default:, max:) | type, interface, union, list relationship | Page size bounds |
| @size(max:) | list argument of a @cypher field | The most items it takes (default maxListArgument) |
| @range(min:, max:) | Int / Float argument of a @cypher field | Bounds of its value (each item of a list); outside is BAD_USER_INPUT |
| @relayId | @key field | Adds a global id and the Node interface |
| @authentication(operations:, jwt:) | type, field | Needs an authenticated request, whose claims satisfy jwt |
| @authorization(filter:, validate:) | type (filter and validate), field (validate) | Row-level rules, compiled into statements |
| @jwt, @jwtClaim(path:) | type, field | The claims shape; rules may only use declared claims |
| @viewer(type:, field:) | @jwt claim | The claim naming the caller's node (by a @key or @unique field): enables isViewer and viewer in rules |
directiveTypeDefs (or lora-graphql directives) prints these as SDL for
editors and codegen.
Filter operators: EQ, IN, LT, LTE, GT, GTE, CONTAINS,
STARTS_WITH, ENDS_WITH, CASE_INSENSITIVE (strings), IS_NULL
(nullable fields), INCLUDES (lists), and on points WITHIN_BBOX and
DISTANCE. Each is checked against the field's type.
Types: String, ID, Int, Float, Boolean, enums, BigInt (a decimal
string), Date, Time, LocalTime, DateTime, LocalDateTime,
Duration (ISO-8601 strings), Point and CartesianPoint (objects; set with
PointInput / CartesianPointInput), and non-null lists of these.
Queries
{
festivalsConnection(
first: 10
after: $cursor
where: {
name: { caseInsensitive: { contains: "land" } }
followers: { some: { key: { eq: "u1" } } }
followersConnection: { some: { edge: { since: { gte: 2020 } } } }
OR: [{ capacity: { gte: 5000 } }, { genre: { name: { eq: "Techno" } } }]
}
sort: [{ capacity: DESC }]
) {
totalCount
aggregate {
count
node {
capacity {
max
avg
}
}
}
edges {
cursor
node {
key
name
followerCount
}
}
pageInfo {
hasNextPage
endCursor
}
}
}- Filters nest, with
AND/OR/NOT. List relationships takesome,all,none,single,countandaggregate(node/edgefieldmin,max,avg,sum); single relationships take the target's filter directly;<field>Connectionquantifies over node and relationship properties together. - Absent and
nullfilters are left out of the statement, so a filter bound to an unset variable costs nothing.eq: nulldoes not meanIS NULL: useisNull: true. AnORbranch left empty that way is left out of theOR, and anORorNOTwith nothing left is left out entirely, so an unset variable never widens a filter to every row; a literalOR: []matches nothing. A relationship quantifier whose filter is empty is left out too: askcount: { gt: 0 }for "has any". A single relationship takes<field>Exists: Booleanfor "is set" (venueExists: false: festivals without a venue); as in every relationship filter, a related node the reader may not see counts as none. countandsinglecount related nodes once each, however many relationships lead to them.allholds on an empty set, and a missing property fails it.CASE_INSENSITIVEcompares lowercased values and cannot use an index: prefer@fulltextfor search over large labels.- Connections page forward with
first/afterand backward withlast/before; one cursor works in both directions.aggregatecovers every match, not only the page; selecting onlytotalCountoraggregatereads no page. sorttakes one field per item. Lists sort by the requested fields only; connections always end on a unique, non-null field (the@key, or an earlier required@uniquefield) so cursors are stable. A cursor is tagged with its sort, and replaying it under another sort is anINVALID_CURSORerror. WithcursorSecretit is also signed (HMAC-SHA-256), and any cursor the server did not issue is rejected. Nested lists without a sort come in@keyorder.- Nulls sort last ascending and first descending, as in Cypher, and keyset pages handle them.
- Every list is bounded:
limit/firstdefault to@limit(default:)(global 25) and asking for more thanmax(global 100) is aLIMIT_EXCEEDEDerror, not a silent clamp.
More query surface
- Edge sort. A relationship connection sorts by
@sortablerelationship properties:followsConnection(sort: [{ edge: { since: DESC } }, { name: ASC }]). Cursors carry the edge value; relationship properties have no index, so this sorts per parent, bounded by the page. - Grouped aggregates.
sessionsGrouped(by: [kind, room], where:, limit:)returns[{ by { kind room } aggregate { count minutes { sum } } }], ordered by the group values, at mostlimitgroups. - Richer aggregates. Strings add
shortest/longest, and aggregate filters takeshortestLength,longestLengthandaverageLength. Durations aggregatemin,max,sumandavg. Relationship connections countcount { nodes edges }: they differ when several relationships lead to the same node.
Interfaces and unions
interface Event @limit(default: 10) {
key: String!
title: String! @filterable(byValue: [EQ, CONTAINS]) @sortable
starts: Int @sortable
}
type Concert implements Event @node @mutation {
key: String! @key
title: String!
starts: Int
band: String
}
type Exhibition implements Event @node @mutation {
key: String! @key
title: String!
starts: Int
artist: String
}
union Headline = Concert | Exhibition
type Venue @node @mutation {
key: String! @key
events: [Event!]! @relationship(type: "HOSTS", direction: OUT) @filterable
headline: Headline @relationship(type: "HEADLINES", direction: OUT)
}{
events(
where: { title: { contains: "Rock" }, typename: [Concert] }
sort: [{ starts: ASC }]
) {
__typename
key
title
... on Concert {
band
}
}
headlines(where: { Exhibition: { title: { eq: "Modern art" } } }) {
__typename
}
venue(key: "v1") {
events(limit: 5) {
key
}
}
}Interfaces and unions range over @node types. An interface's
@filterable and @sortable fields apply to every implementation, and so
does index inference: each implementation's label gets the index. A root
list or relationship field over an interface runs one sorted, limited
subquery per implementation, each able to use its own index, and merges
them by the requested sort, then type name, then key. An interface where
takes the interface's fields plus typename; a union where takes one
filter per member, and once any member is named, members not named are left
out. Mutations connect, create and disconnect per member:
events: { connect: { Concert: [{ key: "c1" }] } }. A single relationship
to a union holds one node across all members.
Interface relationships
An interface field under @declareRelationship is a relationship every
implementation declares (with the same target and shape; the type and
direction may differ), so events { venue { name } } works at interface
level.
Mutations
Mutations exist only for types with @mutation, and address nodes by
@key, so every write-set is exact.
mutation {
createFestivals(
input: [
{
name: "Sunland"
genre: { create: { node: { key: "techno", name: "Techno" } } }
followers: { connect: [{ key: "u1", edge: { since: 2020 } }] }
}
]
) {
festivals {
key
name
genre {
name
}
}
info {
nodesCreated
relationshipsCreated
}
}
updateFestival(
key: "f1"
update: {
capacity: null # removes the property
genre: { connect: { key: "house" } } # replaces the single relationship
followers: {
disconnect: ["u2"]
update: [{ key: "u1", edge: { since: 2021 } }] # in place
}
}
adjust: { visits: { add: 1 }, tags: { push: ["summer"] } }
) {
festival {
key
}
}
updateFestivals(
where: { capacity: { lt: 100 } }
adjust: { capacity: { multiply: 2 } }
limit: 50
) {
info {
nodesUpdated
}
}
deleteFestival(key: "f2") {
nodesDeleted
relationshipsDeleted
}
}Updates set fields (
nullremoves one) and relationships (connect,create,disconnect, andupdateof connected nodes and relationship properties in place). Connecting an already-connected pair keeps one relationship and sets the properties the input gives; the others,@defaults included, keep their values. Defaults apply when a relationship is created. Re-connecting a single relationship to its current target keeps that relationship.adjustapplies math (add,subtract,multiply,divide) and list (push,pop,remove) operators to the stored value atomically; a missing number counts as 0 and a missing list as empty.Bulk
updateFestivals/deleteFestivalsresolve the keyswherematches under the authorization filter, then run the keyed path, so their write-sets are exact too. More matches thanlimit(defaultmaxBatch) is an error that writes nothing, and an emptywhereis refused.upsertFestivalscreates the inputs whose key is new and updates the rest; fields required on create are required only for new keys. A key the caller may not see is never updated, and never reported as taken: it gets the answer a free key would (see below).Creating under a hidden key (
createFestivals,upsertFestivals, a nestedcreate) answers as if the key were free: whatever error the free key would get, and where it would be created,FORBIDDEN("not allowed to create"), never aCONSTRAINT_VIOLATIONthat would confirm the key exists. A key held by a node the caller can read is still reported as taken. Seedocs/design/graphql-threat-model.md.Deletes follow
onDelete:DETACH(default) removes the relationships,CASCADEdeletes what the field reaches (checking the caller may delete each node),RESTRICTrefuses while related nodes remain.@populatedBy(callback: "slug")computes a field withcallbacks: { slug: ({ input, key, context, operation }) => … }.Supplying a computed field.
@timestampand@populatedByfields are not in the inputs, unless@settable(onCreate: true)(oronUpdate: true) says so explicitly and a field-level@authorization(validate:)rule for that operation decides who may supply the value. A supplied value is stored as given (a seed backfilling history, an import keeping its dates); an omitted one is computed as before. Without such a rule the combination is a model error, so a computed field never becomes client-settable by accident: the schema's bypass and@authenticationare not rules here, and a bare@settable(its defaults) changes nothing. Relationship properties cannot opt in.createdAt: DateTime! @timestamp(operations: [CREATE]) @settable(onCreate: true) @authorization(validate: [{ operations: [CREATE], where: { jwt: { roles: { includes: "admin" } } } }])
Each mutation runs in one interactive transaction and checks, before it commits:
- every
connecttarget exists and is visible to the caller (NOT_FOUND); - a single relationship stays single and a required one stays set, even
when written from the other side or when its target is deleted
(
CONSTRAINT_VIOLATION); - every
@uniqueTogetherholds (CONSTRAINT_VIOLATION, below); @authorizationvalidate rules, type and field level (FORBIDDEN).
@uniqueTogether states a uniqueness the engine's per-property constraints
cannot: over relationship ends.
type ConnectionRequest @node @uniqueTogether(fields: ["from", "to"]) {
key: ID! @key(generate: true)
from: Person! @relationship(type: "SENT", direction: IN)
to: Person! @relationship(type: "TO", direction: OUT)
}
type Conversation
@node
@uniqueTogether(fields: ["participants"], where: { kind: { eq: DIRECT } }) {
key: ID! @key
kind: ConversationKind!
participants: [Person!]! @relationship(type: "IN", direction: IN)
}fields names scalar fields (compared by value), single relationships (by
the target's @key) and at most one list relationship (by the set of
target keys, in any order); where limits the nodes compared. A
combination with a null scalar, no target or an empty set is exempt, as a
null is in a unique index. Every generated mutation checks it after its
writes — creates, updates, upserts, nested creates, and connects,
disconnects and deletes from either side — for every caller, the bypass
included: it is a data invariant, not a rule. Each check seeks the written
nodes and compares only with nodes sharing their first relationship end
(or, with scalars only, their first scalar's value).
A @cypher mutation's write-set is unknown, so after its statement it
checks every @uniqueTogether type the statement may write: one whose
label, constrained relationship type or constrained scalar property
(.prop) the statement text names. That check compares every node of the
type (a scan, in the same transaction), and the model warns about each such
mutation (check() reports it under warnings); prefer a generated
mutation for a constrained type. Cypher of your own (tx.execute(), other
clients) is not checked.
Any failure rolls the whole mutation back. Atomicity is per root field:
in an operation with several root fields, each runs in its own
transaction, so a later failure leaves the earlier ones committed. Pass
mutationTransaction: "operation" to run every root field of a mutation
in one transaction through execute() (persisted operations included):
it commits only when the operation reports no error, and otherwise rolls
back and returns data: null. GraphQL Yoga and other Envelop servers on
getSchema() get the same from lora.envelopPlugin(). With another
server calling graphql-js directly, put a lora.begin() transaction in
the context (see Transactions); without one, each root
field commits on its own, and the first such mutation logs a warning. Engine
constraint errors come
back as CONSTRAINT_VIOLATION naming the type and field. A mutation writes
at most maxBatch nodes (default 1000): created and updated nodes count
together (every upsert input, nested creates, each nested update
entry), and a delete reaches at most maxBatch nodes through
onDelete: CASCADE. Relationships written (connects, each key of a
disconnect list, nested update entries) count up to ten times
maxBatch. Going over is LIMIT_EXCEEDED, before the writes that
would exceed it. @key is not updatable.
info reports nodesCreated, nodesUpdated, nodesDeleted,
relationshipsCreated and relationshipsDeleted.
Nested delete and trimmed inputs
update: { stages: { delete: { where: { size: { gt: 2 } }, limit: 10 } } }
deletes connected nodes (a single relationship takes delete: true),
bounded like bulk deletes and following onDelete. limit defaults to
maxBatch; more matches is LIMIT_EXCEEDED.
@relationship(nestedOperations: [CONNECT]) keeps only the listed nested
writes in the inputs, and aggregate: false removes the relationship's
aggregates and aggregate filter. A nested update: [{ key, edge, node }]
(UPDATE) changes the connected node and the relationship's properties;
UPDATE_EDGE offers update: [{ key, edge }] alone, so an input can keep
"set my RSVP" without advertising "edit the festival" (it needs
relationship properties).
An input left with no field is left out, with what would take it: a
relationship without settable properties has no edge input, and a type
with nothing settable on update (no settable field, no relationship with
a nested write) has no update mutations; the model warns when
@mutation(operations: [UPDATE]) asked for them. A generated schema
graphql-js would reject is a ModelError from getSchema(), never an
error on every request.
Search
type Event @node @fulltext(indexes: [{ fields: ["title", "summary"] }]) {
key: String! @key
title: String!
summary: String
embedding: [Float!] @vector(dimensions: 384, similarity: COSINE)
}{
searchEvents(query: "techno sun*", where: { city: { eq: "Ams" } }) {
score
node {
key
title
}
}
similarEvents(to: "e1", limit: 5) {
score
node {
key
}
}
}Full-text queries AND their terms, fold case and accents, and treat a
trailing * as a prefix. A [String!] field in @fulltext indexes each
of its strings. Search connections (searchEventsConnection) page with
cursors and take totalCount: every match after where and the read
rules (a vector search counts within its candidate window); a selection of
only totalCount reads no page. Vector search takes a query vector or the key of
a node whose embedding to start from (to, which is left out of the
results). The index returns its top candidates before where applies, so
the library asks it for four times the page when a filter is present.
@vector fields are stored as VECTOR values, which the index requires, and
read back as [Float!]. Both indexes are part of S1 and created by
assertSchema({ create: true }); read rules apply to every result.
An index matches and ranks by stored values, whatever the read rules say, so search never reaches a value the reader may not read:
- A
@fulltextindex over a field with a mask, a field-level READvalidaterule or@authenticationfor READ is a model error naming the field: searching it would tell which rows contain a hidden word. Leave the field out of the index (index a public copy if it must be found). - A vector search, with
vectororto, needs the vector field's@authentication, and ranks only nodes passing its READvalidaterules (the anchor oftotoo); a node whose vector the reader may not read is not a candidate. A mask on a@vectorfield is a model error.
Every search also has a connection: searchDocsConnection(query:, where:,
first:, after:) pages by keyset on (score, key). A vector index returns
its top candidates before any filter, so vector connections page within
4 × @limit(max:) candidates.
@cypher fields
type Festival @node {
key: ID! @key
similar(limit: Int = 3): [Festival!]!
@cypher(
statement: """
MATCH (this)-[:IN_GENRE]->(:Genre)<-[:IN_GENRE]-(other:Festival)
WHERE other.key <> this.key
RETURN other ORDER BY other.name LIMIT $limit
"""
columnName: "other"
)
}
type Query {
festivalCount: Int!
@cypher(statement: "MATCH (f:Festival) RETURN count(f) AS n")
}
type Mutation {
renameGenre(key: String!, name: String!): Genre
@cypher(
statement: "MATCH (g:Genre) WHERE g.key = $key SET g.name = $name RETURN g"
)
}this is the parent node; arguments are $parameters; $jwt holds the
request's claims; $viewer is the caller (below). columnName is
inferred when the statement's last top-level RETURN has one item,
RETURN x or RETURN … AS x (commas inside calls, lists, maps and
CALL { } do not count); with several items, set it.
Fields returning @node types are projected with the selection like any
other node, and read filters apply to them.
Statements are checked, not trusted:
- at startup: every
$parametermust be an argument,$jwtor$viewer, Query and object fields may not contain write clauses, and unused arguments,OPTIONAL MATCHand statements that never usethisare warnings (lora.model.warnings); - in
check(): every statement is planned withexplain(), so a syntax error, an unknown function or a missing column fails CI with the engine's message, not the first request.
The caller: $viewer
With a @viewer claim, $viewer is the caller's node's @key, in field
and mutation statements alike, so a statement names the viewer, not the
claim:
type Mutation {
createPost(key: String!, caption: String!): Post
@authentication
@cypher(
statement: """
MATCH (a:Person) WHERE a.key = $viewer
CREATE (a)-[:POSTED]->(p:Post {key: $key, caption: $caption})
RETURN p
"""
)
}When @viewer maps to the key, $viewer is the claim itself. When it
maps to another field (an opaque subject), it is read in the statement
with one seek by the claim (head([(v:Person {subject: $claim}) |
v.key])), so the statement above keeps working, and keeps seeking, when
@viewer moves from Person.key to Person.subject. $viewer is null
signed out, for a claim that is not a string or number, and for a token
naming no node. A statement using it without a @viewer claim is a
model error, and viewer is reserved as an argument name, like jwt.
Guarding root fields
@authentication and @authorization(validate:) guard a Query or
Mutation @cypher field before its statement runs. With no node, a rule
tests claims (jwt) and the caller's own node (viewer); node and
filter rules are model errors, and operations / when do not matter:
each rule guards the call.
type Mutation {
verify: Person
@authentication
@authorization(
validate: [{ where: { viewer: { verified: { eq: true } } } }]
)
@cypher(
statement: "MATCH (p:Person) WHERE p.key = $viewer SET p.verified = true RETURN p"
)
}As anywhere, any passing rule grants, and the schema's bypass skips them.
Claim tests are decided in JavaScript: a refusal (FORBIDDEN, or
UNAUTHENTICATED without a token) runs no statement. A viewer test is
one seek, in the mutation's transaction, before the statement. Every root
@cypher field is in the access matrix, guarded or not (Mutation fields
under the operation EXECUTE).
List and number arguments
The statement sees its arguments as sent, so list arguments are capped:
at most @size(max:) items, or maxListArgument (default 1000) without
it. More is BAD_USER_INPUT before any statement runs; each level of a
nested list counts.
createPost(key: String!, hashtags: [String!] = [] @size(max: 30)): PostInt and Float arguments take bounds the same way: @range(min:, max:)
(either or both, inclusive) checks the value, or each item of a list,
before the statement runs, and a default outside the bounds is a model
error. Null is not checked; that is the type's business.
nearby(lat: Float! @range(min: -90, max: 90), km: Int = 10 @range(min: 1, max: 500)): [Festival!]!Filters, sorts and richer results
A scalar @cypher field of a @node type may take @filterable and
@sortable: the statement then runs per node in a CALL before the
filter, in root fields only (through a relationship it is refused). No
index applies, and the model warns so check reports it.
A @cypher field may return an interface or union over @node types
(each node is projected as the member its label says) or an object type
without @node, whose fields are read from the returned map:
RETURN { events: count(e), titles: collect(e.title) } AS s.
Authorization
The library does not verify tokens. Verify them in your server and put the
claims in the context as jwt (or pass jwt: (context) => claims).
type Claims @jwt {
sub: String!
roles: [String!] @jwtClaim(path: "app_metadata.roles")
}
extend schema @authorizationDefaults(requireAuthentication: false)
type Post
@node
@mutation
@authentication(operations: [CREATE, UPDATE, DELETE])
@authorization(
filter: [
{ where: { node: { published: { eq: true } } } }
{ where: { node: { author: { key: { eq: "$jwt.sub" } } } } }
{
where: {
AND: [
{ jwt: { sub: { exists: true } } }
{ node: { tenant: { eq: "$context.tenant" } } }
]
}
}
{ where: { jwt: { roles: { includes: "admin" } } } }
]
validate: [
{
operations: [CREATE, UPDATE]
when: [AFTER]
where: {
OR: [
{ node: { author: { key: { eq: "$jwt.sub" } } } }
{ jwt: { roles: { includes: "admin" } } }
]
}
}
]
) {
key: ID! @key(generate: true)
title: String!
tenant: String!
published: Boolean! @default(value: false)
notes: String @authentication(operations: [READ])
royalties: Int
@authorization(
validate: [
{
operations: [READ]
where: { node: { author: { key: { eq: "$jwt.sub" } } } }
}
]
)
author: User! @relationship(type: "WROTE", direction: IN)
}A rule takes
operations,when(validate rules) andwhere; any other field is a model error, so a misspeltoperationscannot fall back to the default unnoticed.A rule is
{ node, jwt, AND, OR, NOT }.nodeis a filter over the type (relationship properties included, through<field>Connection: { some: { node, edge } }), where"$jwt.path"strings become the caller's claims and"$context.path"strings values from the GraphQL context.jwttests claims (eq,in,includes,contains,startsWith,endsWith,lt,lte,gt,gte,exists).Anonymous callers. Without a token every rule denies, unless the schema says
@authorizationDefaults(requireAuthentication: false). Then a rule with a branch that reads no claims decides that branch for anonymous callers too (the published posts above). A rule that needs claims still denies them, and how depends on the kind of rule: a validate rule asks for a token (UNAUTHENTICATED, notFORBIDDEN), while a READ filter admits no row, so a signed-out caller reads an empty list and the access matrix calls itdenied. It is one setting for the schema; a rule has no such field, and it reaches every rule: a CREATE, UPDATE, DELETE or CONNECT rule that reads no claims decides for signed-out callers as well.checknames each such write rule that no@authenticationcovers. To keep a claim-free rule or branch for signed-in callers, test a claim beside it ({ jwt: { sub: { exists: true } } }, as the tenant rule above does), or put@authenticationon the type or the relationship field.The caller's own node. Mark the claim that identifies the caller with
@viewer(type: "Person", field: "subject")(the field is@keyor@unique), so the key can stay a slug while the subject is an identity provider's opaque id. Rules then say{ node: { isViewer: true } }on the viewer type, or{ node: { author: { isViewer: true } } }through a relationship; this expands to{ subject: { eq: "$jwt.sub" } }at startup and compiles to exactly the same statement. With@viewerdeclared, writing that expansion out by hand is a model error: the mapping lives in one place.viewer: { verified: { eq: true } }tests the caller's own node: one seek by the claim. Both are unknown without the claim, soNOT { isViewer: true }never grants a signed-out caller.isViewertakestrueonly; useNOTfor the opposite. It works through relationships and union members (author: { Person: { isViewer: true } }); in a filter over an interface it is a model error, since there is no single type to expand against. In rule strings,"${viewer.key}"(any scalar field of the viewer type) is the caller's own value: the claim itself for the field@viewermaps to, otherwise read with one seek by the claim. Use it for keys built from the caller's key while the claim is an opaque subject:key: { endsWith: ":${viewer.key}" },key: { eq: "${viewer.key}" }. It stands for one value, not inside a list.The rule's own node.
"${node.path}"in a node part reads the node the rule is about, so a rule can relate two of its paths: "the request's recipient is in the conversation it gates" is{ node: { conversation: { participants: { some: { key: { eq: "${node.to.key}" } } } } } }. The path ends on a scalar field and steps only through single relationships; the value is read in the statement (the stored one, as rules see it) and stands for one value, not inside a list. In a relationship field's rules,${source.path},${target.path}and${edge.property}read its ends and properties: "the payer is on the expense's trip" is{ source: { trip: { members: { some: { key: { eq: "${target.key}" } } } } } }onExpense.paidBy's CONNECT. A named rule that reads${node.…}can't stand inside another node's filter, where it would read the outer node.A whole string starting with
$must be a placeholder ($jwt.<claim>,$context.<path>): a misspelt one ("$jtw.sub") is a model error, not a literal. Write a literal$…as"\\$…".Inside a longer string, write
${jwt.path}or${context.path}:key: { startsWith: "${jwt.sub}:" }confines a user to keys that begin with theirsuband:. The claim must be a string, number or boolean; otherwise the rule denies. Pick a separator nosubcontains: with-, useracould takea-b-…, the key space of usera-b. See docs/design/graphql-threat-model.md.Claim tests run in JavaScript at compile time, so an admin's statement carries no filter at all. Statements stay specialised and index-friendly. A write whose rules the claims alone refuse is refused before any statement runs.
A create under CREATE rules answers the same whether a
@uniquevalue (or the key) is taken by a node the caller may not create: what a free value gets. A create that would succeed answersCONSTRAINT_VIOLATION.A test that needs a claim or context value the request lacks is unknown: false where it stands, and a
NOTover it is false too, so negation can never turn a missing claim into a grant. Node conditions follow Cypher:NOTover a null property is not true.filterrules (any passing rule grants) make other nodes invisible: in lists, lookups, counts, aggregates, search, nested relationships, relationship filters, subscriptions, and as targets of updates, deletes and connects. A node the same mutation creates is not hidden from its own connects, so a filter that depends on the new relationship (a request visible to its sender) does not block a nested create. Filter rules forCREATE_RELATIONSHIPandDELETE_RELATIONSHIPguard both ends of connects and disconnects. Update and delete targets (by key, bulk and nested) must pass the type'sREADfilter as well as itsUPDATE/DELETEfilter: a key the caller cannot read answers like a missing one (null,nodesDeleted: 0), neverFORBIDDENfrom avalidaterule.Write errors never name a node the caller cannot read. A delete that would leave such a node without a required relationship fails with
a Secret the caller can't read requires a Person (Secret.holder);onDelete: RESTRICTheld by such nodes fails withOrg "o" cannot be deleted: Org.docs has onDelete: RESTRICT. Replacing a single relationship whose current target the caller cannot read is refused withFORBIDDEN(not allowed to replace F.genre), and leaves it in place: the caller cannot remove a relationship of a node they cannot see.validaterules fail the request withFORBIDDEN:BEFOREan update or delete,AFTERa create or update (rolling it back), and forREAD: on any returned node, and on cursors, counts and aggregates that cover one. They do not hide nodes from filters; usefilterrules for that.Field-level
@authorization(validate:)guards one field: reading it on a row that fails isFORBIDDEN, filtering by it only matches rows that pass, sorting or aggregating by it is refused, and writing it checks the rule. On a row failing the rule (or where it is unknown, a null property) the filter is false, never null, soNOTover it matches every such row whatever the hidden value: a negated filter reveals no more than the filter itself. The same holds for relationship and@cypherfields with READ rules,<field>Existsand<field>Connection. A write is what the input sets: a create that leaves the field out is not checked against it, even when@defaultor@populatedByfills it, so a CREATE rule can keep averified: Boolean! @default(value: false)settable by admins only while anyone creates the node. Field-level@authenticationalso guards filtering, sorting and aggregating on the field.Reading a guarded field is checked per row, token or not: without the token a rule needs, each row reads the field as
UNAUTHENTICATED. The statement has the same shape either way, socompile(),explain(),check()andexpectSeeksplan-check such operations without a token; passcontext(per operation incheck({ operations })) to compile them as a signed-in caller.@authentication(operations:, jwt:)coversREAD,CREATE,UPDATE,DELETE,CREATE_RELATIONSHIP,DELETE_RELATIONSHIPandSUBSCRIBE, and may require claims. On a relationship field,CREATE/UPDATEcover setting it in a create or update input,CREATE_RELATIONSHIPaconnector nestedcreatethrough it, andDELETE_RELATIONSHIPadisconnector nesteddelete.Rules are checked against the model at startup: an unknown field, operator or (with
@jwt) claim, or a test that is empty or null, is an error, not an open door.Owner-scoped keys.
key: String! @key(scope: VIEWER, separator: ":")keeps created keys in the caller's key space: a create (nested creates and upsert-creates included) must use a key that starts with the@viewerclaim and the separator, and is longer than that prefix. It is checked before any statement runs, soFORBIDDENforbob:xreads the same whetherbob:xexists or not. A claim containing the separator is refused, so useracannot write into the key space of usera:b. It needs@viewer; the schema's bypass skips it. When@viewermaps to a non-key field (an opaque subject), the key space is the caller's node's@key, looked up once by the claim before anything is written; a token naming no node creates nothing. Keys shared by two owners (f1:lou) stay hand-written rules, with${viewer.key}.Masks. A field-level READ rule fails the row; a mask substitutes a value instead:
status: ConnectionRequestStatus! @authorization( mask: [ { unless: { OR: [ { node: { status: { in: [PENDING, ACCEPTED] } } } { node: { to: { isViewer: true } } } ] } value: PENDING } ] ) lastSeenAt: DateTime @authorization(mask: [{ unless: { node: { isViewer: true } } }])A row failing
unlessreads the field asvalue(nullwhen left out, which a non-null field refuses;valueis type-checked). Filters compare the value the reader sees, so a mask never leaks through a filter, and such a filter cannot use the field's index. Rules see the stored value. Sorting, grouping and aggregating by a masked field are refused unless the claims settle the mask (the brief proposed sorting by the masked value; refusing keeps order from hinting at hidden values). Masks sit on scalar fields of@nodetypes other than the@key.Named rules. Define a rule once and use it as
{ rule: "name" }wherever a rule part may stand:extend schema @authorizationRules( rules: [{ name: "admin", where: { jwt: { roles: { includes: "admin" } } } }] ) type Trip @node @authorizationRule( name: "member" where: { OR: [ { node: { members: { some: { isViewer: true } } } } { node: { owner: { isViewer: true } } } ] } ) @authorization( filter: [{ where: { OR: [{ rule: "member" }, { rule: "admin" }] } }] ) { ... } type PackingItem @node @authorization( filter: [ { where: { OR: [{ node: { trip: { rule: "member" } } }, { rule: "admin" }] } } ] ) { ... }Schema rules (
@authorizationRules) test claims only. A type's rules (@authorizationRule, repeatable) are found first, then the schema's; inside a node filter (trip: { rule: "member" }) the name is a rule of that node's type, and that rule must testnodeonly. Rules are inlined at startup, so they compile to exactly the hand-written statement. An unknown name, a type rule shadowing a schema rule, and a cycle (named with its chain) are model errors.bypassandmutationsin@authorizationDefaultsmay name rules too.Schema-wide defaults.
extend schema @authorizationDefaults( bypass: { jwt: { roles: { includes: "admin" } } } mutations: { jwt: { roles: { includes: "editor" } } } )A request passing
bypassskips every filter and validate rule, field and relationship rules included;@authenticationstill applies.bypasstests claims only, so it is decided before the statement is built: an admin's statement carries no rule predicate. A type keeps its rules for everyone with@authorization(bypass: false);@authorization(bypass: true)is the default made explicit, and quietscheck()'s note that the bypass skips the type's field rules.mutationsis the write rule (CREATE,UPDATE,DELETE) of every@mutationtype, per operation: it guards each of those operations that none of the type's own rules covers. Avalidaterule covers the operations it lists; afilterrule coversUPDATEandDELETEwhen it lists them (by default it does), neverCREATE, since there is no node to filter before it exists. So a type with only@authorization(filter: [...])keeps the default onCREATE, and one with avalidaterule forUPDATEonly keeps it onCREATEandDELETE. Where a type's rule covers an operation, it replaces the default for that operation, never merges with it.check()fails on a@mutationtype whose writes nothing guards, unless it says@authorization(public: [...]).requireAuthentication(see "Anonymous callers" above) is the third setting here.
Relationship and @cypher fields take field-level @authorization
with READ validate rules: a row failing the rule reads the field as
FORBIDDEN, and filtering through the field applies the rule too.
Relationship properties take field-level @authentication and
@authorization(validate:) for READ, CREATE and UPDATE. They test
claims (jwt); a node part is a model error. Setting
the property on connect or nested create checks CREATE for a new
relationship and UPDATE for one that already exists; update: { edge }
checks UPDATE. A request the READ rules refuse reads the property as
FORBIDDEN and cannot filter, sort or aggregate by it.
When every relationship field using the properties type declares the
same ends (owner and target @node types), READ rules may also test the
relationship's source, target and edge, as a relationship rule
does, and viewer. They decide per relationship: one edge can carry an
rsvp every member reads and a marker only its member reads. A
relationship failing them reads that property as FORBIDDEN (the
others still read), and nothing may filter, sort or aggregate by it.
Writes stay claims-only: such a part in a CREATE or UPDATE rule, or
on a type whose fields disagree on the ends, is a model error.
type Membership @relationshipProperties {
rsvp: String
lastReadAt: String
@authorization(
validate: [{ operations: [READ], where: { target: { isViewer: true } } }]
)
}Rules on relationships
A relationship field also takes validate rules for the relationship's own operations, where both ends are known:
type Trip @node @mutation {
key: String! @key
owner: Person! @relationship(type: "OWNS", direction: IN)
members: [Person!]!
@relationship(type: "MEMBER", direction: IN, properties: "TripInvite")
@authorization(
validate: [
# the owner invites
{
operations: [CONNECT]
where: { source: { owner: { isViewer: true } } }
}
# the owner removes anyone; a member removes only themselves
{
operations: [DISCONNECT]
where: {
OR: [
{ source: { owner: { isViewer: true } } }
{ target: { isViewer: true } }
]
}
}
# only the member answers their own invitation, and reads its marker
{
operations: [UPDATE_EDGE, READ_EDGE]
where: { target: { isViewer: true } }
}
]
)
}sourceis the node declaring the field,targetthe related node andedgethe relationship's properties;jwt,viewer,AND,ORandNOTwork as in any rule. Anodepart is a model error here, and so is a relationship operation on a type or scalar field, or in the same rule asREAD.CONNECTcovers a new relationship (connect, nested create);DISCONNECTa disconnect, including replacing a single relationship;UPDATE_EDGEupdate: [{ edge }]and a re-connect that sets properties;READ_EDGEreading the properties.CONNECTandUPDATE_EDGEare checked on the relationship after the write andDISCONNECTbefore it, in the mutation's transaction: a failure isFORBIDDENand rolls the mutation back.- The rules hold whichever side the write comes from: a connect through
Person.trips(the same relationship type, the other direction) answers toTrip.members' rules, with source and target as declared onTrip.members. Declare each operation's rules on one of the two fields: the same operation ruled on both is a model error (combine withANDwhere both must pass); different operations may sit on different sides. - A relationship failing
READ_EDGEreads its properties asFORBIDDEN. Unless the claims alone settle the rule, nothing may filter, sort or aggregate by that relationship's properties. - Deleting a node removes its relationships without
DISCONNECTrules: who may delete the node is the type'sDELETErule.
type Membership @relationshipProperties {
role: String
@authorization(
validate: [
{
operations: [CREATE, UPDATE]
where: { jwt: { roles: { includes: "admin" } } }
}
]
)
}The smart layer
S1: indexes from the API. requirements() derives every constraint and
index the API needs, with the reason for each:
| API declares | Needs |
| -------------------------------------- | --------------------------------------- |
| @key | node key constraint |
| @unique | uniqueness constraint |
| non-null @sortable field | existence constraint |
| EQ, IN | nothing: LoraDB indexes equality lazily |
| LT, LTE, GT, GTE, @sortable | RANGE index |
| CONTAINS, STARTS_WITH, ENDS_WITH | TEXT index |
| WITHIN_BBOX, DISTANCE | POINT index |
| @fulltext, @vector | FULLTEXT and VECTOR indexes, by name |
assertSchema() reports what the database lacks (missing), and a
full-text or vector index present under its name but defined differently
(labels, fields, analyzer: mismatched), since search would keep using the
old definition. assertSchema({ create: true }) creates what is missing
and drops and re-creates what is mismatched (created, recreated),
idempotently. check() fails on either.
S2: plans are checked. Every compiled statement records the access path
it was written for. lora.explain(query, variables) plans each statement and
reports a label scan where a seek was expected, a mutating plan behind a
read, or result columns that do not match. The access path checked is the
root field's own, outside every CALL { }: a label scan inside one (a
@cypher statement's MATCH) is reported as a lint-level notes entry,
not blamed on the root. lora.check({ operations }) runs this over your
operations; the CLI does it in CI.
S3: compile once. lora.persist({ id: source }) parses and validates
persisted operations at startup, and lora.execute({ id, variables, context })
runs them with no parsing or validation. Ad hoc documents passed to
execute({ source }) are cached too. A subscription runs the same way with
lora.subscribe({ id | source, variables, context }), which returns an
async iterable of results (end it with context.signal); execute() answers
a subscription with an error pointing there. Translation itself takes about 0.13 ms
for a nested page, and statement text depends only on the shape of the
input, so LoraDB's own plan cache is hit for every repeat.
S5: read-sets and write-sets. See change tracking.
S6: statistics and cost. A relationship filter that names a related node
by key starts from that node and expands, instead of scanning the label.
Every operation has a cost estimate (rows touched, multiplying page sizes
through nested lists, capped by @cardinality), summed across its root
fields, and an operation over maxCost (default 50 000) fails with
COST_EXCEEDED before it runs. lora.analyze() counts nodes and
measures relationship degrees: a nested list is then estimated at its
relationship's maximum degree, measured over every node and capped by the
page size, instead of the page size alone. Not a percentile: the caller
picks the parents (by key, or by following a hub), so any lower bound is
one it can exceed at will.
Filters are charged the rows they examine, not only the rows they return:
- a root filter no index answers (
contains,endsWith,caseInsensitive,NOT,OR, a computed field) costs the label's node count, or the page size without statistics; an equality, range, prefix or point predicate seeks and costs nothing extra; - each relationship a filter follows (
some,none,all,single,count,aggregate, connection filters, single relationships) costs the related nodes it visits per candidate, multiplied per level: the mean degree over a scanned label, the maximum degree from parents the caller picked (by key, or the parents of a nested list), the default page size without statistics; totalCountand aggregates read every match: the label's node count when no key narrows them.
`maxFilterDept
