Embed relate in a Node.js app
A first Node.js program on the @relate/relate package, which opens a store, passes parameters, runs transactions, reads result metadata, and catches errors.
node
By the end of this page, you'll have a Node.js program that keeps a small graph of people and projects in one file. It reads the graph with parameters and reports errors by their class and code. Along the way we write inside a transaction A unit of work that commits as a whole or not at all; each statement is its own transaction unless an explicit begin … commit groups several. Read more in the glossary, roll one back, and read what a result says about itself. It takes about fifteen minutes. relate runs inside your Node.js process, so there is no server to start, and every step writes to a store it creates.
Prerequisites
- Node.js 18 or later, with
npm. - A terminal and a text editor. The relate CLI is not needed: the package carries the engine.
Install the client
The package is @relate/relate. Install it in your project's directory:
npm install @relate/relatenpm adds the engine as a platform package for your operating system and CPU (@relate/<platform>-<arch>). Check that Node.js can load it, and which version it is:
const { Database } = require("@relate/relate");
const { version } = require("@relate/relate/package.json");
Database.open(":memory:");
console.log(version);0.1.2
Requiring @relate/relate loads the engine, and opening a :memory: store runs it, so a missing or mismatched platform package fails at this step.
Notice the version: the programs on this page were checked against that release, and their recorded output comes from it.
Open a store
A store A relate database: a directory, a single .rl file, or :memory:, opened with --database. Read more in the glossary is where relate keeps a graph. :memory: is a store that lives in the process and forgets everything when the process exits. A path ending in .rl is a single-file store, which Database.open() creates when nothing is there yet.
const fs = require("node:fs");
const { Database } = require("@relate/relate");
const memory = Database.open(":memory:");
const conn = memory.connect();
console.log(conn.query("RETURN 'hello' AS greeting").single().greeting);
Database.open("./team.rl");
console.log(fs.existsSync("team.rl"));hello true
Database.open() opens a store, connect() returns a connection on it, and query() runs one statement and returns its result. Notice that team.rl now exists. query() is synchronous: it returns the result, not a Promise. queryAsync() takes the same arguments and returns a Promise of the same result, running the statement off the main thread.
Create the graph
Our graph is three people and two projects: who works on what, since when, and who maintains relate. One CREATE statement writes all of it. The rest of this page uses the same statement, as TEAM, on an in-memory store, so each program runs on its own.
const { Database } = require("@relate/relate");
const TEAM = `
CREATE (ada:Person {name: 'Ada', role: 'engineer'}),
(grace:Person {name: 'Grace', role: 'engineer'}),
(noor:Person {name: 'Noor', role: 'maintainer'}),
(relate:Project {name: 'relate', language: 'Rust'}),
(tck:Project {name: 'opencypher-tck', language: 'Gherkin'}),
(ada)-[:WORKS_ON {since: 2024}]->(relate),
(grace)-[:WORKS_ON {since: 2023}]->(relate),
(grace)-[:WORKS_ON {since: 2022}]->(tck),
(noor)-[:MAINTAINS]->(relate),
(ada)-[:KNOWS]->(grace)
`;
const conn = Database.open(":memory:").connect();
const result = conn.query(TEAM);
console.log("nodes created:", result.stats.nodes_created);
console.log("relationships created:", result.stats.relationships_created);nodes created: 5 relationships created: 5
A write returns no rows, so we read its counters from result.stats. Five nodes and five relationships: the whole team.
Read it back with parameters
A statement names each value it needs as $name, and query() takes the values as a plain object. The statement text stays the same while the values change, and a value never becomes part of the Cypher.
const { Database } = require("@relate/relate");
const TEAM = `
CREATE (ada:Person {name: 'Ada', role: 'engineer'}),
(grace:Person {name: 'Grace', role: 'engineer'}),
(noor:Person {name: 'Noor', role: 'maintainer'}),
(relate:Project {name: 'relate', language: 'Rust'}),
(tck:Project {name: 'opencypher-tck', language: 'Gherkin'}),
(ada)-[:WORKS_ON {since: 2024}]->(relate),
(grace)-[:WORKS_ON {since: 2023}]->(relate),
(grace)-[:WORKS_ON {since: 2022}]->(tck),
(noor)-[:MAINTAINS]->(relate),
(ada)-[:KNOWS]->(grace)
`;
const TEAM_OF = `
MATCH (p:Person)-[w:WORKS_ON]->(:Project {name: $project})
RETURN p.name AS name, w.since AS since
ORDER BY since
`;
const conn = Database.open(":memory:").connect();
conn.query(TEAM);
for (const project of ["relate", "opencypher-tck"]) {
console.log(project);
for (const row of conn.query(TEAM_OF, { project }).rows) {
console.log(" ", row.name, "since", row.since);
}
}relate Grace since 2023 Ada since 2024 opencypher-tck Grace since 2022
result.rows is an array of objects keyed by the names in RETURN. Notice that Noor isn't listed: she maintains relate through a MAINTAINS relationship, and the pattern asks for WORKS_ON.
Change it in a transaction
A connection commits each statement on its own until you call begin(). From then on, every statement is part of one transaction, commit() keeps it, and rollback() throws it away. This program adds Lin to the team and commits, then deletes every project and rolls back.
const { Database } = require("@relate/relate");
const TEAM = `
CREATE (ada:Person {name: 'Ada', role: 'engineer'}),
(grace:Person {name: 'Grace', role: 'engineer'}),
(noor:Person {name: 'Noor', role: 'maintainer'}),
(relate:Project {name: 'relate', language: 'Rust'}),
(tck:Project {name: 'opencypher-tck', language: 'Gherkin'}),
(ada)-[:WORKS_ON {since: 2024}]->(relate),
(grace)-[:WORKS_ON {since: 2023}]->(relate),
(grace)-[:WORKS_ON {since: 2022}]->(tck),
(noor)-[:MAINTAINS]->(relate),
(ada)-[:KNOWS]->(grace)
`;
const COUNT_PEOPLE = "MATCH (p:Person) RETURN count(p) AS n";
const COUNT_PROJECTS = "MATCH (p:Project) RETURN count(p) AS n";
const conn = Database.open(":memory:").connect();
conn.query(TEAM);
conn.begin();
conn.query(
"MATCH (tck:Project {name: 'opencypher-tck'}) " +
"CREATE (:Person {name: 'Lin', role: 'engineer'})-[:WORKS_ON {since: 2026}]->(tck)"
);
conn.commit();
console.log("people after commit:", conn.query(COUNT_PEOPLE).single().n);
conn.begin();
conn.query("MATCH (p:Project) DETACH DELETE p");
console.log("projects inside the transaction:", conn.query(COUNT_PROJECTS).single().n);
conn.rollback();
console.log("projects after rollback:", conn.query(COUNT_PROJECTS).single().n);people after commit: 4 projects inside the transaction: 0 projects after rollback: 2
Inside the transaction, the delete is visible: zero projects. After rollback(), both are back, and Lin, who was committed, stays. Each transaction starts with its own begin().
Read result metadata
A result describes itself as well as holding rows: the column names, what kind of statement it was, and a summary of what it changed.
const { Database } = require("@relate/relate");
const TEAM = `
CREATE (ada:Person {name: 'Ada', role: 'engineer'}),
(grace:Person {name: 'Grace', role: 'engineer'}),
(noor:Person {name: 'Noor', role: 'maintainer'}),
(relate:Project {name: 'relate', language: 'Rust'}),
(tck:Project {name: 'opencypher-tck', language: 'Gherkin'}),
(ada)-[:WORKS_ON {since: 2024}]->(relate),
(grace)-[:WORKS_ON {since: 2023}]->(relate),
(grace)-[:WORKS_ON {since: 2022}]->(tck),
(noor)-[:MAINTAINS]->(relate),
(ada)-[:KNOWS]->(grace)
`;
const conn = Database.open(":memory:").connect();
conn.query(TEAM);
const result = conn.query(
"MATCH (p:Person)-[w:WORKS_ON]->(:Project {name: $project}) " +
"RETURN p.name AS name, w.since AS since ORDER BY since",
{ project: "relate" }
);
console.log(result.columns);
for (const [column, value] of Object.entries(result.rows[0])) {
console.log(column, typeof value);
}
console.log("query type:", result.queryType);
const write = conn.query("MATCH (n:Person {name: 'Noor'}) SET n.role = 'lead'");
const summary = write.consume();
console.log("query type:", summary.queryType);
console.log("properties set:", summary.counters.properties_set);[ 'name', 'since' ] name string since number query type: r query type: w properties set: 1
A result has no list of column types: each value carries its own. A Cypher string arrives as a string, an integer as a number (a bigint beyond Number.MAX_SAFE_INTEGER, so nothing is rounded), and a node as an object with kind: "node". queryType is r for a read and w for a write that returns no columns, and consume() returns the summary, whose counters hold what the statement changed.
Catch an error
Every error the engine reports throws a RelateError, carrying the engine's classification of what went wrong. A misuse caught in JavaScript throws a TypeError or a ResultNotSingleError instead, so the programs on this page rethrow anything that isn't a RelateError. A statement that uses $name without a value is rejected before it runs:
const { Database, RelateError } = require("@relate/relate");
const TEAM = `
CREATE (ada:Person {name: 'Ada', role: 'engineer'}),
(grace:Person {name: 'Grace', role: 'engineer'}),
(noor:Person {name: 'Noor', role: 'maintainer'}),
(relate:Project {name: 'relate', language: 'Rust'}),
(tck:Project {name: 'opencypher-tck', language: 'Gherkin'}),
(ada)-[:WORKS_ON {since: 2024}]->(relate),
(grace)-[:WORKS_ON {since: 2023}]->(relate),
(grace)-[:WORKS_ON {since: 2022}]->(tck),
(noor)-[:MAINTAINS]->(relate),
(ada)-[:KNOWS]->(grace)
`;
const ROLE_OF = "MATCH (p:Person {name: $name}) RETURN p.role AS role";
const conn = Database.open(":memory:").connect();
conn.query(TEAM);
try {
conn.query(ROLE_OF);
} catch (e) {
if (!(e instanceof RelateError)) throw e;
console.log(e.name);
console.log(e.message);
console.log("gqlStatus:", e.gqlStatus);
console.log("code:", e.code);
console.log("retryable:", e.retryable);
}
console.log(conn.query(ROLE_OF, { name: "Noor" }).single().role);RelateError semantic error: ParameterMissing: Expected parameter(s): name gqlStatus: 42N02 code: Neo.ClientError.Statement.ParameterMissing retryable: false maintainer
The message says what's missing; the properties are what code branches on:
| Property | This error | Meaning |
|---|---|---|
gqlStatus | 42N02 | The GQLSTATUS code; the Python client reports the same code for the same error |
code | Neo.ClientError.Statement.ParameterMissing | The Neo4j status code for the same error |
retryable | false | Whether running it again can succeed; true for a write-write conflict, never for a mistake in the statement |
The errors reference lists this message with its cause and remedy. With the value bound, the same statement finds Noor's role.
Put it together
team.js puts the steps together in one program. It opens team.rl, creates the graph only when the store has no people yet, and prints who works on each project. A relate error anywhere ends it with the error's code and a nonzero exit status.
const { Database, RelateError } = require("@relate/relate");
const TEAM = `
CREATE (ada:Person {name: 'Ada', role: 'engineer'}),
(grace:Person {name: 'Grace', role: 'engineer'}),
(noor:Person {name: 'Noor', role: 'maintainer'}),
(relate:Project {name: 'relate', language: 'Rust'}),
(tck:Project {name: 'opencypher-tck', language: 'Gherkin'}),
(ada)-[:WORKS_ON {since: 2024}]->(relate),
(grace)-[:WORKS_ON {since: 2023}]->(relate),
(grace)-[:WORKS_ON {since: 2022}]->(tck),
(noor)-[:MAINTAINS]->(relate),
(ada)-[:KNOWS]->(grace)
`;
const TEAM_OF = `
MATCH (p:Person)-[w:WORKS_ON]->(:Project {name: $project})
RETURN p.name AS name, w.since AS since
ORDER BY since
`;
function ensureTeam(conn) {
const people = conn.query("MATCH (p:Person) RETURN count(p) AS n").single().n;
if (people === 0) {
conn.query(TEAM);
console.log("created the team graph");
} else {
console.log("found", people, "people");
}
}
function report(conn, project) {
console.log(project);
for (const row of conn.query(TEAM_OF, { project }).rows) {
console.log(" ", row.name, "since", row.since);
}
}
function main() {
const conn = Database.open("./team.rl").connect();
ensureTeam(conn);
report(conn, "relate");
report(conn, "opencypher-tck");
}
try {
main();
} catch (e) {
if (!(e instanceof RelateError)) throw e;
console.error(`relate error ${e.gqlStatus}: ${e.message}`);
process.exitCode = 1;
}created the team graph relate Grace since 2023 Ada since 2024 opencypher-tck Grace since 2022
Run it with node team.js. The client has no close method: each statement is durable in team.rl when query() returns, so the program ends without closing anything. Run it a second time and it prints found 3 people instead of creating the graph again, then the same report. If another process has team.rl open for writing, Database.open() throws a RelateError, and the program prints it and exits with status 1.
To read the file with no chance of changing it, open it read-only An open mode (--read-only) that never writes, checkpoints or migrates the store; writes fail, and a directory store can be read beside its writer. Read more in the glossary: Database.open("./team.rl", { readOnly: true }). A write then throws a RelateError with gqlStatus 42N18, and a path with no store behind it throws instead of creating a new store.
What you built
You have a Node.js program that keeps a graph of people and projects in team.rl, one file you can copy or archive. It reads with parameters, writes inside transactions, knows a read from a write by its query type, and reports errors by class and GQLSTATUS code. Ada, Grace, and Noor are waiting in that file for the next run.
Next
- Embed relate in a Python app: the same steps, the same graph, in Python.
- Use the Node.js client: open modes, typed values, tagged templates, async queries and errors beyond the first program.
- Run relate in the browser: the same graph in a web page, on the WebAssembly build.
- Errors: every error message, its cause, its remedy, and its codes.
- Transactions and concurrency: what a transaction isolates, and why a store has one writer.
- Use relate with the Vercel AI SDK: the same client behind a read-only agent tool.
- Your first graph in the CLI: the same graph from the command line.
- Storage formats: the three store layouts and what each writes to disk.