├── .gitignore ├── .travis.yml ├── .versions ├── LICENSE ├── README.md ├── client.coffee ├── package.js ├── server.coffee └── tests.coffee /.gitignore: -------------------------------------------------------------------------------- 1 | .* 2 | .build* 3 | smart.lock 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "0.10" 4 | sudo: required 5 | before_install: 6 | - "curl -L http://git.io/ejPSng | /bin/sh" 7 | -------------------------------------------------------------------------------- /.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 | inter-process-messaging@0.1.0 27 | local-test:peerlibrary:subscription-scope@0.5.0 28 | logging@1.1.20 29 | meteor@1.9.3 30 | minimongo@1.4.5 31 | modern-browsers@0.1.4 32 | modules@0.13.0 33 | modules-runtime@0.10.3 34 | mongo@1.6.2 35 | mongo-decimal@0.1.1 36 | mongo-dev-server@1.1.0 37 | mongo-id@1.0.7 38 | npm-mongo@3.1.2 39 | ordered-dict@1.1.0 40 | peerlibrary:classy-test@0.4.0 41 | peerlibrary:extend-publish@0.6.0 42 | peerlibrary:subscription-scope@0.5.0 43 | promise@0.11.2 44 | random@1.1.0 45 | reload@1.3.0 46 | retry@1.1.0 47 | routepolicy@1.1.0 48 | socket-stream-client@0.2.2 49 | tinytest@1.1.0 50 | tracker@1.2.0 51 | underscore@1.0.10 52 | webapp@1.7.3 53 | webapp-hashing@1.0.9 54 | -------------------------------------------------------------------------------- /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 | subscription scope 2 | ================== 3 | 4 | This Meteor smart package allows scoping of queries on collections only to documents 5 | published by a subscription. 6 | 7 | Adding this package to your [Meteor](http://www.meteor.com/) application extends 8 | subscription's handle with `scopeQuery` and publish endpoint function's `this` is 9 | extended with `this.enableScope()`. 10 | 11 | Both client and server side. 12 | 13 | Installation 14 | ------------ 15 | 16 | ``` 17 | meteor add peerlibrary:subscription-scope 18 | ``` 19 | 20 | API 21 | --- 22 | 23 | The subscription handle returned from [`Meteor.subscribe`](http://docs.meteor.com/#/full/meteor_subscribe) 24 | contain a new method: 25 | 26 | * `scopeQuery()` – returns a query which limits collection's documents only to this subscription 27 | 28 | Limiting is only done on documents, not on fields. If multiple publish endpoints publish different fields 29 | and you subscribe to them, all combined fields will still be available in all queries on the client side. 30 | 31 | Inside the [publish endpoint](http://docs.meteor.com/#/full/meteor_publish) function `this` is 32 | extended with: 33 | 34 | * `enableScope()` – when enabled, for subscriptions to this publish endpoint, clients can use `scopeQuery()` 35 | to limit queries only to the subscription 36 | 37 | Examples 38 | -------- 39 | 40 | If on the server side you have such publish endpoint (using 41 | [MongoDB full-text search](https://docs.mongodb.org/v2.6/reference/operator/query/text/)): 42 | 43 | ```javascript 44 | Meteor.publish('search-documents', function (search) { 45 | this.enableScope(); 46 | 47 | var query = {$text: {$search: search}}; 48 | query['score_' + this._subscriptionId] = {$meta: 'textScore'}; 49 | 50 | return MyCollection.find(query); 51 | }); 52 | ``` 53 | 54 | Then you can on the client side subscribe to it and query only the documents returned from it: 55 | 56 | ```javascript 57 | var subscription = Meteor.subscribe('search-documents', 'foobar'); 58 | 59 | var sort = {} 60 | sort['score_' + subscription.subscriptionId] = -1; 61 | 62 | // Returns documents found on the server, sorted by the full-text score. 63 | MyCollection.find(subscription.scopeQuery(), {sort: sort}).fetch(); 64 | 65 | // Returns count of documents found on the server authored by the current user. 66 | MyCollection.find({$and: [subscription.scopeQuery(), {author: Meteor.userId()}]}).count(); 67 | ``` 68 | 69 | Related projects 70 | ---------------- 71 | 72 | * [find-from-publication](https://github.com/versolearning/find-from-publication) – uses an 73 | extra collection which means that there are some syncing issues between collections and 74 | much more data is send to the client for every document; in short, it is much more complicated 75 | solution to the simple but powerful approach used by this package 76 | -------------------------------------------------------------------------------- /client.coffee: -------------------------------------------------------------------------------- 1 | Connection = Meteor.connection.constructor 2 | 3 | originalSubscribe = Connection::subscribe 4 | Connection::subscribe = (args...) -> 5 | handle = originalSubscribe.apply @, args 6 | 7 | handle.scopeQuery = -> 8 | query = {} 9 | query["_sub_#{handle.subscriptionId}"] = 10 | $exists: true 11 | query 12 | 13 | handle 14 | 15 | # Recreate the convenience method. 16 | Meteor.subscribe = _.bind Meteor.connection.subscribe, Meteor.connection 17 | 18 | originalCompileProjection = LocalCollection._compileProjection 19 | LocalCollection._compileProjection = (fields) -> 20 | fun = originalCompileProjection fields 21 | 22 | (obj) -> 23 | res = fun obj 24 | 25 | for field of res when field.lastIndexOf('_sub_', 0) is 0 26 | delete res[field] 27 | 28 | res 29 | -------------------------------------------------------------------------------- /package.js: -------------------------------------------------------------------------------- 1 | Package.describe({ 2 | name: 'peerlibrary:subscription-scope', 3 | summary: "Scope queries on collections to subscriptions", 4 | version: '0.5.0', 5 | git: 'https://github.com/peerlibrary/meteor-subscription-scope.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 | 'underscore', 16 | 'minimongo' 17 | ]); 18 | 19 | // 3rd party dependencies. 20 | api.use([ 21 | 'peerlibrary:extend-publish@0.6.0' 22 | ]); 23 | 24 | api.addFiles([ 25 | 'server.coffee' 26 | ], 'server'); 27 | 28 | api.addFiles([ 29 | 'client.coffee' 30 | ], 'client'); 31 | }); 32 | 33 | Package.onTest(function (api) { 34 | api.versionsFrom('METEOR@1.8.1'); 35 | 36 | // Core dependencies. 37 | api.use([ 38 | 'coffeescript@2.4.1', 39 | 'ecmascript', 40 | 'random', 41 | 'underscore', 42 | 'mongo' 43 | ]); 44 | 45 | // Internal dependencies. 46 | api.use([ 47 | 'peerlibrary:subscription-scope' 48 | ]); 49 | 50 | // 3rd party dependencies. 51 | api.use([ 52 | 'peerlibrary:classy-test@0.4.0' 53 | ]); 54 | 55 | api.addFiles([ 56 | 'tests.coffee' 57 | ]); 58 | }); 59 | -------------------------------------------------------------------------------- /server.coffee: -------------------------------------------------------------------------------- 1 | extendPublish (name, func, options) -> 2 | newFunc = (args...) -> 3 | publish = @ 4 | 5 | scopeFieldName = "_sub_#{publish._subscriptionId}" 6 | 7 | enabled = false 8 | 9 | publish.enableScope = -> 10 | enabled = true 11 | 12 | originalAdded = publish.added 13 | publish.added = (collectionName, id, fields) -> 14 | # Add our scoping field. 15 | if enabled 16 | fields = _.clone fields 17 | fields[scopeFieldName] = 1 18 | 19 | originalAdded.call @, collectionName, id, fields 20 | 21 | originalChanged = publish.changed 22 | publish.changed = (collectionName, id, fields) -> 23 | # We do not allow changes to our scoping field. 24 | if enabled and scopeFieldName of fields 25 | fields = _.clone fields 26 | delete fields[scopeFieldName] 27 | 28 | originalChanged.call @, collectionName, id, fields 29 | 30 | func.apply publish, args 31 | 32 | [name, newFunc, options] 33 | -------------------------------------------------------------------------------- /tests.coffee: -------------------------------------------------------------------------------- 1 | MyCollection = new Mongo.Collection 'MyCollection' 2 | 3 | if Meteor.isServer 4 | TestDataCollection = new Mongo.Collection null 5 | 6 | Meteor.methods 7 | insertTest: (obj) -> 8 | TestDataCollection.insert obj 9 | 10 | updateTest: (selector, query) -> 11 | TestDataCollection.update selector, query 12 | 13 | removeTest: (selector) -> 14 | TestDataCollection.remove selector 15 | 16 | Meteor.publish 'testDataPublish', (divisor, remainder) -> 17 | @enableScope() 18 | 19 | handle = TestDataCollection.find({i: $mod: [divisor, remainder]}).observeChanges 20 | added: (id, fields) => 21 | @added 'testDataCollection', id, fields 22 | changed: (id, fields) => 23 | @changed 'testDataCollection', id, fields 24 | removed: (id) => 25 | @removed 'testDataCollection', id 26 | 27 | @onStop => 28 | handle.stop() 29 | 30 | @ready() 31 | 32 | MyCollection._ensureIndex 33 | subject: 'text' 34 | 35 | Meteor.publish 'searchDocuments', (search) -> 36 | @enableScope() 37 | 38 | query = 39 | $text: 40 | $search: search 41 | 42 | options = 43 | fields: 44 | "score_#{@_subscriptionId}": 45 | $meta: 'textScore' 46 | pollingIntervalMs: 1000 47 | 48 | MyCollection.find query, options 49 | 50 | else 51 | TestDataCollection = new Mongo.Collection 'testDataCollection' 52 | 53 | class BasicTestCase extends ClassyTestCase 54 | @testName: 'subscription-data - basic' 55 | 56 | setUpServer: -> 57 | TestDataCollection.remove {} 58 | MyCollection.remove {} 59 | 60 | MyCollection.insert 61 | subject: "foo bar test string" 62 | MyCollection.insert 63 | subject: "lorem ipsum test string" 64 | MyCollection.insert 65 | subject: "lorem ipsum dolor sit amet" 66 | 67 | testClientBasic: [ 68 | -> 69 | @subscription1 = @assertSubscribeSuccessful 'testDataPublish', 2, 0, @expect() 70 | @subscription2 = @assertSubscribeSuccessful 'testDataPublish', 2, 1, @expect() 71 | @subscription3 = @assertSubscribeSuccessful 'testDataPublish', 6, 0, @expect() 72 | @subscription4 = @assertSubscribeSuccessful 'testDataPublish', 3, 0, @expect() 73 | , 74 | -> 75 | @assertEqual TestDataCollection.find({}, {fields: {_id: 0}, sort: {i: 1}}).fetch(), [] 76 | @assertEqual TestDataCollection.find(@subscription1.scopeQuery(), {fields: {_id: 0}, sort: {i: 1}}).fetch(), [] 77 | @assertEqual TestDataCollection.find(@subscription2.scopeQuery(), {fields: {_id: 0}, sort: {i: 1}}).fetch(), [] 78 | @assertEqual TestDataCollection.find(@subscription3.scopeQuery(), {fields: {_id: 0}, sort: {i: 1}}).fetch(), [] 79 | @assertEqual TestDataCollection.find(@subscription4.scopeQuery(), {fields: {_id: 0}, sort: {i: 1}}).fetch(), [] 80 | 81 | @documents = [] 82 | 83 | for i in [0...10] 84 | doc = {i: i} 85 | @documents.push doc 86 | Meteor.call 'insertTest', doc, @expect (error, documentId) => 87 | @assertFalse error, error 88 | @assertTrue documentId 89 | 90 | @documents = _.sortBy @documents, 'i' 91 | , 92 | -> 93 | # To wait a bit for change to propagate. 94 | Meteor.setTimeout @expect(), 100 # ms 95 | , 96 | -> 97 | @assertEqual TestDataCollection.find({}, {fields: {_id: 0}, sort: {i: 1}}).fetch(), @documents 98 | @assertEqual TestDataCollection.find(@subscription1.scopeQuery(), {fields: {_id: 0}, sort: {i: 1}}).fetch(), _.filter @documents, ({i}) -> i % 2 is 0 99 | @assertEqual TestDataCollection.find(@subscription2.scopeQuery(), {fields: {_id: 0}, sort: {i: 1}}).fetch(), _.filter @documents, ({i}) -> i % 2 is 1 100 | @assertEqual TestDataCollection.find(@subscription3.scopeQuery(), {fields: {_id: 0}, sort: {i: 1}}).fetch(), _.filter @documents, ({i}) -> i % 6 is 0 101 | @assertEqual TestDataCollection.find(@subscription4.scopeQuery(), {fields: {_id: 0}, sort: {i: 1}}).fetch(), _.filter @documents, ({i}) -> i % 3 is 0 102 | 103 | for i in [0...10] 104 | doc = {i: i} 105 | @documents.push doc 106 | Meteor.call 'insertTest', doc, @expect (error, documentId) => 107 | @assertFalse error, error 108 | @assertTrue documentId 109 | 110 | @documents = _.sortBy @documents, 'i' 111 | , 112 | -> 113 | # To wait a bit for change to propagate. 114 | Meteor.setTimeout @expect(), 100 # ms 115 | , 116 | -> 117 | @assertEqual TestDataCollection.find({}, {fields: {_id: 0}, sort: {i: 1}}).fetch(), @documents 118 | @assertEqual TestDataCollection.find(@subscription1.scopeQuery(), {fields: {_id: 0}, sort: {i: 1}}).fetch(), _.filter @documents, ({i}) -> i % 2 is 0 119 | @assertEqual TestDataCollection.find(@subscription2.scopeQuery(), {fields: {_id: 0}, sort: {i: 1}}).fetch(), _.filter @documents, ({i}) -> i % 2 is 1 120 | @assertEqual TestDataCollection.find(@subscription3.scopeQuery(), {fields: {_id: 0}, sort: {i: 1}}).fetch(), _.filter @documents, ({i}) -> i % 6 is 0 121 | @assertEqual TestDataCollection.find(@subscription4.scopeQuery(), {fields: {_id: 0}, sort: {i: 1}}).fetch(), _.filter @documents, ({i}) -> i % 3 is 0 122 | 123 | @subscription2.stop() 124 | 125 | # To wait a bit for change to propagate. 126 | Meteor.setTimeout @expect(), 100 # ms 127 | , 128 | -> 129 | fields = {_id: 0, i: 1} 130 | fields["_sub_#{@subscription1.subscriptionId}"] = 1 131 | fields["_sub_#{@subscription2.subscriptionId}"] = 1 132 | fields["_sub_#{@subscription3.subscriptionId}"] = 1 133 | fields["_sub_#{@subscription4.subscriptionId}"] = 1 134 | 135 | @assertEqual TestDataCollection.find({}, {fields: fields, sort: {i: 1}}).fetch(), _.filter @documents, ({i}) -> i % 2 is 0 or i % 6 is 0 or i % 3 is 0 136 | @assertEqual TestDataCollection.find(@subscription1.scopeQuery(), {fields: fields, sort: {i: 1}}).fetch(), _.filter @documents, ({i}) -> i % 2 is 0 137 | @assertEqual TestDataCollection.find(@subscription2.scopeQuery(), {fields: fields, sort: {i: 1}}).fetch(), [] 138 | @assertEqual TestDataCollection.find(@subscription3.scopeQuery(), {fields: fields, sort: {i: 1}}).fetch(), _.filter @documents, ({i}) -> i % 6 is 0 139 | @assertEqual TestDataCollection.find(@subscription4.scopeQuery(), {fields: fields, sort: {i: 1}}).fetch(), _.filter @documents, ({i}) -> i % 3 is 0 140 | 141 | @subscription3.stop() 142 | 143 | # To wait a bit for change to propagate. 144 | Meteor.setTimeout @expect(), 100 # ms 145 | , 146 | -> 147 | @assertEqual TestDataCollection.find({}, {fields: {_id: 0, i: 1}, sort: {i: 1}}).fetch(), _.filter @documents, ({i}) -> i % 2 is 0 or i % 3 is 0 148 | @assertEqual TestDataCollection.find(@subscription1.scopeQuery(), {fields: {_id: 0, i: 1}, sort: {i: 1}}).fetch(), _.filter @documents, ({i}) -> i % 2 is 0 149 | @assertEqual TestDataCollection.find(@subscription2.scopeQuery(), {fields: {_id: 0, i: 1}, sort: {i: 1}}).fetch(), [] 150 | @assertEqual TestDataCollection.find(@subscription3.scopeQuery(), {fields: {_id: 0, i: 1}, sort: {i: 1}}).fetch(), [] 151 | @assertEqual TestDataCollection.find(@subscription4.scopeQuery(), {fields: {_id: 0, i: 1}, sort: {i: 1}}).fetch(), _.filter @documents, ({i}) -> i % 3 is 0 152 | ] 153 | 154 | testClientSearch: [ 155 | -> 156 | @subscription = @assertSubscribeSuccessful 'searchDocuments', "lorem", @expect() 157 | , 158 | -> 159 | @assertEqual MyCollection.find( 160 | {} 161 | , 162 | sort: 163 | "score_#{@subscription.subscriptionId}": -1 164 | fields: 165 | _id: 0 166 | ).fetch(), [ 167 | "score_#{@subscription.subscriptionId}": 0.625 168 | subject: "lorem ipsum test string" 169 | , 170 | "score_#{@subscription.subscriptionId}": 0.6 171 | subject: "lorem ipsum dolor sit amet" 172 | ] 173 | , 174 | @runOnServer -> 175 | MyCollection.insert 176 | subject: "lorem ipsum" 177 | , 178 | -> 179 | # To wait a bit for change to propagate. 1000 ms for polling + 100 ms extra. 180 | Meteor.setTimeout @expect(), 1100 # ms 181 | , 182 | -> 183 | @assertEqual MyCollection.find( 184 | {} 185 | , 186 | sort: 187 | "score_#{@subscription.subscriptionId}": -1 188 | fields: 189 | _id: 0 190 | ).fetch(), [ 191 | "score_#{@subscription.subscriptionId}": 0.75 192 | subject: "lorem ipsum" 193 | , 194 | "score_#{@subscription.subscriptionId}": 0.625 195 | subject: "lorem ipsum test string" 196 | , 197 | "score_#{@subscription.subscriptionId}": 0.6 198 | subject: "lorem ipsum dolor sit amet" 199 | ] 200 | 201 | @subscription.stop() 202 | ] 203 | 204 | ClassyTestCase.addTest new BasicTestCase() 205 | --------------------------------------------------------------------------------