Skip to content

localStorage и persistence в MF

QueryRuntime сам ничего не записывает в storage. Host отдельно подключает persistence plugin к root runtime.

Remote только отмечает разрешённые query через metadata:

ts
meta: {
  persist: true,
}

Кто владеет persistence

ДействиеHostRemote
Создать persisterданет
Выбрать storage keyданет
Определить tenant/user partitionданет
Включить opt-in у своей queryразрешает policyставит meta.persist
Flush/dispose при logoutданет

Remote не должен создавать отдельный persister поверх того же runtime: появятся конкурирующие restore/flush lifecycles.

Подключение localStorage в host

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

import { queryRuntime } from "./query-runtime"
import { sessionStore } from "../auth/session.store"
import { createDataPartitionHash } from "../security/partition-hash"

// Не кладём raw userId/email в storage key.
// Эта проектная функция возвращает стабильный неперсональный hash partition.
const partitionHash = createDataPartitionHash({
  tenantId: sessionStore.tenantId,
  userId: sessionStore.userId,
})

export const queryPersister = createSyncStoragePersister({
  // У каждой session partition отдельная запись.
  key: `portal-query-cache:${partitionHash}`,

  // Явно выбираем localStorage.
  storage: window.localStorage,

  onError: (error) => {
    console.error("Не удалось сохранить query cache", error)
  },
})

export const persistence = await persistQueryRuntime({
  runtime: queryRuntime,
  persister: queryPersister,

  // При несовместимом deployment старый snapshot удалится.
  buster: "portal:api-v3",

  // Формат persisted data приложения.
  version: 2,

  // Старше 24 часов cache не восстанавливается.
  maxAge: 24 * 60 * 60_000,

  // Частые cache updates объединяются в один write.
  throttleTime: 1_000,

  // Защита включена по умолчанию; оставляем намеренно явно.
  rejectSensitiveData: true,

  // Дополнительные запрещённые имена полей проекта.
  sensitiveDataCheck: {
    forbiddenKeys: [
      "password",
      "token",
      "authorization",
      "secret",
    ],
  },

  // Ограничиваем размер одной JSON-записи.
  maxPayloadBytes: 1_500_000,
})

createDataPartitionHash не входит в Query Layer. Это project security helper. Не используйте обычный непосоленный hash email как защиту персональных данных.

Порядок startup

Для восстановления cache до создания remote stores:

ts
// 1. Создали runtime без browser side effects.
const runtime = createQueryRuntime(...)

// 2. Подключили persistence: здесь происходит restore/hydrate.
const persistence = await persistQueryRuntime({
  runtime,
  persister,
})

// 3. Подключили focus/online lifecycle.
await runtime.initialize()

// 4. Только теперь создаём scopes и монтируем remote.
const scope = runtime.createParticipantScope(...)

Opt-in query в remote

ts
const currencies = scope.query.createStore<Currency[]>({
  definitionId: "currency.dictionary.v1",
  queryKey: () => ["shared", "currency", "list"],
  queryFn: ({ signal }) => api.currencies({ signal }),

  meta: {
    // Разрешает persistence policy рассмотреть эту query.
    persist: true,
  },
})

meta.persist: true не гарантирует запись: host policy может дополнительно запретить key.

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

Callback получает read-only queryKey, queryHash и копию meta. Он не получает живой Query или response headers.

Что допустимо хранить в localStorage

Подходящие данные:

  • небольшой публичный dictionary;
  • feature flags без secrets;
  • редко меняющийся каталог, разрешённый data policy;
  • технические справочники малого размера.

Не храните:

  • access/refresh token;
  • password;
  • Authorization header;
  • банковские/медицинские данные;
  • полный персональный profile без явного требования;
  • большой список, который может превысить Web Storage quota.

localStorage синхронный и блокирует main thread на serialize/write. Для объёмных разрешённых данных используйте IndexedDB.

sessionStorage

ts
const persister = createSyncStoragePersister({
  key: `portal-session-cache:${partitionHash}`,
  storage: window.sessionStorage,
})

sessionStorage ограничен одной вкладкой и очищается после её закрытия, но это не делает его безопасным для tokens: JavaScript того же origin всё ещё может прочитать данные.

IndexedDB

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

const persister = createIndexedDbPersister({
  databaseName: "portal-query-cache",
  storeName: "runtime",
  key: partitionHash,
})

IndexedDB выполняет асинхронный I/O и лучше подходит для крупных payload. Data classification и partition rules остаются теми же.

Logout

ts
// 1. Remote UI больше не читает stores.
unmountAllRemotes()

// 2. Scope освобождает participant resources.
billingScope.dispose()
supportScope.dispose()

// 3. Останавливаем subscription и завершаем текущий flush.
await persistence.dispose()

// 4. Удаляем snapshot старого пользователя.
await queryPersister.removeClient()

// 5. Освобождаем root runtime.
await queryRuntime.dispose()

Для IndexedDB после remove также вызовите await persister.dispose().

Несколько вкладок

Persistence сохраняет cache на диск, но сам не уведомляет другие открытые вкладки. Для live-update подключите cross-tab sync. Порядок и конфликтующие политики описаны на странице Совместное использование плагинов.