diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..efce947 --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +/src/app/system/console/colors.package.js~ +/package.json~ +/docs/RULES.md~ +# Logs +logs +*.log + +# Runtime data +pids +*.pid +*.seed + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directory +# https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git +node_modules + +# Compiled api docs +api_docs +/log.txt +/bin +/stacks.out +/out +yarn-error.log +NTH + +docs \ No newline at end of file diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..f284bb9 --- /dev/null +++ b/.npmignore @@ -0,0 +1,3 @@ +tests +README.md +.travis.yml \ No newline at end of file diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..501e1d0 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,11 @@ +language: node_js +node_js: + - "10" + - "8" + - "6" +install: + - npm ci +script: + - npm test + - npm run bench +sudo: false diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..0849b8e --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,73 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, gender identity and expression, level of experience, +nationality, personal appearance, race, religion, or sexual identity and +orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or + advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic + address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project team at fredericcharette@gmail.com. All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. The project team is +obligated to maintain confidentiality with regard to the reporter of an incident. +Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, +available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html + +[homepage]: https://www.contributor-covenant.org diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..4fd506e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,8 @@ +# Contribute + +Please contribute! Here are some things that would be great: +- Open an issue! +- Open a pull request! +- Say hi! :wave: + +Please abide by the [code of conduct](CODE_OF_CONDUCT.md). diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1b72f84 --- /dev/null +++ b/LICENSE @@ -0,0 +1,13 @@ +Copyright 2018 Frederic Charette + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/README.md b/README.md index a56d87b..d01d075 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,36 @@ -# REST-store +

+ + Kalm +

+
+ REST-store +

+

+ The resource Manager +


+

