1. Wiki
  2. aether-packages
  3. Storage

Storage

@aetherAssembly/core provides a unified key-value storage abstraction built around a single StorageAdapter interface. All three built-in adapters implement the same async contract, so you can swap them without changing call sites.

The StorageAdapter interface

interface StorageAdapter {
  get<T>(key: string): Promise<T | null>
  set<T>(key: string, value: T): Promise<void>
  delete(key: string): Promise<void>
  clear(): Promise<void>
  keys(): Promise<string[]>
}
MethodBehaviour
get<T>(key)Returns the stored value cast to T, or null if the key doesn't exist. Never throws for a missing key.
set<T>(key, value)Writes value under key. Values must be JSON-serialisable. Overwrites silently.
delete(key)Removes the key. No-ops if the key doesn't exist.
clear()Removes all keys owned by this adapter instance. If scoped (prefix or specific object store), only those keys are removed.
keys()Returns all key names owned by this adapter. Strips any internal prefix — callers see the same keys they passed to set.

All methods are async. Even LocalStorageAdapter, which wraps a synchronous API, returns Promises so callers can treat all adapters uniformly.

IDBAdapter

Backed by IndexedDB via the idb library. The right choice for any significant data in a browser — IndexedDB is not subject to the 5–10 MB localStorage quota and doesn't block the main thread.

import { IDBAdapter } from '@aetherAssembly/core'

const storage = new IDBAdapter('my-app', 'items')
ParamTypeDescription
dbNamestringIndexedDB database name. Use a name unique to your app.
storeNamestringObject store name within that database. Created automatically on first open.

The constructor immediately begins opening the database and stores the resulting promise. Every method awaits that promise before operating — so you can construct the adapter and start calling methods right away; the first call will wait for the DB to be ready. The database opens at version 1. If you need multiple object stores, create separate IDBAdapter instances (one per store).

await storage.set('theme', 'dark')
const theme = await storage.get<string>('theme')  // 'dark'
await storage.keys()                               // ['theme']
await storage.delete('theme')
await storage.clear()

Use in Attyre

const adapter = new IDBAdapter('attyre', 'items')

export async function getItems() {
  const keys = await adapter.keys()
  return Promise.all(keys.map(k => adapter.get(k)))
}

export async function saveItems(items) {
  await adapter.clear()
  await Promise.all(items.map(item => adapter.set(item.id, item)))
}

LocalStorageAdapter

Wraps localStorage with the StorageAdapter interface. Values are JSON-serialised on write and parsed on read.

import { LocalStorageAdapter } from '@aetherAssembly/core'

const storage = new LocalStorageAdapter()         // no prefix
const scoped  = new LocalStorageAdapter('myapp') // keys stored as "myapp.<key>"
ParamTypeDefaultDescription
prefixstring''Optional prefix applied to all keys. get/set/delete/keys always use the unprefixed key.
  • If localStorage is unavailable (SSR, Web Workers, storage blocked), all methods silently no-op. get returns null, keys returns [].
  • clear() only removes keys matching this instance's prefix — it won't touch other apps sharing the same localStorage.
  • QuotaExceededError on set is swallowed silently.

Use it for small, non-critical data where IndexedDB would be overkill — settings, flags, small caches. Before It's Gone re-exports it for settings storage:

// @before-its-gone/core re-export
export { LocalStorageAdapter }
export function createLocalStorageAdapter() {
  return new LocalStorageAdapter()
}

MemoryAdapter

Map-backed in-memory adapter. Zero side effects — data is lost when the instance is garbage collected. Use it in unit tests instead of mocking IDBAdapter or LocalStorageAdapter.

import { MemoryAdapter } from '@aetherAssembly/core'

const storage = new MemoryAdapter()
// full StorageAdapter interface, no side effects
import { describe, it, expect, beforeEach } from 'vitest'
import { MemoryAdapter } from '@aetherAssembly/core'

describe('my feature', () => {
  let storage: MemoryAdapter

  beforeEach(() => { storage = new MemoryAdapter() })

  it('stores and retrieves a value', async () => {
    await storage.set('key', { hello: 'world' })
    expect(await storage.get('key')).toEqual({ hello: 'world' })
  })
})

Writing a custom adapter

Implement StorageAdapter and follow the contract above. A minimal sessionStorage example:

import type { StorageAdapter } from '@aetherAssembly/core'

export class SessionStorageAdapter implements StorageAdapter {
  constructor(private readonly prefix = '') {}

  private key(k: string) {
    return this.prefix ? `${this.prefix}.${k}` : k
  }

  async get<T>(key: string): Promise<T | null> {
    const raw = sessionStorage.getItem(this.key(key))
    if (raw === null) return null
    try { return JSON.parse(raw) as T } catch { return null }
  }

  async set<T>(key: string, value: T): Promise<void> {
    sessionStorage.setItem(this.key(key), JSON.stringify(value))
  }

  async delete(key: string): Promise<void> {
    sessionStorage.removeItem(this.key(key))
  }

  async clear(): Promise<void> {
    for (const k of await this.keys()) sessionStorage.removeItem(this.key(k))
  }

  async keys(): Promise<string[]> {
    const prefixDot = this.prefix ? `${this.prefix}.` : ''
    const result: string[] = []
    for (let i = 0; i < sessionStorage.length; i++) {
      const k = sessionStorage.key(i)
      if (k === null) continue
      result.push(prefixDot && k.startsWith(prefixDot) ? k.slice(prefixDot.length) : k)
    }
    return result
  }
}

Migration pattern

When migrating from one backend to another (e.g. localStorage → IDB), do it as a one-time operation on startup guarded by a flag:

const MIGRATION_FLAG = 'myapp_idb_migrated_v1'
const idb = new IDBAdapter('myapp', 'items')
const ls  = new LocalStorageAdapter('myapp')

async function migrate() {
  if (localStorage.getItem(MIGRATION_FLAG)) return

  const keys = await ls.keys()
  await Promise.all(
    keys.map(async k => {
      const value = await ls.get(k)
      if (value !== null) await idb.set(k, value)
    })
  )

  await ls.clear()
  localStorage.setItem(MIGRATION_FLAG, '1')
}

Set the flag directly in localStorage (not through the adapter) so it survives the migration itself and is always fast to check on subsequent launches. Use a versioned flag name (_v1, _v2) so future migrations can target a new version independently.

Attyre uses exactly this pattern — on first launch after v4.2.0, wardrobe data is read from localStorage, decompressed with LZString, imported to IndexedDB, and the attyre_idb_migrated_v1 flag is set.
Last updated: 2026-10-04 AetherAssembly/aether-packages →