[feature][epic] Мобильное приложение (iOS-first, Capacitor) + offline-режим и синхронизация (PWA / outbox / Yjs) #195

Open
opened 2026-06-25 22:40:57 +03:00 by claude_code · 1 comment
Collaborator

Перенесено из docs/mobile-app-plan.md и docs/offline-sync-plan.md (оба файла удалены; этот issue — единственный носитель планов).

Планы объединены в один issue сознательно: мобильное приложение переиспользует offline-план (этапы M0…M4), а не строит оффлайн заново — см. §10 мобильного плана и весь offline-план. Ниже — оба документа целиком: сначала мобильный план, затем offline-план.


ЧАСТЬ 1. МОБИЛЬНОЕ ПРИЛОЖЕНИЕ

Мобильное приложение gitmost — исследование и план

Статус: исследовательский + проектный документ.
Контекст: gitmost — форк Docmost, чистое веб-приложение. Отдельного
мобильного (нативного/устанавливаемого) приложения нет.
Цель: определить путь к мобильным приложениям — iOS обязательно, Android
как пойдёт
— с заделом на оффлайн в будущем (оффлайн сейчас не требуется).

Документ фиксирует, что уже есть в коде, почему путь к мобилке предопределён
устройством продукта, сравнивает варианты и описывает рекомендуемый план с
привязкой к файлам.


1. TL;DR

  1. Нативного приложения нет. В проекте отсутствуют Capacitor, React Native,
    Cordova и т.п. Мобильного клиента ещё не начинали.
  2. Адаптивная веб-версия — есть, и довольно проработанная. Веб-клиент
    открывается с телефона как mobile-friendly сайт: сворачиваемый сайдбар-drawer,
    отдельные мобильные компоненты (история, поиск, хлебные крошки), responsive-
    примитивы Mantine, mobile-tuned viewport. Это готовый фундамент UI.
  3. Ядро продукта — веб-редактор — нативно не воспроизвести. TipTap 3
    (ProseMirror) + совместное редактирование на Yjs/Hocuspocus плотно сшиты с
    React. Production-порта Yjs под Swift/Kotlin нет. Любой реалистичный путь
    оставляет редактор в WebView.
  4. API уже готов к нативному клиенту. Сервер принимает JWT не только из
    cookie, но и из заголовка Authorization: Bearer. Есть точка входа для
    вебсокета совместного редактирования (POST /auth/collab-token).
  5. Рекомендуемый путь — Capacitor: обернуть существующий React-SPA в
    нативную оболочку (iOS + Android из одного кода), добавить нативные плагины
    (push, биометрия, share, файлы). Эволюция в гибрид (нативная навигация +
    WebView-редактор) делается потом инкрементально, без переписывания.
  6. Оффлайн-будущее уже заложено (Yjs + y-indexeddb). Детальный план —
    в offline-sync-plan.md; мобильное приложение этот
    план переиспользует, а не дублирует.
  7. Главный блокер — не технический, а лицензионный. AGPL форка несовместима
    с условиями App Store, если зашивать веб-клиент в бинарник: DRM/usage-rules
    Apple = «дополнительные ограничения», запрещённые AGPLv3 §10. Развязки —
    грузить клиент с сервера (не из .ipa), PWA или sideload. Детали и матрица —
    в §9; закрывать до кода обёртки.

2. Текущее состояние (как есть)

2.1. Стек

Слой Технологии
Бэкенд NestJS 11 + Fastify, Kysely/Postgres, Redis/BullMQ. API в стиле RPC-POST (соглашение Docmost). Аутентификация — JWT.
Фронт React 18 + Vite + Mantine + TanStack Query + i18next. Обычный SPA.
Ядро (редактор) TipTap 3 (ProseMirror) + совместное редактирование на Yjs через Hocuspocus — см. page-editor.tsx.
Оффлайн-фундамент yjs + y-indexeddb уже в зависимостях клиента (локальная CRDT-копия тела документа).

2.2. Мобильного приложения нет

