diff --git a/openspec/changes/archive/2026-10-16-android-live-albums/.openspec.yaml b/openspec/changes/archive/2026-10-16-android-live-albums/.openspec.yaml new file mode 100644 index 0000000..0cb4765 --- /dev/null +++ b/openspec/changes/archive/2026-10-16-android-live-albums/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-16 diff --git a/openspec/changes/archive/2026-10-16-android-live-albums/design.md b/openspec/changes/archive/2026-10-16-android-live-albums/design.md new file mode 100644 index 0000000..4560f18 --- /dev/null +++ b/openspec/changes/archive/2026-10-16-android-live-albums/design.md @@ -0,0 +1,123 @@ +# 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-перезагрузки, чтобы убрать дублирующий запрос? На поведение и разбивку + задач не влияет. diff --git a/openspec/changes/archive/2026-10-16-android-live-albums/proposal.md b/openspec/changes/archive/2026-10-16-android-live-albums/proposal.md new file mode 100644 index 0000000..8d97114 --- /dev/null +++ b/openspec/changes/archive/2026-10-16-android-live-albums/proposal.md @@ -0,0 +1,67 @@ +# Proposal + +## Why + +Список альбомов в Android-клиенте обновляется только REST-снимком при открытии экрана, +поэтому изменения, сделанные с другого устройства или в веб-клиенте (создание, +переименование, смена обложки, изменение состава участников, удаление альбома, изменение +состава фото), не доходят до открытого приложения до ручного обновления. На вебе это уже +решено персональным потоком `GET /api/sse/me`, и сервер события публикует, но Android к +этому потоку не подключён. + +Отдельно открытый экран альбома не обрабатывает потерю доступа: при удалении альбома +владельцем или исключении участника экран продолжает отображать устаревшие данные +(или пустую сетку с заголовком-заглушкой), а карточка остаётся в списке. + +## What Changes + +- Android подписывается на персональный поток `GET /api/sse/me` и применяет + `album.created`, `album.updated` и `album.deleted` к локальному кэшу (Room) без + дополнительного запроса списка. +- Подписка привязана к сессии: создаётся при входе на главный экран и завершается при + выходе из аккаунта, не пересоздаётся при навигации между экранами. +- Применение события — это мутация кэша: `upsert` объекта альбома или удаление альбома + и связанных с ним фото. Список и открытый экран альбома наблюдают Room и обновляются + сами, дополнительных запросов на каждое событие нет. +- `album.deleted` (удаление владельцем или исключение участника) убирает альбом из кэша; + открытый экран этого альбома перестаёт его показывать и возвращается к списку. +- При переподключении персонального потока клиент догоняет состояние REST-снимком + списка альбомов, поэтому изменения, пропущенные во время разрыва, не теряются. +- Порядок элементов списка — правило отображения клиента: событие, применённое к + локальному кэшу, может изменить позицию альбома (Android упорядочивает серверные + альбомы по времени изменения). +- События `notification` в персональном потоке игнорируются: в Android нет экрана + уведомлений и приглашений. +- **BREAKING**: нет. Изменение аддитивное: пер-альбомный контракт событий и REST остаются + прежними, неизвестные клиенту события он уже сейчас игнорирует. + +## Capabilities + +### New Capabilities + +_Нет — поведение расширяет существующие способности `android-client` и `realtime`._ + +### Modified Capabilities + +- `android-client`: добавляется живое обновление списка альбомов из персонального потока + (применение событий к кэшу без запроса списка, догон при переподключении) и обработка + потери доступа к альбому (удаление из кэша, уход открытого экрана). +- `realtime`: сценарий «Обновление альбома из списка» больше не требует сохранять порядок + списка от всех клиентов — порядок становится правилом отображения клиента. + +## Impact + +- **Код (Android):** новый клиент персонального потока (`data/network`), парсер событий + жизненного цикла альбома, применение событий в `data/repo` (Room), подписка в + `ui/albums/AlbumsViewModel`, обработка потери доступа в `ui/album/AlbumViewModel` и + `ui/album/AlbumScreen`, `di/AppContainer`. +- **Схема Room:** без изменений (новых колонок и миграций не требуется). +- **API:** новых эндпоинтов нет; существующий `GET /api/sse/me` начинает использоваться + мобильным клиентом. Для авторизации используется заголовок `Authorization` (у Android + есть интерцептор), поэтому query-параметр `token` не нужен. +- **Не затрагивается:** веб-клиент, бэкенд (события и веер уже реализованы), схема БД, + пер-альбомный поток, загрузка и синхронизация медиа. +- **Известное ограничение (фиксируется, не закрывается здесь):** пер-альбомный SSE + проверяет доступ только при подключении, поэтому исключённый участник продолжает + получать события альбома до разрыва соединения. Персональный поток закрывает список и + открытый экран, но не отменяет установленное соединение. diff --git a/openspec/changes/archive/2026-10-16-android-live-albums/specs/android-client/spec.md b/openspec/changes/archive/2026-10-16-android-live-albums/specs/android-client/spec.md new file mode 100644 index 0000000..3d1048a --- /dev/null +++ b/openspec/changes/archive/2026-10-16-android-live-albums/specs/android-client/spec.md @@ -0,0 +1,55 @@ +# Spec Delta + +## ADDED Requirements + +### Requirement: Живое обновление списка альбомов +Пока пользователь авторизован, приложение SHALL поддерживать персональный поток и +применять события жизненного цикла альбома к локальному кэшу: создавать или заменять +альбом по полученному объекту и удалять его по идентификатору, не выполняя +дополнительный запрос списка. + +#### Scenario: Альбом создан или изменён в другом месте +- **WHEN** в персональный поток приходит событие создания или изменения доступного альбома +- **THEN** альбом появляется в списке или заменяется без запроса списка + +#### Scenario: Неизвестное событие +- **WHEN** в персональный поток приходит событие, не относящееся к жизненному циклу альбома +- **THEN** приложение не меняет локальный список + +#### Scenario: Навигация между экранами +- **WHEN** пользователь переходит с главного экрана на другой экран приложения +- **THEN** подписка на персональный поток не пересоздаётся + +#### Scenario: Выход из аккаунта +- **WHEN** пользователь выходит из аккаунта +- **THEN** подписка на персональный поток закрывается + +### Requirement: Догон списка при переподключении персонального потока +После восстановления персонального потока приложение SHALL приводить локальный список +альбомов в соответствие с серверным снимком, чтобы отразить изменения, пропущенные за +время разрыва соединения. + +#### Scenario: Переподключение +- **WHEN** персональный поток восстановился после разрыва +- **THEN** приложение перезапрашивает список альбомов, удаляя из кэша альбомы, которых на сервере больше нет + +### Requirement: Потеря доступа к альбому +Приложение SHALL переставать отображать альбом, удалённый владельцем или ставший +недоступным после исключения участника: альбом SHALL исчезать из локального кэша и +списка, а экран открытого альбома SHALL возвращаться к списку. + +#### Scenario: Альбом удалён во время просмотра +- **WHEN** владелец удаляет альбом, пока участник просматривает экран этого альбома +- **THEN** экран перестаёт показывать альбом и возвращается к списку + +#### Scenario: Участник исключён +- **WHEN** участника исключают из альбома +- **THEN** альбом исчезает из его списка, а открытый экран возвращается к списку + +#### Scenario: Экран альбома не открыт +- **WHEN** альбом удалён, а его экран не открыт +- **THEN** карточка альбома исчезает из списка + +#### Scenario: Отсутствие альбома в кэше без сети +- **WHEN** приложение открывает экран альбома, которого нет в кэше, и сеть недоступна +- **THEN** экран не считается потерявшим доступ и не закрывается сам diff --git a/openspec/changes/archive/2026-10-16-android-live-albums/specs/realtime/spec.md b/openspec/changes/archive/2026-10-16-android-live-albums/specs/realtime/spec.md new file mode 100644 index 0000000..389bf06 --- /dev/null +++ b/openspec/changes/archive/2026-10-16-android-live-albums/specs/realtime/spec.md @@ -0,0 +1,20 @@ +# Spec Delta + +## MODIFIED Requirements + +### Requirement: Применение события без повторного запроса +Клиент SHALL обновлять локальный список альбомов из payload'а события: создавать или +заменять альбом по полученному объекту и удалять его по идентификатору, не выполняя +дополнительный запрос списка. + +#### Scenario: Обновление альбома из списка +- **WHEN** приходит событие обновления для альбома, который уже есть в списке клиента +- **THEN** клиент заменяет альбом по полученному объекту без дополнительного запроса списка + +#### Scenario: Удаление альбома из списка +- **WHEN** приходит событие удаления для альбома, который есть в списке клиента +- **THEN** клиент убирает альбом из списка + +#### Scenario: Порядок списка +- **WHEN** клиент применяет событие к списку альбомов +- **THEN** порядок элементов списка определяется правилами отображения этого клиента diff --git a/openspec/changes/archive/2026-10-16-android-live-albums/tasks.md b/openspec/changes/archive/2026-10-16-android-live-albums/tasks.md new file mode 100644 index 0000000..5e06c70 --- /dev/null +++ b/openspec/changes/archive/2026-10-16-android-live-albums/tasks.md @@ -0,0 +1,38 @@ +# Tasks + +## 1. Клиент персонального потока + +- [x] 1.1 Добавить в `GaloApi` запрос персонального потока (`api/sse/me`, `Accept: text/event-stream`); проверить request-тестом, что URL собран от базового адреса и заголовок выставлен +- [x] 1.2 Добавить парсер событий жизненного цикла альбома (`album.created`, `album.updated`, `album.deleted` -> типы с объектом/идентификатором; `notification` и неизвестные -> игнорировать); проверить unit-тестами на каждый тип, на неизвестный тип и на битый JSON +- [x] 1.3 Добавить `PersonalSseClient` по образцу `AlbumSseClient` (backoff, событие `Reconnected` после разрыва); проверить тестом: события доходят в порядке получения, после разрыва приходит `Reconnected` +- [x] 1.4 Проверить завершение соединения при отмене коллектора (отменяется ли `EventSource`, когда поток перестают собирать), при необходимости закрывать его в `finally`/`invokeOnClose`; проверить тестом, что после отмены поток не переоткрывается + +## 2. Применение событий к кэшу + +- [x] 2.1 Вывести удаление в `AlbumLocalSource` (`deleteByIds`) и `PhotoLocalSource` (очистка фото альбома); проверить тестами локального источника, что записи удаляются +- [x] 2.2 Добавить в `AlbumRepository` применение событий: `album.created`/`album.updated` заменяют альбом, `album.deleted` удаляет альбом вместе с его фото; проверить тестами на фейках: обновлённый объект виден из `observeAlbums`, удалённый исчезает вместе со своими фото +- [x] 2.3 Связать клиент персонального потока с репозиторием в `AppContainer` и обработчиком событий; проверить тестом, что событие из потока меняет то, что отдаёт `observeAlbums` + +## 3. Подписка на время сессии + +- [x] 3.1 Подписаться на персональный поток в `AlbumsViewModel` и завершать подписку при уничтожении ViewModel; проверить тестом, что подписка создаётся один раз и не пересоздаётся при повторном обращении к состоянию +- [x] 3.2 На `Reconnected` вызывать `albumRepository.refresh()`; проверить тестом, что альбом, удалённый на сервере за время разрыва, исчезает из кэша после догона +- [x] 3.3 Обновить `android/README.md`, раздел «Live updates»: персональный поток, обновление списка и потеря доступа; проверить, что описанный эндпоинт совпадает с реализованным запросом + +## 4. Потеря доступа к альбому + +- [x] 4.1 Отслеживать в `AlbumViewModel` факт «альбом уже был показан» и отдавать сигнал потери доступа при переходе «ненулевой -> отсутствует»; проверить unit-тестами оба случая: был и исчез — сигнал есть, не было изначально (нет кэша, нет сети) — сигнала нет +- [x] 4.2 По сигналу потери доступа экран альбома возвращается к списку; проверить Compose-тестом (Robolectric), что экран инициирует возврат, а список больше не содержит альбом +- [x] 4.3 Проверить сценарий исключения участника: `album.deleted` приходит только личным потоком, альбом исчезает из списка и открытый экран уходит назад + +## 5. Интеграционная проверка + +- [x] 5.1 Прогнать сквозной сценарий на двух аккаунтах против локального сервера: создание, переименование, смена обложки, удаление альбома и исключение участника — список и открытый экран обновляются без ручного обновления; приложить результат (шаги и наблюдение) +- [x] 5.2 Проверить поведение при истечении токена в долгоживущем потоке: соединение восстанавливается после обновления токена; если нет — зафиксировать ограничение в README +- [x] 5.3 Прогнать на финальной ревизии `cd android && ./gradlew test` и `cd android && ./gradlew :app:assembleDebug`; зафиксировать, что обе команды проходят +- [x] 5.4 Прогонять unit-тесты только на debug-варианте (тестовая активность Compose приходит из debug-зависимости `ui-test-manifest`), чтобы `cd android && ./gradlew test` проходил без тестовой активности в release-манифесте; проверить `./gradlew test`, `:app:assembleDebug` и `:app:assembleRelease` + +## Workflow follow-up + +- Заархивировать change после прохождения ревью проекта (`openspec archive android-live-albums`). +- Проверить, что после архивации требования вошли в `openspec/specs/android-client/spec.md` и `openspec/specs/realtime/spec.md`, а `openspec validate --strict android-live-albums` не выдаёт ошибок. diff --git a/openspec/changes/archive/2026-10-16-fix-album-cover-readiness/.openspec.yaml b/openspec/changes/archive/2026-10-16-fix-album-cover-readiness/.openspec.yaml new file mode 100644 index 0000000..0cb4765 --- /dev/null +++ b/openspec/changes/archive/2026-10-16-fix-album-cover-readiness/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-16 diff --git a/openspec/changes/archive/2026-10-16-fix-album-cover-readiness/design.md b/openspec/changes/archive/2026-10-16-fix-album-cover-readiness/design.md new file mode 100644 index 0000000..462ae5f --- /dev/null +++ b/openspec/changes/archive/2026-10-16-fix-album-cover-readiness/design.md @@ -0,0 +1,65 @@ +# Design + +## Context + +См. `proposal.md` — мотивация. Что важно для подхода: + +- Ссылки на обложку формируются из `photo_objects` (роли `thumb`/`medium`), которые + заполняются асинхронно, поэтому в объекте альбома (`Store.GetByID` джойнит обложку) они + появляются не в момент загрузки, а когда варианты готовы. +- Загрузка фото вызывает `SetCoverIfEmpty`, то есть первое фото становится обложкой ещё до + генерации вариантов, а `NotifyAlbumChanged` на этом шаге публикует `album.updated` уже с + `cover_photo_id`, но с пустыми ссылками. +- Готовность вариантов обрабатывает `Worker.notify` (`internal/photos/worker.go`): он + публикует только `photo.updated` в канал альбома и персональные потоки не трогает. +- `albums.Service` уже умеет перечитать альбом, обогатить обложку (`attachCover`) и веерно + опубликовать `album.updated` (`NotifyAlbumChanged` → `PublishAlbumTo`). + +## Goals / Non-Goals + +**Goals:** + +- Доставить актуальную ссылку на обложку в персональные потоки, когда она появляется. +- Не увеличивать поток событий при массовой загрузке. + +**Non-Goals:** + +- Менять контракт `photo.updated` в канале альбома (открытый экран закрывает свой случай сам). +- Клиентские обходные пути (рефетч списка по таймеру и т.п.). +- Синхронная генерация вариантов при загрузке. + +## Decisions + +**1. Публикует `albums.Service`, а не воркер.** +Решение «объект альбома изменился» принимает владелец альбомов: добавляется +`NotifyCoverReady(ctx, albumID, photoID)`, который перечитывает альбом и публикует +`album.updated`, только если `cover_photo_id == photoID` (иначе no-op), переиспользуя +`NotifyAlbumChanged`. Воркер остаётся про фото и лишь сообщает о готовности. +Альтернатива — строить `album.updated` прямо в воркере: тогда `photos` пришлось бы знать +форму объекта альбома, обложку и аудиторию. + +**2. Условие — совпадение с текущей обложкой.** +Готовность вариантов обычного фото объект альбома не меняет. Без условия пришлось бы на +каждое фото перечитывать альбом и веерно публиковать `album.updated` всем участникам — лишняя +нагрузка при массовой загрузке и лишние события клиентам. +Альтернатива — «публиковать всегда»: проще, но шумно; отвергнуто. + +**3. Проверка по `cover_photo_id`, а не по «в альбоме ещё нет ссылки на обложку».** +Так закрываются оба сценария: и автопоставленная обложка первой загрузкой, и явная установка +обложки на фото, варианты которого ещё не готовы. + +## Risks / Trade-offs + +- **Двойное чтение альбома** (`GetByID` для проверки обложки и внутри `PublishAlbumTo`) → + дополнительное чтение точечное (только для готовности вариантов фото-обложки), принято; при + необходимости схлопывается в один публикующий вызов. +- **Гонка со сменой обложки** между публикацией `photo.created` и готовностью вариантов → + проверка по актуальному `cover_photo_id` отсекает лишнюю публикацию, а новая обложка получит + своё событие при готовности её вариантов. +- **Аудитория недоступна** (ошибка в `members.Audience`) → событие не публикуется; клиент + догоняет состояние при переподключении (`refresh`) — существующее поведение веера. + +## Migration Plan + +Изменение аддитивное: схема и API не меняются, нужен только перезапуск бэкенда (логика и +статика в одном бинарнике). Откат — предыдущий бинарник. diff --git a/openspec/changes/archive/2026-10-16-fix-album-cover-readiness/proposal.md b/openspec/changes/archive/2026-10-16-fix-album-cover-readiness/proposal.md new file mode 100644 index 0000000..1f8341f --- /dev/null +++ b/openspec/changes/archive/2026-10-16-fix-album-cover-readiness/proposal.md @@ -0,0 +1,41 @@ +# Proposal + +## Why + +Обложка альбома на карточке в списке не обновляется вживую: после загрузки фото счётчик +`photos_count` приходит, а обложка остаётся заглушкой до ручного обновления. Ссылка на +обложку (`cover_thumb_url` / `cover_medium_url`) появляется в объекте альбома только после +генерации вариантов thumb/medium, но событие об этом в персональные потоки не публикуется. +Дефект серверной стороны способности `realtime`, поэтому затрагивает и веб-клиент, и Android. + +## What Changes + +- Когда для фото, являющегося обложкой альбома, готовы варианты, система публикует + `album.updated` с актуальным объектом альбома в персональные потоки владельца и + участников — именно тогда объект впервые получает ссылку на обложку. +- Готовность вариантов фото, которое обложкой не является, объект альбома не меняет и + события не порождает: иначе массовая загрузка даёт лишний веер `album.updated` на каждый + файл. +- Контракт существующих событий и REST не меняется, новых эндпоинтов и миграций нет. +- **BREAKING**: нет. + +## Capabilities + +### New Capabilities + +_Нет — поведение расширяет существующую способность `realtime`._ + +### Modified Capabilities + +- `realtime`: добавляется публикация `album.updated` в персональные потоки при готовности + вариантов фото-обложки, поскольку только тогда объект альбома получает ссылку на обложку. + +## Impact + +- **Код (backend):** `internal/albums/service.go` (новая публикация), порт `AlbumAccess` в + `internal/photos/service.go`, вызов из `internal/photos/worker.go` (готовность вариантов), + стаб порта в `internal/photos/helpers_test.go`. +- **Документация:** `project.md` (§11 «Real-time (SSE)», §12 «SSE — детали реализации»). +- **Не затрагивается:** схема БД, миграции, REST, пер-альбомный контракт (`photo.updated` + по-прежнему приходит в канал альбома), клиенты — веб и Android уже применяют + `album.updated` из персонального потока. diff --git a/openspec/changes/archive/2026-10-16-fix-album-cover-readiness/specs/realtime/spec.md b/openspec/changes/archive/2026-10-16-fix-album-cover-readiness/specs/realtime/spec.md new file mode 100644 index 0000000..c2be08d --- /dev/null +++ b/openspec/changes/archive/2026-10-16-fix-album-cover-readiness/specs/realtime/spec.md @@ -0,0 +1,17 @@ +# Spec Delta + +## ADDED Requirements + +### Requirement: Обновление альбома при готовности вариантов обложки +Когда для фото, являющегося обложкой альбома, сгенерированы варианты, система SHALL +публиковать `album.updated` с актуальным объектом альбома в персональные потоки владельца и +участников: только тогда объект получает ссылку на обложку. Готовность вариантов прочих фото +событий не порождает. + +#### Scenario: Варианты обложки готовы +- **WHEN** для фото, которое является обложкой альбома, сгенерированы thumb и medium +- **THEN** владелец и участники получают `album.updated` с непустой ссылкой на обложку + +#### Scenario: Варианты обычного фото готовы +- **WHEN** варианты готовы для фото, которое не является обложкой альбома +- **THEN** персональные потоки не получают `album.updated` diff --git a/openspec/changes/archive/2026-10-16-fix-album-cover-readiness/tasks.md b/openspec/changes/archive/2026-10-16-fix-album-cover-readiness/tasks.md new file mode 100644 index 0000000..8184e9c --- /dev/null +++ b/openspec/changes/archive/2026-10-16-fix-album-cover-readiness/tasks.md @@ -0,0 +1,20 @@ +# Tasks + +## 1. Публикация при готовности вариантов обложки + +- [x] 1.1 Добавить в `albums.Service` публикацию `album.updated` в персональные потоки, когда варианты готовы у фото, являющегося обложкой (проверка по актуальному `cover_photo_id`), переиспользуя существующий веер; проверить тестом: владелец и участник получают событие со ссылкой на обложку, а для фото, которое обложкой не является, публикации нет +- [x] 1.2 Расширить порт `AlbumAccess` в `photos` и вызвать публикацию из `Worker.notify` после `photo.updated`, обновив стаб порта в тестах; проверить тестом, что готовность вариантов фото вызывает публикацию + +## 2. Документация контракта + +- [x] 2.1 Обновить `project.md` (§11 «Real-time (SSE)» и §12 «SSE — детали реализации»): `album.updated` публикуется также при готовности вариантов фото-обложки; проверить, что перечисленные триггеры совпадают с реализованными публикациями + +## 3. Проверка + +- [x] 3.1 Прогнать `cd backend && go test ./...` и зафиксировать, что проходит +- [x] 3.2 Воспроизвести сценарий на временном инстансе: подписка на `/api/sse/me`, загрузка фото в альбом без обложки — после готовности вариантов приходит `album.updated` с непустой ссылкой на обложку; приложить вывод + +## Workflow follow-up + +- Заархивировать change после прохождения ревью проекта (`openspec archive fix-album-cover-readiness`). +- Проверить, что после архивации требование вошло в `openspec/specs/realtime/spec.md`, а `openspec validate --strict fix-album-cover-readiness` не выдаёт ошибок. diff --git a/openspec/specs/android-client/spec.md b/openspec/specs/android-client/spec.md index 96e5045..40fdea4 100644 --- a/openspec/specs/android-client/spec.md +++ b/openspec/specs/android-client/spec.md @@ -619,3 +619,55 @@ SHALL показывать значками расположение содер #### Scenario: Не сопоставлено - **WHEN** сопоставления с серверным фото нет - **THEN** лист показывает локальные атрибуты и локальные действия элемента + +### Requirement: Живое обновление списка альбомов +Пока пользователь авторизован, приложение SHALL поддерживать персональный поток и +применять события жизненного цикла альбома к локальному кэшу: создавать или заменять +альбом по полученному объекту и удалять его по идентификатору, не выполняя +дополнительный запрос списка. + +#### Scenario: Альбом создан или изменён в другом месте +- **WHEN** в персональный поток приходит событие создания или изменения доступного альбома +- **THEN** альбом появляется в списке или заменяется без запроса списка + +#### Scenario: Неизвестное событие +- **WHEN** в персональный поток приходит событие, не относящееся к жизненному циклу альбома +- **THEN** приложение не меняет локальный список + +#### Scenario: Навигация между экранами +- **WHEN** пользователь переходит с главного экрана на другой экран приложения +- **THEN** подписка на персональный поток не пересоздаётся + +#### Scenario: Выход из аккаунта +- **WHEN** пользователь выходит из аккаунта +- **THEN** подписка на персональный поток закрывается + +### Requirement: Догон списка при переподключении персонального потока +После восстановления персонального потока приложение SHALL приводить локальный список +альбомов в соответствие с серверным снимком, чтобы отразить изменения, пропущенные за +время разрыва соединения. + +#### Scenario: Переподключение +- **WHEN** персональный поток восстановился после разрыва +- **THEN** приложение перезапрашивает список альбомов, удаляя из кэша альбомы, которых на сервере больше нет + +### Requirement: Потеря доступа к альбому +Приложение SHALL переставать отображать альбом, удалённый владельцем или ставший +недоступным после исключения участника: альбом SHALL исчезать из локального кэша и +списка, а экран открытого альбома SHALL возвращаться к списку. + +#### Scenario: Альбом удалён во время просмотра +- **WHEN** владелец удаляет альбом, пока участник просматривает экран этого альбома +- **THEN** экран перестаёт показывать альбом и возвращается к списку + +#### Scenario: Участник исключён +- **WHEN** участника исключают из альбома +- **THEN** альбом исчезает из его списка, а открытый экран возвращается к списку + +#### Scenario: Экран альбома не открыт +- **WHEN** альбом удалён, а его экран не открыт +- **THEN** карточка альбома исчезает из списка + +#### Scenario: Отсутствие альбома в кэше без сети +- **WHEN** приложение открывает экран альбома, которого нет в кэше, и сеть недоступна +- **THEN** экран не считается потерявшим доступ и не закрывается сам diff --git a/openspec/specs/realtime/spec.md b/openspec/specs/realtime/spec.md index 31e3bd2..509317f 100644 --- a/openspec/specs/realtime/spec.md +++ b/openspec/specs/realtime/spec.md @@ -116,12 +116,16 @@ comment.created, like.added, like.removed, member.joined, member.left, album.upd #### Scenario: Обновление альбома из списка - **WHEN** приходит событие обновления для альбома, который уже есть в списке клиента -- **THEN** клиент заменяет его на месте, не меняя порядок списка +- **THEN** клиент заменяет альбом по полученному объекту без дополнительного запроса списка #### Scenario: Удаление альбома из списка - **WHEN** приходит событие удаления для альбома, который есть в списке клиента - **THEN** клиент убирает альбом из списка +#### Scenario: Порядок списка +- **WHEN** клиент применяет событие к списку альбомов +- **THEN** порядок элементов списка определяется правилами отображения этого клиента + ### Requirement: Догон состояния альбомов при переподключении Клиент SHALL после восстановления персонального потока синхронизировать список альбомов через REST, чтобы отразить изменения, пропущенные во время разрыва соединения. @@ -165,3 +169,17 @@ comment.created, like.added, like.removed, member.joined, member.left, album.upd #### Scenario: Окончательное удаление фото - **WHEN** фото удаляется окончательно - **THEN** владелец и участники получают `album.updated` с уменьшенным `photos_count` + +### Requirement: Обновление альбома при готовности вариантов обложки +Когда для фото, являющегося обложкой альбома, сгенерированы варианты, система SHALL +публиковать `album.updated` с актуальным объектом альбома в персональные потоки владельца и +участников: только тогда объект получает ссылку на обложку. Готовность вариантов прочих фото +событий не порождает. + +#### Scenario: Варианты обложки готовы +- **WHEN** для фото, которое является обложкой альбома, сгенерированы thumb и medium +- **THEN** владелец и участники получают `album.updated` с непустой ссылкой на обложку + +#### Scenario: Варианты обычного фото готовы +- **WHEN** варианты готовы для фото, которое не является обложкой альбома +- **THEN** персональные потоки не получают `album.updated`