- proposal/design/tasks и delta-спеки photos, realtime - серверный признак edit_pending, бесшовная подмена предпросмотра, мгновенный сброс
152 lines
13 KiB
Markdown
152 lines
13 KiB
Markdown
# 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/тип (поле бэкенда безвредно); откат
|
||
бэкенда не ломает старый фронтенд.
|