diff --git a/src/context.js b/src/context.js index c60760a..517dda4 100644 --- a/src/context.js +++ b/src/context.js @@ -14,6 +14,11 @@ export const INTERNAL_CONFIRMATIONS_KEY = 'experiments.confirmationsInternal'; export const DEFAULT_QUEUE_LIMIT = 50; +// Storage type constants +export const STORAGE_TYPE_USER = 'user'; +export const STORAGE_TYPE_SESSION = 'session'; +export const STORAGE_TYPE_NONE = 'none'; + /** * The EvolvContext provides functionality to manage data relating to the client state, or context in which the * variants will be applied. @@ -27,6 +32,86 @@ function EvolvContext(store) { let remoteContext; let localContext; let initialized = false; + let persistenceMapping = {}; // Maps keys to storage types + + // Storage utility functions + function getStorageKey(key) { + return 'evolv_' + (uid || 'default') + '_' + key; + } + + function saveToStorage(key, value, storageType, isLocal) { + if (!storageType || storageType === STORAGE_TYPE_NONE) return; + + const storageKey = getStorageKey(key); + const storageData = { + value: value, + isLocal: !!isLocal + }; + + try { + const serializedValue = JSON.stringify(storageData); + if (storageType === STORAGE_TYPE_USER && typeof localStorage !== 'undefined') { + localStorage.setItem(storageKey, serializedValue); + } else if (storageType === STORAGE_TYPE_SESSION && typeof sessionStorage !== 'undefined') { + sessionStorage.setItem(storageKey, serializedValue); + } + } catch (e) { + // Silently fail if storage is not available or quota exceeded + console.warn('Evolv: Failed to save to storage:', e.message); + } + } + + function loadFromStorage(key, storageType) { + if (!storageType || storageType === STORAGE_TYPE_NONE) return undefined; + + const storageKey = getStorageKey(key); + + try { + let storedValue; + if (storageType === STORAGE_TYPE_USER && typeof localStorage !== 'undefined') { + storedValue = localStorage.getItem(storageKey); + } else if (storageType === STORAGE_TYPE_SESSION && typeof sessionStorage !== 'undefined') { + storedValue = sessionStorage.getItem(storageKey); + } + + if (!storedValue) return undefined; + + return JSON.parse(storedValue); // Expected format: {value: actualValue, isLocal: boolean} + } catch (e) { + // Silently fail if storage is not available or value is not parseable + console.warn('Evolv: Failed to load from storage:', e.message); + return undefined; + } + } + + function removeFromStorage(key, storageType) { + if (!storageType || storageType === STORAGE_TYPE_NONE) return; + + const storageKey = getStorageKey(key); + + try { + if (storageType === STORAGE_TYPE_USER && typeof localStorage !== 'undefined') { + localStorage.removeItem(storageKey); + } else if (storageType === STORAGE_TYPE_SESSION && typeof sessionStorage !== 'undefined') { + sessionStorage.removeItem(storageKey); + } + } catch (e) { + // Silently fail if storage is not available + console.warn('Evolv: Failed to remove from storage:', e.message); + } + } + + function loadPersistedData() { + Object.keys(persistenceMapping).forEach(function(key) { + const storageType = persistenceMapping[key]; + const storageData = loadFromStorage(key, storageType); + + if (storageData !== undefined) { + const targetContext = storageData.isLocal ? localContext : remoteContext; + objects.setKeyToValue(key, storageData.value, targetContext); + } + }); + } /** * A unique identifier for the participant. @@ -60,6 +145,10 @@ function EvolvContext(store) { uid = _uid; remoteContext = _remoteContext ? objects.deepClone(_remoteContext) : {}; localContext = _localContext ? objects.deepClone(_localContext) : {}; + + // Load any persisted data + loadPersistedData(); + initialized = true; emit(this, CONTEXT_INITIALIZED, this.resolve()); }; @@ -100,6 +189,11 @@ function EvolvContext(store) { objects.setKeyToValue(key, value, context); + // Handle persistence if configured + if (persistenceMapping[key]) { + saveToStorage(key, value, persistenceMapping[key], local); + } + const updated = this.resolve(); if (typeof before === 'undefined') { emit(this, CONTEXT_VALUE_ADDED, key, value, local, updated); @@ -141,6 +235,13 @@ function EvolvContext(store) { context = remoteContext; } + // Handle persistence for all updated keys + Object.keys(flattened).forEach(function(key) { + if (persistenceMapping[key]) { + saveToStorage(key, flattened[key], persistenceMapping[key], local); + } + }); + const thisRef = this; const updated = this.resolve(); Object.keys(flattened).forEach(function(key) { @@ -163,11 +264,16 @@ function EvolvContext(store) { */ this.remove = function(key) { ensureInitialized(); - const local = objects.removeValueForKey(key, localContext); + const local = objects.removeValueForKey(key, localContext); const remote = objects.removeValueForKey(key, remoteContext); const removed = local || remote; if (removed) { + // Remove from persistent storage if key was removed + if (persistenceMapping[key]) { + removeFromStorage(key, persistenceMapping[key]); + } + const updated = this.resolve(); emit(this, CONTEXT_VALUE_REMOVED, key, !remote, updated); emit(this, CONTEXT_CHANGED, updated); @@ -230,7 +336,51 @@ function EvolvContext(store) { const newArray = combined.slice(combined.length - limit); return this.set(key, newArray, local); - } + }; + + /** + * Configures persistence for a specific key. + * + * @param {String} key The key to configure persistence for. + * @param {String} storageType The storage type: 'user' (localStorage), 'session' (sessionStorage), or 'none'. + */ + this.configurePersistence = function(key, storageType) { + if (!key || typeof key !== 'string') { + throw new Error('Evolv: Key must be a non-empty string'); + } + + if (![STORAGE_TYPE_USER, STORAGE_TYPE_SESSION, STORAGE_TYPE_NONE].includes(storageType)) { + throw new Error('Evolv: Storage type must be "user", "session", or "none"'); + } + + if (storageType === STORAGE_TYPE_NONE) { + // Remove persistence mapping + delete persistenceMapping[key]; + // Remove any existing stored data + removeFromStorage(key, STORAGE_TYPE_USER); + removeFromStorage(key, STORAGE_TYPE_SESSION); + } else { + // Set new persistence mapping + persistenceMapping[key] = storageType; + + // If the key already exists in context and context is initialized, save it with the new storage type + if (initialized) { + // Check if value exists in local context first, then remote + let currentValue, isLocal; + if (objects.hasKey(key, localContext)) { + currentValue = objects.getValueForKey(key, localContext); + isLocal = true; + } else if (objects.hasKey(key, remoteContext)) { + currentValue = objects.getValueForKey(key, remoteContext); + isLocal = false; + } + + if (currentValue !== undefined) { + saveToStorage(key, currentValue, storageType, isLocal); + } + } + } + }; } export default EvolvContext; diff --git a/src/tests/context.test.js b/src/tests/context.test.js index 5f114f3..45246d9 100644 --- a/src/tests/context.test.js +++ b/src/tests/context.test.js @@ -1,5 +1,5 @@ import chai from 'chai'; -import Context, { CONTEXT_VALUE_ADDED, CONTEXT_VALUE_CHANGED } from '../context.js'; +import Context, { CONTEXT_VALUE_ADDED, CONTEXT_VALUE_CHANGED, STORAGE_TYPE_USER, STORAGE_TYPE_SESSION, STORAGE_TYPE_NONE } from '../context.js'; import Store from '../store.js'; import { waitFor } from '../waitforit.js'; @@ -36,6 +36,135 @@ describe('context', () => { }); }); + describe('persistence', () => { + let context; + beforeEach(() => { + context = new Context(); + context.initialize('test-persistence', {}, {}); + // Clear any existing stored data + if (typeof localStorage !== 'undefined') { + localStorage.clear(); + } + if (typeof sessionStorage !== 'undefined') { + sessionStorage.clear(); + } + }); + + it('should configure persistence for a key', () => { + expect(() => context.configurePersistence('user.preference', STORAGE_TYPE_USER)).to.not.throw(); + expect(() => context.configurePersistence('session.data', STORAGE_TYPE_SESSION)).to.not.throw(); + expect(() => context.configurePersistence('temp.data', STORAGE_TYPE_NONE)).to.not.throw(); + }); + + it('should throw error for invalid key', () => { + expect(() => context.configurePersistence('', STORAGE_TYPE_USER)).to.throw('Evolv: Key must be a non-empty string'); + expect(() => context.configurePersistence(null, STORAGE_TYPE_USER)).to.throw('Evolv: Key must be a non-empty string'); + }); + + it('should throw error for invalid storage type', () => { + expect(() => context.configurePersistence('test.key', 'invalid')).to.throw('Evolv: Storage type must be "user", "session", or "none"'); + }); + + it('should save and retrieve data with persistence disabled by default', () => { + context.set('test.key', 'test-value'); + expect(context.get('test.key')).to.equal('test-value'); + + // Create new context instance to simulate page reload + const newContext = new Context(); + newContext.initialize('test-persistence', {}, {}); + expect(newContext.get('test.key')).to.be.undefined; + }); + + // Note: These tests would work in a browser environment with localStorage/sessionStorage + // In Node.js test environment, they will just verify the API works without throwing errors + it('should handle storage operations gracefully in non-browser environment', () => { + expect(() => { + context.configurePersistence('user.data', STORAGE_TYPE_USER); + context.set('user.data', { id: 123, name: 'Test User' }); + }).to.not.throw(); + + expect(() => { + context.configurePersistence('session.data', STORAGE_TYPE_SESSION); + context.set('session.data', [1, 2, 3]); + }).to.not.throw(); + }); + + it('should remove persistence configuration when set to none', () => { + context.configurePersistence('test.key', STORAGE_TYPE_USER); + context.set('test.key', 'value'); + + // Remove persistence + context.configurePersistence('test.key', STORAGE_TYPE_NONE); + + // This should work without throwing errors + expect(context.get('test.key')).to.equal('value'); + }); + + it('should persist local context values and restore them to local context', () => { + context.configurePersistence('local.key', STORAGE_TYPE_USER); + context.set('local.key', 'local-value', true); // Set as local + + expect(context.get('local.key')).to.equal('local-value'); + + // Verify the value was persisted and can be restored to local context + // In a browser environment, this would actually work with localStorage + // In Node.js, we just verify the API works without throwing errors + expect(() => { + const newContext = new Context(); + newContext.configurePersistence('local.key', STORAGE_TYPE_USER); + newContext.initialize('test-persistence', {}, {}); + }).to.not.throw(); + }); + + it('should restore local and remote values to correct contexts with mock storage', () => { + // Mock localStorage for this test + const mockStorage = {}; + const originalLocalStorage = global.localStorage; + + global.localStorage = { + setItem: (key, value) => { mockStorage[key] = value; }, + getItem: (key) => mockStorage[key] || null, + removeItem: (key) => { delete mockStorage[key]; }, + clear: () => { for (let key in mockStorage) delete mockStorage[key]; } + }; + + try { + const ctx1 = new Context(); + ctx1.initialize('test-user', {}, {}); + + // Configure persistence for both local and remote values + ctx1.configurePersistence('remote.value', STORAGE_TYPE_USER); + ctx1.configurePersistence('local.value', STORAGE_TYPE_USER); + + // Set values in different contexts + ctx1.set('remote.value', 'remote-data', false); // remote context + ctx1.set('local.value', 'local-data', true); // local context + + // Create new context to simulate page reload + const ctx2 = new Context(); + ctx2.configurePersistence('remote.value', STORAGE_TYPE_USER); + ctx2.configurePersistence('local.value', STORAGE_TYPE_USER); + ctx2.initialize('test-user', {}, {}); + + // Verify values are restored to correct contexts + expect(ctx2.get('remote.value')).to.equal('remote-data'); + expect(ctx2.get('local.value')).to.equal('local-data'); + + // Verify they're in the correct underlying contexts + expect(ctx2.remoteContext.remote.value).to.equal('remote-data'); + expect(ctx2.localContext.local.value).to.equal('local-data'); + + // Local value should not be in remote context and vice versa + expect(ctx2.remoteContext.local).to.be.undefined; + expect(ctx2.localContext.remote).to.be.undefined; + + } finally { + // Restore original localStorage + global.localStorage = originalLocalStorage; + } + }); + }); + describe('update', () => { const options = { version: 1, diff --git a/src/types.d.ts b/src/types.d.ts index d6f684f..2436911 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -157,6 +157,8 @@ export interface WebRemoteContext extends RemoteContext { export interface LocalContext extends Record {} +export type StorageType = 'user' | 'session' | 'none'; + export class EvolvContext { uid: string; remoteContext: RemoteContext | WebRemoteContext; @@ -171,6 +173,7 @@ export class EvolvContext { get(key: string): T; contains(key: string): boolean; pushToArray(key: string, value: any, local?: boolean, limit?: number): boolean; + configurePersistence(key: string, storageType: StorageType): void; } export default EvolvClient;