recoder-code
Version:
Complete AI-powered development platform with ML model training, plugin registry, real-time collaboration, monitoring, infrastructure automation, and enterprise deployment capabilities
1,498 lines (1,359 loc) • 55.1 kB
JavaScript
var mongodb = require('./mongodb');
var DB = require('sharedb').DB;
var OpLinkValidator = require('./op-link-validator');
var MiddlewareHandler = require('./src/middleware/middlewareHandler');
module.exports = ShareDbMongo;
function ShareDbMongo(mongo, options) {
// use without new
if (!(this instanceof ShareDbMongo)) {
return new ShareDbMongo(mongo, options);
}
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;
// Setting this flag to true will attempt to infer a canonical op link for
// getOps rather than using the snapshot as the op link. This allows us to
// not fetch all ops to present when asking only for a subset.
// For more details on this, see the README.
this.getOpsWithoutStrictLinking = options.getOpsWithoutStrictLinking || false;
// Track whether the close method has been called
this.closed = false;
this.mongo = null;
this._mongoClient = null;
this.mongoPoll = null;
this._mongoPollClient = null;
if (typeof mongo === 'string' || typeof mongo === 'function') {
var self = this;
this._connection = this._connect(mongo, options)
.then(function(result) {
self.mongo = result.mongo;
self._mongoClient = result.mongoClient;
self.mongoPoll = result.mongoPoll;
self._mongoPollClient = result.mongoPollClient;
return result;
});
} else {
throw new Error('deprecated: pass mongo as url string or function with callback');
}
this._middleware = new MiddlewareHandler();
};
ShareDbMongo.prototype = Object.create(DB.prototype);
ShareDbMongo.prototype.projectsSnapshots = true;
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.collection(collectionName);
return callback(null, collection);
});
};
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).collection(collectionName);
return callback(null, collection);
});
};
ShareDbMongo.prototype.getCollectionPoll = function(collectionName, callback) {
if (this.pollDelay) {
var self = this;
setTimeout(function() {
self._getCollectionPoll(collectionName, callback);
}, this.pollDelay);
return;
}
this._getCollectionPoll(collectionName, callback);
};
ShareDbMongo.prototype.getDbs = function(callback) {
if (this.closed) {
var err = ShareDbMongo.alreadyClosedError();
return callback(err);
}
this._connection
.then(function(result) {
callback(null, result.mongo, result.mongoPoll);
}, callback);
};
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 connections = [connect(mongo, options.mongoOptions)];
var mongoPoll = options.mongoPoll;
if (mongoPoll) connections.push(connect(mongoPoll, options.mongoPollOptions));
return Promise.all(connections).then(function(clients) {
var mongoClient = clients[0];
var mongoPollClient = clients[1];
return {
mongo: mongoClient.db(),
mongoClient: mongoClient,
mongoPoll: mongoPollClient && mongoPollClient.db(),
mongoPollClient: mongoPollClient
};
});
};
function connect(mongo, options) {
if (typeof mongo === 'function') {
return new Promise(function(resolve, reject) {
mongo(function(error, client) {
if (error) return reject(error);
resolve(client);
});
});
}
options = Object.assign({}, options);
delete options.mongo;
delete options.mongoPoll;
delete options.mongoPollOptions;
delete options.pollDelay;
delete options.disableIndexCreation;
delete options.allowAllQueries;
delete options.allowJSQueries;
delete options.allowAllQueries;
delete options.allowAggregateQueries;
delete options.getOpsWithoutStrictLinking;
if (typeof mongodb.connect === 'function') {
return mongodb.connect(mongo, options);
} else {
var client = new mongodb.MongoClient(mongo, options);
return client.connect();
}
}
ShareDbMongo.prototype.close = function(callback) {
if (!callback) {
callback = function(err) {
if (err) throw err;
};
}
var self = this;
this.getDbs(function(err) {
// Ignore "already closed"
if (err && err.code === 5101) return callback();
if (err) return callback(err);
self.closed = true;
self._mongoClient.close()
.then(function() {
return self._mongoPollClient && self._mongoPollClient.close();
})
.then(function() {
callback(null);
}, callback);
});
};
// **** Commit methods
ShareDbMongo.prototype.commit = function(collectionName, id, op, snapshot, options, callback) {
var self = this;
var request = createRequestForMiddleware(options, collectionName, op);
this._writeOp(collectionName, id, op, snapshot, function(err, result) {
if (err) return callback(err);
var opId = result.insertedId;
self._writeSnapshot(request, id, snapshot, opId, function(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
self._deleteOp(request.collectionName, opId, function(removeErr) {
callback(err || removeErr, succeeded);
});
});
});
};
function createRequestForMiddleware(options, collectionName, op, fields) {
// Create a new request object which will be passed to helper functions and middleware
var request = {
options: options,
collectionName: collectionName
};
if (op) request.op = op;
// When we're creating a request for submitting an op, let downstream middleware know.
if (fields && fields.$submit === true) {
/**
* TODO What if sharedb populated this? We could use MIDDLEWARE_ACTIONS.
*/
request.triggeredBy = 'submitRequest';
}
return request;
}
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);
}
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)
.then(function(result) {
callback(null, result);
}, callback);
});
};
ShareDbMongo.prototype._deleteOp = function(collectionName, opId, callback) {
this.getOpCollection(collectionName, function(err, opCollection) {
if (err) return callback(err);
opCollection.deleteOne({_id: opId})
.then(function(result) {
callback(null, result);
}, callback);
});
};
ShareDbMongo.prototype._writeSnapshot = function(request, id, snapshot, opId, callback) {
var self = this;
this.getCollection(request.collectionName, function(err, collection) {
if (err) return callback(err);
request.documentToWrite = castToDoc(id, snapshot, opId);
if (request.documentToWrite._v === 1) {
self._middleware.trigger(MiddlewareHandler.Actions.beforeCreate, request, function(middlewareErr) {
if (middlewareErr) {
return callback(middlewareErr);
}
collection.insertOne(request.documentToWrite)
.then(
function() {
callback(null, true);
},
function(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);
}
);
});
} else {
request.query = {_id: id, _v: request.documentToWrite._v - 1};
self._middleware.trigger(MiddlewareHandler.Actions.beforeOverwrite, request, function(middlewareErr) {
if (middlewareErr) {
return callback(middlewareErr);
}
collection.replaceOne(request.query, request.documentToWrite)
.then(function(result) {
var succeeded = !!result.modifiedCount;
callback(null, succeeded);
}, callback);
});
}
});
};
// **** Snapshot methods
ShareDbMongo.prototype.getSnapshot = function(collectionName, id, fields, options, callback) {
var self = this;
this.getCollection(collectionName, function(err, collection) {
if (err) return callback(err);
var query = {_id: id};
var projection = getProjection(fields, options);
var request = createRequestForMiddleware(options, collectionName, null, fields);
request.query = query;
self._middleware.trigger(MiddlewareHandler.Actions.beforeSnapshotLookup, request, function(middlewareErr) {
if (middlewareErr) return callback(middlewareErr);
collection.find(request.query, request.findOptions).limit(1).project(projection).next()
.then(function(doc) {
var snapshot = (doc) ? castToSnapshot(doc) : new MongoSnapshot(id, 0, null, undefined);
callback(null, snapshot);
}, callback);
});
});
};
ShareDbMongo.prototype.getSnapshotBulk = function(collectionName, ids, fields, options, callback) {
var self = this;
this.getCollection(collectionName, function(err, collection) {
if (err) return callback(err);
var query = {_id: {$in: ids}};
var projection = getProjection(fields, options);
var request = createRequestForMiddleware(options, collectionName, null, fields);
request.query = query;
self._middleware.trigger(MiddlewareHandler.Actions.beforeSnapshotLookup, request, function(middlewareErr) {
if (middlewareErr) return callback(middlewareErr);
collection.find(request.query, request.findOptions).project(projection).toArray()
.then(function(docs) {
var snapshotMap = {};
for (var i = 0; i < docs.length; i++) {
var snapshot = castToSnapshot(docs[i]);
snapshotMap[snapshot.id] = snapshot;
}
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);
}, callback);
});
});
};
// **** Oplog methods
// Overwrite me if you want to change this behaviour.
ShareDbMongo.prototype.getOplogCollectionName = function(collectionName) {
return 'o_' + collectionName;
};
ShareDbMongo.prototype.validateCollectionName = function(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.
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.collection(name);
// Given the potential problems with creating indexes on the fly, it might
// be preferrable to disable automatic creation
if (self.disableIndexCreation === true) {
return callback(null, collection);
}
if (self.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.
var disabledIndexes = self.disableIndexCreation || {};
var promises = [
collection.createIndex({d: 1, v: 1}, {background: true}),
!disabledIndexes.src_seq_v && collection.createIndex({src: 1, seq: 1, v: 1}, {background: true})
];
Promise.all(promises)
.then(function() {
self.opIndexes[collectionName] = true;
callback(null, collection);
}, callback);
});
};
ShareDbMongo.prototype.getOpsToSnapshot = function(collectionName, id, from, snapshot, options, callback) {
if (snapshot._opLink == null) {
var err = ShareDbMongo.missingLastOperationError(collectionName, id);
return callback(err);
}
var options = Object.assign({}, options);
var to = null;
this._getOps(collectionName, id, from, to, options, function(err, ops) {
if (err) return callback(err);
var filtered = getLinkedOps(ops, null, snapshot._opLink);
var err = null;
if (!options.ignoreMissingOps) {
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;
var options = Object.assign({}, options);
this._getOpLink(collectionName, id, to, options, function(err, opLink) {
if (err) return callback(err);
// We need to fetch slightly more ops than requested in order to work backwards along
// linked ops to provide only valid ops
var fetchOpsTo = null;
if (opLink) {
if (isCurrentVersion(opLink, from)) {
return callback(null, []);
}
var err = opLink && checkDocHasOp(collectionName, id, opLink);
if (err) return callback(err);
if (self.getOpsWithoutStrictLinking) fetchOpsTo = opLink._v;
}
self._getOps(collectionName, id, from, fetchOpsTo, options, function(err, ops) {
if (err) return callback(err);
var filtered = filterOps(ops, opLink, to);
var err = null;
if (!options.ignoreMissingOps) {
err = checkOpsFrom(collectionName, id, filtered, from);
}
if (err) return callback(err);
callback(null, filtered);
});
});
};
ShareDbMongo.prototype.getOpsBulk = function(collectionName, fromMap, toMap, options, callback) {
var self = this;
var ids = Object.keys(fromMap);
this._getSnapshotOpLinkBulk(collectionName, ids, options, 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;
}
var err = checkDocHasOp(collectionName, id, doc);
if (err) return callback(err);
}
var 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
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;
}
callback(null, opsMap);
});
});
};
ShareDbMongo.prototype.getCommittedOpVersion = function(collectionName, id, snapshot, op, options, callback) {
var self = this;
this.getOpCollection(collectionName, function(err, opCollection) {
if (err) return callback(err);
var query = {
src: op.src,
seq: op.seq
};
var projection = {v: 1, _id: 0};
var 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()
.then(function(doc) {
// 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
// history of ops
if (!doc) return callback();
// 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) {
if (err) return callback(err);
for (var i = ops.length; i--;) {
var item = ops[i];
if (op.src === item.src && op.seq === item.seq) {
return callback(null, item.v);
}
}
callback();
});
}, callback);
});
};
function checkOpsFrom(collectionName, id, ops, from) {
if (ops.length === 0) return;
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;
return ShareDbMongo.missingLastOperationError(collectionName, id);
}
function isCurrentVersion(doc, version) {
return doc._v === version;
}
function getDocMap(docs) {
var docMap = {};
for (var i = 0; i < docs.length; i++) {
var doc = docs[i];
docMap[doc._id] = doc;
}
return docMap;
}
function filterOps(ops, doc, to) {
// Always return in the case of no ops found whether or not consistent with
// the snapshot
if (!ops) return [];
if (!ops.length) return ops;
if (!doc) {
// There is no snapshot currently. We already returned if there are no
// ops, so this could happen if:
// 1. The doc was deleted
// 2. The doc create op is written but not the doc snapshot
// 3. Same as 3 for a recreate
// 4. We are in an inconsistent state because of an error
//
// We treat the snapshot as the canonical version, so if the snapshot
// doesn't exist, the doc should be considered deleted. Thus, a delete op
// should be in the last version if no commits are inflight or second to
// last version if commit(s) are inflight. Rather than trying to detect
// ops inconsistent with a deleted state, we are simply returning ops from
// the last delete. Inconsistent states will ultimately cause write
// failures on attempt to commit.
//
// Different delete ops must be identical and must link back to the same
// prior version in order to be inserted, so if there are multiple delete
// 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
// assumed to be consistent in the future.
var 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
// if there are ops but the snapshot doesn't exist.
if (!deleteOp) return [];
return getLinkedOps(ops, to, deleteOp._id);
}
return getLinkedOps(ops, to, doc._o);
}
function getLatestDeleteOp(ops) {
for (var i = ops.length; i--;) {
var 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];
if (link.equals ? !link.equals(op._id) : link !== op._id) continue;
link = op.o;
if (to == null || op.v < to) {
delete op._id;
delete op.o;
linkedOps.push(op);
}
}
return linkedOps.reverse();
}
function getOpsQuery(id, from, to) {
from = from == null ? 0 : from;
var query = {
d: id,
v: {$gte: from}
};
if (to != null) {
query.v.$lt = to;
}
return query;
}
ShareDbMongo.prototype._getOps = function(collectionName, id, from, to, options, callback) {
this.getOpCollection(collectionName, function(err, opCollection) {
if (err) return callback(err);
var query = getOpsQuery(id, from, to);
// 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()
.then(function(result) {
callback(null, result);
}, 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) {
errored = true;
return callback(err);
});
stream.on('end', function() {
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;
});
}
callback(null, opsMap);
});
// Read each op and push onto a list for the appropriate doc
stream.on('data', function(op) {
var id = op.d;
if (opsMap[id]) {
opsMap[id].push(op);
} else {
opsMap[id] = [op];
}
delete op.d;
});
}
ShareDbMongo.prototype._getOpLink = function(collectionName, id, to, options, callback) {
if (!this.getOpsWithoutStrictLinking) return this._getSnapshotOpLink(collectionName, id, options, callback);
var db = this;
this.getOpCollection(collectionName, function(error, collection) {
if (error) return callback(error);
// If to is null, we want the most recent version, so just return the
// snapshot link, which is more efficient than cursoring
if (to == null) {
return db._getSnapshotOpLink(collectionName, id, options, callback);
}
var query = {
d: id,
v: {$gte: to}
};
var projection = {
_id: 0,
v: 1,
o: 1
};
var cursor = collection.find(query).sort({v: 1}).project(projection);
getFirstOpWithUniqueVersion(cursor, null, function(error, op) {
if (error) return callback(error);
if (op) return callback(null, {_o: op.o, _v: op.v});
// If we couldn't find an op to link back from, then fall back to using the current
// snapshot, which is guaranteed to have a link to a valid op.
db._getSnapshotOpLink(collectionName, id, options, callback);
});
});
};
// When getting ops, we need to consider the case where an op is committed to the database,
// but its application to the snapshot is subsequently rejected. This can leave multiple ops
// with the same values for 'd' and 'v', and means that we may return multiple ops for a single
// version if we just perform a naive 'find' operation.
// To avoid this, we try to fetch the first op from 'to' which has a unique 'v', and then we
// work backwards from that op using the linked op 'o' field to get a valid chain of ops.
// See the README for more details.
function getFirstOpWithUniqueVersion(cursor, opLinkValidator, callback) {
opLinkValidator = opLinkValidator || new OpLinkValidator();
var opWithUniqueVersion = opLinkValidator.opWithUniqueVersion();
if (opWithUniqueVersion || opLinkValidator.isAtEndOfList()) {
var error = null;
return closeCursor(cursor, callback, error, opWithUniqueVersion);
}
cursor.next()
.then(
function(op) {
opLinkValidator.push(op);
getFirstOpWithUniqueVersion(cursor, opLinkValidator, callback);
},
function(error) {
closeCursor(cursor, callback, error);
}
);
}
function closeCursor(cursor, callback, error, returnValue) {
cursor.close()
.then(function() {
callback(error, returnValue);
}, callback);
}
ShareDbMongo.prototype._getSnapshotOpLink = function(collectionName, id, options, callback) {
var self = this;
this.getCollection(collectionName, function(err, collection) {
if (err) return callback(err);
var query = {_id: id};
var projection = {_id: 0, _o: 1, _v: 1};
var request = createRequestForMiddleware(options, collectionName);
request.query = query;
self._middleware.trigger(MiddlewareHandler.Actions.beforeSnapshotLookup, request, function(middlewareErr) {
if (middlewareErr) return callback(middlewareErr);
collection.find(query, request.findOptions).limit(1).project(projection).next()
.then(function(result) {
callback(null, result);
}, callback);
});
});
};
ShareDbMongo.prototype._getSnapshotOpLinkBulk = function(collectionName, ids, options, callback) {
var self = this;
this.getCollection(collectionName, function(err, collection) {
if (err) return callback(err);
var query = {_id: {$in: ids}};
var projection = {_o: 1, _v: 1};
var request = createRequestForMiddleware(options, collectionName);
request.query = query;
self._middleware.trigger(MiddlewareHandler.Actions.beforeSnapshotLookup, request, function(middlewareErr) {
if (middlewareErr) return callback(middlewareErr);
collection.find(query, request.findOptions).project(projection).toArray()
.then(function(result) {
callback(null, result);
}, 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()
.then(function(result) {
callback(null, result);
}, 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) {
if (err) return callback(err);
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()
.then(function(doc) {
callback(null, !!doc);
}, callback);
});
};
// **** 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 = {};
getInnerFields(query.$orderby, fields);
getInnerFields(query.$sort, fields);
getInnerFields(query, fields);
return fields;
}
function getInnerFields(params, fields) {
if (!params) return;
for (var key in params) {
var 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];
getInnerFields(item, fields);
}
} else if (key[0] !== '$') {
var property = key.split('.')[0];
fields[property] = true;
}
}
}
function opContainsAnyField(op, fields) {
for (var i = 0; i < op.length; i++) {
var component = op[i];
if (component.p.length === 0) {
return true;
} else if (fields[component.p[0]]) {
return true;
}
}
return false;
}
// 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
// * If there is a collection operation then there are no cursor methods
// * There is at most one cursor operation like $count
//
// 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
for (var key in query) {
if (key[0] === '$') {
if (collectionOperationsMap[key]) {
// Found collection operation. Check that it's unique.
if (collectionOperationKey) {
return ShareDbMongo.onlyOneCollectionOperationError(
collectionOperationKey, key
);
}
collectionOperationKey = key;
} else if (cursorOperationsMap[key]) {
if (cursorOperationKey) {
return ShareDbMongo.onlyOneCursorOperationError(
cursorOperationKey, key
);
}
cursorOperationKey = key;
foundCursorMethod = true;
} else if (cursorTransformsMap[key]) {
foundCursorMethod = true;
}
}
}
if (collectionOperationKey && foundCursorMethod) {
return ShareDbMongo.cursorAndCollectionMethodError(
collectionOperationKey
);
}
return null;
}
function ParsedQuery(
query,
collectionOperationKey,
collectionOperationValue,
cursorTransforms,
cursorOperationKey,
cursorOperationValue
) {
this.query = query;
this.collectionOperationKey = collectionOperationKey;
this.collectionOperationValue = collectionOperationValue;
this.cursorTransforms = cursorTransforms;
this.cursorOperationKey = cursorOperationKey;
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
// * collectionOperationKey, collectionOperationValue: Key and value of the
// single collection operation (eg $mapReduce) defined in the input query,
// or null
// * cursorTransforms: Map of all the cursor transforms in the input query
// (eg $sort)
// * cursorOperationKey, cursorOperationValue: Key and value of the single
// cursor operation (eg $count) defined in the input query, or null
//
// Examples:
//
// parseQuery({foo: {$ne: 'bar'}, $distinct: {field: 'x'}}) ->
// {
// query: {foo: {$ne: 'bar'}},
// collectionOperationKey: '$distinct',
// collectionOperationValue: {field: 'x'},
// cursorTransforms: {},
// cursorOperationKey: null,
// cursorOperationValue: null
// }
//
// parseQuery({foo: 'bar', $limit: 2, $count: true}) ->
// {
// query: {foo: 'bar'},
// collectionOperationKey: null,
// collectionOperationValue: null
// cursorTransforms: {$limit: 2},
// cursorOperationKey: '$count',
// cursorOperationValue: 2
// }
var query = {};
var collectionOperationKey = null;
var collectionOperationValue = null;
var cursorTransforms = {};
var cursorOperationKey = null;
var cursorOperationValue = null;
if (inputQuery.$query) {
throw new Error('unexpected $query: should have called checkQuery');
} else {
for (var key in inputQuery) {
if (collectionOperationsMap[key]) {
collectionOperationKey = key;
collectionOperationValue = inputQuery[key];
} else if (cursorTransformsMap[key]) {
cursorTransforms[key] = inputQuery[key];
} else if (cursorOperationsMap[key]) {
cursorOperationKey = key;
cursorOperationValue = inputQuery[key];
} else {
query[key] = inputQuery[key];
}
}
}
return new ParsedQuery(
query,
collectionOperationKey,
collectionOperationValue,
cursorTransforms,
cursorOperationKey,
cursorOperationValue
);
};
ShareDbMongo._parseQuery = parseQuery; // for tests
// Call on a query after it gets parsed to make it safe against
// matching deleted documents.
function makeQuerySafe(query) {
// Don't modify the query if the user explicitly sets _type already
if (query.hasOwnProperty('_type')) return;
// Deleted documents are kept around so that we can start their version from
// the last version if they get recreated. When docs are deleted, their data
// properties are cleared and _type is set to null. Filter out deleted docs
// by requiring that _type is a string if the query does not naturally
// restrict the results with other keys
if (deletedDocCouldSatisfyQuery(query)) {
query._type = {$type: 2};
}
};
ShareDbMongo._makeQuerySafe = makeQuerySafe; // for tests
// Could a deleted doc (one that contains {_type: null} and no other
// fields) satisfy a query?
//
// Return true if it definitely can, or if we're not sure. (This
// function is used as an optimization to see whether we can avoid
// augmenting the query to ignore deleted documents)
function deletedDocCouldSatisfyQuery(query) {
// Any query with `{foo: value}` with non-null `value` will never
// match deleted documents (that are empty other than the `_type`
// field).
//
// This generalizes to additional classes of queries. Here’s a
// recursive description of queries that can't match a deleted doc:
// In general, a query with `{foo: X}` can't match a deleted doc
// if `X` is guaranteed to not match null or undefined. In addition
// to non-null values, the following clauses are guaranteed to not
// match null or undefined:
//
// * `{$in: [A, B, C]}}` where all of A, B, C are non-null.
// * `{$ne: null}`
// * `{$exists: true}`
// * `{$gt: not null}`, `{gte: not null}`, `{$lt: not null}`, `{$lte: not null}`
//
// In addition, some queries that have `$and` or `$or` at the
// top-level can't match deleted docs:
// * `{$and: [A, B, C]}`, where at least one of A, B, C are queries
// guaranteed to not match `{_type: null}`
// * `{$or: [A, B, C]}`, where all of A, B, C are queries guaranteed
// to not match `{_type: null}`
//
// There are more queries that can't match deleted docs but they
// aren’t that common, e.g. ones using `$type` or bit-wise
// operators.
if (query.hasOwnProperty('$and')) {
if (Array.isArray(query.$and)) {
for (var i = 0; i < query.$and.length; i++) {
if (!deletedDocCouldSatisfyQuery(query.$and[i])) {
return false;
}
}
} else {
// Malformed? Play it safe.
return true;
}
}
for (var prop in query) {
// Ignore fields that remain set on deleted docs
if (
prop === '_id' ||
prop === '_v' ||
prop === '_o' ||
prop === '_m' || (
prop[0] === '_' &&
prop[1] === 'm' &&
prop[2] === '.'
)
) {
continue;
}
// Top-level operators with special handling in this function
if (prop === '$and' || prop === '$or') {
continue;
}
// When using top-level operators that we don't understand, play
// it safe
if (prop[0] === '$') {
return true;
}
if (!couldMatchNull(query[prop])) {
return false;
}
}
if (query.hasOwnProperty('$or')) {
if (Array.isArray(query.$or)) {
for (var i = 0; i < query.$or.length; i++) {
if (deletedDocCouldSatisfyQuery(query.$or[i])) {
return true;
}
}
return false;
} else {
// Malformed? Play it safe.
return true;
}
}
return true;
}
function couldMatchNull(clause) {
if (
typeof clause === 'number' ||
typeof clause === 'boolean' ||
typeof clause === 'string'
) {
return false;
} else if (clause === null) {
return true;
} else if (isPlainObject(clause)) {
// Mongo interprets clauses with multiple properties with an
// 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];
if (prop === '$in' && Array.isArray(value)) {
var partCouldMatchNull = false;
for (var i = 0; i < value.length; i++) {
if (value[i] === null) {
partCouldMatchNull = true;
break;
}
}
if (!partCouldMatchNull) {
return false;
}
} else if (prop === '$ne') {
if (value === null) {
return false;
}
} else if (prop === '$exists') {
if (value) {
return false;
}
} else if (prop === '$gt' || prop === '$gte' || prop === '$lt' || prop === '$lte') {
if (value !== null) {
return false;
}
} else {
// Not sure what to do with this part of the clause; assume it
// could match null.
}
}
// All parts of the clause could match null.
return true;
} else {
// Not a POJO, string, number, or boolean. Not sure what it is,
// but play it safe.
return true;
}
}
function castToDoc(id, snapshot, opLink) {
var data = snapshot.data;
var doc =
(isObject(data)) ? shallowClone(data) :
(data === undefined) ? {} :
{_data: data};
doc._id = id;
doc._type = snapshot.type;
doc._v = snapshot.v;
doc._m = snapshot.m;
doc._o = opLink;
return doc;
}
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;
if (type == null) {
return new MongoSnapshot(id, version, null, undefined, meta, opLink);
}
if (doc.hasOwnProperty('_data')) {
return new MongoSnapshot(id, version, type, data, meta, opLink);
}
data = shallowClone(doc);
delete data._id;
delete data._v;
delete data._type;
delete data._m;
delete data._o;
return new MongoSnapshot(id, version, type, data, meta, opLink);
}
function MongoSnapshot(id, version, type, data, meta, opLink) {
this.id = id;
this.v = version;
this.type = type;
this.data = data;
this.m = meta == null ? null : meta;
if (opLink) this._opLink = opLink;
}
function isObject(value) {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
function shallowClone(object) {
var out = {};
for (var key in object) {
out[key] = object[key];
}
return out;
}
function isPlainObject(value) {
return (
typeof value === 'object' && (
Object.getPrototypeOf(value) === Object.prototype ||
Object.getPrototypeOf(value) === null
)
);
}
// Convert a simple map of fields that we want into a mongo projection. This
// depends on the data being stored at the top level of the document. It will
// only work properly for json documents--which are the only types for which
// we really want projections.
function getProjection(fields, options) {
// When there is no projection specified, still exclude returning the
// metadata that is added to a doc for querying or auditing
if (!fields) {
return (options && options.metadata) ? {_o: 0} : {_m: 0, _o: 0};
}
// Do not project when called by ShareDB submit
if (fields.$submit) return;
var projection = {};
for (var key in fields) {
projection[key] = 1;
}
projection._type = 1;
projection._v = 1;
if (options && options.metadata) projection._m = 1;
return projection;
}
var collectionOperationsMap = {
$distinct: function(collection, query, value, cb) {
collection.distinct(value.field, query)
.then(function(result) {
cb(null, result);
}, cb);
},
$aggregate: function(collection, query, value, cb) {
var cursor = collection.aggregate(value);
cursor.toArray()
.then(function(result) {
cb(null, result);
}, cb);
},
$mapReduce: function(collection, query, value, cb) {
if (typeof value !== 'object') {
var err = ShareDbMongo.malformedQueryOperatorError('$mapReduce');
return cb(err);
}
// This function was removed in mongodb5:
// https://github.com/mongodb/node-mongodb-native/pull/3511
if (typeof collection.mapReduce !== 'function') {
var