+
+ +[![rest-store](https://img.shields.io/npm/v/rest-store.svg)](https://www.npmjs.com/package/rest-store) +[![Node](https://img.shields.io/badge/node->%3D8.0-blue.svg)](https://nodejs.org) +[![Build Status](https://travis-ci.org/fed135/rest-store.svg?branch=master)](https://travis-ci.org/fed135/rest-store.js) +[![Dependencies Status](https://david-dm.org/fed135/rest-store.js.svg)](https://david-dm.org/fed135/rest-store.js) + +--- + +## How it works Want to make your app faster and don't want to spend on extra infrastructure ? **REST-store** is: -An in-memory, self-adjustting cache: +An in-memory, self-adjustting microcache: - Helps reduce the number of requests for 'hot' information - No noticeable footprint - No need for extra caching architecture (redis/memcache) -With request dedupping, batching, retrying and circuit-breaking: +Adds request dedupping, batching, retrying and circuit-breaking: - Process-wide request profiling and mapping - Greatly reduces the number of requests @@ -30,15 +50,86 @@ With request dedupping, batching, retrying and circuit-breaking: ## Usage +**Data-Access layer** + ```node +function getItems(ids, params) { + // Some compute-heavy async function or external request to a DB / service +} +``` +**Store** +```node +const store = require('rest-store'); +const itemStore = store({ getter: { method: getItems }}); ``` +**Model** +```node +function getItemById(id, params) { + itemStore.one(id, params) + .then(item => /* The item you requested */); +} +``` + +## Options + +Name | Required | Default | Description +--- | --- | --- | +getter | true | - | The method to wrap, and how to interpret the returned data. Uses the format `{ method: , responseParser: ` +uniqueOptions | false | `[]` | The list of parameters that, when passed, alter the results of the items requested. Ex: 'language', 'view', 'fields', 'country'. These will generate different combinaisons of cache keys. +cache | false | ```{ + enabled: true, + step: 1000, + ttl: 10000, +}``` | Caching options for the data +batch | false | ```{ + enabled: false, + tick: 40, + limit: 100, +}``` | Batching options for the requests +retry | false | ```{ + enabled: true, + max: 3, + scale: { + mult: 2.5, + base: 5, + } +}``` | Retry options for the requests + + +## Monitoring and events + +REST-store emits events to track cache hits, miss and outbound requests. + +Event | Description +--- | --- +cacheHit | When the requested item is present in the microcache, or is already being fetched. Prevents another request from being created. +cacheMiss | When the requested item is not present in the microcache and is not currently being fetched. A new request will be made. +batch | When a batch of requests is about to be sent. +batchFailed | Indicates that the batch has failed. Retry policy will dictate if it should be re-attempted. +batchCancelled | Indicates that the batch has reached the allowed number of retries and is now abandonning. +batchSuccess | Indicates that the batch request was successful. +bumpCache | When a call for an item fully loaded in the microcache succeeds, it's ttl gets extended. +clearCache | When an item in the microcache has reached it's ttl and is now being evicted. + ## Testing `npm test` -## Contributing +## References + +[White paper - Microcaching]() + +## Contribute + +Please do! This is an open source project - if you see something that you want, [open an issue](//github.com/kalm/kalm.js/issues/new) or file a pull request. + +If you have a major change, it would be better to open an issue first so that we can talk about it. + +I am always looking for more maintainers, as well. Get involved. + +## License -## License +[Apache 2.0](LICENSE) (c) 2017 Frederic Charette diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..79b14b3 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,348 @@ +{ + "name": "rest-batcher", + "version": "1.0.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "@sinonjs/formatio": { + "version": "2.0.0", + "resolved": "http://registry.npmjs.org/@sinonjs/formatio/-/formatio-2.0.0.tgz", + "integrity": "sha512-ls6CAMA6/5gG+O/IdsBcblvnd8qcO/l1TYoNeAzp3wcISOxlPXQEus0mLcdwazEkWjaBdaJ3TaxmNgCLWwvWzg==", + "dev": true, + "requires": { + "samsam": "1.3.0" + } + }, + "assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true + }, + "balanced-match": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", + "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", + "dev": true + }, + "brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "requires": { + "balanced-match": "1.0.0", + "concat-map": "0.0.1" + } + }, + "browser-stdout": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", + "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", + "dev": true + }, + "chai": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.1.2.tgz", + "integrity": "sha1-D2RYS6ZC8PKs4oBiefTwbKI61zw=", + "dev": true, + "requires": { + "assertion-error": "1.1.0", + "check-error": "1.0.2", + "deep-eql": "3.0.1", + "get-func-name": "2.0.0", + "pathval": "1.1.0", + "type-detect": "4.0.8" + } + }, + "check-error": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.2.tgz", + "integrity": "sha1-V00xLt2Iu13YkS6Sht1sCu1KrII=", + "dev": true + }, + "commander": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.11.0.tgz", + "integrity": "sha512-b0553uYA5YAEGgyYIGYROzKQ7X5RAqedkfjiZxwi0kL1g3bOaBNNZfYkzt/CL0umgD5wc9Jec2FbB98CjkMRvQ==", + "dev": true + }, + "concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", + "dev": true + }, + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "deep-eql": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-3.0.1.tgz", + "integrity": "sha512-+QeIQyN5ZuO+3Uk5DYh6/1eKO0m0YmJFGNmFHGACpf1ClL1nmlV/p4gNgbl2pJGxgXb4faqo6UE+M5ACEMyVcw==", + "dev": true, + "requires": { + "type-detect": "4.0.8" + } + }, + "diff": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-3.5.0.tgz", + "integrity": "sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==", + "dev": true + }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", + "dev": true + }, + "fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", + "dev": true + }, + "get-func-name": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.0.tgz", + "integrity": "sha1-6td0q+5y4gQJQzoGY2YCPdaIekE=", + "dev": true + }, + "glob": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz", + "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==", + "dev": true, + "requires": { + "fs.realpath": "1.0.0", + "inflight": "1.0.6", + "inherits": "2.0.3", + "minimatch": "3.0.4", + "once": "1.4.0", + "path-is-absolute": "1.0.1" + } + }, + "growl": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/growl/-/growl-1.10.3.tgz", + "integrity": "sha512-hKlsbA5Vu3xsh1Cg3J7jSmX/WaW6A5oBeqzM88oNbCRQFz+zUaXm6yxS4RVytp1scBoJzSYl4YAEOQIt6O8V1Q==", + "dev": true + }, + "has-flag": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-2.0.0.tgz", + "integrity": "sha1-6CB68cx7MNRGzHC3NLXovhj4jVE=", + "dev": true + }, + "he": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/he/-/he-1.1.1.tgz", + "integrity": "sha1-k0EP0hsAlzUVH4howvJx80J+I/0=", + "dev": true + }, + "inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "dev": true, + "requires": { + "once": "1.4.0", + "wrappy": "1.0.2" + } + }, + "inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=", + "dev": true + }, + "isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=", + "dev": true + }, + "just-extend": { + "version": "1.1.27", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-1.1.27.tgz", + "integrity": "sha512-mJVp13Ix6gFo3SBAy9U/kL+oeZqzlYYYLQBwXVBlVzIsZwBqGREnOro24oC/8s8aox+rJhtZ2DiQof++IrkA+g==", + "dev": true + }, + "lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=", + "dev": true + }, + "lolex": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/lolex/-/lolex-2.3.2.tgz", + "integrity": "sha512-A5pN2tkFj7H0dGIAM6MFvHKMJcPnjZsOMvR7ujCjfgW5TbV6H9vb1PgxLtHvjqNZTHsUolz+6/WEO0N1xNx2ng==", + "dev": true + }, + "minimatch": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", + "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "dev": true, + "requires": { + "brace-expansion": "1.1.11" + } + }, + "minimist": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", + "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=", + "dev": true + }, + "mkdirp": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", + "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", + "dev": true, + "requires": { + "minimist": "0.0.8" + } + }, + "mocha": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-5.1.1.tgz", + "integrity": "sha512-kKKs/H1KrMMQIEsWNxGmb4/BGsmj0dkeyotEvbrAuQ01FcWRLssUNXCEUZk6SZtyJBi6EE7SL0zDDtItw1rGhw==", + "dev": true, + "requires": { + "browser-stdout": "1.3.1", + "commander": "2.11.0", + "debug": "3.1.0", + "diff": "3.5.0", + "escape-string-regexp": "1.0.5", + "glob": "7.1.2", + "growl": "1.10.3", + "he": "1.1.1", + "minimatch": "3.0.4", + "mkdirp": "0.5.1", + "supports-color": "4.4.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true + }, + "nise": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/nise/-/nise-1.3.3.tgz", + "integrity": "sha512-v1J/FLUB9PfGqZLGDBhQqODkbLotP0WtLo9R4EJY2PPu5f5Xg4o0rA8FDlmrjFSv9vBBKcfnOSpfYYuu5RTHqg==", + "dev": true, + "requires": { + "@sinonjs/formatio": "2.0.0", + "just-extend": "1.1.27", + "lolex": "2.3.2", + "path-to-regexp": "1.7.0", + "text-encoding": "0.6.4" + } + }, + "once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "dev": true, + "requires": { + "wrappy": "1.0.2" + } + }, + "path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", + "dev": true + }, + "path-to-regexp": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.7.0.tgz", + "integrity": "sha1-Wf3g9DW62suhA6hOnTvGTpa5k30=", + "dev": true, + "requires": { + "isarray": "0.0.1" + } + }, + "pathval": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.0.tgz", + "integrity": "sha1-uULm1L3mUwBe9rcTYd74cn0GReA=", + "dev": true + }, + "samsam": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/samsam/-/samsam-1.3.0.tgz", + "integrity": "sha512-1HwIYD/8UlOtFS3QO3w7ey+SdSDFE4HRNLZoZRYVQefrOY3l17epswImeB1ijgJFQJodIaHcwkp3r/myBjFVbg==", + "dev": true + }, + "sinon": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-5.0.3.tgz", + "integrity": "sha512-kzBkET1Hf0r0J4uVnlicuAEiq9nnhPrEHZWS0mds+5EaB9rA0XoliIkLaqkBNU9lwPuJACo/velUQQOmTRJtUw==", + "dev": true, + "requires": { + "@sinonjs/formatio": "2.0.0", + "diff": "3.5.0", + "lodash.get": "4.4.2", + "lolex": "2.3.2", + "nise": "1.3.3", + "supports-color": "5.4.0", + "type-detect": "4.0.8" + }, + "dependencies": { + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", + "dev": true + }, + "supports-color": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.4.0.tgz", + "integrity": "sha512-zjaXglF5nnWpsq470jSv6P9DwPvgLkuapYmfDm3JWOm0vkNTVF2tI4UrN2r6jH1qM/uc/WtxYY1hYoA2dOKj5w==", + "dev": true, + "requires": { + "has-flag": "3.0.0" + } + } + } + }, + "supports-color": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-4.4.0.tgz", + "integrity": "sha512-rKC3+DyXWgK0ZLKwmRsrkyHVZAjNkfzeehuFWdGGcqGDTZFH73+RH6S/RDAAxl9GusSjZSUWYLmT9N5pzXFOXQ==", + "dev": true, + "requires": { + "has-flag": "2.0.0" + } + }, + "text-encoding": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/text-encoding/-/text-encoding-0.6.4.tgz", + "integrity": "sha1-45mpgiV6J22uQou5KEXLcb3CbRk=", + "dev": true + }, + "type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true + }, + "wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", + "dev": true + } + } +} diff --git a/package.json b/package.json index 1bc50fd..c82f020 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,18 @@ { - "name": "rest-batcher", + "name": "rest-store", "version": "1.0.0", - "description": "A resource access indexer and batcher", - "main": "index.js", - "directories": { - "test": "test" - }, + "description": "A data access request manager", + "main": "src/index.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "npm run test:unit && npm run test:integration", + "test:unit": "mocha ./tests/unit --exit", + "test:integration": "mocha ./tests/integration --exit" }, - "author": "", - "license": "ISC" + "author": "frederic charette", + "license": "Apache 2.0", + "devDependencies": { + "chai": "^4.1.2", + "mocha": "^5.1.1", + "sinon": "^5.0.3" + } } diff --git a/src/index.js b/src/index.js index 295c563..2c00504 100644 --- a/src/index.js +++ b/src/index.js @@ -4,7 +4,7 @@ /* Requires ------------------------------------------------------------------*/ -const abatch = require('./batch'); +const abatch = require('./queue'); const { requiredParam } = require('./utils'); const EventEmitter = require('events').EventEmitter; diff --git a/src/queue.js b/src/queue.js index 091d305..a692755 100644 --- a/src/queue.js +++ b/src/queue.js @@ -128,7 +128,7 @@ function queue(config, emitter) { return `${context}::${id}`; } - return { add, skip, store }; + return { add, skip, store, complete, contextKey, retry, query }; } module.exports = batcher; diff --git a/src/store.js b/src/store.js index 4346137..b455909 100644 --- a/src/store.js +++ b/src/store.js @@ -8,9 +8,10 @@ * Store constructor * @param {object} config The options for the store * @param {EventEmitter} emitter The event-emitter instance for the batcher + * @param {Map} store A store instance to replace the default in-memory Map */ -function localStore(config, emitter) { - const store = new Map(); +function localStore(config, emitter, store) { + store = store || new Map(); /** * Performs a query that returns a single entities to be cached @@ -60,26 +61,30 @@ function localStore(config, emitter) { return store.delete(key); } + /** + * Attempts to invalidate a key once it's cache step time expires + * @param {string} key The key to be evaluated for invalidation + */ function lru(key) { const record = store.get(key); if (record) { if (record.value && record.timer) { const now = Date.now(); if (now + config.cache.step <= record.timestamp + config.cache.ttl && record.bump === true) { - emitter.emit('bumpCache', { key, timestamp: record.timestamp, expires: now + config.cache.step }); + emitter.emit('cacheBump', { key, timestamp: record.timestamp, expires: now + config.cache.step }); clearTimeout(record.timer); value.timer = setTimeout(() => clear(key), config.cache.step); record.bump = false; } else { - emitter.emit('clearCache', { key, timestamp: record.timestamp, expires: now }); + emitter.emit('cacheClear', { key, timestamp: record.timestamp, expires: now }); clear(key); } } } } - return { get, set, has, clear }; + return { get, set, has, clear, lru }; } /* Exports -------------------------------------------------------------------*/ diff --git a/test/unit/batcher.spec.js b/test/unit/batcher.spec.js deleted file mode 100644 index e092520..0000000 --- a/test/unit/batcher.spec.js +++ /dev/null @@ -1,71 +0,0 @@ -const batcher = require('../../../../server/helpers/media-batcher'); - -const { expect } = require('chai'); -const sinon = require('sinon'); - -describe('helper/media-batcher', () => { - const testElement = { - id: 123, - status: 'approved', - }; - - const tick = 10; - - describe('add', () => { - let storeStub; - let batcherInstance; - - beforeEach(() => { - storeStub = { - get: sinon.stub(), - search: sinon.stub(), - has: sinon.stub(), - storageKey: id => `${id}`, - }; - - batcherInstance = batcher(storeStub, 'getOne', { tick }); - storeStub.search.returns(Promise.resolve({ [testElement.id]: testElement })); - }); - - afterEach(() => { - storeStub.get = null; - storeStub.has = null; - storeStub.search = null; - - batcherInstance = null; - }); - - it('should be a method', () => { - expect(batcherInstance.add).to.be.a('function'); - }); - - it('should return a list of promises equal to the number of ids provided', () => { - expect(batcherInstance.add([123, 456, 789])).to.have.length(3); - }); - - it('should queue all requested ids with the requested options', (done) => { - storeStub.has.returns(false); - batcherInstance.add([123, 456, 789], { foo: 'bar' }); - - expect(storeStub.search.called).to.be.false; - - setTimeout(() => { - expect(storeStub.search.calledWith({ ids: [123, 456, 789], foo: 'bar' }, 'getOne')).to.be.true; - done(); - }, tick + 1); - }); - - it('should resolve items already in the store', (done) => { - storeStub.has.returns(true); - storeStub.get.returns(Promise.resolve(testElement)); - const add = batcherInstance.add([123]); - - expect(storeStub.search.notCalled).to.be.true; - - setTimeout(() => { - expect(storeStub.search.notCalled).to.be.true; - expect(Promise.all(add)).to.eventually.deep.equal([testElement]).notify(done); - }, tick + 1); - }); - }); -}); diff --git a/test/unit/store.spec.js b/test/unit/store.spec.js deleted file mode 100644 index 5463686..0000000 --- a/test/unit/store.spec.js +++ /dev/null @@ -1,216 +0,0 @@ -const astore = require('../src'); -const expect = require('chai').expect; - -const testDuration = 100; - -const testDao = { - getOne: (opts) => { - return new Promise((resolve, reject) => { - setTimeout(() => { - resolve({ - id: opts.id, - stuff: 'yes', - }) - }, testDuration); - }); - }, - search: (opts) => { - return new Promise((resolve, reject) => { - setTimeout(() => { - resolve([ - { - id: 123, - stuff: 'yes', - }, - { - id: 456, - stuff: 'yes', - }, - ]) - }, testDuration); - }); - }, - searchIndexed: (opts) => { - return new Promise((resolve, reject) => { - setTimeout(() => { - resolve({ - '123': { - id: 123, - stuff: 'yes', - }, - '456': { - id: 456, - stuff: 'yes', - }, - }) - }, testDuration); - }); - }, - searchIndexedNull: (opts) => { - return new Promise((resolve, reject) => { - setTimeout(() => { - resolve({ - '123': { - id: 123, - stuff: 'yes', - }, - '456': null, - }) - }, testDuration); - }); - }, -}; - -describe('Smoke test, single', () => { - - let testStore; - beforeEach(() => { - testStore = astore(testDao); - }); - - it('should cache entities', (done) => { - let now = Date.now(); - testStore.get({ id: 123 }, 'getOne') - .then(() => { - expect((Date.now() - now)).to.be.at.least(testDuration - 1); - now = Date.now(); - }) - .then(testStore.get.bind(null, { id: 123 }, 'getOne')) - .then(() => { - expect((Date.now() - now)).to.be.at.most(1); - now = Date.now(); - done(); - }); - }); -}); - -describe('Smoke test, single - disabled', () => { - - let testStore; - beforeEach(() => { - testStore = astore(testDao, { enabled: false }); - }); - - it('should make a direct calls', (done) => { - let now = Date.now(); - testStore.get({ id: 123 }, 'getOne') - .then(() => { - expect((Date.now() - now)).to.be.at.least(testDuration - 1); - now = Date.now(); - }) - .then(testStore.get.bind(null, { id: 123 }, 'getOne')) - .then(() => { - expect((Date.now() - now)).to.be.at.least(testDuration - 1); - now = Date.now(); - done(); - }); - }); -}); - -describe('Smoke test, list', () => { - - let testStore; - beforeEach(() => { - testStore = astore(testDao); - }); - - it('should cache entities individually', (done) => { - let now = Date.now(); - testStore.getList({ ids: [123, 456] }, 'getOne') - .then(() => { - expect((Date.now() - now)).to.be.at.least(testDuration - 1); - now = Date.now(); - }) - .then(testStore.getList.bind(null, { ids: [123, 456] }, 'getOne')) - .then(() => { - expect((Date.now() - now)).to.be.at.most(1); - now = Date.now(); - done(); - }); - }); -}); - - -describe('Smoke test, search', () => { - - let testStore; - beforeEach(() => { - testStore = astore(testDao); - }); - - it('should cache entities', (done) => { - let now = Date.now(); - testStore.search({ }, 'search') - .then(() => { - expect((Date.now() - now)).to.be.at.least(testDuration - 1); - now = Date.now(); - }) - .then(testStore.get.bind(null, { id: 123 }, 'getOne')) - .then(() => { - expect((Date.now() - now)).to.be.at.most(1); - now = Date.now(); - done(); - }); - }); -}); - -describe('Smoke test, search indexed', () => { - - let testStore; - beforeEach(() => { - testStore = astore(testDao); - }); - - it('should cache entities', (done) => { - let now = Date.now(); - testStore.search({ }, 'searchIndexed') - .then(() => { - expect((Date.now() - now)).to.be.at.least(testDuration - 1); - now = Date.now(); - }) - .then(testStore.get.bind(null, { id: 123 }, 'getOne')) - .then(() => { - expect((Date.now() - now)).to.be.at.most(1); - now = Date.now(); - done(); - }); - }); - - it('should handle null entities', (done) => { - let now = Date.now(); - testStore.search({ }, 'searchIndexedNull') - .then(() => { - expect((Date.now() - now)).to.be.at.least(testDuration - 1); - now = Date.now(); - }) - .then(testStore.get.bind(null, { id: 123 }, 'getOne')) - .then(() => { - expect((Date.now() - now)).to.be.at.most(1); - now = Date.now(); - done(); - }); - }); -}); - -describe('Smoke test, direct', () => { - - let testStore; - beforeEach(() => { - testStore = astore(testDao); - }); - - it('should cache entities', (done) => { - let now = Date.now(); - testStore.direct({ id: 123 }, 'getOne') - .then(() => { - expect((Date.now() - now)).to.be.at.least(testDuration - 1); - now = Date.now(); - }) - .then(testStore.direct.bind(null, { id: 123 }, 'getOne')) - .then(() => { - expect((Date.now() - now)).to.be.at.least(testDuration - 1); - now = Date.now(); - done(); - }); - }); -}); \ No newline at end of file diff --git a/test/integration/dao.js b/tests/integration/dao.js similarity index 100% rename from test/integration/dao.js rename to tests/integration/dao.js diff --git a/test/integration/index.js b/tests/integration/index.js similarity index 92% rename from test/integration/index.js rename to tests/integration/index.js index 7b8d637..11b2859 100644 --- a/test/integration/index.js +++ b/tests/integration/index.js @@ -2,13 +2,14 @@ const expect = require('chai').expect; const dao = require('./dao'); const store = require('../../src'); - +// Variables: +// Cache (true/false) +// Batch (true/false) +// Retry (true/false) describe('Rest-store', () => { describe('Happy responses', () => { let testStore; afterEach(() => testStore = null); - context('Cache: true', () => {}) - context('') beforeEach(() => { testStore = store({ getter: { @@ -16,8 +17,6 @@ describe('Rest-store', () => { } }); }); - - }); describe('Empty responses', () => { diff --git a/tests/unit/queue.spec.js b/tests/unit/queue.spec.js new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/store.spec.js b/tests/unit/store.spec.js new file mode 100644 index 0000000..fe21d0c --- /dev/null +++ b/tests/unit/store.spec.js @@ -0,0 +1,138 @@ +/** + * Store component unit tests + */ + +/* Requires ------------------------------------------------------------------*/ + +const store = require('../../src/store'); +const EventEmitter = require('events').EventEmitter; +const expect = require('chai').expect; +const sinon = require('sinon'); + +/* Local variables -----------------------------------------------------------*/ + +const testDuration = 100; +const config = { + cache: { + step: 10, + ttl: 100, + }, +}; + +/* Tests ---------------------------------------------------------------------*/ + +describe('store', () => { + + let testStore; + let mapStub; + let emitterStub; + + describe('#get', () => { + beforeEach(() => { + mapStub = sinon.stub(new Map()); + emitterStub = sinon.stub(new EventEmitter()); + testStore = store(config, emitterStub, mapStub); + + const valueRecord = { value: 'foo' }; + const promiseRecord = { promise: new Promise(()=>{}) }; + testStore.set('testValue', valueRecord); + testStore.set('testPromise', promiseRecord); + }); + + it('should return a value if there\'s a value saved', () => { + expect(testStore.get('testValue')).to.equal('foo'); + expect(mapStub.get).calledWith('testValue'); + expect(valueRecord.bump).to.be.true; + }); + + it('should return a promise if there\'s a promise saved', () => { + expect(testStore.get('testPromise')).to.be.instanceOf(Promise); + expect(mapStub.get).calledWith('testPromise'); + expect(promiseRecord.bump).to.not.be.true; + }); + + it('should return a null if the key is missing', () => { + expect(testStore.get('test')).to.be.null; + expect(mapStub.get).calledWith('test'); + }); + }); + + describe('#set', () => { + beforeEach(() => { + mapStub = sinon.stub(new Map()); + emitterStub = sinon.stub(new EventEmitter()); + testStore = store(config, emitterStub, mapStub); + }); + + it('should save to the store and not schedule lru', (done) => { + const lruMock = sinon.mock(testStore, 'lru'); + expect(testStore.set('testValue', { value: 'foo' })).to.equal({ value: 'foo' }); + setTimeout(() => { + expect(lruMock.neverCalled); + done(); + }, 11); + }); + + it('should save to the store and schedule lru when there\'s a ttl', (done) => { + const lruMock = sinon.mock(testStore, 'lru'); + expect(testStore.set('testLRUValue', { value: 'foo' }, { ttl: 10 })).to.equal({ value: 'foo' }); + setTimeout(() => { + expect(lruMock.calledWith('testLRUValue')); + done(); + }, 11); + }); + }); + + describe('#has', () => { + beforeEach(() => { + mapStub = sinon.stub(new Map()); + emitterStub = sinon.stub(new EventEmitter()); + testStore = store(config, emitterStub, mapStub); + + const valueRecord = { value: 'foo' }; + const promiseRecord = { promise: new Promise(()=>{}) }; + testStore.set('testValue', valueRecord); + testStore.set('testPromise', promiseRecord); + }); + + it('should return true if there\'s a value saved', () => { + expect(testStore.has('testValue')).to.be.true; + expect(mapStub.has).calledWith('testValue'); + }); + + it('should return true if there\'s a promise saved', () => { + expect(testStore.has('testPromise')).to.be.true; + expect(mapStub.has).calledWith('testPromise'); + }); + + it('should return false if the key is missing', () => { + expect(testStore.has('test')).to.be.false; + expect(mapStub.has).calledWith('test'); + }); + }); + + describe('#clear', () => { + beforeEach(() => { + mapStub = sinon.stub(new Map()); + emitterStub = sinon.stub(new EventEmitter()); + testStore = store(config, emitterStub, mapStub); + + const promiseRecord = { promise: new Promise(()=>{}) }; + testStore.set('testPromise', promiseRecord); + }); + + it('should clear the record and return true if there\'s a promise saved', () => { + expect(testStore.clear('testPromise')).to.be.true; + expect(mapStub.clear).calledWith('testPromise'); + }); + + it('should do nothing and return false if the key is missing', () => { + expect(testStore.clear('testPromise')).to.be.false; + expect(mapStub.clear).calledWith('testPromise'); + }); + }); + + describe('#lru', () => { + + }); +});