Files
gitmost/docs/offline-sync-plan.md
vvzvlad e6bda21255 docs: add offline sync & PWA implementation plan
Add docs/offline-sync-plan.md — a ready-to-implement design document for
offline editing and synchronization in gitmost.

- Describes current state: Yjs/Hocuspocus + y-indexeddb for document body
  (CRDT, offline-ready) vs REST-based structural data (online-only).
- Clarifies that PWA installability already exists (inherited from Docmost);
  the missing piece is a service worker for offline app-shell.
- Defines two sync contours (CRDT body / outbox+LWW for REST) and a staged
  plan M0..M4 with per-step files, acceptance criteria and risks.
- Includes conflict-resolution rules, pitfalls, npm deps, open questions
  and an implementation checklist.
2026-06-16 23:26:08 +03:00

30 KiB

Offline-режим и синхронизация правок в gitmost

Статус: проектный документ, готов к реализации. Контекст: gitmost — форк Docmost. Сейчас приложение полностью онлайн. Цель: дать возможность работать оффлайн (читать и редактировать) и синхронизироваться при возврате сети.

Документ описывает текущее устройство, целевую архитектуру и пошаговый план реализации с привязкой к конкретным файлам. Его можно взять и реализовывать по этапам M0…M4.


1. TL;DR

  1. Половина оффлайна уже встроена. Тело страницы редактируется через Yjs (CRDT) + Hocuspocus, а на клиенте уже подключён y-indexeddb. Правки тела уже открытой страницы переживают потерю сети и сами мёржатся при реконнекте — без конфликтов.
  2. «Полностью онлайн» — это всё вокруг тела документа: загрузка самого приложения, навигация (дерево/список), заголовки страниц, комментарии, создание/перемещение/удаление страниц, вложения, авторизация.
  3. Оффлайн делится на два контура с разными механизмами синхронизации:
    • Контур A — тело документа: CRDT (Yjs). Почти готов, нужно укрепить.
    • Контур B — структурные данные (REST): не CRDT. Нужен паттерн локальный кэш + outbox (очередь мутаций) + правила разрешения конфликтов.
  4. PWA — обязательный фундамент, но это два слоя:
    • Installability (manifest + meta-теги) — уже есть в gitmost (унаследовано от Docmost). Forkmost добавляет только косметику.
    • Service worker (кэш app-shell, запуск без сети) — нет нигде, это и есть реальная невыполненная часть. Без него установленное приложение без сети покажет пустой экран.

2. Текущее состояние (как есть)

2.1. Контур A: тело документа — CRDT, почти готово

Где Что делает
page-editor.tsx (L131–206) На каждую страницу создаётся Y.Doc, к нему цепляются IndexeddbPersistence("page.<id>") (локальная копия) и HocuspocusProvider (WS-синк).
persistence.extension.ts Сервер в onStoreDocument хранит в Postgres бинарный ydoc (Y state update) плюс отрендеренный tiptap-JSON content + textContent. В onLoadDocument поднимает ydoc обратно.
collaboration/extensions/redis-sync/ Redis-синк для горизонтального масштабирования инстансов.

Почему это и есть оффлайн-редактирование: Yjs — CRDT, апдейты коммутативны. Пока клиент оффлайн, изменения копятся в Y.Doc и в IndexedDB; при возврате сети HocuspocusProvider обменивается state-векторами и детерминированно сливает правки. Конфликтов «кто кого перезаписал» в теле документа нет.

2.2. Контур B: структурные данные — обычный REST, оффлайн недоступен

Сущность Где Механизм
Заголовок страницы title-editor.tsx (L48–152) REST /pages/update, дебаунс 500 мс. НЕ Yjs.
CRUD страниц, move, restore page-service.ts REST /pages/*
Комментарии comment-service.ts REST /comments/*
Watchers, favorites, labels, дерево, поиск соответствующие features/*/services REST

