Serving the schema
@loradb/lora-graphql is not a server. It gives you a standard
GraphQLSchema and an execute() helper, and your server does the HTTP,
the WebSocket and the token verification. This page shows the three
things every server needs (the context, the document guards and
cancellation) and how to wire them into the common servers.
The package ships runnable versions of these servers in
examples/.
Two ways to run operations
lora.getSchema() | lora.execute() / lora.subscribe() | |
|---|---|---|
| Use with | Any graphql-js server | Your own request handler |
| Document guards | Wire them in (see below) | Applied |
| Parsed document cache | The server's, if any | Built in, by source text (500 documents) |
| Persisted operations | The server's mechanism | persist(), loadManifest(), persistedOnly |
Cost in extensions.cost, timing in extensions.timing | No | Yes |
Variable errors coded BAD_USER_INPUT | No | Yes |
| Repeated errors collapsed | With lora.envelopPlugin() | Yes |
mutationTransaction: "operation" | With lora.envelopPlugin(), or a transaction in the context | Yes |
Read-sets for cache invalidation (readSet: true) | No | Yes |
| Subscriptions | Yes, through the server's subscribe | subscribe(); execute() runs queries and mutations |
Both run the same resolvers, so authorization, limits, cost checks and change tracking behave the same either way.
The request context
Every operation reads these keys from the GraphQL context value:
| Key | Type | Meaning |
|---|---|---|
jwt | object | The caller's verified claims. Absent means anonymous |
signal | AbortSignal | Cancels the request's statements, and ends its subscriptions, when aborted |
transaction | LoraTransaction | Run every operation of the request in this transaction, from lora.begin() |
Any other key is yours. Authorization rules can read it with
"$context.path" strings, for example a tenant id your server resolved
from the host name. See authorization.
If your claims live somewhere other than context.jwt, tell the library
where:
const lora = new LoraGraphQL({
typeDefs,
driver: loraDriver(db),
jwt: (context) => (context as { auth?: { claims?: Record<string, unknown> } }).auth?.claims,
});
The library never verifies a token. Put only claims your server has
verified (signature, issuer, audience, expiry) in the context. Copying
decoded but unverified claims into jwt grants whatever they say.
Document guards
The document guards bound the document before anything is compiled:
nesting depth (12), aliases (30), root fields (20), lexer tokens (5000),
and introspection (off when NODE_ENV is production). execute(),
subscribe() and persist() apply them. Every other server needs them wired in, and the
table shows how:
| Server | Wiring |
|---|---|
| GraphQL Yoga, other Envelop servers | plugins: [lora.envelopPlugin()] |
| Apollo Server | validationRules: lora.validationRules(), parseOptions: parseOptions(guards) |
| graphql-http | validationRules: lora.validationRules(), parse: (s) => parse(s, parseOptions(guards)) |
Plain graphql-js | validate(schema, doc, [...specifiedRules, ...lora.validationRules()]), parse(source, parseOptions(guards)) |
lora.validationRules() and lora.envelopPlugin() use the guards
option you passed to new LoraGraphQL. parseOptions(guards) is a
standalone export: pass it the same guards object (or nothing, for the
defaults). With guards: false, validationRules() returns no rules and
envelopPlugin() applies no limits.
A depth, alias or root field failure is LIMIT_EXCEEDED, and a blocked
introspection query is FORBIDDEN. The token limit stops the parser, so a
document over it comes back as a syntax error without a code.
lora.envelopPlugin() also checks, once, that the server executes with
the same copy of graphql the library loaded, and logs an error when it
does not. Two copies in node_modules make schema checks fail in ways
that are hard to trace; npm ls graphql shows them.
Cancellation and timeouts
Every statement runs under timeoutMs (default 10 000 ms). Put an
AbortSignal in the context too, so a client that disconnects stops its
statements instead of letting them run to the timeout:
- GraphQL Yoga:
signal: request.signal. - Node
httpservers: create anAbortControllerand abort it on the response'scloseevent, as in the examples below.
The signal also ends the request's subscription streams. It need not be
Node's own AbortSignal: any object with aborted, reason and abort
events works, such as whatwg-node's request signal that GraphQL Yoga
passes when it is served over node:http.
Two more limits keep one request from holding the database:
operationTimeoutMs(default twicetimeoutMs) is a deadline for all root fields of a query together. Past it, running statements are aborted and unfinished fields fail withTIMEOUT. Without it, twenty aliased root fields could run for twenty timestimeoutMs.maxConcurrentStatements(default 2) is how many statements one operation runs at once. Reads run on Node's worker pool, four threads by default; this keeps one request's aliases from taking all of them.
A write also waits for LoraDB's single writer lock, for at most
timeoutMs, and then fails with DATABASE_ERROR.
GraphQL Yoga
A complete, tested project with each of the servers below is in
examples/tutorial,
and the tutorial builds it step by step.
import { createServer } from "node:http";
import { createYoga } from "graphql-yoga";
const yoga = createYoga({
schema: lora.getSchema(),
plugins: [lora.envelopPlugin()],
context: async ({ request }) => ({
jwt: await verifiedClaims(request),
signal: request.signal,
}),
});
createServer(yoga).listen(4000);
Yoga serves subscriptions over server-sent events with no extra setup.
Apollo Server
import { ApolloServer } from "@apollo/server";
import { startStandaloneServer } from "@apollo/server/standalone";
import { parseOptions, type LoraGraphQLContext } from "@loradb/lora-graphql";
const server = new ApolloServer<LoraGraphQLContext>({
schema: lora.getSchema(),
validationRules: lora.validationRules(),
parseOptions: parseOptions(),
});
await startStandaloneServer(server, {
listen: { port: 4000 },
context: async ({ req, res }) => {
const controller = new AbortController();
res.once("close", () => controller.abort());
const jwt = await verifiedClaims(req.headers.authorization);
return { ...(jwt ? { jwt } : {}), signal: controller.signal };
},
});
Apollo Server serves on /, not /graphql.
graphql-http
import { createServer } from "node:http";
import { parse } from "graphql";
import { createHandler } from "graphql-http/lib/use/http";
import { parseOptions } from "@loradb/lora-graphql";
const handler = createHandler({
schema: lora.getSchema(),
parse: (source) => parse(source, parseOptions()),
validationRules: lora.validationRules(), // appended to the standard rules
context: async (req) => {
const controller = new AbortController();
req.context.res.once("close", () => controller.abort());
const jwt = await verifiedClaims(req.raw.headers.authorization);
return { ...(jwt ? { jwt } : {}), signal: controller.signal };
},
});
createServer((req, res) => {
if (req.url?.startsWith("/graphql")) handler(req, res);
else res.writeHead(404).end();
}).listen(4000);
graphql-http does not serve subscriptions.
Your own handler with execute()
execute() is the shortest path when you want persisted operations only,
or when you already have an HTTP layer:
import { createServer } from "node:http";
createServer(async (req, res) => {
const body = JSON.parse(await readBody(req)); // { id, variables } or { query, variables }
const controller = new AbortController();
res.once("close", () => controller.abort());
const result = await lora.execute({
id: body.id,
source: body.query,
variables: body.variables,
operationName: body.operationName,
context: { jwt: await verifiedClaims(req.headers.authorization), signal: controller.signal },
});
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify(result));
}).listen(4000);
execute() takes either id (a persisted operation) or source. With
persistedOnly: true it refuses source with PERSISTED_QUERY_ONLY. The
result carries the operation's estimated cost in extensions.cost. See
persisted operations
and lora-graphql compile.
A subscription runs with subscribe(), which takes the same arguments,
persisted id included, with the same document cache, guards and
persistedOnly rule. It returns an async iterable of results, one per
event, or a single result with the errors when the subscription cannot
start. Given a subscription, execute() returns a WRONG_OPERATION_TYPE
error, and so does subscribe() given a query or mutation. The cost limit
(maxCost or budget) applies to each event on its own, so a long-lived
subscription never runs out of it.
const stream = await lora.subscribe({
id: body.id,
variables: body.variables,
context: { jwt, signal: controller.signal }, // aborting ends the stream
});
if (!(Symbol.asyncIterator in stream)) return send(stream); // it did not start
for await (const event of stream) sendEvent(event);
End a stream with the signal or with return() on the iterator (what
for await does on break). Either ends it at once, even while it waits
for an event that a quiet or filtered subscription may never get, and
drops its listener.
Subscriptions over WebSockets
Subscription fields resolve through graphql-js's subscribe, so any
transport that uses it works. With graphql-ws, pass the schema and build
the same context from the connection:
import { WebSocketServer } from "ws";
import { useServer } from "graphql-ws/use/ws";
useServer(
{
schema: lora.getSchema(),
context: async (ctx) => ({
jwt: await verifiedClaimsFromToken(ctx.connectionParams?.authorization),
}),
},
new WebSocketServer({ server: httpServer, path: "/graphql" }),
);
maxSubscriptions (default 100) counts live subscriptions per context
object. graphql-ws calls a context function once per subscription, so
with the code above every subscription has its own context and the limit
never applies. Count per connection instead: put the connection in the
context (connection: ctx) and pass
subscriptionScope: (context) => context.connection to new LoraGraphQL.
Returning the user's id holder works the same way when a user may open
several connections.
graphql-ws does not apply the document guards itself. Validate
subscription documents with [...specifiedRules, ...lora.validationRules()]
in its onSubscribe hook if clients can send arbitrary documents.
What a subscriber sees depends on where events come from:
- By default, events come from mutations made through this
LoraGraphQLinstance. Writes made with raw Cypher, or by@cyphermutations (which have no known write-set), do not produce precise events. - With
changeFeed: true(@loradb/lora-nodeonly), events come from the engine's committed change feed: every committed write to the database, whichever path made it (rawdb.execute()Cypher,@cyphermutations, otherLoraGraphQLinstances on the same database), in commit order. The feed starts with the first subscriber and delivers commits from then on; it does not replay writes made before the process started.
A LoraDB database directory is open in one process at a time (a second
process gets LORA_LOCKED), so subscribers are served by the process
that owns the database. To fan events out across several server
processes, forward lora.changes() from that process to a pub/sub of
your choice, and apply read rules on the receiving side (the raw
write-set carries no authorization).
A subscriber that falls maxQueuedChanges (default 1000) events behind is
ended with LIMIT_EXCEEDED instead of buffering without bound. Clients
should resubscribe and reload. See
subscriptions.
Response caching
Results depend on the caller's claims, so a response cache must be keyed
per caller (per token, or per set of claims). lora.onWrite() reports the
exact write-set of every committed mutation made through the library,
which is what a cache needs for invalidation:
const nodeTypes = [...lora.model.nodes.keys()];
lora.onWrite((change) => {
const types = change.broad ? nodeTypes : change.types;
void cache.invalidate(types.map((typename) => ({ typename })));
});
Three rules keep a cache correct:
- Invalidate by type, not only by entity. A created node, or an
updated one that now matches a cached filter, changes lists that never
held it.
change.entitiesalone misses those. - Do not cache counts and aggregates.
totalCount,aggregate,<plural>Aggregateand<plural>Groupedhold no node to tag, so no write invalidates them. - Keep a TTL.
onWritereports only this instance's mutations, even withchangeFeed: true, so writes made with raw Cypher are invisible to it, and a@cypherfield can read types a write did not touch. To see raw writes too, invalidate fromlora.changes()withchangeFeed: trueinstead.
The Yoga example
does all three with @graphql-yoga/plugin-response-cache. Types need a
@relayId field for the plugin to tag cached objects by id.
Several threads over one database
One Node process can serve from several event loops over one database.
worker_threads that open the same database directory share one engine:
every thread reads and writes the same graph, and there is still one
writer at a time. Each thread builds its own LoraGraphQL and runs
GraphQL execution, compilation and JSON on its own core, which is what a
single JavaScript thread runs out of first
(limits and scaling).
This needs a database directory. An in-memory database belongs to the thread that created it, and separate processes cannot share a directory.
import { createServer } from "node:http";
import { availableParallelism } from "node:os";
import { isMainThread, Worker, workerData } from "node:worker_threads";
import { createDatabase } from "@loradb/lora-node";
import { LoraGraphQL, loraDriver } from "@loradb/lora-graphql";
import { typeDefs } from "./schema.js";
const dir = "./data";
async function open() {
// The same directory in the same process is the same engine.
const db = await createDatabase("app", { databaseDir: dir });
return new LoraGraphQL({
typeDefs,
driver: loraDriver(db),
// Subscriptions and changes() then hear every thread's writes.
changeFeed: true,
});
}
if (isMainThread) {
// Indexes and constraints once, before any thread serves.
const lora = await open();
await lora.assertSchema({ create: true });
const threads = Math.max(1, availableParallelism() - 2);
for (let i = 0; i < threads; i++) {
new Worker(new URL(import.meta.url), { workerData: { port: 4000 + i } });
}
} else {
const lora = await open();
const { port } = workerData as { port: number };
// This thread's cache: dropped for a write made on any thread.
const cache = new Map<string, string>();
void (async () => {
for await (const change of lora.changes()) {
if (change.entities.length > 0) cache.clear();
}
})();
createServer((req, res) => {
const chunks: Buffer[] = [];
req.on("data", (chunk: Buffer) => chunks.push(chunk));
req.on("end", async () => {
const closed = new AbortController();
res.on("close", () => closed.abort());
const { query, variables } = JSON.parse(Buffer.concat(chunks).toString());
const result = await lora.execute({
source: query,
variableValues: variables,
context: { signal: closed.signal },
});
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify(result));
});
}).listen(port);
}
What to know:
- Ports. Each thread above listens on its own port, for a proxy to
balance across. On Linux the threads can share one port instead:
server.listen({ port, reusePort: true }). macOS has noreusePort. onWriteis per instance. It reports the mutations made through that thread'sLoraGraphQL, never another thread's. A write made elsewhere arrives throughlora.changes()withchangeFeed: true, as a change whoseoperationisEXTERNAL: itscreated,updated,deletedandentitiesare filled in, itsfieldis empty.- Subscriptions follow the same feed: with
changeFeed: truea subscriber connected to one thread receives the writes of all of them. Without it, only that thread's. - Per-instance state is per thread: the compile and document caches,
lora.analyze()statistics andmaxSubscriptionscounts. Runanalyze()on each thread that should plan with it. - The libuv pool is shared. Reads from every thread run on the one
pool, so raise
UV_THREADPOOL_SIZEalong with the thread count. - Writes do not scale with threads. There is one writer. Threads add read throughput and keep slow GraphQL work on one thread from stalling requests on another.
- Call
lora.close()in each thread on shutdown.
Backups
A process that serves GraphQL owns its database directory: another
process cannot open it (LORA_LOCKED), and a file copy taken during
writes is not a database. lora.snapshot(targetDir) writes an online
copy from inside the serving process, while requests keep running:
import { createDatabase } from "@loradb/lora-node";
import { LoraGraphQL, loraDriver } from "@loradb/lora-graphql";
const db = await createDatabase("festimap", { databaseDir: "./data" });
const lora = new LoraGraphQL({ typeDefs, driver: loraDriver(db) });
// Nightly, from a timer or an admin route:
const snapshot = await lora.snapshot(`./backups/${new Date().toISOString().slice(0, 10)}`);
// {
// bytes: 69283,
// lsn: 3,
// nodeCount: 100000,
// path: './backups/2026-10-26/festimap.loradb',
// relationshipCount: 0
// }
The copy is <targetDir>/festimap.loradb, the graph as of one committed
transaction (every write up to lsn), and opens with
createDatabase("festimap", { databaseDir: targetDir }). Mutations wait
only while the graph is taken, not while it is written, and the
.loradb appears only once it is complete, so ship the directory
off-host after the promise resolves. targetDir must be missing or
empty. It needs a driver with snapshot(): loraDriver(db) over a
lora-node database opened with a name and databaseDir. See
online backups for what
the copy guarantees and the lora snapshot command for a closed
directory.
Production checklist
- Verify tokens in the server; put only verified claims in
jwt. - Run with
NODE_ENV=production: database errors are masked and introspection is off. Or setmaskErrorsandguards.introspectionexplicitly. - Wire the document guards into the server.
- Pass a
signalin the context. - Set
cursorSecretfrom a secret store so cursors are signed. - Run
lora.assertSchema({ create: true })at startup, or applylora-graphql requirements --ddlin your migrations. - Key any response cache per caller.
- Set
subscriptionScopeif your transport builds a context per subscription. - Run
lora.analyze()on production data, or declare@cardinality, so the cost limit reflects the real graph. - Size the worker pool for your read load: see limits and scaling.
- Call
lora.close()on shutdown whenchangeFeedis on. - Back the database up with
lora.snapshot(targetDir), not a copy of its directory: see Backups.
The authorization page covers each of these in more depth.
WASM and the browser
loraDriver() also wraps a Database from @loradb/lora-wasm. It serves
reads, including search and @cypher queries. It cannot serve:
- mutations, which need interactive transactions: they fail with
DATABASE_ERROR("mutations need a driver with interactive transactions"); explain(),check()andexpectSeeks(), which need the engine's planner through@loradb/lora-node;changeFeed: true.