From 3619d867501aa666a26895978a2cdcc73140e9c1 Mon Sep 17 00:00:00 2001 From: Joakim Carlstein Date: Thu, 7 Dec 2023 14:36:30 +0100 Subject: [PATCH] feat(reporter-pino): first version of the package --- .changeset/perfect-peas-vanish.md | 5 + packages/reporter-pino/README.md | 64 ++++++++++ packages/reporter-pino/package.json | 47 +++++++ packages/reporter-pino/src/index.ts | 180 +++++++++++++++++++++++++++ packages/reporter-pino/tsconfig.json | 8 ++ pnpm-lock.yaml | 129 +++++++++++++++++++ 6 files changed, 433 insertions(+) create mode 100644 .changeset/perfect-peas-vanish.md create mode 100644 packages/reporter-pino/README.md create mode 100644 packages/reporter-pino/package.json create mode 100644 packages/reporter-pino/src/index.ts create mode 100644 packages/reporter-pino/tsconfig.json diff --git a/.changeset/perfect-peas-vanish.md b/.changeset/perfect-peas-vanish.md new file mode 100644 index 0000000..25ed0fa --- /dev/null +++ b/.changeset/perfect-peas-vanish.md @@ -0,0 +1,5 @@ +--- +'@emigrate/reporter-pino': minor +--- + +Implement the first version of the Pino reporter package diff --git a/packages/reporter-pino/README.md b/packages/reporter-pino/README.md new file mode 100644 index 0000000..975623f --- /dev/null +++ b/packages/reporter-pino/README.md @@ -0,0 +1,64 @@ +# @emigrate/reporter-pino + +A [Pino](https://getpino.io/#/) reporter for Emigrate which logs the migration progress using line delimited JSON by default. +Which is great both in production environments and for piping the output to other tools. + +## Installation + +Install the reporter in your project, alongside the Emigrate CLI: + +```bash +npm install --save-dev @emigrate/cli @emigrate/reporter-pino +``` + +## Usage + +### With default options + +Configure the reporter in your `emigrate.config.js` file: + +```js +import reporterPino from '@emigrate/reporter-pino'; + +export default { + directory: 'migrations', + reporter: reporterPino, +}; +``` + +Or simply: + +```js +export default { + directory: 'migrations', + reporter: 'pino', // the @emigrate/reporter- prefix is optional +}; +``` + +Or use the CLI option `--reporter` (or `-r`): + +```bash +emigrate up --reporter pino # the @emigrate/reporter- prefix is optional +``` + +### With custom options + +Configure the reporter in your `emigrate.config.js` file: + +```js +import { createPinoReporter } from '@emigrate/reporter-pino'; + +export default { + directory: 'migrations', + reporter: createPinoReporter({ + level: 'error', // default is 'info' + errorKey: 'err', // default is 'error' + }), +}; +``` + +The log level can also be set using the `LOG_LEVEL` environment variable: + +```bash +LOG_LEVEL=error emigrate up -r pino +``` diff --git a/packages/reporter-pino/package.json b/packages/reporter-pino/package.json new file mode 100644 index 0000000..5817422 --- /dev/null +++ b/packages/reporter-pino/package.json @@ -0,0 +1,47 @@ +{ + "name": "@emigrate/reporter-pino", + "version": "0.0.0", + "publishConfig": { + "access": "public" + }, + "description": "A Pino reporter for Emigrate for logging the migration process.", + "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", + "emigrate-reporter", + "plugin", + "migrations", + "reporter" + ], + "author": "Aboviq AB (https://www.aboviq.com)", + "homepage": "https://github.com/aboviq/emigrate/tree/main/packages/reporter-pino#readme", + "repository": "https://github.com/aboviq/emigrate/tree/main/packages/reporter-pino", + "bugs": "https://github.com/aboviq/emigrate/issues", + "license": "MIT", + "dependencies": { + "@emigrate/plugin-tools": "workspace:*", + "pino": "8.16.2" + }, + "devDependencies": { + "@emigrate/tsconfig": "workspace:*" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/packages/reporter-pino/src/index.ts b/packages/reporter-pino/src/index.ts new file mode 100644 index 0000000..65f6ce5 --- /dev/null +++ b/packages/reporter-pino/src/index.ts @@ -0,0 +1,180 @@ +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/plugin-tools/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 { + #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, ...parameters }: ReporterInitParameters): Awaitable { + this.#command = command; + this.#logger = pino({ + name: 'emigrate', + level: this.logLevel, + errorKey: this.errorKey, + base: { + scope: command, + }, + }); + + this.#logger.info({ parameters }, `Emigrate "${command}" initialized${parameters.dry ? ' (dry-run)' : ''}`); + } + + onCollectedMigrations(migrations: MigrationMetadata[]): Awaitable { + this.#migrations = migrations; + } + + onLockedMigrations(lockedMigrations: MigrationMetadata[]): Awaitable { + 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 = nonLockedMigrations.length - failedMigrations.length; + 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 { + this.#logger.info({ migration, content }, `Created new migration file: ${migration.name}`); + } + + onMigrationRemoveStart(migration: MigrationMetadata): Awaitable { + this.#logger.debug({ migration }, `Removing migration: ${migration.name}`); + } + + onMigrationRemoveSuccess(migration: MigrationMetadataFinished): Awaitable { + this.#logger.info({ migration }, `Successfully removed migration: ${migration.name}`); + } + + onMigrationRemoveError(migration: MigrationMetadataFinished, error: Error): Awaitable { + this.#logger.error({ migration, [this.errorKey]: error }, `Failed to remove migration: ${migration.name}`); + } + + onMigrationStart(migration: MigrationMetadata): Awaitable { + this.#logger.info({ migration }, `${migration.name} (running)`); + } + + onMigrationSuccess(migration: MigrationMetadataFinished): Awaitable { + this.#logger.info({ migration }, `${migration.name} (${migration.status})`); + } + + onMigrationError(migration: MigrationMetadataFinished, error: Error): Awaitable { + this.#logger.error({ migration, [this.errorKey]: error }, `${migration.name} (${migration.status})`); + } + + onMigrationSkip(migration: MigrationMetadataFinished): Awaitable { + this.#logger.info({ migration }, `${migration.name} (${migration.status})`); + } + + onFinished(migrations: MigrationMetadataFinished[], error?: Error | undefined): Awaitable { + 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( + { failed, done, skipped, pending, total, [this.errorKey]: error }, + `Emigrate "${this.#command}" failed`, + ); + } else { + this.#logger.info({ 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'], +}); diff --git a/packages/reporter-pino/tsconfig.json b/packages/reporter-pino/tsconfig.json new file mode 100644 index 0000000..1cfcebb --- /dev/null +++ b/packages/reporter-pino/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 0479d8d..35788f5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -102,6 +102,19 @@ importers: specifier: workspace:* version: link:../tsconfig + packages/reporter-pino: + dependencies: + '@emigrate/plugin-tools': + specifier: workspace:* + version: link:../plugin-tools + pino: + specifier: 8.16.2 + version: 8.16.2 + devDependencies: + '@emigrate/tsconfig': + specifier: workspace:* + version: link:../tsconfig + packages/storage-fs: dependencies: '@emigrate/plugin-tools': @@ -1254,6 +1267,13 @@ packages: through: 2.3.8 dev: false + /abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + dependencies: + event-target-shim: 5.0.1 + dev: false + /acorn-import-assertions@1.9.0(acorn@8.11.2): resolution: {integrity: sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA==} peerDependencies: @@ -1451,6 +1471,11 @@ packages: engines: {node: '>=12'} dev: false + /atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + dev: false + /available-typed-arrays@1.0.5: resolution: {integrity: sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==} engines: {node: '>= 0.4'} @@ -1460,6 +1485,10 @@ packages: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} dev: false + /base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + dev: false + /better-path-resolve@1.0.0: resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} engines: {node: '>=4'} @@ -1520,6 +1549,13 @@ packages: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} dev: false + /buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + dev: false + /builtin-modules@3.3.0: resolution: {integrity: sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==} engines: {node: '>=6'} @@ -2571,6 +2607,11 @@ packages: engines: {node: '>=0.10.0'} dev: false + /event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + dev: false + /eventemitter3@5.0.1: resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} dev: false @@ -2665,6 +2706,11 @@ packages: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} dev: false + /fast-redact@3.3.0: + resolution: {integrity: sha512-6T5V1QK1u4oF+ATxs1lWUmlEk6P2T9HqJG3e2DnHOdVgZy2rFJBoEnrIedcTXlkAHU/zKC+7KETJ+KGGKwxgMQ==} + engines: {node: '>=6'} + dev: false + /fastq@1.15.0: resolution: {integrity: sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==} dependencies: @@ -3121,6 +3167,10 @@ packages: safer-buffer: 2.1.2 dev: false + /ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + dev: false + /ignore@5.2.4: resolution: {integrity: sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==} engines: {node: '>= 4'} @@ -4092,6 +4142,11 @@ packages: es-abstract: 1.22.3 dev: false + /on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + dev: false + /once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} dependencies: @@ -4357,6 +4412,34 @@ packages: engines: {node: '>=6'} dev: false + /pino-abstract-transport@1.1.0: + resolution: {integrity: sha512-lsleG3/2a/JIWUtf9Q5gUNErBqwIu1tUKTT3dUzaf5DySw9ra1wcqKjJjLX1VTY64Wk1eEOYsVGSaGfCK85ekA==} + dependencies: + readable-stream: 4.4.2 + split2: 4.2.0 + dev: false + + /pino-std-serializers@6.2.2: + resolution: {integrity: sha512-cHjPPsE+vhj/tnhCy/wiMh3M3z3h/j15zHQX+S9GkTBgqJuTuJzYJ4gUyACLhDaJ7kk9ba9iRDmbH2tJU03OiA==} + dev: false + + /pino@8.16.2: + resolution: {integrity: sha512-2advCDGVEvkKu9TTVSa/kWW7Z3htI/sBKEZpqiHk6ive0i/7f5b1rsU8jn0aimxqfnSz5bj/nOYkwhBUn5xxvg==} + hasBin: true + dependencies: + atomic-sleep: 1.0.0 + fast-redact: 3.3.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 1.1.0 + pino-std-serializers: 6.2.2 + process-warning: 2.3.2 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.4.3 + sonic-boom: 3.7.0 + thread-stream: 2.4.1 + dev: false + /pkg-dir@4.2.0: resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} engines: {node: '>=8'} @@ -4436,6 +4519,15 @@ packages: parse-ms: 3.0.0 dev: false + /process-warning@2.3.2: + resolution: {integrity: sha512-n9wh8tvBe5sFmsqlg+XQhaQLumwpqoAUruLwjCopgTmUBjJ/fjtBsJzKleCaIGBOMXYEhp1YfKl4d7rJ5ZKJGA==} + dev: false + + /process@0.11.10: + resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} + engines: {node: '>= 0.6.0'} + dev: false + /proto-props@2.0.0: resolution: {integrity: sha512-2yma2tog9VaRZY2mn3Wq51uiSW4NcPYT1cQdBagwyrznrilKSZwIZ0UG3ZPL/mx+axEns0hE35T5ufOYZXEnBQ==} engines: {node: '>=4'} @@ -4461,6 +4553,10 @@ packages: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} dev: false + /quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + dev: false + /quick-lru@4.0.1: resolution: {integrity: sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==} engines: {node: '>=8'} @@ -4529,6 +4625,22 @@ packages: util-deprecate: 1.0.2 dev: false + /readable-stream@4.4.2: + resolution: {integrity: sha512-Lk/fICSyIhodxy1IDK2HazkeGjSmezAWX2egdtJnYhtzKEsBPJowlI6F6LPb5tqIQILrMbx22S5o3GuJavPusA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dependencies: + abort-controller: 3.0.0 + buffer: 6.0.3 + events: 3.3.0 + process: 0.11.10 + string_decoder: 1.3.0 + dev: false + + /real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + dev: false + /redent@3.0.0: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} @@ -4694,6 +4806,11 @@ packages: is-regex: 1.1.4 dev: false + /safe-stable-stringify@2.4.3: + resolution: {integrity: sha512-e2bDA2WJT0wxseVd4lsDP4+3ONX6HpMXQa1ZhFQ7SU+GjvORCmShbCMltrtIDfkYhVHrOcPtj+KhmDBdPdZD1g==} + engines: {node: '>=10'} + dev: false + /safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} dev: false @@ -4850,6 +4967,12 @@ packages: yargs: 15.4.1 dev: false + /sonic-boom@3.7.0: + resolution: {integrity: sha512-IudtNvSqA/ObjN97tfgNmOKyDOs4dNcg4cUUsHDebqsgb8wGBBwb31LIgShNO8fye0dFI52X1+tFoKKI6Rq1Gg==} + dependencies: + atomic-sleep: 1.0.0 + dev: false + /source-map-support@0.5.21: resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} dependencies: @@ -5131,6 +5254,12 @@ packages: resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} dev: false + /thread-stream@2.4.1: + resolution: {integrity: sha512-d/Ex2iWd1whipbT681JmTINKw0ZwOUBZm7+Gjs64DHuX34mmw8vJL2bFAaNacaW72zYiTJxSHi5abUuOi5nsfg==} + dependencies: + real-require: 0.2.0 + dev: false + /through2@4.0.2: resolution: {integrity: sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==} dependencies: