├── .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 | [](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 |
2 |
3 |
Logux-vuex example
4 |
5 |
Counter: {{ count }}
6 |
7 |
8 |
9 |
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 |
--------------------------------------------------------------------------------