UNPKG

@davidceres/couchreplicate

Version:

CouchDB/Cloudant database replication command-line tool

319 lines (282 loc) 8.59 kB
var cloudantqs = require('cloudant-quickstart') var async = require('async') var EventEmitter = require('events') var url = require('url') // pad string var padEnd = function (str, targetLength, padString) { if (str.length < targetLength) { do { str += padString } while (str.length < targetLength) } return str } var isEmptyObject = function (obj) { return typeof obj === 'object' && Object.keys(obj).length === 0 } // extend a URL by adding a database name // Handles present or absent trailing slash var extendURL = function (url, dbname) { if (url.match(/\/$/)) { return url + encodeURIComponent(dbname) } else { return url + '/' + encodeURIComponent(dbname) } } // get source document count before we start var getStartInfo = function (sourceURL) { return cloudantqs(sourceURL).info() } // create the _replicator database var createReplicator = function (replicatorURL) { var r = cloudantqs(replicatorURL) return r.create({indexAll: false}) } // start replicating by creating a _replicator document var startReplication = function (replicatorURL, docId, sourceURL, targetURL, live) { // mediator _replicator database var r = cloudantqs(replicatorURL) // start the replication var obj = { _id: docId, source: sourceURL, target: targetURL, create_target: true, continuous: live } return r.insert(obj) } // fetch the replication document and the target database's info var fetchReplicationStatusDocs = function (status) { // mediator _replicator database var r = cloudantqs(status.replicatorURL) // target database var t = cloudantqs(status.targetURL) return Promise.all([ r.get(status.docId), t.info() ]).then((data) => { status.status = data[0]._replication_state || 'new' status.targetDocCount = data[1].doc_count + data[1].doc_del_count if (typeof data[0]._replication_stats === 'object') { status.docFail = data[0]._replication_stats.doc_write_failures } if (process.env.DEBUG === 'couchreplicate') { console.error(JSON.stringify(data)) } return status }).catch((e) => { return status }) } // poll the replication status until it finishes correctly or in error var monitorReplication = function (status, ee) { var finished = false async.doUntil( (done) => { // after 5 seconds setTimeout(() => { // get the replication status fetchReplicationStatusDocs(status).then(() => { if (status.status === 'error' || status.status === 'failed') { status.error = true finished = true } if (status.status === 'completed') { finished = true } if (status.sourceDocCount > 0) { status.percent = status.targetDocCount / status.sourceDocCount } ee.emit('status', status) done() }) }, 5000) }, // return when finished () => { return finished }, (err, results) => { if (err || status.error) { status.status = 'error' ee.emit('status', status) ee.emit('error', status) } else { ee.emit('completed', status) } } ) } // migrate the _security document from the source to the target var migrateAuth = function (sourceURL, targetURL) { return new Promise((resolve, reject) => { var s = cloudantqs(sourceURL) var t = cloudantqs(targetURL) var securityDoc = '_security' // establish the source account's username var parsed = url.parse(sourceURL) var username = null if (parsed.auth) { username = parsed.auth.split(':')[0] } // fetch the source database's _security document s.get(securityDoc).then((data) => { // if it's empty, do nothing if (isEmptyObject(data)) { return resolve() } // remove any reference to the source database's username if (username && typeof data.cloudant === 'object') { delete data.cloudant[username] } // write the source's _security document to the target return t.update(securityDoc, data) }).then((data) => { resolve() }).catch(reject) }) } // migrate a single database from source ---> target var migrateDB = function (opts) { // sanity check URLs var sourceParsed = url.parse(opts.source) var targetParsed = url.parse(opts.target) // check source URL if (!sourceParsed.protocol || !sourceParsed.hostname) { throw new Error('invalid source URL') } // check target URL if (!targetParsed.protocol || !targetParsed.hostname) { throw new Error('invalid target URL') } // we return an event emitter so we can give real-time updates var ee = new EventEmitter() var bar = null // extract dbname var parsed = url.parse(opts.source) // turn source URL into '_replicator' database var dbname = decodeURIComponent(parsed.pathname.replace(/^\//, '')) parsed.pathname = parsed.path = '/_replicator' // status object var status = { replicatorURL: url.format(parsed), sourceURL: opts.source, targetURL: opts.target, dbname: dbname, docId: dbname.replace(/[^a-zA-Z0-9]/g, '') + '_' + (new Date()).getTime(), status: 'new', sourceDocCount: 0, targetDocCount: 0, docFail: 0, percent: 0, error: false, live: opts.live } // initialise progress bar if (!opts.quiet) { var ProgressBar = require('@magicdawn/ascii-progress') bar = new ProgressBar({ schema: ' ' + padEnd(dbname, 20, ' ') + ' [:bar.green] :percent.green :elapseds.cyan :status.blue', total: 100, status: '' }) } return new Promise((resolve, reject) => { // get source doc count getStartInfo(status.sourceURL).then((info) => { status.sourceDocCount = info.doc_count + info.doc_del_count // start the replication return startReplication(status.replicatorURL, status.docId, status.sourceURL, status.targetURL, status.live) }).then((data) => { ee.emit('status', status) // optionally migrate the auth document if (opts.auth) { return migrateAuth(status.sourceURL, status.targetURL) } else { } }).then((data) => { // monitor the replication if (opts.nomonitor && opts.live) { resolve(status) } else { return monitorReplication(status, ee) } }).catch((e) => { var msg = 'error' if (e.error && e.error.reason) { msg += ' - ' + e.error.reason } status.status = msg status.error = true ee.emit('status', status) ee.emit('error', msg) }) // receive status update events from the monitoring function ee.on('status', (s) => { if (bar) { bar.update(s.percent, { status: s.status }) } }) .on('error', (e) => { reject(e) }) .on('completed', (s) => { if (status.docFail > 0) { status.status = 'error: ' + status.docFail + ' docs failed' status.error = true } if (bar) { bar.update(s.percent, { status: s.status }) } resolve(s) }) }) } // migrate a list of documents from source --> target var migrateList = function (opts) { // enforce maximum number of continuous replications if (opts.live && opts.databases.length > 50) { throw new Error('Maximum number of continuous replications is fifty') } // ignore concurrency in live mode if (opts.live) { opts.concurrency = 50 } // get database names return new Promise((resolve, reject) => { // async queue of migrations var q = async.queue((dbname, done) => { var newopts = JSON.parse(JSON.stringify(opts)) newopts.source = extendURL(newopts.source, dbname) newopts.target = extendURL(newopts.target, dbname) migrateDB(newopts).then((data) => { done(null, data) }).catch((e) => { done(e, null) }) }, opts.concurrency) // push to the queue for (var i in opts.databases) { var dbname = opts.databases[i] if (!dbname.match(/^_/)) { q.push(dbname) } } // when the queue is drained, we're done q.drain = () => { resolve() } }) } // migrate all documents var migrateAll = function (opts) { // get db names and push to the queue var s = cloudantqs(opts.source, 'a') return s.dbs().then((data) => { opts.databases = data return migrateList(opts) }) } module.exports = { migrateDB: migrateDB, migrateList: migrateList, migrateAll: migrateAll, createReplicator: createReplicator }