chore(openspec): архивировать add-orphaned-preview-handling и синхронизировать спеки

Новое требование в phone-album-sync о потере/возврате серверной копии у
разгруженного превью, требование в android-client о метке потерянного элемента
и действии удаления с устройства.
This commit is contained in:
iu
2026-10-16 14:13:01 +03:00
parent 5cfa045171
commit 98aaa3156a
8 changed files with 406 additions and 0 deletions
@@ -0,0 +1,130 @@
# Design
## Context
См. `proposal.md` — Why. Ограничения, важные для подхода:
- Источник правды о связи «элемент устройства → серверное фото» — локальный индекс
`device_assets` (`serverPhotoId`, `contentHash`) плюс метка провенанса в EXIF
разгруженного превью.
- `SyncCoordinator.reconcile` уже получает живой набор фото целевого альбома
(`serverChecksums` → `livePhotoIds`) на каждый проход; отдельный запрос не нужен.
- `SyncPlanner.plan` сейчас намеренно пропускает записи `PREVIEW` в цикле загрузки,
чтобы превью не уехало как оригинал.
- `LocalAssetState` персистится строкой в Room; `GallerySyncPolicy.decide` — `when`
по этому enum, поэтому новое значение компилятор заставит обработать везде.
- Фото события серверного альбома есть (`AlbumEvent.PhotoDeleted`), но экран
альбома устройства на поток серверного альбома не подписан.
## Goals / Non-Goals
**Goals:**
- Замечать, что у разгруженного превью пропала серверная копия, и отражать это
состоянием и меткой.
- Дать пользователю убрать такой элемент с устройства.
- Восстанавливать связь/заливать заново, когда содержимое возвращается.
**Non-Goals:**
- Не менять поведение зеркального «серверного представления» альбома и других
устройств (см. требование «Отражение серверных изменений на устройстве»).
- Не различать «удалили из альбома» и «переместили в другой альбом сервера»
(см. решение 3).
- Не трогать серверный API, схему БД и серверные миграции.
## Decisions
### 1. Отдельное состояние `ORPHANED`, а не флаг
**Решение:** добавить `LocalAssetState.ORPHANED` — локально превью, серверного фото
в целевом альбоме нет.
**Почему:** состояние уже модель переходов (`LOCAL/UPLOADED/AWAITING_FREE/PREVIEW/
SERVER_ONLY`), а `GallerySyncPolicy.decide` — исчерпывающий `when`, так что новый
случай нельзя забыть. Флаг пришлось бы сверять во всех тех же точках вручную.
**Альтернатива:** булев `serverCopyLost` рядом с `state = PREVIEW` — меньше правок
enum, но плодит комбинации состояния и флага и не защищён компилятором.
### 2. Детекция при проверке синхронизации, а не по SSE
**Решение:** в `reconcile` сверять `index.serverPhotoId` с `livePhotoIds`:
отсутствие → `ORPHANED`, присутствие → снять пометку. Тот же код исполняется при
открытии альбома, старте и фоне (`SyncCheck`), поэтому покрывает оффлайн и
пропущенные события.
**Альтернатива:** подписать экран альбома устройства на поток серверного альбома и
реагировать на `photo.deleted` сразу. Даёт мгновенность, но только при открытом
экране и живом SSE, требует новой связи между потоком и `device_assets`, и всё
равно не снимает необходимость reconcile-проверки. Можно добавить позже поверх.
### 3. Не различать «удалили» и «переместили» (v1)
**Решение:** если фото нет в целевом альбоме — элемент считается потерявшим копию,
даже если на сервере то же содержимое лежит в другом альбоме. При возврате
оригинала заливка выполняется.
**Почему:** `serverChecksums` ограничен целевым альбомом; различение требует
кросс-альбомного поиска по контрольной сумме на сервере. Для v1 простота и
предсказуемость важнее; цена — возможный дубликат, если фото именно переместили, а
оригинал вернулся на устройство.
**Альтернатива:** перед заливкой искать hash по всем альбомам сервера и связывать
вместо загрузки. Дороже и усложняет reconcile; оставлено на будущее.
### 4. Планировщик: `ORPHANED` пропускается как `PREVIEW`
**Решение:** в `SyncPlanner.plan` (цикл загрузки) пропускать и `ORPHANED`, и
`PREVIEW`. `GallerySyncPolicy.decide` для `ORPHANED` возвращает `NONE` во всех
режимах, `RestoreManager.restoreTargets` его не берёт.
**Почему:** превью — не оригинал, его нельзя ни заливать как оригинал, ни
восстанавливать с сервера (там пусто). Иначе получаем ложную загрузку или
бесконечный `RESTORE`.
**Альтернатива:** хранить `ORPHANED` как `PREVIEW` с нулевым `serverPhotoId` —
теряется `originalHash`/`serverPhotoId`, нужные для возврата и обратной пометки.
### 5. Возврат оригинала — по исходной контрольной сумме
**Решение:** повторная заливка срабатывает, когда hash из индекса/метки
(`originalHash`) снова присутствует среди локальных ассетов альбома или в другом
альбоме устройства (`hashesOutside`). Заливка идёт обычным путём (новое серверное
фото), в режиме C элемент снова разгружается.
**Почему:** hash уже хранится и переживает переустановку (метка EXIF), а
`hashesOutside` уже считает содержимое в других альбомах.
**Альтернатива:** реагировать только на новый файл в том же альбоме — не поймает
копию, лежащую в другом альбоме устройства.
### 6. Удаление с устройства — через системное согласие
**Решение:** действие «Удалить с устройства» удаляет превью-файл и запись индекса,
переиспользуя существующий путь delete-consent (`SyncRepository.deleteConsent`,
`MediaStore.createDeleteRequest`), к серверу не обращается.
**Почему:** превью записано поверх элемента галереи, которым приложение не владеет,
поэтому тихое удаление невозможно; это тот же механизм, что и у удаления альбома.
## Risks / Trade-offs
- [Дубликат при перемещении фото на сервере] → принято в v1 (решение 3); при
необходимости добавляется кросс-альбомная проверка hash перед заливкой.
- [Возврат оригинала не ловится, если hash не посчитался] → `contentHash` может быть
null; тогда пометка и возврат опираются на провенанс-метку, а элемент остаётся
`ORPHANED` до ручного удаления.
- [Ошибка детекции при частичной загрузке списка сервера] → `serverChecksums`
следует за пагинацией; если запрос неполный, возможна ложная пометка. Митигация:
пометка снимается при следующем успешном проходе, файл не удаляется автоматически.
- [Забытые `when` по enum] → компилятор ловит исчерпывающие `when`; остальные
сравнения со строкой состояния — ручные, их покрывают тесты.
## Migration Plan
Схема БД и серверный API не меняются. Значение `ORPHANED` появляется только для
целевого набора условий; ранее помеченных как `PREVIEW` элементов это не касается,
пока серверное фото живо. Откат — удаление значения из enum и обработчиков; данные
в Room (строки состояний) не мигрируют. Существующие разгруженные превью без
серверной копии получат `ORPHANED` при первом же проходе синхронизации.