This commit is contained in:
Erik 2023-04-12 14:23:41 +03:00
parent 12d95f171a
commit f79451b43d
Signed by: Navy.gif
GPG Key ID: 2532FBBB61C65A68
13 changed files with 2418 additions and 1 deletions

219
.eslintrc.json Normal file
View File

@ -0,0 +1,219 @@
{
"env": {
"es6": true,
"node": true
},
"extends": [ "eslint:recommended", "plugin:@typescript-eslint/recommended" ],
"globals": {
"Atomics": "readonly",
"SharedArrayBuffer": "readonly"
},
"parser": "@typescript-eslint/parser",
"plugins": [ "@typescript-eslint" ],
"parserOptions": {
"ecmaVersion": 2022,
"sourceType": "module"
},
"rules": {
"accessor-pairs": "warn",
"array-callback-return": "warn",
"array-bracket-newline": [ "warn", "consistent" ],
"array-bracket-spacing": [ "warn", "always", { "objectsInArrays": false, "arraysInArrays": false }],
"arrow-spacing": "warn",
"block-scoped-var": "warn",
"block-spacing": [ "warn", "always" ],
"brace-style": [ "warn", "1tbs" ],
"callback-return": "warn",
"camelcase": "warn",
"comma-dangle": [ "warn", "only-multiline" ],
"comma-spacing": [
"warn",
{
"after": true,
"before": false
}
],
"comma-style": "warn",
"computed-property-spacing": [
"warn",
"never"
],
"consistent-this": "warn",
"dot-notation": [
"warn",
{
"allowKeywords": true
}
],
"dot-location": [
"error",
"property"
],
"eol-last": [
"warn",
"never"
],
"eqeqeq": "warn",
"func-call-spacing": "warn",
"func-name-matching": "warn",
"func-names": "warn",
"func-style": "warn",
"function-paren-newline": "warn",
"generator-star-spacing": "warn",
"grouped-accessor-pairs": "warn",
"guard-for-in": "warn",
"handle-callback-err": "warn",
"id-blacklist": "warn",
"id-match": "warn",
"implicit-arrow-linebreak": "warn",
"indent": "warn",
"init-declarations": "warn",
"jsx-quotes": [ "warn", "prefer-single" ],
"key-spacing": [ "warn", { "beforeColon": false, "afterColon": true }],
"keyword-spacing": [ "warn", { "after": true, "before": true }],
"linebreak-style": [
"error",
"unix"
],
"lines-around-comment": "warn",
"lines-around-directive": "warn",
"max-classes-per-file": "warn",
"max-nested-callbacks": "warn",
"new-parens": "warn",
"no-alert": "warn",
"no-array-constructor": "warn",
"no-bitwise": "warn",
"no-buffer-constructor": "warn",
"no-caller": "warn",
"no-console": "warn",
"no-div-regex": "warn",
"no-dupe-else-if": "warn",
"no-duplicate-imports": "warn",
"no-else-return": "warn",
"no-empty-function": "warn",
"no-eq-null": "warn",
"no-eval": "warn",
"no-extend-native": "warn",
"no-extra-bind": "warn",
"no-extra-label": "warn",
"no-extra-parens": "warn",
"no-floating-decimal": "warn",
"no-implicit-coercion": "warn",
"no-implicit-globals": "warn",
"no-implied-eval": "error",
"no-import-assign": "warn",
"no-invalid-this": "warn",
"no-iterator": "warn",
"no-label-var": "warn",
"no-lone-blocks": "warn",
"no-lonely-if": "warn",
"no-loop-func": "warn",
"no-mixed-requires": "warn",
"no-multi-assign": "warn",
"no-multi-spaces": "warn",
"no-multi-str": "warn",
"no-multiple-empty-lines": "warn",
"no-native-reassign": "warn",
"no-negated-in-lhs": "warn",
"no-negated-condition": "error",
"no-nested-ternary": "warn",
"no-new": "warn",
"no-new-func": "warn",
"no-new-object": "warn",
"no-new-require": "warn",
"no-new-wrappers": "warn",
"no-octal-escape": "warn",
"no-path-concat": "warn",
"no-process-exit": "warn",
"no-proto": "warn",
"no-restricted-globals": "warn",
"no-restricted-imports": "warn",
"no-restricted-modules": "warn",
"no-restricted-properties": "warn",
"no-restricted-syntax": "warn",
"no-return-assign": "warn",
"no-return-await": "warn",
"no-script-url": "warn",
"no-self-compare": "warn",
"no-sequences": "warn",
"no-setter-return": "warn",
"no-spaced-func": "warn",
"@typescript-eslint/no-shadow": "error",
"no-tabs": "warn",
"no-template-curly-in-string": "error",
"no-throw-literal": "warn",
"no-undef-init": "error",
"no-undefined": "error",
"no-unmodified-loop-condition": "warn",
"no-unneeded-ternary": "error",
"no-unused-expressions": "warn",
"no-use-before-define": "error",
"no-useless-call": "warn",
"no-useless-computed-key": "warn",
"no-useless-concat": "warn",
"no-useless-constructor": "warn",
"no-useless-rename": "warn",
"no-useless-return": "warn",
"no-var": "warn",
"no-void": "warn",
"no-whitespace-before-property": "error",
"nonblock-statement-body-position": [ "warn", "below" ],
"object-curly-spacing": [
"warn",
"always"
],
"object-property-newline": [ "warn", { "allowAllPropertiesOnSameLine": true }],
"object-shorthand": "warn",
"one-var-declaration-per-line": "warn",
"operator-assignment": "warn",
"operator-linebreak": [ "warn", "before" ],
"padding-line-between-statements": "warn",
"padded-blocks": [ "warn", { "switches": "never" }, { "allowSingleLineBlocks": true }],
"prefer-arrow-callback": "warn",
"prefer-const": "warn",
"prefer-destructuring": "warn",
"prefer-exponentiation-operator": "warn",
"prefer-numeric-literals": "warn",
"prefer-object-spread": "error",
"prefer-promise-reject-errors": "warn",
"prefer-regex-literals": "warn",
"prefer-rest-params": "warn",
"prefer-spread": "warn",
"require-jsdoc": "warn",
"require-unicode-regexp": "warn",
"rest-spread-spacing": "warn",
"semi": "error",
"semi-spacing": "warn",
"semi-style": [
"warn",
"last"
],
"space-before-blocks": "warn",
"space-before-function-paren": [ "error", "always" ],
"space-in-parens": [
"warn",
"never"
],
"spaced-comment": [ "warn", "always" ],
"strict": "warn",
"switch-colon-spacing": "warn",
"symbol-description": "warn",
"template-curly-spacing": [
"warn",
"never"
],
"template-tag-spacing": "warn",
"unicode-bom": [
"warn",
"never"
],
"vars-on-top": "warn",
"wrap-iife": "warn",
"wrap-regex": "error",
"yield-star-spacing": "warn",
"yoda": [
"warn",
"never"
]
}
}

