├── .travis.yml ├── LICENSE ├── example ├── bench.js └── kv.js ├── index.js ├── package.json ├── readme.md └── test ├── kv.js └── random.js /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '8' 4 | - '10' 5 | os: 6 | - windows 7 | - osx 8 | - linux 9 | notifications: 10 | email: false 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 James Halliday 2 | 3 | Redistribution and use in source and binary forms, with or without modification, 4 | are permitted provided that the following conditions are met: 5 | 6 | Redistributions of source code must retain the above copyright notice, this list 7 | of conditions and the following disclaimer. 8 | 9 | Redistributions in binary form must reproduce the above copyright notice, this 10 | list of conditions and the following disclaimer in the documentation and/or 11 | other materials provided with the distribution. 12 | 13 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 14 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 15 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 16 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR 17 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 18 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 19 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 20 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 21 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 22 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 23 | -------------------------------------------------------------------------------- /example/bench.js: -------------------------------------------------------------------------------- 1 | var umkv = require('../') 2 | var db = require('level')('/tmp/kv.db') 3 | var kv = umkv(db) 4 | var randomBytes = require('crypto').randomBytes 5 | 6 | var batchSize = Number(process.argv[2]) 7 | var times = Number(process.argv[3]) 8 | 9 | var keys = [] 10 | for (var i = 0; i < 5000; i++) { 11 | keys.push(randomBytes(4).toString('hex')) 12 | } 13 | 14 | var start = Date.now() 15 | var uid = 0 16 | 17 | ;(function next (n) { 18 | if (n === times) return finish() 19 | var docs = [] 20 | for (var i = 0; i < batchSize; i++) { 21 | docs.push({ 22 | id: uid, 23 | key: keys[Math.floor(Math.random()*keys.length)], 24 | links: uid > 0 ? [uid-1] : [] 25 | }) 26 | uid++ 27 | } 28 | kv.batch(docs, function (err) { 29 | if (err) return console.error(err) 30 | else next(n+1) 31 | }) 32 | })(0) 33 | 34 | function finish () { 35 | var elapsed = Date.now() - start 36 | console.log(elapsed) 37 | } 38 | -------------------------------------------------------------------------------- /example/kv.js: -------------------------------------------------------------------------------- 1 | var umkv = require('../') 2 | var db = require('level')('/tmp/kv.db') 3 | var kv = umkv(db) 4 | 5 | if (process.argv[2] === 'insert') { 6 | var doc = JSON.parse(process.argv[3]) 7 | kv.batch([doc], function (err) { 8 | if (err) console.error(err) 9 | }) 10 | } else if (process.argv[2] === 'get') { 11 | var key = process.argv[3] 12 | kv.get(key, function (err, ids) { 13 | if (err) console.error(err) 14 | else console.log(ids) 15 | }) 16 | } 17 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | var once = require('once') 2 | 3 | var KEY = 'k!' 4 | var LINK = 'l!' 5 | 6 | module.exports = MKV 7 | 8 | function MKV (db, opts) { 9 | if (!(this instanceof MKV)) return new MKV(db, opts) 10 | this._db = db 11 | this._delim = opts && opts.delim ? opts.delim : ',' 12 | this._writing = false 13 | this._writeQueue = [] 14 | this._onremove = opts ? opts.onremove : null 15 | this._onupdate = opts ? opts.onupdate : null 16 | } 17 | 18 | MKV.prototype.batch = function (docs, cb) { 19 | cb = once(cb || noop) 20 | var self = this 21 | if (self._writing) return self._writeQueue.push(docs, cb) 22 | self._writing = true 23 | 24 | var batch = [] 25 | var pending = 1 26 | 27 | for (var i = 0; i < docs.length; i++) { 28 | var id = docs[i].id 29 | if (typeof id !== 'string') id = String(id) 30 | if (id.indexOf(self._delim) >= 0) { 31 | return process.nextTick(cb, new Error('id contains delimiter')) 32 | } 33 | } 34 | 35 | var linkExists = {} // pointed-to documents can't be heads 36 | docs.forEach(function (doc) { 37 | pending++ 38 | ;(doc.links || []).forEach(function (link) { 39 | linkExists[link] = true 40 | }) 41 | self.isLinked(doc.id, function (err, ex) { 42 | if (err) return cb(err) 43 | linkExists[doc.id] = linkExists[doc.id] || ex 44 | if (--pending === 0) writeBatch() 45 | }) 46 | }) 47 | 48 | var keygroup = {} 49 | docs.forEach(function (doc) { 50 | if (keygroup[doc.key]) keygroup[doc.key].push(doc) 51 | else keygroup[doc.key] = [doc] 52 | }) 53 | var values = {} 54 | Object.keys(keygroup).forEach(function (key) { 55 | pending++ 56 | var group = keygroup[key] 57 | self._db.get(KEY + key, function (err, value) { 58 | values[key] = value 59 | if (--pending === 0) writeBatch() 60 | }) 61 | }) 62 | if (--pending === 0) writeBatch() 63 | 64 | function writeBatch () { 65 | var removed = [], removedObj = {}, updated = {} 66 | Object.keys(keygroup).forEach(function (key) { 67 | var group = keygroup[key] 68 | var klinks = values[key] 69 | ? values[key].toString().split(self._delim) 70 | : [] 71 | var khas = {} 72 | for (var i = 0; i < klinks.length; i++) khas[klinks[i]] = true 73 | group.forEach(function (doc) { 74 | var dlinks = {} 75 | ;(doc.links || []).forEach(function (link) { 76 | if (!removedObj[link]) { 77 | removed.push(link) 78 | removedObj[link] = true 79 | } 80 | dlinks[link] = true 81 | batch.push({ 82 | type: 'put', 83 | key: LINK + link, 84 | value: '' 85 | }) 86 | }) 87 | if (!linkExists[doc.id] && !khas[doc.id]) { 88 | klinks.push(doc.id) 89 | } 90 | klinks = klinks.filter(function (link) { 91 | return !Object.prototype.hasOwnProperty.call(dlinks,link) 92 | }) 93 | }) 94 | batch.push({ 95 | type: 'put', 96 | key: KEY + key, 97 | value: klinks.join(self._delim) 98 | }) 99 | updated[key] = klinks 100 | }) 101 | self._db.batch(batch, function (err) { 102 | if (err) cb(err) 103 | else cb() 104 | self._writing = false 105 | if (self._onremove) self._onremove(removed) 106 | if (self._onupdate) self._onupdate(updated) 107 | if (self._writeQueue.length > 0) { 108 | var wdocs = self._writeQueue.shift() 109 | var wcb = self._writeQueue.shift() 110 | self.batch(wdocs, wcb) 111 | } 112 | }) 113 | } 114 | } 115 | 116 | MKV.prototype.get = function (key, cb) { 117 | if (!cb) cb = noop 118 | var self = this 119 | self._db.get(KEY + key, function (err, values) { 120 | if (err) cb(err) 121 | else cb(null, values.toString().split(self._delim)) 122 | }) 123 | } 124 | 125 | MKV.prototype.isLinked = function (id, cb) { 126 | this._db.get(LINK + id, function (err, value) { 127 | cb(null, value !== undefined) 128 | }) 129 | } 130 | 131 | function noop () {} 132 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "unordered-materialized-kv", 3 | "version": "1.3.0", 4 | "description": "materialized view key/id store based on unordered log messages", 5 | "dependencies": { 6 | "once": "^1.4.0" 7 | }, 8 | "devDependencies": { 9 | "memdb": "^1.3.1", 10 | "tape": "^4.9.0" 11 | }, 12 | "scripts": { 13 | "test": "tape test/*.js" 14 | }, 15 | "license": "BSD" 16 | } 17 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # unordered-materialized-kv 2 | 3 | materialized view key/id store based on unordered log messages 4 | 5 | This library presents a familiar key/value materialized view for append-only log 6 | data which can be inserted in any order. New documents point at ancestor 7 | documents under the same key to "overwrite" their values. This library 8 | implements a multi-register conflict strategy, so each key may map to more than 9 | one value. To merge multiple values into a single value, point at more than one 10 | ancestor id. 11 | 12 | This library is useful for kappa architectures with missing or out of order log 13 | entries, or where calculating a topological ordering would be expensive. 14 | 15 | This library does not store values itself, only the IDs to look up values. This 16 | way you can use an append-only log to store your primary values without 17 | duplicating data. 18 | 19 | # example 20 | 21 | ``` js 22 | var umkv = require('unordered-materialized-kv') 23 | var db = require('level')('/tmp/kv.db') 24 | var kv = umkv(db) 25 | 26 | if (process.argv[2] === 'insert') { 27 | var doc = JSON.parse(process.argv[3]) 28 | kv.batch([doc], function (err) { 29 | if (err) console.error(err) 30 | }) 31 | } else if (process.argv[2] === 'get') { 32 | var key = process.argv[3] 33 | kv.get(key, function (err, ids) { 34 | if (err) console.error(err) 35 | else console.log(ids) 36 | }) 37 | } 38 | ``` 39 | 40 | in order with no forking: 41 | 42 | ``` 43 | $ rm -rf /tmp/kv.db \ 44 | && node kv.js insert '{"id":"x","key":"a","links":[]}' \ 45 | && node kv.js insert '{"id":"y","key":"a","links":["x"]}' \ 46 | && node kv.js get a 47 | [ 'y' ] 48 | ``` 49 | 50 | out of order with no forking: 51 | 52 | ``` 53 | $ rm -rf /tmp/kv.db \ 54 | && node kv.js insert '{"id":"y","key":"a","links":["x"]}' \ 55 | && node kv.js insert '{"id":"x","key":"a","links":[]}' \ 56 | && node kv.js get a 57 | [ 'y' ] 58 | ``` 59 | 60 | in order with forking: 61 | 62 | ``` 63 | $ rm -rf /tmp/kv.db \ 64 | && node kv.js insert '{"id":"x","key":"a","links":[]}' \ 65 | && node kv.js insert '{"id":"y","key":"a","links":["x"]}' \ 66 | && node kv.js insert '{"id":"z","key":"a","links":[]}' \ 67 | && node kv.js get a 68 | [ 'y', 'z' ] 69 | ``` 70 | 71 | out of order with forking: 72 | 73 | ``` 74 | $ rm -rf /tmp/kv.db \ 75 | && node kv.js insert '{"id":"z","key":"a","links":[]}' \ 76 | && node kv.js insert '{"id":"x","key":"a","links":[]}' \ 77 | && node kv.js insert '{"id":"y","key":"a","links":["x"]}' \ 78 | && node kv.js get a 79 | [ 'z', 'y' ] 80 | ``` 81 | 82 | # api 83 | 84 | ``` js 85 | var umkv = require('unordered-materialized-kv') 86 | ``` 87 | 88 | ## var kv = umkv(db, opts) 89 | 90 | Create a `kv` instance from a [leveldb][] instance `db` (levelup or leveldown). 91 | 92 | Only the `db.batch()` and `db.get()` interfaces of leveldb are used with no 93 | custom value encoding, so you can use any interface that supports these methods. 94 | 95 | Optionally pass in a custom `opts.delim`. The default is `','`. This delimiter 96 | is used to separate document ids. 97 | 98 | You can pass in a function as `opts.onremove` that will be called with an array 99 | of string keys after those keys are removed from the database due to linking. 100 | 101 | You can pass in a function as `opts.onupdate` that will be called after every 102 | batch is written with an object mapping keys to arrays of ids which represent 103 | the new values you would obtain from `get()` for that key. This is useful for 104 | implementing live queries or subscriptions. 105 | 106 | ## kv.batch(rows, cb) 107 | 108 | Write an array of `rows` into the `kv`. Each `row` in the `rows` array has: 109 | 110 | * `row.key` - string key to use 111 | * `row.id` - unique id string of this record 112 | * `row.links` - array of id string ancestor links 113 | 114 | ## kv.get(key, cb) 115 | 116 | Lookup the array of ids that map to a given string `key` as `cb(err, ids)`. 117 | 118 | [leveldb]: https://github.com/Level/level 119 | 120 | ## kv.isLinked(key, cb) 121 | 122 | Test if a `key` is linked to as `cb(err, exists)` for a boolean `exists`. 123 | 124 | This routine is used internally but you can use this method to save having to 125 | duplicate this logic in your own unordered materialized view. 126 | 127 | # license 128 | 129 | BSD 130 | -------------------------------------------------------------------------------- /test/kv.js: -------------------------------------------------------------------------------- 1 | var test = require('tape') 2 | var memdb = require('memdb') 3 | var umkv = require('../') 4 | 5 | test('single-key linear in-order batch', function (t) { 6 | t.plan(3) 7 | var kv = umkv(memdb()) 8 | var docs = [ 9 | { id: 'a', key: 'cool', links: [] }, 10 | { id: 'b', key: 'cool', links: ['a'] }, 11 | { id: 'c', key: 'cool', links: ['b'] }, 12 | { id: 'd', key: 'cool', links: ['c'] } 13 | ] 14 | kv.batch(docs, function (err) { 15 | t.error(err) 16 | kv.get('cool', function (err, ids) { 17 | t.error(err) 18 | t.deepEqual(ids, ['d']) 19 | }) 20 | }) 21 | }) 22 | 23 | test('single-key forked in-order individual inserts', function (t) { 24 | t.plan(6) 25 | var kv = umkv(memdb()) 26 | var docs = [ 27 | { id: 'a', key: 'cool', links: [] }, 28 | { id: 'b', key: 'cool', links: ['a'] }, 29 | { id: 'c', key: 'cool', links: ['b'] }, 30 | { id: 'd', key: 'cool', links: ['b'] } 31 | ] 32 | ;(function next (i) { 33 | if (i === docs.length) { 34 | return kv.get('cool', function (err, ids) { 35 | t.error(err) 36 | t.deepEqual(ids.sort(), ['c','d']) 37 | }) 38 | } 39 | kv.batch([docs[i]], function (err) { 40 | t.error(err) 41 | next(i+1) 42 | }) 43 | })(0) 44 | }) 45 | 46 | test('single-key forked in-order batch', function (t) { 47 | t.plan(3) 48 | var kv = umkv(memdb()) 49 | var docs = [ 50 | { id: 'a', key: 'cool', links: [] }, 51 | { id: 'b', key: 'cool', links: ['a'] }, 52 | { id: 'c', key: 'cool', links: ['b'] }, 53 | { id: 'd', key: 'cool', links: ['b'] } 54 | ] 55 | kv.batch(docs, function (err) { 56 | t.error(err) 57 | kv.get('cool', function (err, ids) { 58 | t.error(err) 59 | t.deepEqual(ids.sort(), ['c','d']) 60 | }) 61 | }) 62 | }) 63 | 64 | test('single-key forked unordered batch', function (t) { 65 | t.plan(3) 66 | var kv = umkv(memdb()) 67 | var docs = [ 68 | { id: 'c', key: 'cool', links: ['b'] }, 69 | { id: 'd', key: 'cool', links: ['b'] }, 70 | { id: 'a', key: 'cool', links: [] }, 71 | { id: 'b', key: 'cool', links: ['a'] } 72 | ] 73 | kv.batch(docs, function (err) { 74 | t.error(err) 75 | kv.get('cool', function (err, ids) { 76 | t.error(err) 77 | t.deepEqual(ids.sort(), ['c','d']) 78 | }) 79 | }) 80 | }) 81 | 82 | test('single-key forked unordered batch merge', function (t) { 83 | t.plan(3) 84 | var kv = umkv(memdb()) 85 | var docs = [ 86 | { id: 'e', key: 'cool', links: ['c','d'] }, 87 | { id: 'c', key: 'cool', links: ['b'] }, 88 | { id: 'd', key: 'cool', links: ['b'] }, 89 | { id: 'a', key: 'cool', links: [] }, 90 | { id: 'b', key: 'cool', links: ['a'] } 91 | ] 92 | kv.batch(docs, function (err) { 93 | t.error(err) 94 | kv.get('cool', function (err, ids) { 95 | t.error(err) 96 | t.deepEqual(ids.sort(), ['e']) 97 | }) 98 | }) 99 | }) 100 | 101 | test('multi-key forked unordered batch merge', function (t) { 102 | t.plan(5) 103 | var kv = umkv(memdb()) 104 | var docs = [ 105 | { id: 'z', key: 'hey', links: ['x'] }, 106 | { id: 'e', key: 'cool', links: ['c','d'] }, 107 | { id: 'c', key: 'cool', links: ['b'] }, 108 | { id: 'd', key: 'cool', links: ['b'] }, 109 | { id: 'q', key: 'hey', links: ['y','z'] }, 110 | { id: 'y', key: 'hey', links: ['x'] }, 111 | { id: 'a', key: 'cool', links: [] }, 112 | { id: 'x', key: 'hey', links: ['x'] }, 113 | { id: 'b', key: 'cool', links: ['a'] } 114 | ] 115 | kv.batch(docs, function (err) { 116 | t.error(err) 117 | kv.get('cool', function (err, ids) { 118 | t.error(err) 119 | t.deepEqual(ids.sort(), ['e']) 120 | }) 121 | kv.get('hey', function (err, ids) { 122 | t.error(err) 123 | t.deepEqual(ids.sort(), ['q']) 124 | }) 125 | }) 126 | }) 127 | 128 | test('multi-key forked unordered individual inserts merge', function (t) { 129 | t.plan(13) 130 | var kv = umkv(memdb()) 131 | var docs = [ 132 | { id: 'z', key: 'hey', links: ['x'] }, 133 | { id: 'e', key: 'cool', links: ['c','d'] }, 134 | { id: 'c', key: 'cool', links: ['b'] }, 135 | { id: 'd', key: 'cool', links: ['b'] }, 136 | { id: 'q', key: 'hey', links: ['y','z'] }, 137 | { id: 'y', key: 'hey', links: ['x'] }, 138 | { id: 'a', key: 'cool', links: [] }, 139 | { id: 'x', key: 'hey', links: ['x'] }, 140 | { id: 'b', key: 'cool', links: ['a'] } 141 | ] 142 | ;(function next (i) { 143 | if (i === docs.length) { 144 | kv.get('cool', function (err, ids) { 145 | t.error(err) 146 | t.deepEqual(ids.sort(), ['e']) 147 | }) 148 | kv.get('hey', function (err, ids) { 149 | t.error(err) 150 | t.deepEqual(ids.sort(), ['q']) 151 | }) 152 | return 153 | } 154 | kv.batch([docs[i]], function (err) { 155 | t.error(err) 156 | next(i+1) 157 | }) 158 | })(0) 159 | }) 160 | 161 | test('onremove', function (t) { 162 | t.plan(4) 163 | var kv = umkv(memdb(), { 164 | onremove: function (ids) { 165 | t.deepEqual(ids.sort(), ['a','b','c','d']) 166 | } 167 | }) 168 | var docs = [ 169 | { id: 'e', key: 'cool', links: ['c','d'] }, 170 | { id: 'c', key: 'cool', links: ['b'] }, 171 | { id: 'd', key: 'cool', links: ['b'] }, 172 | { id: 'a', key: 'cool', links: [] }, 173 | { id: 'b', key: 'cool', links: ['a'] } 174 | ] 175 | kv.batch(docs, function (err) { 176 | t.error(err) 177 | kv.get('cool', function (err, ids) { 178 | t.error(err) 179 | t.deepEqual(ids.sort(), ['e']) 180 | }) 181 | }) 182 | }) 183 | 184 | test('onupdate with 1 key', function (t) { 185 | t.plan(4) 186 | var kv = umkv(memdb(), { 187 | onupdate: function (update) { 188 | t.deepEqual(update, { 189 | cool: ['e'] 190 | }) 191 | } 192 | }) 193 | var docs = [ 194 | { id: 'e', key: 'cool', links: ['c','d'] }, 195 | { id: 'c', key: 'cool', links: ['b'] }, 196 | { id: 'd', key: 'cool', links: ['b'] }, 197 | { id: 'a', key: 'cool', links: [] }, 198 | { id: 'b', key: 'cool', links: ['a'] } 199 | ] 200 | kv.batch(docs, function (err) { 201 | t.error(err) 202 | kv.get('cool', function (err, ids) { 203 | t.error(err) 204 | t.deepEqual(ids.sort(), ['e']) 205 | }) 206 | }) 207 | }) 208 | 209 | test('onupdate with many keys', function (t) { 210 | t.plan(6) 211 | var kv = umkv(memdb(), { 212 | onupdate: function (update) { 213 | t.deepEqual(update, { 214 | cool: ['e'], 215 | hey: ['q'] 216 | }) 217 | } 218 | }) 219 | var docs = [ 220 | { id: 'z', key: 'hey', links: ['x'] }, 221 | { id: 'e', key: 'cool', links: ['c','d'] }, 222 | { id: 'c', key: 'cool', links: ['b'] }, 223 | { id: 'd', key: 'cool', links: ['b'] }, 224 | { id: 'q', key: 'hey', links: ['y','z'] }, 225 | { id: 'y', key: 'hey', links: ['x'] }, 226 | { id: 'a', key: 'cool', links: [] }, 227 | { id: 'x', key: 'hey', links: ['x'] }, 228 | { id: 'b', key: 'cool', links: ['a'] } 229 | ] 230 | kv.batch(docs, function (err) { 231 | t.error(err) 232 | kv.get('cool', function (err, ids) { 233 | t.error(err) 234 | t.deepEqual(ids.sort(), ['e']) 235 | }) 236 | kv.get('hey', function (err, ids) { 237 | t.error(err) 238 | t.deepEqual(ids.sort(), ['q']) 239 | }) 240 | }) 241 | }) 242 | -------------------------------------------------------------------------------- /test/random.js: -------------------------------------------------------------------------------- 1 | var test = require('tape') 2 | var memdb = require('memdb') 3 | var umkv = require('../') 4 | 5 | test('big random graph', function (t) { 6 | var N = 1000 7 | t.plan(N + 26*2) 8 | var kv = umkv(memdb()) 9 | var keys = [] 10 | var ids = [] 11 | var lastId = 0 12 | for (var i = 0; i < 26; i++) { 13 | keys.push(String.fromCharCode(97+i)) 14 | } 15 | var store = {}, pending = 1 16 | for (var i = 0; i < N; i++) { 17 | var batch = [] 18 | var len = Math.floor(Math.random()*100) 19 | for (var j = 0; j < len; j++) { 20 | var key = keys[Math.floor(Math.random()*keys.length)] 21 | var links = [] 22 | var id = lastId++ 23 | if (!store[key]) store[key] = [] 24 | var n = Math.floor(Math.random()*store[key].length+0.8) 25 | for (var k = 0; k < n; k++) { 26 | links.push(store[key][k]) 27 | } 28 | ids.push(id) 29 | batch.push({ 30 | id: String(id), 31 | key: key, 32 | links: links 33 | }) 34 | store[key].push(String(id)) 35 | for (var k = 0; k < links.length; k++) { 36 | var ix = store[key].indexOf(links[k]) 37 | if (ix >= 0) store[key].splice(ix,1) 38 | } 39 | } 40 | pending++ 41 | kv.batch(batch, function (err) { 42 | t.error(err) 43 | if (--pending === 0) check() 44 | }) 45 | } 46 | if (--pending === 0) check() 47 | 48 | function check () { 49 | keys.sort() 50 | ;(function next (n) { 51 | if (n === keys.length) return 52 | var key = keys[n] 53 | kv.get(key, function (err, ids) { 54 | t.error(err) 55 | t.deepEqual(ids.sort(), store[key].sort()) 56 | next(n+1) 57 | }) 58 | })(0) 59 | } 60 | }) 61 | --------------------------------------------------------------------------------