chore(openspec): архивировать fix-copy-edit-variant-sharing и синхронизировать спеки

Дельта media-objects перенесена в основную спеку: копирование больше не
переносит отредактированную версию, копия показывается в исходном виде и
не наследует параметры правок. Change перемещён в архив за 2026-10-21.
This commit is contained in:
iu
2026-10-21 21:33:43 +03:00
parent 723b19bbe5
commit c886c6f22c
6 changed files with 5 additions and 3 deletions
@@ -0,0 +1,120 @@
# Design
## Context
См. `proposal.md` — Why. Ограничения, важные для подхода:
- `Store.Clone` (Postgres) и `SQLiteStore.Clone` (SQLite) копируют привязки
`photo_objects` одной вставкой `INSERT ... SELECT` без фильтра по ролям, поэтому
копия наследует и обычные варианты, и edit-варианты.
- Обычные варианты действительно должны разделяться: воркер создаёт их через
`createObject(ctx, siblings, ...)` , где `siblings` — все фото, делящие оригинал
(`PhotosByOriginalObject`). Edit-варианты, наоборот, создаются с владельцем
`[]string{photoID}` и привязаны только к одному фото.
- Параметры правок лежат в колонке `photos.edit_params` и при клонировании не
переносятся: `INSERT INTO photos (...)` в `Clone` её не включает. То есть рассинхрон
возникает именно на уровне привязок объектов.
- `ResetEdit` удаляет и параметры, и привязки edit-объектов
(`DeleteObjectBindings`). Значит, у фото с пустыми `edit_params` edit-привязок быть
не должно — кроме как последствия копирования.
- Выдача URL смотрит только на привязки (`attachURLs`), а фронтенд выбирает
`edit_thumb_url ?? thumb_url`. Привязка — источник правды о том, какие варианты
есть у фото; чинить выдачу URL вместо привязки нельзя.
- `ClaimGarbage` собирает готовые объекты без привязок, поэтому удаление привязки
само по себе освобождает объект — отдельная уборка не нужна.
## Goals / Non-Goals
**Goals:**
- Копия фото не наследует отредактированную версию: показывается исходник, параметры
правок пусты.
- Postgres и SQLite ведут себя одинаково, список исключаемых ролей задан один раз.
- Уже существующие копии в базе перестают показывать отредактированную версию без
ручного вмешательства.
**Non-Goals:**
- Не переносим параметры правок на копию: правка принадлежит фото, а не копии.
- Не рендерим отредактированную версию заново для копии.
- Не меняем поведение переноса (`MoveBatch`) и разделяемых обычных вариантов.
- Не трогаем клиентов и HTTP API: формат ответа не меняется.
## Decisions
### 1. Фильтровать роли в `Clone`, а не в выдаче URL
Копирование исключает edit-роли из переносимых привязок:
```
INSERT INTO photo_objects (photo_id, object_id, role)
SELECT $1, object_id, role FROM photo_objects
WHERE photo_id = $2 AND role <> ALL($3)
```
- **Альтернатива: прятать edit-URL, если у фото нет `edit_params`.** Отклонено:
привязка осталась бы в базе, GC считал бы объект живым, а модель и привязка
расходились бы. Привязка — источник правды.
- **Альтернатива: копировать и параметры, и готовую версию.** Отклонено: меняет
семантику «правка принадлежит фото» и требует отдельного рендера для копии.
### 2. Список edit-ролей — в одном месте
Роли уже объявлены в `backend/internal/photos/edit_render.go`
(`RoleEdit`, `RoleEditThumb`, `RoleEditMedium`, `RoleEditPreview`), оба хранилища
находятся в том же пакете. Добавляется общий список ролей и хелпер проверки, чтобы
Postgres и SQLite не расходились и в SQL не попадали строковые литералы.
- **Альтернатива: писать роли литералами в каждом `Clone`.** Отклонено: список
размножится, добавление новой edit-роли забудет одно из хранилищ.
### 3. Разовая чистка существующих копий — миграцией
В базе могут остаться привязки `edit*` у фото с пустыми `edit_params` — это и есть
следы бага копирования. Чистка выполняется миграцией в обоих диалектах
(Postgres и SQLite нумеруются независимо):
```
DELETE FROM photo_objects
WHERE role IN ('edit', 'edit_thumb', 'edit_medium', 'edit_preview')
AND photo_id IN (SELECT id FROM photos WHERE edit_params IS NULL OR edit_params = '{}')
```
- **Альтернатива: чистить лениво при чтении.** Отклонено: правило прячется в двух
местах и не отражается в данных.
- **Альтернатива: ничего не чистить.** Отклонено: старые копии продолжат показывать
отредактированную версию, и расхождение останется в данных.
- Удалённые привязки не осиротевают объекты: отредактированная версия остаётся
привязана к своему фото, поэтому её объекты живут, пока живёт фото, и обычный
`ClaimGarbage` уберёт их вместе с ним.
### 4. Проверка — в существующем тесте копирования
`backend/internal/photos/copy_test.go` уже проверяет, что копия делит объекты с
оригиналом. Добавляется сценарий: после рендера правок копия получает обычные
варианты, но не edit-объекты, и её `edit_params` пусты.
## Risks / Trade-offs
- [Копии, созданные до фикса, продолжат показывать правки до применения миграции] →
Миграция чистит привязки у фото с пустыми `edit_params`; после неё поведение
единообразно.
- [Пользователь может потерять «неожиданно» отредактированную копию] → Копия без
параметров правок всё равно не позволяла их править осмысленно: первая новая правка
перезаписывала обработку с нуля.
- [Опечатка в списке ролей оставит edit-привязку в копии] → Список ролей берётся из
общих констант, а тест проверяет конкретные роли.
- [Миграция удалит привязки у фото, которые потеряли `edit_params` не из-за копирования]
→ Таких фото нет: `ResetEdit` удаляет и параметры, и привязки одновременно.
## Migration Plan
1. Миграция Postgres (`000016_*`) и SQLite (`000008_*`) удаляют «чужие» edit-привязки.
Обе идемпотентны (`DELETE` без `IF EXISTS` по данным).
2. Изменения в `Clone` — только код, схема не меняется.
3. Откат: миграции `down` возвращают удалённые привязки не могут — откат выполняется
только откатом кода; правки фото при этом сохраняются у исходных фото.
## Open Questions
Нет.