├── .codeclimate.yml ├── .eslintrc ├── .gitignore ├── .istanbul.yml ├── .publishrc ├── Apache_License ├── CHANGELOG.md ├── LICENSE ├── README.md ├── bin └── main.js ├── lib ├── actions │ ├── init.js │ └── start.js ├── db_connectors │ └── NeDB │ │ ├── changes_table.js │ │ ├── db.js │ │ ├── table.js │ │ └── uncommitted_changes_table.js ├── log_handlers.js ├── server │ ├── poll │ │ ├── sender_functions.js │ │ └── server.js │ ├── print_interfaces.js │ ├── read_certificates.js │ ├── server.js │ └── socket │ │ ├── handlers.js │ │ └── server.js └── sync │ ├── apply_client_changes.js │ ├── apply_modifications.js │ ├── combine_create_and_update.js │ ├── combine_update_and_update.js │ ├── deep_clone.js │ ├── get_server_changes.js │ ├── handle_client_changes.js │ ├── init_handlers.js │ ├── poll_handler.js │ ├── reduce_changes.js │ ├── resolve_conflicts.js │ ├── set_key_path.js │ ├── socket_handler.js │ └── types.js ├── package-lock.json ├── package.json ├── samples ├── ajax │ ├── index.html │ └── index.js └── socket │ ├── index.html │ └── index.js └── test ├── integration └── sync_and_db │ ├── apply_client_changes.spec.js │ ├── poll │ ├── client_identity.spec.js │ ├── initial_synchronization.spec.js │ ├── partial_client_sync.spec.js │ ├── partial_server_sync.spec.js │ └── subsequent_synchronization.spec.js │ └── socket │ ├── client_changes.spec.js │ ├── client_identity.spec.js │ ├── connection_closed.spec.js │ ├── partial_client_sync.spec.js │ ├── partial_server_sync.spec.js │ └── subscribe.spec.js └── unit ├── db_connectors └── changes_table.spec.js └── sync ├── apply_modifications.spec.js ├── combine_create_and_update.spec.js ├── combine_update_and_update.spec.js ├── reduce_changes.spec.js ├── resolve_conflicts.spec.js └── set_key_path.spec.js /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | --- 2 | engines: 3 | eslint: 4 | enabled: true 5 | channel: "eslint-3" 6 | nodesecurity: 7 | enabled: true 8 | fixme: 9 | enabled: true 10 | duplication: 11 | enabled: true 12 | config: 13 | languages: 14 | - javascript 15 | ratings: 16 | paths: 17 | - "**.js" 18 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "standard", 3 | "env": { 4 | "mocha": true 5 | }, 6 | "rules": { 7 | "semi": ["error", "always"], 8 | "space-before-function-paren": ["error", { 9 | "anonymous": "always", 10 | "named": "never", 11 | "asyncArrow": "ignore" 12 | }], 13 | "comma-dangle": ["error", "always-multiline"], 14 | "object-curly-spacing": ["error", "always"], 15 | "no-else-return": "error", 16 | "max-len": ["error", 120] 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /.idea 3 | *.log 4 | /coverage 5 | -------------------------------------------------------------------------------- /.istanbul.yml: -------------------------------------------------------------------------------- 1 | instrumentation: 2 | excludes: ['**/test/**', '**/bin/**'] 3 | -------------------------------------------------------------------------------- /.publishrc: -------------------------------------------------------------------------------- 1 | { 2 | "validations": { 3 | "vulnerableDependencies": true, 4 | "uncommittedChanges": true, 5 | "untrackedFiles": true, 6 | "sensitiveData": true, 7 | "branch": "master", 8 | "gitTag": true 9 | }, 10 | "confirm": true, 11 | "publishTag": "latest", 12 | "prePublishScript": "npm test" 13 | } -------------------------------------------------------------------------------- /Apache_License: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | This project adheres to [Semantic Versioning](http://semver.org/). 3 | 4 | ## [2.0.0-beta.6] - 2018-03-11 5 | 6 | * Fix WSS support [#12](https://github.com/nponiros/sync_server/pull/12) 7 | * Update vulnerable dependencies 8 | 9 | ## [2.0.0-beta.4] - 2017-02-05 10 | 11 | * Allow more https/wss options than just the certificates 12 | * Bug fix: during poll synchronization a /check call shouldn't increment the next client version 13 | * Fix https://github.com/nponiros/sync\_server/issues/8 14 | 15 | ## [2.0.0-beta.2] - 2017-01-21 16 | 17 | * Allow CORS to be configured 18 | * Add WebSockets support 19 | * Change revision handling 20 | * Each DB changes has its own revision 21 | * Don't save a DB revision. Just read the last revision from the changes table 22 | * Don't return the DB revision when client gets data. Return the revision of the last change the client receives or the current db revision if there are no data for the client 23 | * meta data reading/writing is now async 24 | * Write stack trace into error log 25 | * Update dependencies 26 | 27 | ## [2.0.0-beta.1] - 2016-12-27 28 | 29 | This is a complete rewrite to make the server work with Dexie.Syncable. This version is not backwards compatible. The new API is described in the README. Please open an issue if you are using an old version and want to upgrade it. 30 | 31 | ## [1.0.0] - 2016-02-20 32 | 33 | ### Added 34 | 35 | * Add help flag for CLI 36 | * Add version flag for CLI 37 | * Add support for a JSON configuration file via -c or --config flag 38 | 39 | ### Changed 40 | 41 | * Fix documentation 42 | * Log files are saved in the same directory as the data 43 | * Use both a file logger and a console logger 44 | * On start show which collections are supported and where the data is saved 45 | * Set request size limit to 100kb, the default for body parser 46 | * Update dependencies 47 | * Replace yargs with minimist 48 | * Adjust .eslintrc for version 2.2.0 49 | * Change package.json engine to node version >= 4.0.0 50 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 - 2016 Nikolas Poniros 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 | # SyncServer 2 | 3 | [![Code Climate](https://codeclimate.com/github/nponiros/sync_server/badges/gpa.svg)](https://codeclimate.com/github/nponiros/sync_server) 4 | 5 | ## Synopsis 6 | 7 | A small node server which uses [NeDB](https://github.com/louischatriot/nedb) to write data to the disk. The server can be used with a client for example [SyncClient](https://github.com/nponiros/sync_client) to save change sets which can later be synchronized with other devices. The server was made to work with the [ISyncProtocol](https://github.com/dfahlander/Dexie.js/wiki/Dexie.Syncable.ISyncProtocol) and [Dexie.Syncable](https://www.npmjs.com/package/dexie-syncable). It supports the poll pattern using AJAX and the react pattern using [nodejs-websocket](https://www.npmjs.com/package/nodejs-websocket). 8 | 9 | ## Installation and usage 10 | 11 | Install globally using npm: 12 | 13 | ```bash 14 | npm install -g sync-server 15 | ``` 16 | 17 | Before using the server it has to be initialized with: 18 | 19 | ```bash 20 | sync-server init 21 | ``` 22 | 23 | The `init` action must be executed in an empty directory which will later be used to store the data. This folder represents a Database. During initialization a `config.json` file is create with the default server configuration. 24 | 25 | You can start the server with: 26 | 27 | ```bash 28 | sync-server start --path INIT/DIRECTORY/PATH 29 | ``` 30 | 31 | The `--path` flag must be given the path to the directory in which `init` was called. 32 | 33 | ### Default settings 34 | 35 | These settings are written in a file called `config.json` in the directory in which `init` was called. The config file is split into 4 sections: `db`, `logging`, `server`, and `sync`. 36 | 37 | #### db 38 | 39 | | Setting name | Value | Description | 40 | | ----------------- | ----------------------- | -------------------------------------------------------------------------------------------------------------- | 41 | | connector | 'NeDB' | The database used to store the data. Currently only [NeDB](https://github.com/louischatriot/nedb) is supported | 42 | | opts | {} | Options for the database. These depend on the selected connector | 43 | 44 | __NeDB Options__ 45 | 46 | The `sync-server` supports the following NeDB options: 47 | 48 | * inMemoryOnly 49 | * timestampData 50 | * corruptAlertThreshold 51 | 52 | The [NeDB README](https://github.com/louischatriot/nedb#creatingloading-a-database) contains more information about these options. 53 | 54 | #### logging 55 | 56 | | Setting name | Value | Description | 57 | | ----------------- | ----------------------- | ----------------------------------------------------------------------------------------------- | 58 | | errorLogFileName | "error.log" | File name for the error log. Contains information about exceptions and rejected promises | 59 | | accessLogFileName | "access.log" | File name for the access log. Contains information about the requests made against the server | 60 | 61 | #### server 62 | 63 | | Setting name | Value | Description | 64 | | ----------------- | ----------------------- | ---------------------------------------------------------------------------------------------------- | 65 | | requestSizeLimit | "100kb" | Request size limit for [body-parser](https://www.npmjs.com/package/body-parser) | 66 | | port | 3000 | Server port. Must be a non-privileged port | 67 | | protocol | "http" | Protocol used by the server. "http", "https", "ws" or "wss" | 68 | | https | {} | This object contains the paths for the files needed by https | 69 | | wss | {} | This object contains the paths for the files needed by wss | 70 | | cors | {} | You can use this object to configure [CORS](https://github.com/expressjs/cors#configuration-options) | 71 | 72 | #### sync 73 | 74 | | Setting name | Value | Description | 75 | | ----------------- | ----------------------- | ----------------------------------------------------------------------------------------------------- | 76 | | partialsThreshold | 1000 | If we have more than 1000 changes to send to the client, send only the first 1000 and `partial: true` | 77 | 78 | 79 | ### Node.js Version 80 | 81 | You need to use a new version of Node.js as the code uses ES2015 features which are not available in Node.js versions < 6.0.0. 82 | 83 | ## Caveat 84 | 85 | In case the server encounters an `uncaughtException` or an `unhandledRejection` it will write to the log and exit with status code 1. This should normally not happen, if it does happen please open an [issue](https://github.com/nponiros/sync_server/issues) with the information from the error log. 86 | 87 | ## Protocols 88 | 89 | The server supports 4 different protocols: `http`, `https`, `ws` and `wss`. The `http` and `https` protocols can be used for the poll pattern where the server and client communicate via HTTP requests. The `ws` and `wss` protocols can be used for the react pattern where server and client communicate via WebSockets. Per default the `http` protocol is used. For `https` and `wss` you have to at least provide certificates. See below on how to configure those. 90 | 91 | ### Configuring HTTPS 92 | 93 | In order to use HTTPS you need to set the `protocol` to `"https"` and add paths for the certificate in the `https` object. The attributes `key` and `cert` or for `pfx` are required. For example: 94 | 95 | ```json 96 | { 97 | "server": { 98 | "protocol": "https", 99 | "https": { 100 | "key": "key_filename.pem", 101 | "cert": "cert_filename.pem" 102 | } 103 | } 104 | } 105 | ``` 106 | 107 | The files must be in the same directory as the server's config file. You can also specify other options allowed by Node.js for a https server. 108 | 109 | ### Configuring WebSockets (WS) 110 | 111 | In order to use WebSockets you need to set the `protocol` to `"ws"`. 112 | 113 | ### Configuring Secure WebSockets (WSS) 114 | 115 | In order to use WSS you need to set the `protocol` to `"wss"` and add paths for the certificate in the `wss` object. The attributes `key` and `cert` or for `pfx` are required. For example: 116 | 117 | ```json 118 | { 119 | "server": { 120 | "protocol": "wss", 121 | "wss": { 122 | "key": "key_filename.pem", 123 | "cert": "cert_filename.pem" 124 | } 125 | } 126 | } 127 | ``` 128 | 129 | The files must be in the same directory as the server's config file. You can also specify other options allowed by Node.js for a tls server and options allowed by nodejs-websocket. 130 | 131 | ## API for the poll pattern 132 | 133 | ### Synchronization 134 | 135 | * URL: `/` 136 | * Method: `POST` 137 | * ContentType: `application/json`. This header must be set, otherwise the server will not be able to parse the data 138 | * Params: JSON with 139 | * baseRevision: number (It is set to `0` if it is not defined) 140 | * changes: Array (The [ChangeObj](#changeobj) is described below) 141 | * clientIdentity: number (The server generates one if it is not defined) 142 | * syncedRevision: number (It is set to `0` if it is not defined) 143 | * requestId: any 144 | * partial: boolean (If `true` this is a partial synchronization. Default is `false`) 145 | * Return: JSON object 146 | * If the synchronization was successful 147 | * success: true 148 | * changes: Array (The [ChangeObj](#changeobj) is described below) 149 | * currentRevision: number 150 | * clientIdentity: number (The newly generated clientIdentity or the one that was provided by the client) 151 | * partial: boolean (This is a partial synchronization. The `partialsThreshold` number defines when we only send a partial synchronization) 152 | * requestId: any (requestId sent by the client) 153 | * If the synchronization failed 154 | * success: false 155 | * errorMessage: string 156 | * requestId: any (requestId sent by the client) 157 | * In both cases the status code is set to 200 158 | 159 | ### Online check 160 | 161 | Can be used to check if the server is online. 162 | 163 | * URL: `/check` 164 | * Method: `HEAD` 165 | * Params: None 166 | * Return: Headers 167 | 168 | ## API for the react pattern 169 | 170 | Currently the WebSocket server only supports sending and receiving text messages. Binary is not supported. 171 | 172 | __Request Messages__ 173 | 174 | The server can receive 3 message types: `clientIdentity`, `subscribe` and `changes`. 175 | 176 | __Response Messages__ 177 | 178 | The server can respond with 4 message types: `clientIdentity`, `ack`, `changes` and `error`. 179 | 180 | ### Requests 181 | 182 | #### clientIdentity 183 | 184 | This must be the first message sent. 185 | 186 | * Params: JSON with 187 | * type: "clientIdentity" 188 | * clientIdentity: number (The server generates one if it is not defined) 189 | * Server responds with `clientIdentity` 190 | 191 | #### subscribe 192 | 193 | This must be the second message sent. It is needed to setup callbacks to inform the client about changes made by other clients. 194 | You need to wait on the `clientIdentity` response before subscribing to make sure that the server saved the clientIdentity. 195 | 196 | * Params: JSON with 197 | * type: "subscribe" 198 | * syncedRevision: number (It is set to `0` if it is not defined) 199 | * Server responds with `changes` or `error` 200 | 201 | #### changes 202 | 203 | * Params: JSON with 204 | * type: "changes" 205 | * baseRevision: number (It is set to `0` if it is not defined) 206 | * changes: Array (The [ChangeObj](#changeobj) is described below) 207 | * partial: boolean (If `true` this is a partial synchronization. Default is `false`) 208 | * requestId: any 209 | * Server responds with `ack` or `error` 210 | * This event would trigger a `changes` or `error` message for all other connected clients 211 | 212 | ### Responses 213 | 214 | #### ack 215 | 216 | * Params: JSON object 217 | * type: "ack" 218 | * requestId: any (The ID sent by the client) 219 | 220 | #### clientIdentity 221 | 222 | * Params: JSON object 223 | * type: "clientIdentity" 224 | * clientIdentity: number (The newly generated clientIdentity or the one that was provided by the client) 225 | 226 | #### changes 227 | 228 | * Params: JSON object 229 | * type: "changes" 230 | * changes: Array (The [ChangeObj](#changeobj) is described below) 231 | * currentRevision: number 232 | * partial: boolean (This is a partial synchronization. The `partialsThreshold` number defines when we only send a partial synchronization) 233 | 234 | #### error 235 | 236 | * Params: JSON 237 | * type: "error" 238 | * errorMessage: string 239 | * requestId: any (Only sent if the `changes` request caused an error) 240 | 241 | ## ChangeObj 242 | 243 | There are 3 types of `ChangeObj`. See also [Dexie.Syncable.IDatabaseChange](https://github.com/dfahlander/Dexie.js/wiki/Dexie.Syncable.IDatabaseChange) 244 | 245 | __CREATE__ 246 | 247 | Object with: 248 | * type: 1 249 | * obj: Object (The object to add to the database. Must also contain the `key`, but does not have to use the `key` property ) 250 | * key: any (The unique ID of the object. Is also contained in `obj`) 251 | * table: string (The name of the table to which the object belongs to) 252 | 253 | __UPDATE__ 254 | 255 | Object with: 256 | * type: 2 257 | * mods: Object (Contains only the modifications made to the object with the given `key`) 258 | * key: any (The unique ID of the object. Is also contained in `obj`) 259 | * table: string (The name of the table to which the object belongs to) 260 | 261 | __DELETE__ 262 | 263 | Object with: 264 | * type: 3 265 | * key: any (The unique ID of the object we want to delete) 266 | * table: string (The name of the table to which the object belongs to) 267 | 268 | ## Running the tests 269 | 270 | The following commands can be execute to run the tests. 271 | 272 | ```bash 273 | npm install 274 | npm test 275 | ``` 276 | 277 | ## TODO 278 | 279 | * cleanup changes table -> Can only do that after Dexie.Syncable supports the clear flag 280 | * Add E2E tests 281 | 282 | ## Contributing 283 | 284 | If you feel you can help in any way, be it with documentation, examples, extra testing, or new features please open an [issue](https://github.com/nponiros/sync_server/issues) or [pull request](https://github.com/nponiros/sync_server/pulls). 285 | If you have any questions feel free to open an [issue](https://github.com/nponiros/sync_server/issues) with your question. 286 | 287 | ## License 288 | 289 | [MIT License](./LICENSE) 290 | 291 | Most files in the `sync` directory where copied from the [Dexie Websockets Sample](https://github.com/dfahlander/Dexie.js/blob/master/samples/remote-sync/websocket/WebSocketSyncServer.js) and are under the [Apache 2 license](./Apache_License). Look at the individual file for more details. 292 | -------------------------------------------------------------------------------- /bin/main.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | 'use strict'; 4 | 5 | const path = require('path'); 6 | const argv = require('yargs').argv; 7 | 8 | const startAction = require('../lib/actions/start'); 9 | const initAction = require('../lib/actions/init'); 10 | 11 | const action = argv._[0]; 12 | 13 | const basePath = process.cwd(); 14 | 15 | switch (action) { 16 | case 'init': initAction(basePath); break; 17 | case 'start': { 18 | const dataPath = path.resolve(argv.path); 19 | startAction(dataPath); 20 | break; 21 | } 22 | default: console.log('Action', action, 'not supported'); 23 | } 24 | -------------------------------------------------------------------------------- /lib/actions/init.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | 6 | const config = { 7 | logging: { 8 | errorLogFileName: 'error.log', 9 | accessLogFileName: 'access.log', 10 | }, 11 | server: { 12 | port: 3000, 13 | protocol: 'http', 14 | requestSizeLimit: '100kb', 15 | https: {}, 16 | cors: {}, 17 | }, 18 | db: { 19 | connector: 'NeDB', 20 | opts: {}, 21 | }, 22 | sync: { 23 | partialsThreshold: 1000, 24 | }, 25 | }; 26 | 27 | module.exports = function (basePath) { 28 | const files = fs.readdirSync(basePath); 29 | 30 | if (files.length === 0) { 31 | fs.writeFileSync(path.join(basePath, 'config.json'), JSON.stringify(config, undefined, 2)); 32 | } else { 33 | console.log('Directory is not empty. Will not initialize.'); 34 | } 35 | }; 36 | -------------------------------------------------------------------------------- /lib/actions/start.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | 6 | const initializeLogger = require('../log_handlers'); 7 | const startServer = require('../server/server'); 8 | const initSyncHandler = require('../sync/init_handlers'); 9 | const DB = require('../db_connectors/NeDB/db'); 10 | 11 | module.exports = function (dataPath) { 12 | const settings = JSON.parse(fs.readFileSync(path.join(dataPath, 'config.json'), { encoding: 'utf8' })); 13 | 14 | const logger = initializeLogger(dataPath, settings.logging); 15 | 16 | process.on('uncaughtException', (err) => { 17 | logger.file.error('uncaughtException:', err.name, err.message, err.stack); 18 | logger.console.error('uncaughtException:', err.name, err.message); 19 | process.exit(1); 20 | }); 21 | 22 | process.on('unhandledRejection', (reason, p) => { 23 | logger.file.error('unhandledRejection:', reason, p); 24 | logger.console.error('unhandledRejection:', reason); 25 | process.exit(1); 26 | }); 27 | 28 | const db = new DB(Object.assign({}, settings.db.opts, { dataPath }), logger); 29 | db.init() 30 | .then(() => initSyncHandler(db, logger, settings.sync)) 31 | .then((handlers) => { 32 | startServer({ 33 | syncHandler: handlers, 34 | logger, 35 | settings: settings.server, 36 | dataPath, 37 | }); 38 | }); 39 | }; 40 | -------------------------------------------------------------------------------- /lib/db_connectors/NeDB/changes_table.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const NeDBDataStore = require('nedb'); 4 | 5 | class ChangesTable { 6 | constructor(name, dbOptions) { 7 | const options = { 8 | filename: name, 9 | autoload: true, 10 | }; 11 | this.store = new NeDBDataStore(Object.assign({}, options, dbOptions)); 12 | this.dotReplacer = '__DOT__'; 13 | } 14 | 15 | _replaceObjectKeys(obj, originalString, replaceString) { 16 | return Object.keys(obj).reduce((newObj, key) => 17 | Object.assign(newObj, { [key.replace(originalString, replaceString)]: obj[key] }), 18 | {} 19 | ); 20 | } 21 | 22 | add(changeObject) { 23 | // NeDB does not support dots in an object attribute 24 | // Dexie.Syncable could send mods with dots in the attribute names so 25 | // we need to replace the dots here 26 | let mods = changeObject.mods; 27 | if (mods) { 28 | mods = this._replaceObjectKeys(mods, '.', this.dotReplacer); 29 | Object.assign(changeObject, { mods }); 30 | } 31 | 32 | return new Promise((resolve, reject) => { 33 | this.store.insert(changeObject, (err) => { 34 | if (err) { 35 | reject(err); 36 | } else { 37 | resolve(); 38 | } 39 | }); 40 | }); 41 | } 42 | 43 | getLatestRevision() { 44 | return new Promise((resolve, reject) => { 45 | const query = {}; 46 | return this.store.find(query).sort({ rev: 1 }).exec((err, result) => { 47 | if (err) { 48 | reject(err); 49 | } else { 50 | const rev = result[result.length - 1] ? result[result.length - 1].rev : 0; 51 | resolve(rev); 52 | } 53 | }); 54 | }); 55 | } 56 | 57 | getByRevision(revisionNumber) { 58 | return new Promise((resolve, reject) => { 59 | const query = { 60 | rev: { 61 | $gt: revisionNumber, 62 | }, 63 | }; 64 | return this.store.find(query, { _id: 0, rev: 0 }).sort({ rev: 1 }).exec((err, result) => { 65 | if (err) { 66 | reject(err); 67 | } else { 68 | // See comment in add method 69 | const resultWithDots = result.map((res) => { 70 | if (res.mods) { 71 | const newMods = this._replaceObjectKeys(res.mods, this.dotReplacer, '.'); 72 | Object.assign(res, { mods: newMods }); 73 | } 74 | return res; 75 | }); 76 | resolve(resultWithDots); 77 | } 78 | }); 79 | }); 80 | } 81 | 82 | // Revision (rev) needs to be sent back to the sync handler 83 | // so it knows what the currentRevision for a client is 84 | getByRevisionAndClientID(revisionNumber, clientID) { 85 | return new Promise((resolve, reject) => { 86 | const query = { 87 | rev: { 88 | $gt: revisionNumber, 89 | }, 90 | source: { 91 | $ne: clientID, 92 | }, 93 | }; 94 | return this.store.find(query, { _id: 0, source: 0 }).sort({ rev: 1 }).exec((err, result) => { 95 | if (err) { 96 | reject(err); 97 | } else { 98 | // See comment in add method 99 | const resultWithDots = result.map((res) => { 100 | if (res.mods) { 101 | const newMods = this._replaceObjectKeys(res.mods, this.dotReplacer, '.'); 102 | Object.assign(res, { mods: newMods }); 103 | } 104 | return res; 105 | }); 106 | resolve(resultWithDots); 107 | } 108 | }); 109 | }); 110 | } 111 | } 112 | 113 | module.exports = ChangesTable; 114 | -------------------------------------------------------------------------------- /lib/db_connectors/NeDB/db.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | 6 | const Table = require('./table'); 7 | const ChangesTable = require('./changes_table'); 8 | const UncommittedChangesTable = require('./uncommitted_changes_table'); 9 | 10 | function getInitialMeta() { 11 | // Initial meta data 12 | return { 13 | nextClientID: 1, 14 | tables: [], 15 | }; 16 | } 17 | 18 | const metaFileName = 'meta.json'; 19 | const changesTableName = 'changes.table'; 20 | const uncommittedTableName = 'uncommittedChanges.table'; 21 | 22 | class DB { 23 | constructor(dbOptions, logger) { 24 | this.tables = new Map(); 25 | this.dataPath = dbOptions.dataPath || ''; 26 | this.dbOptions = dbOptions; 27 | this.isInMemory = Boolean(dbOptions.inMemoryOnly); 28 | } 29 | 30 | init() { 31 | return this._loadMetaFile() 32 | .then((meta) => { 33 | this.meta = meta; 34 | return this._loadTables(this.dataPath, meta.tables, this.dbOptions); 35 | }); 36 | } 37 | 38 | _loadTables(dataPath, tableNames, dbOptions) { 39 | tableNames.forEach((tableName) => { 40 | const filename = path.join(dataPath, tableName); 41 | const tableInstance = new Table(filename, dbOptions); 42 | this.tables 43 | .set(tableName, tableInstance); 44 | }); 45 | this.changesTable = new ChangesTable(path.join(dataPath, changesTableName), dbOptions); 46 | this.uncommittedChanges = new UncommittedChangesTable(path.join(dataPath, uncommittedTableName), dbOptions); 47 | } 48 | 49 | _loadMetaFile() { 50 | return new Promise((resolve, reject) => { 51 | const metaFilePath = path.join(this.dataPath, metaFileName); 52 | if (this.isInMemory) { 53 | const meta = getInitialMeta(); 54 | return resolve(meta); 55 | } 56 | 57 | fs.readFile(metaFilePath, { encoding: 'utf8' }, (err, data) => { 58 | if (err) { 59 | if (err.code === 'ENOENT') { 60 | const meta = getInitialMeta(); 61 | resolve(meta); 62 | } else { 63 | reject(err); 64 | } 65 | } else { 66 | const meta = JSON.parse(data); 67 | resolve(meta); 68 | } 69 | }); 70 | }); 71 | } 72 | 73 | _updateMeta() { 74 | if (!this.isInMemory) { 75 | return new Promise((resolve, reject) => { 76 | const metaFilePath = path.join(this.dataPath, metaFileName); 77 | fs.writeFile(metaFilePath, JSON.stringify(this.meta), (err) => { 78 | if (err) { 79 | reject(); 80 | } 81 | resolve(); 82 | }); 83 | }); 84 | } 85 | return Promise.resolve(); 86 | } 87 | 88 | _addTable(tableName) { 89 | const filename = path.join(this.dataPath, tableName); 90 | this.tables.set(tableName, new Table(filename, this.dbOptions)); 91 | this.meta.tables.push(tableName); 92 | return this._updateMeta(); 93 | } 94 | 95 | hasTable(tableName) { 96 | return this.tables.has(tableName); 97 | } 98 | 99 | getNextClientID() { 100 | const nextID = this.meta.nextClientID; 101 | this.meta.nextClientID = this.meta.nextClientID + 1; 102 | return this._updateMeta() 103 | .then(() => { 104 | return nextID; 105 | }); 106 | } 107 | 108 | getRevision() { 109 | return this.changesTable.getLatestRevision(); 110 | } 111 | 112 | addChangesData(data) { 113 | return this.changesTable.add(data); 114 | } 115 | 116 | getChangesData(revision, clientID) { 117 | if (clientID) { 118 | return this.changesTable.getByRevisionAndClientID(revision, clientID); 119 | } 120 | return this.changesTable.getByRevision(revision); 121 | } 122 | 123 | addData(tableName, key, data) { 124 | if (this.hasTable(tableName)) { 125 | return this.tables.get(tableName).add(key, data); 126 | } 127 | return this._addTable(tableName) 128 | .then(() => { 129 | return this.tables.get(tableName).add(key, data); 130 | }); 131 | } 132 | 133 | getData(tableName, key) { 134 | if (this.hasTable(tableName)) { 135 | return this.tables.get(tableName).get(key); 136 | } 137 | return Promise.resolve(); 138 | } 139 | 140 | updateData(tableName, key, data) { 141 | return this.tables.get(tableName).update(key, data); 142 | } 143 | 144 | removeData(tableName, key) { 145 | return this.tables.get(tableName).remove(key); 146 | } 147 | } 148 | 149 | module.exports = DB; 150 | -------------------------------------------------------------------------------- /lib/db_connectors/NeDB/table.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const NeDBDataStore = require('nedb'); 4 | 5 | class Table { 6 | constructor(name, dbOptions) { 7 | const filename = `${name}.table`; 8 | const options = { 9 | filename, 10 | autoload: true, 11 | }; 12 | this.store = new NeDBDataStore(Object.assign({}, options, dbOptions)); 13 | } 14 | 15 | _addID(key, obj) { 16 | return Object.assign({}, obj, { _id: key }); 17 | } 18 | 19 | add(key, changeObject) { 20 | return new Promise((resolve, reject) => { 21 | this.store.insert(this._addID(key, changeObject), (err) => { 22 | if (err) { 23 | reject(err); 24 | } else { 25 | resolve(); 26 | } 27 | }); 28 | }); 29 | } 30 | 31 | get(key) { 32 | return new Promise((resolve, reject) => { 33 | this.store.findOne({ _id: key }, { _id: 0 }, (err, res) => { 34 | if (err) { 35 | reject(err); 36 | } else { 37 | resolve(res); 38 | } 39 | }); 40 | }); 41 | } 42 | 43 | update(key, changeObject) { 44 | return new Promise((resolve, reject) => { 45 | this.store.update({ _id: key }, changeObject, {}, (err) => { 46 | if (err) { 47 | reject(err); 48 | } else { 49 | resolve(); 50 | } 51 | }); 52 | }); 53 | } 54 | 55 | remove(key) { 56 | return new Promise((resolve, reject) => { 57 | this.store.remove({ _id: key }, {}, (err) => { 58 | if (err) { 59 | reject(err); 60 | } else { 61 | resolve(); 62 | } 63 | }); 64 | }); 65 | } 66 | } 67 | 68 | module.exports = Table; 69 | -------------------------------------------------------------------------------- /lib/db_connectors/NeDB/uncommitted_changes_table.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const NeDBDataStore = require('nedb'); 4 | 5 | class UncommittedChangesTable { 6 | constructor(name, dbOptions) { 7 | const options = { 8 | filename: name, 9 | autoload: true, 10 | }; 11 | this.store = new NeDBDataStore(Object.assign({}, options, dbOptions)); 12 | } 13 | 14 | update(clientIdentity, changes) { 15 | return new Promise((resolve, reject) => { 16 | const updateRules = { $push: { changes: { $each: changes } } }; 17 | this.store.update({ _id: clientIdentity }, updateRules, { upsert: true }, (err) => { 18 | if (err) { 19 | reject(err); 20 | } else { 21 | resolve(); 22 | } 23 | }); 24 | }); 25 | } 26 | 27 | get(clientIdentity) { 28 | return new Promise((resolve, reject) => { 29 | this.store.findOne({ _id: clientIdentity }, { _id: 0 }, (err, data) => { 30 | if (err) { 31 | return reject(err); 32 | } 33 | this.store.remove({ _id: clientIdentity }, {}, (err) => { 34 | if (err) { 35 | reject(err); 36 | } else { 37 | resolve(data || { changes: [] }); 38 | } 39 | }); 40 | }); 41 | }); 42 | } 43 | } 44 | 45 | module.exports = UncommittedChangesTable; 46 | -------------------------------------------------------------------------------- /lib/log_handlers.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const winston = require('winston'); 4 | const Logger = winston.Logger; 5 | const FileTransport = winston.transports.File; 6 | const ConsoleTransport = winston.transports.Console; 7 | 8 | const errorTransportName = 'errorTransport'; 9 | const infoTransportName = 'infoTransport'; 10 | 11 | function initializeFileLogger(logsPath, accessLogFileName, errorLogFileName) { 12 | return new Logger({ 13 | transports: [ 14 | new FileTransport({ 15 | name: infoTransportName, 16 | filename: path.join(logsPath, accessLogFileName), 17 | level: 'info', 18 | }), 19 | new FileTransport({ 20 | name: errorTransportName, 21 | filename: path.join(logsPath, errorLogFileName), 22 | level: 'error', 23 | handleExceptions: true, 24 | humanReadableUnhandledException: true, 25 | }), 26 | ], 27 | }); 28 | } 29 | 30 | function initializeConsoleLogger() { 31 | return new Logger({ 32 | transports: [ 33 | new ConsoleTransport({ 34 | name: infoTransportName, 35 | level: 'info', 36 | }), 37 | new ConsoleTransport({ 38 | name: errorTransportName, 39 | level: 'error', 40 | handleExceptions: true, 41 | humanReadableUnhandledException: true, 42 | }), 43 | ], 44 | }); 45 | } 46 | 47 | function initializeLogger(logsPath, { accessLogFileName, errorLogFileName }) { 48 | return { 49 | console: initializeConsoleLogger(), 50 | file: initializeFileLogger(logsPath, accessLogFileName, errorLogFileName), 51 | }; 52 | } 53 | 54 | module.exports = initializeLogger; 55 | -------------------------------------------------------------------------------- /lib/server/poll/sender_functions.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const OK_STATUS_TEXT = 'OK'; 4 | const OK_STATUS_CODE = 200; 5 | const ERROR_STATUS_TEXT = 'INTERNAL ERROR'; 6 | const ERROR_STATUS_CODE = 500; 7 | 8 | const CONTENT_TYPE = 'application/json;charset=UTF-8'; 9 | const CHAR_SET = 'utf8'; 10 | 11 | function send(res, content, statusCode, statusText) { 12 | const data = JSON.stringify(content); 13 | const dataLength = Buffer.byteLength(data, CHAR_SET); 14 | const headers = { 15 | 'Content-Length': dataLength, 16 | 'Content-Type': CONTENT_TYPE, 17 | }; 18 | res.writeHead(statusCode, statusText, headers); 19 | res.end(data, CHAR_SET); 20 | } 21 | 22 | function sendJsonContent(res, content) { 23 | send(res, content, OK_STATUS_CODE, OK_STATUS_TEXT); 24 | } 25 | 26 | function sendError(res, content) { 27 | send(res, content, ERROR_STATUS_CODE, ERROR_STATUS_TEXT); 28 | } 29 | 30 | module.exports = { 31 | jsonContent: sendJsonContent, 32 | error: sendError, 33 | }; 34 | -------------------------------------------------------------------------------- /lib/server/poll/server.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const http = require('http'); 4 | const https = require('https'); 5 | 6 | const bodyParser = require('body-parser'); 7 | const cors = require('cors'); 8 | const express = require('express'); 9 | 10 | const printInterfaces = require('../print_interfaces'); 11 | const send = require('./sender_functions'); 12 | 13 | const readCertificates = require('../read_certificates'); 14 | 15 | function startPollServer({ 16 | logger, 17 | syncHandler, 18 | settings, 19 | dataPath, 20 | }) { 21 | const expressApp = express(); 22 | const pollHandler = syncHandler; 23 | 24 | expressApp.set('port', settings.port); 25 | expressApp.set('x-powered-by', false); 26 | 27 | expressApp.use(cors(settings.cors)); 28 | expressApp.use(bodyParser.json({ limit: settings.requestSizeLimit })); 29 | 30 | expressApp.use('/check', (req, res) => { 31 | res.end(); 32 | }); 33 | 34 | expressApp.use('/', (req, res, next) => { 35 | const syncData = req.body; 36 | pollHandler(syncData) 37 | .then((data) => { 38 | send.jsonContent(res, data); 39 | }) 40 | .catch((e) => { 41 | next(e); 42 | }); 43 | }); 44 | 45 | // catch 404 and forward to error handler 46 | expressApp.use(function (req, res, next) { 47 | const err = new Error('Not Found'); 48 | next(err); 49 | }); 50 | 51 | // Error handler 52 | expressApp.use(function (err, req, res, next) { // eslint-disable-line no-unused-vars 53 | logger.file.error(err.name, err.message, err.stack); 54 | logger.console.error(err.name, err.message); 55 | send.error(res, { 56 | name: err.name, 57 | errorMessage: err.message, 58 | }); 59 | }); 60 | 61 | if (settings.protocol === 'http') { 62 | http.createServer(expressApp) 63 | .listen(expressApp.get('port'), () => { 64 | printInterfaces(logger, settings.protocol, settings.port); 65 | }); 66 | } else { 67 | const httpsSettings = settings.https; 68 | if (!httpsSettings) { 69 | throw new Error('Https configuration is missing'); 70 | } 71 | 72 | const httpsOptions = Object.assign({}, httpsSettings, readCertificates(dataPath, httpsSettings)); 73 | https.createServer(httpsOptions, expressApp) 74 | .listen(expressApp.get('port'), () => { 75 | printInterfaces(logger, settings.protocol, settings.port); 76 | }); 77 | } 78 | } 79 | 80 | module.exports = startPollServer; 81 | -------------------------------------------------------------------------------- /lib/server/print_interfaces.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const os = require('os'); 4 | 5 | function printInterfaces(logger, protocol, port) { 6 | const ifaces = os.networkInterfaces(); 7 | const ifaceNames = Object.keys(ifaces); 8 | logger.console.info('Server addresses:'); 9 | ifaceNames.forEach((name) => { 10 | const iface = ifaces[name]; 11 | iface.forEach((ifaceEntry) => { 12 | if (ifaceEntry.family === 'IPv4') { 13 | logger.console.info(`${ifaceEntry.family}: ${protocol}://${ifaceEntry.address}:${port}`); 14 | } else { 15 | logger.console.info(`${ifaceEntry.family}: ${protocol}://[${ifaceEntry.address}]:${port}`); 16 | } 17 | }); 18 | }); 19 | logger.file.info('Server start'); 20 | } 21 | 22 | module.exports = printInterfaces; 23 | -------------------------------------------------------------------------------- /lib/server/read_certificates.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | 4 | module.exports = function readCertificates(dataPath, tlsSettings) { 5 | const pfx = tlsSettings.pfx; 6 | const key = tlsSettings.key; 7 | const cert = tlsSettings.cert; 8 | 9 | if (cert && key) { 10 | return { 11 | key: fs.readFileSync(path.join(dataPath, key)), 12 | cert: fs.readFileSync(path.join(dataPath, cert)), 13 | }; 14 | } else if (pfx) { 15 | return { 16 | pfx: fs.readFileSync(path.join(dataPath, pfx)), 17 | }; 18 | } 19 | throw new Error('No certificates provided'); 20 | }; 21 | -------------------------------------------------------------------------------- /lib/server/server.js: -------------------------------------------------------------------------------- 1 | const startPollServer = require('./poll/server'); 2 | const startSocketServer = require('./socket/server'); 3 | 4 | function startServer({ 5 | logger, 6 | syncHandler, 7 | settings, 8 | dataPath, 9 | }) { 10 | if (settings.protocol === 'http' || settings.protocol === 'https') { 11 | startPollServer({ 12 | logger, 13 | syncHandler: syncHandler.pollHandler, 14 | settings, 15 | dataPath, 16 | }); 17 | } else if (settings.protocol === 'ws' || settings.protocol === 'wss') { 18 | startSocketServer({ 19 | logger, 20 | syncHandler: syncHandler.socketHandler, 21 | settings, 22 | dataPath, 23 | }); 24 | } else { 25 | throw new Error(`Protocol: ${settings.protocol} not supported`); 26 | } 27 | } 28 | 29 | module.exports = startServer; 30 | -------------------------------------------------------------------------------- /lib/server/socket/handlers.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | function sendData(conn, data) { 4 | conn.sendText(JSON.stringify(data)); 5 | } 6 | 7 | /* 8 | * Inform other clients when we get a change from some client 9 | */ 10 | function reactToChanges(conn) { 11 | return function ({ succeeded, data }) { 12 | const type = { type: succeeded ? 'changes' : 'error' }; 13 | sendData(conn, Object.assign({}, data, type)); 14 | }; 15 | } 16 | 17 | function handleSubscribe(conn, socketHandler, request) { 18 | // 'subscribe' event with syncedRevision. Return 'changes' with server changes 19 | socketHandler 20 | .handleSubscribe(conn.id, request, reactToChanges(conn)); 21 | } 22 | 23 | function handleInitialization(conn, socketHandler, request) { 24 | socketHandler 25 | .handleInitialization(conn.id, request) 26 | .then(({ succeeded, data }) => { 27 | const type = { type: succeeded ? 'clientIdentity' : 'error' }; 28 | return sendData(conn, Object.assign({}, data, type)); 29 | }); 30 | } 31 | 32 | function handleClientChanges(conn, socketHandler, request) { 33 | socketHandler 34 | .handleClientChanges(conn.id, request) 35 | .then(({ succeeded, data }) => { 36 | const type = { type: succeeded ? 'ack' : 'error' }; 37 | return sendData(conn, Object.assign({}, data, type)); 38 | }); 39 | } 40 | 41 | module.exports = { 42 | handleInitialization, 43 | handleClientChanges, 44 | handleSubscribe, 45 | }; 46 | -------------------------------------------------------------------------------- /lib/server/socket/server.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const ws = require('nodejs-websocket'); 4 | 5 | const handlers = require('./handlers'); 6 | const printInterfaces = require('../print_interfaces'); 7 | 8 | const readCertificates = require('../read_certificates'); 9 | 10 | let counter = 0; 11 | function getConnectionID() { 12 | counter = counter + 1; 13 | return counter; 14 | } 15 | 16 | function startSocketServer({ 17 | logger, 18 | syncHandler, 19 | settings, 20 | dataPath, 21 | }) { 22 | function connect(conn) { 23 | const socketHandler = syncHandler; 24 | conn.id = getConnectionID(); 25 | logger.file.info(`New connection. Connection ID ${conn.id}`); 26 | logger.console.info(`New connection. Connection ID ${conn.id}`); 27 | 28 | conn.on('text', (message) => { 29 | const request = JSON.parse(message); 30 | 31 | if (request.type === 'clientIdentity') { 32 | handlers.handleInitialization(conn, socketHandler, request); 33 | } else if (request.type === 'subscribe') { 34 | handlers.handleSubscribe(conn, socketHandler, request); 35 | } else if (request.type === 'changes') { 36 | handlers.handleClientChanges(conn, socketHandler, request); 37 | } 38 | }); 39 | 40 | conn.on('error', (err) => { 41 | logger.file.error(conn.id, err.name, err.message, err.stack); 42 | logger.console.error(conn.id, err.name, err.message); 43 | }); 44 | 45 | conn.on('close', (code, reason) => { 46 | socketHandler.handleConnectionClosed(conn.id); 47 | logger.file.info(conn.id, reason); 48 | logger.console.info(conn.id, code, reason); 49 | }); 50 | } 51 | 52 | if (settings.protocol === 'ws') { 53 | ws.createServer(connect).listen(settings.port, () => { 54 | printInterfaces(logger, settings.protocol, settings.port); 55 | }); 56 | } else { 57 | const wssSettings = settings.wss; 58 | if (!wssSettings) { 59 | throw new Error('WSS configuration is missing'); 60 | } 61 | 62 | // { secure: true } needed by nodejs-websocket 63 | const wssOptions = Object.assign({ secure: true }, wssSettings, readCertificates(dataPath, wssSettings)); 64 | 65 | ws.createServer(wssOptions, connect).listen(settings.port, () => { 66 | printInterfaces(logger, settings.protocol, settings.port); 67 | }); 68 | } 69 | } 70 | 71 | module.exports = startSocketServer; 72 | 73 | -------------------------------------------------------------------------------- /lib/sync/apply_client_changes.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * License: 5 | * The contents of this file were copied from 6 | * https://github.com/dfahlander/Dexie.js/blob/master/samples/remote-sync/websocket/WebSocketSyncServer.js 7 | * and are under the Apache 2 License. 8 | * 9 | * The code was modified to improve readability and to make it work with an asynchronous database. 10 | */ 11 | const reduceChanges = require('./reduce_changes'); 12 | const resolveConflicts = require('./resolve_conflicts'); 13 | const applyModifications = require('./apply_modifications'); 14 | const { CREATE, UPDATE, DELETE } = require('./types'); 15 | 16 | // Current revision will be incremented for each change 17 | function applyClientChanges(db, baseRevision, currentRevision, clientChanges, clientIdentity) { 18 | function handleCreate(change) { 19 | return db.addData(change.table, change.key, change.obj) 20 | .then(() => { 21 | return db.addChangesData({ 22 | rev: ++currentRevision.rev, 23 | source: clientIdentity, 24 | type: CREATE, 25 | table: change.table, 26 | key: change.key, 27 | obj: change.obj, 28 | }); 29 | }); 30 | } 31 | 32 | function handleUpdate(change) { 33 | return db.getData(change.table, change.key) 34 | .then((data) => { 35 | if (data) { 36 | applyModifications(data, change.mods); 37 | return db.updateData(change.table, change.key, data) 38 | .then(() => { 39 | return db.addChangesData({ 40 | rev: ++currentRevision.rev, 41 | source: clientIdentity, 42 | type: UPDATE, 43 | table: change.table, 44 | key: change.key, 45 | mods: change.mods, 46 | }); 47 | }); 48 | } 49 | return Promise.resolve(); 50 | }); 51 | } 52 | 53 | function handleDelete(change) { 54 | return db.getData(change.table, change.key) 55 | .then((data) => { 56 | if (data) { 57 | return db.removeData(change.table, change.key) 58 | .then(() => { 59 | return db.addChangesData({ 60 | rev: ++currentRevision.rev, 61 | source: clientIdentity, 62 | type: DELETE, 63 | table: change.table, 64 | key: change.key, 65 | }); 66 | }); 67 | } 68 | return Promise.resolve(); 69 | }); 70 | } 71 | 72 | const actions = { 73 | [CREATE]: handleCreate, 74 | [UPDATE]: handleUpdate, 75 | [DELETE]: handleDelete, 76 | }; 77 | 78 | // ---------------------------------------------- 79 | // HERE COMES THE QUITE IMPORTANT SYNC ALGORITHM! 80 | // 81 | // 1. Reduce all server changes (not client changes) that have occurred after given 82 | // baseRevision (our changes) to a set (key/value object where key is the combination of table/primaryKey) 83 | // 2. Check all client changes against reduced server 84 | // changes to detect conflict. Resolve conflicts: 85 | // If server created an object with same key as client creates, updates or deletes: Always discard client change. 86 | // If server deleted an object with same key as client creates, updates or deletes: Always discard client change. 87 | // If server updated an object with same key as client updates: 88 | // Apply all properties the client updates unless they conflict with server updates 89 | // If server updated an object with same key as client creates: 90 | // Apply the client create but apply the server update on top 91 | // If server updated an object with same key as client deletes: Let client win. Deletes always wins over Updates. 92 | // 93 | // 3. After resolving conflicts, apply client changes into server database. 94 | // 4. Send an ack to the client that we have persisted its changes 95 | // ---------------------------------------------- 96 | 97 | return db.getChangesData(baseRevision) 98 | .then((serverChanges) => reduceChanges(serverChanges)) 99 | .then((reducedServerChangeSet) => resolveConflicts(clientChanges, reducedServerChangeSet)) 100 | .then((resolved) => { 101 | const promises = resolved 102 | .map((change) => actions[change.type](change)); 103 | return Promise.all(promises); 104 | }); 105 | } 106 | 107 | module.exports = applyClientChanges; 108 | -------------------------------------------------------------------------------- /lib/sync/apply_modifications.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * License: 5 | * The contents of this file were copied from 6 | * https://github.com/dfahlander/Dexie.js/blob/master/samples/remote-sync/websocket/WebSocketSyncServer.js 7 | * and are under the Apache 2 License. 8 | * 9 | * The code was modified to improve readability. 10 | */ 11 | 12 | const setByKeyPath = require('./set_key_path'); 13 | 14 | module.exports = function applyModifications(obj, modifications) { 15 | Object 16 | .keys(modifications) 17 | .forEach((keyPath) => { 18 | setByKeyPath(obj, keyPath, modifications[keyPath]); 19 | }); 20 | return obj; 21 | }; 22 | -------------------------------------------------------------------------------- /lib/sync/combine_create_and_update.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * License: 5 | * The contents of this file were copied from 6 | * https://github.com/dfahlander/Dexie.js/blob/master/samples/remote-sync/websocket/WebSocketSyncServer.js 7 | * and are under the Apache 2 License. 8 | * 9 | * The code was modified to improve readability. 10 | */ 11 | 12 | const applyModifications = require('./apply_modifications'); 13 | const deepClone = require('./deep_clone'); 14 | 15 | module.exports = function combineCreateAndUpdate(prevChange, nextChange) { 16 | // Clone object before modifying since the earlier change in db.changes[] would otherwise be altered. 17 | const clonedChange = deepClone(prevChange); 18 | applyModifications(clonedChange.obj, nextChange.mods); // Apply modifications to existing object. 19 | return clonedChange; 20 | }; 21 | -------------------------------------------------------------------------------- /lib/sync/combine_update_and_update.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * License: 5 | * The contents of this file were copied from 6 | * https://github.com/dfahlander/Dexie.js/blob/master/samples/remote-sync/websocket/WebSocketSyncServer.js 7 | * and are under the Apache 2 License. 8 | * 9 | * The code was modified to improve readability. 10 | */ 11 | 12 | const setByKeyPath = require('./set_key_path'); 13 | const deepClone = require('./deep_clone'); 14 | 15 | module.exports = function combineUpdateAndUpdate(prevChange, nextChange) { 16 | // Clone object before modifying since the earlier change in db.changes[] would otherwise be altered. 17 | const clonedChange = deepClone(prevChange); 18 | Object 19 | .keys(nextChange.mods) 20 | .forEach((keyPath) => { 21 | // If prev-change was changing a parent path of this keyPath, 22 | // we must update the parent path rather than adding this keyPath 23 | let hadParentPath = false; 24 | Object 25 | .keys(prevChange.mods) 26 | .filter((parentPath) => keyPath.indexOf(parentPath + '.') === 0) 27 | .forEach((parentPath) => { 28 | setByKeyPath( 29 | clonedChange.mods[parentPath], 30 | keyPath.substr(parentPath.length + 1), 31 | nextChange.mods[keyPath] 32 | ); 33 | hadParentPath = true; 34 | }); 35 | if (!hadParentPath) { 36 | // Add or replace this keyPath and its new value 37 | clonedChange.mods[keyPath] = nextChange.mods[keyPath]; 38 | } 39 | // In case prevChange contained sub-paths to the new keyPath, 40 | // we must make sure that those sub-paths are removed since 41 | // we must mimic what would happen if applying the two changes after each other: 42 | Object 43 | .keys(prevChange.mods) 44 | .filter((subPath) => subPath.indexOf(keyPath + '.') === 0) 45 | .forEach((subPath) => { 46 | Reflect.deleteProperty(clonedChange.mods, subPath); 47 | }); 48 | }); 49 | return clonedChange; 50 | }; 51 | -------------------------------------------------------------------------------- /lib/sync/deep_clone.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * License: 5 | * The contents of this file were copied from 6 | * https://github.com/dfahlander/Dexie.js/blob/master/samples/remote-sync/websocket/WebSocketSyncServer.js 7 | * and are under the Apache 2 License. 8 | * 9 | */ 10 | 11 | module.exports = function deepClone(obj) { 12 | return JSON.parse(JSON.stringify(obj)); 13 | }; 14 | -------------------------------------------------------------------------------- /lib/sync/get_server_changes.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * License: 5 | * The contents of this file were copied from 6 | * https://github.com/dfahlander/Dexie.js/blob/master/samples/remote-sync/websocket/WebSocketSyncServer.js 7 | * and are under the Apache 2 License. 8 | * 9 | * The code was modified to improve readability and to make it work with an asynchronous database. 10 | */ 11 | 12 | const reduceChanges = require('./reduce_changes'); 13 | 14 | // Attention: the changes array myst be sorted ascending by revision 15 | function getServerChanges(db, syncedRevision, clientIdentity, partialsThreshold, currentDBRevision) { 16 | // Get all changes after syncedRevision that was not performed by the client we're talking to. 17 | return db.getChangesData(syncedRevision, clientIdentity) 18 | // Compact changes so that multiple changes on same object are merged into a single change. 19 | .then((changes) => reduceChanges(changes)) 20 | // Convert the reduced set into an array again. 21 | .then((reducedChangeSet) => Object.keys(reducedChangeSet).map((key) => reducedChangeSet[key])) 22 | .then((reducedArray) => { 23 | if (reducedArray.length > partialsThreshold) { 24 | const changesToSend = reducedArray.slice(0, partialsThreshold); 25 | return { 26 | changes: changesToSend, 27 | partial: true, 28 | }; 29 | } 30 | return { 31 | changes: reducedArray, 32 | partial: false, 33 | }; 34 | }) 35 | .then(({ changes, partial }) => ({ 36 | changes, 37 | partial, 38 | // Save the last revision for which the client will receive changes 39 | // so the the next time it gets changes with a larger revision 40 | // If we have no new changes the return the current db revision 41 | // This allows use to skip our changes the next time we try to apply client changes 42 | currentRevision: changes[changes.length - 1] ? changes[changes.length - 1].rev : currentDBRevision.rev, 43 | })) 44 | .then(({ changes, partial, currentRevision }) => ({ 45 | changes: changes.map((change) => { 46 | // Don't send 'rev' back to client 47 | Reflect.deleteProperty(change, 'rev'); 48 | return change; 49 | }), 50 | partial, 51 | currentRevision, 52 | })); 53 | } 54 | 55 | module.exports = getServerChanges; 56 | -------------------------------------------------------------------------------- /lib/sync/handle_client_changes.js: -------------------------------------------------------------------------------- 1 | const applyClientChanges = require('./apply_client_changes'); 2 | 3 | function handleClientChanges(db, baseRevision, nextRevision, partial, clientID, changes) { 4 | if (partial) { 5 | return db.uncommittedChanges.update(clientID, changes); 6 | } 7 | return db.uncommittedChanges 8 | .get(clientID) 9 | .then((uncommittedChangesObj) => { 10 | return applyClientChanges( 11 | db, 12 | baseRevision, 13 | nextRevision, 14 | [...uncommittedChangesObj.changes, ...changes], 15 | clientID 16 | ); 17 | }); 18 | } 19 | 20 | module.exports = handleClientChanges; 21 | -------------------------------------------------------------------------------- /lib/sync/init_handlers.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const initPollHandler = require('./poll_handler'); 4 | const initSocketHandler = require('./socket_handler'); 5 | 6 | function initHandler(db, logger, opts) { 7 | return db.getRevision() 8 | .then((rev) => ({ rev })) 9 | .then((currentDBRevision) => { 10 | const pollHandler = initPollHandler(db, logger, opts, currentDBRevision); 11 | const socketHandler = initSocketHandler(db, logger, opts, currentDBRevision); 12 | 13 | return { 14 | pollHandler, 15 | socketHandler, 16 | }; 17 | }); 18 | } 19 | 20 | module.exports = initHandler; 21 | -------------------------------------------------------------------------------- /lib/sync/poll_handler.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * License: 5 | * The contents of this file were copied from 6 | * https://github.com/dfahlander/Dexie.js/blob/master/samples/remote-sync/websocket/WebSocketSyncServer.js 7 | * and are under the Apache 2 License. 8 | * 9 | * The code was modified to improve readability and to make it work with an asynchronous database. 10 | */ 11 | 12 | const getServerChanges = require('./get_server_changes'); 13 | const handleClientChanges = require('./handle_client_changes'); 14 | 15 | function initPollHandler(db, logger, opts, currentDBRevision) { 16 | return function handler({ 17 | baseRevision, 18 | changes = [], 19 | clientIdentity, 20 | syncedRevision, 21 | requestId, 22 | partial = false, 23 | }) { 24 | let promise; 25 | if (clientIdentity) { 26 | promise = Promise.resolve(clientIdentity); 27 | } else { 28 | promise = db.getNextClientID(); 29 | } 30 | 31 | // Syncable sends null the first time. We can't use default parameters for this 32 | baseRevision = baseRevision || 0; 33 | syncedRevision = syncedRevision || 0; 34 | 35 | return promise.then((clientID) => { 36 | logger.file.info(`clientIdentity: ${clientID}, requestId: ${requestId}`); 37 | logger.console.info(`clientIdentity: ${clientID}, requestId: ${requestId}`); 38 | 39 | return handleClientChanges(db, baseRevision, currentDBRevision, partial, clientID, changes) 40 | .then(() => getServerChanges(db, syncedRevision, clientID, opts.partialsThreshold, currentDBRevision)) 41 | .then(({ changes, partial, currentRevision }) => ({ 42 | changes, 43 | partial, 44 | currentRevision, 45 | clientIdentity: clientID, 46 | success: true, 47 | requestId, 48 | })) 49 | .catch((err) => { 50 | const msg = `clientIdentity: ${clientID}. requestId: ${requestId}. Error: ${err.name} ${err.message}`; 51 | logger.file.error(msg, err.stack); 52 | logger.console.error(msg); 53 | return { 54 | success: false, 55 | requestId, 56 | errorMessage: err.message, 57 | }; 58 | }); 59 | }); 60 | }; 61 | } 62 | 63 | module.exports = initPollHandler; 64 | -------------------------------------------------------------------------------- /lib/sync/reduce_changes.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * License: 5 | * The contents of this file were copied from 6 | * https://github.com/dfahlander/Dexie.js/blob/master/samples/remote-sync/websocket/WebSocketSyncServer.js 7 | * and are under the Apache 2 License. 8 | * 9 | * The code was modified to improve readability. 10 | */ 11 | 12 | const { CREATE, UPDATE, DELETE } = require('./types'); 13 | const combineCreateAndUpdate = require('./combine_create_and_update'); 14 | const combineUpdateAndUpdate = require('./combine_update_and_update'); 15 | 16 | function reduceCreateChange(prevChange, nextChange) { 17 | switch (nextChange.type) { 18 | case CREATE: 19 | return nextChange; // Another CREATE replaces previous CREATE. 20 | case UPDATE: 21 | return combineCreateAndUpdate(prevChange, nextChange); // Apply nextChange.mods into prevChange.obj 22 | case DELETE: 23 | // Object created and then deleted. If it wasn't for that we MUST handle resent changes, 24 | // we would skip the entire change here. But what if the CREATE was sent earlier, 25 | // and then CREATE/DELETE at a later stage? 26 | // It would become a ghost object in the DB. Therefore, we MUST keep the delete change! 27 | // If object doesn't exist, it wont harm! 28 | return nextChange; 29 | } 30 | } 31 | 32 | function reduceUpdateChange(prevChange, nextChange) { 33 | switch (nextChange.type) { 34 | case CREATE: 35 | return nextChange; // Another CREATE replaces previous update. 36 | case UPDATE: 37 | // Add the additional modifications to existing modification map. 38 | return combineUpdateAndUpdate(prevChange, nextChange); 39 | case DELETE: 40 | return nextChange; // Only send the delete change. What was updated earlier is no longer of interest. 41 | } 42 | } 43 | 44 | function reduceDeleteChange(prevChange, nextChange) { 45 | switch (nextChange.type) { 46 | case CREATE: 47 | return nextChange; // A resurrection occurred. Only create change is of interest. 48 | case UPDATE: 49 | return prevChange; // Nothing to do. We cannot update an object that doesn't exist. Leave the delete change there. 50 | case DELETE: 51 | return prevChange; // Still a delete change. Leave as is. 52 | } 53 | } 54 | 55 | const actions = { 56 | [CREATE]: reduceCreateChange, 57 | [UPDATE]: reduceUpdateChange, 58 | [DELETE]: reduceDeleteChange, 59 | }; 60 | 61 | module.exports = function reduceChanges(changes) { 62 | // Converts an Array of change objects to a set of change objects based on its unique combination of (table ":" key). 63 | // If several changes were applied to the same object, the resulting set will only contain one change for that object. 64 | return changes.reduce((map, nextChange) => { 65 | const id = `${nextChange.table}:${nextChange.key}`; 66 | const prevChange = map[id]; 67 | if (!prevChange) { 68 | // This is the first change on this key. 69 | map[id] = nextChange; 70 | } else { 71 | // Merge the oldchange with the new change 72 | map[id] = actions[prevChange.type](prevChange, nextChange); 73 | } 74 | return map; 75 | }, {}); 76 | }; 77 | -------------------------------------------------------------------------------- /lib/sync/resolve_conflicts.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * License: 5 | * The contents of this file were copied from 6 | * https://github.com/dfahlander/Dexie.js/blob/master/samples/remote-sync/websocket/WebSocketSyncServer.js 7 | * and are under the Apache 2 License. 8 | * 9 | * The code was modified to improve readability. 10 | */ 11 | 12 | const applyModifications = require('./apply_modifications'); 13 | const { CREATE, UPDATE, DELETE } = require('./types'); 14 | 15 | module.exports = function resolveConflicts(clientChanges, serverChangeSet) { 16 | const resolved = []; 17 | clientChanges 18 | .forEach((clientChange) => { 19 | const id = `${clientChange.table}:${clientChange.key}`; 20 | const serverChange = serverChangeSet[id]; 21 | if (!serverChange) { 22 | // No server change on same object. Totally conflict free! 23 | resolved.push(clientChange); 24 | } else if (serverChange.type === UPDATE) { 25 | // Server change overlaps. 26 | // Only if server change is not CREATE or DELETE, we should consider merging in the client change. 27 | switch (clientChange.type) { 28 | case CREATE: 29 | // Server has updated an object with same key as client has recreated. 30 | // Let the client recreation go through, but also apply server modifications. 31 | // No need to clone clientChange.obj before applying modifications 32 | // since no one else refers the clientChanges (it was retrieved from the current request) 33 | applyModifications(clientChange.obj, serverChange.mods); 34 | resolved.push(clientChange); 35 | break; 36 | case UPDATE: 37 | // Server and client has updated the same object. 38 | // Just remove any overlapping keyPaths and only apply non-conflicting parts. 39 | Object 40 | .keys(serverChange.mods) 41 | .forEach((keyPath) => { 42 | // Remove this property from the client change 43 | Reflect.deleteProperty(clientChange.mods, keyPath); 44 | // Also, remove all changes to nested objects under this keyPath from the client change: 45 | Object 46 | .keys(clientChange.mods) 47 | .forEach((clientKeyPath) => { 48 | if (clientKeyPath.indexOf(keyPath + '.') === 0) { 49 | Reflect.deleteProperty(clientChange.mods, clientKeyPath); 50 | } 51 | }); 52 | }); 53 | // Did we delete all keyPaths in the modification set of the clientChange? 54 | if (Object.keys(clientChange.mods).length > 0) { 55 | // No, there were some still there. Let this wing-clipped change be applied: 56 | resolved.push(clientChange); 57 | } 58 | break; 59 | case DELETE: 60 | // Delete always win over update. Even client over a server 61 | resolved.push(clientChange); 62 | break; 63 | } 64 | } // else if serverChange.type is CREATE or DELETE, don't push anything to resolved, 65 | // because the client change is not of any interest 66 | // (CREATE or DELETE would eliminate any client change with the same key!) 67 | }); 68 | return resolved; 69 | }; 70 | -------------------------------------------------------------------------------- /lib/sync/set_key_path.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * License: 5 | * The contents of this file were copied from 6 | * https://github.com/dfahlander/Dexie.js/blob/master/samples/remote-sync/websocket/WebSocketSyncServer.js 7 | * and are under the Apache 2 License. 8 | * 9 | * The code was modified to improve readability. 10 | */ 11 | 12 | module.exports = function setByKeyPath(obj, keyPath, value) { 13 | if (!obj || typeof keyPath !== 'string') { 14 | return; 15 | } 16 | const period = keyPath.indexOf('.'); 17 | if (period !== -1) { 18 | const currentKeyPath = keyPath.substr(0, period); 19 | const remainingKeyPath = keyPath.substr(period + 1); 20 | if (remainingKeyPath === '') { 21 | obj[currentKeyPath] = value; 22 | } else { 23 | let innerObj = obj[currentKeyPath]; 24 | if (!innerObj) { 25 | innerObj = (obj[currentKeyPath] = {}); 26 | } 27 | setByKeyPath(innerObj, remainingKeyPath, value); 28 | } 29 | } else { 30 | obj[keyPath] = value; 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /lib/sync/socket_handler.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const getServerChanges = require('./get_server_changes'); 4 | const _handleClientChanges = require('./handle_client_changes'); 5 | 6 | function initSocketHandler(db, logger, opts, currentDBRevision) { 7 | // connID to callback 8 | const subscriptions = new Map(); 9 | const connToClientIdentity = new Map(); 10 | const clientIDToRevision = new Map(); 11 | 12 | function initSendChangesBackToClient(cb, clientIdentity) { 13 | return function sendChangesBackToClient(syncedRevision) { 14 | return getServerChanges(db, syncedRevision, clientIdentity, opts.partialsThreshold, currentDBRevision) 15 | .then((data) => { 16 | // Update client revision to the latest revision 17 | clientIDToRevision.set(clientIdentity, data.currentRevision); 18 | // will send 'changes' to the client 19 | cb({ succeeded: true, data }); 20 | // We had partial data -> try to send the rest to client 21 | if (data.partial) { 22 | return sendChangesBackToClient(data.currentRevision); 23 | } 24 | const msg = `Send data: clientIdentity: ${clientIdentity}`; 25 | logger.file.info(msg); 26 | logger.console.info(msg); 27 | }) 28 | .catch((err) => { 29 | // will send 'error' to 30 | const msg = `clientIdentity: ${clientIdentity}. Error: ${err.name} ${err.message}`; 31 | logger.file.error(msg, err.stack); 32 | logger.console.error(msg); 33 | cb({ 34 | succeeded: false, 35 | data: { 36 | errorMessage: err.message, 37 | }, 38 | }); 39 | }); 40 | }; 41 | } 42 | 43 | function sendChangesBackToClients(currentClientIdentity) { 44 | const entries = connToClientIdentity.entries(); 45 | [...entries] 46 | .forEach(([connID, clientIdentity]) => { 47 | subscriptions.get(connID)(clientIDToRevision.get(clientIdentity)); 48 | }); 49 | } 50 | 51 | // will send 'clientIdentity' to the client 52 | function handleInitialization(connID, { clientIdentity }) { 53 | let promise; 54 | if (clientIdentity) { 55 | promise = Promise.resolve(clientIdentity); 56 | } else { 57 | promise = db.getNextClientID(); 58 | } 59 | 60 | return promise.then((clientID) => { 61 | connToClientIdentity.set(connID, clientID); 62 | return { 63 | succeeded: true, 64 | data: { clientIdentity: clientID }, 65 | }; 66 | }); 67 | } 68 | 69 | function handleSubscribe(connID, { syncedRevision }, cb) { 70 | const clientIdentity = connToClientIdentity.get(connID); 71 | const sendChangesToClient = initSendChangesBackToClient(cb, clientIdentity); 72 | subscriptions.set(connID, sendChangesToClient); 73 | 74 | // Syncable sends null the first time. We can't use default parameters for this 75 | syncedRevision = syncedRevision || 0; 76 | 77 | const msg = `Subscribe: clientIdentity: ${clientIdentity}`; 78 | logger.file.info(msg); 79 | logger.console.info(msg); 80 | 81 | return sendChangesToClient(syncedRevision); 82 | } 83 | 84 | function handleClientChanges(connID, { baseRevision, changes, partial = false, requestId }) { 85 | const clientIdentity = connToClientIdentity.get(connID); 86 | 87 | // Syncable sends null the first time. We can't use default parameters for this 88 | baseRevision = baseRevision || 0; 89 | 90 | const msg = `clientIdentity: ${clientIdentity}. requestId: ${requestId}`; 91 | logger.file.info(msg); 92 | logger.console.info(msg); 93 | 94 | // Will send 'ack' to client 95 | return _handleClientChanges(db, baseRevision, currentDBRevision, partial, clientIdentity, changes) 96 | .then(() => { 97 | sendChangesBackToClients(clientIdentity); 98 | }) 99 | // will send 'ack' to client 100 | .then(() => ({ 101 | succeeded: true, 102 | data: { requestId }, 103 | })) 104 | .catch((err) => { 105 | const errorMsg = `clientIdentity: ${clientIdentity}. 106 | requestId: ${requestId}. Error: ${err.name} ${err.message}`; 107 | logger.file.error(errorMsg); 108 | logger.console.error(errorMsg); 109 | // will send 'error' to client 110 | return { 111 | succeeded: false, 112 | data: { 113 | errorMessage: err.message, 114 | requestId, 115 | }, 116 | }; 117 | }); 118 | } 119 | 120 | function handleConnectionClosed(connID) { 121 | const msg = `Connection closed: clientIdentity: ${connToClientIdentity.get(connID)}`; 122 | logger.file.info(msg); 123 | logger.console.info(msg); 124 | 125 | subscriptions.delete(connID); 126 | connToClientIdentity.delete(connID); 127 | } 128 | 129 | return { 130 | handleInitialization, 131 | handleSubscribe, 132 | handleClientChanges, 133 | handleConnectionClosed, 134 | // Used for testing 135 | _connToClientIdentity: connToClientIdentity, 136 | _subscriptions: subscriptions, 137 | _clientIDToRevision: clientIDToRevision, 138 | }; 139 | } 140 | 141 | module.exports = initSocketHandler; 142 | -------------------------------------------------------------------------------- /lib/sync/types.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | CREATE: 1, 5 | UPDATE: 2, 6 | DELETE: 3, 7 | }; 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sync-server", 3 | "version": "2.0.0-beta.7", 4 | "bugs": { 5 | "url": "https://github.com/nponiros/sync_server/issues" 6 | }, 7 | "license": "MIT", 8 | "author": "Nikolas Poniros ", 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/nponiros/sync_server.git" 12 | }, 13 | "dependencies": { 14 | "body-parser": "1.18.2", 15 | "cors": "2.8.4", 16 | "express": "4.16.2", 17 | "minimist": "1.2.0", 18 | "nedb": "1.8.0", 19 | "nodejs-websocket": "1.7.1", 20 | "winston": "2.3.1", 21 | "yargs": "6.6.0" 22 | }, 23 | "devDependencies": { 24 | "chakram": "1.5.0", 25 | "eslint": "3.14.0", 26 | "eslint-config-standard": "6.2.1", 27 | "eslint-plugin-promise": "3.4.0", 28 | "eslint-plugin-standard": "2.0.1", 29 | "istanbul": "0.4.5", 30 | "mocha": "3.2.0", 31 | "publish-please": "2.2.0" 32 | }, 33 | "scripts": { 34 | "test": "npm run lint && npm run test:unit && npm run test:integration", 35 | "test:integration": "mocha --reporter spec 'test/integration/**/*.spec.js'", 36 | "test:unit": "mocha --reporter spec 'test/unit/**/*.spec.js'", 37 | "test:coverage": "istanbul cover --include-all-sources _mocha 'test/**/*.spec.js'", 38 | "lint": "eslint --config .eslintrc 'lib/**/*.js' bin/main.js 'test/**/*.js'", 39 | "lint:fix": "eslint --fix --config .eslintrc 'lib/**/*.js' bin/main.js 'test/**/*.js'", 40 | "publish-please": "publish-please", 41 | "prepublish": "publish-please guard" 42 | }, 43 | "engines": { 44 | "node": ">=6.0.0" 45 | }, 46 | "preferGlobal": true, 47 | "bin": { 48 | "sync-server": "./bin/main.js" 49 | }, 50 | "description": "A simple server which can be used to synchronize data from Dexie.Syncable and an ISyncProtocol implementation and write them into files", 51 | "keywords": [ 52 | "file database", 53 | "Dexie", 54 | "Syncable", 55 | "ISyncProtocol" 56 | ] 57 | } 58 | -------------------------------------------------------------------------------- /samples/ajax/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Ajax Example 6 | 7 | 8 | 9 | 10 |

Items list

11 | 12 |
    13 | 14 | 15 | -------------------------------------------------------------------------------- /samples/ajax/index.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | const URL = 'http://localhost:3000'; 3 | const items = []; 4 | const changes = []; 5 | let list; 6 | let clientIdentity; 7 | let syncedRevision = 0; 8 | let baseRevision = 0; 9 | 10 | function getID() { 11 | function s4() { 12 | return Math.floor((1 + Math.random()) * 0x10000) 13 | .toString(16) 14 | .substring(1); 15 | } 16 | 17 | return s4() + s4() + '-' + s4() + '-' + s4() + '-' + 18 | s4() + '-' + s4() + s4() + s4(); 19 | } 20 | 21 | function renderList() { 22 | list.innerHTML = items.map((item) => `
  • ${item}
  • `).join(''); 23 | } 24 | 25 | function sync() { 26 | const dataToSend = { 27 | baseRevision, 28 | changes, 29 | clientIdentity, 30 | syncedRevision, 31 | }; 32 | const headers = new Headers(); 33 | headers.set('Content-Type', 'application/json'); 34 | fetch(URL, { 35 | headers, 36 | method: 'POST', 37 | body: JSON.stringify(dataToSend), 38 | mode: 'cors', 39 | }) 40 | .then((resp) => resp.json()) 41 | .then((data) => { 42 | if (data.success) { 43 | syncedRevision = data.currentRevision; 44 | clientIdentity = data.clientIdentity; 45 | baseRevision++; 46 | items.push(...data.changes.map((change) => change.obj.item)); 47 | renderList(); 48 | } else { 49 | console.error('Error:', data.errorMessage, data); 50 | } 51 | }) 52 | .catch((e) => { 53 | console.error(e); 54 | }); 55 | } 56 | 57 | window.addEventListener('load', () => { 58 | list = document.getElementById('list'); 59 | const btn = document.getElementById('addBtn'); 60 | const input = document.getElementById('itemInput'); 61 | 62 | btn.addEventListener('click', () => { 63 | items.push(input.value); 64 | changes.push({ 65 | type: 1, // Create 66 | key: getID(), 67 | table: 'items', 68 | obj: {item: input.value}, 69 | }); 70 | renderList(); 71 | }); 72 | 73 | sync(); 74 | setInterval(sync, 2000); 75 | }); 76 | })(); 77 | -------------------------------------------------------------------------------- /samples/socket/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Socket Example 6 | 7 | 8 | 9 | 10 |

    Items list

    11 | 12 |
      13 | 14 | 15 | -------------------------------------------------------------------------------- /samples/socket/index.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | const URL = 'ws://localhost:3000'; 3 | const items = []; 4 | let list; 5 | let ws; 6 | let clientIdentity; 7 | let syncedRevision = 0; 8 | let requestId = 0; 9 | let baseRevision = 0; 10 | 11 | function getID() { 12 | function s4() { 13 | return Math.floor((1 + Math.random()) * 0x10000) 14 | .toString(16) 15 | .substring(1); 16 | } 17 | 18 | return s4() + s4() + '-' + s4() + '-' + s4() + '-' + 19 | s4() + '-' + s4() + s4() + s4(); 20 | } 21 | 22 | function renderList() { 23 | list.innerHTML = items.map((item) => `
    • ${item}
    • `).join(''); 24 | } 25 | 26 | function onMessage(event) { 27 | const resp = JSON.parse(event.data); 28 | 29 | if (resp.type === 'clientIdentity') { 30 | console.log('Client identity', resp.clientIdentity); 31 | 32 | // Subscribe here to make sure that we have received the clientIdentity 33 | console.log('Send subscribe'); 34 | ws.send(JSON.stringify({ 35 | type: 'subscribe', 36 | syncedRevision, 37 | })); 38 | } else if (resp.type === 'changes') { 39 | syncedRevision = resp.currentRevision; 40 | items.push(...resp.changes.map((change) => change.obj.item)); 41 | renderList(list, items); 42 | } else if (resp.type === 'error') { 43 | console.error('Message:', resp.errorMessage, 'RequestId:', resp.requestId); 44 | } else if (resp.type === 'ack') { 45 | baseRevision++; 46 | console.log('ack', resp.requestId); 47 | } 48 | } 49 | 50 | function initConnection() { 51 | ws = new WebSocket(URL); 52 | ws.onopen = function () { 53 | console.log('Send clientIdentity'); 54 | ws.send(JSON.stringify({ 55 | type: 'clientIdentity', 56 | })); 57 | }; 58 | 59 | ws.onclose = function () { 60 | console.log('Connection closed'); 61 | }; 62 | 63 | ws.onerror = function () { 64 | console.error('Connection error'); 65 | }; 66 | 67 | ws.onmessage = onMessage; 68 | } 69 | 70 | window.addEventListener('load', () => { 71 | list = document.getElementById('list'); 72 | const btn = document.getElementById('addBtn'); 73 | const input = document.getElementById('itemInput'); 74 | 75 | btn.addEventListener('click', () => { 76 | items.push(input.value); 77 | ws.send(JSON.stringify({ 78 | type: 'changes', 79 | baseRevision, 80 | requestId, 81 | changes: [{ 82 | type: 1, // Create 83 | key: getID(), 84 | table: 'items', 85 | obj: {item: input.value}, 86 | }] 87 | })); 88 | requestId++; 89 | renderList(); 90 | }); 91 | 92 | initConnection(); 93 | }); 94 | })(); 95 | -------------------------------------------------------------------------------- /test/integration/sync_and_db/apply_client_changes.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const chakram = require('chakram'); 4 | const expect = chakram.expect; 5 | 6 | const applyClientChanges = require('../../../lib/sync/apply_client_changes'); 7 | const Db = require('../../../lib/db_connectors/NeDB/db'); 8 | const { CREATE, UPDATE, DELETE } = require('../../../lib/sync/types'); 9 | 10 | const logger = { 11 | file: { 12 | info() { 13 | }, 14 | error() { 15 | }, 16 | }, 17 | console: { 18 | info() { 19 | }, 20 | error() { 21 | }, 22 | }, 23 | }; 24 | 25 | describe('applyClientChanges', () => { 26 | let db; 27 | 28 | beforeEach((done) => { 29 | db = new Db({ inMemoryOnly: true }, logger); 30 | db.init() 31 | .then(() => { 32 | done(); 33 | }) 34 | .catch((e) => { 35 | done(e); 36 | }); 37 | }); 38 | 39 | describe('CREATE', () => { 40 | it('should add the given client data to the given table', (done) => { 41 | const create = { 42 | type: CREATE, 43 | obj: { foo: 'bar' }, 44 | key: 1, 45 | table: 'foo', 46 | }; 47 | db.addData('foo', 3, {}) 48 | .then(() => applyClientChanges(db, 0, { rev: 1 }, [create], 1)) 49 | .then(() => db.getData('foo', 1)) 50 | .then((data) => { 51 | expect(data).to.deep.equal(create.obj); 52 | done(); 53 | }) 54 | .catch((e) => { 55 | done(e); 56 | }); 57 | }); 58 | 59 | it('should add the change object to the changes table', (done) => { 60 | const create = { 61 | type: CREATE, 62 | obj: { foo: 'bar' }, 63 | key: 1, 64 | table: 'foo', 65 | }; 66 | const clientID = 1; 67 | const currentRevision = { rev: 1 }; 68 | applyClientChanges(db, 0, currentRevision, [create], clientID) 69 | .then(() => { 70 | return new Promise((resolve, reject) => { 71 | db.changesTable.store.find({}, (err, data) => { 72 | if (err) { 73 | reject(err); 74 | } 75 | resolve(data); 76 | }); 77 | }); 78 | }) 79 | .then((data) => { 80 | expect(data[0].type).to.equal(CREATE); 81 | expect(data[0].obj).to.deep.equal(create.obj); 82 | expect(data[0].rev).to.equal(2); // Revision was incremented by 1 compared to the current revision 83 | expect(data[0].source).to.equal(clientID); 84 | done(); 85 | }) 86 | .catch((e) => { 87 | done(e); 88 | }); 89 | }); 90 | }); 91 | 92 | describe('UPDATE', () => { 93 | it('shouldn\'t add a change if the given table does not exist', (done) => { 94 | const update = { 95 | type: UPDATE, 96 | table: 'foo', 97 | key: 2, 98 | mods: { foo: 'bar' }, 99 | }; 100 | const clientID = 1; 101 | const currentRevision = { rev: 1 }; 102 | applyClientChanges(db, 0, currentRevision, [update], clientID) 103 | .then(() => { 104 | return new Promise((resolve, reject) => { 105 | db.changesTable.store.count({}, (err, count) => { 106 | if (err) { 107 | reject(err); 108 | } 109 | resolve(count); 110 | }); 111 | }); 112 | }) 113 | .then((count) => { 114 | expect(count).to.equal(0); 115 | done(); 116 | }) 117 | .catch((e) => { 118 | done(e); 119 | }); 120 | }); 121 | 122 | it('shouldn\'t add a change if the given object key does not exist', (done) => { 123 | const update = { 124 | type: UPDATE, 125 | table: 'foo', 126 | key: 2, 127 | mods: { foo: 'bar' }, 128 | }; 129 | const clientID = 1; 130 | const currentRevision = { rev: 1 }; 131 | // Create table 132 | db.addData('foo', 3, {}) 133 | .then(() => applyClientChanges(db, 0, currentRevision, [update], clientID)) 134 | .then(() => { 135 | return new Promise((resolve, reject) => { 136 | db.changesTable.store.count({}, (err, count) => { 137 | if (err) { 138 | reject(err); 139 | } 140 | resolve(count); 141 | }); 142 | }); 143 | }) 144 | .then((count) => { 145 | expect(count).to.equal(0); 146 | done(); 147 | }) 148 | .catch((e) => { 149 | done(e); 150 | }); 151 | }); 152 | 153 | it('should merge the given object with the object in the db and save it', (done) => { 154 | const update = { 155 | type: UPDATE, 156 | table: 'foo', 157 | key: 2, 158 | mods: { foo: 'bar' }, 159 | }; 160 | const clientID = 1; 161 | const currentRevision = { rev: 1 }; 162 | // Create table 163 | db.addData('foo', 2, { bar: 'bar' }) 164 | .then(() => applyClientChanges(db, 0, currentRevision, [update], clientID)) 165 | .then(() => db.getData('foo', 2)) 166 | .then((data) => { 167 | expect(data).to.deep.equal({ foo: 'bar', bar: 'bar' }); 168 | done(); 169 | }) 170 | .catch((e) => { 171 | done(e); 172 | }); 173 | }); 174 | 175 | it('should add the change to the changes table', (done) => { 176 | const update = { 177 | type: UPDATE, 178 | table: 'foo', 179 | key: 2, 180 | mods: { foo: 'bar' }, 181 | }; 182 | const clientID = 1; 183 | const currentRevision = { rev: 1 }; 184 | // Create table 185 | db.addData('foo', 2, {}) 186 | .then(() => applyClientChanges(db, 0, currentRevision, [update], clientID)) 187 | .then(() => { 188 | return new Promise((resolve, reject) => { 189 | db.changesTable.store.find({}, (err, data) => { 190 | if (err) { 191 | reject(err); 192 | } 193 | resolve(data); 194 | }); 195 | }); 196 | }) 197 | .then((data) => { 198 | expect(data.length).to.equal(1); 199 | expect(data[0].type).to.equal(UPDATE); 200 | expect(data[0].mods).to.deep.equal(update.mods); 201 | expect(data[0].rev).to.equal(2); // Revision was incremented by 1 compared to the current revision 202 | expect(data[0].source).to.equal(clientID); 203 | done(); 204 | }) 205 | .catch((e) => { 206 | done(e); 207 | }); 208 | }); 209 | }); 210 | 211 | describe('DELETE', () => { 212 | it('shouldn\'t add a change if the given table does not exist', (done) => { 213 | const remove = { 214 | type: DELETE, 215 | table: 'foo', 216 | key: 2, 217 | }; 218 | const clientID = 1; 219 | const currentRevision = { rev: 1 }; 220 | applyClientChanges(db, 0, currentRevision, [remove], clientID) 221 | .then(() => { 222 | return new Promise((resolve, reject) => { 223 | db.changesTable.store.count({}, (err, count) => { 224 | if (err) { 225 | reject(err); 226 | } 227 | resolve(count); 228 | }); 229 | }); 230 | }) 231 | .then((count) => { 232 | expect(count).to.equal(0); 233 | done(); 234 | }) 235 | .catch((e) => { 236 | done(e); 237 | }); 238 | }); 239 | 240 | it('shouldn\'t add a change if the given object key does not exist', (done) => { 241 | const remove = { 242 | type: DELETE, 243 | table: 'foo', 244 | key: 2, 245 | }; 246 | const clientID = 1; 247 | const currentRevision = { rev: 1 }; 248 | // Create table 249 | db.addData('foo', 3, {}) 250 | .then(() => applyClientChanges(db, 0, currentRevision, [remove], clientID)) 251 | .then(() => { 252 | return new Promise((resolve, reject) => { 253 | db.changesTable.store.count({}, (err, count) => { 254 | if (err) { 255 | reject(err); 256 | } 257 | resolve(count); 258 | }); 259 | }); 260 | }) 261 | .then((count) => { 262 | expect(count).to.equal(0); 263 | done(); 264 | }) 265 | .catch((e) => { 266 | done(e); 267 | }); 268 | }); 269 | 270 | it('should delete the given object from the db', (done) => { 271 | const remove = { 272 | type: DELETE, 273 | table: 'foo', 274 | key: 2, 275 | }; 276 | const clientID = 1; 277 | const currentRevision = { rev: 1 }; 278 | // Create table 279 | db.addData('foo', 2, { bar: 'bar' }) 280 | .then(() => applyClientChanges(db, 0, currentRevision, [remove], clientID)) 281 | .then(() => db.getData('foo', 2)) 282 | .then((data) => { 283 | expect(data).to.be.null; 284 | done(); 285 | }) 286 | .catch((e) => { 287 | done(e); 288 | }); 289 | }); 290 | 291 | it('should add the change to the changes table', (done) => { 292 | const remove = { 293 | type: DELETE, 294 | table: 'foo', 295 | key: 2, 296 | }; 297 | const clientID = 1; 298 | const currentRevision = { rev: 1 }; 299 | // Create table 300 | db.addData('foo', 2, {}) 301 | .then(() => applyClientChanges(db, 0, currentRevision, [remove], clientID)) 302 | .then(() => { 303 | return new Promise((resolve, reject) => { 304 | db.changesTable.store.find({}, (err, data) => { 305 | if (err) { 306 | reject(err); 307 | } 308 | resolve(data); 309 | }); 310 | }); 311 | }) 312 | .then((data) => { 313 | expect(data.length).to.equal(1); 314 | expect(data[0].type).to.equal(DELETE); 315 | expect(data[0].rev).to.equal(2); // Revision was incremented by 1 compared to the current revision 316 | expect(data[0].source).to.equal(clientID); 317 | done(); 318 | }) 319 | .catch((e) => { 320 | done(e); 321 | }); 322 | }); 323 | }); 324 | }); 325 | -------------------------------------------------------------------------------- /test/integration/sync_and_db/poll/client_identity.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const chakram = require('chakram'); 4 | const expect = chakram.expect; 5 | 6 | const syncHandler = require('../../../../lib/sync/poll_handler'); 7 | const Db = require('../../../../lib/db_connectors/NeDB/db'); 8 | 9 | const logger = { 10 | file: { 11 | info() { 12 | }, 13 | error() { 14 | }, 15 | }, 16 | console: { 17 | info() { 18 | }, 19 | error() { 20 | }, 21 | }, 22 | }; 23 | 24 | describe('Poll: clientIdentity', () => { 25 | let db; 26 | let handler; 27 | 28 | beforeEach((done) => { 29 | db = new Db({ inMemoryOnly: true }, logger); 30 | db.init() 31 | .then(() => { 32 | handler = syncHandler(db, logger, { partialsThreshold: 1000 }, { rev: 0 }); 33 | done(); 34 | }) 35 | .catch((e) => { 36 | done(e); 37 | }); 38 | }); 39 | 40 | it('should define a clientIdentity if none was given', (done) => { 41 | handler({ changes: [], requestId: 1 }) 42 | .then((dataToSend) => { 43 | if (!dataToSend.success) { 44 | throw new Error(dataToSend.errorMessage); 45 | } 46 | expect(dataToSend.clientIdentity).to.equal(db.meta.nextClientID - 1); 47 | done(); 48 | }) 49 | .catch((e) => { 50 | done(e); 51 | }); 52 | }); 53 | 54 | it('should leave the clientIdentity as is if a clientIdentity was give', (done) => { 55 | const currentClientID = db.meta.nextClientID; 56 | handler({ changes: [], requestId: 1, clientIdentity: 10 }) 57 | .then((dataToSend) => { 58 | if (!dataToSend.success) { 59 | throw new Error(dataToSend.errorMessage); 60 | } 61 | expect(db.meta.nextClientID).to.equal(currentClientID); 62 | expect(dataToSend.clientIdentity).to.equal(10); 63 | done(); 64 | }) 65 | .catch((e) => { 66 | done(e); 67 | }); 68 | }); 69 | }); 70 | -------------------------------------------------------------------------------- /test/integration/sync_and_db/poll/initial_synchronization.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const chakram = require('chakram'); 4 | const expect = chakram.expect; 5 | 6 | const syncHandler = require('../../../../lib/sync/poll_handler'); 7 | const Db = require('../../../../lib/db_connectors/NeDB/db'); 8 | const { CREATE } = require('../../../../lib/sync/types'); 9 | 10 | const logger = { 11 | file: { 12 | info() { 13 | }, 14 | error() { 15 | }, 16 | }, 17 | console: { 18 | info() { 19 | }, 20 | error() { 21 | }, 22 | }, 23 | }; 24 | 25 | describe('Poll: Initial synchronization', () => { 26 | let db; 27 | let handler; 28 | 29 | beforeEach((done) => { 30 | db = new Db({ inMemoryOnly: true }, logger); 31 | db.init() 32 | .then(() => { 33 | handler = syncHandler(db, logger, { partialsThreshold: 1000 }, { rev: 0 }); 34 | done(); 35 | }) 36 | .catch((e) => { 37 | done(e); 38 | }); 39 | }); 40 | 41 | it('should leave the tables unchanged if no data was sent', (done) => { 42 | const request = { changes: [], requestId: 1, baseRevision: null, syncedRevision: null }; 43 | handler(request) 44 | .then((dataToSend) => { 45 | if (!dataToSend.success) { 46 | throw new Error(dataToSend.errorMessage); 47 | } 48 | return new Promise((resolve, reject) => { 49 | db.changesTable.store.count({}, (err, count) => { 50 | if (err) { 51 | reject(err); 52 | } 53 | resolve(count); 54 | }); 55 | }); 56 | }) 57 | .then((count) => { 58 | expect(count).to.equal(0); 59 | done(); 60 | }) 61 | .catch((e) => { 62 | done(e); 63 | }); 64 | }); 65 | 66 | it('should not try to send any changes if no changes were made', (done) => { 67 | handler({ changes: [], requestId: 1, baseRevision: null, syncedRevision: null }) 68 | .then((dataToSend) => { 69 | if (!dataToSend.success) { 70 | throw new Error(dataToSend.errorMessage); 71 | } 72 | expect(dataToSend.changes).to.deep.equal([]); 73 | done(); 74 | }) 75 | .catch((e) => { 76 | done(e); 77 | }); 78 | }); 79 | 80 | it('should add the changes from the client to the specified tables and the changes table', (done) => { 81 | const create = { 82 | type: CREATE, 83 | obj: { foo: 'bar' }, 84 | key: 1, 85 | table: 'foo', 86 | }; 87 | handler({ changes: [create], requestId: 1, baseRevision: null, syncedRevision: null }) 88 | .then((dataToSend) => { 89 | if (!dataToSend.success) { 90 | throw new Error(dataToSend.errorMessage); 91 | } 92 | // We received no server changes but we got the latest db.revision 93 | // for the next sync call 94 | expect(dataToSend.currentRevision).to.equal(1); 95 | expect(db.meta.tables).to.deep.equal(['foo']); 96 | 97 | return new Promise((resolve, reject) => { 98 | db.changesTable.store.find({}, (err, docs) => { 99 | if (err) { 100 | reject(err); 101 | } 102 | resolve(docs); 103 | }); 104 | }); 105 | }) 106 | .then((docs) => { 107 | expect(docs.length).to.equal(1); 108 | expect(docs[0].rev).to.equal(1); 109 | expect(docs[0].obj).to.deep.equal({ foo: 'bar' }); 110 | 111 | return new Promise((resolve, reject) => { 112 | db.tables.get('foo').store.find({}, (err, docs) => { 113 | if (err) { 114 | reject(err); 115 | } 116 | resolve(docs); 117 | }); 118 | }); 119 | }) 120 | .then((docs) => { 121 | expect(docs.length).to.equal(1); 122 | expect(docs[0]).to.deep.equal({ foo: 'bar', _id: 1 }); 123 | done(); 124 | }) 125 | .catch((e) => { 126 | done(e); 127 | }); 128 | }); 129 | 130 | it('should return all server changes to the client when no revision is given', (done) => { 131 | const create = { 132 | rev: 1, 133 | type: CREATE, 134 | obj: { foo: 'bar' }, 135 | key: 1, 136 | table: 'foo', 137 | }; 138 | db.addChangesData(create) 139 | .then(() => handler({ changes: [], requestId: 1, baseRevision: null, syncedRevision: null })) 140 | .then((dataToSend) => { 141 | if (!dataToSend.success) { 142 | throw new Error(dataToSend.errorMessage); 143 | } 144 | expect(dataToSend.changes.length).to.equal(1); 145 | expect(dataToSend.changes[0]).to.deep.equal({ 146 | type: create.type, 147 | obj: create.obj, 148 | key: create.key, 149 | table: create.table, 150 | }); 151 | done(); 152 | }) 153 | .catch((e) => { 154 | done(e); 155 | }); 156 | }); 157 | }); 158 | -------------------------------------------------------------------------------- /test/integration/sync_and_db/poll/partial_client_sync.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const chakram = require('chakram'); 4 | const expect = chakram.expect; 5 | 6 | const syncHandler = require('../../../../lib/sync/poll_handler'); 7 | const Db = require('../../../../lib/db_connectors/NeDB/db'); 8 | const { CREATE } = require('../../../../lib/sync/types'); 9 | 10 | const logger = { 11 | file: { 12 | info() { 13 | }, 14 | error() { 15 | }, 16 | }, 17 | console: { 18 | info() { 19 | }, 20 | error() { 21 | }, 22 | }, 23 | }; 24 | 25 | describe('Poll: Partial client synchronization', () => { 26 | let db; 27 | let handler; 28 | 29 | beforeEach((done) => { 30 | db = new Db({ inMemoryOnly: true }, logger); 31 | db.init() 32 | .then(() => { 33 | handler = syncHandler(db, logger, { partialsThreshold: 1000 }, { rev: 0 }); 34 | done(); 35 | }) 36 | .catch((e) => { 37 | done(e); 38 | }); 39 | }); 40 | 41 | it('should add the partial data to the uncommittedChanges table', (done) => { 42 | const create = { 43 | type: CREATE, 44 | obj: { foo: 'bar' }, 45 | key: 1, 46 | table: 'foo', 47 | }; 48 | handler({ changes: [create], partial: true }) 49 | .then((dataToSend) => { 50 | if (!dataToSend.success) { 51 | throw new Error(dataToSend.errorMessage); 52 | } 53 | return db.uncommittedChanges.get(dataToSend.clientIdentity); 54 | }) 55 | .then((uncommittedChanges) => { 56 | expect(uncommittedChanges.changes).to.deep.equal([create]); 57 | done(); 58 | }) 59 | .catch((e) => { 60 | done(e); 61 | }); 62 | }); 63 | 64 | it(`should add the data to the given tables and clear uncommittedChanges table 65 | after we received partial = false`, (done) => { 66 | let clientIdentity; 67 | const create1 = { 68 | type: CREATE, 69 | obj: { foo: 'bar' }, 70 | key: 1, 71 | table: 'foo', 72 | }; 73 | const create2 = { 74 | type: CREATE, 75 | obj: { foo: 'baz' }, 76 | key: 2, 77 | table: 'foo', 78 | }; 79 | handler({ changes: [create1], partial: true }) 80 | .then((dataToSend) => { 81 | if (!dataToSend.success) { 82 | throw new Error(dataToSend.errorMessage); 83 | } 84 | clientIdentity = dataToSend.clientIdentity; 85 | return handler({ 86 | changes: [create2], 87 | partial: false, 88 | clientIdentity, 89 | }); 90 | }) 91 | .then((dataToSend) => { 92 | if (!dataToSend.success) { 93 | throw new Error(dataToSend.errorMessage); 94 | } 95 | return db.uncommittedChanges.get(clientIdentity); 96 | }) 97 | .then((uncommittedChanges) => { 98 | expect(uncommittedChanges.changes).to.deep.equal([]); 99 | return db.getData('foo', 1); 100 | }) 101 | .then((data) => { 102 | expect(data.foo).to.equal('bar'); 103 | return db.getData('foo', 2); 104 | }) 105 | .then((data) => { 106 | expect(data.foo).to.equal('baz'); 107 | done(); 108 | }) 109 | .catch((e) => { 110 | done(e); 111 | }); 112 | }); 113 | }); 114 | -------------------------------------------------------------------------------- /test/integration/sync_and_db/poll/partial_server_sync.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const chakram = require('chakram'); 4 | const expect = chakram.expect; 5 | 6 | const syncHandler = require('../../../../lib/sync/poll_handler'); 7 | const Db = require('../../../../lib/db_connectors/NeDB/db'); 8 | const { CREATE } = require('../../../../lib/sync/types'); 9 | 10 | const logger = { 11 | file: { 12 | info() { 13 | }, 14 | error() { 15 | }, 16 | }, 17 | console: { 18 | info() { 19 | }, 20 | error() { 21 | }, 22 | }, 23 | }; 24 | 25 | describe('Poll: Partial server sync', () => { 26 | let db; 27 | let handler; 28 | 29 | beforeEach((done) => { 30 | db = new Db({ inMemoryOnly: true }, logger); 31 | db.init() 32 | .then(() => { 33 | handler = syncHandler(db, logger, { partialsThreshold: 1 }); 34 | done(); 35 | }) 36 | .catch((e) => { 37 | done(e); 38 | }); 39 | }); 40 | 41 | it('should send partial changes', (done) => { 42 | const create1 = { 43 | rev: 1, 44 | type: CREATE, 45 | obj: { foo: 'bar' }, 46 | key: 1, 47 | table: 'foo', 48 | }; 49 | const create2 = { 50 | rev: 2, 51 | type: CREATE, 52 | obj: { foo: 'baz' }, 53 | key: 2, 54 | table: 'foo', 55 | }; 56 | let currentRevision; 57 | db.addChangesData(create1) 58 | .then(() => db.addChangesData(create2)) 59 | .then(() => handler({ requestId: 1, changes: [], clientIdentity: 1 })) 60 | .then((dataToSend) => { 61 | if (!dataToSend.success) { 62 | throw new Error(dataToSend.errorMessage); 63 | } 64 | expect(dataToSend.changes).to.deep.equal([{ 65 | key: create1.key, 66 | type: create1.type, 67 | obj: create1.obj, 68 | table: create1.table, 69 | }]); 70 | expect(dataToSend.partial).to.equal(true); 71 | expect(dataToSend.currentRevision).to.equal(create1.rev); 72 | currentRevision = dataToSend.currentRevision; 73 | return handler({ 74 | requestId: 2, 75 | changes: [], 76 | clientIdentity: 1, 77 | baseRevision: 3, 78 | syncedRevision: currentRevision, 79 | }); 80 | }) 81 | .then((dataToSend) => { 82 | if (!dataToSend.success) { 83 | throw new Error(dataToSend.errorMessage); 84 | } 85 | expect(dataToSend.changes).to.deep.equal([{ 86 | key: create2.key, 87 | type: create2.type, 88 | obj: create2.obj, 89 | table: create2.table, 90 | }]); 91 | expect(dataToSend.partial).to.equal(false); 92 | expect(dataToSend.currentRevision).to.equal(create2.rev); 93 | done(); 94 | }) 95 | .catch((e) => { 96 | done(e); 97 | }); 98 | }); 99 | }); 100 | -------------------------------------------------------------------------------- /test/integration/sync_and_db/poll/subsequent_synchronization.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const chakram = require('chakram'); 4 | const expect = chakram.expect; 5 | 6 | const syncHandler = require('../../../../lib/sync/poll_handler'); 7 | const Db = require('../../../../lib/db_connectors/NeDB/db'); 8 | const { CREATE, UPDATE, DELETE } = require('../../../../lib/sync/types'); 9 | 10 | const logger = { 11 | file: { 12 | info() { 13 | }, 14 | error() { 15 | }, 16 | }, 17 | console: { 18 | info() { 19 | }, 20 | error() { 21 | }, 22 | }, 23 | }; 24 | 25 | describe('Poll: Subsequent Synchronization', () => { 26 | let db; 27 | let handler; 28 | 29 | beforeEach((done) => { 30 | db = new Db({ inMemoryOnly: true }, logger); 31 | db.init() 32 | .then(() => { 33 | handler = syncHandler(db, logger, { partialsThreshold: 1000 }, { rev: 2 }); 34 | done(); 35 | }) 36 | .catch((e) => { 37 | done(e); 38 | }); 39 | }); 40 | 41 | it('should not return server changes to the client when those have the same clientIdentity', (done) => { 42 | const create = { 43 | rev: 1, 44 | type: CREATE, 45 | obj: { foo: 'bar' }, 46 | key: 1, 47 | table: 'foo', 48 | source: 1, 49 | }; 50 | db.addChangesData(create) 51 | .then(() => handler({ changes: [], requestId: 1, clientIdentity: 1 })) 52 | .then((dataToSend) => { 53 | if (!dataToSend.success) { 54 | throw new Error(dataToSend.errorMessage); 55 | } 56 | expect(dataToSend.changes.length).to.equal(0); 57 | done(); 58 | }) 59 | .catch((e) => { 60 | done(e); 61 | }); 62 | }); 63 | 64 | it('should ignore changes on the server which are older than the given syncedRevision', (done) => { 65 | const create1 = { 66 | rev: 1, 67 | type: CREATE, 68 | obj: { foo: 'bar' }, 69 | key: 1, 70 | table: 'foo', 71 | source: 2, 72 | }; 73 | const create2 = { 74 | rev: 2, 75 | type: CREATE, 76 | obj: { bar: 'baz' }, 77 | key: 2, 78 | table: 'foo', 79 | source: 2, 80 | }; 81 | db.addChangesData(create1) 82 | .then(() => db.addChangesData(create2)) 83 | .then(() => handler({ 84 | changes: [], 85 | requestId: 1, 86 | clientIdentity: 1, 87 | syncedRevision: 1, 88 | })) 89 | .then((dataToSend) => { 90 | if (!dataToSend.success) { 91 | throw new Error(dataToSend.errorMessage); 92 | } 93 | expect(dataToSend.changes.length).to.equal(1); 94 | expect(dataToSend.changes[0]).to.deep.equal({ 95 | type: create2.type, 96 | obj: create2.obj, 97 | key: create2.key, 98 | table: create2.table, 99 | }); 100 | done(); 101 | }) 102 | .catch((e) => { 103 | done(e); 104 | }); 105 | }); 106 | 107 | it('should send again the same changes if the client syncedRevision did not change', (done) => { 108 | const create1 = { 109 | rev: 1, 110 | type: CREATE, 111 | obj: { foo: 'bar' }, 112 | key: 1, 113 | table: 'foo', 114 | source: 2, 115 | }; 116 | const create2 = { 117 | rev: 2, 118 | type: CREATE, 119 | obj: { bar: 'baz' }, 120 | key: 2, 121 | table: 'foo', 122 | source: 2, 123 | }; 124 | db.addChangesData(create1) 125 | .then(() => db.addChangesData(create2)) 126 | .then(() => handler({ 127 | changes: [], 128 | requestId: 1, 129 | clientIdentity: 1, 130 | syncedRevision: 1, 131 | })) 132 | .then((dataToSend) => { 133 | if (!dataToSend.success) { 134 | throw new Error(dataToSend.errorMessage); 135 | } 136 | expect(dataToSend.changes.length).to.equal(1); 137 | expect(dataToSend.changes[0]).to.deep.equal({ 138 | type: create2.type, 139 | obj: create2.obj, 140 | key: create2.key, 141 | table: create2.table, 142 | }); 143 | 144 | return handler({ 145 | changes: [], 146 | requestId: 1, 147 | clientIdentity: 1, 148 | syncedRevision: 1, 149 | }); 150 | }) 151 | .then((dataToSend) => { 152 | if (!dataToSend.success) { 153 | throw new Error(dataToSend.errorMessage); 154 | } 155 | expect(dataToSend.changes.length).to.equal(1); 156 | expect(dataToSend.changes[0]).to.deep.equal({ 157 | type: create2.type, 158 | obj: create2.obj, 159 | key: create2.key, 160 | table: create2.table, 161 | }); 162 | done(); 163 | }).catch((e) => { 164 | done(e); 165 | }); 166 | }); 167 | 168 | it('should not error out if a client resends a CREATE change for an object updated by another client', (done) => { 169 | const create = { 170 | type: CREATE, 171 | obj: { foo: 'more than once' }, 172 | key: 1, 173 | table: 'foo', 174 | }; 175 | const update = { 176 | type: UPDATE, 177 | mods: { foo: 'updating it' }, 178 | key: 1, 179 | table: 'foo', 180 | }; 181 | // Client baseRevision matches server revision 182 | const createRequest = { changes: [create], requestId: 1, clientIdentity: 1, syncedRevision: 2, baseRevision: 2 }; 183 | // Use baseRevision 3. We started with db revision 2 and added one change 184 | const updateRequest = { changes: [update], requestId: 1, clientIdentity: 2, syncedRevision: 3, baseRevision: 3 }; 185 | handler(createRequest) 186 | .then((dataToSend) => { 187 | if (!dataToSend.success) { 188 | throw new Error(dataToSend.errorMessage); 189 | } 190 | return db.getData('foo', 1); 191 | }) 192 | .then((data) => { 193 | expect(data).to.deep.equal({ foo: 'more than once' }); 194 | return handler(updateRequest); 195 | }) 196 | .then((dataToSend) => { 197 | if (!dataToSend.success) { 198 | throw new Error(dataToSend.errorMessage); 199 | } 200 | return db.getData('foo', 1); 201 | }) 202 | .then((data) => { 203 | expect(data).to.deep.equal({ foo: 'updating it' }); 204 | return handler(createRequest); 205 | }) 206 | .then((dataToSend) => { 207 | if (!dataToSend.success) { 208 | throw new Error(dataToSend.errorMessage); 209 | } 210 | return db.getData('foo', 1); 211 | }) 212 | .then((data) => { 213 | expect(data).to.deep.equal({ foo: 'updating it' }); 214 | return new Promise((resolve, reject) => { 215 | db.changesTable.store.find({ key: 1 }, (err, data) => { 216 | if (err) { 217 | return reject(err); 218 | } 219 | resolve(data); 220 | }); 221 | }); 222 | }) 223 | .then((changeData) => { 224 | // CREATE and UPDATE change 225 | expect(changeData.length).to.equal(2); 226 | done(); 227 | }) 228 | .catch((e) => { 229 | done(e); 230 | }); 231 | }); 232 | 233 | it('should not error out if a client resends a CREATE change for an object deleted by another client', (done) => { 234 | const create = { 235 | type: CREATE, 236 | obj: { foo: 'more than once' }, 237 | key: 1, 238 | table: 'foo', 239 | }; 240 | const update = { 241 | type: DELETE, 242 | mods: { foo: 'updating it' }, 243 | key: 1, 244 | table: 'foo', 245 | }; 246 | // Client baseRevision matches server revision 247 | const createRequest = { changes: [create], requestId: 1, clientIdentity: 1, syncedRevision: 2, baseRevision: 2 }; 248 | // Use baseRevision 3. We started with db revision 2 and added one change 249 | const updateRequest = { changes: [update], requestId: 1, clientIdentity: 2, syncedRevision: 3, baseRevision: 3 }; 250 | handler(createRequest) 251 | .then((dataToSend) => { 252 | if (!dataToSend.success) { 253 | throw new Error(dataToSend.errorMessage); 254 | } 255 | return db.getData('foo', 1); 256 | }) 257 | .then((data) => { 258 | expect(data).to.deep.equal({ foo: 'more than once' }); 259 | return handler(updateRequest); 260 | }) 261 | .then((dataToSend) => { 262 | if (!dataToSend.success) { 263 | throw new Error(dataToSend.errorMessage); 264 | } 265 | return db.getData('foo', 1); 266 | }) 267 | .then((data) => { 268 | expect(data).to.equal(null); 269 | return handler(createRequest); 270 | }) 271 | .then((dataToSend) => { 272 | if (!dataToSend.success) { 273 | throw new Error(dataToSend.errorMessage); 274 | } 275 | return db.getData('foo', 1); 276 | }) 277 | .then((data) => { 278 | expect(data).to.equal(null); 279 | return new Promise((resolve, reject) => { 280 | db.changesTable.store.find({ key: 1 }, (err, data) => { 281 | if (err) { 282 | return reject(err); 283 | } 284 | resolve(data); 285 | }); 286 | }); 287 | }) 288 | .then((changeData) => { 289 | // CREATE and DELETE change 290 | expect(changeData.length).to.equal(2); 291 | done(); 292 | }) 293 | .catch((e) => { 294 | done(e); 295 | }); 296 | }); 297 | }); 298 | -------------------------------------------------------------------------------- /test/integration/sync_and_db/socket/client_changes.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const chakram = require('chakram'); 4 | const expect = chakram.expect; 5 | 6 | const syncHandler = require('../../../../lib/sync/socket_handler'); 7 | const Db = require('../../../../lib/db_connectors/NeDB/db'); 8 | const { CREATE } = require('../../../../lib/sync/types'); 9 | 10 | const logger = { 11 | file: { 12 | info() { 13 | }, 14 | error() { 15 | }, 16 | }, 17 | console: { 18 | info() { 19 | }, 20 | error() { 21 | }, 22 | }, 23 | }; 24 | 25 | describe('Socket: Handle client changes', () => { 26 | let db; 27 | let handler; 28 | const connID1 = 1; 29 | const connID2 = 2; 30 | const clientIdentity1 = 11; 31 | const clientIdentity2 = 12; 32 | 33 | beforeEach((done) => { 34 | db = new Db({ inMemoryOnly: true }, logger); 35 | db.init() 36 | .then(() => { 37 | handler = syncHandler(db, logger, { partialsThreshold: 1000 }, { rev: 0 }); 38 | done(); 39 | }) 40 | .catch((e) => { 41 | done(e); 42 | }); 43 | }); 44 | 45 | afterEach(() => { 46 | handler.handleConnectionClosed(connID1); 47 | handler.handleConnectionClosed(connID2); 48 | }); 49 | 50 | it('should get back the given requestId', (done) => { 51 | const requestId = 0; 52 | handler.handleInitialization(connID1, { clientIdentity: clientIdentity1 }) 53 | .then(() => handler.handleSubscribe(connID1, { syncedRevision: 0 }, () => { 54 | })) 55 | .then(() => handler.handleClientChanges(connID1, { changes: [], requestId })) 56 | .then(({ data, succeeded }) => { 57 | if (!succeeded) { 58 | throw new Error(data.errorMessage); 59 | } 60 | expect(data.requestId).to.equal(requestId); 61 | done(); 62 | }) 63 | .catch((e) => { 64 | done(e); 65 | }); 66 | }); 67 | 68 | it('should get back any existing server changes', (done) => { 69 | const create = { 70 | rev: 1, 71 | type: CREATE, 72 | obj: { foo: 'bar' }, 73 | key: 1, 74 | table: 'foo', 75 | }; 76 | 77 | function cb({ data, succeeded }) { 78 | if (!succeeded) { 79 | return done(new Error(data.errorMessage)); 80 | } 81 | 82 | try { 83 | expect(data.changes).to.deep.equal([{ 84 | key: create.key, 85 | type: create.type, 86 | obj: create.obj, 87 | table: create.table, 88 | }]); 89 | expect(data.partial).to.equal(false); 90 | done(); 91 | } catch (e) { 92 | done(e); 93 | } 94 | } 95 | 96 | db.addChangesData(create) 97 | .then(() => handler.handleInitialization(connID1, { clientIdentity: clientIdentity1 })) 98 | .then(() => handler.handleSubscribe(connID1, { syncedRevision: 0 }, cb)) 99 | .then(() => handler.handleClientChanges(connID1, { changes: [] })) 100 | .catch((e) => { 101 | done(e); 102 | }); 103 | }); 104 | 105 | it('should trigger getServerChanges for other connections', (done) => { 106 | const create = { 107 | type: CREATE, 108 | obj: { foo: 'bar' }, 109 | key: 1, 110 | table: 'fooa', 111 | }; 112 | 113 | let callCounter1 = 0; 114 | let callCounter2 = 0; 115 | 116 | function cb1({ succeeded, data }) { 117 | // Make sure the client that caused the changes gets triggered, but with an empty changeset 118 | if (callCounter1 === 1) { 119 | expect(data.changes.length).to.equal(0); 120 | expect(data.partial).to.equal(false); 121 | callCounter1 = callCounter1 + 1; 122 | if (callCounter2 > 1) { 123 | done(); 124 | } 125 | } else { 126 | callCounter1 = callCounter1 + 1; 127 | } 128 | } 129 | 130 | function cb2({ succeeded, data }) { 131 | if (!succeeded) { 132 | return done(new Error(data.errorMessage)); 133 | } 134 | 135 | if (callCounter2 === 1) { 136 | try { 137 | expect(data.changes).to.deep.equal([{ 138 | key: create.key, 139 | type: create.type, 140 | obj: create.obj, 141 | table: create.table, 142 | }]); 143 | expect(data.partial).to.equal(false); 144 | callCounter2 = callCounter2 + 1; 145 | if (callCounter2 > 1) { 146 | done(); 147 | } 148 | } catch (e) { 149 | done(e); 150 | } 151 | } else { 152 | callCounter2 = callCounter2 + 1; 153 | } 154 | } 155 | 156 | handler.handleInitialization(connID1, { clientIdentity: clientIdentity1 }) 157 | .then(() => handler.handleInitialization(connID2, { clientIdentity: clientIdentity2 })) 158 | .then(() => handler.handleSubscribe(connID1, { syncedRevision: 0 }, cb1)) 159 | .then(() => handler.handleSubscribe(connID2, { syncedRevision: 0 }, cb2)) 160 | .then(() => handler.handleClientChanges(connID1, { changes: [create] })) 161 | .catch((e) => { 162 | done(e); 163 | }); 164 | }); 165 | }); 166 | -------------------------------------------------------------------------------- /test/integration/sync_and_db/socket/client_identity.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const chakram = require('chakram'); 4 | const expect = chakram.expect; 5 | 6 | const syncHandler = require('../../../../lib/sync/socket_handler'); 7 | const Db = require('../../../../lib/db_connectors/NeDB/db'); 8 | 9 | const logger = { 10 | file: { 11 | info() { 12 | }, 13 | error() { 14 | }, 15 | }, 16 | console: { 17 | info() { 18 | }, 19 | error() { 20 | }, 21 | }, 22 | }; 23 | 24 | describe('Socket: clientIdentity', () => { 25 | let db; 26 | let handler; 27 | const connID = 1; 28 | 29 | beforeEach((done) => { 30 | db = new Db({ inMemoryOnly: true }, logger); 31 | db.init() 32 | .then(() => { 33 | handler = syncHandler(db, logger, { partialsThreshold: 1000 }); 34 | done(); 35 | }) 36 | .catch((e) => { 37 | done(e); 38 | }); 39 | }); 40 | 41 | it('should define a clientIdentity if none was given', (done) => { 42 | handler.handleInitialization(connID, {}) 43 | .then(({ data: dataToSend }) => { 44 | expect(dataToSend.clientIdentity).to.equal(db.meta.nextClientID - 1); 45 | expect(handler._connToClientIdentity.get(connID)).to.equal(db.meta.nextClientID - 1); 46 | done(); 47 | }) 48 | .catch((e) => { 49 | done(e); 50 | }); 51 | }); 52 | 53 | it('should leave the clientIdentity as is if a clientIdentity was give', (done) => { 54 | handler.handleInitialization(connID, { clientIdentity: 10 }) 55 | .then(({ data: dataToSend }) => { 56 | expect(dataToSend.clientIdentity).to.equal(10); 57 | expect(handler._connToClientIdentity.get(connID)).to.equal(10); 58 | done(); 59 | }) 60 | .catch((e) => { 61 | done(e); 62 | }); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /test/integration/sync_and_db/socket/connection_closed.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const chakram = require('chakram'); 4 | const expect = chakram.expect; 5 | 6 | const syncHandler = require('../../../../lib/sync/socket_handler'); 7 | const Db = require('../../../../lib/db_connectors/NeDB/db'); 8 | 9 | const logger = { 10 | file: { 11 | info() { 12 | }, 13 | error() { 14 | }, 15 | }, 16 | console: { 17 | info() { 18 | }, 19 | error() { 20 | }, 21 | }, 22 | }; 23 | 24 | describe('Socket: Connection closed', () => { 25 | let db; 26 | let handler; 27 | const connID = 1; 28 | const clientIdentity = 10; 29 | 30 | beforeEach((done) => { 31 | db = new Db({ inMemoryOnly: true }, logger); 32 | db.init() 33 | .then(() => { 34 | handler = syncHandler(db, logger, { partialsThreshold: 1000 }); 35 | done(); 36 | }) 37 | .catch((e) => { 38 | done(e); 39 | }); 40 | }); 41 | 42 | it('should remove the subscribed function and the connection to client ID mapping', (done) => { 43 | handler.handleInitialization(connID, { clientIdentity }) 44 | .then(() => handler.handleSubscribe(connID, {}, () => { 45 | })) 46 | .then(() => { 47 | expect(handler._connToClientIdentity.get(connID)).to.equal(clientIdentity); 48 | expect(handler._subscriptions.get(connID)).to.not.be.undefined; 49 | 50 | handler.handleConnectionClosed(connID); 51 | 52 | expect(handler._connToClientIdentity.has(connID)).to.equal(false); 53 | expect(handler._subscriptions.has(connID)).to.equal(false); 54 | done(); 55 | }) 56 | .catch((e) => { 57 | done(e); 58 | }); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /test/integration/sync_and_db/socket/partial_client_sync.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const chakram = require('chakram'); 4 | const expect = chakram.expect; 5 | 6 | const syncHandler = require('../../../../lib/sync/socket_handler'); 7 | const Db = require('../../../../lib/db_connectors/NeDB/db'); 8 | const { CREATE } = require('../../../../lib/sync/types'); 9 | 10 | const logger = { 11 | file: { 12 | info() { 13 | }, 14 | error() { 15 | }, 16 | }, 17 | console: { 18 | info() { 19 | }, 20 | error() { 21 | }, 22 | }, 23 | }; 24 | 25 | describe('Socket: Partial client synchronization', () => { 26 | let db; 27 | let handler; 28 | const connID = 1; 29 | const clientIdentity = 10; 30 | 31 | beforeEach((done) => { 32 | db = new Db({ inMemoryOnly: true }, logger); 33 | db.init() 34 | .then(() => { 35 | handler = syncHandler(db, logger, { partialsThreshold: 1000 }); 36 | done(); 37 | }) 38 | .catch((e) => { 39 | done(e); 40 | }); 41 | }); 42 | 43 | it('should add the partial data to the uncommittedChanges table', (done) => { 44 | const create = { 45 | type: CREATE, 46 | obj: { foo: 'bar' }, 47 | key: 1, 48 | table: 'foo', 49 | }; 50 | handler 51 | .handleInitialization(connID, { clientIdentity }) 52 | .then(() => handler.handleSubscribe(connID, {}, () => {})) 53 | .then(() => handler.handleClientChanges(connID, { changes: [create], partial: true })) 54 | .then(() => db.uncommittedChanges.get(clientIdentity)) 55 | .then((uncommittedChanges) => { 56 | expect(uncommittedChanges.changes).to.deep.equal([create]); 57 | done(); 58 | }) 59 | .catch((e) => { 60 | done(e); 61 | }); 62 | }); 63 | 64 | it(`should add the data to the given tables and clear uncommittedChanges table 65 | after we received partial = false`, (done) => { 66 | const create1 = { 67 | type: CREATE, 68 | obj: { foo: 'bar' }, 69 | key: 1, 70 | table: 'foo', 71 | }; 72 | const create2 = { 73 | type: CREATE, 74 | obj: { foo: 'baz' }, 75 | key: 2, 76 | table: 'foo', 77 | }; 78 | 79 | handler 80 | .handleInitialization(connID, { clientIdentity }) 81 | .then(() => handler.handleSubscribe(connID, {}, () => { 82 | })) 83 | .then(() => handler.handleClientChanges(connID, { changes: [create1], partial: true })) 84 | .then(() => handler.handleClientChanges(connID, { changes: [create2], partial: false })) 85 | .then(() => db.uncommittedChanges.get(clientIdentity)) 86 | .then((uncommittedChanges) => { 87 | expect(uncommittedChanges.changes).to.deep.equal([]); 88 | return db.getData('foo', 1); 89 | }) 90 | .then((data) => { 91 | expect(data.foo).to.equal('bar'); 92 | return db.getData('foo', 2); 93 | }) 94 | .then((data) => { 95 | expect(data.foo).to.equal('baz'); 96 | done(); 97 | }) 98 | .catch((e) => { 99 | done(e); 100 | }); 101 | }); 102 | }); 103 | -------------------------------------------------------------------------------- /test/integration/sync_and_db/socket/partial_server_sync.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const chakram = require('chakram'); 4 | const expect = chakram.expect; 5 | 6 | const syncHandler = require('../../../../lib/sync/socket_handler'); 7 | const Db = require('../../../../lib/db_connectors/NeDB/db'); 8 | const { CREATE } = require('../../../../lib/sync/types'); 9 | 10 | const logger = { 11 | file: { 12 | info() { 13 | }, 14 | error() { 15 | }, 16 | }, 17 | console: { 18 | info() { 19 | }, 20 | error() { 21 | }, 22 | }, 23 | }; 24 | 25 | describe('Socket: Partial server sync', () => { 26 | let db; 27 | let handler; 28 | const connID1 = 1; 29 | const connID2 = 2; 30 | const clientIdentity1 = 10; 31 | const clientIdentity2 = 12; 32 | 33 | beforeEach((done) => { 34 | db = new Db({ inMemoryOnly: true }, logger); 35 | db.init() 36 | .then(() => { 37 | handler = syncHandler(db, logger, { partialsThreshold: 1 }, { rev: 0 }); 38 | done(); 39 | }) 40 | .catch((e) => { 41 | done(e); 42 | }); 43 | }); 44 | 45 | it('should send any partial changes in multiple calls', (done) => { 46 | const create1 = { 47 | type: CREATE, 48 | obj: { foo: 'bar' }, 49 | key: 1, 50 | table: 'foo', 51 | }; 52 | const create2 = { 53 | type: CREATE, 54 | obj: { foo: 'baz' }, 55 | key: 2, 56 | table: 'foo', 57 | }; 58 | 59 | let callCounter = 0; 60 | 61 | function cb({ data, succeeded }) { 62 | const thisData = Object.assign({}, data); 63 | if (!succeeded) { 64 | return done(new Error(data.errorMessage)); 65 | } 66 | try { 67 | switch (callCounter) { 68 | // Call during subscribe 69 | case 0: { 70 | // Equal current db revision 71 | expect(data.currentRevision).to.equal(0); 72 | callCounter = callCounter + 1; 73 | break; 74 | } 75 | case 1: { 76 | callCounter = callCounter + 1; 77 | // Make sure we have 1 change. We don't know which change as we don't know the order 78 | // in which the changes are written to the changesTable 79 | expect(data.changes.length).to.equal(1); 80 | expect(thisData.partial).to.equal(true); 81 | expect(data.currentRevision).to.equal(1); // First change has rev 1 82 | expect(handler._clientIDToRevision.get(clientIdentity2)).to.equal(data.currentRevision); 83 | break; 84 | } 85 | case 2: { 86 | // Make sure we have 1 change. We don't know which change as we don't know the order 87 | // in which the changes are written to the changesTable 88 | expect(data.changes.length).to.equal(1); 89 | expect(data.partial).to.equal(false); 90 | expect(data.currentRevision).to.equal(2); // Second change has rev 2 91 | expect(handler._clientIDToRevision.get(clientIdentity2)).to.equal(data.currentRevision); 92 | done(); 93 | break; 94 | } 95 | } 96 | } catch (e) { 97 | done(e); 98 | } 99 | } 100 | 101 | handler.handleInitialization(connID1, { clientIdentity: clientIdentity1 }) 102 | .then(() => handler.handleInitialization(connID2, { clientIdentity: clientIdentity2 })) 103 | .then(() => handler.handleSubscribe(connID1, { syncedRevision: 2 }, () => { 104 | })) 105 | .then(() => handler.handleSubscribe(connID2, { syncedRevision: 0 }, cb)) 106 | .then(() => handler.handleClientChanges(connID1, { changes: [create1, create2] })) 107 | .catch((e) => { 108 | done(e); 109 | }); 110 | }); 111 | }); 112 | -------------------------------------------------------------------------------- /test/integration/sync_and_db/socket/subscribe.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const chakram = require('chakram'); 4 | const expect = chakram.expect; 5 | 6 | const syncHandler = require('../../../../lib/sync/socket_handler'); 7 | const Db = require('../../../../lib/db_connectors/NeDB/db'); 8 | const { CREATE } = require('../../../../lib/sync/types'); 9 | 10 | const logger = { 11 | file: { 12 | info() { 13 | }, 14 | error() { 15 | }, 16 | }, 17 | console: { 18 | info() { 19 | }, 20 | error() { 21 | }, 22 | }, 23 | }; 24 | 25 | describe('Socket: subscribe', () => { 26 | let db; 27 | let handler; 28 | const connID = 1; 29 | const clientIdentity = 10; 30 | 31 | beforeEach((done) => { 32 | db = new Db({ inMemoryOnly: true }, logger); 33 | db.init() 34 | .then(() => { 35 | handler = syncHandler(db, logger, { partialsThreshold: 1000 }); 36 | done(); 37 | }) 38 | .catch((e) => { 39 | done(e); 40 | }); 41 | }); 42 | 43 | it('should send back any changes newer than our revision and the current db revision', (done) => { 44 | const create1 = { 45 | rev: 2, 46 | type: CREATE, 47 | obj: { foo: 'bar' }, 48 | key: 1, 49 | table: 'foo', 50 | }; 51 | const create2 = { 52 | rev: 3, 53 | type: CREATE, 54 | obj: { foo: 'baz' }, 55 | key: 2, 56 | table: 'foo', 57 | }; 58 | 59 | db.meta.revision = 4; 60 | function cb({ succeeded, data }) { 61 | if (!succeeded) { 62 | return done(new Error(data.errorMessage)); 63 | } 64 | 65 | try { 66 | expect(data.changes).to.deep.equal([{ 67 | type: CREATE, 68 | obj: { foo: 'bar' }, 69 | key: 1, 70 | table: 'foo', 71 | }, { 72 | type: CREATE, 73 | obj: { foo: 'baz' }, 74 | key: 2, 75 | table: 'foo', 76 | }]); 77 | expect(data.partial).to.equal(false); 78 | expect(data.currentRevision).to.equal(create2.rev); 79 | done(); 80 | } catch (e) { 81 | done(e); 82 | } 83 | } 84 | 85 | db.addChangesData(create1) 86 | .then(() => db.addChangesData(create2)) 87 | .then(() => handler.handleInitialization(connID, { clientIdentity })) 88 | .then(() => handler.handleSubscribe(connID, { syncedRevision: 1 }, cb)) 89 | .catch((e) => { 90 | done(e); 91 | }); 92 | }); 93 | 94 | it('should send back any changes newer than our revision and work with syncedRevision null', (done) => { 95 | const create1 = { 96 | rev: 1, 97 | type: CREATE, 98 | obj: { foo: 'bar' }, 99 | key: 1, 100 | table: 'foo', 101 | }; 102 | const create2 = { 103 | rev: 2, 104 | type: CREATE, 105 | obj: { foo: 'baz' }, 106 | key: 2, 107 | table: 'foo', 108 | }; 109 | 110 | db.meta.revision = 4; 111 | function cb({ succeeded, data }) { 112 | if (!succeeded) { 113 | return done(new Error(data.errorMessage)); 114 | } 115 | 116 | try { 117 | expect(data.changes).to.deep.equal([{ 118 | type: CREATE, 119 | obj: { foo: 'bar' }, 120 | key: 1, 121 | table: 'foo', 122 | }, { 123 | type: CREATE, 124 | obj: { foo: 'baz' }, 125 | key: 2, 126 | table: 'foo', 127 | }]); 128 | expect(data.partial).to.equal(false); 129 | expect(data.currentRevision).to.equal(create2.rev); 130 | done(); 131 | } catch (e) { 132 | done(e); 133 | } 134 | } 135 | 136 | db.addChangesData(create1) 137 | .then(() => db.addChangesData(create2)) 138 | .then(() => handler.handleInitialization(connID, { clientIdentity })) 139 | .then(() => handler.handleSubscribe(connID, { syncedRevision: null }, cb)) 140 | .catch((e) => { 141 | done(e); 142 | }); 143 | }); 144 | }); 145 | -------------------------------------------------------------------------------- /test/unit/db_connectors/changes_table.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const chakram = require('chakram'); 4 | const expect = chakram.expect; 5 | 6 | const ChangesTable = require('../../../lib/db_connectors/NeDB/changes_table'); 7 | 8 | describe('changesTable', () => { 9 | it('should be able to cope with mods including dots when adding a change', (done) => { 10 | const changesTable = new ChangesTable('changes', { inMemoryOnly: true }); 11 | changesTable 12 | .add({ mods: { 'foo.bar': 'foo', 'bar': 'baz', 'feb.bar': 'fe' } }) 13 | .then(() => { 14 | changesTable.store.find({}, (err, docs) => { 15 | if (err) { 16 | return done(err); 17 | } 18 | expect(docs[0].mods).to.deep.equal({ 19 | [`foo${changesTable.dotReplacer}bar`]: 'foo', 20 | bar: 'baz', 21 | [`feb${changesTable.dotReplacer}bar`]: 'fe', 22 | }); 23 | done(); 24 | }); 25 | }) 26 | .catch((e) => { 27 | done(e); 28 | }); 29 | }); 30 | 31 | it('should replace the dotReplacement with a real dot when changes are read getByRevision()', (done) => { 32 | const changesTable = new ChangesTable('changes', { inMemoryOnly: true }); 33 | const changeObject = { 34 | mods: { 35 | [`foo${changesTable.dotReplacer}bar`]: 'foo', 36 | bar: 'baz', 37 | [`feb${changesTable.dotReplacer}bar`]: 'fe', 38 | }, 39 | rev: 1, 40 | }; 41 | changesTable.store.insert(changeObject, (err) => { 42 | if (err) { 43 | return done(err); 44 | } 45 | changesTable.getByRevision(0) 46 | .then((data) => { 47 | expect(data[0].mods).to.deep.equal({ 48 | 'foo.bar': 'foo', 49 | bar: 'baz', 50 | 'feb.bar': 'fe', 51 | }); 52 | done(); 53 | }) 54 | .catch((err) => { 55 | done(err); 56 | }); 57 | }); 58 | }); 59 | 60 | it('should replace the dotReplacement with a real dot when changes are read getByRevisionAndClientID()', (done) => { 61 | const changesTable = new ChangesTable('changes', { inMemoryOnly: true }); 62 | const changeObject = { 63 | mods: { 64 | [`foo${changesTable.dotReplacer}bar`]: 'foo', 65 | bar: 'baz', 66 | [`feb${changesTable.dotReplacer}bar`]: 'fe' }, 67 | rev: 1, 68 | source: 0, 69 | }; 70 | changesTable.store.insert(changeObject, (err) => { 71 | if (err) { 72 | return done(err); 73 | } 74 | changesTable.getByRevisionAndClientID(0, 1) 75 | .then((data) => { 76 | expect(data[0].mods).to.deep.equal({ 77 | 'foo.bar': 'foo', 78 | bar: 'baz', 79 | 'feb.bar': 'fe', 80 | }); 81 | done(); 82 | }) 83 | .catch((err) => { 84 | done(err); 85 | }); 86 | }); 87 | }); 88 | }); 89 | -------------------------------------------------------------------------------- /test/unit/sync/apply_modifications.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const chakram = require('chakram'); 4 | const expect = chakram.expect; 5 | 6 | const applyModifications = require('../../../lib/sync/apply_modifications'); 7 | 8 | describe('applyModifications', () => { 9 | it('should take all values of the modifications object and set them on the given object', () => { 10 | const modificationsObject = { foo: 'value', bar: 'otherValue' }; 11 | const object = { foo: 'oldValue' }; 12 | const res = applyModifications(object, modificationsObject); 13 | 14 | expect(res).to.deep.equal({ foo: 'value', bar: 'otherValue' }); 15 | expect(object).to.deep.equal({ foo: 'value', bar: 'otherValue' }); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /test/unit/sync/combine_create_and_update.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const chakram = require('chakram'); 4 | const expect = chakram.expect; 5 | 6 | const combineCreateAndUpdate = require('../../../lib/sync/combine_create_and_update'); 7 | 8 | describe('combineCreateAndUpdate', () => { 9 | it('should get a create change and update change and return a combined object', () => { 10 | const createChange = { 11 | obj: { 12 | foo: 'value', 13 | }, 14 | }; 15 | const updateChange = { 16 | mods: { 17 | foo: 'value2', 18 | bar: 'new Value', 19 | }, 20 | }; 21 | 22 | const res = combineCreateAndUpdate(createChange, updateChange); 23 | expect(res.obj).to.deep.equal({ foo: 'value2', bar: 'new Value' }); 24 | }); 25 | 26 | it('should not change the original createObject', () => { 27 | const createChange = { 28 | obj: { 29 | foo: 'value', 30 | }, 31 | }; 32 | const updateChange = { 33 | mods: { 34 | foo: 'value2', 35 | bar: 'new Value', 36 | }, 37 | }; 38 | 39 | combineCreateAndUpdate(createChange, updateChange); 40 | expect(createChange.obj).to.deep.equal({ foo: 'value' }); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /test/unit/sync/combine_update_and_update.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const chakram = require('chakram'); 4 | const expect = chakram.expect; 5 | 6 | const combineUpdateAndUpdate = require('../../../lib/sync/combine_update_and_update'); 7 | 8 | describe('combineUpdateAndUpdate', () => { 9 | it('should combine the keys of nextChange.mods and prevChange.mods', () => { 10 | const prevChange = { 11 | mods: { 12 | foo: 'bar', 13 | }, 14 | }; 15 | const nextChange = { 16 | mods: { 17 | bar: 'baz', 18 | }, 19 | }; 20 | 21 | const res = combineUpdateAndUpdate(prevChange, nextChange); 22 | expect(res.mods).to.deep.equal({ foo: 'bar', bar: 'baz' }); 23 | }); 24 | 25 | it('should leave the original object untouched', () => { 26 | const prevChange = { 27 | mods: { 28 | foo: 'bar', 29 | }, 30 | }; 31 | const nextChange = { 32 | mods: { 33 | bar: 'baz', 34 | }, 35 | }; 36 | 37 | combineUpdateAndUpdate(prevChange, nextChange); 38 | expect(prevChange).to.deep.equal({ mods: { foo: 'bar' } }); 39 | }); 40 | 41 | it('should overwrite a previous change with the same key', () => { 42 | const prevChange = { 43 | mods: { 44 | foo: 'bar', 45 | }, 46 | }; 47 | const nextChange = { 48 | mods: { 49 | foo: 'baz', 50 | }, 51 | }; 52 | 53 | const res = combineUpdateAndUpdate(prevChange, nextChange); 54 | expect(res.mods).to.deep.equal({ foo: 'baz' }); 55 | }); 56 | 57 | it('should ignore a previous change which changed a parent object of the next change', () => { 58 | const prevChange = { 59 | mods: { 60 | 'foo': { bar: 'baz', baz: 'bar' }, 61 | }, 62 | }; 63 | const nextChange = { 64 | mods: { 65 | 'foo.bar': 'foobar', 66 | }, 67 | }; 68 | 69 | const res = combineUpdateAndUpdate(prevChange, nextChange); 70 | expect(res).to.deep.equal({ mods: { foo: { bar: 'foobar', baz: 'bar' } } }); 71 | }); 72 | 73 | it('should ignore a previous change which changed a sub value of the nextChange', () => { 74 | const prevChange = { 75 | mods: { 76 | 'foo.bar': 'foobar', 77 | }, 78 | }; 79 | const nextChange = { 80 | mods: { 81 | 'foo': { bar: 'baz' }, 82 | }, 83 | }; 84 | 85 | const res = combineUpdateAndUpdate(prevChange, nextChange); 86 | expect(res).to.deep.equal({ mods: { 'foo': { bar: 'baz' } } }); 87 | }); 88 | }); 89 | -------------------------------------------------------------------------------- /test/unit/sync/reduce_changes.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const chakram = require('chakram'); 4 | const expect = chakram.expect; 5 | 6 | const reduceChanges = require('../../../lib/sync/reduce_changes'); 7 | const { CREATE, UPDATE, DELETE } = require('../../../lib/sync/types'); 8 | 9 | describe('reduceChanges', () => { 10 | it('should just add the change if there is no other change for this key in the given table', () => { 11 | const create = { 12 | rev: 0, 13 | key: 1, 14 | table: 'foo', 15 | obj: {}, 16 | type: CREATE, 17 | }; 18 | const update = { 19 | rev: 1, 20 | key: 2, 21 | table: 'foo', 22 | obj: {}, 23 | type: UPDATE, 24 | }; 25 | const remove = { 26 | rev: 1, 27 | key: 1, 28 | table: 'bar', 29 | type: DELETE, 30 | }; 31 | const changes = [create, update, remove]; 32 | const res = reduceChanges(changes); 33 | expect(res).to.deep.equal({ 34 | 'foo:1': create, 35 | 'foo:2': update, 36 | 'bar:1': remove, 37 | }); 38 | }); 39 | 40 | // Tests for if a key exists multiple times in a table 41 | describe('prev change was CREATE', () => { 42 | it('should just take the latest CREATE change', () => { 43 | const create1 = { 44 | rev: 0, 45 | key: 1, 46 | table: 'foo', 47 | obj: {}, 48 | type: CREATE, 49 | }; 50 | const create2 = { 51 | rev: 1, 52 | key: 1, 53 | table: 'foo', 54 | obj: { foo: 'bar' }, 55 | type: CREATE, 56 | }; 57 | const changes = [create1, create2]; 58 | const res = reduceChanges(changes); 59 | expect(res).to.deep.equal({ 60 | 'foo:1': create2, 61 | }); 62 | }); 63 | 64 | it('should take the latest DELETE change', () => { 65 | const create = { 66 | rev: 0, 67 | key: 1, 68 | table: 'foo', 69 | obj: {}, 70 | type: CREATE, 71 | }; 72 | const remove = { 73 | rev: 1, 74 | key: 1, 75 | table: 'foo', 76 | type: DELETE, 77 | }; 78 | const changes = [create, remove]; 79 | const res = reduceChanges(changes); 80 | expect(res).to.deep.equal({ 81 | 'foo:1': remove, 82 | }); 83 | }); 84 | 85 | it('should combine the CREATE and UPDATE change and keep rev and type of CREATE', () => { 86 | const create = { 87 | rev: 0, 88 | key: 1, 89 | table: 'foo', 90 | obj: { 91 | foo: 'baz', 92 | }, 93 | type: CREATE, 94 | }; 95 | const update = { 96 | rev: 1, 97 | key: 1, 98 | table: 'foo', 99 | mods: { 100 | title: 'bar', 101 | }, 102 | type: UPDATE, 103 | }; 104 | const changes = [create, update]; 105 | const res = reduceChanges(changes); 106 | expect(res).to.deep.equal({ 107 | 'foo:1': { 108 | rev: 0, 109 | key: 1, 110 | table: 'foo', 111 | obj: { 112 | title: 'bar', 113 | foo: 'baz', 114 | }, 115 | type: CREATE, 116 | }, 117 | }); 118 | }); 119 | }); 120 | 121 | describe('prev change was UPDATE', () => { 122 | it('should just take the latest CREATE change', () => { 123 | const update = { 124 | rev: 0, 125 | key: 1, 126 | table: 'foo', 127 | mods: { foo: 'bar' }, 128 | type: UPDATE, 129 | }; 130 | const create = { 131 | rev: 1, 132 | key: 1, 133 | table: 'foo', 134 | obj: { foo: 'bar baz' }, 135 | type: CREATE, 136 | }; 137 | const changes = [update, create]; 138 | const res = reduceChanges(changes); 139 | expect(res).to.deep.equal({ 140 | 'foo:1': create, 141 | }); 142 | }); 143 | 144 | it('should take the latest DELETE change', () => { 145 | const update = { 146 | rev: 0, 147 | key: 1, 148 | table: 'foo', 149 | mods: { foo: 'bar' }, 150 | type: UPDATE, 151 | }; 152 | const remove = { 153 | rev: 1, 154 | key: 1, 155 | table: 'foo', 156 | type: DELETE, 157 | }; 158 | const changes = [update, remove]; 159 | const res = reduceChanges(changes); 160 | expect(res).to.deep.equal({ 161 | 'foo:1': remove, 162 | }); 163 | }); 164 | 165 | it('should combine two UPDATE changes and keep rev and type of the latest UPDATE', () => { 166 | const update1 = { 167 | rev: 0, 168 | key: 1, 169 | table: 'foo', 170 | mods: { 171 | foo: 'baz', 172 | }, 173 | type: UPDATE, 174 | }; 175 | const update2 = { 176 | rev: 1, 177 | key: 1, 178 | table: 'foo', 179 | mods: { 180 | title: 'bar', 181 | }, 182 | type: UPDATE, 183 | }; 184 | const changes = [update1, update2]; 185 | const res = reduceChanges(changes); 186 | expect(res).to.deep.equal({ 187 | 'foo:1': { 188 | rev: 0, 189 | key: 1, 190 | table: 'foo', 191 | mods: { 192 | title: 'bar', 193 | foo: 'baz', 194 | }, 195 | type: UPDATE, 196 | }, 197 | }); 198 | }); 199 | }); 200 | 201 | describe('prev change was DELETE', () => { 202 | it('should recreate a change if the latest was CREATE and a DELETE previously', () => { 203 | const remove = { 204 | rev: 0, 205 | key: 1, 206 | table: 'foo', 207 | type: DELETE, 208 | }; 209 | const create = { 210 | rev: 1, 211 | key: 1, 212 | table: 'foo', 213 | obj: { foo: 'bar' }, 214 | type: CREATE, 215 | }; 216 | const changes = [remove, create]; 217 | const res = reduceChanges(changes); 218 | expect(res).to.deep.equal({ 219 | 'foo:1': create, 220 | }); 221 | }); 222 | 223 | it('should still DELETE if the latest was a DELETE', () => { 224 | const remove1 = { 225 | rev: 0, 226 | key: 1, 227 | table: 'foo', 228 | type: DELETE, 229 | }; 230 | const remove2 = { 231 | rev: 1, 232 | key: 1, 233 | table: 'foo', 234 | type: DELETE, 235 | }; 236 | const changes = [remove1, remove2]; 237 | const res = reduceChanges(changes); 238 | expect(res).to.deep.equal({ 239 | 'foo:1': remove1, 240 | }); 241 | }); 242 | 243 | it('should still DELETE if the latest was an UPDATE', () => { 244 | const remove = { 245 | rev: 0, 246 | key: 1, 247 | table: 'foo', 248 | type: DELETE, 249 | }; 250 | const update = { 251 | rev: 1, 252 | key: 1, 253 | table: 'foo', 254 | mods: { foo: 'bar' }, 255 | type: UPDATE, 256 | }; 257 | const changes = [remove, update]; 258 | const res = reduceChanges(changes); 259 | expect(res).to.deep.equal({ 260 | 'foo:1': remove, 261 | }); 262 | }); 263 | }); 264 | }); 265 | -------------------------------------------------------------------------------- /test/unit/sync/resolve_conflicts.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const chakram = require('chakram'); 4 | const expect = chakram.expect; 5 | 6 | const resolveConflicts = require('../../../lib/sync/resolve_conflicts'); 7 | const { CREATE, UPDATE, DELETE } = require('../../../lib/sync/types'); 8 | 9 | describe('resolveConflicts', () => { 10 | it('should take the client change if no server changes was found with the same key', () => { 11 | const clientChanges = [{ 12 | type: CREATE, 13 | key: 1, 14 | table: 'foo', 15 | obj: {}, 16 | }]; 17 | const serverChanges = {}; 18 | 19 | const res = resolveConflicts(clientChanges, serverChanges); 20 | expect(res).to.deep.equal([clientChanges[0]]); 21 | }); 22 | 23 | it('should not resolve anything if the server change was a CREATE', () => { 24 | const clientChanges = [{ 25 | type: UPDATE, 26 | key: 1, 27 | table: 'foo', 28 | obj: {}, 29 | }]; 30 | const serverChanges = { 31 | 'foo:1': { type: CREATE }, 32 | }; 33 | 34 | const res = resolveConflicts(clientChanges, serverChanges); 35 | expect(res).to.deep.equal([]); 36 | }); 37 | 38 | it('should not resolve anything if the server change was a DELETE', () => { 39 | const clientChanges = [{ 40 | type: UPDATE, 41 | key: 1, 42 | table: 'foo', 43 | obj: {}, 44 | }]; 45 | const serverChanges = { 46 | 'foo:1': { type: DELETE }, 47 | }; 48 | 49 | const res = resolveConflicts(clientChanges, serverChanges); 50 | expect(res).to.deep.equal([]); 51 | }); 52 | 53 | describe('server change type is UPDATE', () => { 54 | it('should take the client change and merge those with the server change if client type was CREATE', () => { 55 | const clientChanges = [{ 56 | type: CREATE, 57 | obj: { foo: 'bar', foobar: 'foobar' }, 58 | key: 1, 59 | table: 'foo', 60 | }]; 61 | const serverChanges = { 62 | 'foo:1': { 63 | type: UPDATE, 64 | key: 1, 65 | table: 'foo', 66 | mods: { foo: 'baz', bar: 'bar' }, 67 | }, 68 | }; 69 | 70 | const res = resolveConflicts(clientChanges, serverChanges); 71 | expect(res).to.deep.equal([{ 72 | type: CREATE, 73 | key: 1, 74 | table: 'foo', 75 | obj: { foo: 'baz', bar: 'bar', foobar: 'foobar' }, 76 | }]); 77 | }); 78 | 79 | it('should take the client change if the type was DELETE', () => { 80 | const clientChanges = [{ 81 | type: DELETE, 82 | key: 1, 83 | table: 'foo', 84 | }]; 85 | const serverChanges = { 86 | 'foo:1': { 87 | type: UPDATE, 88 | key: 1, 89 | table: 'foo', 90 | mods: { foo: 'baz', bar: 'bar' }, 91 | }, 92 | }; 93 | 94 | const res = resolveConflicts(clientChanges, serverChanges); 95 | expect(res).to.deep.equal([clientChanges[0]]); 96 | }); 97 | 98 | it(`should discard the change if the client and server paths to update 99 | are the same and client type is UPDATE`, () => { 100 | const clientChanges = [{ 101 | type: UPDATE, 102 | mods: { foo: 'bar' }, 103 | key: 1, 104 | table: 'foo', 105 | }]; 106 | const serverChanges = { 107 | 'foo:1': { 108 | type: UPDATE, 109 | key: 1, 110 | table: 'foo', 111 | mods: { foo: 'baz' }, 112 | }, 113 | }; 114 | 115 | const res = resolveConflicts(clientChanges, serverChanges); 116 | expect(res).to.deep.equal([]); 117 | }); 118 | 119 | it(`should discard the change if the client nested path conflicts 120 | with a server parent path and client type is UPDATE`, () => { 121 | const clientChanges = [{ 122 | type: UPDATE, 123 | mods: { 'foo.bar': 'bar' }, 124 | key: 1, 125 | table: 'foo', 126 | }]; 127 | const serverChanges = { 128 | 'foo:1': { 129 | type: UPDATE, 130 | key: 1, 131 | table: 'foo', 132 | mods: { foo: 'baz' }, 133 | }, 134 | }; 135 | 136 | const res = resolveConflicts(clientChanges, serverChanges); 137 | expect(res).to.deep.equal([]); 138 | }); 139 | 140 | it(`should accept the client change if it has key paths not conflicting 141 | with server changes and client type is UPDATE`, () => { 142 | const clientChanges = [{ 143 | type: UPDATE, 144 | mods: { foo: 'bar', foobar: 'foobar' }, 145 | key: 1, 146 | table: 'foo', 147 | }]; 148 | const serverChanges = { 149 | 'foo:1': { 150 | type: UPDATE, 151 | key: 1, 152 | table: 'foo', 153 | mods: { foo: 'baz', bar: 'bar' }, 154 | }, 155 | }; 156 | 157 | const res = resolveConflicts(clientChanges, serverChanges); 158 | expect(res).to.deep.equal([{ 159 | type: UPDATE, 160 | key: 1, 161 | table: 'foo', 162 | mods: { foobar: 'foobar' }, 163 | }]); 164 | }); 165 | 166 | it(`should accept the client change if it changes a parent path and the server changes 167 | the nested path and client type is UPDATE`, () => { 168 | const clientChanges = [{ 169 | type: UPDATE, 170 | mods: { foo: 'bar' }, 171 | key: 1, 172 | table: 'foo', 173 | }]; 174 | const serverChanges = { 175 | 'foo:1': { 176 | type: UPDATE, 177 | key: 1, 178 | table: 'foo', 179 | mods: { 'foo.bar': 'baz' }, 180 | }, 181 | }; 182 | 183 | const res = resolveConflicts(clientChanges, serverChanges); 184 | expect(res).to.deep.equal([{ 185 | type: UPDATE, 186 | key: 1, 187 | table: 'foo', 188 | mods: { foo: 'bar' }, 189 | }]); 190 | }); 191 | }); 192 | }); 193 | -------------------------------------------------------------------------------- /test/unit/sync/set_key_path.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const chakram = require('chakram'); 4 | const expect = chakram.expect; 5 | 6 | const setByKeyPath = require('../../../lib/sync/set_key_path'); 7 | 8 | describe('setByKeyPath', () => { 9 | it('should return undefined if the given object or keyPath are undefined', () => { 10 | let res = setByKeyPath(undefined, '', 'a'); 11 | expect(res).to.be.undefined; 12 | 13 | res = setByKeyPath({}, undefined, 'a'); 14 | expect(res).to.be.undefined; 15 | }); 16 | 17 | it('should set object keyPath to have the given value', () => { 18 | const obj1 = {}; 19 | setByKeyPath(obj1, 'foo', 'value'); 20 | expect(obj1).to.deep.equal({ foo: 'value' }); 21 | 22 | const obj2 = { foo: 'oldValue' }; 23 | setByKeyPath(obj2, 'foo', 'value2'); 24 | expect(obj2).to.deep.equal({ foo: 'value2' }); 25 | }); 26 | 27 | it('should be able to set the value for a nested keyPath', () => { 28 | const obj1 = {}; 29 | setByKeyPath(obj1, 'foo.bar', 'value'); 30 | expect(obj1).to.deep.equal({ foo: { bar: 'value' } }); 31 | 32 | const obj2 = { foo: { bar: 'oldValue' } }; 33 | setByKeyPath(obj2, 'foo.bar', 'value2'); 34 | expect(obj2).to.deep.equal({ foo: { bar: 'value2' } }); 35 | }); 36 | 37 | it('should work if the given keyPath ends with a dot', () => { 38 | const obj1 = {}; 39 | setByKeyPath(obj1, 'foo.bar.', 'value'); 40 | expect(obj1).to.deep.equal({ foo: { bar: 'value' } }); 41 | 42 | const obj2 = { foo: { bar: 'oldValue' } }; 43 | setByKeyPath(obj2, 'foo.bar', 'value2'); 44 | expect(obj2).to.deep.equal({ foo: { bar: 'value2' } }); 45 | }); 46 | }); 47 | --------------------------------------------------------------------------------