diff --git a/openspec/changes/fix-album-sync-churn-and-degradation/.openspec.yaml b/openspec/changes/fix-album-sync-churn-and-degradation/.openspec.yaml new file mode 100644 index 0000000..0cb4765 --- /dev/null +++ b/openspec/changes/fix-album-sync-churn-and-degradation/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-16 diff --git a/openspec/changes/fix-album-sync-churn-and-degradation/design.md b/openspec/changes/fix-album-sync-churn-and-degradation/design.md new file mode 100644 index 0000000..2b5c612 --- /dev/null +++ b/openspec/changes/fix-album-sync-churn-and-degradation/design.md @@ -0,0 +1,171 @@ +# Design + +## Context + +Мотивацию и инцидент см. в `proposal.md`; требования — в дельте `specs/phone-album-sync/`. +Здесь только текущее состояние, задающее подход. + +- Движок синхронизации (`android/.../data/sync/*`) считает диффом «MediaStore сейчас ↔ индекс + Room». Индекс: `SyncedAlbumEntity` (связь и режим), `DeviceAssetEntity` (`assetKey`, + `contentHash`, `serverPhotoId`, `state`, `origin`), `deleted_assets` (журнал удалений), + `upload_jobs` (очередь загрузок с `deviceAlbumId`, `assetKey`, `contentHash`, `syncMode`). +- `SyncPlanner.plan` — чистая функция: сравнивает `LocalAsset.contentHash` с + `IndexedAsset.contentHash` и отдаёт `Upload`, `Link`, `DeleteOnServer`, `RenameOnServer`. + Роль файла она различает только по `state == PREVIEW`; при расхождении состояния с диском + смена роли читается как «содержимое изменилось». +- `OffloadManager` пишет в превью метку провенанса (`ProvenanceStamp`: + `album`, `photo`, `hash`, `device`, `mode`, `asset`, `origin`), где `hash` — контрольная + сумма оригинала на момент разгрузки. +- Загрузка асинхронная (`UploadRepository`/`UploadWorker` на WorkManager); в задание + записывается `contentHash`, посчитанный на момент планирования, а байты читаются по URI уже + во время загрузки. `UploadWorker.recordProvenance` в режиме переноса разгружает оригинал + сразу после успешной загрузки — вне мьютекса `SyncCoordinator`. +- `LocalAssetProvider.hashCached` переиспользует посчитанный sha256, пока не изменилась пара + `(size, dateModified)` **из MediaStore**, которая после записи файла через дескриптор + остаётся старой (на инциденте: `size=169637` в приложении против `2 184 212` на диске). +- Room: версия 8, миграции объявлены явно (`MIGRATION_6_7`, `MIGRATION_7_8`), + `fallbackToDestructiveMigration` включён. +- Сервер менять не нужно: `DELETE /api/photos/{id}` (мягко, в корзину), + `POST /api/photos/{id}/restore` и `original_checksum` в ответе загрузки уже есть. + +## Goals / Non-Goals + +**Goals:** + +- Инвариант «контрольная сумма = идентичность оригинала, состояние = роль локального файла»: + разгрузка и возврат не меняют содержимое и не запускают загрузку или удаление. +- Замена версии безопасна: прежняя серверная копия исчезает только после подтверждения новой. +- Расхождение состояния в индексе самоисправляется, а не превращается в разрушительное + действие. +- Разово вернуть два пострадавших оригинала и убрать деградировавшие копии. + +**Non-Goals:** + +- Серверная модель версий и идентичности содержимого; эндпоинт «заменить содержимое фото». +- Чистка кэша альбома на телефоне (пустые плитки удалённых фото — отдельная задача). +- Доверие к метаданным MediaStore при кэшировании хешей: остаётся как есть, риск закрывается + сверкой контрольной суммы (см. решение 4). +- Семантика зеркалирования чужих серверных фото и права на удаление. + +## Decisions + +### 1. Роль и содержимое различаются по метке превью, а не по состоянию индекса + +Идентичность элемента — `IndexedAsset.contentHash` (контрольная сумма оригинала либо +последней подтверждённой новой версии). Роль локального файла определяется меткой провенанса: +если у файла есть метка, её `hash` совпадает с идентичностью элемента, а фактический хеш файла +от неё отличается — это созданное приложением превью, а не новая версия. + +Альтернативы: доверять только `state` индекса — отклонено, потому что расхождение состояния и +есть причина инцидента (в `device_assets` элементы с превью на диске стояли как `LOCAL`); +сравнивать разрешение с серверной копией — отклонено, потому что законное уменьшение +(кроп/экспорт) тоже меньше по размеру, а серверная копия может быть уже деградировавшей. + +Метка читается только тогда, когда хеш содержимого пересчитывался (промах кэша), то есть +примерно один раз на изменение файла. + +Метка указывает на элемент, чьим превью является файл, — для фото это сам элемент, для +видео это исходное видео, уступившее место постеру под новым `MediaStore`-id. Поэтому +распознавание опирается на `asset` из метки, а не на совпадение ключей: постер +разгруженного видео связывается с видео, а «исчезнувший» с устройства элемент, который всё +ещё представлен постером, не считается удалённым и не удаляет серверную копию. Скачанные +(зеркальные) файлы метку-ключ не несут и по этому пути не обрабатываются. + +### 2. Планировщик возвращает действие «выправить роль», а не молчит + +Превью, принятое за оригинал, не просто игнорируется: планировщик отдаёт +`SyncAction.MarkPreview(assetKey)`, координатор ставит элементу состояние `PREVIEW`. Это +чинит и `GallerySyncPolicy` (иначе он в режиме переноса снова разгружает элемент как +`LOCAL`), и `reflectLostCopies` (переход `PREVIEW ↔ ORPHANED`). Альтернатива — чинить роль +только в координаторе до планирования — отклонена: тогда знание о метке пришлось бы +дублировать в двух местах. + +### 3. Незавершённая загрузка блокирует разгрузку + +`SyncCoordinator.offloadUploaded` пропускает элементы, для которых очередь загрузок знает +незавершённое задание (`SyncUploadQueue.isPending`). Так файл не может быть переписан +превью в момент, когда задание ещё читает байты по URI. Альтернатива — синхронный offload +внутри воркера после загрузки — оставлена, но только для уже подтверждённого фото этого +элемента, когда незавершённых заданий по нему нет. + +Побочный эффект: разгрузка в режиме переноса завершается после того, как очередь опустеет, +то есть чуть позже, чем сегодня (требование «автоматическая разгрузка после появления копии +на сервере» сохраняется). + +### 4. Замена версии: удаление прежней копии — только после подтверждения + +При изменении содержимого планировщик отдаёт `SyncAction.ReplaceUpload(asset, stalePhotoId)` +вместо пары «`Upload` + `DeleteOnServer`»: + +- координатор пишет в журнал удалений запись для `stalePhotoId` (надгробие блокирует + повторную материализацию прежней копии, пока идёт замена) и ставит задание загрузки с + новым полем `replacesPhotoId`; +- **`UploadWorker`** после загрузки сверяет `original_checksum` ответа с хешем задания; при + совпадении удаляет прежнюю копию (мягко, в корзину), записывает провенанс и разгружает + оригинал в режиме переноса; +- при ошибке или расхождении контрольной суммы прежняя копия остаётся, провенанс не + записывается, замена повторится в следующем проходе (надгробие сохраняется, задание + остаётся с ошибкой). + +Сверка различает три исхода, и это принципиально: **совпало** — можно удалять прежнюю копию; +**разошлось** — загрузку нельзя принимать за новую версию (принятую копию отбрасываем мягко, +элемент не считается загруженным, прежняя копия остаётся); **не сообщено** — загрузка +остаётся состоявшейся, но прежняя копия не удаляется. Третий исход понадобился сразу: ответ +на загрузку не отдавал `original_checksum` (сервер писал контрольную сумму только в объект, +хотя требование способности `photos` «Контрольная сумма оригинала» распространяется на данные +фото вообще), и первая версия сверки, считавшая отсутствие контрольной суммы расхождением, +отклоняла все синхронные загрузки. Дефект сервера исправлен: `Service.Upload` заполняет +`p.OriginalChecksum` до ответа и публикации события; клиент при этом остаётся устойчивым к +инстансу, который контрольную сумму не сообщает. + +Схема: `upload_jobs.replacesPhotoId TEXT` (Room 8 → 9). Задание — естественный носитель +одноразовой замены: альтернатива с колонкой в `device_assets` требует явной очистки после +подтверждения, а привязка надгробий к ассетам всё равно требует миграции. Немедленное +удаление как сегодня отклонено: неудачная загрузка оставляет альбом без живой копии. +Явное удаление элемента на устройстве (`SyncAction.DeleteOnServer`) сохраняет прежнее +поведение — файла уже нет, и намерение выражено пользователем. + +### 5. Возврат оригинала оставляет состояние «оригинал + серверная копия» + +`RestoreManager` после записи оригинала помечает элемент `UPLOADED`, а не `LOCAL`. +`LOCAL` остаётся значением для «оригинал есть, на сервер не загружался». Вместе с решением 2 +это убирает повторную разгрузку вернувшихся оригиналов и петлю «вернул → выгрузил → залил». + +### 6. Разовое восстановление двух пострадавших фото + +Операция, а не постоянное поведение: для `IMG_20261004_163151.jpg` восстановить из корзины +оригинал `6d4e1897` в альбом `N2` и удалить деградировавшую копию `b0b29905`; для +`IMG_20261009_153136.jpg` — восстановить `991ea4ee` и удалить `1f49e8fb`. Нужны права +владельца инстанса; если креды недоступны, пользователю выдаются готовые команды. Синхронизация +альбома на телефоне к этому времени уже выключена, поэтому вмешательства в индекс не требуется. + +## Risks / Trade-offs + +- **[У файла нет метки провенанса]** (превью записано до появления метки или переписано + сторонним редактором) → роль берётся из состояния индекса, как раньше; деградацию + ограничивает решение 4: прежняя копия не удаляется, пока новая не подтверждена, поэтому + потеря оригиналов невозможна, а лишняя копия заметна и обратима через корзину. +- **[Сервер не сообщает контрольную сумму]** → загрузка остаётся состоявшейся, но прежняя + копия не удаляется и замена повторяется; так инстанс, отставший от спеки, не может + привести к отклонению всех синхронных загрузок (этот исход уже случался на живой проверке). +- **[Хеш посчитан в момент перезаписи файла]** (гонка с собственной записью) → возможна + загрузка с хешем, не совпадающим с байтами; сверка контрольной суммы (решение 4) отменяет + такую замену и не удаляет прежнюю копию. +- **[Правка собственного превью пользователем]** → элемент считается превью и правка не + уедет на сервер. Осознанный компромисс: файл несёт метку разгруженного превью, а + требование «метка утрачена» уже говорит не удалять и не загружать неопознанное повторно. +- **[Разгрузка откладывается до опустошения очереди]** → режим переноса освобождает место + позже. Освобождение остаётся автоматическим, но перестаёт конкурировать с загрузкой. +- **[Миграция Room 8 → 9]** → одна `ALTER TABLE` с nullable-колонкой; откат безопасен + (колонка просто игнорируется), `fallbackToDestructiveMigration` остаётся страховкой. +- **[Ручная операция по данным инстанса]** → восстановление из корзины обратимо, удаление + выполняется мягко; до и после сверяются размеры и контрольные суммы. + +## Migration Plan + +1. Миграция `MIGRATION_8_9` добавляет `upload_jobs.replacesPhotoId`; старые задания читаются + как «без замены». +2. Сборка и установка APK поверх существующего приложения: индекс и очередь сохраняются. +3. После обновления первый проход сам исправляет состояния элементов (решение 2) и + прекращает цикл на конкретном инстансе. +4. Разовое восстановление двух фото (решение 6) выполняется отдельно и не зависит от выкатки. diff --git a/openspec/changes/fix-album-sync-churn-and-degradation/proposal.md b/openspec/changes/fix-album-sync-churn-and-degradation/proposal.md new file mode 100644 index 0000000..d3c4c04 --- /dev/null +++ b/openspec/changes/fix-album-sync-churn-and-degradation/proposal.md @@ -0,0 +1,70 @@ +# Proposal + +## Why + +Режим переноса (C) может залить на сервер **превью вместо оригинала** и тут же удалить +устаревшую серверную копию, то есть молча уничтожить оригинал. Причина — планировщик не +отличает содержимое от роли локального файла: когда состояние элемента в локальном индексе +расходится с фактической ролью (в индексе «оригинал», на диске превью), смена роли читается +как «содержимое изменилось» и запускается цикл «удалил → залил → удалил». + +Реальный инцидент: альбом устройства U2, режим переноса, серверный альбом `N2`. Из 20 фото +пять перезаливались по кругу (20:03 → 20:08 → 20:42 → 21:24), а два потеряли оригиналы — +`IMG_20261004_163151.jpg` (3 437 694 Б, 3072x4096 → 259 282 Б, 1200x1600) и +`IMG_20261009_153136.jpg` (2 226 043 Б → 192 651 Б). Лечиться это будет только сейчас: +у первого уцелевший оригинал лежит в корзине сервера, у второго — в корзине и в альбоме +«Первый». Побочно тот же цикл оставляет на телефоне элементы, которых на сервере уже нет: +в приложении альбом `N2` показывает 26 плиток вместо 20, шесть из них пустые. + +## What Changes + +- **Роль не является содержимым.** Приложение SHALL отличать созданное им превью (в том числе + по метке провенанса) от нового оригинала: превью никогда не загружается как новая версия и + не влечёт удаления серверной копии, а расхождение состояния в индексе выправляется. + Настоящее изменение содержимого, как и раньше, синхронизируется новой версией. +- **Удаление только после подтверждения.** Устаревшая собственная загрузка удаляется только + после того, как новая версия подтверждена сервером: сообщённая им контрольная сумма + принятого объекта совпала с отправленной. При расхождении загрузка отклоняется вместе с + принятой копией, прежняя серверная копия остаётся в альбоме, а замена повторяется при + следующей синхронизации. Если сервер контрольную сумму не сообщил (например, старая версия + инстанса), загрузка остаётся состоявшейся, но прежняя копия не удаляется. +- **Контрольная сумма в ответе на загрузку.** Сервер SHALL отдавать контрольную сумму + оригинала в данных созданного фото — как требует существующее требование способности + `photos`; сегодня ответ на загрузку её не содержит (дефект сервера, а не изменение API). +- **Разгрузка не гоняется с загрузкой.** Оригинал не заменяется превью, пока по этому + элементу есть незавершённая загрузка. +- **Возврат оригинала устойчив.** После возврата оригиналов на устройство элемент не + разгружается превью повторно, пока альбом снова не переведён в режим переноса. +- **Разовое восстановление двух пострадавших фото** из корзины сервера (и альбома «Первый» + для второго) с удалением деградировавших копий. +- **BREAKING**: нет. Успешные сценарии (загрузка нового, разгрузка, повторная синхронизация, + настоящее изменение содержимого) сохраняют поведение; меняются только защитные условия. + +## Capabilities + +### New Capabilities + +_Нет — поведение относится к существующей способности синхронизации альбома устройства._ + +### Modified Capabilities + +- `phone-album-sync`: различение роли и содержимого (метка превью против нового оригинала), + разгрузка только без незавершённой загрузки, удаление устаревшей загрузки только после + подтверждения новой версии, устойчивый возврат оригиналов. + +## Impact + +- **Код (Android):** `data/sync/SyncPlan` (различение роли и содержимого, отложенная замена), + `data/sync/SyncCoordinator` (выправление состояния, разгрузка без незавершённых загрузок, + постановка замены), `data/sync/LocalAssetProvider` (метка превью рядом с хешем), + `data/upload/UploadWorker` (сверка принятой контрольной суммы, удаление устаревшей копии + после подтверждения, без разгрузки вне координатора), `data/sync/RestoreManager` (состояние + после возврата оригинала), `data/db` (поле «заменяемое фото» в задании загрузки). +- **Схема Room:** версия 8 → 9, `ALTER TABLE upload_jobs ADD COLUMN replacesPhotoId TEXT`. +- **API и бэкенд:** не затрагиваются — используются существующие `DELETE /api/photos/{id}` + (мягко, в корзину), `POST /api/photos/{id}/restore` и `original_checksum` в ответе загрузки. +- **Данные инстанса:** разовое восстановление двух оригиналов и удаление деградировавших + копий (операция, а не постоянное поведение). +- **Не затрагивается:** серверная модель версий/идентичности, дедупликация вложения, чистка + кэша альбома на телефоне (пустые плитки удалённых фото — отдельная задача), доверие к + метаданным MediaStore при кэшировании хешей (сверка контрольной суммы закрывает риск). diff --git a/openspec/changes/fix-album-sync-churn-and-degradation/specs/phone-album-sync/spec.md b/openspec/changes/fix-album-sync-churn-and-degradation/specs/phone-album-sync/spec.md new file mode 100644 index 0000000..49a3a7d --- /dev/null +++ b/openspec/changes/fix-album-sync-churn-and-degradation/specs/phone-album-sync/spec.md @@ -0,0 +1,173 @@ +# Spec Delta + +## ADDED Requirements + +### Requirement: Роль локального файла не является содержимым + +Приложение SHALL отличать превью, созданное им самим при разгрузке (в том числе по метке +провенанса), от новой версии оригинала: разгруженный элемент SHALL NOT загружаться повторно +и SHALL NOT вызывать удаление серверной копии, а расхождение состояния в локальном индексе +SHALL выправляться. Это SHALL относиться и к постеру, которым в режиме переноса заменяется +видео. + +#### Scenario: Разгруженный элемент не создаёт новую версию + +- **WHEN** серверная копия элемента есть в целевом альбоме, а на устройстве лежит созданное приложением превью, в том числе если состояние локального индекса ошибочно указывает оригинал +- **THEN** приложение не ставит загрузку и не удаляет серверную копию + +#### Scenario: Состояние выправляется + +- **WHEN** файл на устройстве является созданным приложением превью, а индекс считает его оригиналом +- **THEN** приложение помечает элемент как разгруженный и последующие проходы не создают загрузок + +#### Scenario: Постер разгруженного видео + +- **WHEN** видео разгружено (заменено постером под новым элементом MediaStore), а его метка указывает на исходное видео +- **THEN** приложение связывает элемент с серверным фото видео и не удаляет это фото на сервере, а постер не загружается как новое фото + +#### Scenario: Настоящее изменение содержимого + +- **WHEN** содержимое медиа заменено содержимым, не созданным приложением при разгрузке +- **THEN** приложение загружает новую версию элемента + +### Requirement: Подтверждение новой версии до удаления устаревшей + +Устаревшая собственная загрузка элемента SHALL удаляться только после подтверждения новой +версии: сообщённая сервером контрольная сумма принятого объекта SHALL совпадать с +отправленной. При расхождении приложение SHALL отклонить загрузку, удалив принятую копию, и +SHALL NOT считать элемент загруженным. Если сервер контрольную сумму не сообщил, загрузка +SHALL считаться состоявшейся, но устаревшая копия SHALL NOT удаляться, а замена SHALL +повториться при следующей синхронизации. + +#### Scenario: Новая версия подтверждена + +- **WHEN** новая версия загружена, а сообщённая сервером контрольная сумма совпала с отправленной +- **THEN** приложение удаляет устаревшую собственную загрузку (мягко, в корзину) + +#### Scenario: Контрольная сумма не совпала + +- **WHEN** сервер сообщил для принятого объекта контрольную сумму, отличную от отправленной +- **THEN** приложение удаляет принятую копию, не считает элемент загруженным и оставляет прежнюю серверную копию в альбоме + +#### Scenario: Контрольная сумма не сообщена + +- **WHEN** сервер не сообщил контрольную сумму принятого объекта +- **THEN** загрузка считается состоявшейся, прежняя серверная копия остаётся в альбоме, а замена повторяется при следующей синхронизации + +#### Scenario: Загрузка не удалась + +- **WHEN** загрузка новой версии завершилась ошибкой +- **THEN** прежняя серверная копия остаётся в альбоме + +### Requirement: Разгрузка без незавершённой загрузки + +Приложение SHALL NOT заменять оригинал превью, пока для этого элемента есть незавершённая +загрузка (в очереди или выполняющаяся), чтобы загрузка не отправила превью вместо оригинала. + +#### Scenario: Загрузка в очереди + +- **WHEN** для элемента есть задание загрузки в очереди или выполняющееся +- **THEN** оригинал на устройстве не заменяется превью + +#### Scenario: Загрузка подтверждена + +- **WHEN** загрузка элемента подтверждена сервером +- **THEN** приложение выполняет обычную разгрузку в режиме переноса + +## MODIFIED Requirements + +### Requirement: Синхронизация изменений с сервером + +Для каждого отслеженного изменения приложение SHALL применить его к целевому +серверному альбому: загрузить новое содержимое, переименовать и перенести через +API. Изменение содержимого SHALL трактоваться как новая загрузка, а устаревшая +собственная загрузка SHALL удаляться после подтверждения новой версии и SHALL NOT +удаляться из-за смены локальной роли файла (оригинал ↔ превью). + +#### Scenario: Новое содержимое + +- **WHEN** в альбоме устройства появилось медиа, которого нет на сервере +- **THEN** приложение загружает его в целевой серверный альбом + +#### Scenario: Переименование доходит до сервера + +- **WHEN** медиа переименовано на устройстве и уже синхронизировано +- **THEN** приложение переименовывает соответствующее серверное фото + +#### Scenario: Изменение содержимого + +- **WHEN** содержимое медиа изменено на устройстве +- **THEN** приложение загружает новую версию и после её подтверждения удаляет устаревшую собственную загрузку + +### Requirement: Провенанс разгруженных превью + +Разгруженные превью, постеры видео и скачанные с сервера элементы SHALL содержать +метаданные провенанса, включая признак происхождения (загружено клиентом или скачано с +сервера), достаточные для восстановления связи с серверным альбомом и принадлежности +загрузок после переустановки приложения. Метка SHALL также служить признаком того, что +локальный файл является созданным приложением превью: элемент с такой меткой SHALL NOT +считаться содержащим оригинал, даже если состояние локального индекса утверждает обратное. + +#### Scenario: Восстановление после переустановки + +- **WHEN** приложение переустановлено или его данные очищены, а альбом устройства содержит разгруженные превью +- **THEN** приложение восстанавливает связь с серверным альбомом и принадлежность своих загрузок из метаданных превью + +#### Scenario: Метка скачанного элемента + +- **WHEN** метка провенанса восстанавливается у элемента, скачанного с сервера +- **THEN** элемент восстанавливается как скачанный, а не как собственная загрузка клиента + +#### Scenario: Метка утрачена + +- **WHEN** метаданные провенанса недоступны (например, файл перезаписан сторонним редактором) +- **THEN** элемент считается неопознанным, не удаляется и не загружается повторно + +#### Scenario: Метка против состояния индекса + +- **WHEN** файл несёт метку созданного приложением превью, а состояние индекса указывает оригинал +- **THEN** элемент считается разгруженным, а не новым оригиналом + +### Requirement: Возврат оригиналов и отвязка + +При выходе из режима C приложение SHALL предложить вернуть оригиналы на устройство +и SHALL предупредить, если пользователь отказывается. Отвязка альбома и выключение +синхронизации SHALL NOT удалять фото с сервера, а скачанные с сервера файлы SHALL +оставаться в галерее как обычные. Выключение синхронизации SHALL быть явным и +устойчивым: восстановление происхождения SHALL NOT возобновлять связь, пока +пользователь не включит синхронизацию снова. + +#### Scenario: Возврат оригиналов + +- **WHEN** пользователь выходит из режима C и соглашается вернуть оригиналы +- **THEN** приложение возвращает оригиналы на устройство вместо превью + +#### Scenario: Отказ от возврата + +- **WHEN** пользователь выходит из режима C и отказывается возвращать оригиналы +- **THEN** приложение предупреждает, что на устройстве останутся только превью + +#### Scenario: Отвязка + +- **WHEN** пользователь отвязывает альбом устройства от серверного +- **THEN** фото на сервере сохраняются, а скачанные файлы остаются в галерее как обычные + +#### Scenario: Выключение синхронизации + +- **WHEN** пользователь выключает синхронизацию альбома устройства +- **THEN** связь с серверным альбомом снимается, а фото на сервере сохраняются + +#### Scenario: Выключение не отменяется восстановлением + +- **WHEN** пользователь выключил синхронизацию (или отвязал альбом), и затем выполняется проверка с восстановлением происхождения по меткам превью +- **THEN** связь не восстанавливается, а альбом остаётся локальным с выбранным режимом выключено + +#### Scenario: Повторное включение + +- **WHEN** пользователь снова включает синхронизацию после выключения +- **THEN** связь создаётся заново, а восстановление происхождения снова действует по обычным правилам + +#### Scenario: Возврат устойчив + +- **WHEN** оригиналы возвращены на устройство после выхода из режима переноса +- **THEN** приложение не разгружает их повторно, пока альбом не переведён в режим переноса снова diff --git a/openspec/changes/fix-album-sync-churn-and-degradation/tasks.md b/openspec/changes/fix-album-sync-churn-and-degradation/tasks.md new file mode 100644 index 0000000..cab385e --- /dev/null +++ b/openspec/changes/fix-album-sync-churn-and-degradation/tasks.md @@ -0,0 +1,34 @@ +# Tasks + +## 1. Роль локального файла вместо состояния индекса + +- [x] 1.1 Добавить в `LocalAsset` (`data/sync/SyncPlan.kt`) признаки метки превью (`stampedOriginalHash`, `stampedAssetKey`), а в `DeviceLocalAssetProvider` читать `ProvenanceStamp` в момент пересчёта хеша файла; решение «локальный файл — созданное приложением превью» вынести в чистую функцию с юнит-тестом (`cd android && ./gradlew :app:testDebugUnitTest --tests "*ProvenanceStampTest" --tests "*SyncPlannerTest"`). +- [x] 1.2 В `SyncPlanner.plan` не загружать превью и не удалять из-за него серверную копию: использовать метку как признак роли и идентичность оригинала, отдавать новое действие `SyncAction.MarkPreview`; покрыть `SyncPlannerTest` случаями «состояние индекса `LOCAL`, файл — превью с меткой» (ожидается `MarkPreview`), «другое содержимое без метки» (ожидается загрузка) и «превью потерявшего копию элемента» (`cd android && ./gradlew :app:testDebugUnitTest --tests "*SyncPlannerTest"`). +- [x] 1.3 В `SyncCoordinator` применять `MarkPreview` (состояние `PREVIEW`) и не разгружать элементы с незавершённой загрузкой (`SyncUploadQueue.isPending`) в `offloadUploaded`; покрыть `SyncCoordinatorTest` случаями «расхождение состояния выправляется» и «загрузка в очереди — разгрузки нет» (`cd android && ./gradlew :app:testDebugUnitTest --tests "*SyncCoordinatorTest"`). +- [x] 1.4 Распознавать постер разгруженного видео как превью исходного элемента по метке (`asset` указывает на видео, а не на постер) и не удалять серверную копию видео, уступившего место постеру; покрыть `SyncPlannerTest` случаями «постер связывается с видео, не загружается и не удаляется» и «видео без постера удаляется как раньше» (`cd android && ./gradlew :app:testDebugUnitTest --tests "*SyncPlannerTest"`). + +## 2. Замена версии с подтверждением + +- [x] 2.1 Добавить `replacesPhotoId` в `UploadJobEntity`, миграцию `MIGRATION_8_9` (версия 9) и ветку в `MigrationTest`; проверить `cd android && ./gradlew :app:testDebugUnitTest --tests "*MigrationTest" --tests "*UploadRepositoryTest"`. +- [x] 2.2 В `SyncPlanner` заменить пару «`Upload` + `DeleteOnServer`» для изменённого содержимого на `SyncAction.ReplaceUpload(asset, stalePhotoId)`, а в `SyncCoordinator` писать надгробие для прежней копии и ставить задание с полем замены; покрыть `SyncPlannerTest` (ожидается `ReplaceUpload`) и `SyncCoordinatorTest` (надгробие и задание с заменой) (`cd android && ./gradlew :app:testDebugUnitTest --tests "*SyncPlannerTest" --tests "*SyncCoordinatorTest"`). +- [x] 2.3 Вынести сверку принятой контрольной суммы в чистую функцию и подключить её в `UploadWorker`: совпадение `original_checksum` с хешем задания удаляет прежнюю копию и записывает провенанс, расхождение или ошибка — не удаляет, не разгружает и оставляет задание с ошибкой; покрыть `SyncUploadTest` (`cd android && ./gradlew :app:testDebugUnitTest --tests "*SyncUploadTest"`). +- [x] 2.4 Сервер: заполнять `p.OriginalChecksum` в `Service.Upload` до ответа и публикации события (существующее требование `photos` «Контрольная сумма оригинала» распространяется на данные созданного фото, но ответ на загрузку её не отдавал — живая проверка показала, что из-за этого отклонялись все синхронные загрузки); покрыть тестом ответа на загрузку (`cd backend && go test ./internal/app/ -run TestUploadResponseExposesOriginalChecksum -v`). +- [x] 2.5 Клиент остаётся устойчивым к инстансу без контрольной суммы: «не сообщено» SHALL NOT считаться расхождением — загрузка принимается, а прежняя копия не удаляется; покрыть `SyncUploadTest` случаями «не сообщено» и «нет заявки на содержимое» (`cd android && ./gradlew :app:testDebugUnitTest --tests "*SyncUploadTest"`). + +## 3. Состояние после возврата оригинала + +- [x] 3.1 В `RestoreManager` помечать восстановленный элемент `UPLOADED` (оригинал и серверная копия) вместо `LOCAL`; покрыть `RestoreManagerTest` и `GallerySyncPolicyTest` случаем «после возврата повторной разгрузки нет» (`cd android && ./gradlew :app:testDebugUnitTest --tests "*RestoreManagerTest" --tests "*GallerySyncPolicyTest"`). + +## 5. Интеграционная проверка + +- [x] 5.1 Прогнать весь Android-набор и собрать debug-APK (`cd android && ./gradlew test :app:assembleDebug`). + +## Workflow follow-up + +- Восстановление пострадавших оригиналов (бывшая задача 4) — ручная процедура владельца инстанса, выполняется вне change, креды в репозиторий и артефакты не попадают: + - вернуть из корзины `6d4e1897-3a7e-4cba-9d85-5bbb4b06f5a4` (IMG_20261004_163151.jpg, 3 437 694 Б, 3072x4096) и `991ea4ee-debb-4ecb-bc26-e4d1a2140085` (IMG_20261009_153136.jpg, 2 226 043 Б, 3072x4096) в альбом `N2` (`POST /api/photos/{id}/restore`); + - удалить деградировавшие копии `b0b29905-712b-4143-9d8c-0b74aa567ce5` и `1f49e8fb-ea9c-4764-8daa-7b185be107e9` (`DELETE /api/photos/{id}`); + - проверить, что в альбоме осталось по одной записи с каждым именем и оригинал у обеих 3072x4096. +- Уборка после регрессии первой версии сверки контрольной суммы (живая проверка 2026-10-16): в новом альбоме `U2` (`e7ae31c9-805c-42f5-8fd2-ea2304f0c485`) лежат 19 мягко удалённых копий десяти файлов, которые приложение само же и отклонило. Их список (id и имена) — в отчёте; удаляются точечно `DELETE /api/photos/{id}/permanent`. Корзину целиком очищать нельзя, пока не восстановлены оригиналы `N2`: они лежат там же. +- Заархивировать изменение после прохождения ревью проекта. +- Проверить результат архивации.