Состояние клиента:

  • React Query: main.tsx (L26), queryClient экспортируется, retry:false, staleTime: 5 мин. Персистентности на диск нет. При перезагрузке без сети читать нечего.
  • HTTP: api-client.ts — axios /api, withCredentials. На 401redirectToLogin(). Важно для оффлайна: редирект на логин при сетевой ошибке недопустим (см. M4).

2.3. PWA: что уже есть

  • manifest.json — присутствует (display: standalone, иконки).
  • index.html (L9–16) — PWA meta-теги (apple-mobile-web-app-capable, mobile-web-app-capable, theme-color и т.д.).
  • Service worker отсутствует. Нет vite-plugin-pwa, Workbox, precache.

Вывод по Forkmost (Vito0912/forkmost): их «PWA-наработки» — это только манифест и meta-теги (closing issue Docmost #328 про устанавливаемость). Service worker / оффлайн-кэша там нет. В gitmost installability уже есть, поэтому из Forkmost переносить нечего, кроме косметики.

2.4. Полезные примитивы, которые уже есть в проекте

  • Fractional indexing для позиций страниц: page.service.ts использует generateJitteredKeyBetween из fractional-indexing-jittered. Позиция — это строковый ключ (position: string), «jittered»-вариант специально снижает коллизии при конкурентных/оффлайн-вставках. Это готовый offline-friendly примитив для перемещений в дереве.
  • Генерация ID: nanoid.utils.tsgenerateSlugId (10 симв.) и nanoIdGen. ID можно генерировать на клиенте и принимать на сервере (нужно для оффлайн-создания, см. M3).

3. Целевая архитектура

                       ┌──────────────────────── Браузер (PWA) ────────────────────────┐
                       │                                                                │
   Тело документа      │   TipTap ⟷ Y.Doc ⟷ IndexeddbPersistence (локальная копия)      │
   (Контур A, CRDT)    │                      │                                         │
                       │                      └── HocuspocusProvider ──┐                │
                       │                                               │                │
   Структурные данные  │   React Query (read) ⟵ IndexedDB persister    │                │
   (Контур B, REST)    │   Мутации ⟶ Outbox (IndexedDB) ──────────┐    │                │
                       │                                          │    │                │
   App shell           │   Service Worker (Workbox precache)      │    │                │
                       └──────────────────────────────────────────┼────┼───────────────┘
                                                                   │    │
                                       (reconnect)                 ▼    ▼
                       ┌──────────────────────── Сервер ───────────────────────────────┐
                       │   REST API (idempotent upsert по client-id)   Hocuspocus (Yjs) │
                       │            │                                        │           │
                       │            └────────────── Postgres ───────────────┘           │
                       └────────────────────────────────────────────────────────────────┘

Два независимых канала синхронизации:

  • Контур A синкается сам через Hocuspocus (Yjs). Руками конфликты не решаем.
  • Контур B синкается через outbox: оффлайн-мутации пишутся в журнал в IndexedDB и проигрываются на сервер при реконнекте; конфликты решаются явными правилами (LWW / per-entity).

4. План реализации по этапам

Этапы инкрементальны: каждый даёт пользователю ощутимый результат и может быть смёржен отдельно. Рекомендуемый порядок — строго M0 → M4.

M0 — PWA shell (фундамент: приложение запускается без сети)

Зачем: без service worker установленное приложение без сети не загрузится. Это разблокирует всё остальное.

Что сделать:

  1. Добавить vite-plugin-pwa (Workbox под капотом) в vite.config.ts.
    • registerType: 'autoUpdate' или prompt (см. риск R3).
    • workbox.globPatterns — прекэш JS/CSS/wasm/шрифтов/иконок.
    • manifest: false или генерация из существующего manifest.json (не дублировать).
    • Навигационный fallback на index.html для SPA-роутов.
    • Runtime caching: CacheFirst для статики, NetworkOnly для /api/** и /collab на этом этапе (REST-кэш появится в M2; SW не должен молча отдавать устаревшие ответы API).
  2. Зарегистрировать SW в main.tsx (registerSW из virtual:pwa-register).
  3. Перенести косметику манифеста/метатегов из Forkmost при желании (бренд, orientation, msapplication-*). Опционально, на оффлайн не влияет.

Файлы: apps/client/vite.config.ts, apps/client/src/main.tsx, apps/client/public/manifest.json, apps/client/index.html.

Критерий приёмки: приложение устанавливается, после первой загрузки открывается без сети (виден shell/лэйаут, а не пустой экран); обновление версии SW не ломает открытую сессию.

Риск: низкий. Изолированный слой, кода приложения не трогает.


M1 — Укрепление оффлайна тела документа (Контур A)

Зачем: убрать известные грабли Yjs и сделать поведение предсказуемым.

Что сделать:

  1. Закрыть ловушку «rebuild ydoc из JSON». В persistence.extension.ts onLoadDocument при пустом page.ydoc пересобирает документ из page.content через TiptapTransformer.toYdoc(...). Если это сработает, пока оффлайн-клиент держит свой Y.Doc со своими client-id, при мёрже возможно дублирование контента (классическая Yjs-ловушка).
    • Гарантировать, что ydoc всегда персистится (после первого сохранения он есть) и ветка rebuild не выполняется для страниц, у которых живут оффлайн-клиенты. Минимум — единожды мигрировать content → ydoc для всех страниц и далее считать ydoc единственным источником правды для тела.
  2. Индикатор оффлайна/синка в UI. Уже есть yjsConnectionStatusAtom и isLocalSynced/isRemoteSynced в page-editor.tsx. Показать состояние («оффлайн», «есть несинхронизированные правки», «синхронизировано»).
  3. Заголовок страницы → в Yjs (рекомендуется). title-editor.tsx сохраняет заголовок REST-ом (дебаунс 500 мс) — оффлайн это не работает и расходится с телом. Варианты:
    • (a) перенести заголовок в тот же Y.Doc (чистое CRDT-решение), либо
    • (b) тащить заголовок через outbox из M3 (LWW). Решение зафиксировать до старта M3 (см. открытый вопрос Q1).

Файлы: apps/server/src/collaboration/extensions/persistence.extension.ts, apps/client/src/features/editor/page-editor.tsx, apps/client/src/features/editor/title-editor.tsx (если вариант a).

Критерий приёмки: правки тела уже открытой страницы, сделанные оффлайн, после реконнекта появляются на сервере и у других клиентов без дублей и потерь; в UI виден статус синка.

Риск: средний (Yjs-семантика, миграция content → ydoc).


M2 — Оффлайн-чтение и навигация (Контур B, read-path)

Зачем: оффлайн нужно видеть дерево, список и метаданные, иначе некуда переходить; и нужно префетчить страницы «на оффлайн».

Что сделать:

  1. Персист React Query на диск. Обернуть экспортируемый queryClient из main.tsx в PersistQueryClientProvider с IndexedDB-persister (@tanstack/query-persist-client-core + idb-хранилище).
    • Кэшировать: дерево пространства, список страниц, метаданные страницы, комментарии. Выставить разумный maxAge/gcTime.
    • Версионировать кэш (buster) по версии приложения, чтобы не «залипал» после деплоя.
  2. «Сделать доступным оффлайн». Действие для пространства/ветки: префетч метаданных и прогрев IndexeddbPersistence для тел страниц (открыть/ подгрузить ydoc каждой целевой страницы заранее), т.к. сейчас локально лежат только ранее открытые страницы.
  3. Runtime caching API в SW (read-only). Для GET-эндпоинтов навигации — StaleWhileRevalidate/NetworkFirst с фолбэком на кэш. Мутации (POST) — по-прежнему мимо кэша (их берёт на себя M3).

Файлы: apps/client/src/main.tsx, новый модуль apps/client/src/lib/offline/ (persister, prefetch), точечно — хуки списков/ дерева в features/page/tree.

Критерий приёмки: после прогрева и ухода в оффлайн пользователь видит дерево и список, открывает заранее подготовленные страницы и читает их тело и комментарии.

Риск: средний (консистентность кэша, инвалидция после деплоя).


M3 — Outbox для мутаций (Контур B, write-path) — ядро оффлайн-синка

Зачем: дать оффлайн-создание/редактирование структурных данных с последующим проигрыванием на сервер.

Что сделать:

  1. Очередь мутаций (outbox) в IndexedDB. Журнал операций { id, entity, op, payload, clientId, baseVersion, createdAt, status }. Использовать offline/paused mutations TanStack Query (onlineManager + queryClient.resumePausedMutations() + персист пауз), либо отдельный модуль apps/client/src/lib/offline/outbox.ts.
  2. Клиентская генерация ID. Для оффлайн-создания страниц/комментариев генерировать id/slugId на клиенте тем же алфавитом, что и nanoid.utils.ts. Для позиций в дереве — generateJitteredKeyBetween из fractional-indexing-jittered (тот же пакет, что на сервере).
  3. Идемпотентный upsert на сервере. Эндпоинты /pages/create, /comments/create и т.д. должны принимать клиентский id и быть идемпотентными по нему (повторная отправка из очереди не должна плодить дубликаты). Точки входа: page-service.ts, comment-service.ts и соответствующие контроллеры сервера.
  4. Optimistic updates + откат. Применять мутацию к кэшу сразу; при неуспешном проигрывании после реконнекта — откат/пометка конфликта.
  5. Правила разрешения конфликтов (см. §5).
  6. Проигрывание при реконнекте в порядке createdAt, с экспоненциальным backoff и идемпотентностью.

Файлы: новый apps/client/src/lib/offline/outbox.ts, обёртки над features/*/services/*, серверные контроллеры/сервисы соответствующих сущностей (idempotent upsert).

Критерий приёмки: оффлайн можно создать страницу, отредактировать заголовок, оставить комментарий, переместить страницу; после реконнекта всё появляется на сервере один раз (без дублей), конфликты разрешаются по заданным правилам.

Риск: высокий (это самостоятельный класс багов синхронизации; требует серверных изменений и тестов на конфликты).


M4 — Вложения и оффлайн-авторизация

Что сделать:

  1. Вложения/картинки оффлайн. Очередь загрузок: blob кладётся в локальный кэш (Cache API/IndexedDB), в документ вставляется ссылка на локальный ресурс; при реконнекте файл доуплоадивается, ссылка переписывается на серверную. Точка входа — features/attachments.
  2. Оффлайн-толерантная авторизация. В api-client.ts 401/сетевые ошибки не должны выкидывать на логин при отсутствии сети — отличать «нет сети» от «реально разлогинен». Collab-токен (JWT с TTL, page-editor.tsx L166–181) оффлайн не обновить — синк должен просто ждать реконнекта, не ломая локальную работу.

Критерий приёмки: оффлайн-вставка картинки доезжает после реконнекта; протухший токен/нет сети не выкидывают пользователя из приложения и не теряют локальные правки.

Риск: средний.


5. Правила разрешения конфликтов (Контур B)

CRDT здесь нет, правила задаём явно по типам сущностей:

Сущность Стратегия
Тело документа Yjs (CRDT) — руками ничего не решаем.
Комментарии Почти append-only. LWW по полю + дедуп по clientId. Простейший случай.
Метаданные страницы (заголовок, иконка) Last-Write-Wins по updatedAt.
Перемещение в дереве Самый сложный случай. Позиции — строковые fractional-ключи (generateJitteredKeyBetween), что снижает коллизии вставок. Нужен серверный реконсилер для «родитель удалён, а ребёнок перемещён» и конкурентных move: правило «удаление побеждает перемещение» (или наоборот — зафиксировать), плюс перегенерация позиции при коллизии.
Удаление vs правка Зафиксировать политику: правка удалённой сущности → конфликт в UI либо «удаление выигрывает».

6. Подводные камни (читать до старта)

  1. Yjs rebuild из JSON → дубли. Ветка content → toYdoc в onLoadDocument опасна для долго-оффлайновых клиентов. Закрыть в M1.
  2. Инвалидция кэша после деплоя. Персист React Query и precache SW должны версионироваться по версии приложения (buster/globPatterns хэши), иначе пользователь застрянет на старом UI/данных.
  3. Обновление service worker. autoUpdate может перезагрузить вкладку с несохранёнными правками. Для редактора предпочтительнее prompt-стратегия (показать «доступно обновление», применить по согласию).
  4. Идемпотентность обязательна. Любая мутация из outbox может отправиться повторно (реконнект/ретрай). Без серверного upsert по clientId — дубли.
  5. Рост IndexedDB. Прогрев тел страниц «на оффлайн» и кэш блобов могут занять много места. Нужны лимиты/очистка (LRU).
  6. Редирект на логин при сетевой ошибке. Сейчас 401redirectToLogin. Оффлайн это выкинет пользователя и потеряет контекст — чинить в M4.

7. Зависимости (npm)

Пакет Зачем Этап
vite-plugin-pwa (+ Workbox) SW, precache app-shell, генерация манифеста M0
@tanstack/query-persist-client-core Персист React Query на диск M2
idb или idb-keyval Обёртка над IndexedDB (persister/outbox/blob-кэш) M2–M4
fractional-indexing-jittered Клиентская генерация позиций (уже есть на сервере) M3

yjs, y-indexeddb, @hocuspocus/providerуже в проекте, доустанавливать не нужно.


8. Объём работ vs ценность (для приоритизации)

Уровень Этапы Что пользователь получает
Минимальный M0 + M1 Приложение грузится оффлайн; уже открытые страницы редактируются и синкаются (тело + заголовок). Навигация — только по закэшированному.
Средний + M2 + M3 Оффлайн-навигация по подготовленным пространствам; оффлайн-создание страниц и комментариев с синком и LWW-конфликтами.
Полный + M4 (и при необходимости — переезд на синк-движок) Вложения оффлайн, устойчивая авторизация. Полноценный local-first.

Прагматичный путь: довести M0+M1 (это ~80% «редактирую то, что открыл»), затем M2/M3 инкрементально. Полный синк-движок (RxDB / ElectricSQL / PowerSync / Replicache / TanStack DB) рассматривать только если оффлайн станет ключевым сценарием продукта — это существенный рефакторинг данных и бэкенда.


9. Открытые вопросы (зафиксировать до реализации)

  • Q1. Заголовок страницы: переносим в Yjs (M1, вариант a) или гоним через outbox (M3, вариант b)? Рекомендация — (a), меньше конфликтных правил.
  • Q2. Политика конфликта «удаление vs правка»: «удаление выигрывает» или явный конфликт в UI?
  • Q3. Стратегия обновления SW для редактора: autoUpdate или prompt? Рекомендация — prompt.
  • Q4. Лимиты локального хранилища (сколько пространств/страниц/блобов держать оффлайн, политика вытеснения).
  • Q5. Целимся в инкрементальный путь (M0…M4) или сразу в синк-движок (уровень «полный»)? От этого зависит, переписывать ли REST-слой.

10. Чеклист реализации

  • M0: vite-plugin-pwa подключён, SW регистрируется, app-shell в precache, /api и /collabNetworkOnly.
  • M0: приложение открывается без сети (shell виден).
  • M1: ветка rebuild ydoc из JSON обезврежена; миграция content → ydoc.
  • M1: индикатор статуса синка в UI.
  • M1: заголовок переведён в Yjs (или решение Q1 принято).
  • M2: React Query персистится в IndexedDB, кэш версионирован.
  • M2: действие «сделать доступным оффлайн» (метаданные + прогрев ydoc).
  • M3: outbox в IndexedDB, клиентские ID, идемпотентный upsert на сервере.
  • M3: optimistic updates + откат; правила конфликтов реализованы.
  • M4: очередь загрузки вложений + локальный blob-кэш.
  • M4: авторизация толерантна к оффлайну (нет редиректа на логин при отсутствии сети).