├── .npmignore ├── .eslintignore ├── .gitignore ├── .eslintrc ├── .babelrc ├── src ├── index-class.js ├── collection.js └── index.js ├── package.json ├── .travis.yml ├── README.md └── test.js /.npmignore: -------------------------------------------------------------------------------- 1 | .travis.yml 2 | notes.md 3 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | *.md 2 | node_modules 3 | lib 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | lib 4 | notes.md 5 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "eslint-config-next", 3 | "env": { 4 | "mocha": true 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "es2015" 4 | ], 5 | "plugins": [ 6 | "transform-async-to-generator" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /src/index-class.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import fnName from 'fn-name'; 4 | import isPlainObject from 'lodash/isPlainObject'; 5 | 6 | export class Index { 7 | constructor(options = {}) { 8 | if (!isPlainObject(options)) options = { properties: options }; 9 | this.properties = this.normalizeProperties(options.properties); 10 | if (options.projection != null) this.projection = options.projection; 11 | if (options.version != null) this.version = options.version; 12 | } 13 | 14 | get keys() { 15 | return this.properties.map(property => property.key); 16 | } 17 | 18 | normalizeProperties(properties) { 19 | if (!Array.isArray(properties)) properties = [properties]; 20 | properties = properties.map(property => { 21 | if (typeof property === 'string') { // simple index 22 | return { key: property, value: true }; 23 | } else if (typeof property === 'function') { // computed index 24 | let key = fnName(property); 25 | if (!key) throw new Error('Invalid index definition: computed index function cannot be anonymous. Use a named function or set the displayName function property.'); 26 | return { key, value: property }; 27 | } else { 28 | throw new Error('Invalid index definition'); 29 | } 30 | }); 31 | return properties; 32 | } 33 | 34 | toJSON() { 35 | let json = { keys: this.keys }; 36 | if (this.projection != null) json.projection = this.projection; 37 | if (this.version != null) json.version = this.version; 38 | return json; 39 | } 40 | } 41 | 42 | export default Index; 43 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "document-store", 3 | "version": "0.9.4", 4 | "description": "Document store with transactions on top of any database", 5 | "author": "Manuel Vila ", 6 | "license": "MIT", 7 | "main": "lib/index.js", 8 | "repository": { 9 | "type": "git", 10 | "url": "git://github.com/object-layer/document-store" 11 | }, 12 | "scripts": { 13 | "compile": "babel src --out-dir lib", 14 | "prepublish": "npm run compile", 15 | "release": "npm run lint && npm run compile && npm test && (git checkout --quiet master && test -z \"$(git status --porcelain)\" && npm version $npm_config_release_type && git push --follow-tags) || echo \"An error occurred (uncommitted changes?)\"", 16 | "release-patch": "npm run release --release-type=patch", 17 | "release-minor": "npm run release --release-type=minor", 18 | "release-major": "npm run release --release-type=major", 19 | "lint": "eslint .", 20 | "test": "mocha --compilers js:babel-register,js:babel-polyfill" 21 | }, 22 | "dependencies": { 23 | "event-emitter-mixin": "^0.3.4", 24 | "fn-name": "^2.0.1", 25 | "key-value-store": "^0.2.1", 26 | "lodash": "^4.6.1", 27 | "set-immediate-promise": "^1.0.6", 28 | "sleep-promise": "^1.0.0" 29 | }, 30 | "devDependencies": { 31 | "babel-cli": "^6.6.5", 32 | "babel-eslint": "^6.0.0", 33 | "babel-plugin-transform-async-to-generator": "^6.7.4", 34 | "babel-polyfill": "^6.7.4", 35 | "babel-preset-es2015": "^6.6.0", 36 | "babel-register": "^6.7.2", 37 | "chai": "^3.5.0", 38 | "eslint": "^2.5.3", 39 | "eslint-config-next": "^0.1.12", 40 | "make-sort-key": "^1.0.3", 41 | "mocha": "^2.4.5", 42 | "universal-log": "^0.3.8" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '0.12' 4 | branches: 5 | only: 6 | - master 7 | services: 8 | - mysql 9 | before_script: 10 | - mysql --user=root --execute='CREATE DATABASE IF NOT EXISTS test;' 11 | - mysql --user=root --execute='GRANT ALL ON test.* TO test@localhost;' 12 | cache: 13 | directories: 14 | - node_modules 15 | deploy: 16 | provider: npm 17 | email: mvila@3base.com 18 | api_key: 19 | secure: LBAIDl9lkef6waBq53qiNubne5BZfxDfJl+XfcmPtjjbkEc+aIjuJl9T1HyC/ru1FRu/UB3h3pKn9fQalni5n1VCU/7SFsNNDg3dbw2hVO2I08jNhNepMw7WYlPNYs3t3Foei6nldt2CsFLfrYsHUd4gwxpZJzscRm09jZr8WZczpl61vdYeuYZXUXRozCmAdlZcDLE7csIR+3W4pu0+4wUGwb71WpTALPxTHwYK7CYa9WjjIQe0ww6cedUBlGBSeVQBHOYcvPh0iZYoGcjyu9R1tACMZmDDWor0XNozB+WEcSGGPBzDFjHdu22MGVQN+86ssvNc4E9Xam2NmoMGSTA4rUIcWT+m3a7Ttr6MdWi9iai95PszG44xFcU4WRAEsHIfzRKxZ8a84CIWsJxZ4GK6nO0wg5Y6pIJRdeoRWOZR+e4LqH/6g2GROPdgnmWyJq5WuBT6hilEOnNTynOWDEX0cIGtImUYKGDD0CJs1lSctw2cPW+rHiUYGTDH4xoPT6V+CURmVgKzBU50xyD91tDQQGuogAdhdQg9kLeXV/ZqkcPCweUbKtyyqjHPfcdRTLIN4PbKDDMZy/WgOhklpiGUoAtllWiRL2Gcyy2gouaqZgmlV/TYcD6lvsBDUVc03u2JFqveBLZNuAh4GXrs0E82UmUMVJnPMp6stz7OIbk= 20 | skip_cleanup: true 21 | notifications: 22 | email: false 23 | slack: 24 | secure: LaedWC1igLr5yHoPdwG7sP/HO1b9qQL6U1Az0GRYMNRyrzg5NUrxJuUs0vZcFoUQXVhA3b3YmeL1yA8za5VGYwRXhpcrT0iAHzfNWZMRHn/d/pWwCzcd+FIit5/8CDS1bVgzR0IaSLwpDf6GNPXJE+KadhxurhQPgK2mty3gZ/PfIGIY5IaStuJkxxQpqK/QvZaJKLHGwiOvA50OTcEnFFpsOqVqFoD3NgGQvX78u4s1GY9274lzro84LFgc1u8CChvmxOjBgRiJGs3G3BYU6wRFxHQ9m1HDo6Yk53wvmAdBtYrrE6fFRC94KFpaV7xn3JNANdiYbIFNgVcPwUv3gaSVXvvFy/7R0YxHQAJt/f42Or+rvDvHqMP/zamaQl4B7eU46SQbllL8jce217n7l0v46SEwmvFbe2MzHjeRcTcjEBAJ6bEb52AQq/Wpc8KBM3Of+U55tRiCy8i+k30Q2P8u+j9UQAPQh3GQOx1+nD5+qDNtVbFGkgUjYVHcOQcQ7/SKfPzoe8gWZeJJyy1H6Q2RQZ5ItH5kSWe9vdZzQLip57sdrxL0EiOTmJb2X2PZU8PzWhGF7jMjA8S/ExIKP3i5wKS+pnRuC8Jbq8KCvYJ0p1tRNE40MOs7XKGCfF2tABymOFmDtIX/xId4h8W22OnksFjSiUS4CsCvpBDhBZM= 25 | sudo: false 26 | -------------------------------------------------------------------------------- /src/collection.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import difference from 'lodash/difference'; 4 | import isEqual from 'lodash/isEqual'; 5 | import Index from './index-class'; 6 | 7 | export class Collection { 8 | constructor(options = {}) { 9 | if (typeof options === 'string') options = { name: options }; 10 | if (!options.name) throw new Error('Collection name is missing'); 11 | this.name = options.name; 12 | this.indexes = []; 13 | let indexes = options.indexes || []; 14 | indexes.forEach(index => { 15 | this.addIndex(index); 16 | }); 17 | } 18 | 19 | addIndex(options = {}) { 20 | let index = new Index(options); 21 | if (this.findIndexIndex(index.keys) !== -1) { 22 | throw new Error('An index with the same keys already exists'); 23 | } 24 | this.indexes.push(index); 25 | } 26 | 27 | findIndex(keys) { 28 | keys = this.normalizeKeys(keys); 29 | let i = this.findIndexIndex(keys); 30 | if (i === -1) throw new Error('Index not found'); 31 | return this.indexes[i]; 32 | } 33 | 34 | findIndexForQueryAndOrder(query, order) { 35 | if (!query) query = {}; 36 | if (!order) order = []; 37 | order = this.normalizeKeys(order); 38 | let queryKeys = Object.keys(query); 39 | let orderKeys = order; 40 | let indexes = this.indexes; 41 | if (queryKeys.length) { 42 | indexes = indexes.filter(idx => { 43 | let keys = idx.keys.slice(0, queryKeys.length); 44 | return difference(queryKeys, keys).length === 0; 45 | }); 46 | } 47 | let index = indexes.find(idx => { 48 | let keys = idx.keys.slice(queryKeys.length); 49 | return isEqual(keys, orderKeys); 50 | }); 51 | if (!index) { 52 | throw new Error(`Index not found (query=${JSON.stringify(query)}, order=${JSON.stringify(order)})`); 53 | } 54 | return index; 55 | } 56 | 57 | findIndexIndex(keys) { 58 | keys = this.normalizeKeys(keys); 59 | return this.indexes.findIndex(index => { 60 | return isEqual(index.keys, keys); 61 | }); 62 | } 63 | 64 | normalizeKeys(keys) { 65 | if (!Array.isArray(keys)) keys = [keys]; 66 | return keys; 67 | } 68 | 69 | normalizeIndex(indexOrKeys) { 70 | if (typeof indexOrKeys === 'string' || Array.isArray(indexOrKeys)) { 71 | return this.findIndex(indexOrKeys); 72 | } else { 73 | return indexOrKeys; 74 | } 75 | } 76 | 77 | toJSON() { 78 | return { 79 | name: this.name, 80 | indexes: this.indexes.map(index => index.toJSON()) 81 | }; 82 | } 83 | } 84 | 85 | export default Collection; 86 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DocumentStore [![Build Status](https://travis-ci.org/object-layer/document-store.svg?branch=master)](https://travis-ci.org/object-layer/document-store) 2 | 3 | Document store with transactions on top of any database. 4 | 5 | ### Why this module? 6 | 7 | Document stores offer a very good developer experience. Take MongoDB for example, the API is straightforward, the data structure is quite flexible and the amount of storage can scale horizontally in a rather good way. 8 | 9 | So, what's wrong? 10 | 11 | First, I don't know how you guys are doing but the lack of transactions is a big concern for me. There are many cases where we have objects with their own identity (therefore not aggregatable) and strong connections between them. To sleep well at night I have to be sure about the integrity of my data. 12 | 13 | Second, I have a little problem with commitment. Choosing a database is not a small matter. If I go on MongoDB today and I want to switch to something else in the future, the transition could be painful. The smartest choices are those that lock us in as little as possible. When I select something as important as a database, I want to choose a set of features and an API but not a particular implementation. 14 | 15 | That's why I created this module which is nothing but a layer on top of [KeyValueStore](https://www.npmjs.com/package/key-value-store), a simple module abstracting any kind of transactional key-value store. 16 | 17 | ### Features 18 | 19 | - Simple and beautiful API. 20 | - Secondary indexes (simple, compound and computed). 21 | - Projections for blazing fast queries. 22 | - Automatic migrations. 23 | - Easy ACID transactions with implicit begin/commit/rollback. 24 | - ES7 `async`/`await` ready. 25 | - Works in Node and browser. 26 | 27 | ### Supported databases 28 | 29 | Since this module is built on top of [KeyValueStore](https://www.npmjs.com/package/key-value-store), the supported databases are: 30 | 31 | - Every databases supported by [AnySQL](https://www.npmjs.com/package/anysql). 32 | - More to come... 33 | 34 | ## Installation 35 | 36 | ``` 37 | npm install --save document-store 38 | ``` 39 | 40 | ## Usage 41 | 42 | ### Simple operations 43 | 44 | ```javascript 45 | import DocumentStore from 'document-store'; 46 | 47 | let store = new DocumentStore({ 48 | name: 'MyCoolProject', 49 | url: 'mysql://test@localhost/test', 50 | collections: ['People'] 51 | }); 52 | 53 | async function simple() { 54 | // Create 55 | await store.put('People', 'abc123', { name: 'John', age: 42 }); 56 | 57 | // Read 58 | let person = await store.get('People', 'abc123'); 59 | 60 | // Update 61 | person.age++; 62 | await store.put('People', 'abc123', person); 63 | 64 | // Delete 65 | await store.delete('People', 'abc123'); 66 | } 67 | ``` 68 | 69 | ### Indexes and queries 70 | 71 | ```javascript 72 | import DocumentStore from 'document-store'; 73 | 74 | let store = new DocumentStore({ 75 | name: 'MyCoolProject', 76 | url: 'mysql://test@localhost/test', 77 | collections: [{ 78 | name: 'People', 79 | indexes: ['name', 'age'] 80 | }] 81 | }); 82 | 83 | async function query() { 84 | // Find all John older than 40 85 | let people = await store.find('People', { 86 | query: { name: 'John' }, 87 | order: ['age'], 88 | startAfter: 40 89 | }); 90 | } 91 | ``` 92 | 93 | ### Transactions 94 | 95 | ```javascript 96 | import DocumentStore from 'document-store'; 97 | 98 | let store = new DocumentStore({ 99 | name: 'MyCoolProject', 100 | url: 'mysql://test@localhost/test', 101 | collections: ['People'] 102 | }); 103 | 104 | async function criticalOperation() { 105 | await store.transaction(async function(transaction) { 106 | let person = await transaction.get('People', 'abc123'); 107 | person.age++; 108 | await transaction.put('People', 'abc123', person); 109 | // ... 110 | // if no error has been thrown, the transaction is automatically committed 111 | }); 112 | } 113 | ``` 114 | 115 | ## Basic concepts 116 | 117 | ### Collections, documents and keys 118 | 119 | Collections are useful to group documents of the same kind but there is no predefined schema. 120 | 121 | Every document has a unique key which can be either a string or a number. 122 | 123 | A document is nothing more than a JavaScript object serializable by `JSON.stringify`. To customize the serialization, you may want to implement the `toJSON()` method on your documents. 124 | 125 | ### Promise based API 126 | 127 | Every asynchronous operation returns a promise. You can handle them as is but I think it is a lot better to consume them with the fantastic ES7 `async`/`await` feature. Since ES7 is not really there yet, you should compile your code with something like [Babel](https://babeljs.io/). 128 | 129 | ## API 130 | 131 | ### `new DocumentStore(options)` 132 | 133 | Create a document store. 134 | 135 | ```javascript 136 | import DocumentStore from 'document-store'; 137 | 138 | let store = new DocumentStore( 139 | name: 'MyCoolProject', 140 | url: 'mysql://test@localhost/test', 141 | collections: ['People'] 142 | ); 143 | ``` 144 | 145 | #### `options` 146 | 147 | - `name`: the name of the document store to create. 148 | - `url`: the URL where your data is stored. Internally, a [KeyValueStore](https://www.npmjs.com/package/key-value-store) is created with that same URL targeting the actual data storage backend. 149 | - `collections`: an array of collection definitions. A collection definition can be either a string or an object. In case of a string, it is simply the name of the collection. In case of an object, the properties are: 150 | - `name`: the name of the collection. 151 | - `indexes` _(optional)_: an array of index definitions. An index definition is an object with the following attributes: 152 | - `properties`: an array of properties from which the index is created. A property can be either a string or a function. In case of a string, it is a _path_ to a property in the indexed documents. A path can be a simple key (e.g. `'country'`) or a nesting of keys (e.g. `'postalAddress.country'`). Finally, the indexed data can be computed from a function (see examples bellow). 153 | - `projection` _(optional)_: an array of document properties to project into the index. This option, in exchange for an increase of size of the indexes, significantly speeds up queries when the `find()` method is used with the `properties` option. 154 | - `version` _(optional)_: this option is useful in conjunction with computed properties. Since the migration engine cannot detect changes made inside functions, it is unable to automatically rebuild indexes when necessary. So, when you change the logic of a computed property, you can increment the `version` option to force the reindexing. 155 | - `log` _(optional)_: an instance of [UniversalLog](https://www.npmjs.com/package/universal-log) used by the document store when important events occur. 156 | 157 | #### Example of index definitions 158 | 159 | ```javascript 160 | let store = new DocumentStore({ 161 | name: 'MyCoolProject', 162 | url: 'mysql://test@localhost/test', 163 | collections: [ 164 | 'Countries', // no indexes 165 | { 166 | name: 'People', 167 | indexes: [ 168 | 'age', // simple index 169 | ['lastName', 'firstName'], // compound index 170 | { 171 | properties: [ 172 | function sortKey(doc) { // computed index 173 | return doc.lastName && doc.lastName.toLowerCase(); 174 | } 175 | ], 176 | version: 1 // to increment if the function changes 177 | }, 178 | { 179 | properties: ['createdOn'], 180 | projection: ['firstName', 'lastName', 'age'] // projection for fast queries 181 | } 182 | ] 183 | } 184 | ] 185 | }); 186 | ``` 187 | 188 | ### `store.get(collection, key, [options])` 189 | 190 | Get a document from the store. 191 | 192 | ```javascript 193 | let person = await store.get('People', 'abc123'); 194 | ``` 195 | 196 | #### `options` 197 | 198 | - `errorIfMissing` _(default: `true`)_: if `true`, an error is thrown when the specified `key` is missing from the store. If `false`, the method returns `undefined` when the `key` is missing. 199 | 200 | ### `store.put(collection, key, doc, [options])` 201 | 202 | Put a document in the store. 203 | 204 | ```javascript 205 | await store.put('People', 'abc123', { name: 'John', age: 42 }); 206 | ``` 207 | 208 | #### `options` 209 | 210 | - `createIfMissing` _(default: `true`)_: if `false`, an error is thrown when the specified `key` is missing from the store ("update" semantic). 211 | - `errorIfExists` _(default: `false`)_: if `true`, an error is thrown when the specified `key` is already present in the store ("create" semantic). 212 | 213 | ### `store.delete(collection, key, [options])` 214 | 215 | Delete a document from the store. 216 | 217 | ```javascript 218 | let hasBeenDeleted = await store.delete('People', 'abc123'); 219 | ``` 220 | 221 | #### `options` 222 | 223 | - `errorIfMissing` _(default: `true`)_: if `true`, an error is thrown when the specified `key` is missing from the store. If `false`, the method returns `false` in case the `key` is missing. 224 | 225 | ### `store.getMany(collection, keys, [options])` 226 | 227 | Get several document from the store. Return an array of objects with two properties: `key` and `document`. The order of the specified `keys` is preserved in the result. 228 | 229 | ```javascript 230 | let people = await store.getMany('People', ['abc123', 'def789', /* ... */]); 231 | ``` 232 | 233 | #### `options` 234 | 235 | - `errorIfMissing` _(default: `true`)_: if `true`, an error is thrown if one of the specified `keys` is missing from the store. 236 | 237 | ### `store.find(collection, [options])` 238 | 239 | Find documents matching the specified criteria. Return an array of objects with two properties: `key` and `document`. 240 | 241 | ```javascript 242 | // Find everyone 243 | let people = await store.find('People'); 244 | 245 | // Find people living in Tokyo 246 | let people = await store.find('People', { query: { city: 'Tokyo' } }); 247 | 248 | // Find all single females between 30 and 40 249 | let people = await store.find('People', { 250 | query: { gender: 'female', status: 'single' }, 251 | order: ['age'], 252 | start: 30, 253 | end: 40 254 | }); 255 | ``` 256 | 257 | #### `options` 258 | 259 | - `query`: an object of key-value pairs corresponding to the search criteria. 260 | - `order`: an array of property names specifying the sort order. When no `order` is specified, the returned items are sorted by key. 261 | - `start`, `startAfter`: when you specify the `order` option, you can restrict the returned items to those greater (or equal) the specified values. When no `order` is specified, you can use the `start` and `startAfter` options to fetch only the items starting with a certain `key`. Finally, since the items are always sorted by `order` and then by `key`, you can specify both at the same time (e.g. `['Tokyo', 'abc123']`). 262 | - `end`, `endBefore`: similar to `start`, `startAfter` but for the less than (or equal) condition. 263 | - `reverse` _(default: `false`)_: if `true`, reverse the order of returned items. 264 | - `limit` _(default: `50000`)_: limit the number of returned items to the specified value. 265 | - `properties` _(default: `'*'`)_: an array of property names or the `'*'` string. If `'*'` is specified (the default), all document properties are fetched. Otherwise, only the specified properties are fetched. Used in conjunction with a `projection`, you can significantly speed up queries. 266 | 267 | Note: the property names specified in the `query` and `order` options should match an existing index, otherwise the method will throw an error. For example, if you have `{ gender: 'female', status: 'single' }` as `query` and `['age']` as `order`, you should have a compound index with `['female', 'status', 'age']` properties in your collection. 268 | 269 | ### `store.count(collection, [options])` 270 | 271 | Count the number of documents matching the specified criteria. 272 | 273 | ```javascript 274 | let peopleCount = await store.count('People', { 275 | query: { city: 'Tokyo', country: 'Japan' } 276 | }); 277 | ``` 278 | 279 | #### `options` 280 | 281 | Same options as the `find()` method (excepted `reverse` and `properties` which are useless in the context of a count). 282 | 283 | ### `store.findAndDelete(collection, [options])` 284 | 285 | Delete documents matching the specified criteria. Return the number of deleted documents. 286 | 287 | ```javascript 288 | let deletedDocsCount = await store.findAndDelete('People', { 289 | query: { country: 'France' } 290 | }); 291 | ``` 292 | 293 | #### `options` 294 | 295 | Same options as the `find()` method (excepted the `properties` option which is useless in the context of a deletion). 296 | 297 | ### `store.forEach(collection, options, fn, [thisArg])` 298 | 299 | Run a function for each document matching the specified criteria. The function is called with `thisArg` as `this` context and receives two parameters: the document and the key. 300 | 301 | ```javascript 302 | await store.forEach( 303 | 'People', 304 | { query: { country: 'Japan' } }, 305 | function(person, key) { 306 | console.log(person.name); 307 | } 308 | ); 309 | ``` 310 | 311 | #### `options` 312 | 313 | Same options as the `find()` method with the addition of: 314 | 315 | - `batchSize` _(default: `250`)_: maximum number of documents to fetch at the same time. Internally, the `find()` method is used to fetch the documents and the `batchSize` option is used to limit the number of documents fetched by each `find()` call. 316 | 317 | ### `store.transaction(fn)` 318 | 319 | Run the specified function inside a transaction. The function receives a transaction handler as its first argument. This handler should be used as a replacement of the document store for every operation made during the execution of the transaction. If any error occurs, the transaction is aborted and the document store is automatically rolled back. 320 | 321 | ```javascript 322 | // Increment a counter 323 | await store.transaction(async function(transaction) { 324 | let counter = await transaction.get('Counters', 'abc123'); 325 | counter.value++; 326 | await transaction.put('Counters', 'abc123', counter); 327 | }); 328 | ``` 329 | 330 | ### `store.close()` 331 | 332 | Close all connections to the document store. 333 | 334 | ```javascript 335 | await store.close(); 336 | ``` 337 | 338 | ## Events 339 | 340 | The following events are emitted by the document store: 341 | 342 | - `'didCreate'`: the document store has been created for the first time. 343 | - `'didInitialize'`: the document store has been initialized. 344 | - `'willUpgrade'`/`'didUpgrade'`: the document store will/did perform an upgrade. 345 | - `'willMigrate'`/`'didMigrate'`: the document store will/did perform a migration. 346 | - `'willPut'`/`'didPut'`: a document will be/has been put in the document store. Listeners receive the following parameters: `collection`, `key`, `oldDocument`, `newDocument`, `options`. 347 | - `'willDelete'`/`'didDelete'`: a document will be/has been deleted from the document store. Listeners receive the following parameters: `collection`, `key`, `document`, `options`. 348 | 349 | The [EventEmitterMixin](https://www.npmjs.com/package/event-emitter-mixin) module is used to send the events. To define a listener, just call the `on()` method on the document store. By returning a promise (or using ES7 `async` keyword), listeners can be asynchronous. 350 | 351 | `'willPut'` and `'willDelete'` are emitted inside a transaction. If any listener throws an error, the document store is automatically rolled back. `'didPut'` and `'didDelete'` are emitted after the transaction has been committed. 352 | 353 | ### Example 354 | 355 | ```javascript 356 | store.on('willDelete', async function(collection, key, document, options) { 357 | if (collection === 'People') { 358 | let person = document; 359 | // Delete related documents 360 | for (let photoId of person.photoIds) { 361 | await this.delete('Photos', photoId); // 'this' is a transaction handler 362 | } 363 | } 364 | }); 365 | ``` 366 | 367 | ## To do 368 | 369 | - Collection renaming. 370 | - More tests and better documentation ([help wanted!](https://github.com/object-layer/document-store/issues/1)). 371 | 372 | ## License 373 | 374 | MIT 375 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { assert } from 'chai'; 4 | import makeSortKey from 'make-sort-key'; 5 | import UniversalLog from 'universal-log'; 6 | import DocumentStore from './src'; 7 | 8 | let log = new UniversalLog(); 9 | 10 | describe('DocumentStore', function() { 11 | describe('migrations', function() { 12 | it('should handle one empty collection', async function() { 13 | this.timeout(30000); 14 | 15 | let store, stats; 16 | try { 17 | store = new DocumentStore({ 18 | name: 'Test', 19 | url: 'mysql://test@localhost/test', 20 | collections: ['Collection1'], 21 | log 22 | }); 23 | 24 | stats = await store.getStatistics(); 25 | assert.strictEqual(stats.keyValueStore.pairsCount, 0); 26 | 27 | await store.initializeDocumentStore(); 28 | stats = await store.getStatistics(); 29 | assert.strictEqual(stats.keyValueStore.pairsCount, 1); 30 | } finally { 31 | if (store) { 32 | await store.destroyAll(); 33 | stats = await store.getStatistics(); 34 | assert.strictEqual(stats.keyValueStore.pairsCount, 0); 35 | } 36 | } 37 | }); 38 | 39 | it('should handle one document in a collection', async function() { 40 | let store, stats; 41 | try { 42 | store = new DocumentStore({ 43 | name: 'Test', 44 | url: 'mysql://test@localhost/test', 45 | collections: ['Collection1'], 46 | log 47 | }); 48 | await store.put('Collection1', 'aaa', { property1: 'value1' }); 49 | stats = await store.getStatistics(); 50 | assert.strictEqual(stats.keyValueStore.pairsCount, 2); 51 | } finally { 52 | if (store) { 53 | await store.destroyAll(); 54 | } 55 | } 56 | }); 57 | 58 | it('should handle one collection added afterwards then removed', async function() { 59 | let store, stats; 60 | try { 61 | store = new DocumentStore({ 62 | name: 'Test', 63 | url: 'mysql://test@localhost/test', 64 | collections: ['Collection1'], 65 | log 66 | }); 67 | await store.initializeDocumentStore(); 68 | stats = await store.getStatistics(); 69 | assert.strictEqual(stats.collectionsCount, 1); 70 | 71 | store = new DocumentStore({ 72 | name: 'Test', 73 | url: 'mysql://test@localhost/test', 74 | collections: ['Collection1', 'Collection2'], 75 | log 76 | }); 77 | await store.initializeDocumentStore(); 78 | stats = await store.getStatistics(); 79 | assert.strictEqual(stats.collectionsCount, 2); 80 | 81 | store = new DocumentStore({ 82 | name: 'Test', 83 | url: 'mysql://test@localhost/test', 84 | collections: ['Collection2'], 85 | log 86 | }); 87 | await store.initializeDocumentStore(); 88 | stats = await store.getStatistics(); 89 | assert.strictEqual(stats.collectionsCount, 1); 90 | assert.strictEqual(stats.removedCollectionsCount, 1); 91 | 92 | await store.removeCollectionsMarkedAsRemoved(); 93 | stats = await store.getStatistics(); 94 | assert.strictEqual(stats.collectionsCount, 1); 95 | assert.strictEqual(stats.removedCollectionsCount, 0); 96 | } finally { 97 | if (store) { 98 | await store.destroyAll(); 99 | } 100 | } 101 | }); 102 | 103 | it('should handle one index added afterwards then removed', async function() { 104 | let store, stats; 105 | try { 106 | store = new DocumentStore({ 107 | name: 'Test', 108 | url: 'mysql://test@localhost/test', 109 | collections: ['Collection1'], 110 | log 111 | }); 112 | await store.put('Collection1', 'aaa', { property1: 'value1' }); 113 | stats = await store.getStatistics(); 114 | assert.strictEqual(stats.indexesCount, 0); 115 | assert.strictEqual(stats.keyValueStore.pairsCount, 2); 116 | 117 | store = new DocumentStore({ 118 | name: 'Test', 119 | url: 'mysql://test@localhost/test', 120 | collections: [{ name: 'Collection1', indexes: ['property1'] }], 121 | log 122 | }); 123 | await store.initializeDocumentStore(); 124 | stats = await store.getStatistics(); 125 | assert.strictEqual(stats.indexesCount, 1); 126 | assert.strictEqual(stats.keyValueStore.pairsCount, 3); 127 | 128 | await store.put('Collection1', 'bbb', { property1: 'value2' }); 129 | stats = await store.getStatistics(); 130 | assert.strictEqual(stats.keyValueStore.pairsCount, 5); 131 | 132 | store = new DocumentStore({ 133 | name: 'Test', 134 | url: 'mysql://test@localhost/test', 135 | collections: ['Collection1'], 136 | log 137 | }); 138 | await store.initializeDocumentStore(); 139 | stats = await store.getStatistics(); 140 | assert.strictEqual(stats.indexesCount, 0); 141 | assert.strictEqual(stats.keyValueStore.pairsCount, 3); 142 | } finally { 143 | if (store) { 144 | await store.destroyAll(); 145 | } 146 | } 147 | }); 148 | 149 | it('should rebuild updated indexes', async function() { 150 | let store, stats, people; 151 | try { 152 | store = new DocumentStore({ 153 | name: 'Test', 154 | url: 'mysql://test@localhost/test', 155 | collections: [{ 156 | name: 'People', 157 | indexes: ['name'] 158 | }], 159 | log 160 | }); 161 | await store.put('People', 'aaa', { name: 'Manu' }); 162 | stats = await store.getStatistics(); 163 | assert.strictEqual(stats.indexesCount, 1); 164 | assert.strictEqual(stats.keyValueStore.pairsCount, 3); 165 | people = await store.find('People', { query: { name: 'Manu' } }); 166 | assert.deepEqual(people, [ { key: 'aaa', document: { name: 'Manu' } } ]); 167 | 168 | store = new DocumentStore({ 169 | name: 'Test', 170 | url: 'mysql://test@localhost/test', 171 | collections: [{ 172 | name: 'People', 173 | indexes: [{ 174 | properties: 'name', 175 | projection: ['name'] 176 | }] 177 | }], 178 | log 179 | }); 180 | await store.initializeDocumentStore(); 181 | stats = await store.getStatistics(); 182 | assert.strictEqual(stats.indexesCount, 1); 183 | assert.strictEqual(stats.keyValueStore.pairsCount, 3); 184 | people = await store.find('People', { query: { name: 'Manu' }, properties: ['name'] }); 185 | assert.deepEqual(people, [ { key: 'aaa', document: { name: 'Manu' } } ]); 186 | 187 | store = new DocumentStore({ 188 | name: 'Test', 189 | url: 'mysql://test@localhost/test', 190 | collections: [{ 191 | name: 'People', 192 | indexes: [{ 193 | properties: function name(doc) { 194 | return doc.name && doc.name.toLowerCase(); 195 | }, 196 | projection: ['name'], 197 | version: 2 198 | }] 199 | }], 200 | log 201 | }); 202 | await store.initializeDocumentStore(); 203 | stats = await store.getStatistics(); 204 | assert.strictEqual(stats.indexesCount, 1); 205 | assert.strictEqual(stats.keyValueStore.pairsCount, 3); 206 | people = await store.find('People', { query: { name: 'manu' }, properties: ['name'] }); 207 | assert.deepEqual(people, [ { key: 'aaa', document: { name: 'Manu' } } ]); 208 | } finally { 209 | if (store) { 210 | await store.destroyAll(); 211 | } 212 | } 213 | }); 214 | }); // migrations 215 | 216 | describe('simple document store', function() { 217 | let store; 218 | 219 | before(async function() { 220 | store = new DocumentStore({ 221 | name: 'Test', 222 | url: 'mysql://test@localhost/test', 223 | collections: [{ name: 'Users' }], 224 | log 225 | }); 226 | }); 227 | 228 | after(async function() { 229 | await store.destroyAll(); 230 | }); 231 | 232 | it('should have a collections definition', async function() { 233 | assert.strictEqual(store.collections.length, 1); 234 | 235 | let collection = store.collections[0]; 236 | assert.strictEqual(collection.name, 'Users'); 237 | assert.strictEqual(collection.indexes.length, 0); 238 | }); 239 | 240 | it('should put, get and delete some documents', async function() { 241 | await store.put('Users', 'mvila', { firstName: 'Manu', age: 42 }); 242 | let user = await store.get('Users', 'mvila'); 243 | assert.deepEqual(user, { firstName: 'Manu', age: 42 }); 244 | let hasBeenDeleted = await store.delete('Users', 'mvila'); 245 | assert.isTrue(hasBeenDeleted); 246 | user = await store.get('Users', 'mvila', { errorIfMissing: false }); 247 | assert.isUndefined(user); 248 | hasBeenDeleted = await store.delete('Users', 'mvila', { errorIfMissing: false }); 249 | assert.isFalse(hasBeenDeleted); 250 | }); 251 | }); // simple document store 252 | 253 | describe('rich document store', function() { 254 | let store; 255 | 256 | before(async function() { 257 | store = new DocumentStore({ 258 | name: 'Test', 259 | url: 'mysql://test@localhost/test', 260 | collections: [ 261 | { 262 | name: 'People', 263 | indexes: [ 264 | ['lastName', 'firstName'], 265 | 'age', 266 | ['country', 'city'], 267 | { 268 | properties: 'country', 269 | projection: ['firstName', 'lastName'] 270 | }, 271 | function fullNameSortKey(doc) { 272 | return makeSortKey(doc.lastName, doc.firstName); 273 | }, 274 | 'tags' 275 | ] 276 | } 277 | ], 278 | log 279 | }); 280 | }); 281 | 282 | after(async function() { 283 | await store.destroyAll(); 284 | }); 285 | 286 | beforeEach(async function() { 287 | await store.put('People', 'aaa', { 288 | firstName: 'Manuel', lastName: 'Vila', 289 | age: 42, city: 'Paris', country: 'France', 290 | tags: ['cool', 'guy'] 291 | }); 292 | await store.put('People', 'bbb', { 293 | firstName: 'Jack', lastName: 'Daniel', 294 | age: 60, city: 'New York', country: 'USA', 295 | tags: ['drunk', 'guy'] 296 | }); 297 | await store.put('People', 'ccc', { 298 | firstName: 'Bob', lastName: 'Cracker', 299 | age: 20, city: 'Los Angeles', country: 'USA', 300 | tags: ['cool', 'guy'] 301 | }); 302 | await store.put('People', 'ddd', { 303 | firstName: 'Vincent', lastName: 'Vila', 304 | age: 43, city: 'Céret', country: 'France' 305 | }); 306 | await store.put('People', 'eee', { 307 | firstName: 'Pierre', lastName: 'Dupont', 308 | age: 39, city: 'Lyon', country: 'France' 309 | }); 310 | await store.put('People', 'fff', { 311 | firstName: 'Jacques', lastName: 'Fleur', 312 | age: 39, city: 'San Francisco', country: 'USA' 313 | }); 314 | }); 315 | 316 | afterEach(async function() { 317 | await store.delete('People', 'aaa', { errorIfMissing: false }); 318 | await store.delete('People', 'bbb', { errorIfMissing: false }); 319 | await store.delete('People', 'ccc', { errorIfMissing: false }); 320 | await store.delete('People', 'ddd', { errorIfMissing: false }); 321 | await store.delete('People', 'eee', { errorIfMissing: false }); 322 | await store.delete('People', 'fff', { errorIfMissing: false }); 323 | }); 324 | 325 | it('should have a collections definition', async function() { 326 | assert.strictEqual(store.collections.length, 1); 327 | let collection = store.collections[0]; 328 | assert.strictEqual(collection.name, 'People'); 329 | assert.strictEqual(collection.indexes.length, 6); 330 | assert.strictEqual(collection.indexes[0].properties.length, 2); 331 | assert.strictEqual(collection.indexes[0].properties[0].key, 'lastName'); 332 | }); 333 | 334 | it('should get many documents', async function() { 335 | let docs = await store.getMany('People', ['aaa', 'ccc']); 336 | assert.strictEqual(docs.length, 2); 337 | assert.strictEqual(docs[0].key, 'aaa'); 338 | assert.strictEqual(docs[0].document.firstName, 'Manuel'); 339 | assert.strictEqual(docs[1].key, 'ccc'); 340 | assert.strictEqual(docs[1].document.firstName, 'Bob'); 341 | }); 342 | 343 | it('should find all documents in a collection', async function() { 344 | let docs = await store.find('People'); 345 | assert.strictEqual(docs.length, 6); 346 | assert.strictEqual(docs[0].key, 'aaa'); 347 | assert.strictEqual(docs[0].document.firstName, 'Manuel'); 348 | assert.strictEqual(docs[5].key, 'fff'); 349 | assert.strictEqual(docs[5].document.firstName, 'Jacques'); 350 | }); 351 | 352 | it('should find and order documents', async function() { 353 | let docs = await store.find('People', { order: 'age' }); 354 | assert.strictEqual(docs.length, 6); 355 | assert.strictEqual(docs[0].key, 'ccc'); 356 | assert.strictEqual(docs[0].document.age, 20); 357 | assert.strictEqual(docs[5].key, 'bbb'); 358 | assert.strictEqual(docs[5].document.age, 60); 359 | 360 | docs = await store.find('People', { order: 'age', reverse: true }); 361 | assert.strictEqual(docs.length, 6); 362 | assert.strictEqual(docs[0].key, 'bbb'); 363 | assert.strictEqual(docs[0].document.age, 60); 364 | assert.strictEqual(docs[5].key, 'ccc'); 365 | assert.strictEqual(docs[5].document.age, 20); 366 | 367 | let err = await catchError(async function() { 368 | await store.find('People', { order: 'missingProperty' }); 369 | }); 370 | assert.instanceOf(err, Error); 371 | }); 372 | 373 | it('should find documents with a query', async function() { 374 | let docs = await store.find('People', { query: { country: 'France' } }); 375 | let keys = pluck(docs, 'key'); 376 | assert.deepEqual(keys, ['aaa', 'ddd', 'eee']); 377 | 378 | docs = await store.find('People', { query: { country: 'USA' } }); 379 | keys = pluck(docs, 'key'); 380 | assert.deepEqual(keys, ['bbb', 'ccc', 'fff']); 381 | 382 | docs = await store.find('People', { query: { city: 'New York', country: 'USA' } }); 383 | keys = pluck(docs, 'key'); 384 | assert.deepEqual(keys, ['bbb']); 385 | 386 | docs = await store.find('People', { query: { country: 'Japan' } }); 387 | assert.strictEqual(docs.length, 0); 388 | }); 389 | 390 | it('should find documents with a query and an order', async function() { 391 | let docs = await store.find('People', { 392 | query: { country: 'USA' }, order: 'city' 393 | }); 394 | let keys = pluck(docs, 'key'); 395 | assert.deepEqual(keys, ['ccc', 'bbb', 'fff']); 396 | 397 | docs = await store.find('People', { 398 | query: { country: 'USA' }, order: 'city', reverse: true 399 | }); 400 | keys = pluck(docs, 'key'); 401 | assert.deepEqual(keys, ['fff', 'bbb', 'ccc']); 402 | }); 403 | 404 | it('should find documents after a specific document', async function() { 405 | let docs = await store.find('People', { 406 | query: { country: 'USA' }, order: 'city', start: 'New York' 407 | }); 408 | let keys = pluck(docs, 'key'); 409 | assert.deepEqual(keys, ['bbb', 'fff']); 410 | 411 | docs = await store.find('People', { 412 | query: { country: 'USA' }, order: 'city', startAfter: 'New York' 413 | }); 414 | keys = pluck(docs, 'key'); 415 | assert.deepEqual(keys, ['fff']); 416 | }); 417 | 418 | it('should find documents before a specific document', async function() { 419 | let docs = await store.find('People', { 420 | query: { country: 'USA' }, order: 'city', end: 'New York' 421 | }); 422 | let keys = pluck(docs, 'key'); 423 | assert.deepEqual(keys, ['ccc', 'bbb']); 424 | 425 | docs = await store.find('People', { 426 | query: { country: 'USA' }, order: 'city', endBefore: 'New York' 427 | }); 428 | keys = pluck(docs, 'key'); 429 | assert.deepEqual(keys, ['ccc']); 430 | }); 431 | 432 | it('should find a limited number of documents', async function() { 433 | let docs = await store.find('People', { 434 | query: { country: 'France' }, limit: 2 435 | }); 436 | let keys = pluck(docs, 'key'); 437 | assert.deepEqual(keys, ['aaa', 'ddd']); 438 | }); 439 | 440 | it('should find documents using an index projection', async function() { 441 | let docs = await store.find('People', { 442 | query: { country: 'France' }, properties: ['firstName', 'lastName'] 443 | }); 444 | let keys = pluck(docs, 'key'); 445 | assert.deepEqual(keys, ['aaa', 'ddd', 'eee']); 446 | assert.deepEqual(docs[0].document, { firstName: 'Manuel', lastName: 'Vila' }); 447 | 448 | docs = await store.find('People', { // will not use projection 449 | query: { country: 'France' }, properties: ['firstName', 'lastName', 'age'] 450 | }); 451 | keys = pluck(docs, 'key'); 452 | assert.deepEqual(keys, ['aaa', 'ddd', 'eee']); 453 | assert.deepEqual(docs[0].document, { 454 | firstName: 'Manuel', lastName: 'Vila', 455 | age: 42, city: 'Paris', country: 'France', 456 | tags: ['cool', 'guy'] 457 | }); 458 | }); 459 | 460 | it('should find documents using a computed index', async function() { 461 | let docs = await store.find('People', { order: 'fullNameSortKey' }); 462 | let keys = pluck(docs, 'key'); 463 | assert.deepEqual(keys, ['ccc', 'bbb', 'eee', 'fff', 'aaa', 'ddd']); 464 | }); 465 | 466 | it('should find documents using an array index', async function() { 467 | let docs, keys; 468 | 469 | docs = await store.find('People', { query: { tags: 'cool' } }); 470 | keys = pluck(docs, 'key'); 471 | assert.deepEqual(keys, ['aaa', 'ccc']); 472 | 473 | docs = await store.find('People', { query: { tags: 'guy' } }); 474 | keys = pluck(docs, 'key'); 475 | assert.deepEqual(keys, ['aaa', 'bbb', 'ccc']); 476 | 477 | docs = await store.find('People', { query: { tags: 'drunk' } }); 478 | keys = pluck(docs, 'key'); 479 | assert.deepEqual(keys, ['bbb']); 480 | 481 | let person = await store.get('People', 'aaa'); 482 | person.tags = ['cool', 'man']; 483 | await store.put('People', 'aaa', person); 484 | docs = await store.find('People', { query: { tags: 'cool' } }); 485 | keys = pluck(docs, 'key'); 486 | assert.deepEqual(keys, ['aaa', 'ccc']); 487 | docs = await store.find('People', { query: { tags: 'guy' } }); 488 | keys = pluck(docs, 'key'); 489 | assert.deepEqual(keys, ['bbb', 'ccc']); 490 | }); 491 | 492 | it('should count all documents in a collection', async function() { 493 | let count = await store.count('People'); 494 | assert.strictEqual(count, 6); 495 | }); 496 | 497 | it('should count documents with a query', async function() { 498 | let count = await store.count('People', { 499 | query: { age: 39 } 500 | }); 501 | assert.strictEqual(count, 2); 502 | 503 | count = await store.count('People', { 504 | query: { country: 'France' } 505 | }); 506 | assert.strictEqual(count, 3); 507 | 508 | count = await store.count('People', { 509 | query: { country: 'France', city: 'Paris' } 510 | }); 511 | assert.strictEqual(count, 1); 512 | 513 | count = await store.count('People', { 514 | query: { country: 'Japan', city: 'Tokyo' } 515 | }); 516 | assert.strictEqual(count, 0); 517 | }); 518 | 519 | it('should iterate over documents', async function() { 520 | let keys = []; 521 | await store.forEach('People', { batchSize: 2 }, async function(doc, key) { 522 | keys.push(key); 523 | }); 524 | assert.deepEqual(keys, ['aaa', 'bbb', 'ccc', 'ddd', 'eee', 'fff']); 525 | }); 526 | 527 | it('should iterate over documents in a specific order', async function() { 528 | let keys = []; 529 | let options = { order: ['lastName', 'firstName'], batchSize: 2 }; 530 | await store.forEach('People', options, async function(doc, key) { 531 | keys.push(key); 532 | }); 533 | assert.deepEqual(keys, ['ccc', 'bbb', 'eee', 'fff', 'aaa', 'ddd']); 534 | }); 535 | 536 | it('should find and delete documents', async function() { 537 | let options = { query: { country: 'France' }, batchSize: 2 }; 538 | let deletedDocsCount = await store.findAndDelete('People', options); 539 | assert.strictEqual(deletedDocsCount, 3); 540 | let docs = await store.find('People'); 541 | let keys = pluck(docs, 'key'); 542 | assert.deepEqual(keys, ['bbb', 'ccc', 'fff']); 543 | deletedDocsCount = await store.findAndDelete('People', options); 544 | assert.strictEqual(deletedDocsCount, 0); 545 | }); 546 | 547 | it('shold change a document inside a transaction', async function() { 548 | assert.isFalse(store.insideTransaction); 549 | await store.transaction(async function(transaction) { 550 | assert.isTrue(transaction.insideTransaction); 551 | let innerDoc = await transaction.get('People', 'aaa'); 552 | assert.strictEqual(innerDoc.firstName, 'Manuel'); 553 | innerDoc.firstName = 'Manu'; 554 | await transaction.put('People', 'aaa', innerDoc); 555 | innerDoc = await transaction.get('People', 'aaa'); 556 | assert.strictEqual(innerDoc.firstName, 'Manu'); 557 | }); 558 | let doc = await store.get('People', 'aaa'); 559 | assert.strictEqual(doc.firstName, 'Manu'); 560 | }); 561 | 562 | it('should change a document inside an aborted transaction', async function() { 563 | try { 564 | assert.isFalse(store.insideTransaction); 565 | await store.transaction(async function(transaction) { 566 | assert.isTrue(transaction.insideTransaction); 567 | let innerDoc = await transaction.get('People', 'aaa'); 568 | assert.strictEqual(innerDoc.firstName, 'Manuel'); 569 | innerDoc.firstName = 'Manu'; 570 | await transaction.put('People', 'aaa', innerDoc); 571 | innerDoc = await transaction.get('People', 'aaa'); 572 | assert.strictEqual(innerDoc.firstName, 'Manu'); 573 | throw new Error('something wrong'); 574 | }); 575 | } catch (err) { 576 | // noop 577 | } 578 | let doc = await store.get('People', 'aaa'); 579 | assert.strictEqual(doc.firstName, 'Manuel'); 580 | }); 581 | }); // rich document store 582 | }); 583 | 584 | async function catchError(fn) { 585 | let err; 586 | try { 587 | await fn(); 588 | } catch (e) { 589 | err = e; 590 | } 591 | return err; 592 | } 593 | 594 | function pluck(array, property) { 595 | return array.map(item => item[property]); 596 | } 597 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import assert from 'assert'; 4 | import EventEmitterMixin from 'event-emitter-mixin'; 5 | import KeyValueStore from 'key-value-store'; 6 | import sleep from 'sleep-promise'; 7 | import setImmediatePromise from 'set-immediate-promise'; 8 | import get from 'lodash/get'; 9 | import clone from 'lodash/clone'; 10 | import difference from 'lodash/difference'; 11 | import isEmpty from 'lodash/isEmpty'; 12 | import isEqual from 'lodash/isEqual'; 13 | import last from 'lodash/last'; 14 | import pull from 'lodash/pull'; 15 | import Collection from './collection'; 16 | 17 | let VERSION = 3; 18 | const RESPIRATION_RATE = 250; 19 | 20 | export class DocumentStore extends EventEmitterMixin() { 21 | constructor(options = {}) { 22 | super(); 23 | 24 | if (!options.name) throw new Error('Document store name is missing'); 25 | if (!options.url) throw new Error('Document store URL is missing'); 26 | 27 | this.name = options.name; 28 | this.store = new KeyValueStore(options.url); 29 | 30 | this.collections = []; 31 | let collections = options.collections || []; 32 | for (let collection of collections) { 33 | this.addCollection(collection); 34 | } 35 | 36 | if (options.log) this.log = options.log; 37 | 38 | this.root = this; 39 | } 40 | 41 | use(plugin) { 42 | plugin.plug(this); 43 | } 44 | 45 | // === Document store ==== 46 | 47 | async initializeDocumentStore() { 48 | if (this.hasBeenInitialized) return; 49 | if (this.isInitializing) return; 50 | if (this.insideTransaction) { 51 | throw new Error('Cannot initialize the document store inside a transaction'); 52 | } 53 | this.isInitializing = true; 54 | try { 55 | let hasBeenCreated = await this.createDocumentStoreIfDoesNotExist(); 56 | if (!hasBeenCreated) { 57 | await this.lockDocumentStore(); 58 | try { 59 | await this.upgradeDocumentStore(); 60 | await this.verifyDocumentStore(); 61 | await this.migrateDocumentStore(); 62 | } finally { 63 | await this.unlockDocumentStore(); 64 | } 65 | } 66 | this.hasBeenInitialized = true; 67 | await this.emit('didInitialize'); 68 | } finally { 69 | this.isInitializing = false; 70 | } 71 | } 72 | 73 | async createDocumentStoreIfDoesNotExist() { 74 | let hasBeenCreated = false; 75 | await this.store.transaction(async function(storeTransaction) { 76 | let record = await this._loadDocumentStoreRecord(storeTransaction, false); 77 | if (!record) { 78 | let collections = this.collections.map(collection => collection.toJSON()); 79 | record = { 80 | name: this.name, 81 | version: VERSION, 82 | collections 83 | }; 84 | await this._saveDocumentStoreRecord(record, storeTransaction, true); 85 | hasBeenCreated = true; 86 | await this.emit('didCreate', storeTransaction); 87 | if (this.log) { 88 | this.log.info(`Document store '${this.name}' created`); 89 | } 90 | } 91 | }.bind(this)); 92 | return hasBeenCreated; 93 | } 94 | 95 | async lockDocumentStore() { 96 | let hasBeenLocked = false; 97 | while (!hasBeenLocked) { 98 | await this.store.transaction(async function(storeTransaction) { 99 | let record = await this._loadDocumentStoreRecord(storeTransaction); 100 | if (!record.isLocked) { 101 | record.isLocked = hasBeenLocked = true; 102 | await this._saveDocumentStoreRecord(record, storeTransaction); 103 | } 104 | }.bind(this)); 105 | if (!hasBeenLocked) { 106 | if (this.log) { 107 | this.log.info(`Waiting document store '${this.name}' unlocking...`); 108 | } 109 | await sleep(5000); // wait 5 secs before retrying 110 | } 111 | } 112 | } 113 | 114 | async unlockDocumentStore() { 115 | let record = await this._loadDocumentStoreRecord(); 116 | record.isLocked = false; 117 | await this._saveDocumentStoreRecord(record); 118 | } 119 | 120 | async upgradeDocumentStore() { 121 | let record = await this._loadDocumentStoreRecord(); 122 | let version = record.version; 123 | 124 | if (version === VERSION) return; 125 | 126 | if (version > VERSION) { 127 | throw new Error('Cannot downgrade the document store'); 128 | } 129 | 130 | this.emit('willUpgrade'); 131 | 132 | if (version < 2) { 133 | delete record.lastMigrationNumber; 134 | record.tables.forEach(table => { 135 | table.indexes = table.indexes.map(index => index.name); 136 | }); 137 | } 138 | 139 | if (version < 3) { 140 | throw new Error('Cannot upgrade the document store to version 3 automatically'); 141 | } 142 | 143 | record.version = VERSION; 144 | await this._saveDocumentStoreRecord(record); 145 | if (this.log) { 146 | this.log.info(`Document store '${this.name}' upgraded to version ${VERSION}`); 147 | } 148 | 149 | this.emit('didUpgrade'); 150 | } 151 | 152 | async verifyDocumentStore() { 153 | // ... 154 | } 155 | 156 | async migrateDocumentStore() { 157 | let record = await this._loadDocumentStoreRecord(); 158 | try { 159 | // Find out added or updated collections 160 | for (let collection of this.collections) { 161 | let existingCollection = record.collections.find(collec => collec.name === collection.name); 162 | if (!existingCollection) { // added collection 163 | this._emitMigrationDidStart(); 164 | record.collections.push(collection.toJSON()); 165 | await this._saveDocumentStoreRecord(record); 166 | if (this.log) { 167 | this.log.info(`Collection '${collection.name}' (document store '${this.name}') added`); 168 | } 169 | } else if (existingCollection.hasBeenRemoved) { 170 | throw new Error('Adding a collection that has been removed is not implemented yet'); 171 | } else { 172 | // Find out added or updated indexes 173 | for (let index of collection.indexes) { 174 | let existingIndex = existingCollection.indexes.find(existingIndex => isEqual(existingIndex.keys, index.keys)); 175 | if (!existingIndex) { // added index 176 | this._emitMigrationDidStart(); 177 | await this._addIndex(collection, index); 178 | existingCollection.indexes.push(index.toJSON()); 179 | await this._saveDocumentStoreRecord(record); 180 | } else if (!(isEqual(existingIndex.projection, index.projection) && existingIndex.version === index.version)) { // updated index 181 | this._emitMigrationDidStart(); 182 | await this._updateIndex(collection, index); 183 | if (index.projection == null) delete existingIndex.projection; 184 | else existingIndex.projection = index.projection; 185 | if (index.version == null) delete existingIndex.version; 186 | else existingIndex.version = index.version; 187 | await this._saveDocumentStoreRecord(record); 188 | } 189 | } 190 | // Find out removed indexes 191 | let existingIndexesKeys = existingCollection.indexes.map(index => index.keys); 192 | for (let existingIndexKeys of existingIndexesKeys) { 193 | if (!collection.indexes.find(index => isEqual(index.keys, existingIndexKeys))) { // removed index 194 | this._emitMigrationDidStart(); 195 | await this._removeIndex(collection.name, existingIndexKeys); 196 | let i = existingCollection.indexes.findIndex(index => isEqual(index.keys, existingIndexKeys)); 197 | assert.notEqual(i, -1); 198 | existingCollection.indexes.splice(i, 1); 199 | await this._saveDocumentStoreRecord(record); 200 | } 201 | } 202 | } 203 | } 204 | 205 | // Find out removed collections 206 | for (let existingCollection of record.collections) { 207 | if (existingCollection.hasBeenRemoved) continue; 208 | let collection = this.collections.find(collection => collection.name === existingCollection.name); 209 | if (!collection) { // removed collection 210 | this._emitMigrationDidStart(); 211 | for (let existingIndexes of existingCollection.indexes) { 212 | await this._removeIndex(existingCollection.name, existingIndexes.keys); 213 | } 214 | existingCollection.indexes.length = 0; 215 | existingCollection.hasBeenRemoved = true; 216 | await this._saveDocumentStoreRecord(record); 217 | if (this.log) { 218 | this.log.info(`Collection '${existingCollection.name}' (document store '${this.name}') marked as removed`); 219 | } 220 | } 221 | } 222 | } finally { 223 | this._emitMigrationDidStop(); 224 | } 225 | } 226 | 227 | _emitMigrationDidStart() { 228 | if (!this.migrationDidStartEventHasBeenEmitted) { 229 | this.emit('willMigrate'); 230 | this.migrationDidStartEventHasBeenEmitted = true; 231 | } 232 | } 233 | 234 | _emitMigrationDidStop() { 235 | if (this.migrationDidStartEventHasBeenEmitted) { 236 | this.emit('didMigrate'); 237 | delete this.migrationDidStartEventHasBeenEmitted; 238 | } 239 | } 240 | 241 | async _addIndex(collection, index) { 242 | let indexName = this.makeIndexName(index.keys); 243 | if (this.log) { 244 | this.log.info(`Adding index '${indexName}' (document store '${this.name}', collection '${collection.name}')...`); 245 | } 246 | let count = 0; 247 | let previousRoundedCount = 0; 248 | await this.forEach(collection, { batchSize: 2500 }, async function(doc, key) { 249 | await this.updateIndex(collection, key, undefined, doc, index); 250 | count++; 251 | const roundedCount = count - count % 5000; 252 | if (roundedCount !== previousRoundedCount) { 253 | this.log.info(`${roundedCount} items scanned (document store '${this.name}', collection '${collection.name}', index '${indexName})`); 254 | previousRoundedCount = roundedCount; 255 | } 256 | }, this); 257 | } 258 | 259 | async _updateIndex(collection, index) { 260 | let indexName = this.makeIndexName(index.keys); 261 | if (this.log) { 262 | this.log.info(`Updating index '${indexName}' (document store '${this.name}', collection '${collection.name}')...`); 263 | } 264 | let prefix = [this.name, this.makeIndexCollectionName(collection.name, indexName)]; 265 | await this.store.findAndDelete({ prefix }); 266 | let count = 0; 267 | let previousRoundedCount = 0; 268 | await this.forEach(collection, { batchSize: 2500 }, async function(doc, key) { 269 | await this.updateIndex(collection, key, undefined, doc, index); 270 | count++; 271 | const roundedCount = count - count % 5000; 272 | if (roundedCount !== previousRoundedCount) { 273 | this.log.info(`${roundedCount} items scanned (document store '${this.name}', collection '${collection.name}', index '${indexName})`); 274 | previousRoundedCount = roundedCount; 275 | } 276 | }, this); 277 | } 278 | 279 | async _removeIndex(collectionName, indexKeys) { 280 | let indexName = this.makeIndexName(indexKeys); 281 | if (this.log) { 282 | this.log.info(`Removing index '${indexName}' (document store '${this.name}', collection '${collectionName}')...`); 283 | } 284 | let prefix = [this.name, this.makeIndexCollectionName(collectionName, indexName)]; 285 | await this.store.findAndDelete({ prefix }); 286 | } 287 | 288 | async _loadDocumentStoreRecord(storeTransaction, errorIfMissing = true) { 289 | if (!storeTransaction) storeTransaction = this.store; 290 | return await storeTransaction.get([this.name], { errorIfMissing }); 291 | } 292 | 293 | async _saveDocumentStoreRecord(record, storeTransaction, errorIfExists) { 294 | if (!storeTransaction) storeTransaction = this.store; 295 | await storeTransaction.put([this.name], record, { 296 | errorIfExists, 297 | createIfMissing: !errorIfExists 298 | }); 299 | } 300 | 301 | async getStatistics() { 302 | let collectionsCount = 0; 303 | let removedCollectionsCount = 0; 304 | let indexesCount = 0; 305 | let record = await this._loadDocumentStoreRecord(undefined, false); 306 | if (record) { 307 | record.collections.forEach(collection => { 308 | if (!collection.hasBeenRemoved) { 309 | collectionsCount++; 310 | } else { 311 | removedCollectionsCount++; 312 | } 313 | indexesCount += collection.indexes.length; 314 | }); 315 | } 316 | let storePairsCount = await this.store.count({ prefix: this.name }); 317 | return { 318 | collectionsCount, 319 | removedCollectionsCount, 320 | indexesCount, 321 | keyValueStore: { 322 | pairsCount: storePairsCount 323 | } 324 | }; 325 | } 326 | 327 | async removeCollectionsMarkedAsRemoved() { 328 | let record = await this._loadDocumentStoreRecord(); 329 | let collectionNames = record.collections.map(collection => collection.name); 330 | for (let i = 0; i < collectionNames.length; i++) { 331 | let collectionName = collectionNames[i]; 332 | let collection = record.collections.find(collection => collection.name === collectionName); 333 | if (!collection.hasBeenRemoved) continue; 334 | await this._removeCollection(collectionName); 335 | pull(record.collections, collection); 336 | await this._saveDocumentStoreRecord(record); 337 | if (this.log) { 338 | this.log.info(`Collection '${collectionName}' (document store '${this.name}') permanently removed`); 339 | } 340 | } 341 | } 342 | 343 | async _removeCollection(collectionName) { 344 | let prefix = [this.name, collectionName]; 345 | await this.store.findAndDelete({ prefix }); 346 | } 347 | 348 | async destroyAll() { 349 | if (this.insideTransaction) { 350 | throw new Error('Cannot destroy a document store inside a transaction'); 351 | } 352 | this.hasBeenInitialized = false; 353 | await this.store.findAndDelete({ prefix: this.name }); 354 | } 355 | 356 | async close() { 357 | await this.store.close(); 358 | } 359 | 360 | // === Collections ==== 361 | 362 | getCollection(name, errorIfMissing) { 363 | if (errorIfMissing == null) errorIfMissing = true; 364 | let collection = this.collections.find(collection => collection.name === name); 365 | if (!collection && errorIfMissing) { 366 | throw new Error(`Collection '${collection.name}' (document store '${this.name}') is missing`); 367 | } 368 | return collection; 369 | } 370 | 371 | addCollection(options = {}) { 372 | let collection = new Collection(options); 373 | let existingCollection = this.getCollection(collection.name, false); 374 | if (existingCollection) { 375 | throw new Error(`Collection '${collection.name}' (document store '${this.name}') already exists`); 376 | } 377 | this.collections.push(collection); 378 | } 379 | 380 | normalizeCollection(collection) { 381 | if (typeof collection === 'string') collection = this.getCollection(collection); 382 | return collection; 383 | } 384 | 385 | // === Indexes ==== 386 | 387 | async updateIndexes(collection, key, oldDoc, newDoc) { 388 | for (let i = 0; i < collection.indexes.length; i++) { 389 | let index = collection.indexes[i]; 390 | await this.updateIndex(collection, key, oldDoc, newDoc, index); 391 | } 392 | } 393 | 394 | async updateIndex(collection, key, oldDoc, newDoc, index) { 395 | let oldValues = []; 396 | let newValues = []; 397 | index.properties.forEach(property => { 398 | let oldValue, newValue; 399 | if (property.value === true) { // simple index 400 | oldValue = oldDoc && get(oldDoc, property.key); 401 | newValue = newDoc && get(newDoc, property.key); 402 | } else { // computed index 403 | oldValue = oldDoc && property.value(oldDoc); 404 | newValue = newDoc && property.value(newDoc); 405 | } 406 | oldValues.push(oldValue); 407 | newValues.push(newValue); 408 | }); 409 | 410 | let oldProjection; 411 | let newProjection; 412 | if (index.projection) { 413 | index.projection.forEach(k => { 414 | let val = get(oldDoc, k); 415 | if (val != null) { 416 | if (!oldProjection) oldProjection = {}; 417 | oldProjection[k] = val; 418 | } 419 | val = get(newDoc, k); 420 | if (val != null) { 421 | if (!newProjection) newProjection = {}; 422 | newProjection[k] = val; 423 | } 424 | }); 425 | } 426 | 427 | let valuesAreDifferent = !isEqual(oldValues, newValues); 428 | let projectionIsDifferent = !isEqual(oldProjection, newProjection); 429 | 430 | if (valuesAreDifferent && !oldValues.includes(undefined)) { 431 | // OPTIMIZE: delete only differences between oldValues and newValues 432 | for (let oldVals of this.scatter(oldValues)) { 433 | // OPTIMIZE: implement and use store.deleteMany() 434 | let indexKey = this.makeIndexKey(collection, index, oldVals, key); 435 | await this.store.delete(indexKey); 436 | } 437 | } 438 | 439 | if ((valuesAreDifferent || projectionIsDifferent) && !newValues.includes(undefined)) { 440 | // OPTIMIZE: put only differences between oldValues and newValues 441 | for (let newVals of this.scatter(newValues)) { 442 | // OPTIMIZE: implement and use store.putMany() 443 | let indexKey = this.makeIndexKey(collection, index, newVals, key); 444 | await this.store.put(indexKey, newProjection); 445 | } 446 | } 447 | } 448 | 449 | // ['a', [1, 2]] => [['a', 1], ['a', 2]] 450 | scatter(inputs) { 451 | let outputs = []; 452 | let lastInput = last(inputs); 453 | if (Array.isArray(lastInput)) { 454 | let otherInputs = inputs.slice(0, -1); 455 | for (let val of lastInput) { 456 | let output = otherInputs.concat(val); 457 | outputs.push(output); 458 | } 459 | } else { 460 | outputs.push(inputs); 461 | } 462 | return outputs; 463 | } 464 | 465 | makeIndexName(keys) { 466 | return keys.join('+'); 467 | } 468 | 469 | makeIndexKey(collection, index, values, key) { 470 | let indexName = this.makeIndexName(index.keys); 471 | let indexKey = [this.name, this.makeIndexCollectionName(collection.name, indexName)]; 472 | indexKey.push.apply(indexKey, values); 473 | indexKey.push(key); 474 | return indexKey; 475 | } 476 | 477 | makeIndexCollectionName(collectionName, indexName) { 478 | return collectionName + ':' + indexName; 479 | } 480 | 481 | makeIndexKeyForQuery(collection, index, query) { 482 | if (!query) query = {}; 483 | let indexName = this.makeIndexName(index.keys); 484 | let indexKey = [this.name, this.makeIndexCollectionName(collection.name, indexName)]; 485 | let keys = index.properties.map(property => property.key); 486 | let queryKeys = Object.keys(query); 487 | for (let i = 0; i < queryKeys.length; i++) { 488 | let key = keys[i]; 489 | indexKey.push(query[key]); 490 | } 491 | return indexKey; 492 | } 493 | 494 | // === Basic operations ==== 495 | 496 | // Options: 497 | // errorIfMissing: throw an error if the document is not found. Default: true. 498 | async get(collection, key, options) { 499 | collection = this.normalizeCollection(collection); 500 | key = this.normalizeKey(key); 501 | options = this.normalizeOptions(options); 502 | await this.initializeDocumentStore(); 503 | let doc = await this.store.get(this.makeDocumentKey(collection, key), options); 504 | return doc; 505 | } 506 | 507 | // Options: 508 | // createIfMissing: add the document if it is missing in the collection. 509 | // If the document is already present, replace it. Default: true. 510 | // errorIfExists: throw an error if the document is already present 511 | // in the collection. Default: false. 512 | async put(collection, key, doc, options) { 513 | collection = this.normalizeCollection(collection); 514 | key = this.normalizeKey(key); 515 | doc = this.normalizeDocument(doc); 516 | options = this.normalizeOptions(options); 517 | await this.initializeDocumentStore(); 518 | let oldDoc; 519 | await this.transaction(async function(tr) { 520 | let docKey = tr.makeDocumentKey(collection, key); 521 | oldDoc = await tr.store.get(docKey, { errorIfMissing: false }); 522 | await tr.emit('willPut', collection, key, oldDoc, doc, options); 523 | await tr.store.put(docKey, doc, options); 524 | await tr.updateIndexes(collection, key, oldDoc, doc); 525 | }); 526 | await this.emit('didPut', collection, key, oldDoc, doc, options); 527 | } 528 | 529 | // Options: 530 | // errorIfMissing: throw an error if the document is not found. Default: true. 531 | async delete(collection, key, options) { 532 | collection = this.normalizeCollection(collection); 533 | key = this.normalizeKey(key); 534 | options = this.normalizeOptions(options); 535 | let hasBeenDeleted = false; 536 | await this.initializeDocumentStore(); 537 | let oldDoc; 538 | await this.transaction(async function(tr) { 539 | let docKey = tr.makeDocumentKey(collection, key); 540 | oldDoc = await tr.store.get(docKey, options); 541 | if (oldDoc) { 542 | await tr.emit('willDelete', collection, key, oldDoc, options); 543 | hasBeenDeleted = await tr.store.delete(docKey, options); 544 | await tr.updateIndexes(collection, key, oldDoc, undefined); 545 | } 546 | }); 547 | if (oldDoc) { 548 | await this.emit('didDelete', collection, key, oldDoc, options); 549 | } 550 | return hasBeenDeleted; 551 | } 552 | 553 | async getMany(collection, keys, options) { 554 | collection = this.normalizeCollection(collection); 555 | if (!Array.isArray(keys)) throw new Error('Invalid keys (should be an array)'); 556 | if (!keys.length) return []; 557 | keys = keys.map(this.normalizeKey, this); 558 | options = this.normalizeOptions(options); 559 | let docKeys = keys.map(key => this.makeDocumentKey(collection, key)); 560 | options = clone(options); 561 | options.returnValues = options.properties === '*' || options.properties.length; 562 | let iterationsCount = 0; 563 | await this.initializeDocumentStore(); 564 | let items = await this.store.getMany(docKeys, options); 565 | let finalItems = []; 566 | for (let item of items) { 567 | let finalItem = { key: last(item.key) }; 568 | if (options.returnValues) finalItem.document = item.value; 569 | finalItems.push(finalItem); 570 | if (++iterationsCount % RESPIRATION_RATE === 0) await setImmediatePromise(); 571 | } 572 | return finalItems; 573 | } 574 | 575 | // Options: 576 | // query: specifies the search query. 577 | // Example: { blogId: 'xyz123', postId: 'abc987' }. 578 | // order: specifies the property to order the results by: 579 | // Example: ['lastName', 'firstName']. 580 | // start, startAfter, end, endBefore: ... 581 | // reverse: if true, the search is made in reverse order. 582 | // properties: indicates properties to fetch. '*' for all properties 583 | // or an array of property name. If an index projection matches 584 | // the requested properties, the projection is used. 585 | // limit: maximum number of documents to return. 586 | async find(collection, options) { 587 | collection = this.normalizeCollection(collection); 588 | options = this.normalizeOptions(options); 589 | if (!isEmpty(options.query) || !isEmpty(options.order)) { 590 | return await this._findWithIndex(collection, options); 591 | } 592 | options = clone(options); 593 | options.prefix = [this.name, collection.name]; 594 | options.returnValues = options.properties === '*' || options.properties.length; 595 | let iterationsCount = 0; 596 | await this.initializeDocumentStore(); 597 | let items = await this.store.find(options); 598 | let finalItems = []; 599 | for (let item of items) { 600 | let finalItem = { key: last(item.key) }; 601 | if (options.returnValues) finalItem.document = item.value; 602 | finalItems.push(finalItem); 603 | if (++iterationsCount % RESPIRATION_RATE === 0) await setImmediatePromise(); 604 | } 605 | return finalItems; 606 | } 607 | 608 | async _findWithIndex(collection, options) { 609 | let index = collection.findIndexForQueryAndOrder(options.query, options.order); 610 | 611 | let fetchDoc = options.properties === '*'; 612 | let useProjection = false; 613 | if (!fetchDoc && options.properties.length) { 614 | let diff = difference(options.properties, index.projection); 615 | useProjection = diff.length === 0; 616 | if (!useProjection) { 617 | fetchDoc = true; 618 | if (this.log) { 619 | this.log.debug('An index projection doesn\'t satisfy requested properties, full documents will be fetched'); 620 | } 621 | } 622 | } 623 | 624 | options = clone(options); 625 | options.prefix = this.makeIndexKeyForQuery(collection, index, options.query); 626 | options.returnValues = useProjection; 627 | 628 | let iterationsCount = 0; 629 | await this.initializeDocumentStore(); 630 | let items = await this.store.find(options); 631 | let transformedItems = []; 632 | for (let item of items) { 633 | let transformedItem = { key: last(item.key) }; 634 | if (useProjection) transformedItem.document = item.value; 635 | transformedItems.push(transformedItem); 636 | if (++iterationsCount % RESPIRATION_RATE === 0) await setImmediatePromise(); 637 | } 638 | items = transformedItems; 639 | 640 | if (fetchDoc) { 641 | let keys = items.map(item => item.key); 642 | items = await this.getMany(collection, keys, { errorIfMissing: false }); 643 | } 644 | 645 | return items; 646 | } 647 | 648 | // Options: same as find() without 'reverse' and 'properties' attributes. 649 | async count(collection, options) { 650 | collection = this.normalizeCollection(collection); 651 | options = this.normalizeOptions(options); 652 | if (!isEmpty(options.query) || !isEmpty(options.order)) { 653 | return await this._countWithIndex(collection, options); 654 | } 655 | options = clone(options); 656 | options.prefix = [this.name, collection.name]; 657 | await this.initializeDocumentStore(); 658 | return await this.store.count(options); 659 | } 660 | 661 | async _countWithIndex(collection, options) { 662 | let index = collection.findIndexForQueryAndOrder(options.query, options.order); 663 | options = clone(options); 664 | options.prefix = this.makeIndexKeyForQuery(collection, index, options.query); 665 | await this.initializeDocumentStore(); 666 | return await this.store.count(options); 667 | } 668 | 669 | // === Composed operations === 670 | 671 | // Options: same as find() plus: 672 | // batchSize: use several find() operations with batchSize as limit. 673 | // Default: 250. 674 | async forEach(collection, options, fn, thisArg) { 675 | collection = this.normalizeCollection(collection); 676 | options = this.normalizeOptions(options); 677 | if (!options.batchSize) options.batchSize = 250; 678 | options = clone(options); 679 | options.limit = options.batchSize; // TODO: global 'limit' option 680 | while (true) { 681 | let items = await this.find(collection, options); 682 | if (!items.length) break; 683 | for (let i = 0; i < items.length; i++) { 684 | let item = items[i]; 685 | await fn.call(thisArg, item.document, item.key); 686 | } 687 | let lastItem = last(items); 688 | options.startAfter = this.makeOrderKey(lastItem.key, lastItem.document, options.order); 689 | delete options.start; 690 | } 691 | } 692 | 693 | // Options: same as forEach() without 'properties' attribute. 694 | async findAndDelete(collection, options) { 695 | collection = this.normalizeCollection(collection); 696 | options = this.normalizeOptions(options); 697 | options = clone(options); 698 | options.properties = []; 699 | let deletedDocsCount = 0; 700 | await this.forEach(collection, options, async function(doc, key) { 701 | let hasBeenDeleted = await this.delete( 702 | collection, key, { errorIfMissing: false } 703 | ); 704 | if (hasBeenDeleted) deletedDocsCount++; 705 | }, this); 706 | return deletedDocsCount; 707 | } 708 | 709 | // === Transactions ==== 710 | 711 | async transaction(fn) { 712 | if (this.insideTransaction) return await fn(this); 713 | await this.initializeDocumentStore(); 714 | return await this.store.transaction(async function(storeTransaction) { 715 | let transaction = Object.create(this); 716 | transaction.store = storeTransaction; 717 | return await fn(transaction); 718 | }.bind(this)); 719 | } 720 | 721 | get insideTransaction() { 722 | return this !== this.root; 723 | } 724 | 725 | // === Helpers ==== 726 | 727 | makeDocumentKey(collection, key) { 728 | return [this.name, collection.name, key]; 729 | } 730 | 731 | makeOrderKey(key, doc, order = []) { 732 | let orderKey = order.map(k => doc[k]); 733 | orderKey.push(key); 734 | return orderKey; 735 | } 736 | 737 | normalizeKey(key) { 738 | if (typeof key !== 'number' && typeof key !== 'string') { 739 | throw new Error('Invalid key type'); 740 | } 741 | if (!key) { 742 | throw new Error('Specified key is null or empty'); 743 | } 744 | return key; 745 | } 746 | 747 | normalizeDocument(doc) { 748 | if (!(doc && typeof doc === 'object')) throw new Error('Invalid document type'); 749 | return doc; 750 | } 751 | 752 | normalizeOptions(options) { 753 | if (!options) options = {}; 754 | if (!options.hasOwnProperty('properties')) { 755 | options.properties = '*'; 756 | } else if (options.properties === '*') { 757 | // It's OK 758 | } else if (Array.isArray(options.properties)) { 759 | // It's OK 760 | } else if (options.properties == null) { 761 | options.properties = []; 762 | } else { 763 | throw new Error('Invalid \'properties\' option'); 764 | } 765 | return options; 766 | } 767 | } 768 | 769 | export default DocumentStore; 770 | --------------------------------------------------------------------------------