From 352ee82cda90bd20a5149363ce2a5e3e57ac0f81 Mon Sep 17 00:00:00 2001
From: Oscar Bazaldua <511911+oscb@users.noreply.github.com>
Date: Thu, 20 Jul 2023 09:25:52 -0700
Subject: [PATCH 1/5] feat: storage options, change priority or use custom

---
 .changeset/smooth-seahorses-unite.md          |   5 +
 .../analytics/__tests__/integration.test.ts   |  28 ++
 packages/browser/src/core/analytics/index.ts  |  95 +++-
 .../storage/__tests__/cookieStorage.test.ts   |  76 ++++
 .../storage/__tests__/localStorage.test.ts    |  70 +++
 .../__tests__/prioritizedListStorage.test.ts  | 159 +++++++
 .../core/storage/__tests__/test-helpers.ts    |  19 +
 .../browser/src/core/storage/cookieStorage.ts |  97 +++++
 packages/browser/src/core/storage/index.ts    |  64 +++
 .../browser/src/core/storage/localStorage.ts  |  60 +++
 .../browser/src/core/storage/memoryStorage.ts |  30 ++
 packages/browser/src/core/storage/settings.ts |  28 ++
 packages/browser/src/core/storage/types.ts    |  93 ++++
 .../src/core/storage/universalStorage.ts      |  55 +++
 .../src/core/user/__tests__/index.test.ts     | 358 ++++-----------
 packages/browser/src/core/user/index.ts       | 409 ++++--------------
 .../src/plugins/segmentio/normalize.ts        |   7 +-
 17 files changed, 1037 insertions(+), 616 deletions(-)
 create mode 100644 .changeset/smooth-seahorses-unite.md
 create mode 100644 packages/browser/src/core/storage/__tests__/cookieStorage.test.ts
 create mode 100644 packages/browser/src/core/storage/__tests__/localStorage.test.ts
 create mode 100644 packages/browser/src/core/storage/__tests__/prioritizedListStorage.test.ts
 create mode 100644 packages/browser/src/core/storage/__tests__/test-helpers.ts
 create mode 100644 packages/browser/src/core/storage/cookieStorage.ts
 create mode 100644 packages/browser/src/core/storage/index.ts
 create mode 100644 packages/browser/src/core/storage/localStorage.ts
 create mode 100644 packages/browser/src/core/storage/memoryStorage.ts
 create mode 100644 packages/browser/src/core/storage/settings.ts
 create mode 100644 packages/browser/src/core/storage/types.ts
 create mode 100644 packages/browser/src/core/storage/universalStorage.ts

diff --git a/.changeset/smooth-seahorses-unite.md b/.changeset/smooth-seahorses-unite.md
new file mode 100644
index 000000000..ae8fa2dc9
--- /dev/null
+++ b/.changeset/smooth-seahorses-unite.md
@@ -0,0 +1,5 @@
+---
+'@segment/analytics-next': minor
+---
+
+Adds storage option in analytics client to specify priority of storage (e.g use cookies over localstorage) or use a custom implementation
diff --git a/packages/browser/src/core/analytics/__tests__/integration.test.ts b/packages/browser/src/core/analytics/__tests__/integration.test.ts
index 64711a803..5e2045b15 100644
--- a/packages/browser/src/core/analytics/__tests__/integration.test.ts
+++ b/packages/browser/src/core/analytics/__tests__/integration.test.ts
@@ -7,7 +7,9 @@ import {
 import { Context } from '../../context'
 import { Plugin } from '../../plugin'
 import { EventQueue } from '../../queue/event-queue'
+import { StoreType } from '../../storage'
 import { Analytics } from '../index'
+import jar from 'js-cookie'
 import {
   TestAfterPlugin,
   TestBeforePlugin,
@@ -271,4 +273,30 @@ describe('Analytics', () => {
       expect(fn).toHaveBeenCalledTimes(1)
     })
   })
+
+  describe('storage', () => {
+    beforeEach(() => {
+      clearAjsBrowserStorage()
+    })
+
+    it('handles custom priority storage', async () => {
+      const setCookieSpy = jest.spyOn(jar, 'set')
+      const expected = 'CookieValue'
+      jar.set('ajs_anonymous_id', expected)
+      localStorage.setItem('ajs_anonymous_id', 'localStorageValue')
+
+      const analytics = new Analytics(
+        { writeKey: '' },
+        {
+          storage: [StoreType.Cookie, StoreType.LocalStorage, StoreType.Memory],
+        }
+      )
+
+      expect(analytics.user().anonymousId()).toEqual(expected)
+
+      analytics.user().id('known-user')
+      expect(analytics.user().id()).toEqual('known-user')
+      expect(setCookieSpy).toHaveBeenCalled()
+    })
+  })
 })
diff --git a/packages/browser/src/core/analytics/index.ts b/packages/browser/src/core/analytics/index.ts
index 0ffe4eb9b..d958e974f 100644
--- a/packages/browser/src/core/analytics/index.ts
+++ b/packages/browser/src/core/analytics/index.ts
@@ -24,15 +24,7 @@ import {
 } from '../events'
 import type { Plugin } from '../plugin'
 import { EventQueue } from '../queue/event-queue'
-import {
-  CookieOptions,
-  getAvailableStorageOptions,
-  Group,
-  ID,
-  UniversalStorage,
-  User,
-  UserOptions,
-} from '../user'
+import { Group, ID, User, UserOptions } from '../user'
 import autoBind from '../../lib/bind-all'
 import { PersistedPriorityQueue } from '../../lib/priority-queue/persisted'
 import type { LegacyDestination } from '../../plugins/ajs-destination'
@@ -50,6 +42,18 @@ import { getGlobal } from '../../lib/get-global'
 import { AnalyticsClassic, AnalyticsCore } from './interfaces'
 import { HighEntropyHint } from '../../lib/client-hints/interfaces'
 import type { LegacySettings } from '../../browser'
+import {
+  CookieOptions,
+  MemoryStorage,
+  UniversalStorage,
+  Storage,
+  StorageSettings,
+  StoreType,
+  applyCookieOptions,
+  initializeStorages,
+  isArrayOfStoreType,
+  isStorageObject,
+} from '../storage'
 
 const deprecationWarning =
   'This is being deprecated and will be not be available in future releases of Analytics JS'
@@ -93,6 +97,7 @@ export interface InitOptions {
   disableAutoISOConversion?: boolean
   initialPageview?: boolean
   cookie?: CookieOptions
+  storage?: StorageSettings
   user?: UserOptions
   group?: UserOptions
   integrations?: Integrations
@@ -133,9 +138,7 @@ export class Analytics
   private _group: Group
   private eventFactory: EventFactory
   private _debug = false
-  private _universalStorage: UniversalStorage<{
-    [k: string]: unknown
-  }>
+  private _universalStorage: Storage
 
   initialized = false
   integrations: Integrations
@@ -162,25 +165,33 @@ export class Analytics
         disablePersistance
       )
 
-    this._universalStorage = new UniversalStorage(
-      disablePersistance ? ['memory'] : ['localStorage', 'cookie', 'memory'],
-      getAvailableStorageOptions(cookieOptions)
+    const storageSetting = options?.storage
+    this._universalStorage = this.createStore(
+      disablePersistance,
+      storageSetting,
+      cookieOptions
     )
 
     this._user =
       user ??
       new User(
-        disablePersistance
-          ? { ...options?.user, persist: false }
-          : options?.user,
+        {
+          persist: !disablePersistance,
+          storage: options?.storage,
+          // Any User specific options override everything else
+          ...options?.user,
+        },
         cookieOptions
       ).load()
     this._group =
       group ??
       new Group(
-        disablePersistance
-          ? { ...options?.group, persist: false }
-          : options?.group,
+        {
+          persist: !disablePersistance,
+          storage: options?.storage,
+          // Any group specific options override everything else
+          ...options?.group,
+        },
         cookieOptions
       ).load()
     this.eventFactory = new EventFactory(this._user)
@@ -194,7 +205,47 @@ export class Analytics
     return this._user
   }
 
