Files
ds-galo/openspec/changes/archive/2026-10-15-album-live-sync/design.md
T
iu 6e68242ec2 chore(openspec): архивировать album-live-sync и синхронизировать основные спеки
- realtime: личный поток несёт события жизненного цикла альбома и изменения
  счётчика фото; добавлены требования о жизненном цикле, получателях, форме
  payload'а, догоне и потере доступа
- albums: photos_count считает только фото вне корзины
2026-10-15 23:25:07 +03:00

183 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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. Проверка после выката: список альбомов на втором устройстве/вкладке обновляется без
перезагрузки при переименовании, смене обложки, добавлении и исключении участника,
загрузке/перемещении/восстановлении/удалении фото и удалении альбома; исключённый видит
исчезновение альбома из списка, а открытая страница добавляет загруженное фото.