├── .gitignore ├── .jshintrc ├── .travis.yml ├── .zuul.yml ├── LICENSE ├── README.md ├── lib ├── database-core.js ├── database.js ├── index.js ├── taskqueue.js └── utils.js ├── package.json └── tests ├── custom-tests.js ├── test.js └── testCommon.js /.gitignore: -------------------------------------------------------------------------------- 1 | lib-cov 2 | *.seed 3 | *.log 4 | *.csv 5 | *.dat 6 | *.out 7 | *.pid 8 | *.gz 9 | 10 | pids 11 | logs 12 | results 13 | 14 | npm-debug.log 15 | node_modules 16 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { "maxlen": 100, 2 | "curly": true, 3 | "eqeqeq": true, 4 | "immed": true, 5 | "newcap": true, 6 | "noarg": true, 7 | "sub": true, 8 | "undef": true, 9 | "unused": "vars", 10 | "eqnull": true, 11 | "browser": true, 12 | "node": true, 13 | "strict": true, 14 | "trailing": true, 15 | "globalstrict": true, 16 | "white": true, 17 | "indent": 2, 18 | "forin": true 19 | } 20 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - stable 5 | 6 | git: 7 | depth: 10 8 | 9 | addons: 10 | firefox: "41.0.1" 11 | sauce_connect: true 12 | 13 | script: npm run $COMMAND 14 | 15 | before_script: 16 | - "export DISPLAY=:99.0" 17 | - "sh -e /etc/init.d/xvfb start" 18 | 19 | env: 20 | global: 21 | - secure: "G1qQhww+f9uQmmXGEyUEEip/wged9mkkAtg4IoywuGP9wo0mSkSiIOJ8i1o9H2RCV2YNBBwt0RbaI6g1N5QvhWt9hbm99rQZCgAW2loTLPDtod4FaM07gc7SlrlLwKkSe1v6+b3fO4BmwLk7C1dYMLek1aGaWNEEGXjUpuNuw3JN4+BzNtLbWEnKqUS9bhEdrmoRXk+nuQiR26Tg02ZcuGTMAVSAWvvzBdZacN8Tu/Tveq6MUay/P6CBE8ovoHUAyXio++0mbWZubq9zC6DvSMAMCe0tbUGKar1dtH7jWQnC1gW40SUcDunBkdjFts297JbdeXj+IEv/Sd869Un3c6zjmFY4w65LCAb9QDYVOxZU+1lVvTRyR5oQ7rA6mS815YcgNGdqSZMdhZxGvmJyiSaSHSG7emWkz75Xt5ZHE6H7J79JAaxT3DHLWeTVwr1excJum3H5i2SPCv3nBPI+o6zSif+6oqU+VbPoFIoFhAkjtrP2qPB0WFteDUaX1NpBRe0MgaZeiwcQPxMx4+L1TOotO93Ecyy0Ch2ALc60sYK3fjEFEnsN/6aS4K5Awcoi05VYnif3aTaFHOvuyt5sfydeM8KyQEqvnbowSsHpsb0oWU3cmVpooxeuO85mQFgeZJnSt2YpncDSC348tBja5J1DM24wA7RT7lXtbGW4B2E=" 22 | - secure: "NNcrDHNrO34CMJLOqDhs35rtj1ddOsBPQ+gN9VZZQnU+9v3CjOKB6MXNmNLDpupz3MTTwYa4dGOA37cjqKA1lE9v4pGaAyi6Xm8QWR8ZxFrFXli5oPfqLK/sLEsdHMvoenEucckZ0NshkTqatK+lrl/n8y06fWCvl+diIMxlvaWDb7tVVYZW97OmPCic8XoHU9VNAdOOshu6jjJQVKgGJ3ldX02/VcVtIT5ld0p2ipnPn5RTQzFu4YsGPR+tX4lJr9mDHyS8OtYabuPnad2npymUKddnGZmSEFe/3J1SxGeJWmNr4iJerC8bWWxw6NFNOvXElfqQOU0R99NbfMVpw+O2WnJg3pM9JR2QDn/gGXZHAEHYc9o4FJluJnxEpYY7hLqs/2fPkAFTDWflkMMnAJnWX/lO42/YO/rC1Z87OT0eWgQ+i6N/W8rHJyQGqRKS5JTf8s3MJGM2835DItyKihEeLxpqibtRj5EszhN0vWqKBRCf2L5JX58yp8JUQcm5sAjElfuNaWKClf4oK3FqmzhDGSojK2Tnv3eZkLwqEqAwwzDwGUMiqre00YGXT+Rn135b8Wp2az5yb1HcPNmS97UuKH6y+Ok4CL13pw+W6KuhCRcxY0VqVUMXw7cWjE4GGNYXwbStZnpnb3AYerf9rzBWidHd22uVhGCfBQIwIpY=" 23 | 24 | matrix: 25 | - BROWSER=firefox COMMAND=test 26 | - COMMAND=test-fakeindexeddb 27 | - COMMAND=test-phantom 28 | - COMMAND=test-saucelabs BROWSER_NAME=iphone BROWSER_VERSION="8.0..latest" 29 | - COMMAND=test-saucelabs BROWSER_NAME=safari BROWSER_VERSION="7..latest" 30 | - COMMAND=test-saucelabs BROWSER_NAME=android BROWSER_VERSION="4.4..latest" 31 | - COMMAND=test-saucelabs BROWSER_NAME=ie BROWSER_VERSION="10..latest" 32 | - COMMAND=test-saucelabs BROWSER_NAME=firefox BROWSER_VERSION="38..latest" 33 | - COMMAND=test-saucelabs BROWSER_NAME=chrome BROWSER_VERSION="42..beta" 34 | branches: 35 | only: 36 | - master 37 | -------------------------------------------------------------------------------- /.zuul.yml: -------------------------------------------------------------------------------- 1 | ui: tape 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Nolan Lawson and 2014 Anton Whalley 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FruitDOWN ![project unmaintained](https://img.shields.io/badge/project-unmaintained-red.svg) [![Build Status](https://travis-ci.org/nolanlawson/fruitdown.svg?branch=master)](https://travis-ci.org/nolanlawson/fruitdown) 2 | 3 | A browser-based LevelDOWN adapter that works over all implementations of IndexedDB, including Apple's buggy version. 4 | 5 | ⚠️⚠️⚠️ 6 | 7 | **Update:** Safari has largely fixed their IndexedDB issues, starting around Safari 10.1. If you are supporting recent versions of iOS and Safari (iOS >=10.3 and Safari >=10.1 roughly) then you do not need `fruitdown`. It is now unmaintained. 8 | 9 | Original documentation follows: 10 | 11 | ⚠️⚠️⚠️ 12 | 13 | This is designed for environments where you can't use WebSQL as a polyfill for Safari browsers, such as: 14 | 15 | * WKWebView, which [doesn't have WebSQL](https://bugs.webkit.org/show_bug.cgi?id=137760) 16 | * Safari/iOS, but you don't want [an annoying popup](http://pouchdb.com/errors.html#not_enough_space) after you reach 5MB 17 | * iOS, but you need to store more than 50MB, which [works in IndexedDB](https://github.com/nolanlawson/database-filler) but [not in WebSQL](http://www.html5rocks.com/en/tutorials/offline/quota-research/) 18 | 19 | This project is intended for use with the [Level ecosystem](https://github.com/level/), including as a [PouchDB adapter](http://pouchdb.com/adapters.html#pouchdb_in_the_browser). 20 | 21 | ## Install 22 | 23 | ``` 24 | npm install fruitdown 25 | ``` 26 | 27 | ## Background 28 | 29 | IndexedDB support is pretty awful these days. Every browser except for Chrome and Firefox has tons of bugs, but Safari's are arguably [the](https://gist.github.com/nolanlawson/08eb857c6b17a30c1b26) [worst](http://www.raymondcamden.com/2014/09/25/IndexedDB-on-iOS-8-Broken-Bad). While there are well-known workarounds for [Microsoft's bugs](https://gist.github.com/nolanlawson/a841ee23436410f37168), most IndexedDB wrappers just gave up and didn't support Apple IndexedDB. [PouchDB](http://pouchdb.com), [LocalForage](http://mozilla.github.io/localForage/), [YDN-DB](http://dev.yathit.com/ydn-db/downloads.html), [Lovefield](https://github.com/google/lovefield), [Dexie](http://dexie.org/), and [Level.js](https://github.com/maxogden/level.js) all either fall back to WebSQL or recommend that you use the [IndexedDBShim](https://github.com/axemclion/IndexedDBShim). 30 | 31 | This library is different. It does all the weird backflips you have to do to support Apple IndexedDB. 32 | 33 | ## Design 34 | 35 | This project is a fork of [localstorage-down](https://github.com/No9/localstorage-down). It uses a tiny subset of the IndexedDB API – just those parts that are supported in Firefox, Chrome, Safari, and IE. The #1 goal is compatibility with as many browsers as possible. The #2 goal is performance. 36 | 37 | Only one object store is ever opened, because Apple's implementation [does not allow you to open more than one at once](https://bugs.webkit.org/show_bug.cgi?id=136937). So presumably you would use something like [level-sublevel](https://github.com/dominictarr/level-sublevel/) to prefix keys. Also every operation is its own transaction, so you should not count on standard IndexedDB transaction guarantees, even when you use `batch()`. However, internally the lib does its own batching, and supports [snapshots](https://github.com/Level/leveldown#snapshots). 38 | 39 | All keys are kept in memory, which is bad for memory usage but actually a win for performance, since IDBCursors are slow. However, the database creates two indexes, because 1) the primary index does not support `openKeyCursor()` per the IndexedDB 1.0 spec, and we want to use it to avoid reading in large values during key iteration, but 2) secondary indexes [do not correctly throw ConstraintErrors in Safari](https://bugs.webkit.org/show_bug.cgi?id=149107). So unfortunately there's a superfluous extra index. ¯\\\_(ツ)\_/¯ 40 | 41 | Another limitation is that both keys and values are converted to strings before being stored. So instead of efficiently using Blobs or even JSON objects, binary strings are stored instead. This is okay, though, because Chrome < 43 (and therefore pre-Lollipop Android) [does not store Blobs correctly](https://code.google.com/p/chromium/issues/detail?id=447836), and Safari [doesn't support Blob storage either](https://bugs.webkit.org/show_bug.cgi?id=143193). 42 | 43 | To avoid [concurrency bugs in IE/Edge](https://gist.github.com/nolanlawson/a841ee23436410f37168), this project borrows PouchDB's system of maintaining a global cache of databases and only ever using one database per name. It also works around a [bug with key ordering](https://bugs.webkit.org/show_bug.cgi?id=149205) in Safari. 44 | 45 | ## Browser support 46 | 47 | FruitDOWN supports [any browser that has IndexedDB](http://caniuse.com/#feat=indexeddb), even those with partial support. Notably: 48 | 49 | * Safari 7.1+ 50 | * iOS 8+ 51 | * IE 10+ 52 | * Chrome 23+ 53 | * Firefox 10+ 54 | * Android 4.4+ 55 | 56 | The buggy [Samsung/HTC IndexedDB variants](https://github.com/pouchdb/pouchdb/issues/1207) based on an older version of the IndexedDB spec, which you will occasionally find in Android 4.3, are not supported. 57 | 58 | See [.travis.yml](https://github.com/nolanlawson/fruitdown/blob/master/.travis.yml#L25-L33) for the full list of browsers that are tested in CI. 59 | 60 | ## Future 61 | 62 | Apple have [pledged to fix IndexedDB](https://twitter.com/grorgwork/status/618152677281697792). When they do, you should stop using this library and use [Level.js](https://github.com/maxogden/level.js) or another IndexedDB wrapper instead. 63 | 64 | 65 | ## Tests 66 | 67 | npm run dev 68 | 69 | Browse to [http://localhost:9966](http://localhost:9966). 70 | View console logs in the browser to see test output. 71 | 72 | ## Automated tests 73 | 74 | Browser: 75 | 76 | 77 | BROWSER=firefox npm test 78 | BROWSER=chrome npm test 79 | 80 | [FakeIndexedDB](https://github.com/dumbmatter/fakeIndexedDB) in Node: 81 | 82 | 83 | npm run test-fakeindexeddb 84 | 85 | PhantomJS tests: 86 | 87 | npm run test-phantom 88 | 89 | ## Thanks 90 | 91 | Thanks to [Anton Whalley](https://github.com/no9), [Adam Shih](https://github.com/adamshih) and everybody else who contributed to localstorage-down. Also thanks to everybody who worked on PouchDB, where most of these IndexedDB bugs were discovered. 92 | -------------------------------------------------------------------------------- /lib/database-core.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // 4 | // Class that should contain everything necessary to interact 5 | // with IndexedDB as a generic key-value store (based on LocalStorage). 6 | // 7 | 8 | /* global indexedDB */ 9 | 10 | var STORE = 'fruitdown'; 11 | 12 | // see http://stackoverflow.com/a/15349865/680742 13 | var nextTick = global.setImmediate || process.nextTick; 14 | 15 | // IE has race conditions, which is what these two caches work around 16 | // https://gist.github.com/nolanlawson/a841ee23436410f37168 17 | var cachedDBs = {}; 18 | var openReqList = {}; 19 | 20 | function StorageCore(dbName) { 21 | this._dbName = dbName; 22 | } 23 | 24 | function getDatabase(dbName, callback) { 25 | if (cachedDBs[dbName]) { 26 | return nextTick(function () { 27 | callback(null, cachedDBs[dbName]); 28 | }); 29 | } 30 | 31 | var req = indexedDB.open(dbName, 1); 32 | 33 | openReqList[dbName] = req; 34 | 35 | req.onupgradeneeded = function (e) { 36 | var db = e.target.result; 37 | 38 | // Apple migration bug (https://bugs.webkit.org/show_bug.cgi?id=136888) 39 | // This will be null the first time rather than 0, so check for 1 40 | if (e.oldVersion === 1) { 41 | return; 42 | } 43 | 44 | // Create a superfluous secondary index purely so we can do 45 | // openKeyCursor(). (Limitation of the IndexedDB API, fixed in v2.0.) 46 | // We would also use this for detecting ConstraintErrors, but that 47 | // doesn't work in Safari: https://bugs.webkit.org/show_bug.cgi?id=149107 48 | db.createObjectStore(STORE).createIndex('fakeKey', 'fakeKey'); 49 | 50 | }; 51 | 52 | req.onsuccess = function (e) { 53 | var db = cachedDBs[dbName] = e.target.result; 54 | callback(null, db); 55 | }; 56 | 57 | req.onerror = function(e) { 58 | var msg = 'Failed to open indexedDB, are you in private browsing mode?'; 59 | console.error(msg); 60 | callback(e); 61 | }; 62 | } 63 | 64 | function openTransactionSafely(db, mode) { 65 | try { 66 | return { 67 | txn: db.transaction(STORE, mode) 68 | }; 69 | } catch (err) { 70 | return { 71 | error: err 72 | }; 73 | } 74 | } 75 | 76 | StorageCore.prototype.getKeys = function (callback) { 77 | getDatabase(this._dbName, function (err, db) { 78 | if (err) { 79 | return callback(err); 80 | } 81 | var txnRes = openTransactionSafely(db, 'readonly'); 82 | if (txnRes.error) { 83 | return callback(txnRes.error); 84 | } 85 | var txn = txnRes.txn; 86 | var store = txn.objectStore(STORE); 87 | 88 | txn.onerror = callback; 89 | 90 | var keys = []; 91 | txn.oncomplete = function () { 92 | // Safari has a bug where these keys aren't returned in sorted 93 | // order, so we have to sort them explicitly 94 | // https://bugs.webkit.org/show_bug.cgi?id=149205 95 | callback(null, keys.sort()); 96 | }; 97 | 98 | // using openKeyCursor avoids reading in the whole value, 99 | // which may be large 100 | var req = store.index('fakeKey').openKeyCursor(); 101 | 102 | req.onsuccess = function (e) { 103 | var cursor = e.target.result; 104 | if (!cursor) { 105 | return; 106 | } 107 | keys.push(cursor.primaryKey); 108 | cursor.continue(); 109 | }; 110 | }); 111 | }; 112 | 113 | StorageCore.prototype.put = function (key, value, callback) { 114 | getDatabase(this._dbName, function (err, db) { 115 | if (err) { 116 | return callback(err); 117 | } 118 | var txnRes = openTransactionSafely(db, 'readwrite'); 119 | if (txnRes.error) { 120 | return callback(txnRes.error); 121 | } 122 | var txn = txnRes.txn; 123 | var store = txn.objectStore(STORE); 124 | 125 | var valueToStore = typeof value === 'string' ? value : value.toString(); 126 | 127 | txn.onerror = callback; 128 | txn.oncomplete = function () { 129 | callback(); 130 | }; 131 | 132 | store.put({value: valueToStore, fakeKey: 0}, key); 133 | }); 134 | }; 135 | 136 | StorageCore.prototype.get = function (key, callback) { 137 | getDatabase(this._dbName, function (err, db) { 138 | if (err) { 139 | return callback(err); 140 | } 141 | var txnRes = openTransactionSafely(db, 'readonly'); 142 | if (txnRes.error) { 143 | return callback(txnRes.error); 144 | } 145 | var txn = txnRes.txn; 146 | var store = txn.objectStore(STORE); 147 | 148 | var gotten; 149 | var req = store.get(key); 150 | req.onsuccess = function (e) { 151 | if (e.target.result) { 152 | gotten = e.target.result.value; 153 | } 154 | }; 155 | 156 | txn.onerror = callback; 157 | txn.oncomplete = function () { 158 | callback(null, gotten); 159 | }; 160 | }); 161 | }; 162 | 163 | StorageCore.prototype.remove = function (key, callback) { 164 | getDatabase(this._dbName, function (err, db) { 165 | if (err) { 166 | return callback(err); 167 | } 168 | var txnRes = openTransactionSafely(db, 'readwrite'); 169 | if (txnRes.error) { 170 | return callback(txnRes.error); 171 | } 172 | var txn = txnRes.txn; 173 | var store = txn.objectStore(STORE); 174 | 175 | store.delete(key); 176 | 177 | txn.onerror = callback; 178 | txn.oncomplete = function () { 179 | callback(); 180 | }; 181 | }); 182 | }; 183 | 184 | StorageCore.destroy = function (dbName, callback) { 185 | nextTick(function () { 186 | //Close open request for "dbName" database to fix ie delay. 187 | if (openReqList[dbName] && openReqList[dbName].result) { 188 | openReqList[dbName].result.close(); 189 | delete cachedDBs[dbName]; 190 | } 191 | var req = indexedDB.deleteDatabase(dbName); 192 | 193 | req.onsuccess = function () { 194 | //Remove open request from the list. 195 | if (openReqList[dbName]) { 196 | openReqList[dbName] = null; 197 | } 198 | callback(null); 199 | }; 200 | 201 | req.onerror = callback; 202 | }); 203 | }; 204 | 205 | module.exports = StorageCore; 206 | -------------------------------------------------------------------------------- /lib/database.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // ArrayBuffer/Uint8Array are old formats that date back to before we 4 | // had a proper browserified buffer type. they may be removed later 5 | var arrayBuffPrefix = 'ArrayBuffer:'; 6 | var arrayBuffRegex = new RegExp('^' + arrayBuffPrefix); 7 | var uintPrefix = 'Uint8Array:'; 8 | var uintRegex = new RegExp('^' + uintPrefix); 9 | 10 | // this is the new encoding format used going forward 11 | var bufferPrefix = 'Buff:'; 12 | var bufferRegex = new RegExp('^' + bufferPrefix); 13 | 14 | var utils = require('./utils'); 15 | var DatabaseCore = require('./database-core'); 16 | var TaskQueue = require('./taskqueue'); 17 | var d64 = require('d64'); 18 | 19 | function Database(dbname) { 20 | this._store = new DatabaseCore(dbname); 21 | this._queue = new TaskQueue(); 22 | } 23 | 24 | Database.prototype.sequentialize = function (callback, fun) { 25 | this._queue.add(fun, callback); 26 | }; 27 | 28 | Database.prototype.init = function (callback) { 29 | var self = this; 30 | self.sequentialize(callback, function (callback) { 31 | self._store.getKeys(function (err, keys) { 32 | if (err) { 33 | return callback(err); 34 | } 35 | self._keys = keys; 36 | return callback(); 37 | }); 38 | }); 39 | }; 40 | 41 | Database.prototype.keys = function (callback) { 42 | var self = this; 43 | self.sequentialize(callback, function (callback) { 44 | callback(null, self._keys.slice()); 45 | }); 46 | }; 47 | 48 | //setItem: Saves and item at the key provided. 49 | Database.prototype.setItem = function (key, value, callback) { 50 | var self = this; 51 | self.sequentialize(callback, function (callback) { 52 | if (Buffer.isBuffer(value)) { 53 | value = bufferPrefix + d64.encode(value); 54 | } 55 | 56 | var idx = utils.sortedIndexOf(self._keys, key); 57 | if (self._keys[idx] !== key) { 58 | self._keys.splice(idx, 0, key); 59 | } 60 | self._store.put(key, value, callback); 61 | }); 62 | }; 63 | 64 | //getItem: Returns the item identified by it's key. 65 | Database.prototype.getItem = function (key, callback) { 66 | var self = this; 67 | self.sequentialize(callback, function (callback) { 68 | self._store.get(key, function (err, retval) { 69 | if (err) { 70 | return callback(err); 71 | } 72 | if (typeof retval === 'undefined' || retval === null) { 73 | // 'NotFound' error, consistent with LevelDOWN API 74 | return callback(new Error('NotFound')); 75 | } 76 | if (typeof retval !== 'undefined') { 77 | if (bufferRegex.test(retval)) { 78 | retval = d64.decode(retval.substring(bufferPrefix.length)); 79 | } else if (arrayBuffRegex.test(retval)) { 80 | // this type is kept for backwards 81 | // compatibility with older databases, but may be removed 82 | // after a major version bump 83 | retval = retval.substring(arrayBuffPrefix.length); 84 | retval = new ArrayBuffer(atob(retval).split('').map(function (c) { 85 | return c.charCodeAt(0); 86 | })); 87 | } else if (uintRegex.test(retval)) { 88 | // ditto 89 | retval = retval.substring(uintPrefix.length); 90 | retval = new Uint8Array(atob(retval).split('').map(function (c) { 91 | return c.charCodeAt(0); 92 | })); 93 | } 94 | } 95 | callback(null, retval); 96 | }); 97 | }); 98 | }; 99 | 100 | //removeItem: Removes the item identified by it's key. 101 | Database.prototype.removeItem = function (key, callback) { 102 | var self = this; 103 | self.sequentialize(callback, function (callback) { 104 | var idx = utils.sortedIndexOf(self._keys, key); 105 | if (self._keys[idx] === key) { 106 | self._keys.splice(idx, 1); 107 | self._store.remove(key, function (err) { 108 | if (err) { 109 | return callback(err); 110 | } 111 | callback(); 112 | }); 113 | } else { 114 | callback(); 115 | } 116 | }); 117 | }; 118 | 119 | Database.prototype.length = function (callback) { 120 | var self = this; 121 | self.sequentialize(callback, function (callback) { 122 | callback(null, self._keys.length); 123 | }); 124 | }; 125 | 126 | module.exports = Database; 127 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var inherits = require('inherits'); 4 | var AbstractLevelDOWN = require('abstract-leveldown').AbstractLevelDOWN; 5 | var AbstractIterator = require('abstract-leveldown').AbstractIterator; 6 | 7 | var Database = require('./database'); 8 | var DatabaseCore = require('./database-core'); 9 | var utils = require('./utils'); 10 | 11 | // see http://stackoverflow.com/a/15349865/680742 12 | var nextTick = global.setImmediate || process.nextTick; 13 | 14 | function DatabaseIterator(db, options) { 15 | 16 | AbstractIterator.call(this, db); 17 | 18 | this._reverse = !!options.reverse; 19 | this._endkey = options.end; 20 | this._startkey = options.start; 21 | this._gt = options.gt; 22 | this._gte = options.gte; 23 | this._lt = options.lt; 24 | this._lte = options.lte; 25 | this._exclusiveStart = options.exclusiveStart; 26 | this._limit = options.limit; 27 | this._count = 0; 28 | 29 | this.onInitCompleteListeners = []; 30 | } 31 | 32 | inherits(DatabaseIterator, AbstractIterator); 33 | 34 | DatabaseIterator.prototype._init = function (callback) { 35 | nextTick(function () { 36 | callback(); 37 | }); 38 | }; 39 | 40 | DatabaseIterator.prototype._next = function (callback) { 41 | var self = this; 42 | 43 | function onInitComplete() { 44 | if (self._pos === self._keys.length || self._pos < 0) { // done reading 45 | return callback(); 46 | } 47 | 48 | var key = self._keys[self._pos]; 49 | 50 | if (!!self._endkey && (self._reverse ? key < self._endkey : key > self._endkey)) { 51 | return callback(); 52 | } 53 | 54 | if (!!self._limit && self._limit > 0 && self._count++ >= self._limit) { 55 | return callback(); 56 | } 57 | 58 | if ((self._lt && key >= self._lt) || 59 | (self._lte && key > self._lte) || 60 | (self._gt && key <= self._gt) || 61 | (self._gte && key < self._gte)) { 62 | return callback(); 63 | } 64 | 65 | self._pos += self._reverse ? -1 : 1; 66 | self.db.container.getItem(key, function (err, value) { 67 | if (err) { 68 | if (err.message === 'NotFound') { 69 | return nextTick(function () { 70 | self._next(callback); 71 | }); 72 | } 73 | return callback(err); 74 | } 75 | callback(null, key, value); 76 | }); 77 | } 78 | if (!self.initStarted) { 79 | self.initStarted = true; 80 | self._init(function (err) { 81 | if (err) { 82 | return callback(err); 83 | } 84 | self.db.container.keys(function (err, keys) { 85 | if (err) { 86 | return callback(err); 87 | } 88 | self._keys = keys; 89 | if (self._startkey) { 90 | var index = utils.sortedIndexOf(self._keys, self._startkey); 91 | var startkey = (index >= self._keys.length || index < 0) ? 92 | undefined : self._keys[index]; 93 | self._pos = index; 94 | if (self._reverse) { 95 | if (self._exclusiveStart || startkey !== self._startkey) { 96 | self._pos--; 97 | } 98 | } else if (self._exclusiveStart && startkey === self._startkey) { 99 | self._pos++; 100 | } 101 | } else { 102 | self._pos = self._reverse ? self._keys.length - 1 : 0; 103 | } 104 | onInitComplete(); 105 | 106 | self.initCompleted = true; 107 | var i = -1; 108 | while (++i < self.onInitCompleteListeners) { 109 | nextTick(self.onInitCompleteListeners[i]); 110 | } 111 | }); 112 | }); 113 | } else if (!self.initCompleted) { 114 | self.onInitCompleteListeners.push(onInitComplete); 115 | } else { 116 | onInitComplete(); 117 | } 118 | }; 119 | 120 | function FruitDown(location) { 121 | if (!(this instanceof FruitDown)) { 122 | return new FruitDown(location); 123 | } 124 | AbstractLevelDOWN.call(this, location); 125 | this.container = new Database(location); 126 | } 127 | 128 | inherits(FruitDown, AbstractLevelDOWN); 129 | 130 | FruitDown.prototype._open = function (options, callback) { 131 | this.container.init(callback); 132 | }; 133 | 134 | FruitDown.prototype._put = function (key, value, options, callback) { 135 | 136 | var err = checkKeyValue(key, 'key'); 137 | 138 | if (err) { 139 | return nextTick(function () { 140 | callback(err); 141 | }); 142 | } 143 | 144 | err = checkKeyValue(value, 'value'); 145 | 146 | if (err) { 147 | return nextTick(function () { 148 | callback(err); 149 | }); 150 | } 151 | 152 | if (typeof value === 'object' && !Buffer.isBuffer(value) && value.buffer === undefined) { 153 | var obj = {}; 154 | obj.storetype = "json"; 155 | obj.data = value; 156 | value = JSON.stringify(obj); 157 | } 158 | 159 | this.container.setItem(key, value, callback); 160 | }; 161 | 162 | FruitDown.prototype._get = function (key, options, callback) { 163 | 164 | var err = checkKeyValue(key, 'key'); 165 | 166 | if (err) { 167 | return nextTick(function () { 168 | callback(err); 169 | }); 170 | } 171 | 172 | if (!Buffer.isBuffer(key)) { 173 | key = String(key); 174 | } 175 | this.container.getItem(key, function (err, value) { 176 | 177 | if (err) { 178 | return callback(err); 179 | } 180 | 181 | if (options.asBuffer !== false && !Buffer.isBuffer(value)) { 182 | value = new Buffer(value); 183 | } 184 | 185 | 186 | if (options.asBuffer === false) { 187 | if (value.indexOf("{\"storetype\":\"json\",\"data\"") > -1) { 188 | var res = JSON.parse(value); 189 | value = res.data; 190 | } 191 | } 192 | callback(null, value); 193 | }); 194 | }; 195 | 196 | FruitDown.prototype._del = function (key, options, callback) { 197 | 198 | var err = checkKeyValue(key, 'key'); 199 | 200 | if (err) { 201 | return nextTick(function () { 202 | callback(err); 203 | }); 204 | } 205 | if (!Buffer.isBuffer(key)) { 206 | key = String(key); 207 | } 208 | 209 | this.container.removeItem(key, callback); 210 | }; 211 | 212 | FruitDown.prototype._batch = function (array, options, callback) { 213 | var self = this; 214 | nextTick(function () { 215 | var err; 216 | var key; 217 | var value; 218 | 219 | var numDone = 0; 220 | var overallErr; 221 | function checkDone() { 222 | if (++numDone === array.length) { 223 | callback(overallErr); 224 | } 225 | } 226 | 227 | if (Array.isArray(array) && array.length) { 228 | for (var i = 0; i < array.length; i++) { 229 | var task = array[i]; 230 | if (task) { 231 | key = Buffer.isBuffer(task.key) ? task.key : String(task.key); 232 | err = checkKeyValue(key, 'key'); 233 | if (err) { 234 | overallErr = err; 235 | checkDone(); 236 | } else if (task.type === 'del') { 237 | self._del(task.key, options, checkDone); 238 | } else if (task.type === 'put') { 239 | value = Buffer.isBuffer(task.value) ? task.value : String(task.value); 240 | err = checkKeyValue(value, 'value'); 241 | if (err) { 242 | overallErr = err; 243 | checkDone(); 244 | } else { 245 | self._put(key, value, options, checkDone); 246 | } 247 | } 248 | } else { 249 | checkDone(); 250 | } 251 | } 252 | } else { 253 | callback(); 254 | } 255 | }); 256 | }; 257 | 258 | FruitDown.prototype._iterator = function (options) { 259 | return new DatabaseIterator(this, options); 260 | }; 261 | 262 | FruitDown.destroy = function (name, callback) { 263 | DatabaseCore.destroy(name, callback); 264 | }; 265 | 266 | function checkKeyValue(obj, type) { 267 | if (obj === null || obj === undefined) { 268 | return new Error(type + ' cannot be `null` or `undefined`'); 269 | } 270 | if (obj === null || obj === undefined) { 271 | return new Error(type + ' cannot be `null` or `undefined`'); 272 | } 273 | 274 | if (type === 'key') { 275 | 276 | if (obj instanceof Boolean) { 277 | return new Error(type + ' cannot be `null` or `undefined`'); 278 | } 279 | if (obj === '') { 280 | return new Error(type + ' cannot be empty'); 281 | } 282 | } 283 | if (obj.toString().indexOf("[object ArrayBuffer]") === 0) { 284 | if (obj.byteLength === 0 || obj.byteLength === undefined) { 285 | return new Error(type + ' cannot be an empty Buffer'); 286 | } 287 | } 288 | 289 | if (Buffer.isBuffer(obj)) { 290 | if (obj.length === 0) { 291 | return new Error(type + ' cannot be an empty Buffer'); 292 | } 293 | } else if (String(obj) === '') { 294 | return new Error(type + ' cannot be an empty String'); 295 | } 296 | } 297 | 298 | module.exports = FruitDown; 299 | -------------------------------------------------------------------------------- /lib/taskqueue.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var argsarray = require('argsarray'); 4 | var Queue = require('tiny-queue'); 5 | 6 | // see http://stackoverflow.com/a/15349865/680742 7 | var nextTick = global.setImmediate || process.nextTick; 8 | 9 | function TaskQueue() { 10 | this.queue = new Queue(); 11 | this.running = false; 12 | } 13 | 14 | TaskQueue.prototype.add = function (fun, callback) { 15 | this.queue.push({fun: fun, callback: callback}); 16 | this.processNext(); 17 | }; 18 | 19 | TaskQueue.prototype.processNext = function () { 20 | var self = this; 21 | if (self.running || !self.queue.length) { 22 | return; 23 | } 24 | self.running = true; 25 | 26 | var task = self.queue.shift(); 27 | nextTick(function () { 28 | task.fun(argsarray(function (args) { 29 | task.callback.apply(null, args); 30 | self.running = false; 31 | self.processNext(); 32 | })); 33 | }); 34 | }; 35 | 36 | module.exports = TaskQueue; 37 | -------------------------------------------------------------------------------- /lib/utils.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | // taken from rvagg/memdown commit 2078b40 3 | exports.sortedIndexOf = function(arr, item) { 4 | var low = 0; 5 | var high = arr.length; 6 | var mid; 7 | while (low < high) { 8 | mid = (low + high) >>> 1; 9 | if (arr[mid] < item) { 10 | low = mid + 1; 11 | } else { 12 | high = mid; 13 | } 14 | } 15 | return low; 16 | }; 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fruitdown", 3 | "description": "A browser-based LevelDOWN adapter that works over all IndexedDB implementations, including Apple's", 4 | "contributors": [ 5 | "Nolan Lawson (https://github.com/nolanlawson)" 6 | ], 7 | "keywords": [ 8 | "leveldb", 9 | "indexeddb", 10 | "safari", 11 | "apple", 12 | "leveldown", 13 | "levelup" 14 | ], 15 | "version": "1.0.2", 16 | "main": "lib/index.js", 17 | "dependencies": { 18 | "abstract-leveldown": "0.12.3", 19 | "argsarray": "0.0.1", 20 | "d64": "^1.0.0", 21 | "inherits": "^2.0.1", 22 | "tiny-queue": "0.2.0" 23 | }, 24 | "devDependencies": { 25 | "beefy": "~1.1.0", 26 | "browserify": "^13.0.0", 27 | "fake-indexeddb": "^1.0.3", 28 | "jshint": "^2.5.0", 29 | "levelup": "^0.18.2", 30 | "phantomjs-prebuilt": "^2.1.5", 31 | "smokestack": "^3.3.1", 32 | "tap-closer": "^1.0.0", 33 | "tap-spec": "^4.1.0", 34 | "tape": "^2.12.3", 35 | "zuul": "^3.10.0" 36 | }, 37 | "repository": { 38 | "type": "git", 39 | "url": "https://github.com/nolanlawson/fruitdown.git" 40 | }, 41 | "browser": { 42 | "bindings": false 43 | }, 44 | "scripts": { 45 | "dev": "npm run jshint && beefy tests/test.js", 46 | "jshint": "jshint -c .jshintrc lib tests", 47 | "test": "npm run jshint && npm run test-browser", 48 | "test-fakeindexeddb": "node tests/test.js", 49 | "test-browser": "browserify tests/test.js | tap-closer | smokestack -b $BROWSER | tap-spec", 50 | "test-saucelabs": "zuul --no-coverage --browser-name $BROWSER_NAME --browser-version $BROWSER_VERSION -- tests/test.js", 51 | "test-phantom": "zuul --no-coverage --phantom -- tests/test.js" 52 | }, 53 | "license": "MIT", 54 | "gypfile": false, 55 | "files": [ 56 | "lib" 57 | ] 58 | } 59 | -------------------------------------------------------------------------------- /tests/custom-tests.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var levelup = require('levelup'); 4 | 5 | module.exports.setUp = function (leveldown, test, testCommon) { 6 | test('setUp common', testCommon.setUp); 7 | test('setUp db', function (t) { 8 | var db = leveldown(testCommon.location()); 9 | db.open(t.end.bind(t)); 10 | }); 11 | }; 12 | 13 | module.exports.all = function (leveldown, tape, testCommon) { 14 | 15 | module.exports.setUp(leveldown, tape, testCommon); 16 | 17 | tape('test .destroy', function (t) { 18 | var db = levelup('destroy-test', {db: leveldown}); 19 | var db2 = levelup('other-db', {db: leveldown}); 20 | db2.put('key2', 'value2', function (err) { 21 | t.notOk(err, 'no error'); 22 | db.put('key', 'value', function (err) { 23 | t.notOk(err, 'no error'); 24 | db.get('key', function (err, value) { 25 | t.notOk(err, 'no error'); 26 | t.equal(value, 'value', 'should have value'); 27 | db.close(function (err) { 28 | t.notOk(err, 'no error'); 29 | leveldown.destroy('destroy-test', function (err) { 30 | t.notOk(err, 'no error'); 31 | var db3 = levelup('destroy-test', {db: leveldown}); 32 | db3.get('key', function (err, value) { 33 | t.ok(err, 'key is not there'); 34 | db2.get('key2', function (err, value) { 35 | t.notOk(err, 'no error'); 36 | t.equal(value, 'value2', 'should have value2'); 37 | t.end(); 38 | }); 39 | }); 40 | }); 41 | }); 42 | }); 43 | }); 44 | }); 45 | }); 46 | 47 | tape('test escaped db name', function (t) { 48 | var db = levelup('bang!', {db: leveldown}); 49 | var db2 = levelup('bang!!', {db: leveldown}); 50 | db.put('!db1', '!db1', function (err) { 51 | t.notOk(err, 'no error'); 52 | db2.put('db2', 'db2', function (err) { 53 | t.notOk(err, 'no error'); 54 | db.close(function (err) { 55 | t.notOk(err, 'no error'); 56 | db2.close(function (err) { 57 | t.notOk(err, 'no error'); 58 | db = levelup('bang!', {db: leveldown}); 59 | db.get('!db2', function (err, key, value) { 60 | t.ok(err, 'got error'); 61 | t.equal(key, undefined, 'key should be null'); 62 | t.equal(value, undefined, 'value should be null'); 63 | t.end(); 64 | }); 65 | }); 66 | }); 67 | }); 68 | }); 69 | }); 70 | 71 | tape('delete while iterating', function (t) { 72 | var db = leveldown(testCommon.location()); 73 | var noerr = function (err) { 74 | t.error(err, 'open correctly'); 75 | }; 76 | var noop = function () {}; 77 | var iterator; 78 | db.open(noerr); 79 | db.put('a', 'A', noop); 80 | db.put('b', 'B', noop); 81 | db.put('c', 'C', noop); 82 | iterator = db.iterator({ keyAsBuffer: false, valueAsBuffer: false, start: 'a' }); 83 | iterator.next(function (err, key, value) { 84 | t.equal(key, 'a'); 85 | t.equal(value, 'A'); 86 | db.del('b', function (err) { 87 | t.notOk(err, 'no error'); 88 | iterator.next(function (err, key, value) { 89 | t.notOk(err, 'no error'); 90 | t.ok(key, 'key exists'); 91 | t.ok(value, 'value exists'); 92 | t.end(); 93 | }); 94 | }); 95 | }); 96 | }); 97 | 98 | tape('add many while iterating', function (t) { 99 | var db = leveldown(testCommon.location()); 100 | var noerr = function (err) { 101 | t.error(err, 'open correctly'); 102 | }; 103 | var noop = function () {}; 104 | var iterator; 105 | db.open(noerr); 106 | db.put('c', 'C', noop); 107 | db.put('d', 'D', noop); 108 | db.put('e', 'E', noop); 109 | iterator = db.iterator({ keyAsBuffer: false, valueAsBuffer: false, start: 'c' }); 110 | iterator.next(function (err, key, value) { 111 | t.equal(key, 'c'); 112 | t.equal(value, 'C'); 113 | db.del('c', function (err) { 114 | t.notOk(err, 'no error'); 115 | db.put('a', 'A', function (err) { 116 | t.notOk(err, 'no error'); 117 | db.put('b', 'B', function (err) { 118 | t.notOk(err, 'no error'); 119 | iterator.next(function (err, key, value) { 120 | t.notOk(err, 'no error'); 121 | t.ok(key, 'key exists'); 122 | t.ok(value, 'value exists'); 123 | t.ok(key >= 'c', 'key "' + key + '" should be greater than c'); 124 | t.end(); 125 | }); 126 | }); 127 | }); 128 | }); 129 | }); 130 | }); 131 | 132 | tape('concurrent batch delete while iterating', function (t) { 133 | var db = leveldown(testCommon.location()); 134 | var noerr = function (err) { 135 | t.error(err, 'open correctly'); 136 | }; 137 | var noop = function () {}; 138 | var iterator; 139 | db.open(noerr); 140 | db.put('a', 'A', noop); 141 | db.put('b', 'B', noop); 142 | db.put('c', 'C', noop); 143 | iterator = db.iterator({ keyAsBuffer: false, valueAsBuffer: false, start: 'a' }); 144 | iterator.next(function (err, key, value) { 145 | t.equal(key, 'a'); 146 | t.equal(value, 'A'); 147 | db.batch([{ 148 | type: 'del', 149 | key: 'b' 150 | }], noerr); 151 | iterator.next(function (err, key, value) { 152 | t.notOk(err, 'no error'); 153 | // on backends that support snapshots, it will be 'b'. 154 | // else it will be 'c' 155 | t.ok(key, 'key should exist'); 156 | t.ok(value, 'value should exist'); 157 | t.end(); 158 | }); 159 | }); 160 | }); 161 | 162 | tape('iterate past end of db', function (t) { 163 | var db = leveldown('aaaaaa'); 164 | var db2 = leveldown('bbbbbb'); 165 | var noerr = function (err) { 166 | t.error(err, 'open correctly'); 167 | }; 168 | var noop = function () {}; 169 | var iterator; 170 | db.open(noerr); 171 | db2.open(noerr); 172 | db.put('1', '1', noop); 173 | db.put('2', '2', noop); 174 | db2.put('3', '3', noop); 175 | iterator = db.iterator({ keyAsBuffer: false, valueAsBuffer: false, start: '1' }); 176 | iterator.next(function (err, key, value) { 177 | t.equal(key, '1'); 178 | t.equal(value, '1'); 179 | t.notOk(err, 'no error'); 180 | iterator.next(function (err, key, value) { 181 | t.notOk(err, 'no error'); 182 | t.equals(key, '2'); 183 | t.equal(value, '2'); 184 | iterator.next(function (err, key, value) { 185 | t.notOk(key, 'should not actually have a key'); 186 | t.end(); 187 | }); 188 | }); 189 | }); 190 | }); 191 | }; 192 | -------------------------------------------------------------------------------- /tests/test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | if (typeof process !== 'undefined' && !process.browser) { 4 | global.indexedDB = require('fake-indexeddb'); 5 | } 6 | 7 | var tape = require('tape'); 8 | var lib = require('../'); 9 | var testCommon = require('./testCommon'); 10 | var testBuffer = new Buffer('hello'); 11 | 12 | require('abstract-leveldown/abstract/leveldown-test').args(lib, tape); 13 | require('abstract-leveldown/abstract/open-test').args(lib, tape, testCommon); 14 | require('abstract-leveldown/abstract/del-test').all(lib, tape, testCommon); 15 | require('abstract-leveldown/abstract/put-test').all(lib, tape, testCommon); 16 | require('abstract-leveldown/abstract/get-test').all(lib, tape, testCommon); 17 | require('abstract-leveldown/abstract/put-get-del-test').all( 18 | lib, tape, testCommon, testBuffer); 19 | require('abstract-leveldown/abstract/close-test').close(lib, tape, testCommon); 20 | require('abstract-leveldown/abstract/iterator-test').all(lib, tape, testCommon); 21 | 22 | require('abstract-leveldown/abstract/chained-batch-test').all(lib, tape, testCommon); 23 | require('abstract-leveldown/abstract/approximate-size-test').setUp(lib, tape, testCommon); 24 | require('abstract-leveldown/abstract/approximate-size-test').args(lib, tape, testCommon); 25 | 26 | require('abstract-leveldown/abstract/ranges-test').all(lib, tape, testCommon); 27 | require('abstract-leveldown/abstract/batch-test').all(lib, tape, testCommon); 28 | 29 | require('./custom-tests.js').all(lib, tape, testCommon); 30 | 31 | -------------------------------------------------------------------------------- /tests/testCommon.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var dbidx = 0; 4 | var theLocation = function () { 5 | return '_leveldown_test_db_' + dbidx++; 6 | }; 7 | 8 | var lastLocation = function () { 9 | return '_leveldown_test_db_' + dbidx; 10 | }; 11 | 12 | var cleanup = function (callback) { 13 | 14 | // TODO 15 | 16 | return callback(); 17 | }; 18 | 19 | var setUp = function (t) { 20 | cleanup(function (err) { 21 | t.notOk(err, 'cleanup returned an error'); 22 | t.end(); 23 | }); 24 | }; 25 | 26 | var tearDown = function (t) { 27 | setUp(t); // same cleanup! 28 | }; 29 | 30 | var collectEntries = function (iterator, callback) { 31 | var data = []; 32 | var next = function () { 33 | iterator.next(function (err, key, value) { 34 | if (err) { 35 | return callback(err); 36 | } 37 | if ((!arguments.length) || (key === undefined) || (key === null)) { 38 | return iterator.end(function (err) { 39 | callback(err, data); 40 | }); 41 | } 42 | 43 | data.push({ key: key, value: value }); 44 | process.nextTick(next); 45 | }); 46 | }; 47 | next(); 48 | }; 49 | 50 | var makeExistingDbTest = function (name, test, leveldown, testFn) { 51 | test(name, function (t) { 52 | cleanup(function () { 53 | var loc = location(); 54 | var db = leveldown(loc); 55 | var done = function (close) { 56 | if (close === false) { 57 | return cleanup(t.end.bind(t)); 58 | } 59 | db.close(function (err) { 60 | t.notOk(err, 'no error from close()'); 61 | cleanup(t.end.bind(t)); 62 | }); 63 | }; 64 | db.open(function (err) { 65 | t.notOk(err, 'no error from open()'); 66 | db.batch([ 67 | { type: 'put', key: 'one', value: '1' }, 68 | { type: 'put', key: 'two', value: '2' }, 69 | { type: 'put', key: 'three', value: '3' } 70 | ], function (err) { 71 | t.notOk(err, 'no error from batch()'); 72 | testFn(db, t, done, loc); 73 | }); 74 | }); 75 | }); 76 | }); 77 | }; 78 | 79 | module.exports = { 80 | location: theLocation, 81 | cleanup: cleanup, 82 | lastLocation: lastLocation, 83 | setUp: setUp, 84 | tearDown: tearDown, 85 | collectEntries: collectEntries, 86 | makeExistingDbTest: makeExistingDbTest 87 | }; 88 | --------------------------------------------------------------------------------