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, though parallel calls on a single Database still serialise on the engine mutex. The surface, helpers, and type guards match the WASM binding for query execution and result handling — the same code largely ports with an import swap. Node also adds one embedded-only convenience the WASM build cannot: optional archive-backed initialization with .loradb files.

Installation / Setup

npm (@loradb/lora-node)

Requirements

  • Node.js 18+
  • For building from source: Rust toolchain (rustup) + @napi-rs/cli

Install

While pre-release, build from source:

cd crates/lora-node
npm install
npm run build # builds native .node artifact + TypeScript

After publish:

npm install @loradb/lora-node

Creating a Client / Connection

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

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

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

createDatabase(...) is the single entry point — there is no synchronous constructor and no Database.create() static. This lets the binding extend initialization later without breaking callers.

Rule of thumb:

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

const inMemory = await createDatabase(); // in-memory database
const persistent = await createDatabase('app', { databaseDir: './data' }); // ./data/app.loradb

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

To open an archive-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, nullNull, arrays → List, plain objects → Map. Dates and spatial points use helper factories — see Typed helpers.

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_ERROR" | "INVALID_PARAMS"
console.error(err.message);
} else {
throw err; // unexpected — rethrow
}
}

Concurrency

// Five lookups in parallel — each awaits the engine mutex
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, but the five queries execute in series inside the native layer. For read parallelism, spin up multiple Database instances (each with its own graph).

Persisting your graph

LoraDB can save the graph to a single file and restore it later. In Node you now have two persistence shapes:

  • createDatabase('app', { databaseDir: './data' }) for archive-backed recovery between process restarts.
  • saveSnapshot / loadSnapshot for point-in-time files that you can move, back up, or load into a fresh handle.
import { createDatabase, 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');

saveSnapshot / loadSnapshot are async like every other @loradb/lora-node call, but the underlying engine call is synchronous and holds the store mutex for the duration — concurrent execute() calls block until the snapshot operation finishes. When you are using plain createDatabase() with no WAL path, a crash between saves loses every mutation since the last save.

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 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 = timestamp()
SET u.handle = $handle, u.updated = 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 'lora-node'; then await createDatabase()

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 guarded by a mutex. Parallel execute() calls against one instance serialise in the native layer — the event loop stays free, but execution is one-at-a-time. For read parallelism, spawn multiple instances with separate graphs / archives.
  • 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