From b56794a269d7004d2a170f059b8bb5e96d116eb1 Mon Sep 17 00:00:00 2001 From: Joakim Carlstein Date: Wed, 15 Nov 2023 16:14:10 +0100 Subject: [PATCH] feat(cli): implement the "up" command with support for "storage" and "loader" plugins --- .changeset/happy-toys-smash.md | 5 ++ packages/cli/src/cli.ts | 49 +++++++++----- packages/cli/src/up-command.ts | 117 +++++++++++++++++++++++++++++++++ 3 files changed, 155 insertions(+), 16 deletions(-) create mode 100644 .changeset/happy-toys-smash.md create mode 100644 packages/cli/src/up-command.ts diff --git a/.changeset/happy-toys-smash.md b/.changeset/happy-toys-smash.md new file mode 100644 index 0000000..43cfab3 --- /dev/null +++ b/.changeset/happy-toys-smash.md @@ -0,0 +1,5 @@ +--- +'@emigrate/cli': minor +--- + +Implement the "up" command with support for "storage" and "loader" plugins diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index f5f4a01..08ae505 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -7,6 +7,7 @@ import { getConfig } from './get-config.js'; type Action = (args: string[]) => Promise; const up: Action = async (args) => { + const config = await getConfig('up'); const { values } = parseArgs({ args, options: { @@ -14,10 +15,13 @@ const up: Action = async (args) => { type: 'boolean', short: 'h', }, - dir: { + directory: { type: 'string', short: 'd', }, + dry: { + type: 'boolean', + }, plugin: { type: 'string', short: 'p', @@ -28,33 +32,46 @@ const up: Action = async (args) => { allowPositionals: false, }); - const showHelp = !values.dir || values.help; - - if (!values.dir) { - console.error('Missing required option: --dir\n'); - } - - if (showHelp) { - console.log(`Usage: emigrate up [options] + const usage = `Usage: emigrate up [options] Run all pending migrations Options: - -h, --help Show this help message and exit - -d, --dir The directory where the migration files are located (required) - -p, --plugin The plugin(s) to use (can be specified multiple times) + -h, --help Show this help message and exit + -d, --directory The directory where the migration files are located (required) + -p, --plugin The plugin(s) to use (can be specified multiple times) + --dry List the pending migrations that would be run without actually running them Examples: - emigrate up --dir src/migrations - emigrate up --dir ./migrations --plugin @emigrate/plugin-storage-mysql -`); + emigrate up --directory src/migrations + emigrate up -d ./migrations --plugin @emigrate/plugin-storage-mysql + emigrate up -d src/migrations --dry +`; + + if (values.help) { + console.log(usage); process.exitCode = 1; return; } - console.log(values); + const { directory = config.directory, dry } = values; + const plugins = [...(config.plugins ?? []), ...(values.plugin ?? [])]; + + try { + const { default: upCommand } = await import('./up-command.js'); + await upCommand({ directory, plugins, dry }); + } catch (error) { + if (error instanceof ShowUsageError) { + console.error(error.message, '\n'); + console.log(usage); + process.exitCode = 1; + return; + } + + throw error; + } }; const newMigration: Action = async (args) => { diff --git a/packages/cli/src/up-command.ts b/packages/cli/src/up-command.ts new file mode 100644 index 0000000..b007edc --- /dev/null +++ b/packages/cli/src/up-command.ts @@ -0,0 +1,117 @@ +import process from 'node:process'; +import { getOrLoadPlugin, getOrLoadPlugins } from '@emigrate/plugin-tools'; +import { type LoaderPlugin } from '@emigrate/plugin-tools/types'; +import { ShowUsageError } from './show-usage-error.js'; +import { type Config } from './types.js'; +import { stripLeadingPeriod } from './strip-leading-period.js'; + +type ExtraFlags = { + dry?: boolean; +}; + +export default async function upCommand({ directory, dry, plugins = [] }: Config & ExtraFlags) { + if (!directory) { + throw new ShowUsageError('Missing required option: directory'); + } + + const storagePlugin = await getOrLoadPlugin('storage', plugins); + + if (!storagePlugin) { + throw new Error('No storage plugin found, please specify a storage plugin using the plugin option'); + } + + const storage = await storagePlugin.initializeStorage(); + const path = await import('node:path'); + const fs = await import('node:fs/promises'); + + const allFilesInMigrationDirectory = await fs.readdir(path.resolve(process.cwd(), directory), { + withFileTypes: true, + }); + + const migrationFiles = allFilesInMigrationDirectory + .filter((file) => file.isFile() && !file.name.startsWith('.') && !file.name.startsWith('_')) + .sort((a, b) => a.name.localeCompare(b.name)) + .map((file) => file.name); + + for await (const migrationHistoryEntry of storage.getHistory()) { + if (migrationFiles.includes(migrationHistoryEntry.name)) { + migrationFiles.splice(migrationFiles.indexOf(migrationHistoryEntry.name), 1); + } + } + + const migrationFileExtensions = new Set(migrationFiles.map((file) => stripLeadingPeriod(path.extname(file)))); + const loaderPlugins = await getOrLoadPlugins('loader', plugins); + + const loaderByExtension = new Map( + [...migrationFileExtensions].map( + (extension) => + [ + extension, + loaderPlugins.find((plugin) => + plugin.loadableExtensions.some((loadableExtension) => stripLeadingPeriod(loadableExtension) === extension), + ), + ] as const, + ), + ); + + for (const [extension, loader] of loaderByExtension) { + if (!loader) { + throw new Error(`No loader plugin found for file extension: ${extension}`); + } + } + + if (dry) { + console.log('Pending migrations:'); + console.log(migrationFiles.map((file) => ` - ${file}`).join('\n')); + console.log('\nDry run, exiting...'); + return; + } + + const lockedMigrationFiles = await storage.lock(migrationFiles); + + let cleaningUp = false; + + const cleanup = async () => { + if (cleaningUp) { + return; + } + + process.off('SIGINT', cleanup); + process.off('SIGTERM', cleanup); + + cleaningUp = true; + await storage.unlock(lockedMigrationFiles); + }; + + process.on('SIGINT', cleanup); + process.on('SIGTERM', cleanup); + + try { + for await (const name of lockedMigrationFiles) { + console.log(' -', name, '...'); + + const extension = stripLeadingPeriod(path.extname(name)); + const filename = path.resolve(process.cwd(), directory, name); + const loader = loaderByExtension.get(extension)!; + + const migration = await loader.loadMigration({ name, filename, extension }); + + try { + await migration(); + + console.log(' -', name, 'done'); + + await storage.onSuccess(name); + } catch (error) { + const errorInstance = error instanceof Error ? error : new Error(String(error)); + + console.error(' -', name, 'failed:', errorInstance.message); + + await storage.onError(name, errorInstance); + throw error; + } + } + } finally { + await cleanup(); + } +}