Skip to main content

@kysera/migrations

Lightweight, type-safe database migration system.

Installation

npm install @kysera/migrations kysely zod

Overview

Dependencies: @kysera/core Peer Dependencies: kysely >=0.29.0 and zod ^4.3.6 — both required

The Zod option schemas (MigrationRunnerOptionsSchema, MigrationDefinitionSchema, MigrationPluginOptionsSchema, MigrationPluginSchema, MigrationStatusSchema, MigrationResultSchema, MigrationRunnerWithPluginsOptionsSchema) are exported from the package root together with their Input/Output types and the parseMigrationRunnerOptions / safeParseMigrationRunnerOptions / parseMigrationDefinition / safeParseMigrationDefinition helpers. The same schemas are also available via the @kysera/migrations/schemas subpath export for tooling that validates migration configuration.

Creating Migrations

createMigration

Create a simple migration.

function createMigration(
name: string,
up: (db: Kysely<any>) => Promise<void>,
down?: (db: Kysely<any>) => Promise<void>
): Migration

interface Migration {
name: string
up: (db: Kysely<any>) => Promise<void>
down?: (db: Kysely<any>) => Promise<void>
}

Example

import { createMigration } from '@kysera/migrations'
import { sql } from 'kysely'

const migrations = [
createMigration(
'001_create_users',
async db => {
await db.schema
.createTable('users')
.addColumn('id', 'serial', col => col.primaryKey())
.addColumn('email', 'varchar(255)', col => col.notNull().unique())
.addColumn('name', 'varchar(100)', col => col.notNull())
.addColumn('created_at', 'timestamp', col =>
col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`)
)
.execute()
},
async db => {
await db.schema.dropTable('users').execute()
}
)
]

createMigrationWithMeta

Create a migration with metadata.

function createMigrationWithMeta(
name: string,
options: {
up: (db: Kysely<any>) => Promise<void>
down?: (db: Kysely<any>) => Promise<void>
description?: string
breaking?: boolean
estimatedDuration?: number
tags?: string[]
}
): MigrationWithMeta

defineMigrations

Define multiple migrations concisely.

function defineMigrations(definitions: MigrationDefinitions): MigrationWithMeta[]

type MigrationDefinitions = Record<string, MigrationDefinition>

Example

const migrations = defineMigrations({
'001_create_users': {
description: 'Create users table',
up: async (db) => {
await db.schema.createTable('users')./* ... */.execute()
},
down: async (db) => {
await db.schema.dropTable('users').execute()
}
},
'002_add_posts': {
description: 'Create posts table',
breaking: false,
up: async (db) => { /* ... */ },
down: async (db) => { /* ... */ }
}
})

Setup

setupMigrations

Create the migrations bookkeeping table (name primary key + executed_at). Idempotent — safe to run multiple times. The migration runner calls it automatically before its first database access, so calling it yourself is only needed for custom tooling.

async function setupMigrations(db: Kysely<unknown>): Promise<void>

Migration Runner

createMigrationRunner

Create a migration runner.

function createMigrationRunner(
db: Kysely<any>,
migrations: Migration[],
options?: MigrationRunnerOptions
): MigrationRunner

interface MigrationRunnerOptions {
dryRun?: boolean
logger?: KyseraLogger
useTransactions?: boolean
stopOnError?: boolean
verbose?: boolean
/** Serialize concurrent runners via a database advisory lock (default: true) */
advisoryLock?: boolean
/** How long to wait for the advisory lock before failing (default: 60000) */
lockTimeoutMs?: number
}

With advisoryLock enabled (the default), concurrent up() runs from several application instances are serialized through a database advisory lock; a runner that cannot acquire the lock within lockTimeoutMs throws MigrationLockError. The lock is skipped for dry runs.

Advisory locking is implemented for PostgreSQL (pg_try_advisory_lock), MySQL (GET_LOCK), and MSSQL (sp_getapplock with @LockOwner = 'Session', so the lock survives across the runner's individual statements and is released with sp_releaseapplock). Acquire and release are pinned to a single pooled connection on every dialect — advisory locks are session-scoped. On SQLite the option is a no-op (single-writer by design).

MSSQL maps sp_getapplock result codes as follows: 0/1 (granted, granted after waiting) succeed; -1 (timeout after lockTimeoutMs) throws MigrationLockError; -2 (canceled), -3 (deadlock victim), and -999 (invalid call) throw DatabaseError naming the code.

Runner Methods

class MigrationRunner {
// Run all pending migrations
async up(): Promise<MigrationResult>

// Rollback migrations (default: 1 step)
async down(steps = 1): Promise<MigrationResult>

// Get status
async status(): Promise<MigrationStatus>

// Reset all migrations
async reset(): Promise<MigrationResult>

// Run up to specific migration
async upTo(targetName: string): Promise<MigrationResult>

// Get executed migrations
async getExecutedMigrations(): Promise<string[]>

// Manual marking
async markAsExecuted(name: string): Promise<void>
async markAsRolledBack(name: string): Promise<void>
}

MigrationResult

interface MigrationResult {
executed: string[]
skipped: string[]
failed: string[]
duration: number
dryRun: boolean
}

MigrationStatus

interface MigrationStatus {
executed: string[]
pending: string[]
total: number
}

Example

import { createMigrationRunner } from '@kysera/migrations'

const runner = createMigrationRunner(db, migrations, {
verbose: true
})

// Run all pending
const result = await runner.up()
console.log(`Executed: ${result.executed.join(', ')}`)

// Check status
const status = await runner.status()
console.log(`Pending: ${status.pending.length}`)

// Rollback last migration
await runner.down(1)

// Reset all
await runner.reset()

One-Liner Functions

runMigrations

async function runMigrations(
db: Kysely<any>,
migrations: Migration[],
options?: MigrationRunnerOptions
): Promise<MigrationResult>

rollbackMigrations

async function rollbackMigrations(
db: Kysely<any>,
migrations: Migration[],
steps?: number,
options?: MigrationRunnerOptions
): Promise<MigrationResult>

getMigrationStatus

async function getMigrationStatus(
db: Kysely<any>,
migrations: Migration[],
options?: Pick<MigrationRunnerOptions, 'logger' | 'verbose'>
): Promise<MigrationStatus>

Example

// Quick usage
await runMigrations(db, migrations)
await rollbackMigrations(db, migrations, 1)
const status = await getMigrationStatus(db, migrations)

Dry Run

Preview migrations without executing:

const result = await runMigrations(db, migrations, { dryRun: true })
console.log('Would execute:', result.executed)

Plugin System

MigrationPlugin

interface MigrationPlugin {
name: string
version: string
onInit?(runner: MigrationRunner): Promise<void> | void
beforeMigration?(migration: Migration, operation: 'up' | 'down'): Promise<void> | void
afterMigration?(
migration: Migration,
operation: 'up' | 'down',
duration: number
): Promise<void> | void
onMigrationError?(
migration: Migration,
operation: 'up' | 'down',
error: unknown
): Promise<void> | void
}

Built-in Plugins

// Logging plugin
const loggingPlugin = createLoggingPlugin(logger)

// Metrics plugin
const metricsPlugin = createMetricsPlugin()

createMigrationRunnerWithPlugins

Async factory that awaits each plugin's onInit hook and returns a MigrationRunnerWithPlugins instance.

async function createMigrationRunnerWithPlugins<DB = unknown>(
db: Kysely<DB>,
migrations: Migration<DB>[],
options?: MigrationRunnerWithPluginsOptions<DB>
): Promise<MigrationRunnerWithPlugins<DB>>

interface MigrationRunnerWithPluginsOptions<DB = unknown> extends MigrationRunnerOptions {
/** Plugins to apply */
plugins?: MigrationPlugin<DB>[]
}

MigrationRunnerWithPlugins

Subclass of MigrationRunner that overrides up() and down() to invoke the plugin lifecycle hooks (beforeMigration, afterMigration, onMigrationError) around each migration. All other runner methods are inherited unchanged.

class MigrationRunnerWithPlugins<DB = unknown> extends MigrationRunner<DB> {
constructor(db: Kysely<DB>, migrations: Migration<DB>[], options?: MigrationRunnerWithPluginsOptions<DB>)

// Returns a copy of the registered plugin list
getPlugins(): MigrationPlugin<DB>[]
}

Usage

import { createMigrationRunnerWithPlugins, createLoggingPlugin } from '@kysera/migrations'

const runner = await createMigrationRunnerWithPlugins(db, migrations, {
plugins: [createLoggingPlugin()]
})

runner.getPlugins() // => [loggingPlugin]

Error Handling

import { MigrationError, MigrationLockError } from '@kysera/migrations'

try {
await runner.up()
} catch (error) {
if (error instanceof MigrationLockError) {
// Another instance holds the migration advisory lock and did not release
// it within lockTimeoutMs. Increase lockTimeoutMs, or set
// advisoryLock: false to bypass (unsafe with concurrent runners).
console.error(error.message)
} else if (error instanceof MigrationError) {
console.error(`Migration ${error.migrationName} failed:`, error.cause)
console.error(`Operation: ${error.operation}`)
}
}

Error Codes

MigrationError.code uses the unified codes from @kysera/core:

import { MigrationErrorCodes, type MigrationErrorCode } from '@kysera/migrations'

MigrationErrorCodes.UP_FAILED // ErrorCodes.MIGRATION_UP_FAILED
MigrationErrorCodes.DOWN_FAILED // ErrorCodes.MIGRATION_DOWN_FAILED
MigrationErrorCodes.VALIDATION_FAILED // ErrorCodes.MIGRATION_VALIDATION_FAILED

Core Errors

The package root re-exports DatabaseError, NotFoundError, BadRequestError, silentLogger, and the KyseraLogger type from @kysera/core. Besides MigrationError and MigrationLockError, the runner throws:

  • BadRequestError — invalid runner options or duplicate migration names
  • NotFoundErrorupTo(targetName) names a migration that doesn't exist

Best Practices

1. Name Migrations Sequentially

001_create_users.ts
002_create_posts.ts
003_add_email_to_users.ts

2. Always Include Down Migration

createMigration(
'001_create_users',
async db => {
/* up */
},
async db => {
/* down - always include! */
}
)

3. Use Dry Run First

// Preview changes with dry run runner
const dryRunner = createMigrationRunner(db, migrations, { dryRun: true })
await dryRunner.up()

// Then execute with normal runner
const runner = createMigrationRunner(db, migrations)
await runner.up()

4. Test Migrations

it('should migrate up and down', async () => {
await runner.up()
const upStatus = await runner.status()
expect(upStatus.pending).toHaveLength(0)

await runner.down()
const downStatus = await runner.status()
expect(downStatus.pending).toHaveLength(migrations.length)
})