1
.gitignore vendored
View File

@ -38,6 +38,7 @@ bower_components
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
build
# Dependency directories
node_modules/

View File

@ -1,3 +1,10 @@
# wrappers
Various wrapper classes I use in my projects
Various wrapper classes I use in my projects.
These are specifically written for my use cases, though feel free to use.
MessageBroker: Wraps `amqp-connection-manager` and `amqplib` by extension. Ensures smooth failover when connected to a cluster.
MariaDB: Wraps `mysql`. Takes care of connection pooling whether connecting to a cluster or single instance.
MongoDB: Wraps `mongodb`. Primarily just adds helper functions.
Expected to be used together with a parent class that has a `createLogger` method, as defined in `/src/interfaces/Server.ts` and `/src/interfaces/Logger.ts`, utilising the logger from https://git.corgi.wtf/Navy.gif/logger.

3
index.ts Normal file
View File

@ -0,0 +1,3 @@
export { MessageBroker } from "./src/MessageBroker.js";
export { MariaDB } from './src/MariaDB.js';
export { MongoDB } from './src/MongoDB.js';

35
package.json Normal file
View File

@ -0,0 +1,35 @@
{
"name": "@navy.gif/wrappers",
"version": "1.0.0",
"description": "Various wrapper classes I use in my projects",
"main": "index.js",
"repository": "https://git.corgi.wtf/Navy.gif/wrappers.git",
"author": "Navy.gif",
"license": "MIT",
"private": false,
"type": "module",
"files": [
"build"
],
"scripts": {
"build": "tsc",
"test": "tsc && jest",
"release": "tsc && yarn publish",
"lint": "eslint --fix"
},
"devDependencies": {
"@types/amqplib": "^0.10.1",
"@types/mysql": "^2.15.21",
"@types/node": "^18.15.11",
"@typescript-eslint/eslint-plugin": "^5.57.1",
"@typescript-eslint/parser": "^5.57.1",
"eslint": "^8.37.0",
"typescript": "^5.0.3"
},
"dependencies": {
"amqp-connection-manager": "^4.1.12",
"amqplib": "^0.10.3",
"mongodb": "^5.2.0",
"mysql": "^2.18.1"
}
}

