diff --git a/lib/storage/providers/IDBKeyValProvider/createStore.ts b/lib/storage/providers/IDBKeyValProvider/createStore.ts index b9d3da671..ca57a320b 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 {logAlert, logInfo} 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,30 @@ 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; + let closedBy: 'browser' | 'versionchange' | 'verifyStoreExists' | 'unknown' = 'unknown'; + + const attachHandlers = (db: IDBDatabase) => { + // 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 = () => { + logInfo('IDB connection closed by browser', {dbName, storeName}); + closedBy = 'browser'; + 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 = () => { + logInfo('IDB connection closing due to versionchange', {dbName, storeName}); + closedBy = 'versionchange'; + db.close(); + dbp = undefined; + }; + }; + const getDB = () => { if (dbp) return dbp; const request = indexedDB.open(dbName); @@ -15,12 +39,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 () => {}, ); @@ -36,6 +55,7 @@ function createStore(dbName: string, storeName: string): UseStore { logInfo(`Store ${storeName} does not exist in database ${dbName}.`); const nextVersion = db.version + 1; + closedBy = 'verifyStoreExists'; db.close(); const request = indexedDB.open(dbName, nextVersion); @@ -50,13 +70,34 @@ function createStore(dbName: string, storeName: string): UseStore { }; 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))); + } + + return (txMode, callback) => + executeTransaction(txMode, callback).catch((error) => { + if (error instanceof DOMException && error.name === 'InvalidStateError') { + logAlert('IDB InvalidStateError, retrying with fresh connection', { + dbName, + storeName, + txMode, + closedBy, + errorMessage: error.message, + }); + dbp = undefined; + closedBy = 'unknown'; + // 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..7b7749941 --- /dev/null +++ b/tests/unit/storage/providers/createStoreTest.ts @@ -0,0 +1,320 @@ +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 - connection resilience', () => { + 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', + closedBy: 'unknown', + errorMessage: 'The database connection is closing.', + }); + }); + + it('should log closedBy as "browser" when onclose preceded the error', async () => { + const dbName = uniqueDBName(); + const store = createStore(dbName, STORE_NAME); + + const db = await captureDB(store); + db.onclose!.call(db, new Event('close')); + + 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('readonly', (s) => IDB.promisifyRequest(s.getAllKeys())); + + expect(logAlertSpy).toHaveBeenCalledWith('IDB InvalidStateError, retrying with fresh connection', expect.objectContaining({closedBy: 'browser'})); + }); + + it('should log closedBy as "versionchange" when onversionchange preceded the error', async () => { + const dbName = uniqueDBName(); + const store = createStore(dbName, STORE_NAME); + + const db = await captureDB(store); + // @ts-expect-error -- our handler ignores the event argument + db.onversionchange!.call(db, new Event('versionchange')); + + 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('readonly', (s) => IDB.promisifyRequest(s.getAllKeys())); + + expect(logAlertSpy).toHaveBeenCalledWith('IDB InvalidStateError, retrying with fresh connection', expect.objectContaining({closedBy: 'versionchange'})); + }); + + it('should reset closedBy to "unknown" after a retry', async () => { + const dbName = uniqueDBName(); + const store = createStore(dbName, STORE_NAME); + + const db = await captureDB(store); + db.onclose!.call(db, new Event('close')); + + 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); + }); + + // First operation triggers retry with closedBy: 'browser' + await store('readonly', (s) => IDB.promisifyRequest(s.getAllKeys())); + expect(logAlertSpy).toHaveBeenCalledWith('IDB InvalidStateError, retrying with fresh connection', expect.objectContaining({closedBy: 'browser'})); + + logAlertSpy.mockClear(); + + // Force another InvalidStateError — closedBy should now be 'unknown' + callCount = 0; + + await store('readonly', (s) => IDB.promisifyRequest(s.getAllKeys())); + expect(logAlertSpy).toHaveBeenCalledWith('IDB InvalidStateError, retrying with fresh connection', expect.objectContaining({closedBy: 'unknown'})); + }); + }); + + 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); + 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); + 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); + 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 versionchange', {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); + // @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'); + }); + }); +});