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

3.5 KiB

migrations/run-migrations — per-engine migration runner

(no submodules)

Purpose

runMigrations applies a plugin's pending migrations from a directory of *.sql files, recording applied versions in the engine-side __plugin_migrations(plugin, version, applied_at) table. It is the single entry point used by installing step of the plugin lifecycle FSM; the plugin host calls it once per (plugin, engine) pair during boot.

The runner is engine-neutral: it issues only standard SQL (CREATE TABLE IF NOT EXISTS, SELECT, INSERT, plus the migration body). When the underlying adapter exposes transaction, each migration is applied inside an atomic transaction; on failure the transaction rolls back and the row in __plugin_migrations is not written.

Features

  • Lexicographic file ordering (001_init.sql before 002_*.sql).
  • Idempotent re-run — applied versions are tracked in __plugin_migrations; a second call with the same file set applies zero migrations.
  • Per-migration transaction { body + INSERT } for engines that support transactions; flat execute + execute fall-back for those that don't (e.g. some Surreal modes).
  • OTel instruments: api.db.migrations_applied_total{plugin} counter + api.db.migration_duration_ms{plugin} histogram.
  • Injectable fs: MigrationsFsReader — tests use an in-memory map, production wires node:fs/promises.
  • On any failure: DB_ADAPTER.MIGRATION_FAILED with cause + details including the failing file name.

Usage: Basic

import { runMigrations } from "@suaveplan/api-db-adapter/migrations";
import { promises as fs } from "node:fs";

const result = await runMigrations({
    plugin: "audit",
    capabilities: [],
    admin,
    migrationsDir: "./db/migrations",
    fs: {
        readdir: (dir) => fs.readdir(dir),
        readFile: (path) => fs.readFile(path, "utf8"),
    },
});
// result.applied = ["001_init.sql", "002_add_invoices.sql"]
// result.skipped = []  // first run

Usage: Advanced

const memFs = {
    readdir: async () => ["001.sql", "002.sql"],
    readFile: async (path: string) =>
        path.endsWith("001.sql")
            ? "CREATE TABLE users(id TEXT)"
            : "ALTER TABLE users ADD COLUMN email TEXT",
};

const first = await runMigrations({
    plugin: "user",
    capabilities: [],
    admin,
    migrationsDir: "/dev/null",
    fs: memFs,
});
// first.applied.length === 2

const second = await runMigrations({
    plugin: "user",
    capabilities: [],
    admin,
    migrationsDir: "/dev/null",
    fs: memFs,
});
// second.applied.length === 0, second.skipped.length === 2

API Reference

  • runMigrations(options): Promise<RunMigrationsResult>.
  • RunMigrationsOptions — { plugin, capabilities, admin, migrationsDir, fs }.
  • RunMigrationsResult — { applied: string[]; skipped: string[] }.
  • MigrationsFsReader — { readdir(dir); readFile(path) }.
  • getMigrationInstruments() / resetMigrationInstruments().

Implementation Notes

The runner intentionally does not parse SQL inside the migration body — that is the engine's job. Failed migrations rely on transaction for atomic rollback; on engines without transactions, partial state is possible if the body succeeds but the INSERT into __plugin_migrations fails. That is out of scope for the runner and the responsibility of operator runbooks (Surrealdb mostly). The applied_at column is an ISO-8601 timestamp, matching the MigrationCursor.appliedAt shape on @suaveplan/api-types/db.