graphile-migrate
Version:
Opinionated SQL-powered migration tool for PostgreSQL
297 lines (295 loc) • 12.6 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
const fs_1 = require("fs");
const current_1 = require("./current");
const hash_1 = require("./hash");
const header_1 = require("./header");
const instrumentation_1 = require("./instrumentation");
const lib_1 = require("./lib");
const memoize_1 = require("./memoize");
const pg_1 = require("./pg");
const pgReal_1 = require("./pgReal");
// From https://stackoverflow.com/a/3561711/141284
function escapeRegexp(str) {
return str.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&");
}
exports.slowGeneratePlaceholderReplacement = (parsedSettings, { database }) => {
const placeholders = lib_1.mergeWithoutClobbering(parsedSettings.placeholders || {}, {
":DATABASE_NAME": database,
":DATABASE_OWNER": parsedSettings.databaseOwner,
}, "do not specify reserved placeholders.");
const regexp = new RegExp("(?:" +
Object.keys(placeholders)
.map(escapeRegexp)
.join("|") +
")\\b", "g");
return (str) => str.replace(regexp, (keyword) => placeholders[keyword] || "");
};
exports.generatePlaceholderReplacement = memoize_1.default(exports.slowGeneratePlaceholderReplacement);
// So memoization above holds from compilePlaceholders
const contextObj = memoize_1.default(database => ({ database }));
function compilePlaceholders(parsedSettings, content, shadow = false) {
const database = shadow
? parsedSettings.shadowDatabaseName
: parsedSettings.databaseName;
if (!database) {
throw new Error("Could not determine name of the database");
}
return exports.generatePlaceholderReplacement(parsedSettings, contextObj(database))(content);
}
exports.compilePlaceholders = compilePlaceholders;
const TABLE_CHECKS = {
migrations: {
columnCount: 4,
},
current: {
columnCount: 3,
},
};
async function verifyGraphileMigrateSchema(pgClient) {
// Verify that graphile_migrate schema exists
const { rows: [graphileMigrateSchema], } = await pgClient.query(`select oid from pg_namespace where nspname = 'graphile_migrate';`);
if (!graphileMigrateSchema) {
throw new Error("You've set manageGraphileMigrateSchema to false, but have not installed our database schema - we cannot continue.");
}
for (const [tableName, expected] of Object.entries(TABLE_CHECKS)) {
// Check that table exists
const { rows: [table], } = await pgClient.query(`select oid from pg_class where relnamespace = ${graphileMigrateSchema.oid} and relname = '${tableName}' and relkind = 'r'`);
if (!table) {
throw new Error(`You've set manageGraphileMigrateSchema to false, but the 'graphile_migrate.${tableName}' table couldn't be found - we cannot continue.`);
}
// Check that it has the right number of columns
const { rows: columns } = await pgClient.query(`select attrelid, attname from pg_attribute where attrelid = ${table.oid} and attnum > 0`);
if (columns.length !== expected.columnCount) {
throw new Error(`You've set manageGraphileMigrateSchema to false, but the 'graphile_migrate.${tableName}' table has the wrong number of columns (${columns.length} != ${expected.columnCount}) - we cannot continue.`);
}
}
return null;
}
async function _migrateMigrationSchema(pgClient, parsedSettings) {
if (!parsedSettings.manageGraphileMigrateSchema) {
// Verify schema
await verifyGraphileMigrateSchema(pgClient);
return;
}
await pgClient.query(`
create schema if not exists graphile_migrate;
create table if not exists graphile_migrate.migrations (
hash text primary key,
previous_hash text references graphile_migrate.migrations,
filename text not null,
date timestamptz not null default now()
);
create table if not exists graphile_migrate.current (
filename text primary key default 'current.sql',
content text not null,
date timestamptz not null default now()
);
`);
}
exports._migrateMigrationSchema = _migrateMigrationSchema;
function parseMigrationText(fullPath, contents,
/**
* Should be set true for committed migrations - requires that there is a \n\n divide after the header
*/
strict = true) {
const lines = contents.split("\n");
const headers = {};
let headerLines = 0;
for (const line of lines) {
// Headers always start with a capital letter
const matches = /^--! ([A-Z][a-zA-Z0-9_]*)(?::(.*))?$/.exec(line);
if (!matches) {
// Not headers any more
break;
}
headerLines++;
const [, key, value = null] = matches;
if (key in headers) {
throw new Error(`Invalid migration '${fullPath}': header '${key}' is specified more than once`);
}
headers[key] = value ? value.trim() : value;
}
if (strict && lines[headerLines] !== "") {
throw new Error(`Invalid migration '${fullPath}': there should be two newlines after the headers section`);
}
const body = lines
.slice(headerLines)
.join("\n")
.trim() + "\n";
return { headers, body };
}
exports.parseMigrationText = parseMigrationText;
function serializeHeader(key, value) {
return `--! ${key}` + (value ? `: ${value}` : "");
}
exports.serializeHeader = serializeHeader;
function serializeMigration(body, headers) {
const headerLines = [];
for (const [key, value] of Object.entries(headers)) {
if (value === undefined) {
continue;
}
headerLines.push(serializeHeader(key, value));
}
const finalBody = `${body.trim()}\n`;
if (headerLines.length) {
return `${headerLines.join("\n")}\n\n${finalBody}`;
}
else {
return finalBody;
}
}
exports.serializeMigration = serializeMigration;
exports.isMigrationFilename = (filename) => current_1.VALID_FILE_REGEX.exec(filename);
async function getLastMigration(pgClient, parsedSettings) {
await _migrateMigrationSchema(pgClient, parsedSettings);
const { rows: [row], } = await pgClient.query(`select filename, previous_hash as "previousHash", hash, date from graphile_migrate.migrations order by filename desc limit 1`);
return row || null;
}
exports.getLastMigration = getLastMigration;
async function getAllMigrations(parsedSettings) {
const { migrationsFolder } = parsedSettings;
const committedMigrationsFolder = `${migrationsFolder}/committed`;
try {
await fs_1.promises.mkdir(migrationsFolder);
}
catch (e) {
// noop
}
try {
await fs_1.promises.mkdir(committedMigrationsFolder);
}
catch (e) {
// noop
}
const files = await fs_1.promises.readdir(committedMigrationsFolder);
const migrations = await Promise.all(files
.map(exports.isMigrationFilename)
.filter((matches) => !!matches)
.map(async (matches) => {
const [realFilename, migrationNumberString, messageSlug = null,] = matches;
const fullPath = `${committedMigrationsFolder}/${realFilename}`;
const contents = await fs_1.promises.readFile(fullPath, "utf8");
const { headers, body } = parseMigrationText(fullPath, contents);
// --! Previous:
const previousHashRaw = headers["Previous"];
if (!previousHashRaw) {
throw new Error(`Invalid committed migration '${fullPath}': no 'Previous' comment`);
}
const previousHash = previousHashRaw && previousHashRaw !== "-" ? previousHashRaw : null;
// --! Hash:
const hash = headers["Hash"];
if (!hash) {
throw new Error(`Invalid committed migration '${fullPath}': no 'Hash' comment`);
}
// --! Message:
const message = headers["Message"];
// --! AllowInvalidHash
const allowInvalidHash = "AllowInvalidHash" in headers;
return {
realFilename,
filename: migrationNumberString + ".sql",
message,
messageSlug,
fullPath,
hash,
previousHash,
allowInvalidHash,
body,
previous: null,
};
}));
migrations.sort((a, b) => a.filename.localeCompare(b.filename, "en"));
// Validate and link
let previous = null;
for (const migration of migrations) {
if (!previous) {
if (migration.previousHash != null) {
throw new Error(`Migration '${migration.filename}' expected a previous migration ('${migration.previousHash}'), but no correctly ordered previous migration was found`);
}
}
else {
if (migration.previousHash !== previous.hash) {
throw new Error(`Previous migration with hash '${previous.hash}' doesn't match '${migration.filename}''s expected previous hash '${migration.previousHash}'`);
}
}
migration.previous = previous;
previous = migration;
}
return migrations;
}
exports.getAllMigrations = getAllMigrations;
async function getMigrationsAfter(parsedSettings, previousMigration) {
const allMigrations = await getAllMigrations(parsedSettings);
return allMigrations.filter(m => !previousMigration || m.filename > previousMigration.filename);
}
exports.getMigrationsAfter = getMigrationsAfter;
async function runStringMigration(pgClient, parsedSettings, context, rawBody, filename, committedMigration, dryRun) {
const placeholderReplacement = exports.generatePlaceholderReplacement(parsedSettings, context);
const sql = placeholderReplacement(rawBody);
const transaction = header_1.isNoTransactionDefined(sql) === false;
if (dryRun) {
return { sql, transaction };
}
return pgReal_1.withAdvisoryLock(pgClient, async () => {
if (transaction) {
await pgClient.query("begin");
}
try {
await instrumentation_1.runQueryWithErrorInstrumentation(pgClient, sql, filename);
if (committedMigration) {
const { hash, previousHash, filename } = committedMigration;
await pgClient.query({
name: "migration-insert",
text: "insert into graphile_migrate.migrations(hash, previous_hash, filename) values ($1, $2, $3)",
values: [hash, previousHash, filename],
});
}
if (transaction) {
await pgClient.query("commit");
}
return { sql, transaction };
}
catch (e) {
if (transaction) {
await pgClient.query("rollback");
}
throw e;
}
});
}
exports.runStringMigration = runStringMigration;
async function undoMigration(parsedSettings, committedMigration) {
const { hash } = committedMigration;
await pg_1.withClient(parsedSettings.connectionString, parsedSettings, async (pgClient) => {
await pgClient.query({
name: "migration-delete",
text: "delete from graphile_migrate.migrations where hash = $1",
values: [hash],
});
});
}
exports.undoMigration = undoMigration;
async function runCommittedMigration(pgClient, parsedSettings, context, committedMigration, logSuffix) {
const { hash, realFilename, filename, body, previousHash, allowInvalidHash, } = committedMigration;
// Check the hash
const newHash = hash_1.calculateHash(body, previousHash);
const hashIsInvalid = newHash !== hash;
if (hashIsInvalid && !allowInvalidHash) {
throw new Error(`Hash for ${realFilename} does not match - ${newHash} !== ${hash}; has the file been tampered with?`);
}
if (allowInvalidHash && !hashIsInvalid) {
throw new Error(`Invalid hash allowed for ${realFilename}; but hash matches.`);
}
parsedSettings.logger.info(`graphile-migrate${logSuffix}: Running migration '${realFilename}'${hashIsInvalid ? " (🚨 INVALID HASH, allowed via AllowInvalidHash 🚨)" : ""}`);
await runStringMigration(pgClient, parsedSettings, context, body, filename, committedMigration);
}
exports.runCommittedMigration = runCommittedMigration;
async function reverseMigration(pgClient, _body) {
// TODO: reverse the migration
// Clean up graphile_migrate.current
await pgClient.query(`delete from graphile_migrate.current where filename = 'current.sql'`);
}
exports.reverseMigration = reverseMigration;
//# sourceMappingURL=migration.js.map