ts-migrate-mongoose
Version:
A migration framework for Mongoose, built with TypeScript.
288 lines (243 loc) • 8.98 kB
text/typescript
import fs from 'node:fs'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
import { Command } from 'commander'
import { config } from 'dotenv'
import { chalk } from './chalk'
import { defaults } from './defaults'
import { Env, Migrator } from './index'
import { loader } from './loader'
import type { ConfigOptions, ConfigOptionsDefault, MigratorOptions } from './types'
/**
* Checks if a file exists at the given path.
* @param filePath The path to the file
* @returns A promise that resolves to true if the file exists, false otherwise
*/
const fileExists = async (filePath: string): Promise<boolean> => {
return fs.promises
.access(filePath)
.then(() => true)
.catch(() => false)
}
/**
* Resolves the config path by checking for valid extensions.
* @param configPath The path to the config file
* @returns A promise that resolves to the resolved config path
* @throws Error if the config file does not have a valid extension
*/
const resolveConfigPath = async (configPath: string): Promise<string> => {
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) {
console.log(`Found config file: ${configFilePath}`)
return configFilePath
}
}
throw new Error(message)
}
/**
* Loads a module from the given config path.
*/
const loadModule = async (configPath: string): Promise<{ default?: ConfigOptionsDefault | ConfigOptions }> => {
const config = await resolveConfigPath(configPath)
const fileUrl = pathToFileURL(config).href
await loader()
const extension = path.extname(config)
if (extension === '.ts') {
return (await import(fileUrl)) as { default?: ConfigOptionsDefault | ConfigOptions }
}
return await import(fileUrl)
}
/**
* Extracts options from the loaded module.
*/
const extractOptions = (module: { default?: ConfigOptionsDefault | ConfigOptions }): ConfigOptions | undefined => {
if (module.default) {
return 'default' in module.default ? module.default.default : (module.default as ConfigOptions)
}
return module as ConfigOptions
}
/**
* Logs an error message to the console.
*/
const logError = (error: unknown): void => {
if (error instanceof Error) {
console.log(chalk.red(error.message))
}
}
/**
* Get the options from the config file
*/
export const getConfig = async (configPath: string): Promise<ConfigOptions> => {
let configOptions: ConfigOptions = {}
if (configPath) {
try {
const configFilePath = path.resolve(configPath)
const module = await loadModule(configFilePath)
const fileOptions = extractOptions(module)
if (fileOptions) {
configOptions = fileOptions
}
} catch (error) {
logError(error)
configOptions = {}
}
}
return configOptions
}
/**
* Converts a string to camel case.
*/
export const toCamelCase = (str: Env): string => {
return str.toLocaleLowerCase().replace(/_([a-z])/g, (g) => (g[1] ? g[1].toUpperCase() : ''))
}
/**
* Gets an environment variable.
*/
export const getEnv = (key: Env): string | undefined => {
// To automatically support camelCase keys
return process.env[key] ?? process.env[toCamelCase(key)]
}
/**
* Gets a boolean environment variable.
*/
export const getEnvBoolean = (key: Env): boolean | undefined => {
const value = getEnv(key)
return value === 'true' ? true : undefined
}
/**
* Get the migrator instance
*/
export const getMigrator = async (options: ConfigOptions): Promise<Migrator> => {
config({ path: '.env' })
config({ path: '.env.local', override: true })
const mode = options.mode ?? getEnv(Env.MIGRATE_MODE)
if (mode) {
config({ path: `.env.${mode}`, override: true })
config({ path: `.env.${mode}.local`, override: true })
}
const configPath = options.configPath ?? getEnv(Env.MIGRATE_CONFIG_PATH) ?? defaults.MIGRATE_CONFIG_PATH
const fileOptions = await getConfig(configPath)
// No default value always required
const uri = options.uri ?? getEnv(Env.MIGRATE_MONGO_URI) ?? fileOptions.uri
// Connect options can be only provided in the config file for cli usage
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
// Can be empty then we use default template
const autosync = Boolean(options.autosync ?? getEnvBoolean(Env.MIGRATE_AUTOSYNC) ?? fileOptions.autosync ?? defaults.MIGRATE_AUTOSYNC)
if (!uri) {
const message = chalk.red('You need to provide the MongoDB Connection URI to persist migration status.\nUse option --uri / -d to provide the URI.')
throw new Error(message)
}
const migratorOptions: MigratorOptions = {
migrationsPath,
uri,
collection,
autosync,
cli: true,
}
if (templatePath) {
migratorOptions.templatePath = templatePath
}
if (connectOptions) {
migratorOptions.connectOptions = connectOptions
}
return Migrator.connect(migratorOptions)
}
/**
* This class is responsible for running migrations in the CLI
*/
export class Migrate {
private readonly program: Command
private migrator!: Migrator
constructor() {
this.program = new Command()
this.program
.name('migrate')
.description(chalk.cyan('CLI migration tool for mongoose'))
.option('-f, --config-path <path>', 'path to the config file')
.option('-d, --uri <string>', chalk.yellow('mongo connection string'))
.option('-c, --collection <string>', 'collection name to use for the migrations')
.option('-a, --autosync <boolean>', 'automatically sync new migrations without prompt')
.option('-m, --migrations-path <path>', 'path to the migration files')
.option('-t, --template-path <path>', 'template file to use when creating a migration')
.option('--mode <string>', 'environment mode to use .env.[mode] file')
.hook('preAction', async () => {
const options = this.program.opts<ConfigOptions>()
this.migrator = await getMigrator(options)
})
this.program
.command('list')
.description('list all migrations')
.action(async () => {
console.log(chalk.cyan('Listing migrations'))
await this.migrator.list()
})
this.program
.command('create <migration-name>')
.description('create a new migration file')
.action(async (migrationName: string) => {
await this.migrator.create(migrationName)
const migrateUp = chalk.cyan(`migrate up ${migrationName}`)
console.log(`Migration created. Run ${migrateUp} to apply the migration`)
})
this.program
.command('up [migration-name]')
.description('run all migrations or a specific migration if name provided')
.option('-s, --single', 'run single migration', false)
.action(async (migrationName?: string, options?: { single: boolean }) => {
await this.migrator.run('up', migrationName, options?.single)
})
this.program
.command('down <migration-name>')
.description('roll back migrations down to given name')
.option('-s, --single', 'run single migration', false)
.action(async (migrationName?: string, options?: { single: boolean }) => {
await this.migrator.run('down', migrationName, options?.single)
})
this.program
.command('prune')
.description('delete extraneous migrations from migration folder or database')
.action(async () => {
await this.migrator.prune()
})
}
/**
* Finish the CLI process
*/
public async finish(exit: boolean, error?: Error): Promise<ConfigOptions> {
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.program.opts<ConfigOptions>()
}
/**
* Run the CLI command
*/
public async run(exit = true): Promise<ConfigOptions> {
try {
await this.program.parseAsync(process.argv)
return await this.finish(exit)
} catch (error: unknown) {
return await this.finish(exit, error instanceof Error ? error : new Error('An unknown error occurred'))
}
}
}