ts-migrate-mongoose
Version:
A migration framework for Mongoose, built with TypeScript.
829 lines (814 loc) • 30.7 kB
JavaScript
import fs from 'node:fs';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { parseArgs } from 'node:util';
import mongoose, { Schema } from 'mongoose';
import readline from 'node:readline';
const chalk = {
red: (text) => `\x1B[31m${text}\x1B[0m`,
green: (text) => `\x1B[32m${text}\x1B[0m`,
yellow: (text) => `\x1B[33m${text}\x1B[0m`,
cyan: (text) => `\x1B[36m${text}\x1B[0m`,
dim: (text) => `\x1B[2m${text}\x1B[0m`,
bold: (text) => `\x1B[1m${text}\x1B[0m`
};
const defaults = {
MIGRATE_CONFIG_PATH: "./migrate",
MIGRATE_MONGO_COLLECTION: "migrations",
MIGRATE_MIGRATIONS_PATH: "./migrations",
MIGRATE_AUTOSYNC: false,
MIGRATE_CLI: false
};
const LINE = /(?:^|^)\s*(?:export\s+)?([\w.-]+)(?:\s*=\s*?|:\s+?)(\s*'(?:\\'|[^'])*'|\s*"(?:\\"|[^"])*"|\s*`(?:\\`|[^`])*`|[^#\r\n]+)?\s*(?:#.*)?(?:$|$)/gm;
const parse = (src) => {
const obj = {};
const lines = src.replaceAll(/\r\n?/gm, "\n");
for (const match of lines.matchAll(LINE)) {
const key = match[1];
let value = (match[2] ?? "").trim();
const maybeQuote = value[0];
value = value.replaceAll(/^(['"`])([\s\S]*)\1$/gm, "$2");
if (maybeQuote === '"') {
value = value.replaceAll(String.raw`\n`, "\n");
value = value.replaceAll(String.raw`\r`, "\r");
}
obj[key] = value;
}
return obj;
};
const populate = (parsed, override = false) => {
for (const [key, value] of Object.entries(parsed)) {
if (override || !(key in process.env)) {
process.env[key] = value;
}
}
};
const config = (options) => {
const envPath = options?.path ? path.resolve(options.path) : path.resolve(process.cwd(), ".env");
try {
const src = fs.readFileSync(envPath, "utf8");
const parsed = parse(src);
populate(parsed, options?.override);
return { parsed };
} catch (error) {
const err = error instanceof Error ? error : new Error("Failed to read .env file");
if (!options?.quiet) {
console.error(err.message);
}
return { parsed: {}, error: err };
}
};
const MIGRATION_FILE_EXTENSIONS = ["ts", "js", "mjs", "cjs"];
const MIGRATION_FILE_REGEX = new RegExp(String.raw`(?<!\.d)\.(${MIGRATION_FILE_EXTENSIONS.join("|")})$`);
const MIGRATION_NAME_REGEX = /^(?!.*\.\.)[A-Za-z0-9._-]+$/;
let loaded = false;
const loader = async () => {
if (loaded) return;
await import('tsx').then(() => {
loaded = true;
}).catch(() => {
loaded = false;
});
};
const getMigrationModel = (connection, collection) => {
const MigrationSchema = new Schema(
{
name: {
type: String,
required: true
},
state: {
type: String,
enum: ["down", "up"],
default: "down"
},
createdAt: {
type: Date,
index: true
},
updatedAt: {
type: Date
}
},
{
collection,
autoCreate: true,
timestamps: true
}
);
MigrationSchema.virtual("filename").get(function() {
return `${this.createdAt.getTime().toString()}-${this.name}`;
});
return connection.model(collection, MigrationSchema);
};
const ansi = {
cursorUp: (n) => `\x1B[${n}A`,
eraseDown: "\x1B[J",
cursorHide: "\x1B[?25l",
cursorShow: "\x1B[?25h"
};
const getPageSize = () => {
return Math.max((process.stdout.rows || 20) - 4, 5);
};
const checkbox = async (options) => {
const { message, choices } = options;
if (!process.stdin.isTTY) {
return choices.map((c) => c.value);
}
return new Promise((resolve) => {
let cursor = 0;
const selected = /* @__PURE__ */ new Set();
let pageSize = getPageSize();
let scrollOffset = 0;
function getVisibleRange() {
if (choices.length <= pageSize) {
return { start: 0, end: choices.length };
}
if (cursor < scrollOffset) {
scrollOffset = cursor;
} else if (cursor >= scrollOffset + pageSize) {
scrollOffset = cursor - pageSize + 1;
}
return { start: scrollOffset, end: Math.min(scrollOffset + pageSize, choices.length) };
}
function getRenderedLines() {
const { start, end } = getVisibleRange();
return end - start + 2;
}
let lastRenderedLines = 0;
const render = () => {
if (lastRenderedLines > 0) {
process.stdout.write(ansi.cursorUp(lastRenderedLines));
}
process.stdout.write(ansi.eraseDown);
const { start, end } = getVisibleRange();
let output = `${chalk.green("?")} ${chalk.bold(message)}
`;
for (let i = start; i < end; i++) {
const choice = choices[i];
const isActive = i === cursor;
const isChecked = selected.has(i);
const pointer = isActive ? chalk.cyan("\u276F") : " ";
const check = isChecked ? chalk.green("\u25C9") : chalk.dim("\u25EF");
output += `${pointer} ${check} ${choice.name}
`;
}
const hints = ["\u2191\u2193 navigate", "space select", "a all", "enter submit"];
if (choices.length > pageSize) {
hints.unshift(`${start + 1}-${end}/${choices.length}`);
}
output += chalk.dim(hints.join(" \u2022 "));
process.stdout.write(output);
lastRenderedLines = getRenderedLines();
};
const onResize = () => {
pageSize = getPageSize();
render();
};
process.stdout.write(ansi.cursorHide);
process.stdout.write("\n".repeat(getRenderedLines()));
lastRenderedLines = getRenderedLines();
render();
readline.emitKeypressEvents(process.stdin);
process.stdin.setRawMode(true);
process.stdin.resume();
const restoreCursor = () => {
process.stdout.write(ansi.cursorShow);
};
process.on("exit", restoreCursor);
process.on("SIGTERM", restoreCursor);
process.on("SIGHUP", restoreCursor);
process.stdout.on("resize", onResize);
const moveCursor = (direction) => {
cursor = (cursor + direction + choices.length) % choices.length;
render();
};
const toggleCurrent = () => {
selected.has(cursor) ? selected.delete(cursor) : selected.add(cursor);
render();
};
const toggleAll = () => {
if (selected.size === choices.length) {
selected.clear();
} else {
for (let i = 0; i < choices.length; i++) selected.add(i);
}
render();
};
const submit = () => {
cleanup();
if (lastRenderedLines > 0) {
process.stdout.write(ansi.cursorUp(lastRenderedLines));
}
process.stdout.write(ansi.eraseDown);
const sorted = [...selected].sort((a, b) => a - b);
const selectedValues = sorted.map((i) => choices[i].value);
const summary = sorted.length ? sorted.map((i) => choices[i].name).join(", ") : "none";
process.stdout.write(`${chalk.green("\u2714")} ${chalk.bold(message)} ${chalk.cyan(summary)}
`);
process.stdout.write(ansi.cursorShow);
resolve(selectedValues);
};
const keyActions = {
up: () => moveCursor(-1),
down: () => moveCursor(1),
space: toggleCurrent,
a: toggleAll,
return: submit
};
const onKeypress = (_str, key) => {
if (key.ctrl && key.name === "c") {
cleanup();
process.stdout.write(`${ansi.cursorShow}
`);
process.exit(130);
}
const action = key.name ? keyActions[key.name] : void 0;
if (action) action();
};
const cleanup = () => {
process.stdin.removeListener("keypress", onKeypress);
process.stdin.setRawMode(false);
process.stdin.pause();
process.removeListener("exit", restoreCursor);
process.removeListener("SIGTERM", restoreCursor);
process.removeListener("SIGHUP", restoreCursor);
process.stdout.removeListener("resize", onResize);
};
process.stdin.on("keypress", onKeypress);
});
};
const template = `// Import your schemas here
import type { Connection } from 'mongoose'
export async function up (connection: Connection): Promise<void> {
// Write migration here
}
export async function down (connection: Connection): Promise<void> {
// Write migration here
}
`;
var Env = /* @__PURE__ */ ((Env2) => {
Env2["MIGRATE_CONFIG_PATH"] = "MIGRATE_CONFIG_PATH";
Env2["MIGRATE_MONGO_COLLECTION"] = "MIGRATE_MONGO_COLLECTION";
Env2["MIGRATE_MIGRATIONS_PATH"] = "MIGRATE_MIGRATIONS_PATH";
Env2["MIGRATE_AUTOSYNC"] = "MIGRATE_AUTOSYNC";
Env2["MIGRATE_CLI"] = "MIGRATE_CLI";
Env2["MIGRATE_MODE"] = "MIGRATE_MODE";
Env2["MIGRATE_MONGO_URI"] = "MIGRATE_MONGO_URI";
Env2["MIGRATE_TEMPLATE_PATH"] = "MIGRATE_TEMPLATE_PATH";
return Env2;
})(Env || {});
const resolveMigrationFile = (basePath) => {
if (fs.existsSync(basePath)) {
return basePath;
}
for (const ext of MIGRATION_FILE_EXTENSIONS) {
const pathWithExt = `${basePath}.${ext}`;
if (fs.existsSync(pathWithExt)) {
return pathWithExt;
}
}
return basePath;
};
class Migrator {
migrationModel;
connection;
uri;
template;
migrationsPath;
collection;
autosync;
cli;
constructor(options) {
mongoose.set("strictQuery", false);
this.template = this.getTemplate(options.templatePath);
this.migrationsPath = path.resolve(options.migrationsPath ?? defaults.MIGRATE_MIGRATIONS_PATH);
this.collection = options.collection ?? defaults.MIGRATE_MONGO_COLLECTION;
this.autosync = options.autosync ?? defaults.MIGRATE_AUTOSYNC;
this.cli = options.cli ?? defaults.MIGRATE_CLI;
this.ensureMigrationsPath();
if (options.uri) {
this.uri = options.uri;
this.connection = mongoose.createConnection(this.uri, options.connectOptions);
} else {
throw new Error("No mongoose connection or mongo uri provided to migrator");
}
this.migrationModel = getMigrationModel(this.connection, this.collection);
}
/**
* Asynchronously creates a new migrator instance
*/
static async connect(options) {
await loader();
const migrator = new Migrator(options);
try {
await migrator.connected();
return migrator;
} catch (error) {
await migrator.close().catch(() => void 0);
throw error;
}
}
/**
* Close the underlying connection to mongo
*/
async close() {
await this.connection.close();
}
/**
* Lists all migrations in the database and their status
*/
async list() {
await this.sync();
const migrations = await this.migrationModel.find().sort({ createdAt: 1, _id: 1 }).exec();
if (!migrations.length) this.log(chalk.yellow("There are no migrations to list"));
return migrations.map((migration) => {
this.logMigrationStatus(migration.state, migration.filename);
return migration;
});
}
/**
* Create a new migration file
*/
async create(migrationName) {
if (!MIGRATION_NAME_REGEX.test(migrationName)) {
throw new Error(`Invalid migration name '${migrationName}'. Allowed characters: letters, digits, underscore, hyphen, and non-consecutive dots.`);
}
const existingMigration = await this.migrationModel.findOne({ name: migrationName }).exec();
if (existingMigration) {
throw new Error(`There is already a migration with name '${migrationName}' in the database`);
}
await this.sync();
const now = Date.now();
const newMigrationFile = `${now.toString()}-${migrationName}.ts`;
fs.writeFileSync(path.join(this.migrationsPath, newMigrationFile), this.template);
const migrationCreated = await this.migrationModel.create({
name: migrationName,
createdAt: now
});
this.log(`${chalk.green("Created")} migration ${chalk.cyan(migrationName)} in ${this.migrationsPath}`);
return migrationCreated;
}
/**
* Runs migrations up to or down to a given migration name
*/
async run(direction, migrationName, single = false) {
if (migrationName !== void 0 && typeof migrationName !== "string") {
throw new Error(`migrationName must be a string, received ${typeof migrationName}`);
}
await this.sync();
const isUp = direction === "up";
const state = isUp ? "down" : "up";
const key = isUp ? "$lte" : "$gte";
const sort = isUp ? 1 : -1;
const untilMigration = await this.findUntilMigration(migrationName, state, single, sort);
if (!untilMigration) {
if (migrationName) {
throw new Error(`Could not find migration with name '${migrationName}' in the database`);
}
return this.noPendingMigrations();
}
const migrationsToRun = await this.collectMigrationsToRun(untilMigration, key, state, single, sort);
if (!migrationsToRun.length) {
return this.noPendingMigrations();
}
const migrationsRan = await this.runMigrations(migrationsToRun, direction);
if (migrationsToRun.length === migrationsRan.length && migrationsRan.length > 0) {
this.log(chalk.green("All migrations finished successfully"));
}
return migrationsRan;
}
/**
* Resolves the `untilMigration` boundary document for `run()`. Either the
* explicitly-named migration, or the most-recent/least-recent candidate
* in the target state depending on whether we're running a single step
* or a full batch.
*/
async findUntilMigration(migrationName, state, single, sort) {
if (migrationName) {
return this.migrationModel.findOne({ name: migrationName }).exec();
}
const tieBreaker = single ? sort : -sort;
return this.migrationModel.findOne({ state }).sort({ createdAt: tieBreaker, _id: tieBreaker }).exec();
}
/**
* Builds the ordered list of migrations to execute for `run()`. In single
* mode, returns just the boundary doc; otherwise returns every pending
* migration on the same side of the boundary, sorted deterministically.
*/
async collectMigrationsToRun(untilMigration, key, state, single, sort) {
if (single) {
return [untilMigration];
}
const query = {
createdAt: { [key]: untilMigration.createdAt },
state
};
return this.migrationModel.find(query).sort({ createdAt: sort, _id: sort }).exec();
}
/**
* Looks at the file system migrations and imports any migrations that are
* on the file system but missing in the database into the database
*
* This functionality is opposite of prune()
*/
async sync() {
try {
const { migrationsInFs } = await this.getMigrations();
let migrationsToImport = migrationsInFs.filter((file) => !file.existsInDatabase).map((file) => file.filename);
migrationsToImport = await this.choseMigrations(migrationsToImport, "The following migrations exist in the migrations folder but not in the database.\nSelect the ones you want to import into the database");
return this.syncMigrations(migrationsToImport);
} catch (error) {
throw new Error("Could not synchronize migrations in the migrations folder up to the database", { cause: error });
}
}
/**
* Removes files in migration directory which don't exist in database.
* This is useful when you want to remove old migrations from the file system
* And then remove them from the database using prune()
*
* This functionality is opposite of sync().
*/
async prune() {
try {
let migrationsDeleted = [];
const { migrationsInDb, migrationsInFs } = await this.getMigrations();
let migrationsToDelete = migrationsInDb.filter((migration) => !migrationsInFs.some((file) => file.filename === migration.filename)).map((migration) => migration.name);
migrationsToDelete = await this.choseMigrations(migrationsToDelete, "The following migrations exist in the database but not in the migrations folder.\nSelect the ones you want to remove from the database");
if (migrationsToDelete.length) {
migrationsDeleted = await this.migrationModel.find({ name: { $in: migrationsToDelete } }).exec();
this.log(`${chalk.red("Removing")} migration(s) from database:
${chalk.cyan(migrationsToDelete.join("\n"))}`);
await this.migrationModel.deleteMany({ name: { $in: migrationsToDelete } }).exec();
}
return migrationsDeleted;
} catch (error) {
throw new Error("Could not prune extraneous migrations from database", { cause: error });
}
}
/**
* Logs a message to the console if there are no pending migrations
* In cli mode, it also lists all migrations and their status
*/
async noPendingMigrations() {
this.log(chalk.yellow("There are no pending migrations"));
if (this.cli) {
this.log(chalk.cyan("Current migrations status:"));
await this.list();
}
return [];
}
/**
* Logs a message to the console if the migrator is running in cli mode or if force is true
*/
log(message) {
if (this.cli) {
console.log(message);
}
}
/**
* Logs migration status to the console
*/
logMigrationStatus(direction, filename) {
const color = direction === "up" ? "green" : "red";
const directionWithColor = chalk[color](`${direction}:`);
this.log(`${directionWithColor} ${filename}`);
}
/**
* Gets template from file system
*/
getTemplate(templatePath) {
if (templatePath && fs.existsSync(templatePath)) {
return fs.readFileSync(templatePath, "utf8");
}
return template;
}
/**
* Ensures that the migrations path exists
*/
ensureMigrationsPath() {
if (!fs.existsSync(this.migrationsPath)) {
fs.mkdirSync(this.migrationsPath, { recursive: true });
}
}
/**
* Connection status of the migrator to the database
*/
async connected() {
return this.connection.asPromise();
}
/**
* Creates a new migration in database to reflect the changes in file system
*/
async syncMigrations(migrationsInFs) {
const promises = migrationsInFs.map(async (filename) => {
const filePath = path.join(this.migrationsPath, filename);
const timestampSeparatorIndex = filename.indexOf("-");
const timestamp = filename.slice(0, timestampSeparatorIndex);
const migrationName = filename.slice(timestampSeparatorIndex + 1);
this.log(`${chalk.green("Adding")} migration ${chalk.cyan(filePath)} into database. State is ${chalk.red("down")}`);
return this.migrationModel.create({
name: migrationName,
createdAt: timestamp
});
});
return Promise.all(promises);
}
/**
* Get migrations in database and in file system at the same time
*/
async getMigrations() {
const files = fs.readdirSync(this.migrationsPath);
const migrationsInDb = await this.migrationModel.find({}).exec();
const migrationsInFs = files.filter((filename) => /^\d{13,}-/.test(filename) && MIGRATION_FILE_REGEX.test(filename)).map((filename) => {
const filenameWithoutExtension = filename.replace(MIGRATION_FILE_REGEX, "");
const [time] = filename.split("-");
const timestamp = Number.parseInt(time ?? "", 10);
const createdAt = new Date(timestamp);
const existsInDatabase = migrationsInDb.some((migration) => filenameWithoutExtension === migration.filename);
return { createdAt, filename: filenameWithoutExtension, existsInDatabase };
});
return { migrationsInDb, migrationsInFs };
}
/**
* Creates a prompt for the user to chose the migrations to run
*/
async choseMigrations(migrations, message) {
if (!this.autosync && migrations.length) {
const selected = await checkbox({
message,
choices: migrations.map((migration) => ({ name: migration, value: migration }))
});
return selected;
}
return migrations;
}
/**
* Run migrations in a given direction
*/
async runMigrations(migrationsToRun, direction) {
const migrationsRan = [];
const migrationsRoot = path.resolve(this.migrationsPath) + path.sep;
for (const migration of migrationsToRun) {
const baseMigrationPath = path.resolve(path.join(this.migrationsPath, migration.filename));
if (!baseMigrationPath.startsWith(migrationsRoot)) {
throw new Error(`Refusing to import migration '${migration.filename}' \u2014 resolved path escapes the migrations directory.`);
}
const migrationFilePath = resolveMigrationFile(baseMigrationPath);
const fileUrl = pathToFileURL(migrationFilePath).href;
const migrationFunctions = await import(fileUrl);
const migrationFunction = migrationFunctions.default?.[direction] ?? migrationFunctions[direction];
if (!migrationFunction) {
throw new Error(`The '${direction}' export is not defined in ${migration.filename}.`);
}
try {
await migrationFunction(this.connection);
this.logMigrationStatus(direction, migration.filename);
await this.migrationModel.updateMany({ name: migration.name }, { $set: { state: direction } }).exec();
migrationsRan.push(migration);
} catch (error) {
throw new Error(`Failed to run migration with name '${migration.name}' due to an error`, { cause: error });
}
}
return migrationsRan;
}
}
const fileExists = async (filePath) => {
return fs.promises.access(filePath).then(() => true).catch(() => false);
};
const resolveConfigPath = async (configPath) => {
const validExtensions = [".ts", ".js", ".json"];
const message = `Config file must have an extension of ${validExtensions.join(", ")}`;
const extension = path.extname(configPath);
if (extension) {
if (!validExtensions.includes(extension)) {
throw new Error(message);
}
return path.resolve(configPath);
}
for (const ext of validExtensions) {
const configFilePath = path.resolve(configPath + ext);
const exists = await fileExists(configFilePath);
if (exists) {
return configFilePath;
}
}
throw new Error(message);
};
const loadModule = async (configPath) => {
const resolvedConfig = await resolveConfigPath(configPath);
const fileUrl = pathToFileURL(resolvedConfig).href;
await loader();
const extension = path.extname(resolvedConfig);
if (extension === ".ts") {
return await import(fileUrl);
}
return await import(fileUrl);
};
const extractOptions = (module) => {
if (module.default) {
return "default" in module.default ? module.default.default : module.default;
}
return module;
};
const logError = (error) => {
if (error instanceof Error) {
console.error(chalk.red(error.message));
}
};
const getConfig = async (configPath, quiet = false) => {
let configOptions = {};
if (configPath) {
try {
const configFilePath = path.resolve(configPath);
const module = await loadModule(configFilePath);
const fileOptions = extractOptions(module);
if (fileOptions) {
configOptions = fileOptions;
}
} catch (error) {
if (!quiet) logError(error);
configOptions = {};
}
}
return configOptions;
};
const toCamelCase = (str) => {
return str.toLocaleLowerCase().replaceAll(/_([a-z])/g, (g) => g[1] ? g[1].toUpperCase() : "");
};
const getEnv = (key) => {
return process.env[key] ?? process.env[toCamelCase(key)];
};
const getEnvBoolean = (key) => {
const value = getEnv(key);
return value === "true" ? true : void 0;
};
const getMigrator = async (options) => {
config({ path: ".env", quiet: true });
config({ path: ".env.local", quiet: true, override: true });
const mode = options.mode ?? getEnv(Env.MIGRATE_MODE);
if (mode) {
config({ path: `.env.${mode}`, quiet: true, override: true });
config({ path: `.env.${mode}.local`, quiet: true, override: true });
}
const configPath = options.configPath ?? getEnv(Env.MIGRATE_CONFIG_PATH) ?? defaults.MIGRATE_CONFIG_PATH;
const isDefaultConfig = configPath === defaults.MIGRATE_CONFIG_PATH;
const fileOptions = await getConfig(configPath, isDefaultConfig);
const uri = options.uri ?? getEnv(Env.MIGRATE_MONGO_URI) ?? fileOptions.uri;
const connectOptions = fileOptions.connectOptions;
const collection = options.collection ?? getEnv(Env.MIGRATE_MONGO_COLLECTION) ?? fileOptions.collection ?? defaults.MIGRATE_MONGO_COLLECTION;
const migrationsPath = options.migrationsPath ?? getEnv(Env.MIGRATE_MIGRATIONS_PATH) ?? fileOptions.migrationsPath ?? defaults.MIGRATE_MIGRATIONS_PATH;
const templatePath = options.templatePath ?? getEnv(Env.MIGRATE_TEMPLATE_PATH) ?? fileOptions.templatePath;
const autosync = Boolean(options.autosync ?? getEnvBoolean(Env.MIGRATE_AUTOSYNC) ?? fileOptions.autosync ?? defaults.MIGRATE_AUTOSYNC);
if (!uri) {
throw new Error("You need to provide the MongoDB Connection URI to persist migration status.\nUse option --uri / -d to provide the URI.");
}
const migratorOptions = {
migrationsPath,
uri,
collection,
autosync,
cli: true
};
if (templatePath) {
migratorOptions.templatePath = templatePath;
}
if (connectOptions) {
migratorOptions.connectOptions = connectOptions;
}
return Migrator.connect(migratorOptions);
};
const commands = [
{ usage: "list", description: "list all migrations" },
{ usage: "create <migration-name>", description: "create a new migration file" },
{ usage: "up [migration-name]", description: "run all migrations or a specific migration if name provided" },
{ usage: "down <migration-name>", description: "roll back migrations down to given name" },
{ usage: "prune", description: "delete extraneous migrations from migration folder or database" }
];
const optionDefs = {
"config-path": { type: "string", short: "f", arg: "<path>", description: "path to the config file" },
uri: { type: "string", short: "d", arg: "<string>", description: "mongo connection string" },
collection: { type: "string", short: "c", arg: "<string>", description: "collection name to use for the migrations" },
autosync: { type: "string", short: "a", arg: "<boolean>", description: "automatically sync new migrations without prompt" },
"migrations-path": { type: "string", short: "m", arg: "<path>", description: "path to the migration files" },
"template-path": { type: "string", short: "t", arg: "<path>", description: "template file to use when creating a migration" },
mode: { type: "string", arg: "<string>", description: "environment mode to use .env.[mode] file" },
single: { type: "boolean", short: "s", description: "run single migration (up/down only)", default: false },
help: { type: "boolean", short: "h", description: "display help" },
version: { type: "boolean", short: "v", description: "display version" }
};
const formatHelp = () => {
const lines = [chalk.cyan("CLI migration tool for mongoose"), "", "Usage: migrate <command> [options]", "", "Commands:"];
const cmdPadding = Math.max(...commands.map((c) => c.usage.length)) + 4;
for (const cmd of commands) {
lines.push(` ${cmd.usage.padEnd(cmdPadding)}${cmd.description}`);
}
lines.push("", "Options:");
const optEntries = Object.entries(optionDefs).map(([name, opt]) => {
const short = opt.short ? `-${opt.short}, ` : " ";
const arg = opt.arg ? ` ${opt.arg}` : "";
const long = `--${name}${arg}`;
return { flag: ` ${short}${long}`, description: opt.description };
});
const flagPadding = Math.max(...optEntries.map((e) => e.flag.length)) + 4;
for (const entry of optEntries) {
lines.push(`${entry.flag.padEnd(flagPadding)}${entry.description}`);
}
return `${lines.join("\n")}
`;
};
const parseArgsOptions = Object.fromEntries(Object.entries(optionDefs).map(([name, { description, arg, ...rest }]) => [name, rest]));
const parseOptions = {
options: parseArgsOptions,
allowPositionals: true
};
class Migrate {
migrator;
parsedOptions = {};
async finish(exit, error) {
if (this.migrator instanceof Migrator) {
await this.migrator.close();
}
if (error) {
console.error(chalk.red(error.message));
if (exit) process.exit(1);
throw error;
}
if (exit) process.exit(0);
return this.parsedOptions;
}
parseOptions(values) {
const options = {};
if (values["config-path"]) options.configPath = values["config-path"];
if (values.uri) options.uri = values.uri;
if (values.collection) options.collection = values.collection;
if (values.autosync !== void 0) options.autosync = values.autosync === "true";
if (values["migrations-path"]) options.migrationsPath = values["migrations-path"];
if (values["template-path"]) options.templatePath = values["template-path"];
if (values.mode) options.mode = values.mode;
return options;
}
async dispatch(command, positionals, single) {
switch (command) {
case "list": {
console.log(chalk.cyan("Listing migrations"));
await this.migrator.list();
break;
}
case "create": {
const migrationName = positionals[1];
if (!migrationName) throw new Error("Migration name is required for create command");
await this.migrator.create(migrationName);
const migrateUp = chalk.cyan(`migrate up ${migrationName}`);
console.log(`Migration created. Run ${migrateUp} to apply the migration`);
break;
}
case "up": {
await this.migrator.run("up", positionals[1], single);
break;
}
case "down": {
const migrationName = positionals[1];
if (!migrationName) throw new Error("Migration name is required for down command");
await this.migrator.run("down", migrationName, single);
break;
}
case "prune": {
await this.migrator.prune();
break;
}
default: {
console.error(formatHelp());
throw new Error(`Unknown command: ${command}`);
}
}
}
async run(exit = true) {
try {
const args = process.argv.slice(2);
const { values, positionals } = parseArgs({ ...parseOptions, args });
if (values.version) {
const pkg = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"));
console.log(pkg.version);
return await this.finish(exit);
}
const command = positionals[0];
if (values.help || !command) {
console.log(formatHelp());
return await this.finish(exit);
}
if (values.single && command !== "up" && command !== "down") {
throw new Error(`Option --single is only valid for 'up' and 'down' commands`);
}
this.parsedOptions = this.parseOptions(values);
this.migrator = await getMigrator(this.parsedOptions);
await this.dispatch(command, positionals, values.single);
return await this.finish(exit);
} catch (error) {
return await this.finish(exit, error instanceof Error ? error : new Error("An unknown error occurred", { cause: error }));
}
}
}
const migrate = new Migrate();
migrate.run();