Skip to main content
The @ooneex/migrations component is a database migration runner for Bun’s native SQL client. Each migration is a class implementing IMigration with up and down methods, a timestamp-based version, and an optional dependency list. Migrations are registered with a decorator, sorted by version, and run inside a transaction — applied versions are tracked in a database table so each one runs exactly once.

Why this component

  • Versioned and ordered. Every migration carries a timestamp version (YYYYMMDDHHMMSSMMM); the runner sorts by version and applies them in order.
  • Up and down. up() applies the change, down() reverses it — so schema changes stay symmetric and reviewable.
  • Transaction-safe. Each migration runs inside sql.begin(); a failure rolls back the whole migration and stops the run.
  • Run-once tracking. Applied versions are recorded in a tracking table (default migrations); already-applied migrations are skipped.
  • Dependency-aware. A migration can declare other migrations as dependencies, and they run first.
  • Container-managed. Register a migration class with a decorator and the runner resolves it from the container.

How it works

You write a migration class that implements IMigration and register it with @decorator.migration(). The up() runner loads every registered migration, sorts it by version, and applies each pending one inside a transaction, recording the version when it succeeds.
MemberPurpose
up(tx, sql)Apply the schema change. Runs inside a transaction (tx); sql is the pooled client.
down(tx, sql)Reverse the schema change — drop exactly what up added.
getVersion()Return the timestamp version string; used for ordering and tracking.
getDependencies()Return the migration classes that must run before this one.
The up() runner drives the lifecycle:
FunctionPurpose
up(config?)Run all pending migrations in version order, each in a transaction.
migrationCreate(config?)Generate a new migration file (and its test) from the template.
getMigrations()Return every registered migration instance, sorted by version.
generateMigrationVersion()Produce a YYYYMMDDHHMMSSMMM timestamp version string.
createMigrationTable(sql, tableName)Create the tracking table (id VARCHAR(20) PRIMARY KEY) if missing.
The tracking table stores one row per applied version. Before running a migration, the runner checks the table for its id; if present, the migration is skipped. Passing --drop to the runner first drops and recreates the public schema — destructive, for development only.

Environment variables

VariableRequiredPurpose
DATABASE_URLYes (unless databaseUrl is passed in config)Postgres connection string used by the runner, e.g. postgres://user:pass@localhost:5432/mydb.
DATABASE_URL=postgres://user:pass@localhost:5432/mydb

Usage

A migration is a class implementing IMigration, registered with the decorator. up() applies the change and down() reverses it:
import { decorator } from "@ooneex/migrations";
import type { IMigration, MigrationClassType } from "@ooneex/migrations";
import type { SQL, TransactionSQL } from "bun";

@decorator.migration()
export class Migration20240115103045 implements IMigration {
  public async up(tx: TransactionSQL, sql: SQL): Promise<void> {
    await tx`CREATE TABLE users (
      id SERIAL PRIMARY KEY,
      email VARCHAR(255) NOT NULL UNIQUE,
      created_at TIMESTAMP DEFAULT NOW()
    )`;
  }

  public async down(tx: TransactionSQL, sql: SQL): Promise<void> {
    await tx`DROP TABLE IF EXISTS users`;
  }

  public getVersion(): string {
    return "20240115103045";
  }

  public getDependencies(): MigrationClassType[] {
    return [];
  }
}
Run all pending migrations with up(). It reads DATABASE_URL by default, or takes an explicit connection string and tracking table name:
import { up } from "@ooneex/migrations";

// Uses DATABASE_URL and the default "migrations" table
await up();

// Or with explicit config
await up({
  databaseUrl: "postgres://user:pass@localhost:5432/mydb",
  tableName: "schema_migrations",
});
Declare dependencies to guarantee ordering beyond the version timestamp — a migration’s dependencies always run first:
public getDependencies(): MigrationClassType[] {
  return [Migration20240115103045];
}

Best practices

  • Make down() reverse up() exactly. Drop precisely what up adds; an asymmetric or irreversible down() is a bug.
  • Never edit an applied migration. On a shared database, add a new corrective migration instead of rewriting one that already ran.
  • Index what you query. Add indexes for foreign keys, WHERE / ORDER BY columns, and unique constraints in up(), and drop them in down() before the table or column they cover.
  • Match the entity. Keep column types, nullability, and lengths in sync with the entity definition — a non-nullable column must be NOT NULL in the migration.
  • Reserve --drop for development. It wipes the whole schema; never run it against a shared or production database.
  • Use dependencies for cross-migration ordering. When one change must precede another regardless of timestamps, declare it in getDependencies().

CLI command

Scaffold a migration with the generator. ooneex migration:create writes a timestamped migration class under modules/<module>/src/migrations/, a matching test under modules/<module>/tests/migrations/, refreshes the migrations.ts barrel export, and creates the module’s bin/migration/up.ts runner if it is missing.
# Generate a migration in the shared module
ooneex migration:create

# Target a specific module
ooneex migration:create --module=auth
OptionDescriptionDefault
--moduleTarget module the migration is generated into.shared
The migration file name is derived from a timestamp version automatically — there is no --name option. Capture the schema change when you implement up() and down(). The generated class is a ready-to-fill stub:
import { decorator, type IMigration, type MigrationClassType } from "@ooneex/migrations";
import type { TransactionSQL } from "bun";

@decorator.migration()
export class Migration20240115103045 implements IMigration {
  public async up(tx: TransactionSQL): Promise<void> {
    // await tx`...`;
  }

  public async down(tx: TransactionSQL): Promise<void> {
    // await tx`...`;
  }

  public getVersion(): string {
    return "20240115103045";
  }

  public getDependencies(): MigrationClassType[] {
    return [];
  }
}
Apply pending migrations across every module with ooneex migration:up:
# Run all pending migrations
ooneex migration:up

# DROP the schema first, then run every migration (destructive — dev only)
ooneex migration:up --drop
OptionDescriptionDefault
--dropDrop and recreate the public schema before running migrations.false
See migration:create and migration:up for the full command references.

Use with Claude and Codex

The generator ships a matching migration:create skill. It runs the scaffold and then guides your AI agent through completing the migration — implementing up() with the schema change, down() with the reverse, and the indexes your queries need. Initialize the skills once for your agent:
ooneex claude:init
Then ask Claude in natural language — it maps the request to the generator, runs it, and fills in the implementation:
Prompt
Create a migration that adds an avatar column to the users table.
For example, the prompt above maps to migration:create, then implements up() to add the avatar column and down() to drop it.