Skip to content

Commit b17b5f5

Browse files
committed
Merge main into lr/ls-less - resolved conflicts in package.json and local-storage-engine.ts
2 parents e6aaabc + 73c64b1 commit b17b5f5

File tree

6 files changed

+514
-256
lines changed

6 files changed

+514
-256
lines changed

src/isolatable-hybrid.store.spec.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
IsolatableHybridConfigurationStore,
33
ServingStoreUpdateStrategy,
44
} from './isolatable-hybrid.store';
5+
import { StorageFullUnableToWrite } from './string-valued.store';
56

67
describe('IsolatableHybridConfigurationStore', () => {
78
const syncStoreMock = {
@@ -130,4 +131,49 @@ describe('IsolatableHybridConfigurationStore', () => {
130131
});
131132
},
132133
);
134+
135+
describe('StorageFullUnableToWrite exception handling', () => {
136+
const store = new IsolatableHybridConfigurationStore(syncStoreMock, asyncStoreMock, 'always');
137+
138+
beforeEach(() => {
139+
jest.resetAllMocks();
140+
});
141+
142+
it('should continue operating even when async store fails with StorageFullUnableToWrite', async () => {
143+
const entries = { key1: 'value1' };
144+
asyncStoreMock.isInitialized.mockReturnValue(true);
145+
asyncStoreMock.isExpired.mockReturnValue(true);
146+
asyncStoreMock.setEntries.mockRejectedValue(new StorageFullUnableToWrite());
147+
syncStoreMock.setEntries.mockResolvedValue(undefined);
148+
syncStoreMock.getKeys.mockReturnValue([]);
149+
150+
// Should not throw - be resilient to async store failures
151+
await expect(store.setEntries(entries)).resolves.not.toThrow();
152+
153+
// Sync store should still be updated for resilience
154+
expect(syncStoreMock.setEntries).toHaveBeenCalledWith(entries);
155+
});
156+
157+
it('should handle StorageFullUnableToWrite during initialization', async () => {
158+
asyncStoreMock.isInitialized.mockReturnValue(false);
159+
asyncStoreMock.setEntries.mockRejectedValue(new StorageFullUnableToWrite());
160+
161+
await expect(store.init()).resolves.not.toThrow();
162+
});
163+
164+
it('should handle async store failures gracefully', async () => {
165+
const entries = { key1: 'value1' };
166+
asyncStoreMock.isInitialized.mockReturnValue(true);
167+
asyncStoreMock.isExpired.mockReturnValue(true);
168+
asyncStoreMock.setEntries.mockRejectedValue(new StorageFullUnableToWrite());
169+
syncStoreMock.setEntries.mockResolvedValue(undefined);
170+
syncStoreMock.getKeys.mockReturnValue([]);
171+
172+
// Should not throw even if async store fails
173+
await expect(store.setEntries(entries)).resolves.not.toThrow();
174+
175+
expect(asyncStoreMock.setEntries).toHaveBeenCalledWith(entries);
176+
expect(syncStoreMock.setEntries).toHaveBeenCalledWith(entries);
177+
});
178+
});
133179
});

src/isolatable-hybrid.store.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import { HybridConfigurationStore, IAsyncStore, ISyncStore } from '@eppo/js-client-sdk-common';
1+
import {
2+
applicationLogger,
3+
HybridConfigurationStore,
4+
IAsyncStore,
5+
ISyncStore,
6+
} from '@eppo/js-client-sdk-common';
27

38
export type ServingStoreUpdateStrategy = 'always' | 'expired' | 'empty';
49

