UNPKG

node-pg-migrate

Version:

PostgreSQL database migration management tool for node.js

531 lines (530 loc) 17.1 kB
#!/usr/bin/env node import { Migration, PG_MIGRATE_LOCK_ID, runner as migrationRunner } from "node-pg-migrate"; import { readFileSync } from "node:fs"; import { register } from "node:module"; import { join, resolve } from "node:path"; import { cwd } from "node:process"; import { pathToFileURL } from "node:url"; import { format } from "node:util"; import ConnectionParameters from "pg/lib/connection-parameters.js"; import yargs from "yargs/yargs"; process.on("uncaughtException", (err) => { console.error(err); process.exit(1); }); async function tryImport(moduleName) { try { const module = await import(moduleName); return module.default || module; } catch (error) { if (error instanceof Error && "code" in error && (error.code === "ERR_MODULE_NOT_FOUND" || error.code === "MODULE_NOT_FOUND")) { return null; } throw error; } } const schemaArg = "schema"; const createSchemaArg = "create-schema"; const databaseUrlVarArg = "database-url-var"; const migrationsDirArg = "migrations-dir"; const useGlobArg = "use-glob"; const migrationsTableArg = "migrations-table"; const migrationsSchemaArg = "migrations-schema"; const createMigrationsSchemaArg = "create-migrations-schema"; const migrationFileLanguageArg = "migration-file-language"; const migrationFilenameFormatArg = "migration-filename-format"; const templateFileNameArg = "template-file-name"; const checkOrderArg = "check-order"; const configValueArg = "config-value"; const configFileArg = "config-file"; const ignorePatternArg = "ignore-pattern"; const singleTransactionArg = "single-transaction"; const lockArg = "lock"; const lockValueArg = "lock-value"; const timestampArg = "timestamp"; const dryRunArg = "dry-run"; const fakeArg = "fake"; const decamelizeArg = "decamelize"; const tsconfigArg = "tsconfig"; const tsNodeArg = "ts-node"; const tsxArg = "tsx"; const verboseArg = "verbose"; const rejectUnauthorizedArg = "reject-unauthorized"; const envPathArg = "envPath"; const parser = yargs(process.argv.slice(2)).usage("Usage: $0 [up|down|create|redo] [migrationName] [options]").options({ [databaseUrlVarArg]: { alias: "d", default: "DATABASE_URL", description: "Name of env variable where is set the databaseUrl", type: "string" }, [migrationsDirArg]: { alias: "m", defaultDescription: '"migrations"', describe: `The directory name or glob pattern containing your migration files (resolved from cwd()). When using glob pattern, "${useGlobArg}" must be used as well`, type: "string" }, [useGlobArg]: { defaultDescription: "false", describe: `Use glob to find migration files. This will use "${migrationsDirArg}" _and_ "${ignorePatternArg}" to glob-search for migration files.`, type: "boolean" }, [migrationsTableArg]: { alias: "t", defaultDescription: '"pgmigrations"', describe: "The table storing which migrations have been run", type: "string" }, [schemaArg]: { alias: "s", defaultDescription: '"public"', describe: "The schema on which migration will be run (defaults to `public`)", type: "string", array: true }, [createSchemaArg]: { defaultDescription: "false", describe: "Creates the configured schema if it doesn't exist", type: "boolean" }, [migrationsSchemaArg]: { defaultDescription: 'Same as "schema"', describe: "The schema storing table which migrations have been run", type: "string" }, [createMigrationsSchemaArg]: { defaultDescription: "false", describe: "Creates the configured migration schema if it doesn't exist", type: "boolean" }, [checkOrderArg]: { defaultDescription: "true", describe: "Check order of migrations before running them", type: "boolean" }, [verboseArg]: { defaultDescription: "true", describe: "Print debug messages - all DB statements run", type: "boolean" }, [ignorePatternArg]: { defaultDescription: '"\\..*"', describe: `Regex or glob pattern for migration files to be ignored. When using glob pattern, "${useGlobArg}" must be used as well`, type: "string" }, [decamelizeArg]: { defaultDescription: "false", describe: "Runs decamelize on table/columns/etc names", type: "boolean" }, [configValueArg]: { default: "db", describe: "Name of config section with db options", type: "string" }, [configFileArg]: { alias: "f", describe: "Name of config file with db options", type: "string" }, [migrationFileLanguageArg]: { alias: "j", defaultDescription: 'last one used or "js" if there is no migration yet', choices: ["js", "ts", "sql"], describe: "Language of the migration file (Only valid with the create action)", type: "string" }, [migrationFilenameFormatArg]: { defaultDescription: '"timestamp"', choices: ["timestamp", "utc"], describe: "Prefix type of migration filename (Only valid with the create action)", type: "string" }, [templateFileNameArg]: { describe: "Path to template for creating migrations", type: "string" }, [tsconfigArg]: { describe: "Path to tsconfig.json file", type: "string" }, [tsNodeArg]: { default: true, describe: "Use ts-node for typescript files", type: "boolean" }, [tsxArg]: { default: false, describe: "Use tsx for typescript files", type: "boolean" }, [envPathArg]: { describe: "Path to the .env file that should be used for configuration", type: "string" }, [dryRunArg]: { default: false, describe: "Prints the SQL but doesn't run it", type: "boolean" }, [fakeArg]: { default: false, describe: "Marks migrations as run", type: "boolean" }, [singleTransactionArg]: { default: true, describe: "Combines all pending migrations into a single database transaction so that if any migration fails, all will be rolled back", type: "boolean" }, [lockArg]: { default: true, describe: "When false, disables locking mechanism and checks", type: "boolean" }, [lockValueArg]: { default: PG_MIGRATE_LOCK_ID, describe: "The value to use for the lock", type: "number" }, [rejectUnauthorizedArg]: { defaultDescription: "false", describe: "Sets rejectUnauthorized SSL option", type: "boolean" }, [timestampArg]: { default: false, describe: "Treats number argument to up/down migration as timestamp", type: "boolean" } }).version().alias("version", "i").help(); const argv = parser.parseSync(); if (argv.help || argv._.length === 0) { parser.showHelp(); process.exit(1); } const envPath = argv[envPathArg]; const dotenvConfig = { // TODO @Shinigami92 2024-04-05: Does the silent option even still exists and do anything? silent: true }; if (envPath) { dotenvConfig.path = envPath; } const dotenv = await tryImport("dotenv"); if (dotenv) { const myEnv = dotenv.config(dotenvConfig); const dotenvExpand = await tryImport("dotenv-expand"); if (dotenvExpand && dotenvExpand.expand) { dotenvExpand.expand(myEnv); } } let MIGRATIONS_DIR = argv[migrationsDirArg]; let USE_GLOB = argv[useGlobArg]; let DB_CONNECTION = process.env[argv[databaseUrlVarArg]]; let IGNORE_PATTERN = argv[ignorePatternArg]; let SCHEMA = argv[schemaArg]; let CREATE_SCHEMA = argv[createSchemaArg]; let MIGRATIONS_SCHEMA = argv[migrationsSchemaArg]; let CREATE_MIGRATIONS_SCHEMA = argv[createMigrationsSchemaArg]; let MIGRATIONS_TABLE = argv[migrationsTableArg]; let MIGRATIONS_FILE_LANGUAGE = argv[migrationFileLanguageArg]; let MIGRATIONS_FILENAME_FORMAT = argv[migrationFilenameFormatArg]; let TEMPLATE_FILE_NAME = argv[templateFileNameArg]; let CHECK_ORDER = argv[checkOrderArg]; let VERBOSE = argv[verboseArg]; let DECAMELIZE = argv[decamelizeArg]; let tsconfigPath = argv[tsconfigArg]; let useTsNode = argv[tsNodeArg]; let useTsx = argv[tsxArg]; async function readTsconfig() { if (tsconfigPath) { let tsconfig; const json5 = await tryImport("json5"); try { const config2 = readFileSync(resolve(cwd(), tsconfigPath), { encoding: "utf8" }); tsconfig = json5 ? json5.parse(config2) : JSON.parse(config2); if (tsconfig["ts-node"]) { tsconfig = { ...tsconfig, ...tsconfig["ts-node"], compilerOptions: { // eslint-disable-next-line unicorn/no-useless-fallback-in-spread ...tsconfig.compilerOptions ?? {}, // eslint-disable-next-line unicorn/no-useless-fallback-in-spread ...tsconfig["ts-node"].compilerOptions ?? {} } }; } } catch (error) { console.error("Can't load tsconfig.json:", error); } if (useTsx) { process.env.TSX_TSCONFIG_PATH = tsconfigPath; } else if (useTsNode) { const tsnode = await tryImport("ts-node"); if (!tsnode) { console.error( "For TypeScript support, please install 'ts-node' module" ); } if (tsconfig && tsnode) { register("ts-node/esm", pathToFileURL("./")); if (!MIGRATIONS_FILE_LANGUAGE) { MIGRATIONS_FILE_LANGUAGE = "ts"; } } else { process.exit(1); } } } } function applyIf(arg, key, obj, condition) { if (arg !== void 0 && !(key in obj)) { return arg; } const val = obj[key]; return condition(val) ? val : arg; } function isString(val) { return typeof val === "string"; } function isBoolean(val) { return typeof val === "boolean"; } function isClientConfig(val) { return typeof val === "object" && val !== null && ("host" in val && !!val.host || "port" in val && !!val.port || "name" in val && !!val.name || "database" in val && !!val.database); } function readJson(json) { if (typeof json === "object" && json !== null) { SCHEMA = applyIf( SCHEMA, schemaArg, json, (val) => Array.isArray(val) || isString(val) && val.length > 0 ); CREATE_SCHEMA = applyIf(CREATE_SCHEMA, createSchemaArg, json, isBoolean); USE_GLOB = applyIf(USE_GLOB, useGlobArg, json, isBoolean); MIGRATIONS_DIR = applyIf(MIGRATIONS_DIR, migrationsDirArg, json, isString); MIGRATIONS_SCHEMA = applyIf( MIGRATIONS_SCHEMA, migrationsSchemaArg, json, isString ); CREATE_MIGRATIONS_SCHEMA = applyIf( CREATE_MIGRATIONS_SCHEMA, createMigrationsSchemaArg, json, isBoolean ); MIGRATIONS_TABLE = applyIf( MIGRATIONS_TABLE, migrationsTableArg, json, isString ); MIGRATIONS_FILE_LANGUAGE = applyIf( MIGRATIONS_FILE_LANGUAGE, migrationFileLanguageArg, json, (val) => val === "js" || val === "ts" || val === "sql" ); MIGRATIONS_FILENAME_FORMAT = applyIf( MIGRATIONS_FILENAME_FORMAT, migrationFilenameFormatArg, json, (val) => val === "timestamp" || val === "utc" ); TEMPLATE_FILE_NAME = applyIf( TEMPLATE_FILE_NAME, templateFileNameArg, json, isString ); IGNORE_PATTERN = applyIf(IGNORE_PATTERN, ignorePatternArg, json, isString); CHECK_ORDER = applyIf(CHECK_ORDER, checkOrderArg, json, isBoolean); VERBOSE = applyIf(VERBOSE, verboseArg, json, isBoolean); DECAMELIZE = applyIf(DECAMELIZE, decamelizeArg, json, isBoolean); DB_CONNECTION = applyIf( DB_CONNECTION, databaseUrlVarArg, json, (val) => typeof val === "string" || typeof val === "object" ); tsconfigPath = applyIf(tsconfigPath, tsconfigArg, json, isString); useTsNode = applyIf(useTsNode, tsNodeArg, json, isBoolean); useTsx = applyIf(useTsx, tsxArg, json, isBoolean); if ("url" in json && json.url) { DB_CONNECTION ??= json.url; } else if (isClientConfig(json) && !DB_CONNECTION) { DB_CONNECTION = { user: json.user, host: json.host || "localhost", database: json.name || json.database, password: json.password, port: json.port || 5432, ssl: json.ssl }; } } else { DB_CONNECTION ??= json; } } const oldSuppressWarning = process.env.SUPPRESS_NO_CONFIG_WARNING; process.env.SUPPRESS_NO_CONFIG_WARNING = "yes"; const config = await tryImport("config"); if (config?.has(argv[configValueArg])) { const db = config.get(argv[configValueArg]); readJson(db); } process.env.SUPPRESS_NO_CONFIG_WARNING = oldSuppressWarning; const configFileName = argv[configFileArg]; if (configFileName) { const jsonConfig = await import(`file://${resolve(configFileName)}`, { with: { type: "json" } }); const json = jsonConfig.default ?? jsonConfig; const section = argv[configValueArg]; readJson(json?.[section] === void 0 ? json : json[section]); } await readTsconfig(); if (useTsx) { const tsx = await tryImport("tsx/esm"); if (!tsx) { console.error("For TSX support, please install 'tsx' module"); } } const action = argv._.shift(); MIGRATIONS_DIR ??= join(cwd(), "migrations"); USE_GLOB ??= false; MIGRATIONS_FILE_LANGUAGE ??= "js"; MIGRATIONS_FILENAME_FORMAT ??= "timestamp"; MIGRATIONS_TABLE ??= "pgmigrations"; SCHEMA ??= ["public"]; CHECK_ORDER ??= true; VERBOSE ??= true; if (action === "create") { let newMigrationName = argv._.length > 0 ? argv._.join("-") : ""; newMigrationName = newMigrationName.replace(/[ _]+/g, "-"); if (!newMigrationName) { console.error("'migrationName' is required."); parser.showHelp(); process.exit(1); } Migration.create(newMigrationName, MIGRATIONS_DIR, { filenameFormat: MIGRATIONS_FILENAME_FORMAT, ...TEMPLATE_FILE_NAME ? { templateFileName: TEMPLATE_FILE_NAME } : { language: MIGRATIONS_FILE_LANGUAGE, ignorePattern: IGNORE_PATTERN } }).then( (migrationPath) => { console.log(format("Created migration -- %s", migrationPath)); process.exit(0); } ).catch((error) => { console.error(error); process.exit(1); }); } else if (action === "up" || action === "down" || action === "redo") { if (!DB_CONNECTION) { const cp = new ConnectionParameters(); if (!process.env[argv[databaseUrlVarArg]] && (!process.env.PGHOST || !cp.user || !cp.database)) { console.error( `The ${argv[databaseUrlVarArg]} environment variable is not set or incomplete connection parameters are provided.` ); process.exit(1); } DB_CONNECTION = cp; } const dryRun = argv[dryRunArg]; if (dryRun) { console.log("dry run"); } const singleTransaction = argv[singleTransactionArg]; const fake = argv[fakeArg]; const TIMESTAMP = argv[timestampArg]; const rejectUnauthorized = argv[rejectUnauthorizedArg]; const noLock = !argv[lockArg]; const lockValue = argv[lockValueArg]; if (noLock) { console.log("no lock"); } const upDownArg = argv._.length > 0 ? argv._[0] : null; let numMigrations; let migrationName; if (upDownArg !== null) { const parsedUpDownArg = Number.parseInt(`${upDownArg}`, 10); if (parsedUpDownArg == upDownArg) { numMigrations = parsedUpDownArg; } else { migrationName = argv._.join("-").replace(/_ /g, "-"); } } const databaseUrl = typeof DB_CONNECTION === "string" ? { connectionString: DB_CONNECTION } : DB_CONNECTION; const options = (direction, _count, _timestamp) => { const count = _count === void 0 ? numMigrations : _count; const timestamp = _timestamp === void 0 ? TIMESTAMP : _timestamp; return { dryRun, databaseUrl: { // eslint-disable-next-line @typescript-eslint/no-misused-spread ...databaseUrl, ...typeof rejectUnauthorized === "boolean" ? { ssl: { // TODO @Shinigami92 2024-04-05: Fix ssl could be boolean // @ts-expect-error: ignore possible boolean for now ...databaseUrl.ssl, rejectUnauthorized } } : void 0 }, // eslint-disable-next-line @typescript-eslint/no-non-null-assertion dir: MIGRATIONS_DIR, useGlob: USE_GLOB, ignorePattern: IGNORE_PATTERN, schema: SCHEMA, createSchema: CREATE_SCHEMA, migrationsSchema: MIGRATIONS_SCHEMA, createMigrationsSchema: CREATE_MIGRATIONS_SCHEMA, // eslint-disable-next-line @typescript-eslint/no-non-null-assertion migrationsTable: MIGRATIONS_TABLE, count, timestamp, file: migrationName, checkOrder: CHECK_ORDER, verbose: VERBOSE, direction, singleTransaction, noLock, lockValue, fake, decamelize: DECAMELIZE }; }; const promise = action === "redo" ? migrationRunner(options("down")).then( () => migrationRunner(options("up", Number.POSITIVE_INFINITY, false)) ) : migrationRunner(options(action)); promise.then(() => { console.log("Migrations complete!"); process.exit(0); }).catch((error) => { console.error(error); process.exit(1); }); } else { console.error("Invalid Action: Must be [up|down|create|redo]."); parser.showHelp(); process.exit(1); } if (argv["force-exit"]) { console.log("Forcing exit"); process.exit(0); }