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

Что в этом PR

Ветка объединяет две фичи: офлайн-режим/синхронизацию (этапы M0–M2 из docs/offline-sync-plan.md) и базовый mobile-app-bootstrap, со согласованным единым service worker.

Offline-sync (M0–M2)

  • M0 — PWA shell: vite-plugin-pwa (generateSW, registerType: 'prompt', manifest:false). /api,/collab,/socket.io — NetworkOnly, GET /api — NetworkFirst, навигация → index.html. Регистрация через useRegisterSW с Mantine-промптом обновления; SW не регистрируется внутри Capacitor (нативная оболочка сама раздаёт ассеты).
  • M1 — заголовок в Yjs + укрепление тела: провайдеры вынесены в общий хук/контекст; заголовок страницы теперь живёт в отдельном Yjs-фрагменте title того же Y.Doc (CRDT, офлайн-устойчиво), REST-сохранение заголовка убрано. Сервер извлекает фрагмент titlepage.title, сидит его для legacy-страниц (строгая защита от дублирования) и шлёт treeUpdate при переименовании, чтобы у других пользователей обновлялось дерево/крошки. Ветка content→ydoc теперь сразу персистит ydoc (обезврежена ловушка дублирования Yjs). Индикатор синка с 3 состояниями.
  • M2 — оффлайн-чтение: React Query персистится в IndexedDB (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/server tsc --noEmit — OK.
  • apps/client vite build — OK, Workbox SW генерируется, manifest.json (mobile) сохраняется.
  • Прошло мульти-агентное ревью (5 измерений) + адверсариальная ре-ревизия фиксов: 2 блокера и несколько major устранены (ключ кэша страницы; treeUpdate при переименовании; boundary-снимок истории при title-only; i18n; Capacitor-гард; полная пагинация прогрева).

Известные ограничения

Мёртвый emit() в title-editor (gateway игнорирует, безвреден); нет юнит-тестов на новые ветки persistence; treeUpdate из collab-процесса рассчитан на одно-процессный деплой (как и REST-путь); прогрев комментариев капается на 50 страниц.

🤖 Generated with Claude Code

Closes #195

## Что в этом PR Ветка объединяет **две фичи**: офлайн-режим/синхронизацию (этапы M0–M2 из `docs/offline-sync-plan.md`) и базовый mobile-app-bootstrap, со согласованным единым service worker. ### Offline-sync (M0–M2) - **M0 — PWA shell:** `vite-plugin-pwa` (generateSW, `registerType: 'prompt'`, `manifest:false`). `/api`,`/collab`,`/socket.io` — NetworkOnly, GET `/api` — NetworkFirst, навигация → `index.html`. Регистрация через `useRegisterSW` с Mantine-промптом обновления; SW **не регистрируется внутри Capacitor** (нативная оболочка сама раздаёт ассеты). - **M1 — заголовок в Yjs + укрепление тела:** провайдеры вынесены в общий хук/контекст; заголовок страницы теперь живёт в отдельном Yjs-фрагменте `title` того же `Y.Doc` (CRDT, офлайн-устойчиво), REST-сохранение заголовка убрано. Сервер извлекает фрагмент `title` → `page.title`, сидит его для legacy-страниц (строгая защита от дублирования) и шлёт `treeUpdate` при переименовании, чтобы у других пользователей обновлялось дерево/крошки. Ветка `content→ydoc` теперь сразу персистит ydoc (обезврежена ловушка дублирования Yjs). Индикатор синка с 3 состояниями. - **M2 — оффлайн-чтение:** React Query персистится в IndexedDB (`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/server` `tsc --noEmit` — OK. - `apps/client` `vite build` — OK, Workbox SW генерируется, `manifest.json` (mobile) сохраняется. - Прошло мульти-агентное ревью (5 измерений) + адверсариальная ре-ревизия фиксов: 2 блокера и несколько major устранены (ключ кэша страницы; `treeUpdate` при переименовании; boundary-снимок истории при title-only; i18n; Capacitor-гард; полная пагинация прогрева). ## Известные ограничения Мёртвый `emit()` в title-editor (gateway игнорирует, безвреден); нет юнит-тестов на новые ветки persistence; `treeUpdate` из collab-процесса рассчитан на одно-процессный деплой (как и REST-путь); прогрев комментариев капается на 50 страниц. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Closes #195 <!-- state:review reviewed_head: 411c05a9d6c3c4685d3ef11f334c588b5c1de801 baseline_head: 411c05a9d6c3c4685d3ef11f334c588b5c1de801 verdict: approved round: 2 max_rounds: 6 open_findings: [] resolved_escalation: F1 -> option C reopened: {} -->
Ghost added 4 commits 2026-06-21 16:05:42 +03:00
Implements the §12 bootstrap from docs/mobile-app-plan.md.

Backend (§6):
- auth: optional returnToken flag on login returns the JWT in the body
  (data.authToken) for native Keychain/Keystore + Bearer; web cookie flow
  unchanged.
- main.ts: explicit CORS allowlist (APP_URL + CORS_ALLOWED_ORIGINS env +
  Capacitor WebView origins), credentials enabled, replaces open enableCors().
- optional OpenAPI/Swagger at /api/docs behind SWAGGER_ENABLED.
- env: CORS_ALLOWED_ORIGINS, SWAGGER_ENABLED, CAP_SERVER_URL.

PWA:
- manifest metadata, hand-rolled service worker (network-first nav, SWR
  assets, never intercepts /api,/socket.io,/collab), prod-only registration,
  apple-touch-icon.

Capacitor:
- capacitor.config.ts (webDir apps/client/dist; iOS via CAP_SERVER_URL to
  avoid bundling the AGPL client in the .ipa, see plan §9), cap:* scripts,
  deps, .gitignore for native dirs.
- docs/mobile-bootstrap.md documenting what is done and the remaining manual
  steps (cap add ios/android, APNs/FCM, stores).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Implements docs/offline-sync-plan.md milestones M0–M2.

M0 (PWA shell):
- Add vite-plugin-pwa (generateSW, registerType: 'prompt', manifest:false);
  NetworkOnly for /api,/collab,/socket.io, NetworkFirst for GET /api,
  navigateFallback to index.html.
- Register SW via useRegisterSW with a Mantine update prompt; skip
  registration inside Capacitor native WebView (is-capacitor guard).

M1 (harden CRDT body + title into Yjs):
- Lift the per-page Y.Doc/Hocuspocus providers into a shared hook+context so
  body and title editors share one doc.
- Move the page title into a dedicated 'title' Yjs fragment (CRDT, offline-
  tolerant); drop the REST title save. Server persists the title fragment to
  page.title and seeds it for legacy pages (empty-fragment guard); a collab
  rename emits a treeUpdate so other users' tree/breadcrumbs refresh.
- Persist the rebuilt ydoc on the content->ydoc path to neutralize the Yjs
  duplication trap. Add a 3-state sync indicator.

M2 (offline read/navigation):
- Persist React Query to IndexedDB (idb-keyval persister, version buster,
  selected roots only).
- "Make available offline" action warms page, space, tree (root+ancestors+
  children) and comments under exact hook keys, plus the page ydoc.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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 added 1 commit 2026-06-21 18:22:45 +03:00
Adds the unit tests called out in the PR #120 review (test-coverage
aspect). No production logic changes — the only non-test edit is exporting
the already-injectable warmInfiniteAll helper so it can be unit tested.

