diff --git a/README.md b/README.md index 754eb33..9a820f9 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,24 @@ async function() { } ``` +You can pass a custom migration table name: + +```typescript +await migrate(dbConfig, "path/to/migration/files", { + migrationTableName: "my_migrations", +}) +``` + +This could, alternatively, be a table in an existing schema: + +```typescript +await createDb(databaseName, {client}) +await client.query("CREATE SCHEMA IF NOT EXISTS my_schema") +await migrate(dbConfig, "path/to/migration/files", { + migrationTableName: "my_schema.migrations", +}) +``` + ## Design decisions ### No down migrations diff --git a/package.json b/package.json index 8f26564..91bf79a 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "check-formatting": "./node_modules/.bin/prettier '**/*.ts' --list-different", "fix-formatting": "./node_modules/.bin/prettier '**/*.ts' --write", "lint": "npm run tslint && npm run check-formatting", - "tslint": "tslint 'src/**/*.ts' --type-check --project tsconfig.json --format verbose", + "tslint": "tslint 'src/**/*.ts' --project tsconfig.json --format verbose", "test-integration": "ava --config ava.config.integration.cjs", "test-unit": "ava --config ava.config.unit.cjs", "test": "npm run test-unit && npm run lint && npm run test-integration", diff --git a/src/__tests__/fixtures/success-existing-db/migrations/1_success.sql b/src/__tests__/fixtures/success-existing-db/migrations/1_success.sql new file mode 100644 index 0000000..598172f --- /dev/null +++ b/src/__tests__/fixtures/success-existing-db/migrations/1_success.sql @@ -0,0 +1,3 @@ +CREATE TABLE success ( + id integer +); diff --git a/src/__tests__/fixtures/success-existing-db/restore.sql.js b/src/__tests__/fixtures/success-existing-db/restore.sql.js new file mode 100644 index 0000000..90a438f --- /dev/null +++ b/src/__tests__/fixtures/success-existing-db/restore.sql.js @@ -0,0 +1,11 @@ +module.exports = ` +CREATE TABLE migrations ( + id integer PRIMARY KEY, + name character varying(100) NOT NULL UNIQUE, + hash character varying(40) NOT NULL, + executed_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP +); + +INSERT INTO migrations ("id","name","hash","executed_at") +VALUES (0,E'create-migrations-table',E'e18db593bcde2aca2a408c4d1100f6abba2195df',E'2020-06-29 18:38:05.064546'); +` diff --git a/src/__tests__/migrate.ts b/src/__tests__/migrate.ts index 30357c4..c517360 100644 --- a/src/__tests__/migrate.ts +++ b/src/__tests__/migrate.ts @@ -190,6 +190,61 @@ test("with pool client", async (t) => { } }) +test("with custom migration table name", async (t) => { + const databaseName = "migration-test-custom-migration-table" + const dbConfig = { + database: databaseName, + user: "postgres", + password: PASSWORD, + host: "localhost", + port, + } + + const migrateWithCustomMigrationTable = () => + migrate(dbConfig, "src/__tests__/fixtures/success-first", { + migrationTableName: "my_migrations", + }) + + await createDb(databaseName, dbConfig) + await migrateWithCustomMigrationTable() + + t.truthy(await doesTableExist(dbConfig, "my_migrations")) + t.truthy(await doesTableExist(dbConfig, "success")) + + await migrateWithCustomMigrationTable() +}) + +test("with custom migration table name in a custom schema", async (t) => { + const databaseName = "migration-test-custom-schema-custom-migration-table" + const dbConfig = { + database: databaseName, + user: "postgres", + password: PASSWORD, + host: "localhost", + port, + } + + const migrateWithCustomMigrationTable = () => + migrate(dbConfig, "src/__tests__/fixtures/success-first", { + migrationTableName: "my_schema.my_migrations", + }) + + const pool = new pg.Pool(dbConfig) + + try { + await createDb(databaseName, dbConfig) + await pool.query("CREATE SCHEMA IF NOT EXISTS my_schema") + await migrateWithCustomMigrationTable() + + t.truthy(await doesTableExist(dbConfig, "my_schema.my_migrations")) + t.truthy(await doesTableExist(dbConfig, "success")) + + await migrateWithCustomMigrationTable() + } finally { + await pool.end() + } +}) + test("successful first migration", (t) => { const databaseName = "migration-test-success-first" const dbConfig = { @@ -284,6 +339,31 @@ test("successful complex js migration", (t) => { }) }) +test("successful migration on an existing database", async (t) => { + const databaseName = "migration-test-success-existing-db" + const dbConfig = { + database: databaseName, + user: "postgres", + password: PASSWORD, + host: "localhost", + port, + } + + const pool = new pg.Pool(dbConfig) + + try { + await createDb(databaseName, dbConfig) + await pool.query(require("./fixtures/success-existing-db/restore.sql")) + await migrate( + dbConfig, + "src/__tests__/fixtures/success-existing-db/migrations", + ) + t.truthy(await doesTableExist(dbConfig, "success")) + } finally { + await pool.end() + } +}) + test("bad arguments - no db config", (t) => { // tslint:disable-next-line no-any return t.throwsAsync((migrate as any)()).then((err) => { @@ -636,12 +716,7 @@ function doesTableExist(dbConfig: pg.ClientConfig, tableName: string) { .connect() .then(() => client.query(SQL` - SELECT EXISTS ( - SELECT 1 - FROM pg_catalog.pg_class c - WHERE c.relname = ${tableName} - AND c.relkind = 'r' - ); + SELECT to_regclass(${tableName}) as matching_tables `), ) .then((result) => { @@ -649,15 +724,19 @@ function doesTableExist(dbConfig: pg.ClientConfig, tableName: string) { return client .end() .then(() => { - return result.rows.length > 0 && result.rows[0].exists + return ( + result.rows.length > 0 && result.rows[0].matching_tables !== null + ) }) .catch((error) => { console.log("Async error in 'doesTableExist", error) - return result.rows.length > 0 && result.rows[0].exists + return ( + result.rows.length > 0 && result.rows[0].matching_tables !== null + ) }) } catch (error) { console.log("Sync error in 'doesTableExist", error) - return result.rows.length > 0 && result.rows[0].exists + return result.rows.length > 0 && result.rows[0].matching_tables !== null } }) } diff --git a/src/files-loader.ts b/src/files-loader.ts index 67592f1..8136502 100644 --- a/src/files-loader.ts +++ b/src/files-loader.ts @@ -2,6 +2,7 @@ import * as fs from "fs" import * as path from "path" import {promisify} from "util" import {load as loadMigrationFile} from "./migration-file" +import {loadInitialMigration} from "./initial-migration" import {Logger, Migration} from "./types" const readDir = promisify(fs.readdir) @@ -11,6 +12,7 @@ const isValidFile = (fileName: string) => /\.(sql|js)$/gi.test(fileName) export const load = async ( directory: string, log: Logger, + migrationTableName: string, ): Promise> => { log(`Loading migrations from: ${directory}`) @@ -18,17 +20,21 @@ export const load = async ( log(`Found migration files: ${fileNames}`) if (fileNames != null) { - const migrationFiles = [ - path.join(__dirname, "migrations/0_create-migrations-table.sql"), - ...fileNames.map((fileName) => path.resolve(directory, fileName)), - ].filter(isValidFile) + const migrationFiles = fileNames + .map((fileName) => path.resolve(directory, fileName)) + .filter(isValidFile) const unorderedMigrations = await Promise.all( migrationFiles.map(loadMigrationFile), ) + const initialMigration = await loadInitialMigration(migrationTableName) + // Arrange in ID order - return unorderedMigrations.sort((a, b) => a.id - b.id) + return [ + initialMigration, + ...unorderedMigrations.sort((a, b) => a.id - b.id), + ] } return [] diff --git a/src/initial-migration.ts b/src/initial-migration.ts new file mode 100644 index 0000000..ba08075 --- /dev/null +++ b/src/initial-migration.ts @@ -0,0 +1,29 @@ +import {hashString} from "./migration-file" + +export const loadInitialMigration = async (migrationTableName: string) => { + // Since the hash of the initial migration is distributed across users' databases + // the values `fileName` and `sql` must NEVER change! + const fileName = "0_create-migrations-table.sql" + const sql = getInitialMigrationSql(migrationTableName) + const hash = hashString(fileName + sql) + + return { + id: 0, + name: "create-migrations-table", + contents: sql, + fileName, + hash, + sql, + } +} + +// Formatting must not change to ensure content hash remains the same +export const getInitialMigrationSql = ( + migrationTableName: string, +) => `CREATE TABLE IF NOT EXISTS ${migrationTableName} ( + id integer PRIMARY KEY, + name varchar(100) UNIQUE NOT NULL, + hash varchar(40) NOT NULL, -- sha1 hex encoded hash of the file name and contents, to ensure it hasn't been altered since applying the migration + executed_at timestamp DEFAULT current_timestamp +); +` diff --git a/src/load-sql-from-js.ts b/src/load-sql-from-js.ts index 0ae03e4..b9b74cc 100644 --- a/src/load-sql-from-js.ts +++ b/src/load-sql-from-js.ts @@ -1,6 +1,6 @@ import * as path from "path" -export const loadSqlFromJs = (filePath: string): string => { +export const loadSqlFromJs = (filePath: string, context?: {}): string => { const migrationModule = require(filePath) if (!migrationModule.generateSql) { throw new Error(`Invalid javascript migration file: '${path.basename( @@ -8,7 +8,7 @@ export const loadSqlFromJs = (filePath: string): string => { )}'. It must to export a 'generateSql' function.`) } - const generatedValue = migrationModule.generateSql() + const generatedValue = migrationModule.generateSql(context) if (typeof generatedValue !== "string") { throw new Error(`Invalid javascript migration file: '${path.basename( filePath, diff --git a/src/migrate.ts b/src/migrate.ts index 414c99f..54f7124 100644 --- a/src/migrate.ts +++ b/src/migrate.ts @@ -18,6 +18,11 @@ export async function migrate( migrationsDirectory: string, config: Config = {}, ): Promise> { + const migrationTableName = + typeof config.migrationTableName === "string" + ? config.migrationTableName + : "migrations" + const log = config.logger != null ? config.logger @@ -32,13 +37,18 @@ export async function migrate( if (typeof migrationsDirectory !== "string") { throw new Error("Must pass migrations directory as a string") } - const intendedMigrations = await load(migrationsDirectory, log) + + const intendedMigrations = await load( + migrationsDirectory, + log, + migrationTableName, + ) if ("client" in dbConfig) { // we have been given a client to use, it should already be connected return withAdvisoryLock( log, - runMigrations(intendedMigrations, log), + runMigrations(intendedMigrations, log, migrationTableName), )(dbConfig.client) } @@ -59,17 +69,22 @@ export async function migrate( const runWith = withConnection( log, - withAdvisoryLock(log, runMigrations(intendedMigrations, log)), + withAdvisoryLock( + log, + runMigrations(intendedMigrations, log, migrationTableName), + ), ) return runWith(client) } -function runMigrations(intendedMigrations: Array, log: Logger) { +function runMigrations( + intendedMigrations: Array, + log: Logger, + migrationTableName: string, +) { return async (client: BasicPgClient) => { try { - const migrationTableName = "migrations" - log("Starting migrations") const appliedMigrations = await fetchAppliedMigrationFromDB( @@ -190,13 +205,13 @@ function logResult(completedMigrations: Array, log: Logger) { } /** Check whether table exists in postgres - http://stackoverflow.com/a/24089729 */ -async function doesTableExist(client: BasicPgClient, tableName: string) { - const result = await client.query(SQL`SELECT EXISTS ( - SELECT 1 - FROM pg_catalog.pg_class c - WHERE c.relname = ${tableName} - AND c.relkind = 'r' -);`) - - return result.rows.length > 0 && result.rows[0].exists +async function doesTableExist( + client: BasicPgClient, + migrationTableName: string, +) { + const result = await client.query(SQL` + SELECT to_regclass(${migrationTableName}) as matching_tables + `) + + return result.rows.length > 0 && result.rows[0].matching_tables !== null } diff --git a/src/migration-file.ts b/src/migration-file.ts index 8df62ff..ef85a87 100644 --- a/src/migration-file.ts +++ b/src/migration-file.ts @@ -11,19 +11,20 @@ const getFileName = (filePath: string) => path.basename(filePath) const getFileContents = async (filePath: string) => readFile(filePath, "utf8") -const hashString = (s: string) => +export const hashString = (s: string) => crypto.createHash("sha1").update(s, "utf8").digest("hex") const getSqlStringLiteral = ( filePath: string, contents: string, type: "js" | "sql", + context?: {}, ) => { switch (type) { case "sql": return contents case "js": - return loadSqlFromJs(filePath) + return loadSqlFromJs(filePath, context) default: { const exhaustiveCheck: never = type return exhaustiveCheck diff --git a/src/migrations/0_create-migrations-table.sql b/src/migrations/0_create-migrations-table.sql deleted file mode 100644 index 9974966..0000000 --- a/src/migrations/0_create-migrations-table.sql +++ /dev/null @@ -1,6 +0,0 @@ -CREATE TABLE IF NOT EXISTS migrations ( - id integer PRIMARY KEY, - name varchar(100) UNIQUE NOT NULL, - hash varchar(40) NOT NULL, -- sha1 hex encoded hash of the file name and contents, to ensure it hasn't been altered since applying the migration - executed_at timestamp DEFAULT current_timestamp -); diff --git a/src/types.ts b/src/types.ts index f496bc4..681b499 100644 --- a/src/types.ts +++ b/src/types.ts @@ -39,6 +39,7 @@ export type Config = Partial export interface FullConfig { readonly logger: Logger + readonly migrationTableName: string } export class MigrationError extends Error {