# Design ## Context Мотивация — в `proposal.md`. Состояние, важное для подхода: - Хаб `internal/sse` держит два независимых пространства подписок: `albums[albumID]` и `users[userID]`; публикация в личный канал уже реализована (`PublishUser`) и уже используется — единственным продюсером является `notifications.Service.Create`. - Пер-альбомные события публикуются из `albums.Service.Update/Delete`, `members.Service.Accept/ChangeRole/Remove`, `photos.Service`, `social.Service`. - Персональный поток `GET /api/sse/me` доступен клиентам, авторизация принимает токен в query-параметре, поэтому существующий web-обёртка `SSEClient` подходит без изменений. - Объект альбома в ответе списка собирается SQL-выборкой с джойном обложки и подзапросами счётчиков (`photos_count`, `members_count`); `albums.Service.Create` и `Update` возвращают объект без обложки, поэтому его нельзя переиспользовать как payload события «как есть». - Владелец альбома не входит в `album_members`: `albums.owner_id` — отдельный источник (владелец не меняет роль и не удаляется из состава). - `album_members` ссылается на `albums` с `ON DELETE CASCADE`: после удаления альбома состав участников уже недоступен. - Web-клиент подписан на SSE только на странице альбома; список альбомов загружается один раз за сессию и не обновляется по событиям. Android-парсер пер-альбомного потока игнорирует неизвестные типы событий. ## Goals / Non-Goals **Goals:** - Доставлять события жизненного цикла альбома (создание, метаданные, состав, удаление) тем пользователям, у которых меняется список альбомов, независимо от того, что они смотрят. - Обновлять список альбомов и счётчик `photos_count` при изменении состава фото (загрузка, перемещение, копирование, восстановление, корзина, окончательное удаление). - Отдавать в событии объект альбома той же формы, что REST-список, чтобы клиент применял его без запроса. - Дать web-клиенту единую подписку на персональный поток и догон состояния при реконнекте. **Non-Goals:** - Персистентные уведомления, их список и UI непрочитанных — не затрагиваются. - Android-клиент не подключается к личному потоку в этом этапе; пер-альбомный контракт должен остаться прежним. - Серверный обрыв пер-альбомного SSE при исключении участника не входит в этап. - Новые HTTP-эндпоинты и изменения схемы БД не требуются. ## Decisions **1. Канал доставки — существующий личный поток, веер по владельцу и участникам.** Событие публикуется в личный канал каждого затронутого пользователя. Альтернативы: (а) публиковать только в канал альбома — не доходит до тех, кто его не смотрит, то есть не решает задачу; (б) новое пространство подписок «альбомы пользователя» — дублирует уже существующий личный канал без выигрыша; (в) WebSocket — ломает сложившийся механизм SSE и требует второго транспорта. Выбран веер по `users[userID]`: переиспользует готовую инфраструктуру и не меняет транспорт. **2. Payload — полный объект альбома, а не сигнал-инвалидация.** Клиент применяет `upsert`/`remove` без запроса; порядок списка не меняется. Альтернатива — тонкий сигнал «список изменился» и рефетч: проще на сервере, но добавляет запрос на каждое изменение и оставляет окно, когда UI показывает старое состояние; при массовых операциях (приглашение нескольких участников) даёт несколько лишних запросов. Отвергнуто в пользу полного payload'а при том же объёме клиентской логики. **3. Источник payload'а — тот же код, что формирует элемент списка.** Перед публикацией альбом перечитывается через `Store.GetByID` и обогащается `attachCover`, то есть формой он идентичен элементу `GET /api/albums`. Причина: `Create`/`Update` отдают объект без URL обложки, а `Create` — ещё и без счётчиков; сборка объекта вручную в точке публикации неизбежно разошлась бы с REST-формой. **4. Резолвер аудитории — лёгкий список идентификаторов пользователей.** Нужен метод, возвращающий id владельца и id участников без джойна с `users`. Альтернатива — переиспользовать `members.Repository.List`, но он тянет `PublicUser` (join с `users`) и возвращает данные, которые никуда не идут; для веера это лишняя работа на каждое событие. Метод добавляется в репозиторий и закрывается интерфейсом в сервисах, как уже сделано для `OwnerID`. **5. Аудиторию удаления вычисляем до удаления альбома.** `Remove`/`Delete` должны собрать список получателей заранее: `album_members` удаляется каскадом вместе с альбомом, а `GetByID` после удаления недоступен. Для `album.deleted` payload'у достаточно только id, поэтому перечитывать объект не нужно. **6. Для исключённого участника переиспользуем `album.deleted`.** Отдельный тип («доступ отозван») не вводим: клиентская реакция одинакова — убрать альбом из списка и закрыть открытую страницу, а таксономия остаётся минимальной. Альтернатива — `album.access.revoked`/`member.left` в личном потоке: семантически точнее, но добавляет второй путь обработки и тип события ради отсутствующего сейчас UI-сообщения. Компромисс: если позже появятся текстовые уведомления («вас исключили»), тип придётся разделить — это зафиксировано в рисках. **7. Точки публикации.** | Событие | Триггер | Получатели | | --- | --- | --- | | `album.created` | создание альбома | владелец | | `album.created` | принятие приглашения | приглашённый | | `album.updated` | принятие приглашения | владелец и участники (кроме приглашённого) | | `album.updated` | смена названия/описания/обложки | владелец и участники | | `album.updated` | смена роли участника | владелец и участники | | `album.updated` | исключение участника | владелец и оставшиеся участники | | `album.updated` | загрузка/копирование/перемещение/восстановление/корзина/удаление фото | владелец и участники (личный поток) | | `photo.created` | загрузка фото в альбом | подписчики канала альбома | | `album.deleted` | удаление альбома | владелец и участники (аудитория до удаления) | | `album.deleted` | исключение участника | исключённый | **11. Изменения состава фото публикуются только в личный поток.** `album.updated` при изменении состава фото уходит получателям в личные каналы, но не в канал альбома: открытая страница узнаёт об изменениях из пер-альбомных `photo.created` / `photo.deleted`, а пер-альбомный `album.updated` заставлял бы её перечитывать альбом через REST на каждое фото (массовая загрузка = поток лишних запросов). Личные каналы для карточек обходятся без REST. Дополнительно `Upload` публикует `photo.created` в канал альбома — это реализация уже существующего требования способности `realtime` («События альбома»), а не новый контракт. **12. `photos_count` считает только фото вне корзины.** Запросы альбома добавляют `deleted_at IS NULL` к подзапросу счётчика в sqlite- и postgres-сторах. Причина: сейчас счётчик включал фото в корзине и расходился с сеткой, из-за чего «удаление» не меняло бы карточку. Так счётчик совпадает с видимым составом, а перевод в корзину, восстановление и окончательное удаление меняют его предсказуемо. Уже помещённые в корзину фото перестают учитываться без миграции данных. **8. Пер-альбомный `album.updated` сохраняется.** Метаданные продолжают публиковаться и в канал альбома: его ждёт Android-парсер (обрабатывает `album.updated`/`album.deleted` как «перечитать альбом»), а открытая страница альбома делает идемпотентную перезагрузку. Снимать пер-альбомную публикацию означало бы ломать совместимость мобильного клиента ради экономии одного запроса. **9. Web: одна подписка на уровне layout'а приложения.** Подписка на `/api/sse/me` живёт вместе с layout'ом `(app)`, а не с отдельными страницами, чтобы соединение не пересоздавалось при навигации; обработчики вызывают методы стора `albums` (`upsert`/`remove`), `onReconnect` вызывает `albums.load()`. Альтернатива — подписка на каждой странице: дублирование соединений и логики. **10. Страница альбома при потере доступа.** При событии удаления/потери доступа страница перестаёт отображать альбом и уходит на главную; отдельного сообщения пользователю в этом этапе не делаем (это уже область центра уведомлений). Обработка нужна с обоих потоков: страница подписана и на канал альбома, и косвенно на личный. ## Risks / Trade-offs - **Дублирование `album.updated`** (пер-альбомный и личный каналы) для открытой страницы → обработчики должны быть идемпотентны: личный делает `upsert`, страница — перезагрузку; лишний запрос на изменение метаданных принимаем осознанно. - **Веер O(участники) на каждое изменение** → при больших альбомах это стоимость пропорциональна числу подписанных пользователей, а не числу участников (публикация уходит в хаб, у которого нет подписчика — no-op). Массовые операции с фото (batch-удаление, перемещение) публикуют `album.updated` на каждое фото: для получателя это дешёвый `upsert` в сторе, на сервере — перечитывание альбома и веер по личным каналам. Это принято осознанно; при необходимости батчи можно схлопнуть в одно событие на альбом. - **Backpressure хаба отключает медленного клиента** → вместе с личным потоком клиент теряет и обновления списка; это уже существующее поведение SSE, и оно компенсируется догоном через `albums.load()` после переподключения. - **`album.deleted` для исключённого семантически перегружен** → если появятся текстовые уведомления, потребуется отдельный тип события; изменение аддитивное. - **Исключённый участник продолжает получать пер-альбомный SSE** до разрыва соединения (доступ проверяется только при подключении) → в этом этапе не закрывается, остаётся отдельной задачей; событие из личного потока закрывает список и открытую страницу, но не отменяет уже установленное соединение. - **URL обложки в payload'е может истечь** (presigned для S3) → URL формируется тем же кодом и с тем же TTL, что в REST-ответе; при переподключении клиент получает свежий список. - **События SSE не имеют идентификаторов** → повторная доставка и пропуски обрабатываются только догоном через REST; история событий не хранится. - **`photo.created` при загрузке приходит без URL вариантов** (thumb/preview ещё не готовы) → клиент вставляет фото-заглушку, а пришедший позже `photo.updated` заполняет URL на месте; порядок сетки не меняется. - **`photos_count` теперь без корзины** → у альбомов с фото в корзине отображаемое число уменьшится сразу после выката; данные не мигрируются. ## Migration Plan Изменение аддитивное: новых эндпоинтов и миграций нет, старые клиенты неизвестные события игнорируют, пер-альбомный контракт событий сохраняется. 1. Задеплоить сервер целиком (статика вшита в бинарник): публикации и веер начинают работать сразу, web-клиент подписку получает в том же артефакте. 2. Порядок отката: вернуть предыдущий бинарник — сервер снова публикует только пер-альбомные события, клиент в том же артефакте просто не получает событий в личном потоке и возвращается к загрузке списка при открытии страницы. Состояние БД не меняется (счётчик снова начнёт включать корзину — это поведение старого бинарника). 3. Проверка после выката: список альбомов на втором устройстве/вкладке обновляется без перезагрузки при переименовании, смене обложки, добавлении и исключении участника, загрузке/перемещении/восстановлении/удалении фото и удалении альбома; исключённый видит исчезновение альбома из списка, а открытая страница добавляет загруженное фото.