Skip to content

Commit

Permalink
feat(cli): add support for "reporter" plugins and include a default r…
Browse files Browse the repository at this point in the history
…eporter
  • Loading branch information
joakimbeng committed Nov 17, 2023
1 parent b61072a commit 8f35812
Show file tree
Hide file tree
Showing 5 changed files with 283 additions and 58 deletions.
6 changes: 6 additions & 0 deletions .changeset/purple-rice-crash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@emigrate/plugin-tools': minor
'@emigrate/cli': minor
---

Add support for "reporter" plugins and implement a simple default reporter
39 changes: 39 additions & 0 deletions packages/cli/src/plugin-reporter-default.ts
Original file line number Diff line number Diff line change
@@ -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;
155 changes: 114 additions & 41 deletions packages/cli/src/up-command.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -12,53 +20,87 @@ 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) {
if (!directory) {
throw new MissingOptionError('directory');
}

const storagePlugin = await getOrLoadPlugin('storage', plugins);
const requirePlugin = async <T extends PluginType>(
type: T,
plugins: Array<Plugin | string>,
): Promise<PluginFromType<T>> => {
const plugin = await getOrLoadPlugin(type, plugins);

if (!storagePlugin) {
if (!plugin) {
throw new BadOptionError(
'plugin',
'No storage plugin found, please specify a storage plugin using the plugin option',
`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 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');

const allFilesInMigrationDirectory = await fs.readdir(path.resolve(process.cwd(), directory), {
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(
Expand All @@ -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;
}

Expand All @@ -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;

if (lastMigrationStatus === 'failed' || lastMigrationStatus === 'skipped') {
await reporter.onMigrationSkip?.(migration);
finishedMigrations.push({ ...migration, status: 'skipped', duration: 0 });
continue;
}

await reporter.onMigrationStart?.(migration);

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 };
const loader = loaderByExtension.get(migration.extension)!;
const start = process.hrtime();

let migration: MigrationFunction;
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);
if (!(errorInstance instanceof EmigrateError)) {
errorInstance = new MigrationRunError(`Failed to run migration: ${migration.relativeFilePath}`, migration, {
cause: error,
});
}

await storage.onError(name, errorInstance);
const duration = getDuration(start);
const finishedMigration: MigrationMetadataFinished = {
...migration,
status: 'done',
duration,
error: errorInstance,
};

if (!(error instanceof EmigrateError)) {
throw new MigrationRunError(`Failed to run migration: ${relativeFilePath}`, metadata, { cause: error });
}
await storage.onError(finishedMigration, errorInstance);
await reporter.onMigrationError?.(finishedMigration, errorInstance);

throw error;
finishedMigrations.push(finishedMigration);
}
}
} finally {
const firstError = finishedMigrations.find((migration) => migration.status === 'failed')?.error;

await reporter.onFinished?.(finishedMigrations, firstError);
await cleanup();
}
}
31 changes: 29 additions & 2 deletions packages/plugin-tools/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
type StoragePlugin,
type Plugin,
type LoaderPlugin,
type ReporterPlugin,
} from './types.js';

export const isGeneratorPlugin = (plugin: any): plugin is GeneratorPlugin => {
Expand All @@ -32,6 +33,25 @@ export const isLoaderPlugin = (plugin: any): plugin is LoaderPlugin => {
return typeof plugin.loadMigration === 'function' && Array.isArray(plugin.loadableExtensions);
};

export const isReporterPlugin = (plugin: any): plugin is ReporterPlugin => {
if (!plugin || typeof plugin !== 'object') {
return false;
}

const reporterFunctions = [
'onInit',
'onCollectedMigrations',
'onLockedMigrations',
'onMigrationStart',
'onMigrationSuccess',
'onMigrationError',
'onMigrationSkip',
'onFinished',
];

return reporterFunctions.some((fn) => typeof plugin[fn] === 'function');
};

export const isPluginOfType = <T extends PluginType>(type: T, plugin: any): plugin is PluginFromType<T> => {
if (type === 'generator') {
return isGeneratorPlugin(plugin);
Expand All @@ -45,14 +65,20 @@ export const isPluginOfType = <T extends PluginType>(type: T, plugin: any): plug
return isLoaderPlugin(plugin);
}

if (type === 'reporter') {
return isReporterPlugin(plugin);
}

throw new Error(`Unknown plugin type: ${type}`);
};

export const getOrLoadPlugin = async <T extends PluginType>(
type: T,
plugins: Array<Plugin | string>,
): Promise<PluginFromType<T> | undefined> => {
for await (const plugin of plugins) {
const reversePlugins = [...plugins].reverse();

for await (const plugin of reversePlugins) {
if (isPluginOfType(type, plugin)) {
return plugin;
}
Expand All @@ -72,8 +98,9 @@ export const getOrLoadPlugins = async <T extends PluginType>(
plugins: Array<Plugin | string>,
): Promise<Array<PluginFromType<T>>> => {
const result: Array<PluginFromType<T>> = [];
const reversePlugins = [...plugins].reverse();

for await (const plugin of plugins) {
for await (const plugin of reversePlugins) {
if (isPluginOfType(type, plugin)) {
result.push(plugin);
continue;
Expand Down
Loading

0 comments on commit 8f35812

Please sign in to comment.