chore(openspec): archive completed changes and sync main specs

- fix-large-album-load -> photos, realtime, media-objects
- fix-album-cover -> photos, albums
This commit is contained in:
iu
2026-10-11 14:16:38 +03:00
parent d7a37328f0
commit d99ac77587
17 changed files with 69 additions and 14 deletions
@@ -0,0 +1,98 @@
# Design
## Context
Требование «Обложка по умолчанию» существовало и работало в старом инлайн-пути генерации
вариантов (`processVariants`), который вызывал `SetCoverIfEmpty`. Рефакторинг media-objects
(коммит `96268ee`) перенёс генерацию вариантов в фоновый worker и потерял этот вызов; сейчас
`SetCoverIfEmpty` вызывается только из `CopyBatch`. Дополнительно обложка не снимается при
уходе фото-обложки. См. `proposal.md — Why`.
Опорные точки:
- `internal/photos/service.go`: `Upload`, `Delete`, `MoveBatch`; `SetCoverIfEmpty` — метод
репозитория фото (`store.go`, `repository.go`, `sqlite_store.go`).
- `internal/photos/worker.go`: с недавних пор обрабатывает новые фото первыми
(`created_at DESC`) — отсюда решение не ставить обложку в worker'е.
- `albums.Update` уже поддерживает `cover_photo_id` и `clear_cover`.
- Frontend: `albums.setCover()` уже существует; контекстное меню — `openMenuForPhoto`
в `albums/[id]/+page.svelte`; секция «Действия» — `PhotoPanel.svelte` (props: `photo`, `role`).
## Goals / Non-Goals
**Goals:**
- Обложкой нового альбома становится первое загруженное фото.
- Обложка снимается, когда фото-обложка покидает альбом (удаление/перенос).
- Пункт «Установить как обложку альбома» доступен из сетки и правой панели.
**Non-Goals:**
- Backfill существующих альбомов.
- Автоперевыбор новой обложки после снятия.
- Изменение поведения Viewer и share-страниц.
## Decisions
### 1. Обложка ставится при загрузке, а не в worker'е
**Выбор:** в `Service.Upload`, после успешного сохранения фото, вызвать
`store.SetCoverIfEmpty(ctx, in.AlbumID, p.ID)`.
**Почему:** формулировка «первое загруженное фото». Worker обрабатывает новейшие фото первыми,
поэтому установка «при обработке» дала бы обложку = последнее загруженное. Загрузка идёт
последовательно по файлам запроса, поэтому первое фото детерминированно выигрывает
(`UPDATE ... WHERE cover_photo_id IS NULL`).
**Альтернативы:**
- *В worker'е по готовности вариантов* — thumb уже есть, но порядок обработки ломает «первое
загруженное».
- *В worker'е с выбором MIN(created_at)* — сложнее и не гарантирует готовность thumb
выбранного фото.
**Следствие:** `cover_thumb_url` пуст до генерации thumb; список альбомов подхватит его при
следующем запросе (JOIN по `cover_photo_id`). Реальное время смены обложки в рейле не
обновляется по SSE — вне области.
### 2. Снятие обложки — атомарный метод репозитория
**Выбор:** метод `ClearCoverIfMatches(ctx, albumID, photoID)`:
`UPDATE albums SET cover_photo_id = NULL WHERE id = $1 AND cover_photo_id = $2`.
Вызывается в `Delete` (корзина) и в `MoveBatch` для исходного альбома.
**Почему:** условный UPDATE защищает от гонки (обложку могли сменить) и не требует чтения.
`PermanentDelete` уже покрыт FK `ON DELETE SET NULL`.
**Альтернативы:**
- *Снять безусловно* — риск снести чужой выбор обложки.
- *FK при soft-delete* — FK не срабатывает при мягком удалении.
### 3. `clear_cover` → NULL
**Выбор:** явный сброс обложки оставляет `cover_photo_id = NULL` (пересчёта по умолчанию нет).
Соответствует решению «очищать, без backfill».
**Альтернативы:**
- *Пересчитать «по умолчанию» (первое фото)* — тогда сброс бессмыслен (обложка мгновенно
вернётся), плюс скрытый backfill.
### 4. Frontend: та же защита прав, что и у редактирования альбома
**Выбор:** пункт показывается при `role === 'owner' || role === 'admin'`; действие вызывает
`albums.setCover(albumId, photo.id)` (существующий `PATCH /albums/:id`). Добавляется в
`openMenuForPhoto` и в секцию «Действия» `PhotoPanel`; иконка `wallpaper`.
**Почему:** переиспользуется существующий API и гейт прав; `PhotoPanel` уже получает `role`.
`albumId` берётся из `photo.album_id`/контекста страницы.
## Risks / Trade-offs
- [Пункт меню/панели меняет обложку, но рейл не обновляется мгновенно, если thumb ещё не готов]
→ допустимо; при следующем запросе обложка появится.
- [Гонка параллельных загрузок в пустой альбом] → условный UPDATE выбирает одного победителя;
«первое» при строго параллельных запросах незначительно недетерминировано.
- [Снятие обложки при переносе может неожиданно обнулить обложку] → это и есть желаемое
поведение по спеке; пользователь может назначить заново.
- [Существующие альбомы без обложки остаются пустыми] → осознанно (no backfill).
## Migration Plan
- Миграций нет. Порядок: backend (загрузка/снятие обложки) → frontend (меню/панель).
- Откат — возврат предыдущего бинаря/фронта; данные не деградируют.