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) => {