├── .editorconfig ├── .gitignore ├── .jsdocrc ├── .npmignore ├── .travis.yml ├── .yaspellerrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── create-logux-store.js ├── example └── basic │ ├── .gitignore │ ├── README.md │ ├── index.js │ ├── package-lock.json │ ├── package.json │ ├── public │ └── index.html │ ├── source │ ├── index.js │ └── root.vue │ └── webpack.config.js ├── index.js ├── package-lock.json ├── package.json ├── test ├── create-logux-store.test.js └── index.test.js └── utils ├── deep-clone.js └── test └── deep-clone.test.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *~ 3 | 4 | node_modules/ 5 | npm-debug.log 6 | yarn-error.log 7 | 8 | coverage/ 9 | docs/ 10 | -------------------------------------------------------------------------------- /.jsdocrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "plugins/markdown" 4 | ], 5 | "opts": { 6 | "template": "node_modules/docdash", 7 | "destination": "docs/" 8 | }, 9 | "templates": { 10 | "default": { 11 | "includeDate": false 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .gitignore 2 | .npmignore 3 | .editorconfig 4 | 5 | example/ 6 | 7 | node_modules/ 8 | npm-debug.log 9 | yarn-error.log 10 | package-lock.json 11 | yarn.lock 12 | 13 | test/ 14 | coverage/ 15 | .travis.yml 16 | 17 | docs/ 18 | .jsdocrc 19 | .yaspellerrc 20 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - "9" 5 | - "8" 6 | - "7" 7 | - "6" 8 | - "5" 9 | - "4" 10 | 11 | cache: 12 | directories: 13 | - "node_modules" 14 | -------------------------------------------------------------------------------- /.yaspellerrc: -------------------------------------------------------------------------------- 1 | { 2 | "lang": "en", 3 | "ignoreCapitalization": true, 4 | "excludeFiles": [ 5 | "docs/*.js.html" 6 | ], 7 | "dictionary": [ 8 | "logux", 9 | "docdash", 10 | "JSDoc", 11 | "Versioning", 12 | "UX", 13 | "Vuex" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | This project adheres to [Semantic Versioning](http://semver.org/). 3 | 4 | ## 0.1.2 5 | * Add the classical syntax of commits 6 | 7 | ## 0.1.1 8 | * Generates JSDoc documentation 9 | * Don’t apply action if it was deleted during waiting for replay end. 10 | 11 | ## 0.1 12 | * Initial release. 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Nikolay Govorov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Logux Vuex 2 | 3 | 5 | 6 | [![Build Status](https://travis-ci.org/nikolay-govorov/logux-vuex.svg?branch=master)](https://travis-ci.org/nikolay-govorov/logux-vuex) 7 | 8 | Logux is a client-server communication protocol. It synchronizes action 9 | between clients and server logs. 10 | 11 | This library provides Vuex compatible API. 12 | 13 | You may see the examples in the [`examples` folder](example/). 14 | 15 | ## Install 16 | 17 | ```sh 18 | npm i --save logux-vuex 19 | ``` 20 | 21 | ## Usage 22 | 23 | Create Vuex store by `createLoguxStore`. It returns original Vuex store `Vuex.Store` function with Logux inside 24 | 25 | ```diff 26 | import Vue from 'vue'; 27 | import Vuex from 'vuex'; 28 | 29 | +import createLoguxStore from 'logux-vuex/create-logux-store'; 30 | 31 | Vue.use(Vuex); 32 | 33 | -const Store = Vuex.Store; 34 | +const Store = createLoguxStore({ 35 | + subprotocol: '1.0.0', 36 | + server: 'wss://localhost:1337', 37 | + userId: 10 38 | +}); 39 | 40 | const store = new Store({ 41 | state: { 42 | count: 0 43 | }, 44 | mutations: { 45 | increment(state) { 46 | state.count++ 47 | } 48 | } 49 | }) 50 | 51 | +store.client.start() 52 | ``` 53 | See also [basic usage example](https://github.com/nikolay-govorov/logux-vuex-example) and [Logux Status] for UX best practices. 54 | 55 | [Logux Status]: https://github.com/logux/logux-status 56 | 57 | ## Commit 58 | 59 | Instead of Vuex, in Logux Vuex you have 4 ways to commit action: 60 | 61 | * `store.commit(action)` is legacy API. Try to avoid it since you can’t 62 | specify how clean this actions. 63 | * `store.commit.local(action, meta)` — action will be visible only to current 64 | browser tab. 65 | * `store.commit.crossTab(action, meta)` — action will be visible 66 | to all browser tab. 67 | * `store.commit.sync(action, meta)` — action will be visible to server 68 | and all browser tabs. 69 | 70 | In all 3 new commit methods you must to specify `meta.reasons` with array 71 | of “reasons”. It is code names of reasons, why this action should be still 72 | in the log. 73 | 74 | ```js 75 | store.commit.crossTab( 76 | { type: 'CHANGE_NAME', name }, { reasons: ['lastName'] } 77 | ) 78 | ``` 79 | 80 | When you don’t need some actions, you can remove reasons from them: 81 | 82 | ```js 83 | store.commit.crossTab( 84 | { type: 'CHANGE_NAME', name }, { reasons: ['lastName'] } 85 | ).then(meta => { 86 | store.log.removeReason('lastName', { maxAdded: meta.added - 1 }) 87 | }) 88 | ``` 89 | 90 | Action with empty reasons will be removed from log. 91 | -------------------------------------------------------------------------------- /create-logux-store.js: -------------------------------------------------------------------------------- 1 | var Store = require('vuex').Store 2 | var isFirstOlder = require('logux-core/is-first-older') 3 | var CrossTabClient = require('logux-client/cross-tab-client') 4 | 5 | var deepClone = require('./utils/deep-clone') 6 | 7 | function warnBadUndo (id) { 8 | var json = JSON.stringify(id) 9 | 10 | console.warn( 11 | 'Logux can not find ' + json + ' to undo it. Maybe action was cleaned.' 12 | ) 13 | } 14 | 15 | function LoguxState (client, config, vuexConfig) { 16 | var self = this 17 | 18 | Store.call(this, deepClone(vuexConfig)) 19 | 20 | this.client = client 21 | this.log = client.log 22 | 23 | var init 24 | var prevMeta 25 | var replaying 26 | var wait = {} 27 | var history = {} 28 | var actionCount = 0 29 | var started = (function (now) { 30 | return { id: now, time: now[0] } 31 | })(client.log.generateId()) 32 | 33 | self.initialize = new Promise(function (resolve) { 34 | init = resolve 35 | }) 36 | 37 | self.commit = function commit () { 38 | var action 39 | var isNoObjectTypeAction 40 | 41 | if (typeof arguments[0] === 'string') { 42 | var type = arguments[0] 43 | var options = Array.prototype.slice.call(arguments, 1) 44 | isNoObjectTypeAction = true 45 | 46 | action = { 47 | type: type, 48 | options: options 49 | } 50 | } else { 51 | action = arguments[0] 52 | } 53 | 54 | var meta = { 55 | id: client.log.generateId(), 56 | tab: client.id, 57 | reasons: ['tab' + client.id], 58 | dispatch: true 59 | } 60 | 61 | client.log.add(action, meta) 62 | 63 | prevMeta = meta 64 | originCommit(action, isNoObjectTypeAction) 65 | saveHistory(meta) 66 | } 67 | 68 | self.commit.local = function local (action, meta) { 69 | meta.tab = client.id 70 | return client.log.add(action, meta) 71 | } 72 | 73 | self.commit.crossTab = function crossTab (action, meta) { 74 | return client.log.add(action, meta) 75 | } 76 | 77 | self.commit.sync = function sync (action, meta) { 78 | if (!meta) meta = {} 79 | if (!meta.reasons) meta.reasons = [] 80 | 81 | meta.sync = true 82 | meta.reasons.push('waitForSync') 83 | 84 | return client.log.add(action, meta) 85 | } 86 | 87 | client.log.on('preadd', function (action, meta) { 88 | if (action.type === 'logux/undo' && meta.reasons.length === 0) { 89 | meta.reasons.push('reasonsLoading') 90 | } 91 | 92 | if (!isFirstOlder(prevMeta, meta) && meta.reasons.length === 0) { 93 | meta.reasons.push('replay') 94 | } 95 | }) 96 | 97 | var lastAdded = 0 98 | var dispatchCalls = 0 99 | client.on('add', function (action, meta) { 100 | if (meta.added > lastAdded) lastAdded = meta.added 101 | 102 | if (meta.dispatch) { 103 | dispatchCalls += 1 104 | 105 | if (lastAdded > config.dispatchHistory && dispatchCalls % 25 === 0) { 106 | client.log.removeReason('tab' + client.id, { 107 | maxAdded: lastAdded - config.dispatchHistory 108 | }) 109 | } 110 | 111 | return 112 | } 113 | 114 | process(action, meta, isFirstOlder(meta, started)) 115 | }) 116 | 117 | client.on('clean', function (action, meta) { 118 | var key = meta.id.join('\t') 119 | 120 | delete wait[key] 121 | delete history[key] 122 | }) 123 | 124 | client.sync.on('state', function () { 125 | if (client.sync.state === 'synchronized') { 126 | client.log.removeReason('waitForSync', { maxAdded: client.sync.lastSent }) 127 | } 128 | }) 129 | 130 | var previous = [] 131 | var ignores = {} 132 | client.log.each(function (action, meta) { 133 | if (!meta.tab) { 134 | if (action.type === 'logux/undo') { 135 | ignores[action.id.join('\t')] = true 136 | } else if (!ignores[meta.id.join('\t')]) { 137 | previous.push([action, meta]) 138 | } 139 | } 140 | }).then(function () { 141 | if (previous.length > 0) { 142 | previous.forEach(function (i) { 143 | process(i[0], i[1], true) 144 | }) 145 | } 146 | 147 | init() 148 | }) 149 | 150 | // Functions 151 | function saveHistory (meta) { 152 | actionCount += 1 153 | 154 | if ( 155 | config.saveStateEvery === 1 || actionCount % config.saveStateEvery === 1 156 | ) { 157 | history[meta.id.join('\t')] = deepClone(self.state) 158 | } 159 | } 160 | 161 | function originCommit (action, isNotObjectTypeAction) { 162 | if (action.type === 'logux/state') { 163 | self.replaceState(action.state) 164 | 165 | return 166 | } 167 | 168 | var commitArgs = arguments 169 | 170 | if (isNotObjectTypeAction) { 171 | commitArgs = [action.type].concat(action.options) 172 | } 173 | 174 | if (action.type in self._mutations) { 175 | Store.prototype.commit.apply(self, commitArgs) 176 | } 177 | } 178 | 179 | function replaceState (state, actions) { 180 | var newState = actions.reduceRight(function (prev, i) { 181 | var changed = deepClone(prev) 182 | 183 | if (vuexConfig.mutations[i[0].type]) { 184 | vuexConfig.mutations[i[0].type](changed, i[0]) 185 | } 186 | 187 | if (history[i[1]]) history[i[1]] = changed 188 | 189 | return changed 190 | }, state) 191 | 192 | originCommit({ type: 'logux/state', state: newState }) 193 | } 194 | 195 | function replay (actionId, replayIsSafe) { 196 | var until = actionId.join('\t') 197 | 198 | var ignore = {} 199 | var actions = [] 200 | var replayed = false 201 | var newAction 202 | var collecting = true 203 | 204 | replaying = new Promise(function (resolve) { 205 | client.log.each(function (action, meta) { 206 | if (meta.tab && meta.tab !== client.id) return true 207 | 208 | var id = meta.id.join('\t') 209 | 210 | if (collecting || !history[id]) { 211 | if (action.type === 'logux/undo') { 212 | ignore[action.id.join('\t')] = true 213 | return true 214 | } 215 | 216 | if (!ignore[id]) actions.push([action, id]) 217 | if (id === until) { 218 | newAction = action 219 | collecting = false 220 | } 221 | 222 | return true 223 | } else { 224 | replayed = true 225 | replaceState(history[id], actions) 226 | 227 | return false 228 | } 229 | }).then(function () { 230 | if (!replayed) { 231 | if (config.onMissedHistory) config.onMissedHistory(newAction) 232 | 233 | var full 234 | if (replayIsSafe) { 235 | full = actions 236 | } else { 237 | full = actions.slice(0) 238 | while (actions.length > 0) { 239 | var last = actions[actions.length - 1] 240 | actions.pop() 241 | if (history[last[1]]) { 242 | replayed = true 243 | replaceState(history[last[1]], actions.concat([ 244 | [newAction, until] 245 | ])) 246 | break 247 | } 248 | } 249 | } 250 | 251 | if (!replayed) { 252 | replaceState(deepClone(vuexConfig.state), full) 253 | } 254 | } 255 | 256 | replaying = false 257 | resolve() 258 | }) 259 | }) 260 | 261 | return replaying 262 | } 263 | 264 | function process (action, meta, replayIsSafe) { 265 | if (replaying) { 266 | var key = meta.id.join('\t') 267 | wait[key] = true 268 | 269 | replaying.then(function () { 270 | if (wait[key]) { 271 | process(action, meta, replayIsSafe) 272 | 273 | delete wait[key] 274 | } 275 | }) 276 | 277 | return 278 | } 279 | 280 | if (action.type === 'logux/undo') { 281 | var reasons = meta.reasons 282 | client.log.byId(action.id).then(function (result) { 283 | if (result[0]) { 284 | if (reasons.length === 1 && reasons[0] === 'reasonsLoading') { 285 | client.log.changeMeta(meta.id, { reasons: result[1].reasons }) 286 | } 287 | delete history[action.id.join('\t')] 288 | replay(action.id) 289 | } else { 290 | client.log.changeMeta(meta.id, { reasons: [] }) 291 | warnBadUndo(action.id) 292 | } 293 | }) 294 | } else if (isFirstOlder(prevMeta, meta)) { 295 | prevMeta = meta 296 | originCommit(action) 297 | if (meta.added) saveHistory(meta) 298 | } else { 299 | replay(meta.id, replayIsSafe).then(function () { 300 | if (meta.reasons.indexOf('replay') !== -1) { 301 | client.log.changeMeta(meta.id, { 302 | reasons: meta.reasons.filter(function (i) { 303 | return i !== 'replay' 304 | }) 305 | }) 306 | } 307 | }) 308 | } 309 | } 310 | } 311 | 312 | LoguxState.prototype = Object.create(Store.prototype) 313 | 314 | LoguxState.prototype.constructor = LoguxState 315 | 316 | function createLoguxState (config) { 317 | if (!config) config = {} 318 | 319 | var client = new CrossTabClient(config) 320 | 321 | return LoguxState.bind(null, client, { 322 | dispatchHistory: config.dispatchHistory || 1000, 323 | saveStateEvery: config.saveStateEvery || 50, 324 | onMissedHistory: config.onMissedHistory 325 | }) 326 | } 327 | 328 | module.exports = createLoguxState 329 | -------------------------------------------------------------------------------- /example/basic/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | public/dist 4 | -------------------------------------------------------------------------------- /example/basic/README.md: -------------------------------------------------------------------------------- 1 | # Logux Vuex basic example 2 | 3 | 5 | 6 | Logux is a client-server communication protocol. It synchronizes action 7 | between clients and server logs. 8 | 9 | This library provides Vuex compatible API. 10 | 11 | ## Installation 12 | 13 | Clone repository and install dependencies: 14 | 15 | ```bash 16 | $ git clone https://github.com/nikolay-govorov/logux-vuex-example.git 17 | $ cd logux-vuex-example 18 | $ npm install 19 | ``` 20 | 21 | ## Using 22 | 23 | Development mode: 24 | 25 | ```bash 26 | $ npm run dev 27 | ``` 28 | 29 | Production mode: 30 | 31 | ```bash 32 | $ npm run build 33 | $ npm run prod 34 | ``` 35 | -------------------------------------------------------------------------------- /example/basic/index.js: -------------------------------------------------------------------------------- 1 | const Server = require('logux-server').Server 2 | 3 | const app = new Server( 4 | Server.loadOptions(process, { 5 | subprotocol: '1.0.0', 6 | supports: '1.x', 7 | root: __dirname 8 | }) 9 | ) 10 | 11 | let increment = 0; 12 | 13 | app.auth((userId, token) => { 14 | return true; 15 | }) 16 | 17 | app.type('increment', { 18 | access() { 19 | return true; 20 | }, 21 | process() { 22 | return ++increment; 23 | } 24 | }); 25 | 26 | app.listen() 27 | -------------------------------------------------------------------------------- /example/basic/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "logux-vuex-example", 3 | "version": "0.1.0", 4 | "description": "An example application using logux-vuex", 5 | "scripts": { 6 | "dev": "nodemon index.js & webpack-dev-server", 7 | "prod": "node index.js & static public", 8 | "build": "webpack" 9 | }, 10 | "author": "Nikolay Govorov ", 11 | "dependencies": { 12 | "logux-core": "^0.2.2", 13 | "logux-server": "^0.2.9", 14 | "logux-vuex": "github:nikolay-govorov/logux-vuex", 15 | "node-static": "^0.7.11", 16 | "vue": "^2.5.17", 17 | "vuex": "^3.0.1" 18 | }, 19 | "devDependencies": { 20 | "vue-loader": "^13.7.3", 21 | "vue-style-loader": "^3.1.2", 22 | "vue-template-compiler": "^2.5.17", 23 | "webpack": "^3.12.0", 24 | "webpack-dev-server": "^2.11.3" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /example/basic/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Vue-logux example 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /example/basic/source/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Vuex from 'vuex'; 3 | import createLoguxStore from 'logux-vuex/create-logux-store'; 4 | 5 | import Root from './root.vue'; 6 | 7 | Vue.use(Vuex); 8 | 9 | const Store = createLoguxStore({ 10 | subprotocol: '1.0.0', 11 | server: 'ws://localhost:1337', 12 | userId: 10 13 | }); 14 | 15 | const store = new Store({ 16 | state: { 17 | count: 0, 18 | }, 19 | 20 | mutations: { 21 | increment(state) { 22 | state.count++ 23 | } 24 | } 25 | }); 26 | 27 | store.client.start(); 28 | 29 | const app = new Vue({ 30 | store, 31 | 32 | render: h => h(Root), 33 | }); 34 | 35 | app.$mount('#root'); 36 | -------------------------------------------------------------------------------- /example/basic/source/root.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 30 | 31 | -------------------------------------------------------------------------------- /example/basic/webpack.config.js: -------------------------------------------------------------------------------- 1 | const { resolve } = require('path'); 2 | 3 | module.exports = { 4 | context: resolve('source'), 5 | 6 | entry: './index', 7 | 8 | output: { 9 | path: resolve('public', 'dist'), 10 | publicPath: '/dist/', 11 | filename: 'bundle.js', 12 | }, 13 | 14 | module: { 15 | rules: [ 16 | { 17 | test: /\.vue$/, 18 | use: ['vue-loader'] 19 | }, 20 | { 21 | test: /\.(css|pcss)$/, 22 | include: [/source/], 23 | use: ['vue-style-loader'] 24 | } 25 | ] 26 | }, 27 | 28 | devServer: { 29 | contentBase: ['public'], 30 | }, 31 | }; 32 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | var createLoguxStore = require('./create-logux-store') 2 | 3 | module.exports = { 4 | createLoguxStore: createLoguxStore 5 | } 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "logux-vuex", 3 | "version": "0.1.3", 4 | "description": "Vuex compatible API for Logux", 5 | "keywords": [ 6 | "logux", 7 | "client", 8 | "vuex" 9 | ], 10 | "author": "Nikolay Govorov ", 11 | "repository": "nikolay-govorov/logux-vuex", 12 | "dependencies": { 13 | "logux-client": "^0.2.10", 14 | "logux-core": "^0.2.2", 15 | "logux-sync": "^0.2.7", 16 | "vuex": "^3.0.1" 17 | }, 18 | "devDependencies": { 19 | "docdash": "^0.4.0", 20 | "eslint": "^4.19.1", 21 | "eslint-config-logux": "^17.0.0", 22 | "eslint-config-standard": "^10.2.1", 23 | "eslint-plugin-es5": "^1.3.1", 24 | "eslint-plugin-import": "^2.14.0", 25 | "eslint-plugin-jest": "^21.26.0", 26 | "eslint-plugin-node": "^5.2.1", 27 | "eslint-plugin-promise": "^3.8.0", 28 | "eslint-plugin-security": "^1.4.0", 29 | "eslint-plugin-standard": "^3.1.0", 30 | "jest": "^21.2.1", 31 | "jsdoc": "^3.5.5", 32 | "lint-staged": "^6.1.1", 33 | "pre-commit": "^1.2.2", 34 | "rimraf": "^2.6.2", 35 | "size-limit": "^0.13.2", 36 | "vue": "^2.5.17", 37 | "yaspeller-ci": "^1.0.0" 38 | }, 39 | "scripts": { 40 | "lint-staged": "lint-staged", 41 | "spellcheck": "yarn docs && yaspeller-ci *.md docs/*.html", 42 | "clean": "rimraf coverage/ docs/", 43 | "lint": "eslint *.js test/{**/,}*.js", 44 | "docs": "jsdoc --configure .jsdocrc *.js", 45 | "test": "jest --coverage && npm run lint && size-limit && yarn spellcheck" 46 | }, 47 | "jest": { 48 | "coverageThreshold": { 49 | "global": { 50 | "statements": 100 51 | } 52 | } 53 | }, 54 | "eslintConfig": { 55 | "extends": "eslint-config-logux/browser" 56 | }, 57 | "size-limit": [ 58 | { 59 | "path": "index.js", 60 | "limit": "13.3 KB" 61 | } 62 | ], 63 | "lint-staged": { 64 | "*.md": "yaspeller-ci", 65 | "*.js": "eslint" 66 | }, 67 | "pre-commit": [ 68 | "lint-staged" 69 | ] 70 | } 71 | -------------------------------------------------------------------------------- /test/create-logux-store.test.js: -------------------------------------------------------------------------------- 1 | var Vue = require('vue') 2 | var Vuex = require('vuex') 3 | 4 | var TestPair = require('logux-sync').TestPair 5 | var TestTime = require('logux-core').TestTime 6 | 7 | Vue.use(Vuex) 8 | 9 | var createLoguxStore = require('../create-logux-store') 10 | 11 | function createStore (mutations, opts) { 12 | if (!opts) opts = { } 13 | if (!opts.server) opts.server = 'wss://localhost:1337' 14 | 15 | opts.subprotocol = '1.0.0' 16 | opts.userId = 10 17 | opts.time = new TestTime() 18 | 19 | var LoguxStore = createLoguxStore(opts) 20 | var store = new LoguxStore({ 21 | state: { value: 0 }, 22 | 23 | mutations: mutations 24 | }) 25 | 26 | var prev = 0 27 | store.log.generateId = function () { 28 | prev += 1 29 | return [prev, store.client.options.userId + ':uuid', 0] 30 | } 31 | 32 | return store 33 | } 34 | 35 | function increment (state) { 36 | state.value = state.value + 1 37 | } 38 | 39 | function historyLine (state, action) { 40 | state.value = state.value + action.value 41 | } 42 | 43 | function actions (log) { 44 | return log.store.created.map(function (i) { 45 | return i[0] 46 | }) 47 | } 48 | 49 | var originWarn = console.warn 50 | afterEach(function () { 51 | console.warn = originWarn 52 | }) 53 | 54 | it('throws error on missed config', function () { 55 | expect(function () { 56 | createLoguxStore() 57 | }).toThrowError('Missed server option in Logux client') 58 | }) 59 | 60 | it('creates Vuex store', function () { 61 | var store = createStore({ increment: increment }) 62 | 63 | store.commit({ 64 | type: 'increment' 65 | }) 66 | 67 | expect(store.state).toEqual({ value: 1 }) 68 | }) 69 | 70 | it('not Object type action', function () { 71 | var store = createStore({ 72 | increment: function (state, count) { 73 | state.value = state.value + count 74 | } 75 | }) 76 | 77 | store.commit('increment', 5) 78 | 79 | expect(store.state).toEqual({ value: 5 }) 80 | }) 81 | 82 | it('creates Logux client', function () { 83 | var store = createStore({ increment: increment }) 84 | 85 | expect(store.client.options.subprotocol).toEqual('1.0.0') 86 | }) 87 | 88 | it('not found mutation', function () { 89 | var store = createStore({ increment: increment }) 90 | 91 | store.commit.crossTab({ type: 'mutation' }) 92 | 93 | store.commit('increment') 94 | store.commit('increment') 95 | 96 | store.commit({ type: 'logux/state', state: { value: 1 } }) 97 | 98 | expect(store.state).toEqual({ value: 1 }) 99 | }) 100 | 101 | it('sets tab ID', function () { 102 | var store = createStore({ increment: increment }) 103 | 104 | return new Promise(function (resolve) { 105 | store.log.on('add', function (action, meta) { 106 | expect(meta.tab).toEqual(store.client.id) 107 | expect(meta.reasons).toEqual(['tab' + store.client.id]) 108 | 109 | resolve() 110 | }) 111 | 112 | store.commit({ 113 | type: 'increment' 114 | }) 115 | }) 116 | }) 117 | 118 | it('has shortcut for add', function () { 119 | var store = createStore({ increment: increment }) 120 | 121 | return store.commit.crossTab( 122 | { type: 'increment' }, { reasons: ['test'] } 123 | ).then(function () { 124 | expect(store.state).toEqual({ value: 1 }) 125 | expect(store.log.store.created[0][1].reasons).toEqual(['test']) 126 | }) 127 | }) 128 | 129 | it('listen for action from other tabs', function () { 130 | var store = createStore({ increment: increment }) 131 | 132 | store.client.emitter.emit('add', { type: 'increment' }, { id: [1, 't', 0] }) 133 | 134 | expect(store.state).toEqual({ value: 1 }) 135 | }) 136 | 137 | it('saves previous states', function () { 138 | var calls = 0 139 | 140 | var store = createStore({ 141 | a: function () { 142 | calls += 1 143 | } 144 | }) 145 | 146 | var promise = Promise.resolve() 147 | 148 | for (var i = 0; i < 60; i++) { 149 | if (i % 2 === 0) { 150 | promise = promise.then(function () { 151 | return store.commit.crossTab({ type: 'a' }, { reasons: ['test'] }) 152 | }) 153 | } else { 154 | store.commit({ type: 'a' }) 155 | } 156 | } 157 | 158 | return promise.then(function () { 159 | expect(calls).toEqual(60) 160 | calls = 0 161 | 162 | return store.commit.crossTab( 163 | { type: 'a' }, { id: [57, '10:uuid', 1], reasons: ['test'] } 164 | ) 165 | }).then(function () { 166 | expect(calls).toEqual(10) 167 | }) 168 | }) 169 | 170 | it('changes history recording frequency', function () { 171 | var calls = 0 172 | 173 | var store = createStore({ 174 | a: function () { 175 | calls += 1 176 | } 177 | }, { 178 | saveStateEvery: 1 179 | }) 180 | 181 | return Promise.all([ 182 | store.commit.crossTab({ type: 'a' }, { reasons: ['test'] }), 183 | store.commit.crossTab({ type: 'a' }, { reasons: ['test'] }), 184 | store.commit.crossTab({ type: 'a' }, { reasons: ['test'] }), 185 | store.commit.crossTab({ type: 'a' }, { reasons: ['test'] }) 186 | ]).then(function () { 187 | calls = 0 188 | return store.commit.crossTab( 189 | { type: 'a' }, { id: [3, '10:uuid', 1], reasons: ['test'] }) 190 | }).then(function () { 191 | expect(calls).toEqual(2) 192 | }) 193 | }) 194 | 195 | it('cleans its history on removing action', function () { 196 | var calls = 0 197 | var store = createStore({ 198 | a: function () { 199 | calls += 1 200 | } 201 | }, { 202 | saveStateEvery: 2 203 | }) 204 | 205 | return Promise.all([ 206 | store.commit.crossTab({ type: 'a' }, { reasons: ['test'] }), 207 | store.commit.crossTab({ type: 'a' }, { reasons: ['test'] }), 208 | store.commit.crossTab({ type: 'a' }, { reasons: ['test'] }), 209 | store.commit.crossTab({ type: 'a' }, { reasons: ['test'] }), 210 | store.commit.crossTab({ type: 'a' }, { reasons: ['test'] }), 211 | store.commit.crossTab({ type: 'a' }, { reasons: ['test'] }) 212 | ]).then(function () { 213 | return store.log.changeMeta([5, '10:uuid', 0], { reasons: [] }) 214 | }).then(function () { 215 | calls = 0 216 | return store.commit.crossTab( 217 | { type: 'a' }, { id: [5, '10:uuid', 1], reasons: ['test'] }) 218 | }).then(function () { 219 | expect(calls).toEqual(3) 220 | }) 221 | }) 222 | 223 | it('changes history', function () { 224 | var store = createStore({ historyLine: historyLine }) 225 | 226 | return Promise.all([ 227 | store.commit.crossTab( 228 | { type: 'historyLine', value: 'a' }, { reasons: ['test'] } 229 | ), 230 | store.commit.crossTab( 231 | { type: 'historyLine', value: 'b' }, { reasons: ['test'] } 232 | ) 233 | ]).then(function () { 234 | store.commit({ type: 'historyLine', value: 'c' }) 235 | store.commit({ type: 'historyLine', value: 'd' }) 236 | 237 | return store.commit.crossTab( 238 | { type: 'historyLine', value: '|' }, 239 | { id: [2, '10:uuid', 1], reasons: ['test'] } 240 | ) 241 | }).then(function () { 242 | expect(store.state.value).toEqual('0ab|cd') 243 | }) 244 | }) 245 | 246 | it('undoes actions', function () { 247 | var store = createStore({ historyLine: historyLine }) 248 | 249 | return Promise.all([ 250 | store.commit.crossTab( 251 | { type: 'historyLine', value: 'a' }, { reasons: ['test'] }), 252 | store.commit.crossTab( 253 | { type: 'historyLine', value: 'b' }, { reasons: ['test'] }), 254 | store.commit.crossTab( 255 | { type: 'historyLine', value: 'c' }, { reasons: ['test'] }) 256 | ]).then(function () { 257 | expect(store.state.value).toEqual('0abc') 258 | 259 | return store.commit.crossTab( 260 | { type: 'logux/undo', id: [2, '10:uuid', 0] }, { reasons: ['test'] } 261 | ) 262 | }).then(function () { 263 | expect(store.state.value).toEqual('0ac') 264 | }) 265 | }) 266 | 267 | it('warns about undoes cleaned action', function () { 268 | console.warn = jest.fn() 269 | var store = createStore({ increment: increment }) 270 | 271 | return store.commit.crossTab( 272 | { type: 'logux/undo', id: [1, 't', 0] }, { reasons: [] } 273 | ).then(function () { 274 | expect(console.warn).toHaveBeenCalledWith( 275 | 'Logux can not find [1,"t",0] to undo it. Maybe action was cleaned.' 276 | ) 277 | }) 278 | }) 279 | 280 | it('replays history since last state', function () { 281 | var onMissedHistory = jest.fn() 282 | 283 | var store = createStore({ historyLine: historyLine }, { 284 | onMissedHistory: onMissedHistory, 285 | saveStateEvery: 2 286 | }) 287 | 288 | return Promise.all([ 289 | store.commit.crossTab( 290 | { type: 'historyLine', value: 'a' }, { reasons: ['one'] }), 291 | store.commit.crossTab( 292 | { type: 'historyLine', value: 'b' }, { reasons: ['test'] }), 293 | store.commit.crossTab( 294 | { type: 'historyLine', value: 'c' }, { reasons: ['test'] }), 295 | store.commit.crossTab( 296 | { type: 'historyLine', value: 'd' }, { reasons: ['test'] }) 297 | ]).then(function () { 298 | return store.log.removeReason('one') 299 | }).then(function () { 300 | return store.commit.crossTab( 301 | { type: 'historyLine', value: '|' }, 302 | { id: [1, '10:uuid', 0], reasons: ['test'] } 303 | ) 304 | }).then(function () { 305 | expect(onMissedHistory) 306 | .toHaveBeenCalledWith({ type: 'historyLine', value: '|' }) 307 | expect(store.state.value).toEqual('0abc|d') 308 | }) 309 | }) 310 | 311 | it('replays history for reason-less action', function () { 312 | var store = createStore({ historyLine: historyLine }) 313 | 314 | return Promise.all([ 315 | store.commit.crossTab( 316 | { type: 'historyLine', value: 'a' }, { reasons: ['test'] }), 317 | store.commit.crossTab( 318 | { type: 'historyLine', value: 'b' }, { reasons: ['test'] }), 319 | store.commit.crossTab( 320 | { type: 'historyLine', value: 'c' }, { reasons: ['test'] }) 321 | ]).then(function () { 322 | return store.commit.crossTab( 323 | { type: 'historyLine', value: '|' }, { id: [1, '10:uuid', 1] } 324 | ) 325 | }).then(function () { 326 | return Promise.resolve() 327 | }).then(function () { 328 | expect(store.state.value).toEqual('0a|bc') 329 | expect(store.log.store.created).toHaveLength(3) 330 | }) 331 | }) 332 | 333 | it('replays actions before staring since initial state', function () { 334 | var onMissedHistory = jest.fn() 335 | var store = createStore({ historyLine: historyLine }, { 336 | onMissedHistory: onMissedHistory, 337 | saveStateEvery: 2 338 | }) 339 | 340 | return Promise.all([ 341 | store.commit.crossTab( 342 | { type: 'historyLine', value: 'b' }, { reasons: ['test'] }), 343 | store.commit.crossTab( 344 | { type: 'historyLine', value: 'c' }, { reasons: ['test'] }), 345 | store.commit.crossTab( 346 | { type: 'historyLine', value: 'd' }, { reasons: ['test'] }) 347 | ]).then(function () { 348 | return store.commit.crossTab( 349 | { type: 'historyLine', value: '|' }, 350 | { id: [0, '10:uuid', 0], reasons: ['test'] } 351 | ) 352 | }).then(function () { 353 | expect(store.state.value).toEqual('0|bcd') 354 | }) 355 | }) 356 | 357 | it('replays actions on missed history', function () { 358 | var onMissedHistory = jest.fn() 359 | 360 | var store = createStore({ historyLine: historyLine }, { 361 | onMissedHistory: onMissedHistory 362 | }) 363 | 364 | return Promise.all([ 365 | store.commit.crossTab( 366 | { type: 'historyLine', value: 'a' }, { reasons: ['one'] }), 367 | store.commit.crossTab( 368 | { type: 'historyLine', value: 'b' }, { reasons: ['test'] }) 369 | ]).then(function () { 370 | return store.log.removeReason('one') 371 | }).then(function () { 372 | return store.commit.crossTab( 373 | { type: 'historyLine', value: '|' }, 374 | { id: [0, '10:uuid', 0], reasons: ['test'] } 375 | ) 376 | }).then(function () { 377 | expect(onMissedHistory) 378 | .toHaveBeenCalledWith({ type: 'historyLine', value: '|' }) 379 | expect(store.state.value).toEqual('0|b') 380 | }) 381 | }) 382 | 383 | it('does not fall on missed onMissedHistory', function () { 384 | var store = createStore({ historyLine: historyLine }) 385 | 386 | return Promise.all([ 387 | store.commit.crossTab( 388 | { type: 'historyLine', value: 'a' }, { reasons: ['first'] }) 389 | ]).then(function () { 390 | return store.log.removeReason('first') 391 | }).then(function () { 392 | return store.commit.crossTab( 393 | { type: 'historyLine', value: '|' }, 394 | { id: [0, '10:uuid', 0], reasons: ['test'] } 395 | ) 396 | }).then(function () { 397 | expect(store.state.value).toEqual('0|') 398 | }) 399 | }) 400 | 401 | it('cleans action added by commit', function () { 402 | var store = createStore({ historyLine: historyLine }, { 403 | dispatchHistory: 3 404 | }) 405 | 406 | function add (index) { 407 | return function () { 408 | store.commit({ type: 'historyLine', value: index }) 409 | } 410 | } 411 | 412 | var promise = Promise.resolve() 413 | for (var i = 1; i <= 25; i++) { 414 | promise = promise.then(add(i)) 415 | } 416 | 417 | return promise.then(function () { 418 | expect(actions(store.log)).toEqual([ 419 | { type: 'historyLine', value: 25 }, 420 | { type: 'historyLine', value: 24 }, 421 | { type: 'historyLine', value: 23 } 422 | ]) 423 | }) 424 | }) 425 | 426 | it('cleans last 1000 by default', function () { 427 | var store = createStore({ increment: increment }) 428 | 429 | var promise = Promise.resolve() 430 | 431 | for (var i = 0; i < 1050; i++) { 432 | promise = promise.then(function () { 433 | store.commit({ type: 'increment' }) 434 | }) 435 | } 436 | 437 | return promise.then(function () { 438 | expect(actions(store.log)).toHaveLength(1000) 439 | }) 440 | }) 441 | 442 | it('copies reasons to undo action', function () { 443 | var store = createStore({ increment: increment }) 444 | 445 | return store.commit.crossTab( 446 | { type: 'increment' }, { reasons: ['a', 'b'] } 447 | ).then(function () { 448 | return store.commit.crossTab( 449 | { type: 'logux/undo', id: [1, '10:uuid', 0] }, { reasons: [] }) 450 | }).then(function () { 451 | return store.log.byId([2, '10:uuid', 0]) 452 | }).then(function (result) { 453 | expect(result[0].type).toEqual('logux/undo') 454 | expect(result[1].reasons).toEqual(['a', 'b']) 455 | }) 456 | }) 457 | 458 | it('does not override undo action reasons', function () { 459 | var store = createStore({ increment: increment }) 460 | 461 | return store.commit.crossTab( 462 | { type: 'increment' }, { reasons: ['a', 'b'] } 463 | ).then(function () { 464 | return store.commit.crossTab( 465 | { type: 'logux/undo', id: [1, '10:uuid', 0] }, 466 | { reasons: ['c'] } 467 | ) 468 | }).then(function () { 469 | return store.log.byId([2, '10:uuid', 0]) 470 | }).then(function (result) { 471 | expect(result[0].type).toEqual('logux/undo') 472 | expect(result[1].reasons).toEqual(['c']) 473 | }) 474 | }) 475 | 476 | it('commites local actions', function () { 477 | var store = createStore({ increment: increment }) 478 | 479 | return store.commit.local( 480 | { type: 'increment' }, { reasons: ['test'] } 481 | ).then(function () { 482 | expect(store.log.store.created[0][0]).toEqual({ type: 'increment' }) 483 | expect(store.log.store.created[0][1].tab).toEqual(store.client.id) 484 | expect(store.log.store.created[0][1].reasons).toEqual(['test']) 485 | }) 486 | }) 487 | 488 | it('commites sync actions', function () { 489 | var store = createStore({ increment: increment }) 490 | 491 | return store.commit.sync( 492 | { type: 'increment' }, { reasons: ['test'] } 493 | ).then(function () { 494 | var log = store.log.store.created 495 | 496 | expect(log[0][0]).toEqual({ type: 'increment' }) 497 | expect(log[0][1].sync).toBeTruthy() 498 | expect(log[0][1].reasons).toEqual(['test', 'waitForSync']) 499 | }) 500 | }) 501 | 502 | it('cleans sync action after synchronization', function () { 503 | var pair = new TestPair() 504 | var store = createStore({ increment: increment }, { server: pair.left }) 505 | 506 | store.client.start() 507 | return pair.wait('left').then(function () { 508 | var protocol = store.client.sync.localProtocol 509 | pair.right.send(['connected', protocol, 'server', [0, 0]]) 510 | return store.client.sync.waitFor('synchronized') 511 | }).then(function () { 512 | store.commit.sync({ type: 'increment' }) 513 | return pair.wait('right') 514 | }).then(function () { 515 | expect(actions(store.log)).toEqual([{ type: 'increment' }]) 516 | pair.right.send(['synced', 1]) 517 | return store.client.sync.waitFor('synchronized') 518 | }).then(function () { 519 | return Promise.resolve() 520 | }).then(function () { 521 | expect(actions(store.log)).toEqual([]) 522 | }) 523 | }) 524 | 525 | it('applies old actions from store', function () { 526 | var store1 = createStore({ historyLine: historyLine }) 527 | var store2 528 | 529 | return Promise.all([ 530 | store1.commit.crossTab( 531 | { type: 'historyLine', value: '1' }, 532 | { id: [0, '10:x', 1], reasons: ['test'] } 533 | ), 534 | store1.commit.crossTab( 535 | { type: 'historyLine', value: '2' }, 536 | { id: [0, '10:x', 2], reasons: ['test'] } 537 | ), 538 | store1.commit.crossTab( 539 | { type: 'historyLine', value: '3' }, 540 | { id: [0, '10:x', 3], reasons: ['test'] } 541 | ), 542 | store1.commit.crossTab( 543 | { type: 'historyLine', value: '4' }, 544 | { id: [0, '10:x', 4], reasons: ['test'] } 545 | ), 546 | store1.log.add( 547 | { type: 'historyLine', value: '5' }, 548 | { id: [0, '10:x', 5], reasons: ['test'], tab: store1.client.id } 549 | ), 550 | store1.commit.crossTab( 551 | { type: 'logux/undo', id: [0, '10:x', 2] }, 552 | { id: [0, '10:x', 6], reasons: ['test'] } 553 | ) 554 | ]).then(function () { 555 | store2 = createStore( 556 | { historyLine: historyLine }, { store: store1.log.store }) 557 | 558 | store2.commit({ type: 'historyLine', value: 'a' }) 559 | store2.commit.crossTab( 560 | { type: 'historyLine', value: 'b' }, { reasons: ['test'] } 561 | ) 562 | expect(store2.state.value).toEqual('0a') 563 | 564 | return store2.initialize 565 | }).then(function () { 566 | expect(store2.state.value).toEqual('0134ab') 567 | }) 568 | }) 569 | -------------------------------------------------------------------------------- /test/index.test.js: -------------------------------------------------------------------------------- 1 | var createLoguxStore = require('../create-logux-store') 2 | var index = require('../') 3 | 4 | it('has createLoguxCreator function', function () { 5 | expect(index.createLoguxStore).toBe(createLoguxStore) 6 | }) 7 | -------------------------------------------------------------------------------- /utils/deep-clone.js: -------------------------------------------------------------------------------- 1 | module.exports = function clone (element) { 2 | // "string", number, boolean 3 | if (typeof element !== 'object') { 4 | return element 5 | } 6 | 7 | if (!element) { 8 | return element 9 | } 10 | 11 | if (Array.isArray(element)) { 12 | return element.map(clone) 13 | } 14 | 15 | return Object.keys(element).reduce(function (all, key) { 16 | all[key] = clone(element[key]) 17 | 18 | return all 19 | }, {}) 20 | } 21 | -------------------------------------------------------------------------------- /utils/test/deep-clone.test.js: -------------------------------------------------------------------------------- 1 | var deepClone = require('../deep-clone') 2 | 3 | it('Must be return null', function () { 4 | expect(deepClone(null)).toBe(null) 5 | }) 6 | 7 | it('Must be return string', function () { 8 | expect(deepClone('str')).toBe('str') 9 | }) 10 | 11 | it('Must be deep clone array', function () { 12 | var a = [3, 4] 13 | var b = deepClone(a) 14 | 15 | a[0] = 5 16 | 17 | expect(a).toEqual([5, 4]) 18 | expect(b).toEqual([3, 4]) 19 | }) 20 | 21 | it('Must be deep clone object', function () { 22 | var a = { key: 4 } 23 | var b = deepClone(a) 24 | 25 | a.key = 5 26 | 27 | expect(a).toEqual({ key: 5 }) 28 | expect(b).toEqual({ key: 4 }) 29 | }) 30 | --------------------------------------------------------------------------------