UNPKG

@adonisjs/lucid

Version:

SQL ORM built on top of Active Record pattern

508 lines (507 loc) 17.3 kB
/* * @adonisjs/lucid * * (c) Harminder Virk <virk@adonisjs.com> * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ import slash from 'slash'; import { EventEmitter } from 'node:events'; import { MigrationSource } from './source.js'; import * as errors from '../errors.js'; /** * Migrator exposes the API to execute migrations using the schema files * for a given connection at a time. */ export class MigrationRunner extends EventEmitter { db; app; options; client; config; /** * Reference to the migrations config for the given connection */ migrationsConfig; /** * Table names for storing schema files and schema versions */ schemaTableName; schemaVersionsTableName; /** * Whether the migrator has been booted */ booted = false; /** * Migration source to collect schema files from the disk */ migrationSource; /** * Flag to know if running the app in production */ isInProduction; /** * Mode decides in which mode the migrator is executing migrations. The migrator * instance can only run in one mode at a time. * * The value is set when `migrate` or `rollback` method is invoked */ direction; /** * Instead of executing migrations, just return the generated SQL queries */ dryRun; /** * Disable advisory locks */ disableLocks; /** * An array of files we have successfully migrated. The files are * collected regardless of `up` or `down` methods */ migratedFiles = {}; /** * Last error occurred when executing migrations */ error = null; /** * Current status of the migrator */ get status() { return !this.booted ? 'pending' : this.error ? 'error' : Object.keys(this.migratedFiles).length ? 'completed' : 'skipped'; } /** * Existing version of migrations. We use versioning to upgrade * existing migrations if we are plan to make a breaking * change. */ version = 2; constructor(db, app, options) { super(); this.db = db; this.app = app; this.options = options; this.client = this.db.connection(this.options.connectionName || this.db.primaryConnectionName); this.config = this.db.getRawConnection(this.client.connectionName).config; this.migrationsConfig = Object.assign({ tableName: 'adonis_schema', disableTransactions: false, }, this.config.migrations); this.schemaTableName = this.migrationsConfig.tableName; this.schemaVersionsTableName = `${this.schemaTableName}_versions`; this.migrationSource = new MigrationSource(this.config, this.app); this.direction = this.options.direction; this.dryRun = !!this.options.dryRun; this.disableLocks = !!this.options.disableLocks; this.isInProduction = app.inProduction; } /** * Returns the client for a given schema file. Schema instructions are * wrapped in a transaction unless transaction is not disabled */ async getClient(disableTransactions) { /** * We do not create a transaction when * * 1. Migration itself disables transaction * 2. Transactions are globally disabled * 3. Doing a dry run */ if (disableTransactions || this.migrationsConfig.disableTransactions || this.dryRun) { return this.client; } return this.client.transaction(); } /** * Roll back the transaction when it's client is a transaction client */ async rollback(client) { if (client.isTransaction) { await client.rollback(); } } /** * Commits a transaction when it's client is a transaction client */ async commit(client) { if (client.isTransaction) { await client.commit(); } } /** * Writes the migrated file to the migrations table. This ensures that * we are not re-running the same migration again */ async recordMigrated(client, name, executionResponse) { if (this.dryRun) { this.migratedFiles[name].queries = executionResponse; return; } await client.insertQuery().table(this.schemaTableName).insert({ name, batch: this.migratedFiles[name].batch, }); } /** * Removes the migrated file from the migrations table. This allows re-running * the migration */ async recordRollback(client, name, executionResponse) { if (this.dryRun) { this.migratedFiles[name].queries = executionResponse; return; } await client.query().from(this.schemaTableName).where({ name }).del(); } /** * Returns the migration source by ensuring value is a class constructor and * has disableTransactions property. */ async getMigrationSource(migration) { const source = await migration.getSource(); if (typeof source === 'function' && 'disableTransactions' in source) { return source; } throw new Error(`Invalid schema class exported by "${migration.name}"`); } /** * Executes a given migration node and cleans up any created transactions * in case of failure */ async executeMigration(migration) { const SchemaClass = await this.getMigrationSource(migration); const client = await this.getClient(SchemaClass.disableTransactions); try { const schema = new SchemaClass(client, migration.name, this.dryRun); this.emit('migration:start', this.migratedFiles[migration.name]); if (this.direction === 'up') { const response = await schema.execUp(); // Handles dry run itself await this.recordMigrated(client, migration.name, response); // Handles dry run itself } else if (this.direction === 'down') { const response = await schema.execDown(); // Handles dry run itself await this.recordRollback(client, migration.name, response); // Handles dry run itself } await this.commit(client); this.migratedFiles[migration.name].status = 'completed'; this.emit('migration:completed', this.migratedFiles[migration.name]); } catch (error) { this.error = error; this.migratedFiles[migration.name].status = 'error'; this.emit('migration:error', this.migratedFiles[migration.name]); await this.rollback(client); throw error; } } /** * Acquires a lock to disallow concurrent transactions. Only works with * `Mysql`, `PostgresSQL` and `MariaDb` for now. * * Make sure we are acquiring lock outside the transactions, since we want * to block other processes from acquiring the same lock. * * Locks are always acquired in dry run too, since we want to stay close * to the real execution cycle */ async acquireLock() { if (!this.client.dialect.supportsAdvisoryLocks || this.disableLocks) { return; } const acquired = await this.client.dialect.getAdvisoryLock(1); if (!acquired) { throw new errors.E_UNABLE_ACQUIRE_LOCK(); } this.emit('acquire:lock'); } /** * Release a lock once complete the migration process. Only works with * `Mysql`, `PostgresSQL` and `MariaDb` for now. */ async releaseLock() { if (!this.client.dialect.supportsAdvisoryLocks || this.disableLocks) { return; } const released = await this.client.dialect.releaseAdvisoryLock(1); if (!released) { throw new errors.E_UNABLE_RELEASE_LOCK(); } this.emit('release:lock'); } /** * Makes the migrations table (if missing). Also created in dry run, since * we always reads from the schema table to find which migrations files to * execute and that cannot be done without missing table. */ async makeMigrationsTable() { const hasTable = await this.client.schema.hasTable(this.schemaTableName); if (hasTable) { return; } this.emit('create:schema:table'); await this.client.schema.createTable(this.schemaTableName, (table) => { table.increments().notNullable(); table.string('name').notNullable(); table.integer('batch').notNullable(); table.timestamp('migration_time').defaultTo(this.client.getWriteClient().fn.now()); }); } /** * Makes the migrations version table (if missing). */ async makeMigrationsVersionsTable() { /** * Return early when table already exists */ const hasTable = await this.client.schema.hasTable(this.schemaVersionsTableName); if (hasTable) { return; } /** * Create table */ this.emit('create:schema_versions:table'); await this.client.schema.createTable(this.schemaVersionsTableName, (table) => { table.integer('version').unsigned().primary(); }); } /** * Returns the latest migrations version. If no rows exist * it inserts a new row for version 1 */ async getLatestVersion() { const rows = await this.client.from(this.schemaVersionsTableName).select('version').limit(1); if (rows.length) { return Number(rows[0].version); } else { await this.client.table(this.schemaVersionsTableName).insert({ version: 1 }); return 1; } } /** * Upgrade migrations name from version 1 to version 2 */ async upgradeFromOneToTwo() { const migrations = await this.getMigratedFilesTillBatch(0); const client = await this.getClient(false); try { await Promise.all(migrations.map((migration) => { return client .from(this.schemaTableName) .where('id', migration.id) .update({ name: slash(migration.name), }); })); await client.from(this.schemaVersionsTableName).where('version', 1).update({ version: 2 }); await this.commit(client); } catch (error) { await this.rollback(client); throw error; } } /** * Upgrade migrations version */ async upgradeVersion(latestVersion) { if (latestVersion === 1) { this.emit('upgrade:version', { from: 1, to: 2 }); await this.upgradeFromOneToTwo(); } } /** * Returns the latest batch from the migrations * table */ async getLatestBatch() { const rows = await this.client.from(this.schemaTableName).max('batch as batch'); return Number(rows[0].batch); } /** * Returns an array of files migrated till now */ async getMigratedFiles() { const rows = await this.client .query() .from(this.schemaTableName) .select('name'); return new Set(rows.map(({ name }) => name)); } /** * Returns an array of files migrated till now. The latest * migrations are on top */ async getMigratedFilesTillBatch(batch) { return this.client .query() .from(this.schemaTableName) .select('name', 'batch', 'migration_time', 'id') .where('batch', '>', batch) .orderBy('id', 'desc'); } /** * Boot the migrator to perform actions. All boot methods must * work regardless of dryRun is enabled or not. */ async boot() { this.emit('start'); this.booted = true; await this.acquireLock(); await this.makeMigrationsTable(); } /** * Shutdown gracefully */ async shutdown() { await this.releaseLock(); this.emit('end'); } /** * Migrate up */ async runUp() { const batch = await this.getLatestBatch(); const existing = await this.getMigratedFiles(); const collected = await this.migrationSource.getMigrations(); /** * Upfront collecting the files to be executed */ collected.forEach((migration) => { if (!existing.has(migration.name)) { this.migratedFiles[migration.name] = { status: 'pending', queries: [], file: migration, batch: batch + 1, }; } }); const filesToMigrate = Object.keys(this.migratedFiles); for (let name of filesToMigrate) { await this.executeMigration(this.migratedFiles[name].file); } } /** * Migrate down (aka rollback) */ async runDown(batch, step) { if (this.isInProduction && this.migrationsConfig.disableRollbacksInProduction) { throw new Error('Rollback in production environment is disabled. Check "config/database" file for options.'); } if (batch === undefined) { batch = (await this.getLatestBatch()) - 1; } const existing = await this.getMigratedFilesTillBatch(batch); const collected = await this.migrationSource.getMigrations(); if (step === undefined || step <= 0) { step = collected.length; } else { batch = (await this.getLatestBatch()) - 1; } /** * Finding schema files for migrations to rollback. We do not perform * rollback when any of the files are missing */ existing.forEach((file) => { const migration = collected.find(({ name }) => name === file.name); if (!migration) { throw new errors.E_MISSING_SCHEMA_FILES([file.name]); } this.migratedFiles[migration.name] = { status: 'pending', queries: [], file: migration, batch: file.batch, }; }); this.migratedFiles = Object.fromEntries(Object.entries(this.migratedFiles).slice(0, step)); const filesToMigrate = Object.keys(this.migratedFiles); for (let name of filesToMigrate) { await this.executeMigration(this.migratedFiles[name].file); } } on(event, callback) { return super.on(event, callback); } /** * Returns a merged list of completed and pending migrations */ async getList() { const existingCollected = new Set(); await this.makeMigrationsTable(); const existing = await this.getMigratedFilesTillBatch(0); const collected = await this.migrationSource.getMigrations(); const list = collected.map((migration) => { const migrated = existing.find(({ name }) => migration.name === name); /** * Already migrated. We move to an additional list, so that we can later * find the one's which are migrated but now missing on the disk */ if (migrated) { existingCollected.add(migrated.name); return { name: migration.name, batch: migrated.batch, status: 'migrated', migrationTime: migrated.migration_time, }; } return { name: migration.name, status: 'pending', }; }); /** * These are the one's which were migrated earlier, but now missing * on the disk */ existing.forEach(({ name, batch, migration_time }) => { if (!existingCollected.has(name)) { list.push({ name, batch, migrationTime: migration_time, status: 'corrupt' }); } }); return list; } /** * Migrate the database by calling the up method */ async run() { try { await this.boot(); /** * Upgrading migrations (if required) */ await this.makeMigrationsVersionsTable(); const latestVersion = await this.getLatestVersion(); if (latestVersion < this.version) { await this.upgradeVersion(latestVersion); } if (this.direction === 'up') { await this.runUp(); } else if (this.options.direction === 'down') { await this.runDown(this.options.batch, this.options.step); } } catch (error) { this.error = error; } await this.shutdown(); } /** * Close database connections */ async close() { await this.db.manager.closeAll(true); } }