mirror-orm
v1.0.4
Published
Lightweight TypeScript ORM for PostgreSQL, SQLite, MySQL and SQL Server using Stage 3 decorators
Maintainers
Readme
Mirror ORM
Lightweight TypeScript ORM for PostgreSQL, SQLite, MySQL and SQL Server, built on Stage 3 decorators.
Features
- Stage 3 decorators — no
experimentalDecoratorsrequired - Multi-database — PostgreSQL, SQLite, MySQL, SQL Server
- Relations —
@ManyToOne,@OneToMany,@OneToOne,@ManyToManywith batch loading (no N+1) - Fluent QueryBuilder — joins, groupBy, having, explain
- Transactions — automatic propagation via
AsyncLocalStorage; nested savepoints - Streaming —
findStream()async generator for all adapters - Read replicas — automatic read/write routing
- Optimistic locking —
@VersionColumn - Pessimistic locking —
FOR UPDATE/FOR SHARE - Soft delete —
@DeletedAtwith automatic filtering - Lifecycle hooks —
@BeforeInsert,@BeforeUpdate,@AfterLoad - Embedded value objects —
@Embedded - Single Table Inheritance —
@ChildEntity - JSON operators —
JsonContains,JsonHasKeyand more (PostgreSQL) - Sub-100 ns/row pure hydration overhead
Installation
npm install mirror-ormInstall the driver for your database:
# PostgreSQL
npm install pg
# SQLite
npm install better-sqlite3
# MySQL
npm install mysql2
# SQL Server
npm install mssqlTypeScript setup
Mirror uses Stage 3 decorators. Set the following in your tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": false
}
}Connecting
import { Connection } from 'mirror-orm';
// PostgreSQL
const conn = await Connection.postgres({
host: 'localhost',
port: 5432,
database: 'mydb',
user: 'postgres',
password: 'secret',
});
// SQLite
const conn = await Connection.sqlite({ database: './app.db' });
// MySQL
const conn = await Connection.mysql({
host: 'localhost',
port: 3306,
database: 'mydb',
user: 'root',
password: 'secret',
});
// SQL Server
const conn = await Connection.sqlServer({
host: 'localhost',
port: 1433,
database: 'mydb',
user: 'sa',
password: 'secret',
});Defining entities
import { Entity, Column, PrimaryColumn, CreatedAt, UpdatedAt } from 'mirror-orm';
@Entity('users')
class User {
@PrimaryColumn({ strategy: 'identity' })
id!: number;
@Column()
name!: string;
@Column({ nullable: true })
email!: string | null;
@CreatedAt()
createdAt!: Date;
@UpdatedAt()
updatedAt!: Date;
}Primary key strategies
| Strategy | Description |
|------------|------------------------------------|
| identity | Auto-increment (database-managed) |
| uuid_v4 | Random UUID |
| uuid_v7 | Time-ordered UUID |
| cuid2 | CUID2 |
| ulid | ULID |
Repository
const repo = conn.getRepository(User);
// Insert
const user = await repo.save(Object.assign(new User(), { name: 'Alice', email: '[email protected]' }));
// Find
const users = await repo.findAll();
const user = await repo.findById(1);
const alice = await repo.findOne({ where: { name: 'Alice' } });
// Update
user.name = 'Alice Smith';
await repo.save(user);
// Delete
await repo.remove(user);
// Bulk
const saved = await repo.saveMany([user1, user2]);
await repo.removeMany([user1, user2]);
// Count / exists
const total = await repo.count({ where: { email: IsNull() } });
const exists = await repo.exists({ email: '[email protected]' });
// Pagination
const page = await repo.findPaginated({ page: 1, limit: 20 });
// { data: User[], meta: { total, page, lastPage, limit } }Filter operators
import { Like, ILike, In, Between, Not, IsNull, IsNotNull, Raw } from 'mirror-orm';
await repo.find({ where: { name: Like('%alice%') } });
await repo.find({ where: { name: ILike('%alice%') } }); // case-insensitive
await repo.find({ where: { id: In([1, 2, 3]) } });
await repo.find({ where: { age: Between(18, 65) } });
await repo.find({ where: { email: Not(IsNull()) } });
await repo.find({ where: { score: Raw(col => `${col} > (SELECT avg(score) FROM users)`) } });
// OR groups
await repo.find({ where: [{ name: 'Alice' }, { name: 'Bob' }] });Find options
await repo.find({
where: { active: true },
orderBy: { createdAt: 'DESC' },
limit: 10,
offset: 20,
relations: ['posts'],
select: ['id', 'name'],
lock: 'pessimistic_write',
withDeleted: false,
});Relations
import { ManyToOne, OneToMany, ManyToMany, OneToOne } from 'mirror-orm';
@Entity('posts')
class Post {
@PrimaryColumn({ strategy: 'identity' })
id!: number;
@Column()
title!: string;
@Column()
authorId!: number;
@ManyToOne(() => User, 'authorId')
author?: User;
}
@Entity('users')
class User {
@PrimaryColumn({ strategy: 'identity' })
id!: number;
@OneToMany(() => Post, 'authorId')
posts?: Post[];
}
// Load with relations
const posts = await postRepo.find({ relations: ['author'] });
// Nested relations
const posts = await postRepo.find({ relations: ['author.address'] });QueryBuilder
const results = await conn
.createQueryBuilder(Post)
.select(['p.id', 'p.title', 'u.name'])
.leftJoin(User, 'u', 'u.id = p.authorId')
.where('p.published = :pub', { pub: true })
.andWhere('u.active = :active', { active: true })
.orderBy('p.createdAt', 'DESC')
.limit(10)
.getMany();Transactions
Transactions propagate automatically via AsyncLocalStorage — any repository obtained from conn.getRepository() inside the callback uses the transaction runner without explicit wiring.
await conn.transaction(async trx => {
const userRepo = trx.getRepository(User);
const postRepo = trx.getRepository(Post);
const user = await userRepo.save(Object.assign(new User(), { name: 'Alice' }));
await postRepo.save(Object.assign(new Post(), { title: 'Hello', authorId: user.id }));
});Nested transaction() calls automatically use savepoints:
await conn.transaction(async () => {
// ...
await conn.transaction(async () => {
// SAVEPOINT — rolls back only this block on error
});
});Streaming
for await (const user of repo.findStream({ where: { active: true } })) {
process(user);
}Read replicas
const conn = await Connection.postgres({
host: 'primary.db',
database: 'mydb',
user: 'app',
password: 'secret',
replica: {
host: 'replica.db',
user: 'app',
password: 'secret',
},
});
// Reads go to replica, writes go to primary — automatically
const users = await repo.findAll(); // replica
await repo.save(user); // primaryOptimistic locking
@Entity('documents')
class Document {
@PrimaryColumn({ strategy: 'identity' })
id!: number;
@Column()
content!: string;
@VersionColumn()
version!: number;
}
// Throws OptimisticLockError if version has changed since load
await repo.save(document);Soft delete
import { DeletedAt } from 'mirror-orm';
@Entity('users')
class User {
@PrimaryColumn({ strategy: 'identity' })
id!: number;
@DeletedAt()
deletedAt!: Date | null;
}
await repo.remove(user); // sets deletedAt
await repo.findAll(); // excludes soft-deleted rows
await repo.findAll({ withDeleted: true }); // includes themEmbedded value objects
class Address {
@Column()
street!: string;
@Column()
city!: string;
}
@Entity('users')
class User {
@PrimaryColumn({ strategy: 'identity' })
id!: number;
@Embedded(() => Address, 'address_')
address!: Address;
}
// Maps to columns: address_street, address_citySingle Table Inheritance
@Entity({ tableName: 'animals', discriminatorColumn: 'type' })
class Animal {
@PrimaryColumn({ strategy: 'identity' })
id!: number;
@Column()
name!: string;
}
@ChildEntity('cat')
class Cat extends Animal {
@Column({ nullable: true })
indoor!: boolean;
}Lifecycle hooks
import { BeforeInsert, BeforeUpdate, AfterLoad } from 'mirror-orm';
@Entity('users')
class User {
@PrimaryColumn({ strategy: 'identity' })
id!: number;
@Column()
password!: string;
@BeforeInsert()
hashPassword() {
this.password = hash(this.password);
}
@AfterLoad()
sanitize() {
this.password = '[hidden]';
}
}JSON operators (PostgreSQL)
import { JsonContains, JsonHasKey, JsonHasAllKeys, JsonHasAnyKey } from 'mirror-orm';
await repo.find({ where: { metadata: JsonContains({ role: 'admin' }) } });
await repo.find({ where: { metadata: JsonHasKey('active') } });
await repo.find({ where: { metadata: JsonHasAllKeys(['a', 'b']) } });
await repo.find({ where: { metadata: JsonHasAnyKey(['a', 'b']) } });Global query filters
@Entity({ tableName: 'users', filters: { active: { active: true } } })
class User { ... }
// Filter applied automatically
await repo.find({ filters: ['active'] });
// Skip filter
await repo.find({ filters: [] });Contributing
See CONTRIBUTING.md.
