diff --git a/index.js b/index.js index f665524a..f9d06cff 100644 --- a/index.js +++ b/index.js @@ -1,445 +1,781 @@ -var async = require('async'); -var mongodb = require('mongodb'); -var DB = require('@teamwork/sharedb').DB; +const async = require('async'); +const mongodb = require('mongodb'); +const DB = require('@teamwork/sharedb').DB; -module.exports = ShareDbMongo; -function ShareDbMongo(mongo, options) { - // use without new - if (!(this instanceof ShareDbMongo)) { - return new ShareDbMongo(mongo, options); - } +class ShareDbMongo extends DB { + constructor(mongo, options) { + super(); - if (typeof mongo === 'object') { - options = mongo; - mongo = options.mongo; + if (typeof mongo === 'object') { + options = mongo; + mongo = options.mongo; + } + if (!options) options = {}; + + // pollDelay is a dodgy hack to work around race conditions replicating the + // data out to the polling target secondaries. If a separate db is specified + // for polling, it defaults to 300ms + this.pollDelay = (options.pollDelay != null) ? options.pollDelay : + (options.mongoPoll) ? 300 : 0; + + // By default, we create indexes on any ops collection that is used + this.disableIndexCreation = options.disableIndexCreation || false; + + // The getOps() method depends on a separate operations collection, and that + // collection should have an index on the operations stored there. We could + // ask people to make these indexes themselves, but by default the mongo + // driver will do it automatically. This approach will leak memory relative + // to the number of collections you have. This should be OK, as we are not + // expecting thousands of mongo collections. + + // Map from collection name -> true for op collections we've ensureIndex'ed + this.opIndexes = {}; + + // Allow $while and $mapReduce queries. These queries let you run arbitrary + // JS on the server. If users make these queries from the browser, there's + // security issues. + this.allowJSQueries = options.allowAllQueries || options.allowJSQueries || false; + + // Aggregate queries are less dangerous, but you can use them to access any + // data in the mongo database. + this.allowAggregateQueries = options.allowAllQueries || options.allowAggregateQueries || false; + + // Track whether the close method has been called + this.closed = false; + + if (typeof mongo === 'string' || typeof mongo === 'function') { + // We can only get the mongodb client instance in a callback, so + // buffer up any requests received in the meantime + this.mongo = null; + this.mongoPoll = null; + this.pendingConnect = []; + this._connect(mongo, options); + } else { + throw new Error('deprecated: pass mongo as url string or function with callback'); + } } - if (!options) options = {}; - - // pollDelay is a dodgy hack to work around race conditions replicating the - // data out to the polling target secondaries. If a separate db is specified - // for polling, it defaults to 300ms - this.pollDelay = (options.pollDelay != null) ? options.pollDelay : - (options.mongoPoll) ? 300 : 0; - - // By default, we create indexes on any ops collection that is used - this.disableIndexCreation = options.disableIndexCreation || false; - // The getOps() method depends on a separate operations collection, and that - // collection should have an index on the operations stored there. We could - // ask people to make these indexes themselves, but by default the mongo - // driver will do it automatically. This approach will leak memory relative - // to the number of collections you have. This should be OK, as we are not - // expecting thousands of mongo collections. - - // Map from collection name -> true for op collections we've ensureIndex'ed - this.opIndexes = {}; + getCollection(collectionName, callback) { + // Check the collection name + const err = this.validateCollectionName(collectionName); + if (err) return callback(err); + // Gotcha: calls back sync if connected or async if not + this.getDbs((err, mongo) => { + if (err) return callback(err); + const collection = mongo.db().collection(collectionName); + return callback(null, collection); + }); + } - // Allow $while and $mapReduce queries. These queries let you run arbitrary - // JS on the server. If users make these queries from the browser, there's - // security issues. - this.allowJSQueries = options.allowAllQueries || options.allowJSQueries || false; + _getCollectionPoll(collectionName, callback) { + // Check the collection name + const err = this.validateCollectionName(collectionName); + if (err) return callback(err); + // Gotcha: calls back sync if connected or async if not + this.getDbs((err, mongo, mongoPoll) => { + if (err) return callback(err); + const collection = (mongoPoll || mongo).db().collection(collectionName); + return callback(null, collection); + }); + } - // Aggregate queries are less dangerous, but you can use them to access any - // data in the mongo database. - this.allowAggregateQueries = options.allowAllQueries || options.allowAggregateQueries || false; + getCollectionPoll(collectionName, callback) { + if (this.pollDelay) { + setTimeout(() => { + this._getCollectionPoll(collectionName, callback); + }, this.pollDelay); + return; + } + this._getCollectionPoll(collectionName, callback); + } - // Track whether the close method has been called - this.closed = false; + getDbs(callback) { + if (this.closed) { + const err = ShareDbMongo.alreadyClosedError(); + return callback(err); + } + // We consider ourself ready to reply if this.mongo is defined and don't check + // this.mongoPoll, since it is optional and is null by default. Thus, it's + // important that these two properties are only set together synchronously + if (this.mongo) return callback(null, this.mongo, this.mongoPoll); + this.pendingConnect.push(callback); + } - if (typeof mongo === 'string' || typeof mongo === 'function') { - // We can only get the mongodb client instance in a callback, so - // buffer up any requests received in the meantime - this.mongo = null; - this.mongoPoll = null; - this.pendingConnect = []; - this._connect(mongo, options); - } else { - throw new Error('deprecated: pass mongo as url string or function with callback'); + _flushPendingConnect() { + const pendingConnect = this.pendingConnect; + this.pendingConnect = null; + for (let i = 0; i < pendingConnect.length; i++) { + pendingConnect[i](null, this.mongo, this.mongoPoll); + } } -}; -ShareDbMongo.prototype = Object.create(DB.prototype); + _mongodbOptions(options) { + if(options instanceof Object) { + return Object.assign(Object.assign({}, options.mongoOptions), { useNewUrlParser: true }) + } else { + return { useNewUrlParser: true }; + } + } -ShareDbMongo.prototype.projectsSnapshots = true; + _connect(mongo, options) { + // Create the mongo connection client connections if needed + // + // Throw errors in this function if we fail to connect, since we aren't + // implementing a way to retry + if (options.mongoPoll) { + let tasks; + if (typeof mongo === 'function') { + tasks = {mongo, mongoPoll: options.mongoPoll}; + } else { + tasks = { + mongo: (parallelCb) => { + mongodb.connect(mongo, this._mongodbOptions(options.mongoOptions), parallelCb); + }, + mongoPoll: (parallelCb) => { + mongodb.connect(options.mongoPoll, this._mongodbOptions(options.mongoPollOptions), parallelCb); + } + }; + } + async.parallel(tasks, (err, results) => { + if (err) throw err; + this.mongo = results.mongo; + this.mongoPoll = results.mongoPoll; + this._flushPendingConnect(); + }); + return; + } + const finish = (err, db) => { + if (err) throw err; + this.mongo = db; + this._flushPendingConnect(); + }; + if (typeof mongo === 'function') { + mongo(finish); + return; + } + mongodb.connect(mongo, this._mongodbOptions(options), finish); + } -ShareDbMongo.prototype.getCollection = function(collectionName, callback) { - // Check the collection name - var err = this.validateCollectionName(collectionName); - if (err) return callback(err); - // Gotcha: calls back sync if connected or async if not - this.getDbs(function(err, mongo) { - if (err) return callback(err); - var collection = mongo.db().collection(collectionName); - return callback(null, collection); - }); -}; + close(callback) { + if (!callback) { + callback = err => { + if (err) throw err; + }; + } + this.getDbs((err, mongo, mongoPoll) => { + if (err) return callback(err); + this.closed = true; + mongo.close(err => { + if (err) return callback(err); + if (!mongoPoll) return callback(); + mongoPoll.close(callback); + }); + }); + } -ShareDbMongo.prototype._getCollectionPoll = function(collectionName, callback) { - // Check the collection name - var err = this.validateCollectionName(collectionName); - if (err) return callback(err); - // Gotcha: calls back sync if connected or async if not - this.getDbs(function(err, mongo, mongoPoll) { - if (err) return callback(err); - var collection = (mongoPoll || mongo).db().collection(collectionName); - return callback(null, collection); - }); -}; + // **** Commit methods -ShareDbMongo.prototype.getCollectionPoll = function(collectionName, callback) { - if (this.pollDelay) { - var self = this; - setTimeout(function() { - self._getCollectionPoll(collectionName, callback); - }, this.pollDelay); - return; + commit(collectionName, id, op, snapshot, options, callback) { + this._writeOp(collectionName, id, op, snapshot, (err, result) => { + if (err) return callback(err); + const opId = result.insertedId; + this._writeSnapshot(collectionName, id, snapshot, opId, (err, succeeded) => { + if (succeeded) return callback(err, succeeded); + // Cleanup unsuccessful op if snapshot write failed. This is not + // necessary for data correctness, but it gets rid of clutter + this._deleteOp(collectionName, opId, removeErr => { + callback(err || removeErr, succeeded); + }); + }); + }); } - this._getCollectionPoll(collectionName, callback); -}; -ShareDbMongo.prototype.getDbs = function(callback) { - if (this.closed) { - var err = ShareDbMongo.alreadyClosedError(); - return callback(err); + _writeOp(collectionName, id, op, snapshot, callback) { + if (typeof op.v !== 'number') { + const err = ShareDbMongo.invalidOpVersionError(collectionName, id, op.v); + return callback(err); + } + this.getOpCollection(collectionName, (err, opCollection) => { + if (err) return callback(err); + const doc = shallowClone(op); + doc.d = id; + doc.o = snapshot._opLink; + opCollection.insertOne(doc, callback); + }); } - // We consider ouself ready to reply if this.mongo is defined and don't check - // this.mongoPoll, since it is optional and is null by default. Thus, it's - // important that these two properties are only set together synchronously - if (this.mongo) return callback(null, this.mongo, this.mongoPoll); - this.pendingConnect.push(callback); -}; -ShareDbMongo.prototype._flushPendingConnect = function() { - var pendingConnect = this.pendingConnect; - this.pendingConnect = null; - for (var i = 0; i < pendingConnect.length; i++) { - pendingConnect[i](null, this.mongo, this.mongoPoll); + _deleteOp(collectionName, opId, callback) { + this.getOpCollection(collectionName, (err, opCollection) => { + if (err) return callback(err); + opCollection.deleteOne({_id: opId}, callback); + }); } -}; -ShareDbMongo.prototype._mongodbOptions = function(options) { - if(options instanceof Object) { - return Object.assign(Object.assign({}, options.mongoOptions), { useNewUrlParser: true }) - } else { - return { useNewUrlParser: true }; + _writeSnapshot(collectionName, id, snapshot, opLink, callback) { + this.getCollection(collectionName, (err, collection) => { + if (err) return callback(err); + const doc = castToDoc(id, snapshot, opLink); + if (doc._v === 1) { + collection.insertOne(doc, (err, result) => { + if (err) { + // Return non-success instead of duplicate key error, since this is + // expected to occur during simultaneous creates on the same id + if (err.code === 11000 && /\b_id_\b/.test(err.message)) { + return callback(null, false); + } + return callback(err); + } + callback(null, true); + }); + } else { + collection.replaceOne({_id: id, _v: doc._v - 1}, doc, (err, result) => { + if (err) return callback(err); + const succeeded = !!result.modifiedCount; + callback(null, succeeded); + }); + } + }); } -} + // **** Snapshot methods -ShareDbMongo.prototype._connect = function(mongo, options) { - // Create the mongo connection client connections if needed - // - // Throw errors in this function if we fail to connect, since we aren't - // implementing a way to retry - var self = this; - if (options.mongoPoll) { - var tasks; - if (typeof mongo === 'function') { - tasks = {mongo: mongo, mongoPoll: options.mongoPoll}; - } else { - tasks = { - mongo: function(parallelCb) { - mongodb.connect(mongo, self._mongodbOptions(options.mongoOptions), parallelCb); - }, - mongoPoll: function(parallelCb) { - mongodb.connect(options.mongoPoll, self._mongodbOptions(options.mongoPollOptions), parallelCb); - } - }; - } - async.parallel(tasks, function(err, results) { - if (err) throw err; - self.mongo = results.mongo; - self.mongoPoll = results.mongoPoll; - self._flushPendingConnect(); + getSnapshot(collectionName, id, fields, options, callback) { + this.getCollection(collectionName, (err, collection) => { + if (err) return callback(err); + const query = {_id: id}; + const projection = getProjection(fields, options); + collection.find(query).limit(1).project(projection).next((err, doc) => { + if (err) return callback(err); + const snapshot = (doc) ? castToSnapshot(doc) : new MongoSnapshot(id, 0, null, undefined); + callback(null, snapshot); + }); }); - return; - } - var finish = function(err, db) { - if (err) throw err; - self.mongo = db; - self._flushPendingConnect(); - }; - if (typeof mongo === 'function') { - mongo(finish); - return; - } - mongodb.connect(mongo, this._mongodbOptions(options), finish); -}; - -ShareDbMongo.prototype.close = function(callback) { - if (!callback) { - callback = function(err) { - if (err) throw err; - }; } - var self = this; - this.getDbs(function(err, mongo, mongoPoll) { - if (err) return callback(err); - self.closed = true; - mongo.close(function(err) { + + getSnapshotBulk(collectionName, ids, fields, options, callback) { + this.getCollection(collectionName, (err, collection) => { if (err) return callback(err); - if (!mongoPoll) return callback(); - mongoPoll.close(callback); + const query = {_id: {$in: ids}}; + const projection = getProjection(fields, options); + collection.find(query).project(projection).toArray((err, docs) => { + if (err) return callback(err); + const snapshotMap = {}; + for (let i = 0; i < docs.length; i++) { + const snapshot = castToSnapshot(docs[i]); + snapshotMap[snapshot.id] = snapshot; + } + const uncreated = []; + for (let i = 0; i < ids.length; i++) { + const id = ids[i]; + if (snapshotMap[id]) continue; + snapshotMap[id] = new MongoSnapshot(id, 0, null, undefined); + } + callback(null, snapshotMap); + }); }); - }); -}; + } + // **** Oplog methods -// **** Commit methods + // Overwrite me if you want to change this behavior. + getOplogCollectionName(collectionName) { + return `o_${collectionName}`; + } -ShareDbMongo.prototype.commit = function(collectionName, id, op, snapshot, options, callback) { - var self = this; - this._writeOp(collectionName, id, op, snapshot, function(err, result) { - if (err) return callback(err); - var opId = result.insertedId; - self._writeSnapshot(collectionName, id, snapshot, opId, function(err, succeeded) { - if (succeeded) return callback(err, succeeded); - // Cleanup unsuccessful op if snapshot write failed. This is not - // neccessary for data correctness, but it gets rid of clutter - self._deleteOp(collectionName, opId, function(removeErr) { - callback(err || removeErr, succeeded); + validateCollectionName(collectionName) { + if ( + collectionName === 'system' || ( + collectionName[0] === 'o' && + collectionName[1] === '_' + ) + ) { + return ShareDbMongo.invalidCollectionError(collectionName); + } + } + + // Get and return the op collection from mongo, ensuring it has the op index. + getOpCollection(collectionName, callback) { + this.getDbs((err, mongo) => { + if (err) return callback(err); + const name = this.getOplogCollectionName(collectionName); + const collection = mongo.db().collection(name); + // Given the potential problems with creating indexes on the fly, it might + // be preferable to disable automatic creation + if (this.disableIndexCreation) { + return callback(null, collection); + } + if (this.opIndexes[collectionName]) { + return callback(null, collection); + } + // WARNING: Creating indexes automatically like this is quite dangerous in + // production if we are starting with a lot of data and no indexes + // already. If new indexes were added or definition of these indexes were + // changed, users upgrading this module could unsuspectingly lock up their + // databases. If indexes are created as the first ops are added to a + // collection this won't be a problem, but this is a dangerous mechanism. + // Perhaps we should only warn instead of creating the indexes, especially + // when there is a lot of data in the collection. + collection.createIndex({d: 1, v: 1}, {background: true}, err => { + if (err) return callback(err); + collection.createIndex({src: 1, seq: 1, v: 1}, {background: true}, err => { + if (err) return callback(err); + this.opIndexes[collectionName] = true; + callback(null, collection); + }); }); }); - }); -}; + } -ShareDbMongo.prototype._writeOp = function(collectionName, id, op, snapshot, callback) { - if (typeof op.v !== 'number') { - var err = ShareDbMongo.invalidOpVersionError(collectionName, id, op.v); - return callback(err); + getOpsToSnapshot(collectionName, id, from, snapshot, options, callback) { + if (snapshot._opLink == null) { + const err = ShareDbMongo.missingLastOperationError(collectionName, id); + return callback(err); + } + this._getOps(collectionName, id, from, options, (err, ops) => { + if (err) return callback(err); + const filtered = getLinkedOps(ops, null, snapshot._opLink); + err = checkOpsFrom(collectionName, id, filtered, from); + if (err) return callback(err); + callback(null, filtered); + }); } - this.getOpCollection(collectionName, function(err, opCollection) { - if (err) return callback(err); - var doc = shallowClone(op); - doc.d = id; - doc.o = snapshot._opLink; - opCollection.insertOne(doc, callback); - }); -}; -ShareDbMongo.prototype._deleteOp = function(collectionName, opId, callback) { - this.getOpCollection(collectionName, function(err, opCollection) { - if (err) return callback(err); - opCollection.deleteOne({_id: opId}, callback); - }); -}; + getOps(collectionName, id, from, to, options, callback) { + this._getSnapshotOpLink(collectionName, id, (err, doc) => { + if (err) return callback(err); + if (doc) { + if (isCurrentVersion(doc, from)) { + return callback(null, []); + } + err = doc && checkDocHasOp(collectionName, id, doc); + if (err) return callback(err); + } + this._getOps(collectionName, id, from, options, (err, ops) => { + if (err) return callback(err); + const filtered = filterOps(ops, doc, to); + err = checkOpsFrom(collectionName, id, filtered, from); + if (err) return callback(err); + callback(null, filtered); + }); + }); + } -ShareDbMongo.prototype._writeSnapshot = function(collectionName, id, snapshot, opLink, callback) { - this.getCollection(collectionName, function(err, collection) { - if (err) return callback(err); - var doc = castToDoc(id, snapshot, opLink); - if (doc._v === 1) { - collection.insertOne(doc, function(err, result) { - if (err) { - // Return non-success instead of duplicate key error, since this is - // expected to occur during simultaneous creates on the same id - if (err.code === 11000 && /\b_id_\b/.test(err.message)) { - return callback(null, false); + getOpsBulk(collectionName, fromMap, toMap, options, callback) { + const ids = Object.keys(fromMap); + this._getSnapshotOpLinkBulk(collectionName, ids, (err, docs) => { + if (err) return callback(err); + const docMap = getDocMap(docs); + // Add empty array for snapshot versions that are up to date and create + // the query conditions for ops that we need to get + const conditions = []; + const opsMap = {}; + for (let i = 0; i < ids.length; i++) { + const id = ids[i]; + const doc = docMap[id]; + const from = fromMap[id]; + if (doc) { + if (isCurrentVersion(doc, from)) { + opsMap[id] = []; + continue; } - return callback(err); + var err = checkDocHasOp(collectionName, id, doc); + if (err) return callback(err); } - callback(null, true); - }); - } else { - collection.replaceOne({_id: id, _v: doc._v - 1}, doc, function(err, result) { + const condition = getOpsQuery(id, from); + conditions.push(condition); + } + // Return right away if none of the snapshot versions are newer than the + // requested versions + if (!conditions.length) return callback(null, opsMap); + // Otherwise, get all of the ops that are newer + this._getOpsBulk(collectionName, conditions, options, (err, opsBulk) => { if (err) return callback(err); - var succeeded = !!result.modifiedCount; - callback(null, succeeded); + for (let i = 0; i < conditions.length; i++) { + const id = conditions[i].d; + const ops = opsBulk[id]; + const doc = docMap[id]; + const from = fromMap[id]; + const to = toMap && toMap[id]; + const filtered = filterOps(ops, doc, to); + var err = checkOpsFrom(collectionName, id, filtered, from); + if (err) return callback(err); + opsMap[id] = filtered; + } + callback(null, opsMap); }); - } - }); -}; - + }); + } -// **** Snapshot methods + _getOps(collectionName, id, from, options, callback) { + this.getOpCollection(collectionName, (err, opCollection) => { + if (err) return callback(err); + const query = getOpsQuery(id, from); + // Exclude the `d` field, which is only for use internal to livedb-mongo. + // Also exclude the `m` field, which can be used to store metadata on ops + // for tracking purposes + const projection = (options && options.metadata) ? {d: 0} : {d: 0, m: 0}; + const sort = {v: 1}; + opCollection.find(query).project(projection).sort(sort).toArray(callback); + }); + } -ShareDbMongo.prototype.getSnapshot = function(collectionName, id, fields, options, callback) { - this.getCollection(collectionName, function(err, collection) { - if (err) return callback(err); - var query = {_id: id}; - var projection = getProjection(fields, options); - collection.find(query).limit(1).project(projection).next(function(err, doc) { + _getOpsBulk(collectionName, conditions, options, callback) { + this.getOpCollection(collectionName, (err, opCollection) => { if (err) return callback(err); - var snapshot = (doc) ? castToSnapshot(doc) : new MongoSnapshot(id, 0, null, undefined); - callback(null, snapshot); + const query = {$or: conditions}; + // Exclude the `m` field, which can be used to store metadata on ops for + // tracking purposes + const projection = (options && options.metadata) ? null : {m: 0}; + const stream = opCollection.find(query).project(projection).stream(); + readOpsBulk(stream, callback); }); - }); -}; + } -ShareDbMongo.prototype.getSnapshotBulk = function(collectionName, ids, fields, options, callback) { - this.getCollection(collectionName, function(err, collection) { - if (err) return callback(err); - var query = {_id: {$in: ids}}; - var projection = getProjection(fields, options); - collection.find(query).project(projection).toArray(function(err, docs) { + _getSnapshotOpLink(collectionName, id, callback) { + this.getCollection(collectionName, (err, collection) => { if (err) return callback(err); - var snapshotMap = {}; - for (var i = 0; i < docs.length; i++) { - var snapshot = castToSnapshot(docs[i]); - snapshotMap[snapshot.id] = snapshot; - } - var uncreated = []; - for (var i = 0; i < ids.length; i++) { - var id = ids[i]; - if (snapshotMap[id]) continue; - snapshotMap[id] = new MongoSnapshot(id, 0, null, undefined); - } - callback(null, snapshotMap); + const query = {_id: id}; + const projection = {_id: 0, _o: 1, _v: 1}; + collection.find(query).limit(1).project(projection).next(callback); }); - }); -}; + } + _getSnapshotOpLinkBulk(collectionName, ids, callback) { + this.getCollection(collectionName, (err, collection) => { + if (err) return callback(err); + const query = {_id: {$in: ids}}; + const projection = {_o: 1, _v: 1}; + collection.find(query).project(projection).toArray(callback); + }); + } -// **** Oplog methods + // **** Query methods -// Overwrite me if you want to change this behaviour. -ShareDbMongo.prototype.getOplogCollectionName = function(collectionName) { - return 'o_' + collectionName; -}; + _query(collection, inputQuery, projection, callback) { + const parsed = this._getSafeParsedQuery(inputQuery, callback); + if (!parsed) return; -ShareDbMongo.prototype.validateCollectionName = function(collectionName) { - if ( - collectionName === 'system' || ( - collectionName[0] === 'o' && - collectionName[1] === '_' - ) - ) { - return ShareDbMongo.invalidCollectionError(collectionName); - } -}; + // Collection operations such as $aggregate run on the whole + // collection. Only one operation is run. The result goes in the + // "extra" argument in the callback. + if (parsed.collectionOperationKey) { + collectionOperationsMap[parsed.collectionOperationKey]( + collection, + parsed.query, + parsed.collectionOperationValue, + (err, extra) => { + if (err) return callback(err); + callback(null, [], extra); + } + ); + return; + } -// Get and return the op collection from mongo, ensuring it has the op index. -ShareDbMongo.prototype.getOpCollection = function(collectionName, callback) { - var self = this; - this.getDbs(function(err, mongo) { - if (err) return callback(err); - var name = self.getOplogCollectionName(collectionName); - var collection = mongo.db().collection(name); - // Given the potential problems with creating indexes on the fly, it might - // be preferrable to disable automatic creation - if (self.disableIndexCreation) { - return callback(null, collection); + // No collection operations were used. Create an initial cursor for + // the query, that can be transformed later. + let cursor = collection.find(parsed.query).project(projection); + + // Cursor transforms such as $skip transform the cursor into a new + // one. If multiple transforms are specified on inputQuery, they all + // run. + for (const key in parsed.cursorTransforms) { + const transform = cursorTransformsMap[key]; + cursor = transform(cursor, parsed.cursorTransforms[key]); + if (!cursor) { + const err = ShareDbMongo.malformedQueryOperatorError(key); + return callback(err); + } } - if (self.opIndexes[collectionName]) { - return callback(null, collection); + + // Cursor operations such as $count run on the cursor, after all + // transforms. Only one operation is run. The result goes in the + // "extra" argument in the callback. + if (parsed.cursorOperationKey) { + cursorOperationsMap[parsed.cursorOperationKey]( + cursor, + parsed.cursorOperationValue, + (err, extra) => { + if (err) return callback(err); + callback(null, [], extra); + } + ); + return; } - // WARNING: Creating indexes automatically like this is quite dangerous in - // production if we are starting with a lot of data and no indexes - // already. If new indexes were added or definition of these indexes were - // changed, users upgrading this module could unsuspectingly lock up their - // databases. If indexes are created as the first ops are added to a - // collection this won't be a problem, but this is a dangerous mechanism. - // Perhaps we should only warn instead of creating the indexes, especially - // when there is a lot of data in the collection. - collection.createIndex({d: 1, v: 1}, {background: true}, function(err) { + + // If no collection operation or cursor operations were used, return + // an array of snapshots that are passed in the "results" argument + // in the callback + cursor.toArray(callback); + } + + query(collectionName, inputQuery, fields, options, callback) { + this.getCollection(collectionName, (err, collection) => { if (err) return callback(err); - collection.createIndex({src: 1, seq: 1, v: 1}, {background: true}, function(err) { + const projection = getProjection(fields, options); + this._query(collection, inputQuery, projection, (err, results, extra) => { if (err) return callback(err); - self.opIndexes[collectionName] = true; - callback(null, collection); + const snapshots = []; + for (let i = 0; i < results.length; i++) { + const snapshot = castToSnapshot(results[i]); + snapshots.push(snapshot); + } + callback(null, snapshots, extra); }); }); - }); -}; - -ShareDbMongo.prototype.getOpsToSnapshot = function(collectionName, id, from, snapshot, options, callback) { - if (snapshot._opLink == null) { - var err = ShareDbMongo.missingLastOperationError(collectionName, id); - return callback(err); } - this._getOps(collectionName, id, from, options, function(err, ops) { - if (err) return callback(err); - var filtered = getLinkedOps(ops, null, snapshot._opLink); - var err = checkOpsFrom(collectionName, id, filtered, from); - if (err) return callback(err); - callback(null, filtered); - }); -}; -ShareDbMongo.prototype.getOps = function(collectionName, id, from, to, options, callback) { - var self = this; - this._getSnapshotOpLink(collectionName, id, function(err, doc) { - if (err) return callback(err); - if (doc) { - if (isCurrentVersion(doc, from)) { - return callback(null, []); - } - var err = doc && checkDocHasOp(collectionName, id, doc); + queryPoll(collectionName, inputQuery, options, callback) { + this.getCollectionPoll(collectionName, (err, collection) => { if (err) return callback(err); - } - self._getOps(collectionName, id, from, options, function(err, ops) { - if (err) return callback(err); - var filtered = filterOps(ops, doc, to); - var err = checkOpsFrom(collectionName, id, filtered, from); - if (err) return callback(err); - callback(null, filtered); + const projection = {_id: 1}; + this._query(collection, inputQuery, projection, (err, results, extra) => { + if (err) return callback(err); + const ids = []; + for (let i = 0; i < results.length; i++) { + ids.push(results[i]._id); + } + callback(null, ids, extra); + }); }); - }); -}; + } -ShareDbMongo.prototype.getOpsBulk = function(collectionName, fromMap, toMap, options, callback) { - var self = this; - var ids = Object.keys(fromMap); - this._getSnapshotOpLinkBulk(collectionName, ids, function(err, docs) { - if (err) return callback(err); - var docMap = getDocMap(docs); - // Add empty array for snapshot versions that are up to date and create - // the query conditions for ops that we need to get - var conditions = []; - var opsMap = {}; - for (var i = 0; i < ids.length; i++) { - var id = ids[i]; - var doc = docMap[id]; - var from = fromMap[id]; - if (doc) { - if (isCurrentVersion(doc, from)) { - opsMap[id] = []; - continue; + queryPollDoc(collectionName, id, inputQuery, options, callback) { + this.getCollectionPoll(collectionName, (err, collection) => { + const parsed = this._getSafeParsedQuery(inputQuery, callback); + if (!parsed) return; + + // Run the query against a particular mongo document by adding an _id filter + const queryId = parsed.query._id; + if (queryId && typeof queryId === 'object') { + // Check if the query contains the id directly in the common pattern of + // a query for a specific list of ids, such as {_id: {$in: [1, 2, 3]}} + if (Array.isArray(queryId.$in) && Object.keys(queryId).length === 1) { + if (queryId.$in.indexOf(id) === -1) { + // If the id isn't in the list of ids, then there is no way this + // can be a match + return callback(null, false); + } else { + // If the id is in the list, then it is equivalent to restrict to our + // particular id and override the current value + parsed.query._id = id; + } + } else { + delete parsed.query._id; + parsed.query.$and = (parsed.query.$and) ? + parsed.query.$and.concat({_id: id}, {_id: queryId}) : + [{_id: id}, {_id: queryId}]; } - var err = checkDocHasOp(collectionName, id, doc); - if (err) return callback(err); + } else if (queryId && queryId !== id) { + // If queryId is a primitive value such as a string or number and it + // isn't equal to the id, then there is no way this can be a match + return callback(null, false); + } else { + // Restrict the query to this particular document + parsed.query._id = id; } - var condition = getOpsQuery(id, from); - conditions.push(condition); + + collection.find(parsed.query).limit(1).project({_id: 1}).next((err, doc) => { + callback(err, !!doc); + }); + }); + } + + // **** Polling optimization + + // Can we poll by checking the query limited to the particular doc only? + canPollDoc(collectionName, query) { + for (let operation in collectionOperationsMap) { + if (query.hasOwnProperty(operation)) return false; } - // Return right away if none of the snapshot versions are newer than the - // requested versions - if (!conditions.length) return callback(null, opsMap); - // Otherwise, get all of the ops that are newer - self._getOpsBulk(collectionName, conditions, options, function(err, opsBulk) { - if (err) return callback(err); - for (var i = 0; i < conditions.length; i++) { - var id = conditions[i].d; - var ops = opsBulk[id]; - var doc = docMap[id]; - var from = fromMap[id]; - var to = toMap && toMap[id]; - var filtered = filterOps(ops, doc, to); - var err = checkOpsFrom(collectionName, id, filtered, from); - if (err) return callback(err); - opsMap[id] = filtered; + for (let operation in cursorOperationsMap) { + if (query.hasOwnProperty(operation)) return false; + } + + if ( + query.hasOwnProperty('$sort') || + query.hasOwnProperty('$orderby') || + query.hasOwnProperty('$limit') || + query.hasOwnProperty('$skip') || + query.hasOwnProperty('$max') || + query.hasOwnProperty('$min') || + query.hasOwnProperty('$returnKey') + ) { + return false; + } + + return true; + } + + // Return true to avoid polling if there is no possibility that an op could + // affect a query's results + skipPoll(collectionName, id, op, query) { + // ShareDB is in charge of doing the validation of ops, so at this point we + // should be able to assume that the op is structured validly + if (op.create || op.del) return false; + if (!op.op) return true; + + // Right now, always re-poll if using a collection operation such as + // $distinct or a cursor operation such as $count. This could be + // optimized further in some cases. + for (let operation in collectionOperationsMap) { + if (query.hasOwnProperty(operation)) return false; + } + for (let operation in cursorOperationsMap) { + if (query.hasOwnProperty(operation)) return false; + } + + // ShareDB calls `skipPoll` inside a try/catch block. If an error is + // thrown, it skips polling -- we can't poll an invalid query. So in + // the code below, we work under the assumption that `query` is + // valid. If an error is thrown, that's fine. + const fields = getFields(query); + + return !opContainsAnyField(op.op, fields); + } + + // Utility methods + + // Return {code: ..., message: ...} on error. Call before parseQuery. + checkQuery(query) { + if (query.$query) { + return ShareDbMongo.$queryDeprecatedError(); + } + + const validMongoErr = checkValidMongo(query); + if (validMongoErr) return validMongoErr; + + if (!this.allowJSQueries) { + if (query.$where != null) { + return ShareDbMongo.$whereDisabledError(); } - callback(null, opsMap); - }); - }); -}; + if (query.$mapReduce != null) { + return ShareDbMongo.$mapReduceDisabledError(); + } + } + + if (!this.allowAggregateQueries && query.$aggregate) { + return ShareDbMongo.$aggregateDisabledError(); + } + } + + // Parses a query and makes it safe against deleted docs. On error, + // call the callback and return null. + _getSafeParsedQuery(inputQuery, callback) { + let err = this.checkQuery(inputQuery); + if (err) { + callback(err); + return null; + } + + try { + const parsed = parseQuery(inputQuery); + makeQuerySafe(parsed.query); + return parsed; + } catch (err) { + err = ShareDbMongo.parseQueryError(err); + callback(err); + return null; + } + + + } + + // Bad request errors + static invalidOpVersionError(collectionName, id, v) { + return { + code: 4101, + message: `Invalid op version ${collectionName}.${id} ${op.v}` + }; + } + + static invalidCollectionError(collectionName) { + return {code: 4102, message: `Invalid collection name ${collectionName}`}; + } + + static $whereDisabledError() { + return {code: 4103, message: '$where queries disabled'}; + } + + static $mapReduceDisabledError() { + return {code: 4104, message: '$mapReduce queries disabled'}; + } + + static $aggregateDisabledError() { + return {code: 4105, message: '$aggregate queries disabled'}; + } + + static $queryDeprecatedError() { + return {code: 4106, message: '$query property deprecated in queries'}; + } + + static malformedQueryOperatorError(operator) { + return {code: 4107, message: `Malformed query operator: ${operator}`}; + } + + static onlyOneCollectionOperationError(operation1, operation2) { + return { + code: 4108, + message: `Only one collection operation allowed. Found ${operation1} and ${operation2}` + }; + } + + static onlyOneCursorOperationError(operation1, operation2) { + return { + code: 4109, + message: `Only one cursor operation allowed. Found ${operation1} and ${operation2}` + }; + } + + static cursorAndCollectionMethodError(collectionOperation) { + return { + code: 4110, + message: `Cursor methods can't run after collection method ${collectionOperation}` + }; + } + + // Internal errors + static alreadyClosedError() { + return {code: 5101, message: 'Already closed'}; + } + + static missingLastOperationError(collectionName, id) { + return { + code: 5102, + message: `Snapshot missing last operation field "_o" ${collectionName}.${id}` + }; + } + + static missingOpsError(collectionName, id, from) { + return { + code: 5103, + message: `Missing ops from requested version ${collectionName}.${id} ${from}` + }; + } + + // Modifies 'err' argument + static parseQueryError(err) { + err.code = 5104; + return err; + } +} + +ShareDbMongo.prototype.projectsSnapshots = true; DB.prototype.getCommittedOpVersion = function(collectionName, id, snapshot, op, options, callback) { - var self = this; - this.getOpCollection(collectionName, function(err, opCollection) { + this.getOpCollection(collectionName, (err, opCollection) => { if (err) return callback(err); - var query = { + const query = { src: op.src, seq: op.seq }; - var projection = {v: 1, _id: 0}; - var sort = {v: 1}; + const projection = {v: 1, _id: 0}; + const sort = {v: 1}; // Find the earliest version at which the op may have been committed. // Since ops are optimistically written prior to writing the snapshot, the // op could end up being written multiple times or have been written but - // not count as committed if not backreferenced from the snapshot - opCollection.find(query).project(projection).sort(sort).limit(1).next(function(err, doc) { + // not count as committed if not back-referenced from the snapshot + opCollection.find(query).project(projection).sort(sort).limit(1).next((err, doc) => { if (err) return callback(err); // If we find no op with the same src and seq, we definitely don't have // any match. This should prevent us from accidentally querying a huge @@ -448,11 +784,11 @@ DB.prototype.getCommittedOpVersion = function(collectionName, id, snapshot, op, // If we do find an op with the same src and seq, we still have to get // the ops from the snapshot to figure out if the op was actually // committed already, and at what version in case of multiple matches - var from = doc.v; - self.getOpsToSnapshot(collectionName, id, from, snapshot, options, function(err, ops) { + const from = doc.v; + this.getOpsToSnapshot(collectionName, id, from, snapshot, options, (err, ops) => { if (err) return callback(err); - for (var i = ops.length; i--;) { - var item = ops[i]; + for (let i = ops.length; i--;) { + const item = ops[i]; if (op.src === item.src && op.seq === item.seq) { return callback(null, item.v); } @@ -468,7 +804,7 @@ function checkOpsFrom(collectionName, id, ops, from) { if (ops[0] && ops[0].v === from) return; if (from == null) return; return ShareDbMongo.missingOpsError(collectionName, id, from); -}; +} function checkDocHasOp(collectionName, id, doc) { if (doc._o) return; @@ -480,9 +816,9 @@ function isCurrentVersion(doc, version) { } function getDocMap(docs) { - var docMap = {}; - for (var i = 0; i < docs.length; i++) { - var doc = docs[i]; + const docMap = {}; + for (let i = 0; i < docs.length; i++) { + const doc = docs[i]; docMap[doc._id] = doc; } return docMap; @@ -514,9 +850,9 @@ function filterOps(ops, doc, to) { // ops at the same version, we can grab any of them for this method. // However, the _id of the delete op might not ultimately match the delete // op that gets maintained if two are written as a result of two - // simultanous delete commits. Thus, the _id of the op should *not* be + // simultaneous delete commits. Thus, the _id of the op should *not* be // assumed to be consistent in the future. - var deleteOp = getLatestDeleteOp(ops); + const deleteOp = getLatestDeleteOp(ops); // Don't return any ops if we don't find a delete operation, which is the // correct thing to do if the doc was just created and the op has been // written but not the snapshot. Note that this will simply return no ops @@ -528,16 +864,16 @@ function filterOps(ops, doc, to) { } function getLatestDeleteOp(ops) { - for (var i = ops.length; i--;) { - var op = ops[i]; + for (let i = ops.length; i--;) { + const op = ops[i]; if (op.del) return op; } } function getLinkedOps(ops, to, link) { - var linkedOps = [] - for (var i = ops.length; i-- && link;) { - var op = ops[i]; + const linkedOps = []; + for (let i = ops.length; i-- && link;) { + const op = ops[i]; if (link.equals ? !link.equals(op._id) : link !== op._id) continue; link = op.o; if (to == null || op.v < to) { @@ -555,51 +891,24 @@ function getOpsQuery(id, from) { {d: id, v: {$gte: from}}; } -ShareDbMongo.prototype._getOps = function(collectionName, id, from, options, callback) { - this.getOpCollection(collectionName, function(err, opCollection) { - if (err) return callback(err); - var query = getOpsQuery(id, from); - // Exclude the `d` field, which is only for use internal to livedb-mongo. - // Also exclude the `m` field, which can be used to store metadata on ops - // for tracking purposes - var projection = (options && options.metadata) ? {d: 0} : {d: 0, m: 0}; - var sort = {v: 1}; - opCollection.find(query).project(projection).sort(sort).toArray(callback); - }); -}; - -ShareDbMongo.prototype._getOpsBulk = function(collectionName, conditions, options, callback) { - this.getOpCollection(collectionName, function(err, opCollection) { - if (err) return callback(err); - var query = {$or: conditions}; - // Exclude the `m` field, which can be used to store metadata on ops for - // tracking purposes - var projection = (options && options.metadata) ? null : {m: 0}; - var stream = opCollection.find(query).project(projection).stream(); - readOpsBulk(stream, callback); - }); -}; - function readOpsBulk(stream, callback) { - var opsMap = {}; - var errored; - stream.on('error', function(err) { + const opsMap = {}; + let errored; + stream.on('error', err => { errored = true; return callback(err); }); - stream.on('end', function() { + stream.on('end', () => { if (errored) return; // Sort ops for each doc in ascending order by version - for (var id in opsMap) { - opsMap[id].sort(function(a, b) { - return a.v - b.v; - }); + for (const id in opsMap) { + opsMap[id].sort((a, b) => a.v - b.v); } callback(null, opsMap); }); // Read each op and push onto a list for the appropriate doc - stream.on('data', function(op) { - var id = op.d; + stream.on('data', op => { + const id = op.d; if (opsMap[id]) { opsMap[id].push(op); } else { @@ -609,215 +918,8 @@ function readOpsBulk(stream, callback) { }); } -ShareDbMongo.prototype._getSnapshotOpLink = function(collectionName, id, callback) { - this.getCollection(collectionName, function(err, collection) { - if (err) return callback(err); - var query = {_id: id}; - var projection = {_id: 0, _o: 1, _v: 1}; - collection.find(query).limit(1).project(projection).next(callback); - }); -}; - -ShareDbMongo.prototype._getSnapshotOpLinkBulk = function(collectionName, ids, callback) { - this.getCollection(collectionName, function(err, collection) { - if (err) return callback(err); - var query = {_id: {$in: ids}}; - var projection = {_o: 1, _v: 1}; - collection.find(query).project(projection).toArray(callback); - }); -}; - - -// **** Query methods - -ShareDbMongo.prototype._query = function(collection, inputQuery, projection, callback) { - var parsed = this._getSafeParsedQuery(inputQuery, callback); - if (!parsed) return; - - // Collection operations such as $aggregate run on the whole - // collection. Only one operation is run. The result goes in the - // "extra" argument in the callback. - if (parsed.collectionOperationKey) { - collectionOperationsMap[parsed.collectionOperationKey]( - collection, - parsed.query, - parsed.collectionOperationValue, - function(err, extra) { - if (err) return callback(err); - callback(null, [], extra); - } - ); - return; - } - - // No collection operations were used. Create an initial cursor for - // the query, that can be transformed later. - var cursor = collection.find(parsed.query).project(projection); - - // Cursor transforms such as $skip transform the cursor into a new - // one. If multiple transforms are specified on inputQuery, they all - // run. - for (var key in parsed.cursorTransforms) { - var transform = cursorTransformsMap[key]; - cursor = transform(cursor, parsed.cursorTransforms[key]); - if (!cursor) { - var err = ShareDbMongo.malformedQueryOperatorError(key); - return callback(err); - } - } - - // Cursor operations such as $count run on the cursor, after all - // transforms. Only one operation is run. The result goes in the - // "extra" argument in the callback. - if (parsed.cursorOperationKey) { - cursorOperationsMap[parsed.cursorOperationKey]( - cursor, - parsed.cursorOperationValue, - function(err, extra) { - if (err) return callback(err); - callback(null, [], extra); - } - ); - return; - } - - // If no collection operation or cursor operations were used, return - // an array of snapshots that are passed in the "results" argument - // in the callback - cursor.toArray(callback); -}; - -ShareDbMongo.prototype.query = function(collectionName, inputQuery, fields, options, callback) { - var self = this; - this.getCollection(collectionName, function(err, collection) { - if (err) return callback(err); - var projection = getProjection(fields, options); - self._query(collection, inputQuery, projection, function(err, results, extra) { - if (err) return callback(err); - var snapshots = []; - for (var i = 0; i < results.length; i++) { - var snapshot = castToSnapshot(results[i]); - snapshots.push(snapshot); - } - callback(null, snapshots, extra); - }); - }); -}; - -ShareDbMongo.prototype.queryPoll = function(collectionName, inputQuery, options, callback) { - var self = this; - this.getCollectionPoll(collectionName, function(err, collection) { - if (err) return callback(err); - var projection = {_id: 1}; - self._query(collection, inputQuery, projection, function(err, results, extra) { - if (err) return callback(err); - var ids = []; - for (var i = 0; i < results.length; i++) { - ids.push(results[i]._id); - } - callback(null, ids, extra); - }); - }); -}; - -ShareDbMongo.prototype.queryPollDoc = function(collectionName, id, inputQuery, options, callback) { - var self = this; - self.getCollectionPoll(collectionName, function(err, collection) { - var parsed = self._getSafeParsedQuery(inputQuery, callback); - if (!parsed) return; - - // Run the query against a particular mongo document by adding an _id filter - var queryId = parsed.query._id; - if (queryId && typeof queryId === 'object') { - // Check if the query contains the id directly in the common pattern of - // a query for a specific list of ids, such as {_id: {$in: [1, 2, 3]}} - if (Array.isArray(queryId.$in) && Object.keys(queryId).length === 1) { - if (queryId.$in.indexOf(id) === -1) { - // If the id isn't in the list of ids, then there is no way this - // can be a match - return callback(null, false); - } else { - // If the id is in the list, then it is equivalent to restrict to our - // particular id and override the current value - parsed.query._id = id; - } - } else { - delete parsed.query._id; - parsed.query.$and = (parsed.query.$and) ? - parsed.query.$and.concat({_id: id}, {_id: queryId}) : - [{_id: id}, {_id: queryId}]; - } - } else if (queryId && queryId !== id) { - // If queryId is a primative value such as a string or number and it - // isn't equal to the id, then there is no way this can be a match - return callback(null, false); - } else { - // Restrict the query to this particular document - parsed.query._id = id; - } - - collection.find(parsed.query).limit(1).project({_id: 1}).next(function(err, doc) { - callback(err, !!doc); - }); - }); -}; - - -// **** Polling optimization - -// Can we poll by checking the query limited to the particular doc only? -ShareDbMongo.prototype.canPollDoc = function(collectionName, query) { - for (var operation in collectionOperationsMap) { - if (query.hasOwnProperty(operation)) return false; - } - for (var operation in cursorOperationsMap) { - if (query.hasOwnProperty(operation)) return false; - } - - if ( - query.hasOwnProperty('$sort') || - query.hasOwnProperty('$orderby') || - query.hasOwnProperty('$limit') || - query.hasOwnProperty('$skip') || - query.hasOwnProperty('$max') || - query.hasOwnProperty('$min') || - query.hasOwnProperty('$returnKey') - ) { - return false; - } - - return true; -}; - -// Return true to avoid polling if there is no possibility that an op could -// affect a query's results -ShareDbMongo.prototype.skipPoll = function(collectionName, id, op, query) { - // ShareDB is in charge of doing the validation of ops, so at this point we - // should be able to assume that the op is structured validly - if (op.create || op.del) return false; - if (!op.op) return true; - - // Right now, always re-poll if using a collection operation such as - // $distinct or a cursor operation such as $count. This could be - // optimized further in some cases. - for (var operation in collectionOperationsMap) { - if (query.hasOwnProperty(operation)) return false; - } - for (var operation in cursorOperationsMap) { - if (query.hasOwnProperty(operation)) return false; - } - - // ShareDB calls `skipPoll` inside a try/catch block. If an error is - // thrown, it skips polling -- we can't poll an invalid query. So in - // the code below, we work under the assumption that `query` is - // valid. If an error is thrown, that's fine. - var fields = getFields(query); - - return !opContainsAnyField(op.op, fields); -}; - function getFields(query) { - var fields = {}; + const fields = {}; getInnerFields(query.$orderby, fields); getInnerFields(query.$sort, fields); getInnerFields(query, fields); @@ -826,25 +928,25 @@ function getFields(query) { function getInnerFields(params, fields) { if (!params) return; - for (var key in params) { - var value = params[key]; + for (const key in params) { + const value = params[key]; if (key === '$not') { getInnerFields(value, fields); } else if (key === '$or' || key === '$and' || key === '$nor') { - for (var i = 0; i < value.length; i++) { - var item = value[i]; + for (let i = 0; i < value.length; i++) { + const item = value[i]; getInnerFields(item, fields); } } else if (key[0] !== '$') { - var property = key.split('.')[0]; + const property = key.split('.')[0]; fields[property] = true; } } } function opContainsAnyField(op, fields) { - for (var i = 0; i < op.length; i++) { - var component = op[i]; + for (let i = 0; i < op.length; i++) { + const component = op[i]; if (component.p.length === 0) { return true; } else if (fields[component.p[0]]) { @@ -855,31 +957,6 @@ function opContainsAnyField(op, fields) { } -// Utility methods - -// Return {code: ..., message: ...} on error. Call before parseQuery. -ShareDbMongo.prototype.checkQuery = function(query) { - if (query.$query) { - return ShareDbMongo.$queryDeprecatedError(); - } - - var validMongoErr = checkValidMongo(query); - if (validMongoErr) return validMongoErr; - - if (!this.allowJSQueries) { - if (query.$where != null) { - return ShareDbMongo.$whereDisabledError(); - } - if (query.$mapReduce != null) { - return ShareDbMongo.$mapReduceDisabledError(); - } - } - - if (!this.allowAggregateQueries && query.$aggregate) { - return ShareDbMongo.$aggregateDisabledError(); - } -}; - // Check that any keys starting with $ are valid Mongo methods. Verify // that: // * There is at most one collection operation like $mapReduce @@ -888,11 +965,11 @@ ShareDbMongo.prototype.checkQuery = function(query) { // // Return {code: ..., message: ...} on error. function checkValidMongo(query) { - var collectionOperationKey = null; // only one allowed - var foundCursorMethod = false; // transform or operation - var cursorOperationKey = null; // only one allowed + let collectionOperationKey = null; // only one allowed + let foundCursorMethod = false; // transform or operation + let cursorOperationKey = null; // only one allowed - for (var key in query) { + for (const key in query) { if (key[0] === '$') { if (collectionOperationsMap[key]) { // Found collection operation. Check that it's unique. @@ -942,27 +1019,6 @@ function ParsedQuery( this.cursorOperationValue = cursorOperationValue; } -// Parses a query and makes it safe against deleted docs. On error, -// call the callback and return null. -ShareDbMongo.prototype._getSafeParsedQuery = function(inputQuery, callback) { - var err = this.checkQuery(inputQuery); - if (err) { - callback(err); - return null; - } - - try { - var parsed = parseQuery(inputQuery); - } catch (err) { - err = ShareDbMongo.parseQueryError(err); - callback(err); - return null; - } - - makeQuerySafe(parsed.query); - return parsed; -}; - function parseQuery(inputQuery) { // Parse sharedb-mongo query format into an object with these keys: // * query: The actual mongo query part of the input query @@ -996,17 +1052,17 @@ function parseQuery(inputQuery) { // cursorOperationValue: 2 // } - var query = {}; - var collectionOperationKey = null; - var collectionOperationValue = null; - var cursorTransforms = {}; - var cursorOperationKey = null; - var cursorOperationValue = null; + const query = {}; + let collectionOperationKey = null; + let collectionOperationValue = null; + const cursorTransforms = {}; + let cursorOperationKey = null; + let cursorOperationValue = null; if (inputQuery.$query) { throw new Error("unexpected $query: should have called checkQuery"); } else { - for (var key in inputQuery) { + for (const key in inputQuery) { if (collectionOperationsMap[key]) { collectionOperationKey = key; collectionOperationValue = inputQuery[key]; @@ -1029,7 +1085,7 @@ function parseQuery(inputQuery) { cursorOperationKey, cursorOperationValue ); -}; +} ShareDbMongo._parseQuery = parseQuery; // for tests // Call on a query after it gets parsed to make it safe against @@ -1045,7 +1101,7 @@ function makeQuerySafe(query) { if (deletedDocCouldSatisfyQuery(query)) { query._type = {$type: 2}; } -}; +} ShareDbMongo._makeQuerySafe = makeQuerySafe; // for tests // Could a deleted doc (one that contains {_type: null} and no other @@ -1083,7 +1139,7 @@ function deletedDocCouldSatisfyQuery(query) { // operators. if (query.hasOwnProperty('$and')) { if (Array.isArray(query.$and)) { - for (var i = 0; i < query.$and.length; i++) { + for (let i = 0; i < query.$and.length; i++) { if (!deletedDocCouldSatisfyQuery(query.$and[i])) { return false; } @@ -1097,7 +1153,7 @@ function deletedDocCouldSatisfyQuery(query) { if (query.hasOwnProperty('$or')) { if (Array.isArray(query.$or)) { - for (var i = 0; i < query.$or.length; i++) { + for (let i = 0; i < query.$or.length; i++) { if (deletedDocCouldSatisfyQuery(query.$or[i])) { return true; } @@ -1109,7 +1165,7 @@ function deletedDocCouldSatisfyQuery(query) { } } - for (var prop in query) { + for (const prop in query) { // Ignore fields that remain set on deleted docs if ( prop === '_id' || @@ -1150,11 +1206,11 @@ function couldMatchNull(clause) { // implied 'and' relationship, e.g. {$gt: 3, $lt: 6}. If every // part of the clause could match null then the full clause could // match null. - for (var prop in clause) { - var value = clause[prop]; + for (const prop in clause) { + const value = clause[prop]; if (prop === '$in' && Array.isArray(value)) { - var partCouldMatchNull = false; - for (var i = 0; i < value.length; i++) { + let partCouldMatchNull = false; + for (let i = 0; i < value.length; i++) { if (value[i] === null) { partCouldMatchNull = true; break; @@ -1191,8 +1247,8 @@ function couldMatchNull(clause) { } function castToDoc(id, snapshot, opLink) { - var data = snapshot.data; - var doc = + const data = snapshot.data; + const doc = (isObject(data)) ? shallowClone(data) : (data === undefined) ? {} : {_data: data}; @@ -1205,12 +1261,12 @@ function castToDoc(id, snapshot, opLink) { } function castToSnapshot(doc) { - var id = doc._id; - var version = doc._v; - var type = doc._type; - var data = doc._data; - var meta = doc._m; - var opLink = doc._o; + const id = doc._id; + const version = doc._v; + const type = doc._type; + let data = doc._data; + const meta = doc._m; + const opLink = doc._o; if (type == null) { return new MongoSnapshot(id, version, null, undefined, meta, opLink); } @@ -1239,8 +1295,8 @@ function isObject(value) { } function shallowClone(object) { - var out = {}; - for (var key in object) { + const out = {}; + for (const key in object) { out[key] = object[key]; } return out; @@ -1268,8 +1324,8 @@ function getProjection(fields, options) { // Do not project when called by ShareDB submit if (fields.$submit) return; - var projection = {}; - for (var key in fields) { + const projection = {}; + for (const key in fields) { projection[key] = 1; } projection._type = 1; @@ -1278,16 +1334,16 @@ function getProjection(fields, options) { return projection; } -var collectionOperationsMap = { +const collectionOperationsMap = { '$distinct': function(collection, query, value, cb) { collection.distinct(value.field, query, cb); }, '$aggregate': function(collection, query, value, cb) { - collection.aggregate(value, function(err, cursor) { + collection.aggregate(value, (err, cursor) => { if(err) { return cb(err); } - cursor.toArray(function (err, res) { + cursor.toArray((err, res) => { if(err) { return cb(err); } @@ -1298,11 +1354,11 @@ var collectionOperationsMap = { }, '$mapReduce': function(collection, query, value, cb) { if (typeof value !== 'object') { - var err = ShareDbMongo.malformedQueryOperatorError('$mapReduce'); + const err = ShareDbMongo.malformedQueryOperatorError('$mapReduce'); return cb(err); } - var mapReduceOptions = { - query: query, + const mapReduceOptions = { + query, out: {inline: 1}, scope: value.scope || {} }; @@ -1311,7 +1367,7 @@ var collectionOperationsMap = { } }; -var cursorOperationsMap = { +const cursorOperationsMap = { '$count': function(cursor, value, cb) { cursor.count(cb); }, @@ -1323,7 +1379,7 @@ var cursorOperationsMap = { } }; -var cursorTransformsMap = { +const cursorTransformsMap = { '$batchSize': function(cursor, size) { return cursor.batchSize(size); }, '$comment': function(cursor, text) { return cursor.comment(text); }, '$hint': function(cursor, index) { return cursor.hint(index); }, @@ -1345,7 +1401,7 @@ var cursorTransformsMap = { return cursor.readConcern(level); }, '$readPref': function(cursor, value) { - // The Mongo driver cursor method takes two argments. Our queries + // The Mongo driver cursor method takes two arguments. Our queries // have a single value for the '$readPref' property. Interpret as // an object with {mode, tagSet}. if (typeof value !== 'object') return null; @@ -1372,71 +1428,4 @@ var cursorTransformsMap = { } }; -// Bad request errors -ShareDbMongo.invalidOpVersionError = function(collectionName, id, v) { - return { - code: 4101, - message: 'Invalid op version ' + collectionName + '.' + id + ' ' + op.v - }; -}; -ShareDbMongo.invalidCollectionError = function(collectionName) { - return {code: 4102, message: 'Invalid collection name ' + collectionName}; -}; -ShareDbMongo.$whereDisabledError = function() { - return {code: 4103, message: '$where queries disabled'}; -}; -ShareDbMongo.$mapReduceDisabledError = function() { - return {code: 4104, message: '$mapReduce queries disabled'}; -}; -ShareDbMongo.$aggregateDisabledError = function() { - return {code: 4105, message: '$aggregate queries disabled'}; -}; -ShareDbMongo.$queryDeprecatedError = function() { - return {code: 4106, message: '$query property deprecated in queries'}; -}; -ShareDbMongo.malformedQueryOperatorError = function(operator) { - return {code: 4107, message: "Malformed query operator: " + operator}; -}; -ShareDbMongo.onlyOneCollectionOperationError = function(operation1, operation2) { - return { - code: 4108, - message: 'Only one collection operation allowed. ' + - 'Found ' + operation1 + ' and ' + operation2 - }; -}; -ShareDbMongo.onlyOneCursorOperationError = function(operation1, operation2) { - return { - code: 4109, - message: 'Only one cursor operation allowed. ' + - 'Found ' + operation1 + ' and ' + operation2 - }; -}; -ShareDbMongo.cursorAndCollectionMethodError = function(collectionOperation) { - return { - code: 4110, - message: 'Cursor methods can\'t run after collection method ' + - collectionOperation - }; -}; - -// Internal errors -ShareDbMongo.alreadyClosedError = function() { - return {code: 5101, message: 'Already closed'}; -}; -ShareDbMongo.missingLastOperationError = function(collectionName, id) { - return { - code: 5102, - message: 'Snapshot missing last operation field "_o" ' + collectionName + '.' + id - }; -}; -ShareDbMongo.missingOpsError = function(collectionName, id, from) { - return { - code: 5103, - message: 'Missing ops from requested version ' + collectionName + '.' + id + ' ' + from - }; -}; -// Modifies 'err' argument -ShareDbMongo.parseQueryError = function(err) { - err.code = 5104; - return err; -}; +module.exports = ShareDbMongo; diff --git a/test/test_mongo.js b/test/test_mongo.js index 1bda83a5..7ef170cb 100644 --- a/test/test_mongo.js +++ b/test/test_mongo.js @@ -1,15 +1,15 @@ -var expect = require('expect.js'); -var mongodb = require('mongodb'); -var ShareDbMongo = require('../index'); -var getQuery = require('@teamwork/sharedb-mingo-memory/get-query'); +const expect = require('expect.js'); +const mongodb = require('mongodb'); +const ShareDbMongo = require('../index'); +const getQuery = require('@teamwork/sharedb-mingo-memory/get-query'); -var mongoUrl = process.env.TEST_MONGO_URL || 'mongodb://localhost:27017/test'; +const mongoUrl = process.env.TEST_MONGO_URL || 'mongodb://localhost:27017/test'; function create(callback) { - var db = ShareDbMongo({mongo: function(shareDbCallback) { - mongodb.connect(mongoUrl, { useNewUrlParser: true }, function(err, mongo) { + const db = new ShareDbMongo({mongo: function(shareDbCallback) { + mongodb.connect(mongoUrl, { useNewUrlParser: true }, (err, mongo) => { if (err) return callback(err); - mongo.db().dropDatabase(function(err) { + mongo.db().dropDatabase(err => { if (err) return callback(err); shareDbCallback(null, mongo); callback(null, db, mongo); @@ -20,10 +20,10 @@ function create(callback) { require('@teamwork/sharedb/test/db')({create: create, getQuery: getQuery}); -describe('mongo db', function() { +describe('mongo db', () => { beforeEach(function(done) { - var self = this; - create(function(err, db, mongo) { + const self = this; + create((err, db, mongo) => { if (err) return done(err); self.db = db; self.mongo = mongo; @@ -35,12 +35,12 @@ describe('mongo db', function() { this.db.close(done); }); - describe('indexes', function() { + describe('indexes', () => { it('adds ops index', function(done) { - var mongo = this.mongo; - this.db.commit('testcollection', 'foo', {v: 0, create: {}}, {}, null, function(err) { + const mongo = this.mongo; + this.db.commit('testcollection', 'foo', {v: 0, create: {}}, {}, null, err => { if (err) return done(err); - mongo.db().collection('o_testcollection').indexInformation(function(err, indexes) { + mongo.db().collection('o_testcollection').indexInformation((err, indexes) => { if (err) return done(err); // Index for getting document(s) ops expect(indexes['d_1_v_1']).ok(); @@ -52,12 +52,12 @@ describe('mongo db', function() { }); it('respects unique indexes', function(done) { - var db = this.db; - this.mongo.db().collection('testcollection').createIndex({x: 1}, {unique: true}, function(err) { + const db = this.db; + this.mongo.db().collection('testcollection').createIndex({x: 1}, {unique: true}, err => { if (err) return done(err); - db.commit('testcollection', 'foo', {v: 0, create: {}}, {v: 1, data: {x: 7}}, null, function(err, succeeded) { + db.commit('testcollection', 'foo', {v: 0, create: {}}, {v: 1, data: {x: 7}}, null, (err, succeeded) => { if (err) return done(err); - db.commit('testcollection', 'bar', {v: 0, create: {}}, {v: 1, data: {x: 7}}, null, function(err, succeeded) { + db.commit('testcollection', 'bar', {v: 0, create: {}}, {v: 1, data: {x: 7}}, null, (err, succeeded) => { expect(err && err.code).equal(11000); done(); }); @@ -66,12 +66,12 @@ describe('mongo db', function() { }); }); - describe('security options', function() { + describe('security options', () => { it('does not allow editing the system collection', function(done) { - var db = this.db; - db.commit('system', 'test', {v: 0, create: {}}, {}, null, function(err) { + const db = this.db; + db.commit('system', 'test', {v: 0, create: {}}, {}, null, err => { expect(err).ok(); - db.getSnapshot('system', 'test', null, null, function(err) { + db.getSnapshot('system', 'test', null, null, err => { expect(err).ok(); done(); }); @@ -79,26 +79,26 @@ describe('mongo db', function() { }); }); - describe('query', function() { + describe('query', () => { // Run query tests for the types of queries supported by ShareDBMingo require('@teamwork/sharedb-mingo-memory/test/query')(); it('does not allow $where queries', function(done) { - this.db.query('testcollection', {$where: 'true'}, null, null, function(err, results) { + this.db.query('testcollection', {$where: 'true'}, null, null, (err, results) => { expect(err).ok(); done(); }); }); it('queryPollDoc does not allow $where queries', function(done) { - this.db.queryPollDoc('testcollection', 'somedoc', {$where: 'true'}, null, function(err) { + this.db.queryPollDoc('testcollection', 'somedoc', {$where: 'true'}, null, err => { expect(err).ok(); done(); }); }); it('$query is deprecated', function(done) { - this.db.query('testcollection', {$query: {}}, null, null, function(err) { + this.db.query('testcollection', {$query: {}}, null, null, err => { expect(err).ok(); expect(err.code).eql(4106); done(); @@ -106,7 +106,7 @@ describe('mongo db', function() { }); it('only one collection operation allowed', function(done) { - this.db.query('testcollection', {$distinct: {y: 1}, $aggregate: {}}, null, null, function(err) { + this.db.query('testcollection', {$distinct: {y: 1}, $aggregate: {}}, null, null, err => { expect(err).ok(); expect(err.code).eql(4108); done(); @@ -114,7 +114,7 @@ describe('mongo db', function() { }); it('only one cursor operation allowed', function(done) { - this.db.query('testcollection', {$count: true, $explain: true}, null, null, function(err) { + this.db.query('testcollection', {$count: true, $explain: true}, null, null, err => { expect(err).ok(); expect(err.code).eql(4109); done(); @@ -122,7 +122,7 @@ describe('mongo db', function() { }); it('cursor transform can\'t run after collection operation', function(done) { - this.db.query('testcollection', {$distinct: {y: 1}, $sort: {y: 1}}, null, null, function(err) { + this.db.query('testcollection', {$distinct: {y: 1}, $sort: {y: 1}}, null, null, err => { expect(err).ok(); expect(err.code).eql(4110); done(); @@ -130,7 +130,7 @@ describe('mongo db', function() { }); it('cursor operation can\'t run after collection operation', function(done) { - this.db.query('testcollection', {$distinct: {y: 1}, $count: true}, null, null, function(err) { + this.db.query('testcollection', {$distinct: {y: 1}, $count: true}, null, null, err => { expect(err).ok(); expect(err.code).eql(4110); done(); @@ -138,7 +138,7 @@ describe('mongo db', function() { }); it('non-object $readPref should return error', function(done) { - this.db.query('testcollection', {$readPref: true}, null, null, function(err) { + this.db.query('testcollection', {$readPref: true}, null, null, err => { expect(err).ok(); expect(err.code).eql(4107); done(); @@ -147,15 +147,15 @@ describe('mongo db', function() { it('malformed $mapReduce should return error', function(done) { this.db.allowJSQueries = true; // required for $mapReduce - this.db.query('testcollection', {$mapReduce: true}, null, null, function(err) { + this.db.query('testcollection', {$mapReduce: true}, null, null, err => { expect(err).ok(); expect(err.code).eql(4107); done(); }); }); - describe('queryPollDoc correctly filters on _id', function(done) { - var snapshot = {type: 'json0', v: 1, data: {}, id: "test"}; + describe('queryPollDoc correctly filters on _id', done => { + const snapshot = {type: 'json0', v: 1, data: {}, id: "test"}; beforeEach(function(done) { this.db.commit('testcollection', snapshot.id, {v: 0, create: {}}, snapshot, null, done); @@ -188,7 +188,7 @@ describe('mongo db', function() { snapshot.id, query, null, - function(err, hasDoc) { + (err, hasDoc) => { if (err) done(err); expect(hasDoc).eql(expectedHasDoc); done(); @@ -198,20 +198,20 @@ describe('mongo db', function() { }); it('$distinct should perform distinct operation', function(done) { - var snapshots = [ + const snapshots = [ {type: 'json0', v: 1, data: {x: 1, y: 1}}, {type: 'json0', v: 1, data: {x: 2, y: 2}}, {type: 'json0', v: 1, data: {x: 3, y: 2}} ]; - var db = this.db; - db.commit('testcollection', 'test1', {v: 0, create: {}}, snapshots[0], null, function(err) { + const db = this.db; + db.commit('testcollection', 'test1', {v: 0, create: {}}, snapshots[0], null, err => { if (err) return done(err); - db.commit('testcollection', 'test2', {v: 0, create: {}}, snapshots[1], null, function(err) { + db.commit('testcollection', 'test2', {v: 0, create: {}}, snapshots[1], null, err => { if (err) return done(err); - db.commit('testcollection', 'test3', {v: 0, create: {}}, snapshots[2], null, function(err) { + db.commit('testcollection', 'test3', {v: 0, create: {}}, snapshots[2], null, err => { if (err) return done(err); - var query = {$distinct: {field: 'y'}}; - db.query('testcollection', query, null, null, function(err, results, extra) { + const query = {$distinct: {field: 'y'}}; + db.query('testcollection', query, null, null, (err, results, extra) => { if (err) return done(err); expect(extra).eql([1, 2]); done(); @@ -222,24 +222,24 @@ describe('mongo db', function() { }); it('$aggregate should perform aggregate command', function(done) { - var snapshots = [ + const snapshots = [ {type: 'json0', v: 1, data: {x: 1, y: 1}}, {type: 'json0', v: 1, data: {x: 2, y: 2}}, {type: 'json0', v: 1, data: {x: 3, y: 2}} ]; - var db = this.db; + const db = this.db; db.allowAggregateQueries = true; - db.commit('testcollection', 'test1', {v: 0, create: {}}, snapshots[0], null, function(err) { + db.commit('testcollection', 'test1', {v: 0, create: {}}, snapshots[0], null, err => { if (err) return done(err); - db.commit('testcollection', 'test2', {v: 0, create: {}}, snapshots[1], null, function(err) { + db.commit('testcollection', 'test2', {v: 0, create: {}}, snapshots[1], null, err => { if (err) return done(err); - db.commit('testcollection', 'test3', {v: 0, create: {}}, snapshots[2], null, function(err) { + db.commit('testcollection', 'test3', {v: 0, create: {}}, snapshots[2], null, err => { if (err) return done(err); - var query = {$aggregate: [ + const query = {$aggregate: [ {$group: {_id: '$y', count: {$sum: 1}}}, {$sort: {count: 1}} ]}; - db.query('testcollection', query, null, null, function(err, results, extra) { + db.query('testcollection', query, null, null, (err, results, extra) => { if (err) return done(err); expect(extra).eql([{_id: 1, count: 1}, {_id: 2, count: 2}]); done(); @@ -250,42 +250,40 @@ describe('mongo db', function() { }); it('does not let you run $aggregate queries without options.allowAggregateQueries', function(done) { - var query = {$aggregate: [ + const query = {$aggregate: [ {$group: {_id: '$y',count: {$sum: 1}}}, {$sort: {count: 1}} ]}; - this.db.query('testcollection', query, null, null, function(err, results) { + this.db.query('testcollection', query, null, null, (err, results) => { expect(err).ok(); done(); }); }); it('does not allow $mapReduce queries by default', function(done) { - var snapshots = [ + const snapshots = [ {type: 'json0', v: 1, data: {player: 'a', round: 1, score: 5}}, {type: 'json0', v: 1, data: {player: 'a', round: 2, score: 7}}, {type: 'json0', v: 1, data: {player: 'b', round: 1, score: 15}} ]; - var db = this.db; - db.commit('testcollection', 'test1', {v: 0, create: {}}, snapshots[0], null, function(err) { + const db = this.db; + db.commit('testcollection', 'test1', {v: 0, create: {}}, snapshots[0], null, err => { if (err) return done(err); - db.commit('testcollection', 'test2', {v: 0, create: {}}, snapshots[1], null, function(err) { + db.commit('testcollection', 'test2', {v: 0, create: {}}, snapshots[1], null, err => { if (err) return done(err); - db.commit('testcollection', 'test3', {v: 0, create: {}}, snapshots[2], null, function(err) { + db.commit('testcollection', 'test3', {v: 0, create: {}}, snapshots[2], null, err => { if (err) return done(err); - var query = { + const query = { $mapReduce: { map: function() { emit(this.player, this.score); }, reduce: function(key, values) { - return values.reduce(function(t, s) { - return t + s; - }); + return values.reduce((t, s) => t + s); } } }; - db.query('testcollection', query, null, null, function(err) { + db.query('testcollection', query, null, null, err => { expect(err).ok(); done(); }); @@ -295,32 +293,30 @@ describe('mongo db', function() { }); it('$mapReduce queries should work when allowJavaScriptQuery == true', function(done) { - var snapshots = [ + const snapshots = [ {type: 'json0', v: 1, data: {player: 'a', round: 1, score: 5}}, {type: 'json0', v: 1, data: {player: 'a', round: 2, score: 7}}, {type: 'json0', v: 1, data: {player: 'b', round: 1, score: 15}} ]; - var db = this.db; + const db = this.db; db.allowJSQueries = true; - db.commit('testcollection', 'test1', {v: 0, create: {}}, snapshots[0], null, function(err) { + db.commit('testcollection', 'test1', {v: 0, create: {}}, snapshots[0], null, err => { if (err) return done(err); - db.commit('testcollection', 'test2', {v: 0, create: {}}, snapshots[1], null, function(err) { + db.commit('testcollection', 'test2', {v: 0, create: {}}, snapshots[1], null, err => { if (err) return done(err); - db.commit('testcollection', 'test3', {v: 0, create: {}}, snapshots[2], null, function(err) { + db.commit('testcollection', 'test3', {v: 0, create: {}}, snapshots[2], null, err => { if (err) return done(err); - var query = { + const query = { $mapReduce: { map: function() { emit(this.player, this.score); }, reduce: function(key, values) { - return values.reduce(function(t, s) { - return t + s; - }); + return values.reduce((t, s) => t + s); } } }; - db.query('testcollection', query, null, null, function(err, results, extra) { + db.query('testcollection', query, null, null, (err, results, extra) => { if (err) return done(err); expect(extra).eql([{_id: 'a', value: 12}, {_id: 'b', value: 15}]); done(); @@ -332,16 +328,16 @@ describe('mongo db', function() { }); }); -describe('mongo db connection', function() { - describe('via url string', function() { +describe('mongo db connection', () => { + describe('via url string', () => { beforeEach(function(done) { - this.db = ShareDbMongo({mongo: mongoUrl}); + this.db = new ShareDbMongo({mongo: mongoUrl}); // This will enqueue the callback, testing the 'pendingConnect' // logic. - this.db.getDbs(function(err, mongo, mongoPoll) { + this.db.getDbs((err, mongo, mongoPoll) => { if (err) return done(err); - mongo.db().dropDatabase(function(err) { + mongo.db().dropDatabase(err => { if (err) return done(err); done(); }); @@ -353,12 +349,12 @@ describe('mongo db connection', function() { }); it('commit and query', function(done) { - var snapshot = {type: 'json0', v: 1, data: {}, id: "test"}; - var db = this.db; + const snapshot = {type: 'json0', v: 1, data: {}, id: "test"}; + const db = this.db; - db.commit('testcollection', snapshot.id, {v: 0, create: {}}, snapshot, null, function(err) { + db.commit('testcollection', snapshot.id, {v: 0, create: {}}, snapshot, null, err => { if (err) return done(err); - db.query('testcollection', {}, null, null, function(err, results) { + db.query('testcollection', {}, null, null, (err, results) => { if (err) return done(err); expect(results).eql([snapshot]); done(); @@ -367,10 +363,10 @@ describe('mongo db connection', function() { }); }); - describe('via url string with mongoPoll and pollDelay option', function() { + describe('via url string with mongoPoll and pollDelay option', () => { beforeEach(function(done) { this.pollDelay = 1000; - this.db = ShareDbMongo({mongo: mongoUrl, mongoPoll: mongoUrl, pollDelay: this.pollDelay}); + this.db = new ShareDbMongo({mongo: mongoUrl, mongoPoll: mongoUrl, pollDelay: this.pollDelay}); done(); }); @@ -379,17 +375,17 @@ describe('mongo db connection', function() { }); it('delays queryPoll but not commit', function(done) { - var db = this.db; - var pollDelay = this.pollDelay; + const db = this.db; + const pollDelay = this.pollDelay; - var snapshot = {type: 'json0', v: 1, data: {}, id: "test"}; - var timeBeforeCommit = new Date; - db.commit('testcollection', snapshot.id, {v: 0, create: {}}, snapshot, null, function(err) { + const snapshot = {type: 'json0', v: 1, data: {}, id: "test"}; + const timeBeforeCommit = new Date; + db.commit('testcollection', snapshot.id, {v: 0, create: {}}, snapshot, null, err => { if (err) return done(err); expect((new Date) - timeBeforeCommit).lessThan(pollDelay); - var timeBeforeQuery = new Date; - db.queryPoll('testcollection', {}, null, function(err, results) { + const timeBeforeQuery = new Date; + db.queryPoll('testcollection', {}, null, (err, results) => { if (err) return done(err); expect(results.length).eql(1); expect((new Date) - timeBeforeQuery).greaterThan(pollDelay); @@ -400,39 +396,39 @@ describe('mongo db connection', function() { }); }); -describe('parse query', function() { - var parseQuery = ShareDbMongo._parseQuery; - var makeQuerySafe = ShareDbMongo._makeQuerySafe; +describe('parse query', () => { + const parseQuery = ShareDbMongo._parseQuery; + const makeQuerySafe = ShareDbMongo._makeQuerySafe; - var addsType = function(query) { - var queryWithTypeNeNull = shallowClone(query); + const addsType = query => { + const queryWithTypeNeNull = shallowClone(query); queryWithTypeNeNull._type = {$type: 2}; - var parsedQuery = parseQuery(query); + const parsedQuery = parseQuery(query); makeQuerySafe(parsedQuery.query); expect(parsedQuery.query).eql(queryWithTypeNeNull); }; - var doesNotModify = function(query) { - var parsedQuery = parseQuery(query); + const doesNotModify = query => { + const parsedQuery = parseQuery(query); makeQuerySafe(parsedQuery.query); expect(parsedQuery.query).eql(query); }; - describe('adds _type: {$type: 2} when necessary', function() { - it('basic', function() { + describe('adds _type: {$type: 2} when necessary', () => { + it('basic', () => { addsType({}); addsType({foo: null}); doesNotModify({foo: 1}); addsType({foo: {$bitsAllSet: 1}}); // We don't try to analyze $bitsAllSet }); - it('does not modify already set type', function() { + it('does not modify already set type', () => { doesNotModify({_type: null}); doesNotModify({_type: 'foo'}); doesNotModify({_type: {$ne: null}}); }); - it('ignores fields that remain set on deleted docs', function() { + it('ignores fields that remain set on deleted docs', () => { addsType({_id: 'x'}); addsType({_o: 'x'}); addsType({_v: 2}); @@ -452,13 +448,13 @@ describe('parse query', function() { doesNotModify({'_m.mtime': 2, foo: 1}); }); - it('$ne', function() { + it('$ne', () => { addsType({foo: {$ne: 1}}); doesNotModify({foo: {$ne: 1}, bar: 1}); doesNotModify({foo: {$ne: null}}); }); - it('comparisons', function() { + it('comparisons', () => { doesNotModify({foo: {$gt: 1}}); doesNotModify({foo: {$gte: 1}}); doesNotModify({foo: {$lt: 1}}); @@ -467,36 +463,36 @@ describe('parse query', function() { addsType({foo: {$gte: null, $lte: null}}); }); - it('$exists', function() { + it('$exists', () => { doesNotModify({foo: {$exists: true}}); addsType({foo: {$exists: false}}); doesNotModify({foo: {$exists: true}, bar: {$exists: false}}); }); - it('$not', function() { + it('$not', () => { addsType({$not: {foo: 1}}); addsType({$not: {foo: null}}); // We don't try to analyze $not }); - it('$in', function() { + it('$in', () => { doesNotModify({foo: {$in: [1, 2, 3]}}); addsType({foo: {$in: [null, 2, 3]}}); doesNotModify({foo: {$in: [null, 2, 3]}, bar: 1}); }) - it('top-level $and', function() { + it('top-level $and', () => { doesNotModify({$and: [{foo: {$ne: null}}, {bar: {$ne: null}}]}); doesNotModify({$and: [{foo: {$ne: 1}}, {bar: {$ne: null}}]}); addsType({$and: [{foo: {$ne: 1}}, {bar: {$ne: 1}}]}); }); - it('top-level $or', function() { + it('top-level $or', () => { doesNotModify({$or: [{foo: {$ne: null}}, {bar: {$ne: null}}]}); addsType({$or: [{foo: {$ne: 1}}, {bar: {$ne: null}}]}); addsType({$or: [{foo: {$ne: 1}}, {bar: {$ne: 1}}]}); }); - it('malformed queries', function() { + it('malformed queries', () => { // if we don't understand the query, definitely don't mark it as // "safe as is" addsType({$or: {foo: 3}}); @@ -510,8 +506,8 @@ describe('parse query', function() { }); function shallowClone(object) { - var out = {}; - for (var key in object) { + const out = {}; + for (const key in object) { out[key] = object[key]; } return out; diff --git a/test/test_skip_poll.js b/test/test_skip_poll.js index 67ef9270..9e928769 100644 --- a/test/test_skip_poll.js +++ b/test/test_skip_poll.js @@ -1,12 +1,12 @@ -var expect = require('expect.js'); -var ShareDbMongo = require('../index'); -var checkOp = require('@teamwork/sharedb/lib/ot').checkOp; +const expect = require('expect.js'); +const ShareDbMongo = require('../index'); +const checkOp = require('@teamwork/sharedb/lib/ot').checkOp; -describe('skipPoll', function() { +describe('skipPoll', () => { // Run a test function against a small sample set of queries function testSampleQueries(test) { function testInNewCase(query) { - it(JSON.stringify(query), function() { + it(JSON.stringify(query), () => { test(query); }) }; @@ -23,28 +23,28 @@ describe('skipPoll', function() { testInNewCase({$distinct: {field: 'a'}}); } - describe('noops always skip', function() { - testSampleQueries(function(query) { + describe('noops always skip', () => { + testSampleQueries(query => { assertSkips({v: 0}, query); }); }); - describe('creates never skip', function() { - testSampleQueries(function(query) { + describe('creates never skip', () => { + testSampleQueries(query => { assertNotSkips({v: 0, create: {type: 'json0', _id: 'dummyid'}}, query); assertNotSkips({v: 0, create: {type: 'json0', _id: 'dummyid', a: 1}}, query); assertNotSkips({v: 0, create: {type: 'json0', _id: 'dummyid', a: {b: 'foo'}}}, query); }); }); - describe('deletes never skip', function() { - testSampleQueries(function(query) { + describe('deletes never skip', () => { + testSampleQueries(query => { assertNotSkips({v: 0, del: true}, query); }); }); - describe('updates', function() { - it('never skip for queries returning extra', function() { + describe('updates', () => { + it('never skip for queries returning extra', () => { test({a: 1, $count: true}); test({$distinct: {field: 'a'}}); @@ -55,7 +55,7 @@ describe('skipPoll', function() { } }); - describe('skip sometimes for queries returning results', function() { + describe('skip sometimes for queries returning results', () => { test({a: 1}, ['a']); test({a: {$in: [1, 2]}}, ['a']); test({a: {$nin: [1, 2]}}, ['a']); @@ -88,19 +88,19 @@ describe('skipPoll', function() { // 'fields' is an array of top-level fields from which query reads function test(query, fields) { - describe(JSON.stringify(query), function() { - it('empty path changes', function() { + describe(JSON.stringify(query), () => { + it('empty path changes', () => { assertNotSkips({op: [{p: [], dummyOp: 1}]}, query); }); - it('top-level field changes', function() { + it('top-level field changes', () => { assertIfSkips({op: [{p: ['a'], dummyOp: 1}]}, query, !has(fields, 'a')); assertIfSkips({op: [{p: ['a', 1], dummyOp: 1}]}, query, !has(fields, 'a')); assertIfSkips({op: [{p: ['x'], dummyOp: 1}]}, query, !has(fields, 'x')); assertIfSkips({op: [{p: ['x', 'y'], dummyOp: 1}]}, query, !has(fields, 'x')); }); - it('multiple ops', function() { + it('multiple ops', () => { assertIfSkips( {op: [{p: ['a'], dummyOp: 1}, {p: ['x'], dummyOp: 1}]}, query, @@ -108,7 +108,7 @@ describe('skipPoll', function() { ); }); - it('multiple ops including empty path', function() { + it('multiple ops including empty path', () => { assertNotSkips({op: [{p: ['a'], dummyOp: 1}, {p: [], dummyOp: 1}]}, query); assertNotSkips({op: [{p: [], dummyOp: 1}, {p: ['x'], dummyOp: 1}]}, query); }); @@ -120,11 +120,11 @@ describe('skipPoll', function() { // `rawOp` is a partial op document, containing only one 'create', 'del' or 'op' function assertIfSkips(rawOp, query, expectedSkips) { - var op = {src: 'dummysrc', seq: 0, v: 0}; - for (var key in rawOp) { + const op = {src: 'dummysrc', seq: 0, v: 0}; + for (const key in rawOp) { op[key] = rawOp[key]; } - var actualSkips = ShareDbMongo.prototype.skipPoll( + const actualSkips = ShareDbMongo.prototype.skipPoll( 'dummycollection', 'dummyid', op, query); expect(actualSkips).eql(expectedSkips); }