223
src/MariaDB.ts Normal file
View File

@ -0,0 +1,223 @@
import { inspect } from 'node:util';
import { ILogger, IServer } from './interfaces/index.js';
import mysql, { Pool, PoolCluster, PoolClusterConfig, PoolConfig, PoolConnection, FieldInfo } from 'mysql';
import { LoggerClientOptions } from './interfaces/Logger.js';
const SAFE_TO_RETRY = [ 'ER_LOCK_DEADLOCK' ];
type Credentials = {
user: string,
password: string,
host: string,
port: number,
database: string
}
type MariaOptions = {
load: boolean,
cluster: PoolClusterConfig,
client: PoolConfig,
credentials: Credentials,
loggerOptions?: LoggerClientOptions
}
type MariaError = {
code: string,
errno: number,
fatal: boolean,
sql: string,
sqlState: string,
sqlMessage:string
} & Error
class MariaDB {
#_activeQueries: number;
#afterLastQuery: (() => void) | null;
#logger: ILogger;
#_ready: boolean;
#config: MariaOptions;
#credentials: Credentials[];
#cluster: boolean;
#pool: PoolCluster | Pool | null;
constructor (server: IServer, options: MariaOptions) {
if (!server)
throw new Error('Missing reference to server!');
if (!options)
throw new Error('No config options provided!');
this.#config = options;
const { host, user, port, password, database } = options.credentials;
const hosts = host.split(',');
this.#credentials = [];
for (const remote of hosts) {
this.#credentials.push({ host: remote, user, port, password, database });
}
this.#pool = null;
this.#_ready = false;
this.#_activeQueries = 0;
this.#afterLastQuery = null;
this.#cluster = this.#credentials.length > 1;
this.#logger = server.createLogger(this, options.loggerOptions);
}
get ready () {
return this.#_ready;
}
get activeQueries () {
return this.#_activeQueries;
}
async init () {
if (this.#config.load)
return this.#logger.info('Not loading MariaDB');
this.#logger.status(`Creating${this.#cluster ? ' cluster' : ''} connection pool`);
if (this.#cluster) {
this.#pool = mysql.createPoolCluster(this.#config.cluster);
for (const creds of this.#credentials)
this.#pool.add({ ...this.#config.client, ...creds });
} else {
this.#pool = mysql.createPool({ ...this.#config.client, ...this.#credentials[0] });
}
this.#pool.on('connection', (connection) => {
this.#logger.debug(`New connection: ${connection?.threadId || null}`);
});
this.#pool.on('acquire', (connection) => {
this.#_activeQueries++;
this.#logger.debug(`Connection acquired: ${connection?.threadId || null}`);
});
this.#pool.on('enqueue', () => {
this.#logger.debug(`Query enqueued for connection`);
});
this.#pool.on('release', (connection) => {
this.#_activeQueries--;
if (!this.ready && !this.#_activeQueries && this.#afterLastQuery)
this.#afterLastQuery();
this.#logger.debug(`Connection released: ${connection?.threadId || null}`);
});
this.#logger.info(`Testing MariaDB connection`);
await new Promise<void>((resolve, reject) => {
if (!this.#pool)
return reject(new Error('Missing connection pool'));
this.#pool.getConnection((err, conn) => {
if (err)
return reject(err);
conn.release();
return resolve();
});
});
this.#logger.status(`Database connected`);
this.#_ready = true;
return this;
}
async close () {
this.#logger.status(`Shutting down database connections`);
if (!this.ready)
return Promise.resolve();
this.#_ready = false;
if (this.#_activeQueries) {
this.#logger.info(`${this.#_activeQueries} active queries still running, letting them finish`);
await this.finishQueries();
this.#logger.info(`Queries finished, shutting down`);
}
return new Promise<void>(resolve => {
if (!this.#pool)
return resolve();
this.#pool.end(() => {
this.#pool?.removeAllListeners();
resolve();
});
});
}
finishQueries () {
return new Promise<void>(resolve => {
this.#afterLastQuery = resolve;
});
}
getConnection (): Promise<PoolConnection> {
return new Promise((resolve, reject) => {
if (!this.#pool)
return reject(new Error('Pool closed'));
this.#pool.getConnection((err, conn) => {
if (err)
return reject(err);
resolve(conn);
});
});
}
/**
* Retry certain queries that are safe to retry, e.g. deadlock
* @throws {MariaError}
* @private
* */
async #_query (query: string, values: (string | number | string[] | number[])[], timeout?: number, attempts = 0): Promise<object[] | FieldInfo[] | null> {
const connection = await this.getConnection();
try {
const result = await new Promise<object[] | FieldInfo[] | undefined>((resolve, reject) => {
const q = connection.query({ timeout, sql: query }, values, (err, results, fields) => {
if (err)
reject(err);
else if (results)
resolve(results);
else
resolve(fields);
connection.release();
});
this.#logger.debug(`Constructed query: ${q.sql}`);
});
return Promise.resolve(result ?? null);
} catch (err) {
const error = err as MariaError;
// Retry safe errors // (Galera) Instance not ready for query
if ((SAFE_TO_RETRY.includes(error.code) || error.errno === 1047) && attempts < 5) //
return this.#_query(query, values, timeout, ++attempts);
return Promise.reject(error);
}
}
async query (query: string, values: (string | number | string[] | number[])[], timeout?: number) {
if (!this.ready)
return Promise.reject(new Error('MariaDB not ready'));
let batch = false;
if (values && typeof values.some === 'function')
batch = values.some(val => val instanceof Array);
this.#logger.debug(`Incoming query (batch: ${batch})\n${query}\n${inspect(values)}`);
return this.#_query(query, values, timeout);
}
q (query: string, values: (string | number | string[] | number[])[], timeout?: number) {
return this.query(query, values, timeout);
}
}
export { MariaDB };

