The generated API
This page walks through what a type gets. The examples use the schema from
getting started, with a Festival type that has
@node @mutation @query(aggregate: true). lora.printPublicSchema() (or
lora-graphql print schema.graphql) prints exactly what clients see, with
no directives, so you can review the surface before you ship it.
Queries and mutations by example runs forty requests against one schema and prints each response, if you would rather start from working queries.
The surface is restrictive by design:
- Reads are generated by default;
@query(read: false)turns them off. - Mutations and subscriptions exist only with
@mutationand@subscription. - A field is filterable only with
@filterable, by the operators listed, and sortable only with@sortable. The@keyfield is always filterable byeqandinand always sortable. The one exception is the relationshipaggregatefilter, which covers every readable field of the related type.
Root fields
| Field | Does |
|---|---|
festivals(where, sort, limit) | A bounded list |
festivalsConnection(where, sort, first, after, last, before) | A Relay connection with keyset cursors in both directions, totalCount and aggregate |
festival(key:) | Lookup by @key |
festivalsAggregate(where) | count, and aggregates of the @key, @sortable and duration fields, with @query(aggregate: true) |
festivalsGrouped(by, where, limit) | Aggregates per group of @groupBy values, with @query(aggregate: true) |
searchFestivals(query, where, limit) and searchFestivalsConnection | Full-text search, with @fulltext |
similarFestivals(vector or to, where, limit) and similarFestivalsConnection | Vector similarity, with a @vector field |
node(id:), nodes(ids:) | Any @relayId type by global id. nodes takes at most maxLimit ids and returns null for each unknown or hidden one |
events(where, sort, limit) | An interface's or union's members together. A list only: no connection, aggregate or single lookup. sort exists when the interface has @sortable fields, and never on a union |
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 |
The single lookup, update<Type>, delete<Type> and <type>Changed use
the type name. Everything else uses the plural, which comes from
@node(plural:) for types and @plural(value:) for interfaces and
unions. Without one, the plural is the name plus s, or es after s,
x, z, ch and sh, or ies for a consonant and y: Person
becomes persons, so set @node(plural: "people") if you want that. Two
types whose root fields collide are a model error asking for a plural.
A type with @mutation(operations: [UPDATE]) but nothing a client could
update gets no update mutations, and the model warns.
Lists and filters
{
festivals(
where: {
name: { contains: "land" }
followers: { some: { key: { eq: "u1" } } }
OR: [{ capacity: { gte: 5000 } }, { genre: { name: { eq: "Techno" } } }]
}
sort: [{ capacity: DESC }]
limit: 10
) {
key
name
followers(limit: 3, sort: [{ name: ASC }]) {
name
}
}
}
- Filters nest with
AND,ORandNOT. - List relationships take
some,all,none,single,countandaggregate. Single relationships take the target's filter directly, plus<field>Exists: true | false. - On a list relationship with relationship properties,
followersConnectionin awherequantifies over the related node and the relationship properties together:followersConnection: { some: { edge: { since: { gte: 2020 } } } }. - A
wheremay nest through at mostmaxFilterDepthrelationship levels (default 2). The relationships page has every relationship filter. - Absent and
nullfilters are left out of the statement, so a filter bound to an unset variable costs nothing.eq: nulldoes not mean "is null": useisNull: true, where the field offersIS_NULL. - An
ORbranch left empty that way is left out of theOR, and anORorNOTwith nothing left is left out entirely: an unset variable never widens a filter to every row. WithOR: [{ region: { in: $r } }, { country: { in: $c } }]and only$rset, the filter isregion IN $r. A literalOR: []still matches nothing. counttakeseq,lt,lte,gtandgte.countandsinglecount related nodes once each, however many relationships lead to them.allholds on an empty set, and a missing property fails it.- A quantifier whose filter is emptied by unset variables is left out
like any other empty filter. A literal
some: {},none: {}orsingle: {}is a validation error: askcount: { gt: 0 },count: { eq: 0 }orcount: { eq: 1 }. sorttakes one field per item. Nulls sort last ascending and first descending, as in Cypher. A list sorts by the fields you ask for; without asortit comes in@keyorder.- Every list is bounded:
limitdefaults to@limit(default:)(globally 25) and more thanmax(globally 100) is aLIMIT_EXCEEDEDerror.
Connections
query ($cursor: String) {
festivalsConnection(first: 10, after: $cursor, sort: [{ capacity: DESC }]) {
totalCount
aggregate {
count
node {
capacity {
max
avg
}
}
}
edges {
cursor
node {
key
name
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
- Connections page forward with
first/afterand backward withlast/before. One cursor works in both directions. - Paging is keyset, never
SKIP. A connection always ends its sort on a unique, non-null field (the@key, or an earlier required@uniquefield), so cursors stay stable while rows are inserted. - A cursor is tagged with its sort. Replaying it under another sort is an
INVALID_CURSORerror. With thecursorSecretoption cursors are also signed, and any cursor the server did not issue is rejected. pageInfohashasNextPage,hasPreviousPage,startCursorandendCursor.totalCountandaggregatecover every match, not only the page. With themaxTotalCountoption a root connection'stotalCountstops counting there ("1000 or more"), which bounds its cost on a large label. Selecting only them reads no page.- List relationships to a node type get a connection too
(
followersConnection). When the relationship has properties, its edges carry them (properties { since }), it can be filtered withwhere: { node, edge }, and sorted by@sortablerelationship properties withsort: [{ edge: { since: DESC } }]. Relationship properties have no index, so an edge sort sorts per parent, bounded by the page. See reading relationships for exactly what each declaration gets.
Aggregates
With @query(aggregate: true):
{
festivalsAggregate(where: { capacity: { gte: 1000 } }) {
count
capacity {
min
max
avg
sum
}
name {
shortest
longest
}
}
festivalsGrouped(by: [kind], limit: 20) {
by {
kind
}
aggregate {
count
capacity {
sum
}
}
}
}
<plural>Aggregateand<plural>Groupedcover the@key, the@sortablefields and duration fields. A connection'saggregate.nodecovers every readable, non-list field except booleans, enums and points.@selectable(onAggregate: false)removes a field from both.- Numbers get
min,max,avgandsum; strings and ids getmin,max,shortestandlongest; durations getmin,max,sumandavg; temporals getminandmax. <plural>Grouped(by:)groups by@groupByfields and returns at mostlimitgroups, ordered by the group values.- Relationship filters take an
aggregateover related nodes and edge properties, includingshortestLength,longestLengthandaverageLengthfor strings. - A relationship connection has an
aggregatewhen its target type has@query(aggregate: true). It countscount { nodes edges }, which differ when several relationships lead to the same node, and aggregates relationship properties underedge. Such a connection has its own types,<Owner><Field>Connectionand<Owner><Field>Edge, in place of the target's<Type>Connectionand<Type>Edge. @relationship(aggregate: false)drops a relationship's aggregates and aggregate filter.
Search
type Event @node @fulltext(indexes: [{ fields: ["title", "summary"] }]) {
key: String! @key
title: String!
summary: String
city: String @filterable
startsOn: Date @sortable
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
}
}
searchEventsConnection(query: "techno", first: 10) {
edges {
cursor
score
node {
key
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
- Full-text queries AND their terms, fold case and accents, and treat a
trailing
*as a prefix. A[String!]field in@fulltextindexes each of its strings. - Vector search takes exactly one of a query
vector(of the declared dimensions) andto, the key of a node whose embedding to start from, which is left out of the results. Anything else isBAD_USER_INPUT. - A vector index returns its top candidates before
whereapplies. With a filter, the library asks for four times the page, so a very selective filter can return fewer rows thanlimiteven when more matches exist. - Results are
<Type>Match { score node }, and connection edges carrycursor,scoreandnode. - Search never reaches a value the reader may not read. A
@fulltextindex over a masked or READ-guarded field is a model error. A vector search needs the vector field's@authenticationand ranks only nodes that pass its READ rules, the anchor oftoincluded. searchEventsConnectionandsimilarEventsConnectionpage forward withfirstandafter. Search connections page by keyset on score and key. Vector connections page within4 × @limit(max:)candidates.- Search connections take
totalCount: every match afterwhereand the read rules, whatever the page (a vector search counts within its candidate window). A selection of onlytotalCountreads no page. - A type with
@sortablefields gives its search rootssort, the same<Type>Sortas its lists. A sort replaces score order: the matches come by its fields, then the key, a connection pages by keyset on them, andscoreis still selectable.totalCountdoes not change. A cursor carries its order, so one from a page in score order isINVALID_CURSORfor a page insortorder and the other way round.
{
searchEventsConnection(query: "techno", sort: [{ startsOn: ASC }], first: 10) {
edges {
cursor
score
node {
key
startsOn
}
}
}
}
- Read rules apply to every result. Both index kinds are created by
assertSchema({ create: true }).
Interfaces and unions
Interfaces and unions range over @node types. An interface's
@filterable and @sortable fields apply to every implementation, and so
does index inference. A root list or relationship 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.
{
events(where: { title: { contains: "Rock" }, typename: [Concert] }) {
__typename
key
... on Concert {
band
}
}
}
An interface where takes the interface's fields plus typename, a list
of <Interface>Implementation enum values that also works inside AND,
OR and NOT. A union
where takes one filter per member, and once any member is named, members
not named are left out. An interface field with @declareRelationship can
be selected at interface level.
A relationship to an interface or union connects, creates and disconnects
per member: events: { connect: { Concert: [{ key: "c1" }] } }. A single
relationship to a union holds one node across all members.
Interface and union connections
<plural>Connection pages an interface or union with cursors, like a
@node type's connection. It takes where, sort, first/after and
last/before, and totalCount counts every match across the members:
{
eventsConnection(sort: [{ starts: ASC }], first: 2) {
totalCount
pageInfo {
hasNextPage
endCursor
}
edges {
node {
__typename
key
title
... on Concert {
band
}
... on Exhibition {
artist
}
}
}
}
}
Over two concerts and two exhibitions:
{
"eventsConnection": {
"totalCount": 4,
"pageInfo": {
"hasNextPage": true,
"endCursor": "eyJzIjoiRXZlbnQ6LHN0YXJ0czpBU0MiLCJ2IjpbMTEsIkNvbmNlcnQiLCJjMiJdfQ"
},
"edges": [
{
"node": {
"__typename": "Exhibition",
"key": "e1",
"title": "Modern art",
"artist": "Ann"
}
},
{
"node": {
"__typename": "Concert",
"key": "c2",
"title": "Jazz brunch",
"band": "Trio"
}
}
]
}
}
The order is the list root's: the requested fields, then the type name,
then the key. A cursor holds those values, so after: endCursor continues
with e2 and c1. Each member reads its own page past the cursor, sorted
and limited, with its own indexes; the pages are merged and cut to
first. totalCount adds up one count per member, and maxTotalCount
caps the sum.
Mutations
Mutations exist only for types with @mutation, and they address nodes by
@key, so every write-set is exact.
mutation {
createFestivals(
input: [
{
name: "Sunland"
genre: { connect: { key: "techno" } }
followers: { connect: [{ key: "u1", edge: { since: 2020 } }] }
}
]
) {
festivals {
key
name
}
info {
nodesCreated
relationshipsCreated
}
}
updateFestival(
key: "f1"
update: {
capacity: null
followers: {
disconnect: ["u2"]
update: [{ key: "u1", edge: { since: 2021 } }]
}
}
adjust: { capacity: { add: 100 } }
) {
festival {
key
}
}
deleteFestival(key: "f2") {
nodesDeleted
relationshipsDeleted
}
}
Nested writes
- Relationship inputs offer
connect,create,disconnect,update(connected nodes and relationship properties, in place) anddelete. Create and upsert inputs offerconnectandcreateonly.@relationship(nestedOperations:)trims the list. Nested writes follow the target type's own@mutation:createneeds itsCREATE,deleteitsDELETE, and updating the connected node itsUPDATE(relationship properties can always be updated). - Connect is by key. A missing or invisible target is
NOT_FOUND, not a silent no-op. Connecting an already connected pair keeps one relationship, sets the properties given and keeps the others. - Disconnect is by key too. A key that is not connected is ignored.
- Connecting a single relationship replaces the current one.
- A nested
deletetakes awhereand alimit(defaultmaxBatch); more matches isLIMIT_EXCEEDED. A single relationship takesdelete: true.
The relationships page has the input shapes for list, single and interface relationships.
Updates and adjust
updatesets fields;nullremoves a property.@keyis not updatable.adjustapplies one operator per field to the stored value atomically, onupdateFestivalandupdateFestivals.IntandFloattakeadd,subtract,multiplyanddivide(Intdivision stays an integer);BigInttakesaddandsubtract; lists takepush,pop(a count from the end) andremove(every occurrence). A missing number counts as 0 and a missing list as empty. Dividing by zero, a negativepop, and setting and adjusting the same field areBAD_USER_INPUT.updateFestival(key:)for a key that does not exist, or that the caller cannot read, returnsfestival: null, anddeleteFestival(key:)returnsnodesDeleted: 0. Neither is an error.
Upsert
upsertFestivals(input: [...]) creates the inputs whose key is new and
updates the rest. Fields required on create are required only for new keys.
The same key twice in one input is CONSTRAINT_VIOLATION, and for a key
that exists, fields settable only on create are left alone.
Upsert never updates a node the caller cannot read, even where its
UPDATE rules would allow the update. Creating under a key
held by such a node, by create…, upsert… or a nested create, answers
as if the key were free: the same error a free key gets, or FORBIDDEN
where a free key would be created. It is never reported as taken, so a
mutation cannot be used to find out whether a hidden node exists. A key
held by a node the caller can read is reported as taken.
A row that would change nothing is not written. Its fields already hold
the values given (a null already absent), and each connect is already
there with the edge properties given; a single relationship holds no other
target. No SET and no relationship work run for it, it does not count
in nodesUpdated, and onWrite, subscriptions and the response cache see
no write; it is still in the payload. Re-running a seed over a seeded
graph costs a read per row. A row is compared only when the comparison
can stand in for the write: its fields readable by any reader of the node
(not write-only, masked or behind a field READ rule, so a guess at a
hidden value is always written), its relationship inputs plain connects
to a concrete type without relationship rules, and no @populatedBy on
update. Any other row is written as before, and the type's UPDATE rules
still hold on an unchanged row. An @timestamp on update is not bumped
for a row that changed nothing.
Bulk updates and deletes
updateFestivals(where:, limit:) and deleteFestivals(where:, limit:)
resolve the keys where matches under the authorization filter, then run
the keyed path, so their write-sets are exact too.
whereis required, and an emptywhereis refused.- More matches than
limit(defaultmaxBatch, 1000) is an error that writes nothing, never a partial write. AlimitabovemaxBatchisLIMIT_EXCEEDED. - A bulk update changes properties only. A relationship in its
updateisBAD_USER_INPUT: change relationships withupdateFestival, one node at a time.
What every mutation guarantees
Before a mutation opens its transaction, its input is checked against
the fields' validation rules
(@length, @pattern, @format, @range, @multipleOf, @size). A
bad value is BAD_USER_INPUT with
every violation listed, and
nothing runs.
Each mutation then runs in one interactive transaction and checks, before it commits, that:
- 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); @authorizationvalidate rules hold, type and field level (FORBIDDEN);- a value computed during the write, by
adjustor a@populatedBycallback, still satisfies the field's validation rules (CONSTRAINT_VIOLATION).
Any failure rolls the whole mutation back.
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, edge updates) count up to ten times maxBatch. Going
over is LIMIT_EXCEEDED, before the writes that would exceed it. Larger
imports belong in a Cypher load, not a GraphQL request.
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. With mutationTransaction: "operation", execute()
(persisted operations included) runs every root field of a mutation in one
transaction, committed only when the operation reports no error; otherwise
it rolls back and returns data: null. GraphQL Yoga and other Envelop
servers on getSchema() get the same from lora.envelopPlugin(). With
another server, 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.
info reports nodesCreated, nodesUpdated, nodesDeleted,
relationshipsCreated and relationshipsDeleted.
A mutation waits for LoraDB's single writer lock. If it cannot get it
within timeoutMs, or before the context's signal aborts, it fails with
DATABASE_ERROR and writes nothing.
Deletes follow @relationship(onDelete:): DETACH (default) removes the
relationships, CASCADE deletes what the field reaches (checking the
caller may delete each node), RESTRICT refuses while related nodes
remain.
Subscriptions
A type with @subscription gets <type>Changed. The event carries the
node's key under the name of its @key field, key here and slug for
a type keyed by slug:
subscription {
festivalChanged(
operations: [CREATE, UPDATE]
where: { capacity: { gte: 1000 } }
) {
operation
key
timestamp
node {
name
capacity
}
}
}
- By default events come from the write-sets of mutations made through
this
LoraGraphQLinstance:@cyphermutations have no write-set, and writes made elsewhere are not seen. WithchangeFeed: true(lora-node) events come from the engine's committed change feed instead: every committed write to that database, whichever path in the owning process made it (raw Cypher,@cyphermutations, other instances), in commit order.previousStateneeds library writes (the feed carries the state after the write). - A node that gained or lost a relationship is an
UPDATE. With@subscription(relationships: true)the type also sendsCONNECTandDISCONNECTevents withrelationship { field type relatedType relatedKey }. - With
@subscription(previousState: true),UPDATEandDELETEevents carrypreviousState, the stored values before the write, at the cost of one read per write. wheretests the node as it is after the write.nodeis read when the event is delivered, through the normal read path, so read rules apply.- A write sends one event per node, the most specific one:
DELETE, elseCREATE, elseUPDATE.nodeisnullon aDELETEevent. - Events for nodes the subscriber cannot read are dropped.
- A deleted node cannot be read back, so
DELETEevents have two limits. A subscription with awheregets none, since there is nothing left to test. And on a type with READ or SUBSCRIBE rules, a deletion goes only to subscribers following thatkey: the rules are checked inside the deleting transaction, on the node as it was. WithchangeFeed: truethere is no such transaction, so only followers whose claims alone settle the rules get it. CONNECTandDISCONNECTevents are sent only when the subscriber may read the related node and the relationship field.previousStateholds readable scalar fields only: no private, write-only or vector fields, and none with a field-levelvalidaterule. Field@authenticationapplies, and a mask that depends on the node reads as masked.@authentication(operations: [SUBSCRIBE])guards the subscription itself, and so does the type's READ@authentication.filterandvalidaterules that listSUBSCRIBEapply to events on top of the READ rules.- A type with
@query(read: false)has no subscription. - Subscribers whose checks compile to the same statement share it: twenty
subscribers with the same
whereand claims cost one visibility query and one node read per write. - A subscriber that falls
maxQueuedChanges(default 1000) behind is ended with an error rather than buffered without bound. Pass asignalin the context to end the stream with the request.
Subscriptions do work on every write, so they are bounded:
| Limit | Default | Effect |
|---|---|---|
maxSubscriptions | 100 | Live subscriptions per scope. The scope is the context object unless subscriptionScope returns the connection or user. One more is LIMIT_EXCEEDED |
maxSubscriptionFilterDepth | 1 | Relationship levels in a subscription's where. Deeper is LIMIT_EXCEEDED |
maxCost / budget | 50,000 | The where is costed once, when the subscription starts. Each event's selection is costed on its own |
subscriptionTimeoutMs | 2000 | Per statement run to check a change. A check that fails or times out ends that subscription with the error |
The transport is your server's: GraphQL Yoga serves subscriptions over
server-sent events out of the box, and graphql-ws works with any server.
Transactions
Pass a transaction from lora.begin() in the context and every operation
of the request runs in it, next to your own Cypher:
const tx = await lora.begin();
try {
await lora.execute({ source: createOrder, context: { jwt, transaction: tx } });
await tx.execute("MATCH (s:Stock {sku: $sku}) SET s.count = s.count - 1", { sku });
tx.touched({ entities: [{ type: "Stock", key: sku }] });
await tx.commit(); // change events fire now
} catch (err) {
await tx.rollback();
throw err;
}
Reads see the transaction's writes, and change events wait for the commit.
Your own Cypher through tx.execute() is not part of any write-set: say
what it wrote with tx.touched() (node types, relationship types, nodes
by key, or nothing for "everything changed") and the commit reports it
like a mutation's, so a response cache drops the reads it changed.
@cypher mutations run in the transaction too, and their event waits
for the commit.
A failed mutation rolls the whole transaction back, including your own
statements. The transaction holds LoraDB's writer lock from begin()
until it ends, so every other write waits for it: keep it short, and
never hold one across a network call.
Errors
Errors carry extensions.code: BAD_USER_INPUT, INVALID_CURSOR,
LIMIT_EXCEEDED, COST_EXCEEDED, TIMEOUT, UNAUTHENTICATED,
FORBIDDEN, NOT_FOUND, CONSTRAINT_VIOLATION (with type and field)
and DATABASE_ERROR (with an id). A document over a guard limit is
LIMIT_EXCEEDED, introspection while it is off is FORBIDDEN, and an ad
hoc document under persistedOnly is PERSISTED_QUERY_ONLY. See
errors for each code, and
authorization for how
database errors are masked in production.