Skip to main content

Overview

Storage adapters allow the achievements-manager engine to persist data to different storage backends. The library provides two built-in adapters and supports custom implementations.

StorageAdapter Interface

All storage adapters implement a simple three-method interface:
// From packages/core/src/types.ts
export type StorageAdapter = {
  get(key: string): string | null;
  set(key: string, value: string): void;
  remove(key: string): void;
};
get
(key: string) => string | null
Retrieves the value for the given key. Returns null if the key doesn’t exist.
set
(key: string, value: string) => void
Stores a value for the given key. Overwrites any existing value.
remove
(key: string) => void
Removes the value for the given key.
All storage values are serialized as JSON strings. The adapter only needs to handle string storage and retrieval.

Built-in Adapters

localStorageAdapter (Default)

Persists data to the browser’s localStorage. This is the default adapter if none is specified:
import { createAchievements, localStorageAdapter } from 'achievements-manager';

const engine = createAchievements({
  definitions: achievements,
  // localStorageAdapter is used by default, but you can specify it explicitly:
  storage: localStorageAdapter(),
});

Optional Prefix

You can provide a prefix to namespace your storage keys:
const engine = createAchievements({
  definitions: achievements,
  storage: localStorageAdapter('my-game'),
});

// Keys will be stored as:
// 'my-game:unlocked'
// 'my-game:progress'
// 'my-game:items'

Implementation

// From packages/core/src/adapters.ts:3-31
export function localStorageAdapter(prefix?: string): StorageAdapter {
  const k = (key: string) => (prefix ? `${prefix}:${key}` : key);
  return {
    get(key) {
      if (typeof window === 'undefined') return null;
      try {
        return localStorage.getItem(k(key));
      } catch {
        return null;
      }
    },
    set(key, value) {
      if (typeof window === 'undefined') return;
      try {
        localStorage.setItem(k(key), value);
      } catch {
        // Storage unavailable or full — silently ignore
      }
    },
    remove(key) {
      if (typeof window === 'undefined') return;
      try {
        localStorage.removeItem(k(key));
      } catch {
        // Storage unavailable — silently ignore
      }
    },
  };
}
The adapter gracefully handles server-side rendering (SSR) environments by checking for window and catching exceptions.

inMemoryAdapter

Stores data in memory using a Map. Data is lost when the page reloads. Useful for testing or temporary sessions:
import { createAchievements, inMemoryAdapter } from 'achievements-manager';

const engine = createAchievements({
  definitions: achievements,
  storage: inMemoryAdapter(),
});

Implementation

// From packages/core/src/adapters.ts:50-61
export function inMemoryAdapter(): StorageAdapter {
  const store = new Map<string, string>();
  return {
    get: (key) => store.get(key) ?? null,
    set: (key, value) => {
      store.set(key, value);
    },
    remove: (key) => {
      store.delete(key);
    },
  };
}
Data stored with inMemoryAdapter is not persisted across page reloads or browser sessions.

Custom Storage Adapters

You can create custom adapters for any storage backend by implementing the StorageAdapter interface.

Example: IndexedDB Adapter

function indexedDBAdapter(dbName: string): StorageAdapter {
  let db: IDBDatabase;

  // Initialize database (simplified for example)
  const init = async () => {
    const request = indexedDB.open(dbName, 1);
    return new Promise<IDBDatabase>((resolve, reject) => {
      request.onsuccess = () => resolve(request.result);
      request.onerror = () => reject(request.error);
      request.onupgradeneeded = (e) => {
        const db = (e.target as IDBOpenDBRequest).result;
        db.createObjectStore('achievements');
      };
    });
  };

  init().then(database => { db = database; });

  return {
    get(key: string): string | null {
      // Note: Real implementation would need to handle async
      // This is a simplified synchronous example
      const transaction = db.transaction(['achievements'], 'readonly');
      const store = transaction.objectStore('achievements');
      const request = store.get(key);
      return request.result ?? null;
    },
    set(key: string, value: string): void {
      const transaction = db.transaction(['achievements'], 'readwrite');
      const store = transaction.objectStore('achievements');
      store.put(value, key);
    },
    remove(key: string): void {
      const transaction = db.transaction(['achievements'], 'readwrite');
      const store = transaction.objectStore('achievements');
      store.delete(key);
    },
  };
}
The current StorageAdapter interface expects synchronous methods. For truly async storage (like IndexedDB), you may need to use a synchronous wrapper or propose changes to the library to support async adapters.

Example: Session Storage Adapter

