├── .babelrc ├── .codeclimate.yml ├── .editorconfig ├── .github ├── contributing.md ├── issue_template.md └── pull_request_template.md ├── .gitignore ├── .istanbul.yml ├── .npmignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── mocha.opts ├── package.json ├── src ├── commons │ ├── base-engine.js │ ├── base-replicator.js │ ├── optimistic-mutator.js │ └── utils │ │ ├── cryptographic.js │ │ ├── index.js │ │ └── misc.js ├── index.js ├── optimistic-mutator.js └── realtime-engine.js └── test ├── commons └── helpers │ ├── emitters.test.js │ ├── optimistic-mutator-online.test.js │ ├── replicator.test.js │ ├── service-events.test.js │ ├── snapshot.test.js │ └── subscribers.test.js ├── emitters.test.js ├── optimistic-mutator-online.test.js ├── replicator.test.js ├── service-events.test.js ├── snapshot.test.js ├── subscribers.test.js ├── utils-cryptographic.test.js └── utils-misc.test.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ "add-module-exports" ], 3 | "presets": [ "es2015" ] 4 | } 5 | -------------------------------------------------------------------------------- /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | languages: 2 | JavaScript: true 3 | engines: 4 | duplication: 5 | enabled: false 6 | # need this: Config Error: Unable to run the duplication engine without any languages enabled. 7 | config: 8 | languages: 9 | - javascript 10 | ratings: 11 | paths: 12 | - "**.js" 13 | exclude_paths: 14 | - "lib/" -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.github/contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing to Feathers 2 | 3 | Thank you for contributing to Feathers! :heart: :tada: 4 | 5 | This repo is the main core and where most issues are reported. Feathers embraces modularity and is broken up across many repos. To make this easier to manage we currently use [Zenhub](https://www.zenhub.com/) for issue triage and visibility. They have a free browser plugin you can install so that you can see what is in flight at any time, but of course you also always see current issues in Github. 6 | 7 | ## Report a bug 8 | 9 | Before creating an issue please make sure you have checked out the docs, specifically the [FAQ](https://docs.feathersjs.com/help/faq.html) section. You might want to also try searching Github. It's pretty likely someone has already asked a similar question. 10 | 11 | If you haven't found your answer please feel free to join our [slack channel](http://slack.feathersjs.com), create an issue on Github, or post on [Stackoverflow](http://stackoverflow.com) using the `feathers` or `feathersjs` tag. We try our best to monitor Stackoverflow but you're likely to get more immediate responses in Slack and Github. 12 | 13 | Issues can be reported in the [issue tracker](https://github.com/feathersjs/feathers/issues). Since feathers combines many modules it can be hard for us to assess the root cause without knowing which modules are being used and what your configuration looks like, so **it helps us immensely if you can link to a simple example that reproduces your issue**. 14 | 15 | ## Report a Security Concern 16 | 17 | We take security very seriously at Feathers. We welcome any peer review of our 100% open source code to ensure nobody's Feathers app is ever compromised or hacked. As a web application developer you are responsible for any security breaches. We do our very best to make sure Feathers is as secure as possible by default. 18 | 19 | In order to give the community time to respond and upgrade we strongly urge you report all security issues to us. Send one of the core team members a PM in [Slack](http://slack.feathersjs.com) or email us at hello@feathersjs.com with details and we will respond ASAP. 20 | 21 | For full details refer to our [Security docs](https://docs.feathersjs.com/SECURITY.html). 22 | 23 | ## Pull Requests 24 | 25 | We :heart: pull requests and we're continually working to make it as easy as possible for people to contribute, including a [Plugin Generator](https://github.com/feathersjs/generator-feathers-plugin) and a [common test suite](https://github.com/feathersjs/feathers-service-tests) for database adapters. 26 | 27 | We prefer small pull requests with minimal code changes. The smaller they are the easier they are to review and merge. A core team member will pick up your PR and review it as soon as they can. They may ask for changes or reject your pull request. This is not a reflection of you as an engineer or a person. Please accept feedback graciously as we will also try to be sensitive when providing it. 28 | 29 | Although we generally accept many PRs they can be rejected for many reasons. We will be as transparent as possible but it may simply be that you do not have the same context or information regarding the roadmap that the core team members have. We value the time you take to put together any contributions so we pledge to always be respectful of that time and will try to be as open as possible so that you don't waste it. :smile: 30 | 31 | **All PRs (except documentation) should be accompanied with tests and pass the linting rules.** 32 | 33 | ### Code style 34 | 35 | Before running the tests from the `test/` folder `npm test` will run ESlint. You can check your code changes individually by running `npm run lint`. 36 | 37 | ### ES6 compilation 38 | 39 | Feathers uses [Babel](https://babeljs.io/) to leverage the latest developments of the JavaScript language. All code and samples are currently written in ES2015. To transpile the code in this repository run 40 | 41 | > npm run compile 42 | 43 | __Note:__ `npm test` will run the compilation automatically before the tests. 44 | 45 | ### Tests 46 | 47 | [Mocha](http://mochajs.org/) tests are located in the `test/` folder and can be run using the `npm run mocha` or `npm test` (with ESLint and code coverage) command. 48 | 49 | ### Documentation 50 | 51 | Feathers documentation is contained in Markdown files in the [feathers-docs](https://github.com/feathersjs/feathers-docs) repository. To change the documentation submit a pull request to that repo, referencing any other PR if applicable, and the docs will be updated with the next release. 52 | 53 | ## External Modules 54 | 55 | If you're written something awesome for Feathers, the Feathers ecosystem, or using Feathers please add it to the [showcase](https://docs.feathersjs.com/why/showcase.html). You also might want to check out the [Plugin Generator](https://github.com/feathersjs/generator-feathers-plugin) that can be used to scaffold plugins to be Feathers compliant from the start. 56 | 57 | If you think it would be a good core module then please contact one of the Feathers core team members in [Slack](http://slack.feathersjs.com) and we can discuss whether it belongs in core and how to get it there. :beers: 58 | 59 | ## Contributor Code of Conduct 60 | 61 | As contributors and maintainers of this project, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities. 62 | 63 | We are committed to making participation in this project a harassment-free experience for everyone, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, or religion. 64 | 65 | Examples of unacceptable behavior by participants include the use of sexual language or imagery, derogatory comments or personal attacks, trolling, public or private harassment, insults, or other unprofessional conduct. 66 | 67 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct. Project maintainers who do not follow the Code of Conduct may be removed from the project team. 68 | 69 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening an issue or contacting one or more of the project maintainers. 70 | 71 | This Code of Conduct is adapted from the [Contributor Covenant](http://contributor-covenant.org), version 1.0.0, available at [http://contributor-covenant.org/version/1/0/0/](http://contributor-covenant.org/version/1/0/0/) 72 | -------------------------------------------------------------------------------- /.github/issue_template.md: -------------------------------------------------------------------------------- 1 | ### Steps to reproduce 2 | 3 | (First please check that this issue is not already solved as [described 4 | here](https://github.com/feathersjs/feathers/blob/master/.github/contributing.md#report-a-bug)) 5 | 6 | - [ ] Tell us what broke. The more detailed the better. 7 | - [ ] If you can, please create a simple example that reproduces the issue and link to a gist, jsbin, repo, etc. 8 | 9 | ### Expected behavior 10 | Tell us what should happen 11 | 12 | ### Actual behavior 13 | Tell us what happens instead 14 | 15 | ### System configuration 16 | 17 | Tell us about the applicable parts of your setup. 18 | 19 | **Module versions** (especially the part that's not working): 20 | 21 | **NodeJS version**: 22 | 23 | **Operating System**: 24 | 25 | **Browser Version**: 26 | 27 | **React Native Version**: 28 | 29 | **Module Loader**: -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ### Summary 2 | 3 | (If you have not already please refer to the contributing guideline as [described 4 | here](https://github.com/feathersjs/feathers/blob/master/.github/contributing.md#pull-requests)) 5 | 6 | - [ ] Tell us about the problem your pull request is solving. 7 | - [ ] Are there any open issues that are related to this? 8 | - [ ] Is this PR dependent on PRs in other repos? 9 | 10 | If so, please mention them to keep the conversations linked together. 11 | 12 | ### Other Information 13 | 14 | If there's anything else that's important and relevant to your pull 15 | request, mention that information here. This could include 16 | benchmarks, or other information. 17 | 18 | Your PR will be reviewed by a core team member and they will work with you to get your changes merged in a timely manner. If merged your PR will automatically be added to the changelog in the next release. 19 | 20 | If your changes involve documentation updates please mention that and link the appropriate PR in [feathers-docs](https://github.com/feathersjs/feathers-docs). 21 | 22 | Thanks for contributing to Feathers! :heart: -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | # Logs 4 | logs 5 | *.log 6 | 7 | # Runtime data 8 | pids 9 | *.pid 10 | *.seed 11 | 12 | # Directory for instrumented libs generated by jscoverage/JSCover 13 | lib-cov 14 | 15 | # Coverage directory used by tools like istanbul 16 | coverage 17 | 18 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 19 | .grunt 20 | 21 | # Compiled binary addons (http://nodejs.org/api/addons.html) 22 | build/Release 23 | 24 | # Dependency directory 25 | # Commenting this out is preferred by some people, see 26 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git- 27 | node_modules 28 | 29 | # Users Environment Variables 30 | .lock-wscript 31 | 32 | # The compiled/babelified modules 33 | lib/ 34 | 35 | # Editors 36 | .idea/ 37 | 38 | # Yarn lockfile 39 | yarn.lock 40 | -------------------------------------------------------------------------------- /.istanbul.yml: -------------------------------------------------------------------------------- 1 | verbose: false 2 | instrumentation: 3 | root: ./src/ 4 | excludes: 5 | - lib/ 6 | include-all-sources: true 7 | reporting: 8 | print: summary 9 | reports: 10 | - html 11 | - text 12 | - lcov 13 | watermarks: 14 | statements: [50, 80] 15 | lines: [50, 80] 16 | functions: [50, 80] 17 | branches: [50, 80] -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .editorconfig 2 | .jshintrc 3 | .travis.yml 4 | .istanbul.yml 5 | .babelrc 6 | .idea/ 7 | src/ 8 | test/ 9 | !lib/ 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - node 4 | - '6' 5 | - '4' 6 | addons: 7 | code_climate: 8 | repo_token: 0833cf69acc3ad4c6f78ea7fac4b1b2520d8f8484b20ed8c114468864bb7ab2c 9 | notifications: 10 | email: false 11 | slack: 12 | rooms: 13 | secure: l2F0EWyUmlXHXqPdsF6oP+FOHgw0db+co3M9mBfX8eXXthY9wjgAoeBGvikiC6PB77bbe3YLWidS622KfaCzCCoBmg91StTauE0UexZmeZJQHPxJkckMY5eTaRcNIoP50HA+G7Rn96XJRcDOXZOzjYxOCv+BUqi3YMGB81KN2pcUZXJlftDY22nsxqqi85cfqK9cXkQRAlKHEOz1i/wP8lQi2i+n314bzwwYSH5t5FNJTPobxtMrBNEKdZHQKjb3E0KC8XlZyQAaFPMgI9brMIeAEKmGazMsNyIKqu9Psd8QrDHDwF5fEHzqHPW5Cc2kQusHzLYAoPVxnw6HSJxeUqKXESmCB4apm6X38fy6Oz8oRf7G/ilqvKkhkLtkl7+HH3XU5h3dNj+IC7YcN7cB5Z6lJ7b7UFja9Sueg+u2OG8aC6fPT6tbJIL/s36OUWZAZFtlfmQ+cF/xL0Gx4mnhTsD7UPoi62wG+Ruc7ZfXXuuztZSQ7cpAN9sN7fsw0vNvkTb1tVgh2eswYnG32mRnl3akCpHmJHtf+4xMHB3FQTJ2+2vmkogASjBknVJWghvL+YsoHAcUdb2Gg5WZfotDEGhQwbLpwsHIrXPie9GdrOLRUY0BHb73RPssbwT6ohMqaA6zwrlUhhuevW9Z5dv6QMJ7VAhJrlG5whE7TGTgWR8= 14 | before_script: 15 | - npm install -g codeclimate-test-reporter 16 | after_script: 17 | - codeclimate-test-reporter < coverage/lcov.info -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## [v0.1.2](https://github.com/feathersjs/feathers-offline-realtime/tree/v0.1.2) (2017-07-09) 4 | [Full Changelog](https://github.com/feathersjs/feathers-offline-realtime/compare/v0.1.1...v0.1.2) 5 | 6 | **Fixed bugs:** 7 | 8 | - crypto dependency breaks react native [\#9](https://github.com/feathersjs/feathers-offline-realtime/issues/9) 9 | 10 | **Merged pull requests:** 11 | 12 | - Replace crypto repo with md5 repo [\#11](https://github.com/feathersjs/feathers-offline-realtime/pull/11) ([eddyystop](https://github.com/eddyystop)) 13 | - Remove asset no longer used in README [\#10](https://github.com/feathersjs/feathers-offline-realtime/pull/10) ([eddyystop](https://github.com/eddyystop)) 14 | 15 | ## [v0.1.1](https://github.com/feathersjs/feathers-offline-realtime/tree/v0.1.1) (2017-07-05) 16 | [Full Changelog](https://github.com/feathersjs/feathers-offline-realtime/compare/v0.1.0...v0.1.1) 17 | 18 | **Implemented enhancements:** 19 | 20 | - Use npm 'component-emitter' instead of 'events' [\#7](https://github.com/feathersjs/feathers-offline-realtime/issues/7) 21 | 22 | ## [v0.1.0](https://github.com/feathersjs/feathers-offline-realtime/tree/v0.1.0) (2017-06-30) 23 | [Full Changelog](https://github.com/feathersjs/feathers-offline-realtime/compare/v0.0.3...v0.1.0) 24 | 25 | ## [v0.0.3](https://github.com/feathersjs/feathers-offline-realtime/tree/v0.0.3) (2017-06-21) 26 | [Full Changelog](https://github.com/feathersjs/feathers-offline-realtime/compare/v0.0.2...v0.0.3) 27 | 28 | ## [v0.0.2](https://github.com/feathersjs/feathers-offline-realtime/tree/v0.0.2) (2017-06-21) 29 | [Full Changelog](https://github.com/feathersjs/feathers-offline-realtime/compare/v0.0.1...v0.0.2) 30 | 31 | **Implemented enhancements:** 32 | 33 | - Updated README to reference feathers-offline-publication [\#2](https://github.com/feathersjs/feathers-offline-realtime/pull/2) ([eddyystop](https://github.com/eddyystop)) 34 | 35 | **Closed issues:** 36 | 37 | - Action required: Greenkeeper could not be activated 🚨 [\#1](https://github.com/feathersjs/feathers-offline-realtime/issues/1) 38 | 39 | ## [v0.0.1](https://github.com/feathersjs/feathers-offline-realtime/tree/v0.0.1) (2017-06-10) 40 | 41 | 42 | \* *This Change Log was automatically generated by [github_changelog_generator](https://github.com/skywinder/Github-Changelog-Generator)* -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Feathers 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 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # feathers-offline-realtime 2 | 3 | > This module is no longer maintained. 4 | 5 | Offline-first realtime replication with optimistic updates. 6 | 7 | ## Installation 8 | 9 | ``` 10 | npm install feathers-offline-realtime --save 11 | ``` 12 | 13 | 14 | ## Documentation 15 | 16 | You can read the docs [here](https://docs.feathersjs.com/guides/offline-first/readme.html). 17 | 18 | ## License 19 | 20 | Copyright (c) 2017 21 | 22 | Licensed under the [MIT license](LICENSE). 23 | -------------------------------------------------------------------------------- /mocha.opts: -------------------------------------------------------------------------------- 1 | --recursive test/ 2 | --compilers js:babel-core/register -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "feathers-offline-realtime", 3 | "description": "Offline-first realtime replication with optimistic updates.", 4 | "version": "0.1.2", 5 | "homepage": "https://github.com/feathersjs/feathers-offline-realtime", 6 | "main": "lib/", 7 | "keywords": [ 8 | "feathers", 9 | "feathers-plugin" 10 | ], 11 | "license": "MIT", 12 | "repository": { 13 | "type": "git", 14 | "url": "git://github.com/feathersjs/feathers-offline-realtime.git" 15 | }, 16 | "author": { 17 | "name": "Feathers contributors", 18 | "email": "hello@feathersjs.com", 19 | "url": "https://feathersjs.com" 20 | }, 21 | "contributors": [], 22 | "bugs": { 23 | "url": "https://github.com/feathersjs/feathers-offline-realtime/issues" 24 | }, 25 | "engines": { 26 | "node": ">= 4.6.0" 27 | }, 28 | "scripts": { 29 | "prepublish": "npm run compile", 30 | "publish": "git push origin --tags && npm run changelog && git push origin", 31 | "release:patch": "npm version patch && npm publish", 32 | "release:minor": "npm version minor && npm publish", 33 | "release:major": "npm version major && npm publish", 34 | "changelog": "github_changelog_generator && git add CHANGELOG.md && git commit -am \"Updating changelog\"", 35 | "compile": "shx rm -rf lib/ && babel -d lib/ src/", 36 | "watch": "babel --watch -d lib/ src/", 37 | "lint": "semistandard src/**/*.js test/**/*.js --fix", 38 | "mocha": "mocha --opts mocha.opts", 39 | "coverage": "istanbul cover node_modules/mocha/bin/_mocha -- --opts mocha.opts", 40 | "test": "npm run compile && npm run lint && npm run coverage", 41 | "start": "npm run compile && node example/app" 42 | }, 43 | "semistandard": { 44 | "sourceType": "module", 45 | "env": [ 46 | "mocha" 47 | ] 48 | }, 49 | "directories": { 50 | "lib": "lib" 51 | }, 52 | "dependencies": { 53 | "component-emitter": "1.2.1", 54 | "debug": "^2.6.8", 55 | "feathers-commons": "0.8.7", 56 | "feathers-errors": "2.8.1", 57 | "feathers-offline-snapshot": "^0.0.1", 58 | "feathers-query-filters": "2.1.2", 59 | "md5": "2.2.1", 60 | "shortid": "2.2.8", 61 | "uberproto": "1.2.0", 62 | "uuid": "3.1.0" 63 | }, 64 | "devDependencies": { 65 | "babel-cli": "^6.24.1", 66 | "babel-core": "^6.24.1", 67 | "babel-plugin-add-module-exports": "^0.2.1", 68 | "babel-preset-es2015": "^6.24.1", 69 | "chai": "^4.0.0", 70 | "feathers": "^2.1.3", 71 | "feathers-hooks": "^2.0.1", 72 | "feathers-memory": "^1.1.0", 73 | "istanbul": "^1.1.0-alpha.1", 74 | "mocha": "^3.4.2", 75 | "semistandard": "^11.0.0", 76 | "shx": "^0.2.2" 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/commons/base-engine.js: -------------------------------------------------------------------------------- 1 | 2 | import EventEmitter from 'component-emitter'; 3 | 4 | import makeDebug from 'debug'; 5 | const debug = makeDebug('base-engine'); 6 | 7 | export default class BaseEngine { 8 | constructor (service, options = {}) { 9 | debug('constructor entered'); 10 | 11 | this._service = service; 12 | this._publication = options.publication; 13 | this._subscriber = options.subscriber || (() => {}); 14 | this._sorter = options.sort; 15 | this._eventEmitter = new EventEmitter(); 16 | 17 | this._listener = eventName => remoteRecord => this._mutateStore( 18 | eventName, remoteRecord, 0 19 | ); 20 | 21 | this._eventListeners = { 22 | created: this._listener('created'), 23 | updated: this._listener('updated'), 24 | patched: this._listener('patched'), 25 | removed: this._listener('removed') 26 | }; 27 | 28 | this.useUuid = options.uuid; 29 | this.emit = this._eventEmitter.emit; 30 | this.on = this._eventEmitter.on; 31 | this.listening = false; 32 | 33 | this.store = { 34 | last: { eventName: '', action: '', record: {} }, 35 | records: [] 36 | }; 37 | } 38 | 39 | snapshot (records) { 40 | debug('snapshot entered'); 41 | 42 | this.store.last = { action: 'snapshot' }; 43 | this.store.records = records; 44 | 45 | if (this._sorter) { 46 | records.sort(this._sorter); 47 | } 48 | 49 | this.emit('events', this.store.records, this.store.last); 50 | this._subscriber(this.store.records, this.store.last); 51 | } 52 | 53 | addListeners () { 54 | debug('addListeners entered'); 55 | const service = this._service; 56 | const eventListeners = this._eventListeners; 57 | 58 | service.on('created', eventListeners.created); 59 | service.on('updated', eventListeners.updated); 60 | service.on('patched', eventListeners.patched); 61 | service.on('removed', eventListeners.removed); 62 | 63 | this.listening = true; 64 | this.emit('events', this.store.records, { action: 'add-listeners' }); 65 | this._subscriber(this.store.records, { action: 'add-listeners' }); 66 | } 67 | 68 | removeListeners () { 69 | debug('removeListeners entered'); 70 | 71 | if (this.listening) { 72 | const service = this._service; 73 | const eventListeners = this._eventListeners; 74 | 75 | service.removeListener('created', eventListeners.created); 76 | service.removeListener('updated', eventListeners.updated); 77 | service.removeListener('patched', eventListeners.patched); 78 | service.removeListener('removed', eventListeners.removed); 79 | 80 | this.listening = false; 81 | this.emit('events', this.store.records, { action: 'remove-listeners' }); 82 | this._subscriber(this.store.records, { action: 'remove-listeners' }); 83 | } 84 | } 85 | 86 | _mutateStore (eventName, remoteRecord, source) { 87 | debug(`_mutateStore started: ${eventName}`); 88 | const that = this; 89 | 90 | const idName = this._useUuid ? 'uuid' : ('id' in remoteRecord ? 'id' : '_id'); 91 | const store = this.store; 92 | const records = store.records; 93 | 94 | const index = this._findIndex(records, record => record[idName] === remoteRecord[idName]); 95 | 96 | if (index >= 0) { 97 | records.splice(index, 1); 98 | } 99 | 100 | if (eventName === 'removed') { 101 | if (index >= 0) { 102 | broadcast('remove'); 103 | } else if (source === 0 && (!this._publication || this._publication(remoteRecord))) { 104 | // Emit service event if it corresponds to a previous optimistic remove 105 | broadcast('remove'); 106 | } 107 | 108 | return; // index >= 0 ? broadcast('remove') : undefined; 109 | } 110 | 111 | if (this._publication && !this._publication(remoteRecord)) { 112 | return index >= 0 ? broadcast('left-pub') : undefined; 113 | } 114 | 115 | records[records.length] = remoteRecord; 116 | 117 | if (this._sorter) { 118 | records.sort(this._sorter); 119 | } 120 | 121 | return broadcast('mutated'); 122 | 123 | function broadcast (action) { 124 | debug(`emitted ${index} ${eventName} ${action}`); 125 | store.last = { source, action, eventName, record: remoteRecord }; 126 | 127 | that.emit('events', records, store.last); 128 | that._subscriber(records, store.last); 129 | } 130 | } 131 | 132 | changeSort (sort) { 133 | this._sorter = sort; 134 | 135 | if (this._sorter) { 136 | this.store.records.sort(this._sorter); 137 | } 138 | 139 | this.emit('events', this.store.records, { action: 'change-sort' }); 140 | this._subscriber(this.store.records, { action: 'change-sort' }); 141 | } 142 | 143 | _findIndex (array, predicate = () => true, fromIndex = 0) { 144 | for (let i = fromIndex, len = array.length; i < len; i++) { 145 | if (predicate(array[i])) { 146 | return i; 147 | } 148 | } 149 | 150 | return -1; 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/commons/base-replicator.js: -------------------------------------------------------------------------------- 1 | 2 | import snapshot from 'feathers-offline-snapshot'; 3 | import { genUuid } from './utils'; 4 | 5 | import makeDebug from 'debug'; 6 | const debug = makeDebug('base-replicator'); 7 | 8 | export default class BaseReplicator { 9 | constructor (service, options = {}) { 10 | debug('constructor entered'); 11 | 12 | // Higher order class defines: this.engine, this.store, this.changeSort, this.on 13 | 14 | this._service = service; 15 | this._query = options.query || {}; 16 | this._publication = options.publication; 17 | 18 | this.genShortUuid = true; 19 | } 20 | 21 | get connected () { 22 | return this.engine.listening; 23 | } 24 | 25 | connect () { 26 | this.engine.removeListeners(); 27 | 28 | return snapshot(this._service, this._query) 29 | .then(records => { 30 | records = this._publication ? records.filter(this._publication) : records; 31 | records = this.engine.sorter ? records.sort(this.engine.sorter) : records; 32 | 33 | this.engine.snapshot(records); 34 | this.engine.addListeners(); 35 | }); 36 | } 37 | 38 | disconnect () { 39 | this.engine.removeListeners(); 40 | } 41 | 42 | useShortUuid (ifShortUuid) { 43 | this.genShortUuid = !!ifShortUuid; 44 | } 45 | 46 | getUuid () { 47 | return genUuid(this.genShortUuid); 48 | } 49 | 50 | // array.sort(Realtime.sort('fieldName')); 51 | static sort (prop) { 52 | return (a, b) => a[prop] > b[prop] ? 1 : (a[prop] < b[prop] ? -1 : 0); 53 | } 54 | 55 | // array.sort(Realtime.multiSort({ field1: 1, field2: -1 })) 56 | static multiSort (order) { 57 | const props = Object.keys(order); 58 | const len = props.length; 59 | 60 | return (a, b) => { 61 | let result = 0; 62 | let i = 0; 63 | 64 | while (result === 0 && i < len) { 65 | const prop = props[i]; 66 | const sense = order[prop]; 67 | 68 | result = a[prop] > b[prop] ? 1 * sense : (a[prop] < b[prop] ? -1 * sense : 0); 69 | i++; 70 | } 71 | 72 | return result; 73 | }; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/commons/optimistic-mutator.js: -------------------------------------------------------------------------------- 1 | 2 | /* 3 | Forked from feathers-memory/src/index.js 4 | */ 5 | import Proto from 'uberproto'; 6 | import errors from 'feathers-errors'; 7 | import filter from 'feathers-query-filters'; 8 | import { sorter, matcher, select, _ } from 'feathers-commons'; 9 | 10 | class Service { 11 | constructor (options = {}) { 12 | this._replicator = options.replicator; 13 | this._engine = this._replicator.engine; 14 | 15 | if (!this._engine.useUuid) { 16 | throw new Error('Replicator must be configured for uuid for optimistic updates. (offline)'); 17 | } 18 | 19 | this._mutateStore = this._engine._mutateStore.bind(this._engine); 20 | this._alwaysSelect = ['id', '_id', 'uuid']; 21 | this._getUuid = this._replicator.getUuid; 22 | 23 | this.store = this._engine.store || { records: [] }; 24 | this.paginate = options.paginate || {}; 25 | } 26 | 27 | extend (obj) { 28 | return Proto.extend(obj, this); 29 | } 30 | 31 | // Find without hooks and mixins that can be used internally and always returns 32 | // a pagination object 33 | _find (params, getFilter = filter) { 34 | const { query, filters } = getFilter(params.query || {}); 35 | let values = _.values(this.store.records).filter(matcher(query)); 36 | 37 | const total = values.length; 38 | 39 | if (filters.$sort) { 40 | values.sort(sorter(filters.$sort)); 41 | } 42 | 43 | if (filters.$skip) { 44 | values = values.slice(filters.$skip); 45 | } 46 | 47 | if (typeof filters.$limit !== 'undefined') { 48 | values = values.slice(0, filters.$limit); 49 | } 50 | 51 | if (filters.$select) { 52 | values = values.map(value => _.pick(value, ...filters.$select)); 53 | } 54 | 55 | return Promise.resolve({ 56 | total, 57 | limit: filters.$limit, 58 | skip: filters.$skip || 0, 59 | data: values 60 | }); 61 | } 62 | 63 | find (params) { 64 | const paginate = typeof params.paginate !== 'undefined' ? params.paginate : this.paginate; 65 | // Call the internal find with query parameter that include pagination 66 | const result = this._find(params, query => filter(query, paginate)); 67 | 68 | if (!(paginate && paginate.default)) { 69 | return result.then(page => page.data); 70 | } 71 | 72 | return result; 73 | } 74 | 75 | get (uuid, params) { 76 | const records = this.store.records; 77 | const index = findUuidIndex(records, uuid); 78 | 79 | if (index === -1) { 80 | return Promise.reject(new errors.NotFound(`No record found for uuid '${uuid}'`)); 81 | } 82 | 83 | return Promise.resolve(records[index]) 84 | .then(select(params, ...this._alwaysSelect)); 85 | } 86 | 87 | // Create without hooks and mixins that can be used internally 88 | _create (data, params) { 89 | this._checkConnected(); 90 | 91 | if (!('uuid' in data)) { 92 | data.uuid = this._getUuid(); 93 | } 94 | 95 | const records = this.store.records; 96 | const index = findUuidIndex(records, data.uuid); 97 | if (index > -1) { 98 | throw new errors.BadRequest('Optimistic create requires unique uuid. (offline)'); 99 | } 100 | 101 | // optimistic mutation 102 | this._mutateStore('created', data, 1); 103 | 104 | // Start actual mutation on remote service 105 | this._replicator._service.create(shallowClone(data), params) 106 | .catch(() => { 107 | this._mutateStore('removed', data, 2); 108 | }); 109 | 110 | return Promise.resolve(data) 111 | .then(select(params, ...this._alwaysSelect)); 112 | } 113 | 114 | create (data, params) { 115 | if (Array.isArray(data)) { 116 | return Promise.all(data.map(current => this._create(current))); 117 | } 118 | 119 | return this._create(data, params); 120 | } 121 | 122 | // Update without hooks and mixins that can be used internally 123 | _update (uuid, data, params) { 124 | this._checkConnected(); 125 | checkUuidExists(data); 126 | 127 | const records = this.store.records; 128 | const index = findUuidIndex(records, uuid); 129 | if (index === -1) { 130 | return Promise.reject(new errors.NotFound(`No record found for uuid '${uuid}'`)); 131 | } 132 | 133 | // We don't want our id to change type if it can be coerced 134 | const beforeRecord = shallowClone(records[index]); 135 | const beforeUuid = beforeRecord.uuid; 136 | data.uuid = beforeUuid == uuid ? beforeUuid : uuid; // eslint-disable-line 137 | 138 | // Optimistic mutation 139 | this._mutateStore('updated', data, 1); 140 | 141 | // Start actual mutation on remote service 142 | this._replicator._service.update(getId(data), shallowClone(data), params) 143 | .catch(() => { 144 | this._mutateStore('updated', beforeRecord, 2); 145 | }); 146 | 147 | return Promise.resolve(data) 148 | .then(select(params, ...this._alwaysSelect)); 149 | } 150 | 151 | update (uuid, data, params) { 152 | if (uuid === null || Array.isArray(data)) { 153 | return Promise.reject(new errors.BadRequest( 154 | `You can not replace multiple instances. Did you mean 'patch'?` 155 | )); 156 | } 157 | 158 | return this._update(uuid, data, params); 159 | } 160 | 161 | // Patch without hooks and mixins that can be used internally 162 | _patch (uuid, data, params) { 163 | this._checkConnected(); 164 | 165 | const records = this.store.records; 166 | const index = findUuidIndex(records, uuid); 167 | if (index === -1) { 168 | return Promise.reject(new errors.NotFound(`No record found for uuid '${uuid}'`)); 169 | } 170 | 171 | // Optimistic mutation 172 | const beforeRecord = shallowClone(records[index]); 173 | const afterRecord = Object.assign({}, beforeRecord, data); 174 | this._mutateStore('patched', afterRecord, 1); 175 | 176 | // Start actual mutation on remote service 177 | this._replicator._service.patch(getId(beforeRecord), shallowClone(data), params) 178 | .catch(() => { 179 | this._mutateStore('updated', beforeRecord, 2); 180 | }); 181 | 182 | return Promise.resolve(afterRecord) 183 | .then(select(params, ...this._alwaysSelect)); 184 | } 185 | 186 | patch (uuid, data, params) { 187 | if (uuid === null) { 188 | return this._find(params).then(page => { 189 | return Promise.all(page.data.map( 190 | current => this._patch(current.uuid, data, params)) 191 | ); 192 | }); 193 | } 194 | 195 | return this._patch(uuid, data, params); 196 | } 197 | 198 | // Remove without hooks and mixins that can be used internally 199 | _remove (uuid, params) { 200 | this._checkConnected(); 201 | 202 | const records = this.store.records; 203 | const index = findUuidIndex(records, uuid); 204 | if (index === -1) { 205 | return Promise.reject(new errors.NotFound(`No record found for uuid '${uuid}'`)); 206 | } 207 | 208 | // Optimistic mutation 209 | const beforeRecord = shallowClone(records[index]); 210 | this._mutateStore('removed', beforeRecord, 1); 211 | 212 | // Start actual mutation on remote service 213 | this._replicator._service.remove(getId(beforeRecord), params) 214 | .catch(() => { 215 | this._mutateStore('created', beforeRecord, 2); 216 | }); 217 | 218 | return Promise.resolve(beforeRecord) 219 | .then(select(params, ...this._alwaysSelect)); 220 | } 221 | 222 | remove (uuid, params) { 223 | if (uuid === null) { 224 | return this._find(params).then(page => 225 | Promise.all(page.data.map(current => 226 | this._remove(current.uuid, params 227 | ) 228 | ))); 229 | } 230 | 231 | return this._remove(uuid, params); 232 | } 233 | 234 | _checkConnected () { 235 | if (!this._replicator.connected) { 236 | throw new errors.BadRequest('Replicator not connected to remote. (offline)'); 237 | } 238 | } 239 | } 240 | 241 | export default function init (options) { 242 | return new Service(options); 243 | } 244 | 245 | init.Service = Service; 246 | 247 | // Helpers 248 | 249 | function findUuidIndex (array, uuid) { 250 | for (let i = 0, len = array.length; i < len; i++) { 251 | if (array[i].uuid == uuid) { // eslint-disable-line 252 | return i; 253 | } 254 | } 255 | 256 | return -1; 257 | } 258 | 259 | function checkUuidExists (record) { 260 | if (!('uuid' in record)) { 261 | throw new errors.BadRequest('Optimistic mutation requires uuid. (offline)'); 262 | } 263 | } 264 | 265 | function getId (record) { 266 | return ('id' in record ? record.id : record._id); 267 | } 268 | 269 | function shallowClone (obj) { 270 | return Object.assign({}, obj); 271 | } 272 | -------------------------------------------------------------------------------- /src/commons/utils/cryptographic.js: -------------------------------------------------------------------------------- 1 | 2 | import md5 from 'md5'; 3 | import uuidV4 from 'uuid/v4'; 4 | import shortid from 'shortid'; 5 | import { stripProps } from './misc'; 6 | 7 | // Integrity of short unique identifiers: https://github.com/dylang/shortid/issues/81#issuecomment-259812835 8 | 9 | export function genUuid(ifShortUuid) { 10 | return ifShortUuid ? shortid.generate() : uuidV4(); 11 | } 12 | 13 | export function hash(value) { 14 | value = typeof value === 'string' ? value : JSON.stringify(value); 15 | return md5(value); 16 | } 17 | 18 | export function hashOfRecord(record) { 19 | return hash(stripProps(record, ['id', '_id'])); 20 | } 21 | -------------------------------------------------------------------------------- /src/commons/utils/index.js: -------------------------------------------------------------------------------- 1 | 2 | import * as cryptographic from './cryptographic'; 3 | import * as misc from './misc'; 4 | 5 | export default Object.assign({}, 6 | cryptographic, 7 | misc 8 | ); 9 | -------------------------------------------------------------------------------- /src/commons/utils/misc.js: -------------------------------------------------------------------------------- 1 | 2 | export function isObject (value) { 3 | return typeof value === 'object' && !Array.isArray(value) && value !== null; 4 | } 5 | 6 | export function stripProps (obj, blacklist) { 7 | blacklist = Array.isArray(blacklist) ? blacklist : (blacklist || []); 8 | const res = {}; 9 | 10 | Object.keys(obj).forEach(prop => { 11 | if (blacklist.indexOf(prop) === -1) { 12 | const value = obj[prop]; 13 | res[prop] = isObject(value) ? stripProps(value, blacklist) : value; 14 | } 15 | }); 16 | 17 | return res; 18 | } 19 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | 2 | import BaseReplicator from './commons/base-replicator'; 3 | import RealtimeEngine from './realtime-engine'; 4 | 5 | import makeDebug from 'debug'; 6 | const debug = makeDebug('realtime-replicator'); 7 | 8 | export default class RealtimeReplicator extends BaseReplicator { 9 | constructor (service, options = {}) { 10 | debug('constructor started'); 11 | super(service, options); 12 | 13 | const engine = this.engine = new RealtimeEngine(service, options); 14 | this.changeSort = (...args) => engine.changeSort(...args); 15 | this.on = (...args) => engine.on(...args); 16 | this.store = engine.store; 17 | 18 | debug('constructor ended'); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/optimistic-mutator.js: -------------------------------------------------------------------------------- 1 | 2 | import optimisticMutator from './commons/optimistic-mutator'; 3 | export default optimisticMutator; 4 | -------------------------------------------------------------------------------- /src/realtime-engine.js: -------------------------------------------------------------------------------- 1 | 2 | import BaseEngine from './commons/base-engine'; 3 | 4 | import makeDebug from 'debug'; 5 | const debug = makeDebug('realtime-engine'); 6 | 7 | export default class RealtimeEngine extends BaseEngine { 8 | constructor (service, options = {}) { 9 | debug('constructor started'); 10 | 11 | super(service, options); 12 | 13 | debug('constructor ended'); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /test/commons/helpers/emitters.test.js: -------------------------------------------------------------------------------- 1 | 2 | const assert = require('chai').assert; 3 | const feathers = require('feathers'); 4 | const memory = require('feathers-memory'); 5 | const hooks = require('feathers-hooks'); 6 | 7 | const sampleLen = 5; 8 | 9 | let app; 10 | 11 | function services1 () { 12 | const app = this; 13 | 14 | app.configure(fromServiceNonPaginatedConfig); 15 | } 16 | 17 | function fromServiceNonPaginatedConfig () { 18 | const app = this; 19 | 20 | app.use('/from', memory({})); 21 | } 22 | 23 | export default function (Replicator, desc) { 24 | describe(`${desc} - emitters`, () => { 25 | let data; 26 | let fromService; 27 | let replicator; 28 | 29 | beforeEach(() => { 30 | app = feathers() 31 | .configure(hooks()) 32 | .configure(services1); 33 | 34 | fromService = app.service('from'); 35 | 36 | data = []; 37 | for (let i = 0, len = sampleLen; i < len; i += 1) { 38 | data.push({ id: i, uuid: 1000 + i, order: i }); 39 | } 40 | }); 41 | 42 | describe('without publication', () => { 43 | let events; 44 | 45 | beforeEach(() => { 46 | events = []; 47 | 48 | return fromService.create(clone(data)) 49 | .then(() => { 50 | replicator = new Replicator(fromService, { 51 | sort: Replicator.sort('order') 52 | }); 53 | replicator.on('events', (records, last) => { 54 | events[events.length] = last; 55 | }); 56 | }); 57 | }); 58 | 59 | it('create works', () => { 60 | return replicator.connect() 61 | .then(() => fromService.create({ id: 99, uuid: 1099, order: 99 })) 62 | .then(() => replicator.disconnect()) 63 | .then(() => { 64 | const records = replicator.store.records; 65 | data[sampleLen] = { id: 99, uuid: 1099, order: 99 }; 66 | 67 | assert.lengthOf(records, sampleLen + 1); 68 | assert.deepEqual(records, data); 69 | 70 | assert.deepEqual(events, [ 71 | { action: 'snapshot' }, 72 | { action: 'add-listeners' }, 73 | { source: 0, eventName: 'created', action: 'mutated', record: { id: 99, uuid: 1099, order: 99 } }, 74 | { action: 'remove-listeners' } 75 | ]); 76 | }); 77 | }); 78 | 79 | it('update works', () => { 80 | return replicator.connect() 81 | .then(() => fromService.update(0, { id: 0, uuid: 1000, order: 99 })) 82 | .then(() => { 83 | const records = replicator.store.records; 84 | data.splice(0, 1); 85 | data[data.length] = { id: 0, uuid: 1000, order: 99 }; 86 | 87 | assert.lengthOf(records, sampleLen); 88 | assert.deepEqual(records, data); 89 | 90 | assert.deepEqual(events, [ 91 | { action: 'snapshot' }, 92 | { action: 'add-listeners' }, 93 | { source: 0, eventName: 'updated', action: 'mutated', record: { id: 0, uuid: 1000, order: 99 } } 94 | ]); 95 | }); 96 | }); 97 | 98 | it('patch works', () => { 99 | return replicator.connect() 100 | .then(() => fromService.patch(1, { order: 99 })) 101 | .then(() => { 102 | const records = replicator.store.records; 103 | data.splice(1, 1); 104 | data[data.length] = { id: 1, uuid: 1001, order: 99 }; 105 | 106 | assert.lengthOf(records, sampleLen); 107 | assert.deepEqual(records, data); 108 | 109 | assert.deepEqual(events, [ 110 | { action: 'snapshot' }, 111 | { action: 'add-listeners' }, 112 | { source: 0, eventName: 'patched', action: 'mutated', record: { id: 1, uuid: 1001, order: 99 } } 113 | ]); 114 | }); 115 | }); 116 | 117 | it('remove works', () => { 118 | return replicator.connect() 119 | .then(() => fromService.remove(2)) 120 | .then(() => { 121 | const records = replicator.store.records; 122 | data.splice(2, 1); 123 | 124 | assert.lengthOf(records, sampleLen - 1); 125 | assert.deepEqual(records, data); 126 | 127 | assert.deepEqual(events, [ 128 | { action: 'snapshot' }, 129 | { action: 'add-listeners' }, 130 | { source: 0, eventName: 'removed', action: 'remove', record: { id: 2, uuid: 1002, order: 2 } } 131 | ]); 132 | }); 133 | }); 134 | }); 135 | 136 | describe('within publication', () => { 137 | const testLen = 4; 138 | let events; 139 | 140 | beforeEach(() => { 141 | events = []; 142 | 143 | return fromService.create(clone(data)) 144 | .then(() => { 145 | replicator = new Replicator(fromService, { 146 | sort: Replicator.sort('order'), 147 | publication: record => record.order <= 3.5 148 | }); 149 | 150 | data.splice(testLen); 151 | 152 | replicator.on('events', (records, last) => { 153 | events[events.length] = last; 154 | }); 155 | }); 156 | }); 157 | 158 | it('create works', () => { 159 | return replicator.connect() 160 | .then(() => fromService.create({ id: 99, uuid: 1099, order: 3.5 })) 161 | .then(() => { 162 | const records = replicator.store.records; 163 | data[testLen] = { id: 99, uuid: 1099, order: 3.5 }; 164 | 165 | assert.lengthOf(records, testLen + 1); 166 | assert.deepEqual(records, data); 167 | 168 | assert.deepEqual(events, [ 169 | { action: 'snapshot' }, 170 | { action: 'add-listeners' }, 171 | { source: 0, eventName: 'created', action: 'mutated', record: { id: 99, uuid: 1099, order: 3.5 } } 172 | ]); 173 | }); 174 | }); 175 | }); 176 | 177 | describe('outside publication', () => { 178 | const testLen = 4; 179 | let events; 180 | 181 | beforeEach(() => { 182 | events = []; 183 | 184 | return fromService.create(clone(data)) 185 | .then(() => { 186 | replicator = new Replicator(fromService, { 187 | sort: Replicator.sort('order'), 188 | publication: record => record.order <= 3.5 189 | }); 190 | 191 | data.splice(testLen); 192 | 193 | replicator.on('events', (records, last) => { 194 | events[events.length] = last; 195 | }); 196 | }); 197 | }); 198 | 199 | it('create works', () => { 200 | return replicator.connect() 201 | .then(() => fromService.create({ id: 99, uuid: 1099, order: 99 })) 202 | .then(() => { 203 | const records = replicator.store.records; 204 | 205 | assert.lengthOf(records, testLen); 206 | assert.deepEqual(records, data); 207 | 208 | assert.deepEqual(events, [ 209 | { action: 'snapshot' }, 210 | { action: 'add-listeners' } 211 | ]); 212 | }); 213 | }); 214 | }); 215 | 216 | describe('moving in/out publication', () => { 217 | const testLen = 4; 218 | let events; 219 | 220 | beforeEach(() => { 221 | events = []; 222 | 223 | return fromService.create(clone(data)) 224 | .then(() => { 225 | replicator = new Replicator(fromService, { 226 | sort: Replicator.sort('order'), 227 | publication: record => record.order <= 3.5 228 | }); 229 | 230 | data.splice(testLen); 231 | 232 | replicator.on('events', (records, last) => { 233 | events[events.length] = last; 234 | }); 235 | }); 236 | }); 237 | 238 | it('patching to without', () => { 239 | return replicator.connect() 240 | .then(() => fromService.patch(1, { order: 99 })) 241 | .then(() => { 242 | const records = replicator.store.records; 243 | data.splice(1, 1); 244 | 245 | assert.lengthOf(records, testLen - 1); 246 | assert.deepEqual(records, data); 247 | 248 | assert.deepEqual(events, [ 249 | { action: 'snapshot' }, 250 | { action: 'add-listeners' }, 251 | { source: 0, eventName: 'patched', action: 'left-pub', record: { id: 1, uuid: 1001, order: 99 } } 252 | ]); 253 | }); 254 | }); 255 | 256 | it('patching to within', () => { 257 | return replicator.connect() 258 | .then(() => fromService.patch(4, { order: 3.5 })) 259 | .then(() => { 260 | const records = replicator.store.records; 261 | data[testLen] = { id: 4, uuid: 1004, order: 3.5 }; 262 | 263 | assert.lengthOf(records, testLen + 1); 264 | assert.deepEqual(records, data); 265 | 266 | assert.deepEqual(events, [ 267 | { action: 'snapshot' }, 268 | { action: 'add-listeners' }, 269 | { source: 0, eventName: 'patched', action: 'mutated', record: { id: 4, uuid: 1004, order: 3.5 } } 270 | ]); 271 | }); 272 | }); 273 | }); 274 | }); 275 | } 276 | 277 | // Helpers 278 | 279 | function clone (obj) { 280 | return JSON.parse(JSON.stringify(obj)); 281 | } 282 | -------------------------------------------------------------------------------- /test/commons/helpers/optimistic-mutator-online.test.js: -------------------------------------------------------------------------------- 1 | 2 | const assert = require('chai').assert; 3 | const feathers = require('feathers'); 4 | const memory = require('feathers-memory'); 5 | const hooks = require('feathers-hooks'); 6 | const errors = require('feathers-errors'); 7 | 8 | const optimisticMutator = require('../../../src/optimistic-mutator'); 9 | 10 | const sampleLen = 5; 11 | 12 | let app; 13 | let clientService; 14 | 15 | function services1 () { 16 | app = this; 17 | 18 | app.configure(fromServiceNonPaginatedConfig); 19 | } 20 | 21 | function fromServiceNonPaginatedConfig () { 22 | const app = this; 23 | 24 | app.use('/from', memory({})); 25 | 26 | app.service('from').hooks({ 27 | before: { 28 | all: context => { 29 | if (context.params.query && context.params.query._fail) { 30 | throw new errors.BadRequest('Fail requested'); 31 | } 32 | } 33 | } 34 | }); 35 | } 36 | 37 | export default function (Replicator, desc) { 38 | describe(`${desc} - optimistic mutation online`, () => { 39 | let data; 40 | let fromService; 41 | let replicator; 42 | 43 | beforeEach(() => { 44 | const app = feathers() 45 | .configure(hooks()) 46 | .configure(services1); 47 | 48 | fromService = app.service('from'); 49 | 50 | data = []; 51 | for (let i = 0, len = sampleLen; i < len; i += 1) { 52 | data.push({ id: i, uuid: 1000 + i, order: i }); 53 | } 54 | }); 55 | 56 | describe('not connected', () => { 57 | let events; 58 | 59 | beforeEach(() => { 60 | events = []; 61 | 62 | return fromService.create(clone(data)) 63 | .then(() => { 64 | replicator = new Replicator(fromService, { sort: Replicator.sort('order'), uuid: true }); 65 | 66 | app.use('clientService', optimisticMutator({ replicator })); 67 | 68 | clientService = app.service('clientService'); 69 | 70 | replicator.on('events', (records, last) => { 71 | events[events.length] = last; 72 | }); 73 | }); 74 | }); 75 | 76 | it('create fails', () => { 77 | return clientService.create({ id: 99, uuid: 1099, order: 99 }) 78 | .then(() => { 79 | assert(false, 'Unexpectedly succeeded.'); 80 | }) 81 | .catch(err => { 82 | assert.equal(err.className, 'bad-request'); 83 | }); 84 | }); 85 | }); 86 | 87 | describe('without publication', () => { 88 | let events; 89 | 90 | beforeEach(() => { 91 | events = []; 92 | 93 | return fromService.create(clone(data)) 94 | .then(() => { 95 | replicator = new Replicator(fromService, { sort: Replicator.sort('order'), uuid: true }); 96 | 97 | app.use('clientService', optimisticMutator({ replicator })); 98 | 99 | clientService = app.service('clientService'); 100 | 101 | replicator.on('events', (records, last) => { 102 | events[events.length] = last; 103 | }); 104 | }); 105 | }); 106 | 107 | it('find works', () => { 108 | return replicator.connect() 109 | .then(() => clientService.find({ query: { order: { $lt: 3 } } })) 110 | .then(result => { 111 | const records = replicator.store.records; 112 | 113 | assert.deepEqual(result, data.slice(0, 3)); 114 | assert.deepEqual(events, [ 115 | { action: 'snapshot' }, 116 | { action: 'add-listeners' } 117 | ]); 118 | 119 | assert.lengthOf(records, sampleLen); 120 | assert.deepEqual(records, data); 121 | }) 122 | .then(() => replicator.disconnect()); 123 | }); 124 | 125 | it('get works', () => { 126 | return replicator.connect() 127 | .then(() => clientService.get(1000)) 128 | .then(result => { 129 | const records = replicator.store.records; 130 | 131 | assert.deepEqual(result, { id: 0, uuid: 1000, order: 0 }); 132 | assert.deepEqual(events, [ 133 | { action: 'snapshot' }, 134 | { action: 'add-listeners' } 135 | ]); 136 | 137 | assert.lengthOf(records, sampleLen); 138 | assert.deepEqual(records, data); 139 | }) 140 | .then(() => replicator.disconnect()); 141 | }); 142 | 143 | it('create works', () => { 144 | return replicator.connect() 145 | .then(() => clientService.create({ id: 99, uuid: 1099, order: 99 })) 146 | .then(delay()) 147 | .then(result => { 148 | const records = replicator.store.records; 149 | 150 | data[sampleLen] = { id: 99, uuid: 1099, order: 99 }; 151 | 152 | assert.deepEqual(result, { id: 99, uuid: 1099, order: 99 }); 153 | assert.deepEqual(events, [ 154 | { action: 'snapshot' }, 155 | { action: 'add-listeners' }, 156 | { source: 1, eventName: 'created', action: 'mutated', record: { id: 99, uuid: 1099, order: 99 } }, 157 | { source: 0, eventName: 'created', action: 'mutated', record: { id: 99, uuid: 1099, order: 99 } } 158 | ]); 159 | 160 | assert.lengthOf(records, sampleLen + 1); 161 | assert.deepEqual(records, data); 162 | }) 163 | .then(() => replicator.disconnect()); 164 | }); 165 | 166 | it('create adds missing uuid', () => { 167 | return replicator.connect() 168 | .then(() => clientService.create({ id: 99, order: 99 })) 169 | .then(data => { 170 | assert.isString(data.uuid); 171 | }) 172 | .then(() => replicator.disconnect()); 173 | }); 174 | 175 | it('update works', () => { 176 | return replicator.connect() 177 | .then(() => clientService.update(1000, { id: 0, uuid: 1000, order: 99 })) 178 | .then(delay()) 179 | .then(result => { 180 | const records = replicator.store.records; 181 | data.splice(0, 1); 182 | data[data.length] = { id: 0, uuid: 1000, order: 99 }; 183 | 184 | assert.deepEqual(result, { id: 0, uuid: 1000, order: 99 }); 185 | assert.lengthOf(records, sampleLen); 186 | assert.deepEqual(records, data); 187 | 188 | assert.deepEqual(events, [ 189 | { action: 'snapshot' }, 190 | { action: 'add-listeners' }, 191 | { source: 1, eventName: 'updated', action: 'mutated', record: { id: 0, uuid: 1000, order: 99 } }, 192 | { source: 0, eventName: 'updated', action: 'mutated', record: { id: 0, uuid: 1000, order: 99 } } 193 | ]); 194 | }); 195 | }); 196 | 197 | it('patch works', () => { 198 | return replicator.connect() 199 | .then(() => clientService.patch(1001, { order: 99 })) 200 | .then(delay()) 201 | .then(result => { 202 | const records = replicator.store.records; 203 | data.splice(1, 1); 204 | data[data.length] = { id: 1, uuid: 1001, order: 99 }; 205 | 206 | assert.deepEqual(result, { id: 1, uuid: 1001, order: 99 }); 207 | assert.lengthOf(records, sampleLen); 208 | assert.deepEqual(records, data); 209 | 210 | assert.deepEqual(events, [ 211 | { action: 'snapshot' }, 212 | { action: 'add-listeners' }, 213 | { source: 1, eventName: 'patched', action: 'mutated', record: { id: 1, uuid: 1001, order: 99 } }, 214 | { source: 0, eventName: 'patched', action: 'mutated', record: { id: 1, uuid: 1001, order: 99 } } 215 | ]); 216 | }); 217 | }); 218 | 219 | it('remove works', () => { 220 | return replicator.connect() 221 | .then(() => clientService.remove(1002)) 222 | .then(delay()) 223 | .then(result => { 224 | const records = replicator.store.records; 225 | data.splice(2, 1); 226 | 227 | assert.deepEqual(result, { id: 2, uuid: 1002, order: 2 }); 228 | assert.lengthOf(records, sampleLen - 1); 229 | assert.deepEqual(records, data); 230 | 231 | assert.deepEqual(events, [ 232 | { action: 'snapshot' }, 233 | { action: 'add-listeners' }, 234 | { source: 1, eventName: 'removed', action: 'remove', record: { id: 2, uuid: 1002, order: 2 } }, 235 | { source: 0, eventName: 'removed', action: 'remove', record: { id: 2, uuid: 1002, order: 2 } } 236 | ]); 237 | }); 238 | }); 239 | }); 240 | 241 | describe('without publication, null id', () => { 242 | let events; 243 | 244 | beforeEach(() => { 245 | events = []; 246 | 247 | return fromService.create(clone(data)) 248 | .then(() => { 249 | replicator = new Replicator(fromService, {sort: Replicator.sort('order'), uuid: true}); 250 | 251 | app.use('clientService', optimisticMutator({replicator})); 252 | 253 | clientService = app.service('clientService'); 254 | 255 | replicator.on('events', (records, last) => { 256 | events[events.length] = last; 257 | }); 258 | }); 259 | }); 260 | 261 | it('create works', () => { 262 | return replicator.connect() 263 | .then(() => clientService.create([ 264 | { id: 98, uuid: 1098, order: 98 }, 265 | { id: 99, uuid: 1099, order: 99 } 266 | ])) 267 | .then(delay()) 268 | .then(result => { 269 | const records = replicator.store.records; 270 | 271 | data[sampleLen] = { id: 98, uuid: 1098, order: 98 }; 272 | data[sampleLen + 1] = { id: 99, uuid: 1099, order: 99 }; 273 | 274 | assert.deepEqual(result, [ 275 | { id: 98, uuid: 1098, order: 98 }, 276 | { id: 99, uuid: 1099, order: 99 } 277 | ]); 278 | assert.deepEqual(events, [ 279 | { action: 'snapshot' }, 280 | { action: 'add-listeners' }, 281 | { source: 1, eventName: 'created', action: 'mutated', record: { id: 98, uuid: 1098, order: 98 } }, 282 | { source: 1, eventName: 'created', action: 'mutated', record: { id: 99, uuid: 1099, order: 99 } }, 283 | { source: 0, eventName: 'created', action: 'mutated', record: { id: 98, uuid: 1098, order: 98 } }, 284 | { source: 0, eventName: 'created', action: 'mutated', record: { id: 99, uuid: 1099, order: 99 } } 285 | ]); 286 | 287 | assert.lengthOf(records, sampleLen + 2); 288 | assert.deepEqual(records, data); 289 | }) 290 | .then(() => replicator.disconnect()); 291 | }); 292 | 293 | it('patch works', () => { 294 | return replicator.connect() 295 | .then(() => clientService.patch(null, { foo: 1 }, { query: { order: { $gt: 0, $lt: 4 } } })) 296 | .then(delay()) 297 | .then(result => { 298 | const records = replicator.store.records; 299 | 300 | data[1].foo = 1; 301 | data[2].foo = 1; 302 | data[3].foo = 1; 303 | 304 | assert.deepEqual(result, [ 305 | { id: 1, uuid: 1001, order: 1, foo: 1 }, 306 | { id: 2, uuid: 1002, order: 2, foo: 1 }, 307 | { id: 3, uuid: 1003, order: 3, foo: 1 } 308 | ]); 309 | 310 | assert.lengthOf(records, sampleLen); 311 | assert.deepEqual(records, data); 312 | 313 | assert.deepEqual(events, [ 314 | { action: 'snapshot' }, 315 | { action: 'add-listeners' }, 316 | { source: 1, eventName: 'patched', action: 'mutated', record: { id: 1, uuid: 1001, order: 1, foo: 1 } }, 317 | { source: 1, eventName: 'patched', action: 'mutated', record: { id: 2, uuid: 1002, order: 2, foo: 1 } }, 318 | { source: 1, eventName: 'patched', action: 'mutated', record: { id: 3, uuid: 1003, order: 3, foo: 1 } }, 319 | { source: 0, eventName: 'patched', action: 'mutated', record: { id: 1, uuid: 1001, order: 1, foo: 1 } }, 320 | { source: 0, eventName: 'patched', action: 'mutated', record: { id: 2, uuid: 1002, order: 2, foo: 1 } }, 321 | { source: 0, eventName: 'patched', action: 'mutated', record: { id: 3, uuid: 1003, order: 3, foo: 1 } } 322 | ]); 323 | }); 324 | }); 325 | 326 | it('remove works', () => { 327 | return replicator.connect() 328 | .then(() => clientService.remove(null, { query: { order: { $gt: 0, $lt: 4 } } })) 329 | .then(delay()) 330 | .then(result => { 331 | const records = replicator.store.records; 332 | data.splice(1, 3); 333 | 334 | assert.deepEqual(result, [ 335 | { id: 1, uuid: 1001, order: 1 }, 336 | { id: 2, uuid: 1002, order: 2 }, 337 | { id: 3, uuid: 1003, order: 3 } 338 | ]); 339 | 340 | assert.lengthOf(records, sampleLen - 3); 341 | assert.deepEqual(records, data); 342 | 343 | assert.deepEqual(events, [ 344 | { action: 'snapshot' }, 345 | { action: 'add-listeners' }, 346 | { source: 1, eventName: 'removed', action: 'remove', record: { id: 1, uuid: 1001, order: 1 } }, 347 | { source: 1, eventName: 'removed', action: 'remove', record: { id: 2, uuid: 1002, order: 2 } }, 348 | { source: 1, eventName: 'removed', action: 'remove', record: { id: 3, uuid: 1003, order: 3 } }, 349 | { source: 0, eventName: 'removed', action: 'remove', record: { id: 1, uuid: 1001, order: 1 } }, 350 | { source: 0, eventName: 'removed', action: 'remove', record: { id: 2, uuid: 1002, order: 2 } }, 351 | { source: 0, eventName: 'removed', action: 'remove', record: { id: 3, uuid: 1003, order: 3 } } 352 | ]); 353 | }); 354 | }); 355 | }); 356 | 357 | describe('without publication & remote error', () => { 358 | let events; 359 | 360 | beforeEach(() => { 361 | events = []; 362 | 363 | return fromService.create(clone(data)) 364 | .then(() => { 365 | replicator = new Replicator(fromService, { sort: Replicator.sort('order'), uuid: true }); 366 | 367 | app.use('clientService', optimisticMutator({ replicator })); 368 | 369 | clientService = app.service('clientService'); 370 | 371 | replicator.on('events', (records, last) => { 372 | events[events.length] = last; 373 | }); 374 | }); 375 | }); 376 | 377 | it('get fails correctly', () => { 378 | return replicator.connect() 379 | .then(() => clientService.get(9999)) 380 | .then(() => { 381 | assert(false, 'Unexpectedly succeeded'); 382 | }) 383 | .catch(err => { 384 | assert.equal(err.className, 'not-found'); 385 | }) 386 | .then(() => replicator.disconnect()); 387 | }); 388 | 389 | it('create recovers', () => { 390 | return replicator.connect() 391 | .then(() => clientService.create({ id: 99, uuid: 1099, order: 99 }, { query: { _fail: true } })) 392 | .then(delay()) 393 | .then(() => { 394 | const records = replicator.store.records; 395 | 396 | assert.deepEqual(events, [ 397 | { action: 'snapshot' }, 398 | { action: 'add-listeners' }, 399 | { source: 1, eventName: 'created', action: 'mutated', record: { id: 99, uuid: 1099, order: 99 } }, 400 | { source: 2, eventName: 'removed', action: 'remove', record: { id: 99, uuid: 1099, order: 99 } } 401 | ]); 402 | 403 | assert.lengthOf(records, sampleLen); 404 | assert.deepEqual(records, data); 405 | }) 406 | .then(() => replicator.disconnect()); 407 | }); 408 | 409 | it('update recovers', () => { 410 | return replicator.connect() 411 | .then(() => clientService.update(1000, { id: 0, uuid: 1000, order: 99 }, { query: { _fail: true } })) 412 | .then(delay()) 413 | .then(() => { 414 | const records = replicator.store.records; 415 | 416 | assert.deepEqual(events, [ 417 | { action: 'snapshot' }, 418 | { action: 'add-listeners' }, 419 | { source: 1, eventName: 'updated', action: 'mutated', record: { id: 0, uuid: 1000, order: 99 } }, 420 | { source: 2, eventName: 'updated', action: 'mutated', record: { id: 0, uuid: 1000, order: 0 } } 421 | ]); 422 | 423 | assert.lengthOf(records, sampleLen); 424 | assert.deepEqual(records, data); 425 | }) 426 | .then(() => replicator.disconnect()); 427 | }); 428 | 429 | it('patch recovers', () => { 430 | return replicator.connect() 431 | .then(() => clientService.patch(1001, { order: 99 }, { query: { _fail: true } })) 432 | .then(delay()) 433 | .then(() => { 434 | const records = replicator.store.records; 435 | 436 | assert.deepEqual(events, [ 437 | { action: 'snapshot' }, 438 | { action: 'add-listeners' }, 439 | { source: 1, eventName: 'patched', action: 'mutated', record: { id: 1, uuid: 1001, order: 99 } }, 440 | { source: 2, eventName: 'updated', action: 'mutated', record: { id: 1, uuid: 1001, order: 1 } } 441 | ]); 442 | 443 | assert.lengthOf(records, sampleLen); 444 | assert.deepEqual(records, data); 445 | }) 446 | .then(() => replicator.disconnect()); 447 | }); 448 | 449 | it('remove recovers', () => { 450 | return replicator.connect() 451 | .then(() => clientService.remove(1002, { query: { _fail: true } })) 452 | .then(delay()) 453 | .then(() => { 454 | const records = replicator.store.records; 455 | 456 | assert.deepEqual(events, [ 457 | { action: 'snapshot' }, 458 | { action: 'add-listeners' }, 459 | { source: 1, eventName: 'removed', action: 'remove', record: { id: 2, uuid: 1002, order: 2 } }, 460 | { source: 2, eventName: 'created', action: 'mutated', record: { id: 2, uuid: 1002, order: 2 } } 461 | ]); 462 | 463 | assert.lengthOf(records, sampleLen); 464 | assert.deepEqual(records, data); 465 | }) 466 | .then(() => replicator.disconnect()); 467 | }); 468 | }); 469 | }); 470 | } 471 | 472 | // Helpers 473 | 474 | function clone (obj) { 475 | return JSON.parse(JSON.stringify(obj)); 476 | } 477 | 478 | function delay (ms = 0) { 479 | return data => new Promise(resolve => { 480 | setTimeout(() => { 481 | resolve(data); 482 | }, ms); 483 | }); 484 | } 485 | -------------------------------------------------------------------------------- /test/commons/helpers/replicator.test.js: -------------------------------------------------------------------------------- 1 | 2 | const assert = require('chai').assert; 3 | const feathers = require('feathers'); 4 | const memory = require('feathers-memory'); 5 | const hooks = require('feathers-hooks'); 6 | 7 | const sampleLen = 5; 8 | let app; 9 | 10 | function services1 () { 11 | const app = this; 12 | 13 | app.configure(fromServiceNonPaginatedConfig); 14 | } 15 | 16 | function fromServiceNonPaginatedConfig () { 17 | const app = this; 18 | 19 | app.use('/from', memory({})); 20 | } 21 | 22 | export default function (Replicator, desc, useUuid) { 23 | describe(`${desc} - replicator ${useUuid ? 'using uuid' : 'using id'}`, () => { 24 | let data; 25 | let fromService; 26 | let replicator; 27 | 28 | beforeEach(() => { 29 | app = feathers() 30 | .configure(hooks()) 31 | .configure(services1); 32 | 33 | fromService = app.service('from'); 34 | 35 | data = []; 36 | for (let i = 0, len = sampleLen; i < len; i += 1) { 37 | data.push({ id: i, uuid: 1000 + i, order: i }); 38 | } 39 | }); 40 | 41 | describe('cryptographic', () => { 42 | beforeEach(() => { 43 | return fromService.create(clone(data)) 44 | .then(() => { 45 | replicator = new Replicator(fromService, { sort: Replicator.sort('order'), useUuid }); 46 | }); 47 | }); 48 | 49 | it('defaults to short uuid', () => { 50 | const uuid = replicator.getUuid(); 51 | 52 | assert.isString(uuid); 53 | assert.isAtMost(uuid.length, 15); 54 | }); 55 | 56 | it('can get long uuid', () => { 57 | replicator.useShortUuid(false); 58 | const uuid = replicator.getUuid(); 59 | 60 | assert.isString(uuid); 61 | assert.lengthOf(uuid, 36); 62 | }); 63 | 64 | it('can get short uuid', () => { 65 | replicator.useShortUuid(true); 66 | const uuid = replicator.getUuid(); 67 | 68 | assert.isString(uuid); 69 | assert.isAtMost(uuid.length, 15); 70 | }); 71 | }); 72 | }); 73 | } 74 | 75 | // Helpers 76 | 77 | function clone (obj) { 78 | return JSON.parse(JSON.stringify(obj)); 79 | } 80 | -------------------------------------------------------------------------------- /test/commons/helpers/service-events.test.js: -------------------------------------------------------------------------------- 1 | 2 | const assert = require('chai').assert; 3 | const feathers = require('feathers'); 4 | const memory = require('feathers-memory'); 5 | const hooks = require('feathers-hooks'); 6 | 7 | const sampleLen = 5; 8 | 9 | let app; 10 | 11 | function services1 () { 12 | const app = this; 13 | 14 | app.configure(fromServiceNonPaginatedConfig); 15 | } 16 | 17 | function fromServiceNonPaginatedConfig () { 18 | const app = this; 19 | 20 | app.use('/from', memory({})); 21 | } 22 | 23 | export default function (Replicator, desc, useUuid) { 24 | describe(`${desc} - mutations ${useUuid ? 'using uuid' : 'using id'}`, () => { 25 | let data; 26 | let fromService; 27 | let replicator; 28 | 29 | beforeEach(() => { 30 | app = feathers() 31 | .configure(hooks()) 32 | .configure(services1); 33 | 34 | fromService = app.service('from'); 35 | 36 | data = []; 37 | for (let i = 0, len = sampleLen; i < len; i += 1) { 38 | data.push({ id: i, uuid: 1000 + i, order: i }); 39 | } 40 | }); 41 | 42 | describe('without publication', () => { 43 | beforeEach(() => { 44 | return fromService.create(clone(data)) 45 | .then(() => { 46 | replicator = new Replicator(fromService, { sort: Replicator.sort('order'), useUuid }); 47 | }); 48 | }); 49 | 50 | it('create works', () => { 51 | return replicator.connect() 52 | .then(() => fromService.create({ id: 99, uuid: 1099, order: 99 })) 53 | .then(() => { 54 | const records = replicator.store.records; 55 | data[sampleLen] = { id: 99, uuid: 1099, order: 99 }; 56 | 57 | assert.lengthOf(records, sampleLen + 1); 58 | assert.deepEqual(records, data); 59 | }); 60 | }); 61 | 62 | it('update works', () => { 63 | return replicator.connect() 64 | .then(() => fromService.update(0, { id: 0, uuid: 1000, order: 99 })) 65 | .then(() => { 66 | const records = replicator.store.records; 67 | data.splice(0, 1); 68 | data[data.length] = { id: 0, uuid: 1000, order: 99 }; 69 | 70 | assert.lengthOf(records, sampleLen); 71 | assert.deepEqual(records, data); 72 | }); 73 | }); 74 | 75 | it('patch works', () => { 76 | return replicator.connect() 77 | .then(() => fromService.patch(1, { order: 99 })) 78 | .then(() => { 79 | const records = replicator.store.records; 80 | data.splice(1, 1); 81 | data[data.length] = { id: 1, uuid: 1001, order: 99 }; 82 | 83 | assert.lengthOf(records, sampleLen); 84 | assert.deepEqual(records, data); 85 | }); 86 | }); 87 | 88 | it('remove works', () => { 89 | return replicator.connect() 90 | .then(() => fromService.remove(2)) 91 | .then(() => { 92 | const records = replicator.store.records; 93 | data.splice(2, 1); 94 | 95 | assert.lengthOf(records, sampleLen - 1); 96 | assert.deepEqual(records, data); 97 | }); 98 | }); 99 | }); 100 | 101 | describe('within publication', () => { 102 | const testLen = 4; 103 | 104 | beforeEach(() => { 105 | return fromService.create(clone(data)) 106 | .then(() => { 107 | replicator = new Replicator(fromService, { 108 | sort: Replicator.sort('order'), 109 | publication: record => record.order <= 3.5, 110 | useUuid 111 | }); 112 | 113 | data.splice(testLen); 114 | }); 115 | }); 116 | 117 | it('create works', () => { 118 | return replicator.connect() 119 | .then(() => fromService.create({ id: 99, uuid: 1099, order: 3.5 })) 120 | .then(() => { 121 | const records = replicator.store.records; 122 | data[testLen] = { id: 99, uuid: 1099, order: 3.5 }; 123 | 124 | assert.lengthOf(records, testLen + 1); 125 | assert.deepEqual(records, data); 126 | }); 127 | }); 128 | 129 | it('update works', () => { 130 | return replicator.connect() 131 | .then(() => fromService.update(0, { id: 0, uuid: 1000, order: 3.5 })) 132 | .then(() => { 133 | const records = replicator.store.records; 134 | data.splice(0, 1); 135 | data[data.length] = { id: 0, uuid: 1000, order: 3.5 }; 136 | 137 | assert.lengthOf(records, testLen); 138 | assert.deepEqual(records, data); 139 | }); 140 | }); 141 | 142 | it('patch works', () => { 143 | return replicator.connect() 144 | .then(() => fromService.patch(1, { order: 1.1 })) 145 | .then(() => { 146 | const records = replicator.store.records; 147 | data[1] = { id: 1, uuid: 1001, order: 1.1 }; 148 | 149 | assert.lengthOf(records, testLen); 150 | assert.deepEqual(records, data); 151 | }); 152 | }); 153 | 154 | it('remove works', () => { 155 | return replicator.connect() 156 | .then(() => fromService.remove(2)) 157 | .then(() => { 158 | const records = replicator.store.records; 159 | data.splice(2, 1); 160 | 161 | assert.lengthOf(records, testLen - 1); 162 | assert.deepEqual(records, data); 163 | }); 164 | }); 165 | }); 166 | 167 | describe('outside publication', () => { 168 | const testLen = 4; 169 | 170 | beforeEach(() => { 171 | return fromService.create(clone(data)) 172 | .then(() => { 173 | replicator = new Replicator(fromService, { 174 | sort: Replicator.sort('order'), 175 | publication: record => record.order <= 3.5, 176 | useUuid 177 | }); 178 | 179 | data.splice(testLen); 180 | }); 181 | }); 182 | 183 | it('create works', () => { 184 | return replicator.connect() 185 | .then(() => fromService.create({ id: 99, uuid: 1099, order: 99 })) 186 | .then(() => { 187 | const records = replicator.store.records; 188 | 189 | assert.lengthOf(records, testLen); 190 | assert.deepEqual(records, data); 191 | }); 192 | }); 193 | 194 | it('update works', () => { 195 | return replicator.connect() 196 | .then(() => fromService.update(4, { id: 4, uuid: 1004, order: 99 })) 197 | .then(() => { 198 | const records = replicator.store.records; 199 | 200 | assert.lengthOf(records, testLen); 201 | assert.deepEqual(records, data); 202 | }); 203 | }); 204 | 205 | it('patch works', () => { 206 | return replicator.connect() 207 | .then(() => fromService.patch(4, { order: 99 })) 208 | .then(() => { 209 | const records = replicator.store.records; 210 | 211 | assert.lengthOf(records, testLen); 212 | assert.deepEqual(records, data); 213 | }); 214 | }); 215 | 216 | it('remove works', () => { 217 | return replicator.connect() 218 | .then(() => fromService.remove(4)) 219 | .then(() => { 220 | const records = replicator.store.records; 221 | 222 | assert.lengthOf(records, testLen); 223 | assert.deepEqual(records, data); 224 | }); 225 | }); 226 | }); 227 | 228 | describe('moving in/out publication', () => { 229 | const testLen = 4; 230 | 231 | beforeEach(() => { 232 | return fromService.create(clone(data)) 233 | .then(() => { 234 | replicator = new Replicator(fromService, { 235 | sort: Replicator.sort('order'), 236 | publication: record => record.order <= 3.5, 237 | useUuid 238 | }); 239 | 240 | data.splice(testLen); 241 | }); 242 | }); 243 | 244 | it('patching to without', () => { 245 | return replicator.connect() 246 | .then(() => fromService.patch(1, { order: 99 })) 247 | .then(() => { 248 | const records = replicator.store.records; 249 | data.splice(1, 1); 250 | 251 | assert.lengthOf(records, testLen - 1); 252 | assert.deepEqual(records, data); 253 | }); 254 | }); 255 | 256 | it('patching to within', () => { 257 | return replicator.connect() 258 | .then(() => fromService.patch(4, { order: 3.5 })) 259 | .then(() => { 260 | const records = replicator.store.records; 261 | data[testLen] = { id: 4, uuid: 1004, order: 3.5 }; 262 | 263 | assert.lengthOf(records, testLen + 1); 264 | assert.deepEqual(records, data); 265 | }); 266 | }); 267 | }); 268 | }); 269 | } 270 | 271 | // Helpers 272 | 273 | function clone (obj) { 274 | return JSON.parse(JSON.stringify(obj)); 275 | } 276 | -------------------------------------------------------------------------------- /test/commons/helpers/snapshot.test.js: -------------------------------------------------------------------------------- 1 | 2 | const assert = require('chai').assert; 3 | const feathers = require('feathers'); 4 | const memory = require('feathers-memory'); 5 | const hooks = require('feathers-hooks'); 6 | 7 | const sampleLen = 25; 8 | 9 | let app; 10 | let data; 11 | let fromService; 12 | let fromServicePaginated; 13 | 14 | function services1 () { 15 | const app = this; 16 | 17 | app.configure(fromServiceNonPaginatedConfig); 18 | app.configure(fromServicePaginatedConfig); 19 | } 20 | 21 | function fromServiceNonPaginatedConfig () { 22 | const app = this; 23 | 24 | app.use('/from', memory({})); 25 | } 26 | 27 | function fromServicePaginatedConfig () { 28 | const app = this; 29 | 30 | app.use('/frompaginated', memory({ 31 | paginate: { 32 | default: 2, 33 | max: 3 34 | } 35 | })); 36 | } 37 | 38 | export default function (Replicator, desc) { 39 | describe(`${desc} - snapshot`, () => { 40 | describe('sorts', () => { 41 | let dataOrder; 42 | let dataId; 43 | let dataIdOrder; 44 | let dataIdXOrder; 45 | 46 | beforeEach(() => { 47 | data = [ 48 | { id: 'q', order: 5 }, 49 | { id: 'a', order: 9 }, 50 | { id: 'z', order: 1 }, 51 | { id: 'q', order: 3 } 52 | ]; 53 | 54 | dataOrder = [ 55 | { id: 'z', order: 1 }, 56 | { id: 'q', order: 3 }, 57 | { id: 'q', order: 5 }, 58 | { id: 'a', order: 9 } 59 | ]; 60 | 61 | dataId = [ 62 | { id: 'a', order: 9 }, 63 | { id: 'q', order: 5 }, 64 | { id: 'q', order: 3 }, 65 | { id: 'z', order: 1 } 66 | ]; 67 | 68 | dataIdOrder = [ 69 | { id: 'a', order: 9 }, 70 | { id: 'q', order: 3 }, 71 | { id: 'q', order: 5 }, 72 | { id: 'z', order: 1 } 73 | ]; 74 | 75 | dataIdXOrder = [ 76 | { id: 'z', order: 1 }, 77 | { id: 'q', order: 3 }, 78 | { id: 'q', order: 5 }, 79 | { id: 'a', order: 9 } 80 | ]; 81 | }); 82 | 83 | it('single sort works', () => { 84 | assert.deepEqual(data.sort(Replicator.sort('order')), dataOrder); 85 | }); 86 | 87 | it('single sort is stable', () => { 88 | assert.deepEqual(data.sort(Replicator.sort('id')), dataId); 89 | }); 90 | 91 | it('multiple sort works', () => { 92 | assert.deepEqual(data.sort(Replicator.multiSort({ id: 1, order: 1 })), dataIdOrder); 93 | }); 94 | 95 | it('multiple sort order works', () => { 96 | assert.deepEqual(data.sort(Replicator.multiSort({ id: -1, order: 1 })), dataIdXOrder); 97 | }); 98 | }); 99 | 100 | describe('snapshot', () => { 101 | beforeEach(() => { 102 | app = feathers() 103 | .configure(hooks()) 104 | .configure(services1); 105 | 106 | fromService = app.service('from'); 107 | fromServicePaginated = app.service('frompaginated'); 108 | 109 | data = []; 110 | for (let i = 0, len = sampleLen; i < len; i += 1) { 111 | data.push({ id: i, order: i }); 112 | } 113 | 114 | return Promise.all([ 115 | fromService.create(data), 116 | fromServicePaginated.create(data) 117 | ]); 118 | }); 119 | 120 | it('non-paginated file', () => { 121 | const replicator = new Replicator(fromService); 122 | 123 | assert.equal(replicator.connected, false); 124 | 125 | return replicator.connect() 126 | .then(() => { 127 | const records = replicator.store.records; 128 | assert.lengthOf(records, sampleLen); 129 | 130 | assert.deepEqual(records.sort(Replicator.sort('order')), data); 131 | 132 | assert.equal(replicator.connected, true); 133 | }); 134 | }); 135 | 136 | it('paginated file', () => { 137 | const replicator = new Replicator(fromServicePaginated); 138 | 139 | return replicator.connect() 140 | .then(() => { 141 | const records = replicator.store.records; 142 | assert.lengthOf(records, sampleLen); 143 | 144 | assert.deepEqual(records.sort(Replicator.sort('order')), data); 145 | }); 146 | }); 147 | 148 | it('query works', () => { 149 | const query = { order: { $lt: 15 } }; 150 | const replicator = new Replicator(fromServicePaginated, { query }); 151 | 152 | return replicator.connect() 153 | .then(() => { 154 | const records = replicator.store.records; 155 | assert.lengthOf(records, 15); 156 | 157 | assert.deepEqual(records.sort(Replicator.sort('order')), data.slice(0, 15)); 158 | }); 159 | }); 160 | 161 | it('publication works', () => { 162 | const query = { order: { $lt: 15 } }; 163 | const publication = record => record.order < 10; 164 | const replicator = new Replicator(fromService, { query, publication }); 165 | 166 | return replicator.connect() 167 | .then(() => { 168 | const records = replicator.store.records; 169 | assert.lengthOf(records, 10); 170 | 171 | assert.deepEqual(records.sort(Replicator.sort('order')), data.slice(0, 10)); 172 | }); 173 | }); 174 | 175 | it('sort works', () => { 176 | const query = { order: { $lt: 15 } }; 177 | const publication = record => record.order < 10; 178 | const replicator = new Replicator(fromService, { 179 | query, publication, sort: Replicator.sort('order') 180 | }); 181 | 182 | return replicator.connect() 183 | .then(() => { 184 | const records = replicator.store.records; 185 | assert.lengthOf(records, 10); 186 | 187 | assert.deepEqual(records, data.slice(0, 10)); 188 | }); 189 | }); 190 | 191 | it('change sort works', () => { 192 | const query = { order: { $lt: 15 } }; 193 | const publication = record => record.order < 10; 194 | const replicator = new Replicator(fromService, { 195 | query, publication, sort: Replicator.sort('order') 196 | }); 197 | 198 | return replicator.connect() 199 | .then(() => { 200 | replicator.changeSort(Replicator.multiSort({ id: -1 })); 201 | 202 | const records = replicator.store.records; 203 | assert.lengthOf(records, 10); 204 | 205 | assert.deepEqual(records, data.slice(0, 10).sort( 206 | (a, b) => a.id > b.id ? -1 : (a.id < b.id ? 1 : 0) 207 | )); 208 | }); 209 | }); 210 | }); 211 | }); 212 | } 213 | -------------------------------------------------------------------------------- /test/commons/helpers/subscribers.test.js: -------------------------------------------------------------------------------- 1 | 2 | const assert = require('chai').assert; 3 | const feathers = require('feathers'); 4 | const memory = require('feathers-memory'); 5 | const hooks = require('feathers-hooks'); 6 | 7 | const sampleLen = 5; 8 | 9 | let app; 10 | 11 | function services1 () { 12 | const app = this; 13 | 14 | app.configure(fromServiceNonPaginatedConfig); 15 | } 16 | 17 | function fromServiceNonPaginatedConfig () { 18 | const app = this; 19 | 20 | app.use('/from', memory({})); 21 | } 22 | 23 | export default function (Replicator, desc) { 24 | describe(`${desc} - subscribers`, () => { 25 | let data; 26 | let fromService; 27 | let replicator; 28 | 29 | beforeEach(() => { 30 | app = feathers() 31 | .configure(hooks()) 32 | .configure(services1); 33 | 34 | fromService = app.service('from'); 35 | 36 | data = []; 37 | for (let i = 0, len = sampleLen; i < len; i += 1) { 38 | data.push({ id: i, uuid: 1000 + i, order: i }); 39 | } 40 | }); 41 | 42 | describe('without publication', () => { 43 | let events; 44 | 45 | beforeEach(() => { 46 | events = []; 47 | 48 | return fromService.create(clone(data)) 49 | .then(() => { 50 | replicator = new Replicator(fromService, { 51 | sort: Replicator.sort('order'), 52 | subscriber: (records, last) => { events[events.length] = last; } 53 | }); 54 | }); 55 | }); 56 | 57 | it('create works', () => { 58 | return replicator.connect() 59 | .then(() => fromService.create({ id: 99, uuid: 1099, order: 99 })) 60 | .then(() => replicator.disconnect()) 61 | .then(() => { 62 | const records = replicator.store.records; 63 | data[sampleLen] = { id: 99, uuid: 1099, order: 99 }; 64 | 65 | assert.lengthOf(records, sampleLen + 1); 66 | assert.deepEqual(records, data); 67 | 68 | assert.deepEqual(events, [ 69 | { action: 'snapshot' }, 70 | { action: 'add-listeners' }, 71 | { source: 0, eventName: 'created', action: 'mutated', record: { id: 99, uuid: 1099, order: 99 } }, 72 | { action: 'remove-listeners' } 73 | ]); 74 | }); 75 | }); 76 | 77 | it('update works', () => { 78 | return replicator.connect() 79 | .then(() => fromService.update(0, { id: 0, uuid: 1000, order: 99 })) 80 | .then(() => { 81 | const records = replicator.store.records; 82 | data.splice(0, 1); 83 | data[data.length] = { id: 0, uuid: 1000, order: 99 }; 84 | 85 | assert.lengthOf(records, sampleLen); 86 | assert.deepEqual(records, data); 87 | 88 | assert.deepEqual(events, [ 89 | { action: 'snapshot' }, 90 | { action: 'add-listeners' }, 91 | { source: 0, eventName: 'updated', action: 'mutated', record: { id: 0, uuid: 1000, order: 99 } } 92 | ]); 93 | }); 94 | }); 95 | 96 | it('patch works', () => { 97 | return replicator.connect() 98 | .then(() => fromService.patch(1, { order: 99 })) 99 | .then(() => { 100 | const records = replicator.store.records; 101 | data.splice(1, 1); 102 | data[data.length] = { id: 1, uuid: 1001, order: 99 }; 103 | 104 | assert.lengthOf(records, sampleLen); 105 | assert.deepEqual(records, data); 106 | 107 | assert.deepEqual(events, [ 108 | { action: 'snapshot' }, 109 | { action: 'add-listeners' }, 110 | { source: 0, eventName: 'patched', action: 'mutated', record: { id: 1, uuid: 1001, order: 99 } } 111 | ]); 112 | }); 113 | }); 114 | 115 | it('remove works', () => { 116 | return replicator.connect() 117 | .then(() => fromService.remove(2)) 118 | .then(() => { 119 | const records = replicator.store.records; 120 | data.splice(2, 1); 121 | 122 | assert.lengthOf(records, sampleLen - 1); 123 | assert.deepEqual(records, data); 124 | 125 | assert.deepEqual(events, [ 126 | { action: 'snapshot' }, 127 | { action: 'add-listeners' }, 128 | { source: 0, eventName: 'removed', action: 'remove', record: { id: 2, uuid: 1002, order: 2 } } 129 | ]); 130 | }); 131 | }); 132 | }); 133 | 134 | describe('within publication', () => { 135 | const testLen = 4; 136 | let events; 137 | 138 | beforeEach(() => { 139 | events = []; 140 | 141 | return fromService.create(clone(data)) 142 | .then(() => { 143 | replicator = new Replicator(fromService, { 144 | sort: Replicator.sort('order'), 145 | publication: record => record.order <= 3.5, 146 | subscriber: (records, last) => { events[events.length] = last; } 147 | }); 148 | 149 | data.splice(testLen); 150 | }); 151 | }); 152 | 153 | it('create works', () => { 154 | return replicator.connect() 155 | .then(() => fromService.create({ id: 99, uuid: 1099, order: 3.5 })) 156 | .then(() => { 157 | const records = replicator.store.records; 158 | data[testLen] = { id: 99, uuid: 1099, order: 3.5 }; 159 | 160 | assert.lengthOf(records, testLen + 1); 161 | assert.deepEqual(records, data); 162 | 163 | assert.deepEqual(events, [ 164 | { action: 'snapshot' }, 165 | { action: 'add-listeners' }, 166 | { source: 0, eventName: 'created', action: 'mutated', record: { id: 99, uuid: 1099, order: 3.5 } } 167 | ]); 168 | }); 169 | }); 170 | }); 171 | 172 | describe('outside publication', () => { 173 | const testLen = 4; 174 | let events; 175 | 176 | beforeEach(() => { 177 | events = []; 178 | 179 | return fromService.create(clone(data)) 180 | .then(() => { 181 | replicator = new Replicator(fromService, { 182 | sort: Replicator.sort('order'), 183 | publication: record => record.order <= 3.5, 184 | subscriber: (records, last) => { events[events.length] = last; } 185 | }); 186 | 187 | data.splice(testLen); 188 | }); 189 | }); 190 | 191 | it('create works', () => { 192 | return replicator.connect() 193 | .then(() => fromService.create({ id: 99, uuid: 1099, order: 99 })) 194 | .then(() => { 195 | const records = replicator.store.records; 196 | 197 | assert.lengthOf(records, testLen); 198 | assert.deepEqual(records, data); 199 | 200 | assert.deepEqual(events, [ 201 | { action: 'snapshot' }, 202 | { action: 'add-listeners' } 203 | ]); 204 | }); 205 | }); 206 | }); 207 | 208 | describe('moving in/out publication', () => { 209 | const testLen = 4; 210 | let events; 211 | 212 | beforeEach(() => { 213 | events = []; 214 | 215 | return fromService.create(clone(data)) 216 | .then(() => { 217 | replicator = new Replicator(fromService, { 218 | sort: Replicator.sort('order'), 219 | publication: record => record.order <= 3.5, 220 | subscriber: (records, last) => { events[events.length] = last; } 221 | }); 222 | 223 | data.splice(testLen); 224 | }); 225 | }); 226 | 227 | it('patching to without', () => { 228 | return replicator.connect() 229 | .then(() => fromService.patch(1, { order: 99 })) 230 | .then(() => { 231 | const records = replicator.store.records; 232 | data.splice(1, 1); 233 | 234 | assert.lengthOf(records, testLen - 1); 235 | assert.deepEqual(records, data); 236 | 237 | assert.deepEqual(events, [ 238 | { action: 'snapshot' }, 239 | { action: 'add-listeners' }, 240 | { source: 0, eventName: 'patched', action: 'left-pub', record: { id: 1, uuid: 1001, order: 99 } } 241 | ]); 242 | }); 243 | }); 244 | 245 | it('patching to within', () => { 246 | return replicator.connect() 247 | .then(() => fromService.patch(4, { order: 3.5 })) 248 | .then(() => { 249 | const records = replicator.store.records; 250 | data[testLen] = { id: 4, uuid: 1004, order: 3.5 }; 251 | 252 | assert.lengthOf(records, testLen + 1); 253 | assert.deepEqual(records, data); 254 | 255 | assert.deepEqual(events, [ 256 | { action: 'snapshot' }, 257 | { action: 'add-listeners' }, 258 | { source: 0, eventName: 'patched', action: 'mutated', record: { id: 4, uuid: 1004, order: 3.5 } } 259 | ]); 260 | }); 261 | }); 262 | }); 263 | }); 264 | } 265 | 266 | // Helpers 267 | 268 | function clone (obj) { 269 | return JSON.parse(JSON.stringify(obj)); 270 | } 271 | -------------------------------------------------------------------------------- /test/emitters.test.js: -------------------------------------------------------------------------------- 1 | 2 | import test from './commons/helpers/emitters.test.js'; 3 | const Realtime = require('../src'); 4 | 5 | test(Realtime, 'realtime'); 6 | -------------------------------------------------------------------------------- /test/optimistic-mutator-online.test.js: -------------------------------------------------------------------------------- 1 | 2 | import test from './commons/helpers/optimistic-mutator-online.test.js'; 3 | const Realtime = require('../src'); 4 | 5 | test(Realtime, 'realtime'); 6 | -------------------------------------------------------------------------------- /test/replicator.test.js: -------------------------------------------------------------------------------- 1 | 2 | import test from './commons/helpers/replicator.test.js'; 3 | const Realtime = require('../src'); 4 | 5 | test(Realtime, 'realtime', false); 6 | test(Realtime, 'realtime', true); 7 | -------------------------------------------------------------------------------- /test/service-events.test.js: -------------------------------------------------------------------------------- 1 | 2 | import test from './commons/helpers/service-events.test.js'; 3 | const Realtime = require('../src'); 4 | 5 | test(Realtime, 'realtime', false); 6 | test(Realtime, 'realtime', true); 7 | -------------------------------------------------------------------------------- /test/snapshot.test.js: -------------------------------------------------------------------------------- 1 | 2 | import test from './commons/helpers/snapshot.test.js'; 3 | const Realtime = require('../src'); 4 | 5 | test(Realtime, 'realtime'); 6 | -------------------------------------------------------------------------------- /test/subscribers.test.js: -------------------------------------------------------------------------------- 1 | 2 | import test from './commons/helpers/subscribers.test.js'; 3 | const Realtime = require('../src'); 4 | 5 | test(Realtime, 'realtime'); 6 | -------------------------------------------------------------------------------- /test/utils-cryptographic.test.js: -------------------------------------------------------------------------------- 1 | 2 | const assert = require('chai').assert; 3 | const { hash, hashOfRecord, genUuid } = require('../src/commons/utils'); 4 | 5 | describe('utils-cryptographic:', () => { 6 | it('hash hashes a string consistently', () => { 7 | const str = JSON.stringify({ a: 'a', b: true }); 8 | const hash1 = hash(str); 9 | const hash2 = hash(str); 10 | 11 | console.log(hash1); 12 | console.log(hash2); 13 | 14 | assert.isString(hash1); 15 | assert.lengthOf(hash1, 32); 16 | assert.isString(hash2); 17 | assert.lengthOf(hash2, 32); 18 | 19 | assert.equal(hash1, hash2); 20 | }); 21 | 22 | it('hashOfRecord ignores id and _id', () => { 23 | assert.equal( 24 | hashOfRecord({ id: 1, _id: 2, a: 3 }), 25 | hashOfRecord({ a: 3 }) 26 | ); 27 | }); 28 | 29 | it('generates long uuid', () => { 30 | const uuid = genUuid(false); 31 | 32 | assert.isString(uuid); 33 | assert.lengthOf(uuid, 36); 34 | }); 35 | 36 | it('generates short uuid', () => { 37 | const uuid = genUuid(true); 38 | 39 | assert.isString(uuid); 40 | assert.isAtMost(uuid.length, 15); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /test/utils-misc.test.js: -------------------------------------------------------------------------------- 1 | 2 | const assert = require('chai').assert; 3 | const { isObject, stripProps } = require('../src/commons/utils'); 4 | 5 | describe('utils-misc:', () => { 6 | it('isObject works', () => { 7 | assert.isOk(isObject({})); 8 | assert.isNotOk(isObject([])); 9 | assert.isNotOk(isObject(null)); 10 | assert.isNotOk(isObject(1)); 11 | assert.isNotOk(isObject('a')); 12 | assert.isNotOk(isObject(undefined)); 13 | assert.isNotOk(isObject(() => { 14 | })); 15 | assert.isNotOk(isObject(true)); 16 | }); 17 | 18 | it('stripProps works', () => { 19 | const from = { a: 1, id: 2, _id: 3, b: { c: 4, id: 5, _id: 6 } }; 20 | 21 | assert.deepEqual(stripProps(from, ['id', '_id']), { a: 1, b: { c: 4 } }); 22 | }); 23 | }); 24 | --------------------------------------------------------------------------------