node-pg-migrate
Version:
PostgreSQL database migration management tool for node.js
531 lines (530 loc) • 17.1 kB
JavaScript
#!/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);
}