Migrations
Create and change your Postgres tables with Kysely migrations in the kysely plugin. Covers where the files go, how they're ordered, when they run, running them yourself, and what happens when one fails.
Your bot's tables have to exist on every database it uses, like your laptop, a teammate's machine, and production. If you create them by running CREATE TABLE yourself, each database needs the same statements in the same order. Miss one, and your bot fails at the first query that touches a table or column that database doesn't have.
A migration is a file that exports two functions. up makes one change to your database, like creating a table. down undoes that change, and Kysely only runs it when you roll the migration back. Kysely keeps a table in each database listing the migrations that database has already run. By default, the plugin runs the rest while your bot starts, before any service can query the tables.
import type { Kysely } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('users')
.addColumn('id', 'serial', (col) => col.primaryKey())
.addColumn('discord_id', 'text', (col) =>
col.notNull().unique()
)
.addColumn('streak', 'integer', (col) =>
col.notNull().defaultTo(0)
)
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable('users').execute();
}up creates the users table, while down drops it. Both take Kysely<any>, because each migration runs against whatever tables the migrations before it created. Your schema describes the tables as they are once every migration has run.
Where to put the files
Kysely compares the names of your migrations against the names recorded in its table to find the ones that haven't run. The shape you give path under migrations sets each name, so changing the shape later can make a database's recorded migrations look missing. The last column shows the name Kysely records for a file called 001-create-users.ts.
path | what the plugin reads | recorded name |
|---|---|---|
| a directory | every script file directly inside it (.ts, .js, .mts, .mjs, .cts, .cjs) that exports up | 001-create-users |
| one file path | that file alone, which has to export up and down | 001-create-users.ts |
| an array of paths | those files, each exporting up and down | 001-create-users.ts |
Whichever shape you pick, path can be relative or absolute. A relative path like './src/migrations' resolves against the folder you start your bot from, so starting the bot from any other folder points the plugin at the wrong place. The samples on this page build path with resolve(import.meta.dirname, './migrations') instead. That resolves against the folder of bot.ts, so it stays correct wherever you start the bot.
The first sample exports up and down by name. A migration file can also default-export one object holding both functions:
import type { Kysely } from 'kysely';
export default {
async up(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('users')
.addColumn('username', 'text')
.execute();
},
async down(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('users')
.dropColumn('username')
.execute();
}
};Only a directory path reads that form, because Kysely checks a file's default export before its named exports. If you list 002-add-username.ts in a file or array path, the plugin throws PluginKyselyInvalidMigrationModule, since it only looks for named up and down exports. Named exports work with all three shapes, so use them unless all your migrations are in one directory.
Kysely sorts every migration name by character code before it runs them, whichever shape you use. Start each file name with a number like 001- or a timestamp to set the order. Pad the number with zeros, since 10-add-streaks sorts before 9-create-users. nameComparator doesn't change that order, because Kysely sorts the names again after the plugin's own sort.
When a previously executed migration is missing
Kysely throws this error when its table lists a migration name that doesn't match any of your current files:
corrupted migrations: previously executed migration 001-create-users.ts is missingWith a file or an array path, a migration's name keeps its file extension, so 001-create-users.ts and 001-create-users.js are two different migrations. If a database ran 001-create-users.ts under seedcord dev, your built bot throws this error against that database, because its file is 001-create-users.js. If you switch a project from a directory to an array, the same error comes up, since 001-create-users becomes 001-create-users.ts.
With a directory path, Kysely records each migration's name without the extension, so the .ts and the .js file both match the same recorded name. Use a directory path, and keep using it once any database has run your migrations.
When they run
By default the plugin runs every pending migration while the bot starts. If a migration throws, startup stops. The plugin closes the pool, and your bot never reaches its handlers.
onStartup changes what runs at that point.
onStartup | what happens while the bot starts |
|---|---|
left out, or true | runs every pending migration |
false | runs nothing |
a MigrationOptions object | runs to the target, or in the direction and steps you pass |
Set it to false when you run migrations as their own deploy step. A failing migration stops the bot either way, so what changes is when you find out. During startup, the deploy has already replaced your running bot, and the new one fails on the tables it expects. As a deploy step, the migration fails while the old bot keeps serving.
import { resolve } from 'node:path';
import { KyselyPostgres } from '@seedcord/plugin-kysely-postgres';
seedcord.attach('db', KyselyPostgres, {
dir: resolve(import.meta.dirname, './services'),
connectionString: 'postgres://localhost:5432/seedcord',
migrations: {
path: resolve(import.meta.dirname, './migrations'),
onStartup: false
}
});With onStartup: false, this bot starts on whatever tables the database already has.
Running them yourself
You can also run migrations from your own code, like an owner-only command that rolls back the last migration, or a script that migrates before your bot starts. The plugin has a method for each job. Once startup has connected, a handler calls them on this.core.db.
| method | what it does |
|---|---|
migrate(options?) | runs to the latest migration, or to the target or direction you pass |
migrateUp(options?) | runs one pending migration, or steps of them |
migrateDown(options?) | reverts the last migration, or the last steps of them |
listMigrations() | every migration, run or pending |
listPendingMigrations() | the ones that haven't run yet |
migrateUp and migrateDown take a StepMigrationOptions, whose only key is steps. listMigrations() returns Kysely's MigrationInfo objects. Each one's executedAt holds a Date once that migration has run, and undefined before.
migrate takes three optional keys. If you pass a target, migrate ignores direction and steps.
| key | what it accepts |
|---|---|
target | a migration name, or NO_MIGRATIONS to revert everything |
direction | 'latest', 'up', or 'down'. Defaults to 'latest' |
steps | how many to run under 'up' or 'down'. Defaults to 1 |
NO_MIGRATIONS comes from kysely/migration.
await db.migrate({ target: NO_MIGRATIONS });That call runs every down in reverse order. Each one only undoes what you wrote in it, so your tables are gone once every down drops what its up created.
steps has to be a whole number of zero or more. A fraction, a negative number, NaN, or Infinity throws PluginKyselyInvalidStepCount. Zero runs nothing. If you pass any other direction, migrate throws PluginKyselyUnknownDirection.
When a migration fails
If a migration throws an Error, you get that error unchanged, so a failing CREATE TABLE shows you Postgres's own message. The plugin throws its own codes for problems around the migrations.
| what went wrong | what you get |
|---|---|
a listed file that doesn't export up and down | PluginKyselyInvalidMigrationModule, with the file path |
| an empty array of paths | PluginKyselyNoMigrationFiles |
a migration that throws something other than an Error | PluginKyselyNonErrorFailure |
a path that doesn't exist | Node's ERR_MODULE_NOT_FOUND, since the plugin imports it as a file |
If a file in a directory doesn't export up, Kysely skips it without an error.
The rest of the options
| option | what it sets |
|---|---|
allowUnorderedMigrations | whether a new migration can sort before one that already ran. Defaults to false |
migrationTableName | the table recording which migrations ran. Defaults to kysely_migration |
migrationLockTableName | the table Kysely locks so two bots don't migrate at once. Defaults to kysely_migration_lock |
migrationTableSchema | the Postgres schema holding both of those tables |
nameComparator | orders the list the plugin prints in its debug log for a file or an array. Kysely sorts by name again before running, so run order stays the same |
allowUnorderedMigrations matters when two branches each add a migration. Say one branch merges 003-add-streaks after the database already ran 004-add-guilds. Unless you set it to true, Kysely throws a corrupted migrations error before running anything.