diff --git a/packages/browser/README.md b/packages/browser/README.md index 4aa285c3..32c55774 100644 --- a/packages/browser/README.md +++ b/packages/browser/README.md @@ -311,12 +311,20 @@ 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. -The memory cache removes the least recently used entry. Persistent caches remove the oldest written entries. -An evicted entry can produce another exposure. Storage failures do not prevent flag evaluation. +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. +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. For rules-based configurations, `DatadogCoreProvider` also includes the rules configuration identity. Changed rules allow new exposures without clearing application-managed hook state. Retrieval metadata (`fetchedAt` and `etag`) and the server's `createdAt` build timestamp do not change that identity. These fields remain available on the configuration and in its portable wire representation. -Both the standalone exposure hook and `DatadogProvider` scope persistent exposure caches by telemetry site, client token, proxy URL, environment, application, service, and source. Scope values are hashed into the storage namespace; raw tokens are not stored in cache keys. Recreating a hook with the same scope retains deduplication, while another destination can emit its own exposures. Older unscoped cache entries are not reused, so upgrading can produce a one-time repeat exposure. Function-valued telemetry proxies use memory-only deduplication because their destination cannot be inferred reliably from the callback's identity. +Both the standalone exposure hook and `DatadogProvider` scope persistent exposure caches by telemetry site, client token, proxy URL, environment, application, service, and source. Scope values are hashed into the storage namespace; raw tokens are not stored in cache keys. Recreating a hook with the same scope retains deduplication, while another destination can emit its own exposures. Existing localStorage and Chrome storage entries are not migrated or deleted, so upgrading can produce repeat exposures. Function-valued telemetry proxies use memory-only deduplication because their destination cannot be inferred reliably from the callback's identity. To exclude one of these integrations, omit that hook factory from both the import list and `composeDatadogTrackingHooks()` call. diff --git a/packages/browser/jest.config.js b/packages/browser/jest.config.js index f26d9190..504ed0f7 100644 --- a/packages/browser/jest.config.js +++ b/packages/browser/jest.config.js @@ -1,7 +1,7 @@ module.exports = { preset: 'ts-jest', testEnvironment: 'jsdom', - setupFilesAfterEnv: ['/src/test-setup.ts'], + setupFilesAfterEnv: ['/test/setup-indexeddb.ts', '/src/test-setup.ts'], transform: { '^.+\\.ts$': [ 'ts-jest', diff --git a/packages/browser/src/cache/assignment-cache-factory.ts b/packages/browser/src/cache/assignment-cache-factory.ts index 6e78d25d..97e1d738 100644 --- a/packages/browser/src/cache/assignment-cache-factory.ts +++ b/packages/browser/src/cache/assignment-cache-factory.ts @@ -1,34 +1,16 @@ import type { AssignmentCache } from '@datadog/flagging-core' -import ChromeStorageAssignmentCache from './chrome-storage-assignment-cache' -import { hasWindowLocalStorage } from './helpers' -import HybridAssignmentCache from './hybrid-assignment-cache' -import { LocalStorageAssignmentCache } from './local-storage-assignment-cache' +import { hasIndexedDB } from './helpers' +import { IndexedDBAssignmentCache } from './indexeddb-assignment-cache' import SimpleAssignmentCache from './simple-assignment-cache' export function assignmentCacheFactory({ forceMemoryOnly = false, - chromeStorage, storageKeySuffix, }: { forceMemoryOnly?: boolean storageKeySuffix: string - chromeStorage?: chrome.storage.StorageArea }): AssignmentCache { - const simpleCache = new SimpleAssignmentCache() - - if (forceMemoryOnly) { - return simpleCache - } - - if (chromeStorage) { - const chromeStorageCache = new ChromeStorageAssignmentCache(chromeStorage, storageKeySuffix) - return new HybridAssignmentCache(simpleCache, chromeStorageCache) - } else { - if (hasWindowLocalStorage()) { - const localStorageCache = new LocalStorageAssignmentCache(storageKeySuffix) - return new HybridAssignmentCache(simpleCache, localStorageCache) - } else { - return simpleCache - } - } + return !forceMemoryOnly && hasIndexedDB() + ? new IndexedDBAssignmentCache(storageKeySuffix) + : new SimpleAssignmentCache() } 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 aea7f88c..00000000 --- a/packages/browser/src/cache/chrome-storage-assignment-cache.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { addTelemetryDebug } from '@datadog/browser-core' -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, storageKeySuffix: string) { - this.storage = new ChromeStorageAsyncMap(chromeStorage, `datadog-assignment-${storageKeySuffix}`) - } - - 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)).catch(() => { - addTelemetryDebug('Failed to persist an exposure cache 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 4c952e12..00000000 --- a/packages/browser/src/cache/chrome-storage-async-map.ts +++ /dev/null @@ -1,90 +0,0 @@ -import type { AsyncMap } from '@datadog/flagging-core' -import { MAX_EXPOSURE_CACHE_ENTRIES } from './constants' - -/** Chrome storage-backed {@link AsyncMap}. */ -export default class ChromeStorageAsyncMap implements AsyncMap { - private readonly prefix: string - private keys: Set | undefined - private pendingOperation = Promise.resolve() - - constructor( - private readonly storage: chrome.storage.StorageArea, - namespace: string - ) { - this.prefix = `${namespace}:` - } - - async has(key: string): Promise { - const value = await this.get(key) - return !!value - } - - async get(key: string): Promise { - const storageKey = this.prefix + key - const subset = await this.storage.get>(storageKey) - return subset?.[storageKey] ?? undefined - } - - async entries(): Promise<{ [p: string]: T }> { - return this.run(async () => { - const entries = await this.readEntries() - this.keys = new Set(Object.keys(entries)) - await this.trim() - for (const key of Object.keys(entries)) { - if (!this.keys.has(key)) delete entries[key] - } - return entries - }) - } - - private async readEntries(): Promise> { - const entries = await this.storage.get>(null) - const scopedEntries: Record = Object.create(null) - for (const [key, value] of Object.entries(entries)) { - if (key.startsWith(this.prefix)) scopedEntries[key.slice(this.prefix.length)] = value - } - return scopedEntries - } - - async set(key: string, value: T) { - return this.run(async () => { - this.keys ??= new Set(Object.keys(await this.readEntries())) - this.keys.delete(key) - this.keys.add(key) - // Evict before writing so a full store has space for the new entry. - await this.trim() - await this.storage.set({ [this.prefix + key]: value }) - }) - } - - async clear() { - return this.run(async () => { - const keys = Object.keys(await this.readEntries()).map((key) => this.prefix + key) - if (keys.length) await this.storage.remove(keys) - this.keys = new Set() - }) - } - - private async trim(): Promise { - const keys = this.keys! - const removed: string[] = [] - for (const key of keys) { - if (keys.size - removed.length <= MAX_EXPOSURE_CACHE_ENTRIES) break - removed.push(key) - } - if (removed.length) { - await this.storage.remove(removed.map((key) => this.prefix + key)) - for (const key of removed) keys.delete(key) - } - } - - private run(operation: () => Promise): Promise { - // Serialize writes and eviction. A failed operation must not stop later writes. - const result = this.pendingOperation.then(operation) - this.pendingOperation = result.then( - () => undefined, - () => undefined - ) - return result - } -} diff --git a/packages/browser/src/cache/exposure-cache.ts b/packages/browser/src/cache/exposure-cache.ts index 7b674fc5..19a078f1 100644 --- a/packages/browser/src/cache/exposure-cache.ts +++ b/packages/browser/src/cache/exposure-cache.ts @@ -1,7 +1,6 @@ import { getMD5Hash } from '@datadog/flagging-core' import type { FlaggingTrackingConfiguration, FlaggingTrackingInitConfiguration } from '../domain/configuration' import { assignmentCacheFactory } from './assignment-cache-factory' -import { chromeStorageIfAvailable } from './helpers' export function createExposureCache( options: Partial, @@ -22,7 +21,6 @@ export function createExposureCache( ) return assignmentCacheFactory({ forceMemoryOnly, - chromeStorage: chromeStorageIfAvailable(), storageKeySuffix: `dd-of-browser-${scope}`, }) } 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/hybrid-assignment-cache.ts b/packages/browser/src/cache/hybrid-assignment-cache.ts deleted file mode 100644 index 9f067e2b..00000000 --- a/packages/browser/src/cache/hybrid-assignment-cache.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { AssignmentCache, AssignmentCacheEntry } from '@datadog/flagging-core' - -/** An {@link AssignmentCache} that can write (set) multiple entries at once (in bulk). */ -export type BulkWriteAssignmentCache = AssignmentCache & { - /** Sets all entries in the cache to the provided array of [key, value] pairs. */ - setEntries(entries: [string, string][]): void -} - -/** An {@link AssignmentCache} that can read (get) all entries at once. */ -export type BulkReadAssignmentCache = AssignmentCache & { - /** Returns all entries in the cache as an array of [key, value] pairs. */ - getEntries(): Promise<[string, string][]> -} - -/** - * An {@link AssignmentCache} implementation that, upon `init`, reads from a persistent and async {@link BulkReadAssignmentCache} - * and writes to a synchronous {@link BulkWriteAssignmentCache} for serving the cache. - * */ -export default class HybridAssignmentCache implements AssignmentCache { - constructor( - private readonly servingStore: BulkWriteAssignmentCache, - private readonly persistentStore: BulkReadAssignmentCache - ) {} - - async init(): Promise { - const entries = await this.persistentStore.getEntries() - if (entries) { - this.servingStore.setEntries(entries) - } - } - - set(key: AssignmentCacheEntry): void { - this.servingStore.set(key) - this.persistentStore.set(key) - } - - has(key: AssignmentCacheEntry): boolean { - return this.servingStore.has(key) - } - - async clear(): Promise { - await Promise.all([this.servingStore.clear(), this.persistentStore.clear()]) - } -} 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..74d2927e --- /dev/null +++ b/packages/browser/src/cache/indexeddb-assignment-cache.ts @@ -0,0 +1,172 @@ +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' + +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 + private operations: Promise = Promise.resolve() + private initialization?: Promise + private persistScheduled = false + private generation = 0 + private storedGeneration = 0 + private pendingKeys = new Set() + + constructor(storageKeySuffix: string) { + super() + this.storageKey = `assignments-${storageKeySuffix}` + } + + init(): Promise { + if (!this.initialization) { + const generation = this.generation + this.initialization = this.enqueue(async () => { + 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) return + this.storedGeneration = stored.generation + const current = Array.from(this.entries()) + super.clear() + this.setEntries(stored.entries) + // Evaluations during loading take precedence over older persisted values. + this.setEntries(current) + if (stored.entries.length > MAX_EXPOSURE_CACHE_ENTRIES) this.persist() + }) + } + return this.initialization + } + + 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 clear, even if an older write is queued. + this.persistScheduled = false + return this.enqueue(async () => { + // 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 + }) + } + + private enqueue(operation: () => Promise): Promise { + this.operations = this.operations.then(operation).catch(() => { + // Storage failures must not stop evaluation or later persistence attempts. + }) + return this.operations + } + + 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 + const pendingKeys = this.pendingKeys + this.pendingKeys = new Set() + const entries = Array.from(this.entries()).filter(([key]) => pendingKeys.has(key)) + const storedGeneration = this.storedGeneration + try { + 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 { 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. + // 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) + } + } + } + }) + }) + } + + 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] { + return ( + Array.isArray(value) && value.length === 2 && value.every((part) => typeof part === 'string') && value[0] !== '' + ) +} diff --git a/packages/browser/src/cache/indexeddb-flags-cache.ts b/packages/browser/src/cache/indexeddb-flags-cache.ts index a7c1ec06..f2148601 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..ee40ced5 --- /dev/null +++ b/packages/browser/src/cache/indexeddb-store.ts @@ -0,0 +1,36 @@ +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) + }) +} + +/** Resolve only after the transaction commits, including reads. Always close the connection. */ +export async function withStore( + mode: IDBTransactionMode, + operation: (store: IDBObjectStore) => IDBRequest +): Promise { + const db = await openDB() + try { + return await new Promise((resolve, reject) => { + const transaction = db.transaction(STORE_NAME, mode) + const request = operation(transaction.objectStore(STORE_NAME)) + transaction.oncomplete = () => resolve(request.result) + transaction.onerror = () => reject(transaction.error) + transaction.onabort = () => reject(transaction.error) + }) + } finally { + db.close() + } +} 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 70c9e8e6..00000000 --- a/packages/browser/src/cache/local-storage-assignment-shim.ts +++ /dev/null @@ -1,82 +0,0 @@ -// noinspection JSUnusedGlobalSymbols (methods are used by common repository) - -import { MAX_EXPOSURE_CACHE_ENTRIES } from './constants' -import { hasWindowLocalStorage } from './helpers' - -export class LocalStorageAssignmentShim { - private readonly localStorageKey: string - - public constructor(storageKeySuffix: string) { - if (!hasWindowLocalStorage()) { - throw new Error('LocalStorage is not available') - } - const keySuffix = storageKeySuffix ? `-${storageKeySuffix}` : '' - this.localStorageKey = `datadog-assignment${keySuffix}` - } - - clear(): void { - window.localStorage.removeItem(this.localStorageKey) - } - - 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) - } - - entries(): IterableIterator<[string, string]> { - return this.getCache().entries() - } - - keys(): IterableIterator { - return this.getCache().keys() - } - - values(): IterableIterator { - return this.getCache().values() - } - - 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 { - const cache = this.getCache() - cache.delete(key) - cache.set(key, value) - this.trim(cache) - return this.setCache(cache) - } - - private getCache(): Map { - const cache = window.localStorage.getItem(this.localStorageKey) - const entries: Map = cache ? new Map(JSON.parse(cache)) : new Map() - if (entries.size > MAX_EXPOSURE_CACHE_ENTRIES) { - this.trim(entries) - this.setCache(entries) - } - return entries - } - - private trim(cache: Map): void { - for (const key of cache.keys()) { - if (cache.size <= MAX_EXPOSURE_CACHE_ENTRIES) break - cache.delete(key) - } - } - - private setCache(cache: Map): this { - window.localStorage.setItem(this.localStorageKey, JSON.stringify(Array.from(cache.entries()))) - return this - } -} diff --git a/packages/browser/src/cache/simple-assignment-cache.ts b/packages/browser/src/cache/simple-assignment-cache.ts index bfcb0f9d..614fe083 100644 --- a/packages/browser/src/cache/simple-assignment-cache.ts +++ b/packages/browser/src/cache/simple-assignment-cache.ts @@ -1,13 +1,9 @@ import { LRUInMemoryAssignmentCache } from '@datadog/flagging-core' import { MAX_EXPOSURE_CACHE_ENTRIES } from './constants' -import type { BulkReadAssignmentCache, BulkWriteAssignmentCache } from './hybrid-assignment-cache' /** A bounded in-memory exposure cache, also used to serve persisted entries. */ -export default class SimpleAssignmentCache - extends LRUInMemoryAssignmentCache - implements BulkWriteAssignmentCache, BulkReadAssignmentCache -{ +export default class SimpleAssignmentCache extends LRUInMemoryAssignmentCache { constructor() { super(MAX_EXPOSURE_CACHE_ENTRIES) } diff --git a/packages/browser/test/cache/assignment-cache-factory.spec.ts b/packages/browser/test/cache/assignment-cache-factory.spec.ts index 8e5e54c0..f0ef1ce2 100644 --- a/packages/browser/test/cache/assignment-cache-factory.spec.ts +++ b/packages/browser/test/cache/assignment-cache-factory.spec.ts @@ -1,77 +1,28 @@ -/** - * @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 - -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 +import { IndexedDBAssignmentCache } from '../../src/cache/indexeddb-assignment-cache' +import SimpleAssignmentCache from '../../src/cache/simple-assignment-cache' - 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] - }) +describe('assignmentCacheFactory', () => { + it('uses IndexedDB when available', () => { + expect(assignmentCacheFactory({ storageKeySuffix: 'scope' })).toBeInstanceOf(IndexedDBAssignmentCache) }) - it('should create a hybrid cache if chrome storage is available', async () => { - const cache = assignmentCacheFactory({ - chromeStorage: mockChromeStorage, - storageKeySuffix: 'foo', - }) - 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('uses memory when IndexedDB is unavailable', () => { + const original = globalThis.indexedDB + // @ts-expect-error Simulate unavailable browser storage. + delete globalThis.indexedDB + try { + const cache = assignmentCacheFactory({ storageKeySuffix: 'scope' }) + expect(cache).toBeInstanceOf(SimpleAssignmentCache) + expect(cache).not.toBeInstanceOf(IndexedDBAssignmentCache) + } finally { + globalThis.indexedDB = original } - cache.set(exposureEvent) - await cache.init() - expect(Object.keys(fakeStore)).toHaveLength(1) }) - it('should create a hybrid cache if local storage is available', () => { - const cache = assignmentCacheFactory({ - storageKeySuffix: 'foo', - }) - 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) + it('uses memory when explicitly requested', () => { + const cache = assignmentCacheFactory({ storageKeySuffix: 'scope', forceMemoryOnly: true }) + expect(cache).toBeInstanceOf(SimpleAssignmentCache) + expect(cache).not.toBeInstanceOf(IndexedDBAssignmentCache) }) }) diff --git a/packages/browser/test/cache/exposure-cache-capacity.spec.ts b/packages/browser/test/cache/exposure-cache-capacity.spec.ts index 756e77c0..f548b75b 100644 --- a/packages/browser/test/cache/exposure-cache-capacity.spec.ts +++ b/packages/browser/test/cache/exposure-cache-capacity.spec.ts @@ -1,8 +1,9 @@ import { assignmentCacheKeyToString, assignmentCacheValueToString, type ExposureEvent } from '@datadog/flagging-core' -import ChromeStorageAsyncMap from '../../src/cache/chrome-storage-async-map' import { MAX_EXPOSURE_CACHE_ENTRIES } from '../../src/cache/constants' -import { LocalStorageAssignmentShim } from '../../src/cache/local-storage-assignment-shim' +import { IndexedDBAssignmentCache } from '../../src/cache/indexeddb-assignment-cache' +import { withStore } from '../../src/cache/indexeddb-store' import SimpleAssignmentCache from '../../src/cache/simple-assignment-cache' +import { nextWrite } from './indexeddb-test-helpers' const exposure = (id: string): ExposureEvent => ({ subject: { id, attributes: {} }, @@ -14,8 +15,6 @@ const entries = (count = MAX_EXPOSURE_CACHE_ENTRIES): [string, string][] => Array.from({ length: count }, (_, index) => [`key-${index}`, 'value']) describe('exposure cache capacity', () => { - beforeEach(() => localStorage.clear()) - it('uses the Node provider limit and evicts the least recently used memory entry', async () => { expect(MAX_EXPOSURE_CACHE_ENTRIES).toBe(50_000) const cache = new SimpleAssignmentCache() @@ -40,62 +39,43 @@ describe('exposure cache capacity', () => { expect(cache.has(old)).toBe(true) }) - it('bounds localStorage on load and on write without changing another namespace', () => { - localStorage.setItem('datadog-assignment-bounded', JSON.stringify(entries(MAX_EXPOSURE_CACHE_ENTRIES + 1))) - localStorage.setItem('datadog-assignment-other', 'preserve') - const cache = new LocalStorageAssignmentShim('bounded') - expect(cache.has('key-0')).toBe(false) - cache.set('key-1', 'updated') - cache.set('new', 'value') - const stored = new Map(JSON.parse(localStorage.getItem('datadog-assignment-bounded')!)) + it('bounds IndexedDB on load and subsequent writes', async () => { + await withStore('readwrite', (store) => store.put(entries(MAX_EXPOSURE_CACHE_ENTRIES + 1), 'assignments-scope')) + const cache = new IndexedDBAssignmentCache('scope') + const pruned = nextWrite() + await cache.init() + await pruned + const imported = new Map(await cache.getEntries()) + expect(imported.size).toBe(MAX_EXPOSURE_CACHE_ENTRIES) + expect(imported.has('key-0')).toBe(false) + const written = nextWrite() + cache.set(exposure('new')) + await written + 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-2')).toBe(false) - expect(stored.get('key-1')).toBe('updated') - expect(new LocalStorageAssignmentShim('bounded').has('new')).toBe(true) - expect(localStorage.getItem('datadog-assignment-other')).toBe('preserve') + expect(stored.has('key-1')).toBe(false) + expect(cache.has(exposure('new'))).toBe(true) }) - function chromeCache(count: number) { - const stored: Record = Object.fromEntries( - entries(count).map(([key, value]) => [`bounded:${key}`, value]) - ) - stored['other:preserve'] = 'value' - const storage = { - get: jest.fn(async () => ({ ...stored })), - set: jest.fn(async (items: Record) => { - Object.assign(stored, items) - }), - remove: jest.fn(async (keys: string[]) => { - for (const key of keys) delete stored[key] - }), - } - const cache = new ChromeStorageAsyncMap(storage as unknown as chrome.storage.StorageArea, 'bounded') - return { stored, storage, cache } - } - - it('bounds Chrome storage on load and serializes concurrent writes and eviction', async () => { - const { stored, cache } = chromeCache(MAX_EXPOSURE_CACHE_ENTRIES + 1) - expect(Object.keys(await cache.entries())).toHaveLength(MAX_EXPOSURE_CACHE_ENTRIES) - expect(stored['bounded:key-0']).toBeUndefined() - await Promise.all([cache.set('key-1', 'updated'), cache.set('new-1', 'value'), cache.set('new-2', 'value')]) - expect(Object.keys(stored)).toHaveLength(MAX_EXPOSURE_CACHE_ENTRIES + 1) - expect(stored['bounded:key-1']).toBe('updated') - expect(stored['bounded:key-2']).toBeUndefined() - expect(stored['bounded:key-3']).toBeUndefined() - expect(stored['bounded:new-1']).toBe('value') - expect(stored['bounded:new-2']).toBe('value') - expect(stored['other:preserve']).toBe('value') - }) + 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 - it('continues after a failed Chrome write and clears after queued writes', async () => { - const { stored, storage, cache } = chromeCache(0) - storage.set.mockRejectedValueOnce(new Error('quota')) - await expect(cache.set('failed', 'value')).rejects.toThrow('quota') - await cache.set('next', 'value') - expect(stored['bounded:next']).toBe('value') - const pendingWrite = cache.set('pending', 'value') - await cache.clear() - await pendingWrite - expect(stored).toEqual({ 'other:preserve': 'value' }) + 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) + 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/exposure-cache.spec.ts b/packages/browser/test/cache/exposure-cache.spec.ts index bf528ef9..805bda1d 100644 --- a/packages/browser/test/cache/exposure-cache.spec.ts +++ b/packages/browser/test/cache/exposure-cache.spec.ts @@ -1,8 +1,9 @@ -import { assignmentCacheKeyToString, type ExposureEvent } from '@datadog/flagging-core' +import type { ExposureEvent } from '@datadog/flagging-core' import { assignmentCacheFactory } from '../../src/cache/assignment-cache-factory' -import ChromeStorageAsyncMap from '../../src/cache/chrome-storage-async-map' import { createExposureCache } from '../../src/cache/exposure-cache' +import { withStore } from '../../src/cache/indexeddb-store' import { validateAndBuildFlaggingTrackingConfiguration } from '../../src/domain/configuration' +import { nextWrite } from './indexeddb-test-helpers' const exposure: ExposureEvent = { flag: { key: 'flag' }, @@ -12,74 +13,33 @@ const exposure: ExposureEvent = { } describe('exposure cache storage boundaries', () => { - beforeEach(() => { - localStorage.clear() - }) - - it('reads Chrome storage entries only from its own namespace', async () => { - const stored: Record = { - 'scope-a:assignment': 'first', - 'scope-b:assignment': 'second', - assignment: 'legacy', - 'scope-b:other': 'other-scope-only', + it('clears only its own namespace and leaves configuration snapshots intact', async () => { + const createCache = (storageKeySuffix: string) => assignmentCacheFactory({ storageKeySuffix }) + await withStore('readwrite', (store) => store.put('preserve', 'v2-flags-config-other')) + for (const scope of ['scope-a', 'scope-b']) { + const cache = createCache(scope) + await cache.init() + const written = nextWrite() + cache.set(exposure) + await written } - const get = jest.fn(async (key: string) => ({ [key]: stored[key] })) - const storage = { get } as unknown as chrome.storage.StorageArea - const first = new ChromeStorageAsyncMap(storage, 'scope-a') - const second = new ChromeStorageAsyncMap(storage, 'scope-b') - - expect(await first.get('assignment')).toBe('first') - expect(get).toHaveBeenLastCalledWith('scope-a:assignment') - expect(await second.get('assignment')).toBe('second') - expect(get).toHaveBeenLastCalledWith('scope-b:assignment') - expect(await first.get('other')).toBeUndefined() - expect(get).toHaveBeenLastCalledWith('scope-a:other') + await createCache('scope-a').clear() + const first = createCache('scope-a') + const second = createCache('scope-b') + 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', + ]) }) - it.each(['localStorage', 'chrome'] as const)( - 'clears only its own %s namespace, including persisted entries', - async (kind) => { - const stored: Record = { unrelated: 'preserve-me' } - const storage = { - get: jest.fn(async () => ({ ...stored })), - set: jest.fn(async (entries: Record) => { - Object.assign(stored, entries) - }), - remove: jest.fn(async (keys: string[]) => { - for (const key of keys) delete stored[key] - }), - clear: jest.fn(), - } as unknown as chrome.storage.StorageArea - const createCache = (storageKeySuffix: string) => - assignmentCacheFactory({ - storageKeySuffix, - chromeStorage: kind === 'chrome' ? storage : undefined, - }) - const first = createCache('scope-a') - const second = createCache('scope-b') - first.set(exposure) - second.set(exposure) - await Promise.all([first.init(), second.init()]) - - const storageKeys = Object.keys(kind === 'chrome' ? stored : localStorage).filter((key) => key !== 'unrelated') - const entrySuffix = kind === 'chrome' ? `:${assignmentCacheKeyToString(exposure)}` : '' - expect(storageKeys.sort()).toEqual([ - `datadog-assignment-scope-a${entrySuffix}`, - `datadog-assignment-scope-b${entrySuffix}`, - ]) - - await first.clear() - - const reloadedFirst = createCache('scope-a') - const reloadedSecond = createCache('scope-b') - await Promise.all([reloadedFirst.init(), reloadedSecond.init()]) - expect(reloadedFirst.has(exposure)).toBe(false) - expect(reloadedSecond.has(exposure)).toBe(true) - expect(stored.unrelated).toBe('preserve-me') - expect(storage.clear).not.toHaveBeenCalled() - } - ) - it('keeps callback proxy caches independent without invoking the proxy during setup', async () => { const proxy = jest.fn(() => 'https://proxy.example.com/intake') const options = { clientToken: 'token', proxy } @@ -87,12 +47,10 @@ describe('exposure cache storage boundaries', () => { const first = createExposureCache(options, configuration) await first.init() first.set(exposure) - const second = createExposureCache(options, configuration) await second.init() expect(first.has(exposure)).toBe(true) expect(second.has(exposure)).toBe(false) - expect(localStorage.length).toBe(0) expect(proxy).not.toHaveBeenCalled() }) }) diff --git a/packages/browser/test/cache/hybrid-assignment-cache.spec.ts b/packages/browser/test/cache/hybrid-assignment-cache.spec.ts deleted file mode 100644 index 26949381..00000000 --- a/packages/browser/test/cache/hybrid-assignment-cache.spec.ts +++ /dev/null @@ -1,94 +0,0 @@ -/** - * @jest-environment jsdom - */ - -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 - -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, 'test') - const localStorageCache = new LocalStorageAssignmentCache('test') - const hybridCache = new HybridAssignmentCache(localStorageCache, chromeStorageCache) - - beforeEach(() => { - window.localStorage.clear() - }) - - it('has should return false if cache is empty', async () => { - const exposureEvent: ExposureEvent = { - subject: { id: 'subject-1', attributes: {} }, - flag: { key: 'flag-1' }, - allocation: { key: 'allocation-1' }, - variant: { key: 'control' }, - } - await hybridCache.init() - expect(hybridCache.has(exposureEvent)).toBeFalsy() - }) - - it('has should return true if cache key is present', async () => { - const exposureEvent: ExposureEvent = { - subject: { id: 'subject-1', attributes: {} }, - flag: { key: 'flag-1' }, - allocation: { key: 'allocation-1' }, - variant: { key: 'control' }, - } - await hybridCache.init() - expect(hybridCache.has(exposureEvent)).toBeFalsy() - expect(localStorageCache.has(exposureEvent)).toBeFalsy() - hybridCache.set(exposureEvent) - expect(hybridCache.has(exposureEvent)).toBeTruthy() - expect(localStorageCache.has(exposureEvent)).toBeTruthy() - }) - - it('should populate localStorageCache from chromeStorageCache', async () => { - const exposureEvent1: ExposureEvent = { - subject: { id: 'subject-1', attributes: {} }, - flag: { key: 'flag-1' }, - allocation: { key: 'allocation-1' }, - variant: { key: 'control' }, - } - const exposureEvent2: ExposureEvent = { - subject: { id: 'subject-2', attributes: {} }, - flag: { key: 'flag-2' }, - allocation: { key: 'allocation-2' }, - variant: { key: 'control' }, - } - const exposureEvent3: ExposureEvent = { - subject: { id: 'subject-1', attributes: {} }, - flag: { key: 'flag-1' }, - allocation: { key: 'foo' }, - variant: { key: 'control' }, - } - expect(localStorageCache.has(exposureEvent1)).toBeFalsy() - chromeStorageCache.set(exposureEvent1) - chromeStorageCache.set(exposureEvent2) - await hybridCache.init() - expect(localStorageCache.has(exposureEvent1)).toBeTruthy() - expect(localStorageCache.has(exposureEvent2)).toBeTruthy() - expect(localStorageCache.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..439170f2 --- /dev/null +++ b/packages/browser/test/cache/indexeddb-assignment-cache.spec.ts @@ -0,0 +1,400 @@ +import { assignmentCacheKeyToString, assignmentCacheValueToString, type ExposureEvent } from '@datadog/flagging-core' +import { IDBDatabase, IDBObjectStore } from 'fake-indexeddb' +import { IndexedDBAssignmentCache } from '../../src/cache/indexeddb-assignment-cache' +import * as storage from '../../src/cache/indexeddb-store' +import { deferred, nextWrite } from './indexeddb-test-helpers' + +const exposure = (id: string): ExposureEvent => ({ + subject: { id, attributes: {} }, + flag: { key: 'flag' }, + allocation: { key: 'allocation' }, + variant: { key: 'control' }, +}) +const a = exposure('a') +const b = exposure('b') +const serialized = (entry: ExposureEvent): [string, string] => [ + assignmentCacheKeyToString(entry), + assignmentCacheValueToString(entry), +] +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', () => { + let cache: IndexedDBAssignmentCache + beforeEach(() => { + cache = new IndexedDBAssignmentCache('scope') + }) + afterEach(() => jest.restoreAllMocks()) + + it('serves synchronously, batches writes, and restores on reload', async () => { + await cache.init() + const operations = jest.spyOn(storage, 'withStore') + const written = nextWrite() + cache.set(a) + cache.set(b) + expect(cache.has(a)).toBe(true) + expect(cache.has(b)).toBe(true) + await written + expect(operations.mock.calls.filter(([mode]) => mode === 'readwrite')).toHaveLength(1) + const reloaded = new IndexedDBAssignmentCache('scope') + await reloaded.init() + expect(reloaded.has(a)).toBe(true) + 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) + // 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 () => { + 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('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() + jest.spyOn(storage, 'withStore').mockImplementationOnce(() => { + started.resolve() + return loading.promise as Promise + }) + const initialized = cache.init() + expect(cache.init()).toBe(initialized) + await started.promise + const updated = { ...a, variant: { key: 'treatment' } } + const written = nextWrite() + cache.set(updated) + cache.set(b) + loading.resolve([serialized(a)]) + await initialized + await written + expect(cache.has(a)).toBe(false) + expect(cache.has(updated)).toBe(true) + expect(cache.has(b)).toBe(true) + expect(await read()).toEqual([serialized(updated), serialized(b)]) + }) + + it('loads old entries before persisting a write made before init', async () => { + await seed([serialized(a)]) + const written = nextWrite() + cache.set(b) + await written + expect(cache.has(a)).toBe(true) + expect(await read()).toEqual([serialized(a), serialized(b)]) + }) + + it('does not restore entries when cleared during loading', async () => { + const loading = deferred() + const started = deferred() + jest.spyOn(storage, 'withStore').mockImplementationOnce(() => { + started.resolve() + return loading.promise as Promise + }) + const initialized = cache.init() + await started.promise + const cleared = cache.clear() + loading.resolve([serialized(a)]) + await Promise.all([initialized, cleared]) + expect(cache.has(a)).toBe(false) + expect(await read()).toEqual([]) + }) + + it('clears after an in-flight write and persists subsequent writes', async () => { + await cache.init() + const writing = deferred() + const started = deferred() + const transact = storage.withStore + jest.spyOn(storage, 'withStore').mockImplementationOnce(async (mode, operation) => { + started.resolve() + await writing.promise + return transact(mode, operation) + }) + cache.set(a) + await started.promise + const cleared = cache.clear() + expect(cache.has(a)).toBe(false) + writing.resolve() + await cleared + expect(await read()).toEqual([]) + const written = nextWrite() + cache.set(b) + await written + expect(await read()).toEqual([serialized(b)]) + }) + + it('keeps a write made immediately after clear', async () => { + await cache.init() + const written = nextWrite(2) + cache.set(a) + const cleared = cache.clear() + cache.set(b) + await cleared + await written + expect(cache.has(a)).toBe(false) + expect(await read()).toEqual([serialized(b)]) + }) + + it('persists post-clear entries when a pre-clear write is waiting for initialization', async () => { + const loading = deferred() + 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 transaction. + await Promise.resolve() + const cleared = cache.clear() + cache.set(b) + loading.resolve([]) + await Promise.all([initialized, cleared]) + const reloaded = new IndexedDBAssignmentCache('scope') + await reloaded.init() + expect(reloaded.has(a)).toBe(false) + expect(reloaded.has(b)).toBe(true) + }) + + it('keeps memory usable after open failure and retries later writes', async () => { + const open = jest.spyOn(indexedDB, 'open').mockImplementation(() => { + throw new Error('unavailable') + }) + await cache.init() + cache.set(a) + await cache.clear() + open.mockRestore() + const written = nextWrite() + cache.set(b) + expect(cache.has(b)).toBe(true) + await written + expect(await read()).toEqual([serialized(b)]) + }) + + it('does not let an aborted transaction poison later operations', async () => { + await cache.init() + const transaction = IDBDatabase.prototype.transaction + const aborted = deferred() + jest.spyOn(IDBDatabase.prototype, 'transaction').mockImplementationOnce(function (this: IDBDatabase, ...args) { + const tx = transaction.apply(this, args) + tx.addEventListener('abort', () => aborted.resolve()) + tx.abort() + return tx + }) + cache.set(a) + await aborted.promise + expect(cache.has(a)).toBe(true) + const written = nextWrite() + cache.set(b) + await written + expect(await read()).toEqual([serialized(a), serialized(b)]) + }) + + 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() + 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' } } + 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)]) + }) + + 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) { + const request = get.call(this, key) + request.addEventListener('success', () => request.transaction!.abort()) + return request + }) + await expect(cache.init()).resolves.toBeUndefined() + expect(cache.has(a)).toBe(false) + const written = nextWrite() + cache.set(a) + await written + expect(await read()).toEqual([serialized(a)]) + }) + + it('closes the connection when a storage operation throws', async () => { + const close = jest.spyOn(IDBDatabase.prototype, 'close') + await expect( + storage.withStore('readwrite', () => { + throw new Error('quota') + }) + ).rejects.toThrow('quota') + expect(close).toHaveBeenCalledTimes(1) + }) + + 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) + await expect(cache.init()).resolves.toBeUndefined() + expect(await cache.getEntries()).toEqual([]) + }) +}) diff --git a/packages/browser/test/cache/indexeddb-flags-cache.spec.ts b/packages/browser/test/cache/indexeddb-flags-cache.spec.ts index 0c641e8c..217ea136 100644 --- a/packages/browser/test/cache/indexeddb-flags-cache.spec.ts +++ b/packages/browser/test/cache/indexeddb-flags-cache.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' import { buildStorageKeySuffix, type FlagsConfiguration, getMD5Hash } from '@datadog/flagging-core' import type { TimeStamp } from '@datadog/js-core/time' import { IDBFactory } from 'fake-indexeddb' diff --git a/packages/browser/test/cache/indexeddb-test-helpers.ts b/packages/browser/test/cache/indexeddb-test-helpers.ts new file mode 100644 index 00000000..ac10058e --- /dev/null +++ b/packages/browser/test/cache/indexeddb-test-helpers.ts @@ -0,0 +1,34 @@ +import { IDBObjectStore } from 'fake-indexeddb' + +/** Observe an actual commit instead of sleeping for an assumed write duration. */ +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 + 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 + }) + }) +} + +export function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve } +} 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/core-tracking.spec.ts b/packages/browser/test/openfeature/core-tracking.spec.ts index ed827378..8c7d53bd 100644 --- a/packages/browser/test/openfeature/core-tracking.spec.ts +++ b/packages/browser/test/openfeature/core-tracking.spec.ts @@ -2,6 +2,7 @@ import { getGlobalObject, INTAKE_SITE_STAGING } from '@datadog/browser-core' import type { FlagsConfiguration } from '@datadog/flagging-core' import { timeStampNow } from '@datadog/js-core/time' import { OpenFeature } from '@openfeature/web-sdk' +import { withStore } from '../../src/cache/indexeddb-store' import type { DDRum } from '../../src/openfeature/rumIntegration' import { composeDatadogTrackingHooks, @@ -12,6 +13,7 @@ import { createDatadogRumTrackingHook, DatadogCoreProvider, } from '../../src/rules-based' +import { nextWrite } from '../cache/indexeddb-test-helpers' import rulesWire from '../data/rules-v1-wire.json' const rulesConfiguration = configurationFromString(JSON.stringify(rulesWire)) @@ -341,27 +343,10 @@ describe('DatadogCoreProvider tracking', () => { }) it('does not let persisted marker-less exposure entries suppress core provider exposures', async () => { - const persisted: Record = {} - Object.defineProperty(globalThis, 'chrome', { - configurable: true, - value: { - storage: { - local: { - get: jest.fn(async () => ({ ...persisted })), - set: jest.fn(async (items: Record) => { - Object.assign(persisted, items) - }), - remove: jest.fn(async (keys: string[]) => { - for (const storageKey of keys) delete persisted[storageKey] - }), - }, - }, - }, - }) - // Persist an entry in the format written without a core configuration marker. const legacy = createExposureOnlyTracking().trackingHooks await legacy.initialize() + const written = nextWrite() legacy.hooks[0].finally!( { flagKey: 'static-flag', context: { targetingKey: 'static-user', plan: 'free' } } as never, { @@ -373,7 +358,8 @@ describe('DatadogCoreProvider tracking', () => { ) jest.advanceTimersByTime(31_000) await legacy.shutdown() - expect(Object.keys(persisted)).toHaveLength(1) + await written + expect(await withStore('readonly', (store) => store.getAllKeys())).toHaveLength(1) const { trackingHooks } = createExposureOnlyTracking() await trackingHooks.initialize() diff --git a/packages/browser/test/openfeature/exposure-cache-scoping.spec.ts b/packages/browser/test/openfeature/exposure-cache-scoping.spec.ts index 88b2b438..5c813882 100644 --- a/packages/browser/test/openfeature/exposure-cache-scoping.spec.ts +++ b/packages/browser/test/openfeature/exposure-cache-scoping.spec.ts @@ -1,4 +1,5 @@ import { OpenFeature } from '@openfeature/web-sdk' +import { withStore } from '../../src/cache/indexeddb-store' import { configurationFromString, createDatadogExposureLoggingHook, @@ -6,6 +7,7 @@ import { DatadogProvider, type DatadogTrackingHooksOptions, } from '../../src/rules-based' +import { nextWrite } from '../cache/indexeddb-test-helpers' import precomputedResponse from '../data/precomputed-v1.json' import rulesWire from '../data/rules-v1-wire.json' @@ -16,43 +18,21 @@ const baseOptions: DatadogTrackingHooksOptions = { service: 'storefront', } -describe.each(['localStorage', 'chrome'] as const)('%s exposure cache isolation', (storageKind) => { +describe('IndexedDB exposure cache isolation', () => { let originalFetch: typeof globalThis.fetch let fetchMock: jest.Mock - const stored: Record = {} beforeEach(() => { jest.useFakeTimers() localStorage.clear() - for (const key of Object.keys(stored)) delete stored[key] - stored['unrelated-setting'] = 'preserve-me' originalFetch = globalThis.fetch fetchMock = jest.fn().mockResolvedValue({ ok: true, status: 200 }) globalThis.fetch = fetchMock - if (storageKind === 'chrome') { - Object.defineProperty(globalThis, 'chrome', { - configurable: true, - value: { - storage: { - local: { - get: jest.fn(async () => ({ ...stored })), - set: jest.fn(async (entries: Record) => { - Object.assign(stored, entries) - }), - remove: jest.fn(async (keys: string[]) => { - for (const key of keys) delete stored[key] - }), - }, - }, - }, - }) - } }) afterEach(async () => { await OpenFeature.clearProviders() globalThis.fetch = originalFetch - Reflect.deleteProperty(globalThis, 'chrome') jest.useRealTimers() }) @@ -73,23 +53,22 @@ describe.each(['localStorage', 'chrome'] as const)('%s exposure cache isolation' await evaluateWith({ ...baseOptions, ...changedOptions }) expect(fetchMock).toHaveBeenCalledTimes(2) - await evaluateWith(baseOptions) - await evaluateWith({ ...baseOptions, ...changedOptions }) + await evaluateWith(baseOptions, false) + await evaluateWith({ ...baseOptions, ...changedOptions }, false) expect(fetchMock).toHaveBeenCalledTimes(2) - const storageKeys = Object.keys(storageKind === 'chrome' ? stored : localStorage) + const storageKeys = await withStore('readonly', (store) => store.getAllKeys()) expect(storageKeys.join()).not.toContain(baseOptions.clientToken) - expect(stored['unrelated-setting']).toBe('preserve-me') } ) it('uses the same scope for implicit and explicit default site', async () => { await evaluateWith(baseOptions) - await evaluateWith({ ...baseOptions, site: 'datadoghq.com' }) + await evaluateWith({ ...baseOptions, site: 'datadoghq.com' }, false) expect(fetchMock).toHaveBeenCalledTimes(1) }) - async function evaluateWith(options: DatadogTrackingHooksOptions) { + async function evaluateWith(options: DatadogTrackingHooksOptions, expectExposure = true) { const exposure = providerKind === 'core' ? createDatadogExposureLoggingHook(options) : undefined try { let provider: DatadogCoreProvider | DatadogProvider @@ -108,6 +87,7 @@ describe.each(['localStorage', 'chrome'] as const)('%s exposure cache isolation' await OpenFeature.setProviderAndWait('cache-scope', provider, { targetingKey: 'user', country: 'US' }) const client = OpenFeature.getClient('cache-scope') if (exposure) client.addHooks(...exposure.hooks) + const written = expectExposure ? nextWrite() : undefined const details = providerKind === 'core' ? client.getBooleanDetails('test-flag', false) @@ -115,6 +95,7 @@ describe.each(['localStorage', 'chrome'] as const)('%s exposure cache isolation' expect(details.errorCode).toBeUndefined() jest.advanceTimersByTime(31_000) client.clearHooks() + await written } finally { await exposure?.shutdown() await OpenFeature.clearProviders() diff --git a/packages/browser/test/openfeature/exposures.spec.ts b/packages/browser/test/openfeature/exposures.spec.ts index e9d94c91..0f85a68a 100644 --- a/packages/browser/test/openfeature/exposures.spec.ts +++ b/packages/browser/test/openfeature/exposures.spec.ts @@ -1,9 +1,11 @@ import { getGlobalObject, INTAKE_SITE_STAGING } from '@datadog/browser-core' import { ErrorCode, type HookContext, OpenFeature } from '@openfeature/web-sdk' +import { IDBFactory } from 'fake-indexeddb' 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', () => { @@ -47,8 +49,8 @@ describe('Exposures End-to-End', () => { const globalObject = getGlobalObject<{ DD_RUM?: DDRum }>() delete globalObject.DD_RUM - // 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')) @@ -859,13 +861,15 @@ 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) - // Simulate page reload: clear providers but keep localStorage + // Simulate page reload: clear providers but keep IndexedDB await OpenFeature.clearProviders() fetchMock.mockClear() @@ -874,11 +878,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/obfuscation.spec.ts b/packages/browser/test/openfeature/obfuscation.spec.ts index 7e257ed1..b72c7960 100644 --- a/packages/browser/test/openfeature/obfuscation.spec.ts +++ b/packages/browser/test/openfeature/obfuscation.spec.ts @@ -1,7 +1,8 @@ import { createHash } from 'node:crypto' import { getGlobalObject } from '@datadog/browser-core' import { OpenFeature, ProviderStatus } from '@openfeature/web-sdk' -import HybridAssignmentCache from '../../src/cache/hybrid-assignment-cache' +import { IndexedDBAssignmentCache } from '../../src/cache/indexeddb-assignment-cache' +import * as indexeddbStore from '../../src/cache/indexeddb-store' import { DatadogProvider } from '../../src/openfeature/provider' import type { DDRum } from '../../src/openfeature/rumIntegration' import { @@ -11,6 +12,7 @@ import { DatadogCoreProvider, } from '../../src/rules-based' import { fetchPrecomputedConfiguration } from '../../src/transport/fetchConfiguration' +import { deferred, nextWrite } from '../cache/indexeddb-test-helpers' const context = { targetingKey: 'athlete-123' } const key = 'new-route-planner' @@ -280,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. @@ -301,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) } @@ -310,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) } @@ -323,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({ @@ -338,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() @@ -388,7 +392,7 @@ describe('browser flag-key obfuscation', () => { ) const provider = new DatadogProvider({ ...options, enableExposureLogging: true }) const clear = jest - .spyOn(HybridAssignmentCache.prototype, 'clear') + .spyOn(IndexedDBAssignmentCache.prototype, 'clear') .mockRejectedValue(new Error('storage unavailable')) try { await provider.initialize(context) @@ -419,7 +423,7 @@ describe('browser flag-key obfuscation', () => { const clearingFinished = new Promise((resolve) => { finishClear = resolve }) - const clear = jest.spyOn(HybridAssignmentCache.prototype, 'clear').mockImplementation(() => { + const clear = jest.spyOn(IndexedDBAssignmentCache.prototype, 'clear').mockImplementation(() => { startClear() return clearingFinished }) @@ -449,7 +453,7 @@ describe('browser flag-key obfuscation', () => { it.each(['', salt])('refreshes without an exposure cache when logging is disabled, salt "%s"', async (publicSalt) => { fetchMock.mockResolvedValue(fetchResponse(response(publicSalt))) const provider = new DatadogProvider(options) - const clear = jest.spyOn(HybridAssignmentCache.prototype, 'clear') + const clear = jest.spyOn(IndexedDBAssignmentCache.prototype, 'clear') try { await provider.initialize(context) fetchMock.mockResolvedValue(fetchResponse(response(publicSalt, false, '2026-09-30T00:01:00Z'))) @@ -486,26 +490,11 @@ describe('browser flag-key obfuscation', () => { it('loads persisted exposure identities before reporting ready', async () => { jest.useFakeTimers() - let resolveRead: ((entries: Record) => void) | undefined - const readStarted = new Promise((started) => { - Object.defineProperty(globalThis, 'chrome', { - configurable: true, - value: { - storage: { - local: { - get: jest.fn( - () => - new Promise>((resolve) => { - resolveRead = resolve - started() - }) - ), - set: jest.fn().mockResolvedValue(undefined), - remove: jest.fn().mockResolvedValue(undefined), - }, - }, - }, - }) + const read = deferred() + const started = deferred() + const load = jest.spyOn(indexeddbStore, 'withStore').mockImplementationOnce(() => { + started.resolve() + return read.promise as Promise }) try { const provider = new DatadogProvider({ ...options, enableExposureLogging: true }) @@ -513,20 +502,20 @@ describe('browser flag-key obfuscation', () => { const initialized = OpenFeature.setProviderAndWait(provider, context).then(() => { ready = true }) - await readStarted + await started.promise await jest.advanceTimersByTimeAsync(100) expect(fetchMock.mock.calls.some(([url]) => String(url).includes('precompute-assignments'))).toBe(true) expect(ready).toBe(false) - resolveRead!({}) + read.resolve([]) await initialized OpenFeature.getClient().getBooleanValue(key, false) jest.advanceTimersByTime(31_000) expect(fetchMock.mock.calls.filter(([url]) => String(url).includes('exposures'))).toHaveLength(1) } finally { // Let provider shutdown finish if an assertion failed before the read resolved. - resolveRead?.({}) - Reflect.deleteProperty(globalThis, 'chrome') + read.resolve([]) + load.mockRestore() } }) }) diff --git a/packages/browser/test/openfeature/provider-persistence.spec.ts b/packages/browser/test/openfeature/provider-persistence.spec.ts index b8b1144c..3b40c6ad 100644 --- a/packages/browser/test/openfeature/provider-persistence.spec.ts +++ b/packages/browser/test/openfeature/provider-persistence.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' import { INTAKE_SITE_STAGING } from '@datadog/browser-core' import type { FlagsConfiguration } from '@datadog/flagging-core' import type { TimeStamp } from '@datadog/js-core/time' diff --git a/packages/browser/test/openfeature/tracking-lifecycle.spec.ts b/packages/browser/test/openfeature/tracking-lifecycle.spec.ts index c6b2c1c5..471b6bae 100644 --- a/packages/browser/test/openfeature/tracking-lifecycle.spec.ts +++ b/packages/browser/test/openfeature/tracking-lifecycle.spec.ts @@ -1,4 +1,5 @@ import { OpenFeature, ProviderEvents, ProviderStatus } from '@openfeature/web-sdk' +import * as indexeddbStore from '../../src/cache/indexeddb-store' import { composeDatadogTrackingHooks, configurationFromString, @@ -139,16 +140,13 @@ describe('tracking resource lifecycle', () => { const readStarted = new Promise((resolve) => { notifyRead = resolve }) - const storage = { - get: jest.fn( - () => - new Promise>((resolve) => { - resolveRead = resolve - notifyRead() - }) - ), - } - Object.defineProperty(globalThis, 'chrome', { configurable: true, value: { storage: { local: storage } } }) + jest.spyOn(indexeddbStore, 'withStore').mockImplementationOnce( + () => + new Promise((resolve) => { + resolveRead = resolve as unknown as typeof resolveRead + notifyRead() + }) + ) const controller = createDatadogExposureLoggingHook(options) controllers.push(controller) const initialize = controller.initialize() diff --git a/packages/browser/test/setup-indexeddb.ts b/packages/browser/test/setup-indexeddb.ts new file mode 100644 index 00000000..046a05d9 --- /dev/null +++ b/packages/browser/test/setup-indexeddb.ts @@ -0,0 +1,12 @@ +// 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' +import { IDBFactory } from 'fake-indexeddb' + +beforeEach(() => { + globalThis.indexedDB = new IDBFactory() +}) diff --git a/test-app/src/obfuscation.ts b/test-app/src/obfuscation.ts index 36ef52a6..2b71607e 100644 --- a/test-app/src/obfuscation.ts +++ b/test-app/src/obfuscation.ts @@ -3,15 +3,18 @@ import { OpenFeature } from '@openfeature/web-sdk' import { reportSuccess } from './smoke' async function run() { + const parameters = new URLSearchParams(location.search) + const exposures = parameters.has('exposures') const provider = new DatadogProvider({ - clientToken: 'obfuscation-smoke-token', + clientToken: parameters.get('token') || 'obfuscation-smoke-token', env: 'test', flaggingProxy: new URL('/assignments', location.href).toString(), customHeaders: { 'X-DD-FEATURE-FLAGS-CAPABILITIES': 'assignment-encoding-flag-key-256-v1' }, - enableExposureLogging: false, + enableExposureLogging: exposures, + ...(exposures ? { proxy: new URL('/exposures', location.href).toString() } : {}), enableFlagEvaluationTracking: false, enableRumFeatureFlagTracking: false, - ...(new URLSearchParams(location.search).has('offline') + ...(parameters.has('offline') ? { flagConfigurationFetch: async () => { throw new Error('Offline test') @@ -22,7 +25,7 @@ async function run() { await OpenFeature.setProviderAndWait(provider, { targetingKey: 'browser-smoke-subject' }) const client = OpenFeature.getClient() const details = client.getBooleanDetails('new-route-planner', false) - reportSuccess({ + const result = { values: [ details.value, client.getStringValue('café', 'default'), @@ -35,7 +38,9 @@ async function run() { missing: client.getBooleanDetails('missing-flag', false).errorCode, mismatch: client.getStringDetails('new-route-planner', 'default').errorCode, status: provider.status, - }) + } + if (exposures) await OpenFeature.clearProviders() + reportSuccess(result) } run().catch((error: unknown) => { diff --git a/test-app/tests/smoke.spec.ts b/test-app/tests/smoke.spec.ts index 07b1b993..8db84b81 100644 --- a/test-app/tests/smoke.spec.ts +++ b/test-app/tests/smoke.spec.ts @@ -232,6 +232,61 @@ test('keeps plaintext responses compatible when rollout is disabled', async ({ p expect(await runSmoke(page, '/obfuscation.html')).toEqual(expectedObfuscationResult) }) +test('persists exposure deduplication across reloads and isolates client tokens', async ({ page }) => { + let salt = '000102030405060708090a0b0c0d0e0f' + const exposures: { flag: { key: string } }[] = [] + await page.route('**/assignments?*', (route) => route.fulfill({ json: assignmentPayload(salt) })) + await page.route( + (url) => url.pathname === '/exposures', + async (route) => { + for (const line of route.request().postData()!.trim().split('\n')) exposures.push(JSON.parse(line)) + await route.fulfill({ status: 202, body: '' }) + } + ) + const storedScopes = () => + page.evaluate( + () => + new Promise((resolve, reject) => { + const open = indexedDB.open('dd-flagging') + open.onerror = () => reject(open.error) + open.onsuccess = () => { + const db = open.result + const read = db.transaction('configurations').objectStore('configurations').getAllKeys() + read.onsuccess = () => { + db.close() + resolve(read.result.filter((key) => String(key).startsWith('assignments-')).length) + } + read.onerror = () => { + db.close() + reject(read.error) + } + } + }) + ) + + expect(await runSmoke(page, '/obfuscation.html?exposures=1')).toEqual(expectedObfuscationResult) + await expect.poll(() => exposures.length).toBe(4) + await expect.poll(storedScopes).toBe(1) + expect(exposures.map((event) => event.flag.key).sort()).toEqual([ + 'café', + 'new-route-planner', + 'number-flag', + 'object-flag', + ]) + + salt = 'f'.repeat(32) + expect(await runSmoke(page, '/obfuscation.html?exposures=1')).toEqual(expectedObfuscationResult) + await page.waitForLoadState('networkidle') + expect(exposures).toHaveLength(4) + expect(await runSmoke(page, '/obfuscation.html?exposures=1&token=other-app')).toEqual(expectedObfuscationResult) + await expect.poll(() => exposures.length).toBe(8) + await expect.poll(storedScopes).toBe(2) + + expect(await runSmoke(page, '/obfuscation.html?exposures=1')).toEqual(expectedObfuscationResult) + await page.waitForLoadState('networkidle') + expect(exposures).toHaveLength(8) +}) + test('evaluates obfuscated keys without native text encoders or Web Crypto', async ({ page }) => { await page.addInitScript(() => { Object.assign(globalThis, { TextEncoder: undefined, TextDecoder: undefined })