Compare commits

..

2 Commits

Author SHA1 Message Date
claude code agent 227
ae6ed76d9a fix(ai-chat): assistant turn renders empty — memo froze on in-place part mutation
The floating AI chat rendered NOTHING for the assistant turn (user bubble +
"thinking" dots showed, but the streamed text and tool-call cards never
appeared) even though the agent ran server-side. The parts DID arrive in
`useChat.messages` — this was purely a render freeze.

Root cause: the MessageItem `React.memo` comparator (#182) decided whether to
re-render by recomputing `messageSignature(prev.message)` vs
`messageSignature(next.message)` inside `arePropsEqual` (plus a
`prev.message === next.message` fast path). But the AI SDK (ai@6 /
@ai-sdk/react@3) streams a turn by MUTATING the same `parts` in place and
handing back a message wrapper that SHARES those mutated parts. So inside the
comparator both `prev.message` and `next.message` already reflect the latest
content — the two signatures are ALWAYS equal — and the memo skipped every
post-mount render. The assistant row therefore froze at its initial empty
(null) render; reasoning-first providers (e.g. z.ai/GLM) start with a
non-visible reasoning part, so the whole answer + tool cards never showed.

Fix: snapshot the signature in the PARENT (MessageList) at render time and pass
it to MessageItem as an immutable `signature` string prop; `arePropsEqual` now
compares that prop. A captured string is immutable, so `prev.signature` holds
the previous render's content and `next.signature` the new content — they differ
as the turn streams in and the row re-renders. Drop the now-incorrect
`prev.message === next.message` fast path (same-ref-but-mutated must still
re-render). MarkdownPart's per-part memo is unaffected (it already keys on the
primitive `text`).

Verified end-to-end against a real OpenAI-compatible provider: the assistant
turn (reasoning + streamed text + tool-call card) now renders live and on
finish. Regression tests added (render + comparator) that fail before / pass
after.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:02:53 +03:00
claude_code
406921ac6a fix(share): tighten and restyle custom-address prefix input
The "Custom address" slug field sized its leftSection with a
character-count heuristic (label.length * 7 + 12), which over-estimated
the real width of the small dimmed domain prefix and left an ugly empty
gap between "docs.../l/" and the input text.

- Measure the real prefix width via a ref + useLayoutEffect (scrollWidth)
  and feed it to leftSectionWidth so the slug sits flush against the
  prefix, regardless of host length or font metrics.
- Restyle the prefix as an attached addon: subtle background, a right
  divider border and input-matching left corner radii.
- Minor spacing tidy: description mb 4->6, action buttons mt xs->sm.

No behavior change: validation, availability probe, save/remove and the
reassign modal are untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 21:05:23 +03:00
69 changed files with 614 additions and 5485 deletions

View File

@@ -92,19 +92,6 @@ IFRAME_EMBED_ALLOWED=false
# Example: https://intranet.example.com,https://portal.example.com
IFRAME_ALLOWED_ORIGINS=
# Comma-separated list of additional origins allowed to call the API via CORS.
# The APP_URL origin and native mobile (Capacitor) origins are always allowed.
# Leave empty for a same-origin (web-only) deployment.
CORS_ALLOWED_ORIGINS=
# Expose OpenAPI/Swagger docs at /api/docs (development/debugging aid only).
SWAGGER_ENABLED=false
# Capacitor (mobile shell): hosted client URL loaded by the iOS shell so the
# AGPL web client is NOT bundled into the .ipa (see docs/mobile-app-plan.md §9).
# Leave empty for Android bundled mode / local development.
CAP_SERVER_URL=
# Enable debug logging in production (default: false)
DEBUG_MODE=false

5
.gitignore vendored
View File

@@ -49,8 +49,3 @@ lerna-debug.log*
# Self-hosted VAD / onnxruntime-web assets (copied from node_modules at dev/build time)
apps/client/public/vad/
# Capacitor native platform projects (generated locally via 'npx cap add ios|android')
/ios
/android
.capacitor

View File

@@ -227,18 +227,6 @@ embeds — plus a large batch of security hardening and test coverage.
injected into the `<head>` of public share pages only (for analytics such as
Google Analytics or Yandex.Metrika), kept separate from the member-facing
HTML-embed feature.
- **Offline reading support**: opened pages, their sidebar tree, breadcrumb
children, and comments are cached in IndexedDB (TanStack Query persister plus
`y-indexeddb` for the page's Yjs document), and a PWA service worker
(vite-plugin-pwa) serves an app shell so previously opened pages stay readable
offline. The offline cache (persisted query cache, Yjs page documents, and the
service-worker API cache) is cleared on logout so a previous user's private
data does not remain in the browser.
- **Mobile bootstrap**: a `returnToken` opt-in on login so native/mobile clients
can request the access JWT in the response body (`data.authToken`) in addition
to the httpOnly cookie (the web client stays cookie-only); an optional
OpenAPI/Swagger UI at `/api/docs` gated by `SWAGGER_ENABLED` (off by default);
and new env vars `CORS_ALLOWED_ORIGINS`, `SWAGGER_ENABLED`, `CAP_SERVER_URL`.
- **MCP**: a hierarchical tree mode for `list_pages`, and per-user auth for the
embedded `/mcp` endpoint.
- **Page tree**: Expand all / Collapse all for the space tree, and
@@ -254,12 +242,6 @@ embeds — plus a large batch of security hardening and test coverage.
### Changed
- **CORS is now an explicit allowlist** (replaces the previous unconfigured
`app.enableCors()`). The same-origin web client is unaffected, but any
separately-hosted cross-domain client must now be listed in
`CORS_ALLOWED_ORIGINS` (native Capacitor/Ionic/localhost WebView origins are
allowed automatically). Requests with no `Origin` header (server-to-server)
are still allowed.
- HTML embed blocks now render inside a sandboxed iframe (separate origin) and,
when the workspace HTML-embed toggle is on, can be inserted by any member
(previously admin-only). Turning the toggle off hides existing embeds and

View File

@@ -10,7 +10,6 @@
<meta name="theme-color" content="#0E1117" media="(prefers-color-scheme: dark)" />
<meta name="theme-color" content="#f6f7f9" media="(prefers-color-scheme: light)" />
<link rel="manifest" href="/manifest.json" />
<link rel="apple-touch-icon" href="/icons/app-icon-192x192.png" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-touch-fullscreen" content="yes" />
<meta name="apple-mobile-web-app-title" content="Gitmost" />

View File

@@ -33,9 +33,7 @@
"@slidoapp/emoji-mart-data": "1.2.4",
"@slidoapp/emoji-mart-react": "1.1.5",
"@tabler/icons-react": "3.40.0",
"@tanstack/query-async-storage-persister": "5.90.17",
"@tanstack/react-query": "5.90.17",
"@tanstack/react-query-persist-client": "5.90.17",
"@tanstack/react-virtual": "3.13.24",
"ai": "6.0.207",
"alfaaz": "1.1.0",
@@ -47,7 +45,6 @@
"highlightjs-sap-abap": "0.3.0",
"i18next": "25.10.1",
"i18next-http-backend": "3.0.6",
"idb-keyval": "6.2.5",
"jotai": "2.18.1",
"jotai-optics": "0.4.0",
"js-cookie": "3.0.7",
@@ -98,7 +95,6 @@
"typescript": "5.9.3",
"typescript-eslint": "8.57.1",
"vite": "8.0.5",
"vite-plugin-pwa": "1.3.0",
"vitest": "4.1.6"
}
}

View File

@@ -464,15 +464,6 @@
"Move page": "Move page",
"Move page to a different space.": "Move page to a different space.",
"Real-time editor connection lost. Retrying...": "Real-time editor connection lost. Retrying...",
"Offline — changes are saved locally and will sync when you reconnect": "Offline — changes are saved locally and will sync when you reconnect",
"Syncing changes…": "Syncing changes…",
"All changes synced": "All changes synced",
"Update available": "Update available",
"Reload": "Reload",
"Make available offline": "Make available offline",
"Saving page for offline use...": "Saving page for offline use...",
"Page is now available offline": "Page is now available offline",
"Failed to make page available offline": "Failed to make page available offline",
"Table of contents": "Table of contents",
"Add headings (H1, H2, H3) to generate a table of contents.": "Add headings (H1, H2, H3) to generate a table of contents.",
"Share": "Share",

View File

@@ -474,15 +474,6 @@
"Move page": "Переместить страницу",
"Move page to a different space.": "Переместите страницу в другое пространство.",
"Real-time editor connection lost. Retrying...": "Соединение с редактором в реальном времени потеряно. Повторная попытка...",
"Offline — changes are saved locally and will sync when you reconnect": "Нет сети — изменения сохраняются локально и синхронизируются при восстановлении соединения",
"Syncing changes…": "Синхронизация изменений…",
"All changes synced": "Все изменения синхронизированы",
"Update available": "Доступно обновление",
"Reload": "Перезагрузить",
"Make available offline": "Сделать доступным офлайн",
"Saving page for offline use...": "Сохраняем страницу для офлайн-доступа…",
"Page is now available offline": "Страница доступна офлайн",
"Failed to make page available offline": "Не удалось сделать страницу доступной офлайн",
"Table of contents": "Оглавление",
"Add headings (H1, H2, H3) to generate a table of contents.": "Добавьте заголовки (H1, H2, H3), чтобы создать оглавление.",
"Share": "Поделиться",

View File

@@ -1,19 +1,30 @@
{
"id": "/",
"name": "Gitmost",
"short_name": "Gitmost",
"description": "Gitmost - open-source collaborative documentation and knowledge base.",
"lang": "en",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#0E1117",
"theme_color": "#0E1117",
"icons": [
{ "src": "icons/favicon-16x16.png", "type": "image/png", "sizes": "16x16" },
{ "src": "icons/favicon-32x32.png", "type": "image/png", "sizes": "32x32" },
{ "src": "icons/app-icon-192x192.png", "type": "image/png", "sizes": "192x192", "purpose": "any" },
{ "src": "icons/app-icon-512x512.png", "type": "image/png", "sizes": "512x512", "purpose": "any" }
{
"src": "icons/favicon-16x16.png",
"type": "image/png",
"sizes": "16x16"
},
{
"src": "icons/favicon-32x32.png",
"type": "image/png",
"sizes": "32x32"
},
{
"src": "icons/app-icon-192x192.png",
"type": "image/png",
"sizes": "180x180 192x192"
},
{
"src": "icons/app-icon-512x512.png",
"type": "image/png",
"sizes": "512x512"
}
]
}

View File

@@ -26,16 +26,20 @@ vi.mock("@/features/ai-chat/utils/markdown.ts", async () => {
});
import MessageItem from "./message-item";
import { messageSignature } from "@/features/ai-chat/utils/message-signature.ts";
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
const msg = (parts: UIMessage["parts"]): UIMessage =>
({ id: "m1", role: "assistant", parts }) as UIMessage;
// Mirror MessageList: snapshot the signature at (parent) render time and pass it
// as the memo key. The signature must NOT be recomputed inside the memo from the
// live (mutable) message — see message-item.tsx.
const renderRow = (message: UIMessage) =>
render(
<MantineProvider>
<MessageItem message={message} />
<MessageItem message={message} signature={messageSignature(message)} />
</MantineProvider>,
);
@@ -67,7 +71,7 @@ describe("MessageItem markdown memoization", () => {
]);
rerender(
<MantineProvider>
<MessageItem message={next} />
<MessageItem message={next} signature={messageSignature(next)} />
</MantineProvider>,
);
@@ -78,4 +82,35 @@ describe("MessageItem markdown memoization", () => {
expect(callsFor("beta")).toBe(1);
expect(callsFor("gamm")).toBe(1);
});
// REGRESSION (empty-render bug): the AI SDK streams a turn by MUTATING the same
// `parts` IN PLACE and reusing the message object. A row that mounted empty
// (reasoning-first providers render nothing at first) must still stream its text
// in once the parent hands down a fresh signature snapshot. Before the fix the
// memo recomputed the signature from the (mutated) message — identical on both
// sides — and froze the row at its empty render, so the answer never appeared.
it("streams text in after the row mounted empty and parts mutated in place", () => {
renderChatMarkdownSpy.mockClear();
// Reuse ONE message object across renders (as the SDK does).
const message = msg([{ type: "text", text: "" }]);
const { rerender, queryByText } = render(
<MantineProvider>
<MessageItem message={message} signature={messageSignature(message)} />
</MantineProvider>,
);
// Empty text part: nothing visible rendered yet.
expect(queryByText("streamed answer")).toBeNull();
// SDK delta: mutate the SAME part in place, then re-render with a NEW snapshot.
(message.parts[0] as { text: string }).text = "streamed answer";
rerender(
<MantineProvider>
<MessageItem message={message} signature={messageSignature(message)} />
</MantineProvider>,
);
// The grown text now renders (the memo did NOT freeze the empty mount).
expect(callsFor("streamed answer")).toBe(1);
expect(queryByText("streamed answer")).not.toBeNull();
});
});

View File

