Files
2026-05-27 23:47:53 +01:00

4.3 KiB

MigrationRunner

(no submodules)

Purpose

MigrationRunner is the interface that DatabaseAdapter.migrate delegates to in concrete adapter implementations. Separating the migration contract from the adapter contract lets adapters swap migration strategies without changing their top-level API — for example, a SQLite adapter might use Drizzle's migration runner while a SurrealDB adapter uses a custom .surql file runner, yet both expose the same DatabaseAdapter.migrate signature to callers.

The three-method design mirrors the principle of least surprise: pendingMigrations is a read-only query (safe to call for status checks without making changes), applyMigration is the atomic unit of work, and run is the high-level orchestrator that wires them together.

Features

  • pendingMigrations — read-only: lists unapplied migrations without side effects
  • applyMigration — atomic unit: applies a single named migration by SQL string
  • run — orchestrator: discovers, orders, and applies all pending migrations
  • MigrationError — thrown with the exact migration name on failure
  • AbortSignal on both applyMigration and run via DbOperationOptions
  • Accepts optional MigrationConfig to override folder/table defaults

Usage: Basic Example

import type { MigrationRunner } from "@suaveplan/db";
import { MigrationError } from "@suaveplan/db";

class FileMigrationRunner implements MigrationRunner {
  async pendingMigrations(config?) {
    const applied = await this.loadApplied(config);
    const all = await this.discoverFiles(config);
    return all
      .filter(m => !applied.has(m.name))
      .map(m => ({ name: m.name, applied: false }));
  }

  async applyMigration(name, sql, opts?) {
    try {
      await this.db.execute(sql, [], opts);
      await this.recordApplied(name);
    } catch (err) {
      throw new MigrationError(name, err instanceof Error ? err.message : String(err), { cause: err });
    }
  }

  async run(config?, opts?) {
    const pending = await this.pendingMigrations(config);
    const names: string[] = [];
    for (const { name } of pending) {
      const sql = await this.loadSql(name, config);
      await this.applyMigration(name, sql, opts);
      names.push(name);
    }
    return { applied: names.length, migrations: names };
  }
}

Usage: Advanced Example

import type { MigrationRunner } from "@suaveplan/db";
import { MigrationError, withDbSpan } from "@suaveplan/db";

// Wrapping run with OTel tracing
async function runMigrations(runner: MigrationRunner): Promise<void> {
  const result = await withDbSpan("db.migrate", () =>
    runner.run({ migrationsFolder: "./db/migrations" })
  );
  console.log(`Applied ${result.applied} migration(s):`, result.migrations);
}

// Status check without applying
async function checkMigrationStatus(runner: MigrationRunner): Promise<void> {
  const pending = await runner.pendingMigrations();
  if (pending.length > 0) {
    console.warn(`${pending.length} unapplied migrations:`, pending.map(m => m.name));
  }
}

API Reference

MigrationRunner

interface MigrationRunner {
  pendingMigrations(config?: MigrationConfig): Promise<MigrationStatus[]>;
  applyMigration(name: string, sql: string, opts?: DbOperationOptions): Promise<void>;
  run(config?: MigrationConfig, opts?: DbOperationOptions): Promise<MigrationResult>;
}
  • pendingMigrations — returns only unapplied migrations; never mutates state
  • applyMigration — applies one migration; throws MigrationError on failure with the migration's name in error.migration
  • run — discovers pending migrations via pendingMigrations and applies them in order via applyMigration; returns aggregate MigrationResult

Implementation Notes

MigrationRunner is intentionally an interface rather than an abstract class. This keeps the dependency graph clean — the runner itself has no implementation, so it imposes no runtime cost. Concrete adapters in @suaveplan/db-sqlite and future packages supply the implementation.

The AbortSignal is only threaded through applyMigration and run — pendingMigrations is a read-only operation and is not expected to take long enough to warrant cancellation in typical use cases. Implementors may add signal support to pendingMigrations as a non-breaking extension.