Skip to content

Commit 90a310e

Browse files
committed
fix(electron-sdk): localstorage indexing
1 parent 6dee197 commit 90a310e

7 files changed

Lines changed: 113 additions & 32 deletions

File tree

packages/sdk/electron/__tests__/ElectronClient.mainProcess.test.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,101 @@ describe('given an initialized ElectronClient with enableIPC: false and polling'
288288
});
289289
});
290290

291+
function makeMemoryStorage() {
292+
const data = new Map<string, string>();
293+
return {
294+
get: jest.fn(async (key: string) => data.get(key) ?? null),
295+
set: jest.fn(async (key: string, value: string) => {
296+
data.set(key, value);
297+
}),
298+
clear: jest.fn(async (key: string) => {
299+
data.delete(key);
300+
}),
301+
keys: () => Array.from(data.keys()),
302+
};
303+
}
304+
305+
describe('maxCachedContexts', () => {
306+
it('evicts the oldest cached context when the limit is exceeded', async () => {
307+
const storage = makeMemoryStorage();
308+
const mockedFetch = mockFetch(JSON.stringify(remoteFlagsMockData), 200);
309+
(ElectronPlatform as jest.Mock).mockReturnValue({
310+
crypto: new ElectronCrypto(),
311+
info: new ElectronInfo(),
312+
requests: {
313+
createEventSource: jest.fn(),
314+
fetch: mockedFetch,
315+
getEventSourceCapabilities: jest.fn(),
316+
},
317+
encoding: new ElectronEncoding(),
318+
storage,
319+
});
320+
321+
const client = createClient(clientSideId, DEFAULT_INITIAL_CONTEXT, {
322+
initialConnectionMode: 'polling',
323+
enableIPC: false,
324+
diagnosticOptOut: true,
325+
sendEvents: false,
326+
maxCachedContexts: 1,
327+
});
328+
await client.start();
329+
330+
// After start, context A's flags are cached.
331+
// Storage should contain: context index + context A data + context A freshness
332+
const keysAfterStart = storage.keys();
333+
const contextDataKeys = keysAfterStart.filter(
334+
(k) => !k.includes('ContextIndex') && !k.includes('_freshness'),
335+
);
336+
expect(contextDataKeys).toHaveLength(1);
337+
338+
// Identify a second context — context A should be evicted (maxCachedContexts: 1)
339+
await client.identify({ kind: 'user', key: 'context-b' });
340+
341+
const keysAfterIdentify = storage.keys();
342+
const contextDataKeysAfter = keysAfterIdentify.filter(
343+
(k) => !k.includes('ContextIndex') && !k.includes('_freshness'),
344+
);
345+
expect(contextDataKeysAfter).toHaveLength(1);
346+
347+
// The surviving key should be different from the first one (context B replaced context A)
348+
expect(contextDataKeysAfter[0]).not.toEqual(contextDataKeys[0]);
349+
});
350+
351+
it('does not cache flags when maxCachedContexts is 0', async () => {
352+
const storage = makeMemoryStorage();
353+
const mockedFetch = mockFetch(JSON.stringify(remoteFlagsMockData), 200);
354+
(ElectronPlatform as jest.Mock).mockReturnValue({
355+
crypto: new ElectronCrypto(),
356+
info: new ElectronInfo(),
357+
requests: {
358+
createEventSource: jest.fn(),
359+
fetch: mockedFetch,
360+
getEventSourceCapabilities: jest.fn(),
361+
},
362+
encoding: new ElectronEncoding(),
363+
storage,
364+
});
365+
366+
const client = createClient(clientSideId, DEFAULT_INITIAL_CONTEXT, {
367+
initialConnectionMode: 'polling',
368+
enableIPC: false,
369+
diagnosticOptOut: true,
370+
sendEvents: false,
371+
maxCachedContexts: 0,
372+
});
373+
await client.start();
374+
375+
// Flags should still evaluate correctly from the network response
376+
expect(client.boolVariation('on-off-flag', false)).toBe(true);
377+
378+
// But no context data should be persisted to storage
379+
const contextDataKeys = storage
380+
.keys()
381+
.filter((k) => !k.includes('ContextIndex') && !k.includes('_freshness'));
382+
expect(contextDataKeys).toHaveLength(0);
383+
});
384+
});
385+
291386
describe('given an initialized ElectronClient with enableIPC: false and streaming', () => {
292387
const logger: LDLogger = {
293388
debug: jest.fn(),

packages/sdk/electron/__tests__/platform/ElectronStorage.test.ts

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,7 @@ jest.mock('electron', () => ({
1111
},
1212
}));
1313

14-
const namespace = 'test_namespace';
15-
const storageFile = `/user/data/ldcache-${namespace}`;
14+
const storageFile = '/user/data/ldcache';
1615
const tempFile = `${storageFile}.tmp`;
1716

1817
const logger: LDLogger = {
@@ -30,7 +29,7 @@ it('handles failed initialization when clearing values', async () => {
3029
(fs.readFile as jest.Mock).mockRejectedValueOnce(new Error('file not found'));
3130
(fs.writeFile as jest.Mock).mockRejectedValueOnce(new Error('write failed'));
3231

33-
const storage = new ElectronStorage(namespace, logger);
32+
const storage = new ElectronStorage(logger);
3433
await storage.clear('key1');
3534

3635
expect(logger.debug).not.toHaveBeenCalled();
@@ -47,7 +46,7 @@ it('can clear values', async () => {
4746
JSON.stringify({ key1: 'value1', key2: 'value2' }),
4847
);
4948

50-
const storage = new ElectronStorage(namespace, logger);
49+
const storage = new ElectronStorage(logger);
5150
await storage.clear('key1');
5251

5352
expect(await storage.get('key1')).toBeNull();
@@ -70,7 +69,7 @@ it('does nothing when clearing a non-existent key', async () => {
7069
JSON.stringify({ key1: 'value1', key2: 'value2' }),
7170
);
7271

73-
const storage = new ElectronStorage(namespace, logger);
72+
const storage = new ElectronStorage(logger);
7473
await storage.clear('key3');
7574

7675
expect(fs.writeFile).not.toHaveBeenCalled();
@@ -88,7 +87,7 @@ it('handles error when clearing values', async () => {
8887
);
8988
(fs.writeFile as jest.Mock).mockRejectedValueOnce(new Error('write failed'));
9089

91-
const storage = new ElectronStorage(namespace, logger);
90+
const storage = new ElectronStorage(logger);
9291
await storage.clear('key1');
9392

9493
expect(await storage.get('key1')).toEqual('value1');
@@ -105,7 +104,7 @@ it('handles failed initialization when getting values', async () => {
105104
(fs.readFile as jest.Mock).mockRejectedValueOnce(new Error('file not found'));
106105
(fs.writeFile as jest.Mock).mockRejectedValueOnce(new Error('write failed'));
107106

108-
const storage = new ElectronStorage(namespace, logger);
107+
const storage = new ElectronStorage(logger);
109108
const value = await storage.get('key1');
110109

111110
expect(value).toBeNull();
@@ -124,7 +123,7 @@ it('can get values', async () => {
124123
JSON.stringify({ key1: 'value1', key2: 'value2' }),
125124
);
126125

127-
const storage = new ElectronStorage(namespace, logger);
126+
const storage = new ElectronStorage(logger);
128127
const value = await storage.get('key1');
129128

130129
expect(value).toEqual('value1');
@@ -140,7 +139,7 @@ it('returns null when getting a non-existent key', async () => {
140139
JSON.stringify({ key1: 'value1', key2: 'value2' }),
141140
);
142141

143-
const storage = new ElectronStorage(namespace, logger);
142+
const storage = new ElectronStorage(logger);
144143
const value = await storage.get('key3');
145144

146145
expect(value).toBeNull();
@@ -155,7 +154,7 @@ it('handles failed initialization when setting values', async () => {
155154
(fs.readFile as jest.Mock).mockRejectedValueOnce(new Error('file not found'));
156155
(fs.writeFile as jest.Mock).mockRejectedValueOnce(new Error('write failed'));
157156

158-
const storage = new ElectronStorage(namespace, logger);
157+
const storage = new ElectronStorage(logger);
159158
await storage.set('key3', 'value3');
160159

161160
expect(logger.debug).not.toHaveBeenCalled();
@@ -172,7 +171,7 @@ it('can set values', async () => {
172171
JSON.stringify({ key1: 'value1', key2: 'value2' }),
173172
);
174173

175-
const storage = new ElectronStorage(namespace, logger);
174+
const storage = new ElectronStorage(logger);
176175
await storage.set('key3', 'value3');
177176

178177
expect(await storage.get('key3')).toEqual('value3');
@@ -195,7 +194,7 @@ it('can set values with existing keys', async () => {
195194
JSON.stringify({ key1: 'value1', key2: 'value2' }),
196195
);
197196

198-
const storage = new ElectronStorage(namespace, logger);
197+
const storage = new ElectronStorage(logger);
199198
await storage.set('key1', 'new-value1');
200199

201200
expect(await storage.get('key1')).toEqual('new-value1');
@@ -219,7 +218,7 @@ it('handles error when setting values', async () => {
219218
);
220219
(fs.writeFile as jest.Mock).mockRejectedValueOnce(new Error('write failed'));
221220

222-
const storage = new ElectronStorage(namespace, logger);
221+
const storage = new ElectronStorage(logger);
223222
await storage.set('key3', 'value3');
224223

225224
expect(await storage.get('key3')).toBeNull();

packages/sdk/electron/contract-tests/entity/src/ClientEntity.ts

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { createHash } from 'crypto';
21
// eslint-disable-next-line import/no-extraneous-dependencies
32
import { app } from 'electron';
43
import fs from 'node:fs';
@@ -257,15 +256,10 @@ export class ClientEntity {
257256
export async function createEntity(options: CreateInstanceParams) {
258257
const logger = makeLogger(options.tag);
259258

260-
// Need to keep track of this to know where electron is storing the caches.
261-
// We can make this a bit more robust by either mocking out the storage path
262-
// or allowing users to define a custom storage. That way we can isolate each
263-
// client's cache.
264259
const clientSideId = options.configuration.credential || 'unknown-env-id';
265260
logger.info(`Creating client with configuration: ${JSON.stringify(options.configuration)}`);
266261

267-
const namespace = createHash('sha256').update(clientSideId).digest?.('base64url');
268-
const storagePath = path.join(app.getPath('userData'), `ldcache-${namespace}`);
262+
const storagePath = path.join(app.getPath('userData'), 'ldcache');
269263

270264
const timeoutMs =
271265
options.configuration.startWaitTimeMs !== null &&

packages/sdk/electron/contract-tests/entity/src/main.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,6 @@ if (process.argv.includes('--build')) {
1515
const capabilities = [
1616
'client-side',
1717
'mobile',
18-
// This is a required feature since electron SDK uses a shared localstorage for all clients.
19-
// Which would cause issues with flag evaluations when multiple clients are created.
20-
'singleton',
2118
'service-endpoints',
2219
'tags',
2320
'user-type',

packages/sdk/electron/src/ElectronClient.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ export class ElectronClient extends LDClientImpl {
9595
credentialType: useClientSideId ? 'clientSideId' : 'mobileKey',
9696
};
9797

98-
const platform = new ElectronPlatform(logger, credential, options);
98+
const platform = new ElectronPlatform(logger, options);
9999
const endpoints = useClientSideId ? browserFdv1Endpoints(credential) : mobileFdv1Endpoints();
100100

101101
super(

packages/sdk/electron/src/platform/ElectronPlatform.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,8 @@ export default class ElectronPlatform implements platform.Platform {
2121

2222
requests: platform.Requests;
2323

24-
constructor(logger: LDLogger, clientSideId: string, options: ElectronOptions) {
25-
const namespace = this.crypto.createHash('sha256').update(clientSideId).digest?.('base64url');
26-
this.storage = new ElectronStorage(namespace!, logger);
24+
constructor(logger: LDLogger, options: ElectronOptions) {
25+
this.storage = new ElectronStorage(logger);
2726
this.requests = new ElectronRequests(
2827
options.tlsParams,
2928
options.proxyOptions,

packages/sdk/electron/src/platform/ElectronStorage.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,8 @@ export default class ElectronStorage implements Storage {
1010
private readonly _initialized: Promise<boolean>;
1111
private _cache: Map<string, string>;
1212

13-
constructor(
14-
private readonly _namespace: string,
15-
private readonly _logger?: LDLogger,
16-
) {
17-
this._storageFile = path.join(electron.app.getPath('userData'), `ldcache-${this._namespace}`);
13+
constructor(private readonly _logger?: LDLogger) {
14+
this._storageFile = path.join(electron.app.getPath('userData'), 'ldcache');
1815
this._tempFile = `${this._storageFile}.tmp`;
1916
this._cache = new Map<string, string>();
2017
this._initialized = this._initialize();

0 commit comments

Comments
 (0)