diff --git a/History.md b/History.md index 11ead80..d681d38 100644 --- a/History.md +++ b/History.md @@ -1,3 +1,10 @@ +## 3.3.0 + +* 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) + ## 3.2.1 / 2015-11-29 * remove `component-clone` as deps, diff --git a/Readme.md b/Readme.md index 938456e..11d1082 100644 --- a/Readme.md +++ b/Readme.md @@ -70,6 +70,75 @@ 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`. + +### 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). + +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. @@ -156,7 +225,11 @@ Delete index by `name` from current store. ### 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 methods are used, the db result will be passed instead. ```js new Schema() @@ -169,6 +242,15 @@ 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`. + ### 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..22e896c --- /dev/null +++ b/src/idb-factory.js @@ -0,0 +1,156 @@ + +/** + * 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 => { + upgradeCallback(e) + } + } + 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..fe91b3d 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 @@ -168,50 +179,214 @@ export default class Schema { } /** - * 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 + /** + * Generate open connection running a sequence of upgrades, keeping the connection open. + * + * @return {Promise} + */ - 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) - }) + open(dbName, version) { + return this.upgrade(dbName, version, true) + } - versionSchema.dropStores.forEach((s) => { - db.deleteObjectStore(s.name) - }) + /** + * Generate open connection running a sequence of upgrades. + * + * @return {Promise} + */ - versionSchema.indexes.forEach((i) => { - tr.objectStore(i.storeName).createIndex(i.name, i.field, { - unique: i.unique, - multiEntry: i.multiEntry, - }) + upgrade(dbName, version, keepOpen) { + let versionSchema + let versions + let afterOpen + const setVersions = () => { + versions = values(this._versions).sort((a, b) => a.version - b.version).values() + } + setVersions() + function thenableUpgradeVersion(dbLast, res, rej, start) { + const lastVers = dbLast.version + let versionIter = versions.next() + while (!versionIter.done && versionIter.value.version < lastVers) { + versionIter = versions.next() + } + versionSchema = versionIter.value + if (versionIter.done || versionSchema.version > version) { + if (!keepOpen) { + dbLast.close() + res() + } else { + res(dbLast) + } + return + } + dbLast.close() + + setTimeout(() => { + open(dbName, versionSchema.version, upgradeneeded((e, ...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(rejct).then((dbs) => { + setVersions() + thenableUpgradeVersion(dbs, resolver, rejct, callbackIndex) + }) + }) + } + 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 complete = false + 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).catch((err) => { + processReject(err, i) + }) + complete = true + return true + } + }) + if (cbFailed && !complete) return + promise.then(() => thenableUpgradeVersion(db, res, rej)) + } + // 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(rejct).then((dbs) => { + afterOpen(dbs, resolver, rejct, iudb.callbackIndex) + }) + }) + } + reject(err) + return + } + const upgrade = upgradeneeded((e, ...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((err) => { + if (err.type === 'blocked') { + reject(err) + } + throw err + }).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) { + thenableUpgradeVersion(db, resolve, reject) + return + } + afterOpen(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} + */ + callback() { + const versions = values(clone(this._versions)).sort((a, b) => a.version - b.version) + return upgradeneeded((e, ...dbInfo) => { + versions.forEach((versionSchema) => { + upgradeVersion(versionSchema, ...dbInfo) versionSchema.callbacks.forEach((cb) => { cb(e) }) }) - } + }) } /** @@ -262,3 +437,45 @@ 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 + const db = e.target.result + const tr = e.target.transaction + cb(e, oldVersion, db, tr) + } +} + +function upgradeVersion(versionSchema, oldVersion, db, tr) { + 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) + }) + + 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..97d6874 100644 --- a/test/index.js +++ b/test/index.js @@ -2,18 +2,27 @@ import 'indexeddbshim' import ES6Promise from 'es6-promise' import { expect } from 'chai' import { pluck, toArray } from 'lodash' -import { del, open } from 'idb-factory' +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() @@ -62,6 +71,7 @@ describe('idb-schema', function idbSchemaTest() { return request(users.count()).then((count) => { expect(count).equal(3) + db.close() }) }) }) @@ -99,6 +109,7 @@ describe('idb-schema', function idbSchemaTest() { const magazines = db.transaction(['magazines'], 'readonly').objectStore('magazines') expect(toArray(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') @@ -167,4 +179,267 @@ describe('idb-schema', function idbSchemaTest() { expect(() => schema.delIndex('')).throws('"name" is required') expect(() => schema.delIndex('byField')).throws('"byField" index is not defined') }) + + 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) + }) + }) })