emigrate/packages/reporter-pino/src/index.ts
Joakim Carlstein cae6d11d53
feat(types): move Emigrate types to separate package and improve types (#41)
* feat(types): move Emigrate types to separate package

Also refactor the types to use discriminating unions for easier error handling and such.
Errors passed to storage plugins should now be serialized and storage plugins are expected to return already serialized errors on failed history entries.

* fix(mysql): handle the new type changes

* fix(storage-fs): handle the new type changes

* feat(cli): better error handling and types

Adapt to the new types from the @emigrate/types package, like discriminating union types and serializing and deserializing errors
2023-12-15 13:03:35 +01:00

193 lines
5.7 KiB
TypeScript

import process from 'node:process';
import { pino, levels, type Logger } from 'pino';
import {
type Awaitable,
type MigrationMetadata,
type MigrationMetadataFinished,
type ReporterInitParameters,
type EmigrateReporter,
} from '@emigrate/types';
type PinoReporterOptions = {
level?: string;
/**
* Customize the key used for logging errors
*
* @default 'error'
* @see https://getpino.io/#/docs/api?id=errorkey-string
*/
errorKey?: string;
};
class PinoReporter implements Required<EmigrateReporter> {
#logger!: Logger;
#migrations?: MigrationMetadata[];
#command!: ReporterInitParameters['command'];
constructor(private readonly options: PinoReporterOptions) {
if (!options.level || !levels.values[options.level]) {
options.level = 'info';
}
}
get logLevel(): string {
if (this.options.level && levels.values[this.options.level]) {
return this.options.level;
}
return 'info';
}
get errorKey(): string {
return this.options.errorKey ?? 'error';
}
onInit({ command, version, ...parameters }: ReporterInitParameters): Awaitable<void> {
this.#command = command;
this.#logger = pino({
name: 'emigrate',
level: this.logLevel,
errorKey: this.errorKey,
base: {
scope: command,
version,
},
});
this.#logger.info({ parameters }, `Emigrate "${command}" initialized${parameters.dry ? ' (dry-run)' : ''}`);
}
onCollectedMigrations(migrations: MigrationMetadata[]): Awaitable<void> {
this.#migrations = migrations;
}
onLockedMigrations(lockedMigrations: MigrationMetadata[]): Awaitable<void> {
const migrations = this.#migrations ?? [];
if (migrations.length === 0) {
this.#logger.info('No pending migrations found');
return;
}
if (migrations.length === lockedMigrations.length) {
this.#logger.info(
{ migrationCount: lockedMigrations.length },
`${lockedMigrations.length} pending migrations to run`,
);
return;
}
const nonLockedMigrations = migrations.filter(
(migration) => !lockedMigrations.some((lockedMigration) => lockedMigration.name === migration.name),
);
const failedMigrations = nonLockedMigrations.filter(
(migration) => 'status' in migration && migration.status === 'failed',
);
const unlockableCount = this.#command === 'up' ? nonLockedMigrations.length - failedMigrations.length : 0;
const parts = [
`${lockedMigrations.length} of ${migrations.length} pending migrations to run`,
unlockableCount > 0 ? `(${unlockableCount} locked)` : '',
failedMigrations.length > 0 ? `(${failedMigrations.length} failed)` : '',
].filter(Boolean);
this.#logger.info({ migrationCount: lockedMigrations.length }, parts.join(' '));
}
onNewMigration(migration: MigrationMetadata, content: string): Awaitable<void> {
this.#logger.info(
{ migration: migration.relativeFilePath, content },
`Created new migration file: ${migration.name}`,
);
}
onMigrationRemoveStart(migration: MigrationMetadata): Awaitable<void> {
this.#logger.debug({ migration: migration.relativeFilePath }, `Removing migration: ${migration.name}`);
}
onMigrationRemoveSuccess(migration: MigrationMetadataFinished): Awaitable<void> {
this.#logger.info({ migration: migration.relativeFilePath }, `Successfully removed migration: ${migration.name}`);
}
onMigrationRemoveError(migration: MigrationMetadataFinished, error: Error): Awaitable<void> {
this.#logger.error(
{ migration: migration.relativeFilePath, [this.errorKey]: error },
`Failed to remove migration: ${migration.name}`,
);
}
onMigrationStart(migration: MigrationMetadata): Awaitable<void> {
this.#logger.info({ migration: migration.relativeFilePath }, `${migration.name} (running)`);
}
onMigrationSuccess(migration: MigrationMetadataFinished): Awaitable<void> {
this.#logger.info({ migration: migration.relativeFilePath }, `${migration.name} (${migration.status})`);
}
onMigrationError(migration: MigrationMetadataFinished, error: Error): Awaitable<void> {
this.#logger.error(
{ migration: migration.relativeFilePath, [this.errorKey]: error },
`${migration.name} (${migration.status})`,
);
}
onMigrationSkip(migration: MigrationMetadataFinished): Awaitable<void> {
this.#logger.info({ migration: migration.relativeFilePath }, `${migration.name} (${migration.status})`);
}
onFinished(migrations: MigrationMetadataFinished[], error?: Error | undefined): Awaitable<void> {
const total = migrations.length;
let done = 0;
let failed = 0;
let skipped = 0;
let pending = 0;
for (const migration of migrations) {
const status = 'status' in migration ? migration.status : undefined;
switch (status) {
case 'done': {
done++;
break;
}
case 'failed': {
failed++;
break;
}
case 'skipped': {
skipped++;
break;
}
case 'pending': {
pending++;
break;
}
default: {
break;
}
}
}
if (error) {
this.#logger.error(
{ result: { failed, done, skipped, pending, total }, [this.errorKey]: error },
`Emigrate "${this.#command}" failed`,
);
} else {
this.#logger.info(
{ result: { failed, done, skipped, pending, total } },
`Emigrate "${this.#command}" finished successfully`,
);
}
}
}
export const createPinoReporter = (options: PinoReporterOptions = {}): EmigrateReporter => {
return new PinoReporter(options);
};
export default createPinoReporter({
level: process.env['LOG_LEVEL'],
});