diff --git a/History.md b/History.md index 11ead80..e4c66a0 100644 --- a/History.md +++ b/History.md @@ -1,3 +1,26 @@ +## 4.0.0 + +* Breaking API change (minor)/Feature: `delStore` will no longer throw upon + being called with a store not present (in case one wishes to delete a store + which is still existing but was not added by idb-schema). (If `delStore` + does throw later (or if there is any other upgrade error), the error + can now be caught by `callback` or `open`/`upgrade`.) +* Fix: Allow stores, indexes, and store `keyPath`s to be an empty string +* Fix: Allow `autoIncrement` with non-empty string `keyPath`s but disallow + arrays and empty string +* Fix: Throw proper exceptions (as would be thrown if sent to IndexedDB at + run-time) +* Feature: Allow `delStore` to pass in name not present within schema (in + case added previously). +* Feature: Add `open` and `upgrade` methods to allow a sequence of upgrades + which can support promises returned by `addCallback` callbacks (and a + `flushIncomplete` method for flushing storage pertaining to incomplete + upgrades) +* Feature: Add `addEarlyCallback` to allow use of `idb-schema` methods + within these synchronous callbacks +* Feature: Support `errBack` argument to `callback` +* Feature: Support object store argument supplied to `getStore` + ## 3.2.1 / 2015-11-29 * remove `component-clone` as deps, diff --git a/Readme.md b/Readme.md index 938456e..5d8a959 100644 --- a/Readme.md +++ b/Readme.md @@ -58,7 +58,7 @@ schema.stores() ## API -### schema.callback() +### schema.callback([errBack]) Generate `onupgradeneeded` callback. @@ -70,6 +70,92 @@ req.onsuccess = (e) => { } ``` +Note that this callback will not support `addCallback` callbacks if they rely +on promises and run transactions (since `upgradeneeded`'s transaction will +expire). You can instead use `schema.open` or `schema.upgrade`. + +`callback` takes an optional `errBack` function which is passed an error +object (upon encountering any errors during the upgrade) as well as the +`upgradeneeded` event. + +```js +const schema = new Schema().version(1).delStore('nonexistentStore') +open(dbName, schema.version(), schema.callback(function errBack(err /* , e */) { + throw new Error('Bad upgrade') +})).catch((err) => { + console.log(err.message) // 'Bad upgrade' +}) +``` + +If no `errBack` is provided, the error responsible will be thrown and can +be caught by the consuming code. + +### schema.open(dbName, [version]) + +With `schema.open`, in addition to getting upgrades applied (including +callbacks added by `addCallback` and even including promise-based callbacks +which utilize transactions), you can use the `db` result opened at the +latest version: + +```js +schema.open('myDb', 3).then((db) => { + // Use db +}) +``` + +However, unlike `callback()`, when `schema.open` is used, the callbacks +added by `addCallback` cannot handle operations such as adding stores +or indexes (though these operations can be executed with the other +methods of idb-schema anyways) though those added by `addEarlyCallback` +can be. + +Besides conducting an upgrade, `schema.open` uses the `open` of +[idb-factory](https://github.com/treojs/idb-factory) behind the scenes, so +one can also catch errors and benefit from its fixing of browser quirks. + +If a version is not supplied, the latest version available within the schema +will be used. + +If you only wish to upgrade and do not wish to keep a connection open, use +`schema.upgrade`. If you wish to manage opening a connection yourself (and are +not using promises within `addCallback` callbacks), you can use +`schema.callback`. + +Despite allowing for promise-based callbacks utilizing transactions, due to +[current limitations in IndexedDB](https://github.com/w3c/IndexedDB/issues/42), +we cannot get a transaction which encompasses both the store/index changes +and the store content changes, so it will not be possible to rollback the +entire version upgrade if the store/index changes transaction succeeds while +the store content change transaction fails. However, upon such a condition +idb-schema will set a `localStorage` property to disallow subsequent attempts +on `schema.open` or `schema.upgrade` to succeed until either the storage +property is manually flushed by the `flushIncomplete()` method or if the +`retry` method on the error object is invoked to return a Promise which will +reattempt to execute the failed callback and the rest of the upgrades and +which will resolve according to whether this next attempt was successful or +not. + +### schema.upgrade(dbName, [version], [keepOpen=false]) + +Equivalent to `schema.open` but without keeping a connection open +(unless `keepOpen` is set to `true`): + +```js +schema.upgrade('myDb', 3).then(() => { + // No database result is available for upgrades. Use `schema.open` if you + // wish to keep a connection to the latest version open + // for non-upgrade related transactions +}) +``` + +### schema.flushIncomplete(dbName) + +If there was an incomplete upgrade, this method will flush the local storage +used to signal to `schema.open`/`schema.upgrade` that they should not yet allow +opening until the upgrade is complete. This method should normally not be used +as it is important to ensure an upgrade occurs like a complete transaction, and +flushing will interfere with this. + ### schema.stores() Get JSON representation of database schema. @@ -137,11 +223,26 @@ Options: Delete store by `name`. +Note that if a non-existent store is provided, this method will +not immediately throw (since it is possible one may wish to use this +for deleting stores added prior to using `idb-schema`). You will be +able to catch the errors, however: + +```js +const schema = new Schema().version(1).delStore('nonexistentStore') +return open(dbName, schema.version(), schema.callback()).catch((err) => { + console.log(err.name) // 'NotFoundError' +}) +``` + ### schema.getStore(name) Switch current store. Use it to make operations with indexes. +This can be the name of a store, or it can also be an actual IndexedDB +store object. + ### schema.addIndex(name, field, [opts]) Create index with `name` and to `field` (or array of fields). @@ -154,9 +255,33 @@ Options: Delete index by `name` from current store. +### schema.addEarlyCallback(cb) + +Adds a `cb` to be executed at the beginning of the `upgradeneeded` event +and passed the event object. This will, out of necessity, run synchronously, +so promises cannot safely be used therein (whether used in `schema.callback` +or `schema.open`/`schema.upgrade`). + +However, due to their early execution, such callbacks are, unlike +`addCallback` callbacks used with `schema.open`/`schema.upgrade`, +able to use methods such as `addStore`. + +```js +const schema = new Schema() +.addStore('users', { increment: true, keyPath: 'id' }) +.addIndex('byName', 'name') +.addEarlyCallback((e) => { + schema.addIndex('byId', 'id') +}) +``` + ### schema.addCallback(cb) -Add `cb` to be executed at the end of the `upgradeneeded` event. +Adds a `cb` to be executed at the end of the `upgradeneeded` event +(if `schema.callback()` is used) or, at the beginning of the `success` +event (if `schema.open` and `schema.upgrade` are used). If `callback` +is used, the callback will be passed the `upgradeneeded` event. If +the other two methods are used, the db result will be passed instead. ```js new Schema() @@ -169,6 +294,16 @@ new Schema() }) ``` +Note that if you wish to use promises within such callbacks and make +transactions within them, your `addCallback` callback should return +a promise chain and then use `schema.open` or `schema.upgrade` because +these methods, unlike `schema.callback`, will cause the callbacks +to be executed safely within the more persistent `onsuccess` event (and the +callback will be passed the database result instead of the `upgradeneeded` +event). If you do not need promises, you will have the option of using +`schema.callback` in addition to `schema.open` or `schema.upgrade` (or +you can use `addEarlyCallback`). + ### schema.clone() Return a deep clone of current schema. diff --git a/package.json b/package.json index cf3f729..6eb9f70 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,8 @@ ], "scripts": { "prepublish": "babel src --out-dir lib", - "test": "eslint src/ test/ && browserify-test -t babelify && SAUCE_USERNAME=idb-schema zuul --tunnel-host http://treojs.com --no-coverage -- test/index.js", + "test:local": "eslint src/ test/ && browserify-test -t babelify", + "test": "npm run test:local && SAUCE_USERNAME=idb-schema zuul --tunnel-host http://treojs.com --no-coverage -- test/index.js", "development": "browserify-test -t babelify --watch" }, "dependencies": { @@ -30,6 +31,7 @@ "babel-core": "6.1.18", "babel-eslint": "^5.0.0-beta4", "babel-plugin-add-module-exports": "^0.1.1", + "babel-polyfill": "^6.7.4", "babel-preset-es2015": "^6.1.18", "babelify": "^7.2.0", "browserify-test": "^2.1.2", @@ -37,7 +39,6 @@ "es6-promise": "^3.0.2", "eslint": "^1.10.2", "eslint-config-airbnb": "^1.0.2", - "idb-factory": "^1.0.0", "idb-request": "^3.0.0", "indexeddbshim": "^2.2.1", "lodash": "^3.10.1", diff --git a/src/idb-factory.js b/src/idb-factory.js new file mode 100644 index 0000000..eae6862 --- /dev/null +++ b/src/idb-factory.js @@ -0,0 +1,162 @@ + +/** + * Open IndexedDB database with `name`. + * Retry logic allows to avoid issues in tests env, + * when db with the same name delete/open repeatedly and can be blocked. + * + * @param {String} dbName + * @param {Number} [version] + * @param {Function} [upgradeCallback] + * @return {Promise} + */ + +export function open(dbName, version, upgradeCallback) { + return new Promise((resolve, reject) => { + if (typeof version === 'function') { + upgradeCallback = version + version = undefined + } + // don't call open with 2 arguments, when version is not set + const req = version ? idb().open(dbName, version) : idb().open(dbName) + req.onblocked = (e) => { + const resume = new Promise((res, rej) => { + // We overwrite handlers rather than make a new + // open() since the original request is still + // open and its onsuccess will still fire if + // the user unblocks by closing the blocking + // connection + req.onsuccess = ev => res(ev.target.result) + req.onerror = ev => { + ev.preventDefault() + rej(ev) + } + }) + e.resume = resume + reject(e) + } + if (typeof upgradeCallback === 'function') { + req.onupgradeneeded = e => { + try { + upgradeCallback(e) + } catch (err) { + // We allow the callback to throw its own error + e.target.result.close() + reject(err) + } + } + } + req.onerror = (e) => { + e.preventDefault() + reject(e) + } + req.onsuccess = (e) => { + resolve(e.target.result) + } + }) +} + +/** + * Delete `db` properly: + * - close it and wait 100ms to disk flush (Safari, older Chrome, Firefox) + * - if database is locked, due to inconsistent exectution of `versionchange`, + * try again in 100ms + * + * @param {IDBDatabase|String} db + * @return {Promise} + */ + +export function del(db) { + const dbName = typeof db !== 'string' ? db.name : db + + return new Promise((resolve, reject) => { + const delDb = () => { + const req = idb().deleteDatabase(dbName) + req.onblocked = (e) => { + // The following addresses part of https://bugzilla.mozilla.org/show_bug.cgi?id=1220279 + e = e.newVersion === null || typeof Proxy === 'undefined' ? e : new Proxy(e, { get: (target, name) => { + return name === 'newVersion' ? null : target[name] + } }) + const resume = new Promise((res, rej) => { + // We overwrite handlers rather than make a new + // delete() since the original request is still + // open and its onsuccess will still fire if + // the user unblocks by closing the blocking + // connection + req.onsuccess = ev => { + // The following are needed currently by PhantomJS: https://github.com/ariya/phantomjs/issues/14141 + if (!('newVersion' in ev)) { + ev.newVersion = e.newVersion + } + + if (!('oldVersion' in ev)) { + ev.oldVersion = e.oldVersion + } + + res(ev) + } + req.onerror = ev => { + ev.preventDefault() + rej(ev) + } + }) + e.resume = resume + reject(e) + } + req.onerror = (e) => { + e.preventDefault() + reject(e) + } + req.onsuccess = (e) => { + // The following is needed currently by PhantomJS (though we cannot polyfill `oldVersion`): https://github.com/ariya/phantomjs/issues/14141 + if (!('newVersion' in e)) { + e.newVersion = null + } + + resolve(e) + } + } + + if (typeof db !== 'string') { + db.close() + setTimeout(delDb, 100) + } else { + delDb() + } + }) +} + +/** + * Compare `first` and `second`. + * Added for consistency with official API. + * + * @param {Any} first + * @param {Any} second + * @return {Number} -1|0|1 + */ + +export function cmp(first, second) { + return idb().cmp(first, second) +} + +/** + * Get globally available IDBFactory instance. + * - it uses `global`, so it can work in any env. + * - it tries to use `global.forceIndexedDB` first, + * so you can rewrite `global.indexedDB` with polyfill + * https://bugs.webkit.org/show_bug.cgi?id=137034 + * - it fallbacks to all possibly available implementations + * https://github.com/axemclion/IndexedDBShim#ios + * - function allows to have dynamic link, + * which can be changed after module's initial exectution + * + * @return {IDBFactory} + */ + +function idb() { + return global.forceIndexedDB + || global.indexedDB + || global.webkitIndexedDB + || global.mozIndexedDB + || global.msIndexedDB + || global.shimIndexedDB +} diff --git a/src/index.js b/src/index.js index 3ec7508..eaed9ec 100644 --- a/src/index.js +++ b/src/index.js @@ -1,7 +1,18 @@ -import values from 'object-values' -import isInteger from 'is-integer' +import 'babel-polyfill' // Object.values, etc. +import { open } from './idb-factory' import isPlainObj from 'is-plain-obj' +const values = Object.values +const isInteger = Number.isInteger +const localStorageExists = typeof window !== 'undefined' && window.localStorage + +const getJSONStorage = (item, dflt) => { + return JSON.parse(localStorage.getItem(item) || dflt || '{}') +} +const setJSONStorage = (item, value) => { + localStorage.setItem(item, JSON.stringify(value)) +} + /** * Maximum version value (unsigned long long) * http://www.w3.org/TR/IndexedDB/#events @@ -41,6 +52,7 @@ export default class Schema { indexes: [], // store.createIndex dropIndexes: [], // store.deleteIndex callbacks: [], + earlyCallbacks: [], version: version, // version } @@ -56,17 +68,20 @@ export default class Schema { */ addStore(name, opts = {}) { - if (typeof name !== 'string' || !name) throw new TypeError('"name" is required') - if (this._stores[name]) throw new TypeError(`"${name}" store is already defined`) + if (typeof name !== 'string') throw new TypeError('"name" is required') // idb-schema requirement + if (this._stores[name]) throw new DOMException(`"${name}" store is already defined`, 'ConstraintError') const store = { name: name, indexes: {}, - keyPath: opts.key || opts.keyPath || null, + keyPath: opts.key || opts.keyPath, autoIncrement: opts.increment || opts.autoIncrement || false, } - if (store.autoIncrement && !store.keyPath) { - throw new TypeError('set keyPath in order to use autoIncrement') + if (!store.keyPath && store.keyPath !== '') { + store.keyPath = null + } + if (store.autoIncrement && (store.keyPath === '' || Array.isArray(store.keyPath))) { + throw new DOMException('keyPath must not be the empty string or a sequence if autoIncrement is in use', 'InvalidAccessError') } this._stores[name] = store @@ -84,10 +99,13 @@ export default class Schema { */ delStore(name) { - if (typeof name !== 'string' || !name) throw new TypeError('"name" is required') - const store = this._stores[name] - if (!store) throw new TypeError(`"${name}" store is not defined`) - delete this._stores[name] + if (typeof name !== 'string') throw new TypeError('"name" is required') // idb-schema requirement + let store = this._stores[name] + if (store) { + delete this._stores[name] + } else { + store = { name: name } + } this._versions[this.version()].dropStores.push(store) this._current.store = null return this @@ -101,7 +119,28 @@ export default class Schema { */ getStore(name) { - if (typeof name !== 'string' || !name) throw new TypeError('"name" is required') + if (name && typeof name === 'object' && 'name' in name && 'indexNames' in name) { + const storeObj = name + name = storeObj.name + const store = { + name: name, + indexes: Array.from(storeObj.indexNames).reduce((obj, iName) => { + const indexObj = storeObj.index(iName) + obj[iName] = { + name: iName, + storeName: name, + field: indexObj.keyPath, + unique: indexObj.unique, + multiEntry: indexObj.multiEntry, + } + return obj + }, {}), + keyPath: storeObj.keyPath, + autoIncrement: storeObj.autoIncrement, + } + this._stores[name] = store + } + if (typeof name !== 'string') throw new DOMException('"name" is required', 'NotFoundError') if (!this._stores[name]) throw new TypeError(`"${name}" store is not defined`) this._current.store = this._stores[name] return this @@ -117,13 +156,13 @@ export default class Schema { */ addIndex(name, field, opts = {}) { - if (typeof name !== 'string' || !name) throw new TypeError('"name" is required') + if (typeof name !== 'string') throw new TypeError('"name" is required') // idb-schema requirement if (typeof field !== 'string' && !Array.isArray(field)) { - throw new TypeError('"field" is required') + throw new SyntaxError('"field" is required') } const store = this._current.store if (!store) throw new TypeError('set current store using "getStore" or "addStore"') - if (store.indexes[name]) throw new TypeError(`"${name}" index is already defined`) + if (store.indexes[name]) throw new DOMException(`"${name}" index is already defined`, 'ConstraintError') const index = { name: name, @@ -146,9 +185,9 @@ export default class Schema { */ delIndex(name) { - if (typeof name !== 'string' || !name) throw new TypeError('"name" is required') + if (typeof name !== 'string') throw new TypeError('"name" is required') // idb-schema requirement const index = this._current.store.indexes[name] - if (!index) throw new TypeError(`"${name}" index is not defined`) + if (!index) throw new DOMException(`"${name}" index is not defined`, 'NotFoundError') delete this._current.store.indexes[name] this._versions[this.version()].dropIndexes.push(index) return this @@ -167,51 +206,243 @@ export default class Schema { return this } + addEarlyCallback(cb) { + this._versions[this.version()].earlyCallbacks.push(cb) + return this + } + /** - * Generate onupgradeneeded callback. + * Flushes storage pertaining to incomplete upgrades * - * @return {Function} + * @return {} */ + flushIncomplete(dbName) { + const incompleteUpgrades = getJSONStorage('idb-incompleteUpgrades') + delete incompleteUpgrades[dbName] + setJSONStorage('idb-incompleteUpgrades', incompleteUpgrades) + } - callback() { - const versions = values(clone(this._versions)).sort((a, b) => a.version - b.version) - return function onupgradeneeded(e) { - const oldVersion = e.oldVersion > MAX_VERSION ? 0 : e.oldVersion // Safari bug - const db = e.target.result - const tr = e.target.transaction - - versions.forEach((versionSchema) => { - if (oldVersion >= versionSchema.version) return - - versionSchema.stores.forEach((s) => { - // Only pass the options that are explicitly specified to createObjectStore() otherwise IE/Edge - // can throw an InvalidAccessError - see https://msdn.microsoft.com/en-us/library/hh772493(v=vs.85).aspx - const opts = {} - if (s.keyPath) opts.keyPath = s.keyPath - if (s.autoIncrement) opts.autoIncrement = s.autoIncrement - db.createObjectStore(s.name, opts) - }) + /** + * Generate open connection running a sequence of upgrades, keeping the connection open. + * + * @return {Promise} + */ - versionSchema.dropStores.forEach((s) => { - db.deleteObjectStore(s.name) - }) + open(dbName, version) { + return this.upgrade(dbName, version, true) + } - versionSchema.indexes.forEach((i) => { - tr.objectStore(i.storeName).createIndex(i.name, i.field, { - unique: i.unique, - multiEntry: i.multiEntry, + /** + * Generate open connection running a sequence of upgrades. + * + * @return {Promise} + */ + + upgrade(dbName, version, keepOpen) { + let versionSchema + let versions + let afterOpen + const setVersions = () => { + versions = values(this._versions).sort((a, b) => a.version - b.version).values() + } + const blockRecover = (reject) => { + return (err) => { + if (err && err.type === 'blocked') { + reject(err) + return + } + throw err + } + } + setVersions() + function thenableUpgradeVersion(dbLast, res, rej, start) { + const lastVers = dbLast.version + let lastGoodSchema + let versionIter + for (versionIter = versions.next(); + (!versionIter.done && versionIter.value.version <= lastVers); + versionIter = versions.next() + ) { + lastGoodSchema = versionIter.value + } + versionSchema = versionIter.value + if (versionIter.done || versionSchema.version > version) { + if (start !== undefined) { + versionSchema = lastGoodSchema + afterOpen(dbLast, res, rej, start) + } else if (!keepOpen) { + dbLast.close() + res() + } else { + res(dbLast) + } + return + } + dbLast.close() + + setTimeout(() => { + open(dbName, versionSchema.version, upgradeneeded((...dbInfo) => { + upgradeVersion(versionSchema, ...dbInfo) + })).then((db) => { + afterOpen(db, res, rej, start) + }).catch((err) => { + rej(err) + }) + }) + } + afterOpen = (db, res, rej, start) => { + // We run callbacks in `success` so promises can be used without fear of the (upgrade) transaction expiring + const processReject = (err, callbackIndex) => { + err = typeof err === 'string' ? new Error(err) : err + err.retry = () => { + return new Promise((resolv, rejct) => { + const resolver = (item) => { + this.flushIncomplete(dbName) + resolv(item) + } + db.close() + // db.transaction can't execute as closing by now, so we close and reopen + open(dbName).catch(blockRecover(rejct)).then((dbs) => { + setVersions() + thenableUpgradeVersion(dbs, resolver, rejct, callbackIndex) + }).catch(rejct) }) + } + if (localStorageExists) { + const incompleteUpgrades = getJSONStorage('idb-incompleteUpgrades') + incompleteUpgrades[dbName] = { + version: db.version, + error: err.message, + callbackIndex, + } + setJSONStorage('idb-incompleteUpgrades', incompleteUpgrades) + } + db.close() + rej(err) + } + let promise = Promise.resolve() + let lastIndex + const cbFailed = versionSchema.callbacks.some((cb, i) => { + if (start !== undefined && i < start) { + return false + } + let ret + try { + ret = cb(db) + } catch (err) { + processReject(err, i) + return true + } + if (ret && ret.then) { + // We need to treat the rest as promises so that they do not + // continue to execute before the current one has a chance to + // execute or fail + promise = versionSchema.callbacks.slice(i + 1).reduce((p, cb2) => { + return p.then(() => { + return cb2(db) + }) + }, ret) + lastIndex = i + return true + } + }) + const complete = lastIndex !== undefined + if (cbFailed && !complete) return + promise = promise.then(() => thenableUpgradeVersion(db, res, rej)) + if (complete) { + promise = promise.catch((err) => { + processReject(err, lastIndex) }) + } + } + // If needed, open higher versions until fully upgraded (noting any transaction failures) + return new Promise((resolve, reject) => { + version = version || this.version() + if (typeof version !== 'number' || version < 1) { + reject(new Error('Bad version supplied for idb-schema upgrade')) + return + } + + let incompleteUpgrades + let iudb + if (localStorageExists) { + incompleteUpgrades = getJSONStorage('idb-incompleteUpgrades') + iudb = incompleteUpgrades[dbName] + } + if (iudb) { + const err = new Error( + 'An upgrade previously failed to complete for version: ' + iudb.version + + ' due to reason: ' + iudb.error + ) + err.badVersion = iudb.version + err.retry = () => { + let versionIter = versions.next() + while (!versionIter.done && versionIter.value.version < err.badVersion) { + versionIter = versions.next() + } + versionSchema = versionIter.value + return new Promise((resolv, rejct) => { + const resolver = (item) => { + this.flushIncomplete(dbName) + resolv(item) + } + // If there was a prior failure, we don't need to worry about `upgradeneeded` yet + open(dbName).catch(blockRecover(rejct)).then((dbs) => { + afterOpen(dbs, resolver, rejct, iudb.callbackIndex) + }).catch(rejct) + }) + } + reject(err) + return + } + const upgrade = upgradeneeded((...dbInfo) => { + // Upgrade from 0 to version 1 + const versionIter = versions.next() + if (versionIter.done) { + throw new Error('No schema versions added for upgrade') + } + versionSchema = versionIter.value + upgradeVersion(versionSchema, ...dbInfo) + }) + open(dbName, upgrade).catch(blockRecover(reject)).then((db) => { + if (version < db.version) { + db.close() + reject(new DOMException('The requested version (' + version + ') is less than the existing version (' + db.version + ').', 'VersionError')) + return + } + if (versionSchema) { + afterOpen(db, resolve, reject) + return + } + thenableUpgradeVersion(db, resolve, reject) + }).catch((err) => reject(err)) + }) + } - versionSchema.dropIndexes.forEach((i) => { - tr.objectStore(i.storeName).deleteIndex(i.name) - }) + /** + * Generate onupgradeneeded callback running a sequence of upgrades. + * + * @return {Function} + */ - versionSchema.callbacks.forEach((cb) => { - cb(e) - }) + callback(errBack) { + const versions = values(this._versions).sort((a, b) => a.version - b.version) + return upgradeneeded((e, oldVersion) => { + versions.some((versionSchema) => { + try { + upgradeVersion(versionSchema, e, oldVersion) + versionSchema.callbacks.forEach((cb) => { + cb(e) + }) + } catch (err) { + if (errBack) { + errBack(err, e) + return true + } + throw err + } }) - } + }) } /** @@ -262,3 +493,50 @@ function clone(obj) { } return obj } + +/** + * Utility for `upgradeneeded`. + * @todo Can `oldVersion` be overwritten and this utility exposed within idb-factory? + */ + +function upgradeneeded(cb) { + return (e) => { + const oldVersion = e.oldVersion > MAX_VERSION ? 0 : e.oldVersion // Safari bug: https://bugs.webkit.org/show_bug.cgi?id=136888 + cb(e, oldVersion) + } +} + +function upgradeVersion(versionSchema, e, oldVersion) { + if (oldVersion >= versionSchema.version) return + + const db = e.target.result + const tr = e.target.transaction + + versionSchema.earlyCallbacks.forEach((cb) => { + cb(e) + }) + + versionSchema.stores.forEach((s) => { + // Only pass the options that are explicitly specified to createObjectStore() otherwise IE/Edge + // can throw an InvalidAccessError - see https://msdn.microsoft.com/en-us/library/hh772493(v=vs.85).aspx + const opts = {} + if (s.keyPath !== null && s.keyPath !== undefined) opts.keyPath = s.keyPath + if (s.autoIncrement) opts.autoIncrement = s.autoIncrement + db.createObjectStore(s.name, opts) + }) + + versionSchema.dropStores.forEach((s) => { + db.deleteObjectStore(s.name) + }) + + versionSchema.indexes.forEach((i) => { + tr.objectStore(i.storeName).createIndex(i.name, i.field, { + unique: i.unique, + multiEntry: i.multiEntry, + }) + }) + + versionSchema.dropIndexes.forEach((i) => { + tr.objectStore(i.storeName).deleteIndex(i.name) + }) +} diff --git a/test/index.js b/test/index.js index 5112450..dacca8d 100644 --- a/test/index.js +++ b/test/index.js @@ -1,19 +1,28 @@ import 'indexeddbshim' import ES6Promise from 'es6-promise' import { expect } from 'chai' -import { pluck, toArray } from 'lodash' -import { del, open } from 'idb-factory' +import { pluck } from 'lodash' +import { del, open } from '../src/idb-factory' import { request } from 'idb-request' import Schema from '../src' describe('idb-schema', function idbSchemaTest() { - this.timeout(5000) + this.timeout(8000) ES6Promise.polyfill() const dbName = 'mydb' let db before(() => del(dbName)) - afterEach(() => del(db || dbName)) + afterEach(() => { + new Schema().flushIncomplete(dbName) + return new Promise((res) => { + setTimeout(() => { + res(del(db || dbName).catch((err) => { + return err.resume + })) + }, 500) + }) + }) it('describes database', () => { const schema = new Schema() @@ -38,11 +47,11 @@ describe('idb-schema', function idbSchemaTest() { return open(dbName, schema.version(), schema.callback()).then((originDb) => { db = originDb expect(db.version).equal(1) - expect(toArray(db.objectStoreNames)).eql(['modules', 'users']) + expect(Array.from(db.objectStoreNames)).eql(['modules', 'users']) const modules = db.transaction(['modules'], 'readonly').objectStore('modules') expect(modules.keyPath).equal('name') - expect(toArray(modules.indexNames).sort()).eql( + expect(Array.from(modules.indexNames).sort()).eql( ['byAuthor', 'byKeywords', 'byMaintainers', 'byRating']) const users = db.transaction(['users'], 'readonly').objectStore('users') @@ -62,6 +71,7 @@ describe('idb-schema', function idbSchemaTest() { return request(users.count()).then((count) => { expect(count).equal(3) + db.close() }) }) }) @@ -83,7 +93,7 @@ describe('idb-schema', function idbSchemaTest() { return open(dbName, schema.version(), schema.callback()).then((originDb) => { db = originDb expect(db.version).equal(3) - expect(toArray(db.objectStoreNames)).eql(['books', 'magazines']) + expect(Array.from(db.objectStoreNames)).eql(['books', 'magazines']) db.close() return new Promise((resolve) => setTimeout(resolve, 100)).then(() => { @@ -95,10 +105,11 @@ describe('idb-schema', function idbSchemaTest() { return open(dbName, schema.version(), schema.callback()).then((originDb2) => { db = originDb2 expect(db.version).equal(4) - expect(toArray(db.objectStoreNames)).eql(['magazines']) + expect(Array.from(db.objectStoreNames)).eql(['magazines']) const magazines = db.transaction(['magazines'], 'readonly').objectStore('magazines') - expect(toArray(magazines.indexNames)).eql(['byFrequency']) + expect(Array.from(magazines.indexNames)).eql(['byFrequency']) + db.close() }) }) }) @@ -137,6 +148,7 @@ describe('idb-schema', function idbSchemaTest() { .addIndex('byYear', 'year') // version + expect(() => schema.version(1)).throws('invalid version') expect(() => new Schema().version(0)).throws('invalid version') expect(() => new Schema().version(-1)).throws('invalid version') expect(() => new Schema().version(2.5)).throws('invalid version') @@ -147,11 +159,12 @@ describe('idb-schema', function idbSchemaTest() { expect(() => new Schema().addStore(101)).throws('"name" is required') expect(() => new Schema().addStore(101)).throws('"name" is required') expect(() => schema.addStore('books')).throws('"books" store is already defined') - expect(() => new Schema().addStore('books', { autoIncrement: true })).throws('set keyPath in order to use autoIncrement') + expect(() => new Schema().addStore('books', { autoIncrement: true, keyPath: '' })).throws('keyPath must not be the empty string or a sequence if autoIncrement is in use') + expect(() => new Schema().addStore('books', { autoIncrement: true, keyPath: [] })).throws('keyPath must not be the empty string or a sequence if autoIncrement is in use') + expect(() => new Schema().addStore('books', { autoIncrement: true, keyPath: ['isbn'] })).throws('keyPath must not be the empty string or a sequence if autoIncrement is in use') // delStore expect(() => new Schema().delStore()).throws('"name" is required') - expect(() => new Schema().delStore('books')).throws('"books" store is not defined') // getStore expect(() => new Schema().getStore()).throws('"name" is required') @@ -164,7 +177,320 @@ describe('idb-schema', function idbSchemaTest() { expect(() => schema.addIndex('byTitle', 'title')).throws('"byTitle" index is already defined') // delIndex - expect(() => schema.delIndex('')).throws('"name" is required') + expect(() => schema.delIndex()).throws('"name" is required') expect(() => schema.delIndex('byField')).throws('"byField" index is not defined') }) + + it('permissible edge cases', () => { + expect(() => new Schema().addStore('books', { autoIncrement: true, keyPath: 'isbn' })).not.throws() + expect(() => new Schema().addStore('books', { keyPath: '' })).not.throws() + expect(() => new Schema().addStore('')).not.throws() + + const schema = new Schema().addStore('books') + expect(() => schema.addIndex('', 'title')).not.throws() + expect(() => schema.addIndex('author', '')).not.throws() + }) + + it('allows bad delStore to be catchable', () => { + let ranErrBack = false + const schema = new Schema() + .version(1) + .delStore('nonexistentStore') + return open(dbName, schema.version(), schema.callback(function errBack(err /* , e */) { + ranErrBack = true + throw err + })).catch((err) => { + expect(err.name).equal('NotFoundError') + expect(ranErrBack).equal(true) + + const schema2 = new Schema().version(2).delStore('nonexistentStore') + return open(dbName, schema2.version(), schema2.callback()).catch((err2) => { + expect(err2.name).equal('NotFoundError') + }) + }) + }) + + it('supports an object store argument supplied to getStore', () => { + const schema = new Schema() + .version(1) + .addStore('books') + .version(2) + .addEarlyCallback(function ecb(e) { + const tr = e.target.transaction + const os = tr.objectStore('books') + schema.getStore(os) + schema.addIndex('byYear', 'year') + }) + return open(dbName, schema.version(), schema.callback()).then((originDb) => { + db = originDb + const trans = db.transaction(['books']) + const store = trans.objectStore('books') + expect(store.name).equal('books') + expect(store.indexNames.contains('byYear')).equal(true) + originDb.close() + }) + }) + + it('completes upgrade allowing for asynchronous callbacks', () => { + let caught = false + const schema = new Schema() + .version(1) + .addStore('books', { keyPath: 'isbn' }) + .addCallback((dbr) => { + const books = dbr.transaction(['books'], 'readwrite').objectStore('books') + return new Promise((resolve) => { + books.put({ name: 'World Peace through World Language', isbn: '1111111111' }) + setTimeout(() => { + const books2 = dbr.transaction(['books'], 'readwrite').objectStore('books') + books2.put({ name: '1984', isbn: '2222222222' }) + resolve() + }, 1000) // Ensure will not run before onsuccess if idb-schema code fails to wait for this promise + }) + }) + .version(2) + .addCallback((dbr) => { + const trans = dbr.transaction(['books'], 'readwrite') + const books = trans.objectStore('books') + return new Promise((resolve) => { + books.put({ name: 'History of the World', isbn: '1234567890' }) + setTimeout(() => { + const books2 = dbr.transaction(['books'], 'readwrite').objectStore('books') + books2.put({ name: 'Mysteries of Life', isbn: '2234567890' }) + resolve() + }, 1000) // Ensure will not run before onsuccess if idb-schema code fails to wait for this promise + }) + }) + .addCallback((dbr) => { + const books = dbr.transaction(['books'], 'readwrite').objectStore('books') + books.put({ name: 'Beginner Chinese', isbn: '3234567890' }) + return new Promise((resolve) => { + setTimeout(() => { + caught = true + resolve() + }, 1000) // Ensure will not run before onsuccess if idb-schema code fails to wait for this promise + }) + }) + .addStore('journals') + .version(3) + .addStore('magazines') + + return schema.open(dbName, 3).then(function opened(originDb) { + db = originDb + const trans = db.transaction(['books', 'magazines']) + const store = trans.objectStore('books') + + let missingStore = false + try { + trans.objectStore('magazines') + } catch (err) { + missingStore = true + } + expect(missingStore).equal(false) + expect(caught).equal(true) + return new Promise((resolve) => { + const req1a = store.get('1111111111') + req1a.onsuccess = e1a => { + expect(e1a.target.result.name).equal('World Peace through World Language') + const req1b = store.get('2222222222') + req1b.onsuccess = e1b => { + expect(e1b.target.result.name).equal('1984') + const req2a = store.get('1234567890') + req2a.onsuccess = e2a => { + expect(e2a.target.result.name).equal('History of the World') + const req2b = store.get('2234567890') + req2b.onsuccess = e2b => { + expect(e2b.target.result.name).equal('Mysteries of Life') + const req2c = store.get('3234567890') + req2c.onsuccess = e2c => { + expect(e2c.target.result.name).equal('Beginner Chinese') + db.close() + resolve() + } + } + } + } + } + }) + }) + }) + + it('allows schema.upgrade to close connection', () => { + const schema = new Schema() + .version(1) + .addStore('books', { keyPath: 'isbn' }) + .addCallback((dbr) => { + return new Promise((resolve) => { + const trans = dbr.transaction(['books'], 'readwrite') + const books = trans.objectStore('books') + books.put({ name: 'World Peace through World Language', isbn: '1111111111' }) + setTimeout(() => { + const books2 = dbr.transaction(['books'], 'readwrite').objectStore('books') + books2.put({ name: '1984', isbn: '2222222222' }) + resolve() + }, 1000) // Ensure will not run before onsuccess if idb-schema code fails to wait for this promise + }) + }) + return schema.upgrade(dbName, 3).then((noDb) => { + expect(noDb).equal(undefined) + return new Promise((resolve) => { + setTimeout(() => { + open(dbName, 3).then((dbr) => { + expect(dbr.close).a('function') + const trans = dbr.transaction('books') + const store = trans.objectStore('books') + const req1a = store.get('1111111111') + req1a.onsuccess = e1a => { + expect(e1a.target.result.name).equal('World Peace through World Language') + const req1b = store.get('2222222222') + req1b.onsuccess = e1b => { + expect(e1b.target.result.name).equal('1984') + dbr.close() + resolve() + } + } + }) + }, 200) + }) + }) + }) + + it('allows user to resume after a bad schema.upgrade (bad sync callback)', () => { + let ct = 0 + let resumedOk = false + let caught = false + let secondRan = false + const schema = new Schema() + .version(1) + .addStore('books', { keyPath: 'isbn' }) + .addCallback((/* dbr */) => { + ct++ + if (ct % 2) throw new Error('bad callback') + resumedOk = true + }) + .addCallback(() => { + expect(resumedOk).equal(true) + secondRan = true + }) + return schema.upgrade(dbName).catch((err) => { + expect(err.message).equal('bad callback') + caught = true + return err.retry() + }).then((missingDb) => { + expect(ct).equal(2) + expect(caught).equal(true) + expect(secondRan).equal(true) + expect(missingDb).equal(undefined) + }) + }) + + it('allows user to resume after a bad schema.upgrade (bad async callback)', () => { + let ct = 0 + let firstRan = false + let thirdRan = false + let caught = false + const schema = new Schema() + .version(1) + .addStore('books', { keyPath: 'isbn' }) + .addCallback(() => { + firstRan = true + }) + .addCallback((/* dbr */) => { + return new Promise((res, rej) => { + setTimeout(() => { + ct++ + if (ct % 2) rej('bad async callback') + else res('ok') + }) + }) + }) + .addCallback(() => { + thirdRan = true + }) + return schema.upgrade(dbName).catch((err) => { + expect(err.message).equal('bad async callback') + expect(firstRan).equal(true) + expect(thirdRan).equal(false) + caught = true + return err.retry() + }).then((missingDb) => { + expect(thirdRan).equal(true) + expect(caught).equal(true) + expect(ct).equal(2) + expect(missingDb).equal(undefined) + }) + }) + + it('allows user to resume after schema.upgrade is tried twice', () => { + let ct = 0 + let firstRan = false + let thirdRan = false + let caught = false + const schema = new Schema() + .version(1) + .addStore('books', { keyPath: 'isbn' }) + .addCallback(() => { + firstRan = true + }) + .addCallback((/* dbr */) => { + return new Promise((res, rej) => { + setTimeout(() => { + ct++ + if (ct % 2) rej('bad async callback') + else res('ok') + }) + }) + }) + .addCallback(() => { + thirdRan = true + }) + return schema.upgrade(dbName).catch((err) => { + expect(err.message).equal('bad async callback') + expect(firstRan).equal(true) + expect(thirdRan).equal(false) + caught = true + }).then(() => { + expect(JSON.parse(localStorage.getItem('idb-incompleteUpgrades'))[dbName].version).equal(1) + schema.upgrade(dbName).catch((err) => { + return err.retry() + }).then(() => { + expect(JSON.parse(localStorage.getItem('idb-incompleteUpgrades'))[dbName]).equal(undefined) + expect(thirdRan).equal(true) + expect(caught).equal(true) + expect(ct).equal(2) + }) + }) + }) + + it('allows user to resume after a bad schema.open', () => { + let ct = 0 + let firstRan = false + let thirdRan = false + let caught = false + const schema = new Schema() + .version(1) + .addStore('books', { keyPath: 'isbn' }) + .addCallback(() => { + firstRan = true + }) + .addCallback((/* dbr */) => { + ct++ + if (ct % 2) throw new Error('bad callback') + else return Promise.resolve('resumed ok') + }) + .addCallback(() => { + thirdRan = true + }) + return schema.open(dbName).catch((err) => { + expect(err.message).equal('bad callback') + expect(firstRan).equal(true) + expect(thirdRan).equal(false) + caught = true + return err.retry() + }).then((originDb) => { + originDb.close() + expect(thirdRan).equal(true) + expect(caught).equal(true) + expect(ct).equal(2) + }) + }) })