@slonik/migrator
Version:
database migration tool using slonik
346 lines • 15.2 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.setupSlonikMigrator = exports.SlonikMigrator = void 0;
const crypto_1 = require("crypto");
const fs_1 = require("fs");
const path_1 = require("path");
const umzug = require("umzug");
const slonik_1 = require("slonik");
const path = require("path");
const ts_command_line_1 = require("@rushstack/ts-command-line");
const templates = require("./templates");
class SlonikMigrator extends umzug.Umzug {
constructor(slonikMigratorOptions) {
super({
context: () => ({
parent: slonikMigratorOptions.slonik,
sql: slonik_1.sql,
connection: null, // connection function is added later by storage setup.
}),
migrations: () => ({
glob: [this.migrationsGlob(), { cwd: path.resolve(slonikMigratorOptions.migrationsPath) }],
resolve: params => this.resolver(params),
}),
storage: {
executed: (...args) => this.executedNames(...args),
logMigration: (...args) => this.logMigration(...args),
unlogMigration: (...args) => this.unlogMigration(...args),
},
logger: slonikMigratorOptions.logger,
create: {
template: filepath => this.template(filepath),
folder: path.resolve(slonikMigratorOptions.migrationsPath),
},
});
this.slonikMigratorOptions = slonikMigratorOptions;
if ('mainModule' in slonikMigratorOptions) {
throw new Error(`Using \`mainModule\` is deprecated. Use \`migrator.runAsCLI()\` instead.`);
}
if (!slonikMigratorOptions.migrationTableName) {
throw new Error(`@slonik/migrator: Relying on the default migration table name is deprecated. You should set this explicitly to 'migration' if you've used a prior version of this library.`);
}
}
getCli(options) {
const cli = super.getCli({ toolDescription: `@slonik/migrator - PostgreSQL migration tool`, ...options });
cli.addAction(new RepairAction(this));
return cli;
}
async runAsCLI(argv) {
var _a, _b;
const result = await super.runAsCLI(argv);
await ((_b = (_a = this.slonikMigratorOptions.slonik).end) === null || _b === void 0 ? void 0 : _b.call(_a));
return result;
}
/** Glob pattern with `migrationsPath` as `cwd`. Could be overridden to support nested directories */
migrationsGlob() {
return './*.{js,ts,sql}';
}
/** Gets a hexadecimal integer to pass to postgres's `select pg_advisory_lock()` function */
advisoryLockId() {
const hashable = '@slonik/migrator advisory lock:' + JSON.stringify(this.slonikMigratorOptions.migrationTableName);
const hex = (0, crypto_1.createHash)('md5').update(hashable).digest('hex').slice(0, 8);
return parseInt(hex, 16);
}
migrationTableNameIdentifier() {
const table = this.slonikMigratorOptions.migrationTableName;
return slonik_1.sql.identifier(Array.isArray(table) ? table : [table]);
}
template(filepath) {
if (filepath.endsWith('.ts')) {
return [[filepath, templates.typescript]];
}
if (filepath.endsWith('.js')) {
return [[filepath, templates.javascript]];
}
const downPath = path.join(path.dirname(filepath), 'down', path.basename(filepath));
return [
[filepath, templates.sqlUp],
[downPath, templates.sqlDown],
];
}
resolver(params) {
if (path.extname(params.name) === '.sql') {
return {
name: params.name,
path: params.path,
up: async ({ path, context }) => {
await context.connection.query(rawQuery((0, fs_1.readFileSync)(path, 'utf8')));
},
down: async ({ path, context }) => {
const downPath = (0, path_1.join)((0, path_1.dirname)(path), 'down', (0, path_1.basename)(path));
await context.connection.query(rawQuery((0, fs_1.readFileSync)(downPath, 'utf8')));
},
};
}
const { connection: slonik } = params.context;
const migrationModule = require(params.path);
return {
name: params.name,
path: params.path,
up: async (upParams) => migrationModule.up({ slonik, sql: slonik_1.sql, ...upParams }),
down: async (downParams) => { var _a; return (_a = migrationModule.down) === null || _a === void 0 ? void 0 : _a.call(migrationModule, { slonik, sql: slonik_1.sql, ...downParams }); },
};
}
async getOrCreateMigrationsTable(context) {
await context.parent.query((0, slonik_1.sql) `
create table if not exists ${this.migrationTableNameIdentifier()}(
name text primary key,
hash text not null,
date timestamptz not null default now()
)
`);
}
async runCommand(command, cb) {
let run = cb;
if (command === 'up' || command === 'down') {
run = async ({ context }) => {
return context.parent.connect(async (conn) => {
const logger = this.slonikMigratorOptions.logger;
const timeout = setTimeout(() => logger === null || logger === void 0 ? void 0 : logger.info({
message: `Waiting for lock. This may mean another process is simultaneously running migrations. You may want to issue a command like "set lock_timeout = '10s'" if this happens frequently. Othrewise, this command may wait until the process is killed.`,
}), 1000);
await conn.any(context.sql `select pg_advisory_lock(${this.advisoryLockId()})`);
try {
clearTimeout(timeout);
const result = await cb({ context });
return result;
}
finally {
await conn.any(context.sql `select pg_advisory_unlock(${this.advisoryLockId()})`).catch(error => {
var _a;
(_a = this.slonikMigratorOptions.logger) === null || _a === void 0 ? void 0 : _a.error({
message: `Failed to unlock. This is expected if the lock acquisition timed out. Otherwise, you may need to run "select pg_advisory_unlock(${this.advisoryLockId()})" manually`,
originalError: error,
});
});
}
});
};
}
return super.runCommand(command, async ({ context: _ctx }) => {
const connect = this.slonikMigratorOptions.singleTransaction ? _ctx.parent.transaction : _ctx.parent.connect;
return connect(async (connection) => {
const context = { ..._ctx, connection };
await this.getOrCreateMigrationsTable(context);
return run({ context });
});
});
}
async repair(options) {
var _a;
const dryRun = (_a = options === null || options === void 0 ? void 0 : options.dryRun) !== null && _a !== void 0 ? _a : false;
await this.runCommand('repair', async ({ context }) => {
var _a, _b;
const infos = await this.executedInfos(context);
const migrationsThatNeedRepair = infos.filter(({ dbHash, diskHash }) => dbHash !== diskHash);
if (migrationsThatNeedRepair.length === 0) {
(_a = this.slonikMigratorOptions.logger) === null || _a === void 0 ? void 0 : _a.info({ message: 'Nothing to repair' });
return;
}
for (const { migration, dbHash, diskHash } of migrationsThatNeedRepair) {
(_b = this.slonikMigratorOptions.logger) === null || _b === void 0 ? void 0 : _b.warn({
message: `Repairing migration ${migration}`,
migration,
oldHash: dbHash,
newHash: diskHash,
dryRun,
});
if (!dryRun)
await this.repairMigration({ name: migration, hash: diskHash, context });
}
});
}
hash(name) {
return (0, crypto_1.createHash)('md5')
.update((0, fs_1.readFileSync)((0, path_1.join)(this.slonikMigratorOptions.migrationsPath, name), 'utf8').trim().replace(/\s+/g, ' '))
.digest('hex')
.slice(0, 10);
}
async executedNames({ context }) {
const infos = await this.executedInfos(context);
infos
.filter(({ dbHash, diskHash }) => dbHash !== diskHash)
.forEach(({ migration, dbHash, diskHash }) => {
var _a;
(_a = this.slonikMigratorOptions.logger) === null || _a === void 0 ? void 0 : _a.warn({
message: `hash in '${this.slonikMigratorOptions.migrationTableName}' table didn't match content on disk.`,
question: `Did you try to change a migration file after it had been run? If you upgraded from v0.8.X-v0.9.X to v.0.10.X, you might need to run the 'repair' command.`,
migration,
dbHash,
diskHash,
});
});
return infos.map(({ migration }) => migration);
}
/**
* Returns the name, dbHash and diskHash for each executed migration.
*/
async executedInfos(context) {
await this.getOrCreateMigrationsTable(context);
const migrations = await context.parent.any((0, slonik_1.sql) `select name, hash from ${this.migrationTableNameIdentifier()}`);
return migrations.map(r => {
const name = r.name;
return {
migration: name,
dbHash: r.hash,
diskHash: this.hash(name),
};
});
}
async logMigration({ name, context }) {
await context.connection.query((0, slonik_1.sql) `
insert into ${this.migrationTableNameIdentifier()}(name, hash)
values (${name}, ${this.hash(name)})
`);
}
async unlogMigration({ name, context }) {
await context.connection.query((0, slonik_1.sql) `
delete from ${this.migrationTableNameIdentifier()}
where name = ${name}
`);
}
async repairMigration({ name, hash, context }) {
await context.connection.query((0, slonik_1.sql) `
update ${this.migrationTableNameIdentifier()}
set hash = ${hash}
where name = ${name}
`);
}
}
exports.SlonikMigrator = SlonikMigrator;
/**
* Logs messages to console. Known events are prettified to strings, unknown
* events or unexpected message properties in known events are logged as objects.
*/
SlonikMigrator.prettyLogger = {
info: message => prettifyAndLog('info', message),
warn: message => prettifyAndLog('warn', message),
error: message => prettifyAndLog('error', message),
debug: message => prettifyAndLog('debug', message),
};
/**
* More reliable than slonik-sql-tag-raw: https://github.com/gajus/slonik-sql-tag-raw/issues/6
* But doesn't sanitise any inputs, so shouldn't be used with templates
*/
const rawQuery = (query) => ({
type: 'SLONIK_TOKEN_SQL',
sql: query,
values: [],
});
/**
* @deprecated use `new SlonikMigrator(...)` which takes the same options.
*
* Note: `mainModule` is not passed into `new SlonikMigrator(...)`. To get the same functionality, use `.runAsCLI()`
*
* @example
* ```
* const migrator = new SlonikMigrator(...)
*
* if (require.main === module) {
* migrator.runAsCLI()
* }
* ```
*/
const setupSlonikMigrator = (options) => {
console.warn(`@slonik/migrator: Use of ${exports.setupSlonikMigrator.name} is deprecated. Use \`new SlonikMigrator(...)\` which takes the same options instead`);
const defaultMigrationTableName = () => {
console.warn(`Relying on the default migration table name is deprecated. You should set this explicitly to 'migration'`);
return 'migration';
};
const migrator = new SlonikMigrator({
slonik: options.slonik,
migrationsPath: options.migrationsPath,
migrationTableName: options.migrationTableName || defaultMigrationTableName(),
logger: options.logger,
});
if (options.mainModule === require.main) {
console.warn(`Using \`mainModule\` is deprecated. Use \`migrator.runAsCLI()\` instead.`);
migrator.runAsCLI();
}
return migrator;
};
exports.setupSlonikMigrator = setupSlonikMigrator;
class RepairAction extends ts_command_line_1.CommandLineAction {
constructor(slonikMigrator) {
super({
actionName: 'repair',
summary: 'Repair hashes in the migration table',
documentation: 'If, for any reason, the hashes are incorrectly stored in the database, you can recompute them using this command. Note that due to a bug in @slonik/migrator v0.8.X-v0.9-X the hashes were incorrectly calculated, so this command is recommended after upgrading to v0.10.',
});
this.slonikMigrator = slonikMigrator;
}
onDefineParameters() {
this.dryRunFlag = this.defineFlagParameter({
parameterShortName: '-d',
parameterLongName: '--dry-run',
description: 'No changes are actually made',
});
}
async onExecute() {
await this.slonikMigrator.repair({ dryRun: this.dryRunFlag.value });
}
}
const createMessageFormats = (formats) => formats;
const MESSAGE_FORMATS = createMessageFormats({
created: msg => {
const { event, path, ...rest } = msg;
return [`created ${path}`, rest];
},
migrating: msg => {
const { event, name, ...rest } = msg;
return [`migrating ${name}`, rest];
},
migrated: msg => {
const { event, name, durationSeconds, ...rest } = msg;
return [`migrated ${name} in ${durationSeconds} s`, rest];
},
reverting: msg => {
const { event, name, ...rest } = msg;
return [`reverting ${name}`, rest];
},
reverted: msg => {
const { event, name, durationSeconds, ...rest } = msg;
return [`reverted ${name} in ${durationSeconds} s`, rest];
},
up: msg => {
const { event, message, ...rest } = msg;
return [`up migration completed, ${message}`, rest];
},
down: msg => {
const { event, message, ...rest } = msg;
return [`down migration completed, ${message}`, rest];
},
});
function isProperEvent(event) {
return typeof event === 'string' && event in MESSAGE_FORMATS;
}
function prettifyAndLog(level, message) {
const { event } = message || {};
if (!isProperEvent(event))
return console[level](message);
const [messageStr, rest] = MESSAGE_FORMATS[event](message);
console[level](messageStr);
if (Object.keys(rest).length > 0)
console[level](rest);
}
//# sourceMappingURL=index.js.map