UNPKG

afridho-mongodb

Version:

A simple MongoDB client wrapper for easy database operations

885 lines (884 loc) 33.3 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.ObjectId = void 0; const mongodb_1 = require("mongodb"); Object.defineProperty(exports, "ObjectId", { enumerable: true, get: function () { return mongodb_1.ObjectId; } }); const dotenv_1 = __importDefault(require("dotenv")); const date_fns_1 = require("date-fns"); // Load environment variables dotenv_1.default.config(); // Define MongoDB client options const mongoOptions = { connectTimeoutMS: 30000, maxPoolSize: 20, // prevent excessive parallel connections maxIdleTimeMS: 60000, }; // Lazy getters for env vars — only validated when actually needed (not at import time). // This allows the module to be imported during build without throwing. function getDbUri() { const uri = process.env.MONGODB_URI || ""; if (!uri) { throw new Error("MONGODB_URI must be set in the environment"); } return uri; } function getDbName() { const name = process.env.DB_NAME || ""; if (!name) { throw new Error("DB_NAME must be set in the environment"); } return name; } async function getMongoClient() { if (global._mongoClient) return global._mongoClient; console.log("🌱 Connecting to MongoDB..."); const client = new mongodb_1.MongoClient(getDbUri(), mongoOptions); await client.connect(); global._mongoClient = client; return client; } /** * Class representing a MongoDB client for a specific collection. */ class ClientDB { /** * Returns the native MongoDB Db instance from the global connection. * Useful for libraries that require a direct Db object (e.g. Better-Auth). */ static async getNativeDb() { const client = await getMongoClient(); return client.db(getDbName()); } /** * Returns the native MongoDB Db instance for this instance's connection. */ async getNativeDb() { await this.connect(); return this.client.db(getDbName()); } /** * Returns the native MongoDB Db instance synchronously. * The driver will handle connection queuing in the background. */ static getNativeDbSync() { if (!global._mongoClient) { global._mongoClient = new mongodb_1.MongoClient(getDbUri(), mongoOptions); } return global._mongoClient.db(getDbName()); } /** * Creates an instance of ClientDB. * @param {string} collectionName - The name of the collection to interact with. */ constructor(collectionName) { // Use the validated DB_URI and predefined options this.collectionName = collectionName; this.collection = null; } /** * Connects to the MongoDB database and initializes the collection. * @returns {Promise<void>} */ async connect() { if (!this.collection) { this.client = await getMongoClient(); const db = this.client.db(getDbName()); this.collection = db.collection(this.collectionName); } } /** * Helper to convert _id string to ObjectId if needed * @param {Document} query - The query object to preprocess * @returns {Document} The processed query with _id converted if applicable */ preprocessQuery(query) { if (query._id && typeof query._id === "string") { try { query._id = new mongodb_1.ObjectId(query._id); } catch (_a) { // Invalid ObjectId string, leave as is or handle error if you prefer } } return query; } /** * Reads a document from the collection based on the provided query. * @param {Document} query - The query to find the document. * @returns {Promise<Document|null>} The found document or null if not found. */ async read(query) { await this.connect(); const processedQuery = this.preprocessQuery(query); return await this.collection.findOne(processedQuery); } /** * Reads all documents from the collection with optional sorting. * @param {ReadAllOptions} [options] - Optional options object. * @param {Document} [options.sort] - Optional sort object, e.g. { createdAt: 1 } for ascending. * @returns {Promise<Document[]>} An array of all documents, optionally sorted. */ async readAll(options) { await this.connect(); let cursor = this.collection.find(); if (options === null || options === void 0 ? void 0 : options.sort) { cursor = cursor.sort(options.sort); } if (options === null || options === void 0 ? void 0 : options.limit) { cursor = cursor.limit(options.limit); } if (options === null || options === void 0 ? void 0 : options.skip) { cursor = cursor.skip(options.skip); } return await cursor.toArray(); } /** * Inserts a new document into the collection. * @param {Document} data - The data to insert. * @returns {Promise<Document>} The result of the insert operation. */ async insert(data) { await this.connect(); return await this.collection.insertOne(data); } /** * Inserts multiple documents into the collection. * @param {Document[]} data - The data to insert. * @returns {Promise<Document>} The result of the insert operation. */ async insertMany(data) { await this.connect(); return await this.collection.insertMany(data); } /** * Updates a document in the collection based on the provided query. * @param {Document} query - The query to find the document to update. * @param {Document} data - The data to update. * @returns {Promise<Document>} The result of the update operation. */ async update(query, data) { await this.connect(); const processedQuery = this.preprocessQuery(query); return await this.collection.updateOne(processedQuery, { $set: data }); } /** * Updates multiple documents in the collection. * - If you pass Mongo operators ($set, $inc, etc), it will use them directly. * - If you pass a plain object, it will wrap it inside $set. * * @param {Document} query - The filter to match documents. * @param {Document} data - The update data (plain object or with operators). * @returns {Promise<Document>} The result of the update operation. */ async updateMany(query, data, options = {}) { var _a; await this.connect(); const processedQuery = this.preprocessQuery(query); const hasOperator = !Array.isArray(data) && Object.keys(data).some((key) => key.startsWith("$")); const updateDoc = Array.isArray(data) ? data : hasOperator ? data : { $set: data }; return await this.collection.updateMany(processedQuery, updateDoc, { upsert: (_a = options.upsert) !== null && _a !== void 0 ? _a : false, }); } /** * Deletes a document from the collection based on the provided query. * @param {Document} query - The query to find the document to delete. * @returns {Promise<Document>} The result of the delete operation. */ async delete(query) { await this.connect(); const processedQuery = this.preprocessQuery(query); return await this.collection.deleteOne(processedQuery); } /** * Deletes multiple documents from the collection based on the provided query. * @param {Document} query - The query to find the documents to delete. * @returns {Promise<Document>} The result of the delete operation. */ async deleteMany(query) { await this.connect(); const processedQuery = this.preprocessQuery(query); return await this.collection.deleteMany(processedQuery); } /** * Finds multiple documents in the collection based on the provided query. * @param {Document} query - The query to find the documents. * @returns {Promise<Document[]>} An array of found documents. */ async find(query, options, project) { await this.connect(); const processedQuery = this.preprocessQuery(query); let cursor = this.collection.find(processedQuery, project ? { projection: project } : undefined); if (options === null || options === void 0 ? void 0 : options.sort) { cursor = cursor.sort(options.sort); } if (options === null || options === void 0 ? void 0 : options.limit) { cursor = cursor.limit(options.limit); } if (options === null || options === void 0 ? void 0 : options.skip) { cursor = cursor.skip(options.skip); } return await cursor.toArray(); } /** * Reads multiple documents from the collection based on a query. * @param {Document} query - The filter query. * @returns {Promise<Document[]>} Array of matching documents. */ async readMany(query) { await this.connect(); const processedQuery = this.preprocessQuery(query); return await this.collection.find(processedQuery).toArray(); } /** * Gets random documents from the collection. * @param {number} [total=1] - The number of random documents to retrieve. * @returns {Promise<Document[]>} An array of random documents. */ async getRandomData(total = 1) { await this.connect(); const pipeline = [{ $sample: { size: total } }]; return await this.collection.aggregate(pipeline).toArray(); } /** * Run an aggregation pipeline on the current collection. * * @param {Array<Document>} pipeline - An array of MongoDB aggregation stages. * Example: * [ * { $unwind: "$tags" }, * { $group: { _id: "$tags", count: { $sum: 1 } } }, * { $sort: { count: -1 } }, * { $limit: 10 } * ] * * @returns {Promise<Document[]>} Resolves with the array of aggregation results. * * @throws {Error} If the aggregation query fails. */ async aggregate(pipeline = []) { try { await this.connect(); // ensure connected return await this.collection.aggregate(pipeline).toArray(); } catch (err) { console.error("Aggregate error:", err); throw err; } } /** * Gets the storage statistics for the collection. * @returns {Promise<{storageSize: number, size: number, count: number}>} The storage statistics including storageSize. */ async getStorageStats() { await this.connect(); const stats = await this.client .db(getDbName()) .command({ collStats: this.collectionName }); return { storageSize: stats.storageSize, size: stats.size, count: stats.count, }; } /** * Gets cluster-wide storage statistics. */ static async getClusterStats() { var _a; const client = await getMongoClient(); const admin = client.db().admin(); const dbList = await admin.listDatabases(); let totalDataSize = 0; let totalStorageSize = 0; let totalIndexSize = 0; const databases = []; for (const dbInfo of dbList.databases) { // Skip system databases that often have restricted access if (["admin", "local", "config"].includes(dbInfo.name)) { continue; } const db = client.db(dbInfo.name); try { const stats = await db.command({ dbStats: 1 }); totalDataSize += stats.dataSize || 0; totalStorageSize += stats.storageSize || 0; totalIndexSize += stats.indexSize || 0; databases.push({ name: dbInfo.name, dataSize: stats.dataSize, storageSize: stats.storageSize, indexSize: stats.indexSize, collections: stats.collections, objects: stats.objects, }); } catch (err) { // Only log if it's not an authorization error to keep logs clean if (!((_a = err.message) === null || _a === void 0 ? void 0 : _a.includes("Unauthorized"))) { console.error(`Error getting stats for db ${dbInfo.name}:`, err); } databases.push({ name: dbInfo.name, error: err.message, }); } } return { totalDataSize, totalStorageSize, totalIndexSize, databases, }; } /** * Closes the MongoDB connection. * @returns {Promise<void>} */ async close() { await this.client.close(); } /** * Finds documents in the current collection and dynamically joins related collections * using MongoDB's `$lookup` aggregation stage. * * This is useful when you want to "populate" data from other collections * (similar to Mongoose's populate) but in a flexible, dynamic way. * * ### Example: * ```ts * const tasks = await tasksDb.findWithRelations( * { column: "todo" }, // filter * [ * { * from: "kanban_tags", * localField: "tags", * foreignField: "value", * as: "tags" * }, * { * from: "kanban_persons", * localField: "persons", * foreignField: "_id", * as: "persons", * isObjectId: true // convert string IDs to ObjectId * } * ], * { * title: 1, * description: 1, * "tags.label": 1, * "persons.name": 1 * }, * { * sort: { createdAt: -1 }, * limit: 10, * skip: 20 * } * ); * ``` * * @param {Document} [filter={}] - MongoDB query filter. Defaults to `{}` (fetch all). * @param {Object[]} [relations=[]] - Array of relation configurations for `$lookup`. * @param {string} relations[].from - Target collection name to join with. * @param {string} relations[].localField - Field in this collection that holds the reference. * @param {string} relations[].foreignField - Field in the target collection to match against. * @param {string} relations[].as - The alias name for the joined data in the output. * @param {boolean} [relations[].isObjectId] - If `true`, will map string IDs in `localField` into ObjectId before lookup. * @param {Document} [project={}] - Optional MongoDB projection object to limit fields in the final output. * @param {ReadAllOptions} [options={}] - Optional settings like sort, skip, and limit. * * @returns {Promise<Document[]>} A promise that resolves to an array of documents with joined relations applied. */ async findWithRelations(filter = {}, relations = [], project = {}, options) { await this.connect(); const processedQuery = this.preprocessQuery(filter); const pipeline = []; if (Object.keys(processedQuery).length > 0) { pipeline.push({ $match: processedQuery }); } for (const rel of relations) { if (rel.isObjectId) { if (rel.isSingle) { // 👇 Single string → ObjectId pipeline.push({ $addFields: { [rel.localField]: { $toObjectId: `$${rel.localField}`, }, }, }); } else { // 👇 Array of strings → map to ObjectIds pipeline.push({ $addFields: { [rel.localField]: { $map: { input: `$${rel.localField}`, as: "id", in: { $toObjectId: "$$id" }, }, }, }, }); } } pipeline.push({ $lookup: { from: rel.from, localField: rel.localField, foreignField: rel.foreignField, as: rel.as, }, }); } if (options === null || options === void 0 ? void 0 : options.sort) pipeline.push({ $sort: options.sort }); if (options === null || options === void 0 ? void 0 : options.skip) pipeline.push({ $skip: options.skip }); if (options === null || options === void 0 ? void 0 : options.limit) pipeline.push({ $limit: options.limit }); if (Object.keys(project).length > 0) { pipeline.push({ $project: project }); } return await this.collection.aggregate(pipeline).toArray(); } /** * Finds a single document in the current collection and dynamically joins related collections * using MongoDB's `$lookup` aggregation stage. * * Mirip dengan `findWithRelations`, tapi hanya return satu dokumen (bukan array). * * ### Example: * ```ts * const task = await tasksDb.findOneWithRelations( * { _id: "66cfa89f3c9c7d776b5f4f10" }, * [ * { * from: "kanban_tags", * localField: "tags", * foreignField: "value", * as: "tags" * }, * { * from: "kanban_persons", * localField: "persons", * foreignField: "_id", * as: "persons", * isObjectId: true * } * ] * ); * ``` * * @param {Document} filter - MongoDB query filter. Biasanya pakai `_id`. * @param {Object[]} [relations=[]] - Array of relation configs sama seperti `findWithRelations`. * @param {Document} [project={}] - Projection untuk limit field output. * * @returns {Promise<Document | null>} A single document with joined relations, or `null`. */ async findOneWithRelations(filter, relations = [], project = {}) { await this.connect(); const processedQuery = this.preprocessQuery(filter); const pipeline = [{ $match: processedQuery }]; // relations for (const rel of relations) { if (rel.isObjectId) { if (rel.isSingle) { pipeline.push({ $addFields: { [rel.localField]: { $toObjectId: `$${rel.localField}`, }, }, }); } else { pipeline.push({ $addFields: { [rel.localField]: { $map: { input: `$${rel.localField}`, as: "id", in: { $toObjectId: "$$id" }, }, }, }, }); } } pipeline.push({ $lookup: { from: rel.from, localField: rel.localField, foreignField: rel.foreignField, as: rel.as, }, }); } if (Object.keys(project).length > 0) { pipeline.push({ $project: project }); } const results = await this.collection.aggregate(pipeline).toArray(); return results[0] || null; } /** * Counts the number of documents matching the query. * Alias for countDocuments. * @param {Document} [query={}] - Optional filter query. * @returns {Promise<number>} The count of matching documents. */ async count(query = {}) { return this.countDocuments(query); } /** * Counts the number of documents matching the query. * @param {Document} [query={}] - Optional filter query. * @returns {Promise<number>} The count of matching documents. */ async countDocuments(query = {}) { await this.connect(); const processedQuery = this.preprocessQuery(query); return await this.collection.countDocuments(processedQuery); } /** * One-time migration: convert string date fields to Date objects. * * @param fields - Which fields to convert (default: createdAt, updatedAt, startAt, endAt) * @returns number of documents updated */ async migrateDateFields(fields = ["createdAt", "updatedAt", "startAt", "endAt"]) { await this.connect(); const cursor = this.collection.find({ $or: fields.map((f) => ({ [f]: { $type: "string" } })), }); let count = 0; while (await cursor.hasNext()) { const doc = await cursor.next(); if (!doc) continue; // TS happy + runtime safe const updates = {}; for (const field of fields) { if (typeof doc[field] === "string") { updates[field] = new Date(doc[field]); } } if (Object.keys(updates).length > 0) { await this.collection.updateOne({ _id: doc._id }, { $set: updates }); count++; } } return count; } /** * INTERNAL: Connect to external cluster */ static async _connectExternal(uri) { const { MongoClient } = require("mongodb"); const client = new MongoClient(uri, { maxPoolSize: 20, connectTimeoutMS: 30000, }); await client.connect(); return client; } /** * INCREMENTAL BACKUP (NO DELETE): * - Hanya ambil dokumen yang updatedAt > lastBackupAt * - Tidak pernah hapus dokumen di backup * - Upsert-only */ static async incrementalBackupOneDatabase(params) { const { targetUri, sourceDb, targetDb } = params; const sourceClient = await ClientDB._connectExternal(process.env.MONGODB_URI); const targetClient = await ClientDB._connectExternal(targetUri); const src = sourceClient.db(sourceDb); const tgt = targetClient.db(targetDb); const metaCol = tgt.collection("_backup_meta"); const meta = await metaCol.findOne({ _id: "incremental" }); const lastBackupAt = (meta === null || meta === void 0 ? void 0 : meta.lastBackupAt) ? new Date(meta.lastBackupAt) : new Date(0); const now = new Date(); const collections = await src.listCollections().toArray(); const report = []; for (const c of collections) { const name = c.name; if (name === "_backup_meta") continue; const srcCol = src.collection(name); const tgtCol = tgt.collection(name); // ambil dokumen yang berubah const changedDocs = await srcCol .find({ updatedAt: { $gt: lastBackupAt } }) .toArray(); for (const doc of changedDocs) { const { _id, ...data } = doc; await tgtCol.updateOne({ _id: _id }, { $set: data }, { upsert: true }); } report.push({ collection: name, upserted: changedDocs.length, }); } // update checkpoint await metaCol.updateOne({ _id: "incremental" }, { $set: { lastBackupAt: now } }, { upsert: true }); await sourceClient.close(); await targetClient.close(); return { mode: "incremental", sourceDb, targetDb, lastBackupAt, executedAt: now, report, }; } /** * MULTI-DATABASE INCREMENTAL (NO DELETE) */ static async incrementalBackupManyDatabases(targetUri, dbList) { const allResults = []; for (const dbName of dbList) { const r = await ClientDB.incrementalBackupOneDatabase({ targetUri, sourceDb: dbName, targetDb: dbName, }); allResults.push(r); } return { mode: "incremental", targetUri, results: allResults, }; } /** * DELTA BACKUP (NO DELETE): * - hanya dokumen baru (yang belum ada _id nya di backup) * - tidak pernah hapus */ static async deltaBackupOneDatabase(params) { const { targetUri, sourceDb, targetDb } = params; const sourceClient = await ClientDB._connectExternal(process.env.MONGODB_URI); const targetClient = await ClientDB._connectExternal(targetUri); const src = sourceClient.db(sourceDb); const tgt = targetClient.db(targetDb); const collections = await src.listCollections().toArray(); const report = []; for (const c of collections) { const name = c.name; const srcCol = src.collection(name); const tgtCol = tgt.collection(name); // semua _id di backup const tgtDocs = await tgtCol .find({}, { projection: { _id: 1 } }) .toArray(); const tgtIds = tgtDocs.map((d) => d._id); // dokumen yang belum ada const newDocs = await srcCol .find({ _id: { $nin: tgtIds }, }) .toArray(); if (newDocs.length) { await tgtCol.insertMany(newDocs); } report.push({ collection: name, inserted: newDocs.length, }); } await sourceClient.close(); await targetClient.close(); return { mode: "delta", sourceDb, targetDb, report, }; } /** * MULTI-DATABASE DELTA (NO DELETE) */ static async deltaBackupManyDatabases(targetUri, dbList) { const allResults = []; for (const dbName of dbList) { const r = await ClientDB.deltaBackupOneDatabase({ targetUri, sourceDb: dbName, targetDb: dbName, }); allResults.push(r); } return { mode: "delta", targetUri, results: allResults, }; } static async fullSyncOneDatabase(params) { const { sourceUri, targetUri, dbName } = params; const now = new Date(); const week = (0, date_fns_1.getISOWeek)(now); const year = (0, date_fns_1.getISOWeekYear)(now); const snapshotDbName = `${dbName}-week-${week}-${year}`; const srcClient = await ClientDB._connectExternal(sourceUri); const tgtClient = await ClientDB._connectExternal(targetUri); const src = srcClient.db(dbName); const tgt = tgtClient.db(snapshotDbName); const collections = await src.listCollections().toArray(); const report = []; let totalInserted = 0; let totalUpdated = 0; let totalDeleted = 0; for (const col of collections) { const name = col.name; const srcCol = src.collection(name); const tgtCol = tgt.collection(name); // --- Load all docs (lean, no heavy fields) const srcDocs = await srcCol.find({}).toArray(); const tgtDocs = await tgtCol .find({}, { projection: { _id: 1, updatedAt: 1 } }) .toArray(); const srcMap = new Map(); const tgtMap = new Map(); srcDocs.forEach((d) => srcMap.set(String(d._id), d)); tgtDocs.forEach((d) => tgtMap.set(String(d._id), d)); const inserts = []; const updates = []; const deletes = []; // --- Compute inserts & updates for (const [id, srcDoc] of srcMap.entries()) { const tgtDoc = tgtMap.get(id); if (!tgtDoc) { inserts.push(srcDoc); } else { // If requires update const srcUpdatedAt = srcDoc.updatedAt instanceof Date ? srcDoc.updatedAt : new Date(srcDoc.updatedAt); const tgtUpdatedAt = tgtDoc.updatedAt instanceof Date ? tgtDoc.updatedAt : new Date(tgtDoc.updatedAt); if (srcUpdatedAt > tgtUpdatedAt) { updates.push(srcDoc); } } } // --- Compute deletes for (const [id, tgtDoc] of tgtMap.entries()) { if (!srcMap.has(id)) { deletes.push(tgtDoc._id); } } // --- Apply inserts if (inserts.length > 0) { await tgtCol.insertMany(inserts); } // --- Apply updates (bulkWrite) if (updates.length > 0) { const bulkOps = updates.map((doc) => { const { _id, ...data } = doc; return { updateOne: { filter: { _id }, update: { $set: data }, }, }; }); await tgtCol.bulkWrite(bulkOps, { ordered: false }); } // --- Apply deletes if (deletes.length > 0) { await tgtCol.deleteMany({ _id: { $in: deletes }, }); } report.push({ collection: name, inserted: inserts.length, updated: updates.length, deleted: deletes.length, }); totalInserted += inserts.length; totalUpdated += updates.length; totalDeleted += deletes.length; } await srcClient.close(); await tgtClient.close(); return { mode: "full-sync-diff", sourceDb: dbName, snapshotDb: snapshotDbName, inserted: totalInserted, updated: totalUpdated, deleted: totalDeleted, report, }; } static async fullSyncManyDatabases(params) { var _a; const { targetURI, dbs } = params; const keepWeeks = (_a = params.keepWeeks) !== null && _a !== void 0 ? _a : 26; const sourceUri = process.env.MONGODB_URI; const tasks = dbs.map((dbName) => ClientDB.fullSyncOneDatabase({ sourceUri, targetUri: targetURI, dbName, })); // ⭐ Parallel execution const results = await Promise.allSettled(tasks); // Run cleanup AFTER all DBs synced await ClientDB.cleanupSnapshots({ targetURI, dbs, keepWeeks, }); return { mode: "full-sync-diff", syncedDatabases: dbs, results, }; } static async cleanupSnapshots(params) { var _a; const { targetURI, dbs } = params; const keepWeeks = (_a = params.keepWeeks) !== null && _a !== void 0 ? _a : 26; const client = await ClientDB._connectExternal(targetURI); const admin = client.db().admin(); const all = await admin.listDatabases(); const names = all.databases.map((d) => d.name); const cutoff = (0, date_fns_1.subWeeks)(new Date(), keepWeeks); const cutoffWeek = (0, date_fns_1.getISOWeek)(cutoff); const cutoffYear = (0, date_fns_1.getISOWeekYear)(cutoff); for (const base of dbs) { const prefix = `${base}-week-`; const matches = names.filter((name) => name.startsWith(prefix)); for (const dbName of matches) { const parts = dbName.replace(prefix, "").split("-"); const week = Number(parts[0]); const year = Number(parts[1]); const isOld = year < cutoffYear || (year === cutoffYear && week < cutoffWeek); if (isOld) { console.log(`🗑 Removing old snapshot: ${dbName}`); await client.db(dbName).dropDatabase(); } } } await client.close(); } } exports.default = ClientDB;