Skip to main content

Using LoraDB in Node.js and TypeScript

Overview​

lora-node is a native N-API binding. Queries run on the libuv threadpool so they don't block the event loop. Auto-commit reads can overlap on engine snapshots; write commits still serialize. The surface, helpers, and type guards match the WASM binding for query execution and result handling — the same query code largely ports with an import swap. Node also adds filesystem-backed persistence the WASM build cannot: container-backed .loradb files, explicit WAL directories, and path-based snapshot save/load.

How-to~5 min

Use LoraDB in a Node.js or TypeScript project

Install the @loradb/lora-node N-API binding, open an async in-memory handle, run a Cypher query, and persist the graph with a .loradb container or a WAL directory.

  1. Install the package
    Run npm install @loradb/lora-node (or yarn add @loradb/lora-node). The published package ships prebuilt N-API binaries for Linux, macOS, and Windows.
    Jump to section
  2. Open a database handle
    Call createDatabase() for an in-memory graph, or createDatabase('app', { databaseDir: './data' }) for a .loradb container. The handle is async-only — every call returns a Promise.
    Jump to section
  3. Run a Cypher query
    Await db.execute("CREATE (:Person {name: 'Ada'})") and then db.execute("MATCH (p:Person) RETURN p.name") to confirm the binding works end-to-end.
    Jump to section
  4. Persist the graph
    Call db.saveSnapshot('graph.bin') for a point-in-time dump, or switch to openWalDatabase({ walDir, snapshotDir, snapshotEveryCommits }) for continuous WAL-backed durability.
    Jump to section

Installation / Setup​

npm (@loradb/lora-node)

Requirements​

  • Node.js 18+ for the published package
  • Node.js 20+ for repository-wide tooling and docs / playground work
  • For building from source: Rust toolchain (rustup) + @napi-rs/cli

Install​

Install from npm:

npm install @loradb/lora-node

When working inside this repository, build from source:

corepack enable
yarn install --immutable
yarn workspace @loradb/lora-node build # native .node artifact + TypeScript

Creating a Client / Connection​

lora-node is async-only. The common initialization path is createDatabase(...), which returns a Promise<Database>:

import { createDatabase } from '@loradb/lora-node';

const db = await createDatabase(); // in-memory by default

There is no synchronous constructor and no Database.create() static. Use createDatabase(...) for in-memory or container-backed .loradb databases, and openWalDatabase(...) when you want an explicit WAL directory.

Rule of thumb:

import { createDatabase, openWalDatabase } from '@loradb/lora-node';

const inMemory = await createDatabase(); // in-memory database
const persistent = await createDatabase('app', { databaseDir: './data' }); // ./data/app.loradb
const wal = await openWalDatabase({
walDir: './data/app.wal',
snapshotDir: './data/app.snapshots',
snapshotEveryCommits: 1000,
});

If you want persistence, pass a database name and databaseDir to createDatabase(...), or pass walDir to openWalDatabase(...).

To open a container-backed embedded database instead of a fresh in-memory one, pass a database name and databaseDir:

import { createDatabase } from '@loradb/lora-node';

const db = await createDatabase('app', { databaseDir: './data' }); // ./data/app.loradb

The name is validated and resolved to <databaseDir>/<name>.loradb. Relative paths resolve from the current working directory. On boot, committed WAL records inside that archive are replayed automatically before the handle is returned.

Do not skip the await

createDatabase() returns a Promise. Calling execute() on the unresolved promise will throw. Always await the factory before running queries, and never instantiate the Database type directly — it is exported as a type only.

Running Your First Query​

import { createDatabase } from '@loradb/lora-node';

const db = await createDatabase();

await db.execute("CREATE (:Person {name: 'Ada', born: 1815})");

const result = await db.execute(
"MATCH (p:Person) RETURN p.name AS name, p.born AS born"
);

console.log(result.rows);
// [ { name: 'Ada', born: 1815 } ]

Examples​

Minimal working example​

Already shown above — await createDatabase() → execute → inspect result.rows.

Parameterised query​

