# Design ## Context Просмотрщик уже рисует клиентский предпросмотр правок: canvas с отфильтрованной видимой областью лежит поверх базовой версии фото (`original_url` в зуме, `preview_url` иначе). Решение «показывать ли серверную версию» сейчас принимается по флагам `hasEdits`, `pending` (локальные параметры ≠ сохранённым) и `!editUrl` ([Viewer.svelte](frontend/src/lib/components/Viewer.svelte)) — то есть по наличию URL, а не по состоянию рендера. Сохранение отложено на 10 секунд ([PhotoEditPanel.svelte](frontend/src/lib/components/PhotoEditPanel.svelte)): `PATCH /photos/:id/edit` ставит задание в `edit_jobs` и публикует `photo.updated`, затем воркер публикует `photo.updated` после вариантов превью и ещё раз после полного варианта. Таблица `edit_jobs` уже существует (`photo_id` — первичный ключ), миграции не нужны. Обогащение URL — `attachURLs`, через него проходят все выдачи фото, включая события SSE. У Viewer есть вторая копия фото `fresh`, перезапрашиваемая по REST, и её URL имеют приоритет над `current`, хотя актуальные приходят по SSE. ## Goals / Non-Goals **Goals:** - API даёт однозначный признак «рендер сохранённых правок не завершён». - Просмотрщик выбирает источник изображения по состоянию, а не по наличию URL. - Подмена предпросмотра серверной версией происходит без показа оригинала или предыдущей отрендеренной версии. - Сброс правок виден сразу, в том числе при возврате контролов в значения по умолчанию. **Non-Goals:** - Менять 10-секундный дебаунс сохранения и схему БД. - Новые состояния рендера (ошибки, приоритеты, отмена), статус ошибки рендера. - Кроссфейд/анимация подмены. - Изменения Android-клиента и синхронизация правок между устройствами сверх SSE. ## Decisions ### D1. `edit_pending` — булев признак из наличия задания в `edit_jobs`, считается в SQL - В `models.Photo` добавляется `EditPending bool` с JSON-тегом `edit_pending`. Поле присутствует всегда; его отсутствие означает старый бэкенд и трактуется клиентом как `false`. - В общие списки колонок фото (`photoColumns` в Postgres, `sqlitePhotoColumns` в SQLite) добавляется `EXISTS (SELECT 1 FROM edit_jobs ej WHERE ej.photo_id = p.id)`; сканирование поля добавляется в функции чтения фото (включая чтение корзины). В корзине попутно исправляется порядок чтения: tags JSON и `album_title` были перепутаны (проверяется регрессионным тестом в контракте). - Обоснование: ответы сохранения/сброса и оба события `photo.updated` собираются через `GetByID`, списки альбома — через `List`; один SELECT даёт признак во всех путях, без дополнительных запросов и без изменений в сервисном слое. - Альтернативы: - отдельный метод репозитория `PendingEditIDs` + обогащение в сервисе — больше точек изменения и N+1 для списков; отвергнуто; - хранить ревизию или хэш отрендеренных параметров (миграция и сравнение на клиенте) — точнее переживает неудачный рендер, но дороже; выносится в отдельное изменение, если понадобится; - клиентская эвристика «URL изменился с момента сохранения» — хрупкая (сброс, повторный рендер, несколько вкладок); отвергнуто. - Семантика: `true`, пока строка задания есть (ожидает, выполняется, повтор); `false` после `CompleteEditJob`/`DeleteEditJob`, включая сброс. Поиск идёт по первичному ключу `edit_jobs.photo_id`. - Совместимость: поле аддитивное, миграций нет; старые клиенты его игнорируют. ### D2. Клиентская машина состояний просмотра | `dirty` (локальные ≠ сохранённым) | `edit_pending` | Что показывает просмотрщик | |---|---|---| | true | не важно | базовая версия + canvas с локальными правками; canvas не рисуется, если локальные правки пусты (сброс) | | false | true | базовая версия + canvas с сохранёнными правками | | false | false | серверная версия; canvas держится до загрузки/декодирования нового изображения | - «Базовая версия» — `original_url` в зуме, `preview_url` иначе (без edit-ролей). - «Серверная версия» — `edit_url` в зуме, `edit_preview_url` иначе; если edit-вариантов нет, показывается базовая версия. - `edit_pending` отсутствует в ответе (старый бэкенд) — считается `false` (поведение как раньше, без падений). - Когда `dirty` и локальные правки пусты (сброс возвратом контролов) — сразу базовая версия, без canvas и без опоры на серверные edit-URL. Это делает сброс мгновенным. - Обоснование: все три наблюдаемых симптома — следствие того, что показ серверной версии включался по `!editUrl` и `hasEdits`, а не по «серверный рендер соответствует сохранённым параметрам и картинка готова к показу». ### D3. Подмена — только после загрузки и декодирования - При переходе в состояние «серверная версия» клиент предзагружает целевой URL (скрытый `Image`) и ждёт `decode()` (фолбэк — `load`); только затем снимает canvas и меняет `src`. До этого canvas остаётся, поэтому оригинал не мелькает. - Canvas поднимается только на время перехода «предпросмотр → серверная версия»: при обычном открытии фото с уже готовым рендером серверную версию грузит сам ``, лишний кадр оригинала не показывается. - Устаревшие предзагрузки отменяются по счётчику/`AbortSignal`, чтобы поздний ответ не вернул предыдущую версию. - Цена — небольшая задержка финального кадра (обычно сотни мс), зато без мигания. - Кроссфейд отвергнут: спекой не требуется, лишняя косметика. ### D4. Один актуальный источник фото в Viewer - `current` (обновляется SSE `photo.updated`) — основной источник URL и состояния. - `fresh` остаётся только фолбэком для страниц без SSE (просмотр по share-ссылке) и первичной загрузки; при выборе URL приоритет у актуального объекта, а refetch защищён счётчиком последовательности, чтобы устаревший ответ не перебил новый. - Обоснование: `photoFullUrl(fresh)` всегда возвращает хотя бы `original_url`, поэтому протухший `fresh` показывает именно оригинал. ### D5. Сброс: показ — мгновенно, отправка — по текущей политике дебаунса - Дебаунс сохранения не меняется: показ исходного вида обеспечивает D2 (`dirty` + пустые параметры → базовая версия), а отложенный `flushSave`/`resetEdit` лишь приводит сервер в соответствие. При уходе из панели `flushSave` уже отправляет сброс. - Альтернатива — отправлять сброс сразу, без дебаунса — уменьшает расхождение между вкладками, но добавляет отдельную политику сохранения и `DELETE` на каждое обнуление контролов; отвергнуто. - Следствие: другие вкладки и устройства увидят сброс после дебаунса — как и любое отложенное сохранение; это осознанный компромисс. ### D6. Индикатор рендера в панели редактора - `rendering` в панели считается как `photo.edit_pending`, а не как `hasEdits && !photo.edit_preview_url`: так индикатор корректен и для повторной правки, и после сброса. ## Risks / Trade-offs - **Рендер падает после всех попыток** → задание удаляется, `edit_pending=false`, остаётся последняя успешная отрендеренная версия, клиент покажет её. Поведение не хуже текущего; отдельный статус ошибки рендера — вне этого изменения. - **Смешанные версии бэкенда и фронтенда** → аддитивное поле: новый фронтенд со старым бэкендом трактует отсутствие как `false` и работает как раньше; новый бэкенд со старым фронтендом безвреден. Деплой в любом порядке, рекомендуется сначала бэкенд. - **Дополнительная колонка в горячих SELECT фото** → `EXISTS` по первичному ключу, стоимость пренебрежимо мала; при необходимости позже выносится в отдельное обогащение. - **Порядок SSE-событий** → сохранение публикует `photo.updated` с `edit_pending=true`, готовность — с `false`; клиент зависит только от значения, не от числа событий. Гонка «воркер завершил рендер раньше reload в publishEdit» даёт `false` и новые URL — консистентно. - **Большие full-res файлы** → финальный кадр появится позже, пока canvas с предпросмотром продолжает показываться; это ожидаемое поведение. ## Migration Plan - Миграций БД нет. Бэкенд и фронтенд разворачиваются независимо (аддитивное поле); рекомендация — сначала бэкенд. - Откат: достаточно вернуть прежний Viewer/тип (поле бэкенда безвредно); откат бэкенда не ломает старый фронтенд.