GraphQL for LoraDB
@loradb/lora-graphql is a schema-first GraphQL layer for LoraDB. You write
one annotated SDL that describes both the graph (labels, keys,
relationships) and the public API (which types are readable, writable,
filterable and sortable). The library turns it into an executable
graphql-js schema whose operations compile to parameterised Cypher
statements.
It also reasons about those statements. It derives the constraints and
indexes they need, checks with explain() that the plans use them, bounds
their cost before they run, compiles authorization rules into them, and
reports exactly what every mutation wrote. The
smart layer page covers each of these.
The generated API is deliberately small. Reads are on by default; writes and subscriptions are opt in per type; a field is filterable or sortable only when you say so. See limits and scaling before you plan a migration.
Find your way
| You want to | Read |
|---|---|
| Learn it by building something | The tutorial: a blog API from an empty directory |
| Get a server running | This page, then serving the schema |
| Model data you know from SQL | Many-to-many relationships, then relationships |
| Look up a directive | Directive reference |
| Validate what clients send | Validation directives |
| See what clients can query and write | Queries and mutations by example, then the generated API |
| Write a query the generator does not cover | @cypher fields |
| Sign callers in | Authentication |
| Decide who may see and change what | Authorization recipes, then the reference |
| Gate a schema in CI | The CLI and testing |
| Go to production | Limits and scaling and observability |
Move from @neo4j/graphql | Migrating |
Install
npm install @loradb/lora-graphql @loradb/lora-node graphql
graphql(16 or 17) is a peer dependency, so the server and the library share one instance.@loradb/lora-nodeis released in lockstep: version X.Y.Z of@loradb/lora-graphqlis tested against@loradb/lora-nodeX.Y.Z. Upgrade both together.- Mutations need interactive transactions, which only the Node binding
has.
@loradb/lora-wasmcan serve reads.
Describe the graph and the API
type Festival @node @mutation @query(aggregate: true) {
key: ID! @key(generate: true)
name: String! @filterable(byValue: [EQ, CONTAINS]) @sortable
capacity: Int @filterable(byValue: [GTE, LT]) @sortable
createdAt: DateTime @timestamp(operations: [CREATE])
genre: Genre @relationship(type: "IN_GENRE", direction: OUT) @filterable
followers: [User!]!
@relationship(type: "FOLLOWS", direction: IN, properties: "Follows")
@filterable
}
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)
}
A few things to notice:
@nodemakes a type a node label. Its@keyfield is required, unique and immutable: it is the address of every lookup, update and delete, the last sort key of every connection, and the anchor of every cursor.@mutationopts the type into generated mutations.Genrehas none, so clients can read genres and connect a festival to an existing one, but not create, update or delete genres.@filterable(byValue: [...])and@sortabledecide what clients may filter and sort by. Nothing else is filterable.@query(aggregate: true)adds aggregate root fields.
Festival.followers and its Follows properties are a many-to-many: the
relationship plays the part a join table plays in SQL.
Many-to-many relationships walks through
that from the relational side.
The directive reference lists every directive, and the generated API shows what each type gets.
Create the schema object
import { readFile } from "node:fs/promises";
import { createDatabase } from "@loradb/lora-node";
import { LoraGraphQL, loraDriver } from "@loradb/lora-graphql";
const typeDefs = await readFile("schema.graphql", "utf8");
const db = await createDatabase();
const lora = new LoraGraphQL({
typeDefs,
driver: loraDriver(db),
});
new LoraGraphQL validates the SDL. An invalid model throws one
ModelError that lists every problem, each located by type and field.
loraDriver(db) adapts a Database from @loradb/lora-node or
@loradb/lora-wasm.
Create the indexes the API needs
const report = await lora.assertSchema({ create: true });
console.log(report.created); // what was missing and is now created
The library infers every constraint and index from the API: a node key
constraint for @key, a RANGE index for @sortable and range filters, a
TEXT index for CONTAINS, and so on. assertSchema() without options only
reports what is missing; with create: true it creates it, idempotently.
Run it at startup, or print the DDL in CI with
lora-graphql requirements schema.graphql --ddl and apply it yourself.
Serve the schema
lora.getSchema() returns a standard GraphQLSchema, so any graphql-js
server can serve it. The request context carries the caller's verified
claims as jwt, and an optional signal that cancels the request's
statements:
import { createYoga } from "graphql-yoga";
import { createServer } from "node:http";
const yoga = createYoga({
schema: lora.getSchema(),
plugins: [lora.envelopPlugin()], // document guards: depth, aliases, tokens
context: async ({ request }) => ({
jwt: await verifiedClaims(request), // your JWT verification
signal: request.signal,
}),
});
createServer(yoga).listen(4000);
The library never verifies tokens. verifiedClaims stands for your own
verification (for example jose's jwtVerify): put only verified claims in
the context. Authentication has that
function in full, and authorization the
other production settings.
lora.envelopPlugin() brings the document guards (depth, aliases, root
fields, lexer tokens, introspection) to GraphQL Yoga and other Envelop
servers. For servers without Envelop, use lora.validationRules() and the
exported parseOptions(). The package ships runnable
examples
for GraphQL Yoga, Apollo Server and graphql-http, and
serving the schema covers each server in detail.
Run a query
{
festivalsConnection(
first: 10
where: { capacity: { gte: 1000 } }
sort: [{ capacity: DESC }]
) {
totalCount
edges {
cursor
node {
key
name
genre {
name
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
Every list is bounded (25 by default, at most 100) and connections page by
keyset cursors. The root field compiles to one Cypher statement; a
connection that also asks for totalCount or aggregate adds a second
one, run in the same read transaction.
Execute and persisted operations
Besides getSchema(), the library can execute documents itself:
const result = await lora.execute({
source: "{ festivals(limit: 5) { key name } }",
variables: {},
context: { jwt },
});
execute() applies the document guards, caches parsed and validated
documents by source text, and returns the operation's cost estimate in
extensions.cost.
For first-party clients, register the operations they use at startup:
lora.persist({
topFestivals: "query ($n: Int) { festivals(limit: $n) { key name } }",
});
await lora.execute({ id: "topFestivals", variables: { n: 5 }, context });
persist() parses and validates every document once, so a broken one fails
at startup. At request time an id is looked up and executed without parsing
or validation. With persistedOnly: true, execute() refuses ad hoc
documents altogether with a PERSISTED_QUERY_ONLY error.
Subscriptions, persisted or not, run with lora.subscribe(), which takes
the same arguments and returns an async iterable of results; see
serving.
lora-graphql compile does the validation at build time instead: it writes
a manifest.json for lora.loadManifest() and an operations.d.ts with
typed variables and results. Operations read from .graphql files get ids
of the form <file path>#<OperationName>. See
typed tooling.
Two caveats:
- The Cypher text is not precompiled. It depends on variable values (absent
filters are left out) and on the caller's claims (claim checks are
folded into the statement), so it is compiled per request and cached per
field on what the compile read: the variables the field uses, the claims
and
$contextvalues it looked up. A claim used only as a rule's filter value ("$jwt.sub") is bound as a parameter, so different users share one compile. - Servers that parse every request themselves produce new document nodes
each time and do not benefit from the per-field compile cache. Use
execute(), persisted operations, or a server with a document cache.
Check it in CI
lora-graphql check schema.graphql --operations src/operations
check builds an in-memory database, asserts the schema, plans every
@cypher statement and every query in your operation files, and exits
non-zero on any finding: a label scan where a seek was expected, a
statement the engine rejects, a missing index. It needs @loradb/lora-node
installed.
What is not supported
The short list. Limits and scaling has the full one, with every bound and its default.
- Offset pagination. Connections use keyset cursors only.
updateanddeletewith an optional, unboundedwhere. Bulk writes need awhereand are bounded bylimit.- Every operation on every type and every filter on every field. Opt in per type and per field.
- JWT verification. Your server verifies the token.
- Federation.
- Subscriptions over writes made outside the library, by default. With
changeFeed: true(lora-node) they see every committed write made in the owning process. A database directory is open in one process at a time, so there are no cross-process subscriptions. See change tracking.
Next
Start
Model
The API
Access control
Operate
Reference