feat(remove): implement the "remove" command for removing migrations from the history

This commit is contained in:
Joakim Carlstein 2023-11-24 15:45:17 +01:00
parent 9447d28ad8
commit d8a6a2428a
12 changed files with 370 additions and 24 deletions

View file

@ -123,6 +123,10 @@ const newMigration: Action = async (args) => {
Create a new migration file with the given name in the specified directory
Arguments:
name The name of the migration file to create (required)
Options:
-h, --help Show this help message and exit
@ -239,9 +243,86 @@ Examples:
}
};
const remove: Action = async (args) => {
const config = await getConfig('remove');
const { values, positionals } = parseArgs({
args,
options: {
help: {
type: 'boolean',
short: 'h',
},
directory: {
type: 'string',
short: 'd',
},
force: {
type: 'boolean',
short: 'f',
},
reporter: {
type: 'string',
short: 'r',
},
storage: {
type: 'string',
short: 's',
},
},
allowPositionals: true,
});
const usage = `Usage: emigrate remove [options] <name>
Remove entries from the migration history.
This is useful if you want to retry a migration that has failed.
Arguments:
name The name of the migration file to remove from the history (required)
Options:
-h, --help Show this help message and exit
-d, --directory The directory where the migration files are located (required)
-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
Examples:
emigrate remove -d migrations -s fs 20231122120529381_some_migration_file.js
emigrate remove --directory ./migrations --storage postgres 20231122120529381_some_migration_file.sql
`;
if (values.help) {
console.log(usage);
process.exitCode = 1;
return;
}
const { directory = config.directory, storage = config.storage, reporter = config.reporter, force } = values;
try {
const { default: removeCommand } = await import('./commands/remove.js');
await removeCommand({ directory, storage, reporter, force }, positionals[0] ?? '');
} catch (error) {
if (error instanceof ShowUsageError) {
console.error(error.message, '\n');
console.log(usage);
process.exitCode = 1;
return;
}
throw error;
}
};
const commands: Record<string, Action> = {
up,
list,
remove,
new: newMigration,
};
@ -259,9 +340,10 @@ if (!action) {
Commands:
up Run all pending migrations
up Run all pending migrations (or do a dry run)
new Create a new migration file
list List all migrations
list List all migrations and their status
remove Remove entries from the migration history
`);
process.exit(1);
}

View file

@ -0,0 +1,110 @@
import process from 'node:process';
import { getOrLoadReporter, getOrLoadStorage } from '@emigrate/plugin-tools';
import { type MigrationHistoryEntry, type MigrationMetadataFinished } from '@emigrate/plugin-tools/types';
import {
BadOptionError,
MigrationNotRunError,
MissingArgumentsError,
MissingOptionError,
OptionNeededError,
} from '../errors.js';
import { type Config } from '../types.js';
import { getMigration } from '../get-migration.js';
import { getDuration } from '../get-duration.js';
type ExtraFlags = {
force?: boolean;
};
const lazyDefaultReporter = async () => import('../reporters/default.js');
export default async function removeCommand(
{ directory, reporter: reporterConfig, storage: storageConfig, force }: Config & ExtraFlags,
name: string,
) {
if (!directory) {
throw new MissingOptionError('directory');
}
if (!name) {
throw new MissingArgumentsError('name');
}
const cwd = process.cwd();
const storagePlugin = await getOrLoadStorage([storageConfig]);
if (!storagePlugin) {
throw new BadOptionError('storage', 'No storage found, please specify a storage using the storage option');
}
const storage = await storagePlugin.initializeStorage();
const reporter = await getOrLoadReporter([reporterConfig ?? lazyDefaultReporter]);
if (!reporter) {
throw new BadOptionError(
'reporter',
'No reporter found, please specify an existing reporter using the reporter option',
);
}
const migrationFile = await getMigration(cwd, directory, name, !force);
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) {
throw new OptionNeededError(
'force',
`The migration "${migrationFile.name}" is not in a failed state. Use the "force" option to force its removal`,
);
}
historyEntry = migrationHistoryEntry;
}
await reporter.onInit?.({ command: 'remove', cwd, dry: false, directory });
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 = new MigrationNotRunError(
`Migration "${migrationFile.name}" is not in the migration history`,
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);
}

View file

@ -18,16 +18,12 @@ import {
import { type Config } from '../types.js';
import { withLeadingPeriod } from '../with-leading-period.js';
import { getMigrations } from '../get-migrations.js';
import { getDuration } from '../get-duration.js';
type ExtraFlags = {
dry?: boolean;
};
const getDuration = (start: [number, number]) => {
const [seconds, nanoseconds] = process.hrtime(start);
return seconds * 1000 + nanoseconds / 1_000_000;
};
const lazyDefaultReporter = async () => import('../reporters/default.js');
const lazyPluginLoaderJs = async () => import('../plugin-loader-js.js');
@ -81,7 +77,7 @@ export default async function upCommand({
relativeFilePath: path.relative(cwd, filePath),
extension: withLeadingPeriod(path.extname(migrationHistoryEntry.name)),
error: new MigrationHistoryError(
`Migration ${migrationHistoryEntry.name} is in a failed state, please fix it first`,
`Migration ${migrationHistoryEntry.name} is in a failed state, please fix and remove it first`,
migrationHistoryEntry,
),
directory,

View file

@ -27,6 +27,15 @@ export class MissingArgumentsError extends ShowUsageError {
}
}
export class OptionNeededError extends ShowUsageError {
constructor(
public option: string,
message: string,
) {
super('ERR_OPT_NEEDED', message);
}
}
export class BadOptionError extends ShowUsageError {
constructor(
public option: string,
@ -70,3 +79,13 @@ export class MigrationRunError extends EmigrateError {
super('ERR_MIGRATION_RUN', message, options);
}
}
export class MigrationNotRunError extends EmigrateError {
constructor(
message: string,
public metadata: MigrationMetadata,
options?: ErrorOptions,
) {
super('ERR_MIGRATION_NOT_RUN', message, options);
}
}

View file

@ -1,7 +1,7 @@
import { cosmiconfig } from 'cosmiconfig';
import { type Config, type EmigrateConfig } from './types.js';
const commands = ['up', 'list', 'new'] as const;
const commands = ['up', 'list', 'new', 'remove'] as const;
type Command = (typeof commands)[number];
export const getConfig = async (command: Command): Promise<Config> => {

View file

@ -0,0 +1,6 @@
import process from 'node:process';
export const getDuration = (start: [number, number]) => {
const [seconds, nanoseconds] = process.hrtime(start);
return seconds * 1000 + nanoseconds / 1_000_000;
};

View file

@ -0,0 +1,42 @@
import path from 'node:path';
import fs from 'node:fs/promises';
import { type MigrationMetadata } from '@emigrate/plugin-tools/types';
import { withLeadingPeriod } from './with-leading-period.js';
import { OptionNeededError } from './errors.js';
const checkMigrationFile = async (name: string, filePath: string) => {
try {
const stats = await fs.stat(filePath);
if (!stats.isFile()) {
throw new Error('Not a file');
}
} catch {
throw new OptionNeededError(
'force',
`The given migration name "${name}" does not exist or is not a file. Use the "force" option to ignore this error`,
);
}
};
export const getMigration = async (
cwd: string,
directory: string,
name: string,
requireExists = true,
): Promise<MigrationMetadata> => {
const filePath = path.resolve(cwd, directory, name);
if (requireExists) {
await checkMigrationFile(name, filePath);
}
return {
name,
filePath,
relativeFilePath: path.relative(cwd, filePath),
extension: withLeadingPeriod(path.extname(name)),
directory,
cwd,
};
};

View file

@ -193,7 +193,7 @@ const getSummary = (
const statusLine = [
failed ? red.bold(`${failed} failed`) : '',
done ? green.bold(`${done} ${command === 'new' ? 'created' : 'done'}`) : '',
done ? green.bold(`${done} ${command === 'new' ? 'created' : command === 'remove' ? 'removed' : 'done'}`) : '',
skipped ? yellow.bold(`${skipped} skipped`) : '',
pending ? cyan.bold(`${pending} pending`) : '',
]
@ -267,6 +267,19 @@ class DefaultFancyReporter implements Required<EmigrateReporter> {
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> {
this.#activeMigration = migration;
}
@ -372,6 +385,18 @@ class DefaultReporter implements Required<EmigrateReporter> {
console.log(getMigrationText(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> {
console.log(getMigrationText(migration, migration));
}

View file

@ -17,4 +17,5 @@ export type EmigrateConfig = Config & {
up?: Config;
new?: Config;
list?: Config;
remove?: Config;
};