Files
ds-galo/openspec/changes/fix-copy-edit-variant-sharing/design.md
T
iu b075f4c3a4 chore(openspec): предложить fix-copy-edit-variant-sharing
Копирование фото переносит и отредактированную версию, хотя правка
принадлежит фото, чьи правки её породили. Изменение описывает фильтр
edit-ролей в Clone, разовую чистку копий миграцией и дельту требования
«Совместные объекты».
2026-10-21 20:31:39 +03:00

8.2 KiB
Raw Blame History

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

Нет.