# Design ## Context См. `proposal.md` — мотивация. Состояние и ограничения, определяющие подход: - Android подписан на SSE **только** пер-альбомно: `AlbumSseClient` вызывается из `AlbumViewModel` и живёт ровно столько, сколько живёт экран альбома. Парсер `AlbumEventParser` сворачивает `album.updated` и `album.deleted` в один `AlbumChanged`, а неизвестные типы возвращает как `null` (тихо игнорирует). - Список альбомов — offline-first: UI наблюдает Room, а `AlbumRepository.refresh()` заменяет кэш серверным снимком через `replaceAll`. - `AlbumDto` уже несёт форму элемента `GET /api/albums` (`cover_thumb_url`, `cover_medium_url`, `photos_count`, `members_count`), и `AlbumEntity` хранит те же поля, поэтому payload события ложится в кэш без сборки объекта вручную. - В Room между `photos` и `albums` **нет** `ForeignKey`/`CASCADE`: удаление строки альбома не уносит его фото. - `TokenStore` не наблюдаем: «залогинен» проверяется императивно, `logout()` чистит токен и запускает навигацию. Наблюдаемого сигнала сессии, на который можно подписать стрим, нет. - `AppContainer` живёт с процессом и запускает фоновые вещи в `start()` (прецедент — `DeviceMediaObserver`), но у него нет крючка «сессия началась/закончилась». - SSE-клиент Android авторизуется заголовком `Authorization` через интерцептор, поэтому query-параметр `token`, нужный вебу, здесь не требуется. - Бэкенд уже всё публикует: удаление альбома владельцем уходит и в канал альбома, и в личные потоки; исключение участника доходит до исключённого **только** личным потоком (в канал альбома уходит `member.left`, который клиент не использует). ## Goals / Non-Goals **Goals:** - Обновлять список альбомов из персонального потока без запроса списка на каждое событие. - Закрыть потерю доступа одним путём, покрывающим и удаление владельцем, и исключение. - Не терять изменения, пропущенные за время разрыва соединения. **Non-Goals:** - Центр уведомлений и приглашения (в Android их нет; `notification` — игнорируем). - Серверный обрыв пер-альбомного SSE при исключении участника — существующее ограничение. - Изменение пер-альбомного контракта событий и REST. - Работа подписки в фоне и обход Doze. - Сортировка списка — отдельный change (`android-album-sort`). ## Decisions **1. Дом подписки — ViewModel главного экрана, клиент — в `AppContainer`.** `AlbumsViewModel` создаётся на стартовой записи навигации после входа, переживает уход на экран альбома/профиля (запись остаётся в back stack) и уничтожается на выходе из аккаунта (`popUpTo ... inclusive`). Это функциональный аналог вебовой подписки на уровне layout'а: жизненный цикл сессии даёт навигация, а не процессный scope. Альтернативы: (а) `AppContainer.scope` — как веб, но нет наблюдаемой сессии и подписка живёт в фоне; (б) подписка на каждом экране — соединения дублируются, список всё равно остаётся мёртвым. Сам клиент кладём в `AppContainer` рядом с `albumSseClient` — как зависимость, чтобы ViewModel оставался тонким. **2. События применяются в Room, а не в UI-состояние.** Обработчик вызывает `AlbumRepository` (upsert/удаление), поэтому и список, и открытый экран альбома получают изменение сами, без дополнительных запросов. Альтернатива — править `UiState` списка напрямую: тогда открытый экран альбома не узнаёт ничего, и логика расходится с offline-first моделью. **3. Потеря доступа — через переход состояния кэша, один путь на два триггера.** `album.deleted` (удаление владельцем или исключение) удаляет альбом из Room; экран альбома реагирует на переход «альбом был ненулевым и стал `null`». Флаг «уже видел» отличает это от честного холодного старта без кэша и без сети. Альтернатива — явные события (`AlbumDeleted` из пер-альбомного парсера и `album.deleted` из личного) с прямым `onBack()`: два пути, дублирование и потребность знать собственный `user_id` для разбора `member.left` (сейчас экран знает только роль). **4. Удаление альбома уносит и его фото.** Так как FK с каскадом нет, удаление в репозитории должно чистить и `photos` этого альбома. Иначе сетка удалённого альбома остаётся живой из кэша. **5. Отдельный клиент и парсер для личного потока.** Личный поток несёт `album.created`, `album.updated`, `album.deleted` и `notification`. `notification` игнорируется явно: у него собственный `type` внутри payload'а (`member.joined`, `like.added`), который нельзя путать с типом SSE-события. Альтернатива — обобщить `AlbumSseClient` параметром запроса и парсера: пока переиспользования мало, обобщение усложняет существующий рабочий путь. **6. Догон при реконнекте — существующий `AlbumRepository.refresh()`.** `refresh()` делает `replaceAll` серверным снимком, поэтому заодно уносит альбомы, удалённые за время разрыва. Отдельный механизм догона не нужен. **7. Пер-альбомную обработку не трогаем.** Открытый экран получает `album.updated`/`album.deleted` и из своего канала, и косвенно из личного. Обработка идемпотентна (`upsert`/удаление), дублирующая работа невелика, а трогать работающий путь ради экономии одного запроса не стоит. **8. Unit-тесты гоняются только на debug-варианте.** Compose-тестовая активность (`androidx.activity.ComponentActivity`) объявляется `ui-test-manifest`, который подключён как debug-зависимость, поэтому release-манифест её не содержит и Robolectric не может запустить `createComposeRule`. Источники тестов вариант-агностичны, поэтому прогон один раз на debug покрывает то же самое, а тестовая активность не попадает в production-манифест. Альтернатива — подключить `ui-test-manifest` как `implementation` — отвергнута: она добавляет тестовую активность в release-APK. ## Risks / Trade-offs - **Отмена коллектора может не отменить `EventSource`** (`closed.await()` бросает до `source.cancel()`) → утечка соединения и цикл реконнектов на удалённом альбоме → покрыть тестом и, если подтвердится, завершать соединение в `finally`/`invokeOnClose`. - **`sseClient` собран без `TokenAuthenticator`** → на 401 посреди длинной сессии клиент не обновит токен, а переоткроется с прежним → покрыть сценарием истечения токена. - **Порядок списка меняется от живых событий** (альбом упорядочены по времени изменения) → принято осознанно; средство управления порядком приходит в `android-album-sort`. - **Исключённый участник продолжает получать пер-альбомный SSE** до разрыва соединения → существующее ограничение, не закрывается здесь. - **Фон и Doze** → подписка не переживает вытеснение процесса; реконнект и догон восстанавливают состояние при возвращении. ## Migration Plan Изменение аддитивное: схемы Room, эндпоинтов и контрактов не меняем. Выкатывается обычным обновлением приложения; откат — предыдущая версия APK, при которой список снова обновляется только по REST-снимку. Данные на сервере и в кэше не мигрируются. ## Open Questions - Стоит ли позже перевести пер-альбомный `album.updated` на применение payload'а в Room вместо REST-перезагрузки, чтобы убрать дублирующий запрос? На поведение и разбивку задач не влияет.