From 6e68242ec29c138c7bc01d58f4040e56c9154e42 Mon Sep 17 00:00:00 2001 From: Iu Date: Thu, 15 Oct 2026 23:24:06 +0300 Subject: [PATCH] =?UTF-8?q?chore(openspec):=20=D0=B0=D1=80=D1=85=D0=B8?= =?UTF-8?q?=D0=B2=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20album-live-s?= =?UTF-8?q?ync=20=D0=B8=20=D1=81=D0=B8=D0=BD=D1=85=D1=80=D0=BE=D0=BD=D0=B8?= =?UTF-8?q?=D0=B7=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20=D0=BE=D1=81?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2=D0=BD=D1=8B=D0=B5=20=D1=81=D0=BF=D0=B5=D0=BA?= =?UTF-8?q?=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - realtime: личный поток несёт события жизненного цикла альбома и изменения счётчика фото; добавлены требования о жизненном цикле, получателях, форме payload'а, догоне и потере доступа - albums: photos_count считает только фото вне корзины --- .../2026-10-15-album-live-sync/.openspec.yaml | 2 + .../2026-10-15-album-live-sync/design.md | 182 ++++++++++++++++++ .../2026-10-15-album-live-sync/proposal.md | 85 ++++++++ .../specs/albums/spec.md | 20 ++ .../specs/realtime/spec.md | 112 +++++++++++ .../2026-10-15-album-live-sync/tasks.md | 45 +++++ openspec/specs/albums/spec.md | 11 +- openspec/specs/realtime/spec.md | 101 +++++++++- 8 files changed, 556 insertions(+), 2 deletions(-) create mode 100644 openspec/changes/archive/2026-10-15-album-live-sync/.openspec.yaml create mode 100644 openspec/changes/archive/2026-10-15-album-live-sync/design.md create mode 100644 openspec/changes/archive/2026-10-15-album-live-sync/proposal.md create mode 100644 openspec/changes/archive/2026-10-15-album-live-sync/specs/albums/spec.md create mode 100644 openspec/changes/archive/2026-10-15-album-live-sync/specs/realtime/spec.md create mode 100644 openspec/changes/archive/2026-10-15-album-live-sync/tasks.md diff --git a/openspec/changes/archive/2026-10-15-album-live-sync/.openspec.yaml b/openspec/changes/archive/2026-10-15-album-live-sync/.openspec.yaml new file mode 100644 index 0000000..a216797 --- /dev/null +++ b/openspec/changes/archive/2026-10-15-album-live-sync/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-15 diff --git a/openspec/changes/archive/2026-10-15-album-live-sync/design.md b/openspec/changes/archive/2026-10-15-album-live-sync/design.md new file mode 100644 index 0000000..76532d3 --- /dev/null +++ b/openspec/changes/archive/2026-10-15-album-live-sync/design.md @@ -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. Проверка после выката: список альбомов на втором устройстве/вкладке обновляется без + перезагрузки при переименовании, смене обложки, добавлении и исключении участника, + загрузке/перемещении/восстановлении/удалении фото и удалении альбома; исключённый видит + исчезновение альбома из списка, а открытая страница добавляет загруженное фото. diff --git a/openspec/changes/archive/2026-10-15-album-live-sync/proposal.md b/openspec/changes/archive/2026-10-15-album-live-sync/proposal.md new file mode 100644 index 0000000..dcbde0b --- /dev/null +++ b/openspec/changes/archive/2026-10-15-album-live-sync/proposal.md @@ -0,0 +1,85 @@ +# Proposal + +## Why + +Real-time сейчас работает только для открытого альбома: события изменений альбома публикуются +в канал конкретного альбома, поэтому у пользователя, который не смотрит этот альбом, список +альбомов (рейл, главная) остаётся устаревшим до перезагрузки страницы. Персональный поток +`GET /api/sse/me` существует, но не несёт событий жизненного цикла альбома, и ни один клиент +в него не подписан — то есть создание, переименование, смена обложки, изменение состава +участников и удаление альбома не доходят до тех, кого они касаются. + +Отдельно устаревает состав фото: загрузка, перемещение, копирование, восстановление и удаление +фото не публикуют событие альбома, поэтому счётчик `photos_count` и обложка на карточках не +обновляются, а открытая страница альбома у других клиентов не показывает загруженное фото +(`Upload` не публикует `photo.created`, хотя способность `realtime` это требует). + +## What Changes + +- Персональный поток `GET /api/sse/me` начинает нести события жизненного цикла альбома: + `album.created` и `album.updated` с полным объектом альбома в той же форме, что отдаёт + `GET /api/albums`, и `album.deleted` с `{id}`. +- Получатели события — пользователи, для которых альбом входит в список доступных: владелец + (`albums.owner_id`) и участники (`album_members`); событие доставляется веером по личным + каналам. +- Публикации добавляются в: создание альбома, принятие инвайта, изменение названия/описания/ + обложки, смену роли, исключение участника и выход, удаление альбома. +- Список альбомов обновляется и при изменении состава фото: загрузка, перемещение, + копирование, восстановление, перевод в корзину и окончательное удаление публикуют + `album.updated` в личные потоки владельца и участников, поэтому `photos_count` и обложка + на карточках становятся актуальными без перезагрузки. +- Загрузка фото публикует `photo.created` в канал альбома, поэтому открытая страница альбома + у других клиентов добавляет новое фото; готовность вариантов по-прежнему приходит как + `photo.updated` и обновляет фото на месте. +- Web-клиент подписывается на `/api/sse/me` на уровне layout'а приложения и применяет + события к стору списка альбомов (`upsert` / `remove`) без повторного запроса; при + переподключении догоняет состояние через `GET /api/albums`. +- Открытая страница альбома начинает обрабатывать `album.deleted` (сейчас обрабатывается + только `album.updated`, поэтому удалённый альбом продолжает отображаться у участников). +- Контракт существующих пер-альбомных событий (`photo.*`, `like.*`, `comment.created`, + `member.*`, `album.updated`) и REST не меняется: `Upload` начинает публиковать уже + предусмотренный контрактом `photo.created`, остальное — аддитивно. + +**Не входит в этап** (осознанно отложено): персистентный центр уведомлений (колокольчик, +список непрочитанных, новые типы в таблице `notifications`); Android-клиент (личный поток +остаётся совместимым, подключение мобильного клиента — отдельный шаг); принудительный обрыв +пер-альбомного SSE у исключённого участника. + +## Capabilities + +### New Capabilities + +_Нет — поведение описывает расширение существующей способности `realtime`, отдельная +способность не вводится._ + +### Modified Capabilities + +- `realtime`: персональный поток обязан нести события жизненного цикла альбома с полным + payload'ом; определяются получатели события, поведение клиента при получении (обновление + или удаление альбома в списке без запроса) и догон состояния при переподключении. Дополнительно + определяются события при изменении состава фото: список альбомов обновляется в реальном + времени, а загрузка фото публикует `photo.created`. +- `albums`: уточняется счётчик `photos_count` — он считает только доступные в альбоме фото + (вне корзины), чтобы совпадать с сеткой и корректно меняться при переводе в корзину и + восстановлении. + +## Impact + +- **Код (backend):** `internal/sse` (веер по личным каналам), `internal/albums/service.go` + (Create/Update/Delete, рассылка `album.updated` получателям), `internal/members/service.go` + (Accept/ChangeRole/Remove), `internal/photos/service.go` (загрузка/удаление/перемещение/ + копирование/восстановление фото публикуют `photo.created` и `album.updated`), точка + сборки `internal/app/app.go` и `cmd/server/main.go` — если для веера потребуется новый + интерфейс-резолвер аудитории альбома. +- **Код (frontend):** `routes/(app)/+layout.svelte` (единая подписка на `/api/sse/me`), + `lib/stores/albums.svelte.ts` (upsert/remove/load как обработчики событий), + `routes/(app)/albums/[id]/+page.svelte` (обработка `album.deleted`), `lib/sse.ts` — + список известных событий. +- **API:** новых HTTP-эндпоинтов нет; существующий `GET /api/sse/me` начинает отдавать + дополнительные типы событий. Для старых клиентов изменение аддитивное. +- **Не затрагивается:** `notifications` (персистентные уведомления остаются как есть), + `android-client` (требование live-обновлений там и так ограничено активным экраном + альбома), схема БД, миграции, зависимости. +- **Известное ограничение, фиксируемое отдельной задачей:** доступ к пер-альбомному SSE + проверяется только при подключении, поэтому исключённый участник продолжает получать + события альбома до разрыва соединения. diff --git a/openspec/changes/archive/2026-10-15-album-live-sync/specs/albums/spec.md b/openspec/changes/archive/2026-10-15-album-live-sync/specs/albums/spec.md new file mode 100644 index 0000000..f69146f --- /dev/null +++ b/openspec/changes/archive/2026-10-15-album-live-sync/specs/albums/spec.md @@ -0,0 +1,20 @@ +# Spec Delta + +## MODIFIED Requirements + +### Requirement: Счётчики альбома +Данные альбома SHALL включать количество фото и участников, чтобы панель и карточки могли +показать их без отдельных запросов. Количество фото SHALL учитывать только фото, доступные в +альбоме (не помещённые в корзину), и совпадать с числом фото, отображаемых в сетке альбома. + +#### Scenario: Счётчики в ответе +- **WHEN** клиент запрашивает список альбомов или альбом по id +- **THEN** ответ содержит число фото и число участников альбома + +#### Scenario: Фото в корзине не учитывается +- **WHEN** фото альбома помещается в корзину +- **THEN** `photos_count` этого альбома уменьшается на единицу + +#### Scenario: Фото восстановлено из корзины +- **WHEN** фото восстанавливается из корзины в альбом +- **THEN** `photos_count` этого альбома увеличивается на единицу diff --git a/openspec/changes/archive/2026-10-15-album-live-sync/specs/realtime/spec.md b/openspec/changes/archive/2026-10-15-album-live-sync/specs/realtime/spec.md new file mode 100644 index 0000000..ab3fad6 --- /dev/null +++ b/openspec/changes/archive/2026-10-15-album-live-sync/specs/realtime/spec.md @@ -0,0 +1,112 @@ +# Spec Delta + +## MODIFIED Requirements + +### Requirement: Персональный поток +Клиент SHALL иметь возможность подписаться на персональный поток (приглашения, уведомления, +изменения его альбомов). + +#### Scenario: Подписка на персональный поток +- **WHEN** пользователь подключается к персональному потоку +- **THEN** система отправляет ему персональные события + +#### Scenario: Изменение списка альбомов +- **WHEN** альбом, доступный пользователю, создан, изменён или удалён +- **THEN** пользователь получает соответствующее событие в персональном потоке + +## ADDED Requirements + +### Requirement: События жизненного цикла альбома +Система SHALL публиковать в персональные потоки события `album.created` и `album.updated` +с объектом альбома и `album.deleted` с идентификатором альбома. + +#### Scenario: Создание альбома +- **WHEN** пользователь создаёт альбом +- **THEN** владелец получает событие `album.created` с объектом альбома + +#### Scenario: Изменение метаданных альбома +- **WHEN** участник с правом редактирования меняет название, описание или обложку +- **THEN** владелец и участники получают событие `album.updated` с актуальным объектом + +#### Scenario: Удаление альбома +- **WHEN** владелец удаляет альбом +- **THEN** владелец и участники получают событие `album.deleted` с идентификатором + +### Requirement: Получатели событий об альбоме +Система SHALL доставлять события жизненного цикла альбома владельцу и всем участникам +альбома, для которых он доступен. + +#### Scenario: Веер по участникам +- **WHEN** альбом изменён +- **THEN** событие получает владелец и каждый участник альбома, включая инициатора изменения + +#### Scenario: Принятие приглашения +- **WHEN** пользователь принимает приглашение в альбом +- **THEN** приглашённый получает событие о появлении альбома в своём списке, а владелец — + обновлённый объект альбома с новым числом участников + +### Requirement: Единая форма объекта альбома в событиях +Объект альбома в событиях SHALL совпадать по форме с элементом ответа списка альбомов, +включая URL обложки и счётчики, чтобы клиент применял событие без дополнительных запросов. + +#### Scenario: Объект события совпадает со списком +- **WHEN** клиент получает `album.created` или `album.updated` +- **THEN** объект содержит те же поля, что элемент ответа `GET /api/albums` + +### Requirement: Применение события без повторного запроса +Клиент SHALL обновлять локальный список альбомов из payload'а события: создавать или +заменять альбом по полученному объекту и удалять его по идентификатору, не выполняя +дополнительный запрос списка. + +#### Scenario: Обновление альбома из списка +- **WHEN** приходит событие обновления для альбома, который уже есть в списке клиента +- **THEN** клиент заменяет его на месте, не меняя порядок списка + +#### Scenario: Удаление альбома из списка +- **WHEN** приходит событие удаления для альбома, который есть в списке клиента +- **THEN** клиент убирает альбом из списка + +### Requirement: Догон состояния альбомов при переподключении +Клиент SHALL после восстановления персонального потока синхронизировать список альбомов +через REST, чтобы отразить изменения, пропущенные во время разрыва соединения. + +#### Scenario: Переподключение персонального потока +- **WHEN** персональный поток восстанавливается после разрыва +- **THEN** клиент перезапрашивает список альбомов и приводит локальное состояние + к серверному + +### Requirement: Потеря доступа к альбому +Клиент SHALL переставать отображать альбом, который удалён или к которому потерян доступ, +при получении соответствующего персонального события, в том числе на открытой странице +альбома. + +#### Scenario: Альбом удалён во время просмотра +- **WHEN** владелец удаляет альбом, пока участник просматривает его страницу +- **THEN** участник получает событие и страница перестаёт отображать удалённый альбом + +#### Scenario: Участник исключён из альбома +- **WHEN** участника исключают из альбома +- **THEN** исключённый получает событие, и альбом убирается из его списка + +### Requirement: Обновление альбома при изменении состава фото +Система SHALL публиковать `album.updated` с актуальным объектом альбома в персональные потоки +владельца и участников, когда состав фото альбома меняется: загрузка, копирование, перемещение, +восстановление, перевод в корзину и окончательное удаление. При загрузке система также SHALL +публиковать `photo.created` в поток альбома (см. способность `realtime`, требование «События +альбома»), чтобы открытая страница добавляла новое фото. + +#### Scenario: Загрузка фото +- **WHEN** пользователь загружает фото в альбом +- **THEN** владелец и участники получают `album.updated` с увеличенным `photos_count` + +#### Scenario: Перемещение фото +- **WHEN** фото перемещается из одного альбома в другой +- **THEN** владелец и участники обоих альбомов получают `album.updated` с актуальными счётчиками + +#### Scenario: Фото в корзине +- **WHEN** фото переводится в корзину или восстанавливается +- **THEN** владелец и участники получают `album.updated` с обновлённым `photos_count` + +#### Scenario: Окончательное удаление фото +- **WHEN** фото удаляется окончательно +- **THEN** владелец и участники получают `album.updated` с уменьшенным `photos_count` diff --git a/openspec/changes/archive/2026-10-15-album-live-sync/tasks.md b/openspec/changes/archive/2026-10-15-album-live-sync/tasks.md new file mode 100644 index 0000000..554ec14 --- /dev/null +++ b/openspec/changes/archive/2026-10-15-album-live-sync/tasks.md @@ -0,0 +1,45 @@ +# Tasks + +## 1. Аудитория альбома и доставка в личный поток + +- [x] 1.1 Добавить в `members.Repository` метод, возвращающий id пользователей альбома (владелец + участники) без join с `users`; реализовать в sqlite- и postgres-сторах; проверить новым тестом репозитория: `cd backend && go test ./internal/members/` +- [x] 1.2 Добавить в `albums.Service` публикацию события альбома в личные каналы получателей: перечитать альбом через `Store.GetByID` и обогатить `attachCover`, разослать по аудитории через `Hub.PublishUser`; закрыть доступ к резолверу аудитории интерфейсом для `members.Service`; проверить тестом с подпиской `Hub.SubscribeUser`, что получатель видит событие с тем же набором полей, что элемент `GET /api/albums` +- [x] 1.3 Проверить, что публикация в личный поток безопасна при отсутствии подписчиков и не затрагивает пер-альбомный канал: тест на хаб, где событие уходит пользователю без подписки, не паникует и не создаёт подписку, а подписчик канала альбома событие не получает; `cd backend && go test ./internal/sse/ ./internal/albums/` + +## 2. Создание, метаданные и удаление альбома + +- [x] 2.1 Публиковать `album.created` владельцу при создании альбома; проверить тестом, что владелец получает событие, а объект перечитан и содержит `photos_count` и `members_count` +- [x] 2.2 Публиковать `album.updated` владельцу и участникам при изменении названия, описания и обложки, сохранив существующую публикацию в канал альбома; проверить тестом, что обе подписки получают событие и payload совпадает по полям с ответом `GET /api/albums/:id` +- [x] 2.3 Публиковать `album.deleted` с идентификатором владельцу и участникам, вычисляя аудиторию до удаления альбома (состав удаляется каскадом); проверить тестом, что событие получено владельцем и участником, а после удаления `members.List` для альбома пуст + +## 3. Состав участников + +- [x] 3.1 При принятии приглашения публиковать `album.created` приглашённому и `album.updated` владельцу и остальным участникам; проверить тестом, что приглашённый получает ровно одно событие с объектом альбома, а владелец — обновлённый `members_count` +- [x] 3.2 При смене роли участника публиковать `album.updated` владельцу и участникам; проверить тестом, что событие получает и пользователь, чью роль изменили +- [x] 3.3 При исключении участника публиковать `album.updated` оставшимся и `album.deleted` с идентификатором исключённому; проверить тестом, что исключённый получает ровно одно событие и в нём нет объекта альбома +- [x] 3.4 Обновить документацию контракта в `project.md` (§11 «Real-time (SSE)» и §12 «SSE — детали реализации»): состав событий личного потока, форма payload'а, догон списка альбомов при переподключении; проверить, что перечисленные типы событий совпадают с реализованными публикациями, а описанные поля — с ответом `GET /api/albums` + +## 4. Web-клиент + +- [x] 4.1 Подписаться на `/api/sse/me` один раз на уровне layout'а `(app)`: `album.created` и `album.updated` применять через `albums.upsert`, `album.deleted` — через `albums.remove`, на `onReconnect` вызывать `albums.load()`; проверить `cd frontend && npm run check` и сценарием в двух вкладках: переименование и смена обложки отражаются без перезагрузки, порядок списка не меняется +- [x] 4.2 Обрабатывать потерю доступа на странице альбома: при событии удаления/исключения страница перестаёт отображать альбом и уходит на главную; проверить сценарием: владелец удаляет альбом, у участника с открытой страницей происходит переход на главную, а альбом исчезает из рейла +- [x] 4.3 Проверить жизненный цикл подписки: соединение создаётся один раз за сессию, не пересоздаётся при навигации между страницами и закрывается при выходе из аккаунта; проверить в DevTools (одно соединение `/api/sse/me` на сессию) и сценарием logout/login + +## 5. Интеграционная проверка + +- [x] 5.1 Сквозной сценарий на двух аккаунтах (два профиля браузера) на локальном сервере: создание альбома, приглашение, смена роли, исключение, удаление — список и открытая страница у второго пользователя обновляются без перезагрузки; проверить вручную и приложить результат (какие шаги и что наблюдалось) +- [x] 5.2 Проверить догон состояния: остановить сервер, изменить и удалить альбом, поднять сервер и убедиться, что после восстановления потока список на клиенте совпадает с `GET /api/albums` (удалённый альбом не возвращается); проверить сценарием и приложить вывод +- [x] 5.3 Прогнать на финальной ревизии `make test`, `cd frontend && npm run check` и `cd frontend && npm run build`; зафиксировать, что все три проходят без ошибок + +## 6. Живое обновление состава фото + +- [x] 6.1 В `albums.Service` добавить рассылку `album.updated` получателям личного потока без публикации в канал альбома (перечитать через `GetByID` + `attachCover`); закрыть метод интерфейсом для `photos.Service`; проверить тестом, что после вызова получатель видит событие, а подписчик канала альбома — нет +- [x] 6.2 Публиковать `photo.created` при загрузке фото и `album.updated` получателям при загрузке, копировании, перемещении, восстановлении, переводе в корзину и окончательном удалении; проверить тестом, что `photos_count` меняется у владельца и участника, а подписчик канала альбома получает `photo.created` +- [x] 6.3 Считать `photos_count` только по фото вне корзины: добавить `deleted_at IS NULL` в подзапросы счётчика в sqlite- и postgres-сторах; проверить тестом, что перевод в корзину уменьшает, а восстановление увеличивает счётчик +- [x] 6.4 Обновить `project.md` (§11/§12): состав фото в личном потоке, `photo.created` при загрузке, семантика `photos_count` +- [x] 6.5 Прогнать `make test`, `cd frontend && npm test`, `cd frontend && npm run check` и `cd frontend && npm run build` + +## Workflow follow-up + +- Заархивировать change после прохождения ревью проекта (`openspec archive album-live-sync`). +- Проверить, что после архивации дельта вошла в `openspec/specs/realtime/spec.md`, а `openspec validate --strict album-live-sync` не выдаёт ошибок. diff --git a/openspec/specs/albums/spec.md b/openspec/specs/albums/spec.md index f6488a9..cb115e0 100644 --- a/openspec/specs/albums/spec.md +++ b/openspec/specs/albums/spec.md @@ -82,8 +82,17 @@ ### Requirement: Счётчики альбома Данные альбома SHALL включать количество фото и участников, чтобы панель и карточки могли -показать их без отдельных запросов. +показать их без отдельных запросов. Количество фото SHALL учитывать только фото, доступные в +альбоме (не помещённые в корзину), и совпадать с числом фото, отображаемых в сетке альбома. #### Scenario: Счётчики в ответе - **WHEN** клиент запрашивает список альбомов или альбом по id - **THEN** ответ содержит число фото и число участников альбома + +#### Scenario: Фото в корзине не учитывается +- **WHEN** фото альбома помещается в корзину +- **THEN** `photos_count` этого альбома уменьшается на единицу + +#### Scenario: Фото восстановлено из корзины +- **WHEN** фото восстанавливается из корзины в альбом +- **THEN** `photos_count` этого альбома увеличивается на единицу diff --git a/openspec/specs/realtime/spec.md b/openspec/specs/realtime/spec.md index 760b763..31e3bd2 100644 --- a/openspec/specs/realtime/spec.md +++ b/openspec/specs/realtime/spec.md @@ -28,12 +28,17 @@ comment.created, like.added, like.removed, member.joined, member.left, album.upd - **THEN** подписчики получают событие photo.updated с этим фото ### Requirement: Персональный поток -Клиент SHALL иметь возможность подписаться на персональный поток (приглашения, уведомления). +Клиент SHALL иметь возможность подписаться на персональный поток (приглашения, уведомления, +изменения его альбомов). #### Scenario: Подписка на персональный поток - **WHEN** пользователь подключается к персональному потоку - **THEN** система отправляет ему персональные события +#### Scenario: Изменение списка альбомов +- **WHEN** альбом, доступный пользователю, создан, изменён или удалён +- **THEN** пользователь получает соответствующее событие в персональном потоке + ### Requirement: Авторизация и область доступа Клиент SHALL подписываться только на доступные ему альбомы; токен передаётся в query-параметре. @@ -66,3 +71,97 @@ comment.created, like.added, like.removed, member.joined, member.left, album.upd #### Scenario: Обновление отсутствующего фото - **WHEN** приходит событие обновления для фото, которого нет в текущем списке - **THEN** клиент не добавляет его в сетку + +### Requirement: События жизненного цикла альбома +Система SHALL публиковать в персональные потоки события `album.created` и `album.updated` +с объектом альбома и `album.deleted` с идентификатором альбома. + +#### Scenario: Создание альбома +- **WHEN** пользователь создаёт альбом +- **THEN** владелец получает событие `album.created` с объектом альбома + +#### Scenario: Изменение метаданных альбома +- **WHEN** участник с правом редактирования меняет название, описание или обложку +- **THEN** владелец и участники получают событие `album.updated` с актуальным объектом + +#### Scenario: Удаление альбома +- **WHEN** владелец удаляет альбом +- **THEN** владелец и участники получают событие `album.deleted` с идентификатором + +### Requirement: Получатели событий об альбоме +Система SHALL доставлять события жизненного цикла альбома владельцу и всем участникам +альбома, для которых он доступен. + +#### Scenario: Веер по участникам +- **WHEN** альбом изменён +- **THEN** событие получает владелец и каждый участник альбома, включая инициатора изменения + +#### Scenario: Принятие приглашения +- **WHEN** пользователь принимает приглашение в альбом +- **THEN** приглашённый получает событие о появлении альбома в своём списке, а владелец — + обновлённый объект альбома с новым числом участников + +### Requirement: Единая форма объекта альбома в событиях +Объект альбома в событиях SHALL совпадать по форме с элементом ответа списка альбомов, +включая URL обложки и счётчики, чтобы клиент применял событие без дополнительных запросов. + +#### Scenario: Объект события совпадает со списком +- **WHEN** клиент получает `album.created` или `album.updated` +- **THEN** объект содержит те же поля, что элемент ответа `GET /api/albums` + +### Requirement: Применение события без повторного запроса +Клиент SHALL обновлять локальный список альбомов из payload'а события: создавать или +заменять альбом по полученному объекту и удалять его по идентификатору, не выполняя +дополнительный запрос списка. + +#### Scenario: Обновление альбома из списка +- **WHEN** приходит событие обновления для альбома, который уже есть в списке клиента +- **THEN** клиент заменяет его на месте, не меняя порядок списка + +#### Scenario: Удаление альбома из списка +- **WHEN** приходит событие удаления для альбома, который есть в списке клиента +- **THEN** клиент убирает альбом из списка + +### Requirement: Догон состояния альбомов при переподключении +Клиент SHALL после восстановления персонального потока синхронизировать список альбомов +через REST, чтобы отразить изменения, пропущенные во время разрыва соединения. + +#### Scenario: Переподключение персонального потока +- **WHEN** персональный поток восстанавливается после разрыва +- **THEN** клиент перезапрашивает список альбомов и приводит локальное состояние + к серверному + +### Requirement: Потеря доступа к альбому +Клиент SHALL переставать отображать альбом, который удалён или к которому потерян доступ, +при получении соответствующего персонального события, в том числе на открытой странице +альбома. + +#### Scenario: Альбом удалён во время просмотра +- **WHEN** владелец удаляет альбом, пока участник просматривает его страницу +- **THEN** участник получает событие и страница перестаёт отображать удалённый альбом + +#### Scenario: Участник исключён из альбома +- **WHEN** участника исключают из альбома +- **THEN** исключённый получает событие, и альбом убирается из его списка + +### Requirement: Обновление альбома при изменении состава фото +Система SHALL публиковать `album.updated` с актуальным объектом альбома в персональные потоки +владельца и участников, когда состав фото альбома меняется: загрузка, копирование, перемещение, +восстановление, перевод в корзину и окончательное удаление. При загрузке система также SHALL +публиковать `photo.created` в поток альбома, чтобы открытая страница добавляла новое фото. + +#### Scenario: Загрузка фото +- **WHEN** пользователь загружает фото в альбом +- **THEN** владелец и участники получают `album.updated` с увеличенным `photos_count` + +#### Scenario: Перемещение фото +- **WHEN** фото перемещается из одного альбома в другой +- **THEN** владелец и участники обоих альбомов получают `album.updated` с актуальными счётчиками + +#### Scenario: Фото в корзине +- **WHEN** фото переводится в корзину или восстанавливается +- **THEN** владелец и участники получают `album.updated` с обновлённым `photos_count` + +#### Scenario: Окончательное удаление фото +- **WHEN** фото удаляется окончательно +- **THEN** владелец и участники получают `album.updated` с уменьшенным `photos_count`