333
src/MessageBroker.ts Normal file
View File

@ -0,0 +1,333 @@
import { ILogger, IServer } from "./interfaces/index.js";
import amqp, { AmqpConnectionManager, ChannelWrapper } from 'amqp-connection-manager';
import { Channel, ConfirmChannel, ConsumeMessage, Options } from 'amqplib';
type ExchangeDef = {
durable?: boolean,
internal?: boolean,
autoDelete?: boolean,
type?: string,
arguments?: object
}
type QueueDef = {
durable?: boolean,
messageTtl?: number,
exclusive?: boolean,
autoDelete?: boolean,
arguments?: string,
expires?: number,
deadLetterExchange?: string,
maxLength: number,
maxPriority: number
}
type BrokerOptions = {
host: string,
user: string,
pass: string,
vhost: string,
exchanges?: {
[key: string]: ExchangeDef
},
queues?: {
[key: string]: QueueDef
}
}
type InternalMessage = {
properties: object,
content: object
}
type InternalPublishMsg = {
exchange: string,
routingKey: string,
} & InternalMessage
type InternalQueueMsg = {
queue: string
} & InternalMessage
type Consumer = (content: object, msg: ConsumeMessage) => Promise<void>
type Subscriber = (content: object, msg: ConsumeMessage) => Promise<void>
class MessageBroker {
// Broker definitions
server: IServer;
hosts: string[];
username: string;
password: string;
vhost: string;
exchanges: { [key: string]: ExchangeDef };
queues: {[key: string]: QueueDef};
// Wrapper related
connection: AmqpConnectionManager | null;
channel: ChannelWrapper | null;
logger: ILogger;
subscribers: Map<string, Subscriber[]>;
consumers: Map<string, {consumer: Consumer, options: Options.Consume}[]>;
_pQueue: InternalPublishMsg[];
_qQueue: InternalQueueMsg[];
_qTO: NodeJS.Timeout | null;
constructor (server: IServer, options: BrokerOptions) {
this.server = server;
this.hosts = options.host.split(',');
this.username = options.user;
this.password = options.pass;
this.vhost = options.vhost || '';
this.exchanges = options.exchanges || {};
this.queues = options.queues || {};
this.connection = null;
this.channel = null;
this.logger = server.createLogger(this);
// Exchanges
this.subscribers = new Map();
// Queues
this.consumers = new Map();
// Internal queues for trying durign connection outage
this._pQueue = [];
this._qQueue = [];
this._qTO = null;
}
async init () {
this.logger.info('Initialising message broker');
const credentials = this.username ? `${this.username}:${this.password}@` : '';
const connectionStrings = this.hosts.map(host => `amqp://${credentials}${host}/${this.vhost}`);
this.connection = await amqp.connect(connectionStrings);
this.connection.on('disconnect', async ({ err }) => {
this.logger.status(`Disconnected: ${err.message}`);
await this.channel?.close();
this.channel = null;
});
this.connection.on('blocked', ({ reason }) => {
this.logger.status(`Blocked: ${reason}`);
});
this.connection.on('connectFailed', ({ err }) => {
this.logger.error(`Message broker failed to connect: ${err.stack || err.message}`);
});
this.connection.on('connect', async ({ url }) => {
this.logger.status(`Message broker connected to ${url}`);
});
await new Promise((resolve) => {
this.connection?.once('connect', resolve);
});
await this.createChannel();
this.connection.on('connect', this.createChannel.bind(this));
}
async createChannel () {
const exchanges = Object.entries(this.exchanges);
const queues = Object.entries(this.queues);
if (this.channel) {
this.logger.debug('Closing old channel');
await this.channel.close().catch(err => this.logger.error(err.stack));
}
if (!this.connection)
throw new Error();
this.logger.debug('Creating channel');
this.channel = this.connection.createChannel({
setup: async (channel: Channel | ConfirmChannel) => {
for (const [ name, props ] of exchanges) {
await channel.assertExchange(name, props.type ?? 'fanout', props);
}
for (const [ name, props ] of queues) {
await channel.assertQueue(name, props);
}
}
});
this.channel.on('close', () => this.logger.status('Channel closed'));
this.channel.on('connect', () => this.logger.status('Channel connected'));
this.channel.on('error', (err, info) => this.logger.error(`${info.name}: ${err.stack}`));
await new Promise((resolve, reject) => {
if (!this.channel)
return reject(new Error('Missing channel?'));
this.channel.once('error', reject);
this.channel.once('connect', resolve);
});
// If the create channel function was called as a result of a failover we'll have to resubscribe
await this._restoreListeners();
// Processes the interal queues of undelivered messages
await this._processQueues();
}
// Consume queue
async consume (queue: string, consumer: Consumer, options: Options.Consume) {
if (!this.channel)
throw new Error('Channel does not exist');
this.logger.debug(`Adding queue consumer for ${queue}`);
await this._consume(queue, consumer, options);
const list = this.consumers.get(queue) ?? [];
list.push({ consumer, options });
this.consumers.set(queue, list);
}
private async _consume (queue: string, consumer: Consumer, options: Options.Consume) {
if (!this.channel)
return Promise.reject(new Error('Channel doesn\'t exist'));
await this.channel.consume(queue, async (msg: ConsumeMessage) => {
if (msg.content)
await consumer(JSON.parse(msg.content.toString()), msg);
this.channel?.ack(msg);
}, options);
}
// Subscribe to exchange, ensures messages aren't lost by binding it to a queue
async subscribe (name: string, listener: Subscriber) {
if (!this.channel)
throw new Error('Channel does not exist');
this.logger.debug(`Subscribing to ${name}`);
// if (!this.subscribers.has(name))
await this._subscribe(name, listener);
const list = this.subscribers.get(name) ?? [];
list.push(listener);
this.subscribers.set(name, list);
}
private async _subscribe (name: string, listener: Subscriber) {
if (!this.channel)
return Promise.reject(new Error('Channel doesn\'t exist'));
await this.channel.assertExchange(name, 'fanout', { durable: true });
const queue = await this.channel.assertQueue('', { exclusive: true });
await this.channel.bindQueue(queue.queue, name, '');
await this.channel.consume(queue.queue, async (msg) => {
if (msg.content)
await listener(JSON.parse(msg.content.toString()), msg);
this.channel?.ack(msg);
});
}
// Add item to queue
async enqueue (queue: string, content: object, headers: object) {
const properties = {
persistent: true,
contentType: 'application/json',
headers
};
// The channel is null while the failover is occurring, so enqueue messages for publishing once the connection is restored
if (!this.channel)
return this._qQueue.push({ queue, content, properties });
try {
await this.channel.sendToQueue(queue, Buffer.from(JSON.stringify(content)), properties);
} catch (_) {
this._qQueue.push({ queue, content, properties });
if (!this._qTO)
this._qTO = setTimeout(this._processQueues.bind(this), 5000);
}
}
// Publish to exchange
async publish (exchange: string, content: object, { headers, routingKey = '' }: {headers?: string, routingKey?: string} = {}) {
const properties = {
contentType: 'application/json',
headers
};
// The channel is null while the failover is occurring, so enqueue messages for publishing once the connection is restored
if (!this.channel)
return this._pQueue.push({ exchange, content, routingKey, properties });
try {
await this.channel.publish(exchange, routingKey, Buffer.from(JSON.stringify(content)), properties);
} catch (err) {
const error = err as Error;
if (!error.message.includes('nack'))
this.logger.error(`Error while publishing to ${exchange}:\n${error.stack}`);
this._pQueue.push({ exchange, content, routingKey, properties });
if (!this._qTO)
this._qTO = setTimeout(this._processQueues.bind(this), 5000);
}
}
assertExchange (exchange: string, props: ExchangeDef) {
if (!this.channel)
throw new Error('Channel doesn\'t exist');
return this.channel.assertExchange(exchange, props.type ?? 'fanout', props);
}
assertQueue (queue: string, opts: QueueDef) {
if (!this.channel)
throw new Error('Channel doesn\'t exist');
return this.channel.assertQueue(queue, opts);
}
// Processes messages queued up while the broker was unreachable
private async _processQueues () {
if (!this.channel)
throw new Error('Channel doesn\'t exist');
this.logger.status('Processing queues of unsent messages');
const pQ = [ ...this._pQueue ];
this._pQueue = [];
for (const msg of pQ) {
const { exchange, content, routingKey, properties } = msg;
const result = await this.channel.publish(exchange, routingKey, Buffer.from(JSON.stringify(content)), properties).catch(() => null);
if (!result)
this._pQueue.push(msg);
}
const qQ = [ ...this._qQueue ];
for (const msg of qQ) {
const { queue, content, properties } = msg;
const result = await this.channel.sendToQueue(queue, Buffer.from(JSON.stringify(content)), properties).catch(() => null);
if (!result)
this._qQueue.push(msg);
}
this.logger.status('Done processing');
if (this._qTO) {
clearTimeout(this._qTO);
this._qTO = null;
}
}
private async _restoreListeners () {
this.logger.status(`Restoring ${this.consumers.size} consumers`);
for (const [ name, list ] of this.consumers) {
this.logger.debug(`Processing consumer ${name}: ${list.length}`);
for (const { consumer, options } of list) {
await this._consume(name, consumer, options);
}
}
this.logger.status(`Restoring ${this.subscribers.size} subscribers`);
for (const [ name, list ] of this.subscribers) {
this.logger.debug(`Processing subscriber ${name}: ${list.length}`);
for (const subscriber of list) {
await this._subscribe(name, subscriber);
}
}
this.logger.status(`Done restoring`);
}
}
export { MessageBroker };

