feat(cli): storage and reporter are now their own options

Instead of mixing storages and reporters with other plugins in the plugin option they now have their own separate options.
This is for increased future flexibility and to be more similar to other CLI tools.

BREAKING CHANGE: the storage to use must now be specified using the "storage" configuration option or the "--storage" CLI option instead of having it among other plugins.
This commit is contained in:
Joakim Carlstein 2023-11-22 11:24:16 +01:00
parent 509cd41663
commit 8e87ade5c0
9 changed files with 150 additions and 95 deletions

View file

@ -1,4 +1,4 @@
# emigrate
# @emigrate/cli
## 0.4.0

View file

@ -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');

View file

@ -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<Config> => {
const commands = ['up', 'list', 'new'] as const;
type Command = (typeof commands)[number];
export const getConfig = async (command: Command): Promise<Config> => {
const explorer = cosmiconfig('emigrate');
const result = await explorer.search();
@ -10,11 +13,14 @@ export const getConfig = async (command: 'up' | 'list' | 'new'): Promise<Config>
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 };
};

View file

@ -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<typeof getMigrationStatus>;
@ -202,7 +202,7 @@ const getHeaderMessage = (migrations?: MigrationMetadata[], lockedMigrations?: M
)} ${ansis.yellow(`(${migrations.length - lockedMigrations.length} locked)`)}`;
};
class DefaultFancyReporter implements Required<ReporterPlugin> {
class DefaultFancyReporter implements Required<EmigrateReporter> {
#migrations: Array<MigrationMetadata | MigrationMetadataFinished> | undefined;
#lockedMigrations: MigrationMetadata[] | undefined;
#activeMigration: MigrationMetadata | undefined;
@ -290,7 +290,7 @@ class DefaultFancyReporter implements Required<ReporterPlugin> {
}
}
class DefaultReporter implements Required<ReporterPlugin> {
class DefaultReporter implements Required<EmigrateReporter> {
#migrations?: MigrationMetadata[];
#lockedMigrations?: MigrationMetadata[];

View file

@ -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<T> = string | T | (() => Awaitable<T>) | (() => Awaitable<{ default: T }>);
export type Config = {
plugins?: Array<string | EmigratePlugin>;
storage?: StringOrModule<EmigrateStorage>;
reporter?: StringOrModule<EmigrateReporter>;
plugins?: Array<StringOrModule<EmigratePlugin>>;
directory?: string;
template?: string;
extension?: string;

View file

@ -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 <T extends PluginType>(
type: T,
plugins: Array<Plugin | string>,
): Promise<PluginFromType<T>> => {
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 });

View file

@ -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 = <T extends PluginType>(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<StringOrModule<unknown>>,
): Promise<EmigrateStorage | undefined> => {
return getOrLoad(potentialStorages, isEmigrateStorage);
};
export const getOrLoadReporter = async (
potentialReporters: Array<StringOrModule<unknown>>,
): Promise<EmigrateReporter | undefined> => {
return getOrLoad(potentialReporters, isEmigrateReporter);
};
export const getOrLoadPlugin = async <T extends PluginType>(
type: T,
plugins: Array<Plugin | string>,
plugins: Array<StringOrModule<unknown>>,
): Promise<PluginFromType<T> | 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<T> => isPluginOfType(type, value));
};
export const getOrLoadPlugins = async <T extends PluginType>(
type: T,
plugins: Array<Plugin | string>,
plugins: Array<StringOrModule<unknown>>,
): Promise<Array<PluginFromType<T>>> => {
const result: Array<PluginFromType<T>> = [];
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 <T extends PluginType>(
return result;
};
export const loadPlugin = async <T extends PluginType>(
type: T,
plugin: string,
): Promise<PluginFromType<T> | undefined> => {
const getOrLoad = async <T>(potentials: Array<StringOrModule<unknown>>, 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 <T extends PluginType>(
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<EmigrateStorage | undefined> => {
return load(name, ['@emigrate/storage-', 'emigrate-storage-', '@emigrate/plugin-storage-'], isEmigrateStorage);
};
export const loadReporter = async (name: string): Promise<EmigrateReporter | undefined> => {
return load(name, ['@emigrate/reporter-', 'emigrate-reporter-'], isEmigrateReporter);
};
export const loadPlugin = async <T extends PluginType>(
type: T,
plugin: string,
): Promise<PluginFromType<T> | undefined> => {
return load(plugin, ['@emigrate/plugin-', 'emigrate-plugin-'], (value: unknown): value is PluginFromType<T> => {
return isPluginOfType(type, value);
});
};
const load = async <T>(
name: string,
prefixes: string[],
check: (value: unknown) => value is T,
): Promise<T | undefined> => {
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

View file

@ -1,4 +1,6 @@
type Awaitable<T> = T | PromiseLike<T>;
export type Awaitable<T> = T | PromiseLike<T>;
export type StringOrModule<T> = string | T | (() => Awaitable<T>) | (() => Awaitable<{ default: T }>);
export type MigrationStatus = 'failed' | 'done';
@ -58,11 +60,11 @@ export type Storage = {
onError(migration: MigrationMetadataFinished, error: Error): Promise<void>;
};
export type StoragePlugin = {
export type EmigrateStorage = {
initializeStorage(): Promise<Storage>;
};
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<void>;
}>;
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;