Skip to main content

Data Synchronization

Архитектура синхронизации данных между backend и TMA frontend.

Core Principle

Гибридная стратегия: 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Баланс, уровень, XP2 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 + позиция юзера + totalParticipants5 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)
caseCooldownsCooldown 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.tsAPI клиент
frontend/src/hooks/useBootstrap.tsHook загрузки
frontend/src/utils/bootstrapHydration.tsГидратация кэша
frontend/src/utils/imagePreloader.tsПредзагрузка изображений
frontend/src/components/LoadingOrchestrator.tsxУправление loading states
backend/src/domains/bootstrap/services/bootstrap.service.tsBackend агрегатор данных
backend/src/domains/bootstrap/controllers/user-bootstrap.controller.tsBootstrap 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.tsuseSalvageItem
Клейм квестаBalance snapshot для анимации, delayed cache removaluseQuestClaim.ts
Клейм достиженияBalance snapshot для анимации, achievement marked CLAIMEDuseAchievementClaim.ts
localStorage sync при мутациях

Каждый mutation hook, меняющий баланс, обязан синкать lastKnownBalance в onSuccess. Это предотвращает ложные анимации TopFloatingReward при следующем входе. Дополнительно, централизованный useEffect в useExternalRewardDetection ловит background refetches — подробнее в Client Storage → lastKnownBalance.

Deferred Balance Commit (pendingBalanceCommit)

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.tsuseSalvageItem
Клейм промокодаusePromoCode.tsuseApplyRewardGains✅ (при XP reward)
Покупка Boost PassuseBoostPass.ts✅ (XP multiplier меняет ранг)

Не инвалидируют ранг: useStreak (Streak Points — отдельная валюта), useRaffle (тратит SP, не зарабатывает XP), реферальный claimPassiveIncome (только SCRAP).

Почему invalidation, а не polling

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

КритерийSSEWebSocket
Направление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

ДанныеИнтервалЗачем
Quests15s (IN_PROGRESS) / 60s (остальные) / 30s (пустой список)Обновление прогресса квестов
Banners5 minРотация рекламы
Active Buffsнет polling (staleTime 30s, invalidation по событиям)Обновляется при активации баффа и window focus (AFK > 5 min)
Season30s (только при overlay: ending/interSeason/noSeason), иначе отключёнCountdown до конца сезона
Raffle30s (refetchInterval)Auto-refresh leaderboard
Dynamic Polling для квестов

Квесты используют адаптивный polling через refetchInterval в useQuests.ts:

  • Если нет квестов или пустой список → 30 сек
  • Если есть IN_PROGRESS квесты → 15 сек (частое обновление прогресса)
  • Если все COMPLETED → 60 сек (фоновая проверка)
  • Polling отключён когда TMA в фоне (refetchIntervalInBackground: false)
Polling для критичных данных — осторожно!

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

  1. Естественный флоу: Пользователь играет → возвращается проверить прогресс
  2. Нагрузка: Нет лишних запросов пока пользователь играет
  3. 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 Preferencesgoloot-settings-storagesound, vibration, theme (Zustand persist)
Dismissed Hintsgoloot-hintsКонтекстные подсказки (Zustand persist)
Balance TrackinglastKnownBalanceОтслеживание "внешних наград"
AFK Detectiongoloot-last-visit-timeSmart refetch при возврате
Banner UXbanner_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

РешениеПреимуществоКомпромисс
Bootstrap1 запрос вместо 8+Больший payload (~80KB)
Optimistic UpdatesМгновенный UI откликСложнее реализация
SSE (не WebSocket)Проще, HTTP-basedТолько server→client
Window Focus (не Polling)Нет лишней нагрузкиДанные не real-time
Нет push для RustПростота, экономия ресурсовЗадержка обнаружения