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,2 @@
schema: spec-driven
created: 2026-10-15
@@ -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. Проверка после выката: список альбомов на втором устройстве/вкладке обновляется без
перезагрузки при переименовании, смене обложки, добавлении и исключении участника,
загрузке/перемещении/восстановлении/удалении фото и удалении альбома; исключённый видит
исчезновение альбома из списка, а открытая страница добавляет загруженное фото.
@@ -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
проверяется только при подключении, поэтому исключённый участник продолжает получать
события альбома до разрыва соединения.
@@ -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` этого альбома увеличивается на единицу
@@ -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`
@@ -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` не выдаёт ошибок.