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
- On read, the stored envelope's version is compared to
options.version. - If they differ, the migration chain is built and applied sequentially.
- The migrated value is written back to storage with the new version.
onMigrate(from, to)is called.
If downgrading and a down() is missing, the key resets to defaultValue and onError is called.
Example — v1 → v3
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
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
uptwice 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.
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:
import { TTLManager, StorageAdapterFactory } from 'vue-storage-kit'
const adapter = StorageAdapterFactory.get('local')
await TTLManager.cleanExpired(adapter, 'myapp:')Check when a specific key expires:
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)
const { value: secret } = useStorage('api-key', {
defaultValue: '',
encrypt: { password: 'user-passphrase', iterations: 100_000 },
})Encrypt with a pre-generated CryptoKey
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:
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
| Option | Type | Default | Description |
|---|---|---|---|
password | string | — | Derive AES-GCM key from this password via PBKDF2 |
key | CryptoKey | — | Use a pre-existing CryptoKey directly |
iterations | number | 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:
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:
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:
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().