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"

Сжатие ​

compress уменьшает сохранённый конверт через нативный Compression Streams API браузера перед записью — без внешних библиотек. Применяется до encrypt (сжатие шифротекста не даёт выигрыша в размере) и покрывается sign, если заданы оба.

ts
const { value: log } = useStorage('activity-log', {
  defaultValue: [] as string[],
  compress: true, // алгоритм по умолчанию 'gzip'
})
ts
const { value: payload } = useStorage('large-payload', {
  defaultValue: {},
  compress: { algorithm: 'deflate-raw' },
})

В окружениях без CompressionStream (или без поддержки запрошенного algorithm — например, 'deflate-raw' не распознаётся до Node 21+) записи по-прежнему проходят без сжатия вместо выброса исключения; чтения обнаруживают это автоматически. Но это больше не происходит молча: при первом попадании на каждую отдельную причину деградации в dev-режиме выводится console.warn (один раз на причину, а не на каждую запись).

CompressOptions ​

algorithm ​

'gzip' | 'deflate' | 'deflate-raw' · по умолчанию: 'gzip'

Использование функций сжатия напрямую ​

Точка входа /compress экспортирует те же функции, что useStorage использует внутри себя, для использования вне его:

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 — проверяет собственный magic-префикс этого пакета

CompressAdapter оборачивает любой StorageAdapter, добавляя опциональные для каждого вызова методы setCompressed()/getDecompressed(), для построения собственного слоя хранилища поверх той же логики сжатия:

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

Шифрование ​

Шифрование обрабатывается субпакетом /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 ​

password ​

string

Вывести ключ AES-GCM из этого пароля через PBKDF2.

key ​

CryptoKey

Использовать уже существующий CryptoKey напрямую.

iterations ​

number · по умолчанию: 100_000

Количество итераций PBKDF2.

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

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

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

ts
import { rotateEncryptedKey } from 'vue-storage-kit/crypto'

// Читает 'api-key' из local storage, расшифровывает старым паролем,
// шифрует заново новым, записывает обратно.
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().