├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── ddp-connector ├── .babelrc ├── .eslintrc.json ├── .gitignore ├── .npmignore ├── package.json └── src │ ├── connectDDP.js │ ├── createConnector.js │ ├── debounceProps.js │ ├── index.js │ └── wrapSelector.js ├── ddp-redux ├── .babelrc ├── .eslintrc.json ├── .gitignore ├── .npmignore ├── README.md ├── package.json └── src │ ├── DDPClient.js │ ├── DDPClient.test.js │ ├── DDPEmitter.js │ ├── DDPEmitter.test.js │ ├── DDPError.js │ ├── DDPSocket.js │ ├── DDPSocket.test.js │ ├── actions.js │ ├── constants.js │ ├── ejson │ ├── custom_models_for_tests.js │ ├── ejson.js │ ├── ejson.test.js │ ├── index.js │ ├── newBinary.js │ ├── stringify.js │ └── tinytest.js │ ├── index.js │ ├── modules │ ├── collections │ │ ├── index.js │ │ ├── middleware.js │ │ ├── middleware.test.js │ │ ├── reducer.js │ │ ├── reducer.test.js │ │ ├── selectors.js │ │ ├── selectors.test.js │ │ └── testCommon.js │ ├── connection │ │ ├── index.js │ │ ├── middleware.js │ │ ├── middleware.test.js │ │ ├── reducer.js │ │ ├── reducer.test.js │ │ ├── selectors.js │ │ └── testCommon.js │ ├── currentUser │ │ ├── index.js │ │ ├── middleware.js │ │ ├── middleware.test.js │ │ ├── reducer.js │ │ ├── reducer.test.js │ │ ├── selectors.js │ │ └── testCommon.js │ ├── messages │ │ ├── index.js │ │ ├── middleware.js │ │ ├── middleware.test.js │ │ ├── reducer.js │ │ ├── reducer.test.js │ │ ├── selectors.js │ │ └── testCommon.js │ ├── methods │ │ ├── helpers.js │ │ ├── index.js │ │ ├── middleware.js │ │ ├── middleware.test.js │ │ ├── reducer.js │ │ ├── reducer.test.js │ │ ├── selectors.js │ │ └── testCommon.js │ ├── queries │ │ ├── index.js │ │ ├── middleware.js │ │ ├── middleware.test.js │ │ ├── reducer.js │ │ ├── reducer.test.js │ │ ├── selectors.js │ │ └── testCommon.js │ ├── queries2 │ │ ├── index.js │ │ ├── middleware.js │ │ ├── middleware.test.js │ │ ├── reducer.js │ │ ├── reducer.test.js │ │ ├── selectors.js │ │ └── testCommon.js │ ├── queues │ │ ├── constants.js │ │ ├── index.js │ │ ├── middleware.js │ │ ├── middleware.test.js │ │ ├── reducer.js │ │ ├── reducer.test.js │ │ └── selectors.js │ ├── resources │ │ ├── index.js │ │ ├── middleware.js │ │ ├── middleware.test.js │ │ ├── reducer.js │ │ ├── reducer.test.js │ │ └── selectors.js │ ├── subscriptions │ │ ├── index.js │ │ ├── middleware.js │ │ ├── middleware.test.js │ │ ├── reducer.js │ │ ├── reducer.test.js │ │ ├── selectors.js │ │ └── testCommon.js │ ├── thunk.js │ └── wrapWithPromise │ │ ├── index.js │ │ ├── middleware.js │ │ ├── middleware.test.js │ │ └── testCommon.js │ ├── shim.js │ └── utils │ ├── Storage.js │ ├── carefullyMapValues.js │ ├── carefullyMapValues.test.js │ ├── createDelayedTask.js │ ├── createInsertEntities.js │ ├── createRemoveEntities.js │ ├── createValuesMappingSelector.js │ ├── createValuesMappingSelector.test.js │ ├── memoizeValuesMapping.js │ ├── memoizeValuesMapping.test.js │ ├── sha256.js │ └── stableMap.js └── example ├── .tmux.conf ├── backend ├── .eslintrc.js ├── .gitignore ├── .meteor │ ├── .finished-upgraders │ ├── .gitignore │ ├── .id │ ├── packages │ ├── platforms │ ├── release │ └── versions ├── imports │ ├── api │ │ ├── TodoLists.js │ │ ├── Todos.js │ │ ├── implement.js │ │ ├── index.js │ │ └── publish.js │ ├── collections │ │ ├── TodoLists.js │ │ └── Todos.js │ ├── common │ └── schema │ │ ├── CreatedUpdatedSchema.js │ │ └── autoValue.js ├── package-lock.json ├── package.json └── server │ └── main.js ├── start-develop.sh ├── start.sh └── todo-web ├── .env ├── .eslintrc.json ├── .gitignore ├── package-lock.json ├── package.json ├── public ├── favicon.ico ├── index.html └── manifest.json └── src ├── common ├── .gitignore ├── api │ ├── TodoLists.js │ └── Todos.js ├── models │ ├── BaseModel.js │ ├── Todo.js │ └── TodoList.js └── utils │ ├── ApiSpec.js │ ├── Schema.js │ └── errors.js ├── components ├── Loader.js └── NotFound.js ├── containers ├── App.js ├── App.test.js ├── Entry.js ├── List.js ├── Lists.js └── LoggedInRoute.js ├── index.css ├── index.js ├── registerServiceWorker.js ├── routes └── Router.jsx ├── storage.js └── store └── rootReducer.js /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | install: 3 | - (cd ddp-redux && npm install) 4 | script: 5 | - (cd ddp-redux && npm run test && npm run test-lib) 6 | node_js: 7 | - "4" 8 | - "6" 9 | - "8" -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Tomasz Lenarcik 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 | # ddp 2 | 3 | [![Build Status][travis-svg]][travis-url] 4 | 5 | `ddp-redux` is a brand new ddp client that will allow you to fetch resources from your ddp server 6 | in a completely declarative way. Subscriptions/ methods (queries) parameters are evaluated by 7 | selectors and all you need to do is to extract collection data from the redux store. 8 | This approach was highly inspired by [apollo-client][apollo-client-url], but the DDP protocol 9 | is the first class citizen in our case. 10 | 11 | An example is worth a thousand words 12 | ```javascript 13 | import ddp from 'ddp-connector'; 14 | 15 | const TodoList = ddp({ 16 | subscriptions: (state, props) => [{ 17 | name: 'api.collections.TodoLists.details', 18 | params: [{ 19 | listId: props.listId, 20 | }], 21 | }, { 22 | name: 'api.collections.Todos.all', 23 | params: [{ 24 | listId: props.listId, 25 | }], 26 | }], 27 | selectors: ({ from, prop }) => ({ 28 | todoList: from('TodoLists').select.one('listId'), 29 | todos: from('Todos').select.where( 30 | createSelector( 31 | prop('listId'), 32 | listId => todo => todo.listId === listId, 33 | ), 34 | ), 35 | }), 36 | })(({ todoList, todos }) => ( 37 |
38 |

{todoList.name}

39 | 42 |
43 | )) 44 | ``` 45 | Most typical DDP "commands" are accessible by simply dispatching a redux action, e.g. 46 | ```javascript 47 | import { callMethod } from 'ddp-redux'; 48 | 49 | // ... 50 | 51 | store.dispatch( 52 | callMethod( 53 | 'myTestMethod', 54 | param1, 55 | param2, 56 | param3, 57 | )) 58 | .then(/* ... */) 59 | .catch(/* ... */) 60 | ); 61 | ``` 62 | 63 | ## Disclaimer 64 | 65 | The project is still in a pretty early stage and it does not have proper documentation yet, 66 | as some parts of the api are very likely to change, e.g. documents selectors. 67 | 68 | ## Running example app 69 | 70 | Assuming yoy have `tmux@2.x` installed you only need to run 71 | ``` 72 | cd example 73 | ./start.sh 74 | ``` 75 | If you want to play with the code and need to ensure that the libraries 76 | are rebuilding automatically use 77 | ``` 78 | ./start-develop.sh 79 | ``` 80 | 81 | ## Installation 82 | 83 | ``` 84 | npm install --save ddp-redux ddp-connector 85 | ``` 86 | If you don't have them already please install the following peer dependencies 87 | ``` 88 | npm install --save react redux react-redux 89 | ``` 90 | 91 | ## Minimal configuration 92 | 93 | ```javascript 94 | import { createStore, combineReducers, applyMiddleware } from 'redux'; 95 | import React from 'react'; 96 | import ReactDOM from 'react-dom'; 97 | import { Provider } from 'react-redux'; 98 | import DDPClient from 'ddp-redux'; 99 | 100 | const ddpClient = new DDPClient({ 101 | endpoint: 'ws://localhost:3000/websocket', 102 | SocketConstructor: WebSocket, 103 | }); 104 | 105 | const reducer = combineReducers({ 106 | ddp: DDPClient.reducer(), 107 | }); 108 | 109 | const store = createStore( 110 | reducer, 111 | {}, 112 | applyMiddleware( 113 | ddpClient.middleware(), 114 | ), 115 | ); 116 | 117 | ReactDOM.render( 118 | 119 | // ... 120 | 121 | document.getElementById('root'), 122 | ); 123 | ``` 124 | 125 | ## Features 126 | 127 | - [x] Supports Meteor standard authentication methods 128 | - [x] Meteor subscriptions 129 | - [x] Fetch data via meteor methods 130 | - [x] Aggregate data from multiple ddp connections 131 | - [x] Basic support for optimistic updates 132 | - [ ] Sync local ids generator with server 133 | - [ ] Out-of-the-box support for client-side joins 134 | - [ ] Offline first 135 | 136 | [travis-svg]: https://travis-ci.org/apendua/ddp-redux.svg?branch=develop 137 | [travis-url]: https://travis-ci.org/apendua/ddp-redux 138 | [apollo-client-url]: https://github.com/apollographql/apollo-client -------------------------------------------------------------------------------- /ddp-connector/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "stage-3", "flow"], 3 | "plugins": ["syntax-trailing-function-commas"] 4 | } -------------------------------------------------------------------------------- /ddp-connector/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb", 3 | "rules": { 4 | "import/prefer-default-export": "off" 5 | }, 6 | "parser": "babel-eslint", 7 | "parserOptions": { 8 | "ecmaVersion": 7, 9 | "sourceType": "module", 10 | "ecmaFeatures": { 11 | "experimentalObjectRestSpread": true 12 | } 13 | }, 14 | "env": { 15 | "es6": true, 16 | "browser": true, 17 | "node": true 18 | }, 19 | "plugins": [], 20 | "settings": {} 21 | } -------------------------------------------------------------------------------- /ddp-connector/.gitignore: -------------------------------------------------------------------------------- 1 | /lib 2 | /node_modules 3 | /package-lock.json 4 | /npm-debug.log -------------------------------------------------------------------------------- /ddp-connector/.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | -------------------------------------------------------------------------------- /ddp-connector/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ddp-connector", 3 | "version": "0.5.0", 4 | "description": "Connect ddp-redux client to react components", 5 | "main": "lib/index.js", 6 | "scripts": { 7 | "test": "jest", 8 | "test-watch": "jest --watch", 9 | "coverage": "jest --coverage", 10 | "build": "babel src/ -d lib/", 11 | "build-watch": "watch 'npm run build' ./src", 12 | "prepublish": "npm run build" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/apendua/ddp.git" 17 | }, 18 | "keywords": [ 19 | "meteor", 20 | "ddp", 21 | "redux", 22 | "react" 23 | ], 24 | "author": "apendua", 25 | "license": "MIT", 26 | "bugs": { 27 | "url": "https://github.com/apendua/ddp/issues" 28 | }, 29 | "homepage": "https://github.com/apendua/ddp#readme", 30 | "dependencies": { 31 | "lodash": "^4.17.4", 32 | "prop-types": "^15.6.0", 33 | "recompose": "^0.26.0", 34 | "reselect": "^3.0.1" 35 | }, 36 | "peerDependencies": { 37 | "ddp-redux": "^0.0.4", 38 | "react": "^15.0.0-0 || ^16.0.0-0", 39 | "react-redux": "^5.0.6" 40 | }, 41 | "devDependencies": { 42 | "babel-cli": "^6.24.1", 43 | "babel-core": "^6.25.0", 44 | "babel-eslint": "^7.2.3", 45 | "babel-plugin-syntax-trailing-function-commas": "^6.22.0", 46 | "babel-preset-es2015": "^6.24.1", 47 | "babel-preset-flow": "^6.23.0", 48 | "babel-preset-stage-0": "^6.24.1", 49 | "babel-preset-stage-3": "^6.24.1", 50 | "eslint": "^4.3.0", 51 | "eslint-config-airbnb": "^15.1.0", 52 | "eslint-import-resolver-meteor": "^0.4.0", 53 | "eslint-plugin-import": "^2.7.0", 54 | "eslint-plugin-jsx-a11y": "^5.1.1", 55 | "eslint-plugin-react": "^7.1.0", 56 | "jest": "^23.1.0", 57 | "watch": "^1.0.2" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /ddp-connector/src/debounceProps.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import forEach from 'lodash/forEach'; 3 | import { 4 | wrapDisplayName, 5 | setDisplayName, 6 | } from 'recompose'; 7 | 8 | /** 9 | * @param {String} name - name of the property to debounce 10 | * @param {Number} ms - number of ms to ms the property update 11 | * @returns {HigherOrderComponent} 12 | */ 13 | const debounceProps = (names, { 14 | ms = 200, 15 | } = {}) => { 16 | const hoc = (BaseComponent) => { 17 | /** 18 | * A component that adds "autosave" function to properties. That function can be called with new changes object 19 | * and the actual call to "onAutosave" will be postponed by the number of milliseconds given by autosaveDelay. 20 | */ 21 | class Container extends React.Component { 22 | constructor(props) { 23 | super(props); 24 | const initialState = {}; 25 | forEach(names, (name) => { 26 | initialState[name] = props[name]; 27 | }); 28 | this.state = initialState; 29 | this.timeout = null; 30 | } 31 | 32 | /** 33 | * Schedule updating component. 34 | * @private 35 | */ 36 | componentWillReceiveProps(nextProps) { 37 | if (this.timeout) { 38 | clearTimeout(this.timeout); 39 | } 40 | this.timeout = setTimeout(this.update.bind(this, nextProps), ms); 41 | } 42 | 43 | /** 44 | * Cancel pending timeout. 45 | * @private 46 | */ 47 | componentWillUnmount() { 48 | if (this.timeout) { 49 | clearTimeout(this.timeout); 50 | } 51 | this.timeout = null; 52 | } 53 | 54 | update(props) { 55 | const newState = {}; 56 | forEach(names, (name) => { 57 | newState[name] = props[name]; 58 | }); 59 | this.setState(newState); 60 | this.timeout = null; 61 | } 62 | 63 | render() { 64 | const props = { 65 | ...this.props, 66 | }; 67 | forEach(names, (name) => { 68 | props[name] = this.state[name]; 69 | }); 70 | return React.createElement(BaseComponent, props); 71 | } 72 | } 73 | return Container; 74 | }; 75 | if (process.env.NODE_ENV !== 'production') { 76 | return BaseComponent => setDisplayName(wrapDisplayName(BaseComponent, 'debounceProps'))(hoc(BaseComponent)); 77 | } 78 | return hoc; 79 | }; 80 | 81 | export default debounceProps; 82 | -------------------------------------------------------------------------------- /ddp-connector/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import mapValues from 'lodash/mapValues'; 3 | import isArray from 'lodash/isArray'; 4 | import createConnector from './createConnector'; 5 | 6 | const identity = x => x; 7 | 8 | const ddp = createConnector({ 9 | defaultMapQueries: ({ 10 | queriesOptions, 11 | }) => (queries) => { 12 | if (isArray(queries)) { 13 | return {}; 14 | } 15 | return mapValues(queries, (query, key) => { 16 | if (!query) { 17 | return query; 18 | } 19 | const options = queriesOptions && queriesOptions[key]; 20 | const mapResult = (options && options.mapResult) || identity; 21 | return mapResult(query.result); 22 | }); 23 | }, 24 | defaultLoader: () => React.createElement('span', {}, 'Loading ...'), 25 | defaultDebounceReady: 100, 26 | }); 27 | 28 | export { 29 | ddp, 30 | createConnector, 31 | }; 32 | 33 | export default ddp; 34 | -------------------------------------------------------------------------------- /ddp-connector/src/wrapSelector.js: -------------------------------------------------------------------------------- 1 | import { 2 | createSelectorCreator, 3 | defaultMemoize, 4 | } from 'reselect'; 5 | import { EJSON } from 'ddp-redux'; 6 | 7 | const identity = x => x; 8 | const constant = x => () => x; 9 | 10 | const createSelector = createSelectorCreator( 11 | defaultMemoize, 12 | EJSON.equals, 13 | ); 14 | 15 | /** 16 | * Create a function that compares the computed value with the previous one 17 | * and if they're deeply equal, it returns the previous result. 18 | */ 19 | const memoize = x => createSelector(x, identity); 20 | 21 | const wrapSelector = (selector) => { 22 | let compiled; 23 | // Make sure that the function is only called with the arguments it needs. 24 | // This is important when the function uses arguments based caching 25 | // to optimize it's computations. 26 | const compile = (func) => { 27 | // if (func.length >= 2) { 28 | // return memoize(func); 29 | // } else if (func.length === 1) { 30 | // return memoize(state => func(state)); 31 | // } 32 | // console.log('no arguments at all', func.toString()); 33 | // return memoize(() => func()); 34 | return memoize(func); 35 | }; 36 | return (state, ownProps) => { 37 | if (compiled) { 38 | return compiled(state, ownProps); 39 | } 40 | if (typeof selector !== 'function') { 41 | compiled = constant(selector); 42 | return compiled(); 43 | } 44 | const props = selector(state, ownProps); 45 | if (typeof props === 'function') { 46 | compiled = compile(props); 47 | return compiled(state, ownProps); 48 | } 49 | compiled = compile(selector); 50 | return props; 51 | }; 52 | }; 53 | 54 | export default wrapSelector; 55 | -------------------------------------------------------------------------------- /ddp-redux/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "stage-3", "flow"], 3 | "plugins": ["syntax-trailing-function-commas"] 4 | } -------------------------------------------------------------------------------- /ddp-redux/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb", 3 | "rules": { 4 | "import/prefer-default-export": "off", 5 | "import/extensions": "off", 6 | "max-len": ["error", 140] 7 | }, 8 | "parser": "babel-eslint", 9 | "parserOptions": { 10 | "ecmaVersion": 7, 11 | "sourceType": "module", 12 | "ecmaFeatures": { 13 | "experimentalObjectRestSpread": true 14 | } 15 | }, 16 | "env": { 17 | "es6": true, 18 | "browser": true, 19 | "node": true 20 | }, 21 | "plugins": [], 22 | "settings": {} 23 | } 24 | -------------------------------------------------------------------------------- /ddp-redux/.gitignore: -------------------------------------------------------------------------------- 1 | /lib 2 | /node_modules 3 | /package-lock.json 4 | /npm-debug.log 5 | /coverage 6 | -------------------------------------------------------------------------------- /ddp-redux/.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | -------------------------------------------------------------------------------- /ddp-redux/README.md: -------------------------------------------------------------------------------- 1 | # ddp-redux 2 | -------------------------------------------------------------------------------- /ddp-redux/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ddp-redux", 3 | "version": "0.5.0", 4 | "description": "A redux based DDP client", 5 | "main": "lib/index.js", 6 | "scripts": { 7 | "test": "jest", 8 | "test-watch": "jest --watch", 9 | "coverage": "jest --coverage", 10 | "build": "babel src/ -d lib/", 11 | "build-watch": "watch 'npm run build' ./src", 12 | "lint": "eslint src/", 13 | "prepublish": "npm run build" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/apendua/ddp.git" 18 | }, 19 | "keywords": [ 20 | "meteor", 21 | "ddp", 22 | "redux", 23 | "websocket" 24 | ], 25 | "author": "apendua", 26 | "license": "MIT", 27 | "bugs": { 28 | "url": "https://github.com/apendua/ddp/issues" 29 | }, 30 | "homepage": "https://github.com/apendua/ddp#readme", 31 | "devDependencies": { 32 | "babel-cli": "^6.26.0", 33 | "babel-core": "^6.26.3", 34 | "babel-eslint": "^7.2.3", 35 | "babel-plugin-syntax-trailing-function-commas": "^6.22.0", 36 | "babel-preset-es2015": "^6.24.1", 37 | "babel-preset-flow": "^6.23.0", 38 | "babel-preset-stage-0": "^6.24.1", 39 | "babel-preset-stage-3": "^6.24.1", 40 | "chai": "^4.1.2", 41 | "eslint": "^4.19.1", 42 | "eslint-config-airbnb": "^16.1.0", 43 | "eslint-import-resolver-meteor": "^0.4.0", 44 | "eslint-plugin-import": "^2.12.0", 45 | "eslint-plugin-jsx-a11y": "^6.0.3", 46 | "eslint-plugin-react": "^7.9.1", 47 | "jest": "^23.1.0", 48 | "redux": "^3.7.2", 49 | "redux-mock-store": "^1.2.3", 50 | "watch": "^1.0.2" 51 | }, 52 | "dependencies": { 53 | "base64-js": "^1.2.1", 54 | "lodash": "^4.17.10", 55 | "regexp.prototype.flags": "^1.2.0", 56 | "reselect": "^3.0.1", 57 | "shallowequal": "^1.0.2" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /ddp-redux/src/DDPClient.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | 3 | import configureStore from 'redux-mock-store'; 4 | import DDPClient from './DDPClient'; 5 | 6 | describe('Test DDPClient', () => { 7 | let testContext; 8 | 9 | beforeEach(() => { 10 | testContext = {}; 11 | }); 12 | 13 | beforeEach(() => { 14 | testContext.ddpClient = new DDPClient(); 15 | }); 16 | 17 | describe('Given I have a ddp middleware', () => { 18 | beforeEach(() => { 19 | testContext.middleware = testContext.ddpClient.middleware(); 20 | testContext.mockStore = configureStore([ 21 | testContext.middleware, 22 | ]); 23 | }); 24 | 25 | test('should accept function as an action', () => { 26 | const store = testContext.mockStore(); 27 | store.dispatch((dispatch) => { 28 | dispatch({ 29 | type: 'test_action', 30 | }); 31 | }); 32 | expect(store.getActions()).toEqual(expect.arrayContaining([ 33 | { type: 'test_action' }, 34 | ])); 35 | }); 36 | }); 37 | 38 | test('should be ok', () => { 39 | expect(testContext.ddpClient).toBeTruthy(); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /ddp-redux/src/DDPEmitter.js: -------------------------------------------------------------------------------- 1 | import forEach from 'lodash/forEach'; 2 | 3 | const listeners = new WeakMap(); 4 | 5 | class DDPEmitter { 6 | constructor() { 7 | listeners.set(this, {}); 8 | } 9 | 10 | on(name, callback) { 11 | const thisListeners = listeners.get(this); 12 | if (!thisListeners[name]) { 13 | thisListeners[name] = []; 14 | } 15 | thisListeners[name].push(callback); 16 | } 17 | 18 | emit(name, ...args) { 19 | forEach(listeners.get(this)[name], callback => callback(...args)); 20 | } 21 | } 22 | 23 | export default DDPEmitter; 24 | -------------------------------------------------------------------------------- /ddp-redux/src/DDPEmitter.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | 3 | import DDPEmitter from './DDPEmitter'; 4 | 5 | describe('Test DDPEmitter', () => { 6 | let testContext; 7 | 8 | beforeEach(() => { 9 | testContext = {}; 10 | }); 11 | 12 | beforeEach(() => { 13 | testContext.emitter = new DDPEmitter(); 14 | testContext.m1 = jest.fn(); 15 | testContext.m2 = jest.fn(); 16 | testContext.m3 = jest.fn(); 17 | testContext.emitter.on('m1', testContext.m1); 18 | testContext.emitter.on('m2', testContext.m2); 19 | testContext.emitter.on('m2', testContext.m3); 20 | }); 21 | 22 | test('should not do anyghing there are no listeners', () => { 23 | testContext.emitter.emit('mx'); 24 | expect(testContext.m1).not.toBeCalled(); 25 | expect(testContext.m2).not.toBeCalled(); 26 | expect(testContext.m3).not.toBeCalled(); 27 | }); 28 | 29 | test('should trigger one listener', () => { 30 | testContext.emitter.emit('m1'); 31 | expect(testContext.m1).toBeCalled(); 32 | expect(testContext.m2).not.toBeCalled(); 33 | expect(testContext.m3).not.toBeCalled(); 34 | }); 35 | 36 | test('should trigger two listeners', () => { 37 | testContext.emitter.emit('m2'); 38 | expect(testContext.m1).not.toBeCalled(); 39 | expect(testContext.m2).toBeCalled(); 40 | expect(testContext.m3).toBeCalled(); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /ddp-redux/src/DDPError.js: -------------------------------------------------------------------------------- 1 | // Based on Meteor's EJSON implementation: https://github.com/meteor/meteor 2 | // 3 | // packages/meteor/errors.js 4 | // 2017-08-10 5 | // 6 | // The MIT License (MIT) 7 | // 8 | // Copyright (c) 2011 - 2017 Meteor Development Group, Inc. 9 | // 10 | // Permission is hereby granted, free of charge, to any person obtaining a copy 11 | // of this software and associated documentation files (the "Software"), to deal 12 | // in the Software without restriction, including without limitation the rights 13 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | // copies of the Software, and to permit persons to whom the Software is 15 | // furnished to do so, subject to the following conditions: 16 | // 17 | // The above copyright notice and this permission notice shall be included in all 18 | // copies or substantial portions of the Software. 19 | // 20 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 26 | // SOFTWARE. 27 | 28 | class DDPError extends Error { 29 | constructor(error, reason, details) { 30 | super(); 31 | 32 | // Ensure we get a proper stack trace in most Javascript environments 33 | if (Error.captureStackTrace) { 34 | // V8 environments (Chrome and Node.js) 35 | Error.captureStackTrace(this, DDPError); 36 | } else { 37 | // Borrow the .stack property of a native Error object. 38 | this.stack = new Error().stack; 39 | } 40 | // Safari magically works. 41 | 42 | // Newer versions of DDP use this property to signify that an error 43 | // can be sent back and reconstructed on the calling client. 44 | this.isClientSafe = true; 45 | 46 | // String code uniquely identifying this kind of error. 47 | this.error = error; 48 | 49 | // Optional: A short human-readable summary of the error. Not 50 | // intended to be shown to end users, just developers. ("Not Found", 51 | // "Internal Server Error") 52 | this.reason = reason; 53 | 54 | // Optional: Additional information about the error, say for 55 | // debugging. It might be a (textual) stack trace if the server is 56 | // willing to provide one. The corresponding thing in HTTP would be 57 | // the body of a 404 or 500 response. (The difference is that we 58 | // never expect this to be shown to end users, only developers, so 59 | // it doesn't need to be pretty.) 60 | this.details = details; 61 | 62 | // This is what gets displayed at the top of a stack trace. Current 63 | // format is "[404]" (if no reason is set) or "File not found [404]" 64 | if (this.reason) { 65 | this.message = `${this.reason} [${this.error}]`; 66 | } else { 67 | this.message = `[${this.error}]`; 68 | } 69 | 70 | this.errorType = 'Meteor.Error'; 71 | } 72 | 73 | // Meteor.Error is basically data and is sent over DDP, so you should be able to 74 | // properly EJSON-clone it. This is especially important because if a 75 | // Meteor.Error is thrown through a Future, the error, reason, and details 76 | // properties become non-enumerable so a standard Object clone won't preserve 77 | // them and they will be lost from DDP. 78 | clone() { 79 | return new DDPError(this.error, this.reason, this.details); 80 | } 81 | } 82 | 83 | DDPError.ERROR_CONNECTION = 'ErrorConnection'; 84 | DDPError.ERROR_BAD_MESSAGE = 'ErrorBadMessage'; 85 | 86 | export default DDPError; 87 | -------------------------------------------------------------------------------- /ddp-redux/src/DDPSocket.js: -------------------------------------------------------------------------------- 1 | import EJSON from './ejson'; 2 | import DDPEmitter from './DDPEmitter'; 3 | 4 | class DDPScoket extends DDPEmitter { 5 | constructor({ 6 | SocketConstructor, 7 | }) { 8 | super(); 9 | 10 | this.SocketConstructor = SocketConstructor; 11 | this.rawSocket = null; 12 | } 13 | 14 | send(obj) { 15 | // console.warn('ddp:out', obj); 16 | this.rawSocket.send(EJSON.stringify(obj)); 17 | } 18 | 19 | open(endpoint) { 20 | if (this.rawSocket) { 21 | throw new Error('Socket already opened'); 22 | } 23 | this.rawSocket = new this.SocketConstructor(endpoint); 24 | 25 | this.rawSocket.onopen = () => { 26 | this.emit('open'); 27 | }; 28 | 29 | this.rawSocket.onclose = () => { 30 | this.rawSocket = null; 31 | this.emit('close'); 32 | }; 33 | 34 | this.rawSocket.onerror = () => { 35 | // NOTE: Overwrite the "onclose" hook to prevent emitting "close" event twice. 36 | // Please note that "delete this.rawSocket.onclose" does not work in this case. 37 | this.rawSocket.onclose = null; 38 | this.rawSocket.close(); 39 | this.rawSocket = null; 40 | this.emit('close'); 41 | }; 42 | 43 | this.rawSocket.onmessage = (message) => { 44 | let obj; 45 | try { 46 | obj = EJSON.parse(message.data); 47 | } catch (err) { 48 | // ignore for now 49 | } 50 | // console.warn('ddp:in', obj); 51 | this.emit('message', obj); 52 | }; 53 | } 54 | 55 | close() { 56 | if (this.rawSocket) { 57 | this.rawSocket.close(); 58 | this.rawSocket = null; 59 | } 60 | } 61 | } 62 | 63 | export default DDPScoket; 64 | -------------------------------------------------------------------------------- /ddp-redux/src/DDPSocket.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | 3 | import DDPSocket from './DDPSocket'; 4 | 5 | class Socket { 6 | constructor(endpoint) { 7 | this.messages = []; 8 | this.endpoint = endpoint; 9 | } 10 | 11 | close() { 12 | if (this.onclose) { 13 | this.onclose(); 14 | } 15 | } 16 | 17 | send(message) { 18 | this.messages.push(message); 19 | } 20 | 21 | triggerMessage(message) { 22 | if (this.onmessage) { 23 | this.onmessage({ data: message }); 24 | } 25 | } 26 | 27 | triggerOpen() { 28 | if (this.onopen) { 29 | this.onopen(); 30 | } 31 | } 32 | } 33 | 34 | describe('Test DDPSocket', () => { 35 | let testContext; 36 | 37 | beforeEach(() => { 38 | testContext = {}; 39 | }); 40 | 41 | beforeEach(() => { 42 | testContext.socket = new DDPSocket({ 43 | endpoint: 'ws://example.com', 44 | SocketConstructor: Socket, 45 | }); 46 | testContext.onMessage = jest.fn(); 47 | testContext.onClose = jest.fn(); 48 | testContext.onOpen = jest.fn(); 49 | testContext.socket.on('message', testContext.onMessage); 50 | testContext.socket.on('close', testContext.onClose); 51 | testContext.socket.on('open', testContext.onOpen); 52 | testContext.socket.open('ws://example.com'); 53 | }); 54 | 55 | test('should trigger onOpen callback', () => { 56 | testContext.socket.rawSocket.triggerOpen(); 57 | expect(testContext.onOpen).toBeCalled(); 58 | }); 59 | 60 | test('should trigger onClose callback', () => { 61 | testContext.socket.close(); 62 | expect(testContext.onClose).toBeCalled(); 63 | }); 64 | 65 | test('should connect to the right endpoint', () => { 66 | expect(testContext.socket.rawSocket.endpoint).toBe('ws://example.com'); 67 | }); 68 | 69 | test('should send a stringified DDP message', () => { 70 | testContext.socket.send({ 71 | msg: 'connect', 72 | }); 73 | expect(testContext.socket.rawSocket.messages).toEqual(expect.arrayContaining([ 74 | '{"msg":"connect"}', 75 | ])); 76 | }); 77 | 78 | test('should receive a parsed DDP message', () => { 79 | testContext.socket.rawSocket.triggerMessage('{"msg":"ping"}'); 80 | expect(testContext.onMessage).toBeCalledWith({ msg: 'ping' }); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /ddp-redux/src/actions.js: -------------------------------------------------------------------------------- 1 | import forEach from 'lodash/forEach'; 2 | import { 3 | DDP_OPEN, 4 | DDP_CLOSE, 5 | DDP_METHOD, 6 | DDP_SUBSCRIBE, 7 | DDP_UNSUBSCRIBE, 8 | DDP_QUERY_REQUEST, 9 | DDP_QUERY_RELEASE, 10 | DDP_QUERY_REFETCH, 11 | DDP_QUERY_UPDATE, 12 | 13 | DDP_RESOURCE_FETCH, 14 | DDP_RESOURCE_CREATE, 15 | DDP_RESOURCE_DELETE, 16 | DDP_RESOURCE_REFETCH, 17 | DDP_RESOURCE_DEPRECATE, 18 | DDP_RESOURCE_UPDATE, 19 | 20 | DDP_LOGIN, 21 | DDP_LOGOUT, 22 | } from './constants'; 23 | import sha256 from './utils/sha256'; 24 | 25 | const hashPassword = password => ({ 26 | digest: sha256(password), 27 | algorithm: 'sha-256', 28 | }); 29 | 30 | export const openSocket = (endpoint, params, meta) => ({ 31 | type: DDP_OPEN, 32 | payload: { 33 | endpoint, 34 | params, 35 | }, 36 | ...meta && { meta }, 37 | }); 38 | 39 | export const closeSocket = (socketId, meta) => ({ 40 | type: DDP_CLOSE, 41 | meta: { 42 | ...meta, 43 | socketId, 44 | }, 45 | }); 46 | 47 | export const callMethod = (name, params, meta) => ({ 48 | type: DDP_METHOD, 49 | payload: { 50 | params, 51 | method: name, 52 | }, 53 | ...meta && { meta }, 54 | }); 55 | 56 | export const subscribe = (name, params, meta) => ({ 57 | type: DDP_SUBSCRIBE, 58 | payload: { 59 | name, 60 | params, 61 | }, 62 | ...meta && { meta }, 63 | }); 64 | 65 | export const unsubscribe = (subId, meta) => ({ 66 | type: DDP_UNSUBSCRIBE, 67 | meta: { 68 | ...meta, 69 | subId, 70 | }, 71 | }); 72 | 73 | export const queryRequest = (name, params, properties) => ({ 74 | type: DDP_QUERY_REQUEST, 75 | payload: { 76 | name, 77 | params, 78 | properties, 79 | }, 80 | }); 81 | 82 | export const queryRelease = (queryId, meta) => ({ 83 | type: DDP_QUERY_RELEASE, 84 | meta: { 85 | ...meta, 86 | queryId, 87 | }, 88 | }); 89 | 90 | 91 | export const queryRefetch = (queryId, meta) => ({ 92 | type: DDP_QUERY_REFETCH, 93 | meta: { 94 | ...meta, 95 | queryId, 96 | }, 97 | }); 98 | 99 | export const queryUpdate = (queryId, payload) => ({ 100 | type: DDP_QUERY_UPDATE, 101 | payload, 102 | meta: { 103 | queryId, 104 | }, 105 | }); 106 | 107 | // NOTE: Do not use this method in general! 108 | export const queryRefetchAll = () => (dispatch, getState) => { 109 | const state = getState(); 110 | forEach(state.ddp.queries, (qyery, queryId) => { 111 | dispatch(queryRefetch(queryId)); 112 | }); 113 | }; 114 | 115 | // RESOURCES 116 | 117 | export const fetchResource = (resourceId, payload) => ({ 118 | type: DDP_RESOURCE_FETCH, 119 | payload, 120 | meta: { 121 | resourceId, 122 | }, 123 | }); 124 | 125 | export const refetchResource = (resourceId, payload) => ({ 126 | type: DDP_RESOURCE_REFETCH, 127 | payload, 128 | meta: { 129 | resourceId, 130 | }, 131 | }); 132 | 133 | export const createResource = (name, params, properties) => ({ 134 | type: DDP_RESOURCE_CREATE, 135 | payload: { 136 | name, 137 | params, 138 | properties, 139 | }, 140 | }); 141 | 142 | export const deleteResource = resourceId => ({ 143 | type: DDP_RESOURCE_DELETE, 144 | meta: { 145 | resourceId, 146 | }, 147 | }); 148 | 149 | export const updateResource = (resourceId, payload) => ({ 150 | type: DDP_RESOURCE_UPDATE, 151 | payload, 152 | meta: { 153 | resourceId, 154 | }, 155 | }); 156 | 157 | export const deprecateResource = resourceId => ({ 158 | type: DDP_RESOURCE_DEPRECATE, 159 | meta: { 160 | resourceId, 161 | }, 162 | }); 163 | 164 | export const login = (params, meta) => ({ 165 | type: DDP_LOGIN, 166 | payload: [params], 167 | ...meta && { meta }, 168 | }); 169 | 170 | export const logout = meta => ({ 171 | type: DDP_LOGOUT, 172 | ...meta && { meta }, 173 | }); 174 | 175 | export const loginWithPassword = ({ 176 | username, 177 | email, 178 | password, 179 | }, meta) => login({ 180 | user: { 181 | username, 182 | email, 183 | }, 184 | password: hashPassword(password), 185 | }, meta); 186 | 187 | export const createUser = ({ 188 | password, 189 | ...rest 190 | }, meta) => ({ 191 | type: DDP_LOGIN, 192 | payload: [{ 193 | password: hashPassword(password), 194 | ...rest, 195 | }], 196 | meta: { 197 | ...meta, 198 | method: 'createUser', 199 | }, 200 | }); 201 | 202 | export const resetPassword = ({ 203 | token, 204 | newPassword, 205 | }, meta) => ({ 206 | type: DDP_LOGIN, 207 | payload: [ 208 | token, 209 | hashPassword(newPassword), 210 | ], 211 | meta: { 212 | ...meta, 213 | method: 'resetPassword', 214 | }, 215 | }); 216 | 217 | export const forgotPassword = ({ 218 | email, 219 | }, meta) => callMethod('forgotPassword', [{ email }], meta); 220 | -------------------------------------------------------------------------------- /ddp-redux/src/ejson/custom_models_for_tests.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | // Based on Meteor's EJSON implementation: https://github.com/meteor/meteor 4 | // 5 | // packages/ejson/custom_models_for_tests.js 6 | // 2017-08-08 7 | // 8 | // The MIT License (MIT) 9 | // 10 | // Copyright (c) 2011 - 2017 Meteor Development Group, Inc. 11 | // 12 | // Permission is hereby granted, free of charge, to any person obtaining a copy 13 | // of this software and associated documentation files (the "Software"), to deal 14 | // in the Software without restriction, including without limitation the rights 15 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 16 | // copies of the Software, and to permit persons to whom the Software is 17 | // furnished to do so, subject to the following conditions: 18 | // 19 | // The above copyright notice and this permission notice shall be included in all 20 | // copies or substantial portions of the Software. 21 | // 22 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 23 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 24 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 25 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 26 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 27 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 28 | // SOFTWARE. 29 | 30 | import { EJSON } from './ejson'; 31 | 32 | class Address { 33 | constructor(city, state) { 34 | this.city = city; 35 | this.state = state; 36 | } 37 | 38 | typeName() { 39 | return 'Address'; 40 | } 41 | 42 | toJSONValue() { 43 | return { 44 | city: this.city, 45 | state: this.state, 46 | }; 47 | } 48 | } 49 | 50 | EJSON.addType('Address', value => new Address(value.city, value.state)); 51 | 52 | class Person { 53 | constructor(name, dob, address) { 54 | this.name = name; 55 | this.dob = dob; 56 | this.address = address; 57 | } 58 | 59 | typeName() { 60 | return 'Person'; 61 | } 62 | 63 | toJSONValue() { 64 | return { 65 | name: this.name, 66 | dob: EJSON.toJSONValue(this.dob), 67 | address: EJSON.toJSONValue(this.address), 68 | }; 69 | } 70 | } 71 | 72 | EJSON.addType( 73 | 'Person', 74 | value => new Person( 75 | value.name, 76 | EJSON.fromJSONValue(value.dob), 77 | EJSON.fromJSONValue(value.address) 78 | ) 79 | ); 80 | 81 | class Holder { 82 | constructor(content) { 83 | this.content = content; 84 | } 85 | 86 | typeName() { 87 | return 'Holder'; 88 | } 89 | 90 | toJSONValue() { 91 | return this.content; 92 | } 93 | } 94 | 95 | EJSON.addType('Holder', value => new Holder(value)); 96 | 97 | const EJSONTest = { 98 | Address, 99 | Person, 100 | Holder, 101 | }; 102 | 103 | export default EJSONTest; 104 | -------------------------------------------------------------------------------- /ddp-redux/src/ejson/index.js: -------------------------------------------------------------------------------- 1 | import { EJSON } from './ejson'; 2 | 3 | export default EJSON; 4 | -------------------------------------------------------------------------------- /ddp-redux/src/ejson/newBinary.js: -------------------------------------------------------------------------------- 1 | // Based on Meteor's EJSON implementation: https://github.com/meteor/meteor 2 | // 3 | // packages/ejson/ejson.js 4 | // 2017-08-08 5 | // 6 | // The MIT License (MIT) 7 | // 8 | // Copyright (c) 2011 - 2017 Meteor Development Group, Inc. 9 | // 10 | // Permission is hereby granted, free of charge, to any person obtaining a copy 11 | // of this software and associated documentation files (the "Software"), to deal 12 | // in the Software without restriction, including without limitation the rights 13 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | // copies of the Software, and to permit persons to whom the Software is 15 | // furnished to do so, subject to the following conditions: 16 | // 17 | // The above copyright notice and this permission notice shall be included in all 18 | // copies or substantial portions of the Software. 19 | // 20 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 26 | // SOFTWARE. 27 | 28 | function newBinary(len) { 29 | if (typeof Uint8Array === 'undefined' || typeof ArrayBuffer === 'undefined') { 30 | const ret = []; 31 | for (let i = 0; i < len; i += 1) { 32 | ret.push(0); 33 | } 34 | ret.$Uint8ArrayPolyfill = true; 35 | return ret; 36 | } 37 | return new Uint8Array(new ArrayBuffer(len)); 38 | } 39 | 40 | export default newBinary; 41 | -------------------------------------------------------------------------------- /ddp-redux/src/ejson/stringify.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | // Based on json2.js from https://github.com/douglascrockford/JSON-js 4 | // 5 | // json2.js 6 | // 2012-10-08 7 | // 8 | // Public Domain. 9 | // 10 | // NO WARRANTY EXPRESSED OR IMPLIED. USE AT YOUR OWN RISK. 11 | 12 | function quote(string) { 13 | return JSON.stringify(string); 14 | } 15 | 16 | const str = (key, holder, singleIndent, outerIndent, canonical) => { 17 | const value = holder[key]; 18 | 19 | // What happens next depends on the value's type. 20 | switch (typeof value) { 21 | case 'string': 22 | return quote(value); 23 | case 'number': 24 | // JSON numbers must be finite. Encode non-finite numbers as null. 25 | return isFinite(value) ? String(value) : 'null'; 26 | case 'boolean': 27 | return String(value); 28 | // If the type is 'object', we might be dealing with an object or an array or 29 | // null. 30 | case 'object': 31 | // Due to a specification blunder in ECMAScript, typeof null is 'object', 32 | // so watch out for that case. 33 | if (!value) { 34 | return 'null'; 35 | } 36 | // Make an array to hold the partial results of stringifying this object 37 | // value. 38 | const innerIndent = outerIndent + singleIndent; 39 | const partial = []; 40 | 41 | let v; 42 | 43 | // Is the value an array? 44 | if (Array.isArray(value) || ({}).hasOwnProperty.call(value, 'callee')) { 45 | // The value is an array. Stringify every element. Use null as a 46 | // placeholder for non-JSON values. 47 | const length = value.length; 48 | for (let i = 0; i < length; i += 1) { 49 | partial[i] = 50 | str(i, value, singleIndent, innerIndent, canonical) || 'null'; 51 | } 52 | 53 | // Join all of the elements together, separated with commas, and wrap 54 | // them in brackets. 55 | if (partial.length === 0) { 56 | v = '[]'; 57 | } else if (innerIndent) { 58 | v = '[\n' + 59 | innerIndent + 60 | partial.join(',\n' + 61 | innerIndent) + 62 | '\n' + 63 | outerIndent + 64 | ']'; 65 | } else { 66 | v = '[' + partial.join(',') + ']'; 67 | } 68 | return v; 69 | } 70 | 71 | // Iterate through all of the keys in the object. 72 | let keys = Object.keys(value); 73 | if (canonical) { 74 | keys = keys.sort(); 75 | } 76 | keys.forEach(k => { 77 | v = str(k, value, singleIndent, innerIndent, canonical); 78 | if (v) { 79 | partial.push(quote(k) + (innerIndent ? ': ' : ':') + v); 80 | } 81 | }); 82 | 83 | // Join all of the member texts together, separated with commas, 84 | // and wrap them in braces. 85 | if (partial.length === 0) { 86 | v = '{}'; 87 | } else if (innerIndent) { 88 | v = '{\n' + 89 | innerIndent + 90 | partial.join(',\n' + 91 | innerIndent) + 92 | '\n' + 93 | outerIndent + 94 | '}'; 95 | } else { 96 | v = '{' + partial.join(',') + '}'; 97 | } 98 | return v; 99 | 100 | default: // Do nothing 101 | } 102 | }; 103 | 104 | // If the JSON object does not yet have a stringify method, give it one. 105 | const canonicalStringify = (value, options) => { 106 | // Make a fake root object containing our value under the key of ''. 107 | // Return the result of stringifying the value. 108 | const allOptions = Object.assign({ 109 | indent: '', 110 | canonical: false, 111 | }, options); 112 | if (allOptions.indent === true) { 113 | allOptions.indent = ' '; 114 | } else if (typeof allOptions.indent === 'number') { 115 | let newIndent = ''; 116 | for (let i = 0; i < allOptions.indent; i++) { 117 | newIndent += ' '; 118 | } 119 | allOptions.indent = newIndent; 120 | } 121 | return str('', {'': value}, allOptions.indent, '', allOptions.canonical); 122 | }; 123 | 124 | export default canonicalStringify; -------------------------------------------------------------------------------- /ddp-redux/src/ejson/tinytest.js: -------------------------------------------------------------------------------- 1 | /* eslint import/no-extraneous-dependencies: "off" */ 2 | import chai from 'chai'; 3 | 4 | const test = { 5 | isTrue: chai.assert.isTrue, 6 | isFalse: chai.assert.isFalse, 7 | equal: chai.assert.deepEqual, 8 | throws: chai.assert.throws, 9 | notEqual: chai.assert.notDeepEqual, 10 | }; 11 | 12 | class Tinytest { 13 | constructor() { 14 | this.tests = []; 15 | } 16 | 17 | add(name, run) { 18 | this.tests.push({ 19 | name, 20 | run, 21 | }); 22 | } 23 | 24 | buildSuite(it) { 25 | this.tests.forEach(({ name, run }) => { 26 | it(name, () => run(test)); 27 | }); 28 | } 29 | } 30 | 31 | export default Tinytest; 32 | -------------------------------------------------------------------------------- /ddp-redux/src/index.js: -------------------------------------------------------------------------------- 1 | import DDPClient from './DDPClient'; 2 | import DDPError from './DDPError'; 3 | import EJSON from './ejson'; 4 | import { createCollectionSelectors } from './modules/collections/selectors'; 5 | import { createCurrentUserSelectors } from './modules/currentUser/selectors'; 6 | import { createSubscriptionsSelector } from './modules/subscriptions/selectors'; 7 | import { createQueriesSelector } from './modules/queries/selectors'; 8 | import { createConnectionSelector } from './modules/connection/selectors'; 9 | import { createMethodsSelector } from './modules/methods/selectors'; 10 | import './shim'; 11 | 12 | export * from './actions'; 13 | export * from './constants'; 14 | export { 15 | EJSON, 16 | DDPError, 17 | createCollectionSelectors, 18 | createCurrentUserSelectors, 19 | createSubscriptionsSelector, 20 | createQueriesSelector, 21 | createConnectionSelector, 22 | createMethodsSelector, 23 | }; 24 | export default DDPClient; 25 | -------------------------------------------------------------------------------- /ddp-redux/src/modules/collections/index.js: -------------------------------------------------------------------------------- 1 | import { createReducer } from './reducer'; 2 | import { createMiddleware } from './middleware'; 3 | import { createSelectors } from './selectors'; 4 | 5 | export { 6 | createReducer, 7 | createMiddleware, 8 | createSelectors, 9 | }; 10 | 11 | -------------------------------------------------------------------------------- /ddp-redux/src/modules/collections/middleware.js: -------------------------------------------------------------------------------- 1 | import some from 'lodash/some'; 2 | import { 3 | DDP_READY, 4 | DDP_UPDATED, 5 | DDP_METHOD, 6 | DDP_ADDED, 7 | DDP_CHANGED, 8 | DDP_REMOVED, 9 | DDP_FLUSH, 10 | DDP_QUERY_UPDATE, 11 | } from '../../constants'; 12 | 13 | /** 14 | * Create middleware for the given ddpClient. 15 | * @param {DDPClient} ddpClient 16 | */ 17 | export const createMiddleware = ddpClient => (store) => { 18 | let flushTimeout = null; 19 | const scheduleFlush = () => { 20 | if (flushTimeout) { 21 | clearTimeout(flushTimeout); 22 | } 23 | flushTimeout = setTimeout(() => { 24 | const state = store.getState(); 25 | if (some(state.ddp.collections, collection => collection.needsUpdate)) { 26 | store.dispatch({ 27 | type: DDP_FLUSH, 28 | }); 29 | } 30 | flushTimeout = null; 31 | }, ddpClient.getFlushTimeout()); 32 | }; 33 | return next => (action) => { 34 | if (!action || typeof action !== 'object') { 35 | return next(action); 36 | } 37 | switch (action.type) { 38 | case DDP_ADDED: 39 | case DDP_CHANGED: 40 | case DDP_REMOVED: 41 | case DDP_METHOD: 42 | case DDP_QUERY_UPDATE: 43 | scheduleFlush(); 44 | return next(action); 45 | case DDP_READY: 46 | case DDP_UPDATED: 47 | store.dispatch({ 48 | type: DDP_FLUSH, 49 | }); 50 | return next(action); 51 | default: 52 | return next(action); 53 | } 54 | }; 55 | }; 56 | -------------------------------------------------------------------------------- /ddp-redux/src/modules/collections/middleware.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | /* eslint no-invalid-this: "off" */ 3 | 4 | import configureStore from 'redux-mock-store'; 5 | import { createMiddleware } from './middleware'; 6 | import { 7 | DDP_FLUSH, 8 | DDP_READY, 9 | DDP_UPDATED, 10 | 11 | DDP_ADDED, 12 | DDP_CHANGED, 13 | DDP_REMOVED, 14 | } from '../../constants'; 15 | import { DDPClient } from './testCommon'; 16 | 17 | jest.useFakeTimers(); 18 | 19 | describe('Test module - collections - middleware', () => { 20 | let testContext; 21 | 22 | beforeEach(() => { 23 | testContext = {}; 24 | }); 25 | 26 | beforeEach(() => { 27 | testContext.send = jest.fn(); 28 | testContext.ddpClient = new DDPClient(); 29 | testContext.ddpClient.socket.send = testContext.send; 30 | testContext.middleware = createMiddleware(testContext.ddpClient); 31 | testContext.mockStore = configureStore([ 32 | testContext.middleware, 33 | ]); 34 | }); 35 | 36 | test('should pass through an unknown action', () => { 37 | const store = testContext.mockStore(); 38 | const action = { 39 | type: 'unknown', 40 | payload: {}, 41 | }; 42 | store.dispatch(action); 43 | expect(store.getActions()).toEqual(expect.arrayContaining([ 44 | action, 45 | ])); 46 | }); 47 | 48 | [ 49 | DDP_ADDED, 50 | DDP_CHANGED, 51 | DDP_REMOVED, 52 | ].forEach((type) => { 53 | test(`should schedule dispatching ${DDP_FLUSH} after ${type}`, () => { 54 | const store = testContext.mockStore({ 55 | ddp: { 56 | collections: { 57 | col1: { 58 | needsUpdate: true, 59 | }, 60 | }, 61 | }, 62 | }); 63 | const action = { 64 | type, 65 | payload: { 66 | }, 67 | meta: { 68 | socketId: 'socket/1', 69 | }, 70 | }; 71 | store.dispatch(action); 72 | expect(store.getActions()).toEqual([ 73 | action, 74 | ]); 75 | 76 | jest.advanceTimersByTime(1000); 77 | 78 | expect(store.getActions()).toEqual([ 79 | action, 80 | { 81 | type: DDP_FLUSH, 82 | }, 83 | ]); 84 | }); 85 | }); 86 | 87 | [ 88 | DDP_READY, 89 | DDP_UPDATED, 90 | ].forEach((type) => { 91 | test(`should dispatch ${DDP_FLUSH} right before ${type}`, () => { 92 | const store = testContext.mockStore({ 93 | ddp: { 94 | collections: { 95 | col1: { 96 | needsUpdate: true, 97 | }, 98 | }, 99 | }, 100 | }); 101 | const action = { 102 | type, 103 | payload: { 104 | }, 105 | }; 106 | store.dispatch(action); 107 | expect(store.getActions()).toEqual([ 108 | { 109 | type: DDP_FLUSH, 110 | }, 111 | action, 112 | ]); 113 | }); 114 | }); 115 | }); 116 | -------------------------------------------------------------------------------- /ddp-redux/src/modules/collections/reducer.js: -------------------------------------------------------------------------------- 1 | import omit from 'lodash/omit'; 2 | import reduce from 'lodash/reduce'; 3 | import isEmpty from 'lodash/isEmpty'; 4 | import { 5 | DDP_METHOD, 6 | DDP_UPDATED, 7 | DDP_ADDED, 8 | DDP_ADDED_BEFORE, 9 | DDP_CHANGED, 10 | DDP_REMOVED, 11 | DDP_FLUSH, 12 | DDP_QUERY_UPDATE, 13 | DDP_QUERY_DELETE, 14 | } from '../../constants'; 15 | import carefullyMapValues from '../../utils/carefullyMapValues'; 16 | import createInsertEntities from '../../utils/createInsertEntities'; 17 | import createRemoveEntities from '../../utils/createRemoveEntities'; 18 | 19 | export const mutateCollections = (state, collection, id, socketId, mutateOne) => { 20 | const stateCollection = state[collection] || {}; 21 | const stateCollectionById = stateCollection.nextById || {}; 22 | const { 23 | current, 24 | ...other 25 | } = stateCollectionById[id] || {}; 26 | const shouldRemove = !mutateOne; 27 | const newCurrent = shouldRemove 28 | ? omit(current, socketId) 29 | : { 30 | ...current, 31 | [socketId]: mutateOne(current && current[socketId]), 32 | }; 33 | const shouldRemoveCurrent = isEmpty(newCurrent); 34 | const shouldRemoveCompletely = shouldRemoveCurrent && isEmpty(other); 35 | return { 36 | ...state, 37 | [collection]: { 38 | ...stateCollection, 39 | needsUpdate: true, 40 | nextById: shouldRemoveCompletely 41 | ? omit(stateCollectionById, id) 42 | : { 43 | ...stateCollectionById, 44 | [id]: shouldRemoveCurrent 45 | ? omit(stateCollectionById[id], 'current') 46 | : { 47 | ...stateCollectionById[id], 48 | current: newCurrent, 49 | }, 50 | }, 51 | }, 52 | }; 53 | }; 54 | 55 | const insertQueries = createInsertEntities('queries', 'queriesOrder'); 56 | const removeQueries = createRemoveEntities('queries', 'queriesOrder'); 57 | 58 | const insertChanges = createInsertEntities('methods', 'methodsOrder'); 59 | const removeChanges = createRemoveEntities('methods', 'methodsOrder'); 60 | 61 | export const createReducer = () => (state = {}, action) => { 62 | switch (action.type) { 63 | case DDP_ADDED: 64 | case DDP_ADDED_BEFORE: 65 | return mutateCollections( 66 | state, 67 | action.payload.collection, 68 | action.payload.id, 69 | action.meta && action.meta.socketId, 70 | () => ({ 71 | _id: action.payload.id, 72 | ...action.payload.fields, 73 | }), 74 | ); 75 | case DDP_CHANGED: 76 | return mutateCollections( 77 | state, 78 | action.payload.collection, 79 | action.payload.id, 80 | action.meta && action.meta.socketId, 81 | doc => ({ 82 | ...omit(doc, action.payload.cleared), 83 | ...action.payload.fields, 84 | }), 85 | ); 86 | case DDP_REMOVED: 87 | return mutateCollections( 88 | state, 89 | action.payload.collection, 90 | action.payload.id, 91 | action.meta && action.meta.socketId, 92 | null, 93 | ); 94 | case DDP_FLUSH: 95 | return carefullyMapValues(state, (collection) => { 96 | if (collection.needsUpdate) { 97 | const { 98 | needsUpdate, 99 | ...other 100 | } = collection; 101 | return { 102 | ...other, 103 | byId: collection.nextById, 104 | }; 105 | } 106 | return collection; 107 | }); 108 | case DDP_QUERY_UPDATE: 109 | return (() => { 110 | const { queryId } = action.meta; 111 | const { 112 | entities, 113 | oldEntities, 114 | } = action.payload; 115 | return insertQueries( 116 | removeQueries( 117 | state, 118 | queryId, 119 | oldEntities, 120 | ), 121 | queryId, 122 | entities, 123 | ); 124 | })(); 125 | case DDP_QUERY_DELETE: 126 | return (() => { 127 | const { queryId } = action.meta; 128 | const { entities } = action.payload; 129 | return removeQueries( 130 | state, 131 | queryId, 132 | entities, 133 | ); 134 | })(); 135 | case DDP_METHOD: 136 | return insertChanges( 137 | state, 138 | action.meta.methodId, 139 | action.meta.entities, 140 | ); 141 | case DDP_UPDATED: 142 | return reduce( 143 | action.meta.methods, 144 | (previousState, method) => (method 145 | ? removeChanges( 146 | previousState, 147 | method.id, 148 | method.entities, 149 | ) 150 | : previousState 151 | ), 152 | state, 153 | ); 154 | default: 155 | return state; 156 | } 157 | }; 158 | -------------------------------------------------------------------------------- /ddp-redux/src/modules/collections/selectors.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | 3 | import { createSelectors } from './selectors'; 4 | import { DDPClient } from './testCommon'; 5 | 6 | const constant = x => () => x; 7 | 8 | describe('Test module - collections - selectors', () => { 9 | let testContext; 10 | 11 | beforeEach(() => { 12 | testContext = {}; 13 | }); 14 | 15 | beforeEach(() => { 16 | testContext.selectors = createSelectors(DDPClient); 17 | testContext.collections1 = { 18 | col1: { 19 | byId: { 20 | 1: { 21 | current: { 22 | 'socket/1': { 23 | _id: '1', 24 | a: 1, 25 | b: 2, 26 | }, 27 | 'socket/2': { 28 | _id: '1', 29 | c: 3, 30 | }, 31 | }, 32 | queries: { 33 | 'query/1': { 34 | d: 4, 35 | }, 36 | }, 37 | queriesOrder: ['query/1'], 38 | }, 39 | }, 40 | }, 41 | col2: { 42 | byId: { 43 | 1: { 44 | current: { 45 | 'socket/1': { 46 | _id: '1', 47 | a: 1, 48 | }, 49 | }, 50 | queries: { 51 | 'query/1': { 52 | _id: '1', 53 | a: 9, 54 | b: 2, 55 | }, 56 | }, 57 | queriesOrder: ['query/1'], 58 | }, 59 | }, 60 | }, 61 | }; 62 | testContext.collections2 = { 63 | ...testContext.collections1, 64 | col1: { 65 | ...testContext.collections1.col1, 66 | byId: { 67 | ...testContext.collections1.col1.byId, 68 | 2: { 69 | current: { 70 | 'socket/1': { 71 | _id: '2', 72 | a: 1, 73 | b: 2, 74 | }, 75 | }, 76 | }, 77 | }, 78 | }, 79 | }; 80 | testContext.collections3 = { 81 | ...testContext.collections2, 82 | col1: { 83 | ...testContext.collections2.col1, 84 | byId: { 85 | ...testContext.collections2.col1.byId, 86 | 2: { 87 | ...testContext.collections2.col1.byId[2], 88 | current: { 89 | ...testContext.collections2.col1.byId[2].current, 90 | 'socket/1': { 91 | ...testContext.collections2.col1.byId[2].current['socket/1'], 92 | a: 3, 93 | }, 94 | }, 95 | }, 96 | }, 97 | }, 98 | }; 99 | testContext.state1 = { ddp: { collections: testContext.collections1 } }; 100 | testContext.state2 = { ddp: { collections: testContext.collections2 } }; 101 | testContext.state3 = { ddp: { collections: testContext.collections3 } }; 102 | }); 103 | 104 | test('should select a document by id', () => { 105 | const selector = testContext.selectors.col1.one(constant('1')); 106 | const expected = { 107 | _id: '1', 108 | a: 1, 109 | b: 2, 110 | c: 3, 111 | d: 4, 112 | }; 113 | const doc1 = selector(testContext.state1); 114 | const doc2 = selector(testContext.state2); 115 | expect(doc1).toEqual(expected); 116 | expect(doc2).toBe(doc1); 117 | expect(testContext.selectors.col1.all().byId().recomputations()).toBe(2); 118 | }); 119 | 120 | test('should find all documents', () => { 121 | const predicate = constant(true); 122 | const selector = testContext.selectors.col1.where(() => predicate); 123 | const doc1 = { 124 | _id: '1', 125 | a: 1, 126 | b: 2, 127 | c: 3, 128 | d: 4, 129 | }; 130 | const doc2 = { 131 | _id: '2', 132 | a: 1, 133 | b: 2, 134 | }; 135 | const results1 = selector(testContext.state1); 136 | const results2 = selector(testContext.state2); 137 | expect(results1).toEqual([ 138 | doc1, 139 | ]); 140 | expect(results2).toEqual([ 141 | doc1, 142 | doc2, 143 | ]); 144 | }); 145 | 146 | test('should find all matching documents', () => { 147 | const predicate = x => x.c === 3; 148 | const selector = testContext.selectors.col1.where(() => predicate); 149 | const doc1 = { 150 | _id: '1', 151 | a: 1, 152 | b: 2, 153 | c: 3, 154 | d: 4, 155 | }; 156 | const results1 = selector(testContext.state1); 157 | const results2 = selector(testContext.state2); 158 | expect(results1).toEqual([ 159 | doc1, 160 | ]); 161 | expect(results2).toEqual([ 162 | doc1, 163 | ]); 164 | }); 165 | 166 | test('should find one matching document', () => { 167 | const predicate = x => x.c === 3; 168 | const selector = testContext.selectors.col1.one.where(() => predicate); 169 | const doc1 = { 170 | _id: '1', 171 | a: 1, 172 | b: 2, 173 | c: 3, 174 | d: 4, 175 | }; 176 | const results1 = selector(testContext.state1); 177 | const results2 = selector(testContext.state2); 178 | expect(results1).toEqual(doc1); 179 | expect(results2).toEqual(doc1); 180 | }); 181 | }); 182 | -------------------------------------------------------------------------------- /ddp-redux/src/modules/collections/testCommon.js: -------------------------------------------------------------------------------- 1 | import DDPEmitter from '../../DDPEmitter'; 2 | 3 | export class DDPClient { 4 | constructor() { 5 | this.socket = new DDPEmitter(); 6 | } 7 | 8 | getFlushTimeout() { 9 | return this.constructor.getFlushTimeout(); 10 | } 11 | 12 | static getFlushTimeout() { 13 | return 200; 14 | } 15 | } 16 | 17 | export class Model1 { 18 | constructor(doc) { 19 | Object.assign(this, doc); 20 | } 21 | } 22 | 23 | Model1.indexes = { 24 | a: {}, 25 | b: {}, 26 | }; 27 | 28 | export class Model2 { 29 | constructor(doc) { 30 | Object.assign(this, doc); 31 | } 32 | } 33 | 34 | Model2.indexes = {}; 35 | 36 | DDPClient.models = { 37 | col1: Model1, 38 | col2: Model2, 39 | }; 40 | -------------------------------------------------------------------------------- /ddp-redux/src/modules/connection/index.js: -------------------------------------------------------------------------------- 1 | import { createReducer } from './reducer'; 2 | import { createMiddleware } from './middleware'; 3 | import { createSelectors } from './selectors'; 4 | 5 | export { 6 | createReducer, 7 | createMiddleware, 8 | createSelectors, 9 | }; 10 | 11 | -------------------------------------------------------------------------------- /ddp-redux/src/modules/connection/middleware.js: -------------------------------------------------------------------------------- 1 | import find from 'lodash/find'; 2 | import { 3 | DEFAULT_SOCKET_ID, 4 | 5 | DDP_PROTOCOL_VERSION, 6 | DDP_FAILED, 7 | DDP_ERROR, 8 | DDP_OPEN, 9 | DDP_CLOSE, 10 | DDP_DISCONNECTED, 11 | DDP_PING, 12 | DDP_PONG, 13 | DDP_CONNECT, 14 | } from '../../constants'; 15 | import DDPError from '../../DDPError'; 16 | import EJSON from '../../ejson'; 17 | import createDelayedTask from '../../utils/createDelayedTask'; 18 | 19 | /** 20 | * Create middleware for the given ddpClient. 21 | * @param {DDPClient} ddpClient 22 | */ 23 | export const createMiddleware = ddpClient => (store) => { 24 | // TODO: Add support for "server_id" message. 25 | ddpClient.on('open', (meta) => { 26 | store.dispatch({ 27 | meta, 28 | type: DDP_CONNECT, 29 | payload: { 30 | version: DDP_PROTOCOL_VERSION, 31 | support: [DDP_PROTOCOL_VERSION], 32 | }, 33 | }); 34 | }); 35 | ddpClient.on('close', (meta) => { 36 | store.dispatch({ 37 | meta, 38 | type: DDP_DISCONNECTED, 39 | }); 40 | }); 41 | if (ddpClient.defaultEndpoint) { 42 | setTimeout(() => { 43 | store.dispatch({ 44 | type: DDP_OPEN, 45 | payload: { 46 | endpoint: ddpClient.defaultEndpoint, 47 | }, 48 | }); 49 | }); 50 | } 51 | const scheduleCleanup = createDelayedTask((socketId) => { 52 | ddpClient.close({ socketId }); 53 | }); 54 | return next => (action) => { 55 | if (!action || typeof action !== 'object') { 56 | return next(action); 57 | } 58 | switch (action.type) { 59 | case DDP_ERROR: 60 | ddpClient.emit('error', new DDPError(DDPError.ERROR_BAD_MESSAGE, action.payload.reason, action.payload.offendingMessage)); 61 | return next(action); 62 | case DDP_PING: 63 | return ((result) => { 64 | store.dispatch({ 65 | type: DDP_PONG, 66 | payload: { 67 | id: action.payload.id, 68 | }, 69 | meta: action.meta, 70 | }); 71 | return result; 72 | })(next(action)); 73 | case DDP_FAILED: // could not negotiate DDP protocol version 74 | ddpClient.close(action.meta); 75 | // NOTE: ddpClient.close() will emit "close" event and it will dispatch the DDP_DISCONNECTED 76 | return next(action); 77 | case DDP_OPEN: 78 | return (() => { 79 | const state = store.getState(); 80 | const { 81 | endpoint, 82 | params, 83 | } = action.payload; 84 | const socket = find(state.ddp.connection.sockets, x => x.endpoint === endpoint && EJSON.equals(x.params, params)); 85 | let socketId = socket && socket.id; 86 | if (!socketId) { 87 | if (!state.ddp.connection.sockets[DEFAULT_SOCKET_ID]) { 88 | socketId = DEFAULT_SOCKET_ID; 89 | } else { 90 | socketId = ddpClient.nextUniqueId(); 91 | } 92 | } 93 | if (socket) { 94 | scheduleCleanup.cancel(socketId); 95 | } else { 96 | ddpClient.open(endpoint, { socketId }); 97 | } 98 | next({ 99 | ...action, 100 | payload: { 101 | ...action.payload, 102 | }, 103 | meta: { 104 | ...action.meta, 105 | socketId, 106 | }, 107 | }); 108 | return socketId; 109 | })(); 110 | case DDP_CLOSE: 111 | return (() => { 112 | const state = store.getState(); 113 | const socket = state.ddp.connection.sockets[action.meta.socketId]; 114 | // NOTE: The number of users will only be decreased after "next(action)" 115 | // so at this moment it's still taking into account the one which 116 | // is resigning. 117 | if (socket && socket.users === 1) { 118 | scheduleCleanup(socket.id); 119 | } 120 | return next(action); 121 | })(); 122 | default: 123 | return next(action); 124 | } 125 | }; 126 | }; 127 | -------------------------------------------------------------------------------- /ddp-redux/src/modules/connection/reducer.js: -------------------------------------------------------------------------------- 1 | import { 2 | DEFAULT_SOCKET_ID, 3 | 4 | DDP_CONNECTION_STATE__CONNECTING, 5 | DDP_CONNECTION_STATE__CONNECTED, 6 | DDP_CONNECTION_STATE__DISCONNECTED, 7 | 8 | DDP_OPEN, 9 | DDP_CLOSE, 10 | DDP_DISCONNECTED, 11 | DDP_CONNECTED, 12 | DDP_CONNECT, 13 | } from '../../constants'; 14 | import carefullyMapValues from '../../utils/carefullyMapValues'; 15 | 16 | export const createSocketReducer = () => (state = { 17 | state: DDP_CONNECTION_STATE__DISCONNECTED, 18 | }, action) => { 19 | switch (action.type) { 20 | case DDP_CONNECT: 21 | return { 22 | ...state, 23 | state: DDP_CONNECTION_STATE__CONNECTING, 24 | }; 25 | case DDP_CONNECTED: 26 | return { 27 | ...state, 28 | state: DDP_CONNECTION_STATE__CONNECTED, 29 | }; 30 | case DDP_DISCONNECTED: 31 | return { 32 | ...state, 33 | state: DDP_CONNECTION_STATE__DISCONNECTED, 34 | }; 35 | default: 36 | return state; 37 | } 38 | }; 39 | 40 | export const createReducer = (DDPClient) => { 41 | const socketReducer = createSocketReducer(DDPClient); 42 | return (state = { 43 | sockets: {}, 44 | }, action) => { 45 | switch (action.type) { 46 | case DDP_OPEN: 47 | return (() => { 48 | const socketId = (action.meta && action.meta.socketId) || DEFAULT_SOCKET_ID; 49 | return { 50 | ...state, 51 | sockets: { 52 | ...state.sockets, 53 | [socketId]: { 54 | ...state.sockets[socketId], 55 | id: socketId, 56 | users: ((state.sockets[socketId] && state.sockets[socketId].users) || 0) + 1, 57 | params: action.payload.params, 58 | endpoint: action.payload.endpoint, 59 | }, 60 | }, 61 | }; 62 | })(); 63 | case DDP_CLOSE: 64 | return (() => { 65 | const socketId = (action.meta && action.meta.socketId) || DEFAULT_SOCKET_ID; 66 | return { 67 | ...state, 68 | sockets: { 69 | ...state.sockets, 70 | [socketId]: { 71 | ...state.sockets[socketId], 72 | users: ((state.sockets[socketId] && state.sockets[socketId].users) || 0) - 1, 73 | }, 74 | }, 75 | }; 76 | })(); 77 | case DDP_CONNECT: 78 | case DDP_CONNECTED: 79 | case DDP_DISCONNECTED: 80 | return (() => { 81 | const actionSocketId = action.meta && action.meta.socketId; 82 | // NOTE: If socket is not yet there, it will be created. 83 | if (actionSocketId) { 84 | return { 85 | ...state, 86 | sockets: carefullyMapValues(state.sockets, (socket, socketId, remove) => { 87 | if (actionSocketId === socketId) { 88 | if (action.type === DDP_DISCONNECTED && !socket.users) { 89 | return remove(socketId); 90 | } 91 | return socketReducer(socket, action); 92 | } 93 | return socket; 94 | }), 95 | }; 96 | } 97 | return state; 98 | })(); 99 | default: 100 | return state; 101 | } 102 | }; 103 | }; 104 | -------------------------------------------------------------------------------- /ddp-redux/src/modules/connection/reducer.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | 3 | import { createReducer } from './reducer'; 4 | import { 5 | DDP_CONNECTION_STATE__DISCONNECTED, 6 | DDP_CONNECTION_STATE__CONNECTING, 7 | DDP_CONNECTION_STATE__CONNECTED, 8 | 9 | DDP_CONNECT, 10 | DDP_CONNECTED, 11 | DDP_DISCONNECTED, 12 | DDP_OPEN, 13 | DDP_CLOSE, 14 | } from '../../constants'; 15 | import { DDPClient } from './testCommon'; 16 | 17 | describe('Test module - connection - reducer', () => { 18 | let testContext; 19 | 20 | beforeEach(() => { 21 | testContext = {}; 22 | }); 23 | 24 | beforeEach(() => { 25 | testContext.reducer = createReducer(DDPClient); 26 | }); 27 | 28 | test('should initialize state', () => { 29 | expect(testContext.reducer(undefined, {})).toEqual({ 30 | sockets: {}, 31 | }); 32 | }); 33 | 34 | test('should change state to "connecting"', () => { 35 | expect(testContext.reducer({ 36 | sockets: { 37 | 1: { 38 | state: DDP_CONNECTION_STATE__DISCONNECTED, 39 | }, 40 | }, 41 | }, { 42 | type: DDP_CONNECT, 43 | payload: {}, 44 | meta: { 45 | socketId: '1', 46 | }, 47 | })).toEqual({ 48 | sockets: { 49 | 1: { 50 | state: DDP_CONNECTION_STATE__CONNECTING, 51 | }, 52 | }, 53 | }); 54 | }); 55 | 56 | test('should change state to "connected"', () => { 57 | expect(testContext.reducer({ 58 | sockets: { 59 | 1: { 60 | state: DDP_CONNECTION_STATE__CONNECTING, 61 | }, 62 | }, 63 | }, { 64 | type: DDP_CONNECTED, 65 | payload: {}, 66 | meta: { 67 | socketId: '1', 68 | }, 69 | })).toEqual({ 70 | sockets: { 71 | 1: { 72 | state: DDP_CONNECTION_STATE__CONNECTED, 73 | }, 74 | }, 75 | }); 76 | }); 77 | 78 | test('should change state to "disconnected"', () => { 79 | expect(testContext.reducer({ 80 | sockets: { 81 | 1: { 82 | state: DDP_CONNECTION_STATE__CONNECTED, 83 | users: 1, 84 | }, 85 | }, 86 | }, { 87 | type: DDP_DISCONNECTED, 88 | payload: {}, 89 | meta: { 90 | socketId: '1', 91 | }, 92 | })).toEqual({ 93 | sockets: { 94 | 1: { 95 | state: DDP_CONNECTION_STATE__DISCONNECTED, 96 | users: 1, 97 | }, 98 | }, 99 | }); 100 | }); 101 | 102 | test('should remove socket completely if there are no users', () => { 103 | expect(testContext.reducer({ 104 | sockets: { 105 | 1: { 106 | state: DDP_CONNECTION_STATE__CONNECTED, 107 | users: 0, 108 | }, 109 | 2: { 110 | state: DDP_CONNECTION_STATE__CONNECTED, 111 | users: 1, 112 | }, 113 | }, 114 | }, { 115 | type: DDP_DISCONNECTED, 116 | payload: {}, 117 | meta: { 118 | socketId: '1', 119 | }, 120 | })).toEqual({ 121 | sockets: { 122 | 2: { 123 | state: DDP_CONNECTION_STATE__CONNECTED, 124 | users: 1, 125 | }, 126 | }, 127 | }); 128 | }); 129 | 130 | test('should increase the number of users', () => { 131 | expect(testContext.reducer({ 132 | sockets: { 133 | 1: { 134 | id: '1', 135 | endpoint: 'http://example.com', 136 | params: [], 137 | state: DDP_CONNECTION_STATE__CONNECTED, 138 | users: 1, 139 | }, 140 | }, 141 | }, { 142 | type: DDP_OPEN, 143 | payload: { 144 | endpoint: 'http://example.com', 145 | params: [], 146 | }, 147 | meta: { 148 | socketId: '1', 149 | }, 150 | })).toEqual({ 151 | sockets: { 152 | 1: { 153 | id: '1', 154 | endpoint: 'http://example.com', 155 | params: [], 156 | state: DDP_CONNECTION_STATE__CONNECTED, 157 | users: 2, 158 | }, 159 | }, 160 | }); 161 | }); 162 | 163 | test('should decrease the number of users', () => { 164 | expect(testContext.reducer({ 165 | sockets: { 166 | 1: { 167 | state: DDP_CONNECTION_STATE__CONNECTED, 168 | users: 1, 169 | }, 170 | }, 171 | }, { 172 | type: DDP_CLOSE, 173 | payload: { 174 | }, 175 | meta: { 176 | socketId: '1', 177 | }, 178 | })).toEqual({ 179 | sockets: { 180 | 1: { 181 | state: DDP_CONNECTION_STATE__CONNECTED, 182 | users: 0, 183 | }, 184 | }, 185 | }); 186 | }); 187 | }); 188 | -------------------------------------------------------------------------------- /ddp-redux/src/modules/connection/selectors.js: -------------------------------------------------------------------------------- 1 | import find from 'lodash/find'; 2 | import { createSelector } from 'reselect'; 3 | import EJSON from '../../ejson'; 4 | import { DEFAULT_SOCKET_ID } from '../../constants'; 5 | 6 | export const createConnectionSelector = ({ 7 | selectDeclaredConnection, 8 | }) => createSelector( 9 | selectDeclaredConnection, 10 | state => state.ddp && state.ddp.connection && state.ddp.connection.sockets, 11 | (y, state) => (y 12 | ? find( 13 | state, 14 | x => x.endpoint === y.endpoint && EJSON.equals(x.params, y.params), 15 | ) 16 | : state[DEFAULT_SOCKET_ID] 17 | ), 18 | ); 19 | -------------------------------------------------------------------------------- /ddp-redux/src/modules/connection/testCommon.js: -------------------------------------------------------------------------------- 1 | import DDPEmitter from '../../DDPEmitter'; 2 | import { DEFAULT_SOCKET_ID } from '../../constants'; 3 | 4 | export class DDPClient extends DDPEmitter { 5 | constructor() { 6 | super(); 7 | this.sockets = {}; 8 | } 9 | 10 | nextUniqueId() { 11 | return this.constructor.defaultUniqueId; 12 | } 13 | 14 | open(endpoint, { socketId = DEFAULT_SOCKET_ID } = {}) { 15 | this.sockets[socketId] = { 16 | endpoint, 17 | }; 18 | } 19 | 20 | close({ socketId = DEFAULT_SOCKET_ID } = {}) { 21 | delete this.sockets[socketId]; 22 | } 23 | } 24 | 25 | DDPClient.defaultUniqueId = '1'; 26 | -------------------------------------------------------------------------------- /ddp-redux/src/modules/currentUser/index.js: -------------------------------------------------------------------------------- 1 | import { createReducer } from './reducer'; 2 | import { createMiddleware } from './middleware'; 3 | import { createSelectors } from './selectors'; 4 | 5 | export { 6 | createReducer, 7 | createMiddleware, 8 | createSelectors, 9 | }; 10 | 11 | -------------------------------------------------------------------------------- /ddp-redux/src/modules/currentUser/middleware.js: -------------------------------------------------------------------------------- 1 | /** @module createUser/middleware */ 2 | 3 | import { 4 | DEFAULT_SOCKET_ID, 5 | DEFAULT_LOGIN_METHOD_NAME, 6 | DEFAULT_LOGOUT_METHOD_NAME, 7 | 8 | DDP_LOGIN, 9 | DDP_LOGGED_IN, 10 | DDP_LOGOUT, 11 | DDP_LOGGED_OUT, 12 | DDP_CONNECTED, 13 | 14 | LOGIN_ACTION_PRIORITY, 15 | } from '../../constants'; 16 | import { callMethod } from '../../actions'; 17 | 18 | /** 19 | * Create middleware for the given ddpClient. 20 | * @param {DDPClient} ddpClient 21 | * @returns {ReduxMiddleware} 22 | * @private 23 | */ 24 | export const createMiddleware = ddpClient => (store) => { 25 | const getSocket = (socketId) => { 26 | const state = store.getState(); 27 | return state.ddp && 28 | state.ddp.connection && 29 | state.ddp.connection.sockets && 30 | state.ddp.connection.sockets[socketId]; 31 | }; 32 | const handleLoginError = (meta, err) => { 33 | store.dispatch({ 34 | type: DDP_LOGGED_OUT, 35 | error: true, 36 | payload: err, 37 | meta, 38 | }); 39 | ddpClient.emit('error', err); 40 | }; 41 | return next => (action) => { 42 | if (!action || typeof action !== 'object') { 43 | return next(action); 44 | } 45 | switch (action.type) { 46 | case DDP_CONNECTED: 47 | return ((result) => { 48 | const { socketId } = action.meta; 49 | const socket = getSocket(socketId); 50 | ddpClient 51 | .getResumeToken(socket) 52 | .then(resume => resume && store.dispatch({ 53 | type: DDP_LOGIN, 54 | payload: [{ resume }], 55 | meta: { 56 | socketId, 57 | }, 58 | })) 59 | .catch((err) => { 60 | console.warn(err); 61 | }); 62 | return result; 63 | })(next(action)); 64 | case DDP_LOGGED_IN: 65 | return (() => { 66 | const socketId = (action.meta && action.meta.socketId) || DEFAULT_SOCKET_ID; 67 | const socket = getSocket(socketId); 68 | ddpClient.setResumeToken(socket, action.payload.token); 69 | return next(action); 70 | })(); 71 | case DDP_LOGGED_OUT: 72 | return (() => { 73 | const socketId = (action.meta && action.meta.socketId) || DEFAULT_SOCKET_ID; 74 | const socket = getSocket(socketId); 75 | ddpClient.clearResumeToken(socket); 76 | return next(action); 77 | })(); 78 | case DDP_LOGIN: 79 | return (() => { 80 | const socketId = (action.meta && action.meta.socketId) || DEFAULT_SOCKET_ID; 81 | const method = (action.meta && action.meta.method) || DEFAULT_LOGIN_METHOD_NAME; 82 | next(action); 83 | const result = store.dispatch(callMethod(method, action.payload, { 84 | ...action.meta, 85 | priority: LOGIN_ACTION_PRIORITY, 86 | })); 87 | if (result instanceof Promise) { 88 | result.then(({ id, token }) => store.dispatch({ 89 | type: DDP_LOGGED_IN, 90 | payload: { 91 | id, 92 | token, 93 | }, 94 | meta: { 95 | socketId, 96 | }, 97 | }), handleLoginError.bind(null, action.meta)); 98 | } 99 | return result; 100 | })(); 101 | case DDP_LOGOUT: 102 | return (() => { 103 | next(action); 104 | const result = store.dispatch(callMethod(DEFAULT_LOGOUT_METHOD_NAME, [], { 105 | ...action.meta, 106 | priority: LOGIN_ACTION_PRIORITY, 107 | })); 108 | if (result instanceof Promise) { 109 | result 110 | .then(() => store.dispatch({ 111 | type: DDP_LOGGED_OUT, 112 | meta: action.meta, 113 | }), handleLoginError.bind(null, action.meta)); 114 | } 115 | return result; 116 | })(); 117 | default: 118 | return next(action); 119 | } 120 | }; 121 | }; 122 | -------------------------------------------------------------------------------- /ddp-redux/src/modules/currentUser/middleware.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | 3 | import configureStore from 'redux-mock-store'; 4 | import { createMiddleware } from './middleware'; 5 | import { DDPClient } from './testCommon'; 6 | 7 | jest.useFakeTimers(); 8 | 9 | describe('Test module - currentUser - middleware', () => { 10 | let testContext; 11 | 12 | beforeEach(() => { 13 | testContext = {}; 14 | }); 15 | 16 | beforeEach(() => { 17 | testContext.send = jest.fn(); 18 | testContext.close = jest.fn(); 19 | testContext.onError = jest.fn(); 20 | testContext.ddpClient = new DDPClient(); 21 | testContext.ddpClient.send = testContext.send; 22 | testContext.ddpClient.close = testContext.close; 23 | testContext.ddpClient.on('error', testContext.onError); 24 | testContext.middleware = createMiddleware(testContext.ddpClient); 25 | testContext.mockStore = configureStore([ 26 | testContext.middleware, 27 | ]); 28 | }); 29 | 30 | test('should pass through an unknown action', () => { 31 | const store = testContext.mockStore(); 32 | const action = { 33 | type: 'unknown', 34 | payload: {}, 35 | }; 36 | store.dispatch(action); 37 | expect(store.getActions()).toEqual(expect.arrayContaining([ 38 | action, 39 | ])); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /ddp-redux/src/modules/currentUser/reducer.js: -------------------------------------------------------------------------------- 1 | /** @module createUser/reducer */ 2 | 3 | import omit from 'lodash/omit'; 4 | import { 5 | DDP_LOGOUT, 6 | DDP_LOGGED_OUT, 7 | DDP_LOGIN, 8 | DDP_LOGGED_IN, 9 | DDP_DISCONNECTED, 10 | 11 | DDP_USER_STATE__LOGGING_IN, 12 | DDP_USER_STATE__LOGGED_IN, 13 | } from '../../constants'; 14 | 15 | export const createSocketReducer = () => (state = {}, action) => { 16 | switch (action.type) { 17 | case DDP_LOGIN: 18 | case DDP_LOGOUT: 19 | return { 20 | ...state, 21 | state: DDP_USER_STATE__LOGGING_IN, 22 | }; 23 | case DDP_LOGGED_IN: 24 | return { 25 | ...state, 26 | state: DDP_USER_STATE__LOGGED_IN, 27 | userId: action.payload.id, 28 | }; 29 | default: 30 | return state; 31 | } 32 | }; 33 | 34 | export const createReducer = (DDPClient) => { 35 | const socketReducer = createSocketReducer(DDPClient); 36 | return (state = {}, action) => { 37 | switch (action.type) { 38 | case DDP_LOGGED_OUT: 39 | case DDP_DISCONNECTED: 40 | return (() => { 41 | const socketId = action.meta && action.meta.socketId; 42 | if (socketId && state[socketId]) { 43 | return omit(state, socketId); 44 | } 45 | return state; 46 | })(); 47 | case DDP_LOGIN: 48 | case DDP_LOGOUT: 49 | case DDP_LOGGED_IN: 50 | return (() => { 51 | if (!action.meta) { 52 | return state; 53 | } 54 | const { 55 | socketId, 56 | } = action.meta; 57 | // NOTE: If socket is not yet there, it will be created. 58 | if (socketId) { 59 | return { 60 | ...state, 61 | [socketId]: socketReducer(state[socketId], action), 62 | }; 63 | } 64 | return state; 65 | })(); 66 | default: 67 | return state; 68 | } 69 | }; 70 | }; 71 | -------------------------------------------------------------------------------- /ddp-redux/src/modules/currentUser/reducer.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | 3 | import { createReducer } from './reducer'; 4 | import { 5 | DDP_USER_STATE__LOGGING_IN, 6 | DDP_USER_STATE__LOGGED_IN, 7 | 8 | DDP_LOGIN, 9 | DDP_LOGGED_IN, 10 | DDP_LOGOUT, 11 | DDP_LOGGED_OUT, 12 | DDP_DISCONNECTED, 13 | } from '../../constants'; 14 | import { DDPClient } from './testCommon'; 15 | 16 | describe('Test module - currentUser - reducer', () => { 17 | let testContext; 18 | 19 | beforeEach(() => { 20 | testContext = {}; 21 | }); 22 | 23 | beforeEach(() => { 24 | testContext.reducer = createReducer(DDPClient); 25 | }); 26 | 27 | test('should initialize state', () => { 28 | expect(testContext.reducer(undefined, {})).toEqual({}); 29 | }); 30 | 31 | test('should create a new entry on login', () => { 32 | expect(testContext.reducer({}, { 33 | type: DDP_LOGIN, 34 | payload: { 35 | }, 36 | meta: { 37 | socketId: 'socket/1', 38 | }, 39 | })).toEqual({ 40 | 'socket/1': { 41 | state: DDP_USER_STATE__LOGGING_IN, 42 | }, 43 | }); 44 | }); 45 | 46 | test('should set state to "logginIn" on logout', () => { 47 | expect(testContext.reducer({}, { 48 | type: DDP_LOGOUT, 49 | meta: { 50 | socketId: 'socket/1', 51 | }, 52 | })).toEqual({ 53 | 'socket/1': { 54 | state: DDP_USER_STATE__LOGGING_IN, 55 | }, 56 | }); 57 | }); 58 | 59 | test('should set user id on logged in', () => { 60 | expect(testContext.reducer({}, { 61 | type: DDP_LOGGED_IN, 62 | payload: { 63 | id: '1234', 64 | }, 65 | meta: { 66 | socketId: 'socket/1', 67 | }, 68 | })).toEqual({ 69 | 'socket/1': { 70 | state: DDP_USER_STATE__LOGGED_IN, 71 | userId: '1234', 72 | }, 73 | }); 74 | }); 75 | 76 | test('should remove an entry on logged out', () => { 77 | expect(testContext.reducer({ 78 | 'socket/1': { 79 | userId: '1234', 80 | }, 81 | 'socker/2': { 82 | userId: '1234', 83 | }, 84 | }, { 85 | type: DDP_LOGGED_OUT, 86 | payload: { 87 | }, 88 | meta: { 89 | socketId: 'socket/1', 90 | }, 91 | })).toEqual({ 92 | 'socker/2': { 93 | userId: '1234', 94 | }, 95 | }); 96 | }); 97 | 98 | test('should remove an entry on disconnected', () => { 99 | expect(testContext.reducer({ 100 | 'socket/1': { 101 | userId: '1234', 102 | }, 103 | 'socker/2': { 104 | userId: '1234', 105 | }, 106 | }, { 107 | type: DDP_DISCONNECTED, 108 | payload: { 109 | }, 110 | meta: { 111 | socketId: 'socket/1', 112 | }, 113 | })).toEqual({ 114 | 'socker/2': { 115 | userId: '1234', 116 | }, 117 | }); 118 | }); 119 | }); 120 | -------------------------------------------------------------------------------- /ddp-redux/src/modules/currentUser/selectors.js: -------------------------------------------------------------------------------- 1 | import { createSelector } from 'reselect'; 2 | import { 3 | DEFAULT_SOCKET_ID, 4 | DDP_USER_STATE__LOGGING_IN, 5 | } from '../../constants'; 6 | import { createCollectionSelectors } from '../collections/selectors'; 7 | 8 | const constant = x => () => x; 9 | const identity = x => x; 10 | 11 | const noModelSpecified = () => { 12 | console.warn(`You attempted to "selectCurrent" user but no User model was specified. 13 | This will result with null value being returned even if a user is logged in. To fix this, 14 | please make sure that you pass a valid Model and collection name to createCollectionSelectors().`); 15 | }; 16 | 17 | export const createCurrentUserSelectors = (Model, collection, { 18 | selectConnectionId = constant(DEFAULT_SOCKET_ID), 19 | } = {}) => { 20 | const usersCollection = collection || (Model && Model.collection); 21 | const userSelectorCreators = usersCollection 22 | ? createCollectionSelectors(Model, usersCollection) 23 | : null; 24 | 25 | const selectCurrentUserState = createSelector( 26 | selectConnectionId, 27 | identity, 28 | (connectionId, state) => (connectionId 29 | ? state.ddp && 30 | state.ddp.currentUser[connectionId] 31 | : null 32 | ), 33 | ); 34 | 35 | const selectCurrentUserId = createSelector( 36 | selectCurrentUserState, 37 | state => state && state.userId, 38 | ); 39 | 40 | const selectCurrent = userSelectorCreators 41 | ? userSelectorCreators.one(selectCurrentUserId) 42 | : noModelSpecified; 43 | 44 | const selectIsLoggingIn = createSelector( 45 | selectCurrentUserState, 46 | state => !!(state && state.state === DDP_USER_STATE__LOGGING_IN), 47 | ); 48 | 49 | // Example usage would be: 50 | // 51 | // current(User).user() 52 | // current(User).userId() 53 | // current(User).isLoggingIn() 54 | 55 | return { 56 | user: constant(selectCurrent), 57 | userId: constant(selectCurrentUserId), 58 | isLoggingIn: constant(selectIsLoggingIn), 59 | }; 60 | }; 61 | -------------------------------------------------------------------------------- /ddp-redux/src/modules/currentUser/testCommon.js: -------------------------------------------------------------------------------- 1 | import DDPEmitter from '../../DDPEmitter'; 2 | import { DEFAULT_SOCKET_ID } from '../../constants'; 3 | 4 | export class DDPClient extends DDPEmitter { 5 | constructor() { 6 | super(); 7 | this.sockets = {}; 8 | } 9 | 10 | nextUniqueId() { 11 | return this.constructor.defaultUniqueId; 12 | } 13 | 14 | open(endpoint, { socketId = DEFAULT_SOCKET_ID } = {}) { 15 | this.sockets[socketId] = { 16 | endpoint, 17 | }; 18 | } 19 | 20 | close({ socketId = DEFAULT_SOCKET_ID } = {}) { 21 | delete this.sockets[socketId]; 22 | } 23 | } 24 | 25 | DDPClient.defaultUniqueId = '1'; 26 | -------------------------------------------------------------------------------- /ddp-redux/src/modules/messages/index.js: -------------------------------------------------------------------------------- 1 | import { createReducer } from './reducer'; 2 | import { createMiddleware } from './middleware'; 3 | import { createSelectors } from './selectors'; 4 | 5 | export { 6 | createReducer, 7 | createMiddleware, 8 | createSelectors, 9 | }; 10 | 11 | -------------------------------------------------------------------------------- /ddp-redux/src/modules/messages/middleware.js: -------------------------------------------------------------------------------- 1 | import max from 'lodash/max'; 2 | import values from 'lodash/values'; 3 | import { 4 | DEFAULT_SOCKET_ID, 5 | 6 | DDP_RESULT, 7 | DDP_CONNECTED, 8 | DDP_METHOD, 9 | DDP_SUB, 10 | DDP_ENQUEUE, 11 | 12 | MESSAGE_TO_ACTION, 13 | ACTION_TO_MESSAGE, 14 | ACTION_TO_PRIORITY, 15 | } from '../../constants'; 16 | 17 | /** 18 | * Return the maximal priority of the current pending messages. 19 | * @param {object} state 20 | * @param {string} socketId 21 | * @returns {number} 22 | */ 23 | const getMessageThreshold = (state, socketId) => { 24 | const priorities = values(state.ddp.messages.sockets[socketId] && 25 | state.ddp.messages.sockets[socketId].pending); 26 | if (priorities.length === 0) { 27 | return -Infinity; 28 | } 29 | return max(priorities); 30 | }; 31 | 32 | /** 33 | * Create middleware for the given ddpClient. 34 | * @param {DDPClient} ddpClient 35 | */ 36 | export const createMiddleware = ddpClient => (store) => { 37 | ddpClient.on('message', (payload, meta) => { 38 | const type = payload.msg && MESSAGE_TO_ACTION[payload.msg]; 39 | if (type) { 40 | store.dispatch({ 41 | type, 42 | payload, 43 | meta, 44 | }); 45 | } 46 | }); 47 | return next => (action) => { 48 | if (!action || typeof action !== 'object') { 49 | return next(action); 50 | } 51 | const socketId = (action.meta && action.meta.socketId) || DEFAULT_SOCKET_ID; 52 | if (action.type === DDP_CONNECTED || action.type === DDP_RESULT) { 53 | // NOTE: We are propagating action first, because 54 | // we want to get an up-to-date threshold. 55 | const result = next(action); 56 | const state = store.getState(); 57 | const queue = state.ddp.messages.sockets[socketId] && 58 | state.ddp.messages.sockets[socketId].queue; 59 | if (queue) { 60 | let t = getMessageThreshold(state, socketId); 61 | let i = 0; 62 | while (i < queue.length && t <= queue[i].meta.priority) { 63 | store.dispatch(queue[i]); 64 | // Note that threshold might have changed after dispatching another action. 65 | t = getMessageThreshold(store.getState(), socketId); 66 | i += 1; 67 | } 68 | } 69 | return result; 70 | } 71 | const msg = ACTION_TO_MESSAGE[action.type]; 72 | if (!msg) { 73 | return next(action); 74 | } 75 | const priority = ACTION_TO_PRIORITY[action.type] || 0; 76 | const newAction = { 77 | ...action, 78 | payload: { 79 | ...action.payload, 80 | msg, 81 | }, 82 | meta: { 83 | priority, // action may overwrite it's priority 84 | socketId, // action may overwrite it's socketId 85 | ...action.meta, 86 | }, 87 | }; 88 | // Ensure that method & sub messages always have valid unique id 89 | if (action.type === DDP_METHOD || action.type === DDP_SUB) { 90 | newAction.payload.id = newAction.payload.id || ddpClient.nextUniqueId(); 91 | } 92 | const state = store.getState(); 93 | const threshold = getMessageThreshold(state, socketId); 94 | if (newAction.meta.priority >= threshold) { 95 | // NOTE: Initially (before "connected" message is received), the threshold will be set 96 | // to "connect" action priority which is the highest possible. As a result, nothing 97 | // will be sent until the connection is established. 98 | ddpClient.send(newAction.payload, newAction.meta); 99 | return next(newAction); 100 | } 101 | return store.dispatch({ 102 | type: DDP_ENQUEUE, 103 | payload: newAction.payload, 104 | meta: { 105 | type: newAction.type, 106 | ...newAction.meta, 107 | }, 108 | }); 109 | }; 110 | }; 111 | -------------------------------------------------------------------------------- /ddp-redux/src/modules/messages/reducer.js: -------------------------------------------------------------------------------- 1 | import omit from 'lodash/omit'; 2 | import { 3 | DEFAULT_SOCKET_ID, 4 | 5 | DDP_DISCONNECTED, 6 | DDP_PONG, 7 | DDP_RESULT, 8 | DDP_CONNECTED, 9 | DDP_CONNECT, 10 | DDP_METHOD, 11 | DDP_SUB, 12 | DDP_UNSUB, 13 | DDP_ENQUEUE, 14 | DDP_OPEN, 15 | 16 | ACTION_TO_PRIORITY, 17 | } from '../../constants'; 18 | import carefullyMapValues from '../../utils/carefullyMapValues'; 19 | 20 | const initialPending = { 21 | '[connect]': ACTION_TO_PRIORITY[DDP_CONNECT], 22 | }; 23 | 24 | export const createSocketReducer = () => (state = { 25 | queue: [], 26 | pending: initialPending, 27 | }, action) => { 28 | switch (action.type) { 29 | case DDP_DISCONNECTED: 30 | return { 31 | ...state, 32 | pending: initialPending, 33 | }; 34 | case DDP_ENQUEUE: 35 | return { 36 | ...state, 37 | queue: ((queue) => { 38 | // elements with higher priority go first 39 | const priority = action.meta.priority || 0; 40 | const newQueue = []; 41 | let i = 0; 42 | while (i < queue.length && priority <= queue[i].meta.priority) { 43 | newQueue.push(queue[i]); 44 | i += 1; 45 | } 46 | const { 47 | type, 48 | ...meta 49 | } = action.meta; 50 | newQueue.push({ 51 | type, 52 | meta, 53 | payload: action.payload, 54 | }); 55 | while (i < queue.length) { 56 | newQueue.push(queue[i]); 57 | i += 1; 58 | } 59 | return newQueue; 60 | })(state.queue), 61 | }; 62 | case DDP_CONNECTED: 63 | return { 64 | ...state, 65 | pending: {}, 66 | }; 67 | case DDP_METHOD: 68 | return { 69 | ...state, 70 | queue: state.queue.filter(x => x.payload.id !== action.payload.id), 71 | pending: { 72 | ...state.pending, 73 | [action.payload.id]: action.meta.priority, 74 | }, 75 | }; 76 | case DDP_RESULT: 77 | return { 78 | ...state, 79 | pending: omit(state.pending, action.payload.id), 80 | }; 81 | case DDP_PONG: 82 | case DDP_SUB: 83 | case DDP_UNSUB: 84 | return { 85 | ...state, 86 | queue: state.queue.filter(x => x.payload.id !== action.payload.id), 87 | }; 88 | default: 89 | return state; 90 | } 91 | }; 92 | 93 | export const createReducer = (DDPClient) => { 94 | const socketReducer = createSocketReducer(DDPClient); 95 | return (state = { 96 | sockets: {}, 97 | }, action) => { 98 | switch (action.type) { 99 | case DDP_OPEN: 100 | return (() => { 101 | const socketId = (action.meta && action.meta.socketId) || DEFAULT_SOCKET_ID; 102 | if (socketId) { 103 | return { 104 | ...state, 105 | sockets: { 106 | ...state.sockets, 107 | [socketId]: socketReducer(undefined, action), 108 | }, 109 | }; 110 | } 111 | return state; 112 | })(); 113 | case DDP_DISCONNECTED: 114 | case DDP_ENQUEUE: 115 | case DDP_CONNECTED: 116 | case DDP_METHOD: 117 | case DDP_RESULT: 118 | case DDP_PONG: 119 | case DDP_SUB: 120 | case DDP_UNSUB: 121 | return (() => { 122 | if (action.meta && action.meta.socketId) { 123 | return { 124 | ...state, 125 | sockets: carefullyMapValues(state.sockets, (socket, socketId) => { 126 | if (socketId === action.meta.socketId) { 127 | return socketReducer(socket, action); 128 | } 129 | return socket; 130 | }), 131 | }; 132 | } 133 | return state; 134 | })(); 135 | default: 136 | return state; 137 | } 138 | }; 139 | }; 140 | -------------------------------------------------------------------------------- /ddp-redux/src/modules/messages/selectors.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apendua/ddp-redux/412b4e38de09d398ad7bcc964f920f6fcf907396/ddp-redux/src/modules/messages/selectors.js -------------------------------------------------------------------------------- /ddp-redux/src/modules/messages/testCommon.js: -------------------------------------------------------------------------------- 1 | /* eslint class-methods-use-this: "off" */ 2 | import DDPEmitter from '../../DDPEmitter'; 3 | 4 | export class DDPClient extends DDPEmitter { 5 | nextUniqueId() { 6 | return '1'; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /ddp-redux/src/modules/methods/helpers.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Pick fields from metadata object that can potentially 4 | * be stored directly at the method object in redux store, 5 | * and vice versa. 6 | * @param {object} object 7 | * @returns {object} 8 | */ 9 | export const extractMetadata = (object) => { 10 | if (!object) { 11 | return {}; 12 | } 13 | const { 14 | queryId, 15 | socketId, 16 | entities, 17 | } = object; 18 | const result = {}; 19 | if (queryId) { 20 | result.queryId = queryId; 21 | } 22 | if (socketId) { 23 | result.socketId = socketId; 24 | } 25 | if (entities) { 26 | result.entities = entities; 27 | } 28 | return result; 29 | }; 30 | -------------------------------------------------------------------------------- /ddp-redux/src/modules/methods/index.js: -------------------------------------------------------------------------------- 1 | import { createReducer } from './reducer'; 2 | import { createMiddleware } from './middleware'; 3 | import { createSelectors } from './selectors'; 4 | 5 | export { 6 | createReducer, 7 | createMiddleware, 8 | createSelectors, 9 | }; 10 | 11 | -------------------------------------------------------------------------------- /ddp-redux/src/modules/methods/reducer.js: -------------------------------------------------------------------------------- 1 | import omit from 'lodash/omit'; 2 | import { 3 | DDP_METHOD_STATE__QUEUED, 4 | DDP_METHOD_STATE__PENDING, 5 | DDP_METHOD_STATE__UPDATED, 6 | DDP_METHOD_STATE__RETURNED, 7 | 8 | DDP_CANCEL, 9 | DDP_METHOD, 10 | DDP_RESULT, 11 | DDP_UPDATED, 12 | DDP_ENQUEUE, 13 | } from '../../constants'; 14 | import { extractMetadata } from './helpers'; 15 | import carefullyMapValues from '../../utils/carefullyMapValues'; 16 | 17 | export const createReducer = () => (state = {}, action) => { 18 | const id = action.meta && action.meta.methodId; 19 | switch (action.type) { 20 | case DDP_CANCEL: 21 | return carefullyMapValues(state, (method, methodId, remove) => { 22 | if (methodId === action.meta.methodId) { 23 | return remove(methodId); 24 | } 25 | return method; 26 | }); 27 | case DDP_ENQUEUE: { 28 | if (action.meta.methodId) { 29 | const { methodId } = action.meta; 30 | return { 31 | ...state, 32 | [methodId]: { 33 | ...state[methodId], 34 | id: methodId, 35 | state: DDP_METHOD_STATE__QUEUED, 36 | }, 37 | }; 38 | } 39 | return state; 40 | } 41 | case DDP_METHOD: 42 | return { 43 | ...state, 44 | [id]: { 45 | ...state[id], 46 | id, 47 | state: DDP_METHOD_STATE__PENDING, 48 | name: action.payload.method, 49 | params: action.payload.params, 50 | ...extractMetadata(action.meta), 51 | }, 52 | }; 53 | case DDP_RESULT: 54 | return state[id] && state[id].state === DDP_METHOD_STATE__PENDING 55 | ? { 56 | ...state, 57 | [id]: { 58 | ...state[id], 59 | state: DDP_METHOD_STATE__RETURNED, 60 | result: action.payload.result, 61 | error: action.payload.error, 62 | }, 63 | } 64 | : omit(state, id); 65 | case DDP_UPDATED: 66 | return carefullyMapValues(state, (method, methodId, remove) => { 67 | if (action.payload.methods.indexOf(methodId) < 0) { 68 | return method; 69 | } 70 | if (method.state === DDP_METHOD_STATE__RETURNED) { 71 | return remove(methodId); 72 | } 73 | return { 74 | ...method, 75 | state: DDP_METHOD_STATE__UPDATED, 76 | }; 77 | }); 78 | default: 79 | return state; 80 | } 81 | }; 82 | 83 | -------------------------------------------------------------------------------- /ddp-redux/src/modules/methods/reducer.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | /* eslint no-invalid-this: "off" */ 3 | 4 | import { createReducer } from './reducer'; 5 | import { 6 | DDP_ENQUEUE, 7 | DDP_METHOD, 8 | DDP_UPDATED, 9 | DDP_RESULT, 10 | DDP_CANCEL, 11 | 12 | DDP_METHOD_STATE__QUEUED, 13 | DDP_METHOD_STATE__PENDING, 14 | DDP_METHOD_STATE__UPDATED, 15 | DDP_METHOD_STATE__RETURNED, 16 | } from '../../constants'; 17 | import { DDPClient } from './testCommon'; 18 | 19 | describe('Test module - methods - reducer', () => { 20 | let testContext; 21 | 22 | beforeEach(() => { 23 | testContext = {}; 24 | }); 25 | 26 | beforeEach(() => { 27 | testContext.reducer = createReducer(DDPClient); 28 | }); 29 | 30 | test('should initialize state', () => { 31 | expect(testContext.reducer(undefined, {})).toEqual({}); 32 | }); 33 | 34 | test('should add new method', () => { 35 | expect(testContext.reducer({}, { 36 | type: DDP_METHOD, 37 | payload: { 38 | id: '1', 39 | method: 'methodA', 40 | params: [1, 2, 3], 41 | }, 42 | meta: { 43 | methodId: '1', 44 | socketId: 'socket/1', 45 | }, 46 | })).toEqual({ 47 | 1: { 48 | id: '1', 49 | name: 'methodA', 50 | params: [1, 2, 3], 51 | state: DDP_METHOD_STATE__PENDING, 52 | socketId: 'socket/1', 53 | }, 54 | }); 55 | }); 56 | 57 | test('should add method in "queued" state', () => { 58 | expect(testContext.reducer({}, { 59 | type: DDP_ENQUEUE, 60 | payload: { 61 | }, 62 | meta: { 63 | type: DDP_METHOD, 64 | methodId: '1', 65 | }, 66 | })).toEqual({ 67 | 1: { 68 | id: '1', 69 | state: DDP_METHOD_STATE__QUEUED, 70 | }, 71 | }); 72 | }); 73 | 74 | test( 75 | 'should switch state from "queued" to "pending" on DDP_METHOD', 76 | () => { 77 | expect(testContext.reducer({ 78 | 1: { 79 | state: DDP_METHOD_STATE__QUEUED, 80 | }, 81 | }, { 82 | type: DDP_METHOD, 83 | payload: { 84 | method: 'methodA', 85 | params: [], 86 | }, 87 | meta: { 88 | methodId: '1', 89 | }, 90 | })).toEqual({ 91 | 1: { 92 | id: '1', 93 | state: DDP_METHOD_STATE__PENDING, 94 | name: 'methodA', 95 | params: [], 96 | }, 97 | }); 98 | }, 99 | ); 100 | 101 | test('should change method state to "returned"', () => { 102 | expect(testContext.reducer({ 103 | 1: { 104 | id: '1', 105 | state: DDP_METHOD_STATE__PENDING, 106 | }, 107 | }, { 108 | type: DDP_RESULT, 109 | payload: { 110 | id: '1', 111 | }, 112 | meta: { 113 | methodId: '1', 114 | }, 115 | })).toEqual({ 116 | 1: { 117 | id: '1', 118 | state: DDP_METHOD_STATE__RETURNED, 119 | result: undefined, 120 | error: undefined, 121 | }, 122 | }); 123 | }); 124 | 125 | test('should change method state to "updated"', () => { 126 | expect(testContext.reducer({ 127 | 1: { 128 | id: '1', 129 | state: DDP_METHOD_STATE__PENDING, 130 | }, 131 | }, { 132 | type: DDP_UPDATED, 133 | payload: { 134 | methods: ['1'], 135 | }, 136 | meta: {}, 137 | })).toEqual({ 138 | 1: { 139 | id: '1', 140 | state: DDP_METHOD_STATE__UPDATED, 141 | }, 142 | }); 143 | }); 144 | 145 | test('should remove method if it is already updated', () => { 146 | expect(testContext.reducer({ 147 | 1: { 148 | id: '1', 149 | state: DDP_METHOD_STATE__UPDATED, 150 | }, 151 | }, { 152 | type: DDP_RESULT, 153 | payload: { 154 | id: '1', 155 | }, 156 | meta: { 157 | methodId: '1', 158 | }, 159 | })).toEqual({}); 160 | }); 161 | 162 | test('should remove method if it already returned', () => { 163 | expect(testContext.reducer({ 164 | 1: { 165 | id: '1', 166 | state: DDP_METHOD_STATE__RETURNED, 167 | }, 168 | }, { 169 | type: DDP_UPDATED, 170 | payload: { 171 | methods: ['1', '2'], 172 | }, 173 | })).toEqual({}); 174 | }); 175 | 176 | test('should remove method if it is canceled', () => { 177 | expect(testContext.reducer({ 178 | 1: { 179 | id: '1', 180 | state: DDP_METHOD_STATE__PENDING, 181 | }, 182 | }, { 183 | type: DDP_CANCEL, 184 | meta: { 185 | methodId: '1', 186 | }, 187 | })).toEqual({}); 188 | }); 189 | }); 190 | -------------------------------------------------------------------------------- /ddp-redux/src/modules/methods/selectors.js: -------------------------------------------------------------------------------- 1 | import map from 'lodash/map'; 2 | import { createSelector } from 'reselect'; 3 | import { DDP_METHOD_STATE__READY } from '../../constants'; 4 | 5 | /** 6 | * Create a selector that returns a list representing 7 | * current state of the methods user is interested in. 8 | * @param {Object} options 9 | * @param {Function} options.selectMethodsIds 10 | */ 11 | export const createMethodsSelector = ({ 12 | selectMethodsIds, 13 | readyState = { 14 | state: DDP_METHOD_STATE__READY, 15 | }, 16 | }) => createSelector( 17 | selectMethodsIds, 18 | state => state.ddp && state.ddp.methods, 19 | // NOTE: If method is not present in the store, it means that's already completed. 20 | (methodsIds, state) => map(methodsIds, id => (id && state[id]) || readyState), 21 | ); 22 | -------------------------------------------------------------------------------- /ddp-redux/src/modules/methods/testCommon.js: -------------------------------------------------------------------------------- 1 | /* eslint class-methods-use-this: "off" */ 2 | import DDPEmitter from '../../DDPEmitter'; 3 | 4 | export class DDPClient extends DDPEmitter { 5 | nextUniqueId() { 6 | return '1'; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /ddp-redux/src/modules/queries/index.js: -------------------------------------------------------------------------------- 1 | import { createReducer } from './reducer'; 2 | import { createMiddleware } from './middleware'; 3 | import { createSelectors } from './selectors'; 4 | 5 | export { 6 | createReducer, 7 | createMiddleware, 8 | createSelectors, 9 | }; 10 | 11 | -------------------------------------------------------------------------------- /ddp-redux/src/modules/queries/reducer.js: -------------------------------------------------------------------------------- 1 | import has from 'lodash/has'; 2 | import omit from 'lodash/omit'; 3 | import { 4 | DDP_STATE__INITIAL, 5 | DDP_STATE__QUEUED, 6 | DDP_STATE__PENDING, 7 | DDP_STATE__READY, 8 | DDP_STATE__OBSOLETE, 9 | DDP_STATE__RESTORING, 10 | DDP_STATE__CANCELED, 11 | 12 | DDP_ENQUEUE, 13 | DDP_DISCONNECTED, 14 | 15 | DDP_QUERY_REQUEST, 16 | DDP_QUERY_RELEASE, 17 | 18 | DDP_QUERY_CREATE, 19 | DDP_QUERY_DELETE, 20 | DDP_QUERY_UPDATE, 21 | DDP_QUERY_REFETCH, 22 | } from '../../constants'; 23 | 24 | const setProperty = propName => (value) => { 25 | if (typeof value === 'function') { 26 | return (state) => { 27 | const valueToSet = value(state[propName]); 28 | return (state[propName] === valueToSet 29 | ? state 30 | : { 31 | ...state, 32 | [propName]: valueToSet, 33 | } 34 | ); 35 | }; 36 | } 37 | return state => (state[propName] === value 38 | ? state 39 | : { 40 | ...state, 41 | [propName]: value, 42 | } 43 | ); 44 | }; 45 | 46 | const increaseBy = value => (currentValue = 0) => currentValue + value; 47 | 48 | const increaseProperty = propName => value => setProperty(propName)(increaseBy(value)); 49 | 50 | const setState = setProperty('state'); 51 | const deleteKey = (object, key) => { 52 | if (has(object, key)) { 53 | return omit(object, key); 54 | } 55 | return object; 56 | }; 57 | 58 | const increaseUsersByOne = increaseProperty('users')(1); 59 | const decreaseUsersByOne = increaseProperty('users')(-1); 60 | 61 | const queryReducer = (state = { 62 | state: DDP_STATE__INITIAL, 63 | }, action) => { 64 | switch (action.type) { 65 | case DDP_QUERY_REQUEST: 66 | return increaseUsersByOne(state); 67 | case DDP_QUERY_RELEASE: 68 | return decreaseUsersByOne(state); 69 | case DDP_ENQUEUE: { 70 | switch (state.state) { 71 | case DDP_STATE__INITIAL: 72 | return setState(DDP_STATE__QUEUED)(state); 73 | default: 74 | return state; 75 | } 76 | } 77 | case DDP_DISCONNECTED: { 78 | switch (state.state) { 79 | case DDP_STATE__PENDING: 80 | return setState(DDP_STATE__CANCELED)(state); 81 | case DDP_STATE__READY: 82 | case DDP_STATE__RESTORING: 83 | return setState(DDP_STATE__OBSOLETE)(state); 84 | default: 85 | return state; 86 | } 87 | } 88 | case DDP_QUERY_CREATE: 89 | return { 90 | ...state, 91 | id: action.meta.queryId, 92 | name: action.payload.name, 93 | params: action.payload.params, 94 | properties: action.payload.properties, 95 | }; 96 | case DDP_QUERY_REFETCH: 97 | return state.users > 0 ? state : setState(DDP_STATE__OBSOLETE)(state); 98 | case DDP_QUERY_UPDATE: { 99 | if (!action.payload) { 100 | switch (state.state) { 101 | case DDP_STATE__INITIAL: 102 | case DDP_STATE__QUEUED: 103 | return setState(DDP_STATE__PENDING)(state); 104 | case DDP_STATE__READY: 105 | return setState(DDP_STATE__RESTORING)(state); 106 | default: 107 | return state; 108 | } 109 | } 110 | const { 111 | error, 112 | result, 113 | entities, 114 | } = action.payload; 115 | if (error) { 116 | return { 117 | ...state, 118 | error, 119 | state: DDP_STATE__CANCELED, 120 | }; 121 | } 122 | return { 123 | ...state, 124 | ...entities !== undefined && { entities }, 125 | result, 126 | state: DDP_STATE__READY, 127 | }; 128 | } 129 | default: 130 | return state; 131 | } 132 | }; 133 | 134 | export const createReducer = () => (state = {}, action) => { 135 | switch (action.type) { 136 | case DDP_ENQUEUE: 137 | case DDP_QUERY_REQUEST: 138 | case DDP_QUERY_RELEASE: 139 | case DDP_QUERY_UPDATE: 140 | case DDP_QUERY_REFETCH: 141 | case DDP_DISCONNECTED: { 142 | const queryId = action.meta && 143 | action.meta.queryId; 144 | if (queryId) { 145 | const queryState = state[queryId]; 146 | return { 147 | ...state, 148 | [queryId]: queryReducer(queryState, action), 149 | }; 150 | } 151 | return state; 152 | } 153 | case DDP_QUERY_DELETE: 154 | return deleteKey(state, action.meta.queryId); 155 | case DDP_QUERY_CREATE: { 156 | const queryId = action.meta && 157 | action.meta.queryId; 158 | if (queryId) { 159 | return { 160 | ...state, 161 | [queryId]: queryReducer(state[queryId], action), 162 | }; 163 | } 164 | return state; 165 | } 166 | default: 167 | return state; 168 | } 169 | }; 170 | -------------------------------------------------------------------------------- /ddp-redux/src/modules/queries/selectors.js: -------------------------------------------------------------------------------- 1 | import find from 'lodash/find'; 2 | import { createSelector } from 'reselect'; 3 | import stableMap from '../../utils/stableMap'; 4 | import EJSON from '../../ejson'; 5 | import { 6 | DEFAULT_SOCKET_ID, 7 | DDP_STATE__READY, 8 | } from '../../constants'; 9 | 10 | const constant = x => () => x; 11 | 12 | export const findQuery = (queries, name, params, properties) => find( 13 | queries, 14 | x => x.name === name && 15 | EJSON.equals(x.params, params) && 16 | EJSON.equals(x.properties, properties), 17 | ); 18 | 19 | /** 20 | * Create a selector that returns a list (or object) representing 21 | * current state of the queries user is interested in. 22 | * @param {Object} options 23 | * @param {Function} options.selectQueriesRequests 24 | * @param {Function} options.selectConnectionId 25 | * @param {Function} options.emptyState - this is used to distinguish between missing subscription and no request at all 26 | */ 27 | export const createQueriesSelector = ({ 28 | selectDeclaredQueries, 29 | selectConnectionId = constant(DEFAULT_SOCKET_ID), 30 | emptyState = { 31 | state: DDP_STATE__READY, 32 | }, 33 | }) => createSelector( 34 | selectDeclaredQueries, 35 | selectConnectionId, 36 | state => state.ddp && state.ddp.queries, 37 | (declaredQueries, socketId, queries) => (socketId 38 | ? stableMap(declaredQueries, y => (y 39 | ? findQuery( 40 | queries, 41 | y.name, 42 | y.params, 43 | { 44 | socketId, 45 | ...y.properties, 46 | }, 47 | ) 48 | : emptyState 49 | )) 50 | : stableMap(declaredQueries, constant(null)) 51 | ), 52 | ); 53 | -------------------------------------------------------------------------------- /ddp-redux/src/modules/queries/testCommon.js: -------------------------------------------------------------------------------- 1 | import DDPEmitter from '../../DDPEmitter'; 2 | import { callMethod } from '../../actions'; 3 | 4 | export class DDPClient extends DDPEmitter { 5 | getQueryCleanupTimeout() { 6 | return this.constructor.getQueryCleanupTimeout(); 7 | } 8 | 9 | nextUniqueId() { 10 | return this.constructor.defaultUniqueId; 11 | } 12 | 13 | extractEntities(result) { 14 | return this.constructor.extractEntities(result); 15 | } 16 | 17 | fetch(...args) { 18 | return this.constructor.fetch(...args); 19 | } 20 | 21 | static getQueryCleanupTimeout() { 22 | return 1000; 23 | } 24 | 25 | static extractEntities(result) { 26 | return result.entities; 27 | } 28 | 29 | static fetch(...args) { 30 | return callMethod(...args); 31 | } 32 | } 33 | 34 | DDPClient.defaultUniqueId = '1'; 35 | -------------------------------------------------------------------------------- /ddp-redux/src/modules/queries2/index.js: -------------------------------------------------------------------------------- 1 | import { createReducer } from './reducer'; 2 | import { createMiddleware } from './middleware'; 3 | import { createSelectors } from './selectors'; 4 | 5 | export { 6 | createReducer, 7 | createMiddleware, 8 | createSelectors, 9 | }; 10 | 11 | -------------------------------------------------------------------------------- /ddp-redux/src/modules/queries2/middleware.js: -------------------------------------------------------------------------------- 1 | import { 2 | DDP_RESOURCE_UPDATE, 3 | DDP_RESOURCE_DELETE, 4 | DDP_RESOURCE_FETCH, 5 | DDP_RESOURCE_REFETCH, 6 | } from '../../constants'; 7 | import { 8 | callMethod, 9 | updateResource, 10 | } from '../../actions'; 11 | 12 | /** 13 | * Create middleware for the given ddpClient. 14 | * @param {DDPClient} ddpClient 15 | */ 16 | export const createMiddleware = ddpClient => store => next => (action) => { 17 | if (!action || 18 | typeof action !== 'object' || 19 | !action.payload || 20 | !action.meta) { 21 | return next(action); 22 | } 23 | switch (action.type) { 24 | case DDP_RESOURCE_FETCH: 25 | case DDP_RESOURCE_REFETCH: 26 | case DDP_RESOURCE_DELETE: 27 | case DDP_RESOURCE_UPDATE: { 28 | const type = action.payload.properties && 29 | action.payload.properties.type; 30 | if (type !== 'query') { 31 | return next(action); 32 | } 33 | break; 34 | } 35 | default: 36 | return next(action); 37 | } 38 | const { 39 | name, 40 | params, 41 | properties, 42 | } = action.payload; 43 | const { 44 | resourceId, 45 | } = action.meta; 46 | 47 | switch (action.type) { 48 | case DDP_RESOURCE_FETCH: 49 | case DDP_RESOURCE_REFETCH: { 50 | const { 51 | socketId, 52 | } = properties; 53 | const nextResult = next(action); 54 | 55 | store.dispatch(callMethod(name, params, { 56 | resourceId, 57 | socketId, 58 | })).then((result) => { 59 | store.dispatch(updateResource(resourceId, { result })); 60 | }).catch((error) => { 61 | store.dispatch(updateResource(resourceId, { error })); 62 | }); 63 | 64 | return nextResult; 65 | } 66 | case DDP_RESOURCE_DELETE: { 67 | const state = store.getState(); 68 | const resource = resourceId && state.ddp.resources[resourceId]; 69 | if (!resource) { 70 | return next(action); 71 | } 72 | return next({ 73 | ...action, 74 | payload: { 75 | entities: resource.entities, 76 | ...action.payload, 77 | }, 78 | }); 79 | } 80 | case DDP_RESOURCE_UPDATE: { 81 | const state = store.getState(); 82 | const resource = resourceId && state.ddp.resources[resourceId]; 83 | if (!resource) { 84 | return next(action); 85 | } 86 | const newAction = { 87 | ...action, 88 | payload: { 89 | ...action.payload, 90 | oldEntities: resource.entities, 91 | }, 92 | }; 93 | if (action.payload && 94 | action.payload.result && 95 | typeof action.payload.result === 'object' 96 | ) { 97 | newAction.payload.entities = ddpClient.extractEntities( 98 | action.payload.result, 99 | { 100 | name, 101 | params, 102 | properties, 103 | }, 104 | ); 105 | } 106 | return next(newAction); 107 | } 108 | default: 109 | return next(action); 110 | } 111 | }; 112 | -------------------------------------------------------------------------------- /ddp-redux/src/modules/queries2/reducer.js: -------------------------------------------------------------------------------- 1 | export const createReducer = () => (state = {}) => state; 2 | -------------------------------------------------------------------------------- /ddp-redux/src/modules/queries2/reducer.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | /* eslint no-invalid-this: "off" */ 3 | 4 | import { createReducer } from './reducer'; 5 | import { DDPClient } from './testCommon'; 6 | 7 | describe('Test module - queries2 - reducer', () => { 8 | let testContext; 9 | 10 | beforeEach(() => { 11 | testContext = {}; 12 | }); 13 | 14 | beforeEach(() => { 15 | testContext.reducer = createReducer(DDPClient); 16 | }); 17 | 18 | test('should initialize state', () => { 19 | expect(testContext.reducer(undefined, {})).toEqual({}); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /ddp-redux/src/modules/queries2/selectors.js: -------------------------------------------------------------------------------- 1 | import find from 'lodash/find'; 2 | import { createSelector } from 'reselect'; 3 | import stableMap from '../../utils/stableMap'; 4 | import EJSON from '../../ejson'; 5 | import { 6 | DEFAULT_SOCKET_ID, 7 | DDP_STATE__READY, 8 | } from '../../constants'; 9 | 10 | const constant = x => () => x; 11 | 12 | export const findQuery = (queries, name, params, properties) => find( 13 | queries, 14 | x => x.name === name && 15 | EJSON.equals(x.params, params) && 16 | EJSON.equals(x.properties, properties), 17 | ); 18 | 19 | /** 20 | * Create a selector that returns a list (or object) representing 21 | * current state of the queries user is interested in. 22 | * @param {Object} options 23 | * @param {Function} options.selectQueriesRequests 24 | * @param {Function} options.selectConnectionId 25 | * @param {Function} options.emptyState - this is used to distinguish between missing subscription and no request at all 26 | */ 27 | export const createQueriesSelector = ({ 28 | selectDeclaredQueries, 29 | selectConnectionId = constant(DEFAULT_SOCKET_ID), 30 | emptyState = { 31 | state: DDP_STATE__READY, 32 | }, 33 | }) => createSelector( 34 | selectDeclaredQueries, 35 | selectConnectionId, 36 | state => state.ddp && state.ddp.queries, 37 | (declaredQueries, socketId, queries) => (socketId 38 | ? stableMap(declaredQueries, y => (y 39 | ? findQuery( 40 | queries, 41 | y.name, 42 | y.params, 43 | { 44 | socketId, 45 | ...y.properties, 46 | }, 47 | ) 48 | : emptyState 49 | )) 50 | : stableMap(declaredQueries, constant(null)) 51 | ), 52 | ); 53 | -------------------------------------------------------------------------------- /ddp-redux/src/modules/queries2/testCommon.js: -------------------------------------------------------------------------------- 1 | import DDPEmitter from '../../DDPEmitter'; 2 | import { callMethod } from '../../actions'; 3 | 4 | export class DDPClient extends DDPEmitter { 5 | getCleanupTimeout() { 6 | return this.constructor.getQueryCleanupTimeout(); 7 | } 8 | 9 | nextUniqueId() { 10 | return this.constructor.defaultUniqueId; 11 | } 12 | 13 | extractEntities(result) { 14 | return this.constructor.extractEntities(result); 15 | } 16 | 17 | fetch(...args) { 18 | return this.constructor.fetch(...args); 19 | } 20 | 21 | static getQueryCleanupTimeout() { 22 | return 1000; 23 | } 24 | 25 | static extractEntities(result) { 26 | return result.entities; 27 | } 28 | 29 | static fetch(...args) { 30 | return callMethod(...args); 31 | } 32 | } 33 | 34 | DDPClient.defaultUniqueId = '1'; 35 | -------------------------------------------------------------------------------- /ddp-redux/src/modules/queues/constants.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apendua/ddp-redux/412b4e38de09d398ad7bcc964f920f6fcf907396/ddp-redux/src/modules/queues/constants.js -------------------------------------------------------------------------------- /ddp-redux/src/modules/queues/index.js: -------------------------------------------------------------------------------- 1 | import { createReducer } from './reducer'; 2 | import { createMiddleware } from './middleware'; 3 | import { createSelectors } from './selectors'; 4 | 5 | export { 6 | createReducer, 7 | createMiddleware, 8 | createSelectors, 9 | }; 10 | 11 | -------------------------------------------------------------------------------- /ddp-redux/src/modules/queues/middleware.js: -------------------------------------------------------------------------------- 1 | import max from 'lodash/max'; 2 | import values from 'lodash/values'; 3 | import { DDP_ENQUEUE } from '../../constants'; 4 | 5 | /** 6 | * Return the maximal priority of the current pending actions. 7 | * @param {object} state 8 | * @param {string} queueId 9 | * @returns {number} 10 | */ 11 | const getMaxPendingValue = (state, queueId) => { 12 | const pendingValues = values(state.ddp.queues[queueId] && 13 | state.ddp.queues[queueId].pending); 14 | if (pendingValues.length === 0) { 15 | return -Infinity; 16 | } 17 | return max(pendingValues); 18 | }; 19 | 20 | /** 21 | * Create middleware for the given ddpClient. 22 | * @param {DDPClient} ddpClient 23 | */ 24 | export const createMiddleware = () => store => next => (action) => { 25 | if (!action || typeof action !== 'object' || action.type === DDP_ENQUEUE) { 26 | return next(action); 27 | } 28 | const queueId = action.meta && 29 | action.meta.queue && 30 | action.meta.queue.id; 31 | if (!queueId) { 32 | return next(action); 33 | } 34 | const { 35 | resolve, 36 | priority = 0, 37 | } = action.meta.queue; 38 | if (resolve) { 39 | // NOTE: We are propagating action first, because 40 | // we want to get an up-to-date threshold. 41 | const result = next(action); 42 | const state = store.getState(); 43 | const elements = state.ddp.queues[queueId] && 44 | state.ddp.queues[queueId].elements; 45 | if (elements) { 46 | let t = getMaxPendingValue(state, queueId); 47 | let i = 0; 48 | while (i < elements.length && t <= (elements[i].meta.queue.priority || 0)) { 49 | store.dispatch(elements[i]); 50 | // Note that threshold might have changed after dispatching another action. 51 | t = getMaxPendingValue(store.getState(), queueId); 52 | i += 1; 53 | } 54 | } 55 | return result; 56 | } 57 | if (priority >= getMaxPendingValue(store.getState(), queueId)) { 58 | return next(action); 59 | } 60 | return store.dispatch({ 61 | ...action, 62 | type: DDP_ENQUEUE, 63 | meta: { 64 | ...action.meta, 65 | type: action.type, 66 | }, 67 | }); 68 | }; 69 | -------------------------------------------------------------------------------- /ddp-redux/src/modules/queues/middleware.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | 3 | import configureStore from 'redux-mock-store'; 4 | import { createMiddleware } from './middleware'; 5 | import { DDP_ENQUEUE } from '../../constants'; 6 | 7 | const createInitialState = (queueId, queueState) => ({ 8 | ddp: { 9 | queues: { 10 | [queueId]: queueState, 11 | }, 12 | }, 13 | }); 14 | 15 | describe('Test module - queues - middleware', () => { 16 | let testContext; 17 | 18 | beforeEach(() => { 19 | testContext = {}; 20 | }); 21 | 22 | beforeEach(() => { 23 | testContext.send = jest.fn(); 24 | testContext.onError = jest.fn(); 25 | testContext.middleware = createMiddleware(); 26 | testContext.mockStore = configureStore([ 27 | testContext.middleware, 28 | ]); 29 | }); 30 | 31 | test('should pass an action without queue meta field', () => { 32 | const store = testContext.mockStore(); 33 | const action = { 34 | type: 'no_meta_queue', 35 | payload: {}, 36 | meta: {}, 37 | }; 38 | store.dispatch(action); 39 | expect(store.getActions()).toEqual(expect.arrayContaining([ 40 | action, 41 | ])); 42 | }); 43 | 44 | test('should enqueue action if priority is too low', () => { 45 | const store = testContext.mockStore(createInitialState('1', { 46 | pending: { 47 | 1: 20, 48 | 2: 30, 49 | }, 50 | elements: [], 51 | })); 52 | const action = { 53 | type: 'action', 54 | payload: { 55 | id: '2', 56 | }, 57 | meta: { 58 | queue: { 59 | id: '1', 60 | priority: 25, 61 | }, 62 | }, 63 | }; 64 | store.dispatch(action); 65 | expect(store.getActions()).toEqual([{ 66 | type: DDP_ENQUEUE, 67 | payload: action.payload, 68 | meta: { 69 | type: action.type, 70 | ...action.meta, 71 | }, 72 | }]); 73 | }); 74 | 75 | test('should empty queue up to the computed threshold', () => { 76 | const store = testContext.mockStore(createInitialState('1', { 77 | pending: { 78 | 1: 10, 79 | 2: 0, 80 | }, 81 | elements: [{ 82 | type: 'action', 83 | payload: { 84 | id: '3', 85 | }, 86 | meta: { 87 | queue: { 88 | id: '1', 89 | priority: 10, 90 | }, 91 | }, 92 | }, { 93 | type: 'action', 94 | payload: { 95 | id: '4', 96 | }, 97 | meta: { 98 | queue: { 99 | id: '1', 100 | priority: 10, 101 | }, 102 | }, 103 | }, { 104 | type: 'action', 105 | payload: { 106 | id: '5', 107 | }, 108 | meta: { 109 | queue: { 110 | id: '1', 111 | priority: 0, 112 | }, 113 | }, 114 | }], 115 | })); 116 | const action = { 117 | type: 'action', 118 | payload: 4, 119 | meta: { 120 | queue: { 121 | id: '1', 122 | resolve: true, 123 | }, 124 | }, 125 | }; 126 | store.dispatch(action); 127 | expect(store.getActions()).toEqual([ 128 | action, 129 | { 130 | type: 'action', 131 | payload: { 132 | id: '3', 133 | }, 134 | meta: { 135 | queue: { 136 | id: '1', 137 | priority: 10, 138 | }, 139 | }, 140 | }, 141 | { 142 | type: 'action', 143 | payload: { 144 | id: '4', 145 | }, 146 | meta: { 147 | queue: { 148 | id: '1', 149 | priority: 10, 150 | }, 151 | }, 152 | }, 153 | ]); 154 | }); 155 | }); 156 | -------------------------------------------------------------------------------- /ddp-redux/src/modules/queues/reducer.js: -------------------------------------------------------------------------------- 1 | import omit from 'lodash/omit'; 2 | import findIndex from 'lodash/findIndex'; 3 | import findLastIndex from 'lodash/findLastIndex'; 4 | import { 5 | DDP_ENQUEUE, 6 | DDP_QUEUE_RESET, 7 | } from '../../constants'; 8 | 9 | export const queueReducer = (state = { 10 | elements: [], 11 | pending: {}, 12 | }, action) => { 13 | switch (action.type) { 14 | case DDP_QUEUE_RESET: 15 | return { 16 | ...state, 17 | pending: (action.payload && 18 | action.payload.initialPending) || {}, 19 | }; 20 | case DDP_ENQUEUE: 21 | return { 22 | ...state, 23 | elements: ((elements) => { 24 | // 1. elements with higher priority go first 25 | // 2. if priority is not specified, we assume 0 26 | const priority = action.meta.queue.priority || 0; 27 | const index = findLastIndex(elements, el => priority <= (el.meta.queue.priority || 0)); 28 | const { 29 | type, 30 | ...meta 31 | } = action.meta; 32 | return [ 33 | ...elements.slice(0, index + 1), 34 | { 35 | type, 36 | meta, 37 | payload: action.payload, 38 | }, 39 | ...elements.slice(index + 1), 40 | ]; 41 | })(state.elements), 42 | }; 43 | default: { 44 | const { 45 | resolve, 46 | elementId, 47 | } = action.meta.queue; 48 | if (!elementId) { 49 | return state; 50 | } 51 | if (resolve) { 52 | return { 53 | ...state, 54 | pending: omit(state.pending, elementId), 55 | }; 56 | } 57 | const index = findIndex(state.elements, el => el.meta.queue.elementId === elementId); 58 | if (index < 0) { 59 | return state; 60 | } 61 | const { pendingValue } = state.elements[index].meta.queue; 62 | return { 63 | ...state, 64 | ...pendingValue !== undefined && { 65 | pending: { 66 | ...state.pending, 67 | [elementId]: pendingValue, 68 | }, 69 | }, 70 | elements: [ 71 | ...state.elements.slice(0, index), 72 | ...state.elements.slice(index + 1), 73 | ], 74 | }; 75 | } 76 | } 77 | }; 78 | 79 | export const createReducer = () => (state = {}, action) => { 80 | const queueId = action.meta && 81 | action.meta.queue && 82 | action.meta.queue.id; 83 | if (!queueId) { 84 | return state; 85 | } 86 | const queue = state[queueId]; 87 | if (!queue && action.type !== DDP_QUEUE_RESET) { 88 | return state; 89 | } 90 | return { 91 | ...state, 92 | [queueId]: queueReducer(queue, action), 93 | }; 94 | }; 95 | -------------------------------------------------------------------------------- /ddp-redux/src/modules/queues/selectors.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apendua/ddp-redux/412b4e38de09d398ad7bcc964f920f6fcf907396/ddp-redux/src/modules/queues/selectors.js -------------------------------------------------------------------------------- /ddp-redux/src/modules/resources/index.js: -------------------------------------------------------------------------------- 1 | import { createReducer } from './reducer'; 2 | import { createMiddleware } from './middleware'; 3 | import { createSelectors } from './selectors'; 4 | 5 | export { 6 | createReducer, 7 | createMiddleware, 8 | createSelectors, 9 | }; 10 | 11 | -------------------------------------------------------------------------------- /ddp-redux/src/modules/resources/reducer.js: -------------------------------------------------------------------------------- 1 | import has from 'lodash/has'; 2 | import omit from 'lodash/omit'; 3 | import { 4 | DDP_STATE__INITIAL, 5 | DDP_STATE__PENDING, 6 | DDP_STATE__READY, 7 | DDP_STATE__OBSOLETE, 8 | DDP_STATE__RESTORING, 9 | DDP_STATE__CANCELED, 10 | 11 | DDP_DISCONNECTED, 12 | 13 | DDP_RESOURCE_REQUEST, 14 | DDP_RESOURCE_RELEASE, 15 | DDP_RESOURCE_REFETCH, 16 | DDP_RESOURCE_FETCH, 17 | 18 | DDP_RESOURCE_CREATE, 19 | DDP_RESOURCE_DELETE, 20 | DDP_RESOURCE_UPDATE, 21 | DDP_RESOURCE_DEPRECATE, 22 | } from '../../constants'; 23 | 24 | const setProperty = propName => (value) => { 25 | if (typeof value === 'function') { 26 | return (state) => { 27 | const valueToSet = value(state[propName]); 28 | return ( 29 | state[propName] === valueToSet 30 | ? state 31 | : { 32 | ...state, 33 | [propName]: valueToSet, 34 | } 35 | ); 36 | }; 37 | } 38 | return state => ( 39 | state[propName] === value 40 | ? state 41 | : { 42 | ...state, 43 | [propName]: value, 44 | } 45 | ); 46 | }; 47 | 48 | const increaseBy = value => (currentValue = 0) => currentValue + value; 49 | const increaseProperty = propName => value => setProperty(propName)(increaseBy(value)); 50 | 51 | const setState = setProperty('state'); 52 | const deleteKey = (object, key) => { 53 | if (has(object, key)) { 54 | return omit(object, key); 55 | } 56 | return object; 57 | }; 58 | 59 | const increaseUsersByOne = increaseProperty('users')(1); 60 | const decreaseUsersByOne = increaseProperty('users')(-1); 61 | 62 | const resourceReducer = (state = { 63 | state: DDP_STATE__INITIAL, 64 | }, action) => { 65 | switch (action.type) { 66 | case DDP_RESOURCE_REQUEST: 67 | return increaseUsersByOne(state); 68 | case DDP_RESOURCE_RELEASE: 69 | return decreaseUsersByOne(state); 70 | case DDP_DISCONNECTED: { 71 | switch (state.state) { 72 | case DDP_STATE__PENDING: 73 | return setState(DDP_STATE__CANCELED)(state); 74 | case DDP_STATE__READY: 75 | case DDP_STATE__RESTORING: 76 | return setState(DDP_STATE__OBSOLETE)(state); 77 | default: 78 | return state; 79 | } 80 | } 81 | case DDP_RESOURCE_CREATE: 82 | return { 83 | ...state, 84 | id: action.meta.resourceId, 85 | name: action.payload.name, 86 | params: action.payload.params, 87 | properties: action.payload.properties, 88 | }; 89 | case DDP_RESOURCE_DEPRECATE: 90 | return state.users > 0 ? state : setState(DDP_STATE__OBSOLETE)(state); 91 | case DDP_RESOURCE_REFETCH: 92 | case DDP_RESOURCE_FETCH: { 93 | switch (state.state) { 94 | case DDP_STATE__INITIAL: 95 | return setState(DDP_STATE__PENDING)(state); 96 | case DDP_STATE__READY: 97 | return setState(DDP_STATE__RESTORING)(state); 98 | default: 99 | return state; 100 | } 101 | } 102 | case DDP_RESOURCE_UPDATE: { 103 | if (!action.payload) { 104 | return state; 105 | } 106 | const { 107 | error, 108 | ...other 109 | } = action.payload; 110 | if (error) { 111 | return { 112 | ...state, 113 | error, 114 | state: DDP_STATE__CANCELED, 115 | }; 116 | } 117 | return { 118 | ...state, 119 | ...other, 120 | state: DDP_STATE__READY, 121 | }; 122 | } 123 | default: 124 | return state; 125 | } 126 | }; 127 | 128 | export const createReducer = () => (state = {}, action) => { 129 | switch (action.type) { 130 | case DDP_RESOURCE_REQUEST: 131 | case DDP_RESOURCE_RELEASE: 132 | case DDP_RESOURCE_UPDATE: 133 | case DDP_RESOURCE_REFETCH: 134 | case DDP_RESOURCE_DEPRECATE: 135 | case DDP_RESOURCE_FETCH: 136 | case DDP_DISCONNECTED: { 137 | const resourceId = action.meta && 138 | action.meta.resourceId; 139 | if (resourceId) { 140 | const resourceState = state[resourceId]; 141 | return { 142 | ...state, 143 | [resourceId]: resourceReducer(resourceState, action), 144 | }; 145 | } 146 | return state; 147 | } 148 | case DDP_RESOURCE_DELETE: 149 | return deleteKey(state, action.meta.resourceId); 150 | case DDP_RESOURCE_CREATE: { 151 | const resourceId = action.meta && 152 | action.meta.resourceId; 153 | if (resourceId) { 154 | return { 155 | ...state, 156 | [resourceId]: resourceReducer(state[resourceId], action), 157 | }; 158 | } 159 | return state; 160 | } 161 | default: 162 | return state; 163 | } 164 | }; 165 | -------------------------------------------------------------------------------- /ddp-redux/src/modules/resources/selectors.js: -------------------------------------------------------------------------------- 1 | import find from 'lodash/find'; 2 | import { createSelector } from 'reselect'; 3 | import stableMap from '../../utils/stableMap'; 4 | import EJSON from '../../ejson'; 5 | import { 6 | DEFAULT_SOCKET_ID, 7 | DDP_STATE__READY, 8 | } from '../../constants'; 9 | 10 | const constant = x => () => x; 11 | 12 | export const findResource = (resources, name, params, properties) => find( 13 | resources, 14 | x => x.name === name && 15 | EJSON.equals(x.properties, properties) && 16 | EJSON.equals(x.params, params), 17 | ); 18 | 19 | /** 20 | * Create a selector that returns a list (or object) representing 21 | * current state of the resources user is interested in. 22 | * @param {Object} options 23 | * @param {Function} options.selectDeclaredResources 24 | * @param {Function} options.selectConnectionId 25 | * @param {Function} options.emptyState 26 | */ 27 | export const createResourcesSelector = ({ 28 | selectDeclaredResources, 29 | selectConnectionId = constant(DEFAULT_SOCKET_ID), 30 | emptyState = { 31 | state: DDP_STATE__READY, 32 | }, 33 | }) => createSelector( 34 | selectDeclaredResources, 35 | selectConnectionId, 36 | state => state.ddp && state.ddp.resources, 37 | (declaredResources, socketId, resources) => (socketId 38 | ? stableMap(declaredResources, y => (y 39 | ? findResource( 40 | resources, 41 | y.name, 42 | y.params, 43 | { 44 | socketId, 45 | ...y.properties, 46 | }, 47 | ) 48 | : emptyState 49 | )) 50 | : stableMap(declaredResources, constant(null)) 51 | ), 52 | ); 53 | -------------------------------------------------------------------------------- /ddp-redux/src/modules/subscriptions/index.js: -------------------------------------------------------------------------------- 1 | import { createReducer } from './reducer'; 2 | import { createMiddleware } from './middleware'; 3 | import { createSelectors } from './selectors'; 4 | 5 | export { 6 | createReducer, 7 | createMiddleware, 8 | createSelectors, 9 | }; 10 | 11 | -------------------------------------------------------------------------------- /ddp-redux/src/modules/subscriptions/reducer.js: -------------------------------------------------------------------------------- 1 | import omit from 'lodash/omit'; 2 | import carefullyMapValues from '../../utils/carefullyMapValues'; 3 | import { 4 | DEFAULT_SOCKET_ID, 5 | 6 | DDP_SUBSCRIPTION_STATE__INITIAL, 7 | DDP_SUBSCRIPTION_STATE__QUEUED, 8 | DDP_SUBSCRIPTION_STATE__PENDING, 9 | DDP_SUBSCRIPTION_STATE__READY, 10 | DDP_SUBSCRIPTION_STATE__RESTORING, 11 | 12 | DDP_ENQUEUE, 13 | DDP_CONNECT, 14 | DDP_SUB, 15 | DDP_UNSUB, 16 | DDP_READY, 17 | DDP_NOSUB, 18 | DDP_SUBSCRIBE, 19 | DDP_UNSUBSCRIBE, 20 | } from '../../constants'; 21 | 22 | export const createReducer = () => (state = {}, action) => { 23 | switch (action.type) { 24 | case DDP_SUBSCRIBE: 25 | return (() => { 26 | const sub = state[action.meta.subId]; 27 | return { 28 | ...state, 29 | [action.meta.subId]: { 30 | id: (sub && sub.id) || action.meta.subId, 31 | state: (sub && sub.state) || DDP_SUBSCRIPTION_STATE__INITIAL, 32 | name: (sub && sub.name) || action.payload.name, 33 | params: (sub && sub.params) || action.payload.params, 34 | users: ((sub && sub.users) || 0) + 1, 35 | socketId: (sub && sub.socketId) || (action.meta && action.meta.socketId) || DEFAULT_SOCKET_ID, 36 | }, 37 | }; 38 | })(); 39 | case DDP_UNSUBSCRIBE: 40 | return state[action.meta.subId] 41 | ? { 42 | ...state, 43 | [action.meta.subId]: { 44 | ...state[action.meta.subId], 45 | users: (state[action.meta.subId].users || 0) - 1, 46 | }, 47 | } 48 | : state; 49 | case DDP_ENQUEUE: { 50 | const { subId } = action.meta; 51 | if (subId) { 52 | const sub = state[subId]; 53 | if (sub) { 54 | switch (sub.state) { 55 | case DDP_SUBSCRIPTION_STATE__INITIAL: 56 | return { 57 | ...state, 58 | [subId]: { 59 | ...sub, 60 | state: DDP_SUBSCRIPTION_STATE__QUEUED, 61 | }, 62 | }; 63 | default: 64 | return state; 65 | } 66 | } 67 | } 68 | return state; 69 | } 70 | case DDP_SUB: { 71 | if (action.meta.subId) { 72 | const { subId } = action.meta; 73 | const query = state[subId]; 74 | if (query) { 75 | switch (query.state) { 76 | case DDP_SUBSCRIPTION_STATE__INITIAL: 77 | case DDP_SUBSCRIPTION_STATE__QUEUED: 78 | return { 79 | ...state, 80 | [subId]: { 81 | ...query, 82 | state: DDP_SUBSCRIPTION_STATE__PENDING, 83 | }, 84 | }; 85 | case DDP_SUBSCRIPTION_STATE__READY: 86 | return { 87 | ...state, 88 | [subId]: { 89 | ...query, 90 | state: DDP_SUBSCRIPTION_STATE__RESTORING, 91 | }, 92 | }; 93 | default: 94 | return state; 95 | } 96 | } 97 | } 98 | return state; 99 | } 100 | case DDP_UNSUB: 101 | return omit(state, [action.meta.subId]); 102 | case DDP_NOSUB: 103 | // NOTE: If the subscription was deleted in the meantime, this will 104 | // have completely no effect. 105 | return carefullyMapValues(state, (sub, id) => { 106 | if (action.meta.subId === id) { 107 | return { 108 | ...sub, 109 | state: DDP_SUBSCRIPTION_STATE__READY, 110 | error: action.payload.error, 111 | }; 112 | } 113 | return sub; 114 | }); 115 | case DDP_READY: 116 | // NOTE: If the subscription was deleted in the meantime, this will 117 | // have completely no effect. 118 | return carefullyMapValues(state, (sub, id) => { 119 | if (action.payload.subs.indexOf(id) >= 0) { 120 | return { 121 | ...sub, 122 | state: DDP_SUBSCRIPTION_STATE__READY, 123 | }; 124 | } 125 | return sub; 126 | }); 127 | case DDP_CONNECT: 128 | return (() => { 129 | const socketId = action.meta && action.meta.socketId; 130 | return carefullyMapValues(state, (sub) => { 131 | if (sub.meta && sub.meta.socketId === socketId && sub.state === DDP_SUBSCRIPTION_STATE__READY) { 132 | return { 133 | ...sub, 134 | state: DDP_SUBSCRIPTION_STATE__RESTORING, 135 | }; 136 | } 137 | return sub; 138 | }); 139 | })(); 140 | default: 141 | return state; 142 | } 143 | }; 144 | 145 | -------------------------------------------------------------------------------- /ddp-redux/src/modules/subscriptions/selectors.js: -------------------------------------------------------------------------------- 1 | import find from 'lodash/find'; 2 | import { createSelector } from 'reselect'; 3 | import stableMap from '../../utils/stableMap'; 4 | import EJSON from '../../ejson'; 5 | import { 6 | DEFAULT_SOCKET_ID, 7 | DDP_SUBSCRIPTION_STATE__READY, 8 | } from '../../constants'; 9 | 10 | const constant = x => () => x; 11 | 12 | /** 13 | * Create a selector that returns a list (or object) representing 14 | * current state of the subscriptions user is interested in. 15 | * @param {Object} options 16 | * @param {Function} options.selectDeclaredSubscriptions 17 | * @param {Function} options.selectConnectionId 18 | * @param {Function} options.emptyState - this is used to distinguish between missing subscription and no request at all 19 | */ 20 | export const createSubscriptionsSelector = ({ 21 | selectDeclaredSubscriptions, 22 | selectConnectionId = constant(DEFAULT_SOCKET_ID), 23 | emptyState = { 24 | state: DDP_SUBSCRIPTION_STATE__READY, 25 | }, 26 | }) => createSelector( 27 | selectDeclaredSubscriptions, 28 | selectConnectionId, 29 | state => state.ddp && state.ddp.subscriptions, 30 | (subscriptions, connectionId, state) => (connectionId 31 | ? stableMap(subscriptions, y => (y 32 | ? find( 33 | state, 34 | x => x.socketId === connectionId && 35 | x.name === y.name && 36 | EJSON.equals(x.params, y.params), 37 | ) 38 | : emptyState 39 | )) 40 | : stableMap(subscriptions, constant(null)) 41 | ), 42 | ); 43 | -------------------------------------------------------------------------------- /ddp-redux/src/modules/subscriptions/testCommon.js: -------------------------------------------------------------------------------- 1 | import DDPEmitter from '../../DDPEmitter'; 2 | 3 | export class DDPClient { 4 | constructor() { 5 | this.socket = new DDPEmitter(); 6 | } 7 | 8 | getSubscriptionCleanupTimeout() { 9 | return this.constructor.getSubscriptionCleanupTimeout(); 10 | } 11 | 12 | nextUniqueId() { 13 | return this.constructor.defaultUniqueId; 14 | } 15 | 16 | static getSubscriptionCleanupTimeout() { 17 | return 1000; 18 | } 19 | } 20 | 21 | DDPClient.defaultUniqueId = '1'; 22 | -------------------------------------------------------------------------------- /ddp-redux/src/modules/thunk.js: -------------------------------------------------------------------------------- 1 | export const createMiddleware = () => 2 | store => next => action => (typeof action === 'function' 3 | ? action(store.dispatch, store.getState) 4 | : next(action) 5 | ); 6 | -------------------------------------------------------------------------------- /ddp-redux/src/modules/wrapWithPromise/index.js: -------------------------------------------------------------------------------- 1 | import { createMiddleware } from './middleware'; 2 | 3 | export { createMiddleware }; 4 | -------------------------------------------------------------------------------- /ddp-redux/src/modules/wrapWithPromise/middleware.js: -------------------------------------------------------------------------------- 1 | import forEach from 'lodash/forEach'; 2 | import DDPError from '../../DDPError'; 3 | import { 4 | DDP_CANCEL, 5 | DDP_METHOD, 6 | DDP_RESULT, 7 | DDP_UPDATED, 8 | DDP_ENQUEUE, 9 | DDP_READY, 10 | DDP_SUB, 11 | DDP_NOSUB, 12 | 13 | DDP_METHOD_STATE__UPDATED, 14 | DDP_METHOD_STATE__RETURNED, 15 | } from '../../constants'; 16 | 17 | /** 18 | * Create middleware for the given ddpClient. 19 | * @param {DDPClient} ddpClient 20 | */ 21 | export const createMiddleware = ddpClient => (store) => { 22 | const promises = {}; 23 | /** 24 | * Create a promise bound to message id. 25 | * @param {String} id 26 | * @returns {Promise} 27 | */ 28 | const createPromise = (id, meta) => { 29 | if (promises[id]) { 30 | return promises[id].promise; 31 | } 32 | promises[id] = {}; 33 | promises[id].meta = meta; 34 | promises[id].promise = new Promise((resolve, reject) => { 35 | promises[id].fulfill = (err, res) => { 36 | delete promises[id]; 37 | if (err) { 38 | reject(err); 39 | } else { 40 | resolve(res); 41 | } 42 | }; 43 | }); 44 | if (ddpClient.onPromise) { 45 | ddpClient.onPromise(promises[id].promise, promises[id].meta); 46 | } 47 | return promises[id].promise; 48 | }; 49 | /** 50 | * Fulfill an existing promise, identified by message id. 51 | * @param {String} id 52 | * @param {Object} options 53 | * @param {Error} [options.error] 54 | * @param {*} [options.result] 55 | */ 56 | const fulfill = (id, { error, result }) => { 57 | const promise = promises[id]; 58 | if (promise) { 59 | promise.fulfill( 60 | ddpClient.cleanError(error), 61 | result, 62 | ); 63 | } 64 | }; 65 | return next => (action) => { 66 | if (!action || typeof action !== 'object') { 67 | return next(action); 68 | } 69 | switch (action.type) { 70 | case DDP_CANCEL: 71 | return (() => { 72 | // cancel can result in either resolving or rejecting the promise, depending on the "error" flag 73 | fulfill(action.meta.methodId, action.error 74 | ? { 75 | error: action.payload || 76 | new DDPError(DDPError.ERROR_CANCELED, 'Method was canceled by user', action), 77 | } 78 | : { result: action.payload }); 79 | return next(action); 80 | })(); 81 | case DDP_ENQUEUE: 82 | case DDP_METHOD: 83 | case DDP_SUB: 84 | return (() => { 85 | let promise = null; 86 | if (action.type === DDP_ENQUEUE) { 87 | switch (action.meta.type) { 88 | case DDP_METHOD: 89 | case DDP_SUB: 90 | promise = createPromise(action.payload.id, action.meta); 91 | break; 92 | default: 93 | return next(action); 94 | } 95 | } else { 96 | promise = createPromise(action.payload.id, action.meta); 97 | } 98 | next(action); 99 | promise.id = action.payload.id; 100 | return promise; 101 | })(); 102 | case DDP_RESULT: 103 | return (() => { 104 | const methodId = action.payload.id; 105 | const state = store.getState(); 106 | const method = state.ddp.methods[methodId]; 107 | if (method && method.state === DDP_METHOD_STATE__UPDATED) { 108 | fulfill(methodId, { 109 | error: action.payload.error, result: action.payload.result, 110 | }); 111 | } 112 | return next(action); 113 | })(); 114 | case DDP_UPDATED: 115 | return (() => { 116 | const state = store.getState(); 117 | forEach(action.payload.methods, (methodId) => { 118 | const method = state.ddp.methods[methodId]; 119 | if (method && method.state === DDP_METHOD_STATE__RETURNED) { 120 | fulfill(methodId, { 121 | error: method.error, result: method.result, 122 | }); 123 | } 124 | }); 125 | return next(action); 126 | })(); 127 | case DDP_READY: 128 | forEach(action.payload.subs, (subId) => { 129 | fulfill(subId, {}); 130 | }); 131 | return next(action); 132 | case DDP_NOSUB: 133 | fulfill(action.payload.id, { 134 | error: action.payload.error, 135 | }); 136 | return next(action); 137 | default: 138 | return next(action); 139 | } 140 | }; 141 | }; 142 | -------------------------------------------------------------------------------- /ddp-redux/src/modules/wrapWithPromise/testCommon.js: -------------------------------------------------------------------------------- 1 | /* eslint class-methods-use-this: "off" */ 2 | import DDPEmitter from '../../DDPEmitter'; 3 | 4 | export class DDPClient extends DDPEmitter { 5 | nextUniqueId() { 6 | return '1'; 7 | } 8 | 9 | cleanError(error) { 10 | if (!error) { 11 | return null; 12 | } 13 | if (typeof error === 'string') { 14 | return new Error(error); 15 | } 16 | return new Error(error.error); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /ddp-redux/src/shim.js: -------------------------------------------------------------------------------- 1 | import flags from 'regexp.prototype.flags'; 2 | 3 | if (!RegExp.prototype.flags) { 4 | flags.shim(); 5 | } 6 | -------------------------------------------------------------------------------- /ddp-redux/src/utils/Storage.js: -------------------------------------------------------------------------------- 1 | /** @module utils/storage */ 2 | 3 | const items = new WeakMap(); 4 | const { hasOwnProperty } = Object.prototype; 5 | const has = (obj, key) => hasOwnProperty.call(obj, key); 6 | 7 | /** 8 | * Interface for key/ value store. 9 | * @interface Storage 10 | */ 11 | 12 | /** 13 | * Get value at the given key. 14 | * @function 15 | * @name Storage#get 16 | * @param {string} key 17 | * @returns {Promise} 18 | */ 19 | 20 | /** 21 | * Set value at the given key. 22 | * @function 23 | * @name Storage#set 24 | * @param {string} key 25 | * @param {any} value 26 | * @returns {Promise} 27 | */ 28 | 29 | /** 30 | * Delete value at the given key. 31 | * @function 32 | * @name Storage#del 33 | * @param {string} key 34 | * @returns {Promise} 35 | */ 36 | 37 | /** 38 | * Implements a trivial in-memory-storage that can be used 39 | * as a fallback if no other storage is available. 40 | * @private 41 | * @class 42 | * @implements {Storage} 43 | */ 44 | class Storage { 45 | constructor() { 46 | items.set(this, {}); 47 | } 48 | 49 | set(key, value) { 50 | items.get(this)[key] = value; 51 | return Promise.resolve(); 52 | } 53 | 54 | del(key) { 55 | delete items.get(this)[key]; 56 | return Promise.resolve(); 57 | } 58 | 59 | get(key) { 60 | const obj = items.get(this); 61 | if (!has(obj, key)) { 62 | return Promise.reject(new Error(`No such key: ${key}`)); 63 | } 64 | return Promise.resolve(obj[key]); 65 | } 66 | } 67 | 68 | export default Storage; 69 | -------------------------------------------------------------------------------- /ddp-redux/src/utils/carefullyMapValues.js: -------------------------------------------------------------------------------- 1 | import omit from 'lodash/omit'; 2 | import mapValues from 'lodash/mapValues'; 3 | 4 | const defaultIsEqual = (a, b) => a === b; 5 | 6 | /** 7 | * Like lodash/mapValues, but with more caution, e.g. when new value is the 8 | * the same as the old one, do not create a new object. Also gives ability 9 | * to remove selected fields. 10 | * @param {object} object to map 11 | * @param {function} mapValue 12 | * @param {function} isEqual 13 | * @returns {object} 14 | */ 15 | const carefullyMapValues = (object, mapValue, isEqual = defaultIsEqual) => { 16 | let modified = false; 17 | 18 | const toRemove = []; 19 | const remove = k => toRemove.push(k); 20 | 21 | const newObject = mapValues(object, (v, k) => { 22 | const newValue = mapValue(v, k, remove); 23 | if (isEqual(newValue, v)) { 24 | return v; 25 | } 26 | modified = true; 27 | return newValue; 28 | }); 29 | if (toRemove.length > 0) { 30 | return omit(newObject, toRemove); 31 | } 32 | if (!modified) { 33 | return object; 34 | } 35 | return newObject; 36 | }; 37 | 38 | export default carefullyMapValues; 39 | -------------------------------------------------------------------------------- /ddp-redux/src/utils/carefullyMapValues.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | import carefullyMapValues from './carefullyMapValues'; 3 | 4 | // TODO: Implement more tests. 5 | describe('Test utility - carefullyMapValues', () => { 6 | test('should remove and map at the same time', () => { 7 | expect(carefullyMapValues({ 8 | 1: 1, 9 | 2: 2, 10 | 3: 3, 11 | 4: 4, 12 | }, (value, key, remove) => { 13 | if (value % 2 === 0) { 14 | return remove(key); 15 | } 16 | return value + 1; 17 | })).toEqual({ 18 | 1: 2, 19 | 3: 4, 20 | }); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /ddp-redux/src/utils/createDelayedTask.js: -------------------------------------------------------------------------------- 1 | const constant = x => () => x; 2 | 3 | /** 4 | * Create a function that will be executed with some delay 5 | * and can be canceled in the meatime. 6 | * @param {function} execute 7 | * @param {object} options 8 | * @param {function} options.getTimeout 9 | * @returns {function} 10 | */ 11 | const createDelayedTask = (execute, { 12 | getTimeout = constant(1000), 13 | } = {}) => { 14 | const timeouts = {}; 15 | /** 16 | * Scheudle task for the given element. 17 | * @param {string} id 18 | */ 19 | const schedule = (id) => { 20 | if (timeouts[id]) { 21 | clearTimeout(timeouts[id]); 22 | } 23 | timeouts[id] = setTimeout(() => { 24 | execute(id); 25 | delete timeouts[id]; 26 | }, getTimeout(id)); 27 | }; 28 | /** 29 | * Cancel task for the given element. 30 | * @param {string} id 31 | */ 32 | const cancel = (id) => { 33 | if (timeouts[id]) { 34 | clearTimeout(timeouts[id]); 35 | delete timeouts[id]; 36 | } 37 | }; 38 | 39 | schedule.cancel = cancel; 40 | return schedule; 41 | }; 42 | 43 | export default createDelayedTask; 44 | -------------------------------------------------------------------------------- /ddp-redux/src/utils/createInsertEntities.js: -------------------------------------------------------------------------------- 1 | import forEach from 'lodash/forEach'; 2 | import without from 'lodash/without'; 3 | import isEmpty from 'lodash/isEmpty'; 4 | 5 | /** 6 | * Create a function that can be used for inseting document snapshots into ddp.collections. 7 | * @param {string} itemsFieldName 8 | * @param {string} orderFieldName 9 | */ 10 | const createInsertEntities = (itemsFieldName, orderFieldName) => { 11 | const insertIntoCollection = (state, dataSourceId, docs) => { 12 | if (!docs || isEmpty(docs)) { 13 | return state; 14 | } 15 | const nextById = { 16 | ...state && state.nextById, 17 | }; 18 | forEach(docs, (fields, docId) => { 19 | nextById[docId] = { 20 | ...nextById[docId], 21 | [itemsFieldName]: { 22 | ...nextById[docId] && nextById[docId][itemsFieldName], 23 | [dataSourceId]: fields, 24 | }, 25 | [orderFieldName]: [ 26 | ...without(nextById[docId] && nextById[docId][orderFieldName], dataSourceId), 27 | dataSourceId, 28 | ], 29 | }; 30 | }); 31 | return { 32 | ...state, 33 | nextById, 34 | needsUpdate: true, 35 | }; 36 | }; 37 | 38 | const insertEntities = (state, dataSourceId, entities) => { 39 | if (isEmpty(entities)) { 40 | return state; 41 | } 42 | const newState = { 43 | ...state, 44 | }; 45 | forEach(entities, (docs, collection) => { 46 | newState[collection] = insertIntoCollection(newState[collection], dataSourceId, docs); 47 | }); 48 | return newState; 49 | }; 50 | 51 | return insertEntities; 52 | }; 53 | 54 | export default createInsertEntities; 55 | -------------------------------------------------------------------------------- /ddp-redux/src/utils/createRemoveEntities.js: -------------------------------------------------------------------------------- 1 | import omit from 'lodash/omit'; 2 | import without from 'lodash/without'; 3 | import isEmpty from 'lodash/isEmpty'; 4 | import carefullyMapValues from './carefullyMapValues'; 5 | 6 | /** 7 | * Create a function that can be used for removing document snapshots 8 | * from ddp.collections. 9 | * @param {string} itemsFieldName 10 | * @param {string} orderFieldName 11 | */ 12 | const createRemoveEntities = (itemsFieldName, orderFieldName) => { 13 | const removeFromCollection = (state, dataSourceId, docs) => { 14 | if (!docs) { 15 | return state; 16 | } 17 | const nextById = carefullyMapValues(state.nextById, (item, id, remove) => { 18 | if (!docs[id]) { 19 | return item; 20 | } 21 | const { 22 | [itemsFieldName]: items, 23 | [orderFieldName]: order, 24 | ...other 25 | } = item; 26 | if (!items) { 27 | return item; 28 | } 29 | const newItemsOrder = without(order, dataSourceId); 30 | if (newItemsOrder.length === 0) { 31 | if (isEmpty(other)) { 32 | return remove(id); 33 | } 34 | return other; 35 | } 36 | return { 37 | ...other, 38 | [itemsFieldName]: omit(items, dataSourceId), 39 | [orderFieldName]: newItemsOrder, 40 | }; 41 | }); 42 | return { 43 | ...state, 44 | nextById, 45 | needsUpdate: true, 46 | }; 47 | }; 48 | 49 | const removeEntities = (state, dataSourceId, entities) => { 50 | if (!entities || isEmpty(entities)) { 51 | return state; 52 | } 53 | return carefullyMapValues(state, (collection, name) => 54 | removeFromCollection(collection, dataSourceId, entities[name])); 55 | }; 56 | 57 | return removeEntities; 58 | }; 59 | 60 | export default createRemoveEntities; 61 | -------------------------------------------------------------------------------- /ddp-redux/src/utils/createValuesMappingSelector.js: -------------------------------------------------------------------------------- 1 | import { defaultMemoize } from 'reselect'; 2 | import memoizeValuesMapping from './memoizeValuesMapping'; 3 | 4 | const defaultIsEqual = (a, b) => a === b; 5 | 6 | const createValuesMappingSelector = (selectObject, mapOneValue, isEqual = defaultIsEqual) => { 7 | let recomputations = 0; 8 | const memoizedMapValues = memoizeValuesMapping((value, key) => { 9 | recomputations += 1; 10 | return mapOneValue(value, key); 11 | }, isEqual); 12 | const selector = defaultMemoize((...args) => memoizedMapValues(selectObject(...args))); 13 | selector.recomputations = () => recomputations; 14 | selector.resetRecomputations = () => { 15 | recomputations = 0; 16 | }; 17 | return selector; 18 | }; 19 | 20 | export default createValuesMappingSelector; 21 | -------------------------------------------------------------------------------- /ddp-redux/src/utils/createValuesMappingSelector.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | 3 | import createValuesMappingSelector from './createValuesMappingSelector'; 4 | 5 | const constant = x => () => x; 6 | const identity = x => x; 7 | 8 | describe('Test utility - createValuesMappingSelector', () => { 9 | let testContext; 10 | 11 | beforeEach(() => { 12 | testContext = {}; 13 | }); 14 | 15 | beforeEach(() => { 16 | testContext.object = {}; 17 | testContext.identity = createValuesMappingSelector(identity, identity); 18 | testContext.constant = createValuesMappingSelector(identity, constant(testContext.object)); 19 | }); 20 | 21 | describe('Given an empty object', () => { 22 | test('should not be changed by identity mapping', () => { 23 | const x = {}; 24 | expect(testContext.identity(x)).toBe(x); 25 | }); 26 | 27 | test('should not be changed by constant mapping', () => { 28 | const x = {}; 29 | expect(testContext.constant(x)).toBe(x); 30 | }); 31 | 32 | test( 33 | 'should return the same result when called with similar argument', 34 | () => { 35 | const x = {}; 36 | const y = {}; 37 | expect(testContext.constant(x)).toBe(testContext.constant(y)); 38 | }, 39 | ); 40 | }); 41 | 42 | describe('Given a non-empty object', () => { 43 | test('should not be changed by identity mapping', () => { 44 | const x = { 45 | a: {}, 46 | b: {}, 47 | }; 48 | expect(testContext.identity(x)).toBe(x); 49 | }); 50 | test('should be changed by constant mapping', () => { 51 | const x = { 52 | a: {}, 53 | b: {}, 54 | }; 55 | expect(testContext.constant(x)).not.toBe(x); 56 | }); 57 | test( 58 | 'should return the same result when called with similar argument', 59 | () => { 60 | const x = { 61 | a: {}, 62 | b: {}, 63 | }; 64 | const y = { 65 | ...x, 66 | }; 67 | expect(testContext.constant(x)).toBe(testContext.constant(y)); 68 | }, 69 | ); 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /ddp-redux/src/utils/memoizeValuesMapping.js: -------------------------------------------------------------------------------- 1 | import shallowEqual from 'shallowequal'; 2 | import carefullyMapValues from './carefullyMapValues'; 3 | 4 | const defaultIsEqual = (a, b) => a === b; 5 | 6 | const memoizeValuesMapping = (mapOneValue, isEqual = defaultIsEqual) => { 7 | let lastInput = null; 8 | let lastResult = null; 9 | return (input) => { 10 | if (!lastResult) { 11 | lastResult = input; 12 | } 13 | const result = carefullyMapValues(input, (value, key) => { 14 | const lastValue = lastResult && lastResult[key]; 15 | if (lastInput && lastInput[key] === value) { 16 | return lastValue; 17 | } 18 | return mapOneValue(value, key); 19 | }, isEqual); 20 | lastInput = input; 21 | if (!shallowEqual(result, lastResult)) { 22 | lastResult = result; 23 | } 24 | return lastResult; 25 | }; 26 | }; 27 | 28 | export default memoizeValuesMapping; 29 | -------------------------------------------------------------------------------- /ddp-redux/src/utils/memoizeValuesMapping.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | import memoizeValuesMapping from './memoizeValuesMapping'; 3 | 4 | const constant = x => () => x; 5 | const identity = x => x; 6 | 7 | describe('Test utility - memoizeValuesMapping', () => { 8 | let testContext; 9 | 10 | beforeEach(() => { 11 | testContext = {}; 12 | }); 13 | 14 | beforeEach(() => { 15 | testContext.object = {}; 16 | testContext.identity = memoizeValuesMapping(identity); 17 | testContext.constant = memoizeValuesMapping(constant(testContext.object)); 18 | }); 19 | 20 | describe('Given an empty object', () => { 21 | test('should not be changed by identity mapping', () => { 22 | const x = {}; 23 | expect(testContext.identity(x)).toBe(x); 24 | }); 25 | 26 | test('should not be changed by constant mapping', () => { 27 | const x = {}; 28 | expect(testContext.constant(x)).toBe(x); 29 | }); 30 | 31 | test( 32 | 'should return the same result when called with similar argument', 33 | () => { 34 | const x = {}; 35 | const y = {}; 36 | expect(testContext.constant(x)).toBe(testContext.constant(y)); 37 | }, 38 | ); 39 | }); 40 | 41 | describe('Given a non-empty object', () => { 42 | test('should not be changed by identity mapping', () => { 43 | const x = { 44 | a: {}, 45 | b: {}, 46 | }; 47 | expect(testContext.identity(x)).toBe(x); 48 | }); 49 | test('should be changed by constant mapping', () => { 50 | const x = { 51 | a: {}, 52 | b: {}, 53 | }; 54 | expect(testContext.constant(x)).not.toBe(x); 55 | }); 56 | test( 57 | 'should return the same result when called with similar argument', 58 | () => { 59 | const x = { 60 | a: {}, 61 | b: {}, 62 | }; 63 | const y = { 64 | ...x, 65 | }; 66 | expect(testContext.constant(x)).toBe(testContext.constant(y)); 67 | }, 68 | ); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /ddp-redux/src/utils/sha256.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | /** 4 | * 5 | * Secure Hash Algorithm (SHA256) 6 | * http://www.webtoolkit.info/javascript-sha256.html 7 | * http://anmar.eu.org/projects/jssha2/ 8 | * 9 | * Original code by Angel Marin, Paul Johnston. 10 | * 11 | **/ 12 | 13 | function SHA256(s) { 14 | 15 | var chrsz = 8; 16 | var hexcase = 0; 17 | 18 | function safe_add (x, y) { 19 | var lsw = (x & 0xFFFF) + (y & 0xFFFF); 20 | var msw = (x >> 16) + (y >> 16) + (lsw >> 16); 21 | return (msw << 16) | (lsw & 0xFFFF); 22 | } 23 | 24 | function S (X, n) { return ( X >>> n ) | (X << (32 - n)); } 25 | function R (X, n) { return ( X >>> n ); } 26 | function Ch(x, y, z) { return ((x & y) ^ ((~x) & z)); } 27 | function Maj(x, y, z) { return ((x & y) ^ (x & z) ^ (y & z)); } 28 | function Sigma0256(x) { return (S(x, 2) ^ S(x, 13) ^ S(x, 22)); } 29 | function Sigma1256(x) { return (S(x, 6) ^ S(x, 11) ^ S(x, 25)); } 30 | function Gamma0256(x) { return (S(x, 7) ^ S(x, 18) ^ R(x, 3)); } 31 | function Gamma1256(x) { return (S(x, 17) ^ S(x, 19) ^ R(x, 10)); } 32 | 33 | function core_sha256 (m, l) { 34 | var K = new Array(0x428A2F98, 0x71374491, 0xB5C0FBCF, 0xE9B5DBA5, 0x3956C25B, 0x59F111F1, 0x923F82A4, 0xAB1C5ED5, 0xD807AA98, 0x12835B01, 0x243185BE, 0x550C7DC3, 0x72BE5D74, 0x80DEB1FE, 0x9BDC06A7, 0xC19BF174, 0xE49B69C1, 0xEFBE4786, 0xFC19DC6, 0x240CA1CC, 0x2DE92C6F, 0x4A7484AA, 0x5CB0A9DC, 0x76F988DA, 0x983E5152, 0xA831C66D, 0xB00327C8, 0xBF597FC7, 0xC6E00BF3, 0xD5A79147, 0x6CA6351, 0x14292967, 0x27B70A85, 0x2E1B2138, 0x4D2C6DFC, 0x53380D13, 0x650A7354, 0x766A0ABB, 0x81C2C92E, 0x92722C85, 0xA2BFE8A1, 0xA81A664B, 0xC24B8B70, 0xC76C51A3, 0xD192E819, 0xD6990624, 0xF40E3585, 0x106AA070, 0x19A4C116, 0x1E376C08, 0x2748774C, 0x34B0BCB5, 0x391C0CB3, 0x4ED8AA4A, 0x5B9CCA4F, 0x682E6FF3, 0x748F82EE, 0x78A5636F, 0x84C87814, 0x8CC70208, 0x90BEFFFA, 0xA4506CEB, 0xBEF9A3F7, 0xC67178F2); 35 | var HASH = new Array(0x6A09E667, 0xBB67AE85, 0x3C6EF372, 0xA54FF53A, 0x510E527F, 0x9B05688C, 0x1F83D9AB, 0x5BE0CD19); 36 | var W = new Array(64); 37 | var a, b, c, d, e, f, g, h, i, j; 38 | var T1, T2; 39 | 40 | m[l >> 5] |= 0x80 << (24 - l % 32); 41 | m[((l + 64 >> 9) << 4) + 15] = l; 42 | 43 | for ( var i = 0; i>5] |= (str.charCodeAt(i / chrsz) & mask) << (24 - i%32); 87 | } 88 | return bin; 89 | } 90 | 91 | function Utf8Encode(string) { 92 | // METEOR change: 93 | // The webtoolkit.info version of this code added this 94 | // Utf8Encode function (which does seem necessary for dealing 95 | // with arbitrary Unicode), but the following line seems 96 | // problematic: 97 | // 98 | // string = string.replace(/\r\n/g,"\n"); 99 | var utftext = ""; 100 | 101 | for (var n = 0; n < string.length; n++) { 102 | 103 | var c = string.charCodeAt(n); 104 | 105 | if (c < 128) { 106 | utftext += String.fromCharCode(c); 107 | } 108 | else if((c > 127) && (c < 2048)) { 109 | utftext += String.fromCharCode((c >> 6) | 192); 110 | utftext += String.fromCharCode((c & 63) | 128); 111 | } 112 | else { 113 | utftext += String.fromCharCode((c >> 12) | 224); 114 | utftext += String.fromCharCode(((c >> 6) & 63) | 128); 115 | utftext += String.fromCharCode((c & 63) | 128); 116 | } 117 | 118 | } 119 | 120 | return utftext; 121 | } 122 | 123 | function binb2hex (binarray) { 124 | var hex_tab = hexcase ? "0123456789ABCDEF" : "0123456789abcdef"; 125 | var str = ""; 126 | for(var i = 0; i < binarray.length * 4; i++) { 127 | str += hex_tab.charAt((binarray[i>>2] >> ((3 - i%4)*8+4)) & 0xF) + 128 | hex_tab.charAt((binarray[i>>2] >> ((3 - i%4)*8 )) & 0xF); 129 | } 130 | return str; 131 | } 132 | 133 | s = Utf8Encode(s); 134 | return binb2hex(core_sha256(str2binb(s), s.length * chrsz)); 135 | } 136 | 137 | export default SHA256; 138 | -------------------------------------------------------------------------------- /ddp-redux/src/utils/stableMap.js: -------------------------------------------------------------------------------- 1 | import map from 'lodash/map'; 2 | import mapValues from 'lodash/mapValues'; 3 | import isArray from 'lodash/isArray'; 4 | 5 | const stableMap = (collection, ...args) => (isArray(collection) 6 | ? map(collection, ...args) 7 | : mapValues(collection, ...args) 8 | ); 9 | 10 | export default stableMap; 11 | -------------------------------------------------------------------------------- /example/.tmux.conf: -------------------------------------------------------------------------------- 1 | # Configuration for tmux 2.1 2 | 3 | set -g mouse on 4 | 5 | bind -t vi-copy WheelUpPane halfpage-up 6 | bind -t vi-copy WheelDownPane halfpage-down 7 | 8 | # Copy & Paste 9 | 10 | setw -g mode-keys vi 11 | 12 | # Setup 'v' to begin selection as in Vim 13 | bind-key -t vi-copy v begin-selection 14 | bind-key -t vi-copy y copy-pipe "reattach-to-user-namespace pbcopy" 15 | 16 | # Update default binding of `Enter` to also use copy-pipe 17 | unbind -t vi-copy Enter 18 | bind-key -t vi-copy Enter copy-pipe "reattach-to-user-namespace pbcopy" 19 | -------------------------------------------------------------------------------- /example/backend/.eslintrc.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs'); 2 | var path = require('path'); 3 | var packages = path.resolve(__dirname, '.meteor', 'packages'); 4 | 5 | module.exports = { 6 | settings: { 7 | 'import/resolver': 'meteor', 8 | 'import/core-modules': fs.readFileSync(packages, 'utf-8') 9 | .split('\n') 10 | .filter(name => name.charAt(0) !=='#') 11 | .filter(name => name.length > 0) 12 | .map(name => name.indexOf('@') > -1 ? name.split('@')[0] : name) 13 | .map(name => 'meteor/' + name) 14 | .concat(['meteor/meteor']) 15 | }, 16 | }; -------------------------------------------------------------------------------- /example/backend/.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | -------------------------------------------------------------------------------- /example/backend/.meteor/.finished-upgraders: -------------------------------------------------------------------------------- 1 | # This file contains information which helps Meteor properly upgrade your 2 | # app when you run 'meteor update'. You should check it into version control 3 | # with your project. 4 | 5 | notices-for-0.9.0 6 | notices-for-0.9.1 7 | 0.9.4-platform-file 8 | notices-for-facebook-graph-api-2 9 | 1.2.0-standard-minifiers-package 10 | 1.2.0-meteor-platform-split 11 | 1.2.0-cordova-changes 12 | 1.2.0-breaking-changes 13 | 1.3.0-split-minifiers-package 14 | 1.4.0-remove-old-dev-bundle-link 15 | 1.4.1-add-shell-server-package 16 | 1.4.3-split-account-service-packages 17 | 1.5-add-dynamic-import-package 18 | -------------------------------------------------------------------------------- /example/backend/.meteor/.gitignore: -------------------------------------------------------------------------------- 1 | local 2 | -------------------------------------------------------------------------------- /example/backend/.meteor/.id: -------------------------------------------------------------------------------- 1 | # This file contains a token that is unique to your project. 2 | # Check it into your repository along with the rest of this directory. 3 | # It can be used for purposes such as: 4 | # - ensuring you don't accidentally deploy one app on top of another 5 | # - providing package authors with aggregated statistics 6 | 7 | 57jic3xl3ucqmaaveg 8 | -------------------------------------------------------------------------------- /example/backend/.meteor/packages: -------------------------------------------------------------------------------- 1 | # Meteor packages used by this project, one per line. 2 | # Check this file (and the other files in this directory) into your repository. 3 | # 4 | # 'meteor add' and 'meteor remove' will edit this file for you, 5 | # but you can also edit it by hand. 6 | 7 | meteor-base@1.2.0 # Packages every Meteor app needs to have 8 | mobile-experience@1.0.5 # Packages for a great mobile UX 9 | mongo@1.3.1 # The database Meteor supports right now 10 | blaze-html-templates@1.0.4 # Compile .html files into Meteor Blaze views 11 | reactive-var@1.0.11 # Reactive variable for tracker 12 | tracker@1.1.3 # Meteor's client-side reactive programming library 13 | 14 | standard-minifier-css@1.3.5 # CSS minifier run for production mode 15 | standard-minifier-js@2.2.0 # JS minifier run for production mode 16 | es5-shim@4.6.15 # ECMAScript 5 compatibility for older browsers. 17 | ecmascript@0.9.0 # Enable ECMAScript2015+ syntax in app code 18 | shell-server@0.3.0 # Server-side component of the `meteor shell` command 19 | 20 | insecure@1.0.7 # Allow all DB writes from clients (for prototyping) 21 | mdg:validation-error 22 | mdg:validated-method 23 | aldeed:simple-schema 24 | aldeed:collection2 25 | dynamic-import@0.2.0 26 | accounts-base 27 | accounts-password 28 | meteorhacks:aggregate 29 | -------------------------------------------------------------------------------- /example/backend/.meteor/platforms: -------------------------------------------------------------------------------- 1 | server 2 | browser 3 | -------------------------------------------------------------------------------- /example/backend/.meteor/release: -------------------------------------------------------------------------------- 1 | METEOR@1.6.0.1 2 | -------------------------------------------------------------------------------- /example/backend/.meteor/versions: -------------------------------------------------------------------------------- 1 | accounts-base@1.4.0 2 | accounts-password@1.5.0 3 | aldeed:collection2@2.10.0 4 | aldeed:collection2-core@1.2.0 5 | aldeed:schema-deny@1.1.0 6 | aldeed:schema-index@1.1.1 7 | aldeed:simple-schema@1.5.3 8 | allow-deny@1.1.0 9 | autoupdate@1.3.12 10 | babel-compiler@6.24.7 11 | babel-runtime@1.1.1 12 | base64@1.0.10 13 | binary-heap@1.0.10 14 | blaze@2.3.2 15 | blaze-html-templates@1.1.2 16 | blaze-tools@1.0.10 17 | boilerplate-generator@1.3.1 18 | caching-compiler@1.1.9 19 | caching-html-compiler@1.1.2 20 | callback-hook@1.0.10 21 | check@1.2.5 22 | ddp@1.4.0 23 | ddp-client@2.2.0 24 | ddp-common@1.3.0 25 | ddp-rate-limiter@1.0.7 26 | ddp-server@2.1.1 27 | deps@1.0.12 28 | diff-sequence@1.0.7 29 | dynamic-import@0.2.1 30 | ecmascript@0.9.0 31 | ecmascript-runtime@0.5.0 32 | ecmascript-runtime-client@0.5.0 33 | ecmascript-runtime-server@0.5.0 34 | ejson@1.1.0 35 | email@1.2.3 36 | es5-shim@4.6.15 37 | geojson-utils@1.0.10 38 | hot-code-push@1.0.4 39 | html-tools@1.0.11 40 | htmljs@1.0.11 41 | http@1.3.0 42 | id-map@1.0.9 43 | insecure@1.0.7 44 | jquery@1.11.10 45 | launch-screen@1.1.1 46 | livedata@1.0.18 47 | localstorage@1.2.0 48 | logging@1.1.19 49 | mdg:validated-method@1.1.0 50 | mdg:validation-error@0.5.1 51 | meteor@1.8.2 52 | meteor-base@1.2.0 53 | meteorhacks:aggregate@1.3.0 54 | meteorhacks:collection-utils@1.2.0 55 | minifier-css@1.2.16 56 | minifier-js@2.2.2 57 | minimongo@1.4.3 58 | mobile-experience@1.0.5 59 | mobile-status-bar@1.0.14 60 | modules@0.11.1 61 | modules-runtime@0.9.1 62 | mongo@1.3.1 63 | mongo-dev-server@1.1.0 64 | mongo-id@1.0.6 65 | mongo-livedata@1.0.12 66 | npm-bcrypt@0.9.3 67 | npm-mongo@2.2.33 68 | observe-sequence@1.0.16 69 | ordered-dict@1.0.9 70 | promise@0.10.0 71 | raix:eventemitter@0.1.3 72 | random@1.0.10 73 | rate-limit@1.0.8 74 | reactive-var@1.0.11 75 | reload@1.1.11 76 | retry@1.0.9 77 | routepolicy@1.0.12 78 | service-configuration@1.0.11 79 | sha@1.0.9 80 | shell-server@0.3.1 81 | spacebars@1.0.15 82 | spacebars-compiler@1.1.3 83 | srp@1.0.10 84 | standard-minifier-css@1.3.5 85 | standard-minifier-js@2.2.3 86 | templating@1.3.2 87 | templating-compiler@1.3.3 88 | templating-runtime@1.3.2 89 | templating-tools@1.1.2 90 | tracker@1.1.3 91 | ui@1.0.13 92 | underscore@1.0.10 93 | url@1.1.0 94 | webapp@1.4.0 95 | webapp-hashing@1.0.9 96 | -------------------------------------------------------------------------------- /example/backend/imports/api/TodoLists.js: -------------------------------------------------------------------------------- 1 | import TodoLists from '/imports/collections/TodoLists'; 2 | import * as api from '/imports/common/api/TodoLists'; 3 | import publish from './publish'; 4 | import implement from './implement'; 5 | 6 | implement(api.insert, { 7 | run({ 8 | title, 9 | }) { 10 | return TodoLists.insert({ 11 | title, 12 | userId: this.userId, 13 | }); 14 | }, 15 | }); 16 | 17 | implement(api.update, { 18 | run({ 19 | listId, 20 | title, 21 | }) { 22 | return TodoLists.update({ 23 | _id: listId, 24 | userId: this.userId, 25 | }, { 26 | $set: { 27 | title, 28 | }, 29 | }); 30 | }, 31 | }); 32 | 33 | implement(api.remove, { 34 | run({ 35 | listId, 36 | }) { 37 | return TodoLists.remove({ 38 | _id: listId, 39 | userId: this.userId, 40 | }); 41 | }, 42 | }); 43 | 44 | publish(api.allLists, { 45 | run() { 46 | return TodoLists.find({ 47 | userId: this.userId, 48 | }); 49 | }, 50 | }); 51 | 52 | publish(api.oneList, { 53 | run({ listId }) { 54 | return TodoLists.find({ 55 | _id: listId, 56 | userId: this.userId, 57 | }); 58 | }, 59 | }); 60 | 61 | -------------------------------------------------------------------------------- /example/backend/imports/api/Todos.js: -------------------------------------------------------------------------------- 1 | import Todos from '/imports/collections/Todos'; 2 | import TodoList from '/imports/common/models/TodoList'; 3 | import * as api from '/imports/common/api/Todos'; 4 | import publish from './publish'; 5 | import implement from './implement'; 6 | 7 | implement(api.insert, { 8 | run({ 9 | name, 10 | done, 11 | listId, 12 | }) { 13 | Todos.insert({ 14 | name, 15 | done, 16 | listId, 17 | userId: this.userId, 18 | }); 19 | }, 20 | }); 21 | 22 | implement(api.update, { 23 | run({ 24 | todoId, 25 | name, 26 | done, 27 | }) { 28 | const mutation = { 29 | $set: { name }, 30 | }; 31 | if (!done) { 32 | mutation.$unset = { done: 1 }; 33 | } else { 34 | mutation.$set.done = true; 35 | } 36 | Todos.update({ 37 | _id: todoId, 38 | userId: this.userId, 39 | }, mutation); 40 | }, 41 | }); 42 | 43 | implement(api.remove, { 44 | run({ 45 | todoId, 46 | }) { 47 | Todos.remove({ 48 | _id: todoId, 49 | userId: this.userId, 50 | }); 51 | }, 52 | }); 53 | 54 | publish(api.todosInList, { 55 | run({ listId }) { 56 | return Todos.find({ 57 | listId, 58 | userId: this.userId, 59 | }); 60 | }, 61 | }); 62 | 63 | implement(api.getStats, { 64 | run() { 65 | const results = Todos.aggregate([ 66 | { $match: { 67 | userId: this.userId, 68 | } }, 69 | { $group: { 70 | _id: { 71 | listId: '$listId', 72 | state: { 73 | $cond: { 74 | if: '$done', 75 | then: 'completed', 76 | else: 'active', 77 | }, 78 | }, 79 | }, 80 | count: { $sum: 1 }, 81 | } }, 82 | ]); 83 | const byListId = {}; 84 | results.forEach(({ _id: { listId, state }, count }) => { 85 | if (!byListId[listId]) { 86 | byListId[listId] = {}; 87 | } 88 | byListId[listId][state] = count; 89 | }); 90 | return { 91 | entities: { 92 | [TodoList.collection]: byListId, 93 | }, 94 | }; 95 | }, 96 | }); 97 | -------------------------------------------------------------------------------- /example/backend/imports/api/implement.js: -------------------------------------------------------------------------------- 1 | import { Meteor } from 'meteor/meteor'; 2 | import { ValidatedMethod } from 'meteor/mdg:validated-method'; 3 | import { ValidationError } from 'meteor/mdg:validation-error'; 4 | 5 | function errors(methodOptions) { 6 | const originalRun = methodOptions.run; 7 | return { 8 | ...methodOptions, 9 | run(...args) { 10 | this.error = function (error, reason, details) { 11 | return new Meteor.Error(`${methodOptions.name}.${error}`, reason, details); 12 | }; 13 | return originalRun.call(this, ...args); 14 | }, 15 | }; 16 | } 17 | 18 | const implement = (method, { mixins = [], onServer, ...options } = {}) => { 19 | const adjustedOptions = { ...options }; 20 | const name = method.getName(); 21 | if (onServer) { 22 | let func = () => {}; 23 | if (Meteor.isServer) { 24 | func = onServer(); 25 | } 26 | if (!func) { 27 | throw new Error(`Invalid method definition, check "onServer" for ${method.getName()}`); 28 | } 29 | if (options.run) { 30 | throw new Error('When "onServer" is provided, "run" is not allowed'); 31 | } 32 | adjustedOptions.run = function (...args) { 33 | if (!this.userId) { 34 | throw new Meteor.Error(`${name}.notAllowed`, 'You must be logged in'); 35 | } 36 | return func(this, ...args); 37 | }; 38 | } 39 | return new ValidatedMethod({ 40 | name, 41 | validate: method.getValidator(ValidationError), 42 | mixins: [ 43 | ...mixins, 44 | errors, 45 | ], 46 | ...adjustedOptions, 47 | }); 48 | }; 49 | 50 | export default implement; 51 | -------------------------------------------------------------------------------- /example/backend/imports/api/index.js: -------------------------------------------------------------------------------- 1 | import './TodoLists.js'; 2 | import './Todos.js'; 3 | -------------------------------------------------------------------------------- /example/backend/imports/api/publish.js: -------------------------------------------------------------------------------- 1 | import { Meteor } from 'meteor/meteor'; 2 | import { ValidationError } from 'meteor/mdg:validation-error'; 3 | 4 | const publish = (apiSpec, { run }) => { 5 | const name = apiSpec.getName(); 6 | const validate = apiSpec.getValidator(ValidationError); 7 | Meteor.publish(name, function (params) { 8 | validate(params); 9 | return run.call(this, params); 10 | }); 11 | }; 12 | 13 | export default publish; 14 | -------------------------------------------------------------------------------- /example/backend/imports/collections/TodoLists.js: -------------------------------------------------------------------------------- 1 | import { SimpleSchema } from 'meteor/aldeed:simple-schema'; 2 | import { Mongo } from 'meteor/mongo'; 3 | import CreatedUpdatedSchema from '../schema/CreatedUpdatedSchema'; 4 | import TodoList from '../common/models/TodoList'; 5 | 6 | const TodoLists = new Mongo.Collection(TodoList.collection, { 7 | transform: doc => new TodoList(doc), 8 | }); 9 | 10 | TodoLists.schema = new SimpleSchema([CreatedUpdatedSchema, { 11 | userId: { type: String, regEx: SimpleSchema.RegEx.Id }, 12 | title: { type: String }, 13 | }]); 14 | 15 | TodoLists.attachSchema(TodoLists.schema); 16 | 17 | export default TodoLists; 18 | -------------------------------------------------------------------------------- /example/backend/imports/collections/Todos.js: -------------------------------------------------------------------------------- 1 | import { SimpleSchema } from 'meteor/aldeed:simple-schema'; 2 | import { Mongo } from 'meteor/mongo'; 3 | import CreatedUpdatedSchema from '../schema/CreatedUpdatedSchema'; 4 | import Todo from '../common/models/Todo'; 5 | 6 | const Todos = new Mongo.Collection(Todo.collection, { 7 | transform: doc => new Todo(doc), 8 | }); 9 | 10 | Todos.schema = new SimpleSchema([CreatedUpdatedSchema, { 11 | userId: { type: String, regEx: SimpleSchema.RegEx.Id }, 12 | listId: { type: String, regEx: SimpleSchema.RegEx.Id }, 13 | name: { type: String }, 14 | done: { type: Boolean, optional: true }, 15 | }]); 16 | 17 | Todos.attachSchema(Todos.schema); 18 | 19 | export default Todos; 20 | -------------------------------------------------------------------------------- /example/backend/imports/common: -------------------------------------------------------------------------------- 1 | ../../todo-web/src/common -------------------------------------------------------------------------------- /example/backend/imports/schema/CreatedUpdatedSchema.js: -------------------------------------------------------------------------------- 1 | import { SimpleSchema } from 'meteor/aldeed:simple-schema'; 2 | import { 3 | setDateOnInsert, 4 | setUserOnInsert, 5 | setDateOnUpdate, 6 | setUserOnUpdate, 7 | } from './autoValue.js'; 8 | 9 | const CreatedUpdatedSchema = new SimpleSchema({ 10 | 11 | createdAt: { type: Date, autoValue: setDateOnInsert }, 12 | createdBy: { type: String, optional: true, regEx: SimpleSchema.RegEx.Id, autoValue: setUserOnInsert }, 13 | 14 | updatedAt: { type: Date, optional: true, autoValue: setDateOnUpdate }, 15 | updatedBy: { type: String, optional: true, regEx: SimpleSchema.RegEx.Id, autoValue: setUserOnUpdate }, 16 | 17 | }); 18 | 19 | export default CreatedUpdatedSchema; 20 | -------------------------------------------------------------------------------- /example/backend/imports/schema/autoValue.js: -------------------------------------------------------------------------------- 1 | export function setUserOnInsert() { 2 | if (!this.isSet && !this.isUpdate) { 3 | return this.userId; 4 | } 5 | return undefined; 6 | } 7 | 8 | export function setDateOnInsert() { 9 | if (!this.isSet && !this.isUpdate) { 10 | return new Date(); 11 | } 12 | return undefined; 13 | } 14 | 15 | export function setUserOnUpdate() { 16 | if (this.isUpdate || this.isUpsert) { 17 | return this.userId; 18 | } 19 | return undefined; 20 | } 21 | 22 | export function setDateOnUpdate() { 23 | if (this.isUpdate || this.isUpsert) { 24 | return new Date(); 25 | } 26 | return undefined; 27 | } 28 | -------------------------------------------------------------------------------- /example/backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server", 3 | "private": true, 4 | "scripts": { 5 | "start": "meteor run" 6 | }, 7 | "dependencies": { 8 | "babel-runtime": "^6.25.0", 9 | "bcrypt": "^1.0.3", 10 | "ddp-redux": "file:../../ddp-redux", 11 | "flat": "^2.0.1", 12 | "lodash": "^4.17.4", 13 | "meteor-node-stubs": "~0.2.4", 14 | "react": "^15.6.1", 15 | "reselect": "^3.0.1", 16 | "very-simple-schema": "0.0.8" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /example/backend/server/main.js: -------------------------------------------------------------------------------- 1 | import '/imports/api'; 2 | -------------------------------------------------------------------------------- /example/start-develop.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | tmux new-session -d -n "example" -s example 3 | tmux source-file .tmux.conf 4 | tmux new-window -n "backend" -t example:1 5 | tmux new-window -n "todo-web" -t example:2 6 | tmux new-window -n "ddp-redux" -t example:3 7 | tmux new-window -n "ddp-connector" -t example:4 8 | tmux send-keys -t example:1 "cd backend" C-m 9 | tmux send-keys -t example:1 "meteor npm i" C-m 10 | tmux send-keys -t example:1 "meteor --port 4000" C-m 11 | tmux send-keys -t example:2 "cd todo-web" C-m 12 | tmux send-keys -t example:2 "npm install" C-m 13 | tmux send-keys -t example:2 "npm run start" C-m 14 | tmux send-keys -t example:3 "cd ../ddp-redux" C-m 15 | tmux send-keys -t example:3 "npm install" C-m 16 | tmux send-keys -t example:3 "npm run build-watch" C-m 17 | tmux send-keys -t example:4 "cd ../ddp-connector" C-m 18 | tmux send-keys -t example:4 "npm install" C-m 19 | tmux send-keys -t example:4 "npm run build-watch" C-m 20 | tmux attach-session -d 21 | -------------------------------------------------------------------------------- /example/start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | ( 3 | cd ../ddp-connector 4 | npm install 5 | npm run build 6 | ) 7 | ( 8 | cd ../ddp-redux 9 | npm install 10 | npm run build 11 | ) 12 | tmux new-session -d -n "example" -s example 13 | tmux source-file .tmux.conf 14 | tmux new-window -n "backend" -t example:1 15 | tmux new-window -n "todo-web" -t example:2 16 | tmux send-keys -t example:1 "cd backend" C-m 17 | tmux send-keys -t example:1 "meteor npm i" C-m 18 | tmux send-keys -t example:1 "meteor --port 4000" C-m 19 | tmux send-keys -t example:2 "cd todo-web" C-m 20 | tmux send-keys -t example:2 "npm install" C-m 21 | tmux send-keys -t example:2 "npm run start" C-m 22 | tmux attach-session -d 23 | -------------------------------------------------------------------------------- /example/todo-web/.env: -------------------------------------------------------------------------------- 1 | REACT_APP_ENDPOINT=ws://localhost:4000/websocket 2 | -------------------------------------------------------------------------------- /example/todo-web/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb", 3 | "rules": { 4 | "import/prefer-default-export": "off", 5 | "react/jsx-filename-extension": "off" 6 | }, 7 | "parser": "babel-eslint", 8 | "parserOptions": { 9 | "ecmaVersion": 7, 10 | "sourceType": "module", 11 | "ecmaFeatures": { 12 | "experimentalObjectRestSpread": true 13 | } 14 | }, 15 | "env": { 16 | "es6": true, 17 | "browser": true, 18 | "node": true 19 | }, 20 | "plugins": [], 21 | "settings": {} 22 | } 23 | -------------------------------------------------------------------------------- /example/todo-web/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules/* 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | -------------------------------------------------------------------------------- /example/todo-web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "todo-web", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "antd": "^3.0.2", 7 | "ddp-connector": "file:../../ddp-connector", 8 | "ddp-redux": "file:../../ddp-redux", 9 | "eslint": "^4.3.0", 10 | "eslint-config-airbnb": "^15.1.0", 11 | "eslint-plugin-import": "^2.7.0", 12 | "eslint-plugin-jsx-a11y": "^5.1.1", 13 | "eslint-plugin-react": "^7.1.0", 14 | "final-form": "^3.0.0", 15 | "flat": "^4.0.0", 16 | "lodash.some": "^4.6.0", 17 | "react": "^16.2.0", 18 | "react-dom": "^16.2.0", 19 | "react-final-form": "^2.0.0", 20 | "react-redux": "^5.0.6", 21 | "react-router": "^4.2.0", 22 | "react-router-dom": "^4.2.2", 23 | "react-scripts": "1.0.17", 24 | "recompose": "^0.26.0", 25 | "redux": "^3.7.2", 26 | "redux-devtools": "^3.4.0", 27 | "redux-thunk": "^2.2.0", 28 | "reselect": "^3.0.1", 29 | "very-simple-schema": "0.0.8" 30 | }, 31 | "scripts": { 32 | "start": "react-scripts start", 33 | "build": "react-scripts build", 34 | "test": "react-scripts test --env=jsdom", 35 | "eject": "react-scripts eject" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /example/todo-web/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apendua/ddp-redux/412b4e38de09d398ad7bcc964f920f6fcf907396/example/todo-web/public/favicon.ico -------------------------------------------------------------------------------- /example/todo-web/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 13 | 22 | Todos Example 23 | 24 | 25 | 28 |
29 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /example/todo-web/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | } 10 | ], 11 | "start_url": "./index.html", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /example/todo-web/src/common/.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | !/node_modules/ddp-connector 3 | -------------------------------------------------------------------------------- /example/todo-web/src/common/api/TodoLists.js: -------------------------------------------------------------------------------- 1 | import Schema from '../utils/Schema.js'; 2 | import ApiSpec from '../utils/ApiSpec.js'; 3 | 4 | export const insert = new ApiSpec({ 5 | name: 'api.TodoLists.insert', 6 | schema: new Schema({ 7 | title: { type: String }, 8 | }), 9 | }); 10 | 11 | export const update = new ApiSpec({ 12 | name: 'api.TodoLists.update', 13 | schema: new Schema({ 14 | listId: { type: String, regEx: Schema.RegEx.Id }, 15 | title: { type: String, optional: true }, 16 | }), 17 | }); 18 | 19 | export const remove = new ApiSpec({ 20 | name: 'api.TodoLists.remove', 21 | schema: new Schema({ 22 | listId: { type: String, regEx: Schema.RegEx.Id }, 23 | }), 24 | }); 25 | 26 | export const allLists = new ApiSpec({ 27 | name: 'api.TodoLists.allLists', 28 | schema: new Schema(), 29 | }); 30 | 31 | export const oneList = new ApiSpec({ 32 | name: 'api.TodoLists.oneList', 33 | schema: new Schema({ 34 | listId: { type: String, regEx: Schema.RegEx.Id }, 35 | }), 36 | }); 37 | -------------------------------------------------------------------------------- /example/todo-web/src/common/api/Todos.js: -------------------------------------------------------------------------------- 1 | import Schema from '../utils/Schema.js'; 2 | import ApiSpec from '../utils/ApiSpec.js'; 3 | 4 | export const insert = new ApiSpec({ 5 | name: 'api.Todos.insert', 6 | schema: new Schema({ 7 | listId: { type: String, regEx: Schema.RegEx.Id }, 8 | name: { type: String }, 9 | done: { type: Boolean, optional: true }, 10 | }), 11 | }); 12 | 13 | export const update = new ApiSpec({ 14 | name: 'api.Todos.update', 15 | schema: new Schema({ 16 | todoId: { type: String, regEx: Schema.RegEx.Id }, 17 | name: { type: String, optional: true }, 18 | done: { type: Boolean, optional: true }, 19 | }), 20 | }); 21 | 22 | export const remove = new ApiSpec({ 23 | name: 'api.Todos.remove', 24 | schema: new Schema({ 25 | todoId: { type: String, regEx: Schema.RegEx.Id }, 26 | }), 27 | }); 28 | 29 | export const todosInList = new ApiSpec({ 30 | name: 'api.Todos.todosInList', 31 | schema: new Schema({ 32 | listId: { type: String, regEx: Schema.RegEx.Id }, 33 | }), 34 | }); 35 | 36 | export const getStats = new ApiSpec({ 37 | name: 'api.Todos.getStats', 38 | schema: new Schema(), 39 | }); 40 | -------------------------------------------------------------------------------- /example/todo-web/src/common/models/BaseModel.js: -------------------------------------------------------------------------------- 1 | class BaseModel { 2 | constructor(doc) { 3 | Object.assign(this, doc); 4 | } 5 | 6 | static set collection(name) { 7 | this._collection = name; 8 | } 9 | 10 | static get collection() { 11 | return this._collection; 12 | } 13 | } 14 | 15 | export default BaseModel; 16 | -------------------------------------------------------------------------------- /example/todo-web/src/common/models/Todo.js: -------------------------------------------------------------------------------- 1 | import BaseModel from './BaseModel.js'; 2 | 3 | class Todo extends BaseModel { 4 | isDone() { 5 | return !!this.done; 6 | } 7 | getName() { 8 | return this.name; 9 | } 10 | getListId() { 11 | return this.listId; 12 | } 13 | } 14 | 15 | Todo.collection = 'Todos'; 16 | export default Todo; 17 | -------------------------------------------------------------------------------- /example/todo-web/src/common/models/TodoList.js: -------------------------------------------------------------------------------- 1 | import BaseModel from './BaseModel.js'; 2 | 3 | class TodoList extends BaseModel { 4 | getTitle() { 5 | return this.title; 6 | } 7 | } 8 | 9 | TodoList.collection = 'TodoLists'; 10 | export default TodoList; 11 | -------------------------------------------------------------------------------- /example/todo-web/src/common/utils/ApiSpec.js: -------------------------------------------------------------------------------- 1 | import { makeSchemaValidator } from './errors.js'; 2 | 3 | class ApiSpec { 4 | constructor(options) { 5 | Object.assign(this, options); 6 | if (!this.name) { 7 | throw new Error('Api spec requires name'); 8 | } 9 | } 10 | 11 | getName() { 12 | return this.name; 13 | } 14 | 15 | withParams(params) { 16 | if (this.schema) { 17 | try { 18 | this.schema.validate(params); // this may throw an error! 19 | } catch (err) { 20 | console.error(err); 21 | console.error('GOT', params); 22 | throw err; 23 | } 24 | } 25 | return { 26 | name: this.getName(), 27 | params: params !== undefined ? [params] : [], 28 | }; 29 | } 30 | 31 | getValidator(ValidationError) { 32 | return makeSchemaValidator(this.schema, ValidationError); 33 | } 34 | } 35 | 36 | export default ApiSpec; 37 | -------------------------------------------------------------------------------- /example/todo-web/src/common/utils/Schema.js: -------------------------------------------------------------------------------- 1 | import { 2 | createSchema, 3 | presetDefault, 4 | } from 'very-simple-schema'; 5 | 6 | const Schema = createSchema({ 7 | plugins: [ 8 | ...presetDefault, 9 | ], 10 | additionalProperties: false, 11 | emptyStringsAreMissingValues: true, 12 | }); 13 | 14 | export default Schema; 15 | -------------------------------------------------------------------------------- /example/todo-web/src/common/utils/errors.js: -------------------------------------------------------------------------------- 1 | import flatten from 'flat'; 2 | 3 | const getErrorMessage = (message) => { 4 | if (typeof message === 'string') { 5 | return message; 6 | } 7 | if (Array.isArray(message)) { 8 | return getErrorMessage(message[0]); 9 | } 10 | if (typeof message === 'object') { 11 | const key = Object.keys(message)[0]; 12 | return getErrorMessage(message[key]); 13 | } 14 | return 'Unrecognized error'; 15 | }; 16 | 17 | export const makeSchemaValidator = (schema, ValidationError) => { 18 | if (!schema) { 19 | return () => {}; 20 | } 21 | return (value) => { 22 | const error = schema.getErrors(value); 23 | if (error) { 24 | const errors = []; 25 | const described = schema.describe(error); 26 | const reason = getErrorMessage(described); 27 | if (typeof described === 'object') { 28 | const fields = flatten(described); 29 | Object.keys(fields).forEach((name) => { 30 | errors.push({ 31 | name, 32 | type: fields[name], 33 | }); 34 | }); 35 | } 36 | throw new ValidationError(errors, reason); 37 | } 38 | }; 39 | }; 40 | -------------------------------------------------------------------------------- /example/todo-web/src/components/Loader.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Loader = () => ( 4 |
Loading ...
5 | ); 6 | 7 | export default Loader; 8 | -------------------------------------------------------------------------------- /example/todo-web/src/components/NotFound.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const NotFound = () => ( 4 |
Not found ...
5 | ); 6 | 7 | export default NotFound; -------------------------------------------------------------------------------- /example/todo-web/src/containers/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Provider } from 'react-redux'; 4 | import Router from '../routes/Router'; 5 | 6 | const App = ({ store }) => ( 7 | 8 | 9 | 10 | ); 11 | 12 | App.propTypes = { 13 | store: PropTypes.object.isRequired, 14 | }; 15 | 16 | export default App; 17 | -------------------------------------------------------------------------------- /example/todo-web/src/containers/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | 5 | it('renders without crashing', () => { 6 | const div = document.createElement('div'); 7 | ReactDOM.render(, div); 8 | }); 9 | -------------------------------------------------------------------------------- /example/todo-web/src/containers/Entry.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | Form, 4 | Field, 5 | } from 'react-final-form'; 6 | import AntdForm from 'antd/lib/form'; 7 | import Input from 'antd/lib/input'; 8 | import Button from 'antd/lib/button'; 9 | import Checkbox from 'antd/lib/checkbox'; 10 | import { Redirect } from 'react-router-dom'; 11 | import ddp from 'ddp-connector'; 12 | import { 13 | loginWithPassword, 14 | createUser, 15 | } from 'ddp-redux'; 16 | 17 | // const composeValidators = (...validators) => 18 | // validators.reudce((a, b) => value => a(value) || b(value)) 19 | 20 | const required = value => (value ? undefined : 'Value is required'); 21 | const FormItem = AntdForm.Item; 22 | 23 | const Entry = ddp({ 24 | selectors: ({ 25 | from, 26 | }) => ({ 27 | user: from('users').select.currentUser(), 28 | }), 29 | mutations: { 30 | onSubmit: ({ 31 | dispatch, 32 | }) => ({ 33 | email, 34 | password, 35 | createNew, 36 | }) => (createNew 37 | ? dispatch(createUser({ email, password })) 38 | : dispatch(loginWithPassword({ email, password })) 39 | ), 40 | }, 41 | })(({ 42 | user, 43 | onSubmit, 44 | }) => (user 45 | ?
46 | 47 |
48 | :
( 57 | 58 |

59 | ddp-redux: Todo Lists 60 |

61 | 62 | {({ input, meta }) => ( 63 | 69 | 70 | 71 | )} 72 | 73 | 74 | {({ input, meta }) => ( 75 | 81 | 82 | 83 | )} 84 | 85 | 86 | {({ input }) => ( 87 | 88 | Create new account? 89 | 90 | )} 91 | 92 |
93 | 100 | 107 |
108 |
109 | )} 110 | /> 111 | )); 112 | 113 | export default Entry; 114 | -------------------------------------------------------------------------------- /example/todo-web/src/containers/List.js: -------------------------------------------------------------------------------- 1 | /* eslint no-underscore-dangle: "off" */ 2 | import React from 'react'; 3 | import { Link } from 'react-router-dom'; 4 | import { 5 | createSelector, 6 | } from 'reselect'; 7 | import { connect } from 'react-redux'; 8 | import { 9 | compose, 10 | withState, 11 | withProps, 12 | withHandlers, 13 | } from 'recompose'; 14 | import { 15 | callMethod, 16 | } from 'ddp-redux/lib/actions'; 17 | import Input from 'antd/lib/input'; 18 | import Checkbox from 'antd/lib/checkbox'; 19 | import Button from 'antd/lib/button'; 20 | import ddp from 'ddp-connector'; 21 | import { 22 | insert, 23 | update, 24 | // remove, 25 | todosInList, 26 | } from '../common/api/Todos'; 27 | import { 28 | oneList, 29 | } from '../common/api/TodoLists'; 30 | import Todo from '../common/models/Todo'; 31 | import TodoList from '../common/models/TodoList'; 32 | import Loader from '../components/Loader'; 33 | 34 | const ListItem = withHandlers({ 35 | onChange: ({ 36 | todo, 37 | onUpdate, 38 | }) => event => onUpdate({ 39 | todoId: todo._id, 40 | done: !!event.target.checked, 41 | name: todo.getName(), 42 | }), 43 | })(({ 44 | todo, 45 | onChange, 46 | }) => ( 47 |
  • 50 | 57 | {todo.name} 58 | 59 |
  • 60 | )); 61 | 62 | const List = compose( 63 | withState('name', 'setName', ''), 64 | withProps(({ match: { params: { listId } } }) => ({ 65 | listId, 66 | })), 67 | ddp({ 68 | subscriptions: (state, { listId }) => [ 69 | oneList.withParams({ listId }), 70 | todosInList.withParams({ listId }), 71 | ], 72 | selectors: ({ 73 | prop, 74 | from, 75 | }) => ({ 76 | list: from(TodoList).select.one('listId'), 77 | todos: from(Todo).select.where( 78 | createSelector( 79 | prop('listId'), 80 | listId => todo => todo.getListId() === listId, 81 | ), 82 | ), 83 | }), 84 | loader: Loader, 85 | }), 86 | connect( 87 | null, 88 | (dispatch, { 89 | name, 90 | setName, 91 | listId, 92 | }) => ({ 93 | onAddTodo: () => 94 | dispatch(callMethod(insert.name, [{ listId, name }])) 95 | .then(() => setName('')), 96 | 97 | onUpdateTodo: ({ todoId, name, done }) => 98 | dispatch(callMethod(update.name, [{ todoId, done, name }], { 99 | entities: { 100 | [Todo.collection]: { 101 | [todoId]: { 102 | done, 103 | name, 104 | }, 105 | }, 106 | }, 107 | })), 108 | }), 109 | ), 110 | withHandlers({ 111 | onChangeName: ({ 112 | setName, 113 | }) => e => setName(e.currentTarget.value), 114 | }), 115 | )(({ 116 | list, 117 | todos, 118 | name, 119 | onAddTodo, 120 | onChangeName, 121 | onUpdateTodo, 122 | }) => ( 123 |
    124 | < Back 125 |

    {list && list.getTitle()}

    126 |
      127 | {todos.map(todo => ( 128 | 129 | ))} 130 |
    • 131 | 136 |
    • 137 |
    138 |

    139 | 145 |

    146 |
    147 | )); 148 | 149 | export default List; 150 | -------------------------------------------------------------------------------- /example/todo-web/src/containers/Lists.js: -------------------------------------------------------------------------------- 1 | /* eslint no-underscore-dangle: "off" */ 2 | import React from 'react'; 3 | import { 4 | compose, 5 | withState, 6 | withHandlers, 7 | lifecycle, 8 | } from 'recompose'; 9 | import { Link } from 'react-router-dom'; 10 | import { 11 | callMethod, 12 | } from 'ddp-redux/lib/actions'; 13 | import ddp from 'ddp-connector'; 14 | import { 15 | logout, 16 | queryRefetchAll, 17 | } from 'ddp-redux'; 18 | import Input from 'antd/lib/input'; 19 | import Button from 'antd/lib/button'; 20 | import List from 'antd/lib/list'; 21 | import { 22 | insert, 23 | allLists, 24 | } from '../common/api/TodoLists'; 25 | import { 26 | getStats, 27 | } from '../common/api/Todos'; 28 | import Loader from '../components/Loader'; 29 | import TodoList from '../common/models/TodoList'; 30 | 31 | const Lists = compose( 32 | lifecycle({ 33 | componentDidMount() { 34 | const { 35 | dispatch, 36 | } = this.props; 37 | // NOTE: Of course this is suboptimal. 38 | // It will be improved after refactoring queries api. 39 | dispatch(queryRefetchAll()); 40 | }, 41 | }), 42 | withState('title', 'setTitle', ''), 43 | ddp({ 44 | subscriptions: [ 45 | allLists.withParams(), 46 | ], 47 | queries: [ 48 | getStats.withParams(), 49 | ], 50 | selectors: ({ 51 | from, 52 | }) => ({ 53 | lists: from(TodoList).select.all(), 54 | }), 55 | loader: Loader, 56 | }), 57 | withHandlers({ 58 | onLogout: ({ dispatch }) => () => dispatch(logout()), 59 | onAddList: ({ 60 | title, 61 | setTitle, 62 | dispatch, 63 | }) => () => 64 | dispatch(callMethod(insert.name, [{ title }])) 65 | .then(() => setTitle('')), 66 | onChangeTitle: ({ 67 | setTitle, 68 | }) => e => setTitle(e.currentTarget.value), 69 | }), 70 | )(({ 71 | lists, 72 | title, 73 | onLogout, 74 | onAddList, 75 | onChangeTitle, 76 | }) => ( 77 |
    78 |

    Todo Lists

    79 |

    80 | 83 |

    84 | ( 88 | 89 | 92 | {list.title} 93 | 94 | } 95 | description={`Active: ${list.active || 0}, completed: ${list.completed || 0}`} 96 | /> 97 | 98 | )} 99 | /> 100 |

    101 | 106 |

    107 |

    108 | 109 |

    110 |
    111 | )); 112 | 113 | export default Lists; 114 | -------------------------------------------------------------------------------- /example/todo-web/src/containers/LoggedInRoute.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Redirect, Route } from 'react-router-dom'; 4 | import ddp from 'ddp-connector'; 5 | 6 | const createLoggedInRoute = ({ 7 | loader, 8 | redirectTo, 9 | }) => { 10 | const DecoratedComponent = ddp({ 11 | loader, 12 | selectors: ({ from, select }) => ({ 13 | currentUser: from('users').select.currentUser(), 14 | isLoggingIn: select.isLoggingIn(), 15 | }), 16 | })(({ 17 | currentUser, 18 | isLoggingIn, 19 | component, 20 | ...props 21 | }) => { 22 | if (isLoggingIn) { 23 | return null; 24 | } 25 | if (currentUser) { 26 | return React.createElement(component, { 27 | ...props, 28 | currentUser, 29 | }); 30 | } 31 | if (redirectTo) { 32 | return React.createElement(Redirect, { 33 | to: { 34 | pathname: redirectTo, 35 | state: { from: props.location }, 36 | hash: props.location.hash, 37 | }, 38 | }); 39 | } 40 | return null; 41 | }); 42 | 43 | const Container = ({ component, ...rest }) => React.createElement(Route, { 44 | ...rest, 45 | render: props => React.createElement(DecoratedComponent, { 46 | ...props, 47 | component, 48 | }), 49 | }); 50 | 51 | Container.propTypes = { 52 | component: PropTypes.func.isRequired, 53 | }; 54 | 55 | return Container; 56 | }; 57 | 58 | const LoggedInRoute = createLoggedInRoute({ 59 | redirectTo: '/entry', 60 | }); 61 | 62 | export { createLoggedInRoute }; 63 | export default LoggedInRoute; 64 | -------------------------------------------------------------------------------- /example/todo-web/src/index.css: -------------------------------------------------------------------------------- 1 | @import '~antd/dist/antd.css'; 2 | 3 | body { 4 | margin: 0; 5 | padding: 0; 6 | font-family: sans-serif; 7 | } 8 | 9 | .container { 10 | width: 50%; 11 | max-width: 400px; 12 | margin: auto; 13 | margin-top: 10%; 14 | } 15 | -------------------------------------------------------------------------------- /example/todo-web/src/index.js: -------------------------------------------------------------------------------- 1 | /* eslint global-require: "off" */ 2 | import { 3 | persistState, 4 | } from 'redux-devtools'; 5 | import { 6 | compose, 7 | createStore, 8 | applyMiddleware, 9 | } from 'redux'; 10 | import DDPClient from 'ddp-redux'; 11 | import React from 'react'; 12 | import ReactDOM from 'react-dom'; 13 | import notification from 'antd/lib/notification'; 14 | import App from './containers/App'; 15 | import registerServiceWorker from './registerServiceWorker'; 16 | import rootReducer from './store/rootReducer'; 17 | import storage from './storage'; 18 | import './index.css'; 19 | 20 | const ddpClient = new DDPClient({ 21 | endpoint: process.env.REACT_APP_ENDPOINT, 22 | SocketConstructor: WebSocket, 23 | storage, 24 | }); 25 | 26 | ddpClient.onPromise = ((promise) => { 27 | promise.catch((err) => { 28 | notification.error({ 29 | message: 'Error', 30 | description: err.reason || err.message, 31 | }); 32 | }); 33 | }); 34 | 35 | const enhancer = compose( 36 | window.__REDUX_DEVTOOLS_EXTENSION__ ? window.__REDUX_DEVTOOLS_EXTENSION__({}) : x => x, 37 | persistState( 38 | window.location.href.match( 39 | /[?&]debug_session=([^&#]+)\b/, 40 | ), 41 | ), 42 | ); 43 | 44 | const store = createStore( 45 | rootReducer, 46 | {}, 47 | compose( 48 | applyMiddleware( 49 | ddpClient.middleware(), 50 | ), 51 | enhancer, 52 | ), 53 | ); 54 | 55 | ReactDOM.render( 56 | , 57 | document.getElementById('root'), 58 | ); 59 | 60 | if (process.env.NODE_ENV !== 'production') { 61 | if (typeof module !== 'undefined' && module.hot) { 62 | module.hot.accept('./containers/App', () => { 63 | const NextApp = require('./containers/App').default; 64 | ReactDOM.render( 65 | , 66 | document.getElementById('root'), 67 | ); 68 | }); 69 | 70 | module.hot.accept('./store/rootReducer.js', () => 71 | store.replaceReducer(require('./store/rootReducer.js').default), 72 | ); 73 | } 74 | } 75 | 76 | registerServiceWorker(); 77 | -------------------------------------------------------------------------------- /example/todo-web/src/registerServiceWorker.js: -------------------------------------------------------------------------------- 1 | // In production, we register a service worker to serve assets from local cache. 2 | 3 | // This lets the app load faster on subsequent visits in production, and gives 4 | // it offline capabilities. However, it also means that developers (and users) 5 | // will only see deployed updates on the "N+1" visit to a page, since previously 6 | // cached resources are updated in the background. 7 | 8 | // To learn more about the benefits of this model, read https://goo.gl/KwvDNy. 9 | // This link also includes instructions on opting out of this behavior. 10 | 11 | const isLocalhost = Boolean( 12 | window.location.hostname === 'localhost' || 13 | // [::1] is the IPv6 localhost address. 14 | window.location.hostname === '[::1]' || 15 | // 127.0.0.1/8 is considered localhost for IPv4. 16 | window.location.hostname.match( 17 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 18 | ) 19 | ); 20 | 21 | export default function register() { 22 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 23 | // The URL constructor is available in all browsers that support SW. 24 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location); 25 | if (publicUrl.origin !== window.location.origin) { 26 | // Our service worker won't work if PUBLIC_URL is on a different origin 27 | // from what our page is served on. This might happen if a CDN is used to 28 | // serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374 29 | return; 30 | } 31 | 32 | window.addEventListener('load', () => { 33 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 34 | 35 | if (!isLocalhost) { 36 | // Is not local host. Just register service worker 37 | registerValidSW(swUrl); 38 | } else { 39 | // This is running on localhost. Lets check if a service worker still exists or not. 40 | checkValidServiceWorker(swUrl); 41 | } 42 | }); 43 | } 44 | } 45 | 46 | function registerValidSW(swUrl) { 47 | navigator.serviceWorker 48 | .register(swUrl) 49 | .then(registration => { 50 | registration.onupdatefound = () => { 51 | const installingWorker = registration.installing; 52 | installingWorker.onstatechange = () => { 53 | if (installingWorker.state === 'installed') { 54 | if (navigator.serviceWorker.controller) { 55 | // At this point, the old content will have been purged and 56 | // the fresh content will have been added to the cache. 57 | // It's the perfect time to display a "New content is 58 | // available; please refresh." message in your web app. 59 | console.log('New content is available; please refresh.'); 60 | } else { 61 | // At this point, everything has been precached. 62 | // It's the perfect time to display a 63 | // "Content is cached for offline use." message. 64 | console.log('Content is cached for offline use.'); 65 | } 66 | } 67 | }; 68 | }; 69 | }) 70 | .catch(error => { 71 | console.error('Error during service worker registration:', error); 72 | }); 73 | } 74 | 75 | function checkValidServiceWorker(swUrl) { 76 | // Check if the service worker can be found. If it can't reload the page. 77 | fetch(swUrl) 78 | .then(response => { 79 | // Ensure service worker exists, and that we really are getting a JS file. 80 | if ( 81 | response.status === 404 || 82 | response.headers.get('content-type').indexOf('javascript') === -1 83 | ) { 84 | // No service worker found. Probably a different app. Reload the page. 85 | navigator.serviceWorker.ready.then(registration => { 86 | registration.unregister().then(() => { 87 | window.location.reload(); 88 | }); 89 | }); 90 | } else { 91 | // Service worker found. Proceed as normal. 92 | registerValidSW(swUrl); 93 | } 94 | }) 95 | .catch(() => { 96 | console.log( 97 | 'No internet connection found. App is running in offline mode.' 98 | ); 99 | }); 100 | } 101 | 102 | export function unregister() { 103 | if ('serviceWorker' in navigator) { 104 | navigator.serviceWorker.ready.then(registration => { 105 | registration.unregister(); 106 | }); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /example/todo-web/src/routes/Router.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | Redirect, 4 | BrowserRouter, 5 | Switch, 6 | Route, 7 | } from 'react-router-dom'; 8 | import List from '../containers/List'; 9 | import Lists from '../containers/Lists'; 10 | import Entry from '../containers/Entry'; 11 | import NotFound from '../components/NotFound'; 12 | import LoggedInRoute from '../containers/LoggedInRoute'; 13 | 14 | const Router = () => ( 15 | 16 | 17 | ( 21 | 26 | )} 27 | /> 28 | 29 | 30 | 31 | 32 | 33 | 34 | ); 35 | 36 | export default Router; 37 | -------------------------------------------------------------------------------- /example/todo-web/src/storage.js: -------------------------------------------------------------------------------- 1 | const storage = { 2 | set: (key, value) => localStorage.setItem(key, value), 3 | del: key => localStorage.removeItem(key), 4 | get: key => localStorage.getItem(key), 5 | }; 6 | 7 | export default storage; 8 | -------------------------------------------------------------------------------- /example/todo-web/src/store/rootReducer.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | import DDPClient from 'ddp-redux'; 3 | 4 | const rootReducer = combineReducers({ 5 | ddp: DDPClient.reducer(), 6 | }); 7 | 8 | export default rootReducer; 9 | --------------------------------------------------------------------------------