diff --git a/.changeset/gold-eggs-argue.md b/.changeset/gold-eggs-argue.md new file mode 100644 index 0000000..6fd0b45 --- /dev/null +++ b/.changeset/gold-eggs-argue.md @@ -0,0 +1,6 @@ +--- +'@emigrate/plugin-tools': minor +'@emigrate/cli': minor +--- + +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. diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index d5c5b9d..a0ead20 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,4 +1,4 @@ -# emigrate +# @emigrate/cli ## 0.4.0 diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index cf3efb1..c7a25bc 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -19,6 +19,14 @@ const up: Action = async (args) => { type: 'string', short: 'd', }, + reporter: { + type: 'string', + short: 'r', + }, + storage: { + type: 'string', + short: 's', + }, dry: { type: 'boolean', }, @@ -40,14 +48,16 @@ Options: -h, --help Show this help message and exit -d, --directory The directory where the migration files are located (required) + -s, --storage The storage to use for where to store the migration history (required) -p, --plugin The plugin(s) to use (can be specified multiple times) + -r, --reporter The reporter to use for reporting the migration progress --dry List the pending migrations that would be run without actually running them Examples: - emigrate up --directory src/migrations - emigrate up -d ./migrations --plugin @emigrate/plugin-storage-mysql - emigrate up -d src/migrations --dry + emigrate up --directory src/migrations -s fs + emigrate up -d ./migrations --storage @emigrate/storage-mysql + emigrate up -d src/migrations -s postgres -r json --dry `; if (values.help) { @@ -56,12 +66,12 @@ Examples: return; } - const { directory = config.directory, dry } = values; + const { directory = config.directory, storage = config.storage, reporter = config.reporter, dry } = values; const plugins = [...(config.plugins ?? []), ...(values.plugin ?? [])]; try { const { default: upCommand } = await import('./up-command.js'); - await upCommand({ directory, plugins, dry }); + await upCommand({ storage, reporter, directory, plugins, dry }); } catch (error) { if (error instanceof ShowUsageError) { console.error(error.message, '\n'); diff --git a/packages/cli/src/get-config.ts b/packages/cli/src/get-config.ts index 85d26b2..b0bfafe 100644 --- a/packages/cli/src/get-config.ts +++ b/packages/cli/src/get-config.ts @@ -1,7 +1,10 @@ import { cosmiconfig } from 'cosmiconfig'; import { type Config, type EmigrateConfig } from './types.js'; -export const getConfig = async (command: 'up' | 'list' | 'new'): Promise => { +const commands = ['up', 'list', 'new'] as const; +type Command = (typeof commands)[number]; + +export const getConfig = async (command: Command): Promise => { const explorer = cosmiconfig('emigrate'); const result = await explorer.search(); @@ -10,11 +13,14 @@ export const getConfig = async (command: 'up' | 'list' | 'new'): Promise return {}; } - const { plugins, directory, template, ...commandsConfig } = result.config as EmigrateConfig; + const config = result.config as EmigrateConfig; - if (commandsConfig[command]) { - return { plugins, directory, template, ...commandsConfig[command] }; + const commandConfig = config[command]; + + for (const command of commands) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete config[command]; } - return { plugins, directory, template }; + return { ...config, ...commandConfig }; }; diff --git a/packages/cli/src/plugin-reporter-default.ts b/packages/cli/src/plugin-reporter-default.ts index 844d843..49d0a6d 100644 --- a/packages/cli/src/plugin-reporter-default.ts +++ b/packages/cli/src/plugin-reporter-default.ts @@ -8,7 +8,7 @@ import prettyMs from 'pretty-ms'; import { type MigrationMetadata, type MigrationMetadataFinished, - type ReporterPlugin, + type EmigrateReporter, } from '@emigrate/plugin-tools/types'; type Status = ReturnType; @@ -202,7 +202,7 @@ const getHeaderMessage = (migrations?: MigrationMetadata[], lockedMigrations?: M )} ${ansis.yellow(`(${migrations.length - lockedMigrations.length} locked)`)}`; }; -class DefaultFancyReporter implements Required { +class DefaultFancyReporter implements Required { #migrations: Array | undefined; #lockedMigrations: MigrationMetadata[] | undefined; #activeMigration: MigrationMetadata | undefined; @@ -290,7 +290,7 @@ class DefaultFancyReporter implements Required { } } -class DefaultReporter implements Required { +class DefaultReporter implements Required { #migrations?: MigrationMetadata[]; #lockedMigrations?: MigrationMetadata[]; diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index 65836b8..27daea4 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -1,9 +1,13 @@ -import { type Plugin } from '@emigrate/plugin-tools/types'; +import { type EmigrateStorage, type Awaitable, type Plugin, type EmigrateReporter } from '@emigrate/plugin-tools/types'; export type EmigratePlugin = Plugin; +type StringOrModule = string | T | (() => Awaitable) | (() => Awaitable<{ default: T }>); + export type Config = { - plugins?: Array; + storage?: StringOrModule; + reporter?: StringOrModule; + plugins?: Array>; directory?: string; template?: string; extension?: string; diff --git a/packages/cli/src/up-command.ts b/packages/cli/src/up-command.ts index 04c6b8d..ae711e4 100644 --- a/packages/cli/src/up-command.ts +++ b/packages/cli/src/up-command.ts @@ -1,11 +1,8 @@ import process from 'node:process'; -import { getOrLoadPlugin, getOrLoadPlugins } from '@emigrate/plugin-tools'; +import { getOrLoadPlugins, getOrLoadReporter, getOrLoadStorage } from '@emigrate/plugin-tools'; import { type LoaderPlugin, type MigrationFunction, - type Plugin, - type PluginType, - type PluginFromType, type MigrationMetadata, type MigrationMetadataFinished, } from '@emigrate/plugin-tools/types'; @@ -20,42 +17,42 @@ import { import { type Config } from './types.js'; import { withLeadingPeriod } from './with-leading-period.js'; import pluginLoaderJs from './plugin-loader-js.js'; -import pluginReporterDefault from './plugin-reporter-default.js'; type ExtraFlags = { dry?: boolean; }; -const requirePlugin = async ( - type: T, - plugins: Array, -): Promise> => { - const plugin = await getOrLoadPlugin(type, plugins); - - if (!plugin) { - throw new BadOptionError( - 'plugin', - `No ${type} plugin found, please specify a ${type} plugin using the plugin option`, - ); - } - - return plugin; -}; - const getDuration = (start: [number, number]) => { const [seconds, nanoseconds] = process.hrtime(start); return seconds * 1000 + nanoseconds / 1_000_000; }; -export default async function upCommand({ directory, dry = false, plugins = [] }: Config & ExtraFlags) { +const lazyDefaultReporter = async () => import('./plugin-reporter-default.js'); + +export default async function upCommand({ + storage: storageConfig, + reporter: reporterConfig, + directory, + dry = false, + plugins = [], +}: Config & ExtraFlags) { if (!directory) { throw new MissingOptionError('directory'); } const cwd = process.cwd(); - const storagePlugin = await requirePlugin('storage', plugins); + const storagePlugin = await getOrLoadStorage([storageConfig]); + + if (!storagePlugin) { + throw new BadOptionError('storage', 'No storage found, please specify a storage using the storage option'); + } + const storage = await storagePlugin.initializeStorage(); - const reporter = await requirePlugin('reporter', [pluginReporterDefault, ...plugins]); + const reporter = await getOrLoadReporter([lazyDefaultReporter, reporterConfig]); + + if (!reporter) { + throw new BadOptionError('reporter', 'No reporter found, please specify a reporter using the reporter option'); + } await reporter.onInit?.({ cwd, dry, directory }); diff --git a/packages/plugin-tools/src/index.ts b/packages/plugin-tools/src/index.ts index dcec9c0..b0726ea 100644 --- a/packages/plugin-tools/src/index.ts +++ b/packages/plugin-tools/src/index.ts @@ -3,10 +3,10 @@ import { type PluginFromType, type PluginType, type GeneratorPlugin, - type StoragePlugin, - type Plugin, + type EmigrateReporter, + type EmigrateStorage, type LoaderPlugin, - type ReporterPlugin, + type StringOrModule, } from './types.js'; export const isGeneratorPlugin = (plugin: any): plugin is GeneratorPlugin => { @@ -17,7 +17,7 @@ export const isGeneratorPlugin = (plugin: any): plugin is GeneratorPlugin => { return typeof plugin.generateMigration === 'function'; }; -export const isStoragePlugin = (plugin: any): plugin is StoragePlugin => { +export const isEmigrateStorage = (plugin: any): plugin is EmigrateStorage => { if (!plugin || typeof plugin !== 'object') { return false; } @@ -33,7 +33,7 @@ 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 => { +export const isEmigrateReporter = (plugin: any): plugin is EmigrateReporter => { if (!plugin || typeof plugin !== 'object') { return false; } @@ -57,50 +57,44 @@ export const isPluginOfType = (type: T, plugin: any): plug return isGeneratorPlugin(plugin); } - if (type === 'storage') { - return isStoragePlugin(plugin); - } - if (type === 'loader') { return isLoaderPlugin(plugin); } - if (type === 'reporter') { - return isReporterPlugin(plugin); - } - throw new Error(`Unknown plugin type: ${type}`); }; +export const getOrLoadStorage = async ( + potentialStorages: Array>, +): Promise => { + return getOrLoad(potentialStorages, isEmigrateStorage); +}; + +export const getOrLoadReporter = async ( + potentialReporters: Array>, +): Promise => { + return getOrLoad(potentialReporters, isEmigrateReporter); +}; + export const getOrLoadPlugin = async ( type: T, - plugins: Array, + plugins: Array>, ): Promise | undefined> => { - const reversePlugins = [...plugins].reverse(); - - for await (const plugin of reversePlugins) { - if (isPluginOfType(type, plugin)) { - return plugin; - } - - const loadedPlugin = typeof plugin === 'string' ? await loadPlugin(type, plugin) : undefined; - - if (loadedPlugin) { - return loadedPlugin; - } - } - - return undefined; + return getOrLoad(plugins, (value: unknown): value is PluginFromType => isPluginOfType(type, value)); }; export const getOrLoadPlugins = async ( type: T, - plugins: Array, + plugins: Array>, ): Promise>> => { const result: Array> = []; const reversePlugins = [...plugins].reverse(); - for await (const plugin of reversePlugins) { + for await (let plugin of reversePlugins) { + if (typeof plugin === 'function') { + plugin = await plugin(); + } + if (isPluginOfType(type, plugin)) { result.push(plugin); continue; @@ -116,10 +110,28 @@ export const getOrLoadPlugins = async ( return result; }; -export const loadPlugin = async ( - type: T, - plugin: string, -): Promise | undefined> => { +const getOrLoad = async (potentials: Array>, check: (value: unknown) => value is T) => { + const reversed = [...potentials].reverse(); + + for await (let potential of reversed) { + if (typeof potential === 'function') { + potential = await potential(); + } + + if (check(potential)) { + return potential; + } + + // Support export default ... + if (potential && typeof potential === 'object' && 'default' in potential && check(potential.default)) { + return potential.default; + } + } + + return undefined; +}; + +const getImportFromEsm = async () => { let importFromEsm = await import('import-from-esm'); // Because of "allowSyntheticDefaultImports" we need to do this ugly hack @@ -127,27 +139,47 @@ export const loadPlugin = async ( importFromEsm = (importFromEsm as any).default as unknown as typeof importFromEsm; } - const importsToTry = plugin.startsWith('.') - ? [plugin] - : [plugin, `@emigrate/plugin-${plugin}`, `emigrate-plugin-${plugin}`]; + return importFromEsm; +}; + +export const loadStorage = async (name: string): Promise => { + return load(name, ['@emigrate/storage-', 'emigrate-storage-', '@emigrate/plugin-storage-'], isEmigrateStorage); +}; + +export const loadReporter = async (name: string): Promise => { + return load(name, ['@emigrate/reporter-', 'emigrate-reporter-'], isEmigrateReporter); +}; + +export const loadPlugin = async ( + type: T, + plugin: string, +): Promise | undefined> => { + return load(plugin, ['@emigrate/plugin-', 'emigrate-plugin-'], (value: unknown): value is PluginFromType => { + return isPluginOfType(type, value); + }); +}; + +const load = async ( + name: string, + prefixes: string[], + check: (value: unknown) => value is T, +): Promise => { + const importFromEsm = await getImportFromEsm(); + + const importsToTry = name.startsWith('.') ? [name] : [name, ...prefixes.map((prefix) => `${prefix}${name}`)]; for await (const importPath of importsToTry) { try { - const pluginModule: unknown = await importFromEsm(process.cwd(), importPath); + const module: unknown = await importFromEsm(process.cwd(), importPath); // Support module.exports = ... - if (isPluginOfType(type, pluginModule)) { - return pluginModule; + if (check(module)) { + return module; } // Support export default ... - if ( - pluginModule && - typeof pluginModule === 'object' && - 'default' in pluginModule && - isPluginOfType(type, pluginModule.default) - ) { - return pluginModule.default; + if (module && typeof module === 'object' && 'default' in module && check(module.default)) { + return module.default; } } catch { // Ignore errors diff --git a/packages/plugin-tools/src/types.ts b/packages/plugin-tools/src/types.ts index 3465bd1..f344f85 100644 --- a/packages/plugin-tools/src/types.ts +++ b/packages/plugin-tools/src/types.ts @@ -1,4 +1,6 @@ -type Awaitable = T | PromiseLike; +export type Awaitable = T | PromiseLike; + +export type StringOrModule = string | T | (() => Awaitable) | (() => Awaitable<{ default: T }>); export type MigrationStatus = 'failed' | 'done'; @@ -58,11 +60,11 @@ export type Storage = { onError(migration: MigrationMetadataFinished, error: Error): Promise; }; -export type StoragePlugin = { +export type EmigrateStorage = { initializeStorage(): Promise; }; -export type InitializeStorageFunction = StoragePlugin['initializeStorage']; +export type InitializeStorageFunction = EmigrateStorage['initializeStorage']; export type MigrationFile = { /** @@ -163,7 +165,7 @@ type InitParameters = { dry: boolean; }; -export type ReporterPlugin = Partial<{ +export type EmigrateReporter = Partial<{ /** * Called when the plugin is initialized, which happens before the migrations are collected. */ @@ -219,13 +221,11 @@ export type ReporterPlugin = Partial<{ onFinished(migrations: MigrationMetadataFinished[], error?: Error): Awaitable; }>; -export type Plugin = StoragePlugin | GeneratorPlugin | LoaderPlugin | ReporterPlugin; +export type Plugin = GeneratorPlugin | LoaderPlugin; type PluginTypeMap = { - storage: StoragePlugin; generator: GeneratorPlugin; loader: LoaderPlugin; - reporter: ReporterPlugin; }; export type PluginType = keyof PluginTypeMap;