@loradb/lora-node
v0.6.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).
Status: prototype / feasibility check. Not published to npm.
Install (local dev)
cd crates/lora-node
npm install
npm run build # builds the Rust cdylib + TypeScript declarations
npm test # runs the vitest suiteThe npm run 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 "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);
}
}The initialization rule is:
import { createDatabase } from "lora-node";
const inMemory = await createDatabase(); // in-memory only
const defaultPersistent = await createDatabase("app"); // ./app.loradb
const nestedPersistent = await createDatabase("app", {
databaseDir: "./data",
syncMode: "group", // 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: "group" writes WAL bytes before execute() resolves
and batches fsyncs for write-heavy workloads. That recovers from ordinary
process death, while syncMode: "perCommit" also protects each committed write
against power loss before execute() resolves. Call await db.sync() before
copying the portable .loradb archive while the database is still open.
Node also has an archive-backed convenience overload:
import { createDatabase } from "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 "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 "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:
LORA_ERROR— parse / analyze / execute failureINVALID_PARAMS— a param value could not be mapped to aLoraValue
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 archive-backed initialization,
syncMode: "group" | "perCommit", 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.
