Database migration management for Kysely with dry-run support and flexible rollback capabilities
npm install @kysera/migrations> Lightweight, type-safe database migration management for Kysera with dry-run support, flexible rollback capabilities, and plugin system.



- Simple API - Intuitive migration creation and execution
- Type-safe - Full TypeScript support with Kysely integration
- State tracking - Automatic migration history in database
- Sequential execution - Migrations run in order
- Dry run mode - Preview changes before execution
- Rollback support - Roll back one or multiple migrations
- Partial migration - Run up to specific migration
- Status reporting - View executed and pending migrations
- Error handling - Typed errors with MigrationError class
- Transaction support - Optional transaction wrapping per migration
- Duplicate detection - Validates unique migration names
- defineMigrations() - Object-based migration definition
- runMigrations() - One-liner to run pending migrations
- rollbackMigrations() - One-liner for rollbacks
- Migration metadata - Description, breaking flag, tags, timing
- Plugin hooks - Before/after migration events
- Built-in plugins - Logging and metrics plugins
- Extensible - Create custom plugins for your needs
``bashpnpm (recommended)
pnpm add @kysera/migrations kysely zod
Note: Zod is a required peer dependency (not optional) for schema validation in the migration system.
Quick Start
$3
`typescript
import { Kysely } from 'kysely'
import { createMigrationRunner, createMigration } from '@kysera/migrations'// Define your migrations
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(255)', col => col.notNull())
.execute()
},
async db => {
await db.schema.dropTable('users').execute()
}
),
createMigration(
'002_create_posts',
async db => {
await db.schema
.createTable('posts')
.addColumn('id', 'serial', col => col.primaryKey())
.addColumn('user_id', 'integer', col =>
col.notNull().references('users.id').onDelete('cascade')
)
.addColumn('title', 'varchar(255)', col => col.notNull())
.execute()
},
async db => {
await db.schema.dropTable('posts').execute()
}
)
]
// Create migration runner
const db = new Kysely({
/ ... /
})
const runner = createMigrationRunner(db, migrations)
// Run all pending migrations
await runner.up()
// Check status
await runner.status()
// Rollback last migration
await runner.down(1)
`$3
`typescript
import { Kysely } from 'kysely'
import { defineMigrations, runMigrations } from '@kysera/migrations'// Define migrations with object syntax
const migrations = defineMigrations({
'001_create_users': {
description: 'Create users table with email and name',
up: async db => {
await db.schema
.createTable('users')
.addColumn('id', 'serial', col => col.primaryKey())
.addColumn('email', 'varchar(255)', col => col.notNull().unique())
.execute()
},
down: async db => {
await db.schema.dropTable('users').execute()
}
},
'002_create_posts': {
description: 'Create posts table',
breaking: false,
tags: ['schema'],
up: async db => {
await db.schema
.createTable('posts')
.addColumn('id', 'serial', col => col.primaryKey())
.addColumn('title', 'varchar(255)', col => col.notNull())
.execute()
}
}
})
// One-liner to run all migrations
await runMigrations(db, migrations)
`$3
`typescript
import { runMigrations } from '@kysera/migrations'// Preview what would happen without making changes
const result = await runMigrations(db, migrations, { dryRun: true })
console.log('Would execute:', result.executed)
console.log('Would skip:', result.skipped)
// No actual changes made to database
`API Reference
$3
####
Migration`typescript
interface Migration {
name: string
up: (db: Kysely) => Promise
down?: (db: Kysely) => Promise
}
`####
MigrationWithMeta`typescript
interface MigrationWithMeta extends Migration {
description?: string // Shown in logs
breaking?: boolean // Shows warning
estimatedDuration?: number // In milliseconds
tags?: string[] // For categorization
}
`####
MigrationStatus`typescript
interface MigrationStatus {
executed: string[]
pending: string[]
total: number
}
`####
MigrationResult`typescript
interface MigrationResult {
executed: string[] // Successfully executed
skipped: string[] // Already executed or no down()
failed: string[] // Failed migrations
duration: number // Total time in ms
dryRun: boolean // Whether dry run mode
}
`####
MigrationRunnerOptions`typescript
interface MigrationRunnerOptions {
dryRun?: boolean // Preview only (default: false)
logger?: KyseraLogger // Logger instance from @kysera/core (default: silentLogger)
useTransactions?: boolean // Wrap in transactions (default: false)
stopOnError?: boolean // Stop on first error (default: true)
verbose?: boolean // Show metadata (default: true)
}
`####
MigrationDefinition`typescript
interface MigrationDefinition {
up: (db: Kysely) => Promise
down?: (db: Kysely) => Promise
description?: string
breaking?: boolean
estimatedDuration?: number
tags?: string[]
}
`####
MigrationDefinitions`typescript
type MigrationDefinitions = Record
`####
MigrationRunnerWithPluginsOptions`typescript
interface MigrationRunnerWithPluginsOptions extends MigrationRunnerOptions {
plugins?: MigrationPlugin[]
}
`####
MigrationErrorCode`typescript
type MigrationErrorCode =
| 'MIGRATION_UP_FAILED'
| 'MIGRATION_DOWN_FAILED'
| 'MIGRATION_VALIDATION_FAILED'
`$3
####
createMigration(name, up, down?)Create a simple migration:
`typescript
const migration = createMigration(
'001_create_users',
async db => {
/ up /
},
async db => {
/ down /
}
)
`####
createMigrationWithMeta(name, options)Create a migration with metadata:
`typescript
const migration = createMigrationWithMeta('001_create_users', {
description: 'Create users table',
breaking: true,
tags: ['schema', 'users'],
estimatedDuration: 5000,
up: async db => {
/ ... /
},
down: async db => {
/ ... /
}
})
`####
createMigrationRunner(db, migrations, options?)Create a MigrationRunner instance:
`typescript
const runner = createMigrationRunner(db, migrations, {
dryRun: false,
logger: console.log,
useTransactions: true
})
`####
createMigrationRunnerWithPlugins(db, migrations, options?)Create a MigrationRunner with plugin support (async factory):
`typescript
const runner = await createMigrationRunnerWithPlugins(db, migrations, {
plugins: [createLoggingPlugin(), createMetricsPlugin()],
useTransactions: true
})// Runner is ready with plugins initialized via onInit
await runner.up()
`$3
####
defineMigrations(definitions)Define migrations using object syntax:
`typescript
const migrations = defineMigrations({
'001_users': {
description: 'Create users',
up: async db => {
/ ... /
},
down: async db => {
/ ... /
}
}
})
`####
runMigrations(db, migrations, options?)Run all pending migrations:
`typescript
const result = await runMigrations(db, migrations)
const result = await runMigrations(db, migrations, { dryRun: true })
`####
rollbackMigrations(db, migrations, steps?, options?)Rollback migrations:
`typescript
await rollbackMigrations(db, migrations) // Last 1
await rollbackMigrations(db, migrations, 3) // Last 3
await rollbackMigrations(db, migrations, 1, { dryRun: true })
`####
getMigrationStatus(db, migrations, options?)Get migration status:
`typescript
const status = await getMigrationStatus(db, migrations)
console.log(Executed: ${status.executed.length})
console.log(Pending: ${status.pending.length})
`$3
####
up(): PromiseRun all pending migrations:
`typescript
const result = await runner.up()
console.log(Executed: ${result.executed.length} migrations)
`####
down(steps?): PromiseRollback last N migrations:
`typescript
await runner.down(1) // Rollback last one
await runner.down(3) // Rollback last three
`####
status(): PromiseGet migration status:
`typescript
const status = await runner.status()
// Logs status to console and returns object
`####
reset(): PromiseRollback all migrations:
`typescript
await runner.reset() // Dangerous! Rolls back everything
`####
upTo(targetName): PromiseRun migrations up to a specific one:
`typescript
await runner.upTo('002_create_posts')
// Runs 001 and 002, stops before 003
`####
getExecutedMigrations(): PromiseGet list of executed migrations:
`typescript
const executed = await runner.getExecutedMigrations()
`####
markAsExecuted(name): PromiseManually mark a migration as executed:
`typescript
await runner.markAsExecuted('001_create_users')
`####
markAsRolledBack(name): PromiseManually mark a migration as rolled back:
`typescript
await runner.markAsRolledBack('001_create_users')
`$3
####
setupMigrations(db)Manually create the migrations tracking table:
`typescript
import { setupMigrations } from '@kysera/migrations'await setupMigrations(db)
// Creates migrations table if not exists
`Plugin System (v0.5.0+)
$3
Consistent with
@kysera/repository Plugin interface:`typescript
interface MigrationPlugin {
name: string
version: string
// Called once when runner is initialized (consistent with repository Plugin.onInit)
onInit?(runner: MigrationRunner): Promise | void
beforeMigration?(migration: Migration, operation: 'up' | 'down'): Promise | void
afterMigration?(
migration: Migration,
operation: 'up' | 'down',
duration: number
): Promise | void
// Unknown error type for consistency with repository Plugin.onError
onMigrationError?(
migration: Migration,
operation: 'up' | 'down',
error: unknown
): Promise | void
}
`$3
#### Logging Plugin
`typescript
import { createLoggingPlugin } from '@kysera/migrations'const loggingPlugin = createLoggingPlugin(console.log)
// or with custom logger
const loggingPlugin = createLoggingPlugin(msg => logger.info(msg))
`#### Metrics Plugin
`typescript
import { createMetricsPlugin } from '@kysera/migrations'const metricsPlugin = createMetricsPlugin()
// After running migrations
const metrics = metricsPlugin.getMetrics()
console.log(metrics.migrations)
// [{ name: '001_users', operation: 'up', duration: 45, success: true }, ...]
`$3
`typescript
const notificationPlugin: MigrationPlugin = {
name: 'notification-plugin',
version: '1.0.0', // Called when runner is created via createMigrationRunnerWithPlugins()
async onInit(runner) {
console.log('Notification plugin initialized')
},
async beforeMigration(migration, operation) {
await slack.send(
Starting ${operation} for ${migration.name})
}, async afterMigration(migration, operation, duration) {
await slack.send(
Completed ${migration.name} in ${duration}ms)
}, async onMigrationError(migration, operation, error) {
// Error is unknown type - handle appropriately
const message = error instanceof Error ? error.message : String(error)
await pagerduty.alert(
Migration failed: ${message})
}
}
`Error Handling
$3
Extends
DatabaseError from @kysera/core for consistency:`typescript
import { MigrationError } from '@kysera/migrations'try {
await runner.up()
} catch (error) {
if (error instanceof MigrationError) {
console.log('Migration:', error.migrationName)
console.log('Operation:', error.operation) // 'up' or 'down'
console.log('Code:', error.code) // 'MIGRATION_UP_FAILED' or 'MIGRATION_DOWN_FAILED'
console.log('Cause:', error.cause?.message)
// Serialize for logging
console.log(error.toJSON())
// { name, message, code, detail, migrationName, operation, cause }
}
}
`$3
For validation errors (e.g., duplicate migration names):
`typescript
import { BadRequestError } from '@kysera/migrations'try {
createMigrationRunner(db, [
createMigration('001_users', ...),
createMigration('001_users', ...), // Duplicate!
])
} catch (error) {
if (error instanceof BadRequestError) {
console.log(error.message) // "Duplicate migration name: 001_users"
console.log(error.code) // "BAD_REQUEST"
}
}
`$3
Uses
NotFoundError from @kysera/core:`typescript
import { NotFoundError } from '@kysera/migrations'try {
await runner.upTo('nonexistent_migration')
} catch (error) {
if (error instanceof NotFoundError) {
console.log(error.message) // "Migration not found"
}
}
`Best Practices
$3
`typescript
// Good - clear ordering
'001_create_users'
'002_create_posts'
'003_add_indexes'// Bad - no guaranteed order
'create_users'
'create_posts'
`$3
`typescript
createMigration(
'001_create_users',
async db => {
/ up /
},
async db => {
/ down - always provide this! /
}
)
`$3
`typescript
createMigrationWithMeta('005_big_refactor', {
description: 'Refactors user permissions system',
breaking: true, // Will show warning
tags: ['breaking', 'permissions'],
up: async db => {
/ ... /
}
})
`$3
`typescript
// Preview in production
await runMigrations(db, migrations, { dryRun: true })// Then run for real
await runMigrations(db, migrations)
`$3
`typescript
const runner = createMigrationRunner(db, migrations, {
useTransactions: true // Each migration wrapped in transaction
})
`Migration Script Example
`typescript
// scripts/migrate.ts
import { Kysely, PostgresDialect } from 'kysely'
import { Pool } from 'pg'
import {
runMigrations,
rollbackMigrations,
getMigrationStatus,
defineMigrations
} from '@kysera/migrations'const migrations = defineMigrations({
// ... your migrations
})
async function main() {
const db = new Kysely({
dialect: new PostgresDialect({
pool: new Pool({ connectionString: process.env.DATABASE_URL })
})
})
const command = process.argv[2]
try {
switch (command) {
case 'up':
console.log('Running migrations...')
const upResult = await runMigrations(db, migrations)
console.log(
Executed: ${upResult.executed.length} migrations)
break case 'down':
const steps = parseInt(process.argv[3] || '1')
console.log(
Rolling back ${steps} migration(s)...)
await rollbackMigrations(db, migrations, steps)
break case 'status':
await getMigrationStatus(db, migrations)
break
case 'dry-run':
console.log('Dry run mode...')
await runMigrations(db, migrations, { dryRun: true })
break
default:
console.log('Usage: pnpm migrate [up|down|status|dry-run] [steps]')
}
} finally {
await db.destroy()
}
}
main()
``json
// package.json
{
"scripts": {
"migrate": "tsx scripts/migrate.ts up",
"migrate:down": "tsx scripts/migrate.ts down",
"migrate:status": "tsx scripts/migrate.ts status",
"migrate:dry-run": "tsx scripts/migrate.ts dry-run"
}
}
`Multi-Database Support
$3
`typescript
createMigration('001_create_users', async db => {
await db.schema
.createTable('users')
.addColumn('id', 'serial', col => col.primaryKey())
.addColumn('created_at', 'timestamp', col => col.notNull().defaultTo(db.fn('now')))
.execute()
})
`$3
`typescript
createMigration('001_create_users', async db => {
await db.schema
.createTable('users')
.addColumn('id', 'integer', col => col.primaryKey().autoIncrement())
.addColumn('created_at', 'datetime', col => col.notNull().defaultTo(db.fn('now')))
.execute()
})
`$3
`typescript
createMigration('001_create_users', async db => {
await db.schema
.createTable('users')
.addColumn('id', 'integer', col => col.primaryKey().autoIncrement())
.addColumn('created_at', 'text', col => col.notNull().defaultTo(sqlCURRENT_TIMESTAMP))
.execute()
})
`Implementation Details
$3
The migration system uses a robust version file fallback pattern to ensure compatibility across different build environments:
1. Primary: Try to load
version.ts (development/source code)
2. Fallback: Try to load version.js (compiled/production code)
3. Default: Use '0.0.0-dev' if both failThis pattern ensures migrations work correctly in both development and production environments without requiring specific build configurations.
Changelog
$3
- Breaking
MigrationError now extends DatabaseError from @kysera/core with code property
- Breaking onMigrationError hook now receives error: unknown (consistent with repository Plugin)
- Breaking createMigrationRunnerWithPlugins() is now async (returns Promise)
- Added onInit hook to MigrationPlugin interface (consistent with repository Plugin)
- Added MigrationErrorCode type export
- Added MigrationDefinition and MigrationDefinitions type exports
- Added MigrationRunnerWithPluginsOptions interface export
- Added DatabaseError and BadRequestError re-exports from @kysera/core
- Added Version file fallback pattern for cross-environment compatibility
- Changed Validation errors now throw BadRequestError instead of generic Error$3
- Added
@kysera/core integration with typed errors
- Added MigrationWithMeta support with description, breaking flag, tags
- Added defineMigrations() for object-based syntax
- Added runMigrations(), rollbackMigrations(), getMigrationStatus() one-liners
- Added MigrationResult return type for all operations
- Added Plugin system with MigrationPlugin interface
- Added Built-in createLoggingPlugin() and createMetricsPlugin()
- Added MigrationError class for better error handling
- Added Duplicate migration name validation
- Added useTransactions option for transaction wrapping
- Added stopOnError option for error handling control
- Fixed Inconsistent dry run behavior in reset() and upTo()
- Fixed MigrationStatus now includes total count$3
- Initial release
Related Packages
@kysera/core - Core utilities and error handling
- @kysera/repository - Repository pattern implementation
- @kysera/audit - Audit logging plugin
- @kysera/soft-delete - Soft delete plugin
- @kysera/timestamps` - Automatic timestamp managementMIT