feat(cli): add support for "reporter" plugins and include a default reporter
This commit is contained in:
parent
b61072a3b6
commit
8f35812fed
5 changed files with 286 additions and 61 deletions
39
packages/cli/src/plugin-reporter-default.ts
Normal file
39
packages/cli/src/plugin-reporter-default.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { type ReporterPlugin } from '@emigrate/plugin-tools/types';
|
||||
|
||||
const reporterDefault: ReporterPlugin = {
|
||||
onInit({ dry, directory }) {
|
||||
console.log(`Running migrations in: ${directory}${dry ? ' (dry run)' : ''}`);
|
||||
},
|
||||
onCollectedMigrations(migrations) {
|
||||
console.log(`Found ${migrations.length} pending migrations`);
|
||||
},
|
||||
onLockedMigrations(migrations) {
|
||||
console.log(`Locked ${migrations.length} migrations`);
|
||||
},
|
||||
onMigrationStart(migration) {
|
||||
console.log(`- ${migration.relativeFilePath} (running)`);
|
||||
},
|
||||
onMigrationSuccess(migration) {
|
||||
console.log(`- ${migration.relativeFilePath} (success) [${migration.duration}ms]`);
|
||||
},
|
||||
onMigrationError(migration, error) {
|
||||
console.error(`- ${migration.relativeFilePath} (failed!) [${migration.duration}ms]`);
|
||||
console.error(error.cause ?? error);
|
||||
},
|
||||
onMigrationSkip(migration) {
|
||||
console.log(`- ${migration.relativeFilePath} (skipped)`);
|
||||
},
|
||||
onFinished(migrations, error) {
|
||||
const totalDuration = migrations.reduce((total, migration) => total + migration.duration, 0);
|
||||
|
||||
if (error) {
|
||||
console.error('Failed to run migrations! [total duration: %dms]', totalDuration);
|
||||
console.error(error.cause ?? error);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Successfully ran ${migrations.length} migrations! [total duration: ${totalDuration}ms]`);
|
||||
},
|
||||
};
|
||||
|
||||
export default reporterDefault;
|
||||
|
|
@ -1,6 +1,14 @@
|
|||
import process from 'node:process';
|
||||
import { getOrLoadPlugin, getOrLoadPlugins } from '@emigrate/plugin-tools';
|
||||
import { type LoaderPlugin, type MigrationFunction } from '@emigrate/plugin-tools/types';
|
||||
import {
|
||||
type LoaderPlugin,
|
||||
type MigrationFunction,
|
||||
type Plugin,
|
||||
type PluginType,
|
||||
type PluginFromType,
|
||||
type MigrationMetadata,
|
||||
type MigrationMetadataFinished,
|
||||
} from '@emigrate/plugin-tools/types';
|
||||
import {
|
||||
BadOptionError,
|
||||
EmigrateError,
|
||||
|
|
@ -12,26 +20,45 @@ import {
|
|||
import { type Config } from './types.js';
|
||||
import { stripLeadingPeriod } from './strip-leading-period.js';
|
||||
import pluginLoaderJs from './plugin-loader-js.js';
|
||||
import pluginReporterDefault from './plugin-reporter-default.js';
|
||||
|
||||
type ExtraFlags = {
|
||||
dry?: boolean;
|
||||
};
|
||||
|
||||
export default async function upCommand({ directory, dry, plugins = [] }: Config & ExtraFlags) {
|
||||
const requirePlugin = async <T extends PluginType>(
|
||||
type: T,
|
||||
plugins: Array<Plugin | string>,
|
||||
): Promise<PluginFromType<T>> => {
|
||||
const plugin = await getOrLoadPlugin(type, plugins);
|
||||
|
||||
if (!plugin) {
|
||||
throw new BadOptionError(
|
||||
'plugin',
|
||||
`No ${type} plugin found, please specify a ${type} plugin using the plugin option`,
|
||||
);
|
||||
}
|
||||
|
||||
return plugin;
|
||||
};
|
||||
|
||||
const getDuration = (start: [number, number]) => {
|
||||
const [seconds, nanoseconds] = process.hrtime(start);
|
||||
return seconds * 1000 + nanoseconds / 1_000_000;
|
||||
};
|
||||
|
||||
export default async function upCommand({ directory, dry = false, plugins = [] }: Config & ExtraFlags) {
|
||||
if (!directory) {
|
||||
throw new MissingOptionError('directory');
|
||||
}
|
||||
|
||||
const storagePlugin = await getOrLoadPlugin('storage', plugins);
|
||||
|
||||
if (!storagePlugin) {
|
||||
throw new BadOptionError(
|
||||
'plugin',
|
||||
'No storage plugin found, please specify a storage plugin using the plugin option',
|
||||
);
|
||||
}
|
||||
|
||||
const cwd = process.cwd();
|
||||
const storagePlugin = await requirePlugin('storage', plugins);
|
||||
const storage = await storagePlugin.initializeStorage();
|
||||
const reporter = await requirePlugin('reporter', [pluginReporterDefault, ...plugins]);
|
||||
|
||||
await reporter.onInit?.({ cwd, dry, directory });
|
||||
|
||||
const path = await import('node:path');
|
||||
const fs = await import('node:fs/promises');
|
||||
|
||||
|
|
@ -39,26 +66,41 @@ export default async function upCommand({ directory, dry, plugins = [] }: Config
|
|||
withFileTypes: true,
|
||||
});
|
||||
|
||||
const migrationFiles = allFilesInMigrationDirectory
|
||||
const migrationFiles: MigrationMetadata[] = allFilesInMigrationDirectory
|
||||
.filter((file) => file.isFile() && !file.name.startsWith('.') && !file.name.startsWith('_'))
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((file) => file.name);
|
||||
.map(({ name }) => {
|
||||
const filePath = path.resolve(process.cwd(), directory, name);
|
||||
|
||||
return {
|
||||
name,
|
||||
filePath,
|
||||
relativeFilePath: path.relative(cwd, filePath),
|
||||
extension: stripLeadingPeriod(path.extname(name)),
|
||||
directory,
|
||||
cwd,
|
||||
};
|
||||
});
|
||||
|
||||
let migrationHistoryError: MigrationHistoryError | undefined;
|
||||
|
||||
for await (const migrationHistoryEntry of storage.getHistory()) {
|
||||
if (migrationHistoryEntry.status === 'failed') {
|
||||
throw new MigrationHistoryError(
|
||||
migrationHistoryError = new MigrationHistoryError(
|
||||
`Migration ${migrationHistoryEntry.name} is in a failed state, please fix it first`,
|
||||
migrationHistoryEntry,
|
||||
);
|
||||
}
|
||||
|
||||
if (migrationFiles.includes(migrationHistoryEntry.name)) {
|
||||
migrationFiles.splice(migrationFiles.indexOf(migrationHistoryEntry.name), 1);
|
||||
const index = migrationFiles.findIndex((migrationFile) => migrationFile.name === migrationHistoryEntry.name);
|
||||
|
||||
if (index !== -1) {
|
||||
migrationFiles.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
const migrationFileExtensions = new Set(migrationFiles.map((file) => stripLeadingPeriod(path.extname(file))));
|
||||
const loaderPlugins = await getOrLoadPlugins('loader', [...plugins, pluginLoaderJs]);
|
||||
const migrationFileExtensions = new Set(migrationFiles.map((migration) => migration.extension));
|
||||
const loaderPlugins = await getOrLoadPlugins('loader', [pluginLoaderJs, ...plugins]);
|
||||
|
||||
const loaderByExtension = new Map<string, LoaderPlugin | undefined>(
|
||||
[...migrationFileExtensions].map(
|
||||
|
|
@ -78,10 +120,19 @@ export default async function upCommand({ directory, dry, plugins = [] }: Config
|
|||
}
|
||||
}
|
||||
|
||||
if (dry) {
|
||||
console.log('Pending migrations:');
|
||||
console.log(migrationFiles.map((file) => ` - ${file}`).join('\n'));
|
||||
console.log('\nDry run, exiting...');
|
||||
await reporter.onCollectedMigrations?.(migrationFiles);
|
||||
|
||||
if (migrationFiles.length === 0 || dry || migrationHistoryError) {
|
||||
await reporter.onLockedMigrations?.([]);
|
||||
|
||||
for await (const migration of migrationFiles) {
|
||||
await reporter.onMigrationSkip?.(migration);
|
||||
}
|
||||
|
||||
await reporter.onFinished?.(
|
||||
migrationFiles.map((migration) => ({ ...migration, status: 'skipped', duration: 0 })),
|
||||
migrationHistoryError,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -104,48 +155,70 @@ export default async function upCommand({ directory, dry, plugins = [] }: Config
|
|||
process.on('SIGINT', cleanup);
|
||||
process.on('SIGTERM', cleanup);
|
||||
|
||||
const finishedMigrations: MigrationMetadataFinished[] = [];
|
||||
|
||||
try {
|
||||
for await (const name of lockedMigrationFiles) {
|
||||
console.log(' -', name, '...');
|
||||
for await (const migration of lockedMigrationFiles) {
|
||||
const lastMigrationStatus = finishedMigrations.at(-1)?.status;
|
||||
|
||||
const extension = stripLeadingPeriod(path.extname(name));
|
||||
const cwd = process.cwd();
|
||||
const filePath = path.resolve(cwd, directory, name);
|
||||
const relativeFilePath = path.relative(cwd, filePath);
|
||||
const loader = loaderByExtension.get(extension)!;
|
||||
const metadata = { name, filePath, relativeFilePath, cwd, directory, extension };
|
||||
if (lastMigrationStatus === 'failed' || lastMigrationStatus === 'skipped') {
|
||||
await reporter.onMigrationSkip?.(migration);
|
||||
finishedMigrations.push({ ...migration, status: 'skipped', duration: 0 });
|
||||
continue;
|
||||
}
|
||||
|
||||
let migration: MigrationFunction;
|
||||
await reporter.onMigrationStart?.(migration);
|
||||
|
||||
const loader = loaderByExtension.get(migration.extension)!;
|
||||
const start = process.hrtime();
|
||||
|
||||
let migrationFunction: MigrationFunction;
|
||||
|
||||
try {
|
||||
try {
|
||||
migration = await loader.loadMigration(metadata);
|
||||
migrationFunction = await loader.loadMigration(migration);
|
||||
} catch (error) {
|
||||
throw new MigrationLoadError(`Failed to load migration file: ${relativeFilePath}`, metadata, {
|
||||
throw new MigrationLoadError(`Failed to load migration file: ${migration.relativeFilePath}`, migration, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
|
||||
await migration();
|
||||
await migrationFunction();
|
||||
|
||||
console.log(' -', name, 'done');
|
||||
const duration = getDuration(start);
|
||||
const finishedMigration: MigrationMetadataFinished = { ...migration, status: 'done', duration };
|
||||
|
||||
await storage.onSuccess(name);
|
||||
await storage.onSuccess(finishedMigration);
|
||||
await reporter.onMigrationSuccess?.(finishedMigration);
|
||||
|
||||
finishedMigrations.push(finishedMigration);
|
||||
} catch (error) {
|
||||
const errorInstance = error instanceof Error ? error : new Error(String(error));
|
||||
let errorInstance = error instanceof Error ? error : new Error(String(error));
|
||||
|
||||
console.error(' -', name, 'failed:', errorInstance.message);
|
||||
|
||||
await storage.onError(name, errorInstance);
|
||||
|
||||
if (!(error instanceof EmigrateError)) {
|
||||
throw new MigrationRunError(`Failed to run migration: ${relativeFilePath}`, metadata, { cause: error });
|
||||
if (!(errorInstance instanceof EmigrateError)) {
|
||||
errorInstance = new MigrationRunError(`Failed to run migration: ${migration.relativeFilePath}`, migration, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
|
||||
throw error;
|
||||
const duration = getDuration(start);
|
||||
const finishedMigration: MigrationMetadataFinished = {
|
||||
...migration,
|
||||
status: 'done',
|
||||
duration,
|
||||
error: errorInstance,
|
||||
};
|
||||
|
||||
await storage.onError(finishedMigration, errorInstance);
|
||||
await reporter.onMigrationError?.(finishedMigration, errorInstance);
|
||||
|
||||
finishedMigrations.push(finishedMigration);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
const firstError = finishedMigrations.find((migration) => migration.status === 'failed')?.error;
|
||||
|
||||
await reporter.onFinished?.(finishedMigrations, firstError);
|
||||
await cleanup();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue