Skip to content

Жизненный цикл данных

Миграции схемы

Когда форма сохранённых данных меняется между релизами, SchemaManager автоматически запускает функции миграции. У каждой миграции есть version (целевая версия), функция up (обновление) и опциональная функция down (откат).

Как это работает

  1. При чтении версия сохранённого конверта сравнивается с options.version.
  2. Если они различаются, строится и последовательно применяется цепочка миграций.
  3. Мигрированное значение записывается обратно в хранилище с новой версией.
  4. Вызывается onMigrate(from, to).

При откате назад, если down() отсутствует, ключ сбрасывается к defaultValue и вызывается onError.

Пример: 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 было { 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

ts
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.

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

Ручная очистка при старте приложения — зачистить все истёкшие ключи с общим префиксом:

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

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

Проверить, когда истекает конкретный ключ:

ts
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)

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

Шифрование с готовым CryptoKey

ts
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:

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

ОпцияТипПо умолчаниюОписание
passwordstringВывести AES-GCM ключ из этого пароля через PBKDF2
keyCryptoKeyИспользовать уже существующий CryptoKey напрямую
iterationsnumber100_000Количество итераций PBKDF2

Должен быть указан либо password, либо key. Выведенные ключи кэшируются в памяти — PBKDF2 выполняется только при первом шифровании/расшифровке с данной парой (password, salt).

Смена пароля/ключа

reencrypt() и rotateEncryptedKey() (тоже из /crypto) позволяют переключить уже зашифрованное значение на новый пароль без того, чтобы вызывающий код когда-либо работал с открытым текстом:

ts
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), если это не так:

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

Это не граница безопасности против пользователя, контролирующего собственный браузер. Какой бы ключ ни использовал sign — пароль, зашитый в ваш JS, или даже CryptoKey, на который у вашего кода есть ссылка — он доступен любому, кто откроет DevTools на вашей странице: его можно прочитать прямо из бандла или из памяти и подделать подпись, которая пройдёт проверку без проблем. У клиентского JavaScript нет способа помешать кому-то редактировать хранилище собственного браузера, с этой опцией или без неё — не полагайтесь на sign (как и на encrypt, если на то пошло) для обеспечения этого.

Для чего sign действительно полезен: отлавливание случайного повреждения — баг в другом месте вашего приложения, пишущий некорректные данные в тот же ключ, хранилище, разделяемое с кодом, который не должен его трогать, гонка в синхронизации между вкладками или нестабильность на уровне хранилища в конкретном браузере. Комбинируйте с encrypt, если вам также нужна конфиденциальность — подпись оборачивает самый внешний слой, так что покрывает и шифротекст тоже:

ts
const { value } = useStorage('vault', {
  defaultValue: {},
  encrypt: { password: 'encrypt-pw' },
  sign: { password: 'sign-pw' }, // может быть другой пароль/ключ, чем у encrypt
})

Самостоятельные sign() / verify() тоже экспортируются из /crypto, зеркально encrypt() / decrypt().