diff --git a/packages/browser/jest.config.js b/packages/browser/jest.config.js index f26d9190..e5a6fedf 100644 --- a/packages/browser/jest.config.js +++ b/packages/browser/jest.config.js @@ -1,6 +1,7 @@ module.exports = { preset: 'ts-jest', testEnvironment: 'jsdom', + setupFiles: ['/test/setup-indexeddb.ts'], setupFilesAfterEnv: ['/src/test-setup.ts'], transform: { '^.+\\.ts$': [ diff --git a/packages/browser/src/cache/assignment-cache-factory.ts b/packages/browser/src/cache/assignment-cache-factory.ts index daa9e02c..add7ecb7 100644 --- a/packages/browser/src/cache/assignment-cache-factory.ts +++ b/packages/browser/src/cache/assignment-cache-factory.ts @@ -1,18 +1,15 @@ import type { AssignmentCache } from '@datadog/flagging-core' -import ChromeStorageAssignmentCache from './chrome-storage-assignment-cache' -import { hasWindowLocalStorage } from './helpers' +import { hasIndexedDB } from './helpers' import HybridAssignmentCache from './hybrid-assignment-cache' -import { LocalStorageAssignmentCache } from './local-storage-assignment-cache' +import { IndexedDBAssignmentCache } from './indexeddb-assignment-cache' import SimpleAssignmentCache from './simple-assignment-cache' export function assignmentCacheFactory({ forceMemoryOnly = false, - chromeStorage, - storageKeySuffix, + clientToken, }: { forceMemoryOnly?: boolean - storageKeySuffix: string - chromeStorage?: chrome.storage.StorageArea + clientToken: string }): AssignmentCache { const simpleCache = new SimpleAssignmentCache() @@ -20,15 +17,10 @@ export function assignmentCacheFactory({ return simpleCache } - if (chromeStorage) { - const chromeStorageCache = new ChromeStorageAssignmentCache(chromeStorage) - return new HybridAssignmentCache(simpleCache, chromeStorageCache) - } else { - if (hasWindowLocalStorage()) { - const localStorageCache = new LocalStorageAssignmentCache(storageKeySuffix) - return new HybridAssignmentCache(simpleCache, localStorageCache) - } else { - return simpleCache - } + if (hasIndexedDB()) { + const indexedDBCache = new IndexedDBAssignmentCache(clientToken) + return new HybridAssignmentCache(simpleCache, indexedDBCache) } + + return simpleCache } diff --git a/packages/browser/src/cache/chrome-storage-assignment-cache.ts b/packages/browser/src/cache/chrome-storage-assignment-cache.ts deleted file mode 100644 index a370c6f1..00000000 --- a/packages/browser/src/cache/chrome-storage-assignment-cache.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { - type AssignmentCacheEntry, - assignmentCacheKeyToString, - assignmentCacheValueToString, -} from '@datadog/flagging-core' - -import ChromeStorageAsyncMap from './chrome-storage-async-map' -import type { BulkReadAssignmentCache } from './hybrid-assignment-cache' - -export default class ChromeStorageAssignmentCache implements BulkReadAssignmentCache { - private readonly storage: ChromeStorageAsyncMap - - constructor(chromeStorage: chrome.storage.StorageArea) { - this.storage = new ChromeStorageAsyncMap(chromeStorage) - } - - init(): Promise { - return Promise.resolve() - } - - set(entry: AssignmentCacheEntry): void { - // "fire-and-forget" - we intentionally don't wait for the promise to resolve - // noinspection JSIgnoredPromiseFromCall - this.storage.set(assignmentCacheKeyToString(entry), assignmentCacheValueToString(entry)) - } - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - has(_entry: AssignmentCacheEntry): boolean { - throw new Error('This should never be called for ChromeStorageAssignmentCache, use getEntries() instead.') - } - - async getEntries(): Promise<[string, string][]> { - const entries = await this.storage.entries() - return Object.entries(entries).map(([key, value]) => [key, value] as [string, string]) - } - - async clear(): Promise { - await this.storage.clear() - } -} diff --git a/packages/browser/src/cache/chrome-storage-async-map.ts b/packages/browser/src/cache/chrome-storage-async-map.ts deleted file mode 100644 index 83a580e3..00000000 --- a/packages/browser/src/cache/chrome-storage-async-map.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { AsyncMap } from '@datadog/flagging-core' - -/** Chrome storage-backed {@link AsyncMap}. */ -export default class ChromeStorageAsyncMap implements AsyncMap { - constructor(private readonly storage: chrome.storage.StorageArea) {} - - async has(key: string): Promise { - const value = await this.get(key) - return !!value - } - - async get(key: string): Promise { - const subset = await this.storage.get(key) - return subset?.[key] ?? undefined - } - - async entries(): Promise<{ [p: string]: T }> { - return await this.storage.get(null) - } - - async set(key: string, value: T) { - await this.storage.set({ [key]: value }) - } - - async clear() { - await this.storage.clear() - } -} diff --git a/packages/browser/src/cache/helpers.ts b/packages/browser/src/cache/helpers.ts index 48ba5d0c..b1b6620f 100644 --- a/packages/browser/src/cache/helpers.ts +++ b/packages/browser/src/cache/helpers.ts @@ -1,25 +1,3 @@ -export function hasChromeStorage(): boolean { - return typeof chrome !== 'undefined' && !!chrome.storage -} - -export function chromeStorageIfAvailable(): chrome.storage.StorageArea | undefined { - return hasChromeStorage() ? chrome.storage.local : undefined -} - -/** Returns whether `window.localStorage` is available */ -export function hasWindowLocalStorage(): boolean { - try { - return typeof window !== 'undefined' && !!window.localStorage - } catch { - // Chrome throws an error if local storage is disabled, and you try to access it - return false - } -} - -export function localStorageIfAvailable(): Storage | undefined { - return hasWindowLocalStorage() ? window.localStorage : undefined -} - /** Returns whether IndexedDB is available */ export function hasIndexedDB(): boolean { try { diff --git a/packages/browser/src/cache/indexeddb-assignment-cache.ts b/packages/browser/src/cache/indexeddb-assignment-cache.ts new file mode 100644 index 00000000..6d628c94 --- /dev/null +++ b/packages/browser/src/cache/indexeddb-assignment-cache.ts @@ -0,0 +1,114 @@ +import { + type AssignmentCacheEntry, + assignmentCacheKeyToString, + assignmentCacheValueToString, + buildStorageKeySuffix, +} from '@datadog/flagging-core' + +import type { BulkReadAssignmentCache } from './hybrid-assignment-cache' +import { openDB, STORE_NAME } from './indexeddb-store' + +export class IndexedDBAssignmentCache implements BulkReadAssignmentCache { + private readonly storageKey: string + private readonly mirror: Map = new Map() + private persistScheduled = false + + constructor(clientToken: string) { + this.storageKey = `assignments-${buildStorageKeySuffix(clientToken)}` + } + + /** No-op — IndexedDB entries are loaded lazily via getEntries(). */ + init(): Promise { + return Promise.resolve() + } + + /** Fire-and-forget persist to IndexedDB. Never blocks the caller, never throws. */ + set(entry: AssignmentCacheEntry): void { + const key = assignmentCacheKeyToString(entry) + const value = assignmentCacheValueToString(entry) + this.mirror.set(key, value) + this.persist() + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + has(_entry: AssignmentCacheEntry): boolean { + throw new Error('This should never be called for IndexedDBAssignmentCache, use getEntries() instead.') + } + + /** Read all persisted entries. Returns [] on any error — never throws. */ + async getEntries(): Promise<[string, string][]> { + try { + const db = await openDB() + try { + const entries = await new Promise<[string, string][] | undefined>((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readonly') + const store = tx.objectStore(STORE_NAME) + const request = store.get(this.storageKey) + request.onsuccess = () => resolve(request.result as [string, string][] | undefined) + request.onerror = () => reject(request.error) + }) + if (Array.isArray(entries)) { + this.mirror.clear() + for (const [k, v] of entries) { + this.mirror.set(k, v) + } + return entries + } + } finally { + db.close() + } + } catch { + // Silently fail — persistence should never break the SDK + } + return [] + } + + /** Remove persisted entries. Never throws. */ + async clear(): Promise { + this.mirror.clear() + try { + const db = await openDB() + try { + await new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readwrite') + const store = tx.objectStore(STORE_NAME) + store.delete(this.storageKey) + tx.oncomplete = () => resolve() + tx.onerror = () => reject(tx.error) + tx.onabort = () => reject(tx.error) + }) + } finally { + db.close() + } + } catch { + // Silently fail + } + } + + /** + * Schedule a fire-and-forget write for the next microtask. Coalesces rapid set() calls + * into a single IDB transaction, avoiding O(n^2) write volume and out-of-order races. + */ + private persist(): void { + if (this.persistScheduled) { + return + } + this.persistScheduled = true + queueMicrotask(() => { + this.persistScheduled = false + const entries = Array.from(this.mirror.entries()) + openDB() + .then((db) => { + const tx = db.transaction(STORE_NAME, 'readwrite') + const store = tx.objectStore(STORE_NAME) + store.put(entries, this.storageKey) + tx.oncomplete = () => db.close() + tx.onerror = () => db.close() + tx.onabort = () => db.close() + }) + .catch(() => { + // Silently fail — persistence should never break the SDK + }) + }) + } +} diff --git a/packages/browser/src/cache/indexeddb-flags-cache.ts b/packages/browser/src/cache/indexeddb-flags-cache.ts index 09f633ff..99ecf857 100644 --- a/packages/browser/src/cache/indexeddb-flags-cache.ts +++ b/packages/browser/src/cache/indexeddb-flags-cache.ts @@ -1,24 +1,7 @@ import type { FlagsConfiguration } from '@datadog/flagging-core' import { buildStorageKeySuffix, getMD5Hash } from '@datadog/flagging-core' import type { EvaluationContext } from '@openfeature/web-sdk' - -const DB_NAME = 'dd-flagging' -const DB_VERSION = 1 -const STORE_NAME = 'configurations' - -function openDB(): Promise { - return new Promise((resolve, reject) => { - const request = indexedDB.open(DB_NAME, DB_VERSION) - request.onupgradeneeded = () => { - const db = request.result - if (!db.objectStoreNames.contains(STORE_NAME)) { - db.createObjectStore(STORE_NAME) - } - } - request.onsuccess = () => resolve(request.result) - request.onerror = () => reject(request.error) - }) -} +import { openDB, STORE_NAME } from './indexeddb-store' function buildConfigKey(clientToken: string, context: EvaluationContext): string { const tokenSuffix = buildStorageKeySuffix(clientToken) diff --git a/packages/browser/src/cache/indexeddb-store.ts b/packages/browser/src/cache/indexeddb-store.ts new file mode 100644 index 00000000..f42485b3 --- /dev/null +++ b/packages/browser/src/cache/indexeddb-store.ts @@ -0,0 +1,17 @@ +export const DB_NAME = 'dd-flagging' +export const DB_VERSION = 1 +export const STORE_NAME = 'configurations' + +export function openDB(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION) + request.onupgradeneeded = () => { + const db = request.result + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME) + } + } + request.onsuccess = () => resolve(request.result) + request.onerror = () => reject(request.error) + }) +} diff --git a/packages/browser/src/cache/local-storage-assignment-cache.ts b/packages/browser/src/cache/local-storage-assignment-cache.ts deleted file mode 100644 index 6aca8c34..00000000 --- a/packages/browser/src/cache/local-storage-assignment-cache.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { AbstractAssignmentCache } from '@datadog/flagging-core' - -import type { BulkReadAssignmentCache, BulkWriteAssignmentCache } from './hybrid-assignment-cache' -import { LocalStorageAssignmentShim } from './local-storage-assignment-shim' - -export class LocalStorageAssignmentCache - extends AbstractAssignmentCache - implements BulkReadAssignmentCache, BulkWriteAssignmentCache -{ - constructor(storageKeySuffix: string) { - super(new LocalStorageAssignmentShim(storageKeySuffix)) - } - - setEntries(entries: [string, string][]): void { - entries.forEach(([key, value]) => { - if (key && value) { - this.delegate.set(key, value) - } - }) - } - - getEntries(): Promise<[string, string][]> { - return Promise.resolve(Array.from(this.entries())) - } -} diff --git a/packages/browser/src/cache/local-storage-assignment-shim.ts b/packages/browser/src/cache/local-storage-assignment-shim.ts deleted file mode 100644 index ecdf3cd2..00000000 --- a/packages/browser/src/cache/local-storage-assignment-shim.ts +++ /dev/null @@ -1,72 +0,0 @@ -// noinspection JSUnusedGlobalSymbols (methods are used by common repository) -import { hasWindowLocalStorage } from './helpers' - -export class LocalStorageAssignmentShim implements Map { - private readonly localStorageKey: string - - public constructor(storageKeySuffix: string) { - if (!hasWindowLocalStorage()) { - throw new Error('LocalStorage is not available') - } - const keySuffix = storageKeySuffix ? `-${storageKeySuffix}` : '' - this.localStorageKey = `eppo-assignment${keySuffix}` - } - - clear(): void { - this.getCache().clear() - } - - delete(key: string): boolean { - return this.getCache().delete(key) - } - - forEach( - callbackfn: (value: string, key: string, map: Map) => void, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - thisArg?: unknown - ): void { - this.getCache().forEach(callbackfn, thisArg) - } - - size: number = this.getCache().size - - entries(): IterableIterator<[string, string]> { - return this.getCache().entries() - } - - keys(): IterableIterator { - return this.getCache().keys() - } - - values(): IterableIterator { - return this.getCache().values() - } - - [Symbol.iterator](): IterableIterator<[string, string]> { - return this.getCache()[Symbol.iterator]() - } - - [Symbol.toStringTag]: string = this.getCache()[Symbol.toStringTag] - - public has(key: string): boolean { - return this.getCache().has(key) - } - - public get(key: string): string | undefined { - return this.getCache().get(key) ?? undefined - } - - public set(key: string, value: string): this { - return this.setCache(this.getCache().set(key, value)) - } - - private getCache(): Map { - const cache = window.localStorage.getItem(this.localStorageKey) - return cache ? new Map(JSON.parse(cache)) : new Map() - } - - private setCache(cache: Map): this { - window.localStorage.setItem(this.localStorageKey, JSON.stringify(Array.from(cache.entries()))) - return this - } -} diff --git a/packages/browser/src/openfeature/provider.ts b/packages/browser/src/openfeature/provider.ts index 50233814..71745282 100644 --- a/packages/browser/src/openfeature/provider.ts +++ b/packages/browser/src/openfeature/provider.ts @@ -16,7 +16,7 @@ import { ProviderStatus, } from '@openfeature/web-sdk' import { assignmentCacheFactory } from '../cache/assignment-cache-factory' -import { chromeStorageIfAvailable, hasIndexedDB } from '../cache/helpers' +import { hasIndexedDB } from '../cache/helpers' import { IndexedDBFlagsCache } from '../cache/indexeddb-flags-cache' import { type FlaggingConfiguration, @@ -73,8 +73,7 @@ export class DatadogProvider implements Provider { const isExposureLoggingEnabled = options.enableExposureLogging ?? true if (isExposureLoggingEnabled && this.configuration) { this.exposureCache = assignmentCacheFactory({ - chromeStorage: chromeStorageIfAvailable(), - storageKeySuffix: 'dd-of-browser', + clientToken: options.clientToken, }) this.hooks.push(createExposureLoggingHook(this.configuration, this.exposureCache)) } diff --git a/packages/browser/test/cache/assignment-cache-factory.spec.ts b/packages/browser/test/cache/assignment-cache-factory.spec.ts index 193adb6a..f1966f5c 100644 --- a/packages/browser/test/cache/assignment-cache-factory.spec.ts +++ b/packages/browser/test/cache/assignment-cache-factory.spec.ts @@ -2,75 +2,37 @@ * @jest-environment jsdom */ -import type { ExposureEvent } from '../../../core/src/configuration/exposureEvent.types' import { assignmentCacheFactory } from '../../src/cache/assignment-cache-factory' import HybridAssignmentCache from '../../src/cache/hybrid-assignment-cache' - -import StorageArea = chrome.storage.StorageArea +import SimpleAssignmentCache from '../../src/cache/simple-assignment-cache' describe('AssignmentCacheFactory', () => { - // TODO: Extract test-only function for this - const fakeStore: { [k: string]: string } = {} - - const get = jest.fn((key?: string) => { - return new Promise((resolve) => { - if (!key) { - resolve(fakeStore) - } else { - resolve({ [key]: fakeStore[key] }) - } - }) - }) as jest.Mock - - const set = jest.fn((items: { [key: string]: string }) => { - return new Promise((resolve) => { - Object.assign(fakeStore, items) - resolve(undefined) - }) - }) as jest.Mock - - const mockChromeStorage = { get, set } as unknown as StorageArea - - beforeEach(() => { - window.localStorage.clear() - Object.keys(fakeStore).forEach((key) => { - delete fakeStore[key] - }) - }) - - it('should create a hybrid cache if chrome storage is available', () => { + it('should create a hybrid cache with IndexedDB when IndexedDB is available', () => { const cache = assignmentCacheFactory({ - chromeStorage: mockChromeStorage, - storageKeySuffix: 'foo', + clientToken: 'test-token', }) expect(cache).toBeInstanceOf(HybridAssignmentCache) - expect(Object.keys(fakeStore)).toHaveLength(0) - const exposureEvent: ExposureEvent = { - subject: { id: 'foo', attributes: {} }, - flag: { key: 'bar' }, - allocation: { key: 'baz' }, - variant: { key: 'qux' }, + }) + + it('should create a simple cache when IndexedDB is unavailable', () => { + const originalIndexedDB = globalThis.indexedDB + // @ts-expect-error — simulating unavailable IndexedDB + delete globalThis.indexedDB + try { + const cache = assignmentCacheFactory({ + clientToken: 'test-token', + }) + expect(cache).toBeInstanceOf(SimpleAssignmentCache) + } finally { + globalThis.indexedDB = originalIndexedDB } - cache.set(exposureEvent) - expect(Object.keys(fakeStore)).toHaveLength(1) }) - it('should create a hybrid cache if local storage is available', () => { + it('should create a simple cache when forceMemoryOnly is true', () => { const cache = assignmentCacheFactory({ - storageKeySuffix: 'foo', + clientToken: 'test-token', + forceMemoryOnly: true, }) - expect(cache).toBeInstanceOf(HybridAssignmentCache) - expect(localStorage.length).toEqual(0) - const exposureEvent: ExposureEvent = { - subject: { id: 'foo', attributes: {} }, - flag: { key: 'bar' }, - allocation: { key: 'baz' }, - variant: { key: 'qux' }, - } - cache.set(exposureEvent) - // chrome storage is not being used - expect(Object.keys(fakeStore)).toHaveLength(0) - // local storage is being used - expect(localStorage.length).toEqual(1) + expect(cache).toBeInstanceOf(SimpleAssignmentCache) }) }) diff --git a/packages/browser/test/cache/hybrid-assignment-cache.spec.ts b/packages/browser/test/cache/hybrid-assignment-cache.spec.ts index aaa6c4b6..68a41c65 100644 --- a/packages/browser/test/cache/hybrid-assignment-cache.spec.ts +++ b/packages/browser/test/cache/hybrid-assignment-cache.spec.ts @@ -1,41 +1,19 @@ -/** - * @jest-environment jsdom - */ - +import { IDBFactory } from 'fake-indexeddb' import type { ExposureEvent } from '../../../core/src/configuration/exposureEvent.types' -import ChromeStorageAssignmentCache from '../../src/cache/chrome-storage-assignment-cache' import HybridAssignmentCache from '../../src/cache/hybrid-assignment-cache' -import { LocalStorageAssignmentCache } from '../../src/cache/local-storage-assignment-cache' - -import StorageArea = chrome.storage.StorageArea +import { IndexedDBAssignmentCache } from '../../src/cache/indexeddb-assignment-cache' +import SimpleAssignmentCache from '../../src/cache/simple-assignment-cache' describe('HybridStorageAssignmentCache', () => { - const fakeStore: Record = {} - - const get = jest.fn((key?: string) => { - return new Promise((resolve) => { - if (!key) { - resolve(fakeStore) - } else { - resolve({ [key]: fakeStore[key] }) - } - }) - }) as jest.Mock - - const set = jest.fn((items: { [key: string]: string }) => { - return new Promise((resolve) => { - Object.assign(fakeStore, items) - resolve(undefined) - }) - }) as jest.Mock - - const mockChromeStorage = { get, set } as unknown as StorageArea - const chromeStorageCache = new ChromeStorageAssignmentCache(mockChromeStorage) - const localStorageCache = new LocalStorageAssignmentCache('test') - const hybridCache = new HybridAssignmentCache(localStorageCache, chromeStorageCache) + let servingCache: SimpleAssignmentCache + let persistentCache: IndexedDBAssignmentCache + let hybridCache: HybridAssignmentCache beforeEach(() => { - window.localStorage.clear() + globalThis.indexedDB = new IDBFactory() + servingCache = new SimpleAssignmentCache() + persistentCache = new IndexedDBAssignmentCache('test-token') + hybridCache = new HybridAssignmentCache(servingCache, persistentCache) }) it('has should return false if cache is empty', async () => { @@ -58,13 +36,13 @@ describe('HybridStorageAssignmentCache', () => { } await hybridCache.init() expect(hybridCache.has(exposureEvent)).toBeFalsy() - expect(localStorageCache.has(exposureEvent)).toBeFalsy() + expect(servingCache.has(exposureEvent)).toBeFalsy() hybridCache.set(exposureEvent) expect(hybridCache.has(exposureEvent)).toBeTruthy() - expect(localStorageCache.has(exposureEvent)).toBeTruthy() + expect(servingCache.has(exposureEvent)).toBeTruthy() }) - it('should populate localStorageCache from chromeStorageCache', async () => { + it('should populate serving cache from persistent cache on init', async () => { const exposureEvent1: ExposureEvent = { subject: { id: 'subject-1', attributes: {} }, flag: { key: 'flag-1' }, @@ -83,12 +61,17 @@ describe('HybridStorageAssignmentCache', () => { allocation: { key: 'foo' }, variant: { key: 'control' }, } - expect(localStorageCache.has(exposureEvent1)).toBeFalsy() - chromeStorageCache.set(exposureEvent1) - chromeStorageCache.set(exposureEvent2) + + // Write entries directly to the persistent store + persistentCache.set(exposureEvent1) + persistentCache.set(exposureEvent2) + // Allow fire-and-forget persist to complete + await new Promise((resolve) => setTimeout(resolve, 50)) + + // Init should hydrate the serving cache from the persistent store await hybridCache.init() - expect(localStorageCache.has(exposureEvent1)).toBeTruthy() - expect(localStorageCache.has(exposureEvent2)).toBeTruthy() - expect(localStorageCache.has(exposureEvent3)).toBeFalsy() + expect(servingCache.has(exposureEvent1)).toBeTruthy() + expect(servingCache.has(exposureEvent2)).toBeTruthy() + expect(servingCache.has(exposureEvent3)).toBeFalsy() }) }) diff --git a/packages/browser/test/cache/indexeddb-assignment-cache.spec.ts b/packages/browser/test/cache/indexeddb-assignment-cache.spec.ts new file mode 100644 index 00000000..feba6e97 --- /dev/null +++ b/packages/browser/test/cache/indexeddb-assignment-cache.spec.ts @@ -0,0 +1,144 @@ +import { IDBFactory } from 'fake-indexeddb' +import type { ExposureEvent } from '../../../core/src/configuration/exposureEvent.types' +import { IndexedDBAssignmentCache } from '../../src/cache/indexeddb-assignment-cache' + +const exposureA: ExposureEvent = { + subject: { id: 'user-1', attributes: {} }, + flag: { key: 'flag-1' }, + allocation: { key: 'alloc-1' }, + variant: { key: 'var-1' }, +} + +const exposureB: ExposureEvent = { + subject: { id: 'user-2', attributes: {} }, + flag: { key: 'flag-2' }, + allocation: { key: 'alloc-2' }, + variant: { key: 'var-2' }, +} + +describe('IndexedDBAssignmentCache', () => { + let cache: IndexedDBAssignmentCache + + beforeEach(() => { + globalThis.indexedDB = new IDBFactory() + cache = new IndexedDBAssignmentCache('test-client-token') + }) + + describe('round-trip set/getEntries', () => { + it('should persist entries and retrieve them', async () => { + cache.set(exposureA) + // Allow fire-and-forget persist to complete + await flushAsync() + + const entries = await cache.getEntries() + expect(entries).toHaveLength(1) + expect(entries[0]).toHaveLength(2) + expect(typeof entries[0][0]).toBe('string') + expect(typeof entries[0][1]).toBe('string') + }) + + it('should persist multiple entries', async () => { + cache.set(exposureA) + cache.set(exposureB) + await flushAsync() + + const entries = await cache.getEntries() + expect(entries).toHaveLength(2) + }) + + it('should return empty array when no entries exist', async () => { + const entries = await cache.getEntries() + expect(entries).toEqual([]) + }) + }) + + describe('client token isolation', () => { + it('should not share data between caches with different client tokens', async () => { + const cacheA = new IndexedDBAssignmentCache('token-aaa') + const cacheB = new IndexedDBAssignmentCache('token-bbb') + + cacheA.set(exposureA) + await flushAsync() + + const entriesA = await cacheA.getEntries() + const entriesB = await cacheB.getEntries() + + expect(entriesA).toHaveLength(1) + expect(entriesB).toEqual([]) + }) + }) + + describe('graceful failure when IndexedDB unavailable', () => { + it('getEntries should return empty array', async () => { + const originalIndexedDB = globalThis.indexedDB + // @ts-expect-error — simulating unavailable IndexedDB + delete globalThis.indexedDB + try { + const entries = await cache.getEntries() + expect(entries).toEqual([]) + } finally { + globalThis.indexedDB = originalIndexedDB + } + }) + + it('set should not throw', () => { + const originalIndexedDB = globalThis.indexedDB + // @ts-expect-error — simulating unavailable IndexedDB + delete globalThis.indexedDB + try { + expect(() => cache.set(exposureA)).not.toThrow() + } finally { + globalThis.indexedDB = originalIndexedDB + } + }) + + it('clear should not throw', async () => { + const originalIndexedDB = globalThis.indexedDB + // @ts-expect-error — simulating unavailable IndexedDB + delete globalThis.indexedDB + try { + await expect(cache.clear()).resolves.toBeUndefined() + } finally { + globalThis.indexedDB = originalIndexedDB + } + }) + }) + + describe('has', () => { + it('should throw because the serving store handles this', () => { + expect(() => cache.has(exposureA)).toThrow() + }) + }) + + describe('cross-instance persistence (simulates page reload)', () => { + it('should read entries written by a previous instance', async () => { + cache.set(exposureA) + cache.set(exposureB) + await flushAsync() + + // New instance with the same token — simulates a fresh page load + const freshCache = new IndexedDBAssignmentCache('test-client-token') + const entries = await freshCache.getEntries() + expect(entries).toHaveLength(2) + }) + }) + + describe('clear', () => { + it('should remove all entries', async () => { + cache.set(exposureA) + await flushAsync() + expect(await cache.getEntries()).toHaveLength(1) + + await cache.clear() + expect(await cache.getEntries()).toEqual([]) + }) + + it('should not throw when DB is already empty', async () => { + await expect(cache.clear()).resolves.toBeUndefined() + }) + }) +}) + +function flushAsync(): Promise { + return new Promise((resolve) => setTimeout(resolve, 50)) +} diff --git a/packages/browser/test/cache/indexeddb-flags-cache.spec.ts b/packages/browser/test/cache/indexeddb-flags-cache.spec.ts index 48a9e5b2..1450bcb1 100644 --- a/packages/browser/test/cache/indexeddb-flags-cache.spec.ts +++ b/packages/browser/test/cache/indexeddb-flags-cache.spec.ts @@ -1,8 +1,3 @@ -// structuredClone is required by fake-indexeddb but not available in jsdom -if (typeof globalThis.structuredClone === 'undefined') { - globalThis.structuredClone = (val: T): T => JSON.parse(JSON.stringify(val)) -} -import 'fake-indexeddb/auto' import type { FlagsConfiguration } from '@datadog/flagging-core' import { IDBFactory } from 'fake-indexeddb' import { IndexedDBFlagsCache } from '../../src/cache/indexeddb-flags-cache' diff --git a/packages/browser/test/cache/local-storage-assignment-cache.spec.ts b/packages/browser/test/cache/local-storage-assignment-cache.spec.ts deleted file mode 100644 index 14c7e395..00000000 --- a/packages/browser/test/cache/local-storage-assignment-cache.spec.ts +++ /dev/null @@ -1,70 +0,0 @@ -import type { ExposureEvent } from '../../../core/src/configuration/exposureEvent.types' -import { LocalStorageAssignmentCache } from '../../src/cache/local-storage-assignment-cache' - -describe('LocalStorageAssignmentCache', () => { - it('typical behavior', () => { - const cache = new LocalStorageAssignmentCache('test') - - const exposureEvent1: ExposureEvent = { - subject: { id: 'subject-1', attributes: {} }, - flag: { key: 'flag-1' }, - allocation: { key: 'allocation-1' }, - variant: { key: 'control' }, - } - - expect(cache.has(exposureEvent1)).toEqual(false) - - cache.set(exposureEvent1) - - expect(cache.has(exposureEvent1)).toEqual(true) // this key has been logged - - // change variation - const exposureEvent2: ExposureEvent = { - subject: { id: 'subject-1', attributes: {} }, - flag: { key: 'flag-1' }, - allocation: { key: 'allocation-1' }, - variant: { key: 'variant' }, - } - - cache.set(exposureEvent2) - - expect(cache.has(exposureEvent1)).toEqual(false) // this key has not been logged - }) - - it('can have independent caches', () => { - const storageKeySuffixA = 'A' - const storageKeySuffixB = 'B' - const cacheA = new LocalStorageAssignmentCache(storageKeySuffixA) - const cacheB = new LocalStorageAssignmentCache(storageKeySuffixB) - - const exposureEventA: ExposureEvent = { - subject: { id: 'subject-1', attributes: {} }, - flag: { key: 'flag-1' }, - allocation: { key: 'allocation-1' }, - variant: { key: 'variation-A' }, - } - - const exposureEventB: ExposureEvent = { - subject: { id: 'subject-1', attributes: {} }, - flag: { key: 'flag-1' }, - allocation: { key: 'allocation-1' }, - variant: { key: 'variation-B' }, - } - - cacheA.set(exposureEventA) - - expect(cacheA.has(exposureEventA)).toEqual(true) - - expect(cacheB.has(exposureEventA)).toEqual(false) - - cacheB.set(exposureEventB) - - expect(cacheA.has(exposureEventA)).toEqual(true) - - expect(cacheB.has(exposureEventA)).toEqual(false) - - expect(cacheA.has(exposureEventB)).toEqual(false) - - expect(cacheB.has(exposureEventB)).toEqual(true) - }) -}) diff --git a/packages/browser/test/openfeature/exposures.spec.ts b/packages/browser/test/openfeature/exposures.spec.ts index 962b93c2..3ee10594 100644 --- a/packages/browser/test/openfeature/exposures.spec.ts +++ b/packages/browser/test/openfeature/exposures.spec.ts @@ -1,5 +1,6 @@ import { INTAKE_SITE_STAGING } from '@datadog/browser-core' import { OpenFeature } from '@openfeature/web-sdk' +import { IDBFactory } from 'fake-indexeddb' import type { FlaggingInitConfiguration } from '../../src/domain/configuration' import { DatadogProvider } from '../../src/openfeature/provider' import precomputedServerResponse from '../data/precomputed-v1.json' @@ -43,8 +44,8 @@ describe('Exposures End-to-End', () => { OpenFeature.clearHandlers() OpenFeature.clearHooks() - // Clear localStorage to reset assignment cache between tests - localStorage.clear() + // Reset IndexedDB to clear assignment cache between tests + globalThis.indexedDB = new IDBFactory() // Mock current time to get deterministic timestamps jest.setSystemTime(new Date('2025-08-04T17:00:00.000Z')) @@ -620,7 +621,7 @@ describe('Exposures End-to-End', () => { // Verify first exposure was logged expect(getExposuresCalls()).toHaveLength(1) - // Simulate page reload: clear providers but keep localStorage + // Simulate page reload: clear providers but keep IndexedDB await OpenFeature.clearProviders() fetchMock.mockClear() @@ -629,11 +630,11 @@ describe('Exposures End-to-End', () => { await OpenFeature.setProviderAndWait(provider2) const client2 = OpenFeature.getClient() - // Evaluate same flag - should NOT log because cache persisted from localStorage + // Evaluate same flag - should NOT log because cache persisted from IndexedDB client2.getStringValue('string-flag', 'default') triggerBatch() - // Should have no new exposure calls (cache was loaded from localStorage) + // Should have no new exposure calls (cache was loaded from IndexedDB) expect(getExposuresCalls()).toHaveLength(0) }) diff --git a/packages/browser/test/openfeature/provider-persistence.spec.ts b/packages/browser/test/openfeature/provider-persistence.spec.ts index 5ad109be..a9abf187 100644 --- a/packages/browser/test/openfeature/provider-persistence.spec.ts +++ b/packages/browser/test/openfeature/provider-persistence.spec.ts @@ -1,8 +1,3 @@ -// structuredClone is required by fake-indexeddb but not available in jsdom -if (typeof globalThis.structuredClone === 'undefined') { - globalThis.structuredClone = (val: T): T => JSON.parse(JSON.stringify(val)) -} -import 'fake-indexeddb/auto' import { INTAKE_SITE_STAGING } from '@datadog/browser-core' import type { FlagsConfiguration } from '@datadog/flagging-core' import { ProviderStatus } from '@openfeature/web-sdk' diff --git a/packages/browser/test/setup-indexeddb.ts b/packages/browser/test/setup-indexeddb.ts new file mode 100644 index 00000000..db79a6b8 --- /dev/null +++ b/packages/browser/test/setup-indexeddb.ts @@ -0,0 +1,7 @@ +// Polyfill structuredClone for fake-indexeddb (not available in jsdom) +if (typeof globalThis.structuredClone === 'undefined') { + globalThis.structuredClone = (val: T): T => JSON.parse(JSON.stringify(val)) +} + +// Auto-register fake IndexedDB globals before any test code runs +import 'fake-indexeddb/auto'