feat(cli): improve error handling with more custom Error instances
This commit is contained in:
parent
8dadfe9a5b
commit
30a448b4cf
6 changed files with 123 additions and 21 deletions
5
.changeset/strange-bags-complain.md
Normal file
5
.changeset/strange-bags-complain.md
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
'@emigrate/cli': minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Improve error handling by making more granular custom Error instances
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
import { parseArgs } from 'node:util';
|
import { parseArgs } from 'node:util';
|
||||||
import { ShowUsageError } from './show-usage-error.js';
|
import { ShowUsageError } from './errors.js';
|
||||||
import { getConfig } from './get-config.js';
|
import { getConfig } from './get-config.js';
|
||||||
|
|
||||||
type Action = (args: string[]) => Promise<void>;
|
type Action = (args: string[]) => Promise<void>;
|
||||||
|
|
@ -208,7 +208,7 @@ try {
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
console.error(error.message);
|
console.error(error.message);
|
||||||
if (error.cause instanceof Error) {
|
if (error.cause instanceof Error) {
|
||||||
console.error(error.cause.message);
|
console.error(error.cause.stack);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
|
|
||||||
71
packages/cli/src/errors.ts
Normal file
71
packages/cli/src/errors.ts
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,21 +2,21 @@ import process from 'node:process';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { getTimestampPrefix, sanitizeMigrationName, getOrLoadPlugin } from '@emigrate/plugin-tools';
|
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 { type Config } from './types.js';
|
||||||
import { stripLeadingPeriod } from './strip-leading-period.js';
|
import { stripLeadingPeriod } from './strip-leading-period.js';
|
||||||
|
|
||||||
export default async function newCommand({ directory, template, plugins = [], extension }: Config, name: string) {
|
export default async function newCommand({ directory, template, plugins = [], extension }: Config, name: string) {
|
||||||
if (!directory) {
|
if (!directory) {
|
||||||
throw new ShowUsageError('Missing required option: directory');
|
throw new MissingOptionError('directory');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!name) {
|
if (!name) {
|
||||||
throw new ShowUsageError('Missing required migration name');
|
throw new MissingArgumentsError('name');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!extension && !template && plugins.length === 0) {
|
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;
|
let filename: string | undefined;
|
||||||
|
|
@ -31,7 +31,7 @@ export default async function newCommand({ directory, template, plugins = [], ex
|
||||||
content = await fs.readFile(templatePath, 'utf8');
|
content = await fs.readFile(templatePath, 'utf8');
|
||||||
content = content.replaceAll('{{name}}', name);
|
content = content.replaceAll('{{name}}', name);
|
||||||
} catch (error) {
|
} 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(
|
filename = `${getTimestampPrefix()}_${sanitizeMigrationName(name)}.${stripLeadingPeriod(
|
||||||
|
|
@ -60,7 +60,10 @@ export default async function newCommand({ directory, template, plugins = [], ex
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!filename || content === undefined) {
|
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);
|
const directoryPath = path.resolve(process.cwd(), directory);
|
||||||
|
|
@ -74,7 +77,7 @@ async function createDirectory(directoryPath: string) {
|
||||||
try {
|
try {
|
||||||
await fs.mkdir(directoryPath, { recursive: true });
|
await fs.mkdir(directoryPath, { recursive: true });
|
||||||
} catch (error) {
|
} 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)}`);
|
console.log(`Created migration file: ${path.relative(process.cwd(), filePath)}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(`Failed to write migration file: ${filePath}`, { cause: error });
|
throw new UnexpectedError(`Failed to write migration file: ${filePath}`, { cause: error });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export class ShowUsageError extends Error {}
|
|
||||||
|
|
@ -1,7 +1,14 @@
|
||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
import { getOrLoadPlugin, getOrLoadPlugins } from '@emigrate/plugin-tools';
|
import { getOrLoadPlugin, getOrLoadPlugins } from '@emigrate/plugin-tools';
|
||||||
import { type LoaderPlugin } from '@emigrate/plugin-tools/types';
|
import { type LoaderPlugin, type MigrationFunction } from '@emigrate/plugin-tools/types';
|
||||||
import { ShowUsageError } from './show-usage-error.js';
|
import {
|
||||||
|
BadOptionError,
|
||||||
|
EmigrateError,
|
||||||
|
MigrationHistoryError,
|
||||||
|
MigrationLoadError,
|
||||||
|
MigrationRunError,
|
||||||
|
MissingOptionError,
|
||||||
|
} from './errors.js';
|
||||||
import { type Config } from './types.js';
|
import { type Config } from './types.js';
|
||||||
import { stripLeadingPeriod } from './strip-leading-period.js';
|
import { stripLeadingPeriod } from './strip-leading-period.js';
|
||||||
import pluginLoaderJs from './plugin-loader-js.js';
|
import pluginLoaderJs from './plugin-loader-js.js';
|
||||||
|
|
@ -12,13 +19,16 @@ type ExtraFlags = {
|
||||||
|
|
||||||
export default async function upCommand({ directory, dry, plugins = [] }: Config & ExtraFlags) {
|
export default async function upCommand({ directory, dry, plugins = [] }: Config & ExtraFlags) {
|
||||||
if (!directory) {
|
if (!directory) {
|
||||||
throw new ShowUsageError('Missing required option: directory');
|
throw new MissingOptionError('directory');
|
||||||
}
|
}
|
||||||
|
|
||||||
const storagePlugin = await getOrLoadPlugin('storage', plugins);
|
const storagePlugin = await getOrLoadPlugin('storage', plugins);
|
||||||
|
|
||||||
if (!storagePlugin) {
|
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();
|
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()) {
|
for await (const migrationHistoryEntry of storage.getHistory()) {
|
||||||
if (migrationHistoryEntry.status === 'failed') {
|
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)) {
|
if (migrationFiles.includes(migrationHistoryEntry.name)) {
|
||||||
|
|
@ -61,7 +74,7 @@ export default async function upCommand({ directory, dry, plugins = [] }: Config
|
||||||
|
|
||||||
for (const [extension, loader] of loaderByExtension) {
|
for (const [extension, loader] of loaderByExtension) {
|
||||||
if (!loader) {
|
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 filePath = path.resolve(cwd, directory, name);
|
||||||
const relativeFilePath = path.relative(cwd, filePath);
|
const relativeFilePath = path.relative(cwd, filePath);
|
||||||
const loader = loaderByExtension.get(extension)!;
|
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 {
|
||||||
|
try {
|
||||||
|
migration = await loader.loadMigration(metadata);
|
||||||
|
} catch (error) {
|
||||||
|
throw new MigrationLoadError(`Failed to load migration file: ${relativeFilePath}`, metadata, {
|
||||||
|
cause: error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
await migration();
|
await migration();
|
||||||
|
|
||||||
console.log(' -', name, 'done');
|
console.log(' -', name, 'done');
|
||||||
|
|
@ -115,12 +137,14 @@ export default async function upCommand({ directory, dry, plugins = [] }: Config
|
||||||
console.error(' -', name, 'failed:', errorInstance.message);
|
console.error(' -', name, 'failed:', errorInstance.message);
|
||||||
|
|
||||||
await storage.onError(name, errorInstance);
|
await storage.onError(name, errorInstance);
|
||||||
|
|
||||||
|
if (!(error instanceof EmigrateError)) {
|
||||||
|
throw new MigrationRunError(`Failed to run migration: ${relativeFilePath}`, metadata, { cause: error });
|
||||||
|
}
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
process.exitCode = 1;
|
|
||||||
} finally {
|
} finally {
|
||||||
await cleanup();
|
await cleanup();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue