From 1f139fd975b7b8a0ee5d3a0d070dc5dd58508547 Mon Sep 17 00:00:00 2001 From: Joakim Carlstein Date: Fri, 26 Jan 2024 15:11:02 +0100 Subject: [PATCH] feat(remove): rework the "remove" command to be more similar to "up" and "list" The old reporter methods related to the "remove" command is not used anymore and instead the shared `onMigrationStart`, `onMigrationSuccess` and `onMigrationError` methods are used. Some preparation has also been made to support for removing multiple migrations at once in the future, similar to how the `--from` and `--to` CLI options work for the "up" command. --- .changeset/thin-pillows-obey.md | 5 + packages/cli/src/array-map-async.ts | 5 + packages/cli/src/cli.ts | 3 +- packages/cli/src/commands/list.ts | 6 + packages/cli/src/commands/remove.test.ts | 306 +++++++++++++++++++++++ packages/cli/src/commands/remove.ts | 157 +++++++----- packages/cli/src/commands/up.test.ts | 119 +++------ packages/cli/src/commands/up.ts | 15 +- packages/cli/src/errors.ts | 11 + packages/cli/src/migration-runner.ts | 53 ++-- packages/cli/src/reporters/default.ts | 64 ++--- packages/cli/src/test-utils.ts | 58 +++++ 12 files changed, 593 insertions(+), 209 deletions(-) create mode 100644 .changeset/thin-pillows-obey.md create mode 100644 packages/cli/src/array-map-async.ts create mode 100644 packages/cli/src/commands/remove.test.ts diff --git a/.changeset/thin-pillows-obey.md b/.changeset/thin-pillows-obey.md new file mode 100644 index 0000000..2f94c63 --- /dev/null +++ b/.changeset/thin-pillows-obey.md @@ -0,0 +1,5 @@ +--- +'@emigrate/cli': minor +--- + +Completely rework how the "remove" command is run, this is to make it more similar to the "up" and "list" command as now it will also use the `onMigrationStart`, `onMigrationSuccess` and `onMigrationError` reporter methods when reporting the command progress. It's also in preparation for adding `--from` and `--to` CLI options for the "remove" command, similar to how the same options work for the "up" command. diff --git a/packages/cli/src/array-map-async.ts b/packages/cli/src/array-map-async.ts new file mode 100644 index 0000000..e602c5a --- /dev/null +++ b/packages/cli/src/array-map-async.ts @@ -0,0 +1,5 @@ +export async function* arrayMapAsync(iterable: AsyncIterable, mapper: (item: T) => U): AsyncIterable { + for await (const item of iterable) { + yield mapper(item); + } +} diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index d6a2e81..bd74870 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -457,8 +457,7 @@ Options: For example if you want to use Dotenv to load environment variables -r, --reporter The reporter to use for reporting the removal process -s, --storage The storage to use to get the migration history (required) - -f, --force Force removal of the migration history entry even if the migration file does not exist - or it's in a non-failed state + -f, --force Force removal of the migration history entry even if the migration is not in a failed state --color Force color output (this option is passed to the reporter) --no-color Disable color output (this option is passed to the reporter) diff --git a/packages/cli/src/commands/list.ts b/packages/cli/src/commands/list.ts index 8d40265..0dcdcf9 100644 --- a/packages/cli/src/commands/list.ts +++ b/packages/cli/src/commands/list.ts @@ -62,6 +62,12 @@ export default async function listCommand({ async execute() { throw new Error('Unexpected execute call'); }, + async onSuccess() { + throw new Error('Unexpected onSuccess call'); + }, + async onError() { + throw new Error('Unexpected onError call'); + }, }); return error ? 1 : 0; diff --git a/packages/cli/src/commands/remove.test.ts b/packages/cli/src/commands/remove.test.ts new file mode 100644 index 0000000..a283551 --- /dev/null +++ b/packages/cli/src/commands/remove.test.ts @@ -0,0 +1,306 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert'; +import { type EmigrateReporter, type Storage, type Plugin, type MigrationMetadataFinished } from '@emigrate/types'; +import { deserializeError } from 'serialize-error'; +import { version } from '../get-package-info.js'; +import { + BadOptionError, + MigrationNotRunError, + MigrationRemovalError, + OptionNeededError, + StorageInitError, +} from '../errors.js'; +import { + getErrorCause, + getMockedReporter, + getMockedStorage, + toEntry, + toMigrations, + type Mocked, +} from '../test-utils.js'; +import removeCommand from './remove.js'; + +describe('remove', () => { + it("returns 1 and finishes with an error when the storage couldn't be initialized", async () => { + const { reporter, run } = getRemoveCommand([]); + + const exitCode = await run('some_migration.js'); + + assert.strictEqual(exitCode, 1, 'Exit code'); + assertPreconditionsFailed(reporter, StorageInitError.fromError(new Error('No storage configured'))); + }); + + it('returns 1 and finishes with an error when the given migration has not been executed', async () => { + const storage = getMockedStorage(['some_other_migration.js']); + const { reporter, run } = getRemoveCommand(['some_migration.js'], storage); + + const exitCode = await run('some_migration.js'); + + assert.strictEqual(exitCode, 1, 'Exit code'); + assertPreconditionsFulfilled( + reporter, + storage, + [ + { + name: 'some_migration.js', + status: 'failed', + error: new MigrationNotRunError('Migration "some_migration.js" is not in the migration history'), + }, + ], + new MigrationNotRunError('Migration "some_migration.js" is not in the migration history'), + ); + }); + + it('returns 1 and finishes with an error when the given migration is not in a failed state in the history', async () => { + const storage = getMockedStorage(['1_old_migration.js', '2_some_migration.js', '3_new_migration.js']); + const { reporter, run } = getRemoveCommand(['2_some_migration.js'], storage); + + const exitCode = await run('2_some_migration.js'); + + assert.strictEqual(exitCode, 1, 'Exit code'); + assertPreconditionsFulfilled( + reporter, + storage, + [ + { + name: '2_some_migration.js', + status: 'failed', + error: OptionNeededError.fromOption( + 'force', + 'The migration "2_some_migration.js" is not in a failed state. Use the "force" option to force its removal', + ), + }, + ], + OptionNeededError.fromOption( + 'force', + 'The migration "2_some_migration.js" is not in a failed state. Use the "force" option to force its removal', + ), + ); + }); + + it('returns 1 and finishes with an error when the given migration does not exist at all', async () => { + const storage = getMockedStorage(['some_migration.js']); + const { reporter, run } = getRemoveCommand(['some_migration.js'], storage); + + const exitCode = await run('some_other_migration.js'); + + assert.strictEqual(exitCode, 1, 'Exit code'); + assertPreconditionsFulfilled( + reporter, + storage, + [], + BadOptionError.fromOption('name', 'The migration: "migrations/some_other_migration.js" was not found'), + ); + }); + + it('returns 0, removes the migration from the history and finishes without an error when the given migration is in a failed state', async () => { + const storage = getMockedStorage([toEntry('some_migration.js', 'failed')]); + const { reporter, run } = getRemoveCommand(['some_migration.js'], storage); + + const exitCode = await run('some_migration.js'); + + assert.strictEqual(exitCode, 0, 'Exit code'); + assertPreconditionsFulfilled(reporter, storage, [{ name: 'some_migration.js', status: 'done', started: true }]); + }); + + it('returns 0, removes the migration from the history and finishes without an error when the given migration is not in a failed state but "force" is true', async () => { + const storage = getMockedStorage(['1_old_migration.js', '2_some_migration.js', '3_new_migration.js']); + const { reporter, run } = getRemoveCommand(['2_some_migration.js'], storage); + + const exitCode = await run('2_some_migration.js', { force: true }); + + assert.strictEqual(exitCode, 0, 'Exit code'); + assertPreconditionsFulfilled(reporter, storage, [{ name: '2_some_migration.js', status: 'done', started: true }]); + }); + + it('returns 1 and finishes with an error when the removal of the migration crashes', async () => { + const storage = getMockedStorage([toEntry('some_migration.js', 'failed')]); + storage.remove.mock.mockImplementation(async () => { + throw new Error('Some error'); + }); + const { reporter, run } = getRemoveCommand(['some_migration.js'], storage); + + const exitCode = await run('some_migration.js'); + + assert.strictEqual(exitCode, 1, 'Exit code'); + assertPreconditionsFulfilled( + reporter, + storage, + [ + { + name: 'some_migration.js', + status: 'failed', + error: new Error('Some error'), + started: true, + }, + ], + new MigrationRemovalError('Failed to remove migration: migrations/some_migration.js', { + cause: new Error('Some error'), + }), + ); + }); +}); + +function getRemoveCommand(migrationFiles: string[], storage?: Mocked, plugins?: Plugin[]) { + const reporter = getMockedReporter(); + + const run = async ( + name: string, + options?: Omit[0], 'cwd' | 'directory' | 'storage' | 'reporter' | 'plugins'>, + ) => { + return removeCommand( + { + cwd: '/emigrate', + directory: 'migrations', + storage: { + async initializeStorage() { + if (!storage) { + throw new Error('No storage configured'); + } + + return storage; + }, + }, + reporter, + plugins: plugins ?? [], + async getMigrations(cwd, directory) { + return toMigrations(cwd, directory, migrationFiles); + }, + ...options, + }, + name, + ); + }; + + return { + reporter, + storage, + run, + }; +} + +function assertPreconditionsFailed(reporter: Mocked>, finishedError?: Error) { + assert.strictEqual(reporter.onInit.mock.calls.length, 1); + assert.deepStrictEqual(reporter.onInit.mock.calls[0]?.arguments, [ + { + command: 'remove', + cwd: '/emigrate', + version, + dry: false, + 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'); +} + +function assertPreconditionsFulfilled( + reporter: Mocked>, + storage: 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: 'remove', + cwd: '/emigrate', + version, + dry: false, + color: undefined, + directory: 'migrations', + }, + ]); + + let started = 0; + let done = 0; + let failed = 0; + let skipped = 0; + let pending = 0; + let failedAndStarted = 0; + const failedEntries: typeof expected = []; + const successfulEntries: typeof expected = []; + + for (const entry of expected) { + if (entry.started) { + started++; + } + + // eslint-disable-next-line default-case + switch (entry.status) { + case 'done': { + done++; + + if (entry.started) { + successfulEntries.push(entry); + } + + break; + } + + case 'failed': { + failed++; + failedEntries.push(entry); + + if (entry.started) { + failedAndStarted++; + } + + break; + } + + case 'skipped': { + skipped++; + break; + } + + case 'pending': { + pending++; + break; + } + } + } + + assert.strictEqual(reporter.onCollectedMigrations.mock.calls.length, 1, 'Collected call'); + assert.strictEqual(storage.lock.mock.calls.length, 0, 'Storage lock never called'); + assert.strictEqual(storage.unlock.mock.calls.length, 0, 'Storage unlock never called'); + assert.strictEqual(reporter.onLockedMigrations.mock.calls.length, 0, 'Locked call'); + assert.strictEqual(reporter.onMigrationStart.mock.calls.length, started, 'Started migrations'); + assert.strictEqual(reporter.onMigrationSuccess.mock.calls.length, successfulEntries.length, 'Successful migrations'); + assert.strictEqual(storage.remove.mock.calls.length, started, 'Storage remove called'); + assert.strictEqual(reporter.onMigrationError.mock.calls.length, failedEntries.length, '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, expected.length, 'Finished entries length'); + assert.deepStrictEqual( + entries.map((entry) => `${entry.name} (${entry.status})`), + expected.map((entry) => `${entry.name} (${entry.status})`), + 'Finished entries', + ); + assert.strictEqual(storage.end.mock.calls.length, 1, 'Storage end called once'); +} diff --git a/packages/cli/src/commands/remove.ts b/packages/cli/src/commands/remove.ts index cd2a42d..6b3d977 100644 --- a/packages/cli/src/commands/remove.ts +++ b/packages/cli/src/commands/remove.ts @@ -1,29 +1,44 @@ -import process from 'node:process'; +import path from 'node:path'; import { getOrLoadReporter, getOrLoadStorage } from '@emigrate/plugin-tools'; -import { type MigrationHistoryEntry, type MigrationMetadataFinished } from '@emigrate/types'; +import { type MigrationMetadata, isFinishedMigration } from '@emigrate/types'; import { BadOptionError, MigrationNotRunError, + MigrationRemovalError, MissingArgumentsError, MissingOptionError, OptionNeededError, StorageInitError, + toError, } from '../errors.js'; import { type Config } from '../types.js'; -import { getMigration } from '../get-migration.js'; -import { getDuration } from '../get-duration.js'; import { exec } from '../exec.js'; import { version } from '../get-package-info.js'; +import { collectMigrations } from '../collect-migrations.js'; +import { migrationRunner } from '../migration-runner.js'; +import { arrayMapAsync } from '../array-map-async.js'; +import { type GetMigrationsFunction } from '../get-migrations.js'; type ExtraFlags = { cwd: string; force?: boolean; + getMigrations?: GetMigrationsFunction; }; +type RemovableMigrationMetadata = MigrationMetadata & { originalStatus?: 'done' | 'failed' }; + const lazyDefaultReporter = async () => import('../reporters/default.js'); export default async function removeCommand( - { directory, reporter: reporterConfig, storage: storageConfig, color, cwd, force = false }: Config & ExtraFlags, + { + directory, + reporter: reporterConfig, + storage: storageConfig, + color, + cwd, + force = false, + getMigrations, + }: Config & ExtraFlags, name: string, ) { if (!directory) { @@ -59,71 +74,79 @@ export default async function removeCommand( return 1; } - const [migrationFile, fileError] = await exec(async () => getMigration(cwd, directory, name, !force)); + try { + const collectedMigrations = arrayMapAsync( + collectMigrations(cwd, directory, storage.getHistory(), getMigrations), + (migration) => { + if (isFinishedMigration(migration)) { + if (migration.status === 'failed') { + const { status, duration, error, ...pendingMigration } = migration; + const removableMigration: RemovableMigrationMetadata = { ...pendingMigration, originalStatus: status }; - if (fileError) { - await reporter.onFinished?.([], fileError); + return removableMigration; + } - await storage.end(); + if (migration.status === 'done') { + const { status, duration, ...pendingMigration } = migration; + const removableMigration: RemovableMigrationMetadata = { ...pendingMigration, originalStatus: status }; + + return removableMigration; + } + + throw new Error(`Unexpected migration status: ${migration.status}`); + } + + return migration as RemovableMigrationMetadata; + }, + ); + + if (!name.includes(path.sep)) { + name = path.join(directory, name); + } + + const error = await migrationRunner({ + dry: false, + lock: false, + name, + reporter, + storage, + migrations: collectedMigrations, + migrationFilter(migration) { + return migration.relativeFilePath === name; + }, + async validate(migration) { + if (migration.originalStatus === 'done' && !force) { + throw OptionNeededError.fromOption( + 'force', + `The migration "${migration.name}" is not in a failed state. Use the "force" option to force its removal`, + ); + } + + if (!migration.originalStatus) { + throw MigrationNotRunError.fromMetadata(migration); + } + }, + async execute(migration) { + try { + await storage.remove(migration); + } catch (error) { + throw MigrationRemovalError.fromMetadata(migration, toError(error)); + } + }, + async onSuccess() { + // No-op + }, + async onError() { + // No-op + }, + }); + + return error ? 1 : 0; + } catch (error) { + await reporter.onFinished?.([], toError(error)); return 1; + } finally { + await storage.end(); } - - const finishedMigrations: MigrationMetadataFinished[] = []; - let historyEntry: MigrationHistoryEntry | undefined; - let removalError: Error | undefined; - - for await (const migrationHistoryEntry of storage.getHistory()) { - if (migrationHistoryEntry.name !== migrationFile.name) { - continue; - } - - if (migrationHistoryEntry.status === 'done' && !force) { - removalError = OptionNeededError.fromOption( - 'force', - `The migration "${migrationFile.name}" is not in a failed state. Use the "force" option to force its removal`, - ); - } else { - historyEntry = migrationHistoryEntry; - } - } - - await reporter.onMigrationRemoveStart?.(migrationFile); - - const start = process.hrtime(); - - if (historyEntry) { - try { - await storage.remove(migrationFile); - - const duration = getDuration(start); - const finishedMigration: MigrationMetadataFinished = { ...migrationFile, status: 'done', duration }; - - await reporter.onMigrationRemoveSuccess?.(finishedMigration); - - finishedMigrations.push(finishedMigration); - } catch (error) { - removalError = error instanceof Error ? error : new Error(String(error)); - } - } else if (!removalError) { - removalError = MigrationNotRunError.fromMetadata(migrationFile); - } - - if (removalError) { - const duration = getDuration(start); - const finishedMigration: MigrationMetadataFinished = { - ...migrationFile, - status: 'failed', - error: removalError, - duration, - }; - await reporter.onMigrationRemoveError?.(finishedMigration, removalError); - finishedMigrations.push(finishedMigration); - } - - await reporter.onFinished?.(finishedMigrations, removalError); - - await storage.end(); - - return removalError ? 1 : 0; } diff --git a/packages/cli/src/commands/up.test.ts b/packages/cli/src/commands/up.test.ts index 17f1b39..869af10 100644 --- a/packages/cli/src/commands/up.test.ts +++ b/packages/cli/src/commands/up.test.ts @@ -1,8 +1,7 @@ -import { describe, it, mock, type Mock } from 'node:test'; +import { describe, it, mock } from 'node:test'; import assert from 'node:assert'; import { type EmigrateReporter, - type MigrationHistoryEntry, type Storage, type Plugin, type SerializedError, @@ -19,14 +18,16 @@ import { StorageInitError, toSerializedError, } from '../errors.js'; -import { toEntries, toEntry, toMigrations } from '../test-utils.js'; +import { + type Mocked, + toEntry, + toMigrations, + getMockedReporter, + getMockedStorage, + getErrorCause, +} from '../test-utils.js'; import upCommand from './up.js'; -type Mocked = { - // @ts-expect-error - This is a mock - [K in keyof T]: Mock; -}; - describe('up', () => { it("returns 1 and finishes with an error when the storage couldn't be initialized", async () => { const { reporter, run } = getUpCommand(['some_migration.js']); @@ -38,7 +39,7 @@ describe('up', () => { }); it('returns 0 and finishes without an error when there are no migrations to run', async () => { - const storage = getStorage([]); + const storage = getMockedStorage([]); const { reporter, run } = getUpCommand([], storage); const exitCode = await run(); @@ -48,7 +49,7 @@ describe('up', () => { }); it('returns 0 and finishes without an error when all migrations have already been run', async () => { - const storage = getStorage(['my_migration.js']); + const storage = getMockedStorage(['my_migration.js']); const { reporter, run } = getUpCommand(['my_migration.js'], storage); const exitCode = await run(); @@ -58,7 +59,7 @@ describe('up', () => { }); it('returns 0 and finishes without an error when all migrations have already been run even when the history responds without file extensions', async () => { - const storage = getStorage(['my_migration']); + const storage = getMockedStorage(['my_migration']); const { reporter, run } = getUpCommand(['my_migration.js'], storage); const exitCode = await run(); @@ -71,7 +72,7 @@ describe('up', () => { const migration = mock.fn(async () => { // Success }); - const storage = getStorage(['some_already_run_migration.js']); + const storage = getMockedStorage(['some_already_run_migration.js']); const { reporter, run } = getUpCommand( ['some_already_run_migration.js', 'some_migration.js', 'some_other_migration.js'], storage, @@ -96,7 +97,7 @@ describe('up', () => { }); it('returns 1 and finishes with an error when a pending migration throw when run', async () => { - const storage = getStorage(['some_already_run_migration.js']); + const storage = getMockedStorage(['some_already_run_migration.js']); const { reporter, run } = getUpCommand( ['some_already_run_migration.js', 'some_migration.js', 'fail.js', 'some_other_migration.js'], storage, @@ -132,7 +133,7 @@ describe('up', () => { describe('each migration file extension needs a corresponding loader plugin', () => { it('returns 1 and finishes with an error when there are migration file extensions without a corresponding loader plugin', async () => { - const storage = getStorage([]); + const storage = getMockedStorage([]); const { reporter, run } = getUpCommand(['some_other.js', 'some_file.sql'], storage); const exitCode = await run(); @@ -155,7 +156,7 @@ describe('up', () => { }); 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 storage = getStorage([]); + const storage = getMockedStorage([]); const { reporter, run } = getUpCommand(['some_other.js', 'some_file.sql'], storage); const exitCode = await run({ dry: true }); @@ -181,7 +182,7 @@ describe('up', () => { describe('failed migrations in the history are blocking', () => { it('returns 1 and finishes with an error when there are failed migrations in the history', async () => { const failedEntry = toEntry('some_failed_migration.js', 'failed'); - const storage = getStorage([failedEntry]); + const storage = getMockedStorage([failedEntry]); const { reporter, run } = getUpCommand([failedEntry.name, 'some_file.js'], storage); const exitCode = await run(); @@ -211,7 +212,7 @@ describe('up', () => { it('returns 1 and finishes with an error when there are failed migrations in the history in dry-run mode as well', async () => { const failedEntry = toEntry('some_failed_migration.js', 'failed'); - const storage = getStorage([failedEntry]); + const storage = getMockedStorage([failedEntry]); const { reporter, run } = getUpCommand([failedEntry.name, 'some_file.js'], storage); const exitCode = await run({ dry: true }); @@ -241,7 +242,7 @@ describe('up', () => { it('returns 0 and finishes without an error when the failed migrations in the history are not part of the current set of migrations', async () => { const failedEntry = toEntry('some_failed_migration.js', 'failed'); - const storage = getStorage([failedEntry]); + const storage = getMockedStorage([failedEntry]); const { reporter, run } = getUpCommand([], storage); const exitCode = await run(); @@ -255,7 +256,7 @@ describe('up', () => { const migration = mock.fn(async () => { // Success }); - const storage = getStorage(['some_already_run_migration.js']); + const storage = getMockedStorage(['some_already_run_migration.js']); const { reporter, run } = getUpCommand( ['some_already_run_migration.js', 'some_migration.js', 'some_other_migration.js'], storage, @@ -281,7 +282,7 @@ describe('up', () => { describe('limiting which pending migrations to run', () => { 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 storage = getStorage(['some_already_run_migration.js']); + const storage = getMockedStorage(['some_already_run_migration.js']); const { reporter, run } = getUpCommand( ['some_already_run_migration.js', 'some_migration.js', 'some_other_migration.js'], storage, @@ -300,7 +301,7 @@ describe('up', () => { const migration = mock.fn(async () => { // Success }); - const storage = getStorage(['1_some_already_run_migration.js']); + const storage = getMockedStorage(['1_some_already_run_migration.js']); const { reporter, run } = getUpCommand( [ '1_some_already_run_migration.js', @@ -334,7 +335,7 @@ describe('up', () => { const migration = mock.fn(async () => { // Success }); - const storage = getStorage(['1_some_already_run_migration.js']); + const storage = getMockedStorage(['1_some_already_run_migration.js']); const { reporter, run } = getUpCommand( [ '1_some_already_run_migration.js', @@ -368,7 +369,7 @@ describe('up', () => { const migration = mock.fn(async () => { // Success }); - const storage = getStorage(['1_some_already_run_migration.js']); + const storage = getMockedStorage(['1_some_already_run_migration.js']); const { reporter, run } = getUpCommand( ['1_some_already_run_migration.js', '2_some_migration.js', '4_some_other_migration.js'], storage, @@ -396,7 +397,7 @@ describe('up', () => { const migration = mock.fn(async () => { // Success }); - const storage = getStorage(['1_some_already_run_migration.js']); + const storage = getMockedStorage(['1_some_already_run_migration.js']); const { reporter, run } = getUpCommand( ['1_some_already_run_migration.js', '2_some_migration.js', '4_some_other_migration.js'], storage, @@ -430,7 +431,7 @@ describe('up', () => { }); it('returns 0 and finishes without an error when pending migrations after given "from" parameter are validated and listed successfully in dry-mode', async () => { - const storage = getStorage(['1_some_already_run_migration.js']); + const storage = getMockedStorage(['1_some_already_run_migration.js']); const { reporter, run } = getUpCommand( ['1_some_already_run_migration.js', '2_some_migration.js', '3_some_other_migration.js'], storage, @@ -449,7 +450,7 @@ describe('up', () => { const migration = mock.fn(async () => { // Success }); - const storage = getStorage(['1_some_already_run_migration.js']); + const storage = getMockedStorage(['1_some_already_run_migration.js']); const { reporter, run } = getUpCommand( [ '1_some_already_run_migration.js', @@ -483,7 +484,7 @@ describe('up', () => { const migration = mock.fn(async () => { // Success }); - const storage = getStorage(['1_some_already_run_migration.js']); + const storage = getMockedStorage(['1_some_already_run_migration.js']); const { reporter, run } = getUpCommand( ['1_some_already_run_migration.js', '2_some_migration.js', '4_some_other_migration.js'], storage, @@ -517,7 +518,7 @@ describe('up', () => { const migration = mock.fn(async () => { // Success }); - const storage = getStorage(['1_some_already_run_migration.js']); + const storage = getMockedStorage(['1_some_already_run_migration.js']); const { reporter, run } = getUpCommand( ['1_some_already_run_migration.js', '2_some_migration.js', '4_some_other_migration.js'], storage, @@ -542,7 +543,7 @@ describe('up', () => { }); it('returns 0 and finishes without an error when pending migrations after given "to" parameter are validated and listed successfully in dry-mode', async () => { - const storage = getStorage(['1_some_already_run_migration.js']); + const storage = getMockedStorage(['1_some_already_run_migration.js']); const { reporter, run } = getUpCommand( [ '1_some_already_run_migration.js', @@ -567,7 +568,7 @@ describe('up', () => { const migration = mock.fn(async () => { // Success }); - const storage = getStorage(['1_some_already_run_migration.js']); + const storage = getMockedStorage(['1_some_already_run_migration.js']); const { reporter, run } = getUpCommand( [ '1_some_already_run_migration.js', @@ -607,7 +608,7 @@ describe('up', () => { const migration = mock.fn(async () => { // Success }); - const storage = getStorage(['1_some_already_run_migration.js']); + const storage = getMockedStorage(['1_some_already_run_migration.js']); const { reporter, run } = getUpCommand( [ '1_some_already_run_migration.js', @@ -651,7 +652,7 @@ describe('up', () => { const migration = mock.fn(async () => { // Success }); - const storage = getStorage(['1_some_already_run_migration.js']); + const storage = getMockedStorage(['1_some_already_run_migration.js']); const { reporter, run } = getUpCommand( [ '1_some_already_run_migration.js', @@ -703,7 +704,7 @@ describe('up', () => { }, { times: 1 }, ); - const storage = getStorage(['1_some_already_run_migration.js']); + const storage = getMockedStorage(['1_some_already_run_migration.js']); const { reporter, run } = getUpCommand( [ '1_some_already_run_migration.js', @@ -762,7 +763,7 @@ describe('up', () => { }, { times: 1 }, ); - const storage = getStorage(['1_some_already_run_migration.js']); + const storage = getMockedStorage(['1_some_already_run_migration.js']); const { reporter, run } = getUpCommand( [ '1_some_already_run_migration.js', @@ -812,56 +813,8 @@ describe('up', () => { }); }); -function getErrorCause(error: Error | undefined): Error | SerializedError | undefined { - if (error?.cause instanceof Error) { - return error.cause; - } - - if (typeof error?.cause === 'object' && error.cause !== null) { - return error.cause as unknown as SerializedError; - } - - return undefined; -} - -async function noop() { - // noop -} - -function getStorage(historyEntries: Array) { - const storage: Mocked = { - lock: mock.fn(async (migrations) => migrations), - unlock: mock.fn(async () => { - // void - }), - getHistory: mock.fn(async function* () { - yield* toEntries(historyEntries); - }), - remove: mock.fn(), - onSuccess: mock.fn(), - onError: mock.fn(), - end: mock.fn(), - }; - - return storage; -} - function getUpCommand(migrationFiles: string[], storage?: Mocked, plugins?: Plugin[]) { - const reporter: Mocked> = { - onFinished: mock.fn(noop), - onInit: mock.fn(noop), - onAbort: mock.fn(noop), - onCollectedMigrations: mock.fn(noop), - onLockedMigrations: mock.fn(noop), - onNewMigration: mock.fn(noop), - onMigrationRemoveStart: mock.fn(noop), - onMigrationRemoveSuccess: mock.fn(noop), - onMigrationRemoveError: mock.fn(noop), - onMigrationStart: mock.fn(noop), - onMigrationSuccess: mock.fn(noop), - onMigrationError: mock.fn(noop), - onMigrationSkip: mock.fn(noop), - }; + const reporter = getMockedReporter(); const run = async ( options?: Omit< diff --git a/packages/cli/src/commands/up.ts b/packages/cli/src/commands/up.ts index 5488f2a..e0faba4 100644 --- a/packages/cli/src/commands/up.ts +++ b/packages/cli/src/commands/up.ts @@ -1,7 +1,14 @@ import path from 'node:path'; import { getOrLoadPlugins, getOrLoadReporter, getOrLoadStorage } from '@emigrate/plugin-tools'; import { isFinishedMigration, type LoaderPlugin } from '@emigrate/types'; -import { BadOptionError, MigrationLoadError, MissingOptionError, StorageInitError, toError } from '../errors.js'; +import { + BadOptionError, + MigrationLoadError, + MissingOptionError, + StorageInitError, + toError, + toSerializedError, +} from '../errors.js'; import { type Config } from '../types.js'; import { withLeadingPeriod } from '../with-leading-period.js'; import { type GetMigrationsFunction } from '../get-migrations.js'; @@ -138,6 +145,12 @@ export default async function upCommand({ await migrationFunction(); }, + async onSuccess(migration) { + await storage.onSuccess(migration); + }, + async onError(migration, error) { + await storage.onError(migration, toSerializedError(error)); + }, }); return error ? 1 : 0; diff --git a/packages/cli/src/errors.ts b/packages/cli/src/errors.ts index 8582050..5e276bb 100644 --- a/packages/cli/src/errors.ts +++ b/packages/cli/src/errors.ts @@ -137,6 +137,16 @@ export class MigrationNotRunError extends EmigrateError { } } +export class MigrationRemovalError extends EmigrateError { + static fromMetadata(metadata: MigrationMetadata, cause?: Error) { + return new MigrationRemovalError(`Failed to remove migration: ${metadata.relativeFilePath}`, { cause }); + } + + constructor(message: string | undefined, options?: ErrorOptions) { + super(message, options, 'ERR_MIGRATION_REMOVE'); + } +} + export class StorageInitError extends EmigrateError { static fromError(error: Error) { return new StorageInitError('Could not initialize storage', { cause: error }); @@ -182,6 +192,7 @@ errorConstructors.set('MigrationHistoryError', MigrationHistoryError as unknown errorConstructors.set('MigrationLoadError', MigrationLoadError as unknown as ErrorConstructor); errorConstructors.set('MigrationRunError', MigrationRunError as unknown as ErrorConstructor); errorConstructors.set('MigrationNotRunError', MigrationNotRunError as unknown as ErrorConstructor); +errorConstructors.set('MigrationRemovalError', MigrationRemovalError as unknown as ErrorConstructor); errorConstructors.set('StorageInitError', StorageInitError as unknown as ErrorConstructor); errorConstructors.set('CommandAbortError', CommandAbortError as unknown as ErrorConstructor); errorConstructors.set('ExecutionDesertedError', ExecutionDesertedError as unknown as ErrorConstructor); diff --git a/packages/cli/src/migration-runner.ts b/packages/cli/src/migration-runner.ts index a8ee424..1aeea3f 100644 --- a/packages/cli/src/migration-runner.ts +++ b/packages/cli/src/migration-runner.ts @@ -9,28 +9,34 @@ import { type FailedMigrationMetadata, type SuccessfulMigrationMetadata, } from '@emigrate/types'; -import { toError, EmigrateError, MigrationRunError, toSerializedError, BadOptionError } from './errors.js'; +import { toError, EmigrateError, MigrationRunError, BadOptionError } from './errors.js'; import { exec } from './exec.js'; import { getDuration } from './get-duration.js'; -type MigrationRunnerParameters = { +type MigrationRunnerParameters = { dry: boolean; + lock?: boolean; limit?: number; + name?: string; from?: string; to?: string; abortSignal?: AbortSignal; abortRespite?: number; reporter: EmigrateReporter; storage: Storage; - migrations: AsyncIterable; - migrationFilter?: (migration: MigrationMetadata | MigrationMetadataFinished) => boolean; - validate: (migration: MigrationMetadata) => Promise; - execute: (migration: MigrationMetadata) => Promise; + migrations: AsyncIterable; + migrationFilter?: (migration: T) => boolean; + validate: (migration: T) => Promise; + execute: (migration: T) => Promise; + onSuccess: (migration: SuccessfulMigrationMetadata) => Promise; + onError: (migration: FailedMigrationMetadata, error: Error) => Promise; }; -export const migrationRunner = async ({ +export const migrationRunner = async ({ dry, + lock = true, limit, + name, from, to, abortSignal, @@ -40,8 +46,10 @@ export const migrationRunner = async ({ migrations, validate, execute, + onSuccess, + onError, migrationFilter = () => true, -}: MigrationRunnerParameters): Promise => { +}: MigrationRunnerParameters): Promise => { const collectedMigrations: Array = []; const validatedMigrations: Array = []; const migrationsToLock: MigrationMetadata[] = []; @@ -64,10 +72,15 @@ export const migrationRunner = async ({ { once: true }, ); + let nameFound = false; let fromFound = false; let toFound = false; for await (const migration of migrations) { + if (name && migration.relativeFilePath === name) { + nameFound = true; + } + if (from && migration.relativeFilePath === from) { fromFound = true; } @@ -129,7 +142,9 @@ export const migrationRunner = async ({ let optionError: Error | undefined; - if (from && !fromFound) { + if (name && !nameFound) { + optionError = BadOptionError.fromOption('name', `The migration: "${name}" was not found`); + } else if (from && !fromFound) { optionError = BadOptionError.fromOption('from', `The "from" migration: "${from}" was not found`); } else if (to && !toFound) { optionError = BadOptionError.fromOption('to', `The "to" migration: "${to}" was not found`); @@ -151,9 +166,10 @@ export const migrationRunner = async ({ migrationsToLock.length = 0; } - const [lockedMigrations, lockError] = dry - ? [migrationsToLock] - : await exec(async () => storage.lock(migrationsToLock), { abortSignal, abortRespite }); + const [lockedMigrations, lockError] = + dry || !lock + ? [migrationsToLock] + : await exec(async () => storage.lock(migrationsToLock), { abortSignal, abortRespite }); if (lockError) { for (const migration of migrationsToLock) { @@ -168,7 +184,7 @@ export const migrationRunner = async ({ migrationsToLock.length = 0; skip = true; - } else { + } else if (lock) { for (const migration of migrationsToLock) { const isLocked = lockedMigrations.some((lockedMigration) => lockedMigration.name === migration.name); @@ -231,7 +247,7 @@ export const migrationRunner = async ({ const start = hrtime(); - const [, migrationError] = await exec(async () => execute(migration), { abortSignal, abortRespite }); + const [, migrationError] = await exec(async () => execute(migration as T), { abortSignal, abortRespite }); const duration = getDuration(start); @@ -242,7 +258,7 @@ export const migrationRunner = async ({ duration, error: migrationError, }; - await storage.onError(finishedMigration, toSerializedError(migrationError)); + await onError(finishedMigration, migrationError); await reporter.onMigrationError?.(finishedMigration, migrationError); finishedMigrations.push(finishedMigration); skip = true; @@ -252,15 +268,14 @@ export const migrationRunner = async ({ status: 'done', duration, }; - await storage.onSuccess(finishedMigration); + await onSuccess(finishedMigration); await reporter.onMigrationSuccess?.(finishedMigration); finishedMigrations.push(finishedMigration); } } - const [, unlockError] = dry - ? [] - : await exec(async () => storage.unlock(lockedMigrations ?? []), { abortSignal, abortRespite }); + const [, unlockError] = + dry || !lock ? [] : await exec(async () => storage.unlock(lockedMigrations ?? []), { abortSignal, abortRespite }); // eslint-disable-next-line unicorn/no-array-callback-reference const firstFailed = finishedMigrations.find(isFailedMigration); diff --git a/packages/cli/src/reporters/default.ts b/packages/cli/src/reporters/default.ts index 1caeda7..c1bc4d1 100644 --- a/packages/cli/src/reporters/default.ts +++ b/packages/cli/src/reporters/default.ts @@ -13,6 +13,7 @@ import { } from '@emigrate/types'; type Status = ReturnType; +type Command = ReporterInitParameters['command']; const interactive = isInteractive(); const spinner = interactive ? elegantSpinner() : () => figures.pointerSmall; @@ -30,11 +31,16 @@ const getTitle = ({ command, version, dry, cwd }: ReporterInitParameters) => { }; const getMigrationStatus = ( + command: Command, migration: MigrationMetadata | MigrationMetadataFinished, activeMigration?: MigrationMetadata, ) => { if ('status' in migration) { - return migration.status; + return command === 'remove' && migration.status === 'done' ? 'removed' : migration.status; + } + + if (command === 'remove' && migration.name === activeMigration?.name) { + return 'removing'; } return migration.name === activeMigration?.name ? 'running' : 'pending'; @@ -42,6 +48,10 @@ const getMigrationStatus = ( const getIcon = (status: Status) => { switch (status) { + case 'removing': { + return cyan(spinner()); + } + case 'running': { return cyan(spinner()); } @@ -50,6 +60,10 @@ const getIcon = (status: Status) => { return gray(figures.pointerSmall); } + case 'removed': { + return green(figures.tick); + } + case 'done': { return green(figures.tick); } @@ -89,20 +103,19 @@ const getName = (name: string, status?: Status) => { }; const getMigrationText = ( + command: Command, migration: MigrationMetadata | MigrationMetadataFinished, activeMigration?: MigrationMetadata, ) => { const pathWithoutName = migration.relativeFilePath.slice(0, -migration.name.length); const nameWithoutExtension = migration.name.slice(0, -migration.extension.length); - const status = getMigrationStatus(migration, activeMigration); + const status = getMigrationStatus(command, migration, activeMigration); const parts = [' ', getIcon(status)]; parts.push(`${dim(pathWithoutName)}${getName(nameWithoutExtension, status)}${dim(migration.extension)}`); - if ('status' in migration) { - parts.push(gray`(${migration.status})`); - } else if (migration.name === activeMigration?.name) { - parts.push(gray`(running)`); + if ('status' in migration || migration.name === activeMigration?.name) { + parts.push(gray`(${status})`); } if ('duration' in migration && migration.duration) { @@ -319,19 +332,6 @@ class DefaultFancyReporter implements Required { this.#migrations = [migration]; } - onMigrationRemoveStart(migration: MigrationMetadata): Awaitable { - this.#migrations = [migration]; - this.#activeMigration = migration; - } - - onMigrationRemoveSuccess(migration: MigrationMetadataFinished): Awaitable { - this.#finishMigration(migration); - } - - onMigrationRemoveError(migration: MigrationMetadataFinished, _error: Error): Awaitable { - this.#finishMigration(migration); - } - onMigrationStart(migration: MigrationMetadata): void | PromiseLike { this.#activeMigration = migration; } @@ -376,7 +376,9 @@ class DefaultFancyReporter implements Required { const parts = [ getTitle(this.#parameters), getHeaderMessage(this.#parameters.command, this.#migrations, this.#lockedMigrations), - this.#migrations?.map((migration) => getMigrationText(migration, this.#activeMigration)).join('\n') ?? '', + this.#migrations + ?.map((migration) => getMigrationText(this.#parameters.command, migration, this.#activeMigration)) + .join('\n') ?? '', getAbortMessage(this.#abortReason), getSummary(this.#parameters.command, this.#migrations), getError(this.#error), @@ -441,35 +443,23 @@ class DefaultReporter implements Required { } onNewMigration(migration: MigrationMetadata, _content: string): Awaitable { - console.log(getMigrationText(migration)); - } - - onMigrationRemoveStart(migration: MigrationMetadata): Awaitable { - console.log(getMigrationText(migration)); - } - - onMigrationRemoveSuccess(migration: MigrationMetadataFinished): Awaitable { - console.log(getMigrationText(migration)); - } - - onMigrationRemoveError(migration: MigrationMetadataFinished, _error: Error): Awaitable { - console.error(getMigrationText(migration)); + console.log(getMigrationText(this.#parameters.command, migration)); } onMigrationStart(migration: MigrationMetadata): void | PromiseLike { - console.log(getMigrationText(migration, migration)); + console.log(getMigrationText(this.#parameters.command, migration, migration)); } onMigrationSuccess(migration: MigrationMetadataFinished): void | PromiseLike { - console.log(getMigrationText(migration)); + console.log(getMigrationText(this.#parameters.command, migration)); } onMigrationError(migration: MigrationMetadataFinished, _error: Error): void | PromiseLike { - console.error(getMigrationText(migration)); + console.error(getMigrationText(this.#parameters.command, migration)); } onMigrationSkip(migration: MigrationMetadataFinished): void | PromiseLike { - console.log(getMigrationText(migration)); + console.log(getMigrationText(this.#parameters.command, migration)); } onFinished(migrations: MigrationMetadataFinished[], error?: Error | undefined): void | PromiseLike { diff --git a/packages/cli/src/test-utils.ts b/packages/cli/src/test-utils.ts index 2e1b7cc..b6228d2 100644 --- a/packages/cli/src/test-utils.ts +++ b/packages/cli/src/test-utils.ts @@ -1,11 +1,69 @@ +import { mock, type Mock } from 'node:test'; import path from 'node:path'; import { + type SerializedError, + type EmigrateReporter, type FailedMigrationHistoryEntry, type MigrationHistoryEntry, type MigrationMetadata, type NonFailedMigrationHistoryEntry, + type Storage, } from '@emigrate/types'; +export type Mocked = { + // @ts-expect-error - This is a mock + [K in keyof T]: Mock; +}; + +export async function noop() { + // noop +} + +export function getErrorCause(error: Error | undefined): Error | SerializedError | undefined { + if (error?.cause instanceof Error) { + return error.cause; + } + + if (typeof error?.cause === 'object' && error.cause !== null) { + return error.cause as unknown as SerializedError; + } + + return undefined; +} + +export function getMockedStorage(historyEntries: Array) { + const storage: Mocked = { + lock: mock.fn(async (migrations) => migrations), + unlock: mock.fn(async () => { + // void + }), + getHistory: mock.fn(async function* () { + yield* toEntries(historyEntries); + }), + remove: mock.fn(), + onSuccess: mock.fn(), + onError: mock.fn(), + end: mock.fn(), + }; + + return storage; +} + +export function getMockedReporter(): Mocked> { + return { + onFinished: mock.fn(noop), + onInit: mock.fn(noop), + onAbort: mock.fn(noop), + onCollectedMigrations: mock.fn(noop), + onLockedMigrations: mock.fn(noop), + onNewMigration: mock.fn(noop), + onMigrationStart: mock.fn(noop), + onMigrationSuccess: mock.fn(noop), + onMigrationError: mock.fn(noop), + onMigrationSkip: mock.fn(noop), + }; +} + export function toMigration(cwd: string, directory: string, name: string): MigrationMetadata { return { name,