316
src/MongoDB.ts Normal file
View File

@ -0,0 +1,316 @@
import { inspect } from "node:util";
import { MongoClient, MongoClientOptions, Db } from "mongodb";
import { IServer, ILogger, LoggerClientOptions } from "./interfaces/index.js";
type Credentials = {
URI: string,
user: string,
password: string,
host: string,
port: number,
database: string,
authDb: string
}
type MongoOptions = {
credentials: Credentials,
loggerOptions: LoggerClientOptions,
client: MongoClientOptions
}
/**
* A dedicated class to locally wrap the mongodb API wrapper
*
* @class MongoDB
*/
class MongoDB {
#database: string;
#config: MongoOptions;
#logger: ILogger;
#URI: string;
#db: Db | null;
#client: MongoClient;
constructor (server: IServer, config: MongoOptions) {
if (!server)
throw new Error('Missing reference to server!');
if (!config)
throw new Error('No config options provided!');
const { user, password, host, port, database, URI, authDb } = config.credentials;
if ((!host?.length || !port || !database?.length) && !URI)
throw new Error(`Must provide host, port, and database OR URI parameters!`);
this.#config = config;
this.#db = null; // DB connection
this.#database = database; // Which database to connect to
this.#logger = server.createLogger(this, config.loggerOptions);
if (URI) {
this.#URI = URI;
} else {
let AUTH_DB = authDb;
const auth = user ? `${user}:${password}@` : '';
if (!AUTH_DB && auth) {
this.#logger.warn(`No explicit auth db provided with MONGO_AUTH_DB, will attempt to use MONGO_DB for auth source`);
AUTH_DB = authDb;
} else if (!auth) {
this.#logger.warn(`No auth provided, proceeding without`);
}
this.#URI = `mongodb://${auth}${host}:${port}/${AUTH_DB || ''}?readPreference=secondaryPreferred`;
}
this.#client = new MongoClient(this.#URI, this.#config.client);
// TODO figure out reconnecting to DB when connection fails
this.#client.on('error', (error) => this.#logger.error(`MongoDB error:\n${error.stack}`))
.on('timeout', () => this.#logger.warn(`MongoDB timed out`))
.on('close', () => this.#logger.info(`MongoDB client disconnected`))
.on('open', () => this.#logger.info(`MongoDB client connected`));
}
/**
* Initialises the connection to Mongo
*
* @memberof MongoDB
*/
async init () {
this.#logger.status(`Initializing database connection to ${this.#client.options.hosts}`);
await this.#client.connect();
this.#logger.debug(`Connected, selecting DB`);
this.#db = await this.#client.db(this.#database);
this.#logger.status('MongoDB ready');
return this;
}
async close () {
this.#logger.status('Closing database connection');
await this.#client.close();
this.#db = null;
}
get mongoClient () {
return this.#client;
}
/**
* Find and return the first match
*
* @param {String} db The collection in which the data is to be updated
* @param {Object} query The filter that is used to find the data
* @returns {Array} An array containing the corresponding objects for the query
* @memberof Database
*/
async find (db: string, query: object, options: object) {
if (!this.#db)
throw new Error(`MongoDB not connected`);
if (typeof db !== 'string')
throw new TypeError('Expecting collection name for the first argument');
this.#logger.debug(`Incoming find query for ${db} with parameters ${inspect(query)}`);
const cursor = this.#db.collection(db).find(query, options);
return cursor.toArray();
}
/**
* Find and return the first match
*
* @param {String} db The collection in which the data is to be updated
* @param {Object} query The filter that is used to find the data
* @returns {Object} An object containing the queried data
* @memberof Database
*/
async findOne (db: string, query: object, options = {}) {
if (!this.#db)
throw new Error(`MongoDB not connected`);
if (typeof db !== 'string')
throw new TypeError('Expecting collection name for the first argument');
this.#logger.debug(`Incoming findOne query for ${db} with parameters ${inspect(query)}`);
const result = await this.#db.collection(db).findOne(query, options);
return result;
}
/**
* Update any and all filter matches.
*
* @param {String} db The collection in which the data is to be updated
* @param {Object} filter The filter that is used to find the data
* @param {Object} data The updated data
* @returns {WriteResult} Object containing the followint counts: Matched, Upserted, Modified
* @memberof Database
*/
async updateMany (db: string, filter: object, data: object, upsert = false) {
if (!this.#db)
throw new Error(`MongoDB not connected`);
if (typeof db !== 'string')
throw new TypeError('Expecting collection name for the first argument');
if (!filter)
throw new Error(`Cannot run update many without a filter, if you mean to update every single document, pass an empty object`);
this.#logger.debug(`Incoming update query for '${db}' with parameters\n${inspect(filter)}\nand data\n${inspect(data)}`);
const result = await this.#db.collection(db).updateMany(filter, { $set: data }, { upsert });
return result;
}
/**
* Update the first filter match.
*
* @param {String} db The collection in which the data is to be updated
* @param {Object} filter The filter that is used to find the data
* @param {Object} data The updated data
* @returns {WriteResult} Object containing the followint counts: Matched, Upserted, Modified
* @memberof Database
*/
async updateOne (db: string, filter: object, data: object, upsert = false) {
if (!this.#db)
throw new Error(`MongoDB not connected`);
if (typeof db !== 'string')
throw new TypeError('Expecting collection name for the first argument');
this.#logger.debug(`Incoming updateOne query for ${db} with parameters ${inspect(filter)}`);
const result = await this.#db.collection(db).updateOne(filter, { $set: data }, { upsert });
return result;
}
/**
* Insert document.
*
* @param {String} db The collection in which the data is to be updated
* @param {Object} filter The filter that is used to find the data
* @param {Object} data The updated data
* @returns {WriteResult} Object containing the followint counts: Matched, Upserted, Modified
* @memberof Database
*/
async insertOne (db: string, data: object) {
if (!this.#db)
throw new Error(`MongoDB not connected`);
if (typeof db !== 'string')
throw new TypeError('Expecting collection name for the first argument');
this.#logger.debug(`Incoming insertOne query for ${db} with parameters ${inspect(data)}`);
const result = await this.#db.collection(db).insertOne(data);
return result;
}
async deleteOne (db: string, filter: object) {
if (!this.#db)
throw new Error(`MongoDB not connected`);
if (typeof db !== 'string')
throw new TypeError('Expecting collection name for the first argument');
this.#logger.debug(`Incoming deleteOne query for ${db} with parameters ${inspect(filter)}`);
const result = await this.#db.collection(db).deleteOne(filter);
return result;
}
/**
* Push data to an array
*
* @param {string} db The collection to query
* @param {object} filter The filter to find the document to update
* @param {object} data The data to be pushed
* @param {boolean} [upsert=false]
* @returns
* @memberof Database
*/
async push (db: string, filter: object, data: object, upsert = false) {
if (!this.#db)
throw new Error(`MongoDB not connected`);
if (typeof db !== 'string')
throw new TypeError('Expecting collection name for the first argument');
this.#logger.debug(`Incoming push query for ${db}, with upsert ${upsert} and with parameters ${inspect(filter)} and data ${inspect(data)}`);
const result = await this.#db.collection(db).updateOne(filter, { $push: data }, { upsert });
return result;
}
/**
* Find a random element from a database
*
* @param {string} db The collection to query
* @param {object} [filter={}] The filtering object to narrow down the sample pool
* @param {number} [amount=1] Amount of items to return
* @returns {object}
* @memberof Database
*/
random (db: string, filter = {}, amount = 1) {
if (!this.#db)
throw new Error(`MongoDB not connected`);
if (typeof db !== 'string')
throw new TypeError('Expecting collection name for the first argument');
this.#logger.debug(`Incoming random query for ${db} with parameters ${inspect(filter)} and amount ${amount}`);
if (amount > 100)
amount = 100;
const cursor = this.#db.collection(db).aggregate([{ $match: filter }, { $sample: { size: amount } }]);
return cursor.toArray();
}
stats (options = {}) {
if (!this.#db)
throw new Error(`MongoDB not connected`);
const result = this.#db.stats(options);
return result;
}
collection (coll: string) {
if (!this.#db)
throw new Error(`MongoDB not connected`);
return this.#db.collection(coll);
}
async ensureIndex (collection: string, indices = []) {
if (!this.#db)
throw new Error(`MongoDB not connected`);
if (!(indices instanceof Array))
indices = [ indices ];
await this.#db.collection(collection).createIndex(indices);
}
async getKey (key: string, collection = 'memoryStore') {
const response = await this.findOne(collection, { key });
if (response)
return response.value;
return null;
}
async setKey (key: string, value: object, collection = 'memoryStore') {
await this.updateOne(collection, { key }, { value }, true);
return value;
}
}
export { MongoDB };

14
src/interfaces/Logger.ts Normal file
View File

@ -0,0 +1,14 @@
export type LoggerClientOptions = {
guard: string,
customStreams: string[],
logLevel: number,
logLevelMapping: {[key: string]: number}
}
export interface ILogger {
info(str: string): void
status(str: string): void
debug(str: string): void
warn(str: string): void
error(str: string): void
}

5
src/interfaces/Server.ts Normal file
View File

@ -0,0 +1,5 @@
import { ILogger, LoggerClientOptions } from "./Logger.js";
export interface IServer {
createLogger(obj: object, options?: LoggerClientOptions): ILogger
}

2
src/interfaces/index.ts Normal file
View File

@ -0,0 +1,2 @@
export { ILogger, LoggerClientOptions } from './Logger.js';
export { IServer } from './Server.js';

109
tsconfig.json Normal file
View File

@ -0,0 +1,109 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig to read more about this file */
/* Projects */
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "ES2022", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
"lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */
// "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
/* Modules */
"module": "ES2022", /* Specify what module code is generated. */
// "rootDir": "./", /* Specify the root folder within your source files. */
"moduleResolution": "nodenext", /* Specify how TypeScript looks up a file from a given module specifier. */
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
// "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
// "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
// "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
// "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
// "resolveJsonModule": true, /* Enable importing .json files. */
// "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
/* Emit */
"declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
"outDir": "./build", /* Specify an output folder for all emitted files. */
// "removeComments": true, /* Disable emitting comments. */
// "noEmit": true, /* Disable emitting files from a compilation. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
// "newLine": "crlf", /* Set the newline character for emitting files. */
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
/* Interop Constraints */
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
// "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
/* Type Checking */
"strict": true, /* Enable all strict type-checking options. */
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
}
}

1150
yarn.lock Normal file

File diff suppressed because it is too large Load Diff