@@ -21,8 +26,12 @@ export class IsolatableHybridConfigurationStore<T> extends HybridConfigurationSt
2126
/** @Override */
2227
public async setEntries(entries: Record<string, T>): Promise<boolean> {
2328
if (this.persistentStore) {
24-
// always update persistent store
25-
await this.persistentStore.setEntries(entries);
29+
try {
30+
// always update persistent store
31+
await this.persistentStore.setEntries(entries);
32+
} catch (e) {
33+
applicationLogger.warn(`Failed to setEntries on persistent store: ${e}`);
34+
}
2635
}
2736

2837
const persistentStoreIsExpired =

src/local-storage-engine.ts

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import * as LZString from 'lz-string';
22

33
import { CONFIGURATION_KEY, META_KEY } from './storage-key-constants';
4-
import { IStringStorageEngine } from './string-valued.store';
4+
import {
5+
IStringStorageEngine,
6+
StorageFullUnableToWrite,
7+
LocalStorageUnknownFailure,
8+
} from './string-valued.store';
59

610
interface EppoGlobalMeta {
711
migratedAt?: number;
@@ -52,15 +56,53 @@ export class LocalStorageEngine implements IStringStorageEngine {
5256
return this.localStorage.getItem(this.metaKey);
5357
};
5458

59+
/**
60+
* @throws StorageFullUnableToWrite
61+
* @throws LocalStorageUnknownFailure
62+
*/
5563
public setContentsJsonString = async (configurationJsonString: string): Promise<void> => {
5664
const compressed = LZString.compressToBase64(configurationJsonString);
57-
this.localStorage.setItem(this.contentsKey, compressed);
65+
this.safeWrite(this.contentsKey, compressed);
5866
};
5967

68+
/**
69+
* @throws StorageFullUnableToWrite
70+
* @throws LocalStorageUnknownFailure
71+
*/
6072
public setMetaJsonString = async (metaJsonString: string): Promise<void> => {
61-
this.localStorage.setItem(this.metaKey, metaJsonString);
73+
this.safeWrite(this.metaKey, metaJsonString);
6274
};
6375

76+
/**
77+
* @throws StorageFullUnableToWrite
78+
* @throws LocalStorageUnknownFailure
79+
*/
80+
private safeWrite(key: string, value: string): void {
81+
try {
82+
this.localStorage.setItem(key, value);
83+
} catch (error) {
84+
if (error instanceof DOMException) {
85+
// Check for quota exceeded error
86+
if (error.code === DOMException.QUOTA_EXCEEDED_ERR || error.name === 'QuotaExceededError') {
87+
try {
88+
this.clear();
89+
// Retry setting the item after clearing
90+
this.localStorage.setItem(key, value);
91+
return;
92+
} catch {
93+
throw new StorageFullUnableToWrite();
94+
}
95+
}
96+
}
97+
// For any other error, wrap it in our custom exception
98+
const errorMessage = error instanceof Error ? error.message : String(error);
99+
throw new LocalStorageUnknownFailure(
100+
`Failed to write to localStorage: ${errorMessage}`,
101+
error instanceof Error ? error : (error as Error),
102+
);
103+
}
104+
}
105+
64106
private ensureCompressionMigration(): void {
65107
const globalMeta = this.getGlobalMeta();
66108

@@ -115,4 +157,21 @@ export class LocalStorageEngine implements IStringStorageEngine {
115157
private setGlobalMeta(meta: EppoGlobalMeta): void {
116158
this.localStorage.setItem(LocalStorageEngine.GLOBAL_META_KEY, JSON.stringify(meta));
117159
}
160+
161+
public clear(): void {
162+
const keysToDelete: string[] = [];
163+
164+
// Collect all keys that start with 'eppo-configuration'
165+
for (let i = 0; i < this.localStorage.length; i++) {
166+
const key = this.localStorage.key(i);
167+
if (key?.startsWith(CONFIGURATION_KEY)) {
168+
keysToDelete.push(key);
169+
}
170+
}
171+
172+
// Delete collected keys
173+
keysToDelete.forEach((key) => {
174+
this.localStorage.removeItem(key);
175+
});
176+
}
118177
}

src/local-storage.store.spec.ts

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
*/
44

55
import { LocalStorageEngine } from './local-storage-engine';
6-
import { StringValuedAsyncStore } from './string-valued.store';
6+
import { StringValuedAsyncStore, StorageFullUnableToWrite, LocalStorageUnknownFailure } from './string-valued.store';
77

88
describe('LocalStorageStore', () => {
99
// Note: window.localStorage is mocked for the node environment via the jsdom jest environment
@@ -80,4 +80,105 @@ describe('LocalStorageStore', () => {
8080
expect(await storeB.entries()).toEqual({ theKey: 'B' });
8181
expect(await storeB.isExpired()).toBe(false);
8282
});
83+
84+
describe('clear method', () => {
85+
it('should clear all eppo-configuration keys', () => {
86+
window.localStorage.setItem('eppo-configuration-test', 'value1');
87+
window.localStorage.setItem('eppo-configuration-other', 'value2');
88+
window.localStorage.setItem('other-key', 'value3');
89+
90+
localStorageEngine.clear();
91+
92+
expect(window.localStorage.getItem('eppo-configuration-test')).toBeNull();
93+
expect(window.localStorage.getItem('eppo-configuration-other')).toBeNull();
94+
expect(window.localStorage.getItem('other-key')).toBe('value3');
95+
});
96+
97+
it('should handle empty storage', () => {
98+
window.localStorage.clear();
99+
expect(() => localStorageEngine.clear()).not.toThrow();
100+
});
101+
});
102+
103+
describe('StorageFullUnableToWrite exception handling', () => {
104+
let mockLocalStorage: Storage;
105+
let localStorageEngineWithMock: LocalStorageEngine;
106+
107+
beforeEach(() => {
108+
mockLocalStorage = {
109+
getItem: jest.fn(),
110+
setItem: jest.fn(),
111+
removeItem: jest.fn(),
112+
clear: jest.fn(),
113+
length: 0,
114+
key: jest.fn(),
115+
};
116+
localStorageEngineWithMock = new LocalStorageEngine(mockLocalStorage, 'test');
117+
});
118+
119+
it('should throw StorageFullUnableToWrite when setContentsJsonString fails after clear and retry', async () => {
120+
const quotaError = new DOMException('QuotaExceededError', 'QuotaExceededError');
121+
Object.defineProperty(quotaError, 'code', { value: DOMException.QUOTA_EXCEEDED_ERR });
122+
123+
(mockLocalStorage.setItem as jest.Mock).mockImplementation(() => {
124+
throw quotaError;
125+
});
126+
(mockLocalStorage.key as jest.Mock).mockReturnValue(null);
127+
(mockLocalStorage.length as any) = 0;
128+
129+
await expect(
130+
localStorageEngineWithMock.setContentsJsonString('test-config')
131+
).rejects.toThrow(StorageFullUnableToWrite);
132+
});
133+
134+
it('should throw StorageFullUnableToWrite when setMetaJsonString fails after clear and retry', async () => {
135+
const quotaError = new DOMException('QuotaExceededError', 'QuotaExceededError');
136+
Object.defineProperty(quotaError, 'code', { value: DOMException.QUOTA_EXCEEDED_ERR });
137+
138+
(mockLocalStorage.setItem as jest.Mock).mockImplementation(() => {
139+
throw quotaError;
140+
});
141+
(mockLocalStorage.key as jest.Mock).mockReturnValue(null);
142+
(mockLocalStorage.length as any) = 0;
143+
144+
await expect(
145+
localStorageEngineWithMock.setMetaJsonString('test-meta')
146+
).rejects.toThrow(StorageFullUnableToWrite);
147+
});
148+
149+
it('should succeed after clearing when retry works', async () => {
150+
const quotaError = new DOMException('QuotaExceededError', 'QuotaExceededError');
151+
Object.defineProperty(quotaError, 'code', { value: DOMException.QUOTA_EXCEEDED_ERR });
152+
153+
let callCount = 0;
154+
(mockLocalStorage.setItem as jest.Mock).mockImplementation(() => {
155+
callCount++;
156+
if (callCount === 1) {
157+
throw quotaError;
158+
}
159+
// Second call succeeds
160+
});
161+
(mockLocalStorage.key as jest.Mock).mockReturnValue('eppo-configuration-old');
162+
(mockLocalStorage.length as any) = 1;
163+
164+
await expect(
165+
localStorageEngineWithMock.setContentsJsonString('test-config')
166+
).resolves.not.toThrow();
167+
168+
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith('eppo-configuration-old');
169+
expect(mockLocalStorage.setItem).toHaveBeenCalledTimes(2);
170+
});
171+
172+
it('should throw LocalStorageUnknownFailure for non-quota errors', async () => {
173+
const otherError = new Error('Some other error');
174+
(mockLocalStorage.setItem as jest.Mock).mockImplementation(() => {
175+
throw otherError;
176+
});
177+
178+
const error = await localStorageEngineWithMock.setContentsJsonString('test-config').catch(e => e);
179+
expect(error).toBeInstanceOf(LocalStorageUnknownFailure);
180+
expect(error.originalError).toBe(otherError);
181+
expect(error.message).toContain('Some other error');
182+
});
183+
});
83184
});

src/string-valued.store.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,22 @@
11
import { IAsyncStore } from '@eppo/js-client-sdk-common';
22

3+
export class StorageFullUnableToWrite extends Error {
4+
constructor(message = 'Storage is full and unable to write.') {
5+
super(message);
6+
this.name = 'StorageFullUnableToWrite';
7+
}
8+
}
9+
10+
export class LocalStorageUnknownFailure extends Error {
11+
constructor(
12+
message = 'Local storage operation failed for unknown reason.',
13+
public originalError?: Error,
14+
) {
15+
super(message);
16+
this.name = 'LocalStorageUnknownFailure';
17+
}
18+
}
19+
320
/**
421
* Interface for a string-based storage engine which stores string contents as well as metadata
522
* about those contents (e.g., when it was last updated)
@@ -49,6 +66,10 @@ export class StringValuedAsyncStore<T> implements IAsyncStore<T> {
4966
return contentsJsonString ? JSON.parse(contentsJsonString) : {};
5067
}
5168

69+
/**
70+
* @param entries
71+
* @throws StorageFullUnableToWrite
72+
*/
5273
public async setEntries(entries: Record<string, T>): Promise<void> {
5374
// String-based storage takes a dictionary of key-value string pairs,
5475
// so we write the entire configuration and meta to a single location for each.

0 commit comments

Comments
 (0)