UNPKG

mongo-collector

Version:

CLI tool to collect field values from a MongoDB collection into target collection with batching

209 lines (179 loc) 6.77 kB
import { MongoClient, ObjectId } from "mongodb"; import { performance } from "perf_hooks"; function validateConfig(cfg) { if (!cfg.source || !cfg.target || !cfg.aggregation) { console.error("❌ Error: config missing required sections (source, target, aggregation)"); process.exit(1); } if (typeof cfg.target.rewriteDocuments !== "boolean") { console.error("❌ Error: target.rewriteDocuments must be boolean."); process.exit(1); } if (typeof cfg.target.rewriteArray !== "boolean") { console.error("❌ Error: target.rewriteArray must be boolean."); process.exit(1); } if (typeof cfg.target.duplicatesInArray !== "boolean") { console.error("❌ Error: target.duplicatesInArray must be boolean."); process.exit(1); } if (typeof cfg.aggregation.allowDiskUse !== "boolean") { console.error("❌ Error: aggregation.allowDiskUse must be boolean."); process.exit(1); } } function toMaybeObjectId(id) { if (!id) return null; try { return /^[a-fA-F0-9]{24}$/.test(String(id)) ? new ObjectId(String(id)) : id; } catch { return id; } } // normalization of values function normalizeTopLevelElement(v, unwrapObjectId) { function unwrap(val) { if (val instanceof ObjectId) { return unwrapObjectId ? val.toHexString() : val; } return val; } if (Array.isArray(v)) { const inner = v.filter((x) => x != null).map(unwrap); return inner.length ? inner : null; } if (v == null) return null; return unwrap(v); } export async function runMongoCollector(config) { validateConfig(config); const { source, target, aggregation } = config; console.log("🚀 Start\n"); console.log("📥 Source"); console.log(` 🌐 URI: ${source.uri}`); console.log(` 🗄️ Database: ${source.db}`); console.log(` 📂 Collection: ${source.collection}`); console.log(` 🔑 Field: ${source.field}`); console.log(` 🔍 Match: ${JSON.stringify(source.match || {})}\n`); console.log("📤 Target"); console.log(` 🌐 URI: ${target.uri}`); console.log(` 🗄️ Database: ${target.db}`); console.log(` 📂 Collection: ${target.collection}`); console.log(` 📝 Field: ${target.field}`); console.log( ` 🏷️ documentId: ${target.documentId === false ? "false (always new doc)" : target.documentId ?? "(not provided → new doc)"}` ); console.log("\n⚙️ Config"); console.log(` 🧹 rewriteDocuments: ${target.rewriteDocuments}`); console.log(` 📂 rewriteArray: ${target.rewriteArray}`); console.log(` 🔁 duplicatesInArray: ${target.duplicatesInArray}`); console.log(` 🔓 unwrapObjectId: ${target.unwrapObjectId}`); console.log(` 💾 allowDiskUse: ${aggregation.allowDiskUse}`); console.log(` 📦 batchSize: ${aggregation.batchSize}\n`); const spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; let spinnerIndex = 0; const start = performance.now(); const spinnerInterval = setInterval(() => { const frame = spinnerFrames[spinnerIndex % spinnerFrames.length]; process.stdout.write( `\r${frame} Collecting values from '${source.db}.${source.collection}' field '${source.field}' ...` ); spinnerIndex++; }, 100); const inClient = new MongoClient(source.uri); const outClient = new MongoClient(target.uri); try { await inClient.connect(); await outClient.connect(); const inColl = inClient.db(source.db).collection(source.collection); const outColl = outClient.db(target.db).collection(target.collection); if (target.rewriteDocuments) { const delRes = await outColl.deleteMany({}); if (process.stdout.clearLine) process.stdout.clearLine(0); if (process.stdout.cursorTo) process.stdout.cursorTo(0); console.log(`🧹 Target cleared: deleted ${delRes.deletedCount} docs\n`); } const cursor = inColl.aggregate( [ { $match: source.match || {} }, { $project: { v: `$${source.field}` } }, ], { allowDiskUse: aggregation.allowDiskUse, batchSize: aggregation.batchSize } ); let batch = []; let totalCollected = 0; let docsWritten = 0; let firstChunk = false; const providedTargetId = !target.rewriteDocuments && target.documentId !== false && target.documentId != null ? toMaybeObjectId(target.documentId) : null; async function flush(arr) { if (!arr.length) return; if (!firstChunk && providedTargetId) { if (target.rewriteArray) { await outColl.updateOne( { _id: providedTargetId }, { $set: { [target.field]: arr } }, { upsert: true } ); } else { const operator = target.duplicatesInArray ? "$push" : "$addToSet"; await outColl.updateOne( { _id: providedTargetId }, { [operator]: { [target.field]: { $each: arr } } }, { upsert: true } ); } } else { await outColl.insertOne({ [target.field]: arr }); } firstChunk = true; docsWritten++; } for await (const doc of cursor) { const top = normalizeTopLevelElement(doc?.v, target.unwrapObjectId); if (top != null) { batch.push(top); totalCollected++; } if (batch.length >= aggregation.batchSize) { await flush(batch); batch = []; } } if (batch.length) { await flush(batch); } clearInterval(spinnerInterval); if (process.stdout.clearLine) process.stdout.clearLine(0); if (process.stdout.cursorTo) process.stdout.cursorTo(0); console.log("\n✅ Operation completed"); console.log(`\n📊 Total values collected: ${totalCollected.toLocaleString()}`); console.log(`🧩 Documents written: ${docsWritten.toLocaleString()}`); const duration = performance.now() - start; const minutes = Math.floor(duration / 60000); const seconds = Math.floor((duration % 60000) / 1000); const milliseconds = Math.floor(duration % 1000); let timeString = ""; if (minutes > 0) timeString += `${minutes} min `; if (seconds > 0) timeString += `${seconds} sec `; timeString += `${milliseconds} ms`; console.log(`\n⏱️ Lead time: ${timeString}`); } catch (err) { clearInterval(spinnerInterval); if (process.stdout.clearLine) process.stdout.clearLine(0); if (process.stdout.cursorTo) process.stdout.cursorTo(0); console.error("❌ Collector error:", err?.message ?? err); process.exit(1); } finally { await inClient.close().catch(() => { }); await outClient.close().catch(() => { }); } }