import * as fs from 'fs'; import { Settings } from 'graphile-migrate'; import { join, parse } from 'path'; import { Pool } from 'pg'; import { CompareMigrationHashesErrorCallback, MigrationRecord } from './types'; /** * Regex to determine if the committed migration file name is correct. * Source 'graphile-migrate' package: https://github.com/graphile/migrate/blob/main/src/current.ts#L8 */ const VALID_FILE_REGEX = /^([0-9]+)(-[-_a-zA-Z0-9]*)?\.sql$/; /** * Finds path to `migrations` folder, based on passed Settings. * 'graphile-migrate' package parses all settings into internal ParsedSettings interface. * Settings Parser function is not exported, logic to determine path to folder was copied from the library source code. * Source, function `parseSettings`: https://github.com/graphile/migrate/blob/main/src/settings.ts#L172 * @param settings - migration configuration object. * @returns string representing path to `migrations` directory. */ export const getMigrationsPath = (settings: Settings): string => { return settings.migrationsFolder ?? join(process.cwd(), 'migrations'); }; /** * Extracts migration headers from committed migration file. * Code for header extraction taken from 'graphile-migrate' package. * Source, function `parseMigrationText`:https://github.com/graphile/migrate/blob/main/src/migration.ts#L202 */ const extractMigrationRecord = async ( migrationFilePath: string, migrationNumber: string, ): Promise => { const contents = await fs.promises.readFile(migrationFilePath, 'utf8'); const lines = contents.split('\n'); const headers: { [key: string]: string | null; } = {}; 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; } const [, key, value = null] = matches; if (key in headers) { throw new Error( `Invalid migration '${migrationFilePath}': header '${key}' is specified more than once`, ); } headers[key] = value ? value.trim() : value; } // --! Previous: const previousHashRaw = headers['Previous']; if (!previousHashRaw) { throw new Error( `Invalid committed migration '${migrationFilePath}': no 'Previous' comment`, ); } const previousHash = previousHashRaw && previousHashRaw !== '-' ? previousHashRaw : null; // --! Hash: const hash = headers['Hash']; if (!hash) { throw new Error( `Invalid committed migration '${migrationFilePath}': no 'Hash' comment`, ); } return { previousHash: previousHash, hash: hash, filename: migrationNumber + '.sql', fullFilename: parse(migrationFilePath).base, source: 'file', }; }; /** * Helper to check if passed string is a valid migration filename * @param filename - name of migration file */ const isMigrationFilename = (filename: string): RegExpMatchArray | null => VALID_FILE_REGEX.exec(filename); export async function getFileMigrationRecords( migrationsDirectory: string, errorCallback: CompareMigrationHashesErrorCallback, ): Promise { const committedMigrationsFolder = join(migrationsDirectory, 'committed'); if (!fs.existsSync(committedMigrationsFolder)) { errorCallback(`Directory '${committedMigrationsFolder}' was not found.`); return []; } //all files from `committedMigrationsFolder` const migrationFiles = await fs.promises.readdir(committedMigrationsFolder); // //retrieve all migration files from `migration/committed` folder return Promise.all( migrationFiles .map(isMigrationFilename) .filter((matches): matches is RegExpMatchArray => !!matches) .map(async (matches) => { const [realFilename, migrationNumberString] = matches; const fullPath = join(committedMigrationsFolder, realFilename); return extractMigrationRecord(fullPath, migrationNumberString); }), ); } export async function getMigrationHistoryRecords( connectionString: string | undefined | null, _errorCallback: CompareMigrationHashesErrorCallback, ): Promise { if (!connectionString) { throw new Error('Database connection string is not set up.'); } const pgPool = new Pool({ connectionString: connectionString, }); const pgClient = await pgPool.connect(); const { rows: [{ migrationsTableExists }], } = await pgClient.query<{ migrationsTableExists: boolean }>( `SELECT EXISTS (SELECT FROM pg_tables WHERE schemaname = 'graphile_migrate' AND tablename = 'migrations') as "migrationsTableExists"`, ); if (!migrationsTableExists) { //This table does not exist yet. It will be generated when the migration ran for the first time. return []; } const { rows } = await pgClient.query( `select filename, previous_hash as "previousHash", hash, 'database' as "source" from graphile_migrate.migrations`, ); pgClient.release(); pgPool.end(); return rows; }