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.
This commit is contained in:
Joakim Carlstein 2024-01-26 15:11:02 +01:00 committed by Joakim Carlstein
parent 86e0d52e5c
commit 1f139fd975
12 changed files with 593 additions and 209 deletions

View file

@ -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.

View file

@ -0,0 +1,5 @@
export async function* arrayMapAsync<T, U>(iterable: AsyncIterable<T>, mapper: (item: T) => U): AsyncIterable<U> {
for await (const item of iterable) {
yield mapper(item);
}
}

View file

@ -457,8 +457,7 @@ Options:
For example if you want to use Dotenv to load environment variables For example if you want to use Dotenv to load environment variables
-r, --reporter <name> The reporter to use for reporting the removal process -r, --reporter <name> The reporter to use for reporting the removal process
-s, --storage <name> The storage to use to get the migration history (required) -s, --storage <name> 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 -f, --force Force removal of the migration history entry even if the migration is not in a failed state
or it's in a non-failed state
--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)

View file

@ -62,6 +62,12 @@ export default async function listCommand({
async execute() { async execute() {
throw new Error('Unexpected execute call'); 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; return error ? 1 : 0;

View file

@ -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<Storage>, plugins?: Plugin[]) {
const reporter = getMockedReporter();
const run = async (
name: string,
options?: Omit<Parameters<typeof removeCommand>[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<Required<EmigrateReporter>>, 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<Required<EmigrateReporter>>,
storage: Mocked<Storage>,
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');
}

View file

@ -1,29 +1,44 @@
import process from 'node:process'; import path from 'node:path';
import { getOrLoadReporter, getOrLoadStorage } from '@emigrate/plugin-tools'; import { getOrLoadReporter, getOrLoadStorage } from '@emigrate/plugin-tools';
import { type MigrationHistoryEntry, type MigrationMetadataFinished } from '@emigrate/types'; import { type MigrationMetadata, isFinishedMigration } from '@emigrate/types';
import { import {
BadOptionError, BadOptionError,
MigrationNotRunError, MigrationNotRunError,
MigrationRemovalError,
MissingArgumentsError, MissingArgumentsError,
MissingOptionError, MissingOptionError,
OptionNeededError, OptionNeededError,
StorageInitError, StorageInitError,
toError,
} from '../errors.js'; } from '../errors.js';
import { type Config } from '../types.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 { exec } from '../exec.js';
import { version } from '../get-package-info.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 = { type ExtraFlags = {
cwd: string; cwd: string;
force?: boolean; force?: boolean;
getMigrations?: GetMigrationsFunction;
}; };
type RemovableMigrationMetadata = MigrationMetadata & { originalStatus?: 'done' | 'failed' };
const lazyDefaultReporter = async () => import('../reporters/default.js'); const lazyDefaultReporter = async () => import('../reporters/default.js');
export default async function removeCommand( 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, name: string,
) { ) {
if (!directory) { if (!directory) {
@ -59,71 +74,79 @@ export default async function removeCommand(
return 1; 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) { return removableMigration;
await reporter.onFinished?.([], fileError); }
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; return 1;
} } finally {
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(); await storage.end();
}
return removalError ? 1 : 0;
} }

View file

@ -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 assert from 'node:assert';
import { import {
type EmigrateReporter, type EmigrateReporter,
type MigrationHistoryEntry,
type Storage, type Storage,
type Plugin, type Plugin,
type SerializedError, type SerializedError,
@ -19,14 +18,16 @@ import {
StorageInitError, StorageInitError,
toSerializedError, toSerializedError,
} from '../errors.js'; } 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'; import upCommand from './up.js';
type Mocked<T> = {
// @ts-expect-error - This is a mock
[K in keyof T]: Mock<T[K]>;
};
describe('up', () => { describe('up', () => {
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 () => {
const { reporter, run } = getUpCommand(['some_migration.js']); 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 () => { 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 { reporter, run } = getUpCommand([], storage);
const exitCode = await run(); 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 () => { 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 { reporter, run } = getUpCommand(['my_migration.js'], storage);
const exitCode = await run(); 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 () => { 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 { reporter, run } = getUpCommand(['my_migration.js'], storage);
const exitCode = await run(); const exitCode = await run();
@ -71,7 +72,7 @@ describe('up', () => {
const migration = mock.fn(async () => { const migration = mock.fn(async () => {
// Success // Success
}); });
const storage = getStorage(['some_already_run_migration.js']); const storage = getMockedStorage(['some_already_run_migration.js']);
const { reporter, run } = getUpCommand( const { reporter, run } = getUpCommand(
['some_already_run_migration.js', 'some_migration.js', 'some_other_migration.js'], ['some_already_run_migration.js', 'some_migration.js', 'some_other_migration.js'],
storage, storage,
@ -96,7 +97,7 @@ describe('up', () => {
}); });
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 storage = getStorage(['some_already_run_migration.js']); const storage = getMockedStorage(['some_already_run_migration.js']);
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'],
storage, storage,
@ -132,7 +133,7 @@ describe('up', () => {
describe('each migration file extension needs a corresponding loader plugin', () => { 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 () => { 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 { reporter, run } = getUpCommand(['some_other.js', 'some_file.sql'], storage);
const exitCode = await run(); 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 () => { 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 { reporter, run } = getUpCommand(['some_other.js', 'some_file.sql'], storage);
const exitCode = await run({ dry: true }); const exitCode = await run({ dry: true });
@ -181,7 +182,7 @@ describe('up', () => {
describe('failed migrations in the history are blocking', () => { 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 () => { 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 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 { reporter, run } = getUpCommand([failedEntry.name, 'some_file.js'], storage);
const exitCode = await run(); 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 () => { 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 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 { reporter, run } = getUpCommand([failedEntry.name, 'some_file.js'], storage);
const exitCode = await run({ dry: true }); 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 () => { 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 failedEntry = toEntry('some_failed_migration.js', 'failed');
const storage = getStorage([failedEntry]); const storage = getMockedStorage([failedEntry]);
const { reporter, run } = getUpCommand([], storage); const { reporter, run } = getUpCommand([], storage);
const exitCode = await run(); const exitCode = await run();
@ -255,7 +256,7 @@ describe('up', () => {
const migration = mock.fn(async () => { const migration = mock.fn(async () => {
// Success // Success
}); });
const storage = getStorage(['some_already_run_migration.js']); const storage = getMockedStorage(['some_already_run_migration.js']);
const { reporter, run } = getUpCommand( const { reporter, run } = getUpCommand(
['some_already_run_migration.js', 'some_migration.js', 'some_other_migration.js'], ['some_already_run_migration.js', 'some_migration.js', 'some_other_migration.js'],
storage, storage,
@ -281,7 +282,7 @@ describe('up', () => {
describe('limiting which pending migrations to run', () => { 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 () => { 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( const { reporter, run } = getUpCommand(
['some_already_run_migration.js', 'some_migration.js', 'some_other_migration.js'], ['some_already_run_migration.js', 'some_migration.js', 'some_other_migration.js'],
storage, storage,
@ -300,7 +301,7 @@ describe('up', () => {
const migration = mock.fn(async () => { const migration = mock.fn(async () => {
// Success // Success
}); });
const storage = getStorage(['1_some_already_run_migration.js']); const storage = getMockedStorage(['1_some_already_run_migration.js']);
const { reporter, run } = getUpCommand( const { reporter, run } = getUpCommand(
[ [
'1_some_already_run_migration.js', '1_some_already_run_migration.js',
@ -334,7 +335,7 @@ describe('up', () => {
const migration = mock.fn(async () => { const migration = mock.fn(async () => {
// Success // Success
}); });
const storage = getStorage(['1_some_already_run_migration.js']); const storage = getMockedStorage(['1_some_already_run_migration.js']);
const { reporter, run } = getUpCommand( const { reporter, run } = getUpCommand(
[ [
'1_some_already_run_migration.js', '1_some_already_run_migration.js',
@ -368,7 +369,7 @@ describe('up', () => {
const migration = mock.fn(async () => { const migration = mock.fn(async () => {
// Success // Success
}); });
const storage = getStorage(['1_some_already_run_migration.js']); const storage = getMockedStorage(['1_some_already_run_migration.js']);
const { reporter, run } = getUpCommand( const { reporter, run } = getUpCommand(
['1_some_already_run_migration.js', '2_some_migration.js', '4_some_other_migration.js'], ['1_some_already_run_migration.js', '2_some_migration.js', '4_some_other_migration.js'],
storage, storage,
@ -396,7 +397,7 @@ describe('up', () => {
const migration = mock.fn(async () => { const migration = mock.fn(async () => {
// Success // Success
}); });
const storage = getStorage(['1_some_already_run_migration.js']); const storage = getMockedStorage(['1_some_already_run_migration.js']);
const { reporter, run } = getUpCommand( const { reporter, run } = getUpCommand(
['1_some_already_run_migration.js', '2_some_migration.js', '4_some_other_migration.js'], ['1_some_already_run_migration.js', '2_some_migration.js', '4_some_other_migration.js'],
storage, 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 () => { 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( const { reporter, run } = getUpCommand(
['1_some_already_run_migration.js', '2_some_migration.js', '3_some_other_migration.js'], ['1_some_already_run_migration.js', '2_some_migration.js', '3_some_other_migration.js'],
storage, storage,
@ -449,7 +450,7 @@ describe('up', () => {
const migration = mock.fn(async () => { const migration = mock.fn(async () => {
// Success // Success
}); });
const storage = getStorage(['1_some_already_run_migration.js']); const storage = getMockedStorage(['1_some_already_run_migration.js']);
const { reporter, run } = getUpCommand( const { reporter, run } = getUpCommand(
[ [
'1_some_already_run_migration.js', '1_some_already_run_migration.js',
@ -483,7 +484,7 @@ describe('up', () => {
const migration = mock.fn(async () => { const migration = mock.fn(async () => {
// Success // Success
}); });
const storage = getStorage(['1_some_already_run_migration.js']); const storage = getMockedStorage(['1_some_already_run_migration.js']);
const { reporter, run } = getUpCommand( const { reporter, run } = getUpCommand(
['1_some_already_run_migration.js', '2_some_migration.js', '4_some_other_migration.js'], ['1_some_already_run_migration.js', '2_some_migration.js', '4_some_other_migration.js'],
storage, storage,
@ -517,7 +518,7 @@ describe('up', () => {
const migration = mock.fn(async () => { const migration = mock.fn(async () => {
// Success // Success
}); });
const storage = getStorage(['1_some_already_run_migration.js']); const storage = getMockedStorage(['1_some_already_run_migration.js']);
const { reporter, run } = getUpCommand( const { reporter, run } = getUpCommand(
['1_some_already_run_migration.js', '2_some_migration.js', '4_some_other_migration.js'], ['1_some_already_run_migration.js', '2_some_migration.js', '4_some_other_migration.js'],
storage, 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 () => { 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( const { reporter, run } = getUpCommand(
[ [
'1_some_already_run_migration.js', '1_some_already_run_migration.js',
@ -567,7 +568,7 @@ describe('up', () => {
const migration = mock.fn(async () => { const migration = mock.fn(async () => {
// Success // Success
}); });
const storage = getStorage(['1_some_already_run_migration.js']); const storage = getMockedStorage(['1_some_already_run_migration.js']);
const { reporter, run } = getUpCommand( const { reporter, run } = getUpCommand(
[ [
'1_some_already_run_migration.js', '1_some_already_run_migration.js',
@ -607,7 +608,7 @@ describe('up', () => {
const migration = mock.fn(async () => { const migration = mock.fn(async () => {
// Success // Success
}); });
const storage = getStorage(['1_some_already_run_migration.js']); const storage = getMockedStorage(['1_some_already_run_migration.js']);
const { reporter, run } = getUpCommand( const { reporter, run } = getUpCommand(
[ [
'1_some_already_run_migration.js', '1_some_already_run_migration.js',
@ -651,7 +652,7 @@ describe('up', () => {
const migration = mock.fn(async () => { const migration = mock.fn(async () => {
// Success // Success
}); });
const storage = getStorage(['1_some_already_run_migration.js']); const storage = getMockedStorage(['1_some_already_run_migration.js']);
const { reporter, run } = getUpCommand( const { reporter, run } = getUpCommand(
[ [
'1_some_already_run_migration.js', '1_some_already_run_migration.js',
@ -703,7 +704,7 @@ describe('up', () => {
}, },
{ times: 1 }, { times: 1 },
); );
const storage = getStorage(['1_some_already_run_migration.js']); const storage = getMockedStorage(['1_some_already_run_migration.js']);
const { reporter, run } = getUpCommand( const { reporter, run } = getUpCommand(
[ [
'1_some_already_run_migration.js', '1_some_already_run_migration.js',
@ -762,7 +763,7 @@ describe('up', () => {
}, },
{ times: 1 }, { times: 1 },
); );
const storage = getStorage(['1_some_already_run_migration.js']); const storage = getMockedStorage(['1_some_already_run_migration.js']);
const { reporter, run } = getUpCommand( const { reporter, run } = getUpCommand(
[ [
'1_some_already_run_migration.js', '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<string | MigrationHistoryEntry>) {
const storage: Mocked<Storage> = {
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<Storage>, plugins?: Plugin[]) { function getUpCommand(migrationFiles: string[], storage?: Mocked<Storage>, plugins?: Plugin[]) {
const reporter: Mocked<Required<EmigrateReporter>> = { const reporter = getMockedReporter();
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 run = async ( const run = async (
options?: Omit< options?: Omit<

View file

@ -1,7 +1,14 @@
import path from 'node:path'; import path from 'node:path';
import { getOrLoadPlugins, getOrLoadReporter, getOrLoadStorage } from '@emigrate/plugin-tools'; import { getOrLoadPlugins, getOrLoadReporter, getOrLoadStorage } from '@emigrate/plugin-tools';
import { isFinishedMigration, type LoaderPlugin } from '@emigrate/types'; 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 { type Config } from '../types.js';
import { withLeadingPeriod } from '../with-leading-period.js'; import { withLeadingPeriod } from '../with-leading-period.js';
import { type GetMigrationsFunction } from '../get-migrations.js'; import { type GetMigrationsFunction } from '../get-migrations.js';
@ -138,6 +145,12 @@ export default async function upCommand({
await migrationFunction(); await migrationFunction();
}, },
async onSuccess(migration) {
await storage.onSuccess(migration);
},
async onError(migration, error) {
await storage.onError(migration, toSerializedError(error));
},
}); });
return error ? 1 : 0; return error ? 1 : 0;

View file

@ -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 { export class StorageInitError extends EmigrateError {
static fromError(error: Error) { static fromError(error: Error) {
return new StorageInitError('Could not initialize storage', { cause: 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('MigrationLoadError', MigrationLoadError as unknown as ErrorConstructor);
errorConstructors.set('MigrationRunError', MigrationRunError as unknown as ErrorConstructor); errorConstructors.set('MigrationRunError', MigrationRunError as unknown as ErrorConstructor);
errorConstructors.set('MigrationNotRunError', MigrationNotRunError 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('StorageInitError', StorageInitError as unknown as ErrorConstructor);
errorConstructors.set('CommandAbortError', CommandAbortError as unknown as ErrorConstructor); errorConstructors.set('CommandAbortError', CommandAbortError as unknown as ErrorConstructor);
errorConstructors.set('ExecutionDesertedError', ExecutionDesertedError as unknown as ErrorConstructor); errorConstructors.set('ExecutionDesertedError', ExecutionDesertedError as unknown as ErrorConstructor);

View file

@ -9,28 +9,34 @@ import {
type FailedMigrationMetadata, type FailedMigrationMetadata,
type SuccessfulMigrationMetadata, type SuccessfulMigrationMetadata,
} from '@emigrate/types'; } 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 { exec } from './exec.js';
import { getDuration } from './get-duration.js'; import { getDuration } from './get-duration.js';
type MigrationRunnerParameters = { type MigrationRunnerParameters<T extends MigrationMetadata | MigrationMetadataFinished> = {
dry: boolean; dry: boolean;
lock?: boolean;
limit?: number; limit?: number;
name?: string;
from?: string; from?: string;
to?: string; to?: string;
abortSignal?: AbortSignal; abortSignal?: AbortSignal;
abortRespite?: number; abortRespite?: number;
reporter: EmigrateReporter; reporter: EmigrateReporter;
storage: Storage; storage: Storage;
migrations: AsyncIterable<MigrationMetadata | MigrationMetadataFinished>; migrations: AsyncIterable<T>;
migrationFilter?: (migration: MigrationMetadata | MigrationMetadataFinished) => boolean; migrationFilter?: (migration: T) => boolean;
validate: (migration: MigrationMetadata) => Promise<void>; validate: (migration: T) => Promise<void>;
execute: (migration: MigrationMetadata) => Promise<void>; execute: (migration: T) => Promise<void>;
onSuccess: (migration: SuccessfulMigrationMetadata) => Promise<void>;
onError: (migration: FailedMigrationMetadata, error: Error) => Promise<void>;
}; };
export const migrationRunner = async ({ export const migrationRunner = async <T extends MigrationMetadata | MigrationMetadataFinished>({
dry, dry,
lock = true,
limit, limit,
name,
from, from,
to, to,
abortSignal, abortSignal,
@ -40,8 +46,10 @@ export const migrationRunner = async ({
migrations, migrations,
validate, validate,
execute, execute,
onSuccess,
onError,
migrationFilter = () => true, migrationFilter = () => true,
}: MigrationRunnerParameters): Promise<Error | undefined> => { }: MigrationRunnerParameters<T>): Promise<Error | undefined> => {
const collectedMigrations: Array<MigrationMetadata | MigrationMetadataFinished> = []; const collectedMigrations: Array<MigrationMetadata | MigrationMetadataFinished> = [];
const validatedMigrations: Array<MigrationMetadata | MigrationMetadataFinished> = []; const validatedMigrations: Array<MigrationMetadata | MigrationMetadataFinished> = [];
const migrationsToLock: MigrationMetadata[] = []; const migrationsToLock: MigrationMetadata[] = [];
@ -64,10 +72,15 @@ export const migrationRunner = async ({
{ once: true }, { once: true },
); );
let nameFound = false;
let fromFound = false; let fromFound = false;
let toFound = false; let toFound = false;
for await (const migration of migrations) { for await (const migration of migrations) {
if (name && migration.relativeFilePath === name) {
nameFound = true;
}
if (from && migration.relativeFilePath === from) { if (from && migration.relativeFilePath === from) {
fromFound = true; fromFound = true;
} }
@ -129,7 +142,9 @@ export const migrationRunner = async ({
let optionError: Error | undefined; 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`); optionError = BadOptionError.fromOption('from', `The "from" migration: "${from}" was not found`);
} else if (to && !toFound) { } else if (to && !toFound) {
optionError = BadOptionError.fromOption('to', `The "to" migration: "${to}" was not found`); optionError = BadOptionError.fromOption('to', `The "to" migration: "${to}" was not found`);
@ -151,7 +166,8 @@ export const migrationRunner = async ({
migrationsToLock.length = 0; migrationsToLock.length = 0;
} }
const [lockedMigrations, lockError] = dry const [lockedMigrations, lockError] =
dry || !lock
? [migrationsToLock] ? [migrationsToLock]
: await exec(async () => storage.lock(migrationsToLock), { abortSignal, abortRespite }); : await exec(async () => storage.lock(migrationsToLock), { abortSignal, abortRespite });
@ -168,7 +184,7 @@ export const migrationRunner = async ({
migrationsToLock.length = 0; migrationsToLock.length = 0;
skip = true; skip = true;
} else { } else if (lock) {
for (const migration of migrationsToLock) { for (const migration of migrationsToLock) {
const isLocked = lockedMigrations.some((lockedMigration) => lockedMigration.name === migration.name); const isLocked = lockedMigrations.some((lockedMigration) => lockedMigration.name === migration.name);
@ -231,7 +247,7 @@ export const migrationRunner = async ({
const start = hrtime(); 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); const duration = getDuration(start);
@ -242,7 +258,7 @@ export const migrationRunner = async ({
duration, duration,
error: migrationError, error: migrationError,
}; };
await storage.onError(finishedMigration, toSerializedError(migrationError)); await onError(finishedMigration, migrationError);
await reporter.onMigrationError?.(finishedMigration, migrationError); await reporter.onMigrationError?.(finishedMigration, migrationError);
finishedMigrations.push(finishedMigration); finishedMigrations.push(finishedMigration);
skip = true; skip = true;
@ -252,15 +268,14 @@ export const migrationRunner = async ({
status: 'done', status: 'done',
duration, duration,
}; };
await storage.onSuccess(finishedMigration); await onSuccess(finishedMigration);
await reporter.onMigrationSuccess?.(finishedMigration); await reporter.onMigrationSuccess?.(finishedMigration);
finishedMigrations.push(finishedMigration); finishedMigrations.push(finishedMigration);
} }
} }
const [, unlockError] = dry const [, unlockError] =
? [] dry || !lock ? [] : await exec(async () => storage.unlock(lockedMigrations ?? []), { abortSignal, abortRespite });
: await exec(async () => storage.unlock(lockedMigrations ?? []), { abortSignal, abortRespite });
// eslint-disable-next-line unicorn/no-array-callback-reference // eslint-disable-next-line unicorn/no-array-callback-reference
const firstFailed = finishedMigrations.find(isFailedMigration); const firstFailed = finishedMigrations.find(isFailedMigration);

View file

@ -13,6 +13,7 @@ import {
} from '@emigrate/types'; } from '@emigrate/types';
type Status = ReturnType<typeof getMigrationStatus>; type Status = ReturnType<typeof getMigrationStatus>;
type Command = ReporterInitParameters['command'];
const interactive = isInteractive(); const interactive = isInteractive();
const spinner = interactive ? elegantSpinner() : () => figures.pointerSmall; const spinner = interactive ? elegantSpinner() : () => figures.pointerSmall;
@ -30,11 +31,16 @@ const getTitle = ({ command, version, dry, cwd }: ReporterInitParameters) => {
}; };
const getMigrationStatus = ( const getMigrationStatus = (
command: Command,
migration: MigrationMetadata | MigrationMetadataFinished, migration: MigrationMetadata | MigrationMetadataFinished,
activeMigration?: MigrationMetadata, activeMigration?: MigrationMetadata,
) => { ) => {
if ('status' in migration) { 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'; return migration.name === activeMigration?.name ? 'running' : 'pending';
@ -42,6 +48,10 @@ const getMigrationStatus = (
const getIcon = (status: Status) => { const getIcon = (status: Status) => {
switch (status) { switch (status) {
case 'removing': {
return cyan(spinner());
}
case 'running': { case 'running': {
return cyan(spinner()); return cyan(spinner());
} }
@ -50,6 +60,10 @@ const getIcon = (status: Status) => {
return gray(figures.pointerSmall); return gray(figures.pointerSmall);
} }
case 'removed': {
return green(figures.tick);
}
case 'done': { case 'done': {
return green(figures.tick); return green(figures.tick);
} }
@ -89,20 +103,19 @@ const getName = (name: string, status?: Status) => {
}; };
const getMigrationText = ( const getMigrationText = (
command: Command,
migration: MigrationMetadata | MigrationMetadataFinished, migration: MigrationMetadata | MigrationMetadataFinished,
activeMigration?: MigrationMetadata, activeMigration?: MigrationMetadata,
) => { ) => {
const pathWithoutName = migration.relativeFilePath.slice(0, -migration.name.length); const pathWithoutName = migration.relativeFilePath.slice(0, -migration.name.length);
const nameWithoutExtension = migration.name.slice(0, -migration.extension.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)]; const parts = [' ', getIcon(status)];
parts.push(`${dim(pathWithoutName)}${getName(nameWithoutExtension, status)}${dim(migration.extension)}`); parts.push(`${dim(pathWithoutName)}${getName(nameWithoutExtension, status)}${dim(migration.extension)}`);
if ('status' in migration) { if ('status' in migration || migration.name === activeMigration?.name) {
parts.push(gray`(${migration.status})`); parts.push(gray`(${status})`);
} else if (migration.name === activeMigration?.name) {
parts.push(gray`(running)`);
} }
if ('duration' in migration && migration.duration) { if ('duration' in migration && migration.duration) {
@ -319,19 +332,6 @@ class DefaultFancyReporter implements Required<EmigrateReporter> {
this.#migrations = [migration]; this.#migrations = [migration];
} }
onMigrationRemoveStart(migration: MigrationMetadata): Awaitable<void> {
this.#migrations = [migration];
this.#activeMigration = migration;
}
onMigrationRemoveSuccess(migration: MigrationMetadataFinished): Awaitable<void> {
this.#finishMigration(migration);
}
onMigrationRemoveError(migration: MigrationMetadataFinished, _error: Error): Awaitable<void> {
this.#finishMigration(migration);
}
onMigrationStart(migration: MigrationMetadata): void | PromiseLike<void> { onMigrationStart(migration: MigrationMetadata): void | PromiseLike<void> {
this.#activeMigration = migration; this.#activeMigration = migration;
} }
@ -376,7 +376,9 @@ class DefaultFancyReporter implements Required<EmigrateReporter> {
const parts = [ const parts = [
getTitle(this.#parameters), getTitle(this.#parameters),
getHeaderMessage(this.#parameters.command, this.#migrations, this.#lockedMigrations), 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), getAbortMessage(this.#abortReason),
getSummary(this.#parameters.command, this.#migrations), getSummary(this.#parameters.command, this.#migrations),
getError(this.#error), getError(this.#error),
@ -441,35 +443,23 @@ class DefaultReporter implements Required<EmigrateReporter> {
} }
onNewMigration(migration: MigrationMetadata, _content: string): Awaitable<void> { onNewMigration(migration: MigrationMetadata, _content: string): Awaitable<void> {
console.log(getMigrationText(migration)); console.log(getMigrationText(this.#parameters.command, migration));
}
onMigrationRemoveStart(migration: MigrationMetadata): Awaitable<void> {
console.log(getMigrationText(migration));
}
onMigrationRemoveSuccess(migration: MigrationMetadataFinished): Awaitable<void> {
console.log(getMigrationText(migration));
}
onMigrationRemoveError(migration: MigrationMetadataFinished, _error: Error): Awaitable<void> {
console.error(getMigrationText(migration));
} }
onMigrationStart(migration: MigrationMetadata): void | PromiseLike<void> { onMigrationStart(migration: MigrationMetadata): void | PromiseLike<void> {
console.log(getMigrationText(migration, migration)); console.log(getMigrationText(this.#parameters.command, migration, migration));
} }
onMigrationSuccess(migration: MigrationMetadataFinished): void | PromiseLike<void> { onMigrationSuccess(migration: MigrationMetadataFinished): void | PromiseLike<void> {
console.log(getMigrationText(migration)); console.log(getMigrationText(this.#parameters.command, migration));
} }
onMigrationError(migration: MigrationMetadataFinished, _error: Error): void | PromiseLike<void> { onMigrationError(migration: MigrationMetadataFinished, _error: Error): void | PromiseLike<void> {
console.error(getMigrationText(migration)); console.error(getMigrationText(this.#parameters.command, migration));
} }
onMigrationSkip(migration: MigrationMetadataFinished): void | PromiseLike<void> { onMigrationSkip(migration: MigrationMetadataFinished): void | PromiseLike<void> {
console.log(getMigrationText(migration)); console.log(getMigrationText(this.#parameters.command, migration));
} }
onFinished(migrations: MigrationMetadataFinished[], error?: Error | undefined): void | PromiseLike<void> { onFinished(migrations: MigrationMetadataFinished[], error?: Error | undefined): void | PromiseLike<void> {

View file

@ -1,11 +1,69 @@
import { mock, type Mock } from 'node:test';
import path from 'node:path'; import path from 'node:path';
import { import {
type SerializedError,
type EmigrateReporter,
type FailedMigrationHistoryEntry, type FailedMigrationHistoryEntry,
type MigrationHistoryEntry, type MigrationHistoryEntry,
type MigrationMetadata, type MigrationMetadata,
type NonFailedMigrationHistoryEntry, type NonFailedMigrationHistoryEntry,
type Storage,
} from '@emigrate/types'; } from '@emigrate/types';
export type Mocked<T> = {
// @ts-expect-error - This is a mock
[K in keyof T]: Mock<T[K]>;
};
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<string | MigrationHistoryEntry>) {
const storage: Mocked<Storage> = {
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<Required<EmigrateReporter>> {
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 { export function toMigration(cwd: string, directory: string, name: string): MigrationMetadata {
return { return {
name, name,