diff --git a/.changeset/strange-bags-complain.md b/.changeset/strange-bags-complain.md new file mode 100644 index 0000000..5327cf5 --- /dev/null +++ b/.changeset/strange-bags-complain.md @@ -0,0 +1,5 @@ +--- +'@emigrate/cli': minor +--- + +Improve error handling by making more granular custom Error instances diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 08ae505..f5a6627 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -1,7 +1,7 @@ #!/usr/bin/env node import process from 'node:process'; import { parseArgs } from 'node:util'; -import { ShowUsageError } from './show-usage-error.js'; +import { ShowUsageError } from './errors.js'; import { getConfig } from './get-config.js'; type Action = (args: string[]) => Promise; @@ -208,7 +208,7 @@ try { if (error instanceof Error) { console.error(error.message); if (error.cause instanceof Error) { - console.error(error.cause.message); + console.error(error.cause.stack); } } else { console.error(error); diff --git a/packages/cli/src/errors.ts b/packages/cli/src/errors.ts new file mode 100644 index 0000000..0aa7af6 --- /dev/null +++ b/packages/cli/src/errors.ts @@ -0,0 +1,71 @@ +import { type MigrationHistoryEntry, type MigrationMetadata } from '@emigrate/plugin-tools/types'; + +const formatter = new Intl.ListFormat('en', { style: 'long', type: 'disjunction' }); + +export class EmigrateError extends Error { + constructor( + public code: string, + message: string, + options?: ErrorOptions, + ) { + super(message, options); + } +} + +export class ShowUsageError extends EmigrateError {} + +export class MissingOptionError extends ShowUsageError { + constructor(public option: string | string[]) { + super('ERR_MISSING_OPT', `Missing required option: ${Array.isArray(option) ? formatter.format(option) : option}`); + } +} + +export class MissingArgumentsError extends ShowUsageError { + constructor(public argument: string) { + super('ERR_MISSING_ARGS', `Missing required argument: ${argument}`); + } +} + +export class BadOptionError extends ShowUsageError { + constructor( + public option: string, + message: string, + ) { + super('ERR_BAD_OPT', message); + } +} + +export class UnexpectedError extends EmigrateError { + constructor(message: string, options?: ErrorOptions) { + super('ERR_UNEXPECTED', message, options); + } +} + +export class MigrationHistoryError extends EmigrateError { + constructor( + message: string, + public entry: MigrationHistoryEntry, + ) { + super('ERR_MIGRATION_HISTORY', message); + } +} + +export class MigrationLoadError extends EmigrateError { + constructor( + message: string, + public metadata: MigrationMetadata, + options?: ErrorOptions, + ) { + super('ERR_MIGRATION_LOAD', message, options); + } +} + +export class MigrationRunError extends EmigrateError { + constructor( + message: string, + public metadata: MigrationMetadata, + options?: ErrorOptions, + ) { + super('ERR_MIGRATION_RUN', message, options); + } +} diff --git a/packages/cli/src/new-command.ts b/packages/cli/src/new-command.ts index 7b22e45..6b26088 100644 --- a/packages/cli/src/new-command.ts +++ b/packages/cli/src/new-command.ts @@ -2,21 +2,21 @@ import process from 'node:process'; import fs from 'node:fs/promises'; import path from 'node:path'; import { getTimestampPrefix, sanitizeMigrationName, getOrLoadPlugin } from '@emigrate/plugin-tools'; -import { ShowUsageError } from './show-usage-error.js'; +import { BadOptionError, MissingArgumentsError, MissingOptionError, UnexpectedError } from './errors.js'; import { type Config } from './types.js'; import { stripLeadingPeriod } from './strip-leading-period.js'; export default async function newCommand({ directory, template, plugins = [], extension }: Config, name: string) { if (!directory) { - throw new ShowUsageError('Missing required option: directory'); + throw new MissingOptionError('directory'); } if (!name) { - throw new ShowUsageError('Missing required migration name'); + throw new MissingArgumentsError('name'); } if (!extension && !template && plugins.length === 0) { - throw new ShowUsageError('Missing required option: extension, template or plugin'); + throw new MissingOptionError(['extension', 'template', 'plugin']); } let filename: string | undefined; @@ -31,7 +31,7 @@ export default async function newCommand({ directory, template, plugins = [], ex content = await fs.readFile(templatePath, 'utf8'); content = content.replaceAll('{{name}}', name); } catch (error) { - throw new Error(`Failed to read template file: ${templatePath}`, { cause: error }); + throw new UnexpectedError(`Failed to read template file: ${templatePath}`, { cause: error }); } filename = `${getTimestampPrefix()}_${sanitizeMigrationName(name)}.${stripLeadingPeriod( @@ -60,7 +60,10 @@ export default async function newCommand({ directory, template, plugins = [], ex } if (!filename || content === undefined) { - throw new ShowUsageError('No generator plugin found, please specify a generator plugin using the plugin option'); + throw new BadOptionError( + 'plugin', + 'No generator plugin found, please specify a generator plugin using the plugin option', + ); } const directoryPath = path.resolve(process.cwd(), directory); @@ -74,7 +77,7 @@ async function createDirectory(directoryPath: string) { try { await fs.mkdir(directoryPath, { recursive: true }); } catch (error) { - throw new Error(`Failed to create migration directory: ${directoryPath}`, { cause: error }); + throw new UnexpectedError(`Failed to create migration directory: ${directoryPath}`, { cause: error }); } } @@ -84,6 +87,6 @@ async function saveFile(filePath: string, content: string) { console.log(`Created migration file: ${path.relative(process.cwd(), filePath)}`); } catch (error) { - throw new Error(`Failed to write migration file: ${filePath}`, { cause: error }); + throw new UnexpectedError(`Failed to write migration file: ${filePath}`, { cause: error }); } } diff --git a/packages/cli/src/show-usage-error.ts b/packages/cli/src/show-usage-error.ts deleted file mode 100644 index 5404758..0000000 --- a/packages/cli/src/show-usage-error.ts +++ /dev/null @@ -1 +0,0 @@ -export class ShowUsageError extends Error {} diff --git a/packages/cli/src/up-command.ts b/packages/cli/src/up-command.ts index 0be6e07..22fd81c 100644 --- a/packages/cli/src/up-command.ts +++ b/packages/cli/src/up-command.ts @@ -1,7 +1,14 @@ import process from 'node:process'; import { getOrLoadPlugin, getOrLoadPlugins } from '@emigrate/plugin-tools'; -import { type LoaderPlugin } from '@emigrate/plugin-tools/types'; -import { ShowUsageError } from './show-usage-error.js'; +import { type LoaderPlugin, type MigrationFunction } from '@emigrate/plugin-tools/types'; +import { + BadOptionError, + EmigrateError, + MigrationHistoryError, + MigrationLoadError, + MigrationRunError, + MissingOptionError, +} from './errors.js'; import { type Config } from './types.js'; import { stripLeadingPeriod } from './strip-leading-period.js'; import pluginLoaderJs from './plugin-loader-js.js'; @@ -12,13 +19,16 @@ type ExtraFlags = { export default async function upCommand({ directory, dry, plugins = [] }: Config & ExtraFlags) { if (!directory) { - throw new ShowUsageError('Missing required option: directory'); + throw new MissingOptionError('directory'); } const storagePlugin = await getOrLoadPlugin('storage', plugins); if (!storagePlugin) { - throw new Error('No storage plugin found, please specify a storage plugin using the plugin option'); + throw new BadOptionError( + 'plugin', + 'No storage plugin found, please specify a storage plugin using the plugin option', + ); } const storage = await storagePlugin.initializeStorage(); @@ -36,7 +46,10 @@ export default async function upCommand({ directory, dry, plugins = [] }: Config for await (const migrationHistoryEntry of storage.getHistory()) { if (migrationHistoryEntry.status === 'failed') { - throw new Error(`Migration ${migrationHistoryEntry.name} is in a failed state, please fix it first`); + throw new MigrationHistoryError( + `Migration ${migrationHistoryEntry.name} is in a failed state, please fix it first`, + migrationHistoryEntry, + ); } if (migrationFiles.includes(migrationHistoryEntry.name)) { @@ -61,7 +74,7 @@ export default async function upCommand({ directory, dry, plugins = [] }: Config for (const [extension, loader] of loaderByExtension) { if (!loader) { - throw new Error(`No loader plugin found for file extension: ${extension}`); + throw new BadOptionError('plugin', `No loader plugin found for file extension: ${extension}`); } } @@ -100,10 +113,19 @@ export default async function upCommand({ directory, dry, plugins = [] }: Config 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 migration = await loader.loadMigration({ name, filePath, relativeFilePath, cwd, directory, extension }); + let migration: MigrationFunction; try { + try { + migration = await loader.loadMigration(metadata); + } catch (error) { + throw new MigrationLoadError(`Failed to load migration file: ${relativeFilePath}`, metadata, { + cause: error, + }); + } + await migration(); console.log(' -', name, 'done'); @@ -115,12 +137,14 @@ export default async function upCommand({ directory, dry, plugins = [] }: Config 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 }); + } + throw error; } } - } catch (error) { - console.error(error); - process.exitCode = 1; } finally { await cleanup(); }