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.sqlbefore002_*.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; flatexecute + executefall-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 wiresnode:fs/promises. - On any failure:
DB_ADAPTER.MIGRATION_FAILEDwith 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.