Skip to content

Commit 60b0852

Browse files
compress configuration cache local storage values; migrate by dropping existing keys (FFL-718) (#245)
* compress configuration cache local storage values; migrate by dropping existing keys (FFL-718) * docs * leave configuration meta uncompressed * remove console spy * base64 encoding * 3.17.0 * alpha * fix spec * merge main * fix tests after merge * merge fixes * merge artifact * v3.17.0 --------- Co-authored-by: Tyler Potter <[email protected]>
1 parent 588f3f4 commit 60b0852

File tree

6 files changed

+328
-9
lines changed

6 files changed

+328
-9
lines changed

js-client-sdk.api.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,10 @@ export interface IClientConfigSync {
172172
// (undocumented)
173173
banditLogger?: IBanditLogger;
174174
// (undocumented)
175+
configFetchedAt?: string;
176+
// (undocumented)
177+
configPublishedAt?: string;
178+
// (undocumented)
175179
enableOverrides?: boolean;
176180
// (undocumented)
177181
flagsConfiguration: Record<string, Flag | ObfuscatedFlag>;

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@eppo/js-client-sdk",
3-
"version": "3.16.2",
3+
"version": "3.17.0",
44
"description": "Eppo SDK for client-side JavaScript applications",
55
"main": "dist/index.js",
66
"files": [
@@ -59,8 +59,9 @@
5959
"webpack-cli": "^6.0.1"
6060
},
6161
"dependencies": {
62+
"@eppo/js-client-sdk-common": "4.15.1",
6263
"@types/chrome": "^0.0.313",
63-
"@eppo/js-client-sdk-common": "4.15.1"
64+
"lz-string": "^1.5.0"
6465
},
6566
"packageManager": "[email protected]+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
6667
}

src/local-storage-engine.spec.ts

Lines changed: 251 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
1+
/**
2+
* @jest-environment jsdom
3+
*/
4+
5+
import * as LZString from 'lz-string';
6+
17
import { LocalStorageEngine } from './local-storage-engine';
28
import { StorageFullUnableToWrite, LocalStorageUnknownFailure } from './string-valued.store';
39

410
describe('LocalStorageEngine', () => {
5-
let mockLocalStorage: Storage;
11+
let mockLocalStorage: Storage & { _length: number };
612
let engine: LocalStorageEngine;
713

814
beforeEach(() => {
@@ -17,16 +23,27 @@ describe('LocalStorageEngine', () => {
1723
removeItem: jest.fn(),
1824
setItem: jest.fn(),
1925
} as Storage & { _length: number };
26+
27+
// Setup: migration already completed to avoid interference with basic functionality tests
28+
(mockLocalStorage.getItem as jest.Mock).mockImplementation((key) => {
29+
if (key === 'eppo-meta') return JSON.stringify({ version: 1, migratedAt: Date.now() });
30+
return null;
31+
});
32+
2033
engine = new LocalStorageEngine(mockLocalStorage, 'test');
2134
});
2235

23-
describe('setContentsJsonString', () => {
36+
afterEach(() => {
37+
jest.clearAllMocks();
38+
});
39+
40+
describe('Basic Functionality', () => {
2441
it('should set item successfully when no error occurs', async () => {
2542
await engine.setContentsJsonString('test-config');
2643

2744
expect(mockLocalStorage.setItem).toHaveBeenCalledWith(
2845
'eppo-configuration-test',
29-
'test-config',
46+
LZString.compressToBase64('test-config'),
3047
);
3148
});
3249

@@ -59,7 +76,7 @@ describe('LocalStorageEngine', () => {
5976
expect(mockLocalStorage.setItem).toHaveBeenCalledTimes(2);
6077
expect(mockLocalStorage.setItem).toHaveBeenLastCalledWith(
6178
'eppo-configuration-test',
62-
'test-config',
79+
LZString.compressToBase64('test-config'),
6380
);
6481
});
6582

@@ -156,4 +173,234 @@ describe('LocalStorageEngine', () => {
156173
expect(mockLocalStorage.removeItem).toHaveBeenCalledTimes(3);
157174
});
158175
});
176+
177+
describe('Compression Migration', () => {
178+
let migrationEngine: LocalStorageEngine;
179+
180+
beforeEach(() => {
181+
// Reset mocks for migration tests
182+
jest.clearAllMocks();
183+
mockLocalStorage = {
184+
get length() {
185+
return this._length || 0;
186+
},
187+
_length: 0,
188+
clear: jest.fn(),
189+
getItem: jest.fn(),
190+
key: jest.fn(),
191+
removeItem: jest.fn(),
192+
setItem: jest.fn(),
193+
} as Storage & { _length: number };
194+
});
195+
196+
describe('Migration', () => {
197+
it('should run migration on first construction', () => {
198+
// Setup: no global meta exists (first time)
199+
(mockLocalStorage.getItem as jest.Mock).mockImplementation((key) => {
200+
if (key === 'eppo-meta') return null;
201+
return null;
202+
});
203+
204+
// Mock localStorage.length and key() for iteration
205+
(mockLocalStorage as Storage & { _length: number })._length = 3;
206+
(mockLocalStorage.key as jest.Mock).mockImplementation((index) => {
207+
const keys = ['eppo-configuration-abc123', 'eppo-configuration-meta-def456', 'other-key'];
208+
return keys[index] || null;
209+
});
210+
211+
migrationEngine = new LocalStorageEngine(mockLocalStorage, 'test');
212+
213+
// Should have removed configuration keys
214+
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith('eppo-configuration-abc123');
215+
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith('eppo-configuration-meta-def456');
216+
expect(mockLocalStorage.removeItem).not.toHaveBeenCalledWith('other-key');
217+
218+
// Should have set global meta
219+
expect(mockLocalStorage.setItem).toHaveBeenCalledWith(
220+
'eppo-meta',
221+
expect.stringContaining('"version":1'),
222+
);
223+
});
224+
225+
it('should skip migration if already completed', () => {
226+
// Setup: migration already done
227+
(mockLocalStorage.getItem as jest.Mock).mockImplementation((key) => {
228+
if (key === 'eppo-meta') return JSON.stringify({ version: 1, migratedAt: Date.now() });
229+
return null;
230+
});
231+
232+
migrationEngine = new LocalStorageEngine(mockLocalStorage, 'test');
233+
234+
// Should not have removed any keys
235+
expect(mockLocalStorage.removeItem).not.toHaveBeenCalled();
236+
});
237+
238+
it('should handle migration errors gracefully', () => {
239+
// Setup: no global meta, but error during migration
240+
(mockLocalStorage.getItem as jest.Mock).mockImplementation((key) => {
241+
if (key === 'eppo-meta') return null;
242+
return null;
243+
});
244+
245+
// Make removeItem throw an error
246+
(mockLocalStorage.removeItem as jest.Mock).mockImplementation(() => {
247+
throw new Error('Storage error');
248+
});
249+
250+
(mockLocalStorage as Storage & { _length: number })._length = 1;
251+
(mockLocalStorage.key as jest.Mock).mockReturnValue('eppo-configuration-test');
252+
253+
// Should not throw error, just continue silently
254+
expect(() => new LocalStorageEngine(mockLocalStorage, 'test')).not.toThrow();
255+
});
256+
});
257+
258+
describe('Compression', () => {
259+
beforeEach(() => {
260+
// Setup: migration already completed
261+
(mockLocalStorage.getItem as jest.Mock).mockImplementation((key) => {
262+
if (key === 'eppo-meta') return JSON.stringify({ version: 1, migratedAt: Date.now() });
263+
return null;
264+
});
265+
266+
migrationEngine = new LocalStorageEngine(mockLocalStorage, 'test');
267+
});
268+
269+
it('should compress data when storing', async () => {
270+
const testData = JSON.stringify({ flag: 'test-flag', value: 'test-value' });
271+
272+
await migrationEngine.setContentsJsonString(testData);
273+
274+
expect(mockLocalStorage.setItem).toHaveBeenCalledWith(
275+
'eppo-configuration-test',
276+
LZString.compressToBase64(testData),
277+
);
278+
});
279+
280+
it('should decompress data when reading', async () => {
281+
const testData = JSON.stringify({ flag: 'test-flag', value: 'test-value' });
282+
const compressedData = LZString.compressToBase64(testData);
283+
284+
(mockLocalStorage.getItem as jest.Mock).mockImplementation((key) => {
285+
if (key === 'eppo-meta') return JSON.stringify({ version: 1, migratedAt: Date.now() });
286+
if (key === 'eppo-configuration-test') return compressedData;
287+
return null;
288+
});
289+
290+
const result = await migrationEngine.getContentsJsonString();
291+
292+
expect(result).toBe(testData);
293+
});
294+
295+
it('should handle decompression errors gracefully', async () => {
296+
// Mock LZString.decompress to throw an error
297+
const decompressSpy = jest
298+
.spyOn(LZString, 'decompressFromBase64')
299+
.mockImplementation(() => {
300+
throw new Error('Decompression failed');
301+
});
302+
303+
(mockLocalStorage.getItem as jest.Mock).mockImplementation((key) => {
304+
if (key === 'eppo-meta') return JSON.stringify({ version: 1, migratedAt: Date.now() });
305+
if (key === 'eppo-configuration-test') return 'corrupted-data';
306+
return null;
307+
});
308+
309+
const result = await migrationEngine.getContentsJsonString();
310+
311+
expect(result).toBe(null);
312+
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith('eppo-configuration-test');
313+
314+
decompressSpy.mockRestore();
315+
});
316+
317+
it('should store and retrieve meta data without compression', async () => {
318+
const metaData = JSON.stringify({ lastUpdated: Date.now() });
319+
320+
await migrationEngine.setMetaJsonString(metaData);
321+
322+
// Meta data should be stored uncompressed
323+
expect(mockLocalStorage.setItem).toHaveBeenCalledWith(
324+
'eppo-configuration-meta-test',
325+
metaData,
326+
);
327+
328+
// Test reading back
329+
(mockLocalStorage.getItem as jest.Mock).mockImplementation((key) => {
330+
if (key === 'eppo-meta') return JSON.stringify({ version: 1, migratedAt: Date.now() });
331+
if (key === 'eppo-configuration-meta-test') return metaData;
332+
return null;
333+
});
334+
335+
const result = await migrationEngine.getMetaJsonString();
336+
expect(result).toBe(metaData);
337+
});
338+
339+
it('should return null for non-existent data', async () => {
340+
(mockLocalStorage.getItem as jest.Mock).mockImplementation((key) => {
341+
if (key === 'eppo-meta') return JSON.stringify({ version: 1, migratedAt: Date.now() });
342+
return null;
343+
});
344+
345+
const contentsResult = await migrationEngine.getContentsJsonString();
346+
const metaResult = await migrationEngine.getMetaJsonString();
347+
348+
expect(contentsResult).toBe(null);
349+
expect(metaResult).toBe(null);
350+
});
351+
});
352+
353+
describe('Global Meta Management', () => {
354+
it('should parse valid global meta', () => {
355+
const validMeta = { version: 1, migratedAt: Date.now() };
356+
(mockLocalStorage.getItem as jest.Mock).mockImplementation((key) => {
357+
if (key === 'eppo-meta') return JSON.stringify(validMeta);
358+
return null;
359+
});
360+
361+
new LocalStorageEngine(mockLocalStorage, 'test');
362+
363+
expect(mockLocalStorage.getItem).toHaveBeenCalledWith('eppo-meta');
364+
});
365+
366+
it('should handle corrupted global meta', () => {
367+
(mockLocalStorage.getItem as jest.Mock).mockImplementation((key) => {
368+
if (key === 'eppo-meta') return 'invalid-json';
369+
return null;
370+
});
371+
372+
(mockLocalStorage as Storage & { _length: number })._length = 0;
373+
374+
// Should not throw error, just continue silently with default version
375+
expect(() => new LocalStorageEngine(mockLocalStorage, 'test')).not.toThrow();
376+
});
377+
});
378+
379+
describe('Space Optimization', () => {
380+
it('should actually compress large configuration data', () => {
381+
// Create a large configuration object with repetitive data
382+
const largeConfig = {
383+
flags: Array.from({ length: 100 }, (_, i) => ({
384+
flagKey: `test-flag-${i}`,
385+
variationType: 'STRING',
386+
allocations: [
387+
{ key: 'control', value: 'control-value' },
388+
{ key: 'treatment', value: 'treatment-value' },
389+
],
390+
rules: [{ conditions: [{ attribute: 'userId', operator: 'MATCHES', value: '.*' }] }],
391+
})),
392+
};
393+
394+
const originalJson = JSON.stringify(largeConfig);
395+
const compressedData = LZString.compressToBase64(originalJson);
396+
397+
// Verify compression actually reduces size
398+
expect(compressedData.length).toBeLessThan(originalJson.length);
399+
400+
// Verify compression ratio is reasonable (should be significant for repetitive JSON)
401+
const compressionRatio = compressedData.length / originalJson.length;
402+
expect(compressionRatio).toBeLessThan(0.5); // At least 50% compression
403+
});
404+
});
405+
});
159406
});

0 commit comments

Comments
 (0)