mikro-orm-neo4j
v0.3.2
Published
Neo4j driver for MikroORM (OGM-style graph support)
Maintainers
Readme
mikro-orm-neo4j
A native Neo4j driver for MikroORM.
📚 Full documentation — getting started, modeling, querying, multi-tenancy and
operations. The pages live in docs/ and are published on every push to main.
This package provides seamless integration between MikroORM and Neo4j, enabling graph-native operations, complex Cypher query building, and advanced graph data modeling (such as relationship properties) while keeping the familiar MikroORM API.
Features
- 🚀 Full MikroORM
EntityManagerandEntityRepositorysupport - 🕸️ Graph-native relationships: Support for proper directed relationships in Neo4j (
IN,OUT). - 💎 Relationship Properties (Pivot Entities): Model complex graph relationships natively.
- 🏗️ Neo4jQueryBuilder: Fluent API wrapping
@neo4j/cypher-builderto write raw Cypher natively with ORM parameter injection, pattern matching, and relationship navigation (.related()). - 🏷️ Polymorphic Queries: Support for multi-label inheritance and querying.
- 🧭 Schemas (multi-tenancy): MikroORM's
schemaoption — fixed or wildcard — implemented as soft subgraphs, scoping writes, reads and both ends of every relationship. - 🌊 Cursor-based streaming:
em.stream()reads through a Bolt cursor (PULL n) instead of buffering the result — bounded memory, populated relations merged as rows arrive,close()/asStream()/ live stats. - 🎯 Partial loading that is actually partial:
fields/excludebecome a Cypher map projection (RETURN n { .id, .title }), so the properties you did not ask for never leave the server —lazyproperties included. - 🧮 Full filter-operator coverage:
$like/$ilike,$re(andRegExpvalues),$nin,$not,$exists, the list operators ($overlap,$contains,$contained,$size) and the collection ones ($some,$none,$every,$size) over relations — see query conditions. - 🔎 Full-text search, declared where it is searched:
@Index({ type: 'fulltext' })on a readonly@Property({ type: new Neo4jFullTextType([...]), persist: false })— PostgreSQL'sFullTextTypeshape — backs it with a real Neo4j fulltext index (analyzer, weights and all);{ search: { $fulltext: '…' } }reads exactly that index, an interface indexes every implementor at once, and the GraphQL SDL declares it with@fulltext. - 🗂️ Indexes & Constraints:
@Index()/@Unique()declarations are materialized as real Neo4j indexes and constraints viaorm.schema.ensureIndexes(). - 🧩 Native Decorator Extensions: Fully type-safe strongly-defined
neo4jandrelationconfiguration parameters integrated cleanly inside MikroORM properties via declaration merging. - 📦 Dual-format support (ESM & CommonJS).
Installation
pnpm add mikro-orm-neo4j
pnpm add -D @mikro-orm/core @mikro-orm/decorators @mikro-orm/reflectionQuick Start
1. Initialize the ORM
Create your MikroORM instance using the Neo4j driver:
import { MikroORM } from 'mikro-orm-neo4j';
import { TsMorphMetadataProvider } from '@mikro-orm/reflection';
const orm = await MikroORM.init({
clientUrl: 'bolt://localhost:7687', // Your Neo4j URI
user: 'neo4j',
password: 'password',
entities: ['./dist/entities'],
entitiesTs: ['./src/entities'],
metadataProvider: TsMorphMetadataProvider,
});
const em = orm.em;2. Define Entities
You can define entities using standard MikroORM decorators, but with native Neo4j extensions.
Decorator Approach
Support for native extension properties relationship inside @ManyToOne/@ManyToMany decorators, and labels inside @Entity() allows you to configure graph-specific metadata.
import { Entity, PrimaryKey, Property, ManyToOne, Collection, OneToMany } from '@mikro-orm/core';
@Entity({ labels: ['User', 'Person'] })
export class User {
@PrimaryKey()
id!: string;
@Property()
name!: string;
@OneToMany(() => Post, post => post.author)
posts = new Collection<Post>(this);
}
@Entity()
export class Post {
@PrimaryKey()
id!: string;
@Property()
title!: string;
// Utilize the natively-augmented relation object to denote Neo4j specifics
@ManyToOne(() => User, { relationship: { type: 'CREATED', direction: 'IN' } })
author!: User;
}Functional Approach (defineEntity)
For those who prefer a functional style or are building dynamic schemas, mikro-orm-neo4j provides a specialized defineEntity wrapper that includes the neo4j helper for property configuration.
import { defineEntity, neo4j } from 'mikro-orm-neo4j';
import * as crypto from 'node:crypto';
export const MovieSchema = defineEntity({
name: 'Movie',
labels: ['Cinema', 'Show'], // Native Neo4j labels
properties(p) {
return {
id: p.uuid().primary().onCreate(() => crypto.randomUUID()),
title: p.string(),
released: p.integer(),
actors: () => neo4j(
p.manyToMany(ActorSchema).mappedBy('movies'),
{ type: 'ACTED_IN', direction: 'IN' }
),
};
},
});
// To add logic or methods, use setClass
export class Movie extends MovieSchema.class {
get isNew(): boolean {
return this.released > 2020;
}
}
MovieSchema.setClass(Movie);Interface inheritance and relationships
You can define a reusable interface entity that captures common fields and declared relationship fields, and then implement it in concrete Neo4j node entities.
const ProductionSchema = defineEntity({
name: 'Production',
abstract: true,
inheritance: 'interface',
properties(p) {
return {
title: p.string(),
actors: () => p.manyToMany(PersonSchema),
};
},
});
const MovieSchema = defineEntity({
name: 'Movie',
extends: ProductionSchema,
labels: ['Movie'],
properties(p) {
return {
released: p.integer(),
actors: () => neo4j(
p.manyToMany(PersonSchema).owner().pivotEntity(() => ActedInSchema),
{ type: 'ACTED_IN', direction: 'IN' },
),
};
},
});
const SeriesSchema = defineEntity({
name: 'Series',
extends: ProductionSchema,
labels: ['Series'],
properties(p) {
return {
episodes: p.integer(),
actors: () => neo4j(
p.manyToMany(PersonSchema).owner().pivotEntity(() => ActedInSeriesSchema),
{ type: 'ACTED_IN', direction: 'IN' },
),
};
},
});This produces GraphQL SDL where Production is emitted as an interface with the declared relationship:
interface Production {
title: String!
actors: [Person!]! @declareRelationship
}
type Movie implements Production @node {
released: Int!
actors: [Person!]! @relationship(type: "ACTED_IN", direction: IN, properties: "ActedIn")
}
type Series implements Production @node {
episodes: Int!
actors: [Person!]! @relationship(type: "ACTED_IN", direction: IN, properties: "ActedInSeries")
}Setting up TypeScript Typings
mikro-orm-neo4j adds its graph options to MikroORM's own option types by declaration merging, so
relationship and labels autocomplete beside nullable and ref — no as any anywhere. The
property-level option merges into PropertyOptions; the entity-level ones need one line of
ceremony, because core declares EntityOptions as a type alias rather than an interface.
The exact setup, both routes, and which package the decorators come from in MikroORM 7: TypeScript setup.
3. Relationship Properties (Pivot Entities)
In Neo4j, relationships can have their own properties. You can model this using a standard @Entity() configured as a relationshipEntity.
import { Entity, PrimaryKey, Property, ManyToOne, Collection, ManyToMany } from '@mikro-orm/core';
@Entity()
export class Actor {
@PrimaryKey()
id!: string;
@Property()
name!: string;
@ManyToMany(() => Movie, undefined, {
pivotEntity: () => ActedIn,
inversedBy: 'actors',
relationship: { type: 'ACTED_IN', direction: 'OUT' }
})
movies = new Collection<Movie>(this);
}
@Entity()
export class Movie {
@PrimaryKey()
id!: string;
@Property()
title!: string;
}Using decorators
Mark this entity as a Neo4j Relationship instead of a Node!
@Entity({ relationship: { type: 'ACTED_IN' } })
export class ActedIn {
@PrimaryKey()
id!: string;
@ManyToOne(() => Actor, { primary: true })
actor!: Actor;
@ManyToOne(() => Movie, { primary: true })
movie!: Movie;
@Property()
roles!: string[]; // Relationship property stored inside Neo4j relation data!
}Using defineEntity
const ActedInSchema = defineEntity({
name: 'ActedIn',
relationship: { type: 'ACTED_IN' },
properties(p) {
return {
id: p.uuid().primary(),
actor: () => p.manyToOne(ActorSchema).primary(),
movie: () => p.manyToOne(MovieSchema).primary(),
roles: p.array('string'),
};
},
});[!TIP] Why
relationshipinstead of custom decorators like@Rel? Relying on MikroORM's built-inPropertyOptionsensures better compatibility with the internal lifecycle hook systems and removes the buggy reflection extraction complexities of scanning custom external decorators during schema generation.
Filtering through a relation
A filter may traverse relations, at any depth. The driver turns each hop into a pattern subquery rather than comparing a property:
await em.find(Book, { tags: { name: 'Fiction' } });
await em.find(Tag, { books: { author: { name: 'Ann' } } }); // two hops
await em.find(Book, { $or: [{ tags: { name: 'Fiction' } }, { author: { name: 'Bob' } }] });MATCH (this0:Book)
WHERE EXISTS { MATCH (this0)-[:TAGGED]->(t:Tag) WHERE t.name = $param0 }
RETURN this0 AS nodePassing a key instead of a condition ({ author: 'A1' }, or an operator such as
{ author: { $in: [...] } }) keeps comparing the denormalized foreign key stored on the node — no
traversal, since the answer is already there.
populate: ['$infer']
Populating whatever the filter reached works here, and unlike the SQL drivers it is type-safe:
const tags = await em.find(Tag, { books: { author: { name: 'Ann' } } }, { populate: ['$infer'] });
tags[0].books[0].author.$.name; // typed as loaded — Loaded<Tag, 'books' | 'books.author'>MikroORM implements $infer inside the SQL query builder, reading the hint off the joins the filter
produced; a graph read has no joins, so the driver walks the filter itself and populates select-in.
On the SQL drivers the hint reaches Loaded<> as the opaque literal '$infer', which matches no
key — IsPrefixed special-cases '*' but never '$infer' — so Loaded<Book, '$infer'> is
structurally Loaded<Book, never>, and the contravariant load-hint marker makes it unassignable
even to Loaded<Book, 'author'>. Here the hint resolves to the traversed paths, so the relations
come back typed as loaded.
How many-to-many is loaded
The driver stays on MikroORM's standard pivot path (usesPivotTable() is true), so pivotEntity,
relation properties, per-relation populate limits and reference-only loading behave exactly as they
do on the SQL drivers. The difference is what a "pivot table" means here: it is the edge itself,
so the join is a pattern match rather than a table join.
Populating a collection therefore costs two queries: one that walks the edges, and one that loads the
targets it found through the normal read path — which is what keeps filters, field selection, nested
populate, ordering and schema scoping identical to a plain find.
-- 1. walk the edges
MATCH (owner:Actor)-[:ACTED_IN]->(target:Movie) WHERE owner.id IN $ids
RETURN owner, target
-- 2. load the targets (regular read path)
MATCH (this0:Movie) WHERE this0.id IN $ids RETURN this0 AS nodeNote. Auto-generated pivots are plumbing, not model: they never appear as
@nodetypes in the generated GraphQL SDL, and the inverse-side field MikroORM synthesizes for a one-sided many-to-many is omitted from it too.
4. Constructing Complex Cypher Queries (Neo4jQueryBuilder)
The custom Neo4jQueryBuilder extends MikroORM principles and seamlessly bridges them to robust graph traversal queries via Cypher.
Filtering & Relation traversal with match and related
const qb = em.createQueryBuilder(User);
// Finds users named John Doe who created a specific post, and returns the post title
const result = await qb
.match()
.where('name', 'John Doe')
.related(User, 'posts') // automatically extracts 'CREATED' relationship metadata
.where('title', 'Graph Databases 101')
.return(['title'])
.execute();Complex Multi-Path Traversals
const qb = em.createQueryBuilder(Actor);
const Cypher = qb.getCypher(); // Direct access to underlying @neo4j/cypher-builder toolkit
// Find actors who acted in "The Matrix" AND also directed it
const { cypher, params } = qb
.match()
.related(Actor, 'movies')
.where('title', 'The Matrix')
.match() // Starts a new MATCH statement linking the context
// Use raw pattern building for complex graph spans
.rawCypherPattern(new Cypher.Pattern(qb.getCurrentNode()).related(new Cypher.Relationship({ type: 'DIRECTED' })).to(new Cypher.Node({ labels: ['Movie'] })))
.return(['name'])
.build();5. Read Replicas & Load Balancing
For large-scale applications, mikro-orm-neo4j supports read-replicas out of the box. You can configure multiple read-only connections in MikroORM.init().
const orm = await MikroORM.init({
clientUrl: 'bolt://primary:7687',
user: 'neo4j',
password: 'password',
replicas: [
{ clientUrl: 'bolt://replica-1:7687', user: 'neo4j', password: 'password' },
{ clientUrl: 'bolt://replica-2:7687', user: 'neo4j', password: 'password' },
],
});Which reads go there is opt-in per query, and the rule is worth stating exactly:
A read that asks goes to a replica.
connectionTypeis MikroORM's own option, and it is honoured onfind,findOne,count, streams, and the relation loads behindpopulate:const books = await em.find(Book, {}, { connectionType: 'read' }); const total = await em.count(Book, {}, { connectionType: 'read' }); const cursor = em.stream(Book, { connectionType: 'read' });A populated find loads its relations from the same connection as its root, so one logical read is never split across two servers with different lag.
A read that says nothing stays on the primary. Replica reads are not a default here. A replica lags by an unbounded amount, so a read following a write in the same request can legitimately miss it, and making every find quietly eventually-consistent is not a trade to take on a caller's behalf.
A transaction always wins. Inside
em.transactional(), statements run on the connection the transaction was opened on —connectionTypecannot move one out of it, because doing so would execute it outside the transaction entirely, blind to its uncommitted state and free of its rollback.Writes never route. No write path consults the option, and core does not declare it on a write's options in the first place.
Manual control, for anything the option does not cover:
const readConn = em.getDriver().getConnection('read'); const writeConn = em.getDriver().getConnection('write');A write on a read connection is refused. The session is opened in read access mode, so the server rejects it. A declared routine is refused earlier still — before a statement is sent — because its declaration says what access it needs; see §15.
Replica reads can be stale. That is the whole trade you are making by asking. Read-after-write within one request is the case that bites; leave those on the primary.
6. Transaction Management
Proper transactional support is essential for data integrity. mikro-orm-neo4j fully supports MikroORM's transaction API.
Declarative Transactions
Use em.transactional() to wrap multiple operations in a single Neo4j transaction. If the callback throws, the transaction is automatically rolled back.
await em.transactional(async (txEm) => {
const user = txEm.create(User, { name: 'Alice' });
txEm.persist(user);
const post = txEm.create(Post, { title: 'First Post', author: user });
txEm.persist(post);
await txEm.flush();
});Manual Transaction Control
const fork = em.fork();
await fork.begin();
try {
// ... operations
await fork.commit();
} catch (e) {
await fork.rollback();
throw e;
}7. Exception Handling
The driver automatically maps Neo4j-specific error codes to standard MikroORM exceptions:
| Exception | Neo4j Error Code Example |
| :--- | :--- |
| UniqueConstraintViolationException | Neo.ClientError.Schema.ConstraintValidationFailed |
| NotNullConstraintViolationException | Neo.ClientError.Schema.PropertyExistenceError |
| SyntaxErrorException | Neo.ClientError.Statement.SyntaxError |
| ReadOnlyException | Neo.ClientError.Statement.AccessMode (Write on Read Replica) |
| DeadlockException | Neo.TransientError.Transaction.DeadlockDetected |
| ConnectionException | Neo.TransientError.Network.ConnectivityError |
8. Indexes & Constraints (ensureIndexes)
A graph has no tables to create: its schema is its indexes and constraints. orm.schema.ensureIndexes() reads the indexes / uniques you declared on your entities and creates them in Neo4j.
@Entity({ tableName: 'Document' })
@Index({ properties: ['tenant', 'id'] })
@Unique({ properties: ['tenant', 'externalId'] })
export class Document {
@PrimaryKey()
id!: string;
@Property()
tenant!: string;
@Property()
externalId!: string;
}
// Typically at bootstrap:
await orm.schema.ensureIndexes();Every statement is emitted with IF NOT EXISTS, so ensureIndexes() is idempotent and safe to run on every boot — no diffing against the live schema. orm.schema.create() delegates to it, and orm.schema.getCreateSchemaSQL() returns the Cypher without executing it, which is useful as a dry-run:
console.log(await orm.schema.getCreateSchemaSQL());
// CREATE RANGE INDEX `Document_tenant_id_idx` IF NOT EXISTS FOR (n:`Document`) ON (n.`tenant`, n.`id`);
// CREATE CONSTRAINT `Document_tenant_externalId_unique` IF NOT EXISTS FOR (n:`Document`) REQUIRE (n.`tenant`, n.`externalId`) IS UNIQUEIndex types
type maps onto the Neo4j index kinds. Omitting it gives you a RANGE index, which is what you want for equality and range lookups.
| type | Neo4j index | Notes |
|---|---|---|
| (omitted) / 'range' | RANGE | Supports composite keys. |
| 'text' | TEXT | Single property only. |
| 'point' | POINT | Single property only. |
| 'fulltext' | FULLTEXT | Declared on a search field — see full-text indexes below. |
Property order matters for composite indexes: ['tenant', 'id'] can serve a lookup by tenant alone, but not by id alone.
Full-text indexes
A fulltext index is never declared as an index. It is declared by the search field that reads it —
an unpersisted property typed as the search, the way PostgreSQL types a tsvector column — which is
what lets a filter say what it searches instead of naming an index and hoping it covers the right
properties:
@Entity()
export class Book {
@Property() title!: string;
@Property() summary!: string;
@Index({ type: 'fulltext' })
@Property({
type: new Neo4jFullTextType(['title', 'summary'], {
name: 'BookSearch',
analyzer: 'english',
eventuallyConsistent: false,
queryName: 'searchBooks',
}),
persist: false,
nullable: true,
})
readonly search?: string;
}CREATE FULLTEXT INDEX `BookSearch` IF NOT EXISTS FOR (n:`Book`) ON EACH [n.`title`, n.`summary`]
OPTIONS { indexConfig: { `fulltext.analyzer`: 'english', `fulltext.eventually_consistent`: false } }Both halves are required, as in PostgreSQL: the type says which properties are scored, the index is
what gets created. An index pointing anywhere but at a search field is refused at discovery, and so is
a search field with no index. queryName is GraphQL metadata and never reaches Cypher — see § 12.
Searching is covered in query conditions.
Nodes, edges and labels
- Multi-label entities are indexed on their primary label only. Neo4j indexes per label, and a query matching a secondary label already seeks through the primary one, so indexing all of them would multiply indexes for no gain.
- Relationship entities (
@Entity({ relationship: { type: 'ACTED_IN' } })) are indexed as edges:FOR ()-[r:ACTED_IN]-() ON (r.billing). - Indexes are built on the property name as written on the node — the JS key, which is what the driver persists. The naming strategy affects the label, not the properties.
Options that have no Neo4j equivalent
Rather than emit an index that quietly means something different from what you declared, the generator is explicit about what it cannot map:
| Option | Behaviour |
|---|---|
| where (partial index/constraint) | Throws. A partial unique emitted as a total one would reject legitimate rows. Model the filtered subset with a dedicated label instead. |
| expression (functional index) | Warns and skips. |
| type: 'vector' | Throws — vector indexes need an explicit dimension and similarity function; create them with raw Cypher. |
| include, fillFactor, invisible, deferMode, clustered | Ignored — they are SQL planner hints with no Neo4j meaning. |
NODE KEY and IS NOT NULL constraints are Enterprise-only in Neo4j and are not emitted.
Not covered yet:
update()(which would require diffing againstSHOW INDEXES) anddrop(). SinceensureIndexes()is idempotent, removing a declaration does not remove the index — drop it manually withDROP INDEX <name>.
9. Schema Generation & GraphQL Support
The driver includes a Neo4jSchemaGenerator that can export your MikroORM metadata as a GraphQL SDL (Schema Definition Language) compatible with the @neo4j/graphql library.
This is particularly powerful for:
- 🤖 AI-Ready Schemas: AI agents and LLMs perform significantly better when provided with a detailed SDL including semantic descriptions.
- ⚡ Instant APIs: Generate a standard GraphQL schema for Neo4j based on your ORM models.
Generating SDL
You can access the generator through the standard MikroORM schema API:
const sdl = orm.schema.getGraphSdl();
console.log(sdl);From SDL to a running API. A complete GraphQL backend with NestJS takes that string the rest of the way —
@neo4j/graphqlturns it into an executable schema,GraphQLModuleserves it, and the resulting CRUD API reads and writes the same nodes yourEntityManagerdoes. Pinned end to end bytests/Neo4jNestGraphQL.test.ts.
Enrichment with comment
Both the decorator and functional APIs support a comment property. These comments are automatically translated into GraphQL docstrings (triple-quoted strings) in the generated SDL.
Decorator Approach
@Entity({ comment: 'Represents a human user in the system.' })
export class User {
@PrimaryKey()
id!: string;
@Property({ comment: 'The display name used in public profiles.' })
name!: string;
}Functional Approach
export const ProductSchema = defineEntity({
name: 'Product',
comment: 'An item available for purchase.',
properties(p) {
return {
id: p.uuid().primary(),
price: p.number({ comment: 'Retail price in USD.' }),
};
},
});Resulting SDL Example
"""
An item available for purchase.
"""
type Product @node {
id: ID!
"""
Retail price in USD.
"""
price: Float!
}10. Schemas — multi-tenancy and soft-subgraphs
MikroORM's schema entity option splits data into named partitions: one tenant per schema, an
audit schema kept apart from live data, a shared public catalog. SQL drivers implement it with
real database schemas. Neo4j has no such thing inside a database, so this driver implements it as a
soft subgraph: a reserved __schema node property plus a SchemaNode marker label, applied
implicitly by the driver everywhere identity or matching happens.
The public API is the ordinary MikroORM one — nothing here is Neo4j-specific:
const tenant = orm.em.fork({ schema: 'tenant-1' });
tenant.create(Invoice, { id: 'INV-1', total: 90 });
await tenant.flush();
await tenant.find(Invoice, {}); // only tenant-1's invoices
await tenant.find(Invoice, {}, { schema: 'tenant-2' }); // one-off overrideOnboarding a tenant costs zero migrations. The schema is a property, not DDL: a new tenant is a new value, not a new set of tables.
The three modes
| Declaration | Meaning | Resolved from |
|---|---|---|
| (no schema option) | Not schema-aware. Untouched by all of this. | — |
| { schema: '*' } | Wildcard — belongs to whichever schema is querying. | FindOptions.schema → em.schema → config schema → public |
| { schema: 'audit' } | Fixed. Always audit, whatever the EntityManager says. | the declaration itself |
Precedence is core's own chain, so it matches the SQL drivers exactly: a fixed schema always
wins, and '*' never means "all schemas" at query time.
// Decorator API
@Entity({ schema: '*' }) // per-tenant
export class Invoice { /* … */ }
@Entity({ schema: 'audit' }) // always in `audit`
export class AuditLog { /* … */ }
@Entity({ schema: 'public' }) // shared catalog
export class Currency { /* … */ }
// defineEntity API
export const InvoiceSchema = defineEntity({
name: 'Invoice',
schema: '*',
properties(p) {
return { id: p.uuid().primary(), total: p.number() };
},
});Set the schema per unit of work with a fork, per query with FindOptions.schema, or ambiently:
const tenant = orm.em.fork({ schema: 'tenant-1' }); // preferred: scoped, no leakage
orm.em.schema = 'tenant-1'; // ambient, respects the request context
await orm.em.find(Invoice, {}, { schema: 'tenant-2' }); // one query onlyStorage model
A schema-aware node stores the resolved schema — including the default public — and gains the
marker label:
(:Invoice:SchemaNode { id: "INV-1", __schema: "tenant-1", total: 90 })The property is the source of truth: it is part of the MERGE key, and of every implicit predicate.
The marker label exists so cross-entity admin work ("list every schema", "drop this tenant") has one
label to match, backed by a single global index. The schema is never left absent to mean "default" —
that would force IS NULL predicates and make the index unusable.
Consequences worth knowing:
- The schema is part of node identity. The same primary key under two schemas is two nodes.
Re-persisting the same
(id, schema)updates that node in place. __schemais immutable through updates. Nothing moves a node between schemas; see the migration recipe below for the deliberate, explicit alternative.__schemanever reaches your entities. Hydration strips exactly that key — a property of your own genuinely namedschemais left alone.
Cross-schema relations
Each end of a relationship resolves the schema of its own entity, so a per-tenant entity can point at a fixed shared-catalog entity without the catalog being copied per tenant:
@Entity({ schema: '*' })
export class Invoice {
@PrimaryKey() id!: string;
@ManyToOne(() => Currency, { ref: true, relationship: { type: 'PRICED_IN', direction: 'OUT' } })
currency!: Ref<Currency>; // Currency is @Entity({ schema: 'public' }) — one node, shared
}Endpoint scoping is a correctness property, not a convenience: without it, two tenants sharing a business id would let one tenant's edge attach to the other tenant's node — the cross-schema form of the leak documented in the C11 appendix.
Query builder
Neo4jQueryBuilder scopes the root match and any relation target it resolved from entity metadata,
with two explicit escape hatches:
const qb = tenant.createQueryBuilder(Invoice).match();
qb.build(); // … WHERE this0.__schema = $param0
qb.withSchema('tenant-2'); // read another schema (fixed-schema entities still win)
qb.ignoreSchema(); // read across every schema — deliberately explicitBoth are no-ops on entities that never declared schema, and may be chained in any order.
Indexes (Neo4j ≥ 5.7)
ensureIndexes() emits, per schema-aware entity, a composite uniqueness constraint on
(__schema, …primary key) — schema first, so the backing index also serves scoped scans — prefixes
your own uniques and RANGE indexes with __schema (SQL parity: unique is per-schema, so two tenants
may share an email), and emits one global range index on SchemaNode(__schema).
CREATE CONSTRAINT `Invoice___schema_id_unique` IF NOT EXISTS
FOR (n:`Invoice`) REQUIRE (n.`__schema`, n.`id`) IS UNIQUEComposite uniqueness constraints require Neo4j 5.7 or newer (Community included). That is a hard
requirement of schema support; on an older server ensureIndexes() fails with an explicit message
rather than leaving identity unprotected. NODE KEY is Enterprise-only and is not used.
Adding schema to an entity that already has data
Turning on the option makes every query filter on __schema, and nodes written before the change
have none — they become invisible. Backfill before deploying the entity change:
const generator = orm.schema as Neo4jSchemaGenerator;
await generator.assignDefaultSchema(Invoice); // defaults to the entity's resolved schema
await generator.assignDefaultSchema(Invoice, 'tenant-1');It writes in batches (CALL { … } IN TRANSACTIONS), so it must not run inside em.transactional.
The equivalent raw Cypher, if you prefer to run it as a migration:
MATCH (n:Invoice) WHERE n.`__schema` IS NULL
CALL { WITH n SET n.`__schema` = 'public' SET n:SchemaNode } IN TRANSACTIONS OF 10000 ROWSRolling back is simply removing the schema option: the extra property and label linger harmlessly,
and MATCH (n:SchemaNode) REMOVE n.__schema, n:SchemaNode clears them.
Limitations, stated plainly
- Soft, not hard, isolation. Every schema lives in one database. If you need physical separation, point MikroORM at a different database or instance. A raw Cypher query can still read across schemas, by design.
- Raw surfaces are unscoped, exactly as raw SQL is in MikroORM:
em.run(), virtual-entityexpressions, and the query builder'spattern()/call()composition APIs. Add the predicate yourself there. - Opt-in per entity — a deliberate divergence from SQL. In SQL every table lives in a schema, so
em.schemaaddresses everything. Here, an entity that never declaredschemais untouched: no label, no property, no predicate, byte-identical Cypher, even when a schema is explicitly forced throughFindOptions.schema, a fork, orwithSchema(). This keeps existing databases working unchanged; the alternative would silently return nothing for pre-existing nodes. __schemaandSchemaNodeare reserved on schema-aware entities.- The denormalized scalar FK property stored on a node keeps only the first primary-key column and is ambiguous across schemas — a pre-existing limitation (C10/C11 appendix); the edge carries the truth.
Results
tests/Neo4jSchemaSupport.test.ts pins all of the above
against a real Neo4j (Testcontainers): resolution precedence, identity across schemas, scoped
find/count/update/delete, relationship-endpoint isolation, cross-schema relations, pivot scoping,
query-builder scoping, hydration hygiene, the backfill utility, and byte-identical Cypher for
entities that never opted in. One test runs EXPLAIN on a scoped find and asserts the plan seeks an
index rather than scanning the label.
11. Streaming large result sets
em.stream() reads the result through a Bolt cursor: records are pulled chunkSize at a time
(PULL n) and handed over as they arrive, so memory tracks the batch size rather than the size of
the result. The returned cursor is an async iterable, and ending the loop — break, an error,
close() — cancels the query server-side and returns the session to the pool.
const stream = em.stream(Book, {
where: { price: { $gt: 100 } },
populate: ['author', 'tags'],
orderBy: { id: 'ASC' },
chunkSize: 500, // records per round trip; 1000 by default
});
for await (const book of stream) {
console.log(book.title, book.author.$.name, book.tags.length);
}Populated relations are expanded, not collected: find() groups a to-many relation with
collect(), which packs the whole collection into one record; a stream returns one row per
(root, child) pair and reassembles the entities as the rows arrive, so the fetch size keeps bounding
memory. Nested paths (populate: ['books.tags']) come out of the same query, and limit / offset
window the entities rather than the multiplied rows.
The cursor also does what a for await loop cannot:
const cursor = em.stream(Book, { orderBy: { id: 'ASC' } });
cursor.asStream({ transform: (b) => `${b.title}\n` }).pipe(createWriteStream('books.txt'));
await cursor.close(); // release it by hand; idempotent
cursor.stats; // { fetchSize, records, yielded, closed, took }Raw Cypher streams too — em.streamRaw(cypher, params, { chunkSize }) is em.run() one row at a
time, and qb.stream() does the same for the query builder. Streamed entities are not managed;
see streaming for the query shapes that stay lazy, cancellation,
transactions, virtual entities, and how the cursor compares to Drivine's.
12. Full-text search
Neo4j reads a fulltext index through a procedure, not a WHERE predicate, so a $fulltext
condition does not filter a scan — it replaces it. What a search names is a search field, never
an index: an unpersisted property typed as the search, which is PostgreSQL's shape — there the same
search is a tsvector column fed by the others, and $fulltext filters on it.
import { Neo4jFullTextType } from 'mikro-orm-neo4j';
@Entity()
export class Book {
@PrimaryKey() id!: string;
@Property() title!: string;
@Property() summary!: string;
/** Stands for a search over several fields; nothing is stored for it. */
@Index({ type: 'fulltext' })
@Property({
type: new Neo4jFullTextType({ A: 'title', C: 'summary' }, { analyzer: 'english' }),
persist: false,
nullable: true,
})
readonly search?: string;
}await em.find(Book, { search: { $fulltext: 'graph databases' } }); // title + summary
await em.find(Book, { search: { $fulltext: 'graph' }, price: { $lt: 50 } });
// Paging is MikroORM's own, and bounds the index scan when nothing can drop a row
await em.find(Book, { search: { $fulltext: 'graph' } }, { limit: 10, offset: 20 });CALL db.index.fulltext.queryNodes('Book_title_summary_idx', $param0) YIELD node AS this0, score AS var1
WHERE (this0:Book AND this0.price < $param1)
RETURN this0 AS nodeProperty names take the place of the values PostgreSQL's onUpdate combines — nothing is stored, so
there is nothing to combine — and because this is a property type rather than an option of ours, the
same declaration travels through defineEntity unchanged:
indexes: [{ properties: ['search'], type: 'fulltext' }],
// …
search: () =>
p.type(new Neo4jFullTextType({ A: 'title', C: 'summary' })).persist(false).nullable(),Rows arrive in descending relevance (an orderBy replaces it), weights become Lucene boosts
(title:(q)^10 OR summary:(q)^2), and the whole of Lucene's syntax reaches the index: grahp~,
relation*, summary:machines, graph AND NOT sql.
A search field declared on an interface covers every entity implementing it — fulltext is the only index kind Neo4j lets span labels:
@Entity({ inheritance: 'interface' })
abstract class Production {
@Property() title!: string;
@Index({ type: 'fulltext' })
@Property({ type: new Neo4jFullTextType(['title']), persist: false, nullable: true })
readonly search?: string;
}CREATE FULLTEXT INDEX `Production_title_idx` IF NOT EXISTS FOR (n:`Movie`|`Series`) ON EACH [n.`title`]em.find(MovieProduction, …) gets movies, em.find(Production, …) gets both. em.count(),
em.stream(), em.nativeUpdate() and em.nativeDelete() accept the operator too, and the query
builder exposes the score:
const hits = await em.createQueryBuilder(Book, 'b')
.fullText('search', 'graph databases')
.return(['title', 'score'])
.execute();There is no entity-wide { $fulltext: … }: one text index per collection is a MongoDB shape, and a
Neo4j entity may have several over different fields. Every index — the ones its own search fields
declare and the ones it inherits — is declared on its GraphQL type:
type Book @node @fulltext(indexes: [{ indexName: "Book_title_summary_idx", queryName: "booksByTitleAndSummary", fields: ["title", "summary"] }]) {
title: String!
summary: String!
}Full details — analyzers, weights, interface indexes, limitations — in query conditions.
13. Advanced Usage
Custom labels via defineEntity
You can specify multiple labels for an entity which will be used during query generation and node creation.
const AuthorSchema = defineEntity({
name: 'Author',
labels: ['Author', 'Person'],
properties(p) {
return {
id: p.uuid().primary(),
name: p.string(),
};
},
});Writing Cypher by hand
Some traversals are not expressible as a filter, and should not have to be. There are two ways to write Cypher here, and the choice between them is the whole story:
| You are… | Use |
|---|---|
| modelling something you will query again, with filters, ordering and paging | a virtual entity |
| writing one query, in one place, and just want its rows | em.run<T>() |
| adding a Cypher expression inside an ordinary filter | cypher`…` |
cypher`…` — a fragment inside a filter
import { cypher } from 'mikro-orm-neo4j';
// as a key: the fragment is the thing being compared
await em.find(Book, { [cypher`toLower(this0.title)`]: 'graph databases' });
// as a value
await em.find(Book, { price: cypher`coalesce(${fallback}, 0)` });
// with an operator, or as the whole predicate
await em.find(Book, { [cypher`this0.price * 1.2`]: { $gt: 100 } });
await em.find(Book, { [cypher`this0.published IS NOT NULL`]: [] });Fragments nest inside $and, $or and $not, and sit beside ordinary conditions.
An interpolated value can never become Cypher. Only the literal parts of the template — the text you typed between the ${} — become Cypher; everything interpolated is bound as a named Bolt parameter allocated by the query builder, so a fragment's parameters can never collide with the driver's own. Core's sql tag binds positionally, which is a convention Bolt does not share, which is why this is a separate tag rather than a reuse. A SQL fragment reaching a Neo4j filter is a loud error rather than a silently dropped predicate.
cypher.ref(), cypher.lower() and cypher.upper() cover the common helpers.
em.run<T>() — one query, plain rows
const rows = await em.run<{ name: string; total: number }>(
'MATCH (u:User)-[:CREATED]->(p) RETURN u.name AS name, count(p) AS total',
);
const recent = await em.run<{ id: string }>(
cypher`MATCH (b:Book) WHERE b.price > ${threshold} RETURN b.id AS id`,
);
for await (const row of em.streamRaw<{ id: string }>('MATCH (b:Book) RETURN b.id AS id')) {
// …
}Values are converted on the way out — integers become numbers, date-times become Date, nodes and relationships become plain objects. Two things to know: the type parameter is a declaration, not a validation (nothing checks that the query returns that shape), and rows are never managed entities — they do not enter the identity map and flushing does not persist them.
Virtual entities — the modelled path
A virtual entity turns a hand-written query into a read model with the full read surface: filters, ordering, paging, Loaded<> typing, serialization.
@Entity({
expression: `
MATCH (b:Book)-[:WROTE]-(a:Author)
RETURN b.id AS id, b.title AS title, b.price AS price, a.name AS author
`,
})
export class BookListing {
@Property() id!: string;
@Property() title!: string;
@Property() price!: number;
@Property() author!: string;
}
await em.find(BookListing, { price: { $gt: 20 } }, { orderBy: { title: 'ASC' }, limit: 20 });
await em.count(BookListing, { author: 'Ann' });The driver applies those options around your expression. With nothing to apply, the expression runs byte-identical — a virtual entity that was working keeps generating exactly the Cypher it did.
Full details in raw Cypher.
14. Logging, sessions and testing
Every statement the driver sends — reads, writes, counts, pivot loads, streams, schema statements and em.run() — passes through a single execution funnel, which is what makes the ORM's own logging configuration work here with no driver-specific setup.
const orm = await MikroORM.init({
debug: ['query', 'query-params'],
slowQueryThreshold: 200,
highlighter: new Neo4jHighlighter(),
});Each entry carries the elapsed time, the rows returned, the entities affected and which connection it ran on; a failing statement is logged at error level before the exception is converted, so it appears in the log with its timing rather than only as a stack trace. Transaction boundaries are logged as begin / commit / rollback, because a query log without them cannot be read back as a sequence. Per-query logging: { label, enabled } and loggerContext work as they do elsewhere.
Cypher parameters are named and travel as a separate map, so query-params appends that map rather than inlining values into the statement. affectedRows counts entities created or removed — properties set are deliberately excluded, since a node created with five properties is one affected row, not six.
An AbortSignal is honoured on queries, on em.run() and on transactions:
const controller = new AbortController();
const books = em.find(Book, {}, { signal: controller.signal });
controller.abort();A signal that has already fired sends nothing to the server. Neo4j has no per-query cancel on an open session, so every strategy stronger than "ignore" resolves to terminating the session — except inside your own transaction, where the abort propagates and the transaction rolls back instead.
Options a graph database cannot honour are refused rather than ignored: isolation levels, nested propagation when you ask for it explicitly, and pessimistic lockMode. Each error says what to do instead.
For tests, withRollback runs a callback inside a transaction that is always rolled back:
import { withRollback } from 'mikro-orm-neo4j';
await withRollback(orm, async (em) => {
em.create(Book, { id: '1', title: 'Graph Databases' });
await em.flush();
expect(await em.findOne(Book, { id: '1' })).toBeTruthy();
});It is a plain function taking a callback — no test-runner globals, no lifecycle hooks — so it works under Vitest, Jest or neither, and a failing test points at the line that failed.
Full details in observability & sessions.
15. Stored routines — APOC, GDS and the built-ins
Neo4j's procedures and functions are where a large part of its practical power lives, and reaching for them usually means dropping out of the ORM into an untyped em.run(). Here they are declared, typed, callable and composable — and generated from the server rather than hand-written.
The one thing to absorb first: a Neo4j routine is installed server-side and discovered, not authored and created. The driver never emits routine DDL, and every field of MikroORM's routine model that presupposes authoring — body, language, security, definer, deterministic, dataAccess — fails initialisation with an explanation rather than being quietly ignored.
Declaring and calling
import { defineRoutine, MikroORM } from 'mikro-orm-neo4j';
const PageRank = defineRoutine({
name: 'gds.pageRank.stream',
type: 'procedure',
access: 'read',
params: {
graphName: { runtimeType: 'string' },
configuration: { runtimeType: 'object', nullable: true },
},
columns: {
nodeId: { runtimeType: 'number' },
score: { runtimeType: 'number' },
},
});
const orm = await MikroORM.init({ entities: [Book], routines: [PageRank] });
// rows: Array<{ nodeId: number; score: number }> — no generics at the call site
const rows = await orm.em.callRoutine(PageRank, { graphName: 'books' });Arguments are always bound as Bolt parameters, so a value can never become part of the statement. A parameter declared nullable: true may be left out entirely, which is how the server applies its own default rather than being handed a null.
A function is the other kind, and returns a scalar:
const Clean = defineRoutine({
name: 'apoc.text.clean',
type: 'function',
access: 'read',
params: { text: { runtimeType: 'string' } },
returns: { runtimeType: 'string' },
// Runs locally when the server does not have APOC — and never when it does.
bodyJs: ({ text }) => text.replace(/[^a-z0-9]/gi, '').toLowerCase(),
});
await em.callRoutine(Clean, { text: 'Hello, World!' }); // 'helloworld'A routine's declared access selects its session: a read routine may be routed to a replica, and a write routine attempted on a read-only connection is refused before a statement is sent. Calls join the ambient transaction and roll back with it.
Generating declarations
Hand-writing declarations for hundreds of APOC and GDS procedures is not a plan, so the driver reads the server and writes them:
mikro-orm-neo4j-routines --url bolt://localhost:7687 --user neo4j --password secret \
--namespace apoc --namespace gds --out ./src/routinesThe output is meant to be committed — reviewable when a plugin is upgraded, diffable when a signature changes, and usable in CI without a database. It is safe to commit because generation is deterministic: sorted, formatted identically and carrying no timestamp, so regenerating against an unchanged server produces no diff. One file per namespace by default; --single-file if you would rather have one.
Anything the generator cannot map precisely is emitted as unknown, never as an any-like type, and reported by name — so the compiler makes you decide rather than letting a wrong type through. A NODE column is the usual one, and the hand edit is a single line:
columns: {
node: { entity: () => Book }, // now a managed Book
score: { runtimeType: 'number' },
}Composing, not just calling
A graph procedure is usually a query's source rather than an isolated call, which is the part core's standalone callRoutine cannot reach:
const hits = await em.createQueryBuilder()
.callProcedure(QueryBooks, { indexName: 'Book_title', queryString: 'graph' })
.yield('node', 'score')
.where('score', { $gt: 0.5 })
.orderBy('score', 'DESC')
.limit(10)
.execute();Yielded columns are ordinary query values — filter, order, page and project them. The yield list is checked against the declaration before anything is sent, so a mistyped column names itself rather than arriving as a syntax error from the server.
A routine can also back a virtual entity, which gives its rows the full read surface including count:
@Entity({ expression: () => routineSource(DbLabels) })
class LabelView {
@Property() label!: string;
}
await em.find(LabelView, { label: { $like: 'Book%' } }, { orderBy: { label: 'ASC' }, limit: 10 });Full details — the field-by-field mapping, the local-fallback guarantee, hydration, and the generator workflow — in stored routines.
Upstream status. MikroORM marks stored-routine support experimental in v7.1. The driver keeps a thin surface over it, pins
@mikro-orm/coreexactly, and carries a contract test that fails the build if the shape it depends on moves.
Documentation
The full documentation site is at manuel-antunes.github.io/mikro-orm-neo4j. Its content lives in
docs/ — run it locally with pnpm docs:dev.
- Schemas — multi-tenancy and soft-subgraphs — how the
schemaoption maps onto a graph, where the implicit predicates are injected, what stays unscoped, and the constraints it generates. - Query conditions & full-text search — which filter operators the driver translates, and how
$fulltextmaps onto Neo4j's fulltext indexes: declaration, analyzers, index selection, relevance, GraphQL. - Streaming large results — how
em.stream()maps onto a Bolt cursor, why populated relations are expanded rather than collected, which query shapes stay lazy, and the cursor API. - Raw Cypher — the
cyphertagged template and why its parameters are named, when to reach for a virtual entity instead, and what the typed raw-execution helpers do and do not promise. - Observability & sessions — every logging knob and what each entry carries, session access mode, cancellation, the transaction options Neo4j cannot honour, and the
withRollbacktest helper. - Stored routines — declaring, calling, composing and generating Neo4j procedures and functions: which declaration fields are refused and why, access-mode routing, the local fallback's exact guarantee, entity hydration for yielded nodes, and the generator workflow.
- Idempotent
MERGE(C10) & tenant-scoped relations (C11) — hownativeInsertbecame idempotent by primary key, how relationships are now matched on the full primary key (closing a cross-tenant leak), and the composite primary-key support that ships alongside.
Troubleshooting
Node.js globSync SyntaxError
If you encounter SyntaxError: The requested module 'node:fs' does not provide an export named 'globSync', it means you are running a version of Node.js older than 22.0.0.
Solution: Ensure your environment (including CI runners and Docker containers) is using Node.js 22+.
Running Workflows with act on Apple Silicon
When using act to test GitHub Actions locally on an Apple M-series (M1/M2/M3) chip, you may encounter an exit code 137 (OOM or Architecture crash) during the setup-node step.
Solution: Specify the container architecture explicitly to avoid emulation crashes:
act --container-architecture linux/amd64Additionally, ensure your Docker Desktop has at least 4GB-6GB of RAM allocated in Settings > Resources.
License
MIT License
Author
Manuel Antunes
