npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

mikro-orm-neo4j

v0.3.2

Published

Neo4j driver for MikroORM (OGM-style graph support)

Readme

mikro-orm-neo4j

NPM Version License Documentation

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 EntityManager and EntityRepository support
  • 🕸️ 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-builder to 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 schema option — 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 / exclude become a Cypher map projection (RETURN n { .id, .title }), so the properties you did not ask for never leave the server — lazy properties included.
  • 🧮 Full filter-operator coverage: $like/$ilike, $re (and RegExp values), $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's FullTextType shape — 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 via orm.schema.ensureIndexes().
  • 🧩 Native Decorator Extensions: Fully type-safe strongly-defined neo4j and relation configuration 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/reflection

Quick 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 relationship instead of custom decorators like @Rel? Relying on MikroORM's built-in PropertyOptions ensures 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 node

Passing 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 node

Note. Auto-generated pivots are plumbing, not model: they never appear as @node types 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. connectionType is MikroORM's own option, and it is honoured on find, findOne, count, streams, and the relation loads behind populate:

    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 — connectionType cannot 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 UNIQUE

Index 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 against SHOW INDEXES) and drop(). Since ensureIndexes() is idempotent, removing a declaration does not remove the index — drop it manually with DROP 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/graphql turns it into an executable schema, GraphQLModule serves it, and the resulting CRUD API reads and writes the same nodes your EntityManager does. Pinned end to end by tests/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 override

Onboarding 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 only

Storage 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.
  • __schema is immutable through updates. Nothing moves a node between schemas; see the migration recipe below for the deliberate, explicit alternative.
  • __schema never reaches your entities. Hydration strips exactly that key — a property of your own genuinely named schema is 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 explicit

Both 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 UNIQUE

Composite 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 ROWS

Rolling 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-entity expressions, and the query builder's pattern() / 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.schema addresses everything. Here, an entity that never declared schema is untouched: no label, no property, no predicate, byte-identical Cypher, even when a schema is explicitly forced through FindOptions.schema, a fork, or withSchema(). This keeps existing databases working unchanged; the alternative would silently return nothing for pre-existing nodes.
  • __schema and SchemaNode are 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 node

Property 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/routines

The 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/core exactly, 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 schema option 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 $fulltext maps 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 cypher tagged 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 withRollback test 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) — how nativeInsert became 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/amd64

Additionally, ensure your Docker Desktop has at least 4GB-6GB of RAM allocated in Settings > Resources.

License

MIT License

Author

Manuel Antunes