Server (Jest):
- persistence.extension.spec.ts: onStoreDocument classification matrix
  (no-op / title-only / body+title / body-only), onLoadDocument seed +
  persist gating (early-return, page-null, ydoc seed, already-seeded
  no-persist, legacy content->ydoc), and seedTitleFragment 4-branch guard.
- collaboration.util.spec.ts: buildTitleSeedYdoc round-trip.
- environment.service.spec.ts: getCorsAllowedOrigins / isSwaggerEnabled.
- auth.controller.spec.ts: login returnToken opt-in branch.

Client (Vitest):
- query-persister.test.ts: shouldDehydrateOfflineQuery status + allowlist
  gates and OFFLINE_PERSIST_ROOTS membership.
- is-capacitor.test.ts: isCapacitorNativePlatform platform detection.
- make-offline.test.ts: warmInfiniteAll cursor walk / maxPages / error
  swallow, and warmPageYdoc settle-once + timeout + teardown.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
vvzvlad added 1 commit 2026-06-21 18:49:50 +03:00
PR #120 rewrote auth.controller.spec.ts and environment.service.spec.ts in a
leaner style but dropped several edge cases that PR #116 covered. Port the
gaps so the server coverage matches the original review intent:

- auth.controller: returnToken=false must behave like the omitted case
  (no token in the response body, cookie still set) — guards an
  `!== undefined`-style regression.
- environment.getCorsAllowedOrigins: empty string -> [], single origin,
  and leading/trailing/duplicate commas with spaces -> trimmed list.
- environment.isSwaggerEnabled: mixed-case "True" -> true; "false"/""/"1"
  -> false.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
vvzvlad added 1 commit 2026-06-21 19:12:11 +03:00
Carries the still-applicable findings from the PR #116 review into PR #120,
since #120 includes the mobile-bootstrap commit. CORS hardening (removing the
unconditional localhost/capacitor origins) is intentionally left out of scope.

