5.0 KiB
runner — MigrationRunner class + createMigrationRunner factory
(no submodules)
Purpose
The MigrationRunner is the dialect-agnostic core of the package. It reads
migration files from a directory, sorts them by leading-digit order, applies
any not-yet-applied ones via the injected MigrationAdapter, and records
each application with a real SHA-256 checksum. Unlike the upstream runner —
which declared validateChecksums: true but never actually compared
checksums — this implementation reads every already-applied migration's file
content from disk, recomputes the hash, and halts with
MigrationError(MIGRATION.CHECKSUM_DRIFT) on mismatch BEFORE any pending
migration is touched.
The runner is server-tier: it uses node:fs/promises for directory and file
access and node:crypto for SHA-256. Both Bun and Node ship these natively.
Features
- Per-dialect default file extension (
.surqlforsurrealql,.sqlotherwise). - Numerical ordering by leading-digit prefix (
001_initial.sql→ order 1). - Real SHA-256 checksum validation against on-disk files — drift detected
pre-apply and surfaced as
MIGRATION.CHECKSUM_DRIFTwith{name, expected, actual}context. AbortSignalpropagation — the runner checkssignal.abortedbetween pending migrations; aborts return the partial result array.- Dry-run mode (
{ dryRun: true }) logs but does not invokeadapter.execute/adapter.insertApplied. - Halt on first failure — the runner does NOT continue past a failed apply, so partial-success deployments stay consistent.
- OTel counter + histogram per apply attempt, regardless of outcome.
rollback()removes the tracking record of the most recent migration (sorted byappliedAtdesc); does not revert schema.MIGRATION.DIRECTORY_NOT_FOUNDwhenrunFromDirectoryis given a missing path.MIGRATION.INVALID_FILE_NAMEwhen a.sql/.surqlfile does not start with one or more digits.
Usage: Basic Example
import { MigrationRunner } from "@suaveplan/api-migrations/runner/runner";
import { createSqliteAdapter } from "@suaveplan/api-migrations/adapters/sqlite/sqlite";
const runner = new MigrationRunner(createSqliteAdapter(client));
const results = await runner.runFromDirectory("./db/migrations");
for (const r of results) {
console.log(r.success ? `OK ${r.name} (${r.duration}ms)` : `ERR ${r.name}: ${r.error}`);
}
Usage: Advanced Example
import { MigrationRunner } from "@suaveplan/api-migrations/runner/runner";
const controller = new AbortController();
setTimeout(() => controller.abort(), 5_000);
const runner = new MigrationRunner(adapter, {
tableName: "_app_migrations",
fileExtension: ".sql",
validateChecksums: true,
logger: {
debug() {},
info: (m) => console.log(`[mig] ${m}`),
warn: (m) => console.warn(`[mig] ${m}`),
error: (m) => console.error(`[mig] ${m}`),
},
});
const results = await runner.runFromDirectory("./db/migrations", {
signal: controller.signal,
});
const failed = results.find((r) => !r.success);
if (failed) {
throw new Error(`Apply halted at ${failed.name}: ${failed.error}`);
}
API Reference
class MigrationRunner— constructor(adapter, options?).runFromDirectory(dirPath, { signal? }?): Promise<MigrationResult[]>.run(files, { signal? }?): Promise<MigrationResult[]>.runMigration(file, signal?): Promise<MigrationResult>.getAppliedMigrations(): Promise<MigrationRecord[]>.getPendingMigrations(dir): Promise<MigrationFile[]>.rollback(): Promise<MigrationResult | null>.scanDirectory(dir): Promise<MigrationFile[]>.
createMigrationRunner(adapter, options?)— factory mirroring upstream.computeChecksum(content)—node:cryptoSHA-256, lowercase hex.- Options:
tableName(default"_migrations"),fileExtension(per-dialect default),validateChecksums(defaulttrue),dryRun(defaultfalse),logger(default noop),clock(defaultDate.now).
Implementation Notes
- Checksum validation happens BEFORE any pending migration is applied. If a
drift is detected the runner halts — the operator must restore the file
or rerun with
validateChecksums: false(audited and discouraged). - Pre-aborted signals are honored before the first apply attempt — the result array is empty in that case.
- Failures inside
adapter.executeoradapter.insertAppliedare wrapped inMigrationError(MIGRATION.APPLY_FAILED), with the underlying client error ascause. The runner returns{success: false, error}and breaks the loop, so subsequent migrations are not attempted. - Rollback only removes the most recent tracking row — sorting is
performed in memory by
appliedAtdesc so adapters do not need to guarantee returned-row order. MIGRATION.ROLLBACK_NOT_SUPPORTEDis raised by adapters configured to refusedeleteApplied(e.g. the surrealql adapter with{rollback: false}). The runner surfaces this code untouched.