const result = await db.execute(
"MATCH (p:Person) WHERE p.name = $name RETURN p.name AS name",
{ name: 'Ada' }
);

Values map automatically: JS numbers → Int or Float, strings → String, booleans → Bool, null → Null, arrays → List, plain objects → Map. Dates and spatial points use helper factories — see Typed helpers.

Explain and profile​

explain and profile are first-class binding methods, not Cypher keywords that you prepend to the query string. Use db.explain(...) when you want the compiled plan without running the executor:

const plan = await db.explain(
"MATCH (p:Person) WHERE p.name = $name RETURN p",
{ name: 'Ada' }
);

console.log(plan.shape); // "readOnly" or "mutating"
console.log(plan.resultColumns); // ["p"]
console.log(plan.tree.operator); // top-level physical operator

The plan tree contains stable operator ids, an operator label, opaque human-readable details, estimatedRows (currently null), and child operators. explain never invokes the executor, so even CREATE, MERGE, SET, DELETE, and REMOVE plans leave the graph untouched.

Use db.profile(...) when you want the same plan plus runtime metrics:

const profile = await db.profile(
"MATCH (p:Person) WHERE p.name = $name RETURN p",
{ name: 'Ada' }
);

console.log(profile.metrics.totalElapsedNs);
console.log(profile.metrics.totalRows);
console.log(profile.metrics.mutated);
console.log(profile.metrics.perOperator);
profile executes the query

Mutating queries passed to profile produce the same side effects as execute: WAL-backed databases write the commit, snapshots observe the new state, and the live graph advances. Use explain to inspect a mutating plan without running it.

Both methods accept the same parameter values as execute, including tagged helper structs for temporal, spatial, vector, and binary values. Graph structs such as LoraNode are returned by queries; for input, pass property values or typed helper values:

import { date, wgs84 } from '@loradb/lora-node';

const params = {
since: date('1800-01-01'),
near: wgs84(4.89, 52.37),
radius: 5000,
};

const plan = await db.explain(
`MATCH (c:City)
WHERE c.founded >= $since
AND geo.distance(c.location, $near) < $radius
RETURN c.name AS name`,
params
);

const profile = await db.profile(
`MATCH (c:City)
WHERE c.founded >= $since
AND geo.distance(c.location, $near) < $radius
RETURN c.name AS name`,
params
);

Structured result handling​

import type { LoraNode } from '@loradb/lora-node';
import { isNode } from '@loradb/lora-node';

const res = await db.execute<{ n: LoraNode }>(
"MATCH (n:Person) RETURN n"
);

for (const row of res.rows) {
if (isNode(row.n)) {
console.log(row.n.id, row.n.labels, row.n.properties);
}
}

Express route handler​

import express from 'express';
import { createDatabase, LoraError } from '@loradb/lora-node';

const db = await createDatabase();
const app = express();
app.use(express.json());

app.get('/users/:id', async (req, res) => {
try {
const { rows } = await db.execute(
"MATCH (u:User {id: $id}) RETURN u {.id, .handle, .tier} AS user",
{ id: Number(req.params.id) }
);
if (rows.length === 0) return res.status(404).end();
res.json(rows[0].user);
} catch (err) {
if (err instanceof LoraError) {
return res.status(400).json({ error: err.message, code: err.code });
}
console.error(err);
res.status(500).end();
}
});

app.listen(3000);

The same shape generalises to Fastify, Hono, and the edge/serverless handlers — the Database instance lives at module scope.

Handle errors​

import { LoraError } from '@loradb/lora-node';

try {
await db.execute("BAD QUERY");
} catch (err) {
if (err instanceof LoraError) {
console.error(err.code); // "LORA_PARSE", "LORA_INVALID_PARAMS", ...
console.error(err.message);
} else {
throw err; // unexpected — rethrow
}
}

Concurrency​

// Five lookups in parallel — read-only queries can overlap on snapshots
const handles = ['alice', 'bob', 'carol', 'dan', 'eve'];
const results = await Promise.all(
handles.map(h =>
db.execute("MATCH (u:User {handle: $h}) RETURN u.id", { h })
)
);

