Skip to content

Commit 9748d00

Browse files
committed
compress configuration cache local storage values; migrate by dropping existing keys (FFL-718)
1 parent 7771ace commit 9748d00

File tree

5 files changed

+386
-6
lines changed

5 files changed

+386
-6
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.1",
3+
"version": "3.17.0-alpha.1",
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: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
/**
2+
* @jest-environment jsdom
3+
*/
4+
5+
import * as LZString from 'lz-string';
6+
7+
import { LocalStorageEngine } from './local-storage-engine';
8+
9+
describe('LocalStorageEngine Compression Migration', () => {
10+
let mockLocalStorage: Storage;
11+
12+
beforeEach(() => {
13+
mockLocalStorage = {
14+
getItem: jest.fn(),
15+
setItem: jest.fn(),
16+
removeItem: jest.fn(),
17+
clear: jest.fn(),
18+
get length() {
19+
return this._length || 0;
20+
},
21+
key: jest.fn(),
22+
_length: 0,
23+
} as any;
24+
});
25+
26+
afterEach(() => {
27+
jest.clearAllMocks();
28+
});
29+
30+
describe('Migration', () => {
31+
it('should run migration on first construction', () => {
32+
// Setup: no global meta exists (first time)
33+
(mockLocalStorage.getItem as jest.Mock).mockImplementation((key) => {
34+
if (key === 'eppo-meta') return null;
35+
return null;
36+
});
37+
38+
// Mock localStorage.length and key() for iteration
39+
(mockLocalStorage as any)._length = 3;
40+
(mockLocalStorage.key as jest.Mock).mockImplementation((index) => {
41+
const keys = ['eppo-configuration-abc123', 'eppo-configuration-meta-def456', 'other-key'];
42+
return keys[index] || null;
43+
});
44+
45+
const consoleSpy = jest.spyOn(console, 'log').mockImplementation();
46+
47+
new LocalStorageEngine(mockLocalStorage, 'test');
48+
49+
// Should have logged migration
50+
expect(consoleSpy).toHaveBeenCalledWith('Running storage migration from v0 to v1');
51+
expect(consoleSpy).toHaveBeenCalledWith(
52+
'Configuration cleanup completed - fresh configs will be compressed',
53+
);
54+
55+
// Should have removed configuration keys
56+
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith('eppo-configuration-abc123');
57+
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith('eppo-configuration-meta-def456');
58+
expect(mockLocalStorage.removeItem).not.toHaveBeenCalledWith('other-key');
59+
60+
// Should have set global meta
61+
expect(mockLocalStorage.setItem).toHaveBeenCalledWith(
62+
'eppo-meta',
63+
expect.stringContaining('"version":1'),
64+
);
65+
66+
consoleSpy.mockRestore();
67+
});
68+
69+
it('should skip migration if already completed', () => {
70+
// Setup: migration already done
71+
(mockLocalStorage.getItem as jest.Mock).mockImplementation((key) => {
72+
if (key === 'eppo-meta') return JSON.stringify({ version: 1, migratedAt: Date.now() });
73+
return null;
74+
});
75+
76+
const consoleSpy = jest.spyOn(console, 'log').mockImplementation();
77+
78+
new LocalStorageEngine(mockLocalStorage, 'test');
79+
80+
// Should not have logged migration
81+
expect(consoleSpy).not.toHaveBeenCalledWith(
82+
expect.stringContaining('Running storage migration'),
83+
);
84+
85+
// Should not have removed any keys
86+
expect(mockLocalStorage.removeItem).not.toHaveBeenCalled();
87+
88+
consoleSpy.mockRestore();
89+
});
90+
91+
it('should handle migration errors gracefully', () => {
92+
// Setup: no global meta, but error during migration
93+
(mockLocalStorage.getItem as jest.Mock).mockImplementation((key) => {
94+
if (key === 'eppo-meta') return null;
95+
return null;
96+
});
97+
98+
// Make removeItem throw an error
99+
(mockLocalStorage.removeItem as jest.Mock).mockImplementation(() => {
100+
throw new Error('Storage error');
101+
});
102+
103+
(mockLocalStorage as any)._length = 1;
104+
(mockLocalStorage.key as jest.Mock).mockReturnValue('eppo-configuration-test');
105+
106+
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation();
107+
108+
new LocalStorageEngine(mockLocalStorage, 'test');
109+
110+
expect(consoleSpy).toHaveBeenCalledWith('Migration failed:', expect.any(Error));
111+
112+
consoleSpy.mockRestore();
113+
});
114+
});
115+
116+
describe('Compression', () => {
117+
let engine: LocalStorageEngine;
118+
119+
beforeEach(() => {
120+
// Setup: migration already completed
121+
(mockLocalStorage.getItem as jest.Mock).mockImplementation((key) => {
122+
if (key === 'eppo-meta') return JSON.stringify({ version: 1, migratedAt: Date.now() });
123+
return null;
124+
});
125+
126+
engine = new LocalStorageEngine(mockLocalStorage, 'test');
127+
});
128+
129+
it('should compress data when storing', async () => {
130+
const testData = JSON.stringify({ flag: 'test-flag', value: 'test-value' });
131+
132+
await engine.setContentsJsonString(testData);
133+
134+
expect(mockLocalStorage.setItem).toHaveBeenCalledWith(
135+
'eppo-configuration-test',
136+
LZString.compress(testData),
137+
);
138+
});
139+
140+
it('should decompress data when reading', async () => {
141+
const testData = JSON.stringify({ flag: 'test-flag', value: 'test-value' });
142+
const compressedData = LZString.compress(testData);
143+
144+
(mockLocalStorage.getItem as jest.Mock).mockImplementation((key) => {
145+
if (key === 'eppo-meta') return JSON.stringify({ version: 1, migratedAt: Date.now() });
146+
if (key === 'eppo-configuration-test') return compressedData;
147+
return null;
148+
});
149+
150+
const result = await engine.getContentsJsonString();
151+
152+
expect(result).toBe(testData);
153+
});
154+
155+
it('should handle decompression errors gracefully', async () => {
156+
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation();
157+
158+
// Mock LZString.decompress to throw an error
159+
const decompressSpy = jest.spyOn(LZString, 'decompress').mockImplementation(() => {
160+
throw new Error('Decompression failed');
161+
});
162+
163+
(mockLocalStorage.getItem as jest.Mock).mockImplementation((key) => {
164+
if (key === 'eppo-meta') return JSON.stringify({ version: 1, migratedAt: Date.now() });
165+
if (key === 'eppo-configuration-test') return 'corrupted-data';
166+
return null;
167+
});
168+
169+
const result = await engine.getContentsJsonString();
170+
171+
expect(result).toBe(null);
172+
expect(consoleSpy).toHaveBeenCalledWith(
173+
'Failed to decompress configuration, removing corrupted data',
174+
);
175+
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith('eppo-configuration-test');
176+
177+
decompressSpy.mockRestore();
178+
consoleSpy.mockRestore();
179+
});
180+
181+
it('should compress and decompress meta data', async () => {
182+
const metaData = JSON.stringify({ lastUpdated: Date.now() });
183+
184+
await engine.setMetaJsonString(metaData);
185+
186+
expect(mockLocalStorage.setItem).toHaveBeenCalledWith(
187+
'eppo-configuration-meta-test',
188+
LZString.compress(metaData),
189+
);
190+
191+
// Test reading back
192+
const compressedMeta = LZString.compress(metaData);
193+
(mockLocalStorage.getItem as jest.Mock).mockImplementation((key) => {
194+
if (key === 'eppo-meta') return JSON.stringify({ version: 1, migratedAt: Date.now() });
195+
if (key === 'eppo-configuration-meta-test') return compressedMeta;
196+
return null;
197+
});
198+
199+
const result = await engine.getMetaJsonString();
200+
expect(result).toBe(metaData);
201+
});
202+
203+
it('should return null for non-existent data', async () => {
204+
(mockLocalStorage.getItem as jest.Mock).mockImplementation((key) => {
205+
if (key === 'eppo-meta') return JSON.stringify({ version: 1, migratedAt: Date.now() });
206+
return null;
207+
});
208+
209+
const contentsResult = await engine.getContentsJsonString();
210+
const metaResult = await engine.getMetaJsonString();
211+
212+
expect(contentsResult).toBe(null);
213+
expect(metaResult).toBe(null);
214+
});
215+
});
216+
217+
describe('Global Meta Management', () => {
218+
it('should parse valid global meta', () => {
219+
const validMeta = { version: 1, migratedAt: Date.now() };
220+
(mockLocalStorage.getItem as jest.Mock).mockImplementation((key) => {
221+
if (key === 'eppo-meta') return JSON.stringify(validMeta);
222+
return null;
223+
});
224+
225+
new LocalStorageEngine(mockLocalStorage, 'test');
226+
227+
expect(mockLocalStorage.getItem).toHaveBeenCalledWith('eppo-meta');
228+
});
229+
230+
it('should handle corrupted global meta', () => {
231+
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation();
232+
233+
(mockLocalStorage.getItem as jest.Mock).mockImplementation((key) => {
234+
if (key === 'eppo-meta') return 'invalid-json';
235+
return null;
236+
});
237+
238+
(mockLocalStorage as any)._length = 0;
239+
240+
new LocalStorageEngine(mockLocalStorage, 'test');
241+
242+
expect(consoleSpy).toHaveBeenCalledWith('Failed to parse global meta:', expect.any(Error));
243+
244+
consoleSpy.mockRestore();
245+
});
246+
});
247+
248+
describe('Space Optimization', () => {
249+
it('should actually compress large configuration data', () => {
250+
// Create a large configuration object with repetitive data
251+
const largeConfig = {
252+
flags: Array.from({ length: 100 }, (_, i) => ({
253+
flagKey: `test-flag-${i}`,
254+
variationType: 'STRING',
255+
allocations: [
256+
{ key: 'control', value: 'control-value' },
257+
{ key: 'treatment', value: 'treatment-value' },
258+
],
259+
rules: [{ conditions: [{ attribute: 'userId', operator: 'MATCHES', value: '.*' }] }],
260+
})),
261+
};
262+
263+
const originalJson = JSON.stringify(largeConfig);
264+
const compressedData = LZString.compress(originalJson);
265+
266+
// Verify compression actually reduces size
267+
expect(compressedData.length).toBeLessThan(originalJson.length);
268+
269+
// Verify compression ratio is reasonable (should be significant for repetitive JSON)
270+
const compressionRatio = compressedData.length / originalJson.length;
271+
expect(compressionRatio).toBeLessThan(0.5); // At least 50% compression
272+
});
273+
});
274+
});

0 commit comments

Comments
 (0)