docs(openspec): изменение fix-edit-preview-handoff

- proposal/design/tasks и delta-спеки photos, realtime
- серверный признак edit_pending, бесшовная подмена предпросмотра, мгновенный сброс
This commit is contained in:
iu
2026-10-25 10:02:04 +03:00
parent 4d5cf3c1cb
commit 1c29f5d961
6 changed files with 374 additions and 0 deletions
@@ -0,0 +1,151 @@
# 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 поднимается только на время перехода «предпросмотр → серверная версия»:
при обычном открытии фото с уже готовым рендером серверную версию грузит сам
`<img>`, лишний кадр оригинала не показывается.
- Устаревшие предзагрузки отменяются по счётчику/`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/тип (поле бэкенда безвредно); откат
бэкенда не ломает старый фронтенд.