diff --git a/docs/js-client-sdk.iclientconfig.md b/docs/js-client-sdk.iclientconfig.md index 9fa2f9e0..6a235d54 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 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 new file mode 100644 index 00000000..c9e4ee10 --- /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 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:** + +```typescript +useIndexedDB?: boolean; +``` diff --git a/docs/js-client-sdk.iprecomputedclientconfig.md b/docs/js-client-sdk.iprecomputedclientconfig.md index c153fd25..3b269c9c 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 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 new file mode 100644 index 00000000..e944886f --- /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 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:** + +```typescript +useIndexedDB?: boolean; +``` diff --git a/js-client-sdk.api.md b/js-client-sdk.api.md index 4ec328b8..88e43298 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 @@ -198,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 c23943a3..1fe9339a 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 00000000..7063bf61 --- /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 00000000..40b739ab --- /dev/null +++ b/src/cache/indexed-db-assignment-shim.ts @@ -0,0 +1,223 @@ +/** + * IndexedDB-backed Map implementation for assignment cache. + * + * 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'; + 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. + * Stores the Map as a native array of entries using IndexedDB's structured clone algorithm. + */ + 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); + + // Store the Map as a native array of entries + 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/configuration-factory.ts b/src/configuration-factory.ts index bc41b7c0..29cfbe9e 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 4fb6edcf..9f6d447b 100644 --- a/src/i-client-config.ts +++ b/src/i-client-config.ts @@ -112,6 +112,15 @@ 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 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; } /** @@ -189,4 +198,13 @@ export interface IClientConfig 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 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/index.ts b/src/index.ts index cb9eade7..e5b29888 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(), @@ -373,6 +376,7 @@ export class EppoJSClient extends EppoClient { const assignmentCache = assignmentCacheFactory({ chromeStorage: chromeStorageIfAvailable(), storageKeySuffix, + useIndexedDB: config.useIndexedDB, }); if (assignmentCache instanceof HybridAssignmentCache) { await assignmentCache.init(); @@ -805,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.spec.ts b/src/indexed-db-storage-engine.spec.ts new file mode 100644 index 00000000..6c5e423d --- /dev/null +++ b/src/indexed-db-storage-engine.spec.ts @@ -0,0 +1,253 @@ +/** + * @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('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 JSON string was stored directly + const storedValue = storedData.get('eppo-configuration-test'); + expect(storedValue).toBe(configJson); + expect(typeof storedValue).toBe('string'); + }); + + it('should retrieve configuration as JSON string', async () => { + const testConfig = { flag1: { key: 'flag1', enabled: true } }; + const configJson = JSON.stringify(testConfig); + storedData.set('eppo-configuration-test', configJson); + + const result = await engine.getContentsJsonString(); + + expect(result).toBe(configJson); + }); + + 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', 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(result).toBe(configJson); + 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 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 new file mode 100644 index 00000000..5c85df9c --- /dev/null +++ b/src/indexed-db-storage-engine.ts @@ -0,0 +1,194 @@ +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 significantly larger storage capacity + * (gigabytes, browser-dependent, typically 10GB+) compared to localStorage's ~5-10MB limit. + * + * 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 JSON strings + * - '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); + } + // Create assignments store for assignment cache (if not already created) + if (!db.objectStoreNames.contains('assignments')) { + db.createObjectStore('assignments'); + } + }; + }); + + return this.dbPromise; + } + + /** + * Get a value from an object store by key. + * Returns native objects directly from IndexedDB (no parsing needed). + */ + // 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); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + 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. + * Stores native objects directly in IndexedDB using structured clone algorithm. + * @throws StorageFullUnableToWrite when quota is exceeded + * @throws LocalStorageUnknownFailure for other errors + */ + // 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'); + 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 { + return this.get(IndexedDBStorageEngine.CONTENTS_STORE, this.contentsKey); + } + + public async getMetaJsonString(): Promise { + return this.get(IndexedDBStorageEngine.META_STORE, this.metaKey); + } + + /** + * @throws StorageFullUnableToWrite + * @throws LocalStorageUnknownFailure + */ + public async setContentsJsonString(configurationJsonString: string): Promise { + await this.set(IndexedDBStorageEngine.CONTENTS_STORE, this.contentsKey, configurationJsonString); + } + + /** + * @throws StorageFullUnableToWrite + * @throws LocalStorageUnknownFailure + */ + public async setMetaJsonString(metaJsonString: string): Promise { + await this.set(IndexedDBStorageEngine.META_STORE, this.metaKey, metaJsonString); + } +}