Data Synchronization
Архитектура синхронизации данных между backend и TMA frontend.
Гибридная стратегия: Bootstrap для начальной загрузки + Optimistic Updates для действий пользователя + SSE для real-time событий + Smart Refresh при возврате в приложение.
1. Summary
Проблема: TMA должен ощущаться как native app, а не как медленный веб-сайт с постоянными loading спиннерами.
Решение:
- Один запрос
/api/bootstrapвместо 8+ отдельных при старте - Optimistic updates — UI реагирует мгновенно, до ответа сервера
- SSE для real-time событий (feed, raffle)
- Smart refresh при возврате в приложение (window focus)
Почему НЕ WebSocket / НЕ Polling:
- 10k DAU × polling 30 сек = 200k req/min — убийственная нагрузка
- WebSocket держит соединение — сложность + ресурсы сервера
- TMA "засыпает" когда Telegram свёрнут — соединение всё равно рвётся
2. Архитектура
3. Bootstrap
Что загружается
Один запрос GET /api/bootstrap возвращает ~60-80 KB JSON:
| Данные | Описание | staleTime |
|---|---|---|
profile | Баланс, уровень, XP | 2 min |
quests | Список с прогрессом | 10 sec |
achievements | Список с прогрессом | 10 min |
cases | Доступные кейсы | 30 sec |
inventory | Инвентарь (до 100 предметов) | 3 min |
streak | Статистика стриков | 5 min |
season | Текущий сезон | 5 min |
activeBuffs | Активные баффы | 30 sec |
leaderboard | Топ-10 + позиция юзера + totalParticipants | 5 min (global) |
dailySpins | Активные рулетки | 30 sec |
craftableItems | Скины для крафта | 3 min |
initialFeed | Последние 10 событий | 5 min (global) |
referral | Реферальная статистика (stats/config/code/referrer) | 5-10 min |
raffle | Активный розыгрыш | 5 min |
banners | Рекламные баннеры | 5 min |
freeOpens | Бесплатные открытия кейсов | 5 min (global) |
caseCooldowns | Cooldown timestamps для кейсов | 5 min (global) |
streakLeaderboard | Топ-10 стрикеров + аватарки | 5 min (global) |
referralLeaderboard | Топ-10 рефереров + аватарки | 5 min (global) |
Гидратация кэша
После получения данных — hydrateBootstrapData() заполняет React Query кэш. Query keys и типы импортируются из хуков (см. ADR #007: Typed Query Keys):
import { PROFILE_QUERY_KEY } from '../hooks/useProfile';
import { QUESTS_QUERY_KEY } from '../hooks/useQuests';
import { achievementsQueryKey, type AchievementsData } from '../hooks/useAchievements';
import { CASES_QUERY_KEY, type CasesData } from '../hooks/useCases';
import { inventoryQueryKey } from '../hooks/useInventory';
import { STREAK_STATS_QUERY_KEY, STREAK_LEADERBOARD_QUERY_KEY } from '../hooks/useStreak';
import { SEASON_QUERY_KEY, SEASON_LEADERBOARD_QUERY_KEY } from '../hooks/useSeason';
import { ACTIVE_BUFFS_QUERY_KEY, type ActiveBuffsData } from '../hooks/useActiveBuffs';
import { DAILY_SPINS_QUERY_KEY, type DailySpinsData } from '../hooks/useDailySpins';
import { CRAFTABLE_ITEMS_QUERY_KEY, craftCheckQueryKey } from '../hooks/useCraft';
import { REFERRAL_STATS_QUERY_KEY, REFERRAL_CONFIG_QUERY_KEY,
MY_REFERRAL_CODE_QUERY_KEY, MY_REFERRER_QUERY_KEY } from '../hooks/useReferrals';
import { REFERRAL_LEADERBOARD_QUERY_KEY } from '../hooks/useReferralLeaderboard';
import { RAFFLE_CURRENT_QUERY_KEY } from '../hooks/useRaffle';
import { BANNERS_QUERY_KEY, type BannersData } from '../hooks/useBanners';
import { FREE_OPENS_QUERY_KEY, type FreeOpensData } from '../hooks/useFreeOpens';
import { CASE_COOLDOWNS_QUERY_KEY, type CaseCooldownsData } from '../hooks/useCaseCooldowns';
import { FEED_INITIAL_QUERY_KEY, type FeedInitialData } from '../hooks/useLiveFeed';
queryClient.setQueryData(PROFILE_QUERY_KEY, profileWithAvatar, { updatedAt: now });
queryClient.setQueryData(QUESTS_QUERY_KEY, data.quests, { updatedAt: now });
queryClient.setQueryData<AchievementsData>(achievementsQueryKey(), data.achievements, { updatedAt: now });
queryClient.setQueryData<CasesData>(CASES_QUERY_KEY, data.cases, { updatedAt: now });
// ... и так для всех доменов (inventory, streak stats + streak leaderboard,
// season, season leaderboard, activeBuffs, dailySpins, craftableItems + craft-check per item,
// initialFeed, referral (4 keys), referral leaderboard, raffle, banners, freeOpens,
// caseCooldowns, streakLeaderboard, referralLeaderboard)
// NOTE: maintenance REMOVED from bootstrap hydration — managed by dedicated useMaintenance() hook
Результат: При навигации на любой экран — данные уже в кэше, нет loading спиннеров.
Предзагрузка изображений
Bootstrap также триггерит предзагрузку динамических изображений:
Phase 1: Critical (scrap, xp, sp currency images) — загружаются ПЕРВЫМИ
Phase 2: Static (grain texture, badges/blueprints/fragments) — после критичных
Phase 3: Dynamic (скины из inventory, кейсов, наград, квестов, аватарки лидербордов) — timeout 7s
Ключевые файлы
| Файл | Назначение |
|---|---|
frontend/src/services/bootstrap.api.ts | API клиент |
frontend/src/hooks/useBootstrap.ts | Hook загрузки |
frontend/src/utils/bootstrapHydration.ts | Гидратация кэша |
frontend/src/utils/imagePreloader.ts | Предзагрузка изображений |
frontend/src/components/LoadingOrchestrator.tsx | Управление loading states |
backend/src/domains/bootstrap/services/bootstrap.service.ts | Backend агрегатор данных |
backend/src/domains/bootstrap/controllers/user-bootstrap.controller.ts | Bootstrap endpoint |
4. Optimistic Updates
Паттерн
const mutation = useMutation({
mutationFn: openCase,
// 1. ДО запроса — мгновенное обновление UI
onMutate: async ({ casePrice }) => {
await queryClient.cancelQueries({ queryKey: ['profile'] });
const previous = queryClient.getQueryData(['profile']);
// Optimistic: списываем баланс сразу
queryClient.setQueryData(['profile'], (old) => ({
...old,
scrap: old.scrap - casePrice,
}));
return { previous };
},
// 2. При ОШИБКЕ — откат к предыдущему состоянию
onError: (err, variables, context) => {
queryClient.setQueryData(['profile'], context.previous);
},
// 3. При УСПЕХЕ — синхронизация с сервером
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['profile'] });
queryClient.invalidateQueries({ queryKey: ['inventory'] });
},
});
Где используется
| Действие | Optimistic Update | Файл |
|---|---|---|
| Открытие кейса | Scrap списывается, cooldown ставится, freeOpens декрементируется | useOpenCase.ts |
| Salvage предмета | Предмет удаляется из инвентаря | useInventory.ts → useSalvageItem |
| Клейм квеста | Balance snapshot для анимации, delayed cache removal | useQuestClaim.ts |
| Клейм достижения | Balance snapshot для анимации, achievement marked CLAIMED | useAchievementClaim.ts |
Каждый mutation hook, меняющий баланс, обязан синкать lastKnownBalance в onSuccess. Это предотвращает ложные анимации TopFloatingReward при следующем входе. Дополнительно, централизованный useEffect в useExternalRewardDetection ловит background refetches — подробнее в Client Storage → lastKnownBalance.
Flows с длинной анимацией (case opening, daily spin) используют deferred balance update через useUserStore.setPendingBalanceCommit(). Баланс не обновляется при получении API ответа — обновление откладывается до момента, когда пользователь нажимает "Забрать".
Правило: Callback в setPendingBalanceCommit ОБЯЗАН синхронно обновлять кэш через addScrapToCache()/addXpToCache() ПЕРЕД вызовом invalidateQueries(). Без синхронного обновления getProfileFromCache() в handleCloseModal вернёт stale значения, и updateLastKnownBalance() сохранит stale баланс → при перезаходе ложная "внешняя награда".
// ✅ Правильно (паттерн useOpenCase / DailySpinScreen)
setPendingBalanceCommit(() => {
addScrapToCache(queryClient, amount); // sync: cache + localStorage
invalidateQueries(PROFILE_QUERY_KEY); // async: server verification
});
// ❌ Неправильно — только async refetch, кэш не обновлён
setPendingBalanceCommit(() => {
invalidateQueries(PROFILE_QUERY_KEY); // getProfileFromCache вернёт stale!
});
Leaderboard Invalidation
При XP-зарабатывающих событиях дополнительно инвалидируется SEASON_LEADERBOARD_QUERY_KEY, чтобы ProfileScreen обновил актуальный ранг пользователя в фоне:
| Событие | Хук | Меняет ранг |
|---|---|---|
| Открытие кейса | useOpenCase.ts | ✅ |
| Клейм квеста | useQuestClaim.ts | ✅ (при XP reward) |
| Клейм достижения | useAchievementClaim.ts | ✅ (при XP reward) |
| Разборка предмета (salvage) | useInventory.ts → useSalvageItem | ✅ |
| Клейм промокода | usePromoCode.ts → useApplyRewardGains | ✅ (при XP reward) |
| Покупка Boost Pass | useBoostPass.ts | ✅ (XP multiplier меняет ранг) |
Не инвалидируют ранг: useStreak (Streak Points — отдельная валюта), useRaffle (тратит SP, не зарабатывает XP), реферальный claimPassiveIncome (только SCRAP).
ProfileScreen монтируется один раз при старте (Keep-Alive паттерн TMA). Глобальный refetchOnWindowFocus: false + staleTime 5 min (global default) означают, что React Query не перефетчит leaderboard часто. Явный queryClient.invalidateQueries({ queryKey: SEASON_LEADERBOARD_QUERY_KEY }) обходит staleTime и запускает фоновый рефетч пока ProfileScreen смонтирован.
5. Real-Time (SSE)
Activity Feed
// useLiveFeed.ts
const url = getFeedSSEUrl(); // `${VITE_API_URL}/api/feed/live`
const eventSource = new EventSource(url);
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'init') {
setInitialEvents(data.events || []); // Начальные события
} else if (data.type === 'new') {
addEvent(data.event); // Новое событие (с дедупликацией по event.id)
}
};
// Автопереподключение при ошибке через 5 сек + polling fallback
eventSource.onerror = () => {
startPolling(); // Fallback на 30s polling
reconnectRef.current = setTimeout(connectSSE, 5000);
};
Raffle Live Feed
// useRaffleLiveFeed.ts
const url = `${API_BASE}/api/raffle/${raffleId}/live`;
const eventSource = new EventSource(url);
// События: ticket_purchased, raffle_completed
eventSource.onmessage = (e) => {
const data = JSON.parse(e.data);
if (data.type === 'init') {
// Фильтрует только ticket_purchased события
setEvents(data.events.filter(ev => ev.type === 'ticket_purchased'));
} else if (data.type === 'new') {
const event = data.event;
if (event.type === 'ticket_purchased') {
// Дедупликация по timestamp + buyerId
addEvent(event); // .slice(0, 5)
} else if (event.type === 'raffle_completed') {
onRaffleCompleted?.(event);
}
}
};
Почему SSE, а не WebSocket
| Критерий | SSE | WebSocket |
|---|---|---|
| Направление | Server → Client (достаточно для нас) | Bidirectional |
| Сложность | HTTP-based, простой fallback | Отдельный протокол |
| Reconnect | Автоматический | Нужно реализовывать |
| Нагрузка | Легче для сервера | Держит соединение |
6. Background Refresh
Window Focus
При возврате в TMA (когда пользователь открывает Telegram):
// useAppVisibility.ts
// Debounced (1 секунда) для предотвращения спама при частом переключении
const handleAppVisible = useDebouncedCallback(() => {
const timeSinceLastVisit = Date.now() - lastVisitTimestamp;
// High priority — всегда обновляем
queryClient.invalidateQueries({ queryKey: ['profile'] });
queryClient.invalidateQueries({ queryKey: ['quests'], exact: false });
// Medium priority — если AFK > 5 минут
if (timeSinceLastVisit > 5 * 60 * 1000) {
refreshInventoryCache(queryClient); // active + inactive queries
queryClient.invalidateQueries({ queryKey: ['activeBuffs'] });
queryClient.invalidateQueries({ queryKey: ['season'] });
}
}, 1000);
Polling
| Данные | Интервал | Зачем |
|---|---|---|
| Quests | 15s (IN_PROGRESS) / 60s (остальные) / 30s (пустой список) | Обновление прогресса квестов |
| Banners | 5 min | Ротация рекламы |
| Active Buffs | нет polling (staleTime 30s, invalidation по событиям) | Обновляется при активации баффа и window focus (AFK > 5 min) |
| Season | 30s (только при overlay: ending/interSeason/noSeason), иначе отключён | Countdown до конца сезона |
| Raffle | 30s (refetchInterval) | Auto-refresh leaderboard |
Квесты используют адаптивный polling через refetchInterval в useQuests.ts:
- Если нет квестов или пустой список → 30 сек
- Если есть IN_PROGRESS квесты → 15 сек (частое обновление прогресса)
- Если все COMPLETED → 60 сек (фоновая проверка)
- Polling отключён когда TMA в фоне (
refetchIntervalInBackground: false)
Profile, Inventory — не используют polling (обновляются через Optimistic Updates и Window Focus). Квесты — исключение: динамический polling 15-60s. Raffle — polling 30s пока модалка открыта. Season — условный polling 30s только при overlay (ending/interSeason/noSeason). Active Buffs — без polling, обновляются через invalidation при событиях и window focus.
7. External Events (Rust Webhooks)
Проблема
Rust плагин отправляет webhook когда игрок выполнил квест на сервере. Frontend не узнаёт об этом мгновенно — нет push-канала.
Решение (текущее)
Почему это OK
- Естественный флоу: Пользователь играет → возвращается проверить прогресс
- Нагрузка: Нет лишних запросов пока пользователь играет
- TMA специфика: Telegram Mini App "засыпает" когда свёрнут
Альтернативы (для дальнейшего улучшения)
| Подход | Сложность | Статус |
|---|---|---|
| Dynamic polling для IN_PROGRESS | Низкая | ✅ Реализовано (15s/60s в useQuests.ts) |
| SSE для квестов | Средняя | Не реализовано. Если UX polling недостаточен |
| Telegram Bot notification | Низкая | Не реализовано. "Квест выполнен! Заберите награду" |
8. React Query Configuration
Global Defaults
// QueryProvider.tsx
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 min — данные считаются свежими
gcTime: 30 * 60 * 1000, // 30 min — кэш держится в памяти
refetchOnWindowFocus: false, // Отключено глобально
refetchOnReconnect: true, // При восстановлении сети
retry: 2, // 2 попытки при ошибке
},
},
});
Per-Hook Overrides
Критичные данные переопределяют глобальные настройки:
// useUserProfile.ts
useQuery({
queryKey: ['profile'],
staleTime: 2 * 60 * 1000, // 2 min (чаще обновляется)
refetchOnMount: true, // Всегда обновлять при монтировании
refetchOnWindowFocus: true, // Включено для профиля
});
// useAchievements.ts, useInventory.ts, useCases.ts — тоже refetchOnWindowFocus: true
// useQuests.ts — staleTime: 10s + refetchInterval 15-60s (polling)
// useSeason.ts — refetchOnWindowFocus: false, refetchOnMount: false
9. Zustand vs React Query
Разделение ответственности
Zustand Store (useUserStore) | React Query Cache |
|---|---|
| UI анимации (TopFloatingReward, buff notifications) | Server state (profile, quests, inventory) |
| Reward queue и pending bonuses | Кэширование ответов API |
Глобальные награды (showTopReward) | staleTime / refetch логика |
Взаимодействие
React Query — единственный source of truth для данных профиля. Zustand не хранит копию профиля. Вместо этого хуки напрямую читают из React Query cache через getProfileFromCache():
// utils/profileCacheUpdater.ts
export const getProfileFromCache = (queryClient: QueryClient) =>
queryClient.getQueryData<UserProfile>(PROFILE_QUERY_KEY);
// Мутации обновляют React Query cache напрямую:
addScrapToCache(queryClient, amount, source);
addXpToCache(queryClient, amount);
// Zustand используется только для UI-анимаций:
const { showTopReward } = useUserStore.getState();
showTopReward('scrap', oldValue, newValue);
10. Client-Side Storage
localStorage Strategy
Помимо in-memory React Query cache, приложение использует localStorage для:
| Тип данных | Ключ | Назначение |
|---|---|---|
| UI Preferences | goloot-settings-storage | sound, vibration, theme (Zustand persist) |
| Dismissed Hints | goloot-hints | Контекстные подсказки (Zustand persist) |
| Balance Tracking | lastKnownBalance | Отслеживание "внешних наград" |
| AFK Detection | goloot-last-visit-time | Smart refetch при возврате |
| Banner UX | banner_hidden_{id} | Скрытые баннеры (30 min TTL) |
Total footprint: ~500-1000 bytes (очень лёгкий).
Разделение ответственности
| React Query (in-memory) | localStorage |
|---|---|
| Server state (profile, quests) | UI preferences (theme, sound) |
| Кэширование API ответов | Персистентные настройки |
| staleTime / refetch логика | Offline-first UX |
| ~80 KB bootstrap payload | ~1 KB preferences |
Подробнее
См. Client-Side Storage для детальной документации стратегии localStorage.
11. Trade-offs Summary
| Решение | Преимущество | Компромисс |
|---|---|---|
| Bootstrap | 1 запрос вместо 8+ | Больший payload (~80KB) |
| Optimistic Updates | Мгновенный UI отклик | Сложнее реализация |
| SSE (не WebSocket) | Проще, HTTP-based | Только server→client |
| Window Focus (не Polling) | Нет лишней нагрузки | Данные не real-time |
| Нет push для Rust | Простота, экономия ресурсов | Задержка обнаружения |
Related
- Client-Side Storage — стратегия localStorage и in-memory cache
- ADR #007: Typed Query Keys — типизированные ключи React Query
- ADR #005: Profile State Management — React Query как source of truth
- Overview — общая архитектура системы
- Redis Integration — кэширование на backend
- Rust Integration — webhook API