import { inspect } from "node:util"; import { MongoClient, MongoClientOptions, Db, Document, WithId, ObjectId, Filter, IndexSpecification } 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 MongoQuery = { _id?: unknown, [key: string]: unknown } export type MongoOptions = { credentials: Credentials, loggerOptions?: LoggerClientOptions, client?: MongoClientOptions, load?: boolean } /** * 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`)); } get database () { return this.#_database; } get client () { return this.#_client; } /** * Initialises the connection to Mongo * * @memberof MongoDB */ async init () { if (!this.#config.load) return this.#logger.info('Not loading MongoDB'); if (this.#db) throw new Error('Database already connected'); 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 () { if (!this.#db) return; this.#logger.status('Closing database connection'); await this.#_client.close(); this.#db = null; this.#logger.status('Database closed'); } 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: MongoQuery, options?: object): Promise[]> { if (!this.#db) throw new Error(`MongoDB not connected`); if (typeof db !== 'string') throw new TypeError('Expecting collection name for the first argument'); if (typeof query._id === 'string') query._id = new ObjectId(query._id); this.#logger.debug(`Incoming find query for ${db} with parameters ${inspect(query)}`); const cursor = this.#db.collection(db).find(query as Filter, 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: MongoQuery, options = {}): Promise | null> { if (!this.#db) throw new Error(`MongoDB not connected`); if (typeof db !== 'string') throw new TypeError('Expecting collection name for the first argument'); if (typeof query._id === 'string' && query._id.length === 12) query._id = new ObjectId(query._id); this.#logger.debug(`Incoming findOne query for ${db} with parameters ${inspect(query)}`); const result = await this.#db.collection(db).findOne(query as Filter, 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: MongoQuery, data: Document, 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`); if (typeof filter._id === 'string') filter._id = new ObjectId(filter._id); 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 as 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: MongoQuery, data: Document, 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 (typeof filter._id === 'string') filter._id = new ObjectId(filter._id); this.#logger.debug(`Incoming updateOne query for ${db} with parameters ${inspect(filter)}`); const result = await this.#db.collection(db).updateOne(filter as 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: Document) { if (!this.#db) throw new Error(`MongoDB not connected`); if (typeof db !== 'string') throw new TypeError('Expecting collection name for the first argument'); if (typeof data._id === 'string') data._id = new ObjectId(data._id); 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: Document) { if (!this.#db) throw new Error(`MongoDB not connected`); if (typeof db !== 'string') throw new TypeError('Expecting collection name for the first argument'); if (typeof filter._id === 'string') filter._id = new ObjectId(filter._id); 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: Document, 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 (typeof filter._id === 'string') filter._id = new ObjectId(filter._id); 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: Document = {}, 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'); if (typeof filter._id === 'string') filter._id = new ObjectId(filter._id); 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: IndexSpecification = []) { if (!this.#db) throw new Error(`MongoDB not connected`); if (!(indices instanceof Array)) indices = [ indices ]; await this.#db.collection(collection).createIndex(indices); } } export { MongoDB };