chore(openspec): архивировать album-live-sync и синхронизировать основные спеки

- realtime: личный поток несёт события жизненного цикла альбома и изменения
  счётчика фото; добавлены требования о жизненном цикле, получателях, форме
  payload'а, догоне и потере доступа
- albums: photos_count считает только фото вне корзины
This commit is contained in:
iu
2026-10-15 23:25:07 +03:00
parent e82f45647c
commit 6e68242ec2
8 changed files with 556 additions and 2 deletions
@@ -0,0 +1,182 @@
# 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. Проверка после выката: список альбомов на втором устройстве/вкладке обновляется без
перезагрузки при переименовании, смене обложки, добавлении и исключении участника,
загрузке/перемещении/восстановлении/удалении фото и удалении альбома; исключённый видит
исчезновение альбома из списка, а открытая страница добавляет загруженное фото.