@@ -245,4 +245,172 @@ describe('createStore', () => {
245245 expect ( result ) . toBe ( 'value' ) ;
246246 } ) ;
247247 } ) ;
248+
249+ describe ( 'backing store healing' , ( ) => {
250+ /**
251+ * Helper: creates a DOMException matching Chromium's backing store corruption error.
252+ */
253+ function backingStoreError ( ) {
254+ return new DOMException ( 'Internal error opening backing store for indexedDB.open.' , 'UnknownError' ) ;
255+ }
256+
257+ it ( 'should heal mid-session by closing and reopening the connection' , async ( ) => {
258+ const store = createStore ( uniqueDBName ( ) , STORE_NAME ) ;
259+
260+ await store ( 'readwrite' , ( s ) => {
261+ s . put ( 'value' , 'key1' ) ;
262+ return IDB . promisifyRequest ( s . transaction ) ;
263+ } ) ;
264+
265+ const original = IDBDatabase . prototype . transaction ;
266+ let callCount = 0 ;
267+ jest . spyOn ( IDBDatabase . prototype , 'transaction' ) . mockImplementation ( function ( this : IDBDatabase , ...args ) {
268+ callCount ++ ;
269+ if ( callCount === 1 ) {
270+ throw backingStoreError ( ) ;
271+ }
272+ return original . apply ( this , args ) ;
273+ } ) ;
274+
275+ const result = await store ( 'readonly' , ( s ) => IDB . promisifyRequest ( s . get ( 'key1' ) ) ) ;
276+ expect ( result ) . toBe ( 'value' ) ;
277+ expect ( callCount ) . toBe ( 2 ) ;
278+ expect ( logInfoSpy ) . toHaveBeenCalledWith ( 'IDB heal: backing store error, attempting close + reopen' , expect . objectContaining ( { healAttemptsRemaining : 2 } ) ) ;
279+ } ) ;
280+
281+ it ( 'should heal on init when indexedDB.open() rejects with UnknownError' , async ( ) => {
282+ const dbName = uniqueDBName ( ) ;
283+
284+ // Pre-create the DB with data
285+ const setupStore = createStore ( dbName , STORE_NAME ) ;
286+ await setupStore ( 'readwrite' , ( s ) => {
287+ s . put ( 'persisted' , 'key1' ) ;
288+ return IDB . promisifyRequest ( s . transaction ) ;
289+ } ) ;
290+
291+ // Fresh store instance (simulates app restart — dbp starts undefined)
292+ const store = createStore ( dbName , STORE_NAME ) ;
293+
294+ // Mock indexedDB.open to fail twice, then succeed.
295+ // First failure: initial open in the first store() call.
296+ // Second failure: the heal retry's open in that same call — propagates error.
297+ // Third call (second store() invocation): succeeds, proving budget still has 1 left.
298+ const realOpen = indexedDB . open . bind ( indexedDB ) ;
299+ let openCallCount = 0 ;
300+ jest . spyOn ( indexedDB , 'open' ) . mockImplementation ( ( name : string , version ?: number ) => {
301+ openCallCount ++ ;
302+ if ( openCallCount <= 2 ) {
303+ const req = { } as IDBOpenDBRequest ;
304+ Promise . resolve ( ) . then ( ( ) => {
305+ Object . defineProperty ( req , 'error' , { value : backingStoreError ( ) , configurable : true } ) ;
306+ req . onerror ?.( new Event ( 'error' ) as Event & { target : IDBOpenDBRequest } ) ;
307+ } ) ;
308+ return req ;
309+ }
310+ return realOpen ( name , version ) ;
311+ } ) ;
312+
313+ // First call: open fails, heal retry also fails — error propagates
314+ await expect ( store ( 'readonly' , ( s ) => IDB . promisifyRequest ( s . get ( 'key1' ) ) ) ) . rejects . toThrow ( 'Internal error opening backing store' ) ;
315+
316+ // Second call: open succeeds (mock exhausted), heals
317+ const result = await store ( 'readonly' , ( s ) => IDB . promisifyRequest ( s . get ( 'key1' ) ) ) ;
318+ expect ( result ) . toBe ( 'persisted' ) ;
319+ expect ( openCallCount ) . toBeGreaterThanOrEqual ( 3 ) ;
320+ } ) ;
321+
322+ it ( 'should stop healing after budget exhausts across multiple operations' , async ( ) => {
323+ const store = createStore ( uniqueDBName ( ) , STORE_NAME ) ;
324+
325+ // All opens fail permanently
326+ jest . spyOn ( indexedDB , 'open' ) . mockImplementation ( ( ) => {
327+ const req = { } as IDBOpenDBRequest ;
328+ Promise . resolve ( ) . then ( ( ) => {
329+ Object . defineProperty ( req , 'error' , { value : backingStoreError ( ) , configurable : true } ) ;
330+ req . onerror ?.( new Event ( 'error' ) as Event & { target : IDBOpenDBRequest } ) ;
331+ } ) ;
332+ return req ;
333+ } ) ;
334+
335+ // Each call consumes 1 heal attempt (initial fails, heal retry also fails, propagates)
336+ await expect ( store ( 'readonly' , ( s ) => IDB . promisifyRequest ( s . get ( 'k' ) ) ) ) . rejects . toThrow ( 'Internal error opening backing store' ) ;
337+ await expect ( store ( 'readonly' , ( s ) => IDB . promisifyRequest ( s . get ( 'k' ) ) ) ) . rejects . toThrow ( 'Internal error opening backing store' ) ;
338+ await expect ( store ( 'readonly' , ( s ) => IDB . promisifyRequest ( s . get ( 'k' ) ) ) ) . rejects . toThrow ( 'Internal error opening backing store' ) ;
339+
340+ // Budget exhausted — 4th call should NOT attempt healing
341+ logInfoSpy . mockClear ( ) ;
342+ await expect ( store ( 'readonly' , ( s ) => IDB . promisifyRequest ( s . get ( 'k' ) ) ) ) . rejects . toThrow ( 'Internal error opening backing store' ) ;
343+ expect ( logInfoSpy ) . not . toHaveBeenCalledWith ( expect . stringContaining ( 'IDB heal' ) , expect . anything ( ) ) ;
344+ } ) ;
345+
346+ it ( 'should reset heal budget after a successful operation' , async ( ) => {
347+ const dbName = uniqueDBName ( ) ;
348+ const store = createStore ( dbName , STORE_NAME ) ;
349+
350+ await store ( 'readwrite' , ( s ) => {
351+ s . put ( 'value' , 'key1' ) ;
352+ return IDB . promisifyRequest ( s . transaction ) ;
353+ } ) ;
354+
355+ const original = IDBDatabase . prototype . transaction ;
356+
357+ // Drain budget to 1 remaining: fail twice, each heals successfully
358+ for ( let i = 0 ; i < 2 ; i ++ ) {
359+ let callCount = 0 ;
360+ const spy = jest . spyOn ( IDBDatabase . prototype , 'transaction' ) . mockImplementation ( function ( this : IDBDatabase , ...args ) {
361+ callCount ++ ;
362+ if ( callCount === 1 ) {
363+ throw backingStoreError ( ) ;
364+ }
365+ spy . mockRestore ( ) ;
366+ return original . apply ( this , args ) ;
367+ } ) ;
368+ await store ( 'readonly' , ( s ) => IDB . promisifyRequest ( s . get ( 'key1' ) ) ) ; }
369+
370+ // Clean success — resets budget to 3
371+ jest . restoreAllMocks ( ) ;
372+ logInfoSpy = jest . spyOn ( Logger , 'logInfo' ) ;
373+ await store ( 'readonly' , ( s ) => IDB . promisifyRequest ( s . get ( 'key1' ) ) ) ;
374+
375+ // Now fail 3 more times — all should still heal (proving budget was reset)
376+ for ( let i = 0 ; i < 3 ; i ++ ) {
377+ let callCount = 0 ;
378+ const spy = jest . spyOn ( IDBDatabase . prototype , 'transaction' ) . mockImplementation ( function ( this : IDBDatabase , ...args ) {
379+ callCount ++ ;
380+ if ( callCount === 1 ) {
381+ throw backingStoreError ( ) ;
382+ }
383+ spy . mockRestore ( ) ;
384+ return original . apply ( this , args ) ;
385+ } ) ;
386+ const result = await store ( 'readonly' , ( s ) => IDB . promisifyRequest ( s . get ( 'key1' ) ) ) ; expect ( result ) . toBe ( 'value' ) ;
387+ }
388+ } ) ;
389+
390+ it ( 'should not attempt healing for non-backing-store errors' , async ( ) => {
391+ const store = createStore ( uniqueDBName ( ) , STORE_NAME ) ;
392+
393+ await store ( 'readwrite' , ( s ) => {
394+ s . put ( 'value' , 'key1' ) ;
395+ return IDB . promisifyRequest ( s . transaction ) ;
396+ } ) ;
397+
398+ // UnknownError but different message
399+ jest . spyOn ( IDBDatabase . prototype , 'transaction' ) . mockImplementation ( ( ) => {
400+ throw new DOMException ( 'Some other unknown error' , 'UnknownError' ) ;
401+ } ) ;
402+ await expect ( store ( 'readonly' , ( s ) => IDB . promisifyRequest ( s . get ( 'key1' ) ) ) ) . rejects . toThrow ( 'Some other unknown error' ) ;
403+
404+ jest . restoreAllMocks ( ) ;
405+ logInfoSpy = jest . spyOn ( Logger , 'logInfo' ) ;
406+
407+ // QuotaExceededError
408+ jest . spyOn ( IDBDatabase . prototype , 'transaction' ) . mockImplementation ( ( ) => {
409+ throw new DOMException ( 'Quota exceeded' , 'QuotaExceededError' ) ;
410+ } ) ;
411+ await expect ( store ( 'readonly' , ( s ) => IDB . promisifyRequest ( s . get ( 'key1' ) ) ) ) . rejects . toThrow ( 'Quota exceeded' ) ;
412+
413+ expect ( logInfoSpy ) . not . toHaveBeenCalledWith ( expect . stringContaining ( 'IDB heal' ) , expect . anything ( ) ) ;
414+ } ) ;
415+ } ) ;
248416} ) ;
0 commit comments