Profile
Back to NewsBack
GitHub Trending 5 min
Reader Mode
adam-cowley/neode: Neo4j OGM for Node.js

adam-cowley/neode: Neo4j OGM for Node.js

neode

neode is a Neo4j OGM for Node.js designed to take care of the CRUD boilerplate involved with setting up a graph database project. Just install, define your models and go.

It runs against any Cypher-compatible engine: Neo4j (Community, Enterprise, Aura) and Memgraph over Bolt, or the embedded Relate engine in-process.

Coming from neode 0.4.9? Your existing map-based schemas keep working — read the migration guide.

1.0 alpha. This branch is the 1.0.0-alpha.0 prerelease line. Install it with the next dist-tag — npm install neode still gives you 0.4.9 until 1.0 goes stable.

Quickstart

Four steps: install, connect, define a model, round-trip.

1. Install

npm install neode@next

Requires Node.js 20 or newer (Neo4j 5.x / 2025.x servers, neo4j-driver 6).

2. Connect

// index.ts
import { fromEnv } from 'neode'

// Reads NEO4J_URI, NEO4J_USERNAME, NEO4J_PASSWORD, NEO4J_DATABASE (and the // rest of the NEO4J_* set) — or from a .env file const neode = fromEnv()

Already have a driver, or want the embedded engine? Every connect option is real:

import neo4j from 'neo4j-driver'
import Neode from 'neode'

const driver = neo4j.driver('bolt://localhost:7687', neo4j.auth.basic('neo4j', 'password'))

const neode = Neode.forDriver(driver) // over a neo4j-driver Driver // Neode.forRelate(database) // or: embedded Relate engine, in-process

3. Define a model — pick your style

Two definition APIs. They feed the same metamodel and the same registry, so they are interchangeable — pick one per project or mix freely, and every read/write path behaves identically either way (parity between the two is covered by the golden-parity test suite).

// Decorators — a plain class, standard TC39 decorators
import { NodeEntity, Property, Uuid } from 'neode'

@NodeEntity() export class Person { @Uuid({ primary: true }) // auto-generated UUID primary key id: string

@Property({ type: 'string', unique: true, required: true, min: 2, max: 50 }) name: string

@Property({ type: 'int' }) age: number }

// Map-based — no decorators, same result
import { defineModel } from 'neode'

export const Person = defineModel('Person', { id: { type: 'uuid', primary: true }, name: { type: 'string', unique: true, required: true, min: 2, max: 50 }, age: 'int', // shorthand: property : type })

Both return a class you can pass to the facade, repositories and services below.

4. Round-trip

// Create — validated against the model before the write
const adam = await neode.create(Person, { name: 'Adam', age: 30 })

// Read — by primary key, or by filter const found = await neode.find(Person, adam.id) const people = await neode.get(Person, { age: 30 })

// Update await neode.update(adam, { age: 31 })

// Delete await neode.delete(adam)

Then create the constraints and indexes the model declares (unique, index, …):

await neode.installSchema() // CREATE CONSTRAINT … IF NOT EXISTS / CREATE INDEX … IF NOT EXISTS
await neode.dropSchema() // reverses installSchema() exactly

exists constraints need Neo4j Enterprise — set NEO4J_ENTERPRISE=true or call neode.setEnterprise(true).

That's the whole loop. Everything else the library does is in the matrix below.

Model definitions are process-global. Models registered through decorators, defineModel() or neode.model() / with() live in one shared registry, so every Neode instance in a process sees them. One set of models per process is the intended shape; run separate processes for separate schemas.

Feature matrix

| Area | What you get | Details | | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------ | | Property types | any, string, number, int, integer, float, boolean, uuid, date, time, datetime, localtime, localdatetime, point, duration — plus array, defaults, primary / unique / index, hidden / protected / readonly, onCreateSet / onMatchSet / alwaysSet | Entities & decorators | | Validation | Zod-powered rule names in the property config (required, min / max, email, regex, uuid, …). Every write path — save, create, update — is validated, with all failures collected into one error | Validation rules | | Relationships | @OneToOne / @OneToMany (eager loading), relationship entities with @StartNode / @EndNode, relate() / detach() with edge properties, cascade delete options | Relationship decorators | | Read-model decorators | @RelatedOne, @RelatedMany, @RelationshipCount, @RelationshipExists, @LabelPresent, @Computed, @Expression — projected into the read query, one query per read | API reference | | Repositories | Repository — find, findAll, count, save, patch, delete, increment, countBy, deleteAll — plus custom @EntityRepository subclasses (TypeORM parity) | Repositories | | Transactions | inReadTransaction / inWriteTransaction callback form, openReadTransaction / openWriteTransaction handle form, and batch() with per-statement errors | Transactions | | Events | EventType.NODE_CREATED / NODE_UPDATED / NODE_DELETED, RELATIONSHIP_CREATED / RELATIONSHIP_MERGED, QUERY_EXECUTED — emitted synchronously, scoped to the instance, inside and outside transactions | Events | | Schema sync & drift | installSchema() / dropSchema() with deterministic names, inspectSchema(), and the drift workflow: schemaDrift() → formatSchemaDrift() → applySchemaDrift() | Schema sync | | Full-text search | search() over a full-text index, toFulltextQuery() to escape user input — hydrated entities with relevance scores | kNN & full-text search | | Vector search | @Vector({ dimensions }) on a property + knn() k-nearest-neighbour search (Cypher 25 SEARCH clause with a procedure fallback) | kNN & full-text search | | Pagination | Offset mode { page, limit, order? } and cursor/keyset mode { limit, orderBy, after?/before? } on a temporal property, both via neode.paginate() | The Neode facade | | Bulk writes | createMany() / mergeMany() — chunked UNWIND writes, one managed transaction per chunk, rows validated like single creates | The Neode facade | | Querying | Fluent Builder (all values parameterised), raw neode.cypher(), hydrate / hydrateFirst / toCollection, toJson() / jsonSafe() serialisation | Query builder | | Error taxonomy | Typed NeodeError base, ConstraintViolationError (with parsed label / property), TransientError (retryable), classifyError() / isTransientError() | API reference | | Agent ontology | ontology() / ontologyMarkdown() — emit the live graph schema for grounding Text2Cypher prompts or MCP resources | API reference | | Testing & ops | wipe(), testId(), data factories (neode.factory()), verifyConnectivity() / waitUntilReachable(), splitCypherStatements() / runCypherScript() | API reference |

kNN & full-text search

import { knn, search, toFulltextQuery } from 'neode'

const runner = neode.getRunner()

const vectorHits = await knn(runner, 'Person', 'person_plot_vector', vector, 5) const textHits = await search(runner, 'Person', 'person_plot', toFulltextQuery(query))

Indexes are declared on the model — @Vector({ dimensions: 1536 }) for vectors (installed by installSchema()), fulltext: true on a property or installFulltextIndexes(definitions) for full-text.

Docs

| Document | Contents | | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | | docs/api-reference.md | Full API — decorators, map schemas, facade methods, transactions, events, repositories, validation, connection providers | | docs/migration-guide.md | Migrating from neode 0.4.9 — IDs, facade signatures, sessions, batch, cascade, and what's new |

Chat with me