-
Notifications
You must be signed in to change notification settings - Fork 2
feat: Add context persistence functionality #199
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 getStoragePrefix() { | ||
| return 'evolv_' + (uid || 'default') + '_'; | ||
| } | ||
|
|
||
| function saveToStorage(key, value, storageType, isLocal) { | ||
tfoster marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| if (!storageType || storageType === STORAGE_TYPE_NONE) return; | ||
|
|
||
| const storageKey = getStoragePrefix() + key; | ||
| const storageData = { | ||
| value: value, | ||
| isLocal: !!isLocal | ||
| }; | ||
| const serializedValue = JSON.stringify(storageData); | ||
|
|
||
| try { | ||
tfoster marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| 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 = getStoragePrefix() + 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 = getStoragePrefix() + 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.setPersistence = function(key, storageType) { | ||
tfoster marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| 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) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Minor potential bug -- you can configure a key for sessionStorage and then for localStorage, and it will persist them in both. |
||
| // 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; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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.setPersistence('user.preference', STORAGE_TYPE_USER)).to.not.throw(); | ||
tfoster marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| expect(() => context.setPersistence('session.data', STORAGE_TYPE_SESSION)).to.not.throw(); | ||
| expect(() => context.setPersistence('temp.data', STORAGE_TYPE_NONE)).to.not.throw(); | ||
| }); | ||
|
|
||
| it('should throw error for invalid key', () => { | ||
| expect(() => context.setPersistence('', STORAGE_TYPE_USER)).to.throw('Evolv: Key must be a non-empty string'); | ||
| expect(() => context.setPersistence(null, STORAGE_TYPE_USER)).to.throw('Evolv: Key must be a non-empty string'); | ||
| }); | ||
|
|
||
| it('should throw error for invalid storage type', () => { | ||
| expect(() => context.setPersistence('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.setPersistence('user.data', STORAGE_TYPE_USER); | ||
| context.set('user.data', { id: 123, name: 'Test User' }); | ||
| }).to.not.throw(); | ||
|
|
||
| expect(() => { | ||
| context.setPersistence('session.data', STORAGE_TYPE_SESSION); | ||
| context.set('session.data', [1, 2, 3]); | ||
| }).to.not.throw(); | ||
| }); | ||
|
|
||
| it('should remove persistence configuration when set to none', () => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Better title is 'should not clear the context when persistence removed'? |
||
| context.setPersistence('test.key', STORAGE_TYPE_USER); | ||
| context.set('test.key', 'value'); | ||
|
|
||
| // Remove persistence | ||
| context.setPersistence('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.setPersistence('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.setPersistence('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.setPersistence('remote.value', STORAGE_TYPE_USER); | ||
| ctx1.setPersistence('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.setPersistence('remote.value', STORAGE_TYPE_USER); | ||
| ctx2.setPersistence('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; | ||
| } | ||
| }); | ||
| }); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Would love to see some ordering tests -
And Removing from persistence |
||
|
|
||
| describe('update', () => { | ||
| const options = { | ||
| version: 1, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why not make the function resolve the whole name for you. Example: