From b075f4c3a419d7dfa0452fd18bff21e3da0badb3 Mon Sep 17 00:00:00 2001 From: Iu Date: Wed, 21 Oct 2026 20:31:39 +0300 Subject: [PATCH] =?UTF-8?q?chore(openspec):=20=D0=BF=D1=80=D0=B5=D0=B4?= =?UTF-8?q?=D0=BB=D0=BE=D0=B6=D0=B8=D1=82=D1=8C=20fix-copy-edit-variant-sh?= =?UTF-8?q?aring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Копирование фото переносит и отредактированную версию, хотя правка принадлежит фото, чьи правки её породили. Изменение описывает фильтр edit-ролей в Clone, разовую чистку копий миграцией и дельту требования «Совместные объекты». --- .../.openspec.yaml | 2 + .../fix-copy-edit-variant-sharing/design.md | 119 ++++++++++++++++++ .../fix-copy-edit-variant-sharing/proposal.md | 46 +++++++ .../specs/media-objects/spec.md | 30 +++++ .../fix-copy-edit-variant-sharing/tasks.md | 35 ++++++ 5 files changed, 232 insertions(+) create mode 100644 openspec/changes/fix-copy-edit-variant-sharing/.openspec.yaml create mode 100644 openspec/changes/fix-copy-edit-variant-sharing/design.md create mode 100644 openspec/changes/fix-copy-edit-variant-sharing/proposal.md create mode 100644 openspec/changes/fix-copy-edit-variant-sharing/specs/media-objects/spec.md create mode 100644 openspec/changes/fix-copy-edit-variant-sharing/tasks.md diff --git a/openspec/changes/fix-copy-edit-variant-sharing/.openspec.yaml b/openspec/changes/fix-copy-edit-variant-sharing/.openspec.yaml new file mode 100644 index 0000000..dc36fb1 --- /dev/null +++ b/openspec/changes/fix-copy-edit-variant-sharing/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-21 diff --git a/openspec/changes/fix-copy-edit-variant-sharing/design.md b/openspec/changes/fix-copy-edit-variant-sharing/design.md new file mode 100644 index 0000000..ea62403 --- /dev/null +++ b/openspec/changes/fix-copy-edit-variant-sharing/design.md @@ -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 + +Нет. diff --git a/openspec/changes/fix-copy-edit-variant-sharing/proposal.md b/openspec/changes/fix-copy-edit-variant-sharing/proposal.md new file mode 100644 index 0000000..502781a --- /dev/null +++ b/openspec/changes/fix-copy-edit-variant-sharing/proposal.md @@ -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 и клиенты не меняются; миграций нет. diff --git a/openspec/changes/fix-copy-edit-variant-sharing/specs/media-objects/spec.md b/openspec/changes/fix-copy-edit-variant-sharing/specs/media-objects/spec.md new file mode 100644 index 0000000..dfdca2a --- /dev/null +++ b/openspec/changes/fix-copy-edit-variant-sharing/specs/media-objects/spec.md @@ -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** копия не получает отредактированные объекты, показывается в исходном виде и + не наследует параметры правок diff --git a/openspec/changes/fix-copy-edit-variant-sharing/tasks.md b/openspec/changes/fix-copy-edit-variant-sharing/tasks.md new file mode 100644 index 0000000..9c6e698 --- /dev/null +++ b/openspec/changes/fix-copy-edit-variant-sharing/tasks.md @@ -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 Проверить вручную: отредактировать фото, скопировать его в другой альбом и + убедиться, что копия показывает исходник, а панель правок у копии пустая