Service worker routing (latent bug fix + testability):
- vite.config.ts: anchor Workbox path matching to a segment boundary
  (^/<seg>(/|$)) instead of startsWith, so siblings like /apidocs,
  /collaborators, /socket.iox are no longer mis-routed as API/realtime and
  forced NetworkOnly; align navigateFallbackDenylist with the same anchors.
- new apps/client/src/pwa/sw-strategy.ts holds the canonical predicates
  (isApiPath, isCollabOrSocketPath) + unit tests; the vite.config regexes
  mirror it inline (Workbox generateSW serializes urlPattern fns standalone,
  so they cannot import the module).

Server CORS (R1 extraction + coverage):
- extract buildCorsAllowlist / isOriginAllowed into cors.util.ts with unit
  tests (evil-origin rejected, WebView/no-Origin allowed); main.ts rewired to
  use them with byte-for-byte identical behavior.

Privacy — clear offline cache on logout:
- new clear-offline-cache.ts purges the persisted query cache
  (idb-keyval gitmost-rq-cache), the Yjs page.* IndexedDB databases, and the
  service-worker api-get-cache; wired into handleLogout (best-effort, before
  the redirect) so a previous user's private data does not linger locally.

Conventions & docs:
- prettier fixes on main.ts and login.dto.ts.
- CHANGELOG: document offline reading, returnToken opt-in, optional Swagger,
  new env vars, logout cache-clear, and the CORS open->allowlist breaking
  change.
- docs/mobile-app-plan.md: correct the now-false §2.4 claims and update the
  §12 checklist (native cap add ios left unchecked — generated locally,
  gitignored).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Owner

collab → ws: treeUpdate пересекает границу процессов и теряется. Подтверждено: CollabAppModule не содержит WsModule, в двухпроцессном проде broadcast переименования мёртв (тот же дефект продублирован выше как warning).

Вариант A — мост через Redis (effort: medium): collab-процесс публикует «tree updated» в Redis pub/sub, WS-слой API-процесса переотдаёт через WsTreeService. + закрывает дыру, WS — единый авторитет broadcast, переиспользует обязательный Redis. − новый межпроцессный контракт, защита от двойного broadcast в одно-процессном режиме.
Вариант B — propagation с клиента (effort: small): опереться на уже эмитящийся из title-editor updateOne, убрать серверный treeUpdate. + минимально, убирает мёртвый путь. − теряется server-authoritative/permission-filtered гарантия; офлайн-редакторы — только при refetch.
Вариант C — оставить, но честно ограничить (effort: small): задокументировать «single-process only» у места вызова. − ship'ится broadcast, который в проде не работает.

Вариант A

> collab → ws: treeUpdate пересекает границу процессов и теряется. Подтверждено: CollabAppModule не содержит WsModule, в двухпроцессном проде broadcast переименования мёртв (тот же дефект продублирован выше как warning). > > Вариант A — мост через Redis (effort: medium): collab-процесс публикует «tree updated» в Redis pub/sub, WS-слой API-процесса переотдаёт через WsTreeService. + закрывает дыру, WS — единый авторитет broadcast, переиспользует обязательный Redis. − новый межпроцессный контракт, защита от двойного broadcast в одно-процессном режиме. > Вариант B — propagation с клиента (effort: small): опереться на уже эмитящийся из title-editor updateOne, убрать серверный treeUpdate. + минимально, убирает мёртвый путь. − теряется server-authoritative/permission-filtered гарантия; офлайн-редакторы — только при refetch. > Вариант C — оставить, но честно ограничить (effort: small): задокументировать «single-process only» у места вызова. − ship'ится broadcast, который в проде не работает. Вариант A
Owner

Офлайн-прогрев зеркалит query-ключи хуков «EXACTLY» без общего источника (make-offline.ts vs page-query.ts). Сейчас совпадает, но при рефакторинге ключа/staleTime прогрев «успешен», а офлайн-чтение тихо пустое.

Вариант A (effort: medium): queryOptions(...)-фабрики, общие для хука и прогрева.
Вариант B (effort: small): тест, что прогрев пишет под те же ключи, что читают хуки — дрейф падает в CI.

Вариант А

> Офлайн-прогрев зеркалит query-ключи хуков «EXACTLY» без общего источника (make-offline.ts vs page-query.ts). Сейчас совпадает, но при рефакторинге ключа/staleTime прогрев «успешен», а офлайн-чтение тихо пустое. > > Вариант A (effort: medium): queryOptions(...)-фабрики, общие для хука и прогрева. > Вариант B (effort: small): тест, что прогрев пишет под те же ключи, что читают хуки — дрейф падает в CI. Вариант А
Owner

