feat(cli): add --from and --to options to limit what migrations to run

This commit is contained in:
Joakim Carlstein 2024-01-19 10:34:06 +01:00 committed by Joakim Carlstein
parent 02c142e39a
commit 9ef0fa2776
8 changed files with 485 additions and 346 deletions

View file

@ -0,0 +1,5 @@
---
'@emigrate/cli': minor
---
Add --from and --to CLI options to control which migrations to include or skip when executing migrations.

View file

@ -41,6 +41,41 @@ bun add @emigrate/cli
## Usage ## Usage
```text
Usage: emigrate up [options]
Run all pending migrations
Options:
-h, --help Show this help message and exit
-d, --directory <path> The directory where the migration files are located (required)
-i, --import <module> 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 <name> The storage to use for where to store the migration history (required)
-p, --plugin <name> The plugin(s) to use (can be specified multiple times)
-r, --reporter <name> The reporter to use for reporting the migration progress
-l, --limit <count> Limit the number of migrations to run
-f, --from <name> Start running migrations from the given migration name, the given name doesn't need to exist
and is compared in lexicographical order
-t, --to <name> 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: Create a new migration:
```bash ```bash

View file

@ -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. The directory where the migration files are located. The given path should be absolute or relative to the current working directory.
### `-f`, `--from <name>`
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 <name>`
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 <module>` ### `-i`, `--import <module>`
A module to import before running the migrations. This option can be specified multiple times. A module to import before running the migrations. This option can be specified multiple times.

View file

@ -35,6 +35,10 @@ Options:
-p, --plugin <name> The plugin(s) to use (can be specified multiple times) -p, --plugin <name> The plugin(s) to use (can be specified multiple times)
-r, --reporter <name> The reporter to use for reporting the migration progress -r, --reporter <name> The reporter to use for reporting the migration progress
-l, --limit <count> Limit the number of migrations to run -l, --limit <count> Limit the number of migrations to run
-f, --from <name> Start running migrations from the given migration name, the given name doesn't need to exist
and is compared in lexicographical order
-t, --to <name> 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 --dry List the pending migrations that would be run without actually running them
--color Force color output (this option is passed to the reporter) --color Force color output (this option is passed to the reporter)
--no-color Disable 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 ./migrations --storage @emigrate/mysql
emigrate up -d src/migrations -s postgres -r json --dry emigrate up -d src/migrations -s postgres -r json --dry
emigrate up -d ./migrations -s mysql --import dotenv/config emigrate up -d ./migrations -s mysql --import dotenv/config
emigrate up --limit 1
emigrate up --to 20231122120529381_some_migration_file.js
``` ```
### Examples ### Examples

View file

@ -52,6 +52,14 @@ const up: Action = async (args) => {
type: 'string', type: 'string',
short: 'l', short: 'l',
}, },
from: {
type: 'string',
short: 'f',
},
to: {
type: 'string',
short: 't',
},
dry: { dry: {
type: 'boolean', type: 'boolean',
}, },
@ -85,6 +93,10 @@ Options:
-p, --plugin <name> The plugin(s) to use (can be specified multiple times) -p, --plugin <name> The plugin(s) to use (can be specified multiple times)
-r, --reporter <name> The reporter to use for reporting the migration progress -r, --reporter <name> The reporter to use for reporting the migration progress
-l, --limit <count> Limit the number of migrations to run -l, --limit <count> Limit the number of migrations to run
-f, --from <name> Start running migrations from the given migration name, the given name doesn't need to exist
and is compared in lexicographical order
-t, --to <name> 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 --dry List the pending migrations that would be run without actually running them
--color Force color output (this option is passed to the reporter) --color Force color output (this option is passed to the reporter)
--no-color Disable 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 ./migrations --storage @emigrate/mysql
emigrate up -d src/migrations -s postgres -r json --dry emigrate up -d src/migrations -s postgres -r json --dry
emigrate up -d ./migrations -s mysql --import dotenv/config emigrate up -d ./migrations -s mysql --import dotenv/config
emigrate up --limit 1
emigrate up --to 20231122120529381_some_migration_file.js
`; `;
if (values.help) { if (values.help) {
@ -109,6 +123,8 @@ Examples:
storage = config.storage, storage = config.storage,
reporter = config.reporter, reporter = config.reporter,
dry, dry,
from,
to,
limit: limitString, limit: limitString,
import: imports = [], import: imports = [],
} = values; } = values;
@ -135,6 +151,8 @@ Examples:
cwd, cwd,
dry, dry,
limit, limit,
from,
to,
color: useColors(values), color: useColors(values),
}); });
} catch (error) { } catch (error) {

View file

@ -10,9 +10,11 @@ import {
type SerializedError, type SerializedError,
type FailedMigrationHistoryEntry, type FailedMigrationHistoryEntry,
type NonFailedMigrationHistoryEntry, type NonFailedMigrationHistoryEntry,
type MigrationMetadataFinished,
} from '@emigrate/types'; } from '@emigrate/types';
import { deserializeError } from 'serialize-error'; import { deserializeError } from 'serialize-error';
import { version } from '../get-package-info.js'; import { version } from '../get-package-info.js';
import { BadOptionError, MigrationHistoryError, MigrationRunError, StorageInitError } from '../errors.js';
import upCommand from './up.js'; import upCommand from './up.js';
type Mocked<T> = { type Mocked<T> = {
@ -27,25 +29,7 @@ describe('up', () => {
const exitCode = await run(); const exitCode = await run();
assert.strictEqual(exitCode, 0); assert.strictEqual(exitCode, 0);
assert.strictEqual(reporter.onInit.mock.calls.length, 1); assertPreconditionsFulfilled({ dry: false }, reporter, []);
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]);
}); });
it('returns 0 and finishes without an error when all migrations have already been run', async () => { 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(); const exitCode = await run();
assert.strictEqual(exitCode, 0); assert.strictEqual(exitCode, 0);
assert.strictEqual(reporter.onInit.mock.calls.length, 1); assertPreconditionsFulfilled({ dry: false }, reporter, []);
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]);
}); });
it('returns 0 and finishes without an error when all migrations have already been run even when the history responds without file extensions', async () => { 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(); const exitCode = await run();
assert.strictEqual(exitCode, 0); assert.strictEqual(exitCode, 0);
assert.strictEqual(reporter.onInit.mock.calls.length, 1); assertPreconditionsFulfilled({ dry: false }, reporter, []);
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]);
}); });
it('returns 1 and finishes with an error when there are migration file extensions without a corresponding loader plugin', async () => { 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(); const exitCode = await run();
assert.strictEqual(exitCode, 1); assert.strictEqual(exitCode, 1);
assert.strictEqual(reporter.onInit.mock.calls.length, 1); assertPreconditionsFulfilled(
assert.strictEqual(reporter.onCollectedMigrations.mock.calls.length, 1); { dry: false },
assert.strictEqual(reporter.onLockedMigrations.mock.calls.length, 1); reporter,
assert.strictEqual(reporter.onMigrationStart.mock.calls.length, 0); [
assert.strictEqual(reporter.onMigrationSuccess.mock.calls.length, 0); { name: 'some_other.js', status: 'skipped' },
assert.strictEqual(reporter.onMigrationError.mock.calls.length, 1); {
assert.strictEqual(reporter.onMigrationSkip.mock.calls.length, 1); name: 'some_file.sql',
const args = reporter.onFinished.mock.calls[0]?.arguments; status: 'failed',
assert.strictEqual(args?.length, 2); error: BadOptionError.fromOption('plugin', 'No loader plugin found for file extension: .sql'),
const entries = args[0]; },
const error = args[1]; ],
assert.deepStrictEqual( BadOptionError.fromOption('plugin', 'No loader plugin found for file extension: .sql'),
entries.map((entry) => `${entry.name} (${entry.status})`),
['some_other.js (skipped)', 'some_file.sql (failed)'],
); );
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 () => { 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 { 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(exitCode, 1);
assert.strictEqual(reporter.onInit.mock.calls.length, 1); assertPreconditionsFulfilled(
assert.strictEqual(reporter.onCollectedMigrations.mock.calls.length, 1); { dry: true },
assert.strictEqual(reporter.onLockedMigrations.mock.calls.length, 1); reporter,
assert.strictEqual(reporter.onMigrationStart.mock.calls.length, 0); [
assert.strictEqual(reporter.onMigrationSuccess.mock.calls.length, 0); { name: 'some_other.js', status: 'skipped' },
assert.strictEqual(reporter.onMigrationError.mock.calls.length, 1); {
assert.strictEqual(reporter.onMigrationSkip.mock.calls.length, 1); name: 'some_file.sql',
const args = reporter.onFinished.mock.calls[0]?.arguments; status: 'failed',
assert.strictEqual(args?.length, 2); error: BadOptionError.fromOption('plugin', 'No loader plugin found for file extension: .sql'),
const entries = args[0]; },
const error = args[1]; ],
assert.strictEqual(entries.length, 2); BadOptionError.fromOption('plugin', 'No loader plugin found for file extension: .sql'),
assert.deepStrictEqual(
entries.map((entry) => `${entry.name} (${entry.status})`),
['some_other.js (skipped)', 'some_file.sql (failed)'],
); );
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 () => { 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(); const exitCode = await run();
assert.strictEqual(exitCode, 1); assert.strictEqual(exitCode, 1);
assert.strictEqual(reporter.onInit.mock.calls.length, 1); assertPreconditionsFulfilled(
assert.deepStrictEqual(reporter.onInit.mock.calls[0]?.arguments, [ { dry: false },
reporter,
[
{ {
command: 'up', name: 'some_failed_migration.js',
cwd: '/emigrate', status: 'failed',
version, error: new MigrationHistoryError(
dry: false, 'Migration some_failed_migration.js is in a failed state, it should be fixed and removed',
color: undefined, { cause: failedEntry.error },
directory: 'migrations', ),
}, },
]); { name: 'some_file.js', status: 'skipped' },
assert.strictEqual(reporter.onCollectedMigrations.mock.calls.length, 1); ],
assert.strictEqual(reporter.onLockedMigrations.mock.calls.length, 1); new MigrationHistoryError(
assert.strictEqual(reporter.onMigrationStart.mock.calls.length, 0); 'Migration some_failed_migration.js is in a failed state, it should be fixed and removed',
assert.strictEqual(reporter.onMigrationSuccess.mock.calls.length, 0); { cause: failedEntry.error },
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)'],
); );
}); });
@ -201,38 +127,24 @@ describe('up', () => {
const exitCode = await run({ dry: true }); const exitCode = await run({ dry: true });
assert.strictEqual(exitCode, 1); assert.strictEqual(exitCode, 1);
assert.strictEqual(reporter.onInit.mock.calls.length, 1); assertPreconditionsFulfilled(
assert.deepStrictEqual(reporter.onInit.mock.calls[0]?.arguments, [ { dry: true },
reporter,
[
{ {
command: 'up', name: 'some_failed_migration.js',
cwd: '/emigrate', status: 'failed',
version, error: new MigrationHistoryError(
dry: true, 'Migration some_failed_migration.js is in a failed state, it should be fixed and removed',
color: undefined, { cause: failedEntry.error },
directory: 'migrations', ),
}, },
]); { name: 'some_file.js', status: 'skipped' },
assert.strictEqual(reporter.onCollectedMigrations.mock.calls.length, 1); ],
assert.strictEqual(reporter.onLockedMigrations.mock.calls.length, 1); new MigrationHistoryError(
assert.strictEqual(reporter.onMigrationStart.mock.calls.length, 0); 'Migration some_failed_migration.js is in a failed state, it should be fixed and removed',
assert.strictEqual(reporter.onMigrationSuccess.mock.calls.length, 0); { cause: failedEntry.error },
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)'],
); );
}); });
@ -243,25 +155,7 @@ describe('up', () => {
const exitCode = await run(); const exitCode = await run();
assert.strictEqual(exitCode, 0); assert.strictEqual(exitCode, 0);
assert.strictEqual(reporter.onInit.mock.calls.length, 1); assertPreconditionsFulfilled({ dry: false }, reporter, []);
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]);
}); });
it("returns 1 and finishes with an error when the storage couldn't be initialized", async () => { 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(); const exitCode = await run();
assert.strictEqual(exitCode, 1); assert.strictEqual(exitCode, 1);
assert.strictEqual(reporter.onInit.mock.calls.length, 1); assertPreconditionsFailed({ dry: false }, reporter, StorageInitError.fromError(new Error('No storage configured')));
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');
}); });
it('returns 0 and finishes without an error when all pending migrations are run successfully', async () => { 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(); const exitCode = await run();
assert.strictEqual(exitCode, 0); assert.strictEqual(exitCode, 0);
assert.strictEqual(reporter.onInit.mock.calls.length, 1); assertPreconditionsFulfilled({ dry: false }, reporter, [
assert.deepStrictEqual(reporter.onInit.mock.calls[0]?.arguments, [ { name: 'some_migration.js', status: 'done', started: true },
{ { name: 'some_other_migration.js', status: 'done', started: true },
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, 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 () => { 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 }); const exitCode = await run({ limit: 1 });
assert.strictEqual(exitCode, 0); assert.strictEqual(exitCode, 0);
assert.strictEqual(reporter.onInit.mock.calls.length, 1); assertPreconditionsFulfilled({ dry: false }, reporter, [
assert.deepStrictEqual(reporter.onInit.mock.calls[0]?.arguments, [ { name: 'some_migration.js', status: 'done', started: true },
{ { name: 'some_other_migration.js', status: 'skipped' },
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 () => { 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 }); const exitCode = await run({ dry: true, limit: 1 });
assert.strictEqual(exitCode, 0); assert.strictEqual(exitCode, 0);
assert.strictEqual(reporter.onInit.mock.calls.length, 1); assertPreconditionsFulfilled({ dry: true }, reporter, [
assert.deepStrictEqual(reporter.onInit.mock.calls[0]?.arguments, [ { name: 'some_migration.js', status: 'pending' },
{ { name: 'some_other_migration.js', status: 'skipped' },
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); 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 () => {
assert.strictEqual(reporter.onMigrationSuccess.mock.calls.length, 0); const { reporter, run } = getUpCommand(
assert.strictEqual(reporter.onMigrationError.mock.calls.length, 0); ['1_some_already_run_migration.js', '2_some_migration.js', '4_some_other_migration.js'],
assert.strictEqual(reporter.onMigrationSkip.mock.calls.length, 2); getStorage(['1_some_already_run_migration.js']),
assert.strictEqual(reporter.onFinished.mock.calls.length, 1); [
const [entries, error] = reporter.onFinished.mock.calls[0]?.arguments ?? []; {
assert.strictEqual(error, undefined); loadableExtensions: ['.js'],
assert.strictEqual(entries?.length, 2); async loadMigration() {
assert.deepStrictEqual( return async () => {
entries.map((entry) => `${entry.name} (${entry.status})`), // Success
['some_migration.js (pending)', 'some_other_migration.js (skipped)'], };
},
},
],
); );
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 () => { 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(); const exitCode = await run();
assert.strictEqual(exitCode, 1); assert.strictEqual(exitCode, 1);
assert.strictEqual(reporter.onInit.mock.calls.length, 1); assertPreconditionsFulfilled(
assert.deepStrictEqual(reporter.onInit.mock.calls[0]?.arguments, [ { dry: false },
{ reporter,
command: 'up', [
cwd: '/emigrate', { name: 'some_migration.js', status: 'done', started: true },
version, { name: 'fail.js', status: 'failed', started: true, error: new Error('Oh noes!') },
dry: false, { name: 'some_other_migration.js', status: 'skipped' },
color: undefined, ],
directory: 'migrations', new MigrationRunError('Failed to run migration: migrations/fail.js', { cause: new Error('Oh noes!') }),
},
]);
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)'],
); );
}); });
}); });
@ -610,3 +513,127 @@ function getUpCommand(migrationFiles: string[], storage?: Mocked<Storage>, plugi
run, run,
}; };
} }
function assertPreconditionsFulfilled(
options: { dry: boolean },
reporter: Mocked<Required<EmigrateReporter>>,
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<Required<EmigrateReporter>>,
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');
}

View file

@ -15,6 +15,8 @@ type ExtraFlags = {
cwd: string; cwd: string;
dry?: boolean; dry?: boolean;
limit?: number; limit?: number;
from?: string;
to?: string;
getMigrations?: GetMigrationsFunction; getMigrations?: GetMigrationsFunction;
}; };
@ -27,6 +29,8 @@ export default async function upCommand({
directory, directory,
color, color,
limit, limit,
from,
to,
dry = false, dry = false,
plugins = [], plugins = [],
cwd, cwd,
@ -86,6 +90,8 @@ export default async function upCommand({
const error = await migrationRunner({ const error = await migrationRunner({
dry, dry,
limit, limit,
from,
to,
reporter, reporter,
storage, storage,
migrations: await arrayFromAsync(collectedMigrations), migrations: await arrayFromAsync(collectedMigrations),

View file

@ -16,6 +16,8 @@ import { getDuration } from './get-duration.js';
type MigrationRunnerParameters = { type MigrationRunnerParameters = {
dry: boolean; dry: boolean;
limit?: number; limit?: number;
from?: string;
to?: string;
reporter: EmigrateReporter; reporter: EmigrateReporter;
storage: Storage; storage: Storage;
migrations: Array<MigrationMetadata | MigrationMetadataFinished>; migrations: Array<MigrationMetadata | MigrationMetadataFinished>;
@ -26,6 +28,8 @@ type MigrationRunnerParameters = {
export const migrationRunner = async ({ export const migrationRunner = async ({
dry, dry,
limit, limit,
from,
to,
reporter, reporter,
storage, storage,
migrations, migrations,
@ -34,8 +38,8 @@ export const migrationRunner = async ({
}: MigrationRunnerParameters): Promise<Error | undefined> => { }: MigrationRunnerParameters): Promise<Error | undefined> => {
await reporter.onCollectedMigrations?.(migrations); await reporter.onCollectedMigrations?.(migrations);
const finishedMigrations: MigrationMetadataFinished[] = []; const validatedMigrations: Array<MigrationMetadata | MigrationMetadataFinished> = [];
const migrationsToRun: MigrationMetadata[] = []; const migrationsToLock: MigrationMetadata[] = [];
let skip = false; let skip = false;
@ -43,24 +47,35 @@ export const migrationRunner = async ({
if (isFinishedMigration(migration)) { if (isFinishedMigration(migration)) {
skip ||= migration.status === 'failed' || migration.status === 'skipped'; skip ||= migration.status === 'failed' || migration.status === 'skipped';
finishedMigrations.push(migration); validatedMigrations.push(migration);
} else if (skip) { } else if (
finishedMigrations.push({ skip ||
Boolean(from && migration.name < from) ||
Boolean(to && migration.name > to) ||
(limit && migrationsToLock.length >= limit)
) {
validatedMigrations.push({
...migration, ...migration,
status: dry ? 'pending' : 'skipped', status: 'skipped',
}); });
} else { } else {
try { try {
await validate(migration); await validate(migration);
migrationsToRun.push(migration); migrationsToLock.push(migration);
validatedMigrations.push(migration);
} catch (error) { } catch (error) {
for await (const migration of migrationsToRun) { for (const migration of migrationsToLock) {
finishedMigrations.push({ ...migration, status: 'skipped' }); const validatedIndex = validatedMigrations.indexOf(migration);
validatedMigrations[validatedIndex] = {
...migration,
status: 'skipped',
};
} }
migrationsToRun.length = 0; migrationsToLock.length = 0;
finishedMigrations.push({ validatedMigrations.push({
...migration, ...migration,
status: 'failed', status: 'failed',
duration: 0, 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 const [lockedMigrations, lockError] = dry
? [migrationsToLock] ? [migrationsToLock]
: await exec(async () => storage.lock(migrationsToLock)); : await exec(async () => storage.lock(migrationsToLock));
if (lockError) { if (lockError) {
for await (const migration of migrationsToRun) { for (const migration of migrationsToLock) {
finishedMigrations.push({ ...migration, status: 'skipped' }); const validatedIndex = validatedMigrations.indexOf(migration);
validatedMigrations[validatedIndex] = {
...migration,
status: 'skipped',
};
} }
migrationsToRun.length = 0; migrationsToLock.length = 0;
skip = true; skip = true;
} else { } 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); await reporter.onLockedMigrations?.(lockedMigrations);
} }
for await (const finishedMigration of finishedMigrations) { const finishedMigrations: MigrationMetadataFinished[] = [];
switch (finishedMigration.status) {
for await (const migration of validatedMigrations) {
if (isFinishedMigration(migration)) {
switch (migration.status) {
case 'failed': { case 'failed': {
await reporter.onMigrationError?.(finishedMigration, finishedMigration.error); await reporter.onMigrationError?.(migration, migration.error);
break; break;
} }
case 'pending': { case 'pending': {
await reporter.onMigrationSkip?.(finishedMigration); await reporter.onMigrationSkip?.(migration);
break; break;
} }
case 'skipped': { case 'skipped': {
await reporter.onMigrationSkip?.(finishedMigration); await reporter.onMigrationSkip?.(migration);
break; break;
} }
default: { default: {
await reporter.onMigrationSuccess?.(finishedMigration); await reporter.onMigrationSuccess?.(migration);
break; break;
} }
} }
finishedMigrations.push(migration);
continue;
} }
for await (const migration of lockedMigrations ?? []) {
if (dry || skip) { if (dry || skip) {
const finishedMigration: MigrationMetadataFinished = { const finishedMigration: MigrationMetadataFinished = {
...migration, ...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 ?? [])); const [, unlockError] = dry ? [] : await exec(async () => storage.unlock(lockedMigrations ?? []));
// eslint-disable-next-line unicorn/no-array-callback-reference // eslint-disable-next-line unicorn/no-array-callback-reference