@spinejs/mikro-orm
v0.1.5
Published
MikroORM integration for SpineJS: request-scoped EntityManager / unit-of-work via CLS.
Maintainers
Readme
@spinejs/mikro-orm
MikroORM integration for SpineJS: a request-scoped EntityManager / unit-of-work carried by CLS. Configure the connection once, register one interceptor, and your services persist domain changes at request end — with no manager threaded through signatures and no .save(). See ADR 0016.
Install
yarn add @spinejs/mikro-orm @mikro-orm/core @mikro-orm/better-sqlite@mikro-orm/core and a driver are peers — pin both to v6 (the sqlite drivers do not yet ship v7). @spinejs/mikro-orm builds on @spinejs/cls for the request scope, so ClsModule + ClsInterceptor are wired alongside it.
Quick start
Define the entity with EntitySchema (no decorators) and a repository for custom queries. The schema's repository: () => UserRepository link lets register([UserRepository]) resolve the entity:
// user.entity.ts
import { EntitySchema, EntityRepository } from "@spinejs/mikro-orm";
export class User {
id!: number;
email!: string;
name!: string;
}
export class UserRepository extends EntityRepository<User> {
findByEmail(email: string) {
return this.findOne({ email });
}
}
export const UserSchema = new EntitySchema<User>({
class: User,
repository: () => UserRepository, // required so register([UserRepository]) resolves the entity
properties: {
id: { type: "number", primary: true, autoincrement: true },
email: { type: "string" },
name: { type: "string" },
},
});Configure the connection once at app level (opens on start with retry, closes on stop), and register the repository in the feature module:
// app.module.ts
import { Module } from "@spinejs/core";
import { ClsModule } from "@spinejs/cls";
import { MikroOrmModule } from "@spinejs/mikro-orm";
import { BetterSqliteDriver } from "@mikro-orm/better-sqlite";
import { UserSchema } from "./user.entity";
import { UserModule } from "./user.module";
@Module({
imports: [
ClsModule,
MikroOrmModule.configure({
driver: BetterSqliteDriver,
dbName: "app.sqlite",
entities: [UserSchema],
// optional startup retry (defaults: 5 attempts, 200ms, backoff ×2)
retry: { attempts: 10, delayMs: 500, backoff: 2 },
}),
UserModule,
],
})
export class AppModule {}
// user.module.ts
@Module({
imports: [MikroOrmModule.register([UserRepository])],
providers: [UserService],
})
export class UserModule {}Inject the repository by class token and mutate entities normally — the change is committed at request end, no .save():
// user.service.ts
import { Injectable } from "@spinejs/core";
import { UserRepository } from "./user.entity";
@Injectable({ inject: [UserRepository] })
export class UserService {
constructor(private readonly users: UserRepository) {}
async rename(id: number, name: string) {
const user = await this.users.findOneOrFail({ id });
user.name = name; // dirty-tracked; committed when the request ends. No .save().
}
}Wire the interceptor
Register MikroOrmInterceptor in your transport's configure({ interceptors }) after ClsInterceptor — it forks the request EntityManager into the CLS scope, so it must run inside it:
interceptors: {
inject: [ClsService, MikroOrmInterceptor],
factory: (cls: ClsService, orm: MikroOrmInterceptor) => [
new ClsInterceptor(cls), // outermost: opens the CLS scope
orm, // inside the scope: forks the EM + brackets the transaction
],
}Entities: define them with
EntitySchema(no decorators) — the portable style under spine's stage-3 / no-reflect-metadatabuild. MikroORM v6 decorator entities are legacy-only (experimentalDecorators) and need an explicittypeper property. See the docs for details.
Reference
MikroOrmModule.configure(options)— registers a connection;optionsare MikroORMOptionsplus an optionalretry: { attempts, delayMs, backoff }. Constructs at build, connects ononStart(with retry), closes ononStop. Passnamefor an additional (named) connection andmultiWriteto let it be written alongside another (see Multiple connections).MikroOrmModule.register([...], { connection? })— exposes a module's repositories: a customEntityRepositorysubclass (by class token) or an entity class (default repo viarepositoryOf).connectionbinds them to a named connection.repositoryOf(Entity, connection?)— a typedInjectionToken<EntityRepository<Entity>>for entities with no custom repository;connectionnamespaces the token by connection.mikroOrmRef(name)/entityManagerRef(name)/mikroOrmInterceptorRef(name)— injection tokens for a named connection'sMikroORM, requestEntityManager, and interceptor.MikroOrmInterceptor— forks a per-requestEntityManagerinto CLS andflush()es it once at request end on a successful envelope (no up-frontbegin(); a request that wrote nothing opens no transaction). Transport-agnostic: add it to theinterceptorsarray as-is — the slot is aChainInterceptorwhose union admits a base interceptor, so no cast is needed.- Re-exports
MikroORM,EntityManager,EntitySchema,EntityRepository, and theOptionstype from@mikro-orm/core; plusmikroOrmProvider/entityManagerProvider/connectWithRetryfor hand-wiring.
Multiple connections
One database is the default (class tokens, everything above). For more — a replica, an audit store — give each additional connection a name; it is injected via mikroOrmRef(name) and brings its own lifecycle. Stack each connection's interceptor (mikroOrmInterceptorRef(name)) on the transport. A request writes at most one connection unless the connections opt into multiWrite (then best-effort sequential — no cross-DB atomicity).
Migrations
Declare a migrations block on a connection you already configure — no mikro-orm.config.ts, no second source of truth — and the MikroORM Migrator is wired onto it with entity-first defaults (emit: "ts", snapshot, transactional, allOrNothing). A named connection's folder defaults to ./migrations/<name>, and configuration fails closed if two connections would share a path or a tracking table. @mikro-orm/migrations (^6) is an optional peer, required only when you declare migrations; a connection with no block is byte-for-byte unchanged. Commit migration files and the snapshot together.
MikroOrmModule.configure({
driver,
dbName,
entities: [User],
migrations: { path: "./migrations" },
});Run them with the spine-migrate CLI, pointed at the same AppModule your server boots — the exit
code fails your CI build on failure:
spine-migrate migration:create --module ./dist/app.module.js#AppModule
spine-migrate migration:up --module ./dist/app.module.js#AppModule # + list / pending / down
spine-migrate migration:up --connection analytics --module ./dist/app.module.js#AppModule--module <path>#<Export> locates your AppModule; --connection targets a named connection (default
otherwise). The default emit: "ts" needs a TS loader (node --import tsx …) or compiled .js output.
The same operations run programmatically — runMigrations(AppModule, argv) (headless, rejects on
failure, never process.exit) or an injected MigrationRunner.
Production-safe by default. migration:fresh (drop + re-apply, dev reset) and the opt-in
migrations.migrateOnStart (apply pending at boot) refuse unless NODE_ENV is explicitly
development/test — production, unknown, and unset all refuse (fail-closed). fresh additionally
requires an orthogonal --force-drop and refuses a connection sharing a physical database. No migration
is ever a side effect of a production boot; there is no advisory lock, so apply migrations explicitly in
CI/CD.
Limitations
- No cross-database atomicity — multiple connections are supported, but a request writes at most one unless you opt into
multiWrite, and even then flushes are best-effort sequential (no two-phase commit). - Pin
@mikro-orm/core+ driver to one major (v6 today) — a duplicated core copy breaks repository resolution (instanceof/ token identity). MikroOrmInterceptorneeds an active CLS scope — register it afterClsInterceptor; outside a scope it fails fast with an explicit diagnostic.
