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_userscreate_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);