From e6bda212553dfb1db90251ae96cd7460029bd543 Mon Sep 17 00:00:00 2001 From: vvzvlad Date: Tue, 16 Jun 2026 23:26:08 +0300 Subject: [PATCH] docs: add offline sync & PWA implementation plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/offline-sync-plan.md | 393 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 393 insertions(+) create mode 100644 docs/offline-sync-plan.md diff --git a/docs/offline-sync-plan.md b/docs/offline-sync-plan.md new file mode 100644 index 00000000..0c43462d --- /dev/null +++ b/docs/offline-sync-plan.md @@ -0,0 +1,393 @@ +# 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](../apps/client/src/features/editor/page-editor.tsx) (L131–206) | На каждую страницу создаётся `Y.Doc`, к нему цепляются `IndexeddbPersistence("page.")` (локальная копия) **и** `HocuspocusProvider` (WS-синк). | +| [persistence.extension.ts](../apps/server/src/collaboration/extensions/persistence.extension.ts) | Сервер в `onStoreDocument` хранит в Postgres бинарный `ydoc` (Y state update) **плюс** отрендеренный tiptap-JSON `content` + `textContent`. В `onLoadDocument` поднимает `ydoc` обратно. | +| [collaboration/extensions/redis-sync/](../apps/server/src/collaboration/extensions/redis-sync/) | Redis-синк для горизонтального масштабирования инстансов. | + +Почему это и есть оффлайн-редактирование: Yjs — CRDT, апдейты коммутативны. +Пока клиент оффлайн, изменения копятся в `Y.Doc` и в IndexedDB; при возврате +сети `HocuspocusProvider` обменивается state-векторами и **детерминированно +сливает** правки. Конфликтов «кто кого перезаписал» в теле документа нет. + +### 2.2. Контур B: структурные данные — обычный REST, оффлайн недоступен + +| Сущность | Где | Механизм | +|---|---|---| +| Заголовок страницы | [title-editor.tsx](../apps/client/src/features/editor/title-editor.tsx) (L48–152) | REST `/pages/update`, дебаунс 500 мс. **НЕ Yjs.** | +| CRUD страниц, move, restore | [page-service.ts](../apps/client/src/features/page/services/page-service.ts) | REST `/pages/*` | +| Комментарии | [comment-service.ts](../apps/client/src/features/comment/services/comment-service.ts) | REST `/comments/*` | +| Watchers, favorites, labels, дерево, поиск | соответствующие `features/*/services` | REST | + +Состояние клиента: +- React Query: [main.tsx](../apps/client/src/main.tsx) (L26), `queryClient` + экспортируется, `retry:false`, `staleTime: 5 мин`. **Персистентности на диск + нет.** При перезагрузке без сети читать нечего. +- HTTP: [api-client.ts](../apps/client/src/lib/api-client.ts) — axios `/api`, + `withCredentials`. На `401` → `redirectToLogin()`. **Важно для оффлайна:** + редирект на логин при сетевой ошибке недопустим (см. M4). + +### 2.3. PWA: что уже есть + +- [manifest.json](../apps/client/public/manifest.json) — присутствует + (`display: standalone`, иконки). +- [index.html](../apps/client/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](../apps/server/src/core/page/services/page.service.ts) + использует `generateJitteredKeyBetween` из `fractional-indexing-jittered`. + Позиция — это строковый ключ (`position: string`), «jittered»-вариант + специально снижает коллизии при конкурентных/оффлайн-вставках. Это готовый + offline-friendly примитив для перемещений в дереве. +- **Генерация ID:** + [nanoid.utils.ts](../apps/server/src/common/helpers/nanoid.utils.ts) — + `generateSlugId` (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](../apps/client/vite.config.ts). + - `registerType: 'autoUpdate'` или `prompt` (см. риск R3). + - `workbox.globPatterns` — прекэш JS/CSS/wasm/шрифтов/иконок. + - `manifest: false` или генерация из существующего + [manifest.json](../apps/client/public/manifest.json) (не дублировать). + - Навигационный fallback на `index.html` для SPA-роутов. + - Runtime caching: `CacheFirst` для статики, **`NetworkOnly` для `/api/**` + и `/collab`** на этом этапе (REST-кэш появится в M2; SW не должен молча + отдавать устаревшие ответы API). +2. Зарегистрировать SW в [main.tsx](../apps/client/src/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](../apps/server/src/collaboration/extensions/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](../apps/client/src/features/editor/page-editor.tsx). + Показать состояние («оффлайн», «есть несинхронизированные правки», + «синхронизировано»). +3. **Заголовок страницы → в Yjs (рекомендуется).** + [title-editor.tsx](../apps/client/src/features/editor/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](../apps/client/src/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](../apps/server/src/common/helpers/nanoid.utils.ts). + Для позиций в дереве — `generateJitteredKeyBetween` из + `fractional-indexing-jittered` (тот же пакет, что на сервере). +3. **Идемпотентный upsert на сервере.** Эндпоинты `/pages/create`, + `/comments/create` и т.д. должны принимать клиентский `id` и быть + идемпотентными по нему (повторная отправка из очереди не должна плодить + дубликаты). Точки входа: + [page-service.ts](../apps/client/src/features/page/services/page-service.ts), + [comment-service.ts](../apps/client/src/features/comment/services/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](../apps/client/src/lib/api-client.ts) `401`/сетевые ошибки + **не должны** выкидывать на логин при отсутствии сети — отличать «нет сети» + от «реально разлогинен». Collab-токен (JWT с TTL, + [page-editor.tsx](../apps/client/src/features/editor/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. **Редирект на логин при сетевой ошибке.** Сейчас `401` → `redirectToLogin`. + Оффлайн это выкинет пользователя и потеряет контекст — чинить в 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` и `/collab` — `NetworkOnly`. +- [ ] 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: авторизация толерантна к оффлайну (нет редиректа на логин при отсутствии сети).