-  get storage(): UniversalStorage {
+  /**
+   * Creates the storage system based on the settings received
+   * @returns Storage
+   */
+  private createStore(
+    disablePersistance: boolean,
+    storageSetting: InitOptions['storage'],
+    cookieOptions?: CookieOptions | undefined
+  ): Storage {
+    // DisablePersistance option overrides all, no storage will be used outside of memory even if specified
+    if (disablePersistance) {
+      return new MemoryStorage()
+    } else {
+      if (storageSetting !== undefined && storageSetting !== null) {
+        if (isArrayOfStoreType(storageSetting)) {
+          // We will create the store with the priority for customer settings
+          return new UniversalStorage(
+            initializeStorages(
+              applyCookieOptions(storageSetting, cookieOptions)
+            )
+          )
+        } else if (isStorageObject(storageSetting)) {
+          // If it is an object we will use the customer provided storage
+          return storageSetting
+        }
+      }
+    }
+    // We default to our multi storage with priority
+    return new UniversalStorage(
+      initializeStorages([
+        StoreType.LocalStorage,
+        {
+          name: StoreType.Cookie,
+          settings: cookieOptions,
+        },
+        StoreType.Memory,
+      ])
+    )
+  }
+
+  get storage(): Storage {
     return this._universalStorage
   }
 
diff --git a/packages/browser/src/core/storage/__tests__/cookieStorage.test.ts b/packages/browser/src/core/storage/__tests__/cookieStorage.test.ts
new file mode 100644
index 000000000..da02beccc
--- /dev/null
+++ b/packages/browser/src/core/storage/__tests__/cookieStorage.test.ts
@@ -0,0 +1,76 @@
+import { CookieStorage } from '../cookieStorage'
+import jar from 'js-cookie'
+import { disableCookies } from './test-helpers'
+
+describe('cookieStorage', () => {
+  function clearCookies() {
+    document.cookie.split(';').forEach(function (c) {
+      document.cookie = c
+        .replace(/^ +/, '')
+        .replace(/=.*/, '=;expires=' + new Date().toUTCString() + ';path=/')
+    })
+  }
+
+  afterEach(() => {
+    clearCookies()
+  })
+
+  describe('#available', () => {
+    afterEach(() => {
+      jest.restoreAllMocks()
+    })
+
+    it('is available', () => {
+      const cookie = new CookieStorage()
+      expect(cookie.available).toBe(true)
+    })
+
+    it("is unavailable if can't write cookies", () => {
+      disableCookies()
+      const cookie = new CookieStorage()
+      expect(cookie.available).toBe(false)
+    })
+  })
+
+  describe('cookie options', () => {
+    it('should have default cookie options', () => {
+      const cookie = new CookieStorage()
+      expect(cookie['options'].domain).toBe(undefined)
+      expect(cookie['options'].maxage).toBe(365)
+      expect(cookie['options'].path).toBe('/')
+      expect(cookie['options'].sameSite).toBe('Lax')
+      expect(cookie['options'].secure).toBe(undefined)
+    })
+
+    it('should set options properly', () => {
+      const cookie = new CookieStorage({
+        domain: 'foo',
+        secure: true,
+        path: '/test',
+      })
+      expect(cookie['options'].domain).toBe('foo')
+      expect(cookie['options'].secure).toBe(true)
+      expect(cookie['options'].path).toBe('/test')
+      expect(cookie['options'].secure).toBe(true)
+    })
+
+    it('should pass options when creating cookie', () => {
+      const jarSpy = jest.spyOn(jar, 'set')
+      const cookie = new CookieStorage({
+        domain: 'foo',
+        secure: true,
+        path: '/test',
+      })
+
+      cookie.set('foo', 'bar')
+
+      expect(jarSpy).toHaveBeenCalledWith('foo', 'bar', {
+        domain: 'foo',
+        expires: 365,
+        path: '/test',
+        sameSite: 'Lax',
+        secure: true,
+      })
+    })
+  })
+})
diff --git a/packages/browser/src/core/storage/__tests__/localStorage.test.ts b/packages/browser/src/core/storage/__tests__/localStorage.test.ts
new file mode 100644
index 000000000..30c73a121
--- /dev/null
+++ b/packages/browser/src/core/storage/__tests__/localStorage.test.ts
@@ -0,0 +1,70 @@
+import { LocalStorage } from '../localStorage'
+
+describe('LocalStorage', function () {
+  let store: LocalStorage
+
+  beforeEach(() => {
+    jest.spyOn(console, 'warn').mockImplementation(() => {}) // silence console spam.
+    store = new LocalStorage()
+  })
+
+  afterEach(() => {
+    localStorage.clear()
+  })
+
+  describe('#get', function () {
+    it('should return null if localStorage throws an error (or does not exist)', function () {
+      const getItemSpy = jest
+        .spyOn(global.Storage.prototype, 'getItem')
+        .mockImplementationOnce(() => {
+          throw new Error('getItem fail.')
+        })
+      store.set('foo', 'some value')
+      expect(store.get('foo')).toBeNull()
+      expect(getItemSpy).toBeCalledTimes(1)
+    })
+
+    it('should not get an empty record', function () {
+      expect(store.get('abc')).toBe(null)
+    })
+
+    it('should get an existing record', function () {
+      store.set('x', { a: 'b' })
+      store.set('a', 'hello world')
+      store.set('b', '')
+      store.set('c', false)
+      store.set('d', null)
+      store.set('e', undefined)
+
+      expect(store.get('x')).toStrictEqual({ a: 'b' })
+      expect(store.get('a')).toBe('hello world')
+      expect(store.get('b')).toBe('')
+      expect(store.get('c')).toBe(false)
+      expect(store.get('d')).toBe(null)
+      expect(store.get('e')).toBe('undefined')
+    })
+  })
+
+  describe('#set', function () {
+    it('should be able to set a record', function () {
+      store.set('x', { a: 'b' })
+      expect(store.get('x')).toStrictEqual({ a: 'b' })
+    })
+
+    it('should catch localStorage quota exceeded errors', () => {
+      const val = 'x'.repeat(10 * 1024 * 1024)
+      store.set('foo', val)
+
+      expect(store.get('foo')).toBe(null)
+    })
+  })
+
+  describe('#clear', function () {
+    it('should be able to remove a record', function () {
+      store.set('x', { a: 'b' })
+      expect(store.get('x')).toStrictEqual({ a: 'b' })
+      store.clear('x')
+      expect(store.get('x')).toBe(null)
+    })
+  })
+})
diff --git a/packages/browser/src/core/storage/__tests__/prioritizedListStorage.test.ts b/packages/browser/src/core/storage/__tests__/prioritizedListStorage.test.ts
new file mode 100644
index 000000000..f05f5ad9d
--- /dev/null
+++ b/packages/browser/src/core/storage/__tests__/prioritizedListStorage.test.ts
@@ -0,0 +1,159 @@
+import jar from 'js-cookie'
+import { CookieStorage } from '../cookieStorage'
+import { LocalStorage } from '../localStorage'
+import { MemoryStorage } from '../memoryStorage'
+import { UniversalStorage } from '../universalStorage'
+describe('prioritizedListStorage', function () {
+  const defaultTargets = [
+    new CookieStorage(),
+    new LocalStorage(),
+    new MemoryStorage(),
+  ]
+  const getFromLS = (key: string) => JSON.parse(localStorage.getItem(key) ?? '')
+  beforeEach(function () {
+    clear()
+  })
+
+  function clear(): void {
+    document.cookie.split(';').forEach(function (c) {
+      document.cookie = c
+        .replace(/^ +/, '')
+        .replace(/=.*/, '=;expires=' + new Date().toUTCString() + ';path=/')
+    })
+    localStorage.clear()
+  }
+
+  describe('#get', function () {
+    it('picks data from cookies first', function () {
+      jar.set('ajs_test_key', '🍊')
+      localStorage.setItem('ajs_test_key', 'ðŸ’ū')
+      const us = new UniversalStorage(defaultTargets)
+      expect(us.get('ajs_test_key')).toEqual('🍊')
+    })
+
+    it('picks data from localStorage if there is no cookie target', function () {
+      jar.set('ajs_test_key', '🍊')
+      localStorage.setItem('ajs_test_key', 'ðŸ’ū')
+      const us = new UniversalStorage([new LocalStorage(), new MemoryStorage()])
+      expect(us.get('ajs_test_key')).toEqual('ðŸ’ū')
+    })
+
+    it('get data from memory', function () {
+      jar.set('ajs_test_key', '🍊')
+      localStorage.setItem('ajs_test_key', 'ðŸ’ū')
+      const us = new UniversalStorage([new MemoryStorage()])
+      expect(us.get('ajs_test_key')).toBeNull()
+    })
+
+    it('order of default targets matters!', function () {
+      jar.set('ajs_test_key', '🍊')
+      localStorage.setItem('ajs_test_key', 'ðŸ’ū')
+      const us = new UniversalStorage(defaultTargets)
+      expect(us.get('ajs_test_key')).toEqual('🍊')
+    })
+
+    it('returns null if there are no storage targets', function () {
+      jar.set('ajs_test_key', '🍊')
+      localStorage.setItem('ajs_test_key', 'ðŸ’ū')
+      const us = new UniversalStorage([])
+      expect(us.get('ajs_test_key')).toBeNull()
+    })
+
+    //   it('can override the default targets', function () {
+    //     jar.set('ajs_test_key', '🍊')
+    //     localStorage.setItem('ajs_test_key', 'ðŸ’ū')
+    //     const us = new PrioritizedListStorage(
+    //       defaultTargets
+    //     )
+    //     expect(us.get('ajs_test_key', ['localStorage'])).toEqual('ðŸ’ū')
+    //     expect(us.get('ajs_test_key', ['localStorage', 'memory'])).toEqual('ðŸ’ū')
+    //     expect(us.get('ajs_test_key', ['cookie', 'memory'])).toEqual('🍊')
+    //     expect(us.get('ajs_test_key', ['cookie', 'localStorage'])).toEqual('🍊')
+    //     expect(us.get('ajs_test_key', ['cookie'])).toEqual('🍊')
+    //     expect(us.get('ajs_test_key', ['memory'])).toEqual(null)
+    //   })
+  })
+
+  describe('#set', function () {
+    it('set the data in all storage types', function () {
+      const us = new UniversalStorage<{ ajs_test_key: string }>(defaultTargets)
+      us.set('ajs_test_key', '💰')
+      expect(jar.get('ajs_test_key')).toEqual('💰')
+      expect(getFromLS('ajs_test_key')).toEqual('💰')
+    })
+
+    it('skip saving data to localStorage', function () {
+      const us = new UniversalStorage([
+        new CookieStorage(),
+        new MemoryStorage(),
+      ])
+      us.set('ajs_test_key', '💰')
+      expect(jar.get('ajs_test_key')).toEqual('💰')
+      expect(localStorage.getItem('ajs_test_key')).toEqual(null)
+    })
+
+    it('skip saving data to cookie', function () {
+      const us = new UniversalStorage([new LocalStorage(), new MemoryStorage()])
+      us.set('ajs_test_key', '💰')
+      expect(jar.get('ajs_test_key')).toEqual(undefined)
+      expect(getFromLS('ajs_test_key')).toEqual('💰')
+    })
+
+    it('can save and retrieve from memory when there is no other storage', function () {
+      const us = new UniversalStorage([new MemoryStorage()])
+      us.set('ajs_test_key', '💰')
+      expect(jar.get('ajs_test_key')).toEqual(undefined)
+      expect(localStorage.getItem('ajs_test_key')).toEqual(null)
+      expect(us.get('ajs_test_key')).toEqual('💰')
+    })
+
+    it('does not write to cookies when cookies are not available', function () {
+      const cookieStore = new CookieStorage()
+      jest.spyOn(cookieStore, 'available', 'get').mockReturnValueOnce(false)
+      const us = new UniversalStorage([
+        new LocalStorage(),
+        cookieStore,
+        new MemoryStorage(),
+      ])
+      us.set('ajs_test_key', '💰')
+      expect(jar.get('ajs_test_key')).toEqual(undefined)
+      expect(getFromLS('ajs_test_key')).toEqual('💰')
+      expect(us.get('ajs_test_key')).toEqual('💰')
+    })
+
+    it('does not write to LS when LS is not available', function () {
+      const localStorage = new LocalStorage()
+      jest.spyOn(localStorage, 'available', 'get').mockReturnValueOnce(false)
+      const us = new UniversalStorage([
+        localStorage,
+        new CookieStorage(),
+        new MemoryStorage(),
+      ])
+      us.set('ajs_test_key', '💰')
+      expect(jar.get('ajs_test_key')).toEqual('💰')
+      expect(localStorage.get('ajs_test_key')).toEqual(null)
+      expect(us.get('ajs_test_key')).toEqual('💰')
+    })
+
+    //   it('can override the default targets', function () {
+    //     const us = new UniversalStorage(
+    //       defaultTargets,
+    //       getAvailableStorageOptions()
+    //     )
+    //     us.set('ajs_test_key', '💰', ['localStorage'])
+    //     expect(jar.get('ajs_test_key')).toEqual(undefined)
+    //     expect(getFromLS('ajs_test_key')).toEqual('💰')
+    //     expect(us.get('ajs_test_key')).toEqual('💰')
+
+    //     us.set('ajs_test_key_2', 'ðŸĶī', ['cookie'])
+    //     expect(jar.get('ajs_test_key_2')).toEqual('ðŸĶī')
+    //     expect(localStorage.getItem('ajs_test_key_2')).toEqual(null)
+    //     expect(us.get('ajs_test_key_2')).toEqual('ðŸĶī')
+
+    //     us.set('ajs_test_key_3', 'ðŸ‘ŧ', [])
+    //     expect(jar.get('ajs_test_key_3')).toEqual(undefined)
+    //     expect(localStorage.getItem('ajs_test_key_3')).toEqual(null)
+    //     expect(us.get('ajs_test_key_3')).toEqual(null)
+    //   })
+  })
+})
diff --git a/packages/browser/src/core/storage/__tests__/test-helpers.ts b/packages/browser/src/core/storage/__tests__/test-helpers.ts
new file mode 100644
index 000000000..7bd5af18b
--- /dev/null
+++ b/packages/browser/src/core/storage/__tests__/test-helpers.ts
@@ -0,0 +1,19 @@
+/**
+ * Disables Cookies
+ * @returns jest spy
+ */
+export function disableCookies(): jest.SpyInstance {
+  return jest
+    .spyOn(window.navigator, 'cookieEnabled', 'get')
+    .mockReturnValue(false)
+}
+
+/**
+ * Disables LocalStorage
+ * @returns jest spy
+ */
+export function disableLocalStorage(): jest.SpyInstance {
+  return jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
+    throw new Error()
+  })
+}
diff --git a/packages/browser/src/core/storage/cookieStorage.ts b/packages/browser/src/core/storage/cookieStorage.ts
new file mode 100644
index 000000000..02f5eb50a
--- /dev/null
+++ b/packages/browser/src/core/storage/cookieStorage.ts
@@ -0,0 +1,97 @@
+import { BaseStorage, StorageObject, StoreType } from './types'
+import jar from 'js-cookie'
+import { tld } from '../user/tld'
+
+const ONE_YEAR = 365
+
+export interface CookieOptions {
+  maxage?: number
+  domain?: string
+  path?: string
+  secure?: boolean
+  sameSite?: string
+}
+
+/**
+ * Data storage using browser cookies
+ */
+export class CookieStorage<
+  Data extends StorageObject = StorageObject
+> extends BaseStorage {
+  get available(): boolean {
+    let cookieEnabled = window.navigator.cookieEnabled
+
+    if (!cookieEnabled) {
+      jar.set('ajs:cookies', 'test')
+      cookieEnabled = document.cookie.includes('ajs:cookies')
+      jar.remove('ajs:cookies')
+    }
+
+    return cookieEnabled
+  }
+
+  get type() {
+    return StoreType.Cookie
+  }
+
+  static get defaults(): CookieOptions {
+    return {
+      maxage: ONE_YEAR,
+      domain: tld(window.location.href),
+      path: '/',
+      sameSite: 'Lax',
+    }
+  }
+
+  private options: Required<CookieOptions>
+
+  constructor(options: CookieOptions = CookieStorage.defaults) {
+    super()
+    this.options = {
+      ...CookieStorage.defaults,
+      ...options,
+    } as Required<CookieOptions>
+  }
+
+  private opts(): jar.CookieAttributes {
+    return {
+      sameSite: this.options.sameSite as jar.CookieAttributes['sameSite'],
+      expires: this.options.maxage,
+      domain: this.options.domain,
+      path: this.options.path,
+      secure: this.options.secure,
+    }
+  }
+
+  get<K extends keyof Data>(key: K): Data[K] | null {
+    try {
+      const value = jar.get(key)
+
+      if (value === undefined || value === null) {
+        return null
+      }
+
+      try {
+        return JSON.parse(value) ?? null
+      } catch (e) {
+        return (value ?? null) as unknown as Data[K] | null
+      }
+    } catch (e) {
+      return null
+    }
+  }
+
+  set<K extends keyof Data>(key: K, value: Data[K] | null): void {
+    if (typeof value === 'string') {
+      jar.set(key, value, this.opts())
+    } else if (value === null) {
+      jar.remove(key, this.opts())
+    } else {
+      jar.set(key, JSON.stringify(value), this.opts())
+    }
+  }
+
+  clear<K extends keyof Data>(key: K): void {
+    return jar.remove(key, this.opts())
+  }
+}
diff --git a/packages/browser/src/core/storage/index.ts b/packages/browser/src/core/storage/index.ts
new file mode 100644
index 000000000..5793795b0
--- /dev/null
+++ b/packages/browser/src/core/storage/index.ts
@@ -0,0 +1,64 @@
+import { CookieOptions, CookieStorage } from './cookieStorage'
+import { LocalStorage } from './localStorage'
+import { MemoryStorage } from './memoryStorage'
+import { isStoreTypeWithSettings } from './settings'
+import { StoreType, Storage, InitializeStorageArgs } from './types'
+
+export * from './types'
+export * from './localStorage'
+export * from './cookieStorage'
+export * from './memoryStorage'
+export * from './universalStorage'
+export * from './settings'
+
+/**
+ * Creates multiple storage systems from an array of StoreType and options
+ * @param args StoreType and options
+ * @returns Storage array
+ */
+export function initializeStorages(args: InitializeStorageArgs): Storage[] {
+  const storages = args.map((s) => {
+    let type: StoreType
+    let settings
+
+    if (isStoreTypeWithSettings(s)) {
+      type = s.name
+      settings = s.settings
+    } else {
+      type = s
+    }
+
+    switch (type) {
+      case StoreType.Cookie:
+        return new CookieStorage(settings)
+      case StoreType.LocalStorage:
+        return new LocalStorage()
+      case StoreType.Memory:
+        return new MemoryStorage()
+      default:
+        throw new Error(`Unknown Store Type: ${s}`)
+    }
+  })
+  return storages
+}
+
+/**
+ * Injects the CookieOptions into a the arguments for initializeStorage
+ * @param storeTypes list of storeType
+ * @param cookieOptions cookie Options
+ * @returns arguments for initializeStorage
+ */
+export function applyCookieOptions(
+  storeTypes: StoreType[],
+  cookieOptions?: CookieOptions
+): InitializeStorageArgs {
+  return storeTypes.map((s) => {
+    if (cookieOptions && s === StoreType.Cookie) {
+      return {
+        name: s,
+        settings: cookieOptions,
+      }
+    }
+    return s
+  })
+}
diff --git a/packages/browser/src/core/storage/localStorage.ts b/packages/browser/src/core/storage/localStorage.ts
new file mode 100644
index 000000000..f5385f965
--- /dev/null
+++ b/packages/browser/src/core/storage/localStorage.ts
@@ -0,0 +1,60 @@
+import { BaseStorage, StorageObject, StoreType } from './types'
+
+/**
+ * Data storage using browser's localStorage
+ */
+export class LocalStorage<
+  Data extends StorageObject = StorageObject
+> extends BaseStorage {
+  private localStorageWarning(key: keyof Data, state: 'full' | 'unavailable') {
+    console.warn(`Unable to access ${key}, localStorage may be ${state}`)
+  }
+
+  get type() {
+    return StoreType.LocalStorage
+  }
+
+  get available(): boolean {
+    const test = 'test'
+    try {
+      localStorage.setItem(test, test)
+      localStorage.removeItem(test)
+      return true
+    } catch (e) {
+      return false
+    }
+  }
+
+  get<K extends keyof Data>(key: K): Data[K] | null {
+    try {
+      const val = localStorage.getItem(key)
+      if (val === null) {
+        return null
+      }
+      try {
+        return JSON.parse(val) ?? null
+      } catch (e) {
+        return (val ?? null) as unknown as Data[K] | null
+      }
+    } catch (err) {
+      this.localStorageWarning(key, 'unavailable')
+      return null
+    }
+  }
+
+  set<K extends keyof Data>(key: K, value: Data[K] | null): void {
+    try {
+      localStorage.setItem(key, JSON.stringify(value))
+    } catch {
+      this.localStorageWarning(key, 'full')
+    }
+  }
+
+  clear<K extends keyof Data>(key: K): void {
+    try {
+      return localStorage.removeItem(key)
+    } catch (err) {
+      this.localStorageWarning(key, 'unavailable')
+    }
+  }
+}
diff --git a/packages/browser/src/core/storage/memoryStorage.ts b/packages/browser/src/core/storage/memoryStorage.ts
new file mode 100644
index 000000000..fa9a3d580
--- /dev/null
+++ b/packages/browser/src/core/storage/memoryStorage.ts
@@ -0,0 +1,30 @@
+import { BaseStorage, StorageObject, StoreType } from './types'
+
+/**
+ * Data Storage using in memory object
+ */
+export class MemoryStorage<
+  Data extends StorageObject = StorageObject
+> extends BaseStorage<Data> {
+  private cache: Record<string, unknown> = {}
+
+  get type() {
+    return StoreType.Memory
+  }
+
+  get available(): boolean {
+    return true
+  }
+
+  get<K extends keyof Data>(key: K): Data[K] | null {
+    return (this.cache[key] ?? null) as Data[K] | null
+  }
+
+  set<K extends keyof Data>(key: K, value: Data[K] | null): void {
+    this.cache[key] = value
+  }
+
+  clear<K extends keyof Data>(key: K): void {
+    delete this.cache[key]
+  }
+}
diff --git a/packages/browser/src/core/storage/settings.ts b/packages/browser/src/core/storage/settings.ts
new file mode 100644
index 000000000..2eab09876
--- /dev/null
+++ b/packages/browser/src/core/storage/settings.ts
@@ -0,0 +1,28 @@
+import { Storage, StoreType, StoreTypeWithSettings } from './types'
+
+export type StorageSettings = Storage | StoreType[]
+
+export function isArrayOfStoreType(s: StorageSettings): s is StoreType[] {
+  return (
+    s !== undefined &&
+    s !== null &&
+    Array.isArray(s) &&
+    s.every((e) => Object.values(StoreType).includes(e))
+  )
+}
+
+export function isStorageObject(s: StorageSettings): s is Storage {
+  return (
+    s !== undefined &&
+    s !== null &&
+    !Array.isArray(s) &&
+    typeof s === 'object' &&
+    s.get !== undefined
+  )
+}
+
+export function isStoreTypeWithSettings(
+  s: StoreTypeWithSettings | StoreType
+): s is StoreTypeWithSettings {
+  return typeof s === 'object' && s.name !== undefined
+}
diff --git a/packages/browser/src/core/storage/types.ts b/packages/browser/src/core/storage/types.ts
new file mode 100644
index 000000000..18f3b0f26
--- /dev/null
+++ b/packages/browser/src/core/storage/types.ts
@@ -0,0 +1,93 @@
+import { CookieOptions } from './cookieStorage'
+
+/**
+ * Known Storage Types
+ *
+ * Convenience settings for storage systems that AJS includes support for
+ */
+export enum StoreType {
+  Cookie = 'cookie',
+  LocalStorage = 'localStorage',
+  Memory = 'memory',
+}
+
+export type StorageObject = Record<string, unknown>
+
+/**
+ * Defines a Storage object for use in AJS Client.
+ */
+export interface Storage<Data extends StorageObject = StorageObject> {
+  /**
+   * Returns the kind of storage.
+   * @example cookie, localStorage, custom
+   */
+  get type(): StoreType | string
+
+  /**
+   * Tests if the storage is available for use in the current environment
+   */
+  get available(): boolean
+  /**
+   * get value for the key from the stores. it will return the first value found in the stores
+   * @param key key for the value to be retrieved
+   * @returns value for the key or null if not found
+   */
+  get<K extends keyof Data>(key: K): Data[K] | null
+  /*
+    This is to support few scenarios where:
+    - value exist in one of the stores ( as a result of other stores being cleared from browser ) and we want to resync them
+    - read values in AJS 1.0 format ( for customers after 1.0 --> 2.0 migration ) and then re-write them in AJS 2.0 format
+  */
+
+  /**
+   * get value for the key from the stores. it will pick the first value found in the stores, and then sync the value to all the stores
+   * if the found value is a number, it will be converted to a string. this is to support legacy behavior that existed in AJS 1.0
+   * @param key key for the value to be retrieved
+   * @returns value for the key or null if not found
+   */
+  getAndSync<K extends keyof Data>(key: K): Data[K] | null
+  /**
+   * it will set the value for the key in all the stores
+   * @param key key for the value to be stored
+   * @param value value to be stored
+   * @returns value that was stored
+   */
+  set<K extends keyof Data>(key: K, value: Data[K] | null): void
+  /**
+   * remove the value for the key from all the stores
+   * @param key key for the value to be removed
+   * @param storeTypes optional array of store types to be used for removing the value
+   */
+  clear<K extends keyof Data>(key: K): void
+}
+
+/**
+ * Abstract class for creating basic storage systems
+ */
+export abstract class BaseStorage<Data extends StorageObject = StorageObject>
+  implements Storage<Data>
+{
+  abstract get type(): StoreType | string
+  abstract get available(): boolean
+  abstract get<K extends keyof Data>(key: K): Data[K] | null
+  abstract set<K extends keyof Data>(key: K, value: Data[K] | null): void
+  abstract clear<K extends keyof Data>(key: K): void
+  /**
+   * By default a storage getAndSync will handle calls exactly as the
+   */
+  getAndSync<K extends keyof Data>(key: K): Data[K] | null {
+    const val = this.get(key)
+    // legacy behavior, getAndSync can change the type of a value from number to string (AJS 1.0 stores numerical values as a number)
+    const coercedValue = (typeof val === 'number' ? val.toString() : val) as
+      | Data[K]
+      | null
+    return coercedValue
+  }
+}
+
+export interface StoreTypeWithSettings<T extends StoreType = StoreType> {
+  name: T
+  settings?: T extends StoreType.Cookie ? CookieOptions : never
+}
+
+export type InitializeStorageArgs = (StoreTypeWithSettings | StoreType)[]
diff --git a/packages/browser/src/core/storage/universalStorage.ts b/packages/browser/src/core/storage/universalStorage.ts
new file mode 100644
index 000000000..9e16bac38
--- /dev/null
+++ b/packages/browser/src/core/storage/universalStorage.ts
@@ -0,0 +1,55 @@
+import { Storage, StorageObject } from './types'
+
+/**
+ * Uses multiple storages in a priority list to get/set values in the order they are specified.
+ */
+export class UniversalStorage<Data extends StorageObject = StorageObject>
+  implements Storage<Data>
+{
+  private stores: Storage[]
+
+  constructor(stores: Storage[]) {
+    this.stores = stores.filter((s) => s.available)
+  }
+
+  get available(): boolean {
+    return this.stores.some((s) => s.available)
+  }
+
+  get type(): string {
+    return 'PriorityListStorage'
+  }
+
+  get<K extends keyof Data>(key: K): Data[K] | null {
+    let val: Data[K] | null = null
+
+    for (const store of this.stores) {
+      val = store.get(key) as Data[K] | null
+      if (val !== undefined && val !== null) {
+        return val
+      }
+    }
+    return null
+  }
+
+  set<K extends keyof Data>(key: K, value: Data[K] | null): void {
+    this.stores.forEach((s) => s.set(key, value))
+  }
+
+  clear<K extends keyof Data>(key: K): void {
+    this.stores.forEach((s) => s.clear(key))
+  }
+
+  getAndSync<K extends keyof Data>(key: K): Data[K] | null {
+    const val = this.get(key)
+
+    // legacy behavior, getAndSync can change the type of a value from number to string (AJS 1.0 stores numerical values as a number)
+    const coercedValue = (typeof val === 'number' ? val.toString() : val) as
+      | Data[K]
+      | null
+
+    this.set(key, coercedValue)
+
+    return coercedValue
+  }
+}
diff --git a/packages/browser/src/core/user/__tests__/index.test.ts b/packages/browser/src/core/user/__tests__/index.test.ts
index 7f78deab7..045c3cedd 100644
--- a/packages/browser/src/core/user/__tests__/index.test.ts
+++ b/packages/browser/src/core/user/__tests__/index.test.ts
@@ -1,14 +1,12 @@
-import {
-  User,
-  LocalStorage,
-  Cookie,
-  Group,
-  UniversalStorage,
-  StoreType,
-  getAvailableStorageOptions,
-} from '..'
-import jar from 'js-cookie'
 import assert from 'assert'
