diff --git a/packages/allow-deny/allow-deny.js b/packages/allow-deny/allow-deny.js index c2ab3d6a6c..ebe9eddc9f 100644 --- a/packages/allow-deny/allow-deny.js +++ b/packages/allow-deny/allow-deny.js @@ -87,6 +87,9 @@ CollectionPrototype._defineMutationMethods = function(options) { self._insecure = undefined; self._validators = { + insert: {allow: [], deny: []}, + update: {allow: [], deny: []}, + remove: {allow: [], deny: []}, insertAsync: {allow: [], deny: []}, updateAsync: {allow: [], deny: []}, removeAsync: {allow: [], deny: []}, @@ -110,26 +113,40 @@ CollectionPrototype._defineMutationMethods = function(options) { if (self._connection && (self._connection === Meteor.server || Meteor.isClient)) { const m = {}; - ['insertAsync', 'updateAsync', 'removeAsync'].forEach((method) => { + [ + 'insertAsync', + 'updateAsync', + 'removeAsync', + 'insert', + 'update', + 'remove', + ].forEach(method => { const methodName = self._prefix + method; if (options.useExisting) { - const handlerPropName = Meteor.isClient ? '_methodHandlers' : 'method_handlers'; + const handlerPropName = Meteor.isClient + ? '_methodHandlers' + : 'method_handlers'; // Do not try to create additional methods if this has already been called. // (Otherwise the .methods() call below will throw an error.) - if (self._connection[handlerPropName] && - typeof self._connection[handlerPropName][methodName] === 'function') return; + if ( + self._connection[handlerPropName] && + typeof self._connection[handlerPropName][methodName] === 'function' + ) + return; } + const isInsert = name => name.includes('insert'); + m[methodName] = function (/* ... */) { // All the methods do their own validation, instead of using check(). check(arguments, [Match.Any]); const args = Array.from(arguments); try { - // For an insertAsync, if the client didn't specify an _id, generate one + // For an insert/insertAsync, if the client didn't specify an _id, generate one // now; because this uses DDP.randomStream, it will be consistent with // what the client generated. We generate it now rather than later so - // that if (eg) an allow/deny rule does an insertAsync to the same + // that if (eg) an allow/deny rule does an insert/insertAsync to the same // collection (not that it really should), the generated _id will // still be the first use of the stream and will be consistent. // @@ -138,42 +155,44 @@ CollectionPrototype._defineMutationMethods = function(options) { // between arbitrary client-specified _id fields and merely // client-controlled-via-randomSeed fields. let generatedId = null; - if (method === "insertAsync" && !hasOwn.call(args[0], '_id')) { + if (isInsert(method) && !hasOwn.call(args[0], '_id')) { generatedId = self._makeNewID(); } if (this.isSimulation) { // In a client simulation, you can do any mutation (even with a // complex selector). - if (generatedId !== null) + if (generatedId !== null) { args[0]._id = generatedId; - return self._collection[method].apply( - self._collection, args); + } + return self._collection[method].apply(self._collection, args); } // This is the server receiving a method call from the client. // We don't allow arbitrary selectors in mutations from the client: only // single-ID selectors. - if (method !== 'insertAsync') - throwIfSelectorIsNotId(args[0], method); + if (!isInsert(method)) throwIfSelectorIsNotId(args[0], method); if (self._restricted) { // short circuit if there is no way it will pass. if (self._validators[method].allow.length === 0) { throw new Meteor.Error( - 403, "Access denied. No allow validators set on restricted " + - "collection for method '" + method + "'."); + 403, + 'Access denied. No allow validators set on restricted ' + + "collection for method '" + + method + + "'." + ); } const validatedMethodName = '_validated' + method.charAt(0).toUpperCase() + method.slice(1); args.unshift(this.userId); - method === 'insertAsync' && args.push(generatedId); + isInsert(method) && args.push(generatedId); return self[validatedMethodName].apply(self, args); } else if (self._isInsecure()) { - if (generatedId !== null) - args[0]._id = generatedId; + if (generatedId !== null) args[0]._id = generatedId; // In insecure mode, allow any mutation (with a simple selector). // XXX This is kind of bogus. Instead of blindly passing whatever // we get from the network to this function, we should actually @@ -189,7 +208,7 @@ CollectionPrototype._defineMutationMethods = function(options) { } else { // In secure mode, if we haven't called allow or deny, then nothing // is permitted. - throw new Meteor.Error(403, "Access denied"); + throw new Meteor.Error(403, 'Access denied'); } } catch (e) { if ( @@ -261,7 +280,37 @@ CollectionPrototype._validatedInsertAsync = function (userId, doc, if (generatedId !== null) doc._id = generatedId; - self._collection.insertAsync.call(self._collection, doc); + return self._collection.insertAsync.call(self._collection, doc); +}; + +CollectionPrototype._validatedInsert = function (userId, doc, + generatedId) { + const self = this; + + // call user validators. + // Any deny returns true means denied. + if (self._validators.insert.deny.some((validator) => { + return validator(userId, docToValidate(validator, doc, generatedId)); + })) { + throw new Meteor.Error(403, "Access denied"); + } + // Any allow returns true means proceed. Throw error if they all fail. + + if (self._validators.insert.allow.every((validator) => { + return !validator(userId, docToValidate(validator, doc, generatedId)); + })) { + throw new Meteor.Error(403, "Access denied"); + } + + // If we generated an ID above, insert it now: after the validation, but + // before actually inserting. + if (generatedId !== null) + doc._id = generatedId; + + return (Meteor.isServer + ? self._collection.insertAsync + : self._collection.insert + ).call(self._collection, doc); }; // Simulate a mongo `update` operation while validating that the access @@ -364,6 +413,102 @@ CollectionPrototype._validatedUpdateAsync = async function( self._collection, selector, mutator, options); }; +CollectionPrototype._validatedUpdate = function( + userId, selector, mutator, options) { + const self = this; + + check(mutator, Object); + + options = Object.assign(Object.create(null), options); + + if (!LocalCollection._selectorIsIdPerhapsAsObject(selector)) + throw new Error("validated update should be of a single ID"); + + // We don't support upserts because they don't fit nicely into allow/deny + // rules. + if (options.upsert) + throw new Meteor.Error(403, "Access denied. Upserts not " + + "allowed in a restricted collection."); + + const noReplaceError = "Access denied. In a restricted collection you can only" + + " update documents, not replace them. Use a Mongo update operator, such " + + "as '$set'."; + + const mutatorKeys = Object.keys(mutator); + + // compute modified fields + const modifiedFields = {}; + + if (mutatorKeys.length === 0) { + throw new Meteor.Error(403, noReplaceError); + } + mutatorKeys.forEach((op) => { + const params = mutator[op]; + if (op.charAt(0) !== '$') { + throw new Meteor.Error(403, noReplaceError); + } else if (!hasOwn.call(ALLOWED_UPDATE_OPERATIONS, op)) { + throw new Meteor.Error( + 403, "Access denied. Operator " + op + " not allowed in a restricted collection."); + } else { + Object.keys(params).forEach((field) => { + // treat dotted fields as if they are replacing their + // top-level part + if (field.indexOf('.') !== -1) + field = field.substring(0, field.indexOf('.')); + + // record the field we are trying to change + modifiedFields[field] = true; + }); + } + }); + + const fields = Object.keys(modifiedFields); + + const findOptions = {transform: null}; + if (!self._validators.fetchAllFields) { + findOptions.fields = {}; + self._validators.fetch.forEach((fieldName) => { + findOptions.fields[fieldName] = 1; + }); + } + + const doc = self._collection.findOne(selector, findOptions); + if (!doc) // none satisfied! + return 0; + + // call user validators. + // Any deny returns true means denied. + if (self._validators.update.deny.some((validator) => { + const factoriedDoc = transformDoc(validator, doc); + return validator(userId, + factoriedDoc, + fields, + mutator); + })) { + throw new Meteor.Error(403, "Access denied"); + } + // Any allow returns true means proceed. Throw error if they all fail. + if (self._validators.update.allow.every((validator) => { + const factoriedDoc = transformDoc(validator, doc); + return !validator(userId, + factoriedDoc, + fields, + mutator); + })) { + throw new Meteor.Error(403, "Access denied"); + } + + options._forbidReplace = true; + + // Back when we supported arbitrary client-provided selectors, we actually + // rewrote the selector to include an _id clause before passing to Mongo to + // avoid races, but since selector is guaranteed to already just be an ID, we + // don't have to any more. + + return self._collection.update.call( + self._collection, selector, mutator, options); +}; + // Only allow these operations in validated updates. Specifically // whitelist operations, rather than blacklist, so new complex // operations that are added aren't automatically allowed. A complex @@ -414,6 +559,43 @@ CollectionPrototype._validatedRemoveAsync = async function(userId, selector) { return self._collection.removeAsync.call(self._collection, selector); }; +CollectionPrototype._validatedRemove = function(userId, selector) { + const self = this; + + const findOptions = {transform: null}; + if (!self._validators.fetchAllFields) { + findOptions.fields = {}; + self._validators.fetch.forEach((fieldName) => { + findOptions.fields[fieldName] = 1; + }); + } + + const doc = self._collection.findOne(selector, findOptions); + if (!doc) + return 0; + + // call user validators. + // Any deny returns true means denied. + if (self._validators.remove.deny.some((validator) => { + return validator(userId, transformDoc(validator, doc)); + })) { + throw new Meteor.Error(403, "Access denied"); + } + // Any allow returns true means proceed. Throw error if they all fail. + if (self._validators.remove.allow.every((validator) => { + return !validator(userId, transformDoc(validator, doc)); + })) { + throw new Meteor.Error(403, "Access denied"); + } + + // Back when we supported arbitrary client-provided selectors, we actually + // rewrote the selector to {_id: {$in: [ids that we found]}} before passing to + // Mongo to avoid races, but since selector is guaranteed to already just be + // an ID, we don't have to any more. + + return self._collection.remove.call(self._collection, selector); +}; + CollectionPrototype._callMutatorMethodAsync = async function _callMutatorMethod(name, args) { // For two out of three mutator methods, the first argument is a selector @@ -429,6 +611,36 @@ CollectionPrototype._callMutatorMethodAsync = async function _callMutatorMethod( return await this._connection.applyAsync(mutatorMethodName, args, { returnStubValue: true , throwStubExceptions: true }); } +CollectionPrototype._callMutatorMethod = function _callMutatorMethod(name, args, callback) { + if (Meteor.isClient && !callback && !alreadyInSimulation()) { + // Client can't block, so it can't report errors by exception, + // only by callback. If they forget the callback, give them a + // default one that logs the error, so they aren't totally + // baffled if their writes don't work because their database is + // down. + // Don't give a default callback in simulation, because inside stubs we + // want to return the results from the local collection immediately and + // not force a callback. + callback = function (err) { + if (err) + Meteor._debug(name + " failed", err); + }; + } + + // For two out of three mutator methods, the first argument is a selector + const firstArgIsSelector = name === "update" || name === "remove"; + if (firstArgIsSelector && !alreadyInSimulation()) { + // If we're about to actually send an RPC, we should throw an error if + // this is a non-ID selector, because the mutation methods only allow + // single-ID selectors. (If we don't throw here, we'll see flicker.) + throwIfSelectorIsNotId(args[0], name); + } + + const mutatorMethodName = this._prefix + name; + return this._connection.apply( + mutatorMethodName, args, { returnStubValue: true }, callback); +} + function transformDoc(validator, doc) { if (validator.transform) return validator.transform(doc); @@ -454,7 +666,7 @@ function docToValidate(validator, doc, generatedId) { function addValidator(collection, allowOrDeny, options) { // validate keys - const validKeysRegEx = /^(?:insertAsync|updateAsync|removeAsync|fetch|transform)$/; + const validKeysRegEx = /^(?:insertAsync|updateAsync|removeAsync|insert|update|remove|fetch|transform)$/; Object.keys(options).forEach((key) => { if (!validKeysRegEx.test(key)) throw new Error(allowOrDeny + ": Invalid key: " + key); @@ -462,20 +674,30 @@ function addValidator(collection, allowOrDeny, options) { collection._restricted = true; - ['insertAsync', 'updateAsync', 'removeAsync'].forEach((name) => { + [ + 'insertAsync', + 'updateAsync', + 'removeAsync', + 'insert', + 'update', + 'remove', + ].forEach(name => { if (hasOwn.call(options, name)) { if (!(options[name] instanceof Function)) { - throw new Error(allowOrDeny + ": Value for `" + name + "` must be a function"); + throw new Error( + allowOrDeny + ': Value for `' + name + '` must be a function' + ); } // If the transform is specified at all (including as 'null') in this // call, then take that; otherwise, take the transform from the // collection. if (options.transform === undefined) { - options[name].transform = collection._transform; // already wrapped + options[name].transform = collection._transform; // already wrapped } else { options[name].transform = LocalCollection.wrapTransform( - options.transform); + options.transform + ); } collection._validators[name][allowOrDeny].push(options[name]); } diff --git a/packages/ddp-client/test/livedata_test_service.js b/packages/ddp-client/test/livedata_test_service.js index c59da5fe54..55d6e49ba8 100644 --- a/packages/ddp-client/test/livedata_test_service.js +++ b/packages/ddp-client/test/livedata_test_service.js @@ -97,6 +97,15 @@ Ledger.allow({ removeAsync: function() { return true; }, + insert: function() { + return true; + }, + update: function() { + return true; + }, + remove: function() { + return true; + }, fetch: [] }); diff --git a/packages/ddp-client/test/livedata_tests.js b/packages/ddp-client/test/livedata_tests.js index f0ba610409..97e107a184 100644 --- a/packages/ddp-client/test/livedata_tests.js +++ b/packages/ddp-client/test/livedata_tests.js @@ -381,46 +381,44 @@ const subscribeBeforeRun = async (subName, testId, cb) => { // this is a big hack (and XXX pollutes the global test namespace) testAsyncMulti('livedata - compound methods', [ async function(test) { - await Ledger.insertAsync({ - name: 'alice', - balance: 100, - world: test.runId(), - }); - await Ledger.insertAsync({ name: 'bob', balance: 50, world: test.runId() }); + if (Meteor.isClient) { + Meteor.subscribe('ledger', test.runId(), () => {}); + } + + await Ledger.insertAsync( + { name: 'alice', balance: 100, world: test.runId() } + ); + await Ledger.insertAsync( + { name: 'bob', balance: 50, world: test.runId() } + ); }, async function(test) { - await subscribeBeforeRun('ledger', test.runId(), async () => { + await Meteor.callAsync( + 'ledger/transfer', + test.runId(), + 'alice', + 'bob', + 10, + ) + await checkBalances(test, 90, 60); + }, + async function(test) { + try { await Meteor.callAsync( 'ledger/transfer', test.runId(), 'alice', 'bob', - 10 + 100, + true, ); - await checkBalances(test, 90, 60); - }); - }, - async function(test) { - await subscribeBeforeRun('ledger', test.runId(), async () => { - try { - await Meteor.callAsync( - 'ledger/transfer', - test.runId(), - 'alice', - 'bob', - 100, - true - ); - } catch (e) {} - - if (Meteor.isClient) { - // client can fool itself by cheating, but only until the sync - // finishes - await checkBalances(test, -10, 160); - } else { - await checkBalances(test, 90, 60); - } - }); + } catch (e) { + } + if (Meteor.isClient) + // client can fool itself by cheating, but only until the sync + // finishes + await checkBalances(test, -10, 160); + else await checkBalances(test, 90, 60); }, ]); diff --git a/packages/ddp-server/livedata_server.js b/packages/ddp-server/livedata_server.js index a05b0c5b2d..c6f4216d81 100644 --- a/packages/ddp-server/livedata_server.js +++ b/packages/ddp-server/livedata_server.js @@ -328,9 +328,7 @@ var Session = function (server, version, socket, options) { self.send({ msg: 'connected', session: self.id }); // On initial connect, spin up all the universal publishers. - Meteor._runAsync(function() { - self.startUniversalSubs(); - }); + self.startUniversalSubs(); if (version !== 'pre1' && options.heartbeatInterval !== 0) { // We no longer need the low level timeout because we have heartbeats. @@ -372,8 +370,15 @@ Object.assign(Session.prototype, { sendAdded(collectionName, id, fields) { - if (this._canSend(collectionName)) - this.send({msg: "added", collection: collectionName, id, fields}); + if (this._canSend(collectionName)) { + if (!this._publishCursorPromise) { + this.send({ msg: 'added', collection: collectionName, id, fields }); + return; + } + this._publishCursorPromise.finally(() => + this.send({ msg: 'added', collection: collectionName, id, fields }) + ); + } }, sendChanged(collectionName, id, fields) { @@ -602,7 +607,7 @@ Object.assign(Session.prototype, { unblock(); // in case the handler didn't already do it } - Meteor._runAsync(runHandlers); + runHandlers(); }; processNext(); @@ -714,10 +719,13 @@ Object.assign(Session.prototype, { // Find the handler var handler = self.server.method_handlers[msg.method]; if (!handler) { - self.send({ - msg: 'result', id: msg.id, - error: new Meteor.Error(404, `Method '${msg.method}' not found`)}); - fence.arm(); + fence.arm().finally(() => { + self.send({ + msg: 'result', + id: msg.id, + error: new Meteor.Error(404, `Method '${msg.method}' not found`), + }); + }); return; } @@ -790,8 +798,9 @@ Object.assign(Session.prototype, { }); function finish() { - fence.arm(); - unblock(); + return fence.arm().finally(() => { + unblock(); + }); } const payload = { @@ -800,18 +809,20 @@ Object.assign(Session.prototype, { }; promise.then(result => { - finish(); - if (result !== undefined) { - payload.result = result; - } - self.send(payload); + finish().then(() => { + if (result !== undefined) { + payload.result = result; + } + self.send(payload); + }); }, (exception) => { - finish(); - payload.error = wrapInternalException( - exception, - `while invoking method '${msg.method}'` - ); - self.send(payload); + finish().then(() => { + payload.error = wrapInternalException( + exception, + `while invoking method '${msg.method}'` + ); + self.send(payload); + }); }); } }, @@ -1190,7 +1201,7 @@ Object.assign(Subscription.prototype, { // mark subscription as ready. self.ready(); } else { - res._publishCursor(self).then(() => { + this._publishCursorPromise = res._publishCursor(self).then(() => { self.ready(); }).catch((e) => self.error(e)); } @@ -1226,7 +1237,7 @@ Object.assign(Subscription.prototype, { } self.ready(); } else { - Promise.all(res.map((c) => c._publishCursor(self))).then(() => { + this._publishCursorPromise = Promise.all(res.map((c) => c._publishCursor(self))).then(() => { self.ready(); }).catch((e) => self.error(e)); } @@ -1367,6 +1378,7 @@ Object.assign(Subscription.prototype, { ids.add(id); } + this._session._publishCursorPromise = this._publishCursorPromise; this._session.added(this._subscriptionHandle, collectionName, id, fields); }, @@ -1707,9 +1719,7 @@ Object.assign(Server.prototype, { // self.sessions to change while we're running this loop. self.sessions.forEach(function (session) { if (!session._dontStartNewUniversalSubs) { - Meteor._runAsync(function() { - session._startSubscription(handler); - }); + session._startSubscription(handler); } }); } diff --git a/packages/minimongo/local_collection.js b/packages/minimongo/local_collection.js index d73ecafe12..f7ff82aeb5 100644 --- a/packages/minimongo/local_collection.js +++ b/packages/minimongo/local_collection.js @@ -155,7 +155,6 @@ export default class LocalCollection { } }); - // TODO -> Check here. this._observeQueue.drain(); if (callback) { Meteor.defer(() => { diff --git a/packages/mongo/collection.js b/packages/mongo/collection.js index 43cc672253..8c4ec81cfa 100644 --- a/packages/mongo/collection.js +++ b/packages/mongo/collection.js @@ -621,7 +621,113 @@ Object.assign(Mongo.Collection.prototype, { // generating their result until the database has acknowledged // them. In the future maybe we should provide a flag to turn this // off. - async insertAsync(doc) { + + _insert(doc, callback) { + // Make sure we were passed a document to insert + if (!doc) { + throw new Error('insert requires an argument'); + } + + // Make a shallow clone of the document, preserving its prototype. + doc = Object.create( + Object.getPrototypeOf(doc), + Object.getOwnPropertyDescriptors(doc) + ); + + if ('_id' in doc) { + if ( + !doc._id || + !(typeof doc._id === 'string' || doc._id instanceof Mongo.ObjectID) + ) { + throw new Error( + 'Meteor requires document _id fields to be non-empty strings or ObjectIDs' + ); + } + } else { + let generateId = true; + + // Don't generate the id if we're the client and the 'outermost' call + // This optimization saves us passing both the randomSeed and the id + // Passing both is redundant. + if (this._isRemoteCollection()) { + const enclosing = DDP._CurrentMethodInvocation.get(); + if (!enclosing) { + generateId = false; + } + } + + if (generateId) { + doc._id = this._makeNewID(); + } + } + + + // On inserts, always return the id that we generated; on all other + // operations, just return the result from the collection. + var chooseReturnValueFromCollectionResult = function(result) { + if (Meteor._isPromise(result)) return result; + + if (doc._id) { + return doc._id; + } + + // XXX what is this for?? + // It's some iteraction between the callback to _callMutatorMethod and + // the return value conversion + doc._id = result; + + return result; + }; + + const wrappedCallback = wrapCallback( + callback, + chooseReturnValueFromCollectionResult + ); + + if (this._isRemoteCollection()) { + const result = this._callMutatorMethod('insert', [doc], wrappedCallback); + return chooseReturnValueFromCollectionResult(result); + } + + // it's my collection. descend into the collection object + // and propagate any exception. + try { + // If the user provided a callback and the collection implements this + // operation asynchronously, then queryRet will be undefined, and the + // result will be returned through the callback instead. + let result; + if (!!wrappedCallback) { + this._collection.insert(doc, wrappedCallback); + } else { + // If we don't have the callback, we assume the user is using the promise. + // We can't just pass this._collection.insert to the promisify because it would lose the context. + result = this._collection.insert(doc); + } + + return chooseReturnValueFromCollectionResult(result); + } catch (e) { + if (callback) { + callback(e); + return null; + } + throw e; + } + }, + + /** + * @summary Insert a document in the collection. Returns its unique _id. + * @locus Anywhere + * @method insert + * @memberof Mongo.Collection + * @instance + * @param {Object} doc The document to insert. May not yet have an _id attribute, in which case Meteor will generate one for you. + * @param {Function} [callback] Optional. If present, called with an error object as the first argument and, if no error, the _id as the second. + */ + insert(doc, callback) { + return this._insert(doc, callback); + }, + + async _insertAsync(doc) { // Make sure we were passed a document to insert if (!doc) { throw new Error('insert requires an argument'); @@ -698,18 +804,17 @@ Object.assign(Mongo.Collection.prototype, { } }, - // /** - // * @summary Insert a document in the collection. Returns its unique _id. - // * @locus Anywhere - // * @method insert - // * @memberof Mongo.Collection - // * @instance - // * @param {Object} doc The document to insert. May not yet have an _id attribute, in which case Meteor will generate one for you. - // * @param {Function} [callback] Optional. If present, called with an error object as the first argument and, if no error, the _id as the second. - // */ - // insertAsync(doc, callback) { - // return this._insertAsync(doc, callback); - // }, + /** + * @summary Insert a document in the collection. Returns a promise that will return the document's unique _id when solved. + * @locus Anywhere + * @method insert + * @memberof Mongo.Collection + * @instance + * @param {Object} doc The document to insert. May not yet have an _id attribute, in which case Meteor will generate one for you. + */ + insertAsync(doc) { + return this._insertAsync(doc); + }, /** * @summary Modify one or more documents in the collection. Returns the number of matched documents. @@ -723,7 +828,6 @@ Object.assign(Mongo.Collection.prototype, { * @param {Boolean} options.multi True to modify all matching documents; false to only modify one of the matching documents (the default). * @param {Boolean} options.upsert True to insert a document if no matching documents are found. * @param {Array} options.arrayFilters Optional. Used in combination with MongoDB [filtered positional operator](https://docs.mongodb.com/manual/reference/operator/update/positional-filtered/) to specify which elements to modify in an array field. - * @param {Function} [callback] Optional. If present, called with an error object as the first argument and, if no error, the number of affected documents as the second. */ async updateAsync(selector, modifier, ...optionsAndCallback) { @@ -773,7 +877,83 @@ Object.assign(Mongo.Collection.prototype, { }, /** - * @summary Remove documents from the collection + * @summary Asynchronously modifies one or more documents in the collection. Returns the number of matched documents. + * @locus Anywhere + * @method update + * @memberof Mongo.Collection + * @instance + * @param {MongoSelector} selector Specifies which documents to modify + * @param {MongoModifier} modifier Specifies how to modify the documents + * @param {Object} [options] + * @param {Boolean} options.multi True to modify all matching documents; false to only modify one of the matching documents (the default). + * @param {Boolean} options.upsert True to insert a document if no matching documents are found. + * @param {Array} options.arrayFilters Optional. Used in combination with MongoDB [filtered positional operator](https://docs.mongodb.com/manual/reference/operator/update/positional-filtered/) to specify which elements to modify in an array field. + * @param {Function} [callback] Optional. If present, called with an error object as the first argument and, if no error, the number of affected documents as the second. + */ + update(selector, modifier, ...optionsAndCallback) { + const callback = popCallbackFromArgs(optionsAndCallback); + + // We've already popped off the callback, so we are left with an array + // of one or zero items + const options = { ...(optionsAndCallback[0] || null) }; + let insertedId; + if (options && options.upsert) { + // set `insertedId` if absent. `insertedId` is a Meteor extension. + if (options.insertedId) { + if ( + !( + typeof options.insertedId === 'string' || + options.insertedId instanceof Mongo.ObjectID + ) + ) + throw new Error('insertedId must be string or ObjectID'); + insertedId = options.insertedId; + } else if (!selector || !selector._id) { + insertedId = this._makeNewID(); + options.generatedId = true; + options.insertedId = insertedId; + } + } + + selector = Mongo.Collection._rewriteSelector(selector, { + fallbackId: insertedId, + }); + + const wrappedCallback = wrapCallback(callback); + + if (this._isRemoteCollection()) { + const args = [selector, modifier, options]; + + return this._callMutatorMethod('update', args); + } + + // it's my collection. descend into the collection object + // and propagate any exception. + // If the user provided a callback and the collection implements this + // operation asynchronously, then queryRet will be undefined, and the + // result will be returned through the callback instead. + //console.log({callback, options, selector, modifier, coll: this._collection}); + try { + // If the user provided a callback and the collection implements this + // operation asynchronously, then queryRet will be undefined, and the + // result will be returned through the callback instead. + return this._collection.update( + selector, + modifier, + options, + wrappedCallback + ); + } catch (e) { + if (callback) { + callback(e); + return null; + } + throw e; + } + }, + + /** + * @summary Asynchronously removes documents from the collection. * @locus Anywhere * @method remove * @memberof Mongo.Collection @@ -792,6 +972,27 @@ Object.assign(Mongo.Collection.prototype, { return this._collection.removeAsync(selector); }, + /** + * @summary Remove documents from the collection + * @locus Anywhere + * @method remove + * @memberof Mongo.Collection + * @instance + * @param {MongoSelector} selector Specifies which documents to remove + */ + remove(selector) { + selector = Mongo.Collection._rewriteSelector(selector); + + if (this._isRemoteCollection()) { + return this._callMutatorMethod('remove', [selector]); + } + + // it's my collection. descend into the collection1 object + // and propagate any exception. + return this._collection.remove(selector); + }, + + // Determine if this collection is simply a minimongo representation of a real // database on another server _isRemoteCollection() { @@ -800,7 +1001,7 @@ Object.assign(Mongo.Collection.prototype, { }, /** - * @summary Modify one or more documents in the collection, or insert one if no matching documents were found. Returns an object with keys `numberAffected` (the number of documents modified) and `insertedId` (the unique _id of the document that was inserted, if any). + * @summary Asynchronously modifies one or more documents in the collection, or insert one if no matching documents were found. Returns an object with keys `numberAffected` (the number of documents modified) and `insertedId` (the unique _id of the document that was inserted, if any). * @locus Anywhere * @method upsert * @memberof Mongo.Collection @@ -821,10 +1022,32 @@ Object.assign(Mongo.Collection.prototype, { }); }, + /** + * @summary Modify one or more documents in the collection, or insert one if no matching documents were found. Returns an object with keys `numberAffected` (the number of documents modified) and `insertedId` (the unique _id of the document that was inserted, if any). + * @locus Anywhere + * @method upsert + * @memberof Mongo.Collection + * @instance + * @param {MongoSelector} selector Specifies which documents to modify + * @param {MongoModifier} modifier Specifies how to modify the documents + * @param {Object} [options] + * @param {Boolean} options.multi True to modify all matching documents; false to only modify one of the matching documents (the default). + */ + upsert(selector, modifier, options) { + return this.update( + selector, + modifier, + { + ...options, + _returnObject: true, + upsert: true, + }); + }, + // We'll actually design an index API later. For now, we just pass through to // Mongo's, but make it synchronous. /** - * @summary Creates the specified index on the collection. + * @summary Asynchronously creates the specified index on the collection. * @locus server * @method ensureIndexAsync * @deprecated in 3.0 @@ -851,7 +1074,7 @@ Object.assign(Mongo.Collection.prototype, { }, /** - * @summary Creates the specified index on the collection. + * @summary Asynchronously creates the specified index on the collection. * @locus server * @method createIndexAsync * @memberof Mongo.Collection diff --git a/packages/mongo/observe_multiplex.js b/packages/mongo/observe_multiplex.js index 196d98a598..d248e06943 100644 --- a/packages/mongo/observe_multiplex.js +++ b/packages/mongo/observe_multiplex.js @@ -23,7 +23,7 @@ ObserveMultiplexer = class { const self = this; this.callbackNames().forEach(callbackName => { this[callbackName] = function(/* ... */) { - self._applyCallback(callbackName, _.toArray(arguments)); + return self._applyCallback(callbackName, _.toArray(arguments)); }; }); }