diff --git a/.changeset/sharp-buses-draw.md b/.changeset/sharp-buses-draw.md new file mode 100644 index 0000000..1070334 --- /dev/null +++ b/.changeset/sharp-buses-draw.md @@ -0,0 +1,5 @@ +--- +'@emigrate/cli': minor +--- + +Add --limit option to the "up" command, for limiting the number of migrations to run diff --git a/docs/src/content/docs/commands/up.mdx b/docs/src/content/docs/commands/up.mdx index 2c2dd8a..c80a715 100644 --- a/docs/src/content/docs/commands/up.mdx +++ b/docs/src/content/docs/commands/up.mdx @@ -64,6 +64,11 @@ Show command help and exit List the pending migrations that would be run without actually running them +### `-l, --limit ` + +Limit the number of migrations to run. Can be combined with `--dry` which will show "pending" for the migrations that would be run if not in dry-run mode, +and "skipped" for the migrations that also haven't been run but won't because of the set limit. + ### `-d`, `--directory ` The directory where the migration files are located. The given path should be absolute or relative to the current working directory. diff --git a/packages/cli/README.md b/packages/cli/README.md index 19f8c42..f398240 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -20,6 +20,35 @@ bun add @emigrate/cli ## Usage +```text +Usage: emigrate up [options] + +Run all pending migrations + +Options: + + -h, --help Show this help message and exit + -d, --directory The directory where the migration files are located (required) + -i, --import Additional modules/packages to import before running the migrations (can be specified multiple times) + For example if you want to use Dotenv to load environment variables or when using TypeScript + -s, --storage The storage to use for where to store the migration history (required) + -p, --plugin The plugin(s) to use (can be specified multiple times) + -r, --reporter The reporter to use for reporting the migration progress + -l, --limit Limit the number of migrations to run + --dry List the pending migrations that would be run without actually running them + --color Force color output (this option is passed to the reporter) + --no-color Disable color output (this option is passed to the reporter) + +Examples: + + emigrate up --directory src/migrations -s fs + emigrate up -d ./migrations --storage @emigrate/mysql + emigrate up -d src/migrations -s postgres -r json --dry + emigrate up -d ./migrations -s mysql --import dotenv/config +``` + +### Examples + Create a new migration: ```bash diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 5bec5df..203287a 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -48,6 +48,10 @@ const up: Action = async (args) => { type: 'string', short: 's', }, + limit: { + type: 'string', + short: 'l', + }, dry: { type: 'boolean', }, @@ -80,6 +84,7 @@ Options: -s, --storage The storage to use for where to store the migration history (required) -p, --plugin The plugin(s) to use (can be specified multiple times) -r, --reporter The reporter to use for reporting the migration progress + -l, --limit Limit the number of migrations to run --dry List the pending migrations that would be run without actually running them --color Force color output (this option is passed to the reporter) --no-color Disable color output (this option is passed to the reporter) @@ -104,15 +109,34 @@ Examples: storage = config.storage, reporter = config.reporter, dry, + limit: limitString, import: imports = [], } = values; const plugins = [...(config.plugins ?? []), ...(values.plugin ?? [])]; + const limit = limitString === undefined ? undefined : Number.parseInt(limitString, 10); + + if (Number.isNaN(limit)) { + console.error('Invalid limit value, expected an integer but was:', limitString); + console.log(usage); + process.exitCode = 1; + return; + } + await importAll(cwd, imports); try { const { default: upCommand } = await import('./commands/up.js'); - process.exitCode = await upCommand({ storage, reporter, directory, plugins, cwd, dry, color: useColors(values) }); + process.exitCode = await upCommand({ + storage, + reporter, + directory, + plugins, + cwd, + dry, + limit, + color: useColors(values), + }); } catch (error) { if (error instanceof ShowUsageError) { console.error(error.message, '\n'); diff --git a/packages/cli/src/commands/up.test.ts b/packages/cli/src/commands/up.test.ts index 5cdae25..f9d42cd 100644 --- a/packages/cli/src/commands/up.test.ts +++ b/packages/cli/src/commands/up.test.ts @@ -198,7 +198,7 @@ describe('up', () => { const failedEntry = toEntry('some_failed_migration.js', 'failed'); const { reporter, run } = getUpCommand([failedEntry.name, 'some_file.js'], getStorage([failedEntry])); - const exitCode = await run(true); + const exitCode = await run({ dry: true }); assert.strictEqual(exitCode, 1); assert.strictEqual(reporter.onInit.mock.calls.length, 1); @@ -344,6 +344,88 @@ describe('up', () => { ); }); + it('returns 0 and finishes without an error when the given number of pending migrations are run successfully', async () => { + const { reporter, run } = getUpCommand( + ['some_already_run_migration.js', 'some_migration.js', 'some_other_migration.js'], + getStorage(['some_already_run_migration.js']), + [ + { + loadableExtensions: ['.js'], + async loadMigration() { + return async () => { + // Success + }; + }, + }, + ], + ); + + const exitCode = await run({ limit: 1 }); + + assert.strictEqual(exitCode, 0); + assert.strictEqual(reporter.onInit.mock.calls.length, 1); + assert.deepStrictEqual(reporter.onInit.mock.calls[0]?.arguments, [ + { + command: 'up', + cwd: '/emigrate', + version, + dry: false, + color: undefined, + directory: 'migrations', + }, + ]); + assert.strictEqual(reporter.onCollectedMigrations.mock.calls.length, 1); + assert.strictEqual(reporter.onLockedMigrations.mock.calls.length, 1); + assert.strictEqual(reporter.onMigrationStart.mock.calls.length, 1); + assert.strictEqual(reporter.onMigrationSuccess.mock.calls.length, 1); + assert.strictEqual(reporter.onMigrationError.mock.calls.length, 0); + assert.strictEqual(reporter.onMigrationSkip.mock.calls.length, 1); + assert.strictEqual(reporter.onFinished.mock.calls.length, 1); + const [entries, error] = reporter.onFinished.mock.calls[0]?.arguments ?? []; + assert.strictEqual(error, undefined); + assert.strictEqual(entries?.length, 2); + assert.deepStrictEqual( + entries.map((entry) => `${entry.name} (${entry.status})`), + ['some_migration.js (done)', 'some_other_migration.js (skipped)'], + ); + }); + + it('returns 0 and finishes without an error with the given number of pending migrations are validated and listed successfully in dry-mode', async () => { + const { reporter, run } = getUpCommand( + ['some_already_run_migration.js', 'some_migration.js', 'some_other_migration.js'], + getStorage(['some_already_run_migration.js']), + ); + + const exitCode = await run({ dry: true, limit: 1 }); + + assert.strictEqual(exitCode, 0); + assert.strictEqual(reporter.onInit.mock.calls.length, 1); + assert.deepStrictEqual(reporter.onInit.mock.calls[0]?.arguments, [ + { + command: 'up', + cwd: '/emigrate', + version, + dry: true, + color: undefined, + directory: 'migrations', + }, + ]); + assert.strictEqual(reporter.onCollectedMigrations.mock.calls.length, 1); + assert.strictEqual(reporter.onLockedMigrations.mock.calls.length, 1); + assert.strictEqual(reporter.onMigrationStart.mock.calls.length, 0); + assert.strictEqual(reporter.onMigrationSuccess.mock.calls.length, 0); + assert.strictEqual(reporter.onMigrationError.mock.calls.length, 0); + assert.strictEqual(reporter.onMigrationSkip.mock.calls.length, 2); + assert.strictEqual(reporter.onFinished.mock.calls.length, 1); + const [entries, error] = reporter.onFinished.mock.calls[0]?.arguments ?? []; + assert.strictEqual(error, undefined); + assert.strictEqual(entries?.length, 2); + assert.deepStrictEqual( + entries.map((entry) => `${entry.name} (${entry.status})`), + ['some_migration.js (pending)', 'some_other_migration.js (skipped)'], + ); + }); + it('returns 1 and finishes with an error when a pending migration throw when run', async () => { const { reporter, run } = getUpCommand( ['some_already_run_migration.js', 'some_migration.js', 'fail.js', 'some_other_migration.js'], @@ -495,7 +577,12 @@ function getUpCommand(migrationFiles: string[], storage?: Mocked, plugi onMigrationSkip: mock.fn(noop), }; - const run = async (dry = false) => { + const run = async ( + options?: Omit< + Parameters[0], + 'cwd' | 'directory' | 'storage' | 'reporter' | 'plugins' | 'getMigrations' + >, + ) => { return upCommand({ cwd: '/emigrate', directory: 'migrations', @@ -509,11 +596,11 @@ function getUpCommand(migrationFiles: string[], storage?: Mocked, plugi }, }, reporter, - dry, plugins: plugins ?? [], async getMigrations(cwd, directory) { return toMigrations(cwd, directory, migrationFiles); }, + ...options, }); }; diff --git a/packages/cli/src/commands/up.ts b/packages/cli/src/commands/up.ts index c260c46..9613982 100644 --- a/packages/cli/src/commands/up.ts +++ b/packages/cli/src/commands/up.ts @@ -14,6 +14,7 @@ import { version } from '../get-package-info.js'; type ExtraFlags = { cwd: string; dry?: boolean; + limit?: number; getMigrations?: GetMigrationsFunction; }; @@ -25,6 +26,7 @@ export default async function upCommand({ reporter: reporterConfig, directory, color, + limit, dry = false, plugins = [], cwd, @@ -83,6 +85,7 @@ export default async function upCommand({ const error = await migrationRunner({ dry, + limit, reporter, storage, migrations: await arrayFromAsync(collectedMigrations), diff --git a/packages/cli/src/migration-runner.ts b/packages/cli/src/migration-runner.ts index 8cdf88c..f62011e 100644 --- a/packages/cli/src/migration-runner.ts +++ b/packages/cli/src/migration-runner.ts @@ -15,6 +15,7 @@ import { getDuration } from './get-duration.js'; type MigrationRunnerParameters = { dry: boolean; + limit?: number; reporter: EmigrateReporter; storage: Storage; migrations: Array; @@ -24,6 +25,7 @@ type MigrationRunnerParameters = { export const migrationRunner = async ({ dry, + limit, reporter, storage, migrations, @@ -70,7 +72,12 @@ export const migrationRunner = async ({ } } - const [lockedMigrations, lockError] = dry ? [migrationsToRun] : await exec(async () => storage.lock(migrationsToRun)); + const migrationsToLock = limit ? migrationsToRun.slice(0, limit) : migrationsToRun; + const migrationsToSkip = limit ? migrationsToRun.slice(limit) : []; + + const [lockedMigrations, lockError] = dry + ? [migrationsToLock] + : await exec(async () => storage.lock(migrationsToLock)); if (lockError) { for await (const migration of migrationsToRun) { @@ -152,6 +159,17 @@ export const migrationRunner = async ({ } } + for await (const migration of migrationsToSkip) { + const finishedMigration: MigrationMetadataFinished = { + ...migration, + status: 'skipped', + }; + + await reporter.onMigrationSkip?.(finishedMigration); + + finishedMigrations.push(finishedMigration); + } + const [, unlockError] = dry ? [] : await exec(async () => storage.unlock(lockedMigrations ?? [])); // eslint-disable-next-line unicorn/no-array-callback-reference