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