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 effectsapplyMigration— atomic unit: applies a single named migration by SQL stringrun— orchestrator: discovers, orders, and applies all pending migrationsMigrationError— thrown with the exact migration name on failureAbortSignalon bothapplyMigrationandrunviaDbOperationOptions- Accepts optional
MigrationConfigto 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 stateapplyMigration— applies one migration; throwsMigrationErroron failure with the migration's name inerror.migrationrun— discovers pending migrations viapendingMigrationsand applies them in order viaapplyMigration; returns aggregateMigrationResult
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.