Жизненный цикл данных
Миграции схемы
Когда форма сохранённых данных меняется между релизами, SchemaManager автоматически запускает функции миграции. У каждой миграции есть version (целевая версия), функция up (обновление) и опциональная функция down (откат).
Как это работает
- При чтении версия сохранённого конверта сравнивается с
options.version. - Если они различаются, строится и последовательно применяется цепочка миграций.
- Мигрированное значение записывается обратно в хранилище с новой версией.
- Вызывается
onMigrate(from, to).
При откате назад, если down() отсутствует, ключ сбрасывается к defaultValue и вызывается onError.
Пример: 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 было { darkMode: boolean }, v2 вводит строку theme
up: (d: any) => ({ ...d, theme: d.darkMode ? 'dark' : 'light' }),
down: (d: any) => {
const { theme, ...rest } = d
return { ...rest, darkMode: theme === 'dark' }
},
},
{
version: 3,
// в v2 не было locale, v3 добавляет его из старого поля lang
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}`),
})Пользователь на v1 открывает приложение, читает { darkMode: true } и после прохождения цепочки получает { darkMode: true, theme: 'dark', locale: 'en' }. Мигрированное значение сохраняется немедленно.
Интерфейс Migration
interface Migration {
version: number // целевая версия после этой миграции
up: (data: unknown) => unknown // обновление с version-1 до version
down?: (data: unknown) => unknown // опциональный откат с version до version-1
}Миграции должны быть идемпотентными — повторный запуск
upне должен повреждать данные.
TTL и срок действия
TTL хранится внутри конверта рядом с данными (поле exp). При каждом чтении, если Date.now() > exp, ключ удаляется и возвращается defaultValue.
const {
value: otp,
expiry,
remove,
} = useStorage('otp', {
defaultValue: '',
ttl: 5 * 60 * 1000, // 5 минут
onExpire: () => router.push('/login'),
})Ручная очистка при старте приложения — зачистить все истёкшие ключи с общим префиксом:
import { TTLManager, StorageAdapterFactory } from 'vue-storage-kit'
const adapter = StorageAdapterFactory.get('local')
await TTLManager.cleanExpired(adapter, 'myapp:')Проверить, когда истекает конкретный ключ:
const exp = await TTLManager.getExpiry(adapter, 'otp')
console.log(exp?.toLocaleTimeString()) // например, "14:35:00"Шифрование
Шифрование реализовано в подпакете /crypto через нативный Web Crypto API браузера — без внешних библиотек. Зашифрованные значения хранятся как одна base64-строка: salt[16] + iv[12] + ciphertext.
Шифрование с паролем (PBKDF2)
const { value: secret } = useStorage('api-key', {
defaultValue: '',
encrypt: { password: 'user-passphrase', iterations: 100_000 },
})Шифрование с готовым CryptoKey
const key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, false, [
'encrypt',
'decrypt',
])
const { value } = useStorage('vault', {
defaultValue: {},
encrypt: { key },
})Использование функций шифрования напрямую
Точка входа /crypto экспортирует encrypt и decrypt для использования вне 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 | — | Вывести AES-GCM ключ из этого пароля через PBKDF2 |
key | CryptoKey | — | Использовать уже существующий CryptoKey напрямую |
iterations | number | 100_000 | Количество итераций PBKDF2 |
Должен быть указан либо password, либо key. Выведенные ключи кэшируются в памяти — PBKDF2 выполняется только при первом шифровании/расшифровке с данной парой (password, salt).
Смена пароля/ключа
reencrypt() и rotateEncryptedKey() (тоже из /crypto) позволяют переключить уже зашифрованное значение на новый пароль без того, чтобы вызывающий код когда-либо работал с открытым текстом:
import { rotateEncryptedKey } from 'vue-storage-kit/crypto'
// Читает 'api-key' из локального хранилища, расшифровывает старым паролем,
// шифрует заново новым, записывает обратно.
await rotateEncryptedKey(
'local',
'api-key',
{ password: 'old-passphrase', iterations: 100_000 },
{ password: 'new-passphrase', iterations: 100_000 },
)reencrypt(raw, oldOpts, newOpts) делает то же самое на уровне строки (расшифровка + повторное шифрование), если вы не проходите через StorageAdapter.
Обнаружение повреждения (подпись)
sign добавляет проверку HMAC-SHA256 без шифрования значения — данные остаются читаемыми в открытом виде, но при следующем чтении useStorage() проверяет, что они всё ещё совпадают с тем, что было записано, и сообщает { type: 'signature-invalid', key } через onError (откатываясь к defaultValue), если это не так:
const { value: plan } = useStorage('subscription-tier', {
defaultValue: 'free',
sign: { password: 'app-signing-key' },
})Это не граница безопасности против пользователя, контролирующего собственный браузер. Какой бы ключ ни использовал sign — пароль, зашитый в ваш JS, или даже CryptoKey, на который у вашего кода есть ссылка — он доступен любому, кто откроет DevTools на вашей странице: его можно прочитать прямо из бандла или из памяти и подделать подпись, которая пройдёт проверку без проблем. У клиентского JavaScript нет способа помешать кому-то редактировать хранилище собственного браузера, с этой опцией или без неё — не полагайтесь на sign (как и на encrypt, если на то пошло) для обеспечения этого.
Для чего sign действительно полезен: отлавливание случайного повреждения — баг в другом месте вашего приложения, пишущий некорректные данные в тот же ключ, хранилище, разделяемое с кодом, который не должен его трогать, гонка в синхронизации между вкладками или нестабильность на уровне хранилища в конкретном браузере. Комбинируйте с encrypt, если вам также нужна конфиденциальность — подпись оборачивает самый внешний слой, так что покрывает и шифротекст тоже:
const { value } = useStorage('vault', {
defaultValue: {},
encrypt: { password: 'encrypt-pw' },
sign: { password: 'sign-pw' }, // может быть другой пароль/ключ, чем у encrypt
})Самостоятельные sign() / verify() тоже экспортируются из /crypto, зеркально encrypt() / decrypt().