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"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.
const { value: log } = useStorage('activity-log', {
defaultValue: [] as string[],
compress: true, // algorithm defaults to 'gzip'
})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:
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 prefixCompressAdapter 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:
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)
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
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:
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().