Skip to content

Data Lifecycle

Schema migrations

When the shape of stored data changes between releases, SchemaManager runs migration functions automatically. Each migration has a version (the target version), an up function (upgrade), and an optional down function (rollback).

How it works

  1. On read, the stored envelope's version is compared to options.version.
  2. If they differ, the migration chain is built and applied sequentially.
  3. The migrated value is written back to storage with the new version.
  4. onMigrate(from, to) is called.

If downgrading and a down() is missing, the key resets to defaultValue and onError is called.

Example — v1 → v3

ts
import { useStorage } from 'vue-storage-kit'

interface SettingsV3 {
  theme: 'light' | 'dark'
  locale: string
}

const { value: settings } = useStorage<SettingsV3>('settings', {
  defaultValue: { theme: 'light', locale: 'en' },
  version: 3,
  migrations: [
    {
      version: 2,
      // v1 had { darkMode: boolean }, v2 introduces theme string
      up: (d: any) => ({ ...d, theme: d.darkMode ? 'dark' : 'light' }),
      down: (d: any) => {
        const { theme, ...rest } = d
        return { ...rest, darkMode: theme === 'dark' }
      },
    },
    {
      version: 3,
      // v2 had no locale, v3 adds it from the old lang field
      up: (d: any) => ({ ...d, locale: d.lang ?? 'en' }),
      down: (d: any) => {
        const { locale, ...rest } = d
        return { ...rest, lang: locale }
      },
    },
  ],
  onMigrate: (from, to) => console.log(`Migrated settings ${from} → ${to}`),
})

A user on v1 opens the app, reads { darkMode: true }, and receives { darkMode: true, theme: 'dark', locale: 'en' } after the chain runs. The migrated value is persisted immediately.

Migration interface

ts
interface Migration {
  version: number // target version after this migration
  up: (data: unknown) => unknown // upgrade from version-1 to version
  down?: (data: unknown) => unknown // optional rollback from version to version-1
}

Migrations must be idempotent — running up twice must not corrupt data.

TTL and expiry

TTL is stored inside the envelope alongside the data (exp field). On every read, if Date.now() > exp, the key is deleted and defaultValue is returned.

ts
const {
  value: otp,
  expiry,
  remove,
} = useStorage('otp', {
  defaultValue: '',
  ttl: 5 * 60 * 1000, // 5 minutes
  onExpire: () => router.push('/login'),
})

Manual cleanup on app start — sweep all expired keys with a shared prefix:

ts
import { TTLManager, StorageAdapterFactory } from 'vue-storage-kit'

const adapter = StorageAdapterFactory.get('local')
await TTLManager.cleanExpired(adapter, 'myapp:')

Check when a specific key expires:

ts
const exp = await TTLManager.getExpiry(adapter, 'otp')
console.log(exp?.toLocaleTimeString()) // e.g. "14:35:00"

Encryption

Encryption is handled by the /crypto subpackage using the browser's native Web Crypto API — no external libraries. Encrypted values are stored as a single base64 string: salt[16] + iv[12] + ciphertext.

Encrypt with a password (PBKDF2)

ts
const { value: secret } = useStorage('api-key', {
  defaultValue: '',
  encrypt: { password: 'user-passphrase', iterations: 100_000 },
})

Encrypt with a pre-generated CryptoKey

ts
const key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, false, [
  'encrypt',
  'decrypt',
])

const { value } = useStorage('vault', {
  defaultValue: {},
  encrypt: { key },
})

Use the encryption functions directly

The /crypto entry point exports encrypt and decrypt for use outside of useStorage:

ts
import { encrypt, decrypt } from 'vue-storage-kit/crypto'

const ciphertext = await encrypt('sensitive data', { password: 'pass', iterations: 10_000 })
const plaintext = await decrypt(ciphertext, { password: 'pass', iterations: 10_000 })

EncryptOptions

OptionTypeDefaultDescription
passwordstringDerive AES-GCM key from this password via PBKDF2
keyCryptoKeyUse a pre-existing CryptoKey directly
iterationsnumber100_000PBKDF2 iteration count

Either password or key must be provided. Derived keys are cached in memory — PBKDF2 runs only on the first encrypt/decrypt with a given (password, salt) pair.

Rotating a password/key

reencrypt() and rotateEncryptedKey() (also from /crypto) let you switch an already-encrypted value to a new password without the caller ever handling the plaintext:

ts
import { rotateEncryptedKey } from 'vue-storage-kit/crypto'

// Reads 'api-key' from local storage, decrypts with the old password,
// re-encrypts with the new one, writes it back.
await rotateEncryptedKey(
  'local',
  'api-key',
  { password: 'old-passphrase', iterations: 100_000 },
  { password: 'new-passphrase', iterations: 100_000 },
)

reencrypt(raw, oldOpts, newOpts) does the same thing at the string level (decrypt + re-encrypt), if you're not going through a StorageAdapter.

Corruption detection (signing)

sign appends an HMAC-SHA256 check without encrypting the value — the data stays plainly readable, but on the next read, useStorage() verifies it still matches what was written and reports { type: 'signature-invalid', key } via onError (falling back to defaultValue) if it doesn't:

ts
const { value: plan } = useStorage('subscription-tier', {
  defaultValue: 'free',
  sign: { password: 'app-signing-key' },
})

This is not a security boundary against a user who controls their own browser. Whatever key sign uses — a password baked into your JS, or even a CryptoKey your own code holds a reference to — is reachable by anyone who opens DevTools on your page: they can read it straight out of the bundle or out of memory, and forge a signature that verifies just fine. Client-side JavaScript has no way to stop someone from editing their own browser's storage, with or without this option — don't rely on sign (or encrypt, for that matter) to enforce that.

What sign is useful for: catching accidental corruption — a bug elsewhere in your app writing malformed data to the same key, storage shared with code that shouldn't touch it, a race in cross-tab sync, or storage-layer flakiness in a particular browser. Combine with encrypt if you also need confidentiality — signing wraps the outermost layer, so it covers the ciphertext too:

ts
const { value } = useStorage('vault', {
  defaultValue: {},
  encrypt: { password: 'encrypt-pw' },
  sign: { password: 'sign-pw' }, // can be a different password/key than encrypt
})

Standalone sign() / verify() are also exported from /crypto, mirroring encrypt() / decrypt().