+import jar from 'js-cookie'
+import { Group, User } from '..'
+import { LocalStorage, StoreType, Storage } from '../../storage'
+import {
+  disableCookies,
+  disableLocalStorage,
+} from '../../storage/__tests__/test-helpers'
+import { MemoryStorage } from '../../storage/memoryStorage'
 
 function clear(): void {
   document.cookie.split(';').forEach(function (c) {
@@ -23,10 +21,11 @@ let store: LocalStorage
 beforeEach(function () {
   store = new LocalStorage()
   clear()
+  // Restore any cookie, localstorage disable
+  jest.restoreAllMocks()
+  jest.spyOn(console, 'warn').mockImplementation(() => {}) // silence console spam.
 })
 
-jest.spyOn(console, 'warn').mockImplementation(() => {}) // silence console spam.
-
 describe('user', () => {
   const cookieKey = User.defaults.cookie.key
   const localStorageKey = User.defaults.localStorage.key
@@ -76,7 +75,7 @@ describe('user', () => {
 
     describe('when cookies are disabled', () => {
       beforeEach(() => {
-        jest.spyOn(Cookie, 'available').mockReturnValueOnce(false)
+        disableCookies()
 
         user = new User()
         clear()
@@ -140,8 +139,8 @@ describe('user', () => {
 
     describe('when cookies and localStorage are disabled', () => {
       beforeEach(() => {
-        jest.spyOn(Cookie, 'available').mockReturnValueOnce(false)
-        jest.spyOn(LocalStorage, 'available').mockReturnValueOnce(false)
+        disableCookies()
+        disableLocalStorage()
 
         user = new User()
         clear()
@@ -161,10 +160,6 @@ describe('user', () => {
         assert(user.id() === 'id')
       })
 
-      it('should be null by default', () => {
-        assert(user.id() === null)
-      })
-
       it('should not reset anonymousId if the user didnt have previous id', () => {
         const prev = user.anonymousId()
         user.id('foo')
@@ -308,7 +303,7 @@ describe('user', () => {
 
     describe('when cookies are disabled', () => {
       beforeEach(() => {
-        jest.spyOn(Cookie, 'available').mockReturnValueOnce(false)
+        disableCookies()
 
         user = new User()
       })
@@ -336,8 +331,8 @@ describe('user', () => {
 
     describe('when cookies and localStorage are disabled', () => {
       beforeEach(() => {
-        jest.spyOn(LocalStorage, 'available').mockReturnValueOnce(false)
-        jest.spyOn(Cookie, 'available').mockReturnValueOnce(false)
+        disableCookies()
+        disableLocalStorage()
 
         user = new User()
       })
@@ -504,40 +499,6 @@ describe('user', () => {
     })
   })
 
-  describe('#options', () => {
-    it('should have default cookie options', () => {
-      const cookie = new Cookie()
-      expect(cookie['options'].domain).toBe(undefined)
-      expect(cookie['options'].maxage).toBe(365)
-      expect(cookie['options'].path).toBe('/')
-      expect(cookie['options'].sameSite).toBe('Lax')
-      expect(cookie['options'].secure).toBe(undefined)
-    })
-
-    it('should set options properly', () => {
-      const cookie = new Cookie({ domain: 'foo', secure: true, path: '/test' })
-      expect(cookie['options'].domain).toBe('foo')
-      expect(cookie['options'].secure).toBe(true)
-      expect(cookie['options'].path).toBe('/test')
-      expect(cookie['options'].secure).toBe(true)
-    })
-
-    it('should pass options when creating cookie', () => {
-      const jarSpy = jest.spyOn(jar, 'set')
-      const cookie = new Cookie({ domain: 'foo', secure: true, path: '/test' })
-
-      cookie.set('foo', 'bar')
-
-      expect(jarSpy).toHaveBeenCalledWith('foo', 'bar', {
-        domain: 'foo',
-        expires: 365,
-        path: '/test',
-        sameSite: 'Lax',
-        secure: true,
-      })
-    })
-  })
-
   describe('#save', () => {
     let user: User
 
@@ -762,6 +723,79 @@ describe('user', () => {
       )
     })
   })
+
+  describe('storage', () => {
+    it('allows custom storage priority', () => {
+      const expected = 'CookieValue'
+      // Set a cookie first
+      jar.set('ajs_anonymous_id', expected)
+      store.set('ajs_anonymous_id', 'localStorageValue')
+      const user = new User({
+        storage: [StoreType.Cookie, StoreType.LocalStorage, StoreType.Memory],
+      })
+      expect(user.anonymousId()).toEqual(expected)
+    })
+
+    it('custom storage priority respects availability', () => {
+      const expected = 'localStorageValue'
+      // Set a cookie first
+      jar.set('ajs_anonymous_id', 'CookieValue')
+      disableCookies()
+      store.set('ajs_anonymous_id', expected)
+      const user = new User({
+        storage: [StoreType.Cookie, StoreType.LocalStorage, StoreType.Memory],
+      })
+      expect(user.anonymousId()).toEqual(expected)
+    })
+
+    it('persist option overrides any custom storage', () => {
+      const setCookieSpy = jest.spyOn(jar, 'set')
+      const user = new User({
+        storage: [StoreType.Cookie, StoreType.LocalStorage, StoreType.Memory],
+        persist: false,
+      })
+      user.id('id')
+
+      expect(user.id()).toBe('id')
+      expect(jar.get('ajs_user_id')).toBeFalsy()
+      expect(store.get('ajs_user_id')).toBeFalsy()
+      expect(setCookieSpy.mock.calls.length).toBe(0)
+    })
+
+    it('disable option overrides any custom storage', () => {
+      const setCookieSpy = jest.spyOn(jar, 'set')
+      const user = new User({
+        storage: [StoreType.Cookie, StoreType.LocalStorage, StoreType.Memory],
+        disable: true,
+      })
+      user.id('id')
+
+      expect(user.id()).toBe(null)
+      expect(jar.get('ajs_user_id')).toBeFalsy()
+      expect(store.get('ajs_user_id')).toBeFalsy()
+      expect(setCookieSpy.mock.calls.length).toBe(0)
+    })
+
+    it('can use a fully custom storage object', () => {
+      const customStore: Storage = {
+        get type() {
+          return 'something'
+        },
+        get available() {
+          return true
+        },
+        get: jest.fn().mockReturnValue('custom'),
+        set: jest.fn(),
+        clear: jest.fn(),
+        getAndSync: jest.fn().mockReturnValue('custom'),
+      }
+
+      const user = new User({ storage: customStore })
+      user.id('id')
+      expect(customStore.set).toHaveBeenCalled()
+      expect(user.id()).toBe('custom')
+    })
+  })
 })
 
 describe('group', () => {
@@ -872,64 +906,6 @@ describe('group', () => {
   })
 })
 
-describe('store', function () {
-  describe('#get', function () {
-    it('should return null if localStorage throws an error (or does not exist)', function () {
-      const getItemSpy = jest
-        .spyOn(global.Storage.prototype, 'getItem')
-        .mockImplementationOnce(() => {
-          throw new Error('getItem fail.')
-        })
-      store.set('foo', 'some value')
-      expect(store.get('foo')).toBeNull()
-      expect(getItemSpy).toBeCalledTimes(1)
-    })
-
-    it('should not get an empty record', function () {
-      expect(store.get('abc')).toBe(null)
-    })
-
-    it('should get an existing record', function () {
-      store.set('x', { a: 'b' })
-      store.set('a', 'hello world')
-      store.set('b', '')
-      store.set('c', false)
-      store.set('d', null)
-      store.set('e', undefined)
-
-      expect(store.get('x')).toStrictEqual({ a: 'b' })
-      expect(store.get('a')).toBe('hello world')
-      expect(store.get('b')).toBe('')
-      expect(store.get('c')).toBe(false)
-      expect(store.get('d')).toBe(null)
-      expect(store.get('e')).toBe('undefined')
-    })
-  })
-
-  describe('#set', function () {
-    it('should be able to set a record', function () {
-      store.set('x', { a: 'b' })
-      expect(store.get('x')).toStrictEqual({ a: 'b' })
-    })
-
-    it('should catch localStorage quota exceeded errors', () => {
-      const val = 'x'.repeat(10 * 1024 * 1024)
-      store.set('foo', val)
-
-      expect(store.get('foo')).toBe(null)
-    })
-  })
-
-  describe('#remove', function () {
-    it('should be able to remove a record', function () {
-      store.set('x', { a: 'b' })
-      expect(store.get('x')).toStrictEqual({ a: 'b' })
-      store.remove('x')
-      expect(store.get('x')).toBe(null)
-    })
-  })
-})
-
 describe('Custom cookie params', () => {
   it('allows for overriding keys', () => {
     const customUser = new User(
@@ -947,157 +923,3 @@ describe('Custom cookie params', () => {
     expect(customUser.traits()).toEqual({ trait: true })
   })
 })
-
-describe('universal storage', function () {
-  const defaultTargets = ['cookie', 'localStorage', 'memory'] as StoreType[]
-  const getFromLS = (key: string) => JSON.parse(localStorage.getItem(key) ?? '')
-  beforeEach(function () {
-    clear()
-  })
-
-  describe('#get', function () {
-    it('picks data from cookies first', function () {
-      jar.set('ajs_test_key', '🍊')
-      localStorage.setItem('ajs_test_key', 'ðŸ’ū')
-      const us = new UniversalStorage(
-        defaultTargets,
-        getAvailableStorageOptions()
-      )
-      expect(us.get('ajs_test_key')).toEqual('🍊')
-    })
-
-    it('picks data from localStorage if there is no cookie target', function () {
-      jar.set('ajs_test_key', '🍊')
-      localStorage.setItem('ajs_test_key', 'ðŸ’ū')
-      const us = new UniversalStorage(
-        ['localStorage', 'memory'],
-        getAvailableStorageOptions()
-      )
-      expect(us.get('ajs_test_key')).toEqual('ðŸ’ū')
-    })
-
-    it('get data from memory', function () {
-      jar.set('ajs_test_key', '🍊')
-      localStorage.setItem('ajs_test_key', 'ðŸ’ū')
-      const us = new UniversalStorage(['memory'], getAvailableStorageOptions())
-      expect(us.get('ajs_test_key')).toBeNull()
-    })
-
-    it('order of default targets matters!', function () {
-      jar.set('ajs_test_key', '🍊')
-      localStorage.setItem('ajs_test_key', 'ðŸ’ū')
-      const us = new UniversalStorage(
-        ['cookie', 'localStorage', 'memory'],
-        getAvailableStorageOptions()
-      )
-      expect(us.get('ajs_test_key')).toEqual('🍊')
-    })
-
-    it('returns null if there are no storage targets', function () {
-      jar.set('ajs_test_key', '🍊')
-      localStorage.setItem('ajs_test_key', 'ðŸ’ū')
-      const us = new UniversalStorage([], getAvailableStorageOptions())
-      expect(us.get('ajs_test_key')).toBeNull()
-    })
-
-    it('can override the default targets', function () {
-      jar.set('ajs_test_key', '🍊')
-      localStorage.setItem('ajs_test_key', 'ðŸ’ū')
-      const us = new UniversalStorage(
-        defaultTargets,
-        getAvailableStorageOptions()
-      )
-      expect(us.get('ajs_test_key', ['localStorage'])).toEqual('ðŸ’ū')
-      expect(us.get('ajs_test_key', ['localStorage', 'memory'])).toEqual('ðŸ’ū')
-      expect(us.get('ajs_test_key', ['cookie', 'memory'])).toEqual('🍊')
-      expect(us.get('ajs_test_key', ['cookie', 'localStorage'])).toEqual('🍊')
-      expect(us.get('ajs_test_key', ['cookie'])).toEqual('🍊')
-      expect(us.get('ajs_test_key', ['memory'])).toEqual(null)
-    })
-  })
-
-  describe('#set', function () {
-    it('set the data in all storage types', function () {
-      const us = new UniversalStorage<{ ajs_test_key: string }>(
-        defaultTargets,
-        getAvailableStorageOptions()
-      )
-      us.set('ajs_test_key', '💰')
-      expect(jar.get('ajs_test_key')).toEqual('💰')
-      expect(getFromLS('ajs_test_key')).toEqual('💰')
-    })
-
-    it('skip saving data to localStorage', function () {
-      const us = new UniversalStorage(
-        ['cookie', 'memory'],
-        getAvailableStorageOptions()
-      )
-      us.set('ajs_test_key', '💰')
-      expect(jar.get('ajs_test_key')).toEqual('💰')
-      expect(localStorage.getItem('ajs_test_key')).toEqual(null)
-    })
-
-    it('skip saving data to cookie', function () {
-      const us = new UniversalStorage(
-        ['localStorage', 'memory'],
-        getAvailableStorageOptions()
-      )
-      us.set('ajs_test_key', '💰')
-      expect(jar.get('ajs_test_key')).toEqual(undefined)
-      expect(getFromLS('ajs_test_key')).toEqual('💰')
-    })
-
-    it('can save and retrieve from memory when there is no other storage', function () {
-      const us = new UniversalStorage(['memory'], getAvailableStorageOptions())
-      us.set('ajs_test_key', '💰')
-      expect(jar.get('ajs_test_key')).toEqual(undefined)
-      expect(localStorage.getItem('ajs_test_key')).toEqual(null)
-      expect(us.get('ajs_test_key')).toEqual('💰')
-    })
-
-    it('does not write to cookies when cookies are not available', function () {
-      jest.spyOn(Cookie, 'available').mockReturnValueOnce(false)
-      const us = new UniversalStorage(
-        ['localStorage', 'cookie', 'memory'],
-        getAvailableStorageOptions()
-      )
-      us.set('ajs_test_key', '💰')
-      expect(jar.get('ajs_test_key')).toEqual(undefined)
-      expect(getFromLS('ajs_test_key')).toEqual('💰')
-      expect(us.get('ajs_test_key')).toEqual('💰')
-    })
-
-    it('does not write to LS when LS is not available', function () {
-      jest.spyOn(LocalStorage, 'available').mockReturnValueOnce(false)
-      const us = new UniversalStorage(
-        ['localStorage', 'cookie', 'memory'],
-        getAvailableStorageOptions()
-      )
-      us.set('ajs_test_key', '💰')
-      expect(jar.get('ajs_test_key')).toEqual('💰')
-      expect(localStorage.getItem('ajs_test_key')).toEqual(null)
-      expect(us.get('ajs_test_key')).toEqual('💰')
-    })
-
-    it('can override the default targets', function () {
-      const us = new UniversalStorage(
-        defaultTargets,
-        getAvailableStorageOptions()
-      )
-      us.set('ajs_test_key', '💰', ['localStorage'])
-      expect(jar.get('ajs_test_key')).toEqual(undefined)
-      expect(getFromLS('ajs_test_key')).toEqual('💰')
-      expect(us.get('ajs_test_key')).toEqual('💰')
-
-      us.set('ajs_test_key_2', 'ðŸĶī', ['cookie'])
-      expect(jar.get('ajs_test_key_2')).toEqual('ðŸĶī')
-      expect(localStorage.getItem('ajs_test_key_2')).toEqual(null)
-      expect(us.get('ajs_test_key_2')).toEqual('ðŸĶī')
-
-      us.set('ajs_test_key_3', 'ðŸ‘ŧ', [])
-      expect(jar.get('ajs_test_key_3')).toEqual(undefined)
-      expect(localStorage.getItem('ajs_test_key_3')).toEqual(null)
-      expect(us.get('ajs_test_key_3')).toEqual(null)
-    })
-  })
-})
diff --git a/packages/browser/src/core/user/index.ts b/packages/browser/src/core/user/index.ts
index a778ecd21..f62607dec 100644
--- a/packages/browser/src/core/user/index.ts
+++ b/packages/browser/src/core/user/index.ts
@@ -1,8 +1,20 @@
 import { v4 as uuid } from '@lukeed/uuid'
-import jar from 'js-cookie'
-import { Traits } from '../events'
-import { tld } from './tld'
 import autoBind from '../../lib/bind-all'
+import { Traits } from '../events'
+import {
+  CookieOptions,
+  UniversalStorage,
+  Storage,
+  StorageObject,
+  StorageSettings,
+  StoreType,
+  applyCookieOptions,
+  initializeStorages,
+  isArrayOfStoreType,
+  isStorageObject,
+} from '../storage'
+import { MemoryStorage } from '../storage/memoryStorage'
+import {} from '../storage/settings'
 
 export type ID = string | null | undefined
 
@@ -22,6 +34,12 @@ export interface UserOptions {
   localStorage?: {
     key: string
   }
+
+  /**
+   * Storage system to use
+   * @example new MemoryStorage, [StoreType.Cookie, StoreType.Memory]
+   */
+  storage?: StorageSettings
 }
 
 const defaults = {
@@ -35,290 +53,6 @@ const defaults = {
   },
 }
 
-export type StoreType = 'cookie' | 'localStorage' | 'memory'
-
-type StorageObject = Record<string, unknown>
-
-class Store {
-  private cache: Record<string, unknown> = {}
-
-  get<T>(key: string): T | null {
-    return this.cache[key] as T | null
-  }
-
-  set<T>(key: string, value: T | null): void {
-    this.cache[key] = value
-  }
-
-  remove(key: string): void {
-    delete this.cache[key]
-  }
-  get type(): StoreType {
-    return 'memory'
-  }
-}
-
-const ONE_YEAR = 365
-
-export class Cookie extends Store {
-  static available(): boolean {
-    let cookieEnabled = window.navigator.cookieEnabled
-
-    if (!cookieEnabled) {
-      jar.set('ajs:cookies', 'test')
-      cookieEnabled = document.cookie.includes('ajs:cookies')
-      jar.remove('ajs:cookies')
-    }
-
-    return cookieEnabled
-  }
-
-  static get defaults(): CookieOptions {
-    return {
-      maxage: ONE_YEAR,
-      domain: tld(window.location.href),
-      path: '/',
-      sameSite: 'Lax',
-    }
-  }
-
-  private options: Required<CookieOptions>
-
-  constructor(options: CookieOptions = Cookie.defaults) {
-    super()
-    this.options = {
-      ...Cookie.defaults,
-      ...options,
-    } as Required<CookieOptions>
-  }
-
-  private opts(): jar.CookieAttributes {
-    return {
-      sameSite: this.options.sameSite as jar.CookieAttributes['sameSite'],
-      expires: this.options.maxage,
-      domain: this.options.domain,
-      path: this.options.path,
-      secure: this.options.secure,
-    }
-  }
-
-  get<T>(key: string): T | null {
-    try {
-      const value = jar.get(key)
-
-      if (!value) {
-        return null
-      }
-
-      try {
-        return JSON.parse(value)
-      } catch (e) {
-        return value as unknown as T
-      }
-    } catch (e) {
-      return null
-    }
-  }
-
-  set<T>(key: string, value: T): void {
-    if (typeof value === 'string') {
-      jar.set(key, value, this.opts())
-    } else if (value === null) {
-      jar.remove(key, this.opts())
-    } else {
-      jar.set(key, JSON.stringify(value), this.opts())
-    }
-  }
-
-  remove(key: string): void {
-    return jar.remove(key, this.opts())
-  }
-
-  get type(): StoreType {
-    return 'cookie'
-  }
-}
-
-const localStorageWarning = (key: string, state: 'full' | 'unavailable') => {
-  console.warn(`Unable to access ${key}, localStorage may be ${state}`)
-}
-
-export class LocalStorage extends Store {
-  static available(): boolean {
-    const test = 'test'
-    try {
-      localStorage.setItem(test, test)
-      localStorage.removeItem(test)
-      return true
-    } catch (e) {
-      return false
-    }
-  }
-
-  get<T>(key: string): T | null {
-    try {
-      const val = localStorage.getItem(key)
-      if (val === null) {
-        return null
-      }
-      try {
-        return JSON.parse(val)
-      } catch (e) {
-        return val as any as T
-      }
-    } catch (err) {
-      localStorageWarning(key, 'unavailable')
-      return null
-    }
-  }
-
-  set<T>(key: string, value: T): void {
-    try {
-      localStorage.setItem(key, JSON.stringify(value))
-    } catch {
-      localStorageWarning(key, 'full')
-    }
-  }
-
-  remove(key: string): void {
-    try {
-      return localStorage.removeItem(key)
-    } catch (err) {
-      localStorageWarning(key, 'unavailable')
-    }
-  }
-
-  get type(): StoreType {
-    return 'localStorage'
-  }
-}
-
-export interface CookieOptions {
-  maxage?: number
-  domain?: string
-  path?: string
-  secure?: boolean
-  sameSite?: string
-}
-
-export class UniversalStorage<Data extends StorageObject = StorageObject> {
-  private enabledStores: StoreType[]
-  private storageOptions: StorageOptions
-
-  constructor(stores: StoreType[], storageOptions: StorageOptions) {
-    this.storageOptions = storageOptions
-    this.enabledStores = stores
-  }
-
-  private getStores(storeTypes: StoreType[] | undefined): Store[] {
-    const stores: Store[] = []
-    this.enabledStores
-      .filter((i) => !storeTypes || storeTypes?.includes(i))
-      .forEach((storeType) => {
-        const storage = this.storageOptions[storeType]
-        if (storage !== undefined) {
-          stores.push(storage)
-        }
-      })
-
-    return stores
-  }
-
-  /*
-    This is to support few scenarios where:
-    - value exist in one of the stores ( as a result of other stores being cleared from browser ) and we want to resync them
-    - read values in AJS 1.0 format ( for customers after 1.0 --> 2.0 migration ) and then re-write them in AJS 2.0 format
-  */
-
-  /**
-   * get value for the key from the stores. it will pick the first value found in the stores, and then sync the value to all the stores
-   * if the found value is a number, it will be converted to a string. this is to support legacy behavior that existed in AJS 1.0
-   * @param key key for the value to be retrieved
-   * @param storeTypes optional array of store types to be used for performing get and sync
-   * @returns value for the key or null if not found
-   */
-  public getAndSync<K extends keyof Data>(
-    key: K,
-    storeTypes?: StoreType[]
-  ): Data[K] | null {
-    const val = this.get(key, storeTypes)
-
-    // legacy behavior, getAndSync can change the type of a value from number to string (AJS 1.0 stores numerical values as a number)
-    const coercedValue = (typeof val === 'number' ? val.toString() : val) as
-      | Data[K]
-      | null
-
-    this.set(key, coercedValue, storeTypes)
-
-    return coercedValue
-  }
-
-  /**
-   * get value for the key from the stores. it will return the first value found in the stores
-   * @param key key for the value to be retrieved
-   * @param storeTypes optional array of store types to be used for retrieving the value
-   * @returns value for the key or null if not found
-   */
-  public get<K extends keyof Data>(
-    key: K,
-    storeTypes?: StoreType[]
-  ): Data[K] | null {
-    let val = null
-
-    for (const store of this.getStores(storeTypes)) {
-      val = store.get<Data[K]>(key)
-      if (val) {
-        return val
-      }
-    }
-    return null
-  }
-
-  /**
-   * it will set the value for the key in all the stores
-   * @param key key for the value to be stored
-   * @param value value to be stored
-   * @param storeTypes optional array of store types to be used for storing the value
-   * @returns value that was stored
-   */
-  public set<K extends keyof Data>(
-    key: K,
-    value: Data[K] | null,
-    storeTypes?: StoreType[]
-  ): void {
-    for (const store of this.getStores(storeTypes)) {
-      store.set(key, value)
-    }
-  }
-
-  /**
-   * remove the value for the key from all the stores
-   * @param key key for the value to be removed
-   * @param storeTypes optional array of store types to be used for removing the value
-   */
-  public clear<K extends keyof Data>(key: K, storeTypes?: StoreType[]): void {
-    for (const store of this.getStores(storeTypes)) {
-      store.remove(key)
-    }
-  }
-}
-
-type StorageOptions = {
-  cookie: Cookie | undefined
-  localStorage: LocalStorage | undefined
-  memory: Store
-}
-
-export function getAvailableStorageOptions(
-  cookieOptions?: CookieOptions
-): StorageOptions {
-  return {
-    cookie: Cookie.available() ? new Cookie(cookieOptions) : undefined,
-    localStorage: LocalStorage.available() ? new LocalStorage() : undefined,
-    memory: new Store(),
-  }
-}
-
 export class User {
   static defaults = defaults
 
@@ -327,7 +61,7 @@ export class User {
   private anonKey: string
   private cookieOptions?: CookieOptions
 
-  private legacyUserStore: UniversalStorage<{
+  private legacyUserStore: Storage<{
     [k: string]:
       | {
           id?: string
@@ -335,58 +69,38 @@ export class User {
         }
       | string
   }>
-  private traitsStore: UniversalStorage<{
+  private traitsStore: Storage<{
     [k: string]: Traits
   }>
 
-  private identityStore: UniversalStorage<{
+  private identityStore: Storage<{
     [k: string]: string
   }>
 
   options: UserOptions = {}
 
   constructor(options: UserOptions = defaults, cookieOptions?: CookieOptions) {
-    this.options = options
+    this.options = { ...defaults, ...options }
     this.cookieOptions = cookieOptions
 
     this.idKey = options.cookie?.key ?? defaults.cookie.key
     this.traitsKey = options.localStorage?.key ?? defaults.localStorage.key
     this.anonKey = 'ajs_anonymous_id'
 
-    const isDisabled = options.disable === true
-    const shouldPersist = options.persist !== false
-
-    let defaultStorageTargets: StoreType[] = isDisabled
-      ? []
-      : shouldPersist
-      ? ['localStorage', 'cookie', 'memory']
-      : ['memory']
-
-    const storageOptions = getAvailableStorageOptions(cookieOptions)
-
-    if (options.localStorageFallbackDisabled) {
-      defaultStorageTargets = defaultStorageTargets.filter(
-        (t) => t !== 'localStorage'
-      )
-    }
-
-    this.identityStore = new UniversalStorage(
-      defaultStorageTargets,
-      storageOptions
-    )
+    this.identityStore = this.createStorage(this.options, cookieOptions)
 
     // using only cookies for legacy user store
-    this.legacyUserStore = new UniversalStorage(
-      defaultStorageTargets.filter(
-        (t) => t !== 'localStorage' && t !== 'memory'
-      ),
-      storageOptions
+    this.legacyUserStore = this.createStorage(
+      this.options,
+      cookieOptions,
+      (s) => s === StoreType.Cookie
     )
 
     // using only localStorage / memory for traits store
-    this.traitsStore = new UniversalStorage(
-      defaultStorageTargets.filter((t) => t !== 'cookie'),
-      storageOptions
+    this.traitsStore = this.createStorage(
+      this.options,
+      cookieOptions,
+      (s) => s !== StoreType.Cookie
     )
 
     const legacyUser = this.legacyUserStore.get(defaults.cookie.oldKey)
@@ -510,6 +224,59 @@ export class User {
   save(): boolean {
     return true
   }
+
+  /**
+   * Creates the right storage system applying all the user options, cookie options and particular filters
+   * @param options UserOptions
+   * @param cookieOpts CookieOptions
+   * @param filterStores filter function to apply to any StoreTypes (skipped if options specify using a custom storage)
+   * @returns a Storage object
+   */
+  private createStorage<T extends StorageObject = StorageObject>(
+    options: UserOptions,
+    cookieOpts?: CookieOptions,
+    filterStores?: (value: StoreType) => boolean
+  ): Storage<T> {
+    let stores: StoreType[] = [
+      StoreType.LocalStorage,
+      StoreType.Cookie,
+      StoreType.Memory,
+    ]
+
+    // If disabled we won't have any storage functionality
+    if (options.disable) {
+      return new UniversalStorage<T>([])
+    }
+
+    // If persistance is disabled we will always fallback to Memory Storage
+    if (!options.persist) {
+      return new MemoryStorage<T>()
+    }
+
+    if (options.storage !== undefined && options.storage !== null) {
+      // If the user is sending its own storage implementation we will use that without any modifications
+      if (isStorageObject(options.storage)) {
+        return options.storage as Storage<T>
+      } else if (isArrayOfStoreType(options.storage)) {
+        // If the user only specified order of stores we will still apply filters and transformations e.g. not using localStorage if localStorageFallbackDisabled
+        stores = options.storage
+      }
+    }
+
+    // Disable LocalStorage
+    if (options.localStorageFallbackDisabled) {
+      stores = stores.filter((s) => s !== StoreType.LocalStorage)
+    }
+
+    // Apply Additional filters
+    if (filterStores) {
+      stores = stores.filter(filterStores)
+    }
+
+    return new UniversalStorage(
+      initializeStorages(applyCookieOptions(stores, cookieOpts))
+    )
+  }
 }
 
 const groupDefaults: UserOptions = {
@@ -524,7 +291,7 @@ const groupDefaults: UserOptions = {
 
 export class Group extends User {
   constructor(options: UserOptions = groupDefaults, cookie?: CookieOptions) {
-    super(options, cookie)
+    super({ ...groupDefaults, ...options }, cookie)
     autoBind(this)
   }
 
diff --git a/packages/browser/src/plugins/segmentio/normalize.ts b/packages/browser/src/plugins/segmentio/normalize.ts
index eb1b6ceed..288aa27de 100644
--- a/packages/browser/src/plugins/segmentio/normalize.ts
+++ b/packages/browser/src/plugins/segmentio/normalize.ts
@@ -7,7 +7,7 @@ import { tld } from '../../core/user/tld'
 import { SegmentFacade } from '../../lib/to-facade'
 import { SegmentioSettings } from './index'
 import { version } from '../../generated/version'
-import { getAvailableStorageOptions, UniversalStorage } from '../../core/user'
+import { CookieStorage, UniversalStorage } from '../../core/storage'
 
 let cookieOptions: jar.CookieAttributes | undefined
 function getCookieOptions(): jar.CookieAttributes {
@@ -97,10 +97,7 @@ function referrerId(
 ): void {
   const storage = new UniversalStorage<{
     's:context.referrer': Ad
-  }>(
-    disablePersistance ? [] : ['cookie'],
-    getAvailableStorageOptions(getCookieOptions())
-  )
+  }>(disablePersistance ? [] : [new CookieStorage(getCookieOptions())])
 
   const stored = storage.get('s:context.referrer')
   let ad: Ad | undefined | null = ads(query)

From ac0076e55743fbb40a59b0c102e4b76c78d76539 Mon Sep 17 00:00:00 2001
From: Oscar Bazaldua <511911+oscb@users.noreply.github.com>
Date: Mon, 24 Jul 2023 12:10:56 -0700
Subject: [PATCH 2/5] chore: address pr comments

---
 .../analytics/__tests__/integration.test.ts   | 22 +++++++
 packages/browser/src/core/analytics/index.ts  |  2 +-
 ...orage.test.ts => universalStorage.test.ts} | 65 ++++++-------------
 packages/browser/src/core/storage/settings.ts | 11 +---
 packages/browser/src/core/storage/types.ts    |  3 +-
 packages/browser/src/core/user/index.ts       |  3 +-
 6 files changed, 47 insertions(+), 59 deletions(-)
 rename packages/browser/src/core/storage/__tests__/{prioritizedListStorage.test.ts => universalStorage.test.ts} (64%)

diff --git a/packages/browser/src/core/analytics/__tests__/integration.test.ts b/packages/browser/src/core/analytics/__tests__/integration.test.ts
index 5e2045b15..eb1c6beda 100644
--- a/packages/browser/src/core/analytics/__tests__/integration.test.ts
+++ b/packages/browser/src/core/analytics/__tests__/integration.test.ts
@@ -298,5 +298,27 @@ describe('Analytics', () => {
       expect(analytics.user().id()).toEqual('known-user')
       expect(setCookieSpy).toHaveBeenCalled()
     })
+
+    it('handles disabling storage', async () => {
+      const setCookieSpy = jest.spyOn(jar, 'set')
+      const expected = 'CookieValue'
+      jar.set('ajs_anonymous_id', expected)
+      localStorage.setItem('ajs_anonymous_id', 'localStorageValue')
+
+      const analytics = new Analytics(
+        { writeKey: '' },
+        {
+          storage: [StoreType.Cookie, StoreType.Memory],
+        }
+      )
+
+      expect(analytics.user().anonymousId()).toEqual(expected)
+
+      analytics.user().id('known-user')
+      expect(analytics.user().id()).toEqual('known-user')
+      expect(setCookieSpy).toHaveBeenCalled()
+      // Local storage shouldn't change
+      expect(localStorage.getItem('ajs_anonymous_id')).toBe('localStorageValue')
+    })
   })
 })
diff --git a/packages/browser/src/core/analytics/index.ts b/packages/browser/src/core/analytics/index.ts
index d958e974f..22ea0d97a 100644
--- a/packages/browser/src/core/analytics/index.ts
+++ b/packages/browser/src/core/analytics/index.ts
@@ -218,7 +218,7 @@ export class Analytics
     if (disablePersistance) {
       return new MemoryStorage()
     } else {
-      if (storageSetting !== undefined && storageSetting !== null) {
+      if (storageSetting) {
         if (isArrayOfStoreType(storageSetting)) {
           // We will create the store with the priority for customer settings
           return new UniversalStorage(
diff --git a/packages/browser/src/core/storage/__tests__/prioritizedListStorage.test.ts b/packages/browser/src/core/storage/__tests__/universalStorage.test.ts
similarity index 64%
rename from packages/browser/src/core/storage/__tests__/prioritizedListStorage.test.ts
rename to packages/browser/src/core/storage/__tests__/universalStorage.test.ts
index f05f5ad9d..e147cb5a7 100644
--- a/packages/browser/src/core/storage/__tests__/prioritizedListStorage.test.ts
+++ b/packages/browser/src/core/storage/__tests__/universalStorage.test.ts
@@ -3,7 +3,9 @@ import { CookieStorage } from '../cookieStorage'
 import { LocalStorage } from '../localStorage'
 import { MemoryStorage } from '../memoryStorage'
 import { UniversalStorage } from '../universalStorage'
-describe('prioritizedListStorage', function () {
+import { disableCookies, disableLocalStorage } from './test-helpers'
+
+describe('UniversalStorage', function () {
   const defaultTargets = [
     new CookieStorage(),
     new LocalStorage(),
@@ -14,6 +16,10 @@ describe('prioritizedListStorage', function () {
     clear()
   })
 
+  afterEach(() => {
+    jest.restoreAllMocks()
+  })
+
   function clear(): void {
     document.cookie.split(';').forEach(function (c) {
       document.cookie = c
@@ -48,8 +54,12 @@ describe('prioritizedListStorage', function () {
     it('order of default targets matters!', function () {
       jar.set('ajs_test_key', '🍊')
       localStorage.setItem('ajs_test_key', 'ðŸ’ū')
-      const us = new UniversalStorage(defaultTargets)
-      expect(us.get('ajs_test_key')).toEqual('🍊')
+      const us = new UniversalStorage([
+        new LocalStorage(),
+        new CookieStorage(),
+        new MemoryStorage(),
+      ])
+      expect(us.get('ajs_test_key')).toEqual('ðŸ’ū')
     })
 
     it('returns null if there are no storage targets', function () {
@@ -58,24 +68,10 @@ describe('prioritizedListStorage', function () {
       const us = new UniversalStorage([])
       expect(us.get('ajs_test_key')).toBeNull()
     })
-
-    //   it('can override the default targets', function () {
-    //     jar.set('ajs_test_key', '🍊')
-    //     localStorage.setItem('ajs_test_key', 'ðŸ’ū')
-    //     const us = new PrioritizedListStorage(
-    //       defaultTargets
-    //     )
-    //     expect(us.get('ajs_test_key', ['localStorage'])).toEqual('ðŸ’ū')
-    //     expect(us.get('ajs_test_key', ['localStorage', 'memory'])).toEqual('ðŸ’ū')
-    //     expect(us.get('ajs_test_key', ['cookie', 'memory'])).toEqual('🍊')
-    //     expect(us.get('ajs_test_key', ['cookie', 'localStorage'])).toEqual('🍊')
-    //     expect(us.get('ajs_test_key', ['cookie'])).toEqual('🍊')
-    //     expect(us.get('ajs_test_key', ['memory'])).toEqual(null)
-    //   })
   })
 
   describe('#set', function () {
-    it('set the data in all storage types', function () {
+    it('sets the data in all storage types', function () {
       const us = new UniversalStorage<{ ajs_test_key: string }>(defaultTargets)
       us.set('ajs_test_key', '💰')
       expect(jar.get('ajs_test_key')).toEqual('💰')
@@ -108,11 +104,10 @@ describe('prioritizedListStorage', function () {
     })
 
     it('does not write to cookies when cookies are not available', function () {
-      const cookieStore = new CookieStorage()
-      jest.spyOn(cookieStore, 'available', 'get').mockReturnValueOnce(false)
+      disableCookies()
       const us = new UniversalStorage([
         new LocalStorage(),
-        cookieStore,
+        new CookieStorage(),
         new MemoryStorage(),
       ])
       us.set('ajs_test_key', '💰')
@@ -122,38 +117,16 @@ describe('prioritizedListStorage', function () {
     })
 
     it('does not write to LS when LS is not available', function () {
-      const localStorage = new LocalStorage()
-      jest.spyOn(localStorage, 'available', 'get').mockReturnValueOnce(false)
+      disableLocalStorage()
       const us = new UniversalStorage([
-        localStorage,
+        new LocalStorage(),
         new CookieStorage(),
         new MemoryStorage(),
       ])
       us.set('ajs_test_key', '💰')
       expect(jar.get('ajs_test_key')).toEqual('💰')
-      expect(localStorage.get('ajs_test_key')).toEqual(null)
+      expect(localStorage.getItem('ajs_test_key')).toEqual(null)
       expect(us.get('ajs_test_key')).toEqual('💰')
     })
-
-    //   it('can override the default targets', function () {
-    //     const us = new UniversalStorage(
-    //       defaultTargets,
-    //       getAvailableStorageOptions()
-    //     )
-    //     us.set('ajs_test_key', '💰', ['localStorage'])
-    //     expect(jar.get('ajs_test_key')).toEqual(undefined)
-    //     expect(getFromLS('ajs_test_key')).toEqual('💰')
-    //     expect(us.get('ajs_test_key')).toEqual('💰')
-
-    //     us.set('ajs_test_key_2', 'ðŸĶī', ['cookie'])
-    //     expect(jar.get('ajs_test_key_2')).toEqual('ðŸĶī')
-    //     expect(localStorage.getItem('ajs_test_key_2')).toEqual(null)
-    //     expect(us.get('ajs_test_key_2')).toEqual('ðŸĶī')
-
-    //     us.set('ajs_test_key_3', 'ðŸ‘ŧ', [])
-    //     expect(jar.get('ajs_test_key_3')).toEqual(undefined)
-    //     expect(localStorage.getItem('ajs_test_key_3')).toEqual(null)
-    //     expect(us.get('ajs_test_key_3')).toEqual(null)
-    //   })
   })
 })
diff --git a/packages/browser/src/core/storage/settings.ts b/packages/browser/src/core/storage/settings.ts
index 2eab09876..1503bcb1c 100644
--- a/packages/browser/src/core/storage/settings.ts
+++ b/packages/browser/src/core/storage/settings.ts
@@ -4,21 +4,14 @@ export type StorageSettings = Storage | StoreType[]
 
 export function isArrayOfStoreType(s: StorageSettings): s is StoreType[] {
   return (
-    s !== undefined &&
-    s !== null &&
+    s &&
     Array.isArray(s) &&
     s.every((e) => Object.values(StoreType).includes(e))
   )
 }
 
 export function isStorageObject(s: StorageSettings): s is Storage {
-  return (
-    s !== undefined &&
-    s !== null &&
-    !Array.isArray(s) &&
-    typeof s === 'object' &&
-    s.get !== undefined
-  )
+  return s && !Array.isArray(s) && typeof s === 'object' && s.get !== undefined
 }
 
 export function isStoreTypeWithSettings(
diff --git a/packages/browser/src/core/storage/types.ts b/packages/browser/src/core/storage/types.ts
index 18f3b0f26..eaa0c192f 100644
--- a/packages/browser/src/core/storage/types.ts
+++ b/packages/browser/src/core/storage/types.ts
@@ -73,7 +73,8 @@ export abstract class BaseStorage<Data extends StorageObject = StorageObject>
   abstract set<K extends keyof Data>(key: K, value: Data[K] | null): void
   abstract clear<K extends keyof Data>(key: K): void
   /**
-   * By default a storage getAndSync will handle calls exactly as the
+   * By default a storage getAndSync will handle calls exactly as a normal get.
+   * getAndSync needs to be implemented for more complex storage types that might wrap several base storage systems (see UniversalStorage)
    */
   getAndSync<K extends keyof Data>(key: K): Data[K] | null {
     const val = this.get(key)
diff --git a/packages/browser/src/core/user/index.ts b/packages/browser/src/core/user/index.ts
index f62607dec..b958ff350 100644
--- a/packages/browser/src/core/user/index.ts
+++ b/packages/browser/src/core/user/index.ts
@@ -4,6 +4,7 @@ import { Traits } from '../events'
 import {
   CookieOptions,
   UniversalStorage,
+  MemoryStorage,
   Storage,
   StorageObject,
   StorageSettings,
@@ -13,8 +14,6 @@ import {
   isArrayOfStoreType,
   isStorageObject,
 } from '../storage'
-import { MemoryStorage } from '../storage/memoryStorage'
-import {} from '../storage/settings'
 
 export type ID = string | null | undefined
 

From eff7dc197d212e459f08d6d451ce8107a5096869 Mon Sep 17 00:00:00 2001
From: Oscar Bazaldua <511911+oscb@users.noreply.github.com>
Date: Fri, 28 Jul 2023 17:29:43 -0700
Subject: [PATCH 3/5] fix: remove custom storage option and interface

---
 .changeset/smooth-seahorses-unite.md          |  2 +-
 .../analytics/__tests__/integration.test.ts   | 12 ++++-
 packages/browser/src/core/analytics/index.ts  | 15 ++----
 .../storage/__tests__/cookieStorage.test.ts   | 18 -------
 .../storage/__tests__/localStorage.test.ts    |  2 +-
 .../core/storage/__tests__/test-helpers.ts    | 20 +++++---
 .../__tests__/universalStorage.test.ts        |  8 ++-
 .../browser/src/core/storage/cookieStorage.ts | 27 ++--------
 packages/browser/src/core/storage/index.ts    |  4 +-
 .../browser/src/core/storage/localStorage.ts  | 25 ++-------
 .../browser/src/core/storage/memoryStorage.ts | 18 ++-----
 packages/browser/src/core/storage/settings.ts | 20 ++++----
 packages/browser/src/core/storage/types.ts    | 51 +------------------
 .../src/core/storage/universalStorage.ts      | 51 +++++++++++--------
 .../src/core/user/__tests__/index.test.ts     | 46 +++++------------
 packages/browser/src/core/user/index.ts       | 23 ++++-----
 16 files changed, 117 insertions(+), 225 deletions(-)

diff --git a/.changeset/smooth-seahorses-unite.md b/.changeset/smooth-seahorses-unite.md
index ae8fa2dc9..ef526d79c 100644
--- a/.changeset/smooth-seahorses-unite.md
+++ b/.changeset/smooth-seahorses-unite.md
@@ -2,4 +2,4 @@
 '@segment/analytics-next': minor
 ---
 
-Adds storage option in analytics client to specify priority of storage (e.g use cookies over localstorage) or use a custom implementation
+Adds storage option in analytics client to specify priority of storage (e.g use cookies over localstorage)
diff --git a/packages/browser/src/core/analytics/__tests__/integration.test.ts b/packages/browser/src/core/analytics/__tests__/integration.test.ts
index eb1c6beda..73337107d 100644
--- a/packages/browser/src/core/analytics/__tests__/integration.test.ts
+++ b/packages/browser/src/core/analytics/__tests__/integration.test.ts
@@ -288,7 +288,13 @@ describe('Analytics', () => {
       const analytics = new Analytics(
         { writeKey: '' },
         {
-          storage: [StoreType.Cookie, StoreType.LocalStorage, StoreType.Memory],
+          storage: {
+            stores: [
+              StoreType.Cookie,
+              StoreType.LocalStorage,
+              StoreType.Memory,
+            ],
+          },
         }
       )
 
@@ -308,7 +314,9 @@ describe('Analytics', () => {
       const analytics = new Analytics(
         { writeKey: '' },
         {
-          storage: [StoreType.Cookie, StoreType.Memory],
+          storage: {
+            stores: [StoreType.Cookie, StoreType.Memory],
+          },
         }
       )
 
diff --git a/packages/browser/src/core/analytics/index.ts b/packages/browser/src/core/analytics/index.ts
index 22ea0d97a..ef88ed131 100644
--- a/packages/browser/src/core/analytics/index.ts
+++ b/packages/browser/src/core/analytics/index.ts
@@ -46,13 +46,11 @@ import {
   CookieOptions,
   MemoryStorage,
   UniversalStorage,
-  Storage,
   StorageSettings,
   StoreType,
   applyCookieOptions,
   initializeStorages,
   isArrayOfStoreType,
-  isStorageObject,
 } from '../storage'
 
 const deprecationWarning =
@@ -138,7 +136,7 @@ export class Analytics
   private _group: Group
   private eventFactory: EventFactory
   private _debug = false
-  private _universalStorage: Storage
+  private _universalStorage: UniversalStorage
 
   initialized = false
   integrations: Integrations
@@ -213,22 +211,19 @@ export class Analytics
     disablePersistance: boolean,
     storageSetting: InitOptions['storage'],
     cookieOptions?: CookieOptions | undefined
-  ): Storage {
+  ): UniversalStorage {
     // DisablePersistance option overrides all, no storage will be used outside of memory even if specified
     if (disablePersistance) {
-      return new MemoryStorage()
+      return new UniversalStorage([new MemoryStorage()])
     } else {
       if (storageSetting) {
         if (isArrayOfStoreType(storageSetting)) {
           // We will create the store with the priority for customer settings
           return new UniversalStorage(
             initializeStorages(
-              applyCookieOptions(storageSetting, cookieOptions)
+              applyCookieOptions(storageSetting.stores, cookieOptions)
             )
           )
-        } else if (isStorageObject(storageSetting)) {
-          // If it is an object we will use the customer provided storage
-          return storageSetting
         }
       }
     }
@@ -245,7 +240,7 @@ export class Analytics
     )
   }
 
-  get storage(): Storage {
+  get storage(): UniversalStorage {
     return this._universalStorage
   }
 
diff --git a/packages/browser/src/core/storage/__tests__/cookieStorage.test.ts b/packages/browser/src/core/storage/__tests__/cookieStorage.test.ts
index da02beccc..3ac9dd34f 100644
--- a/packages/browser/src/core/storage/__tests__/cookieStorage.test.ts
+++ b/packages/browser/src/core/storage/__tests__/cookieStorage.test.ts
@@ -1,6 +1,5 @@
 import { CookieStorage } from '../cookieStorage'
 import jar from 'js-cookie'
-import { disableCookies } from './test-helpers'
 
 describe('cookieStorage', () => {
   function clearCookies() {
@@ -15,23 +14,6 @@ describe('cookieStorage', () => {
     clearCookies()
   })
 
-  describe('#available', () => {
-    afterEach(() => {
-      jest.restoreAllMocks()
-    })
-
-    it('is available', () => {
-      const cookie = new CookieStorage()
-      expect(cookie.available).toBe(true)
-    })
-
-    it("is unavailable if can't write cookies", () => {
-      disableCookies()
-      const cookie = new CookieStorage()
-      expect(cookie.available).toBe(false)
-    })
-  })
-
   describe('cookie options', () => {
     it('should have default cookie options', () => {
       const cookie = new CookieStorage()
diff --git a/packages/browser/src/core/storage/__tests__/localStorage.test.ts b/packages/browser/src/core/storage/__tests__/localStorage.test.ts
index 30c73a121..09c3b9f2d 100644
--- a/packages/browser/src/core/storage/__tests__/localStorage.test.ts
+++ b/packages/browser/src/core/storage/__tests__/localStorage.test.ts
@@ -63,7 +63,7 @@ describe('LocalStorage', function () {
     it('should be able to remove a record', function () {
       store.set('x', { a: 'b' })
       expect(store.get('x')).toStrictEqual({ a: 'b' })
-      store.clear('x')
+      store.remove('x')
       expect(store.get('x')).toBe(null)
     })
   })
diff --git a/packages/browser/src/core/storage/__tests__/test-helpers.ts b/packages/browser/src/core/storage/__tests__/test-helpers.ts
index 7bd5af18b..20faf069a 100644
--- a/packages/browser/src/core/storage/__tests__/test-helpers.ts
+++ b/packages/browser/src/core/storage/__tests__/test-helpers.ts
@@ -1,19 +1,27 @@
+import jar from 'js-cookie'
 /**
  * Disables Cookies
  * @returns jest spy
  */
-export function disableCookies(): jest.SpyInstance {
-  return jest
-    .spyOn(window.navigator, 'cookieEnabled', 'get')
-    .mockReturnValue(false)
+export function disableCookies(): void {
+  jest.spyOn(window.navigator, 'cookieEnabled', 'get').mockReturnValue(false)
+  jest.spyOn(jar, 'set').mockImplementation(() => {
+    throw new Error()
+  })
+  jest.spyOn(jar, 'get').mockImplementation(() => {
+    throw new Error()
+  })
 }
 
 /**
  * Disables LocalStorage
  * @returns jest spy
  */
-export function disableLocalStorage(): jest.SpyInstance {
-  return jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
+export function disableLocalStorage(): void {
+  jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
+    throw new Error()
+  })
+  jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
     throw new Error()
   })
 }
diff --git a/packages/browser/src/core/storage/__tests__/universalStorage.test.ts b/packages/browser/src/core/storage/__tests__/universalStorage.test.ts
index e147cb5a7..93bdaffc9 100644
--- a/packages/browser/src/core/storage/__tests__/universalStorage.test.ts
+++ b/packages/browser/src/core/storage/__tests__/universalStorage.test.ts
@@ -103,21 +103,20 @@ describe('UniversalStorage', function () {
       expect(us.get('ajs_test_key')).toEqual('💰')
     })
 
-    it('does not write to cookies when cookies are not available', function () {
-      disableCookies()
+    it('handles cookie errors gracefully', function () {
+      disableCookies() // Cookies is going to throw exceptions now
       const us = new UniversalStorage([
         new LocalStorage(),
         new CookieStorage(),
         new MemoryStorage(),
       ])
       us.set('ajs_test_key', '💰')
-      expect(jar.get('ajs_test_key')).toEqual(undefined)
       expect(getFromLS('ajs_test_key')).toEqual('💰')
       expect(us.get('ajs_test_key')).toEqual('💰')
     })
 
     it('does not write to LS when LS is not available', function () {
-      disableLocalStorage()
+      disableLocalStorage() // Localstorage will throw exceptions
       const us = new UniversalStorage([
         new LocalStorage(),
         new CookieStorage(),
@@ -125,7 +124,6 @@ describe('UniversalStorage', function () {
       ])
       us.set('ajs_test_key', '💰')
       expect(jar.get('ajs_test_key')).toEqual('💰')
-      expect(localStorage.getItem('ajs_test_key')).toEqual(null)
       expect(us.get('ajs_test_key')).toEqual('💰')
     })
   })
diff --git a/packages/browser/src/core/storage/cookieStorage.ts b/packages/browser/src/core/storage/cookieStorage.ts
index 02f5eb50a..9450d55d9 100644
--- a/packages/browser/src/core/storage/cookieStorage.ts
+++ b/packages/browser/src/core/storage/cookieStorage.ts
@@ -1,4 +1,4 @@
-import { BaseStorage, StorageObject, StoreType } from './types'
+import { Store, StorageObject } from './types'
 import jar from 'js-cookie'
 import { tld } from '../user/tld'
 
@@ -15,25 +15,9 @@ export interface CookieOptions {
 /**
  * Data storage using browser cookies
  */
-export class CookieStorage<
-  Data extends StorageObject = StorageObject
-> extends BaseStorage {
-  get available(): boolean {
-    let cookieEnabled = window.navigator.cookieEnabled
-
-    if (!cookieEnabled) {
-      jar.set('ajs:cookies', 'test')
-      cookieEnabled = document.cookie.includes('ajs:cookies')
-      jar.remove('ajs:cookies')
-    }
-
-    return cookieEnabled
-  }
-
-  get type() {
-    return StoreType.Cookie
-  }
-
+export class CookieStorage<Data extends StorageObject = StorageObject>
+  implements Store<Data>
+{
   static get defaults(): CookieOptions {
     return {
       maxage: ONE_YEAR,
@@ -46,7 +30,6 @@ export class CookieStorage<
   private options: Required<CookieOptions>
 
   constructor(options: CookieOptions = CookieStorage.defaults) {
-    super()
     this.options = {
       ...CookieStorage.defaults,
       ...options,
@@ -91,7 +74,7 @@ export class CookieStorage<
     }
   }
 
-  clear<K extends keyof Data>(key: K): void {
+  remove<K extends keyof Data>(key: K): void {
     return jar.remove(key, this.opts())
   }
 }
diff --git a/packages/browser/src/core/storage/index.ts b/packages/browser/src/core/storage/index.ts
index 5793795b0..aeb9a3b84 100644
--- a/packages/browser/src/core/storage/index.ts
+++ b/packages/browser/src/core/storage/index.ts
@@ -2,7 +2,7 @@ import { CookieOptions, CookieStorage } from './cookieStorage'
 import { LocalStorage } from './localStorage'
 import { MemoryStorage } from './memoryStorage'
 import { isStoreTypeWithSettings } from './settings'
-import { StoreType, Storage, InitializeStorageArgs } from './types'
+import { StoreType, Store, InitializeStorageArgs } from './types'
 
 export * from './types'
 export * from './localStorage'
@@ -16,7 +16,7 @@ export * from './settings'
  * @param args StoreType and options
  * @returns Storage array
  */
-export function initializeStorages(args: InitializeStorageArgs): Storage[] {
+export function initializeStorages(args: InitializeStorageArgs): Store[] {
   const storages = args.map((s) => {
     let type: StoreType
     let settings
diff --git a/packages/browser/src/core/storage/localStorage.ts b/packages/browser/src/core/storage/localStorage.ts
index f5385f965..9eb369d77 100644
--- a/packages/browser/src/core/storage/localStorage.ts
+++ b/packages/browser/src/core/storage/localStorage.ts
@@ -1,30 +1,15 @@
-import { BaseStorage, StorageObject, StoreType } from './types'
+import { StorageObject, Store } from './types'
 
 /**
  * Data storage using browser's localStorage
  */
-export class LocalStorage<
-  Data extends StorageObject = StorageObject
-> extends BaseStorage {
+export class LocalStorage<Data extends StorageObject = StorageObject>
+  implements Store<Data>
+{
   private localStorageWarning(key: keyof Data, state: 'full' | 'unavailable') {
     console.warn(`Unable to access ${key}, localStorage may be ${state}`)
   }
 
-  get type() {
-    return StoreType.LocalStorage
-  }
-
-  get available(): boolean {
-    const test = 'test'
-    try {
-      localStorage.setItem(test, test)
-      localStorage.removeItem(test)
-      return true
-    } catch (e) {
-      return false
-    }
-  }
-
   get<K extends keyof Data>(key: K): Data[K] | null {
     try {
       const val = localStorage.getItem(key)
@@ -50,7 +35,7 @@ export class LocalStorage<
     }
   }
 
-  clear<K extends keyof Data>(key: K): void {
+  remove<K extends keyof Data>(key: K): void {
     try {
       return localStorage.removeItem(key)
     } catch (err) {
diff --git a/packages/browser/src/core/storage/memoryStorage.ts b/packages/browser/src/core/storage/memoryStorage.ts
index fa9a3d580..7840e7ba7 100644
--- a/packages/browser/src/core/storage/memoryStorage.ts
+++ b/packages/browser/src/core/storage/memoryStorage.ts
@@ -1,21 +1,13 @@
-import { BaseStorage, StorageObject, StoreType } from './types'
+import { Store, StorageObject } from './types'
 
 /**
  * Data Storage using in memory object
  */
-export class MemoryStorage<
-  Data extends StorageObject = StorageObject
-> extends BaseStorage<Data> {
+export class MemoryStorage<Data extends StorageObject = StorageObject>
+  implements Store<Data>
+{
   private cache: Record<string, unknown> = {}
 
-  get type() {
-    return StoreType.Memory
-  }
-
-  get available(): boolean {
-    return true
-  }
-
   get<K extends keyof Data>(key: K): Data[K] | null {
     return (this.cache[key] ?? null) as Data[K] | null
   }
@@ -24,7 +16,7 @@ export class MemoryStorage<
     this.cache[key] = value
   }
 
-  clear<K extends keyof Data>(key: K): void {
+  remove<K extends keyof Data>(key: K): void {
     delete this.cache[key]
   }
 }
diff --git a/packages/browser/src/core/storage/settings.ts b/packages/browser/src/core/storage/settings.ts
index 1503bcb1c..cc11a58fb 100644
--- a/packages/browser/src/core/storage/settings.ts
+++ b/packages/browser/src/core/storage/settings.ts
@@ -1,19 +1,21 @@
-import { Storage, StoreType, StoreTypeWithSettings } from './types'
+import { StoreType, StoreTypeWithSettings } from './types'
 
-export type StorageSettings = Storage | StoreType[]
+export type UniversalStorageSettings = { stores: StoreType[] }
 
-export function isArrayOfStoreType(s: StorageSettings): s is StoreType[] {
+// This is setup this way to permit eventually a different set of settings for custom storage
+export type StorageSettings = UniversalStorageSettings
+
+export function isArrayOfStoreType(
+  s: StorageSettings
+): s is UniversalStorageSettings {
   return (
     s &&
-    Array.isArray(s) &&
-    s.every((e) => Object.values(StoreType).includes(e))
+    s.stores &&
+    Array.isArray(s.stores) &&
+    s.stores.every((e) => Object.values(StoreType).includes(e))
   )
 }
 
-export function isStorageObject(s: StorageSettings): s is Storage {
-  return s && !Array.isArray(s) && typeof s === 'object' && s.get !== undefined
-}
-
 export function isStoreTypeWithSettings(
   s: StoreTypeWithSettings | StoreType
 ): s is StoreTypeWithSettings {
diff --git a/packages/browser/src/core/storage/types.ts b/packages/browser/src/core/storage/types.ts
index eaa0c192f..c2943ebe8 100644
--- a/packages/browser/src/core/storage/types.ts
+++ b/packages/browser/src/core/storage/types.ts
@@ -16,36 +16,14 @@ export type StorageObject = Record<string, unknown>
 /**
  * Defines a Storage object for use in AJS Client.
  */
-export interface Storage<Data extends StorageObject = StorageObject> {
-  /**
-   * Returns the kind of storage.
-   * @example cookie, localStorage, custom
-   */
-  get type(): StoreType | string
-
-  /**
-   * Tests if the storage is available for use in the current environment
-   */
-  get available(): boolean
+export interface Store<Data extends StorageObject = StorageObject> {
   /**
    * get value for the key from the stores. it will return the first value found in the stores
    * @param key key for the value to be retrieved
    * @returns value for the key or null if not found
    */
   get<K extends keyof Data>(key: K): Data[K] | null
-  /*
-    This is to support few scenarios where:
-    - value exist in one of the stores ( as a result of other stores being cleared from browser ) and we want to resync them
-    - read values in AJS 1.0 format ( for customers after 1.0 --> 2.0 migration ) and then re-write them in AJS 2.0 format
-  */
 
-  /**
-   * get value for the key from the stores. it will pick the first value found in the stores, and then sync the value to all the stores
-   * if the found value is a number, it will be converted to a string. this is to support legacy behavior that existed in AJS 1.0
-   * @param key key for the value to be retrieved
-   * @returns value for the key or null if not found
-   */
-  getAndSync<K extends keyof Data>(key: K): Data[K] | null
   /**
    * it will set the value for the key in all the stores
    * @param key key for the value to be stored
@@ -58,32 +36,7 @@ export interface Storage<Data extends StorageObject = StorageObject> {
    * @param key key for the value to be removed
    * @param storeTypes optional array of store types to be used for removing the value
    */
-  clear<K extends keyof Data>(key: K): void
-}
-
-/**
- * Abstract class for creating basic storage systems
- */
-export abstract class BaseStorage<Data extends StorageObject = StorageObject>
-  implements Storage<Data>
-{
-  abstract get type(): StoreType | string
-  abstract get available(): boolean
-  abstract get<K extends keyof Data>(key: K): Data[K] | null
-  abstract set<K extends keyof Data>(key: K, value: Data[K] | null): void
-  abstract clear<K extends keyof Data>(key: K): void
-  /**
-   * By default a storage getAndSync will handle calls exactly as a normal get.
-   * getAndSync needs to be implemented for more complex storage types that might wrap several base storage systems (see UniversalStorage)
-   */
-  getAndSync<K extends keyof Data>(key: K): Data[K] | null {
-    const val = this.get(key)
-    // legacy behavior, getAndSync can change the type of a value from number to string (AJS 1.0 stores numerical values as a number)
-    const coercedValue = (typeof val === 'number' ? val.toString() : val) as
-      | Data[K]
-      | null
-    return coercedValue
-  }
+  remove<K extends keyof Data>(key: K): void
 }
 
 export interface StoreTypeWithSettings<T extends StoreType = StoreType> {
diff --git a/packages/browser/src/core/storage/universalStorage.ts b/packages/browser/src/core/storage/universalStorage.ts
index 9e16bac38..41cebf926 100644
--- a/packages/browser/src/core/storage/universalStorage.ts
+++ b/packages/browser/src/core/storage/universalStorage.ts
@@ -1,45 +1,56 @@
-import { Storage, StorageObject } from './types'
+import { Store, StorageObject } from './types'
 
 /**
  * Uses multiple storages in a priority list to get/set values in the order they are specified.
  */
-export class UniversalStorage<Data extends StorageObject = StorageObject>
-  implements Storage<Data>
-{
-  private stores: Storage[]
+export class UniversalStorage<Data extends StorageObject = StorageObject> {
+  private stores: Store[]
 
-  constructor(stores: Storage[]) {
-    this.stores = stores.filter((s) => s.available)
-  }
-
-  get available(): boolean {
-    return this.stores.some((s) => s.available)
-  }
-
-  get type(): string {
-    return 'PriorityListStorage'
+  constructor(stores: Store[]) {
+    this.stores = stores
   }
 
   get<K extends keyof Data>(key: K): Data[K] | null {
     let val: Data[K] | null = null
 
     for (const store of this.stores) {
-      val = store.get(key) as Data[K] | null
-      if (val !== undefined && val !== null) {
-        return val
+      try {
+        val = store.get(key) as Data[K] | null
+        if (val !== undefined && val !== null) {
+          return val
+        }
+      } catch (e) {
+        console.warn(`Can't access ${key}: ${e}`)
       }
     }
     return null
   }
 
   set<K extends keyof Data>(key: K, value: Data[K] | null): void {
-    this.stores.forEach((s) => s.set(key, value))
+    this.stores.forEach((s) => {
+      try {
+        s.set(key, value)
+      } catch (e) {
+        console.warn(`Can't set ${key}: ${e}`)
+      }
+    })
   }
 
   clear<K extends keyof Data>(key: K): void {
-    this.stores.forEach((s) => s.clear(key))
+    this.stores.forEach((s) => {
+      try {
+        s.remove(key)
+      } catch (e) {
+        console.warn(`Can't remove ${key}: ${e}`)
+      }
+    })
   }
 
+  /*
+    This is to support few scenarios where:
+    - value exist in one of the stores ( as a result of other stores being cleared from browser ) and we want to resync them
+    - read values in AJS 1.0 format ( for customers after 1.0 --> 2.0 migration ) and then re-write them in AJS 2.0 format
+  */
   getAndSync<K extends keyof Data>(key: K): Data[K] | null {
     const val = this.get(key)
 
diff --git a/packages/browser/src/core/user/__tests__/index.test.ts b/packages/browser/src/core/user/__tests__/index.test.ts
index 045c3cedd..f047ef4d6 100644
--- a/packages/browser/src/core/user/__tests__/index.test.ts
+++ b/packages/browser/src/core/user/__tests__/index.test.ts
@@ -1,12 +1,11 @@
 import assert from 'assert'
 import jar from 'js-cookie'
 import { Group, User } from '..'
-import { LocalStorage, StoreType, Storage } from '../../storage'
+import { LocalStorage, StoreType, Store } from '../../storage'
 import {
   disableCookies,
   disableLocalStorage,
 } from '../../storage/__tests__/test-helpers'
-import { MemoryStorage } from '../../storage/memoryStorage'
 
 function clear(): void {
   document.cookie.split(';').forEach(function (c) {
@@ -149,9 +148,6 @@ describe('user', () => {
       it('should get an id from memory', () => {
         user.id('id')
         assert(user.id() === 'id')
-
-        expect(jar.get(cookieKey)).toBeFalsy()
-        expect(store.get(cookieKey)).toBeFalsy()
       })
 
       it('should get an id when not persisting', () => {
@@ -197,9 +193,6 @@ describe('user', () => {
       it('should get an id from memory', () => {
         user.id('id')
         assert(user.id() === 'id')
-
-        expect(jar.get(cookieKey)).toBeFalsy()
-        expect(store.get(cookieKey)).toBeFalsy()
       })
 
       it('should be null by default', () => {
@@ -340,7 +333,6 @@ describe('user', () => {
       it('should get an id from memory', () => {
         user.anonymousId('anon-id')
         assert(user.anonymousId() === 'anon-id')
-        expect(jar.get('ajs_anonymous_id')).toBeFalsy()
       })
     })
 
@@ -731,7 +723,9 @@ describe('user', () => {
       jar.set('ajs_anonymous_id', expected)
       store.set('ajs_anonymous_id', 'localStorageValue')
       const user = new User({
-        storage: [StoreType.Cookie, StoreType.LocalStorage, StoreType.Memory],
+        storage: {
+          stores: [StoreType.Cookie, StoreType.LocalStorage, StoreType.Memory],
+        },
       })
       expect(user.anonymousId()).toEqual(expected)
     })
@@ -743,7 +737,9 @@ describe('user', () => {
       disableCookies()
       store.set('ajs_anonymous_id', expected)
       const user = new User({
-        storage: [StoreType.Cookie, StoreType.LocalStorage, StoreType.Memory],
+        storage: {
+          stores: [StoreType.Cookie, StoreType.LocalStorage, StoreType.Memory],
+        },
       })
       expect(user.anonymousId()).toEqual(expected)
     })
@@ -751,7 +747,9 @@ describe('user', () => {
     it('persist option overrides any custom storage', () => {
       const setCookieSpy = jest.spyOn(jar, 'set')
       const user = new User({
-        storage: [StoreType.Cookie, StoreType.LocalStorage, StoreType.Memory],
+        storage: {
+          stores: [StoreType.Cookie, StoreType.LocalStorage, StoreType.Memory],
+        },
         persist: false,
       })
       user.id('id')
@@ -765,7 +763,9 @@ describe('user', () => {
     it('disable option overrides any custom storage', () => {
       const setCookieSpy = jest.spyOn(jar, 'set')
       const user = new User({
-        storage: [StoreType.Cookie, StoreType.LocalStorage, StoreType.Memory],
+        storage: {
+          stores: [StoreType.Cookie, StoreType.LocalStorage, StoreType.Memory],
+        },
         disable: true,
       })
       user.id('id')
@@ -775,26 +775,6 @@ describe('user', () => {
       expect(store.get('ajs_user_id')).toBeFalsy()
       expect(setCookieSpy.mock.calls.length).toBe(0)
     })
-
-    it('can use a fully custom storage object', () => {
-      const customStore: Storage = {
-        get type() {
-          return 'something'
-        },
-        get available() {
-          return true
-        },
-        get: jest.fn().mockReturnValue('custom'),
-        set: jest.fn(),
-        clear: jest.fn(),
-        getAndSync: jest.fn().mockReturnValue('custom'),
-      }
-
-      const user = new User({ storage: customStore })
-      user.id('id')
-      expect(customStore.set).toHaveBeenCalled()
-      expect(user.id()).toBe('custom')
-    })
   })
 })
 
diff --git a/packages/browser/src/core/user/index.ts b/packages/browser/src/core/user/index.ts
index b958ff350..cd9d86dc6 100644
--- a/packages/browser/src/core/user/index.ts
+++ b/packages/browser/src/core/user/index.ts
@@ -5,14 +5,12 @@ import {
   CookieOptions,
   UniversalStorage,
   MemoryStorage,
-  Storage,
   StorageObject,
   StorageSettings,
   StoreType,
   applyCookieOptions,
   initializeStorages,
   isArrayOfStoreType,
-  isStorageObject,
 } from '../storage'
 
 export type ID = string | null | undefined
@@ -35,8 +33,8 @@ export interface UserOptions {
   }
 
   /**
-   * Storage system to use
-   * @example new MemoryStorage, [StoreType.Cookie, StoreType.Memory]
+   * Store priority
+   * @example stores: [StoreType.Cookie, StoreType.Memory]
    */
   storage?: StorageSettings
 }
@@ -60,7 +58,7 @@ export class User {
   private anonKey: string
   private cookieOptions?: CookieOptions
 
-  private legacyUserStore: Storage<{
+  private legacyUserStore: UniversalStorage<{
     [k: string]:
       | {
           id?: string
@@ -68,11 +66,11 @@ export class User {
         }
       | string
   }>
-  private traitsStore: Storage<{
+  private traitsStore: UniversalStorage<{
     [k: string]: Traits
   }>
 
-  private identityStore: Storage<{
+  private identityStore: UniversalStorage<{
     [k: string]: string
   }>
 
@@ -235,7 +233,7 @@ export class User {
     options: UserOptions,
     cookieOpts?: CookieOptions,
     filterStores?: (value: StoreType) => boolean
-  ): Storage<T> {
+  ): UniversalStorage<T> {
     let stores: StoreType[] = [
       StoreType.LocalStorage,
       StoreType.Cookie,
@@ -249,16 +247,13 @@ export class User {
 
     // If persistance is disabled we will always fallback to Memory Storage
     if (!options.persist) {
-      return new MemoryStorage<T>()
+      return new UniversalStorage<T>([new MemoryStorage<T>()])
     }
 
     if (options.storage !== undefined && options.storage !== null) {
-      // If the user is sending its own storage implementation we will use that without any modifications
-      if (isStorageObject(options.storage)) {
-        return options.storage as Storage<T>
-      } else if (isArrayOfStoreType(options.storage)) {
+      if (isArrayOfStoreType(options.storage)) {
         // If the user only specified order of stores we will still apply filters and transformations e.g. not using localStorage if localStorageFallbackDisabled
-        stores = options.storage
+        stores = options.storage.stores
       }
     }
 

From aa14d570a5402eb8c19e3b7ecd358666b125875b Mon Sep 17 00:00:00 2001
From: Oscar Bazaldua <511911+oscb@users.noreply.github.com>
Date: Mon, 7 Aug 2023 10:37:37 -0700
Subject: [PATCH 4/5] chore: replace enum with const type

---
 packages/browser/src/core/storage/types.ts         | 14 ++++++++------
 .../browser/src/core/user/__tests__/index.test.ts  |  2 +-
 2 files changed, 9 insertions(+), 7 deletions(-)

diff --git a/packages/browser/src/core/storage/types.ts b/packages/browser/src/core/storage/types.ts
index c2943ebe8..e64bc0f1a 100644
--- a/packages/browser/src/core/storage/types.ts
+++ b/packages/browser/src/core/storage/types.ts
@@ -1,15 +1,17 @@
 import { CookieOptions } from './cookieStorage'
 
+export const StoreType = {
+  Cookie: 'cookie',
+  LocalStorage: 'localStorage',
+  Memory: 'memory',
+} as const
+
 /**
  * Known Storage Types
  *
  * Convenience settings for storage systems that AJS includes support for
  */
-export enum StoreType {
-  Cookie = 'cookie',
-  LocalStorage = 'localStorage',
-  Memory = 'memory',
-}
+export type StoreType = typeof StoreType[keyof typeof StoreType]
 
 export type StorageObject = Record<string, unknown>
 
@@ -41,7 +43,7 @@ export interface Store<Data extends StorageObject = StorageObject> {
 
 export interface StoreTypeWithSettings<T extends StoreType = StoreType> {
   name: T
-  settings?: T extends StoreType.Cookie ? CookieOptions : never
+  settings?: T extends 'cookie' ? CookieOptions : never
 }
 
 export type InitializeStorageArgs = (StoreTypeWithSettings | StoreType)[]
diff --git a/packages/browser/src/core/user/__tests__/index.test.ts b/packages/browser/src/core/user/__tests__/index.test.ts
index f047ef4d6..46048c1b2 100644
--- a/packages/browser/src/core/user/__tests__/index.test.ts
+++ b/packages/browser/src/core/user/__tests__/index.test.ts
@@ -1,7 +1,7 @@
 import assert from 'assert'
 import jar from 'js-cookie'
 import { Group, User } from '..'
-import { LocalStorage, StoreType, Store } from '../../storage'
+import { LocalStorage, StoreType } from '../../storage'
 import {
   disableCookies,
   disableLocalStorage,

From 8332060f49da5a7b69887d4a8fe5ea3a00970723 Mon Sep 17 00:00:00 2001
From: Oscar Bazaldua <511911+oscb@users.noreply.github.com>
Date: Tue, 8 Aug 2023 10:27:31 -0700
Subject: [PATCH 5/5] fix: adding test for cookie getter override

---
 .../storage/__tests__/universalStorage.test.ts    | 14 ++++++++++++++
 .../src/core/user/__tests__/storage.test.ts       | 15 ---------------
 2 files changed, 14 insertions(+), 15 deletions(-)
 delete mode 100644 packages/browser/src/core/user/__tests__/storage.test.ts

diff --git a/packages/browser/src/core/storage/__tests__/universalStorage.test.ts b/packages/browser/src/core/storage/__tests__/universalStorage.test.ts
index 93bdaffc9..3c813ebb9 100644
--- a/packages/browser/src/core/storage/__tests__/universalStorage.test.ts
+++ b/packages/browser/src/core/storage/__tests__/universalStorage.test.ts
@@ -126,5 +126,19 @@ describe('UniversalStorage', function () {
       expect(jar.get('ajs_test_key')).toEqual('💰')
       expect(us.get('ajs_test_key')).toEqual('💰')
     })
+
+    it('handles cookie getter overrides gracefully', function () {
+      ;(document as any).__defineGetter__('cookie', function () {
+        return ''
+      })
+      const us = new UniversalStorage([
+        new LocalStorage(),
+        new CookieStorage(),
+        new MemoryStorage(),
+      ])
+      us.set('ajs_test_key', '💰')
+      expect(getFromLS('ajs_test_key')).toEqual('💰')
+      expect(us.get('ajs_test_key')).toEqual('💰')
+    })
   })
 })
diff --git a/packages/browser/src/core/user/__tests__/storage.test.ts b/packages/browser/src/core/user/__tests__/storage.test.ts
deleted file mode 100644
index 30100a30c..000000000
--- a/packages/browser/src/core/user/__tests__/storage.test.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import { Cookie } from '..'
-
-describe('Cookie storage', () => {
-  it('should report cookie storage available when cookies are accessible', () => {
-    expect(Cookie.available()).toBe(true)
-  })
-
-  it('should report cookie storage unavailable when cookies are not accessible', () => {
-    ;(document as any).__defineGetter__('cookie', function () {
-      return ''
-    })
-
-    expect(Cookie.available()).toBe(false)
-  })
-})