@kysera/migrations
v0.8.7
Published
Database migration management for Kysely with dry-run support and flexible rollback capabilities
Maintainers
Readme
@kysera/migrations
Lightweight, type-safe database migration management for Kysera with dry-run support, flexible rollback capabilities, and plugin system.
Features
Core Migration Management
- 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
Advanced Features
- 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
MigrationErrorclass - Transaction support - Optional transaction wrapping per migration
- Duplicate detection - Validates unique migration names
Developer Experience (v0.5.0+)
defineMigrations()- Object-based migration definitionrunMigrations()- One-liner to run pending migrationsrollbackMigrations()- One-liner for rollbacks- Migration metadata - Description, breaking flag, tags, timing
Plugin System (v0.5.0+)
- Plugin hooks - Before/after migration events
- Built-in plugins - Logging and metrics plugins
- Extensible - Create custom plugins for your needs
Installation
# pnpm (recommended)
pnpm add @kysera/migrations kysely zod
# npm
npm install @kysera/migrations kysely zod
# yarn
yarn add @kysera/migrations kysely zod
# bun
bun add @kysera/migrations kysely zodNote: Zod is a required peer dependency (not optional) for schema validation in the migration system.
Quick Start
Basic Usage
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<Database>({
/* ... */
})
const runner = createMigrationRunner(db, migrations)
// Run all pending migrations
await runner.up()
// Check status
await runner.status()
// Rollback last migration
await runner.down(1)Minimalist API (v0.5.0+)
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)Dry Run Mode
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 databaseAPI Reference
Types
Migration
interface Migration {
name: string
up: (db: Kysely<any>) => Promise<void>
down?: (db: Kysely<any>) => Promise<void>
}MigrationWithMeta
interface MigrationWithMeta extends Migration {
description?: string // Shown in logs
breaking?: boolean // Shows warning
estimatedDuration?: number // In milliseconds
tags?: string[] // For categorization
}MigrationStatus
interface MigrationStatus {
executed: string[]
pending: string[]
total: number
}MigrationResult
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
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
interface MigrationDefinition {
up: (db: Kysely<any>) => Promise<void>
down?: (db: Kysely<any>) => Promise<void>
description?: string
breaking?: boolean
estimatedDuration?: number
tags?: string[]
}MigrationDefinitions
type MigrationDefinitions = Record<string, MigrationDefinition>MigrationRunnerWithPluginsOptions
interface MigrationRunnerWithPluginsOptions extends MigrationRunnerOptions {
plugins?: MigrationPlugin[]
}MigrationErrorCode
type MigrationErrorCode =
| 'MIGRATION_UP_FAILED'
| 'MIGRATION_DOWN_FAILED'
| 'MIGRATION_VALIDATION_FAILED'Factory Functions
createMigration(name, up, down?)
Create a simple migration:
const migration = createMigration(
'001_create_users',
async db => {
/* up */
},
async db => {
/* down */
}
)createMigrationWithMeta(name, options)
Create a migration with metadata:
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:
const runner = createMigrationRunner(db, migrations, {
dryRun: false,
logger: console.log,
useTransactions: true
})createMigrationRunnerWithPlugins(db, migrations, options?)
Create a MigrationRunner with plugin support (async factory):
const runner = await createMigrationRunnerWithPlugins(db, migrations, {
plugins: [createLoggingPlugin(), createMetricsPlugin()],
useTransactions: true
})
// Runner is ready with plugins initialized via onInit
await runner.up()One-Liner Functions (v0.5.0+)
defineMigrations(definitions)
Define migrations using object syntax:
const migrations = defineMigrations({
'001_users': {
description: 'Create users',
up: async db => {
/* ... */
},
down: async db => {
/* ... */
}
}
})runMigrations(db, migrations, options?)
Run all pending migrations:
const result = await runMigrations(db, migrations)
const result = await runMigrations(db, migrations, { dryRun: true })rollbackMigrations(db, migrations, steps?, options?)
Rollback migrations:
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:
const status = await getMigrationStatus(db, migrations)
console.log(`Executed: ${status.executed.length}`)
console.log(`Pending: ${status.pending.length}`)MigrationRunner Methods
up(): Promise<MigrationResult>
Run all pending migrations:
const result = await runner.up()
console.log(`Executed: ${result.executed.length} migrations`)down(steps?): Promise<MigrationResult>
Rollback last N migrations:
await runner.down(1) // Rollback last one
await runner.down(3) // Rollback last threestatus(): Promise<MigrationStatus>
Get migration status:
const status = await runner.status()
// Logs status to console and returns objectreset(): Promise<MigrationResult>
Rollback all migrations:
await runner.reset() // Dangerous! Rolls back everythingupTo(targetName): Promise<MigrationResult>
Run migrations up to a specific one:
await runner.upTo('002_create_posts')
// Runs 001 and 002, stops before 003getExecutedMigrations(): Promise<string[]>
Get list of executed migrations:
const executed = await runner.getExecutedMigrations()markAsExecuted(name): Promise<void>
Manually mark a migration as executed:
await runner.markAsExecuted('001_create_users')markAsRolledBack(name): Promise<void>
Manually mark a migration as rolled back:
await runner.markAsRolledBack('001_create_users')Standalone Functions
setupMigrations(db)
Manually create the migrations tracking table:
import { setupMigrations } from '@kysera/migrations'
await setupMigrations(db)
// Creates migrations table if not existsPlugin System (v0.5.0+)
Plugin Interface
Consistent with @kysera/repository Plugin interface:
interface MigrationPlugin {
name: string
version: string
// Called once when runner is initialized (consistent with repository Plugin.onInit)
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
// Unknown error type for consistency with repository Plugin.onError
onMigrationError?(
migration: Migration,
operation: 'up' | 'down',
error: unknown
): Promise<void> | void
}Built-in Plugins
Logging Plugin
import { createLoggingPlugin } from '@kysera/migrations'
const loggingPlugin = createLoggingPlugin(console.log)
// or with custom logger
const loggingPlugin = createLoggingPlugin(msg => logger.info(msg))Metrics Plugin
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 }, ...]Creating Custom Plugins
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
MigrationError
Extends DatabaseError from @kysera/core for consistency:
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 }
}
}BadRequestError
For validation errors (e.g., duplicate migration names):
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"
}
}NotFoundError
Uses NotFoundError from @kysera/core:
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
1. Use Numeric Prefixes
// Good - clear ordering
'001_create_users'
'002_create_posts'
'003_add_indexes'
// Bad - no guaranteed order
'create_users'
'create_posts'2. Always Provide down() Methods
createMigration(
'001_create_users',
async db => {
/* up */
},
async db => {
/* down - always provide this! */
}
)3. Use Metadata for Complex Migrations
createMigrationWithMeta('005_big_refactor', {
description: 'Refactors user permissions system',
breaking: true, // Will show warning
tags: ['breaking', 'permissions'],
up: async db => {
/* ... */
}
})4. Test with Dry Run First
// Preview in production
await runMigrations(db, migrations, { dryRun: true })
// Then run for real
await runMigrations(db, migrations)5. Use Transactions for Safety
const runner = createMigrationRunner(db, migrations, {
useTransactions: true // Each migration wrapped in transaction
})Migration Script Example
// 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()// 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
PostgreSQL
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()
})MySQL
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()
})SQLite
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(sql`CURRENT_TIMESTAMP`))
.execute()
})Implementation Details
Version File Fallback Pattern
The migration system uses a robust version file fallback pattern to ensure compatibility across different build environments:
- Primary: Try to load
version.ts(development/source code) - Fallback: Try to load
version.js(compiled/production code) - Default: Use
'0.0.0-dev'if both fail
This pattern ensures migrations work correctly in both development and production environments without requiring specific build configurations.
Changelog
v0.5.1
- Breaking
MigrationErrornow extendsDatabaseErrorfrom@kysera/corewithcodeproperty - Breaking
onMigrationErrorhook now receiveserror: unknown(consistent with repository Plugin) - Breaking
createMigrationRunnerWithPlugins()is now async (returnsPromise<MigrationRunnerWithPlugins>) - Added
onInithook toMigrationPlugininterface (consistent with repository Plugin) - Added
MigrationErrorCodetype export - Added
MigrationDefinitionandMigrationDefinitionstype exports - Added
MigrationRunnerWithPluginsOptionsinterface export - Added
DatabaseErrorandBadRequestErrorre-exports from@kysera/core - Added Version file fallback pattern for cross-environment compatibility
- Changed Validation errors now throw
BadRequestErrorinstead of genericError
v0.5.0
- Added
@kysera/coreintegration with typed errors - Added
MigrationWithMetasupport with description, breaking flag, tags - Added
defineMigrations()for object-based syntax - Added
runMigrations(),rollbackMigrations(),getMigrationStatus()one-liners - Added
MigrationResultreturn type for all operations - Added Plugin system with
MigrationPlugininterface - Added Built-in
createLoggingPlugin()andcreateMetricsPlugin() - Added
MigrationErrorclass for better error handling - Added Duplicate migration name validation
- Added
useTransactionsoption for transaction wrapping - Added
stopOnErroroption for error handling control - Fixed Inconsistent dry run behavior in
reset()andupTo() - Fixed
MigrationStatusnow includestotalcount
v0.4.1
- 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 management
License
MIT
