@typeforge-orm/orm
v0.1.0
Published
TypeScript ORM with EF Core-style snapshot migrations, change tracking, and TypeORM-style decorators — PostgreSQL first.
Downloads
162
Maintainers
Readme
@typeforge-orm/orm
A TypeScript ORM for PostgreSQL with:
- EF Core-style snapshot migrations — diff your entities, generate typed migration files, apply with one command.
- Change tracking —
DbContext.saveChanges()detects inserts, updates, and deletes automatically. - TypeORM-style decorators —
@Entity,@Column,@PrimaryKey, relations, indexes, soft-delete. - Typed query API — fluent
where / orderBy / take / skip / includewith full TypeScript inference. - Advisory-lock runner — safe concurrent deployments; all pending migrations apply in one atomic transaction.
typeforgeCLI —generate,run,revert,statuscommands, reads a plain TypeScript config file.
Installation
npm install @typeforge-orm/orm reflect-metadata pg
npm install -D @types/pg ts-node typescripttsconfig.json must have:
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"useDefineForClassFields": false
}
}reflect-metadata must be imported once before any entities are loaded — typically in your entry point:
import 'reflect-metadata';Quick start
1. Define entities
// user.entity.ts
import 'reflect-metadata';
import { Entity, PrimaryKey, Column, Index, SoftDelete } from '@typeforge-orm/orm';
@SoftDelete()
@Index(['email'], { unique: true })
@Entity({ table: 'users' })
export class User {
@PrimaryKey({ generated: 'uuid' })
id!: string;
@Column({ type: 'varchar', length: 255 })
email!: string;
@Column({ type: 'varchar', length: 100 })
name!: string;
@Column({ type: 'int' })
age!: number;
@Column({ type: 'boolean', default: true })
isActive!: boolean;
deletedAt!: Date | null;
}// post.entity.ts
import { Entity, PrimaryKey, Column, ManyToOne } from '@typeforge-orm/orm';
import { User } from './user.entity';
@Entity({ table: 'posts' })
export class Post {
@PrimaryKey({ generated: 'uuid' })
id!: string;
@Column({ type: 'varchar', length: 500 })
title!: string;
@Column({ type: 'text' })
body!: string;
@Column({ type: 'uuid' })
authorId!: string;
@Column({ type: 'boolean', default: false })
published!: boolean;
@ManyToOne(() => User, u => u.id)
author?: User;
}2. Create a DbContext
// app-db-context.ts
import { DbContext, type DbContextOptions } from '@typeforge-orm/orm';
import { User } from './user.entity';
import { Post } from './post.entity';
export class AppDbContext extends DbContext {
get users() { return this.set(User); }
get posts() { return this.set(Post); }
}3. Connect and use
import 'reflect-metadata';
import { PostgresDriver } from '@typeforge-orm/orm';
import { AppDbContext } from './app-db-context';
const driver = new PostgresDriver({
host: 'localhost', port: 5432,
database: 'myapp', user: 'postgres', password: 'postgres',
max: 10, // pool size
});
// One context per request / unit of work
const connection = await driver.connect();
const db = new AppDbContext({ driver, connection });
// INSERT
const user = new User();
user.email = '[email protected]';
user.name = 'Alice';
user.age = 30;
db.users.add(user);
await db.saveChanges();
console.log(user.id); // generated UUID
// SELECT
const alice = await db.users.find(user.id); // by PK
const active = await db.users
.where(u => u.isActive)
.orderBy(u => u.name)
.toList();
// UPDATE
alice!.name = 'Alice Smith';
await db.saveChanges(); // only the changed column is sent
// Soft DELETE
db.users.remove(alice!);
await db.saveChanges(); // sets deleted_at, not a hard DELETE
await connection.release();
await driver.end();Decorator reference
| Decorator | Target | Description |
|---|---|---|
| @Entity(opts?) | class | Mark as a mapped table. opts.table overrides the auto snake_case name. |
| @PrimaryKey(opts?) | property | Primary key. opts.generated: 'uuid' \| 'identity' \| 'sequence'. |
| @Column(opts?) | property | Mapped column. opts.type, opts.nullable, opts.length, opts.default. |
| @RowVersion() | property | Optimistic-concurrency token (int, auto-incremented by DB). |
| @Index(cols, opts?) | class | Declare an index. opts.unique, opts.name. |
| @SoftDelete(col?) | class | Enable soft-delete via a nullable timestamp column (default deleted_at). |
| @OneToOne(target, inverse?, opts?) | property | One-to-one relation. |
| @OneToMany(target, inverse?, opts?) | property | One-to-many relation. |
| @ManyToOne(target, inverse?, opts?) | property | Many-to-one relation. |
| @ManyToMany(target, inverse?, opts?) | property | Many-to-many relation. |
Column types: 'string' | 'int' | 'float' | 'boolean' | 'date' | 'datetime' | 'uuid' | 'text' | 'json' | 'varchar'
Query API
const dbSet = db.users; // DbSet<User>
// Fluent chain — all methods return a new Queryable
const results = await dbSet
.where(u => u.isActive) // filter
.where(u => gt(u.age, 18)) // additional filter (AND)
.orderBy(u => u.name) // ORDER BY name ASC
.orderByDesc(u => u.age) // then BY age DESC
.take(20) // LIMIT 20
.skip(40) // OFFSET 40
.asNoTracking() // skip change tracker
.includeDeleted() // include soft-deleted rows
.toList(); // execute → User[]
// Terminators
await dbSet.firstOrDefault(); // first row or undefined
await dbSet.where(...).count(); // SELECT COUNT(*)
const exists = await dbSet.where(...).any(); // SELECT 1 … LIMIT 1Expression helpers
import { eq, ne, lt, lte, gt, gte, and, or, not,
isNull, isNotNull, like, ilike, inList, between } from '@typeforge-orm/orm';
db.users.where(u => eq(u.email, '[email protected]'));
db.users.where(u => like(u.name, 'Ali%'));
db.users.where(u => inList(u.age, [18, 21, 25]));
db.users.where(u => between(u.age, 18, 65));
db.users.where(u => and(u.isActive, gt(u.age, 18)));
db.users.where(u => or(isNull(u.deletedAt), gt(u.age, 0)));Change tracking
DbContext tracks every entity returned by queries:
// Tracked automatically on load
const user = await db.users.find(id);
user!.name = 'Updated'; // change detected
db.users.remove(user!); // marks for deletion
const { inserted, updated, deleted } = await db.saveChanges();Use asNoTracking() for read-only queries that don't need tracking (better performance):
const readOnly = await db.users.asNoTracking().toList();Migrations
CLI setup
Create typeforge.config.ts in your project root:
// typeforge.config.ts
import 'reflect-metadata';
import * as path from 'path';
import * as dotenv from 'dotenv';
dotenv.config();
// Import all entity files so decorators register
import './src/entities/user.entity';
import './src/entities/post.entity';
import type { TypeForgeCliConfig } from '@typeforge-orm/orm';
const config: TypeForgeCliConfig = {
migrationsDir: path.resolve(__dirname, 'src/migrations'),
connection: {
host: process.env.DB_HOST ?? 'localhost',
port: Number(process.env.DB_PORT ?? 5432),
database: process.env.DB_NAME ?? 'myapp_dev',
user: process.env.DB_USER ?? 'postgres',
password: process.env.DB_PASS ?? '',
},
getMigrations: () => require('./src/migrations').migrations,
};
export default config;Add scripts to package.json:
{
"scripts": {
"migration:generate": "typeforge generate",
"migration:run": "typeforge run",
"migration:status": "typeforge status",
"migration:revert": "typeforge revert"
}
}Commands
| Command | What it does |
|---|---|
| typeforge generate [Label] | Diff current entities against snapshot → emit a typed migration file |
| typeforge run | Create DB if missing, apply all pending migrations in one transaction |
| typeforge status | List applied and pending migrations with timestamps |
| typeforge revert [N] | Roll back the last N migrations (default 1) in one transaction |
Migration files
Generated files are plain TypeScript — review and edit them freely before running:
// src/migrations/1700000000000_AddEmailIndex.migration.ts
import type { Migration, MigrationContext } from '@typeforge-orm/orm';
export class AddEmailIndex_1700000000000 implements Migration {
readonly name = 'AddEmailIndex_1700000000000';
async up(ctx: MigrationContext): Promise<void> {
await ctx.execute(`CREATE UNIQUE INDEX "idx_users_email" ON "users" ("email")`);
}
async down(ctx: MigrationContext): Promise<void> {
await ctx.execute(`DROP INDEX "idx_users_email"`);
}
}Register every migration in your barrel:
// src/migrations/index.ts
import type { Migration } from '@typeforge-orm/orm';
import { Init_1699000000000 } from './1699000000000_init.migration';
import { AddEmailIndex_1700000000000 } from './1700000000000_AddEmailIndex.migration';
export const migrations: Migration[] = [
new Init_1699000000000(),
new AddEmailIndex_1700000000000(),
];NestJS integration
// database.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { PostgresDriver, ensureDatabase, MigrationRunner, connectPg } from '@typeforge-orm/orm';
@Module({ imports: [ConfigModule] })
export class DatabaseModule {
static forRoot() {
return {
module: DatabaseModule,
providers: [
{
provide: PostgresDriver,
useFactory: async (cfg: ConfigService) => {
const opts = {
host: cfg.get('DB_HOST', 'localhost'),
port: cfg.get<number>('DB_PORT', 5432),
database: cfg.get('DB_NAME', 'myapp'),
user: cfg.get('DB_USER', 'postgres'),
password: cfg.get('DB_PASS', ''),
};
// create DB + run migrations on startup
await ensureDatabase(opts, opts.database);
const { conn, end } = await connectPg(opts);
const runner = new MigrationRunner(conn);
await runner.run(require('./migrations').migrations);
await end();
// return pool driver for request-scoped connections
return new PostgresDriver({ ...opts, max: cfg.get<number>('DB_POOL', 10) });
},
inject: [ConfigService],
},
],
exports: [PostgresDriver],
};
}
}Error types
All errors extend TypeForgeError and carry a stable code string:
| Class | Code | Thrown when |
|---|---|---|
| ConfigurationError | TFE_CONFIG | Invalid decorator usage or missing metadata |
| ConnectionError | TFE_CONNECTION | Pool / client cannot connect |
| QueryError | TFE_QUERY | SQL execution fails (includes .sql property) |
| EntityNotFoundError | TFE_NOT_FOUND | find() / first() finds no row |
| OptimisticConcurrencyError | TFE_CONCURRENCY | @RowVersion mismatch on save |
| ValidationError | TFE_VALIDATION | Pre-save validation failure |
| TransactionError | TFE_TRANSACTION | Transaction begin / commit / rollback failure |
import { TypeForgeError, QueryError } from '@typeforge-orm/orm';
try {
await db.saveChanges();
} catch (e) {
if (e instanceof QueryError) {
console.error('SQL that failed:', e.sql);
}
}PostgresDriver options
new PostgresDriver({
host: 'localhost', // default
port: 5432, // default
database: 'myapp',
user: 'postgres',
password: 'secret',
ssl: false, // set true for managed cloud DBs
max: 10, // connection pool size
});Requirements
| Peer | Version |
|---|---|
| Node.js | ≥ 18 |
| PostgreSQL | ≥ 13 (uses gen_random_uuid(), advisory locks) |
| reflect-metadata | ≥ 0.2 |
| ts-node | ≥ 10 (optional — needed only if typeforge.config.ts is TypeScript) |
License
MIT © TypeForge Contributors
