feat(cli): implement the "list" command for listing migration history and pending migrations

This commit is contained in:
Joakim Carlstein 2023-11-22 14:19:10 +01:00
parent e79dd4bca9
commit 53cdb23237
8 changed files with 199 additions and 38 deletions

View file

@ -0,0 +1,5 @@
---
'@emigrate/cli': minor
---
Implement the "list" command for listing the full migration history and any pending migrations

View file

@ -175,6 +175,7 @@ Examples:
};
const list: Action = async (args) => {
const config = await getConfig('list');
const { values } = parseArgs({
args,
options: {
@ -182,17 +183,60 @@ const list: Action = async (args) => {
type: 'boolean',
short: 'h',
},
plugin: {
directory: {
type: 'string',
short: 'p',
multiple: true,
default: [],
short: 'd',
},
reporter: {
type: 'string',
short: 'r',
},
storage: {
type: 'string',
short: 's',
},
},
allowPositionals: false,
});
console.log(values);
const usage = `Usage: emigrate list [options]
List all migrations and their status. This command does not run any migrations.
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 migrations
-s, --storage The storage to use to get the migration history (required)
Examples:
emigrate list -d migrations -s fs
emigrate list --directory ./migrations --storage postgres --reporter json
`;
if (values.help) {
console.log(usage);
process.exitCode = 1;
return;
}
const { directory = config.directory, storage = config.storage, reporter = config.reporter } = values;
try {
const { default: listCommand } = await import('./commands/list.js');
await listCommand({ directory, storage, reporter });
} 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> = {

View file

@ -0,0 +1,82 @@
import process from 'node:process';
import path from 'node:path';
import { getOrLoadReporter, getOrLoadStorage } from '@emigrate/plugin-tools';
import { type MigrationMetadataFinished } from '@emigrate/plugin-tools/types';
import { BadOptionError, MigrationHistoryError, MissingOptionError } from '../errors.js';
import { type Config } from '../types.js';
import { withLeadingPeriod } from '../with-leading-period.js';
import { getMigrations } from '../get-migrations.js';
const lazyDefaultReporter = async () => import('../reporters/default.js');
export default async function listCommand({ directory, reporter: reporterConfig, storage: storageConfig }: Config) {
if (!directory) {
throw new MissingOptionError('directory');
}
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',
);
}
await reporter.onInit?.({ command: 'list', cwd, dry: false, directory });
const migrationFiles = await getMigrations(cwd, directory);
let migrationHistoryError: MigrationHistoryError | undefined;
const finishedMigrations: MigrationMetadataFinished[] = [];
for await (const migrationHistoryEntry of storage.getHistory()) {
const filePath = path.resolve(cwd, directory, migrationHistoryEntry.name);
const finishedMigration: MigrationMetadataFinished = {
name: migrationHistoryEntry.name,
status: migrationHistoryEntry.status,
filePath,
relativeFilePath: path.relative(cwd, filePath),
extension: withLeadingPeriod(path.extname(migrationHistoryEntry.name)),
directory,
cwd,
duration: 0,
};
if (migrationHistoryEntry.status === 'failed') {
migrationHistoryError = new MigrationHistoryError(
`Migration ${migrationHistoryEntry.name} is in a failed state`,
migrationHistoryEntry,
);
await reporter.onMigrationError?.(finishedMigration, migrationHistoryError);
} else {
await reporter.onMigrationSuccess?.(finishedMigration);
}
finishedMigrations.push(finishedMigration);
const index = migrationFiles.findIndex((migrationFile) => migrationFile.name === migrationHistoryEntry.name);
if (index !== -1) {
migrationFiles.splice(index, 1);
}
}
for await (const migration of migrationFiles) {
const finishedMigration: MigrationMetadataFinished = { ...migration, status: 'pending', duration: 0 };
await reporter.onMigrationSkip?.(finishedMigration);
finishedMigrations.push(finishedMigration);
}
await reporter.onFinished?.(finishedMigrations, migrationHistoryError);
}

View file

@ -17,6 +17,7 @@ import {
import { type Config } from '../types.js';
import { withLeadingPeriod } from '../with-leading-period.js';
import pluginLoaderJs from '../plugin-loader-js.js';
import { getMigrations } from '../get-migrations.js';
type ExtraFlags = {
dry?: boolean;
@ -59,28 +60,7 @@ export default async function upCommand({
await reporter.onInit?.({ command: 'up', cwd, dry, directory });
const path = await import('node:path');
const fs = await import('node:fs/promises');
const allFilesInMigrationDirectory = await fs.readdir(path.resolve(process.cwd(), directory), {
withFileTypes: true,
});
const migrationFiles: MigrationMetadata[] = allFilesInMigrationDirectory
.filter((file) => file.isFile() && !file.name.startsWith('.') && !file.name.startsWith('_'))
.sort((a, b) => a.name.localeCompare(b.name))
.map(({ name }) => {
const filePath = path.resolve(process.cwd(), directory, name);
return {
name,
filePath,
relativeFilePath: path.relative(cwd, filePath),
extension: withLeadingPeriod(path.extname(name)),
directory,
cwd,
};
});
const migrationFiles = await getMigrations(cwd, directory);
let migrationHistoryError: MigrationHistoryError | undefined;

View file

@ -46,7 +46,7 @@ export class MigrationHistoryError extends EmigrateError {
message: string,
public entry: MigrationHistoryEntry,
) {
super('ERR_MIGRATION_HISTORY', message);
super('ERR_MIGRATION_HISTORY', message, { cause: entry.error });
}
}

View file

@ -0,0 +1,28 @@
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';
export const getMigrations = async (cwd: string, directory: string): Promise<MigrationMetadata[]> => {
const allFilesInMigrationDirectory = await fs.readdir(path.resolve(cwd, directory), {
withFileTypes: true,
});
const migrationFiles: MigrationMetadata[] = allFilesInMigrationDirectory
.filter((file) => file.isFile() && !file.name.startsWith('.') && !file.name.startsWith('_'))
.sort((a, b) => a.name.localeCompare(b.name))
.map(({ name }) => {
const filePath = path.resolve(cwd, directory, name);
return {
name,
filePath,
relativeFilePath: path.relative(cwd, filePath),
extension: withLeadingPeriod(path.extname(name)),
directory,
cwd,
};
});
return migrationFiles;
};

View file

@ -1,5 +1,5 @@
import path from 'node:path';
import { black, blueBright, bold, cyan, dim, gray, green, red, redBright, yellow } from 'ansis';
import { black, blueBright, bold, cyan, dim, faint, gray, green, red, redBright, yellow } from 'ansis';
import logUpdate from 'log-update';
import elegantSpinner from 'elegant-spinner';
import figures from 'figures';
@ -25,7 +25,7 @@ const formatDuration = (duration: number): string => {
};
const getTitle = ({ command, directory, dry, cwd }: ReporterInitParameters) => {
return `${black.bgBlueBright` Emigrate `} ${blueBright.bold(command)} ${gray(cwd + path.sep)}${directory}${
return `${black.bgBlueBright(' Emigrate ').trim()} ${blueBright.bold(command)} ${gray(cwd + path.sep)}${directory}${
dry ? yellow` (dry run)` : ''
}`;
};
@ -79,6 +79,10 @@ const getName = (name: string, status?: Status) => {
return yellow(name);
}
case 'pending': {
return faint(name);
}
default: {
return name;
}
@ -108,7 +112,18 @@ const getMigrationText = (
return parts.join(' ');
};
const getError = (error?: Error, indent = ' ') => {
type ErrorLike = {
name?: string;
message: string;
stack?: string;
cause?: unknown;
};
const isErrorLike = (error: unknown): error is ErrorLike => {
return typeof error === 'object' && error !== null && 'message' in error && typeof error.message === 'string';
};
const getError = (error?: ErrorLike, indent = ' ') => {
if (!error) {
return '';
}
@ -127,7 +142,7 @@ const getError = (error?: Error, indent = ' ') => {
const parts = [`${indent}${bold.red(errorTitle)}`, ...stack.map((line) => `${indent}${dim(line)}`)];
if (error.cause instanceof Error) {
if (isErrorLike(error.cause)) {
const nextIndent = `${indent} `;
parts.push(`\n${nextIndent}${bold('Original error cause:')}\n`, getError(error.cause, nextIndent));
}
@ -143,9 +158,10 @@ const getSummary = (
let done = 0;
let failed = 0;
let skipped = 0;
let pending = 0;
for (const migration of migrations) {
const status = getMigrationStatus(migration);
const status = 'status' in migration ? migration.status : undefined;
switch (status) {
case 'done': {
done++;
@ -162,6 +178,11 @@ const getSummary = (
break;
}
case 'pending': {
pending++;
break;
}
default: {
break;
}
@ -174,6 +195,7 @@ const getSummary = (
failed ? red.bold(`${failed} failed`) : '',
done ? green.bold(`${done} ${command === 'new' ? 'created' : 'done'}`) : '',
skipped ? yellow.bold(`${skipped} skipped`) : '',
pending ? cyan.bold(`${pending} pending`) : '',
]
.filter(Boolean)
.join(dim(' | '));
@ -262,13 +284,13 @@ class DefaultFancyReporter implements Required<EmigrateReporter> {
}
#finishMigration(migration: MigrationMetadataFinished): void {
if (!this.#migrations) {
return;
}
this.#migrations ??= [];
const index = this.#migrations.findIndex((m) => m.name === migration.name);
if (index !== -1) {
if (index === -1) {
this.#migrations.push(migration);
} else {
this.#migrations[index] = migration;
}
}

View file

@ -2,7 +2,7 @@ export type Awaitable<T> = T | PromiseLike<T>;
export type StringOrModule<T> = string | T | (() => Awaitable<T>) | (() => Awaitable<{ default: T }>);
export type MigrationStatus = 'failed' | 'done';
export type MigrationStatus = 'failed' | 'done' | 'pending';
export type MigrationHistoryEntry = {
name: string;