From 9ef0fa2776f8bf3820944ee714dfa5b2b9137ec6 Mon Sep 17 00:00:00 2001 From: Joakim Carlstein Date: Fri, 19 Jan 2024 10:34:06 +0100 Subject: [PATCH] feat(cli): add --from and --to options to limit what migrations to run --- .changeset/quiet-ravens-sleep.md | 5 + README.md | 35 ++ docs/src/content/docs/commands/up.mdx | 18 + packages/cli/README.md | 6 + packages/cli/src/cli.ts | 18 + packages/cli/src/commands/up.test.ts | 621 ++++++++++++++------------ packages/cli/src/commands/up.ts | 6 + packages/cli/src/migration-runner.ts | 122 +++-- 8 files changed, 485 insertions(+), 346 deletions(-) create mode 100644 .changeset/quiet-ravens-sleep.md diff --git a/.changeset/quiet-ravens-sleep.md b/.changeset/quiet-ravens-sleep.md new file mode 100644 index 0000000..24023cf --- /dev/null +++ b/.changeset/quiet-ravens-sleep.md @@ -0,0 +1,5 @@ +--- +'@emigrate/cli': minor +--- + +Add --from and --to CLI options to control which migrations to include or skip when executing migrations. diff --git a/README.md b/README.md index d494410..bd62417 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,41 @@ 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 + -f, --from Start running migrations from the given migration name, the given name doesn't need to exist + and is compared in lexicographical order + -t, --to Skip migrations after the given migration name, the given name doesn't need to exist + and is compared in lexicographical order + --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 + emigrate up --limit 1 + emigrate up --to 20231122120529381_some_migration_file.js +``` + +### Examples + Create a new migration: ```bash diff --git a/docs/src/content/docs/commands/up.mdx b/docs/src/content/docs/commands/up.mdx index c80a715..39d6151 100644 --- a/docs/src/content/docs/commands/up.mdx +++ b/docs/src/content/docs/commands/up.mdx @@ -73,6 +73,24 @@ and "skipped" for the migrations that also haven't been run but won't because of The directory where the migration files are located. The given path should be absolute or relative to the current working directory. +### `-f`, `--from ` + +The name of the migration to start from. This can be used to run only a subset of the pending migrations. + +The given migration name does not need to exist and is compared in lexicographical order with the migration names, so it can be a prefix of a migration name or similar. + +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 "from". + +### `-t`, `--to ` + +The name of the migration to end at. This can be used to run only a subset of the pending migrations. + +The given migration name does not need to exist and is compared in lexicographical order with the migration names, so it can be a prefix of a migration name or similar. + +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 "to". + ### `-i`, `--import ` A module to import before running the migrations. This option can be specified multiple times. diff --git a/packages/cli/README.md b/packages/cli/README.md index f398240..95945ef 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -35,6 +35,10 @@ Options: -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 + -f, --from Start running migrations from the given migration name, the given name doesn't need to exist + and is compared in lexicographical order + -t, --to Skip migrations after the given migration name, the given name doesn't need to exist + and is compared in lexicographical order --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) @@ -45,6 +49,8 @@ Examples: 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 + emigrate up --limit 1 + emigrate up --to 20231122120529381_some_migration_file.js ``` ### Examples diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 203287a..1ac7520 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -52,6 +52,14 @@ const up: Action = async (args) => { type: 'string', short: 'l', }, + from: { + type: 'string', + short: 'f', + }, + to: { + type: 'string', + short: 't', + }, dry: { type: 'boolean', }, @@ -85,6 +93,10 @@ Options: -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 + -f, --from Start running migrations from the given migration name, the given name doesn't need to exist + and is compared in lexicographical order + -t, --to Skip migrations after the given migration name, the given name doesn't need to exist + and is compared in lexicographical order --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) @@ -95,6 +107,8 @@ Examples: 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 + emigrate up --limit 1 + emigrate up --to 20231122120529381_some_migration_file.js `; if (values.help) { @@ -109,6 +123,8 @@ Examples: storage = config.storage, reporter = config.reporter, dry, + from, + to, limit: limitString, import: imports = [], } = values; @@ -135,6 +151,8 @@ Examples: cwd, dry, limit, + from, + to, color: useColors(values), }); } catch (error) { diff --git a/packages/cli/src/commands/up.test.ts b/packages/cli/src/commands/up.test.ts index f9d42cd..df4a07e 100644 --- a/packages/cli/src/commands/up.test.ts +++ b/packages/cli/src/commands/up.test.ts @@ -10,9 +10,11 @@ import { type SerializedError, type FailedMigrationHistoryEntry, type NonFailedMigrationHistoryEntry, + type MigrationMetadataFinished, } from '@emigrate/types'; import { deserializeError } from 'serialize-error'; import { version } from '../get-package-info.js'; +import { BadOptionError, MigrationHistoryError, MigrationRunError, StorageInitError } from '../errors.js'; import upCommand from './up.js'; type Mocked = { @@ -27,25 +29,7 @@ describe('up', () => { const exitCode = await run(); assert.strictEqual(exitCode, 0); - assert.strictEqual(reporter.onInit.mock.calls.length, 1); - assert.deepStrictEqual(reporter.onInit.mock.calls[0]?.arguments, [ - { - command: 'up', - cwd: '/emigrate', - dry: false, - color: undefined, - version, - 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, 0); - assert.strictEqual(reporter.onFinished.mock.calls.length, 1); - assert.deepStrictEqual(reporter.onFinished.mock.calls[0]?.arguments, [[], undefined]); + assertPreconditionsFulfilled({ dry: false }, reporter, []); }); it('returns 0 and finishes without an error when all migrations have already been run', async () => { @@ -54,25 +38,7 @@ describe('up', () => { const exitCode = await run(); assert.strictEqual(exitCode, 0); - assert.strictEqual(reporter.onInit.mock.calls.length, 1); - assert.deepStrictEqual(reporter.onInit.mock.calls[0]?.arguments, [ - { - command: 'up', - cwd: '/emigrate', - dry: false, - color: undefined, - version, - 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, 0); - assert.strictEqual(reporter.onFinished.mock.calls.length, 1); - assert.deepStrictEqual(reporter.onFinished.mock.calls[0]?.arguments, [[], undefined]); + assertPreconditionsFulfilled({ dry: false }, reporter, []); }); it('returns 0 and finishes without an error when all migrations have already been run even when the history responds without file extensions', async () => { @@ -81,25 +47,7 @@ describe('up', () => { const exitCode = await run(); assert.strictEqual(exitCode, 0); - assert.strictEqual(reporter.onInit.mock.calls.length, 1); - assert.deepStrictEqual(reporter.onInit.mock.calls[0]?.arguments, [ - { - command: 'up', - cwd: '/emigrate', - dry: false, - color: undefined, - version, - 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, 0); - assert.strictEqual(reporter.onFinished.mock.calls.length, 1); - assert.deepStrictEqual(reporter.onFinished.mock.calls[0]?.arguments, [[], undefined]); + assertPreconditionsFulfilled({ dry: false }, reporter, []); }); it('returns 1 and finishes with an error when there are migration file extensions without a corresponding loader plugin', async () => { @@ -108,48 +56,40 @@ describe('up', () => { const exitCode = await run(); assert.strictEqual(exitCode, 1); - assert.strictEqual(reporter.onInit.mock.calls.length, 1); - 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, 1); - assert.strictEqual(reporter.onMigrationSkip.mock.calls.length, 1); - const args = reporter.onFinished.mock.calls[0]?.arguments; - assert.strictEqual(args?.length, 2); - const entries = args[0]; - const error = args[1]; - assert.deepStrictEqual( - entries.map((entry) => `${entry.name} (${entry.status})`), - ['some_other.js (skipped)', 'some_file.sql (failed)'], + assertPreconditionsFulfilled( + { dry: false }, + reporter, + [ + { name: 'some_other.js', status: 'skipped' }, + { + name: 'some_file.sql', + status: 'failed', + error: BadOptionError.fromOption('plugin', 'No loader plugin found for file extension: .sql'), + }, + ], + BadOptionError.fromOption('plugin', 'No loader plugin found for file extension: .sql'), ); - assert.strictEqual(entries.length, 2); - assert.strictEqual(error?.message, 'No loader plugin found for file extension: .sql'); }); it('returns 1 and finishes with an error when there are migration file extensions without a corresponding loader plugin in dry-run mode as well', async () => { const { reporter, run } = getUpCommand(['some_other.js', 'some_file.sql'], getStorage([])); - const exitCode = await run(); + const exitCode = await run({ dry: true }); assert.strictEqual(exitCode, 1); - assert.strictEqual(reporter.onInit.mock.calls.length, 1); - 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, 1); - assert.strictEqual(reporter.onMigrationSkip.mock.calls.length, 1); - const args = reporter.onFinished.mock.calls[0]?.arguments; - assert.strictEqual(args?.length, 2); - const entries = args[0]; - const error = args[1]; - assert.strictEqual(entries.length, 2); - assert.deepStrictEqual( - entries.map((entry) => `${entry.name} (${entry.status})`), - ['some_other.js (skipped)', 'some_file.sql (failed)'], + assertPreconditionsFulfilled( + { dry: true }, + reporter, + [ + { name: 'some_other.js', status: 'skipped' }, + { + name: 'some_file.sql', + status: 'failed', + error: BadOptionError.fromOption('plugin', 'No loader plugin found for file extension: .sql'), + }, + ], + BadOptionError.fromOption('plugin', 'No loader plugin found for file extension: .sql'), ); - assert.strictEqual(error?.message, 'No loader plugin found for file extension: .sql'); }); it('returns 1 and finishes with an error when there are failed migrations in the history', async () => { @@ -159,38 +99,24 @@ describe('up', () => { const exitCode = await run(); assert.strictEqual(exitCode, 1); - 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, 0); - assert.strictEqual(reporter.onMigrationSuccess.mock.calls.length, 0); - assert.strictEqual(reporter.onMigrationError.mock.calls.length, 1); - assert.deepStrictEqual( - getErrorCause(reporter.onMigrationError.mock.calls[0]?.arguments[1]), - deserializeError(failedEntry.error), - ); - 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?.message, - `Migration ${failedEntry.name} is in a failed state, it should be fixed and removed`, - ); - assert.deepStrictEqual(getErrorCause(error), deserializeError(failedEntry.error)); - assert.strictEqual(entries?.length, 2); - assert.deepStrictEqual( - entries.map((entry) => `${entry.name} (${entry.status})`), - ['some_failed_migration.js (failed)', 'some_file.js (skipped)'], + assertPreconditionsFulfilled( + { dry: false }, + reporter, + [ + { + name: 'some_failed_migration.js', + status: 'failed', + error: new MigrationHistoryError( + 'Migration some_failed_migration.js is in a failed state, it should be fixed and removed', + { cause: failedEntry.error }, + ), + }, + { name: 'some_file.js', status: 'skipped' }, + ], + new MigrationHistoryError( + 'Migration some_failed_migration.js is in a failed state, it should be fixed and removed', + { cause: failedEntry.error }, + ), ); }); @@ -201,38 +127,24 @@ describe('up', () => { const exitCode = await run({ dry: true }); assert.strictEqual(exitCode, 1); - 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, 1); - assert.deepStrictEqual( - getErrorCause(reporter.onMigrationError.mock.calls[0]?.arguments[1]), - deserializeError(failedEntry.error), - ); - 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?.message, - `Migration ${failedEntry.name} is in a failed state, it should be fixed and removed`, - ); - assert.deepStrictEqual(getErrorCause(error), deserializeError(failedEntry.error)); - assert.strictEqual(entries?.length, 2); - assert.deepStrictEqual( - entries.map((entry) => `${entry.name} (${entry.status})`), - ['some_failed_migration.js (failed)', 'some_file.js (pending)'], + assertPreconditionsFulfilled( + { dry: true }, + reporter, + [ + { + name: 'some_failed_migration.js', + status: 'failed', + error: new MigrationHistoryError( + 'Migration some_failed_migration.js is in a failed state, it should be fixed and removed', + { cause: failedEntry.error }, + ), + }, + { name: 'some_file.js', status: 'skipped' }, + ], + new MigrationHistoryError( + 'Migration some_failed_migration.js is in a failed state, it should be fixed and removed', + { cause: failedEntry.error }, + ), ); }); @@ -243,25 +155,7 @@ describe('up', () => { const exitCode = await run(); 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, 0); - assert.strictEqual(reporter.onMigrationSuccess.mock.calls.length, 0); - assert.strictEqual(reporter.onMigrationError.mock.calls.length, 0); - assert.strictEqual(reporter.onMigrationSkip.mock.calls.length, 0); - assert.strictEqual(reporter.onFinished.mock.calls.length, 1); - assert.deepStrictEqual(reporter.onFinished.mock.calls[0]?.arguments, [[], undefined]); + assertPreconditionsFulfilled({ dry: false }, reporter, []); }); it("returns 1 and finishes with an error when the storage couldn't be initialized", async () => { @@ -270,32 +164,7 @@ describe('up', () => { const exitCode = await run(); assert.strictEqual(exitCode, 1); - 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, 0); - assert.strictEqual(reporter.onLockedMigrations.mock.calls.length, 0); - 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, 0); - assert.strictEqual(reporter.onFinished.mock.calls.length, 1); - const args = reporter.onFinished.mock.calls[0]?.arguments; - assert.strictEqual(args?.length, 2); - const entries = args[0]; - const error = args[1]; - const cause = getErrorCause(error); - assert.deepStrictEqual(entries, []); - assert.strictEqual(error?.message, 'Could not initialize storage'); - assert.strictEqual(cause?.message, 'No storage configured'); + assertPreconditionsFailed({ dry: false }, reporter, StorageInitError.fromError(new Error('No storage configured'))); }); it('returns 0 and finishes without an error when all pending migrations are run successfully', async () => { @@ -317,31 +186,10 @@ describe('up', () => { const exitCode = await run(); 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', - }, + assertPreconditionsFulfilled({ dry: false }, reporter, [ + { name: 'some_migration.js', status: 'done', started: true }, + { name: 'some_other_migration.js', status: 'done', started: true }, ]); - assert.strictEqual(reporter.onCollectedMigrations.mock.calls.length, 1); - assert.strictEqual(reporter.onLockedMigrations.mock.calls.length, 1); - assert.strictEqual(reporter.onMigrationStart.mock.calls.length, 2); - assert.strictEqual(reporter.onMigrationSuccess.mock.calls.length, 2); - assert.strictEqual(reporter.onMigrationError.mock.calls.length, 0); - assert.strictEqual(reporter.onMigrationSkip.mock.calls.length, 0); - 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 (done)'], - ); }); it('returns 0 and finishes without an error when the given number of pending migrations are run successfully', async () => { @@ -363,31 +211,10 @@ describe('up', () => { 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', - }, + assertPreconditionsFulfilled({ dry: false }, reporter, [ + { name: 'some_migration.js', status: 'done', started: true }, + { name: 'some_other_migration.js', status: 'skipped' }, ]); - 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 () => { @@ -399,31 +226,125 @@ describe('up', () => { 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', - }, + assertPreconditionsFulfilled({ dry: true }, reporter, [ + { name: 'some_migration.js', status: 'pending' }, + { name: 'some_other_migration.js', status: 'skipped' }, ]); - 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 0 and finishes without an error when pending migrations after given "from" parameter are run successfully, even when the "from" is not an existing migration', async () => { + const { reporter, run } = getUpCommand( + ['1_some_already_run_migration.js', '2_some_migration.js', '4_some_other_migration.js'], + getStorage(['1_some_already_run_migration.js']), + [ + { + loadableExtensions: ['.js'], + async loadMigration() { + return async () => { + // Success + }; + }, + }, + ], ); + + const exitCode = await run({ from: '3_non_existing_migration.js' }); + + assert.strictEqual(exitCode, 0); + assertPreconditionsFulfilled({ dry: false }, reporter, [ + { name: '2_some_migration.js', status: 'skipped' }, + { name: '4_some_other_migration.js', status: 'done', started: true }, + ]); + }); + + it('returns 0 and finishes without an error when pending migrations after given "from" parameter are validated and listed successfully in dry-mode, even when the "from" is not an existing migration', async () => { + const { reporter, run } = getUpCommand( + ['1_some_already_run_migration.js', '2_some_migration.js', '4_some_other_migration.js'], + getStorage(['1_some_already_run_migration.js']), + ); + + const exitCode = await run({ dry: true, from: '3_non_existing_migration.js' }); + + assert.strictEqual(exitCode, 0); + assertPreconditionsFulfilled({ dry: true }, reporter, [ + { name: '2_some_migration.js', status: 'skipped' }, + { name: '4_some_other_migration.js', status: 'pending' }, + ]); + }); + + it('returns 0 and finishes without an error when pending migrations before given "to" parameter are run successfully, even when the "to" is not an existing migration', async () => { + const { reporter, run } = getUpCommand( + ['1_some_already_run_migration.js', '2_some_migration.js', '4_some_other_migration.js'], + getStorage(['1_some_already_run_migration.js']), + [ + { + loadableExtensions: ['.js'], + async loadMigration() { + return async () => { + // Success + }; + }, + }, + ], + ); + + const exitCode = await run({ to: '3_non_existing_migration.js' }); + + assert.strictEqual(exitCode, 0); + assertPreconditionsFulfilled({ dry: false }, reporter, [ + { name: '2_some_migration.js', status: 'done', started: true }, + { name: '4_some_other_migration.js', status: 'skipped' }, + ]); + }); + + it('returns 0 and finishes without an error when pending migrations after given "to" parameter are validated and listed successfully in dry-mode, even when the "to" is not an existing migration', async () => { + const { reporter, run } = getUpCommand( + ['1_some_already_run_migration.js', '2_some_migration.js', '4_some_other_migration.js'], + getStorage(['1_some_already_run_migration.js']), + ); + + const exitCode = await run({ dry: true, to: '3_non_existing_migration.js' }); + + assert.strictEqual(exitCode, 0); + assertPreconditionsFulfilled({ dry: true }, reporter, [ + { name: '2_some_migration.js', status: 'pending' }, + { name: '4_some_other_migration.js', status: 'skipped' }, + ]); + }); + + it('returns 0 and finishes without an error when the pending migrations fulfilling "from", "to" and "limit" are run successfully', async () => { + const { reporter, run } = getUpCommand( + [ + '1_some_already_run_migration.js', + '2_some_migration.js', + '3_another_migration.js', + '4_some_other_migration.js', + '5_yet_another_migration.js', + '6_some_more_migration.js', + ], + getStorage(['1_some_already_run_migration.js']), + [ + { + loadableExtensions: ['.js'], + async loadMigration() { + return async () => { + // Success + }; + }, + }, + ], + ); + + const exitCode = await run({ from: '3_another_migration.js', to: '5_yet_another_migration.js', limit: 2 }); + + assert.strictEqual(exitCode, 0); + assertPreconditionsFulfilled({ dry: false }, reporter, [ + { name: '2_some_migration.js', status: 'skipped' }, + { name: '3_another_migration.js', status: 'done', started: true }, + { name: '4_some_other_migration.js', status: 'done', started: true }, + { name: '5_yet_another_migration.js', status: 'skipped' }, + { name: '6_some_more_migration.js', status: 'skipped' }, + ]); }); it('returns 1 and finishes with an error when a pending migration throw when run', async () => { @@ -447,33 +368,15 @@ describe('up', () => { const exitCode = await run(); assert.strictEqual(exitCode, 1); - 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, 2); - assert.strictEqual(reporter.onMigrationSuccess.mock.calls.length, 1); - assert.strictEqual(reporter.onMigrationError.mock.calls.length, 1); - assert.strictEqual(reporter.onMigrationError.mock.calls[0]?.arguments[1]?.message, 'Oh noes!'); - 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?.message, 'Failed to run migration: migrations/fail.js'); - const cause = getErrorCause(error); - assert.strictEqual(cause?.message, 'Oh noes!'); - assert.strictEqual(entries?.length, 3); - assert.deepStrictEqual( - entries.map((entry) => `${entry.name} (${entry.status})`), - ['some_migration.js (done)', 'fail.js (failed)', 'some_other_migration.js (skipped)'], + assertPreconditionsFulfilled( + { dry: false }, + reporter, + [ + { name: 'some_migration.js', status: 'done', started: true }, + { name: 'fail.js', status: 'failed', started: true, error: new Error('Oh noes!') }, + { name: 'some_other_migration.js', status: 'skipped' }, + ], + new MigrationRunError('Failed to run migration: migrations/fail.js', { cause: new Error('Oh noes!') }), ); }); }); @@ -610,3 +513,127 @@ function getUpCommand(migrationFiles: string[], storage?: Mocked, plugi run, }; } + +function assertPreconditionsFulfilled( + options: { dry: boolean }, + reporter: Mocked>, + expected: Array<{ name: string; status: MigrationMetadataFinished['status']; started?: boolean; error?: Error }>, + finishedError?: Error, +) { + assert.strictEqual(reporter.onInit.mock.calls.length, 1); + assert.deepStrictEqual(reporter.onInit.mock.calls[0]?.arguments, [ + { + command: 'up', + cwd: '/emigrate', + version, + dry: options.dry, + color: undefined, + directory: 'migrations', + }, + ]); + + let started = 0; + let done = 0; + let failed = 0; + let skipped = 0; + let pending = 0; + const failedEntries: typeof expected = []; + + for (const entry of expected) { + if (entry.started) { + started++; + } + + // eslint-disable-next-line default-case + switch (entry.status) { + case 'done': { + done++; + break; + } + + case 'failed': { + failed++; + failedEntries.push(entry); + break; + } + + case 'skipped': { + skipped++; + break; + } + + case 'pending': { + pending++; + break; + } + } + } + + assert.strictEqual(reporter.onCollectedMigrations.mock.calls.length, 1, 'Collected call'); + assert.strictEqual(reporter.onLockedMigrations.mock.calls.length, 1, 'Locked call'); + assert.strictEqual(reporter.onMigrationStart.mock.calls.length, started, 'Started migrations'); + assert.strictEqual(reporter.onMigrationSuccess.mock.calls.length, done, 'Successful migrations'); + assert.strictEqual(reporter.onMigrationError.mock.calls.length, failed, 'Failed migrations'); + + for (const [index, entry] of failedEntries.entries()) { + if (entry.status === 'failed') { + const error = reporter.onMigrationError.mock.calls[index]?.arguments[1]; + assert.deepStrictEqual(error, entry.error, 'Error'); + const cause = entry.error?.cause; + assert.deepStrictEqual(error?.cause, cause ? deserializeError(cause) : cause, 'Error cause'); + } + } + + assert.strictEqual(reporter.onMigrationSkip.mock.calls.length, pending + skipped, 'Total pending and skipped'); + assert.strictEqual(reporter.onFinished.mock.calls.length, 1, 'Finished called once'); + const [entries, error] = reporter.onFinished.mock.calls[0]?.arguments ?? []; + assert.deepStrictEqual(error, finishedError, 'Finished error'); + const cause = getErrorCause(error); + const expectedCause = finishedError?.cause; + assert.deepStrictEqual( + cause, + expectedCause ? deserializeError(expectedCause) : expectedCause, + 'Finished error cause', + ); + assert.strictEqual(entries?.length, expected.length, 'Finished entries length'); + assert.deepStrictEqual( + entries.map((entry) => `${entry.name} (${entry.status})`), + expected.map((entry) => `${entry.name} (${entry.status})`), + 'Finished entries', + ); +} + +function assertPreconditionsFailed( + options: { dry: boolean }, + reporter: Mocked>, + finishedError?: Error, +) { + assert.strictEqual(reporter.onInit.mock.calls.length, 1); + assert.deepStrictEqual(reporter.onInit.mock.calls[0]?.arguments, [ + { + command: 'up', + cwd: '/emigrate', + version, + dry: options.dry, + color: undefined, + directory: 'migrations', + }, + ]); + assert.strictEqual(reporter.onCollectedMigrations.mock.calls.length, 0, 'Collected call'); + assert.strictEqual(reporter.onLockedMigrations.mock.calls.length, 0, 'Locked call'); + assert.strictEqual(reporter.onMigrationStart.mock.calls.length, 0, 'Started migrations'); + assert.strictEqual(reporter.onMigrationSuccess.mock.calls.length, 0, 'Successful migrations'); + assert.strictEqual(reporter.onMigrationError.mock.calls.length, 0, 'Failed migrations'); + assert.strictEqual(reporter.onMigrationSkip.mock.calls.length, 0, 'Total pending and skipped'); + assert.strictEqual(reporter.onFinished.mock.calls.length, 1, 'Finished called once'); + const [entries, error] = reporter.onFinished.mock.calls[0]?.arguments ?? []; + assert.deepStrictEqual(error, finishedError, 'Finished error'); + const cause = getErrorCause(error); + const expectedCause = finishedError?.cause; + assert.deepStrictEqual( + cause, + expectedCause ? deserializeError(expectedCause) : expectedCause, + 'Finished error cause', + ); + assert.strictEqual(entries?.length, 0, 'Finished entries length'); +} diff --git a/packages/cli/src/commands/up.ts b/packages/cli/src/commands/up.ts index 9613982..6ffecdc 100644 --- a/packages/cli/src/commands/up.ts +++ b/packages/cli/src/commands/up.ts @@ -15,6 +15,8 @@ type ExtraFlags = { cwd: string; dry?: boolean; limit?: number; + from?: string; + to?: string; getMigrations?: GetMigrationsFunction; }; @@ -27,6 +29,8 @@ export default async function upCommand({ directory, color, limit, + from, + to, dry = false, plugins = [], cwd, @@ -86,6 +90,8 @@ export default async function upCommand({ const error = await migrationRunner({ dry, limit, + from, + to, reporter, storage, migrations: await arrayFromAsync(collectedMigrations), diff --git a/packages/cli/src/migration-runner.ts b/packages/cli/src/migration-runner.ts index f62011e..e09d6c4 100644 --- a/packages/cli/src/migration-runner.ts +++ b/packages/cli/src/migration-runner.ts @@ -16,6 +16,8 @@ import { getDuration } from './get-duration.js'; type MigrationRunnerParameters = { dry: boolean; limit?: number; + from?: string; + to?: string; reporter: EmigrateReporter; storage: Storage; migrations: Array; @@ -26,6 +28,8 @@ type MigrationRunnerParameters = { export const migrationRunner = async ({ dry, limit, + from, + to, reporter, storage, migrations, @@ -34,8 +38,8 @@ export const migrationRunner = async ({ }: MigrationRunnerParameters): Promise => { await reporter.onCollectedMigrations?.(migrations); - const finishedMigrations: MigrationMetadataFinished[] = []; - const migrationsToRun: MigrationMetadata[] = []; + const validatedMigrations: Array = []; + const migrationsToLock: MigrationMetadata[] = []; let skip = false; @@ -43,24 +47,35 @@ export const migrationRunner = async ({ if (isFinishedMigration(migration)) { skip ||= migration.status === 'failed' || migration.status === 'skipped'; - finishedMigrations.push(migration); - } else if (skip) { - finishedMigrations.push({ + validatedMigrations.push(migration); + } else if ( + skip || + Boolean(from && migration.name < from) || + Boolean(to && migration.name > to) || + (limit && migrationsToLock.length >= limit) + ) { + validatedMigrations.push({ ...migration, - status: dry ? 'pending' : 'skipped', + status: 'skipped', }); } else { try { await validate(migration); - migrationsToRun.push(migration); + migrationsToLock.push(migration); + validatedMigrations.push(migration); } catch (error) { - for await (const migration of migrationsToRun) { - finishedMigrations.push({ ...migration, status: 'skipped' }); + for (const migration of migrationsToLock) { + const validatedIndex = validatedMigrations.indexOf(migration); + + validatedMigrations[validatedIndex] = { + ...migration, + status: 'skipped', + }; } - migrationsToRun.length = 0; + migrationsToLock.length = 0; - finishedMigrations.push({ + validatedMigrations.push({ ...migration, status: 'failed', duration: 0, @@ -72,50 +87,70 @@ export const migrationRunner = async ({ } } - 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) { - finishedMigrations.push({ ...migration, status: 'skipped' }); + for (const migration of migrationsToLock) { + const validatedIndex = validatedMigrations.indexOf(migration); + + validatedMigrations[validatedIndex] = { + ...migration, + status: 'skipped', + }; } - migrationsToRun.length = 0; + migrationsToLock.length = 0; skip = true; } else { + for (const migration of migrationsToLock) { + const isLocked = lockedMigrations.some((lockedMigration) => lockedMigration.name === migration.name); + + if (!isLocked) { + const validatedIndex = validatedMigrations.indexOf(migration); + + validatedMigrations[validatedIndex] = { + ...migration, + status: 'skipped', + }; + } + } + await reporter.onLockedMigrations?.(lockedMigrations); } - for await (const finishedMigration of finishedMigrations) { - switch (finishedMigration.status) { - case 'failed': { - await reporter.onMigrationError?.(finishedMigration, finishedMigration.error); - break; + const finishedMigrations: MigrationMetadataFinished[] = []; + + for await (const migration of validatedMigrations) { + if (isFinishedMigration(migration)) { + switch (migration.status) { + case 'failed': { + await reporter.onMigrationError?.(migration, migration.error); + break; + } + + case 'pending': { + await reporter.onMigrationSkip?.(migration); + break; + } + + case 'skipped': { + await reporter.onMigrationSkip?.(migration); + break; + } + + default: { + await reporter.onMigrationSuccess?.(migration); + break; + } } - case 'pending': { - await reporter.onMigrationSkip?.(finishedMigration); - break; - } - - case 'skipped': { - await reporter.onMigrationSkip?.(finishedMigration); - break; - } - - default: { - await reporter.onMigrationSuccess?.(finishedMigration); - break; - } + finishedMigrations.push(migration); + continue; } - } - for await (const migration of lockedMigrations ?? []) { if (dry || skip) { const finishedMigration: MigrationMetadataFinished = { ...migration, @@ -159,17 +194,6 @@ 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