├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── database.rules.bolt ├── demo.js ├── env.json.dist ├── firebase-search.js ├── firebase-search.spec.js ├── package.json ├── services ├── environment.js └── log.js ├── spec └── support │ └── jasmine.json ├── test-algolia.js ├── test-elasticsearch.js ├── test-package.js ├── tests ├── algolia-test.js ├── bootstrap.js ├── elasticsearch-test.js └── package-test.js ├── tunnel.js └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Optional npm cache directory 34 | .npm 35 | 36 | # Optional REPL history 37 | .node_repl_history 38 | 39 | .DS_Store 40 | bower_components 41 | env.json 42 | .vscode 43 | service-account.json 44 | fake-users.json -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Optional npm cache directory 34 | .npm 35 | 36 | # Optional REPL history 37 | .node_repl_history 38 | 39 | .DS_Store 40 | bower_components 41 | env.json 42 | .vscode 43 | service-account.json 44 | fake-users.json 45 | 46 | # NPM-specific ignores 47 | env.json.dist 48 | spec 49 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Chris Esplin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | Install with npm: ```npm install quiver-firebase-search```. 4 | 5 | # Configuration 6 | 7 | ### Configure Firebase 8 | - Create a Firebase project 9 | - Configure a billing account for the Google Cloud project associated with this new Firebase project. You may need to upgrade your Firebase from Spark (free) to Blaze (pay as you go). 10 | - Go to your project's Google Cloud Console's [API Manager](https://console.cloud.google.com/apis/credentials) and create a service account JSON file 11 | 1. Click ***Create credentials*** 12 | 2. Select ***Service account key*** 13 | 3. Create new service account, give it a name and select JSON 14 | 4. Download and save this key securely on your local machine 15 | 16 | ### Configure env.json 17 | 18 | - Create ```/env.json``` using ```/env.json.dist``` as a template. Make sure to reference the service account JSON file that you created in the last step. 19 | - ```env.json``` has three root nodes, ```defaults```, ```development```, ```test``` and ```production```. Fill in your details under the ```defaults``` node. The other three nodes are used to override your defaults according to ```process.env.NODE_ENV```. So if you're in production, ```process.env.NODE_ENV``` will be ```production```, and any overrides that you provide under the ```production``` attribute will override your defaults. 20 | - You'll add your Elasticsearch and Algolia details to ```env.json``` once you have each service configured. 21 | - If you're not using Elasticsearch, do not include any ```elasticsearch``` attributes in ```env.json```. The same goes for Algolia... don't include specs for the services that you're not using. Excluding a spec will disable that part of FirebaseSearch. So excluding ```defaults.elasticsearch``` will disable Elasticsearch. Of course, if you add a spec under ```production.elasticsearch```, then FirebaseSearch will attempt to configure Elasticsearch in your production environment. 22 | 23 | ### Configure Elasticsearch 24 | - Find Google's [Elasticsearch project on Cloud Launcher](https://console.cloud.google.com/launcher/details/click-to-deploy-images/elasticsearch?q=elasticsearch&project=firebase-search) 25 | - Launch a new cluster. Feel free to use the cheapest configuration. Elasticsearch doesn't need much processing power for simple operations. 26 | - The [Deployment Manager](https://console.cloud.google.com/deployments) will have most of the details that you need to configure ```env.json```. 27 | - You have a couple of options for connecting to your cluster. You can use the external IP, or you can use the gcloud utility to create a local tunnel and work off of an internal, tunnelled IP. The external IP method requires that you whitelist all Elasticsearch clients via your [Google Cloud firewall rules](https://console.cloud.google.com/networking/firewalls/list). Tunnelling is a bit easier, because gcloud handles all of the configuration for you. Of course, you could also tunnel manually using SSH or Nginx... so tunnelling is the most flexible and possibly the most secure way to connect. 28 | - If you want to use the external IP, go to your [Compute Engine](https://console.cloud.google.com/compute/instances) page to find the external IP address for your cluster and then whitelist your client with a firewall rule. 29 | - If you'd rather tunnel... 30 | - Install [gcloud](https://cloud.google.com/sdk/) 31 | - Run ```gcloud --version``` and update if prompted 32 | - Run ```gcloud init``` to get your project initialized 33 | - Run ```npm run-script tunnel``` to read out a shell command that you can use to launch a local tunnel on port 9200 to your Elasticsearch cluster. This tunnel is required for testing and development, but not for production. 34 | - Visit [http://localhost:9200](http://localhost:9200) in your browser. You should see some JSON read out from Elasticsearch if your cluster is running and your tunnel is also live. 35 | 36 | ### Configure Algolia 37 | - Sign up for [Algolia](https://www.algolia.com/) 38 | - Copy your [api keys](https://www.algolia.com/api-keys) to ```env.json```. 39 | 40 | ### Testing 41 | 42 | - Make sure that you've configured Firebase, Elasticsearch and Algolia according to the above instructions. 43 | - Run ```npm install && npm test``` to ensure that everything is configured correctly. This command will test the indexing against the databaseURL that you referenced in ```env.json```. It will create some dummy data under ```/firebase-search/test/users```. It doesn't hurt to leave the dummy data, but it doesn't delete it automatically in case you want to run the tests again later. There's no need to attack the SWAPI servers that supply dummy data. 44 | - You can run tests individually with ```node test-algolia.js``` and ```node test-elasticsearch.js```. 45 | 46 | # Example Usage 47 | 48 | ```javascript 49 | var FirebaseSearch = require('firebase-search.js'); 50 | var firebase = require('firebase'); 51 | 52 | firebase.initializeApp({ 53 | "databaseURL": "https://quiver-firebase-search.firebaseio.com", 54 | "serviceAccount": "./service-account.json" 55 | }); 56 | 57 | var usersRef = firebase.database().ref('demo/users'); 58 | var elasticsearchConfig = { 59 | host: 'localhost:9200', 60 | log: 'warning', 61 | index: 'development' 62 | }; 63 | var algoliaConfig = { 64 | "applicationID": "XXXXXXXX", 65 | "searchAPIKey": "XXXXXXXX", 66 | "monitoringAPIKey": "XXXXXXXX", 67 | "apiKey": "XXXXXXXX" 68 | }; 69 | 70 | var search = new FirebaseSearch(usersRef, { 71 | elasticsearch: elasticsearchConfig, 72 | algolia: algoliaConfig 73 | }, 'users'); 74 | 75 | // Optional elasticsearch configuration settings 76 | var config = { 77 | added: { /* settings passed into elasticsearch client create function */ }, 78 | changed: { /* settings passed into elasticsearch client update function */ }, 79 | deleted: { /* settings passed into elasticsearch client delete function */ } 80 | }; 81 | 82 | search.elasticsearch.firebase.start(config); 83 | search.algolia.firebase.start(); 84 | ``` 85 | 86 | # FirebaseSearch Functions 87 | 88 | ## FirebaseSearch.elasticsearch.client 89 | 90 | The entire [Elasticsearch client api](https://www.elastic.co/guide/en/elasticsearch/client/javascript-api/current/api-reference.html) is available via ```FirebaseSearch.elasticsearch.client```. 91 | 92 | ## FirebaseSearch.prototype.elasticsearch 93 | 94 | A number of top-level Elasticsearch functions are proxied by FirebaseSearch from the [original api](https://www.elastic.co/guide/en/elasticsearch/client/javascript-api/current/api-reference.html). They're used internally by FirebaseSearch and also exist to provide a nice Promise-based api. 95 | 96 | All functions assumed the default ```index``` and ```type``` values, although they can be overridden as needed. So where you'd typically need to make a call like ```firebasSearch.elasticsearch.create({index: 'development', type: 'users', body: {name: 'Chris'}});```, with the proxied version you can simply call ```firebasSearch.elasticsearch.create({body: {name: 'Chris'}});```. 97 | 98 | ### Elasticsearch top-level proxied functions 99 | 100 | ***FirebaseSearch.prototype.elasticsearch.ping()*** 101 | 102 | ```javascript 103 | search.elasticsearch.ping() 104 | .then(function (isThisOn) { 105 | console.log('Is this thing on?', isThisOn); 106 | }); 107 | ``` 108 | 109 | ***FirebaseSearch.prototype.elasticsearch.create(requestObject)*** 110 | 111 | ```javascript 112 | search.elasticsearch.create({ 113 | body: { 114 | name: 'Chris' 115 | } 116 | }) 117 | .then(function (res) { 118 | console.log('Create response', res); 119 | }); 120 | ``` 121 | 122 | ***FirebaseSearch.prototype.elasticsearch.update(requestObject)*** 123 | 124 | ```javascript 125 | search.elasticsearch.update({ 126 | body: { 127 | doc: { 128 | name: 'Spike' 129 | } 130 | } 131 | }) 132 | .then(function (res) { 133 | console.log('Update response', res); 134 | }); 135 | ``` 136 | 137 | ***FirebaseSearch.prototype.elasticsearch.delete(requestObject)*** 138 | 139 | ```javascript 140 | search.elasticsearch.delete({ 141 | id: 'someUserId' 142 | }) 143 | .then(function (res) { 144 | console.log('Delete response', res); 145 | }); 146 | ``` 147 | 148 | ***FirebaseSearch.prototype.elasticsearch.exists(requestObject)*** 149 | 150 | ```javascript 151 | search.elasticsearch.exists({ 152 | id: 'someUserId' 153 | }) 154 | .then(function (exists) { 155 | console.log('Does this record exist?', exists); 156 | }); 157 | ``` 158 | 159 | ***FirebaseSearch.prototype.elasticsearch.get(requestObject)*** 160 | 161 | ```javascript 162 | search.elasticsearch.get({ 163 | id: 'someUserId' 164 | }) 165 | .then(function (res) { 166 | console.log('Get response', res); 167 | }); 168 | ``` 169 | 170 | ***FirebaseSearch.prototype.elasticsearch.search(requestObject)*** 171 | 172 | ```javascript 173 | search.elasticsearch.search({ 174 | q: 'name:Chris' 175 | }) 176 | .then(function (res) { 177 | console.log('Search response', res); 178 | }); 179 | ``` 180 | 181 | ## FirebaseSearch.prototype.elasticsearch.indices 182 | 183 | The functions found under ```FirebaseSearch.prototype.elasticsearch.indices``` are all proxies of their [corresponding Elasticsearch functions](https://www.elastic.co/guide/en/elasticsearch/client/javascript-api/current/api-reference.html#api-indices-create). The only difference is that you don't have to specify any parameters to use them, because FirebaseSearch already knows which index you're using and defaults to that index. Of course, you can always override the default parameters if necessary. 184 | 185 | ### Elasticsearch proxied index functions 186 | 187 | - ***elasticsearch.indices.exists()*** 188 | - ***elasticsearch.indices.delete()*** 189 | - ***elasticsearch.indices.create()*** 190 | - ***elasticsearch.indices.ensure()*** 191 | 192 | ### Usage 193 | 194 | ```javascript 195 | search.elasticsearch.indices.exists() 196 | .then(function (exists) { 197 | console.log('Does the index exist?', exists); 198 | }); 199 | 200 | search.elasticsearch.indices.delete() 201 | .then(function () { 202 | console.log('index deleted'); 203 | }); 204 | 205 | search.elasticsearch.indices.create() 206 | .then(function () { 207 | console.log('index created'); 208 | }); 209 | 210 | search.elasticsearch.indices.ensure() 211 | .then(function () { 212 | console.log('index created if necessary'); 213 | }); 214 | ``` 215 | 216 | ### FirebaseSearch.prototype.elasticsearch.firebase 217 | 218 | The functions found under ```FirebaseSearch.prototype.elasticsearch.firebase``` handle common Firebase operations. 219 | 220 | ***FirebaseSearch.prototype.elasticsearch.firebase.build()*** 221 | 222 | Builds the Elasticsearch index to reflect all existing Firebase records 223 | 224 | ```javascript 225 | search.elasticsearch.firebase.build() 226 | .then(function () { 227 | console.log('Index built and synced with current Firebase state.'); 228 | }) 229 | ``` 230 | 231 | ***FirebaseSearch.prototype.elasticsearch.firebase.start()*** 232 | 233 | Starts listening to Firebase records additions, changes and removals, syncing Elasticsearch appropriately 234 | 235 | Optional `config` is used to pass custom parameters to ElasticSearch's [create](https://www.elastic.co/guide/en/elasticsearch/client/javascript-api/current/api-reference.html#api-create), [update](https://www.elastic.co/guide/en/elasticsearch/client/javascript-api/current/api-reference.html#api-update), and [delete](https://www.elastic.co/guide/en/elasticsearch/client/javascript-api/current/api-reference.html#api-delete) function calls. 236 | 237 | ```javascript 238 | // Optional elasticsearch configuration settings 239 | var config = { 240 | added: { /* settings passed into elasticsearch client create function */ }, 241 | changed: { /* settings passed into elasticsearch client update function */ }, 242 | deleted: { /* settings passed into elasticsearch client delete function */ } 243 | }; 244 | 245 | search.elasticsearch.firebase.start(config) 246 | .then(function () { 247 | console.log('Syncing Elasticsearch with Firebase'); 248 | }) 249 | ``` 250 | 251 | ***FirebaseSearch.prototype.elasticsearch.firebase.stop()*** 252 | 253 | Stops listening to Firebase and syncing Elasticsearch 254 | 255 | ```javascript 256 | search.elasticsearch.firebase.stop() 257 | .then(function () { 258 | console.log('Stopped syncing Elasticsearch with Firebase'); 259 | }) 260 | ``` 261 | 262 | ### FirebaseSearch.algolia.client 263 | 264 | Provides access to the Algolia [client api](https://www.algolia.com/doc/api-client/javascript/getting-started#init-index) 265 | 266 | ### FirebaseSearch.algolia.index 267 | 268 | Provides access to the Algolia [index api](https://www.algolia.com/doc/api-client/javascript/getting-started#init-index) 269 | 270 | ### FirebaseSearch.prototype.algolia 271 | 272 | These proxied functions are used internally by FirebaseSearch and are also available for manipulating Algolia. 273 | 274 | These functions all return promises and can be called so that they wait for Algolia to finish its operations and confirm success before resolving the promise. Algolia returns all write operations immediately and provides a ```waitTask(taskID)``` function to wait for task completion. 275 | 276 | ***FirebaseSearch.prototype.algolia.search(searchText, options)*** 277 | 278 | Takes a search string as a first argument and an optional search options objects as a second argument. 279 | 280 | ```javascript 281 | search.algolia.search('search text', { 282 | hitsPerPage: 25 283 | }) 284 | .then(function (res) { 285 | console.log('Search results', res); 286 | }); 287 | ``` 288 | 289 | ***FirebaseSearch.prototype.algolia.addObject(object, shouldWait)*** 290 | 291 | ```javascript 292 | search.algolia.addObject({ 293 | name: 'Chris', 294 | objectID: '123456' 295 | }, true) 296 | .then(function (res) { 297 | console.log('Object added', res); 298 | }); 299 | ``` 300 | 301 | ***FirebaseSearch.prototype.algolia.saveObject(object, shouldWait)*** 302 | 303 | ```javascript 304 | search.algolia.saveObject({ 305 | name: 'Chris', 306 | objectID: '123456' 307 | }, true) 308 | .then(function (res) { 309 | console.log('Object saved', res); 310 | }); 311 | ``` 312 | 313 | ***FirebaseSearch.prototype.algolia.deleteObject(objectID, shouldWait)*** 314 | 315 | ```javascript 316 | search.algolia.deleteObject('123456', true) 317 | .then(function (res) { 318 | console.log('Object deleted', res); 319 | }); 320 | ``` 321 | 322 | ***FirebaseSearch.prototype.algolia.setSettings(settings)*** 323 | 324 | ```javascript 325 | search.algolia.setSettings({ 326 | customRanking: ['desc(height)'] 327 | }) 328 | .then(function () { 329 | console.log('Setting set'); 330 | }); 331 | ``` 332 | 333 | ***FirebaseSearch.prototype.algolia.listIndexes()*** 334 | 335 | ```javascript 336 | search.algolia.listIndexes() 337 | .then(function (indexes) { 338 | console.log('indexes', indexes); 339 | }); 340 | ``` 341 | 342 | ***FirebaseSearch.prototype.algolia.clearIndex()*** 343 | 344 | ```javascript 345 | search.algolia.clearIndex() 346 | .then(function () { 347 | console.log('Index cleared'); 348 | }); 349 | ``` 350 | 351 | ***FirebaseSearch.prototype.algolia.waitTask()*** 352 | 353 | ```javascript 354 | search.algolia.index.partialUpdateObject({ 355 | objectID: '123456', 356 | favoriteColor: 'green' 357 | }, function (err, content) { 358 | search.algolia.waitTask(content.taskID) 359 | .then(function () { 360 | console.log('task complete'); 361 | }); 362 | }); 363 | ``` 364 | 365 | ###FirebaseSearch.prototype.algolia.exists(objectType) 366 | 367 | Algolia doesn't come with an "exists" function out of the box. But Elasticsearch's exist function is so useful, we might as well pre-package one for Algolia as well. 368 | 369 | ```javascript 370 | search.algolia.exists('users') 371 | .then(function (exists) { 372 | console.log('Users index exists', exists); 373 | }); 374 | ``` 375 | 376 | ### FirebaseSearch.prototype.algolia.firebase 377 | 378 | The functions found under ```FirebaseSearch.prototype.algolia.firebase``` handle common Firebase operations. 379 | 380 | ***FirebaseSearch.prototype.algolia.firebase.build()*** 381 | 382 | Builds the Algolia index to reflect all existing Firebase records 383 | 384 | ```javascript 385 | search.algolia.firebase.build() 386 | .then(function () { 387 | console.log('Index built and synced with current Firebase state.'); 388 | }) 389 | ``` 390 | 391 | ***FirebaseSearch.prototype.algolia.firebase.start()*** 392 | 393 | Starts listening to Firebase records additions, changes and removals, syncing Algolia appropriately 394 | 395 | ```javascript 396 | search.algolia.firebase.start() 397 | .then(function () { 398 | console.log('Syncing Algolia with Firebase'); 399 | }) 400 | ``` 401 | 402 | ***FirebaseSearch.prototype.algolia.firebase.stop()*** 403 | 404 | Stops listening to Firebase and syncing Algolia 405 | 406 | ```javascript 407 | search.algolia.firebase.stop() 408 | .then(function () { 409 | console.log('Stopped syncing Algolia with Firebase'); 410 | }) 411 | ``` 412 | 413 | # Events 414 | 415 | Syncing with Elasticsearch and Algolia is all so asynchronous and difficult to track, that an events system is the easiest way to manage wait for syncing operations. 416 | 417 | These events are all called after syncing has been completed by one of the ```*.start``` functions. 418 | 419 | The ```all``` event is mostly for debugging, but it could be used for all sorts of stuff. It's fired every time any other event is fired. 420 | 421 | - ***all*** 422 | - ***elasticsearch_child_added*** 423 | - ***elasticsearch_child_changed*** 424 | - ***elasticsearch_child_removed*** 425 | - ***algolia_child_added*** 426 | - ***algolia_child_changed*** 427 | - ***algolia_child_removed*** 428 | 429 | ## Usage 430 | 431 | ```javascript 432 | search.on('all', function (e){ 433 | console.log('Event name', e.name); 434 | console.log('Event detail', e.detail); 435 | }); 436 | 437 | search.on('elasticsearch_child_added', function (record){ 438 | console.log('Record synced', record); 439 | }); 440 | 441 | search.on('elasticsearch_child_changed', function (record){ 442 | console.log('Record synced', record); 443 | }); 444 | 445 | search.on('elasticsearch_child_removed', function (record){ 446 | console.log('Record synced', record); 447 | }); 448 | 449 | search.on('algolia_child_added', function (record){ 450 | console.log('Record synced', record); 451 | }); 452 | 453 | search.on('algolia_child_changed', function (record){ 454 | console.log('Record synced', record); 455 | }); 456 | 457 | search.on('algolia_child_removed', function (record){ 458 | console.log('Record synced', record); 459 | }); 460 | ``` 461 | -------------------------------------------------------------------------------- /database.rules.bolt: -------------------------------------------------------------------------------- 1 | path /firebase-search/{environment}/users { 2 | read() { true } 3 | } 4 | 5 | path /firebase-search/{environment}/user-search is Search { 6 | read() { true } 7 | write() { true } 8 | } 9 | 10 | type Search { 11 | validate() { this.q.length < 100 && this.timestamp.length < 100 } 12 | q: String, 13 | timestamp: String 14 | } -------------------------------------------------------------------------------- /demo.js: -------------------------------------------------------------------------------- 1 | var FirebaseSearch = require('./firebase-search.js'); 2 | var firebase = require('firebase'); 3 | var axios = require('axios'); 4 | var _ = require('lodash'); 5 | 6 | firebase.initializeApp({ 7 | "databaseURL": "https://quiver-firebase-search.firebaseio.com", 8 | "serviceAccount": "./service-account.json" 9 | }); 10 | 11 | var usersRef = firebase.database().ref('demo/users'); 12 | var elasticsearchConfig = { 13 | host: 'localhost:9200', 14 | log: 'warning', 15 | index: 'development' 16 | }; 17 | var algoliaConfig = require('./env.json').defaults.algolia; 18 | // Sample Algolia config 19 | // var algoliaConfig = { 20 | // "applicationID": "XXXXXXXX", 21 | // "searchAPIKey": "XXXXXXXX", 22 | // "monitoringAPIKey": "XXXXXXXX", 23 | // "apiKey": "XXXXXXXX" 24 | // }; 25 | 26 | var search = new FirebaseSearch(usersRef, { 27 | elasticsearch: elasticsearchConfig, 28 | algolia: algoliaConfig 29 | }, 'users'); 30 | 31 | search.elasticsearch.indices.exists() 32 | .then(function(exists) { // Delete elasticsearch index if it exists 33 | return exists ? search.elasticsearch.indices.delete() : true; 34 | }) 35 | .then(function() { // Create elasticsearch index 36 | return search.elasticsearch.indices.create(); 37 | }) 38 | .then(function() { // Check if Algolia index exists 39 | return search.algolia.exists(); 40 | }) 41 | .then(function(exists) { // Make sure that Algolia index exists 42 | return exists ? search.algolia.clearIndex(true) : search.algolia.setSettings({attributesToIndex: ['name', 'gender']}); 43 | }) 44 | .then(function() { // Set listeners 45 | search.elasticsearch.firebase.start(); 46 | search.algolia.firebase.start(); 47 | search.on('all', function(e) { 48 | console.log(e.name, e.detail.name, "\n"); 49 | }); 50 | return true; 51 | }) 52 | .then(function() { 53 | return usersRef.remove(); 54 | }) 55 | .then(function () { // Download 5 users from SWAPI 56 | var i = 5; 57 | var promises = []; 58 | var users = []; 59 | var getUser = function (i) { 60 | promises.push(axios.get(`http://swapi.co/api/people/${i + 1}/`) 61 | .then(function (res) { 62 | users.push(res.data); 63 | }) 64 | .catch(function (err) { 65 | console.log('axios err', i); 66 | return true; 67 | })); 68 | }; 69 | 70 | while (i--) { 71 | getUser(i); 72 | } 73 | return Promise.all(promises) 74 | .then(function() { 75 | return users; 76 | }); 77 | }) 78 | .then(function (users) { // Write users to disk 79 | var jsonFormat = require('json-format'); 80 | var fs = require('fs'); 81 | var fakeUsersFile = fs.openSync('./fake-users.json', 'w+'); 82 | fs.writeSync(fakeUsersFile, jsonFormat(users)); 83 | return fs.closeSync(fakeUsersFile); 84 | }) 85 | .then(function () { // Read users from disk and push one to Firebase every 1000 millis 86 | return new Promise(function (resolve, reject) { 87 | var users = require('./fake-users.json'); 88 | var pushUser = function (user) { 89 | usersRef.push(user) 90 | .then(function () { 91 | setTimeout(function () { 92 | if (users.length) { 93 | pushUser(users.pop()); 94 | } else { 95 | resolve(); 96 | } 97 | }, 1000); 98 | }); 99 | }; 100 | pushUser(users.pop()); 101 | }); 102 | }) 103 | .then(function () { 104 | console.log('All records added. Now play around with the Firebase data to watch things change.'); 105 | // process.exit(); 106 | }); 107 | 108 | -------------------------------------------------------------------------------- /env.json.dist: -------------------------------------------------------------------------------- 1 | { 2 | "defaults": { 3 | "firebaseConfig": { 4 | "databaseURL": "https://quiver-firebase-search.firebaseio.com", 5 | "serviceAccount": "./service-account.json", 6 | "secret": "1234567890123456789012345678901234567890" 7 | }, 8 | "log": "console", 9 | "elasticsearch": { 10 | "project": "quiver-firebase-search", 11 | "zone": "us-central1-f", 12 | "vm": "elasticsearch-1-1-vm", 13 | "host": "localhost:9200", 14 | "log": "warning" 15 | }, 16 | "algolia": { 17 | "applicationID": "1234567890", 18 | "searchAPIKey": "12345678901234567890123456789012345678901234567890", 19 | "monitoringAPIKey": "12345678901234567890123456789012345678901234567890", 20 | "apiKey": "12345678901234567890123456789012345678901234567890" 21 | } 22 | }, 23 | "development": { 24 | "elasticsearch": { 25 | "index": "development" 26 | } 27 | }, 28 | "test": { 29 | "log": "console", 30 | "elasticsearch": { 31 | "host": "localhost:9200", 32 | "index": "test" 33 | } 34 | }, 35 | "production": { 36 | "log": "firebase", 37 | "elasticsearch": { 38 | "host": "104.197.163.172:9200", 39 | "index": "production" 40 | } 41 | } 42 | } -------------------------------------------------------------------------------- /firebase-search.js: -------------------------------------------------------------------------------- 1 | const inherits = require('util').inherits; 2 | const EventEmitter = require('events').EventEmitter; 3 | const _ = require('lodash'); 4 | const Queue = require('queue'); 5 | 6 | inherits(FirebaseSearch, EventEmitter); 7 | 8 | module.exports = FirebaseSearch; 9 | 10 | function FirebaseSearch(ref, options, type) { 11 | if (!(this instanceof FirebaseSearch)) { 12 | return new FirebaseSearch(ref, options, type); 13 | } 14 | 15 | var firebaseSearch = this; 16 | 17 | setPrototype(firebaseSearch); 18 | 19 | this.ensureExistingUser = function() { 20 | ref.orderByKey().limitToLast(1).once('value', function(snap) { 21 | if (!snap.numChildren()) { 22 | var fakeRef = ref.push(); 23 | fakeRef.set(true).then(function() { 24 | fakeRef.remove(); 25 | }); 26 | } 27 | }); 28 | }; 29 | 30 | this.getLastKey = function() { 31 | return new Promise(function(resolve, reject) { 32 | var ref = firebaseSearch.ref.orderByKey().limitToLast(1); 33 | var handler = function(snap) { 34 | ref.off('child_added', handler); 35 | resolve(snap.key); 36 | }; 37 | var timer = setTimeout( 38 | function() { 39 | // Must be empty if no response in 1000 millis 40 | ref.off('child_added', handler); 41 | reject('Timeout! Could be an empty Firebase collection.'); 42 | }, 43 | 1000 44 | ); 45 | ref.on('child_added', handler); 46 | }); 47 | }; 48 | 49 | this.fire = function(name, detail) { 50 | firebaseSearch.emit(name, detail); 51 | firebaseSearch.emit('all', { 52 | name: name, 53 | detail: detail 54 | }); 55 | }; 56 | 57 | this.ref = ref; 58 | this.options = options; 59 | this.type = type || ref.toString().replace(/.+\.com\//, '').replace(/\//g, ':'); 60 | if (options.log) { 61 | this.log = require('./services/log')(ref.parent.child(`firebase-search/logs/${this.type}`)); 62 | } else { 63 | this.log = console.log; 64 | } 65 | if (this.options.elasticsearch) { 66 | var elasticsearch = require('elasticsearch'); 67 | this.elasticsearch.client = new elasticsearch.Client(_.clone(this.options.elasticsearch)); 68 | } else { 69 | this.elasticsearch = 'Elasticsearch options undefined'; 70 | } 71 | 72 | if (this.options.algolia) { 73 | var algoliasearch = require('algoliasearch'); 74 | this.algolia.client = algoliasearch(this.options.algolia.applicationID, this.options.algolia.apiKey); 75 | this.algolia.index = this.algolia.client.initIndex(this.type); 76 | } else { 77 | this.algolia = 'Algolia options undefined'; 78 | } 79 | } 80 | 81 | function setPrototype(firebaseSearch) { 82 | function toPromise(fn, args, algoliaWait) { 83 | return new Promise( 84 | function(resolve, reject) { 85 | var argsArray = Array.isArray(args) ? args : [args]; 86 | 87 | if (algoliaWait) { 88 | argsArray = argsArray.concat([ 89 | function(err, content) { 90 | if (err) { 91 | reject(err); 92 | } else { 93 | firebaseSearch.algolia.index.waitTask(content.taskID, function(err) { 94 | err ? reject(err) : resolve(content); 95 | }); 96 | } 97 | } 98 | ]); 99 | } else { 100 | argsArray = argsArray.concat([ 101 | function(err, res) { 102 | err ? reject(err) : resolve(res); 103 | } 104 | ]); 105 | } 106 | 107 | if (typeof argsArray[0] === 'undefined') { 108 | argsArray.splice(0, 1); 109 | } 110 | fn.apply(this, argsArray); 111 | }.bind(this) 112 | ); 113 | } 114 | 115 | function addKeyToSnap(snap) { 116 | var obj = snap.val(); 117 | obj.__id__ = snap.key; 118 | return obj; 119 | } 120 | 121 | firebaseSearch.elasticsearch = { 122 | indices: { 123 | exists: function(params) { 124 | var params = _.defaults(params, { 125 | index: firebaseSearch.options.elasticsearch.index 126 | }); 127 | return toPromise( 128 | firebaseSearch.elasticsearch.client.indices.exists.bind(firebaseSearch.elasticsearch.client.indices), 129 | params 130 | ); 131 | }, 132 | delete: function(params) { 133 | var params = _.defaults(params, { 134 | index: firebaseSearch.options.elasticsearch.index 135 | }); 136 | return toPromise( 137 | firebaseSearch.elasticsearch.client.indices.delete.bind(firebaseSearch.elasticsearch.client.indices), 138 | params 139 | ); 140 | }, 141 | create: function(params) { 142 | var params = _.defaults(params, { 143 | index: firebaseSearch.options.elasticsearch.index 144 | }); 145 | return toPromise( 146 | firebaseSearch.elasticsearch.client.indices.create.bind(firebaseSearch.elasticsearch.client.indices), 147 | params 148 | ); 149 | }, 150 | ensure: function() { 151 | var params = _.defaults(params, { 152 | index: firebaseSearch.options.elasticsearch.index 153 | }); 154 | return firebaseSearch.elasticsearch.indices.exists().then(function(exists) { 155 | return exists || firebaseSearch.elasticsearch.indices.create(params); 156 | }); 157 | } 158 | }, 159 | ping: function() { 160 | return toPromise(firebaseSearch.elasticsearch.client.ping.bind(firebaseSearch.elasticsearch.client), { 161 | hello: 'elasticsearch!' 162 | }); 163 | }, 164 | create: function(params) { 165 | var params = _.defaults(params, { 166 | index: firebaseSearch.options.elasticsearch.index, 167 | type: firebaseSearch.type 168 | }); 169 | return firebaseSearch.elasticsearch 170 | .exists(params) 171 | .then(function(exists) { 172 | return !exists ? true : firebaseSearch.elasticsearch.delete(params); 173 | }) 174 | .then(function() { 175 | return toPromise( 176 | firebaseSearch.elasticsearch.client.create.bind(firebaseSearch.elasticsearch.client), 177 | params 178 | ); 179 | }); 180 | }, 181 | update: function(params) { 182 | var params = _.defaults(params, { 183 | index: firebaseSearch.options.elasticsearch.index, 184 | type: firebaseSearch.type 185 | }); 186 | return toPromise(firebaseSearch.elasticsearch.client.update.bind(firebaseSearch.elasticsearch.client), params); 187 | }, 188 | delete: function(params) { 189 | var params = _.defaults(params, { 190 | index: firebaseSearch.options.elasticsearch.index, 191 | type: firebaseSearch.type 192 | }); 193 | return toPromise(firebaseSearch.elasticsearch.client.delete.bind(firebaseSearch.elasticsearch.client), params); 194 | }, 195 | exists: function(params) { 196 | var params = _.defaults(params, { 197 | index: firebaseSearch.options.elasticsearch.index, 198 | type: firebaseSearch.type 199 | }); 200 | return toPromise(firebaseSearch.elasticsearch.client.exists.bind(firebaseSearch.elasticsearch.client), params); 201 | }, 202 | get: function(params) { 203 | var params = _.defaults(params, { 204 | index: firebaseSearch.options.elasticsearch.index, 205 | type: firebaseSearch.type 206 | }); 207 | return toPromise(firebaseSearch.elasticsearch.client.get.bind(firebaseSearch.elasticsearch.client), params); 208 | }, 209 | search: function(params) { 210 | var params = _.defaults(params, { 211 | index: firebaseSearch.options.elasticsearch.index, 212 | type: firebaseSearch.type 213 | }); 214 | return toPromise(firebaseSearch.elasticsearch.client.search.bind(firebaseSearch.elasticsearch.client), params); 215 | }, 216 | firebase: { 217 | // Firebase index management 218 | build: function(returnPromise) { 219 | return firebaseSearch.getLastKey().then(function(lastKey) { 220 | return new Promise(function(resolve, reject) { 221 | var ref = firebaseSearch.ref.orderByKey(); 222 | var promises = []; 223 | var finish = false; 224 | var successful = 0; 225 | var failed = 0; 226 | var total = 0; 227 | var handler = function(snap) { 228 | var promise = firebaseSearch.elasticsearch 229 | .create({ 230 | id: snap.key, 231 | body: snap.val() 232 | }) 233 | .then(function(res) { 234 | successful += res._shards.successful; 235 | failed += res._shards.failed; 236 | total += 1; 237 | return res; 238 | }); 239 | 240 | if (returnPromise) { 241 | promises.push(promise); 242 | } 243 | 244 | if (snap.key === lastKey) { 245 | ref.off('child_added', handler); 246 | finish = _.debounce( 247 | function() { 248 | resolve({ 249 | successful: successful, 250 | failed: failed, 251 | total: total 252 | }); 253 | }, 254 | 250 255 | ); 256 | } 257 | 258 | if (finish) { 259 | promise.then(function() { 260 | if (returnPromise) { 261 | resolve(Promise.all(promises)); 262 | } else { 263 | finish(); 264 | } 265 | }); 266 | } 267 | }; 268 | ref.on('child_added', handler); 269 | }); 270 | }); 271 | }, 272 | start: function(config) { 273 | // set default value for config to an empty object if not provided 274 | config = typeof config !== 'undefined' ? config : {}; 275 | 276 | return new Promise(function(resolve, reject) { 277 | var ref = firebaseSearch.ref; 278 | var started; 279 | firebaseSearch.elasticsearch.handlers = { 280 | child_added: function(snap) { 281 | if (!started) { 282 | // Skip the first child_added event. It's often an existing record. 283 | started = true; 284 | resolve(snap.key); 285 | } else { 286 | firebaseSearch.fire('elasticsearch_child_added', addKeyToSnap(snap)); 287 | // firebaseSearch.log('elasticsearch_child_added', snap.key); 288 | firebaseSearch.elasticsearch.create( 289 | _.merge({}, config.added, { 290 | id: snap.key, 291 | body: snap.val() 292 | }) 293 | ); 294 | } 295 | }, 296 | child_changed: function(snap) { 297 | firebaseSearch.fire('elasticsearch_child_changed', addKeyToSnap(snap)); 298 | // firebaseSearch.log('elasticsearch_child_changed', snap.key); 299 | firebaseSearch.elasticsearch.update( 300 | _.merge({}, config.changed, { 301 | id: snap.key, 302 | body: { 303 | doc: snap.val() 304 | } 305 | }) 306 | ); 307 | }, 308 | child_removed: function(snap) { 309 | firebaseSearch.fire('elasticsearch_child_removed', addKeyToSnap(snap)); 310 | // firebaseSearch.log('elasticsearch_child_removed', snap.key); 311 | firebaseSearch.elasticsearch.delete( 312 | _.merge({}, config.deleted, { 313 | id: snap.key 314 | }) 315 | ); 316 | } 317 | }; 318 | firebaseSearch.elasticsearch.listeningRefs = { 319 | child_added: ref.orderByKey().limitToLast(1), 320 | child_changed: ref, 321 | child_removed: ref 322 | }; 323 | firebaseSearch.elasticsearch.listeningRefs.child_added.once('value').then(function(snap) { 324 | if (!snap.numChildren()) (started = true), resolve(true); 325 | firebaseSearch.elasticsearch.listeningRefs.child_added.on( 326 | 'child_added', 327 | firebaseSearch.elasticsearch.handlers.child_added 328 | ); 329 | firebaseSearch.elasticsearch.listeningRefs.child_changed.on( 330 | 'child_changed', 331 | firebaseSearch.elasticsearch.handlers.child_changed 332 | ); 333 | firebaseSearch.elasticsearch.listeningRefs.child_removed.on( 334 | 'child_removed', 335 | firebaseSearch.elasticsearch.handlers.child_removed 336 | ); 337 | }); 338 | }); 339 | }, 340 | stop: function() { 341 | if (firebaseSearch.elasticsearch.handlers && firebaseSearch.elasticsearch.listeningRefs) { 342 | firebaseSearch.elasticsearch.listeningRefs.child_added.off( 343 | 'child_added', 344 | firebaseSearch.elasticsearch.handlers.child_added 345 | ); 346 | firebaseSearch.elasticsearch.listeningRefs.child_changed.off( 347 | 'child_changed', 348 | firebaseSearch.elasticsearch.handlers.child_changed 349 | ); 350 | firebaseSearch.elasticsearch.listeningRefs.child_removed.off( 351 | 'child_removed', 352 | firebaseSearch.elasticsearch.handlers.child_removed 353 | ); 354 | } else { 355 | firebaseSearch.log('Firebase elasticsearch listeners not started.'); 356 | } 357 | } 358 | } 359 | }; 360 | 361 | firebaseSearch.algolia = { 362 | search: function(query, options) { 363 | return toPromise(firebaseSearch.algolia.index.search.bind(firebaseSearch.algolia.index), [query, options]); 364 | }, 365 | addObject: function(args, shouldWait) { 366 | return toPromise(firebaseSearch.algolia.index.addObject.bind(firebaseSearch.algolia.index), args, shouldWait); 367 | }, 368 | saveObject: function(args, shouldWait) { 369 | return toPromise(firebaseSearch.algolia.index.saveObject.bind(firebaseSearch.algolia.index), args, shouldWait); 370 | }, 371 | deleteObject: function(args, shouldWait) { 372 | return toPromise(firebaseSearch.algolia.index.deleteObject.bind(firebaseSearch.algolia.index), args, shouldWait); 373 | }, 374 | setSettings: function(args) { 375 | return toPromise(firebaseSearch.algolia.index.setSettings.bind(firebaseSearch.algolia.index), args); 376 | }, 377 | listIndexes: function() { 378 | return toPromise(firebaseSearch.algolia.client.listIndexes.bind(firebaseSearch.algolia.client)); 379 | }, 380 | clearIndex: function(shouldWait) { 381 | return toPromise( 382 | firebaseSearch.algolia.index.clearIndex.bind(firebaseSearch.algolia.index), 383 | undefined, 384 | shouldWait 385 | ); 386 | }, 387 | waitTask: function(args) { 388 | return toPromise(firebaseSearch.algolia.index.waitTask.bind(firebaseSearch.algolia.index), args); 389 | }, 390 | exists: function(name) { 391 | var name = name || firebaseSearch.type; 392 | return firebaseSearch.algolia.listIndexes().then(function(indexes) { 393 | return !!~_.map(indexes.items, 'name').indexOf(name); 394 | }); 395 | }, 396 | firebase: { 397 | build: function() { 398 | return firebaseSearch.getLastKey().then(function(lastKey) { 399 | var queue = new Queue(); 400 | var successful = 0; 401 | var failed = 0; 402 | var total = 0; 403 | 404 | return new Promise(function(resolve, reject) { 405 | var ref = firebaseSearch.ref.orderByKey(); 406 | const handler = ref.on('child_added', function handleChildAdded(snap) { 407 | var obj = snap.val(); 408 | obj.objectID = snap.key; 409 | 410 | total++; 411 | queue.push(function(done) { 412 | firebaseSearch.algolia.addObject(obj, true).then(() => successful++).catch(() => failed++).then(done); 413 | }); 414 | if (snap.key == lastKey) { 415 | queue.start(function(err) { 416 | resolve({ 417 | successful: successful, 418 | failed: failed, 419 | total: total 420 | }); 421 | }); 422 | } 423 | }); 424 | }); 425 | }); 426 | }, 427 | start: function() { 428 | return new Promise(function(resolve, reject) { 429 | var ref = firebaseSearch.ref; 430 | var started; 431 | var startingKeys = []; 432 | firebaseSearch.algolia.handlers = { 433 | child_added: function(snap) { 434 | if (!started) { 435 | // Skip the first child_added event. It's often an existing record. 436 | const index = startingKeys.indexOf(snap.key); 437 | startingKeys.splice(index, 1); 438 | if (!startingKeys.length) { 439 | started = true; 440 | resolve(snap.key); 441 | } 442 | } else { 443 | var obj = snap.val(); 444 | obj.objectID = snap.key; 445 | firebaseSearch.algolia.addObject(obj, true).then(function() { 446 | // firebaseSearch.log('algolia_child_added', snap.key); 447 | firebaseSearch.fire('algolia_child_added', obj); 448 | }); 449 | } 450 | }, 451 | child_changed: function(snap) { 452 | var obj = snap.val(); 453 | obj.objectID = snap.key; 454 | firebaseSearch.algolia.saveObject(obj, true).then(function() { 455 | // firebaseSearch.log('algolia_child_changed', snap.key); 456 | firebaseSearch.fire('algolia_child_changed', obj); 457 | }); 458 | }, 459 | child_removed: function(snap) { 460 | firebaseSearch.algolia.deleteObject(snap.key, true).then(function() { 461 | // firebaseSearch.log('algolia_child_removed', snap.key); 462 | firebaseSearch.fire('algolia_child_removed', snap.key); 463 | }); 464 | } 465 | }; 466 | firebaseSearch.algolia.listeningRefs = { 467 | child_added: ref.orderByKey(), 468 | child_changed: ref, 469 | child_removed: ref 470 | }; 471 | 472 | firebaseSearch.algolia.listeningRefs.child_added.once('value').then(function(snap) { 473 | if (!snap.numChildren()) { 474 | started = true; 475 | resolve(true); 476 | } else { 477 | startingKeys = Object.keys(snap.val()); 478 | } 479 | firebaseSearch.algolia.listeningRefs.child_added.on( 480 | 'child_added', 481 | firebaseSearch.algolia.handlers.child_added 482 | ); 483 | firebaseSearch.algolia.listeningRefs.child_changed.on( 484 | 'child_changed', 485 | firebaseSearch.algolia.handlers.child_changed 486 | ); 487 | firebaseSearch.algolia.listeningRefs.child_removed.on( 488 | 'child_removed', 489 | firebaseSearch.algolia.handlers.child_removed 490 | ); 491 | }); 492 | }); 493 | }, 494 | stop: function() { 495 | if (firebaseSearch.algolia.handlers && firebaseSearch.algolia.listeningRefs) { 496 | firebaseSearch.algolia.listeningRefs.child_added.off( 497 | 'child_added', 498 | firebaseSearch.algolia.handlers.child_added 499 | ); 500 | firebaseSearch.algolia.listeningRefs.child_changed.off( 501 | 'child_changed', 502 | firebaseSearch.algolia.handlers.child_changed 503 | ); 504 | firebaseSearch.algolia.listeningRefs.child_removed.off( 505 | 'child_removed', 506 | firebaseSearch.algolia.handlers.child_removed 507 | ); 508 | } else { 509 | firebaseSearch.log('Firebase algolia listeners not started.'); 510 | } 511 | } 512 | } 513 | }; 514 | } 515 | -------------------------------------------------------------------------------- /firebase-search.spec.js: -------------------------------------------------------------------------------- 1 | const admin = require('firebase-admin'); 2 | const env = require('./services/environment'); 3 | var credential = admin.credential.cert(env.firebaseConfig.serviceAccount); 4 | 5 | admin.initializeApp({ 6 | databaseURL: env.firebaseConfig.databaseURL, 7 | credential 8 | }); 9 | const ref = admin.database().ref('firebase-search/test/algolia'); 10 | const FirebaseSearch = require('./firebase-search'); 11 | 12 | describe('Firebase Search', () => { 13 | let search; 14 | beforeEach(() => { 15 | search = new FirebaseSearch(ref, env); 16 | }); 17 | 18 | beforeEach(done => 19 | clean().then(() => { 20 | search.algolia.search('test').then(res => { 21 | expect(res.nbHits).toEqual(0); 22 | done(); 23 | }); 24 | })); 25 | 26 | afterAll(done => clean().then(done)); 27 | 28 | function clean() { 29 | return ref.remove().then(() => search.algolia.clearIndex(true)); 30 | } 31 | 32 | describe('Algolia', () => { 33 | afterEach(done => { 34 | search.removeAllListeners(); 35 | search.algolia.firebase.stop(); 36 | done(); 37 | }); 38 | 39 | it( 40 | 'should build', 41 | done => { 42 | const fakeEntries = createFakeEntries(10); 43 | const updates = getUpdates(fakeEntries); 44 | 45 | ref 46 | .update(updates) 47 | .then(() => search.algolia.firebase.build()) 48 | .then(() => search.algolia.search('test')) 49 | .then(res => { 50 | expect(res.nbHits).toEqual(10); 51 | done(); 52 | }) 53 | .catch(done); 54 | }, 55 | 60000 56 | ); 57 | 58 | it( 59 | 'should sync', 60 | done => { 61 | const fakeEntries = createFakeEntries(10); 62 | const initialEntries = fakeEntries.slice(0, 4); 63 | const secondaryEntries = fakeEntries.slice(5); 64 | const initialUpdates = getUpdates(initialEntries); 65 | const secondaryUpdates = getUpdates(secondaryEntries); 66 | 67 | let counter = 0; 68 | let recordsAdded = []; 69 | search.on('algolia_child_added', function handleAlgoliaChildAdded(record) { 70 | counter++; 71 | recordsAdded.push(record.postId); 72 | if (counter == 5) { 73 | expect(recordsAdded.sort().join()).toEqual('test-0,test-1,test-2,test-3,test-4'); 74 | done(); 75 | } 76 | }); 77 | 78 | ref.update(initialUpdates).then(() => search.algolia.firebase.start()).then(() => ref.update(secondaryUpdates)); 79 | }, 80 | 60000 81 | ); 82 | 83 | function getUpdates(entries) { 84 | return entries.reduce( 85 | (updates, entry) => { 86 | updates[entry.postId] = entry; 87 | return updates; 88 | }, 89 | {} 90 | ); 91 | } 92 | 93 | function createFakeEntries(n = 5) { 94 | var i = n; 95 | var fakeEntries = []; 96 | while (i--) { 97 | fakeEntries.push({ 98 | postId: `test-${i}`, 99 | userComment: `#fake #${i}`, 100 | userCommentParts: ['#fake', `#${i}`] 101 | }); 102 | } 103 | return fakeEntries; 104 | } 105 | }); 106 | }); 107 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "quiver-firebase-search", 3 | "version": "1.0.6", 4 | "description": "Index your Firebase collections to Elasticsearch and/or Algolia", 5 | "main": "firebase-search.js", 6 | "scripts": { 7 | "tunnel": "node tunnel.js", 8 | "test": "node test-elasticsearch.js && node test-algolia.js", 9 | "jasmine": "jasmineal", 10 | "deploy-database": "firebase deploy --only database", 11 | "deploy-hosting": "firebase deploy --only hosting", 12 | "deploy": "firebase deploy" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/deltaepsilon/firebase-search.git" 17 | }, 18 | "keywords": [ 19 | "firebase", 20 | "elasticsearch", 21 | "algolia", 22 | "search" 23 | ], 24 | "author": "Chris Esplin ", 25 | "license": "MIT", 26 | "bugs": { 27 | "url": "https://github.com/deltaepsilon/firebase-search/issues" 28 | }, 29 | "homepage": "https://github.com/deltaepsilon/firebase-search", 30 | "devDependencies": { 31 | "axios": "^0.13.1", 32 | "firebase-admin": "^4.2.0", 33 | "jasmine": "^2.5.3", 34 | "json-format": "^0.1.2", 35 | "tape": "^4.6.0", 36 | "tape-catch": "^1.0.6" 37 | }, 38 | "dependencies": { 39 | "algoliasearch": "^3.22.1", 40 | "elasticsearch": "^11.0.1", 41 | "firebase": "^3.2.1", 42 | "lodash": "^4.14.1", 43 | "queue": "^4.2.1" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /services/environment.js: -------------------------------------------------------------------------------- 1 | var env = require('../env.json'); 2 | var environment = process.env.NODE_ENV; 3 | var _ = require('lodash'); 4 | var result = { 5 | environment: environment, 6 | development: _.defaultsDeep(env.development, env.defaults), 7 | test: _.defaultsDeep(env.test, env.defaults), 8 | production: _.defaultsDeep(env.production, env.defaults) 9 | }; 10 | 11 | module.exports = _.defaultsDeep(result, result[environment]); -------------------------------------------------------------------------------- /services/log.js: -------------------------------------------------------------------------------- 1 | var env = require('./environment.js'); 2 | var log = env.log; 3 | var _ = require('lodash'); 4 | 5 | module.exports = function (logRef) { 6 | if (env.log === 'console') { 7 | return console.log.bind(console); 8 | } else if (env.log === 'firebase') { 9 | return function () { 10 | logRef.push({ 11 | log: _.toArray(arguments).join(""), 12 | time: (new Date()).toString() 13 | }); 14 | }; 15 | } 16 | }; -------------------------------------------------------------------------------- /spec/support/jasmine.json: -------------------------------------------------------------------------------- 1 | { 2 | "spec_dir": "", 3 | "spec_files": [ 4 | "firebase-search.spec.js" 5 | ], 6 | "helpers": [ 7 | "helpers/**/*.js" 8 | ], 9 | "stopSpecOnExpectationFailure": false, 10 | "random": false 11 | } 12 | -------------------------------------------------------------------------------- /test-algolia.js: -------------------------------------------------------------------------------- 1 | require('./tests/algolia-test')(); -------------------------------------------------------------------------------- /test-elasticsearch.js: -------------------------------------------------------------------------------- 1 | require('./tests/elasticsearch-test')(); -------------------------------------------------------------------------------- /test-package.js: -------------------------------------------------------------------------------- 1 | require('./tests/package-test')(); -------------------------------------------------------------------------------- /tests/algolia-test.js: -------------------------------------------------------------------------------- 1 | var axios = require('axios'); 2 | var test = require('tape-catch'); 3 | var _ = require('lodash'); 4 | var bootstrap = require('./bootstrap'); 5 | 6 | var firebase = bootstrap.firebase; 7 | var env = bootstrap.env; 8 | var search = bootstrap.search; 9 | 10 | var ref = bootstrap.ref; 11 | var usersRef = bootstrap.usersRef; 12 | var log = bootstrap.log; 13 | var wait = bootstrap.wait; 14 | 15 | module.exports = function () { 16 | return usersRef.remove() 17 | .then(function () { 18 | return search.algolia.listIndexes(); 19 | }) 20 | .then(function (indices) { 21 | var names = _.map(indices, 'name'); 22 | return ~names.indexOf(search.options.type) ? search.algolia.clearIndex() : true; 23 | }) 24 | .then(function () { 25 | return search.algolia.setSettings({ 26 | attributesToIndex: [ 27 | 'name', 28 | 'timestamp' 29 | ] 30 | }); 31 | }) 32 | .then(function () { 33 | return search.algolia.firebase.start(); 34 | }) 35 | .then(function (firstKey) { 36 | if (firstKey !== true) throw new Error('firstKey should be true'); 37 | return true; 38 | }) 39 | .then(function () { 40 | return search.algolia.clearIndex(); 41 | }) 42 | .then(function () { 43 | return bootstrap.initialize(); 44 | }) 45 | .then(function () { 46 | return new Promise(function (resolve, reject) { 47 | test('Index should be empty before build', function (t) { 48 | search.algolia.listIndexes() 49 | .then(function (indices) { 50 | var index = _.find(indices.items, function (index) { 51 | return index.name === search.type; 52 | }); 53 | t.equal(index.name, search.type); 54 | t.equal(index.entries, 0); 55 | t.end(); 56 | }); 57 | }); 58 | 59 | test('Should build all records', function (t) { 60 | usersRef.once('value') 61 | .then(function (snap) { 62 | return snap.numChildren(); 63 | }) 64 | .then(function (numChildren) { 65 | search.algolia.firebase.build(true) 66 | .then(function (res) { 67 | t.equal(res.successful, numChildren); 68 | t.end(); 69 | }); 70 | }); 71 | }); 72 | 73 | test('Should track additions and changes', function (t) { 74 | return search.algolia.firebase.start() 75 | .then(function (firstKey) { 76 | var timestamp = (new Date()).toString(); 77 | var firstRef = usersRef.child(firstKey); 78 | var newUserRef = usersRef.child('newUser'); 79 | var hits; 80 | 81 | var promises = [ 82 | new Promise(function (resolve, reject) { 83 | search.once('algolia_child_added', resolve); 84 | }), 85 | new Promise(function (resolve, reject) { 86 | search.once('algolia_child_changed', resolve); 87 | }) 88 | ]; 89 | newUserRef.remove() 90 | .then(function () { 91 | return usersRef.child(firstKey).child('timestamp').remove(); 92 | }) 93 | .then(function () { 94 | var payload = { 95 | 'newUser': { 96 | name: 'Chris', 97 | timestamp: timestamp 98 | } 99 | }; 100 | payload[firstKey + '/timestamp'] = timestamp; 101 | return usersRef.update(payload); 102 | }) 103 | .then(function () { 104 | return Promise.all(promises); 105 | }) 106 | .then(function () { 107 | return search.algolia.search(timestamp); 108 | }) 109 | .then(function (res) { 110 | t.equal(res.hits.length, 2); 111 | return newUserRef.remove(); 112 | }) 113 | .then(function () { 114 | t.end(); 115 | }); 116 | }); 117 | }); 118 | 119 | test('Should end empty', function (t) { 120 | search.algolia.clearIndex(true) 121 | .then(function () { 122 | return search.algolia.listIndexes(); 123 | }) 124 | .then(function (indices) { 125 | var index = _.find(indices.items, function (index) { 126 | return index.name === search.type; 127 | }); 128 | t.equal(index.name, search.type); 129 | t.equal(index.entries, 0); 130 | t.end(); 131 | resolve(); 132 | }); 133 | }); 134 | }); 135 | }) 136 | .then(function () { 137 | console.log('algolia tests complete'); 138 | process.exit(); 139 | return true; 140 | }) 141 | .catch(function (err) { 142 | console.log('algolia-test.js error', err); 143 | }); 144 | }; 145 | -------------------------------------------------------------------------------- /tests/bootstrap.js: -------------------------------------------------------------------------------- 1 | process.env.NODE_ENV = 'test'; // Set env to test 2 | var firebase = require('firebase'); 3 | var env = require('../services/environment'); 4 | var axios = require('axios'); 5 | var test = require('tape-catch'); 6 | var _ = require('lodash'); 7 | var FirebaseSearch = require('../firebase-search.js'); 8 | 9 | firebase.initializeApp(env.firebaseConfig); 10 | 11 | var ref = firebase.database().ref('firebase-search/' + env.environment); 12 | var usersRef = ref.child('users'); 13 | var log = require('../services/log')(ref.child('logs')); 14 | var wait = function (time) { 15 | return function (arg) { 16 | var that = this; 17 | return new Promise(function (resolve, reject) { 18 | setImmediate(function () { 19 | setTimeout(function () { 20 | resolve.call(that, arg); 21 | }, time); 22 | }); 23 | }); 24 | } 25 | }; 26 | 27 | 28 | module.exports = { 29 | firebase: firebase, 30 | env: env, 31 | ref: ref, 32 | usersRef: usersRef, 33 | log: log, 34 | search: new FirebaseSearch(usersRef, env), 35 | wait: wait, 36 | initialize: function () { 37 | return ref.once('value') 38 | .then(function (snap) { 39 | if (snap.val()) { 40 | return true; 41 | } else { 42 | return usersRef.remove() 43 | .then(function () { 44 | var promises = []; 45 | var i = 10; 46 | while (i--) { 47 | promises.push(axios.get(`http://swapi.co/api/people/${i + 1}/`) 48 | .then(function (res) { 49 | return usersRef.push(res.data); 50 | })); 51 | } 52 | return Promise.all(promises); 53 | }); 54 | } 55 | }); 56 | } 57 | }; 58 | -------------------------------------------------------------------------------- /tests/elasticsearch-test.js: -------------------------------------------------------------------------------- 1 | var axios = require('axios'); 2 | var test = require('tape-catch'); 3 | var _ = require('lodash'); 4 | var bootstrap = require('./bootstrap'); 5 | 6 | var firebase = bootstrap.firebase; 7 | var env = bootstrap.env; 8 | var search = bootstrap.search; 9 | 10 | var ref = bootstrap.ref; 11 | var usersRef = bootstrap.usersRef; 12 | var log = bootstrap.log; 13 | var wait = bootstrap.wait; 14 | module.exports = function () { 15 | return usersRef.remove() 16 | .then(function () { 17 | return usersRef.child('newUser').remove(); 18 | }) 19 | .then(function () { 20 | return search.elasticsearch.indices.exists() 21 | .then(function (exists) { 22 | return exists ? search.elasticsearch.indices.delete() : true; 23 | }); 24 | }) 25 | .then(function () { 26 | return search.elasticsearch.indices.ensure(); 27 | }) 28 | .then(function () { 29 | return search.elasticsearch.firebase.start(); 30 | }) 31 | .then(function (firstKey) { 32 | if (firstKey !== true) throw new Error('firstKey should be true'); 33 | return true; 34 | }) 35 | .then(function () { 36 | return bootstrap.initialize(); 37 | }) 38 | .then(function () { 39 | return new Promise(function (resolve, reject) { 40 | test('Index should be empty before build', function (t) { 41 | search.elasticsearch.search({ 42 | body: { 43 | query: { 44 | match_all: {} 45 | } 46 | } 47 | }) 48 | .then(function (res) { 49 | t.equal(res.hits.hits.length, 0); 50 | t.end(); 51 | resolve(); 52 | }); 53 | }); 54 | 55 | }); 56 | }) 57 | .then(function () { 58 | return new Promise(function (resolve, reject) { 59 | test('ping should succeed', function (t) { 60 | search.elasticsearch.ping() 61 | .then(function (res) { 62 | t.equal(res, true); 63 | t.end(); 64 | resolve(); 65 | }); 66 | }); 67 | }); 68 | }) 69 | .then(function () { 70 | return new Promise(function (resolve, reject) { 71 | test('Index should be built', function (t) { 72 | search.elasticsearch.firebase.build() 73 | .then(wait(1000)) 74 | .then(function () { 75 | return Promise.all([ 76 | search.ref.once('value'), 77 | search.elasticsearch.search({ 78 | body: { 79 | query: { 80 | match_all: {} 81 | } 82 | } 83 | }) 84 | ]) 85 | }) 86 | .then(function (values) { 87 | var existing = values[0].numChildren(); 88 | var hits = values[1].hits.hits; 89 | t.equal(hits.length, existing); 90 | t.end(); 91 | resolve(); 92 | }); 93 | 94 | }); 95 | }); 96 | }) 97 | .then(function () { 98 | return new Promise(function (resolve, reject) { 99 | test('should find Luke Skywalker', function (t) { 100 | search.elasticsearch.search({ 101 | q: 'name:Luke' 102 | }) 103 | .then(function (res) { 104 | if (!res.hits.hits[0]) { 105 | return console.log('no hits found... rebuild data.'); 106 | } 107 | var firstHit = res.hits.hits[0]._source; 108 | t.equal(firstHit.name, 'Luke Skywalker'); 109 | t.end(); 110 | resolve(); 111 | }); 112 | }); 113 | }); 114 | }) 115 | .then(function () { 116 | return new Promise(function (resolve, reject) { 117 | test('Should track additions and changes', function (t) { 118 | return search.elasticsearch.firebase.start() 119 | .then(function (firstKey) { 120 | var timestamp = (new Date()).toString(); 121 | var firstRef = usersRef.child(firstKey); 122 | var newUserRef = usersRef.child('newUser'); 123 | var hits; 124 | 125 | var promises = [ 126 | new Promise(function (resolve, reject) { 127 | search.once('elasticsearch_child_added', resolve); 128 | }), 129 | new Promise(function (resolve, reject) { 130 | search.once('elasticsearch_child_changed', resolve); 131 | }) 132 | ]; 133 | newUserRef.remove() 134 | .then(function () { 135 | return usersRef.child(firstKey).child('timestamp').remove(); 136 | }) 137 | .then(function () { 138 | var payload = { 139 | 'newUser': { 140 | name: 'Chris', 141 | timestamp: timestamp 142 | } 143 | }; 144 | payload[firstKey + '/timestamp'] = timestamp; 145 | return usersRef.update(payload); 146 | }) 147 | .then(function () { 148 | return Promise.all(promises); 149 | }) 150 | .then(wait(1000)) 151 | .then(function () { 152 | return search.elasticsearch.search({ 153 | body: { 154 | query: { 155 | match: { 156 | timestamp: timestamp 157 | } 158 | } 159 | } 160 | }); 161 | }) 162 | .then(function (res) { 163 | t.equal(res.hits.hits.length, 2); 164 | t.end(); 165 | resolve(); 166 | }); 167 | }); 168 | }); 169 | }); 170 | }) 171 | .then(function () { 172 | return new Promise(function (resolve, reject) { 173 | test('Should track removals', function (t) { 174 | usersRef.child('newUser').remove() 175 | .then(wait(50)) 176 | .then(function () { 177 | return Promise.all([ 178 | search.elasticsearch.search({ 179 | body: { 180 | query: { 181 | match_all: {} 182 | } 183 | } 184 | }), 185 | usersRef.once('value') 186 | ]); 187 | }) 188 | .then(function (values) { 189 | var hits = values[0].hits.hits; 190 | var snap = values[1]; 191 | t.equal(hits.length, snap.numChildren()); 192 | t.end(); 193 | resolve(); 194 | }); 195 | }); 196 | }); 197 | }) 198 | .then(function () { 199 | return new Promise(function (resolve, reject) { 200 | test('is this thing on?', function (t) { 201 | t.skip(); 202 | t.end(); 203 | resolve(); 204 | }); 205 | }); 206 | }) 207 | .then(function (res) { 208 | log('Elasticsearch tests complete '); 209 | process.exit(); 210 | return true; 211 | }) 212 | .catch(function (err) { 213 | log('error', err); 214 | }); 215 | }; 216 | -------------------------------------------------------------------------------- /tests/package-test.js: -------------------------------------------------------------------------------- 1 | var firebase = require('firebase'); 2 | var env = require('../services/environment'); 3 | var test = require('tape-catch'); 4 | var FirebaseSearch = require('../firebase-search.js'); 5 | 6 | firebase.initializeApp(env.firebaseConfig); 7 | 8 | var ref = firebase.database().ref(); 9 | var searchOne = new FirebaseSearch(ref.child('searchOne'), env, 'searchOne'); 10 | var searchTwo = new FirebaseSearch(ref.child('searchOne'), env, 'searchTwo'); 11 | var searchThree = new FirebaseSearch(ref.child('searchThree'), env, 'searchThree'); 12 | console.log(searchOne.type, searchTwo.type, searchThree.type); 13 | 14 | module.exports = function () { 15 | searchOne.algolia.firebase.start(); 16 | searchTwo.algolia.firebase.start(); 17 | searchThree.algolia.firebase.start(); 18 | 19 | console.log(searchOne.type, searchTwo.type, searchThree.type); 20 | }; 21 | 22 | -------------------------------------------------------------------------------- /tunnel.js: -------------------------------------------------------------------------------- 1 | var env = require('./services/environment.js'); 2 | var spawn = require('child_process').spawn; 3 | var options = [ 4 | '--ssh-flag', 5 | '-L9200:localhost:9200', 6 | '--project', 7 | env.elasticsearch.project, 8 | '--zone', 9 | env.elasticsearch.zone, 10 | env.elasticsearch.vm 11 | ]; 12 | 13 | var command = `gcloud compute ssh --ssh-flag=-L9200:localhost:9200 --project=${env.elasticsearch.project} --zone=${env.elasticsearch.zone} ${env.elasticsearch.vm}`; 14 | 15 | console.log("Run the following command to establish a local tunnel:\n\n", command, "\n\n"); 16 | 17 | process.exit(); -------------------------------------------------------------------------------- /yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | "@types/jsonwebtoken@^7.1.33": 6 | version "7.2.0" 7 | resolved "https://registry.yarnpkg.com/@types/jsonwebtoken/-/jsonwebtoken-7.2.0.tgz#0fed32c8501da80ac9839d2d403a65c83d776ffd" 8 | dependencies: 9 | "@types/node" "*" 10 | 11 | "@types/node@*": 12 | version "7.0.12" 13 | resolved "https://registry.yarnpkg.com/@types/node/-/node-7.0.12.tgz#ae5f67a19c15f752148004db07cbbb372e69efc9" 14 | 15 | agentkeepalive@^2.1.1: 16 | version "2.2.0" 17 | resolved "https://registry.yarnpkg.com/agentkeepalive/-/agentkeepalive-2.2.0.tgz#c5d1bd4b129008f1163f236f86e5faea2026e2ef" 18 | 19 | algoliasearch@^3.22.1: 20 | version "3.22.1" 21 | resolved "https://registry.yarnpkg.com/algoliasearch/-/algoliasearch-3.22.1.tgz#132abb11134c52a3fd3ded3fcf6acb64f5eb738d" 22 | dependencies: 23 | agentkeepalive "^2.1.1" 24 | debug "2.3.3" 25 | envify "^4.0.0" 26 | es6-promise "^4.0.5" 27 | events "^1.1.0" 28 | foreach "^2.0.5" 29 | global "^4.3.0" 30 | inherits "^2.0.1" 31 | isarray "^2.0.1" 32 | load-script "^1.0.0" 33 | object-keys "^1.0.11" 34 | querystring-es3 "^0.2.1" 35 | reduce "^1.0.1" 36 | semver "^5.1.0" 37 | tunnel-agent "^0.4.3" 38 | 39 | ansi-regex@^2.0.0: 40 | version "2.1.1" 41 | resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-2.1.1.tgz#c3b33ab5ee360d86e0e628f0468ae7ef27d654df" 42 | 43 | ansi-styles@^2.2.1: 44 | version "2.2.1" 45 | resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe" 46 | 47 | asap@~2.0.3: 48 | version "2.0.5" 49 | resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.5.tgz#522765b50c3510490e52d7dcfe085ef9ba96958f" 50 | 51 | axios@^0.13.1: 52 | version "0.13.1" 53 | resolved "https://registry.yarnpkg.com/axios/-/axios-0.13.1.tgz#3e67abfe4333bc9d2d5fe6fbd13b4694eafc8df8" 54 | dependencies: 55 | follow-redirects "0.0.7" 56 | 57 | balanced-match@^0.4.1: 58 | version "0.4.2" 59 | resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-0.4.2.tgz#cb3f3e3c732dc0f01ee70b403f302e61d7709838" 60 | 61 | base64url@2.0.0, base64url@^2.0.0: 62 | version "2.0.0" 63 | resolved "https://registry.yarnpkg.com/base64url/-/base64url-2.0.0.tgz#eac16e03ea1438eff9423d69baa36262ed1f70bb" 64 | 65 | brace-expansion@^1.0.0: 66 | version "1.1.7" 67 | resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.7.tgz#3effc3c50e000531fb720eaff80f0ae8ef23cf59" 68 | dependencies: 69 | balanced-match "^0.4.1" 70 | concat-map "0.0.1" 71 | 72 | buffer-equal-constant-time@1.0.1: 73 | version "1.0.1" 74 | resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819" 75 | 76 | chalk@^1.0.0: 77 | version "1.1.3" 78 | resolved "https://registry.yarnpkg.com/chalk/-/chalk-1.1.3.tgz#a8115c55e4a702fe4d150abd3872822a7e09fc98" 79 | dependencies: 80 | ansi-styles "^2.2.1" 81 | escape-string-regexp "^1.0.2" 82 | has-ansi "^2.0.0" 83 | strip-ansi "^3.0.0" 84 | supports-color "^2.0.0" 85 | 86 | concat-map@0.0.1: 87 | version "0.0.1" 88 | resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" 89 | 90 | debug@2.3.3, debug@^2.2.0: 91 | version "2.3.3" 92 | resolved "https://registry.yarnpkg.com/debug/-/debug-2.3.3.tgz#40c453e67e6e13c901ddec317af8986cda9eff8c" 93 | dependencies: 94 | ms "0.7.2" 95 | 96 | deep-equal@~1.0.1: 97 | version "1.0.1" 98 | resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.0.1.tgz#f5d260292b660e084eff4cdbc9f08ad3247448b5" 99 | 100 | define-properties@^1.1.2: 101 | version "1.1.2" 102 | resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.1.2.tgz#83a73f2fea569898fb737193c8f873caf6d45c94" 103 | dependencies: 104 | foreach "^2.0.5" 105 | object-keys "^1.0.8" 106 | 107 | defined@~1.0.0: 108 | version "1.0.0" 109 | resolved "https://registry.yarnpkg.com/defined/-/defined-1.0.0.tgz#c98d9bcef75674188e110969151199e39b1fa693" 110 | 111 | dom-storage@2.0.2: 112 | version "2.0.2" 113 | resolved "https://registry.yarnpkg.com/dom-storage/-/dom-storage-2.0.2.tgz#ed17cbf68abd10e0aef8182713e297c5e4b500b0" 114 | 115 | dom-walk@^0.1.0: 116 | version "0.1.1" 117 | resolved "https://registry.yarnpkg.com/dom-walk/-/dom-walk-0.1.1.tgz#672226dc74c8f799ad35307df936aba11acd6018" 118 | 119 | ecdsa-sig-formatter@1.0.9: 120 | version "1.0.9" 121 | resolved "https://registry.yarnpkg.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.9.tgz#4bc926274ec3b5abb5016e7e1d60921ac262b2a1" 122 | dependencies: 123 | base64url "^2.0.0" 124 | safe-buffer "^5.0.1" 125 | 126 | elasticsearch@^11.0.1: 127 | version "11.0.1" 128 | resolved "https://registry.yarnpkg.com/elasticsearch/-/elasticsearch-11.0.1.tgz#d180684c6bde7ecfa0fa24e62fa1c872eeae08e7" 129 | dependencies: 130 | chalk "^1.0.0" 131 | forever-agent "^0.6.0" 132 | lodash "^3.10.0" 133 | lodash-compat "^3.0.0" 134 | promise "^7.1.1" 135 | 136 | envify@^4.0.0: 137 | version "4.0.0" 138 | resolved "https://registry.yarnpkg.com/envify/-/envify-4.0.0.tgz#f791343e3d11cc29cce41150300a8af61c66cab0" 139 | dependencies: 140 | esprima "~3.1.0" 141 | through "~2.3.4" 142 | 143 | es-abstract@^1.5.0: 144 | version "1.7.0" 145 | resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.7.0.tgz#dfade774e01bfcd97f96180298c449c8623fb94c" 146 | dependencies: 147 | es-to-primitive "^1.1.1" 148 | function-bind "^1.1.0" 149 | is-callable "^1.1.3" 150 | is-regex "^1.0.3" 151 | 152 | es-to-primitive@^1.1.1: 153 | version "1.1.1" 154 | resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.1.1.tgz#45355248a88979034b6792e19bb81f2b7975dd0d" 155 | dependencies: 156 | is-callable "^1.1.1" 157 | is-date-object "^1.0.1" 158 | is-symbol "^1.0.1" 159 | 160 | es6-promise@^4.0.5: 161 | version "4.1.0" 162 | resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-4.1.0.tgz#dda03ca8f9f89bc597e689842929de7ba8cebdf0" 163 | 164 | escape-string-regexp@^1.0.2: 165 | version "1.0.5" 166 | resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" 167 | 168 | esprima@~3.1.0: 169 | version "3.1.3" 170 | resolved "https://registry.yarnpkg.com/esprima/-/esprima-3.1.3.tgz#fdca51cee6133895e3c88d535ce49dbff62a4633" 171 | 172 | events@^1.1.0: 173 | version "1.1.1" 174 | resolved "https://registry.yarnpkg.com/events/-/events-1.1.1.tgz#9ebdb7635ad099c70dcc4c2a1f5004288e8bd924" 175 | 176 | exit@^0.1.2: 177 | version "0.1.2" 178 | resolved "https://registry.yarnpkg.com/exit/-/exit-0.1.2.tgz#0632638f8d877cc82107d30a0fff1a17cba1cd0c" 179 | 180 | faye-websocket@0.9.3: 181 | version "0.9.3" 182 | resolved "https://registry.yarnpkg.com/faye-websocket/-/faye-websocket-0.9.3.tgz#482a505b0df0ae626b969866d3bd740cdb962e83" 183 | dependencies: 184 | websocket-driver ">=0.5.1" 185 | 186 | firebase-admin@^4.2.0: 187 | version "4.2.0" 188 | resolved "https://registry.yarnpkg.com/firebase-admin/-/firebase-admin-4.2.0.tgz#92b8d900becb7c5f499eb68ca267a94c652c709f" 189 | dependencies: 190 | "@types/jsonwebtoken" "^7.1.33" 191 | faye-websocket "0.9.3" 192 | jsonwebtoken "7.1.9" 193 | 194 | firebase@^3.2.1: 195 | version "3.7.5" 196 | resolved "https://registry.yarnpkg.com/firebase/-/firebase-3.7.5.tgz#54190ddf6956662a43123ef060734acbd9663985" 197 | dependencies: 198 | dom-storage "2.0.2" 199 | faye-websocket "0.9.3" 200 | jsonwebtoken "7.1.9" 201 | rsvp "3.2.1" 202 | xmlhttprequest "1.8.0" 203 | 204 | follow-redirects@0.0.7: 205 | version "0.0.7" 206 | resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-0.0.7.tgz#34b90bab2a911aa347571da90f22bd36ecd8a919" 207 | dependencies: 208 | debug "^2.2.0" 209 | stream-consume "^0.1.0" 210 | 211 | for-each@~0.3.2: 212 | version "0.3.2" 213 | resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.2.tgz#2c40450b9348e97f281322593ba96704b9abd4d4" 214 | dependencies: 215 | is-function "~1.0.0" 216 | 217 | foreach@^2.0.5: 218 | version "2.0.5" 219 | resolved "https://registry.yarnpkg.com/foreach/-/foreach-2.0.5.tgz#0bee005018aeb260d0a3af3ae658dd0136ec1b99" 220 | 221 | forever-agent@^0.6.0: 222 | version "0.6.1" 223 | resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91" 224 | 225 | fs.realpath@^1.0.0: 226 | version "1.0.0" 227 | resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" 228 | 229 | function-bind@^1.0.2, function-bind@^1.1.0, function-bind@~1.1.0: 230 | version "1.1.0" 231 | resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.0.tgz#16176714c801798e4e8f2cf7f7529467bb4a5771" 232 | 233 | glob@^7.0.6, glob@~7.1.1: 234 | version "7.1.1" 235 | resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.1.tgz#805211df04faaf1c63a3600306cdf5ade50b2ec8" 236 | dependencies: 237 | fs.realpath "^1.0.0" 238 | inflight "^1.0.4" 239 | inherits "2" 240 | minimatch "^3.0.2" 241 | once "^1.3.0" 242 | path-is-absolute "^1.0.0" 243 | 244 | global@^4.3.0, global@~4.3.0: 245 | version "4.3.1" 246 | resolved "https://registry.yarnpkg.com/global/-/global-4.3.1.tgz#5f757908c7cbabce54f386ae440e11e26b7916df" 247 | dependencies: 248 | min-document "^2.19.0" 249 | process "~0.5.1" 250 | 251 | has-ansi@^2.0.0: 252 | version "2.0.0" 253 | resolved "https://registry.yarnpkg.com/has-ansi/-/has-ansi-2.0.0.tgz#34f5049ce1ecdf2b0649af3ef24e45ed35416d91" 254 | dependencies: 255 | ansi-regex "^2.0.0" 256 | 257 | has@^1.0.1, has@~1.0.1: 258 | version "1.0.1" 259 | resolved "https://registry.yarnpkg.com/has/-/has-1.0.1.tgz#8461733f538b0837c9361e39a9ab9e9704dc2f28" 260 | dependencies: 261 | function-bind "^1.0.2" 262 | 263 | hoek@2.x.x: 264 | version "2.16.3" 265 | resolved "https://registry.yarnpkg.com/hoek/-/hoek-2.16.3.tgz#20bb7403d3cea398e91dc4710a8ff1b8274a25ed" 266 | 267 | inflight@^1.0.4: 268 | version "1.0.6" 269 | resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" 270 | dependencies: 271 | once "^1.3.0" 272 | wrappy "1" 273 | 274 | inherits@2, inherits@^2.0.1, inherits@~2.0.0, inherits@~2.0.3: 275 | version "2.0.3" 276 | resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" 277 | 278 | is-callable@^1.1.1, is-callable@^1.1.3: 279 | version "1.1.3" 280 | resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.1.3.tgz#86eb75392805ddc33af71c92a0eedf74ee7604b2" 281 | 282 | is-date-object@^1.0.1: 283 | version "1.0.1" 284 | resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.0.1.tgz#9aa20eb6aeebbff77fbd33e74ca01b33581d3a16" 285 | 286 | is-function@~1.0.0: 287 | version "1.0.1" 288 | resolved "https://registry.yarnpkg.com/is-function/-/is-function-1.0.1.tgz#12cfb98b65b57dd3d193a3121f5f6e2f437602b5" 289 | 290 | is-regex@^1.0.3: 291 | version "1.0.4" 292 | resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.0.4.tgz#5517489b547091b0930e095654ced25ee97e9491" 293 | dependencies: 294 | has "^1.0.1" 295 | 296 | is-symbol@^1.0.1: 297 | version "1.0.1" 298 | resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.0.1.tgz#3cc59f00025194b6ab2e38dbae6689256b660572" 299 | 300 | isarray@^2.0.1: 301 | version "2.0.1" 302 | resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.1.tgz#a37d94ed9cda2d59865c9f76fe596ee1f338741e" 303 | 304 | isemail@1.x.x: 305 | version "1.2.0" 306 | resolved "https://registry.yarnpkg.com/isemail/-/isemail-1.2.0.tgz#be03df8cc3e29de4d2c5df6501263f1fa4595e9a" 307 | 308 | jasmine-core@~2.5.2: 309 | version "2.5.2" 310 | resolved "https://registry.yarnpkg.com/jasmine-core/-/jasmine-core-2.5.2.tgz#6f61bd79061e27f43e6f9355e44b3c6cab6ff297" 311 | 312 | jasmine@^2.5.3: 313 | version "2.5.3" 314 | resolved "https://registry.yarnpkg.com/jasmine/-/jasmine-2.5.3.tgz#5441f254e1fc2269deb1dfd93e0e57d565ff4d22" 315 | dependencies: 316 | exit "^0.1.2" 317 | glob "^7.0.6" 318 | jasmine-core "~2.5.2" 319 | 320 | joi@^6.10.1: 321 | version "6.10.1" 322 | resolved "https://registry.yarnpkg.com/joi/-/joi-6.10.1.tgz#4d50c318079122000fe5f16af1ff8e1917b77e06" 323 | dependencies: 324 | hoek "2.x.x" 325 | isemail "1.x.x" 326 | moment "2.x.x" 327 | topo "1.x.x" 328 | 329 | json-format@^0.1.2: 330 | version "0.1.2" 331 | resolved "https://registry.yarnpkg.com/json-format/-/json-format-0.1.2.tgz#97944beed282d69c5490c31ff2b8d80037801f19" 332 | 333 | jsonwebtoken@7.1.9: 334 | version "7.1.9" 335 | resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-7.1.9.tgz#847804e5258bec5a9499a8dc4a5e7a3bae08d58a" 336 | dependencies: 337 | joi "^6.10.1" 338 | jws "^3.1.3" 339 | lodash.once "^4.0.0" 340 | ms "^0.7.1" 341 | xtend "^4.0.1" 342 | 343 | jwa@^1.1.4: 344 | version "1.1.5" 345 | resolved "https://registry.yarnpkg.com/jwa/-/jwa-1.1.5.tgz#a0552ce0220742cd52e153774a32905c30e756e5" 346 | dependencies: 347 | base64url "2.0.0" 348 | buffer-equal-constant-time "1.0.1" 349 | ecdsa-sig-formatter "1.0.9" 350 | safe-buffer "^5.0.1" 351 | 352 | jws@^3.1.3: 353 | version "3.1.4" 354 | resolved "https://registry.yarnpkg.com/jws/-/jws-3.1.4.tgz#f9e8b9338e8a847277d6444b1464f61880e050a2" 355 | dependencies: 356 | base64url "^2.0.0" 357 | jwa "^1.1.4" 358 | safe-buffer "^5.0.1" 359 | 360 | load-script@^1.0.0: 361 | version "1.0.0" 362 | resolved "https://registry.yarnpkg.com/load-script/-/load-script-1.0.0.tgz#0491939e0bee5643ee494a7e3da3d2bac70c6ca4" 363 | 364 | lodash-compat@^3.0.0: 365 | version "3.10.2" 366 | resolved "https://registry.yarnpkg.com/lodash-compat/-/lodash-compat-3.10.2.tgz#c6940128a9d30f8e902cd2cf99fd0cba4ecfc183" 367 | 368 | lodash.once@^4.0.0: 369 | version "4.1.1" 370 | resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac" 371 | 372 | lodash@^3.10.0: 373 | version "3.10.1" 374 | resolved "https://registry.yarnpkg.com/lodash/-/lodash-3.10.1.tgz#5bf45e8e49ba4189e17d482789dfd15bd140b7b6" 375 | 376 | lodash@^4.14.1: 377 | version "4.17.4" 378 | resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.4.tgz#78203a4d1c328ae1d86dca6460e369b57f4055ae" 379 | 380 | min-document@^2.19.0: 381 | version "2.19.0" 382 | resolved "https://registry.yarnpkg.com/min-document/-/min-document-2.19.0.tgz#7bd282e3f5842ed295bb748cdd9f1ffa2c824685" 383 | dependencies: 384 | dom-walk "^0.1.0" 385 | 386 | minimatch@^3.0.2: 387 | version "3.0.3" 388 | resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.3.tgz#2a4e4090b96b2db06a9d7df01055a62a77c9b774" 389 | dependencies: 390 | brace-expansion "^1.0.0" 391 | 392 | minimist@~1.2.0: 393 | version "1.2.0" 394 | resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.0.tgz#a35008b20f41383eec1fb914f4cd5df79a264284" 395 | 396 | moment@2.x.x: 397 | version "2.18.1" 398 | resolved "https://registry.yarnpkg.com/moment/-/moment-2.18.1.tgz#c36193dd3ce1c2eed2adb7c802dbbc77a81b1c0f" 399 | 400 | ms@0.7.2, ms@^0.7.1: 401 | version "0.7.2" 402 | resolved "https://registry.yarnpkg.com/ms/-/ms-0.7.2.tgz#ae25cf2512b3885a1d95d7f037868d8431124765" 403 | 404 | object-inspect@~1.2.1: 405 | version "1.2.2" 406 | resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.2.2.tgz#c82115e4fcc888aea14d64c22e4f17f6a70d5e5a" 407 | 408 | object-keys@^1.0.11, object-keys@^1.0.8, object-keys@~1.0.0: 409 | version "1.0.11" 410 | resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.0.11.tgz#c54601778ad560f1142ce0e01bcca8b56d13426d" 411 | 412 | once@^1.3.0: 413 | version "1.4.0" 414 | resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" 415 | dependencies: 416 | wrappy "1" 417 | 418 | path-is-absolute@^1.0.0: 419 | version "1.0.1" 420 | resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" 421 | 422 | process@~0.5.1: 423 | version "0.5.2" 424 | resolved "https://registry.yarnpkg.com/process/-/process-0.5.2.tgz#1638d8a8e34c2f440a91db95ab9aeb677fc185cf" 425 | 426 | promise@^7.1.1: 427 | version "7.1.1" 428 | resolved "https://registry.yarnpkg.com/promise/-/promise-7.1.1.tgz#489654c692616b8aa55b0724fa809bb7db49c5bf" 429 | dependencies: 430 | asap "~2.0.3" 431 | 432 | querystring-es3@^0.2.1: 433 | version "0.2.1" 434 | resolved "https://registry.yarnpkg.com/querystring-es3/-/querystring-es3-0.2.1.tgz#9ec61f79049875707d69414596fd907a4d711e73" 435 | 436 | queue@^4.2.1: 437 | version "4.2.1" 438 | resolved "https://registry.yarnpkg.com/queue/-/queue-4.2.1.tgz#5318ed8a227a9734e6bfeeb24a057782922751db" 439 | dependencies: 440 | inherits "~2.0.0" 441 | 442 | reduce@^1.0.1: 443 | version "1.0.1" 444 | resolved "https://registry.yarnpkg.com/reduce/-/reduce-1.0.1.tgz#14fa2e5ff1fc560703a020cbb5fbaab691565804" 445 | dependencies: 446 | object-keys "~1.0.0" 447 | 448 | resolve@~1.1.7: 449 | version "1.1.7" 450 | resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.1.7.tgz#203114d82ad2c5ed9e8e0411b3932875e889e97b" 451 | 452 | resumer@~0.0.0: 453 | version "0.0.0" 454 | resolved "https://registry.yarnpkg.com/resumer/-/resumer-0.0.0.tgz#f1e8f461e4064ba39e82af3cdc2a8c893d076759" 455 | dependencies: 456 | through "~2.3.4" 457 | 458 | rsvp@3.2.1: 459 | version "3.2.1" 460 | resolved "https://registry.yarnpkg.com/rsvp/-/rsvp-3.2.1.tgz#07cb4a5df25add9e826ebc67dcc9fd89db27d84a" 461 | 462 | safe-buffer@^5.0.1: 463 | version "5.0.1" 464 | resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.0.1.tgz#d263ca54696cd8a306b5ca6551e92de57918fbe7" 465 | 466 | semver@^5.1.0: 467 | version "5.3.0" 468 | resolved "https://registry.yarnpkg.com/semver/-/semver-5.3.0.tgz#9b2ce5d3de02d17c6012ad326aa6b4d0cf54f94f" 469 | 470 | stream-consume@^0.1.0: 471 | version "0.1.0" 472 | resolved "https://registry.yarnpkg.com/stream-consume/-/stream-consume-0.1.0.tgz#a41ead1a6d6081ceb79f65b061901b6d8f3d1d0f" 473 | 474 | string.prototype.trim@~1.1.2: 475 | version "1.1.2" 476 | resolved "https://registry.yarnpkg.com/string.prototype.trim/-/string.prototype.trim-1.1.2.tgz#d04de2c89e137f4d7d206f086b5ed2fae6be8cea" 477 | dependencies: 478 | define-properties "^1.1.2" 479 | es-abstract "^1.5.0" 480 | function-bind "^1.0.2" 481 | 482 | strip-ansi@^3.0.0: 483 | version "3.0.1" 484 | resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-3.0.1.tgz#6a385fb8853d952d5ff05d0e8aaf94278dc63dcf" 485 | dependencies: 486 | ansi-regex "^2.0.0" 487 | 488 | supports-color@^2.0.0: 489 | version "2.0.0" 490 | resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-2.0.0.tgz#535d045ce6b6363fa40117084629995e9df324c7" 491 | 492 | tape-catch@^1.0.6: 493 | version "1.0.6" 494 | resolved "https://registry.yarnpkg.com/tape-catch/-/tape-catch-1.0.6.tgz#12931d5ea60a03a97d9bd19d0d7d8cfc3f6cecf1" 495 | dependencies: 496 | global "~4.3.0" 497 | 498 | tape@^4.6.0: 499 | version "4.6.3" 500 | resolved "https://registry.yarnpkg.com/tape/-/tape-4.6.3.tgz#637e77581e9ab2ce17577e9bd4ce4f575806d8b6" 501 | dependencies: 502 | deep-equal "~1.0.1" 503 | defined "~1.0.0" 504 | for-each "~0.3.2" 505 | function-bind "~1.1.0" 506 | glob "~7.1.1" 507 | has "~1.0.1" 508 | inherits "~2.0.3" 509 | minimist "~1.2.0" 510 | object-inspect "~1.2.1" 511 | resolve "~1.1.7" 512 | resumer "~0.0.0" 513 | string.prototype.trim "~1.1.2" 514 | through "~2.3.8" 515 | 516 | through@~2.3.4, through@~2.3.8: 517 | version "2.3.8" 518 | resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" 519 | 520 | topo@1.x.x: 521 | version "1.1.0" 522 | resolved "https://registry.yarnpkg.com/topo/-/topo-1.1.0.tgz#e9d751615d1bb87dc865db182fa1ca0a5ef536d5" 523 | dependencies: 524 | hoek "2.x.x" 525 | 526 | tunnel-agent@^0.4.3: 527 | version "0.4.3" 528 | resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.4.3.tgz#6373db76909fe570e08d73583365ed828a74eeeb" 529 | 530 | websocket-driver@>=0.5.1: 531 | version "0.6.5" 532 | resolved "https://registry.yarnpkg.com/websocket-driver/-/websocket-driver-0.6.5.tgz#5cb2556ceb85f4373c6d8238aa691c8454e13a36" 533 | dependencies: 534 | websocket-extensions ">=0.1.1" 535 | 536 | websocket-extensions@>=0.1.1: 537 | version "0.1.1" 538 | resolved "https://registry.yarnpkg.com/websocket-extensions/-/websocket-extensions-0.1.1.tgz#76899499c184b6ef754377c2dbb0cd6cb55d29e7" 539 | 540 | wrappy@1: 541 | version "1.0.2" 542 | resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" 543 | 544 | xmlhttprequest@1.8.0: 545 | version "1.8.0" 546 | resolved "https://registry.yarnpkg.com/xmlhttprequest/-/xmlhttprequest-1.8.0.tgz#67fe075c5c24fef39f9d65f5f7b7fe75171968fc" 547 | 548 | xtend@^4.0.1: 549 | version "4.0.1" 550 | resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.1.tgz#a5c6d532be656e23db820efb943a1f04998d63af" 551 | --------------------------------------------------------------------------------