node-pg-migrate
Version:
PostgreSQL database migration management tool for node.js
668 lines (618 loc) • 20.2 kB
text/typescript
#!/usr/bin/env node
import type { DotenvConfigOptions } from 'dotenv';
// Import as node-pg-migrate, so tsup does not self-reference as '../dist'
// otherwise this could not be imported by esm
// @ts-ignore: when a clean was made, the types are not present in the first run
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 type { ClientConfig } from 'pg';
import type ConnectionParametersType from 'pg/lib/connection-parameters';
// TODO causes tests to fail when `.js` is removed
// @ts-expect-error type exports from @types/pg doesn't match importing
import ConnectionParameters from 'pg/lib/connection-parameters.js';
import yargs from 'yargs/yargs';
import type { RunnerOption } from '../src';
import type { FilenameFormat } from '../src/migration';
process.on('uncaughtException', (err) => {
console.error(err);
process.exit(1);
});
/**
* Try to import a module and return null if it doesn't exist.
*
* @param moduleName The name of the module to import.
*/
async function tryImport<TModule = unknown>(
moduleName: string
): Promise<TModule | null> {
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);
}
/* Load env before accessing process.env */
const envPath = argv[envPathArg];
// Create default dotenv config
const dotenvConfig: DotenvConfigOptions & { silent: boolean } = {
// TODO @Shinigami92 2024-04-05: Does the silent option even still exists and do anything?
silent: true,
};
// If the path has been configured, add it to the config, otherwise don't change the default dotenv path
if (envPath) {
dotenvConfig.path = envPath;
}
const dotenv = await tryImport<typeof import('dotenv')>('dotenv');
if (dotenv) {
// Load config from ".env" file
const myEnv = dotenv.config(dotenvConfig);
const dotenvExpand =
await tryImport<typeof import('dotenv-expand')>('dotenv-expand');
if (dotenvExpand && dotenvExpand.expand) {
dotenvExpand.expand(myEnv);
}
}
let MIGRATIONS_DIR = argv[migrationsDirArg];
let USE_GLOB = argv[useGlobArg];
let DB_CONNECTION:
| string
| ConnectionParametersType
| ClientConfig
| undefined = process.env[argv[databaseUrlVarArg]];
let IGNORE_PATTERN = argv[ignorePatternArg];
let SCHEMA: string | string[] | undefined = 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: 'js' | 'ts' | 'sql' | undefined = argv[
migrationFileLanguageArg
] as 'js' | 'ts' | 'sql' | undefined;
let MIGRATIONS_FILENAME_FORMAT: FilenameFormat | undefined = argv[
migrationFilenameFormatArg
] as FilenameFormat | undefined;
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(): Promise<void> {
if (tsconfigPath) {
let tsconfig;
const json5 = await tryImport<typeof import('json5')>('json5');
try {
const config = readFileSync(resolve(cwd(), tsconfigPath), {
encoding: 'utf8',
});
tsconfig = json5 ? json5.parse(config) : JSON.parse(config);
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<typeof import('ts-node')>('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<TArg, TKey extends string = string>(
arg: TArg,
key: TKey,
obj: { [k in TKey]?: unknown },
condition: (val: (typeof obj)[TKey]) => val is TArg
): TArg {
if (arg !== undefined && !(key in obj)) {
return arg;
}
const val = obj[key];
return condition(val) ? val : arg;
}
function isString(val: unknown): val is string {
return typeof val === 'string';
}
function isBoolean(val: unknown): val is boolean {
return typeof val === 'boolean';
}
function isClientConfig(val: unknown): val is ClientConfig & { name?: string } {
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: unknown): void {
if (typeof json === 'object' && json !== null) {
SCHEMA = applyIf(
SCHEMA,
schemaArg,
json,
(val): val is string | string[] =>
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 is 'js' | 'ts' | 'sql' =>
val === 'js' || val === 'ts' || val === 'sql'
);
MIGRATIONS_FILENAME_FORMAT = applyIf(
MIGRATIONS_FILENAME_FORMAT,
migrationFilenameFormatArg,
json,
(val): val is FilenameFormat => 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): val is string | ConnectionParametersType | ClientConfig =>
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 as string | ConnectionParametersType | ClientConfig;
}
}
// Load config (and suppress the no-config-warning)
const oldSuppressWarning = process.env.SUPPRESS_NO_CONFIG_WARNING;
process.env.SUPPRESS_NO_CONFIG_WARNING = 'yes';
const config = await tryImport<typeof import('config')>('config');
if (config?.has(argv[configValueArg])) {
const db = config.get(argv[configValueArg]);
readJson(db);
}
process.env.SUPPRESS_NO_CONFIG_WARNING = oldSuppressWarning;
const configFileName: string | undefined = 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] === undefined ? json : json[section]);
}
await readTsconfig();
if (useTsx) {
const tsx = await tryImport<typeof import('tsx/esm/api')>('tsx/esm');
if (!tsx) {
console.error("For TSX support, please install 'tsx' module");
}
}
const action = argv._.shift();
// defaults
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') {
// replaces spaces with dashes - should help fix some errors
let newMigrationName = argv._.length > 0 ? argv._.join('-') : '';
// forces use of dashes in names - keep thing clean
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(
(
// @ts-ignore: when a clean was made, the types are not present in the first run
migrationPath
) => {
console.log(format('Created migration -- %s', migrationPath));
process.exit(0);
}
)
.catch((error: unknown) => {
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: number;
let migrationName: string;
if (upDownArg !== null) {
const parsedUpDownArg = Number.parseInt(`${upDownArg}`, 10);
// eslint-disable-next-line eqeqeq
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: 'up' | 'down',
count?: number,
timestamp?: boolean
) => RunnerOption = (direction, _count, _timestamp) => {
const count = _count === undefined ? numMigrations : _count;
const timestamp = _timestamp === undefined ? 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,
},
}
: undefined),
} as ClientConfig,
// 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: unknown) => {
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);
}