diff --git a/.changeset/early-hornets-marry.md b/.changeset/early-hornets-marry.md new file mode 100644 index 0000000..9625faf --- /dev/null +++ b/.changeset/early-hornets-marry.md @@ -0,0 +1,5 @@ +--- +'@emigrate/cli': minor +--- + +Make Emigrate Error instances deserializable using the serialize-error package, and also switch to its serializeError method diff --git a/.changeset/eleven-sheep-think.md b/.changeset/eleven-sheep-think.md new file mode 100644 index 0000000..e834122 --- /dev/null +++ b/.changeset/eleven-sheep-think.md @@ -0,0 +1,5 @@ +--- +'@emigrate/cli': patch +--- + +Shutdown the storage correctly in case of directory or file reading errors diff --git a/.changeset/healthy-monkeys-tan.md b/.changeset/healthy-monkeys-tan.md new file mode 100644 index 0000000..55a64fb --- /dev/null +++ b/.changeset/healthy-monkeys-tan.md @@ -0,0 +1,5 @@ +--- +'@emigrate/types': minor +--- + +Use discriminating union types for migration types for easier error handling and such diff --git a/.changeset/hip-mayflies-provide.md b/.changeset/hip-mayflies-provide.md new file mode 100644 index 0000000..97f027d --- /dev/null +++ b/.changeset/hip-mayflies-provide.md @@ -0,0 +1,6 @@ +--- +'@emigrate/plugin-tools': minor +'@emigrate/types': minor +--- + +Move the Emigrate plugin types to a separate package for fewer version bumps in plugins hopefully diff --git a/.changeset/moody-penguins-repair.md b/.changeset/moody-penguins-repair.md new file mode 100644 index 0000000..42f6421 --- /dev/null +++ b/.changeset/moody-penguins-repair.md @@ -0,0 +1,5 @@ +--- +'@emigrate/cli': minor +--- + +Adapt to the new discriminating union types in @emigrate/types diff --git a/.changeset/spotty-otters-attack.md b/.changeset/spotty-otters-attack.md new file mode 100644 index 0000000..14d984c --- /dev/null +++ b/.changeset/spotty-otters-attack.md @@ -0,0 +1,5 @@ +--- +'@emigrate/types': minor +--- + +Make it easier for storage plugins by serializing errors passed to the onError method and let it respond with serialized errors in the getHistory method diff --git a/packages/cli/package.json b/packages/cli/package.json index 934474f..53f0add 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -44,13 +44,15 @@ "license": "MIT", "dependencies": { "@emigrate/plugin-tools": "workspace:*", + "@emigrate/types": "workspace:*", "ansis": "2.0.2", "cosmiconfig": "8.3.6", "elegant-spinner": "3.0.0", "figures": "6.0.1", "is-interactive": "2.0.0", "log-update": "6.0.0", - "pretty-ms": "8.0.0" + "pretty-ms": "8.0.0", + "serialize-error": "11.0.3" }, "volta": { "extends": "../../package.json" diff --git a/packages/cli/src/collect-migrations.ts b/packages/cli/src/collect-migrations.ts index 4fb3598..d583a44 100644 --- a/packages/cli/src/collect-migrations.ts +++ b/packages/cli/src/collect-migrations.ts @@ -1,8 +1,4 @@ -import { - type MigrationHistoryEntry, - type MigrationMetadata, - type MigrationMetadataFinished, -} from '@emigrate/plugin-tools/types'; +import { type MigrationHistoryEntry, type MigrationMetadata, type MigrationMetadataFinished } from '@emigrate/types'; import { toMigrationMetadata } from './to-migration-metadata.js'; import { getMigrations as getMigrationsOriginal } from './get-migrations.js'; diff --git a/packages/cli/src/commands/list.ts b/packages/cli/src/commands/list.ts index 8641fd9..d0ed7b5 100644 --- a/packages/cli/src/commands/list.ts +++ b/packages/cli/src/commands/list.ts @@ -1,6 +1,6 @@ import process from 'node:process'; import { getOrLoadReporter, getOrLoadStorage } from '@emigrate/plugin-tools'; -import { BadOptionError, MissingOptionError, StorageInitError } from '../errors.js'; +import { BadOptionError, MissingOptionError, StorageInitError, toError } from '../errors.js'; import { type Config } from '../types.js'; import { exec } from '../exec.js'; import { migrationRunner } from '../migration-runner.js'; @@ -12,20 +12,20 @@ const lazyDefaultReporter = async () => import('../reporters/default.js'); export default async function listCommand({ directory, reporter: reporterConfig, storage: storageConfig }: Config) { if (!directory) { - throw new MissingOptionError('directory'); + throw MissingOptionError.fromOption('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'); + throw BadOptionError.fromOption('storage', 'No storage found, please specify a storage using the storage option'); } const reporter = await getOrLoadReporter([reporterConfig ?? lazyDefaultReporter]); if (!reporter) { - throw new BadOptionError( + throw BadOptionError.fromOption( 'reporter', 'No reporter found, please specify an existing reporter using the reporter option', ); @@ -36,25 +36,33 @@ export default async function listCommand({ directory, reporter: reporterConfig, const [storage, storageError] = await exec(async () => storagePlugin.initializeStorage()); if (storageError) { - await reporter.onFinished?.([], new StorageInitError('Could not initialize storage', { cause: storageError })); + await reporter.onFinished?.([], StorageInitError.fromError(storageError)); return 1; } - const collectedMigrations = collectMigrations(cwd, directory, storage.getHistory()); + try { + const collectedMigrations = collectMigrations(cwd, directory, storage.getHistory()); - const error = await migrationRunner({ - dry: true, - reporter, - storage, - migrations: await arrayFromAsync(collectedMigrations), - async validate() { - // No-op - }, - async execute() { - throw new Error('Unexpected execute call'); - }, - }); + const error = await migrationRunner({ + dry: true, + reporter, + storage, + migrations: await arrayFromAsync(collectedMigrations), + async validate() { + // No-op + }, + async execute() { + throw new Error('Unexpected execute call'); + }, + }); - return error ? 1 : 0; + return error ? 1 : 0; + } catch (error) { + await reporter.onFinished?.([], toError(error)); + + return 1; + } finally { + await storage.end(); + } } diff --git a/packages/cli/src/commands/new.ts b/packages/cli/src/commands/new.ts index 3967312..8c551c4 100644 --- a/packages/cli/src/commands/new.ts +++ b/packages/cli/src/commands/new.ts @@ -2,11 +2,19 @@ import process from 'node:process'; import fs from 'node:fs/promises'; import path from 'node:path'; import { getTimestampPrefix, sanitizeMigrationName, getOrLoadPlugin, getOrLoadReporter } from '@emigrate/plugin-tools'; -import { type MigrationMetadata } from '@emigrate/plugin-tools/types'; -import { BadOptionError, MissingArgumentsError, MissingOptionError, UnexpectedError } from '../errors.js'; +import { type MigrationMetadataFinished, type MigrationMetadata, isFailedMigration } from '@emigrate/types'; +import { + BadOptionError, + EmigrateError, + MissingArgumentsError, + MissingOptionError, + UnexpectedError, + toError, +} from '../errors.js'; import { type Config } from '../types.js'; import { withLeadingPeriod } from '../with-leading-period.js'; import { version } from '../get-package-info.js'; +import { getDuration } from '../get-duration.js'; const lazyDefaultReporter = async () => import('../reporters/default.js'); @@ -15,15 +23,15 @@ export default async function newCommand( name: string, ) { if (!directory) { - throw new MissingOptionError('directory'); + throw MissingOptionError.fromOption('directory'); } if (!name) { - throw new MissingArgumentsError('name'); + throw MissingArgumentsError.fromArgument('name'); } if (!extension && !template && plugins.length === 0) { - throw new MissingOptionError(['extension', 'template', 'plugin']); + throw MissingOptionError.fromOption(['extension', 'template', 'plugin']); } const cwd = process.cwd(); @@ -31,7 +39,7 @@ export default async function newCommand( const reporter = await getOrLoadReporter([reporterConfig ?? lazyDefaultReporter]); if (!reporter) { - throw new BadOptionError( + throw BadOptionError.fromOption( 'reporter', 'No reporter found, please specify an existing reporter using the reporter option', ); @@ -39,6 +47,8 @@ export default async function newCommand( await reporter.onInit?.({ command: 'new', version, cwd, dry: false, directory }); + const start = process.hrtime(); + let filename: string | undefined; let content: string | undefined; @@ -82,7 +92,7 @@ export default async function newCommand( } if (!filename || content === undefined) { - throw new BadOptionError( + throw BadOptionError.fromOption( 'plugin', 'No generator plugin found, please specify a generator plugin using the plugin option', ); @@ -102,19 +112,31 @@ export default async function newCommand( await reporter.onNewMigration?.(migration, content); - let saveError: Error | undefined; + const finishedMigrations: MigrationMetadataFinished[] = []; try { await createDirectory(directoryPath); await saveFile(filePath, content); + const duration = getDuration(start); + finishedMigrations.push({ ...migration, status: 'done', duration }); } catch (error) { - saveError = error instanceof Error ? error : new Error(String(error)); + const duration = getDuration(start); + const errorInstance = toError(error); + finishedMigrations.push({ ...migration, status: 'failed', duration, error: errorInstance }); } - await reporter.onFinished?.( - [{ ...migration, status: saveError ? 'failed' : 'done', error: saveError, duration: 0 }], - saveError, - ); + // eslint-disable-next-line unicorn/no-array-callback-reference + const firstFailed = finishedMigrations.find(isFailedMigration); + const firstError = + firstFailed?.error instanceof EmigrateError + ? firstFailed.error + : firstFailed + ? new UnexpectedError(`Failed to create migration file: ${firstFailed.relativeFilePath}`, { + cause: firstFailed?.error, + }) + : undefined; + + await reporter.onFinished?.(finishedMigrations, firstError); } async function createDirectory(directoryPath: string) { diff --git a/packages/cli/src/commands/remove.ts b/packages/cli/src/commands/remove.ts index f48f08b..852a34e 100644 --- a/packages/cli/src/commands/remove.ts +++ b/packages/cli/src/commands/remove.ts @@ -1,6 +1,6 @@ import process from 'node:process'; import { getOrLoadReporter, getOrLoadStorage } from '@emigrate/plugin-tools'; -import { type MigrationHistoryEntry, type MigrationMetadataFinished } from '@emigrate/plugin-tools/types'; +import { type MigrationHistoryEntry, type MigrationMetadataFinished } from '@emigrate/types'; import { BadOptionError, MigrationNotRunError, @@ -26,24 +26,24 @@ export default async function removeCommand( name: string, ) { if (!directory) { - throw new MissingOptionError('directory'); + throw MissingOptionError.fromOption('directory'); } if (!name) { - throw new MissingArgumentsError('name'); + throw MissingArgumentsError.fromArgument('name'); } 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'); + throw BadOptionError.fromOption('storage', 'No storage found, please specify a storage using the storage option'); } const reporter = await getOrLoadReporter([reporterConfig ?? lazyDefaultReporter]); if (!reporter) { - throw new BadOptionError( + throw BadOptionError.fromOption( 'reporter', 'No reporter found, please specify an existing reporter using the reporter option', ); @@ -52,14 +52,22 @@ export default async function removeCommand( const [storage, storageError] = await exec(async () => storagePlugin.initializeStorage()); if (storageError) { - await reporter.onFinished?.([], new StorageInitError('Could not initialize storage', { cause: storageError })); + await reporter.onFinished?.([], StorageInitError.fromError(storageError)); return 1; } await reporter.onInit?.({ command: 'remove', version, cwd, dry: false, directory }); - const migrationFile = await getMigration(cwd, directory, name, !force); + const [migrationFile, fileError] = await exec(async () => getMigration(cwd, directory, name, !force)); + + if (fileError) { + await reporter.onFinished?.([], fileError); + + await storage.end(); + + return 1; + } const finishedMigrations: MigrationMetadataFinished[] = []; let historyEntry: MigrationHistoryEntry | undefined; @@ -71,7 +79,7 @@ export default async function removeCommand( } if (migrationHistoryEntry.status === 'done' && !force) { - removalError = new OptionNeededError( + removalError = OptionNeededError.fromOption( 'force', `The migration "${migrationFile.name}" is not in a failed state. Use the "force" option to force its removal`, ); @@ -98,10 +106,7 @@ export default async function removeCommand( removalError = error instanceof Error ? error : new Error(String(error)); } } else if (!removalError) { - removalError = new MigrationNotRunError( - `Migration "${migrationFile.name}" is not in the migration history`, - migrationFile, - ); + removalError = MigrationNotRunError.fromMetadata(migrationFile); } if (removalError) { diff --git a/packages/cli/src/commands/up.test.ts b/packages/cli/src/commands/up.test.ts index fe1be22..c0b63c4 100644 --- a/packages/cli/src/commands/up.test.ts +++ b/packages/cli/src/commands/up.test.ts @@ -1,7 +1,6 @@ import { describe, it, mock, type Mock } from 'node:test'; import assert from 'node:assert'; import path from 'node:path'; -import { serializeError } from '@emigrate/plugin-tools'; import { type EmigrateReporter, type MigrationHistoryEntry, @@ -9,7 +8,10 @@ import { type Storage, type Plugin, type SerializedError, -} from '@emigrate/plugin-tools/types'; + type FailedMigrationHistoryEntry, + type NonFailedMigrationHistoryEntry, +} from '@emigrate/types'; +import { deserializeError } from 'serialize-error'; import { version } from '../get-package-info.js'; import upCommand from './up.js'; @@ -117,7 +119,10 @@ describe('up', () => { assert.strictEqual(reporter.onMigrationStart.mock.calls.length, 0); assert.strictEqual(reporter.onMigrationSuccess.mock.calls.length, 0); assert.strictEqual(reporter.onMigrationError.mock.calls.length, 1); - assert.strictEqual(getErrorCause(reporter.onMigrationError.mock.calls[0]?.arguments[1]), failedEntry.error); + assert.deepStrictEqual( + getErrorCause(reporter.onMigrationError.mock.calls[0]?.arguments[1]), + deserializeError(failedEntry.error), + ); assert.strictEqual(reporter.onMigrationSkip.mock.calls.length, 1); assert.strictEqual(reporter.onFinished.mock.calls.length, 1); const [entries, error] = reporter.onFinished.mock.calls[0]?.arguments ?? []; @@ -125,7 +130,7 @@ describe('up', () => { error?.message, `Migration ${failedEntry.name} is in a failed state, it should be fixed and removed`, ); - assert.strictEqual(getErrorCause(error), failedEntry.error); + assert.deepStrictEqual(getErrorCause(error), deserializeError(failedEntry.error)); assert.strictEqual(entries?.length, 2); assert.deepStrictEqual( entries.map((entry) => `${entry.name} (${entry.status})`), @@ -155,7 +160,10 @@ describe('up', () => { assert.strictEqual(reporter.onMigrationStart.mock.calls.length, 0); assert.strictEqual(reporter.onMigrationSuccess.mock.calls.length, 0); assert.strictEqual(reporter.onMigrationError.mock.calls.length, 1); - assert.strictEqual(getErrorCause(reporter.onMigrationError.mock.calls[0]?.arguments[1]), failedEntry.error); + assert.deepStrictEqual( + getErrorCause(reporter.onMigrationError.mock.calls[0]?.arguments[1]), + deserializeError(failedEntry.error), + ); assert.strictEqual(reporter.onMigrationSkip.mock.calls.length, 1); assert.strictEqual(reporter.onFinished.mock.calls.length, 1); const [entries, error] = reporter.onFinished.mock.calls[0]?.arguments ?? []; @@ -163,7 +171,7 @@ describe('up', () => { error?.message, `Migration ${failedEntry.name} is in a failed state, it should be fixed and removed`, ); - assert.strictEqual(getErrorCause(error), failedEntry.error); + assert.deepStrictEqual(getErrorCause(error), deserializeError(failedEntry.error)); assert.strictEqual(entries?.length, 2); assert.deepStrictEqual( entries.map((entry) => `${entry.name} (${entry.status})`), @@ -354,27 +362,38 @@ function toMigrations(cwd: string, directory: string, names: string[]): Migratio return names.map((name) => toMigration(cwd, directory, name)); } -function toEntry( - name: string | MigrationHistoryEntry, - status: MigrationHistoryEntry['status'] = 'done', -): MigrationHistoryEntry { - if (typeof name === 'string') { +function toEntry(name: MigrationHistoryEntry): MigrationHistoryEntry; +function toEntry( + name: string, + status?: S, +): S extends 'failed' ? FailedMigrationHistoryEntry : NonFailedMigrationHistoryEntry; + +function toEntry(name: string | MigrationHistoryEntry, status?: 'done' | 'failed'): MigrationHistoryEntry { + if (typeof name !== 'string') { + return name.status === 'failed' ? name : name; + } + + if (status === 'failed') { return { name, status, date: new Date(), - error: status === 'failed' ? serializeError(new Error('Failed')) : undefined, + error: { name: 'Error', message: 'Failed' }, }; } - return name; + return { + name, + status: status ?? 'done', + date: new Date(), + }; } function toEntries( names: Array, - status: MigrationHistoryEntry['status'] = 'done', + status?: MigrationHistoryEntry['status'], ): MigrationHistoryEntry[] { - return names.map((name) => toEntry(name, status)); + return names.map((name) => (typeof name === 'string' ? toEntry(name, status) : name)); } async function noop() { diff --git a/packages/cli/src/commands/up.ts b/packages/cli/src/commands/up.ts index 8c1cceb..07ec7c9 100644 --- a/packages/cli/src/commands/up.ts +++ b/packages/cli/src/commands/up.ts @@ -1,7 +1,7 @@ import process from 'node:process'; import { getOrLoadPlugins, getOrLoadReporter, getOrLoadStorage } from '@emigrate/plugin-tools'; -import { isFinishedMigration, type LoaderPlugin } from '@emigrate/plugin-tools/types'; -import { BadOptionError, MigrationLoadError, MissingOptionError, StorageInitError } from '../errors.js'; +import { isFinishedMigration, type LoaderPlugin } from '@emigrate/types'; +import { BadOptionError, MigrationLoadError, MissingOptionError, StorageInitError, toError } from '../errors.js'; import { type Config } from '../types.js'; import { withLeadingPeriod } from '../with-leading-period.js'; import { type GetMigrationsFunction } from '../get-migrations.js'; @@ -31,19 +31,19 @@ export default async function upCommand({ getMigrations, }: Config & ExtraFlags): Promise { if (!directory) { - throw new MissingOptionError('directory'); + throw MissingOptionError.fromOption('directory'); } const storagePlugin = await getOrLoadStorage([storageConfig]); if (!storagePlugin) { - throw new BadOptionError('storage', 'No storage found, please specify a storage using the storage option'); + throw BadOptionError.fromOption('storage', 'No storage found, please specify a storage using the storage option'); } const reporter = await getOrLoadReporter([reporterConfig ?? lazyDefaultReporter]); if (!reporter) { - throw new BadOptionError( + throw BadOptionError.fromOption( 'reporter', 'No reporter found, please specify an existing reporter using the reporter option', ); @@ -54,57 +54,66 @@ export default async function upCommand({ const [storage, storageError] = await exec(async () => storagePlugin.initializeStorage()); if (storageError) { - await reporter.onFinished?.([], new StorageInitError('Could not initialize storage', { cause: storageError })); + await reporter.onFinished?.([], StorageInitError.fromError(storageError)); return 1; } - const collectedMigrations = filterAsync( - collectMigrations(cwd, directory, storage.getHistory(), getMigrations), - (migration) => !isFinishedMigration(migration) || migration.status === 'failed', - ); + try { + const collectedMigrations = filterAsync( + collectMigrations(cwd, directory, storage.getHistory(), getMigrations), + (migration) => !isFinishedMigration(migration) || migration.status === 'failed', + ); - const loaderPlugins = await getOrLoadPlugins('loader', [lazyPluginLoaderJs, ...plugins]); + const loaderPlugins = await getOrLoadPlugins('loader', [lazyPluginLoaderJs, ...plugins]); - const loaderByExtension = new Map(); + const loaderByExtension = new Map(); - const getLoaderByExtension = (extension: string) => { - if (!loaderByExtension.has(extension)) { - const loader = loaderPlugins.find((plugin) => - plugin.loadableExtensions.some((loadableExtension) => withLeadingPeriod(loadableExtension) === extension), - ); + const getLoaderByExtension = (extension: string) => { + if (!loaderByExtension.has(extension)) { + const loader = loaderPlugins.find((plugin) => + plugin.loadableExtensions.some((loadableExtension) => withLeadingPeriod(loadableExtension) === extension), + ); - loaderByExtension.set(extension, loader); - } - - return loaderByExtension.get(extension); - }; - - const error = await migrationRunner({ - dry, - reporter, - storage, - migrations: await arrayFromAsync(collectedMigrations), - async validate(migration) { - const loader = getLoaderByExtension(migration.extension); - - if (!loader) { - throw new BadOptionError('plugin', `No loader plugin found for file extension: ${migration.extension}`); - } - }, - async execute(migration) { - const loader = getLoaderByExtension(migration.extension)!; - const [migrationFunction, loadError] = await exec(async () => loader.loadMigration(migration)); - - if (loadError) { - throw new MigrationLoadError(`Failed to load migration file: ${migration.relativeFilePath}`, migration, { - cause: loadError, - }); + loaderByExtension.set(extension, loader); } - await migrationFunction(); - }, - }); + return loaderByExtension.get(extension); + }; - return error ? 1 : 0; + const error = await migrationRunner({ + dry, + reporter, + storage, + migrations: await arrayFromAsync(collectedMigrations), + async validate(migration) { + const loader = getLoaderByExtension(migration.extension); + + if (!loader) { + throw BadOptionError.fromOption( + 'plugin', + `No loader plugin found for file extension: ${migration.extension}`, + ); + } + }, + async execute(migration) { + const loader = getLoaderByExtension(migration.extension)!; + const [migrationFunction, loadError] = await exec(async () => loader.loadMigration(migration)); + + if (loadError) { + throw MigrationLoadError.fromMetadata(migration, loadError); + } + + await migrationFunction(); + }, + }); + + return error ? 1 : 0; + } catch (error) { + await reporter.onFinished?.([], toError(error)); + + return 1; + } finally { + await storage.end(); + } } diff --git a/packages/cli/src/errors.ts b/packages/cli/src/errors.ts index d728336..4038901 100644 --- a/packages/cli/src/errors.ts +++ b/packages/cli/src/errors.ts @@ -1,14 +1,26 @@ -import { type MigrationHistoryEntry, type MigrationMetadata } from '@emigrate/plugin-tools/types'; +import { + type SerializedError, + type MigrationMetadata, + type FailedMigrationMetadata, + type FailedMigrationHistoryEntry, +} from '@emigrate/types'; +import { serializeError, errorConstructors, deserializeError } from 'serialize-error'; const formatter = new Intl.ListFormat('en', { style: 'long', type: 'disjunction' }); export const toError = (error: unknown) => (error instanceof Error ? error : new Error(String(error))); +export const toSerializedError = (error: unknown) => { + const errorInstance = toError(error); + + return serializeError(errorInstance) as unknown as SerializedError; +}; + export class EmigrateError extends Error { constructor( - public code: string, - message: string, + message: string | undefined, options?: ErrorOptions, + public code?: string, ) { super(message, options); } @@ -17,82 +29,132 @@ export class EmigrateError extends Error { 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}`); + static fromOption(option: string | string[]) { + return new MissingOptionError( + `Missing required option: ${Array.isArray(option) ? formatter.format(option) : option}`, + undefined, + option, + ); + } + + constructor( + message: string | undefined, + options?: ErrorOptions, + public option: string | string[] = '', + ) { + super(message, options, 'ERR_MISSING_OPT'); } } export class MissingArgumentsError extends ShowUsageError { - constructor(public argument: string) { - super('ERR_MISSING_ARGS', `Missing required argument: ${argument}`); + static fromArgument(argument: string) { + return new MissingArgumentsError(`Missing required argument: ${argument}`, undefined, argument); + } + + constructor( + message: string | undefined, + options?: ErrorOptions, + public argument = '', + ) { + super(message, options, 'ERR_MISSING_ARGS'); } } export class OptionNeededError extends ShowUsageError { + static fromOption(option: string, message: string) { + return new OptionNeededError(message, undefined, option); + } + constructor( - public option: string, - message: string, + message: string | undefined, + options?: ErrorOptions, + public option = '', ) { - super('ERR_OPT_NEEDED', message); + super(message, options, 'ERR_OPT_NEEDED'); } } export class BadOptionError extends ShowUsageError { + static fromOption(option: string, message: string) { + return new BadOptionError(message, undefined, option); + } + constructor( - public option: string, - message: string, + message: string | undefined, + options?: ErrorOptions, + public option = '', ) { - super('ERR_BAD_OPT', message); + super(message, options, 'ERR_BAD_OPT'); } } export class UnexpectedError extends EmigrateError { - constructor(message: string, options?: ErrorOptions) { - super('ERR_UNEXPECTED', message, options); + constructor(message: string | undefined, options?: ErrorOptions) { + super(message, options, 'ERR_UNEXPECTED'); } } export class MigrationHistoryError extends EmigrateError { - constructor( - message: string, - public entry: MigrationHistoryEntry, - ) { - super('ERR_MIGRATION_HISTORY', message, { cause: entry.error }); + static fromHistoryEntry(entry: FailedMigrationHistoryEntry) { + return new MigrationHistoryError(`Migration ${entry.name} is in a failed state, it should be fixed and removed`, { + cause: deserializeError(entry.error), + }); + } + + constructor(message: string | undefined, options?: ErrorOptions) { + super(message, options, 'ERR_MIGRATION_HISTORY'); } } export class MigrationLoadError extends EmigrateError { - constructor( - message: string, - public metadata: MigrationMetadata, - options?: ErrorOptions, - ) { - super('ERR_MIGRATION_LOAD', message, options); + static fromMetadata(metadata: MigrationMetadata, cause?: Error) { + return new MigrationLoadError(`Failed to load migration file: ${metadata.relativeFilePath}`, { cause }); + } + + constructor(message: string | undefined, options?: ErrorOptions) { + super(message, options, 'ERR_MIGRATION_LOAD'); } } export class MigrationRunError extends EmigrateError { - constructor( - message: string, - public metadata: MigrationMetadata, - options?: ErrorOptions, - ) { - super('ERR_MIGRATION_RUN', message, options); + static fromMetadata(metadata: FailedMigrationMetadata) { + return new MigrationRunError(`Failed to run migration: ${metadata.relativeFilePath}`, { cause: metadata.error }); + } + + constructor(message: string | undefined, options?: ErrorOptions) { + super(message, options, 'ERR_MIGRATION_RUN'); } } export class MigrationNotRunError extends EmigrateError { - constructor( - message: string, - public metadata: MigrationMetadata, - options?: ErrorOptions, - ) { - super('ERR_MIGRATION_NOT_RUN', message, options); + static fromMetadata(metadata: MigrationMetadata, cause?: Error) { + return new MigrationNotRunError(`Migration "${metadata.name}" is not in the migration history`, { cause }); + } + + constructor(message: string | undefined, options?: ErrorOptions) { + super(message, options, 'ERR_MIGRATION_NOT_RUN'); } } export class StorageInitError extends EmigrateError { - constructor(message: string, options?: ErrorOptions) { - super('ERR_STORAGE_INIT', message, options); + static fromError(error: Error) { + return new StorageInitError('Could not initialize storage', { cause: error }); + } + + constructor(message: string | undefined, options?: ErrorOptions) { + super(message, options, 'ERR_STORAGE_INIT'); } } + +errorConstructors.set('EmigrateError', EmigrateError as ErrorConstructor); +errorConstructors.set('ShowUsageError', ShowUsageError as ErrorConstructor); +errorConstructors.set('MissingOptionError', MissingOptionError as unknown as ErrorConstructor); +errorConstructors.set('MissingArgumentsError', MissingArgumentsError as unknown as ErrorConstructor); +errorConstructors.set('OptionNeededError', OptionNeededError as unknown as ErrorConstructor); +errorConstructors.set('BadOptionError', BadOptionError as unknown as ErrorConstructor); +errorConstructors.set('UnexpectedError', UnexpectedError as ErrorConstructor); +errorConstructors.set('MigrationHistoryError', MigrationHistoryError as unknown as ErrorConstructor); +errorConstructors.set('MigrationLoadError', MigrationLoadError as unknown as ErrorConstructor); +errorConstructors.set('MigrationRunError', MigrationRunError as unknown as ErrorConstructor); +errorConstructors.set('MigrationNotRunError', MigrationNotRunError as unknown as ErrorConstructor); +errorConstructors.set('StorageInitError', StorageInitError as unknown as ErrorConstructor); diff --git a/packages/cli/src/get-migration.ts b/packages/cli/src/get-migration.ts index bdb801d..3f297f2 100644 --- a/packages/cli/src/get-migration.ts +++ b/packages/cli/src/get-migration.ts @@ -1,6 +1,6 @@ import path from 'node:path'; import fs from 'node:fs/promises'; -import { type MigrationMetadata } from '@emigrate/plugin-tools/types'; +import { type MigrationMetadata } from '@emigrate/types'; import { withLeadingPeriod } from './with-leading-period.js'; import { OptionNeededError } from './errors.js'; @@ -12,7 +12,7 @@ const checkMigrationFile = async (name: string, filePath: string) => { throw new Error('Not a file'); } } catch { - throw new OptionNeededError( + throw OptionNeededError.fromOption( 'force', `The given migration name "${name}" does not exist or is not a file. Use the "force" option to ignore this error`, ); diff --git a/packages/cli/src/get-migrations.ts b/packages/cli/src/get-migrations.ts index 0da9f6b..735fc49 100644 --- a/packages/cli/src/get-migrations.ts +++ b/packages/cli/src/get-migrations.ts @@ -1,20 +1,32 @@ import path from 'node:path'; import fs from 'node:fs/promises'; -import { type MigrationMetadata } from '@emigrate/plugin-tools/types'; +import { type Dirent } from 'node:fs'; +import { type MigrationMetadata } from '@emigrate/types'; import { withLeadingPeriod } from './with-leading-period.js'; +import { BadOptionError } from './errors.js'; export type GetMigrationsFunction = typeof getMigrations; +const tryReadDirectory = async (directoryPath: string): Promise => { + try { + return await fs.readdir(directoryPath, { + withFileTypes: true, + }); + } catch { + throw BadOptionError.fromOption('directory', `Couldn't read directory: ${directoryPath}`); + } +}; + export const getMigrations = async (cwd: string, directory: string): Promise => { - const allFilesInMigrationDirectory = await fs.readdir(path.resolve(cwd, directory), { - withFileTypes: true, - }); + const directoryPath = path.resolve(cwd, directory); + + const allFilesInMigrationDirectory = await tryReadDirectory(directoryPath); 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); + const filePath = path.join(directoryPath, name); return { name, diff --git a/packages/cli/src/get-package-info.ts b/packages/cli/src/get-package-info.ts index 9e5a801..7adc08a 100644 --- a/packages/cli/src/get-package-info.ts +++ b/packages/cli/src/get-package-info.ts @@ -1,5 +1,6 @@ import fs from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; +import { UnexpectedError } from './errors.js'; type PackageInfo = { version: string; @@ -24,7 +25,7 @@ const getPackageInfo = async () => { // ignore } - throw new Error(`Could not read package info from: ${packageInfoPath}`); + throw new UnexpectedError(`Could not read package info from: ${packageInfoPath}`); }; export const { version } = await getPackageInfo(); diff --git a/packages/cli/src/migration-runner.ts b/packages/cli/src/migration-runner.ts index 7972854..a8914e2 100644 --- a/packages/cli/src/migration-runner.ts +++ b/packages/cli/src/migration-runner.ts @@ -1,12 +1,15 @@ import process from 'node:process'; import { isFinishedMigration, + isFailedMigration, type EmigrateReporter, type MigrationMetadata, type MigrationMetadataFinished, type Storage, -} from '@emigrate/plugin-tools/types'; -import { toError, EmigrateError, MigrationRunError } from './errors.js'; + type FailedMigrationMetadata, + type SuccessfulMigrationMetadata, +} from '@emigrate/types'; +import { toError, EmigrateError, MigrationRunError, toSerializedError } from './errors.js'; import { exec } from './exec.js'; import { getDuration } from './get-duration.js'; @@ -43,7 +46,6 @@ export const migrationRunner = async ({ finishedMigrations.push({ ...migration, status: dry ? 'pending' : 'skipped', - duration: 0, }); } else { try { @@ -51,7 +53,7 @@ export const migrationRunner = async ({ migrationsToRun.push(migration); } catch (error) { for await (const migration of migrationsToRun) { - finishedMigrations.push({ ...migration, status: 'skipped', duration: 0 }); + finishedMigrations.push({ ...migration, status: 'skipped' }); } migrationsToRun.length = 0; @@ -72,7 +74,7 @@ export const migrationRunner = async ({ if (lockError) { for await (const migration of migrationsToRun) { - finishedMigrations.push({ ...migration, duration: 0, status: 'skipped' }); + finishedMigrations.push({ ...migration, status: 'skipped' }); } migrationsToRun.length = 0; @@ -85,7 +87,7 @@ export const migrationRunner = async ({ for await (const finishedMigration of finishedMigrations) { switch (finishedMigration.status) { case 'failed': { - await reporter.onMigrationError?.(finishedMigration, finishedMigration.error!); + await reporter.onMigrationError?.(finishedMigration, finishedMigration.error); break; } @@ -111,7 +113,6 @@ export const migrationRunner = async ({ const finishedMigration: MigrationMetadataFinished = { ...migration, status: dry ? 'pending' : 'skipped', - duration: 0, }; await reporter.onMigrationSkip?.(finishedMigration); @@ -127,39 +128,43 @@ export const migrationRunner = async ({ const [, migrationError] = await exec(async () => execute(migration)); const duration = getDuration(start); - const finishedMigration: MigrationMetadataFinished = { - ...migration, - status: migrationError ? 'failed' : 'done', - duration, - error: migrationError, - }; - finishedMigrations.push(finishedMigration); if (migrationError) { - await storage.onError(finishedMigration, migrationError); + const finishedMigration: FailedMigrationMetadata = { + ...migration, + status: 'failed', + duration, + error: migrationError, + }; + await storage.onError(finishedMigration, toSerializedError(migrationError)); await reporter.onMigrationError?.(finishedMigration, migrationError); + finishedMigrations.push(finishedMigration); skip = true; } else { + const finishedMigration: SuccessfulMigrationMetadata = { + ...migration, + status: 'done', + duration, + }; await storage.onSuccess(finishedMigration); await reporter.onMigrationSuccess?.(finishedMigration); + finishedMigrations.push(finishedMigration); } } const [, unlockError] = dry ? [] : await exec(async () => storage.unlock(lockedMigrations ?? [])); - const firstFailed = finishedMigrations.find((migration) => migration.status === 'failed'); + // eslint-disable-next-line unicorn/no-array-callback-reference + const firstFailed = finishedMigrations.find(isFailedMigration); const firstError = firstFailed?.error instanceof EmigrateError ? firstFailed.error : firstFailed - ? new MigrationRunError(`Failed to run migration: ${firstFailed.relativeFilePath}`, firstFailed, { - cause: firstFailed?.error, - }) + ? MigrationRunError.fromMetadata(firstFailed) : undefined; const error = unlockError ?? firstError ?? lockError; await reporter.onFinished?.(finishedMigrations, error); - await storage.end(); return error; }; diff --git a/packages/cli/src/plugin-loader-js.ts b/packages/cli/src/plugin-loader-js.ts index 45cff1c..3a502ac 100644 --- a/packages/cli/src/plugin-loader-js.ts +++ b/packages/cli/src/plugin-loader-js.ts @@ -1,5 +1,5 @@ import { promisify } from 'node:util'; -import { type LoaderPlugin } from '@emigrate/plugin-tools/types'; +import { type LoaderPlugin } from '@emigrate/types'; // eslint-disable-next-line @typescript-eslint/ban-types const promisifyIfNeeded = (fn: T) => { diff --git a/packages/cli/src/reporters/default.ts b/packages/cli/src/reporters/default.ts index 67104e0..b290612 100644 --- a/packages/cli/src/reporters/default.ts +++ b/packages/cli/src/reporters/default.ts @@ -10,8 +10,7 @@ import { type EmigrateReporter, type ReporterInitParameters, type Awaitable, -} from '@emigrate/plugin-tools/types'; -import { EmigrateError } from '../errors.js'; +} from '@emigrate/types'; type Status = ReturnType; @@ -147,15 +146,14 @@ const getError = (error?: ErrorLike, indent = ' ') => { others[property] = error[property as keyof ErrorLike]; } - const codeString = typeof others['code'] === 'string' ? others['code'] : undefined; + const codeString = + typeof others['code'] === 'string' || typeof others['code'] === 'number' ? others['code'] : undefined; const code = codeString ? ` [${codeString}]` : ''; - const errorTitle = error.name - ? `${error.name}${codeString && !error.name.includes(codeString) ? code : ''}: ${error.message}` - : error.message; + const errorTitle = error.name ? `${error.name}${code}: ${error.message}` : error.message; const parts = [`${indent}${bold.red(errorTitle)}`, ...stack.map((line) => `${indent} ${dim(line.trim())}`)]; - if (properties.length > 0 && !(error instanceof EmigrateError)) { + if (properties.length > 0) { parts.push(`${indent} ${JSON.stringify(others, undefined, 2).split('\n').join(`\n${indent} `)}`); } diff --git a/packages/cli/src/to-migration-metadata.ts b/packages/cli/src/to-migration-metadata.ts index 5075a89..9c3f196 100644 --- a/packages/cli/src/to-migration-metadata.ts +++ b/packages/cli/src/to-migration-metadata.ts @@ -1,5 +1,5 @@ import path from 'node:path'; -import { type MigrationHistoryEntry, type MigrationMetadataFinished } from '@emigrate/plugin-tools/types'; +import { type MigrationHistoryEntry, type MigrationMetadataFinished } from '@emigrate/types'; import { withLeadingPeriod } from './with-leading-period.js'; import { MigrationHistoryError } from './errors.js'; @@ -8,7 +8,22 @@ export const toMigrationMetadata = ( { cwd, directory }: { cwd: string; directory: string }, ): MigrationMetadataFinished => { const filePath = path.resolve(cwd, directory, entry.name); - const finishedMigration: MigrationMetadataFinished = { + + if (entry.status === 'failed') { + return { + name: entry.name, + status: entry.status, + filePath, + relativeFilePath: path.relative(cwd, filePath), + extension: withLeadingPeriod(path.extname(entry.name)), + directory, + cwd, + duration: 0, + error: MigrationHistoryError.fromHistoryEntry(entry), + }; + } + + return { name: entry.name, status: entry.status, filePath, @@ -18,13 +33,4 @@ export const toMigrationMetadata = ( cwd, duration: 0, }; - - if (entry.status === 'failed') { - finishedMigration.error = new MigrationHistoryError( - `Migration ${entry.name} is in a failed state, it should be fixed and removed`, - entry, - ); - } - - return finishedMigration; }; diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index 1cb58db..2880bd8 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -1,4 +1,4 @@ -import { type EmigrateStorage, type Awaitable, type Plugin, type EmigrateReporter } from '@emigrate/plugin-tools/types'; +import { type EmigrateStorage, type Awaitable, type Plugin, type EmigrateReporter } from '@emigrate/types'; export type EmigratePlugin = Plugin; diff --git a/packages/mysql/package.json b/packages/mysql/package.json index b36d282..8e13e7e 100644 --- a/packages/mysql/package.json +++ b/packages/mysql/package.json @@ -38,6 +38,7 @@ "license": "MIT", "dependencies": { "@emigrate/plugin-tools": "workspace:*", + "@emigrate/types": "workspace:*", "mysql2": "3.6.5" }, "devDependencies": { diff --git a/packages/mysql/src/index.ts b/packages/mysql/src/index.ts index 82b1c96..46a1f55 100644 --- a/packages/mysql/src/index.ts +++ b/packages/mysql/src/index.ts @@ -10,6 +10,7 @@ import { type ResultSetHeader, type RowDataPacket, } from 'mysql2/promise'; +import { getTimestampPrefix, sanitizeMigrationName } from '@emigrate/plugin-tools'; import { type MigrationMetadata, type EmigrateStorage, @@ -19,8 +20,8 @@ import { type MigrationMetadataFinished, type GenerateMigrationFunction, type GeneratorPlugin, -} from '@emigrate/plugin-tools/types'; -import { getTimestampPrefix, sanitizeMigrationName, serializeError } from '@emigrate/plugin-tools'; + type SerializedError, +} from '@emigrate/types'; const defaultTable = 'migrations'; @@ -87,7 +88,7 @@ type HistoryEntry = { name: string; status: MigrationStatus; date: Date; - error?: unknown; + error?: SerializedError; }; const lockMigration = async (pool: Pool, table: string, migration: MigrationMetadata) => { @@ -117,7 +118,12 @@ const unlockMigration = async (pool: Pool, table: string, migration: MigrationMe return result.affectedRows === 1; }; -const finishMigration = async (pool: Pool, table: string, migration: MigrationMetadataFinished) => { +const finishMigration = async ( + pool: Pool, + table: string, + migration: MigrationMetadataFinished, + _error?: SerializedError, +) => { const [result] = await pool.execute({ sql: ` UPDATE @@ -208,12 +214,20 @@ export const createMysqlStorage = ({ table = defaultTable, connection }: MysqlSt }); for (const row of rows) { + if (row.status === 'failed') { + yield { + name: row.name, + status: row.status, + date: new Date(row.date), + error: row.error ?? { name: 'Error', message: 'Unknown error' }, + }; + continue; + } + yield { name: row.name, status: row.status, date: new Date(row.date), - // FIXME: Migrate the migrations table to support the error column - error: row.status === 'failed' ? serializeError(new Error('Unknown error reason')) : undefined, }; } }, @@ -221,7 +235,7 @@ export const createMysqlStorage = ({ table = defaultTable, connection }: MysqlSt await finishMigration(pool, table, migration); }, async onError(migration, error) { - await finishMigration(pool, table, { ...migration, status: 'failed', error }); + await finishMigration(pool, table, migration, error); }, async end() { await pool.end(); diff --git a/packages/plugin-generate-js/package.json b/packages/plugin-generate-js/package.json index 2006266..485d9ac 100644 --- a/packages/plugin-generate-js/package.json +++ b/packages/plugin-generate-js/package.json @@ -35,7 +35,8 @@ "bugs": "https://github.com/aboviq/emigrate/issues", "license": "MIT", "dependencies": { - "@emigrate/plugin-tools": "workspace:*" + "@emigrate/plugin-tools": "workspace:*", + "@emigrate/types": "workspace:*" }, "devDependencies": { "@emigrate/tsconfig": "workspace:*" diff --git a/packages/plugin-generate-js/src/index.ts b/packages/plugin-generate-js/src/index.ts index 6d4dec3..c262931 100644 --- a/packages/plugin-generate-js/src/index.ts +++ b/packages/plugin-generate-js/src/index.ts @@ -1,5 +1,5 @@ import { getTimestampPrefix, sanitizeMigrationName } from '@emigrate/plugin-tools'; -import { type GenerateMigrationFunction } from '@emigrate/plugin-tools/types'; +import { type GenerateMigrationFunction } from '@emigrate/types'; export const generateMigration: GenerateMigrationFunction = async (name) => { return { diff --git a/packages/plugin-tools/package.json b/packages/plugin-tools/package.json index 606a58c..e4e922d 100644 --- a/packages/plugin-tools/package.json +++ b/packages/plugin-tools/package.json @@ -12,10 +12,6 @@ ".": { "import": "./dist/index.js", "types": "./dist/index.d.ts" - }, - "./types": { - "import": "./dist/types.js", - "types": "./dist/types.d.ts" } }, "files": [ @@ -43,6 +39,7 @@ "@emigrate/tsconfig": "workspace:*" }, "dependencies": { + "@emigrate/types": "workspace:*", "import-from-esm": "1.3.3" }, "volta": { diff --git a/packages/plugin-tools/src/index.ts b/packages/plugin-tools/src/index.ts index ce08988..033fabb 100644 --- a/packages/plugin-tools/src/index.ts +++ b/packages/plugin-tools/src/index.ts @@ -7,21 +7,7 @@ import { type EmigrateStorage, type LoaderPlugin, type StringOrModule, - type SerializedError, -} from './types.js'; - -export const serializeError = (error: Error): SerializedError => { - const properties: Record = { - name: error.name, - }; - - for (const key of Object.getOwnPropertyNames(error)) { - const value = error[key as keyof Error]; - properties[key] = value instanceof Error ? serializeError(value) : value; - } - - return properties as SerializedError; -}; +} from '@emigrate/types'; export const isGeneratorPlugin = (plugin: any): plugin is GeneratorPlugin => { if (!plugin || typeof plugin !== 'object') { diff --git a/packages/reporter-pino/package.json b/packages/reporter-pino/package.json index ed8fe67..bbc40ae 100644 --- a/packages/reporter-pino/package.json +++ b/packages/reporter-pino/package.json @@ -35,7 +35,7 @@ "bugs": "https://github.com/aboviq/emigrate/issues", "license": "MIT", "dependencies": { - "@emigrate/plugin-tools": "workspace:*", + "@emigrate/types": "workspace:*", "pino": "8.16.2" }, "devDependencies": { diff --git a/packages/reporter-pino/src/index.ts b/packages/reporter-pino/src/index.ts index ee259d3..65e8a10 100644 --- a/packages/reporter-pino/src/index.ts +++ b/packages/reporter-pino/src/index.ts @@ -6,7 +6,7 @@ import { type MigrationMetadataFinished, type ReporterInitParameters, type EmigrateReporter, -} from '@emigrate/plugin-tools/types'; +} from '@emigrate/types'; type PinoReporterOptions = { level?: string; diff --git a/packages/storage-fs/package.json b/packages/storage-fs/package.json index 51bd6dd..3fe27a3 100644 --- a/packages/storage-fs/package.json +++ b/packages/storage-fs/package.json @@ -35,7 +35,7 @@ "bugs": "https://github.com/aboviq/emigrate/issues", "license": "MIT", "dependencies": { - "@emigrate/plugin-tools": "workspace:*" + "@emigrate/types": "workspace:*" }, "devDependencies": { "@emigrate/tsconfig": "workspace:*" diff --git a/packages/storage-fs/src/index.ts b/packages/storage-fs/src/index.ts index 1c643ec..85e3acb 100644 --- a/packages/storage-fs/src/index.ts +++ b/packages/storage-fs/src/index.ts @@ -1,8 +1,7 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import process from 'node:process'; -import { serializeError } from '@emigrate/plugin-tools'; -import { type SerializedError, type EmigrateStorage, type MigrationStatus } from '@emigrate/plugin-tools/types'; +import { type SerializedError, type EmigrateStorage, type MigrationStatus } from '@emigrate/types'; export type StorageFsOptions = { filename: string; @@ -27,7 +26,7 @@ export default function storageFs({ filename }: StorageFsOptions): EmigrateStora let lastUpdate: Promise = Promise.resolve(); - const update = async (migration: string, status: MigrationStatus, error?: Error) => { + const update = async (migration: string, status: MigrationStatus, error?: SerializedError) => { lastUpdate = lastUpdate.then(async () => { const history = await read(); @@ -36,7 +35,7 @@ export default function storageFs({ filename }: StorageFsOptions): EmigrateStora [migration]: { status, date: new Date().toISOString(), - error: error ? serializeError(error) : undefined, + error, }, }; @@ -99,11 +98,20 @@ export default function storageFs({ filename }: StorageFsOptions): EmigrateStora const history = await read(); for (const [name, { status, date, error }] of Object.entries(history)) { + if (status === 'failed') { + yield { + name, + status, + date: new Date(date), + error: error ?? { name: 'Error', message: 'Unknown error' }, + }; + continue; + } + yield { name, status, date: new Date(date), - error, }; } }, diff --git a/packages/types/CHANGELOG.md b/packages/types/CHANGELOG.md new file mode 100644 index 0000000..dba78ad --- /dev/null +++ b/packages/types/CHANGELOG.md @@ -0,0 +1,82 @@ +# @emigrate/types + +## 0.7.0 + +### Minor Changes + +- bad4e25: Pass the Emigrate CLI's version number to reporters + +## 0.6.0 + +### Minor Changes + +- a79f8e8: When serializing errors take all "own properties" into account to be able to serialize errors thrown by the `mysql2` package for instance without losing any information + +### Patch Changes + +- a79f8e8: Serialization of errors now happens inside storage plugins because it makes more sense and the types are easier to work with this way + +## 0.5.0 + +### Minor Changes + +- 703e6f0: Add "end" method to storage plugins so they can cleanup resources when a command is finished +- c1d5597: Add serializeError utility function for serializing Error instances + +## 0.4.1 + +### Patch Changes + +- 20ed2e8: Try importing plugins (and reporters) using prefixes before importing without, this is to avoid issue with accidentaly importing other non-emigrate related packages. E.g. setting the reporter to "pino" would import the "pino" package without this fix and will import "@emigrate/reporter-pino" with this fix. +- d916043: Fix a regression issue where plugins wasn't correctly loaded if specified as strings + +## 0.4.0 + +### Minor Changes + +- 5e8572b: Pass the current command to the reporter +- 8e87ade: Move storages and reporters out from the plugin option into their own separate options (i.e. "--reporter" and "--storage" respectively). This makes it easier to change the interfaces of storages and reporters, and it's also more similar to other tools. +- 672fae1: Include "@emigrate/" in the plugin prefix list, i.e. when searching for the plugin "blaha" it will look for the packages "blaha", "@emigrate/blaha", "@emigrate/plugin-blaha" and "emigrate-plugin-blaha" and use the first of them that exists +- d8a6a24: Implement the "remove" command for removing migration entries from the history + +### Patch Changes + +- 60ae3b8: Fix loading of lazy loaded plugins with default exports +- acb0b4f: Keep upper cased letters in migration file names by default + +## 0.3.0 + +### Minor Changes + +- 8f35812: Add support for "reporter" plugins and implement a simple default reporter + +## 0.2.0 + +### Minor Changes + +- 23a323c: Add the convenience functions `getOrLoadPlugin` and `getOrLoadPlugins` +- 62bd5a4: Add more properties to the MigrationMetadata type to ease writing "loader" plugins +- 81fde2e: Prepare for supporting "loader" plugins. A loader plugin is used to transform a migration file of a given type (file extension) to a function that will execute the actual migration. +- 9f5abf7: Simplify plugin interfaces by getting rid of the "type" string, in preparation for having packages that contains multiple different plugins + +### Patch Changes + +- 1799b6e: Add missing types and utility methods related to the new "loader" plugins +- 3e0ff07: Specify files to include in published NPM package + +## 0.1.1 + +### Patch Changes + +- 50fce0a: Add some simple README's for each package and add homepage, repository and bugs URLs to each package.json file + +## 0.1.0 + +### Minor Changes + +- cdafd05: First version of the @emigrate/plugin-tools package which contains some nice to have utilities when building and using Emigrate plugins +- 9c239e0: Use import-from-esm to resolve plugins relative to the current working directory and add a convenient plugin loader helper (loadPlugin) + +### Patch Changes + +- 1634094: Remove double and trailing underscores in sanitized filenames and lower case the result for consistent filenames diff --git a/packages/types/README.md b/packages/types/README.md new file mode 100644 index 0000000..a054f6c --- /dev/null +++ b/packages/types/README.md @@ -0,0 +1,3 @@ +# @emigrate/plugin-tools + +This package contains utilities and types to simplify creating and working with Emigrate plugins. diff --git a/packages/types/package.json b/packages/types/package.json new file mode 100644 index 0000000..343a2d3 --- /dev/null +++ b/packages/types/package.json @@ -0,0 +1,42 @@ +{ + "name": "@emigrate/types", + "version": "0.7.0", + "publishConfig": { + "access": "public" + }, + "description": "Common Emigrate TypeScript types to ease plugin development.", + "main": "dist/index.js", + "types": "dist/index.d.js", + "type": "module", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc --pretty", + "build:watch": "tsc --pretty --watch", + "lint": "xo --cwd=../.. $(pwd)" + }, + "keywords": [ + "emigrate", + "typescript", + "migrations", + "types" + ], + "author": "Aboviq AB (https://www.aboviq.com)", + "homepage": "https://github.com/aboviq/emigrate/tree/main/packages/types#readme", + "repository": "https://github.com/aboviq/emigrate/tree/main/packages/types", + "bugs": "https://github.com/aboviq/emigrate/issues", + "license": "MIT", + "devDependencies": { + "@emigrate/tsconfig": "workspace:*" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/packages/plugin-tools/src/types.ts b/packages/types/src/index.ts similarity index 85% rename from packages/plugin-tools/src/types.ts rename to packages/types/src/index.ts index 080d649..a062519 100644 --- a/packages/plugin-tools/src/types.ts +++ b/packages/types/src/index.ts @@ -4,21 +4,23 @@ export type StringOrModule = string | T | (() => Awaitable) | (() => Await export type MigrationStatus = 'failed' | 'done' | 'pending'; -export type SerializedError = { - [key: string]: unknown; - name?: string; - message: string; - stack?: string; - cause?: unknown; +export type SerializedError = Record; + +export type FailedMigrationHistoryEntry = { + name: string; + status: 'failed'; + date: Date; + error: SerializedError; }; -export type MigrationHistoryEntry = { +export type NonFailedMigrationHistoryEntry = { name: string; - status: MigrationStatus; + status: Exclude; date: Date; - error?: SerializedError; }; +export type MigrationHistoryEntry = FailedMigrationHistoryEntry | NonFailedMigrationHistoryEntry; + export type Storage = { /** * Acquire a lock on the given migrations. @@ -71,10 +73,13 @@ export type Storage = { /** * Called when a migration has failed. * + * The passed error will be serialized so it's easily storable it in the history. + * If the original Error instance is needed it's available as the `error` property on the finished migration. + * * @param migration The name of the migration that should be marked as failed. - * @param error The error that caused the migration to fail. + * @param error The error that caused the migration to fail. Serialized for easy storage. */ - onError(migration: MigrationMetadataFinished, error: Error): Promise; + onError(migration: MigrationMetadataFinished, error: SerializedError): Promise; /** * Called when the command is finished or aborted (e.g. by a SIGTERM or SIGINT signal). * @@ -153,18 +158,38 @@ export type MigrationMetadata = { extension: string; }; -export type MigrationMetadataFinished = MigrationMetadata & { - status: MigrationStatus | 'skipped'; +export type FailedMigrationMetadata = MigrationMetadata & { + status: 'failed'; duration: number; - error?: Error; + error: Error; }; +export type SkippedMigrationMetadata = MigrationMetadata & { + status: 'skipped' | 'pending'; +}; + +export type SuccessfulMigrationMetadata = MigrationMetadata & { + status: 'done'; + duration: number; +}; + +export type MigrationMetadataFinished = + | FailedMigrationMetadata + | SkippedMigrationMetadata + | SuccessfulMigrationMetadata; + export const isFinishedMigration = ( migration: MigrationMetadata | MigrationMetadataFinished, ): migration is MigrationMetadataFinished => { return 'status' in migration; }; +export const isFailedMigration = ( + migration: MigrationMetadata | MigrationMetadataFinished, +): migration is FailedMigrationMetadata => { + return 'status' in migration && migration.status === 'failed'; +}; + export type LoaderPlugin = { /** * The file extensions that this plugin can load. @@ -243,13 +268,13 @@ export type EmigrateReporter = Partial<{ * * This is only called when the command is 'remove'. */ - onMigrationRemoveSuccess(migration: MigrationMetadataFinished): Awaitable; + onMigrationRemoveSuccess(migration: SuccessfulMigrationMetadata): Awaitable; /** * Called when a migration couldn't be removed from the migration history. * * This is only called when the command is 'remove'. */ - onMigrationRemoveError(migration: MigrationMetadataFinished, error: Error): Awaitable; + onMigrationRemoveError(migration: FailedMigrationMetadata, error: Error): Awaitable; /** * Called when a migration is about to be executed. * @@ -266,7 +291,7 @@ export type EmigrateReporter = Partial<{ * * @param migration Information about the migration that was executed. */ - onMigrationSuccess(migration: MigrationMetadataFinished): Awaitable; + onMigrationSuccess(migration: SuccessfulMigrationMetadata): Awaitable; /** * Called when a migration has failed. * @@ -276,7 +301,7 @@ export type EmigrateReporter = Partial<{ * @param migration Information about the migration that failed. * @param error The error that caused the migration to fail. */ - onMigrationError(migration: MigrationMetadataFinished, error: Error): Awaitable; + onMigrationError(migration: FailedMigrationMetadata, error: Error): Awaitable; /** * Called when a migration is skipped * @@ -288,7 +313,7 @@ export type EmigrateReporter = Partial<{ * * @param migration Information about the migration that was skipped. */ - onMigrationSkip(migration: MigrationMetadataFinished): Awaitable; + onMigrationSkip(migration: SkippedMigrationMetadata): Awaitable; /** * Called as a final step after all migrations have been executed, listed or removed. * diff --git a/packages/types/tsconfig.json b/packages/types/tsconfig.json new file mode 100644 index 0000000..1cfcebb --- /dev/null +++ b/packages/types/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@emigrate/tsconfig/build.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9e01a46..b4875ba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,6 +56,9 @@ importers: '@emigrate/plugin-tools': specifier: workspace:* version: link:../plugin-tools + '@emigrate/types': + specifier: workspace:* + version: link:../types ansis: specifier: 2.0.2 version: 2.0.2 @@ -77,6 +80,9 @@ importers: pretty-ms: specifier: 8.0.0 version: 8.0.0 + serialize-error: + specifier: 11.0.3 + version: 11.0.3 devDependencies: '@emigrate/tsconfig': specifier: workspace:* @@ -87,6 +93,9 @@ importers: '@emigrate/plugin-tools': specifier: workspace:* version: link:../plugin-tools + '@emigrate/types': + specifier: workspace:* + version: link:../types mysql2: specifier: 3.6.5 version: 3.6.5 @@ -100,6 +109,9 @@ importers: '@emigrate/plugin-tools': specifier: workspace:* version: link:../plugin-tools + '@emigrate/types': + specifier: workspace:* + version: link:../types devDependencies: '@emigrate/tsconfig': specifier: workspace:* @@ -107,6 +119,9 @@ importers: packages/plugin-tools: dependencies: + '@emigrate/types': + specifier: workspace:* + version: link:../types import-from-esm: specifier: 1.3.3 version: 1.3.3 @@ -117,9 +132,9 @@ importers: packages/reporter-pino: dependencies: - '@emigrate/plugin-tools': + '@emigrate/types': specifier: workspace:* - version: link:../plugin-tools + version: link:../types pino: specifier: 8.16.2 version: 8.16.2 @@ -130,9 +145,9 @@ importers: packages/storage-fs: dependencies: - '@emigrate/plugin-tools': + '@emigrate/types': specifier: workspace:* - version: link:../plugin-tools + version: link:../types devDependencies: '@emigrate/tsconfig': specifier: workspace:* @@ -140,6 +155,12 @@ importers: packages/tsconfig: {} + packages/types: + devDependencies: + '@emigrate/tsconfig': + specifier: workspace:* + version: link:../tsconfig + packages: /@aashutoshrathi/word-wrap@1.2.6: @@ -4919,6 +4940,13 @@ packages: resolution: {integrity: sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==} dev: false + /serialize-error@11.0.3: + resolution: {integrity: sha512-2G2y++21dhj2R7iHAdd0FIzjGwuKZld+7Pl/bTU6YIkrC2ZMbVUjm+luj6A6V34Rv9XfKJDKpTWu9W4Gse1D9g==} + engines: {node: '>=14.16'} + dependencies: + type-fest: 2.19.0 + dev: false + /serialize-javascript@6.0.1: resolution: {integrity: sha512-owoXEFjWRllis8/M1Q+Cw5k8ZH40e3zhp/ovX+Xr/vi1qj6QesbyXXViFbpNvWvPNAD62SutwEXavefrLJWj7w==} dependencies: