Bunary is v0.x and under active development — APIs may still move between minor releases.

    @bunary/orm

    <!-- AUTO-GENERATED FILE — DO NOT EDIT DIRECTLY. Generated by: bun run sync:packages Source: https://raw.githubusercontent.com/bunary-dev/orm/main/docs/index.md -->

    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:

    1. ORM Connection - Configure credentials and connection settings
    2. Database Drivers - Abstraction layer for different database types
    3. 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 = true to exclude createdAt and updatedAt, 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:

    1. Add MySQL client library (e.g., mysql2)
    2. Implement MysqlDriver in src/drivers/mysql-driver.ts
    3. Update createDriver() in src/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

    Last updated