Skip to content

Совместное использование плагинов

Persistence, sync и offline queue можно включить вместе, но каждый отвечает за свой тип состояния.

Не путайте механизмы

СобытиеPersistenceSyncOffline queue
Reload вкладкивосстанавливает query cacheне помогаетвосстанавливает pending commands
Изменение в другой открытой вкладкесамо по себе не уведомляетуведомляетlock предотвращает двойной replay
Пользователь нажал «создать» offlineне сохраняет командуне выполняет командусохраняет и replay-ит
Response dataсохраняет opt-in queryinvalidate или hydrateхранит только variables команды

Startup с тремя плагинами

ts
// 1. Создаём root runtime.
const runtime = createQueryRuntime({
  mode: "application",
  identity: {
    applicationId: "portal",
    version: "1.0.0",
  },
})

// 2. Restore query cache до создания QueryStore.
const persistence = await persistQueryRuntime({
  runtime,
  persister,
})

// 3. Подключаем browser lifecycle.
await runtime.initialize()

// 4. Создаём application scope.
const appScope = runtime.createParticipantScope({
  participantId: "portal-app",
  participantType: "application",
})

// 5. Открываем канал live-sync.
const sync = setupBroadcastSync({
  runtime,
  runtimeId: "portal",
  scope: partitionHash,
  mode: "invalidate",
})

// 6. Создаём offline queue, регистрируем handlers и mount listener.
const offline = new OfflineMutationQueue(runtime, "PORTAL_OFFLINE", {
  scope: {
    applicationId: "portal",
    tenantId,
    userId,
  },
  whitelist: ["comments.create"],
  lockManager,
})

offline.register("comments.create", replayCreateComment)
const unmountOffline = offline.mount()

Порядок важен: hydrated cache должен появиться до автоматического старта QueryStore, а offline handlers — до первого replay.

Query одновременно persist и broadcast

ts
const countries = appScope.query.createStore<Country[]>({
  queryKey: () => ["dictionary", "countries"],
  queryFn: ({ signal }) => dictionaryApi.countries({ signal }),
  meta: {
    persist: true,
    broadcast: true,
  },
})

Значения metadata — два независимых opt-in:

  • persistence policy может разрешить запись в storage;
  • sync policy может разрешить событие другой вкладке.

Feature не должна считать метку окончательным разрешением. Composition policy дополнительно проверяет namespace/data classification.

Mutation online

mermaid
flowchart TD
    Mutation["MutationStore success"] --> Invalidate["Local query invalidation"]
    Invalidate --> Sync["Broadcast invalidate"]
    Invalidate --> Persist["Throttle persistence flush"]
    Sync --> Other["Другая вкладка refetch"]

Persistence не отправляет событие другой вкладке. Sync делает это отдельно.

Mutation offline

mermaid
flowchart TD
    UI["Domain store"] --> Queue["Offline enqueue"]
    Queue --> Storage["Partitioned storage"]
    Online["Browser online"] --> Replay["Single-tab replay"]
    Storage --> Replay
    Replay --> Api["Idempotent API"]
    Api --> Invalidate["Scoped invalidation"]

После успешного replay зарегистрированный handler вызывает appScope.query.invalidate(...). Sync увидит изменение разрешённой query, а persistence позже сохранит новый успешный cache.

Две вкладки и offline replay

Без coordination обе вкладки могут восстановить одну очередь и отправить одну команду. Передайте общий QueryLockManager:

ts
const offline = new OfflineMutationQueue(runtime, "PORTAL_OFFLINE", {
  scope: {
    applicationId: "portal",
    tenantId,
    userId,
  },
  whitelist: ["comments.create"],
  lockManager,
  replayLockName: `portal:offline:${partitionHash}`,
})

Lock уменьшает параллельный replay, но backend idempotency key остаётся обязательным: вкладка может закрыться после HTTP success до удаления task.

Invalidate или hydrate при persistence

Рекомендуемое сочетание:

ts
mode: "invalidate"

Другая вкладка сама получает свежие данные. Затем её persistence сохраняет собственный cache.

hydrate выбирайте только для разрешённых non-sensitive datasets. Иначе data одновременно проходит через BroadcastChannel и сохраняется в storage, что расширяет поверхность доступа.

Разные version/buster

Используйте согласованные значения:

ts
const cacheBuster = `${APP_VERSION}:${API_SCHEMA_VERSION}`

persistQueryRuntime({
  runtime,
  persister,
  buster: cacheBuster,
})

setupBroadcastSync({
  runtime,
  runtimeId: "portal",
  buster: cacheBuster,
})

Так новая вкладка не применит несовместимое сообщение старой deployment и не hydrate-ит старый persisted snapshot.

Logout lifecycle

ts
// 1. UI перестаёт создавать новые операции.
unmountApplicationUi()

// 2. Снимаем live listeners.
sync.dispose()
unmountOffline()
offline.cancelReplay()

// 3. Освобождаем stores participant.
appScope.dispose()

// 4. Закрываем persistence subscription.
await persistence.dispose()

// 5. Удаляем cache и offline state по product policy.
await persister.removeClient()
removeOfflinePartitionSnapshot()

// 6. Закрываем runtime старой session.
await runtime.dispose()

Не переносите controllers и queues между users. Создавайте их заново для новой partition.

Checklist пересечения

  1. Есть ли единый partition id у persistence, sync и offline queue?
  2. Все ли persisted/broadcast query имеют явный opt-in?
  3. Проверяет ли host namespace allowlist?
  4. Имеет ли offline endpoint backend idempotency?
  5. Есть ли cross-tab replay lock?
  6. Совпадают ли version/buster?
  7. Удаляются ли данные старой session на logout?
  8. Нет ли tokens, passwords и headers в storage/BroadcastChannel?