diff --git a/.nvmrc b/.nvmrc index 9a0c3d3..80a9956 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v14.15.4 +v20.16.0 diff --git a/package-lock.json b/package-lock.json index eeb74c0..5d820fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,6 @@ "name": "idb-keyval", "version": "6.2.1", "license": "Apache-2.0", - "dependencies": { - "safari-14-idb-fix": "^3.0.0" - }, "devDependencies": { "@babel/core": "^7.18.5", "@babel/plugin-external-helpers": "^7.17.12", @@ -5128,11 +5125,6 @@ "tslib": "^2.1.0" } }, - "node_modules/safari-14-idb-fix": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/safari-14-idb-fix/-/safari-14-idb-fix-3.0.0.tgz", - "integrity": "sha512-eBNFLob4PMq8JA1dGyFn6G97q3/WzNtFK4RnzT1fnLq+9RyrGknzYiM/9B12MnKAxuj1IXr7UKYtTNtjyKMBog==" - }, "node_modules/safe-buffer": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", @@ -9588,11 +9580,6 @@ "tslib": "^2.1.0" } }, - "safari-14-idb-fix": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/safari-14-idb-fix/-/safari-14-idb-fix-3.0.0.tgz", - "integrity": "sha512-eBNFLob4PMq8JA1dGyFn6G97q3/WzNtFK4RnzT1fnLq+9RyrGknzYiM/9B12MnKAxuj1IXr7UKYtTNtjyKMBog==" - }, "safe-buffer": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", diff --git a/src/index.ts b/src/index.ts index 8467fbe..177092f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,21 +1,8 @@ -export function promisifyRequest( - request: IDBRequest | IDBTransaction, -): Promise { - return new Promise((resolve, reject) => { - // @ts-ignore - file size hacks - request.oncomplete = request.onsuccess = () => resolve(request.result); - // @ts-ignore - file size hacks - request.onabort = request.onerror = () => reject(request.error); - }); -} +import { openDatabase, promisifyRequest } from './util'; export function createStore(dbName: string, storeName: string): UseStore { - const request = indexedDB.open(dbName); - request.onupgradeneeded = () => request.result.createObjectStore(storeName); - const dbp = promisifyRequest(request); - return (txMode, callback) => - dbp.then((db) => + openDatabase(dbName, storeName).then(db => callback(db.transaction(storeName, txMode).objectStore(storeName)), ); } diff --git a/src/util.ts b/src/util.ts new file mode 100644 index 0000000..7bac989 --- /dev/null +++ b/src/util.ts @@ -0,0 +1,70 @@ +export function promisifyRequest( + request: IDBRequest | IDBTransaction, +): Promise { + return new Promise((resolve, reject) => { + // @ts-ignore - file size hacks + request.oncomplete = request.onsuccess = () => resolve(request.result); + // @ts-ignore - file size hacks + request.onabort = request.onerror = () => reject(request.error); + }); +} + +const qq: {[dbName: string]: {promise?: Promise; resolve: (db: IDBDatabase) => void;}} = {}; + +export async function openDatabase( + dbName: string, storeName: string, retry = true +): Promise { + const q = qq[dbName] || {}; + if (!q.promise) { + q.promise = new Promise(resolve => q.resolve = resolve); + qq[dbName] = q; + _openDatabase(dbName, storeName, q.resolve, retry); + } + + const db = await q.promise; + delete q.promise; + return db; +} + +// Meant to be used only in specific tests +export async function closeDatabase(dbName: string) { + if (!databases[dbName]) { + console.assert(true, `Could not find database "${dbName}" to close.`); + return; + } + + databases[dbName].close(); +} + +const databases: {[dbName: string]: IDBDatabase} = {}; + +async function _openDatabase( + dbName: string, storeName: string, resolve: (db: IDBDatabase) => void, retry: boolean +): Promise { + if (!databases[dbName]) { + const request = indexedDB.open(dbName); + request.onupgradeneeded = () => request.result.createObjectStore(storeName); + resolve(databases[dbName] = await promisifyRequest(request)); + return; + } + + if (!retry) { + resolve(databases[dbName]); + return; + } + + try { + // Basic way to check if the db is open. + databases[dbName].transaction(storeName); + resolve(databases[dbName]); + } catch (err: any) { + // Log here on purpose. + console.debug( + `Could not open a transaction on "${dbName}" due to ${err.name} (${err.message}). ` + + 'Trying to reopen the connection...' + ); + // Try re-open. + delete databases[dbName]; + _openDatabase(dbName, storeName, resolve, retry); + } +} \ No newline at end of file diff --git a/test/index.ts b/test/index.ts index 19540b7..9cfddde 100644 --- a/test/index.ts +++ b/test/index.ts @@ -4,7 +4,6 @@ import { get, set, del, - promisifyRequest, clear, createStore, keys, @@ -14,7 +13,8 @@ import { update, getMany, delMany -} from '../src'; +} from '../src/index'; +import { closeDatabase, promisifyRequest } from '../src/util'; import { assert as typeAssert, IsExact } from 'conditional-type-checks'; const { assert } = chai; @@ -551,5 +551,39 @@ mocha.setup('tdd'); }); }); + suite('Resilience', () => { + setup(() => Promise.all([clear(), clear(customStore)])); + + test('Database connection recovery', async () => { + await set('foo', 'bar'); + // Close the database just before getting a value. This is supposed to simulate + // an unexpected/platform closure of the database for whatever reason. The problem + // is present on iOS Safari, but not on Android Chrome/WebView and it's difficult + // to trigger/reproduce since it appears random. + closeDatabase('keyval-store'); + + let value; + try { + value = await get('foo'); + } catch (_) { + assert.fail('A get(...) must not throw if the db connection has been closed.'); + } + + assert.strictEqual(value, 'bar', 'Could not get value'); + + closeDatabase('keyval-store'); + try { + await setMany([ + ['bar', 'baz'], + ['baz', 'cat'], + ]); + } catch (_) { + assert.fail('A setMany(...) must not throw if the db connection has been closed.'); + } + + assert.deepEqual(await keys(), ['bar', 'baz', 'foo'], 'Could not get all test keys'); + }); + }); + mocha.run(); })();