Skip to content

Commit

Permalink
Merge pull request #148 from Sebastian-Webster/146-add-option-type-va…
Browse files Browse the repository at this point in the history
…lidation

feat: add option type validation
  • Loading branch information
Sebastian-Webster authored Nov 20, 2024
2 parents 8f17a01 + 65e9121 commit 47e095a
Show file tree
Hide file tree
Showing 8 changed files with 146 additions and 53 deletions.
107 changes: 103 additions & 4 deletions src/constants.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,104 @@
const CONSTANTS = {
MIN_SUPPORTED_MYSQL: '8.0.20'
}
import { InternalServerOptions, OptionTypeChecks } from "../types";
import { randomUUID } from "crypto";
import {normalize as normalizePath} from 'path'
import { tmpdir } from "os";
import { valid as validSemver } from "semver";

export default CONSTANTS
export const MIN_SUPPORTED_MYSQL = '8.0.20';

export const DEFAULT_OPTIONS_GENERATOR: () => InternalServerOptions = () => ({
version: undefined,
dbName: 'dbdata',
logLevel: 'ERROR',
portRetries: 10,
downloadBinaryOnce: true,
lockRetries: 1_000,
lockRetryWait: 1_000,
username: 'root',
ignoreUnsupportedSystemVersion: false,
port: 0,
xPort: 0,
downloadRetries: 10,
initSQLString: '',
_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`
});

export const DEFAULT_OPTIONS_KEYS = Object.freeze(Object.keys(DEFAULT_OPTIONS_GENERATOR()))

export const LOG_LEVELS = {
'LOG': 0,
'WARN': 1,
'ERROR': 2
} as const;

export const INTERNAL_OPTIONS = ['_DO_NOT_USE_deleteDBAfterStopped', '_DO_NOT_USE_dbPath', '_DO_NOT_USE_binaryDirectoryPath'] 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.'
},
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.'
},
logLevel: {
check: (opt: any) => opt === undefined || Object.keys(LOG_LEVELS).includes(opt),
errorMessage: 'Option logLevel must be either undefined or "LOG", "WARN", or "ERROR".'
},
portRetries: {
check: (opt: any) => opt === undefined || typeof opt === 'number' && opt >= 0,
errorMessage: 'Option portRetries must be either undefined, a positive number, or 0.'
},
downloadBinaryOnce: {
check: (opt: any) => opt === undefined || typeof opt === 'boolean',
errorMessage: 'Option downloadBinaryOnce must be either undefined or a boolean.'
},
lockRetries: {
check: (opt: any) => opt === undefined || typeof opt === 'number' && opt >= 0,
errorMessage: 'Option lockRetries must be either undefined, a positive number, or 0.'
},
lockRetryWait: {
check: (opt: any) => opt === undefined || typeof opt === 'number' && opt >= 0,
errorMessage: 'Option lockRetryWait must be either undefined, a positive number, or 0.'
},
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.'
},
ignoreUnsupportedSystemVersion: {
check: (opt: any) => opt === undefined || typeof opt === 'boolean',
errorMessage: 'Option ignoreUnsupportedSystemVersion must be either undefined or a 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.'
},
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.'
},
downloadRetries: {
check: (opt: any) => opt === undefined || typeof opt === 'number' && opt >= 0,
errorMessage: 'Option downloadRetries must be either undefined, a positive number, or 0.'
},
initSQLString: {
check: (opt: any) => opt === undefined || typeof opt === 'string',
errorMessage: 'Option initSQLString must be either undefined or a 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.'
},
_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.'
},
_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.'
}
} as const;
48 changes: 20 additions & 28 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,62 +2,54 @@ import Logger from './libraries/Logger'
import * as os from 'node:os'
import Executor from "./libraries/Executor"
import { satisfies, lt } from "semver"
import DBDestroySignal from "./libraries/AbortSignal"
import { BinaryInfo, InternalServerOptions, ServerOptions } from '../types'
import getBinaryURL from './libraries/Version'
import MySQLVersions from './versions.json'
import { downloadBinary } from './libraries/Downloader'
import { randomUUID } from "crypto";
import {normalize as normalizePath} from 'path'
import CONSTANTS from './constants'
import { MIN_SUPPORTED_MYSQL, DEFAULT_OPTIONS_KEYS, OPTION_TYPE_CHECKS, INTERNAL_OPTIONS, DEFAULT_OPTIONS_GENERATOR } from './constants'

export async function createDB(opts?: ServerOptions) {
const defaultOptions: InternalServerOptions = {
dbName: 'dbdata',
logLevel: 'ERROR',
portRetries: 10,
downloadBinaryOnce: true,
lockRetries: 1_000,
lockRetryWait: 1_000,
username: 'root',
deleteDBAfterStopped: true,
//mysqlmsn = MySQL Memory Server Node.js
dbPath: normalizePath(`${os.tmpdir()}/mysqlmsn/dbs/${randomUUID().replace(/-/g, '')}`),
ignoreUnsupportedSystemVersion: false,
port: 0,
xPort: 0,
binaryDirectoryPath: `${os.tmpdir()}/mysqlmsn/binaries`,
downloadRetries: 10,
initSQLString: ''
}

const suppliedOpts = opts || {};
const suppliedOptsKeys = Object.keys(suppliedOpts);
const internalOpts = ['_DO_NOT_USE_deleteDBAfterStopped', '_DO_NOT_USE_dbPath', '_DO_NOT_USE_binaryDirectoryPath'];

for (const opt of internalOpts) {
for (const opt of INTERNAL_OPTIONS) {
if (suppliedOptsKeys.includes(opt)) {
console.warn(`[ mysql-memory-server - Options WARN ]: Creating MySQL database with option ${opt}. This is considered unstable and should not be used externally. Please consider removing this option.`)
}
}

const options = DEFAULT_OPTIONS_GENERATOR();

const options: InternalServerOptions = {...defaultOptions, ...opts}
for (const opt of suppliedOptsKeys) {
if (!DEFAULT_OPTIONS_KEYS.includes(opt)) {
throw `Option ${opt} is not a valid option.`
}

if (!OPTION_TYPE_CHECKS[opt].check(suppliedOpts[opt])) {
//Supplied option failed the check
throw OPTION_TYPE_CHECKS[opt].errorMessage
}

if (suppliedOpts[opt] !== undefined) {
options[opt] = suppliedOpts[opt]
}
}

const logger = new Logger(options.logLevel)

const executor = new Executor(logger)

const version = await executor.getMySQLVersion(options.version)

const unsupportedMySQLIsInstalled = version && lt(version.version, CONSTANTS.MIN_SUPPORTED_MYSQL)
const unsupportedMySQLIsInstalled = version && lt(version.version, MIN_SUPPORTED_MYSQL)

const throwUnsupportedError = unsupportedMySQLIsInstalled && !options.ignoreUnsupportedSystemVersion && !options.version

if (throwUnsupportedError) {
throw `A version of MySQL is installed on your system that is not supported by this package. If you want to download a MySQL binary instead of getting this error, please set the option "ignoreUnsupportedSystemVersion" to true.`
}

if (options.version && lt(options.version, CONSTANTS.MIN_SUPPORTED_MYSQL)) {
if (options.version && lt(options.version, MIN_SUPPORTED_MYSQL)) {
//The difference between the throw here and the throw above is this throw is because the selected "version" is not supported.
//The throw above is because the system-installed MySQL is out of date and "ignoreUnsupportedSystemVersion" is not set to true.
throw `The selected version of MySQL (${options.version}) is not currently supported by this package. Please choose a different version to use.`
Expand Down
2 changes: 1 addition & 1 deletion src/libraries/Downloader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ function extractBinary(url: string, archiveLocation: string, extractedLocation:
export function downloadBinary(binaryInfo: BinaryInfo, options: InternalServerOptions, logger: Logger): Promise<string> {
return new Promise(async (resolve, reject) => {
const {url, version} = binaryInfo;
const dirpath = options.binaryDirectoryPath
const dirpath = options._DO_NOT_USE_binaryDirectoryPath
logger.log('Binary path:', dirpath)
await fsPromises.mkdir(dirpath, {recursive: true})

Expand Down
10 changes: 5 additions & 5 deletions src/libraries/Executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ class Executor {
if (portIssue || xPortIssue) {
this.logger.log('Error log when exiting for port in use error:', errorLog)
try {
await this.#deleteDatabaseDirectory(options.dbPath)
await this.#deleteDatabaseDirectory(options._DO_NOT_USE_dbPath)
} catch (e) {
this.logger.error(e)
return reject(`MySQL failed to listen on a certain port. To restart MySQL with a different port, the database directory needed to be deleted. An error occurred while deleting the database directory. Aborting. The error was: ${e}`)
Expand All @@ -107,7 +107,7 @@ class Executor {
}

try {
if (options.deleteDBAfterStopped) {
if (options._DO_NOT_USE_deleteDBAfterStopped) {
await this.#deleteDatabaseDirectory(dbPath)
}
} catch (e) {
Expand Down Expand Up @@ -400,15 +400,15 @@ class Executor {

this.logger.log('Writing init file')

await fsPromises.writeFile(`${options.dbPath}/init.sql`, initText, {encoding: 'utf8'})
await fsPromises.writeFile(`${options._DO_NOT_USE_dbPath}/init.sql`, initText, {encoding: 'utf8'})

this.logger.log('Finished writing init file')
}

async startMySQL(options: InternalServerOptions, binaryFilepath: string): Promise<MySQLDB> {
let retries = 0;

const datadir = normalizePath(`${options.dbPath}/data`)
const datadir = normalizePath(`${options._DO_NOT_USE_dbPath}/data`)

do {
await this.#setupDataDirectories(options, binaryFilepath, datadir, true);
Expand All @@ -420,7 +420,7 @@ class Executor {

try {
this.logger.log('Starting MySQL process')
const resolved = await this.#startMySQLProcess(options, port, mySQLXPort, datadir, options.dbPath, binaryFilepath)
const resolved = await this.#startMySQLProcess(options, port, mySQLXPort, datadir, options._DO_NOT_USE_dbPath, binaryFilepath)
this.logger.log('Starting process was successful')
return resolved
} catch (e) {
Expand Down
7 changes: 1 addition & 6 deletions src/libraries/Logger.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,5 @@
import { LOG_LEVEL } from "../../types";

const LOG_LEVELS = {
'LOG': 0,
'WARN': 1,
'ERROR': 2
}
import { LOG_LEVELS } from "../constants";

class Logger {
LOG_LEVEL: number;
Expand Down
6 changes: 3 additions & 3 deletions stress-tests/stress.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,13 @@ for (let i = 0; i < 100; i++) {
const options: ServerOptions = {
username: 'dbuser',
logLevel: 'LOG',
deleteDBAfterStopped: !process.env.useCIDBPath,
_DO_NOT_USE_deleteDBAfterStopped: !process.env.useCIDBPath,
ignoreUnsupportedSystemVersion: true
}

if (process.env.useCIDBPath) {
options.dbPath = `${dbPath}/${i}`
options.binaryDirectoryPath = binaryPath
options._DO_NOT_USE_dbPath = `${dbPath}/${i}`
options._DO_NOT_USE_binaryDirectoryPath = binaryPath
}

const db = await createDB(options)
Expand Down
6 changes: 3 additions & 3 deletions tests/sql.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,13 @@ beforeEach(async () => {
const options: ServerOptions = {
username: 'root',
logLevel: 'LOG',
deleteDBAfterStopped: !process.env.useCIDBPath,
_DO_NOT_USE_deleteDBAfterStopped: !process.env.useCIDBPath,
ignoreUnsupportedSystemVersion: true
}

if (process.env.useCIDBPath) {
options.dbPath = `${dbPath}/${randomUUID()}`
options.binaryDirectoryPath = binaryPath
options._DO_NOT_USE_dbPath = `${dbPath}/${randomUUID()}`
options._DO_NOT_USE_binaryDirectoryPath = binaryPath
}

db = await createDB(options)
Expand Down
13 changes: 10 additions & 3 deletions types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,14 @@ export type InternalServerOptions = {
lockRetries: number,
lockRetryWait: number,
username: string,
deleteDBAfterStopped: boolean,
dbPath: string,
ignoreUnsupportedSystemVersion: boolean,
port: number,
xPort: number,
binaryDirectoryPath: string,
downloadRetries: number,
initSQLString: string
_DO_NOT_USE_deleteDBAfterStopped: boolean,
_DO_NOT_USE_dbPath: string,
_DO_NOT_USE_binaryDirectoryPath: string,
}

export type ExecutorOptions = {
Expand Down Expand Up @@ -76,4 +76,11 @@ export type InstalledMySQLVersion = {
export type BinaryInfo = {
url: string,
version: string
}

export type OptionTypeChecks = {
[key in keyof Required<ServerOptions>]: {
check: (opt: any) => boolean,
errorMessage: string
}
}

0 comments on commit 47e095a

Please sign in to comment.