В package.json и apps/*/package.json нет capacitor, react-native,
cordova, expo. Нативной оболочки в репозитории не заведено.

2.3. Адаптивная веб-версия — есть

Что Где
Адаптивная оболочка Mantine AppShell с breakpoint: "sm", раздельные состояния collapsed.mobile / collapsed.desktop global-app-shell.tsx (L85–99)
Отдельный мобильный сайдбар-drawer (mobileSidebarAtom отделён от desktopSidebarAtom), авто-закрытие при навигации по дереву sidebar-atom.ts, space-tree-row.tsx (L147–148)
Мобильная модалка истории + свой CSS history-modal.tsx (L17–19), history-modal-mobile.tsx
Мобильный контрол поиска search-control.tsx (L38–42)
Мобильный рендер хлебных крошек через useMediaQuery breadcrumb.tsx (L41)
Responsive-примитивы hiddenFrom/visibleFrom (~16 мест), медиа-запросы в CSS-модулях по всему apps/client/src
Mobile-tuned viewport (width=device-width, user-scalable=no) index.html (L8)

Важно: адаптив проверялся в мобильном браузере, а не в WebView нативной
оболочки. Перед сборкой приложения нужно прогнать UI как PWA/в WebView и
отловить отличия (жесты, экранная клавиатура/IME в редакторе, safe-area).

2.4. Готовность API к нативному клиенту

  • Bearer-токен уже поддержан. JWT извлекается из cookie или из заголовка
    Authorization: см. jwt.strategy.ts (L27–29).
    Серверная сторона нативной авторизации менять не нужно.
  • Токен сейчас не возвращается в теле логина. login
    (L55–105) кладёт JWT только в httpOnly-cookie (setAuthCookie L222–230).
  • Точка входа вебсокета коллаборации: POST /auth/collab-token (L187–193).
  • CORS открыт без конфигурации: app.enableCors() (L144).
  • OpenAPI/Swagger отсутствует (@nestjs/swagger не подключён) — авто-генерации
    типизированного клиента сейчас нет.

3. Почему путь к мобилке предопределён

Три факта диктуют решение независимо от моды:

  1. Редактор практически невозможно переписать нативно. ProseMirror + весь
    набор TipTap-расширений + Yjs-CRDT — это не «поле ввода». Нативного
    production-порта Yjs под Swift/Kotlin нет (есть Rust yrs с биндингами, но
    это отдельный тяжёлый проект). Переписывание ядра нативно = годы и вечное
    расхождение с веб-версией. Вывод: редактор остаётся в WebView.
  2. API уже умеет нативного клиента (Bearer, collab-token).
  3. Оффлайн-фундамент уже заложен на веб-уровне (Yjs + y-indexeddb),
    и он работает внутри WebView.

4. Три возможных пути

Путь Суть Плюсы Минусы Вердикт
A. Полностью нативно (Swift/Kotlin) Переписать всё, включая редактор и CRDT-синк Максимально нативный UX Воспроизвести ProseMirror + расширения + Yjs; несоразмерные трудозатраты; вечное отставание от веба Не наш случай
B. WebView-обёртка SPA (Capacitor) Обернуть существующий React-клиент в нативную оболочку, native-возможности — плагинами Реюз ~100% кода (редактор, коллаборация, оффлайн); один кодовый бэйз → iOS+Android; быстро Менее «нативно»; риск отказа App Store за «просто сайт» (4.2) — лечится нативной ценностью Рекомендуется
C. Гибрид: нативная оболочка + WebView-редактор Навигация/списки/поиск/логин — нативно (React Native/Swift), экран редактирования — web в WebView Лучший UX; путь Notion/Linear Заметно больше работы; нужен мост JS↔native ⚖️ Цель эволюции из B

5. Рекомендуемый путь

B (Capacitor) как первый релиз, с заложенной эволюцией в C.

Почему:

  • Capacitor создан под сценарий «есть веб-приложение → хочу его в App Store с
    нативными возможностями». Переиспользуется весь React-клиент и, главное,
    редактор — то, что нативно не сделать.
  • Один кодовый бэйз закрывает «iOS обязательно» и «Android как пойдёт»
    одновременно, без второй команды.
  • Адаптивная вёрстка уже есть (см. §2.3) — переверстывать под телефон с нуля
    не нужно; работа смещается в нативную обвязку.
  • Оффлайн-будущее подготовлено (Yjs + y-indexeddb); см.
    offline-sync-plan.md.
  • Когда упрётесь в UX отдельных экранов — их по одному выносят в нативную
    оболочку, оставив редактор в WebView. То есть B → C делается инкрементально.

Почему не чистый React Native сразу: редактор всё равно придётся держать в
WebView (ядро web-only), но при этом теряется прямой реюз остального React-кода
и появляется мост как обязательная сложность с первого дня — для iOS-first
старта это лишний оверхед.

Альтернатива: если критичен максимально нативный UX с первого релиза и есть
ресурс — сразу путь C на React Native (Expo) с WebView только под редактор.
Это сознательный размен «больше работы сейчас» за «более нативное ощущение».

⚠️ Лицензионная оговорка к iOS. Обычный Capacitor зашивает веб-билд
apps/client в .ipa — для публикации в App Store это нарушает AGPL
(см. §9). Выбор Capacitor для Android остаётся в силе, но на iOS
веб-клиент нельзя бандлить в бинарник: либо грузить его с сервера
(server.url), либо PWA. То есть рекомендация «B (Capacitor)» применима к
Android как есть, а к iOS — только в конфигурации без зашитого AGPL.


6. Что доработать на бэкенде

Немного, но конкретно:

  1. Выдача токена в теле ответа для нативного хранения. Сейчас логин кладёт
    JWT только в httpOnly-cookie и не возвращает его в body. На мобиле
    httpOnly-cookie между разными origin (capacitor://localhost ↔ API) — боль
    с SameSite/CORS. Чище: мобильный логин-флоу, возвращающий JWT в ответе, чтобы
    хранить его в Keychain/Keystore и слать как Authorization: Bearer. Сервер
    уже принимает Bearer — менять надо только выдачу.
    Файлы: auth.controller.ts.
  2. CORS. Сейчас app.enableCors() (L144) без
    конфигурации. Под мобильные origin'ы и для безопасности задать явный whitelist.
  3. Push-уведомления. Модуль notification уже есть — добавить регистрацию
    device-token и интеграцию APNs (iOS) / FCM (Android).
  4. Опционально — OpenAPI/Swagger. Сейчас спецификации нет; добавить
    @nestjs/swagger дёшево и сильно ускорит мобильную разработку
    (типизированный клиент).

7. Android-специфика

На пути Capacitor Android едет почти бесплатно (npx cap add android из того же
веб-билда), но есть нюансы:

  • Движок в плюс. Android System WebView (Chromium) обновляется через Play
    Store независимо от ОС и обычно свежее iOS WKWebView. Более рискованный движок
    по совместимости — это iOS, а не Android.
  • Фрагментация. Дешёвые/старые устройства с малой памятью и устаревшим
    WebView; стек тяжёлый (ProseMirror + Yjs + mermaid + katex + excalidraw) —
    тестировать на бюджетных аппаратах.
  • Обвязка под Android: аппаратная/жестовая кнопка «Назад» (навигация внутри
    приложения, а не выход), FCM для push, Android App Links (вместо iOS
    Universal Links), подписание и Play Console.
  • Главный риск именно для Android — ввод текста в ProseMirror на Gboard/IME.
    Историческая боль contenteditable на Android (прыжки курсора, дубли символов
    при композиции). Стало лучше, но проверять в первую очередь и рано.
  • Магазин. Google Play лояльнее к webview-обёрткам, чем App Store; риск
    «отклонят как просто сайт» для Play практически неактуален.

8. iOS-специфика

  • WKWebView на движке WebKit жёстко привязан к версии ОС — это более
    рискованный по совместимости движок (тестировать прежде всего его).
  • App Store guideline 4.2 (minimum functionality). Чистая webview-обёртка
    рискует отклонением «это просто сайт». Лечится реальной нативной ценностью:
    push, share-extension, биометрический разблок, оффлайн-кэш — всё это Capacitor
    даёт плагинами.
  • safe-area под «чёлку»/системные панели, поведение экранной клавиатуры в
    редакторе.

9. Лицензионный блокер: AGPL ↔ App Store (iOS)

Это не инженерная, а лицензионная задача — закрывать её надо до кода
обёртки, иначе можно сделать приложение, которое некуда легально опубликовать.
Ниже — инженерно-лицензионный разбор, не юридическая консультация; финально
подтверждать у того, кто разбирается в лицензиях.

9.1. Суть конфликта

gitmost — форк Docmost под AGPL-3.0 (константа форка: «100% open, AGPL-only»).
Две вещи несовместимы:

  • AGPLv3 §10 (последний абзац) запрещает накладывать на получателя кода
    любые дополнительные ограничения сверх самой лицензии.
  • Стандартный EULA App Store ровно их и накладывает: FairPlay/DRM,
    привязка установки к Apple ID с лимитом устройств (usage rules), запрет
    свободного перераспространения бинарника.

Приняв условия Apple, чтобы попасть в App Store, вы нарушаете AGPL кода, который
раздаёте.

9.2. Почему это бьёт именно по форку

Запрет «дополнительных ограничений» связывает лицензиатов, но не самого
правообладателя
: владелец 100% копирайта может опубликовать свой код в App Store.
Но в gitmost бóльшая часть копирайта принадлежит upstream-Docmost и
контрибьюторам — вы выступаете дистрибьютором чужого AGPL-кода и не можете
единолично добавить App-Store-исключение.

Прецеденты: VLC (удалён из App Store в 2011 по жалобе на конфликт GPL с
условиями стора; вернулся только после перелицензирования и согласия
правообладателей), GNU Go — снят по той же причине. Это не теоретический риск.

9.3. Ключевой принцип развязки: лицензия смотрит на .ipa, а не на устройство

Определяющее — что раздаёт сам Apple (.ipa под FairPlay) и кто раздаёт
AGPL-байты
, а не то, окажутся ли они в итоге на устройстве:

  • AGPL внутри .ipa → получен под ограничениями Apple → нарушение.
  • AGPL скачан с вашего сервера → получен от вас под AGPL (исходники открыты,
    §13 выполнен) → ограничения Apple на него не накладываются, даже если бандл
    кэшируется в песочнице приложения.

Следствие: офлайн на iOS легально достижим — если кэшированный бандл пришёл с
вашего сервера, а не из .ipa. Ограничение тут не лицензионное, а в ревью
Apple
(см. §9.5).

9.4. Варианты «грузить веб-клиент с сервера»

A. WebView навигируется на хостед-клиент (server.url). Capacitor умеет
server: { url: 'https://app.example.com' } — оболочка грузит WebView с удалённого
URL, мост и нативные плагины по-прежнему инжектятся. В .ipa — ноль AGPL.

  • Плюс: лицензионно самый чистый; origin = ваш домен, поэтому cookie/CORS
    работают как в браузере (боль capacitor://localhost ↔ API из §6 исчезает —
    токен в body/Keychain может и не понадобиться).
  • Минус: холодный старт требует сети; сервер лёг → приложение кирпич; офлайна по
    умолчанию нет.

B. OTA: пустой шелл скачивает и кэширует бандл. Шелл при первом запуске тянет
JS-бандл с вашего сервера и кэширует как веб-ассеты (механизм Cordova/CodePush).
Open-source self-host-вариант — @capgo/capacitor-updater (важно для AGPL-проекта:
без привязки к проприетарному Appflow).

  • Плюс: даёт офлайн — кэш AGPL легален, т.к. распространён вами, а не Apple.
  • Минус: упирается в политику Apple по hot-update (§9.5).

Не-обходы (мифы): «никто не засудит» — это нарушение, а не обход; «LGPL-нуть
обёртку» — не помогает (проблема в AGPL-веб-клиенте, а не в обёртке); «mere
aggregation» — не катит: зашитый бандл это комбинированное распространяемое
произведение, а не простая агрегация.

9.5. Гейты Apple

# Guideline Суть Влияние
1 2.5.2 (исполняемый код) Скачивать/исполнять нативный код нельзя, но есть исключение для скриптов, исполняемых встроенным WebKit/JavascriptCore, если они не меняют назначение приложения Загрузка веб-клиента в WKWebView под исключение попадает: вариант A — чистый, B — терпимый, но с границами
2 4.2 (minimum functionality) Чистый WebView-«просто сайт» рискует отклонением Лечится нативной ценностью в оболочке (push/APNs, биометрия, share, файлы — ваш нативный код, не AGPL)
3 конфликт двух гейтов «Лицензионно чистый» вариант (пустой шелл качает всё) — самый рискованный для ревью; «безопасный для ревью» (зашить веб-билд в .ipa) — лицензионное нарушение Совместить (офлайн) + (чистая AGPL) + (низкий риск ревью) в одной конфигурации нельзя — выбираете любые два

Безопасность: раз исполняете удалённый код — только HTTPS, желательно cert-pinning
(подмена сервера = произвольный JS в WebView пользователя).

9.6. Итоговая матрица распространения iOS

Конфигурация AGPL-чистота Офлайн Риск ревью Apple
A. server.url на хостед-клиент чистая нет средний (4.2, лечится плагинами)
B. OTA пустой шелл + кэш бандла чистая есть выше (2.5.2 + 4.2)
Зашить веб-билд в .ipa (обычный Capacitor) нарушение низкий
PWA чистая App Store не нужен
Sideload / EU DMA-маркетплейсы (iOS 17.4+) чистая вне App Store; только ЕС

Вывод: для iOS PWA — самое дешёвое решение, закрывающее всё сразу. Если
присутствие именно в App Store критично — вариант A (server.url + нативные
плагины под 4.2) легальный и реалистичный ценой «онлайн для холодного старта».
Офлайн в App Store (вариант B) технически и лицензионно возможен, но это
максимальный риск на ревью — закладывать только если офлайн на iOS обязателен.
Совместить «App Store + зашитый офлайн AGPL» легально нельзя, пока копирайт не ваш.


10. Оффлайн в будущем

Оффлайн сейчас не требуется, но позиция хорошая:

  • Тело документа уже редактируется через Yjs (CRDT) + y-indexeddb — локальная
    копия и автослияние правок работают, в том числе в WebView.
  • «Полностью онлайн» — это всё вокруг тела (навигация, заголовки, комментарии,
    CRUD, вложения, авторизация). Их оффлайн-синхронизация описана отдельным
    планом с этапами M0…M4 — см. offline-sync-plan.md.
  • Мобильное приложение переиспользует этот план, а не строит оффлайн заново.
    Нюанс Android: System WebView под нехваткой места может чистить хранилище →
    для оффлайна, возможно, понадобится дублировать критичные данные в нативное
    хранилище, чтобы локальные копии не вычищались.

11. Открытые вопросы (зафиксировать до старта)

  • Q1. Путь: Capacitor (B) с эволюцией в гибрид, или сразу React Native (C)?
    Рекомендация — B.
  • Q2. Мобильная авторизация: отдельный логин-флоу с токеном в body + Keychain/
    Keystore + Bearer (рекомендуется) или попытка работать через cookie в WebView?
  • Q3. Push: APNs + FCM сразу или iOS-first?
  • Q4. Подключать ли OpenAPI/Swagger для генерации мобильного клиента?
  • Q5. Когда включать оффлайн (M0…M4 из offline-sync-plan.md) относительно
    первого мобильного релиза?
  • Q6. iOS-дистрибуция при AGPL (§9): App Store через server.url
    (онлайн-клиент, без зашитого AGPL), PWA или sideload/EU-маркетплейсы? Этот
    лицензионный путь нужно подтвердить до кода обёртки. Рекомендация — PWA для
    iOS, Capacitor для Android.

12. Чеклист первого шага (бутстрап Capacitor, iOS-first)

  • Закрыть лицензионный путь iOS (§9) ДО кода обёртки: выбрать
    server.url / PWA / sideload и подтвердить у разбирающегося в лицензиях.
  • Не бандлить AGPL-веб-клиент в iOS .ipa (DRM/usage-rules App Store ⟂
    AGPLv3 §10) — на iOS грузить клиент с сервера или идти через PWA.
  • Прогнать существующий адаптивный UI как PWA/в WebView, отловить отличия
    (жесты, IME в редакторе, safe-area).
  • Добавить Capacitor в монорепо, нацелить на веб-билд apps/client
    (Android — зашитый билд; iOS — server.url/PWA без зашитого AGPL, см. §9).
  • npx cap add ios (Android — npx cap add android, когда будет готова обвязка).
  • Бэкенд: мобильный логин-флоу с токеном в body; хранить токен в Keychain/
    Keystore; слать Authorization: Bearer.
  • Бэкенд: явный CORS-whitelist под мобильные origin'ы.
  • Native-плагины под App Store 4.2: push, биометрия, share, файлы.
  • Push: APNs (iOS); FCM добавить вместе с Android.
  • Проверить вебсокет коллаборации из WebView (/auth/collab-token + Hocuspocus).
  • (Опционально) Подключить @nestjs/swagger.

ЧАСТЬ 2. OFFLINE-РЕЖИМ И СИНХРОНИЗАЦИЯ

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 (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. На 401redirectToLogin(). Важно для оффлайна:
    редирект на логин при сетевой ошибке недопустим (см. 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 установленное приложение без сети не загрузится.
Это разблокирует всё остальное.

Что сделать:

  1. Добавить 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).
  2. Зарегистрировать SW в 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
    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.
    Показать состояние («оффлайн», «есть несинхронизированные правки»,
    «синхронизировано»).
  3. Заголовок страницы → в Yjs (рекомендуется).
    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 в
    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.
    Для позиций в дереве — generateJitteredKeyBetween из
    fractional-indexing-jittered (тот же пакет, что на сервере).
  3. Идемпотентный upsert на сервере. Эндпоинты /pages/create,
    /comments/create и т.д. должны принимать клиентский id и быть
    идемпотентными по нему (повторная отправка из очереди не должна плодить
    дубликаты). Точки входа:
    page-service.ts,
    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 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. Подводные камни (читать до старта)

  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. Редирект на логин при сетевой ошибке. Сейчас 401redirectToLogin.
    Оффлайн это выкинет пользователя и потеряет контекст — чинить в 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 и /collabNetworkOnly.
  • 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: авторизация толерантна к оффлайну (нет редиректа на логин при отсутствии сети).
> Перенесено из `docs/mobile-app-plan.md` и `docs/offline-sync-plan.md` (оба файла удалены; этот issue — единственный носитель планов). > > Планы объединены в один issue сознательно: мобильное приложение **переиспользует** offline-план (этапы M0…M4), а не строит оффлайн заново — см. §10 мобильного плана и весь offline-план. Ниже — оба документа целиком: сначала мобильный план, затем offline-план. --- # ЧАСТЬ 1. МОБИЛЬНОЕ ПРИЛОЖЕНИЕ # Мобильное приложение gitmost — исследование и план > Статус: исследовательский + проектный документ. > Контекст: gitmost — форк Docmost, чистое веб-приложение. Отдельного > мобильного (нативного/устанавливаемого) приложения **нет**. > Цель: определить путь к мобильным приложениям — **iOS обязательно, Android > как пойдёт** — с заделом на оффлайн в будущем (оффлайн сейчас не требуется). Документ фиксирует, что уже есть в коде, почему путь к мобилке предопределён устройством продукта, сравнивает варианты и описывает рекомендуемый план с привязкой к файлам. --- ## 1. TL;DR 1. **Нативного приложения нет.** В проекте отсутствуют Capacitor, React Native, Cordova и т.п. Мобильного клиента ещё не начинали. 2. **Адаптивная веб-версия — есть, и довольно проработанная.** Веб-клиент открывается с телефона как mobile-friendly сайт: сворачиваемый сайдбар-drawer, отдельные мобильные компоненты (история, поиск, хлебные крошки), responsive- примитивы Mantine, mobile-tuned `viewport`. Это готовый фундамент UI. 3. **Ядро продукта — веб-редактор — нативно не воспроизвести.** TipTap 3 (ProseMirror) + совместное редактирование на Yjs/Hocuspocus плотно сшиты с React. Production-порта Yjs под Swift/Kotlin нет. Любой реалистичный путь оставляет редактор в **WebView**. 4. **API уже готов к нативному клиенту.** Сервер принимает JWT не только из cookie, но и из заголовка `Authorization: Bearer`. Есть точка входа для вебсокета совместного редактирования (`POST /auth/collab-token`). 5. **Рекомендуемый путь — Capacitor:** обернуть существующий React-SPA в нативную оболочку (iOS + Android из одного кода), добавить нативные плагины (push, биометрия, share, файлы). Эволюция в гибрид (нативная навигация + WebView-редактор) делается потом инкрементально, без переписывания. 6. **Оффлайн-будущее уже заложено** (Yjs + `y-indexeddb`). Детальный план — в [offline-sync-plan.md](offline-sync-plan.md); мобильное приложение этот план переиспользует, а не дублирует. 7. **Главный блокер — не технический, а лицензионный.** AGPL форка несовместима с условиями App Store, если зашивать веб-клиент в бинарник: DRM/usage-rules Apple = «дополнительные ограничения», запрещённые AGPLv3 §10. Развязки — грузить клиент с сервера (не из `.ipa`), PWA или sideload. Детали и матрица — в §9; закрывать **до** кода обёртки. --- ## 2. Текущее состояние (как есть) ### 2.1. Стек | Слой | Технологии | |---|---| | Бэкенд | NestJS 11 + Fastify, Kysely/Postgres, Redis/BullMQ. API в стиле RPC-POST (соглашение Docmost). Аутентификация — JWT. | | Фронт | React 18 + Vite + Mantine + TanStack Query + i18next. Обычный SPA. | | Ядро (редактор) | TipTap 3 (ProseMirror) + совместное редактирование на Yjs через Hocuspocus — см. [page-editor.tsx](../apps/client/src/features/editor/page-editor.tsx). | | Оффлайн-фундамент | `yjs` + `y-indexeddb` уже в зависимостях клиента (локальная CRDT-копия тела документа). | ### 2.2. Мобильного приложения нет В `package.json` и `apps/*/package.json` нет `capacitor`, `react-native`, `cordova`, `expo`. Нативной оболочки в репозитории не заведено. ### 2.3. Адаптивная веб-версия — есть | Что | Где | |---|---| | Адаптивная оболочка Mantine `AppShell` с `breakpoint: "sm"`, раздельные состояния `collapsed.mobile` / `collapsed.desktop` | [global-app-shell.tsx](../apps/client/src/components/layouts/global/global-app-shell.tsx) (L85–99) | | Отдельный мобильный сайдбар-drawer (`mobileSidebarAtom` отделён от `desktopSidebarAtom`), авто-закрытие при навигации по дереву | [sidebar-atom.ts](../apps/client/src/components/layouts/global/hooks/atoms/sidebar-atom.ts), [space-tree-row.tsx](../apps/client/src/features/page/tree/components/space-tree-row.tsx) (L147–148) | | Мобильная модалка истории + свой CSS | [history-modal.tsx](../apps/client/src/features/page-history/components/history-modal.tsx) (L17–19), `history-modal-mobile.tsx` | | Мобильный контрол поиска | [search-control.tsx](../apps/client/src/features/search/components/search-control.tsx) (L38–42) | | Мобильный рендер хлебных крошек через `useMediaQuery` | [breadcrumb.tsx](../apps/client/src/features/page/components/breadcrumbs/breadcrumb.tsx) (L41) | | Responsive-примитивы `hiddenFrom`/`visibleFrom` (~16 мест), медиа-запросы в CSS-модулях | по всему `apps/client/src` | | Mobile-tuned viewport (`width=device-width, user-scalable=no`) | [index.html](../apps/client/index.html) (L8) | > Важно: адаптив проверялся в мобильном **браузере**, а не в WebView нативной > оболочки. Перед сборкой приложения нужно прогнать UI как PWA/в WebView и > отловить отличия (жесты, экранная клавиатура/IME в редакторе, safe-area). ### 2.4. Готовность API к нативному клиенту - **Bearer-токен уже поддержан.** JWT извлекается из cookie **или** из заголовка `Authorization`: см. [jwt.strategy.ts](../apps/server/src/core/auth/strategies/jwt.strategy.ts) (L27–29). Серверная сторона нативной авторизации менять не нужно. - **Токен сейчас не возвращается в теле логина.** [`login`](../apps/server/src/core/auth/auth.controller.ts) (L55–105) кладёт JWT только в `httpOnly`-cookie ([`setAuthCookie`](../apps/server/src/core/auth/auth.controller.ts) L222–230). - **Точка входа вебсокета коллаборации:** [`POST /auth/collab-token`](../apps/server/src/core/auth/auth.controller.ts) (L187–193). - **CORS открыт без конфигурации:** [`app.enableCors()`](../apps/server/src/main.ts) (L144). - **OpenAPI/Swagger отсутствует** (`@nestjs/swagger` не подключён) — авто-генерации типизированного клиента сейчас нет. --- ## 3. Почему путь к мобилке предопределён Три факта диктуют решение независимо от моды: 1. **Редактор практически невозможно переписать нативно.** ProseMirror + весь набор TipTap-расширений + Yjs-CRDT — это не «поле ввода». Нативного production-порта Yjs под Swift/Kotlin нет (есть Rust `yrs` с биндингами, но это отдельный тяжёлый проект). Переписывание ядра нативно = годы и вечное расхождение с веб-версией. **Вывод: редактор остаётся в WebView.** 2. **API уже умеет нативного клиента** (Bearer, collab-token). 3. **Оффлайн-фундамент уже заложен** на веб-уровне (Yjs + `y-indexeddb`), и он работает внутри WebView. --- ## 4. Три возможных пути | Путь | Суть | Плюсы | Минусы | Вердикт | |---|---|---|---|---| | **A. Полностью нативно** (Swift/Kotlin) | Переписать всё, включая редактор и CRDT-синк | Максимально нативный UX | Воспроизвести ProseMirror + расширения + Yjs; несоразмерные трудозатраты; вечное отставание от веба | ❌ Не наш случай | | **B. WebView-обёртка SPA (Capacitor)** | Обернуть существующий React-клиент в нативную оболочку, native-возможности — плагинами | Реюз ~100% кода (редактор, коллаборация, оффлайн); один кодовый бэйз → iOS+Android; быстро | Менее «нативно»; риск отказа App Store за «просто сайт» (4.2) — лечится нативной ценностью | ✅ Рекомендуется | | **C. Гибрид: нативная оболочка + WebView-редактор** | Навигация/списки/поиск/логин — нативно (React Native/Swift), экран редактирования — web в WebView | Лучший UX; путь Notion/Linear | Заметно больше работы; нужен мост JS↔native | ⚖️ Цель эволюции из B | --- ## 5. Рекомендуемый путь **B (Capacitor) как первый релиз, с заложенной эволюцией в C.** Почему: - Capacitor создан под сценарий «есть веб-приложение → хочу его в App Store с нативными возможностями». Переиспользуется весь React-клиент и, главное, редактор — то, что нативно не сделать. - Один кодовый бэйз закрывает «iOS обязательно» и «Android как пойдёт» одновременно, без второй команды. - Адаптивная вёрстка уже есть (см. §2.3) — переверстывать под телефон с нуля не нужно; работа смещается в нативную обвязку. - Оффлайн-будущее подготовлено (Yjs + `y-indexeddb`); см. [offline-sync-plan.md](offline-sync-plan.md). - Когда упрётесь в UX отдельных экранов — их по одному выносят в нативную оболочку, оставив редактор в WebView. То есть B → C делается инкрементально. Почему **не** чистый React Native сразу: редактор всё равно придётся держать в WebView (ядро web-only), но при этом теряется прямой реюз остального React-кода и появляется мост как обязательная сложность с первого дня — для iOS-first старта это лишний оверхед. > Альтернатива: если критичен максимально нативный UX с первого релиза и есть > ресурс — сразу путь C на React Native (Expo) с WebView только под редактор. > Это сознательный размен «больше работы сейчас» за «более нативное ощущение». ⚠️ **Лицензионная оговорка к iOS.** Обычный Capacitor зашивает веб-билд `apps/client` в `.ipa` — для публикации в App Store это **нарушает AGPL** (см. §9). Выбор Capacitor для **Android** остаётся в силе, но на **iOS** веб-клиент нельзя бандлить в бинарник: либо грузить его с сервера (`server.url`), либо PWA. То есть рекомендация «B (Capacitor)» применима к Android как есть, а к iOS — только в конфигурации без зашитого AGPL. --- ## 6. Что доработать на бэкенде Немного, но конкретно: 1. **Выдача токена в теле ответа для нативного хранения.** Сейчас логин кладёт JWT только в `httpOnly`-cookie и не возвращает его в body. На мобиле `httpOnly`-cookie между разными origin (`capacitor://localhost` ↔ API) — боль с SameSite/CORS. Чище: мобильный логин-флоу, возвращающий JWT в ответе, чтобы хранить его в Keychain/Keystore и слать как `Authorization: Bearer`. Сервер уже принимает Bearer — менять надо только **выдачу**. Файлы: [auth.controller.ts](../apps/server/src/core/auth/auth.controller.ts). 2. **CORS.** Сейчас [`app.enableCors()`](../apps/server/src/main.ts) (L144) без конфигурации. Под мобильные origin'ы и для безопасности задать явный whitelist. 3. **Push-уведомления.** Модуль `notification` уже есть — добавить регистрацию device-token и интеграцию **APNs** (iOS) / **FCM** (Android). 4. **Опционально — OpenAPI/Swagger.** Сейчас спецификации нет; добавить `@nestjs/swagger` дёшево и сильно ускорит мобильную разработку (типизированный клиент). --- ## 7. Android-специфика На пути Capacitor Android едет почти бесплатно (`npx cap add android` из того же веб-билда), но есть нюансы: - **Движок в плюс.** Android System WebView (Chromium) обновляется через Play Store независимо от ОС и обычно свежее iOS WKWebView. Более рискованный движок по совместимости — это iOS, а не Android. - **Фрагментация.** Дешёвые/старые устройства с малой памятью и устаревшим WebView; стек тяжёлый (ProseMirror + Yjs + mermaid + katex + excalidraw) — тестировать на бюджетных аппаратах. - **Обвязка под Android:** аппаратная/жестовая кнопка «Назад» (навигация внутри приложения, а не выход), **FCM** для push, Android App Links (вместо iOS Universal Links), подписание и Play Console. - **Главный риск именно для Android — ввод текста в ProseMirror на Gboard/IME.** Историческая боль `contenteditable` на Android (прыжки курсора, дубли символов при композиции). Стало лучше, но **проверять в первую очередь и рано**. - **Магазин.** Google Play лояльнее к webview-обёрткам, чем App Store; риск «отклонят как просто сайт» для Play практически неактуален. --- ## 8. iOS-специфика - **WKWebView** на движке WebKit жёстко привязан к версии ОС — это более рискованный по совместимости движок (тестировать прежде всего его). - **App Store guideline 4.2 (minimum functionality).** Чистая webview-обёртка рискует отклонением «это просто сайт». Лечится реальной нативной ценностью: push, share-extension, биометрический разблок, оффлайн-кэш — всё это Capacitor даёт плагинами. - **safe-area** под «чёлку»/системные панели, поведение экранной клавиатуры в редакторе. --- ## 9. Лицензионный блокер: AGPL ↔ App Store (iOS) > Это не инженерная, а **лицензионная** задача — закрывать её надо **до** кода > обёртки, иначе можно сделать приложение, которое некуда легально опубликовать. > Ниже — инженерно-лицензионный разбор, **не** юридическая консультация; финально > подтверждать у того, кто разбирается в лицензиях. ### 9.1. Суть конфликта gitmost — форк Docmost под **AGPL-3.0** (константа форка: «100% open, AGPL-only»). Две вещи несовместимы: - **AGPLv3 §10** (последний абзац) запрещает накладывать на получателя кода **любые дополнительные ограничения** сверх самой лицензии. - **Стандартный EULA App Store** ровно их и накладывает: **FairPlay/DRM**, привязка установки к Apple ID с лимитом устройств (**usage rules**), запрет свободного перераспространения бинарника. Приняв условия Apple, чтобы попасть в App Store, вы нарушаете AGPL кода, который раздаёте. ### 9.2. Почему это бьёт именно по форку Запрет «дополнительных ограничений» связывает **лицензиатов, но не самого правообладателя**: владелец 100% копирайта может опубликовать свой код в App Store. Но в gitmost бóльшая часть копирайта принадлежит **upstream-Docmost** и контрибьюторам — вы выступаете дистрибьютором *чужого* AGPL-кода и не можете единолично добавить App-Store-исключение. Прецеденты: **VLC** (удалён из App Store в 2011 по жалобе на конфликт GPL с условиями стора; вернулся только после перелицензирования и согласия правообладателей), **GNU Go** — снят по той же причине. Это не теоретический риск. ### 9.3. Ключевой принцип развязки: лицензия смотрит на `.ipa`, а не на устройство Определяющее — **что раздаёт сам Apple** (`.ipa` под FairPlay) и **кто раздаёт AGPL-байты**, а не то, окажутся ли они в итоге на устройстве: - AGPL **внутри `.ipa`** → получен под ограничениями Apple → **нарушение**. - AGPL **скачан с вашего сервера** → получен от вас под AGPL (исходники открыты, §13 выполнен) → ограничения Apple на него **не** накладываются, даже если бандл кэшируется в песочнице приложения. Следствие: **офлайн на iOS легально достижим** — если кэшированный бандл пришёл с вашего сервера, а не из `.ipa`. Ограничение тут не лицензионное, а в **ревью Apple** (см. §9.5). ### 9.4. Варианты «грузить веб-клиент с сервера» **A. WebView навигируется на хостед-клиент (`server.url`).** Capacitor умеет `server: { url: 'https://app.example.com' }` — оболочка грузит WebView с удалённого URL, мост и нативные плагины по-прежнему инжектятся. В `.ipa` — ноль AGPL. - Плюс: лицензионно самый чистый; **origin = ваш домен**, поэтому cookie/CORS работают как в браузере (боль `capacitor://localhost` ↔ API из §6 исчезает — токен в body/Keychain может и не понадобиться). - Минус: холодный старт требует сети; сервер лёг → приложение кирпич; офлайна по умолчанию нет. **B. OTA: пустой шелл скачивает и кэширует бандл.** Шелл при первом запуске тянет JS-бандл с вашего сервера и кэширует как веб-ассеты (механизм Cordova/CodePush). Open-source self-host-вариант — `@capgo/capacitor-updater` (важно для AGPL-проекта: без привязки к проприетарному Appflow). - Плюс: **даёт офлайн** — кэш AGPL легален, т.к. распространён вами, а не Apple. - Минус: упирается в политику Apple по hot-update (§9.5). **Не-обходы (мифы):** «никто не засудит» — это нарушение, а не обход; «LGPL-нуть обёртку» — не помогает (проблема в AGPL-веб-клиенте, а не в обёртке); «mere aggregation» — не катит: зашитый бандл это комбинированное распространяемое произведение, а не простая агрегация. ### 9.5. Гейты Apple | # | Guideline | Суть | Влияние | |---|---|---|---| | 1 | **2.5.2** (исполняемый код) | Скачивать/исполнять **нативный** код нельзя, **но** есть исключение для скриптов, исполняемых встроенным WebKit/JavascriptCore, если они не меняют назначение приложения | Загрузка веб-клиента в `WKWebView` под исключение попадает: вариант A — чистый, B — терпимый, но с границами | | 2 | **4.2** (minimum functionality) | Чистый WebView-«просто сайт» рискует отклонением | Лечится нативной ценностью в оболочке (push/APNs, биометрия, share, файлы — ваш нативный код, не AGPL) | | 3 | конфликт двух гейтов | «Лицензионно чистый» вариант (пустой шелл качает всё) — самый рискованный для ревью; «безопасный для ревью» (зашить веб-билд в `.ipa`) — лицензионное нарушение | **Совместить (офлайн) + (чистая AGPL) + (низкий риск ревью) в одной конфигурации нельзя — выбираете любые два** | Безопасность: раз исполняете удалённый код — только HTTPS, желательно cert-pinning (подмена сервера = произвольный JS в WebView пользователя). ### 9.6. Итоговая матрица распространения iOS | Конфигурация | AGPL-чистота | Офлайн | Риск ревью Apple | |---|---|---|---| | A. `server.url` на хостед-клиент | ✅ чистая | ❌ нет | средний (4.2, лечится плагинами) | | B. OTA пустой шелл + кэш бандла | ✅ чистая | ✅ есть | выше (2.5.2 + 4.2) | | Зашить веб-билд в `.ipa` (обычный Capacitor) | ❌ нарушение | ✅ | низкий | | **PWA** | ✅ чистая | ✅ | App Store не нужен | | Sideload / EU DMA-маркетплейсы (iOS 17.4+) | ✅ чистая | ✅ | вне App Store; **только ЕС** | **Вывод:** для iOS **PWA** — самое дешёвое решение, закрывающее всё сразу. Если присутствие именно в App Store критично — **вариант A** (`server.url` + нативные плагины под 4.2) легальный и реалистичный ценой «онлайн для холодного старта». Офлайн в App Store (вариант B) технически и лицензионно возможен, но это максимальный риск на ревью — закладывать только если офлайн на iOS обязателен. Совместить «App Store + зашитый офлайн AGPL» легально нельзя, пока копирайт не ваш. --- ## 10. Оффлайн в будущем Оффлайн сейчас не требуется, но позиция хорошая: - Тело документа уже редактируется через Yjs (CRDT) + `y-indexeddb` — локальная копия и автослияние правок работают, в том числе в WebView. - «Полностью онлайн» — это всё вокруг тела (навигация, заголовки, комментарии, CRUD, вложения, авторизация). Их оффлайн-синхронизация описана отдельным планом с этапами M0…M4 — см. [offline-sync-plan.md](offline-sync-plan.md). - Мобильное приложение **переиспользует** этот план, а не строит оффлайн заново. Нюанс Android: System WebView под нехваткой места может чистить хранилище → для оффлайна, возможно, понадобится дублировать критичные данные в нативное хранилище, чтобы локальные копии не вычищались. --- ## 11. Открытые вопросы (зафиксировать до старта) - **Q1.** Путь: Capacitor (B) с эволюцией в гибрид, или сразу React Native (C)? Рекомендация — B. - **Q2.** Мобильная авторизация: отдельный логин-флоу с токеном в body + Keychain/ Keystore + Bearer (рекомендуется) или попытка работать через cookie в WebView? - **Q3.** Push: APNs + FCM сразу или iOS-first? - **Q4.** Подключать ли OpenAPI/Swagger для генерации мобильного клиента? - **Q5.** Когда включать оффлайн (M0…M4 из offline-sync-plan.md) относительно первого мобильного релиза? - **Q6.** iOS-дистрибуция при AGPL (§9): App Store через `server.url` (онлайн-клиент, без зашитого AGPL), PWA или sideload/EU-маркетплейсы? Этот лицензионный путь нужно подтвердить **до** кода обёртки. Рекомендация — PWA для iOS, Capacitor для Android. --- ## 12. Чеклист первого шага (бутстрап Capacitor, iOS-first) - [ ] **Закрыть лицензионный путь iOS (§9) ДО кода обёртки:** выбрать `server.url` / PWA / sideload и подтвердить у разбирающегося в лицензиях. - [ ] **Не бандлить AGPL-веб-клиент в iOS `.ipa`** (DRM/usage-rules App Store ⟂ AGPLv3 §10) — на iOS грузить клиент с сервера или идти через PWA. - [ ] Прогнать существующий адаптивный UI как PWA/в WebView, отловить отличия (жесты, IME в редакторе, safe-area). - [ ] Добавить Capacitor в монорепо, нацелить на веб-билд `apps/client` (Android — зашитый билд; iOS — `server.url`/PWA без зашитого AGPL, см. §9). - [ ] `npx cap add ios` (Android — `npx cap add android`, когда будет готова обвязка). - [ ] Бэкенд: мобильный логин-флоу с токеном в body; хранить токен в Keychain/ Keystore; слать `Authorization: Bearer`. - [ ] Бэкенд: явный CORS-whitelist под мобильные origin'ы. - [ ] Native-плагины под App Store 4.2: push, биометрия, share, файлы. - [ ] Push: APNs (iOS); FCM добавить вместе с Android. - [ ] Проверить вебсокет коллаборации из WebView (`/auth/collab-token` + Hocuspocus). - [ ] (Опционально) Подключить `@nestjs/swagger`. --- # ЧАСТЬ 2. OFFLINE-РЕЖИМ И СИНХРОНИЗАЦИЯ # 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: авторизация толерантна к оффлайну (нет редиректа на логин при отсутствии сети).
claude_code added the featureepic labels 2026-06-25 22:40:57 +03:00
Author
Collaborator

Частично реализуется в PR #120 (feat(offline): offline-sync (M0–M2) + mobile-app-bootstrap, ветка feature/offline-sync). PR закрывает offline-этапы M0–M2 и базовый bootstrap мобильного приложения; остальное (M3–M4 и полноценная мобильная обвязка из плана) остаётся в этом issue.

Частично реализуется в PR #120 (feat(offline): offline-sync (M0–M2) + mobile-app-bootstrap, ветка feature/offline-sync). PR закрывает offline-этапы M0–M2 и базовый bootstrap мобильного приложения; остальное (M3–M4 и полноценная мобильная обвязка из плана) остаётся в этом issue.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#195