Skip to content

Commit

Permalink
feat: Integrate new AssignmentCache API
Browse files Browse the repository at this point in the history
  • Loading branch information
felipecsl committed Jun 13, 2024
1 parent 5b8af87 commit 2edad42
Show file tree
Hide file tree
Showing 9 changed files with 175 additions and 91 deletions.
24 changes: 24 additions & 0 deletions src/cache/chrome-storage-async-map.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { AsyncMap } from '@eppo/js-client-sdk-common';

Check failure on line 1 in src/cache/chrome-storage-async-map.ts

View workflow job for this annotation

GitHub Actions / typecheck

Module '"@eppo/js-client-sdk-common"' has no exported member 'AsyncMap'.

Check failure on line 1 in src/cache/chrome-storage-async-map.ts

View workflow job for this annotation

GitHub Actions / lint-test-sdk

Module '"@eppo/js-client-sdk-common"' has no exported member 'AsyncMap'.

/** Chrome storage-backed {@link AsyncMap}. */
export default class ChromeStorageAsyncMap implements AsyncMap<string, string> {
constructor(private readonly storage: chrome.storage.StorageArea) {}

async has(key: string): Promise<boolean> {
const value = await this.get(key);
return !!value;
}

async get(key: string): Promise<string | undefined> {
const subset = await this.storage.get(key);
return subset?.[key] ?? undefined;
}

async entries(): Promise<{ [p: string]: string }> {
return await this.storage.get(null);
}

async set(key: string, value: string) {
await this.storage.set({ [key]: value });
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,23 +8,23 @@ describe('LocalStorageAssignmentCache', () => {
it('typical behavior', () => {
const cache = new LocalStorageAssignmentCache('test');
expect(
cache.hasLoggedAssignment({
cache.has({
subjectKey: 'subject-1',
flagKey: 'flag-1',
allocationKey: 'allocation-1',
variationKey: 'control',
}),
).toEqual(false);

cache.setLastLoggedAssignment({
cache.set({
subjectKey: 'subject-1',
flagKey: 'flag-1',
allocationKey: 'allocation-1',
variationKey: 'control',
});

expect(
cache.hasLoggedAssignment({
cache.has({
subjectKey: 'subject-1',
flagKey: 'flag-1',
allocationKey: 'allocation-1',
Expand All @@ -33,15 +33,15 @@ describe('LocalStorageAssignmentCache', () => {
).toEqual(true); // this key has been logged

// change variation
cache.setLastLoggedAssignment({
cache.set({
subjectKey: 'subject-1',
flagKey: 'flag-1',
allocationKey: 'allocation-1',
variationKey: 'variant',
});

expect(
cache.hasLoggedAssignment({
cache.has({
subjectKey: 'subject-1',
flagKey: 'flag-1',
allocationKey: 'allocation-1',
Expand All @@ -62,53 +62,53 @@ describe('LocalStorageAssignmentCache', () => {
allocationKey: 'allocation-1',
};

cacheA.setLastLoggedAssignment({
cacheA.set({
variationKey: 'variation-A',
...constantAssignmentProperties,
});

expect(
cacheA.hasLoggedAssignment({
cacheA.has({
variationKey: 'variation-A',
...constantAssignmentProperties,
}),
).toEqual(true);

expect(
cacheB.hasLoggedAssignment({
cacheB.has({
variationKey: 'variation-A',
...constantAssignmentProperties,
}),
).toEqual(false);

cacheB.setLastLoggedAssignment({
cacheB.set({
variationKey: 'variation-B',
...constantAssignmentProperties,
});

expect(
cacheA.hasLoggedAssignment({
cacheA.has({
variationKey: 'variation-A',
...constantAssignmentProperties,
}),
).toEqual(true);

expect(
cacheB.hasLoggedAssignment({
cacheB.has({
variationKey: 'variation-A',
...constantAssignmentProperties,
}),
).toEqual(false);

expect(
cacheA.hasLoggedAssignment({
cacheA.has({
variationKey: 'variation-B',
...constantAssignmentProperties,
}),
).toEqual(false);

expect(
cacheB.hasLoggedAssignment({
cacheB.has({
variationKey: 'variation-B',
...constantAssignmentProperties,
}),
Expand Down
103 changes: 103 additions & 0 deletions src/cache/local-storage-assignment-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { AbstractAssignmentCache } from '@eppo/js-client-sdk-common';

Check failure on line 1 in src/cache/local-storage-assignment-cache.ts

View workflow job for this annotation

GitHub Actions / typecheck

Module '"@eppo/js-client-sdk-common"' has no exported member 'AbstractAssignmentCache'.

Check failure on line 1 in src/cache/local-storage-assignment-cache.ts

View workflow job for this annotation

GitHub Actions / lint-test-sdk

Module '"@eppo/js-client-sdk-common"' has no exported member 'AbstractAssignmentCache'.

import { hasWindowLocalStorage } from '../configuration-factory';

export class LocalStorageAssignmentCache extends AbstractAssignmentCache<LocalStorageAssignmentShim> {
constructor(storageKeySuffix: string) {
super(new LocalStorageAssignmentShim(storageKeySuffix));
}
}

// noinspection JSUnusedGlobalSymbols (methods are used by common repository)
class LocalStorageAssignmentShim implements Map<string, string> {
private readonly localStorageKey: string;

public constructor(storageKeySuffix: string) {
const keySuffix = storageKeySuffix ? `-${storageKeySuffix}` : '';
this.localStorageKey = `eppo-assignment${keySuffix}`;
}

clear(): void {
this.getCache().clear();
}

delete(key: string): boolean {
return this.getCache().delete(key);
}

forEach(
callbackfn: (value: string, key: string, map: Map<string, string>) => void,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
thisArg?: any,
): void {
this.getCache().forEach(callbackfn, thisArg);
}

size: number;

entries(): IterableIterator<[string, string]> {
if (!hasWindowLocalStorage()) {
return [][Symbol.iterator]();
}
return this.getCache().entries();
}

keys(): IterableIterator<string> {
if (!hasWindowLocalStorage()) {
return [][Symbol.iterator]();
}
return this.getCache().keys();
}

values(): IterableIterator<string> {
if (!hasWindowLocalStorage()) {
return [][Symbol.iterator]();
}
return this.getCache().values();
}

[Symbol.iterator](): IterableIterator<[string, string]> {
if (!hasWindowLocalStorage()) {
return [][Symbol.iterator]();
}
return this.getCache()[Symbol.iterator]();
}

[Symbol.toStringTag]: string;

public has(key: string): boolean {
if (!hasWindowLocalStorage()) {
return false;
}

return this.getCache().has(key);
}

public get(key: string): string | undefined {
if (!hasWindowLocalStorage()) {
return undefined;
}

return this.getCache().get(key) ?? undefined;
}

public set(key: string, value: string): this {
if (!hasWindowLocalStorage()) {
return this;
}

const cache = this.getCache();
cache.set(key, value);
this.setCache(cache);
return this;
}

private getCache(): Map<string, string> {
const cache = window.localStorage.getItem(this.localStorageKey);
return cache ? new Map(JSON.parse(cache)) : new Map();
}

private setCache(cache: Map<string, string>) {
window.localStorage.setItem(this.localStorageKey, JSON.stringify(Array.from(cache.entries())));
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { IAsyncStore } from '@eppo/js-client-sdk-common';

import ChromeStorageAsyncMap from './cache/chrome-storage-async-map';
import { ChromeStorageEngine } from './chrome-storage-engine';
import { StringValuedAsyncStore } from './string-valued.store';

Expand Down Expand Up @@ -28,7 +29,10 @@ describe('ChromeStorageStore', () => {
const dummyKeySuffix = 'test';
const fakeStoreContentsKey = `eppo-configuration-${dummyKeySuffix}`;
const fakeStoreMetaKey = `eppo-configuration-meta-${dummyKeySuffix}`;
const chromeStorageEngine = new ChromeStorageEngine(mockChromeStorage, dummyKeySuffix);
const chromeStorageEngine = new ChromeStorageEngine(
new ChromeStorageAsyncMap(mockChromeStorage),
dummyKeySuffix,
);
let chromeStore: IAsyncStore<unknown>;
let now: number;

Expand Down Expand Up @@ -91,9 +95,15 @@ describe('ChromeStorageStore', () => {
});

it('stores independently based on key suffix', async () => {
const chromeStorageEngineA = new ChromeStorageEngine(mockChromeStorage, 'A');
const chromeStorageEngineA = new ChromeStorageEngine(
new ChromeStorageAsyncMap(mockChromeStorage),
'A',
);
const chromeStoreA = new StringValuedAsyncStore(chromeStorageEngineA, 1);
const chromeStorageEngineB = new ChromeStorageEngine(mockChromeStorage, 'B');
const chromeStorageEngineB = new ChromeStorageEngine(
new ChromeStorageAsyncMap(mockChromeStorage),
'B',
);
const chromeStoreB = new StringValuedAsyncStore(chromeStorageEngineB, 1);

await chromeStoreA.setEntries({ theKey: 'A' });
Expand Down
19 changes: 8 additions & 11 deletions src/chrome-storage-engine.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import ChromeStorageAsyncMap from './cache/chrome-storage-async-map';
import { CONFIGURATION_KEY, META_KEY } from './storage-key-constants';
import { IStringStorageEngine } from './string-valued.store';

Expand All @@ -15,31 +16,27 @@ export class ChromeStorageEngine implements IStringStorageEngine {
private readonly contentsKey;
private readonly metaKey;

public constructor(private storageArea: chrome.storage.StorageArea, storageKeySuffix: string) {
public constructor(private storageMap: ChromeStorageAsyncMap, storageKeySuffix: string) {
const keySuffix = storageKeySuffix ? `-${storageKeySuffix}` : '';
this.contentsKey = CONFIGURATION_KEY + keySuffix;
this.metaKey = META_KEY + keySuffix;
}

public getContentsJsonString = async (): Promise<string | null> => {
const storageSubset = await this.storageArea.get(this.contentsKey);
return storageSubset?.[this.contentsKey] ?? null;
const item = await this.storageMap.get(this.contentsKey);
return item ?? null;
};

public getMetaJsonString = async (): Promise<string | null> => {
const storageSubset = await this.storageArea.get(this.metaKey);
return storageSubset?.[this.metaKey] ?? null;
const item = await this.storageMap.get(this.metaKey);
return item ?? null;
};

public setContentsJsonString = async (configurationJsonString: string): Promise<void> => {
await this.storageArea.set({
[this.contentsKey]: configurationJsonString,
});
await this.storageMap.set(this.contentsKey, configurationJsonString);
};

public setMetaJsonString = async (metaJsonString: string): Promise<void> => {
await this.storageArea.set({
[this.metaKey]: metaJsonString,
});
await this.storageMap.set(this.metaKey, metaJsonString);
};
}
6 changes: 5 additions & 1 deletion src/configuration-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
MemoryStore,
} from '@eppo/js-client-sdk-common';

import ChromeStorageAsyncMap from './cache/chrome-storage-async-map';
import { ChromeStorageEngine } from './chrome-storage-engine';
import {
IsolatableHybridConfigurationStore,
Expand Down Expand Up @@ -50,7 +51,10 @@ export function configurationStorageFactory(
);
} else if (hasChromeStorage && chromeStorage) {
// Chrome storage is available, use it as a fallback
const chromeStorageEngine = new ChromeStorageEngine(chromeStorage, storageKeySuffix ?? '');
const chromeStorageEngine = new ChromeStorageEngine(
new ChromeStorageAsyncMap(chromeStorage),
storageKeySuffix ?? '',
);
return new IsolatableHybridConfigurationStore(
new MemoryStore<Flag>(),
new StringValuedAsyncStore<Flag>(chromeStorageEngine, maxAgeSeconds),
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,13 @@ import {
AttributeType,
} from '@eppo/js-client-sdk-common';

import { LocalStorageAssignmentCache } from './cache/local-storage-assignment-cache';
import {
configurationStorageFactory,
hasChromeStorage,
hasWindowLocalStorage,
} from './configuration-factory';
import { ServingStoreUpdateStrategy } from './isolatable-hybrid.store';
import { LocalStorageAssignmentCache } from './local-storage-assignment-cache';
import { sdkName, sdkVersion } from './sdk-data';

/**
Expand Down
16 changes: 8 additions & 8 deletions src/isolatable-hybrid.store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,23 +11,23 @@ export type ServingStoreUpdateStrategy = 'always' | 'expired' | 'empty';
*/
export class IsolatableHybridConfigurationStore<T> extends HybridConfigurationStore<T> {
constructor(
servingStore: ISyncStore<T>,
persistentStore: IAsyncStore<T> | null,
private readonly _servingStore: ISyncStore<T>,
private readonly _persistentStore: IAsyncStore<T> | null,
private servingStoreUpdateStrategy: ServingStoreUpdateStrategy = 'always',
) {
super(servingStore, persistentStore);
super(_servingStore, _persistentStore);
}

/** @Override */
public async setEntries(entries: Record<string, T>): Promise<void> {
if (this.persistentStore) {
if (this._persistentStore) {
// always update persistent store
await this.persistentStore.setEntries(entries);
await this._persistentStore.setEntries(entries);
}

const persistentStoreIsExpired =
!this.persistentStore || (await this.persistentStore.isExpired());
const servingStoreIsEmpty = !this.servingStore.getKeys()?.length;
!this._persistentStore || (await this._persistentStore.isExpired());
const servingStoreIsEmpty = !this._servingStore.getKeys()?.length;

// Update the serving store based on the update strategy:
// "always" - always update the serving store
Expand All @@ -39,7 +39,7 @@ export class IsolatableHybridConfigurationStore<T> extends HybridConfigurationSt
(persistentStoreIsExpired && servingStoreIsEmpty);

if (updateServingStore) {
this.servingStore.setEntries(entries);
this._servingStore.setEntries(entries);
}
}
}
Loading

0 comments on commit 2edad42

Please sign in to comment.