# Design ## Context См. `proposal.md` — Why. Ниже — что в коде определяет подход. - `SyncCoordinator.reconcile` получает `serverChecksums(link.serverAlbumId)` и вычисляет `livePhotoIds = serverChecksums.values.toSet()`, но `SyncPlanner.plan` и `offloadUploaded` этот набор не используют: первый смотрит только на `indexed.serverPhotoId != null`, второй — на `entity.serverPhotoId != null`. `reflectLostCopies` умеет лишь перекрасить `PREVIEW ↔ ORPHANED` и тоже по `livePhotoIds`. - Разгрузка вызывается из трёх мест: `SyncCoordinator.offloadUploaded` (режим C), `UploadWorker.recordProvenance` (сразу после успешной загрузки) и `DeviceAlbumViewModel.freeSpace` (ручное освобождение по согласию системы). - `AssetOffloader.offload(asset)` не требует доказательств, что копия жива. - `SyncRepository.link` перезаписывает `serverAlbumId`; смену цели обслуживают `DeviceAlbumViewModel.setTarget` и `createAlbum` — оба просто линкуют и запускают reconcile. - Серверный перенос фото есть: `SyncRemote.movePhotos` → `POST /api/photo-batch/move`, который пропускает удалённые фото и возвращает число перенесённых, не сообщая об ошибке за пропуск. - `LocalAssetState.ORPHANED` уже вычисляется и показывается перечёркнутым облаком на элементе; счётчика и заметного предупреждения нет. ## Goals / Non-Goals **Goals:** - Сделать невозможной разгрузку без подтверждённой копии в текущем целевом альбоме. - Не терять связь с уже загруженными фото при смене целевого альбома. - Сделать потерю серверной копии видимой и оставить её устранение явным действием. **Non-Goals:** - Восстановление уже потерянных копий из корзины сервера. - Предупреждение об удалении серверного альбома из веб-клиента: сервер и веб не знают о зависимости альбома устройства. - Ручные загрузки в серверный альбом и их режимы. ## Decisions **Единственный источник доверия — живой список фото целевого альбома.** И `plan`, и разгрузка опираются на `livePhotoIds`, уже полученный в `reconcile`. Альтернатива — отдельный запрос «фото существует» — отклонена: лишние round-trip и рассинхрон. **Планировщик лечит устаревшую запись, а не игнорирует её.** Ветка «записанный `photoId`» срабатывает только если `photoId ∈ livePhotoIds`; иначе ассет проходит обычный путь: дедуп по контрольной сумме, а при отсутствии — загрузка. Удаление серверных фото в конце плана тоже ограничено `livePhotoIds`, чтобы не тронуть чужую копию. **Разгрузка требует доказательства в подписи.** `AssetOffloader.offload(asset, liveServerPhotoIds)` и новый исход `OffloadResult.NotOnServer`, если копии нет. Так инвариант живёт в одном месте, а вызывающий обязан доказать, что копия жива: - `offloadUploaded` передаёт `livePhotoIds` из `reconcile`; - `recordProvenance` передаёт `setOf(photo.id)` — только что созданное фото; - `freeSpace` сначала читает `serverChecksums` текущего линка и передаёт его значения; при ошибке не разгружает ничего. **Смена цели — транзакция «перенести, проверить, переключить».** `SyncCoordinator.retarget` переносит фото провенанса этого альбома в новый альбом, затем перечитывает новый альбом и убеждается, что все переносимые копии на месте; только тогда переключает связь. Если прежний альбом недоступен или копии не доехали — связь остаётся прежней, результат возвращается как отказ. Альтернатива «переключить и попробовать потом» отклонена: именно она дала инцидент. **Уже потерянные копии не блокируют смену цели, но сообщаются.** Фото, которых нет ни в прежнем альбоме, ни в новом (например, давно осиротевшие), перенести нельзя; они считаются и возвращаются в результате, чтобы пользователь знал, но не мешают переключить альбом. **Потеря копии показывается из `ORPHANED`, а не из проверки существования альбома.** Существование целевого альбома при офлайне недоказуемо, а `ORPHANED` выставляется только после успешного чтения альбома. Один новый запрос `GROUP BY deviceAlbumId` по `state = 'ORPHANED'` даёт счётчик для карточки и баннера; на карточке потеря копии перекрывает прогресс операции. ## Risks / Trade-offs - [Перенос пропускает удалённые фото без ошибки] → проверяем результат чтением нового альбома, а не ответом `move`; ненулевое расхождение — отказ. - [Лишние `serverChecksums` в `freeSpace` и `retarget`] → это редкие явные действия, не цикл; при ошибке сети ничего не разгружаем и не переключаем. - [Планировщик начинает перезаливать содержимое, если сервер не отдал контрольную сумму] → `serverChecksums` падает вместе с `reconcile` (исключение до применения плана), поэтому «пустой живой список из-за ошибки» не доходит до загрузок. - [Счётчик `ORPHANED` в карточке устаревает между проходами] → приемлемо: он обновляется тем же потоком Room, что и всё представление, и означает «на последнем успешном проходе». ## Migration Plan Схема Room не меняется; миграций нет. Откат — обычная сборка без изменения. ## Open Questions - Нужен ли отдельный сценарий «перенести потерянные копии из прежнего альбома» как ручное действие, когда включённый режим уже сменился — отдельным изменением.