Skip to content

Persistence

Persistence сохраняет выбранные успешные query из QueryRuntime, чтобы после reload восстановить cache до нового HTTP-запроса.

Mutation state и direct RequestStore не сохраняются.

Простой пример с localStorage

ts
import { createSyncStoragePersister, persistQueryRuntime } from "@dubium/query-layer/persist"

import { runtime } from "./query-runtime"

// Persister знает только способ read/write/remove snapshot.
export const persister = createSyncStoragePersister({
    key: "portal:public-query-cache",
    storage: window.localStorage,
    onError: (error) => {
        console.error("Persistence storage error", error)
    },
})

// Controller связывает persister с cache конкретного runtime.
export const persistence = await persistQueryRuntime({
    runtime,
    persister,

    // Snapshot другой версии deployment будет удалён.
    buster: "portal:api-v1",

    // Snapshot старше часа не восстанавливаем.
    maxAge: 60 * 60_000,

    // Объединяем частые cache events в один write.
    throttleTime: 1_000,

    // По умолчанию true; указываем явно как security decision.
    rejectSensitiveData: true,
})

Query должна дать opt-in

ts
// Обычный application scope создаётся один раз после startup runtime.
const appScope = runtime.createParticipantScope({
    participantId: "portal-app",
    participantType: "application",
})

const countries = appScope.query.createStore<Country[]>({
    queryKey: () => ["dictionary", "countries"],
    queryFn: ({ signal }) => dictionaryApi.countries({ signal }),
    staleTime: 24 * 60 * 60_000,
    meta: {
        // Default persistence policy сохраняет только такие query.
        persist: true,
    },
})

Без meta.persist === true default policy пропускает query.

Что происходит при startup

ts
const runtime = createQueryRuntime(...)

// restoreClient → validation → migration → hydrate.
const persistence = await persistQueryRuntime({ runtime, persister })

await runtime.initialize()

// Store сразу увидит hydrated cache, если key совпадает.
const appScope = runtime.createParticipantScope(...)

Если snapshot повреждён, слишком стар, имеет другой buster или не может быть мигрирован, он удаляется.

Snapshot хранит вид каждой записи cache. Поэтому InfiniteQueryStore после cold start восстанавливает { pages, pageParams } как InfiniteQuery и связывает реальную queryFn при создании store, не конфликтуя с обычным Query того же key.

Поддерживаемые значения

Стандартный serializer сохраняет plain objects (включая объекты с null prototype), arrays, undefined, finite и специальные числовые значения, BigInt, Date, RegExp, Map и Set. Циклические ссылки, functions, symbols и экземпляры пользовательских классов отклоняются ошибкой. Это предотвращает незаметную потерю prototype и методов после restore. Для иной domain-модели передайте собственные serialize/deserialize и восстановите тип явно.

localStorage или sessionStorage

ts
const localPersister = createSyncStoragePersister({
    key: "portal-cache",
    storage: window.localStorage,
})

const tabPersister = createSyncStoragePersister({
    key: "portal-tab-cache",
    storage: window.sessionStorage,
})
StorageЖизньПодходит
localStorageМежду reload и перезапуском браузераМаленький разрешённый cache
sessionStorageПока открыта вкладкаВременный cache одной вкладки

Оба API синхронные и доступны JavaScript того же origin. Не храните secrets.

IndexedDB

ts
import { createIndexedDbPersister, persistQueryRuntime } from "@dubium/query-layer/persist"

const persister = createIndexedDbPersister({
    databaseName: "portal-query-cache",
    databaseVersion: 1,
    storeName: "runtime",
    key: "public-dictionaries",
    onError: reportPersistenceError,
})

const persistence = await persistQueryRuntime({
    runtime,
    persister,
})

IndexedDB работает асинхронно и лучше подходит для более крупных разрешённых payload.

Все options persistQueryRuntime

OptionTypeDefaultЧто делает
runtimeIQueryRuntimeContractRuntime, cache которого сохраняется. Обязательно.
persisterIPersisterAdapter read/write/remove. Обязательно.
busterstringИнвалидирует snapshot при несовместимой сборке/API.
versionnumber2Версия persisted schema приложения.
migrationsRecord<number, TPersistMigration>Последовательные миграции к новой version.
maxAgenumberбез лимитаМаксимальный возраст snapshot в миллисекундах.
throttleTimenumber1000Задержка объединения writes.
maxPayloadBytesnumberбез project limitМаксимальный размер сериализованного snapshot.
shouldPersistQuery(query) => booleanmeta.persist === trueДополнительная allowlist policy query.
rejectSensitiveDatabooleantrueЗапрещает snapshot с чувствительными полями.
sensitiveDataCheckISensitiveDataCheckOptionsdefault guardНастраивает forbidden keys/проверку.
onPersistError(error) => voidПолучает restore/serialize/storage ошибки.
clockIClocksystem clockВнедряемое время для тестов.
timeoutManagerITimeoutManagersystem timersВнедряемый throttle scheduler.

Options sync storage persister

OptionTypeDefaultЧто делает
keystringQUERY_LAYER_CACHEКлюч Web Storage.
storageStoragewindow.localStoragelocalStorage/sessionStorage/custom adapter.
serialize(client) => stringsafe serializerПреобразует snapshot в строку.
deserialize(value) => unknownsafe deserializerРазбирает строку; результат ещё валидируется.
onError(error) => voidПолучает Web Storage/codec ошибки.

Options IndexedDB persister

OptionTypeDefault
databaseNamestringdubium-query-layer
databaseVersionnumber1
storeNamestringquery-cache
keyIDBValidKeypersisted-client
indexedDBIDBFactoryglobalThis.indexedDB
onError(error) => void

Custom filter

ts
const persistence = await persistQueryRuntime({
    runtime,
    persister,
    shouldPersistQuery: (query) => {
        return query.meta?.persist === true && query.queryKey[0] === "dictionary"
    },
})

Callback получает копии queryKey, queryHash и meta, но не получает response body отдельным аргументом или методы Query.

Migration

ts
const persistence = await persistQueryRuntime({
    runtime,
    persister,
    version: 3,
    migrations: {
        3: (clientV2) => migrateV2ToV3(clientV2),
    },
})

Переход выполняется последовательно. Если нужного шага нет или migration возвращает false, snapshot удаляется.

migrations получают только структурно валидные persisted clients. Исторические snapshots schema v1, созданные до обязательного поля kind, встроенные persisters отклоняют на этапе validation и удаляют: безопасно определить вид Query по форме пользовательских данных невозможно. Поэтому такие v1 snapshots не передаются в migrations[2] и инвалидируются без эвристик.

Controller lifecycle

МетодЧто делает
flush()Немедленно пытается сохранить разрешённый cache.
dispose()Снимает subscription, выполняет финальный безопасный flush и останавливает controller.
ts
await persistence.flush()
await persistence.dispose()

// При logout старой session удаляем snapshot.
await persister.removeClient()

// Только IndexedDB persister имеет собственное соединение.
await indexedDbPersister.dispose()

User и tenant

Не используйте один storage key для разных пользователей. Включайте безопасный partition hash в key и удаляйте snapshot на logout. Для MF-порядка смотрите localStorage и persistence.