
This blog is written by AI for SEO
Porting Kuzu Node.js Code to HelixDB in TypeScript
To port a kuzu nodejs app to HelixDB, you swap the Database and Connection pair for a HelixDB TypeScript client, rewrite queries with writeBatch(), readBatch() and g() (or send supported openCypher to /v2/cypher over fetch), then move your data and verify it. HelixDB is an open-source, Apache-2.0 graph-vector database built in Rust that keeps graph, vector and full-text search in one engine. Most Kuzu-based agent memory, RAG and knowledge graph apps end up needing all three anyway.
This guide covers the other route, moving to HelixDB. It is the third post in a series, after the Python port and the Go port. To see which of your queries survive untouched, read Kuzu Cypher on HelixDB. You need Node.js, TypeScript, and your existing Kuzu database directory.
Step 1: Inventory your Kuzu Node.js code as it is today
Write down every Kuzu call your app makes, because the port is a call-by-call mapping. Most kuzu npm code looks like this:
import kuzu from "kuzu";
const db = new kuzu.Database("./mydb");
const conn = new kuzu.Connection(db);
const result = await conn.query("MATCH (u:User) RETURN u.name, u.age");
const rows = await result.getAll();
const prepared = await conn.prepare(
"MATCH (u:User) WHERE u.age > $minAge RETURN u.name, u.age"
);
const filtered = await conn.execute(prepared, { minAge: 21 });
console.log(await filtered.getAll());Grep your repo for new kuzu.Database, new kuzu.Connection, conn.query, conn.prepare, conn.execute and getAll. Then sort the Cypher strings into three piles: plain reads (MATCH, WHERE, RETURN), plain writes (CREATE, SET, DELETE), and anything using MERGE, variable-length paths, shortest paths, UNION, CALL, subqueries, LOAD CSV or schema DDL. Also note every node that carries more than one label, since HelixDB takes one label per node.
The third pile is your real porting work. The first two mostly map across, and Step 6 covers the third.
Record one more thing while you are here: your CREATE NODE TABLE and CREATE REL TABLE statements. They are schema DDL, which the Cypher endpoint rejects, and you will express the same model as labels and properties in the SDK.
Step 2: Install @helix-db/helix-db and pick embedded or server mode
Install the TypeScript client. Choose embedded mode if your Kuzu app was in-process, which is the usual case.
npm install @helix-db/helix-db
npm install @helix-db/helix-db-embedded # embedded mode onlyEmbedded mode is the closest analogue to new kuzu.Database(path). The engine runs inside your Node process, in memory or on disk, and needs no CLI. Server mode fits when several services share one graph. Locally, run helix init local and then helix start dev (needs Docker or Podman; it serves on port 6969). The same engine also runs as a self-hosted Docker server against in-memory, on-disk or S3-compatible object storage, or as managed HelixDB Cloud. These are configuration choices, not tiers: same SDKs, same endpoint.
To run Cypher from Step 5, you need server image v0.0.13 or later, or HelixDB Cloud. Embedded mode is the SDK path. Check the HelixDB docs for the current setup pages before you pin versions.
Step 3: Replace Database and Connection with Client.embedded or Client.server
One client object replaces the Database and Connection pair. For an on-disk embedded database:
import { Client } from "@helix-db/helix-db";
const client = await Client.embedded({
kind: "disk",
root: "./data",
database: "app",
});The kind is "disk", not "onDisk", and it needs a root path. There is also an in-memory kind for tests. If a second process needs read access to the same on-disk data, it opens Client.embeddedReader for read-only access.
For a server, connect by URL and add an API key where one is required:
import { Client } from "@helix-db/helix-db";
const client = Client.server("http://localhost:6969").withApiKey(process.env.HELIX_API_KEY!);You don't create a separate connection handle per request, and you can't rely on auto-initialize-on-first-query behavior. Hold one client for the life of the process and close it at shutdown (Step 7).
Step 4: Port writes and reads to writeBatch(), readBatch() and g()
Rewrite each Kuzu CREATE and MATCH as a batch built with g(), then run it with .send(). Nothing executes until you call it. The SDK DSL produces JSON, and the database parses it and runs it. There is no separate query language to learn and nothing compiled ahead of time.
A write that replaces CREATE (:User {name: 'Ada'}):
import { Client, g, writeBatch } from "@helix-db/helix-db";
const client = await Client.embedded({ kind: "disk", root: "./data", database: "app" });
const write = writeBatch()
.varAs("user", g().addN("User", { name: "Ada", age: 36 }))
.returning(["user"]);
await client.query(write.toQueryRequest()).send();A read that replaces MATCH (u:User) RETURN u.name:
import { g, readBatch } from "@helix-db/helix-db";
const read = readBatch()
.varAs("users", g().nWithLabel("User").valueMap(["$id", "name"]))
.returning(["users"]);
const users = await client.query(read.toQueryRequest()).send();The run call is client.query(query.toQueryRequest()).send(). There is no query.send(client). Filters, comparison and logical predicates, ordering, limit, skip and range all exist in the DSL, so a WHERE u.age > 21 ORDER BY u.name LIMIT 10 maps to predicates plus ordering and limit on the same traversal.
The result shape differs from getAll(). You get the named variables you listed in returning. Name them after what your calling code expects and the rest of your app barely changes.
Step 5: Replace prepared statements and run supported Cypher over /v2/cypher with fetch
Prepared statements map to parameter binding in the SDK, and Cypher that fits HelixDB's openCypher profile can run as-is over HTTP. HelixDB runs a supported openCypher profile at POST /v2/cypher, beside native JSON queries on the same graph, transactions and indexes. Supported: MATCH and OPTIONAL MATCH with fixed-length patterns, WHERE, WITH, RETURN, UNWIND, ORDER BY, SKIP, LIMIT, CREATE, SET, REMOVE, DELETE, DETACH DELETE, common functions and aggregation.
The published TypeScript SDK (3.2.0) has no Cypher method, so call the endpoint with fetch. Your conn.query strings that sit inside the profile need the least rewriting here:
const res = await fetch("http://localhost:6969/v2/cypher", {
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${process.env.HELIX_API_KEY}`,
},
body: JSON.stringify({
query: "MATCH (u:User) WHERE u.age > $minAge RETURN u.name, u.age",
params: { minAge: 21 },
}),
});
const rows = await res.json();Check the request body fields and auth header against the current docs before you ship. The endpoint is the stable part, and the exact envelope is what you confirm. This route needs server image v0.0.13 or later, or HelixDB Cloud, so it does not apply to embedded mode.
For anything that is not Cypher, bind parameters in the SDK instead. defineParams and param let you declare a parameterized query once and call it with different values, the same role conn.prepare plus conn.execute played.
Step 6: Rewrite what the openCypher profile rejects
Anything in your third pile from Step 1 is rejected before it runs, so rewrite it with the SDK. The profile rejects MERGE, variable-length relationships, shortest paths, UNION, CALL subqueries, procedures and namespaced functions such as APOC, EXISTS, COUNT, and COLLECT subqueries, pattern comprehensions, map projections, LOAD CSV, schema DDL, and multiple labels per node.
Practical rewrites:
MERGE: do a read first, then a conditional write in application code or a batch condition. Decide explicitly whether repeated events should create parallel edges or update one.
Variable-length paths and shortest paths: unroll to a fixed number of hops in
g(), or traverse in a loop in your code and stop when you reach the target.UNION: run two reads and concatenate the results in TypeScript.
LOAD CSV: read the file in Node and insert with
writeBatch().Schema DDL: drop the CREATE NODE TABLE and CREATE REL TABLE statements. Labels and properties are declared by writing them.
Multiple labels per node: pick one label and store the rest as a property you filter on.
Vectors are a property on nodes and edges. If you kept embeddings in a separate FLOAT array column in Kuzu, move them to a vector property and index it. Vector search can then be pre-filtered to related entities. A vector property has to be top-level, and an unrestricted vector search caps at 800 effective results.
Step 7: Move the data, close the client and verify
Export from Kuzu, batch-load into HelixDB, close the client, then compare counts. Kuzu can export tables with COPY ... TO in CSV or Parquet. Read those files in Node and insert in chunks with writeBatch(), nodes first and edges after. Keep your old primary keys as a property so edges can find their endpoints.
import { Client, g, writeBatch } from "@helix-db/helix-db";
async function loadUsers(client: Client, users: { id: string; name: string }[]) {
for (let i = 0; i < users.length; i += 500) {
let batch = writeBatch();
users.slice(i, i + 500).forEach((u, j) => {
batch = batch.varAs(`u${j}`, g().addN("User", { kuzuId: u.id, name: u.name }));
});
await client.query(batch.toQueryRequest()).send();
}
}The chunk size is a starting point, so tune it to your row width. Graph and vector writes commit or roll back together, so a failed chunk does not leave half a batch behind.
When you are done, close the client:
await client.close();Closing the writer persists data. There is no flush(), no readOnly flag and no mode option. Verify by counting nodes and edges per label on both sides, then run your ten most important Kuzu queries against both and diff the output. Fix mismatches before you cut over.
Port one query path first, then cut over
Pick the one Kuzu query your app calls most, port it with readBatch() and g(), and diff its output against Kuzu on a copy of your data. Once that matches, the rest is repetition. Run helix init local then helix start dev for a local server, or install @helix-db/helix-db and open an embedded client. If the port goes well, star HelixDB at github.com/HelixDB/helix-db. The Kuzu migration and Cypher compatibility guides cover the query-by-query details.