Тема
Совместное использование плагинов
Persistence, sync и offline queue можно включить вместе, но каждый отвечает за свой тип состояния.
Не путайте механизмы
| Событие | Persistence | Sync | Offline queue |
|---|---|---|---|
| Reload вкладки | восстанавливает query cache | не помогает | восстанавливает pending commands |
| Изменение в другой открытой вкладке | само по себе не уведомляет | уведомляет | lock предотвращает двойной replay |
| Пользователь нажал «создать» offline | не сохраняет команду | не выполняет команду | сохраняет и replay-ит |
| Response data | сохраняет opt-in query | invalidate или 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 пересечения
- Есть ли единый partition id у persistence, sync и offline queue?
- Все ли persisted/broadcast query имеют явный opt-in?
- Проверяет ли host namespace allowlist?
- Имеет ли offline endpoint backend idempotency?
- Есть ли cross-tab replay lock?
- Совпадают ли version/buster?
- Удаляются ли данные старой session на logout?
- Нет ли tokens, passwords и headers в storage/BroadcastChannel?