Run database migrations¶
Migrations evolve your schema in a controlled, repeatable way. Katalyst discovers
KatalystMigration implementations under your scanned packages and runs them with a
MigrationRunner. This guide covers writing migrations, running them at startup, and
inspecting them operationally.
Enable migrations¶
Add the feature toggle and pair it with an explicit schema policy. For services that own their schema lifecycle, validate on startup and let migrations apply changes:
fun main(args: Array<String>) = katalystApplication(args) {
engine(NettyServer)
beanEngine(KoinBeanEngine)
features {
enableYamlConfiguration()
enableMigrations {
runAtStartup = true
schemaTable = "katalyst_schema_migrations"
}
}
database { fromConfiguration() }
scanPackages("com.example")
schema { validateOnStartup() }
}
Use schema { none() } when an external migration job applies changes before the service
boots.
Write a migration¶
Implement KatalystMigration. The up() method performs the change; id must be unique.
import io.github.darkryh.katalyst.migrations.KatalystMigration
import org.jetbrains.exposed.v1.jdbc.SchemaUtils
class CreateUsersTable : KatalystMigration {
override val id = "2024060101_create_users"
override val description = "Create users table"
override val checksum = "create-users-v1"
override val tags = setOf("prod")
override fun up() {
SchemaUtils.create(UsersTable)
}
}
For data fixes, run SQL inside an Exposed transaction against the injected DatabaseFactory:
class NormalizeStatuses(private val databaseFactory: DatabaseFactory) : KatalystMigration {
override val id = "2024060102_normalize_status"
override fun up() {
transaction(databaseFactory.database) {
exec("UPDATE auth_accounts SET status = 'active' WHERE status IS NULL")
}
}
}
Rules to know:
idis unique and, by default, its numeric prefix supplies the orderingversion.checksumis stored in the history table and validated on later runs; a changed checksum for an already-applied migration is a validation error.tagsparticipate inincludeTags/excludeTagsfiltering.down()exists on the interface but rollback orchestration is not part of the current runtime runner.
See the migrations reference for the full contract.
Filter and target migrations¶
Configure the run through enableMigrations { … }:
enableMigrations {
runAtStartup = true
includeTags = setOf("prod") // only run migrations tagged "prod"
targetVersion = "2024060105" // inclusive: run ids <= this value
baselineVersion = "2024060100" // mark everything <= as applied, without running up()
}
All options are listed in the MigrationOptions reference.
Inspect migrations without applying them¶
Build a MigrationRunner from the active DatabaseFactory to drive read-only checks from a
CLI, CI job, or admin endpoint.
Status¶
val report = runner.status(discoveredMigrations)
println("Pending: ${report.pending.map { it.id }}")
println("Applied: ${report.applied.map { it.id }}")
status() never creates the history table or executes anything. If the history table does
not exist yet, every eligible migration is reported as pending.
Validate¶
val validation = runner.validateMigrations(discoveredMigrations)
if (!validation.valid) validation.errors.forEach(::println)
// or fail hard in CI:
validation.throwIfInvalid()
Validation catches duplicate ids, duplicate version/id pairs, and checksum drift.
Dry run¶
val dryRun = runner.dryRun(discoveredMigrations)
dryRun.pending.forEach { println("Would run ${it.id}: ${it.description}") }
dryRun() validates first, then lists what would execute given the current tags, target,
and history — without touching the database.
Related¶
- Migrations reference — interfaces, options, and report types.
- Define tables and repositories — the schema you migrate.