From 9998cf04d551784789e46add48433f60f016892c Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Sun, 1 Mar 2026 14:47:06 +0100 Subject: [PATCH 1/6] Move assignment cache from localStorage to IndexedDB, scoped by clientToken MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the localStorage-based assignment cache (exposure deduplication) with IndexedDB, consistent with the flags cache. Storage is now scoped by clientToken via buildStorageKeySuffix(), so different apps on the same origin no longer share state. - New IndexedDBAssignmentCache: same dd-flagging DB / configurations store, in-memory Map mirror for fast synchronous set(), fire-and-forget persistence to IndexedDB. - Simplified factory: IndexedDB → memory-only (Chrome storage and localStorage dropped). - Updated provider to pass clientToken instead of hardcoded key. --- .../src/cache/assignment-cache-factory.ts | 26 ++-- .../cache/chrome-storage-assignment-cache.ts | 40 ------ .../src/cache/chrome-storage-async-map.ts | 28 ---- packages/browser/src/cache/helpers.ts | 8 -- .../src/cache/indexeddb-assignment-cache.ts | 120 ++++++++++++++++ packages/browser/src/openfeature/provider.ts | 5 +- .../cache/assignment-cache-factory.spec.ts | 84 ++++------- .../cache/hybrid-assignment-cache.spec.ts | 70 ++++----- .../cache/indexeddb-assignment-cache.spec.ts | 136 ++++++++++++++++++ .../test/openfeature/exposures.spec.ts | 10 +- 10 files changed, 330 insertions(+), 197 deletions(-) delete mode 100644 packages/browser/src/cache/chrome-storage-assignment-cache.ts delete mode 100644 packages/browser/src/cache/chrome-storage-async-map.ts create mode 100644 packages/browser/src/cache/indexeddb-assignment-cache.ts create mode 100644 packages/browser/test/cache/indexeddb-assignment-cache.spec.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..8e2e1442 100644 --- a/packages/browser/src/cache/helpers.ts +++ b/packages/browser/src/cache/helpers.ts @@ -1,11 +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 { 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..4203faa0 --- /dev/null +++ b/packages/browser/src/cache/indexeddb-assignment-cache.ts @@ -0,0 +1,120 @@ +import { + type AssignmentCacheEntry, + assignmentCacheKeyToString, + assignmentCacheValueToString, + buildStorageKeySuffix, +} from '@datadog/flagging-core' + +import type { BulkReadAssignmentCache } from './hybrid-assignment-cache' + +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) + }) +} + +export class IndexedDBAssignmentCache implements BulkReadAssignmentCache { + private readonly storageKey: string + private readonly mirror: Map = new Map() + + 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 + } + } + + /** Fire-and-forget write to IndexedDB. Resolves on tx.oncomplete; silently swallows errors. */ + private persist(): void { + 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 + }) + } +} 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..fa678f86 100644 --- a/packages/browser/test/cache/assignment-cache-factory.spec.ts +++ b/packages/browser/test/cache/assignment-cache-factory.spec.ts @@ -1,76 +1,44 @@ +// 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' + /** * @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..8d04708c 100644 --- a/packages/browser/test/cache/hybrid-assignment-cache.spec.ts +++ b/packages/browser/test/cache/hybrid-assignment-cache.spec.ts @@ -1,41 +1,24 @@ -/** - * @jest-environment jsdom - */ - +// 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 { 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 +41,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 +66,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..dc303cdf --- /dev/null +++ b/packages/browser/test/cache/indexeddb-assignment-cache.spec.ts @@ -0,0 +1,136 @@ +// 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 { 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('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/openfeature/exposures.spec.ts b/packages/browser/test/openfeature/exposures.spec.ts index 962b93c2..27f59cf2 100644 --- a/packages/browser/test/openfeature/exposures.spec.ts +++ b/packages/browser/test/openfeature/exposures.spec.ts @@ -1,3 +1,8 @@ +// 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 { OpenFeature } from '@openfeature/web-sdk' import type { FlaggingInitConfiguration } from '../../src/domain/configuration' @@ -43,8 +48,9 @@ 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 + const { IDBFactory } = require('fake-indexeddb') + globalThis.indexedDB = new IDBFactory() // Mock current time to get deterministic timestamps jest.setSystemTime(new Date('2025-08-04T17:00:00.000Z')) From ed637cb811645dcf7715a19939f6a2801769f361 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Mon, 2 Mar 2026 12:17:45 -0500 Subject: [PATCH 2/6] Address review feedback: clean up dead code, deduplicate, batch writes - Delete orphaned localStorage and Chrome storage cache files and helpers. - Extract shared openDB() and IndexedDB constants into indexeddb-store.ts. - Batch persist() writes via queueMicrotask so rapid set() calls coalesce into a single IDB transaction per tick. - Move structuredClone polyfill and fake-indexeddb setup to shared jest setup file. - Replace inline require() with top-level import in exposures spec. - Add cross-instance persistence test for the page-reload scenario. --- packages/browser/jest.config.js | 1 + packages/browser/src/cache/helpers.ts | 14 ---- .../src/cache/indexeddb-assignment-cache.ts | 58 +++++++-------- .../src/cache/indexeddb-flags-cache.ts | 19 +---- packages/browser/src/cache/indexeddb-store.ts | 17 +++++ .../cache/local-storage-assignment-cache.ts | 25 ------- .../cache/local-storage-assignment-shim.ts | 72 ------------------- .../cache/assignment-cache-factory.spec.ts | 6 -- .../cache/hybrid-assignment-cache.spec.ts | 5 -- .../cache/indexeddb-assignment-cache.spec.ts | 18 +++-- .../test/cache/indexeddb-flags-cache.spec.ts | 5 -- .../local-storage-assignment-cache.spec.ts | 70 ------------------ .../test/openfeature/exposures.spec.ts | 7 +- .../openfeature/provider-persistence.spec.ts | 5 -- packages/browser/test/setup-indexeddb.ts | 7 ++ 15 files changed, 66 insertions(+), 263 deletions(-) create mode 100644 packages/browser/src/cache/indexeddb-store.ts delete mode 100644 packages/browser/src/cache/local-storage-assignment-cache.ts delete mode 100644 packages/browser/src/cache/local-storage-assignment-shim.ts delete mode 100644 packages/browser/test/cache/local-storage-assignment-cache.spec.ts create mode 100644 packages/browser/test/setup-indexeddb.ts 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/helpers.ts b/packages/browser/src/cache/helpers.ts index 8e2e1442..b1b6620f 100644 --- a/packages/browser/src/cache/helpers.ts +++ b/packages/browser/src/cache/helpers.ts @@ -1,17 +1,3 @@ -/** 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 index 4203faa0..6d628c94 100644 --- a/packages/browser/src/cache/indexeddb-assignment-cache.ts +++ b/packages/browser/src/cache/indexeddb-assignment-cache.ts @@ -6,28 +6,12 @@ import { } from '@datadog/flagging-core' import type { BulkReadAssignmentCache } from './hybrid-assignment-cache' - -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' 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)}` @@ -101,20 +85,30 @@ export class IndexedDBAssignmentCache implements BulkReadAssignmentCache { } } - /** Fire-and-forget write to IndexedDB. Resolves on tx.oncomplete; silently swallows errors. */ + /** + * 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 { - 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 - }) + 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/test/cache/assignment-cache-factory.spec.ts b/packages/browser/test/cache/assignment-cache-factory.spec.ts index fa678f86..f1966f5c 100644 --- a/packages/browser/test/cache/assignment-cache-factory.spec.ts +++ b/packages/browser/test/cache/assignment-cache-factory.spec.ts @@ -1,9 +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' - /** * @jest-environment jsdom */ diff --git a/packages/browser/test/cache/hybrid-assignment-cache.spec.ts b/packages/browser/test/cache/hybrid-assignment-cache.spec.ts index 8d04708c..68a41c65 100644 --- a/packages/browser/test/cache/hybrid-assignment-cache.spec.ts +++ b/packages/browser/test/cache/hybrid-assignment-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 { IDBFactory } from 'fake-indexeddb' import type { ExposureEvent } from '../../../core/src/configuration/exposureEvent.types' import HybridAssignmentCache from '../../src/cache/hybrid-assignment-cache' diff --git a/packages/browser/test/cache/indexeddb-assignment-cache.spec.ts b/packages/browser/test/cache/indexeddb-assignment-cache.spec.ts index dc303cdf..feba6e97 100644 --- a/packages/browser/test/cache/indexeddb-assignment-cache.spec.ts +++ b/packages/browser/test/cache/indexeddb-assignment-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 { IDBFactory } from 'fake-indexeddb' import type { ExposureEvent } from '../../../core/src/configuration/exposureEvent.types' import { IndexedDBAssignmentCache } from '../../src/cache/indexeddb-assignment-cache' @@ -115,6 +110,19 @@ describe('IndexedDBAssignmentCache', () => { }) }) + 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) 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 27f59cf2..d3ab3012 100644 --- a/packages/browser/test/openfeature/exposures.spec.ts +++ b/packages/browser/test/openfeature/exposures.spec.ts @@ -1,10 +1,6 @@ -// 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 { 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' @@ -49,7 +45,6 @@ describe('Exposures End-to-End', () => { OpenFeature.clearHooks() // Reset IndexedDB to clear assignment cache between tests - const { IDBFactory } = require('fake-indexeddb') globalThis.indexedDB = new IDBFactory() // Mock current time to get deterministic timestamps 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' From 9ccf8627fba25519411ef0fd49711e8b2df3b73d Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Mon, 2 Mar 2026 12:30:21 -0500 Subject: [PATCH 3/6] Fix stale localStorage references in test comments --- packages/browser/test/openfeature/exposures.spec.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/browser/test/openfeature/exposures.spec.ts b/packages/browser/test/openfeature/exposures.spec.ts index d3ab3012..3ee10594 100644 --- a/packages/browser/test/openfeature/exposures.spec.ts +++ b/packages/browser/test/openfeature/exposures.spec.ts @@ -621,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() @@ -630,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) }) From 93073b9b40ec1e6f2073c4bbef981f9fc7635ef2 Mon Sep 17 00:00:00 2001 From: Sameeran Kunche Date: Thu, 8 Oct 2026 08:49:36 -0700 Subject: [PATCH 4/6] =?UTF-8?q?=F0=9F=90=9B=20fix(browser):=20preserve=20e?= =?UTF-8?q?xposure=20cache=20updates=20across=20instances?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/browser/README.md | 6 +- .../src/cache/indexeddb-assignment-cache.ts | 57 +++++++++++- .../cache/exposure-cache-capacity.spec.ts | 22 +++++ .../cache/indexeddb-assignment-cache.spec.ts | 91 +++++++++++++++++++ .../test/cache/indexeddb-test-helpers.ts | 15 ++- .../test/openfeature/exposures.spec.ts | 3 + .../test/openfeature/obfuscation.spec.ts | 22 +++-- 7 files changed, 197 insertions(+), 19 deletions(-) diff --git a/packages/browser/README.md b/packages/browser/README.md index 44d1e9d0..287dace8 100644 --- a/packages/browser/README.md +++ b/packages/browser/README.md @@ -311,8 +311,10 @@ Reapplying the same configuration preserves deduplication. Neither provider emit `createdAt` is a configuration timestamp, not an experiment revision. This behavior does not depend on it changing on every request. Exposure caches retain up to 50,000 entries per scope, matching the Node provider's limit. -Exposure checks use a bounded in-memory cache. IndexedDB stores snapshots of that cache in the background. -The least recently used entries are removed when the cache reaches its limit. Evicted entries can produce another exposure. +Exposure checks use a bounded in-memory cache. Background IndexedDB transactions merge newly recorded exposures +with the stored entries, so instances sharing a scope preserve each other's writes without replaying their loaded snapshots. +Memory removes the least recently used entries; persistence removes the oldest written entries when it reaches its limit. +Evicted entries can produce another exposure. If IndexedDB is unavailable or a storage operation fails, evaluation and in-memory deduplication continue. An abrupt page exit can lose an unfinished write and permit a repeat exposure after reload. diff --git a/packages/browser/src/cache/indexeddb-assignment-cache.ts b/packages/browser/src/cache/indexeddb-assignment-cache.ts index b1510fdf..1b3edff2 100644 --- a/packages/browser/src/cache/indexeddb-assignment-cache.ts +++ b/packages/browser/src/cache/indexeddb-assignment-cache.ts @@ -1,15 +1,16 @@ -import type { AssignmentCacheEntry } from '@datadog/flagging-core' +import { type AssignmentCacheEntry, assignmentCacheKeyToString } from '@datadog/flagging-core' import { MAX_EXPOSURE_CACHE_ENTRIES } from './constants' import { withStore } from './indexeddb-store' import SimpleAssignmentCache from './simple-assignment-cache' -/** Keep exposure checks synchronous. Persist bounded snapshots without blocking evaluation. */ +/** Keep exposure checks synchronous. Merge pending entries into storage without blocking evaluation. */ export class IndexedDBAssignmentCache extends SimpleAssignmentCache { private readonly storageKey: string private operations: Promise = Promise.resolve() private initialization?: Promise private persistScheduled = false private generation = 0 + private pendingKeys = new Set() constructor(storageKeySuffix: string) { super() @@ -37,12 +38,20 @@ export class IndexedDBAssignmentCache extends SimpleAssignmentCache { set(entry: AssignmentCacheEntry): void { void this.init() super.set(entry) + const key = assignmentCacheKeyToString(entry) + this.pendingKeys.delete(key) + this.pendingKeys.add(key) + // Keep the pending key index bounded even if storage is unavailable. + if (this.pendingKeys.size > MAX_EXPOSURE_CACHE_ENTRIES) { + this.pendingKeys.delete(this.pendingKeys.values().next().value!) + } this.persist() } clear(): Promise { super.clear() this.generation++ + this.pendingKeys.clear() // New entries need a write after the delete, even if an older write is queued. this.persistScheduled = false return this.enqueue(async () => { @@ -60,10 +69,52 @@ export class IndexedDBAssignmentCache extends SimpleAssignmentCache { private persist(): void { if (this.persistScheduled) return this.persistScheduled = true + const generation = this.generation void Promise.resolve().then(() => { void this.enqueue(async () => { + // A queued write from before clear must not consume post-clear entries. + if (generation !== this.generation) return this.persistScheduled = false - await withStore('readwrite', (store) => store.put(Array.from(this.entries()), this.storageKey)) + const pendingKeys = this.pendingKeys + this.pendingKeys = new Set() + const entries = Array.from(this.entries()).filter(([key]) => pendingKeys.has(key)) + try { + await withStore('readwrite', (store) => { + // IndexedDB serializes read/write transactions across connections and tabs. + // Merge only this batch's changes, never the instance's stale loaded snapshot. + const request = store.get(this.storageKey) + request.onsuccess = () => { + try { + const stored = new Map( + Array.isArray(request.result) ? request.result.filter(isEntry) : [] + ) + for (const [key, value] of entries) { + stored.delete(key) + stored.set(key, value) + } + for (const key of stored.keys()) { + if (stored.size <= MAX_EXPOSURE_CACHE_ENTRIES) break + stored.delete(key) + } + store.put(Array.from(stored), this.storageKey) + } catch { + // Exceptions in a request callback must abort the whole merge. + store.transaction.abort() + } + } + return request + }) + } catch { + if (generation === this.generation) { + // Retry failed entries on a later write, retaining only keys still in memory. + // Read their current values then, so retries cannot overwrite newer assignments. + const newerKeys = this.pendingKeys + this.pendingKeys = new Set() + for (const [key] of this.entries()) { + if (pendingKeys.has(key) || newerKeys.has(key)) this.pendingKeys.add(key) + } + } + } }) }) } diff --git a/packages/browser/test/cache/exposure-cache-capacity.spec.ts b/packages/browser/test/cache/exposure-cache-capacity.spec.ts index 3183bc0d..22901962 100644 --- a/packages/browser/test/cache/exposure-cache-capacity.spec.ts +++ b/packages/browser/test/cache/exposure-cache-capacity.spec.ts @@ -56,4 +56,26 @@ describe('exposure cache capacity', () => { expect(stored.has('key-1')).toBe(false) expect(cache.has(exposure('new'))).toBe(true) }) + + it("bounds the merged store without discarding another instance's recent writes", async () => { + await withStore('readwrite', (store) => store.put(entries(), 'assignments-scope')) + const first = new IndexedDBAssignmentCache('scope') + const second = new IndexedDBAssignmentCache('scope') + await Promise.all([first.init(), second.init()]) + const written = nextWrite(2) + first.set(exposure('first')) + second.set(exposure('second')) + await written + + const stored = new Map(await withStore('readonly', (store) => store.get('assignments-scope'))) + expect(stored.size).toBe(MAX_EXPOSURE_CACHE_ENTRIES) + expect(stored.has('key-0')).toBe(false) + expect(stored.has('key-1')).toBe(false) + expect(stored.get(assignmentCacheKeyToString(exposure('first')))).toBe( + assignmentCacheValueToString(exposure('first')) + ) + expect(stored.get(assignmentCacheKeyToString(exposure('second')))).toBe( + assignmentCacheValueToString(exposure('second')) + ) + }) }) diff --git a/packages/browser/test/cache/indexeddb-assignment-cache.spec.ts b/packages/browser/test/cache/indexeddb-assignment-cache.spec.ts index 0b99ca3e..68a197a6 100644 --- a/packages/browser/test/cache/indexeddb-assignment-cache.spec.ts +++ b/packages/browser/test/cache/indexeddb-assignment-cache.spec.ts @@ -42,6 +42,61 @@ describe('IndexedDBAssignmentCache', () => { expect(reloaded.has(b)).toBe(true) }) + it('preserves committed entries from another instance of the same scope', async () => { + const other = new IndexedDBAssignmentCache('scope') + await Promise.all([cache.init(), other.init()]) + let written = nextWrite() + cache.set(a) + await written + written = nextWrite() + other.set(b) + await written + + const reloaded = new IndexedDBAssignmentCache('scope') + await reloaded.init() + expect(reloaded.has(a)).toBe(true) + expect(reloaded.has(b)).toBe(true) + }) + + it('merges concurrent writes from separate instances atomically', async () => { + const other = new IndexedDBAssignmentCache('scope') + await Promise.all([cache.init(), other.init()]) + const written = nextWrite(2) + cache.set(a) + other.set(b) + await written + expect(new Map(await read())).toEqual(new Map([serialized(a), serialized(b)])) + }) + + it("does not restore cleared entries from another instance's loaded snapshot", async () => { + await seed([serialized(a)]) + const other = new IndexedDBAssignmentCache('scope') + await Promise.all([cache.init(), other.init()]) + await cache.clear() + const written = nextWrite() + other.set(b) + await written + + const reloaded = new IndexedDBAssignmentCache('scope') + await reloaded.init() + expect(reloaded.has(a)).toBe(false) + expect(reloaded.has(b)).toBe(true) + }) + + it("does not overwrite another instance's updated assignment with a loaded value", async () => { + await seed([serialized(a)]) + const other = new IndexedDBAssignmentCache('scope') + await Promise.all([cache.init(), other.init()]) + const updated = { ...a, variant: { key: 'treatment' } } + let written = nextWrite() + cache.set(updated) + await written + written = nextWrite() + other.set(b) + await written + expect(await read()).toEqual([serialized(updated), serialized(b)]) + }) + it('initializes once without replacing an updated assignment with persisted data', async () => { const loading = deferred() const started = deferred() @@ -176,6 +231,42 @@ describe('IndexedDBAssignmentCache', () => { expect(await read()).toEqual([serialized(a), serialized(b)]) }) + it('retries the latest assignment after a failed write without replaying loaded entries', async () => { + await seed([serialized(b)]) + await cache.init() + const attempted = deferred() + jest.spyOn(storage, 'withStore').mockImplementationOnce(async () => { + attempted.resolve() + throw new Error('write failed') + }) + cache.set(a) + await attempted.promise + const other = new IndexedDBAssignmentCache('scope') + await other.clear() + const updated = { ...a, variant: { key: 'treatment' } } + const written = nextWrite() + cache.set(updated) + await written + expect(await read()).toEqual([serialized(updated)]) + }) + + it('aborts and retries when put throws inside the merge callback', async () => { + await cache.init() + const aborted = deferred() + const put = jest.spyOn(IDBObjectStore.prototype, 'put').mockImplementationOnce(function (this: IDBObjectStore) { + this.transaction.addEventListener('abort', () => aborted.resolve()) + throw new Error('quota') + }) + cache.set(a) + await aborted.promise + put.mockRestore() + expect(cache.has(a)).toBe(true) + const written = nextWrite() + cache.set(b) + await written + expect(await read()).toEqual([serialized(a), serialized(b)]) + }) + it('settles a read transaction that aborts after its request succeeds', async () => { const get = IDBObjectStore.prototype.get jest.spyOn(IDBObjectStore.prototype, 'get').mockImplementationOnce(function (this: IDBObjectStore, key) { diff --git a/packages/browser/test/cache/indexeddb-test-helpers.ts b/packages/browser/test/cache/indexeddb-test-helpers.ts index 99d73a89..ac10058e 100644 --- a/packages/browser/test/cache/indexeddb-test-helpers.ts +++ b/packages/browser/test/cache/indexeddb-test-helpers.ts @@ -1,18 +1,25 @@ import { IDBObjectStore } from 'fake-indexeddb' /** Observe an actual commit instead of sleeping for an assumed write duration. */ -export function nextWrite(): Promise { +export function nextWrite(count = 1): Promise { const put = IDBObjectStore.prototype.put return new Promise((resolve, reject) => { + let started = 0 + let completed = 0 const spy = jest.spyOn(IDBObjectStore.prototype, 'put').mockImplementation(function ( this: IDBObjectStore, ...args: Parameters ) { const request = put.apply(this, args) if (!String(args[1]).startsWith('assignments-')) return request - spy.mockRestore() - request.transaction!.addEventListener('complete', () => resolve()) - request.transaction!.addEventListener('abort', () => reject(request.transaction!.error)) + if (++started === count) spy.mockRestore() + request.transaction!.addEventListener('complete', () => { + if (++completed === count) resolve() + }) + request.transaction!.addEventListener('abort', () => { + spy.mockRestore() + reject(request.transaction!.error) + }) return request }) }) diff --git a/packages/browser/test/openfeature/exposures.spec.ts b/packages/browser/test/openfeature/exposures.spec.ts index f1d94e1c..0f85a68a 100644 --- a/packages/browser/test/openfeature/exposures.spec.ts +++ b/packages/browser/test/openfeature/exposures.spec.ts @@ -5,6 +5,7 @@ import type { FlaggingInitConfiguration } from '../../src/domain/configuration' import { createDatadogExposureLoggingHook } from '../../src/openfeature/exposures' import { DatadogProvider } from '../../src/openfeature/provider' import type { DDRum } from '../../src/openfeature/rumIntegration' +import { nextWrite } from '../cache/indexeddb-test-helpers' import precomputedServerResponse from '../data/precomputed-v1.json' describe('Exposures End-to-End', () => { @@ -860,8 +861,10 @@ describe('Exposures End-to-End', () => { await OpenFeature.setProviderAndWait(provider1) const client1 = OpenFeature.getClient() + const written = nextWrite() client1.getStringValue('string-flag', 'default') triggerBatch() + await written // Verify first exposure was logged expect(getExposuresCalls()).toHaveLength(1) diff --git a/packages/browser/test/openfeature/obfuscation.spec.ts b/packages/browser/test/openfeature/obfuscation.spec.ts index 87df3d9f..b72c7960 100644 --- a/packages/browser/test/openfeature/obfuscation.spec.ts +++ b/packages/browser/test/openfeature/obfuscation.spec.ts @@ -12,7 +12,7 @@ import { DatadogCoreProvider, } from '../../src/rules-based' import { fetchPrecomputedConfiguration } from '../../src/transport/fetchConfiguration' -import { deferred } from '../cache/indexeddb-test-helpers' +import { deferred, nextWrite } from '../cache/indexeddb-test-helpers' const context = { targetingKey: 'athlete-123' } const key = 'new-route-planner' @@ -282,18 +282,20 @@ describe('browser flag-key obfuscation', () => { ) await install() - const evaluate = () => { + const evaluate = async (expectWrite = false) => { + const written = expectWrite ? nextWrite() : undefined expect(client.getBooleanValue(key, false)).toBe(true) jest.advanceTimersByTime(31_000) + await written } - evaluate() - evaluate() + await evaluate(true) + await evaluate() expect(exposureEvents()).toHaveLength(1) // createdAt may stay unchanged across requests. Reusing the same snapshot // must preserve deduplication, including a portable serialization round trip. await refresh() - evaluate() + await evaluate() expect(exposureEvents()).toHaveLength(1) // Timestamp changes permit another exposure with identical assignment IDs. @@ -303,8 +305,8 @@ describe('browser flag-key obfuscation', () => { await refresh() jest.advanceTimersByTime(31_000) expect(exposureEvents()).toHaveLength(index + 1) - evaluate() - evaluate() + await evaluate(true) + await evaluate() expect(exposureEvents()).toHaveLength(index + 2) } @@ -312,7 +314,7 @@ describe('browser flag-key obfuscation', () => { for (const [index, publicSalt] of ['f'.repeat(32), '', salt].entries()) { payload = response(publicSalt, true, `2026-09-30T00:0${index + 2}:00Z`) await refresh() - evaluate() + await evaluate(true) expect(exposureEvents()).toHaveLength(index + 4) } @@ -325,7 +327,7 @@ describe('browser flag-key obfuscation', () => { ].entries()) { Object.assign(assignment, change) await refresh() - evaluate() + await evaluate(true) expect(exposureEvents()).toHaveLength(index + 7) } expect(exposureEvents()[8]).toMatchObject({ @@ -340,7 +342,7 @@ describe('browser flag-key obfuscation', () => { await tracking?.shutdown() await OpenFeature.clearProviders() await install() - evaluate() + await evaluate() expect(exposureEvents()).toHaveLength(9) client.clearHooks() await tracking?.shutdown() From 9cd02365b8b754027ebd5b8d8d35e0074a75a3ed Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Thu, 8 Oct 2026 13:08:04 -0400 Subject: [PATCH 5/6] =?UTF-8?q?=F0=9F=90=9B=20fix(browser):=20reject=20exp?= =?UTF-8?q?osure=20writes=20invalidated=20by=20another=20tab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/browser/README.md | 4 + .../src/cache/indexeddb-assignment-cache.ts | 99 ++++++++++----- .../cache/exposure-cache-capacity.spec.ts | 4 +- .../browser/test/cache/exposure-cache.spec.ts | 5 + .../cache/indexeddb-assignment-cache.spec.ts | 116 ++++++++++++++++-- 5 files changed, 189 insertions(+), 39 deletions(-) diff --git a/packages/browser/README.md b/packages/browser/README.md index 287dace8..32c55774 100644 --- a/packages/browser/README.md +++ b/packages/browser/README.md @@ -315,6 +315,10 @@ Exposure checks use a bounded in-memory cache. Background IndexedDB transactions with the stored entries, so instances sharing a scope preserve each other's writes without replaying their loaded snapshots. Memory removes the least recently used entries; persistence removes the oldest written entries when it reaches its limit. Evicted entries can produce another exposure. +Clearing a scope stores an empty cache with an incremented invalidation counter. Other instances reject pending or retried +writes with an older counter. On their next write, they discard stale memory and pending entries, then load the current cache. +This can produce repeat exposures. Other tabs' memory caches are not updated immediately, and concurrent tabs can still log +the same exposure. The cache reduces duplicates; it does not guarantee exactly-once logging across tabs. If IndexedDB is unavailable or a storage operation fails, evaluation and in-memory deduplication continue. An abrupt page exit can lose an unfinished write and permit a repeat exposure after reload. diff --git a/packages/browser/src/cache/indexeddb-assignment-cache.ts b/packages/browser/src/cache/indexeddb-assignment-cache.ts index 1b3edff2..5a378e49 100644 --- a/packages/browser/src/cache/indexeddb-assignment-cache.ts +++ b/packages/browser/src/cache/indexeddb-assignment-cache.ts @@ -3,6 +3,11 @@ import { MAX_EXPOSURE_CACHE_ENTRIES } from './constants' import { withStore } from './indexeddb-store' import SimpleAssignmentCache from './simple-assignment-cache' +interface StoredExposures { + generation: number + entries: [string, string][] +} + /** Keep exposure checks synchronous. Merge pending entries into storage without blocking evaluation. */ export class IndexedDBAssignmentCache extends SimpleAssignmentCache { private readonly storageKey: string @@ -10,6 +15,7 @@ export class IndexedDBAssignmentCache extends SimpleAssignmentCache { private initialization?: Promise private persistScheduled = false private generation = 0 + private storedGeneration = 0 private pendingKeys = new Set() constructor(storageKeySuffix: string) { @@ -21,15 +27,16 @@ export class IndexedDBAssignmentCache extends SimpleAssignmentCache { if (!this.initialization) { const generation = this.generation this.initialization = this.enqueue(async () => { - const stored: unknown = await withStore('readonly', (store) => store.get(this.storageKey)) + const stored = readStored(await withStore('readonly', (store) => store.get(this.storageKey))) // A clear during loading must not restore the previous configuration's exposures. - if (generation !== this.generation || !Array.isArray(stored)) return + if (generation !== this.generation) return + this.storedGeneration = stored.generation const current = Array.from(this.entries()) super.clear() - this.setEntries(stored.filter(isEntry)) + this.setEntries(stored.entries) // Evaluations during loading take precedence over older persisted values. this.setEntries(current) - if (stored.length > MAX_EXPOSURE_CACHE_ENTRIES) this.persist() + if (stored.entries.length > MAX_EXPOSURE_CACHE_ENTRIES) this.persist() }) } return this.initialization @@ -52,10 +59,12 @@ export class IndexedDBAssignmentCache extends SimpleAssignmentCache { super.clear() this.generation++ this.pendingKeys.clear() - // New entries need a write after the delete, even if an older write is queued. + // New entries need a write after the clear, even if an older write is queued. this.persistScheduled = false return this.enqueue(async () => { - await withStore('readwrite', (store) => store.delete(this.storageKey)) + // Keep a counter so other instances can reject their pre-clear writes. + const stored = await this.update((current) => ({ generation: current.generation + 1, entries: [] })) + this.storedGeneration = stored.generation }) } @@ -78,32 +87,32 @@ export class IndexedDBAssignmentCache extends SimpleAssignmentCache { const pendingKeys = this.pendingKeys this.pendingKeys = new Set() const entries = Array.from(this.entries()).filter(([key]) => pendingKeys.has(key)) + const storedGeneration = this.storedGeneration try { - await withStore('readwrite', (store) => { - // IndexedDB serializes read/write transactions across connections and tabs. - // Merge only this batch's changes, never the instance's stale loaded snapshot. - const request = store.get(this.storageKey) - request.onsuccess = () => { - try { - const stored = new Map( - Array.isArray(request.result) ? request.result.filter(isEntry) : [] - ) - for (const [key, value] of entries) { - stored.delete(key) - stored.set(key, value) - } - for (const key of stored.keys()) { - if (stored.size <= MAX_EXPOSURE_CACHE_ENTRIES) break - stored.delete(key) - } - store.put(Array.from(stored), this.storageKey) - } catch { - // Exceptions in a request callback must abort the whole merge. - store.transaction.abort() - } + const stored = await this.update((current) => { + if (current.generation !== storedGeneration) return current + // Merge only this batch's changes, never the instance's loaded snapshot. + const merged = new Map(current.entries) + for (const [key, value] of entries) { + merged.delete(key) + merged.set(key, value) + } + for (const key of merged.keys()) { + if (merged.size <= MAX_EXPOSURE_CACHE_ENTRIES) break + merged.delete(key) } - return request + return { generation: storedGeneration, entries: Array.from(merged) } }) + if (generation === this.generation && stored.generation !== storedGeneration) { + // Another instance cleared the scope. Drop this batch and any queued old writes. + // This can repeat an exposure, but cannot restore a cleared deduplication entry. + this.generation++ + this.storedGeneration = stored.generation + this.persistScheduled = false + this.pendingKeys.clear() + super.clear() + this.setEntries(stored.entries) + } } catch { if (generation === this.generation) { // Retry failed entries on a later write, retaining only keys still in memory. @@ -118,6 +127,38 @@ export class IndexedDBAssignmentCache extends SimpleAssignmentCache { }) }) } + + private async update(change: (current: StoredExposures) => StoredExposures): Promise { + let updated!: StoredExposures + await withStore('readwrite', (store) => { + // IndexedDB serializes these read/write transactions across connections and tabs. + const request = store.get(this.storageKey) + request.onsuccess = () => { + try { + updated = change(readStored(request.result)) + store.put(updated, this.storageKey) + } catch { + store.transaction.abort() + } + } + return request + }) + return updated + } +} + +function readStored(value: unknown): StoredExposures { + // Accept snapshots written by earlier builds without an invalidation counter. + if (Array.isArray(value)) return { generation: 0, entries: value.filter(isEntry) } + if ( + typeof value === 'object' && value !== null && + 'generation' in value && typeof value.generation === 'number' && + Number.isSafeInteger(value.generation) && value.generation >= 0 && + 'entries' in value && Array.isArray(value.entries) + ) { + return { generation: value.generation, entries: value.entries.filter(isEntry) } + } + return { generation: 0, entries: [] } } function isEntry(value: unknown): value is [string, string] { diff --git a/packages/browser/test/cache/exposure-cache-capacity.spec.ts b/packages/browser/test/cache/exposure-cache-capacity.spec.ts index 22901962..f548b75b 100644 --- a/packages/browser/test/cache/exposure-cache-capacity.spec.ts +++ b/packages/browser/test/cache/exposure-cache-capacity.spec.ts @@ -51,7 +51,7 @@ describe('exposure cache capacity', () => { const written = nextWrite() cache.set(exposure('new')) await written - const stored = new Map(await withStore('readonly', (store) => store.get('assignments-scope'))) + const stored = new Map((await withStore('readonly', (store) => store.get('assignments-scope'))).entries) expect(stored.size).toBe(MAX_EXPOSURE_CACHE_ENTRIES) expect(stored.has('key-1')).toBe(false) expect(cache.has(exposure('new'))).toBe(true) @@ -67,7 +67,7 @@ describe('exposure cache capacity', () => { second.set(exposure('second')) await written - const stored = new Map(await withStore('readonly', (store) => store.get('assignments-scope'))) + const stored = new Map((await withStore('readonly', (store) => store.get('assignments-scope'))).entries) expect(stored.size).toBe(MAX_EXPOSURE_CACHE_ENTRIES) expect(stored.has('key-0')).toBe(false) expect(stored.has('key-1')).toBe(false) diff --git a/packages/browser/test/cache/exposure-cache.spec.ts b/packages/browser/test/cache/exposure-cache.spec.ts index 2b375c0a..805bda1d 100644 --- a/packages/browser/test/cache/exposure-cache.spec.ts +++ b/packages/browser/test/cache/exposure-cache.spec.ts @@ -29,7 +29,12 @@ describe('exposure cache storage boundaries', () => { await Promise.all([first.init(), second.init()]) expect(first.has(exposure)).toBe(false) expect(second.has(exposure)).toBe(true) + expect(await withStore('readonly', (store) => store.get('assignments-scope-a'))).toEqual({ + generation: 1, + entries: [], + }) expect(await withStore('readonly', (store) => store.getAllKeys())).toEqual([ + 'assignments-scope-a', 'assignments-scope-b', 'v2-flags-config-other', ]) diff --git a/packages/browser/test/cache/indexeddb-assignment-cache.spec.ts b/packages/browser/test/cache/indexeddb-assignment-cache.spec.ts index 68a197a6..4b5a955e 100644 --- a/packages/browser/test/cache/indexeddb-assignment-cache.spec.ts +++ b/packages/browser/test/cache/indexeddb-assignment-cache.spec.ts @@ -16,7 +16,7 @@ const serialized = (entry: ExposureEvent): [string, string] => [ assignmentCacheKeyToString(entry), assignmentCacheValueToString(entry), ] -const read = () => storage.withStore('readonly', (store) => store.get('assignments-scope')) +const read = async () => (await storage.withStore('readonly', (store) => store.get('assignments-scope')))?.entries const seed = (value: unknown) => storage.withStore('readwrite', (store) => store.put(value, 'assignments-scope')) describe('IndexedDBAssignmentCache', () => { @@ -80,7 +80,10 @@ describe('IndexedDBAssignmentCache', () => { const reloaded = new IndexedDBAssignmentCache('scope') await reloaded.init() expect(reloaded.has(a)).toBe(false) - expect(reloaded.has(b)).toBe(true) + // The first stale batch is discarded, including new entries recorded before it observes the clear. + expect(reloaded.has(b)).toBe(false) + expect(other.has(a)).toBe(false) + expect(other.has(b)).toBe(false) }) it("does not overwrite another instance's updated assignment with a loaded value", async () => { @@ -97,6 +100,70 @@ describe('IndexedDBAssignmentCache', () => { expect(await read()).toEqual([serialized(updated), serialized(b)]) }) + it('rejects a pending write from before another instance clears the scope', async () => { + const other = new IndexedDBAssignmentCache('scope') + await Promise.all([cache.init(), other.init()]) + const started = deferred() + const resume = deferred() + const transact = storage.withStore + jest.spyOn(storage, 'withStore').mockImplementationOnce(async (mode, operation) => { + started.resolve() + await resume.promise + return transact(mode, operation) + }) + other.set(a) + await started.promise + await cache.clear() + + const written = nextWrite() + resume.resolve() + await written + const reloaded = new IndexedDBAssignmentCache('scope') + await reloaded.init() + expect(reloaded.has(a)).toBe(false) + expect(other.has(a)).toBe(false) + + // Once the instance observes the clear, new exposures can be persisted again. + const next = nextWrite() + other.set(b) + await next + const latest = new IndexedDBAssignmentCache('scope') + await latest.init() + expect(latest.has(a)).toBe(false) + expect(latest.has(b)).toBe(true) + }) + + it('rejects a failed write retried after another instance clears the scope', async () => { + const other = new IndexedDBAssignmentCache('scope') + await Promise.all([cache.init(), other.init()]) + const attempted = deferred() + jest.spyOn(storage, 'withStore').mockImplementationOnce(async () => { + attempted.resolve() + throw new Error('write failed') + }) + other.set(a) + await attempted.promise + await cache.clear() + + const written = nextWrite() + other.set(b) + await written + const reloaded = new IndexedDBAssignmentCache('scope') + await reloaded.init() + expect(reloaded.has(a)).toBe(false) + expect(other.has(a)).toBe(false) + }) + + it('increments the stored counter for concurrent clears', async () => { + const other = new IndexedDBAssignmentCache('scope') + await Promise.all([cache.init(), other.init()]) + await Promise.all([cache.clear(), other.clear()]) + expect(await storage.withStore('readonly', (store) => store.get('assignments-scope'))).toEqual({ + generation: 2, + entries: [], + }) + }) + it('initializes once without replacing an updated assignment with persisted data', async () => { const loading = deferred() const started = deferred() @@ -142,7 +209,7 @@ describe('IndexedDBAssignmentCache', () => { loading.resolve([serialized(a)]) await Promise.all([initialized, cleared]) expect(cache.has(a)).toBe(false) - expect(await read()).toBeUndefined() + expect(await read()).toEqual([]) }) it('clears after an in-flight write and persists subsequent writes', async () => { @@ -161,7 +228,7 @@ describe('IndexedDBAssignmentCache', () => { expect(cache.has(a)).toBe(false) writing.resolve() await cleared - expect(await read()).toBeUndefined() + expect(await read()).toEqual([]) const written = nextWrite() cache.set(b) await written @@ -170,7 +237,7 @@ describe('IndexedDBAssignmentCache', () => { it('keeps a write made immediately after clear', async () => { await cache.init() - const written = nextWrite() + const written = nextWrite(2) cache.set(a) const cleared = cache.clear() cache.set(b) @@ -185,7 +252,7 @@ describe('IndexedDBAssignmentCache', () => { jest.spyOn(storage, 'withStore').mockImplementationOnce(() => loading.promise as Promise) const initialized = cache.init() cache.set(a) - // Let persistence queue behind the pending read, before clear queues its delete. + // Let persistence queue behind the pending read, before clear queues its transaction. await Promise.resolve() const cleared = cache.clear() cache.set(b) @@ -231,7 +298,7 @@ describe('IndexedDBAssignmentCache', () => { expect(await read()).toEqual([serialized(a), serialized(b)]) }) - it('retries the latest assignment after a failed write without replaying loaded entries', async () => { + it('discards a stale retry and persists the latest assignment after observing the clear', async () => { await seed([serialized(b)]) await cache.init() const attempted = deferred() @@ -244,8 +311,32 @@ describe('IndexedDBAssignmentCache', () => { const other = new IndexedDBAssignmentCache('scope') await other.clear() const updated = { ...a, variant: { key: 'treatment' } } + let written = nextWrite() + cache.set(updated) + await written + expect(await read()).toEqual([]) + expect(cache.has(updated)).toBe(false) + written = nextWrite() + cache.set(updated) + await written + expect(await read()).toEqual([serialized(updated)]) + }) + + it('retries the latest assignment when the scope was not cleared', async () => { + await cache.init() + const attempted = deferred() + const resume = deferred() + jest.spyOn(storage, 'withStore').mockImplementationOnce(async () => { + attempted.resolve() + await resume.promise + throw new Error('write failed') + }) + cache.set(a) + await attempted.promise + const updated = { ...a, variant: { key: 'treatment' } } const written = nextWrite() cache.set(updated) + resume.resolve() await written expect(await read()).toEqual([serialized(updated)]) }) @@ -292,7 +383,16 @@ describe('IndexedDBAssignmentCache', () => { expect(close).toHaveBeenCalledTimes(1) }) - it.each([null, 'invalid', { invalid: true }, [null, [], ['key', 12], ['', 'value']]])( + it.each([ + null, + 'invalid', + { invalid: true }, + [null, [], ['key', 12], ['', 'value']], + { generation: -1, entries: [serialized(a)] }, + { generation: '1', entries: [serialized(a)] }, + { generation: 1, entries: null }, + { generation: 1, entries: [null, ['key', 12]] }, + ])( 'ignores malformed persisted data: %j', async (value) => { await seed(value) From 76a7a52ea899c64109b4598ad4fe301a302fae87 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Thu, 8 Oct 2026 13:11:07 -0400 Subject: [PATCH 6/6] =?UTF-8?q?=F0=9F=8E=A8=20style(browser):=20format=20e?= =?UTF-8?q?xposure=20cache=20invalidation=20changes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../browser/src/cache/indexeddb-assignment-cache.ts | 12 ++++++++---- .../test/cache/indexeddb-assignment-cache.spec.ts | 13 +++++-------- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/packages/browser/src/cache/indexeddb-assignment-cache.ts b/packages/browser/src/cache/indexeddb-assignment-cache.ts index 5a378e49..74d2927e 100644 --- a/packages/browser/src/cache/indexeddb-assignment-cache.ts +++ b/packages/browser/src/cache/indexeddb-assignment-cache.ts @@ -151,10 +151,14 @@ function readStored(value: unknown): StoredExposures { // Accept snapshots written by earlier builds without an invalidation counter. if (Array.isArray(value)) return { generation: 0, entries: value.filter(isEntry) } if ( - typeof value === 'object' && value !== null && - 'generation' in value && typeof value.generation === 'number' && - Number.isSafeInteger(value.generation) && value.generation >= 0 && - 'entries' in value && Array.isArray(value.entries) + typeof value === 'object' && + value !== null && + 'generation' in value && + typeof value.generation === 'number' && + Number.isSafeInteger(value.generation) && + value.generation >= 0 && + 'entries' in value && + Array.isArray(value.entries) ) { return { generation: value.generation, entries: value.entries.filter(isEntry) } } diff --git a/packages/browser/test/cache/indexeddb-assignment-cache.spec.ts b/packages/browser/test/cache/indexeddb-assignment-cache.spec.ts index 4b5a955e..439170f2 100644 --- a/packages/browser/test/cache/indexeddb-assignment-cache.spec.ts +++ b/packages/browser/test/cache/indexeddb-assignment-cache.spec.ts @@ -392,12 +392,9 @@ describe('IndexedDBAssignmentCache', () => { { generation: '1', entries: [serialized(a)] }, { generation: 1, entries: null }, { generation: 1, entries: [null, ['key', 12]] }, - ])( - 'ignores malformed persisted data: %j', - async (value) => { - await seed(value) - await expect(cache.init()).resolves.toBeUndefined() - expect(await cache.getEntries()).toEqual([]) - } - ) + ])('ignores malformed persisted data: %j', async (value) => { + await seed(value) + await expect(cache.init()).resolves.toBeUndefined() + expect(await cache.getEntries()).toEqual([]) + }) })