Files
skills/docs/api-stack/api/api-migrations/src/runner/runner.md
2026-05-29 08:20:56 +01:00

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 (.surql for surrealql, .sql otherwise).
  • 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_DRIFT with {name, expected, actual} context.
  • AbortSignal propagation — the runner checks signal.aborted between pending migrations; aborts return the partial result array.
  • Dry-run mode ({ dryRun: true }) logs but does not invoke adapter.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 by appliedAt desc); does not revert schema.
  • MIGRATION.DIRECTORY_NOT_FOUND when runFromDirectory is given a missing path.
  • MIGRATION.INVALID_FILE_NAME when a .sql/.surql file 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:crypto SHA-256, lowercase hex.
  • Options: tableName (default "_migrations"), fileExtension (per-dialect default), validateChecksums (default true), dryRun (default false), logger (default noop), clock (default Date.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.execute or adapter.insertApplied are wrapped in MigrationError(MIGRATION.APPLY_FAILED), with the underlying client error as cause. 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 appliedAt desc so adapters do not need to guarantee returned-row order.
  • MIGRATION.ROLLBACK_NOT_SUPPORTED is raised by adapters configured to refuse deleteApplied (e.g. the surrealql adapter with {rollback: false}). The runner surfaces this code untouched.