The event loop stays responsive. Read-only calls can overlap on engine snapshots; write commits still serialize.

Limit on open transactions​

Each open db.begin() transaction and each write stream() that has to wait for the writer lock runs on a thread of its own. A database keeps at most maxOpenTransactions of them open at once: 1024 unless you set it. Past the limit the call fails at once with LORA_TOO_MANY_OPEN instead of starting another thread or waiting in a queue.

Plain db.execute() writes that arrive while a transaction holds the writer lock do not take a thread each. They wait in one queue per database and run in order, on a single thread, once the lock is free. A queued write still fails with LORA_TIMEOUT when its timeoutMs (or the database's queryTimeoutMs) runs out or its signal aborts, and the time spent queued counts. dispose() rejects the writes still queued.

const db = await createDatabase('app', {
databaseDir: './data',
maxOpenTransactions: 256,
});

try {
const tx = await db.begin();
try {
await tx.execute("CREATE (:Order {id: $id})", { id: 1 });
await tx.commit();
} finally {
if (tx.isOpen) await tx.rollback();
}
} catch (err) {
if (err instanceof LoraError && err.code === 'LORA_TOO_MANY_OPEN') {
// Too much in flight: shed load, or retry once others have finished.
}
}

A slot frees up as soon as its transaction commits or rolls back, or its stream ends or is closed. A transaction or stream you drop without finishing frees its slot once it is garbage-collected, so finish them explicitly.

Read-write db.transaction() batches never fail this way. At most maxOpenTransactions of them run at once, and the rest wait their turn in order. The wait counts against the batch's timeoutMs, and its signal can cancel it:

// 2000 batches: up to 256 run at a time, the others queue.
await Promise.all(
orders.map((o) =>
db.transaction(
[{ query: "CREATE (:Order {id: $id})", params: { id: o.id } }],
'read_write',
{ timeoutMs: 5000 },
),
),
);

openWalDatabase() takes the same option, and the limits apply per database handle.

Persisting your graph​

LoraDB has three Node persistence shapes:

  • createDatabase() for a purely in-memory graph.
  • createDatabase('app', { databaseDir: './data' }) for container-backed recovery between process restarts.
  • openWalDatabase({ walDir: './data/wal', snapshotDir: './data/snapshots' }) for an explicit WAL directory with optional managed snapshots.
  • saveSnapshot / loadSnapshot for point-in-time files that you can move, back up, or load into a fresh handle.
import {
createDatabase,
openWalDatabase,
type SnapshotMeta,
} from '@loradb/lora-node';

const db = await createDatabase();
await db.execute("CREATE (:Person {name: 'Ada'})");

// Save everything to disk.
const meta: SnapshotMeta = await db.saveSnapshot('graph.bin');
console.log(meta.nodeCount, meta.relationshipCount);

// Restore into a fresh handle (in a new process, for example).
const db2 = await createDatabase();
await db2.loadSnapshot('graph.bin');

const durable = await openWalDatabase({
walDir: './data/wal',
snapshotDir: './data/snapshots',
snapshotEveryCommits: 1000,
snapshotOptions: { compression: { format: 'gzip', level: 1 } },
syncMode: 'groupSync',
});

saveSnapshot / loadSnapshot are async like every other @loradb/lora-node call, but the underlying engine still encodes or decodes the whole graph. When you are using plain createDatabase() with no archive or WAL path, a crash loses all in-memory state. Manual snapshots protect only the mutations saved before the crash; WAL-backed opens replay committed writes on restart.

saveSnapshot() with no path returns a Buffer. saveSnapshot(path) writes atomically to disk and returns SnapshotMeta. loadSnapshot accepts a path string / URL, Buffer, Uint8Array, ArrayBuffer, Node Readable, Web ReadableStream, or async iterable.

To back up a database opened with a name and databaseDir while it serves traffic, call db.snapshot(targetDir) from the process that owns it: it writes <targetDir>/<name>.loradb as of one committed transaction while reads and writes continue, and resolves with { path, bytes, lsn, nodeCount, relationshipCount }. For a directory no process has open, npx lora snapshot <databaseDir> <targetDir> does the same. db.checkpoint() checkpoints on demand. See online backups.

See the canonical Snapshots guide for the full metadata shape, atomic-rename guarantees, and boundaries.

Common Patterns​

Bulk insert from a JS array​

const rows = [
{ id: 1, name: 'Ada' },
{ id: 2, name: 'Grace' },
{ id: 3, name: 'Alan' },
];

await db.execute(
`UNWIND $rows AS row
CREATE (:User {id: row.id, name: row.name})`,
{ rows }
);

See UNWIND.

Typed helpers​

Build typed temporal / spatial values in JS and pass them as parameters:

import { createDatabase, date, duration, wgs84 } from '@loradb/lora-node';

const db = await createDatabase();

await db.execute(
"CREATE (:Trip {when: $when, span: $span, origin: $origin})",
{
when: date('2026-05-01'),
span: duration('PT90M'),
origin: wgs84(4.89, 52.37),
}
);

Available factories: date, time, localtime, datetime, localdatetime, duration, cartesian, cartesian3d, wgs84, wgs84_3d.

Type guards​

FunctionNarrows to
isNode(v)LoraNode
isRelationship(v)LoraRelationship
isPath(v)LoraPath
isPoint(v)LoraPoint
isTemporal(v)any temporal variant

Other methods​

await db.clear(); // drop all nodes + relationships
await db.nodeCount(); // number of nodes
await db.relationshipCount(); // number of relationships
db.dispose(); // release the native handle

clear / nodeCount / relationshipCount return Promises for API symmetry but run synchronously inside the native layer. dispose() is synchronous and idempotent; call it when you need to reopen the same archive or WAL directory inside the same process.

Repository pattern​

Database is exported as a type-only symbol — use it to annotate the instance that createDatabase() returned:

import { createDatabase, type Database } from '@loradb/lora-node';

export class UserRepo {
constructor(private readonly db: Database) {}

async upsert(id: number, handle: string) {
await this.db.execute(
`MERGE (u:User {id: $id})
ON CREATE SET u.created = temporal.timestamp()
SET u.handle = $handle, u.updated = temporal.timestamp()`,
{ id, handle }
);
}

async findByHandle(handle: string) {
const { rows } = await this.db.execute(
"MATCH (u:User {handle: $handle}) RETURN u {.*} AS user",
{ handle }
);
return rows[0]?.user ?? null;
}
}

// Wire it up — initialization stays async-first at module scope.
const db = await createDatabase();
const users = new UserRepo(db);

Common initialization mistakes​

❌ Wrong✅ Right
const db = new Database()const db = await createDatabase()
const db = Database.create() (missing await)const db = await createDatabase()
Database.create() (legacy name)createDatabase()
import { Database } from '@loradb/lora-node'; new Database()import { createDatabase } from '@loradb/lora-node'; then await createDatabase()
createDatabase(undefined, { walDir: './wal' })openWalDatabase({ walDir: './wal' })

Database is a type-only export. Importing it as a value and calling new Database() is a compile error — synchronous initialization has been removed on purpose.

Error Handling​

Two classes to know:

ClassWhen
LoraErrorAny engine-level failure — parse, semantic, runtime
InvalidParamsErrorHost supplied a parameter that couldn't be mapped to a LoraValue

For the engine-level cases see the Troubleshooting guide.

Performance / Best Practices​

  • Integer precision. Engine integers are i64. JS number loses precision above Number.MAX_SAFE_INTEGER (2^53). For very large IDs prefer bigint parameters or string encoding. See Troubleshooting → integer precision.
  • Concurrency. Each Database has its own in-memory graph. Auto-commit reads can overlap on Arc snapshots; write commits and explicit read-write transactions serialize. The event loop stays free while native work runs.
  • No cancellation. Once dispatched, a query runs to completion. Bound variable-length patterns and UNWIND list sizes.
  • Dispose explicitly only when you need to release the native handle eagerly, especially before reopening the same archive in one process; otherwise GC eventually cleans up.

See also​