From d145c34e8e76bdae6943ae86e9830964906ea102 Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Thu, 1 Oct 2026 14:24:43 +0200 Subject: [PATCH 1/9] Degrade to MemoryOnlyProvider once the IDB heal budget is exhausted --- lib/OnyxUtils.ts | 8 ++++---- lib/storage/errors.ts | 8 ++++---- .../providers/IDBKeyValProvider/classifyError.ts | 9 +++++++++ .../providers/IDBKeyValProvider/createStore.ts | 12 +++++++++--- tests/unit/storage/providers/classifyErrorTest.ts | 2 ++ tests/unit/storage/providers/createStoreTest.ts | 6 +++++- tests/unit/storage/tryOrDegradePerformanceTest.ts | 15 +++++++++++++++ 7 files changed, 48 insertions(+), 12 deletions(-) diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts index c7ddb5478..3cabc8a00 100644 --- a/lib/OnyxUtils.ts +++ b/lib/OnyxUtils.ts @@ -567,16 +567,16 @@ function reportStorageQuota(error?: Error): Promise { * The connection layer (createStore) owns connection/transport recovery; this operation layer owns * capacity recovery (eviction) so that a given failure is retried by exactly one layer: * - INVALID_DATA: logs an alert and throws (the same data will always fail). - * - TRANSIENT / FATAL: the connection layer already retried (transient) or exhausted its heal budget - * and alerted (fatal). Retrying here would only re-amplify, so we skip the write quietly. + * - TRANSIENT / FATAL: the connection layer already retried (transient) or spent a heal attempt + * (fatal). Retrying here would only re-amplify, so we skip the write quietly. * - CAPACITY: evicts the least recently accessed evictable key and retries, under a session-level * circuit breaker (see lib/StorageCircuitBreaker.ts) that halts the loop once eviction stops making * progress or failures storm — the per-operation budget alone cannot stop a session-wide storm. * - DISK_PRESSURE: the device disk itself is full (or the database files are unreadable), so neither * retries nor in-DB eviction can free space — the write is dropped (cache stays authoritative) with * a single throttled alert + quota snapshot per burst. - * - UNAVAILABLE: the storage engine does not exist in this environment, so the storage layer has - * already degraded to the in-memory provider. No retry. + * - UNAVAILABLE: the storage engine does not exist in this environment or its heal budget ran out, so + * the storage layer has already degraded to the in-memory provider. No retry. * - UNKNOWN: the provider couldn't classify it — log the full error shape (name + message + * provider) once so it's visible, then bounded retry without eviction. */ diff --git a/lib/storage/errors.ts b/lib/storage/errors.ts index 3e9bba7af..f47bddff8 100644 --- a/lib/storage/errors.ts +++ b/lib/storage/errors.ts @@ -22,12 +22,12 @@ const StorageErrorClass = { DISK_PRESSURE: 'diskPressure', /** Non-serializable payload. Never retriable — the same data will always fail. */ INVALID_DATA: 'invalidData', - /** Backing-store corruption. Owner: connection layer — budgeted heal, then give up. */ + /** Backing-store corruption. Owner: connection layer — budgeted heal, then rethrow as UNAVAILABLE. */ FATAL: 'fatal', /** The storage engine itself does not exist in this environment (for example `indexedDB` is an - * undeclared global in Chrome for iOS private tabs and in Lockdown Mode). Never retriable — the - * engine cannot appear mid-session. Owner: the storage layer — degrade - * to the in-memory provider. */ + * undeclared global in Chrome for iOS private tabs and in Lockdown Mode), or a FATAL heal budget + * ran out. Never retriable — the engine cannot recover mid-session. Owner: the storage layer — + * degrade to the in-memory provider. */ UNAVAILABLE: 'unavailable', /** Unmatched by the active provider. Owner: operation layer — bounded retry, and log the shape so * recurring cases can be promoted into one of the classes above. */ diff --git a/lib/storage/providers/IDBKeyValProvider/classifyError.ts b/lib/storage/providers/IDBKeyValProvider/classifyError.ts index af94e9cb1..c490d2ad2 100644 --- a/lib/storage/providers/IDBKeyValProvider/classifyError.ts +++ b/lib/storage/providers/IDBKeyValProvider/classifyError.ts @@ -2,6 +2,9 @@ import type {ValueOf} from 'type-fest'; import {StorageErrorClass, getErrorParts} from '../../errors'; import {INDEXED_DB_UNAVAILABLE_MESSAGE} from './isIndexedDBAvailable'; +/** Thrown by `createStore` once the FATAL heal budget is spent, so the storage layer degrades to memory-only. */ +const IDB_HEAL_EXHAUSTED_MESSAGE = 'IndexedDB heal budget exhausted'; + /** * Classifies an IndexedDB write failure into the shared storage taxonomy (lib/storage/errors.ts). * Matching is done on the lowercased error name and message. This is the IndexedDB engine's own @@ -15,6 +18,11 @@ function classifyIDBError(error: unknown): ValueOf { return StorageErrorClass.UNAVAILABLE; } + // The engine exists but reopening it did not recover it, so it is unusable for this session. + if (message.includes(IDB_HEAL_EXHAUSTED_MESSAGE.toLowerCase())) { + return StorageErrorClass.UNAVAILABLE; + } + // Non-serializable data passed to IDBObjectStore.put — retrying is futile. if (message.includes("failed to execute 'put' on 'idbobjectstore'")) { return StorageErrorClass.INVALID_DATA; @@ -61,3 +69,4 @@ function classifyIDBError(error: unknown): ValueOf { } export default classifyIDBError; +export {IDB_HEAL_EXHAUSTED_MESSAGE}; diff --git a/lib/storage/providers/IDBKeyValProvider/createStore.ts b/lib/storage/providers/IDBKeyValProvider/createStore.ts index 5315f9c98..c6942ffd4 100644 --- a/lib/storage/providers/IDBKeyValProvider/createStore.ts +++ b/lib/storage/providers/IDBKeyValProvider/createStore.ts @@ -2,7 +2,7 @@ import * as IDB from 'idb-keyval'; import type {UseStore} from 'idb-keyval'; import * as Logger from '../../../Logger'; import {StorageErrorClass} from '../../errors'; -import classifyIDBError from './classifyError'; +import classifyIDBError, {IDB_HEAL_EXHAUSTED_MESSAGE} from './classifyError'; import isIndexedDBAvailable, {INDEXED_DB_UNAVAILABLE_MESSAGE} from './isIndexedDBAvailable'; const HEAL_ATTEMPTS_MAX = 3; @@ -163,7 +163,8 @@ function createStore(dbName: string, storeName: string): UseStore { // stale. Drop it and retry once with a fresh one. Unbudgeted: a single reopen is always worth it // and is bounded per operation. // - FATAL (Chromium backing-store corruption) — reopening can recover transient corruption, but - // repeating forever is futile, so the heal is budgeted (3 attempts, reset on success). + // repeating forever is futile, so the heal is budgeted (3 attempts, reset on success). Once the + // budget is spent the error is rethrown as UNAVAILABLE so the storage layer degrades to memory-only. // Mirrors Dexie's PR1398_maxLoop pattern: https://github.com/dexie/Dexie.js/blob/master/src/functions/temp-transaction.ts // - CAPACITY / UNKNOWN are NOT the connection layer's responsibility — propagate to the operation // layer (OnyxUtils.retryOperation) without retrying here, to avoid compounding retries. @@ -204,11 +205,16 @@ function createStore(dbName: string, storeName: string): UseStore { } if (errorClass === StorageErrorClass.FATAL) { + const errorMessage = error instanceof Error ? error.message : String(error); Logger.logAlert('IDB heal: backing store error — heal budget exhausted, giving up', { dbName, storeName, + errorMessage, }); - } else if (errorClass === StorageErrorClass.UNKNOWN) { + throw new Error(`${IDB_HEAL_EXHAUSTED_MESSAGE}: ${errorMessage}`, {cause: error}); + } + + if (errorClass === StorageErrorClass.UNKNOWN) { // UNKNOWN — unexpected at this layer; record it so it's visible. CAPACITY is the // expected propagation path (the operation layer owns its logging, and suppresses it // entirely once the circuit breaker is open), so we do NOT log it here — doing so was a diff --git a/tests/unit/storage/providers/classifyErrorTest.ts b/tests/unit/storage/providers/classifyErrorTest.ts index a0051b570..6e6b01aa8 100644 --- a/tests/unit/storage/providers/classifyErrorTest.ts +++ b/tests/unit/storage/providers/classifyErrorTest.ts @@ -21,6 +21,8 @@ describe('classifyIDBError', () => { [new ReferenceError("Can't find variable: indexedDB"), StorageErrorClass.UNAVAILABLE], [new ReferenceError('indexedDB is not defined'), StorageErrorClass.UNAVAILABLE], [new Error('indexedDB is not available in this environment'), StorageErrorClass.UNAVAILABLE], + // FATAL heal budget spent. + [new Error('IndexedDB heal budget exhausted: Internal error.'), StorageErrorClass.UNAVAILABLE], // Anything else stays UNKNOWN. [new Error('some brand new failure'), StorageErrorClass.UNKNOWN], ])('classifies %s as %s', (error, expectedClass) => { diff --git a/tests/unit/storage/providers/createStoreTest.ts b/tests/unit/storage/providers/createStoreTest.ts index 9f2d8214f..07a86434b 100644 --- a/tests/unit/storage/providers/createStoreTest.ts +++ b/tests/unit/storage/providers/createStoreTest.ts @@ -471,7 +471,11 @@ describe('createStore', () => { // Budget exhausted — 4th call should NOT attempt healing, but should log budget exhausted logAlertSpy.mockClear(); - await expect(store('readonly', (s) => IDB.promisifyRequest(s.get('k')))).rejects.toThrow('Internal error opening backing store'); + const exhaustedError = await store('readonly', (s) => IDB.promisifyRequest(s.get('k'))).catch((error: unknown) => error); + expect(exhaustedError).toBeInstanceOf(Error); + expect((exhaustedError as Error).message).toBe('IndexedDB heal budget exhausted: Internal error opening backing store for indexedDB.open.'); + expect((exhaustedError as Error).cause).toBeInstanceOf(DOMException); + expect(classifyIDBError(exhaustedError)).toBe(StorageErrorClass.UNAVAILABLE); expect(logAlertSpy).toHaveBeenCalledWith(expect.stringContaining('heal budget exhausted'), expect.anything()); expect(logAlertSpy).not.toHaveBeenCalledWith(expect.stringContaining('dropping cached connection and reopening'), expect.anything()); }); diff --git a/tests/unit/storage/tryOrDegradePerformanceTest.ts b/tests/unit/storage/tryOrDegradePerformanceTest.ts index b5b2a4162..e44ab02fb 100644 --- a/tests/unit/storage/tryOrDegradePerformanceTest.ts +++ b/tests/unit/storage/tryOrDegradePerformanceTest.ts @@ -105,6 +105,21 @@ describe('storage/tryOrDegradePerformance', () => { expect(storage.getStorageProvider().name).toBe('MemoryOnlyProvider'); }); + it('should fall back to MemoryOnlyProvider once the IndexedDB heal budget is exhausted', async () => { + const {storage} = loadIsolatedStorage(); + + storage.init(); + + const targetError = new Error('IndexedDB heal budget exhausted: Internal error.', {cause: new DOMException('Internal error.', 'UnknownError')}); + storage.getStorageProvider().setItem = jest.fn().mockReturnValue(Promise.reject(targetError)); + + await expect(storage.setItem('key', {test: 'data'})).rejects.toBe(targetError); + + expect(storage.getStorageProvider().name).toBe('MemoryOnlyProvider'); + await storage.setItem('key', {test: 'data'}); + await expect(storage.getItem('key')).resolves.toEqual({test: 'data'}); + }); + it('should still classify the error as UNAVAILABLE after degrading to MemoryOnlyProvider', async () => { const {storage} = loadIsolatedStorage(); From f86b7e58f55fff1fbcbe778e348510f3a0b9d0bf Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Thu, 1 Oct 2026 14:43:15 +0200 Subject: [PATCH 2/9] Degrade to MemoryOnlyProvider when the init read fails with a FATAL error --- lib/OnyxUtils.ts | 6 ++++++ lib/storage/__mocks__/index.ts | 1 + lib/storage/index.ts | 6 ++++++ tests/unit/onyxUtilsTest.ts | 19 +++++++++++++++++++ .../storage/tryOrDegradePerformanceTest.ts | 8 ++++++++ 5 files changed, 40 insertions(+) diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts index 3cabc8a00..7fc9d2efd 100644 --- a/lib/OnyxUtils.ts +++ b/lib/OnyxUtils.ts @@ -920,6 +920,12 @@ function initializeWithDefaultKeyStates(): Promise { .catch((error) => { Logger.logAlert(`Failed to load data from storage during init. The app will boot with default key states only. Error: ${error}`); + // The connection layer already reopened once and the read still failed. The session now runs on + // defaults, so writing it into a database we could not read gains nothing. + if (Storage.classifyError(error) === StorageErrorClass.FATAL) { + Storage.degradeToMemoryOnly(error); + } + // Populate the key index so getAllKeys() returns correct results for default keys. // Without this, subscribers that check getAllKeys() would see an empty set even // though we have default values in cache. diff --git a/lib/storage/__mocks__/index.ts b/lib/storage/__mocks__/index.ts index 90c75fd62..273c1cd2b 100644 --- a/lib/storage/__mocks__/index.ts +++ b/lib/storage/__mocks__/index.ts @@ -19,6 +19,7 @@ const StorageMock = { init, classifyError: jest.fn(classifyError), getStorageProvider: jest.fn(() => MemoryOnlyProvider), + degradeToMemoryOnly: jest.fn(), getItem: jest.fn(MemoryOnlyProvider.getItem), multiGet: jest.fn(MemoryOnlyProvider.multiGet), setItem: jest.fn(MemoryOnlyProvider.setItem), diff --git a/lib/storage/index.ts b/lib/storage/index.ts index 13f245b40..f730232b6 100644 --- a/lib/storage/index.ts +++ b/lib/storage/index.ts @@ -22,6 +22,7 @@ const initPromise = new Promise((resolve) => { type Storage = { getStorageProvider: () => StorageProvider; + degradeToMemoryOnly: (error: Error) => void; } & Omit, 'name' | 'store'>; /** @@ -70,6 +71,11 @@ const storage: Storage = { return provider; }, + /** + * Swaps the provider for `MemoryOnlyProvider` when the caller knows storage is unusable for this session. + */ + degradeToMemoryOnly: (error) => degradePerformance(error), + /** * Classifies a write error using the platform provider's own classifier. Synchronous and pure — * never wrapped in tryOrDegradePerformance. diff --git a/tests/unit/onyxUtilsTest.ts b/tests/unit/onyxUtilsTest.ts index 779786958..8efcb575b 100644 --- a/tests/unit/onyxUtilsTest.ts +++ b/tests/unit/onyxUtilsTest.ts @@ -1555,6 +1555,25 @@ describe('OnyxUtils', () => { }); }); + describe('initializeWithDefaultKeyStates', () => { + it('should degrade to memory-only when the init read fails with a FATAL error', async () => { + const internalError = Object.assign(new Error('Internal error.'), {name: 'UnknownError'}); + jest.mocked(StorageMock.getAll).mockRejectedValueOnce(internalError); + + await OnyxUtils.initializeWithDefaultKeyStates(); + + expect(StorageMock.degradeToMemoryOnly).toHaveBeenCalledWith(internalError); + }); + + it('should not degrade to memory-only when the init read fails with an UNKNOWN error', async () => { + jest.mocked(StorageMock.getAll).mockRejectedValueOnce(new Error('some brand new failure')); + + await OnyxUtils.initializeWithDefaultKeyStates(); + + expect(StorageMock.degradeToMemoryOnly).not.toHaveBeenCalled(); + }); + }); + describe('afterInit', () => { beforeEach(() => { // Resets the deferred init task before each test. diff --git a/tests/unit/storage/tryOrDegradePerformanceTest.ts b/tests/unit/storage/tryOrDegradePerformanceTest.ts index e44ab02fb..cf6177e26 100644 --- a/tests/unit/storage/tryOrDegradePerformanceTest.ts +++ b/tests/unit/storage/tryOrDegradePerformanceTest.ts @@ -120,6 +120,14 @@ describe('storage/tryOrDegradePerformance', () => { await expect(storage.getItem('key')).resolves.toEqual({test: 'data'}); }); + it('should fall back to MemoryOnlyProvider when degradeToMemoryOnly is called', () => { + const {storage} = loadIsolatedStorage(); + + storage.degradeToMemoryOnly(new DOMException('Internal error.', 'UnknownError')); + + expect(storage.getStorageProvider().name).toBe('MemoryOnlyProvider'); + }); + it('should still classify the error as UNAVAILABLE after degrading to MemoryOnlyProvider', async () => { const {storage} = loadIsolatedStorage(); From 7b8795cae9e3f4ccdbfceaa4e1885c669368527b Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Mon, 5 Oct 2026 12:24:48 +0200 Subject: [PATCH 3/9] comments clean up --- lib/OnyxUtils.ts | 2 -- lib/storage/index.ts | 3 --- lib/storage/providers/IDBKeyValProvider/classifyError.ts | 2 -- lib/storage/providers/IDBKeyValProvider/createStore.ts | 3 +-- 4 files changed, 1 insertion(+), 9 deletions(-) diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts index 7fc9d2efd..4e5cd19cf 100644 --- a/lib/OnyxUtils.ts +++ b/lib/OnyxUtils.ts @@ -920,8 +920,6 @@ function initializeWithDefaultKeyStates(): Promise { .catch((error) => { Logger.logAlert(`Failed to load data from storage during init. The app will boot with default key states only. Error: ${error}`); - // The connection layer already reopened once and the read still failed. The session now runs on - // defaults, so writing it into a database we could not read gains nothing. if (Storage.classifyError(error) === StorageErrorClass.FATAL) { Storage.degradeToMemoryOnly(error); } diff --git a/lib/storage/index.ts b/lib/storage/index.ts index f730232b6..0c641e7da 100644 --- a/lib/storage/index.ts +++ b/lib/storage/index.ts @@ -71,9 +71,6 @@ const storage: Storage = { return provider; }, - /** - * Swaps the provider for `MemoryOnlyProvider` when the caller knows storage is unusable for this session. - */ degradeToMemoryOnly: (error) => degradePerformance(error), /** diff --git a/lib/storage/providers/IDBKeyValProvider/classifyError.ts b/lib/storage/providers/IDBKeyValProvider/classifyError.ts index c490d2ad2..35d2c2a9b 100644 --- a/lib/storage/providers/IDBKeyValProvider/classifyError.ts +++ b/lib/storage/providers/IDBKeyValProvider/classifyError.ts @@ -2,7 +2,6 @@ import type {ValueOf} from 'type-fest'; import {StorageErrorClass, getErrorParts} from '../../errors'; import {INDEXED_DB_UNAVAILABLE_MESSAGE} from './isIndexedDBAvailable'; -/** Thrown by `createStore` once the FATAL heal budget is spent, so the storage layer degrades to memory-only. */ const IDB_HEAL_EXHAUSTED_MESSAGE = 'IndexedDB heal budget exhausted'; /** @@ -18,7 +17,6 @@ function classifyIDBError(error: unknown): ValueOf { return StorageErrorClass.UNAVAILABLE; } - // The engine exists but reopening it did not recover it, so it is unusable for this session. if (message.includes(IDB_HEAL_EXHAUSTED_MESSAGE.toLowerCase())) { return StorageErrorClass.UNAVAILABLE; } diff --git a/lib/storage/providers/IDBKeyValProvider/createStore.ts b/lib/storage/providers/IDBKeyValProvider/createStore.ts index c6942ffd4..1f357e705 100644 --- a/lib/storage/providers/IDBKeyValProvider/createStore.ts +++ b/lib/storage/providers/IDBKeyValProvider/createStore.ts @@ -163,8 +163,7 @@ function createStore(dbName: string, storeName: string): UseStore { // stale. Drop it and retry once with a fresh one. Unbudgeted: a single reopen is always worth it // and is bounded per operation. // - FATAL (Chromium backing-store corruption) — reopening can recover transient corruption, but - // repeating forever is futile, so the heal is budgeted (3 attempts, reset on success). Once the - // budget is spent the error is rethrown as UNAVAILABLE so the storage layer degrades to memory-only. + // repeating forever is futile, so the heal is budgeted (3 attempts, reset on success). // Mirrors Dexie's PR1398_maxLoop pattern: https://github.com/dexie/Dexie.js/blob/master/src/functions/temp-transaction.ts // - CAPACITY / UNKNOWN are NOT the connection layer's responsibility — propagate to the operation // layer (OnyxUtils.retryOperation) without retrying here, to avoid compounding retries. From 96d000657f5a334f9ef5663ba873f7ad3bd3d90c Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Tue, 6 Oct 2026 09:38:18 +0200 Subject: [PATCH 4/9] add comment line --- lib/storage/providers/IDBKeyValProvider/classifyError.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/storage/providers/IDBKeyValProvider/classifyError.ts b/lib/storage/providers/IDBKeyValProvider/classifyError.ts index 35d2c2a9b..9031cd3f5 100644 --- a/lib/storage/providers/IDBKeyValProvider/classifyError.ts +++ b/lib/storage/providers/IDBKeyValProvider/classifyError.ts @@ -17,6 +17,7 @@ function classifyIDBError(error: unknown): ValueOf { return StorageErrorClass.UNAVAILABLE; } + // Reopen budget spent on FATAL errors, so the store is treated as gone for the rest of the session. if (message.includes(IDB_HEAL_EXHAUSTED_MESSAGE.toLowerCase())) { return StorageErrorClass.UNAVAILABLE; } From b6ac2eb70536cb1aaf40b66fb21a1e1ef6a48346 Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Tue, 6 Oct 2026 09:46:24 +0200 Subject: [PATCH 5/9] refactor the InbKeyVal provider so there are no repetitions of error names --- lib/storage/providers/IDBKeyValProvider/classifyError.ts | 9 +++------ lib/storage/providers/IDBKeyValProvider/createStore.ts | 9 +++++---- lib/storage/providers/IDBKeyValProvider/errorMessages.ts | 7 +++++++ lib/storage/providers/IDBKeyValProvider/index.ts | 5 +++-- .../providers/IDBKeyValProvider/isIndexedDBAvailable.ts | 4 ---- 5 files changed, 18 insertions(+), 16 deletions(-) create mode 100644 lib/storage/providers/IDBKeyValProvider/errorMessages.ts diff --git a/lib/storage/providers/IDBKeyValProvider/classifyError.ts b/lib/storage/providers/IDBKeyValProvider/classifyError.ts index 9031cd3f5..f7c5f2934 100644 --- a/lib/storage/providers/IDBKeyValProvider/classifyError.ts +++ b/lib/storage/providers/IDBKeyValProvider/classifyError.ts @@ -1,8 +1,6 @@ import type {ValueOf} from 'type-fest'; import {StorageErrorClass, getErrorParts} from '../../errors'; -import {INDEXED_DB_UNAVAILABLE_MESSAGE} from './isIndexedDBAvailable'; - -const IDB_HEAL_EXHAUSTED_MESSAGE = 'IndexedDB heal budget exhausted'; +import IDBErrorMessage from './errorMessages'; /** * Classifies an IndexedDB write failure into the shared storage taxonomy (lib/storage/errors.ts). @@ -13,12 +11,12 @@ function classifyIDBError(error: unknown): ValueOf { const {name, message} = getErrorParts(error); // The engine is absent, not broken e.g. private tabs and Lockdown Mode on WebKit. - if (message.includes("can't find variable: indexeddb") || message.includes('indexeddb is not defined') || message.includes(INDEXED_DB_UNAVAILABLE_MESSAGE.toLowerCase())) { + if (message.includes("can't find variable: indexeddb") || message.includes('indexeddb is not defined') || message.includes(IDBErrorMessage.UNAVAILABLE.toLowerCase())) { return StorageErrorClass.UNAVAILABLE; } // Reopen budget spent on FATAL errors, so the store is treated as gone for the rest of the session. - if (message.includes(IDB_HEAL_EXHAUSTED_MESSAGE.toLowerCase())) { + if (message.includes(IDBErrorMessage.HEAL_EXHAUSTED.toLowerCase())) { return StorageErrorClass.UNAVAILABLE; } @@ -68,4 +66,3 @@ function classifyIDBError(error: unknown): ValueOf { } export default classifyIDBError; -export {IDB_HEAL_EXHAUSTED_MESSAGE}; diff --git a/lib/storage/providers/IDBKeyValProvider/createStore.ts b/lib/storage/providers/IDBKeyValProvider/createStore.ts index 1f357e705..eb776b20d 100644 --- a/lib/storage/providers/IDBKeyValProvider/createStore.ts +++ b/lib/storage/providers/IDBKeyValProvider/createStore.ts @@ -2,8 +2,9 @@ import * as IDB from 'idb-keyval'; import type {UseStore} from 'idb-keyval'; import * as Logger from '../../../Logger'; import {StorageErrorClass} from '../../errors'; -import classifyIDBError, {IDB_HEAL_EXHAUSTED_MESSAGE} from './classifyError'; -import isIndexedDBAvailable, {INDEXED_DB_UNAVAILABLE_MESSAGE} from './isIndexedDBAvailable'; +import classifyIDBError from './classifyError'; +import IDBErrorMessage from './errorMessages'; +import isIndexedDBAvailable from './isIndexedDBAvailable'; const HEAL_ATTEMPTS_MAX = 3; @@ -59,7 +60,7 @@ function createStore(dbName: string, storeName: string): UseStore { if (dbp) return dbp; if (!isIndexedDBAvailable()) { - return Promise.reject(new Error(INDEXED_DB_UNAVAILABLE_MESSAGE)); + return Promise.reject(new Error(IDBErrorMessage.UNAVAILABLE)); } const request = indexedDB.open(dbName); @@ -210,7 +211,7 @@ function createStore(dbName: string, storeName: string): UseStore { storeName, errorMessage, }); - throw new Error(`${IDB_HEAL_EXHAUSTED_MESSAGE}: ${errorMessage}`, {cause: error}); + throw new Error(`${IDBErrorMessage.HEAL_EXHAUSTED}: ${errorMessage}`, {cause: error}); } if (errorClass === StorageErrorClass.UNKNOWN) { diff --git a/lib/storage/providers/IDBKeyValProvider/errorMessages.ts b/lib/storage/providers/IDBKeyValProvider/errorMessages.ts new file mode 100644 index 000000000..9805e80a4 --- /dev/null +++ b/lib/storage/providers/IDBKeyValProvider/errorMessages.ts @@ -0,0 +1,7 @@ +/** Messages of errors thrown by the IndexedDB provider itself, matched by `classifyIDBError` as UNAVAILABLE. */ +const IDBErrorMessage = { + UNAVAILABLE: 'indexedDB is not available in this environment', + HEAL_EXHAUSTED: 'IndexedDB heal budget exhausted', +} as const; + +export default IDBErrorMessage; diff --git a/lib/storage/providers/IDBKeyValProvider/index.ts b/lib/storage/providers/IDBKeyValProvider/index.ts index 3870321a2..546c9acd2 100644 --- a/lib/storage/providers/IDBKeyValProvider/index.ts +++ b/lib/storage/providers/IDBKeyValProvider/index.ts @@ -5,7 +5,8 @@ import type StorageProvider from '../types'; import type {OnyxKey, OnyxValue} from '../../../types'; import createStore from './createStore'; import classifyIDBError from './classifyError'; -import isIndexedDBAvailable, {INDEXED_DB_UNAVAILABLE_MESSAGE} from './isIndexedDBAvailable'; +import IDBErrorMessage from './errorMessages'; +import isIndexedDBAvailable from './isIndexedDBAvailable'; import type {StorageKeyValuePair} from '../types'; const DB_NAME = 'OnyxDB'; @@ -40,7 +41,7 @@ const provider: StorageProvider = { */ init() { if (!isIndexedDBAvailable()) { - throw new Error(`IDBKeyVal store could not be created: ${INDEXED_DB_UNAVAILABLE_MESSAGE}`); + throw new Error(`IDBKeyVal store could not be created: ${IDBErrorMessage.UNAVAILABLE}`); } const newIdbKeyValStore = createStore(DB_NAME, STORE_NAME); diff --git a/lib/storage/providers/IDBKeyValProvider/isIndexedDBAvailable.ts b/lib/storage/providers/IDBKeyValProvider/isIndexedDBAvailable.ts index cabd8c30b..8e660421e 100644 --- a/lib/storage/providers/IDBKeyValProvider/isIndexedDBAvailable.ts +++ b/lib/storage/providers/IDBKeyValProvider/isIndexedDBAvailable.ts @@ -1,6 +1,3 @@ -/** Matched by `classifyIDBError` so a guarded failure classifies as UNAVAILABLE like an unguarded one. */ -const INDEXED_DB_UNAVAILABLE_MESSAGE = 'indexedDB is not available in this environment'; - /** * `typeof` instead of `indexedDB === undefined`: on WebKit the global is undeclared in private tabs and * Lockdown Mode, so reading it throws a `ReferenceError` instead of evaluating to `undefined`. @@ -10,4 +7,3 @@ function isIndexedDBAvailable(): boolean { } export default isIndexedDBAvailable; -export {INDEXED_DB_UNAVAILABLE_MESSAGE}; From 05a867405021af043bdc14d182b844f21ba00320 Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Tue, 6 Oct 2026 09:50:12 +0200 Subject: [PATCH 6/9] API docs --- API-INTERNAL.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/API-INTERNAL.md b/API-INTERNAL.md index 901f6791a..df46837f7 100644 --- a/API-INTERNAL.md +++ b/API-INTERNAL.md @@ -90,16 +90,16 @@ The connection layer (createStore) owns connection/transport recovery; this oper capacity recovery (eviction) so that a given failure is retried by exactly one layer:

  • INVALID_DATA: logs an alert and throws (the same data will always fail).
  • -
  • TRANSIENT / FATAL: the connection layer already retried (transient) or exhausted its heal budget -and alerted (fatal). Retrying here would only re-amplify, so we skip the write quietly.
  • +
  • TRANSIENT / FATAL: the connection layer already retried (transient) or spent a heal attempt +(fatal). Retrying here would only re-amplify, so we skip the write quietly.
  • CAPACITY: evicts the least recently accessed evictable key and retries, under a session-level circuit breaker (see lib/StorageCircuitBreaker.ts) that halts the loop once eviction stops making progress or failures storm — the per-operation budget alone cannot stop a session-wide storm.
  • DISK_PRESSURE: the device disk itself is full (or the database files are unreadable), so neither retries nor in-DB eviction can free space — the write is dropped (cache stays authoritative) with a single throttled alert + quota snapshot per burst.
  • -
  • UNAVAILABLE: the storage engine does not exist in this environment, so the storage layer has -already degraded to the in-memory provider. No retry.
  • +
  • UNAVAILABLE: the storage engine does not exist in this environment or its heal budget ran out, so +the storage layer has already degraded to the in-memory provider. No retry.
  • UNKNOWN: the provider couldn't classify it — log the full error shape (name + message + provider) once so it's visible, then bounded retry without eviction.
@@ -321,16 +321,16 @@ Handles storage operation failures based on the error class (see lib/storage/err The connection layer (createStore) owns connection/transport recovery; this operation layer owns capacity recovery (eviction) so that a given failure is retried by exactly one layer: - INVALID_DATA: logs an alert and throws (the same data will always fail). -- TRANSIENT / FATAL: the connection layer already retried (transient) or exhausted its heal budget - and alerted (fatal). Retrying here would only re-amplify, so we skip the write quietly. +- TRANSIENT / FATAL: the connection layer already retried (transient) or spent a heal attempt + (fatal). Retrying here would only re-amplify, so we skip the write quietly. - CAPACITY: evicts the least recently accessed evictable key and retries, under a session-level circuit breaker (see lib/StorageCircuitBreaker.ts) that halts the loop once eviction stops making progress or failures storm — the per-operation budget alone cannot stop a session-wide storm. - DISK_PRESSURE: the device disk itself is full (or the database files are unreadable), so neither retries nor in-DB eviction can free space — the write is dropped (cache stays authoritative) with a single throttled alert + quota snapshot per burst. -- UNAVAILABLE: the storage engine does not exist in this environment, so the storage layer has - already degraded to the in-memory provider. No retry. +- UNAVAILABLE: the storage engine does not exist in this environment or its heal budget ran out, so + the storage layer has already degraded to the in-memory provider. No retry. - UNKNOWN: the provider couldn't classify it — log the full error shape (name + message + provider) once so it's visible, then bounded retry without eviction. From 606493d42d41ef5682014af20aa4bb8d0666c6c7 Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Tue, 6 Oct 2026 16:40:58 +0200 Subject: [PATCH 7/9] typo fix --- lib/storage/providers/IDBKeyValProvider/errorMessages.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/storage/providers/IDBKeyValProvider/errorMessages.ts b/lib/storage/providers/IDBKeyValProvider/errorMessages.ts index 9805e80a4..b9a4dc80a 100644 --- a/lib/storage/providers/IDBKeyValProvider/errorMessages.ts +++ b/lib/storage/providers/IDBKeyValProvider/errorMessages.ts @@ -1,6 +1,6 @@ /** Messages of errors thrown by the IndexedDB provider itself, matched by `classifyIDBError` as UNAVAILABLE. */ const IDBErrorMessage = { - UNAVAILABLE: 'indexedDB is not available in this environment', + UNAVAILABLE: 'IndexedDB is not available in this environment', HEAL_EXHAUSTED: 'IndexedDB heal budget exhausted', } as const; From b2d31b18ba391eb63d95be98933673168bb414a6 Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Tue, 6 Oct 2026 16:44:09 +0200 Subject: [PATCH 8/9] fix comment --- lib/storage/providers/IDBKeyValProvider/errorMessages.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/storage/providers/IDBKeyValProvider/errorMessages.ts b/lib/storage/providers/IDBKeyValProvider/errorMessages.ts index b9a4dc80a..eda4e5018 100644 --- a/lib/storage/providers/IDBKeyValProvider/errorMessages.ts +++ b/lib/storage/providers/IDBKeyValProvider/errorMessages.ts @@ -1,4 +1,4 @@ -/** Messages of errors thrown by the IndexedDB provider itself, matched by `classifyIDBError` as UNAVAILABLE. */ +/** Messages of errors thrown by the IndexedDB provider itself. `classifyIDBError` maps both to `StorageErrorClass.UNAVAILABLE`. */ const IDBErrorMessage = { UNAVAILABLE: 'IndexedDB is not available in this environment', HEAL_EXHAUSTED: 'IndexedDB heal budget exhausted', From 467a05a343affbdf1340a907dab7d670d8fc91fd Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Wed, 7 Oct 2026 13:27:27 +0200 Subject: [PATCH 9/9] Use UNAVAILABLE error constant in createStore tests --- tests/unit/storage/providers/createStoreTest.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/unit/storage/providers/createStoreTest.ts b/tests/unit/storage/providers/createStoreTest.ts index 07a86434b..a5d43d40a 100644 --- a/tests/unit/storage/providers/createStoreTest.ts +++ b/tests/unit/storage/providers/createStoreTest.ts @@ -3,6 +3,7 @@ import createStore from '../../../../lib/storage/providers/IDBKeyValProvider/cre import * as Logger from '../../../../lib/Logger'; import {StorageErrorClass} from '../../../../lib/storage/errors'; import classifyIDBError from '../../../../lib/storage/providers/IDBKeyValProvider/classifyError'; +import IDBErrorMessage from '../../../../lib/storage/providers/IDBKeyValProvider/errorMessages'; const STORE_NAME = 'teststore'; let testDbCounter = 0; @@ -60,7 +61,7 @@ describe('createStore', () => { await withoutIndexedDB(async () => { const operation = store('readonly', (s) => IDB.promisifyRequest(s.get('key1'))); - await expect(operation).rejects.toThrow('indexedDB is not available in this environment'); + await expect(operation).rejects.toThrow(IDBErrorMessage.UNAVAILABLE); await expect(operation.catch((error: unknown) => classifyIDBError(error))).resolves.toBe(StorageErrorClass.UNAVAILABLE); }); }); @@ -69,7 +70,7 @@ describe('createStore', () => { const store = createStore(uniqueDBName(), STORE_NAME); await withoutIndexedDB(async () => { - await expect(store('readonly', (s) => IDB.promisifyRequest(s.get('key1')))).rejects.toThrow('indexedDB is not available in this environment'); + await expect(store('readonly', (s) => IDB.promisifyRequest(s.get('key1')))).rejects.toThrow(IDBErrorMessage.UNAVAILABLE); }); expect(logInfoSpy).not.toHaveBeenCalledWith(expect.stringContaining('IDB transient error'), expect.anything()); @@ -81,7 +82,7 @@ describe('createStore', () => { const store = createStore(uniqueDBName(), STORE_NAME); await withoutIndexedDB(async () => { - await expect(store('readonly', (s) => IDB.promisifyRequest(s.get('key1')))).rejects.toThrow('indexedDB is not available in this environment'); + await expect(store('readonly', (s) => IDB.promisifyRequest(s.get('key1')))).rejects.toThrow(IDBErrorMessage.UNAVAILABLE); }); await store('readwrite', (s) => {