@@ -415,4 +415,134 @@ describe('createStore', () => {
415415 expect ( logInfoSpy ) . not . toHaveBeenCalledWith ( expect . stringContaining ( 'IDB heal' ) , expect . anything ( ) ) ;
416416 } ) ;
417417 } ) ;
418+
419+ describe ( 'connection lost healing' , ( ) => {
420+ function connectionLostError ( ) {
421+ return new DOMException (
422+ 'Connection to Indexed Database server lost. Refresh the page to try again' ,
423+ 'UnknownError' ,
424+ ) ;
425+ }
426+
427+ it ( 'should heal by dropping cached connection and reopening' , async ( ) => {
428+ const store = createStore ( uniqueDBName ( ) , STORE_NAME ) ;
429+
430+ await store ( 'readwrite' , ( s ) => {
431+ s . put ( 'value' , 'key1' ) ;
432+ return IDB . promisifyRequest ( s . transaction ) ;
433+ } ) ;
434+
435+ const original = IDBDatabase . prototype . transaction ;
436+ let callCount = 0 ;
437+ jest . spyOn ( IDBDatabase . prototype , 'transaction' ) . mockImplementation ( function ( this : IDBDatabase , ...args ) {
438+ callCount ++ ;
439+ if ( callCount === 1 ) {
440+ throw connectionLostError ( ) ;
441+ }
442+ return original . apply ( this , args ) ;
443+ } ) ;
444+
445+ const result = await store ( 'readonly' , ( s ) => IDB . promisifyRequest ( s . get ( 'key1' ) ) ) ;
446+ expect ( result ) . toBe ( 'value' ) ;
447+ expect ( callCount ) . toBe ( 2 ) ;
448+ expect ( logInfoSpy ) . toHaveBeenCalledWith (
449+ 'IDB heal: connection lost error, attempting drop cached connection and reopen' ,
450+ expect . objectContaining ( { healAttemptsRemaining : expect . any ( Number ) } ) ,
451+ ) ;
452+ } ) ;
453+
454+ it ( 'should stop healing after budget exhausts' , async ( ) => {
455+ const store = createStore ( uniqueDBName ( ) , STORE_NAME ) ;
456+
457+ // All transaction calls fail permanently with connection lost.
458+ // The heal path clears dbp and calls indexedDB.open() again — mock that to
459+ // also fail so fake-indexeddb doesn't deadlock waiting for the old connection
460+ // to close (same pattern as the backing store budget exhaustion test).
461+ jest . spyOn ( IDBDatabase . prototype , 'transaction' ) . mockImplementation ( ( ) => {
462+ throw connectionLostError ( ) ;
463+ } ) ;
464+ jest . spyOn ( indexedDB , 'open' ) . mockImplementation ( ( ) => {
465+ const req = { } as IDBOpenDBRequest ;
466+ Promise . resolve ( ) . then ( ( ) => {
467+ Object . defineProperty ( req , 'error' , { value : connectionLostError ( ) , configurable : true } ) ;
468+ req . onerror ?.( new Event ( 'error' ) as Event & { target : IDBOpenDBRequest } ) ;
469+ } ) ;
470+ return req ;
471+ } ) ;
472+
473+ // Each call drains 1 heal attempt (initial fails, heal retry also fails)
474+ await expect ( store ( 'readonly' , ( s ) => IDB . promisifyRequest ( s . get ( 'k' ) ) ) ) . rejects . toThrow ( 'Connection to Indexed Database server lost' ) ;
475+ await expect ( store ( 'readonly' , ( s ) => IDB . promisifyRequest ( s . get ( 'k' ) ) ) ) . rejects . toThrow ( 'Connection to Indexed Database server lost' ) ;
476+ await expect ( store ( 'readonly' , ( s ) => IDB . promisifyRequest ( s . get ( 'k' ) ) ) ) . rejects . toThrow ( 'Connection to Indexed Database server lost' ) ;
477+
478+ // Budget exhausted — 4th call should NOT attempt healing
479+ logInfoSpy . mockClear ( ) ;
480+ await expect ( store ( 'readonly' , ( s ) => IDB . promisifyRequest ( s . get ( 'k' ) ) ) ) . rejects . toThrow ( 'Connection to Indexed Database server lost' ) ;
481+ expect ( logInfoSpy ) . not . toHaveBeenCalledWith ( expect . stringContaining ( 'IDB heal' ) , expect . anything ( ) ) ;
482+ } ) ;
483+
484+ it ( 'should also heal "connection is closing" variant' , async ( ) => {
485+ const store = createStore ( uniqueDBName ( ) , STORE_NAME ) ;
486+
487+ await store ( 'readwrite' , ( s ) => {
488+ s . put ( 'value' , 'key1' ) ;
489+ return IDB . promisifyRequest ( s . transaction ) ;
490+ } ) ;
491+
492+ const original = IDBDatabase . prototype . transaction ;
493+ let callCount = 0 ;
494+ jest . spyOn ( IDBDatabase . prototype , 'transaction' ) . mockImplementation ( function ( this : IDBDatabase , ...args ) {
495+ callCount ++ ;
496+ if ( callCount === 1 ) {
497+ throw new DOMException ( 'Connection is closing.' , 'UnknownError' ) ;
498+ }
499+ return original . apply ( this , args ) ;
500+ } ) ;
501+
502+ const result = await store ( 'readonly' , ( s ) => IDB . promisifyRequest ( s . get ( 'key1' ) ) ) ;
503+ expect ( result ) . toBe ( 'value' ) ;
504+ expect ( callCount ) . toBe ( 2 ) ;
505+ } ) ;
506+
507+ it ( 'should share heal budget with backing store errors' , async ( ) => {
508+ const store = createStore ( uniqueDBName ( ) , STORE_NAME ) ;
509+
510+ // All transaction calls fail permanently, alternating error types.
511+ // The heal path clears dbp and calls indexedDB.open() again — mock that to
512+ // also fail so fake-indexeddb doesn't deadlock waiting for the old connection
513+ // to close.
514+ const txErrors = [
515+ new DOMException ( 'Internal error opening backing store for indexedDB.open.' , 'UnknownError' ) ,
516+ connectionLostError ( ) ,
517+ new DOMException ( 'Internal error opening backing store for indexedDB.open.' , 'UnknownError' ) ,
518+ ] ;
519+ let txErrorIndex = 0 ;
520+ jest . spyOn ( IDBDatabase . prototype , 'transaction' ) . mockImplementation ( ( ) => {
521+ const err = txErrors [ Math . min ( txErrorIndex , txErrors . length - 1 ) ] ;
522+ txErrorIndex ++ ;
523+ throw err ;
524+ } ) ;
525+ jest . spyOn ( indexedDB , 'open' ) . mockImplementation ( ( ) => {
526+ const req = { } as IDBOpenDBRequest ;
527+ Promise . resolve ( ) . then ( ( ) => {
528+ Object . defineProperty ( req , 'error' , {
529+ value : new DOMException ( 'Internal error opening backing store for indexedDB.open.' , 'UnknownError' ) ,
530+ configurable : true ,
531+ } ) ;
532+ req . onerror ?.( new Event ( 'error' ) as Event & { target : IDBOpenDBRequest } ) ;
533+ } ) ;
534+ return req ;
535+ } ) ;
536+
537+ // 3 calls drain the budget (each call: fail + heal retry fail = 1 budget)
538+ await expect ( store ( 'readonly' , ( s ) => IDB . promisifyRequest ( s . get ( 'k' ) ) ) ) . rejects . toThrow ( ) ;
539+ await expect ( store ( 'readonly' , ( s ) => IDB . promisifyRequest ( s . get ( 'k' ) ) ) ) . rejects . toThrow ( ) ;
540+ await expect ( store ( 'readonly' , ( s ) => IDB . promisifyRequest ( s . get ( 'k' ) ) ) ) . rejects . toThrow ( ) ;
541+
542+ // Budget exhausted — no more healing for either error type
543+ logInfoSpy . mockClear ( ) ;
544+ await expect ( store ( 'readonly' , ( s ) => IDB . promisifyRequest ( s . get ( 'k' ) ) ) ) . rejects . toThrow ( ) ;
545+ expect ( logInfoSpy ) . not . toHaveBeenCalledWith ( expect . stringContaining ( 'IDB heal' ) , expect . anything ( ) ) ;
546+ } ) ;
547+ } ) ;
418548} ) ;
0 commit comments