chore(openspec): предложить fix-copy-edit-variant-sharing
Копирование фото переносит и отредактированную версию, хотя правка принадлежит фото, чьи правки её породили. Изменение описывает фильтр edit-ролей в Clone, разовую чистку копий миграцией и дельту требования «Совместные объекты».
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-21
|
||||
@@ -0,0 +1,119 @@
|
||||
# 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
|
||||
|
||||
Нет.
|
||||
@@ -0,0 +1,46 @@
|
||||
# Proposal
|
||||
|
||||
## Why
|
||||
|
||||
Отредактированная версия фото задумана принадлежащей только тому фото, чьи правки её
|
||||
породили: воркер создаёт edit-объекты с владельцем `[]string{photoID}` и комментирует
|
||||
это прямо. Но копирование фото (`Clone`) переносит **все** привязки `photo_objects`,
|
||||
включая роли `edit`, `edit_thumb`, `edit_medium`, `edit_preview`. Копия фото, сделанная
|
||||
после рендера правок, поэтому показывает отредактированную картинку, но не наследует
|
||||
`edit_params`: в панели правок у копии параметров нет, и первая же новая правка
|
||||
перезаписывает исходную обработку с нуля. Поведение расходится с замыслом и с
|
||||
описанием в спеке.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Копирование фото перестаёт переносить отредактированную версию: в копию
|
||||
попадают только обычные роли (`original`, `thumb`, `medium`, `preview`, `playback`).
|
||||
- Копия фото с правками показывается в исходном виде, параметры правок копии
|
||||
остаются пустыми — как и сейчас.
|
||||
- Обычные варианты по-прежнему разделяются между копиями одного оригинала: правка
|
||||
принадлежит фото, а не объекту.
|
||||
- Спека `media-objects` приводится к этому правилу: уточняется, кому принадлежит
|
||||
отредактированная версия и что происходит при копировании.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
Нет.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `media-objects`: копирование фото не переносит отредактированную версию на копию;
|
||||
отредактированная версия остаётся привязанной только к фото, чьи правки её создали.
|
||||
|
||||
## Impact
|
||||
|
||||
- `backend/internal/photos/store.go` — `Store.Clone` (Postgres): при копировании
|
||||
`photo_objects` исключаются роли `edit*`.
|
||||
- `backend/internal/photos/sqlite_store.go` — `SQLiteStore.Clone` (SQLite): то же
|
||||
поведение.
|
||||
- `backend/internal/photos/edit_render.go` — константы ролей `edit*` используются как
|
||||
список исключений (без дублирования строк в двух хранилищах).
|
||||
- `backend/internal/photos/copy_test.go` — тест на копию фото с отрендеренной
|
||||
отредактированной версией.
|
||||
- Схема БД, HTTP API и клиенты не меняются; миграций нет.
|
||||
@@ -0,0 +1,30 @@
|
||||
# Spec Delta
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Совместные объекты
|
||||
Несколько фото SHALL иметь возможность ссылаться на один и тот же физический
|
||||
объект; перенос и копирование SHALL изменять только ссылки и SHALL не изменять
|
||||
объекты хранилища. Отредактированная версия SHALL создаваться и привязываться только
|
||||
к тому фото, чьи правки её породили, в отличие от обычных вариантов, которые
|
||||
разделяются копиями того же оригинала. Копирование SHALL NOT переносить
|
||||
отредактированную версию на копию: копия фото с правками SHALL показываться в исходном
|
||||
виде и SHALL NOT наследовать параметры правок.
|
||||
|
||||
#### Scenario: Копирование
|
||||
- **WHEN** пользователь копирует фото в другой альбом
|
||||
- **THEN** создаётся новая запись, ссылающаяся на те же объекты, без операций с хранилищем
|
||||
|
||||
#### Scenario: Перенос
|
||||
- **WHEN** пользователь переносит фото в другой альбом
|
||||
- **THEN** изменяется только привязка к альбому, объекты хранилища не меняются
|
||||
|
||||
#### Scenario: Правка не распространяется на копии
|
||||
- **WHEN** фото скопировали в другой альбом, а затем отредактировали исходное фото
|
||||
- **THEN** отредактированные объекты привязаны только к отредактированному фото, а копия
|
||||
остаётся в исходном виде
|
||||
|
||||
#### Scenario: Копия отредактированного фото
|
||||
- **WHEN** фото с уже отрендеренной отредактированной версией копируют в другой альбом
|
||||
- **THEN** копия не получает отредактированные объекты, показывается в исходном виде и
|
||||
не наследует параметры правок
|
||||
@@ -0,0 +1,35 @@
|
||||
# Tasks
|
||||
|
||||
## 1. Список edit-ролей
|
||||
|
||||
- [ ] 1.1 В `backend/internal/photos/edit_render.go` собрать общий список ролей
|
||||
(`RoleEdit`, `RoleEditThumb`, `RoleEditMedium`, `RoleEditPreview`) и хелпер проверки
|
||||
роли; проверить, что список используется в обоих хранилищах и не дублируется
|
||||
строковыми литералами
|
||||
|
||||
## 2. Копирование без отредактированной версии
|
||||
|
||||
- [ ] 2.1 В `Store.Clone` (`backend/internal/photos/store.go`) исключить edit-роли из
|
||||
переносимых привязок `photo_objects`; проверить тестом `copy_test.go`: копия
|
||||
получает обычные варианты и не получает edit-объекты
|
||||
- [ ] 2.2 В `SQLiteStore.Clone` (`backend/internal/photos/sqlite_store.go`) сделать то же
|
||||
для SQLite; проверить тем же тестом, прогнанным на SQLite-хранилище
|
||||
|
||||
## 3. Разовая чистка существующих копий
|
||||
|
||||
- [ ] 3.1 Добавить миграцию Postgres `backend/migrations/000016_*.up.sql` и `.down.sql`,
|
||||
удаляющую edit-привязки у фото с пустыми `edit_params`; проверить на тестовой базе,
|
||||
что привязки исходных фото с правками остались, а «чужие» удалены
|
||||
- [ ] 3.2 Добавить миграцию SQLite `backend/migrations/sqlite/000008_*.up.sql` и
|
||||
`.down.sql` с тем же действием; проверить тем же сценарием на SQLite
|
||||
- [ ] 3.3 Убедиться, что удалённые объекты подбираются сборщиком мусора
|
||||
(`ClaimGarbage` не возвращает объекты с привязками); проверить существующим тестом
|
||||
`objects_test.go`
|
||||
|
||||
## 4. Интеграционная проверка
|
||||
|
||||
- [ ] 4.1 Прогнать `cd backend && go test ./internal/photos/ -v`; убедиться, что тесты
|
||||
зелёные
|
||||
- [ ] 4.2 Прогнать `make test`; убедиться, что весь бэкенд зелёный
|
||||
- [ ] 4.3 Проверить вручную: отредактировать фото, скопировать его в другой альбом и
|
||||
убедиться, что копия показывает исходник, а панель правок у копии пустая
|
||||
Reference in New Issue
Block a user