arango-migrate
Version:
Migration tools for ArangoDB
459 lines (452 loc) • 16.8 kB
JavaScript
import { Command } from 'commander';
import * as path from 'path';
import path__default from 'path';
import glob from 'glob';
import { Database, aql } from 'arangojs';
import fs from 'fs';
import slugify from 'slugify';
import { pathToFileURL } from 'url';
const isString = s => {
return typeof s === 'string' || s instanceof String;
};
const MIGRATION_TEMPLATE_JS = `/**
* @typedef { import("arango-migrate").Migration } Migration
*/
/**
* @type { Migration }
*/
const migration = {
async collections () {
return []
},
async up (db, step) {
}
}
export default migration
`;
const MIGRATION_TEMPLATE_TS = `import { Collections, Migration, StepFunction } from 'arango-migrate'
import { Database } from 'arangojs'
const migration: Migration = {
async collections (): Promise<Collections> {
return []
},
async up (db: Database, step: StepFunction) {}
}
export default migration
`;
const DEFAULT_CONFIG_PATH = './config.migrate.js';
const DEFAULT_MIGRATIONS_PATH = './migrations';
const DEFAULT_MIGRATION_HISTORY_COLLECTION = 'migration_history';
class ArangoMigrate {
constructor(options) {
this.options = void 0;
this.migrationHistoryCollection = void 0;
this.db = void 0;
this.migrationPaths = void 0;
this.migrationsPath = void 0;
this.options = options;
this.migrationsPath = this.options.migrationsPath || DEFAULT_MIGRATIONS_PATH;
this.migrationHistoryCollection = this.options.migrationHistoryCollection || DEFAULT_MIGRATION_HISTORY_COLLECTION;
this.migrationPaths = this.loadMigrationPaths(this.migrationsPath);
}
static async loadConfig(configPath = DEFAULT_CONFIG_PATH) {
const p = path__default.resolve(configPath);
if (!fs.existsSync(p)) {
throw new Error(`Config file ${p} not found.`);
}
const importedConfig = await import(pathToFileURL(p).href);
const config = importedConfig.default;
if (!config.dbConfig) {
throw new Error('Config object must contain a dbConfig property.');
}
return config;
}
loadMigrationPaths(migrationsPath) {
return glob.sync(migrationsPath + '/*').reduce((acc, filePath) => {
return [...acc, path__default.resolve(filePath)];
}, []).sort((a, b) => a.localeCompare(b));
}
getMigrationPaths() {
return this.migrationPaths;
}
async initialize() {
const name = this.options.dbConfig.databaseName;
try {
this.db = new Database({
...this.options.dbConfig,
databaseName: undefined
});
this.db = await this.db.createDatabase(name);
} catch (err) {
this.db = new Database(this.options.dbConfig);
this.db = this.db.database(name);
}
}
migrationExists(version) {
return this.getMigrationPathFromVersion(version) !== undefined;
}
getMigrationPathFromVersion(version) {
return this.migrationPaths.find(x => {
const basename = path__default.basename(x);
return version === Number(basename.split('_')[0]);
});
}
async getMigrationFromVersion(version) {
const migrationPath = this.migrationPaths.find(x => {
const basename = path__default.basename(x);
return version === Number(basename.split('_')[0]) && fs.existsSync(path__default.resolve(x));
});
const importedMigration = await import(pathToFileURL(migrationPath).href);
return importedMigration.default;
}
async getMigrationHistoryCollection() {
let collection;
try {
collection = await this.db.createCollection(this.migrationHistoryCollection);
} catch {
collection = this.db.collection(this.migrationHistoryCollection);
}
return collection;
}
async getMigrationHistory(direction = 'ASC') {
const collection = await this.getMigrationHistoryCollection();
return await (await this.db.query(aql`
FOR x IN ${collection}
SORT x.counter ${direction}
RETURN x`)).all();
}
async getLatestMigration() {
const collection = await this.getMigrationHistoryCollection();
return await (await this.db.query(aql`
FOR x IN ${collection}
SORT x.counter DESC
LIMIT 1
RETURN x`)).next();
}
async writeMigrationHistory(direction, name, description, version) {
const collection = await this.getMigrationHistoryCollection();
const latest = await this.getLatestMigration();
await collection.save({
name,
description,
version,
direction,
counter: latest ? latest.counter + 1 : 1,
createdAt: new Date()
});
}
async initializeTransactionCollections(collections) {
const newCollections = new Set();
const allCollectionNames = new Set();
const transactionCollections = [];
let createdCollectionCount = 0;
for (const collectionData of collections) {
const data = isString(collectionData) ? {
collectionName: collectionData
} : collectionData;
allCollectionNames.add(data.collectionName);
let collection;
try {
if (this.options.autoCreateNewCollections !== false) {
/**
* NOTE: arangojs *.d.ts invites user to pass "literal" options object
* to infer typeof collection. Thus there is no another way to support
* collections() API but using this ugly "as any" cast
*/
collection = await this.db.createCollection(data.collectionName, data.options);
createdCollectionCount++;
newCollections.add(collection);
}
} catch {
collection = this.db.collection(data.collectionName);
if (!collection) {
throw new Error(`Collection ${data.collectionName} not found.`);
}
}
if (collection) {
transactionCollections.push(collection);
}
}
return {
transactionCollections,
newCollections,
allCollectionNames,
createdCollectionCount
};
}
async runUpMigrations(to, dryRun, noHistory) {
const versions = this.getVersionsFromMigrationPaths();
if (!to) {
to = versions[versions.length - 1];
}
const history = await this.getMigrationHistory('DESC');
const versionsToRun = versions.filter(version => {
const migration = history.find(migration => migration.version === version);
return (migration == null ? void 0 : migration.direction) !== 'up' && version <= to;
});
let appliedMigrations = 0;
let createdCollections = 0;
for (const i of versionsToRun) {
let migration;
try {
migration = await this.getMigrationFromVersion(i);
} catch (err) {
console.log(err);
return;
}
const name = path__default.basename(this.getMigrationPathFromVersion(i));
const collectionNames = migration.collections ? await migration.collections() : [];
const {
transactionCollections,
newCollections,
createdCollectionCount
} = await this.initializeTransactionCollections(collectionNames);
createdCollections += createdCollectionCount;
let beforeUpData;
if (migration.beforeUp) {
beforeUpData = await migration.beforeUp(this.db);
}
const transactionOptions = await (migration.transactionOptions == null ? void 0 : migration.transactionOptions());
const transaction = await this.db.beginTransaction(transactionCollections, transactionOptions);
let error;
let upResult;
if (migration.up) {
try {
upResult = await migration.up(this.db, callback => transaction.step(callback), beforeUpData);
} catch (err) {
console.log(err);
error = new Error(`Running up failed for migration ${i}.`);
}
}
if (!dryRun) {
try {
const transactionStatus = await transaction.commit();
if (transactionStatus.status !== 'committed') {
error = new Error(`Transaction failed with status ${transactionStatus.status} for migration ${name}.`);
}
} catch (err) {
error = new Error('Transaction failed.');
}
}
try {
if (migration.afterUp) {
await migration.afterUp(this.db, upResult);
}
} catch (err) {
error = new Error(`afterUp threw an error ${err}.`);
}
if (error) {
for (const collection of Array.from(newCollections)) {
await collection.drop();
}
}
if (!error) {
if (!dryRun && noHistory !== true) {
await this.writeMigrationHistory('up', name, migration.description, i);
}
}
if (error) {
throw error;
}
appliedMigrations += 1;
}
return {
appliedMigrations,
createdCollections
};
}
async runDownMigrations(to, dryRun, noHistory, disallowMissingVersions) {
const latestMigration = await this.getLatestMigration();
if (!latestMigration) {
throw new Error('No migrations have been applied.');
}
if (!to) {
to = 1;
}
let appliedMigrations = 0;
let createdCollections = 0;
let version = latestMigration.version;
while (version >= to) {
if (!this.migrationExists(version) && !disallowMissingVersions) {
version--;
continue;
}
let migration;
try {
migration = await this.getMigrationFromVersion(version);
} catch (err) {
console.log(err);
return;
}
const name = path__default.basename(this.getMigrationPathFromVersion(version));
const collectionNames = migration.collections ? await migration.collections() : [];
const {
transactionCollections,
newCollections,
createdCollectionCount
} = await this.initializeTransactionCollections(collectionNames);
createdCollections += createdCollectionCount;
let error;
let beforeDownData;
if (migration.beforeDown) {
beforeDownData = await migration.beforeDown(this.db);
}
const transaction = await this.db.beginTransaction(transactionCollections);
let downResult;
if (migration.down) {
try {
downResult = await migration.down(this.db, callback => transaction.step(callback), beforeDownData);
} catch (err) {
console.log(err);
error = new Error(`Running up failed for migration ${version}.`);
}
}
if (!dryRun) {
try {
const transactionStatus = await transaction.commit();
if (transactionStatus.status !== 'committed') {
error = new Error(`Transaction failed with status ${transactionStatus.status} for migration ${name}.`);
}
} catch (err) {
error = new Error('Transaction failed.');
}
}
try {
if (migration.afterDown) {
await migration.afterDown(this.db, downResult);
}
} catch (err) {
error = new Error(`afterDown threw an error ${err}.`);
}
if (error) {
for (const collection of Array.from(newCollections)) {
await collection.drop();
}
}
if (!error) {
if (!dryRun && noHistory !== true) {
await this.writeMigrationHistory('down', name, migration.description, version);
}
}
if (error) {
throw error;
}
appliedMigrations += 1;
version--;
}
return {
appliedMigrations,
createdCollections
};
}
getVersionsFromMigrationPaths() {
return this.migrationPaths.map(migrationPath => {
return Number(path__default.basename(migrationPath).split('_')[0]);
}).sort((a, b) => a - b);
}
validateMigrationFolderNotEmpty() {
if (this.migrationPaths.length === 0) {
throw new Error('No migrations.');
}
}
validateMigrationVersions() {
const versions = this.getVersionsFromMigrationPaths();
if (!versions || versions.length !== new Set(versions).size) {
throw new Error('Migration versions must be unique.');
}
}
async validateMigrationVersion(version) {
const latestMigration = await this.getLatestMigration();
if (!latestMigration && version > 1) {
throw new Error(`Migration sequence must start with 1, not ${version}.`);
}
if (latestMigration && version > Number(latestMigration.version) + 1) {
throw new Error(`Migration must be ran in sequence. ${version} must immediately follow ${latestMigration.version}.`);
}
if (latestMigration && version <= Number(latestMigration.version)) {
const name = this.getMigrationPathFromVersion(version);
throw new Error(`Cannot run up migration ${name} because migration has already been applied.`);
}
}
writeNewMigration(name, typescript) {
name = slugify(name, '_');
const version = Date.now();
if (!fs.existsSync(path__default.resolve(this.migrationsPath))) {
fs.mkdirSync(path__default.resolve(this.migrationsPath));
}
const res = path__default.resolve(`${this.migrationsPath}/${version}_${name}${typescript ? '.ts' : '.js'}`);
fs.writeFileSync(res, typescript ? MIGRATION_TEMPLATE_TS : MIGRATION_TEMPLATE_JS);
return res;
}
async hasNewMigrations() {
const history = await this.getMigrationHistory('DESC');
if (!history.length) {
return true;
}
const versions = this.getVersionsFromMigrationPaths();
return versions.filter(version => {
const migration = history.find(migration => migration.version === version);
return (migration == null ? void 0 : migration.direction) !== 'up';
}).length !== 0;
}
}
(async () => {
const program = new Command();
program.option('-c, --config <config>', 'path to a js config file. Defaults to ./config.migrate.js').option('-u, --up', 'run up migrations. Defaults to running all un-applied migrations if no --to parameter is provided').option('-d, --down', 'run down migrations').option('-t, --to <version>', 'run migrations to and including a specific version').option('-i --init <name>', 'initialize a new migration file').option('-l --list', 'list all applied migrations').option('-dr --dry-run', 'dry run. Executes migration lifecycle functions but never commits the transaction to the database or writes to the migration history log').option('-nh --no-history', 'Skips writing to the migration history log. Use this with caution since the applied migrations will not be saved in the migration history log, opening the possibility of applying the same migration multiple times and potentially dirtying your data').option('--disallow-missing-versions', 'raise an exception if there are missing versions when running down migrations');
program.parse(process.argv);
const options = program.opts();
const configPath = path.resolve(options.config || DEFAULT_CONFIG_PATH);
const config = await ArangoMigrate.loadConfig(configPath);
const am = new ArangoMigrate(config);
am.initialize().then(async () => {
if (options.list) {
const history = await am.getMigrationHistory();
if (!history.length) {
console.log('No migration history.');
} else {
console.table(history);
}
process.exit(0);
} else if (options.init) {
console.log(`Migration created at ${am.writeNewMigration(options.init, options.typescript)}.`);
process.exit(0);
} else if (options.up) {
am.validateMigrationFolderNotEmpty();
am.validateMigrationVersions();
const single = Number(options.single) >= 0;
const to = Number(single ? options.single : options.to);
if (!(await am.hasNewMigrations()) && !options.dryRun) {
console.log('No new migrations to run.');
process.exit(0);
}
const {
createdCollections,
appliedMigrations
} = await am.runUpMigrations(to, options.dryRun, options.noHistory);
console.log(`${createdCollections} collections created.`);
if (options.dryRun) {
console.log(`${appliedMigrations} \`up\` migrations dry ran.`);
} else {
console.log(`${appliedMigrations} \`up\` migrations applied.`);
}
process.exit(0);
} else if (options.down) {
am.validateMigrationFolderNotEmpty();
am.validateMigrationVersions();
const to = Number(options.to);
const {
createdCollections,
appliedMigrations
} = await am.runDownMigrations(to, options.dryRun, options.noHistory, options.disallowMissingVersions);
console.log(`${createdCollections} collections created.`);
if (options.dryRun) {
console.log(`${appliedMigrations} \`down\` migrations dry ran.`);
} else {
console.log(`${appliedMigrations} \`down\` migrations applied.`);
}
process.exit(0);
}
});
})();
//# sourceMappingURL=cli.module.js.map