Skip to main content

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 @mutation and @subscription.
  • A field is filterable only with @filterable, by the operators listed, and sortable only with @sortable. The @key field is always filterable by eq and in and always sortable. The one exception is the relationship aggregate filter, which covers every readable field of the related type.

Root fields​

FieldDoes
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 searchFestivalsConnectionFull-text search, with @fulltext
similarFestivals(vector or to, where, limit) and similarFestivalsConnectionVector 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, upsertFestivalsWith @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, OR and NOT.
  • List relationships take some, all, none, single, count and aggregate. Single relationships take the target's filter directly, plus <field>Exists: true | false.
  • On a list relationship with relationship properties, followersConnection in a where quantifies over the related node and the relationship properties together: followersConnection: { some: { edge: { since: { gte: 2020 } } } }.
  • A where may nest through at most maxFilterDepth relationship levels (default 2). The relationships page has every relationship filter.
  • Absent and null filters are left out of the statement, so a filter bound to an unset variable costs nothing. eq: null does not mean "is null": use isNull: true, where the field offers IS_NULL.
  • An OR branch left empty that way is left out of the OR, and an OR or NOT with nothing left is left out entirely: an unset variable never widens a filter to every row. With OR: [{ region: { in: $r } }, { country: { in: $c } }] and only $r set, the filter is region IN $r. A literal OR: [] still matches nothing.
  • count takes eq, lt, lte, gt and gte. count and single count related nodes once each, however many relationships lead to them. all holds 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: {} or single: {} is a validation error: ask count: { gt: 0 }, count: { eq: 0 } or count: { eq: 1 }.
  • sort takes one field per item. Nulls sort last ascending and first descending, as in Cypher. A list sorts by the fields you ask for; without a sort it comes in @key order.
  • Every list is bounded: limit defaults to @limit(default:) (globally 25) and more than max (globally 100) is a LIMIT_EXCEEDED error.

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 / after and backward with last / 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 @unique field), so cursors stay stable while rows are inserted.
  • A cursor is tagged with its sort. Replaying it under another sort is an INVALID_CURSOR error. With the cursorSecret option cursors are also signed, and any cursor the server did not issue is rejected.
  • pageInfo has hasNextPage, hasPreviousPage, startCursor and endCursor.
  • totalCount and aggregate cover every match, not only the page. With the maxTotalCount option a root connection's totalCount stops 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 with where: { node, edge }, and sorted by @sortable relationship properties with sort: [{ 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>Aggregate and <plural>Grouped cover the @key, the @sortable fields and duration fields. A connection's aggregate.node covers every readable, non-list field except booleans, enums and points. @selectable(onAggregate: false) removes a field from both.
  • Numbers get min, max, avg and sum; strings and ids get min, max, shortest and longest; durations get min, max, sum and avg; temporals get min and max.
  • <plural>Grouped(by:) groups by @groupBy fields and returns at most limit groups, ordered by the group values.
  • Relationship filters take an aggregate over related nodes and edge properties, including shortestLength, longestLength and averageLength for strings.
  • A relationship connection has an aggregate when its target type has @query(aggregate: true). It counts count { nodes edges }, which differ when several relationships lead to the same node, and aggregates relationship properties under edge. Such a connection has its own types, <Owner><Field>Connection and <Owner><Field>Edge, in place of the target's <Type>Connection and <Type>Edge.
  • @relationship(aggregate: false) drops a relationship's aggregates and aggregate filter.
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 @fulltext indexes each of its strings.
  • Vector search takes exactly one of a query vector (of the declared dimensions) and to, the key of a node whose embedding to start from, which is left out of the results. Anything else is BAD_USER_INPUT.
  • A vector index returns its top candidates before where applies. With a filter, the library asks for four times the page, so a very selective filter can return fewer rows than limit even when more matches exist.
  • Results are <Type>Match { score node }, and connection edges carry cursor, score and node.
  • Search never reaches a value the reader may not read. A @fulltext index over a masked or READ-guarded field is a model error. A vector search needs the vector field's @authentication and ranks only nodes that pass its READ rules, the anchor of to included.
  • searchEventsConnection and similarEventsConnection page forward with first and after. Search connections page by keyset on score and key. Vector connections page within 4 × @limit(max:) candidates.
  • Search connections take totalCount: every match after where and the read rules, whatever the page (a vector search counts within its candidate window). A selection of only totalCount reads no page.
  • A type with @sortable fields gives its search roots sort, the same <Type>Sort as its lists. A sort replaces score order: the matches come by its fields, then the key, a connection pages by keyset on them, and score is still selectable. totalCount does not change. A cursor carries its order, so one from a page in score order is INVALID_CURSOR for a page in sort order 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) and delete. Create and upsert inputs offer connect and create only. @relationship(nestedOperations:) trims the list. Nested writes follow the target type's own @mutation: create needs its CREATE, delete its DELETE, and updating the connected node its UPDATE (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 delete takes a where and a limit (default maxBatch); more matches is LIMIT_EXCEEDED. A single relationship takes delete: true.