@@ -10,21 +10,28 @@ vi.mock("react-i18next", () => ({
}));
import { arePropsEqual } from "./message-item";
import { messageSignature } from "@/features/ai-chat/utils/message-signature.ts";
/**
* Tests for `arePropsEqual`, the `React.memo` comparator for MessageItem. It must
* return false on any visible prop/content change (so the row re-renders) and
* true when nothing visible changed (so a finalized row is skipped). A FIXED
* message id is used so a content-identical clone yields an equal signature.
* true when nothing visible changed (so a finalized row is skipped). The memo key
* is the `signature` PROP — an immutable snapshot the PARENT (MessageList) takes
* per render via `messageSignature(message)`. A FIXED message id is used so a
* content-identical clone yields an equal signature.
*/
const msg = (parts: UIMessage["parts"]): UIMessage =>
({ id: "m1", role: "assistant", parts }) as UIMessage;
// Build the props the parent would pass, INCLUDING the snapshot signature it
// computes during its own render (the load-bearing part — see message-item.tsx:
// the signature must never be recomputed inside arePropsEqual).
const props = (
message: UIMessage,
over: Record<string, unknown> = {},
) => ({
message,
signature: messageSignature(message),
showCitations: true,
neutralizeInternalLinks: false,
assistantName: "AI",
@@ -53,7 +60,7 @@ describe("arePropsEqual", () => {
).toBe(false);
});
it("returns true on the identity fast path (same message object, equal props)", () => {
it("returns true for equal snapshot + equal props (finalized row skipped)", () => {
const m = msg([{ type: "text", text: "answer" }]);
expect(arePropsEqual(props(m), props(m))).toBe(true);
});
@@ -70,4 +77,36 @@ describe("arePropsEqual", () => {
const b = msg([{ type: "text", text: "answer grown" }]);
expect(arePropsEqual(props(a), props(b))).toBe(false);
});
// REGRESSION (empty-render bug): the AI SDK streams deltas by mutating the SAME
// `parts` in place and handing back a message wrapper that SHARES them. So the
// PREVIOUS and NEXT props can carry the SAME (mutated) message object, and
// recomputing `messageSignature(message)` inside the comparator would read
// identical (latest) content on BOTH sides → always "equal" → the memo skips
// every streamed update and the assistant row freezes at its initial empty
// render. The comparator MUST instead trust the immutable `signature` SNAPSHOT
// the parent captured at each render. This fails against the old implementation
// (a `prev.message === next.message` fast path + a signature recomputed from the
// live objects).
it("re-renders when parts were mutated in place but the snapshot changed", () => {
const message = msg([{ type: "text", text: "" }]); // empty (renders null)
const prevSig = messageSignature(message); // snapshot BEFORE the delta
// SDK streams a delta by mutating the shared part IN PLACE:
(message.parts[0] as { text: string }).text = "hello world";
const nextSig = messageSignature(message); // snapshot AFTER the delta
expect(prevSig).not.toBe(nextSig);
// Same object reference on both sides (the SDK reuses it), differing snapshots.
const base = {
message,
showCitations: true,
neutralizeInternalLinks: false,
assistantName: "AI",
};
expect(
arePropsEqual(
{ ...base, signature: prevSig },
{ ...base, signature: nextSig },
),
).toBe(false);
});
});

View File

@@ -11,12 +11,30 @@ import { assistantMessageHasVisibleContent } from "@/features/ai-chat/utils/mess
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
import { resolveAssistantName } from "@/features/ai-chat/utils/assistant-name.ts";
import { reasoningTokensForPart } from "@/features/ai-chat/utils/reasoning-tokens.ts";
import { messageSignature } from "@/features/ai-chat/utils/message-signature.ts";
import { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
import classes from "@/features/ai-chat/components/ai-chat.module.css";
interface MessageItemProps {
message: UIMessage;
/**
* Immutable content signature for `message`, computed by the PARENT
* (MessageList) during its render via `messageSignature(message)`. This is the
* memo key (see `arePropsEqual`): it MUST be a snapshot captured at render time,
* NOT recomputed from `message` inside `arePropsEqual`.
*
* WHY (load-bearing): the AI SDK streams deltas by mutating the SAME `parts`
* array/objects in place and handing back a message wrapper that SHARES those
* mutated parts. So inside `arePropsEqual`, `prev.message` and `next.message`
* both reflect the CURRENT (latest) parts — `messageSignature(prev.message) ===
* messageSignature(next.message)` is therefore ALWAYS true, the memo skips every
* post-mount render, and the assistant row freezes at its initial empty (null)
* render — i.e. the streamed answer + tool cards never appear (reasoning-first
* providers start empty, so NOTHING shows). Snapshotting the signature into this
* immutable string prop in the parent fixes that: `prev.signature` holds the
* value from the previous render (old content) and `next.signature` the new
* content, so they differ as the turn streams in and the row re-renders.
*/
signature: string;
/**
* Forwarded to ToolCallCard: whether tool cards render page citation links.
* Defaults to true (internal chat). The public share passes false.
@@ -88,6 +106,8 @@ function MessageItem({
neutralizeInternalLinks = false,
assistantName,
}: MessageItemProps) {
// `signature` is intentionally not read in the body — it exists solely as the
// memo key (see arePropsEqual). The render reads `message` directly.
const { t } = useTranslation();
const isUser = message.role === "user";
@@ -203,24 +223,30 @@ function MessageItem({
}
/** Skip re-rendering a message whose visible content is unchanged. The streaming
* TAIL message gets a fresh object whose signature changes each delta, so it
* still re-renders and streams in; every FINALIZED message is skipped, turning a
* per-token whole-transcript re-render into a tail-only one. */
* TAIL message gets a fresh `signature` snapshot each delta (computed by the
* parent), so it still re-renders and streams in; every FINALIZED message keeps
* the same signature and is skipped, turning a per-token whole-transcript
* re-render into a tail-only one.
*
* CRITICAL: compare the `signature` PROP (an immutable snapshot the parent took
* at its own render), NEVER `messageSignature(prev.message)` vs
* `messageSignature(next.message)`. The AI SDK mutates the shared `parts` in
* place, so both `prev.message` and `next.message` reflect the latest content
* here — recomputing the signature from them yields equal strings every time and
* freezes the row at its initial empty render (the bug this guards against). See
* the `signature` prop doc. Likewise there is NO `prev.message === next.message`
* fast path: same-reference-but-mutated must still re-render when the snapshot
* signature changed. */
export function arePropsEqual(
prev: MessageItemProps,
next: MessageItemProps,
): boolean {
if (
prev.showCitations !== next.showCitations ||
prev.neutralizeInternalLinks !== next.neutralizeInternalLinks ||
prev.assistantName !== next.assistantName
) {
return false;
}
// Fast path: identical message object (finalized rows keep their identity
// across deltas) — skip without building signatures.
if (prev.message === next.message) return true;
return messageSignature(prev.message) === messageSignature(next.message);
return (
prev.signature === next.signature &&
prev.showCitations === next.showCitations &&
prev.neutralizeInternalLinks === next.neutralizeInternalLinks &&
prev.assistantName === next.assistantName
);
}
export default memo(MessageItem, arePropsEqual);

View File

@@ -6,6 +6,7 @@ import MessageItem from "@/features/ai-chat/components/message-item.tsx";
import TypingIndicator from "@/features/ai-chat/components/typing-indicator.tsx";
import { isToolPart, toolRunState, ToolUiPart } from "@/features/ai-chat/utils/tool-parts.tsx";
import { assistantMessageHasVisibleContent } from "@/features/ai-chat/utils/message-content.ts";
import { messageSignature } from "@/features/ai-chat/utils/message-signature.ts";
import classes from "@/features/ai-chat/components/ai-chat.module.css";
interface MessageListProps {
@@ -196,9 +197,16 @@ export default function MessageList({
<ScrollArea className={classes.messages} viewportRef={viewportRef} scrollbarSize={6} type="scroll">
<Stack gap={0} pr="xs">
{messages.map((message) => (
// `signature` is snapshotted HERE (parent render) into an immutable
// string and handed to MessageItem as its memo key. It must NOT be
// recomputed inside MessageItem's arePropsEqual: the AI SDK mutates the
// shared `parts` in place, so prev/next message objects both read the
// latest content there and the memo would skip every streamed update
// (freezing the row at its empty render). See message-item.tsx.
<MessageItem
key={message.id}
message={message}
signature={messageSignature(message)}
showCitations={showCitations}
neutralizeInternalLinks={neutralizeInternalLinks}
assistantName={assistantName}

View File

@@ -23,7 +23,6 @@ import { acceptInvitation } from "@/features/workspace/services/workspace-servic
import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route.ts";
import { RESET } from "jotai/utils";
import { useTranslation } from "react-i18next";
import { clearOfflineCache } from "@/features/offline/clear-offline-cache";
export default function useAuth() {
const { t } = useTranslation();
@@ -124,13 +123,6 @@ export default function useAuth() {
const handleLogout = async () => {
setCurrentUser(RESET);
await logout();
// Purge the previous user's offline data while the page is still alive —
// window.location.replace below would otherwise interrupt async cleanup.
try {
await clearOfflineCache();
} catch {
// best-effort: never block logout on cache cleanup
}
window.location.replace(`${APP_ROUTE.AUTH.LOGIN}?logout=1`);
};

View File

@@ -10,12 +10,6 @@ export const readOnlyEditorAtom = atom<Editor | null>(null);
export const yjsConnectionStatusAtom = atom<string>("");
// Local (IndexedDB) persistence sync state for the current page's Y.Doc.
export const isLocalSyncedAtom = atom<boolean>(false);
// Remote (Hocuspocus) sync state for the current page's Y.Doc.
export const isRemoteSyncedAtom = atom<boolean>(false);
export const showAiMenuAtom = atom(false);
export const showLinkMenuAtom = atom(false);

View File

@@ -1,21 +0,0 @@
import { createContext, useContext } from "react";
import type { HocuspocusProvider } from "@hocuspocus/provider";
import type * as Y from "yjs";
// Shared collaboration providers lifted above the title/body editors so that
// both siblings bind to the SAME Y.Doc and HocuspocusProvider. The title lives
// in a dedicated 'title' fragment of the same doc as the body.
export interface EditorProvidersContextValue {
ydoc: Y.Doc;
remote: HocuspocusProvider;
providersReady: boolean;
}
export const EditorProvidersContext =
createContext<EditorProvidersContextValue | null>(null);
// Returns the shared providers, or null when rendered outside of a provider.
// Consumers must be null-safe (the body editor falls back to a non-collab mode).
export function useEditorProviders(): EditorProvidersContextValue | null {
return useContext(EditorProvidersContext);
}

View File

@@ -34,8 +34,6 @@ import {
} from "@/features/editor/atoms/editor-atoms.ts";
import { DictationGroup } from "@/features/editor/components/fixed-toolbar/groups/dictation-group";
import { GenerateTitleGroup } from "@/features/editor/components/fixed-toolbar/groups/generate-title-group";
import { usePageCollabProviders } from "@/features/editor/hooks/use-page-collab-providers";
import { EditorProvidersContext } from "@/features/editor/contexts/editor-providers-context";
const MemoizedTitleEditor = React.memo(TitleEditor);
const MemoizedPageEditor = React.memo(PageEditor);
@@ -92,10 +90,6 @@ export function FullEditor({
user.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
const isEditMode = currentPageEditMode === PageEditMode.Edit;
// Single shared Y.Doc + HocuspocusProvider for both the title and body
// editors (title lives in the 'title' fragment of the same doc).
const { ydoc, remote, providersReady } = usePageCollabProviders(pageId);
// Apply the user's saved preference only once on initial load, not on every
// page navigation — so the mode sticks across navigations within a session.
useEffect(() => {
@@ -116,32 +110,28 @@ export function FullEditor({
)}
<MemoizedDeletedPageBanner slugId={slugId} />
<MemoizedTemporaryNoteBanner slugId={slugId} />
<EditorProvidersContext.Provider
value={ydoc && remote ? { ydoc, remote, providersReady } : null}
>
<MemoizedTitleEditor
pageId={pageId}
slugId={slugId}
title={title}
spaceSlug={spaceSlug}
editable={editable}
/>
<PageByline
pageId={pageId}
creator={creator}
contributors={contributors}
editable={editable}
isEditMode={isEditMode}
isDictationEnabled={isDictationEnabled}
isTitleGenEnabled={isTitleGenEnabled}
/>
<MemoizedPageEditor
pageId={pageId}
editable={editable}
content={content}
canComment={canComment}
/>
</EditorProvidersContext.Provider>
<MemoizedTitleEditor
pageId={pageId}
slugId={slugId}
title={title}
spaceSlug={spaceSlug}
editable={editable}
/>
<PageByline
pageId={pageId}
creator={creator}
contributors={contributors}
editable={editable}
isEditMode={isEditMode}
isDictationEnabled={isDictationEnabled}
isTitleGenEnabled={isTitleGenEnabled}
/>
<MemoizedPageEditor
pageId={pageId}
editable={editable}
content={content}
canComment={canComment}
/>
</Container>
);
}

View File

@@ -1,205 +0,0 @@
import { useEffect, useRef, useState } from "react";
import { IndexeddbPersistence } from "y-indexeddb";
import * as Y from "yjs";
import {
HocuspocusProvider,
onStatusParameters,
WebSocketStatus,
HocuspocusProviderWebsocket,
onSyncedParameters,
onStatelessParameters,
} from "@hocuspocus/provider";
import { useAtom, useSetAtom } from "jotai";
import useCollaborationUrl from "@/features/editor/hooks/use-collaboration-url";
import {
isLocalSyncedAtom,
isRemoteSyncedAtom,
yjsConnectionStatusAtom,
} from "@/features/editor/atoms/editor-atoms";
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
import { useDocumentVisibility } from "@mantine/hooks";
import { useIdle } from "@/hooks/use-idle.ts";
import { queryClient } from "@/main.tsx";
import { IPage } from "@/features/page/types/page.types.ts";
import { useParams } from "react-router-dom";
import { extractPageSlugId } from "@/lib";
import { FIVE_MINUTES } from "@/lib/constants.ts";
import { jwtDecode } from "jwt-decode";
export interface PageCollabProviders {
ydoc: Y.Doc | null;
remote: HocuspocusProvider | null;
socket: HocuspocusProviderWebsocket | null;
providersReady: boolean;
}
/**
* Owns the full collaboration provider lifecycle for a page so that the title
* and body editors can share a single Y.Doc + HocuspocusProvider. The behavior
* is relocated verbatim from page-editor.tsx: it creates the providers once per
* pageId, connects/disconnects on idle/visibility, attaches each render,
* destroys on unmount, refreshes the collab token on auth failure, and applies
* the onStateless 'page.updated' cache update.
*/
export function usePageCollabProviders(pageId: string): PageCollabProviders {
const collaborationURL = useCollaborationUrl();
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
yjsConnectionStatusAtom,
);
const setIsLocalSyncedAtom = useSetAtom(isLocalSyncedAtom);
const setIsRemoteSyncedAtom = useSetAtom(isRemoteSyncedAtom);
const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken();
// The provider-creating effect runs only once per pageId, so any token read
// inside its handlers would be captured STALE (the old token at first render).
// Mirror the latest token into a ref the auth-failure handler can read live.
const collabTokenRef = useRef<string | undefined>(undefined);
useEffect(() => {
collabTokenRef.current = collabQuery?.token;
}, [collabQuery?.token]);
const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false });
const documentState = useDocumentVisibility();
const { pageSlug } = useParams();
const slugId = extractPageSlugId(pageSlug);
// Providers only created once per pageId
const providersRef = useRef<{
ydoc: Y.Doc;
local: IndexeddbPersistence;
remote: HocuspocusProvider;
socket: HocuspocusProviderWebsocket;
} | null>(null);
const [providersReady, setProvidersReady] = useState(false);
// Mirror the local/remote sync flags into shared atoms so the header
// indicator can read them. These atoms are the single source of truth; the
// wrappers keep the existing call sites valid while driving only the atoms.
const setLocalSynced = (value: boolean) => {
setIsLocalSyncedAtom(value);
};
const setRemoteSynced = (value: boolean) => {
setIsRemoteSyncedAtom(value);
};
useEffect(() => {
if (!providersRef.current) {
const documentName = `page.${pageId}`;
const ydoc = new Y.Doc();
const local = new IndexeddbPersistence(documentName, ydoc);
const socket = new HocuspocusProviderWebsocket({
url: collaborationURL,
});
const onLocalSyncedHandler = () => {
setLocalSynced(true);
};
const onStatusHandler = (event: onStatusParameters) => {
setYjsConnectionStatus(event.status);
};
const onSyncedHandler = (event: onSyncedParameters) => {
setRemoteSynced(event.state);
};
const onStatelessHandler = ({ payload }: onStatelessParameters) => {
try {
const message = JSON.parse(payload);
if (message?.type !== "page.updated" || !message.updatedAt) return;
const pageData = queryClient.getQueryData<IPage>(["pages", slugId]);
if (pageData) {
queryClient.setQueryData(["pages", slugId], {
...pageData,
updatedAt: message.updatedAt,
...(message.lastUpdatedBy && {
lastUpdatedBy: message.lastUpdatedBy,
}),
});
}
} catch {
// ignore unrelated stateless messages
}
};
const onAuthenticationFailedHandler = () => {
// Read the token from the ref, not the closed-over `collabQuery`: this
// handler is created once and would otherwise decode a stale token after
// a refetch. A missing/malformed token must NOT crash the handler —
// jwtDecode(undefined) throws — so treat any decode failure as "needs
// refresh" and proceed to refetch + reconnect instead of getting stuck.
const token = collabTokenRef.current;
let needsRefresh = true; // no/unparseable token -> fetch a fresh one and reconnect
if (token) {
try {
const payload = jwtDecode<{ exp: number }>(token);
needsRefresh = Date.now() / 1000 >= payload.exp;
} catch {
needsRefresh = true; // malformed token -> refresh
}
}
if (!needsRefresh) return;
refetchCollabToken().then((result) => {
if (result.data?.token) {
socket.disconnect();
setTimeout(() => {
remote.configuration.token = result.data.token;
socket.connect();
}, 100);
}
});
};
const remote = new HocuspocusProvider({
websocketProvider: socket,
name: documentName,
document: ydoc,
token: collabQuery?.token,
onAuthenticationFailed: onAuthenticationFailedHandler,
onStatus: onStatusHandler,
onSynced: onSyncedHandler,
onStateless: onStatelessHandler,
});
local.on("synced", onLocalSyncedHandler);
providersRef.current = { ydoc, socket, local, remote };
setProvidersReady(true);
} else {
setProvidersReady(true);
}
// Only destroy on final unmount
return () => {
providersRef.current?.socket.destroy();
providersRef.current?.remote.destroy();
providersRef.current?.local.destroy();
providersRef.current = null;
// Reset shared sync state on page change/unmount.
setLocalSynced(false);
setRemoteSynced(false);
};
}, [pageId]);
// Only connect/disconnect on tab/idle, not destroy
useEffect(() => {
if (!providersReady || !providersRef.current) return;
const socket = providersRef.current.socket;
if (
isIdle &&
documentState === "hidden" &&
yjsConnectionStatus === WebSocketStatus.Connected
) {
socket.disconnect();
return;
}
if (
documentState === "visible" &&
yjsConnectionStatus === WebSocketStatus.Disconnected
) {
resetIdle();
socket.connect();
}
}, [isIdle, documentState, providersReady, resetIdle]);
// Attach here, to make sure the connection gets properly established
providersRef.current?.remote.attach();
return {
ydoc: providersRef.current?.ydoc ?? null,
remote: providersRef.current?.remote ?? null,
socket: providersRef.current?.socket ?? null,
providersReady,
};
}

View File

@@ -6,7 +6,16 @@ import React, {
useRef,
useState,
} from "react";
import { WebSocketStatus } from "@hocuspocus/provider";
import { IndexeddbPersistence } from "y-indexeddb";
import * as Y from "yjs";
import {
HocuspocusProvider,
onStatusParameters,
WebSocketStatus,
HocuspocusProviderWebsocket,
onSyncedParameters,
onStatelessParameters,
} from "@hocuspocus/provider";
import {
Editor,
EditorContent,
@@ -19,15 +28,13 @@ import {
mainExtensions,
} from "@/features/editor/extensions/extensions";
import { useAtom, useAtomValue } from "jotai";
import useCollaborationUrl from "@/features/editor/hooks/use-collaboration-url";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import {
currentPageEditModeAtom,
isLocalSyncedAtom,
isRemoteSyncedAtom,
pageEditorAtom,
yjsConnectionStatusAtom,
} from "@/features/editor/atoms/editor-atoms";
import { useEditorProviders } from "@/features/editor/contexts/editor-providers-context";
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
import {
activeCommentIdAtom,
@@ -51,8 +58,10 @@ import {
} from "@/features/editor/components/common/editor-paste-handler.tsx";
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu-lazy";
import DrawioMenu from "./components/drawio/drawio-menu";
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
import SearchAndReplaceDialog from "@/features/editor/components/search-and-replace/search-and-replace-dialog.tsx";
import { useDebouncedCallback } from "@mantine/hooks";
import { useDebouncedCallback, useDocumentVisibility } from "@mantine/hooks";
import { useIdle } from "@/hooks/use-idle.ts";
import { queryClient } from "@/main.tsx";
import { IPage } from "@/features/page/types/page.types.ts";
import { useParams } from "react-router-dom";
@@ -63,7 +72,9 @@ import {
GitmostInsertRecordingResult,
gitmostInsertRecordingIntoEditor,
} from "@/features/editor/gitmost/gitmost-recording.ts";
import { FIVE_MINUTES } from "@/lib/constants.ts";
import { PageEditMode } from "@/features/user/types/user.types.ts";
import { jwtDecode } from "jwt-decode";
import { searchSpotlight } from "@/features/search/constants.ts";
import { useEditorScroll } from "./hooks/use-editor-scroll";
import { EditorLinkMenu } from "@/features/editor/components/link/link-menu";
@@ -88,6 +99,7 @@ export default function PageEditor({
canComment,
}: PageEditorProps) {
const { t } = useTranslation();
const collaborationURL = useCollaborationUrl();
const isComponentMounted = useRef(false);
const editorRef = useRef<Editor | null>(null);
@@ -101,10 +113,22 @@ export default function PageEditor({
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
const [showReadOnlyCommentPopup] = useAtom(showReadOnlyCommentPopupAtom);
const [isLocalSynced, setIsLocalSynced] = useState(false);
const [isRemoteSynced, setIsRemoteSynced] = useState(false);
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
yjsConnectionStatusAtom,
);
const menuContainerRef = useRef(null);
const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken();
// Always holds the latest collab token. The provider effect below runs once
// per pageId, so a handler created inside it would otherwise close over a
// stale `collabQuery`. Reading the ref gives the current token instead.
const collabTokenRef = useRef<string | undefined>(undefined);
useEffect(() => {
collabTokenRef.current = collabQuery?.token;
}, [collabQuery?.token]);
const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false });
const documentState = useDocumentVisibility();
const { pageSlug } = useParams();
const slugId = extractPageSlugId(pageSlug);
const currentPageEditMode = useAtomValue(currentPageEditModeAtom);
@@ -113,27 +137,141 @@ export default function PageEditor({
[isComponentMounted],
);
const { handleScrollTo } = useEditorScroll({ canScroll });
// Providers only created once per pageId
const providersRef = useRef<{
local: IndexeddbPersistence;
remote: HocuspocusProvider;
socket: HocuspocusProviderWebsocket;
} | null>(null);
const [providersReady, setProvidersReady] = useState(false);
// Shared providers + Y.Doc lifted into full-editor via context. The provider
// lifecycle (creation, idle/visibility connect, attach, destroy, token
// refresh) lives in usePageCollabProviders. Null-safe when rendered without
// the context (defensive) — in practice full-editor always provides it.
const editorProviders = useEditorProviders();
const remote = editorProviders?.remote ?? null;
const providersReady = editorProviders?.providersReady ?? false;
const isLocalSynced = useAtomValue(isLocalSyncedAtom);
const isRemoteSynced = useAtomValue(isRemoteSyncedAtom);
useEffect(() => {
if (!providersRef.current) {
const documentName = `page.${pageId}`;
const ydoc = new Y.Doc();
const local = new IndexeddbPersistence(documentName, ydoc);
const socket = new HocuspocusProviderWebsocket({
url: collaborationURL,
});
const onLocalSyncedHandler = () => {
setIsLocalSynced(true);
};
const onStatusHandler = (event: onStatusParameters) => {
setYjsConnectionStatus(event.status);
};
const onSyncedHandler = (event: onSyncedParameters) => {
setIsRemoteSynced(event.state);
};
const onStatelessHandler = ({ payload }: onStatelessParameters) => {
try {
const message = JSON.parse(payload);
if (message?.type !== "page.updated" || !message.updatedAt) return;
const pageData = queryClient.getQueryData<IPage>(["pages", slugId]);
if (pageData) {
queryClient.setQueryData(["pages", slugId], {
...pageData,
updatedAt: message.updatedAt,
...(message.lastUpdatedBy && {
lastUpdatedBy: message.lastUpdatedBy,
}),
});
}
} catch {
// ignore unrelated stateless messages
}
};
const onAuthenticationFailedHandler = () => {
// Read the latest token via the ref (the closure-captured `collabQuery`
// may be stale). Guard the decode: a missing or unparseable token must
// not throw "Invalid token specified" and should trigger a refresh so
// the editor reconnects even when the initial token fetch failed.
const token = collabTokenRef.current;
let needsRefresh = true; // no/unparseable token -> fetch a fresh one and reconnect
if (token) {
try {
// A token that decodes but lacks a numeric `exp` must be treated as
// expired (`Date.now()/1000 >= undefined` is `false`, which would
// otherwise skip the reconnect), so refresh on any missing/non-number exp.
const exp = jwtDecode<{ exp?: number }>(token).exp;
needsRefresh = typeof exp !== "number" || Date.now() / 1000 >= exp;
} catch {
needsRefresh = true;
}
}
if (!needsRefresh) return;
refetchCollabToken().then((result) => {
if (result.data?.token) {
socket.disconnect();
setTimeout(() => {
remote.configuration.token = result.data.token;
socket.connect();
}, 100);
}
});
};
const remote = new HocuspocusProvider({
websocketProvider: socket,
name: documentName,
document: ydoc,
token: collabQuery?.token,
onAuthenticationFailed: onAuthenticationFailedHandler,
onStatus: onStatusHandler,
onSynced: onSyncedHandler,
onStateless: onStatelessHandler,
});
local.on("synced", onLocalSyncedHandler);
providersRef.current = { socket, local, remote };
setProvidersReady(true);
} else {
setProvidersReady(true);
}
// Only destroy on final unmount
return () => {
providersRef.current?.socket.destroy();
providersRef.current?.remote.destroy();
providersRef.current?.local.destroy();
providersRef.current = null;
};
}, [pageId]);
// Only connect/disconnect on tab/idle, not destroy
useEffect(() => {
if (!providersReady || !providersRef.current) return;
const socket = providersRef.current.socket;
if (
isIdle &&
documentState === "hidden" &&
yjsConnectionStatus === WebSocketStatus.Connected
) {
socket.disconnect();
return;
}
if (
documentState === "visible" &&
yjsConnectionStatus === WebSocketStatus.Disconnected
) {
resetIdle();
socket.connect();
}
}, [isIdle, documentState, providersReady, resetIdle]);
// Attach here, to make sure the connection gets properly established
providersRef.current?.remote.attach();
const extensions = useMemo(() => {
if (!providersReady || !remote || !currentUser?.user) {
if (!providersReady || !providersRef.current || !currentUser?.user) {
return mainExtensions;
}
const remoteProvider = providersRef.current.remote;
return [
...mainExtensions,
...collabExtensions(remote, currentUser?.user),
...collabExtensions(remoteProvider, currentUser?.user),
];
}, [providersReady, remote, currentUser?.user]);
}, [providersReady, currentUser?.user]);
const editor = useEditor(
{
@@ -375,7 +513,7 @@ export default function PageEditor({
{editor &&
!editorIsEditable &&
(editable || canComment) &&
remote && <ReadonlyBubbleMenu editor={editor} />}
providersRef.current && <ReadonlyBubbleMenu editor={editor} />}
{showCommentPopup && (
<CommentDialog editor={editor} pageId={pageId} />
)}

View File

@@ -1,5 +1,5 @@
import "@/features/editor/styles/index.css";
import { useEffect } from "react";
import React, { useCallback, useEffect, useState } from "react";
import { EditorContent, useEditor } from "@tiptap/react";
import { Document } from "@tiptap/extension-document";
import { Heading } from "@tiptap/extension-heading";
@@ -11,14 +11,14 @@ import {
pageEditorAtom,
titleEditorAtom,
} from "@/features/editor/atoms/editor-atoms";
import { updatePageData } from "@/features/page/queries/page-query";
import {
updatePageData,
useUpdateTitlePageMutation,
} from "@/features/page/queries/page-query";
import { useDebouncedCallback, getHotkeyHandler } from "@mantine/hooks";
import { useAtom } from "jotai";
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
import {
Collaboration,
isChangeOrigin,
} from "@tiptap/extension-collaboration";
import { History } from "@tiptap/extension-history";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
@@ -28,9 +28,6 @@ import localEmitter from "@/lib/local-emitter.ts";
import { PageEditMode } from "@/features/user/types/user.types.ts";
import { searchSpotlight } from "@/features/search/constants.ts";
import { platformModifierKey } from "@/lib";
import { useEditorProviders } from "@/features/editor/contexts/editor-providers-context";
import { queryClient } from "@/main.tsx";
import { IPage } from "@/features/page/types/page.types.ts";
export interface TitleEditorProps {
pageId: string;
@@ -48,83 +45,65 @@ export function TitleEditor({
editable,
}: TitleEditorProps) {
const { t } = useTranslation();
const { mutateAsync: updateTitlePageMutationAsync } =
useUpdateTitlePageMutation();
const pageEditor = useAtomValue(pageEditorAtom);
const [, setTitleEditor] = useAtom(titleEditorAtom);
const emit = useQueryEmit();
const navigate = useNavigate();
const [activePageId, setActivePageId] = useState(pageId);
const currentPageEditMode = useAtomValue(currentPageEditModeAtom);
// Shared Y.Doc (title lives in its own 'title' fragment of the same doc as
// the body). Yjs is the source of truth for the title content.
const editorProviders = useEditorProviders();
const ydoc = editorProviders?.ydoc ?? null;
const providersReady = editorProviders?.providersReady ?? false;
// Until the shared doc is ready, the collaborative editor binds nothing and
// would render an empty heading until the Yjs 'title' fragment hydrates. Show
// a non-editable static <h1> with the `title` prop in the meantime. The prop
// is NEVER fed into the collaborative editor (Yjs stays the single source of
// truth — seeding it would duplicate the title).
const titleReady = providersReady && !!ydoc;
const titleEditor = useEditor(
{
extensions: [
Document.extend({
content: "heading",
}),
Heading.configure({
levels: [1],
}),
Text,
Placeholder.configure({
placeholder: t("Untitled"),
showOnlyWhenEditable: false,
}),
// Bind the title to the dedicated 'title' fragment of the shared doc.
// Collaboration also manages undo/redo, so the History extension is
// intentionally omitted (it would conflict with Yjs). When the doc is
// not ready yet the editor renders empty until the doc arrives.
...(ydoc
? [Collaboration.configure({ document: ydoc, field: "title" })]
: []),
EmojiCommand,
],
onCreate({ editor }) {
if (editor) {
// @ts-ignore
setTitleEditor(editor);
}
const titleEditor = useEditor({
extensions: [
Document.extend({
content: "heading",
}),
Heading.configure({
levels: [1],
}),
Text,
Placeholder.configure({
placeholder: t("Untitled"),
showOnlyWhenEditable: false,
}),
History.configure({
depth: 20,
}),
EmojiCommand,
],
onCreate({ editor }) {
if (editor) {
// @ts-ignore
setTitleEditor(editor);
setActivePageId(pageId);
}
},
onUpdate({ editor }) {
debounceUpdate();
},
editable: editable,
content: title,
immediatelyRender: true,
shouldRerenderOnTransaction: false,
editorProps: {
attributes: {
"aria-label": t("Page title"),
},
onUpdate({ editor, transaction }) {
// Drive URL + tree propagation only on genuine local edits; skip
// remote/collab-origin Yjs updates to avoid feedback loops.
if (transaction && isChangeOrigin(transaction)) return;
debouncedPropagateTitle(editor.getText());
},
editable: editable,
immediatelyRender: true,
shouldRerenderOnTransaction: false,
editorProps: {
attributes: {
"aria-label": t("Page title"),
},
handleDOMEvents: {
keydown: (_view, event) => {
if (platformModifierKey(event) && event.code === "KeyS") {
event.preventDefault();
return true;
}
if (platformModifierKey(event) && event.code === "KeyK") {
searchSpotlight.open();
return true;
}
},
handleDOMEvents: {
keydown: (_view, event) => {
if (platformModifierKey(event) && event.code === "KeyS") {
event.preventDefault();
return true;
}
if (platformModifierKey(event) && event.code === "KeyK") {
searchSpotlight.open();
return true;
}
},
},
},
[pageId, ydoc],
);
});
useEffect(() => {
const anchorId = window.location.hash
@@ -134,42 +113,59 @@ export function TitleEditor({
navigate(pageSlug, { replace: true });
}, [title]);
// On a local title change: update the URL slug and propagate the change to
// the live tree/breadcrumbs for online users. No REST round-trip — the title
// itself is persisted through Yjs. Offline this simply no-ops the socket
// emit and the title syncs on reconnect.
const debouncedPropagateTitle = useDebouncedCallback((titleText: string) => {
const anchorId = window.location.hash
? window.location.hash.substring(1)
: undefined;
navigate(buildPageUrl(spaceSlug, slugId, titleText, anchorId), {
replace: true,
const saveTitle = useCallback(() => {
if (!titleEditor || activePageId !== pageId) return;
if (
titleEditor.getText() === title ||
(titleEditor.getText() === "" && title === null)
) {
return;
}
updateTitlePageMutationAsync({
pageId: pageId,
title: titleEditor.getText(),
}).then((page) => {
const event: UpdateEvent = {
operation: "updateOne",
spaceId: page.spaceId,
entity: ["pages"],
id: page.id,
payload: {
title: page.title,
slugId: page.slugId,
parentPageId: page.parentPageId,
icon: page.icon,
},
};
if (page.title !== titleEditor.getText()) return;
updatePageData(page);
localEmitter.emit("message", event);
emit(event);
});
}, [pageId, title, titleEditor]);
const page =
queryClient.getQueryData<IPage>(["pages", slugId]) ??
queryClient.getQueryData<IPage>(["pages", pageId]);
if (!page) return;
const debounceUpdate = useDebouncedCallback(saveTitle, 500);
const updatedPage: IPage = { ...page, title: titleText };
const event: UpdateEvent = {
operation: "updateOne",
spaceId: page.spaceId,
entity: ["pages"],
id: page.id,
payload: {
title: titleText,
slugId: page.slugId,
parentPageId: page.parentPageId,
icon: page.icon,
},
};
updatePageData(updatedPage);
localEmitter.emit("message", event);
emit(event);
}, 500);
useEffect(() => {
// Do not overwrite the title while the user is actively editing it. The
// server rebroadcasts PAGE_UPDATED to the author too, and that echo can
// carry a title that lags behind what the user has just typed; resetting
// content from it here would drop in-progress characters and jump the
// cursor. Apply external title changes only when the field is not focused.
if (
titleEditor &&
!titleEditor.isDestroyed &&
!titleEditor.isFocused &&
title !== titleEditor.getText()
) {
titleEditor.commands.setContent(title);
}
}, [pageId, title, titleEditor]);
useEffect(() => {
setTimeout(() => {
@@ -179,6 +175,13 @@ export function TitleEditor({
}, 300);
}, [titleEditor]);
useEffect(() => {
return () => {
// force-save title on navigation
saveTitle();
};
}, [pageId]);
useEffect(() => {
if (!titleEditor) return;
titleEditor.setEditable(editable && currentPageEditMode === PageEditMode.Edit);
@@ -245,22 +248,16 @@ export function TitleEditor({
return (
<div className="page-title">
{titleReady ? (
<EditorContent
editor={titleEditor}
onKeyDown={(event) => {
// First handle the search hotkey
getHotkeyHandler([["mod+F", openSearchDialog]])(event);
<EditorContent
editor={titleEditor}
onKeyDown={(event) => {
// First handle the search hotkey
getHotkeyHandler([["mod+F", openSearchDialog]])(event);
// Then handle other key events
handleTitleKeyDown(event);
}}
/>
) : (
// Static, non-editable fallback so the title is visible before Yjs
// hydrates the 'title' fragment. Not wired into the collaborative editor.
<h1>{title}</h1>
)}
// Then handle other key events
handleTitleKeyDown(event);
}}
/>
</div>
);
}

View File

@@ -1,107 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// vi.mock factories are hoisted above imports, so the spies they reference must
// be declared via vi.hoisted (also hoisted). These are inspected by assertions.
const h = vi.hoisted(() => ({
clear: vi.fn(),
del: vi.fn(),
}));
// The module under test imports the app entry at load time — it must be mocked.
vi.mock("@/main.tsx", () => ({
queryClient: { clear: h.clear },
}));
vi.mock("idb-keyval", () => ({
del: h.del,
}));
import { clearOfflineCache } from "./clear-offline-cache";
import { OFFLINE_CACHE_KEY } from "./query-persister";
// jsdom does not provide indexedDB.databases() or Cache Storage, so the browser
// globals are stubbed per-test. We restore them afterwards.
const originalIndexedDB = (globalThis as any).indexedDB;
const originalCaches = (globalThis as any).caches;
beforeEach(() => {
h.clear.mockClear();
h.del.mockClear();
});
afterEach(() => {
(globalThis as any).indexedDB = originalIndexedDB;
(globalThis as any).caches = originalCaches;
vi.restoreAllMocks();
});
describe("clearOfflineCache", () => {
it("resolves without throwing when the browser globals are absent", async () => {
(globalThis as any).indexedDB = undefined;
delete (globalThis as any).caches;
await expect(clearOfflineCache()).resolves.toBeUndefined();
// The two store-agnostic steps still run.
expect(h.clear).toHaveBeenCalledTimes(1);
expect(h.del).toHaveBeenCalledWith(OFFLINE_CACHE_KEY);
});
it("deletes only `page.*` IndexedDB databases and only `api-get-cache` caches", async () => {
const deleteDatabase = vi.fn((_name: string) => {
const request: any = {};
// Resolve the deletion on the next microtask, like a real IDBRequest.
queueMicrotask(() => request.onsuccess && request.onsuccess());
return request;
});
(globalThis as any).indexedDB = {
databases: vi
.fn()
.mockResolvedValue([
{ name: "page.aaa" },
{ name: "page.bbb" },
{ name: "keyval-store" },
{ name: undefined },
]),
deleteDatabase,
};
const cacheDelete = vi.fn().mockResolvedValue(true);
(globalThis as any).caches = {
keys: vi
.fn()
.mockResolvedValue([
"workbox-runtime-https://app/api-get-cache",
"other-cache",
]),
delete: cacheDelete,
};
await expect(clearOfflineCache()).resolves.toBeUndefined();
// Only the two page.* databases are deleted.
expect(deleteDatabase).toHaveBeenCalledTimes(2);
expect(deleteDatabase).toHaveBeenCalledWith("page.aaa");
expect(deleteDatabase).toHaveBeenCalledWith("page.bbb");
// Only the api-get-cache entry is deleted.
expect(cacheDelete).toHaveBeenCalledTimes(1);
expect(cacheDelete).toHaveBeenCalledWith(
"workbox-runtime-https://app/api-get-cache",
);
});
it("never throws even if a step rejects (best-effort)", async () => {
h.del.mockRejectedValueOnce(new Error("idb boom"));
(globalThis as any).indexedDB = {
databases: vi.fn().mockRejectedValue(new Error("databases boom")),
deleteDatabase: vi.fn(),
};
(globalThis as any).caches = {
keys: vi.fn().mockRejectedValue(new Error("caches boom")),
delete: vi.fn(),
};
await expect(clearOfflineCache()).resolves.toBeUndefined();
expect(h.clear).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,92 +0,0 @@
import { del } from "idb-keyval";
import { queryClient } from "@/main.tsx";
import { OFFLINE_CACHE_KEY } from "./query-persister";
/**
* Best-effort purge of all of the current user's offline data from the browser.
*
* On logout the previous user's private data would otherwise linger locally and
* be readable by the next person on the device. This clears the three offline
* stores the app writes:
* 1. the in-memory + IndexedDB-persisted TanStack Query cache (idb-keyval key
* `OFFLINE_CACHE_KEY`),
* 2. the Yjs page documents (IndexedDB databases named `page.<id>` created by
* y-indexeddb in make-offline.ts), and
* 3. any legacy service worker `api-get-cache` Cache Storage entry. The
* Workbox runtime no longer creates this cache (the GET /api NetworkFirst
* rule was removed — offline reads come from the persisted RQ cache), so
* this is now a defensive cleanup for caches left by older app versions.
*
* Fully best-effort: every step is isolated so a single failure neither blocks
* the remaining steps nor throws to the caller (logout must never be blocked on
* cache cleanup). Callers may ignore the resolved value.
*
* Limitations:
* - Deleting the Yjs page databases relies on `indexedDB.databases()`, which
* is unavailable in some browsers (notably Firefox). There we skip silently;
* those `page.<id>` databases are then left in place.
* - Cache Storage clearing only runs where `caches` exists (secure contexts /
* service-worker-capable browsers).
*/
export async function clearOfflineCache(): Promise<void> {
// 1a. Drop the in-memory query cache immediately.
try {
queryClient.clear();
} catch {
// best-effort: ignore in-memory cache reset failures
}
// 1b. Delete the persisted RQ cache from IndexedDB.
try {
await del(OFFLINE_CACHE_KEY);
} catch {
// best-effort: ignore persisted-cache deletion failures
}
// 2. Delete the Yjs page IndexedDB databases (`page.<id>`).
// `indexedDB.databases()` is not implemented everywhere (e.g. Firefox); when
// it is missing we cannot enumerate the page databases, so we skip silently.
try {
if (
typeof indexedDB !== "undefined" &&
typeof indexedDB.databases === "function"
) {
const dbs = await indexedDB.databases();
for (const db of dbs) {
const name = db?.name;
if (typeof name !== "string" || !name.startsWith("page.")) continue;
try {
// Fire-and-forget delete; await a thin wrapper so a slow delete does
// not race the page teardown, but never reject on it.
await new Promise<void>((resolve) => {
const request = indexedDB.deleteDatabase(name);
request.onsuccess = () => resolve();
request.onerror = () => resolve();
request.onblocked = () => resolve();
});
} catch {
// best-effort per database
}
}
}
} catch {
// best-effort: ignore enumeration/deletion failures
}
// 3. Clear any legacy service worker API cache. Current builds no longer
// create it, but an older client may have left an "api-get-cache" entry
// (Workbox may prefix the name), so match by substring rather than exact name.
try {
if ("caches" in window) {
const keys = await caches.keys();
await Promise.all(
keys
.filter((key) => key.includes("api-get-cache"))
.map((key) => caches.delete(key)),
);
}
} catch {
// best-effort: ignore Cache Storage failures
}
}

View File

@@ -1,258 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// vi.mock factories are hoisted above imports, so any spy they reference must be
// declared with vi.hoisted (which is hoisted as well). These shared spies are
// inspected by the assertions below.
const h = vi.hoisted(() => ({
ydocDestroy: vi.fn(),
idbDestroy: vi.fn(),
providerOn: vi.fn(),
providerOff: vi.fn(),
providerDestroy: vi.fn(),
}));
// The module under test imports the app entry at load time — it must be mocked.
vi.mock("@/main.tsx", () => ({
queryClient: { setQueryData: vi.fn(), prefetchQuery: vi.fn() },
}));
vi.mock("@/features/page/services/page-service", () => ({
getPageById: vi.fn(),
getPageBreadcrumbs: vi.fn(),
getSidebarPages: vi.fn(),
getAllSidebarPages: vi.fn(),
}));
vi.mock("@/features/space/services/space-service.ts", () => ({
getSpaceById: vi.fn(),
}));
vi.mock("@/features/comment/services/comment-service", () => ({
getPageComments: vi.fn(),
}));
// Use the `function` form (not an arrow) so Vitest binds the constructor return
// value when the module under test calls `new Y.Doc()` etc.
vi.mock("yjs", () => ({
Doc: vi.fn(function () {
return { destroy: h.ydocDestroy };
}),
}));
vi.mock("y-indexeddb", () => ({
IndexeddbPersistence: vi.fn(function () {
return { destroy: h.idbDestroy };
}),
}));
vi.mock("@hocuspocus/provider", () => ({
HocuspocusProvider: vi.fn(function () {
return { on: h.providerOn, off: h.providerOff, destroy: h.providerDestroy };
}),
}));
import {
warmInfiniteAll,
warmPageYdoc,
makePageAvailableOffline,
} from "./make-offline";
import { queryClient } from "@/main.tsx";
import {
getPageById,
getPageBreadcrumbs,
getSidebarPages,
} from "@/features/page/services/page-service";
import { getPageComments } from "@/features/comment/services/comment-service";
const setQueryData = (queryClient as any).setQueryData as ReturnType<
typeof vi.fn
>;
const prefetchQuery = (queryClient as any).prefetchQuery as ReturnType<
typeof vi.fn
>;
beforeEach(() => {
// Clear call history WITHOUT wiping the mock implementations the vi.mock
// factories installed (vi.clearAllMocks would drop the constructor return
// objects and break the provider/idb/yjs spies).
setQueryData.mockClear();
prefetchQuery.mockReset();
prefetchQuery.mockResolvedValue(undefined);
(getPageById as ReturnType<typeof vi.fn>).mockReset();
(getPageBreadcrumbs as ReturnType<typeof vi.fn>).mockReset();
(getSidebarPages as ReturnType<typeof vi.fn>).mockReset();
(getPageComments as ReturnType<typeof vi.fn>).mockReset();
h.ydocDestroy.mockClear();
h.idbDestroy.mockClear();
h.providerOn.mockClear();
h.providerOff.mockClear();
h.providerDestroy.mockClear();
});
describe("warmInfiniteAll", () => {
it("warms a single page and writes the InfiniteData cache shape", async () => {
const res = { items: [{ id: 1 }], meta: { nextCursor: null } };
const fetchPage = vi.fn().mockResolvedValue(res);
await warmInfiniteAll(["comments", "p1"], fetchPage);
expect(fetchPage).toHaveBeenCalledTimes(1);
expect(fetchPage).toHaveBeenCalledWith(undefined);
expect(setQueryData).toHaveBeenCalledTimes(1);
expect(setQueryData).toHaveBeenCalledWith(["comments", "p1"], {
pages: [res],
pageParams: [undefined],
});
});
it("walks the cursor chain across multiple pages", async () => {
const r0 = { items: [], meta: { nextCursor: "c1" } };
const r1 = { items: [], meta: { nextCursor: "c2" } };
const r2 = { items: [], meta: { nextCursor: null } };
const fetchPage = vi
.fn()
.mockResolvedValueOnce(r0)
.mockResolvedValueOnce(r1)
.mockResolvedValueOnce(r2);
await warmInfiniteAll(["comments", "p1"], fetchPage);
expect(fetchPage).toHaveBeenCalledTimes(3);
expect(fetchPage.mock.calls.map((c) => c[0])).toEqual([
undefined,
"c1",
"c2",
]);
const payload = setQueryData.mock.calls[0][1];
expect(payload.pages).toEqual([r0, r1, r2]);
expect(payload.pageParams).toEqual([undefined, "c1", "c2"]);
});
it("caps pagination at maxPages", async () => {
// Always returns a non-null cursor — the cap is the only thing that stops it.
const fetchPage = vi
.fn()
.mockResolvedValue({ items: [], meta: { nextCursor: "more" } });
await warmInfiniteAll(["comments", "p1"], fetchPage, 2);
expect(fetchPage).toHaveBeenCalledTimes(2);
const payload = setQueryData.mock.calls[0][1];
expect(payload.pages).toHaveLength(2);
});
it("returns true on success", async () => {
const fetchPage = vi
.fn()
.mockResolvedValue({ items: [], meta: { nextCursor: null } });
await expect(
warmInfiniteAll(["comments", "p1"], fetchPage),
).resolves.toBe(true);
});
it("reports errors (returns false) and never writes the cache on failure", async () => {
const fetchPage = vi.fn().mockRejectedValue(new Error("network"));
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
await expect(
warmInfiniteAll(["comments", "p1"], fetchPage),
).resolves.toBe(false);
expect(setQueryData).not.toHaveBeenCalled();
expect(errorSpy).toHaveBeenCalled();
errorSpy.mockRestore();
});
});
describe("makePageAvailableOffline", () => {
const okPage = {
id: "uuid-1",
slugId: "slug-1",
space: { slug: "space-slug" },
};
it("returns ok:true with no failures when every step succeeds", async () => {
(getPageById as ReturnType<typeof vi.fn>).mockResolvedValue(okPage);
(getPageBreadcrumbs as ReturnType<typeof vi.fn>).mockResolvedValue([]);
(getSidebarPages as ReturnType<typeof vi.fn>).mockResolvedValue({
items: [],
meta: { nextCursor: null },
});
(getPageComments as ReturnType<typeof vi.fn>).mockResolvedValue({
items: [],
meta: { nextCursor: null },
});
const result = await makePageAvailableOffline({
pageId: "uuid-1",
spaceId: "space-uuid",
});
expect(result).toEqual({ ok: true, failed: [] });
});
it("returns ok:false with the failed step label when a warm step fails", async () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
(getPageById as ReturnType<typeof vi.fn>).mockResolvedValue(okPage);
(getPageBreadcrumbs as ReturnType<typeof vi.fn>).mockResolvedValue([]);
(getSidebarPages as ReturnType<typeof vi.fn>).mockResolvedValue({
items: [],
meta: { nextCursor: null },
});
// Comments warm fails -> labeled "comments".
(getPageComments as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error("network"),
);
const result = await makePageAvailableOffline({
pageId: "uuid-1",
spaceId: "space-uuid",
});
expect(result.ok).toBe(false);
expect(result.failed).toContain("comments");
expect(errorSpy).toHaveBeenCalled();
errorSpy.mockRestore();
});
});
describe("warmPageYdoc", () => {
afterEach(() => {
vi.useRealTimers();
});
it("resolves on synced, detaches the listener once, and tears everything down (settle-once)", async () => {
const promise = warmPageYdoc("p1", "ws://x");
// Grab the synced handler the provider registered.
expect(h.providerOn).toHaveBeenCalledWith("synced", expect.any(Function));
const handler = h.providerOn.mock.calls.find(
(c) => c[0] === "synced",
)![1] as () => void;
handler();
await expect(promise).resolves.toBeUndefined();
// Listener detached and everything cleaned up.
expect(h.providerOff).toHaveBeenCalledWith("synced", expect.any(Function));
expect(h.providerDestroy).toHaveBeenCalledTimes(1);
expect(h.idbDestroy).toHaveBeenCalledTimes(1);
expect(h.ydocDestroy).toHaveBeenCalledTimes(1);
// Firing the handler again must NOT re-run cleanup (settled guard).
handler();
expect(h.providerDestroy).toHaveBeenCalledTimes(1);
expect(h.idbDestroy).toHaveBeenCalledTimes(1);
expect(h.ydocDestroy).toHaveBeenCalledTimes(1);
});
it("resolves and cleans up after the timeout when synced never fires", async () => {
vi.useFakeTimers();
const promise = warmPageYdoc("p1", "ws://x");
// Do not fire "synced"; let the 8s safety timeout settle it.
await vi.advanceTimersByTimeAsync(8000);
await expect(promise).resolves.toBeUndefined();
expect(h.providerDestroy).toHaveBeenCalledTimes(1);
expect(h.idbDestroy).toHaveBeenCalledTimes(1);
expect(h.ydocDestroy).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,272 +0,0 @@
import * as Y from "yjs";
import { IndexeddbPersistence } from "y-indexeddb";
import { HocuspocusProvider } from "@hocuspocus/provider";
import { queryClient } from "@/main.tsx";
import {
getPageById,
getPageBreadcrumbs,
getSidebarPages,
} from "@/features/page/services/page-service";
import {
pageKeys,
sidebarPagesQueryOptions,
} from "@/features/page/queries/page-query";
import { spaceByIdQueryOptions } from "@/features/space/queries/space-query";
import { RQ_KEY } from "@/features/comment/queries/comment-query";
import { getPageComments } from "@/features/comment/services/comment-service";
import { IPage } from "@/features/page/types/page.types";
import { IPagination } from "@/lib/types.ts";
/**
* Fully paginate an infinite query and write the @tanstack InfiniteData cache
* shape ({ pages, pageParams }) that the matching useInfiniteQuery hook reads.
*
* The default prefetchInfiniteQuery only warms the FIRST page, which leaves
* hooks that treat hasNextPage as still-loading (e.g. the comments panel)
* spinning forever offline, and silently truncates large lists. This walks the
* cursor chain until it runs out (or hits maxPages) so the whole list is cached.
*
* Best-effort: a failure does not throw (a partial/failed warm is still useful),
* but it is reported — the error is logged with context and `false` is returned
* so the caller can record the failed step instead of silently succeeding.
*
* Returns true if the whole list was paginated and written, false on any error.
*
* Exported for unit testing of the cursor-walk / cache-write behavior.
*/
export async function warmInfiniteAll<T>(
queryKey: readonly unknown[],
fetchPage: (cursor: string | undefined) => Promise<IPagination<T>>,
maxPages = 50,
): Promise<boolean> {
try {
const pages: IPagination<T>[] = [];
const pageParams: (string | undefined)[] = [];
let cursor: string | undefined = undefined;
for (let i = 0; i < maxPages; i++) {
const res = await fetchPage(cursor);
pages.push(res);
pageParams.push(cursor);
cursor = res?.meta?.nextCursor ?? undefined;
if (!cursor) break;
}
queryClient.setQueryData(queryKey, { pages, pageParams });
return true;
} catch (error) {
console.error("warmInfiniteAll failed", { queryKey, error });
return false;
}
}
export interface MakePageAvailableOfflineParams {
pageId: string;
spaceId?: string;
}
/**
* Outcome of {@link makePageAvailableOffline}. `ok` is true only when every warm
* step succeeded; `failed` lists the labels of the steps that failed (a subset
* of: "page", "space", "tree", "breadcrumbs", "comments").
*/
export interface MakePageAvailableOfflineResult {
ok: boolean;
failed: string[];
}
/**
* Best-effort prefetch of a page's read queries so they get persisted to
* IndexedDB and become readable offline.
*
* Each step is isolated and this function does NOT throw — a partial warm is
* still useful. Instead of silently succeeding, every failed step is logged
* with a label and recorded in the returned result: `{ ok, failed }` where
* `ok` is true only if no step failed and `failed` lists the failed step
* labels. Only meaningful while online (the underlying requests must succeed).
*/
export async function makePageAvailableOffline({
pageId,
spaceId,
}: MakePageAvailableOfflineParams): Promise<MakePageAvailableOfflineResult> {
const failed: string[] = [];
// Fetch the page document ONCE and write it under BOTH cache keys, exactly
// like usePageQuery's onData effect. Every page consumer reads
// pageKeys.detail(slugId) (usePageQuery keys on the slugId for routed reads),
// so warming only the uuid key would leave the offline page blank.
let page: IPage | undefined;
try {
page = await getPageById({ pageId });
queryClient.setQueryData(pageKeys.detail(page.slugId), page);
queryClient.setQueryData(pageKeys.detail(page.id), page);
} catch (error) {
console.error("makePageAvailableOffline: page step failed", {
pageId,
error,
});
failed.push("page");
}
// Warm the space — page.tsx renders nothing until the space query resolves
// (useGetSpaceBySlugQuery). Awaited (not the fire-and-forget prefetchSpace) so
// the space is actually persisted before the caller fires its toast. Shares
// spaceByIdQueryOptions so the key/fn cannot drift from the hook.
try {
const spaceSlug = page?.space?.slug;
if (spaceSlug) {
await queryClient.prefetchQuery(spaceByIdQueryOptions(spaceSlug));
}
} catch (error) {
console.error("makePageAvailableOffline: space step failed", {
pageId,
error,
});
failed.push("space");
}
// Warm the sidebar tree root so the WHOLE root level renders offline (matches
// useGetRootSidebarPagesQuery's pageKeys.rootSidebar(spaceId) infinite cache).
// Fully paginated so large root levels are not truncated at 100.
if (spaceId) {
const ok = await warmInfiniteAll(pageKeys.rootSidebar(spaceId), (cursor) =>
getSidebarPages({ spaceId, cursor, limit: 100 }),
);
if (!ok) failed.push("tree");
}
// Warm the children of the page and of every ancestor so the path to this
// page is expandable offline. We MIRROR fetchAllAncestorChildren exactly via
// sidebarPagesQueryOptions — same pageKeys.sidebar({ pageId, spaceId }) key,
// same getAllSidebarPages fn (which aggregates ALL children pages, so nothing
// is truncated at 100), same 30min staleTime — otherwise the warmed cache
// would never be read by the offline tree.
const warmSidebarChildren = async (id: string): Promise<boolean> => {
try {
// Keep EXACTLY { pageId, spaceId } so the key hashes identically to
// fetchAllAncestorChildren's (no parentPageId, no extra fields).
const params = { pageId: id, spaceId };
await queryClient.prefetchQuery(sidebarPagesQueryOptions(params));
return true;
} catch (error) {
console.error("makePageAvailableOffline: tree node step failed", {
pageId: id,
error,
});
return false;
}
};
// The page's own children.
if (!(await warmSidebarChildren(pageId))) failed.push("tree");
// Each ancestor's children. Use the breadcrumbs endpoint ONLY to discover the
// ancestor ids — we intentionally do NOT cache the breadcrumbs themselves
// (the UI derives the path from the tree).
try {
const ancestors = (await getPageBreadcrumbs(pageId)) as
| Array<{ id?: string }>
| undefined;
for (const ancestor of ancestors ?? []) {
const ancestorId = ancestor?.id;
if (!ancestorId || ancestorId === pageId) continue;
if (!(await warmSidebarChildren(ancestorId))) failed.push("tree");
}
} catch (error) {
console.error("makePageAvailableOffline: breadcrumbs step failed", {
pageId,
error,
});
failed.push("breadcrumbs");
}
// Comments (matches useCommentsQuery's RQ_KEY(pageId) infinite cache).
// useCommentsQuery reports isLoading while hasNextPage is true, so warming
// only the first page leaves the offline comments panel spinning forever on
// pages with >100 comments. Fully paginate so the last cached page has no
// nextCursor and the panel settles offline.
const commentsOk = await warmInfiniteAll(RQ_KEY(pageId), (cursor) =>
getPageComments({ pageId, cursor, limit: 100 }),
);
if (!commentsOk) failed.push("comments");
// Dedupe — the tree label can be recorded once per failed node/ancestor.
const uniqueFailed = [...new Set(failed)];
return { ok: uniqueFailed.length === 0, failed: uniqueFailed };
}
/**
* Best-effort warm-up of the page's Yjs document into IndexedDB so the editor
* can open offline.
*
* Opens a local IndexeddbPersistence plus a transient HocuspocusProvider to
* pull the server state into IndexedDB, then tears both down once synced (or
* after a timeout). Entirely wrapped in try/catch — NEVER throws.
*
* Only meaningful when online at warm time; offline it is a no-op that resolves.
*/
export async function warmPageYdoc(
pageId: string,
collabUrl: string,
token?: string,
): Promise<void> {
let ydoc: Y.Doc | null = null;
let local: IndexeddbPersistence | null = null;
let remote: HocuspocusProvider | null = null;
try {
const documentName = `page.${pageId}`;
ydoc = new Y.Doc();
local = new IndexeddbPersistence(documentName, ydoc);
remote = new HocuspocusProvider({
url: collabUrl,
name: documentName,
document: ydoc,
token,
});
const provider = remote;
await new Promise<void>((resolve) => {
let settled = false;
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const finish = () => {
if (settled) return;
settled = true;
// Clear the pending timeout and detach the listener so neither leaks
// after we resolve.
if (timeoutId !== undefined) clearTimeout(timeoutId);
try {
provider.off("synced", finish);
} catch {
// best-effort
}
resolve();
};
// Resolve once the server state has synced into the local doc...
provider.on("synced", finish);
// ...or give up after a short timeout so we never hang.
timeoutId = setTimeout(finish, 8000);
});
} catch {
// best-effort
} finally {
try {
remote?.destroy();
} catch {
// best-effort
}
try {
local?.destroy();
} catch {
// best-effort
}
try {
ydoc?.destroy();
} catch {
// best-effort
}
}
}

View File

@@ -1,84 +0,0 @@
import { describe, it, expect } from "vitest";
import {
shouldDehydrateOfflineQuery,
OFFLINE_PERSIST_ROOTS,
} from "./query-persister";
// Small helper to build the structural query shape the predicate reads.
const makeQuery = (status: string, queryKey: readonly unknown[]) =>
({ state: { status }, queryKey }) as any;
describe("shouldDehydrateOfflineQuery", () => {
it("returns true for a successful query whose root is in the allowlist", () => {
expect(shouldDehydrateOfflineQuery(makeQuery("success", ["pages", "abc"]))).toBe(
true,
);
expect(
shouldDehydrateOfflineQuery(
makeQuery("success", ["sidebar-pages", { pageId: "p", spaceId: "s" }]),
),
).toBe(true);
expect(
shouldDehydrateOfflineQuery(makeQuery("success", ["comments", "p1"])),
).toBe(true);
expect(
shouldDehydrateOfflineQuery(makeQuery("success", ["space", "s"])),
).toBe(true);
expect(
shouldDehydrateOfflineQuery(makeQuery("success", ["recent-changes"])),
).toBe(true);
});
it("returns false when the status is not success (status gate)", () => {
expect(
shouldDehydrateOfflineQuery(makeQuery("pending", ["pages", "abc"])),
).toBe(false);
expect(
shouldDehydrateOfflineQuery(makeQuery("error", ["pages", "abc"])),
).toBe(false);
});
it("returns false for a successful query whose root is NOT in the allowlist (privacy gate)", () => {
expect(
shouldDehydrateOfflineQuery(makeQuery("success", ["collab-token", "ws"])),
).toBe(false);
expect(
shouldDehydrateOfflineQuery(makeQuery("success", ["trash", "s"])),
).toBe(false);
expect(
shouldDehydrateOfflineQuery(makeQuery("success", ["unknown"])),
).toBe(false);
});
it("returns false for an empty/undefined queryKey", () => {
// String(undefined) is not a member of the allowlist.
expect(shouldDehydrateOfflineQuery(makeQuery("success", []))).toBe(false);
expect(
shouldDehydrateOfflineQuery(makeQuery("success", undefined as any)),
).toBe(false);
});
});
describe("OFFLINE_PERSIST_ROOTS", () => {
it("contains exactly the expected 8 navigation/read roots", () => {
const expected = [
"pages",
"sidebar-pages",
"root-sidebar-pages",
"breadcrumbs",
"comments",
"space",
"spaces",
"recent-changes",
];
expect(OFFLINE_PERSIST_ROOTS.size).toBe(8);
for (const root of expected) {
expect(OFFLINE_PERSIST_ROOTS.has(root)).toBe(true);
}
});
it("does NOT contain volatile/auth keys", () => {
expect(OFFLINE_PERSIST_ROOTS.has("collab-token")).toBe(false);
expect(OFFLINE_PERSIST_ROOTS.has("trash")).toBe(false);
});
});

View File

@@ -1,50 +0,0 @@
import { get, set, del } from "idb-keyval";
import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
// Structural subset of a TanStack Query we read when deciding what to persist.
// We avoid importing the branded `Query` class because the persist-client and
// react-query may resolve to different `@tanstack/query-core` copies, whose
// `Query` types are nominally incompatible (private brand). This structural
// shape stays assignable to whichever copy the persister expects.
type DehydratableQuery = {
state: { status: string };
queryKey: readonly unknown[];
};
// idb-keyval key under which TanStack Query persists its dehydrated cache.
// Exported so the logout cache-clear logic deletes the exact same key (no
// magic-string drift between persist and purge).
export const OFFLINE_CACHE_KEY = "gitmost-rq-cache";
// IndexedDB-backed storage adapter for TanStack Query's async persister.
const idbStorage = {
getItem: (key: string) => get<string>(key).then((v) => v ?? null),
setItem: (key: string, value: string) => set(key, value),
removeItem: (key: string) => del(key),
};
export const queryPersister = createAsyncStoragePersister({
storage: idbStorage,
key: OFFLINE_CACHE_KEY,
throttleTime: 1000,
});
// Only navigation/read query roots are persisted for offline reading.
// Volatile/auth queries (collab tokens, trash lists) are intentionally excluded.
export const OFFLINE_PERSIST_ROOTS = new Set<string>([
"pages",
"sidebar-pages",
"root-sidebar-pages",
"breadcrumbs",
"comments",
"space",
"spaces",
"recent-changes",
]);
export function shouldDehydrateOfflineQuery(query: DehydratableQuery): boolean {
return (
query.state.status === "success" &&
OFFLINE_PERSIST_ROOTS.has(String(query.queryKey?.[0]))
);
}

View File

@@ -12,8 +12,6 @@ import {
IconList,
IconMarkdown,
IconPrinter,
IconCloud,
IconCloudCheck,
IconStar,
IconStarFilled,
IconTrash,
@@ -41,8 +39,6 @@ import { Trans, useTranslation } from "react-i18next";
import ExportModal from "@/components/common/export-modal";
import { htmlToMarkdown } from "@docmost/editor-ext";
import {
isLocalSyncedAtom,
isRemoteSyncedAtom,
pageEditorAtom,
yjsConnectionStatusAtom,
} from "@/features/editor/atoms/editor-atoms.ts";
@@ -415,16 +411,14 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
function ConnectionWarning() {
const { t } = useTranslation();
const yjsConnectionStatus = useAtomValue(yjsConnectionStatusAtom);
const isLocalSynced = useAtomValue(isLocalSyncedAtom);
const isRemoteSynced = useAtomValue(isRemoteSyncedAtom);
const [showWarning, setShowWarning] = useState(false);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isDisconnected = ["disconnected", "connecting"].includes(
yjsConnectionStatus,
);
useEffect(() => {
const isDisconnected = ["disconnected", "connecting"].includes(
yjsConnectionStatus,
);
if (isDisconnected) {
if (!timeoutRef.current) {
timeoutRef.current = setTimeout(() => setShowWarning(true), 5000);
@@ -436,7 +430,7 @@ function ConnectionWarning() {
}
setShowWarning(false);
}
}, [isDisconnected]);
}, [yjsConnectionStatus]);
// Cleanup only on unmount
useEffect(() => {
@@ -447,59 +441,22 @@ function ConnectionWarning() {
};
}, []);
// State (1): offline/disconnected — changes are kept locally. Preserve the
// existing >5s debounce before surfacing this state.
if (isDisconnected) {
if (!showWarning) return null;
if (!showWarning) return null;
const offlineLabel = t(
"Offline — changes are saved locally and will sync when you reconnect",
);
return (
<Tooltip label={offlineLabel} openDelay={250} withArrow>
<ThemeIcon
variant="default"
c="red"
role="status"
aria-label={offlineLabel}
style={{ border: "none" }}
>
<IconWifiOff size={20} stroke={2} />
</ThemeIcon>
</Tooltip>
);
}
// State (2): connected but the remote replica is not fully caught up yet.
if (!isRemoteSynced || !isLocalSynced) {
const syncingLabel = t("Syncing changes…");
return (
<Tooltip label={syncingLabel} openDelay={250} withArrow>
<ThemeIcon
variant="default"
c="dimmed"
role="status"
aria-label={syncingLabel}
style={{ border: "none" }}
>
<IconCloud size={20} stroke={2} />
</ThemeIcon>
</Tooltip>
);
}
// State (3): fully synced — subtle confirmation indicator.
const syncedLabel = t("All changes synced");
return (
<Tooltip label={syncedLabel} openDelay={250} withArrow>
<Tooltip
label={t("Real-time editor connection lost. Retrying...")}
openDelay={250}
withArrow
>
<ThemeIcon
variant="default"
c="dimmed"
c="red"
role="status"
aria-label={syncedLabel}
aria-label={t("Real-time editor connection lost. Retrying...")}
style={{ border: "none" }}
>
<IconCloudCheck size={20} stroke={2} />
<IconWifiOff size={20} stroke={2} />
</ThemeIcon>
</Tooltip>
);

View File

@@ -1,7 +1,6 @@
import {
InfiniteData,
QueryKey,
queryOptions,
useInfiniteQuery,
UseInfiniteQueryResult,
useMutation,
@@ -44,36 +43,11 @@ import { SpaceTreeNode } from "@/features/page/tree/types";
import { useQueryEmit } from "@/features/websocket/use-query-emit";
import { moveToTrashNotificationMessage } from "@/features/page/components/move-to-trash-notification";
/**
* Centralized React Query key factories for page queries. The hooks below and
* the offline warm path (features/offline/make-offline.ts) share these so the
* runtime keys can never silently drift apart.
*/
export const pageKeys = {
detail: (idOrSlug: string) => ["pages", idOrSlug] as const,
sidebar: (data: unknown) => ["sidebar-pages", data] as const,
rootSidebar: (spaceId: string) => ["root-sidebar-pages", spaceId] as const,
breadcrumbs: (pageId: string) => ["breadcrumbs", pageId] as const,
recentChanges: (spaceId?: string) => ["recent-changes", spaceId] as const,
};
/**
* Shared queryOptions for the sidebar-pages (ancestor children) query. Both
* fetchAllAncestorChildren and the offline warm path consume this so the key,
* queryFn and staleTime stay identical.
*/
export const sidebarPagesQueryOptions = (params: SidebarPagesParams) =>
queryOptions({
queryKey: pageKeys.sidebar(params),
queryFn: () => getAllSidebarPages(params),
staleTime: 30 * 60 * 1000,
});
export function usePageQuery(
pageInput: Partial<IPageInput>,
): UseQueryResult<IPage, Error> {
const query = useQuery({
queryKey: pageKeys.detail(pageInput.pageId),
queryKey: ["pages", pageInput.pageId],
queryFn: () => getPageById(pageInput),
enabled: !!pageInput.pageId,
staleTime: 5 * 60 * 1000,
@@ -82,9 +56,9 @@ export function usePageQuery(
useEffect(() => {
if (query.data) {
if (isValidUuid(pageInput.pageId)) {
queryClient.setQueryData(pageKeys.detail(query.data.slugId), query.data);
queryClient.setQueryData(["pages", query.data.slugId], query.data);
} else {
queryClient.setQueryData(pageKeys.detail(query.data.id), query.data);
queryClient.setQueryData(["pages", query.data.id], query.data);
}
}
}, [query.data]);
@@ -106,20 +80,18 @@ export function useCreatePageMutation() {
}
export function updatePageData(data: IPage) {
const pageBySlug = queryClient.getQueryData<IPage>(
pageKeys.detail(data.slugId),
);
const pageById = queryClient.getQueryData<IPage>(pageKeys.detail(data.id));
const pageBySlug = queryClient.getQueryData<IPage>(["pages", data.slugId]);
const pageById = queryClient.getQueryData<IPage>(["pages", data.id]);
if (pageBySlug) {
queryClient.setQueryData(pageKeys.detail(data.slugId), {
queryClient.setQueryData(["pages", data.slugId], {
...pageBySlug,
...data,
});
}
if (pageById) {
queryClient.setQueryData(pageKeys.detail(data.id), { ...pageById, ...data });
queryClient.setQueryData(["pages", data.id], { ...pageById, ...data });
}
invalidateOnUpdatePage(
@@ -173,11 +145,11 @@ export function useRemovePageMutation() {
});
// Stamp deletedAt so a re-visit shows the trash banner, not stale state.
const cached = queryClient.getQueryData<IPage>(pageKeys.detail(pageId));
const cached = queryClient.getQueryData<IPage>(["pages", pageId]);
if (cached) {
const stamped = { ...cached, deletedAt: new Date() };
queryClient.setQueryData(pageKeys.detail(cached.id), stamped);
queryClient.setQueryData(pageKeys.detail(cached.slugId), stamped);
queryClient.setQueryData(["pages", cached.id], stamped);
queryClient.setQueryData(["pages", cached.slugId], stamped);
}
invalidateOnDeletePage(pageId);
@@ -298,11 +270,8 @@ export function useRestorePageMutation() {
// Replace would strip space/permissions/content and break the editor.
const merge = (cached: IPage | undefined) =>
cached ? { ...cached, ...restoredPage } : cached;
queryClient.setQueryData<IPage>(pageKeys.detail(restoredPage.id), merge);
queryClient.setQueryData<IPage>(
pageKeys.detail(restoredPage.slugId),
merge,
);
queryClient.setQueryData<IPage>(["pages", restoredPage.id], merge);
queryClient.setQueryData<IPage>(["pages", restoredPage.slugId], merge);
},
onError: (error) => {
notifications.show({
@@ -317,7 +286,7 @@ export function useGetSidebarPagesQuery(
data: SidebarPagesParams | null,
): UseInfiniteQueryResult<InfiniteData<IPagination<IPage>, unknown>> {
return useInfiniteQuery({
queryKey: pageKeys.sidebar(data),
queryKey: ["sidebar-pages", data],
enabled: !!data?.pageId || !!data?.spaceId,
queryFn: ({ pageParam }) =>
getSidebarPages({ ...data, cursor: pageParam, limit: 100 }),
@@ -328,7 +297,7 @@ export function useGetSidebarPagesQuery(
export function useGetRootSidebarPagesQuery(data: SidebarPagesParams) {
return useInfiniteQuery({
queryKey: pageKeys.rootSidebar(data.spaceId),
queryKey: ["root-sidebar-pages", data.spaceId],
queryFn: async ({ pageParam }) => {
return getSidebarPages({
spaceId: data.spaceId,
@@ -354,7 +323,7 @@ export function usePageBreadcrumbsQuery(
pageId: string,
): UseQueryResult<Partial<IPage[]>, Error> {
return useQuery({
queryKey: pageKeys.breadcrumbs(pageId),
queryKey: ["breadcrumbs", pageId],
queryFn: () => getPageBreadcrumbs(pageId),
enabled: !!pageId,
});
@@ -366,12 +335,10 @@ export async function fetchAllAncestorChildren(
// refresh (#159 #8), which must NOT receive the 30-min-cached children.
opts?: { fresh?: boolean },
) {
// not using a hook here, so we can call it inside a useEffect hook. Reuse the
// shared sidebarPagesQueryOptions (key + queryFn) so the offline warm path and
// this fetch never drift, but override staleTime for the `fresh` reconnect
// refresh (#159 #8), which must force a server refetch (staleTime 0).
// not using a hook here, so we can call it inside a useEffect hook
const response = await queryClient.fetchQuery({
...sidebarPagesQueryOptions(params),
queryKey: ["sidebar-pages", params],
queryFn: () => getAllSidebarPages(params),
staleTime: opts?.fresh ? 0 : 30 * 60 * 1000,
});
@@ -381,7 +348,7 @@ export async function fetchAllAncestorChildren(
export function useRecentChangesQuery(spaceId?: string) {
return useInfiniteQuery({
queryKey: pageKeys.recentChanges(spaceId),
queryKey: ["recent-changes", spaceId],
queryFn: ({ pageParam }) =>
getRecentChanges({ spaceId, cursor: pageParam, limit: 15 }),
initialPageParam: undefined as string | undefined,
@@ -447,12 +414,12 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
let queryKey: QueryKey = null;
if (data.parentPageId === null) {
queryKey = pageKeys.rootSidebar(data.spaceId);
queryKey = ["root-sidebar-pages", data.spaceId];
} else {
queryKey = pageKeys.sidebar({
pageId: data.parentPageId,
spaceId: data.spaceId,
});
queryKey = [
"sidebar-pages",
{ pageId: data.parentPageId, spaceId: data.spaceId },
];
}
//update all sidebar pages
@@ -512,7 +479,7 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
//update root sidebar pages haschildern
const rootSideBarMatches = queryClient.getQueriesData({
queryKey: pageKeys.rootSidebar(data.spaceId),
queryKey: ["root-sidebar-pages", data.spaceId],
exact: false,
});
@@ -536,7 +503,7 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
//update recent changes
queryClient.invalidateQueries({
queryKey: pageKeys.recentChanges(data.spaceId),
queryKey: ["recent-changes", data.spaceId],
});
}
@@ -550,9 +517,9 @@ export function invalidateOnUpdatePage(
invalidatePageTree();
let queryKey: QueryKey = null;
if (parentPageId === null) {
queryKey = pageKeys.rootSidebar(spaceId);
queryKey = ["root-sidebar-pages", spaceId];
} else {
queryKey = pageKeys.sidebar({ pageId: parentPageId, spaceId: spaceId });
queryKey = ["sidebar-pages", { pageId: parentPageId, spaceId: spaceId }];
}
//update all sidebar pages
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(
@@ -575,7 +542,7 @@ export function invalidateOnUpdatePage(
//update recent changes
queryClient.invalidateQueries({
queryKey: pageKeys.recentChanges(spaceId),
queryKey: ["recent-changes", spaceId],
});
}
@@ -590,8 +557,8 @@ export function updateCacheOnMovePage(
// Remove page from old parent's cache
const oldQueryKey =
oldParentId === null
? pageKeys.rootSidebar(spaceId)
: pageKeys.sidebar({ pageId: oldParentId, spaceId });
? ["root-sidebar-pages", spaceId]
: ["sidebar-pages", { pageId: oldParentId, spaceId }];
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(
oldQueryKey,
@@ -611,7 +578,7 @@ export function updateCacheOnMovePage(
if (oldParentId !== null) {
const oldParentCache = queryClient.getQueryData<
InfiniteData<IPagination<IPage>>
>(pageKeys.sidebar({ pageId: oldParentId, spaceId }));
>(["sidebar-pages", { pageId: oldParentId, spaceId }]);
const remainingChildren =
oldParentCache?.pages.flatMap((p) => p.items).length ?? 0;
@@ -649,8 +616,8 @@ export function updateCacheOnMovePage(
// Add page to new parent's cache
const newQueryKey =
newParentId === null
? pageKeys.rootSidebar(spaceId)
: pageKeys.sidebar({ pageId: newParentId, spaceId });
? ["root-sidebar-pages", spaceId]
: ["sidebar-pages", { pageId: newParentId, spaceId }];
queryClient.setQueryData<InfiniteData<IPagination<Partial<IPage>>>>(
newQueryKey,

View File

@@ -7,7 +7,6 @@ import { notifications } from "@mantine/notifications";
import {
IconArrowRight,
IconClockHour4,
IconCloudDownload,
IconCopy,
IconDotsVertical,
IconFileExport,
@@ -36,12 +35,6 @@ import {
useToggleTemplateMutation,
useToggleTemporaryMutation,
} from "@/features/page-embed/queries/page-embed-query";
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
import { getCollaborationUrl } from "@/lib/config.ts";
import {
makePageAvailableOffline,
warmPageYdoc,
} from "@/features/offline/make-offline";
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
import { treeModel } from "@/features/page/tree/model/tree-model";
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
@@ -78,40 +71,6 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
const isTemplate = !!node.isTemplate;
const toggleTemporary = useToggleTemporaryMutation();
const isTemporary = !!node.temporaryExpiresAt;
const { data: collabQuery } = useCollabToken();
const handleMakeAvailableOffline = async () => {
notifications.show({ message: t("Saving page for offline use...") });
try {
// Prefetch read queries so they get persisted to IndexedDB. The result
// reports whether every warm step succeeded.
const result = await makePageAvailableOffline({
pageId: node.id,
spaceId: node.spaceId,
});
// Best-effort: warm the page's Yjs document into IndexedDB.
await warmPageYdoc(node.id, getCollaborationUrl(), collabQuery?.token);
if (result.ok) {
notifications.show({ message: t("Page is now available offline") });
} else {
// Partial warm — the page may still be partly usable offline, but some
// queries failed to cache, so surface it as an error rather than a
// silent success.
notifications.show({
message: t("Failed to make page available offline"),
color: "red",
});
}
} catch {
// makePageAvailableOffline no longer throws, but warmPageYdoc and other
// unexpected failures stay guarded here.
notifications.show({
message: t("Failed to make page available offline"),
color: "red",
});
}
};
const handleToggleTemplate = async () => {
const next = !isTemplate;
@@ -272,17 +231,6 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
{t("Export")}
</Menu.Item>
<Menu.Item
leftSection={<IconCloudDownload size={16} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleMakeAvailableOffline();
}}
>
{t("Make available offline")}
</Menu.Item>
{canEdit && (
<>
<Menu.Item

View File

@@ -14,6 +14,7 @@ import {
useCreatePageMutation,
useRemovePageMutation,
useMovePageMutation,
useUpdatePageMutation,
updateCacheOnMovePage,
} from "@/features/page/queries/page-query.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts";
@@ -25,6 +26,7 @@ export type UseTreeMutation = {
parentId: string | null,
opts?: { temporary?: boolean },
) => Promise<void>;
handleRename: (id: string, name: string) => Promise<void>;
handleDelete: (id: string) => Promise<void>;
};
@@ -36,6 +38,7 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
// children) and then immediately invokes a handler.
const store = useStore();
const createPageMutation = useCreatePageMutation();
const updatePageMutation = useUpdatePageMutation();
const removePageMutation = useRemovePageMutation();
const movePageMutation = useMovePageMutation();
const navigate = useNavigate();
@@ -189,6 +192,20 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
[spaceId, createPageMutation, setData, store, navigate, spaceSlug],
);
const handleRename = useCallback(
async (id: string, name: string) => {
setData((prev) =>
treeModel.update(prev, id, { name } as Partial<SpaceTreeNode>),
);
try {
await updatePageMutation.mutateAsync({ pageId: id, title: name });
} catch (error) {
console.error("Error updating page title:", error);
}
},
[updatePageMutation, setData],
);
const handleDelete = useCallback(
async (id: string) => {
const node = treeModel.find(
@@ -234,7 +251,7 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
[removePageMutation, setData, store, pageSlug, navigate, spaceSlug],
);
return { handleMove, handleCreate, handleDelete };
return { handleMove, handleCreate, handleRename, handleDelete };
}
function isPageInNode(node: SpaceTreeNode, pageSlug: string): boolean {

View File

@@ -1,5 +1,6 @@
import {
ActionIcon,
Box,
Button,
Group,
Modal,
@@ -7,7 +8,7 @@ import {
TextInput,
} from "@mantine/core";
import { IconExternalLink } from "@tabler/icons-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import CopyTextButton from "@/components/common/copy.tsx";
import { getAppUrl } from "@/lib/config.ts";
@@ -122,12 +123,25 @@ export default function ShareAliasSection({
const showTaken =
isValid && !unchanged && availability && !availability.available;
// The slug prefix (e.g. "docs.example.com/l/") is static for the session.
const prefixLabel = aliasPrefixLabel();
const prefixRef = useRef<HTMLDivElement>(null);
const [prefixWidth, setPrefixWidth] = useState(0);
// Measure the real rendered width of the prefix so the slug input sits flush
// next to it, instead of after an over-estimated character-counted gap.
useLayoutEffect(() => {
if (prefixRef.current) {
setPrefixWidth(Math.ceil(prefixRef.current.scrollWidth) + 1);
}
}, [prefixLabel]);
return (
<>
<Text size="sm" fw={500} mt="md">
{t("Custom address")}
</Text>
<Text size="xs" c="dimmed" mb={4}>
<Text size="xs" c="dimmed" mb={6}>
{t("A short, memorable link you can point at any shared page.")}
</Text>
@@ -159,11 +173,27 @@ export default function ShareAliasSection({
// visibly to what gets stored.
onBlur={() => setValue(normalized)}
leftSection={
<Text size="xs" c="dimmed" pl={4} style={{ whiteSpace: "nowrap" }}>
{aliasPrefixLabel()}
</Text>
<Box
ref={prefixRef}
style={{
display: "flex",
alignItems: "center",
width: "100%",
height: "100%",
paddingInline: "var(--mantine-spacing-xs)",
whiteSpace: "nowrap",
fontSize: "var(--mantine-font-size-xs)",
color: "var(--mantine-color-dimmed)",
backgroundColor: "var(--mantine-color-default-hover)",
borderRight: "1px solid var(--mantine-color-default-border)",
borderTopLeftRadius: "var(--input-radius)",
borderBottomLeftRadius: "var(--input-radius)",
}}
>
{prefixLabel}
</Box>
}
leftSectionWidth={Math.min(aliasPrefixLabel().length * 7 + 12, 180)}
leftSectionWidth={prefixWidth || undefined}
placeholder={t("my-page")}
disabled={readOnly}
error={
@@ -175,7 +205,7 @@ export default function ShareAliasSection({
}
/>
<Group mt="xs" gap="xs">
<Group mt="sm" gap="xs">
<Button
size="compact-sm"
onClick={() => handleSave(false)}

View File

@@ -1,6 +1,5 @@
import {
keepPreviousData,
queryOptions,
useInfiniteQuery,
useMutation,
useQuery,
@@ -32,37 +31,11 @@ import { getRecentChanges } from "@/features/page/services/page-service.ts";
import { useEffect } from "react";
import { validate as isValidUuid } from "uuid";
/**
* Centralized React Query key factories for space queries. The hooks below and
* the offline warm path (features/offline/make-offline.ts) share these so the
* runtime keys can never silently drift apart.
*/
export const spaceKeys = {
detail: (idOrSlug: string) => ["space", idOrSlug] as const,
list: (params?: QueryParams) => ["spaces", params] as const,
members: (spaceId: string, query?: string) =>
["spaceMembers", spaceId, query] as const,
};
/**
* Shared queryOptions for fetching a space by id/slug. Both
* useGetSpaceBySlugQuery and the offline warm path consume this so the key,
* queryFn and staleTime stay identical. (`enabled` is intentionally omitted —
* prefetchQuery ignores it anyway and the warm path always passes a real id;
* the hook reapplies `enabled` itself.)
*/
export const spaceByIdQueryOptions = (spaceId: string) =>
queryOptions({
queryKey: spaceKeys.detail(spaceId),
queryFn: () => getSpaceById(spaceId),
staleTime: 5 * 60 * 1000,
});
export function useGetSpacesQuery(
params?: QueryParams,
): UseQueryResult<IPagination<ISpace>, Error> {
return useQuery({
queryKey: spaceKeys.list(params),
queryKey: ["spaces", params],
queryFn: () => getSpaces(params),
placeholderData: keepPreviousData,
refetchOnMount: true,
@@ -71,16 +44,16 @@ export function useGetSpacesQuery(
export function useSpaceQuery(spaceId: string): UseQueryResult<ISpace, Error> {
const query = useQuery({
queryKey: spaceKeys.detail(spaceId),
queryKey: ["space", spaceId],
queryFn: () => getSpaceById(spaceId),
enabled: !!spaceId,
});
useEffect(() => {
if (query.data) {
if (isValidUuid(spaceId)) {
queryClient.setQueryData(spaceKeys.detail(query.data.slug), query.data);
queryClient.setQueryData(["space", query.data.slug], query.data);
} else {
queryClient.setQueryData(spaceKeys.detail(query.data.id), query.data);
queryClient.setQueryData(["space", query.data.id], query.data);
}
}
}, [query.data]);
@@ -89,11 +62,8 @@ export function useSpaceQuery(spaceId: string): UseQueryResult<ISpace, Error> {
}
export const prefetchSpace = (spaceSlug: string, spaceId?: string) => {
// Note: intentionally NOT using spaceByIdQueryOptions here — that factory sets
// a 5min staleTime which would let this prefetch skip fetching fresh data;
// prefetchSpace must always refetch (default staleTime: 0).
queryClient.prefetchQuery({
queryKey: spaceKeys.detail(spaceSlug),
queryKey: ["space", spaceSlug],
queryFn: () => getSpaceById(spaceSlug),
});
@@ -130,8 +100,10 @@ export function useGetSpaceBySlugQuery(
spaceId: string,
): UseQueryResult<ISpace, Error> {
return useQuery({
...spaceByIdQueryOptions(spaceId),
queryKey: ["space", spaceId],
queryFn: () => getSpaceById(spaceId),
enabled: !!spaceId,
staleTime: 5 * 60 * 1000,
});
}
@@ -144,16 +116,14 @@ export function useUpdateSpaceMutation() {
onSuccess: (data, variables) => {
notifications.show({ message: t("Space updated successfully") });
const space = queryClient.getQueryData(
spaceKeys.detail(variables.spaceId),
) as ISpace;
const space = queryClient.getQueryData([
"space",
variables.spaceId,
]) as ISpace;
if (space) {
const updatedSpace = { ...space, ...data };
queryClient.setQueryData(
spaceKeys.detail(variables.spaceId),
updatedSpace,
);
queryClient.setQueryData(spaceKeys.detail(data.slug), updatedSpace);
queryClient.setQueryData(["space", variables.spaceId], updatedSpace);
queryClient.setQueryData(["space", data.slug], updatedSpace);
}
queryClient.invalidateQueries({
@@ -178,7 +148,7 @@ export function useDeleteSpaceMutation() {
if (variables.slug) {
queryClient.removeQueries({
queryKey: spaceKeys.detail(variables.slug),
queryKey: ["space", variables.slug],
exact: true,
});
}
@@ -186,7 +156,7 @@ export function useDeleteSpaceMutation() {
// Remove space-specific queries
if (variables.id) {
queryClient.removeQueries({
queryKey: spaceKeys.detail(variables.id),
queryKey: ["space", variables.id],
exact: true,
});
@@ -226,7 +196,7 @@ export function useSpaceMembersInfiniteQuery(
query?: string,
) {
return useInfiniteQuery({
queryKey: spaceKeys.members(spaceId, query),
queryKey: ["spaceMembers", spaceId, query],
queryFn: ({ pageParam }) =>
getSpaceMembers(spaceId, { cursor: pageParam, limit: 50, query }),
enabled: !!spaceId,

View File

@@ -11,8 +11,7 @@ import { MantineProvider } from "@mantine/core";
import { BrowserRouter } from "react-router-dom";
import { ModalsProvider } from "@mantine/modals";
import { Notifications } from "@mantine/notifications";
import { QueryClient } from "@tanstack/react-query";
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { HelmetProvider } from "react-helmet-async";
import "./i18n";
import { PostHogProvider } from "posthog-js/react";
@@ -22,12 +21,6 @@ import {
isCloud,
isPostHogEnabled,
} from "@/lib/config.ts";
import {
queryPersister,
shouldDehydrateOfflineQuery,
} from "@/features/offline/query-persister";
import { PwaUpdatePrompt } from "@/pwa/pwa-update-prompt";
import { isCapacitorNativePlatform } from "@/pwa/is-capacitor";
import posthog from "posthog-js";
export const queryClient = new QueryClient({
@@ -37,8 +30,6 @@ export const queryClient = new QueryClient({
refetchOnWindowFocus: false,
retry: false,
staleTime: 5 * 60 * 1000,
// Keep cached read data around long enough to be persisted/restored for offline use.
gcTime: 1000 * 60 * 60 * 24,
},
},
});
@@ -59,34 +50,15 @@ root.render(
<BrowserRouter>
<MantineProvider theme={theme} cssVariablesResolver={mantineCssResolver}>
<ModalsProvider>
<PersistQueryClientProvider
client={queryClient}
persistOptions={{
persister: queryPersister,
maxAge: 1000 * 60 * 60 * 24,
buster: APP_VERSION,
dehydrateOptions: {
shouldDehydrateQuery: shouldDehydrateOfflineQuery,
},
}}
>
<QueryClientProvider client={queryClient}>
<Notifications position="bottom-center" limit={3} zIndex={10000} />
{/* Skip SW registration inside the Capacitor native WebView — the
native shell serves assets itself; a browser SW would conflict. */}
{!isCapacitorNativePlatform() && <PwaUpdatePrompt />}
<HelmetProvider>
<PostHogProvider client={posthog}>
<App />
</PostHogProvider>
</HelmetProvider>
</PersistQueryClientProvider>
</QueryClientProvider>
</ModalsProvider>
</MantineProvider>
</BrowserRouter>,
);
// Service worker registration is owned by <PwaUpdatePrompt /> above (via
// vite-plugin-pwa's useRegisterSW: Workbox precache + prompt-based updates,
// and skipped inside the Capacitor native WebView). The earlier hand-written
// /sw.js registration from the mobile bootstrap was removed here to avoid a
// double registration / competing service worker.

View File

@@ -1,39 +0,0 @@
import { describe, it, expect, afterEach } from "vitest";
import { isCapacitorNativePlatform } from "./is-capacitor";
describe("isCapacitorNativePlatform", () => {
afterEach(() => {
// Keep tests isolated from each other and from the rest of the suite.
delete (globalThis as any).Capacitor;
});
it("returns false when Capacitor is undefined", () => {
expect(isCapacitorNativePlatform()).toBe(false);
});
it("uses isNativePlatform() when it is a function", () => {
(globalThis as any).Capacitor = { isNativePlatform: () => true };
expect(isCapacitorNativePlatform()).toBe(true);
(globalThis as any).Capacitor = { isNativePlatform: () => false };
expect(isCapacitorNativePlatform()).toBe(false);
});
it("falls back to the boolean property when isNativePlatform is not a function", () => {
(globalThis as any).Capacitor = { isNativePlatform: true };
expect(isCapacitorNativePlatform()).toBe(true);
(globalThis as any).Capacitor = { isNativePlatform: false };
expect(isCapacitorNativePlatform()).toBe(false);
});
it("returns false when reading Capacitor throws (try/catch)", () => {
Object.defineProperty(globalThis, "Capacitor", {
configurable: true,
get() {
throw new Error("boom");
},
});
expect(isCapacitorNativePlatform()).toBe(false);
});
});

View File

@@ -1,23 +0,0 @@
/**
* Detects whether the client is running inside a Capacitor native WebView
* (native iOS/Android shell from the feature/mobile-app-bootstrap branch).
*
* This is a pure runtime check against the global `Capacitor` object that the
* native bridge injects — no `@capacitor/*` dependency is added. On the plain
* browser / installed-PWA path `window.Capacitor` is undefined, so this returns
* false and the Workbox service worker registers normally.
*
* Inside the native WebView the SW must NOT register: it would layer a redundant
* (and conflicting) cache over Capacitor's own asset serving and interfere with
* the native auth/CORS flow.
*/
export function isCapacitorNativePlatform(): boolean {
try {
const cap = (globalThis as any)?.Capacitor;
return !!(cap && typeof cap.isNativePlatform === "function"
? cap.isNativePlatform()
: cap?.isNativePlatform);
} catch {
return false;
}
}

View File

@@ -1,59 +0,0 @@
import { useEffect } from "react";
import { Button } from "@mantine/core";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
import { useRegisterSW } from "virtual:pwa-register/react";
// Stable notification id so we can show/hide a single update prompt.
const UPDATE_NOTIFICATION_ID = "pwa-update-available";
/**
* Listens for a waiting service worker and surfaces a Mantine notification
* prompting the user to reload into the new version.
*
* Must be mounted inside the Mantine provider subtree (Notifications must be
* available). Renders nothing itself.
*/
export function PwaUpdatePrompt() {
const { t } = useTranslation();
const {
needRefresh: [needRefresh],
updateServiceWorker,
} = useRegisterSW({
onRegisterError(error) {
// Best-effort: a failed registration must not break the app.
console.error("Service worker registration error:", error);
},
});
useEffect(() => {
if (!needRefresh) return;
notifications.show({
id: UPDATE_NOTIFICATION_ID,
title: t("Update available"),
message: (
<Button
size="xs"
variant="light"
mt="xs"
onClick={() => updateServiceWorker(true)}
>
{t("Reload")}
</Button>
),
autoClose: false,
withCloseButton: true,
});
// Hide the notification when the prompt is no longer needed / on cleanup.
return () => {
notifications.hide(UPDATE_NOTIFICATION_ID);
};
}, [needRefresh, t, updateServiceWorker]);
return null;
}
export default PwaUpdatePrompt;

View File

@@ -1,32 +0,0 @@
import { describe, it, expect } from "vitest";
import { isApiPath, isCollabOrSocketPath } from "./sw-strategy";
describe("isApiPath", () => {
it("matches the /api segment and its subtree", () => {
expect(isApiPath("/api")).toBe(true);
expect(isApiPath("/api/")).toBe(true);
expect(isApiPath("/api/pages")).toBe(true);
});
it("does not over-match sibling paths", () => {
expect(isApiPath("/apidocs")).toBe(false);
expect(isApiPath("/apixyz")).toBe(false);
expect(isApiPath("/")).toBe(false);
expect(isApiPath("/pages")).toBe(false);
});
});
describe("isCollabOrSocketPath", () => {
it("matches the /collab and /socket.io segments and their subtrees", () => {
expect(isCollabOrSocketPath("/collab")).toBe(true);
expect(isCollabOrSocketPath("/collab/x")).toBe(true);
expect(isCollabOrSocketPath("/socket.io")).toBe(true);
expect(isCollabOrSocketPath("/socket.io/abc")).toBe(true);
});
it("does not over-match sibling paths", () => {
expect(isCollabOrSocketPath("/collaborators")).toBe(false);
expect(isCollabOrSocketPath("/collabx")).toBe(false);
expect(isCollabOrSocketPath("/socket.iox")).toBe(false);
});
});

View File

@@ -1,32 +0,0 @@
/**
* Canonical service-worker routing predicates.
*
* IMPORTANT: With vite-plugin-pwa using Workbox `generateSW`, the
* `runtimeCaching[].urlPattern` functions are serialized standalone into the
* generated service worker and CANNOT reference imported symbols. The matching
* logic is therefore duplicated as inline regex literals in
* apps/client/vite.config.ts. This module is the testable source of truth, and
* the two MUST be kept in sync. This duplication is intentional and is the
* documented Workbox limitation.
*
* Matching is anchored to a path SEGMENT boundary (`^/<seg>(/|$)`) so that
* sibling paths like `/apidocs`, `/collaborators`, `/socket.iox` are NOT
* wrongly treated as API/realtime traffic.
*/
/**
* True when `pathname` is the `/api` segment or anything beneath it.
* `/api` and `/api/...` -> true; `/apidocs`, `/apixyz` -> false.
*/
export function isApiPath(pathname: string): boolean {
return /^\/api(\/|$)/.test(pathname);
}
/**
* True when `pathname` is the `/collab` or `/socket.io` segment (or beneath it).
* `/collab`, `/collab/x`, `/socket.io`, `/socket.io/abc` -> true;
* `/collaborators`, `/collabx`, `/socket.iox` -> false.
*/
export function isCollabOrSocketPath(pathname: string): boolean {
return /^\/(collab|socket\.io)(\/|$)/.test(pathname);
}

View File

@@ -1,4 +1,2 @@
/// <reference types="vite/client" />
/// <reference types="vite-plugin-pwa/react" />
/// <reference types="vite-plugin-pwa/info" />
declare const APP_VERSION: string

View File

@@ -1,6 +1,5 @@
import { defineConfig, loadEnv } from "vite";
import react from "@vitejs/plugin-react";
import { VitePWA } from "vite-plugin-pwa";
import * as path from "path";
import { execSync } from "node:child_process";
@@ -54,51 +53,7 @@ export default defineConfig(({ mode }) => {
},
APP_VERSION: JSON.stringify(resolveAppVersion(envPath)),
},
plugins: [
react(),
VitePWA({
registerType: "prompt",
injectRegister: null,
strategies: "generateSW",
manifest: false,
workbox: {
globPatterns: ["**/*.{js,css,html,svg,png,ico,woff2,json}"],
navigateFallback: "index.html",
// Segment-anchored (`^/<seg>(/|$)`) so navigation requests to these
// segments are consistently excluded from the SPA fallback, mirroring
// the runtimeCaching urlPattern regexes below.
//
// `/share`, `/mcp`, and `/robots.txt` mirror the server static-serve
// exclude list (apps/server/src/main.ts setGlobalPrefix `exclude`):
// robots.txt, the SEO/OG/analytics-injected public share HTML, and the
// embedded MCP endpoint are served by server controllers, so the SW must
// never shadow them with the precached index.html app shell (doing so
// would break SEO and MCP).
navigateFallbackDenylist: [
/^\/api(\/|$)/,
/^\/collab(\/|$)/,
/^\/socket\.io(\/|$)/,
/^\/share(\/|$)/,
/^\/mcp(\/|$)/,
/^\/robots\.txt$/,
],
cleanupOutdatedCaches: true,
clientsClaim: true,
// The urlPattern regexes below mirror apps/client/src/pwa/sw-strategy.ts
// and MUST be kept in sync with it. Workbox `generateSW` serializes these
// functions standalone into the generated service worker, so they cannot
// import the module — the matching logic is intentionally duplicated as
// self-contained inline regex literals anchored to a path segment boundary.
runtimeCaching: [
{ urlPattern: ({ url }) => /^\/(collab|socket\.io)(\/|$)/.test(url.pathname), handler: "NetworkOnly" },
// All /api stays network-only; offline reads come from the persisted
// React Query cache (IndexedDB) + y-indexeddb, not the SW HTTP cache.
{ urlPattern: ({ url }) => /^\/api(\/|$)/.test(url.pathname), handler: "NetworkOnly" },
],
},
devOptions: { enabled: false },
}),
],
plugins: [react()],
build: {
rolldownOptions: {
output: {

View File

@@ -64,7 +64,6 @@
"@nestjs/platform-fastify": "^11.1.19",
"@nestjs/platform-socket.io": "^11.1.19",
"@nestjs/schedule": "^6.1.3",
"@nestjs/swagger": "^11.2.0",
"@nestjs/terminus": "^11.1.1",
"@nestjs/throttler": "^6.5.0",
"@nestjs/websockets": "^11.1.19",

View File

@@ -24,9 +24,7 @@ import { CollabWsAdapter } from './adapter/collab-ws.adapter';
import {
CollaborationHandler,
CollabEventHandlers,
writeTitleFragment,
} from './collaboration.handler';
import { User } from '@docmost/db/types/entity.types';
@Injectable()
export class CollaborationGateway {
@@ -151,45 +149,6 @@ export class CollaborationGateway {
return this.hocuspocus.openDirectConnection(documentName, context);
}
/**
* Write a new page title INTO the page's Yjs 'title' fragment, Redis-INDEPENDENT.
*
* Unlike the Redis-routed `handleYjsEvent` path — which routes through
* `redisSync?.handleEvent` and SILENTLY no-ops when Redis is disabled
* (COLLAB_DISABLE_REDIS=true → redisSync === null) — this goes straight
* through the local Hocuspocus `openDirectConnection`. The title sync
* therefore works in BOTH single-process (no Redis) and Redis-clustered
* deployments.
*
* openDirectConnection loads the doc from persistence when no editor is
* connected, so this works whether or not an editor is currently open: the
* clear+reseed lands on the loaded doc and is persisted by onStoreDocument.
*
* Provenance: when the caller is the agent, the actor/aiChatId are threaded
* into the connection `context` so onStoreDocument sees `context.actor ===
* 'agent'` for the resulting title store (mirrors the body/REST path). The
* resulting title store is usually a no-op anyway — PageService already wrote
* the same title to the page.title column, so onStoreDocument's
* `titleText !== page.title` guard skips the column write — but we wire the
* context for correctness regardless.
*/
async writePageTitle(
pageId: string,
title: string,
context?: { user?: User; actor?: string; aiChatId?: string },
): Promise<void> {
const documentName = `page.${pageId}`;
const connection = await this.hocuspocus.openDirectConnection(
documentName,
context ?? {},
);
try {
await connection.transact((doc) => writeTitleFragment(doc, title));
} finally {
await connection.disconnect();
}
}
/*
*Can be used before calling openDirectConnection directly
*/

View File

@@ -1,139 +0,0 @@
import * as Y from 'yjs';
import { TiptapTransformer } from '@hocuspocus/transformer';
import { writeTitleFragment } from './collaboration.handler';
import { CollaborationGateway } from './collaboration.gateway';
import {
buildTitleSeedYdoc,
jsonToText,
tiptapExtensions,
} from './collaboration.util';
// Read the plain text held in the doc's 'title' XmlFragment, the same way
// PersistenceExtension.onStoreDocument extracts it before writing page.title.
const readTitleText = (doc: Y.Doc): string => {
const titleJson = TiptapTransformer.fromYdoc(doc, 'title');
return titleJson ? jsonToText(titleJson).trim() : '';
};
describe('writeTitleFragment — the clear+seed title write (Bug 1)', () => {
it('replaces an OLD title fragment with EXACTLY the new title (no duplication)', () => {
// Seed the doc's 'title' fragment with an OLD title, like a real page.
const doc = new Y.Doc();
Y.applyUpdate(doc, Y.encodeStateAsUpdate(buildTitleSeedYdoc('Old Title')));
expect(readTitleText(doc)).toBe('Old Title');
writeTitleFragment(doc, 'New Title');
// The fragment must contain EXACTLY the new title — not "Old TitleNew Title"
// (append) or "New TitleNew Title" (duplication). A single heading node.
expect(readTitleText(doc)).toBe('New Title');
const titleJson = TiptapTransformer.fromYdoc(doc, 'title') as any;
expect(titleJson.content).toHaveLength(1);
expect(titleJson.content[0].type).toBe('heading');
});
it('seeds the title fragment when it started empty', () => {
const doc = new Y.Doc();
// Force the 'title' fragment to exist but be empty.
doc.getXmlFragment('title');
expect(readTitleText(doc)).toBe('');
writeTitleFragment(doc, 'First Title');
expect(readTitleText(doc)).toBe('First Title');
});
it('does not corrupt the body when rewriting the title', () => {
// A doc with both a body and an old title; the body must survive untouched.
const doc = new Y.Doc();
const bodyDoc = TiptapTransformer.toYdoc(
{
type: 'doc',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'body text' }] },
],
},
'default',
tiptapExtensions,
);
Y.applyUpdate(doc, Y.encodeStateAsUpdate(bodyDoc));
Y.applyUpdate(doc, Y.encodeStateAsUpdate(buildTitleSeedYdoc('Old')));
writeTitleFragment(doc, 'New');
expect(readTitleText(doc)).toBe('New');
const bodyJson = TiptapTransformer.fromYdoc(doc, 'default');
expect(jsonToText(bodyJson)).toContain('body text');
});
});
describe('CollaborationGateway.writePageTitle — Redis-independent path', () => {
// Build a gateway with only its hocuspocus.openDirectConnection stubbed; the
// method must drive the clear+seed through that direct connection (NOT through
// redisSync), so the title write survives COLLAB_DISABLE_REDIS.
const makeGateway = (doc: Y.Doc) => {
const disconnect = jest.fn().mockResolvedValue(undefined);
const transact = jest.fn(async (fn: (d: Y.Doc) => void) => {
fn(doc);
});
const openDirectConnection = jest
.fn()
.mockResolvedValue({ transact, disconnect });
const gateway = Object.create(CollaborationGateway.prototype);
// redisSync is intentionally null — this is the no-Redis scenario.
gateway.redisSync = null;
gateway.hocuspocus = { openDirectConnection } as any;
return { gateway, openDirectConnection, transact, disconnect };
};
it('writes the new title via openDirectConnection and disconnects', async () => {
const doc = new Y.Doc();
Y.applyUpdate(doc, Y.encodeStateAsUpdate(buildTitleSeedYdoc('Old Title')));
const { gateway, openDirectConnection, disconnect } = makeGateway(doc);
await gateway.writePageTitle('page-1', 'New Title', { user: { id: 'u1' } });
expect(openDirectConnection).toHaveBeenCalledWith(
'page.page-1',
expect.objectContaining({ user: { id: 'u1' } }),
);
expect(readTitleText(doc)).toBe('New Title');
expect(disconnect).toHaveBeenCalledTimes(1);
});
it('threads agent provenance into the connection context', async () => {
const doc = new Y.Doc();
const { gateway, openDirectConnection } = makeGateway(doc);
await gateway.writePageTitle('page-1', 'Agent Title', {
user: { id: 'u1' },
actor: 'agent',
aiChatId: 'chat-1',
});
expect(openDirectConnection).toHaveBeenCalledWith(
'page.page-1',
expect.objectContaining({ actor: 'agent', aiChatId: 'chat-1' }),
);
});
it('disconnects even when the transaction throws', async () => {
const disconnect = jest.fn().mockResolvedValue(undefined);
const openDirectConnection = jest.fn().mockResolvedValue({
transact: jest.fn().mockRejectedValue(new Error('boom')),
disconnect,
});
const gateway = Object.create(CollaborationGateway.prototype);
gateway.redisSync = null;
gateway.hocuspocus = { openDirectConnection } as any;
await expect(
gateway.writePageTitle('page-1', 'X', {}),
).rejects.toThrow('boom');
expect(disconnect).toHaveBeenCalledTimes(1);
});
});

View File

@@ -2,7 +2,6 @@ import { Injectable, Logger } from '@nestjs/common';
import { Hocuspocus, Document } from '@hocuspocus/server';
import { TiptapTransformer } from '@hocuspocus/transformer';
import {
buildTitleSeedYdoc,
prosemirrorNodeToYElement,
tiptapExtensions,
} from './collaboration.util';
@@ -14,35 +13,6 @@ export type CollabEventHandlers = ReturnType<
CollaborationHandler['getHandlers']
>;
/**
* Clear+reseed the 'title' XmlFragment of `doc` so it holds EXACTLY `title`.
*
* Used by the gateway's direct `writePageTitle` method to write a new page
* title INTO the page's Yjs 'title' fragment. The title lives in the same
* Y.Doc as the body; onStoreDocument extracts it on every save, so a REST/MCP
* rename that only updated the page.title DB column would be reverted on the
* next collaborative save unless the Yjs 'title' fragment is kept in sync.
* The whole fragment is replaced (no merge/append),
* mirroring the 'replace' body path: the new title fully supersedes the old.
*
* DELIBERATE TRADE-OFF: because this does a FULL clear+replace of the 'title'
* fragment, a REST/MCP rename arriving while a user is actively editing the
* title in an open editor WILL overwrite that in-progress edit. This is
* acceptable — the title is a short, rarely-concurrently-edited field — and is
* preferable to leaving a stale Yjs title that onStoreDocument would revert the
* DB column to on the next save.
*/
export function writeTitleFragment(doc: Y.Doc, title: string): void {
const titleFragment = doc.getXmlFragment('title');
if (titleFragment.length > 0) {
titleFragment.delete(0, titleFragment.length);
}
const newTitleDoc = buildTitleSeedYdoc(title);
Y.applyUpdate(doc, Y.encodeStateAsUpdate(newTitleDoc));
}
@Injectable()
export class CollaborationHandler {
private readonly logger = new Logger(CollaborationHandler.name);

View File

@@ -1,13 +1,9 @@
import * as Y from 'yjs';
import { TiptapTransformer } from '@hocuspocus/transformer';
import {
getPageId,
isEmptyParagraphDoc,
jsonToNode,
prosemirrorNodeToYElement,
buildTitleSeedYdoc,
jsonToText,
tiptapExtensions,
} from './collaboration.util';
import { Node } from '@tiptap/pm/model';
@@ -245,49 +241,3 @@ describe('prosemirrorNodeToYElement', () => {
expect(element.get(1).get(0).toString()).toBe('two');
});
});
describe('buildTitleSeedYdoc', () => {
it('builds a level-1 heading carrying the title text', () => {
const doc = buildTitleSeedYdoc('Hello World');
const json: any = TiptapTransformer.fromYdoc(doc, 'title');
const first = json.content?.[0];
expect(first.type).toBe('heading');
expect(first.attrs.level).toBe(1);
expect(jsonToText(json).trim()).toBe('Hello World');
});
it('produces a non-empty title fragment for a non-empty title', () => {
const doc = buildTitleSeedYdoc('Some Title');
expect(doc.get('title', Y.XmlFragment).length).toBeGreaterThan(0);
});
it('produces a heading with no text child for an empty title', () => {
const doc = buildTitleSeedYdoc('');
const json: any = TiptapTransformer.fromYdoc(doc, 'title');
const first = json.content?.[0];
expect(first.type).toBe('heading');
// No text content for an empty title.
expect(first.content ?? []).toHaveLength(0);
expect(jsonToText(json).trim()).toBe('');
});
it('round-trips a title through build -> extract -> build -> extract', () => {
const title = 'Round Trip Title';
const doc1 = buildTitleSeedYdoc(title);
const text1 = jsonToText(TiptapTransformer.fromYdoc(doc1, 'title')).trim();
const doc2 = buildTitleSeedYdoc(text1);
const text2 = jsonToText(TiptapTransformer.fromYdoc(doc2, 'title')).trim();
expect(text1).toBe(title);
expect(text2).toBe(text1);
});
// Touch tiptapExtensions so the import is exercised (mirrors the brief's import
// list and guards against accidental tree-shaking of the schema dependency).
it('uses the shared tiptap extensions schema', () => {
expect(Array.isArray(tiptapExtensions)).toBe(true);
});
});

View File

@@ -59,7 +59,6 @@ import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
import { Node, Schema } from '@tiptap/pm/model';
import * as Y from 'yjs';
import { Logger } from '@nestjs/common';
import { TiptapTransformer } from '@hocuspocus/transformer';
export const tiptapExtensions = [
StarterKit.configure({
@@ -144,34 +143,6 @@ export function jsonToText(tiptapJson: JSONContent) {
return generateText(tiptapJson, tiptapExtensions);
}
/**
* Build a standalone Y.Doc that holds ONLY the page title, in a dedicated Yjs
* fragment named exactly 'title' (the collaborative title-editor contract with
* the client). The ProseMirror shape is a doc with a single level-1 heading
* whose text is the title (empty title => heading with no text child).
*
* The encoded state of the returned doc can be merged into a body doc via
* `Y.applyUpdate(doc, Y.encodeStateAsUpdate(titleSeed))` to seed the title
* fragment for legacy pages. Seeding MUST be guarded by an emptiness check on
* the existing 'title' fragment to avoid the Yjs duplication trap.
*/
export function buildTitleSeedYdoc(title: string): Y.Doc {
return TiptapTransformer.toYdoc(
{
type: 'doc',
content: [
{
type: 'heading',
attrs: { level: 1 },
content: title ? [{ type: 'text', text: title }] : [],
},
],
},
'title',
tiptapExtensions,
);
}
export function jsonToNode(tiptapJson: JSONContent) {
const schema = getSchema(tiptapExtensions);
try {

View File

@@ -1,9 +1,3 @@
export const HISTORY_INTERVAL = 5 * 60 * 1000;
export const HISTORY_FAST_INTERVAL = 60 * 1000;
export const HISTORY_FAST_THRESHOLD = 5 * 60 * 1000;
// Redis pub/sub channel that bridges a PAGE_UPDATED tree snapshot (a title/icon
// rename) from the standalone collab process to the API process, which is the
// single broadcast authority. Imported by both halves of the bridge:
// PageTreeBridgePublisher (collab process) and PageTreeBridgeSubscriber (API process).
export const COLLAB_TREE_UPDATE_CHANNEL = 'collab:tree-update';

View File

@@ -1,483 +0,0 @@
import * as Y from 'yjs';
import { TiptapTransformer } from '@hocuspocus/transformer';
import { PersistenceExtension } from './persistence.extension';
import { buildTitleSeedYdoc, tiptapExtensions } from '../collaboration.util';
// Direct instantiation with stub deps, mirroring the auth/env unit specs.
const bodyJson = {
type: 'doc',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'hello' }] }],
};
// Build a body Y.Doc with a known JSON, plus a monkey-patched broadcastStateless
// (the real Hocuspocus Document supplies it; a bare Y.Doc does not).
const buildDoc = () => {
const d: any = TiptapTransformer.toYdoc(bodyJson, 'default', tiptapExtensions);
d.broadcastStateless = jest.fn();
return d;
};
const cloneOut = (doc: any) =>
JSON.parse(JSON.stringify(TiptapTransformer.fromYdoc(doc, 'default')));
const addTitleFragment = (doc: any, title: string) =>
Y.applyUpdate(doc, Y.encodeStateAsUpdate(buildTitleSeedYdoc(title)));
describe('PersistenceExtension', () => {
let pageRepo: any;
let pageHistoryRepo: any;
let trx: any;
let db: any;
let aiQueue: any;
let historyQueue: any;
let notificationQueue: any;
let collabHistory: any;
let transclusionService: any;
let ext: PersistenceExtension;
beforeEach(() => {
pageRepo = {
findById: jest.fn(),
updatePage: jest.fn().mockResolvedValue(undefined),
};
pageHistoryRepo = {
findPageLastHistory: jest.fn(),
saveHistory: jest.fn(),
};
trx = {};
db = { transaction: () => ({ execute: (fn: any) => fn(trx) }) };
aiQueue = { add: jest.fn().mockResolvedValue(undefined) };
historyQueue = { add: jest.fn().mockResolvedValue(undefined) };
notificationQueue = { add: jest.fn().mockResolvedValue(undefined) };
collabHistory = { addContributors: jest.fn().mockResolvedValue(undefined) };
transclusionService = {
syncPageTransclusions: jest.fn().mockResolvedValue(undefined),
syncPageReferences: jest.fn().mockResolvedValue(undefined),
syncPageTemplateReferences: jest.fn().mockResolvedValue(undefined),
};
ext = new PersistenceExtension(
pageRepo as any,
pageHistoryRepo as any,
db as any,
aiQueue as any,
historyQueue as any,
notificationQueue as any,
collabHistory as any,
transclusionService as any,
);
});
describe('seedTitleFragment', () => {
it('returns false for empty/whitespace/null titles', () => {
const doc = new Y.Doc();
expect((ext as any).seedTitleFragment(doc, '')).toBe(false);
expect((ext as any).seedTitleFragment(doc, ' ')).toBe(false);
expect((ext as any).seedTitleFragment(doc, null)).toBe(false);
});
it('does NOT re-seed an existing non-empty title fragment', () => {
const doc = new Y.Doc();
addTitleFragment(doc, 'Existing');
expect((ext as any).seedTitleFragment(doc, 'Other')).toBe(false);
const text = TiptapTransformer.fromYdoc(doc, 'title');
expect(JSON.stringify(text)).toContain('Existing');
expect(JSON.stringify(text)).not.toContain('Other');
});
it('seeds an empty fragment from a non-empty title and returns true', () => {
const doc = new Y.Doc();
expect((ext as any).seedTitleFragment(doc, 'Hello')).toBe(true);
const json: any = TiptapTransformer.fromYdoc(doc, 'title');
expect(JSON.stringify(json)).toContain('Hello');
});
it('returns false (defensive) when reading the fragment throws', () => {
const fakeDoc = {
get: () => {
throw new Error('boom');
},
};
expect((ext as any).seedTitleFragment(fakeDoc as any, 'X')).toBe(false);
});
});
describe('onStoreDocument', () => {
const basePage = (overrides: any) => ({
id: 'PAGE_ID',
slugId: 'slug',
spaceId: 'space',
parentPageId: null,
creatorId: 'creator',
contributorIds: ['creator'],
workspaceId: 'ws',
title: 'whatever',
content: null,
lastUpdatedSource: 'user',
createdAt: new Date().toISOString(),
...overrides,
});
const context = { user: { id: 'u1', name: 'U', avatarUrl: null } };
it('no-op when neither body nor title changed', async () => {
const document = buildDoc();
const page = basePage({
content: cloneOut(document),
title: 'hello title',
});
pageRepo.findById.mockResolvedValue(page);
await ext.onStoreDocument({
documentName: 'page.PAGE_ID',
document,
context,
} as any);
expect(pageRepo.updatePage).not.toHaveBeenCalled();
expect(document.broadcastStateless).not.toHaveBeenCalled();
expect(collabHistory.addContributors).not.toHaveBeenCalled();
expect(transclusionService.syncPageTransclusions).not.toHaveBeenCalled();
expect(aiQueue.add).not.toHaveBeenCalled();
expect(historyQueue.add).not.toHaveBeenCalled();
});
it('title-only change persists the title without body side-effects', async () => {
const document = buildDoc();
addTitleFragment(document, 'New Title');
const page = basePage({
content: cloneOut(document),
title: 'Old Title',
});
pageRepo.findById.mockResolvedValue(page);
await ext.onStoreDocument({
documentName: 'page.PAGE_ID',
document,
context,
} as any);
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
const call = pageRepo.updatePage.mock.calls[0];
expect(call[0].title).toBe('New Title');
expect(call[0].ydoc).toBeDefined();
expect(call[0].contributorIds).toBeDefined();
expect('content' in call[0]).toBe(false);
// Title-only must not touch the body-authorship provenance.
expect('lastUpdatedSource' in call[0]).toBe(false);
expect(call[1]).toBe('PAGE_ID');
expect(call[3].treeUpdate.title).toBe('New Title');
expect(collabHistory.addContributors).toHaveBeenCalledTimes(1);
expect(collabHistory.addContributors).toHaveBeenCalledWith(
'PAGE_ID',
expect.any(Array),
);
expect(document.broadcastStateless).toHaveBeenCalledTimes(1);
expect(transclusionService.syncPageTransclusions).not.toHaveBeenCalled();
expect(aiQueue.add).not.toHaveBeenCalled();
expect(historyQueue.add).not.toHaveBeenCalled();
});
it('an EMPTY title fragment does NOT overwrite a non-empty page.title (anti-corruption guard, Bug 2)', async () => {
// The client can momentarily seed the 'title' fragment as an EMPTY heading
// (hasTitleFragment true, extracted text '') before the real title syncs.
// Body is unchanged here, so the only candidate write is the title -> the
// guard must turn this into a full no-op (no updatePage, no broadcast).
const document = buildDoc();
addTitleFragment(document, ''); // empty heading: length > 0 but text ''
const page = basePage({
content: cloneOut(document),
title: 'Real Title',
});
pageRepo.findById.mockResolvedValue(page);
await ext.onStoreDocument({
documentName: 'page.PAGE_ID',
document,
context,
} as any);
// No write at all: the empty title is not authoritative and the body is
// unchanged, so onStoreDocument must take the no-op fast path.
expect(pageRepo.updatePage).not.toHaveBeenCalled();
expect(document.broadcastStateless).not.toHaveBeenCalled();
});
it('an EMPTY title fragment alongside a body change persists the body but NOT an empty title (anti-corruption guard, Bug 2)', async () => {
const document = buildDoc();
addTitleFragment(document, ''); // empty title fragment
const page = basePage({
content: { type: 'doc', content: [] }, // different body -> bodyChanged
title: 'Real Title',
});
pageRepo.findById.mockResolvedValue(page);
await ext.onStoreDocument({
documentName: 'page.PAGE_ID',
document,
context,
} as any);
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
const call = pageRepo.updatePage.mock.calls[0];
// Body is persisted, but the title is NOT included (empty == not
// authoritative) and no tree update is broadcast for the title.
expect(call[0].content).toBeTruthy();
expect('title' in call[0]).toBe(false);
expect(call[3]).toBeUndefined();
});
it('body + title change persists both with full body side-effects', async () => {
const document = buildDoc();
addTitleFragment(document, 'New Title');
const page = basePage({
content: { type: 'doc', content: [] },
title: 'Old Title',
});
pageRepo.findById.mockResolvedValue(page);
await ext.onStoreDocument({
documentName: 'page.PAGE_ID',
document,
context,
} as any);
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
const call = pageRepo.updatePage.mock.calls[0];
expect(call[0].content).toBeTruthy();
expect(call[0].title).toBe('New Title');
expect(call[0].ydoc).toBeDefined();
expect(call[0].lastUpdatedSource).toBe('user');
expect(call[3].treeUpdate).toBeDefined();
expect(transclusionService.syncPageTransclusions).toHaveBeenCalled();
expect(aiQueue.add).toHaveBeenCalled();
expect(historyQueue.add).toHaveBeenCalled();
expect(collabHistory.addContributors).toHaveBeenCalled();
expect(document.broadcastStateless).toHaveBeenCalled();
});
it('body-only change persists the body without a tree update', async () => {
const document = buildDoc();
const page = basePage({
content: { type: 'doc', content: [] },
title: 'whatever',
});
pageRepo.findById.mockResolvedValue(page);
await ext.onStoreDocument({
documentName: 'page.PAGE_ID',
document,
context,
} as any);
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
const call = pageRepo.updatePage.mock.calls[0];
expect(call[0].content).toBeTruthy();
expect('title' in call[0]).toBe(false);
// No treeUpdate for a body-only save.
expect(call[3]).toBeUndefined();
expect(transclusionService.syncPageTransclusions).toHaveBeenCalled();
expect(aiQueue.add).toHaveBeenCalled();
expect(historyQueue.add).toHaveBeenCalled();
expect(document.broadcastStateless).toHaveBeenCalled();
});
});
describe('onLoadDocument', () => {
it('returns early (no DB read) when the document is not empty', async () => {
const document = { isEmpty: () => false };
const result = await ext.onLoadDocument({
documentName: 'page.PAGE_ID',
document,
} as any);
expect(result).toBeUndefined();
expect(pageRepo.findById).not.toHaveBeenCalled();
});
it('returns undefined and does not persist when the page is null', async () => {
const document = { isEmpty: () => true };
pageRepo.findById.mockResolvedValue(null);
const result = await ext.onLoadDocument({
documentName: 'page.PAGE_ID',
document,
} as any);
expect(result).toBeUndefined();
expect(pageRepo.updatePage).not.toHaveBeenCalled();
});
it('seeds + persists under a lock when the persisted ydoc lacks a title fragment', async () => {
const src = TiptapTransformer.toYdoc(bodyJson, 'default', tiptapExtensions);
const page = {
id: 'PAGE_ID',
title: 'Legacy Title',
ydoc: Buffer.from(Y.encodeStateAsUpdate(src)),
content: null,
};
// Both the cheap pre-check and the locked re-read return the same row.
pageRepo.findById.mockResolvedValue(page);
const document = { isEmpty: () => true };
const result = await ext.onLoadDocument({
documentName: 'page.PAGE_ID',
document,
} as any);
// The locked re-read must take the row lock inside the tx.
const lockedReadCall = pageRepo.findById.mock.calls.find(
(c: any[]) => c[1]?.withLock,
);
expect(lockedReadCall).toBeDefined();
expect(lockedReadCall[1].trx).toBe(trx);
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
const call = pageRepo.updatePage.mock.calls[0];
expect(Buffer.isBuffer(call[0].ydoc)).toBe(true);
expect(call[1]).toBe('PAGE_ID');
// Persist must run inside the transaction.
expect(call[2]).toBe(trx);
expect(result).toBeTruthy();
});
it('does NOT lock or persist when the ydoc already has a title fragment', async () => {
const src = TiptapTransformer.toYdoc(bodyJson, 'default', tiptapExtensions);
Y.applyUpdate(src, Y.encodeStateAsUpdate(buildTitleSeedYdoc('Has Title')));
const page = {
id: 'PAGE_ID',
title: 'Has Title',
ydoc: Buffer.from(Y.encodeStateAsUpdate(src)),
content: null,
};
pageRepo.findById.mockResolvedValue(page);
const document = { isEmpty: () => true };
const result = await ext.onLoadDocument({
documentName: 'page.PAGE_ID',
document,
} as any);
// Hot path: only the cheap lock-free read, no locked re-read, no write.
expect(pageRepo.findById).toHaveBeenCalledTimes(1);
expect(pageRepo.findById.mock.calls[0][1]?.withLock).toBeFalsy();
expect(pageRepo.updatePage).not.toHaveBeenCalled();
expect(result).toBeTruthy();
});
it('converts legacy content -> ydoc inside a tx and persists a {ydoc} Buffer', async () => {
const page = {
id: 'PAGE_ID',
title: 'T',
ydoc: null,
content: bodyJson,
};
pageRepo.findById.mockResolvedValue(page);
const document = { isEmpty: () => true };
const result = await ext.onLoadDocument({
documentName: 'page.PAGE_ID',
document,
} as any);
const lockedReadCall = pageRepo.findById.mock.calls.find(
(c: any[]) => c[1]?.withLock,
);
expect(lockedReadCall).toBeDefined();
expect(lockedReadCall[1].trx).toBe(trx);
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
const call = pageRepo.updatePage.mock.calls[0];
expect(Buffer.isBuffer(call[0].ydoc)).toBe(true);
expect(call[2]).toBe(trx);
// The rebuilt doc carries the body.
expect(JSON.stringify(cloneOut(result))).toContain('hello');
});
it('SKIPS rebuild when the locked re-read shows the ydoc was already healed', async () => {
// Simulate a concurrent process: the cheap pre-check sees ydoc=null (legacy
// rebuild path), but by the time we hold the lock another process has
// already persisted a healthy ydoc. We must adopt it, not rebuild/clobber.
const healed = TiptapTransformer.toYdoc(
{ type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'healed' }] }] },
'default',
tiptapExtensions,
);
Y.applyUpdate(healed, Y.encodeStateAsUpdate(buildTitleSeedYdoc('Healed Title')));
const healedYdoc = Buffer.from(Y.encodeStateAsUpdate(healed));
const preCheck = { id: 'PAGE_ID', title: 'T', ydoc: null, content: bodyJson };
const lockedRow = {
id: 'PAGE_ID',
title: 'Healed Title',
ydoc: healedYdoc,
content: bodyJson,
};
pageRepo.findById
.mockResolvedValueOnce(preCheck) // cheap pre-check
.mockResolvedValueOnce(lockedRow); // locked re-read
const document = { isEmpty: () => true };
const result = await ext.onLoadDocument({
documentName: 'page.PAGE_ID',
document,
} as any);
// The healthy ydoc had a title fragment already, so nothing was rebuilt or
// seeded -> no clobbering write.
expect(pageRepo.updatePage).not.toHaveBeenCalled();
// The returned doc is the healed body, NOT a fresh rebuild of bodyJson.
expect(JSON.stringify(cloneOut(result))).toContain('healed');
});
it('REJECTS the load when the rebuild persist fails (does not return an unpersisted doc)', async () => {
const page = { id: 'PAGE_ID', title: 'T', ydoc: null, content: bodyJson };
pageRepo.findById.mockResolvedValue(page);
pageRepo.updatePage.mockRejectedValue(new Error('db down'));
const errSpy = jest
.spyOn((ext as any).logger, 'error')
.mockImplementation(() => undefined);
const document = { isEmpty: () => true };
await expect(
ext.onLoadDocument({
documentName: 'page.PAGE_ID',
document,
} as any),
).rejects.toThrow('db down');
expect(errSpy).toHaveBeenCalled();
});
it('seed-only persist FAILURE returns the doc from the existing ydoc (no throw)', async () => {
const src = TiptapTransformer.toYdoc(bodyJson, 'default', tiptapExtensions);
const page = {
id: 'PAGE_ID',
title: 'Legacy Title',
ydoc: Buffer.from(Y.encodeStateAsUpdate(src)),
content: null,
};
pageRepo.findById.mockResolvedValue(page);
pageRepo.updatePage.mockRejectedValue(new Error('db down'));
const errSpy = jest
.spyOn((ext as any).logger, 'error')
.mockImplementation(() => undefined);
const document = { isEmpty: () => true };
const result = await ext.onLoadDocument({
documentName: 'page.PAGE_ID',
document,
} as any);
// Non-fatal: we fall back to the doc loaded from the existing page.ydoc.
expect(result).toBeTruthy();
expect(JSON.stringify(cloneOut(result))).toContain('hello');
expect(errSpy).toHaveBeenCalled();
});
});
});

View File

@@ -9,7 +9,6 @@ import * as Y from 'yjs';
import { Injectable, Logger } from '@nestjs/common';
import { TiptapTransformer } from '@hocuspocus/transformer';
import {
buildTitleSeedYdoc,
getPageId,
isEmptyParagraphDoc,
jsonToText,
@@ -117,10 +116,6 @@ export class PersistenceExtension implements Extension {
return;
}
// Cheap, lock-free pre-check (hot path stays lock-free). It tells us whether
// any heal (legacy rebuild and/or title seed) is needed; the heal itself
// re-reads the row FOR UPDATE and re-validates inside a transaction so it
// runs exactly once (see healUnderLock).
const page = await this.pageRepo.findById(pageId, {
includeContent: true,
includeYdoc: true,
@@ -132,164 +127,33 @@ export class PersistenceExtension implements Extension {
}
if (page.ydoc) {
this.logger.debug(`ydoc loaded from db: ${pageId}`);
const doc = new Y.Doc();
Y.applyUpdate(doc, new Uint8Array(page.ydoc));
const dbState = new Uint8Array(page.ydoc);
// Legacy pages persisted their title only in the `page.title` column; the
// ydoc has no 'title' fragment. Decide cheaply (no lock) whether a seed is
// needed by inspecting the loaded doc's 'title' fragment. A seed is needed
// only when that fragment is empty AND there is a non-empty column title.
let titleSeedNeeded = false;
try {
const titleFrag = doc.get('title', Y.XmlFragment);
titleSeedNeeded = titleFrag.length === 0 && !!page.title?.trim();
} catch (err) {
// A malformed title fragment must not break loading; skip the seed.
this.logger.warn(`failed to inspect title fragment: ${err?.['message']}`);
titleSeedNeeded = false;
}
if (!titleSeedNeeded) {
// Fully healthy: a ydoc with a title fragment (or nothing to seed).
this.logger.debug(`ydoc loaded from db: ${pageId}`);
return doc;
}
// SEED-ONLY heal: a valid page.ydoc already exists; we only need to add the
// title fragment. If the persist fails we must NOT hand out an unpersisted
// fresh-client-id seed (it could later duplicate the title), so we fall
// back to the healthy doc loaded from the EXISTING page.ydoc, without the
// seed. The title just won't render until a later successful heal —
// non-fatal, non-corrupting.
try {
return await this.healUnderLock(pageId);
} catch (err) {
this.logger.error(
`Failed to persist seeded ydoc for page ${pageId}; serving existing ydoc without title seed`,
err,
);
return doc;
}
Y.applyUpdate(doc, dbState);
return doc;
}
// NOTE (offline-sync M1, Goal 2): this per-load self-heal converts +
// title-seeds + persists every legacy page (content set, ydoc null) on its
// first open, which neutralizes the duplication trap incrementally. A
// proactive one-shot BATCH migration over all such pages could be added
// later, but it requires the tiptap schema + TiptapTransformer (Node/Yjs),
// which a Kysely SQL migration cannot run; no runnable-task/CLI convention
// exists in this repo yet, so we deliberately avoid a fragile migration.
//
// If no ydoc state in db, REBUILD a Y.Doc from the JSON in page.content under
// a row lock (see healUnderLock).
// if no ydoc state in db convert json in page.content to Ydoc.
if (page.content) {
// REBUILD heal: surface failures. If the persist fails we REFUSE the load
// (re-throw) rather than hand out an unpersisted fresh-client-id rebuild —
// returning it would re-arm the duplication trap. A transient DB failure
// means the client reconnects and retries: correctness over availability.
try {
return await this.healUnderLock(pageId);
} catch (err) {
this.logger.error(
`Failed to persist rebuilt ydoc for page ${pageId}; refusing load`,
err,
);
throw err;
}
this.logger.debug(`converting json to ydoc: ${pageId}`);
const ydoc = TiptapTransformer.toYdoc(
page.content,
'default',
tiptapExtensions,
);
Y.encodeStateAsUpdate(ydoc);
return ydoc;
}
this.logger.debug(`creating fresh ydoc: ${pageId}`);
return new Y.Doc();
}
/**
* Serialize the legacy self-heal (rebuild from page.content and/or seed the
* title fragment, then persist) so it runs exactly ONCE per page, closing the
* Yjs duplication trap. Both TiptapTransformer.toYdoc and buildTitleSeedYdoc
* mint FRESH Yjs client-ids every call, so two concurrent rebuilds (the API
* process via openDirectConnection AND the standalone collab process both
* seeing `ydoc IS NULL`) could each persist a different-client-id state and let
* a long-offline client merge-and-duplicate. We prevent that by re-reading the
* row FOR UPDATE inside a transaction and re-validating state under the lock:
* whoever wins the lock heals; the loser observes the healthy `ydoc` and adopts
* it instead of rebuilding. The persist happens IN THE SAME TX, so a failed
* write rolls back and propagates out (the caller then decides refuse vs.
* fall-back).
*/
private async healUnderLock(pageId: string): Promise<Y.Doc> {
return executeTx(this.db, async (trx) => {
const locked = await this.pageRepo.findById(pageId, {
withLock: true,
includeContent: true,
includeYdoc: true,
trx,
});
const doc = new Y.Doc();
let rebuilt = false;
if (locked?.ydoc) {
// Another process already healed (or the page always had a ydoc): adopt
// the healthy persisted state, do NOT rebuild.
Y.applyUpdate(doc, new Uint8Array(locked.ydoc));
} else if (locked?.content) {
this.logger.debug(`converting json to ydoc: ${pageId}`);
const built = TiptapTransformer.toYdoc(
locked.content,
'default',
tiptapExtensions,
);
Y.applyUpdate(doc, Y.encodeStateAsUpdate(built));
rebuilt = true;
}
// else: no ydoc and no content -> a fresh empty doc.
// Idempotent, emptiness-guarded title seed (safe to call always).
const seeded = this.seedTitleFragment(doc, locked?.title ?? null);
if (rebuilt || seeded) {
// Persist IN THE SAME TX. If this throws, the tx rolls back and the
// error propagates out of executeTx to the caller.
await this.pageRepo.updatePage(
{ ydoc: Buffer.from(Y.encodeStateAsUpdate(doc)) },
pageId,
trx,
);
this.logger.debug(`persisted rebuilt/seeded ydoc: ${pageId}`);
}
return doc;
});
}
/**
* Seed the 'title' fragment of `doc` from the `page.title` column for legacy
* pages whose persisted ydoc has no title fragment yet.
*
* Guarded STRICTLY by emptiness: we only seed when the existing 'title'
* fragment is empty AND there is a non-empty column title. Seeding a non-empty
* fragment would re-introduce the Yjs duplication trap, so we never do it.
* Returns true when a seed was applied (so the caller can persist).
* Defensive: a malformed title must not break document loading.
*/
private seedTitleFragment(doc: Y.Doc, title: string | null): boolean {
const trimmed = (title ?? '').trim();
if (!trimmed) return false;
try {
const titleFrag = doc.get('title', Y.XmlFragment);
if (titleFrag.length !== 0) return false;
const titleSeed = buildTitleSeedYdoc(title);
Y.applyUpdate(doc, Y.encodeStateAsUpdate(titleSeed));
this.logger.debug('seeded title fragment from page.title column');
return true;
} catch (err) {
this.logger.warn(`failed to seed title fragment: ${err?.['message']}`);
return false;
}
}
async onStoreDocument(data: onStoreDocumentPayload) {
const { documentName, document, context } = data;
@@ -307,34 +171,7 @@ export class PersistenceExtension implements Extension {
this.logger.warn('jsonToText' + err?.['message']);
}
// Title lives in the SAME Y.Doc as the body, in a dedicated 'title' fragment
// (the collaborative title-editor contract with the client). Extract it
// defensively: a malformed title fragment must NOT crash the document store.
// `hasTitleFragment` distinguishes "the doc actually carries a title
// fragment" from "legacy doc with no title fragment" — only the former may
// write page.title, so a legacy doc never clobbers the column with ''.
let titleText = '';
let hasTitleFragment = false;
try {
const titleFrag = document.get('title', Y.XmlFragment);
hasTitleFragment = !!titleFrag && titleFrag.length > 0;
if (hasTitleFragment) {
const titleJson = TiptapTransformer.fromYdoc(document, 'title');
titleText = titleJson ? jsonToText(titleJson).trim() : '';
}
} catch (err) {
this.logger.warn('title extraction: ' + err?.['message']);
hasTitleFragment = false;
}
let page: Page = null;
// Tracks whether the BODY ('default') changed in this store. The heavy
// body-only side-effects (transclusion sync, mentions, RAG, history) stay
// gated on this so a title-only change does not trigger them.
let bodyChanged = false;
// Tracks a successful title-only persist so the post-tx contributor folding
// (collabHistory.addContributors) runs for the title-only case too.
let titleOnlyPersisted = false;
const editingUserIds = this.consumeContributors(documentName);
// Sticky agent marker: 'agent' if any agent edit landed in this window, OR
// if the current writer is the agent (covers a store with no prior onChange
@@ -368,80 +205,11 @@ export class PersistenceExtension implements Extension {
return;
}
bodyChanged = !isDeepStrictEqual(tiptapJson, page.content);
// Only a populated 'title' fragment may update page.title; compare
// against the current column value (treat null as '').
//
// ANTI-CORRUPTION GUARD (Bug 2): the client's collaborative title-editor
// can momentarily initialize the 'title' fragment as an EMPTY heading
// (so `hasTitleFragment` is true, but the extracted `titleText` is '')
// BEFORE the server's real-title seed has synced. Writing that '' would
// silently wipe a non-empty page.title to "untitled". A wiki page is
// never legitimately retitled to empty via this path, so we treat an
// empty extracted title as "not authoritative" and never persist it.
// The `titleText.length > 0` clause makes this guard apply to BOTH the
// title-only branch and the body+title branch below.
//
// DELIBERATE: this intentionally makes it impossible to retitle a page
// to EMPTY via the collab path — a wiki page is never legitimately
// empty-titled. If a non-empty-title rule ever needs relaxing or
// enforcing differently, the REST UpdatePageDto is the place to validate
// the title, not this collab guard.
const titleChanged =
hasTitleFragment &&
titleText.length > 0 &&
titleText !== (page.title ?? '');
// No-op fast path: neither body nor title changed.
if (!bodyChanged && !titleChanged) {
if (isDeepStrictEqual(tiptapJson, page.content)) {
page = null;
return;
}
// Title-only change: the body is unchanged, so skip the heavy body
// history/contributor logic and persist just the new title and the
// ydoc (the title fragment edit lives in the same ydoc). The early-skip
// used to drop this case entirely, losing the title change.
if (!bodyChanged) {
// Fold the window's editing users into contributors the same way the
// body branch does, so a user who edited ONLY the title is not dropped
// from page.contributorIds.
const contributorIds = Array.from(
new Set([
...(page.contributorIds || []),
...editingUserIds,
page.creatorId,
]),
);
await this.pageRepo.updatePage(
{
title: titleText,
ydoc: ydocState,
lastUpdatedById: context.user.id,
contributorIds,
// A title-only change is not a body-authorship transition; leave
// lastUpdatedSource/aiChatId untouched so the user->agent history
// boundary in the body branch is not bypassed.
},
pageId,
trx,
// Mirror PageService.update's tree snapshot so a collaborative rename
// propagates to other users' sidebar/breadcrumbs like the REST rename.
{
treeUpdate: {
id: pageId,
slugId: page.slugId,
spaceId: page.spaceId,
parentPageId: page.parentPageId ?? null,
title: titleText,
},
},
);
this.logger.debug(`Page title updated: ${pageId} - SlugId: ${page.slugId}`);
titleOnlyPersisted = true;
return;
}
let contributorIds = undefined;
try {
const existingContributors = page.contributorIds || [];
@@ -459,22 +227,29 @@ export class PersistenceExtension implements Extension {
// Approach A — boundary snapshot before the agent's first edit.
// When this store is the agent's and the page's currently persisted
// state was authored by a human, pin that human state as its own
// history version BEFORE the agent overwrites it. `page` still holds the
// OLD content/provenance here, so saveHistory(page) captures the
// pre-agent state tagged 'user'. The agent's new content is snapshotted
// later by the debounced PAGE_HISTORY job ('agent'). Skip if the prior
// state is already agent-authored (boundary already pinned on the
// user->agent transition), if the page is effectively empty, or if the
// latest existing snapshot already equals this human state (avoid
// duplicates).
if (lastUpdatedSource === 'agent' && page.lastUpdatedSource !== 'agent') {
// history version BEFORE the agent overwrites it. `page` still holds
// the OLD content/provenance here, so saveHistory(page) captures the
// pre-agent state tagged 'user'. The agent's new content is
// snapshotted later by the debounced PAGE_HISTORY job ('agent'). Skip
// if the prior state is already agent-authored (boundary already
// pinned on the user->agent transition), if the page is effectively
// empty, or if the latest existing snapshot already equals this human
// state (avoid duplicates).
if (
lastUpdatedSource === 'agent' &&
page.lastUpdatedSource !== 'agent'
) {
const lastHistory = await this.pageHistoryRepo.findPageLastHistory(
pageId,
{ includeContent: true, trx },
);
const humanBaselineMissing =
!lastHistory || !isDeepStrictEqual(lastHistory.content, page.content);
if (!isEmptyParagraphDoc(page.content as any) && humanBaselineMissing) {
!lastHistory ||
!isDeepStrictEqual(lastHistory.content, page.content);
if (
!isEmptyParagraphDoc(page.content as any) &&
humanBaselineMissing
) {
await this.pageHistoryRepo.saveHistory(page, {
contributorIds: page.contributorIds ?? undefined,
trx,
@@ -492,27 +267,9 @@ export class PersistenceExtension implements Extension {
lastUpdatedSource,
lastUpdatedAiChatId: context?.aiChatId ?? null,
contributorIds: contributorIds,
// Persist the title in the SAME transaction when the title fragment
// changed alongside the body.
...(titleChanged ? { title: titleText } : {}),
},
pageId,
trx,
// Mirror PageService.update's tree snapshot so a collaborative rename
// propagates to other users' sidebar/breadcrumbs like the REST rename.
// Only attach when the title actually changed; a body-only save must
// not trigger a tree broadcast.
titleChanged
? {
treeUpdate: {
id: pageId,
slugId: page.slugId,
spaceId: page.spaceId,
parentPageId: page.parentPageId ?? null,
title: titleText,
},
}
: undefined,
);
this.logger.debug(`Page updated: ${pageId} - SlugId: ${page.slugId}`);
@@ -533,8 +290,6 @@ export class PersistenceExtension implements Extension {
}
}
// `page` is truthy whenever anything was persisted (body OR title-only), so
// the page.updated broadcast fires for a title-only change too.
if (page) {
document.broadcastStateless(
JSON.stringify({
@@ -552,20 +307,11 @@ export class PersistenceExtension implements Extension {
: undefined,
}),
);
}
// Record the window's editing users in collab history for a title-only
// change too (the body branch does this below, gated on bodyChanged).
if (page && titleOnlyPersisted) {
await this.collabHistory.addContributors(pageId, editingUserIds);
}
// Body-only side-effects: skip them for a title-only change (body unchanged).
if (page && bodyChanged) {
await this.syncTransclusion(pageId, page.workspaceId, tiptapJson);
}
if (page && bodyChanged) {
if (page) {
await this.collabHistory.addContributors(pageId, editingUserIds);
const mentions = extractMentions(tiptapJson);

View File

@@ -1,81 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { RedisService } from '@nestjs-labs/nestjs-ioredis';
import { PageTreeBridgePublisher } from './page-tree-bridge.publisher';
import { COLLAB_TREE_UPDATE_CHANNEL } from '../constants';
import {
PageEvent,
TreeUpdateSnapshot,
} from '../../database/listeners/page.listener';
const treeUpdate: TreeUpdateSnapshot = {
id: 'page-1',
slugId: 'slug-1',
spaceId: 'space-1',
parentPageId: null,
title: 'Renamed',
icon: '🚀',
};
describe('PageTreeBridgePublisher', () => {
let publisher: PageTreeBridgePublisher;
let redis: { publish: jest.Mock };
beforeEach(async () => {
redis = { publish: jest.fn().mockResolvedValue(1) };
const redisService = { getOrThrow: () => redis } as unknown as RedisService;
const module: TestingModule = await Test.createTestingModule({
providers: [
PageTreeBridgePublisher,
{ provide: RedisService, useValue: redisService },
],
}).compile();
publisher = module.get<PageTreeBridgePublisher>(PageTreeBridgePublisher);
});
it('WITH a `treeUpdate`: publishes the JSON snapshot on the channel', async () => {
const event: PageEvent = {
pageIds: ['page-1'],
workspaceId: 'ws-1',
treeUpdate,
};
await publisher.onPageUpdated(event);
expect(redis.publish).toHaveBeenCalledTimes(1);
expect(redis.publish).toHaveBeenCalledWith(
COLLAB_TREE_UPDATE_CHANNEL,
JSON.stringify(treeUpdate),
);
});
it('content-only save (NO `treeUpdate`): does NOT publish', async () => {
const event: PageEvent = {
pageIds: ['page-1'],
workspaceId: 'ws-1',
};
await publisher.onPageUpdated(event);
expect(redis.publish).not.toHaveBeenCalled();
});
it('a publish rejection is caught (no throw)', async () => {
redis.publish.mockRejectedValueOnce(new Error('redis down'));
const errorSpy = jest
.spyOn(publisher['logger'], 'error')
.mockImplementation(() => undefined);
const event: PageEvent = {
pageIds: ['page-1'],
workspaceId: 'ws-1',
treeUpdate,
};
await expect(publisher.onPageUpdated(event)).resolves.toBeUndefined();
expect(errorSpy).toHaveBeenCalledTimes(1);
errorSpy.mockRestore();
});
});

View File

@@ -1,55 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { RedisService } from '@nestjs-labs/nestjs-ioredis';
import type { Redis } from 'ioredis';
import { EventName } from '../../common/events/event.contants';
import { PageEvent } from '../../database/listeners/page.listener';
import { COLLAB_TREE_UPDATE_CHANNEL } from '../constants';
/**
* Collab-process half of the cross-process tree-update bridge.
*
* The standalone collab process bootstraps `CollabAppModule`, which does NOT
* import `WsModule`/`PageWsListener`. So when a collaborative title/icon rename
* persists and emits `EventName.PAGE_UPDATED` with a `treeUpdate` snapshot, there
* is no listener in this process to broadcast it — the live tree update would be
* lost for 2-process (COLLAB_URL set) deployments.
*
* This publisher fills that gap: it forwards the `treeUpdate` snapshot over a
* Redis pub/sub channel to the API process, which re-broadcasts it via
* `WsTreeService` (the single broadcast authority).
*
* It is registered ONLY in `CollabAppModule.providers`, so it never runs in the
* API process (where `PageWsListener` already broadcasts the same event locally).
* That module placement is what prevents a double broadcast. In single-process
* mode `CollabAppModule` is not loaded at all, so this publisher never runs.
*/
@Injectable()
export class PageTreeBridgePublisher {
private readonly logger = new Logger(PageTreeBridgePublisher.name);
private readonly redis: Redis;
constructor(private readonly redisService: RedisService) {
this.redis = this.redisService.getOrThrow();
}
@OnEvent(EventName.PAGE_UPDATED)
async onPageUpdated(event: PageEvent): Promise<void> {
// Mirror PageWsListener's gating: only title/icon changes carry a snapshot.
// Content-only saves leave `treeUpdate` undefined and are ignored.
if (!event.treeUpdate) return;
try {
await this.redis.publish(
COLLAB_TREE_UPDATE_CHANNEL,
JSON.stringify(event.treeUpdate),
);
} catch (err) {
// A Redis publish failure must not break the store path.
this.logger.error(
`Failed to publish tree update to ${COLLAB_TREE_UPDATE_CHANNEL}`,
err instanceof Error ? err.stack : String(err),
);
}
}
}

View File

@@ -20,7 +20,6 @@ import { CaslModule } from '../../core/casl/casl.module';
import { ThrottleModule } from '../../integrations/throttle/throttle.module';
import { CacheModule } from '@nestjs/cache-manager';
import KeyvRedis from '@keyv/redis';
import { PageTreeBridgePublisher } from '../listeners/page-tree-bridge.publisher';
@Module({
imports: [
@@ -55,6 +54,6 @@ import { PageTreeBridgePublisher } from '../listeners/page-tree-bridge.publisher
? [CollaborationController]
: []),
],
providers: [AppService, PageTreeBridgePublisher],
providers: [AppService],
})
export class CollabAppModule {}

View File

@@ -19,87 +19,4 @@ describe('AuthController', () => {
it('should be defined', () => {
expect(controller).toBeDefined();
});
// The EE MFA module is absent in this repo, so require() throws and is caught;
// login falls through to authService.login -> setAuthCookie -> returnToken.
describe('login returnToken branch', () => {
const workspace = { id: 'ws1', enforceSso: false };
const makeController = () => {
const authService = {
login: jest.fn().mockResolvedValue('jwt-token-123'),
};
const environmentService = {
getCookieExpiresIn: jest.fn().mockReturnValue(new Date()),
isHttps: jest.fn().mockReturnValue(false),
};
const ctrl = new AuthController(
authService as any,
{} as any,
environmentService as any,
{} as any,
{} as any,
);
const res = { setCookie: jest.fn() };
return { ctrl, authService, res };
};
it('returns the body token and sets the cookie when returnToken is true', async () => {
const { ctrl, authService, res } = makeController();
const loginInput = {
email: 'a@b.com',
password: 'pw',
returnToken: true,
};
const result = await ctrl.login(
workspace as any,
res as any,
loginInput as any,
);
expect(result).toEqual({ authToken: 'jwt-token-123' });
expect(res.setCookie).toHaveBeenCalledTimes(1);
expect(res.setCookie).toHaveBeenCalledWith(
'authToken',
'jwt-token-123',
expect.objectContaining({ httpOnly: true }),
);
expect(authService.login).toHaveBeenCalled();
});
it('returns no body token but still sets the cookie when returnToken is omitted', async () => {
const { ctrl, res } = makeController();
const loginInput = { email: 'a@b.com', password: 'pw' };
const result = await ctrl.login(
workspace as any,
res as any,
loginInput as any,
);
expect(result).toBeUndefined();
expect(res.setCookie).toHaveBeenCalledTimes(1);
});
// Guards against an `!== undefined`-style bug: an explicit `false` must
// behave exactly like the omitted case (cookie set, no token in the body).
it('returns no body token but still sets the cookie when returnToken is false', async () => {
const { ctrl, res } = makeController();
const loginInput = {
email: 'a@b.com',
password: 'pw',
returnToken: false,
};
const result = await ctrl.login(
workspace as any,
res as any,
loginInput as any,
);
expect(result).toBeUndefined();
expect(res.setCookie).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -97,12 +97,6 @@ export class AuthController {
} else if (mfaResult.authToken) {
// User doesn't have MFA and workspace doesn't require it
this.setAuthCookie(res, mfaResult.authToken);
// Opt-in body token for native clients (Bearer auth). The response is
// wrapped by TransformHttpResponseInterceptor, so clients read it at
// `data.authToken`. Web clients omit returnToken and keep the cookie.
if (loginInput.returnToken) {
return { authToken: mfaResult.authToken };
}
return;
}
}
@@ -110,12 +104,6 @@ export class AuthController {
const authToken = await this.authService.login(loginInput, workspace.id);
this.setAuthCookie(res, authToken);
// Opt-in body token for native clients (Bearer auth). The response is wrapped
// by TransformHttpResponseInterceptor, so clients read it at `data.authToken`.
// Web clients omit returnToken and keep using the httpOnly cookie only.
if (loginInput.returnToken) {
return { authToken };
}
}
@UseGuards(SetupGuard)

View File

@@ -1,10 +1,4 @@
import {
IsBoolean,
IsEmail,
IsNotEmpty,
IsOptional,
IsString,
} from 'class-validator';
import { IsEmail, IsNotEmpty, IsString } from 'class-validator';
export class LoginDto {
@IsNotEmpty()
@@ -14,13 +8,4 @@ export class LoginDto {
@IsNotEmpty()
@IsString()
password: string;
// When true, the access token is returned in the response body (in addition
// to the httpOnly cookie) so native/mobile clients can store it in
// Keychain/Keystore and send it as 'Authorization: Bearer'. Web clients omit
// this flag and keep using the cookie. Opt-in only: the token is never put in
// the body otherwise.
@IsOptional()
@IsBoolean()
returnToken?: boolean;
}

View File

@@ -31,102 +31,6 @@ describe('PageService', () => {
expect(service).toBeDefined();
});
describe('update — title sync into collab doc (Bug 1)', () => {
const makeUpdateService = () => {
const pageRepo = {
updatePage: jest.fn().mockResolvedValue(undefined),
findById: jest.fn().mockResolvedValue({ id: 'page-1' }),
};
const generalQueue = { add: jest.fn().mockResolvedValue(undefined) };
const collaborationGateway = {
writePageTitle: jest.fn().mockResolvedValue(undefined),
};
const svc = new PageService(
pageRepo as any, // pageRepo
{} as any, // pagePermissionRepo
{} as any, // attachmentRepo
{} as any, // db
{} as any, // storageService
{} as any, // attachmentQueue
{} as any, // aiQueue
generalQueue as any, // generalQueue
{} as any, // eventEmitter
collaborationGateway as any, // collaborationGateway
{} as any, // watcherService
{} as any, // transclusionService
);
return { svc, pageRepo, collaborationGateway };
};
const basePage = (): Page =>
({
id: 'page-1',
slugId: 'slug-1',
spaceId: 'space-1',
workspaceId: 'ws-1',
parentPageId: null,
title: 'Old Title',
icon: null,
contributorIds: [],
}) as any;
const user = { id: 'u1' } as any;
it('writes the new title into the collab doc when the title actually changed', async () => {
const { svc, collaborationGateway } = makeUpdateService();
await svc.update(basePage(), { title: 'New Title' } as any, user);
// Must use the Redis-independent writePageTitle (direct
// openDirectConnection), NOT handleYjsEvent which no-ops without Redis.
expect(collaborationGateway.writePageTitle).toHaveBeenCalledTimes(1);
expect(collaborationGateway.writePageTitle).toHaveBeenCalledWith(
'page-1',
'New Title',
expect.objectContaining({ user }),
);
});
it('threads agent provenance into the collab title write', async () => {
const { svc, collaborationGateway } = makeUpdateService();
await svc.update(basePage(), { title: 'New Title' } as any, user, {
actor: 'agent',
aiChatId: 'chat-1',
} as any);
expect(collaborationGateway.writePageTitle).toHaveBeenCalledWith(
'page-1',
'New Title',
expect.objectContaining({ actor: 'agent', aiChatId: 'chat-1' }),
);
});
it('does NOT write into the collab doc when the title is unchanged', async () => {
const { svc, collaborationGateway } = makeUpdateService();
// Same title -> titleChanged is false; an icon-only change must not fire
// the title sync.
await svc.update(
basePage(),
{ title: 'Old Title', icon: '📄' } as any,
user,
);
expect(collaborationGateway.writePageTitle).not.toHaveBeenCalled();
});
it('does NOT write into the collab doc when the DTO omits the title', async () => {
const { svc, collaborationGateway } = makeUpdateService();
await svc.update(basePage(), { icon: '📄' } as any, user);
expect(collaborationGateway.writePageTitle).not.toHaveBeenCalled();
});
});
describe('movePage cycle guard (#67)', () => {
// A valid fractional-indexing key — movePage validates `position` by feeding
// it to generateJitteredKeyBetween(position, null) before anything else.

View File

@@ -260,8 +260,6 @@ export class PageService {
contributors.add(user.id);
const contributorIds = Array.from(contributors);
const isAgent = provenance?.actor === 'agent';
// Detect a real title/icon change so the WS tree listener can broadcast an
// `updateOne` to the space (rename / icon swap) WITHOUT re-broadcasting on a
// content-only save. Only treat a field as changed when the DTO actually
@@ -304,43 +302,6 @@ export class PageService {
: undefined,
);
// Bug 1: a REST/MCP rename wrote the new title ONLY to the page.title DB
// column above. The title's source of truth is the Yjs 'title' fragment in
// the page's collab doc, which onStoreDocument re-extracts on every save —
// so leaving the fragment stale would REVERT this rename on the page's next
// collaborative save (and re-broadcast the old title). Push the new title
// into the Yjs 'title' fragment so Yjs stays in sync and never reverts.
//
// Use the gateway's writePageTitle (direct openDirectConnection) rather than
// a Redis-routed handleYjsEvent path: handleYjsEvent routes through
// redisSync and SILENTLY no-ops when Redis is disabled
// (COLLAB_DISABLE_REDIS=true), which would let the rename revert in a
// single-process deployment. writePageTitle is Redis-independent and
// openDirectConnection loads the doc from persistence when no editor is
// connected, so this also works for an offline page. Thread agent provenance
// into the context so onStoreDocument tags the title store 'agent' too.
if (titleChanged) {
try {
await this.collaborationGateway.writePageTitle(
page.id,
updatePageDto.title,
{
user,
...(isAgent
? { actor: 'agent', aiChatId: provenance.aiChatId }
: {}),
},
);
} catch (err) {
// The DB column write already succeeded (fast-read source stays
// correct); a failure to sync Yjs here must not fail the rename. Log so
// a persistent desync is visible.
this.logger.warn(
`Failed to sync renamed title into collab doc for page ${page.id}: ${err?.['message']}`,
);
}
}
this.generalQueue
.add(QueueJob.ADD_PAGE_WATCHERS, {
userIds: [user.id],

View File

@@ -1,93 +0,0 @@
import { buildCorsAllowlist, isOriginAllowed } from './cors.util';
const WEBVIEW_ORIGINS = [
'capacitor://localhost',
'ionic://localhost',
'https://localhost',
];
describe('isOriginAllowed', () => {
const allowlist = buildCorsAllowlist({
appUrl: 'https://app.example',
configuredOrigins: ['https://other.example'],
});
it('allows requests with no Origin header', () => {
expect(isOriginAllowed(undefined, allowlist)).toBe(true);
expect(isOriginAllowed('', allowlist)).toBe(true);
});
it('allows an exact allowlisted origin', () => {
expect(isOriginAllowed('https://app.example', allowlist)).toBe(true);
expect(isOriginAllowed('https://other.example', allowlist)).toBe(true);
});
it('allows each native WebView origin', () => {
for (const origin of WEBVIEW_ORIGINS) {
expect(isOriginAllowed(origin, allowlist)).toBe(true);
}
});
it('rejects a foreign credentialed origin', () => {
// With credentials:true a foreign credentialed origin must be rejected.
expect(isOriginAllowed('https://evil.example', allowlist)).toBe(false);
});
it('rejects the cleartext http://localhost origin', () => {
// The native shell uses the secure scheme (https://localhost) on Android and
// the capacitor:// custom scheme on iOS, so cleartext http://localhost must
// not be trusted.
expect(isOriginAllowed('http://localhost', allowlist)).toBe(false);
});
it('rejects a trailing-slash mismatch', () => {
expect(isOriginAllowed('https://app.example/', allowlist)).toBe(false);
});
it('rejects a host-case mismatch', () => {
expect(isOriginAllowed('https://APP.example', allowlist)).toBe(false);
});
it('allows no-Origin but rejects cross-origin with an empty allowlist', () => {
const empty: ReadonlySet<string> = new Set<string>();
expect(isOriginAllowed(undefined, empty)).toBe(true);
expect(isOriginAllowed('https://app.example', empty)).toBe(false);
});
});
describe('buildCorsAllowlist', () => {
it('contains the app URL, each configured origin, and all WebView origins', () => {
const allowlist = buildCorsAllowlist({
appUrl: 'https://app.example',
configuredOrigins: ['https://a.example', 'https://b.example'],
});
expect(allowlist.has('https://app.example')).toBe(true);
expect(allowlist.has('https://a.example')).toBe(true);
expect(allowlist.has('https://b.example')).toBe(true);
for (const origin of WEBVIEW_ORIGINS) {
expect(allowlist.has(origin)).toBe(true);
}
});
it('deduplicates when a configured origin coincides with the app URL', () => {
const allowlist = buildCorsAllowlist({
appUrl: 'https://app.example',
configuredOrigins: ['https://app.example'],
});
// app URL + WebView origins, the duplicate configured origin collapses.
expect(allowlist.size).toBe(1 + WEBVIEW_ORIGINS.length);
});
it('always includes every WebView origin even with no configured origins', () => {
const allowlist = buildCorsAllowlist({
appUrl: 'https://app.example',
configuredOrigins: [],
});
for (const origin of WEBVIEW_ORIGINS) {
expect(allowlist.has(origin)).toBe(true);
}
});
});

View File

@@ -1,50 +0,0 @@
// CORS trust boundary helpers. `buildCorsAllowlist` produces the exact set of
// origins the API trusts, and `isOriginAllowed` is the predicate the enableCors
// origin callback uses to accept/reject each request. With credentials:true a
// foreign credentialed origin must never be allowed, so anything not in the
// allowlist (apart from no-Origin requests) is rejected.
// Native WebView origins used by the Capacitor/Ionic mobile shell. Always
// trusted so the native client can call the API.
//
// - `capacitor://localhost` — iOS native custom scheme.
// - `ionic://localhost` — legacy native custom scheme.
// - `https://localhost` — Android default secure scheme.
//
// The cleartext `http://localhost` origin is intentionally NOT trusted: the
// Capacitor shell uses the secure scheme (capacitor.config.ts sets
// `cleartext: false` and does not override `androidScheme`, so Capacitor's
// default Android scheme is `https` => origin `https://localhost`), and iOS runs
// in hosted mode (`server.url` = CAP_SERVER_URL, whose origin is the app URL
// already in the allowlist). No native client legitimately uses
// `http://localhost`, so allowing it would only widen the credentialed-CORS
// surface to arbitrary local http content.
const NATIVE_WEBVIEW_ORIGINS = [
'capacitor://localhost',
'ionic://localhost',
'https://localhost',
] as const;
// Build the CORS allowlist: the app URL, all configured cross-origin clients,
// and the native WebView origins. Dedup is automatic via Set.
export function buildCorsAllowlist(input: {
appUrl: string;
configuredOrigins: readonly string[];
}): Set<string> {
return new Set<string>([
input.appUrl,
...input.configuredOrigins,
...NATIVE_WEBVIEW_ORIGINS,
]);
}
// Decide whether a request's Origin is allowed. A missing Origin header (curl,
// server-to-server, some native WebViews) is allowed; otherwise the origin must
// be present in the allowlist.
export function isOriginAllowed(
origin: string | undefined,
allowlist: ReadonlySet<string>,
): boolean {
if (!origin) return true;
return allowlist.has(origin);
}

View File

@@ -5,13 +5,6 @@ import { EnvironmentService } from './environment.service';
describe('EnvironmentService', () => {
let service: EnvironmentService;
// Build a service over a stub ConfigService whose get(key, def) returns
// values from the supplied env map (falling back to the provided default).
const makeService = (env: Record<string, string>) =>
new EnvironmentService({
get: (k: string, d?: string) => (k in env ? env[k] : d),
} as any);
beforeEach(() => {
service = new EnvironmentService(
{} as any, // configService
@@ -21,83 +14,4 @@ describe('EnvironmentService', () => {
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('getCorsAllowedOrigins', () => {
it('splits, trims, and drops empty entries', () => {
const svc = makeService({
CORS_ALLOWED_ORIGINS: 'https://a.com, https://b.com ,, https://c.com',
});
expect(svc.getCorsAllowedOrigins()).toEqual([
'https://a.com',
'https://b.com',
'https://c.com',
]);
});
it('returns an empty array when the var is absent', () => {
const svc = makeService({});
expect(svc.getCorsAllowedOrigins()).toEqual([]);
});
it('returns an empty array for an empty string', () => {
const svc = makeService({ CORS_ALLOWED_ORIGINS: '' });
expect(svc.getCorsAllowedOrigins()).toEqual([]);
});
it('returns a single origin unchanged', () => {
const svc = makeService({
CORS_ALLOWED_ORIGINS: 'https://app.example',
});
expect(svc.getCorsAllowedOrigins()).toEqual(['https://app.example']);
});
// Adversarial case: leading/trailing/duplicate commas with surrounding
// spaces must be dropped, exercising both .map(trim) and .filter(Boolean).
it('drops leading/trailing commas with surrounding spaces', () => {
const svc = makeService({ CORS_ALLOWED_ORIGINS: ' , a , , b ' });
expect(svc.getCorsAllowedOrigins()).toEqual(['a', 'b']);
});
});
describe('isSwaggerEnabled', () => {
it('is true for "true"', () => {
expect(makeService({ SWAGGER_ENABLED: 'true' }).isSwaggerEnabled()).toBe(
true,
);
});
it('is true case-insensitively for "TRUE"', () => {
expect(makeService({ SWAGGER_ENABLED: 'TRUE' }).isSwaggerEnabled()).toBe(
true,
);
});
it('is true for mixed-case "True"', () => {
expect(makeService({ SWAGGER_ENABLED: 'True' }).isSwaggerEnabled()).toBe(
true,
);
});
it('defaults to false when absent', () => {
expect(makeService({}).isSwaggerEnabled()).toBe(false);
});
it('is false for non-"true" values', () => {
expect(makeService({ SWAGGER_ENABLED: '0' }).isSwaggerEnabled()).toBe(
false,
);
expect(makeService({ SWAGGER_ENABLED: 'yes' }).isSwaggerEnabled()).toBe(
false,
);
expect(makeService({ SWAGGER_ENABLED: 'false' }).isSwaggerEnabled()).toBe(
false,
);
expect(makeService({ SWAGGER_ENABLED: '' }).isSwaggerEnabled()).toBe(
false,
);
expect(makeService({ SWAGGER_ENABLED: '1' }).isSwaggerEnabled()).toBe(
false,
);
});
});
});

View File

@@ -320,19 +320,4 @@ export class EnvironmentService {
.map((o) => o.trim())
.filter(Boolean);
}
getCorsAllowedOrigins(): string[] {
const raw = this.configService.get<string>('CORS_ALLOWED_ORIGINS', '');
return raw
.split(',')
.map((o) => o.trim())
.filter(Boolean);
}
isSwaggerEnabled(): boolean {
const enabled = this.configService
.get<string>('SWAGGER_ENABLED', 'false')
.toLowerCase();
return enabled === 'true';
}
}

View File

@@ -15,11 +15,6 @@ import { InternalLogFilter } from './common/logger/internal-log-filter';
import { EnvironmentService } from './integrations/environment/environment.service';
import { resolveFrameHeader } from './common/helpers';
import { resolveTrustProxy } from './integrations/environment/trust-proxy.util';
import {
buildCorsAllowlist,
isOriginAllowed,
} from './integrations/environment/cors.util';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>(
@@ -154,43 +149,8 @@ async function bootstrap() {
}),
);
// Configure CORS explicitly (replaces the previous unconfigured enableCors()).
// The web client is same-origin in production; an explicit allowlist lets
// native/mobile WebView origins (Capacitor) and any configured cross-origin
// clients call the API, while everything else is rejected.
const corsAllowedOrigins = buildCorsAllowlist({
appUrl: environmentService.getAppUrl(),
configuredOrigins: environmentService.getCorsAllowedOrigins(),
});
app.enableCors({
// Allow requests with no Origin header (curl, server-to-server, some native
// WebView requests) and any origin in the allowlist; reject the rest.
origin: (
origin: string | undefined,
callback: (err: Error | null, allow?: boolean) => void,
) => {
callback(null, isOriginAllowed(origin, corsAllowedOrigins));
},
credentials: true,
methods: ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
});
app.enableCors();
app.useGlobalInterceptors(new TransformHttpResponseInterceptor(reflector));
if (environmentService.isSwaggerEnabled()) {
// Optional OpenAPI docs to speed up typed mobile-client generation.
const swaggerConfig = new DocumentBuilder()
.setTitle('Gitmost API')
.setDescription('Gitmost REST API (RPC-style POST endpoints).')
.setVersion(process.env.APP_VERSION || '0.0.0')
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, swaggerConfig);
SwaggerModule.setup('api/docs', app, document);
}
app.enableShutdownHooks();
const logger = new Logger('NestApplication');

View File

@@ -1,114 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { RedisService } from '@nestjs-labs/nestjs-ioredis';
import { PageTreeBridgeSubscriber } from './page-tree-bridge.subscriber';
import { WsTreeService } from '../ws-tree.service';
import { COLLAB_TREE_UPDATE_CHANNEL } from '../../collaboration/constants';
import { TreeUpdateSnapshot } from '../../database/listeners/page.listener';
const treeUpdate: TreeUpdateSnapshot = {
id: 'page-1',
slugId: 'slug-1',
spaceId: 'space-1',
parentPageId: null,
title: 'Renamed',
icon: '🚀',
};
describe('PageTreeBridgeSubscriber.onMessage', () => {
let subscriber: PageTreeBridgeSubscriber;
let wsTree: { broadcastPageUpdated: jest.Mock };
beforeEach(async () => {
wsTree = {
broadcastPageUpdated: jest.fn().mockResolvedValue(undefined),
};
// onMessage is driven directly; no real redis connection is needed.
const redisService = {
getOrThrow: () => ({ duplicate: () => ({}) }),
} as unknown as RedisService;
const module: TestingModule = await Test.createTestingModule({
providers: [
PageTreeBridgeSubscriber,
{ provide: RedisService, useValue: redisService },
{ provide: WsTreeService, useValue: wsTree },
],
}).compile();
subscriber = module.get<PageTreeBridgeSubscriber>(PageTreeBridgeSubscriber);
});
it('valid JSON on the channel: broadcasts the parsed snapshot', async () => {
await subscriber.onMessage(
COLLAB_TREE_UPDATE_CHANNEL,
JSON.stringify(treeUpdate),
);
expect(wsTree.broadcastPageUpdated).toHaveBeenCalledTimes(1);
expect(wsTree.broadcastPageUpdated).toHaveBeenCalledWith(treeUpdate);
});
it('malformed JSON: does NOT broadcast and does not throw', async () => {
const warnSpy = jest
.spyOn(subscriber['logger'], 'warn')
.mockImplementation(() => undefined);
await expect(
subscriber.onMessage(COLLAB_TREE_UPDATE_CHANNEL, '{not json'),
).resolves.toBeUndefined();
expect(wsTree.broadcastPageUpdated).not.toHaveBeenCalled();
expect(warnSpy).toHaveBeenCalledTimes(1);
warnSpy.mockRestore();
});
it('message on a different channel: ignored', async () => {
await subscriber.onMessage('some:other:channel', JSON.stringify(treeUpdate));
expect(wsTree.broadcastPageUpdated).not.toHaveBeenCalled();
});
it('broadcast rejects: onMessage does not throw / produce unhandled rejection', async () => {
wsTree.broadcastPageUpdated.mockRejectedValueOnce(new Error('db down'));
const warnSpy = jest
.spyOn(subscriber['logger'], 'warn')
.mockImplementation(() => undefined);
await expect(
subscriber.onMessage(
COLLAB_TREE_UPDATE_CHANNEL,
JSON.stringify(treeUpdate),
),
).resolves.toBeUndefined();
expect(wsTree.broadcastPageUpdated).toHaveBeenCalledTimes(1);
expect(warnSpy).toHaveBeenCalledTimes(1);
warnSpy.mockRestore();
});
it('onModuleInit when subscribe() rejects: resolves without throwing', async () => {
const sub = {
on: jest.fn(),
subscribe: jest.fn().mockRejectedValue(new Error('redis down')),
};
const redisService = {
getOrThrow: () => ({ duplicate: () => sub }),
} as unknown as RedisService;
const local = new PageTreeBridgeSubscriber(
redisService,
wsTree as unknown as WsTreeService,
);
const errorSpy = jest
.spyOn(local['logger'], 'error')
.mockImplementation(() => undefined);
await expect(local.onModuleInit()).resolves.toBeUndefined();
expect(sub.subscribe).toHaveBeenCalledTimes(1);
expect(errorSpy).toHaveBeenCalledTimes(1);
errorSpy.mockRestore();
});
});

View File

@@ -1,115 +0,0 @@
import {
Injectable,
Logger,
OnModuleDestroy,
OnModuleInit,
} from '@nestjs/common';
import { RedisService } from '@nestjs-labs/nestjs-ioredis';
import type { Redis } from 'ioredis';
import { COLLAB_TREE_UPDATE_CHANNEL } from '../../collaboration/constants';
import { TreeUpdateSnapshot } from '../../database/listeners/page.listener';
import { WsTreeService } from '../ws-tree.service';
/**
* API-process half of the cross-process tree-update bridge.
*
* It subscribes to the Redis pub/sub channel that the collab process's
* `PageTreeBridgePublisher` publishes to and re-broadcasts each collab-originated
* `treeUpdate` snapshot through `WsTreeService`. This is what makes a
* collaborative rename reach other users' sidebars in 2-process (COLLAB_URL set)
* deployments. The API process is the single broadcast authority:
* `broadcastPageUpdated` routes through the restriction-aware `emitTreeEvent`, so
* this path stays authorization-safe.
*
* In single-process mode this subscriber still subscribes, but nobody publishes
* (the publisher lives only in `CollabAppModule`), so it stays idle and harmless.
*
* NOTE: this assumes a SINGLE API broadcaster. With multiple horizontally-scaled
* API replicas, every replica would receive the pub/sub message and re-broadcast,
* duplicating the client update (the Socket.IO Redis adapter already fans a single
* emit out to all replicas' clients). Scaling the API horizontally would require a
* consumer-group / leader-election scheme instead of fan-out pub/sub. That is out
* of scope for the current single-API deployment.
*/
@Injectable()
export class PageTreeBridgeSubscriber
implements OnModuleInit, OnModuleDestroy
{
private readonly logger = new Logger(PageTreeBridgeSubscriber.name);
private sub?: Redis;
constructor(
private readonly redisService: RedisService,
private readonly wsTree: WsTreeService,
) {}
async onModuleInit(): Promise<void> {
// A connection in subscribe mode cannot run other commands, so use a
// dedicated duplicated client (mirrors RedisSyncExtension's `sub`).
this.sub = this.redisService.getOrThrow().duplicate();
// ioredis connections emit 'error' on disconnect/reconnect; an EventEmitter
// 'error' with no listener THROWS and can crash the process. The bridge is
// optional, so just log and stay alive (mirrors RedisSyncExtension).
this.sub.on('error', (err) =>
this.logger.warn(`tree-update subscriber redis error: ${err?.message}`),
);
this.sub.on('message', (channel, message) =>
this.onMessage(channel, message),
);
// The bridge is optional for core API operation: if Redis is down at boot,
// subscribe() rejects — log and continue rather than crash API bootstrap.
try {
await this.sub.subscribe(COLLAB_TREE_UPDATE_CHANNEL);
} catch (err) {
this.logger.error(
`Failed to subscribe to ${COLLAB_TREE_UPDATE_CHANNEL}; cross-process tree updates disabled: ${
err instanceof Error ? err.message : String(err)
}`,
);
}
}
async onMessage(channel: string, message: string): Promise<void> {
if (channel !== COLLAB_TREE_UPDATE_CHANNEL) return;
let snapshot: TreeUpdateSnapshot;
try {
snapshot = JSON.parse(message) as TreeUpdateSnapshot;
} catch (err) {
// Malformed payload must never throw out of the message handler.
this.logger.warn(
`Dropping malformed tree update on ${COLLAB_TREE_UPDATE_CHANNEL}: ${
err instanceof Error ? err.message : String(err)
}`,
);
return;
}
// broadcastPageUpdated -> emitTreeEvent does a DB permission read that can
// reject. ioredis does not await this handler, so a rejection would become
// an unhandled promise rejection — swallow it (warn, never rethrow).
try {
await this.wsTree.broadcastPageUpdated(snapshot);
} catch (err) {
this.logger.warn(
`Failed to broadcast tree update for page ${snapshot.id}: ${
err instanceof Error ? err.message : String(err)
}`,
);
}
}
async onModuleDestroy(): Promise<void> {
if (!this.sub) return;
try {
await this.sub.unsubscribe(COLLAB_TREE_UPDATE_CHANNEL);
await this.sub.quit();
} catch (err) {
this.logger.warn(
`Failed to tear down tree-update subscriber: ${
err instanceof Error ? err.message : String(err)
}`,
);
}
}
}

View File

@@ -3,19 +3,12 @@ import { WsGateway } from './ws.gateway';
import { WsService } from './ws.service';
import { WsTreeService } from './ws-tree.service';
import { PageWsListener } from './listeners/page-ws.listener';
import { PageTreeBridgeSubscriber } from './listeners/page-tree-bridge.subscriber';
import { TokenModule } from '../core/auth/token.module';
@Global()
@Module({
imports: [TokenModule],
providers: [
WsGateway,
WsService,
WsTreeService,
PageWsListener,
PageTreeBridgeSubscriber,
],
providers: [WsGateway, WsService, WsTreeService, PageWsListener],
exports: [WsGateway, WsService, WsTreeService],
})
export class WsModule {}

View File

@@ -1,29 +0,0 @@
import type { CapacitorConfig } from "@capacitor/cli";
// Capacitor configuration for the Gitmost mobile shell.
//
// AGPL / App Store note (see docs/mobile-app-plan.md section 9): the AGPL web
// client must NOT be bundled into the iOS .ipa. On iOS, point the shell at a
// hosted client via CAP_SERVER_URL (server.url) so the AGPL bytes are served
// from our own server rather than redistributed under Apple's DRM/usage-rules.
// Android may bundle the local web build (webDir) directly.
const serverUrl = process.env.CAP_SERVER_URL?.trim();
const config: CapacitorConfig = {
appId: "xyz.vvzvlad.gitmost",
appName: "Gitmost",
// Web build output of apps/client (Android bundled mode / local assets).
// Build it with `pnpm run client:build` before `cap sync`.
webDir: "apps/client/dist",
...(serverUrl
? {
// iOS / hosted mode: load the client from our server (AGPL-clean).
server: {
url: serverUrl,
cleartext: false,
},
}
: {}),
};
export default config;

View File

@@ -1,367 +0,0 @@
# Мобильное приложение 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).
Серверная сторона нативной авторизации менять не нужно. (Подтверждено
мобильным бутстрапом.)
- **Токен можно вернуть в теле логина (opt-in).** [`login`](../apps/server/src/core/auth/auth.controller.ts)
по-прежнему кладёт JWT в `httpOnly`-cookie, а при флаге `returnToken` дополнительно
возвращает его в теле ответа (`data.authToken`) для нативных клиентов; веб-клиент
остаётся на cookie. Реализовано мобильным бутстрапом.
- **Точка входа вебсокета коллаборации:** [`POST /auth/collab-token`](../apps/server/src/core/auth/auth.controller.ts) (L187–193).
(Подтверждено мобильным бутстрапом.)
- **CORS — явный allowlist.** Вместо безусловного `app.enableCors()` теперь
настраиваемый whitelist через `CORS_ALLOWED_ORIGINS` плюс автоматически
разрешённые нативные WebView-origin'ы (Capacitor/Ionic/localhost). Реализовано
мобильным бутстрапом.
- **OpenAPI/Swagger — опционально.** Swagger UI доступен на `/api/docs` за флагом
`SWAGGER_ENABLED` (по умолчанию выключен), что даёт авто-генерацию типизированного
клиента. Реализовано мобильным бутстрапом.
---
## 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).
- [x] Добавить Capacitor в монорепо, нацелить на веб-билд `apps/client`
(Android — зашитый билд; iOS — `server.url`/PWA без зашитого AGPL, см. §9).
- [ ] `npx cap add ios` (Android — `npx cap add android`, когда будет готова обвязка) — нативные проекты генерируются локально и намеренно не хранятся в VCS (см. §9).
- [x] Бэкенд: мобильный логин-флоу с токеном в body; хранить токен в Keychain/
Keystore; слать `Authorization: Bearer`.
- [x] Бэкенд: явный CORS-whitelist под мобильные origin'ы.
- [ ] Native-плагины под App Store 4.2: push, биометрия, share, файлы.
- [ ] Push: APNs (iOS); FCM добавить вместе с Android.
- [ ] Проверить вебсокет коллаборации из WebView (`/auth/collab-token` + Hocuspocus).
- [x] (Опционально) Подключить `@nestjs/swagger`.

View File

@@ -1,61 +0,0 @@
# Mobile app bootstrap
Purpose: this document records what has been bootstrapped in the repo to enable a
mobile app for Gitmost, per the first-step checklist in
[docs/mobile-app-plan.md](./mobile-app-plan.md) section 12.
## What is in the repo now
- **PWA**: web app manifest plus a service worker generated by `vite-plugin-pwa`
using Workbox (`strategies: "generateSW"` — not hand-rolled). The SW is built
for production only (`devOptions: { enabled: false }`) and uses
`registerType: "prompt"`, so the user is asked to apply an update rather than it
auto-updating; registration goes through `virtual:pwa-register/react`
(`useRegisterSW`) in `apps/client/src/pwa/pwa-update-prompt.tsx`, mounted from
`main.tsx` and skipped inside the Capacitor native WebView. The SW precaches the
app shell (`globPatterns` js/css/html/...) and serves `navigateFallback:
"index.html"` for SPA routes, with `navigateFallbackDenylist` excluding the
server-owned routes `/api`, `/collab`, `/socket.io`, `/share/`, `/mcp`, and
`/robots.txt`. `runtimeCaching` keeps `/collab`, `/socket.io`, and all `/api`
as `NetworkOnly` — offline reads are served by the persisted TanStack Query
cache (IndexedDB) and `y-indexeddb` for the page Yjs doc, not by an SW HTTP
cache. This lets the existing responsive web UI be installed and run as a
Progressive Web App. See [docs/offline-sync-plan.md](./offline-sync-plan.md) for
the full offline/sync design.
- **Backend mobile auth**: opt-in token return from the login flow. The login
request accepts a `returnToken` flag (must be sent as a JSON boolean) that makes
the server include the auth token in the response body, and the server already
accepts a `Bearer` token in the `Authorization` header. Note the global response
interceptor wraps every payload, so the native client reads the token at
`response.data.authToken` (not at the top level). A native client can store this
token (Keychain / Keystore) and send it as `Authorization: Bearer` on each request.
- **Explicit CORS allowlist**: the server reads a `CORS_ALLOWED_ORIGINS` env
variable for the allowed origins, and always allows the native WebView origins
(`capacitor://localhost`, `ionic://localhost`, `https://localhost`) so the
mobile shell can call the API.
- **Optional OpenAPI / Swagger**: an opt-in OpenAPI/Swagger surface gated behind
the `SWAGGER_ENABLED` env flag, useful for developing the native client.
- **Capacitor config**: [capacitor.config.ts](../capacitor.config.ts) at the
repo root. It targets the `apps/client` web build output (`apps/client/dist`)
for the Android bundled mode, and on iOS loads the client from a hosted server
via `CAP_SERVER_URL` (`server.url`) so the AGPL web client is not bundled into
the `.ipa` (see mobile-app-plan section 9).
## Remaining MANUAL / local steps (require Xcode / external accounts, out of scope here)
- Run `pnpm install` to fetch the Capacitor packages and `@nestjs/swagger`.
- Run `pnpm run client:build` to produce the web build in `apps/client/dist`.
- Run `npx cap add ios` and/or `npx cap add android` to generate the native
platform projects (these live outside version control; see `.gitignore`).
- Set `CAP_SERVER_URL` for the iOS build so the shell loads the hosted client
(AGPL-clean), then run `pnpm run mobile:build` / `cap sync`.
- Set up push notifications: APNs for iOS and FCM for Android.
- Obtain an Apple Developer account and the App Store / Play Console listings.
- Confirm the AGPL iOS distribution decision (mobile-app-plan section 9) before
shipping anything to the App Store.
## See also
For the full background, rationale, and the licensing analysis, see
[docs/mobile-app-plan.md](./mobile-app-plan.md) (section 12 for the bootstrap
checklist, section 9 for the AGPL / App Store licensing path).

View File

@@ -16,18 +16,10 @@
"server:start": "nx run server:start:prod",
"email:dev": "nx run server:email:dev",
"dev": "pnpm concurrently -n \"frontend,backend\" -c \"cyan,green\" \"pnpm run client:dev\" \"pnpm run server:dev\"",
"clean": "rm -rf apps/*/dist packages/*/dist apps/client/node_modules/.vite",
"cap:copy": "cap copy",
"cap:sync": "cap sync",
"cap:ios": "cap open ios",
"cap:android": "cap open android",
"mobile:build": "pnpm run client:build && cap sync"
"clean": "rm -rf apps/*/dist packages/*/dist apps/client/node_modules/.vite"
},
"dependencies": {
"@braintree/sanitize-url": "^7.1.2",
"@capacitor/android": "^7.0.0",
"@capacitor/core": "^7.0.0",
"@capacitor/ios": "^7.0.0",
"@casl/ability": "6.8.0",
"@docmost/editor-ext": "workspace:*",
"@floating-ui/dom": "^1.7.3",
@@ -86,7 +78,6 @@
"yjs": "^13.6.30"
},
"devDependencies": {
"@capacitor/cli": "^7.0.0",
"@nx/js": "22.6.1",
"@types/bytes": "^3.1.5",
"@types/qrcode": "^1.5.6",

1185
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff