├── .npmignore ├── SUMMARY.md ├── docs ├── ChangeLog.md ├── basics │ ├── README.md │ ├── TimeToLive.md │ ├── Operations.md │ └── Configuration.md ├── Recipes.md ├── advanced │ ├── README.md │ ├── Deduplication.md │ ├── ChangeListener.md │ ├── Plugins-KnownPlugins.md │ ├── Plugins-CheatSheet.md │ ├── ViewOf.md │ ├── Invalidation.md │ ├── CustomId.md │ └── Plugins.md ├── recipes │ ├── Polling.md │ └── SeparateApiFolder.md ├── README.md ├── MainContributors.md ├── Demos.md ├── Concepts.md ├── GettingStarted.md ├── Contribute.md ├── Background.md └── Introduction.md ├── examples ├── minimal-react-ladda │ ├── .gitignore │ ├── README.md │ ├── dist │ │ └── index.html │ ├── src │ │ ├── api │ │ │ ├── index.js │ │ │ └── hackernews.js │ │ └── index.js │ ├── webpack.config.js │ └── package.json ├── minimal-vue-ladda │ ├── .gitignore │ ├── README.md │ ├── dist │ │ └── index.html │ ├── src │ │ ├── api │ │ │ ├── index.js │ │ │ └── hackernews.js │ │ └── index.js │ ├── webpack.config.js │ └── package.json ├── minimal-vanilla-ladda │ ├── .gitignore │ ├── README.md │ ├── src │ │ ├── api │ │ │ ├── index.js │ │ │ └── hackernews.js │ │ └── index.js │ ├── dist │ │ └── index.html │ ├── webpack.config.js │ └── package.json └── README.md ├── .gitignore ├── src ├── index.js ├── plugins │ ├── cache │ │ ├── operations │ │ │ ├── no-operation.js │ │ │ ├── command.js │ │ │ ├── update.js │ │ │ ├── delete.js │ │ │ ├── create.js │ │ │ ├── update.spec.js │ │ │ ├── create.spec.js │ │ │ ├── command.spec.js │ │ │ ├── no-operation.spec.js │ │ │ ├── read.js │ │ │ ├── delete.spec.js │ │ │ └── read.spec.js │ │ ├── serializer.js │ │ ├── merger.js │ │ ├── merger.spec.js │ │ ├── serializer.spec.js │ │ ├── id-helper.js │ │ ├── index.js │ │ ├── id-helper.spec.js │ │ ├── index.spec.js │ │ ├── cache.js │ │ ├── test-helper.js │ │ ├── entity-store.js │ │ ├── query-cache.spec.js │ │ ├── query-cache.js │ │ └── entity-store.spec.js │ └── dedup │ │ ├── index.js │ │ └── index.spec.js ├── listener-store.js ├── builder.js ├── validator.js ├── validator.spec.js └── builder.spec.js ├── .travis.yml ├── .babelrc ├── mocha.config.js ├── book.json ├── rollup.config.js ├── .eslintrc ├── LICENSE ├── package.json ├── CODE_OF_CONDUCT.md └── README.md /.npmignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /SUMMARY.md: -------------------------------------------------------------------------------- 1 | docs/README.md -------------------------------------------------------------------------------- /docs/ChangeLog.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | -------------------------------------------------------------------------------- /examples/minimal-react-ladda/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ -------------------------------------------------------------------------------- /examples/minimal-vue-ladda/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ -------------------------------------------------------------------------------- /examples/minimal-vanilla-ladda/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | _book 3 | dist/* 4 | npm-debug.log 5 | 6 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { build } from './builder'; 2 | 3 | export { build }; 4 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | You will find a selection of minimal projects that use Ladda. Browse into the project and follow the `README.md` to start the project. -------------------------------------------------------------------------------- /docs/basics/README.md: -------------------------------------------------------------------------------- 1 | # Basics 2 | 3 | You will find all the basic know how and configuration to use Ladda. We'll go through the most important configuration options. 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | script: npm test 3 | node_js: 4 | - "node" 5 | after_script: 6 | - npm run coverage && cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js 7 | -------------------------------------------------------------------------------- /docs/Recipes.md: -------------------------------------------------------------------------------- 1 | # Recipes 2 | 3 | A collection of best practices on how to use Ladda in your application. Involves both how to use Ladda in a good way and how Ladda can be used to solve specific problems. 4 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "test": { 4 | "presets": [ 5 | "env", 6 | "stage-1" 7 | ], 8 | "plugins": [ 9 | "istanbul", 10 | ] 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /examples/minimal-vue-ladda/README.md: -------------------------------------------------------------------------------- 1 | # Ladda in Vue 2 | 3 | * `git clone git@github.com:petercrona/ladda.git` 4 | * cd examples/minimal-vue-ladda 5 | * npm install 6 | * npm start 7 | * visit `http://localhost:8080/` 8 | -------------------------------------------------------------------------------- /examples/minimal-react-ladda/README.md: -------------------------------------------------------------------------------- 1 | # Ladda in React 2 | 3 | * `git clone git@github.com:petercrona/ladda.git` 4 | * cd examples/minimal-react-ladda 5 | * npm install 6 | * npm start 7 | * visit `http://localhost:8080/` 8 | -------------------------------------------------------------------------------- /examples/minimal-vanilla-ladda/README.md: -------------------------------------------------------------------------------- 1 | # Ladda in Vanilla JavaScript 2 | 3 | * `git clone git@github.com:petercrona/ladda.git` 4 | * cd examples/minimal-vanilla-ladda 5 | * npm install 6 | * npm start 7 | * visit `http://localhost:8080/` 8 | -------------------------------------------------------------------------------- /examples/minimal-vue-ladda/dist/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | minimal-vue 5 | 6 | 7 |
8 | 9 | 10 | -------------------------------------------------------------------------------- /examples/minimal-react-ladda/dist/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | minimal-react 5 | 6 | 7 |
8 | 9 | 10 | -------------------------------------------------------------------------------- /mocha.config.js: -------------------------------------------------------------------------------- 1 | import chai, { expect } from 'chai'; 2 | import sinonChai from 'sinon-chai'; 3 | 4 | chai.use(sinonChai); 5 | 6 | global.fdescribe = (...args) => describe.only(...args); 7 | global.fit = (...args) => it.only(...args); 8 | global.expect = expect; 9 | -------------------------------------------------------------------------------- /examples/minimal-vue-ladda/src/api/index.js: -------------------------------------------------------------------------------- 1 | import { build } from 'ladda-cache'; 2 | import * as hackernews from './hackernews.js'; 3 | 4 | const config = { 5 | hackernews: { 6 | ttl: 300, 7 | api: hackernews 8 | }, 9 | }; 10 | 11 | const api = build(config); 12 | 13 | export default api; 14 | -------------------------------------------------------------------------------- /examples/minimal-react-ladda/src/api/index.js: -------------------------------------------------------------------------------- 1 | import { build } from 'ladda-cache'; 2 | import * as hackernews from './hackernews.js'; 3 | 4 | const config = { 5 | hackernews: { 6 | ttl: 300, 7 | api: hackernews 8 | }, 9 | }; 10 | 11 | const api = build(config); 12 | 13 | export default api; 14 | -------------------------------------------------------------------------------- /examples/minimal-vanilla-ladda/src/api/index.js: -------------------------------------------------------------------------------- 1 | import { build } from 'ladda-cache'; 2 | import * as hackernews from './hackernews.js'; 3 | 4 | const config = { 5 | hackernews: { 6 | ttl: 300, 7 | api: hackernews 8 | }, 9 | }; 10 | 11 | const api = build(config); 12 | 13 | export default api; 14 | -------------------------------------------------------------------------------- /src/plugins/cache/operations/no-operation.js: -------------------------------------------------------------------------------- 1 | import {passThrough} from 'ladda-fp'; 2 | import {invalidateQuery} from '../cache'; 3 | 4 | export function decorateNoOperation(c, cache, notify, e, aFn) { 5 | return (...args) => { 6 | return aFn(...args) 7 | .then(passThrough(() => invalidateQuery(cache, e, aFn))) 8 | .then(passThrough(() => notify(args, null))); 9 | }; 10 | } 11 | -------------------------------------------------------------------------------- /src/plugins/cache/serializer.js: -------------------------------------------------------------------------------- 1 | const EMPTY_STRING = '__EMPTY_STRING__'; 2 | 3 | export const serialize = (x) => { 4 | if (x instanceof Date) { 5 | return x.toISOString(); 6 | } 7 | if (x === '') { 8 | return EMPTY_STRING; 9 | } 10 | if (x instanceof Object) { 11 | return Object.keys(x).map(k => 12 | serialize(x[k]) 13 | ).join('-'); 14 | } 15 | return x; 16 | }; 17 | -------------------------------------------------------------------------------- /examples/minimal-vue-ladda/src/api/hackernews.js: -------------------------------------------------------------------------------- 1 | const BASE_URL = 'https://hn.algolia.com/api/v1/'; 2 | 3 | getList.operation = 'READ'; 4 | getList.idFrom = o => o.objectID; 5 | function getList(query) { 6 | const url = `${BASE_URL}search?query=${query}&hitsPerPage=200`; 7 | return fetch(url) 8 | .then(response => response.json()) 9 | .then(result => result.hits); 10 | } 11 | 12 | export { 13 | getList, 14 | } -------------------------------------------------------------------------------- /examples/minimal-react-ladda/src/api/hackernews.js: -------------------------------------------------------------------------------- 1 | const BASE_URL = 'https://hn.algolia.com/api/v1/'; 2 | 3 | getList.operation = 'READ'; 4 | getList.idFrom = o => o.objectID; 5 | function getList(query) { 6 | const url = `${BASE_URL}search?query=${query}&hitsPerPage=200`; 7 | return fetch(url) 8 | .then(response => response.json()) 9 | .then(result => result.hits); 10 | } 11 | 12 | export { 13 | getList, 14 | } -------------------------------------------------------------------------------- /examples/minimal-vanilla-ladda/src/api/hackernews.js: -------------------------------------------------------------------------------- 1 | const BASE_URL = 'https://hn.algolia.com/api/v1/'; 2 | 3 | getList.operation = 'READ'; 4 | getList.idFrom = o => o.objectID; 5 | function getList(query) { 6 | const url = `${BASE_URL}search?query=${query}&hitsPerPage=200`; 7 | return fetch(url) 8 | .then(response => response.json()) 9 | .then(result => result.hits); 10 | } 11 | 12 | export { 13 | getList, 14 | } -------------------------------------------------------------------------------- /examples/minimal-vanilla-ladda/dist/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | minimal-vanilla 5 | 6 | 7 |
8 |

Search Hacker News with Ladda

9 |

There shouldn't be a second network request, when you search for something twice.

10 | 11 | 12 |
13 | 14 | 15 | -------------------------------------------------------------------------------- /book.json: -------------------------------------------------------------------------------- 1 | { 2 | "gitbook": "3.2.2", 3 | "title": "Ladda", 4 | "plugins": ["edit-link", "prism", "-highlight", "github", "anchorjs"], 5 | "pluginsConfig": { 6 | "edit-link": { 7 | "base": "https://github.com/petercrona/ladda/tree/master", 8 | "label": "Edit This Page" 9 | }, 10 | "github": { 11 | "url": "https://github.com/petercrona/ladda/" 12 | }, 13 | "theme-default": { 14 | "styles": { 15 | "website": "build/gitbook.css" 16 | } 17 | } 18 | } 19 | } -------------------------------------------------------------------------------- /docs/advanced/README.md: -------------------------------------------------------------------------------- 1 | # Advanced 2 | 3 | We believe that the basics is enough to make Ladda useful for you. But as you create more and more entities you will need to model dependencies between these. For example, posting a message might need to invalidate the cache of an activity stream. You might also try to reduce the amount of data going over the wire by introducing smaller representations of entities, eg. a MiniUser which is a User but only with {id, name}. In the advanced topics we go through how Ladda can help you out in these and other cases. 4 | -------------------------------------------------------------------------------- /src/plugins/cache/operations/command.js: -------------------------------------------------------------------------------- 1 | import {passThrough} from 'ladda-fp'; 2 | import * as Cache from '../cache'; 3 | import {addId} from '../id-helper'; 4 | 5 | export function decorateCommand(c, cache, notify, e, aFn) { 6 | return (...args) => { 7 | return aFn(...args) 8 | .then(passThrough(() => Cache.invalidateQuery(cache, e, aFn))) 9 | .then(passThrough((o) => Cache.storeEntities(cache, e, addId(c, undefined, undefined, Array.isArray(o) ? o : [o])))) 10 | .then(passThrough((o) => notify([...args], o))); 11 | }; 12 | } 13 | 14 | -------------------------------------------------------------------------------- /src/plugins/cache/operations/update.js: -------------------------------------------------------------------------------- 1 | import {passThrough} from 'ladda-fp'; 2 | import * as Cache from '../cache'; 3 | import {addId} from '../id-helper'; 4 | 5 | export function decorateUpdate(c, cache, notify, e, aFn) { 6 | return (eValue, ...args) => { 7 | return aFn(eValue, ...args) 8 | .then(passThrough(() => Cache.invalidateQuery(cache, e, aFn))) 9 | .then(passThrough(() => Cache.storeEntity(cache, e, addId(c, undefined, undefined, eValue)))) 10 | .then(passThrough(() => notify([eValue, ...args], eValue))); 11 | }; 12 | } 13 | -------------------------------------------------------------------------------- /src/plugins/cache/operations/delete.js: -------------------------------------------------------------------------------- 1 | import {passThrough} from 'ladda-fp'; 2 | import * as Cache from '../cache'; 3 | import {serialize} from '../serializer'; 4 | 5 | export function decorateDelete(c, cache, notify, e, aFn) { 6 | return (...args) => { 7 | return aFn(...args) 8 | .then(passThrough(() => Cache.invalidateQuery(cache, e, aFn))) 9 | .then(() => { 10 | const removed = Cache.removeEntity(cache, e, serialize(args[0])); 11 | if (removed) { 12 | notify(args, removed); 13 | } 14 | }); 15 | }; 16 | } 17 | -------------------------------------------------------------------------------- /src/plugins/cache/operations/create.js: -------------------------------------------------------------------------------- 1 | import {passThrough, compose} from 'ladda-fp'; 2 | import {storeEntity, storeCreateEvent, invalidateQuery} from '../cache'; 3 | import {addId, getId} from '../id-helper'; 4 | 5 | export function decorateCreate(c, cache, notify, e, aFn) { 6 | return (...args) => { 7 | return aFn(...args) 8 | .then(passThrough(() => invalidateQuery(cache, e, aFn))) 9 | .then(passThrough(compose(storeEntity(cache, e), addId(c, aFn, args)))) 10 | .then(passThrough(compose(storeCreateEvent(cache, e), getId(c, aFn, args)))) 11 | .then(passThrough(notify(args))); 12 | }; 13 | } 14 | -------------------------------------------------------------------------------- /src/listener-store.js: -------------------------------------------------------------------------------- 1 | import { curry, map_ } from 'ladda-fp'; 2 | 3 | const remove = curry((el, arr) => { 4 | const i = arr.indexOf(el); 5 | if (i !== -1) { arr.splice(i, 1); } 6 | return arr; 7 | }); 8 | 9 | const addChangeListener = curry((listeners, listener) => { 10 | listeners.push(listener); 11 | return () => remove(listener, listeners); 12 | }); 13 | 14 | const notify = curry((listeners, change) => map_((l) => l(change), listeners)); 15 | 16 | export const createListenerStore = () => { 17 | const listeners = []; 18 | return { 19 | onChange: notify(listeners), 20 | addChangeListener: addChangeListener(listeners) 21 | }; 22 | }; 23 | 24 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from 'rollup-plugin-babel'; 2 | import pkg from './package.json'; 3 | 4 | export default [ 5 | { 6 | input: 'src/index.js', 7 | output: [ 8 | { 9 | file: pkg.main, 10 | format: 'cjs' 11 | }, 12 | { 13 | file: pkg.module, 14 | format: 'es' 15 | } 16 | ], 17 | external: ['ladda-fp'], 18 | plugins: [ 19 | babel({ 20 | babelrc: false, 21 | plugins: ['external-helpers'], 22 | presets: [ 23 | ['env', { 24 | modules: false 25 | }], 26 | 'stage-1' 27 | ], 28 | exclude: ['node_modules/**'] 29 | }) 30 | ] 31 | } 32 | ]; 33 | 34 | -------------------------------------------------------------------------------- /examples/minimal-vue-ladda/src/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | 3 | import api from './api'; 4 | 5 | new Vue({ 6 | el: '#app', 7 | template: ` 8 |
9 |

Search Hacker News with Ladda

10 |

There shouldn't be a second network request, when you search for something twice.

11 |
12 | 13 | 14 |
15 |
16 | {{item.title}} 17 |
18 |
19 | `, 20 | data: { 21 | list: [], 22 | query: '', 23 | }, 24 | methods: { 25 | onSearch() { 26 | api.hackernews.getList(this.query).then(hits => this.list = hits); 27 | }, 28 | } 29 | }); 30 | -------------------------------------------------------------------------------- /examples/minimal-vanilla-ladda/webpack.config.js: -------------------------------------------------------------------------------- 1 | var webpack = require('webpack'); 2 | 3 | module.exports = { 4 | entry: './src/index.js', 5 | module: { 6 | loaders: [{ 7 | test: /\.js$/, 8 | exclude: /node_modules/, 9 | loader: 'babel-loader', 10 | query: { 11 | presets: ['es2015'] 12 | } 13 | }] 14 | }, 15 | output: { 16 | path: __dirname + '/dist', 17 | publicPath: '/', 18 | filename: 'bundle.js' 19 | }, 20 | devServer: { 21 | contentBase: './dist' 22 | }, 23 | plugins: [ 24 | new webpack.ProvidePlugin({ 25 | Promise: 'es6-promise-promise' 26 | }), 27 | new webpack.ProvidePlugin({ 28 | 'fetch': 'imports-loader?this=>global!exports-loader?global.fetch!whatwg-fetch' 29 | }), 30 | ] 31 | }; 32 | -------------------------------------------------------------------------------- /docs/recipes/Polling.md: -------------------------------------------------------------------------------- 1 | # Polling 2 | 3 | Caching and polling are not natural friends. We still didn't find a very nice way to make it work, but we did find a way to make it work. In the future you might get (or create a PR!) something fancy like the possibility to specify a cache alias (making poll write into the cache of getAll). 4 | 5 | ```javascript 6 | getAll.operation = 'READ'; 7 | export function getAll(query) { 8 | return get('/downloads', query); 9 | } 10 | 11 | poll.invalidates = ['getAll']; 12 | export function poll(query) { 13 | return get('/downloads', query); 14 | } 15 | ``` 16 | 17 | Everytime you call poll, it will get the latest data. It won't cache it (since no operation is specified). But it will invalidate getAll, ensuring that a subsequent call to getAll won't show older data than you just retrieved by calling poll. When you are not polling, getAll will be cached as specified in your entity configuration. 18 | -------------------------------------------------------------------------------- /examples/minimal-vue-ladda/webpack.config.js: -------------------------------------------------------------------------------- 1 | var webpack = require('webpack'); 2 | 3 | module.exports = { 4 | entry: './src/index.js', 5 | module: { 6 | loaders: [{ 7 | test: /\.js$/, 8 | exclude: /node_modules/, 9 | loader: 'babel-loader', 10 | query: { 11 | presets: ['es2015'] 12 | } 13 | }] 14 | }, 15 | resolve: { 16 | extensions: ['.js'], 17 | alias: { 18 | 'vue': 'vue/dist/vue.common.js' 19 | } 20 | }, 21 | output: { 22 | path: __dirname + '/dist', 23 | publicPath: '/', 24 | filename: 'bundle.js' 25 | }, 26 | devServer: { 27 | contentBase: './dist' 28 | }, 29 | plugins: [ 30 | new webpack.ProvidePlugin({ 31 | Promise: 'es6-promise-promise' 32 | }), 33 | new webpack.ProvidePlugin({ 34 | 'fetch': 'imports-loader?this=>global!exports-loader?global.fetch!whatwg-fetch' 35 | }), 36 | ] 37 | }; 38 | -------------------------------------------------------------------------------- /docs/advanced/Deduplication.md: -------------------------------------------------------------------------------- 1 | # Deduplication 2 | 3 | Ladda tries to optimize "READ" operations by deduplicating identical 4 | simultaneous requests and therefore reduce the load both on server and 5 | client. 6 | 7 | *Identical* means calling the same function with identical arguments. 8 |
9 | *Simultaneous* means that another call has been made before the first 10 | call has been resolved or rejected. 11 | 12 | Given the following code, where `getUsers` is a "READ" operation: 13 | 14 | ```javascript 15 | // Component 1 16 | api.user.getUsers(); 17 | 18 | // Component 2 19 | api.user.getUsers(); 20 | 21 | // Component 3 22 | api.user.getUsers(); 23 | ``` 24 | 25 | Ladda will only make a single call to `getUsers` and distribute its 26 | result to all callers. 27 | 28 | 29 | This feature can be disabled on a global, an entity and a function 30 | level. Check the [Configuration Reference](/docs/basics/Configuration.md) for details. 31 | -------------------------------------------------------------------------------- /examples/minimal-react-ladda/webpack.config.js: -------------------------------------------------------------------------------- 1 | var webpack = require('webpack'); 2 | 3 | module.exports = { 4 | entry: [ 5 | 'webpack-dev-server/client?http://localhost:8080', 6 | 'webpack/hot/only-dev-server', 7 | './src/index.js' 8 | ], 9 | module: { 10 | loaders: [{ 11 | test: /\.jsx?$/, 12 | exclude: /node_modules/, 13 | loader: 'react-hot-loader!babel-loader' 14 | }] 15 | }, 16 | resolve: { 17 | extensions: ['*', '.js', '.jsx'] 18 | }, 19 | output: { 20 | path: __dirname + '/dist', 21 | publicPath: '/', 22 | filename: 'bundle.js' 23 | }, 24 | devServer: { 25 | contentBase: './dist', 26 | hot: true 27 | }, 28 | plugins: [ 29 | new webpack.ProvidePlugin({ 30 | Promise: 'es6-promise-promise' 31 | }), 32 | new webpack.ProvidePlugin({ 33 | 'fetch': 'imports-loader?this=>global!exports-loader?global.fetch!whatwg-fetch' 34 | }), 35 | ] 36 | }; 37 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["airbnb-base"], 3 | "rules": { 4 | "comma-dangle": ["error", "never"], 5 | "no-use-before-define": ["error", { "functions": false }], 6 | "no-underscore-dangle": "off", 7 | "no-param-reassign": "off", 8 | "max-len": [2, 100, { 9 | "ignoreComments": true, 10 | "tabWidth": 2 11 | }], 12 | "space-before-function-paren": ["error", "never"], 13 | "arrow-parens": 0, 14 | "import/prefer-default-export": 0, 15 | "arrow-body-style": "off", 16 | "object-curly-spacing": 0, 17 | "no-prototype-builtins": 0, 18 | "no-plusplus": 0, 19 | "no-restricted-syntax": 0 20 | }, 21 | "globals": { 22 | "describe": true, 23 | "fdescribe": true, 24 | "before": true, 25 | "beforeEach": true, 26 | "it": true, 27 | "fit": true, 28 | "xit": true, 29 | "expect": true, 30 | "after": true, 31 | "afterEach": true, 32 | "TEST_HELPERS": true 33 | } 34 | } 35 | 36 | -------------------------------------------------------------------------------- /examples/minimal-vanilla-ladda/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "minimal-vanilla-ladda", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "scripts": { 6 | "start": "webpack-dev-server --progress --colors --config ./webpack.config.js", 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "keywords": [], 10 | "author": "", 11 | "license": "ISC", 12 | "babel": { 13 | "presets": [ 14 | "es2015", 15 | "stage-2" 16 | ] 17 | }, 18 | "devDependencies": { 19 | "babel-core": "^6.23.1", 20 | "babel-loader": "^6.3.2", 21 | "babel-preset-es2015": "^6.22.0", 22 | "babel-preset-stage-2": "^6.22.0", 23 | "exports-loader": "^0.6.4", 24 | "imports-loader": "^0.7.1", 25 | "webpack": "^2.2.1", 26 | "webpack-dev-server": "^2.4.1" 27 | }, 28 | "dependencies": { 29 | "es6-promise-promise": "^1.0.0", 30 | "ladda-cache": "^0.1.2", 31 | "whatwg-fetch": "^2.0.2" 32 | }, 33 | "description": "" 34 | } 35 | -------------------------------------------------------------------------------- /examples/minimal-vue-ladda/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "minimal-vue-ladda", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "scripts": { 6 | "start": "webpack-dev-server --progress --colors --config ./webpack.config.js", 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "keywords": [], 10 | "author": "", 11 | "license": "ISC", 12 | "babel": { 13 | "presets": [ 14 | "es2015", 15 | "stage-2" 16 | ] 17 | }, 18 | "devDependencies": { 19 | "babel-core": "^6.23.1", 20 | "babel-loader": "^6.3.2", 21 | "babel-preset-es2015": "^6.22.0", 22 | "babel-preset-stage-2": "^6.22.0", 23 | "exports-loader": "^0.6.4", 24 | "imports-loader": "^0.7.1", 25 | "webpack": "^2.2.1", 26 | "webpack-dev-server": "^2.4.1" 27 | }, 28 | "dependencies": { 29 | "es6-promise-promise": "^1.0.0", 30 | "ladda-cache": "^0.1.2", 31 | "vue": "^2.2.1", 32 | "whatwg-fetch": "^2.0.3" 33 | }, 34 | "description": "" 35 | } 36 | -------------------------------------------------------------------------------- /src/plugins/dedup/index.js: -------------------------------------------------------------------------------- 1 | import { reduce } from 'ladda-fp'; 2 | 3 | const toKey = (args) => JSON.stringify(args); 4 | 5 | const isActive = reduce( 6 | (active, conf = {}) => active && (conf.enableDeduplication || 7 | conf.enableDeduplication === undefined), 8 | true 9 | ); 10 | 11 | export const dedupPlugin = ({ config }) => ({ entity, fn }) => { 12 | if (fn.operation !== 'READ') { return fn; } 13 | const cache = {}; 14 | 15 | return (...args) => { 16 | if (!isActive([config, entity, fn])) { 17 | return fn(...args); 18 | } 19 | 20 | const key = toKey(args); 21 | const cached = cache[key]; 22 | if (cached) { return cached; } 23 | 24 | const promise = fn(...args); 25 | cache[key] = promise; 26 | const cleanup = () => delete cache[key]; 27 | 28 | return promise.then((res) => { 29 | cleanup(); 30 | return res; 31 | }, (err) => { 32 | cleanup(); 33 | return Promise.reject(err); 34 | }); 35 | }; 36 | }; 37 | -------------------------------------------------------------------------------- /src/plugins/cache/merger.js: -------------------------------------------------------------------------------- 1 | export function merge(source, destination) { 2 | const result = { ...destination }; 3 | 4 | const keysForNonObjects = getNonObjectKeys(source); 5 | keysForNonObjects.forEach(key => { 6 | if (destination[key] !== undefined) { 7 | result[key] = source[key]; 8 | } 9 | }); 10 | 11 | const keysForObjects = getObjectKeys(source); 12 | keysForObjects.forEach(key => { 13 | if (destination[key] !== undefined) { 14 | result[key] = merge(source[key], destination[key]); 15 | } 16 | }); 17 | 18 | return result; 19 | } 20 | 21 | function getNonObjectKeys(object) { 22 | return Object.keys(object).filter(key => { 23 | return object[key] === null 24 | || typeof object[key] !== 'object' 25 | || Array.isArray(object[key]); 26 | }); 27 | } 28 | 29 | function getObjectKeys(object) { 30 | return Object.keys(object).filter(key => { 31 | return object[key] !== null 32 | && !Array.isArray(object[key]) 33 | && typeof object[key] === 'object'; 34 | }); 35 | } 36 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Table of Contents 2 | 3 | * [Read Me](/README.md) 4 | * [Background](/docs/Background.md) 5 | * [Introduction](/docs/Introduction.md) 6 | * [Demos](/docs/Demos.md) 7 | * [Getting Started](/docs/GettingStarted.md) 8 | * [Concepts](/docs/Concepts.md) 9 | * [Basics](/docs/basics/README.md) 10 | * [Operations](/docs/basics/Operations.md) 11 | * [Time To Live](/docs/basics/TimeToLive.md) 12 | * [Advanced](/docs/advanced/README.md) 13 | * [Invalidation](/docs/advanced/Invalidation.md) 14 | * [Views](/docs/advanced/ViewOf.md) 15 | * [Deduplication](/docs/advanced/Deduplication.md) 16 | * [Custom ID](/docs/advanced/CustomId.md) 17 | * [Plugins](/docs/advanced/Plugins.md) 18 | * [Cheat Sheet](/docs/advanced/Plugins-CheatSheet.md) 19 | * [Known Plugins](/docs/advanced/Plugins-KnownPlugins.md) 20 | * [Recipes](/docs/Recipes.md) 21 | * [Separate API Folder](/docs/recipes/SeparateApiFolder.md) 22 | * [Polling](/docs/recipes/Polling.md) 23 | * [Configuration Reference](/docs/basics/Configuration.md) 24 | * [Main Contributors](/docs/MainContributors.md) 25 | * [Contribute](/docs/Contribute.md) 26 | -------------------------------------------------------------------------------- /examples/minimal-react-ladda/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "minimal-react-ladda", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "webpack-dev-server --progress --colors --hot --config ./webpack.config.js", 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "keywords": [], 11 | "author": "", 12 | "license": "ISC", 13 | "babel": { 14 | "presets": [ 15 | "es2015", 16 | "react", 17 | "stage-2" 18 | ] 19 | }, 20 | "devDependencies": { 21 | "babel-core": "^6.23.1", 22 | "babel-loader": "^6.3.2", 23 | "babel-preset-es2015": "^6.22.0", 24 | "babel-preset-react": "^6.23.0", 25 | "babel-preset-stage-2": "^6.22.0", 26 | "exports-loader": "^0.6.4", 27 | "imports-loader": "^0.7.1", 28 | "react-hot-loader": "^1.3.1", 29 | "webpack": "^2.2.1", 30 | "webpack-dev-server": "^2.4.1" 31 | }, 32 | "dependencies": { 33 | "es6-promise-promise": "^1.0.0", 34 | "ladda-cache": "^0.1.2", 35 | "react": "^15.4.2", 36 | "react-dom": "^15.4.2", 37 | "whatwg-fetch": "^2.0.3" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Peter Crona 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 | -------------------------------------------------------------------------------- /examples/minimal-vanilla-ladda/src/index.js: -------------------------------------------------------------------------------- 1 | import api from './api'; 2 | 3 | const addButtonEvent = () => 4 | document.getElementById('searchButton') 5 | .addEventListener('click', onSearch); 6 | 7 | const onSearch = () => { 8 | removeList(); 9 | 10 | doSearch(getElementByIdValue('searchInput')) 11 | .then(appendList); 12 | }; 13 | 14 | const getElementByIdValue = id => 15 | document.getElementById(id).value; 16 | 17 | const doSearch = query => 18 | api.hackernews.getList(query); 19 | 20 | const removeList = () => { 21 | const listNode = document.getElementById('list'); 22 | 23 | if (listNode) { 24 | listNode.parentNode.removeChild(listNode); 25 | } 26 | } 27 | 28 | const appendList = list => { 29 | const listNode = document.createElement('div'); 30 | listNode.setAttribute('id', 'list'); 31 | document.getElementById('app').appendChild(listNode); 32 | 33 | list.forEach(appendItem(listNode)); 34 | }; 35 | 36 | const appendItem = listNode => item => { 37 | const itemNode = document.createElement('div'); 38 | itemNode.appendChild(document.createTextNode(item.title)); 39 | listNode.appendChild(itemNode); 40 | }; 41 | 42 | (() => addButtonEvent())(); 43 | -------------------------------------------------------------------------------- /docs/advanced/ChangeListener.md: -------------------------------------------------------------------------------- 1 | # Change Listener 2 | 3 | The returned api object of Ladda's `build` function exposes a registration function to be notified every time entities inside Ladda's cache change. The field is called `__addChangeListener`. 4 | 5 | ```javascript 6 | import { build } from 'ladda-cache'; 7 | 8 | const config = { /* your configuration here */ }; 9 | const api = build(config); 10 | 11 | api.__addChangeListener((change) => /* act on change */) 12 | ``` 13 | 14 | `__addChangeListener` returns an unsubscribe function to stop listening 15 | for changes. 16 | 17 | ```javascript 18 | const unsubscribe = api.__addChangeListener((change) => /* act on change */) 19 | unsubscribe(); 20 | ``` 21 | 22 | The signature of the change object is as follows: 23 | ```javascript 24 | { 25 | type: 'UPDATE' | 'REMOVE', 26 | entity: EntityName, 27 | entities: EntityValue[] 28 | } 29 | ``` 30 | 31 | At this point in time there is no difference made between adding new 32 | EntityValues and updating already present ones: Both events lead to a 33 | change of the type `UPDATE`. 34 | The `entities` field is guaranteed to be a list of EntityValues, even if 35 | a change only affects a single entity. 36 | 37 | 38 | -------------------------------------------------------------------------------- /docs/MainContributors.md: -------------------------------------------------------------------------------- 1 | # Main Contributors 2 | 3 | Many people are involved in Ladda in one way or another. Might be by just saying something giving us an idea, by ad-hoc feedback on Facebook or whathever. Thanks to all of you! 4 | 5 | Some people and organizations that contributed a lot: 6 | - [**Small Improvements**](https://www.small-improvements.com): The first user of Ladda and also allowed us to work on Ladda at work during our slack time! Being a place full of highly skilled developers, and being a feedback company, has resulted in plenty of constructive feedback for Ladda! 7 | 8 | - [**Robin Wieruch**](https://github.com/rwieruch): Pushed Ladda from being "Peter friendly" to being "Human friendly" (or well, at least "Developer friendly"). Kicked off the work with docs and examples. Proposed new features and discussed existing. 9 | 10 | - [**Gernot Höflechner**](https://github.com/LFDM/): Pushed Peter to work on Ladda. Kept bringing it up until it happened. Also involved in the [initial discussions](./Background.md) and discussions about the future of Ladda. 11 | 12 | - [**Peter Crona**](https://github.com/petercrona): Started the project and has worked hard to keep it nice and tidy. Focusing on the code, but also tried writing some documentation. 13 | -------------------------------------------------------------------------------- /docs/basics/TimeToLive.md: -------------------------------------------------------------------------------- 1 | # Time To Live 2 | 3 | For every cache it is important to fine-tune the TTL (time to live) for every entity. Ladda uses a sensible default of **300 seconds** (5 minutes). This works quite well in most applications, it protects against stale data, but also provides a great performance boost to your application and reduces the load on your server. 4 | 5 | It might be tempting to set the TTL to forever (a very high number). In theory, if you always invalidate your entities when you should, this would actually work and be the best option. However, you lose the self-repairing capability that a lower TTL offers. If you at some point would forget to invalidate some data, then your users would still eventually see the most recent data if the TTL for your cache is reasonably low (eg. 300 seconds). If the cache is set to forever, your users will need to refresh their browsers to get the most recent data. This, and the fact that one request every 300 seconds is already quite good, is the motivation for caching for 300 seconds by default. 6 | 7 | ## Configure TTL 8 | 9 | A simple example of TTL being set to 60 seconds: 10 | 11 | ```javascript 12 | const config = { 13 | user: { 14 | ttl: 60, 15 | api: userApi 16 | } 17 | }; 18 | ``` 19 | -------------------------------------------------------------------------------- /docs/advanced/Plugins-KnownPlugins.md: -------------------------------------------------------------------------------- 1 | # Known Plugins 2 | 3 |
4 |
5 | Just created your own Ladda plugin? 6 |
7 |
8 | Feel free to add it to this list and share it with the community! 9 |
10 |
11 | 12 | - [ladda-logger](https://github.com/ladda-js/ladda-logger) 13 | 14 | A more sophisticated version of the plugin we just build. Logs on every 15 | change to the Ladda cache and gives you timings on how long your API 16 | calls take. 17 | 18 | - [ladda-observable](https://github.com/ladda-js/ladda-observable) 19 | 20 | Adds an observable interface to all `READ` operations. Allows you to be 21 | notified whenever something related to your API call has changed, e.g. 22 | you can observe a list of entities and get notified once one of this 23 | changes is updated. 24 | 25 | - [ladda-denormalizer](https://github.com/ladda-js/ladda-denormalizer) 26 | 27 | Allows to define denormalization schemas and strategies, so that your 28 | server can send plain ids instead of full entity objects. The plugin 29 | will resolve these ids for you in an optimized fashion, so that your 30 | client-side code can stay simple an operate on full entities. 31 | 32 | -------------------------------------------------------------------------------- /docs/Demos.md: -------------------------------------------------------------------------------- 1 | # Demos 2 | 3 | Check out some of our demos, designed to show some of the benefits of Ladda. 4 | 5 | ## CRUD Example 6 | 7 | This application showcases Ladda's CRUD caching capabilities, including 8 | updating Entities, as well as invalidation across different Entities. 9 | [Check it out](http://opensource.small-improvements.com/ladda-example-crud/) and make sure you also take a look at the [source code](https://github.com/SmallImprovements/ladda-example-crud), which contains a more detailed description. 10 | 11 | ## Searching Hacker News 12 | 13 | This is an example to see Ladda in action solely for the `READ` operation. It interacts with an API and shows how you would benefit from the cache. The example shows you the time to live (TTL) for every cached result, which has been configured to 15 seconds. It also shows you how many cache hits and misses you had, and an estimation of time saved by using the cache. [Check it out](https://rwieruch.github.io/ladda-react-example/). 14 | 15 | ## Examples in the Ladda Repository 16 | 17 | There are a couple of [examples](https://github.com/petercrona/ladda/tree/master/examples) in the Ladda repository. You can clone Ladda to your local machine, navigate to one of the examples and follow the *README.md* to start the example. 18 | -------------------------------------------------------------------------------- /src/plugins/cache/merger.spec.js: -------------------------------------------------------------------------------- 1 | import {merge} from './merger'; 2 | 3 | describe('Merger', () => { 4 | it('overwrites stuff in dest', () => { 5 | const src = {a: 'hej'}; 6 | const dest = {a: 'hello'}; 7 | const res = merge(src, dest); 8 | expect(res.a).to.equal('hej'); 9 | }); 10 | it('do not write anything that does not exist in destination object', () => { 11 | const src = {a: 'hej'}; 12 | const dest = {}; 13 | const res = merge(src, dest); 14 | expect(res).to.deep.equal(dest); 15 | }); 16 | it('merge objects in the objects', () => { 17 | const src = {a: {b: {c: 'hello'}}}; 18 | const dest = {a: {b: {c: 'hej'}}}; 19 | const res = merge(src, dest); 20 | expect(res.a.b.c).to.equal('hello'); 21 | }); 22 | it('do not merge objects in the objects of destination lack object', () => { 23 | const src = {a: {b: {c: 'hello'}}}; 24 | const dest = {a: {b: {e: 'hej'}}}; 25 | const res = merge(src, dest); 26 | expect(Object.keys(res.a.b)).to.not.deep.equal(['c']); 27 | }); 28 | it('do not write anything that does not exist in destination object (object)', () => { 29 | const src = {a: {foo: 'bar'}}; 30 | const dest = {}; 31 | const res = merge(src, dest); 32 | expect(res).to.deep.equal(dest); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /examples/minimal-react-ladda/src/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | 4 | import api from './api'; 5 | 6 | class App extends Component { 7 | 8 | constructor(props) { 9 | super(props); 10 | this.state = { list: null }; 11 | } 12 | 13 | onSearch = (e) => { 14 | e.preventDefault(); 15 | 16 | if (this.input.value == '') { 17 | return; 18 | } 19 | 20 | api.hackernews.getList(this.input.value) 21 | .then((hits) => this.setState({ list: hits })); 22 | } 23 | 24 | render() { 25 | const { list } = this.state; 26 | return ( 27 |
28 |

Search Hacker News with Ladda

29 |

There shouldn't be a second network request, when you search for something twice.

30 |
31 | this.input = node} /> 32 | 33 |
34 | { list && list.map(item =>
{item.title}
) } 35 |
36 | ); 37 | } 38 | 39 | } 40 | 41 | ReactDOM.render( 42 | , 43 | document.getElementById('app') 44 | ); 45 | 46 | module.hot.accept(); 47 | -------------------------------------------------------------------------------- /src/plugins/cache/serializer.spec.js: -------------------------------------------------------------------------------- 1 | import {serialize} from './serializer'; 2 | 3 | describe('Serializer', () => { 4 | it('serializes string to itself', () => { 5 | const res = serialize('hello'); 6 | expect(res).to.equal('hello'); 7 | }); 8 | it('serializes date to iso string', () => { 9 | const d = new Date('2018-04-18T08:55:54.974Z'); 10 | const res = serialize(d); 11 | expect(res).to.equal('2018-04-18T08:55:54.974Z'); 12 | }); 13 | it('serializes object by taking its values and joining with -', () => { 14 | const res = serialize({a: 'hello', b: 'world'}); 15 | expect(res).to.equal('hello-world'); 16 | }); 17 | it('serializes objects recursively by taking its values and joining with -', () => { 18 | const res = serialize({a: 'hello', b: {c: 'world', d: '!'}}); 19 | expect(res).to.equal('hello-world-!'); 20 | }); 21 | it('serializes nested objects and arrays properly', () => { 22 | const a = [[[[1]]]]; 23 | const d = new Date(); 24 | const o = {a: {b: d}}; 25 | expect(serialize(a)).to.equal('1'); 26 | expect(serialize(o)).to.equal(d.toISOString()); 27 | }); 28 | it('int serialize to int', () => { 29 | const res = serialize(1); 30 | expect(res).to.equal(1); 31 | }); 32 | it('falsy returns itself', () => { 33 | const res = serialize(null); 34 | expect(res).to.equal(null); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /src/plugins/cache/id-helper.js: -------------------------------------------------------------------------------- 1 | import {curry, map, prop} from 'ladda-fp'; 2 | import {serialize} from './serializer'; 3 | 4 | export const EMPTY_ARGS_PLACEHOLDER = '__EMPTY_ARGS__'; 5 | 6 | const createIdFromArgs = (args) => serialize(args) || EMPTY_ARGS_PLACEHOLDER; 7 | 8 | const getIdGetter = (c, aFn) => { 9 | if (aFn && aFn.idFrom && typeof aFn.idFrom === 'function') { 10 | return aFn.idFrom; 11 | } 12 | return prop(c.idField || 'id'); 13 | }; 14 | 15 | export const getId = curry((c, aFn, args, o) => { 16 | if (aFn && aFn.idFrom === 'ARGS') { 17 | return createIdFromArgs(args); 18 | } 19 | return getIdGetter(c, aFn)(o); 20 | }); 21 | 22 | export const addId = curry((c, aFn, args, o) => { 23 | if (aFn && aFn.idFrom === 'ARGS') { 24 | return { 25 | ...o, 26 | __ladda__id: createIdFromArgs(args) 27 | }; 28 | } 29 | const getId_ = getIdGetter(c, aFn); 30 | if (Array.isArray(o)) { 31 | return map(x => ({ 32 | ...x, 33 | __ladda__id: getId_(x) 34 | }), o); 35 | } 36 | return { 37 | ...o, 38 | __ladda__id: getId_(o) 39 | }; 40 | }); 41 | 42 | export const removeId = (o) => { 43 | if (!o) { 44 | return o; 45 | } 46 | 47 | if (Array.isArray(o)) { 48 | return map(x => { 49 | delete x.__ladda__id; 50 | return x; 51 | }, o); 52 | } 53 | delete o.__ladda__id; 54 | return o; 55 | }; 56 | -------------------------------------------------------------------------------- /src/plugins/cache/index.js: -------------------------------------------------------------------------------- 1 | import {curry, values} from 'ladda-fp'; 2 | import {createCache} from './cache'; 3 | import {decorateCreate} from './operations/create'; 4 | import {decorateRead} from './operations/read'; 5 | import {decorateUpdate} from './operations/update'; 6 | import {decorateDelete} from './operations/delete'; 7 | import {decorateCommand} from './operations/command'; 8 | import {decorateNoOperation} from './operations/no-operation'; 9 | 10 | const HANDLERS = { 11 | CREATE: decorateCreate, 12 | READ: decorateRead, 13 | UPDATE: decorateUpdate, 14 | DELETE: decorateDelete, 15 | COMMAND: decorateCommand, 16 | NO_OPERATION: decorateNoOperation 17 | }; 18 | 19 | const normalizePayload = payload => { 20 | if (payload === null) { 21 | return payload; 22 | } 23 | return Array.isArray(payload) ? payload : [payload]; 24 | }; 25 | 26 | const notify = curry((onChange, entity, fn, args, payload) => { 27 | onChange({ 28 | operation: fn.operation, 29 | entity: entity.name, 30 | apiFn: fn.fnName, 31 | values: normalizePayload(payload), 32 | args 33 | }); 34 | }); 35 | 36 | export const cachePlugin = (onChange) => ({ config, entityConfigs }) => { 37 | const cache = createCache(values(entityConfigs)); 38 | return ({ entity, fn }) => { 39 | const handler = HANDLERS[fn.operation]; 40 | const notify_ = notify(onChange, entity, fn); 41 | return handler(config, cache, notify_, entity, fn); 42 | }; 43 | }; 44 | -------------------------------------------------------------------------------- /src/plugins/cache/id-helper.spec.js: -------------------------------------------------------------------------------- 1 | import {addId, removeId, EMPTY_ARGS_PLACEHOLDER} from './id-helper'; 2 | 3 | describe('IdHelper', () => { 4 | it('removeId is the inverse of addId', () => { 5 | const o = {id: 1}; 6 | expect(removeId(addId({}, undefined, undefined, o))).to.deep.equal(o); 7 | }); 8 | it('addId creates id from args if idFrom is ARGS', () => { 9 | const o = {name: 'kalle'}; 10 | const aFn = {idFrom: 'ARGS'}; 11 | expect(addId({}, aFn, [1, 2, 3], o)).to.deep.equal( 12 | {...o, __ladda__id: '1-2-3'} 13 | ); 14 | }); 15 | it('addId handles empty args if idFrom is ARGS', () => { 16 | const o = {name: 'kalle'}; 17 | const aFn = {idFrom: 'ARGS'}; 18 | expect(addId({}, aFn, [], o)).to.deep.equal( 19 | {...o, __ladda__id: EMPTY_ARGS_PLACEHOLDER} 20 | ); 21 | }); 22 | it('removing id from undefined returns undefined', () => { 23 | const o = undefined; 24 | expect(removeId(o)).to.equal(o); 25 | }); 26 | it('removing id from array remove it from individual elements', () => { 27 | const o = [{__ladda__id: 1, id: 1}, {__ladda__id: 2, id: 2}, {__ladda__id: 3, id: 3}]; 28 | expect(removeId(o)).to.deep.equal([{id: 1}, {id: 2}, {id: 3}]); 29 | }); 30 | it('addId can use a custom function', () => { 31 | const o = {myId: 15}; 32 | const aFn = {idFrom: x => x.myId}; 33 | const res = addId({}, aFn, [1, 2, 3], o); 34 | expect(res).to.deep.equal({...o, __ladda__id: 15}); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /docs/Concepts.md: -------------------------------------------------------------------------------- 1 | # Concepts 2 | 3 | Please consider this a glossary, it introduces concepts that are important in Ladda and will help you to better understand the documentation and code. 4 | 5 | * **ID**: Unique identifier for a EntityValue. By default assumed to be the property "id" of an object. But can be overwritten (see Ladda Config). 6 | 7 | * **Entity**: An Entity is an object with a specified set of keys and values they store. For instance, User can be an entity specified as *user { id, name, email, phoneNumber }*. MiniUser can be another entity, specified as *miniUser { id, name }*. 8 | 9 | * **EntityName**: For example "user". Used to reference entities (see Entity definition). 10 | 11 | * **EntityConfig**: Configuration of an entity. 12 | 13 | * **Api**: Registered in the EntityConfig. Technically it is an object with keys corresponding to function names and values to ApiFunctions. 14 | 15 | * **ApiFunction**: A function returning a Promise and that is part of an Api. 16 | 17 | * **EntityValue**: An object fullfilling the specification of an Entity. This is the main type used and required for all the advanced features of Ladda. Eg. *{ id, name, email, phoneNumber}* which is the EntitityValue for the entity user (specified in the Entity definition). 18 | 19 | * **BlobValue**: Can be either a list, an object or just a single value. Differs from EntityValue in that no ID exists. You use this type by specifying `yourFunction.idFrom = 'ARGS'`. The arguments with which you called the ApiFunction will be used to create an ID. 20 | -------------------------------------------------------------------------------- /docs/GettingStarted.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | Do a `npm install ladda-cache --save` in your project. Now, to use Ladda you need to configure it and export an API built from the configuration. Create a file "api/index.js": 4 | 5 | ```javascript 6 | import * as projectApi from './project'; 7 | import { build } from 'ladda-cache'; 8 | 9 | const config = { 10 | project: { 11 | api: projectApi 12 | } 13 | }; 14 | 15 | export default build(config); 16 | ``` 17 | 18 | where project is a bunch of api-methods (living in /api/project.js) returning promises, it might look like: 19 | 20 | ```javascript 21 | getProjectsCreatedAfter.operation = 'READ'; 22 | export function getProjectsCreatedAfter(date) { 23 | return get(resource, {date}); // Returns a promise containing a list of projects (where each project has an ID) 24 | } 25 | ``` 26 | 27 | where `get` is a function performing a HTTP-requests and returning a promise. You will need to create an API for your own application using your own method for creating get requests (for example [Axios](https://github.com/axios/axios)). When you call `getProjectsCreatedAfter` the results will be cached. So if you call it more than once within 300s (default time to life for the cache), only one HTTP-request will be made per date. Just don't forget that each project must have an ID, by default in a property "id" (eg. project.id). 28 | 29 | For a very concise and self-contained example, check out [this minimal example](https://github.com/petercrona/ladda-example-mini-project/blob/master/script.js). 30 | -------------------------------------------------------------------------------- /docs/Contribute.md: -------------------------------------------------------------------------------- 1 | # Contribute 2 | 3 | We'd love to see others help out with Ladda. There's definitely room for improvement everywhere. Testing, documentation, coding, thinking about new features yet more important refining existing features. 4 | 5 | ## Guidelines 6 | 7 | Things that are important to us: 8 | 9 | - Before creating a PR, make sure to write up an Issue 10 | 11 | - When creating a PR, make sure to have essential test coverage 12 | 13 | - The core API needs to be stable - try to not change it or have good reasons for changing it 14 | 15 | - Don't try make Ladda something else - it is not Relay/GraphQL 16 | 17 | - Keep it simple - think about how to allow for a plugin that does what you think of rather than extending Ladda unless it is obviously something that belongs in Ladda's core code. 18 | 19 | ## Further Feature Ideas 20 | 21 | Just a collection of ideas, might happen but might also not happen: 22 | 23 | - Expose when an entity has been manipulated with the goal to make it easy to extend Ladda outside of the core code (eg. to build framework integrations). 24 | 25 | - Optimistic updates 26 | 27 | - Framework integrations (for example a React HOC that re-renders on changes to entities) that should be an independent library 28 | 29 | - Multiple request support (handle the case were Ladda gets multiple requests before the first one has resolved) 30 | 31 | - Garbage collection - Eg. Keep track of data without any references and no "byId" ApiFunctions. Delete it. 32 | 33 | - Cache aliases (allow a ApiFunction to write to another ApiFunctions cache, useful for polling) 34 | 35 | Feel free to propose a feature in the Issues. 36 | -------------------------------------------------------------------------------- /docs/advanced/Plugins-CheatSheet.md: -------------------------------------------------------------------------------- 1 | # Plugins Cheat Sheet 2 | 3 | ## Signature 4 | 5 | ```javascript 6 | ({ entityConfigs, config, addChangeListener }) => ({ entity, fn }) => ApiFn 7 | ``` 8 | 9 |
10 | 11 | It is a good practice to allow your users to pass in an additional 12 | plugin configuration object, thus reaching a final shape like this: 13 | 14 | ```javascript 15 | export const yourPlugin = (pluginConfig = {}) => { 16 | return ({ entityConfigs, config, addChangeListener }) => { 17 | // Use this space to setup additional data structures and helpers, 18 | // that act across entities. 19 | return ({ entity, fn }) => { 20 | // Use this space to setup additional data structures and helpers, 21 | // that act on a single entity. 22 | return (...args) => { 23 | // Do your magic here! 24 | // Invoke the original fn with its arguments or a variation of it. 25 | return fn(...args); 26 | }; 27 | }; 28 | }; 29 | }; 30 | ``` 31 | 32 | We commonly refer to this process as __create => setup => decorate__ 33 | steps, with the final goal of producing a decorated __ApiFunction__. 34 | 35 | ## Apply a plugin 36 | 37 | Pass plugins in a list of plugins as an optional second argument to 38 | Ladda's `build` function. 39 | 40 | ```javascript 41 | import { build } from 'ladda-cache'; 42 | import { logger } from 'ladda-logger'; 43 | import { observable } from 'ladda-observable'; 44 | 45 | const config = { /* your ladda configuration */ }; 46 | 47 | export default build(config, [ 48 | observable(), 49 | logger() 50 | ]); 51 | ``` 52 | 53 | Plugins are evaluated from left to right. 54 | 55 | -------------------------------------------------------------------------------- /docs/basics/Operations.md: -------------------------------------------------------------------------------- 1 | # Operations 2 | 3 | Ladda shows its whole potential in a create, read, update & delete application. The four pillars are the basic functions for a persistent storage. These are used in most RESTful APIs yet Ladda can opt-in to benefit from the paradigm. Below is a list of Ladda's requirements on API-functions of each type. 4 | 5 | * **CREATE**: An API call which returns an EntityValue (object with an ID, eg. a user). 6 | 7 | * **READ**: An API call which returns an EntityValue, or a list of EntityValues (eg. a user or list of users). 8 | 9 | * **UPDATE**: Takes the updated EntityValue as the first argument (eg. a user). No assumptions on what is returned by the server are made. The first argument provided is used to update the cache. 10 | 11 | * **DELETE**: Takes an ID as the first argument. No assumptions on what is returned by the server are made. 12 | 13 | * **COMMAND**: Takes any arguments and expects an updated EntityValue, or a list of them, as return value. This return value is used to update the cache. 14 | 15 | * **NO_OPERATION** : When no operation is specified Ladda will not do anything by default. However, you can still use the invalidation logic of Ladda, see EntityConfig. No assumptions on what is returned by the server are made. 16 | 17 | An example of how a function of operation CREATE might look is: 18 | 19 | ```javascript 20 | createUser.operation = 'CREATE'; 21 | function createUser(user) { 22 | return performPostRequst('/api/user', user); 23 | } 24 | ``` 25 | 26 | Note that the backend must return a User in the example above. The user must have an ID, by default as the property "id" (eg. user.id). 27 | -------------------------------------------------------------------------------- /src/plugins/cache/index.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-expressions */ 2 | 3 | import {decorate} from './index'; 4 | import {createEntityStore} from './entity-store'; 5 | import {createQueryCache, put, contains} from './query-cache'; 6 | import {addId} from './id-helper'; 7 | import {createApiFunction} from './test-helper'; 8 | 9 | const config = [ 10 | { 11 | name: 'user', 12 | ttl: 300, 13 | api: { 14 | getUsers: (x) => x, 15 | getUsers2: (x) => x, 16 | deleteUser: (x) => x 17 | }, 18 | invalidates: ['user'], 19 | invalidatesOn: ['GET'] 20 | }, 21 | { 22 | name: 'cars', 23 | ttl: 200, 24 | api: { 25 | triggerCarValueCalculation: createApiFunction((x) => Promise.resolve([x])) 26 | }, 27 | invalidates: ['user'], 28 | invalidatesOn: ['NO_OPERATION'] 29 | } 30 | ]; 31 | 32 | 33 | describe('Decorate', () => { 34 | xit('decorated function invalidates if NO_OPERATION is configured', (done) => { 35 | const aFn = createApiFunction(() => Promise.resolve('hej')); 36 | const xOrg = [{id: 1, name: 'Kalle'}]; 37 | const es = createEntityStore(config); 38 | const qc = createQueryCache(es); 39 | const eUser = config[0]; 40 | const eCar = config[1]; 41 | const carsApi = decorate({}, es, qc, eCar, aFn); 42 | put(qc, eUser, aFn, [1], addId({}, undefined, undefined, xOrg)); 43 | 44 | expect(contains(qc, eUser, aFn, [1])).to.be.true; 45 | const shouldHaveRemovedUser = () => { 46 | expect(contains(qc, eUser, aFn, [1])).to.be.false; 47 | done(); 48 | }; 49 | carsApi.api.triggerCarValueCalculation(xOrg).then(shouldHaveRemovedUser); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /src/plugins/cache/cache.js: -------------------------------------------------------------------------------- 1 | import {curry} from 'ladda-fp'; 2 | import * as QueryCache from './query-cache'; 3 | import * as EntityStore from './entity-store'; 4 | 5 | export const createCache = (entityConfigs, onChange) => { 6 | const entityStore = EntityStore.createEntityStore(entityConfigs, onChange); 7 | const queryCache = QueryCache.createQueryCache(entityStore, onChange); 8 | return {entityStore, queryCache}; 9 | }; 10 | 11 | export const storeQueryResponse = ({queryCache}, ...args) => { 12 | return QueryCache.put(queryCache, ...args); 13 | }; 14 | 15 | export const getQueryResponse = QueryCache.getValue; 16 | 17 | export const getQueryResponseWithMeta = ({queryCache}, ...args) => { 18 | return QueryCache.get(queryCache, ...args); 19 | }; 20 | 21 | export const containsQueryResponse = ({queryCache}, ...args) => { 22 | return QueryCache.contains(queryCache, ...args); 23 | }; 24 | 25 | export const invalidateQuery = ({queryCache}, ...args) => { 26 | return QueryCache.invalidate(queryCache, ...args); 27 | }; 28 | 29 | export const hasExpired = ({queryCache}, ...args) => { 30 | return QueryCache.hasExpired(queryCache, ...args); 31 | }; 32 | 33 | export const storeCreateEvent = curry(({queryCache}, entity, id) => { 34 | return QueryCache.storeCreateEvent(queryCache, entity, id); 35 | }); 36 | 37 | export const storeEntity = curry(({entityStore}, ...args) => { 38 | return EntityStore.put(entityStore, ...args); 39 | }); 40 | 41 | export const storeEntities = ({entityStore}, ...args) => { 42 | return EntityStore.mPut(entityStore, ...args); 43 | }; 44 | 45 | export const getEntity = curry(({entityStore}, ...args) => { 46 | return EntityStore.get(entityStore, ...args); 47 | }); 48 | 49 | export const removeEntity = ({entityStore}, ...args) => { 50 | return EntityStore.remove(entityStore, ...args); 51 | }; 52 | 53 | export const containsEntity = ({entityStore}, ...args) => { 54 | return EntityStore.contains(entityStore, ...args); 55 | }; 56 | 57 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ladda-cache", 3 | "version": "0.2.20", 4 | "description": "Data fetching layer with support for caching", 5 | "main": "dist/bundle.js", 6 | "module": "dist/module.js", 7 | "files": [ 8 | "dist/module.js" 9 | ], 10 | "dependencies": { 11 | "ladda-fp": "^0.2.3" 12 | }, 13 | "devDependencies": { 14 | "babel-core": "^6.9.0", 15 | "babel-plugin-external-helpers": "^6.22.0", 16 | "babel-plugin-istanbul": "^4.0.0", 17 | "babel-preset-env": "^1.6.1", 18 | "babel-preset-stage-1": "^6.5.0", 19 | "babel-preset-stage-2": "^6.5.0", 20 | "babel-register": "^6.9.0", 21 | "chai": "^3.5.0", 22 | "coveralls": "^2.12.0", 23 | "eslint": "^3.17.1", 24 | "eslint-config-airbnb-base": "^11.1.1", 25 | "eslint-plugin-import": "^2.2.0", 26 | "gitbook-cli": "^2.3.0", 27 | "mocha": "^2.5.3", 28 | "nyc": "^10.1.2", 29 | "rollup": "^0.53.0", 30 | "rollup-plugin-babel": "^3.0.3", 31 | "sinon": "^1.17.7", 32 | "sinon-chai": "^2.8.0" 33 | }, 34 | "scripts": { 35 | "docs:prepare": "gitbook install", 36 | "docs:watch": "npm run docs:prepare && gitbook serve", 37 | "test": "env NODE_PATH=$NODE_PATH:$PWD/src NODE_ENV=test ./node_modules/.bin/mocha --compilers js:babel-register --reporter spec src/*.spec.js 'src/**/*.spec.js' --require mocha.config", 38 | "coverage": "env NODE_PATH=$NODE_PATH:$PWD/src NODE_ENV=test nyc -x '**/*.spec.js' -x '**/*.config.js' --reporter=lcov --reporter=text mocha --compilers js:babel-register --reporter spec src/*.spec.js 'src/**/*.spec.js' --require mocha.config", 39 | "lint": "eslint src", 40 | "prepublish": "npm test && npm run build", 41 | "build": "rollup -c" 42 | }, 43 | "author": [ 44 | "Peter Crona (http://www.icecoldcode.com)", 45 | "Gernot Hoeflechner <1986gh@gmail.com> (http://github.com/lfdm)" 46 | ], 47 | "license": "MIT", 48 | "repository": { 49 | "type": "git", 50 | "url": "https://github.com/ladda-js/ladda.git" 51 | }, 52 | "homepage": "https://www.ladda.io/" 53 | } 54 | -------------------------------------------------------------------------------- /src/plugins/cache/test-helper.js: -------------------------------------------------------------------------------- 1 | import {identity} from 'ladda-fp'; 2 | 3 | export const createApiFunction = (fn, config = {}) => { 4 | const fnCopy = fn.bind(null); 5 | fnCopy.operation = config.operation || 'NO_OPERATION'; 6 | fnCopy.invalidates = config.invalidates || []; 7 | fnCopy.idFrom = config.idFrom || 'ENTITY'; 8 | fnCopy.byId = config.byId || false; 9 | fnCopy.byIds = config.byIds || false; 10 | return fnCopy; 11 | }; 12 | 13 | export const createEntityConfig = (config) => { 14 | return { 15 | ttl: 300, 16 | invalidates: [], 17 | invalidatesOn: ['CREATE', 'UPDATE', 'DELETE'], 18 | ...config 19 | }; 20 | }; 21 | 22 | export const createSampleConfig = () => { 23 | return [ 24 | createEntityConfig({ 25 | name: 'user', 26 | api: { 27 | getUsers: createApiFunction(identity, {operation: 'READ'}), 28 | getUsers2: createApiFunction(identity, {operation: 'READ'}), 29 | deleteUser: createApiFunction(identity, {operation: 'DELETE'}) 30 | }, 31 | invalidates: ['user'], 32 | invalidatesOn: ['READ'] 33 | }), 34 | createEntityConfig({ 35 | name: 'userPreview', 36 | api: { 37 | getPreviews: createApiFunction(identity, {operation: 'READ'}), 38 | updatePreview: createApiFunction(identity, {operation: 'UPDATE'}) 39 | }, 40 | viewOf: 'user' 41 | }), 42 | createEntityConfig({ 43 | name: 'cars', 44 | ttl: 200, 45 | api: { 46 | getCars: createApiFunction(identity, {operation: 'READ'}), 47 | updateCar: createApiFunction(identity, {operation: 'UPDATE'}) 48 | }, 49 | viewOf: 'user', 50 | invalidates: ['user'] 51 | }), 52 | createEntityConfig({ 53 | name: 'bikes', 54 | ttl: 200, 55 | api: { 56 | getCars: createApiFunction(identity, {operation: 'READ'}), 57 | updateCar: createApiFunction(identity, {operation: 'UPDATE'}) 58 | } 59 | }), 60 | createEntityConfig({ 61 | name: 'userSettings', 62 | api: { 63 | getSettings: createApiFunction(identity, {operation: 'READ'}), 64 | updateSettings: createApiFunction(identity, {operation: 'UPDATE'}) 65 | } 66 | }) 67 | ]; 68 | }; 69 | -------------------------------------------------------------------------------- /src/plugins/cache/operations/update.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-expressions */ 2 | 3 | import sinon from 'sinon'; 4 | import {curry} from 'ladda-fp'; 5 | import {decorateUpdate} from './update'; 6 | import * as Cache from '../cache'; 7 | import {createApiFunction} from '../test-helper'; 8 | 9 | const curryNoop = () => () => {}; 10 | 11 | const config = [ 12 | { 13 | name: 'user', 14 | ttl: 300, 15 | api: { 16 | getUsers: (x) => x, 17 | getUsers2: (x) => x, 18 | deleteUser: (x) => x 19 | }, 20 | invalidates: ['user'], 21 | invalidatesOn: ['GET'] 22 | }, 23 | { 24 | name: 'userPreview', 25 | ttl: 200, 26 | api: { 27 | getPreviews: (x) => x, 28 | updatePreview: (x) => x 29 | }, 30 | invalidates: ['fda'], 31 | viewOf: 'user' 32 | }, 33 | { 34 | name: 'listUser', 35 | ttl: 200, 36 | api: { 37 | getPreviews: (x) => x, 38 | updatePreview: (x) => x 39 | }, 40 | invalidates: ['fda'], 41 | viewOf: 'user' 42 | } 43 | ]; 44 | 45 | describe('Update', () => { 46 | describe('decorateUpdate', () => { 47 | it('Updates cache based on argument', () => { 48 | const cache = Cache.createCache(config); 49 | const e = config[0]; 50 | const xOrg = {id: 1, name: 'Kalle'}; 51 | const aFnWithoutSpy = createApiFunction(() => Promise.resolve(xOrg)); 52 | const aFn = sinon.spy(aFnWithoutSpy); 53 | 54 | const res = decorateUpdate({}, cache, curryNoop, e, aFn); 55 | return res(xOrg, 'other args').then(() => { 56 | expect(Cache.getEntity(cache, e, 1).value).to.deep.equal({...xOrg, __ladda__id: 1}); 57 | }); 58 | }); 59 | 60 | it('triggers an UPDATE notification', () => { 61 | const spy = sinon.spy(); 62 | const n = curry((a, b) => spy(a, b)); 63 | 64 | const cache = Cache.createCache(config); 65 | const e = config[0]; 66 | const xOrg = {id: 1, name: 'Kalle'}; 67 | const aFnWithoutSpy = createApiFunction(() => Promise.resolve(xOrg)); 68 | const aFn = sinon.spy(aFnWithoutSpy); 69 | 70 | const res = decorateUpdate({}, cache, n, e, aFn); 71 | return res(xOrg, 'other args').then(() => { 72 | expect(spy).to.have.been.calledOnce; 73 | expect(spy).to.have.been.calledWith([xOrg, 'other args'], xOrg); 74 | }); 75 | }); 76 | }); 77 | }); 78 | -------------------------------------------------------------------------------- /docs/recipes/SeparateApiFolder.md: -------------------------------------------------------------------------------- 1 | # Separate API Folder 2 | 3 | For many people one of the big benefits of using Ladda is in fact not related to Ladda itself. Something that we believe in is a layered architecture where the API is a separate layer. In essence, this means that there's one place, eg. one folder, where all API-requests are performed. Your application structure might look like: 4 | 5 | ``` 6 | - src 7 | -- pages 8 | -- components 9 | -- services 10 | -- api 11 | ``` 12 | 13 | By putting the client-side API-layer in a separate folder you can easily move it with you as you switch framework. And it's just nice to keep one of your application's boundaries clearly separated. It also means that Ladda can easily be added and, if you chose to, removed from your application. A common setup for Ladda is: 14 | 15 | ``` 16 | - src 17 | -- api 18 | --- index.js 19 | --- user.js 20 | --- mini-user.js 21 | ``` 22 | 23 | where you put your configuration in `index.js` and define your ApiFunctions in the other files named after the entities they are for. It might look like: 24 | 25 | *index.js*: 26 | ```javascript 27 | import * as userApi from './user'; 28 | import * as miniUserApi from './mini-user'; 29 | import { build } from 'ladda-cache'; 30 | 31 | const config = { 32 | user: { 33 | api: userApi 34 | }, 35 | miniUser: { 36 | api: miniUserApi, 37 | viewOf: 'user' 38 | } 39 | }; 40 | 41 | export default build(config); 42 | ``` 43 | 44 | *user.js*: 45 | ```javascript 46 | getUsers.operation = 'READ'; 47 | export function getUsers() { 48 | return get('/users'); 49 | } 50 | ``` 51 | 52 | *mini-user.js*: 53 | ```javascript 54 | getMiniUsers.operation = 'READ'; 55 | export function getMiniUsers() { 56 | return get('/mini-users'); 57 | } 58 | ``` 59 | 60 | Your application code will now use Ladda as: 61 | 62 | ```javascript 63 | import api from 'api'; 64 | 65 | api.miniUser.getMiniUsers().then(miniUsers => console.log(miniUsers); 66 | api.user.getUsers().then(users => console.log(users)); 67 | ``` 68 | 69 | If you would like to remove Ladda, you would simply update your `api/index.js` as: 70 | 71 | ```javascript 72 | import * as user from './user'; 73 | import * as miniUser from './mini-user'; 74 | 75 | return { 76 | user, 77 | miniUser 78 | }; 79 | ``` 80 | 81 | And enjoy your still operating application (without having a cache). 82 | -------------------------------------------------------------------------------- /src/plugins/cache/operations/create.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-expressions */ 2 | 3 | import sinon from 'sinon'; 4 | import {curry} from 'ladda-fp'; 5 | import {decorateCreate} from './create'; 6 | import {createCache, getEntity} from '../cache'; 7 | import {createApiFunction} from '../test-helper'; 8 | 9 | const curryNoop = () => () => {}; 10 | 11 | const config = [ 12 | { 13 | name: 'user', 14 | ttl: 300, 15 | api: { 16 | getUsers: (x) => x, 17 | getUsers2: (x) => x, 18 | deleteUser: (x) => x 19 | }, 20 | invalidates: ['user'], 21 | invalidatesOn: ['GET'] 22 | }, 23 | { 24 | name: 'userPreview', 25 | ttl: 200, 26 | api: { 27 | getPreviews: (x) => x, 28 | updatePreview: (x) => x 29 | }, 30 | invalidates: ['fda'], 31 | viewOf: 'user' 32 | }, 33 | { 34 | name: 'listUser', 35 | ttl: 200, 36 | api: { 37 | getPreviews: (x) => x, 38 | updatePreview: (x) => x 39 | }, 40 | invalidates: ['fda'], 41 | viewOf: 'user' 42 | } 43 | ]; 44 | 45 | describe('Create', () => { 46 | describe('decorateCreate', () => { 47 | it('Adds value to entity store', () => { 48 | const cache = createCache(config); 49 | const e = config[0]; 50 | const xOrg = {name: 'Kalle'}; 51 | const response = {...xOrg, id: 1}; 52 | const aFnWithoutSpy = createApiFunction(() => Promise.resolve(response)); 53 | const aFn = sinon.spy(aFnWithoutSpy); 54 | const res = decorateCreate({}, cache, curryNoop, e, aFn); 55 | return res(xOrg).then((newX) => { 56 | expect(newX).to.equal(response); 57 | expect(getEntity(cache, e, 1).value).to.deep.equal({...response, __ladda__id: 1}); 58 | }); 59 | }); 60 | 61 | it('triggers a CREATE notification', () => { 62 | const spy = sinon.spy(); 63 | const n = curry((a, b) => spy(a, b)); 64 | const cache = createCache(config); 65 | const e = config[0]; 66 | const xOrg = {name: 'Kalle'}; 67 | const response = {...xOrg, id: 1}; 68 | const aFnWithoutSpy = createApiFunction(() => Promise.resolve(response)); 69 | const aFn = sinon.spy(aFnWithoutSpy); 70 | const res = decorateCreate({}, cache, n, e, aFn); 71 | return res(xOrg).then((newX) => { 72 | expect(spy).to.have.been.calledOnce; 73 | expect(spy).to.have.been.calledWith([xOrg], newX); 74 | }); 75 | }); 76 | }); 77 | }); 78 | -------------------------------------------------------------------------------- /src/plugins/cache/operations/command.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-expressions */ 2 | 3 | import sinon from 'sinon'; 4 | import {curry} from 'ladda-fp'; 5 | import {decorateCommand} from './command'; 6 | import * as Cache from '../cache'; 7 | import {createApiFunction} from '../test-helper'; 8 | 9 | const curryNoop = () => () => {}; 10 | 11 | const config = [ 12 | { 13 | name: 'user', 14 | ttl: 300, 15 | api: { 16 | getUsers: (x) => x, 17 | deleteUser: (x) => x 18 | }, 19 | invalidates: ['user'], 20 | invalidatesOn: ['GET'] 21 | } 22 | ]; 23 | 24 | describe('Command', () => { 25 | describe('decorateCommand', () => { 26 | it('Updates cache based on return value', () => { 27 | const cache = Cache.createCache(config); 28 | const e = config[0]; 29 | const xOrg = {id: 1, name: 'Kalle'}; 30 | const aFnWithoutSpy = createApiFunction(() => Promise.resolve(xOrg)); 31 | const aFn = sinon.spy(aFnWithoutSpy); 32 | 33 | const res = decorateCommand({}, cache, curryNoop, e, aFn); 34 | return res('an arg', 'other args').then((nextXOrg) => { 35 | expect(Cache.getEntity(cache, e, 1).value).to.deep.equal({...nextXOrg, __ladda__id: 1}); 36 | }); 37 | }); 38 | 39 | it('Updates cache based on returned array value', () => { 40 | const cache = Cache.createCache(config); 41 | const e = config[0]; 42 | const xOrg = {id: 1, name: 'Kalle'}; 43 | const aFnWithoutSpy = createApiFunction(() => Promise.resolve([xOrg])); 44 | const aFn = sinon.spy(aFnWithoutSpy); 45 | 46 | const res = decorateCommand({}, cache, curryNoop, e, aFn); 47 | return res('an arg', 'other args').then((nextXOrg) => { 48 | expect(Cache.getEntity(cache, e, 1).value).to.deep.equal({...nextXOrg[0], __ladda__id: 1}); 49 | }); 50 | }); 51 | 52 | it('triggers an UPDATE notification', () => { 53 | const spy = sinon.spy(); 54 | const n = curry((a, b) => spy(a, b)); 55 | 56 | const cache = Cache.createCache(config); 57 | const e = config[0]; 58 | const xOrg = {id: 1, name: 'Kalle'}; 59 | const aFnWithoutSpy = createApiFunction(() => Promise.resolve(xOrg)); 60 | const aFn = sinon.spy(aFnWithoutSpy); 61 | 62 | const res = decorateCommand({}, cache, n, e, aFn); 63 | return res('an arg', 'other args').then((nextXOrg) => { 64 | expect(spy).to.have.been.calledOnce; 65 | expect(spy).to.have.been.calledWith(['an arg', 'other args'], nextXOrg); 66 | }); 67 | }); 68 | }); 69 | }); 70 | 71 | -------------------------------------------------------------------------------- /docs/Background.md: -------------------------------------------------------------------------------- 1 | # Background 2 | 3 | Ladda was created to solve a real problem at [Small Improvements](https://www.small-improvements.com/). At Small Improvements we are developing an application to manage and facilitate feedback within a company. Both more formal, such as the yearly performance appraisals many companies have, but also less formal such as an employee wanting to praise another employee or request feedback from a couple employees, for example after having a presentation or after finishing a project. In addition to this, we handle many more things, such as goals or objectives and a vast number of integrations and customizations. The main point is that it is a fairly complex piece of software, and it is built as a single page application. 4 | 5 | The issue we started to encounter was that loading all the data took quite a while, especially for large companies. First step was to ensure that we only loaded the data that is needed. However, even this was quite a lot of data. We started to explore different solutions. Therefore we invested quite a lot in evaluating GraphQL and Relay. But we didn't find anything that made us happy. We previously switched from Angular 1 to React and we wanted a solution that would make it easier, rather than harder, to jump single page application framework the next time. In addition to this, we didn't want our application code to get more complex, but if anything, we would like it to get less complex. We didn't feel that any existing solution fullfilled all our wishes. 6 | 7 | A couple of developers at Small Improvements got really intrerested in this topic. We started to look around for external solutions yet experimented with our own solutions. Finally we discussed our findings. We picked Ladda for its simplicity, it living outside of the application code and it promoting something that we thought is a good idea in general: well-defined entities. We also realized that using something that is not aware of the framework would help us to synchronize data between React and Angular (we are still using both SPA solutions). Ladda doesn't care if the request comes from React or Angular, and the cache works regardless of where the request originated. Hence, we could synchronize data between Angular and React without doing anything more than using Ladda. If we loaded all users in Angular, updated one of them, and then loaded all users in React, we would get the latest data in React without the need for an API-request. This is how Ladda was born. But enough history, let's get more concrete and look at [what benefits you can get](/docs/Introduction.md). 8 | -------------------------------------------------------------------------------- /docs/basics/Configuration.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | 3 | There are a handful optional options to configure Ladda. In a minimal configuration you need to specify at least **api** on the entity and **operation** on the method. 4 | 5 | ## Entity Configuration 6 | 7 | * **ttl**: How long to cache in seconds. Default is `300` seconds. 8 | 9 | * **invalidatesOn**: `[Operation]` where `Operation := "CREATE" | "READ" | "UPDATE" | "DELETE" | "COMMAND" | "NO_OPERATION"`. Default is `["CREATE", "UPDATE", "DELETE", "COMMAND"]`. 10 | 11 | * **invalidates**: `[EntityName]` where EntityName is the key of your EntityConfig. By default an empty list `[]`. 12 | 13 | * **viewOf**: `EntityName`. Only specify if this is a subset for another entity (all fields must exist in the other entity). 14 | 15 | * **api** (required): An object of ApiFunctions, functions that communicate with an external service and return a Promise. The function name as key and function as value. 16 | 17 | * **enableDeduplication**: `true | false` Enables [deduplication](/docs/advanced/Deduplication.md) 18 | of all "READ" operations for this entity. Defaults to true. 19 | 20 | ## Method Configuration 21 | 22 | * **operation**: `"CREATE" | "READ" | "UPDATE" | "DELETE" | "COMMAND" | "NO_OPERATION"`. Default is `"NO_OPERATION"`. 23 | 24 | * **invalidates**: `[ApiFunctionName]` where ApiFunctionName is a name of another function in the same api (see Entity Configuration - api). By default this is the empty list `[]`. 25 | 26 | * **idFrom**: `"ENTITY" | "ARGS" | Function`. Where `"ARGS"` is a string telling Ladda to generate an id for you by serializing the ARGS the ApiFunction is called with. "ARGS" will also tell Ladda to treat the value as a BlobValue. Function is a function `(EntityValue -> ID)`. By default "ENTITY". 27 | 28 | * **byId**: `true | false`. This is an optimization that tells Ladda that the first argument is an id. This allows Ladda to directly try to fetch the data from the cache, even if it was acquired by another call. This is useful if you previously called for example "getAllUsers" and now want to fetch one user directly from the cache. By default false. 29 | 30 | * **byIds**: `true | false`. Another optimization which tells Ladda that 31 | the first argument is a list of ids. Ladda will try to make an optimal 32 | call (if any), looking up items from the cache and only calling for 33 | items that are not yet present. Defaults to false. 34 | 35 | * **enableDeduplication**: `true | false` Enable [deduplication](/docs/advanced/Deduplication.md) 36 | a "READ" operation. Defaults to true. 37 | 38 | ## Ladda Configuration 39 | 40 | * **idField**: Specify the default property that contains the ID. By default this is `"id"`. 41 | 42 | * **enableDeduplication**: `true | false` Enable [deduplication](/docs/advanced/Deduplication.md) 43 | of "READ" operation for all entities. Defaults to true. 44 | -------------------------------------------------------------------------------- /docs/advanced/ViewOf.md: -------------------------------------------------------------------------------- 1 | # Views 2 | 3 | As more and more people use wireless connections with limited data plans, we need to care about what we send over the wire. A common solution to this is to send only what the user needs. For example, if we are listing a 1000 users, we can save quite many KBs by sending only what is needed rather than a full-blown user. 4 | 5 | However, if it means that we need to refetch the users all the time, we didn't gain much. Let's call the representation shown when listing 1000 users for `MiniUser`. And let's call the full-blown user for `User`. If MiniUser shows the name of a user, and we update the name on a User, then we would normally need to invalidate the MiniUser or manually update the name of the MiniUser corresponding to the User. Either we make our code more complicated, or we need to send more bytes over the wire than necessary. 6 | 7 | Ladda allows you to specify that an entity is a view of another entity. For our example, it would look like: 8 | 9 | ```javascript 10 | const config = { 11 | user: { 12 | api: userApi 13 | }, 14 | miniUser: { 15 | api: miniUserApi, 16 | viewOf: 'user' 17 | } 18 | }; 19 | ``` 20 | 21 | Now, if you update a miniUser, the corresponding user will be updated and vice versa. Same goes for delete and read. If you read a new User, the miniUser will automatically use it (since it is newer than the miniUser). If you delete a User, it will be removed from both the miniUser and user. You get all this for free, by simply telling Ladda that the entity is a view of another. 22 | 23 | ## One Rule 24 | 25 | In order for this to work, there's one strong rule. The view must be a subset of the entity of which it is a view. This means that all properties on the view must also exist on the other entity. For example: 26 | 27 | ``` 28 | User { 29 | id, 30 | name, 31 | email, 32 | dateOfBith, 33 | phoneNumber, 34 | gender 35 | } 36 | ``` 37 | 38 | ``` 39 | MiniUser { 40 | id, 41 | name 42 | } 43 | ``` 44 | 45 | works just fine. But 46 | 47 | ``` 48 | User { 49 | id, 50 | name, 51 | email, 52 | dateOfBith, 53 | phoneNumber, 54 | gender 55 | } 56 | ``` 57 | 58 | ``` 59 | MiniUser { 60 | id, 61 | name, 62 | nickname 63 | } 64 | ``` 65 | 66 | will not work. Since `nickname` only exists in MiniUser and not User. 67 | 68 | ## One Caveat 69 | 70 | In order to save memory and increase performance, Ladda will always prefer the real entity if it exists. This means that if you ask for MiniUsers you can get Users. Since MiniUser is a subset of User, this should not be a problem. But, you can't for example iterate over the keys of a MiniUser, since the keys might vary depending on if Ladda had a newer User or MiniUser. The only assumption you can make is that you will get *at least* the properties that constitute a MiniUser. 71 | -------------------------------------------------------------------------------- /src/plugins/cache/operations/no-operation.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-expressions */ 2 | 3 | import sinon from 'sinon'; 4 | import {curry} from 'ladda-fp'; 5 | import {decorateNoOperation} from './no-operation'; 6 | import * as Cache from '../cache'; 7 | import {createSampleConfig, createApiFunction} from '../test-helper'; 8 | 9 | const curryNoop = () => () => {}; 10 | 11 | const config = createSampleConfig(); 12 | 13 | describe('DecorateNoOperation', () => { 14 | it('Invalidates based on what is specified in the original function', () => { 15 | const cache = Cache.createCache(config); 16 | const e = config[0]; 17 | const xOrg = {__ladda__id: 1, name: 'Kalle'}; 18 | const aFn = sinon.spy(() => Promise.resolve({})); 19 | const getUsers = () => Promise.resolve(xOrg); 20 | getUsers.fnName = 'getUsers'; 21 | aFn.invalidates = ['getUsers']; 22 | Cache.storeQueryResponse(cache, e, getUsers, ['args'], xOrg); 23 | const res = decorateNoOperation({}, cache, curryNoop, e, aFn); 24 | return res(xOrg).then(() => { 25 | const killedCache = !Cache.containsQueryResponse(cache, e, getUsers, ['args']); 26 | expect(killedCache).to.be.true; 27 | }); 28 | }); 29 | 30 | it('Does not change original function', () => { 31 | const cache = Cache.createCache(config); 32 | const e = config[0]; 33 | const aFn = sinon.spy(() => { 34 | return Promise.resolve({}); 35 | }); 36 | decorateNoOperation({}, cache, curryNoop, e, aFn); 37 | expect(aFn.operation).to.be.undefined; 38 | }); 39 | 40 | it('Ignored inherited invalidation config', () => { 41 | const cache = Cache.createCache(config); 42 | const e = config[0]; 43 | const xOrg = {__ladda__id: 1, name: 'Kalle'}; 44 | const aFnWithoutSpy = createApiFunction(() => Promise.resolve({}), {invalidates: ['user']}); 45 | const aFn = sinon.spy(aFnWithoutSpy); 46 | const getUsers = createApiFunction(() => Promise.resolve(xOrg)); 47 | aFn.hasOwnProperty = () => false; 48 | Cache.storeQueryResponse(cache, e, getUsers, ['args'], xOrg); 49 | const res = decorateNoOperation({}, cache, curryNoop, e, aFn); 50 | return res(xOrg).then(() => { 51 | const killedCache = !Cache.containsQueryResponse(cache, e, getUsers, ['args']); 52 | expect(killedCache).to.be.false; 53 | }); 54 | }); 55 | 56 | it('trigger notification', () => { 57 | const spy = sinon.spy(); 58 | const n = curry((a, b) => spy(a, b)); 59 | const cache = Cache.createCache(config); 60 | const e = config[0]; 61 | const xOrg = {__ladda__id: 1, name: 'Kalle'}; 62 | const aFn = sinon.spy(() => Promise.resolve({})); 63 | const getUsers = () => Promise.resolve(xOrg); 64 | aFn.invalidates = ['getUsers']; 65 | Cache.storeQueryResponse(cache, e, getUsers, ['args'], xOrg); 66 | const res = decorateNoOperation({}, cache, n, e, aFn); 67 | return res(xOrg).then(() => { 68 | expect(spy).to.have.been.calledOnce; 69 | expect(spy).to.have.been.calledWith([xOrg], null); 70 | }); 71 | }); 72 | }); 73 | -------------------------------------------------------------------------------- /src/plugins/cache/operations/read.js: -------------------------------------------------------------------------------- 1 | import {passThrough, compose, curry, reduce, toIdMap, map, concat, zip} from 'ladda-fp'; 2 | import * as Cache from '../cache'; 3 | import {addId, removeId} from '../id-helper'; 4 | 5 | const readFromCache = curry((cache, e, aFn, id) => { 6 | if (Cache.containsEntity(cache, e, id) && !aFn.alwaysGetFreshData) { 7 | const v = Cache.getEntity(cache, e, id); 8 | if (!Cache.hasExpired(cache, e, v)) { 9 | return removeId(v.value); 10 | } 11 | } 12 | return undefined; 13 | }); 14 | 15 | const decorateReadSingle = (c, cache, notify, e, aFn) => { 16 | return (id) => { 17 | const fromCache = readFromCache(cache, e, aFn, id); 18 | if (fromCache) { 19 | return Promise.resolve(fromCache); 20 | } 21 | 22 | return aFn(id) 23 | .then(passThrough(compose(Cache.storeEntity(cache, e), addId(c, aFn, id)))) 24 | .then(passThrough(() => Cache.invalidateQuery(cache, e, aFn))) 25 | .then(passThrough(notify([id]))); 26 | }; 27 | }; 28 | 29 | const decorateReadSome = (c, cache, notify, e, aFn) => { 30 | return (ids) => { 31 | const readFromCache_ = readFromCache(cache, e, aFn); 32 | const [cached, remaining] = reduce(([c_, r], id) => { 33 | const fromCache = readFromCache_(id); 34 | if (fromCache) { 35 | c_.push(fromCache); 36 | } else { 37 | r.push(id); 38 | } 39 | return [c_, r]; 40 | }, [[], []], ids); 41 | 42 | if (!remaining.length) { 43 | return Promise.resolve(cached); 44 | } 45 | 46 | const addIds = map(([id, item]) => addId(c, aFn, id, item)); 47 | 48 | return aFn(remaining) 49 | .then(passThrough(compose(Cache.storeEntities(cache, e), addIds, zip(remaining)))) 50 | .then(passThrough(() => Cache.invalidateQuery(cache, e, aFn))) 51 | .then((other) => { 52 | const asMap = compose(toIdMap, concat)(cached, other); 53 | return map((id) => asMap[id], ids); 54 | }) 55 | .then(passThrough(notify([remaining]))); 56 | }; 57 | }; 58 | 59 | const decorateReadQuery = (c, cache, notify, e, aFn) => { 60 | return (...args) => { 61 | if (Cache.containsQueryResponse(cache, e, aFn, args) && !aFn.alwaysGetFreshData) { 62 | const v = Cache.getQueryResponseWithMeta(cache, c, e, aFn, args); 63 | if (!Cache.hasExpired(cache, e, v)) { 64 | return Promise.resolve(v.value ? removeId(Cache.getQueryResponse(v.value)) : null); 65 | } 66 | } 67 | 68 | return aFn(...args) 69 | .then(passThrough( 70 | compose(Cache.storeQueryResponse(cache, e, aFn, args), 71 | addId(c, aFn, args)))) 72 | .then(passThrough(() => Cache.invalidateQuery(cache, e, aFn))) 73 | .then(passThrough(notify(args))); 74 | }; 75 | }; 76 | 77 | export function decorateRead(c, cache, notify, e, aFn) { 78 | if (aFn.byId) { 79 | return decorateReadSingle(c, cache, notify, e, aFn); 80 | } 81 | if (aFn.byIds) { 82 | return decorateReadSome(c, cache, notify, e, aFn); 83 | } 84 | return decorateReadQuery(c, cache, notify, e, aFn); 85 | } 86 | -------------------------------------------------------------------------------- /docs/advanced/Invalidation.md: -------------------------------------------------------------------------------- 1 | # Invalidation 2 | 3 | Ladda caches the value returned by ApiFunctions with the operation `READ`. By default the cache lives as long as specified as TTL (default 300 seconds). But sometimes you will need to clear the caches before the TTL has expired. This is called invalidation. There are two places where you can configure invalidation. On an entity and on a ApiFunction. Normally you will only need to specify it on the entity. 4 | 5 | ## Cache Invalidation on Entity 6 | 7 | It is not uncommon that entities are related in some way. For instance, if you are developing a game you might have a top list. After finishing a game you know that this top list will be updated. Not directly by you, but by your backend as a consequence of you finishing a game. In this case you want to invalidate the currently cached top list, because you know it might have changed. Ladda makes this easy. The configuration would look something like this: 8 | 9 | ```javascript 10 | const config = { 11 | topList: { 12 | ttl: 3600, 13 | api: topListApi 14 | }, 15 | game: { 16 | api: gameApi, 17 | invalidates: ['topList'] 18 | } 19 | }; 20 | ``` 21 | 22 | When you would call for example `api.game.reportFinished(finishedGame)`, Ladda would automatically invalidate the topList for you. 23 | 24 | You also have the ability to make you invalidation a bit more fine-grained. There's a `invalidatesOn` option which allows you to specify on which operation to invalidate the specified entities. For example, it could look like: 25 | 26 | ```javascript 27 | const config = { 28 | topList: { 29 | ttl: 3600, 30 | api: topListApi 31 | }, 32 | game: { 33 | ttl: 300, 34 | api: gameApi, 35 | invalidates: ['topList'], 36 | invalidatesOn: ['UPDATE'] // Default: ['CREATE', 'UPDATE', 'DELETE', 'COMMAND'] 37 | } 38 | }; 39 | ``` 40 | 41 | In addition to the normal CRUD operations, you can specify `invalidatesOn: ['NO_OPERATION']` which allows you to invalidate other entities' caches even if you don't use Ladda to cache the ApiFunction (If no operation is specified on a ApiFunction, Ladda will leave the function alone and not cache it nor update any cache). If necessary, you can also specify invalidation directly on ApiFunctions. 42 | 43 | ## Cache Invalidation on ApiFunction 44 | 45 | Sometimes you will need to invalidate just another ApiFunction's cache. This can be achieved by specifying `invalidates` on a ApiFunction. It might look like this: 46 | 47 | ```javascript 48 | recalculateTopPlayers.invalidates = ['getTopPlayers']; 49 | function recalculateTopPlayers() { 50 | return performPostRequst('/api/players/top/recalculate'); 51 | } 52 | 53 | getTopPlayers.operation = 'READ'; 54 | function getTopPlayers() { 55 | return performGetRequest('/api/players/top'); 56 | } 57 | 58 | getAllPlayers.operation = 'READ'; 59 | function getAllPlayers() { 60 | return performGetRequest('/api/players'); 61 | } 62 | ``` 63 | 64 | Calling `recalculateTopPlayers` would invalidate `getTopPlayers`, so you would get the new top players next time you call `getTopPlayers`. However, `getAllPlayers` would still keep its cache. Note that you can only invalidate other ApiFunctions within the same Api (/in the same entity). 65 | -------------------------------------------------------------------------------- /docs/advanced/CustomId.md: -------------------------------------------------------------------------------- 1 | # Custom ID 2 | 3 | Ladda needs an unique identifier for every EntityValue to cache it. By default Ladda assumes that every EntityValue has this identifier as a property `id`. Of course, we realize that this is not always true, so you can configure what to use as a identifier in a couple of ways. 4 | 5 | ## Default ID Property 6 | 7 | Sometimes you have identifiers, but they are called `_id`, `objectID` or something else. In this case you will need to tell Ladda about this, for example, if your default ID property is `objectID`, you could configure this as: 8 | 9 | ```javascript 10 | const config = { 11 | user: { 12 | ttl: 60, 13 | api: userApi 14 | }, 15 | __config: { 16 | idField: 'objectID' 17 | } 18 | }; 19 | ``` 20 | 21 | This will tell Ladda to always pick `objectID` rather than `id` as the ID for the EntityValue. 22 | 23 | ## Override ID Property for ApiFunction 24 | 25 | There are always exceptions. Maybe most of your API use `objectID` as the ID property. But a few parts of your API use `_id`. This can easily be handled by configuring it on your lower level ApiFunctions: 26 | 27 | ```javascript 28 | getUser.operation = 'READ'; 29 | getUser.idFrom = user => user._id; 30 | function getUser(id) { 31 | return performGetRequest('/api/user', id); 32 | } 33 | ``` 34 | 35 | This will tell Ladda to always pick `_id` rather than `objectID` as the ID for the EntityValue. 36 | 37 | ## No ID at All 38 | 39 | Finally, you might not have any ID. For example, if your endpoint uses a custom format. In general, we recommend you to put some effort into getting an ID. For instance, by updating your backend, preprocessing your response in your ApiFunction or to use `idFrom` with a function on your ApiFunction. 40 | 41 | However, if you do not plan to manipulate the response (eg. update and post the updated version to your backend), then you can treat it as a BlobValue. This means that Ladda will not try to do anything clever except of caching the value and returning it if you call the same method with the same arguments on a `READ` operation. Basically, it will be equivalent to the good ol' `if (inCache) { return cachedValue; } else { val = loadValue; putInCache(val); return val;}`. This removes the requirement of the response having an ID, the ID will be created by Ladda by serializing the arguments of the called ApiFunction. To enable this, configure your ApiFunction as: 42 | 43 | ``` 44 | getHackernewsArticles.operation = 'READ'; 45 | getHackernewsArticles.idFrom = 'ARGS'; 46 | function getHackernewsArticles(query) { 47 | return performGetRequst(HACKERNEWS_SEARCH_URL, query); 48 | } 49 | ``` 50 | 51 | The `ARGS` will work regardless of what Hackernews returns. And calling the ApiFunction with the same query twice will give you a cached result. However, if you had a `updateHackernewsArticle` ApiFunction, it would not update the cached result later returned by `getHackernewsArticles`. You would need to invalidate `getHackernewsArticles` to prevent stale data: 52 | 53 | ``` 54 | updateHackernewsArticle.operation = 'UPDATE'; 55 | updateHackernewsArticle.invalidates = ['getHackernewsArticles']; 56 | function updateHackernewsArticle(newArticle) { 57 | return performPutRequest(HACKERNEWS_ARTICLE_URL, newArticle); 58 | } 59 | 60 | getHackernewsArticles.operation = 'READ'; 61 | getHackernewsArticles.idFrom = 'ARGS'; 62 | function getHackernewsArticles(query) { 63 | return performGetRequst(HACKERNEWS_SEARCH_URL, query); 64 | } 65 | ``` 66 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at petercrona89@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /src/plugins/cache/operations/delete.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-expressions */ 2 | 3 | import sinon from 'sinon'; 4 | import {curry} from 'ladda-fp'; 5 | import {decorateDelete} from './delete'; 6 | import * as Cache from '../cache'; 7 | import {addId} from '../id-helper'; 8 | import {createApiFunction} from '../test-helper'; 9 | 10 | const curryNoop = () => () => {}; 11 | 12 | const config = [ 13 | { 14 | name: 'user', 15 | ttl: 300, 16 | api: { 17 | getUsers: (x) => x, 18 | getUsers2: (x) => x, 19 | deleteUser: (x) => x 20 | }, 21 | invalidates: ['user'], 22 | invalidatesOn: ['GET'] 23 | }, 24 | { 25 | name: 'userPreview', 26 | ttl: 200, 27 | api: { 28 | getPreviews: (x) => x, 29 | updatePreview: (x) => x 30 | }, 31 | invalidates: ['fda'], 32 | viewOf: 'user' 33 | }, 34 | { 35 | name: 'listUser', 36 | ttl: 200, 37 | api: { 38 | getPreviews: (x) => x, 39 | updatePreview: (x) => x 40 | }, 41 | invalidates: ['fda'], 42 | viewOf: 'user' 43 | } 44 | ]; 45 | 46 | describe('Delete', () => { 47 | describe('decorateDelete', () => { 48 | it('Removes cache', () => { 49 | const cache = Cache.createCache(config); 50 | const e = config[0]; 51 | const xOrg = {id: 1, name: 'Kalle'}; 52 | const aFnWithoutSpy = createApiFunction(() => Promise.resolve({})); 53 | const aFn = sinon.spy(aFnWithoutSpy); 54 | Cache.storeEntity(cache, e, addId({}, undefined, undefined, xOrg)); 55 | const res = decorateDelete({}, cache, curryNoop, e, aFn); 56 | return res(1).then(() => { 57 | expect(Cache.getEntity(cache, e, 1)).to.equal(undefined); 58 | }); 59 | }); 60 | 61 | it('Removes cache only using first argument as id', () => { 62 | const cache = Cache.createCache(config); 63 | const e = config[0]; 64 | const xOrg = {id: 1, name: 'Kalle'}; 65 | const aFnWithoutSpy = createApiFunction(() => Promise.resolve({})); 66 | const aFn = sinon.spy(aFnWithoutSpy); 67 | Cache.storeEntity(cache, e, addId({}, undefined, undefined, xOrg)); 68 | const res = decorateDelete({}, cache, curryNoop, e, aFn); 69 | return res(1, 2).then(() => { 70 | expect(Cache.getEntity(cache, e, 1)).to.equal(undefined); 71 | }); 72 | }); 73 | 74 | it('triggers DELETE notification', () => { 75 | const spy = sinon.spy(); 76 | const n = curry((a, b) => spy(a, b)); 77 | const cache = Cache.createCache(config); 78 | const e = config[0]; 79 | const xOrg = {id: 1, name: 'Kalle'}; 80 | const aFnWithoutSpy = createApiFunction(() => Promise.resolve({})); 81 | const aFn = sinon.spy(aFnWithoutSpy); 82 | Cache.storeEntity(cache, e, addId({}, undefined, undefined, xOrg)); 83 | const res = decorateDelete({}, cache, n, e, aFn); 84 | return res(1).then(() => { 85 | expect(spy).to.have.been.calledOnce; 86 | expect(spy).to.have.been.calledWith([1], xOrg); 87 | }); 88 | }); 89 | 90 | it('does not trigger notification when item was not in cache', () => { 91 | const spy = sinon.spy(); 92 | const n = curry((a, b) => spy(a, b)); 93 | const cache = Cache.createCache(config); 94 | const e = config[0]; 95 | const xOrg = {id: 1, name: 'Kalle'}; 96 | const aFnWithoutSpy = createApiFunction(() => Promise.resolve({})); 97 | const aFn = sinon.spy(aFnWithoutSpy); 98 | Cache.storeEntity(cache, e, addId({}, undefined, undefined, xOrg)); 99 | const res = decorateDelete({}, cache, n, e, aFn); 100 | return res(2).then(() => { 101 | expect(spy).not.to.have.been.called; 102 | }); 103 | }); 104 | }); 105 | }); 106 | -------------------------------------------------------------------------------- /docs/Introduction.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | Ladda is an independent, lightweight caching solution for your JavaScript application. It is a library simple to get started with yet supports sophisticated cache management. In this section we will to explain how it can be useful for you and how to proceed if you want to give Ladda a shot. 4 | 5 | ## How Does It Help Me? 6 | 7 | Nowadays applications often are centered around the Create, Read, Update and Delete ([CRUD](https://en.wikipedia.org/wiki/Create,_read,_update_and_delete)) operations. You get a list of items from an API, want to update and delete an item or maybe create a new item. 8 | 9 | Ladda caches your CRUD operations. When you get the list of items, it will cache the result. Every subsequent time you get the list of items, the cached result will be used. You don't perform the request. 10 | 11 | In pseudo code, an ad-hoc caching solution often looks like the following: 12 | 13 | ```javascript 14 | var result; 15 | 16 | if (/* check if result is cached */) { 17 | // get result from cache 18 | result = cache.getList(); 19 | } else { 20 | // get result from API 21 | result = apiService.getList(); 22 | 23 | // store result in cache 24 | cache.put(/* store result and index */) 25 | } 26 | 27 | return result; 28 | ``` 29 | 30 | In contrast to this, Ladda shields away the cache layer for you and you end up with: 31 | 32 | ```javascript 33 | return apiService.getList(); 34 | ``` 35 | 36 | The `getList()` request would look the same from the outside. You only reach out to your API layer to handle the request. At the place where you define the API layer, Ladda would be a thin decorator for your `getList()` functionality. 37 | 38 | ```javascript 39 | getList.operation = 'READ'; 40 | function getList() { 41 | // API request 42 | } 43 | ``` 44 | 45 | That's only the `READ` operation in CRUD though. A simple ad-hoc caching solution (as above) doesn't support the `CREATE`, `UPDATE` or `DELETE` operations. Those are a bit more difficult to implement and that's where Ladda will help you. 46 | 47 | [Ladda does support those operations](/docs/basics/Operations.md). When you update the list, by using a `CREATE`, `UPDATE` or `DELETE` operation, the requests are made to the API as usual. However, Ladda will also update your cache. Once you request the list of items again from the API, you will get the updated result from the cache without making a new API-request. For example, if you changed the name of a user, there's no need to refetch all users from your backend. Ladda ensures that you get up to date data if you would for example call "getAllUsers" again, without an API-request being made. 48 | 49 | ## When to Use Ladda 50 | 51 | Ladda is not meant to cover all possible use cases. To get the full power of Ladda you need to have **well-defined entities**. For example, you might have an entity User, which contains an id, name, email and contact details. Then you might have an entity ListUser, which only contains an id and a name. The important bit is that these concepts exist and that you refer to them as User and ListUser rather than "A user might have an id, name, email and contact details, but sometimes only id and name". 52 | 53 | Of course, if you come up with creative ways of using Ladda, go ahead! You can quite easily use Ladda just as a simple cache for external calls, in which case you don't need a well-defined entity. But to leverge the more advanced parts, well-defined entities are necessary. 54 | 55 | ## Next Steps 56 | 57 | You should convince yourself by trying the [Demos](/docs/Demos.md). Keep in mind that Ladda comes with more advantages than being a simple `READ` operation cache. It supports all the CRUD operations. Ladda keeps your cache in sync and minimizes the number of requests made to the backend. Data is only refetched if it has to be. 58 | 59 | If you already want to get started, checkout the [Getting Started](/docs/GettingStarted.md) section. 60 | -------------------------------------------------------------------------------- /src/builder.js: -------------------------------------------------------------------------------- 1 | import {map, mapObject, mapValues, compose, toObject, reduce, fromPairs, 2 | toPairs, prop, filterObject, isEqual, not, curry, copyFunction 3 | } from 'ladda-fp'; 4 | 5 | import {cachePlugin} from './plugins/cache/index'; 6 | import {dedupPlugin} from './plugins/dedup/index'; 7 | import {createListenerStore} from './listener-store'; 8 | import {validateConfig} from './validator'; 9 | 10 | // [[EntityName, EntityConfig]] -> Entity 11 | const toEntity = ([name, c]) => ({ 12 | name, 13 | ...c 14 | }); 15 | 16 | const KNOWN_STATICS = { 17 | name: true, 18 | length: true, 19 | prototype: true, 20 | caller: true, 21 | arguments: true, 22 | arity: true 23 | }; 24 | 25 | const hoistMetaData = (a, b) => { 26 | const keys = Object.getOwnPropertyNames(a); 27 | for (let i = keys.length - 1; i >= 0; i--) { 28 | const k = keys[i]; 29 | if (!KNOWN_STATICS[k]) { 30 | b[k] = a[k]; 31 | } 32 | } 33 | return b; 34 | }; 35 | 36 | export const mapApiFunctions = (fn, entityConfigs) => { 37 | return mapValues((entity) => { 38 | return { 39 | ...entity, 40 | api: reduce( 41 | // As apiFn name we use key of the api field and not the name of the 42 | // fn directly. This is controversial. Decision was made because 43 | // the original function name might be polluted at this point, e.g. 44 | // containing a "bound" prefix. 45 | (apiM, [apiFnName, apiFn]) => { 46 | const getFn = compose(prop(apiFnName), prop('api')); 47 | apiM[apiFnName] = hoistMetaData(getFn(entity), fn({ entity, fn: apiFn })); 48 | return apiM; 49 | }, 50 | {}, 51 | toPairs(entity.api) 52 | ) 53 | }; 54 | }, entityConfigs); 55 | }; 56 | 57 | // EntityConfig -> Api 58 | const toApi = mapValues(prop('api')); 59 | 60 | // EntityConfig -> EntityConfig 61 | const setEntityConfigDefaults = ec => { 62 | return { 63 | ttl: 300, 64 | invalidates: [], 65 | invalidatesOn: ['CREATE', 'UPDATE', 'DELETE', 'COMMAND'], 66 | enableDeduplication: true, 67 | ...ec 68 | }; 69 | }; 70 | 71 | // EntityConfig -> EntityConfig 72 | const setApiConfigDefaults = ec => { 73 | const defaults = { 74 | operation: 'NO_OPERATION', 75 | invalidates: [], 76 | idFrom: 'ENTITY', 77 | byId: false, 78 | byIds: false, 79 | enableDeduplication: true 80 | }; 81 | 82 | const writeToObjectIfNotSet = curry((o, [k, v]) => { 83 | if (!o.hasOwnProperty(k)) { 84 | o[k] = v; 85 | } 86 | }); 87 | const setDefaults = apiConfig => { 88 | const copy = copyFunction(apiConfig); 89 | mapObject(writeToObjectIfNotSet(copy), defaults); 90 | return copy; 91 | }; 92 | 93 | const setFnName = ([name, apiFn]) => { 94 | apiFn.fnName = name; 95 | return [name, apiFn]; 96 | }; 97 | 98 | const mapApi = compose( 99 | fromPairs, 100 | map(setFnName), 101 | toPairs, 102 | mapValues(setDefaults) 103 | ); 104 | 105 | return { 106 | ...ec, 107 | api: ec.api ? mapApi(ec.api) : ec.api 108 | }; 109 | }; 110 | 111 | // Config -> Map String EntityConfig 112 | export const getEntityConfigs = compose( // exported for testing 113 | toObject(prop('name')), 114 | mapObject(toEntity), 115 | mapValues(setApiConfigDefaults), 116 | mapValues(setEntityConfigDefaults), 117 | filterObject(compose(not, isEqual('__config'))) 118 | ); 119 | 120 | const getGlobalConfig = (config) => ({ 121 | idField: 'id', 122 | enableDeduplication: true, 123 | useProductionBuild: process.NODE_ENV === 'production', 124 | ...(config.__config || {}) 125 | }); 126 | 127 | const applyPlugin = curry((addChangeListener, config, entityConfigs, plugin) => { 128 | const pluginDecorator = plugin({ addChangeListener, config, entityConfigs }); 129 | return mapApiFunctions(pluginDecorator, entityConfigs); 130 | }); 131 | 132 | const createPluginList = (core, plugins) => { 133 | return plugins.length ? 134 | [core, dedupPlugin, ...plugins, dedupPlugin] : 135 | [core, dedupPlugin]; 136 | }; 137 | 138 | // Config -> Api 139 | export const build = (c, ps = []) => { 140 | const config = getGlobalConfig(c); 141 | const entityConfigs = getEntityConfigs(c); 142 | validateConfig(console, entityConfigs, config); 143 | const listenerStore = createListenerStore(config); 144 | const applyPlugin_ = applyPlugin(listenerStore.addChangeListener, config); 145 | const applyPlugins = reduce(applyPlugin_, entityConfigs); 146 | const createApi = compose(toApi, applyPlugins); 147 | return createApi(createPluginList(cachePlugin(listenerStore.onChange), ps)); 148 | }; 149 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Ladda](https://smallimprovementstech.files.wordpress.com/2017/03/laddalogo-horiz-color-21.png) 2 | 3 | ![Build status](https://api.travis-ci.org/ladda-js/ladda.svg?branch=master) 4 | [![Coverage Status](https://coveralls.io/repos/github/petercrona/ladda/badge.svg?branch=master)](https://coveralls.io/github/petercrona/ladda?branch=master) 5 | 6 | Ladda is a library that helps you with caching, invalidation of caches and to handle different representations of the same data in a **performant** and **memory efficient** way. It is written in **JavaScript** (ES2015) and designed to be used by **single-page applications**. It is framework agnostic, so it works equally well with React, Vue, Angular or just vanilla JavaScript. 7 | 8 | The main goal with Ladda is to make it easy for you to add sophisticated caching **without making your application code more complex**. Ladda will take care of logic that would otherwise increase the complexity of your application code, and it will do so in a corner outside of your application. 9 | 10 | If you had no caching before, you can expect a **significant performance boost** with possibly no changes to you application code. Ladda is designed to be something you can ignore once you set it up. 11 | 12 | When developing your application you shouldn't care about Ladda nor caching. You should just assume that backend calls are for free, that they will be cached if possible and data will be re-fetched if it has to. This can **simplify your application code**. 13 | 14 | If you get bored of Ladda you can easily get rid of it. Ladda is designed to influence your application code as little as possible. We want you to get hooked, but not because of the cost of getting rid of Ladda. 15 | 16 | # Demo 17 | 18 | The easiest way to get a glimpse of what Ladda can do is checking out our [demos](/docs/Demos.md). 19 | 20 | # Get Started 21 | 22 | Check out the [guide](/docs/GettingStarted.md) for getting started. In addition, you can have a look in the [examples folder](https://github.com/ladda-js/ladda/tree/master/examples). These are standalone examples where you only need to follow the README.md to setup the project. There is an addtional minimal example, where you can find everything in one file, that you can clone and run: Check out [ladda-example-mini-project](https://github.com/petercrona/ladda-example-mini-project) ([code](https://github.com/petercrona/ladda-example-mini-project/blob/master/script.js)). 23 | 24 | # Documentation 25 | 26 | The documentation gives you an [exhaustive overview of Ladda](https://www.ladda.io/). 27 | 28 | # Why Use Ladda? 29 | 30 | The sales pitch - A bunch of things that we are proud of: Lightweight, Quality, Standalone, Low Buy-In. 31 | 32 | ## Lightweight 33 | 34 | Ladda is a lightweight library and comes with no additional dependencies. The library has a file size of only 14 KB (minimized). 35 | 36 | ## Quality 37 | 38 | Ladda has a high test coverage (**100%** line coverage) with tests constantly being added. And yes, we know that high test coverage is a "feel good" number, our focus is still on meaningful and good tests. It has a reasonably simple architecture and often tries to stay [tacit](https://www.youtube.com/watch?v=seVSlKazsNk&feature=youtu.be) and concise by taking inspiration from [functional programming](https://drboolean.gitbooks.io/mostly-adequate-guide/content/). We urge you to check out the [source code](https://github.com/ladda-js/ladda/tree/master/src). You can help us to improve it further or just enjoy reading functional JavaScript. 39 | 40 | ## Standalone 41 | 42 | Apart from being independent from any dependencies, Ladda is library and framework agnostic. It doesn't depend on the latest single page application framework out there. It doesn't reinvent the wheel of caching every time a new framework comes around. You can use it in your evolving application as your caching solution. 43 | 44 | ## Low Buy-In 45 | 46 | Ladda is just a wrapper around your client-side API layer. Somewhere in your application you might have defined all your outgoing API requests. Ladda will wrap these requests and act as your client-side cache. The API requests themselves don't change, but Ladda enhances them with caching capabilities. To get rid of Ladda, you can just remove the wrapping, and your API functions return to just being themselves. We believe that it is equally important to make it easy to add Ladda to your application, as it is to make it easy to remove Ladda from your application. 47 | 48 | ## Browser Support 49 | All the major modern browsers are supported. However, note that for old browsers, such as Internet Explorer 11, **you will need a polyfill for Promises**. 50 | 51 | # Contribute 52 | 53 | Please let us know if you have any feedback. Fork the repo, create Pull Requests and Issues. Have a look into [how to contribute](/docs/Contribute.md). 54 | -------------------------------------------------------------------------------- /src/plugins/cache/entity-store.js: -------------------------------------------------------------------------------- 1 | /* A data structure that is aware of views and entities. 2 | * 1. If a value exist both in a view and entity, the newest value is preferred. 3 | * 2. If a view or entity is removed, the connected views and entities are also removed. 4 | * 3. If a new view value is added, it will be merged into the entity value if such exist. 5 | * otherwise a new view value will be added. 6 | * 7 | * Note that a view will always return at least what constitutes the view. 8 | * It can return the full entity too. This means the client code needs to take this into account 9 | * by not depending on only a certain set of values being there. 10 | * This is done to save memory and to simplify data synchronization. 11 | * Of course, this also requiers the view to truly be a subset of the entity. 12 | */ 13 | 14 | import {curry, reduce, map_, clone} from 'ladda-fp'; 15 | import {merge} from './merger'; 16 | import {removeId} from './id-helper'; 17 | 18 | // Value -> StoreValue 19 | const toStoreValue = v => ({value: v, timestamp: Date.now()}); 20 | 21 | // EntityStore -> String -> Value 22 | const read = ([_, s], k) => (s[k] ? {...s[k], value: clone(s[k].value)} : s[k]); 23 | 24 | // EntityStore -> String -> Value -> () 25 | const set = ([eMap, s], k, v) => { s[k] = toStoreValue(clone(v)); }; 26 | 27 | // EntityStore -> String -> () 28 | const rm = curry(([_, s], k) => delete s[k]); 29 | 30 | // Entity -> String 31 | const getEntityType = e => e.viewOf || e.name; 32 | 33 | // EntityStore -> Entity -> () 34 | const rmViews = ([eMap, s], e) => { 35 | const entityType = getEntityType(e); 36 | const toRemove = [...eMap[entityType]]; 37 | map_(rm([eMap, s]), toRemove); 38 | }; 39 | 40 | // Entity -> Value -> String 41 | const createEntityKey = (e, v) => { 42 | return getEntityType(e) + v.__ladda__id; 43 | }; 44 | 45 | // Entity -> Value -> String 46 | const createViewKey = (e, v) => { 47 | return e.name + v.__ladda__id; 48 | }; 49 | 50 | // Entity -> Bool 51 | const isView = e => !!e.viewOf; 52 | 53 | // Function -> Function -> EntityStore -> Entity -> Value -> a 54 | const handle = curry((viewHandler, entityHandler, s, e, v) => { 55 | if (isView(e)) { 56 | return viewHandler(s, e, v); 57 | } 58 | return entityHandler(s, e, v); 59 | }); 60 | 61 | // EntityStore -> Entity -> Value -> Bool 62 | const entityValueExist = (s, e, v) => !!read(s, createEntityKey(e, v)); 63 | 64 | // EntityStore -> Entity -> Value -> () 65 | const setEntityValue = (s, e, v) => { 66 | if (!v.__ladda__id) { 67 | throw new Error(`Value is missing id, tried to add to entity ${e.name}`); 68 | } 69 | const k = createEntityKey(e, v); 70 | set(s, k, v); 71 | return v; 72 | }; 73 | 74 | // EntityStore -> Entity -> Value -> () 75 | const setViewValue = (s, e, v) => { 76 | if (!v.__ladda__id) { 77 | throw new Error(`Value is missing id, tried to add to view ${e.name}`); 78 | } 79 | 80 | if (entityValueExist(s, e, v)) { 81 | const eValue = read(s, createEntityKey(e, v)).value; 82 | setEntityValue(s, e, merge(v, eValue)); 83 | rmViews(s, e); // all views will prefer entity cache since it is newer 84 | } else { 85 | const k = createViewKey(e, v); 86 | set(s, k, v); 87 | } 88 | 89 | return v; 90 | }; 91 | 92 | // EntityStore -> Entity -> [Value] -> () 93 | export const mPut = curry((es, e, xs) => { 94 | map_(handle(setViewValue, setEntityValue)(es, e))(xs); 95 | }); 96 | 97 | // EntityStore -> Entity -> Value -> () 98 | export const put = curry((es, e, x) => mPut(es, e, [x])); 99 | 100 | // EntityStore -> Entity -> String -> Value 101 | const getEntityValue = (s, e, id) => { 102 | const k = createEntityKey(e, {__ladda__id: id}); 103 | return read(s, k); 104 | }; 105 | 106 | // EntityStore -> Entity -> String -> Value 107 | const getViewValue = (s, e, id) => { 108 | const entityValue = read(s, createEntityKey(e, {__ladda__id: id})); 109 | const viewValue = read(s, createViewKey(e, {__ladda__id: id})); 110 | const onlyViewValueExist = viewValue && !entityValue; 111 | 112 | if (onlyViewValueExist) { 113 | return viewValue; 114 | } 115 | return entityValue; 116 | }; 117 | 118 | // EntityStore -> Entity -> String -> () 119 | export const get = handle(getViewValue, getEntityValue); 120 | 121 | // EntityStore -> Entity -> String -> Value 122 | export const remove = (es, e, id) => { 123 | const x = get(es, e, id); 124 | rm(es, createEntityKey(e, {__ladda__id: id})); 125 | rmViews(es, e); 126 | if (x) { 127 | return removeId(x.value); 128 | } 129 | return undefined; 130 | }; 131 | 132 | // EntityStore -> Entity -> String -> Bool 133 | export const contains = (es, e, id) => !!handle(getViewValue, getEntityValue)(es, e, id); 134 | 135 | // EntityStore -> Entity -> EntityStore 136 | const registerView = ([eMap, ...other], e) => { 137 | if (!eMap[e.viewOf]) { 138 | eMap[e.viewOf] = []; 139 | } 140 | eMap[e.viewOf].push(e.name); 141 | return [eMap, ...other]; 142 | }; 143 | 144 | // EntityStore -> Entity -> EntityStore 145 | const registerEntity = ([eMap, ...other], e) => { 146 | if (!eMap[e.name]) { 147 | eMap[e.name] = []; 148 | } 149 | return [eMap, ...other]; 150 | }; 151 | 152 | // EntityStore -> Entity -> EntityStore 153 | const updateIndex = (m, e) => { return isView(e) ? registerView(m, e) : registerEntity(m, e); }; 154 | 155 | // [Entity] -> EntityStore 156 | export const createEntityStore = (c) => reduce(updateIndex, [{}, {}], c); 157 | -------------------------------------------------------------------------------- /src/plugins/cache/query-cache.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-expressions */ 2 | 3 | import {createEntityStore} from './entity-store'; 4 | import {createQueryCache, getValue, put, contains, get, invalidate} from './query-cache'; 5 | import {addId} from './id-helper'; 6 | import {createSampleConfig, createApiFunction} from './test-helper'; 7 | 8 | const config = createSampleConfig(); 9 | 10 | describe('QueryCache', () => { 11 | describe('createQueryCache', () => { 12 | it('Returns an object', () => { 13 | const es = createEntityStore(config); 14 | const qc = createQueryCache(es); 15 | expect(qc).to.be.a('object'); 16 | }); 17 | }); 18 | describe('getValue', () => { 19 | it('extracts values from an array of cache values and returns these', () => { 20 | const expected = [[1, 2, 3], [4, 5, 6]]; 21 | const data = [{value: [1, 2, 3]}, {value: [4, 5, 6]}]; 22 | expect(getValue(data)).to.deep.equal(expected); 23 | }); 24 | it('extracts values from a cache value and returns it', () => { 25 | const expected = [1, 2, 3]; 26 | const data = {value: [1, 2, 3]}; 27 | expect(getValue(data)).to.deep.equal(expected); 28 | }); 29 | }); 30 | describe('contains & put', () => { 31 | it('if an element exist, return true', () => { 32 | const es = createEntityStore(config); 33 | const qc = createQueryCache(es); 34 | const e = config[0]; 35 | const aFn = (x) => x; 36 | const args = [1, 2, 3]; 37 | const xs = [{id: 1}, {id: 2}, {id: 3}]; 38 | put(qc, e, aFn, args, addId({}, undefined, undefined, xs)); 39 | expect(contains(qc, e, aFn, args)).to.be.true; 40 | }); 41 | it('if an element exist, and args contains a complex object, return true', () => { 42 | const es = createEntityStore(config); 43 | const qc = createQueryCache(es); 44 | const e = config[0]; 45 | const aFn = (x) => x; 46 | const args = [1, {hello: {world: 'Kalle'}}, 3]; 47 | const xs = [{id: 1}, {id: 2}, {id: 3}]; 48 | put(qc, e, aFn, args, addId({}, undefined, undefined, xs)); 49 | expect(contains(qc, e, aFn, args)).to.be.true; 50 | }); 51 | it('if an element exist, and args contains a simple object, return true', () => { 52 | const es = createEntityStore(config); 53 | const qc = createQueryCache(es); 54 | const e = config[0]; 55 | const aFn = (x) => x; 56 | const args = [1, {hello: 'world'}, 3]; 57 | const xs = [{id: 1}, {id: 2}, {id: 3}]; 58 | put(qc, e, aFn, args, addId({}, undefined, undefined, xs)); 59 | expect(contains(qc, e, aFn, args)).to.be.true; 60 | }); 61 | it('if an element does not exist, return false', () => { 62 | const es = createEntityStore(config); 63 | const qc = createQueryCache(es); 64 | const e = config[0]; 65 | const aFn = (x) => x; 66 | const args = [1, 2, 3]; 67 | expect(contains(qc, e, aFn, args)).to.be.false; 68 | }); 69 | }); 70 | describe('get', () => { 71 | it('if an element exist, return it', () => { 72 | const es = createEntityStore(config); 73 | const qc = createQueryCache(es); 74 | const e = config[0]; 75 | const aFn = (x) => x; 76 | const args = [1, 2, 3]; 77 | const xs = [{id: 1}, {id: 2}, {id: 3}]; 78 | const xsRet = [{id: 1, __ladda__id: 1}, {id: 2, __ladda__id: 2}, {id: 3, __ladda__id: 3}]; 79 | put(qc, e, aFn, args, addId({}, undefined, undefined, xs)); 80 | expect(getValue(get(qc, undefined, e, aFn, args).value)).to.deep.equal(xsRet); 81 | }); 82 | it('if an does not exist, throw an error', () => { 83 | const es = createEntityStore(config); 84 | const qc = createQueryCache(es); 85 | const e = config[0]; 86 | const aFn = (x) => x; 87 | const args = [1, 2, 3]; 88 | const fnUnderTest = () => getValue(get(qc, e, aFn, args).value); 89 | expect(fnUnderTest).to.throw(Error); 90 | }); 91 | }); 92 | describe('invalidate', () => { 93 | it('invalidates other cache as specified', () => { 94 | const es = createEntityStore(config); 95 | const qc = createQueryCache(es); 96 | const eUser = config[0]; 97 | const eCars = config[2]; 98 | const aFn = createApiFunction(x => x, {operation: 'CREATE'}); 99 | const args = [1, 2, 3]; 100 | const xs = [{id: 1}, {id: 2}, {id: 3}]; 101 | put(qc, eUser, aFn, args, addId({}, undefined, undefined, xs)); 102 | invalidate(qc, eCars, aFn); 103 | const hasUser = contains(qc, eUser, aFn, args); 104 | expect(hasUser).to.be.false; 105 | }); 106 | it('does not crash when no invalidates specified', () => { 107 | const es = createEntityStore(config); 108 | const qc = createQueryCache(es); 109 | const eBikes = config[3]; 110 | const aFn = createApiFunction(x => x, {operation: 'CREATE'}); 111 | aFn.operation = 'CREATE'; 112 | const fn = () => invalidate(qc, eBikes, aFn); 113 | expect(fn).to.not.throw(); 114 | }); 115 | it('does not invalidate other cache that starts with the same string', () => { 116 | const es = createEntityStore(config); 117 | const qc = createQueryCache(es); 118 | const eCars = config[2]; 119 | const eUserSettings = config[4]; 120 | const aFn = createApiFunction(x => x, {operation: 'CREATE'}); 121 | const args = [1, 2, 3]; 122 | const xs = [{id: 1}, {id: 2}, {id: 3}]; 123 | put(qc, eUserSettings, aFn, args, addId({}, undefined, undefined, xs)); 124 | invalidate(qc, eCars, aFn); 125 | const hasUserSettings = contains(qc, eUserSettings, aFn, args); 126 | expect(hasUserSettings).to.be.true; 127 | }); 128 | }); 129 | }); 130 | -------------------------------------------------------------------------------- /src/validator.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-len */ 2 | import { compose, map_, toPairs } from 'ladda-fp'; 3 | 4 | const warn = (logger, msg, ...args) => { 5 | logger.error(`Ladda Config Error: ${msg}`, ...args); 6 | }; 7 | 8 | const OPERATIONS = ['CREATE', 'READ', 'UPDATE', 'COMMAND', 'DELETE', 'NO_OPERATION']; 9 | const isOperation = (op) => OPERATIONS.indexOf(op) !== -1; 10 | const isConfigured = (entityName, entityConfigs) => !!entityConfigs[entityName]; 11 | const isIdFromString = (idFrom) => typeof idFrom === 'string' && ['ENTITY', 'ARGS'].indexOf(idFrom) !== -1; 12 | const isValidLogger = (logger) => logger && typeof logger.error === 'function'; 13 | 14 | const getEntityNames = (entityConfigs) => Object.keys(entityConfigs); 15 | 16 | const checkApiDeclaration = (logger, entityConfigs, entityName, entity) => { 17 | if (typeof entity.api !== 'object') { 18 | warn(logger, `No api definition found for entity ${entityName}`); 19 | return; 20 | } 21 | 22 | const warnApi = (msg, ...args) => warn( 23 | logger, 24 | `Invalid api config. ${msg}`, 25 | ...args 26 | ); 27 | 28 | const apiNames = Object.keys(entity.api); 29 | 30 | compose( 31 | // eslint-disable-next-line no-unused-vars 32 | map_(([fnName, fn]) => { 33 | const { operation, invalidates, idFrom, byId, byIds, enableDeduplication } = fn; 34 | const fullName = `${entityName}.${fnName}`; 35 | if (!isOperation(operation)) { 36 | warnApi( 37 | `${fullName}'s operation is ${operation}, use one of: `, 38 | OPERATIONS 39 | ); 40 | } 41 | 42 | if (typeof byId !== 'boolean') { 43 | warnApi( 44 | `${fullName}'s byId needs to be a boolean, was ${typeof byId}'` 45 | ); 46 | } 47 | 48 | if (typeof byIds !== 'boolean') { 49 | warnApi( 50 | `${fullName}'s byIds needs to be a boolean, was ${typeof byIds}'` 51 | ); 52 | } 53 | 54 | if (typeof enableDeduplication !== 'boolean') { 55 | warnApi( 56 | `${fullName}'s enableDeduplication needs to be a boolean, was ${typeof enableDeduplication}'` 57 | ); 58 | } 59 | 60 | if (typeof idFrom !== 'function' && !isIdFromString(idFrom)) { 61 | warnApi( 62 | `${fullName} defines illegal idFrom. Use 'ENTITY', 'ARGS', or a function (Entity => id)` 63 | ); 64 | } 65 | 66 | map_((fnToInvalidate) => { 67 | if (typeof entity.api[fnToInvalidate] !== 'function') { 68 | warnApi( 69 | `${fullName} tries to invalidate ${fnToInvalidate}, which is not a function. Use on of: `, 70 | apiNames 71 | ); 72 | } 73 | }, invalidates); 74 | }), 75 | toPairs 76 | )(entity.api); 77 | }; 78 | 79 | const checkViewOf = (logger, entityConfigs, entityName, entity) => { 80 | const { viewOf } = entity; 81 | if (viewOf && !isConfigured(viewOf, entityConfigs)) { 82 | warn( 83 | logger, 84 | `The view ${viewOf} of entity ${entityName} is not configured. Use on of: `, 85 | getEntityNames(entityConfigs) 86 | ); 87 | } 88 | }; 89 | 90 | const checkInvalidations = (logger, entityConfigs, entityName, entity) => { 91 | const { invalidates, invalidatesOn } = entity; 92 | map_((entityToInvalidate) => { 93 | if (!isConfigured(entityToInvalidate, entityConfigs)) { 94 | warn( 95 | logger, 96 | `Entity ${entityName} tries to invalidate ${entityToInvalidate}, which is not configured. Use one of: `, 97 | getEntityNames(entityConfigs) 98 | ); 99 | } 100 | }, invalidates); 101 | 102 | map_((operation) => { 103 | if (!isOperation(operation)) { 104 | warn( 105 | logger, 106 | `Entity ${entityName} tries to invalidate on invalid operation ${operation}. Use on of: `, 107 | OPERATIONS 108 | ); 109 | } 110 | }, invalidatesOn); 111 | }; 112 | 113 | const checkTTL = (logger, entityConfigs, entityName, entity) => { 114 | if (typeof entity.ttl !== 'number') { 115 | warn( 116 | logger, 117 | `Entity ${entityName} specified ttl as type of ${typeof entity.ttl}, needs to be a number in seconds` 118 | ); 119 | } 120 | }; 121 | 122 | const checkNoDedup = (logger, entityConfigs, entityName, entity) => { 123 | if (typeof entity.enableDeduplication !== 'boolean') { 124 | warn( 125 | logger, 126 | `Entity ${entityName} specified enableDeduplication as ${typeof entity.enableDeduplication}, needs to be a boolean` 127 | ); 128 | } 129 | }; 130 | 131 | const checkEntities = (logger, entityConfigs) => { 132 | const checks = [ 133 | checkApiDeclaration, 134 | checkViewOf, 135 | checkInvalidations, 136 | checkTTL, 137 | checkNoDedup 138 | ]; 139 | 140 | compose( 141 | map_(([entityName, entity]) => { 142 | map_((check) => check(logger, entityConfigs, entityName, entity), checks); 143 | }), 144 | toPairs 145 | )(entityConfigs); 146 | }; 147 | 148 | const checkGlobalConfig = (logger, config) => { 149 | const { enableDeduplication, idField } = config; 150 | if (typeof enableDeduplication !== 'boolean') { 151 | warn(logger, 'enableDeduplication needs to be a boolean, was string'); 152 | } 153 | 154 | if (typeof idField !== 'string') { 155 | warn(logger, 'idField needs to be a string, was boolean'); 156 | } 157 | }; 158 | 159 | 160 | export const validateConfig = (logger, entityConfigs, config) => { 161 | // do not remove the process.NODE_ENV check here - allows uglifiers 162 | // to optimize and remove all unreachable code. 163 | if (process.NODE_ENV === 'production' || config.useProductionBuild || !isValidLogger(logger)) { 164 | return; 165 | } 166 | 167 | 168 | checkGlobalConfig(logger, config); 169 | checkEntities(logger, entityConfigs); 170 | }; 171 | -------------------------------------------------------------------------------- /src/plugins/dedup/index.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-expressions */ 2 | 3 | import sinon from 'sinon'; 4 | import { dedupPlugin } from '.'; 5 | 6 | const delay = (cb, t = 5) => new Promise((res, rej) => { 7 | setTimeout(() => cb().then(res, rej), t); 8 | }); 9 | 10 | describe('dedup', () => { 11 | describe('for operations other than READ', () => { 12 | it('just returns the original apiFn', () => { 13 | const user = { id: 'x' }; 14 | const fn = sinon.spy(() => Promise.resolve({ ...user })); 15 | fn.operation = 'UPDATE'; 16 | const wrappedApiFn = dedupPlugin({})({ fn }); 17 | expect(wrappedApiFn).to.equal(fn); 18 | }); 19 | }); 20 | 21 | describe('for READ operations', () => { 22 | it('wraps the function', () => { 23 | const user = { id: 'x' }; 24 | const fn = sinon.spy(() => Promise.resolve(user)); 25 | fn.operation = 'READ'; 26 | const wrappedApiFn = dedupPlugin({})({ fn }); 27 | expect(wrappedApiFn).not.to.equal(fn); 28 | }); 29 | 30 | it('makes several calls when apiFn is called with different args', () => { 31 | const user = { id: 'x' }; 32 | const fn = sinon.spy(() => Promise.resolve({ ...user })); 33 | fn.operation = 'READ'; 34 | const wrappedApiFn = dedupPlugin({})({ fn }); 35 | return Promise.all([ 36 | wrappedApiFn('x'), 37 | wrappedApiFn('y') 38 | ]).then(() => { 39 | expect(fn).to.have.been.calledTwice; 40 | }); 41 | }); 42 | 43 | it('only makes one call when apiFn is called in identical args', () => { 44 | const user = { id: 'x' }; 45 | const fn = sinon.spy(() => delay(() => Promise.resolve({ ...user }))); 46 | fn.operation = 'READ'; 47 | const wrappedApiFn = dedupPlugin({})({ fn }); 48 | return Promise.all([ 49 | wrappedApiFn('x'), 50 | wrappedApiFn('x') 51 | ]).then(() => { 52 | expect(fn).to.have.been.calledOnce; 53 | }); 54 | }); 55 | 56 | it('detects complex arguments properly', () => { 57 | const user = { id: 'x' }; 58 | const fn = sinon.spy(() => delay(() => Promise.resolve({ ...user }))); 59 | fn.operation = 'READ'; 60 | const wrappedApiFn = dedupPlugin({})({ fn }); 61 | return Promise.all([ 62 | wrappedApiFn({ a: 1, b: [2, 3] }, 'a'), 63 | wrappedApiFn({ a: 1, b: [2, 3] }, 'a') 64 | ]).then(() => { 65 | expect(fn).to.have.been.calledOnce; 66 | }); 67 | }); 68 | 69 | it('passes the result of the single call to all callees', () => { 70 | const user = { id: 'x' }; 71 | const fn = sinon.spy(() => delay(() => Promise.resolve({ ...user }))); 72 | fn.operation = 'READ'; 73 | const wrappedApiFn = dedupPlugin({})({ fn }); 74 | return Promise.all([ 75 | wrappedApiFn(), 76 | wrappedApiFn() 77 | ]).then((res) => { 78 | expect(res[0]).to.deep.equal(user); 79 | expect(res[1]).to.deep.equal(user); 80 | expect(res[0]).to.equal(res[1]); 81 | }); 82 | }); 83 | 84 | it('makes subsequent calls if another calls is made after the first one is resolved', () => { 85 | const user = { id: 'x' }; 86 | const fn = sinon.spy(() => delay(() => Promise.resolve({ ...user }))); 87 | fn.operation = 'READ'; 88 | const wrappedApiFn = dedupPlugin({})({ fn }); 89 | 90 | return wrappedApiFn().then(() => { 91 | return wrappedApiFn().then(() => { 92 | expect(fn).to.have.been.calledTwice; 93 | }); 94 | }); 95 | }); 96 | 97 | it('also makes subsequent calls after the first one is rejected', () => { 98 | const user = { id: 'x' }; 99 | const fn = sinon.spy(() => delay(() => Promise.reject({ ...user }))); 100 | fn.operation = 'READ'; 101 | const wrappedApiFn = dedupPlugin({})({ fn }); 102 | 103 | return wrappedApiFn().catch(() => { 104 | return wrappedApiFn().catch(() => { 105 | expect(fn).to.have.been.calledTwice; 106 | }); 107 | }); 108 | }); 109 | 110 | it('propagates errors to all callees', () => { 111 | const error = { error: 'ERROR' }; 112 | const fn = sinon.spy(() => delay(() => Promise.reject(error))); 113 | const wrappedApiFn = dedupPlugin({})({ fn }); 114 | return Promise.all([ 115 | wrappedApiFn().catch((err) => err), 116 | wrappedApiFn().catch((err) => err) 117 | ]).then((res) => { 118 | expect(res[0]).to.equal(error); 119 | expect(res[0]).to.equal(res[1]); 120 | }); 121 | }); 122 | 123 | it('can be disabled on a global level', () => { 124 | const user = { id: 'x' }; 125 | const fn = sinon.spy(() => delay(() => Promise.resolve({ ...user }))); 126 | fn.operation = 'READ'; 127 | const config = { enableDeduplication: false }; 128 | const wrappedApiFn = dedupPlugin({ config })({ fn }); 129 | 130 | return Promise.all([ 131 | wrappedApiFn(), 132 | wrappedApiFn() 133 | ]).then(() => { 134 | expect(fn).to.have.been.calledTwice; 135 | }); 136 | }); 137 | 138 | it('can be disabled on an entity level', () => { 139 | const user = { id: 'x' }; 140 | const fn = sinon.spy(() => delay(() => Promise.resolve({ ...user }))); 141 | fn.operation = 'READ'; 142 | const entity = { enableDeduplication: false }; 143 | const wrappedApiFn = dedupPlugin({})({ fn, entity }); 144 | 145 | return Promise.all([ 146 | wrappedApiFn(), 147 | wrappedApiFn() 148 | ]).then(() => { 149 | expect(fn).to.have.been.calledTwice; 150 | }); 151 | }); 152 | 153 | it('can be disabled on a function level', () => { 154 | const user = { id: 'x' }; 155 | const fn = sinon.spy(() => delay(() => Promise.resolve({ ...user }))); 156 | fn.operation = 'READ'; 157 | fn.enableDeduplication = false; 158 | const wrappedApiFn = dedupPlugin({})({ fn }); 159 | 160 | return Promise.all([ 161 | wrappedApiFn(), 162 | wrappedApiFn() 163 | ]).then(() => { 164 | expect(fn).to.have.been.calledTwice; 165 | }); 166 | }); 167 | }); 168 | }); 169 | -------------------------------------------------------------------------------- /src/plugins/cache/query-cache.js: -------------------------------------------------------------------------------- 1 | /* Handles queries, in essence all GET operations. 2 | * Provides invalidation and querying. Uses the underlying EntityStore for all actual data. 3 | * Only ids are stored here. 4 | */ 5 | 6 | import {on2, prop, join, reduce, identity, toPairs, flatten, 7 | curry, map, map_, startsWith, compose, filter} from 'ladda-fp'; 8 | import {mPut as mPutInEs, get as getFromEs} from './entity-store'; 9 | import {serialize} from './serializer'; 10 | import {removeId, addId} from './id-helper'; 11 | 12 | // Entity -> [String] -> String 13 | const createKey = on2(reduce(join('-')), prop('name'), map(serialize)); 14 | 15 | // Value -> CacheValue 16 | const toCacheValue = (xs, createEvents = []) => ({value: xs, timestamp: Date.now(), createEvents }); 17 | 18 | // CacheValue -> Value 19 | const toValue = prop('value'); 20 | 21 | // Entity -> [ApiFnName] 22 | const getApiFnNamesWhichUpdateOnCreate = compose( 23 | reduce((mem, [fnName, fn]) => (fn.updateOnCreate ? [...mem, fnName] : mem), []), 24 | toPairs, 25 | prop('api') 26 | ); 27 | 28 | // QueryCache -> Entity -> ApiFnName 29 | const getCacheValuesForFn = curry((queryCache, entity, name) => { 30 | const key = createKey(entity, [name]); 31 | // for fns without arguments: check for direct match 32 | // for fns with arguments: check, but ignore the arguments, which are added behind a - 33 | const regexp = new RegExp(`^${key}(-|$)`); 34 | return compose( 35 | reduce((mem, [cacheKey, cacheValue]) => { 36 | return regexp.test(cacheKey) ? [...mem, cacheValue] : mem; 37 | }, []), 38 | toPairs, 39 | prop('cache') 40 | )(queryCache); 41 | }); 42 | 43 | // QueryCache -> Entity -> Id -> void 44 | export const storeCreateEvent = (queryCache, entity, id) => { 45 | return compose( 46 | map_((cacheValue) => cacheValue.createEvents.push(id)), 47 | flatten, 48 | map(getCacheValuesForFn(queryCache, entity)), 49 | getApiFnNamesWhichUpdateOnCreate, 50 | )(entity); 51 | }; 52 | 53 | // QueryCache -> String -> Bool 54 | const inCache = (qc, k) => !!qc.cache[k]; 55 | 56 | // QueryCache -> Entity -> String -> CacheValue 57 | const getFromCache = (qc, e, k) => { 58 | const rawValue = toValue(qc.cache[k]); 59 | const getValuesFromEs = compose(filter(identity), map(getFromEs(qc.entityStore, e))); 60 | const value = Array.isArray(rawValue) 61 | ? getValuesFromEs(rawValue) 62 | : getFromEs(qc.entityStore, e, rawValue); 63 | return { 64 | ...qc.cache[k], 65 | value 66 | }; 67 | }; 68 | 69 | // QueryCache -> Entity -> ApiFunction -> [a] -> [b] -> [b] 70 | export const put = curry((qc, e, aFn, args, xs) => { 71 | const k = createKey(e, [aFn.fnName, ...args]); 72 | if (Array.isArray(xs)) { 73 | qc.cache[k] = toCacheValue(map(prop('__ladda__id'), xs)); 74 | } else { 75 | qc.cache[k] = toCacheValue(prop('__ladda__id', xs)); 76 | } 77 | mPutInEs(qc.entityStore, e, Array.isArray(xs) ? xs : [xs]); 78 | return xs; 79 | }); 80 | 81 | // (CacheValue | [CacheValue]) -> Promise 82 | export const getValue = (v) => { 83 | return Array.isArray(v) ? map(toValue, v) : toValue(v); 84 | }; 85 | 86 | // QueryCache -> Entity -> ApiFunction -> [a] -> Bool 87 | export const contains = (qc, e, aFn, args) => { 88 | const k = createKey(e, [aFn.fnName, ...args]); 89 | return inCache(qc, k); 90 | }; 91 | 92 | // Entity -> Milliseconds 93 | const getTtl = e => e.ttl * 1000; 94 | 95 | // QueryCache -> Entity -> CacheValue -> Bool 96 | export const hasExpired = (qc, e, cv) => (Date.now() - cv.timestamp) > getTtl(e); 97 | 98 | // QueryCache -> Config -> Entity -> ApiFunction -> [a] -> Bool 99 | export const get = (qc, c, e, aFn, args) => { 100 | const k = createKey(e, [aFn.fnName, ...args]); 101 | if (!inCache(qc, k)) { 102 | throw new Error( 103 | `Tried to access ${e.name} with key ${k} which doesn't exist. 104 | Do a contains check first!` 105 | ); 106 | } 107 | const plainCacheValue = qc.cache[k]; 108 | while (aFn.updateOnCreate && plainCacheValue.createEvents.length) { 109 | const id = plainCacheValue.createEvents.shift(); 110 | const cachedValue = getFromCache(qc, e, k); 111 | const entityValue = getFromEs(qc.entityStore, e, id); 112 | if (!entityValue) { 113 | // the item might have been deleted in the meantime 114 | continue; // eslint-disable-line no-continue 115 | } 116 | const getVal = compose(removeId, getValue); 117 | const nextEntities = aFn.updateOnCreate(args, getVal(entityValue), getVal(cachedValue.value)); 118 | if (!nextEntities) { 119 | continue; // eslint-disable-line no-continue 120 | } 121 | const nextCachedValue = compose( 122 | (xs) => toCacheValue(map(prop('__ladda__id'), xs, plainCacheValue.createEvents)), 123 | addId(c, aFn, args) 124 | )(nextEntities); 125 | qc.cache[k] = nextCachedValue; 126 | } 127 | return getFromCache(qc, e, k); 128 | }; 129 | 130 | // Entity -> Operation -> Bool 131 | const shouldInvalidateEntity = (e, op) => { 132 | const invalidatesOn = e.invalidatesOn; 133 | return invalidatesOn && invalidatesOn.indexOf(op) > -1; 134 | }; 135 | 136 | // QueryCache -> String -> () 137 | const invalidateEntity = curry((qc, entityName) => { 138 | const keys = Object.keys(qc.cache); 139 | const removeIfEntity = k => { 140 | if (startsWith(`${entityName}-`, k)) { 141 | delete qc.cache[k]; 142 | } 143 | }; 144 | map_(removeIfEntity, keys); 145 | }); 146 | 147 | // Object -> [String] 148 | const getInvalidates = x => x.invalidates; 149 | 150 | // QueryCache -> Entity -> ApiFunction -> () 151 | const invalidateBasedOnEntity = (qc, e, aFn) => { 152 | if (shouldInvalidateEntity(e, aFn.operation)) { 153 | map_(invalidateEntity(qc), getInvalidates(e)); 154 | } 155 | }; 156 | 157 | // QueryCache -> Entity -> ApiFunction -> () 158 | const invalidateBasedOnApiFn = (qc, e, aFn) => { 159 | const prependEntity = x => `${e.name}-${x}`; 160 | const invalidateEntityByApiFn = compose(invalidateEntity(qc), prependEntity); 161 | map_(invalidateEntityByApiFn, getInvalidates(aFn)); 162 | }; 163 | 164 | // QueryCache -> Entity -> ApiFunction -> () 165 | export const invalidate = (qc, e, aFn) => { 166 | invalidateBasedOnEntity(qc, e, aFn); 167 | invalidateBasedOnApiFn(qc, e, aFn); 168 | }; 169 | 170 | // EntityStore -> QueryCache 171 | export const createQueryCache = (es) => { 172 | return {entityStore: es, cache: {}}; 173 | }; 174 | -------------------------------------------------------------------------------- /src/plugins/cache/entity-store.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-expressions */ 2 | 3 | import {createEntityStore, put, mPut, get, contains, remove} from './entity-store'; 4 | import {addId} from './id-helper'; 5 | 6 | const config = [ 7 | { 8 | name: 'user', 9 | ttl: 300, 10 | api: { 11 | getUsers: (x) => x, 12 | deleteUser: (x) => x 13 | }, 14 | invalidates: ['alles'] 15 | }, 16 | { 17 | name: 'userPreview', 18 | ttl: 200, 19 | api: { 20 | getPreviews: (x) => x, 21 | updatePreview: (x) => x 22 | }, 23 | invalidates: ['fda'], 24 | viewOf: 'user' 25 | }, 26 | { 27 | name: 'listUser', 28 | ttl: 200, 29 | api: { 30 | getPreviews: (x) => x, 31 | updatePreview: (x) => x 32 | }, 33 | invalidates: ['fda'], 34 | viewOf: 'user' 35 | } 36 | ]; 37 | 38 | describe('EntityStore', () => { 39 | describe('createEntityStore', () => { 40 | it('returns store', () => { 41 | const s = createEntityStore(config); 42 | expect(s).to.be.ok; 43 | }); 44 | it('returns store', () => { 45 | const myConfig = [ 46 | { 47 | name: 'userPreview', 48 | viewOf: 'user' 49 | }, 50 | { 51 | name: 'user' 52 | } 53 | ]; 54 | const s = createEntityStore(myConfig); 55 | expect(s).to.be.ok; 56 | }); 57 | }); 58 | describe('put', () => { 59 | it('an added value is later returned when calling get', () => { 60 | const s = createEntityStore(config); 61 | const v = {id: 'hello'}; 62 | const e = { name: 'user'}; 63 | put(s, e, addId({}, undefined, undefined, v)); 64 | const r = get(s, e, v.id); 65 | expect(r.value).to.deep.equal({...v, __ladda__id: 'hello'}); 66 | }); 67 | it('altering an added value does not alter the stored value when doing a get later', () => { 68 | const s = createEntityStore(config); 69 | const v = {id: 'hello', name: 'kalle'}; 70 | const e = { name: 'user'}; 71 | put(s, e, addId({}, undefined, undefined, v)); 72 | v.name = 'ingvar'; 73 | const r = get(s, e, v.id); 74 | expect(r.value.name).to.equal('kalle'); 75 | }); 76 | it('an added value to a view is later returned when calling get for view', () => { 77 | const s = createEntityStore(config); 78 | const v = {id: 'hello'}; 79 | const e = { name: 'user'}; 80 | put(s, e, addId({}, undefined, undefined, v)); 81 | const r = get(s, e, v.id); 82 | expect(r.value).to.deep.equal({...v, __ladda__id: 'hello'}); 83 | }); 84 | it('merges view into entity value', () => { 85 | const s = createEntityStore(config); 86 | const v = {id: 'hello'}; 87 | const e = {name: 'user'}; 88 | const eView = {name: 'userPreview', viewOf: 'user'}; 89 | put(s, e, addId({}, undefined, undefined, {...v, name: 'kalle'})); 90 | put(s, eView, addId({}, undefined, undefined, {...v, name: 'ingvar'})); 91 | const r = get(s, eView, v.id); 92 | expect(r.value).to.be.deep.equal({__ladda__id: 'hello', id: 'hello', name: 'ingvar'}); 93 | }); 94 | it('writing view value without id throws error', () => { 95 | const s = createEntityStore(config); 96 | const v = {aid: 'hello'}; 97 | const eView = {name: 'userPreview', viewOf: 'user'}; 98 | const write = () => put(s, eView, addId({}, undefined, undefined, {...v, name: 'kalle'})); 99 | expect(write).to.throw(Error); 100 | }); 101 | it('writing entitiy value without id throws error', () => { 102 | const s = createEntityStore(config); 103 | const v = {aid: 'hello'}; 104 | const e = {name: 'user'}; 105 | const write = () => put(s, e, addId({}, undefined, undefined, {...v, name: 'kalle'})); 106 | expect(write).to.throw(Error); 107 | }); 108 | }); 109 | 110 | describe('mPut', () => { 111 | it('adds values which are later returned when calling get', () => { 112 | const s = createEntityStore(config); 113 | const v1 = {id: 'hello'}; 114 | const v2 = {id: 'there'}; 115 | const e = { name: 'user'}; 116 | const v1WithId = addId({}, undefined, undefined, v1); 117 | const v2WithId = addId({}, undefined, undefined, v2); 118 | mPut(s, e, [v1WithId, v2WithId]); 119 | const r1 = get(s, e, v1.id); 120 | const r2 = get(s, e, v2.id); 121 | expect(r1.value).to.deep.equal({...v1, __ladda__id: 'hello'}); 122 | expect(r2.value).to.deep.equal({...v2, __ladda__id: 'there'}); 123 | }); 124 | }); 125 | describe('get', () => { 126 | it('gets value with timestamp', () => { 127 | const s = createEntityStore(config); 128 | const v = {id: 'hello'}; 129 | const e = { name: 'user'}; 130 | put(s, e, addId({}, undefined, undefined, v)); 131 | const r = get(s, e, v.id); 132 | expect(r.timestamp).to.not.be.undefined; 133 | }); 134 | it('altering retrieved value does not alter the stored value', () => { 135 | const s = createEntityStore(config); 136 | const v = {id: 'hello', name: 'kalle'}; 137 | const e = { name: 'user'}; 138 | put(s, e, addId({}, undefined, undefined, v)); 139 | const r = get(s, e, v.id); 140 | r.value.name = 'ingvar'; 141 | const r2 = get(s, e, v.id); 142 | expect(r2.value.name).to.equal(v.name); 143 | }); 144 | it('gets undefined if value does not exist', () => { 145 | const s = createEntityStore(config); 146 | const v = {id: 'hello'}; 147 | const e = { name: 'user'}; 148 | const r = get(s, e, v.id); 149 | expect(r).to.be.undefined; 150 | }); 151 | it('gets undefined for view if not existing', () => { 152 | const s = createEntityStore(config); 153 | const v = {id: 'hello'}; 154 | const e = {name: 'userPreview', viewOf: 'user'}; 155 | const r = get(s, e, v.id); 156 | expect(r).to.be.undefined; 157 | }); 158 | it('gets entity of view if only it exist', () => { 159 | const s = createEntityStore(config); 160 | const v = {id: 'hello'}; 161 | const e = {name: 'user'}; 162 | put(s, e, addId({}, undefined, undefined, v)); 163 | const eView = {name: 'userPreview', viewOf: 'user'}; 164 | const r = get(s, eView, v.id); 165 | expect(r.value).to.be.deep.equal({...v, __ladda__id: 'hello'}); 166 | }); 167 | it('gets view if only it exist', () => { 168 | const s = createEntityStore(config); 169 | const v = {id: 'hello'}; 170 | const eView = {name: 'userPreview', viewOf: 'user'}; 171 | put(s, eView, addId({}, undefined, undefined, v)); 172 | const r = get(s, eView, v.id); 173 | expect(r.value).to.be.deep.equal({...v, __ladda__id: 'hello'}); 174 | }); 175 | it('gets entity value if same timestamp as view value', () => { 176 | const s = createEntityStore(config); 177 | const v = {id: 'hello'}; 178 | const e = {name: 'user'}; 179 | const eView = {name: 'userPreview', viewOf: 'user'}; 180 | put(s, eView, addId({}, undefined, undefined, v)); 181 | put(s, e, addId({}, undefined, undefined, {...v, name: 'kalle'})); 182 | const r = get(s, eView, v.id); 183 | expect(r.value).to.be.deep.equal({...v, name: 'kalle', __ladda__id: 'hello'}); 184 | }); 185 | it('gets entity value if newer than view value', (done) => { 186 | const s = createEntityStore(config); 187 | const v = {id: 'hello'}; 188 | const e = {name: 'user'}; 189 | const eView = {name: 'userPreview', viewOf: 'user'}; 190 | put(s, eView, addId({}, undefined, undefined, v)); 191 | setTimeout(() => { 192 | put(s, e, addId({}, undefined, undefined, {...v, name: 'kalle'})); 193 | const r = get(s, eView, v.id); 194 | expect(r.value).to.be.deep.equal({...v, name: 'kalle', __ladda__id: 'hello'}); 195 | done(); 196 | }, 1); 197 | }); 198 | }); 199 | describe('contains', () => { 200 | it('true if value exist', () => { 201 | const s = createEntityStore(config); 202 | const v = {id: 'hello'}; 203 | const e = { name: 'user'}; 204 | put(s, e, addId({}, undefined, undefined, v)); 205 | const r = contains(s, e, v.id); 206 | expect(r).to.be.true; 207 | }); 208 | it('false if no value exist', () => { 209 | const s = createEntityStore(config); 210 | const v = {id: 'hello'}; 211 | const e = { name: 'user'}; 212 | const r = contains(s, e, v.id); 213 | expect(r).to.be.false; 214 | }); 215 | }); 216 | describe('remove', () => { 217 | it('removes an existing value', () => { 218 | const s = createEntityStore(config); 219 | const v = {id: 'hello'}; 220 | const e = { name: 'user'}; 221 | put(s, e, addId({}, undefined, undefined, v)); 222 | remove(s, e, v.id); 223 | const r = contains(s, e, v.id); 224 | expect(r).to.be.false; 225 | }); 226 | it('does not crash when removing not existing value', () => { 227 | const s = createEntityStore(config); 228 | const v = {id: 'hello'}; 229 | const e = { name: 'user'}; 230 | const fn = () => remove(s, e, v.id); 231 | expect(fn).to.not.throw(); 232 | }); 233 | }); 234 | }); 235 | -------------------------------------------------------------------------------- /src/validator.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-expressions */ 2 | import sinon from 'sinon'; 3 | import { validateConfig } from './validator'; 4 | import { getEntityConfigs } from './builder'; 5 | 6 | const createLogger = () => ({ 7 | error: sinon.spy() 8 | }); 9 | 10 | const createGlobalConfig = (conf) => ({ 11 | idField: 'id', 12 | enableDeduplication: true, 13 | useProductionBuild: false, 14 | ...conf 15 | }); 16 | 17 | describe('validateConfig', () => { 18 | it('does not do anything when using production build', () => { 19 | const logger = createLogger(); 20 | const eConfigs = getEntityConfigs({ 21 | user: {} 22 | }); 23 | const config = createGlobalConfig({ useProductionBuild: true }); 24 | 25 | validateConfig(logger, eConfigs, config); 26 | expect(logger.error).not.to.have.been.called; 27 | }); 28 | 29 | it('does not do anything when invalid logger is passed', () => { 30 | const invalidLogger = { x: sinon.spy() }; 31 | 32 | const eConfigs = getEntityConfigs({ 33 | user: {} 34 | }); 35 | const config = createGlobalConfig({ useProductionBuild: true }); 36 | 37 | validateConfig(invalidLogger, eConfigs, config); 38 | expect(invalidLogger.x).not.to.have.been.called; 39 | }); 40 | 41 | it('checks the global config object - idField', () => { 42 | const logger = createLogger(); 43 | 44 | const eConfigs = getEntityConfigs({ 45 | user: { 46 | api: { 47 | getAll: () => {} 48 | } 49 | } 50 | }); 51 | const config = createGlobalConfig({ idField: true }); 52 | 53 | validateConfig(logger, eConfigs, config); 54 | expect(logger.error).to.have.been.called; 55 | expect(logger.error.args[0][0]).to.match(/idField.*string.*was.*boolean/); 56 | }); 57 | 58 | it('checks the global config object - enableDeduplication', () => { 59 | const logger = createLogger(); 60 | 61 | const eConfigs = getEntityConfigs({ 62 | user: { 63 | api: { 64 | getAll: () => {} 65 | } 66 | } 67 | }); 68 | const config = createGlobalConfig({ enableDeduplication: 'X' }); 69 | 70 | validateConfig(logger, eConfigs, config); 71 | expect(logger.error).to.have.been.called; 72 | expect(logger.error.args[0][0]).to.match(/enableDeduplication.*boolean.*was.*string/); 73 | }); 74 | 75 | it('checks for missing api declarations', () => { 76 | const logger = createLogger(); 77 | 78 | const eConfigs = getEntityConfigs({ 79 | user: { 80 | api: { 81 | getAll: () => {} 82 | } 83 | }, 84 | activity: {} 85 | }); 86 | const config = createGlobalConfig({}); 87 | 88 | validateConfig(logger, eConfigs, config); 89 | expect(logger.error).to.have.been.called; 90 | expect(logger.error.args[0][0]).to.match(/No api definition.*activity/); 91 | }); 92 | 93 | it('checks for non-configured views', () => { 94 | const logger = createLogger(); 95 | 96 | const eConfigs = getEntityConfigs({ 97 | user: { 98 | api: { 99 | getAll: () => {} 100 | } 101 | }, 102 | mediumUser: { 103 | api: { 104 | getAll: () => {} 105 | }, 106 | viewOf: 'user' 107 | }, 108 | miniUser: { 109 | api: { 110 | getAll: () => {} 111 | }, 112 | viewOf: 'mdiumUser' // typo! 113 | } 114 | }); 115 | const config = createGlobalConfig({}); 116 | 117 | validateConfig(logger, eConfigs, config); 118 | expect(logger.error).to.have.been.called; 119 | expect(logger.error.args[0][0]).to.match(/mdiumUser.*miniUser.*not configured/); 120 | }); 121 | 122 | it('checks for wrong invalidation targets', () => { 123 | const logger = createLogger(); 124 | 125 | const eConfigs = getEntityConfigs({ 126 | user: { 127 | api: { getAll: () => {} }, 128 | invalidates: ['activity', 'ntification'] // typo! 129 | }, 130 | activity: { 131 | api: { getAll: () => {} }, 132 | viewOf: 'user' 133 | }, 134 | notification: { 135 | api: { getAll: () => {} }, 136 | invalidates: ['activity'] 137 | } 138 | }); 139 | const config = createGlobalConfig({}); 140 | 141 | validateConfig(logger, eConfigs, config); 142 | expect(logger.error).to.have.been.called; 143 | expect(logger.error.args[0][0]).to.match(/user.*invalidate.*ntification.*not configured/); 144 | }); 145 | 146 | it('checks for wrong invalidation operations', () => { 147 | const logger = createLogger(); 148 | 149 | const eConfigs = getEntityConfigs({ 150 | user: { 151 | api: { getAll: () => {} } 152 | }, 153 | activity: { 154 | api: { getAll: () => {} }, 155 | viewOf: 'user' 156 | }, 157 | notification: { 158 | api: { getAll: () => {} }, 159 | invalidates: ['activity'], 160 | invalidatesOn: ['X'] 161 | } 162 | }); 163 | const config = createGlobalConfig({}); 164 | 165 | validateConfig(logger, eConfigs, config); 166 | expect(logger.error).to.have.been.called; 167 | expect(logger.error.args[0][0]).to.match(/notification.*invalid operation.*X/); 168 | }); 169 | 170 | it('checks for wrong ttl values', () => { 171 | const logger = createLogger(); 172 | 173 | const eConfigs = getEntityConfigs({ 174 | user: { 175 | api: { getAll: () => {} }, 176 | ttl: 300 177 | }, 178 | activity: { 179 | api: { getAll: () => {} }, 180 | ttl: 'xxx' 181 | } 182 | }); 183 | const config = createGlobalConfig({}); 184 | 185 | validateConfig(logger, eConfigs, config); 186 | expect(logger.error).to.have.been.called; 187 | expect(logger.error.args[0][0]).to.match(/activity.*ttl.*string.*needs to be a number/); 188 | }); 189 | 190 | it('checks for wrong enableDeduplication value', () => { 191 | const logger = createLogger(); 192 | 193 | const eConfigs = getEntityConfigs({ 194 | user: { 195 | api: { getAll: () => {} }, 196 | enableDeduplication: true 197 | }, 198 | activity: { 199 | api: { getAll: () => {} }, 200 | enableDeduplication: 'X' 201 | } 202 | }); 203 | const config = createGlobalConfig({}); 204 | 205 | validateConfig(logger, eConfigs, config); 206 | expect(logger.error).to.have.been.called; 207 | expect(logger.error.args[0][0]).to.match( 208 | /activity.*enableDeduplication.*string.*needs to be a boolean/ 209 | ); 210 | }); 211 | 212 | 213 | it('checks for wrong api operations', () => { 214 | const logger = createLogger(); 215 | 216 | const getAll = () => {}; 217 | getAll.operation = 'X'; 218 | 219 | const eConfigs = getEntityConfigs({ 220 | user: { 221 | api: { getAll } 222 | } 223 | }); 224 | const config = createGlobalConfig({}); 225 | 226 | validateConfig(logger, eConfigs, config); 227 | expect(logger.error).to.have.been.called; 228 | expect(logger.error.args[0][0]).to.match(/user.getAll.*operation.*X/); 229 | }); 230 | 231 | it('checks for wrong api byId field', () => { 232 | const logger = createLogger(); 233 | 234 | const getAll = () => {}; 235 | getAll.operation = 'READ'; 236 | getAll.byId = 'xxx'; 237 | 238 | const eConfigs = getEntityConfigs({ 239 | user: { 240 | api: { getAll } 241 | } 242 | }); 243 | const config = createGlobalConfig({}); 244 | 245 | validateConfig(logger, eConfigs, config); 246 | expect(logger.error).to.have.been.called; 247 | expect(logger.error.args[0][0]).to.match(/user.getAll.*byId.*string/); 248 | }); 249 | 250 | it('checks for wrong api byIds field', () => { 251 | const logger = createLogger(); 252 | 253 | const getAll = () => {}; 254 | getAll.operation = 'READ'; 255 | getAll.byIds = 'xxx'; 256 | 257 | const eConfigs = getEntityConfigs({ 258 | user: { 259 | api: { getAll } 260 | } 261 | }); 262 | const config = createGlobalConfig({}); 263 | 264 | validateConfig(logger, eConfigs, config); 265 | expect(logger.error).to.have.been.called; 266 | expect(logger.error.args[0][0]).to.match(/user.getAll.*byIds.*string/); 267 | }); 268 | 269 | it('checks for wrong api enableDeduplication definition', () => { 270 | const logger = createLogger(); 271 | 272 | const getAll = () => {}; 273 | getAll.operation = 'READ'; 274 | getAll.enableDeduplication = 'X'; 275 | 276 | const eConfigs = getEntityConfigs({ 277 | user: { 278 | api: { getAll } 279 | } 280 | }); 281 | const config = createGlobalConfig({}); 282 | 283 | validateConfig(logger, eConfigs, config); 284 | expect(logger.error).to.have.been.called; 285 | expect(logger.error.args[0][0]).to.match(/user.getAll.*enableDeduplication.*string/); 286 | }); 287 | 288 | it('checks for wrong api idFrom (illegal type)', () => { 289 | const logger = createLogger(); 290 | 291 | const getAll = () => {}; 292 | getAll.operation = 'READ'; 293 | getAll.idFrom = true; 294 | 295 | const eConfigs = getEntityConfigs({ 296 | user: { 297 | api: { getAll } 298 | } 299 | }); 300 | const config = createGlobalConfig({}); 301 | 302 | validateConfig(logger, eConfigs, config); 303 | expect(logger.error).to.have.been.called; 304 | expect(logger.error.args[0][0]).to.match(/user.getAll.*idFrom/); 305 | }); 306 | 307 | it('checks for wrong api idFrom (illegal string)', () => { 308 | const logger = createLogger(); 309 | 310 | const getAll = () => {}; 311 | getAll.operation = 'READ'; 312 | getAll.idFrom = 'X'; 313 | 314 | const eConfigs = getEntityConfigs({ 315 | user: { 316 | api: { getAll } 317 | } 318 | }); 319 | const config = createGlobalConfig({}); 320 | 321 | validateConfig(logger, eConfigs, config); 322 | expect(logger.error).to.have.been.called; 323 | expect(logger.error.args[0][0]).to.match(/user.getAll.*idFrom/); 324 | }); 325 | 326 | it('checks for wrong api invalidates definition', () => { 327 | const logger = createLogger(); 328 | 329 | const getAll = () => {}; 330 | getAll.operation = 'READ'; 331 | getAll.invalidates = ['getOne', 'getSme']; // typo! 332 | 333 | const getOne = () => {}; 334 | getOne.operation = 'READ'; 335 | 336 | const getSome = () => {}; 337 | getSome.operation = 'READ'; 338 | 339 | const eConfigs = getEntityConfigs({ 340 | user: { 341 | api: { getAll, getSome, getOne } 342 | } 343 | }); 344 | const config = createGlobalConfig({}); 345 | 346 | validateConfig(logger, eConfigs, config); 347 | expect(logger.error).to.have.been.called; 348 | expect(logger.error.args[0][0]).to.match(/user.getAll.*invalidate.*getSme/); 349 | }); 350 | 351 | it('checks for correct operation', () => { 352 | const logger = createLogger(); 353 | 354 | const getAll = () => {}; 355 | getAll.operation = 'RAD'; 356 | 357 | const eConfigs = getEntityConfigs({ 358 | user: { 359 | api: { getAll } 360 | } 361 | }); 362 | const config = createGlobalConfig({}); 363 | 364 | validateConfig(logger, eConfigs, config); 365 | expect(logger.error).to.have.been.calledOnce; 366 | expect(logger.error.args[0][0]).to.match(/user.getAll.*operation is RAD.*use one of/); 367 | }); 368 | 369 | it('informs about several errors', () => { 370 | const logger = createLogger(); 371 | 372 | const getAll = () => {}; 373 | getAll.operation = 'READ'; 374 | getAll.idFrom = 'X'; 375 | 376 | const eConfigs = getEntityConfigs({ 377 | user: { 378 | api: { getAll }, 379 | invalidates: ['X'] 380 | } 381 | }); 382 | const config = createGlobalConfig({}); 383 | 384 | validateConfig(logger, eConfigs, config); 385 | expect(logger.error).to.have.been.calledTwice; 386 | }); 387 | 388 | it('happily accepts valid configurations', () => { 389 | const logger = createLogger(); 390 | 391 | const getAll = () => {}; 392 | getAll.operation = 'READ'; 393 | getAll.idFrom = 'ENTITY'; 394 | 395 | const createUser = () => {}; 396 | createUser.operation = 'CREATE'; 397 | 398 | const updateUser = () => {}; 399 | updateUser.operation = 'UPDATE'; 400 | 401 | const deleteUser = () => {}; 402 | deleteUser.operation = 'DELETE'; 403 | 404 | const commandForUser = () => {}; 405 | commandForUser.operation = 'COMMAND'; 406 | 407 | const noopUser = () => {}; 408 | noopUser.operation = 'NO_OPERATION'; 409 | 410 | const eConfigs = getEntityConfigs({ 411 | user: { 412 | api: { getAll, createUser, updateUser, deleteUser, commandForUser, noopUser }, 413 | invalidates: ['activity'] 414 | }, 415 | activity: { 416 | api: { getAll: () => {} }, 417 | ttl: 400 418 | } 419 | }); 420 | const config = createGlobalConfig({}); 421 | 422 | validateConfig(logger, eConfigs, config); 423 | expect(logger.error).not.to.have.been.called; 424 | }); 425 | }); 426 | -------------------------------------------------------------------------------- /docs/advanced/Plugins.md: -------------------------------------------------------------------------------- 1 | # Plugins 2 | 3 | Ladda was built with extensibility in mind and features a powerful 4 | plugin API to build additional functionality on top of its simple core. 5 | 6 | Under the hood Ladda's core functionality (caching, views and 7 | invalidation) is implemented using this API as well. 8 | 9 | Check out the [Cheat Sheet](/docs/advanced/Plugins-CheatSheet.md) to get an 10 | overview on a single glance and take a look at our [curated list of 11 | plugins](/docs/advanced/Plugins-KnownPlugins.md). 12 | 13 | ## Building a simple logger plugin 14 | 15 | At its core a plugin is a higher order function, which returns a 16 | decorator function, which is invoked for each __ApiFunction__ you 17 | specified in your Ladda configuration - it is is supposed to return a 18 | new decorated version of the given ApiFunction. 19 | 20 | Let's start with the minimal boilerplate, which is needed to get a 21 | plugin off the ground: 22 | 23 | ```javascript 24 | export const logger = (pluginConfig = {}) => { 25 | return ({ entityConfigs, config, addChangeListener }) => { 26 | return ({ entity, fn }) => { 27 | return (...args) => { 28 | return fn(...args); 29 | }; 30 | }; 31 | }; 32 | }; 33 | ``` 34 | 35 | These function can be described as three individual steps, which 36 | eventually return a decorate ApiFunction. 37 | We refer to these steps as __create__, __setup__ and __decorate__. 38 | 39 | If we were to give these functions names, our boilerplate would look 40 | like this: 41 | 42 | ```javascript 43 | function create(pluginConfig = {}) { 44 | return function setup({ entityConfigs, config, addChangeListener }) { 45 | return function decorate({ entity, fn }) { 46 | return function decoratedApiFn(...args) { 47 | return fn(...args); 48 | } 49 | } 50 | } 51 | } 52 | ``` 53 | 54 | ### Create: The plugin factory 55 | 56 | ```javascript 57 | export const logger = (pluginConfig = {}) => { 58 | return ({ entityConfigs, config, addChangeListener }) => { 59 | // ... 60 | }; 61 | }; 62 | ``` 63 | 64 | It is generally a good practice to expose your plugin as a module, which 65 | is a plugin factory: A function which produces a plugin. 66 | 67 | While this is strictly speaking not needed it allows you to take 68 | additional configuration arguments for your plugin. 69 | 70 | Our simple logger will not act on any additional configuration for now, 71 | but it is not unreasonable to assume that we might enhance it's 72 | capabilites in the future. We could for example create our plugin like 73 | this: `logger({ disable: true })`, so that we could turn the logger off 74 | with a simple boolean flag. 75 | 76 | Try to adhere to this principle, even if your plugin does not take any 77 | configuration arguments when you start out. Also try to provide good 78 | defaults, so that your users can try and play with your plugin easily. 79 | 80 | ### Setup: Producing the plugin decorator function 81 | 82 | ```javascript 83 | export const logger = (pluginConfig = {}) => { 84 | return ({ entityConfigs, config, addChangeListener }) => { 85 | return ({ entity, fn }) => { 86 | // ... 87 | }; 88 | }; 89 | }; 90 | ``` 91 | 92 | We mentioned earlier, that a plugin is a function which produces a 93 | decorator function, which should return a new decorated ApiFunction. 94 | 95 | This function is called exactly once during build time (when Ladda's 96 | `build` function is called). 97 | 98 | The Plugin API tries to give you as much information as possible while 99 | you are creating your plugin. The plugin function therefore receives a 100 | single object with the complete entity configuration you specified, the 101 | global ladda configuration and the registration function to add a change 102 | listener. 103 | 104 | `entityConfigs` is a slightly enhanced version of the configuration you 105 | defined as first argument of your `build` call. It is a dictionary, 106 | where the keys are __EntityNames__ and the values __EntityConfigs__. 107 | 108 | There are three differences to what you initially passed: 109 | - All defaults are applied, so that you can inspect precisely how each 110 | entity is configured. 111 | - For ease of use each __EntityConfig__ has an additional `name` 112 | property, which equals to the __EntityName__. 113 | - If you specified a global Ladda configuration with `__config`, you 114 | will not find it here. 115 | 116 | `config` is the global Ladda Configuration you might have specified in 117 | the `__config` field of your build configuration. Even if you left it 118 | out (as it is optional) you will receive an object with applied defaults 119 | here. 120 | 121 | `addChangeListener` allows us to register a callback to be invoked each 122 | time something changes inside of Ladda's cache. 123 | The callback is invoked with a single argument, a ChangeObject of the 124 | following shape: 125 | 126 | 127 | ```javascript 128 | { 129 | operation: 'CREATE' | 'READ' | 'UPDATE' | 'DELETE' | 'COMMAND' | 'NO_OPERATION', 130 | entity: EntityName, 131 | apiFn: ApiFunctionName, 132 | args: Any[] | null 133 | values: EntityValue[], 134 | } 135 | ``` 136 | 137 | It provides all information about which call triggered a change, 138 | including the arguments array. 139 | 140 | The `values` field is guaranteed to be a list of EntityValues, even if 141 | a change only affects a single entity. The only expection are 142 | `NO_OPERATION` operations, which will always return `null` here. 143 | 144 | `addChangeListener` returns a deregistration function. Call it to stop 145 | listening for changes. 146 | 147 |
148 | 149 | 150 | A more sophisticated plugin would use this space to define additional 151 | data structures, that should act across all entities, hence we refer to 152 | this step as __setup__. 153 | 154 | Things are a little simpler with our logger plugin - e.g. it doesn't 155 | hold any state of its own. Let's notify the user that Ladda's setup is 156 | running and present all configuration we received: 157 | 158 | 159 | ```javascript 160 | export const logger = (pluginConfig = {}) => { 161 | return ({ entityConfigs, config, addChangeListener }) => { 162 | console.log('Ladda: Setup in progress', pluginConfig, entityConfigs, config); 163 | return ({ entity, fn }) => { 164 | // ... 165 | }; 166 | }; 167 | }; 168 | ``` 169 | 170 | We can also notify the users about any changes that happen within Ladda 171 | and register a change listener: 172 | 173 | ```javascript 174 | export const logger = (pluginConfig = {}) => { 175 | return ({ entityConfigs, config, addChangeListener }) => { 176 | console.log('Ladda: Setup in progress', pluginConfig, entityConfigs, config); 177 | 178 | addChangeListener((change) => console.log('Ladda: Cache change', change)); 179 | 180 | return ({ entity, fn }) => { 181 | // ... 182 | }; 183 | }; 184 | }; 185 | ``` 186 | 187 | We need to return a decorator function here, which will be invoked for 188 | every ApiFunction we defined in our build configuration. Our goal is to 189 | wrap such an ApiFunction and return one with enhanced functionality. 190 | 191 | 192 | ### Decorate: Wrapping the original ApiFunction 193 | 194 | ```javascript 195 | export const logger = (pluginConfig = {}) => { 196 | return ({ entityConfigs, config, addChangeListener }) => { 197 | // ... 198 | 199 | return ({ entity, fn }) => { 200 | return (...args) => { 201 | return fn(...args); 202 | } 203 | }; 204 | }; 205 | }; 206 | ``` 207 | 208 | Our decorator function will receive a single argument, which is an object 209 | with two fields: 210 | 211 | - `entity` is an __EntityConfig__ as described above. All defaults are 212 | applied and an additional `name` property is present to identify it. 213 | - `fn` is the original __ApiFunction__ we want to act on. It has all 214 | meta data attached, that was defined in the build configuration, 215 | including defaults. In addition Ladda's `build` function also added the 216 | property `fnName`, so that we can easily identify it. 217 | 218 | With this comprehensive information we can easily add additional 219 | behavior to an ApiFunction. 220 | 221 | We return a function which takes the same arguments as the original call 222 | and make sure that we also return the same type. This is again fairly 223 | simple in our logger example, where we can just invoke the original 224 | function with the arguments we receive and return its value. 225 | 226 | Let's add some logging around this ApiFunction: 227 | 228 | ```javascript 229 | export const logger = (pluginConfig = {}) => { 230 | return ({ entityConfigs, config, addChangeListener }) => { 231 | // ... 232 | 233 | return ({ entity, fn }) => { 234 | return (...args) => { 235 | console.log(`Ladda: Calling ${entity.name}.${fn.fnName} with args`, args); 236 | return fn(...args).then( 237 | (res) => { 238 | console.log(`Ladda: Resolved ${entity.name}.${fn.fnName} with`, res); 239 | return res; 240 | }, 241 | (err) => { 242 | console.log(`Ladda: Rejected ${entity.name}.${fn.fnName} with`, err) 243 | return Promise.reject(err); 244 | } 245 | ); 246 | } 247 | }; 248 | }; 249 | }; 250 | ``` 251 | 252 | We issue a first log statement immediately when the function is invoked 253 | and print out the arguments we received. By using the entity 254 | configuration we got passed in and the meta data of the ApiFunction we 255 | can produce a nice string to reveal which function just got called: 256 | `${entity.name}.${fn.fnName}`. This could for example produce 257 | something like `user.getAll`. 258 | 259 | We then use Promise chaining to intercept the result of our original 260 | ApiFunction call and log whether the promise was resolved or rejected. 261 | As our logger is a passive plugin that just provides an additional 262 | side-effect (printing to the console), we make sure that we pass the 263 | original results on properly: The resolved promise value, or the error 264 | with which our promise got rejected. 265 | 266 | Mind that you can just return a plain function from this decorator 267 | function. You do __NOT__ need to worry about all meta data the `fn` you 268 | received was provided with. Ladda's `build` function will make sure, 269 | that all meta data that was originally defined will be added to the 270 | final API function. This includes additional meta data you define on the 271 | ApiFunction object in your plugin (an example of this can be found in the 272 | [ladda-observable](https://github.com/ladda-js/ladda-observable) plugin, which 273 | adds an additional function to the ApiFunction object). 274 | 275 | 276 | ### Putting it altogether 277 | 278 | Here is the final version of our simple logger plugin: 279 | 280 | ```javascript 281 | export const logger = (pluginConfig = {}) => { 282 | return ({ entityConfigs, config, addChangeListener }) => { 283 | console.log('Ladda: Setup in progress', pluginConfig, entityConfigs, config); 284 | 285 | addChangeListener((change) => console.log('Ladda: Cache change', change)); 286 | 287 | return ({ entity, fn }) => { 288 | return (...args) => { 289 | console.log(`Ladda: Calling ${entity.name}.${fn.fnName} with args`, args); 290 | return fn(...args).then( 291 | (res) => { 292 | console.log(`Ladda: Resolved ${entity.name}.${fn.fnName} with`, res); 293 | return res; 294 | }, 295 | (err) => { 296 | console.log(`Ladda: Rejected ${entity.name}.${fn.fnName} with`, err) 297 | return Promise.reject(err); 298 | } 299 | ); 300 | }; 301 | }; 302 | }; 303 | }; 304 | ``` 305 | 306 | We log during the setup process and reveal all the configuration our 307 | plugin would have access to, log all change objects which are spawned 308 | when Ladda's cache is updated and inform our users about each individual 309 | api call that is made. 310 | 311 | ### Using your plugin with Ladda 312 | 313 | We now need to instruct Ladda to use our plugin. Ladda's `build` 314 | function takes an optional second argument, which allows us to specify a 315 | list of plugins we want to use. 316 | 317 | 318 | ```javascript 319 | import { build } from 'ladda'; 320 | import { logger } from './logger'; 321 | 322 | const config = { /* your ladda configuration */ }; 323 | 324 | export default build(config, [ 325 | logger() 326 | ]); 327 | ``` 328 | 329 | Mind that plugins are evaluated from left to right. Given a list of 330 | plugins like `[a(), b(), c()]` this means that plugin `c` would be able 331 | to see all information the plugins `a` and `b` have provided. The 332 | ApiFunction which is passed to `c` is the ApiFunction produced by `b`, 333 | which itself is passed a reference to the ApiFunction produced by `a`. 334 | 335 |
336 | 337 | And that's it! Congratulations, you just built your first Ladda plugin! 338 | You can try to run this code for yourself to see it in action, or open 339 | up your developer console while browsing the [Contact List Example 340 | Application](https://...). This app uses a more comprehensive 341 | implementation of the logger we just built, which can be found in the 342 | [ladda-logger](https://github.com/ladda-js/ladda-logger) repository. 343 | -------------------------------------------------------------------------------- /src/plugins/cache/operations/read.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-expressions */ 2 | 3 | import sinon from 'sinon'; 4 | import {curry, map} from 'ladda-fp'; 5 | import {decorateRead} from './read'; 6 | import {createCache} from '../cache'; 7 | import {createSampleConfig, createApiFunction} from '../test-helper'; 8 | 9 | const curryNoop = () => () => {}; 10 | const config = createSampleConfig(); 11 | 12 | describe('Read', () => { 13 | describe('decorateRead', () => { 14 | it('stores and returns an array with elements that lack id', () => { 15 | const cache = createCache(config); 16 | const e = config[0]; 17 | const xOrg = [{name: 'Kalle'}, {name: 'Anka'}]; 18 | const aFnWithoutSpy = createApiFunction(() => Promise.resolve(xOrg), {idFrom: 'ARGS'}); 19 | const aFn = sinon.spy(aFnWithoutSpy); 20 | const res = decorateRead({}, cache, curryNoop, e, aFn); 21 | return res(1).then(x => { 22 | expect(x).to.deep.equal(xOrg); 23 | }); 24 | }); 25 | 26 | it('does set id to serialized args if idFrom ARGS', () => { 27 | const cache = createCache(config); 28 | const e = config[0]; 29 | const xOrg = {name: 'Kalle'}; 30 | const aFnWithoutSpy = createApiFunction(() => Promise.resolve(xOrg), {idFrom: 'ARGS'}); 31 | const aFn = sinon.spy(aFnWithoutSpy); 32 | const res = decorateRead({}, cache, curryNoop, e, aFn); 33 | return res({hello: 'hej', other: 'svej'}).then(x => { 34 | expect(x).to.deep.equal({name: 'Kalle'}); 35 | }); 36 | }); 37 | 38 | describe('with byId set', () => { 39 | it('calls api fn if not in cache', () => { 40 | const cache = createCache(config); 41 | const e = config[0]; 42 | const xOrg = {id: 1, name: 'Kalle'}; 43 | const aFnWithoutSpy = createApiFunction(() => Promise.resolve(xOrg), {byId: true}); 44 | const aFn = sinon.spy(aFnWithoutSpy); 45 | const res = decorateRead({}, cache, curryNoop, e, aFn); 46 | return res(1).then(() => { 47 | expect(aFn.callCount).to.equal(1); 48 | }); 49 | }); 50 | 51 | it('calls api fn if in cache, but expired', () => { 52 | const myConfig = createSampleConfig(); 53 | myConfig[0].ttl = 0; 54 | const cache = createCache(myConfig); 55 | const e = myConfig[0]; 56 | const xOrg = {id: 1, name: 'Kalle'}; 57 | const aFnWithoutSpy = createApiFunction(() => Promise.resolve(xOrg), {byId: true}); 58 | const aFn = sinon.spy(aFnWithoutSpy); 59 | const res = decorateRead({}, cache, curryNoop, e, aFn); 60 | const delay = () => new Promise((resolve) => setTimeout(resolve, 1)); 61 | return res(1).then(delay).then(res.bind(null, 1)).then(() => { 62 | expect(aFn.callCount).to.equal(2); 63 | }); 64 | }); 65 | 66 | it('does not call api fn if in cache', () => { 67 | const cache = createCache(config); 68 | const e = config[0]; 69 | const xOrg = {id: 1, name: 'Kalle'}; 70 | const aFnWithoutSpy = createApiFunction(() => Promise.resolve(xOrg), {byId: true}); 71 | const aFn = sinon.spy(aFnWithoutSpy); 72 | const res = decorateRead({}, cache, curryNoop, e, aFn); 73 | return res(1).then(res.bind(null, 1)).then(() => { 74 | expect(aFn.callCount).to.equal(1); 75 | }); 76 | }); 77 | 78 | it('triggers notify when not in cache', () => { 79 | const spy = sinon.spy(); 80 | const n = curry((a, b) => spy(a, b)); 81 | const cache = createCache(config); 82 | const e = config[0]; 83 | const xOrg = {id: 1, name: 'Kalle'}; 84 | const aFnWithoutSpy = createApiFunction(() => Promise.resolve(xOrg), {byId: true}); 85 | const aFn = sinon.spy(aFnWithoutSpy); 86 | const res = decorateRead({}, cache, n, e, aFn); 87 | return res(1).then((r) => { 88 | expect(spy).to.have.been.calledWith([1], r); 89 | }); 90 | }); 91 | 92 | it('does not trigger notify when in cache', () => { 93 | const spy = sinon.spy(); 94 | const n = curry((a, b) => spy(a, b)); 95 | const cache = createCache(config); 96 | const e = config[0]; 97 | const xOrg = {id: 1, name: 'Kalle'}; 98 | const aFnWithoutSpy = createApiFunction(() => Promise.resolve(xOrg), {byId: true}); 99 | const aFn = sinon.spy(aFnWithoutSpy); 100 | const res = decorateRead({}, cache, n, e, aFn); 101 | return res(1).then(res.bind(null, 1)).then(() => { 102 | expect(spy).to.have.been.calledOnce; // and not a second time for the cache hit 103 | }); 104 | }); 105 | }); 106 | 107 | describe('with byIds', () => { 108 | const users = { 109 | a: { id: 'a' }, 110 | b: { id: 'b' }, 111 | c: { id: 'c' } 112 | }; 113 | 114 | const fn = (ids) => Promise.resolve(map((id) => users[id], ids)); 115 | const decoratedFn = createApiFunction(fn, { byIds: true }); 116 | 117 | it('calls api fn if nothing in cache', () => { 118 | const cache = createCache(config); 119 | const e = config[0]; 120 | const fnWithSpy = sinon.spy(decoratedFn); 121 | const apiFn = decorateRead({}, cache, curryNoop, e, fnWithSpy); 122 | return apiFn(['a', 'b']).then((res) => { 123 | expect(res).to.deep.equal([users.a, users.b]); 124 | }); 125 | }); 126 | 127 | it('calls api fn if nothing in cache', () => { 128 | const cache = createCache(config); 129 | const e = config[0]; 130 | const fnWithSpy = sinon.spy(decoratedFn); 131 | const apiFn = decorateRead({}, cache, curryNoop, e, fnWithSpy); 132 | return apiFn(['a', 'b']).then((res) => { 133 | expect(res).to.deep.equal([users.a, users.b]); 134 | }); 135 | }); 136 | 137 | it('puts item in the cache and can read them again', () => { 138 | const cache = createCache(config); 139 | const e = config[0]; 140 | const fnWithSpy = sinon.spy(decoratedFn); 141 | const apiFn = decorateRead({}, cache, curryNoop, e, fnWithSpy); 142 | const args = ['a', 'b']; 143 | return apiFn(args).then(() => { 144 | return apiFn(args).then((res) => { 145 | expect(fnWithSpy).to.have.been.calledOnce; 146 | expect(res).to.deep.equal([users.a, users.b]); 147 | }); 148 | }); 149 | }); 150 | 151 | it('only makes additional request for uncached items', () => { 152 | const cache = createCache(config); 153 | const e = config[0]; 154 | const fnWithSpy = sinon.spy(decoratedFn); 155 | const apiFn = decorateRead({}, cache, curryNoop, e, fnWithSpy); 156 | return apiFn(['a', 'b']).then(() => { 157 | return apiFn(['b', 'c']).then(() => { 158 | expect(fnWithSpy).to.have.been.calledTwice; 159 | expect(fnWithSpy).to.have.been.calledWith(['a', 'b']); 160 | expect(fnWithSpy).to.have.been.calledWith(['c']); 161 | }); 162 | }); 163 | }); 164 | 165 | it('returns all items in correct order when making partial requests', () => { 166 | const cache = createCache(config); 167 | const e = config[0]; 168 | const fnWithSpy = sinon.spy(decoratedFn); 169 | const apiFn = decorateRead({}, cache, curryNoop, e, fnWithSpy); 170 | return apiFn(['a', 'b']).then(() => { 171 | return apiFn(['a', 'b', 'c']).then((res) => { 172 | expect(res).to.deep.equal([users.a, users.b, users.c]); 173 | }); 174 | }); 175 | }); 176 | 177 | it('triggers notify when not in cache', () => { 178 | const spy = sinon.spy(); 179 | const n = curry((a, b) => spy(a, b)); 180 | const cache = createCache(config); 181 | const e = config[0]; 182 | const fnWithSpy = sinon.spy(decoratedFn); 183 | const apiFn = decorateRead({}, cache, n, e, fnWithSpy); 184 | return apiFn(['a', 'b']).then((r) => { 185 | expect(spy).to.have.been.calledOnce; 186 | expect(spy).to.have.been.calledWith([['a', 'b']], r); 187 | }); 188 | }); 189 | 190 | it('triggers notify when not in cache for partial request', () => { 191 | const spy = sinon.spy(); 192 | const n = curry((a, b) => spy(a, b)); 193 | const cache = createCache(config); 194 | const e = config[0]; 195 | const fnWithSpy = sinon.spy(decoratedFn); 196 | const apiFn = decorateRead({}, cache, n, e, fnWithSpy); 197 | return apiFn(['a', 'b']).then(() => { 198 | spy.reset(); 199 | return apiFn(['a', 'b', 'c']).then((r) => { 200 | expect(spy).to.have.been.calledOnce; 201 | expect(spy).to.have.been.calledWith([['c']], r); 202 | }); 203 | }); 204 | }); 205 | 206 | it('does not trigger notify when in cache', () => { 207 | const spy = sinon.spy(); 208 | const n = curry((a, b) => spy(a, b)); 209 | const cache = createCache(config); 210 | const e = config[0]; 211 | const fnWithSpy = sinon.spy(decoratedFn); 212 | const apiFn = decorateRead({}, cache, n, e, fnWithSpy); 213 | return apiFn(['a', 'b']).then(() => { 214 | spy.reset(); 215 | return apiFn(['a', 'b']).then(() => { 216 | expect(spy).not.to.have.been.called; 217 | }); 218 | }); 219 | }); 220 | }); 221 | 222 | it('calls api fn if not in cache', () => { 223 | const cache = createCache(config); 224 | const e = config[0]; 225 | const xOrg = {id: 1, name: 'Kalle'}; 226 | const aFnWithoutSpy = createApiFunction(() => Promise.resolve(xOrg)); 227 | const aFn = sinon.spy(aFnWithoutSpy); 228 | const res = decorateRead({}, cache, curryNoop, e, aFn); 229 | return res(1).then(() => { 230 | expect(aFn.callCount).to.equal(1); 231 | }); 232 | }); 233 | 234 | it('does not call api fn if in cache', () => { 235 | const cache = createCache(config); 236 | const e = config[0]; 237 | const xOrg = {id: 1, name: 'Kalle'}; 238 | const aFnWithoutSpy = createApiFunction(() => Promise.resolve(xOrg)); 239 | const aFn = sinon.spy(aFnWithoutSpy); 240 | const res = decorateRead({}, cache, curryNoop, e, aFn); 241 | 242 | const firstCall = res(1); 243 | 244 | return firstCall.then(() => { 245 | return res(1).then(() => { 246 | expect(aFn.callCount).to.equal(1); 247 | }); 248 | }); 249 | }); 250 | 251 | it('does call api fn if in cache but expired', () => { 252 | const cache = createCache(config); 253 | const e = {...config[0], ttl: -1}; 254 | const xOrg = {id: 1, name: 'Kalle'}; 255 | const aFnWithoutSpy = createApiFunction(() => Promise.resolve(xOrg)); 256 | const aFn = sinon.spy(aFnWithoutSpy); 257 | const res = decorateRead({}, cache, curryNoop, e, aFn); 258 | 259 | const firstCall = res(1); 260 | 261 | return firstCall.then(() => { 262 | return res(1).then(() => { 263 | expect(aFn.callCount).to.equal(2); 264 | }); 265 | }); 266 | }); 267 | 268 | it('calls api fn if not in cache (plural)', () => { 269 | const cache = createCache(config); 270 | const e = config[0]; 271 | const xOrg = [{id: 1, name: 'Kalle'}]; 272 | const aFnWithoutSpy = createApiFunction(() => Promise.resolve(xOrg)); 273 | const aFn = sinon.spy(aFnWithoutSpy); 274 | const res = decorateRead({}, cache, curryNoop, e, aFn); 275 | return res(1).then((x) => { 276 | expect(x).to.equal(xOrg); 277 | }); 278 | }); 279 | 280 | it('does not call api fn if in cache (plural)', () => { 281 | const cache = createCache(config); 282 | const e = config[0]; 283 | const xOrg = [{id: 1, name: 'Kalle'}]; 284 | const aFnWithoutSpy = createApiFunction(() => Promise.resolve(xOrg)); 285 | const aFn = sinon.spy(aFnWithoutSpy); 286 | const res = decorateRead({}, cache, curryNoop, e, aFn); 287 | 288 | const firstCall = res(1); 289 | 290 | return firstCall.then(() => { 291 | return res(1).then(() => { 292 | expect(aFn.callCount).to.equal(1); 293 | }); 294 | }); 295 | }); 296 | 297 | it('does call api fn if in cache but expired (plural)', () => { 298 | const cache = createCache(config); 299 | const e = {...config[0], ttl: -1}; 300 | const xOrg = [{id: 1, name: 'Kalle'}]; 301 | const aFnWithoutSpy = createApiFunction(() => Promise.resolve(xOrg)); 302 | const aFn = sinon.spy(aFnWithoutSpy); 303 | const res = decorateRead({}, cache, curryNoop, e, aFn); 304 | 305 | const firstCall = res(1); 306 | 307 | return firstCall.then(() => { 308 | return res(1).then(() => { 309 | expect(aFn.callCount).to.equal(2); 310 | }); 311 | }); 312 | }); 313 | 314 | it('throws if id is missing', () => { 315 | const cache = createCache(config); 316 | const e = {...config[0], ttl: 300}; 317 | const xOrg = {name: 'Kalle'}; 318 | const aFnWithoutSpy = createApiFunction(() => Promise.resolve(xOrg)); 319 | const aFn = sinon.spy(aFnWithoutSpy); 320 | const res = decorateRead({}, cache, curryNoop, e, aFn); 321 | 322 | return res().catch(err => { 323 | expect(err).to.be.a('Error'); 324 | }); 325 | }); 326 | 327 | it('triggers notify when not in cache', () => { 328 | const spy = sinon.spy(); 329 | const n = curry((a, b) => spy(a, b)); 330 | const cache = createCache(config); 331 | const e = config[0]; 332 | const xOrg = [{id: 1, name: 'Kalle'}]; 333 | const aFnWithoutSpy = createApiFunction(() => Promise.resolve(xOrg)); 334 | const aFn = sinon.spy(aFnWithoutSpy); 335 | const res = decorateRead({}, cache, n, e, aFn); 336 | 337 | return res(1).then((r) => { 338 | expect(spy).to.have.been.calledOnce; 339 | expect(spy).to.have.been.calledWith([1], r); 340 | }); 341 | }); 342 | 343 | it('does not trigger notify when in cache', () => { 344 | const spy = sinon.spy(); 345 | const n = curry((a, b) => spy(a, b)); 346 | const cache = createCache(config); 347 | const e = config[0]; 348 | const xOrg = [{id: 1, name: 'Kalle'}]; 349 | const aFnWithoutSpy = createApiFunction(() => Promise.resolve(xOrg)); 350 | const aFn = sinon.spy(aFnWithoutSpy); 351 | const res = decorateRead({}, cache, n, e, aFn); 352 | 353 | const firstCall = res(1); 354 | 355 | return firstCall.then(() => { 356 | spy.reset(); 357 | return res(1).then(() => { 358 | expect(spy).not.to.have.been.called; 359 | }); 360 | }); 361 | }); 362 | }); 363 | }); 364 | -------------------------------------------------------------------------------- /src/builder.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-expressions */ 2 | 3 | import sinon from 'sinon'; 4 | import {curry, toIdMap} from 'ladda-fp'; 5 | import {build} from './builder'; 6 | 7 | const users = [{ id: 1 }, { id: 2 }]; 8 | const usersMap = toIdMap(users); 9 | 10 | const getUser = (id) => Promise.resolve(usersMap[id]); 11 | getUser.operation = 'READ'; 12 | 13 | const getUsers = () => Promise.resolve(users); 14 | getUsers.operation = 'READ'; 15 | 16 | const deleteUser = (id) => Promise.resolve(usersMap[id]); 17 | deleteUser.operation = 'DELETE'; 18 | 19 | const noopUser = () => Promise.resolve(['a', 'b']); 20 | noopUser.operation = 'NO_OPERATION'; 21 | 22 | const config = () => ({ 23 | user: { 24 | ttl: 300, 25 | api: { 26 | getUser, 27 | getUsers, 28 | deleteUser, 29 | noopUser 30 | }, 31 | invalidates: ['alles'] 32 | }, 33 | __config: { 34 | useProductionBuild: true 35 | } 36 | }); 37 | 38 | describe('builder', () => { 39 | it('Builds the API', () => { 40 | const api = build(config()); 41 | expect(api).to.be.ok; 42 | }); 43 | it('Two read api calls will only require one api request to be made', (done) => { 44 | const myConfig = config(); 45 | myConfig.user.api.getUsers = sinon.spy(myConfig.user.api.getUsers); 46 | const api = build(myConfig); 47 | 48 | const expectOnlyOneApiCall = () => { 49 | expect(myConfig.user.api.getUsers.callCount).to.equal(1); 50 | done(); 51 | }; 52 | 53 | Promise.resolve() 54 | .then(() => api.user.getUsers()) 55 | .then(() => api.user.getUsers()) 56 | .then(expectOnlyOneApiCall); 57 | }); 58 | 59 | it('Two read api calls will return the same output', (done) => { 60 | const myConfig = config(); 61 | myConfig.user.api.getUsers = sinon.spy(myConfig.user.api.getUsers); 62 | const api = build(myConfig); 63 | 64 | const expectOnlyOneApiCall = (xs) => { 65 | expect(xs).to.be.deep.equal([{id: 1}, {id: 2}]); 66 | done(); 67 | }; 68 | 69 | Promise.resolve() 70 | .then(() => api.user.getUsers()) 71 | .then(() => api.user.getUsers()) 72 | .then(expectOnlyOneApiCall); 73 | }); 74 | 75 | it('1000 calls is not slow', (done) => { 76 | const myConfig = config(); 77 | myConfig.user.api.getUsers = sinon.spy(myConfig.user.api.getUsers); 78 | myConfig.user.api.getUsers.idFrom = 'ARGS'; 79 | const api = build(myConfig); 80 | const start = Date.now(); 81 | const checkTimeConstraint = () => { 82 | expect(Date.now() - start < 1000).to.be.true; 83 | done(); 84 | }; 85 | 86 | let bc = Promise.resolve(); 87 | for (let i = 0; i < 1000; i++) { 88 | bc = bc.then(() => api.user.getUsers('wei')); 89 | } 90 | bc.then(checkTimeConstraint); 91 | }); 92 | 93 | it('Works with non default id set', (done) => { 94 | const myConfig = config(); 95 | myConfig.__config = {idField: 'mySecretId', useProductionBuild: true}; 96 | myConfig.user.api.getUsers = sinon.spy( 97 | () => Promise.resolve([{mySecretId: 1}, {mySecretId: 2}]) 98 | ); 99 | myConfig.user.api.getUsers.operation = 'READ'; 100 | const api = build(myConfig); 101 | const expectOnlyOneApiCall = (xs) => { 102 | expect(myConfig.user.api.getUsers.callCount).to.equal(1); 103 | expect(xs).to.be.deep.equal([{mySecretId: 1}, {mySecretId: 2}]); 104 | done(); 105 | }; 106 | 107 | Promise.resolve() 108 | .then(() => api.user.getUsers()) 109 | .then(() => api.user.getUsers()) 110 | .then(expectOnlyOneApiCall); 111 | }); 112 | 113 | it('Delete removes value from cached array', (done) => { 114 | const myConfig = config(); 115 | myConfig.user.api.getUsers = sinon.spy(() => Promise.resolve([{id: 1}, {id: 2}])); 116 | myConfig.user.api.getUsers.operation = 'READ'; 117 | const api = build(myConfig); 118 | const expectUserToBeRemoved = (xs) => { 119 | expect(xs).to.be.deep.equal([{id: 2}]); 120 | done(); 121 | }; 122 | 123 | Promise.resolve() 124 | .then(() => api.user.getUsers()) 125 | .then(() => api.user.deleteUser('1')) 126 | .then(() => api.user.getUsers()) 127 | .then(expectUserToBeRemoved); 128 | }); 129 | 130 | describe('after deletion', () => { 131 | it('calls the apiFn again when marked as byId === true', () => { 132 | const myConfig = config(); 133 | const spy = sinon.spy(); 134 | 135 | const getUserById = () => { 136 | spy(); 137 | return Promise.resolve({ id: 4 }); 138 | }; 139 | getUserById.operation = 'READ'; 140 | getUserById.byId = true; 141 | 142 | myConfig.user.api.getUserById = getUserById; 143 | 144 | const api = build(myConfig); 145 | 146 | return Promise.resolve() 147 | .then(() => api.user.getUserById('1')) 148 | .then(() => api.user.deleteUser('1')) 149 | .then(() => api.user.getUserById('1')) 150 | .then(() => { 151 | expect(spy).to.have.been.calledTwice; 152 | }); 153 | }); 154 | 155 | it('returns null for normal read operation when entity is requested again', () => { 156 | const myConfig = config(); 157 | 158 | const api = build(myConfig); 159 | 160 | return Promise.resolve() 161 | .then(() => api.user.getUser('1')) 162 | .then(() => api.user.deleteUser('1')) 163 | .then(() => api.user.getUser('1')) 164 | .then((user) => { 165 | expect(user).to.be.null; 166 | }); 167 | }); 168 | }); 169 | 170 | 171 | it('TTL set to zero means we never get a cache hit', (done) => { 172 | const myConfig = config(); 173 | myConfig.user.ttl = 0; 174 | myConfig.user.api.getUsers = sinon.spy(myConfig.user.api.getUsers); 175 | const api = build(myConfig); 176 | 177 | const expectOnlyOneApiCall = () => { 178 | expect(myConfig.user.api.getUsers.callCount).to.equal(2); 179 | done(); 180 | }; 181 | 182 | const delay = () => new Promise(res => setTimeout(() => res(), 1)); 183 | 184 | Promise.resolve() 185 | .then(() => api.user.getUsers()) 186 | .then(delay) 187 | .then(() => api.user.getUsers()) 188 | .then(expectOnlyOneApiCall); 189 | }); 190 | 191 | it('takes plugins as second argument', (done) => { 192 | const myConfig = config(); 193 | const pluginTracker = {}; 194 | const plugin = (pConfig) => { 195 | const pName = pConfig.name; 196 | pluginTracker[pName] = {}; 197 | return curry(({ config: c, entityConfigs }, { fn }) => { 198 | pluginTracker[pName][fn.fnName] = true; 199 | return fn; 200 | }); 201 | }; 202 | const pluginName = 'X'; 203 | const expectACall = () => expect(pluginTracker[pluginName].getUsers).to.be.true; 204 | 205 | const api = build(myConfig, [plugin({ name: pluginName })]); 206 | api.user.getUsers() 207 | .then(expectACall) 208 | .then(() => done()); 209 | }); 210 | 211 | it('applies dedup before and after the plugins, if there are any', () => { 212 | const getAll = sinon.stub().returns(Promise.resolve([])); 213 | getAll.operation = 'READ'; 214 | const conf = { test: { api: { getAll } } }; 215 | const plugin = () => ({ fn }) => () => { 216 | fn(); 217 | fn(); 218 | return fn(); 219 | }; 220 | const api = build(conf, [plugin]); 221 | api.test.getAll(); 222 | api.test.getAll(); 223 | return api.test.getAll().then(() => { 224 | expect(getAll).to.have.been.calledOnce; 225 | }); 226 | }); 227 | 228 | describe('change listener', () => { 229 | it('exposes Ladda\'s listener/onChange interface to plugins', () => { 230 | const plugin = ({ addChangeListener }) => { 231 | expect(addChangeListener).to.be; 232 | return ({ fn }) => fn; 233 | }; 234 | 235 | build(config(), [plugin]); 236 | }); 237 | 238 | it('returns a deregistration fn', () => { 239 | const spy = sinon.spy(); 240 | 241 | const plugin = ({ addChangeListener }) => { 242 | const deregister = addChangeListener(spy); 243 | deregister(); 244 | return ({ fn }) => fn; 245 | }; 246 | 247 | const api = build(config(), [plugin]); 248 | 249 | return api.user.getUsers().then(() => { 250 | expect(spy).not.to.have.been.called; 251 | }); 252 | }); 253 | 254 | it('can call deregistration fn several times without harm', () => { 255 | const spy = sinon.spy(); 256 | 257 | const plugin = ({ addChangeListener }) => { 258 | const deregister = addChangeListener(spy); 259 | deregister(); 260 | deregister(); 261 | deregister(); 262 | return ({ fn }) => fn; 263 | }; 264 | 265 | const api = build(config(), [plugin]); 266 | 267 | return api.user.getUsers().then(() => { 268 | expect(spy).not.to.have.been.called; 269 | }); 270 | }); 271 | 272 | describe('allows plugins to add a listener, which gets notified on all cache changes', () => { 273 | it('on READ operations', () => { 274 | const spy = sinon.spy(); 275 | 276 | const plugin = ({ addChangeListener }) => { 277 | addChangeListener(spy); 278 | return ({ fn }) => fn; 279 | }; 280 | 281 | const api = build(config(), [plugin]); 282 | 283 | return api.user.getUsers().then(() => { 284 | expect(spy).to.have.been.calledOnce; 285 | const changeObject = spy.args[0][0]; 286 | expect(changeObject.entity).to.equal('user'); 287 | expect(changeObject.apiFn).to.equal('getUsers'); 288 | expect(changeObject.operation).to.equal('READ'); 289 | expect(changeObject.values).to.deep.equal(users); 290 | expect(changeObject.args).to.deep.equal([]); 291 | }); 292 | }); 293 | 294 | it('on NO_OPERATION operations', () => { 295 | const spy = sinon.spy(); 296 | 297 | const plugin = ({ addChangeListener }) => { 298 | addChangeListener(spy); 299 | return ({ fn }) => fn; 300 | }; 301 | 302 | const api = build(config(), [plugin]); 303 | 304 | return api.user.noopUser('x').then(() => { 305 | expect(spy).to.have.been.calledOnce; 306 | const changeObject = spy.args[0][0]; 307 | expect(changeObject.entity).to.equal('user'); 308 | expect(changeObject.apiFn).to.equal('noopUser'); 309 | expect(changeObject.operation).to.equal('NO_OPERATION'); 310 | expect(changeObject.values).to.deep.equal(null); 311 | expect(changeObject.args).to.deep.equal(['x']); 312 | }); 313 | }); 314 | 315 | it('on DELETE operations', () => { 316 | const spy = sinon.spy(); 317 | 318 | const plugin = ({ addChangeListener }) => { 319 | addChangeListener(spy); 320 | return ({ fn }) => fn; 321 | }; 322 | 323 | const api = build(config(), [plugin]); 324 | 325 | // fill the cache with users so that we can delete something 326 | return api.user.getUsers().then(() => { 327 | spy.reset(); 328 | return api.user.deleteUser('1').then(() => { 329 | expect(spy).to.have.been.calledOnce; 330 | const changeObject = spy.args[0][0]; 331 | expect(changeObject.entity).to.equal('user'); 332 | expect(changeObject.apiFn).to.equal('deleteUser'); 333 | expect(changeObject.operation).to.equal('DELETE'); 334 | expect(changeObject.values).to.deep.equal([{ id: 1 }]); 335 | expect(changeObject.args).to.deep.equal(['1']); 336 | }); 337 | }); 338 | }); 339 | }); 340 | 341 | it('does not trigger when a pure cache hit is made', () => { 342 | const spy = sinon.spy(); 343 | 344 | const plugin = ({ addChangeListener }) => { 345 | addChangeListener(spy); 346 | return ({ fn }) => fn; 347 | }; 348 | 349 | const api = build(config(), [plugin]); 350 | 351 | return api.user.getUsers().then(() => { 352 | expect(spy).to.have.been.calledOnce; 353 | 354 | return api.user.getUsers().then(() => { 355 | expect(spy).to.have.been.calledOnce; 356 | }); 357 | }); 358 | }); 359 | 360 | it('returns a deregistration function to remove the listener', () => { 361 | const spy = sinon.spy(); 362 | 363 | const plugin = ({ addChangeListener }) => { 364 | const deregister = addChangeListener(spy); 365 | deregister(); 366 | return ({ fn }) => fn; 367 | }; 368 | 369 | const api = build(config(), [plugin]); 370 | 371 | return api.user.getUsers().then(() => { 372 | expect(spy).not.to.have.been.called; 373 | }); 374 | }); 375 | }); 376 | 377 | describe('updateOnCreate', () => { 378 | it('allows to define a hook, which updates the query cache on create', () => { 379 | const xs = [{ id: 1 }, { id: 2 }]; 380 | 381 | const createX = (newX) => Promise.resolve(newX); 382 | createX.operation = 'CREATE'; 383 | 384 | const getXs = () => Promise.resolve(xs); 385 | getXs.operation = 'READ'; 386 | getXs.updateOnCreate = (args, newX, cachedXs) => [...cachedXs, newX]; 387 | 388 | const api = build({ x: { api: { getXs, createX } } }); 389 | 390 | return api.x.getXs().then((cachedXs) => { 391 | expect(cachedXs).to.deep.equal(xs); 392 | 393 | return api.x.createX({ id: 3 }).then((nextX) => { 394 | return api.x.getXs().then((nextXs) => { 395 | expect(nextXs).to.deep.equal([...xs, nextX]); 396 | }); 397 | }); 398 | }); 399 | }); 400 | 401 | it('makes sure that updateOnCreate hook is applied only when needed', () => { 402 | // we're testing here whether our detection on where to apply a create event 403 | // actually works. We had a bug here, where a create event was applied twice, 404 | // when several updateOnCreate fns where defined, on api functions, where 405 | // the name of one was a substring of another (like getList and getList2) 406 | const xs = [{ id: 1 }, { id: 2 }]; 407 | 408 | const createX = (newX) => Promise.resolve(newX); 409 | createX.operation = 'CREATE'; 410 | 411 | const getList = () => Promise.resolve(xs); 412 | getList.operation = 'READ'; 413 | getList.updateOnCreate = (args, newX, cachedXs) => [...cachedXs, newX]; 414 | 415 | // eslint-disable-next-line no-unused-vars 416 | const getList2 = (someArg) => Promise.resolve(xs); 417 | getList2.operation = 'READ'; 418 | getList2.updateOnCreate = (args, newX, cachedXs) => [...cachedXs, newX]; 419 | 420 | const getList3 = () => Promise.resolve(xs); 421 | getList3.operation = 'READ'; 422 | getList3.updateOnCreate = (args, newX, cachedXs) => [...cachedXs, newX]; 423 | 424 | const api = build({ x: { api: { getList, getList2, getList3, createX } } }); 425 | return api.x.getList2('x').then(() => { 426 | return api.x.getList3().then(() => { 427 | return api.x.createX({ id: 3 }).then((nextX) => { 428 | return api.x.getList2('x').then((nextXs) => { 429 | expect(nextXs).to.deep.equal([...xs, nextX]); 430 | return api.x.getList3().then((otherNextXs) => { 431 | expect(otherNextXs).to.deep.equal([...xs, nextX]); 432 | }); 433 | }); 434 | }); 435 | }); 436 | }); 437 | }); 438 | 439 | it('can decide how to update based on prior arguments', () => { 440 | const xs = [{ id: 1 }, { id: 2 }]; 441 | 442 | const createX = (newX) => Promise.resolve(newX); 443 | createX.operation = 'CREATE'; 444 | 445 | const getXs = () => Promise.resolve(xs); 446 | getXs.operation = 'READ'; 447 | getXs.updateOnCreate = (args, newX, cachedXs) => { 448 | return args[0] ? [newX, ...cachedXs] : [...cachedXs, newX]; 449 | }; 450 | 451 | const api = build({ x: { api: { getXs, createX } } }); 452 | 453 | return Promise.all([ 454 | api.x.getXs(true), 455 | api.x.getXs(false) 456 | ]).then(() => { 457 | return api.x.createX({ id: 3 }).then((nextX) => { 458 | return Promise.all([ 459 | api.x.getXs(true), 460 | api.x.getXs(false) 461 | ]).then(([prepended, appended]) => { 462 | expect(prepended).to.deep.equal([nextX, ...xs]); 463 | expect(appended).to.deep.equal([...xs, nextX]); 464 | }); 465 | }); 466 | }); 467 | }); 468 | 469 | it('can decide not to update anything', () => { 470 | const xs = [{ id: 1 }, { id: 2 }]; 471 | 472 | const createX = (newX) => Promise.resolve(newX); 473 | createX.operation = 'CREATE'; 474 | 475 | const getXs = () => Promise.resolve(xs); 476 | getXs.operation = 'READ'; 477 | getXs.updateOnCreate = () => {}; 478 | 479 | const api = build({ x: { api: { getXs, createX } } }); 480 | 481 | return api.x.getXs().then((cachedXs) => { 482 | expect(cachedXs).to.deep.equal(xs); 483 | 484 | return api.x.createX({ id: 3 }).then(() => { 485 | return api.x.getXs().then((nextXs) => { 486 | expect(nextXs).to.deep.equal(xs); 487 | }); 488 | }); 489 | }); 490 | }); 491 | 492 | it('works fine when created element is removed before we try to access the list again', () => { 493 | const xs = [{ id: 1 }, { id: 2 }]; 494 | 495 | const createX = (newX) => Promise.resolve(newX); 496 | createX.operation = 'CREATE'; 497 | 498 | const getXs = () => Promise.resolve(xs); 499 | getXs.operation = 'READ'; 500 | getXs.updateOnCreate = (args, newX, cachedXs) => [...cachedXs, newX]; 501 | 502 | const deleteX = () => Promise.resolve(); 503 | deleteX.operation = 'DELETE'; 504 | 505 | const api = build({ x: { api: { getXs, createX, deleteX } } }); 506 | 507 | return api.x.getXs().then((cachedXs) => { 508 | expect(cachedXs).to.deep.equal(xs); 509 | 510 | return api.x.createX({ id: 3 }).then(() => { 511 | return api.x.deleteX(3).then(() => { 512 | return api.x.getXs().then((nextXs) => { 513 | expect(nextXs).to.deep.equal(xs); 514 | }); 515 | }); 516 | }); 517 | }); 518 | }); 519 | }); 520 | 521 | describe('idFrom ARGS', () => { 522 | const getX = (a, b) => { 523 | if (a === '' && b === '') { 524 | return Promise.resolve({ x: 'xxx' }); 525 | } 526 | return Promise.resolve({ x: 'x' }); 527 | }; 528 | getX.operation = 'READ'; 529 | getX.idFrom = 'ARGS'; 530 | 531 | const idFromArgsConfig = () => ({ 532 | x: { 533 | ttl: 300, 534 | api: { getX } 535 | }, 536 | __config: { 537 | useProductionBuild: true 538 | } 539 | }); 540 | 541 | it('works when return value has no id and args are present', () => { 542 | const c = idFromArgsConfig(); 543 | const api = build(c); 544 | 545 | return api.x.getX('some', 'random', false, 'args').then((x) => { 546 | expect(x.x).to.equal('x'); // we basically just wanna know it doesn't throw 547 | }); 548 | }); 549 | 550 | it('works when return value has no id and NO args are present', () => { 551 | const c = idFromArgsConfig(); 552 | const api = build(c); 553 | 554 | return api.x.getX().then((x) => { 555 | expect(x.x).to.equal('x'); // we basically just wanna know it doesn't throw 556 | }); 557 | }); 558 | 559 | it('deals properly with empty strings', () => { 560 | const c = idFromArgsConfig(); 561 | const api = build(c); 562 | 563 | return api.x.getX('').then((x) => { 564 | expect(x.x).to.equal('x'); 565 | return api.x.getX('', '').then((secondX) => { 566 | expect(secondX.x).to.equal('xxx'); 567 | }); 568 | }); 569 | }); 570 | }); 571 | }); 572 | 573 | --------------------------------------------------------------------------------