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.
30 KiB
Offline-режим и синхронизация правок в gitmost
Статус: проектный документ, готов к реализации. Контекст: gitmost — форк Docmost. Сейчас приложение полностью онлайн. Цель: дать возможность работать оффлайн (читать и редактировать) и синхронизироваться при возврате сети.
Документ описывает текущее устройство, целевую архитектуру и пошаговый план реализации с привязкой к конкретным файлам. Его можно взять и реализовывать по этапам M0…M4.
1. TL;DR
- Половина оффлайна уже встроена. Тело страницы редактируется через Yjs
(CRDT) + Hocuspocus, а на клиенте уже подключён
y-indexeddb. Правки тела уже открытой страницы переживают потерю сети и сами мёржатся при реконнекте — без конфликтов. - «Полностью онлайн» — это всё вокруг тела документа: загрузка самого приложения, навигация (дерево/список), заголовки страниц, комментарии, создание/перемещение/удаление страниц, вложения, авторизация.
- Оффлайн делится на два контура с разными механизмами синхронизации:
- Контур A — тело документа: CRDT (Yjs). Почти готов, нужно укрепить.
- Контур B — структурные данные (REST): не CRDT. Нужен паттерн локальный кэш + outbox (очередь мутаций) + правила разрешения конфликтов.
- 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. На401→redirectToLogin(). Важно для оффлайна: редирект на логин при сетевой ошибке недопустим (см. 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.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 установленное приложение без сети не загрузится. Это разблокирует всё остальное.
Что сделать:
- Добавить
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).
- Зарегистрировать SW в main.tsx
(
registerSWизvirtual:pwa-register). - Перенести косметику манифеста/метатегов из 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 и сделать поведение предсказуемым.
Что сделать:
- Закрыть ловушку «rebuild ydoc из JSON». В
persistence.extension.ts
onLoadDocumentпри пустомpage.ydocпересобирает документ изpage.contentчерезTiptapTransformer.toYdoc(...). Если это сработает, пока оффлайн-клиент держит свойY.Docсо своими client-id, при мёрже возможно дублирование контента (классическая Yjs-ловушка).- Гарантировать, что
ydocвсегда персистится (после первого сохранения он есть) и ветка rebuild не выполняется для страниц, у которых живут оффлайн-клиенты. Минимум — единожды мигрироватьcontent → ydocдля всех страниц и далее считатьydocединственным источником правды для тела.
- Гарантировать, что
- Индикатор оффлайна/синка в UI. Уже есть
yjsConnectionStatusAtomиisLocalSynced/isRemoteSyncedв page-editor.tsx. Показать состояние («оффлайн», «есть несинхронизированные правки», «синхронизировано»). - Заголовок страницы → в Yjs (рекомендуется).
title-editor.tsx
сохраняет заголовок REST-ом (дебаунс 500 мс) — оффлайн это не работает и
расходится с телом. Варианты:
- (a) перенести заголовок в тот же
Y.Doc(чистое CRDT-решение), либо - (b) тащить заголовок через outbox из M3 (LWW). Решение зафиксировать до старта M3 (см. открытый вопрос Q1).
- (a) перенести заголовок в тот же
Файлы: 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)
Зачем: оффлайн нужно видеть дерево, список и метаданные, иначе некуда переходить; и нужно префетчить страницы «на оффлайн».
Что сделать:
- Персист React Query на диск. Обернуть экспортируемый
queryClientиз main.tsx вPersistQueryClientProviderс IndexedDB-persister (@tanstack/query-persist-client-core+ idb-хранилище).- Кэшировать: дерево пространства, список страниц, метаданные страницы,
комментарии. Выставить разумный
maxAge/gcTime. - Версионировать кэш (
buster) по версии приложения, чтобы не «залипал» после деплоя.
- Кэшировать: дерево пространства, список страниц, метаданные страницы,
комментарии. Выставить разумный
- «Сделать доступным оффлайн». Действие для пространства/ветки: префетч
метаданных и прогрев
IndexeddbPersistenceдля тел страниц (открыть/ подгрузитьydocкаждой целевой страницы заранее), т.к. сейчас локально лежат только ранее открытые страницы. - 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) — ядро оффлайн-синка
Зачем: дать оффлайн-создание/редактирование структурных данных с последующим проигрыванием на сервер.
Что сделать:
- Очередь мутаций (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. - Клиентская генерация ID. Для оффлайн-создания страниц/комментариев
генерировать
id/slugIdна клиенте тем же алфавитом, что и nanoid.utils.ts. Для позиций в дереве —generateJitteredKeyBetweenизfractional-indexing-jittered(тот же пакет, что на сервере). - Идемпотентный upsert на сервере. Эндпоинты
/pages/create,/comments/createи т.д. должны принимать клиентскийidи быть идемпотентными по нему (повторная отправка из очереди не должна плодить дубликаты). Точки входа: page-service.ts, comment-service.ts и соответствующие контроллеры сервера. - Optimistic updates + откат. Применять мутацию к кэшу сразу; при неуспешном проигрывании после реконнекта — откат/пометка конфликта.
- Правила разрешения конфликтов (см. §5).
- Проигрывание при реконнекте в порядке
createdAt, с экспоненциальным backoff и идемпотентностью.
Файлы: новый apps/client/src/lib/offline/outbox.ts, обёртки над
features/*/services/*, серверные контроллеры/сервисы соответствующих
сущностей (idempotent upsert).
Критерий приёмки: оффлайн можно создать страницу, отредактировать заголовок, оставить комментарий, переместить страницу; после реконнекта всё появляется на сервере один раз (без дублей), конфликты разрешаются по заданным правилам.
Риск: высокий (это самостоятельный класс багов синхронизации; требует серверных изменений и тестов на конфликты).
M4 — Вложения и оффлайн-авторизация
Что сделать:
- Вложения/картинки оффлайн. Очередь загрузок: blob кладётся в локальный
кэш (Cache API/IndexedDB), в документ вставляется ссылка на локальный
ресурс; при реконнекте файл доуплоадивается, ссылка переписывается на
серверную. Точка входа —
features/attachments. - Оффлайн-толерантная авторизация. В
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. Подводные камни (читать до старта)
- Yjs rebuild из JSON → дубли. Ветка
content → toYdocвonLoadDocumentопасна для долго-оффлайновых клиентов. Закрыть в M1. - Инвалидция кэша после деплоя. Персист React Query и precache SW должны
версионироваться по версии приложения (
buster/globPatternsхэши), иначе пользователь застрянет на старом UI/данных. - Обновление service worker.
autoUpdateможет перезагрузить вкладку с несохранёнными правками. Для редактора предпочтительнееprompt-стратегия (показать «доступно обновление», применить по согласию). - Идемпотентность обязательна. Любая мутация из outbox может отправиться
повторно (реконнект/ретрай). Без серверного upsert по
clientId— дубли. - Рост IndexedDB. Прогрев тел страниц «на оффлайн» и кэш блобов могут занять много места. Нужны лимиты/очистка (LRU).
- Редирект на логин при сетевой ошибке. Сейчас
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: авторизация толерантна к оффлайну (нет редиректа на логин при отсутствии сети).