import { inspect } from 'node:util'; import { MongoClient, MongoClientOptions, Db, Document, WithId, ObjectId, Filter, IndexSpecification, CreateIndexesOptions, FindOptions } 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 } type StringIndexable = {[key: string]: boolean | string | number | Document | object} const objIsSubset = (superObj: StringIndexable, subObj: StringIndexable): boolean => { return Object.keys(subObj).every(ele => { if (typeof subObj[ele] === 'object' && typeof superObj[ele] === 'object') { return objIsSubset(superObj[ele] as StringIndexable, subObj[ele] as StringIndexable); } return subObj[ele] === superObj[ele]; }); }; /** * 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?: FindOptions): 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: FindOptions = {}): 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: T, 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); } count (coll: string, query: Document) { if (!this.#db) throw new Error('MongoDB not connected'); return this.#db.collection(coll).countDocuments(query); } async ensureIndex (collection: string, index: IndexSpecification, options?: CreateIndexesOptions & StringIndexable): Promise { if (!this.#db) return Promise.reject(new Error('MongoDB not connected')); if (!(index instanceof Array)) index = [ index ]; const indexes = await this.#db.collection(collection).indexes(); const existing = indexes.find(idx => idx.name.startsWith(index)); if (existing && this.#indexesEqual(existing, options)) return; if (existing) await this.#db.collection(collection).dropIndex(existing.name); await this.#db.collection(collection).createIndex(index, options); } async ensureIndices (collection: string, indices: IndexSpecification[], options?: CreateIndexesOptions & StringIndexable): Promise { if (!this.#db) return Promise.reject(new Error('MongoDB not connected')); for (const index of indices) await this.ensureIndex(collection, index, options); } #indexesEqual (existing: Document, options?: CreateIndexesOptions & StringIndexable) { // 3 keys on the existing means that only the name was given if (!options && Object.keys(existing).length === 3) return true; else if (!options) return false; const keys = Object.keys(options); for (const key of keys) { if (typeof options[key] !== typeof existing[key]) return false; if (typeof options[key] === 'object' && !objIsSubset(existing, options)) return false; else if (options[key] !== existing[key]) return false; } return true; } } export { MongoDB };