# 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: авторизация толерантна к оффлайну (нет редиректа на логин при отсутствии сети).