@loradb/lora-node
v0.15.0
Published
Node.js / TypeScript bindings for the Lora in-memory graph database
Maintainers
Readme
lora-node
Node.js / TypeScript bindings for the Lora graph
engine. The package exposes a first-class typed API: query results are
modelled as discriminated unions, temporal values carry kind tags, and the
Database class is strongly typed in both directions (params and rows).
Non-blocking: execute() dispatches each query to the libuv threadpool via
napi::Task. The JS event loop
stays free for the full duration of a query — a 2 000-node MATCH happily
interleaves with setImmediate ticks on the main thread (proven by a
dedicated vitest).
Package:
@loradb/lora-node.
Install (local dev)
From the repository root:
corepack enable
yarn install --immutable
yarn workspace @loradb/lora-node build # Rust cdylib + TypeScript declarations
yarn workspace @loradb/lora-node test # vitest suiteThe build:native step uses @napi-rs/cli and
produces a platform-specific lora-node.<platform>-<arch>.node artifact next
to package.json.
Usage
lora-node is async-only — the sole initialization pattern is
createDatabase(...). There is no synchronous constructor and no
Database.create() static; Database is a type-only export.
import { createDatabase, isNode, type LoraNode } from "@loradb/lora-node";
const db = await createDatabase(); // in-memory by default
await db.execute("CREATE (:Person {name: $n, age: $a})", { n: "Alice", a: 30 });
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.properties.name);
}
}Explain & Profile
db.explain() and db.profile() are first-class methods alongside
db.execute(). They are intentionally separate calls, not a flag on
execute(), so you have to opt in explicitly to plan inspection or
metrics collection.
const plan = await db.explain(
"MATCH (p:Person) WHERE p.name = $name RETURN p",
{ name: "Alice" },
);
console.log(plan.shape); // "readOnly"
console.log(plan.tree.operator); // top-most operator label
const profile = await db.profile(
"MATCH (p:Person) WHERE p.name = $name RETURN p",
{ name: "Alice" },
);
console.log(profile.metrics.totalElapsedNs);
console.log(profile.metrics.perOperator);explain() never invokes the executor — calling it on a mutating
query (CREATE, MERGE, SET, DELETE, REMOVE) leaves the graph
untouched.
profile()executes the query for real. Mutating queries produce the same side effects asexecute(): the WAL is written, snapshots observe the commit, and the live store advances. Useexplain()to inspect a mutating plan without running it.
The initialization rule is:
import { createDatabase } from "@loradb/lora-node";
const inMemory = await createDatabase(); // in-memory only
const defaultPersistent = await createDatabase("app"); // ./app.loradb
const nestedPersistent = await createDatabase("app", {
databaseDir: "./data",
syncMode: "groupSync", // default
}); // ./data/app.loradbPassing a database name enables persistence. Use databaseDir when you want a
directory other than the current working directory.
The default syncMode: "groupSync" writes WAL bytes before execute() resolves
and batches fsyncs for write-heavy workloads. Call await db.sync() when you
need an immediate durability boundary before copying the portable .loradb
archive while the database is still open.
Node also has a container-backed convenience overload:
import { createDatabase } from "@loradb/lora-node";
const db = await createDatabase("app", { databaseDir: "./data" });The database name is validated and resolved under databaseDir, or under the
current working directory when no directory is supplied, appending .loradb to
the basename when needed. Relative paths resolve from the current working
directory. This is a Node-only initialization convenience; the query surface,
shared types, and async method signatures still match lora-wasm.
Persistent opens for the same resolved archive path in one Node process share a
single live native engine. Call db.dispose() when you need to release a handle
eagerly; cross-process opens of the same archive are blocked to prevent
split-brain writers.
For explicit WAL directories with managed snapshots, use openWalDatabase:
import { openWalDatabase } from "@loradb/lora-node";
const db = await openWalDatabase({
walDir: "./data/wal",
snapshotDir: "./data/snapshots",
snapshotEveryCommits: 1000,
snapshotKeepOld: 2,
});snapshotOptions accepts the same compression/encryption options as
saveSnapshot.
Snapshots
saveSnapshot(path) writes the current graph to a local file. Plain strings
are always treated as paths. Calling saveSnapshot() returns a Node Buffer;
object formats such as { format: "base64" }, { format: "arrayBuffer" },
{ format: "uint8Array" }, and { format: "stream" } return in-memory
snapshot data in that shape. { format: "path", path } accepts either a path
string or file: URL.
loadSnapshot accepts a NodeSnapshotSource: a filesystem path, file: URL,
HTTP(S) or data: URL, Buffer, Uint8Array, ArrayBuffer, Node
Readable, web ReadableStream, or async iterable of byte chunks.
import { readFile } from "node:fs/promises";
import { createReadStream } from "node:fs";
import { pathToFileURL } from "node:url";
import { createDatabase } from "@loradb/lora-node";
const db = await createDatabase();
await db.execute("CREATE (:Person {name: 'Alice'})");
await db.saveSnapshot("./graph.lorasnap");
const bytes = await db.saveSnapshot();
const base64 = await db.saveSnapshot({ format: "base64" });
const stream = await db.saveSnapshot({ format: "stream" });
await db.loadSnapshot("./graph.lorasnap");
await db.loadSnapshot(pathToFileURL("./graph.lorasnap"));
await db.loadSnapshot(await readFile("./graph.lorasnap"));
await db.loadSnapshot(createReadStream("./graph.lorasnap"));
await db.loadSnapshot(bytes);
await db.loadSnapshot(stream);
await db.loadSnapshot(new URL("https://example.com/graph.lorasnap"));Typed value model
| TS type | Runtime shape |
|-------------------------|-------------------------------------------------------------------------------|
| null/boolean/number/string | pass-through JS primitives |
| LoraValue[] / object | homogeneous arrays and nested records |
| LoraNode | { kind: "node", id, labels, properties } |
| LoraRelationship | { kind: "relationship", id, startId, endId, type, properties } |
| LoraPath | { kind: "path", nodes: number[], rels: number[] } |
| LoraDate…LoraDuration | { kind: "date", iso: "YYYY-MM-DD" } etc. |
| LoraPoint | Discriminated union on srid, see below |
LoraPoint is a discriminated union over the four supported CRSes:
| Shape | Meaning |
|--------------------------------------------------------------------------------------------------------------|----------------------|
| { kind: "point", srid: 7203, crs: "cartesian", x, y } | Cartesian 2D |
| { kind: "point", srid: 9157, crs: "cartesian-3D", x, y, z } | Cartesian 3D |
| { kind: "point", srid: 4326, crs: "WGS-84-2D", x, y, longitude, latitude } | WGS-84 2D |
| { kind: "point", srid: 4979, crs: "WGS-84-3D", x, y, z, longitude, latitude, height } | WGS-84 3D |
Helper constructors (date("2025-01-15"), cartesian(1, 2), cartesian3d(1, 2, 3),
wgs84(lon, lat), wgs84_3d(lon, lat, height), duration("P1M"), …) and
narrowing guards (isNode, isRelationship, isPath, isPoint, isTemporal)
are exported from lora-node.
distance()on WGS-84-3D points ignoresheight— see functions reference for the full spatial reference and known limitations.
Architecture
lora-database (Rust)
└── lora-node (crate, cdylib) <- napi-rs bindings, AsyncTask
└── ts/index.ts <- strongly-typed async wrapper
└── ../shared-ts/types.ts <- shared TS contract (with lora-wasm)Query execution path:
JS main thread libuv threadpool Rust
────────────── ─────────────────── ────────────────
db.execute(…) ──► ExecuteTask::compute() ──► parser → analyzer →
compiler → executor →
storage
◄── resolve() wraps serde_json::Value
into JsUnknown and resolves the PromiseThe Rust crate is added to the workspace root (Cargo.toml). The Node side is
self-contained inside this directory. Only sub-millisecond operations
(clear, nodeCount, relationshipCount) stay synchronous inside napi; the
TS wrapper still exposes them as Promise-returning methods to keep the API
identical to lora-wasm.
Errors
db.execute(...) throws LoraError with a narrowed code from the
LoraErrorCode union — these mirror lora_database::LoraErrorCode 1:1.
Common ones:
LORA_PARSE— Cypher syntax could not be parsedLORA_SEMANTIC— analysis failure (unknown variable, label, type mismatch, …)LORA_INVALID_PARAMS— a parameter value could not be coerced to aLoraValueLORA_READ_ONLY— mutating statement issued in a read-only contextLORA_NOT_FOUND— a named entity does not existLORA_CONSTRAINT,LORA_UNIQUE_CONSTRAINT,LORA_NOT_NULL_CONSTRAINT,LORA_FOREIGN_KEY,LORA_TRANSACTION— graph constraint or transaction failureLORA_INVALID_VECTOR— vector value failed dimension / coordinate-type validationLORA_TIMEOUT— query exceeded its cooperative deadlineLORA_DATABASE_NAME— logical database name violates the portable-path rulesLORA_CONFIG,LORA_VALIDATION— configuration or validation failureLORA_IO,LORA_CONNECTION,LORA_WAL_CORRUPTION,LORA_WAL_POISONED— storage failuresLORA_SNAPSHOT_CODEC,LORA_SNAPSHOT_CRYPTO— snapshot codec / crypto failuresLORA_INTERNAL— last-resort fallback when the engine cannot classify the failureUNKNOWN— catch-all for messages without a recognized code
See ts/types.ts (LoraErrorCode) for the full list.
Known limitations
- Concurrent writes. Each
execute()hops through the threadpool; read queries can share the store read lock, while writes serialize on the store write lock. Firing many concurrent write queries against the sameDatabase(e.g. 2 000 parallelCREATEs viaPromise.all) works but queues behind that write lock. Preferawait-in-a-loop or a single batched query for heavy write workloads. - I64 precision. Integer values above
Number.MAX_SAFE_INTEGER(2^53) are returned as JSnumberand lose precision. Abigint-aware path would require extending the value serializer. - Cancellation. The napi
Taskabstraction does not support cancellation once dispatched; a runaway query runs to completion. - WAL surface. Node persistence exposes container-backed initialization,
syncMode: "groupSync", anddb.sync(). Checkpoint, truncate, and status controls are not exposed yet. - Archive ownership. One archive can only be open by one writer process at a time. Multiple Node handles in the same process share the same live engine; a second process is rejected while the first holds the archive lock.
