ADR 010: User Model — Single Table (без декомпозиции)
Status
Accepted - 2026-03-06
Context
Вопрос
Нужно ли разбивать User модель (~63 колонки) на несколько таблиц (UserStats, UserSteamProfile, UserBanInfo)?
Текущая структура User (~63 scalar поля)
| Группа | Кол-во | Hot path |
|---|---|---|
| Identity (id, name, avatar) | 8 | Да — каждый запрос |
| Экономика (scrap, xp, level) | 4 | Да — каждое начисление |
| Quiz счётчики | 3 | Да — каждый квиз |
| Стрики + Streak Points | 6 | Да — ежедневно |
| Счётчики активности | 8 | Да — лидерборды, профиль |
| Cooldowns + timestamps | 6 | Да — проверка кулдаунов |
| Steam + верификация | 11 | Нет — только при привязке/выводе |
| Бан + апелляции | 8 | Частично — isBanned на каждый запрос |
| Titles | 4 | Нет — только UI |
| Прочее (referral, push, status) | 5 | Частично |
Рассмотренные варианты декомпозиции
| Модель | Поля | Выигрыш | Проблемы |
|---|---|---|---|
UserSteamProfile | 11 steam* | User → 52 | steamId — @unique + @@index, используется в webhook matching. Требует JOIN или двухшаговый запрос |
UserBanInfo | 8 ban/appeal* | User → 55 | isBanned читается в auth middleware на каждом запросе. Вынос = JOIN на hot path или денормализация флага (полумера) |
UserStats | Не применимо | — | Детализация scrap/xp по источникам (scrapFrom*, xpFrom*) живёт только на UserSeasonStats, не на User. Предложение из первого аудита было основано на неверных данных |
Decision
Оставить User как единую таблицу. Не декомпозировать.
Причины
1. PostgreSQL справляется
63 колонки — далеко от лимита (~1600). Row size ~400 bytes — умещается в один 8KB block. Нет реальных performance-проблем.
2. Atomic updates без транзакций
Одна prisma.user.update() vs $transaction([user.update, steamProfile.update]). Меньше кода, меньше точек отказа, проще отладка.
3. Counter drift решён архитектурно
Централизованные сервисы addScrap() и addUserXP() — единая точка входа для всех начислений. season-stats-updater.ts обновляет scrapTotalEarned + scrapFrom{Source} в одном upsert. Reconciliation cron не нужен.
4. Blast radius непропорционален выигрышу
- UserSteamProfile: ~8 файлов, средний риск (webhook matching по
steamId) - UserBanInfo: ~5 файлов, но
isBannedна auth hot path — высокий риск - Выигрыш чисто когнитивный, не технический
5. Денормализованные счётчики оправданы
quizzesCompleted, casesOpened и др. — теоретически SUM(UserSeasonStats.*), но используются в лидербордах и профиле напрямую. Один SELECT vs агрегация по сезонам.
Когда пересмотреть
- Row lock contention при >100K активных пользователей
- Рефакторинг Steam-интеграции (естественный момент для выноса
UserSteamProfile) ALTER TABLE users ADD COLUMNначнёт вызывать заметные lock-и в production
Связанные решения
- Counter consistency:
scrap.service.ts,xp.service.ts,season-stats-updater.ts - Quest refactoring: ADR в
PLAN_rustquest/(39 rust* →rustParams Json?)