- proposal/design/tasks и delta-спеки photos, realtime - серверный признак edit_pending, бесшовная подмена предпросмотра, мгновенный сброс
13 KiB
Design
Context
Просмотрщик уже рисует клиентский предпросмотр правок: canvas с отфильтрованной
видимой областью лежит поверх базовой версии фото (original_url в зуме,
preview_url иначе). Решение «показывать ли серверную версию» сейчас принимается
по флагам hasEdits, pending (локальные параметры ≠ сохранённым) и !editUrl
(Viewer.svelte) — то есть по наличию
URL, а не по состоянию рендера.
Сохранение отложено на 10 секунд (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(обновляется SSEphoto.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/тип (поле бэкенда безвредно); откат бэкенда не ломает старый фронтенд.