├── .github └── workflows │ └── meteor.yml ├── .gitignore ├── .versions ├── LICENSE ├── README.md ├── package.js ├── server.coffee └── tests.coffee /.github/workflows/meteor.yml: -------------------------------------------------------------------------------- 1 | name: Meteor package 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-20.04 8 | 9 | container: registry.gitlab.com/tozd/docker/meteor-testing:ubuntu-focal-1.12.1 10 | 11 | steps: 12 | - uses: actions/checkout@v2 13 | with: 14 | submodules: true 15 | - run: /run-test-packages.sh -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .* 2 | .build* 3 | smart.lock 4 | -------------------------------------------------------------------------------- /.versions: -------------------------------------------------------------------------------- 1 | allow-deny@1.1.0 2 | babel-compiler@7.3.4 3 | babel-runtime@1.3.0 4 | base64@1.0.11 5 | binary-heap@1.0.11 6 | boilerplate-generator@1.6.0 7 | caching-compiler@1.2.1 8 | callback-hook@1.1.0 9 | check@1.3.1 10 | coffeescript@2.4.1 11 | coffeescript-compiler@2.4.1 12 | ddp@1.4.0 13 | ddp-client@2.3.3 14 | ddp-common@1.4.0 15 | ddp-server@2.3.0 16 | diff-sequence@1.1.1 17 | dynamic-import@0.5.1 18 | ecmascript@0.12.7 19 | ecmascript-runtime@0.7.0 20 | ecmascript-runtime-client@0.8.0 21 | ecmascript-runtime-server@0.7.1 22 | ejson@1.1.0 23 | fetch@0.1.1 24 | geojson-utils@1.0.10 25 | id-map@1.1.0 26 | insecure@1.0.7 27 | inter-process-messaging@0.1.0 28 | lamhieu:meteorx@2.0.1 29 | lamhieu:unblock@1.0.0 30 | local-test:peerlibrary:reactive-publish@0.10.0 31 | logging@1.1.20 32 | meteor@1.9.3 33 | minimongo@1.4.5 34 | modern-browsers@0.1.4 35 | modules@0.13.0 36 | modules-runtime@0.10.3 37 | mongo@1.6.2 38 | mongo-decimal@0.1.1 39 | mongo-dev-server@1.1.0 40 | mongo-id@1.0.7 41 | npm-mongo@3.1.2 42 | ordered-dict@1.1.0 43 | peerlibrary:assert@0.3.0 44 | peerlibrary:classy-test@0.4.0 45 | peerlibrary:extend-publish@0.6.0 46 | peerlibrary:fiber-utils@0.10.0 47 | peerlibrary:reactive-mongo@0.4.0 48 | peerlibrary:reactive-publish@0.10.0 49 | peerlibrary:server-autorun@0.8.0 50 | promise@0.11.2 51 | random@1.1.0 52 | reactive-var@1.0.11 53 | reload@1.3.0 54 | retry@1.1.0 55 | routepolicy@1.1.0 56 | socket-stream-client@0.2.2 57 | tinytest@1.1.0 58 | tracker@1.2.0 59 | underscore@1.0.10 60 | webapp@1.7.3 61 | webapp-hashing@1.0.9 62 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015, The PeerLibrary Project 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | * Redistributions of source code must retain the above copyright 7 | notice, this list of conditions and the following disclaimer. 8 | * Redistributions in binary form must reproduce the above copyright 9 | notice, this list of conditions and the following disclaimer in the 10 | documentation and/or other materials provided with the distribution. 11 | * Neither the name of the PeerLibrary Project nor the 12 | names of its contributors may be used to endorse or promote products 13 | derived from this software without specific prior written permission. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 16 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 17 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 18 | DISCLAIMED. IN NO EVENT SHALL COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY 19 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 20 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 21 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 22 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 23 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 24 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | reactive-publish 2 | ================ 3 | 4 | This Meteor smart package extends [publish endpoints](http://docs.meteor.com/#/full/meteor_publish) 5 | with support for reactivity so that you can use 6 | [server-side autorun](https://github.com/peerlibrary/meteor-server-autorun) inside a publish function. 7 | 8 | After adding this package you can use server-side [Tracker.autorun](http://docs.meteor.com/#/full/tracker_autorun) 9 | inside your publish function and any published documents will be automatically correctly send to the client while your 10 | reactive computation will rerun when any dependency triggers invalidation. Only changes to documents between reruns 11 | will be send to the client. As a rule, things work exactly as you would expect, just reactively if they are inside an `autorun`. 12 | You can use any source of reactivity, like [reactive server-side MongoDB queries](https://github.com/peerlibrary/meteor-reactive-mongo) 13 | and reactive variables. 14 | 15 | Publish function's `this` is extended with `this.autorun` which behaves the same as `Tracker.autorun`, but you can 16 | return a cursor of array of cursors you want to publish, and `this` inside the computation function is bound to the 17 | publish context. Moreover, computation is automatically stopped when subscription is stopped. If you use 18 | `Tracker.autorun` you have to take care of this yourselves, or you can return a computation from the publish function 19 | to have it stopped automatically as well. 20 | 21 | Server side only. 22 | 23 | Installation 24 | ------------ 25 | 26 | ``` 27 | meteor add peerlibrary:reactive-publish 28 | ``` 29 | 30 | Additional packages 31 | ------------------- 32 | 33 | * [peerlibrary:subscription-data](https://github.com/peerlibrary/meteor-subscription-data) – Support for 34 | reactive and shared subscription data context which allows you to change arguments to the publish function without 35 | restarting it, and have a side channel to communicate metadata back to the subscriber as well 36 | 37 | Examples 38 | -------- 39 | 40 | You can make a simple publish across an one-to-many relation: 41 | 42 | ```javascript 43 | Meteor.publish('subscribed-posts', function () { 44 | this.autorun(function (computation) { 45 | var user = User.findOne(this.userId, {fields: {subscribedPosts: 1}}); 46 | 47 | return Posts.find({_id: {$in: user && user.subscribedPosts || []}}); 48 | }); 49 | }); 50 | ``` 51 | 52 | You can make queries which are based on time: 53 | 54 | ```javascript 55 | var currentTime = new ReactiveVar(Date.now()); 56 | 57 | Meteor.setInterval(function () { 58 | currentTime.set(Date.now()); 59 | }, 1000); // ms 60 | 61 | Meteor.publish('recent-posts', function () { 62 | this.autorun(function (computation) { 63 | return Posts.find({ 64 | timestamp: { 65 | $exists: true, 66 | $gte: currentTime.get() - (60 * 1000) // ms 67 | } 68 | }, { 69 | sort: { 70 | timestamp: 1 71 | } 72 | }); 73 | }); 74 | }); 75 | ``` 76 | 77 | You can make complicated but reactive permission checks. For example, support user groups: 78 | 79 | ```javascript 80 | Meteor.publish('posts', function () { 81 | this.autorun(function (computation) { 82 | var user = User.findOne(this.userId, {fields: {groups: 1}}); 83 | 84 | return Posts.find({ 85 | $or: [{ 86 | 'access.userId': user && user._id 87 | }, { 88 | 'access.groupId': { 89 | $in: user && user.groups || [] 90 | } 91 | }] 92 | }); 93 | }); 94 | }); 95 | ``` 96 | 97 | Discussion 98 | ---------- 99 | 100 | Adding this package to your [Meteor](http://www.meteor.com/) application will make all MongoDB queries 101 | reactive by default (you can still specify [`reactive: false`](http://docs.meteor.com/#/full/find) to 102 | queries to disable reactivity for a specific query, or use 103 | [`Tracker.nonreactive`](http://docs.meteor.com/#/full/tracker_nonreactive)). It will also automatically enable 104 | [server-side autorun](https://github.com/peerlibrary/meteor-server-autorun). All this might break some existing 105 | server-side code which might not expect to be reactive. Inspect locations where your code or packages you are using 106 | already (before using this package) call `Tracker.autorun` on the server. In most cases this occurs only in the code 107 | which is shared between client and server. 108 | 109 | While documents are send to the client only once and in later reruns of computations only changes are send, 110 | the server side still has to make a new query and compute a diff what to send for every rerun, so this approach 111 | is suitable for reactivity which is not common, but you still want to support it. For example, queries with 112 | reactive permission checks often will not change during the life-time of a query because permissions change rarely. 113 | But if they do, a user will see results reactively and immediately. 114 | 115 | Consider also optimizing your `autorun`s by splitting them into multiple `autorun`s or by nesting them. You can 116 | also use [computed fields](https://github.com/peerlibrary/meteor-computed-field) to minimize propagation of 117 | reactive change. 118 | 119 | When using this approach to support reactive joins it is most suitable for one-to-many relations, where the "one" document 120 | changes infrequently. For many-to-many joins consider publishing collections separately and join them on the client side. 121 | The issue is that for any change for the first "many" documents, the computation will be invalidated and rerun to 122 | query for second set of "many" documents. Alternatively, you can consider using [PeerDB](https://github.com/peerlibrary/meteor-peerdb) 123 | which effectively denormalizes joins across many-to-many relations and allows direct querying and publishing. 124 | 125 | Feel free to make pull requests with optimizations. 126 | 127 | Note that calling [`onStop`](http://docs.meteor.com/#/full/publish_onstop) inside a reactive computation probably does 128 | not do what you want. It will register a callback to be called when the whole publication is stopped and if you are 129 | doing this inside an `autorun` this means that a new callback is registered every time the computation is rerun. 130 | Which can potentially add many callbacks. Moreover, they will be called only after the whole publication stops, and 131 | not between computation reruns. What you probably want is to use 132 | [`Tracker.onInvalidate`](http://docs.meteor.com/#/full/tracker_oninvalidate) which runs when the computation itself 133 | is invalidated or stopped. Do notice that for reactive queries and observes inside a computation it is not needed to 134 | free their resources by yourself because that will be done already automatically. 135 | 136 | Acknowledgments 137 | --------------- 138 | 139 | This package is based on the great work by [Diggory Blake](https://github.com/Diggsey/meteor-reactive-publish) 140 | who made the first implementation. 141 | 142 | Related projects 143 | ---------------- 144 | 145 | There are few other similar projects trying to address similar features, but theirs APIs are cumbersome and are different 146 | than what developers are used to for Meteor. 147 | 148 | * [meteor-publish-with-relations](https://github.com/tmeasday/meteor-publish-with-relations) – complicated custom API not 149 | allowing to reuse existing publish functions, which means no support for custom publish with `added`/`changed`/`removed`, 150 | no support for other reactive sources 151 | * [meteor-smart-publish](https://github.com/yeputons/meteor-smart-publish) – complicated custom way of defining 152 | dependencies and works only with query cursors and not other reactive sources 153 | * [reywood:publish-composite](https://github.com/englue/meteor-publish-composite) – allow you to define a nested structure 154 | of cursors, which get documents from higher levels in a reactive manner, but it works only with only with query cursors 155 | and not custom `added`/`changed`/`removed` functions or other reactive sources 156 | * [copleyjk:simple-publish](https://github.com/copleykj/meteor-simple-publish) – seems similar to 157 | `meteor-publish-with-relations`, but a more developed version covering more edge cases; on the other hand it 158 | has the same limitations of no support for `added`/`changed`/`removed` or other reactive sources 159 | * [peerlibrary:related](https://github.com/peerlibrary/meteor-related) – our previous implementation with different API 160 | and no reactivity support, but with support for custom `added`/`changed`/`removed` publishing 161 | -------------------------------------------------------------------------------- /package.js: -------------------------------------------------------------------------------- 1 | Package.describe({ 2 | summary: "Reactive publish endpoints", 3 | version: '0.10.0', 4 | name: 'peerlibrary:reactive-publish', 5 | git: 'https://github.com/peerlibrary/meteor-reactive-publish.git' 6 | }); 7 | 8 | Package.onUse(function (api) { 9 | api.versionsFrom('METEOR@1.8.1'); 10 | 11 | // Core dependencies. 12 | api.use([ 13 | 'coffeescript@2.4.1', 14 | 'ecmascript', 15 | 'mongo', 16 | 'minimongo', 17 | 'underscore' 18 | ], 'server'); 19 | 20 | // 3rd party dependencies. 21 | api.use([ 22 | 'peerlibrary:server-autorun@0.8.0', 23 | 'peerlibrary:reactive-mongo@0.4.0', 24 | 'peerlibrary:extend-publish@0.6.0' 25 | ], 'server'); 26 | 27 | api.addFiles([ 28 | 'server.coffee' 29 | ], 'server'); 30 | }); 31 | 32 | Package.onTest(function (api) { 33 | api.versionsFrom('METEOR@1.8.1'); 34 | 35 | // Core dependencies. 36 | api.use([ 37 | 'coffeescript@2.4.1', 38 | 'ecmascript', 39 | 'insecure', 40 | 'random', 41 | 'underscore', 42 | 'reactive-var', 43 | 'check', 44 | 'mongo' 45 | ]); 46 | 47 | // Internal dependencies. 48 | api.use([ 49 | 'peerlibrary:reactive-publish' 50 | ]); 51 | 52 | // 3rd party dependencies. 53 | api.use([ 54 | 'peerlibrary:assert@0.3.0', 55 | 'peerlibrary:server-autorun@0.8.0', 56 | 'peerlibrary:classy-test@0.4.0', 57 | 'lamhieu:unblock@1.0.0' 58 | ]); 59 | 60 | api.addFiles([ 61 | 'tests.coffee' 62 | ]); 63 | }); 64 | -------------------------------------------------------------------------------- /server.coffee: -------------------------------------------------------------------------------- 1 | Fiber = Npm.require 'fibers' 2 | 3 | getCollectionNames = (result) -> 4 | if result and _.isArray result 5 | resultNames = (cursor._getCollectionName() for cursor in result when _.isObject(cursor) and '_getCollectionName' of cursor) 6 | else if result and _.isObject(result) and '_getCollectionName' of result 7 | resultNames = [result._getCollectionName()] 8 | else 9 | resultNames = [] 10 | 11 | resultNames 12 | 13 | checkNames = (publish, allCollectionNames, id, collectionNames) -> 14 | for computationId, names of allCollectionNames when computationId isnt id 15 | for collectionName in names when collectionName in collectionNames 16 | publish.error new Error "Multiple cursors for collection '#{collectionName}'" 17 | return false 18 | 19 | true 20 | 21 | iterateObjectOrMapKeys = (objectOrMap, fn) -> 22 | if (objectOrMap instanceof Map) 23 | for [ key ] from objectOrMap 24 | fn(key) 25 | else 26 | for key of objectOrMap 27 | fn(key) 28 | 29 | wrapCallbacks = (callbacks, initializingReference) -> 30 | # If observeChanges is called inside a reactive context we have to make extra effort to pass the computation to the 31 | # observeChanges callbacks so that the computation is available to the "added" publish method, if it is called. We use 32 | # fiber object for that. observeChanges callbacks are not called in a reactive context. Additionally, we want this to 33 | # be passed only during the observeChanges initialization (when it is calling "added" callbacks in a blocking manner). 34 | if Tracker.active 35 | Meteor._nodeCodeMustBeInFiber() 36 | currentComputation = Tracker.currentComputation 37 | callbacks = _.clone callbacks 38 | for callbackName, callback of callbacks when callbackName in ['added', 'changed', 'removed', 'addedBefore', 'movedBefore'] 39 | do (callbackName, callback) -> 40 | callbacks[callbackName] = (args...) -> 41 | if initializingReference.initializing 42 | previousPublishComputation = Fiber.current._publishComputation 43 | Fiber.current._publishComputation = currentComputation 44 | try 45 | callback.apply null, args 46 | finally 47 | Fiber.current._publishComputation = previousPublishComputation 48 | else 49 | callback.apply null, args 50 | 51 | callbacks 52 | 53 | originalObserveChanges = MongoInternals.Connection::_observeChanges 54 | MongoInternals.Connection::_observeChanges = (cursorDescription, ordered, callbacks, nonMutatingCallbacks) -> 55 | initializing = true 56 | 57 | callbacks = wrapCallbacks callbacks, initializing: initializing 58 | 59 | handle = originalObserveChanges.call @, cursorDescription, ordered, callbacks, nonMutatingCallbacks 60 | initializing = false 61 | handle 62 | 63 | originalLocalCollectionCursorObserveChanges = LocalCollection.Cursor::observeChanges 64 | LocalCollection.Cursor::observeChanges = (callbacks, options = {}) -> 65 | initializing = true 66 | 67 | callbacks = wrapCallbacks callbacks, initializing: initializing 68 | 69 | handle = originalLocalCollectionCursorObserveChanges.call @, callbacks, options 70 | initializing = false 71 | handle 72 | 73 | extendPublish (name, publishFunction, options) -> 74 | newPublishFunction = (args...) -> 75 | publish = @ 76 | 77 | oldDocuments = {} 78 | documents = {} 79 | 80 | allCollectionNames = {} 81 | 82 | publish._currentComputation = -> 83 | if Tracker.active 84 | return Tracker.currentComputation 85 | else 86 | # Computation can also be passed through current fiber in the case the "added" method is called 87 | # from the observeChanges callback from an observeChanges called inside a reactive context. 88 | return Fiber.current._publishComputation 89 | 90 | null 91 | 92 | publish._installCallbacks = -> 93 | computation = @_currentComputation() 94 | 95 | return unless computation 96 | 97 | unless computation._publishOnStopSet 98 | computation._publishOnStopSet = true 99 | 100 | computation.onStop => 101 | delete oldDocuments[computation._id] 102 | delete documents[computation._id] 103 | 104 | unless computation._publishAfterRunSet 105 | computation._publishAfterRunSet = true 106 | 107 | computation.afterRun => 108 | # We remove those which are not published anymore. 109 | iterateObjectOrMapKeys @_documents, (collectionName) => 110 | if @_documents instanceof Map 111 | currentlyPublishedDocumentIds = Array.from(@_documents.get(collectionName)) 112 | else 113 | currentlyPublishedDocumentIds = _.keys(@_documents[collectionName] or {}) 114 | 115 | currentComputationAddedDocumentIds = _.keys(documents[computation._id]?[collectionName] or {}) 116 | # If afterRun for other autoruns in the publish function have not yet run, we have to look in "documents" as well. 117 | otherComputationsAddedDocumentsIds = _.union (_.keys(docs[collectionName] or {}) for computationId, docs of documents when computationId isnt "#{computation._id}")... 118 | # But after afterRun, "documents" is empty to be ready for next rerun of the computation, so we look into "oldDocuments". 119 | otherComputationsPreviouslyAddedDocumentsIds = _.union (_.keys(docs[collectionName] or {}) for computationId, docs of oldDocuments when computationId isnt "#{computation._id}")... 120 | 121 | # We ignore IDs found in both otherComputationsAddedDocumentsIds and otherComputationsPreviouslyAddedDocumentsIds 122 | # which might ignore more IDs then necessary (an ID might be previously added which has not been added in this 123 | # iteration) but this is OK because in afterRun of other computations this will be corrected and documents 124 | # with those IDs removed. 125 | for id in _.difference currentlyPublishedDocumentIds, currentComputationAddedDocumentIds, otherComputationsAddedDocumentsIds, otherComputationsPreviouslyAddedDocumentsIds 126 | @removed collectionName, @_idFilter.idParse id 127 | 128 | computation.beforeRun => 129 | oldDocuments[computation._id] = documents[computation._id] or {} 130 | documents[computation._id] = {} 131 | 132 | computation._publishAfterRunSet = false 133 | 134 | computation._trackerInstance.requireFlush() 135 | 136 | return 137 | 138 | originalAdded = publish.added 139 | publish.added = (collectionName, id, fields) -> 140 | stringId = @_idFilter.idStringify id 141 | 142 | @_installCallbacks() 143 | 144 | currentComputation = @_currentComputation() 145 | Meteor._ensure(documents, currentComputation._id, collectionName)[stringId] = true if currentComputation 146 | 147 | # If document as already present in publish then we call changed to send updated fields (Meteor sends only a diff). 148 | # This can hide some errors in publish functions if they one calls "added" on an existing document and we could 149 | # make it so that this behavior works only inside reactive computation (if "currentComputation" is set), but we 150 | # can also make it so that publish function tries to do something smarter (sending a diff) in all cases, as we do. 151 | if ((@_documents instanceof Map && @_documents.get(collectionName)?.has(stringId)) || @_documents[collectionName]?[stringId]) 152 | oldFields = {} 153 | # If some field existed before, but does not exist anymore, we have to remove it by calling "changed" 154 | # with value set to "undefined". So we look into current session's state and see which fields are currently 155 | # known and create an object of same fields, just all values set to "undefined". We then override some fields 156 | # with new values. Only top-level fields matter. 157 | _documents = @_session?.getCollectionView(collectionName)?.documents or {} 158 | if _documents instanceof Map 159 | dataByKey = _documents.get(stringId)?.dataByKey or {} 160 | else 161 | dataByKey = _documents?[stringId]?.dataByKey or {} 162 | 163 | iterateObjectOrMapKeys dataByKey, (field) => 164 | oldFields[field] = undefined 165 | 166 | @changed collectionName, id, _.extend oldFields, fields 167 | else 168 | originalAdded.call @, collectionName, id, fields 169 | 170 | ready = false 171 | 172 | originalReady = publish.ready 173 | publish.ready = -> 174 | @_installCallbacks() 175 | 176 | # Mark it as ready only the first time. 177 | originalReady.call @ unless ready 178 | ready = true 179 | 180 | # To return nothing. 181 | return 182 | 183 | handles = [] 184 | # This autorun is nothing special, just that it makes sure handles are stopped when publish stops, 185 | # and that you can return cursors from the function which would be automatically published. 186 | publish.autorun = (runFunc) -> 187 | handle = Tracker.autorun (computation) -> 188 | computation.onInvalidate -> 189 | delete allCollectionNames[computation._id] 190 | 191 | try 192 | result = runFunc.call publish, computation 193 | catch error 194 | computation.stop() 195 | 196 | if computation.firstRun 197 | throw error 198 | else 199 | publish.error(error) 200 | return 201 | 202 | collectionNames = getCollectionNames result 203 | allCollectionNames[computation._id] = collectionNames 204 | 205 | unless checkNames publish, allCollectionNames, "#{computation._id}", collectionNames 206 | computation.stop() 207 | return 208 | 209 | # Specially handle if computation has been returned. 210 | if result instanceof Tracker.Computation 211 | if publish._isDeactivated() 212 | result.stop() 213 | else 214 | handles.push result 215 | else 216 | publish._publishHandlerResult result unless publish._isDeactivated() 217 | 218 | if publish._isDeactivated() 219 | handle.stop() 220 | else 221 | handles.push handle 222 | 223 | handle 224 | 225 | publish.onStop -> 226 | while handles.length 227 | handle = handles.shift() 228 | handle?.stop() 229 | 230 | result = publishFunction.apply publish, args 231 | 232 | collectionNames = getCollectionNames result 233 | allCollectionNames[''] = collectionNames 234 | return unless checkNames publish, allCollectionNames, '', collectionNames 235 | 236 | # Specially handle if computation has been returned. 237 | if result instanceof Tracker.Computation 238 | if publish._isDeactivated() 239 | result.stop() 240 | else 241 | handles.push result 242 | 243 | # Do not return anything. 244 | return 245 | 246 | else 247 | result 248 | 249 | [name, newPublishFunction, options] 250 | -------------------------------------------------------------------------------- /tests.coffee: -------------------------------------------------------------------------------- 1 | for idGeneration in ['STRING', 'MONGO'] 2 | do (idGeneration) -> 3 | allCollections = [] 4 | 5 | if idGeneration is 'STRING' 6 | generateId = -> 7 | Random.id() 8 | else 9 | generateId = -> 10 | new Meteor.Collection.ObjectID() 11 | 12 | Users = new Mongo.Collection "Users_meteor_reactivepublish_tests_#{idGeneration}", {idGeneration} 13 | Posts = new Mongo.Collection "Posts_meteor_reactivepublish_tests_#{idGeneration}", {idGeneration} 14 | Addresses = new Mongo.Collection "Addresses_meteor_reactivepublish_tests_#{idGeneration}", {idGeneration} 15 | Fields = new Mongo.Collection "Fields_meteor_reactivepublish_tests_#{idGeneration}", {idGeneration} 16 | 17 | allCollections.push Users 18 | allCollections.push Posts 19 | allCollections.push Addresses 20 | allCollections.push Fields 21 | 22 | if Meteor.isServer 23 | LocalCollection = new Mongo.Collection null, {idGeneration} 24 | 25 | localCollectionLimit = new ReactiveVar null 26 | 27 | Meteor.publish null, -> 28 | Users.find() 29 | 30 | Meteor.publish "posts_#{idGeneration}", (ids) -> 31 | Posts.find 32 | _id: 33 | $in: ids 34 | 35 | Meteor.publish "users-posts_#{idGeneration}", (userId) -> 36 | handle = Tracker.autorun (computation) => 37 | user = Users.findOne userId, 38 | fields: 39 | posts: 1 40 | 41 | projectedField = Fields.findOne userId 42 | 43 | Posts.find( 44 | _id: 45 | $in: user?.posts or [] 46 | , 47 | fields: _.omit (projectedField or {}), '_id' 48 | ).observeChanges 49 | added: (id, fields) => 50 | assert not Tracker.active 51 | fields.dummyField = true 52 | @added "Posts_meteor_reactivepublish_tests_#{idGeneration}", id, fields 53 | changed: (id, fields) => 54 | assert not Tracker.active 55 | @changed "Posts_meteor_reactivepublish_tests_#{idGeneration}", id, fields 56 | removed: (id) => 57 | assert not Tracker.active 58 | @removed "Posts_meteor_reactivepublish_tests_#{idGeneration}", id 59 | 60 | @ready() 61 | 62 | @onStop => 63 | handle?.stop() 64 | handle = null 65 | 66 | Meteor.publish "users-posts-foreach_#{idGeneration}", (userId) -> 67 | # Handle is being returned and stopped automatically. 68 | Tracker.autorun (computation) => 69 | user = Users.findOne userId, 70 | fields: 71 | posts: 1 72 | 73 | projectedField = Fields.findOne userId 74 | 75 | Posts.find( 76 | _id: 77 | $in: user?.posts or [] 78 | , 79 | fields: _.omit (projectedField or {}), '_id' 80 | ).forEach (document, i, cursor) => 81 | fields = _.omit document, '_id' 82 | fields.dummyField = true 83 | @added "Posts_meteor_reactivepublish_tests_#{idGeneration}", document._id, fields 84 | 85 | @ready() 86 | 87 | Meteor.publish "users-posts-autorun_#{idGeneration}", (userId) -> 88 | # Handle is being returned and stopped automatically. 89 | Tracker.autorun (computation) => 90 | user = Users.findOne userId, 91 | fields: 92 | posts: 1 93 | 94 | projectedField = Fields.findOne userId 95 | 96 | Tracker.autorun (computation) => 97 | Posts.find( 98 | _id: 99 | $in: user?.posts or [] 100 | , 101 | fields: _.omit (projectedField or {}), '_id' 102 | ).forEach (document, i, cursor) => 103 | fields = _.omit document, '_id' 104 | fields.dummyField = true 105 | @added "Posts_meteor_reactivepublish_tests_#{idGeneration}", document._id, fields 106 | 107 | @ready() 108 | 109 | Meteor.publish "users-posts-method_#{idGeneration}", (userId) -> 110 | # Handle is being returned and stopped automatically. 111 | Tracker.autorun (computation) => 112 | {user, projectedField} = Meteor.call "userAndProjection_#{idGeneration}", userId 113 | 114 | Posts.find( 115 | _id: 116 | $in: user?.posts or [] 117 | , 118 | fields: _.omit (projectedField or {}), '_id' 119 | ).observeChanges 120 | added: (id, fields) => 121 | assert not Tracker.active 122 | fields.dummyField = true 123 | @added "Posts_meteor_reactivepublish_tests_#{idGeneration}", id, fields 124 | changed: (id, fields) => 125 | assert not Tracker.active 126 | @changed "Posts_meteor_reactivepublish_tests_#{idGeneration}", id, fields 127 | removed: (id) => 128 | assert not Tracker.active 129 | @removed "Posts_meteor_reactivepublish_tests_#{idGeneration}", id 130 | 131 | @ready() 132 | 133 | Meteor.publish "users-posts-and-addresses_#{idGeneration}", (userId) -> 134 | self = @ 135 | 136 | @autorun (computation) -> 137 | # To test that a computation is bound to the publish. 138 | assert.equal @, self 139 | 140 | user1 = Users.findOne userId, 141 | fields: 142 | posts: 1 143 | 144 | Posts.find( 145 | _id: 146 | $in: user1?.posts or [] 147 | ) 148 | 149 | @autorun (computation) => 150 | user2 = Users.findOne userId, 151 | fields: 152 | addresses: 1 153 | 154 | Addresses.find( 155 | _id: 156 | $in: user2?.addresses or [] 157 | ) 158 | 159 | Meteor.publish "users-posts-and-addresses-together_#{idGeneration}", (userId) -> 160 | @autorun (computation) => 161 | user = Users.findOne userId, 162 | fields: 163 | posts: 1 164 | addresses: 1 165 | 166 | [ 167 | Posts.find( 168 | _id: 169 | $in: user?.posts or [] 170 | ) 171 | , 172 | Addresses.find( 173 | _id: 174 | $in: user?.addresses or [] 175 | ) 176 | ] 177 | 178 | Meteor.publish "users-posts-count_#{idGeneration}", (userId, countId) -> 179 | @autorun (computation) => 180 | user = Users.findOne userId, 181 | fields: 182 | posts: 1 183 | 184 | count = 0 185 | initializing = true 186 | 187 | Posts.find( 188 | _id: 189 | $in: user?.posts or [] 190 | ).observeChanges 191 | added: (id) => 192 | assert not Tracker.active 193 | count++ 194 | @changed "Counts_#{idGeneration}", countId, count: count unless initializing 195 | removed: (id) => 196 | assert not Tracker.active 197 | count-- 198 | @changed "Counts_#{idGeneration}", countId, count: count unless initializing 199 | 200 | initializing = false 201 | 202 | @added "Counts_#{idGeneration}", countId, 203 | count: count 204 | 205 | @ready() 206 | 207 | currentTime = new ReactiveVar Date.now() 208 | 209 | Meteor.setInterval -> 210 | currentTime.set Date.now() 211 | # Using 1 ms to stress-test the system. In practice you should be using a much larger interval. 212 | , 1 # ms 213 | 214 | Meteor.publish "recent-posts_#{idGeneration}", -> 215 | @autorun (computation) => 216 | timestamp = currentTime.get() - 2000 # ms 217 | 218 | Posts.find( 219 | timestamp: 220 | $exists: true 221 | $gte: timestamp 222 | , 223 | sort: 224 | timestamp: 1 225 | ) 226 | 227 | # Error is expected. 228 | Meteor.publish "multiple-cursors-1_#{idGeneration}", -> 229 | @autorun (computation) => 230 | Posts.find() 231 | 232 | @autorun (computation) => 233 | Posts.find() 234 | 235 | # Error is expected. 236 | Meteor.publish "multiple-cursors-2_#{idGeneration}", -> 237 | @autorun (computation) => 238 | Posts.find() 239 | 240 | Posts.find() 241 | 242 | Meteor.publish "localCollection_#{idGeneration}", -> 243 | @autorun (computation) => 244 | LocalCollection.find({}, {sort: {i: 1}, limit: localCollectionLimit.get()}).observeChanges 245 | addedBefore: (id, fields, before) => 246 | @added "localCollection_#{idGeneration}", id, fields 247 | changed: (id, fields) => 248 | @changed "localCollection_#{idGeneration}", id, fields 249 | removed: (id) => 250 | @removed "localCollection_#{idGeneration}", id 251 | 252 | @ready() 253 | 254 | Meteor.publish "unblocked-users-posts_#{idGeneration}", (userId) -> 255 | @unblock() 256 | 257 | @autorun (computation) => 258 | user = Users.findOne userId, 259 | fields: 260 | posts: 1 261 | 262 | Posts.find( 263 | _id: 264 | $in: user?.posts or [] 265 | ) 266 | 267 | methods = {} 268 | methods["insertPost_#{idGeneration}"] = (timestamp) -> 269 | check timestamp, Number 270 | 271 | Posts.insert 272 | timestamp: timestamp 273 | 274 | methods["userAndProjection_#{idGeneration}"] = (userId) -> 275 | user = Users.findOne userId, 276 | fields: 277 | posts: 1 278 | 279 | projectedField = Fields.findOne userId 280 | 281 | {user, projectedField} 282 | 283 | methods["setLocalCollectionLimit_#{idGeneration}"] = (limit) -> 284 | localCollectionLimit.set limit 285 | 286 | methods["insertLocalCollection_#{idGeneration}"] = (doc) -> 287 | LocalCollection.insert doc 288 | 289 | # We use our own insert method to not have latency compensation so that observeChanges 290 | # on the client really matches how databases changes on the server. 291 | Meteor.methods methods 292 | 293 | else 294 | LocalCollection = new Mongo.Collection "localCollection_#{idGeneration}", {idGeneration} 295 | 296 | localMethods = {} 297 | localMethods["clearLocalCollection_#{idGeneration}"] = -> 298 | LocalCollection.remove {} 299 | 300 | Meteor.methods localMethods 301 | 302 | class ReactivePublishTestCase extends ClassyTestCase 303 | @testName: "reactivepublish - #{idGeneration}" 304 | 305 | setUpServer: -> 306 | # Initialize the database. 307 | Users.remove {} 308 | Posts.remove {} 309 | Addresses.remove {} 310 | Fields.remove {} 311 | 312 | setUpClient: -> 313 | @countsCollection ?= new Mongo.Collection "Counts_#{idGeneration}", {idGeneration} 314 | 315 | @basic: (publishName) -> [ 316 | -> 317 | @userId = generateId() 318 | @countId = generateId() 319 | 320 | @assertSubscribeSuccessful "#{publishName}_#{idGeneration}", @userId, @expect() 321 | @assertSubscribeSuccessful "users-posts-count_#{idGeneration}", @userId, @countId, @expect() 322 | , 323 | -> 324 | @assertEqual Posts.find().fetch(), [] 325 | @assertEqual @countsCollection.findOne(@countId)?.count, 0 326 | 327 | @posts = [] 328 | 329 | for i in [0...10] 330 | Posts.insert {}, @expect (error, id) => 331 | @assertFalse error, error?.toString?() or error 332 | @assertTrue id 333 | @posts.push id 334 | 335 | Meteor.setTimeout @expect(), 1000 # ms 336 | , 337 | -> 338 | @assertEqual Posts.find().fetch(), [] 339 | @assertEqual @countsCollection.findOne(@countId)?.count, 0 340 | 341 | Users.insert 342 | _id: @userId 343 | posts: @posts 344 | , 345 | @expect (error, userId) => 346 | @assertFalse error, error?.toString?() or error 347 | @assertTrue userId 348 | @assertEqual userId, @userId 349 | 350 | Meteor.setTimeout @expect(), 1000 # ms 351 | , 352 | -> 353 | Posts.find().forEach (post) => 354 | @assertTrue post.dummyField 355 | @assertItemsEqual _.pluck(Posts.find().fetch(), '_id'), @posts 356 | @assertEqual @countsCollection.findOne(@countId)?.count, @posts.length 357 | 358 | @shortPosts = @posts[0...5] 359 | 360 | Users.update @userId, 361 | posts: @shortPosts 362 | , 363 | @expect (error, count) => 364 | @assertFalse error, error?.toString?() or error 365 | @assertEqual count, 1 366 | 367 | Meteor.setTimeout @expect(), 1000 # ms 368 | , 369 | -> 370 | Posts.find().forEach (post) => 371 | @assertTrue post.dummyField 372 | @assertItemsEqual _.pluck(Posts.find().fetch(), '_id'), @shortPosts 373 | @assertEqual @countsCollection.findOne(@countId)?.count, @shortPosts.length 374 | 375 | Users.update @userId, 376 | posts: [] 377 | , 378 | @expect (error, count) => 379 | @assertFalse error, error?.toString?() or error 380 | @assertEqual count, 1 381 | 382 | Meteor.setTimeout @expect(), 1000 # ms 383 | , 384 | -> 385 | @assertItemsEqual _.pluck(Posts.find().fetch(), '_id'), [] 386 | @assertEqual @countsCollection.findOne(@countId)?.count, 0 387 | 388 | Users.update @userId, 389 | posts: @posts 390 | , 391 | @expect (error, count) => 392 | @assertFalse error, error?.toString?() or error 393 | @assertEqual count, 1 394 | 395 | Meteor.setTimeout @expect(), 1000 # ms 396 | , 397 | -> 398 | Posts.find().forEach (post) => 399 | @assertTrue post.dummyField, true 400 | @assertItemsEqual _.pluck(Posts.find().fetch(), '_id'), @posts 401 | @assertEqual @countsCollection.findOne(@countId)?.count, @posts.length 402 | 403 | Posts.remove @posts[0], @expect (error, count) => 404 | @assertFalse error, error?.toString?() or error 405 | @assertEqual count, 1 406 | 407 | Meteor.setTimeout @expect(), 1000 # ms 408 | , 409 | -> 410 | Posts.find().forEach (post) => 411 | @assertTrue post.dummyField 412 | @assertItemsEqual _.pluck(Posts.find().fetch(), '_id'), @posts[1..] 413 | @assertEqual @countsCollection.findOne(@countId)?.count, @posts.length - 1 414 | 415 | Users.remove @userId, 416 | @expect (error) => 417 | @assertFalse error, error?.toString?() or error 418 | 419 | Meteor.setTimeout @expect(), 1000 # ms 420 | , 421 | -> 422 | @assertItemsEqual _.pluck(Posts.find().fetch(), '_id'), [] 423 | @assertEqual @countsCollection.findOne(@countId)?.count, 0 424 | ] 425 | 426 | testClientBasic: @basic 'users-posts' 427 | 428 | testClientBasicForeach: @basic 'users-posts-foreach' 429 | 430 | testClientBasicAutorun: @basic 'users-posts-autorun' 431 | 432 | testClientBasicMethod: @basic 'users-posts-method' 433 | 434 | @unsubscribing: (publishName) -> [ 435 | -> 436 | @userId = generateId() 437 | @countId = generateId() 438 | 439 | @assertSubscribeSuccessful "#{publishName}_#{idGeneration}", @userId, @expect() 440 | @assertSubscribeSuccessful "users-posts-count_#{idGeneration}", @userId, @countId, @expect() 441 | , 442 | -> 443 | @assertEqual Posts.find().fetch(), [] 444 | @assertEqual @countsCollection.findOne(@countId)?.count, 0 445 | 446 | @posts = [] 447 | 448 | for i in [0...10] 449 | Posts.insert {}, @expect (error, id) => 450 | @assertFalse error, error?.toString?() or error 451 | @assertTrue id 452 | @posts.push id 453 | 454 | Meteor.setTimeout @expect(), 1000 # ms 455 | , 456 | -> 457 | @assertEqual Posts.find().fetch(), [] 458 | @assertEqual @countsCollection.findOne(@countId)?.count, 0 459 | 460 | Users.insert 461 | _id: @userId 462 | posts: @posts 463 | , 464 | @expect (error, userId) => 465 | @assertFalse error, error?.toString?() or error 466 | @assertTrue userId 467 | @assertEqual userId, @userId 468 | 469 | Meteor.setTimeout @expect(), 1000 # ms 470 | , 471 | -> 472 | Posts.find().forEach (post) => 473 | @assertTrue post.dummyField 474 | @assertItemsEqual _.pluck(Posts.find().fetch(), '_id'), @posts 475 | @assertEqual @countsCollection.findOne(@countId)?.count, @posts.length 476 | 477 | # We have to update posts to trigger at least one rerun. 478 | Users.update @userId, 479 | posts: _.shuffle @posts 480 | , 481 | @expect (error, count) => 482 | @assertFalse error, error?.toString?() or error 483 | @assertEqual count, 1 484 | 485 | Meteor.setTimeout @expect(), 1000 # ms 486 | , 487 | -> 488 | Posts.find().forEach (post) => 489 | @assertTrue post.dummyField 490 | @assertItemsEqual _.pluck(Posts.find().fetch(), '_id'), @posts 491 | @assertEqual @countsCollection.findOne(@countId)?.count, @posts.length 492 | 493 | callback = @expect() 494 | @postsSubscribe = Meteor.subscribe "posts_#{idGeneration}", @posts, 495 | onReady: callback 496 | onError: (error) => 497 | @assertFail 498 | type: 'subscribe' 499 | message: "Subscrption to endpoint failed, but should have succeeded." 500 | callback() 501 | @unsubscribeAll() 502 | 503 | Meteor.setTimeout @expect(), 2000 504 | , 505 | -> 506 | # After unsubscribing from the reactive publish which added dummyField, 507 | # dummyField should be removed from documents available on the client side 508 | Posts.find().forEach (post) => 509 | @assertIsUndefined post.dummyField 510 | @assertItemsEqual _.pluck(Posts.find().fetch(), '_id'), @posts 511 | 512 | @postsSubscribe.stop() 513 | ] 514 | 515 | testClientUnsubscribing: @unsubscribing 'users-posts' 516 | 517 | testClientUnsubscribingForeach: @unsubscribing 'users-posts-foreach' 518 | 519 | testClientUnsubscribingAutorun: @unsubscribing 'users-posts-autorun' 520 | 521 | testClientUnsubscribingMethod: @unsubscribing 'users-posts-method' 522 | 523 | @removeField: (publishName) -> [ 524 | -> 525 | @userId = generateId() 526 | 527 | @assertSubscribeSuccessful "#{publishName}_#{idGeneration}", @userId, @expect() 528 | , 529 | -> 530 | @assertEqual Posts.find().fetch(), [] 531 | 532 | Fields.insert 533 | _id: @userId 534 | foo: 1 535 | dummyField: 1 536 | , 537 | @expect (error, id) => 538 | @assertFalse error, error?.toString?() or error 539 | @assertTrue id 540 | @fieldsId = id 541 | 542 | Posts.insert {foo: 'bar'}, @expect (error, id) => 543 | @assertFalse error, error?.toString?() or error 544 | @assertTrue id 545 | @postId = id 546 | 547 | Meteor.setTimeout @expect(), 1000 # ms 548 | , 549 | -> 550 | @assertEqual Posts.find().fetch(), [] 551 | 552 | Users.insert 553 | _id: @userId 554 | posts: [@postId] 555 | , 556 | @expect (error, userId) => 557 | @assertFalse error, error?.toString?() or error 558 | @assertTrue userId 559 | @assertEqual userId, @userId 560 | 561 | Meteor.setTimeout @expect(), 1000 # ms 562 | , 563 | -> 564 | @assertItemsEqual Posts.find().fetch(), [ 565 | _id: @postId 566 | foo: 'bar' 567 | dummyField: true 568 | ] 569 | 570 | Posts.update @postId, 571 | $set: 572 | foo: 'baz' 573 | , 574 | @expect (error, count) => 575 | @assertFalse error, error?.toString?() or error 576 | @assertEqual count, 1 577 | 578 | Meteor.setTimeout @expect(), 1000 # ms 579 | , 580 | -> 581 | @assertItemsEqual Posts.find().fetch(), [ 582 | _id: @postId 583 | foo: 'baz' 584 | dummyField: true 585 | ] 586 | 587 | Posts.update @postId, 588 | $unset: 589 | foo: '' 590 | , 591 | @expect (error, count) => 592 | @assertFalse error, error?.toString?() or error 593 | @assertEqual count, 1 594 | 595 | Meteor.setTimeout @expect(), 1000 # ms 596 | , 597 | -> 598 | @assertItemsEqual Posts.find().fetch(), [ 599 | _id: @postId 600 | dummyField: true 601 | ] 602 | 603 | Posts.update @postId, 604 | $set: 605 | foo: 'bar' 606 | , 607 | @expect (error, count) => 608 | @assertFalse error, error?.toString?() or error 609 | @assertEqual count, 1 610 | 611 | Meteor.setTimeout @expect(), 1000 # ms 612 | , 613 | -> 614 | @assertItemsEqual Posts.find().fetch(), [ 615 | _id: @postId 616 | foo: 'bar' 617 | dummyField: true 618 | ] 619 | 620 | Fields.update @userId, 621 | $unset: 622 | foo: '' 623 | , 624 | @expect (error, count) => 625 | @assertFalse error, error?.toString?() or error 626 | @assertEqual count, 1 627 | 628 | Meteor.setTimeout @expect(), 1000 # ms 629 | , 630 | -> 631 | @assertItemsEqual Posts.find().fetch(), [ 632 | _id: @postId 633 | dummyField: true 634 | ] 635 | ] 636 | 637 | testClientRemoveField: @removeField 'users-posts' 638 | 639 | testClientRemoveFieldForeach: @removeField 'users-posts-foreach' 640 | 641 | testClientRemoveFieldAutorun: @removeField 'users-posts-autorun' 642 | 643 | testClientRemoveFieldMethod: @removeField 'users-posts-method' 644 | 645 | @multiple: (publishName) -> [ 646 | -> 647 | @userId = generateId() 648 | 649 | @assertSubscribeSuccessful "#{publishName}_#{idGeneration}", @userId, @expect() 650 | -> 651 | @assertEqual Posts.find().fetch(), [] 652 | @assertEqual Addresses.find().fetch(), [] 653 | 654 | @posts = [] 655 | 656 | for i in [0...10] 657 | Posts.insert {}, @expect (error, id) => 658 | @assertFalse error, error?.toString?() or error 659 | @assertTrue id 660 | @posts.push id 661 | 662 | @addresses = [] 663 | 664 | for i in [0...10] 665 | Addresses.insert {}, @expect (error, id) => 666 | @assertFalse error, error?.toString?() or error 667 | @assertTrue id 668 | @addresses.push id 669 | 670 | Meteor.setTimeout @expect(), 1000 # ms 671 | , 672 | -> 673 | @assertEqual Posts.find().fetch(), [] 674 | @assertEqual Addresses.find().fetch(), [] 675 | 676 | Users.insert 677 | _id: @userId 678 | posts: @posts 679 | addresses: @addresses 680 | , 681 | @expect (error, userId) => 682 | @assertFalse error, error?.toString?() or error 683 | @assertTrue userId 684 | @assertEqual userId, @userId 685 | 686 | Meteor.setTimeout @expect(), 1000 # ms 687 | , 688 | -> 689 | @assertItemsEqual _.pluck(Posts.find().fetch(), '_id'), @posts 690 | @assertItemsEqual _.pluck(Addresses.find().fetch(), '_id'), @addresses 691 | 692 | Users.update @userId, 693 | $set: 694 | posts: @posts[0..5] 695 | , 696 | @expect (error, count) => 697 | @assertFalse error, error?.toString?() or error 698 | @assertEqual count, 1 699 | 700 | Meteor.setTimeout @expect(), 1000 # ms 701 | , 702 | -> 703 | @assertItemsEqual _.pluck(Posts.find().fetch(), '_id'), @posts[0..5] 704 | @assertItemsEqual _.pluck(Addresses.find().fetch(), '_id'), @addresses 705 | 706 | Users.update @userId, 707 | $set: 708 | addresses: @addresses[0..5] 709 | , 710 | @expect (error, count) => 711 | @assertFalse error, error?.toString?() or error 712 | @assertEqual count, 1 713 | 714 | Meteor.setTimeout @expect(), 1000 # ms 715 | , 716 | -> 717 | @assertItemsEqual _.pluck(Posts.find().fetch(), '_id'), @posts[0..5] 718 | @assertItemsEqual _.pluck(Addresses.find().fetch(), '_id'), @addresses[0..5] 719 | 720 | Users.update @userId, 721 | $unset: 722 | addresses: '' 723 | , 724 | @expect (error, count) => 725 | @assertFalse error, error?.toString?() or error 726 | @assertEqual count, 1 727 | 728 | Meteor.setTimeout @expect(), 1000 # ms 729 | , 730 | -> 731 | @assertItemsEqual _.pluck(Posts.find().fetch(), '_id'), @posts[0..5] 732 | @assertItemsEqual _.pluck(Addresses.find().fetch(), '_id'), [] 733 | 734 | Users.remove @userId, @expect (error, count) => 735 | @assertFalse error, error?.toString?() or error 736 | @assertEqual count, 1 737 | 738 | Meteor.setTimeout @expect(), 1000 # ms 739 | , 740 | -> 741 | @assertItemsEqual _.pluck(Posts.find().fetch(), '_id'), [] 742 | @assertItemsEqual _.pluck(Addresses.find().fetch(), '_id'), [] 743 | ] 744 | 745 | testClientMultiple: @multiple 'users-posts-and-addresses' 746 | 747 | testClientMultipleTogether: @multiple 'users-posts-and-addresses-together' 748 | 749 | testClientReactiveTime: [ 750 | -> 751 | @assertSubscribeSuccessful "recent-posts_#{idGeneration}", @expect() 752 | 753 | @changes = [] 754 | 755 | @handle = Posts.find( 756 | timestamp: 757 | $exists: true 758 | ).observeChanges 759 | added: (id, fields) => 760 | @changes.push {added: id, timestamp: Date.now()} 761 | changes: (id, fields) => 762 | @assertFail() 763 | removed: (id) => 764 | @changes.push {removed: id, timestamp: Date.now()} 765 | -> 766 | @assertEqual Posts.find(timestamp: $exists: true).fetch(), [] 767 | 768 | @posts = [] 769 | 770 | for i in [0...10] 771 | timestamp = Date.now() + i * 91 # ms 772 | do (timestamp) => 773 | # We use a method to not have any client-side simulation which can 774 | # interfere with the observation of the Posts collection. 775 | Meteor.call "insertPost_#{idGeneration}", timestamp, @expect (error, id) => 776 | @assertFalse error, error?.toString?() or error 777 | @assertTrue id 778 | @posts.push 779 | _id: id 780 | timestamp: timestamp 781 | 782 | # We have to wait for all posts to be inserted and pushed to the client. 783 | Meteor.setTimeout @expect(), 1000 # ms 784 | -> 785 | @posts = _.sortBy @posts, 'timestamp' 786 | 787 | @assertEqual Posts.find( 788 | timestamp: 789 | $exists: true 790 | , 791 | sort: 792 | timestamp: 1 793 | ).fetch(), @posts 794 | 795 | # We wait for 2000 ms for all documents to be removed, and then a bit more 796 | # to make sure the publish endpoint gets synced to the client. 797 | Meteor.setTimeout @expect(), 3000 # ms 798 | -> 799 | @assertEqual Posts.find( 800 | timestamp: 801 | $exists: true 802 | ).fetch(), [] 803 | 804 | @assertEqual @changes.length, 20 805 | 806 | postsId = _.pluck @posts, '_id' 807 | # There should be first changes for adding, in possibly different order. 808 | @assertItemsEqual (change.added for change in @changes when change.added), postsId 809 | # And then in the known order changes for removing. 810 | @assertEqual (change.removed for change in @changes when change.removed), postsId 811 | 812 | addedTimestamps = (change.timestamp for change in @changes when change.added) 813 | removedTimestamps = (change.timestamp for change in @changes when change.removed) 814 | 815 | addedTimestamps.sort() 816 | removedTimestamps.sort() 817 | 818 | sum = (list) -> _.reduce list, ((memo, num) -> memo + num), 0 819 | 820 | averageAdded = sum(addedTimestamps) / addedTimestamps.length 821 | averageRemoved = sum(removedTimestamps) / removedTimestamps.length 822 | 823 | # Removing starts after 2000 ms, so there should be at least this difference between averages. 824 | @assertTrue averageAdded + 2000 < averageRemoved 825 | 826 | removedDelta = 0 827 | 828 | for removed, i in removedTimestamps when i < removedTimestamps.length - 1 829 | removedDelta += removedTimestamps[i + 1] - removed 830 | 831 | removedDelta /= removedTimestamps.length - 1 832 | 833 | # Each removed is approximately 91 ms apart. So the average of deltas should be somewhere there. 834 | @assertTrue removedDelta > 60, removedDelta 835 | ] 836 | 837 | testClientMultipleCursors: -> 838 | # Error is expected. 839 | @subscribe "multiple-cursors-1_#{idGeneration}", 840 | onError: @expect => 841 | @assertTrue true 842 | 843 | # Error is expected. 844 | @subscribe "multiple-cursors-2_#{idGeneration}", 845 | onError: @expect => 846 | @assertTrue true 847 | 848 | testClientLocalCollection: [ 849 | -> 850 | Meteor.call "clearLocalCollection_#{idGeneration}", @expect (error) => 851 | @assertFalse error, error 852 | , 853 | -> 854 | Meteor.call "setLocalCollectionLimit_#{idGeneration}", 10, @expect (error) => 855 | @assertFalse error, error 856 | , 857 | -> 858 | @assertSubscribeSuccessful "localCollection_#{idGeneration}", @expect() 859 | , 860 | -> 861 | @assertEqual LocalCollection.find({}).fetch(), [] 862 | 863 | for i in [0...10] 864 | Meteor.call "insertLocalCollection_#{idGeneration}", {i: i}, @expect (error, documentId) => 865 | @assertFalse error, error 866 | @assertTrue documentId 867 | , 868 | -> 869 | # To wait a bit for change to propagate. 870 | Meteor.setTimeout @expect(), 100 # ms 871 | , 872 | -> 873 | @assertEqual LocalCollection.find({}).count(), 10 874 | 875 | Meteor.call "setLocalCollectionLimit_#{idGeneration}", 5, @expect (error) => 876 | @assertFalse error, error 877 | 878 | # To wait a bit for change to propagate. 879 | Meteor.setTimeout @expect(), 100 # ms 880 | , 881 | -> 882 | @assertEqual LocalCollection.find({}).count(), 5 883 | 884 | for i in [0...10] 885 | Meteor.call "insertLocalCollection_#{idGeneration}", {i: i}, @expect (error, documentId) => 886 | @assertFalse error, error 887 | @assertTrue documentId 888 | , 889 | -> 890 | # To wait a bit for change to propagate. 891 | Meteor.setTimeout @expect(), 100 # ms 892 | , 893 | -> 894 | @assertEqual LocalCollection.find({}).count(), 5 895 | 896 | Meteor.call "setLocalCollectionLimit_#{idGeneration}", 15, @expect (error) => 897 | @assertFalse error, error 898 | 899 | # To wait a bit for change to propagate. 900 | Meteor.setTimeout @expect(), 100 # ms 901 | , 902 | -> 903 | @assertEqual LocalCollection.find({}).count(), 15 904 | ] 905 | 906 | testClientUnblockedPublish: [ 907 | @runOnServer -> 908 | @multiplexerCountBefore = 0 909 | for collection in allCollections when collection 910 | @multiplexerCountBefore += Object.keys(collection._driver.mongo._observeMultiplexers).length 911 | , 912 | -> 913 | @userId = generateId() 914 | handle = @subscribe "unblocked-users-posts_#{idGeneration}", @userId 915 | handle?.stop() 916 | 917 | Meteor.setTimeout @expect(), 1000 # ms 918 | , 919 | @runOnServer -> 920 | multiplexerCountAfter = 0 921 | for collection in allCollections when collection 922 | multiplexerCountAfter += Object.keys(collection._driver.mongo._observeMultiplexers).length 923 | 924 | @assertEqual @multiplexerCountBefore, multiplexerCountAfter 925 | ] 926 | 927 | # Register the test case. 928 | ClassyTestCase.addTest new ReactivePublishTestCase() 929 | 930 | if Meteor.isServer 931 | Meteor.publish "initial error", -> 932 | @autorun () => 933 | throw new Meteor.Error('triggered error') 934 | 935 | Meteor.publish "rereun error", -> 936 | reactiveError = new ReactiveVar false 937 | 938 | @autorun () => 939 | if reactiveError.get() 940 | throw new Meteor.Error('triggered error') 941 | 942 | setTimeout (-> reactiveError.set(true)), 1000 943 | @ready() 944 | 945 | class ReactivePublishErrorTests extends ClassyTestCase 946 | @testName: "reactivepublish - errors" 947 | 948 | testClientInitialError: -> 949 | @assertSubscribeFails "initial error", @expect() 950 | 951 | testClientRerunError: -> 952 | isReady = false 953 | @subscribe "rereun error", 954 | onStop: @expect (error) => 955 | @assertTrue isReady, true, 'received error on rerun' 956 | @assertEqual error.message, '[triggered error]' 957 | onReady: => 958 | isReady = true 959 | 960 | ClassyTestCase.addTest new ReactivePublishErrorTests 961 | 962 | --------------------------------------------------------------------------------