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.
This commit is contained in:
393
docs/offline-sync-plan.md
Normal file
393
docs/offline-sync-plan.md
Normal file
@@ -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.<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: авторизация толерантна к оффлайну (нет редиректа на логин при отсутствии сети).
|
||||
Reference in New Issue
Block a user