diff --git a/packages/sdk/electron/__tests__/ElectronClient.mainProcess.test.ts b/packages/sdk/electron/__tests__/ElectronClient.mainProcess.test.ts index 1023958982..e904548b55 100644 --- a/packages/sdk/electron/__tests__/ElectronClient.mainProcess.test.ts +++ b/packages/sdk/electron/__tests__/ElectronClient.mainProcess.test.ts @@ -288,6 +288,101 @@ describe('given an initialized ElectronClient with enableIPC: false and polling' }); }); +function makeMemoryStorage() { + const data = new Map(); + return { + get: jest.fn(async (key: string) => data.get(key) ?? null), + set: jest.fn(async (key: string, value: string) => { + data.set(key, value); + }), + clear: jest.fn(async (key: string) => { + data.delete(key); + }), + keys: () => Array.from(data.keys()), + }; +} + +describe('maxCachedContexts', () => { + it('evicts the oldest cached context when the limit is exceeded', async () => { + const storage = makeMemoryStorage(); + const mockedFetch = mockFetch(JSON.stringify(remoteFlagsMockData), 200); + (ElectronPlatform as jest.Mock).mockReturnValue({ + crypto: new ElectronCrypto(), + info: new ElectronInfo(), + requests: { + createEventSource: jest.fn(), + fetch: mockedFetch, + getEventSourceCapabilities: jest.fn(), + }, + encoding: new ElectronEncoding(), + storage, + }); + + const client = createClient(clientSideId, DEFAULT_INITIAL_CONTEXT, { + initialConnectionMode: 'polling', + enableIPC: false, + diagnosticOptOut: true, + sendEvents: false, + maxCachedContexts: 1, + }); + await client.start(); + + // After start, context A's flags are cached. + // Storage should contain: context index + context A data + context A freshness + const keysAfterStart = storage.keys(); + const contextDataKeys = keysAfterStart.filter( + (k) => !k.includes('ContextIndex') && !k.includes('_freshness'), + ); + expect(contextDataKeys).toHaveLength(1); + + // Identify a second context — context A should be evicted (maxCachedContexts: 1) + await client.identify({ kind: 'user', key: 'context-b' }); + + const keysAfterIdentify = storage.keys(); + const contextDataKeysAfter = keysAfterIdentify.filter( + (k) => !k.includes('ContextIndex') && !k.includes('_freshness'), + ); + expect(contextDataKeysAfter).toHaveLength(1); + + // The surviving key should be different from the first one (context B replaced context A) + expect(contextDataKeysAfter[0]).not.toEqual(contextDataKeys[0]); + }); + + it('does not cache flags when maxCachedContexts is 0', async () => { + const storage = makeMemoryStorage(); + const mockedFetch = mockFetch(JSON.stringify(remoteFlagsMockData), 200); + (ElectronPlatform as jest.Mock).mockReturnValue({ + crypto: new ElectronCrypto(), + info: new ElectronInfo(), + requests: { + createEventSource: jest.fn(), + fetch: mockedFetch, + getEventSourceCapabilities: jest.fn(), + }, + encoding: new ElectronEncoding(), + storage, + }); + + const client = createClient(clientSideId, DEFAULT_INITIAL_CONTEXT, { + initialConnectionMode: 'polling', + enableIPC: false, + diagnosticOptOut: true, + sendEvents: false, + maxCachedContexts: 0, + }); + await client.start(); + + // Flags should still evaluate correctly from the network response + expect(client.boolVariation('on-off-flag', false)).toBe(true); + + // But no context data should be persisted to storage + const contextDataKeys = storage + .keys() + .filter((k) => !k.includes('ContextIndex') && !k.includes('_freshness')); + expect(contextDataKeys).toHaveLength(0); + }); +}); + describe('given an initialized ElectronClient with enableIPC: false and streaming', () => { const logger: LDLogger = { debug: jest.fn(), diff --git a/packages/sdk/electron/__tests__/platform/ElectronPlatform.test.ts b/packages/sdk/electron/__tests__/platform/ElectronPlatform.test.ts new file mode 100644 index 0000000000..288da954b7 --- /dev/null +++ b/packages/sdk/electron/__tests__/platform/ElectronPlatform.test.ts @@ -0,0 +1,52 @@ +import type { LDLogger } from '@launchdarkly/js-client-sdk-common'; + +import ElectronPlatform from '../../src/platform/ElectronPlatform'; + +const failingStorage = { + get: jest.fn().mockRejectedValue(new Error('disk read failed')), + set: jest.fn().mockRejectedValue(new Error('disk write failed')), + clear: jest.fn().mockRejectedValue(new Error('disk clear failed')), +}; + +jest.mock('../../src/platform/ElectronStorage', () => ({ + getElectronStorage: () => failingStorage, +})); + +const logger: LDLogger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), +}; + +let platform: ElectronPlatform; + +beforeEach(() => { + jest.clearAllMocks(); + platform = new ElectronPlatform(logger, {}); +}); + +it('logs error and returns null when storage get fails', async () => { + const result = await platform.storage!.get('some-key'); + + expect(result).toBeNull(); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('Error getting key from storage: some-key'), + ); +}); + +it('logs error and swallows when storage set fails', async () => { + await platform.storage!.set('some-key', 'some-value'); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('Error setting key in storage: some-key'), + ); +}); + +it('logs error and swallows when storage clear fails', async () => { + await platform.storage!.clear('some-key'); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('Error clearing key from storage: some-key'), + ); +}); diff --git a/packages/sdk/electron/__tests__/platform/ElectronStorage.test.ts b/packages/sdk/electron/__tests__/platform/ElectronStorage.test.ts index c34f9ef1b9..8651b1da0f 100644 --- a/packages/sdk/electron/__tests__/platform/ElectronStorage.test.ts +++ b/packages/sdk/electron/__tests__/platform/ElectronStorage.test.ts @@ -1,8 +1,9 @@ import * as fs from 'fs/promises'; -import type { LDLogger } from '@launchdarkly/js-client-sdk-common'; - -import ElectronStorage from '../../src/platform/ElectronStorage'; +import ElectronStorage, { + getElectronStorage, + resetElectronStorage, +} from '../../src/platform/ElectronStorage'; jest.mock('fs/promises'); jest.mock('electron', () => ({ @@ -11,35 +12,20 @@ jest.mock('electron', () => ({ }, })); -const namespace = 'test_namespace'; -const storageFile = `/user/data/ldcache-${namespace}`; +const storageFile = '/user/data/ldcache'; const tempFile = `${storageFile}.tmp`; -const logger: LDLogger = { - debug: jest.fn(), - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), -}; - beforeEach(() => { jest.clearAllMocks(); + resetElectronStorage(); }); -it('handles failed initialization when clearing values', async () => { +it('throws on clear when initialization failed', async () => { (fs.readFile as jest.Mock).mockRejectedValueOnce(new Error('file not found')); (fs.writeFile as jest.Mock).mockRejectedValueOnce(new Error('write failed')); - const storage = new ElectronStorage(namespace, logger); - await storage.clear('key1'); - - expect(logger.debug).not.toHaveBeenCalled(); - expect(logger.info).not.toHaveBeenCalled(); - expect(logger.warn).not.toHaveBeenCalled(); - expect(logger.error).toHaveBeenCalledWith('Error initializing storage: write failed'); - expect(logger.error).toHaveBeenCalledWith( - 'Error clearing key from storage: key1, reason: Storage is not initialized', - ); + const storage = new ElectronStorage(); + await expect(storage.clear('key1')).rejects.toThrow('Storage is not initialized: write failed'); }); it('can clear values', async () => { @@ -47,7 +33,7 @@ it('can clear values', async () => { JSON.stringify({ key1: 'value1', key2: 'value2' }), ); - const storage = new ElectronStorage(namespace, logger); + const storage = new ElectronStorage(); await storage.clear('key1'); expect(await storage.get('key1')).toBeNull(); @@ -58,11 +44,6 @@ it('can clear values', async () => { expect.anything(), ); expect(fs.rename).toHaveBeenCalledWith(tempFile, storageFile); - - expect(logger.debug).not.toHaveBeenCalled(); - expect(logger.info).not.toHaveBeenCalled(); - expect(logger.warn).not.toHaveBeenCalled(); - expect(logger.error).not.toHaveBeenCalled(); }); it('does nothing when clearing a non-existent key', async () => { @@ -70,53 +51,34 @@ it('does nothing when clearing a non-existent key', async () => { JSON.stringify({ key1: 'value1', key2: 'value2' }), ); - const storage = new ElectronStorage(namespace, logger); + const storage = new ElectronStorage(); await storage.clear('key3'); expect(fs.writeFile).not.toHaveBeenCalled(); expect(fs.rename).not.toHaveBeenCalled(); - - expect(logger.debug).not.toHaveBeenCalled(); - expect(logger.info).not.toHaveBeenCalled(); - expect(logger.warn).not.toHaveBeenCalled(); - expect(logger.error).not.toHaveBeenCalled(); }); -it('handles error when clearing values', async () => { +it('updates cache even when disk flush fails on clear', async () => { (fs.readFile as jest.Mock).mockReturnValueOnce( JSON.stringify({ key1: 'value1', key2: 'value2' }), ); (fs.writeFile as jest.Mock).mockRejectedValueOnce(new Error('write failed')); - const storage = new ElectronStorage(namespace, logger); - await storage.clear('key1'); - - expect(await storage.get('key1')).toEqual('value1'); + const storage = new ElectronStorage(); + // The flush error propagates so the per-client wrapper can log it + await expect(storage.clear('key1')).rejects.toThrow('write failed'); - expect(logger.debug).not.toHaveBeenCalled(); - expect(logger.info).not.toHaveBeenCalled(); - expect(logger.warn).not.toHaveBeenCalled(); - expect(logger.error).toHaveBeenCalledWith( - 'Error clearing key from storage: key1, reason: write failed', - ); + // Cache is updated synchronously — the in-memory state reflects the clear + // even if the disk write failed. + expect(await storage.get('key1')).toBeNull(); }); -it('handles failed initialization when getting values', async () => { +it('throws on get when initialization failed', async () => { (fs.readFile as jest.Mock).mockRejectedValueOnce(new Error('file not found')); (fs.writeFile as jest.Mock).mockRejectedValueOnce(new Error('write failed')); - const storage = new ElectronStorage(namespace, logger); - const value = await storage.get('key1'); - - expect(value).toBeNull(); - - expect(logger.debug).not.toHaveBeenCalled(); - expect(logger.info).not.toHaveBeenCalled(); - expect(logger.warn).not.toHaveBeenCalled(); - expect(logger.error).toHaveBeenCalledWith('Error initializing storage: write failed'); - expect(logger.error).toHaveBeenCalledWith( - 'Error getting key from storage: key1, reason: Storage is not initialized', - ); + const storage = new ElectronStorage(); + await expect(storage.get('key1')).rejects.toThrow('Storage is not initialized: write failed'); }); it('can get values', async () => { @@ -124,15 +86,10 @@ it('can get values', async () => { JSON.stringify({ key1: 'value1', key2: 'value2' }), ); - const storage = new ElectronStorage(namespace, logger); + const storage = new ElectronStorage(); const value = await storage.get('key1'); expect(value).toEqual('value1'); - - expect(logger.debug).not.toHaveBeenCalled(); - expect(logger.info).not.toHaveBeenCalled(); - expect(logger.warn).not.toHaveBeenCalled(); - expect(logger.error).not.toHaveBeenCalled(); }); it('returns null when getting a non-existent key', async () => { @@ -140,30 +97,19 @@ it('returns null when getting a non-existent key', async () => { JSON.stringify({ key1: 'value1', key2: 'value2' }), ); - const storage = new ElectronStorage(namespace, logger); + const storage = new ElectronStorage(); const value = await storage.get('key3'); expect(value).toBeNull(); - - expect(logger.debug).not.toHaveBeenCalled(); - expect(logger.info).not.toHaveBeenCalled(); - expect(logger.warn).not.toHaveBeenCalled(); - expect(logger.error).not.toHaveBeenCalled(); }); -it('handles failed initialization when setting values', async () => { +it('throws on set when initialization failed', async () => { (fs.readFile as jest.Mock).mockRejectedValueOnce(new Error('file not found')); (fs.writeFile as jest.Mock).mockRejectedValueOnce(new Error('write failed')); - const storage = new ElectronStorage(namespace, logger); - await storage.set('key3', 'value3'); - - expect(logger.debug).not.toHaveBeenCalled(); - expect(logger.info).not.toHaveBeenCalled(); - expect(logger.warn).not.toHaveBeenCalled(); - expect(logger.error).toHaveBeenCalledWith('Error initializing storage: write failed'); - expect(logger.error).toHaveBeenCalledWith( - 'Error setting key in storage: key3, reason: Storage is not initialized', + const storage = new ElectronStorage(); + await expect(storage.set('key3', 'value3')).rejects.toThrow( + 'Storage is not initialized: write failed', ); }); @@ -172,7 +118,7 @@ it('can set values', async () => { JSON.stringify({ key1: 'value1', key2: 'value2' }), ); - const storage = new ElectronStorage(namespace, logger); + const storage = new ElectronStorage(); await storage.set('key3', 'value3'); expect(await storage.get('key3')).toEqual('value3'); @@ -183,11 +129,6 @@ it('can set values', async () => { expect.anything(), ); expect(fs.rename).toHaveBeenCalledWith(tempFile, storageFile); - - expect(logger.debug).not.toHaveBeenCalled(); - expect(logger.info).not.toHaveBeenCalled(); - expect(logger.warn).not.toHaveBeenCalled(); - expect(logger.error).not.toHaveBeenCalled(); }); it('can set values with existing keys', async () => { @@ -195,7 +136,7 @@ it('can set values with existing keys', async () => { JSON.stringify({ key1: 'value1', key2: 'value2' }), ); - const storage = new ElectronStorage(namespace, logger); + const storage = new ElectronStorage(); await storage.set('key1', 'new-value1'); expect(await storage.get('key1')).toEqual('new-value1'); @@ -206,28 +147,32 @@ it('can set values with existing keys', async () => { expect.anything(), ); expect(fs.rename).toHaveBeenCalledWith(tempFile, storageFile); - - expect(logger.debug).not.toHaveBeenCalled(); - expect(logger.info).not.toHaveBeenCalled(); - expect(logger.warn).not.toHaveBeenCalled(); - expect(logger.error).not.toHaveBeenCalled(); }); -it('handles error when setting values', async () => { +it('updates cache even when disk flush fails on set', async () => { (fs.readFile as jest.Mock).mockReturnValueOnce( JSON.stringify({ key1: 'value1', key2: 'value2' }), ); (fs.writeFile as jest.Mock).mockRejectedValueOnce(new Error('write failed')); - const storage = new ElectronStorage(namespace, logger); - await storage.set('key3', 'value3'); + const storage = new ElectronStorage(); + // The flush error propagates so the per-client wrapper can log it + await expect(storage.set('key3', 'value3')).rejects.toThrow('write failed'); - expect(await storage.get('key3')).toBeNull(); + // Cache is updated synchronously — the in-memory state reflects the set + // even if the disk write failed. + expect(await storage.get('key3')).toEqual('value3'); +}); - expect(logger.debug).not.toHaveBeenCalled(); - expect(logger.info).not.toHaveBeenCalled(); - expect(logger.warn).not.toHaveBeenCalled(); - expect(logger.error).toHaveBeenCalledWith( - 'Error setting key in storage: key3, reason: write failed', - ); +it('getElectronStorage returns the same instance on subsequent calls', () => { + const a = getElectronStorage(); + const b = getElectronStorage(); + expect(a).toBe(b); +}); + +it('resetElectronStorage causes a fresh instance on next call', () => { + const a = getElectronStorage(); + resetElectronStorage(); + const b = getElectronStorage(); + expect(a).not.toBe(b); }); diff --git a/packages/sdk/electron/contract-tests/entity/src/ClientEntity.ts b/packages/sdk/electron/contract-tests/entity/src/ClientEntity.ts index fc8e1c4a32..0b5d4ffe48 100644 --- a/packages/sdk/electron/contract-tests/entity/src/ClientEntity.ts +++ b/packages/sdk/electron/contract-tests/entity/src/ClientEntity.ts @@ -1,11 +1,16 @@ -import { createHash } from 'crypto'; // eslint-disable-next-line import/no-extraneous-dependencies import { app } from 'electron'; import fs from 'node:fs'; import path from 'node:path'; // eslint-disable-next-line import/no-extraneous-dependencies -import { createClient, LDClient, LDLogger, LDOptions } from '@launchdarkly/electron-client-sdk'; +import { + createClient, + LDClient, + LDLogger, + LDOptions, + resetElectronStorage, +} from '@launchdarkly/electron-client-sdk'; import { CommandParams, CommandType, @@ -147,6 +152,9 @@ export class ClientEntity { if (fs.existsSync(`${this._storagePath}.tmp`)) { fs.rmSync(`${this._storagePath}.tmp`, { recursive: true }); } + // Reset the singleton so the next client gets a fresh storage instance + // that reads from disk instead of returning stale in-memory data. + resetElectronStorage(); this._logger.info('Test ended'); } catch (error) { this._logger.error(`Error closing client: ${error}`); @@ -257,15 +265,10 @@ export class ClientEntity { export async function createEntity(options: CreateInstanceParams) { const logger = makeLogger(options.tag); - // Need to keep track of this to know where electron is storing the caches. - // We can make this a bit more robust by either mocking out the storage path - // or allowing users to define a custom storage. That way we can isolate each - // client's cache. const clientSideId = options.configuration.credential || 'unknown-env-id'; logger.info(`Creating client with configuration: ${JSON.stringify(options.configuration)}`); - const namespace = createHash('sha256').update(clientSideId).digest?.('base64url'); - const storagePath = path.join(app.getPath('userData'), `ldcache-${namespace}`); + const storagePath = path.join(app.getPath('userData'), 'ldcache'); const timeoutMs = options.configuration.startWaitTimeMs !== null && diff --git a/packages/sdk/electron/contract-tests/entity/src/main.ts b/packages/sdk/electron/contract-tests/entity/src/main.ts index b4e745109e..c19ef9df1a 100644 --- a/packages/sdk/electron/contract-tests/entity/src/main.ts +++ b/packages/sdk/electron/contract-tests/entity/src/main.ts @@ -15,9 +15,6 @@ if (process.argv.includes('--build')) { const capabilities = [ 'client-side', 'mobile', - // This is a required feature since electron SDK uses a shared localstorage for all clients. - // Which would cause issues with flag evaluations when multiple clients are created. - 'singleton', 'service-endpoints', 'tags', 'user-type', diff --git a/packages/sdk/electron/src/ElectronClient.ts b/packages/sdk/electron/src/ElectronClient.ts index ad3fb6bc82..5b3baf91e3 100644 --- a/packages/sdk/electron/src/ElectronClient.ts +++ b/packages/sdk/electron/src/ElectronClient.ts @@ -95,7 +95,7 @@ export class ElectronClient extends LDClientImpl { credentialType: useClientSideId ? 'clientSideId' : 'mobileKey', }; - const platform = new ElectronPlatform(logger, credential, options); + const platform = new ElectronPlatform(logger, options); const endpoints = useClientSideId ? browserFdv1Endpoints(credential) : mobileFdv1Endpoints(); super( diff --git a/packages/sdk/electron/src/index.ts b/packages/sdk/electron/src/index.ts index 389a72dd36..cd6913c60c 100644 --- a/packages/sdk/electron/src/index.ts +++ b/packages/sdk/electron/src/index.ts @@ -6,6 +6,9 @@ import type { LDPlugin } from './LDPlugin'; export * from './LDCommon'; +/** @internal */ +export { resetElectronStorage } from './platform/ElectronStorage'; + export type { ElectronOptions as LDOptions, LDClient, diff --git a/packages/sdk/electron/src/platform/ElectronPlatform.ts b/packages/sdk/electron/src/platform/ElectronPlatform.ts index 13a894098b..35fc9f2328 100644 --- a/packages/sdk/electron/src/platform/ElectronPlatform.ts +++ b/packages/sdk/electron/src/platform/ElectronPlatform.ts @@ -5,7 +5,7 @@ import ElectronCrypto from './ElectronCrypto'; import ElectronEncoding from './ElectronEncoding'; import ElectronInfo from './ElectronInfo'; import ElectronRequests from './ElectronRequests'; -import ElectronStorage from './ElectronStorage'; +import { getElectronStorage } from './ElectronStorage'; // NOTE: Because Electron main process runs on Node.js, this platform should be // very similar to the Node server sdk platform. @@ -21,9 +21,32 @@ export default class ElectronPlatform implements platform.Platform { requests: platform.Requests; - constructor(logger: LDLogger, clientSideId: string, options: ElectronOptions) { - const namespace = this.crypto.createHash('sha256').update(clientSideId).digest?.('base64url'); - this.storage = new ElectronStorage(namespace!, logger); + constructor(logger: LDLogger, options: ElectronOptions) { + const globalStorage = getElectronStorage(); + this.storage = { + async get(key: string): Promise { + try { + return await globalStorage.get(key); + } catch (error) { + logger.error(`Error getting key from storage: ${key}, reason: ${error}`); + return null; + } + }, + async set(key: string, value: string): Promise { + try { + await globalStorage.set(key, value); + } catch (error) { + logger.error(`Error setting key in storage: ${key}, reason: ${error}`); + } + }, + async clear(key: string): Promise { + try { + await globalStorage.clear(key); + } catch (error) { + logger.error(`Error clearing key from storage: ${key}, reason: ${error}`); + } + }, + }; this.requests = new ElectronRequests( options.tlsParams, options.proxyOptions, diff --git a/packages/sdk/electron/src/platform/ElectronStorage.ts b/packages/sdk/electron/src/platform/ElectronStorage.ts index c38c129119..a6d120c489 100644 --- a/packages/sdk/electron/src/platform/ElectronStorage.ts +++ b/packages/sdk/electron/src/platform/ElectronStorage.ts @@ -2,19 +2,19 @@ import * as electron from 'electron'; import * as fs from 'fs/promises'; import * as path from 'path'; -import type { LDLogger, Storage } from '@launchdarkly/js-client-sdk-common'; +import type { Storage } from '@launchdarkly/js-client-sdk-common'; export default class ElectronStorage implements Storage { private readonly _storageFile: string; private readonly _tempFile: string; private readonly _initialized: Promise; + private _initError?: Error; private _cache: Map; + private _flushPending: boolean = false; + private _flushQueue: Promise = Promise.resolve(); - constructor( - private readonly _namespace: string, - private readonly _logger?: LDLogger, - ) { - this._storageFile = path.join(electron.app.getPath('userData'), `ldcache-${this._namespace}`); + constructor() { + this._storageFile = path.join(electron.app.getPath('userData'), 'ldcache'); this._tempFile = `${this._storageFile}.tmp`; this._cache = new Map(); this._initialized = this._initialize(); @@ -23,25 +23,22 @@ export default class ElectronStorage implements Storage { private async _initialize(): Promise { try { try { - // Clean up any leftover temp files from crashed writes await fs.unlink(this._tempFile); } catch { // Ignore error if file doesn't exist } try { - // Populate cache if file exists const data = await fs.readFile(this._storageFile, 'utf8'); const parsed = JSON.parse(data); this._cache = new Map(Object.entries(parsed)); } catch { - // If file doesn't exist, initialize with empty object await this._atomicWriteToFile(this._cache); } return true; } catch (error) { - this._logError('Error initializing storage', error); + this._initError = error instanceof Error ? error : new Error(String(error)); return false; } } @@ -49,70 +46,74 @@ export default class ElectronStorage implements Storage { private async _atomicWriteToFile(data: Map): Promise { try { const content = JSON.stringify(Object.fromEntries(data)); - - // Write to temporary file first await fs.writeFile(this._tempFile, content, { encoding: 'utf8', mode: 0o600 }); - - // Rename temporary file to target file (atomic operation on most filesystems) await fs.rename(this._tempFile, this._storageFile); } catch (error) { try { await fs.unlink(this._tempFile); - } catch (cleanupError) { - this._logError('Error cleaning up temporary file', cleanupError); + } catch { + // Ignore cleanup errors } - throw error; // Re-throw the original error + throw error; } } - private _logError(message: string, error: unknown) { - const errorMessage = error instanceof Error ? error.message : error; - this._logger?.error(`${message}: ${errorMessage}`); - } - private async _throwIfNotInitialized() { const initialized = await this._initialized; if (!initialized) { - throw new Error('Storage is not initialized'); + const reason = this._initError ? `: ${this._initError.message}` : ''; + throw new Error(`Storage is not initialized${reason}`); } } async clear(key: string): Promise { - try { - await this._throwIfNotInitialized(); - if (this._cache.has(key)) { - const cacheCopy = new Map(this._cache); - cacheCopy.delete(key); - await this._atomicWriteToFile(cacheCopy); - // Only update cache if write was successful - this._cache = cacheCopy; - } - } catch (error) { - this._logError(`Error clearing key from storage: ${key}, reason`, error); + await this._throwIfNotInitialized(); + if (this._cache.has(key)) { + this._cache.delete(key); + await this._scheduleFlush(); } } async get(key: string): Promise { - try { - await this._throwIfNotInitialized(); - const value = this._cache.get(key); - return value ?? null; - } catch (error) { - this._logError(`Error getting key from storage: ${key}, reason`, error); - return null; - } + await this._throwIfNotInitialized(); + const value = this._cache.get(key); + return value ?? null; } async set(key: string, value: string): Promise { - try { - await this._throwIfNotInitialized(); - const cacheCopy = new Map(this._cache); - cacheCopy.set(key, value); - await this._atomicWriteToFile(cacheCopy); - // Only update cache if write was successful - this._cache = cacheCopy; - } catch (error) { - this._logError(`Error setting key in storage: ${key}, reason`, error); + await this._throwIfNotInitialized(); + this._cache.set(key, value); + await this._scheduleFlush(); + } + + private _scheduleFlush(): Promise { + if (this._flushPending) { + return this._flushQueue; } + this._flushPending = true; + + const flush = this._flushQueue.then(async () => { + this._flushPending = false; + await this._atomicWriteToFile(new Map(this._cache)); + }); + + this._flushQueue = flush.catch(() => {}); + return flush; } } + +// Storage should be a singleton to support multiple instances of the SDK. This should have +// the same limitations as the browser sdk using the shared localStorage cache. +let instance: ElectronStorage | undefined; + +export function getElectronStorage(): ElectronStorage { + if (!instance) { + instance = new ElectronStorage(); + } + return instance; +} + +/** @internal Visible for testing only. */ +export function resetElectronStorage(): void { + instance = undefined; +}