UNPKG

graphile-migrate

Version:

Opinionated SQL-powered migration tool for PostgreSQL

297 lines (295 loc) 12.6 kB
"use strict"; 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