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"

Compression ​

compress shrinks the stored envelope via the browser's native Compression Streams API before it's written — no external libraries. It's applied before encrypt (compressing ciphertext yields no size benefit) and covered by sign if both are set.

ts
const { value: log } = useStorage('activity-log', {
  defaultValue: [] as string[],
  compress: true, // algorithm defaults to 'gzip'
})
ts
const { value: payload } = useStorage('large-payload', {
  defaultValue: {},
  compress: { algorithm: 'deflate-raw' },
})

In environments without CompressionStream (or without support for the requested algorithm — 'deflate-raw' isn't recognized until Node 21+, for example), writes still pass through uncompressed rather than throwing; reads detect this automatically. This is no longer silent, though: a dev-mode console.warn fires the first time each distinct degrade reason is hit (once per reason, not once per write).

CompressOptions ​

algorithm ​

'gzip' | 'deflate' | 'deflate-raw' · default: 'gzip'

Use the compression functions directly ​

The /compress entry point exports the same functions useStorage uses internally, for use outside of it:

ts
import { compress, decompress, isCompressed } from 'vue-storage-kit/compress'

const packed = await compress(JSON.stringify(largeObject))
const restored = JSON.parse(await decompress(packed))

isCompressed(packed) // true — checks for this package's own magic prefix

CompressAdapter wraps any StorageAdapter to add opt-in-per-call setCompressed()/getDecompressed() methods, for building your own storage layer on top of the same compression logic:

ts
import { CompressAdapter } from 'vue-storage-kit/compress'
import { StorageAdapterFactory } from 'vue-storage-kit'

const adapter = new CompressAdapter(StorageAdapterFactory.get('local'), { algorithm: 'gzip' })

await adapter.setCompressed('key', JSON.stringify(largeObject))
const raw = await adapter.getDecompressed('key')

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 ​

password ​

string

Derive an AES-GCM key from this password via PBKDF2.

key ​

CryptoKey

Use a pre-existing CryptoKey directly.

iterations ​

number · default: 100_000

PBKDF2 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().