From 055bcc2833d2ab63936f48a66ab7f0df149e45c8 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Mon, 12 Jan 2026 19:32:34 -0500 Subject: [PATCH 1/5] feat: add IndexedDB storage engine as alternative to localStorage Add IndexedDBStorageEngine as an opt-in alternative to localStorage for storing flag configurations. IndexedDB provides significantly larger storage capacity (~50MB+) compared to localStorage (~5-10MB). Key changes: - New IndexedDBStorageEngine implementing IStringStorageEngine interface - Uses LZ-string compression like LocalStorageEngine for consistency - Add hasIndexedDB() detection helper to configuration-factory - Update configurationStorageFactory to support IndexedDB with useIndexedDB flag - Add useIndexedDB configuration option to IClientConfig - Proper error handling for quota exceeded and IndexedDB-specific errors IndexedDB is opt-in only. LocalStorage remains the default storage mechanism, ensuring no breaking changes for existing users. Storage priority when useIndexedDB=true: 1. Custom persistentStore (if provided) 2. IndexedDB (if available and opted in) 3. Chrome storage (if available) 4. LocalStorage (if available) 5. Memory-only (fallback) --- js-client-sdk.api.md | 1 + src/configuration-factory.ts | 23 ++++ src/i-client-config.ts | 7 ++ src/index.ts | 3 + src/indexed-db-storage-engine.ts | 196 +++++++++++++++++++++++++++++++ 5 files changed, 230 insertions(+) create mode 100644 src/indexed-db-storage-engine.ts diff --git a/js-client-sdk.api.md b/js-client-sdk.api.md index 4ec328b..618ca17 100644 --- a/js-client-sdk.api.md +++ b/js-client-sdk.api.md @@ -163,6 +163,7 @@ export interface IClientConfig extends IBaseRequestConfig { // Warning: (ae-forgotten-export) The symbol "ServingStoreUpdateStrategy" needs to be exported by the entry point index.d.ts updateOnFetch?: ServingStoreUpdateStrategy; useExpiredCache?: boolean; + useIndexedDB?: boolean; } // @public diff --git a/src/configuration-factory.ts b/src/configuration-factory.ts index bc41b7c..29cfbe9 100644 --- a/src/configuration-factory.ts +++ b/src/configuration-factory.ts @@ -12,6 +12,7 @@ import { import ChromeStorageAsyncMap from './cache/chrome-storage-async-map'; import { ChromeStorageEngine } from './chrome-storage-engine'; +import { IndexedDBStorageEngine } from './indexed-db-storage-engine'; import { IsolatableHybridConfigurationStore, ServingStoreUpdateStrategy, @@ -34,6 +35,8 @@ export function configurationStorageFactory( servingStoreUpdateStrategy = 'always', hasChromeStorage = false, hasWindowLocalStorage = false, + hasIndexedDB = false, + useIndexedDB = false, persistentStore = undefined, forceMemoryOnly = false, }: { @@ -41,6 +44,8 @@ export function configurationStorageFactory( servingStoreUpdateStrategy?: ServingStoreUpdateStrategy; hasChromeStorage?: boolean; hasWindowLocalStorage?: boolean; + hasIndexedDB?: boolean; + useIndexedDB?: boolean; persistentStore?: IAsyncStore; forceMemoryOnly?: boolean; }, @@ -62,6 +67,14 @@ export function configurationStorageFactory( persistentStore, servingStoreUpdateStrategy, ); + } else if (useIndexedDB && hasIndexedDB) { + // IndexedDB is available and user has opted in + const indexedDBStorageEngine = new IndexedDBStorageEngine(storageKeySuffix ?? ''); + return new IsolatableHybridConfigurationStore( + new MemoryStore(), + new StringValuedAsyncStore(indexedDBStorageEngine, maxAgeSeconds), + servingStoreUpdateStrategy, + ); } else if (hasChromeStorage && chromeStorage) { // Chrome storage is available, use it as a fallback const chromeStorageEngine = new ChromeStorageEngine( @@ -135,3 +148,13 @@ export function hasWindowLocalStorage(): boolean { export function localStorageIfAvailable(): Storage | undefined { return hasWindowLocalStorage() ? window.localStorage : undefined; } + +/** Returns whether IndexedDB is available */ +export function hasIndexedDB(): boolean { + try { + return typeof window !== 'undefined' && typeof window.indexedDB !== 'undefined' && !!window.indexedDB; + } catch { + // Some environments may throw when accessing indexedDB + return false; + } +} diff --git a/src/i-client-config.ts b/src/i-client-config.ts index 4fb6edc..7369291 100644 --- a/src/i-client-config.ts +++ b/src/i-client-config.ts @@ -189,4 +189,11 @@ export interface IClientConfig extends IBaseRequestConfig { * The key to use for the overrides store. */ overridesStorageKey?: string; + + /** + * Use IndexedDB for storing flag configurations instead of localStorage. + * IndexedDB provides larger storage capacity (~50MB+) compared to localStorage (~5-10MB). + * (default: false) + */ + useIndexedDB?: boolean; } diff --git a/src/index.ts b/src/index.ts index cb9eade..8a105df 100644 --- a/src/index.ts +++ b/src/index.ts @@ -37,6 +37,7 @@ import { chromeStorageIfAvailable, configurationStorageFactory, hasChromeStorage, + hasIndexedDB, hasWindowLocalStorage, localStorageIfAvailable, overrideStorageFactory, @@ -337,6 +338,8 @@ export class EppoJSClient extends EppoClient { persistentStore, hasChromeStorage: hasChromeStorage(), hasWindowLocalStorage: hasWindowLocalStorage(), + hasIndexedDB: hasIndexedDB(), + useIndexedDB: config.useIndexedDB, }, { chromeStorage: chromeStorageIfAvailable(), diff --git a/src/indexed-db-storage-engine.ts b/src/indexed-db-storage-engine.ts new file mode 100644 index 0000000..43f6f7f --- /dev/null +++ b/src/indexed-db-storage-engine.ts @@ -0,0 +1,196 @@ +import * as LZString from 'lz-string'; + +import { CONFIGURATION_KEY, META_KEY } from './storage-key-constants'; +import { + IStringStorageEngine, + StorageFullUnableToWrite, + LocalStorageUnknownFailure, +} from './string-valued.store'; + +/** + * IndexedDB implementation of a string-valued store for storing a configuration and its metadata. + * + * This provides an alternative to localStorage with larger storage capacity (~50MB+ vs ~5-10MB). + * Like LocalStorageEngine, it compresses data using LZ-string before storage. + * + * The database uses a simple key-value structure with two object stores: + * - 'contents': stores compressed configuration data + * - 'meta': stores metadata about the configuration (e.g., lastUpdatedAtMs) + */ +export class IndexedDBStorageEngine implements IStringStorageEngine { + private static readonly DB_NAME = 'eppo-sdk-storage'; + private static readonly DB_VERSION = 1; + private static readonly CONTENTS_STORE = 'contents'; + private static readonly META_STORE = 'meta'; + + private dbPromise: Promise | null = null; + private readonly contentsKey: string; + private readonly metaKey: string; + + public constructor(storageKeySuffix: string) { + const keySuffix = storageKeySuffix ? `-${storageKeySuffix}` : ''; + this.contentsKey = CONFIGURATION_KEY + keySuffix; + this.metaKey = META_KEY + keySuffix; + } + + /** + * Initialize the IndexedDB database and create object stores if needed. + * This is called lazily on first access. + */ + private initDB(): Promise { + if (this.dbPromise) { + return this.dbPromise; + } + + this.dbPromise = new Promise((resolve, reject) => { + const request = indexedDB.open( + IndexedDBStorageEngine.DB_NAME, + IndexedDBStorageEngine.DB_VERSION, + ); + + request.onerror = () => { + reject( + new LocalStorageUnknownFailure( + `Failed to open IndexedDB: ${request.error?.message}`, + request.error as Error, + ), + ); + }; + + request.onsuccess = () => { + resolve(request.result); + }; + + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result; + + // Create object stores if they don't exist + if (!db.objectStoreNames.contains(IndexedDBStorageEngine.CONTENTS_STORE)) { + db.createObjectStore(IndexedDBStorageEngine.CONTENTS_STORE); + } + if (!db.objectStoreNames.contains(IndexedDBStorageEngine.META_STORE)) { + db.createObjectStore(IndexedDBStorageEngine.META_STORE); + } + }; + }); + + return this.dbPromise; + } + + /** + * Get a value from an object store by key. + */ + private async get(storeName: string, key: string): Promise { + try { + const db = await this.initDB(); + const transaction = db.transaction([storeName], 'readonly'); + const store = transaction.objectStore(storeName); + const request = store.get(key); + + return new Promise((resolve, reject) => { + request.onsuccess = () => { + resolve(request.result ?? null); + }; + request.onerror = () => { + reject( + new LocalStorageUnknownFailure( + `Failed to read from IndexedDB: ${request.error?.message}`, + request.error as Error, + ), + ); + }; + }); + } catch (error) { + if (error instanceof LocalStorageUnknownFailure) { + throw error; + } + const errorMessage = error instanceof Error ? error.message : String(error); + throw new LocalStorageUnknownFailure( + `Failed to read from IndexedDB: ${errorMessage}`, + error instanceof Error ? error : undefined, + ); + } + } + + /** + * Set a value in an object store by key. + * @throws StorageFullUnableToWrite when quota is exceeded + * @throws LocalStorageUnknownFailure for other errors + */ + private async set(storeName: string, key: string, value: string): Promise { + try { + const db = await this.initDB(); + const transaction = db.transaction([storeName], 'readwrite'); + const store = transaction.objectStore(storeName); + const request = store.put(value, key); + + return new Promise((resolve, reject) => { + request.onsuccess = () => { + resolve(); + }; + request.onerror = () => { + // Check for quota exceeded error + if ( + request.error && + (request.error.name === 'QuotaExceededError' || + (request.error as DOMException).code === DOMException.QUOTA_EXCEEDED_ERR) + ) { + reject(new StorageFullUnableToWrite()); + } else { + reject( + new LocalStorageUnknownFailure( + `Failed to write to IndexedDB: ${request.error?.message}`, + request.error as Error, + ), + ); + } + }; + }); + } catch (error) { + if (error instanceof StorageFullUnableToWrite || error instanceof LocalStorageUnknownFailure) { + throw error; + } + const errorMessage = error instanceof Error ? error.message : String(error); + throw new LocalStorageUnknownFailure( + `Failed to write to IndexedDB: ${errorMessage}`, + error instanceof Error ? error : undefined, + ); + } + } + + public async getContentsJsonString(): Promise { + const stored = await this.get(IndexedDBStorageEngine.CONTENTS_STORE, this.contentsKey); + if (!stored) return null; + + try { + return LZString.decompressFromBase64(stored) || null; + } catch (e) { + // Failed to decompress configuration, removing corrupted data + await this.set(IndexedDBStorageEngine.CONTENTS_STORE, this.contentsKey, '').catch(() => { + // Ignore errors when trying to clean up corrupted data + }); + return null; + } + } + + public async getMetaJsonString(): Promise { + return this.get(IndexedDBStorageEngine.META_STORE, this.metaKey); + } + + /** + * @throws StorageFullUnableToWrite + * @throws LocalStorageUnknownFailure + */ + public async setContentsJsonString(configurationJsonString: string): Promise { + const compressed = LZString.compressToBase64(configurationJsonString); + await this.set(IndexedDBStorageEngine.CONTENTS_STORE, this.contentsKey, compressed); + } + + /** + * @throws StorageFullUnableToWrite + * @throws LocalStorageUnknownFailure + */ + public async setMetaJsonString(metaJsonString: string): Promise { + await this.set(IndexedDBStorageEngine.META_STORE, this.metaKey, metaJsonString); + } +} From e2afcbd2731a614fb51aa9a8ac0769d0bb2f5cf9 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Mon, 12 Jan 2026 20:08:13 -0500 Subject: [PATCH 2/5] feat: add IndexedDB support for assignment cache with blob storage Extend IndexedDB implementation to support the assignment cache in addition to the configuration store. The assignment cache is stored as a blob (serialized Map) for efficient storage and retrieval. Key changes: - New IndexedDBAssignmentShim implementing Map interface - Stores assignment cache as blob in IndexedDB 'assignments' object store - New IndexedDBAssignmentCache wrapper class - Update assignmentCacheFactory to support IndexedDB with useIndexedDB flag - Add useIndexedDB to IPrecomputedClientConfig interface - IndexedDB database now has three object stores: - 'contents': compressed flag configuration - 'meta': configuration metadata - 'assignments': assignment cache blob Storage priority for assignment cache when useIndexedDB=true: 1. Chrome storage (if available) 2. IndexedDB (if available and opted in) 3. LocalStorage (if available) 4. Memory-only (fallback) Assignment cache data is stored as a serialized array of Map entries for efficient blob storage, providing ~50MB+ capacity vs localStorage's ~5-10MB limit. --- docs/js-client-sdk.iclientconfig.md | 19 ++ ...s-client-sdk.iclientconfig.useindexeddb.md | 13 ++ js-client-sdk.api.md | 1 + src/cache/assignment-cache-factory.ts | 20 +- src/cache/indexed-db-assignment-cache.ts | 39 ++++ src/cache/indexed-db-assignment-shim.ts | 220 ++++++++++++++++++ src/i-client-config.ts | 7 + src/index.ts | 2 + src/indexed-db-storage-engine.ts | 4 + 9 files changed, 318 insertions(+), 7 deletions(-) create mode 100644 docs/js-client-sdk.iclientconfig.useindexeddb.md create mode 100644 src/cache/indexed-db-assignment-cache.ts create mode 100644 src/cache/indexed-db-assignment-shim.ts diff --git a/docs/js-client-sdk.iclientconfig.md b/docs/js-client-sdk.iclientconfig.md index 9fa2f9e..f0efadc 100644 --- a/docs/js-client-sdk.iclientconfig.md +++ b/docs/js-client-sdk.iclientconfig.md @@ -206,6 +206,25 @@ boolean _(Optional)_ Whether initialization will be considered successfully complete if expired cache values are loaded. If false, initialization will always wait for a fetch if cached values are expired. (default: false) + + + +[useIndexedDB?](./js-client-sdk.iclientconfig.useindexeddb.md) + + + + + + + +boolean + + + + +_(Optional)_ Use IndexedDB for storing flag configurations instead of localStorage. IndexedDB provides larger storage capacity (\~50MB+) compared to localStorage (\~5-10MB). (default: false) + + diff --git a/docs/js-client-sdk.iclientconfig.useindexeddb.md b/docs/js-client-sdk.iclientconfig.useindexeddb.md new file mode 100644 index 0000000..e031f18 --- /dev/null +++ b/docs/js-client-sdk.iclientconfig.useindexeddb.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [@eppo/js-client-sdk](./js-client-sdk.md) > [IClientConfig](./js-client-sdk.iclientconfig.md) > [useIndexedDB](./js-client-sdk.iclientconfig.useindexeddb.md) + +## IClientConfig.useIndexedDB property + +Use IndexedDB for storing flag configurations instead of localStorage. IndexedDB provides larger storage capacity (\~50MB+) compared to localStorage (\~5-10MB). (default: false) + +**Signature:** + +```typescript +useIndexedDB?: boolean; +``` diff --git a/js-client-sdk.api.md b/js-client-sdk.api.md index 618ca17..88e4329 100644 --- a/js-client-sdk.api.md +++ b/js-client-sdk.api.md @@ -199,6 +199,7 @@ export interface IPrecomputedClientConfig extends IBaseRequestConfig { // // (undocumented) precompute: IPrecompute; + useIndexedDB?: boolean; } // @public diff --git a/src/cache/assignment-cache-factory.ts b/src/cache/assignment-cache-factory.ts index c23943a..1fe9339 100644 --- a/src/cache/assignment-cache-factory.ts +++ b/src/cache/assignment-cache-factory.ts @@ -1,18 +1,21 @@ import { AssignmentCache } from '@eppo/js-client-sdk-common'; -import { hasWindowLocalStorage } from '../configuration-factory'; +import { hasIndexedDB, hasWindowLocalStorage } from '../configuration-factory'; import ChromeStorageAssignmentCache from './chrome-storage-assignment-cache'; import HybridAssignmentCache from './hybrid-assignment-cache'; +import { IndexedDBAssignmentCache } from './indexed-db-assignment-cache'; import { LocalStorageAssignmentCache } from './local-storage-assignment-cache'; import SimpleAssignmentCache from './simple-assignment-cache'; export function assignmentCacheFactory({ forceMemoryOnly = false, + useIndexedDB = false, chromeStorage, storageKeySuffix, }: { forceMemoryOnly?: boolean; + useIndexedDB?: boolean; storageKeySuffix: string; chromeStorage?: chrome.storage.StorageArea; }): AssignmentCache { @@ -22,15 +25,18 @@ export function assignmentCacheFactory({ return simpleCache; } + // Priority order: Chrome storage > IndexedDB (if opted in) > localStorage > memory-only if (chromeStorage) { const chromeStorageCache = new ChromeStorageAssignmentCache(chromeStorage); return new HybridAssignmentCache(simpleCache, chromeStorageCache); + } else if (useIndexedDB && hasIndexedDB()) { + // IndexedDB is available and user has opted in + const indexedDBCache = new IndexedDBAssignmentCache(storageKeySuffix); + return new HybridAssignmentCache(simpleCache, indexedDBCache); + } else if (hasWindowLocalStorage()) { + const localStorageCache = new LocalStorageAssignmentCache(storageKeySuffix); + return new HybridAssignmentCache(simpleCache, localStorageCache); } else { - if (hasWindowLocalStorage()) { - const localStorageCache = new LocalStorageAssignmentCache(storageKeySuffix); - return new HybridAssignmentCache(simpleCache, localStorageCache); - } else { - return simpleCache; - } + return simpleCache; } } diff --git a/src/cache/indexed-db-assignment-cache.ts b/src/cache/indexed-db-assignment-cache.ts new file mode 100644 index 0000000..7063bf6 --- /dev/null +++ b/src/cache/indexed-db-assignment-cache.ts @@ -0,0 +1,39 @@ +import { AbstractAssignmentCache } from '@eppo/js-client-sdk-common'; + +import { BulkReadAssignmentCache, BulkWriteAssignmentCache } from './hybrid-assignment-cache'; +import { IndexedDBAssignmentShim } from './indexed-db-assignment-shim'; + +/** + * IndexedDB-backed assignment cache. + * + * Provides persistent storage for assignment results with larger capacity than localStorage. + * Stores assignments as a blob in IndexedDB for efficient storage and retrieval. + */ +export class IndexedDBAssignmentCache + extends AbstractAssignmentCache + implements BulkReadAssignmentCache, BulkWriteAssignmentCache +{ + constructor(storageKeySuffix: string) { + super(new IndexedDBAssignmentShim(storageKeySuffix)); + } + + setEntries(entries: [string, string][]): void { + entries.forEach(([key, value]) => { + if (key && value) { + this.delegate.set(key, value); + } + }); + } + + async getEntries(): Promise<[string, string][]> { + return Array.from(this.entries()); + } + + /** + * Initialize the cache by loading from IndexedDB. + * This should be called during SDK initialization. + */ + async init(): Promise { + await this.delegate.init(); + } +} diff --git a/src/cache/indexed-db-assignment-shim.ts b/src/cache/indexed-db-assignment-shim.ts new file mode 100644 index 0000000..465b011 --- /dev/null +++ b/src/cache/indexed-db-assignment-shim.ts @@ -0,0 +1,220 @@ +/** + * IndexedDB-backed Map implementation for assignment cache. + * + * Stores the entire assignment cache as a single blob in IndexedDB for efficiency. + * This provides better storage capacity (~50MB+) compared to localStorage (~5-10MB) + * and better performance for large datasets through async operations. + */ +export class IndexedDBAssignmentShim implements Map { + private static readonly DB_NAME = 'eppo-sdk-storage'; + private static readonly DB_VERSION = 1; + private static readonly ASSIGNMENTS_STORE = 'assignments'; + + private readonly assignmentKey: string; + private dbPromise: Promise | null = null; + private cache: Map | null = null; + + public constructor(storageKeySuffix: string) { + const keySuffix = storageKeySuffix ? `-${storageKeySuffix}` : ''; + this.assignmentKey = `eppo-assignment${keySuffix}`; + } + + /** + * Initialize the IndexedDB database and create object stores if needed. + * This is called lazily on first access. + */ + private initDB(): Promise { + if (this.dbPromise) { + return this.dbPromise; + } + + this.dbPromise = new Promise((resolve, reject) => { + const request = indexedDB.open( + IndexedDBAssignmentShim.DB_NAME, + IndexedDBAssignmentShim.DB_VERSION, + ); + + request.onerror = () => { + reject(new Error(`Failed to open IndexedDB: ${request.error?.message}`)); + }; + + request.onsuccess = () => { + resolve(request.result); + }; + + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result; + + // Create assignments object store if it doesn't exist + if (!db.objectStoreNames.contains(IndexedDBAssignmentShim.ASSIGNMENTS_STORE)) { + db.createObjectStore(IndexedDBAssignmentShim.ASSIGNMENTS_STORE); + } + + // Note: The config storage engine may have already created 'contents' and 'meta' stores + // We only create 'assignments' here if needed + if (!db.objectStoreNames.contains('contents')) { + db.createObjectStore('contents'); + } + if (!db.objectStoreNames.contains('meta')) { + db.createObjectStore('meta'); + } + }; + }); + + return this.dbPromise; + } + + /** + * Load the cache from IndexedDB. + * Returns a cached Map if already loaded, otherwise fetches from IndexedDB. + */ + private async getCache(): Promise> { + if (this.cache !== null) { + return this.cache; + } + + try { + const db = await this.initDB(); + const transaction = db.transaction([IndexedDBAssignmentShim.ASSIGNMENTS_STORE], 'readonly'); + const store = transaction.objectStore(IndexedDBAssignmentShim.ASSIGNMENTS_STORE); + const request = store.get(this.assignmentKey); + + return new Promise>((resolve, reject) => { + request.onsuccess = () => { + const data = request.result; + if (data && Array.isArray(data)) { + this.cache = new Map(data); + resolve(this.cache); + } else { + this.cache = new Map(); + resolve(this.cache); + } + }; + request.onerror = () => { + // On error, return empty map + this.cache = new Map(); + resolve(this.cache); + }; + }); + } catch (error) { + // If IndexedDB fails, return empty map + this.cache = new Map(); + return this.cache; + } + } + + /** + * Persist the cache to IndexedDB as a blob. + * Stores the Map as a JSON-serialized array of entries. + */ + private async setCache(cache: Map): Promise { + this.cache = cache; + + try { + const db = await this.initDB(); + const transaction = db.transaction([IndexedDBAssignmentShim.ASSIGNMENTS_STORE], 'readwrite'); + const store = transaction.objectStore(IndexedDBAssignmentShim.ASSIGNMENTS_STORE); + + // Serialize the Map as an array of entries for efficient blob storage + const data = Array.from(cache.entries()); + const request = store.put(data, this.assignmentKey); + + return new Promise((resolve, reject) => { + request.onsuccess = () => { + resolve(); + }; + request.onerror = () => { + // Silently fail on write errors - assignment cache is not critical + resolve(); + }; + }); + } catch (error) { + // Silently fail on errors - assignment cache is not critical + return Promise.resolve(); + } + } + + // Map interface implementation - all methods are synchronous wrappers around async operations + // The cache is loaded once and kept in memory for fast synchronous access + + clear(): void { + this.cache = new Map(); + this.setCache(this.cache).catch(() => { + // Ignore errors + }); + } + + delete(key: string): boolean { + if (this.cache === null) { + return false; + } + const result = this.cache.delete(key); + if (result) { + this.setCache(this.cache).catch(() => { + // Ignore errors + }); + } + return result; + } + + forEach( + callbackfn: (value: string, key: string, map: Map) => void, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + thisArg?: any, + ): void { + if (this.cache !== null) { + this.cache.forEach(callbackfn, thisArg); + } + } + + get size(): number { + return this.cache?.size ?? 0; + } + + entries(): IterableIterator<[string, string]> { + return (this.cache ?? new Map()).entries(); + } + + keys(): IterableIterator { + return (this.cache ?? new Map()).keys(); + } + + values(): IterableIterator { + return (this.cache ?? new Map()).values(); + } + + [Symbol.iterator](): IterableIterator<[string, string]> { + return (this.cache ?? new Map())[Symbol.iterator](); + } + + get [Symbol.toStringTag](): string { + return 'IndexedDBAssignmentShim'; + } + + public has(key: string): boolean { + return this.cache?.has(key) ?? false; + } + + public get(key: string): string | undefined { + return this.cache?.get(key); + } + + public set(key: string, value: string): this { + if (this.cache === null) { + this.cache = new Map(); + } + this.cache.set(key, value); + this.setCache(this.cache).catch(() => { + // Ignore errors + }); + return this; + } + + /** + * Initialize the cache by loading from IndexedDB. + * This should be called during SDK initialization to warm up the cache. + */ + public async init(): Promise { + await this.getCache(); + } +} diff --git a/src/i-client-config.ts b/src/i-client-config.ts index 7369291..d31c3ad 100644 --- a/src/i-client-config.ts +++ b/src/i-client-config.ts @@ -112,6 +112,13 @@ export interface IPrecomputedClientConfig extends IBaseRequestConfig { * The key to use for the overrides store. */ overridesStorageKey?: string; + + /** + * Use IndexedDB for storing flag configurations and assignment cache instead of localStorage. + * IndexedDB provides larger storage capacity (~50MB+) compared to localStorage (~5-10MB). + * (default: false) + */ + useIndexedDB?: boolean; } /** diff --git a/src/index.ts b/src/index.ts index 8a105df..e5b2988 100644 --- a/src/index.ts +++ b/src/index.ts @@ -376,6 +376,7 @@ export class EppoJSClient extends EppoClient { const assignmentCache = assignmentCacheFactory({ chromeStorage: chromeStorageIfAvailable(), storageKeySuffix, + useIndexedDB: config.useIndexedDB, }); if (assignmentCache instanceof HybridAssignmentCache) { await assignmentCache.init(); @@ -808,6 +809,7 @@ export async function precomputedInit( const assignmentCache = assignmentCacheFactory({ chromeStorage: chromeStorageIfAvailable(), storageKeySuffix, + useIndexedDB: config.useIndexedDB, }); if (assignmentCache instanceof HybridAssignmentCache) { await assignmentCache.init(); diff --git a/src/indexed-db-storage-engine.ts b/src/indexed-db-storage-engine.ts index 43f6f7f..5773e87 100644 --- a/src/indexed-db-storage-engine.ts +++ b/src/indexed-db-storage-engine.ts @@ -71,6 +71,10 @@ export class IndexedDBStorageEngine implements IStringStorageEngine { if (!db.objectStoreNames.contains(IndexedDBStorageEngine.META_STORE)) { db.createObjectStore(IndexedDBStorageEngine.META_STORE); } + // Create assignments store for assignment cache (if not already created) + if (!db.objectStoreNames.contains('assignments')) { + db.createObjectStore('assignments'); + } }; }); From bae22004f7d46241ce709dc7a46aac3e1a053d43 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Tue, 13 Jan 2026 22:22:31 -0500 Subject: [PATCH 3/5] refactor: use native objects for IndexedDB storage instead of compression - Remove LZ-string compression from IndexedDBStorageEngine - Store configuration as native JavaScript objects using structured clone - Update JSDoc to reflect accurate storage capacity (gigabytes vs ~50MB) - Add unit tests for IndexedDB native object storage --- .../js-client-sdk.iprecomputedclientconfig.md | 19 ++ ...k.iprecomputedclientconfig.useindexeddb.md | 13 + src/cache/indexed-db-assignment-shim.ts | 15 +- src/i-client-config.ts | 10 +- src/indexed-db-storage-engine.spec.ts | 262 ++++++++++++++++++ src/indexed-db-storage-engine.ts | 35 ++- 6 files changed, 332 insertions(+), 22 deletions(-) create mode 100644 docs/js-client-sdk.iprecomputedclientconfig.useindexeddb.md create mode 100644 src/indexed-db-storage-engine.spec.ts diff --git a/docs/js-client-sdk.iprecomputedclientconfig.md b/docs/js-client-sdk.iprecomputedclientconfig.md index c153fd2..9fc58be 100644 --- a/docs/js-client-sdk.iprecomputedclientconfig.md +++ b/docs/js-client-sdk.iprecomputedclientconfig.md @@ -90,6 +90,25 @@ IPrecompute + + + +[useIndexedDB?](./js-client-sdk.iprecomputedclientconfig.useindexeddb.md) + + + + + + + +boolean + + + + +_(Optional)_ Use IndexedDB for storing flag configurations and assignment cache instead of localStorage. IndexedDB provides larger storage capacity (\~50MB+) compared to localStorage (\~5-10MB). (default: false) + + diff --git a/docs/js-client-sdk.iprecomputedclientconfig.useindexeddb.md b/docs/js-client-sdk.iprecomputedclientconfig.useindexeddb.md new file mode 100644 index 0000000..abaf697 --- /dev/null +++ b/docs/js-client-sdk.iprecomputedclientconfig.useindexeddb.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [@eppo/js-client-sdk](./js-client-sdk.md) > [IPrecomputedClientConfig](./js-client-sdk.iprecomputedclientconfig.md) > [useIndexedDB](./js-client-sdk.iprecomputedclientconfig.useindexeddb.md) + +## IPrecomputedClientConfig.useIndexedDB property + +Use IndexedDB for storing flag configurations and assignment cache instead of localStorage. IndexedDB provides larger storage capacity (\~50MB+) compared to localStorage (\~5-10MB). (default: false) + +**Signature:** + +```typescript +useIndexedDB?: boolean; +``` diff --git a/src/cache/indexed-db-assignment-shim.ts b/src/cache/indexed-db-assignment-shim.ts index 465b011..40b739a 100644 --- a/src/cache/indexed-db-assignment-shim.ts +++ b/src/cache/indexed-db-assignment-shim.ts @@ -1,9 +1,12 @@ /** * IndexedDB-backed Map implementation for assignment cache. * - * Stores the entire assignment cache as a single blob in IndexedDB for efficiency. - * This provides better storage capacity (~50MB+) compared to localStorage (~5-10MB) - * and better performance for large datasets through async operations. + * Stores the assignment cache as a native JavaScript array in IndexedDB using the + * structured clone algorithm. This provides significantly larger storage capacity + * (gigabytes, browser-dependent, typically 10GB+) compared to localStorage's ~5-10MB limit. + * + * Chrome compresses IndexedDB values natively at the storage layer. The cache is loaded + * into memory at initialization for fast synchronous access during flag evaluations. */ export class IndexedDBAssignmentShim implements Map { private static readonly DB_NAME = 'eppo-sdk-storage'; @@ -104,8 +107,8 @@ export class IndexedDBAssignmentShim implements Map { } /** - * Persist the cache to IndexedDB as a blob. - * Stores the Map as a JSON-serialized array of entries. + * Persist the cache to IndexedDB. + * Stores the Map as a native array of entries using IndexedDB's structured clone algorithm. */ private async setCache(cache: Map): Promise { this.cache = cache; @@ -115,7 +118,7 @@ export class IndexedDBAssignmentShim implements Map { const transaction = db.transaction([IndexedDBAssignmentShim.ASSIGNMENTS_STORE], 'readwrite'); const store = transaction.objectStore(IndexedDBAssignmentShim.ASSIGNMENTS_STORE); - // Serialize the Map as an array of entries for efficient blob storage + // Store the Map as a native array of entries const data = Array.from(cache.entries()); const request = store.put(data, this.assignmentKey); diff --git a/src/i-client-config.ts b/src/i-client-config.ts index d31c3ad..9f6d447 100644 --- a/src/i-client-config.ts +++ b/src/i-client-config.ts @@ -115,7 +115,9 @@ export interface IPrecomputedClientConfig extends IBaseRequestConfig { /** * Use IndexedDB for storing flag configurations and assignment cache instead of localStorage. - * IndexedDB provides larger storage capacity (~50MB+) compared to localStorage (~5-10MB). + * IndexedDB provides significantly larger storage capacity (gigabytes, browser-dependent) + * compared to localStorage (~5-10MB). Data is stored as native JavaScript objects using + * IndexedDB's structured clone algorithm for efficient storage and retrieval. * (default: false) */ useIndexedDB?: boolean; @@ -198,8 +200,10 @@ export interface IClientConfig extends IBaseRequestConfig { overridesStorageKey?: string; /** - * Use IndexedDB for storing flag configurations instead of localStorage. - * IndexedDB provides larger storage capacity (~50MB+) compared to localStorage (~5-10MB). + * Use IndexedDB for storing flag configurations and assignment cache instead of localStorage. + * IndexedDB provides significantly larger storage capacity (gigabytes, browser-dependent) + * compared to localStorage (~5-10MB). Data is stored as native JavaScript objects using + * IndexedDB's structured clone algorithm for efficient storage and retrieval. * (default: false) */ useIndexedDB?: boolean; diff --git a/src/indexed-db-storage-engine.spec.ts b/src/indexed-db-storage-engine.spec.ts new file mode 100644 index 0000000..841a871 --- /dev/null +++ b/src/indexed-db-storage-engine.spec.ts @@ -0,0 +1,262 @@ +/** + * @jest-environment jsdom + */ + +import { IndexedDBStorageEngine } from './indexed-db-storage-engine'; +import { StorageFullUnableToWrite, LocalStorageUnknownFailure } from './string-valued.store'; + +// Mock IndexedDB for testing +class MockIDBDatabase { + objectStoreNames = { + contains: jest.fn().mockReturnValue(false), + }; + transaction = jest.fn(); + createObjectStore = jest.fn(); +} + +class MockIDBObjectStore { + get = jest.fn(); + put = jest.fn(); +} + +class MockIDBTransaction { + objectStore = jest.fn(); +} + +class MockIDBRequest { + result: unknown = null; + error: Error | null = null; + onsuccess: ((event: Event) => void) | null = null; + onerror: ((event: Event) => void) | null = null; +} + +class MockIDBOpenDBRequest extends MockIDBRequest { + onupgradeneeded: ((event: IDBVersionChangeEvent) => void) | null = null; +} + +describe('IndexedDBStorageEngine', () => { + let mockDB: MockIDBDatabase; + let mockStore: MockIDBObjectStore; + let mockTransaction: MockIDBTransaction; + let mockOpenRequest: MockIDBOpenDBRequest; + let engine: IndexedDBStorageEngine; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let storedData: Map; + + beforeEach(() => { + storedData = new Map(); + mockDB = new MockIDBDatabase(); + mockStore = new MockIDBObjectStore(); + mockTransaction = new MockIDBTransaction(); + mockOpenRequest = new MockIDBOpenDBRequest(); + + mockTransaction.objectStore.mockReturnValue(mockStore); + mockDB.transaction.mockReturnValue(mockTransaction); + + // Simulate IndexedDB storage + mockStore.get.mockImplementation((key: string) => { + const request = new MockIDBRequest(); + setTimeout(() => { + request.result = storedData.get(key) ?? null; + request.onsuccess?.({ target: request } as unknown as Event); + }, 0); + return request; + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockStore.put.mockImplementation((value: any, key: string) => { + const request = new MockIDBRequest(); + setTimeout(() => { + storedData.set(key, value); + request.onsuccess?.({ target: request } as unknown as Event); + }, 0); + return request; + }); + + // Mock global indexedDB + const mockIndexedDB = { + open: jest.fn().mockImplementation(() => { + setTimeout(() => { + mockOpenRequest.result = mockDB; + // Simulate upgrade needed for new database + mockOpenRequest.onupgradeneeded?.({ + target: mockOpenRequest, + } as unknown as IDBVersionChangeEvent); + mockOpenRequest.onsuccess?.({ target: mockOpenRequest } as unknown as Event); + }, 0); + return mockOpenRequest; + }), + }; + + Object.defineProperty(global, 'indexedDB', { + value: mockIndexedDB, + writable: true, + }); + + engine = new IndexedDBStorageEngine('test'); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('Native Object Storage', () => { + it('should store configuration as native object', async () => { + const testConfig = { flag1: { key: 'flag1', enabled: true }, flag2: { key: 'flag2', enabled: false } }; + const configJson = JSON.stringify(testConfig); + + await engine.setContentsJsonString(configJson); + + // Verify the native object was stored (not a string) + const storedValue = storedData.get('eppo-configuration-test'); + expect(storedValue).toEqual(testConfig); + expect(typeof storedValue).toBe('object'); + }); + + it('should retrieve configuration and return as JSON string', async () => { + const testConfig = { flag1: { key: 'flag1', enabled: true } }; + storedData.set('eppo-configuration-test', testConfig); + + const result = await engine.getContentsJsonString(); + + expect(result).toBe(JSON.stringify(testConfig)); + }); + + it('should return null when no configuration exists', async () => { + const result = await engine.getContentsJsonString(); + + expect(result).toBe(null); + }); + + it('should handle round-trip correctly', async () => { + const testConfig = { + 'feature-flag': { + key: 'feature-flag', + enabled: true, + variationType: 'BOOLEAN', + allocations: [{ key: 'default', value: true }], + }, + }; + const configJson = JSON.stringify(testConfig); + + await engine.setContentsJsonString(configJson); + const result = await engine.getContentsJsonString(); + + expect(result).toBe(configJson); + expect(JSON.parse(result!)).toEqual(testConfig); + }); + + it('should handle complex nested configuration objects', async () => { + const complexConfig = { + flag1: { + key: 'flag1', + enabled: true, + variations: { + control: { key: 'control', value: 'a' }, + treatment: { key: 'treatment', value: 'b' }, + }, + allocations: [ + { + key: 'allocation1', + rules: [{ conditions: [{ attribute: 'userId', operator: 'MATCHES', value: '.*' }] }], + splits: [{ variationKey: 'control', shards: [] }], + }, + ], + }, + }; + const configJson = JSON.stringify(complexConfig); + + await engine.setContentsJsonString(configJson); + const result = await engine.getContentsJsonString(); + + expect(JSON.parse(result!)).toEqual(complexConfig); + }); + }); + + describe('Meta Storage', () => { + it('should store and retrieve meta data', async () => { + const metaData = JSON.stringify({ lastUpdatedAtMs: Date.now() }); + + await engine.setMetaJsonString(metaData); + + // Meta is stored directly (it's already a string for interface compatibility) + const storedMeta = storedData.get('eppo-configuration-meta-test'); + expect(storedMeta).toBe(metaData); + }); + + it('should return null when no meta exists', async () => { + const result = await engine.getMetaJsonString(); + + expect(result).toBe(null); + }); + }); + + describe('Error Handling', () => { + it('should handle corrupted data gracefully', async () => { + // Store something that will fail JSON.stringify + const circularObj: Record = {}; + circularObj.self = circularObj; + storedData.set('eppo-configuration-test', circularObj); + + // Should return null and attempt to clear corrupted data + const result = await engine.getContentsJsonString(); + + expect(result).toBe(null); + }); + + it('should throw StorageFullUnableToWrite on quota exceeded', async () => { + const quotaError = new DOMException('Quota exceeded', 'QuotaExceededError'); + Object.defineProperty(quotaError, 'code', { value: DOMException.QUOTA_EXCEEDED_ERR }); + + mockStore.put.mockImplementation(() => { + const request = new MockIDBRequest(); + setTimeout(() => { + request.error = quotaError; + request.onerror?.({ target: request } as unknown as Event); + }, 0); + return request; + }); + + await expect(engine.setContentsJsonString('{"test": true}')).rejects.toThrow( + StorageFullUnableToWrite, + ); + }); + + it('should throw LocalStorageUnknownFailure on other errors', async () => { + const unknownError = new Error('Unknown error'); + + mockStore.put.mockImplementation(() => { + const request = new MockIDBRequest(); + setTimeout(() => { + request.error = unknownError; + request.onerror?.({ target: request } as unknown as Event); + }, 0); + return request; + }); + + await expect(engine.setContentsJsonString('{"test": true}')).rejects.toThrow( + LocalStorageUnknownFailure, + ); + }); + }); + + describe('Storage Key Suffix', () => { + it('should use correct key with suffix', async () => { + const testConfig = { flag: { key: 'flag' } }; + + await engine.setContentsJsonString(JSON.stringify(testConfig)); + + expect(storedData.has('eppo-configuration-test')).toBe(true); + expect(storedData.has('eppo-configuration')).toBe(false); + }); + + it('should use correct key without suffix', async () => { + const engineNoSuffix = new IndexedDBStorageEngine(''); + const testConfig = { flag: { key: 'flag' } }; + + await engineNoSuffix.setContentsJsonString(JSON.stringify(testConfig)); + + expect(storedData.has('eppo-configuration')).toBe(true); + }); + }); +}); diff --git a/src/indexed-db-storage-engine.ts b/src/indexed-db-storage-engine.ts index 5773e87..347f297 100644 --- a/src/indexed-db-storage-engine.ts +++ b/src/indexed-db-storage-engine.ts @@ -1,5 +1,3 @@ -import * as LZString from 'lz-string'; - import { CONFIGURATION_KEY, META_KEY } from './storage-key-constants'; import { IStringStorageEngine, @@ -10,11 +8,15 @@ import { /** * IndexedDB implementation of a string-valued store for storing a configuration and its metadata. * - * This provides an alternative to localStorage with larger storage capacity (~50MB+ vs ~5-10MB). - * Like LocalStorageEngine, it compresses data using LZ-string before storage. + * This provides an alternative to localStorage with significantly larger storage capacity + * (gigabytes, browser-dependent, typically 10GB+) compared to localStorage's ~5-10MB limit. + * + * Configuration is stored as a native JavaScript object using IndexedDB's structured clone + * algorithm, which is more efficient than JSON string storage. Chrome compresses IndexedDB + * values natively at the storage layer. * * The database uses a simple key-value structure with two object stores: - * - 'contents': stores compressed configuration data + * - 'contents': stores configuration data as native objects * - 'meta': stores metadata about the configuration (e.g., lastUpdatedAtMs) */ export class IndexedDBStorageEngine implements IStringStorageEngine { @@ -83,15 +85,18 @@ export class IndexedDBStorageEngine implements IStringStorageEngine { /** * Get a value from an object store by key. + * Returns native objects directly from IndexedDB (no parsing needed). */ - private async get(storeName: string, key: string): Promise { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private async get(storeName: string, key: string): Promise { try { const db = await this.initDB(); const transaction = db.transaction([storeName], 'readonly'); const store = transaction.objectStore(storeName); const request = store.get(key); - return new Promise((resolve, reject) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return new Promise((resolve, reject) => { request.onsuccess = () => { resolve(request.result ?? null); }; @@ -118,10 +123,12 @@ export class IndexedDBStorageEngine implements IStringStorageEngine { /** * Set a value in an object store by key. + * Stores native objects directly in IndexedDB using structured clone algorithm. * @throws StorageFullUnableToWrite when quota is exceeded * @throws LocalStorageUnknownFailure for other errors */ - private async set(storeName: string, key: string, value: string): Promise { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private async set(storeName: string, key: string, value: any): Promise { try { const db = await this.initDB(); const transaction = db.transaction([storeName], 'readwrite'); @@ -167,10 +174,11 @@ export class IndexedDBStorageEngine implements IStringStorageEngine { if (!stored) return null; try { - return LZString.decompressFromBase64(stored) || null; + // stored is already a native object, stringify for interface compatibility + return JSON.stringify(stored); } catch (e) { - // Failed to decompress configuration, removing corrupted data - await this.set(IndexedDBStorageEngine.CONTENTS_STORE, this.contentsKey, '').catch(() => { + // Failed to serialize configuration, removing corrupted data + await this.set(IndexedDBStorageEngine.CONTENTS_STORE, this.contentsKey, null).catch(() => { // Ignore errors when trying to clean up corrupted data }); return null; @@ -186,8 +194,9 @@ export class IndexedDBStorageEngine implements IStringStorageEngine { * @throws LocalStorageUnknownFailure */ public async setContentsJsonString(configurationJsonString: string): Promise { - const compressed = LZString.compressToBase64(configurationJsonString); - await this.set(IndexedDBStorageEngine.CONTENTS_STORE, this.contentsKey, compressed); + // Parse JSON once, then store as native object for efficient retrieval + const config = JSON.parse(configurationJsonString); + await this.set(IndexedDBStorageEngine.CONTENTS_STORE, this.contentsKey, config); } /** From 43ca4fc6f5c478b8f30aa9e53fa3639756c5557f Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Tue, 13 Jan 2026 22:27:55 -0500 Subject: [PATCH 4/5] doc update --- docs/js-client-sdk.iclientconfig.md | 2 +- docs/js-client-sdk.iclientconfig.useindexeddb.md | 2 +- docs/js-client-sdk.iprecomputedclientconfig.md | 2 +- docs/js-client-sdk.iprecomputedclientconfig.useindexeddb.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/js-client-sdk.iclientconfig.md b/docs/js-client-sdk.iclientconfig.md index f0efadc..6a235d5 100644 --- a/docs/js-client-sdk.iclientconfig.md +++ b/docs/js-client-sdk.iclientconfig.md @@ -222,7 +222,7 @@ boolean -_(Optional)_ Use IndexedDB for storing flag configurations instead of localStorage. IndexedDB provides larger storage capacity (\~50MB+) compared to localStorage (\~5-10MB). (default: false) +_(Optional)_ Use IndexedDB for storing flag configurations and assignment cache instead of localStorage. IndexedDB provides significantly larger storage capacity (gigabytes, browser-dependent) compared to localStorage (\~5-10MB). Data is stored as native JavaScript objects using IndexedDB's structured clone algorithm for efficient storage and retrieval. (default: false) diff --git a/docs/js-client-sdk.iclientconfig.useindexeddb.md b/docs/js-client-sdk.iclientconfig.useindexeddb.md index e031f18..c9e4ee1 100644 --- a/docs/js-client-sdk.iclientconfig.useindexeddb.md +++ b/docs/js-client-sdk.iclientconfig.useindexeddb.md @@ -4,7 +4,7 @@ ## IClientConfig.useIndexedDB property -Use IndexedDB for storing flag configurations instead of localStorage. IndexedDB provides larger storage capacity (\~50MB+) compared to localStorage (\~5-10MB). (default: false) +Use IndexedDB for storing flag configurations and assignment cache instead of localStorage. IndexedDB provides significantly larger storage capacity (gigabytes, browser-dependent) compared to localStorage (\~5-10MB). Data is stored as native JavaScript objects using IndexedDB's structured clone algorithm for efficient storage and retrieval. (default: false) **Signature:** diff --git a/docs/js-client-sdk.iprecomputedclientconfig.md b/docs/js-client-sdk.iprecomputedclientconfig.md index 9fc58be..3b269c9 100644 --- a/docs/js-client-sdk.iprecomputedclientconfig.md +++ b/docs/js-client-sdk.iprecomputedclientconfig.md @@ -106,7 +106,7 @@ boolean -_(Optional)_ Use IndexedDB for storing flag configurations and assignment cache instead of localStorage. IndexedDB provides larger storage capacity (\~50MB+) compared to localStorage (\~5-10MB). (default: false) +_(Optional)_ Use IndexedDB for storing flag configurations and assignment cache instead of localStorage. IndexedDB provides significantly larger storage capacity (gigabytes, browser-dependent) compared to localStorage (\~5-10MB). Data is stored as native JavaScript objects using IndexedDB's structured clone algorithm for efficient storage and retrieval. (default: false) diff --git a/docs/js-client-sdk.iprecomputedclientconfig.useindexeddb.md b/docs/js-client-sdk.iprecomputedclientconfig.useindexeddb.md index abaf697..e944886 100644 --- a/docs/js-client-sdk.iprecomputedclientconfig.useindexeddb.md +++ b/docs/js-client-sdk.iprecomputedclientconfig.useindexeddb.md @@ -4,7 +4,7 @@ ## IPrecomputedClientConfig.useIndexedDB property -Use IndexedDB for storing flag configurations and assignment cache instead of localStorage. IndexedDB provides larger storage capacity (\~50MB+) compared to localStorage (\~5-10MB). (default: false) +Use IndexedDB for storing flag configurations and assignment cache instead of localStorage. IndexedDB provides significantly larger storage capacity (gigabytes, browser-dependent) compared to localStorage (\~5-10MB). Data is stored as native JavaScript objects using IndexedDB's structured clone algorithm for efficient storage and retrieval. (default: false) **Signature:** From f047eb23fb1da2dcf44ca49a843d9b629518a880 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Tue, 13 Jan 2026 22:38:15 -0500 Subject: [PATCH 5/5] refactor: store JSON strings directly in IndexedDB storage engine Simplify IndexedDBStorageEngine to store JSON strings directly instead of parsing to native objects. This matches the IStringStorageEngine interface contract and removes unnecessary JSON.parse/stringify roundtrip overhead. --- src/indexed-db-storage-engine.spec.ts | 31 ++++++++++----------------- src/indexed-db-storage-engine.ts | 25 +++++---------------- 2 files changed, 16 insertions(+), 40 deletions(-) diff --git a/src/indexed-db-storage-engine.spec.ts b/src/indexed-db-storage-engine.spec.ts index 841a871..6c5e423 100644 --- a/src/indexed-db-storage-engine.spec.ts +++ b/src/indexed-db-storage-engine.spec.ts @@ -100,26 +100,27 @@ describe('IndexedDBStorageEngine', () => { jest.clearAllMocks(); }); - describe('Native Object Storage', () => { - it('should store configuration as native object', async () => { + describe('JSON String Storage', () => { + it('should store configuration as JSON string', async () => { const testConfig = { flag1: { key: 'flag1', enabled: true }, flag2: { key: 'flag2', enabled: false } }; const configJson = JSON.stringify(testConfig); await engine.setContentsJsonString(configJson); - // Verify the native object was stored (not a string) + // Verify the JSON string was stored directly const storedValue = storedData.get('eppo-configuration-test'); - expect(storedValue).toEqual(testConfig); - expect(typeof storedValue).toBe('object'); + expect(storedValue).toBe(configJson); + expect(typeof storedValue).toBe('string'); }); - it('should retrieve configuration and return as JSON string', async () => { + it('should retrieve configuration as JSON string', async () => { const testConfig = { flag1: { key: 'flag1', enabled: true } }; - storedData.set('eppo-configuration-test', testConfig); + const configJson = JSON.stringify(testConfig); + storedData.set('eppo-configuration-test', configJson); const result = await engine.getContentsJsonString(); - expect(result).toBe(JSON.stringify(testConfig)); + expect(result).toBe(configJson); }); it('should return null when no configuration exists', async () => { @@ -146,7 +147,7 @@ describe('IndexedDBStorageEngine', () => { expect(JSON.parse(result!)).toEqual(testConfig); }); - it('should handle complex nested configuration objects', async () => { + it('should handle complex nested configuration', async () => { const complexConfig = { flag1: { key: 'flag1', @@ -169,6 +170,7 @@ describe('IndexedDBStorageEngine', () => { await engine.setContentsJsonString(configJson); const result = await engine.getContentsJsonString(); + expect(result).toBe(configJson); expect(JSON.parse(result!)).toEqual(complexConfig); }); }); @@ -192,17 +194,6 @@ describe('IndexedDBStorageEngine', () => { }); describe('Error Handling', () => { - it('should handle corrupted data gracefully', async () => { - // Store something that will fail JSON.stringify - const circularObj: Record = {}; - circularObj.self = circularObj; - storedData.set('eppo-configuration-test', circularObj); - - // Should return null and attempt to clear corrupted data - const result = await engine.getContentsJsonString(); - - expect(result).toBe(null); - }); it('should throw StorageFullUnableToWrite on quota exceeded', async () => { const quotaError = new DOMException('Quota exceeded', 'QuotaExceededError'); diff --git a/src/indexed-db-storage-engine.ts b/src/indexed-db-storage-engine.ts index 347f297..5c85df9 100644 --- a/src/indexed-db-storage-engine.ts +++ b/src/indexed-db-storage-engine.ts @@ -11,12 +11,11 @@ import { * This provides an alternative to localStorage with significantly larger storage capacity * (gigabytes, browser-dependent, typically 10GB+) compared to localStorage's ~5-10MB limit. * - * Configuration is stored as a native JavaScript object using IndexedDB's structured clone - * algorithm, which is more efficient than JSON string storage. Chrome compresses IndexedDB - * values natively at the storage layer. + * Configuration and metadata are stored as JSON strings, matching the IStringStorageEngine + * interface contract. Chrome compresses IndexedDB values natively at the storage layer. * * The database uses a simple key-value structure with two object stores: - * - 'contents': stores configuration data as native objects + * - 'contents': stores configuration data as JSON strings * - 'meta': stores metadata about the configuration (e.g., lastUpdatedAtMs) */ export class IndexedDBStorageEngine implements IStringStorageEngine { @@ -170,19 +169,7 @@ export class IndexedDBStorageEngine implements IStringStorageEngine { } public async getContentsJsonString(): Promise { - const stored = await this.get(IndexedDBStorageEngine.CONTENTS_STORE, this.contentsKey); - if (!stored) return null; - - try { - // stored is already a native object, stringify for interface compatibility - return JSON.stringify(stored); - } catch (e) { - // Failed to serialize configuration, removing corrupted data - await this.set(IndexedDBStorageEngine.CONTENTS_STORE, this.contentsKey, null).catch(() => { - // Ignore errors when trying to clean up corrupted data - }); - return null; - } + return this.get(IndexedDBStorageEngine.CONTENTS_STORE, this.contentsKey); } public async getMetaJsonString(): Promise { @@ -194,9 +181,7 @@ export class IndexedDBStorageEngine implements IStringStorageEngine { * @throws LocalStorageUnknownFailure */ public async setContentsJsonString(configurationJsonString: string): Promise { - // Parse JSON once, then store as native object for efficient retrieval - const config = JSON.parse(configurationJsonString); - await this.set(IndexedDBStorageEngine.CONTENTS_STORE, this.contentsKey, config); + await this.set(IndexedDBStorageEngine.CONTENTS_STORE, this.contentsKey, configurationJsonString); } /**