Compare commits
22 Commits
test/244-p
...
feature/of
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd186406b6 | ||
|
|
47f37072ab | ||
|
|
5eb92f2cef | ||
|
|
57b77c35e5 | ||
|
|
411c05a9d6 | ||
|
|
e8805b39c8 | ||
|
|
67a3663fc5 | ||
|
|
2cf30c7690 | ||
|
|
ca26af9e9d | ||
|
|
3d6f48c3bd | ||
|
|
2f5b520af2 | ||
|
|
655970dd49 | ||
|
|
7ceef2bae6 | ||
|
|
77aa9443e9 | ||
|
|
1ac9a8df98 | ||
|
|
8cfc4c3c40 | ||
|
|
85ad697cd4 | ||
|
|
ccc5e97000 | ||
|
|
df02f2d672 | ||
|
|
7ac7fcba2d | ||
|
|
caeb555039 | ||
|
|
e05495ba4f |
13
.env.example
13
.env.example
@@ -92,6 +92,19 @@ 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
5
.gitignore
vendored
@@ -49,3 +49,8 @@ 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
|
||||
|
||||
23
CHANGELOG.md
23
CHANGELOG.md
@@ -67,6 +67,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
`nosniff` + restrictive CSP + attachment disposition for non-image mimes) and
|
||||
are RAM-only, bound to the instance that created them. Tunable via five
|
||||
`SANDBOX_*` env vars (see `.env.example`). (#243)
|
||||
- **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 two offline stores (the persisted query cache and the Yjs page
|
||||
documents) are cleared on logout AND on sign-in so a previous user's private
|
||||
data does not remain in the browser; the same purge also defensively drops any
|
||||
legacy service-worker `api-get-cache` left by older clients (current builds
|
||||
serve `/api` as NetworkOnly, so there is no active service-worker API cache).
|
||||
- **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`.
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -87,6 +101,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
JSON-compatible schema (no custom tags / no code execution) behind the same
|
||||
size-cap, redirect and path-traversal guards. The `AI_AGENT_ROLES_CATALOG_URL`
|
||||
base-URL contract is unchanged. (#229)
|
||||
- **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. **Upgrade note:** the old bare `app.enableCors()` reflected
|
||||
*any* origin (with `credentials:false`), so any previously-working cross-domain
|
||||
REST/browser client is now rejected until its origin is added to
|
||||
`CORS_ALLOWED_ORIGINS` (see `.env.example`).
|
||||
|
||||
### Fixed
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<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" />
|
||||
|
||||
@@ -33,7 +33,9 @@
|
||||
"@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",
|
||||
@@ -45,6 +47,7 @@
|
||||
"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",
|
||||
@@ -95,6 +98,7 @@
|
||||
"typescript": "5.9.3",
|
||||
"typescript-eslint": "8.57.1",
|
||||
"vite": "8.0.5",
|
||||
"vite-plugin-pwa": "1.3.0",
|
||||
"vitest": "4.1.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -464,6 +464,18 @@
|
||||
"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",
|
||||
"You're offline": "You're offline",
|
||||
"This page hasn't been saved for offline use, so it can't be loaded right now. Reconnect to the internet and try again.": "This page hasn't been saved for offline use, so it can't be loaded right now. Reconnect to the internet and try again.",
|
||||
"Retry": "Retry",
|
||||
"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",
|
||||
|
||||
@@ -474,6 +474,18 @@
|
||||
"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": "Не удалось сделать страницу доступной офлайн",
|
||||
"You're offline": "Вы офлайн",
|
||||
"This page hasn't been saved for offline use, so it can't be loaded right now. Reconnect to the internet and try again.": "Эта страница не была сохранена для офлайн-доступа, поэтому её нельзя загрузить сейчас. Подключитесь к интернету и попробуйте снова.",
|
||||
"Retry": "Повторить",
|
||||
"Table of contents": "Оглавление",
|
||||
"Add headings (H1, H2, H3) to generate a table of contents.": "Добавьте заголовки (H1, H2, H3), чтобы создать оглавление.",
|
||||
"Share": "Поделиться",
|
||||
|
||||
@@ -1,30 +1,19 @@
|
||||
{
|
||||
"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": "180x180 192x192"
|
||||
},
|
||||
{
|
||||
"src": "icons/app-icon-512x512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
{ "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" }
|
||||
]
|
||||
}
|
||||
|
||||
154
apps/client/src/features/auth/hooks/use-auth.test.ts
Normal file
154
apps/client/src/features/auth/hooks/use-auth.test.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
|
||||
// react-i18next: identity t() so the hook renders without an i18n provider.
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (k: string) => k }),
|
||||
}));
|
||||
|
||||
// react-router-dom: only useNavigate is used by the hook.
|
||||
const navigateMock = vi.fn();
|
||||
vi.mock("react-router-dom", () => ({
|
||||
useNavigate: () => navigateMock,
|
||||
}));
|
||||
|
||||
// The auth service is the network boundary; stub login/logout per test.
|
||||
const loginMock = vi.fn();
|
||||
const logoutMock = vi.fn();
|
||||
vi.mock("@/features/auth/services/auth-service", () => ({
|
||||
login: (...args: unknown[]) => loginMock(...args),
|
||||
logout: (...args: unknown[]) => logoutMock(...args),
|
||||
forgotPassword: vi.fn(),
|
||||
passwordReset: vi.fn(),
|
||||
setupWorkspace: vi.fn(),
|
||||
verifyUserToken: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/workspace/services/workspace-service.ts", () => ({
|
||||
acceptInvitation: vi.fn(),
|
||||
}));
|
||||
|
||||
// The offline cache purge is the unit under test — assert it is invoked.
|
||||
const clearOfflineCacheMock = vi.fn();
|
||||
vi.mock("@/features/offline/clear-offline-cache", () => ({
|
||||
clearOfflineCache: () => clearOfflineCacheMock(),
|
||||
}));
|
||||
|
||||
// app-route helpers are pure config; provide deterministic values.
|
||||
vi.mock("@/lib/app-route.ts", () => ({
|
||||
default: { AUTH: { LOGIN: "/login" }, HOME: "/home" },
|
||||
getPostLoginRedirect: () => "/home",
|
||||
}));
|
||||
|
||||
// Mantine notifications: avoid touching the DOM-bound notification system.
|
||||
vi.mock("@mantine/notifications", () => ({
|
||||
notifications: { show: vi.fn() },
|
||||
}));
|
||||
|
||||
import useAuth from "./use-auth";
|
||||
|
||||
beforeEach(() => {
|
||||
navigateMock.mockReset();
|
||||
loginMock.mockReset();
|
||||
loginMock.mockResolvedValue(undefined);
|
||||
logoutMock.mockReset();
|
||||
logoutMock.mockResolvedValue(undefined);
|
||||
clearOfflineCacheMock.mockReset();
|
||||
clearOfflineCacheMock.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe("useAuth.handleSignIn", () => {
|
||||
it("clears the offline cache BEFORE logging in (cross-user leak guard)", async () => {
|
||||
const order: string[] = [];
|
||||
clearOfflineCacheMock.mockImplementation(async () => {
|
||||
order.push("clear");
|
||||
});
|
||||
loginMock.mockImplementation(async () => {
|
||||
order.push("login");
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useAuth());
|
||||
await act(async () => {
|
||||
await result.current.signIn({ email: "b@x", password: "pw" } as any);
|
||||
});
|
||||
|
||||
expect(clearOfflineCacheMock).toHaveBeenCalledTimes(1);
|
||||
expect(loginMock).toHaveBeenCalledTimes(1);
|
||||
// The purge must run before the new session's login resolves.
|
||||
expect(order).toEqual(["clear", "login"]);
|
||||
expect(navigateMock).toHaveBeenCalledWith("/home");
|
||||
});
|
||||
|
||||
it("does not block sign-in when the cache purge throws (best-effort)", async () => {
|
||||
clearOfflineCacheMock.mockRejectedValue(new Error("idb unavailable"));
|
||||
|
||||
const { result } = renderHook(() => useAuth());
|
||||
await act(async () => {
|
||||
await result.current.signIn({ email: "b@x", password: "pw" } as any);
|
||||
});
|
||||
|
||||
// Login still proceeds despite the cleanup failure.
|
||||
expect(loginMock).toHaveBeenCalledTimes(1);
|
||||
expect(navigateMock).toHaveBeenCalledWith("/home");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useAuth.handleLogout", () => {
|
||||
const replaceMock = vi.fn();
|
||||
let originalLocation: Location;
|
||||
|
||||
beforeEach(() => {
|
||||
replaceMock.mockReset();
|
||||
// window.location.replace is the post-logout redirect. jsdom's real `replace`
|
||||
// is a non-configurable method that warns "not implemented", so swap the
|
||||
// whole location object for one whose `replace` we can capture.
|
||||
originalLocation = window.location;
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: { replace: replaceMock },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: originalLocation,
|
||||
});
|
||||
});
|
||||
|
||||
it("purges the offline cache exactly once BEFORE redirecting (cross-user leak guard)", async () => {
|
||||
const order: string[] = [];
|
||||
clearOfflineCacheMock.mockImplementation(async () => {
|
||||
order.push("clear");
|
||||
});
|
||||
replaceMock.mockImplementation((url: string) => {
|
||||
order.push(`replace:${url}`);
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useAuth());
|
||||
await act(async () => {
|
||||
await result.current.logout();
|
||||
});
|
||||
|
||||
expect(clearOfflineCacheMock).toHaveBeenCalledTimes(1);
|
||||
// Purge must complete before the redirect (which would otherwise interrupt
|
||||
// the async cleanup).
|
||||
expect(order).toEqual(["clear", "replace:/login?logout=1"]);
|
||||
});
|
||||
|
||||
it("still redirects when the cache purge throws (best-effort, never blocks logout)", async () => {
|
||||
clearOfflineCacheMock.mockRejectedValue(new Error("idb unavailable"));
|
||||
|
||||
const { result } = renderHook(() => useAuth());
|
||||
await act(async () => {
|
||||
await result.current.logout();
|
||||
});
|
||||
|
||||
// The thrown purge error is swallowed and the redirect still fires.
|
||||
expect(clearOfflineCacheMock).toHaveBeenCalledTimes(1);
|
||||
expect(replaceMock).toHaveBeenCalledTimes(1);
|
||||
expect(replaceMock).toHaveBeenCalledWith("/login?logout=1");
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,7 @@ 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();
|
||||
@@ -33,6 +34,20 @@ export default function useAuth() {
|
||||
const handleSignIn = async (data: ILogin) => {
|
||||
setIsLoading(true);
|
||||
|
||||
// Purge any previous user's offline data BEFORE signing in (mirrors logout).
|
||||
// On a shared/kiosk device the prior session may have ended WITHOUT an
|
||||
// explicit logout (cookie/JWT expiry, tab close, force-quit), leaving user
|
||||
// A's persisted query cache (gitmost-rq-cache) and Yjs page bodies
|
||||
// (page.<id>) in IndexedDB. Without this purge user B would briefly read A's
|
||||
// cached currentUser/pages/comments on first render (UserProvider serves the
|
||||
// cached user) and A's page bodies would stay readable offline. Best-effort:
|
||||
// never block sign-in on cache cleanup.
|
||||
try {
|
||||
await clearOfflineCache();
|
||||
} catch {
|
||||
// best-effort: never block sign-in on cache cleanup
|
||||
}
|
||||
|
||||
try {
|
||||
await login(data);
|
||||
setIsLoading(false);
|
||||
@@ -123,6 +138,13 @@ 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`);
|
||||
};
|
||||
|
||||
|
||||
43
apps/client/src/features/auth/queries/auth-query.test.ts
Normal file
43
apps/client/src/features/auth/queries/auth-query.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { AxiosError } from "axios";
|
||||
import { collabTokenRetry } from "./auth-query";
|
||||
|
||||
// Regression for the offline white-screen (#237/#238): offline the collab-token
|
||||
// POST rejects as an axios NETWORK error (isAxiosError === true but
|
||||
// error.response === undefined). The old predicate read `error.response.status`
|
||||
// without a guard and threw an uncaught TypeError inside the React Query retryer
|
||||
// BEFORE React mounted, blanking the whole app. The predicate must stay total.
|
||||
describe("collabTokenRetry", () => {
|
||||
it("does NOT throw and returns a retryable value for a network error with no response (offline)", () => {
|
||||
// An axios error with no `response` is exactly the offline/network-failure shape.
|
||||
const networkError = new AxiosError("Network Error");
|
||||
expect(networkError.response).toBeUndefined();
|
||||
|
||||
let result: boolean | number = false;
|
||||
expect(() => {
|
||||
result = collabTokenRetry(0, networkError);
|
||||
}).not.toThrow();
|
||||
// Network failures stay retryable (truthy), matching the original intent.
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false (no retry) for a real 404 response", () => {
|
||||
const notFound = new AxiosError("Not Found");
|
||||
notFound.response = { status: 404 } as AxiosError["response"];
|
||||
expect(collabTokenRetry(0, notFound)).toBe(false);
|
||||
});
|
||||
|
||||
it("retries for a non-404 response (e.g. 500)", () => {
|
||||
const serverError = new AxiosError("Server Error");
|
||||
serverError.response = { status: 500 } as AxiosError["response"];
|
||||
expect(collabTokenRetry(0, serverError)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not throw and retries for a non-axios error", () => {
|
||||
let result: boolean | number = false;
|
||||
expect(() => {
|
||||
result = collabTokenRetry(0, new Error("boom"));
|
||||
}).not.toThrow();
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,27 @@ import { getCollabToken, verifyUserToken } from "../services/auth-service";
|
||||
import { ICollabToken, IVerifyUserToken } from "../types/auth.types";
|
||||
import { isAxiosError } from "axios";
|
||||
|
||||
/**
|
||||
* Retry predicate for the collab-token query.
|
||||
*
|
||||
* Offline (or any network failure) the POST rejects as an axios NETWORK error:
|
||||
* `isAxiosError(error) === true` but `error.response === undefined`. Reading
|
||||
* `error.response.status` without a guard threw an uncaught TypeError inside the
|
||||
* React Query retryer BEFORE React mounted, white-screening the whole app on an
|
||||
* offline cold boot (#237/#238). Optional-chaining `error.response?.status`
|
||||
* keeps the predicate total: a network error (no response) is retryable, a real
|
||||
* 404 is not. Extracted (and exported) so it can be unit-tested in isolation.
|
||||
*/
|
||||
export function collabTokenRetry(
|
||||
_failureCount: number,
|
||||
error: Error,
|
||||
): boolean {
|
||||
if (isAxiosError(error) && error.response?.status === 404) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function useVerifyUserTokenQuery(
|
||||
verify: IVerifyUserToken,
|
||||
): UseQueryResult<any, Error> {
|
||||
@@ -22,13 +43,7 @@ export function useCollabToken(): UseQueryResult<ICollabToken, Error> {
|
||||
//refetchInterval: 12 * 60 * 60 * 1000, // 12hrs
|
||||
//refetchIntervalInBackground: true,
|
||||
refetchOnMount: true,
|
||||
//@ts-ignore
|
||||
retry: (failureCount, error) => {
|
||||
if (isAxiosError(error) && error.response.status === 404) {
|
||||
return false;
|
||||
}
|
||||
return 10;
|
||||
},
|
||||
retry: collabTokenRetry,
|
||||
retryDelay: (retryAttempt) => {
|
||||
// Exponential backoff: 5s, 10s, 20s, etc.
|
||||
return 5000 * Math.pow(2, retryAttempt - 1);
|
||||
|
||||
@@ -20,6 +20,7 @@ import { notifications } from "@mantine/notifications";
|
||||
import { IPagination } from "@/lib/types.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { offlineMutationKeys } from "@/features/offline/offline-mutations";
|
||||
|
||||
export const RQ_KEY = (pageId: string) => ["comments", pageId];
|
||||
|
||||
@@ -60,6 +61,9 @@ export function useCreateCommentMutation() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<IComment, Error, Partial<IComment>>({
|
||||
// Stable key so a paused comment-create restored from IndexedDB after an
|
||||
// offline reload finds its default mutationFn and is replayed on reconnect.
|
||||
mutationKey: offlineMutationKeys.createComment,
|
||||
mutationFn: (data) => createComment(data),
|
||||
onSuccess: (newComment) => {
|
||||
const cache = queryClient.getQueryData(
|
||||
|
||||
@@ -10,6 +10,12 @@ 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 showLinkMenuAtom = atom(false);
|
||||
|
||||
// Current page's edit mode — initialized from the user's saved preference on
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
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);
|
||||
}
|
||||
@@ -34,6 +34,8 @@ 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);
|
||||
@@ -80,16 +82,24 @@ export function FullEditor({
|
||||
// AI title generation is gated by the general AI chat flag (the same toggle
|
||||
// that enables the chat agent); the server enforces it too (#199).
|
||||
const isTitleGenEnabled = workspace?.settings?.ai?.chat === true;
|
||||
const fullPageWidth = user.settings?.preferences?.fullPageWidth;
|
||||
// `user` can momentarily be null during logout teardown (the currentUser atom
|
||||
// is reset before this subtree unmounts). Optional-chain every access so the
|
||||
// teardown render does not throw "Cannot read properties of null (reading
|
||||
// 'settings')".
|
||||
const fullPageWidth = user?.settings?.preferences?.fullPageWidth;
|
||||
const editorToolbarEnabled =
|
||||
user.settings?.preferences?.editorToolbar ?? false;
|
||||
user?.settings?.preferences?.editorToolbar ?? false;
|
||||
const [currentPageEditMode, setCurrentPageEditMode] = useAtom(
|
||||
currentPageEditModeAtom,
|
||||
);
|
||||
const userPageEditMode =
|
||||
user.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
|
||||
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(() => {
|
||||
@@ -110,28 +120,32 @@ export function FullEditor({
|
||||
)}
|
||||
<MemoizedDeletedPageBanner slugId={slugId} />
|
||||
<MemoizedTemporaryNoteBanner slugId={slugId} />
|
||||
<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
|
||||
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>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
48
apps/client/src/features/editor/hooks/collab-token.test.ts
Normal file
48
apps/client/src/features/editor/hooks/collab-token.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// jwt-decode is mocked so we can drive the four token states deterministically
|
||||
// (decode success with a chosen exp, or a thrown decode error).
|
||||
const decodeMock = vi.hoisted(() => vi.fn());
|
||||
vi.mock("jwt-decode", () => ({
|
||||
jwtDecode: decodeMock,
|
||||
}));
|
||||
|
||||
import { collabTokenNeedsRefresh } from "./collab-token";
|
||||
|
||||
const NOW_MS = 1_000_000_000; // fixed "now" in ms (so NOW_MS/1000 seconds)
|
||||
|
||||
beforeEach(() => {
|
||||
decodeMock.mockReset();
|
||||
});
|
||||
|
||||
describe("collabTokenNeedsRefresh", () => {
|
||||
it("returns true when there is no token (fetch a fresh one)", () => {
|
||||
expect(collabTokenNeedsRefresh(undefined, NOW_MS)).toBe(true);
|
||||
// jwtDecode must not even be called for a missing token.
|
||||
expect(decodeMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns true when the token is malformed (jwtDecode throws)", () => {
|
||||
decodeMock.mockImplementation(() => {
|
||||
throw new Error("invalid token");
|
||||
});
|
||||
expect(collabTokenNeedsRefresh("garbage", NOW_MS)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for a valid, not-yet-expired token (no reconnect)", () => {
|
||||
// exp is in the future relative to NOW.
|
||||
decodeMock.mockReturnValue({ exp: NOW_MS / 1000 + 60 });
|
||||
expect(collabTokenNeedsRefresh("good", NOW_MS)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for a valid but expired token (refresh + reconnect)", () => {
|
||||
// exp is in the past relative to NOW.
|
||||
decodeMock.mockReturnValue({ exp: NOW_MS / 1000 - 60 });
|
||||
expect(collabTokenNeedsRefresh("expired", NOW_MS)).toBe(true);
|
||||
});
|
||||
|
||||
it("treats exp exactly equal to now as expired (>= boundary)", () => {
|
||||
decodeMock.mockReturnValue({ exp: NOW_MS / 1000 });
|
||||
expect(collabTokenNeedsRefresh("boundary", NOW_MS)).toBe(true);
|
||||
});
|
||||
});
|
||||
26
apps/client/src/features/editor/hooks/collab-token.ts
Normal file
26
apps/client/src/features/editor/hooks/collab-token.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { jwtDecode } from "jwt-decode";
|
||||
|
||||
/**
|
||||
* Decide whether a collab token must be refreshed before reconnecting after an
|
||||
* onAuthenticationFailed event. Pure and side-effect free so the four token
|
||||
* states can be unit-tested directly:
|
||||
* - no token -> true (fetch a fresh one and reconnect)
|
||||
* - undecodable/malformed -> true (jwtDecode throws -> refresh)
|
||||
* - valid, not expired -> false (token is still good; do NOT reconnect)
|
||||
* - valid, expired -> true (refresh + reconnect)
|
||||
*
|
||||
* `nowMs` is injectable for deterministic tests; it defaults to `Date.now()`.
|
||||
*/
|
||||
export function collabTokenNeedsRefresh(
|
||||
token: string | undefined,
|
||||
nowMs: number = Date.now(),
|
||||
): boolean {
|
||||
if (!token) return true;
|
||||
try {
|
||||
const payload = jwtDecode<{ exp: number }>(token);
|
||||
return nowMs / 1000 >= payload.exp;
|
||||
} catch {
|
||||
// malformed/undecodable token -> refresh
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -139,7 +139,7 @@ describe("useGeneratePageTitle", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("happy path: applies the title, refreshes cache, writes the field, broadcasts", async () => {
|
||||
it("happy path: applies the title, refreshes cache, broadcasts, and does NOT write the editor", async () => {
|
||||
const store = createStore();
|
||||
const titleEditor = makeTitleEditor();
|
||||
store.set(pageEditorAtom as never, makePageEditor("pageA"));
|
||||
@@ -157,9 +157,11 @@ describe("useGeneratePageTitle", () => {
|
||||
title: "Generated Title",
|
||||
});
|
||||
expect(updatePageDataMock).toHaveBeenCalledWith(PAGE_A);
|
||||
expect(titleEditor.commands.setContent).toHaveBeenCalledWith(
|
||||
"Generated Title",
|
||||
);
|
||||
// The title editor is bound to the Yjs `title` fragment; the server REST
|
||||
// update reseeds that fragment and the reseed reaches the bound editor on
|
||||
// its own. Writing here too would double/garble the title, so the hook must
|
||||
// NOT touch the editor (regression guard for the Yjs duplication trap).
|
||||
expect(titleEditor.commands.setContent).not.toHaveBeenCalled();
|
||||
expect(localEmitMock).toHaveBeenCalled();
|
||||
expect(emitMock).toHaveBeenCalled();
|
||||
expect(notificationsShowMock).toHaveBeenCalledWith(
|
||||
@@ -167,7 +169,7 @@ describe("useGeneratePageTitle", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("does NOT write the visible title field when the user navigated away during generation", async () => {
|
||||
it("keeps the DB write keyed by the captured pageId and still broadcasts after navigation", async () => {
|
||||
const store = createStore();
|
||||
const titleEditor = makeTitleEditor(); // persistent across navigation
|
||||
store.set(pageEditorAtom as never, makePageEditor("pageA"));
|
||||
@@ -203,55 +205,9 @@ describe("useGeneratePageTitle", () => {
|
||||
pageId: "pageA",
|
||||
title: "Generated Title",
|
||||
});
|
||||
// ...but we must NOT stamp page A's title into page B's visible field.
|
||||
// ...the hook never writes the editor regardless of navigation...
|
||||
expect(titleEditor.commands.setContent).not.toHaveBeenCalled();
|
||||
// The change is still broadcast to other clients.
|
||||
expect(emitMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT write the visible title field when the title editor is focused", async () => {
|
||||
const store = createStore();
|
||||
const titleEditor = makeTitleEditor();
|
||||
store.set(pageEditorAtom as never, makePageEditor("pageA"));
|
||||
store.set(titleEditorAtom as never, titleEditor);
|
||||
|
||||
// Resolve generation under our control so we can mark the live title editor
|
||||
// as focused before the post-generation write runs.
|
||||
let resolveTitle!: (t: string) => void;
|
||||
generatePageTitleMock.mockReturnValue(
|
||||
new Promise<string>((res) => {
|
||||
resolveTitle = res;
|
||||
}),
|
||||
);
|
||||
updateTitleMock.mockResolvedValue(PAGE_A);
|
||||
const { result } = setup("pageA", store);
|
||||
|
||||
let pending!: Promise<void>;
|
||||
act(() => {
|
||||
pending = result.current.mutateAsync();
|
||||
});
|
||||
|
||||
// The user clicked into the title field while the model ran — overwriting it
|
||||
// now would clobber what they are actively typing.
|
||||
act(() => {
|
||||
(titleEditor as { isFocused: boolean }).isFocused = true;
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
resolveTitle("Generated Title");
|
||||
await pending;
|
||||
});
|
||||
|
||||
// The DB write still persists the value...
|
||||
expect(updateTitleMock).toHaveBeenCalledWith({
|
||||
pageId: "pageA",
|
||||
title: "Generated Title",
|
||||
});
|
||||
expect(updatePageDataMock).toHaveBeenCalledWith(PAGE_A);
|
||||
// ...but the visible field is left alone while it is focused.
|
||||
expect(titleEditor.commands.setContent).not.toHaveBeenCalled();
|
||||
// The change is still broadcast to other clients.
|
||||
expect(localEmitMock).toHaveBeenCalled();
|
||||
// ...and the change is still broadcast to other clients.
|
||||
expect(emitMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import { useRef } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { htmlToMarkdown } from "@docmost/editor-ext";
|
||||
import {
|
||||
pageEditorAtom,
|
||||
titleEditorAtom,
|
||||
} from "@/features/editor/atoms/editor-atoms.ts";
|
||||
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
|
||||
import {
|
||||
updatePageData,
|
||||
useUpdateTitlePageMutation,
|
||||
@@ -33,18 +29,9 @@ const MAX_CONTENT_CHARS = 20000;
|
||||
export function useGeneratePageTitle(pageId: string) {
|
||||
const { t } = useTranslation();
|
||||
const pageEditor = useAtomValue(pageEditorAtom);
|
||||
const titleEditor = useAtomValue(titleEditorAtom);
|
||||
const { mutateAsync: updateTitle } = useUpdateTitlePageMutation();
|
||||
const emit = useQueryEmit();
|
||||
|
||||
// The page/title editors come from GLOBAL atoms that re-point when the user
|
||||
// navigates to another page. The mutation below awaits the model for 1-3s, and
|
||||
// its closure captures the editors from the render that started it. Keep a live
|
||||
// reference so the post-generation write targets whatever page is on screen
|
||||
// *now*, not the page the generation was started from.
|
||||
const editorsRef = useRef({ pageEditor, titleEditor });
|
||||
editorsRef.current = { pageEditor, titleEditor };
|
||||
|
||||
return useMutation<void, Error, void>({
|
||||
mutationFn: async () => {
|
||||
if (!pageEditor || pageEditor.isDestroyed) return;
|
||||
@@ -70,33 +57,15 @@ export function useGeneratePageTitle(pageId: string) {
|
||||
const page = await updateTitle({ pageId, title }); // POST /pages/update
|
||||
updatePageData(page); // refresh the react-query cache
|
||||
|
||||
// Reflect the new title in the field immediately. The button lives in the
|
||||
// byline, so the title editor is not focused — setContent is safe and stays
|
||||
// undoable through its History extension (Ctrl/Cmd+Z reverts the change).
|
||||
//
|
||||
// Guard against navigation during generation: if the user switched pages
|
||||
// while the model ran, the (persistent) title editor now shows ANOTHER
|
||||
// page, so writing here would drop page A's title into page B's visible
|
||||
// field. page-editor.tsx stamps the live page editor with its pageId
|
||||
// (`editor.storage.pageId`), mirroring TitleEditor's `activePageId !==
|
||||
// pageId` guard — bail the visible write unless that live editor still
|
||||
// belongs to the page this title was generated for. The DB write above is
|
||||
// already correct (keyed by the captured `pageId`), and the broadcast below
|
||||
// still propagates page A's change to other clients.
|
||||
const livePageEditor = editorsRef.current.pageEditor;
|
||||
const liveTitleEditor = editorsRef.current.titleEditor;
|
||||
// `storage.pageId` is stamped untyped in page-editor.tsx's onCreate.
|
||||
const livePageId = (livePageEditor?.storage as { pageId?: string })
|
||||
?.pageId;
|
||||
const stillOnPage = livePageId === pageId;
|
||||
if (
|
||||
stillOnPage &&
|
||||
liveTitleEditor &&
|
||||
!liveTitleEditor.isDestroyed &&
|
||||
!liveTitleEditor.isFocused
|
||||
) {
|
||||
liveTitleEditor.commands.setContent(page.title);
|
||||
}
|
||||
// Do NOT write the title into the editor here. The title editor is bound to
|
||||
// the Yjs `title` fragment and Yjs is the source of truth. The server REST
|
||||
// /pages/update reseeds that fragment (writePageTitle → writeTitleFragment,
|
||||
// a full clear+replace) and the reseed reaches the bound title editor on
|
||||
// its own as a remote provider update. The old REST-era setContent here
|
||||
// would race that reseed and double/garble the title (the "Yjs duplication
|
||||
// trap"), so it is intentionally omitted. The DB write above is keyed by
|
||||
// the captured `pageId`, so it stays correct even if the user navigated
|
||||
// away during generation.
|
||||
|
||||
// Broadcast to other clients, mirroring TitleEditor.saveTitle's event shape.
|
||||
const event: UpdateEvent = {
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
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 { collabTokenNeedsRefresh } from "@/features/editor/hooks/collab-token";
|
||||
import { pageYdocName } from "@/features/editor/page-ydoc-name";
|
||||
import { pageKeys } from "@/features/page/queries/page-query";
|
||||
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
if (!providersRef.current) {
|
||||
const documentName = pageYdocName(pageId);
|
||||
const ydoc = new Y.Doc();
|
||||
const local = new IndexeddbPersistence(documentName, ydoc);
|
||||
const socket = new HocuspocusProviderWebsocket({
|
||||
url: collaborationURL,
|
||||
});
|
||||
const onLocalSyncedHandler = () => {
|
||||
setIsLocalSyncedAtom(true);
|
||||
};
|
||||
const onStatusHandler = (event: onStatusParameters) => {
|
||||
setYjsConnectionStatus(event.status);
|
||||
};
|
||||
const onSyncedHandler = (event: onSyncedParameters) => {
|
||||
setIsRemoteSyncedAtom(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>(
|
||||
pageKeys.detail(slugId),
|
||||
);
|
||||
if (pageData) {
|
||||
queryClient.setQueryData(pageKeys.detail(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.
|
||||
if (!collabTokenNeedsRefresh(collabTokenRef.current)) 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.
|
||||
setIsLocalSyncedAtom(false);
|
||||
setIsRemoteSyncedAtom(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,
|
||||
};
|
||||
}
|
||||
@@ -6,16 +6,7 @@ import React, {
|
||||
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 { WebSocketStatus } from "@hocuspocus/provider";
|
||||
import {
|
||||
Editor,
|
||||
EditorContent,
|
||||
@@ -28,13 +19,15 @@ 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,
|
||||
@@ -58,10 +51,8 @@ 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, useDocumentVisibility } from "@mantine/hooks";
|
||||
import { useIdle } from "@/hooks/use-idle.ts";
|
||||
import { useDebouncedCallback } from "@mantine/hooks";
|
||||
import { queryClient } from "@/main.tsx";
|
||||
import { IPage } from "@/features/page/types/page.types.ts";
|
||||
import { useParams } from "react-router-dom";
|
||||
@@ -72,9 +63,7 @@ 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";
|
||||
@@ -103,7 +92,6 @@ export default function PageEditor({
|
||||
canComment,
|
||||
}: PageEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const collaborationURL = useCollaborationUrl();
|
||||
const isComponentMounted = useRef(false);
|
||||
const editorRef = useRef<Editor | null>(null);
|
||||
|
||||
@@ -117,22 +105,10 @@ 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);
|
||||
@@ -141,141 +117,27 @@ 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);
|
||||
|
||||
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();
|
||||
// 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);
|
||||
|
||||
const extensions = useMemo(() => {
|
||||
if (!providersReady || !providersRef.current || !currentUser?.user) {
|
||||
if (!providersReady || !remote || !currentUser?.user) {
|
||||
return mainExtensions;
|
||||
}
|
||||
|
||||
const remoteProvider = providersRef.current.remote;
|
||||
|
||||
return [
|
||||
...mainExtensions,
|
||||
...collabExtensions(remoteProvider, currentUser?.user),
|
||||
...collabExtensions(remote, currentUser?.user),
|
||||
];
|
||||
}, [providersReady, currentUser?.user]);
|
||||
}, [providersReady, remote, currentUser?.user]);
|
||||
|
||||
const editor = useEditor(
|
||||
{
|
||||
@@ -550,7 +412,7 @@ export default function PageEditor({
|
||||
{editor &&
|
||||
!editorIsEditable &&
|
||||
(editable || canComment) &&
|
||||
providersRef.current && <ReadonlyBubbleMenu editor={editor} />}
|
||||
remote && <ReadonlyBubbleMenu editor={editor} />}
|
||||
{showCommentPopup && (
|
||||
<CommentDialog editor={editor} pageId={pageId} />
|
||||
)}
|
||||
|
||||
14
apps/client/src/features/editor/page-ydoc-name.ts
Normal file
14
apps/client/src/features/editor/page-ydoc-name.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Single source of truth for the IndexedDB / Hocuspocus document name of a
|
||||
* page's collaborative Yjs doc.
|
||||
*
|
||||
* The `page.<id>` convention is shared knowledge across three call sites: the
|
||||
* live editor providers (`use-page-collab-providers`), the offline warm path
|
||||
* (`make-offline`), and the offline purge (`clear-offline-cache`, which matches
|
||||
* the databases to delete by this prefix). Centralizing it here stops those
|
||||
* sites from silently drifting apart.
|
||||
*/
|
||||
export const PAGE_YDOC_NAME_PREFIX = "page.";
|
||||
|
||||
export const pageYdocName = (pageId: string): string =>
|
||||
`${PAGE_YDOC_NAME_PREFIX}${pageId}`;
|
||||
33
apps/client/src/features/editor/title-collab.test.ts
Normal file
33
apps/client/src/features/editor/title-collab.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// isChangeOrigin is mocked so we can simulate local vs remote/collab-origin
|
||||
// transactions without constructing a real ProseMirror/Yjs transaction.
|
||||
const isChangeOriginMock = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@tiptap/extension-collaboration", () => ({
|
||||
isChangeOrigin: isChangeOriginMock,
|
||||
}));
|
||||
|
||||
import { shouldPropagateTitleChange } from "./title-collab";
|
||||
|
||||
beforeEach(() => {
|
||||
isChangeOriginMock.mockReset();
|
||||
});
|
||||
|
||||
describe("shouldPropagateTitleChange", () => {
|
||||
it("propagates a genuine local edit (isChangeOrigin false)", () => {
|
||||
isChangeOriginMock.mockReturnValue(false);
|
||||
expect(shouldPropagateTitleChange({ local: true })).toBe(true);
|
||||
expect(isChangeOriginMock).toHaveBeenCalledWith({ local: true });
|
||||
});
|
||||
|
||||
it("skips a remote/collab-origin update (isChangeOrigin true)", () => {
|
||||
isChangeOriginMock.mockReturnValue(true);
|
||||
expect(shouldPropagateTitleChange({ remote: true })).toBe(false);
|
||||
});
|
||||
|
||||
it("propagates when there is no transaction (treated as local)", () => {
|
||||
expect(shouldPropagateTitleChange(undefined)).toBe(true);
|
||||
// isChangeOrigin must not be called for a missing transaction.
|
||||
expect(isChangeOriginMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
19
apps/client/src/features/editor/title-collab.ts
Normal file
19
apps/client/src/features/editor/title-collab.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { isChangeOrigin } from "@tiptap/extension-collaboration";
|
||||
|
||||
/**
|
||||
* Whether a TitleEditor `onUpdate` should drive URL + tree propagation.
|
||||
*
|
||||
* Only genuine LOCAL edits propagate. Remote/collab-origin Yjs updates
|
||||
* (detected via `isChangeOrigin`) are skipped so a remote title change is not
|
||||
* re-broadcast back, which would create a feedback loop. A missing transaction
|
||||
* is treated as a local edit (propagate).
|
||||
*
|
||||
* Extracted as a pure helper so the skip decision is unit-testable without
|
||||
* mounting the full collaborative editor.
|
||||
*/
|
||||
export function shouldPropagateTitleChange(transaction: unknown): boolean {
|
||||
return !(
|
||||
transaction &&
|
||||
isChangeOrigin(transaction as Parameters<typeof isChangeOrigin>[0])
|
||||
);
|
||||
}
|
||||
87
apps/client/src/features/editor/title-editor.test.tsx
Normal file
87
apps/client/src/features/editor/title-editor.test.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
|
||||
// Drive the fallback-vs-collaborative switch (titleReady = providersReady &&
|
||||
// !!ydoc) by controlling what the editor-providers context returns.
|
||||
const editorProvidersValue: { ydoc: unknown; providersReady: boolean } = {
|
||||
ydoc: null,
|
||||
providersReady: false,
|
||||
};
|
||||
vi.mock("@/features/editor/contexts/editor-providers-context", () => ({
|
||||
useEditorProviders: () => editorProvidersValue,
|
||||
}));
|
||||
|
||||
// Mock the tiptap React bindings so the test does not mount a real editor:
|
||||
// useEditor returns a minimal stub and EditorContent renders a marker.
|
||||
vi.mock("@tiptap/react", () => ({
|
||||
useEditor: () => ({
|
||||
isInitialized: true,
|
||||
commands: { focus: vi.fn() },
|
||||
setEditable: vi.fn(),
|
||||
getText: () => "",
|
||||
}),
|
||||
EditorContent: () => <div data-testid="collab-editor" />,
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (k: string) => k }),
|
||||
}));
|
||||
|
||||
const navigateMock = vi.fn();
|
||||
vi.mock("react-router-dom", () => ({
|
||||
useNavigate: () => navigateMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/features/websocket/use-query-emit.ts", () => ({
|
||||
useQueryEmit: () => vi.fn(),
|
||||
}));
|
||||
|
||||
// page-query transitively imports @/main.tsx; mock it to a pure stub.
|
||||
vi.mock("@/features/page/queries/page-query", () => ({
|
||||
updatePageData: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/main.tsx", () => ({
|
||||
queryClient: { getQueryData: vi.fn(), setQueryData: vi.fn() },
|
||||
}));
|
||||
|
||||
import { TitleEditor } from "./title-editor";
|
||||
|
||||
const baseProps = {
|
||||
pageId: "p1",
|
||||
slugId: "slug-1",
|
||||
title: "My Page Title",
|
||||
spaceSlug: "space",
|
||||
editable: true,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
navigateMock.mockReset();
|
||||
editorProvidersValue.ydoc = null;
|
||||
editorProvidersValue.providersReady = false;
|
||||
});
|
||||
|
||||
describe("TitleEditor fallback vs collaborative switch", () => {
|
||||
it("renders a static <h1> with the title before the shared doc is ready", () => {
|
||||
editorProvidersValue.ydoc = null;
|
||||
editorProvidersValue.providersReady = false;
|
||||
|
||||
render(<TitleEditor {...baseProps} />);
|
||||
|
||||
const heading = screen.getByRole("heading", { level: 1 });
|
||||
expect(heading.textContent).toBe("My Page Title");
|
||||
// The collaborative editor must NOT mount until the doc is ready.
|
||||
expect(screen.queryByTestId("collab-editor")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the collaborative editor once the shared doc is ready", () => {
|
||||
editorProvidersValue.ydoc = {}; // truthy shared doc
|
||||
editorProvidersValue.providersReady = true;
|
||||
|
||||
render(<TitleEditor {...baseProps} />);
|
||||
|
||||
expect(screen.getByTestId("collab-editor")).toBeDefined();
|
||||
// The static fallback <h1> is gone — Yjs is the single source of truth and
|
||||
// the prop is never seeded into the collaborative editor.
|
||||
expect(screen.queryByRole("heading", { level: 1 })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import "@/features/editor/styles/index.css";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { EditorContent, useEditor } from "@tiptap/react";
|
||||
import { Document } from "@tiptap/extension-document";
|
||||
import { Heading } from "@tiptap/extension-heading";
|
||||
@@ -11,14 +11,11 @@ import {
|
||||
pageEditorAtom,
|
||||
titleEditorAtom,
|
||||
} from "@/features/editor/atoms/editor-atoms";
|
||||
import {
|
||||
updatePageData,
|
||||
useUpdateTitlePageMutation,
|
||||
} from "@/features/page/queries/page-query";
|
||||
import { pageKeys, updatePageData } 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 { History } from "@tiptap/extension-history";
|
||||
import { Collaboration } from "@tiptap/extension-collaboration";
|
||||
import { shouldPropagateTitleChange } from "@/features/editor/title-collab";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -28,6 +25,9 @@ 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;
|
||||
@@ -45,65 +45,82 @@ 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);
|
||||
|
||||
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"),
|
||||
// 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);
|
||||
}
|
||||
},
|
||||
handleDOMEvents: {
|
||||
keydown: (_view, event) => {
|
||||
if (platformModifierKey(event) && event.code === "KeyS") {
|
||||
event.preventDefault();
|
||||
return true;
|
||||
}
|
||||
if (platformModifierKey(event) && event.code === "KeyK") {
|
||||
searchSpotlight.open();
|
||||
return true;
|
||||
}
|
||||
onUpdate({ editor, transaction }) {
|
||||
// Drive URL + tree propagation only on genuine local edits; skip
|
||||
// remote/collab-origin Yjs updates to avoid feedback loops.
|
||||
if (!shouldPropagateTitleChange(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;
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
[pageId, ydoc],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const anchorId = window.location.hash
|
||||
@@ -113,59 +130,45 @@ export function TitleEditor({
|
||||
navigate(pageSlug, { replace: true });
|
||||
}, [title]);
|
||||
|
||||
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);
|
||||
// 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,
|
||||
});
|
||||
}, [pageId, title, titleEditor]);
|
||||
|
||||
const debounceUpdate = useDebouncedCallback(saveTitle, 500);
|
||||
const page =
|
||||
queryClient.getQueryData<IPage>(pageKeys.detail(slugId)) ??
|
||||
queryClient.getQueryData<IPage>(pageKeys.detail(pageId));
|
||||
if (!page) return;
|
||||
|
||||
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]);
|
||||
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);
|
||||
// Drive the local (same-tab) tree/breadcrumb update. The cross-user tree
|
||||
// refresh is handled server-side: the collab process extracts the renamed
|
||||
// 'title' Yjs fragment and broadcasts a treeUpdate. The previous socket
|
||||
// `emit(event)` here was a no-op (the gateway ignores it) and was removed.
|
||||
localEmitter.emit("message", event);
|
||||
}, 500);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
@@ -175,13 +178,6 @@ export function TitleEditor({
|
||||
}, 300);
|
||||
}, [titleEditor]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// force-save title on navigation
|
||||
saveTitle();
|
||||
};
|
||||
}, [pageId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!titleEditor) return;
|
||||
titleEditor.setEditable(editable && currentPageEditMode === PageEditMode.Edit);
|
||||
@@ -248,16 +244,22 @@ export function TitleEditor({
|
||||
|
||||
return (
|
||||
<div className="page-title">
|
||||
<EditorContent
|
||||
editor={titleEditor}
|
||||
onKeyDown={(event) => {
|
||||
// First handle the search hotkey
|
||||
getHotkeyHandler([["mod+F", openSearchDialog]])(event);
|
||||
{titleReady ? (
|
||||
<EditorContent
|
||||
editor={titleEditor}
|
||||
onKeyDown={(event) => {
|
||||
// First handle the search hotkey
|
||||
getHotkeyHandler([["mod+F", openSearchDialog]])(event);
|
||||
|
||||
// Then handle other key events
|
||||
handleTitleKeyDown(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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { IconHourglass, IconPlus } from "@tabler/icons-react";
|
||||
import { ReactNode } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { onlineManager } from "@tanstack/react-query";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
|
||||
import { useCreatePageMutation } from "@/features/page/queries/page-query.ts";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
@@ -36,21 +38,39 @@ function CreateNoteButton({
|
||||
const createPageMutation = useCreatePageMutation();
|
||||
|
||||
const createNote = async (space: ISpace) => {
|
||||
// `spaceId`/`temporary` are accepted by the create-page endpoint but are
|
||||
// not part of the shared `IPageInput` type; cast to satisfy the mutation
|
||||
// signature.
|
||||
const variables = {
|
||||
spaceId: space.id,
|
||||
...(temporary ? { temporary: true } : {}),
|
||||
} as any;
|
||||
|
||||
if (!onlineManager.isOnline()) {
|
||||
// Offline: the create is PAUSED and queued — its promise will not resolve
|
||||
// until we are back online, so awaiting it here would spin the button
|
||||
// forever. Fire it without awaiting (it persists and replays on reconnect)
|
||||
// and tell the user it was saved offline instead of leaving a dead spinner.
|
||||
createPageMutation.mutate(variables);
|
||||
notifications.show({
|
||||
color: "blue",
|
||||
message: t("You're offline. This note will be created once you reconnect."),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// `spaceId`/`temporary` are accepted by the create-page endpoint but are
|
||||
// not part of the shared `IPageInput` type; cast to satisfy the mutation
|
||||
// signature.
|
||||
const createdPage = await createPageMutation.mutateAsync({
|
||||
spaceId: space.id,
|
||||
...(temporary ? { temporary: true } : {}),
|
||||
} as any);
|
||||
const createdPage = await createPageMutation.mutateAsync(variables);
|
||||
navigate(buildPageUrl(space.slug, createdPage.slugId, createdPage.title));
|
||||
} catch {
|
||||
// useCreatePageMutation already surfaces a red notification on error.
|
||||
}
|
||||
};
|
||||
|
||||
const isPending = createPageMutation.isPending;
|
||||
// A paused (offline) mutation stays `isPending`, so gate the spinner on it NOT
|
||||
// being paused — otherwise the button would spin forever after an offline
|
||||
// create. The offline path above gives its own "saved offline" feedback.
|
||||
const isPending = createPageMutation.isPending && !createPageMutation.isPaused;
|
||||
|
||||
// Exactly one writable space → create directly, no picker needed.
|
||||
if (writableSpaces.length === 1) {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import i18n from "@/i18n.ts";
|
||||
import {
|
||||
formatRelativeTime,
|
||||
getTimeGroup,
|
||||
groupNotificationsByTime,
|
||||
} from "@/features/notification/notification.utils.ts";
|
||||
@@ -134,59 +132,3 @@ describe("groupNotificationsByTime", () => {
|
||||
expect(groupNotificationsByTime([], labels)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatRelativeTime — relative buckets and absolute-date fallback", () => {
|
||||
// Distinct fixed clock for the relative formatter (uses Date.now via `new
|
||||
// Date()`), so the bucket boundaries are deterministic under fake timers.
|
||||
const NOW = new Date("2026-06-15T12:00:00.000Z");
|
||||
const MIN = 60_000;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.setSystemTime(NOW);
|
||||
});
|
||||
|
||||
// ISO string `ms` milliseconds before NOW.
|
||||
function ago(ms: number): string {
|
||||
return new Date(NOW.getTime() - ms).toISOString();
|
||||
}
|
||||
|
||||
it("returns the i18n 'now' label for anything under a minute", () => {
|
||||
expect(formatRelativeTime(ago(0))).toBe(i18n.t("now"));
|
||||
expect(formatRelativeTime(ago(59_000))).toBe(i18n.t("now"));
|
||||
});
|
||||
|
||||
it("crosses into the minutes bucket exactly at 1 minute", () => {
|
||||
expect(formatRelativeTime(ago(MIN - 1000))).toBe(i18n.t("now"));
|
||||
expect(formatRelativeTime(ago(MIN))).toBe("1m");
|
||||
expect(formatRelativeTime(ago(5 * MIN))).toBe("5m");
|
||||
expect(formatRelativeTime(ago(59 * MIN))).toBe("59m");
|
||||
});
|
||||
|
||||
it("crosses into the hours bucket exactly at 60 minutes", () => {
|
||||
expect(formatRelativeTime(ago(60 * MIN - 1000))).toBe("59m");
|
||||
expect(formatRelativeTime(ago(HOUR))).toBe("1h");
|
||||
expect(formatRelativeTime(ago(23 * HOUR))).toBe("23h");
|
||||
});
|
||||
|
||||
it("crosses into the days bucket exactly at 24 hours", () => {
|
||||
expect(formatRelativeTime(ago(24 * HOUR - 1000))).toBe("23h");
|
||||
expect(formatRelativeTime(ago(DAY))).toBe("1d");
|
||||
expect(formatRelativeTime(ago(6 * DAY))).toBe("6d");
|
||||
});
|
||||
|
||||
it("falls back to an absolute short date once >= 7 days old", () => {
|
||||
// 6d -> still relative; 7d -> absolute date (no longer N[mhd], and equal to
|
||||
// the localized short-date of the source timestamp).
|
||||
expect(formatRelativeTime(ago(6 * DAY))).toBe("6d");
|
||||
|
||||
const sevenDaysAgo = ago(7 * DAY);
|
||||
const result = formatRelativeTime(sevenDaysAgo);
|
||||
expect(result).not.toMatch(/^\d+[mhd]$/);
|
||||
expect(result).not.toBe(i18n.t("now"));
|
||||
const expected = new Intl.DateTimeFormat(i18n.language, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}).format(new Date(sevenDaysAgo));
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
107
apps/client/src/features/offline/clear-offline-cache.test.ts
Normal file
107
apps/client/src/features/offline/clear-offline-cache.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
112
apps/client/src/features/offline/clear-offline-cache.ts
Normal file
112
apps/client/src/features/offline/clear-offline-cache.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { del } from "idb-keyval";
|
||||
|
||||
import { queryClient } from "@/main.tsx";
|
||||
import {
|
||||
OFFLINE_CACHE_KEY,
|
||||
freezeOfflinePersistence,
|
||||
unfreezeOfflinePersistence,
|
||||
} from "./query-persister";
|
||||
import { PAGE_YDOC_NAME_PREFIX } from "@/features/editor/page-ydoc-name";
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
// Freeze the throttled persister BEFORE touching the cache so the
|
||||
// queryClient.clear() below cannot trigger a late re-write of the (still
|
||||
// nearly-full) dehydrated snapshot after we del() the key — which would
|
||||
// otherwise resurrect the previous user's persisted data in IndexedDB.
|
||||
// Re-enabled in `finally` so the next (sign-in) session persists normally.
|
||||
freezeOfflinePersistence();
|
||||
|
||||
try {
|
||||
// 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_YDOC_NAME_PREFIX))
|
||||
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
|
||||
}
|
||||
} finally {
|
||||
// Re-enable persistence for the next session (sign-in continues running in
|
||||
// the same tab; logout reloads via window.location.replace, so this is a
|
||||
// harmless no-op there).
|
||||
unfreezeOfflinePersistence();
|
||||
}
|
||||
}
|
||||
475
apps/client/src/features/offline/make-offline.test.ts
Normal file
475
apps/client/src/features/offline/make-offline.test.ts
Normal file
@@ -0,0 +1,475 @@
|
||||
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 and reports the truncation (returns false)", 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" } });
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
// Hitting maxPages with a cursor still pending is a truncated warm: the
|
||||
// (partial) cache is still written, but the result is reported as false.
|
||||
await expect(
|
||||
warmInfiniteAll(["comments", "p1"], fetchPage, 2),
|
||||
).resolves.toBe(false);
|
||||
|
||||
expect(fetchPage).toHaveBeenCalledTimes(2);
|
||||
const payload = setQueryData.mock.calls[0][1];
|
||||
expect(payload.pages).toHaveLength(2);
|
||||
expect(errorSpy).toHaveBeenCalled();
|
||||
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
// Helper: the page-ids passed to the sidebar-children warm (its query key is
|
||||
// ["sidebar-pages", { pageId, spaceId }]) — i.e. which nodes were prefetched.
|
||||
const warmedSidebarIds = () =>
|
||||
prefetchQuery.mock.calls
|
||||
.map((c) => c[0])
|
||||
.filter((opts: any) => opts?.queryKey?.[0] === "sidebar-pages")
|
||||
.map((opts: any) => opts.queryKey[1]?.pageId);
|
||||
|
||||
it("warms the page + every ancestor's children once and skips the self-ancestor guard", async () => {
|
||||
(getPageById as ReturnType<typeof vi.fn>).mockResolvedValue(okPage);
|
||||
// Breadcrumbs include two real ancestors, the page's OWN id (must be skipped
|
||||
// by the ancestorId === pageId guard so it is not warmed twice), and a
|
||||
// malformed entry with no id (also skipped).
|
||||
(getPageBreadcrumbs as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{ id: "anc-1" },
|
||||
{ id: "uuid-1" }, // === pageId -> guard
|
||||
{ id: "anc-2" },
|
||||
{}, // no id -> skipped
|
||||
]);
|
||||
(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",
|
||||
});
|
||||
|
||||
const ids = warmedSidebarIds();
|
||||
// The page's own children (warmSidebarChildren(pageId)) plus each real
|
||||
// ancestor — exactly once each. The self-ancestor (uuid-1 in breadcrumbs) is
|
||||
// NOT a second warm: uuid-1 appears once (from the page's own children call).
|
||||
expect(ids).toEqual(["uuid-1", "anc-1", "anc-2"]);
|
||||
expect(ids.filter((id: string) => id === "uuid-1")).toHaveLength(1);
|
||||
expect(result).toEqual({ ok: true, failed: [] });
|
||||
});
|
||||
|
||||
it("dedupes repeated tree failures into a single 'tree' label", async () => {
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
(getPageById as ReturnType<typeof vi.fn>).mockResolvedValue(okPage);
|
||||
(getPageBreadcrumbs as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{ id: "anc-1" },
|
||||
{ id: "anc-2" },
|
||||
]);
|
||||
(getSidebarPages as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
items: [],
|
||||
meta: { nextCursor: null },
|
||||
});
|
||||
(getPageComments as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
items: [],
|
||||
meta: { nextCursor: null },
|
||||
});
|
||||
// Fail ONLY the sidebar-children prefetches (page-own + both ancestors = 3
|
||||
// failures); the currentUser/space prefetches still resolve.
|
||||
prefetchQuery.mockImplementation(async (opts: any) => {
|
||||
if (opts?.queryKey?.[0] === "sidebar-pages") throw new Error("network");
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const result = await makePageAvailableOffline({
|
||||
pageId: "uuid-1",
|
||||
spaceId: "space-uuid",
|
||||
});
|
||||
|
||||
// Three node warms failed but the contract collapses them to one "tree".
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.failed).toEqual(["tree"]);
|
||||
expect(errorSpy).toHaveBeenCalled();
|
||||
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("records 'breadcrumbs' (not 'tree') when the breadcrumbs lookup rejects", async () => {
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
(getPageById as ReturnType<typeof vi.fn>).mockResolvedValue(okPage);
|
||||
// Ancestor discovery fails -> the ancestor-walk is recorded as "breadcrumbs".
|
||||
(getPageBreadcrumbs as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
new Error("network"),
|
||||
);
|
||||
(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",
|
||||
});
|
||||
|
||||
// The page's own children still warmed fine (prefetch resolves), so the only
|
||||
// failure is the breadcrumbs lookup.
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.failed).toEqual(["breadcrumbs"]);
|
||||
expect(errorSpy).toHaveBeenCalled();
|
||||
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("records 'page' when the central document fetch (getPageById) rejects", async () => {
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
// The central page document fetch fails (the most realistic failure).
|
||||
(getPageById as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
new Error("network"),
|
||||
);
|
||||
(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",
|
||||
});
|
||||
|
||||
// With no page document, the space step is skipped (no slug), so the only
|
||||
// failure label is "page".
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.failed).toContain("page");
|
||||
expect(result.failed).not.toContain("space");
|
||||
expect(errorSpy).toHaveBeenCalled();
|
||||
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("records 'space' when ONLY the space prefetch rejects", 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 },
|
||||
});
|
||||
(getPageComments as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
items: [],
|
||||
meta: { nextCursor: null },
|
||||
});
|
||||
// Fail ONLY the space prefetch (queryKey ["space", slug]); the currentUser
|
||||
// and sidebar-children prefetches still resolve.
|
||||
prefetchQuery.mockImplementation(async (opts: any) => {
|
||||
if (opts?.queryKey?.[0] === "space") throw new Error("network");
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const result = await makePageAvailableOffline({
|
||||
pageId: "uuid-1",
|
||||
spaceId: "space-uuid",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.failed).toContain("space");
|
||||
expect(errorSpy).toHaveBeenCalled();
|
||||
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("records 'currentUser' when ONLY the currentUser prefetch rejects", 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 },
|
||||
});
|
||||
(getPageComments as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
items: [],
|
||||
meta: { nextCursor: null },
|
||||
});
|
||||
// Fail ONLY the currentUser prefetch (queryKey ["currentUser"]); the space
|
||||
// and sidebar-children prefetches still resolve.
|
||||
prefetchQuery.mockImplementation(async (opts: any) => {
|
||||
if (opts?.queryKey?.[0] === "currentUser") throw new Error("network");
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const result = await makePageAvailableOffline({
|
||||
pageId: "uuid-1",
|
||||
spaceId: "space-uuid",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.failed).toContain("currentUser");
|
||||
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();
|
||||
// Returns true because the real "synced" event fired.
|
||||
await expect(promise).resolves.toBe(true);
|
||||
|
||||
// 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 false and cleans up after the timeout when synced never fires", async () => {
|
||||
vi.useFakeTimers();
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const promise = warmPageYdoc("p1", "ws://x");
|
||||
|
||||
// Do not fire "synced"; let the 8s safety timeout settle it.
|
||||
await vi.advanceTimersByTimeAsync(8000);
|
||||
// Returns false (the doc never synced) and logs the timeout with the pageId.
|
||||
await expect(promise).resolves.toBe(false);
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
"warmPageYdoc: timed out before sync",
|
||||
{ pageId: "p1" },
|
||||
);
|
||||
|
||||
expect(h.providerDestroy).toHaveBeenCalledTimes(1);
|
||||
expect(h.idbDestroy).toHaveBeenCalledTimes(1);
|
||||
expect(h.ydocDestroy).toHaveBeenCalledTimes(1);
|
||||
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
337
apps/client/src/features/offline/make-offline.ts
Normal file
337
apps/client/src/features/offline/make-offline.ts
Normal file
@@ -0,0 +1,337 @@
|
||||
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 { getMyInfo } from "@/features/user/services/user-service";
|
||||
import { userKeys } from "@/features/user/hooks/use-current-user";
|
||||
import { IPage } from "@/features/page/types/page.types";
|
||||
import { IPagination } from "@/lib/types.ts";
|
||||
import { pageYdocName } from "@/features/editor/page-ydoc-name";
|
||||
|
||||
/**
|
||||
* 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 ONLY if the cursor chain was fully exhausted and written. If the
|
||||
* walk stops because it hit `maxPages` while a `nextCursor` is still pending,
|
||||
* the cached list is truncated AND its last page keeps a nextCursor that cannot
|
||||
* be re-fetched offline (hooks that gate on hasNextPage would spin forever), so
|
||||
* that case is logged and returns false too — the caller records it as a failed
|
||||
* warm instead of a silent truncated success. The (partial) cache is still
|
||||
* written so what we did fetch is usable.
|
||||
*
|
||||
* 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;
|
||||
let exhausted = false;
|
||||
|
||||
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) {
|
||||
exhausted = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
queryClient.setQueryData(queryKey, { pages, pageParams });
|
||||
|
||||
if (!exhausted) {
|
||||
// Stopped at maxPages with a cursor still pending: the list is truncated
|
||||
// and the last cached page's nextCursor is un-fetchable offline. Report it
|
||||
// as a failed warm rather than a silent truncated success.
|
||||
console.error("warmInfiniteAll truncated at maxPages", {
|
||||
queryKey,
|
||||
maxPages,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
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: "currentUser", "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[] = [];
|
||||
|
||||
// Warm the current user (['currentUser']) so the auth-gated <Layout> can
|
||||
// hydrate offline. UserProvider blanks the whole app while useCurrentUser has
|
||||
// no data, and the offline POST /api/users/me fails as a network error, so
|
||||
// without a persisted user a pinned page still white-screens after relaunch
|
||||
// (#238). Persisted via OFFLINE_PERSIST_ROOTS; warmed here so the persisted
|
||||
// cache actually has an entry to restore.
|
||||
try {
|
||||
await queryClient.prefetchQuery({
|
||||
queryKey: userKeys.currentUser(),
|
||||
queryFn: () => getMyInfo(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("makePageAvailableOffline: currentUser step failed", {
|
||||
pageId,
|
||||
error,
|
||||
});
|
||||
failed.push("currentUser");
|
||||
}
|
||||
|
||||
// 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.
|
||||
*
|
||||
* Returns true ONLY when the provider's real "synced" event fired — i.e. the
|
||||
* server state actually landed in IndexedDB. The timeout and failure paths
|
||||
* return false (and log with the pageId) so the caller does not report a page
|
||||
* as offline-available when its editor body never warmed. For a wiki the editor
|
||||
* body IS the page, so a silent timeout here is a real misreport.
|
||||
*
|
||||
* 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<boolean> {
|
||||
let ydoc: Y.Doc | null = null;
|
||||
let local: IndexeddbPersistence | null = null;
|
||||
let remote: HocuspocusProvider | null = null;
|
||||
// Flipped to true ONLY inside the real "synced" handler; the timeout/failure
|
||||
// paths leave it false. Returned so the caller can record a failed editor warm.
|
||||
let didSync = false;
|
||||
|
||||
try {
|
||||
const documentName = pageYdocName(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;
|
||||
// `synced` is true only when called from the real "synced" handler; the
|
||||
// timeout path passes false so didSync stays false on a give-up.
|
||||
const finish = (synced: boolean) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
didSync = synced;
|
||||
// Clear the pending timeout and detach the listener so neither leaks
|
||||
// after we resolve.
|
||||
if (timeoutId !== undefined) clearTimeout(timeoutId);
|
||||
try {
|
||||
provider.off("synced", onSynced);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
if (!synced) {
|
||||
// Gave up before the server synced: the page body never landed in
|
||||
// IndexedDB. Log with the pageId (parity with the other warm steps)
|
||||
// so the caller can report the editor step as failed.
|
||||
console.error("warmPageYdoc: timed out before sync", { pageId });
|
||||
}
|
||||
resolve();
|
||||
};
|
||||
|
||||
const onSynced = () => finish(true);
|
||||
|
||||
// Resolve once the server state has synced into the local doc...
|
||||
provider.on("synced", onSynced);
|
||||
// ...or give up after a short timeout so we never hang.
|
||||
timeoutId = setTimeout(() => finish(false), 8000);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("warmPageYdoc: warm failed", { pageId, error });
|
||||
} finally {
|
||||
try {
|
||||
remote?.destroy();
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
try {
|
||||
local?.destroy();
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
try {
|
||||
ydoc?.destroy();
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
|
||||
return didSync;
|
||||
}
|
||||
45
apps/client/src/features/offline/offline-fallback.tsx
Normal file
45
apps/client/src/features/offline/offline-fallback.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Button, Container, Group, Stack, Text, Title } from "@mantine/core";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getAppName } from "@/lib/config";
|
||||
|
||||
/**
|
||||
* Shown when the authenticated app shell cannot hydrate because the current
|
||||
* user is unavailable AND there is no cached user to fall back on (e.g. an
|
||||
* offline cold boot of a page that was never warmed for offline).
|
||||
*
|
||||
* Previously UserProvider returned a bare `<></>` in this situation, which
|
||||
* white-screened the whole app on any offline reload (#237/#238). Rendering an
|
||||
* explicit "you're offline" state with a retry instead gives the user a clear,
|
||||
* non-blank fallback and a way to recover once the network returns.
|
||||
*/
|
||||
export function OfflineFallback() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>
|
||||
{t("You're offline")} - {getAppName()}
|
||||
</title>
|
||||
</Helmet>
|
||||
<Container size="sm" py={80}>
|
||||
<Stack align="center" gap="md">
|
||||
<Title order={2} ta="center">
|
||||
{t("You're offline")}
|
||||
</Title>
|
||||
<Text c="dimmed" size="lg" ta="center">
|
||||
{t(
|
||||
"This page hasn't been saved for offline use, so it can't be loaded right now. Reconnect to the internet and try again.",
|
||||
)}
|
||||
</Text>
|
||||
<Group justify="center">
|
||||
<Button onClick={() => window.location.reload()} variant="subtle">
|
||||
{t("Retry")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
}
|
||||
114
apps/client/src/features/offline/offline-mutations.test.ts
Normal file
114
apps/client/src/features/offline/offline-mutations.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { QueryClient, hydrate, dehydrate } from "@tanstack/react-query";
|
||||
|
||||
// Stub the network services so a replayed mutation hits a spy, not the network.
|
||||
const h = vi.hoisted(() => ({
|
||||
createPage: vi.fn(),
|
||||
movePage: vi.fn(),
|
||||
createComment: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/page/services/page-service", () => ({
|
||||
createPage: h.createPage,
|
||||
movePage: h.movePage,
|
||||
}));
|
||||
vi.mock("@/features/comment/services/comment-service", () => ({
|
||||
createComment: h.createComment,
|
||||
}));
|
||||
// page-query pulls in the app entry (queryClient) and a lot of UI deps via its
|
||||
// cache helpers; we only need invalidateOnCreatePage to be a no-op here.
|
||||
vi.mock("@/features/page/queries/page-query", () => ({
|
||||
invalidateOnCreatePage: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
offlineMutationKeys,
|
||||
registerOfflineMutationDefaults,
|
||||
} from "./offline-mutations";
|
||||
|
||||
beforeEach(() => {
|
||||
h.createPage.mockReset().mockResolvedValue({ id: "new-page" });
|
||||
h.movePage.mockReset().mockResolvedValue(undefined);
|
||||
h.createComment.mockReset().mockResolvedValue({ id: "new-comment" });
|
||||
});
|
||||
|
||||
describe("registerOfflineMutationDefaults", () => {
|
||||
it("registers a default mutationFn for every offline mutation key", () => {
|
||||
const qc = new QueryClient();
|
||||
registerOfflineMutationDefaults(qc);
|
||||
|
||||
for (const key of Object.values(offlineMutationKeys)) {
|
||||
const defaults = qc.getMutationDefaults(key);
|
||||
expect(typeof defaults?.mutationFn).toBe("function");
|
||||
}
|
||||
});
|
||||
|
||||
// The headline durability guarantee: a paused mutation dehydrated into
|
||||
// IndexedDB while offline must, after a reload, have a mutationFn so
|
||||
// resumePausedMutations() actually replays the write on reconnect.
|
||||
it("makes a rehydrated paused create replayable by resumePausedMutations", async () => {
|
||||
// 1) Simulate the offline tab: a paused create mutation gets dehydrated.
|
||||
const offlineClient = new QueryClient();
|
||||
const observer = offlineClient.getMutationCache().build(offlineClient, {
|
||||
mutationKey: offlineMutationKeys.createPage,
|
||||
});
|
||||
// Force the dehydrate-worthy paused state (offline = isPaused) with the
|
||||
// payload the user submitted before losing connectivity.
|
||||
observer.state.isPaused = true;
|
||||
observer.state.status = "pending";
|
||||
observer.state.variables = { spaceId: "s1", title: "Offline page" };
|
||||
|
||||
const dehydrated = dehydrate(offlineClient, {
|
||||
shouldDehydrateMutation: () => true,
|
||||
});
|
||||
expect(dehydrated.mutations).toHaveLength(1);
|
||||
// The dehydrated mutation carries NO mutationFn (functions aren't
|
||||
// serializable) — only its key + variables survive the reload.
|
||||
expect((dehydrated.mutations[0] as any).mutationFn).toBeUndefined();
|
||||
|
||||
// 2) Simulate the fresh page after reload: register defaults, then hydrate
|
||||
// the persisted paused mutation back in.
|
||||
const freshClient = new QueryClient();
|
||||
registerOfflineMutationDefaults(freshClient);
|
||||
hydrate(freshClient, dehydrated);
|
||||
|
||||
expect(freshClient.getMutationCache().getAll()).toHaveLength(1);
|
||||
|
||||
// 3) Reconnect: replay the paused mutations.
|
||||
await freshClient.resumePausedMutations();
|
||||
|
||||
// The default mutationFn ran with the persisted variables — the write is
|
||||
// NOT silently dropped.
|
||||
expect(h.createPage).toHaveBeenCalledTimes(1);
|
||||
expect(h.createPage).toHaveBeenCalledWith({
|
||||
spaceId: "s1",
|
||||
title: "Offline page",
|
||||
});
|
||||
});
|
||||
|
||||
it("makes a rehydrated paused move replayable by resumePausedMutations", async () => {
|
||||
const offlineClient = new QueryClient();
|
||||
const observer = offlineClient.getMutationCache().build(offlineClient, {
|
||||
mutationKey: offlineMutationKeys.movePage,
|
||||
});
|
||||
observer.state.isPaused = true;
|
||||
observer.state.status = "pending";
|
||||
observer.state.variables = { pageId: "p1", parentPageId: null, position: "a" };
|
||||
|
||||
const dehydrated = dehydrate(offlineClient, {
|
||||
shouldDehydrateMutation: () => true,
|
||||
});
|
||||
|
||||
const freshClient = new QueryClient();
|
||||
registerOfflineMutationDefaults(freshClient);
|
||||
hydrate(freshClient, dehydrated);
|
||||
await freshClient.resumePausedMutations();
|
||||
|
||||
expect(h.movePage).toHaveBeenCalledTimes(1);
|
||||
expect(h.movePage).toHaveBeenCalledWith({
|
||||
pageId: "p1",
|
||||
parentPageId: null,
|
||||
position: "a",
|
||||
});
|
||||
});
|
||||
});
|
||||
64
apps/client/src/features/offline/offline-mutations.ts
Normal file
64
apps/client/src/features/offline/offline-mutations.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { createPage, movePage } from "@/features/page/services/page-service";
|
||||
import { createComment } from "@/features/comment/services/comment-service";
|
||||
import { invalidateOnCreatePage } from "@/features/page/queries/page-query";
|
||||
import type {
|
||||
IMovePage,
|
||||
IPage,
|
||||
IPageInput,
|
||||
} from "@/features/page/types/page.types";
|
||||
import type { IComment } from "@/features/comment/types/comment.types";
|
||||
|
||||
/**
|
||||
* Stable mutation keys for the offline-relevant structural mutations.
|
||||
*
|
||||
* When the browser goes offline, React Query PAUSES these mutations and the
|
||||
* PersistQueryClientProvider dehydrates the paused mutation into IndexedDB. On a
|
||||
* reload-while-offline the mutation is restored, but a restored mutation has NO
|
||||
* observer (no component is mounted) — so its replay relies entirely on the
|
||||
* `mutationFn` registered via `setMutationDefaults` for its `mutationKey`.
|
||||
* Without that, `resumePausedMutations()` finds a paused mutation with no
|
||||
* `mutationFn` and silently no-ops, dropping the offline create/move/comment
|
||||
* (#237/#238). Each offline mutation hook tags itself with the matching key so
|
||||
* the rehydrated paused mutation can find its default `mutationFn` and replay.
|
||||
*/
|
||||
export const offlineMutationKeys = {
|
||||
createPage: ["create-page"] as const,
|
||||
movePage: ["move-page"] as const,
|
||||
createComment: ["create-comment"] as const,
|
||||
};
|
||||
|
||||
/**
|
||||
* Register default `mutationFn`s (and the minimal success side effects safe to
|
||||
* run without a mounted component) for the offline-relevant mutation keys, so a
|
||||
* paused mutation restored from IndexedDB after an offline reload is replayable
|
||||
* by `resumePausedMutations()` on reconnect.
|
||||
*
|
||||
* Called once when the QueryClient is created (see main.tsx). The hooks still
|
||||
* carry their own inline `mutationFn`/`onSuccess` for the live in-session path;
|
||||
* these defaults only take over for a rehydrated paused mutation that lost its
|
||||
* observer across the reload.
|
||||
*/
|
||||
export function registerOfflineMutationDefaults(queryClient: QueryClient): void {
|
||||
queryClient.setMutationDefaults(offlineMutationKeys.createPage, {
|
||||
mutationFn: (data: Partial<IPageInput>) => createPage(data),
|
||||
// Re-converge the sidebar tree / recent-changes from the authoritative
|
||||
// create response. Pure cache writes — safe with no component mounted.
|
||||
onSuccess: (data: IPage) => {
|
||||
invalidateOnCreatePage(data);
|
||||
},
|
||||
});
|
||||
|
||||
queryClient.setMutationDefaults(offlineMutationKeys.movePage, {
|
||||
// Replay the server-side move. The tree re-converges from the next online
|
||||
// sidebar fetch / websocket `moveTreeNode` echo, so no cache write is
|
||||
// needed here (the optimistic tree state was local-only anyway).
|
||||
mutationFn: (data: IMovePage) => movePage(data),
|
||||
});
|
||||
|
||||
queryClient.setMutationDefaults(offlineMutationKeys.createComment, {
|
||||
// Replay the server-side comment create. The comments list refetches on the
|
||||
// online reload, so the replay only needs to persist the write.
|
||||
mutationFn: (data: Partial<IComment>) => createComment(data),
|
||||
});
|
||||
}
|
||||
128
apps/client/src/features/offline/offline-resume.test.ts
Normal file
128
apps/client/src/features/offline/offline-resume.test.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { QueryClient, onlineManager } from "@tanstack/react-query";
|
||||
import {
|
||||
persistQueryClientRestore,
|
||||
persistQueryClientSave,
|
||||
} from "@tanstack/react-query-persist-client";
|
||||
|
||||
// Stub the network services so a replayed mutation hits a spy, not the network.
|
||||
const h = vi.hoisted(() => ({
|
||||
createPage: vi.fn(),
|
||||
movePage: vi.fn(),
|
||||
createComment: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/page/services/page-service", () => ({
|
||||
createPage: h.createPage,
|
||||
movePage: h.movePage,
|
||||
}));
|
||||
vi.mock("@/features/comment/services/comment-service", () => ({
|
||||
createComment: h.createComment,
|
||||
}));
|
||||
vi.mock("@/features/page/queries/page-query", () => ({
|
||||
invalidateOnCreatePage: vi.fn(),
|
||||
}));
|
||||
|
||||
// In-memory idb-keyval so the REAL queryPersister round-trips through a fake
|
||||
// store (the actual persist -> reload -> restore path, not a hand-built blob).
|
||||
const store = new Map<string, string>();
|
||||
vi.mock("idb-keyval", () => ({
|
||||
get: vi.fn((k: string) => Promise.resolve(store.get(k) ?? undefined)),
|
||||
set: vi.fn((k: string, v: string) => {
|
||||
store.set(k, v);
|
||||
return Promise.resolve();
|
||||
}),
|
||||
del: vi.fn((k: string) => {
|
||||
store.delete(k);
|
||||
return Promise.resolve();
|
||||
}),
|
||||
}));
|
||||
|
||||
import { queryPersister } from "./query-persister";
|
||||
import {
|
||||
offlineMutationKeys,
|
||||
registerOfflineMutationDefaults,
|
||||
} from "./offline-mutations";
|
||||
|
||||
const BUSTER = "test-buster";
|
||||
|
||||
beforeEach(() => {
|
||||
store.clear();
|
||||
h.createPage.mockReset().mockResolvedValue({ id: "new-page" });
|
||||
h.movePage.mockReset().mockResolvedValue(undefined);
|
||||
h.createComment.mockReset().mockResolvedValue({ id: "new-comment" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// onlineManager is a global singleton; leave it in the default online state.
|
||||
onlineManager.setOnline(true);
|
||||
});
|
||||
|
||||
describe("offline paused-mutation resume across a reload", () => {
|
||||
// This is the #120 silent-data-loss reproduction: a paused mutation persisted
|
||||
// to IndexedDB while offline, then the tab RELOADS while still offline, must
|
||||
// resume on reconnect. It exercises the real persister round-trip plus the two
|
||||
// boot-time fixes the app wiring relies on:
|
||||
// (a) onlineManager seeded to the real offline state so the later reconnect
|
||||
// is a true offline->online transition that auto-resumes, and
|
||||
// (b) resumePausedMutations() called after the persister restores (what the
|
||||
// PersistQueryClientProvider onSuccess does), with mutation defaults
|
||||
// registered BEFORE the resume so the rehydrated mutation has a fn.
|
||||
it("replays a rehydrated paused create on reconnect (mutationFn fires)", async () => {
|
||||
// --- Tab 1, OFFLINE: user creates a page; it pauses and gets persisted. ---
|
||||
onlineManager.setOnline(false); // (a) boot seeded offline
|
||||
|
||||
const client1 = new QueryClient();
|
||||
registerOfflineMutationDefaults(client1);
|
||||
const observer = client1.getMutationCache().build(client1, {
|
||||
mutationKey: offlineMutationKeys.createPage,
|
||||
});
|
||||
observer.state.isPaused = true;
|
||||
observer.state.status = "pending";
|
||||
observer.state.variables = { spaceId: "s1", title: "Offline page" };
|
||||
|
||||
await persistQueryClientSave({
|
||||
// Cast: persist-client-core and react-query may resolve to different
|
||||
// @tanstack/query-core copies whose QueryClient brands are nominally
|
||||
// incompatible (see query-persister.ts). Structurally identical at runtime.
|
||||
queryClient: client1 as any,
|
||||
persister: queryPersister,
|
||||
buster: BUSTER,
|
||||
dehydrateOptions: { shouldDehydrateMutation: () => true },
|
||||
});
|
||||
// The paused mutation is now in the persisted store.
|
||||
expect(store.size).toBe(1);
|
||||
|
||||
// --- RELOAD while still offline: fresh client restores from the SAME
|
||||
// persister. Defaults are registered BEFORE restore/resume. ---
|
||||
const client2 = new QueryClient();
|
||||
registerOfflineMutationDefaults(client2);
|
||||
client2.mount(); // subscribes to onlineManager (auto-resume on reconnect)
|
||||
|
||||
await persistQueryClientRestore({
|
||||
queryClient: client2 as any,
|
||||
persister: queryPersister,
|
||||
buster: BUSTER,
|
||||
});
|
||||
expect(client2.getMutationCache().getAll()).toHaveLength(1);
|
||||
|
||||
// (b) onSuccess wiring resumes after restore — but we are still OFFLINE, so
|
||||
// the mutation must stay paused and NOT fire yet.
|
||||
await client2.resumePausedMutations();
|
||||
expect(h.createPage).not.toHaveBeenCalled();
|
||||
|
||||
// --- RECONNECT: the offline->online transition auto-resumes the paused
|
||||
// mutation and its registered default mutationFn finally fires. ---
|
||||
onlineManager.setOnline(true);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(h.createPage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(h.createPage).toHaveBeenCalledWith({
|
||||
spaceId: "s1",
|
||||
title: "Offline page",
|
||||
});
|
||||
|
||||
client2.unmount();
|
||||
});
|
||||
});
|
||||
48
apps/client/src/features/offline/persist-roots.guard.test.ts
Normal file
48
apps/client/src/features/offline/persist-roots.guard.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
// The query modules transitively import the app entry (@/main.tsx) for the
|
||||
// shared queryClient; mock it so importing the key factories has no side effects.
|
||||
import { vi } from "vitest";
|
||||
vi.mock("@/main.tsx", () => ({
|
||||
queryClient: { setQueryData: vi.fn(), getQueryData: vi.fn() },
|
||||
}));
|
||||
|
||||
import { OFFLINE_PERSIST_ROOTS } from "./query-persister";
|
||||
import { pageKeys } from "@/features/page/queries/page-query";
|
||||
import { spaceKeys } from "@/features/space/queries/space-query";
|
||||
import { RQ_KEY } from "@/features/comment/queries/comment-query";
|
||||
import { userKeys } from "@/features/user/hooks/use-current-user";
|
||||
|
||||
/**
|
||||
* Architecture guard (#13): every string persisted via OFFLINE_PERSIST_ROOTS
|
||||
* must be the ROOT (queryKey[0]) of some exported query-key factory. If a
|
||||
* factory's root is renamed without updating the persist registry — or vice
|
||||
* versa — offline persist/warm silently breaks (persisted keys never match the
|
||||
* live queries). This turns that silent regression into a red build.
|
||||
*
|
||||
* Each factory is invoked with throwaway args; only queryKey[0] is inspected.
|
||||
*/
|
||||
function rootOf(key: readonly unknown[]): string {
|
||||
return String(key[0]);
|
||||
}
|
||||
|
||||
const FACTORY_ROOTS = new Set<string>([
|
||||
rootOf(pageKeys.detail("x")),
|
||||
rootOf(pageKeys.sidebar({})),
|
||||
rootOf(pageKeys.rootSidebar("x")),
|
||||
rootOf(pageKeys.breadcrumbs("x")),
|
||||
rootOf(pageKeys.recentChanges("x")),
|
||||
rootOf(spaceKeys.detail("x")),
|
||||
rootOf(spaceKeys.list()),
|
||||
rootOf(RQ_KEY("x")),
|
||||
rootOf(userKeys.currentUser()),
|
||||
]);
|
||||
|
||||
describe("OFFLINE_PERSIST_ROOTS is backed by real query-key factories", () => {
|
||||
it("maps every persisted root to an exported factory root", () => {
|
||||
const unbacked = [...OFFLINE_PERSIST_ROOTS].filter(
|
||||
(root) => !FACTORY_ROOTS.has(root),
|
||||
);
|
||||
expect(unbacked).toEqual([]);
|
||||
});
|
||||
});
|
||||
128
apps/client/src/features/offline/query-persister.test.ts
Normal file
128
apps/client/src/features/offline/query-persister.test.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
|
||||
// In-memory idb-keyval so we can observe whether the persister actually writes.
|
||||
const h = vi.hoisted(() => ({
|
||||
get: vi.fn(() => Promise.resolve(undefined)),
|
||||
set: vi.fn(() => Promise.resolve()),
|
||||
del: vi.fn(() => Promise.resolve()),
|
||||
}));
|
||||
vi.mock("idb-keyval", () => h);
|
||||
|
||||
import {
|
||||
shouldDehydrateOfflineQuery,
|
||||
OFFLINE_PERSIST_ROOTS,
|
||||
queryPersister,
|
||||
freezeOfflinePersistence,
|
||||
unfreezeOfflinePersistence,
|
||||
} 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);
|
||||
// currentUser is persisted so the auth-gated Layout can hydrate offline.
|
||||
expect(
|
||||
shouldDehydrateOfflineQuery(makeQuery("success", ["currentUser"])),
|
||||
).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 9 navigation/read roots", () => {
|
||||
const expected = [
|
||||
"pages",
|
||||
"sidebar-pages",
|
||||
"root-sidebar-pages",
|
||||
"breadcrumbs",
|
||||
"comments",
|
||||
"space",
|
||||
"spaces",
|
||||
"recent-changes",
|
||||
"currentUser",
|
||||
];
|
||||
expect(OFFLINE_PERSIST_ROOTS.size).toBe(9);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
describe("freeze/unfreeze persistence (logout no-late-write guard)", () => {
|
||||
const dummyClient = {
|
||||
timestamp: Date.now(),
|
||||
buster: "",
|
||||
clientState: { mutations: [], queries: [] },
|
||||
} as any;
|
||||
|
||||
afterEach(() => {
|
||||
// Always leave persistence enabled so other tests/sessions persist normally.
|
||||
unfreezeOfflinePersistence();
|
||||
h.set.mockClear();
|
||||
});
|
||||
|
||||
it("does NOT write to storage while frozen", async () => {
|
||||
freezeOfflinePersistence();
|
||||
await queryPersister.persistClient(dummyClient);
|
||||
expect(h.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resumes writing to storage once unfrozen", async () => {
|
||||
freezeOfflinePersistence();
|
||||
unfreezeOfflinePersistence();
|
||||
await queryPersister.persistClient(dummyClient);
|
||||
expect(h.set).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
84
apps/client/src/features/offline/query-persister.ts
Normal file
84
apps/client/src/features/offline/query-persister.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
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),
|
||||
};
|
||||
|
||||
const basePersister = createAsyncStoragePersister({
|
||||
storage: idbStorage,
|
||||
key: OFFLINE_CACHE_KEY,
|
||||
throttleTime: 1000,
|
||||
});
|
||||
|
||||
// When frozen, persistClient becomes a no-op so no new dehydrated snapshot is
|
||||
// written to IndexedDB. This closes a logout data-leak race: clearing the cache
|
||||
// (queryClient.clear()) fires `removed` cache events, each of which the persist
|
||||
// subscription turns into a throttled persistClient call. The FIRST such call
|
||||
// dehydrates a still-nearly-full snapshot and its async write can land AFTER the
|
||||
// del() that clears the key, resurrecting the previous user's data (~180KB) in
|
||||
// IndexedDB. Freezing before clear()/del() prevents any such rewrite. Re-enabled
|
||||
// afterwards so the next (sign-in) session persists normally. See
|
||||
// clear-offline-cache.ts.
|
||||
let persistFrozen = false;
|
||||
|
||||
export function freezeOfflinePersistence(): void {
|
||||
persistFrozen = true;
|
||||
}
|
||||
|
||||
export function unfreezeOfflinePersistence(): void {
|
||||
persistFrozen = false;
|
||||
}
|
||||
|
||||
export const queryPersister = {
|
||||
persistClient: (persistedClient: Parameters<typeof basePersister.persistClient>[0]) =>
|
||||
persistFrozen ? Promise.resolve() : basePersister.persistClient(persistedClient),
|
||||
restoreClient: () => basePersister.restoreClient(),
|
||||
removeClient: () => basePersister.removeClient(),
|
||||
};
|
||||
|
||||
// Only navigation/read query roots are persisted for offline reading.
|
||||
// Volatile/auth queries (collab tokens, trash lists) are intentionally excluded.
|
||||
//
|
||||
// `currentUser` IS persisted: UserProvider gates the entire <Layout> subtree on
|
||||
// useCurrentUser(), and offline the POST /api/users/me fails as a no-response
|
||||
// network error. Without the persisted/hydrated user the gate blanked every
|
||||
// authenticated route on an offline cold boot (#237/#238). It is the logged-in
|
||||
// user's own profile (already mirrored to localStorage["currentUser"]), so
|
||||
// persisting it to IndexedDB leaks nothing new while unlocking offline reads.
|
||||
export const OFFLINE_PERSIST_ROOTS = new Set<string>([
|
||||
"pages",
|
||||
"sidebar-pages",
|
||||
"root-sidebar-pages",
|
||||
"breadcrumbs",
|
||||
"comments",
|
||||
"space",
|
||||
"spaces",
|
||||
"recent-changes",
|
||||
"currentUser",
|
||||
]);
|
||||
|
||||
export function shouldDehydrateOfflineQuery(query: DehydratableQuery): boolean {
|
||||
return (
|
||||
query.state.status === "success" &&
|
||||
OFFLINE_PERSIST_ROOTS.has(String(query.queryKey?.[0]))
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
IconList,
|
||||
IconMarkdown,
|
||||
IconPrinter,
|
||||
IconCloud,
|
||||
IconCloudCheck,
|
||||
IconStar,
|
||||
IconStarFilled,
|
||||
IconTrash,
|
||||
@@ -39,6 +41,8 @@ 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";
|
||||
@@ -411,14 +415,16 @@ 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);
|
||||
|
||||
useEffect(() => {
|
||||
const isDisconnected = ["disconnected", "connecting"].includes(
|
||||
yjsConnectionStatus,
|
||||
);
|
||||
const isDisconnected = ["disconnected", "connecting"].includes(
|
||||
yjsConnectionStatus,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDisconnected) {
|
||||
if (!timeoutRef.current) {
|
||||
timeoutRef.current = setTimeout(() => setShowWarning(true), 5000);
|
||||
@@ -430,7 +436,7 @@ function ConnectionWarning() {
|
||||
}
|
||||
setShowWarning(false);
|
||||
}
|
||||
}, [yjsConnectionStatus]);
|
||||
}, [isDisconnected]);
|
||||
|
||||
// Cleanup only on unmount
|
||||
useEffect(() => {
|
||||
@@ -441,22 +447,59 @@ function ConnectionWarning() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!showWarning) return null;
|
||||
// State (1): offline/disconnected — changes are kept locally. Preserve the
|
||||
// existing >5s debounce before surfacing this state.
|
||||
if (isDisconnected) {
|
||||
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={t("Real-time editor connection lost. Retrying...")}
|
||||
openDelay={250}
|
||||
withArrow
|
||||
>
|
||||
<Tooltip label={syncedLabel} openDelay={250} withArrow>
|
||||
<ThemeIcon
|
||||
variant="default"
|
||||
c="red"
|
||||
c="dimmed"
|
||||
role="status"
|
||||
aria-label={t("Real-time editor connection lost. Retrying...")}
|
||||
aria-label={syncedLabel}
|
||||
style={{ border: "none" }}
|
||||
>
|
||||
<IconWifiOff size={20} stroke={2} />
|
||||
<IconCloudCheck size={20} stroke={2} />
|
||||
</ThemeIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
InfiniteData,
|
||||
QueryKey,
|
||||
queryOptions,
|
||||
useInfiniteQuery,
|
||||
UseInfiniteQueryResult,
|
||||
useMutation,
|
||||
@@ -42,12 +43,38 @@ import { treeModel } from "@/features/page/tree/model/tree-model";
|
||||
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";
|
||||
import { offlineMutationKeys } from "@/features/offline/offline-mutations";
|
||||
|
||||
/**
|
||||
* 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: ["pages", pageInput.pageId],
|
||||
queryKey: pageKeys.detail(pageInput.pageId),
|
||||
queryFn: () => getPageById(pageInput),
|
||||
enabled: !!pageInput.pageId,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
@@ -56,9 +83,9 @@ export function usePageQuery(
|
||||
useEffect(() => {
|
||||
if (query.data) {
|
||||
if (isValidUuid(pageInput.pageId)) {
|
||||
queryClient.setQueryData(["pages", query.data.slugId], query.data);
|
||||
queryClient.setQueryData(pageKeys.detail(query.data.slugId), query.data);
|
||||
} else {
|
||||
queryClient.setQueryData(["pages", query.data.id], query.data);
|
||||
queryClient.setQueryData(pageKeys.detail(query.data.id), query.data);
|
||||
}
|
||||
}
|
||||
}, [query.data]);
|
||||
@@ -69,6 +96,10 @@ export function usePageQuery(
|
||||
export function useCreatePageMutation() {
|
||||
const { t } = useTranslation();
|
||||
return useMutation<IPage, Error, Partial<IPageInput>>({
|
||||
// Stable key so a paused create restored from IndexedDB after an offline
|
||||
// reload finds its default mutationFn (registerOfflineMutationDefaults) and
|
||||
// is replayed by resumePausedMutations() on reconnect instead of being lost.
|
||||
mutationKey: offlineMutationKeys.createPage,
|
||||
mutationFn: (data) => createPage(data),
|
||||
onSuccess: (data) => {
|
||||
invalidateOnCreatePage(data);
|
||||
@@ -80,18 +111,20 @@ export function useCreatePageMutation() {
|
||||
}
|
||||
|
||||
export function updatePageData(data: IPage) {
|
||||
const pageBySlug = queryClient.getQueryData<IPage>(["pages", data.slugId]);
|
||||
const pageById = queryClient.getQueryData<IPage>(["pages", data.id]);
|
||||
const pageBySlug = queryClient.getQueryData<IPage>(
|
||||
pageKeys.detail(data.slugId),
|
||||
);
|
||||
const pageById = queryClient.getQueryData<IPage>(pageKeys.detail(data.id));
|
||||
|
||||
if (pageBySlug) {
|
||||
queryClient.setQueryData(["pages", data.slugId], {
|
||||
queryClient.setQueryData(pageKeys.detail(data.slugId), {
|
||||
...pageBySlug,
|
||||
...data,
|
||||
});
|
||||
}
|
||||
|
||||
if (pageById) {
|
||||
queryClient.setQueryData(["pages", data.id], { ...pageById, ...data });
|
||||
queryClient.setQueryData(pageKeys.detail(data.id), { ...pageById, ...data });
|
||||
}
|
||||
|
||||
invalidateOnUpdatePage(
|
||||
@@ -145,11 +178,11 @@ export function useRemovePageMutation() {
|
||||
});
|
||||
|
||||
// Stamp deletedAt so a re-visit shows the trash banner, not stale state.
|
||||
const cached = queryClient.getQueryData<IPage>(["pages", pageId]);
|
||||
const cached = queryClient.getQueryData<IPage>(pageKeys.detail(pageId));
|
||||
if (cached) {
|
||||
const stamped = { ...cached, deletedAt: new Date() };
|
||||
queryClient.setQueryData(["pages", cached.id], stamped);
|
||||
queryClient.setQueryData(["pages", cached.slugId], stamped);
|
||||
queryClient.setQueryData(pageKeys.detail(cached.id), stamped);
|
||||
queryClient.setQueryData(pageKeys.detail(cached.slugId), stamped);
|
||||
}
|
||||
|
||||
invalidateOnDeletePage(pageId);
|
||||
@@ -188,6 +221,9 @@ export function useDeletePageMutation() {
|
||||
|
||||
export function useMovePageMutation() {
|
||||
return useMutation<void, Error, IMovePage>({
|
||||
// Stable key so a paused move restored from IndexedDB after an offline
|
||||
// reload finds its default mutationFn and is replayed on reconnect.
|
||||
mutationKey: offlineMutationKeys.movePage,
|
||||
mutationFn: (data) => movePage(data),
|
||||
});
|
||||
}
|
||||
@@ -267,8 +303,11 @@ 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>(["pages", restoredPage.id], merge);
|
||||
queryClient.setQueryData<IPage>(["pages", restoredPage.slugId], merge);
|
||||
queryClient.setQueryData<IPage>(pageKeys.detail(restoredPage.id), merge);
|
||||
queryClient.setQueryData<IPage>(
|
||||
pageKeys.detail(restoredPage.slugId),
|
||||
merge,
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
notifications.show({
|
||||
@@ -283,7 +322,7 @@ export function useGetSidebarPagesQuery(
|
||||
data: SidebarPagesParams | null,
|
||||
): UseInfiniteQueryResult<InfiniteData<IPagination<IPage>, unknown>> {
|
||||
return useInfiniteQuery({
|
||||
queryKey: ["sidebar-pages", data],
|
||||
queryKey: pageKeys.sidebar(data),
|
||||
enabled: !!data?.pageId || !!data?.spaceId,
|
||||
queryFn: ({ pageParam }) =>
|
||||
getSidebarPages({ ...data, cursor: pageParam, limit: 100 }),
|
||||
@@ -294,7 +333,7 @@ export function useGetSidebarPagesQuery(
|
||||
|
||||
export function useGetRootSidebarPagesQuery(data: SidebarPagesParams) {
|
||||
return useInfiniteQuery({
|
||||
queryKey: ["root-sidebar-pages", data.spaceId],
|
||||
queryKey: pageKeys.rootSidebar(data.spaceId),
|
||||
queryFn: async ({ pageParam }) => {
|
||||
return getSidebarPages({
|
||||
spaceId: data.spaceId,
|
||||
@@ -320,7 +359,7 @@ export function usePageBreadcrumbsQuery(
|
||||
pageId: string,
|
||||
): UseQueryResult<Partial<IPage[]>, Error> {
|
||||
return useQuery({
|
||||
queryKey: ["breadcrumbs", pageId],
|
||||
queryKey: pageKeys.breadcrumbs(pageId),
|
||||
queryFn: () => getPageBreadcrumbs(pageId),
|
||||
enabled: !!pageId,
|
||||
});
|
||||
@@ -332,10 +371,12 @@ 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
|
||||
// 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).
|
||||
const response = await queryClient.fetchQuery({
|
||||
queryKey: ["sidebar-pages", params],
|
||||
queryFn: () => getAllSidebarPages(params),
|
||||
...sidebarPagesQueryOptions(params),
|
||||
staleTime: opts?.fresh ? 0 : 30 * 60 * 1000,
|
||||
});
|
||||
|
||||
@@ -345,7 +386,7 @@ export async function fetchAllAncestorChildren(
|
||||
|
||||
export function useRecentChangesQuery(spaceId?: string) {
|
||||
return useInfiniteQuery({
|
||||
queryKey: ["recent-changes", spaceId],
|
||||
queryKey: pageKeys.recentChanges(spaceId),
|
||||
queryFn: ({ pageParam }) =>
|
||||
getRecentChanges({ spaceId, cursor: pageParam, limit: 15 }),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
@@ -416,12 +457,12 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
|
||||
|
||||
let queryKey: QueryKey = null;
|
||||
if (data.parentPageId === null) {
|
||||
queryKey = ["root-sidebar-pages", data.spaceId];
|
||||
queryKey = pageKeys.rootSidebar(data.spaceId);
|
||||
} else {
|
||||
queryKey = [
|
||||
"sidebar-pages",
|
||||
{ pageId: data.parentPageId, spaceId: data.spaceId },
|
||||
];
|
||||
queryKey = pageKeys.sidebar({
|
||||
pageId: data.parentPageId,
|
||||
spaceId: data.spaceId,
|
||||
});
|
||||
}
|
||||
|
||||
//update all sidebar pages
|
||||
@@ -481,7 +522,7 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
|
||||
|
||||
//update root sidebar pages haschildern
|
||||
const rootSideBarMatches = queryClient.getQueriesData({
|
||||
queryKey: ["root-sidebar-pages", data.spaceId],
|
||||
queryKey: pageKeys.rootSidebar(data.spaceId),
|
||||
exact: false,
|
||||
});
|
||||
|
||||
@@ -505,7 +546,7 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
|
||||
|
||||
//update recent changes
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["recent-changes", data.spaceId],
|
||||
queryKey: pageKeys.recentChanges(data.spaceId),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -519,9 +560,9 @@ export function invalidateOnUpdatePage(
|
||||
invalidatePageTree();
|
||||
let queryKey: QueryKey = null;
|
||||
if (parentPageId === null) {
|
||||
queryKey = ["root-sidebar-pages", spaceId];
|
||||
queryKey = pageKeys.rootSidebar(spaceId);
|
||||
} else {
|
||||
queryKey = ["sidebar-pages", { pageId: parentPageId, spaceId: spaceId }];
|
||||
queryKey = pageKeys.sidebar({ pageId: parentPageId, spaceId: spaceId });
|
||||
}
|
||||
//update all sidebar pages
|
||||
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(
|
||||
@@ -544,7 +585,7 @@ export function invalidateOnUpdatePage(
|
||||
|
||||
//update recent changes
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["recent-changes", spaceId],
|
||||
queryKey: pageKeys.recentChanges(spaceId),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -559,8 +600,8 @@ export function updateCacheOnMovePage(
|
||||
// Remove page from old parent's cache
|
||||
const oldQueryKey =
|
||||
oldParentId === null
|
||||
? ["root-sidebar-pages", spaceId]
|
||||
: ["sidebar-pages", { pageId: oldParentId, spaceId }];
|
||||
? pageKeys.rootSidebar(spaceId)
|
||||
: pageKeys.sidebar({ pageId: oldParentId, spaceId });
|
||||
|
||||
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(
|
||||
oldQueryKey,
|
||||
@@ -580,7 +621,7 @@ export function updateCacheOnMovePage(
|
||||
if (oldParentId !== null) {
|
||||
const oldParentCache = queryClient.getQueryData<
|
||||
InfiniteData<IPagination<IPage>>
|
||||
>(["sidebar-pages", { pageId: oldParentId, spaceId }]);
|
||||
>(pageKeys.sidebar({ pageId: oldParentId, spaceId }));
|
||||
|
||||
const remainingChildren =
|
||||
oldParentCache?.pages.flatMap((p) => p.items).length ?? 0;
|
||||
@@ -618,8 +659,8 @@ export function updateCacheOnMovePage(
|
||||
// Add page to new parent's cache
|
||||
const newQueryKey =
|
||||
newParentId === null
|
||||
? ["root-sidebar-pages", spaceId]
|
||||
: ["sidebar-pages", { pageId: newParentId, spaceId }];
|
||||
? pageKeys.rootSidebar(spaceId)
|
||||
: pageKeys.sidebar({ pageId: newParentId, spaceId });
|
||||
|
||||
queryClient.setQueryData<InfiniteData<IPagination<Partial<IPage>>>>(
|
||||
newQueryKey,
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
|
||||
// --- Mocks for the heavy / networked module graph ---------------------------
|
||||
// NodeMenu pulls in query hooks, page services, websocket emit, i18n,
|
||||
// notifications and three modal children. The F1 "make available offline"
|
||||
// guarantee lives entirely inside handleMakeAvailableOffline, so we mock the
|
||||
// two offline helpers + the collab-token hook + notifications and stub away
|
||||
// everything else so the menu renders in isolation. matchMedia (read by
|
||||
// MantineProvider) is stubbed globally in vitest.setup.ts.
|
||||
|
||||
// vi.mock factories are hoisted above imports, so the shared spies they
|
||||
// reference must be declared with vi.hoisted (hoisted as well).
|
||||
const h = vi.hoisted(() => ({
|
||||
makePageAvailableOffline: vi.fn(),
|
||||
warmPageYdoc: vi.fn(),
|
||||
notificationsShow: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/offline/make-offline", () => ({
|
||||
makePageAvailableOffline: (...args: unknown[]) =>
|
||||
h.makePageAvailableOffline(...args),
|
||||
warmPageYdoc: (...args: unknown[]) => h.warmPageYdoc(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@mantine/notifications", () => ({
|
||||
notifications: { show: (...args: unknown[]) => h.notificationsShow(...args) },
|
||||
}));
|
||||
|
||||
// t is identity so assertions can match the real source strings by key.
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock("react-router-dom", () => ({
|
||||
useParams: () => ({ spaceSlug: "space-slug" }),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/auth/queries/auth-query.tsx", () => ({
|
||||
useCollabToken: () => ({ data: { token: "collab-token" } }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/config.ts", () => ({
|
||||
getCollaborationUrl: () => "wss://collab.example",
|
||||
getAppUrl: () => "https://app.example",
|
||||
}));
|
||||
|
||||
vi.mock("@/features/page/tree/hooks/use-tree-mutation.ts", () => ({
|
||||
useTreeMutation: () => ({ handleDelete: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/websocket/use-query-emit.ts", () => ({
|
||||
useQueryEmit: () => vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/favorite/queries/favorite-query", () => ({
|
||||
useFavoriteIds: () => new Set<string>(),
|
||||
useAddFavoriteMutation: () => ({ mutate: vi.fn() }),
|
||||
useRemoveFavoriteMutation: () => ({ mutate: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/page-embed/queries/page-embed-query", () => ({
|
||||
useToggleTemplateMutation: () => ({ mutateAsync: vi.fn() }),
|
||||
useToggleTemporaryMutation: () => ({ mutateAsync: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/page/services/page-service.ts", () => ({
|
||||
duplicatePage: vi.fn(),
|
||||
}));
|
||||
|
||||
// The modal children drag in export / move / copy stacks we never exercise.
|
||||
vi.mock("@/components/common/export-modal", () => ({ default: () => null }));
|
||||
vi.mock("@/features/page/components/move-page-modal.tsx", () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
vi.mock("@/features/page/components/copy-page-modal.tsx", () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
import { NodeMenu } from "./space-tree-node-menu";
|
||||
import type { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
|
||||
function node(): SpaceTreeNode {
|
||||
return {
|
||||
id: "page-1",
|
||||
slugId: "slug-1",
|
||||
name: "My Page",
|
||||
icon: undefined,
|
||||
position: "a0",
|
||||
spaceId: "space-1",
|
||||
parentPageId: null as unknown as string,
|
||||
hasChildren: false,
|
||||
children: [],
|
||||
};
|
||||
}
|
||||
|
||||
function renderMenu() {
|
||||
render(
|
||||
<MantineProvider>
|
||||
<NodeMenu node={node()} canEdit={true} />
|
||||
</MantineProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
// Open the menu (click the dots target) and click "Make available offline".
|
||||
async function triggerMakeAvailableOffline() {
|
||||
// Before opening, the only button is the menu target ActionIcon.
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
const item = await screen.findByText("Make available offline");
|
||||
fireEvent.click(item);
|
||||
}
|
||||
|
||||
// The handler always fires a leading "Saving page for offline use..." toast and
|
||||
// then the result/error toast — so the LAST show() call is the outcome we pin.
|
||||
function lastShown(): { message?: string; color?: string } {
|
||||
const calls = h.notificationsShow.mock.calls;
|
||||
return calls[calls.length - 1]?.[0] ?? {};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
h.makePageAvailableOffline.mockReset();
|
||||
h.warmPageYdoc.mockReset();
|
||||
h.notificationsShow.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("NodeMenu — make available offline (F1 guarantee)", () => {
|
||||
it("full success: read queries warmed AND ydoc synced → success toast with no error color", async () => {
|
||||
h.makePageAvailableOffline.mockResolvedValue({ ok: true, failed: [] });
|
||||
h.warmPageYdoc.mockResolvedValue(true);
|
||||
|
||||
renderMenu();
|
||||
await triggerMakeAvailableOffline();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastShown().message).toBe("Page is now available offline");
|
||||
});
|
||||
// Success path: no red color (the gate `result.ok && ydocSynced` held).
|
||||
expect(lastShown().color).toBeUndefined();
|
||||
// warmPageYdoc was consulted with the page id, collab url and token.
|
||||
expect(h.warmPageYdoc).toHaveBeenCalledWith(
|
||||
"page-1",
|
||||
"wss://collab.example",
|
||||
"collab-token",
|
||||
);
|
||||
});
|
||||
|
||||
it("ydoc NOT synced: read queries ok but warmPageYdoc=false → RED toast naming 'editor'", async () => {
|
||||
// F1: a page whose editor body never landed in IndexedDB must NOT be
|
||||
// reported as available offline, even though every read query succeeded.
|
||||
h.makePageAvailableOffline.mockResolvedValue({ ok: true, failed: [] });
|
||||
h.warmPageYdoc.mockResolvedValue(false);
|
||||
|
||||
renderMenu();
|
||||
await triggerMakeAvailableOffline();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastShown().color).toBe("red");
|
||||
});
|
||||
expect(lastShown().message).toContain("editor");
|
||||
expect(lastShown().message).not.toBe("Page is now available offline");
|
||||
});
|
||||
|
||||
it("read-query failures: failed=['page','comments'] → RED toast naming the failed steps", async () => {
|
||||
h.makePageAvailableOffline.mockResolvedValue({
|
||||
ok: false,
|
||||
failed: ["page", "comments"],
|
||||
});
|
||||
h.warmPageYdoc.mockResolvedValue(true);
|
||||
|
||||
renderMenu();
|
||||
await triggerMakeAvailableOffline();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastShown().color).toBe("red");
|
||||
});
|
||||
expect(lastShown().message).toContain("page");
|
||||
expect(lastShown().message).toContain("comments");
|
||||
});
|
||||
|
||||
it("thrown error: rejection's response.data.message is extracted into the RED toast", async () => {
|
||||
h.makePageAvailableOffline.mockRejectedValue({
|
||||
response: { data: { message: "boom" } },
|
||||
});
|
||||
h.warmPageYdoc.mockResolvedValue(true);
|
||||
|
||||
renderMenu();
|
||||
await triggerMakeAvailableOffline();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastShown().color).toBe("red");
|
||||
});
|
||||
expect(lastShown().message).toContain("boom");
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import { notifications } from "@mantine/notifications";
|
||||
import {
|
||||
IconArrowRight,
|
||||
IconClockHour4,
|
||||
IconCloudDownload,
|
||||
IconCopy,
|
||||
IconDotsVertical,
|
||||
IconFileExport,
|
||||
@@ -35,6 +36,12 @@ 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 { pageToTreeNode } from "@/features/page/tree/utils";
|
||||
@@ -72,6 +79,57 @@ 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,
|
||||
});
|
||||
// Warm the page's Yjs document into IndexedDB. For a wiki the editor body
|
||||
// IS the page, so this only truly succeeds when the doc actually synced;
|
||||
// a timeout/failure here must NOT be reported as offline-available.
|
||||
const ydocSynced = await warmPageYdoc(
|
||||
node.id,
|
||||
getCollaborationUrl(),
|
||||
collabQuery?.token,
|
||||
);
|
||||
|
||||
// Fold a failed editor warm into the failed-step set so it surfaces in the
|
||||
// same error UI as the read-query failures (the editor body never landed
|
||||
// in IndexedDB, so the page would open blank offline).
|
||||
const failed = ydocSynced ? result.failed : [...result.failed, "editor"];
|
||||
|
||||
if (result.ok && ydocSynced) {
|
||||
notifications.show({ message: t("Page is now available offline") });
|
||||
} else {
|
||||
// Partial warm — the page may still be partly usable offline, but some
|
||||
// queries (or the editor body) failed to cache, so surface it as an
|
||||
// error rather than a silent success. Name the failed step(s) (AGENTS.md:
|
||||
// errors must be specific, never a bare generic string).
|
||||
notifications.show({
|
||||
message: `${t("Failed to make page available offline")}: ${failed.join(", ")}`,
|
||||
color: "red",
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
// makePageAvailableOffline no longer throws, but warmPageYdoc and other
|
||||
// unexpected failures stay guarded here. Log the raw error and surface the
|
||||
// real cause to the user instead of a bare generic string (AGENTS.md).
|
||||
console.error("handleMakeAvailableOffline failed", err);
|
||||
const reason =
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ?? (err instanceof Error ? err.message : String(err));
|
||||
notifications.show({
|
||||
message: `${t("Failed to make page available offline")}: ${reason}`,
|
||||
color: "red",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleTemplate = async () => {
|
||||
const next = !isTemplate;
|
||||
@@ -228,6 +286,17 @@ 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
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
useCreatePageMutation,
|
||||
useRemovePageMutation,
|
||||
useMovePageMutation,
|
||||
useUpdatePageMutation,
|
||||
updateCacheOnMovePage,
|
||||
} from "@/features/page/queries/page-query.ts";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
@@ -27,7 +26,6 @@ export type UseTreeMutation = {
|
||||
parentId: string | null,
|
||||
opts?: { temporary?: boolean },
|
||||
) => Promise<void>;
|
||||
handleRename: (id: string, name: string) => Promise<void>;
|
||||
handleDelete: (id: string) => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -39,7 +37,6 @@ 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();
|
||||
@@ -205,20 +202,6 @@ 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(
|
||||
@@ -264,7 +247,7 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
|
||||
[removePageMutation, setData, store, pageSlug, navigate, spaceSlug],
|
||||
);
|
||||
|
||||
return { handleMove, handleCreate, handleRename, handleDelete };
|
||||
return { handleMove, handleCreate, handleDelete };
|
||||
}
|
||||
|
||||
function isPageInNode(node: SpaceTreeNode, pageSlug: string): boolean {
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { findBreadcrumbPath } from "./utils";
|
||||
import type { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
|
||||
// findBreadcrumbPath walks the live, SHARED sidebar tree. The high-value
|
||||
// invariant: when a node has no usable name it must surface "Untitled" ONLY on
|
||||
// the returned breadcrumb chain via a shallow copy — never by mutating the input
|
||||
// node (which would silently rename the node in the sidebar). Also covers normal
|
||||
// ancestor-chain resolution, the not-found case, and nested children.
|
||||
|
||||
function node(id: string, over: Partial<SpaceTreeNode> = {}): SpaceTreeNode {
|
||||
return {
|
||||
id,
|
||||
slugId: `slug-${id}`,
|
||||
name: id.toUpperCase(),
|
||||
icon: undefined,
|
||||
position: "a0",
|
||||
spaceId: "space-1",
|
||||
parentPageId: null as unknown as string,
|
||||
hasChildren: false,
|
||||
children: [],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("findBreadcrumbPath", () => {
|
||||
it("does NOT mutate the input tree when a node has an empty/whitespace name", () => {
|
||||
// A whitespace-only-named node nested under a blank-named root.
|
||||
const target = node("target", { name: " " });
|
||||
const root = node("root", { name: "", hasChildren: true, children: [target] });
|
||||
const tree = [root];
|
||||
|
||||
const result = findBreadcrumbPath(tree, "target");
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
// The RETURNED chain shows "Untitled" for both blank nodes.
|
||||
expect(result!.map((n) => n.name)).toEqual(["Untitled", "Untitled"]);
|
||||
// The original input nodes are untouched (still blank).
|
||||
expect(root.name).toBe("");
|
||||
expect(target.name).toBe(" ");
|
||||
// The renamed breadcrumb entries are fresh copies, not the input objects.
|
||||
expect(result![0]).not.toBe(root);
|
||||
expect(result![1]).not.toBe(target);
|
||||
});
|
||||
|
||||
it("returns the SAME node reference (no copy) when the name is non-empty", () => {
|
||||
// No rename needed -> the node is passed through by reference (cheap path).
|
||||
const target = node("target", { name: "Real Title" });
|
||||
const result = findBreadcrumbPath([target], "target");
|
||||
expect(result![0]).toBe(target);
|
||||
expect(result![0].name).toBe("Real Title");
|
||||
});
|
||||
|
||||
it("resolves the full ancestor chain ending at the target", () => {
|
||||
const target = node("c");
|
||||
const mid = node("b", { hasChildren: true, children: [target] });
|
||||
const root = node("a", { hasChildren: true, children: [mid] });
|
||||
const result = findBreadcrumbPath([root], "c");
|
||||
expect(result!.map((n) => n.id)).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
it("finds a target nested under a deeper sibling branch", () => {
|
||||
// Two root branches; the target lives inside the second branch's child.
|
||||
const target = node("deep");
|
||||
const branch2 = node("r2", {
|
||||
hasChildren: true,
|
||||
children: [node("x"), node("y", { hasChildren: true, children: [target] })],
|
||||
});
|
||||
const branch1 = node("r1", { hasChildren: true, children: [node("z")] });
|
||||
const result = findBreadcrumbPath([branch1, branch2], "deep");
|
||||
expect(result!.map((n) => n.id)).toEqual(["r2", "y", "deep"]);
|
||||
});
|
||||
|
||||
it("returns null when the page id is not present in the tree", () => {
|
||||
const root = node("root", { hasChildren: true, children: [node("child")] });
|
||||
expect(findBreadcrumbPath([root], "missing")).toBeNull();
|
||||
expect(findBreadcrumbPath([], "anything")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -8,8 +8,6 @@ import {
|
||||
closeIds,
|
||||
mergeRootTrees,
|
||||
loadedOpenBranchIds,
|
||||
sortPositionKeys,
|
||||
pageToTreeNode,
|
||||
} from "./utils";
|
||||
import type { IPage } from "@/features/page/types/page.types.ts";
|
||||
import type { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
@@ -62,82 +60,6 @@ function treeNode(id: string, children: SpaceTreeNode[] = []): SpaceTreeNode {
|
||||
};
|
||||
}
|
||||
|
||||
describe("sortPositionKeys", () => {
|
||||
it("orders items ascending by their fractional `position` string", () => {
|
||||
const items = [
|
||||
{ id: "c", position: "a5" },
|
||||
{ id: "a", position: "a1" },
|
||||
{ id: "b", position: "a3" },
|
||||
];
|
||||
expect(sortPositionKeys(items).map((i) => i.id)).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
it("is a stable sort: equal positions keep their input order", () => {
|
||||
const items = [
|
||||
{ id: "x", position: "a1" },
|
||||
{ id: "y", position: "a1" },
|
||||
{ id: "z", position: "a1" },
|
||||
];
|
||||
expect(sortPositionKeys(items).map((i) => i.id)).toEqual(["x", "y", "z"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pageToTreeNode", () => {
|
||||
function pageRow(over: Partial<IPage> = {}): IPage {
|
||||
return {
|
||||
id: "p1",
|
||||
slugId: "slug-p1",
|
||||
title: "My Page",
|
||||
icon: "📄",
|
||||
position: "a1",
|
||||
hasChildren: true,
|
||||
spaceId: "space-1",
|
||||
parentPageId: null as unknown as string,
|
||||
...over,
|
||||
} as IPage;
|
||||
}
|
||||
|
||||
it("maps page.title -> node.name and copies the core fields", () => {
|
||||
const node = pageToTreeNode(pageRow());
|
||||
// The non-trivial transform: a page's `title` becomes the tree node's `name`.
|
||||
expect(node.name).toBe("My Page");
|
||||
expect(node.id).toBe("p1");
|
||||
expect(node.slugId).toBe("slug-p1");
|
||||
expect(node.icon).toBe("📄");
|
||||
expect(node.position).toBe("a1");
|
||||
expect(node.spaceId).toBe("space-1");
|
||||
expect(node.hasChildren).toBe(true);
|
||||
// Always materialized with an empty children array.
|
||||
expect(node.children).toEqual([]);
|
||||
});
|
||||
|
||||
it("derives canEdit from page.permissions.canEdit when the flat field is absent", () => {
|
||||
const node = pageToTreeNode(
|
||||
pageRow({ canEdit: undefined, permissions: { canEdit: true } } as Partial<IPage>),
|
||||
);
|
||||
expect(node.canEdit).toBe(true);
|
||||
});
|
||||
|
||||
it("prefers the flat page.canEdit over permissions.canEdit", () => {
|
||||
const node = pageToTreeNode(
|
||||
pageRow({ canEdit: false, permissions: { canEdit: true } } as Partial<IPage>),
|
||||
);
|
||||
expect(node.canEdit).toBe(false);
|
||||
});
|
||||
|
||||
it("carries temporaryExpiresAt straight off the page", () => {
|
||||
const expiresAt = "2026-06-27T21:00:00.000Z";
|
||||
expect(pageToTreeNode(pageRow({ temporaryExpiresAt: expiresAt })).temporaryExpiresAt).toBe(
|
||||
expiresAt,
|
||||
);
|
||||
});
|
||||
|
||||
it("applies overrides on top of the mapped fields (e.g. optimistic blank name)", () => {
|
||||
const node = pageToTreeNode(pageRow(), { name: "" });
|
||||
expect(node.name).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildTree", () => {
|
||||
it("builds one node per unique page", () => {
|
||||
const tree = buildTree([page("a", "a1"), page("b", "a2")]);
|
||||
|
||||
@@ -70,22 +70,18 @@ export function findBreadcrumbPath(
|
||||
path: SpaceTreeNode[] = [],
|
||||
): SpaceTreeNode[] | null {
|
||||
for (const node of tree) {
|
||||
// Never mutate the input tree (it is the live, shared sidebar tree state).
|
||||
// When a node has no usable name, surface "Untitled" via a shallow copy that
|
||||
// only the returned breadcrumb chain sees — the source node stays untouched.
|
||||
const displayNode: SpaceTreeNode =
|
||||
!node.name || node.name.trim() === ""
|
||||
? { ...node, name: "Untitled" }
|
||||
: node;
|
||||
if (!node.name || node.name.trim() === "") {
|
||||
node.name = "Untitled";
|
||||
}
|
||||
|
||||
if (node.id === pageId) {
|
||||
return [...path, displayNode];
|
||||
return [...path, node];
|
||||
}
|
||||
|
||||
if (node.children) {
|
||||
const newPath = findBreadcrumbPath(node.children, pageId, [
|
||||
...path,
|
||||
displayNode,
|
||||
node,
|
||||
]);
|
||||
if (newPath) {
|
||||
return newPath;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
keepPreviousData,
|
||||
queryOptions,
|
||||
useInfiniteQuery,
|
||||
useMutation,
|
||||
useQuery,
|
||||
@@ -31,11 +32,37 @@ 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: ["spaces", params],
|
||||
queryKey: spaceKeys.list(params),
|
||||
queryFn: () => getSpaces(params),
|
||||
placeholderData: keepPreviousData,
|
||||
refetchOnMount: true,
|
||||
@@ -44,16 +71,16 @@ export function useGetSpacesQuery(
|
||||
|
||||
export function useSpaceQuery(spaceId: string): UseQueryResult<ISpace, Error> {
|
||||
const query = useQuery({
|
||||
queryKey: ["space", spaceId],
|
||||
queryKey: spaceKeys.detail(spaceId),
|
||||
queryFn: () => getSpaceById(spaceId),
|
||||
enabled: !!spaceId,
|
||||
});
|
||||
useEffect(() => {
|
||||
if (query.data) {
|
||||
if (isValidUuid(spaceId)) {
|
||||
queryClient.setQueryData(["space", query.data.slug], query.data);
|
||||
queryClient.setQueryData(spaceKeys.detail(query.data.slug), query.data);
|
||||
} else {
|
||||
queryClient.setQueryData(["space", query.data.id], query.data);
|
||||
queryClient.setQueryData(spaceKeys.detail(query.data.id), query.data);
|
||||
}
|
||||
}
|
||||
}, [query.data]);
|
||||
@@ -62,8 +89,11 @@ 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: ["space", spaceSlug],
|
||||
queryKey: spaceKeys.detail(spaceSlug),
|
||||
queryFn: () => getSpaceById(spaceSlug),
|
||||
});
|
||||
|
||||
@@ -100,10 +130,8 @@ export function useGetSpaceBySlugQuery(
|
||||
spaceId: string,
|
||||
): UseQueryResult<ISpace, Error> {
|
||||
return useQuery({
|
||||
queryKey: ["space", spaceId],
|
||||
queryFn: () => getSpaceById(spaceId),
|
||||
...spaceByIdQueryOptions(spaceId),
|
||||
enabled: !!spaceId,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -116,14 +144,16 @@ export function useUpdateSpaceMutation() {
|
||||
onSuccess: (data, variables) => {
|
||||
notifications.show({ message: t("Space updated successfully") });
|
||||
|
||||
const space = queryClient.getQueryData([
|
||||
"space",
|
||||
variables.spaceId,
|
||||
]) as ISpace;
|
||||
const space = queryClient.getQueryData(
|
||||
spaceKeys.detail(variables.spaceId),
|
||||
) as ISpace;
|
||||
if (space) {
|
||||
const updatedSpace = { ...space, ...data };
|
||||
queryClient.setQueryData(["space", variables.spaceId], updatedSpace);
|
||||
queryClient.setQueryData(["space", data.slug], updatedSpace);
|
||||
queryClient.setQueryData(
|
||||
spaceKeys.detail(variables.spaceId),
|
||||
updatedSpace,
|
||||
);
|
||||
queryClient.setQueryData(spaceKeys.detail(data.slug), updatedSpace);
|
||||
}
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
@@ -148,7 +178,7 @@ export function useDeleteSpaceMutation() {
|
||||
|
||||
if (variables.slug) {
|
||||
queryClient.removeQueries({
|
||||
queryKey: ["space", variables.slug],
|
||||
queryKey: spaceKeys.detail(variables.slug),
|
||||
exact: true,
|
||||
});
|
||||
}
|
||||
@@ -156,7 +186,7 @@ export function useDeleteSpaceMutation() {
|
||||
// Remove space-specific queries
|
||||
if (variables.id) {
|
||||
queryClient.removeQueries({
|
||||
queryKey: ["space", variables.id],
|
||||
queryKey: spaceKeys.detail(variables.id),
|
||||
exact: true,
|
||||
});
|
||||
|
||||
@@ -196,7 +226,7 @@ export function useSpaceMembersInfiniteQuery(
|
||||
query?: string,
|
||||
) {
|
||||
return useInfiniteQuery({
|
||||
queryKey: ["spaceMembers", spaceId, query],
|
||||
queryKey: spaceKeys.members(spaceId, query),
|
||||
queryFn: ({ pageParam }) =>
|
||||
getSpaceMembers(spaceId, { cursor: pageParam, limit: 50, query }),
|
||||
enabled: !!spaceId,
|
||||
|
||||
@@ -2,9 +2,19 @@ import { useQuery, UseQueryResult } from "@tanstack/react-query";
|
||||
import { getMyInfo } from "@/features/user/services/user-service";
|
||||
import { ICurrentUser } from "@/features/user/types/user.types";
|
||||
|
||||
/**
|
||||
* Centralized React Query key factory for current-user queries. This hook and
|
||||
* the offline warm path (features/offline/make-offline.ts) share it so the
|
||||
* runtime key can never silently drift, and the OFFLINE_PERSIST_ROOTS guard
|
||||
* test can assert the persisted "currentUser" root maps to a real factory.
|
||||
*/
|
||||
export const userKeys = {
|
||||
currentUser: () => ["currentUser"] as const,
|
||||
};
|
||||
|
||||
export default function useCurrentUser(): UseQueryResult<ICurrentUser> {
|
||||
return useQuery({
|
||||
queryKey: ["currentUser"],
|
||||
queryKey: userKeys.currentUser(),
|
||||
queryFn: async () => {
|
||||
return await getMyInfo();
|
||||
},
|
||||
|
||||
118
apps/client/src/features/user/user-provider.test.tsx
Normal file
118
apps/client/src/features/user/user-provider.test.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { HelmetProvider } from "react-helmet-async";
|
||||
|
||||
// Control useCurrentUser per test; stub the rest of UserProvider's network/
|
||||
// socket dependencies so we only exercise its render-gating logic.
|
||||
const h = vi.hoisted(() => ({ useCurrentUser: vi.fn() }));
|
||||
|
||||
vi.mock("@/features/user/hooks/use-current-user", () => ({
|
||||
default: h.useCurrentUser,
|
||||
}));
|
||||
vi.mock("@/features/auth/queries/auth-query.tsx", () => ({
|
||||
useCollabToken: () => ({ data: undefined }),
|
||||
}));
|
||||
vi.mock("@/features/websocket/use-query-subscription.ts", () => ({
|
||||
useQuerySubscription: () => {},
|
||||
}));
|
||||
vi.mock("@/features/websocket/use-tree-socket.ts", () => ({
|
||||
useTreeSocket: () => {},
|
||||
}));
|
||||
vi.mock("@/features/notification/hooks/use-notification-socket.ts", () => ({
|
||||
useNotificationSocket: () => {},
|
||||
}));
|
||||
vi.mock("@/main.tsx", () => ({ queryClient: {} }));
|
||||
vi.mock("@/features/user/connect-resync.ts", () => ({
|
||||
makeConnectHandler: () => () => {},
|
||||
}));
|
||||
vi.mock("socket.io-client", () => ({
|
||||
io: () => ({ on: vi.fn(), disconnect: vi.fn() }),
|
||||
}));
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (k: string) => k,
|
||||
i18n: {
|
||||
changeLanguage: vi.fn(),
|
||||
language: "en-US",
|
||||
resolvedLanguage: "en-US",
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
import { UserProvider } from "./user-provider";
|
||||
|
||||
const networkError = { message: "Network Error" }; // axios network error: no `response`
|
||||
|
||||
function renderProvider() {
|
||||
return render(
|
||||
<HelmetProvider>
|
||||
<MemoryRouter>
|
||||
<MantineProvider>
|
||||
<UserProvider>
|
||||
<div data-testid="app-child">app content</div>
|
||||
</UserProvider>
|
||||
</MantineProvider>
|
||||
</MemoryRouter>
|
||||
</HelmetProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
h.useCurrentUser.mockReset();
|
||||
});
|
||||
|
||||
describe("UserProvider offline render-gating", () => {
|
||||
it("renders the app (cached children) when useCurrentUser errors offline but a cached user exists", () => {
|
||||
// Offline reload: the persisted ['currentUser'] cache hydrates `data`, but
|
||||
// the background POST /api/users/me refetch fails as a network error.
|
||||
h.useCurrentUser.mockReturnValue({
|
||||
data: {
|
||||
user: { id: "u1", locale: "en" },
|
||||
workspace: { id: "w1" },
|
||||
},
|
||||
isLoading: false,
|
||||
error: networkError,
|
||||
isError: true,
|
||||
});
|
||||
|
||||
renderProvider();
|
||||
|
||||
// The cached app must render — NOT a blank fragment (#237/#238).
|
||||
expect(screen.getByTestId("app-child")).toBeDefined();
|
||||
expect(screen.queryByText("You're offline")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the offline fallback (not a blank fragment) when erroring with no cached user", () => {
|
||||
h.useCurrentUser.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
error: networkError,
|
||||
isError: true,
|
||||
});
|
||||
|
||||
const { container } = renderProvider();
|
||||
|
||||
// Previously this returned `<></>` — a blank white screen. Now it must show
|
||||
// an explicit offline fallback.
|
||||
expect(screen.getByText("You're offline")).toBeDefined();
|
||||
expect(screen.queryByTestId("app-child")).toBeNull();
|
||||
expect(container.textContent?.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("renders the app normally on a successful currentUser load", () => {
|
||||
h.useCurrentUser.mockReturnValue({
|
||||
data: {
|
||||
user: { id: "u1", locale: "en" },
|
||||
workspace: { id: "w1" },
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
isError: false,
|
||||
});
|
||||
|
||||
renderProvider();
|
||||
expect(screen.getByTestId("app-child")).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import { useTreeSocket } from "@/features/websocket/use-tree-socket.ts";
|
||||
import { useNotificationSocket } from "@/features/notification/hooks/use-notification-socket.ts";
|
||||
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
||||
import { Error404 } from "@/components/ui/error-404.tsx";
|
||||
import { OfflineFallback } from "@/features/offline/offline-fallback.tsx";
|
||||
import { queryClient } from "@/main.tsx";
|
||||
import { makeConnectHandler } from "@/features/user/connect-resync.ts";
|
||||
|
||||
@@ -70,14 +71,30 @@ export function UserProvider({ children }: React.PropsWithChildren) {
|
||||
document.documentElement.lang = i18n.resolvedLanguage || i18n.language || "en-US";
|
||||
}, [i18n.language, i18n.resolvedLanguage]);
|
||||
|
||||
if (isLoading) return <></>;
|
||||
// First load with no cached user yet: render nothing briefly while the
|
||||
// persisted ['currentUser'] cache hydrates (avoids flashing the offline
|
||||
// fallback before restore). Once we have a user we render the app even if a
|
||||
// refetch is still in flight.
|
||||
if (isLoading && !data) return <></>;
|
||||
|
||||
if (isError && error?.["response"]?.status === 404) {
|
||||
return <Error404 />;
|
||||
}
|
||||
|
||||
// We have a (possibly cached/stale) user — render the app. Offline, the
|
||||
// POST /api/users/me refetch fails as a network error, but the persisted/
|
||||
// hydrated user is enough to render the cached UI. Previously `if (error)
|
||||
// return <></>` blanked every authenticated route on an offline reload even
|
||||
// though the cached data was present (#237/#238).
|
||||
if (data) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// No user AND an error (offline cold boot of a page never warmed for offline,
|
||||
// or no persisted cache to restore): show an explicit offline fallback rather
|
||||
// than a blank white screen.
|
||||
if (error) {
|
||||
return <></>;
|
||||
return <OfflineFallback />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
applyAddTreeNode,
|
||||
applyMoveTreeNode,
|
||||
applyDeleteTreeNode,
|
||||
applyUpdateOne,
|
||||
} from "./tree-socket-reducers";
|
||||
import { treeModel } from "@/features/page/tree/model/tree-model";
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
@@ -339,76 +338,3 @@ describe("applyAddTreeNode", () => {
|
||||
expect(treeModel.find(next, "temp")?.temporaryExpiresAt).toBe(expiresAt);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyUpdateOne", () => {
|
||||
// A loaded two-level tree so we can patch both a root and a nested node.
|
||||
const buildTree = (): SpaceTreeNode[] => [
|
||||
node("root", {
|
||||
position: "a0",
|
||||
name: "Root",
|
||||
icon: "📁",
|
||||
hasChildren: true,
|
||||
children: [node("child", { position: "a1", parentPageId: "root", name: "Child", icon: "📄" })],
|
||||
}),
|
||||
];
|
||||
|
||||
// Build the UpdateEvent envelope; only `id`/`payload` matter to the reducer.
|
||||
const ev = (id: string, payload: Record<string, unknown>) =>
|
||||
({
|
||||
operation: "updateOne",
|
||||
spaceId: "space-1",
|
||||
entity: ["pages"],
|
||||
id,
|
||||
payload,
|
||||
}) as unknown as Parameters<typeof applyUpdateOne>[1];
|
||||
|
||||
it("applies a title-only update to the node's name (icon untouched)", () => {
|
||||
const tree = buildTree();
|
||||
const next = applyUpdateOne(tree, ev("child", { title: "Renamed" }));
|
||||
const child = treeModel.find(next, "child");
|
||||
expect(child?.name).toBe("Renamed");
|
||||
// Icon is left as it was.
|
||||
expect(child?.icon).toBe("📄");
|
||||
});
|
||||
|
||||
it("applies an icon-only update to the node's icon (name untouched)", () => {
|
||||
const tree = buildTree();
|
||||
const next = applyUpdateOne(tree, ev("root", { icon: "🔥" }));
|
||||
const root = treeModel.find(next, "root");
|
||||
expect(root?.icon).toBe("🔥");
|
||||
expect(root?.name).toBe("Root");
|
||||
});
|
||||
|
||||
it("applies a combined title + icon update", () => {
|
||||
const tree = buildTree();
|
||||
const next = applyUpdateOne(tree, ev("child", { title: "Both", icon: "⭐" }));
|
||||
const child = treeModel.find(next, "child");
|
||||
expect(child?.name).toBe("Both");
|
||||
expect(child?.icon).toBe("⭐");
|
||||
});
|
||||
|
||||
it("returns prev UNCHANGED (same reference) when the id is not loaded", () => {
|
||||
const tree = buildTree();
|
||||
const next = applyUpdateOne(tree, ev("ghost", { title: "Nope" }));
|
||||
expect(next).toBe(tree);
|
||||
});
|
||||
|
||||
it("returns prev UNCHANGED (same reference) for a no-op payload (no title/icon)", () => {
|
||||
// The node exists, but the payload carries neither title nor icon -> nothing
|
||||
// to patch, so the reducer must hand back the same array reference.
|
||||
const tree = buildTree();
|
||||
const next = applyUpdateOne(tree, ev("child", {}));
|
||||
expect(next).toBe(tree);
|
||||
});
|
||||
|
||||
it("treats an explicit null icon/title as a value to apply (undefined check, not truthiness)", () => {
|
||||
// The reducer guards on `!== undefined`, so a clearing null IS applied.
|
||||
const tree = buildTree();
|
||||
const next = applyUpdateOne(tree, ev("child", { title: "", icon: null }));
|
||||
const child = treeModel.find(next, "child");
|
||||
expect(child?.name).toBe("");
|
||||
expect(child?.icon).toBeNull();
|
||||
// And it did change something -> a fresh reference, not prev.
|
||||
expect(next).not.toBe(tree);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,7 +11,8 @@ import { MantineProvider } from "@mantine/core";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { ModalsProvider } from "@mantine/modals";
|
||||
import { Notifications } from "@mantine/notifications";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { QueryClient, onlineManager } from "@tanstack/react-query";
|
||||
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
||||
import { HelmetProvider } from "react-helmet-async";
|
||||
import "./i18n";
|
||||
import { PostHogProvider } from "posthog-js/react";
|
||||
@@ -21,6 +22,13 @@ import {
|
||||
isCloud,
|
||||
isPostHogEnabled,
|
||||
} from "@/lib/config.ts";
|
||||
import {
|
||||
queryPersister,
|
||||
shouldDehydrateOfflineQuery,
|
||||
} from "@/features/offline/query-persister";
|
||||
import { registerOfflineMutationDefaults } from "@/features/offline/offline-mutations";
|
||||
import { PwaUpdatePrompt } from "@/pwa/pwa-update-prompt";
|
||||
import { isCapacitorNativePlatform } from "@/pwa/is-capacitor";
|
||||
import posthog from "posthog-js";
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
@@ -30,10 +38,30 @@ 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,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Register default mutationFns for the offline-relevant structural mutations so
|
||||
// a paused mutation restored from IndexedDB after an offline reload still has a
|
||||
// mutationFn and is replayed by resumePausedMutations() on reconnect (instead
|
||||
// of silently no-op'ing and dropping the offline create/move/comment). MUST run
|
||||
// before any resumePausedMutations() so rehydrated paused mutations have a fn.
|
||||
registerOfflineMutationDefaults(queryClient);
|
||||
|
||||
// Seed TanStack Query's onlineManager from the REAL connectivity state at boot.
|
||||
// It defaults to `online: true` and only flips on window online/offline events,
|
||||
// so a tab that COLD-BOOTS offline would wrongly believe it is online: paused
|
||||
// mutations restored from IndexedDB would never get a later offline->online
|
||||
// transition to trigger their replay, and the offline UI affordances could not
|
||||
// tell they are offline. Seeding here makes the first real `online` event a true
|
||||
// transition that auto-resumes the rehydrated paused mutations (#120 data loss).
|
||||
if (typeof navigator !== "undefined" && "onLine" in navigator) {
|
||||
onlineManager.setOnline(navigator.onLine);
|
||||
}
|
||||
|
||||
if (isCloud() && isPostHogEnabled) {
|
||||
posthog.init(getPostHogKey(), {
|
||||
api_host: getPostHogHost(),
|
||||
@@ -50,15 +78,44 @@ root.render(
|
||||
<BrowserRouter>
|
||||
<MantineProvider theme={theme} cssVariablesResolver={mantineCssResolver}>
|
||||
<ModalsProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<PersistQueryClientProvider
|
||||
client={queryClient}
|
||||
persistOptions={{
|
||||
persister: queryPersister,
|
||||
maxAge: 1000 * 60 * 60 * 24,
|
||||
buster: APP_VERSION,
|
||||
dehydrateOptions: {
|
||||
shouldDehydrateQuery: shouldDehydrateOfflineQuery,
|
||||
},
|
||||
}}
|
||||
// After the persister finishes rehydrating, replay any paused
|
||||
// mutations restored from IndexedDB. If we are back online this fires
|
||||
// them immediately; if still offline they stay paused and TanStack's
|
||||
// onlineManager auto-resumes them on the next online transition (which
|
||||
// is now a true transition thanks to the onlineManager seeding above).
|
||||
// Without this, a paused mutation persisted while offline and then
|
||||
// reloaded would never resume and the user's work would be lost (#120).
|
||||
onSuccess={() => {
|
||||
queryClient.resumePausedMutations();
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
</QueryClientProvider>
|
||||
</PersistQueryClientProvider>
|
||||
</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.
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts"
|
||||
import { useTranslation } from "react-i18next";
|
||||
import React from "react";
|
||||
import { EmptyState } from "@/components/ui/empty-state.tsx";
|
||||
import { OfflineFallback } from "@/features/offline/offline-fallback.tsx";
|
||||
import { IconAlertTriangle, IconFileOff } from "@tabler/icons-react";
|
||||
import { Button } from "@mantine/core";
|
||||
import { Link } from "react-router-dom";
|
||||
@@ -62,7 +63,19 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
|
||||
}
|
||||
|
||||
if (isError || !page) {
|
||||
if ([401, 403, 404].includes(error?.["status"])) {
|
||||
// An offline fetch of a page that was never saved for offline use yields a
|
||||
// network error with NO HTTP status (status is undefined), which would
|
||||
// otherwise fall through to the generic "Error fetching page data." state.
|
||||
// When we are offline (or the failure is a network error with no status),
|
||||
// show the dedicated "You're offline — this page isn't saved for offline"
|
||||
// fallback instead, so the user understands why the page won't load.
|
||||
const httpStatus = error?.["status"];
|
||||
const isOffline =
|
||||
typeof navigator !== "undefined" && navigator.onLine === false;
|
||||
if (isOffline || (isError && httpStatus == null)) {
|
||||
return <OfflineFallback />;
|
||||
}
|
||||
if ([401, 403, 404].includes(httpStatus)) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={IconFileOff}
|
||||
|
||||
39
apps/client/src/pwa/is-capacitor.test.ts
Normal file
39
apps/client/src/pwa/is-capacitor.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
23
apps/client/src/pwa/is-capacitor.ts
Normal file
23
apps/client/src/pwa/is-capacitor.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
59
apps/client/src/pwa/pwa-update-prompt.tsx
Normal file
59
apps/client/src/pwa/pwa-update-prompt.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
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;
|
||||
32
apps/client/src/pwa/sw-strategy.test.ts
Normal file
32
apps/client/src/pwa/sw-strategy.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
32
apps/client/src/pwa/sw-strategy.ts
Normal file
32
apps/client/src/pwa/sw-strategy.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
2
apps/client/src/vite-env.d.ts
vendored
2
apps/client/src/vite-env.d.ts
vendored
@@ -1,2 +1,4 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="vite-plugin-pwa/react" />
|
||||
/// <reference types="vite-plugin-pwa/info" />
|
||||
declare const APP_VERSION: string
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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";
|
||||
|
||||
@@ -53,7 +54,55 @@ export default defineConfig(({ mode }) => {
|
||||
},
|
||||
APP_VERSION: JSON.stringify(resolveAppVersion(envPath)),
|
||||
},
|
||||
plugins: [react()],
|
||||
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`, `/l`, 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, the embedded MCP endpoint, and the `l/:alias` vanity short-link
|
||||
// (a server 302 to a share page) are served by server controllers, so
|
||||
// the SW must never shadow them with the precached index.html app shell.
|
||||
// For `/l/:alias` the client router has NO matching route, so serving
|
||||
// the app shell would dead-end on Error404 and break the public link;
|
||||
// it must reach the server to perform the redirect.
|
||||
navigateFallbackDenylist: [
|
||||
/^\/api(\/|$)/,
|
||||
/^\/collab(\/|$)/,
|
||||
/^\/socket\.io(\/|$)/,
|
||||
/^\/share(\/|$)/,
|
||||
/^\/mcp(\/|$)/,
|
||||
/^\/l(\/|$)/,
|
||||
/^\/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 },
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
rolldownOptions: {
|
||||
output: {
|
||||
|
||||
@@ -64,6 +64,7 @@
|
||||
"@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",
|
||||
|
||||
@@ -24,7 +24,10 @@ import { CollabWsAdapter } from './adapter/collab-ws.adapter';
|
||||
import {
|
||||
CollaborationHandler,
|
||||
CollabEventHandlers,
|
||||
writeTitleFragment,
|
||||
} from './collaboration.handler';
|
||||
import { User } from '@docmost/db/types/entity.types';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
@Injectable()
|
||||
export class CollaborationGateway {
|
||||
@@ -149,6 +152,70 @@ 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 {
|
||||
// Write the new title into the in-memory 'title' fragment AND capture the
|
||||
// resulting full doc state so we can persist it directly below.
|
||||
let ydocState: Buffer | null = null;
|
||||
await connection.transact((doc) => {
|
||||
writeTitleFragment(doc, title);
|
||||
ydocState = Buffer.from(Y.encodeStateAsUpdate(doc));
|
||||
});
|
||||
|
||||
// F1 (variant C): persist the 'title' fragment to `page.ydoc` DIRECTLY,
|
||||
// bypassing onStoreDocument. PageService.update already wrote the new title
|
||||
// to the page.title COLUMN before calling this, so onStoreDocument's no-op
|
||||
// fast-path (titleText === column) would NOT persist the in-memory fragment
|
||||
// on disconnect — leaving the stored ydoc with the OLD title, which a later
|
||||
// body edit would then revert the column back to. Writing the ydoc here
|
||||
// makes BOTH column and persisted fragment consistent (NEW = NEW).
|
||||
//
|
||||
// Safe with or without a live editor: the write is idempotent and carries
|
||||
// no tree snapshot (no double broadcast); when an editor is connected, the
|
||||
// normal onStoreDocument flow still persists the (superset) state later and
|
||||
// the live clients receive the title change through the transact above.
|
||||
if (ydocState) {
|
||||
await this.persistenceExtension.persistTitleFragmentYdoc(
|
||||
pageId,
|
||||
ydocState,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await connection.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
*Can be used before calling openDirectConnection directly
|
||||
*/
|
||||
|
||||
155
apps/server/src/collaboration/collaboration.handler.spec.ts
Normal file
155
apps/server/src/collaboration/collaboration.handler.spec.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
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;
|
||||
// F1 (variant C): writePageTitle persists the 'title' fragment directly so a
|
||||
// later body edit can't revert the rename (see title-rename-durability.spec).
|
||||
const persistTitleFragmentYdoc = jest.fn().mockResolvedValue(undefined);
|
||||
gateway.persistenceExtension = { persistTitleFragmentYdoc } as any;
|
||||
|
||||
return {
|
||||
gateway,
|
||||
openDirectConnection,
|
||||
transact,
|
||||
disconnect,
|
||||
persistTitleFragmentYdoc,
|
||||
};
|
||||
};
|
||||
|
||||
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, persistTitleFragmentYdoc } =
|
||||
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');
|
||||
// The renamed fragment is persisted directly to page.ydoc (F1 variant C).
|
||||
expect(persistTitleFragmentYdoc).toHaveBeenCalledWith(
|
||||
'page-1',
|
||||
expect.any(Buffer),
|
||||
);
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Hocuspocus, Document } from '@hocuspocus/server';
|
||||
import { TiptapTransformer } from '@hocuspocus/transformer';
|
||||
import {
|
||||
buildTitleSeedYdoc,
|
||||
prosemirrorNodeToYElement,
|
||||
tiptapExtensions,
|
||||
} from './collaboration.util';
|
||||
@@ -13,6 +14,35 @@ 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);
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import * as Y from 'yjs';
|
||||
import { TiptapTransformer } from '@hocuspocus/transformer';
|
||||
import {
|
||||
getPageId,
|
||||
isEmptyParagraphDoc,
|
||||
jsonToNode,
|
||||
prosemirrorNodeToYElement,
|
||||
buildTitleSeedYdoc,
|
||||
jsonToText,
|
||||
} from './collaboration.util';
|
||||
import { Node } from '@tiptap/pm/model';
|
||||
|
||||
@@ -241,3 +244,43 @@ 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -59,6 +59,7 @@ 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({
|
||||
@@ -143,6 +144,34 @@ 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 {
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
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';
|
||||
|
||||
@@ -0,0 +1,483 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import * as Y from 'yjs';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { TiptapTransformer } from '@hocuspocus/transformer';
|
||||
import {
|
||||
buildTitleSeedYdoc,
|
||||
getPageId,
|
||||
isEmptyParagraphDoc,
|
||||
jsonToText,
|
||||
@@ -116,6 +117,10 @@ 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,
|
||||
@@ -127,33 +132,193 @@ export class PersistenceExtension implements Extension {
|
||||
}
|
||||
|
||||
if (page.ydoc) {
|
||||
this.logger.debug(`ydoc loaded from db: ${pageId}`);
|
||||
|
||||
const doc = new Y.Doc();
|
||||
const dbState = new Uint8Array(page.ydoc);
|
||||
Y.applyUpdate(doc, new Uint8Array(page.ydoc));
|
||||
|
||||
Y.applyUpdate(doc, dbState);
|
||||
return doc;
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
// if no ydoc state in db convert json in page.content to Ydoc.
|
||||
// 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 (page.content) {
|
||||
this.logger.debug(`converting json to ydoc: ${pageId}`);
|
||||
|
||||
const ydoc = TiptapTransformer.toYdoc(
|
||||
page.content,
|
||||
'default',
|
||||
tiptapExtensions,
|
||||
);
|
||||
|
||||
Y.encodeStateAsUpdate(ydoc);
|
||||
return ydoc;
|
||||
// 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(`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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist an already-encoded Y.Doc state directly to `page.ydoc`, mirroring the
|
||||
* `pageRepo.updatePage({ ydoc })` write that onStoreDocument uses.
|
||||
*
|
||||
* Used by the gateway's writePageTitle (F1, variant C). A REST/MCP/agent rename
|
||||
* with no live editor writes the new title into the in-memory 'title' fragment,
|
||||
* but onStoreDocument's no-op fast-path (page.title column already equals the
|
||||
* new title) does NOT persist that in-memory fragment, so the stored `page.ydoc`
|
||||
* keeps the OLD title — and a later body edit then reverts the rename (loads the
|
||||
* OLD fragment, sees it differs from the column, overwrites the column back to
|
||||
* OLD). Writing the ydoc here keeps the persisted fragment consistent with the
|
||||
* column so the rename survives.
|
||||
*
|
||||
* Broadcast-safe / no double broadcast: this carries no `treeUpdate`, so the
|
||||
* tree WS + redis listeners (which gate on `treeUpdate`) do NOT re-broadcast the
|
||||
* rename — only PageService.update's own PAGE_UPDATED does. The only extra
|
||||
* side-effect is an idempotent search reindex.
|
||||
*
|
||||
* Idempotent and lock-free, so it is safe whether or not a live editor is
|
||||
* connected: Yjs state is cumulative, so a concurrent onStoreDocument simply
|
||||
* persists a superset of this state later.
|
||||
*/
|
||||
async persistTitleFragmentYdoc(
|
||||
pageId: string,
|
||||
ydocState: Buffer,
|
||||
): Promise<void> {
|
||||
await this.pageRepo.updatePage({ ydoc: ydocState }, pageId);
|
||||
}
|
||||
|
||||
async onStoreDocument(data: onStoreDocumentPayload) {
|
||||
const { documentName, document, context } = data;
|
||||
|
||||
@@ -171,7 +336,34 @@ 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
|
||||
@@ -205,11 +397,80 @@ export class PersistenceExtension implements Extension {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDeepStrictEqual(tiptapJson, page.content)) {
|
||||
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) {
|
||||
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 || [];
|
||||
@@ -227,29 +488,22 @@ 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,
|
||||
@@ -267,9 +521,27 @@ 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}`);
|
||||
@@ -290,6 +562,8 @@ 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({
|
||||
@@ -307,11 +581,20 @@ 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) {
|
||||
if (page && bodyChanged) {
|
||||
await this.collabHistory.addContributors(pageId, editingUserIds);
|
||||
|
||||
const mentions = extractMentions(tiptapJson);
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ 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: [
|
||||
@@ -54,6 +55,6 @@ import KeyvRedis from '@keyv/redis';
|
||||
? [CollaborationController]
|
||||
: []),
|
||||
],
|
||||
providers: [AppService],
|
||||
providers: [AppService, PageTreeBridgePublisher],
|
||||
})
|
||||
export class CollabAppModule {}
|
||||
|
||||
187
apps/server/src/collaboration/title-rename-durability.spec.ts
Normal file
187
apps/server/src/collaboration/title-rename-durability.spec.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import * as Y from 'yjs';
|
||||
import { TiptapTransformer } from '@hocuspocus/transformer';
|
||||
import { CollaborationGateway } from './collaboration.gateway';
|
||||
import { PersistenceExtension } from './extensions/persistence.extension';
|
||||
import {
|
||||
buildTitleSeedYdoc,
|
||||
jsonToText,
|
||||
tiptapExtensions,
|
||||
} from './collaboration.util';
|
||||
|
||||
/**
|
||||
* F1 (variant C) — rename durability for a page with an already-persisted Yjs
|
||||
* 'title' fragment and NO live editor (the REST/MCP/agent rename path).
|
||||
*
|
||||
* The bug: PageService.update writes the NEW title to the `page.title` COLUMN,
|
||||
* then calls gateway.writePageTitle, which loads the page's ydoc (fragment =
|
||||
* OLD) and overwrites it to NEW in memory. On disconnect, onStoreDocument sees
|
||||
* titleText(NEW) === column(NEW) → no-op fast-path → it does NOT persist the
|
||||
* in-memory fragment. So `page.ydoc` keeps the OLD title, and a LATER body edit
|
||||
* loads the OLD fragment, sees it differs from the column, and silently reverts
|
||||
* the column back to OLD.
|
||||
*
|
||||
* The fix: writePageTitle persists the 'title' fragment to `page.ydoc` DIRECTLY
|
||||
* (via PersistenceExtension.persistTitleFragmentYdoc) after the transact, so the
|
||||
* persisted fragment and the column stay consistent.
|
||||
*
|
||||
* This test drives the REAL writePageTitle + the REAL onStoreDocument against an
|
||||
* in-memory page row, so it FAILS on the pre-fix no-op behaviour and PASSES after.
|
||||
*/
|
||||
|
||||
const PAGE_ID = '550e8400-e29b-41d4-a716-446655440000';
|
||||
const USER_ID = 'user-1';
|
||||
const OLD_TITLE = 'Old Title';
|
||||
const NEW_TITLE = 'Renamed Title';
|
||||
|
||||
const bodyJson = (text: string) => ({
|
||||
type: 'doc',
|
||||
content: [{ type: 'paragraph', content: [{ type: 'text', text }] }],
|
||||
});
|
||||
|
||||
// Build the initial persisted ydoc carrying BOTH a 'title' fragment and a body.
|
||||
const makeInitialYdoc = (title: string, body: any): Buffer => {
|
||||
const doc = new Y.Doc();
|
||||
Y.applyUpdate(doc, Y.encodeStateAsUpdate(buildTitleSeedYdoc(title)));
|
||||
Y.applyUpdate(
|
||||
doc,
|
||||
Y.encodeStateAsUpdate(TiptapTransformer.toYdoc(body, 'default', tiptapExtensions)),
|
||||
);
|
||||
return Buffer.from(Y.encodeStateAsUpdate(doc));
|
||||
};
|
||||
|
||||
// Load a doc from a persisted buffer (mirrors openDirectConnection loading from
|
||||
// persistence when no editor is connected). hocuspocus augments the live doc
|
||||
// with broadcastStateless(); a bare Y.Doc lacks it, so stub it.
|
||||
const loadDoc = (buf: Buffer): Y.Doc => {
|
||||
const doc = new Y.Doc();
|
||||
if (buf) Y.applyUpdate(doc, new Uint8Array(buf));
|
||||
(doc as any).broadcastStateless = jest.fn();
|
||||
return doc;
|
||||
};
|
||||
|
||||
// Read the 'title' fragment text from a persisted buffer.
|
||||
const readTitle = (buf: Buffer): string => {
|
||||
const doc = loadDoc(buf);
|
||||
const titleJson = TiptapTransformer.fromYdoc(doc, 'title');
|
||||
return titleJson ? jsonToText(titleJson).trim() : '';
|
||||
};
|
||||
|
||||
describe('rename durability (F1 variant C): persisted title fragment survives a body edit', () => {
|
||||
it('persists the renamed title into page.ydoc so a later body edit does not revert it', async () => {
|
||||
// In-memory page row = the DB.
|
||||
const row: any = {
|
||||
id: PAGE_ID,
|
||||
slugId: 'slug-1',
|
||||
spaceId: 'space-1',
|
||||
workspaceId: 'ws-1',
|
||||
creatorId: 'creator-1',
|
||||
contributorIds: ['creator-1'],
|
||||
createdAt: new Date('2020-01-01T00:00:00Z'),
|
||||
lastUpdatedSource: 'user',
|
||||
title: OLD_TITLE,
|
||||
// content column mirrors the normalized body in the ydoc.
|
||||
content: TiptapTransformer.fromYdoc(
|
||||
loadDoc(makeInitialYdoc(OLD_TITLE, bodyJson('BODY V1'))),
|
||||
'default',
|
||||
),
|
||||
ydoc: makeInitialYdoc(OLD_TITLE, bodyJson('BODY V1')),
|
||||
};
|
||||
|
||||
const pageRepo = {
|
||||
findById: jest.fn(async () => ({ ...row })),
|
||||
updatePage: jest.fn(async (data: any, _pageId?: string) => {
|
||||
Object.assign(row, data, { updatedAt: new Date() });
|
||||
}),
|
||||
};
|
||||
const pageHistoryRepo = {
|
||||
saveHistory: jest.fn().mockResolvedValue(undefined),
|
||||
findPageLastHistory: jest.fn().mockResolvedValue(null),
|
||||
};
|
||||
const noopQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||
const collabHistory = { addContributors: jest.fn().mockResolvedValue(undefined) };
|
||||
const transclusionService = {
|
||||
syncPageTransclusions: jest.fn().mockResolvedValue(undefined),
|
||||
syncPageReferences: jest.fn().mockResolvedValue(undefined),
|
||||
syncPageTemplateReferences: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
// db whose transaction().execute(fn) runs fn with a trx stub (drives the
|
||||
// real executeTx helper without a database).
|
||||
const db = {
|
||||
transaction: () => ({
|
||||
execute: (fn: (trx: any) => Promise<any>) => fn({ __trx: true }),
|
||||
}),
|
||||
};
|
||||
|
||||
const ext = new PersistenceExtension(
|
||||
pageRepo as any,
|
||||
pageHistoryRepo as any,
|
||||
db as any,
|
||||
noopQueue as any,
|
||||
noopQueue as any,
|
||||
noopQueue as any,
|
||||
collabHistory as any,
|
||||
transclusionService as any,
|
||||
);
|
||||
jest.spyOn(ext['logger'], 'debug').mockImplementation(() => undefined);
|
||||
jest.spyOn(ext['logger'], 'warn').mockImplementation(() => undefined);
|
||||
jest.spyOn(ext['logger'], 'error').mockImplementation(() => undefined);
|
||||
|
||||
const documentName = `page.${PAGE_ID}`;
|
||||
// Fake hocuspocus: openDirectConnection loads a doc from the CURRENT persisted
|
||||
// ydoc (no live editor) and, on disconnect, runs the real onStoreDocument —
|
||||
// exactly the no-live-editor unload path.
|
||||
const fakeHocuspocus = {
|
||||
openDirectConnection: jest.fn(async (name: string, context: any) => {
|
||||
const liveDoc = loadDoc(row.ydoc);
|
||||
return {
|
||||
transact: async (fn: (doc: Y.Doc) => void) => fn(liveDoc),
|
||||
disconnect: async () => {
|
||||
await ext.onStoreDocument({
|
||||
documentName: name,
|
||||
document: liveDoc,
|
||||
context,
|
||||
} as any);
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
const gateway: CollaborationGateway = Object.create(
|
||||
CollaborationGateway.prototype,
|
||||
);
|
||||
(gateway as any).hocuspocus = fakeHocuspocus;
|
||||
(gateway as any).persistenceExtension = ext;
|
||||
|
||||
// --- REST/service rename (no live editor) ---
|
||||
// 1) PageService.update writes the NEW title to the column.
|
||||
await pageRepo.updatePage({ title: NEW_TITLE }, PAGE_ID);
|
||||
// 2) PageService.update syncs the Yjs 'title' fragment.
|
||||
await gateway.writePageTitle(PAGE_ID, NEW_TITLE, {
|
||||
user: { id: USER_ID } as any,
|
||||
});
|
||||
|
||||
// Reload the persisted ydoc: the 'title' fragment must now be NEW.
|
||||
// (Pre-fix this is still OLD — writePageTitle did not persist the fragment.)
|
||||
expect(readTitle(row.ydoc)).toBe(NEW_TITLE);
|
||||
|
||||
// --- a later body edit must NOT revert the title ---
|
||||
const editDoc = loadDoc(row.ydoc);
|
||||
const frag = editDoc.getXmlFragment('default');
|
||||
const p = new Y.XmlElement('paragraph');
|
||||
const t = new Y.XmlText();
|
||||
t.insert(0, 'appended');
|
||||
p.insert(0, [t]);
|
||||
frag.insert(frag.length, [p]);
|
||||
|
||||
await ext.onStoreDocument({
|
||||
documentName,
|
||||
document: editDoc,
|
||||
context: { user: { id: USER_ID } },
|
||||
} as any);
|
||||
|
||||
// The body edit was persisted, and the title stayed NEW in BOTH the column
|
||||
// and the persisted ydoc fragment (pre-fix the column reverts to OLD).
|
||||
expect(row.title).toBe(NEW_TITLE);
|
||||
expect(readTitle(row.ydoc)).toBe(NEW_TITLE);
|
||||
});
|
||||
});
|
||||
@@ -1,278 +0,0 @@
|
||||
import * as Y from 'yjs';
|
||||
import { getSchema } from '@tiptap/core';
|
||||
import {
|
||||
initProseMirrorDoc,
|
||||
absolutePositionToRelativePosition,
|
||||
prosemirrorJSONToYDoc,
|
||||
} from '@tiptap/y-tiptap';
|
||||
import { tiptapExtensions } from './collaboration.util';
|
||||
import {
|
||||
setYjsMark,
|
||||
removeYjsMarkByAttribute,
|
||||
updateYjsMarkAttribute,
|
||||
type YjsSelection,
|
||||
} from './yjs.util';
|
||||
|
||||
/**
|
||||
* Unit tests for the server-side Yjs mark helpers used by the collaboration
|
||||
* handler to set/resolve/delete comment marks directly on the shared Y.Doc
|
||||
* (collaboration.handler.ts: setCommentMark / resolveCommentMark).
|
||||
*
|
||||
* The fragment shape mirrors production exactly: a `default` XmlFragment whose
|
||||
* children are block XmlElements (paragraph) holding XmlText runs. For setYjsMark
|
||||
* the selection is a pair of Yjs RelativePosition JSONs (what the client sends);
|
||||
* we synthesize them from known ProseMirror absolute positions via
|
||||
* absolutePositionToRelativePosition so the marked range is deterministic.
|
||||
*/
|
||||
|
||||
const schema = getSchema(tiptapExtensions);
|
||||
|
||||
// Build a real Y.Doc from ProseMirror JSON (same path the collab handler uses
|
||||
// via TiptapTransformer) and return the doc + its `default` fragment.
|
||||
function buildFromPm(pmJson: unknown) {
|
||||
const ydoc = prosemirrorJSONToYDoc(
|
||||
schema,
|
||||
pmJson as never,
|
||||
'default',
|
||||
) as unknown as Y.Doc;
|
||||
const fragment = ydoc.getXmlFragment('default');
|
||||
return { ydoc, fragment };
|
||||
}
|
||||
|
||||
// Make a YjsSelection (anchor/head RelativePosition JSON) for two ProseMirror
|
||||
// absolute positions in `fragment`.
|
||||
function selectionFor(
|
||||
fragment: Y.XmlFragment,
|
||||
anchorPos: number,
|
||||
headPos: number,
|
||||
): YjsSelection {
|
||||
const { mapping } = initProseMirrorDoc(fragment, schema);
|
||||
const anchor = absolutePositionToRelativePosition(
|
||||
anchorPos,
|
||||
fragment as never,
|
||||
mapping,
|
||||
);
|
||||
const head = absolutePositionToRelativePosition(
|
||||
headPos,
|
||||
fragment as never,
|
||||
mapping,
|
||||
);
|
||||
return {
|
||||
anchor: Y.relativePositionToJSON(anchor),
|
||||
head: Y.relativePositionToJSON(head),
|
||||
};
|
||||
}
|
||||
|
||||
// The XmlText run of the i-th top-level paragraph.
|
||||
function paragraphText(fragment: Y.XmlFragment, index = 0): Y.XmlText {
|
||||
const para = fragment.get(index) as Y.XmlElement;
|
||||
return para.get(0) as Y.XmlText;
|
||||
}
|
||||
|
||||
// --- raw fragment builder for the remove/update tests (no schema needed) ---
|
||||
//
|
||||
// removeYjsMarkByAttribute / updateYjsMarkAttribute only read item.toDelta() and
|
||||
// call item.format(); they never touch the ProseMirror schema. Build the runs
|
||||
// directly so we control which segment carries which comment attrs.
|
||||
function buildWithComments(
|
||||
segments: Array<{
|
||||
text: string;
|
||||
comment?: { commentId: string; resolved: boolean };
|
||||
}>,
|
||||
): { fragment: Y.XmlFragment; text: Y.XmlText } {
|
||||
const ydoc = new Y.Doc();
|
||||
const fragment = ydoc.getXmlFragment('default');
|
||||
const para = new Y.XmlElement('paragraph');
|
||||
fragment.insert(0, [para]);
|
||||
const text = new Y.XmlText();
|
||||
para.insert(0, [text]);
|
||||
let offset = 0;
|
||||
for (const seg of segments) {
|
||||
text.insert(offset, seg.text);
|
||||
if (seg.comment) {
|
||||
text.format(offset, seg.text.length, { comment: seg.comment });
|
||||
}
|
||||
offset += seg.text.length;
|
||||
}
|
||||
return { fragment, text };
|
||||
}
|
||||
|
||||
describe('setYjsMark', () => {
|
||||
it('applies the mark over exactly the selected sub-range (PM pos 1..6 = "Hello")', () => {
|
||||
const { ydoc, fragment } = buildFromPm({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'Hello world' }] },
|
||||
],
|
||||
});
|
||||
// PM pos 1 = start of the paragraph text; pos 6 = just after "Hello".
|
||||
const sel = selectionFor(fragment, 1, 6);
|
||||
|
||||
setYjsMark(ydoc as never, fragment, sel, 'comment', {
|
||||
commentId: 'c1',
|
||||
resolved: false,
|
||||
});
|
||||
|
||||
// The run splits: "Hello" carries the comment mark, " world" stays clean.
|
||||
expect(paragraphText(fragment).toDelta()).toEqual([
|
||||
{
|
||||
insert: 'Hello',
|
||||
attributes: { comment: { commentId: 'c1', resolved: false } },
|
||||
},
|
||||
{ insert: ' world' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('normalizes a reversed selection (head before anchor) to the same range', () => {
|
||||
const { ydoc, fragment } = buildFromPm({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'Hello world' }] },
|
||||
],
|
||||
});
|
||||
// anchor=6, head=1 — reversed; setYjsMark takes min/max so it marks "Hello".
|
||||
const sel = selectionFor(fragment, 6, 1);
|
||||
|
||||
setYjsMark(ydoc as never, fragment, sel, 'comment', {
|
||||
commentId: 'c2',
|
||||
resolved: false,
|
||||
});
|
||||
|
||||
expect(paragraphText(fragment).toDelta()).toEqual([
|
||||
{
|
||||
insert: 'Hello',
|
||||
attributes: { comment: { commentId: 'c2', resolved: false } },
|
||||
},
|
||||
{ insert: ' world' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('marks across two paragraphs (range spans an element boundary)', () => {
|
||||
const { ydoc, fragment } = buildFromPm({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'aaa' }] },
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'bbb' }] },
|
||||
],
|
||||
});
|
||||
// PM positions: "aaa" = 1..4; the </p><p> boundary consumes pos 4 and 5, so
|
||||
// "bbb" starts at pos 6 (chars at 6,7,8). Select pos 2 (inside "aaa") to pos
|
||||
// 8 (after the second "b").
|
||||
const sel = selectionFor(fragment, 2, 8);
|
||||
|
||||
setYjsMark(ydoc as never, fragment, sel, 'comment', {
|
||||
commentId: 'c3',
|
||||
resolved: false,
|
||||
});
|
||||
|
||||
// First paragraph: "a" clean, "aa" marked.
|
||||
expect(paragraphText(fragment, 0).toDelta()).toEqual([
|
||||
{ insert: 'a' },
|
||||
{
|
||||
insert: 'aa',
|
||||
attributes: { comment: { commentId: 'c3', resolved: false } },
|
||||
},
|
||||
]);
|
||||
// Second paragraph: "bb" marked, "b" clean.
|
||||
expect(paragraphText(fragment, 1).toDelta()).toEqual([
|
||||
{
|
||||
insert: 'bb',
|
||||
attributes: { comment: { commentId: 'c3', resolved: false } },
|
||||
},
|
||||
{ insert: 'b' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeYjsMarkByAttribute', () => {
|
||||
it('removes only the run whose attribute value matches, leaving others', () => {
|
||||
const { fragment, text } = buildWithComments([
|
||||
{ text: 'AAA', comment: { commentId: 'c1', resolved: false } },
|
||||
{ text: 'BBB', comment: { commentId: 'c2', resolved: false } },
|
||||
]);
|
||||
|
||||
removeYjsMarkByAttribute(fragment, 'comment', 'commentId', 'c1');
|
||||
|
||||
// c1's run loses the mark; c2's run is untouched.
|
||||
expect(text.toDelta()).toEqual([
|
||||
{ insert: 'AAA' },
|
||||
{
|
||||
insert: 'BBB',
|
||||
attributes: { comment: { commentId: 'c2', resolved: false } },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('does nothing when no run carries the requested value (no-match branch)', () => {
|
||||
const { fragment, text } = buildWithComments([
|
||||
{ text: 'AAA', comment: { commentId: 'c1', resolved: false } },
|
||||
]);
|
||||
const before = text.toDelta();
|
||||
|
||||
removeYjsMarkByAttribute(fragment, 'comment', 'commentId', 'does-not-exist');
|
||||
|
||||
expect(text.toDelta()).toEqual(before);
|
||||
});
|
||||
|
||||
it('leaves a different mark type alone', () => {
|
||||
// A run carrying only `bold` must survive a comment removal pass.
|
||||
const ydoc = new Y.Doc();
|
||||
const fragment = ydoc.getXmlFragment('default');
|
||||
const para = new Y.XmlElement('paragraph');
|
||||
fragment.insert(0, [para]);
|
||||
const text = new Y.XmlText();
|
||||
para.insert(0, [text]);
|
||||
text.insert(0, 'XYZ');
|
||||
text.format(0, 3, { bold: true });
|
||||
|
||||
removeYjsMarkByAttribute(fragment, 'comment', 'commentId', 'c1');
|
||||
|
||||
expect(text.toDelta()).toEqual([
|
||||
{ insert: 'XYZ', attributes: { bold: true } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateYjsMarkAttribute', () => {
|
||||
it('merges new attributes into the matching run, preserving the rest', () => {
|
||||
const { fragment, text } = buildWithComments([
|
||||
{ text: 'AAA', comment: { commentId: 'c1', resolved: false } },
|
||||
{ text: 'BBB', comment: { commentId: 'c2', resolved: false } },
|
||||
]);
|
||||
|
||||
updateYjsMarkAttribute(
|
||||
fragment,
|
||||
'comment',
|
||||
{ name: 'commentId', value: 'c1' },
|
||||
{ resolved: true },
|
||||
);
|
||||
|
||||
// c1's run flips resolved=true (commentId preserved via merge); c2 untouched.
|
||||
expect(text.toDelta()).toEqual([
|
||||
{
|
||||
insert: 'AAA',
|
||||
attributes: { comment: { commentId: 'c1', resolved: true } },
|
||||
},
|
||||
{
|
||||
insert: 'BBB',
|
||||
attributes: { comment: { commentId: 'c2', resolved: false } },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('does nothing when no run matches (no-match branch)', () => {
|
||||
const { fragment, text } = buildWithComments([
|
||||
{ text: 'AAA', comment: { commentId: 'c1', resolved: false } },
|
||||
]);
|
||||
const before = text.toDelta();
|
||||
|
||||
updateYjsMarkAttribute(
|
||||
fragment,
|
||||
'comment',
|
||||
{ name: 'commentId', value: 'nope' },
|
||||
{ resolved: true },
|
||||
);
|
||||
|
||||
expect(text.toDelta()).toEqual(before);
|
||||
});
|
||||
});
|
||||
@@ -1,166 +0,0 @@
|
||||
import { McpClientsService } from './mcp-clients.service';
|
||||
|
||||
/**
|
||||
* Unit tests for the two security-critical surfaces of McpClientsService that the
|
||||
* sibling specs (ssrf-guard / validate-resolved-addresses / lease) do NOT cover:
|
||||
*
|
||||
* 1. `decryptHeaders` (private) — FAIL-OPEN behavior. A decrypt/parse failure
|
||||
* (e.g. APP_SECRET rotated, tampered blob) must NEVER throw and must NEVER
|
||||
* log the blob: it returns `undefined` so the connect proceeds WITHOUT the
|
||||
* now-unreadable auth headers (which then 401s and the server is skipped),
|
||||
* rather than crashing the whole turn.
|
||||
*
|
||||
* 2. `this.guardedFetch` (private, bound to the SSRF-pinned dispatcher) — the
|
||||
* per-request DNS-rebinding guard. A blocked host (private/loopback/metadata
|
||||
* IP literal, or an unparseable URL) must REJECT before any socket is opened;
|
||||
* a public host is allowed through to the real `fetch` with the pinned
|
||||
* dispatcher attached.
|
||||
*
|
||||
* No network and no DB: the repo + secretBox deps are stubbed, and global `fetch`
|
||||
* is mocked for the single allow-path assertion.
|
||||
*/
|
||||
|
||||
// Build the service with a SecretBoxService stub whose decryptSecret is supplied
|
||||
// per-test. The repo dep is unused by the methods under test.
|
||||
function buildService(decryptSecret: (blob: string) => string) {
|
||||
const secretBox = { decryptSecret: jest.fn(decryptSecret) };
|
||||
const service = new McpClientsService({} as never, secretBox as never);
|
||||
return { service, secretBox };
|
||||
}
|
||||
|
||||
describe('McpClientsService.decryptHeaders', () => {
|
||||
// Reach the private method via the as-any pattern common in these NestJS specs.
|
||||
const callDecrypt = (
|
||||
service: McpClientsService,
|
||||
blob: string | null,
|
||||
): Record<string, string> | undefined =>
|
||||
(
|
||||
service as unknown as {
|
||||
decryptHeaders: (b: string | null) => Record<string, string> | undefined;
|
||||
}
|
||||
).decryptHeaders(blob);
|
||||
|
||||
it('returns undefined for a null blob without decrypting', () => {
|
||||
const { service, secretBox } = buildService(() => '{}');
|
||||
expect(callDecrypt(service, null)).toBeUndefined();
|
||||
expect(secretBox.decryptSecret).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('decrypts a valid blob and keeps only string-valued headers', () => {
|
||||
const { service } = buildService(() =>
|
||||
JSON.stringify({
|
||||
Authorization: 'Bearer abc',
|
||||
'X-Api-Key': 'k',
|
||||
// Non-string values must be dropped, not coerced.
|
||||
count: 5,
|
||||
flag: true,
|
||||
nested: { a: 1 },
|
||||
}),
|
||||
);
|
||||
expect(callDecrypt(service, 'cipher')).toEqual({
|
||||
Authorization: 'Bearer abc',
|
||||
'X-Api-Key': 'k',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns undefined when the decrypted object has no string headers', () => {
|
||||
const { service } = buildService(() => JSON.stringify({ count: 5 }));
|
||||
// No usable headers -> undefined (connect with no auth header), not {}.
|
||||
expect(callDecrypt(service, 'cipher')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('FAILS OPEN: a decrypt error returns undefined instead of throwing', () => {
|
||||
const { service } = buildService(() => {
|
||||
throw new Error('Failed to decrypt secret — APP_SECRET may have changed');
|
||||
});
|
||||
const warnSpy = jest
|
||||
.spyOn(
|
||||
(service as unknown as { logger: { warn: (...a: unknown[]) => void } })
|
||||
.logger,
|
||||
'warn',
|
||||
)
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
let result: unknown;
|
||||
expect(() => {
|
||||
result = callDecrypt(service, 'tampered-blob');
|
||||
}).not.toThrow();
|
||||
expect(result).toBeUndefined();
|
||||
// It warns (so ops sees degradation) but never logs the blob itself.
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||
expect(String(warnSpy.mock.calls[0]?.[0])).not.toContain('tampered-blob');
|
||||
});
|
||||
|
||||
it('FAILS OPEN: malformed JSON (decrypts to non-JSON) returns undefined', () => {
|
||||
const { service } = buildService(() => 'not-json{');
|
||||
jest
|
||||
.spyOn(
|
||||
(service as unknown as { logger: { warn: (...a: unknown[]) => void } })
|
||||
.logger,
|
||||
'warn',
|
||||
)
|
||||
.mockImplementation(() => undefined);
|
||||
expect(callDecrypt(service, 'cipher')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('McpClientsService.guardedFetch (SSRF per-request guard)', () => {
|
||||
// The bound guardedFetch closure lives on the instance as a private field.
|
||||
const guardedFetchOf = (service: McpClientsService) =>
|
||||
(service as unknown as { guardedFetch: typeof fetch }).guardedFetch;
|
||||
|
||||
let fetchSpy: jest.SpiedFunction<typeof fetch>;
|
||||
|
||||
beforeEach(() => {
|
||||
// Any reachable real fetch would be a network call; assert per-test that the
|
||||
// blocked paths never reach it, and stub a Response for the allow path.
|
||||
fetchSpy = jest
|
||||
.spyOn(global, 'fetch')
|
||||
.mockResolvedValue(new Response('ok', { status: 200 }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
const blocked: Array<[string, string]> = [
|
||||
['loopback IPv4', 'http://127.0.0.1/mcp'],
|
||||
['private 10/8', 'http://10.0.0.5/mcp'],
|
||||
['private 192.168/16', 'http://192.168.1.1/mcp'],
|
||||
['cloud metadata link-local', 'http://169.254.169.254/latest/meta-data/'],
|
||||
['loopback IPv6 (bracketed)', 'http://[::1]:8080/mcp'],
|
||||
];
|
||||
|
||||
it.each(blocked)(
|
||||
'rejects a request to %s without opening a socket',
|
||||
async (_label, url) => {
|
||||
const { service } = buildService(() => '{}');
|
||||
await expect(guardedFetchOf(service)(url)).rejects.toThrow(
|
||||
/blocked request/,
|
||||
);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects an unparseable URL as a blocked request', async () => {
|
||||
const { service } = buildService(() => '{}');
|
||||
await expect(
|
||||
guardedFetchOf(service)('::: not a url :::'),
|
||||
).rejects.toThrow('blocked request: invalid URL');
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows a public IP literal and forwards through the pinned dispatcher', async () => {
|
||||
const { service } = buildService(() => '{}');
|
||||
const res = await guardedFetchOf(service)('http://8.8.8.8/mcp');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
// The init MUST carry the SSRF-pinned undici dispatcher (the rebinding pin);
|
||||
// dropping it would let undici do a second, unchecked DNS resolution.
|
||||
const init = fetchSpy.mock.calls[0][1] as RequestInit & {
|
||||
dispatcher?: unknown;
|
||||
};
|
||||
expect(init.dispatcher).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -1,124 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
import { AiChatToolsService } from './ai-chat-tools.service';
|
||||
import * as loader from './docmost-client.loader';
|
||||
import type { DocmostClientLike } from './docmost-client.loader';
|
||||
// The real zod-agnostic registry, imported from source so the contract is checked
|
||||
// against exactly what the @docmost/mcp package ships (no hand-stub).
|
||||
import { SHARED_TOOL_SPECS } from '../../../../../../packages/mcp/src/tool-specs';
|
||||
|
||||
/**
|
||||
* CONTRACT: SHARED_TOOL_SPECS <-> in-app tool wiring parity.
|
||||
*
|
||||
* `packages/mcp/src/tool-specs.ts` is the single source of truth for the tools
|
||||
* that are intentionally IDENTICAL across the standalone MCP server (zod v3) and
|
||||
* the in-app AI-SDK service (zod v4). The in-app service builds each one via
|
||||
* `sharedTool(sharedToolSpecs.<key>, execute)`, keyed by the spec's `inAppKey`.
|
||||
*
|
||||
* This test fails the build if a spec is added to the registry but never wired
|
||||
* in-app, if an `inAppKey` is renamed without updating the service, if the
|
||||
* description drifts between the registry and the exposed tool, if the
|
||||
* snake_case `mcpName` <-> camelCase `inAppKey` convention is broken, or if the
|
||||
* exposed tool's input-schema keys diverge from the spec's `buildShape`.
|
||||
*
|
||||
* It does NOT need @docmost/mcp built: the registry is imported from TS source,
|
||||
* and the ESM loader is mocked so `forUser()` never dynamically imports the
|
||||
* package.
|
||||
*/
|
||||
describe('SHARED_TOOL_SPECS contract parity', () => {
|
||||
// Empty fake client: no tool is executed here — every assertion is on tool
|
||||
// presence / metadata / schema, so the client methods are never called.
|
||||
const fakeClient: Partial<DocmostClientLike> = {};
|
||||
const tokenServiceStub = {
|
||||
generateAccessToken: jest.fn().mockResolvedValue('access-token'),
|
||||
generateCollabToken: jest.fn().mockResolvedValue('collab-token'),
|
||||
};
|
||||
|
||||
let tools: Record<string, unknown>;
|
||||
|
||||
beforeAll(async () => {
|
||||
jest.spyOn(loader, 'loadDocmostMcp').mockResolvedValue({
|
||||
DocmostClient: function () {
|
||||
return fakeClient as DocmostClientLike;
|
||||
} as unknown as loader.DocmostClientCtor,
|
||||
// Feed the service the SAME registry this test asserts against.
|
||||
sharedToolSpecs: SHARED_TOOL_SPECS as unknown as Record<
|
||||
string,
|
||||
loader.SharedToolSpec
|
||||
>,
|
||||
});
|
||||
const service = new AiChatToolsService(
|
||||
tokenServiceStub as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{ asSink: () => ({ put: jest.fn(), has: jest.fn(), evict: jest.fn() }) } as never,
|
||||
);
|
||||
tools = (await service.forUser(
|
||||
{ id: 'user-1', email: 'u@example.com', workspaceId: 'ws-1' } as never,
|
||||
'session-1',
|
||||
'ws-1',
|
||||
'chat-1',
|
||||
)) as unknown as Record<string, unknown>;
|
||||
});
|
||||
|
||||
afterAll(() => jest.restoreAllMocks());
|
||||
|
||||
// camelCase -> snake_case, matching the registry's mcpName convention.
|
||||
const toSnake = (s: string) =>
|
||||
s.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
|
||||
|
||||
// Type as the (optional-buildShape) SharedToolSpec; the `satisfies` literal
|
||||
// above otherwise narrows to a union where some members lack buildShape.
|
||||
const specEntries = Object.entries(SHARED_TOOL_SPECS) as Array<
|
||||
[string, loader.SharedToolSpec]
|
||||
>;
|
||||
|
||||
// Sanity: the registry is non-empty, so the per-spec table below is not vacuous.
|
||||
it('registry is non-empty', () => {
|
||||
expect(specEntries.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
describe.each(specEntries)('spec "%s"', (registryKey, spec) => {
|
||||
it('registry key equals its inAppKey', () => {
|
||||
// The service indexes the registry by property name; a key != inAppKey
|
||||
// would wire the wrong (or no) tool.
|
||||
expect(spec.inAppKey).toBe(registryKey);
|
||||
});
|
||||
|
||||
it('mcpName is the snake_case form of inAppKey', () => {
|
||||
expect(spec.mcpName).toBe(toSnake(spec.inAppKey));
|
||||
});
|
||||
|
||||
it('is exposed in-app under its inAppKey', () => {
|
||||
// Fails if a spec is added to the registry but never wired in forUser().
|
||||
expect(tools[spec.inAppKey]).toBeDefined();
|
||||
});
|
||||
|
||||
it("exposed tool's description matches the registry description", () => {
|
||||
const tool = tools[spec.inAppKey] as { description: string };
|
||||
expect(tool.description).toBe(spec.description);
|
||||
});
|
||||
|
||||
it("exposed tool's input-schema keys match buildShape (incl. required)", () => {
|
||||
const tool = tools[spec.inAppKey] as {
|
||||
inputSchema: { jsonSchema: { properties?: Record<string, unknown>; required?: string[] } };
|
||||
};
|
||||
const json = tool.inputSchema.jsonSchema;
|
||||
const actualKeys = Object.keys(json.properties ?? {}).sort();
|
||||
|
||||
// Derive the spec's declared shape with THIS layer's zod (v4) — the same
|
||||
// call the service makes — then compare key sets and required-ness.
|
||||
const shape = spec.buildShape ? spec.buildShape(z) : {};
|
||||
const expectedKeys = Object.keys(shape).sort();
|
||||
expect(actualKeys).toEqual(expectedKeys);
|
||||
|
||||
// A non-.optional() field must surface as required in the advertised schema.
|
||||
const expectedRequired = Object.entries(shape)
|
||||
.filter(([, field]) => !(field as z.ZodTypeAny).isOptional?.())
|
||||
.map(([k]) => k)
|
||||
.sort();
|
||||
expect((json.required ?? []).slice().sort()).toEqual(expectedRequired);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -19,4 +19,87 @@ 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -97,6 +97,12 @@ 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;
|
||||
}
|
||||
}
|
||||
@@ -104,6 +110,12 @@ 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)
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { IsEmail, IsNotEmpty, IsString } from 'class-validator';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsEmail,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
} from 'class-validator';
|
||||
|
||||
export class LoginDto {
|
||||
@IsNotEmpty()
|
||||
@@ -8,4 +14,13 @@ 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;
|
||||
}
|
||||
|
||||
@@ -31,6 +31,102 @@ 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.
|
||||
|
||||
@@ -265,6 +265,8 @@ 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
|
||||
@@ -307,6 +309,43 @@ 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],
|
||||
|
||||
93
apps/server/src/integrations/environment/cors.util.spec.ts
Normal file
93
apps/server/src/integrations/environment/cors.util.spec.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
50
apps/server/src/integrations/environment/cors.util.ts
Normal file
50
apps/server/src/integrations/environment/cors.util.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
// 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);
|
||||
}
|
||||
@@ -5,6 +5,13 @@ 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
|
||||
@@ -15,6 +22,74 @@ describe('EnvironmentService', () => {
|
||||
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', () => {
|
||||
// Case-insensitive: "true" in any casing enables Swagger.
|
||||
it.each(['true', 'TRUE', 'True'])('is true for "%s"', (value) => {
|
||||
expect(
|
||||
makeService({ SWAGGER_ENABLED: value }).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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSandboxTtlMs', () => {
|
||||
// ConfigService stub: get(key, def) returns the configured value for the key
|
||||
// (falling back to def), matching the @nestjs/config contract the service
|
||||
|
||||
@@ -397,4 +397,19 @@ export class EnvironmentService {
|
||||
getSandboxMaxTotalBytes(): number {
|
||||
return this.getPositiveIntEnv('SANDBOX_MAX_TOTAL_BYTES', 134_217_728);
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,110 +1,18 @@
|
||||
import { Readable } from 'stream';
|
||||
import { StorageService } from './storage.service';
|
||||
import type { StorageDriver } from './interfaces';
|
||||
|
||||
/**
|
||||
* StorageService is a thin facade over the injected StorageDriver: each public
|
||||
* method must forward to the driver with the SAME arguments and return/await the
|
||||
* driver's result unchanged (the read paths return it; the write paths await it).
|
||||
* A mock driver lets us assert that delegation exactly, with no real S3/disk IO.
|
||||
*/
|
||||
describe('StorageService delegation', () => {
|
||||
// Every driver method is a jest mock so we can assert call args + return passing.
|
||||
function buildDriver(): jest.Mocked<StorageDriver> {
|
||||
return {
|
||||
upload: jest.fn().mockResolvedValue(undefined),
|
||||
uploadStream: jest.fn().mockResolvedValue(undefined),
|
||||
copy: jest.fn().mockResolvedValue(undefined),
|
||||
read: jest.fn(),
|
||||
readStream: jest.fn(),
|
||||
readRangeStream: jest.fn(),
|
||||
exists: jest.fn(),
|
||||
getUrl: jest.fn(),
|
||||
getSignedUrl: jest.fn(),
|
||||
delete: jest.fn().mockResolvedValue(undefined),
|
||||
getDriver: jest.fn(),
|
||||
getDriverName: jest.fn(),
|
||||
getConfig: jest.fn(),
|
||||
} as unknown as jest.Mocked<StorageDriver>;
|
||||
}
|
||||
|
||||
let driver: jest.Mocked<StorageDriver>;
|
||||
// Direct instantiation with a stub driver. The Test.createTestingModule form
|
||||
// failed to resolve the STORAGE_DRIVER_TOKEN at compile(); this smoke test only
|
||||
// needs the service to construct.
|
||||
describe('StorageService', () => {
|
||||
let service: StorageService;
|
||||
|
||||
beforeEach(() => {
|
||||
driver = buildDriver();
|
||||
service = new StorageService(driver as unknown as StorageDriver);
|
||||
});
|
||||
|
||||
it('upload forwards path + content to the driver', async () => {
|
||||
const buf = Buffer.from('data');
|
||||
await service.upload('a/b.png', buf);
|
||||
expect(driver.upload).toHaveBeenCalledWith('a/b.png', buf);
|
||||
});
|
||||
|
||||
it('uploadStream forwards path, stream and options', async () => {
|
||||
const stream = Readable.from(['x']);
|
||||
await service.uploadStream('a/b.bin', stream, { recreateClient: true });
|
||||
expect(driver.uploadStream).toHaveBeenCalledWith('a/b.bin', stream, {
|
||||
recreateClient: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('copy forwards both paths', async () => {
|
||||
await service.copy('from.txt', 'to.txt');
|
||||
expect(driver.copy).toHaveBeenCalledWith('from.txt', 'to.txt');
|
||||
});
|
||||
|
||||
it('read returns the driver buffer unchanged', async () => {
|
||||
const buf = Buffer.from('content');
|
||||
driver.read.mockResolvedValue(buf);
|
||||
await expect(service.read('f.txt')).resolves.toBe(buf);
|
||||
expect(driver.read).toHaveBeenCalledWith('f.txt');
|
||||
});
|
||||
|
||||
it('readStream returns the driver stream unchanged', async () => {
|
||||
const stream = Readable.from(['y']);
|
||||
driver.readStream.mockResolvedValue(stream);
|
||||
await expect(service.readStream('f.bin')).resolves.toBe(stream);
|
||||
expect(driver.readStream).toHaveBeenCalledWith('f.bin');
|
||||
});
|
||||
|
||||
it('readRangeStream forwards the range object and returns the stream', async () => {
|
||||
const stream = Readable.from(['z']);
|
||||
driver.readRangeStream.mockResolvedValue(stream);
|
||||
const range = { start: 0, end: 99 };
|
||||
await expect(service.readRangeStream('f.bin', range)).resolves.toBe(stream);
|
||||
expect(driver.readRangeStream).toHaveBeenCalledWith('f.bin', range);
|
||||
});
|
||||
|
||||
it('exists returns the driver boolean', async () => {
|
||||
driver.exists.mockResolvedValue(false);
|
||||
await expect(service.exists('missing')).resolves.toBe(false);
|
||||
expect(driver.exists).toHaveBeenCalledWith('missing');
|
||||
});
|
||||
|
||||
it('getSignedUrl forwards path + expiry and returns the signed url', async () => {
|
||||
driver.getSignedUrl.mockResolvedValue('https://signed/url');
|
||||
await expect(service.getSignedUrl('f.png', 600)).resolves.toBe(
|
||||
'https://signed/url',
|
||||
service = new StorageService(
|
||||
{} as any, // storageDriver
|
||||
);
|
||||
expect(driver.getSignedUrl).toHaveBeenCalledWith('f.png', 600);
|
||||
});
|
||||
|
||||
it('getUrl returns the driver url synchronously', () => {
|
||||
driver.getUrl.mockReturnValue('https://cdn/f.png');
|
||||
expect(service.getUrl('f.png')).toBe('https://cdn/f.png');
|
||||
expect(driver.getUrl).toHaveBeenCalledWith('f.png');
|
||||
});
|
||||
|
||||
it('delete forwards the path', async () => {
|
||||
await service.delete('old.txt');
|
||||
expect(driver.delete).toHaveBeenCalledWith('old.txt');
|
||||
});
|
||||
|
||||
it('getDriverName returns the driver name', () => {
|
||||
driver.getDriverName.mockReturnValue('s3');
|
||||
expect(service.getDriverName()).toBe('s3');
|
||||
expect(driver.getDriverName).toHaveBeenCalledTimes(1);
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,11 @@ import { EnvironmentService } from './integrations/environment/environment.servi
|
||||
import { SANDBOX_API_PATH } from './integrations/sandbox/sandbox.constants';
|
||||
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,8 +159,43 @@ async function bootstrap() {
|
||||
}),
|
||||
);
|
||||
|
||||
app.enableCors();
|
||||
// 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.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');
|
||||
|
||||
114
apps/server/src/ws/listeners/page-tree-bridge.subscriber.spec.ts
Normal file
114
apps/server/src/ws/listeners/page-tree-bridge.subscriber.spec.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
115
apps/server/src/ws/listeners/page-tree-bridge.subscriber.ts
Normal file
115
apps/server/src/ws/listeners/page-tree-bridge.subscriber.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
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)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,19 @@ 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],
|
||||
providers: [
|
||||
WsGateway,
|
||||
WsService,
|
||||
WsTreeService,
|
||||
PageWsListener,
|
||||
PageTreeBridgeSubscriber,
|
||||
],
|
||||
exports: [WsGateway, WsService, WsTreeService],
|
||||
})
|
||||
export class WsModule {}
|
||||
|
||||
29
capacitor.config.ts
Normal file
29
capacitor.config.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
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;
|
||||
381
docs/mobile-app-plan.md
Normal file
381
docs/mobile-app-plan.md
Normal file
@@ -0,0 +1,381 @@
|
||||
# Мобильное приложение gitmost — исследование и план
|
||||
|
||||
> Статус: исследовательский + проектный документ.
|
||||
> Контекст: gitmost — форк Docmost, чистое веб-приложение. Отдельного
|
||||
> мобильного (нативного/устанавливаемого) приложения **нет**.
|
||||
> Цель: определить путь к мобильным приложениям — **iOS обязательно, Android
|
||||
> как пойдёт**. Оффлайн-чтение уже реализовано (`apps/client/src/features/offline/`,
|
||||
> этапы M0…M2 — персист TanStack Query в IndexedDB + Yj/`y-indexeddb` тело
|
||||
> документа); полноценная двусторонняя синхронизация (этапы M3…M4) ещё впереди.
|
||||
|
||||
Документ фиксирует, что уже есть в коде, почему путь к мобилке предопределён
|
||||
устройством продукта, сравнивает варианты и описывает рекомендуемый план с
|
||||
привязкой к файлам.
|
||||
|
||||
---
|
||||
|
||||
## 1. TL;DR
|
||||
|
||||
1. **Capacitor-обвязка заведена, собранного нативного приложения ещё нет.** В
|
||||
монорепо добавлены `@capacitor/core|android|ios|cli` и корневой
|
||||
`capacitor.config.ts` (бутстрап этого PR, см. §12). Сгенерированные нативные
|
||||
проекты (`ios/`, `android/`) намеренно не хранятся в VCS, и сборки в App
|
||||
Store / Play ещё нет — это первый шаг к мобильному клиенту, а не готовое
|
||||
приложение.
|
||||
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` + персист TanStack
|
||||
Query в IndexedDB, этапы M0…M2 — см. `apps/client/src/features/offline/`).
|
||||
Полная двусторонняя синхронизация записи (этапы M3…M4) ведётся отдельно;
|
||||
мобильное приложение этот план переиспользует, а не дублирует.
|
||||
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. Capacitor-обвязка заведена (собранного приложения ещё нет)
|
||||
|
||||
Корневой `package.json` содержит `@capacitor/core|android|ios` (и `@capacitor/cli`
|
||||
в devDependencies), в корне лежит [capacitor.config.ts](../capacitor.config.ts).
|
||||
`react-native`, `cordova`, `expo` по-прежнему не используются. Сгенерированные
|
||||
нативные проекты (`ios/`, `android/`) намеренно не коммитятся — это бутстрап
|
||||
оболочки, а не готовый бинарник.
|
||||
|
||||
### 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`); оффлайн-синхронизация
|
||||
ведётся отдельным планом (этапы M0…M4).
|
||||
- Когда упрётесь в 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. Что доработать на бэкенде
|
||||
|
||||
Часть уже сделана бутстрапом этого PR (см. §2.4), осталось нативно-инфраструктурное:
|
||||
|
||||
1. **Выдача токена в теле ответа для нативного хранения — ✅ сделано.** Логин
|
||||
по-прежнему кладёт JWT в `httpOnly`-cookie, а при opt-in флаге `returnToken`
|
||||
дополнительно возвращает токен в теле (`data.authToken`) для нативного хранения
|
||||
в Keychain/Keystore и отправки как `Authorization: Bearer`. Сервер принимал
|
||||
Bearer и раньше; добавлена именно **выдача**.
|
||||
Файлы: [auth.controller.ts](../apps/server/src/core/auth/auth.controller.ts).
|
||||
2. **CORS — ✅ сделано.** Безусловный `app.enableCors()` заменён на явный allowlist:
|
||||
[`buildCorsAllowlist`/`isOriginAllowed`](../apps/server/src/integrations/environment/cors.util.ts)
|
||||
собирают доверенные origin'ы из `CORS_ALLOWED_ORIGINS` плюс нативные
|
||||
WebView-origin'ы; [main.ts](../apps/server/src/main.ts) передаёт их в
|
||||
`app.enableCors({ origin: callback, credentials: true })`.
|
||||
3. **Push-уведомления.** Модуль `notification` уже есть — добавить регистрацию
|
||||
device-token и интеграцию **APNs** (iOS) / **FCM** (Android). (Ещё не сделано.)
|
||||
4. **OpenAPI/Swagger — ✅ сделано (opt-in).** Подключён `@nestjs/swagger`; Swagger
|
||||
UI доступен на `/api/docs` за флагом `SWAGGER_ENABLED` (по умолчанию выключен),
|
||||
что даёт авто-генерацию типизированного клиента.
|
||||
|
||||
---
|
||||
|
||||
## 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. Оффлайн в будущем
|
||||
|
||||
Оффлайн-**чтение** уже реализовано (этапы M0…M2 этого PR), позиция хорошая:
|
||||
|
||||
- Тело документа уже редактируется через Yjs (CRDT) + `y-indexeddb` — локальная
|
||||
копия и автослияние правок работают, в том числе в WebView.
|
||||
- Чтение «вокруг тела» (навигация, заголовки, комментарии, дерево, текущий
|
||||
пользователь) теперь читается оффлайн из персистентного кэша TanStack Query в
|
||||
IndexedDB; см. `apps/client/src/features/offline/` (в т.ч. `make-offline.ts` —
|
||||
ручной прогрев страницы в оффлайн). Полная **двусторонняя** синхронизация
|
||||
записи (этапы M3…M4) ещё впереди.
|
||||
- Мобильное приложение **переиспользует** этот план, а не строит оффлайн заново.
|
||||
Нюанс 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) относительно
|
||||
первого мобильного релиза?
|
||||
- **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`.
|
||||
63
docs/mobile-bootstrap.md
Normal file
63
docs/mobile-bootstrap.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# 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`, `/l`
|
||||
(the vanity short-link `l/:alias`, excluded from the `/api` global prefix and
|
||||
resolved server-side), 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. The offline/sync design (stages M0…M4) is summarized in
|
||||
[mobile-app-plan.md](./mobile-app-plan.md).
|
||||
- **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).
|
||||
11
package.json
11
package.json
@@ -16,10 +16,18 @@
|
||||
"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"
|
||||
"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"
|
||||
},
|
||||
"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",
|
||||
@@ -78,6 +86,7 @@
|
||||
"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",
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { schema } from "@tiptap/pm/schema-basic";
|
||||
import type { Node as PMNode } from "@tiptap/pm/model";
|
||||
import { Transform } from "@tiptap/pm/transform";
|
||||
import { recreateTransform } from "./recreateTransform";
|
||||
|
||||
/**
|
||||
* recreateTransform diffs two documents and produces ProseMirror steps that turn
|
||||
* `fromDoc` into `toDoc`. It is the backbone of collaborative/version diffing, so
|
||||
* THE invariant that matters is: replaying the produced steps on `fromDoc` must
|
||||
* reproduce `toDoc` exactly. Every test below re-applies the steps onto a fresh
|
||||
* Transform seeded from `fromDoc` (not just trusting `tr.doc`) and asserts node
|
||||
* equality with `.eq()`. If a regression makes any step wrong, the round-trip
|
||||
* breaks and the test fails.
|
||||
*/
|
||||
|
||||
// Real ProseMirror schema (the standard basic schema) with paragraph/heading +
|
||||
// strong/em marks — the same primitives the editor diffs in production.
|
||||
const doc = (...c: PMNode[]) => schema.node("doc", null, c);
|
||||
const p = (...c: PMNode[]) =>
|
||||
schema.node("paragraph", null, c.length ? c : undefined);
|
||||
const h = (level: number, ...c: PMNode[]) =>
|
||||
schema.node("heading", { level }, c);
|
||||
const t = (text: string, ...marks: any[]) =>
|
||||
schema.text(text, marks.length ? marks : undefined);
|
||||
const strong = schema.marks.strong.create();
|
||||
const em = schema.marks.em.create();
|
||||
|
||||
// Replay the diff's steps onto a fresh Transform built from `fromDoc`. This is
|
||||
// the faithful "apply(diff) == target" check — it exercises the actual Step
|
||||
// objects rather than the transform's internal accumulated doc.
|
||||
function applyDiff(fromDoc: PMNode, toDoc: PMNode, options?: any): PMNode {
|
||||
const tr = recreateTransform(fromDoc, toDoc, options);
|
||||
const replay = new Transform(fromDoc);
|
||||
tr.steps.forEach((s) => {
|
||||
const result = replay.maybeStep(s);
|
||||
if (result.failed) throw new Error(`step failed: ${result.failed}`);
|
||||
});
|
||||
return replay.doc;
|
||||
}
|
||||
|
||||
describe("recreateTransform round-trip (apply(diff) == target)", () => {
|
||||
it("reconstructs the target on plain text insertion", () => {
|
||||
// Inserting " world" must yield exactly the target paragraph.
|
||||
const from = doc(p(t("hello")));
|
||||
const to = doc(p(t("hello world")));
|
||||
expect(applyDiff(from, to).eq(to)).toBe(true);
|
||||
});
|
||||
|
||||
it("reconstructs the target on text deletion", () => {
|
||||
// Deleting a trailing word is the inverse of insertion and must round-trip.
|
||||
const from = doc(p(t("hello world")));
|
||||
const to = doc(p(t("hello")));
|
||||
expect(applyDiff(from, to).eq(to)).toBe(true);
|
||||
});
|
||||
|
||||
it("reconstructs the target when a word is replaced mid-string", () => {
|
||||
// A char-level replace in the middle must not corrupt the surrounding text.
|
||||
const from = doc(p(t("the quick brown fox")));
|
||||
const to = doc(p(t("the slow brown fox")));
|
||||
expect(applyDiff(from, to).eq(to)).toBe(true);
|
||||
});
|
||||
|
||||
it("reconstructs the target when a mark is added (complexSteps path)", () => {
|
||||
// Mark-only changes are diffed in a separate pass; the bolded run must match.
|
||||
const from = doc(p(t("hello")));
|
||||
const to = doc(p(t("hello", strong)));
|
||||
const out = applyDiff(from, to);
|
||||
expect(out.eq(to)).toBe(true);
|
||||
// Sanity: the produced doc actually carries the strong mark.
|
||||
expect(out.firstChild!.firstChild!.marks.length).toBe(1);
|
||||
});
|
||||
|
||||
it("reconstructs the target when a mark is removed", () => {
|
||||
// Removing the only mark must leave the same text with no marks.
|
||||
const from = doc(p(t("hello", strong)));
|
||||
const to = doc(p(t("hello")));
|
||||
const out = applyDiff(from, to);
|
||||
expect(out.eq(to)).toBe(true);
|
||||
expect(out.firstChild!.firstChild!.marks.length).toBe(0);
|
||||
});
|
||||
|
||||
it("reconstructs the target on a paragraph split into two blocks", () => {
|
||||
// Structural change (one block -> two) must replay as valid replace steps.
|
||||
const from = doc(p(t("hello world")));
|
||||
const to = doc(p(t("hello")), p(t("world")));
|
||||
const out = applyDiff(from, to);
|
||||
expect(out.eq(to)).toBe(true);
|
||||
expect(out.childCount).toBe(2);
|
||||
});
|
||||
|
||||
it("reconstructs the target on a node-type change (paragraph -> heading)", () => {
|
||||
// Type/attrs changes drive the setNodeMarkup branch; the node must become a
|
||||
// heading while keeping its text.
|
||||
const from = doc(p(t("hello")));
|
||||
const to = doc(h(1, t("hello")));
|
||||
const out = applyDiff(from, to);
|
||||
expect(out.eq(to)).toBe(true);
|
||||
expect(out.firstChild!.type.name).toBe("heading");
|
||||
});
|
||||
|
||||
it("reconstructs a combined structural + mark change", () => {
|
||||
// Several diff kinds at once (new block + italic run) still round-trips.
|
||||
const from = doc(p(t("alpha")));
|
||||
const to = doc(p(t("alpha")), p(t("beta", em)));
|
||||
const out = applyDiff(from, to);
|
||||
expect(out.eq(to)).toBe(true);
|
||||
});
|
||||
|
||||
it("produces an empty step list for identical documents", () => {
|
||||
// No diff => no work; spurious steps would mean wasted/incorrect history.
|
||||
const from = doc(p(t("same")));
|
||||
const to = doc(p(t("same")));
|
||||
const tr = recreateTransform(from, to);
|
||||
expect(tr.steps.length).toBe(0);
|
||||
expect(tr.doc.eq(to)).toBe(true);
|
||||
});
|
||||
|
||||
it("round-trips with complexSteps:false (marks diffed as replaces)", () => {
|
||||
// With complexSteps off, mark changes are folded into replace steps rather
|
||||
// than dedicated mark steps — the result must still equal the target.
|
||||
const from = doc(p(t("hello")));
|
||||
const to = doc(p(t("hello", strong)));
|
||||
expect(applyDiff(from, to, { complexSteps: false }).eq(to)).toBe(true);
|
||||
});
|
||||
|
||||
it("round-trips with wordDiffs:true (whole-word text diffing)", () => {
|
||||
// wordDiffs changes the granularity of the text diff, not the outcome.
|
||||
const from = doc(p(t("the quick brown fox")));
|
||||
const to = doc(p(t("the quick red fox")));
|
||||
expect(applyDiff(from, to, { wordDiffs: true }).eq(to)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,108 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { Schema } from "@tiptap/pm/model";
|
||||
import type { Node as PMNode } from "@tiptap/pm/model";
|
||||
import { tableNodes } from "@tiptap/pm/tables";
|
||||
import { EditorState, Selection } from "@tiptap/pm/state";
|
||||
import { getSelectionRangeInColumn } from "./get-selection-range-in-column";
|
||||
|
||||
/**
|
||||
* getSelectionRangeInColumn computes the rectangular column range (the set of
|
||||
* column indexes, plus anchor/head cell positions) that a drag-reorder or
|
||||
* column-select operation should act on, accounting for merged (colspan) cells.
|
||||
* It keys off the table found from the current selection, so we drive it with a
|
||||
* real EditorState whose selection sits inside the table.
|
||||
*/
|
||||
|
||||
// Real ProseMirror table schema (same primitives the editor uses) so TableMap /
|
||||
// cellsInRect behave exactly as in production.
|
||||
const tNodes = tableNodes({
|
||||
tableGroup: "block",
|
||||
cellContent: "inline*",
|
||||
cellAttributes: {},
|
||||
});
|
||||
const schema = new Schema({
|
||||
nodes: {
|
||||
doc: { content: "block+" },
|
||||
paragraph: { group: "block", content: "inline*", toDOM: () => ["p", 0] },
|
||||
text: { group: "inline" },
|
||||
...tNodes,
|
||||
},
|
||||
marks: {},
|
||||
});
|
||||
const cell = (txt: string, attrs?: Record<string, unknown>): PMNode =>
|
||||
schema.nodes.table_cell.createChecked(attrs ?? null, schema.text(txt));
|
||||
const row = (...cells: PMNode[]): PMNode =>
|
||||
schema.nodes.table_row.createChecked(null, cells);
|
||||
const table = (...rows: PMNode[]): PMNode =>
|
||||
schema.nodes.table.createChecked(null, rows);
|
||||
const doc = (...content: PMNode[]): PMNode =>
|
||||
schema.nodes.doc.createChecked(null, content);
|
||||
|
||||
// Build a transaction whose selection is inside the table (the function locates
|
||||
// the table via `tr.selection.$from`).
|
||||
const trFor = (d: PMNode) =>
|
||||
EditorState.create({ doc: d, selection: Selection.atStart(d) }).tr;
|
||||
|
||||
// A 2-row x 3-col grid; each column is identifiable by its top-row letter.
|
||||
const grid3x2 = () =>
|
||||
doc(
|
||||
table(
|
||||
row(cell("a"), cell("b"), cell("c")),
|
||||
row(cell("d"), cell("e"), cell("f")),
|
||||
),
|
||||
);
|
||||
|
||||
describe("getSelectionRangeInColumn", () => {
|
||||
it("returns a single-column range for a single index", () => {
|
||||
// Asking for column 1 yields exactly indexes [1].
|
||||
const tr = trFor(grid3x2());
|
||||
const range = getSelectionRangeInColumn(tr, 1);
|
||||
expect(range).toBeTruthy();
|
||||
expect(range!.indexes).toEqual([1]);
|
||||
});
|
||||
|
||||
it("anchor/head resolve to the top and bottom cells OF the requested column", () => {
|
||||
// $head must point at the column's first (top) cell and $anchor at its last
|
||||
// (bottom) cell — pinning that the returned positions belong to column 1,
|
||||
// not some other column.
|
||||
const tr = trFor(grid3x2());
|
||||
const range = getSelectionRangeInColumn(tr, 1)!;
|
||||
expect(tr.doc.nodeAt(range.$head.pos)?.textContent).toBe("b"); // top of col 1
|
||||
expect(tr.doc.nodeAt(range.$anchor.pos)?.textContent).toBe("e"); // bottom of col 1
|
||||
});
|
||||
|
||||
it("returns the inclusive span of columns for a multi-column request", () => {
|
||||
// A 0..2 request must enumerate every covered column, in order.
|
||||
const tr = trFor(grid3x2());
|
||||
const range = getSelectionRangeInColumn(tr, 0, 2);
|
||||
expect(range!.indexes).toEqual([0, 1, 2]);
|
||||
});
|
||||
|
||||
it("returns a two-column span for an adjacent pair", () => {
|
||||
const tr = trFor(grid3x2());
|
||||
const range = getSelectionRangeInColumn(tr, 1, 2);
|
||||
expect(range!.indexes).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("expands the range to cover a horizontally merged (colspan) cell", () => {
|
||||
// Row 0 col 0 spans 2 columns. Requesting just column 0 must pull column 1
|
||||
// into the range because they are merged together in the top row.
|
||||
const d = doc(
|
||||
table(
|
||||
row(cell("ab", { colspan: 2 }), cell("c")),
|
||||
row(cell("d"), cell("e"), cell("f")),
|
||||
),
|
||||
);
|
||||
const tr = trFor(d);
|
||||
const range = getSelectionRangeInColumn(tr, 0);
|
||||
expect(range!.indexes).toEqual([0, 1]);
|
||||
});
|
||||
|
||||
it("throws when the requested column is entirely out of range", () => {
|
||||
// No cells exist at column 5 of a 3-wide table, so the function cannot pick
|
||||
// an anchor cell and dereferences undefined — pin this as the current
|
||||
// (caller-guarded) contract so a silent behavior change is caught.
|
||||
const tr = trFor(grid3x2());
|
||||
expect(() => getSelectionRangeInColumn(tr, 5)).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,156 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { Schema } from "@tiptap/pm/model";
|
||||
import type { Node as PMNode } from "@tiptap/pm/model";
|
||||
import { tableNodes, CellSelection } from "@tiptap/pm/tables";
|
||||
import { EditorState, Selection } from "@tiptap/pm/state";
|
||||
import { moveColumn } from "./move-column";
|
||||
import { convertTableNodeToArrayOfRows } from "./convert-table-node-to-array-of-rows";
|
||||
import { findTable } from "./query";
|
||||
|
||||
/**
|
||||
* moveColumn reorders whole columns of a real ProseMirror table by mutating a
|
||||
* Transaction (transpose -> move row -> transpose back -> replace). The invariant
|
||||
* is that after the call each column appears at its new position with every
|
||||
* cell's content preserved and nothing dropped or duplicated.
|
||||
*/
|
||||
|
||||
const tNodes = tableNodes({
|
||||
tableGroup: "block",
|
||||
cellContent: "inline*",
|
||||
cellAttributes: {},
|
||||
});
|
||||
const schema = new Schema({
|
||||
nodes: {
|
||||
doc: { content: "block+" },
|
||||
paragraph: { group: "block", content: "inline*", toDOM: () => ["p", 0] },
|
||||
text: { group: "inline" },
|
||||
...tNodes,
|
||||
},
|
||||
marks: {},
|
||||
});
|
||||
const cell = (txt: string): PMNode =>
|
||||
schema.nodes.table_cell.createChecked(null, schema.text(txt));
|
||||
const row = (...cells: PMNode[]): PMNode =>
|
||||
schema.nodes.table_row.createChecked(null, cells);
|
||||
const table = (...rows: PMNode[]): PMNode =>
|
||||
schema.nodes.table.createChecked(null, rows);
|
||||
const doc = (...content: PMNode[]): PMNode =>
|
||||
schema.nodes.doc.createChecked(null, content);
|
||||
|
||||
const grid = (tr: any): string[][] => {
|
||||
const t = findTable(tr.doc.resolve(tr.selection.from))!;
|
||||
return convertTableNodeToArrayOfRows(t.node).map((r) =>
|
||||
r.map((c) => (c ? c.textContent : "")),
|
||||
);
|
||||
};
|
||||
|
||||
// 2-row x 3-col table; column k is (rowX-col-k). Columns: 0=(a,d) 1=(b,e) 2=(c,f).
|
||||
const grid3x2 = () =>
|
||||
doc(
|
||||
table(
|
||||
row(cell("a"), cell("b"), cell("c")),
|
||||
row(cell("d"), cell("e"), cell("f")),
|
||||
),
|
||||
);
|
||||
|
||||
const stateFor = (d: PMNode) =>
|
||||
EditorState.create({ doc: d, selection: Selection.atStart(d) });
|
||||
|
||||
describe("moveColumn", () => {
|
||||
it("moves the first column to the last index, preserving column content", () => {
|
||||
// origin 0 -> target 2 sends column (a,d) to the right: cols become 1,2,0.
|
||||
const state = stateFor(grid3x2());
|
||||
const tr = state.tr;
|
||||
const ok = moveColumn({
|
||||
tr,
|
||||
originIndex: 0,
|
||||
targetIndex: 2,
|
||||
select: false,
|
||||
pos: state.selection.from,
|
||||
});
|
||||
expect(ok).toBe(true);
|
||||
expect(grid(tr)).toEqual([
|
||||
["b", "c", "a"],
|
||||
["e", "f", "d"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("moves a later column to the first index", () => {
|
||||
// origin 2 -> target 0 pulls column (c,f) to the front: cols become 2,0,1.
|
||||
const state = stateFor(grid3x2());
|
||||
const tr = state.tr;
|
||||
const ok = moveColumn({
|
||||
tr,
|
||||
originIndex: 2,
|
||||
targetIndex: 0,
|
||||
select: false,
|
||||
pos: state.selection.from,
|
||||
});
|
||||
expect(ok).toBe(true);
|
||||
expect(grid(tr)).toEqual([
|
||||
["c", "a", "b"],
|
||||
["f", "d", "e"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("never drops or duplicates cells when reordering columns", () => {
|
||||
const state = stateFor(grid3x2());
|
||||
const tr = state.tr;
|
||||
moveColumn({
|
||||
tr,
|
||||
originIndex: 1,
|
||||
targetIndex: 2,
|
||||
select: false,
|
||||
pos: state.selection.from,
|
||||
});
|
||||
expect(grid(tr).flat().sort()).toEqual(
|
||||
["a", "b", "c", "d", "e", "f"].sort(),
|
||||
);
|
||||
expect(grid(tr)[0].length).toBe(3);
|
||||
});
|
||||
|
||||
it("returns false (no-op) when target equals origin", () => {
|
||||
const state = stateFor(grid3x2());
|
||||
const tr = state.tr;
|
||||
const before = grid(tr);
|
||||
const ok = moveColumn({
|
||||
tr,
|
||||
originIndex: 1,
|
||||
targetIndex: 1,
|
||||
select: false,
|
||||
pos: state.selection.from,
|
||||
});
|
||||
expect(ok).toBe(false);
|
||||
expect(grid(tr)).toEqual(before);
|
||||
});
|
||||
|
||||
it("returns false when pos is not inside a table", () => {
|
||||
const d = doc(
|
||||
schema.nodes.paragraph.createChecked(null, schema.text("plain")),
|
||||
);
|
||||
const state = stateFor(d);
|
||||
const tr = state.tr;
|
||||
const ok = moveColumn({
|
||||
tr,
|
||||
originIndex: 0,
|
||||
targetIndex: 1,
|
||||
select: false,
|
||||
pos: state.selection.from,
|
||||
});
|
||||
expect(ok).toBe(false);
|
||||
});
|
||||
|
||||
it("installs a CellSelection on the moved column when select is true", () => {
|
||||
const state = stateFor(grid3x2());
|
||||
const tr = state.tr;
|
||||
const ok = moveColumn({
|
||||
tr,
|
||||
originIndex: 0,
|
||||
targetIndex: 2,
|
||||
select: true,
|
||||
pos: state.selection.from,
|
||||
});
|
||||
expect(ok).toBe(true);
|
||||
expect(tr.selection instanceof CellSelection).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,167 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { Schema } from "@tiptap/pm/model";
|
||||
import type { Node as PMNode } from "@tiptap/pm/model";
|
||||
import { tableNodes, CellSelection } from "@tiptap/pm/tables";
|
||||
import { EditorState, Selection } from "@tiptap/pm/state";
|
||||
import { moveRow } from "./move-row";
|
||||
import { convertTableNodeToArrayOfRows } from "./convert-table-node-to-array-of-rows";
|
||||
import { findTable } from "./query";
|
||||
|
||||
/**
|
||||
* moveRow reorders whole rows of a real ProseMirror table by mutating a
|
||||
* Transaction: it locates the table, computes origin/target row ranges, rebuilds
|
||||
* the table with rows reordered, and replaces it in the doc. The invariant is
|
||||
* that after the call the table's rows appear in the new order with every cell's
|
||||
* content preserved, and no rows are dropped or duplicated.
|
||||
*/
|
||||
|
||||
const tNodes = tableNodes({
|
||||
tableGroup: "block",
|
||||
cellContent: "inline*",
|
||||
cellAttributes: {},
|
||||
});
|
||||
const schema = new Schema({
|
||||
nodes: {
|
||||
doc: { content: "block+" },
|
||||
paragraph: { group: "block", content: "inline*", toDOM: () => ["p", 0] },
|
||||
text: { group: "inline" },
|
||||
...tNodes,
|
||||
},
|
||||
marks: {},
|
||||
});
|
||||
const cell = (txt: string): PMNode =>
|
||||
schema.nodes.table_cell.createChecked(null, schema.text(txt));
|
||||
const row = (...cells: PMNode[]): PMNode =>
|
||||
schema.nodes.table_row.createChecked(null, cells);
|
||||
const table = (...rows: PMNode[]): PMNode =>
|
||||
schema.nodes.table.createChecked(null, rows);
|
||||
const doc = (...content: PMNode[]): PMNode =>
|
||||
schema.nodes.doc.createChecked(null, content);
|
||||
|
||||
// Read the table's content as a grid of cell texts (rows x cols) from whatever
|
||||
// table currently lives in `tr.doc`.
|
||||
const grid = (tr: any): string[][] => {
|
||||
const t = findTable(tr.doc.resolve(tr.selection.from))!;
|
||||
return convertTableNodeToArrayOfRows(t.node).map((r) =>
|
||||
r.map((c) => (c ? c.textContent : "")),
|
||||
);
|
||||
};
|
||||
|
||||
// 3-row x 2-col table; each row identifiable by its cells.
|
||||
const grid2x3 = () =>
|
||||
doc(
|
||||
table(
|
||||
row(cell("r0a"), cell("r0b")),
|
||||
row(cell("r1a"), cell("r1b")),
|
||||
row(cell("r2a"), cell("r2b")),
|
||||
),
|
||||
);
|
||||
|
||||
const stateFor = (d: PMNode) =>
|
||||
EditorState.create({ doc: d, selection: Selection.atStart(d) });
|
||||
|
||||
describe("moveRow", () => {
|
||||
it("moves the first row down to the last index, preserving content", () => {
|
||||
// origin 0 -> target 2 makes row 0 land after the other rows: [r1, r2, r0].
|
||||
const state = stateFor(grid2x3());
|
||||
const tr = state.tr;
|
||||
const ok = moveRow({
|
||||
tr,
|
||||
originIndex: 0,
|
||||
targetIndex: 2,
|
||||
select: false,
|
||||
pos: state.selection.from,
|
||||
});
|
||||
expect(ok).toBe(true);
|
||||
expect(grid(tr)).toEqual([
|
||||
["r1a", "r1b"],
|
||||
["r2a", "r2b"],
|
||||
["r0a", "r0b"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("moves a lower row up to an earlier index", () => {
|
||||
// origin 2 -> target 0 lifts the last row above the rest: [r2, r0, r1].
|
||||
const state = stateFor(grid2x3());
|
||||
const tr = state.tr;
|
||||
const ok = moveRow({
|
||||
tr,
|
||||
originIndex: 2,
|
||||
targetIndex: 0,
|
||||
select: false,
|
||||
pos: state.selection.from,
|
||||
});
|
||||
expect(ok).toBe(true);
|
||||
expect(grid(tr)).toEqual([
|
||||
["r2a", "r2b"],
|
||||
["r0a", "r0b"],
|
||||
["r1a", "r1b"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("never drops or duplicates rows when reordering", () => {
|
||||
// The full multiset of cell texts is invariant under any valid move.
|
||||
const state = stateFor(grid2x3());
|
||||
const tr = state.tr;
|
||||
moveRow({
|
||||
tr,
|
||||
originIndex: 1,
|
||||
targetIndex: 2,
|
||||
select: false,
|
||||
pos: state.selection.from,
|
||||
});
|
||||
const flat = grid(tr).flat().sort();
|
||||
expect(flat).toEqual(
|
||||
["r0a", "r0b", "r1a", "r1b", "r2a", "r2b"].sort(),
|
||||
);
|
||||
expect(grid(tr).length).toBe(3);
|
||||
});
|
||||
|
||||
it("returns false (no-op) when target equals origin", () => {
|
||||
// Moving a row onto itself is rejected and leaves the table unchanged.
|
||||
const state = stateFor(grid2x3());
|
||||
const tr = state.tr;
|
||||
const before = grid(tr);
|
||||
const ok = moveRow({
|
||||
tr,
|
||||
originIndex: 1,
|
||||
targetIndex: 1,
|
||||
select: false,
|
||||
pos: state.selection.from,
|
||||
});
|
||||
expect(ok).toBe(false);
|
||||
expect(grid(tr)).toEqual(before);
|
||||
});
|
||||
|
||||
it("returns false when pos is not inside a table", () => {
|
||||
// Without a table at `pos`, the function bails out instead of throwing.
|
||||
const d = doc(
|
||||
schema.nodes.paragraph.createChecked(null, schema.text("plain")),
|
||||
);
|
||||
const state = stateFor(d);
|
||||
const tr = state.tr;
|
||||
const ok = moveRow({
|
||||
tr,
|
||||
originIndex: 0,
|
||||
targetIndex: 1,
|
||||
select: false,
|
||||
pos: state.selection.from,
|
||||
});
|
||||
expect(ok).toBe(false);
|
||||
});
|
||||
|
||||
it("installs a CellSelection on the moved row when select is true", () => {
|
||||
// With select:true the moved row at the target index is selected.
|
||||
const state = stateFor(grid2x3());
|
||||
const tr = state.tr;
|
||||
const ok = moveRow({
|
||||
tr,
|
||||
originIndex: 0,
|
||||
targetIndex: 2,
|
||||
select: true,
|
||||
pos: state.selection.from,
|
||||
});
|
||||
expect(ok).toBe(true);
|
||||
expect(tr.selection instanceof CellSelection).toBe(true);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user