features/offline/make-offline.ts вручную зеркалит query-ключи, разбросанные по features/{page,space,comment}/queries/*.
make-offline руками воспроизводит точные ключи и fetch-функции хуков (["pages", slugId], ["sidebar-pages", {pageId, spaceId}], ["root-sidebar-pages", spaceId], ["comments", pageId], ["space", slug]). Единой фабрики ключей в клиенте нет; инвариант «совпадать точно» живёт только в комментариях. Любое переименование/реордер ключа молча сломает офлайн-прогрев без сигнала на сборке/тестах, а каждый новый офлайн-тип запроса требует ручного дубля здесь. Сейчас баг не вызывает — ключи верны; концерн forward-looking.

Вариант A — оставить как есть (effort: none). Плюсы: ноль рефактора, ключи верны, дублирование в одном файле. Минусы: класс «тихого дрейфа», правки в make-offline на каждый новый тип.
Вариант B — общая фабрика ключей (pageKeys/spaceKeys/commentKeys), используемая и хуками, и make-offline (effort: medium). Плюсы: единый источник правды, типовая связность, новый офлайн-тип = однострочник; развивает уже начатый RQ_KEY. Минусы: затрагивает несколько query-модулей вне scope PR.
Вариант C — каждый query-модуль экспортирует свой warmOffline(params) (effort: small-medium). Плюсы: «как меня прогреть» рядом с запросом, меньше blast-radius. Минусы: форма ключа всё ещё дублируется внутри модуля (если не совместить с B).

Вариант B

> features/offline/make-offline.ts вручную зеркалит query-ключи, разбросанные по features/{page,space,comment}/queries/*. > make-offline руками воспроизводит точные ключи и fetch-функции хуков (["pages", slugId], ["sidebar-pages", {pageId, spaceId}], ["root-sidebar-pages", spaceId], ["comments", pageId], ["space", slug]). Единой фабрики ключей в клиенте нет; инвариант «совпадать точно» живёт только в комментариях. Любое переименование/реордер ключа молча сломает офлайн-прогрев без сигнала на сборке/тестах, а каждый новый офлайн-тип запроса требует ручного дубля здесь. Сейчас баг не вызывает — ключи верны; концерн forward-looking. > > Вариант A — оставить как есть (effort: none). Плюсы: ноль рефактора, ключи верны, дублирование в одном файле. Минусы: класс «тихого дрейфа», правки в make-offline на каждый новый тип. > Вариант B — общая фабрика ключей (pageKeys/spaceKeys/commentKeys), используемая и хуками, и make-offline (effort: medium). Плюсы: единый источник правды, типовая связность, новый офлайн-тип = однострочник; развивает уже начатый RQ_KEY. Минусы: затрагивает несколько query-модулей вне scope PR. > Вариант C — каждый query-модуль экспортирует свой warmOffline(params) (effort: small-medium). Плюсы: «как меня прогреть» рядом с запросом, меньше blast-radius. Минусы: форма ключа всё ещё дублируется внутри модуля (если не совместить с B). Вариант B
Ghost force-pushed feature/offline-sync from 34a210b257 to 0c87e92d8f 2026-06-22 01:18:43 +03:00 Compare
Ghost added 1 commit 2026-06-22 01:46:59 +03:00
Title now lives in the page's Yjs 'title' fragment, but two paths corrupted it:

- Rename-revert: a REST/MCP title change wrote only the page.title column,
  never the Yjs fragment, so the next editor open replayed the stale Yjs title
  and reverted the rename. PageService.update now mirrors the new title into the
  Yjs 'title' fragment via CollaborationGateway.writePageTitle, which goes
  through openDirectConnection directly (Redis-independent: works with
  COLLAB_DISABLE_REDIS and in single-process deployments, unlike the
  Redis-routed handleYjsEvent path). The write is best-effort: a Yjs failure is
  logged and never rolls back the committed column write. Agent provenance
  (actor/aiChatId) is threaded into the store context.

- Untitled-on-open: an empty/just-initialized 'title' fragment clobbered a
  non-empty page.title to '' on open. onStoreDocument now treats the title as
  changed only when the extracted text is non-empty, covering both the
  title-only and body+title save branches. Empty-retitling via collab is
  intentionally impossible; the REST DTO is the place to enforce non-empty.

writeTitleFragment does a full clear+seed of the 'title' fragment (no
duplication/concatenation) and leaves the body fragment intact. Removed the dead
useTreeMutation.handleRename path. Adds unit tests for writeTitleFragment, the
gateway write, the anti-empty-clobber guard, and agent provenance.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ghost added 1 commit 2026-06-22 01:57:24 +03:00
onLoadDocument 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>
Ghost added 1 commit 2026-06-22 02:10:58 +03:00
In 2-process deployments (COLLAB_URL set) the standalone collab process runs
Hocuspocus onStoreDocument, which emits PAGE_UPDATED with a treeUpdate snapshot
on a collaborative rename. But CollabAppModule has no WsModule, so PageWsListener
(the broadcaster) only exists in the API process — the collab-originated tree
update never reached clients, and other users' sidebars/breadcrumbs went stale.

Bridge it over Redis pub/sub with the API process as the single broadcast
authority:

- PageTreeBridgePublisher (registered ONLY in CollabAppModule) listens for
  PAGE_UPDATED and, when a treeUpdate snapshot is present, publishes it to the
  collab:tree-update channel. Gated exactly like PageWsListener so content-only
  saves never publish noise.
- PageTreeBridgeSubscriber (registered in WsModule, API process) subscribes on a
  dedicated duplicated connection and re-broadcasts each snapshot through
  WsTreeService.broadcastPageUpdated — the same restriction-aware emitTreeEvent
  path, so authorization is preserved.

Double-broadcast is prevented by module placement: the publisher lives only in
the standalone collab process's root module, so in single-process mode it is
never loaded and the local PageWsListener stays the sole broadcaster.

The bridge is optional and fail-safe: publish errors, malformed payloads,
broadcast rejections, an unlistened 'error' on the subscriber connection, and a
subscribe() failure at boot are all caught and logged, never crashing or blocking
the process. NOTE: assumes a single API broadcaster; horizontal API scaling would
need a consumer-group/leader-election instead of fan-out pub/sub.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ghost added 1 commit 2026-06-22 02:20:49 +03:00
The onAuthenticationFailed handler is created once per page (effect keyed on
pageId), so it closed over the initial collab token and decoded a STALE value
after a refetch. Worse, jwtDecode(undefined) throws, so when the token had not
loaded (or the request failed) the handler crashed before it could refetch and
reconnect — leaving the editor stuck disconnected.

Mirror the latest token into a ref the handler reads live, and guard the decode:
a missing or malformed token is treated as 'needs refresh' so it refetches and
reconnects instead of throwing. A valid, unexpired token still early-returns.

Also remove two local useState sync flags (isLocalSynced/isRemoteSynced) that
were set but never read — the header indicator consumes the Jotai atoms, and the
hook's return values were never destructured by any caller. The setter wrappers
now drive only the atoms.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ghost added 1 commit 2026-06-22 02:35:34 +03:00
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 added 1 commit 2026-06-22 02:44:57 +03:00
- Service worker (vite-plugin-pwa/Workbox): add /share/, /mcp, and /robots.txt
  to navigateFallbackDenylist so the SPA app-shell never shadows those
  server-rendered routes (they mirror the server static-serve exclude list — the
  share SEO/OG HTML, the MCP endpoint, and robots.txt must come from the server).
- Remove the dead /api GET NetworkFirst Workbox rule (api-get-cache): offline
  reads are served by the persisted TanStack Query cache (IndexedDB) + y-indexeddb,
  never by an SW HTTP cache, so caching GET /api only risked stale responses. All
  /api is now NetworkOnly. clearOfflineCache still deletes any legacy api-get-cache
  defensively (comment updated to note it is no longer created).
- CORS: drop the cleartext 'http://localhost' native-WebView origin. The Capacitor
  shell uses the secure scheme (capacitor.config cleartext:false, default Android
  scheme https, iOS hosted via CAP_SERVER_URL), so no native client uses it;
  allowing it only widened the credentialed-CORS surface. Keeps capacitor://,
  ionic://, and https://localhost.
- docs/mobile-bootstrap.md: replace the inaccurate 'hand-rolled service worker'
  description with the real Workbox generateSW setup (prompt registration via
  virtual:pwa-register, production-only, denylist, NetworkOnly, RQ/y-indexeddb
  offline reads) and drop http://localhost from the CORS origins list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
vvzvlad added the featureepic labels 2026-06-24 20:49:07 +03:00
vvzvlad changed title from feat(offline): offline-sync (M0–M2) + mobile-app-bootstrap, unified service worker to WIP: feat(offline): offline-sync (M0–M2) + mobile-app-bootstrap, unified service worker 2026-06-25 23:59:45 +03:00
Owner

Code 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).

Статус прошлых блокеров

  • Key-drift (страница пишется и под slugId, и под uuid) — закрыт в коде: make-offline.ts:102-103 пишет страницу под pageKeys.detail(page.slugId) И pageKeys.detail(page.id) с явным комментарием про blank-офлайн. Остаётся не зафиксирован тестом (см. Test coverage).
  • PR #116 review findings / запрошенное покрытие — применены в коммитах 0c87e92d, ada7e3f6 (cursor-walk, settle-once teardown, error-path warmInfiniteAll покрыты).
  • SW denylist / мёртвый /api NetworkFirst кэш — закрыт: правило убрано, clearOfflineCache теперь чистит api-get-cache лишь как defensive cleanup для старых клиентов (clear-offline-cache.ts:77-88). Регрессий по аспекту не найдено (regressions: LGTM).

Must fix before merge

  • [test-coverage] Покрыть ancestor-walk в makePageAvailableOfflinemake-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.
  • [test-coverage] Покрыть метки отказов шагов page/space/tree/breadcrumbs + dedupemake-offline.test.ts:190-213 Из контракта { ok, failed } (его читает space-tree-node-menu.tsx) проверяется только метка "comments". Ветки "page" (getPageById reject), "space" (prefetch spaceByIdQueryOptions reject), "tree" (warmSidebarChildren для своих детей reject), "breadcrumbs" (getPageBreadcrumbs reject) и dedupe [...new Set(failed)] (make-offline.ts:195) не покрыты — неверная/проглоченная метка прошла бы тесты. Fix: кейсы на каждую метку плюс кейс с двумя падающими tree-узлами, проверяющий, что "tree" появляется ровно один раз.

Non-blocking

  • [security] warning: чистить Yjs page.* БД на логауте и там, где indexedDB.databases() недоступен (Firefox), либо предупреждать пользователяclear-offline-cache.ts:47-75 clearOfflineCache() (единственная очистка на логауте) удаляет тела страниц из page.<id> только при наличии indexedDB.databases(); коммент в коде (:26-28) сам признаёт, что на Firefox эти БД остаются. На общем устройстве следующий пользователь сможет открыть их в офлайн-редакторе. Экспозиция частичная (только Firefox, только «прогретые» страницы), но это новая для фичи durable-persistence утечка. Fix: трекать имена прогретых Yjs-документов (в idb-keyval) и удалять именно их через indexedDB.deleteDatabase(), не завися от enumeration; либо показывать на логауте предупреждение, что офлайн-копии остаются на устройстве.
  • [stability] warning: возвращать сигнал успеха из 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 пути), warmPageYdoc settle-once teardown и timeout-путь, happy-path makePageAvailableOffline. Без тестов (см. Must fix): ancestor-walk и метки отказов page/space/tree/breadcrumbs + dedupe.

  • [test-coverage] warning: зафиксировать запись страницы под ОБОИМИ ключами (slugId и id)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.
## Code 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)._ ### Статус прошлых блокеров - **Key-drift (страница пишется и под slugId, и под uuid)** — закрыт в коде: `make-offline.ts:102-103` пишет страницу под `pageKeys.detail(page.slugId)` И `pageKeys.detail(page.id)` с явным комментарием про blank-офлайн. Остаётся не зафиксирован тестом (см. Test coverage). - **PR #116 review findings / запрошенное покрытие** — применены в коммитах `0c87e92d`, `ada7e3f6` (cursor-walk, settle-once teardown, error-path warmInfiniteAll покрыты). - **SW denylist / мёртвый /api NetworkFirst кэш** — закрыт: правило убрано, `clearOfflineCache` теперь чистит `api-get-cache` лишь как defensive cleanup для старых клиентов (`clear-offline-cache.ts:77-88`). Регрессий по аспекту не найдено (regressions: LGTM). ### Must fix before merge - **[test-coverage] Покрыть ancestor-walk в `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`. - **[test-coverage] Покрыть метки отказов шагов page/space/tree/breadcrumbs + dedupe** — `make-offline.test.ts:190-213` Из контракта `{ ok, failed }` (его читает `space-tree-node-menu.tsx`) проверяется только метка `"comments"`. Ветки `"page"` (`getPageById` reject), `"space"` (prefetch `spaceByIdQueryOptions` reject), `"tree"` (`warmSidebarChildren` для своих детей reject), `"breadcrumbs"` (`getPageBreadcrumbs` reject) и dedupe `[...new Set(failed)]` (`make-offline.ts:195`) не покрыты — неверная/проглоченная метка прошла бы тесты. Fix: кейсы на каждую метку плюс кейс с двумя падающими tree-узлами, проверяющий, что `"tree"` появляется ровно один раз. ### Non-blocking - **[security] warning: чистить Yjs `page.*` БД на логауте и там, где `indexedDB.databases()` недоступен (Firefox), либо предупреждать пользователя** — `clear-offline-cache.ts:47-75` `clearOfflineCache()` (единственная очистка на логауте) удаляет тела страниц из `page.<id>` только при наличии `indexedDB.databases()`; коммент в коде (`:26-28`) сам признаёт, что на Firefox эти БД остаются. На общем устройстве следующий пользователь сможет открыть их в офлайн-редакторе. Экспозиция частичная (только Firefox, только «прогретые» страницы), но это новая для фичи durable-persistence утечка. Fix: трекать имена прогретых Yjs-документов (в idb-keyval) и удалять именно их через `indexedDB.deleteDatabase()`, не завися от enumeration; либо показывать на логауте предупреждение, что офлайн-копии остаются на устройстве. - **[stability] warning: возвращать сигнал успеха из `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 пути), `warmPageYdoc` settle-once teardown и timeout-путь, happy-path `makePageAvailableOffline`. Без тестов (см. Must fix): ancestor-walk и метки отказов page/space/tree/breadcrumbs + dedupe. - **[test-coverage] warning: зафиксировать запись страницы под ОБОИМИ ключами (slugId и id)** — `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.
Ghost force-pushed feature/offline-sync from 1a53106efe to 9732bc888c 2026-06-26 20:55:00 +03:00 Compare
Ghost force-pushed feature/offline-sync from 9732bc888c to babba10e40 2026-06-26 20:58:46 +03:00 Compare
Ghost force-pushed feature/offline-sync from babba10e40 to 01825ccb5d 2026-06-26 21:07:12 +03:00 Compare
Ghost force-pushed feature/offline-sync from 01825ccb5d to fa4753643c 2026-06-27 05:32:35 +03:00 Compare
Ghost added 1 commit 2026-06-27 19:35:38 +03:00
Fixes the offline-sync defects QA found on PR #120 (#237/#238/#220).

Blank-shell / white-screen on offline reload (HIGH):
- auth-query.tsx: the useCollabToken retry predicate read
  `error.response.status` unguarded. Offline the collab-token POST rejects as
  an axios NETWORK error (isAxiosError true, response undefined), so `.status`
  threw an uncaught TypeError in the React Query retryer BEFORE React mounted,
  white-screening every route. Extracted the predicate as `collabTokenRetry`
  and guarded it with optional chaining (`error.response?.status === 404`).
- user-provider.tsx: gated the whole <Layout> on useCurrentUser() and returned
  a bare `<></>` on any error, blanking every authenticated route offline even
  when cached data existed. Now renders the cached app when a (stale) user is
  present and an explicit OfflineFallback when there is no user to fall back on.
- query-persister.ts / make-offline.ts: persist and warm the ['currentUser']
  query so the auth gate can hydrate offline (pinned pages now survive relaunch).

Offline structural create/move/comment silently lost on reload (HIGH):
- offline-mutations.ts: register setMutationDefaults (default mutationFns) for
  stable mutation keys and tag useCreatePageMutation / useMovePageMutation /
  useCreateCommentMutation with those keys. A paused mutation dehydrated to
  IndexedDB while offline now has a mutationFn after reload, so
  resumePausedMutations() replays it on reconnect instead of no-op'ing.

Tests (client vitest): collabTokenRetry no longer throws on a no-response
network error; UserProvider renders cached children / the offline fallback (not
a blank fragment) on a network error; a rehydrated paused create/move is
replayable via resumePausedMutations; currentUser persist-root coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ghost added 1 commit 2026-06-27 22:29:27 +03:00
Security:
- Clear the offline IndexedDB cache on sign-in (not only logout) so a previous
  user's persisted query cache and Yjs page bodies cannot leak to the next user
  on a shared device when the prior session ended without an explicit logout.

Regressions:
- Remove the double Yjs title write from the AI title-generation path: the title
  editor is bound to the Yjs `title` fragment and the server REST update reseeds
  it, so the local setContent raced that reseed and doubled/garbled the title.

Conventions / i18n / docs:
- Remove the unused showAiMenuAtom.
- Register the 3 offline-fallback strings in en-US and ru-RU.
- Fix the 5 broken links to the nonexistent docs/offline-sync-plan.md.

Stability / simplification:
- warmInfiniteAll now reports truncation (returns false) when it hits maxPages
  with a cursor still pending instead of silently succeeding.
- space-tree make-offline catch logs the raw error and surfaces the real cause.
- Move the Offline/Mobile/CORS CHANGELOG entries from the released 0.93.0 section
  into [Unreleased] (CORS is a documented breaking change).
- Drop the pass-through sync-flag forwarders in use-page-collab-providers; set the
  atoms directly.
- Collapse the three isSwaggerEnabled true-cases into it.each.

Tests / architecture:
- Extract collabTokenNeedsRefresh (pure) and cover all four token states.
- Extract shouldPropagateTitleChange and cover the collab-origin skip; add a
  TitleEditor render test for the static-h1 vs collaborative-editor switch.
- Add a use-auth test asserting the sign-in cache purge runs before login.
- Add an OFFLINE_PERSIST_ROOTS guard test asserting every persisted root maps to
  an exported query-key factory; route make-offline's currentUser warm through a
  new userKeys factory.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ghost force-pushed feature/offline-sync from 41e91c26e4 to 2cf30c7690 2026-06-28 15:26:52 +03:00 Compare
Ghost added 1 commit 2026-06-28 17:51:26 +03:00
Address six QA findings on the offline-sync feature:

1. HIGH — silent data loss: a paused mutation persisted to IndexedDB and
   reloaded while still offline never resumed on reconnect. Seed TanStack
   onlineManager from navigator.onLine at boot (it defaults to online:true and
   only flips on events, so a cold-boot-offline tab wrongly believed it was
   online and never got a true online transition), and call
   resumePausedMutations() in PersistQueryClientProvider onSuccess after the
   persister rehydrates (defaults are registered before, so the restored
   mutation has a mutationFn). New offline-resume.test.ts reproduces the full
   persist -> reload -> reconnect path.

2. MEDIUM (security) — logout did not durably clear gitmost-rq-cache: the
   throttled persister re-wrote the key ~1s after del() with the still-in-memory
   snapshot, resurrecting the previous user's data. Freeze the persister
   (persistClient becomes a no-op) before clearing/deleting so neither the
   clear()-triggered nor any in-flight write can repopulate the key; re-enable
   afterwards for the next sign-in session.

3. MEDIUM (UX) — offline create spun forever: the create-note button awaited a
   mutateAsync that stays pending while paused. Detect offline, fire-and-forget
   the (queued) mutation, show a "saved offline" notice, and gate the spinner on
   !isPaused so it no longer hangs.

4. LOW — an uncached page opened offline showed the generic "Error fetching page
   data." instead of the offline fallback (offline fetch yields no HTTP status).
   Render OfflineFallback when navigator is offline or the error has no status.

5. LOW — logout teardown threw "Cannot read properties of null (reading
   'settings')" in full-editor.tsx: optional-chain the (transiently null) user.

6. Tab title "Untitled": investigated — the tab-title derivation in page.tsx is
   byte-identical to develop and already reads page.title from REST/cache (the
   recommended source); live edits keep it in sync via updatePageData. Not a
   tab-title-derivation regression introduced by this PR; no change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ghost added the needs-human label 2026-06-28 22:42:10 +03:00
Ghost added review/changes-requested and removed needs-human labels 2026-06-28 23:33:06 +03:00
Ghost added the status/in-progress label 2026-06-29 00:17:05 +03:00
Ghost added 2 commits 2026-06-29 00:41:07 +03:00
A REST/MCP/agent rename (no live editor) wrote the new title to the
page.title column, then writePageTitle loaded the ydoc (fragment still
OLD) and set it to NEW only in memory. On disconnect onStoreDocument saw
titleText(NEW) === column(NEW), took the no-op fast-path, and never
persisted the in-memory fragment — so page.ydoc kept the OLD title and a
later body edit silently reverted the column back to OLD.

writePageTitle now persists the 'title' fragment to page.ydoc DIRECTLY
(PersistenceExtension.persistTitleFragmentYdoc) after the transact,
bypassing the no-op onStoreDocument. The write carries no treeUpdate, so
the tree WS/redis listeners do not re-broadcast (no double broadcast),
and it is idempotent/lock-free so it is safe whether or not a live editor
is connected. Adds a persist-then-reload-then-edit-body regression test
that fails on the old no-op behaviour and passes after the fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
F2: navigateFallbackDenylist was missing the server's `l/:alias` vanity
short-link, so a top-nav to /l/<alias> after SW registration got the
index.html app shell (which has no /l route) and dead-ended on Error404
instead of the server's 302 redirect. Add /^\/l(\/|$)/ mirroring main.ts.

F3: the partial-failure branch of "make page available offline" showed a
bare generic toast; include result.failed step labels in the message per
AGENTS.md (errors must be specific), matching the catch-branch below it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ghost added review/needs and removed review/changes-requested labels 2026-06-29 00:47:04 +03:00
Ghost added review/approved and removed review/needs labels 2026-06-29 01:28:41 +03:00
This pull request has changes conflicting with the target branch.
  • CHANGELOG.md
  • apps/server/src/integrations/environment/environment.service.spec.ts
  • apps/server/src/integrations/environment/environment.service.ts
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin feature/offline-sync:feature/offline-sync
git checkout feature/offline-sync
Sign in to join this conversation.
No Reviewers
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#120