diff --git a/lib/storage/providers/IDBKeyValProvider/createStore.ts b/lib/storage/providers/IDBKeyValProvider/createStore.ts index b9d3da671..d7c6d0f8b 100644 --- a/lib/storage/providers/IDBKeyValProvider/createStore.ts +++ b/lib/storage/providers/IDBKeyValProvider/createStore.ts @@ -1,6 +1,6 @@ import * as IDB from 'idb-keyval'; import type {UseStore} from 'idb-keyval'; -import {logInfo} from '../../../Logger'; +import * as Logger from '../../../Logger'; // This is a copy of the createStore function from idb-keyval, we need a custom implementation // because we need to create the database manually in order to ensure that the store exists before we use it. @@ -8,6 +8,28 @@ import {logInfo} from '../../../Logger'; // source: https://github.com/jakearchibald/idb-keyval/blob/9d19315b4a83897df1e0193dccdc29f78466a0f3/src/index.ts#L12 function createStore(dbName: string, storeName: string): UseStore { let dbp: Promise | undefined; + + const attachHandlers = (db: IDBDatabase) => { + // Browsers may close idle IDB connections at any time, especially Safari. + // We clear the cached promise so the next operation opens a fresh connection. + // https://developer.mozilla.org/en-US/docs/Web/API/IDBDatabase/close_event + // eslint-disable-next-line no-param-reassign + db.onclose = () => { + Logger.logInfo('IDB connection closed by browser', {dbName, storeName}); + dbp = undefined; + }; + + // When another tab triggers a DB version upgrade, we must close the connection + // to unblock the upgrade; otherwise the other tab's open request hangs indefinitely. + // https://developer.mozilla.org/en-US/docs/Web/API/IDBDatabase/versionchange_event + // eslint-disable-next-line no-param-reassign + db.onversionchange = () => { + Logger.logInfo('IDB connection closing due to version change', {dbName, storeName}); + db.close(); + dbp = undefined; + }; + }; + const getDB = () => { if (dbp) return dbp; const request = indexedDB.open(dbName); @@ -15,12 +37,7 @@ function createStore(dbName: string, storeName: string): UseStore { dbp = IDB.promisifyRequest(request); dbp.then( - (db) => { - // It seems like Safari sometimes likes to just close the connection. - // It's supposed to fire this event when that happens. Let's hope it does! - // eslint-disable-next-line no-param-reassign - db.onclose = () => (dbp = undefined); - }, + attachHandlers, // eslint-disable-next-line @typescript-eslint/no-empty-function () => {}, ); @@ -34,7 +51,7 @@ function createStore(dbName: string, storeName: string): UseStore { return db; } - logInfo(`Store ${storeName} does not exist in database ${dbName}.`); + Logger.logInfo(`Store ${storeName} does not exist in database ${dbName}.`); const nextVersion = db.version + 1; db.close(); @@ -45,18 +62,39 @@ function createStore(dbName: string, storeName: string): UseStore { return; } - logInfo(`Creating store ${storeName} in database ${dbName}.`); + Logger.logInfo(`Creating store ${storeName} in database ${dbName}.`); updatedDatabase.createObjectStore(storeName); }; dbp = IDB.promisifyRequest(request); + // eslint-disable-next-line @typescript-eslint/no-empty-function + dbp.then(attachHandlers, () => {}); return dbp; }; - return (txMode, callback) => - getDB() + function executeTransaction(txMode: IDBTransactionMode, callback: (store: IDBObjectStore) => T | PromiseLike): Promise { + return getDB() .then(verifyStoreExists) .then((db) => callback(db.transaction(storeName, txMode).objectStore(storeName))); + } + + // If the connection was closed between getDB() resolving and db.transaction() executing, + // the transaction throws InvalidStateError. We catch it and retry once with a fresh connection. + return (txMode, callback) => + executeTransaction(txMode, callback).catch((error) => { + if (error instanceof DOMException && error.name === 'InvalidStateError') { + Logger.logAlert('IDB InvalidStateError, retrying with fresh connection', { + dbName, + storeName, + txMode, + errorMessage: error.message, + }); + dbp = undefined; + // Retry only once — this call is not wrapped, so if it also fails the error propagates normally. + return executeTransaction(txMode, callback); + } + throw error; + }); } export default createStore; diff --git a/tests/unit/storage/providers/createStoreTest.ts b/tests/unit/storage/providers/createStoreTest.ts new file mode 100644 index 000000000..231244706 --- /dev/null +++ b/tests/unit/storage/providers/createStoreTest.ts @@ -0,0 +1,248 @@ +import * as IDB from 'idb-keyval'; +import createStore from '../../../../lib/storage/providers/IDBKeyValProvider/createStore'; +import * as Logger from '../../../../lib/Logger'; + +const STORE_NAME = 'teststore'; +let testDbCounter = 0; + +function uniqueDBName() { + testDbCounter += 1; + return `TestCreateStoreDB_${testDbCounter}`; +} + +/** + * Captures the internal IDBDatabase instance used by a store by intercepting + * the first db.transaction() call. + */ +async function captureDB(store: ReturnType): Promise { + const captured: {db?: IDBDatabase} = {}; + const original = IDBDatabase.prototype.transaction; + const spy = jest.spyOn(IDBDatabase.prototype, 'transaction').mockImplementation(function (this: IDBDatabase, ...args) { + captured.db = this; + spy.mockRestore(); + return original.apply(this, args); + }); + await store('readonly', (s) => IDB.promisifyRequest(s.getAllKeys())); + return captured.db; +} + +describe('createStore', () => { + let logAlertSpy: jest.SpyInstance; + let logInfoSpy: jest.SpyInstance; + + beforeEach(() => { + logAlertSpy = jest.spyOn(Logger, 'logAlert'); + logInfoSpy = jest.spyOn(Logger, 'logInfo'); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('InvalidStateError retry', () => { + it('should retry once and succeed when db.transaction throws InvalidStateError', async () => { + const store = createStore(uniqueDBName(), STORE_NAME); + + await store('readwrite', (s) => { + s.put('initial', 'key1'); + return IDB.promisifyRequest(s.transaction); + }); + + const original = IDBDatabase.prototype.transaction; + let callCount = 0; + jest.spyOn(IDBDatabase.prototype, 'transaction').mockImplementation(function (this: IDBDatabase, ...args) { + callCount += 1; + if (callCount === 1) { + throw new DOMException('The database connection is closing.', 'InvalidStateError'); + } + return original.apply(this, args); + }); + + const result = await store('readonly', (s) => IDB.promisifyRequest(s.get('key1'))); + + expect(result).toBe('initial'); + expect(callCount).toBe(2); + }); + + it('should propagate InvalidStateError if retry also fails', async () => { + const store = createStore(uniqueDBName(), STORE_NAME); + + await store('readwrite', (s) => { + s.put('value', 'key1'); + return IDB.promisifyRequest(s.transaction); + }); + + jest.spyOn(IDBDatabase.prototype, 'transaction').mockImplementation(() => { + throw new DOMException('The database connection is closing.', 'InvalidStateError'); + }); + + await expect(store('readonly', (s) => IDB.promisifyRequest(s.get('key1')))).rejects.toThrow(DOMException); + expect(logAlertSpy).toHaveBeenCalledTimes(1); + }); + + it('should not retry on non-InvalidStateError DOMException', async () => { + const store = createStore(uniqueDBName(), STORE_NAME); + + await store('readwrite', (s) => { + s.put('value', 'key1'); + return IDB.promisifyRequest(s.transaction); + }); + + let callCount = 0; + jest.spyOn(IDBDatabase.prototype, 'transaction').mockImplementation(() => { + callCount += 1; + throw new DOMException('Not found', 'NotFoundError'); + }); + + await expect(store('readonly', (s) => IDB.promisifyRequest(s.get('key1')))).rejects.toThrow(DOMException); + expect(callCount).toBe(1); + expect(logAlertSpy).not.toHaveBeenCalled(); + }); + + it('should not retry on non-DOMException errors', async () => { + const store = createStore(uniqueDBName(), STORE_NAME); + + await store('readwrite', (s) => { + s.put('value', 'key1'); + return IDB.promisifyRequest(s.transaction); + }); + + let callCount = 0; + jest.spyOn(IDBDatabase.prototype, 'transaction').mockImplementation(() => { + callCount += 1; + throw new TypeError('Something went wrong'); + }); + + await expect(store('readonly', (s) => IDB.promisifyRequest(s.get('key1')))).rejects.toThrow(TypeError); + expect(callCount).toBe(1); + expect(logAlertSpy).not.toHaveBeenCalled(); + }); + + it('should preserve data integrity after a successful retry', async () => { + const store = createStore(uniqueDBName(), STORE_NAME); + + await store('readwrite', (s) => { + s.put('existing', 'key0'); + return IDB.promisifyRequest(s.transaction); + }); + + const original = IDBDatabase.prototype.transaction; + let callCount = 0; + jest.spyOn(IDBDatabase.prototype, 'transaction').mockImplementation(function (this: IDBDatabase, ...args) { + callCount += 1; + if (callCount === 1) { + throw new DOMException('The database connection is closing.', 'InvalidStateError'); + } + return original.apply(this, args); + }); + + await store('readwrite', (s) => { + s.put('retried_value', 'key1'); + return IDB.promisifyRequest(s.transaction); + }); + + jest.restoreAllMocks(); + logAlertSpy = jest.spyOn(Logger, 'logAlert'); + + const result = await store('readonly', (s) => IDB.promisifyRequest(s.get('key1'))); + expect(result).toBe('retried_value'); + }); + }); + + describe('diagnostic logging', () => { + it('should log alert with all diagnostic fields on retry', async () => { + const dbName = uniqueDBName(); + const store = createStore(dbName, STORE_NAME); + + await store('readwrite', (s) => { + s.put('value', 'key1'); + return IDB.promisifyRequest(s.transaction); + }); + + const original = IDBDatabase.prototype.transaction; + let callCount = 0; + jest.spyOn(IDBDatabase.prototype, 'transaction').mockImplementation(function (this: IDBDatabase, ...args) { + callCount += 1; + if (callCount === 1) { + throw new DOMException('The database connection is closing.', 'InvalidStateError'); + } + return original.apply(this, args); + }); + + await store('readwrite', (s) => { + s.put('value2', 'key2'); + return IDB.promisifyRequest(s.transaction); + }); + + expect(logAlertSpy).toHaveBeenCalledWith('IDB InvalidStateError, retrying with fresh connection', { + dbName, + storeName: STORE_NAME, + txMode: 'readwrite', + errorMessage: 'The database connection is closing.', + }); + }); + }); + + describe('onclose handler', () => { + it('should log info when browser closes the connection', async () => { + const dbName = uniqueDBName(); + const store = createStore(dbName, STORE_NAME); + + const db = await captureDB(store); + expect(db).toBeDefined(); + db?.onclose?.call(db, new Event('close')); + + expect(logInfoSpy).toHaveBeenCalledWith('IDB connection closed by browser', {dbName, storeName: STORE_NAME}); + }); + + it('should recover with a fresh connection after browser close', async () => { + const store = createStore(uniqueDBName(), STORE_NAME); + + await store('readwrite', (s) => { + s.put('value', 'key1'); + return IDB.promisifyRequest(s.transaction); + }); + + const db = await captureDB(store); + expect(db).toBeDefined(); + db?.onclose?.call(db, new Event('close')); + + const result = await store('readonly', (s) => IDB.promisifyRequest(s.get('key1'))); + expect(result).toBe('value'); + }); + }); + + describe('onversionchange handler', () => { + it('should close connection and log when versionchange fires', async () => { + const dbName = uniqueDBName(); + const store = createStore(dbName, STORE_NAME); + + const db = await captureDB(store); + expect(db).toBeDefined(); + const closeSpy = jest.spyOn(db!, 'close'); + + // @ts-expect-error -- our handler ignores the event argument + db?.onversionchange?.call(db, new Event('versionchange')); + + expect(closeSpy).toHaveBeenCalled(); + expect(logInfoSpy).toHaveBeenCalledWith('IDB connection closing due to version change', {dbName, storeName: STORE_NAME}); + }); + + it('should recover with a fresh connection after versionchange', async () => { + const store = createStore(uniqueDBName(), STORE_NAME); + + await store('readwrite', (s) => { + s.put('value', 'key1'); + return IDB.promisifyRequest(s.transaction); + }); + + const db = await captureDB(store); + expect(db).toBeDefined(); + // @ts-expect-error -- our handler ignores the event argument + db?.onversionchange?.call(db, new Event('versionchange')); + + const result = await store('readonly', (s) => IDB.promisifyRequest(s.get('key1'))); + expect(result).toBe('value'); + }); + }); +});