The relationships page has the input shapes for list, single and interface relationships.

Updates and adjust​

  • update sets fields; null removes a property. @key is not updatable.
  • adjust applies one operator per field to the stored value atomically, on updateFestival and updateFestivals. Int and Float take add, subtract, multiply and divide (Int division stays an integer); BigInt takes add and subtract; lists take push, pop (a count from the end) and remove (every occurrence). A missing number counts as 0 and a missing list as empty. Dividing by zero, a negative pop, and setting and adjusting the same field are BAD_USER_INPUT.
  • updateFestival(key:) for a key that does not exist, or that the caller cannot read, returns festival: null, and deleteFestival(key:) returns nodesDeleted: 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.

  • where is required, and an empty where is refused.
  • More matches than limit (default maxBatch, 1000) is an error that writes nothing, never a partial write. A limit above maxBatch is LIMIT_EXCEEDED.
  • A bulk update changes properties only. A relationship in its update is BAD_USER_INPUT: change relationships with updateFestival, 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 connect target 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 @uniqueTogether holds (CONSTRAINT_VIOLATION);
  • @authorization validate rules hold, type and field level (FORBIDDEN);
  • a value computed during the write, by adjust or a @populatedBy callback, 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 LoraGraphQL instance: @cypher mutations have no write-set, and writes made elsewhere are not seen. With changeFeed: 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, @cypher mutations, other instances), in commit order. previousState needs 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 sends CONNECT and DISCONNECT events with relationship { field type relatedType relatedKey }.
  • With @subscription(previousState: true), UPDATE and DELETE events carry previousState, the stored values before the write, at the cost of one read per write.
  • where tests the node as it is after the write. node is 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, else CREATE, else UPDATE. node is null on a DELETE event.
  • Events for nodes the subscriber cannot read are dropped.
  • A deleted node cannot be read back, so DELETE events have two limits. A subscription with a where gets none, since there is nothing left to test. And on a type with READ or SUBSCRIBE rules, a deletion goes only to subscribers following that key: the rules are checked inside the deleting transaction, on the node as it was. With changeFeed: true there is no such transaction, so only followers whose claims alone settle the rules get it.
  • CONNECT and DISCONNECT events are sent only when the subscriber may read the related node and the relationship field.
  • previousState holds readable scalar fields only: no private, write-only or vector fields, and none with a field-level validate rule. Field @authentication applies, 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. filter and validate rules that list SUBSCRIBE apply 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 where and 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 a signal in the context to end the stream with the request.

Subscriptions do work on every write, so they are bounded:

LimitDefaultEffect
maxSubscriptions100Live subscriptions per scope. The scope is the context object unless subscriptionScope returns the connection or user. One more is LIMIT_EXCEEDED
maxSubscriptionFilterDepth1Relationship levels in a subscription's where. Deeper is LIMIT_EXCEEDED
maxCost / budget50,000The where is costed once, when the subscription starts. Each event's selection is costed on its own
subscriptionTimeoutMs2000Per 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.