Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/browser/jest.config.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'jsdom',
setupFiles: ['<rootDir>/test/setup-indexeddb.ts'],
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
transform: {
'^.+\\.ts$': [
Expand Down
26 changes: 9 additions & 17 deletions packages/browser/src/cache/assignment-cache-factory.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,26 @@
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()

if (forceMemoryOnly) {
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
}
40 changes: 0 additions & 40 deletions packages/browser/src/cache/chrome-storage-assignment-cache.ts

This file was deleted.

28 changes: 0 additions & 28 deletions packages/browser/src/cache/chrome-storage-async-map.ts

This file was deleted.

22 changes: 0 additions & 22 deletions packages/browser/src/cache/helpers.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
114 changes: 114 additions & 0 deletions packages/browser/src/cache/indexeddb-assignment-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import {
type AssignmentCacheEntry,
assignmentCacheKeyToString,
assignmentCacheValueToString,
buildStorageKeySuffix,
} from '@datadog/flagging-core'

import type { BulkReadAssignmentCache } from './hybrid-assignment-cache'
import { openDB, STORE_NAME } from './indexeddb-store'

export class IndexedDBAssignmentCache implements BulkReadAssignmentCache {
private readonly storageKey: string
private readonly mirror: Map<string, string> = new Map()
private persistScheduled = false

constructor(clientToken: string) {
this.storageKey = `assignments-${buildStorageKeySuffix(clientToken)}`
}

/** No-op — IndexedDB entries are loaded lazily via getEntries(). */
init(): Promise<void> {
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)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minor: you could also add tx.onabort = () => reject(tx.error) in case the transaction is aborted

})
if (Array.isArray(entries)) {
this.mirror.clear()
for (const [k, v] of entries) {
this.mirror.set(k, v)
}
return entries
}
Comment on lines +50 to +56
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential race condition between

  1. the fire-and-forget set method schedules persist of this.mirror via queueMicrotask
  2. when this.mirror.clear() in getEntries destroys unpersisted writes

which would cause data loss. The getEntries() method treats IndexedDB as the source of truth and overwrites the mirror, but the mirror may contain entries that were queued for persistence but haven't been written yet.

I haven't looked much further, so it's possible that these methods are used in a way that this race condition isn't a concern, but perhaps worth taking a second look.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the careful review; will take another look at this.

} finally {
db.close()
}
} catch {
// Silently fail — persistence should never break the SDK
}
return []
}

/** Remove persisted entries. Never throws. */
async clear(): Promise<void> {
this.mirror.clear()
try {
const db = await openDB()
try {
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite')
const store = tx.objectStore(STORE_NAME)
store.delete(this.storageKey)
tx.oncomplete = () => resolve()
tx.onerror = () => reject(tx.error)
tx.onabort = () => reject(tx.error)
})
} finally {
db.close()
}
} catch {
// Silently fail
}
}

/**
* Schedule a fire-and-forget write for the next microtask. Coalesces rapid set() calls
* into a single IDB transaction, avoiding O(n^2) write volume and out-of-order races.
*/
private persist(): void {
if (this.persistScheduled) {
return
}
this.persistScheduled = true
queueMicrotask(() => {
this.persistScheduled = false
const entries = Array.from(this.mirror.entries())
openDB()
.then((db) => {
const tx = db.transaction(STORE_NAME, 'readwrite')
const store = tx.objectStore(STORE_NAME)
store.put(entries, this.storageKey)
tx.oncomplete = () => db.close()
tx.onerror = () => db.close()
tx.onabort = () => db.close()
})
.catch(() => {
// Silently fail — persistence should never break the SDK
})
})
}
}
19 changes: 1 addition & 18 deletions packages/browser/src/cache/indexeddb-flags-cache.ts
Original file line number Diff line number Diff line change
@@ -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<IDBDatabase> {
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)
Expand Down
17 changes: 17 additions & 0 deletions packages/browser/src/cache/indexeddb-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export const DB_NAME = 'dd-flagging'
export const DB_VERSION = 1
export const STORE_NAME = 'configurations'

export function openDB(): Promise<IDBDatabase> {
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)
})
}
25 changes: 0 additions & 25 deletions packages/browser/src/cache/local-storage-assignment-cache.ts

This file was deleted.

Loading