A Modern, Type-Safe, and Expressive ORM for Bun
Stabilize is a lightweight, feature-rich ORM designed for performance and developer experience. It provides a unified, database-agnostic API for PostgreSQL, MySQL, and SQLite. Powered by a robust query builder, programmatic model definitions, automatic versioning, and a full-featured command-line interface, Stabilize is built to scale with your app.
- Unified API: Write once, run on PostgreSQL, MySQL, or SQLite.
- Programmatic Model Definitions: Define models and columns using the
defineModelAPI with theDataTypesenum for database-agnostic schemas. - Full-Featured CLI: Generate models, manage migrations, seed data, and reset your database from the command line with stabilize-cli.
- Automatic Migrations: Generate database-specific SQL schemas directly from your model definitions.
- Versioned Models & Time-Travel: Enable versioning in your model configuration for automatic history tables and snapshot queries.
- Retry Logic: Automatic exponential backoff for database queries to handle transient connection issues.
- Connection Pooling: Efficient connection management for PostgreSQL and MySQL.
- Transactional Integrity: Built-in support for atomic transactions with automatic rollback on failure.
- Advanced Query Builder: Fluent, chainable API for building complex queries, including joins, filters, ordering, and pagination.
- Pagination Helper: Easily paginate any query with
.paginate(page, pageSize)and get{ data, total, page, pageSize }. - Advanced Model Validation: Enforce rules like
required,minLength,maxLength,pattern, and custom validators—errors are thrown on invalid input. - Model Relationships: Define
OneToOne,ManyToOne,OneToMany, andManyToManyrelationships in the model configuration. - Soft Deletes: Enable soft deletes in the model configuration for transparent "deleted" flags and safe row removal.
- Lifecycle Hooks: Define hooks in the model configuration or as class methods for lifecycle events like
beforeCreate,afterUpdate, etc. - Pluggable Logging: Includes a robust
StabilizeLoggerwith support for file-based, rotating logs. - Custom Errors:
StabilizeErrorprovides clear, consistent error handling. - Caching Layer: Optional Redis-backed caching with
cache-asideandwrite-throughstrategies. - Custom Query Scopes: Define reusable query conditions (scopes) in models for simplified, reusable filtering logic.
- Timestamps: Automatically manage
createdAtandupdatedAtcolumns for tracking record creation and update times. - SQL Default Expressions: Support database-side default expressions (e.g.,
gen_random_uuid(),NOW()) for columns using thesqlDefault()helper. - Nested Relations (Eager Loading): Load deeply nested relations using dot notation like
"roles.permissions". - AutoMigrate with Index Management: Automatically create, detect, and remove indexes and unique constraints during migration.
- Advanced Query Builder Filters: Chainable
.orWhere(),.whereIn(),.whereNotIn(),.whereNull(),.whereNotNull(),.whereBetween(),.groupBy(),.having(),.lock()methods. - Optimistic Locking: Add
optimisticLock: trueto a version column to automatically detect concurrent modification conflicts and throwCONCURRENT_MODIFICATIONerrors. - findAndCount: Get paginated results with a total count in one call.
- findOneBy / findBy: TypeORM-style conditional finders without writing raw SQL.
- Aggregate Queries: Run
count(),sum(),avg(),min(),max()directly from the repository or query builder. - Cursor-Based Pagination: Efficient forward/backward cursor pagination for large datasets (Prisma-style
findMany). - exists: Check if a record exists without loading it.
- recoverAll: Bulk restore all soft-deleted records.
- truncate: Clear all rows from a table.
- seed / defineSeed: Laravel-style seeding framework with
defineSeedandrunSeeds. - resetDatabase: Drop tables, re-migrate, and optionally re-seed for development.
- healthCheck: Get database and table health status with latency for monitoring endpoints.
- rawQuery / rawExec: Execute raw SQL directly from the
Stabilizeinstance. - bulkUpsert: Upsert multiple records in a single transaction.
- findMany: Prisma-style query with
where,cursor,take,skip,orderBy. - countDistinct: Count unique values in a column.
- increment / decrement: Atomically increment or decrement a numeric field.
- pluck: Get an array of a single column's values (Rails-style).
- selectColumns: Get only specific columns from a query.
- toggle: Toggle a boolean field (Rails-style).
- updateBy / deleteBy: Conditional bulk updates and deletes without writing SQL.
- restoreBy: Restore soft-deleted records matching conditions.
- findDeleted / withTrashed: Query soft-deleted or all records.
- upsertMany: Batch upsert in configurable batch sizes.
- map / each / eachBatch: Transform, iterate, and batch-process query results.
- lockForUpdate: Pessimistic row locking for read-modify-write.
- firstOrCreate / updateOrCreate: Laravel-style find-or-create patterns.
- first / last / random: Single-record shortcuts.
- StabilizeEmitter: Event system for
query,error,connection:open/close,transaction:start/complete/error. - TransactionIsolationLevel: Type for
READ UNCOMMITTED,READ COMMITTED,REPEATABLE READ,SERIALIZABLE. - generateUUID: Cross-runtime UUID generation helper.
- poolStats: Get connection pool statistics.
- Database Backup & Restore:
db:backupanddb:restorecommands for database backup management. - API Generation:
generate:apicommand scaffolds full CRUD REST API routes from models. - Fresh Migrations:
migrate:freshdrops all tables and re-runs migrations without seeding. - Database Size Analysis:
db:sizecommand shows table sizes and row count statistics.
Stabilize ORM requires a modern JavaScript runtime (Bun v1.3+).
# Using Bun
bun add stabilize-orm
# Using npm
npm install stabilize-orm- Blog - Blog with users, posts, comments, and versioning
- E-Commerce - Products, categories, orders with transactions
- REST API - Express.js REST API with pagination and optimistic locking
- SaaS - Multi-tenant SaaS with tenants, members, and scoped projects
- CMS - Content management with authors, categories, articles, and versioning
- Analytics - Event tracking with aggregations and metrics
Create a database configuration file.
// config/database.tsimport{DBType,typeDBConfig}from"stabilize-orm";constdbConfig: DBConfig={type: DBType.Postgres,connectionString:
process.env.DATABASE_URL||"postgres://user:password@localhost:5432/mydb",retryAttempts: 3,retryDelay: 1000,};exportdefaultdbConfig;Next, create a central ORM instance for your application.
// db.tsimport{Stabilize,typeCacheConfig,typeLoggerConfig,LogLevel,}from"stabilize-orm";importdbConfigfrom"./database";constcacheConfig: CacheConfig={enabled: process.env.CACHE_ENABLED==="true",redisUrl: process.env.REDIS_URL,ttl: 60,};constloggerConfig: LoggerConfig={level: LogLevel.Info,filePath: "logs/stabilize.log",maxFileSize: 5*1024*1024,// 5MBmaxFiles: 3,};exportconstorm=newStabilize(dbConfig,cacheConfig,loggerConfig);Define your tables as classes using the defineModel function. The DataTypes enum ensures database-agnostic schemas.
// models/User.tsimport{defineModel,DataTypes,RelationType}from"stabilize-orm";import{UserRole}from"./UserRole";constUser=defineModel({tableName: "users",versioned: true,columns: {id: {type: DataTypes.Integer,required: true},email: {type: DataTypes.String,length: 100,required: true,unique: true,},},relations: [{type: RelationType.OneToMany,target: ()=>UserRole,property: "roles",foreignKey: "userId",},],hooks: {beforeCreate: (entity)=>console.log(`Creating user: ${entity.email}`),},});export{User};The built-in pagination helper makes it easy to retrieve a page of rows using LIMIT/OFFSET semantics.
constusers=awaituserRepository.find().paginate(2,10).execute(dbClient);// users = [...] // Returns an array of rows for that pageOr, use the query builder:
constusers=awaituserRepository.find().where('isActive = ?',true).paginate(1,20).execute(dbClient);Models can define advanced validation rules for columns, including:
requiredminLength/maxLengthpattern(RegExp)customValidator(function)
Validation errors are thrown on create/update if data is invalid.
constUser=defineModel({tableName: "users",columns: {id: {type: DataTypes.Integer,required: true},email: {type: DataTypes.String,required: true,unique: true,minLength: 6,pattern: /^[^@]+@[^@]+\.[^@]+$/,customValidator: (val)=>val.endsWith("@offbytesecure.com")||"Must use an @offbytesecure.com email",},password: {type: DataTypes.String,minLength: 8},},});Enable automatic history tracking and time-travel queries by setting versioned: true in your model configuration.
- Each change is recorded in a
<table>_historytable with version, operation, and audit columns. - Supports snapshot queries, rollbacks, audits, and time-travel.
import{defineModel,DataTypes}from"stabilize-orm";constUser=defineModel({tableName: "users",versioned: true,columns: {id: {type: DataTypes.Integer,required: true},name: {type: DataTypes.String,length: 100},},});// --- Using versioning features:constuserRepository=orm.getRepository(User);// Rollback to a previous versionawaituserRepository.rollback(1,3);// roll back user with id=1 to version 3// Get a snapshot as of a specific dateconstuserAsOf=awaituserRepository.asOf(1,newDate("2025-01-01T00:00:00Z"));console.log(userAsOf);// View full version historyconsthistory=awaituserRepository.history(1);console.log(history);Stabilize ORM supports lifecycle hooks defined in the model configuration or as class methods. You can run logic before/after create, update, delete, or save.
import{defineModel,DataTypes}from"stabilize-orm";constUser=defineModel({tableName: "users",columns: {id: {type: DataTypes.Integer,required: true},name: {type: DataTypes.String,length: 100},createdAt: {type: DataTypes.DateTime},updatedAt: {type: DataTypes.DateTime},},hooks: {beforeCreate: (entity)=>{entity.createdAt=newDate();},beforeUpdate: (entity)=>{entity.updatedAt=newDate();},afterCreate: (entity)=>{console.log(`User created: ${entity.name}`);},},});// Add a hook as a class methodUser.prototype.afterUpdate=asyncfunction(){console.log(`Updated user: ${this.name}`);};export{User};Supported hooks: beforeCreate, afterCreate, beforeUpdate, afterUpdate, beforeDelete, afterDelete, beforeSave, afterSave.
Stabilize includes a powerful CLI for managing your workflow. See: stabilize-cli on GitHub
Generate a model:
stabilize-cli generate:model Product
Generate a migration from a model:
stabilize-cli generate:migration User
Generate a seed file:
stabilize-cli generate:seed InitialRoles
Generate a REST API scaffold:
stabilize-cli generate:api User
Run all pending migrations:
stabilize-cli migrate
Roll back the last migration:
stabilize-cli migrate:rollback
Fresh migration (drop + re-migrate):
stabilize-cli migrate:fresh --force
Run all pending seeds (in dependency order):
stabilize-cli seed
Check the status of migrations and seeds:
stabilize-cli status
Reset the database (drop, migrate, seed):
stabilize-cli db:reset
Backup the database:
stabilize-cli db:backup
Restore from a backup:
stabilize-cli db:restore backups/backup_20250101120000.db --force
Database size statistics:
stabilize-cli db:size
Health check:
stabilize-cli health
CLI info:
stabilize-cli info
import{orm}from"./db";import{User}from"./models/User";constuserRepository=orm.getRepository(User);constnewUser=awaituserRepository.create({email: "lwazicd@icloud.com"});constfoundUser=awaituserRepository.findOne(newUser.id);constupdatedUser=awaituserRepository.update(newUser.id,{email: "admin@offbytesecure.com",});awaituserRepository.delete(newUser.id);constactiveAdmins=awaitorm.getRepository(UserRole).find().join("users","user_roles.user_id = users.id").join("roles","user_roles.role_id = roles.id").select("users.id","users.email","roles.name as role_name").where("roles.name = ?","Admin").orderBy("users.email ASC").execute();console.log(activeAdmins);{select(...fields: string[]): QueryBuilder<User>;where(condition: string, ...params: any[]): QueryBuilder<User>;orWhere(condition: string, ...params: any[]): QueryBuilder<User>;whereIn(column: string,values: any[]): QueryBuilder<User>;whereNotIn(column: string,values: any[]): QueryBuilder<User>;whereNull(column: string): QueryBuilder<User>;whereNotNull(column: string): QueryBuilder<User>;whereBetween(column: string,start: any,end: any): QueryBuilder<User>;groupBy(clause: string): QueryBuilder<User>;having(condition: string, ...params: any[]): QueryBuilder<User>;join(table: string,condition: string): QueryBuilder<User>;orderBy(clause: string): QueryBuilder<User>;limit(limit: number): QueryBuilder<User>;offset(offset: number): QueryBuilder<User>;lock(mode?: "FOR UPDATE"|"FOR SHARE"): QueryBuilder<User>;withRelations(...relations: string[]): QueryBuilder<User>;scope(name: string, ...args: any[]): QueryBuilder<User>;paginate(page: number,pageSize: number): QueryBuilder<User>;build(): { query: string; params: any[]};execute(client?: DBClient,cache?: Cache,cacheKey?: string): Promise<User[]>;}Define reusable query conditions (scopes) in your model configuration to simplify and reuse common filtering logic. Scopes are applied via the scope method on Repository or QueryBuilder, allowing you to chain them with other query operations.
import{defineModel,DataTypes}from"stabilize-orm";import{orm}from"./db";constUser=defineModel({tableName: "users",columns: {id: {type: DataTypes.Integer,required: true},email: {type: DataTypes.String,length: 100,required: true},isActive: {type: DataTypes.Boolean,required: true},createdAt: {type: DataTypes.DateTime},updatedAt: {type: DataTypes.DateTime},},scopes: {active: (qb)=>qb.where("isActive = ?",true),recent: (qb,days: number)=>qb.where("createdAt >= ?",newDate(Date.now()-days*24*60*60*1000),),},});constuserRepository=orm.getRepository(User);// Fetch active usersconstactiveUsers=awaituserRepository.scope("active").execute();// Fetch users created in the last 7 daysconstrecentUsers=awaituserRepository.scope("recent",7).execute();// Combine scopes with other query operationsconstrecentActiveUsers=awaituserRepository.scope("active").scope("recent",7).orderBy("createdAt DESC").limit(10).execute();console.log(recentActiveUsers);Enable automatic management of createdAt and updatedAt columns by setting timestamps in your model configuration. The ORM automatically sets these fields during create, update, bulkCreate, bulkUpdate, and upsert operations in a TypeScript-safe manner, eliminating the need for manual hooks.
import{defineModel,DataTypes}from"stabilize-orm";import{orm}from"./db";constUser=defineModel({tableName: "users",columns: {id: {type: DataTypes.Integer,required: true},email: {type: DataTypes.String,length: 100,required: true},createdAt: {type: DataTypes.DateTime},updatedAt: {type: DataTypes.DateTime},},timestamps: {createdAt: "createdAt",updatedAt: "updatedAt",},});constuserRepository=orm.getRepository(User);// Create a user (createdAt and updatedAt set automatically)constnewUser=awaituserRepository.create({email: "lwazicd@icloud.com"});console.log(newUser.createdAt,newUser.updatedAt);// Outputs current timestamp// Update a user (updatedAt updated automatically)constupdatedUser=awaituserRepository.update(newUser.id,{email: "admin@offbytesecure.com",});console.log(updatedUser.updatedAt);// Outputs new timestamp// Bulk create usersconstnewUsers=awaituserRepository.bulkCreate([{email: "user1@example.com"},{email: "user2@example.com"},]);console.log(newUsers.map((u)=>u.createdAt));// Outputs timestamps for each userEnable soft deletes by setting softDelete: true and marking a column (e.g., deletedAt) with softDelete: true in the model configuration.
- Use
repository.delete(id)to mark an entity as deleted. - Use
repository.recover(id)to restore a soft-deleted entity. - Queries automatically exclude soft-deleted rows unless specified otherwise.
import{defineModel,DataTypes}from"stabilize-orm";constUser=defineModel({tableName: "users",softDelete: true,columns: {id: {type: DataTypes.Integer,required: true},email: {type: DataTypes.String,length: 100,required: true},deletedAt: {type: DataTypes.DateTime,softDelete: true},},});constuserRepository=orm.getRepository(User);awaituserRepository.create({email: "lwazicd@icloud.com"});awaituserRepository.delete(1);// Soft deleteawaituserRepository.recover(1);// RecoverStabilize ORM works seamlessly with web frameworks like Express.
importexpressfrom"express";import{orm}from"./db";import{User}from"./models/User";constapp=express();app.use(express.json());constuserRepository=orm.getRepository(User);app.get("/users",async(req,res)=>{try{constusers=awaituserRepository.find().execute();res.json(users);}catch(err){res.status(500).json({error: "Failed to fetch users."});}});app.post("/users",async(req,res)=>{try{constuser=awaituserRepository.create(req.body);res.status(201).json(user);}catch(err){res.status(500).json({error: "User creation failed."});}});app.listen(3000,()=>{console.log("Server listening on port 3000");});- Use time-travel queries to inspect historical entity states.
- Assert audit trails and rollback operations in your tests.
Enable optimistic locking to detect concurrent modifications. Add optimisticLock: true to a version column in your model.
import{defineModel,DataTypes}from"stabilize-orm";constUser=defineModel({tableName: "users",columns: {id: {type: DataTypes.Integer,required: true},name: {type: DataTypes.String},version: {type: DataTypes.Integer,optimisticLock: true},},});constuserRepository=orm.getRepository(User);// Create with initial versionconstuser=awaituserRepository.create({name: "Lwazi",version: 1});// Update - version is automatically incremented// If another transaction modified the record, a CONCURRENT_MODIFICATION error is throwntry{awaituserRepository.update(user.id,{name: "Updated",version: user.version,});}catch(err){if(err.code==="CONCURRENT_MODIFICATION"){console.log("Record was modified by another transaction");}}Use sqlDefault() to set database-side default values for columns (e.g., gen_random_uuid(), NOW()).
import{defineModel,DataTypes,sqlDefault}from"stabilize-orm";constUser=defineModel({tableName: "users",columns: {id: {type: DataTypes.UUID,required: true,defaultExpression: sqlDefault("gen_random_uuid()"),},name: {type: DataTypes.String},createdAt: {type: DataTypes.DateTime,defaultExpression: sqlDefault("NOW()"),},},});The query builder now supports additional filter methods:
constresults=awaituserRepository.find().where("status = ?","active").orWhere("role = ?","admin").whereIn("age",[25,30,35]).whereBetween("createdAt",newDate("2025-01-01"),newDate("2025-12-31")).whereNull("deletedAt").groupBy("department").having("COUNT(*) > ?",5).orderBy("createdAt DESC").limit(10).execute();Load deeply nested relations using dot notation:
constuser=awaituserRepository.findOne(1,{relations: ["roles","roles.permissions"],});Run aggregate queries directly on the repository or query builder.
// Repository-level aggregatesconsttotal=awaituserRepository.count();constexists=awaituserRepository.exists({email: "admin@offbytesecure.com",});conststats=awaituserRepository.aggregate({count: "*",sum: ["salary"],avg: ["salary"],min: ["salary"],max: ["salary"],});// stats = { count_: 100, sum_salary: 5000000, avg_salary: 50000, min_salary: 20000, max_salary: 150000 }TypeORM-style conditional finders without writing raw SQL.
// Find one record by conditionconstuser=awaituserRepository.findOneBy({email: "lwazicd@icloud.com"});// Find multiple recordsconstadmins=awaituserRepository.findBy({role: "admin"},{limit: 10,orderBy: "createdAt DESC"},);// Combined with relationsconstuser=awaituserRepository.findOneBy({email: "lwazicd@icloud.com"},{relations: ["roles"]},);Get paginated results with a total count in a single call.
const{ data, total }=awaituserRepository.findAndCount();console.log(`Showing ${data.length} of ${total} total records`);Efficient cursor-based pagination for large datasets.
// First pageconstpage1=awaituserRepository.findMany({take: 10,orderBy: {field: "id",direction: "ASC"},});// Next page using cursorconstlastId=page1[page1.length-1].id;constpage2=awaituserRepository.findMany({cursor: {field: "id",value: lastId,direction: "forward"},take: 10,orderBy: {field: "id",direction: "ASC"},});Define and run seeds for populating development/test databases.
import{defineSeed,runSeeds,resetDatabase}from"stabilize-orm";// Define a seeddefineSeed("create-default-roles",async(db)=>{constroleRepo=orm.getRepository(Role);awaitroleRepo.bulkCreate([{name: "Admin",permissions: "all"},{name: "User",permissions: "read"},]);});// Run all seedsawaitrunSeeds(orm.client);// Reset database (drop, migrate, seed)awaitresetDatabase(orm.client,[User,Role]);Monitor database and cache connectivity with latency.
consthealth=awaitorm.healthCheck();// { status: "healthy", database: "postgres", latencyMs: 12.5, cacheStatus: "connected" }// Per-table healthconstuserHealth=awaituserRepository.healthCheck();// { status: "healthy", table: "users", rows: 142, latencyMs: 8.3 }Upsert multiple records in a single transaction.
constusers=awaituserRepository.bulkUpsert([{email: "lwazicd@icloud.com",name: "Lwazi"},{email: "ciniso@icloud.com",name: "Ciniso"},],["email"],// unique key(s));Execute raw SQL queries directly from the ORM instance.
constresults=awaitorm.rawQuery("SELECT * FROM users WHERE age > ?",[25]);const{ affectedRows }=awaitorm.rawExec("UPDATE users SET active = false WHERE last_login < ?",[oneYearAgo],);// Restore all soft-deleted recordsconstrecovered=awaituserRepository.recoverAll();console.log(`Recovered ${recovered} records`);// Clear the tableawaituserRepository.truncate();Atomically update numeric fields without loading the record.
constupdated=awaituserRepository.increment(user.id,"loginCount",1);constupdated=awaituserRepository.decrement(user.id,"credits",5);// Get just the email column as an arrayconstemails=awaituserRepository.pluck("email");// ["lwazicd@icloud.com", "ciniso@icloud.com", ...]// Get specific columnsconstusers=awaituserRepository.selectColumns("id","email");// [{ id: 1, email: "lwazicd@icloud.com" }, ...]Toggle a boolean field.
consttoggled=awaituserRepository.toggle(user.id,"isActive");// isActive was true, now false (or vice versa)// Update all active users' role to "member"constupdated=awaituserRepository.updateBy({isActive: true},{role: "member"},);// Delete all users with null emailconstdeleted=awaituserRepository.deleteBy({email: null});// Get only soft-deleted recordsconstdeletedUsers=awaituserRepository.findDeleted().execute();// Get all records including soft-deletedconstallUsers=awaituserRepository.withTrashed().execute();// Restore matching soft-deleted recordsconstrestored=awaituserRepository.restoreBy({role: "admin"});// Find or create in one callconstuser=awaituserRepository.firstOrCreate({email: "lwazicd@icloud.com"},{name: "Lwazi"},);// Find and update, or create if not foundconstuser=awaituserRepository.updateOrCreate({email: "lwazicd@icloud.com"},{name: "Updated Name"},);constfirstUser=awaituserRepository.first();constadmin=awaituserRepository.first({role: "admin"});constlastUser=awaituserRepository.last();constrandomUser=awaituserRepository.random();constuser=awaituserRepository.lockForUpdate(user.id);// The row is now locked for the duration of the transactionSubscribe to ORM lifecycle events.
constorm=newStabilize(dbConfig);orm.events.on("query",(entry)=>{console.log(`[${entry.durationMs}ms] ${entry.query}`);});orm.events.on("error",(err)=>{console.error("ORM error:",err);});orm.events.on("connection:open",(dbType)=>{console.log(`Connected to ${dbType}`);});conststats=awaitorm.poolStats();// { active: 5, idle: 10, total: 15 }import{generateUUID}from"stabilize-orm";constid=generateUUID();// "550e8400-e29b-41d4-a716-446655440000"Licensed under the MIT License. See LICENSE.md for details.
Created with ❤️ by ElectronSz
File last updated: 2026-04-02
