migrate
js/database/migrate.ts
fino:database/migrate — reversible database migrations.
This module applies ordered up migrations, rolls back explicit down
migrations, and records migration history through the generic
fino:database connection interface, so the same migration set runs against
SQLite and Postgres alike. SQL directive parsing and callable SQL function
generation live in fino:database/sql; this module uses that engine for
.sql migration files and re-exports it for compatibility.
Migrations are ordered by lexicographic id, so the usual convention is a
sortable prefix such as 001_create_users or an ISO timestamp. History is
append-only: every up and down run inserts a row into the history table
(fino_migrations unless tableName is provided), and the set of currently
active migrations is derived by replaying that log. Each migration's up
SQL is checksummed with SHA-256; if an already-applied migration's text
changes, planning fails with a MigrationError before any later migration
runs.
By default each migration executes inside db.transaction(), so a failing
statement leaves behind neither partial schema changes nor a history row.
Pass transaction: false for statements that cannot run inside a
transaction (for example CREATE INDEX CONCURRENTLY on Postgres).
import { Database } from 'fino:database';
import { loadMigrations, migrate, rollback } from 'fino:database/migrate';
await using db = await Database.open('./app.db');
const migrations = await loadMigrations('./migrations/*.sql');
await migrate(db, migrations); // apply everything not yet applied
await rollback(db, migrations); // undo the most recent migration
Classes
class MigrationError extends Error {
Error thrown for migration planning, checksum, history, and execution failures.
Raised for duplicate migration ids, checksum drift in an already-applied
migration, invalid history table names, rollbacks of migrations that are
missing from the provided set or define no down body, and migration
modules that do not export up. Database errors raised while executing
migration SQL propagate as-is and are not wrapped.
import { MigrationError, migrate } from 'fino:database/migrate';
try {
await migrate(db, migrations);
} catch (err) {
if (err instanceof MigrationError) console.error('migration rejected:', err.message);
else throw err;
}
Constructors
constructor(message: string)
Interfaces
interface Migration {
A reversible database migration.
Migrations are identified and ordered by id, so ids should sort in the
order migrations must run. up and down may be SQL strings, arrays of SQL
statements, or functions that return SQL (see MigrationBody). down is
required only when a rollback includes the migration.
import { defineMigration } from 'fino:database/migrate';
const createUsers = defineMigration({
id: '001_create_users',
up: 'CREATE TABLE users (id TEXT PRIMARY KEY, name TEXT NOT NULL)',
down: 'DROP TABLE users'
});
Properties
id: string
Unique identifier; migrations run in lexicographic id order.
name?: string
Human-readable name recorded in history. Defaults to the portion of id
after the first underscore (001_create_users → create_users).
up: MigrationBody
SQL applied when the migration runs forward.
down?: MigrationBody
SQL that reverts up. Required only when a rollback reaches this
migration.
checksum?: string
Explicit checksum recorded in history and compared on later runs. When
omitted, the SHA-256 hex digest of the resolved up SQL is used —
resolving a function body invokes it, so functional migrations with side
effects should set this explicitly.
interface MigrationContext {
Function context passed to executable migration bodies.
When up or down is a function, it receives this context: the live
connection, the migration's own definition, and the sql template tag from
fino:database for building portable fragments.
import { defineMigration } from 'fino:database/migrate';
const seedAdmin = defineMigration({
id: '002_seed_admin',
checksum: 'seed-admin-v1',
up: async ({ db }) => {
await db.prepare('INSERT INTO users VALUES (?, ?)').run('1', 'admin');
},
down: `DELETE FROM users WHERE id = '1'`
});
Properties
db: DatabaseConnection
The connection the migration is executing against.
migration: Migration
The migration definition being executed.
sql: typeof sql
The sql template tag from fino:database.
interface MigrationRecord {
One row of migration history.
The history table is append-only: applying a migration inserts an 'up' row
and rolling it back inserts a 'down' row. Functions such as
getAppliedMigrations() collapse this log into the set of currently active
migrations.
import { getAppliedMigrations } from 'fino:database/migrate';
for (const record of await getAppliedMigrations(db)) {
console.log(record.id, new Date(record.appliedAt), `${record.durationMs}ms`);
}
Properties
id: string
Id of the migration this row records.
name: string
Migration name at the time it was recorded.
checksum: string
Checksum of the migration's up SQL when it was applied. Rollback rows
repeat the checksum of the run they revert.
direction: 'up' | 'down'
Whether this row records an apply ('up') or a rollback ('down').
appliedAt: number
Completion time in milliseconds since the Unix epoch.
durationMs: number
Execution time in milliseconds.
interface MigrationPlan {
Planned migration state returned by planMigrations().
import { loadMigrations, planMigrations } from 'fino:database/migrate';
const plan = await planMigrations(db, await loadMigrations('./migrations/*.sql'));
console.log(`${plan.applied.length} applied, ${plan.pending.length} pending`);
Properties
applied: MigrationRecord[]
Currently active migrations, one record per applied migration.
pending: Migration[]
Migrations not yet applied, in the order migrate() would run them.
interface LoadMigrationsOptions {
Options for loadMigrations().
import { loadMigrations } from 'fino:database/migrate';
const migrations = await loadMigrations('*.sql', { cwd: '/srv/app/migrations' });
Properties
cwd?: string
Base directory for glob patterns and relative paths.
fs?: FileSystem
Filesystem used to expand globs and read .sql files; defaults to a
DiskFileSystem. TypeScript migration modules are loaded with import()
regardless of this option.
interface MigrateOptions {
Options shared by migration planning and execution.
import { migrate } from 'fino:database/migrate';
await migrate(db, migrations, { tableName: 'schema_history', transaction: false });
Properties
tableName?: string
History table name; defaults to fino_migrations. Must be a plain SQL
identifier ([A-Za-z_][A-Za-z0-9_]*) or a MigrationError is thrown.
transaction?: boolean
Whether each migration runs inside db.transaction(). Defaults to true;
set to false for statements that cannot run in a transaction, at the
cost of atomicity when a statement fails.
interface RollbackOptions extends MigrateOptions {
Options for rollback execution.
import { rollback } from 'fino:database/migrate';
await rollback(db, migrations, { steps: 2 });
Properties
steps?: number
How many active migrations to revert, newest first. Defaults to 1.
Types
type MigrationBody = string | string[] | (
(
ctx: MigrationContext
) => void | string | string[] | Promise<void | string | string[]>
)
SQL body accepted by migration execution.
A body is a single SQL string, an array of statements executed in order (one
db.exec() per entry; blank entries are skipped), or a function receiving a
MigrationContext. Functions may return SQL to execute, or perform their
work directly through ctx.db and return nothing.
When the migration has no explicit checksum, its up body is resolved to
text for checksumming — which invokes function bodies — so functions should
either be pure SQL generators or be paired with an explicit checksum.
Functions
function defineMigration(migration: Migration): Migration
Return migration unchanged while preserving the public Migration type.
An identity helper for writing migrations inline or as TypeScript modules: it adds type checking and editor completion without altering the value.
import { defineMigration } from 'fino:database/migrate';
export default defineMigration({
id: '003_add_email',
up: 'ALTER TABLE users ADD COLUMN email TEXT',
down: 'ALTER TABLE users DROP COLUMN email'
});
async function getAppliedMigrations(
db: DatabaseConnection,
options: MigrateOptions = {
}
): Promise<MigrationRecord[]>
Read currently applied migrations from the history table.
Replays the append-only history log, so migrations that were later rolled back do not appear. A missing history table is treated as an empty migration history, making this safe to call against a fresh database.
import { getAppliedMigrations } from 'fino:database/migrate';
const applied = await getAppliedMigrations(db);
console.log(applied.map((record) => record.id));
async function planMigrations(
db: DatabaseConnection,
migrations: Migration[],
options: MigrateOptions = {
}
): Promise<MigrationPlan>
Compare migrations with database history and return applied and pending sets.
Migrations are de-duplicated and sorted by id before comparison; duplicate
ids throw a MigrationError. For each migration that is already active, the
current checksum of its up body is compared against the recorded one, and
a mismatch throws a MigrationError naming the drifted migration.
This is a dry run: nothing is executed and no history is written, so it
suits a migrate --dry-run style status command.
import { loadMigrations, planMigrations } from 'fino:database/migrate';
const plan = await planMigrations(db, await loadMigrations('./migrations/*.sql'));
for (const migration of plan.pending) console.log('would apply', migration.id);
async function migrate(
db: DatabaseConnection,
migrations: Migration[],
options: MigrateOptions = {
}
): Promise<MigrationRecord[]>
Apply all pending up migrations.
Creates the history table if needed, plans against current history — which
rejects checksum drift before anything runs — then applies pending
migrations in id order. Each migration executes inside db.transaction()
unless transaction is false, so a failing statement rolls back both the
migration's changes and its history row. Already-applied migrations are
skipped, making repeated calls idempotent.
Returns records for the migrations applied by this call; an up-to-date database yields an empty array.
import { Database } from 'fino:database';
import { loadMigrations, migrate } from 'fino:database/migrate';
await using db = await Database.open(process.env.DATABASE_URL ?? ':memory:');
const applied = await migrate(db, await loadMigrations('./migrations/*.sql'));
for (const record of applied) console.log(`applied ${record.id} in ${record.durationMs}ms`);
async function rollback(
db: DatabaseConnection,
migrations: Migration[],
options: RollbackOptions = {
}
): Promise<MigrationRecord[]>
Roll back applied migrations by executing their down bodies.
Active migrations are reverted newest first (descending id); steps
controls how many and defaults to 1. Every reverted migration must be
present in migrations and define down, otherwise a MigrationError is
thrown. Each rollback runs inside db.transaction() unless transaction is
false, and appends a 'down' history row rather than deleting the
original record. Returns records for the reverted migrations.
import { loadMigrations, rollback } from 'fino:database/migrate';
const migrations = await loadMigrations('./migrations/*.sql');
await rollback(db, migrations); // undo the most recent migration
await rollback(db, migrations, { steps: 2 }); // undo the next two
async function loadMigrations(
pattern: string | string[],
options: LoadMigrationsOptions = {
}
): Promise<Migration[]>
Load migrations from SQL files, TypeScript migration modules, or globs.
Each pattern is either a literal path or a glob (any of *, ?, {, [
triggers glob expansion through the filesystem's glob()); cwd anchors
both forms. Matched paths are sorted, and each migration's id is derived
from the file basename with .sql, .ts, or .sql.ts stripped, so
filenames like 001_create_users.sql produce ordered ids.
.sql files may be plain SQL — the whole file becomes the up body — or
directive SQL (see fino:database/sql) with an exported up() function and
optional down(). Other files are imported as modules and must export up;
exported down, id, and name are used when present. Throws a
MigrationError for modules without a usable up export or for duplicate
ids, and a MigrationParseError for directive SQL that does not export
up().
import { loadMigrations, migrate } from 'fino:database/migrate';
// migrations/001_users.sql:
// -- function up()
// CREATE TABLE users (id TEXT PRIMARY KEY, name TEXT NOT NULL);
//
// -- function down()
// DROP TABLE users;
const migrations = await loadMigrations('./migrations/*.sql');
await migrate(db, migrations);