diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md index 530bbc7..4166893 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.md +++ b/.github/ISSUE_TEMPLATE/bug-report.md @@ -16,13 +16,13 @@ assignees: '' -- Write a clear and concise description of what the bug is here. -**To Reproduce** --- -Write the steps to reproduce the behavior here: -1. ... -2. ... -3. ... -4. ... +**Command Line Invocation** + +Not Applicable + +**Reproducible Code Example** + +Not Applicable **Expected behavior** -- @@ -42,6 +42,7 @@ None - OS and OS version (e.g macOS 15.0.1): - JS runtime and runtime version (E.g Node.js 22.9.0): - mysql-memory-server version: +- In which environment does this bug occur? (Application Code, CLI, or both): **Additional context** -- diff --git a/README.md b/README.md index c9981e6..227e370 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # MySQL Memory Server -This package allows you to create ephemeral MySQL databases from JavaScript and/or TypeScript code, great for testing. When creating a new database, if the version selected is not installed on the system, the binary is downloaded from MySQL's CDN (cdn.mysql.com) +This package allows you to create ephemeral MySQL databases from JavaScript and/or TypeScript code and also the CLI, great for testing, CI, and learning MySQL. When creating a new database, if the version selected is not installed on the system, the binary is downloaded from MySQL's CDN (cdn.mysql.com) You can run multiple MySQL databases with this package at the same time. Each database will use a random free port. The databases will automatically shutdown when the JS runtime process exits. A `stop()` method is also provided to stop each database instance. @@ -30,7 +30,7 @@ Requirements for Linux: - If using the system installed MySQL server: 8.0.20 and newer - If not using the system installed MySQL server: 8.0.39, 8.0.40, 8.1.0, 8.2.0, 8.3.0, 8.4.2, 8.4.3, 9.0.1, 9.1.0 -## Usage +## Example Usage - Application Code This package supports both ESM and CJS so you can use import or require. @@ -45,7 +45,7 @@ const db = await createDB() //Create a new database with custom options set const db = await createDB({ - // see Options for the options you can use in this object and their default values + // see Options below for the options you can use in this object and their default values // for example: version: '8.4.x' }) @@ -73,6 +73,14 @@ await db.stop() MySQL database initialization can take some time. If you run into a "Timeout exceeded" error with your tests, the timeout should be extended. If using Jest, information about how to do this can be found here: https://jestjs.io/docs/jest-object#jestsettimeouttimeout +## Example Usage - CLI + +```sh +# Options are added by doing --{optionName} {optionValue} +# See Options below for the options you can use with this package +npx mysql-memory-server --version 8.4.x +``` + ## Documentation ##### `createDB(options: ServerOptions): Promise` @@ -93,7 +101,7 @@ If on Windows, this is the name of the named pipe that the MySQL X Plugin is lis - `stop: () => Promise` The method to stop the database. The returned promise resolves when the database has successfully stopped. -###### Options: +#### Options: - `version: string` Required: No @@ -213,28 +221,38 @@ The internal queries that are ran before the queries in ```initSQLString``` are *** ### :warning: Internal Options :warning: -The following options are only meant for internal debugging use. Their behaviour may change or they may get removed between major/minor/patch versions and they are not to be considered stable. The options below will not follow Semantic Versioning so it is advised to not use them. +The following options are only meant for internal use (such as CI or the internals for running this package via the CLI). Their behaviour may change or they may get removed between major/minor/patch versions and they are not to be considered stable. The options below will not follow Semantic Versioning so it is advised to not use them. - `_DO_NOT_USE_deleteDBAfterStopped: boolean` -Required: No - Default: true Description: Changes whether or not the database will be deleted after it has been stopped. If set to `true`, the database WILL be deleted after it has been stopped. - `_DO_NOT_USE_dbPath: string` -Required: No - Default: `TMPDIR/mysqlmsn/dbs/UUID` (replacing TMPDIR with the OS temp directory and UUID with a UUIDv4 without seperating dashes). Description: The folder to store database-related data in - `_DO_NOT_USE_binaryDirectoryPath: string` -Required: No - Default: `TMPDIR/mysqlmsn/binaries` (replacing TMPDIR with the OS temp directory) Description: The folder to store the MySQL binaries when they are downloaded from the CDN. + +- `_DO_NOT_USE_beforeSignalCleanupMessage: string` + +Required: No + +Default: undefined + +Description: The message to get displayed in the console before the cleanup that happens when the Node.js process is stopped without the ```stop()``` method being called first. + +- `_DO_NOT_USE_afterSignalCleanupMessage: string` + +Required: No + +Default: undefined + +Description: The message to get displayed in the console after the cleanup that happens when the Node.js process is stopped without the ```stop()``` method being called first. diff --git a/package.json b/package.json index 2ad355d..c46751f 100644 --- a/package.json +++ b/package.json @@ -50,5 +50,6 @@ "bugs": { "url": "https://github.com/Sebastian-Webster/mysql-memory-server-nodejs/issues" }, - "homepage": "https://github.com/Sebastian-Webster/mysql-memory-server-nodejs" + "homepage": "https://github.com/Sebastian-Webster/mysql-memory-server-nodejs", + "bin": "dist/src/cli.js" } diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..75e6902 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,46 @@ +#!/usr/bin/env node +import { createDB } from "./index"; +import { OPTION_TYPE_CHECKS } from "./constants"; + +async function main() { + const definedOptions = process.argv.filter((option) => option.startsWith('--')) + const options = { + _DO_NOT_USE_beforeSignalCleanupMessage: '\nShutting down the epehemeral MySQL database and cleaning all related files...', + _DO_NOT_USE_afterSignalCleanupMessage: 'Shutdown and cleanup is complete.' + } + for (const opt of definedOptions) { + const index = process.argv.indexOf(opt) + const optionValue = process.argv[index + 1] + + if (optionValue === undefined) { + throw `Option ${opt} must have a value.` + } + + const optionName = opt.slice(2) + const optionType = OPTION_TYPE_CHECKS[optionName].definedType; + + //Try to convert the options to their correct types. + //We do not need to do any proper type validation here as the library will make sure everything is correct. + //Like for example, if a string is passed to a number option, it'll be converted to NaN here, but the library + //will throw an error for it not being an actual number. + if (optionType === 'boolean') { + if (optionValue === 'true') { + options[optionName] = true + } else if (optionValue === 'false') { + options[optionName] = false + } else { + options[optionName] = optionValue + } + } else if (optionType === 'number') { + options[optionName] = parseInt(optionValue) + } else { + options[opt.slice(2)] = optionValue + } + } + console.log('Creating ephemeral MySQL database...') + const db = await createDB(options); + console.log(`A MySQL databases has been successfully created with the following parameters:\n\nUsername: ${db.username} \nDatabase Name: ${db.dbName} \nPort: ${db.port} \nX Plugin Port: ${db.xPort} \nSocket: ${db.socket} \nX Plugin Socket: ${db.xSocket}\n`) + console.log(`If you want to use the MySQL CLI client to connect to the database, you can use either commands: \nmysql -u ${db.username} -P ${db.port} --protocol tcp \nOR\nmysql -u ${db.username} --socket ${db.socket}`) +} + +main() diff --git a/src/constants.ts b/src/constants.ts index 64056ab..0a13d67 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -23,7 +23,9 @@ export const DEFAULT_OPTIONS_GENERATOR: () => InternalServerOptions = () => ({ _DO_NOT_USE_deleteDBAfterStopped: true, //mysqlmsn = MySQL Memory Server Node.js _DO_NOT_USE_dbPath: normalizePath(`${tmpdir()}/mysqlmsn/dbs/${randomUUID().replace(/-/g, '')}`), - _DO_NOT_USE_binaryDirectoryPath: `${tmpdir()}/mysqlmsn/binaries` + _DO_NOT_USE_binaryDirectoryPath: `${tmpdir()}/mysqlmsn/binaries`, + _DO_NOT_USE_beforeSignalCleanupMessage: '', + _DO_NOT_USE_afterSignalCleanupMessage: '' }); export const DEFAULT_OPTIONS_KEYS = Object.freeze(Object.keys(DEFAULT_OPTIONS_GENERATOR())) @@ -34,71 +36,97 @@ export const LOG_LEVELS = { 'ERROR': 2 } as const; -export const INTERNAL_OPTIONS = ['_DO_NOT_USE_deleteDBAfterStopped', '_DO_NOT_USE_dbPath', '_DO_NOT_USE_binaryDirectoryPath'] as const; +export const INTERNAL_OPTIONS = ['_DO_NOT_USE_deleteDBAfterStopped', '_DO_NOT_USE_dbPath', '_DO_NOT_USE_binaryDirectoryPath', '_DO_NOT_USE_beforeSignalCleanup', '_DO_NOT_USE_afterSignalCleanup'] as const; export const OPTION_TYPE_CHECKS: OptionTypeChecks = { version: { check: (opt: any) => opt === undefined || typeof opt === 'string' && validSemver(opt) !== null, - errorMessage: 'Option version must be either undefined or a valid semver string.' + errorMessage: 'Option version must be either undefined or a valid semver string.', + definedType: 'string' }, dbName: { check: (opt: any) => opt === undefined || typeof opt === 'string' && opt.length <= 64, - errorMessage: 'Option dbName must be either undefined or a string that is not longer than 64 characters.' + errorMessage: 'Option dbName must be either undefined or a string that is not longer than 64 characters.', + definedType: 'string' }, logLevel: { check: (opt: any) => opt === undefined || Object.keys(LOG_LEVELS).includes(opt), - errorMessage: 'Option logLevel must be either undefined or "LOG", "WARN", or "ERROR".' + errorMessage: 'Option logLevel must be either undefined or "LOG", "WARN", or "ERROR".', + definedType: 'string' }, portRetries: { check: (opt: any) => opt === undefined || typeof opt === 'number' && opt >= 0, - errorMessage: 'Option portRetries must be either undefined, a positive number, or 0.' + errorMessage: 'Option portRetries must be either undefined, a positive number, or 0.', + definedType: 'number' }, downloadBinaryOnce: { check: (opt: any) => opt === undefined || typeof opt === 'boolean', - errorMessage: 'Option downloadBinaryOnce must be either undefined or a boolean.' + errorMessage: 'Option downloadBinaryOnce must be either undefined or a boolean.', + definedType: 'boolean' }, lockRetries: { check: (opt: any) => opt === undefined || typeof opt === 'number' && opt >= 0, - errorMessage: 'Option lockRetries must be either undefined, a positive number, or 0.' + errorMessage: 'Option lockRetries must be either undefined, a positive number, or 0.', + definedType: 'number' }, lockRetryWait: { check: (opt: any) => opt === undefined || typeof opt === 'number' && opt >= 0, - errorMessage: 'Option lockRetryWait must be either undefined, a positive number, or 0.' + errorMessage: 'Option lockRetryWait must be either undefined, a positive number, or 0.', + definedType: 'number' }, username: { check: (opt: any) => opt === undefined || typeof opt === 'string' && opt.length <= 32, - errorMessage: 'Option username must be either undefined or a string that is not longer than 32 characters.' + errorMessage: 'Option username must be either undefined or a string that is not longer than 32 characters.', + definedType: 'string' }, ignoreUnsupportedSystemVersion: { check: (opt: any) => opt === undefined || typeof opt === 'boolean', - errorMessage: 'Option ignoreUnsupportedSystemVersion must be either undefined or a boolean.' + errorMessage: 'Option ignoreUnsupportedSystemVersion must be either undefined or a boolean.', + definedType: 'boolean' }, port: { check: (opt: any) => opt === undefined || typeof opt === 'number' && opt >= 0 && opt <= 65535, - errorMessage: 'Option port must be either undefined or a number that is between 0 and 65535 inclusive.' + errorMessage: 'Option port must be either undefined or a number that is between 0 and 65535 inclusive.', + definedType: 'number' }, xPort: { check: (opt: any) => opt === undefined || typeof opt === 'number' && opt >= 0 && opt <= 65535, - errorMessage: 'Option xPort must be either undefined or a number that is between 0 and 65535 inclusive.' + errorMessage: 'Option xPort must be either undefined or a number that is between 0 and 65535 inclusive.', + definedType: 'number' }, downloadRetries: { check: (opt: any) => opt === undefined || typeof opt === 'number' && opt >= 0, - errorMessage: 'Option downloadRetries must be either undefined, a positive number, or 0.' + errorMessage: 'Option downloadRetries must be either undefined, a positive number, or 0.', + definedType: 'number' }, initSQLString: { check: (opt: any) => opt === undefined || typeof opt === 'string', - errorMessage: 'Option initSQLString must be either undefined or a string.' + errorMessage: 'Option initSQLString must be either undefined or a string.', + definedType: 'string' }, _DO_NOT_USE_deleteDBAfterStopped: { check: (opt: any) => opt === undefined || typeof opt === 'boolean', - errorMessage: 'Option _DO_NOT_USE_deleteDBAfterStopped must be either undefined or a boolean.' + errorMessage: 'Option _DO_NOT_USE_deleteDBAfterStopped must be either undefined or a boolean.', + definedType: 'boolean' }, _DO_NOT_USE_dbPath: { check: (opt: any) => opt === undefined || typeof opt === 'string', - errorMessage: 'Option _DO_NOT_USE_dbPath must be either undefined or a string.' + errorMessage: 'Option _DO_NOT_USE_dbPath must be either undefined or a string.', + definedType: 'string' }, _DO_NOT_USE_binaryDirectoryPath: { check: (opt: any) => opt === undefined || typeof opt === 'string', - errorMessage: 'Option _DO_NOT_USE_binaryDirectoryPath must be either undefined or a string.' + errorMessage: 'Option _DO_NOT_USE_binaryDirectoryPath must be either undefined or a string.', + definedType: 'string' + }, + _DO_NOT_USE_beforeSignalCleanupMessage: { + check: (opt: any) => opt === undefined || typeof opt === 'string', + errorMessage: 'Option _DO_NOT_USE_beforeSignalCleanup must be either undefined or a string.', + definedType: 'string' + }, + _DO_NOT_USE_afterSignalCleanupMessage: { + check: (opt: any) => opt === undefined || typeof opt === 'string', + errorMessage: 'Option _DO_NOT_USE_afterSignalCleanup must be either undefined or a string.', + definedType: 'string' } } as const; \ No newline at end of file diff --git a/src/libraries/AbortSignal.ts b/src/libraries/AbortSignal.ts deleted file mode 100644 index 89a9a05..0000000 --- a/src/libraries/AbortSignal.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { onExit } from 'signal-exit' - -const DBDestroySignal = new AbortController(); - -function abortSignal() { - if (!DBDestroySignal.signal.aborted) { - DBDestroySignal.abort('Process is exiting') - } -} - -onExit(abortSignal) - -export default DBDestroySignal; \ No newline at end of file diff --git a/src/libraries/Executor.ts b/src/libraries/Executor.ts index 5d7839e..64cc129 100644 --- a/src/libraries/Executor.ts +++ b/src/libraries/Executor.ts @@ -5,13 +5,15 @@ import * as fsPromises from 'fs/promises'; import * as fs from 'fs'; import Logger from "./Logger"; import { GenerateRandomPort } from "./Port"; -import DBDestroySignal from "./AbortSignal"; import { ExecuteFileReturn, InstalledMySQLVersion, InternalServerOptions, MySQLDB } from "../../types"; import {normalize as normalizePath, resolve as resolvePath} from 'path' import { lockFile, waitForLock } from "./FileLock"; +import { onExit } from "signal-exit"; class Executor { logger: Logger; + DBDestroySignal = new AbortController(); + removeExitHandler: () => void constructor(logger: Logger) { this.logger = logger; @@ -19,7 +21,7 @@ class Executor { #executeFile(command: string, args: string[]): Promise { return new Promise(resolve => { - execFile(command, args, {signal: DBDestroySignal.signal}, (error, stdout, stderr) => { + execFile(command, args, {signal: this.DBDestroySignal.signal}, (error, stdout, stderr) => { resolve({error, stdout, stderr}) }) }) @@ -64,6 +66,20 @@ class Executor { } } + //Returns a path to the binary if it should be deleted + //If it should not be deleted then it returns null + #returnBinaryPathToDelete(binaryFilepath: string, options: InternalServerOptions): string | null { + if (binaryFilepath.includes(os.tmpdir()) && !options.downloadBinaryOnce) { + const splitPath = binaryFilepath.split(os.platform() === 'win32' ? '\\' : '/') + const binariesIndex = splitPath.indexOf('binaries') + //The path will be the directory path for the binary download + splitPath.splice(binariesIndex + 2) + return splitPath.join('/') + } + + return null + } + #startMySQLProcess(options: InternalServerOptions, port: number, mySQLXPort: number, datadir: string, dbPath: string, binaryFilepath: string): Promise { const errors: string[] = [] const logFile = `${dbPath}/log.log` @@ -75,7 +91,7 @@ class Executor { const socket = os.platform() === 'win32' ? `MySQL-${crypto.randomUUID()}` : `${dbPath}/m.sock` const xSocket = os.platform() === 'win32' ? `MySQLX-${crypto.randomUUID()}` : `${dbPath}/x.sock` - const process = spawn(binaryFilepath, ['--no-defaults', `--port=${port}`, `--datadir=${datadir}`, `--mysqlx-port=${mySQLXPort}`, `--mysqlx-socket=${xSocket}`, `--socket=${socket}`, `--general-log-file=${logFile}`, '--general-log=1', `--init-file=${dbPath}/init.sql`, '--bind-address=127.0.0.1', '--innodb-doublewrite=OFF', '--mysqlx=FORCE', `--log-error=${errorLogFile}`, `--user=${os.userInfo().username}`], {signal: DBDestroySignal.signal, killSignal: 'SIGKILL'}) + const process = spawn(binaryFilepath, ['--no-defaults', `--port=${port}`, `--datadir=${datadir}`, `--mysqlx-port=${mySQLXPort}`, `--mysqlx-socket=${xSocket}`, `--socket=${socket}`, `--general-log-file=${logFile}`, '--general-log=1', `--init-file=${dbPath}/init.sql`, '--bind-address=127.0.0.1', '--innodb-doublewrite=OFF', '--mysqlx=FORCE', `--log-error=${errorLogFile}`, `--user=${os.userInfo().username}`], {signal: this.DBDestroySignal.signal, killSignal: 'SIGKILL'}) //resolveFunction is the function that will be called to resolve the promise that stops the database. //If resolveFunction is not undefined, the database has received a kill signal and data cleanup procedures should run. @@ -111,16 +127,12 @@ class Executor { await this.#deleteDatabaseDirectory(dbPath) } } catch (e) { - this.logger.error('An erorr occurred while deleting database directory at path:', dbPath, '| The error was:', e) + this.logger.error('An error occurred while deleting database directory at path:', dbPath, '| The error was:', e) } finally { try { - if (binaryFilepath.includes(os.tmpdir()) && !options.downloadBinaryOnce) { - const splitPath = binaryFilepath.split(os.platform() === 'win32' ? '\\' : '/') - const binariesIndex = splitPath.indexOf('binaries') - //The path will be the directory path for the binary download - splitPath.splice(binariesIndex + 2) - //Delete the binary folder - await fsPromises.rm(splitPath.join('/'), {force: true, recursive: true}) + const binaryPathToDelete = this.#returnBinaryPathToDelete(binaryFilepath, options) + if (binaryPathToDelete) { + await fsPromises.rm(binaryPathToDelete, {force: true, recursive: true, maxRetries: 50}) } } catch (e) { this.logger.error('An error occurred while deleting no longer needed MySQL binary:', e) @@ -180,6 +192,8 @@ class Executor { stop: () => { return new Promise(async (resolve, reject) => { resolveFunction = resolve; + + this.removeExitHandler() const killed = await this.#killProcess(process) @@ -406,6 +420,35 @@ class Executor { } async startMySQL(options: InternalServerOptions, binaryFilepath: string): Promise { + this.removeExitHandler = onExit(() => { + if (options._DO_NOT_USE_beforeSignalCleanupMessage) { + console.log(options._DO_NOT_USE_beforeSignalCleanupMessage) + } + + this.DBDestroySignal.abort() + + if (options._DO_NOT_USE_deleteDBAfterStopped) { + try { + fs.rmSync(options._DO_NOT_USE_dbPath, {recursive: true, maxRetries: 50, force: true}) + } catch (e) { + this.logger.error('An error occurred while deleting database directory path:', e) + } + } + + const binaryPathToDelete = this.#returnBinaryPathToDelete(binaryFilepath, options) + if (binaryPathToDelete) { + try { + fs.rmSync(binaryPathToDelete, {force: true, recursive: true, maxRetries: 50}) + } catch (e) { + this.logger.error('An error occurred while deleting database binary:', e) + } + } + + if (options._DO_NOT_USE_afterSignalCleanupMessage) { + console.log(options._DO_NOT_USE_afterSignalCleanupMessage) + } + }) + let retries = 0; const datadir = normalizePath(`${options._DO_NOT_USE_dbPath}/data`) diff --git a/types/index.ts b/types/index.ts index 8d2f9d9..300ae40 100644 --- a/types/index.ts +++ b/types/index.ts @@ -18,7 +18,9 @@ export type ServerOptions = { initSQLString?: string | undefined _DO_NOT_USE_deleteDBAfterStopped?: boolean | undefined, _DO_NOT_USE_dbPath?: string | undefined, - _DO_NOT_USE_binaryDirectoryPath?: string | undefined + _DO_NOT_USE_binaryDirectoryPath?: string | undefined, + _DO_NOT_USE_beforeSignalCleanupMessage?: string | undefined, + _DO_NOT_USE_afterSignalCleanupMessage?: string | undefined } export type InternalServerOptions = { @@ -38,6 +40,8 @@ export type InternalServerOptions = { _DO_NOT_USE_deleteDBAfterStopped: boolean, _DO_NOT_USE_dbPath: string, _DO_NOT_USE_binaryDirectoryPath: string, + _DO_NOT_USE_beforeSignalCleanupMessage: string, + _DO_NOT_USE_afterSignalCleanupMessage: string } export type ExecutorOptions = { @@ -81,6 +85,7 @@ export type BinaryInfo = { export type OptionTypeChecks = { [key in keyof Required]: { check: (opt: any) => boolean, - errorMessage: string + errorMessage: string, + definedType: "string" | "boolean" | "number" } } \ No newline at end of file