function sessionStorageAdapter(prefix?: string): StorageAdapter {
  const k = (key: string) => (prefix ? `${prefix}:${key}` : key);
  return {
    get(key) {
      if (typeof window === 'undefined') return null;
      try {
        return sessionStorage.getItem(k(key));
      } catch {
        return null;
      }
    },
    set(key, value) {
      if (typeof window === 'undefined') return;
      try {
        sessionStorage.setItem(k(key), value);
      } catch {
        // Storage unavailable or full
      }
    },
    remove(key) {
      if (typeof window === 'undefined') return;
      try {
        sessionStorage.removeItem(k(key));
      } catch {
        // Storage unavailable
      }
    },
  };
}

Example: Async Storage Adapter (React Native)

import AsyncStorage from '@react-native-async-storage/async-storage';

function asyncStorageAdapter(): StorageAdapter {
  // Cache to provide synchronous access
  const cache = new Map<string, string>();
  let initialized = false;

  // Preload cache on first access
  const ensureInitialized = async () => {
    if (initialized) return;
    const keys = await AsyncStorage.getAllKeys();
    const entries = await AsyncStorage.multiGet(keys);
    entries.forEach(([key, value]) => {
      if (value !== null) cache.set(key, value);
    });
    initialized = true;
  };

  // Trigger initialization
  ensureInitialized();

  return {
    get(key: string): string | null {
      return cache.get(key) ?? null;
    },
    set(key: string, value: string): void {
      cache.set(key, value);
      // Fire and forget async write
      AsyncStorage.setItem(key, value);
    },
    remove(key: string): void {
      cache.delete(key);
      // Fire and forget async removal
      AsyncStorage.removeItem(key);
    },
  };
}

Storage Keys

The engine uses three storage keys:
unlocked
string
Stores the array of unlocked achievement IDs as a JSON string.
progress
string
Stores the progress object as a JSON string, mapping achievement IDs to their current progress values.
items
string
Stores the collected items object as a JSON string, mapping achievement IDs to arrays of collected item strings.
Each key also has a corresponding :hash suffix for integrity checking (see Anti-Cheat).
// From packages/core/src/engine.ts:21-24
const STORAGE_KEY_UNLOCKED = 'unlocked';
const STORAGE_KEY_PROGRESS = 'progress';
const STORAGE_KEY_ITEMS = 'items';
const HASH_SUFFIX = ':hash';

Using Custom Adapters

Simply pass your custom adapter to the storage config option:
const myCustomAdapter: StorageAdapter = {
  get(key) { /* ... */ },
  set(key, value) { /* ... */ },
  remove(key) { /* ... */ },
};

const engine = createAchievements({
  definitions: achievements,
  storage: myCustomAdapter,
});

Testing with Storage Adapters

The inMemoryAdapter is particularly useful for testing:
import { createAchievements, inMemoryAdapter } from 'achievements-manager';
import { describe, it, expect } from 'vitest';

describe('Achievement System', () => {
  it('should unlock achievements', () => {
    const engine = createAchievements({
      definitions: [
        { id: 'test', label: 'Test', description: 'Test achievement' },
      ],
      storage: inMemoryAdapter(), // Fresh storage for each test
    });

    engine.unlock('test');
    expect(engine.isUnlocked('test')).toBe(true);
  });
});
Each call to inMemoryAdapter() creates a fresh, isolated storage instance—perfect for test isolation.

Best Practices

  1. Use localStorageAdapter for web apps unless you have specific requirements
  2. Add a prefix to avoid key collisions with other libraries: localStorageAdapter('my-app')
  3. Use inMemoryAdapter for tests to avoid test pollution
  4. Handle errors gracefully in custom adapters (storage quota, permissions, etc.)
  5. Consider SSR environments by checking for window or using isomorphic storage
  6. Cache async storage if your backend is async (like React Native’s AsyncStorage)
// Example: Production-ready adapter with error handling
function robustLocalStorageAdapter(prefix?: string): StorageAdapter {
  const k = (key: string) => (prefix ? `${prefix}:${key}` : key);
  
  const isAvailable = () => {
    try {
      return typeof window !== 'undefined' && 'localStorage' in window;
    } catch {
      return false;
    }
  };

  return {
    get(key) {
      if (!isAvailable()) return null;
      try {
        return localStorage.getItem(k(key));
      } catch (error) {
        console.error('Storage get error:', error);
        return null;
      }
    },
    set(key, value) {
      if (!isAvailable()) return;
      try {
        localStorage.setItem(k(key), value);
      } catch (error) {
        console.error('Storage set error:', error);
      }
    },
    remove(key) {
      if (!isAvailable()) return;
      try {
        localStorage.removeItem(k(key));
      } catch (error) {
        console.error('Storage remove error:', error);
      }
    },
  };
}

Build docs developers (and LLMs) love