feat(up): add --limit option to limit the number of migrations to run

This commit is contained in:
Joakim Carlstein 2024-01-18 19:01:23 +01:00 committed by Joakim Carlstein
parent bf4d596980
commit 02c142e39a
7 changed files with 176 additions and 5 deletions

View file

@ -0,0 +1,5 @@
---
'@emigrate/cli': minor
---
Add --limit option to the "up" command, for limiting the number of migrations to run

View file

@ -64,6 +64,11 @@ Show command help and exit
List the pending migrations that would be run without actually running them List the pending migrations that would be run without actually running them
### `-l, --limit <count>`
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 <path>` ### `-d`, `--directory <path>`
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.

View file

@ -20,6 +20,35 @@ 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
--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: Create a new migration:
```bash ```bash

View file

@ -48,6 +48,10 @@ const up: Action = async (args) => {
type: 'string', type: 'string',
short: 's', short: 's',
}, },
limit: {
type: 'string',
short: 'l',
},
dry: { dry: {
type: 'boolean', type: 'boolean',
}, },
@ -80,6 +84,7 @@ Options:
-s, --storage <name> The storage to use for where to store the migration history (required) -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) -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
--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)
@ -104,15 +109,34 @@ Examples:
storage = config.storage, storage = config.storage,
reporter = config.reporter, reporter = config.reporter,
dry, dry,
limit: limitString,
import: imports = [], import: imports = [],
} = values; } = values;
const plugins = [...(config.plugins ?? []), ...(values.plugin ?? [])]; 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); await importAll(cwd, imports);
try { try {
const { default: upCommand } = await import('./commands/up.js'); 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) { } catch (error) {
if (error instanceof ShowUsageError) { if (error instanceof ShowUsageError) {
console.error(error.message, '\n'); console.error(error.message, '\n');

View file

@ -198,7 +198,7 @@ describe('up', () => {
const failedEntry = toEntry('some_failed_migration.js', 'failed'); const failedEntry = toEntry('some_failed_migration.js', 'failed');
const { reporter, run } = getUpCommand([failedEntry.name, 'some_file.js'], getStorage([failedEntry])); 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(exitCode, 1);
assert.strictEqual(reporter.onInit.mock.calls.length, 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 () => { it('returns 1 and finishes with an error when a pending migration throw when run', async () => {
const { reporter, run } = getUpCommand( const { reporter, run } = getUpCommand(
['some_already_run_migration.js', 'some_migration.js', 'fail.js', 'some_other_migration.js'], ['some_already_run_migration.js', 'some_migration.js', 'fail.js', 'some_other_migration.js'],
@ -495,7 +577,12 @@ function getUpCommand(migrationFiles: string[], storage?: Mocked<Storage>, plugi
onMigrationSkip: mock.fn(noop), onMigrationSkip: mock.fn(noop),
}; };
const run = async (dry = false) => { const run = async (
options?: Omit<
Parameters<typeof upCommand>[0],
'cwd' | 'directory' | 'storage' | 'reporter' | 'plugins' | 'getMigrations'
>,
) => {
return upCommand({ return upCommand({
cwd: '/emigrate', cwd: '/emigrate',
directory: 'migrations', directory: 'migrations',
@ -509,11 +596,11 @@ function getUpCommand(migrationFiles: string[], storage?: Mocked<Storage>, plugi
}, },
}, },
reporter, reporter,
dry,
plugins: plugins ?? [], plugins: plugins ?? [],
async getMigrations(cwd, directory) { async getMigrations(cwd, directory) {
return toMigrations(cwd, directory, migrationFiles); return toMigrations(cwd, directory, migrationFiles);
}, },
...options,
}); });
}; };

View file

@ -14,6 +14,7 @@ import { version } from '../get-package-info.js';
type ExtraFlags = { type ExtraFlags = {
cwd: string; cwd: string;
dry?: boolean; dry?: boolean;
limit?: number;
getMigrations?: GetMigrationsFunction; getMigrations?: GetMigrationsFunction;
}; };
@ -25,6 +26,7 @@ export default async function upCommand({
reporter: reporterConfig, reporter: reporterConfig,
directory, directory,
color, color,
limit,
dry = false, dry = false,
plugins = [], plugins = [],
cwd, cwd,
@ -83,6 +85,7 @@ export default async function upCommand({
const error = await migrationRunner({ const error = await migrationRunner({
dry, dry,
limit,
reporter, reporter,
storage, storage,
migrations: await arrayFromAsync(collectedMigrations), migrations: await arrayFromAsync(collectedMigrations),

View file

@ -15,6 +15,7 @@ import { getDuration } from './get-duration.js';
type MigrationRunnerParameters = { type MigrationRunnerParameters = {
dry: boolean; dry: boolean;
limit?: number;
reporter: EmigrateReporter; reporter: EmigrateReporter;
storage: Storage; storage: Storage;
migrations: Array<MigrationMetadata | MigrationMetadataFinished>; migrations: Array<MigrationMetadata | MigrationMetadataFinished>;
@ -24,6 +25,7 @@ type MigrationRunnerParameters = {
export const migrationRunner = async ({ export const migrationRunner = async ({
dry, dry,
limit,
reporter, reporter,
storage, storage,
migrations, 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) { if (lockError) {
for await (const migration of migrationsToRun) { 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 ?? [])); 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