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

@ -0,0 +1,7 @@
---
'@emigrate/plugin-tools': minor
'@emigrate/storage-fs': minor
'@emigrate/cli': minor
---
Implement the "remove" command for removing migration entries from the history

View file

@ -123,6 +123,10 @@ const newMigration: Action = async (args) => {
Create a new migration file with the given name in the specified directory 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: Options:
-h, --help Show this help message and exit -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> = { const commands: Record<string, Action> = {
up, up,
list, list,
remove,
new: newMigration, new: newMigration,
}; };
@ -259,9 +340,10 @@ if (!action) {
Commands: Commands:
up Run all pending migrations up Run all pending migrations (or do a dry run)
new Create a new migration file 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); 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 { type Config } from '../types.js';
import { withLeadingPeriod } from '../with-leading-period.js'; import { withLeadingPeriod } from '../with-leading-period.js';
import { getMigrations } from '../get-migrations.js'; import { getMigrations } from '../get-migrations.js';
import { getDuration } from '../get-duration.js';
type ExtraFlags = { type ExtraFlags = {
dry?: boolean; 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 lazyDefaultReporter = async () => import('../reporters/default.js');
const lazyPluginLoaderJs = async () => import('../plugin-loader-js.js'); const lazyPluginLoaderJs = async () => import('../plugin-loader-js.js');
@ -81,7 +77,7 @@ export default async function upCommand({
relativeFilePath: path.relative(cwd, filePath), relativeFilePath: path.relative(cwd, filePath),
extension: withLeadingPeriod(path.extname(migrationHistoryEntry.name)), extension: withLeadingPeriod(path.extname(migrationHistoryEntry.name)),
error: new MigrationHistoryError( 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, migrationHistoryEntry,
), ),
directory, 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 { export class BadOptionError extends ShowUsageError {
constructor( constructor(
public option: string, public option: string,
@ -70,3 +79,13 @@ export class MigrationRunError extends EmigrateError {
super('ERR_MIGRATION_RUN', message, options); 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 { cosmiconfig } from 'cosmiconfig';
import { type Config, type EmigrateConfig } from './types.js'; 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]; type Command = (typeof commands)[number];
export const getConfig = async (command: Command): Promise<Config> => { 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 = [ const statusLine = [
failed ? red.bold(`${failed} failed`) : '', 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`) : '', skipped ? yellow.bold(`${skipped} skipped`) : '',
pending ? cyan.bold(`${pending} pending`) : '', pending ? cyan.bold(`${pending} pending`) : '',
] ]
@ -267,6 +267,19 @@ 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;
} }
@ -372,6 +385,18 @@ class DefaultReporter implements Required<EmigrateReporter> {
console.log(getMigrationText(migration)); 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> { onMigrationStart(migration: MigrationMetadata): void | PromiseLike<void> {
console.log(getMigrationText(migration, migration)); console.log(getMigrationText(migration, migration));
} }

View file

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

View file

@ -32,6 +32,15 @@ export type Storage = {
* @param migrations The previously successfully locked migrations that should now be unlocked. * @param migrations The previously successfully locked migrations that should now be unlocked.
*/ */
unlock(migrations: MigrationMetadata[]): Promise<void>; unlock(migrations: MigrationMetadata[]): Promise<void>;
/**
* Remove a migration from the history.
*
* This is used to remove a migration from the history which is needed for failed migrations to be re-executed.
*
* @param migration The migration that should be removed from the history.
*/
remove(migration: MigrationMetadata): Promise<void>;
/** /**
* Get the history of previously executed migrations. * Get the history of previously executed migrations.
* *
@ -154,7 +163,7 @@ export type ReporterInitParameters = {
/** /**
* The command that is being executed * The command that is being executed
*/ */
command: 'up' | 'new' | 'list'; command: 'up' | 'new' | 'list' | 'remove';
/** /**
* The directory where the migration files are located * The directory where the migration files are located
*/ */
@ -197,6 +206,24 @@ export type EmigrateReporter = Partial<{
* This is only called when the command is 'new'. * This is only called when the command is 'new'.
*/ */
onNewMigration(migration: MigrationMetadata, content: string): Awaitable<void>; onNewMigration(migration: MigrationMetadata, content: string): Awaitable<void>;
/**
* Called when a migration is about to be removed from the migration history.
*
* This is only called when the command is 'remove'.
*/
onMigrationRemoveStart(migration: MigrationMetadata): Awaitable<void>;
/**
* Called when a migration is successfully removed from the migration history.
*
* This is only called when the command is 'remove'.
*/
onMigrationRemoveSuccess(migration: MigrationMetadataFinished): Awaitable<void>;
/**
* Called when a migration couldn't be removed from the migration history.
*
* This is only called when the command is 'remove'.
*/
onMigrationRemoveError(migration: MigrationMetadataFinished, error: Error): Awaitable<void>;
/** /**
* Called when a migration is about to be executed. * Called when a migration is about to be executed.
* *
@ -230,15 +257,18 @@ export type EmigrateReporter = Partial<{
* Will be called when a migration is skipped because a previous migration failed, * Will be called when a migration is skipped because a previous migration failed,
* it couldn't be successfully locked, or in case of a dry run when the command is "up". * it couldn't be successfully locked, or in case of a dry run when the command is "up".
* When the command is "list" this will be called for each pending migration (i.e. those that have not run yet). * When the command is "list" this will be called for each pending migration (i.e. those that have not run yet).
* When the command is "remove" this will be called when the removal of some migrations are skipped
* because the removal of a previous migration failed.
* *
* @param migration Information about the migration that was skipped. * @param migration Information about the migration that was skipped.
*/ */
onMigrationSkip(migration: MigrationMetadataFinished): Awaitable<void>; onMigrationSkip(migration: MigrationMetadataFinished): Awaitable<void>;
/** /**
* Called as a final step after all migrations have been executed or listed. * Called as a final step after all migrations have been executed, listed or removed.
* *
* This is called either after all migrations have been listed successfully for the "list" command * This is called either after all migrations have been listed successfully for the "list" command
* or for the "up" command when they are executed successfully, at the end of a dry run, or when a migration has failed. * or for the "up" command when they are executed successfully, at the end of a dry run, or when a migration has failed.
* It is also called after migrations have been removed from the history with the "remove" command.
* It is also called after a migration file has been generated with the "new" command. * It is also called after a migration file has been generated with the "new" command.
* *
* @param migrations Information about all migrations that were executed or listed, their status and any error that occurred. * @param migrations Information about all migrations that were executed or listed, their status and any error that occurred.

View file

@ -55,31 +55,59 @@ export default function storageFs({ filename }: StorageFsOptions): EmigrateStora
}, },
}; };
await fs.writeFile(filePath, JSON.stringify(newHistory, undefined, 2)); try {
await fs.writeFile(filePath, JSON.stringify(newHistory, undefined, 2));
} catch (error) {
throw new Error(`Failed to write migration history to file: ${filePath}`, { cause: error });
}
}); });
return lastUpdate; return lastUpdate;
}; };
const acquireLock = async () => {
try {
const fd = await fs.open(lockFilePath, 'wx');
await fd.close();
} catch (error) {
throw new Error('Could not acquire file lock for migrations', { cause: error });
}
};
const releaseLock = async () => {
try {
await fs.unlink(lockFilePath);
} catch {
// Ignore
}
};
return { return {
async initializeStorage() { async initializeStorage() {
return { return {
async lock(migrations) { async lock(migrations) {
try { await acquireLock();
const fd = await fs.open(lockFilePath, 'wx');
await fd.close(); return migrations;
return migrations;
} catch {
throw new Error('Could not acquire file lock for migrations');
}
}, },
async unlock() { async unlock() {
await releaseLock();
},
async remove(migration) {
await acquireLock();
try { try {
await fs.unlink(lockFilePath); const history = await read();
} catch {
// Ignore // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete history[migration.name];
await fs.writeFile(filePath, JSON.stringify(history, undefined, 2));
} catch (error) {
throw new Error(`Failed to remove migration from history: ${migration.name}`, { cause: error });
} finally {
await releaseLock();
} }
}, },
async *getHistory() { async *getHistory() {