WIP: feat(offline): offline-sync (M0–M2) + mobile-app-bootstrap, unified service worker #120
Draft
Ghost
wants to merge 18 commits from
feature/offline-sync into develop
pull from: feature/offline-sync
merge into: vvzvlad:develop
vvzvlad:main
vvzvlad:test/244-part-b
vvzvlad:fix/255-ws-redis-adapter-leak
vvzvlad:feat/251-intentional-clear
vvzvlad:fix/252-e2e-open-handles
vvzvlad:feat/184-autonomous-agent-runs
vvzvlad:feat/221-image-captions
vvzvlad:feat/git-sync
vvzvlad:refactor/193-tool-spec-registry
vvzvlad:fix/244-dataloss-bugs
vvzvlad:fix/embeddings-reindex-progress
vvzvlad:develop
vvzvlad:feat/229-catalog-yaml
vvzvlad:feat/243-blob-sandbox
vvzvlad:feat/228-inline-footnotes
vvzvlad:fix/qa-ui-bugs-216-218
vvzvlad:feature/agent-roles-catalog
vvzvlad:fix/share-alias-rename
vvzvlad:fix/ai-chat-empty-render
vvzvlad:feat/191-chat-doc-binding
vvzvlad:feat/201-temporary-notes
vvzvlad:feat/198-interrupt-agent
vvzvlad:feat/ai-chat-full-history
vvzvlad:feat/199-ai-generate-title
vvzvlad:feat/205-share-aliases
vvzvlad:batch/issues-189-187-170
vvzvlad:feat/170-mcp-test-button
vvzvlad:feat/189-context-badge
vvzvlad:feat/198-interrupt-agent-send-now
vvzvlad:fix/issues-190-159
vvzvlad:fix/ai-chat-new-chat-during-stream
vvzvlad:fix/ai-chat-stream-perf
vvzvlad:batch/issues-2026-06-25
vvzvlad:feat/ai-chat-persistent-history
vvzvlad:fix/ai-chat-copy-chat-wysiwyg
vvzvlad:fix/ai-stream-reset-resilience
vvzvlad:fix/ai-stream-undici-timeout
vvzvlad:fix/footnote-review-1227-followup
vvzvlad:fix/ai-chat-token-counter-realtime
vvzvlad:docs/manual-qa-test-plan
No Reviewers
Labels
Clear labels
bug
documentation
duplicate
enhancement
epic
feature
good first issue
help wanted
idea
invalid
needs-human
question
refactor
review/approved
review/changes-requested
review/needs
security
status/blocked
status/done
status/in-progress
status/ready
test
wontfix
Something isn't working
Improvements or additions to documentation
This issue or pull request already exists
New feature or request
Large multi-phase effort spanning many changes
New functionality request
Good for newcomers
Extra attention is needed
Idea / proposal for discussion
This doesn't seem right
эскалация: нужно решение человека
Further information is requested
Code cleanup / refactoring
в последнем ревью нет открытых blocking-находок
последнее ревью оставило открытые blocking-находки
head не ревьюился (head != reviewed_head)
Security / hardening issue
ждёт зависимость blocked_by
закрыто и проверено
в активной работе (мягкая заявка)
специфицировано, не заблокировано, ждёт исполнителя
Test coverage / test infrastructure
This will not be worked on
Milestone
No items
No Milestone
Projects
Clear projects
No project
No Assignees
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: vvzvlad/gitmost#120
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Delete Branch "feature/offline-sync"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Что в этом PR
Ветка объединяет две фичи: офлайн-режим/синхронизацию (этапы M0–M2 из
docs/offline-sync-plan.md) и базовый mobile-app-bootstrap, со согласованным единым service worker.Offline-sync (M0–M2)
vite-plugin-pwa(generateSW,registerType: 'prompt',manifest:false)./api,/collab,/socket.io— NetworkOnly, GET/api— NetworkFirst, навигация →index.html. Регистрация черезuseRegisterSWс Mantine-промптом обновления; SW не регистрируется внутри Capacitor (нативная оболочка сама раздаёт ассеты).titleтого жеY.Doc(CRDT, офлайн-устойчиво), REST-сохранение заголовка убрано. Сервер извлекает фрагментtitle→page.title, сидит его для legacy-страниц (строгая защита от дублирования) и шлётtreeUpdateпри переименовании, чтобы у других пользователей обновлялось дерево/крошки. Веткаcontent→ydocтеперь сразу персистит ydoc (обезврежена ловушка дублирования Yjs). Индикатор синка с 3 состояниями.idb-keyval, версионирование поAPP_VERSION, только нужные ключи). Действие «Сделать доступным офлайн» полностью прогревает страницу, space, дерево (корень + предки + дети) и комментарии под точными ключами хуков, плюс прогревydoc.Mobile-app-bootstrap (влит)
PWA-манифест, Capacitor (
capacitor.config.ts), серверная mobile-авторизация (Bearer-токен), CORS-allowlist для нативных WebView, опц. Swagger.Согласование двойного service worker
Mobile-ветка несла рукописный
public/sw.js+ ручную регистрацию. Workbox-SW из offline-sync функционально перекрывает его и добавляет precache + prompt-обновления, поэтому:apps/client/public/sw.jsи ручная регистрация вmain.tsx(во избежание двойной регистрации);manifest.json(offline-sync используетmanifest:false),capacitor.config.ts,apple-touch-icon, все серверные mobile-auth/CORS/Swagger изменения;pnpm-lock.yamlпересобран под объединённые зависимости (Capacitor +@nestjs/swagger).Объём (намеренно не входит)
M3 (outbox оффлайн-мутаций) и M4 (вложения офлайн, оффлайн-толерантная авторизация) — отдельными итерациями.
Проверки
apps/servertsc --noEmit— OK.apps/clientvite build— OK, Workbox SW генерируется,manifest.json(mobile) сохраняется.treeUpdateпри переименовании; boundary-снимок истории при title-only; i18n; Capacitor-гард; полная пагинация прогрева).Известные ограничения
Мёртвый
emit()в title-editor (gateway игнорирует, безвреден); нет юнит-тестов на новые ветки persistence;treeUpdateиз collab-процесса рассчитан на одно-процессный деплой (как и REST-путь); прогрев комментариев капается на 50 страниц.🤖 Generated with Claude Code
Closes #195
The mobile bootstrap shipped a hand-written public/sw.js plus a manual navigator.serviceWorker.register('/sw.js') in main.tsx. The offline-sync Workbox SW (vite-plugin-pwa, generateSW) functionally supersedes it (NetworkOnly for /api,/collab,/socket.io, navigateFallback to the app shell, runtime caching) and adds precache + prompt-based updates, so: - Remove the hand-written apps/client/public/sw.js. - Remove the manual SW registration block from main.tsx; registration is now owned by <PwaUpdatePrompt/> via useRegisterSW (skipped in Capacitor native). - Regenerate pnpm-lock.yaml for the merged Capacitor + @nestjs/swagger deps. Kept from mobile-app-bootstrap: the richer manifest.json (offline-sync uses manifest:false), capacitor.config.ts, the apple-touch-icon, and all server mobile-auth/CORS/Swagger changes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>vvzvlad referenced this pull request2026-06-21 16:37:04 +03:00
Вариант A
Вариант А
Вариант B
34a210b257to0c87e92d8fonLoadDocument rebuilds a legacy page (page.content, no page.ydoc) into a Yjs doc and seeds its 'title' fragment from the page.title column. Both TiptapTransformer.toYdoc and buildTitleSeedYdoc mint fresh Yjs client-ids on every call, so the heal must run exactly once per page. Three holes let it run twice (or lose a write): - Duplication trap: the initial page read took no row lock, so two processes (the API process via openDirectConnection and the standalone collab process) could both observe ydoc IS NULL and each rebuild with different client-ids; a long-offline client merging an earlier rebuild then duplicates all content. - Lost-update: persistYdoc wrote updatePage({ydoc}) outside any transaction, so it could clobber a concurrent onStoreDocument write (which does take a lock). - Swallowed write errors: a failed heal-persist was logged but the unpersisted fresh-client-id doc was returned anyway, silently re-arming the trap. Fix: the heal now runs in healUnderLock, which re-reads the row FOR UPDATE inside one transaction and re-validates under the lock — if ydoc is now present it adopts it (no rebuild, no write), otherwise it rebuilds, seeds, and persists the ydoc in the SAME transaction. The healthy hot path still loads with no lock and no write. Failure handling surfaces instead of hiding: a rebuild-persist failure refuses the load (re-throw + error log) so an unpersisted rebuild is never handed out, while a seed-only persist failure serves the existing healthy ydoc without the unpersisted seed (non-fatal). Removed the non-transactional persistYdoc. Deliberately does NOT use a fixed clientID: identical client-ids across docs built from differing content violate Yjs per-actor uniqueness and corrupt worse than the trap; serialization under the row lock is the correct fix. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>The 'Make available offline' warm path re-typed React Query key literals and re-declared queryKey+queryFn pairs that the feature hooks already owned, so the two could silently drift (a hook key change would leave the warm cache under a stale key). Centralize them so there is one source: - Add pageKeys (page-query.ts) and spaceKeys (space-query.ts) key factories and route the inline key literals through them. Partial-match keys and 2-element spaceMembers invalidations are deliberately left inline so their effective key VALUE (and invalidation breadth) is unchanged. - Add queryOptions factories sidebarPagesQueryOptions and spaceByIdQueryOptions, consumed by both the hooks (fetchAllAncestorChildren, useGetSpaceBySlugQuery) and the warm path. Comments reuse the existing RQ_KEY factory. The warm path also stops silently succeeding: warmInfiniteAll returns a boolean and logs failures; makePageAvailableOffline is best-effort (never throws) and returns { ok, failed[] }, recording each failed step by label; the tree menu caller now shows a success or error toast from result.ok. Removed the unused slugId/parentPageId params from the offline params type. This is a behavior-preserving centralization: effective query keys, queryFns, staleTime and enabled are unchanged for every hook. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>Ghost referenced this pull request2026-06-25 23:40:12 +03:00
feat(offline): offline-sync (M0–M2) + mobile-app-bootstrap, unified service workerto WIP: feat(offline): offline-sync (M0–M2) + mobile-app-bootstrap, unified service workerCode review (delta) — PR #120: offline-sync (M0–M2) + mobile bootstrap, unified service worker
Вердикт: Request changes. Код-логика домена корректна и регрессий не вносит, но новая ветка ancestor-walk в
makePageAvailableOffline(заявленная суть фичи — «сделать путь к странице раскрываемым офлайн») и большинство меток отказов шагов вообще не покрыты тестами. Must-fix — два пробела в тестах ниже; остальное (Firefox-очистка, тихий отказ Yjs-прогрева) — non-blocking warnings, допустимые для WIP.Скоуп: ДЕЛЬТА-ревью. DELTA review, but the branch was REBASED after its last review (06-21 Block merge), so a line-precise since-review delta is not available. This reviews the CURRENT state of the offline-sync DOMAIN vs develop, SCOPED to: offline cache/persister, PWA service-worker strategy, capacitor detection, cross-process page-tree bridge (publisher + subscriber), and the collaboration layer. Develop-merge / mobile-bootstrap noise outside these paths is excluded. (~15 файлов, +1131/−20). Аспекты: security, stability, regressions, test-coverage (параллельные ревьюеры + judge).
Статус прошлых блокеров
make-offline.ts:102-103пишет страницу подpageKeys.detail(page.slugId)ИpageKeys.detail(page.id)с явным комментарием про blank-офлайн. Остаётся не зафиксирован тестом (см. Test coverage).0c87e92d,ada7e3f6(cursor-walk, settle-once teardown, error-path warmInfiniteAll покрыты).clearOfflineCacheтеперь чиститapi-get-cacheлишь как defensive cleanup для старых клиентов (clear-offline-cache.ts:77-88). Регрессий по аспекту не найдено (regressions: LGTM).Must fix before merge
makePageAvailableOffline—make-offline.test.ts:170-213Оба теста стабятgetPageBreadcrumbs -> [](строки 172, 193), поэтому циклfor (const ancestor of ancestors ?? []), skip-guard!ancestorId || ancestorId === pageIdи отказwarmSidebarChildren(ancestorId) -> failed.push("tree")(make-offline.ts:171-175) никогда не выполняются — это и есть ядро фичи. Fix: добавить тест, гдеgetPageBreadcrumbsрезолвит непустой массив, включающий ancestor сid === pageIdплюс хотя бы один отличный ancestor; проверить, что сама страница пропущена (self-skip), каждый отличный ancestor прогрет черезsidebarPagesQueryOptionsс верным ключом, а отказ прогрева ancestor пишет"tree"вresult.failed.make-offline.test.ts:190-213Из контракта{ ok, failed }(его читаетspace-tree-node-menu.tsx) проверяется только метка"comments". Ветки"page"(getPageByIdreject),"space"(prefetchspaceByIdQueryOptionsreject),"tree"(warmSidebarChildrenдля своих детей reject),"breadcrumbs"(getPageBreadcrumbsreject) и dedupe[...new Set(failed)](make-offline.ts:195) не покрыты — неверная/проглоченная метка прошла бы тесты. Fix: кейсы на каждую метку плюс кейс с двумя падающими tree-узлами, проверяющий, что"tree"появляется ровно один раз.Non-blocking
page.*БД на логауте и там, гдеindexedDB.databases()недоступен (Firefox), либо предупреждать пользователя —clear-offline-cache.ts:47-75clearOfflineCache()(единственная очистка на логауте) удаляет тела страниц изpage.<id>только при наличииindexedDB.databases(); коммент в коде (:26-28) сам признаёт, что на Firefox эти БД остаются. На общем устройстве следующий пользователь сможет открыть их в офлайн-редакторе. Экспозиция частичная (только Firefox, только «прогретые» страницы), но это новая для фичи durable-persistence утечка. Fix: трекать имена прогретых Yjs-документов (в idb-keyval) и удалять именно их черезindexedDB.deleteDatabase(), не завися от enumeration; либо показывать на логауте предупреждение, что офлайн-копии остаются на устройстве.warmPageYdoc, чтобы вызывающий не рапортовал «доступно офлайн» при упавшем/протаймаутившем Yjs-синке —make-offline.ts:209-272Функция всегда резолвитvoid, проглатывая auth-фейл провайдера, недоступный collab-сервер и срабатывание 8s-таймаута; путь таймаута резолвится идентичноsyncedи без лога. Вызывающий (space-tree-node-menu.tsx) показывает «Page is now available offline» только по RQ-результату, игнорируя исход Yjs, — при сбое collab пользователю говорят «офлайн готово», а редактор офлайн открывается пустым/несинхронизированным, причём оператор не увидит этого в логах. Fix: вернуть boolean (trueпри сработавшемsynced,falseна timeout/error) и логировать warning на ветке timeout/error; вызывающему свернуть это в success/error-тост.Test coverage
Новая логика домена покрыта частично. Хорошо покрыто:
warmInfiniteAll(cursor-walk, maxPages cap, success/error пути),warmPageYdocsettle-once teardown и timeout-путь, happy-pathmakePageAvailableOffline. Без тестов (см. Must fix): ancestor-walk и метки отказов page/space/tree/breadcrumbs + dedupe.make-offline.test.ts:170-188Тест ok:true проверяет только{ ok:true, failed:[] }и не утверждает, чтоsetQueryDataвызван сpageKeys.detail('slug-1')иpageKeys.detail('uuid-1')(make-offline.ts:102-103). Это ровно тот key-drift, что ловил прошлый ревью; приsetQueryData-моке регрессия (потеря slugId-записи) прошла бы молча. Fix: в ok:true-тесте проверить оба вызоваsetQueryDataс обоими ключами, несущими page.1a53106efeto9732bc888c9732bc888ctobabba10e40babba10e40to01825ccb5d01825ccb5dtofa4753643c41e91c26e4to2cf30c7690View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.