diff --git a/lib/storage/index.ts b/lib/storage/index.ts index c143c8e85..c3415db00 100644 --- a/lib/storage/index.ts +++ b/lib/storage/index.ts @@ -26,32 +26,26 @@ function degradePerformance(error: Error) { } /** - * Runs a piece of code and degrades performance if certain errors are thrown + * Runs a piece of code and degrades performance if certain errors are thrown. + * Catches both sync throws and async rejections from the storage provider. */ function tryOrDegradePerformance(fn: () => Promise | T, waitForInitialization = true): Promise { - return new Promise((resolve, reject) => { - const promise = waitForInitialization ? initPromise : Promise.resolve(); - - promise.then(() => { - try { - resolve(fn()); - } catch (error) { - // Test for known critical errors that the storage provider throws, e.g. when storage is full - if (error instanceof Error) { - // IndexedDB error when storage is full (https://github.com/Expensify/App/issues/29403) - if (error.message.includes('Internal error opening backing store for indexedDB.open')) { - degradePerformance(error); - } - - // catch the error if DB connection can not be established/DB can not be created - if (error.message.includes('IDBKeyVal store could not be created')) { - degradePerformance(error); - } - } - - reject(error); + const waitPromise = waitForInitialization ? initPromise : Promise.resolve(); + + return waitPromise.then(() => fn()).catch((error) => { + if (error instanceof Error) { + // IndexedDB error when backing store is corrupted (https://github.com/Expensify/App/issues/87862) + if (error.message.includes('Internal error opening backing store for indexedDB.open')) { + degradePerformance(error); } - }); + + // DB connection could not be established / DB could not be created + if (error.message.includes('IDBKeyVal store could not be created')) { + degradePerformance(error); + } + } + + throw error; }); } diff --git a/lib/storage/providers/IDBKeyValProvider/createStore.ts b/lib/storage/providers/IDBKeyValProvider/createStore.ts index d7c6d0f8b..16cf627bb 100644 --- a/lib/storage/providers/IDBKeyValProvider/createStore.ts +++ b/lib/storage/providers/IDBKeyValProvider/createStore.ts @@ -2,6 +2,28 @@ import * as IDB from 'idb-keyval'; import type {UseStore} from 'idb-keyval'; import * as Logger from '../../../Logger'; +/** + * Attempts to heal a corrupted IDB by deleting the database entirely. + * On success the next `indexedDB.open()` will recreate it from scratch. + */ +function healCorruptedDatabase(dbName: string): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.deleteDatabase(dbName); + request.onsuccess = () => { + Logger.logInfo('IDB database deleted successfully, will recreate on next operation', {dbName}); + resolve(); + }; + request.onerror = () => { + Logger.logAlert('IDB deleteDatabase failed, corruption is unrecoverable', {dbName}); + reject(request.error ?? new DOMException('Failed to delete corrupted database', 'UnknownError')); + }; + request.onblocked = () => { + Logger.logAlert('IDB deleteDatabase blocked by other connections', {dbName}); + reject(new DOMException('deleteDatabase blocked', 'UnknownError')); + }; + }); +} + // 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. // If the store does not exist, idb-keyval will throw an error @@ -36,11 +58,11 @@ function createStore(dbName: string, storeName: string): UseStore { request.onupgradeneeded = () => request.result.createObjectStore(storeName); dbp = IDB.promisifyRequest(request); - dbp.then( - attachHandlers, - // eslint-disable-next-line @typescript-eslint/no-empty-function - () => {}, - ); + dbp.then(attachHandlers, () => { + // Clear the cached rejected promise so the next operation retries + // with a fresh indexedDB.open() instead of returning the same rejection. + dbp = undefined; + }); return dbp; }; @@ -67,8 +89,9 @@ function createStore(dbName: string, storeName: string): UseStore { }; dbp = IDB.promisifyRequest(request); - // eslint-disable-next-line @typescript-eslint/no-empty-function - dbp.then(attachHandlers, () => {}); + dbp.then(attachHandlers, () => { + dbp = undefined; + }); return dbp; }; @@ -80,6 +103,10 @@ function createStore(dbName: string, storeName: string): UseStore { // 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. + // + // If the LevelDB backing store is corrupted (Chromium-specific), we attempt to heal by + // deleting the database and retrying. If healing fails, the error propagates up to + // tryOrDegradePerformance which falls back to MemoryOnlyProvider. return (txMode, callback) => executeTransaction(txMode, callback).catch((error) => { if (error instanceof DOMException && error.name === 'InvalidStateError') { @@ -93,6 +120,17 @@ function createStore(dbName: string, storeName: string): UseStore { // Retry only once — this call is not wrapped, so if it also fails the error propagates normally. return executeTransaction(txMode, callback); } + + if (error instanceof DOMException && error.message.includes('Internal error opening backing store')) { + Logger.logAlert('IDB backing store corrupted, attempting to heal', { + dbName, + storeName, + errorMessage: error.message, + }); + dbp = undefined; + return healCorruptedDatabase(dbName).then(() => executeTransaction(txMode, callback)); + } + throw error; }); } diff --git a/tests/unit/storage/StorageCorruptionTest.ts b/tests/unit/storage/StorageCorruptionTest.ts new file mode 100644 index 000000000..4ebfe22b4 --- /dev/null +++ b/tests/unit/storage/StorageCorruptionTest.ts @@ -0,0 +1,141 @@ +/* eslint-disable import/first */ +jest.unmock('../../../lib/storage'); +jest.unmock('../../../lib/storage/platforms/index'); +jest.unmock('../../../lib/storage/providers/IDBKeyValProvider'); + +// React Native jest preset resolves platforms/index to platforms/index.native (SQLiteProvider). +// Force it to use IDBKeyValProvider for these tests. +jest.mock('../../../lib/storage/platforms/index.native', () => require('../../../lib/storage/providers/IDBKeyValProvider')); + +import type {UseStore} from 'idb-keyval'; +import type StorageModule from '../../../lib/storage'; +import type IDBKeyValProviderModule from '../../../lib/storage/providers/IDBKeyValProvider'; + +// Each test gets fresh module instances to avoid shared state (e.g. degraded provider persisting). +let storage: typeof StorageModule; +let IDBKeyValProvider: typeof IDBKeyValProviderModule; + +/** + * Creates a DOMException that matches the real Chromium "Internal error opening backing store" error. + */ +function createBackingStoreError(): DOMException { + return new DOMException('Internal error opening backing store for indexedDB.open.', 'UnknownError'); +} + +describe('Storage corruption detection and healing', () => { + beforeEach(() => { + jest.resetModules(); + storage = require('../../../lib/storage').default; + IDBKeyValProvider = require('../../../lib/storage/providers/IDBKeyValProvider').default; + storage.init(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('Async error handling in tryOrDegradePerformance', () => { + it('should catch async IDB rejections and degrade to MemoryOnlyProvider', async () => { + await storage.setItem('test_key', {id: 'test_value'}); + expect(await storage.getItem('test_key')).toEqual({id: 'test_value'}); + + // Replace the store function entirely — bypasses createStore's heal logic, + // tests that tryOrDegradePerformance catches async rejections. + IDBKeyValProvider.store = (() => Promise.reject(createBackingStoreError())) as unknown as UseStore; + + await expect(storage.setItem('key2', {id: 'value2'})).rejects.toThrow('Internal error opening backing store'); + expect(storage.getStorageProvider().name).toBe('MemoryOnlyProvider'); + }); + }); + + describe('Corruption healing in createStore', () => { + it('should detect corruption, delete the database, re-init, and recover', async () => { + await storage.setItem('important_key', {id: 'important_value'}); + expect(await storage.getItem('important_key')).toEqual({id: 'important_value'}); + + // Re-init to get a fresh createStore instance with dbp = undefined + IDBKeyValProvider.init(); + + // Mock indexedDB.open to fail once with backing store error, then work normally. + // idb-keyval's promisifyRequest sets request.onsuccess/onerror as properties, + // so we must trigger those callbacks, not dispatch DOM events. + let openCallCount = 0; + const realOpen = indexedDB.open.bind(indexedDB); + const openSpy = jest.spyOn(indexedDB, 'open').mockImplementation((name: string, version?: number) => { + openCallCount++; + if (openCallCount <= 1) { + const req = {} as IDBOpenDBRequest; + Promise.resolve().then(() => { + Object.defineProperty(req, 'error', {value: createBackingStoreError(), configurable: true}); + if (typeof req.onerror === 'function') { + req.onerror(new Event('error') as Event & {target: IDBOpenDBRequest}); + } + }); + return req; + } + openSpy.mockRestore(); + return realOpen(name, version); + }); + + const deleteDbSpy = jest.spyOn(indexedDB, 'deleteDatabase'); + + await storage.setItem('new_key', {id: 'new_value'}); + + expect(deleteDbSpy).toHaveBeenCalledWith('OnyxDB'); + + const recovered = await storage.getItem('new_key'); + expect(recovered).toEqual({id: 'new_value'}); + }); + + it('should degrade to MemoryOnlyProvider when healing fails', async () => { + await storage.setItem('key1', {id: 'value1'}); + + // All IDB operations fail permanently + IDBKeyValProvider.store = (() => Promise.reject(createBackingStoreError())) as unknown as UseStore; + + // deleteDatabase also fails + jest.spyOn(indexedDB, 'deleteDatabase').mockImplementation(() => { + const req = {} as IDBOpenDBRequest; + Promise.resolve().then(() => { + Object.defineProperty(req, 'error', {value: createBackingStoreError(), configurable: true}); + if (typeof req.onerror === 'function') { + req.onerror(new Event('error') as Event & {target: IDBOpenDBRequest}); + } + }); + return req; + }); + + try { + await storage.setItem('key2', {id: 'value2'}); + } catch { + // Expected + } + + // Subsequent operations should work via MemoryOnlyProvider + await storage.setItem('memory_key', {id: 'memory_value'}); + expect(await storage.getItem('memory_key')).toEqual({id: 'memory_value'}); + expect(storage.getStorageProvider().name).toBe('MemoryOnlyProvider'); + }); + }); + + describe('Error classification', () => { + it('should only trigger corruption healing for backing store errors, not other IDB errors', async () => { + await storage.setItem('key1', {id: 'value1'}); + + // QuotaExceeded — not a backing store error + IDBKeyValProvider.store = (() => Promise.reject(new DOMException('Quota exceeded', 'QuotaExceededError'))) as unknown as UseStore; + + const deleteDbSpy = jest.spyOn(indexedDB, 'deleteDatabase'); + + try { + await storage.setItem('key2', {id: 'value2'}); + } catch { + // May or may not throw + } + + expect(deleteDbSpy).not.toHaveBeenCalled(); + // Should NOT have degraded — QuotaExceeded is not a backing store error + expect(storage.getStorageProvider().name).not.toBe('MemoryOnlyProvider'); + }); + }); +});