kysely
Version:
Type safe SQL query builder
523 lines (522 loc) • 19.9 kB
JavaScript
/// <reference types="./migrator.d.ts" />
import { NoopPlugin } from '../plugin/noop-plugin.js';
import { WithSchemaPlugin } from '../plugin/with-schema/with-schema-plugin.js';
import { freeze, getLast } from '../util/object-utils.js';
export const DEFAULT_MIGRATION_TABLE = 'kysely_migration';
export const DEFAULT_MIGRATION_LOCK_TABLE = 'kysely_migration_lock';
export const DEFAULT_ALLOW_UNORDERED_MIGRATIONS = false;
export const MIGRATION_LOCK_ID = 'migration_lock';
export const NO_MIGRATIONS = freeze({ __noMigrations__: true });
/**
* A class for running migrations.
*
* ### Example
*
* This example uses the {@link FileMigrationProvider} that reads migrations
* files from a single folder. You can easily implement your own
* {@link MigrationProvider} if you want to provide migrations some
* other way.
*
* ```ts
* import { promises as fs } from 'fs'
* import path from 'path'
*
* const migrator = new Migrator({
* db,
* provider: new FileMigrationProvider({
* fs,
* path,
* // Path to the folder that contains all your migrations.
* migrationFolder: 'some/path/to/migrations'
* })
* })
* ```
*/
export class Migrator {
#props;
constructor(props) {
this.#props = freeze(props);
}
/**
* Returns a {@link MigrationInfo} object for each migration.
*
* The returned array is sorted by migration name.
*/
async getMigrations() {
const executedMigrations = (await this.#doesTableExists(this.#migrationTable))
? await this.#props.db
.withPlugin(this.#schemaPlugin)
.selectFrom(this.#migrationTable)
.select(['name', 'timestamp'])
.execute()
: [];
const migrations = await this.#resolveMigrations();
return migrations.map(({ name, ...migration }) => {
const executed = executedMigrations.find((it) => it.name === name);
return {
name,
migration,
executedAt: executed ? new Date(executed.timestamp) : undefined,
};
});
}
/**
* Runs all migrations that have not yet been run.
*
* This method returns a {@link MigrationResultSet} instance and _never_ throws.
* {@link MigrationResultSet.error} holds the error if something went wrong.
* {@link MigrationResultSet.results} contains information about which migrations
* were executed and which failed. See the examples below.
*
* This method goes through all possible migrations provided by the provider and runs the
* ones whose names come alphabetically after the last migration that has been run. If the
* list of executed migrations doesn't match the beginning of the list of possible migrations
* an error is returned.
*
* ### Examples
*
* ```ts
* const db = new Kysely<Database>({
* dialect: new PostgresDialect({
* host: 'localhost',
* database: 'kysely_test',
* }),
* })
*
* const migrator = new Migrator({
* db,
* provider: new FileMigrationProvider(
* // Path to the folder that contains all your migrations.
* 'some/path/to/migrations'
* )
* })
*
* const { error, results } = await migrator.migrateToLatest()
*
* results?.forEach((it) => {
* if (it.status === 'Success') {
* console.log(`migration "${it.migrationName}" was executed successfully`)
* } else if (it.status === 'Error') {
* console.error(`failed to execute migration "${it.migrationName}"`)
* }
* })
*
* if (error) {
* console.error('failed to run `migrateToLatest`')
* console.error(error)
* }
* ```
*/
async migrateToLatest() {
return this.#migrate(() => ({ direction: 'Up', step: Infinity }));
}
/**
* Migrate up/down to a specific migration.
*
* This method returns a {@link MigrationResultSet} instance and _never_ throws.
* {@link MigrationResultSet.error} holds the error if something went wrong.
* {@link MigrationResultSet.results} contains information about which migrations
* were executed and which failed.
*
* ### Examples
*
* ```ts
* await migrator.migrateTo('some_migration')
* ```
*
* If you specify the name of the first migration, this method migrates
* down to the first migration, but doesn't run the `down` method of
* the first migration. In case you want to migrate all the way down,
* you can use a special constant `NO_MIGRATIONS`:
*
* ```ts
* await migrator.migrateTo(NO_MIGRATIONS)
* ```
*/
async migrateTo(targetMigrationName) {
return this.#migrate(({ migrations, executedMigrations, pendingMigrations, }) => {
if (targetMigrationName === NO_MIGRATIONS) {
return { direction: 'Down', step: Infinity };
}
if (!migrations.find((m) => m.name === targetMigrationName)) {
throw new Error(`migration "${targetMigrationName}" doesn't exist`);
}
const executedIndex = executedMigrations.indexOf(targetMigrationName);
const pendingIndex = pendingMigrations.findIndex((m) => m.name === targetMigrationName);
if (executedIndex !== -1) {
return {
direction: 'Down',
step: executedMigrations.length - executedIndex - 1,
};
}
else if (pendingIndex !== -1) {
return { direction: 'Up', step: pendingIndex + 1 };
}
else {
throw new Error(`migration "${targetMigrationName}" isn't executed or pending`);
}
});
}
/**
* Migrate one step up.
*
* This method returns a {@link MigrationResultSet} instance and _never_ throws.
* {@link MigrationResultSet.error} holds the error if something went wrong.
* {@link MigrationResultSet.results} contains information about which migrations
* were executed and which failed.
*
* ### Examples
*
* ```ts
* await migrator.migrateUp()
* ```
*/
async migrateUp() {
return this.#migrate(() => ({ direction: 'Up', step: 1 }));
}
/**
* Migrate one step down.
*
* This method returns a {@link MigrationResultSet} instance and _never_ throws.
* {@link MigrationResultSet.error} holds the error if something went wrong.
* {@link MigrationResultSet.results} contains information about which migrations
* were executed and which failed.
*
* ### Examples
*
* ```ts
* await migrator.migrateDown()
* ```
*/
async migrateDown() {
return this.#migrate(() => ({ direction: 'Down', step: 1 }));
}
async #migrate(getMigrationDirectionAndStep) {
try {
await this.#ensureMigrationTablesExists();
return await this.#runMigrations(getMigrationDirectionAndStep);
}
catch (error) {
if (error instanceof MigrationResultSetError) {
return error.resultSet;
}
return { error };
}
}
get #migrationTableSchema() {
return this.#props.migrationTableSchema;
}
get #migrationTable() {
return this.#props.migrationTableName ?? DEFAULT_MIGRATION_TABLE;
}
get #migrationLockTable() {
return this.#props.migrationLockTableName ?? DEFAULT_MIGRATION_LOCK_TABLE;
}
get #allowUnorderedMigrations() {
return (this.#props.allowUnorderedMigrations ?? DEFAULT_ALLOW_UNORDERED_MIGRATIONS);
}
get #schemaPlugin() {
if (this.#migrationTableSchema) {
return new WithSchemaPlugin(this.#migrationTableSchema);
}
return new NoopPlugin();
}
async #ensureMigrationTablesExists() {
await this.#ensureMigrationTableSchemaExists();
await this.#ensureMigrationTableExists();
await this.#ensureMigrationLockTableExists();
await this.#ensureLockRowExists();
}
async #ensureMigrationTableSchemaExists() {
if (!this.#migrationTableSchema) {
// Use default schema. Nothing to do.
return;
}
if (!(await this.#doesSchemaExists())) {
try {
await this.#createIfNotExists(this.#props.db.schema.createSchema(this.#migrationTableSchema));
}
catch (error) {
// At least on PostgreSQL, `if not exists` doesn't guarantee the `create schema`
// query doesn't throw if the schema already exits. That's why we check if
// the schema exist here and ignore the error if it does.
if (!(await this.#doesSchemaExists())) {
throw error;
}
}
}
}
async #ensureMigrationTableExists() {
if (!(await this.#doesTableExists(this.#migrationTable))) {
try {
if (this.#migrationTableSchema) {
await this.#createIfNotExists(this.#props.db.schema.createSchema(this.#migrationTableSchema));
}
await this.#createIfNotExists(this.#props.db.schema
.withPlugin(this.#schemaPlugin)
.createTable(this.#migrationTable)
.addColumn('name', 'varchar(255)', (col) => col.notNull().primaryKey())
// The migration run time as ISO string. This is not a real date type as we
// can't know which data type is supported by all future dialects.
.addColumn('timestamp', 'varchar(255)', (col) => col.notNull()));
}
catch (error) {
// At least on PostgreSQL, `if not exists` doesn't guarantee the `create table`
// query doesn't throw if the table already exits. That's why we check if
// the table exist here and ignore the error if it does.
if (!(await this.#doesTableExists(this.#migrationTable))) {
throw error;
}
}
}
}
async #ensureMigrationLockTableExists() {
if (!(await this.#doesTableExists(this.#migrationLockTable))) {
try {
await this.#createIfNotExists(this.#props.db.schema
.withPlugin(this.#schemaPlugin)
.createTable(this.#migrationLockTable)
.addColumn('id', 'varchar(255)', (col) => col.notNull().primaryKey())
.addColumn('is_locked', 'integer', (col) => col.notNull().defaultTo(0)));
}
catch (error) {
// At least on PostgreSQL, `if not exists` doesn't guarantee the `create table`
// query doesn't throw if the table already exits. That's why we check if
// the table exist here and ignore the error if it does.
if (!(await this.#doesTableExists(this.#migrationLockTable))) {
throw error;
}
}
}
}
async #ensureLockRowExists() {
if (!(await this.#doesLockRowExists())) {
try {
await this.#props.db
.withPlugin(this.#schemaPlugin)
.insertInto(this.#migrationLockTable)
.values({ id: MIGRATION_LOCK_ID, is_locked: 0 })
.execute();
}
catch (error) {
if (!(await this.#doesLockRowExists())) {
throw error;
}
}
}
}
async #doesSchemaExists() {
const schemas = await this.#props.db.introspection.getSchemas();
return schemas.some((it) => it.name === this.#migrationTableSchema);
}
async #doesTableExists(tableName) {
const schema = this.#migrationTableSchema;
const tables = await this.#props.db.introspection.getTables({
withInternalKyselyTables: true,
});
return tables.some((it) => it.name === tableName && (!schema || it.schema === schema));
}
async #doesLockRowExists() {
const lockRow = await this.#props.db
.withPlugin(this.#schemaPlugin)
.selectFrom(this.#migrationLockTable)
.where('id', '=', MIGRATION_LOCK_ID)
.select('id')
.executeTakeFirst();
return !!lockRow;
}
async #runMigrations(getMigrationDirectionAndStep) {
const adapter = this.#props.db.getExecutor().adapter;
const lockOptions = freeze({
lockTable: this.#props.migrationLockTableName ?? DEFAULT_MIGRATION_LOCK_TABLE,
lockRowId: MIGRATION_LOCK_ID,
lockTableSchema: this.#props.migrationTableSchema,
});
const run = async (db) => {
try {
await adapter.acquireMigrationLock(db, lockOptions);
const state = await this.#getState(db);
if (state.migrations.length === 0) {
return { results: [] };
}
const { direction, step } = getMigrationDirectionAndStep(state);
if (step <= 0) {
return { results: [] };
}
if (direction === 'Down') {
return await this.#migrateDown(db, state, step);
}
else if (direction === 'Up') {
return await this.#migrateUp(db, state, step);
}
return { results: [] };
}
finally {
await adapter.releaseMigrationLock(db, lockOptions);
}
};
if (adapter.supportsTransactionalDdl) {
return this.#props.db.transaction().execute(run);
}
else {
return this.#props.db.connection().execute(run);
}
}
async #getState(db) {
const migrations = await this.#resolveMigrations();
const executedMigrations = await this.#getExecutedMigrations(db);
this.#ensureNoMissingMigrations(migrations, executedMigrations);
if (!this.#allowUnorderedMigrations) {
this.#ensureMigrationsInOrder(migrations, executedMigrations);
}
const pendingMigrations = this.#getPendingMigrations(migrations, executedMigrations);
return freeze({
migrations,
executedMigrations,
lastMigration: getLast(executedMigrations),
pendingMigrations,
});
}
#getPendingMigrations(migrations, executedMigrations) {
return migrations.filter((migration) => {
return !executedMigrations.includes(migration.name);
});
}
async #resolveMigrations() {
const allMigrations = await this.#props.provider.getMigrations();
return Object.keys(allMigrations)
.sort()
.map((name) => ({
...allMigrations[name],
name,
}));
}
async #getExecutedMigrations(db) {
const executedMigrations = await db
.withPlugin(this.#schemaPlugin)
.selectFrom(this.#migrationTable)
.select('name')
.orderBy(['timestamp', 'name'])
.execute();
return executedMigrations.map((it) => it.name);
}
#ensureNoMissingMigrations(migrations, executedMigrations) {
// Ensure all executed migrations exist in the `migrations` list.
for (const executed of executedMigrations) {
if (!migrations.some((it) => it.name === executed)) {
throw new Error(`corrupted migrations: previously executed migration ${executed} is missing`);
}
}
}
#ensureMigrationsInOrder(migrations, executedMigrations) {
// Ensure the executed migrations are the first ones in the migration list.
for (let i = 0; i < executedMigrations.length; ++i) {
if (migrations[i].name !== executedMigrations[i]) {
throw new Error(`corrupted migrations: expected previously executed migration ${executedMigrations[i]} to be at index ${i} but ${migrations[i].name} was found in its place. New migrations must always have a name that comes alphabetically after the last executed migration.`);
}
}
}
async #migrateDown(db, state, step) {
const migrationsToRollback = state.executedMigrations
.slice()
.reverse()
.slice(0, step)
.map((name) => {
return state.migrations.find((it) => it.name === name);
});
const results = migrationsToRollback.map((migration) => {
return {
migrationName: migration.name,
direction: 'Down',
status: 'NotExecuted',
};
});
for (let i = 0; i < results.length; ++i) {
const migration = migrationsToRollback[i];
try {
if (migration.down) {
await migration.down(db);
await db
.withPlugin(this.#schemaPlugin)
.deleteFrom(this.#migrationTable)
.where('name', '=', migration.name)
.execute();
results[i] = {
migrationName: migration.name,
direction: 'Down',
status: 'Success',
};
}
}
catch (error) {
results[i] = {
migrationName: migration.name,
direction: 'Down',
status: 'Error',
};
throw new MigrationResultSetError({
error,
results,
});
}
}
return { results };
}
async #migrateUp(db, state, step) {
const migrationsToRun = state.pendingMigrations.slice(0, step);
const results = migrationsToRun.map((migration) => {
return {
migrationName: migration.name,
direction: 'Up',
status: 'NotExecuted',
};
});
for (let i = 0; i < results.length; i++) {
const migration = state.pendingMigrations[i];
try {
await migration.up(db);
await db
.withPlugin(this.#schemaPlugin)
.insertInto(this.#migrationTable)
.values({
name: migration.name,
timestamp: new Date().toISOString(),
})
.execute();
results[i] = {
migrationName: migration.name,
direction: 'Up',
status: 'Success',
};
}
catch (error) {
results[i] = {
migrationName: migration.name,
direction: 'Up',
status: 'Error',
};
throw new MigrationResultSetError({
error,
results,
});
}
}
return { results };
}
async #createIfNotExists(qb) {
if (this.#props.db.getExecutor().adapter.supportsCreateIfNotExists) {
qb = qb.ifNotExists();
}
await qb.execute();
}
}
class MigrationResultSetError extends Error {
#resultSet;
constructor(result) {
super();
this.#resultSet = result;
}
get resultSet() {
return this.#resultSet;
}
}