@bunary/orm
ORM for Bunary - a Bun-first backend framework inspired by Laravel Eloquent.
Features a database abstraction layer that supports multiple database types through a unified API.
Installation
bun add @bunary/orm
Quick Start
import { Model, setOrmConfig } from "@bunary/orm";
// Configure the ORM with credentials
setOrmConfig({
database: {
type: "sqlite",
sqlite: {
path: "./database.sqlite"
}
}
});
// Models use the configured database automatically
const user = await Model.table("users").find(1);
const users = await Model.table("users").all();
const usersWithColumns = await Model.table("users")
.select("id", "name", "email")
.all();
const usersWithoutPassword = await Model.table("users")
.exclude("password")
.all();
Architecture
The ORM uses a database abstraction layer:
- ORM Connection - Configure credentials and connection settings
- Database Drivers - Abstraction layer for different database types
- Models - Use the abstraction to query any database type
Model → Query Builder → Database Driver → Database
Configuration
The ORM can be configured in two ways:
Option 1: Using @bunary/core (Optional)
Configure the ORM alongside your app configuration using @bunary/core:
import { createConfig, defineConfig } from "@bunary/core";
// Create a config store
const configStore = createConfig(
defineConfig({
app: {
name: "MyApp",
env: "development",
},
orm: {
database: {
type: "sqlite",
sqlite: {
path: "./database.sqlite"
}
}
}
})
);
// Make config store available globally for ORM to access
globalThis.__bunaryCoreConfigStore = configStore;
The ORM will automatically read its configuration from @bunary/core when a config store is available and no explicit config is set.
Note: @bunary/core is an optional peer dependency. Install it only if you want to use the integrated configuration approach.
Option 2: Using setOrmConfig()
You can also configure the ORM directly using setOrmConfig():
import { setOrmConfig } from "@bunary/orm";
setOrmConfig({
database: {
type: "sqlite",
sqlite: {
path: "./database.sqlite" // Path to SQLite database file
}
}
});
Note: setOrmConfig() will override any configuration from @bunary/core.
SQLite
// Using @bunary/core
defineConfig({
orm: {
database: {
type: "sqlite",
sqlite: {
path: "./database.sqlite"
}
}
}
});
// Or using setOrmConfig()
setOrmConfig({
database: {
type: "sqlite",
sqlite: {
path: "./database.sqlite"
}
}
});
MySQL (Coming Soon)
// Using @bunary/core
defineConfig({
orm: {
database: {
type: "mysql",
mysql: {
host: "localhost",
port: 3306,
user: "root",
password: "password",
database: "mydb"
}
}
}
});
// Or using setOrmConfig()
setOrmConfig({
database: {
type: "mysql",
mysql: {
host: "localhost",
port: 3306,
user: "root",
password: "password",
database: "mydb"
}
}
});
API
Eloquent-like Models with BaseModel
For an Eloquent-like experience, extend BaseModel to create model classes:
import { BaseModel } from "@bunary/orm";
class Users extends BaseModel {
protected static tableName = "users";
protected static protected = ["password", "secret_key"]; // Auto-excluded fields
protected static timestamps = true; // Auto-exclude createdAt, updatedAt
}
// Use it like Laravel Eloquent:
const users = await Users.all(); // password and timestamps automatically excluded
const user = await Users.find(1);
const user = await Users.where("email", "john@example.com").first();
const count = await Users.count();
// Create records (UUID auto-generated by default)
const newUser = await Users.create({
name: "John Doe",
email: "john@example.com"
});
// newUser.id is auto-generated UUID v7
BaseModel Features
- Automatic field exclusion: Define
protected static protected = ["password"]to automatically exclude sensitive fields - Timestamp management: Set
protected static timestamps = trueto excludecreatedAtandupdatedAt, or provide a custom array - UUID-first primary keys: Defaults to UUID v7 primary keys (using
Bun.randomUUIDv7()) - All query builder methods: Available as static methods on your model class
BaseModel Configuration
class Users extends BaseModel {
// Required: Table name
protected static tableName = "users";
// Optional: Fields to automatically exclude from all queries
protected static protected = ["password", "secret_key"];
// Optional: Timestamp fields to exclude
// - true: Exclude createdAt, updatedAt (default)
// - false: Don't exclude timestamps
// - ["createdAt"]: Exclude only createdAt
protected static timestamps = true;
// Optional: Primary key type (defaults to "uuid")
// - "uuid": Use UUID v7 primary keys (auto-generated with Bun.randomUUIDv7())
// - "integer": Use integer primary keys
protected static primaryKeyType: "uuid" | "integer" = "uuid";
// Optional: Primary key column name (defaults to "id")
protected static primaryKeyName = "id";
}
Model.table(tableName)
Start a query for a specific table. Returns a QueryBuilder instance.
const query = Model.table("users");
Query Builder Methods
find(id: string | number)
Find a record by its ID.
const user = await Model.table("users").find(1);
// Returns: { id: 1, name: "John", email: "john@example.com", ... } or null
all()
Get all records from the table.
const users = await Model.table("users").all();
// Returns: [{ id: 1, name: "John", ... }, { id: 2, name: "Jane", ... }, ...]
select(...columns: string[])
Select specific columns from the results.
const users = await Model.table("users")
.select("id", "name", "email")
.all();
// Returns only id, name, and email columns
exclude(...columns: string[])
Exclude specific columns from the results.
const users = await Model.table("users")
.exclude("password", "secret_key")
.all();
// Returns all columns except password and secret_key
where(column: string, operatorOrValue: string | number | boolean, value?: string | number | boolean)
Add a where condition to filter results.
// Simple equality (default operator is '=')
const users = await Model.table("users")
.where("age", 25)
.all();
// With explicit operator
const users = await Model.table("users")
.where("age", ">", 18)
.all();
limit(count: number)
Limit the number of results returned.
const users = await Model.table("users")
.limit(10)
.all();
offset(count: number)
Skip a number of records (useful for pagination).
const users = await Model.table("users")
.limit(10)
.offset(20) // Skip first 20 records
.all();
orderBy(column: string, direction?: "asc" | "desc")
Order results by a column.
const users = await Model.table("users")
.orderBy("name", "asc")
.all();
// Default direction is 'asc'
const users = await Model.table("users")
.orderBy("created_at")
.all();
first()
Get the first record matching the query.
const user = await Model.table("users")
.where("email", "john@example.com")
.first();
// Returns: { id: 1, name: "John", ... } or null
count()
Count the number of records matching the query.
const total = await Model.table("users").count();
const activeUsers = await Model.table("users")
.where("active", true)
.count();
Method Chaining
All query builder methods are chainable:
// Chain select with find
const user = await Model.table("users")
.select("id", "name")
.find(1);
// Chain exclude with all
const users = await Model.table("users")
.exclude("password")
.all();
// When both select() and exclude() are used, select() takes precedence
const users = await Model.table("users")
.select("id", "name")
.exclude("password") // This is ignored since select() is used
.all();
Database Drivers
The ORM uses a driver abstraction layer, making it easy to add new database types.
SQLite (MVP)
SQLite is fully supported and uses Bun's native bun:sqlite module.
MySQL (Structure Ready)
The MySQL driver structure is in place. To implement:
- Add MySQL client library (e.g.,
mysql2) - Implement
MysqlDriverinsrc/drivers/mysql-driver.ts - Update
createDriver()insrc/connection.ts
PostgreSQL (Planned)
PostgreSQL support will follow the same pattern as MySQL.
Custom Drivers (Third-Party Registry)
Third-party packages can register custom database drivers using the driver registry API:
import { registerDriver, createDriver, type DriverFactory } from "@bunary/orm";
import type { DatabaseConfig, DatabaseDriver } from "@bunary/orm";
// Define your custom driver
class PostgresDriver implements DatabaseDriver {
// ... implement DatabaseDriver interface
}
// Register the driver factory
const factory: DriverFactory = (config: DatabaseConfig) => {
return new PostgresDriver(config.postgres!);
};
registerDriver("postgres", factory);
// Now you can use it in your config
setOrmConfig({
database: {
type: "postgres",
postgres: {
// your postgres config
}
}
});
Note: Registered drivers take precedence over built-in drivers, allowing you to override default implementations if needed.
Transactions
The ORM supports database transactions for atomic operations:
import { getDriver } from "@bunary/orm";
const driver = getDriver();
// Execute operations in a transaction
await driver.transaction(async (tx) => {
tx.exec("INSERT INTO users (name) VALUES (?)", "Alice");
tx.exec("INSERT INTO users (name) VALUES (?)", "Bob");
// If any operation fails, all changes are rolled back
});
// Transactions automatically commit on success or rollback on error
try {
await driver.transaction(async (tx) => {
tx.exec("INSERT INTO users (name) VALUES (?)", "Alice");
throw new Error("Something went wrong");
// This will automatically rollback
});
} catch (error) {
// Transaction was rolled back
}
// Nested transactions use savepoints
await driver.transaction(async (tx) => {
tx.exec("INSERT INTO users (name) VALUES (?)", "Alice");
await tx.transaction(async (tx2) => {
tx2.exec("INSERT INTO users (name) VALUES (?)", "Bob");
// Nested transaction (savepoint)
});
// Both commits if successful, or both rollback on error
});
Schema Builder (Migrations)
The ORM provides a schema builder for creating and altering tables (SQLite):
import { Schema, setOrmConfig } from "@bunary/orm";
setOrmConfig({
database: {
type: "sqlite",
sqlite: { path: "./database.sqlite" }
}
});
// Create a table with UUID primary key
Schema.createTable("users", (table) => {
table.uuid("id").primary(); // UUID primary key (defaults to "id")
table.string("name", 255).notNull();
table.string("email", 255).unique().notNull();
table.boolean("active").default(true);
table.timestamp("deleted_at").nullable();
table.timestamps();
});
// Or use integer primary key
Schema.createTable("posts", (table) => {
table.increments("id"); // Integer primary key
table.text("title").notNull();
});
// Check if table/column exists
if (!Schema.hasTable("users")) {
Schema.createTable("users", (table) => { /* ... */ });
}
if (!Schema.hasColumn("users", "phone")) {
Schema.table("users", (table) => {
table.string("phone", 20).nullable();
});
}
// Rename a table
Schema.renameTable("users", "accounts");
// Drop a table
Schema.dropTable("users");
Schema methods: createTable(name, callback), dropTable(name), table(name, callback) (alter), hasTable(name), hasColumn(table, column), renameTable(oldName, newName).
Column types: increments("id"), integer("col"), text("col"), string("col", length?), boolean("col"), timestamp("col"), uuid("id"?), foreignId("col"), timestamps().
Column modifiers: .nullable(), .notNull(), .default(value), .unique(), .primary().
Constraints: unique(["a", "b"]), index(["a", "b"]), foreign("col").references("table", "column"), foreignId("col").references("table", "column").
Migrations Repository
Track which migrations have been applied using MigrationsRepository. It ensures a migrations table exists, records applied migrations by name and batch, and supports listing and rollback.
import { MigrationsRepository, setOrmConfig } from "@bunary/orm";
setOrmConfig({
database: {
type: "sqlite",
sqlite: { path: "./database.sqlite" }
}
});
const repo = new MigrationsRepository();
repo.ensureTable(); // Create migrations table if missing (idempotent)
// Run a migration, then log it
// ... execute migration SQL ...
repo.log("20260101000000_create_users", repo.getNextBatchNumber());
// List applied migrations (ordered by id)
const applied = repo.listApplied();
// Rollback: delete one migration record or a whole batch
repo.deleteLog("20260101000000_create_users");
repo.deleteBatch(2); // Remove all migrations in batch 2
API: ensureTable(), log(name, batch), listApplied(), getNextBatchNumber(), getLastBatch(), deleteLog(name), deleteBatch(batch).
Migrator Runner
Run and rollback migrations using the createMigrator() API. Discovers migration files, runs pending migrations in order, and supports rollback. Uses transactions for safety.
import { createMigrator, setOrmConfig } from "@bunary/orm";
setOrmConfig({
database: {
type: "sqlite",
sqlite: { path: "./database.sqlite" }
}
});
const migrator = createMigrator({ migrationsPath: "./database/migrations" });
// Check status
const status = await migrator.status();
console.log("Ran:", status.ran);
console.log("Pending:", status.pending);
// Run all pending migrations
await migrator.up();
// Rollback last batch
await migrator.down();
// Rollback multiple batches
await migrator.down({ steps: 2 });
Migration file format: Each migration file must export up() and down() functions. Use the Schema Builder for type-safe table definitions:
// database/migrations/20260101000000_create_users.ts
import { Schema } from "@bunary/orm";
export async function up() {
Schema.createTable("users", (table) => {
table.uuid("id").primary();
table.string("name", 255).notNull();
table.string("email", 255).unique().notNull();
table.boolean("active").default(true);
table.timestamps();
});
Schema.createTable("posts", (table) => {
table.uuid("id").primary();
table.foreignId("user_id").references("users", "id");
table.string("title", 255).notNull();
table.text("content").nullable();
table.timestamps();
});
}
export async function down() {
Schema.dropTable("posts");
Schema.dropTable("users");
}
Complete migration workflow example:
import { createMigrator, Schema, setOrmConfig } from "@bunary/orm";
setOrmConfig({
database: {
type: "sqlite",
sqlite: { path: "./database.sqlite" }
}
});
// Create migrator instance
const migrator = createMigrator({ migrationsPath: "./database/migrations" });
// Check migration status
const status = await migrator.status();
console.log(`Ran: ${status.ran.length}, Pending: ${status.pending.length}`);
// Run all pending migrations
await migrator.up();
// Rollback last batch if needed
await migrator.down();
API: createMigrator(options?), migrator.status(), migrator.up(), migrator.down({ steps? }). Migrations run in transactions - if any migration fails, all changes are rolled back.
Advanced Usage
Direct Driver Access
For advanced use cases, you can access the driver directly:
import { getDriver } from "@bunary/orm";
const driver = getDriver();
const result = driver.query("SELECT * FROM users WHERE id = ?", 1);
const user = result.get();
Driver Connection Management
The ORM caches driver instances for connection reuse. getDriver() returns the same driver instance when called multiple times with the same configuration, reducing connection overhead.
import { getDriver, closeDriver, resetDriver } from "@bunary/orm";
// Get driver (cached after first call)
const driver1 = getDriver();
const driver2 = getDriver(); // Returns same instance as driver1
// Explicitly close the cached driver
closeDriver(); // Closes connection and clears cache
// Reset cache without closing (useful for testing)
resetDriver(); // Clears cache, next getDriver() creates new instance
Note: The driver cache is automatically invalidated when the configuration changes, ensuring you always get a driver matching the current config.
Requirements
- Bun ≥ 1.4.0
License
MIT